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">
- @foreach ($servers as $server) + @forelse ($servers as $server) - @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. --}} +
+ +

{{ __('servers.switcher_empty_title') }}

+

{{ __('servers.switcher_empty_hint') }}

+ @can('manage-fleet') +
+ + + {{ __('servers.add_server') }} + +
+ @endcan +
+ @endforelse
diff --git a/resources/views/livewire/servers/groups.blade.php b/resources/views/livewire/servers/groups.blade.php index ccfd6e0..eaf7913 100644 --- a/resources/views/livewire/servers/groups.blade.php +++ b/resources/views/livewire/servers/groups.blade.php @@ -15,10 +15,9 @@

{{ __('groups.title') }}

{{ __('groups.subtitle') }}

- + {{ __('groups.back_to_servers') }} - + {{-- New group --}} diff --git a/resources/views/livewire/servers/index.blade.php b/resources/views/livewire/servers/index.blade.php index 188e742..78b31ab 100644 --- a/resources/views/livewire/servers/index.blade.php +++ b/resources/views/livewire/servers/index.blade.php @@ -13,10 +13,9 @@
@can('manage-fleet') - + {{ __('groups.title') }} - + {{ __('servers.add_server') }} diff --git a/resources/views/livewire/servers/show.blade.php b/resources/views/livewire/servers/show.blade.php index 24f95d2..2588ca3 100644 --- a/resources/views/livewire/servers/show.blade.php +++ b/resources/views/livewire/servers/show.blade.php @@ -279,6 +279,10 @@ + {{-- Busy cue while the post-toggle security re-read runs (up to a few SSH round-trips). --}} + + +
@foreach ($hardening as $check) @php @@ -338,7 +342,10 @@ @endcan @else @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). --}} {{ $check['featureOn'] ? __('common.disable') : __('common.enable') }} @@ -405,7 +412,8 @@ - @if ($fwManageable) + {{-- Adding rules to an INACTIVE firewall would suggest they take effect — gate on active. --}} + @if ($fwManageable && $fwActive) @can('manage-network')

{{ __('servers.firewall_read_error') }}

- @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. --}}
-

{{ __('servers.firewalld_inactive') }}

+

{{ __('servers.firewall_inactive_hint', ['tool' => $fwToolLabel]) }}

@can('manage-network') {{ __('common.enable') }} diff --git a/tests/Feature/SshKeyProvisionModalTest.php b/tests/Feature/SshKeyProvisionModalTest.php index b59e72a..44aba28 100644 --- a/tests/Feature/SshKeyProvisionModalTest.php +++ b/tests/Feature/SshKeyProvisionModalTest.php @@ -36,7 +36,9 @@ class SshKeyProvisionModalTest extends TestCase ->assertSet('done', true) ->assertSet('ok', true) ->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'); } diff --git a/tests/Feature/UiBatchFixesTest.php b/tests/Feature/UiBatchFixesTest.php new file mode 100644 index 0000000..a3a29fc --- /dev/null +++ b/tests/Feature/UiBatchFixesTest.php @@ -0,0 +1,160 @@ +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 */ + 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" + ); + } + } +} diff --git a/update.sh b/update.sh index efd6d5c..dcddcbb 100755 --- a/update.sh +++ b/update.sh @@ -53,6 +53,25 @@ bold() { printf '\033[1m%s\033[0m\n' "$*"; } info() { printf ' %s\n' "$*"; } 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" [ -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." @@ -83,8 +102,9 @@ if [ "${CLUSEV_UPDATE_REEXEC:-0}" != 1 ]; then fi before_hash="$(sha256sum -- "$0" | cut -d' ' -f1)" info "Hole Aktualisierungen (git pull --ff-only) ..." - timeout 300 git pull "${pull_args[@]}" \ - || 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." + timeout 300 git pull "${pull_args[@]}" >> "$LOG_FILE" 2>&1 \ + || { 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)" # 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." fi info "Verifiziere Signatur des gepullten Commits (${signers}) ..." - git -c gpg.ssh.allowedSignersFile="$signers" verify-commit HEAD \ - || 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." + 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. Details: ${LOG_FILE}" info "Signatur ok." else info "Hinweis: Commit-Signaturpruefung nicht konfiguriert (CLUSEV_ALLOWED_SIGNERS bzw. .clusev-allowed-signers) — uebersprungen."