feat(system): auto-restart sentinel — one-click restart via host watcher (no docker socket)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat/v1-foundation
boban 2026-06-14 23:42:50 +02:00
parent 73eecd03e0
commit 3399a5017a
12 changed files with 445 additions and 5 deletions

View File

@ -40,6 +40,9 @@ class Index extends Component
/** True after a save — shows the "restart the stack to apply" notice. */
public bool $restartPending = false;
/** True once the operator clicked "restart now" — the host watcher is applying it. */
public bool $restartRequested = false;
public string $channel = 'stable';
public string $tlsMode = 'caddy';
@ -60,6 +63,7 @@ class Index extends Component
$this->domain = (string) ($deployment->domain() ?? '');
$this->domainInput = (string) ($deployment->configuredDomain() ?? '');
$this->restartPending = $deployment->restartPending();
$this->restartRequested = $deployment->restartRequested();
$channel = Setting::get('release_channel', config('clusev.channel')) ?? 'stable';
// Clamp to a valid user channel — a stale/legacy value (e.g. 'dev') falls back to stable.
$this->channel = in_array($channel, self::CHANNELS, true) ? $channel : 'stable';
@ -123,6 +127,19 @@ class Index extends Component
$this->dispatch('notify', message: __('system.domain_saved_notify'));
}
/**
* One-click restart: write the sentinel and let the HOST watcher restart the stack.
* The container never touches the Docker socket it only requests via a marker file
* the scoped systemd .path unit reacts to (see docker/restart-sentinel/). Kept explicit
* (a button, not auto-fired on save) so the operator controls the timing.
*/
public function restartNow(DeploymentService $deployment): void
{
$deployment->requestRestart();
$this->restartRequested = true;
$this->dispatch('notify', message: __('system.restart_running'));
}
/** Release channel changes are audited (confirmation via the shared modal). */
public function confirmChannel(string $channel): void
{

View File

@ -33,6 +33,16 @@ class DeploymentService
/** Snapshot of the active domain, written at container start (relative to storage/). */
private const ACTIVE_FILE = 'framework/active-domain';
/**
* Restart sentinel a marker file the dashboard writes to REQUEST a stack restart
* (relative to storage/). It lives in its own bind-mounted directory (storage/app/
* restart-signal, mapped to ./run on the host in docker-compose.prod.yml) so a
* host-side watcher (a scoped systemd .path unit see docker/restart-sentinel/)
* sees it appear and runs `docker compose restart`, then deletes it. The container
* NEVER gets the Docker socket; it only writes a non-sensitive marker file.
*/
private const RESTART_FILE = 'app/restart-signal/restart.request';
/**
* The PENDING domain: dashboard override (Setting `panel_domain`) if a row exists
* (empty row = explicit bare IP), otherwise the install-time APP_DOMAIN. This is
@ -142,6 +152,46 @@ class DeploymentService
return $this->configuredDomain() !== $this->domain();
}
/** Absolute path of the restart sentinel on the shared (bind-mounted) volume. */
public function restartSignalPath(): string
{
return storage_path(self::RESTART_FILE);
}
/**
* Request a stack restart by writing the sentinel file. A host-side watcher (a scoped
* systemd .path unit, see docker/restart-sentinel/) reacts to its appearance and runs
* `docker compose restart`, then removes it. The content is a non-sensitive marker
* (an ISO timestamp) the trigger is the file existing, not what it holds. Never throws.
*/
public function requestRestart(): void
{
$path = $this->restartSignalPath();
$dir = dirname($path);
if (! is_dir($dir)) {
@mkdir($dir, 0775, true);
}
@file_put_contents($path, now()->toIso8601String().PHP_EOL);
}
/** True while a restart request is pending (the host watcher clears it once handled). */
public function restartRequested(): bool
{
return is_file($this->restartSignalPath());
}
/** Remove the sentinel (used by tests / a manual reset; the host watcher deletes it normally). */
public function clearRestartRequest(): void
{
$path = $this->restartSignalPath();
if (is_file($path)) {
@unlink($path);
}
}
/** True when the panel domain is a dashboard override (a panel_domain row exists). */
public function domainIsOverridden(): bool
{

View File

@ -33,6 +33,13 @@ services:
target: prod
expose:
- "80"
volumes:
# Restart sentinel — the dashboard writes ./run/restart.request here (inside the
# container: storage/app/restart-signal/) to REQUEST a stack restart. A host-side
# watcher (a scoped systemd .path unit — see docker/restart-sentinel/) sees the file
# appear, runs `docker compose restart`, then deletes it. The container gets NO Docker
# socket; it only writes a non-sensitive marker file on this shared bind mount.
- ./run:/var/www/html/storage/app/restart-signal
depends_on:
mariadb:
condition: service_healthy

View File

@ -0,0 +1,92 @@
# 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; `loop` is a standalone inotify/poll fallback for hosts without systemd. |
| `clusev-restart.path` | systemd `.path` unit — `PathExists=` the sentinel, triggers the service. |
| `clusev-restart.service` | systemd oneshot — runs `watch.sh once`. |
## Install (systemd — preferred)
From the project root (default path `/home/nexxo/clusev`; adjust the units if yours differs):
```bash
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:
```bash
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`):
```bash
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 `docker` at all. It only writes
`storage/app/restart-signal/restart.request` on 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
arbitrary `docker` commands — at worst it can request a restart of the same stack.
- `watch.sh` only deletes the sentinel **after a successful** `docker 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 |

View File

@ -0,0 +1,24 @@
# Clusev restart-sentinel — path unit (HOST-side).
#
# Watches for the restart sentinel the dashboard writes and triggers
# clusev-restart.service the moment it appears. Install both this unit and
# clusev-restart.service into /etc/systemd/system/, then:
#
# systemctl daemon-reload
# systemctl enable --now clusev-restart.path
#
# Adjust PathExists= below if your project lives somewhere other than
# /home/nexxo/clusev (it must match the ./run bind mount in docker-compose.prod.yml).
[Unit]
Description=Clusev restart sentinel — watch for a dashboard restart request
After=docker.service
Wants=docker.service
[Path]
# Fires the .service whenever this file exists. systemd re-arms after the service
# runs; the service deletes the sentinel, so each request triggers exactly once.
PathExists=/home/nexxo/clusev/run/restart.request
Unit=clusev-restart.service
[Install]
WantedBy=multi-user.target

View File

@ -0,0 +1,26 @@
# Clusev restart-sentinel — service unit (HOST-side).
#
# Triggered by clusev-restart.path. Runs watch.sh once: restarts the prod stack
# (docker compose up -d) and deletes the sentinel. A oneshot — it exits when done,
# and the .path unit re-arms for the next request.
#
# SECURITY: this is the ONLY component allowed to drive Docker. The panel container
# has NO Docker socket; it merely writes the sentinel file this unit reacts to.
#
# Edit WorkingDirectory= / ExecStart= / User= below if your project path or the
# Docker-capable user differ from the defaults.
[Unit]
Description=Clusev restart sentinel — restart the prod stack on request
After=docker.service
Requires=docker.service
[Service]
Type=oneshot
# The user must be able to run `docker compose` (root, or a member of the docker group).
User=nexxo
WorkingDirectory=/home/nexxo/clusev
Environment=CLUSEV_DIR=/home/nexxo/clusev
ExecStart=/home/nexxo/clusev/docker/restart-sentinel/watch.sh once
# Don't let a single failed restart wedge the unit; the sentinel survives a failure
# (watch.sh only deletes it on success), so the next .path trigger retries.
TimeoutStartSec=180

View File

@ -0,0 +1,86 @@
#!/usr/bin/env bash
# Clusev restart-sentinel watcher — runs on the HOST (never in a container).
#
# Watches for the restart sentinel file the dashboard writes (via a shared bind
# mount, see docker-compose.prod.yml: ./run -> storage/app/restart-signal) and,
# when it appears, restarts the prod stack, then deletes the sentinel.
#
# SECURITY: the panel container gets NO Docker socket. It only writes a marker
# file. This scoped host unit is the ONLY thing that talks to Docker — so a panel
# compromise cannot drive arbitrary `docker` commands.
#
# Two ways to run it:
# 1. systemd .path unit (preferred) — clusev-restart.path triggers
# clusev-restart.service, which runs this script ONCE per sentinel.
# Invoke as: watch.sh once
# 2. Standalone poll/inotify loop (fallback, no systemd) — runs forever:
# Invoke as: watch.sh loop
#
# Config via env (with sane defaults):
# CLUSEV_DIR project dir holding docker-compose.prod.yml (default: script's repo root)
# CLUSEV_SIGNAL absolute path of the sentinel file (default: $CLUSEV_DIR/run/restart.request)
# CLUSEV_POLL poll interval seconds for the loop fallback (default: 5)
set -euo pipefail
# Resolve the repo root: docker/restart-sentinel/ -> ../../
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
DEFAULT_DIR="$(cd "${SCRIPT_DIR}/../.." && pwd)"
CLUSEV_DIR="${CLUSEV_DIR:-$DEFAULT_DIR}"
CLUSEV_SIGNAL="${CLUSEV_SIGNAL:-${CLUSEV_DIR}/run/restart.request}"
CLUSEV_POLL="${CLUSEV_POLL:-5}"
COMPOSE_FILE="${CLUSEV_DIR}/docker-compose.prod.yml"
log() { printf '%s clusev-restart: %s\n' "$(date -Is)" "$*"; }
# Restart the stack and consume the sentinel. `up -d` (not bare `restart`) so a
# changed image/config is also applied and any down service is brought back.
do_restart() {
[ -f "$COMPOSE_FILE" ] || { log "compose file not found: $COMPOSE_FILE — skipping"; return 1; }
log "restart requested — applying (docker compose up -d) in ${CLUSEV_DIR}"
if docker compose -f "$COMPOSE_FILE" up -d; then
log "stack restarted"
else
log "docker compose up -d FAILED — leaving sentinel so it retries"
return 1
fi
rm -f "$CLUSEV_SIGNAL" && log "sentinel cleared: $CLUSEV_SIGNAL"
}
# One shot: only act if the sentinel is actually present (the .path unit guarantees this,
# but a manual invocation might not).
run_once() {
if [ -f "$CLUSEV_SIGNAL" ]; then
do_restart
else
log "no sentinel at $CLUSEV_SIGNAL — nothing to do"
fi
}
# Fallback loop for hosts without systemd: prefer inotifywait, else poll.
run_loop() {
log "watching $CLUSEV_SIGNAL (loop mode)"
if command -v inotifywait >/dev/null 2>&1; then
# Act once on startup in case the sentinel already exists, then block on events.
[ -f "$CLUSEV_SIGNAL" ] && do_restart || true
while true; do
# Watch the directory (the file is created/moved into it) for create/move events.
inotifywait -q -e create -e moved_to -e close_write "$(dirname "$CLUSEV_SIGNAL")" >/dev/null 2>&1 || true
[ -f "$CLUSEV_SIGNAL" ] && do_restart || true
done
else
log "inotifywait not found — polling every ${CLUSEV_POLL}s"
while true; do
[ -f "$CLUSEV_SIGNAL" ] && do_restart || true
sleep "$CLUSEV_POLL"
done
fi
}
case "${1:-once}" in
once) run_once ;;
loop) run_loop ;;
*) log "usage: $0 [once|loop]"; exit 64 ;;
esac

View File

@ -140,6 +140,39 @@ phase 4/7 "Stack starten"
$COMPOSE up -d
info "Container gestartet"
# ── restart sentinel (host watcher) — idempotent, best-effort ────────
# Installs the scoped systemd units so the dashboard's "Jetzt neu starten" button
# works (it writes ./run/restart.request; the host unit restarts the stack). The
# container never gets the Docker socket. Skipped (with a hint) when systemd or
# sudo are unavailable — see docker/restart-sentinel/README.md for the manual path.
install_restart_sentinel() {
local src="docker/restart-sentinel" dst="/etc/systemd/system" proj sysd
proj="$(pwd)"
if ! command -v systemctl >/dev/null 2>&1; then
warn "systemd nicht gefunden — Neustart-Watcher nicht installiert (siehe ${src}/README.md, Loop-Fallback)."; return 0
fi
sysd="sudo"; [ "$(id -u)" = 0 ] && sysd=""
if [ -n "$sysd" ] && ! sudo -n true 2>/dev/null; then
warn "Kein passwortloses sudo — Neustart-Watcher nicht installiert. Manuell: ${src}/README.md."; return 0
fi
# Render the units with THIS project's path + the invoking user (the units in the
# repo default to /home/nexxo/clusev; rewrite to the real deploy location).
local tmp_path tmp_svc
tmp_path="$(mktemp)"; tmp_svc="$(mktemp)"
sed "s#/home/nexxo/clusev#${proj}#g" "${src}/clusev-restart.path" > "$tmp_path"
sed -e "s#/home/nexxo/clusev#${proj}#g" -e "s/^User=.*/User=$(id -un)/" "${src}/clusev-restart.service" > "$tmp_svc"
if $sysd install -m 0644 "$tmp_path" "${dst}/clusev-restart.path" \
&& $sysd install -m 0644 "$tmp_svc" "${dst}/clusev-restart.service" \
&& $sysd systemctl daemon-reload \
&& $sysd systemctl enable --now clusev-restart.path; then
info "Neustart-Watcher aktiv (clusev-restart.path)"
else
warn "Neustart-Watcher konnte nicht installiert werden — siehe ${src}/README.md."
fi
rm -f "$tmp_path" "$tmp_svc"
}
install_restart_sentinel
# ── [5/7] wait for the database ──────────────────────────────────────
phase 5/7 "Datenbank bereit"
db_ready=0

View File

@ -36,7 +36,10 @@ return [
// Restart-required notice
'restart_title' => 'Neustart erforderlich',
'restart_body' => 'Die Domain ist gespeichert. Starte den Stack auf dem Host neu, damit sie übernommen wird:',
'restart_body' => 'Die Änderung ist gespeichert. Starte den Stack neu, damit sie übernommen wird.',
'restart_now' => 'Jetzt neu starten',
'restart_running' => 'Neustart wird ausgeführt …',
'restart_watcher_hint' => 'Falls nach ~30 s nichts passiert, ist der Host-Watcher evtl. nicht installiert — siehe Doku (docker/restart-sentinel).',
'restart_lockout_hint' => 'Nach dem Neustart eventuell neu anmelden. Falls die neue Domain (DNS/Zertifikat) noch nicht erreichbar ist, bleibt das Panel über http://<Server-IP> als Rückfallweg erreichbar.',
// Release channel panel

View File

@ -36,7 +36,10 @@ return [
// Restart-required notice
'restart_title' => 'Restart required',
'restart_body' => 'The domain is saved. Restart the stack on the host to apply it:',
'restart_body' => 'The change is saved. Restart the stack to apply it.',
'restart_now' => 'Restart now',
'restart_running' => 'Restart is running …',
'restart_watcher_hint' => 'If nothing happens after ~30 s, the host watcher may not be installed — see the docs (docker/restart-sentinel).',
'restart_lockout_hint' => 'You may need to sign in again after the restart. If the new domain (DNS/certificate) is not reachable yet, the panel stays available at http://<server-IP> as a fallback.',
// Release channel panel

View File

@ -47,14 +47,29 @@
<p class="font-mono text-[11px] leading-relaxed">{{ __('system.ssh_reset_hint') }}</p>
</div>
{{-- Restart-required notice (shown after a successful save) --}}
{{-- Restart-required notice (shown after a successful save). One-click: the button
writes a sentinel a HOST-side watcher reacts to and restarts the stack the
container never gets the Docker socket. No terminal needed. --}}
@if ($restartPending)
<div class="mb-4 flex items-start gap-3 rounded-md border border-warning/30 bg-warning/10 p-3">
<x-icon name="alert" class="mt-0.5 h-4 w-4 shrink-0 text-warning" />
<div class="min-w-0">
<div class="min-w-0 flex-1">
<p class="text-sm text-ink">{{ __('system.restart_title') }}</p>
<p class="mt-0.5 font-mono text-[11px] leading-relaxed text-ink-3">{{ __('system.restart_body') }}</p>
<code class="mt-2 block overflow-x-auto rounded border border-line bg-void px-2.5 py-1.5 font-mono text-[11px] text-accent-text">docker compose -f docker-compose.prod.yml restart</code>
@if ($restartRequested)
<p class="mt-2 flex items-center gap-2 font-mono text-[11px] text-accent-text">
<x-icon name="rotate" class="h-3.5 w-3.5 shrink-0" />{{ __('system.restart_running') }}
</p>
@else
<div class="mt-3">
<x-btn variant="primary" wire:click="restartNow" wire:loading.attr="disabled" wire:target="restartNow">
<x-icon name="rotate" class="h-3.5 w-3.5" /> {{ __('system.restart_now') }}
</x-btn>
</div>
@endif
<p class="mt-2 font-mono text-[11px] leading-relaxed text-ink-4">{{ __('system.restart_watcher_hint') }}</p>
<p class="mt-2 font-mono text-[11px] leading-relaxed text-ink-4">{{ __('system.restart_lockout_hint') }}</p>
</div>
</div>

View File

@ -0,0 +1,84 @@
<?php
namespace Tests\Feature;
use App\Livewire\System\Index;
use App\Models\User;
use App\Services\DeploymentService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use Tests\TestCase;
/**
* The restart sentinel: the dashboard writes a marker file (DeploymentService) that a
* HOST-side watcher reacts to and restarts the stack. The container never gets the
* Docker socket. These tests exercise the file lifecycle + the one-click Livewire flow,
* and always clean the sentinel up afterwards (it lives on a real storage path).
*/
class RestartSentinelTest extends TestCase
{
use RefreshDatabase;
private function sentinelPath(): string
{
return app(DeploymentService::class)->restartSignalPath();
}
private function removeSentinel(): void
{
$path = $this->sentinelPath();
if (is_file($path)) {
@unlink($path);
}
}
protected function setUp(): void
{
parent::setUp();
$this->removeSentinel(); // never inherit a stray sentinel from a prior run
}
protected function tearDown(): void
{
$this->removeSentinel(); // leave the storage dir as we found it
parent::tearDown();
}
public function test_request_restart_creates_the_sentinel_and_request_reports_it(): void
{
$deployment = app(DeploymentService::class);
$this->assertFalse($deployment->restartRequested(), 'no sentinel before requesting');
$deployment->requestRestart();
$this->assertTrue(is_file($this->sentinelPath()), 'sentinel file exists on disk');
$this->assertTrue($deployment->restartRequested(), 'restartRequested() reflects the file');
$this->assertNotSame('', trim((string) file_get_contents($this->sentinelPath())), 'sentinel carries a marker');
}
public function test_clear_restart_request_removes_the_sentinel(): void
{
$deployment = app(DeploymentService::class);
$deployment->requestRestart();
$this->assertTrue($deployment->restartRequested());
$deployment->clearRestartRequest();
$this->assertFalse($deployment->restartRequested(), 'sentinel is gone after clearing');
}
public function test_restart_now_writes_the_sentinel_and_sets_the_flag(): void
{
$this->actingAs(User::factory()->create(['must_change_password' => false]));
Livewire::test(Index::class)
->call('restartNow', app(DeploymentService::class))
->assertSet('restartRequested', true);
$this->assertTrue(
app(DeploymentService::class)->restartRequested(),
'the one-click action persisted the sentinel a host watcher will see',
);
}
}