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
parent
b7e44ca3c2
commit
4248c83c42
|
|
@ -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.*
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 = [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
82
install.sh
82
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)"
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
<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"
|
||||
@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',
|
||||
|
|
@ -36,7 +36,24 @@
|
|||
<x-icon name="activity" class="h-3.5 w-3.5 shrink-0 text-accent" />
|
||||
@endif
|
||||
</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>
|
||||
|
|
|
|||
|
|
@ -15,10 +15,9 @@
|
|||
<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>
|
||||
</div>
|
||||
<a 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-btn :href="route('servers.index')" wire:navigate>
|
||||
<x-icon name="server" class="h-3.5 w-3.5" /> {{ __('groups.back_to_servers') }}
|
||||
</a>
|
||||
</x-btn>
|
||||
</div>
|
||||
|
||||
{{-- New group --}}
|
||||
|
|
|
|||
|
|
@ -13,10 +13,9 @@
|
|||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
@can('manage-fleet')
|
||||
<a 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-btn :href="route('servers.groups')" wire:navigate>
|
||||
<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-icon name="plus" class="h-3.5 w-3.5" />
|
||||
{{ __('servers.add_server') }}
|
||||
|
|
|
|||
|
|
@ -279,6 +279,10 @@
|
|||
</x-panel>
|
||||
|
||||
<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">
|
||||
@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). --}}
|
||||
<x-modal-trigger variant="secondary" size="sm" class="shrink-0"
|
||||
wire:loading.attr="disabled" wire:target="reloadSnapshot"
|
||||
component="modals.hardening-action"
|
||||
:arguments="['serverId' => $server->id, 'action' => $check['key'], 'enable' => ! $check['featureOn']]">
|
||||
{{ $check['featureOn'] ? __('common.disable') : __('common.enable') }}
|
||||
|
|
@ -405,7 +412,8 @@
|
|||
<x-panel :title="__('servers.firewall_title')"
|
||||
:subtitle="$fwToolLabel . ($fwActive ? __('servers.firewall_sub_active') : __('servers.firewall_sub_inactive'))"
|
||||
:padded="false">
|
||||
@if ($fwManageable)
|
||||
{{-- Adding rules to an INACTIVE firewall would suggest they take effect — gate on active. --}}
|
||||
@if ($fwManageable && $fwActive)
|
||||
@can('manage-network')
|
||||
<x-slot:actions>
|
||||
<x-modal-trigger variant="accent" size="sm"
|
||||
|
|
@ -421,11 +429,14 @@
|
|||
<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>
|
||||
</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">
|
||||
<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')
|
||||
<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]">
|
||||
{{ __('common.enable') }}
|
||||
</x-modal-trigger>
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
28
update.sh
28
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."
|
||||
|
|
|
|||
Loading…
Reference in New Issue