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
parent
6f8f5dbc2b
commit
8d7f2da8d0
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue