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
parent
3629f18844
commit
b847b80ab6
|
|
@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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.',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue