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).
181 lines
5.6 KiB
PHP
Executable File
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();
|
|
}
|
|
}
|