From 37efa64b9d826fa55a4df3bd0fa96031f881d265 Mon Sep 17 00:00:00 2001 From: boban Date: Sun, 14 Jun 2026 10:46:59 +0200 Subject: [PATCH] fix(ssh): require an exec marker in the connection probe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- app/Services/FleetService.php | 19 +++++++++++++------ lang/de/backend.php | 1 + lang/en/backend.php | 1 + 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/app/Services/FleetService.php b/app/Services/FleetService.php index 8c221f1..0f6ceed 100644 --- a/app/Services/FleetService.php +++ b/app/Services/FleetService.php @@ -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()]; diff --git a/lang/de/backend.php b/lang/de/backend.php index 0b21181..8c388e0 100644 --- a/lang/de/backend.php +++ b/lang/de/backend.php @@ -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).', diff --git a/lang/en/backend.php b/lang/en/backend.php index 05bc84f..623d327 100644 --- a/lang/en/backend.php +++ b/lang/en/backend.php @@ -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).',