fix(ssh): require an exec marker in the connection probe

Auth success alone does not prove the server can run commands (a server that
authenticates but rejects exec channels would be accepted, yet every fleet
operation runs commands). The probe now echoes a unique marker and requires it
in the output — proving exec works, while still tolerating servers that omit
the optional exit-status packet.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat/v1-foundation
boban 2026-06-14 10:46:59 +02:00
parent 07f0a0cb00
commit 37efa64b9d
3 changed files with 15 additions and 6 deletions

View File

@ -25,6 +25,9 @@ class FleetService
{
private const MARK = '===CLUSEV:';
// Unique marker the connection probe echoes — proves the exec channel works.
private const PROBE_MARKER = 'CLUSEV_PROBE_OK';
public function __construct(private readonly CredentialVault $vault) {}
private function client(Server $server, int $timeout = 12): SshClient
@ -130,16 +133,20 @@ class FleetService
try {
$ssh = (new SshClient($this->vault, timeout: 10))->connect($server);
try {
// A successful login (connect() throws otherwise) already proves the
// credential; running a trivial command also exercises the exec channel.
// Do NOT gate on the exit code: some SSH servers omit the optional
// exit-status packet, so getExitStatus() returns false even on success —
// reaching here without an exception is proof enough.
$ssh->run('true');
// Require a unique marker in the OUTPUT to prove the exec channel really
// works — auth alone is not enough, since every fleet operation runs
// commands (a server that authenticates but rejects exec would otherwise
// be accepted). Checking the output, not the exit code, also tolerates
// SSH servers that omit the optional exit-status packet.
[$out] = $ssh->run('echo '.self::PROBE_MARKER);
} finally {
$ssh->disconnect();
}
if (! str_contains($out, self::PROBE_MARKER)) {
return ['ok' => false, 'error' => __('backend.ssh_probe_no_exec')];
}
return ['ok' => true, 'error' => null];
} catch (Throwable $e) {
return ['ok' => false, 'error' => $e->getMessage()];

View File

@ -38,6 +38,7 @@ return [
// ── HardeningService: errors + guards ────────────────────────────────
'hardening_status_read_failed' => 'Härtungs-Status konnte nicht gelesen werden.',
'ssh_probe_no_exec' => 'SSH-Anmeldung erfolgreich, aber es lassen sich keine Befehle ausführen.',
'ssh_password_self_lockout' => 'Clusev verbindet sich selbst per Passwort — Passwort-Login kann nicht deaktiviert werden, sonst verliert Clusev den Zugang. Hinterlege zuerst einen SSH-Key-Zugang.',
'ssh_password_no_key' => 'Kein SSH-Key hinterlegt — Passwort-Login kann nicht deaktiviert werden (Aussperrgefahr).',

View File

@ -38,6 +38,7 @@ return [
// ── HardeningService: errors + guards ────────────────────────────────
'hardening_status_read_failed' => 'Could not read hardening status.',
'ssh_probe_no_exec' => 'SSH login succeeded, but commands could not be executed.',
'ssh_password_self_lockout' => 'Clusev connects itself via password — password login cannot be disabled, otherwise Clusev loses access. Set up SSH key access first.',
'ssh_password_no_key' => 'No SSH key in place — password login cannot be disabled (lockout risk).',