Fresh-install bug batch: prod poller, installer UX, switcher/firewall polish

Prod metrics poller: docker-compose.prod.yml gains a `metrics` service
(clusev:poll-metrics --interval=15). The poller previously existed only in the
dev supervisor config, so on a customer install no process ever promoted a
server out of 'pending' (Initialisierung) or refreshed CPU/MEM — fresh
installs sat at 0% forever. The service inherits the hardened *image anchor.

Installer output: new run_logged() helper routes noisy steps (apt, docker
build/pull, compose up, migrate, caches) into install.log at the repo root —
TTY gets a spinner + "ok" lines, non-TTY gets start/ok lines, failures tail
the last 40 log lines and abort. Secret-bearing steps (APP_KEY, admin
password) deliberately stay out of the log. update.sh shares the same log via
CLUSEV_INSTALL_LOG and redirects git pull/verify-commit detail into it. The
log is initialized symlink-safely (mktemp + atomic rename -T) because the
repo root belongs to the unprivileged clusev user after install (Codex
finding); /install.log is gitignored so updates never conflict.

Server switcher: an empty fleet now renders an explanatory empty state with a
role-gated "Server hinzufügen" CTA instead of an empty dropdown panel.

Servers/groups headers: the raw <a> buttons (unconditional min-h-11) are
replaced with x-btn, aligning them with the h-8 accent button + status pill.

Firewall panel: the rules box is hidden for ANY inactive firewall (previously
only firewalld — an inactive ufw showed a misleading rules table via `ufw
show added`), with a tool-agnostic hint + Aktivieren action; the add-rule
trigger is gated on active. hardeningApplied now performs an in-place
security re-read (OS profile + hardening + firewall + fail2ban) instead of a
full-page skeleton reload, with a wire:loading cue and toggles disabled while
it runs — the state can no longer be re-toggled against stale data.
SshKeyProvision drops its redundant hardeningApplied (credentialChanged
already full-reloads).

Hardening modal: failure output limit 200 → 400 so the 202-char root-lockout
guard message renders unclipped; the root-login description now states that
PermitRootLogin no blocks both password and key logins.

Command palette: chords g o (Verfügbarkeit) + g b (Bedrohungen).

Codex-reviewed (one finding: symlink-following log init — fixed + re-verified
clean). 760 tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
feat/v1-foundation
boban 2026-07-08 18:57:04 +02:00
parent b7e44ca3c2
commit 4248c83c42
18 changed files with 362 additions and 41 deletions

1
.gitignore vendored
View File

@ -47,6 +47,7 @@ yarn-error.log
# host-side update transcript (written at the repo root — NOT under ./run — so a compromised # host-side update transcript (written at the repo root — NOT under ./run — so a compromised
# container cannot symlink-redirect the root updater's tee; see docker/restart-sentinel/watch.sh) # container cannot symlink-redirect the root updater's tee; see docker/restart-sentinel/watch.sh)
/update.log /update.log
/install.log
# transient status temp files: created at the repo root then atomically renamed into ./run (same fs, # transient status temp files: created at the repo root then atomically renamed into ./run (same fs,
# container-inaccessible) so the root write can't be symlink-redirected — normally gone in an instant # container-inaccessible) so the root write can't be symlink-redirected — normally gone in an instant
/.phase.* /.phase.*

View File

