diff --git a/app/Livewire/System/Index.php b/app/Livewire/System/Index.php index 98f59fa..62aa2e8 100644 --- a/app/Livewire/System/Index.php +++ b/app/Livewire/System/Index.php @@ -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 { diff --git a/app/Services/DeploymentService.php b/app/Services/DeploymentService.php index 7d249e2..929fe10 100644 --- a/app/Services/DeploymentService.php +++ b/app/Services/DeploymentService.php @@ -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 { diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 118b9b9..b7c4a4a 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -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 diff --git a/docker/restart-sentinel/README.md b/docker/restart-sentinel/README.md new file mode 100644 index 0000000..04bc245 --- /dev/null +++ b/docker/restart-sentinel/README.md @@ -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 | diff --git a/docker/restart-sentinel/clusev-restart.path b/docker/restart-sentinel/clusev-restart.path new file mode 100644 index 0000000..ab2ea36 --- /dev/null +++ b/docker/restart-sentinel/clusev-restart.path @@ -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 diff --git a/docker/restart-sentinel/clusev-restart.service b/docker/restart-sentinel/clusev-restart.service new file mode 100644 index 0000000..10f51e2 --- /dev/null +++ b/docker/restart-sentinel/clusev-restart.service @@ -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 diff --git a/docker/restart-sentinel/watch.sh b/docker/restart-sentinel/watch.sh new file mode 100755 index 0000000..bb9de94 --- /dev/null +++ b/docker/restart-sentinel/watch.sh @@ -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 diff --git a/install.sh b/install.sh index fcd01a3..1895c6a 100755 --- a/install.sh +++ b/install.sh @@ -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 diff --git a/lang/de/system.php b/lang/de/system.php index 8601fe2..f9ea54f 100644 --- a/lang/de/system.php +++ b/lang/de/system.php @@ -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:// als Rückfallweg erreichbar.', // Release channel panel diff --git a/lang/en/system.php b/lang/en/system.php index fc539e0..d277cf0 100644 --- a/lang/en/system.php +++ b/lang/en/system.php @@ -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:// as a fallback.', // Release channel panel diff --git a/resources/views/livewire/system/index.blade.php b/resources/views/livewire/system/index.blade.php index 18aa49a..5f2f7b2 100644 --- a/resources/views/livewire/system/index.blade.php +++ b/resources/views/livewire/system/index.blade.php @@ -47,14 +47,29 @@

{{ __('system.ssh_reset_hint') }}

- {{-- 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)
-
+

{{ __('system.restart_title') }}

{{ __('system.restart_body') }}

- docker compose -f docker-compose.prod.yml restart + + @if ($restartRequested) +

+ {{ __('system.restart_running') }} +

+ @else +
+ + {{ __('system.restart_now') }} + +
+ @endif + +

{{ __('system.restart_watcher_hint') }}

{{ __('system.restart_lockout_hint') }}

diff --git a/tests/Feature/RestartSentinelTest.php b/tests/Feature/RestartSentinelTest.php new file mode 100644 index 0000000..5ae4546 --- /dev/null +++ b/tests/Feature/RestartSentinelTest.php @@ -0,0 +1,84 @@ +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', + ); + } +}