CluPilotCloud/app/Services/Ssh/PhpseclibRemoteShell.php

85 lines
2.6 KiB
PHP

<?php
namespace App\Services\Ssh;
use phpseclib3\Crypt\PublicKeyLoader;
use phpseclib3\Net\SSH2;
use RuntimeException;
/**
* Real SSH session via phpseclib3. Not unit-tested (live I/O); verified against
* a real host during end-to-end runs. Files are written via a base64 heredoc so
* a single SSH channel suffices (no separate SFTP subsystem needed).
*/
class PhpseclibRemoteShell implements RemoteShell
{
private ?SSH2 $ssh = null;
public function connectWithPassword(string $host, string $user, string $password): void
{
$ssh = new SSH2($host);
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);
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));
}
private function ssh(): SSH2
{
if ($this->ssh === null) {
throw new RuntimeException('RemoteShell is not connected.');
}
return $this->ssh;
}
}