diff --git a/.gitignore b/.gitignore index a20788e..8ea5635 100644 --- a/.gitignore +++ b/.gitignore @@ -47,6 +47,7 @@ yarn-error.log # 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) /update.log +/install.log # 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 /.phase.* diff --git a/app/Livewire/Modals/HardeningAction.php b/app/Livewire/Modals/HardeningAction.php index ba5f7f0..ebbaca8 100644 --- a/app/Livewire/Modals/HardeningAction.php +++ b/app/Livewire/Modals/HardeningAction.php @@ -95,8 +95,10 @@ class HardeningAction extends ModalComponent $this->done = true; $this->ok = $result['ok']; - // Clean result only — no raw command output on success; a short reason on failure. - $this->output = $this->ok ? '' : Str::limit(trim($result['output']) ?: __('modals.hardening_action.error_unknown'), 200); + // Clean result only — no raw command output on success; a bounded reason on failure. 400, + // 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([ 'user_id' => Auth::id(), diff --git a/app/Livewire/Modals/SshKeyProvision.php b/app/Livewire/Modals/SshKeyProvision.php index 8e5226e..2c43ed1 100644 --- a/app/Livewire/Modals/SshKeyProvision.php +++ b/app/Livewire/Modals/SshKeyProvision.php @@ -66,7 +66,8 @@ class SshKeyProvision extends ModalComponent $this->done = true; $this->ok = false; $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('notify', message: __('modals.ssh_key_provision.notify_failed')); @@ -79,7 +80,6 @@ class SshKeyProvision extends ModalComponent $this->publicKey = $result['publicKey'] ?? null; $this->error = $result['ok'] ? null : ($result['error'] ?? __('modals.ssh_key_provision.error_unknown')); - $this->dispatch('hardeningApplied'); $this->dispatch('credentialChanged'); $this->dispatch('notify', message: $this->ok diff --git a/app/Livewire/Servers/Show.php b/app/Livewire/Servers/Show.php index f275db3..cbd531f 100644 --- a/app/Livewire/Servers/Show.php +++ b/app/Livewire/Servers/Show.php @@ -350,11 +350,45 @@ class Show extends Component * checklist + firewall row reflect the new state. */ #[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->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 = []; + } } /** diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 9324d0b..588512e 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -96,6 +96,20 @@ services: redis: 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 # (the Clusev "host" terminal). Internal only; authorizes the single-use token before opening a PTY. terminal: diff --git a/install.sh b/install.sh index a4ccab9..e1a1b1e 100755 --- a/install.sh +++ b/install.sh @@ -44,6 +44,61 @@ set_stage() { 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 ──────────────────────────────────────────────────── # force_kv: always write (config/derived). set_kv: write only when empty/placeholder. 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 info "Docker nicht gefunden — Installation aus dem offiziellen Docker-apt-Repo ..." export DEBIAN_FRONTEND=noninteractive - apt-get update - apt-get install -y ca-certificates curl + run_logged "apt-Paketquellen aktualisieren" apt-get update + run_logged "Basis-Pakete installieren" apt-get install -y ca-certificates curl install -m 0755 -d /etc/apt/keyrings curl -fsSL "https://download.docker.com/linux/${OS_ID}/gpg" -o /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' \ "$(dpkg --print-architecture)" "$OS_ID" "$codename" \ > /etc/apt/sources.list.d/docker.list - apt-get update - apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin + run_logged "Docker-Paketquelle einlesen" apt-get update + 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 docker compose version >/dev/null 2>&1 || die "Docker-Installation fehlgeschlagen (docker compose weiterhin nicht verfuegbar)." 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)" ;; *) info "Lokaler Build" ;; esac -if [ "${CLUSEV_PULL:-0}" = "1" ]; then $COMPOSE pull; else $COMPOSE build; fi -info "Image bereit" +if [ "${CLUSEV_PULL:-0}" = "1" ]; then + 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-)" case "$cur_key" in ""|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)" [ -n "$NEW_KEY" ] || die "APP_KEY konnte nicht erzeugt werden." force_kv APP_KEY "$NEW_KEY"; info "APP_KEY erzeugt" ;; @@ -313,11 +372,10 @@ esac # ── [5/9] stack ────────────────────────────────────────────────────── phase 5/9 "Stack starten" 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 # 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 -info "Container gestartet" # ── host watchers (restart + update sentinels) — idempotent, best-effort ──── # Installs the scoped systemd units so the dashboard's "Jetzt neu starten" and @@ -408,15 +466,15 @@ done # ── [7/9] migrate + caches ─────────────────────────────────────────── phase 7/9 "Migrationen" set_stage migrate -$COMPOSE exec -T -u app app php artisan migrate --force -$COMPOSE exec -T -u app app php artisan config:cache >/dev/null -$COMPOSE exec -T -u app app php artisan route:cache >/dev/null -info "Schema migriert; Caches gebaut" +run_logged "Schema migrieren" $COMPOSE exec -T -u app app php artisan migrate --force +run_logged "Config-Cache bauen" $COMPOSE exec -T -u app app php artisan config:cache +run_logged "Routen-Cache bauen" $COMPOSE exec -T -u app app php artisan route:cache # ── [8/9] first admin ──────────────────────────────────────────────── phase 8/9 "Admin anlegen" email_args=() [ -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)" 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)" diff --git a/lang/de/backend.php b/lang/de/backend.php index 94ad94d..d3f5fab 100644 --- a/lang/de/backend.php +++ b/lang/de/backend.php @@ -26,7 +26,7 @@ return [ // ── 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_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_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.', diff --git a/lang/de/servers.php b/lang/de/servers.php index fb3e20e..ca01f3a 100644 --- a/lang/de/servers.php +++ b/lang/de/servers.php @@ -102,7 +102,7 @@ return [ 'firewall_sub_inactive' => ' · inaktiv', 'firewall_add_rule' => 'Regel', '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_incoming' => 'eingehend: :value', 'firewall_outgoing' => 'ausgehend: :value', @@ -155,6 +155,8 @@ return [ // ── Switcher ────────────────────────────────────────────────────────── 'switcher_no_server' => 'Kein Server', + 'switcher_empty_title' => 'Noch keine Server verbunden.', + 'switcher_empty_hint' => 'Verbundene Server erscheinen hier.', // ── Notifications (Show component) ──────────────────────────────────── 'notify_access_locked' => 'Zugang gesperrt.', diff --git a/lang/en/backend.php b/lang/en/backend.php index bd26a00..0663d88 100644 --- a/lang/en/backend.php +++ b/lang/en/backend.php @@ -26,7 +26,7 @@ return [ // ── 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_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_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.', diff --git a/lang/en/servers.php b/lang/en/servers.php index 8716389..5bc034c 100644 --- a/lang/en/servers.php +++ b/lang/en/servers.php @@ -102,7 +102,7 @@ return [ 'firewall_sub_inactive' => ' · inactive', 'firewall_add_rule' => 'Rule', '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_incoming' => 'incoming: :value', 'firewall_outgoing' => 'outgoing: :value', @@ -155,6 +155,8 @@ return [ // ── Switcher ────────────────────────────────────────────────────────── 'switcher_no_server' => 'No server', + 'switcher_empty_title' => 'No servers connected yet.', + 'switcher_empty_hint' => 'Connected servers will appear here.', // ── Notifications (Show component) ──────────────────────────────────── 'notify_access_locked' => 'Access locked.', diff --git a/resources/views/components/command-palette.blade.php b/resources/views/components/command-palette.blade.php index c75719d..4b8460d 100644 --- a/resources/views/components/command-palette.blade.php +++ b/resources/views/components/command-palette.blade.php @@ -25,11 +25,11 @@ ['shell.nav_posture', '/posture', 'p', 'operate'], ['shell.nav_patch', '/patch', 'u', 'operate'], ['shell.nav_certs', '/certs', 'r', 'operate'], - ['shell.nav_health', '/health', null, 'operate'], + ['shell.nav_health', '/health', 'o', 'operate'], // Account ['shell.nav_settings', '/settings', 'e', null], ['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_help', config('clusev.docs_url'), 'h', null], ]; diff --git a/resources/views/livewire/server-switcher.blade.php b/resources/views/livewire/server-switcher.blade.php index 756dc47..84985ae 100644 --- a/resources/views/livewire/server-switcher.blade.php +++ b/resources/views/livewire/server-switcher.blade.php @@ -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" role="listbox">
{{ __('servers.switcher_empty_title') }}
+{{ __('servers.switcher_empty_hint') }}
+ @can('manage-fleet') +{{ __('groups.subtitle') }}
- +{{ __('servers.firewall_read_error') }}
{{ __('servers.firewalld_inactive') }}
+{{ __('servers.firewall_inactive_hint', ['tool' => $fwToolLabel]) }}
@can('manage-network')