feat(ssh): provision modal + hardening-row action — confirm, run, reveal key once

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
feat/v1-foundation
boban 2026-06-14 22:09:59 +02:00
parent 6f8f5dbc2b
commit 8d7f2da8d0
8 changed files with 248 additions and 5 deletions

View File

@ -0,0 +1,76 @@
<?php
namespace App\Livewire\Modals;
use App\Models\Server;
use App\Services\SshKeyProvisioner;
use LivewireUI\Modal\ModalComponent;
/**
* Confirm + run the safe "generate key → install → verify → switch credential → disable
* password login" flow (R5). On success the freshly generated private key is revealed ONCE
* (download client-side; it is never re-fetchable). The key is also kept encrypted in the vault.
*/
class SshKeyProvision extends ModalComponent
{
public int $serverId;
public string $serverName = '';
public bool $done = false;
public bool $ok = false;
public ?string $privateKey = null;
public ?string $publicKey = null;
public ?string $error = null;
public function mount(int $serverId): void
{
$this->serverId = $serverId;
$this->serverName = Server::find($serverId)?->name ?? '';
}
public static function modalMaxWidth(): string
{
return 'lg';
}
public function run(SshKeyProvisioner $provisioner): void
{
if ($this->done) {
return;
}
$server = Server::find($this->serverId);
if (! $server) {
$this->error = __('common.server_not_found');
return;
}
$result = $provisioner->enableKeyOnlyAccess($server);
$this->done = true;
$this->ok = $result['ok'];
$this->privateKey = $result['privateKey'] ?? null;
$this->publicKey = $result['publicKey'] ?? null;
$this->error = $result['ok'] ? null : ($result['error'] ?? __('modals.ssh_key_provision.error_unknown'));
// A credential switch and/or a hardening change happened whenever a key was generated or
// password auth was turned off — refresh the page state either way.
$this->dispatch('hardeningApplied');
$this->dispatch('credentialChanged');
$this->dispatch('notify', message: $this->ok
? __('modals.ssh_key_provision.notify_ok', ['server' => $this->serverName])
: __('modals.ssh_key_provision.notify_failed'));
}
public function render()
{
return view('livewire.modals.ssh-key-provision');
}
}

View File

