From b847b80ab638262c7db67af501ba8a1ce837869b Mon Sep 17 00:00:00 2001 From: boban Date: Sun, 14 Jun 2026 21:57:45 +0200 Subject: [PATCH] =?UTF-8?q?feat(ssh):=20SshKeyProvisioner=20=E2=80=94=20ge?= =?UTF-8?q?nerate+install=20key,=20verify,=20switch=20credential,=20then?= =?UTF-8?q?=20disable=20password=20(rollback-safe)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- app/Services/SshKeyProvisioner.php | 100 ++++++++++++++++++++++ lang/de/backend.php | 3 + lang/en/backend.php | 3 + tests/Feature/SshKeyProvisionerTest.php | 105 ++++++++++++++++++++++++ 4 files changed, 211 insertions(+) create mode 100644 app/Services/SshKeyProvisioner.php create mode 100644 tests/Feature/SshKeyProvisionerTest.php diff --git a/app/Services/SshKeyProvisioner.php b/app/Services/SshKeyProvisioner.php new file mode 100644 index 0000000..5bd8a24 --- /dev/null +++ b/app/Services/SshKeyProvisioner.php @@ -0,0 +1,100 @@ +credential; + + if (! $cred) { + return ['ok' => false, 'error' => __('backend.no_ssh_credential', ['server' => $server->name])]; + } + + $privateKey = null; + $publicKey = null; + + // Only generate + switch when the panel still authenticates with a password. If the + // credential is already a key, the operator just wants password auth turned off. + if ($cred->auth_type !== 'key') { + $key = EC::createKey('ed25519'); + $publicKey = $key->getPublicKey()->toString('OpenSSH', ['comment' => 'clusev-key']); + $privateKey = (string) $key->toString('OpenSSH'); + + // 1) Install the public key — additive, harmless while password auth is still on. + try { + $this->fleet->addAuthorizedKey($server, $publicKey); + } catch (Throwable $e) { + return ['ok' => false, 'error' => $e->getMessage()]; + } + + // 2) Snapshot the current credential, then switch to the new key. + $snapshot = [ + 'auth_type' => $cred->auth_type, + 'secret' => $cred->secret, + 'passphrase' => $cred->passphrase, + ]; + $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); + + 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'] ?? ''])]; + } + + $this->audit($server, 'ssh_key.autoprovision'); + } + + // 4) Disable password auth (the hardening guard passes now: credential is key + key installed). + $res = $this->hardening->apply($server, 'ssh_password', false); + + if (! $res['ok']) { + // The credential is already switched, so key access remains — just report the failure. + return ['ok' => false, 'error' => $res['output'], 'privateKey' => $privateKey, 'publicKey' => $publicKey]; + } + + return array_filter([ + 'ok' => true, + 'privateKey' => $privateKey, + 'publicKey' => $publicKey, + ], fn ($v) => $v !== null); + } + + private function audit(Server $server, string $action): void + { + AuditEvent::create([ + 'user_id' => Auth::id(), + 'server_id' => $server->id, + 'actor' => Auth::user()?->name ?? 'system', + 'action' => $action, + 'target' => $server->name, + 'ip' => request()->ip(), + ]); + } +} diff --git a/lang/de/backend.php b/lang/de/backend.php index 8c388e0..5dcf614 100644 --- a/lang/de/backend.php +++ b/lang/de/backend.php @@ -98,4 +98,7 @@ return [ // ── HardeningService: invalid action invariant (:action) ───────────── 'unknown_hardening' => 'Unbekannte Härtung: :action', + + // ── SshKeyProvisioner ───────────────────────────────────────────────── + 'ssh_key_verify_failed' => 'Key-Login konnte nicht bestätigt werden (:error) — Passwort-Login bleibt aktiv.', ]; diff --git a/lang/en/backend.php b/lang/en/backend.php index 623d327..fe588ef 100644 --- a/lang/en/backend.php +++ b/lang/en/backend.php @@ -98,4 +98,7 @@ return [ // ── HardeningService: invalid action invariant (:action) ───────────── 'unknown_hardening' => 'Unknown hardening action: :action', + + // ── SshKeyProvisioner ───────────────────────────────────────────────── + 'ssh_key_verify_failed' => 'Key login could not be verified (:error) — password login stays enabled.', ]; diff --git a/tests/Feature/SshKeyProvisionerTest.php b/tests/Feature/SshKeyProvisionerTest.php new file mode 100644 index 0000000..81716b8 --- /dev/null +++ b/tests/Feature/SshKeyProvisionerTest.php @@ -0,0 +1,105 @@ + 'box', 'ip' => '10.0.0.5', 'ssh_port' => 22, 'status' => 'online']); + $server->credential()->create(['username' => 'admin', 'auth_type' => 'password', 'secret' => 'pw-secret']); + + return $server->fresh('credential'); + } + + public function test_happy_path_installs_key_switches_credential_and_disables_password(): void + { + $server = $this->passwordServer(); + + $fleet = Mockery::mock(FleetService::class); + $fleet->shouldReceive('addAuthorizedKey')->once(); + $fleet->shouldReceive('testConnection')->once()->andReturn(['ok' => true, 'error' => null]); + + $hardening = Mockery::mock(HardeningService::class); + $hardening->shouldReceive('apply')->once()->with(Mockery::type(Server::class), 'ssh_password', false) + ->andReturn(['ok' => true, 'output' => '']); + + $result = (new SshKeyProvisioner($fleet, $hardening))->enableKeyOnlyAccess($server); + + $this->assertTrue($result['ok']); + $this->assertNotEmpty($result['privateKey']); + $this->assertStringContainsString('ssh-ed25519', $result['publicKey']); + + $cred = $server->fresh('credential')->credential; + $this->assertSame('key', $cred->auth_type); + $this->assertStringContainsString('PRIVATE KEY', $cred->secret); // now holds the private PEM + } + + public function test_verify_failure_rolls_back_the_credential_and_does_not_disable_password(): void + { + $server = $this->passwordServer(); + + $fleet = Mockery::mock(FleetService::class); + $fleet->shouldReceive('addAuthorizedKey')->once(); + $fleet->shouldReceive('testConnection')->once()->andReturn(['ok' => false, 'error' => 'auth failed']); + + $hardening = Mockery::mock(HardeningService::class); + $hardening->shouldNotReceive('apply'); // never disable password if the key didn't verify + + $result = (new SshKeyProvisioner($fleet, $hardening))->enableKeyOnlyAccess($server); + + $this->assertFalse($result['ok']); + $this->assertNotEmpty($result['error']); + + $cred = $server->fresh('credential')->credential; + $this->assertSame('password', $cred->auth_type, 'credential restored to password'); + $this->assertSame('pw-secret', $cred->secret, 'original secret restored'); + } + + public function test_already_key_credential_skips_keygen_and_just_disables_password(): void + { + $server = Server::create(['name' => 'box2', 'ip' => '10.0.0.6', 'ssh_port' => 22, 'status' => 'online']); + $server->credential()->create(['username' => 'admin', 'auth_type' => 'key', 'secret' => 'EXISTING-PRIVATE-KEY']); + $server = $server->fresh('credential'); + + $fleet = Mockery::mock(FleetService::class); + $fleet->shouldNotReceive('addAuthorizedKey'); // already key — no new key generated + $fleet->shouldNotReceive('testConnection'); + + $hardening = Mockery::mock(HardeningService::class); + $hardening->shouldReceive('apply')->once()->with(Mockery::type(Server::class), 'ssh_password', false) + ->andReturn(['ok' => true, 'output' => '']); + + $result = (new SshKeyProvisioner($fleet, $hardening))->enableKeyOnlyAccess($server); + + $this->assertTrue($result['ok']); + $this->assertArrayNotHasKey('privateKey', $result); // nothing new to reveal + $this->assertSame('EXISTING-PRIVATE-KEY', $server->fresh('credential')->credential->secret); + } + + public function test_no_credential_returns_error(): void + { + $server = Server::create(['name' => 'box3', 'ip' => '10.0.0.7', 'ssh_port' => 22, 'status' => 'online']); + + $result = (new SshKeyProvisioner(Mockery::mock(FleetService::class), Mockery::mock(HardeningService::class))) + ->enableKeyOnlyAccess($server); + + $this->assertFalse($result['ok']); + } +}