Confirmed-security quick-wins from a full re-audit (6-agent subsystem fan-out,
Codex-reviewed). Each item ships with a regression test proven to fail pre-fix.
- files: gate file CONTENT reads (download / edit / FileEditor::load) behind the
`operate` ability so a read-only viewer keeps LISTING/browse access but can no
longer pull file bytes over the server credential (root -> /etc/shadow, keys,
.env). Hide the download/edit/delete controls from viewers in the blade, and
basename() Files::open() for parity with the other path builders.
- fail2ban: reject a leading dash/dot in validJail(). A jail name like "-h" or
"--help" was parsed by fail2ban-client as an OPTION (argument-injection) rather
than a positional jail; internal dashes ("nginx-http-auth") still pass.
- bruteforce: release the atomically-claimed ban-audit dedup slot when the
AuditEvent write throws, so a transient DB failure no longer suppresses the
auth.ip_banned audit for the whole 60s window (the ban itself is unaffected).
- update path: verify_update_request now FAILS CLOSED when UPDATE_HMAC_KEY is
unset (was fail-open -> any ./run marker could drive a root update). Pin the
self-update `git pull` to the recorded CLUSEV_BUILD_BRANCH and refuse a diverged
checkout, so a `git checkout` in the tree cannot redirect the next root update.
- wireguard: normalise the DNS input to a single comma list and QUOTE the WG_DNS
assignment in wg.env; an unquoted space previously split the assignment and
broke the next `. "$WG_ENV"`.
13 new regression tests. 614 tests, Pint, shellcheck, Codex review all green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
||
|---|---|---|
| .. | ||
| README.md | ||
| clusev-restart.path | ||
| clusev-restart.service | ||
| clusev-update.path | ||
| clusev-update.service | ||
| watch.sh | ||
README.md
Clusev restart sentinel (host watcher)
One-click stack restart from the dashboard — without giving the container the Docker socket.
How it works
Dashboard (app container) Host
───────────────────────── ────
System → "Jetzt neu starten"
│
▼
DeploymentService::requestRestart()
writes storage/app/restart-signal/restart.request
│ (shared bind mount: ./run on the host)
▼
./run/restart.request appears ──────────► systemd clusev-restart.path
│ (PathExists=)
▼
clusev-restart.service (oneshot)
│ runs watch.sh once
▼
docker compose -f docker-compose.prod.yml up -d
rm ./run/restart.request
The app only ever writes a marker file. A scoped host-side systemd unit does the restart. The file content is a non-sensitive ISO timestamp; the trigger is the file existing, not what it holds.
Files
| File | Role |
|---|---|
watch.sh |
The actual logic: once (systemd) restarts the stack + deletes the sentinel; update runs update.sh (git pull + re-install); loop is a standalone inotify/poll fallback for hosts without systemd (restart only). |
clusev-restart.path |
systemd .path unit — PathExists= the restart sentinel, triggers the restart service. |
clusev-restart.service |
systemd oneshot — runs watch.sh once as the clusev user. |
clusev-update.path |
systemd .path unit — PathExists= the update sentinel (run/update.request), triggers the update service. |
clusev-update.service |
systemd oneshot — runs watch.sh update as root (update.sh → install.sh needs root). |
The update sentinel (dashboard → Version & Releases → "Jetzt aktualisieren") works exactly like the
restart one, but the watcher runs update.sh instead of a plain restart, and consumes the sentinel
before running so a persistent pull failure can't loop. install.sh installs both pairs of units.
Install (systemd — preferred)
From the project root (default path /home/nexxo/clusev; adjust the units if yours differs):
sudo cp docker/restart-sentinel/clusev-restart.path /etc/systemd/system/
sudo cp docker/restart-sentinel/clusev-restart.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now clusev-restart.path
Check it:
systemctl status clusev-restart.path
# Trigger a test restart by hand:
touch ./run/restart.request
journalctl -u clusev-restart.service -f
Both units hard-code /home/nexxo/clusev. If your project lives elsewhere, edit
PathExists= (in .path), and WorkingDirectory= / Environment=CLUSEV_DIR= /
ExecStart= (in .service) before copying. The User= in the service must be able to
run docker compose (root, or a member of the docker group).
Install (no systemd — poll/inotify fallback)
Run the watcher as a long-lived loop (e.g. under your own supervisor, tmux, or nohup):
CLUSEV_DIR=/home/nexxo/clusev docker/restart-sentinel/watch.sh loop
It uses inotifywait if available (apt-get install inotify-tools), otherwise polls
every CLUSEV_POLL seconds (default 5).
Security note
- The container gets NO Docker socket — it cannot run
dockerat all. It only writesstorage/app/restart-signal/restart.requeston a shared bind mount. - The restart is performed by a scoped host systemd unit (
clusev-restart.service), the only component permitted to drive Docker. A panel compromise therefore cannot run arbitrarydockercommands — at worst it can request a restart of the same stack. watch.shonly deletes the sentinel after a successfuldocker compose up -d, so a transient failure is retried on the next trigger rather than silently lost.
Config (watch.sh env)
| Var | Default | Meaning |
|---|---|---|
CLUSEV_DIR |
repo root (resolved from the script path) | dir holding docker-compose.prod.yml |
CLUSEV_SIGNAL |
$CLUSEV_DIR/run/restart.request |
absolute path of the sentinel file |
CLUSEV_POLL |
5 |
poll interval (seconds) for the no-inotify loop fallback |