feat(ssh): SshKeyProvisioner — generate+install key, verify, switch credential, then disable password (rollback-safe)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
feat/v1-foundation
boban 2026-06-14 21:57:45 +02:00
parent 3629f18844
commit b847b80ab6
4 changed files with 211 additions and 0 deletions

View File

@ -0,0 +1,100 @@
<?php
namespace App\Services;
use App\Models\AuditEvent;
use App\Models\Server;
use Illuminate\Support\Facades\Auth;
use phpseclib3\Crypt\EC;
use Throwable;
/**
* Generates an SSH key, installs it, proves key login works, switches the panel's own
* credential to it, and only THEN disables password auth so neither the operator nor
* the control plane can be locked out. The plaintext private key is returned ONCE for
* the UI to reveal; it is also kept encrypted in the vault (the panel connects with it).
*/
class SshKeyProvisioner
{
public function __construct(
private readonly FleetService $fleet,
private readonly HardeningService $hardening,
) {}
/**
* @return array{ok: bool, privateKey?: string, publicKey?: string, error?: string}
*/
public function enableKeyOnlyAccess(Server $server): array
{
$cred = $server->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(),
]);
}
}

View File

@ -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.',
];

View File

@ -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.',
];

View File

@ -0,0 +1,105 @@
<?php
namespace Tests\Feature;
use App\Models\Server;
use App\Services\FleetService;
use App\Services\HardeningService;
use App\Services\SshKeyProvisioner;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Mockery;
use Tests\TestCase;
class SshKeyProvisionerTest extends TestCase
{
use RefreshDatabase;
protected function tearDown(): void
{
Mockery::close();
parent::tearDown();
}
private function passwordServer(): Server
{
$server = Server::create(['name' => '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']);
}
}