clusev/app/Services/SshKeyProvisioner.php

101 lines
3.7 KiB
PHP

<?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(),
]);
}
}