114 lines
4.3 KiB
PHP
114 lines
4.3 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,
|
|
) {}
|
|
|
|
/**
|
|
* `privateKey` may be present even on `ok:false` — when password auth failed to disable AFTER the
|
|
* credential was already switched to the verified key. Callers MUST display it to the operator and
|
|
* MUST NOT log or persist it (it is the plaintext private key).
|
|
*
|
|
* @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).
|
|
// The credential is already the verified key, so any failure here still leaves the operator
|
|
// with working key access — never a lockout.
|
|
try {
|
|
$res = $this->hardening->apply($server, 'ssh_password', false);
|
|
} catch (Throwable $e) {
|
|
return array_filter([
|
|
'ok' => false, 'error' => $e->getMessage(), 'privateKey' => $privateKey, 'publicKey' => $publicKey,
|
|
], fn ($v) => $v !== null);
|
|
}
|
|
|
|
if (! $res['ok']) {
|
|
return array_filter([
|
|
'ok' => false, 'error' => $res['output'], 'privateKey' => $privateKey, 'publicKey' => $publicKey,
|
|
], fn ($v) => $v !== null);
|
|
}
|
|
|
|
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(),
|
|
]);
|
|
}
|
|
}
|