@ -164,6 +164,26 @@ return [
'notify_failed' => 'Aktualisierung fehlgeschlagen.',
],
// ── SSH key provision ─────────────────────────────────────────────────
'ssh_key_provision' => [
'title' => 'SSH-Key erstellen & Passwort-Login deaktivieren',
'subtitle' => 'Für :server wird ein SSH-Key erzeugt und installiert; Passwort-Login wird erst danach abgeschaltet.',
'steps_title' => 'Ablauf',
'step_generate' => 'Key erzeugen und auf dem Server hinterlegen.',
'step_verify' => 'Key-Login prüfen — Passwort-Login bleibt bis dahin aktiv.',
'step_disable' => 'Erst nach erfolgreicher Prüfung: Passwort-Login deaktivieren.',
'safety_note' => 'Schlägt die Prüfung fehl, bleibt Passwort-Login aktiv — kein Aussperren.',
'confirm' => 'Erstellen & deaktivieren',
'running' => 'Wird ausgeführt …',
'result_ok' => 'Passwort-Login deaktiviert — Zugang nur noch per Key.',
'key_warning' => 'Diesen Private-Key jetzt sichern — er wird nur einmal angezeigt.',
'download' => 'Key herunterladen',
'done' => 'Fertig',
'notify_ok' => 'SSH-Key eingerichtet, Passwort-Login deaktiviert (:server).',
'notify_failed' => 'SSH-Key-Einrichtung fehlgeschlagen.',
'error_unknown' => 'Unbekannter Fehler.',
],
// ── Generic confirm dialog defaults (ConfirmAction) ───────────────────
'confirm_action' => [
'default_heading' => 'Aktion bestätigen',

View File

@ -85,6 +85,7 @@ return [
'check_secure' => 'sicher',
'check_open' => 'offen',
'fail2ban_configure' => 'fail2ban konfigurieren',
'ssh_key_provision_action' => 'Sicher deaktivieren',
// ── Show: firewall rules ──────────────────────────────────────────────
'firewall_title' => 'Firewall-Regeln',

View File

@ -163,6 +163,26 @@ return [
'notify_failed' => 'Update failed.',
],
// ── SSH key provision ─────────────────────────────────────────────────
'ssh_key_provision' => [
'title' => 'Create SSH key & disable password login',
'subtitle' => 'An SSH key will be generated and installed on :server; password login is disabled only afterwards.',
'steps_title' => 'Process',
'step_generate' => 'Generate key and store it on the server.',
'step_verify' => 'Verify key login — password login stays active until then.',
'step_disable' => 'Only after successful verification: disable password login.',
'safety_note' => 'If verification fails, password login remains active — no lockout.',
'confirm' => 'Create & disable',
'running' => 'Running…',
'result_ok' => 'Password login disabled — access via key only.',
'key_warning' => 'Save this private key now — it is shown only once.',
'download' => 'Download key',
'done' => 'Done',
'notify_ok' => 'SSH key set up, password login disabled (:server).',
'notify_failed' => 'SSH key setup failed.',
'error_unknown' => 'Unknown error.',
],
// ── Generic confirm dialog defaults (ConfirmAction) ───────────────────
'confirm_action' => [
'default_heading' => 'Confirm action',

View File

@ -85,6 +85,7 @@ return [
'check_secure' => 'secure',
'check_open' => 'open',
'fail2ban_configure' => 'Configure fail2ban',
'ssh_key_provision_action' => 'Disable safely',
// ── Show: firewall rules ──────────────────────────────────────────────
'firewall_title' => 'Firewall rules',

View File

@ -0,0 +1,58 @@
<div class="p-5 sm:p-6">
<div class="flex items-start gap-3.5">
<span class="grid h-10 w-10 shrink-0 place-items-center rounded-md border border-accent/30 bg-accent/10 text-accent-text">
<x-icon name="shield" class="h-5 w-5" />
</span>
<div class="min-w-0 flex-1">
<h2 class="font-display text-base font-semibold text-ink">{{ __('modals.ssh_key_provision.title') }}</h2>
<p class="mt-1 text-sm leading-relaxed text-ink-2">{{ __('modals.ssh_key_provision.subtitle', ['server' => $serverName]) }}</p>
</div>
</div>
@if (! $done)
<div class="mt-4 rounded-md border border-line bg-inset p-4">
<p class="font-mono text-[11px] uppercase tracking-wider text-ink-3">{{ __('modals.ssh_key_provision.steps_title') }}</p>
<ol class="mt-2 list-decimal space-y-1 pl-4 text-sm text-ink-2">
<li>{{ __('modals.ssh_key_provision.step_generate') }}</li>
<li>{{ __('modals.ssh_key_provision.step_verify') }}</li>
<li>{{ __('modals.ssh_key_provision.step_disable') }}</li>
</ol>
<p class="mt-3 flex items-start gap-1.5 font-mono text-[11px] text-ink-4">
<x-icon name="shield" class="mt-0.5 h-3.5 w-3.5 shrink-0" />{{ __('modals.ssh_key_provision.safety_note') }}
</p>
</div>
<div class="mt-6 flex items-center justify-end gap-2">
<x-btn variant="secondary" wire:click="$dispatch('closeModal')">{{ __('common.cancel') }}</x-btn>
<x-btn variant="primary" wire:click="run" wire:target="run" wire:loading.attr="disabled">
<svg wire:loading wire:target="run" class="h-3.5 w-3.5 animate-spin" 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>
<span wire:loading.remove wire:target="run">{{ __('modals.ssh_key_provision.confirm') }}</span>
<span wire:loading wire:target="run">{{ __('modals.ssh_key_provision.running') }}</span>
</x-btn>
</div>
@else
@if ($ok)
<p class="mt-4 flex items-center gap-1.5 font-mono text-[11px] text-online"><x-icon name="lock" class="h-3.5 w-3.5 shrink-0" />{{ __('modals.ssh_key_provision.result_ok') }}</p>
@else
<p class="mt-4 flex items-start gap-1.5 font-mono text-[11px] text-warning"><x-icon name="alert" class="mt-0.5 h-3.5 w-3.5 shrink-0" />{{ $error }}</p>
@endif
@if ($privateKey)
<div class="mt-3 rounded-md border border-warning/30 bg-warning/10 p-3" x-data>
<p class="flex items-center gap-1.5 font-mono text-[11px] text-warning"><x-icon name="alert" class="h-3.5 w-3.5 shrink-0" />{{ __('modals.ssh_key_provision.key_warning') }}</p>
<textarea x-ref="pk" readonly rows="6" x-on:click="$el.select()"
class="mt-2 w-full rounded-md border border-line bg-void px-3 py-2 font-mono text-[10px] leading-relaxed text-ink-2 focus:outline-none">{{ $privateKey }}</textarea>
<div class="mt-2">
<x-btn variant="secondary" size="sm"
x-on:click="const b=new Blob([$refs.pk.value],{type:'text/plain'});const a=document.createElement('a');a.href=URL.createObjectURL(b);a.download='clusev-{{ \Illuminate\Support\Str::slug($serverName) ?: 'server' }}-id_ed25519';a.click();URL.revokeObjectURL(a.href)">
<x-icon name="folder" class="h-3.5 w-3.5" /> {{ __('modals.ssh_key_provision.download') }}
</x-btn>
</div>
</div>
@endif
<div class="mt-6 flex items-center justify-end">
<x-btn variant="primary" wire:click="$dispatch('closeModal')">{{ __('modals.ssh_key_provision.done') }}</x-btn>
</div>
@endif
</div>

View File

@ -224,11 +224,18 @@
<x-icon name="settings" class="h-3.5 w-3.5" />
</x-btn>
@endif
{{-- Uniform style across all rows (R10): always the bordered secondary button. --}}
<x-btn variant="secondary" size="sm" class="shrink-0"
wire:click="$dispatch('openModal', { component: 'modals.hardening-action', arguments: { serverId: {{ $server->id }}, action: '{{ $check['key'] }}', enable: {{ $check['featureOn'] ? 'false' : 'true' }} } })">
{{ $check['featureOn'] ? __('common.disable') : __('common.enable') }}
</x-btn>
@if ($check['key'] === 'ssh_password' && $check['featureOn'])
{{-- Safe auto-flow: generate+install a key, verify, switch the panel credential, THEN disable. --}}
<x-btn variant="secondary" size="sm" class="shrink-0"
wire:click="$dispatch('openModal', { component: 'modals.ssh-key-provision', arguments: { serverId: {{ $server->id }} } })">
{{ __('servers.ssh_key_provision_action') }}
</x-btn>
@else
<x-btn variant="secondary" size="sm" class="shrink-0"
wire:click="$dispatch('openModal', { component: 'modals.hardening-action', arguments: { serverId: {{ $server->id }}, action: '{{ $check['key'] }}', enable: {{ $check['featureOn'] ? 'false' : 'true' }} } })">
{{ $check['featureOn'] ? __('common.disable') : __('common.enable') }}
</x-btn>
@endif
@endif
</div>
@endforeach

View File

@ -0,0 +1,60 @@
<?php
namespace Tests\Feature;
use App\Livewire\Modals\SshKeyProvision;
use App\Models\Server;
use App\Models\User;
use App\Services\SshKeyProvisioner;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use Mockery;
use Tests\TestCase;
class SshKeyProvisionModalTest extends TestCase
{
use RefreshDatabase;
protected function tearDown(): void
{
Mockery::close();
parent::tearDown();
}
public function test_run_reveals_the_key_and_signals_a_reload_on_success(): void
{
$this->actingAs(User::factory()->create());
$server = Server::create(['name' => 'box', 'ip' => '10.0.0.9', 'ssh_port' => 22, 'status' => 'online']);
$svc = Mockery::mock(SshKeyProvisioner::class);
$svc->shouldReceive('enableKeyOnlyAccess')->once()
->andReturn(['ok' => true, 'privateKey' => 'PRIVATE-PEM', 'publicKey' => 'ssh-ed25519 AAAA']);
app()->instance(SshKeyProvisioner::class, $svc);
Livewire::test(SshKeyProvision::class, ['serverId' => $server->id])
->call('run')
->assertSet('done', true)
->assertSet('ok', true)
->assertSet('privateKey', 'PRIVATE-PEM')
->assertDispatched('hardeningApplied')
->assertSee('PRIVATE-PEM');
}
public function test_run_surfaces_an_error_without_revealing_a_key(): void
{
$this->actingAs(User::factory()->create());
$server = Server::create(['name' => 'box', 'ip' => '10.0.0.9', 'ssh_port' => 22, 'status' => 'online']);
$svc = Mockery::mock(SshKeyProvisioner::class);
$svc->shouldReceive('enableKeyOnlyAccess')->once()
->andReturn(['ok' => false, 'error' => 'verify failed']);
app()->instance(SshKeyProvisioner::class, $svc);
Livewire::test(SshKeyProvision::class, ['serverId' => $server->id])
->call('run')
->assertSet('done', true)
->assertSet('ok', false)
->assertSet('privateKey', null)
->assertSee('verify failed');
}
}