2024-09-16 22:33:43 +02:00
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
namespace App\Helpers;
|
|
|
|
|
|
2024-09-17 12:26:11 +02:00
|
|
|
use App\Models\PrivateKey;
|
2024-09-19 12:06:56 +02:00
|
|
|
use App\Models\Server;
|
2026-06-02 14:05:26 +02:00
|
|
|
use Illuminate\Contracts\Cache\LockTimeoutException;
|
|
|
|
|
use Illuminate\Support\Facades\Cache;
|
2024-09-16 22:33:43 +02:00
|
|
|
use Illuminate\Support\Facades\Hash;
|
2025-09-10 08:19:38 +02:00
|
|
|
use Illuminate\Support\Facades\Log;
|
2024-09-19 12:06:56 +02:00
|
|
|
use Illuminate\Support\Facades\Process;
|
2026-03-15 03:06:21 +01:00
|
|
|
use Illuminate\Support\Facades\Storage;
|
2024-09-16 22:33:43 +02:00
|
|
|
|
|
|
|
|
class SshMultiplexingHelper
|
|
|
|
|
{
|
2026-05-22 18:01:53 +02:00
|
|
|
public static function serverSshConfiguration(Server $server): array
|
2024-09-16 22:33:43 +02:00
|
|
|
{
|
2024-09-17 12:26:11 +02:00
|
|
|
$privateKey = PrivateKey::findOrFail($server->private_key_id);
|
2024-09-16 22:33:43 +02:00
|
|
|
|
|
|
|
|
return [
|
2026-05-22 18:01:53 +02:00
|
|
|
'sshKeyLocation' => $privateKey->getKeyLocation(),
|
|
|
|
|
'muxFilename' => self::muxSocket($server),
|
2024-09-16 22:33:43 +02:00
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
2024-11-22 10:06:12 +01:00
|
|
|
public static function ensureMultiplexedConnection(Server $server): bool
|
2024-09-16 22:33:43 +02:00
|
|
|
{
|
2026-06-02 14:05:26 +02:00
|
|
|
if (! self::isMultiplexingEnabled()) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
2024-09-16 22:33:43 +02:00
|
|
|
|
2026-06-02 14:05:26 +02:00
|
|
|
if (self::connectionIsReusable($server)) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
return Cache::lock(
|
|
|
|
|
self::connectionLockKey($server),
|
|
|
|
|
config('constants.ssh.mux_lock_ttl')
|
|
|
|
|
)->block(config('constants.ssh.mux_lock_timeout'), function () use ($server) {
|
|
|
|
|
if (self::connectionIsReusable($server)) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (self::masterConnectionExists($server)) {
|
|
|
|
|
return self::refreshMultiplexedConnection($server);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return self::establishNewMultiplexedConnection($server);
|
|
|
|
|
});
|
|
|
|
|
} catch (LockTimeoutException) {
|
|
|
|
|
Log::warning('SSH multiplexing lock timeout, falling back to non-multiplexed connection', [
|
|
|
|
|
'server' => $server->name ?? $server->ip,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
return false;
|
|
|
|
|
} catch (\Throwable $e) {
|
|
|
|
|
Log::warning('SSH multiplexing lock unavailable, falling back to non-multiplexed connection', [
|
|
|
|
|
'server' => $server->name ?? $server->ip,
|
|
|
|
|
'error' => $e->getMessage(),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
return false;
|
|
|
|
|
}
|
2026-05-22 18:22:22 +02:00
|
|
|
}
|
|
|
|
|
|
2026-06-02 14:05:26 +02:00
|
|
|
public static function establishNewMultiplexedConnection(Server $server): bool
|
2026-05-22 18:22:22 +02:00
|
|
|
{
|
2026-06-02 14:05:26 +02:00
|
|
|
$sshConfig = self::serverSshConfiguration($server);
|
|
|
|
|
$sshKeyLocation = $sshConfig['sshKeyLocation'];
|
|
|
|
|
$muxSocket = $sshConfig['muxFilename'];
|
|
|
|
|
$connectionTimeout = self::getConnectionTimeout($server);
|
|
|
|
|
$serverInterval = config('constants.ssh.server_interval');
|
|
|
|
|
$muxPersistTime = config('constants.ssh.mux_persist_time');
|
|
|
|
|
|
|
|
|
|
$establishCommand = "ssh -fN -o ControlMaster=auto -o ControlPath=$muxSocket -o ControlPersist={$muxPersistTime} ";
|
|
|
|
|
|
2024-09-23 19:23:46 +02:00
|
|
|
if (data_get($server, 'settings.is_cloudflare_tunnel')) {
|
2026-06-02 14:05:26 +02:00
|
|
|
$establishCommand .= ' -o ProxyCommand="cloudflared access ssh --hostname %h" ';
|
2024-09-23 19:23:46 +02:00
|
|
|
}
|
2025-09-10 08:19:38 +02:00
|
|
|
|
2026-06-02 14:05:26 +02:00
|
|
|
$establishCommand .= self::getCommonSshOptions($server, $sshKeyLocation, $connectionTimeout, $serverInterval);
|
|
|
|
|
$establishCommand .= self::escapedUserAtHost($server);
|
|
|
|
|
|
|
|
|
|
$establishProcess = Process::run($establishCommand);
|
|
|
|
|
if ($establishProcess->exitCode() !== 0) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
self::storeConnectionMetadata($server);
|
|
|
|
|
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public static function removeMuxFile(Server $server): void
|
|
|
|
|
{
|
|
|
|
|
Process::run(self::muxControlCommand($server, 'exit'));
|
|
|
|
|
self::clearConnectionMetadata($server);
|
2024-09-16 22:33:43 +02:00
|
|
|
}
|
|
|
|
|
|
2026-05-22 18:01:53 +02:00
|
|
|
public static function generateScpCommand(Server $server, string $source, string $dest): string
|
2024-09-16 22:33:43 +02:00
|
|
|
{
|
|
|
|
|
$sshConfig = self::serverSshConfiguration($server);
|
|
|
|
|
$sshKeyLocation = $sshConfig['sshKeyLocation'];
|
2026-05-22 18:01:53 +02:00
|
|
|
$scpCommand = 'timeout '.config('constants.ssh.command_timeout').' scp ';
|
2024-09-16 22:33:43 +02:00
|
|
|
|
2024-10-02 08:15:03 +02:00
|
|
|
if ($server->isIpv6()) {
|
2026-05-22 18:01:53 +02:00
|
|
|
$scpCommand .= '-6 ';
|
2024-10-02 08:15:03 +02:00
|
|
|
}
|
2026-05-22 18:01:53 +02:00
|
|
|
|
2026-05-22 18:27:40 +02:00
|
|
|
if (self::isMultiplexingEnabled()) {
|
2026-06-02 14:05:26 +02:00
|
|
|
try {
|
|
|
|
|
if (self::ensureMultiplexedConnection($server)) {
|
|
|
|
|
$scpCommand .= self::multiplexingOptions($server);
|
|
|
|
|
}
|
|
|
|
|
} catch (\Throwable $e) {
|
|
|
|
|
Log::warning('SSH multiplexing failed for SCP, falling back to non-multiplexed connection', [
|
|
|
|
|
'server' => $server->name ?? $server->ip,
|
|
|
|
|
'error' => $e->getMessage(),
|
|
|
|
|
]);
|
|
|
|
|
}
|
2024-09-16 22:33:43 +02:00
|
|
|
}
|
|
|
|
|
|
2024-09-23 19:23:46 +02:00
|
|
|
if (data_get($server, 'settings.is_cloudflare_tunnel')) {
|
2026-05-22 18:01:53 +02:00
|
|
|
$scpCommand .= '-o ProxyCommand="cloudflared access ssh --hostname %h" ';
|
2024-09-23 19:23:46 +02:00
|
|
|
}
|
2024-09-17 12:26:11 +02:00
|
|
|
|
2026-05-22 18:01:53 +02:00
|
|
|
$scpCommand .= self::getCommonSshOptions($server, $sshKeyLocation, self::getConnectionTimeout($server), config('constants.ssh.server_interval'), isScp: true);
|
|
|
|
|
|
2025-06-26 12:23:08 +02:00
|
|
|
if ($server->isIpv6()) {
|
2026-05-26 13:48:10 +02:00
|
|
|
return $scpCommand.escapeshellarg($source).' '.escapeshellarg($server->user).'@['.escapeshellarg($server->ip).']:'.escapeshellarg($dest);
|
2025-06-26 12:23:08 +02:00
|
|
|
}
|
2024-09-16 22:33:43 +02:00
|
|
|
|
2026-05-26 13:48:10 +02:00
|
|
|
return $scpCommand.escapeshellarg($source).' '.self::escapedUserAtHost($server).':'.escapeshellarg($dest);
|
2024-09-16 22:33:43 +02:00
|
|
|
}
|
|
|
|
|
|
2026-05-31 21:52:46 +02:00
|
|
|
public static function generateSshCommand(Server $server, string $command, bool $disableMultiplexing = false, ?int $commandTimeout = null): string
|
2024-09-16 22:33:43 +02:00
|
|
|
{
|
|
|
|
|
if ($server->settings->force_disabled) {
|
|
|
|
|
throw new \RuntimeException('Server is disabled.');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$sshConfig = self::serverSshConfiguration($server);
|
|
|
|
|
$sshKeyLocation = $sshConfig['sshKeyLocation'];
|
2024-11-22 10:43:58 +01:00
|
|
|
|
|
|
|
|
self::validateSshKey($server->privateKey);
|
|
|
|
|
|
2026-05-31 21:52:46 +02:00
|
|
|
$commandTimeout = $commandTimeout ?? (int) config('constants.ssh.command_timeout');
|
|
|
|
|
$sshCommand = $commandTimeout > 0 ? "timeout {$commandTimeout} ssh " : 'ssh ';
|
2024-09-16 22:33:43 +02:00
|
|
|
|
2026-05-22 18:27:40 +02:00
|
|
|
if (! $disableMultiplexing && self::isMultiplexingEnabled()) {
|
2026-06-02 14:05:26 +02:00
|
|
|
try {
|
|
|
|
|
if (self::ensureMultiplexedConnection($server)) {
|
|
|
|
|
$sshCommand .= self::multiplexingOptions($server);
|
|
|
|
|
}
|
|
|
|
|
} catch (\Throwable $e) {
|
|
|
|
|
Log::warning('SSH multiplexing failed, falling back to non-multiplexed connection', [
|
|
|
|
|
'server' => $server->name ?? $server->ip,
|
|
|
|
|
'error' => $e->getMessage(),
|
|
|
|
|
]);
|
|
|
|
|
}
|
2024-09-16 22:33:43 +02:00
|
|
|
}
|
|
|
|
|
|
2024-09-23 19:23:46 +02:00
|
|
|
if (data_get($server, 'settings.is_cloudflare_tunnel')) {
|
2026-05-22 18:01:53 +02:00
|
|
|
$sshCommand .= "-o ProxyCommand='cloudflared access ssh --hostname %h' ";
|
2024-09-23 19:23:46 +02:00
|
|
|
}
|
2024-09-17 12:26:11 +02:00
|
|
|
|
2026-05-22 18:01:53 +02:00
|
|
|
$sshCommand .= self::getCommonSshOptions($server, $sshKeyLocation, self::getConnectionTimeout($server), config('constants.ssh.server_interval'));
|
2024-09-16 22:33:43 +02:00
|
|
|
|
2026-05-22 18:01:53 +02:00
|
|
|
$delimiter = base64_encode(Hash::make($command));
|
2024-09-16 22:33:43 +02:00
|
|
|
$command = str_replace($delimiter, '', $command);
|
|
|
|
|
|
2026-05-22 18:27:40 +02:00
|
|
|
return $sshCommand.self::escapedUserAtHost($server)." 'bash -se' << \\$delimiter".PHP_EOL
|
2024-09-17 12:26:11 +02:00
|
|
|
.$command.PHP_EOL
|
|
|
|
|
.$delimiter;
|
2026-05-22 18:01:53 +02:00
|
|
|
}
|
|
|
|
|
|
2026-06-02 14:05:26 +02:00
|
|
|
public static function getConnectionTimeout(Server $server): int
|
|
|
|
|
{
|
|
|
|
|
$timeout = data_get($server, 'settings.connection_timeout');
|
|
|
|
|
|
|
|
|
|
return is_numeric($timeout) && (int) $timeout > 0
|
|
|
|
|
? (int) $timeout
|
|
|
|
|
: (int) config('constants.ssh.connection_timeout');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public static function isConnectionHealthy(Server $server): bool
|
|
|
|
|
{
|
|
|
|
|
$sshConfig = self::serverSshConfiguration($server);
|
|
|
|
|
$muxSocket = $sshConfig['muxFilename'];
|
|
|
|
|
$healthCheckTimeout = config('constants.ssh.mux_health_check_timeout');
|
|
|
|
|
|
|
|
|
|
$healthCommand = "timeout $healthCheckTimeout ssh -o ControlMaster=auto -o ControlPath=$muxSocket ";
|
|
|
|
|
if (data_get($server, 'settings.is_cloudflare_tunnel')) {
|
|
|
|
|
$healthCommand .= '-o ProxyCommand="cloudflared access ssh --hostname %h" ';
|
|
|
|
|
}
|
|
|
|
|
$healthCommand .= self::escapedUserAtHost($server)." 'echo \"health_check_ok\"'";
|
|
|
|
|
|
|
|
|
|
$process = Process::run($healthCommand);
|
|
|
|
|
|
|
|
|
|
return $process->exitCode() === 0 && str_contains($process->output(), 'health_check_ok');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public static function isConnectionExpired(Server $server): bool
|
|
|
|
|
{
|
|
|
|
|
$connectionAge = self::getConnectionAge($server);
|
|
|
|
|
$maxAge = config('constants.ssh.mux_max_age');
|
|
|
|
|
|
|
|
|
|
return $connectionAge !== null && $connectionAge > $maxAge;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public static function getConnectionAge(Server $server): ?int
|
|
|
|
|
{
|
|
|
|
|
$connectionTime = Cache::get("ssh_mux_connection_time_{$server->uuid}");
|
|
|
|
|
|
|
|
|
|
if ($connectionTime === null) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return time() - $connectionTime;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public static function refreshMultiplexedConnection(Server $server): bool
|
|
|
|
|
{
|
|
|
|
|
self::removeMuxFile($server);
|
|
|
|
|
|
|
|
|
|
return self::establishNewMultiplexedConnection($server);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static function connectionLockKey(Server $server): string
|
|
|
|
|
{
|
|
|
|
|
return 'ssh_mux_lock_'.(gethostname() ?: 'unknown').'_'.$server->uuid;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static function masterConnectionExists(Server $server): bool
|
|
|
|
|
{
|
|
|
|
|
return Process::run(self::muxControlCommand($server, 'check'))->exitCode() === 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static function connectionIsReusable(Server $server): bool
|
|
|
|
|
{
|
|
|
|
|
if (! self::masterConnectionExists($server)) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (self::getConnectionAge($server) === null) {
|
|
|
|
|
self::storeConnectionMetadata($server);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (self::isConnectionExpired($server)) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (config('constants.ssh.mux_health_check_enabled') && ! self::isConnectionHealthy($server)) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static function muxControlCommand(Server $server, string $operation): string
|
|
|
|
|
{
|
|
|
|
|
$command = "ssh -O {$operation} -o ControlPath=".self::muxSocket($server).' ';
|
|
|
|
|
if (data_get($server, 'settings.is_cloudflare_tunnel')) {
|
|
|
|
|
$command .= '-o ProxyCommand="cloudflared access ssh --hostname %h" ';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return $command.self::escapedUserAtHost($server);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-22 18:01:53 +02:00
|
|
|
private static function multiplexingOptions(Server $server): string
|
|
|
|
|
{
|
|
|
|
|
return '-o ControlMaster=auto '
|
|
|
|
|
.'-o ControlPath='.self::muxSocket($server).' '
|
|
|
|
|
.'-o ControlPersist='.config('constants.ssh.mux_persist_time').' ';
|
|
|
|
|
}
|
2024-09-17 12:26:11 +02:00
|
|
|
|
2026-05-22 18:01:53 +02:00
|
|
|
private static function muxSocket(Server $server): string
|
|
|
|
|
{
|
|
|
|
|
return '/var/www/html/storage/app/ssh/mux/mux_'.$server->uuid;
|
2024-09-17 12:26:11 +02:00
|
|
|
}
|
|
|
|
|
|
2026-03-03 11:51:38 +01:00
|
|
|
private static function escapedUserAtHost(Server $server): string
|
|
|
|
|
{
|
|
|
|
|
return escapeshellarg($server->user).'@'.escapeshellarg($server->ip);
|
|
|
|
|
}
|
|
|
|
|
|
2024-09-17 12:26:11 +02:00
|
|
|
private static function isMultiplexingEnabled(): bool
|
|
|
|
|
{
|
2024-11-12 15:18:48 +01:00
|
|
|
return config('constants.ssh.mux_enabled') && ! config('constants.coolify.is_windows_docker_desktop');
|
2024-09-17 12:26:11 +02:00
|
|
|
}
|
|
|
|
|
|
2024-11-22 10:06:12 +01:00
|
|
|
private static function validateSshKey(PrivateKey $privateKey): void
|
2024-09-17 12:26:11 +02:00
|
|
|
{
|
2024-11-22 10:06:12 +01:00
|
|
|
$keyLocation = $privateKey->getKeyLocation();
|
2026-03-15 03:06:21 +01:00
|
|
|
$filename = "ssh_key@{$privateKey->uuid}";
|
|
|
|
|
$disk = Storage::disk('ssh-keys');
|
|
|
|
|
|
|
|
|
|
$needsRewrite = false;
|
|
|
|
|
|
|
|
|
|
if (! $disk->exists($filename)) {
|
|
|
|
|
$needsRewrite = true;
|
|
|
|
|
} else {
|
|
|
|
|
$diskContent = $disk->get($filename);
|
|
|
|
|
if ($diskContent !== $privateKey->private_key) {
|
|
|
|
|
Log::warning('SSH key file content does not match database, resyncing', [
|
|
|
|
|
'key_uuid' => $privateKey->uuid,
|
|
|
|
|
]);
|
|
|
|
|
$needsRewrite = true;
|
|
|
|
|
}
|
|
|
|
|
}
|
2024-09-17 12:26:11 +02:00
|
|
|
|
2026-03-15 03:06:21 +01:00
|
|
|
if ($needsRewrite) {
|
2024-11-22 10:06:12 +01:00
|
|
|
$privateKey->storeInFileSystem();
|
2024-09-17 12:26:11 +02:00
|
|
|
}
|
2026-03-15 03:06:21 +01:00
|
|
|
|
|
|
|
|
if (file_exists($keyLocation)) {
|
|
|
|
|
$currentPerms = fileperms($keyLocation) & 0777;
|
2026-03-16 21:27:10 +01:00
|
|
|
if ($currentPerms !== 0600 && ! chmod($keyLocation, 0600)) {
|
|
|
|
|
Log::warning('Failed to set SSH key file permissions to 0600', [
|
|
|
|
|
'key_uuid' => $privateKey->uuid,
|
|
|
|
|
'path' => $keyLocation,
|
|
|
|
|
]);
|
2026-03-15 03:06:21 +01:00
|
|
|
}
|
|
|
|
|
}
|
2024-09-17 12:26:11 +02:00
|
|
|
}
|
|
|
|
|
|
2024-09-19 12:06:56 +02:00
|
|
|
private static function getCommonSshOptions(Server $server, string $sshKeyLocation, int $connectionTimeout, int $serverInterval, bool $isScp = false): string
|
2024-09-17 12:26:11 +02:00
|
|
|
{
|
2024-09-19 12:06:56 +02:00
|
|
|
$options = "-i {$sshKeyLocation} "
|
2024-09-16 22:33:43 +02:00
|
|
|
.'-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null '
|
|
|
|
|
.'-o PasswordAuthentication=no '
|
|
|
|
|
."-o ConnectTimeout=$connectionTimeout "
|
|
|
|
|
."-o ServerAliveInterval=$serverInterval "
|
|
|
|
|
.'-o RequestTTY=no '
|
2024-09-19 12:06:56 +02:00
|
|
|
.'-o LogLevel=ERROR ';
|
|
|
|
|
|
|
|
|
|
if ($isScp) {
|
2026-05-22 18:01:53 +02:00
|
|
|
return $options.'-P '.escapeshellarg((string) $server->port).' ';
|
2025-09-10 08:19:38 +02:00
|
|
|
}
|
|
|
|
|
|
2026-05-22 18:01:53 +02:00
|
|
|
return $options.'-p '.escapeshellarg((string) $server->port).' ';
|
2025-09-10 08:19:38 +02:00
|
|
|
}
|
2026-06-02 14:05:26 +02:00
|
|
|
|
|
|
|
|
private static function storeConnectionMetadata(Server $server): void
|
|
|
|
|
{
|
|
|
|
|
Cache::put("ssh_mux_connection_time_{$server->uuid}", time(), config('constants.ssh.mux_persist_time') + 300);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static function clearConnectionMetadata(Server $server): void
|
|
|
|
|
{
|
|
|
|
|
Cache::forget("ssh_mux_connection_time_{$server->uuid}");
|
|
|
|
|
}
|
2024-09-19 12:06:56 +02:00
|
|
|
}
|