Files
server-manager/src/Services/SSHService.php
Agent 63bf125420 fix: allocate PTY for agent install to work with sudo requiretty
The remote server has 'Defaults requiretty' in sudoers, which prevents
sudo from running without a TTY. Modified SSHService::exec() to accept
an optional  parameter that allocates a pseudo-terminal, and
extracts the exit status from output when PTY is used (since phpseclib
doesn't return exit status in PTY mode).
2026-06-11 18:52:40 -04:00

181 lines
5.6 KiB
PHP
Executable File

<?php
declare(strict_types=1);
namespace ServerManager\Services;
use phpseclib3\Net\SSH2;
use phpseclib3\Crypt\PublicKeyLoader;
use ServerManager\Core\App;
use ServerManager\Core\Encryption;
use ServerManager\Core\Logger;
class SSHService
{
private ?SSH2 $connection = null;
private array $serverConfig;
private Logger $logger;
public function __construct()
{
$this->logger = Logger::getInstance();
}
public function connect(array $server, bool $forceReconnect = false): bool
{
if ($this->connection !== null && !$forceReconnect) {
return $this->connection->isConnected();
}
$this->serverConfig = $server;
$config = App::getConfig()['ssh'];
try {
$host = $server['ip_address'] ?? $server['hostname'];
$port = (int) ($server['ssh_port'] ?? 22);
$this->connection = new SSH2($host, $port, $config['timeout'] ?? 30);
if (!empty($server['ssh_key'])) {
$key = PublicKeyLoader::load($server['ssh_key']);
$result = $this->connection->login($server['ssh_user'], $key);
} elseif (!empty($server['ssh_password'])) {
$result = $this->connection->login($server['ssh_user'], $server['ssh_password']);
} else {
throw new \RuntimeException('No authentication method configured');
}
if (!$result) {
throw new \RuntimeException('SSH authentication failed for ' . $host);
}
if ($config['keepalive_interval'] ?? 0 > 0) {
$this->connection->setKeepAlive($config['keepalive_interval']);
}
$this->logger->info("SSH connection established to {$host}");
return true;
} catch (\Throwable $e) {
$this->logger->error("SSH connection failed: " . $e->getMessage(), [
'host' => $host ?? 'unknown',
'port' => $port ?? 22,
]);
$this->connection = null;
return false;
}
}
public function disconnect(): void
{
if ($this->connection !== null) {
$this->connection->disconnect();
$this->connection = null;
}
}
public function exec(string $command, int $timeout = 0, bool $pty = false): array
{
if ($this->connection === null || !$this->connection->isConnected()) {
throw new \RuntimeException('No active SSH connection');
}
$timeout = $timeout ?: (App::getConfig()['ssh']['command_timeout'] ?? 60);
$this->connection->setTimeout($timeout);
if ($pty) {
$command .= '; echo "___EXIT___:$?"';
}
$output = $this->connection->exec($command, null, $pty);
$exitStatus = $this->connection->getExitStatus();
$stderr = $this->connection->getStdError() ?? '';
if ($pty && ($exitStatus === false || $exitStatus === null)) {
if (preg_match('/___EXIT___:(\d+)/', $output, $m)) {
$exitStatus = (int) $m[1];
$output = str_replace($m[0] . "\n", '', $output);
$output = str_replace($m[0], '', $output);
}
}
$this->logger->info("SSH command executed", [
'command' => $command,
'exit_status' => $exitStatus,
]);
return [
'output' => $output,
'exit_status' => $exitStatus,
'stderr' => $stderr,
];
}
public function write(string $data): void
{
if ($this->connection !== null && $this->connection->isConnected()) {
$this->connection->write($data);
}
}
public function read(string $expected = '', int $mode = SSH2::READ_REGEX): string
{
if ($this->connection === null || !$this->connection->isConnected()) {
throw new \RuntimeException('No active SSH connection');
}
return $this->connection->read($expected, $mode);
}
public function getInteractiveShell(): ?SSH2
{
return $this->connection;
}
public function testConnection(array $server): bool
{
try {
$result = $this->connect($server, true);
if ($result) {
$this->disconnect();
return true;
}
return false;
} catch (\Throwable $e) {
$this->logger->warning("SSH connection test failed", ['error' => $e->getMessage()]);
return false;
}
}
public function getSystemInfo(): array
{
$commands = [
'hostname' => 'hostname',
'os' => 'cat /etc/os-release | head -1',
'kernel' => 'uname -r',
'uptime' => 'uptime -p',
'cpu_model' => 'cat /proc/cpuinfo | grep "model name" | head -1',
'cpu_cores' => 'nproc',
'mem_total' => 'free -b | grep Mem | awk \'{print $2}\'',
'mem_used' => "free -b | grep Mem | awk '{print $3}'",
'mem_avail' => "free -b | grep Mem | awk '{print $7}'",
'disk_total' => "df -B1 / | tail -1 | awk '{print $2}'",
'disk_used' => "df -B1 / | tail -1 | awk '{print $3}'",
'disk_avail' => "df -B1 / | tail -1 | awk '{print $4}'",
'disk_percent' => "df / | tail -1 | awk '{print $5}' | sed 's/%//'",
];
$info = [];
foreach ($commands as $key => $cmd) {
$result = $this->exec($cmd);
$info[$key] = trim($result['output']);
}
return $info;
}
public function __destruct()
{
$this->disconnect();
}
}