fix(ssh): best-effort audit + exception-safe switch/verify and modal run(); lock serverId

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
feat/v1-foundation
boban 2026-06-14 22:16:54 +02:00
parent 8d7f2da8d0
commit 588f5aee2f
3 changed files with 59 additions and 17 deletions

View File

@ -4,6 +4,7 @@ namespace App\Livewire\Modals;
use App\Models\Server;
use App\Services\SshKeyProvisioner;
use Livewire\Attributes\Locked;
use LivewireUI\Modal\ModalComponent;
/**
@ -13,6 +14,7 @@ use LivewireUI\Modal\ModalComponent;
*/
class SshKeyProvision extends ModalComponent
{
#[Locked]
public int $serverId;
public string $serverName = '';
@ -51,7 +53,18 @@ class SshKeyProvision extends ModalComponent
return;
}
try {
$result = $provisioner->enableKeyOnlyAccess($server);
} catch (\Throwable $e) {
$this->done = true;
$this->ok = false;
$this->error = $e->getMessage();
$this->dispatch('hardeningApplied');
$this->dispatch('credentialChanged');
$this->dispatch('notify', message: __('modals.ssh_key_provision.notify_failed'));
return;
}
$this->done = true;
$this->ok = $result['ok'];
@ -59,8 +72,6 @@ class SshKeyProvision extends ModalComponent
$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');

View File

@ -53,20 +53,30 @@ class SshKeyProvisioner
return ['ok' => false, 'error' => $e->getMessage()];
}
// 2) Snapshot the current credential, then switch to the new key.
// 2) Snapshot the current credential BEFORE the switch.
$snapshot = [
'auth_type' => $cred->auth_type,
'secret' => $cred->secret,
'passphrase' => $cred->passphrase,
];
// 3) Switch + verify — wrapped so a DB/SSH exception triggers best-effort restore.
try {
$cred->forceFill(['auth_type' => 'key', 'secret' => $privateKey, 'passphrase' => null])->save();
// 3) Verify key login works (password auth is still on, so this is risk-free).
$server->load('credential');
$probe = $this->fleet->testConnection($server);
} catch (Throwable $e) {
// a DB/SSH error during switch+verify — best-effort restore, never half-switched.
try {
$cred->forceFill($snapshot)->save();
} catch (Throwable) {
}
return ['ok' => false, 'error' => $e->getMessage()];
}
if (! $probe['ok']) {
// Roll back — never leave the panel on an unverified credential.
$cred->forceFill($snapshot)->save();
return ['ok' => false, 'error' => __('backend.ssh_key_verify_failed', ['error' => $probe['error'] ?? ''])];
@ -101,6 +111,7 @@ class SshKeyProvisioner
private function audit(Server $server, string $action): void
{
try {
AuditEvent::create([
'user_id' => Auth::id(),
'server_id' => $server->id,
@ -109,5 +120,8 @@ class SshKeyProvisioner
'target' => $server->name,
'ip' => request()->ip(),
]);
} catch (Throwable) {
// auditing must never abort a verified cutover
}
}
}

View File

@ -57,4 +57,21 @@ class SshKeyProvisionModalTest extends TestCase
->assertSet('privateKey', null)
->assertSee('verify failed');
}
public function test_run_surfaces_a_thrown_service_exception_as_an_error(): 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()->andThrow(new \RuntimeException('ssh dropped'));
app()->instance(SshKeyProvisioner::class, $svc);
Livewire::test(SshKeyProvision::class, ['serverId' => $server->id])
->call('run')
->assertSet('done', true)
->assertSet('ok', false)
->assertSet('privateKey', null)
->assertSee('ssh dropped');
}
}