setTimeout($this->commandTimeout()); if (! $ssh->login($user, $password)) { throw new RuntimeException("SSH password login failed for {$user}@{$host}"); } $this->ssh = $ssh; } public function connectWithKey(string $host, string $user, string $privateKey, ?string $expectedFingerprint = null): void { $key = PublicKeyLoader::load($privateKey); $ssh = new SSH2($host); $ssh->setTimeout($this->commandTimeout()); if (! $ssh->login($user, $key)) { throw new RuntimeException("SSH key login failed for {$user}@{$host}"); } $this->ssh = $ssh; // Host-key pinning: reject a server whose key changed since onboarding. if (filled($expectedFingerprint) && $this->hostKeyFingerprint() !== $expectedFingerprint) { $this->ssh = null; throw new RuntimeException("SSH host key mismatch for {$host} (possible interception or re-provision)."); } } public function run(string $command): CommandResult { $stdout = (string) $this->ssh()->exec($command); $exit = $this->ssh()->getExitStatus(); // No exit status = interrupted/closed channel → treat as failure, never success. return new CommandResult(is_int($exit) ? $exit : 255, $stdout, ''); } public function putFile(string $remotePath, string $contents): void { $b64 = base64_encode($contents); $dir = escapeshellarg(dirname($remotePath)); $path = escapeshellarg($remotePath); $result = $this->run("mkdir -p {$dir} && printf %s ".escapeshellarg($b64)." | base64 -d > {$path}"); if (! $result->ok()) { throw new RuntimeException("Failed to write remote file {$remotePath}"); } } public function hostKeyFingerprint(): string { $key = $this->ssh()->getServerPublicHostKey(); return $key === false ? '' : 'SHA256:'.base64_encode(hash('sha256', (string) $key, true)); } /** Long provisioning commands (apt full-upgrade …) far exceed the ~10s default. */ private function commandTimeout(): int { return (int) config('provisioning.ssh.command_timeout', 2000); } private function ssh(): SSH2 { if ($this->ssh === null) { throw new RuntimeException('RemoteShell is not connected.'); } return $this->ssh; } }