@ -95,8 +95,10 @@ class HardeningAction extends ModalComponent
$this->done = true; $this->done = true;
$this->ok = $result['ok']; $this->ok = $result['ok'];
// Clean result only — no raw command output on success; a short reason on failure. // Clean result only — no raw command output on success; a bounded reason on failure. 400,
$this->output = $this->ok ? '' : Str::limit(trim($result['output']) ?: __('modals.hardening_action.error_unknown'), 200); // not 200: our own guard messages (e.g. backend.ssh_root_self_lockout, 202 chars) must
// never be cut mid-sentence — the box wraps with break-words, so length is fine.
$this->output = $this->ok ? '' : Str::limit(trim($result['output']) ?: __('modals.hardening_action.error_unknown'), 400);
AuditEvent::create([ AuditEvent::create([
'user_id' => Auth::id(), 'user_id' => Auth::id(),

View File

@ -66,7 +66,8 @@ class SshKeyProvision extends ModalComponent
$this->done = true; $this->done = true;
$this->ok = false; $this->ok = false;
$this->error = $e->getMessage(); $this->error = $e->getMessage();
$this->dispatch('hardeningApplied'); // credentialChanged already triggers the FULL page reload on Show (key list +
// credential + security state) — no extra hardeningApplied needed.
$this->dispatch('credentialChanged'); $this->dispatch('credentialChanged');
$this->dispatch('notify', message: __('modals.ssh_key_provision.notify_failed')); $this->dispatch('notify', message: __('modals.ssh_key_provision.notify_failed'));
@ -79,7 +80,6 @@ class SshKeyProvision extends ModalComponent
$this->publicKey = $result['publicKey'] ?? null; $this->publicKey = $result['publicKey'] ?? null;
$this->error = $result['ok'] ? null : ($result['error'] ?? __('modals.ssh_key_provision.error_unknown')); $this->error = $result['ok'] ? null : ($result['error'] ?? __('modals.ssh_key_provision.error_unknown'));
$this->dispatch('hardeningApplied');
$this->dispatch('credentialChanged'); $this->dispatch('credentialChanged');
$this->dispatch('notify', message: $this->ok $this->dispatch('notify', message: $this->ok

View File

@ -350,11 +350,45 @@ class Show extends Component
* checklist + firewall row reflect the new state. * checklist + firewall row reflect the new state.
*/ */
#[On('hardeningApplied')] #[On('hardeningApplied')]
public function reloadSnapshot(FleetService $fleet): void public function reloadSnapshot(): void
{ {
// A hardening/fail2ban toggle changes only the SECURITY reads. Re-probe the OS profile
// (a firewall "Aktivieren" may have just INSTALLED the tool the panels key off) and
// re-read the three security states — but keep the page rendered (no skeleton flip)
// and skip the full metrics/volumes/keys snapshot: that halves the SSH round-trips
// and the operator sees the page update in place instead of a long blank reload.
// The Sicherheit/Firewall panels show a wire:loading cue targeting this method.
$this->server->refresh(); $this->server->refresh();
$this->ready = false;
$this->load($fleet); try {
$profile = app(OsDetector::class)->detect($this->server, fresh: true);
$this->os = [
'familyLabel' => $profile->familyLabel(),
'packageManager' => $profile->packageManager,
'firewallTool' => $profile->firewallTool,
'serviceManager' => $profile->serviceManager,
];
} catch (Throwable) {
// keep the current profile on failure
}
try {
$this->hardening = app(HardeningService::class)->state($this->server);
} catch (Throwable) {
$this->hardening = [];
}
try {
$this->firewall = app(FirewallService::class)->status($this->server);
} catch (Throwable) {
$this->firewall = [];
}
try {
$this->fail2ban = app(Fail2banService::class)->status($this->server);
} catch (Throwable) {
$this->fail2ban = [];
}
} }
/** /**

View File

@ -96,6 +96,20 @@ services:
redis: redis:
condition: service_started condition: service_started
# Live metrics poller — real CPU/MEM/disk over SSH every 15s for credentialed servers; promotes
# 'pending' (Initialisierung) → online/warning/offline and broadcasts MetricsTicked. Also feeds
# the metrics:latest cache that clusev:sample-metrics (scheduler) persists as history. Prod
# counterpart of the dev-only supervisord.dev.conf [program:metrics] — without it a fresh install
# never polls. Deliberately no extra_hosts (least privilege, see the app-service comment above).
metrics:
<<: *image
command: php artisan clusev:poll-metrics --interval=15
depends_on:
mariadb:
condition: service_healthy
redis:
condition: service_started
# Terminal sidecar: WebSocket (/terminal/ws via Caddy) ↔ SSH PTY (per server) or local shell PTY # Terminal sidecar: WebSocket (/terminal/ws via Caddy) ↔ SSH PTY (per server) or local shell PTY
# (the Clusev "host" terminal). Internal only; authorizes the single-use token before opening a PTY. # (the Clusev "host" terminal). Internal only; authorizes the single-use token before opening a PTY.
terminal: terminal:

View File

@ -44,6 +44,61 @@ set_stage() {
return 0 return 0
} }
# ── run_logged: Detail ins Log, Fortschritt im Terminal ──────────────
# Laute Schritte (apt, docker build/pull/up, migrate) laufen hierdurch: die komplette Ausgabe
# geht nach install.log am REPO-ROOT — NICHT run/, das ist der container-beschreibbare Bind-
# Mount, ein Root-Write dort koennte einem vorgelegten Symlink folgen (vgl. watch.sh zu
# update.log). Am TTY tickt ein Spinner; ohne TTY (update.sh/systemd-Watcher: stdout ist die
# tee-Pipe nach update.log) nur Start-/Ok-Zeilen, keine Steuerzeichen. Bei Fehler: die letzten
# 40 Logzeilen + Abbruch — Fehler bleiben vollstaendig sichtbar.
# NIE fuer Schritte verwenden, deren Ausgabe ein Secret enthaelt (APP_KEY, Admin-Passwort).
# init_log_file: symlink-sicheres Anlegen. install.log liegt im Repo-Root, der nach der
# Installation dem unprivilegierten clusev-User gehoert (chown -R unten) — ein plain `: >` als
# root wuerde einem vorgelegten Symlink folgen (gleiche Klasse wie run/, siehe set_stage).
# mktemp + atomares rename ersetzt einen Link selbst und folgt ihm nie; -T verhindert das
# Hineinverschieben in ein Symlink-auf-Verzeichnis-Ziel.
init_log_file() {
local path="$1" dir tmp
dir="$(dirname -- "$path")"
tmp="$(mktemp "${dir}/.install-log.XXXXXX" 2>/dev/null)" || return 1
chmod 0644 "$tmp" 2>/dev/null || true
mv -fT -- "$tmp" "$path" 2>/dev/null || { rm -f "$tmp" 2>/dev/null; return 1; }
}
LOG_FILE="${CLUSEV_INSTALL_LOG:-$(pwd)/install.log}"
# Frisch anlegen, ausser update.sh hat das Log schon angelegt und via CLUSEV_INSTALL_LOG vererbt.
if [ -z "${CLUSEV_INSTALL_LOG:-}" ]; then
init_log_file "$LOG_FILE" || LOG_FILE=/dev/null
fi
run_logged() {
local msg="$1"; shift
printf '\n===== %s | %s\n' "$(date '+%F %T' 2>/dev/null || echo '-')" "$msg" >> "$LOG_FILE" 2>/dev/null || true
if [ -t 1 ]; then
"$@" >> "$LOG_FILE" 2>&1 &
local pid=$! sp='|/-\' i=0 rc=0
while kill -0 "$pid" 2>/dev/null; do
printf '\r %s %s ...' "${sp:i++%4:1}" "$msg"
sleep 0.2
done
if wait "$pid"; then
printf '\r %s ... ok\033[K\n' "$msg"
else
rc=$?
printf '\r\033[K' >&2
tail -n 40 "$LOG_FILE" 2>/dev/null | sed 's/^/ /' >&2
die "${msg} fehlgeschlagen (Exit ${rc}) — vollstaendiges Log: ${LOG_FILE}"
fi
else
info "${msg} ..."
local rc=0
"$@" >> "$LOG_FILE" 2>&1 || rc=$?
if [ "$rc" -ne 0 ]; then
tail -n 40 "$LOG_FILE" 2>/dev/null | sed 's/^/ /' >&2
die "${msg} fehlgeschlagen (Exit ${rc}) — vollstaendiges Log: ${LOG_FILE}"
fi
info "${msg} ok"
fi
}
# ── .env mutators ──────────────────────────────────────────────────── # ── .env mutators ────────────────────────────────────────────────────
# force_kv: always write (config/derived). set_kv: write only when empty/placeholder. # force_kv: always write (config/derived). set_kv: write only when empty/placeholder.
force_kv() { force_kv() {
@ -98,8 +153,8 @@ if command -v docker >/dev/null 2>&1 && docker compose version >/dev/null 2>&1;
elif [ "$IS_APT" = 1 ]; then elif [ "$IS_APT" = 1 ]; then
info "Docker nicht gefunden — Installation aus dem offiziellen Docker-apt-Repo ..." info "Docker nicht gefunden — Installation aus dem offiziellen Docker-apt-Repo ..."
export DEBIAN_FRONTEND=noninteractive export DEBIAN_FRONTEND=noninteractive
apt-get update run_logged "apt-Paketquellen aktualisieren" apt-get update
apt-get install -y ca-certificates curl run_logged "Basis-Pakete installieren" apt-get install -y ca-certificates curl
install -m 0755 -d /etc/apt/keyrings install -m 0755 -d /etc/apt/keyrings
curl -fsSL "https://download.docker.com/linux/${OS_ID}/gpg" -o /etc/apt/keyrings/docker.asc curl -fsSL "https://download.docker.com/linux/${OS_ID}/gpg" -o /etc/apt/keyrings/docker.asc
chmod a+r /etc/apt/keyrings/docker.asc chmod a+r /etc/apt/keyrings/docker.asc
@ -108,8 +163,8 @@ elif [ "$IS_APT" = 1 ]; then
printf 'deb [arch=%s signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/%s %s stable\n' \ printf 'deb [arch=%s signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/%s %s stable\n' \
"$(dpkg --print-architecture)" "$OS_ID" "$codename" \ "$(dpkg --print-architecture)" "$OS_ID" "$codename" \
> /etc/apt/sources.list.d/docker.list > /etc/apt/sources.list.d/docker.list
apt-get update run_logged "Docker-Paketquelle einlesen" apt-get update
apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin run_logged "Docker installieren" apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
systemctl enable --now docker systemctl enable --now docker
docker compose version >/dev/null 2>&1 || die "Docker-Installation fehlgeschlagen (docker compose weiterhin nicht verfuegbar)." docker compose version >/dev/null 2>&1 || die "Docker-Installation fehlgeschlagen (docker compose weiterhin nicht verfuegbar)."
info "Docker installiert + aktiviert" info "Docker installiert + aktiviert"
@ -298,12 +353,16 @@ case "${CLUSEV_RELEASE_MODE:-build}" in
pull) info "Release-Images aus release-images.lock (Pull statt Build)" ;; pull) info "Release-Images aus release-images.lock (Pull statt Build)" ;;
*) info "Lokaler Build" ;; *) info "Lokaler Build" ;;
esac esac
if [ "${CLUSEV_PULL:-0}" = "1" ]; then $COMPOSE pull; else $COMPOSE build; fi if [ "${CLUSEV_PULL:-0}" = "1" ]; then
info "Image bereit" run_logged "Release-Images laden" $COMPOSE pull
else
run_logged "Image bauen (dauert beim ersten Mal einige Minuten)" $COMPOSE build
fi
cur_key="$(grep -E '^APP_KEY=' "$ENV_FILE" | head -n1 | cut -d= -f2-)" cur_key="$(grep -E '^APP_KEY=' "$ENV_FILE" | head -n1 | cut -d= -f2-)"
case "$cur_key" in case "$cur_key" in
""|base64:) ""|base64:)
# NICHT durch run_logged/install.log leiten — die Ausgabe IST der APP_KEY (Secret).
NEW_KEY="$($COMPOSE run --rm --no-deps app php artisan key:generate --show 2>/dev/null | tr -d '\r' | tail -n1)" NEW_KEY="$($COMPOSE run --rm --no-deps app php artisan key:generate --show 2>/dev/null | tr -d '\r' | tail -n1)"
[ -n "$NEW_KEY" ] || die "APP_KEY konnte nicht erzeugt werden." [ -n "$NEW_KEY" ] || die "APP_KEY konnte nicht erzeugt werden."
force_kv APP_KEY "$NEW_KEY"; info "APP_KEY erzeugt" ;; force_kv APP_KEY "$NEW_KEY"; info "APP_KEY erzeugt" ;;
@ -313,11 +372,10 @@ esac
# ── [5/9] stack ────────────────────────────────────────────────────── # ── [5/9] stack ──────────────────────────────────────────────────────
phase 5/9 "Stack starten" phase 5/9 "Stack starten"
set_stage restart set_stage restart
$COMPOSE up -d run_logged "Container starten" $COMPOSE up -d
# Caddyfile changes ship as edits to a bind-mounted file; `up -d` does NOT recreate caddy for a # Caddyfile changes ship as edits to a bind-mounted file; `up -d` does NOT recreate caddy for a
# content-only change, so force it to re-read the (possibly updated) Caddyfile on every deploy. # content-only change, so force it to re-read the (possibly updated) Caddyfile on every deploy.
$COMPOSE up -d --force-recreate caddy >/dev/null 2>&1 || true $COMPOSE up -d --force-recreate caddy >/dev/null 2>&1 || true
info "Container gestartet"
# ── host watchers (restart + update sentinels) — idempotent, best-effort ──── # ── host watchers (restart + update sentinels) — idempotent, best-effort ────
# Installs the scoped systemd units so the dashboard's "Jetzt neu starten" and # Installs the scoped systemd units so the dashboard's "Jetzt neu starten" and
@ -408,15 +466,15 @@ done
# ── [7/9] migrate + caches ─────────────────────────────────────────── # ── [7/9] migrate + caches ───────────────────────────────────────────
phase 7/9 "Migrationen" phase 7/9 "Migrationen"
set_stage migrate set_stage migrate
$COMPOSE exec -T -u app app php artisan migrate --force run_logged "Schema migrieren" $COMPOSE exec -T -u app app php artisan migrate --force
$COMPOSE exec -T -u app app php artisan config:cache >/dev/null run_logged "Config-Cache bauen" $COMPOSE exec -T -u app app php artisan config:cache
$COMPOSE exec -T -u app app php artisan route:cache >/dev/null run_logged "Routen-Cache bauen" $COMPOSE exec -T -u app app php artisan route:cache
info "Schema migriert; Caches gebaut"
# ── [8/9] first admin ──────────────────────────────────────────────── # ── [8/9] first admin ────────────────────────────────────────────────
phase 8/9 "Admin anlegen" phase 8/9 "Admin anlegen"
email_args=() email_args=()
[ -n "$ADMIN_EMAIL" ] && email_args=(--email="$ADMIN_EMAIL") [ -n "$ADMIN_EMAIL" ] && email_args=(--email="$ADMIN_EMAIL")
# NICHT durch run_logged/install.log leiten — die Ausgabe enthaelt das Admin-Passwort (Secret).
INSTALL_OUT="$($COMPOSE exec -T -u app app php artisan clusev:install "${email_args[@]}" 2>&1 || true)" INSTALL_OUT="$($COMPOSE exec -T -u app app php artisan clusev:install "${email_args[@]}" 2>&1 || true)"
ADMIN_PW="$(printf '%s\n' "$INSTALL_OUT" | sed -n 's/^CLUSEV_ADMIN_PASSWORD=//p' | head -n1)" ADMIN_PW="$(printf '%s\n' "$INSTALL_OUT" | sed -n 's/^CLUSEV_ADMIN_PASSWORD=//p' | head -n1)"
ADMIN_MAIL="$(printf '%s\n' "$INSTALL_OUT" | sed -n 's/^CLUSEV_ADMIN_EMAIL=//p' | head -n1)" ADMIN_MAIL="$(printf '%s\n' "$INSTALL_OUT" | sed -n 's/^CLUSEV_ADMIN_EMAIL=//p' | head -n1)"

View File

@ -26,7 +26,7 @@ return [
// ── HardeningService: modal descriptions (per direction) ───────────── // ── HardeningService: modal descriptions (per direction) ─────────────
'desc_ssh_root_enable' => 'Erlaubt direkten Root-Login über SSH wieder (PermitRootLogin yes) und lädt den SSH-Dienst neu.', 'desc_ssh_root_enable' => 'Erlaubt direkten Root-Login über SSH wieder (PermitRootLogin yes) und lädt den SSH-Dienst neu.',
'desc_ssh_root_disable' => 'Unterbindet direkten Root-Login über SSH (PermitRootLogin no) und lädt den SSH-Dienst neu.', 'desc_ssh_root_disable' => 'Unterbindet direkten Root-Login über SSH vollständig (PermitRootLogin no) — blockiert die Anmeldung als root per Passwort UND per SSH-Key — und lädt den SSH-Dienst neu.',
'desc_ssh_password_enable' => 'Erlaubt die Anmeldung per Passwort wieder (PasswordAuthentication yes).', 'desc_ssh_password_enable' => 'Erlaubt die Anmeldung per Passwort wieder (PasswordAuthentication yes).',
'desc_ssh_password_disable' => 'Anmeldung nur noch per SSH-Key (PasswordAuthentication no). Nur möglich, wenn ein Key hinterlegt ist.', 'desc_ssh_password_disable' => 'Anmeldung nur noch per SSH-Key (PasswordAuthentication no). Nur möglich, wenn ein Key hinterlegt ist.',
'desc_fail2ban_enable' => 'Installiert fail2ban (falls nötig) und startet den Dienst. Wiederholte Fehl-Logins werden gesperrt.', 'desc_fail2ban_enable' => 'Installiert fail2ban (falls nötig) und startet den Dienst. Wiederholte Fehl-Logins werden gesperrt.',

View File

@ -102,7 +102,7 @@ return [
'firewall_sub_inactive' => ' · inaktiv', 'firewall_sub_inactive' => ' · inaktiv',
'firewall_add_rule' => 'Regel', 'firewall_add_rule' => 'Regel',
'firewall_read_error' => 'Firewall-Status konnte nicht gelesen werden (Verbindung/Rechte). Bitte später erneut laden.', 'firewall_read_error' => 'Firewall-Status konnte nicht gelesen werden (Verbindung/Rechte). Bitte später erneut laden.',
'firewalld_inactive' => 'firewalld ist installiert, aber inaktiv.', 'firewall_inactive_hint' => ':tool ist installiert, aber inaktiv — Regeln werden derzeit nicht durchgesetzt.',
'firewall_default' => 'Standard', 'firewall_default' => 'Standard',
'firewall_incoming' => 'eingehend: :value', 'firewall_incoming' => 'eingehend: :value',
'firewall_outgoing' => 'ausgehend: :value', 'firewall_outgoing' => 'ausgehend: :value',
@ -155,6 +155,8 @@ return [
// ── Switcher ────────────────────────────────────────────────────────── // ── Switcher ──────────────────────────────────────────────────────────
'switcher_no_server' => 'Kein Server', 'switcher_no_server' => 'Kein Server',
'switcher_empty_title' => 'Noch keine Server verbunden.',
'switcher_empty_hint' => 'Verbundene Server erscheinen hier.',
// ── Notifications (Show component) ──────────────────────────────────── // ── Notifications (Show component) ────────────────────────────────────
'notify_access_locked' => 'Zugang gesperrt.', 'notify_access_locked' => 'Zugang gesperrt.',

View File

@ -26,7 +26,7 @@ return [
// ── HardeningService: modal descriptions (per direction) ───────────── // ── HardeningService: modal descriptions (per direction) ─────────────
'desc_ssh_root_enable' => 'Re-allows direct root login over SSH (PermitRootLogin yes) and reloads the SSH service.', 'desc_ssh_root_enable' => 'Re-allows direct root login over SSH (PermitRootLogin yes) and reloads the SSH service.',
'desc_ssh_root_disable' => 'Blocks direct root login over SSH (PermitRootLogin no) and reloads the SSH service.', 'desc_ssh_root_disable' => 'Blocks direct root login over SSH entirely (PermitRootLogin no) — both password and SSH-key logins as root — and reloads the SSH service.',
'desc_ssh_password_enable' => 'Re-allows password login (PasswordAuthentication yes).', 'desc_ssh_password_enable' => 'Re-allows password login (PasswordAuthentication yes).',
'desc_ssh_password_disable' => 'Login via SSH key only (PasswordAuthentication no). Only possible if a key is in place.', 'desc_ssh_password_disable' => 'Login via SSH key only (PasswordAuthentication no). Only possible if a key is in place.',
'desc_fail2ban_enable' => 'Installs fail2ban (if needed) and starts the service. Repeated failed logins get banned.', 'desc_fail2ban_enable' => 'Installs fail2ban (if needed) and starts the service. Repeated failed logins get banned.',

View File

@ -102,7 +102,7 @@ return [
'firewall_sub_inactive' => ' · inactive', 'firewall_sub_inactive' => ' · inactive',
'firewall_add_rule' => 'Rule', 'firewall_add_rule' => 'Rule',
'firewall_read_error' => 'Firewall status could not be read (connection/permissions). Please reload later.', 'firewall_read_error' => 'Firewall status could not be read (connection/permissions). Please reload later.',
'firewalld_inactive' => 'firewalld is installed but inactive.', 'firewall_inactive_hint' => ':tool is installed but inactive — rules are not currently enforced.',
'firewall_default' => 'Default', 'firewall_default' => 'Default',
'firewall_incoming' => 'incoming: :value', 'firewall_incoming' => 'incoming: :value',
'firewall_outgoing' => 'outgoing: :value', 'firewall_outgoing' => 'outgoing: :value',
@ -155,6 +155,8 @@ return [
// ── Switcher ────────────────────────────────────────────────────────── // ── Switcher ──────────────────────────────────────────────────────────
'switcher_no_server' => 'No server', 'switcher_no_server' => 'No server',
'switcher_empty_title' => 'No servers connected yet.',
'switcher_empty_hint' => 'Connected servers will appear here.',
// ── Notifications (Show component) ──────────────────────────────────── // ── Notifications (Show component) ────────────────────────────────────
'notify_access_locked' => 'Access locked.', 'notify_access_locked' => 'Access locked.',

View File

@ -25,11 +25,11 @@
['shell.nav_posture', '/posture', 'p', 'operate'], ['shell.nav_posture', '/posture', 'p', 'operate'],
['shell.nav_patch', '/patch', 'u', 'operate'], ['shell.nav_patch', '/patch', 'u', 'operate'],
['shell.nav_certs', '/certs', 'r', 'operate'], ['shell.nav_certs', '/certs', 'r', 'operate'],
['shell.nav_health', '/health', null, 'operate'], ['shell.nav_health', '/health', 'o', 'operate'],
// Account // Account
['shell.nav_settings', '/settings', 'e', null], ['shell.nav_settings', '/settings', 'e', null],
['shell.nav_alerts', '/alerts', 'a', 'manage-panel'], ['shell.nav_alerts', '/alerts', 'a', 'manage-panel'],
['shell.nav_threats', '/threats', null, 'manage-panel'], ['shell.nav_threats', '/threats', 'b', 'manage-panel'],
['shell.nav_versions', '/versions', 'v', null], ['shell.nav_versions', '/versions', 'v', null],
['shell.nav_help', config('clusev.docs_url'), 'h', null], ['shell.nav_help', config('clusev.docs_url'), 'h', null],
]; ];

View File

@ -20,7 +20,7 @@
class="absolute inset-x-0 top-full z-50 mt-1 overflow-hidden rounded-md border border-line bg-raised shadow-pop" class="absolute inset-x-0 top-full z-50 mt-1 overflow-hidden rounded-md border border-line bg-raised shadow-pop"
role="listbox"> role="listbox">
<div class="max-h-72 overflow-y-auto p-1"> <div class="max-h-72 overflow-y-auto p-1">
@foreach ($servers as $server) @forelse ($servers as $server)
<button type="button" wire:click="select('{{ $server->uuid }}')" @click="open = false" <button type="button" wire:click="select('{{ $server->uuid }}')" @click="open = false"
@class([ @class([
'flex min-h-11 w-full items-center gap-2.5 rounded-sm px-2.5 py-1.5 text-left transition-colors hover:bg-surface', 'flex min-h-11 w-full items-center gap-2.5 rounded-sm px-2.5 py-1.5 text-left transition-colors hover:bg-surface',
@ -36,7 +36,24 @@
<x-icon name="activity" class="h-3.5 w-3.5 shrink-0 text-accent" /> <x-icon name="activity" class="h-3.5 w-3.5 shrink-0 text-accent" />
@endif @endif
</button> </button>
@endforeach @empty
{{-- Empty fleet: explain the state + offer the add-server action instead of a bare panel.
The @click on the WRAPPER (bubbling) closes the dropdown never on x-modal-trigger
itself, a caller x-on:click would override its internal open() handler. --}}
<div class="px-2.5 py-4 text-center">
<x-icon name="server" class="mx-auto h-5 w-5 text-ink-4" />
<p class="mt-2 text-sm text-ink-2">{{ __('servers.switcher_empty_title') }}</p>
<p class="mt-0.5 font-mono text-[11px] text-ink-4">{{ __('servers.switcher_empty_hint') }}</p>
@can('manage-fleet')
<div class="mt-3 flex justify-center" @click="open = false">
<x-modal-trigger variant="accent" component="modals.create-server">
<x-icon name="plus" class="h-3.5 w-3.5" />
{{ __('servers.add_server') }}
</x-modal-trigger>
</div>
@endcan
</div>
@endforelse
</div> </div>
</div> </div>
</div> </div>

View File

@ -15,10 +15,9 @@
<h2 class="mt-0.5 font-display text-xl font-semibold text-ink sm:text-2xl">{{ __('groups.title') }}</h2> <h2 class="mt-0.5 font-display text-xl font-semibold text-ink sm:text-2xl">{{ __('groups.title') }}</h2>
<p class="mt-1 text-sm text-ink-3">{{ __('groups.subtitle') }}</p> <p class="mt-1 text-sm text-ink-3">{{ __('groups.subtitle') }}</p>
</div> </div>
<a href="{{ route('servers.index') }}" wire:navigate <x-btn :href="route('servers.index')" wire:navigate>
class="inline-flex min-h-11 items-center gap-1.5 rounded-md border border-line bg-inset px-3 text-xs text-ink-2 transition-colors hover:bg-raised hover:text-ink">
<x-icon name="server" class="h-3.5 w-3.5" /> {{ __('groups.back_to_servers') }} <x-icon name="server" class="h-3.5 w-3.5" /> {{ __('groups.back_to_servers') }}
</a> </x-btn>
</div> </div>
{{-- New group --}} {{-- New group --}}

View File

@ -13,10 +13,9 @@
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
@can('manage-fleet') @can('manage-fleet')
<a href="{{ route('servers.groups') }}" wire:navigate <x-btn :href="route('servers.groups')" wire:navigate>
class="inline-flex min-h-11 items-center gap-1.5 rounded-md border border-line bg-inset px-3 text-xs text-ink-2 transition-colors hover:bg-raised hover:text-ink">
<x-icon name="folder" class="h-3.5 w-3.5" /> {{ __('groups.title') }} <x-icon name="folder" class="h-3.5 w-3.5" /> {{ __('groups.title') }}
</a> </x-btn>
<x-modal-trigger variant="accent" component="modals.create-server"> <x-modal-trigger variant="accent" component="modals.create-server">
<x-icon name="plus" class="h-3.5 w-3.5" /> <x-icon name="plus" class="h-3.5 w-3.5" />
{{ __('servers.add_server') }} {{ __('servers.add_server') }}

View File

@ -279,6 +279,10 @@
</x-panel> </x-panel>
<x-panel :title="__('servers.security_title')" :subtitle="__('servers.security_subtitle')" :padded="false"> <x-panel :title="__('servers.security_title')" :subtitle="__('servers.security_subtitle')" :padded="false">
{{-- Busy cue while the post-toggle security re-read runs (up to a few SSH round-trips). --}}
<x-slot:actions>
<svg wire:loading wire:target="reloadSnapshot" class="h-3.5 w-3.5 animate-spin text-ink-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><path d="M21 12a9 9 0 1 1-6.219-8.56" stroke-linecap="round" /></svg>
</x-slot:actions>
<div class="divide-y divide-line"> <div class="divide-y divide-line">
@foreach ($hardening as $check) @foreach ($hardening as $check)
@php @php
@ -338,7 +342,10 @@
@endcan @endcan
@else @else
@can('manage-network') @can('manage-network')
{{-- Disabled while the post-apply re-read runs prevents re-opening the
modal against the stale pre-toggle state ("2-3 Versuche"-Effekt). --}}
<x-modal-trigger variant="secondary" size="sm" class="shrink-0" <x-modal-trigger variant="secondary" size="sm" class="shrink-0"
wire:loading.attr="disabled" wire:target="reloadSnapshot"
component="modals.hardening-action" component="modals.hardening-action"
:arguments="['serverId' => $server->id, 'action' => $check['key'], 'enable' => ! $check['featureOn']]"> :arguments="['serverId' => $server->id, 'action' => $check['key'], 'enable' => ! $check['featureOn']]">
{{ $check['featureOn'] ? __('common.disable') : __('common.enable') }} {{ $check['featureOn'] ? __('common.disable') : __('common.enable') }}
@ -405,7 +412,8 @@
<x-panel :title="__('servers.firewall_title')" <x-panel :title="__('servers.firewall_title')"
:subtitle="$fwToolLabel . ($fwActive ? __('servers.firewall_sub_active') : __('servers.firewall_sub_inactive'))" :subtitle="$fwToolLabel . ($fwActive ? __('servers.firewall_sub_active') : __('servers.firewall_sub_inactive'))"
:padded="false"> :padded="false">
@if ($fwManageable) {{-- Adding rules to an INACTIVE firewall would suggest they take effect gate on active. --}}
@if ($fwManageable && $fwActive)
@can('manage-network') @can('manage-network')
<x-slot:actions> <x-slot:actions>
<x-modal-trigger variant="accent" size="sm" <x-modal-trigger variant="accent" size="sm"
@ -421,11 +429,14 @@
<x-icon name="alert" class="h-4 w-4 shrink-0 text-warning" /> <x-icon name="alert" class="h-4 w-4 shrink-0 text-warning" />
<p class="font-mono text-[11px] text-ink-3">{{ __('servers.firewall_read_error') }}</p> <p class="font-mono text-[11px] text-ink-3">{{ __('servers.firewall_read_error') }}</p>
</div> </div>
@elseif ($fwTool === 'firewalld' && ! $fwActive) @elseif (! $fwActive)
{{-- Inactive firewall (ufw OR firewalld): rules are not enforced, so showing the
rules table would mislead compact hint + Aktivieren instead. --}}
<div class="flex flex-wrap items-center justify-between gap-3 px-4 py-4 sm:px-5"> <div class="flex flex-wrap items-center justify-between gap-3 px-4 py-4 sm:px-5">
<p class="font-mono text-[11px] text-ink-3">{{ __('servers.firewalld_inactive') }}</p> <p class="font-mono text-[11px] text-ink-3">{{ __('servers.firewall_inactive_hint', ['tool' => $fwToolLabel]) }}</p>
@can('manage-network') @can('manage-network')
<x-modal-trigger variant="secondary" size="sm" <x-modal-trigger variant="secondary" size="sm"
wire:loading.attr="disabled" wire:target="reloadSnapshot"
component="modals.hardening-action" :arguments="['serverId' => $server->id, 'action' => 'firewall', 'enable' => true]"> component="modals.hardening-action" :arguments="['serverId' => $server->id, 'action' => 'firewall', 'enable' => true]">
{{ __('common.enable') }} {{ __('common.enable') }}
</x-modal-trigger> </x-modal-trigger>

View File

@ -36,7 +36,9 @@ class SshKeyProvisionModalTest extends TestCase
->assertSet('done', true) ->assertSet('done', true)
->assertSet('ok', true) ->assertSet('ok', true)
->assertSet('privateKey', 'PRIVATE-PEM') ->assertSet('privateKey', 'PRIVATE-PEM')
->assertDispatched('hardeningApplied') // credentialChanged triggers the full page reload on Show (covers the former
// redundant hardeningApplied, which now does only the light security re-read).
->assertDispatched('credentialChanged')
->assertSee('PRIVATE-PEM'); ->assertSee('PRIVATE-PEM');
} }

View File

@ -0,0 +1,160 @@
<?php
namespace Tests\Feature;
use App\Livewire\Servers\Show;
use App\Livewire\ServerSwitcher;
use App\Models\Server;
use App\Models\User;
use App\Services\Fail2banService;
use App\Services\FirewallService;
use App\Services\HardeningService;
use App\Support\Os\OsDetector;
use App\Support\Os\OsProfile;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use Mockery;
use Tests\TestCase;
/**
* UI batch fixes from the fresh-VM test round:
* - server switcher shows an explanatory empty state (with a role-gated add-server CTA)
* instead of an empty dropdown when the fleet has no servers;
* - an INACTIVE firewall hides the misleading rules box + "Regel hinzufügen" and shows
* the tool-agnostic inactive hint instead (previously ufw fell through to the rules box);
* - `hardeningApplied` re-reads only the security state in place the page must NOT
* flip back to the skeleton (ready stays true);
* - the hardening guard messages fit the modal's failure box unclipped (limit 400).
*/
class UiBatchFixesTest extends TestCase
{
use RefreshDatabase;
protected function tearDown(): void
{
Mockery::close();
parent::tearDown();
}
private function admin(): User
{
return User::factory()->create(['must_change_password' => false]);
}
private function viewer(): User
{
return User::factory()->create(['must_change_password' => false, 'role' => 'viewer']);
}
private function server(): Server
{
return Server::create(['name' => 's1', 'ip' => '10.0.0.9', 'ssh_port' => 22, 'status' => 'online']);
}
/** @return array<string, mixed> */
private function ufwState(bool $active): array
{
return [
'tool' => 'ufw', 'supported' => true, 'installed' => true, 'active' => $active,
'manageable' => true, 'readOnly' => false, 'readError' => false,
'defaults' => ['incoming' => 'deny', 'outgoing' => 'allow'],
'rules' => [['num' => 1, 'to' => '22/tcp', 'action' => 'ALLOW', 'from' => 'Anywhere', 'v6' => false]],
];
}
public function test_switcher_shows_empty_state_with_cta_for_admin(): void
{
Livewire::actingAs($this->admin())
->test(ServerSwitcher::class)
->assertSee(__('servers.switcher_no_server'))
->assertSee(__('servers.switcher_empty_title'))
->assertSee(__('servers.switcher_empty_hint'))
->assertSee(__('servers.add_server'));
}
public function test_switcher_empty_state_hides_cta_for_viewer(): void
{
Livewire::actingAs($this->viewer())
->test(ServerSwitcher::class)
->assertSee(__('servers.switcher_empty_title'))
->assertDontSee(__('servers.add_server'));
}
public function test_switcher_with_servers_has_no_empty_state(): void
{
$this->server();
Livewire::actingAs($this->admin())
->test(ServerSwitcher::class)
->assertSee('s1')
->assertDontSee(__('servers.switcher_empty_title'));
}
public function test_inactive_ufw_shows_hint_and_hides_rules_and_add_rule(): void
{
Livewire::actingAs($this->admin())
->test(Show::class, ['server' => $this->server()])
->set('ready', true)
->set('firewall', $this->ufwState(active: false))
->assertSee(__('servers.firewall_inactive_hint', ['tool' => 'UFW']))
// The add-rule button label is just "Regel" (substring of the panel title), so assert
// on the trigger's unique modal component name instead of the visible label.
->assertDontSee('modals.firewall-rule', escape: false)
->assertDontSee('22/tcp');
}
public function test_active_ufw_shows_rules_and_add_rule(): void
{
Livewire::actingAs($this->admin())
->test(Show::class, ['server' => $this->server()])
->set('ready', true)
->set('firewall', $this->ufwState(active: true))
->assertSee('modals.firewall-rule', escape: false)
->assertSee('22/tcp')
->assertDontSee(__('servers.firewall_inactive_hint', ['tool' => 'UFW']));
}
public function test_hardening_applied_reloads_security_in_place_without_skeleton(): void
{
$server = $this->server();
$profile = new OsProfile(
family: 'debian', id: 'debian', prettyName: 'Debian 12',
packageManager: 'apt', firewallTool: 'ufw', serviceManager: 'systemd',
);
$detector = Mockery::mock(OsDetector::class);
$detector->shouldReceive('detect')->andReturn($profile);
$this->instance(OsDetector::class, $detector);
$hardening = Mockery::mock(HardeningService::class);
$hardening->shouldReceive('state')->andReturn([]);
$this->instance(HardeningService::class, $hardening);
$firewall = Mockery::mock(FirewallService::class);
$firewall->shouldReceive('status')->andReturn($this->ufwState(active: true));
$this->instance(FirewallService::class, $firewall);
$fail2ban = Mockery::mock(Fail2banService::class);
$fail2ban->shouldReceive('status')->andReturn(['supported' => false]);
$this->instance(Fail2banService::class, $fail2ban);
Livewire::actingAs($this->admin())
->test(Show::class, ['server' => $server])
->set('ready', true)
->dispatch('hardeningApplied')
->assertSet('ready', true)
->assertSet('firewall.active', true);
}
public function test_root_lockout_guard_message_fits_the_modal_output_limit(): void
{
foreach (['de', 'en'] as $locale) {
$msg = trans('backend.ssh_root_self_lockout', [], $locale);
$this->assertLessThanOrEqual(
400,
mb_strlen($msg),
"backend.ssh_root_self_lockout ({$locale}) must fit the modal's 400-char failure box unclipped"
);
}
}
}

View File

@ -53,6 +53,25 @@ bold() { printf '\033[1m%s\033[0m\n' "$*"; }
info() { printf ' %s\n' "$*"; } info() { printf ' %s\n' "$*"; }
die() { printf '\033[31m x %s\033[0m\n' "$*" >&2; exit 1; } die() { printf '\033[31m x %s\033[0m\n' "$*" >&2; exit 1; }
# Detail-Log fuer laute Schritte: git pull hier, apt/build/up/migrate in install.sh — via
# CLUSEV_INSTALL_LOG geerbt landet alles in EINEM install.log am Repo-Root (nicht run/ —
# container-beschreibbarer Bind-Mount, siehe watch.sh). Bei Fehlern werden die letzten
# Zeilen gezeigt; das vollstaendige Log bleibt liegen. (Der Re-Exec nach einem
# Self-Update leert das Log erneut — unkritisch, der Pull ist dann schon dokumentiert.)
# Symlink-sicheres Anlegen (Repo-Root gehoert dem unprivilegierten clusev-User; ein plain
# `: >` als root wuerde einem vorgelegten Symlink folgen): mktemp + atomares rename ersetzt
# einen Link selbst und folgt ihm nie.
init_log_file() {
local path="$1" dir tmp
dir="$(dirname -- "$path")"
tmp="$(mktemp "${dir}/.install-log.XXXXXX" 2>/dev/null)" || return 1
chmod 0644 "$tmp" 2>/dev/null || true
mv -fT -- "$tmp" "$path" 2>/dev/null || { rm -f "$tmp" 2>/dev/null; return 1; }
}
LOG_FILE="${REPO_DIR}/install.log"
init_log_file "$LOG_FILE" || LOG_FILE=/dev/null
export CLUSEV_INSTALL_LOG="$LOG_FILE"
[ "$(id -u)" = 0 ] || die "Bitte mit sudo ausfuehren: sudo ./update.sh" [ "$(id -u)" = 0 ] || die "Bitte mit sudo ausfuehren: sudo ./update.sh"
[ -f docker-compose.prod.yml ] || die "Keine Clusev-Installation hier (docker-compose.prod.yml fehlt)." [ -f docker-compose.prod.yml ] || die "Keine Clusev-Installation hier (docker-compose.prod.yml fehlt)."
[ -x ./install.sh ] || [ -f ./install.sh ] || die "install.sh fehlt — Repo unvollstaendig." [ -x ./install.sh ] || [ -f ./install.sh ] || die "install.sh fehlt — Repo unvollstaendig."
@ -83,8 +102,9 @@ if [ "${CLUSEV_UPDATE_REEXEC:-0}" != 1 ]; then
fi fi
before_hash="$(sha256sum -- "$0" | cut -d' ' -f1)" before_hash="$(sha256sum -- "$0" | cut -d' ' -f1)"
info "Hole Aktualisierungen (git pull --ff-only) ..." info "Hole Aktualisierungen (git pull --ff-only) ..."
timeout 300 git pull "${pull_args[@]}" \ timeout 300 git pull "${pull_args[@]}" >> "$LOG_FILE" 2>&1 \
|| die "git pull fehlgeschlagen — Timeout, fehlende Zugangsdaten zum privaten Repo, lokale Aenderungen oder divergierter Branch. Mit 'git status' und dem Repo-Zugriff (Token im origin-Remote) pruefen und erneut versuchen." || { tail -n 40 "$LOG_FILE" 2>/dev/null | sed 's/^/ /' >&2; die "git pull fehlgeschlagen — Timeout, fehlende Zugangsdaten zum privaten Repo, lokale Aenderungen oder divergierter Branch. Mit 'git status' und dem Repo-Zugriff (Token im origin-Remote) pruefen und erneut versuchen. Details: ${LOG_FILE}"; }
info "Codestand aktuell ($(git rev-parse --short=7 HEAD 2>/dev/null || echo '?'))"
after_hash="$(sha256sum -- "$0" | cut -d' ' -f1)" after_hash="$(sha256sum -- "$0" | cut -d' ' -f1)"
# 3. Verify the pulled HEAD is signed by a TRUSTED maintainer key BEFORE anything acts on it # 3. Verify the pulled HEAD is signed by a TRUSTED maintainer key BEFORE anything acts on it
@ -124,8 +144,8 @@ if [ "${CLUSEV_UPDATE_REEXEC:-0}" != 1 ]; then
die "Der allowed-signers Trust-Anchor (${signers}) ist git-versioniert und damit ueber einen Pull austauschbar — Update abgebrochen. Die Datei muss untracked/gitignored sein (siehe .clusev-allowed-signers) oder ausserhalb des Arbeitsbaums liegen." die "Der allowed-signers Trust-Anchor (${signers}) ist git-versioniert und damit ueber einen Pull austauschbar — Update abgebrochen. Die Datei muss untracked/gitignored sein (siehe .clusev-allowed-signers) oder ausserhalb des Arbeitsbaums liegen."
fi fi
info "Verifiziere Signatur des gepullten Commits (${signers}) ..." info "Verifiziere Signatur des gepullten Commits (${signers}) ..."
git -c gpg.ssh.allowedSignersFile="$signers" verify-commit HEAD \ git -c gpg.ssh.allowedSignersFile="$signers" verify-commit HEAD >> "$LOG_FILE" 2>&1 \
|| die "Signaturpruefung fehlgeschlagen — HEAD ist NICHT von einem vertrauten Maintainer-Schluessel signiert. Update abgebrochen (moegliche Kompromittierung von Origin oder Transport). Der Stack laeuft unveraendert weiter." || die "Signaturpruefung fehlgeschlagen — HEAD ist NICHT von einem vertrauten Maintainer-Schluessel signiert. Update abgebrochen (moegliche Kompromittierung von Origin oder Transport). Der Stack laeuft unveraendert weiter. Details: ${LOG_FILE}"
info "Signatur ok." info "Signatur ok."
else else
info "Hinweis: Commit-Signaturpruefung nicht konfiguriert (CLUSEV_ALLOWED_SIGNERS bzw. .clusev-allowed-signers) — uebersprungen." info "Hinweis: Commit-Signaturpruefung nicht konfiguriert (CLUSEV_ALLOWED_SIGNERS bzw. .clusev-allowed-signers) — uebersprungen."