93 lines
2.9 KiB
PHP
93 lines
2.9 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);
|
|
$ssh->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;
|
|
}
|
|
}
|