Skip to content

Commit

Permalink
Merge pull request #3454 from peaklabs-dev/fix-ssh-keys
Browse files Browse the repository at this point in the history
Fix: SSH Keys, Multiplexing issues and a lot of other small things for dev and prod
  • Loading branch information
andrasbacsai committed Sep 19, 2024
2 parents a65b623 + 631b4e6 commit cfd9ae9
Show file tree
Hide file tree
Showing 31 changed files with 702 additions and 485 deletions.
4 changes: 2 additions & 2 deletions .env.development.example
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ APP_KEY=
APP_URL=http://localhost
APP_PORT=8000
APP_DEBUG=true
SSH_MUX_ENABLED=false
SSH_MUX_ENABLED=true

# PostgreSQL Database Configuration
DB_DATABASE=coolify
Expand All @@ -21,7 +21,7 @@ RAY_ENABLED=false
# Set custom ray port
RAY_PORT=

# Clockwork Configuration
# Clockwork Configuration (remove?)
CLOCKWORK_ENABLED=false
CLOCKWORK_QUEUE_COLLECT=true

Expand Down
3 changes: 2 additions & 1 deletion app/Actions/CoolifyTask/RunRemoteProcess.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Process;
use Spatie\Activitylog\Models\Activity;
use App\Helpers\SshMultiplexingHelper;

class RunRemoteProcess
{
Expand Down Expand Up @@ -137,7 +138,7 @@ protected function getCommand(): string
$command = $this->activity->getExtraProperty('command');
$server = Server::whereUuid($server_uuid)->firstOrFail();

return generateSshCommand($server, $command);
return SshMultiplexingHelper::generateSshCommand($server, $command);
}

protected function handleOutput(string $type, string $output)
Expand Down
2 changes: 1 addition & 1 deletion app/Actions/Proxy/CheckProxy.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ public function handle(Server $server, $fromUI = false)
if (is_null($proxyType) || $proxyType === 'NONE' || $server->proxy->force_stop) {
return false;
}
['uptime' => $uptime, 'error' => $error] = $server->validateConnection();
['uptime' => $uptime, 'error' => $error] = $server->validateConnection(false);
if (! $uptime) {
throw new \Exception($error);
}
Expand Down
5 changes: 5 additions & 0 deletions app/Console/Kernel.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
use App\Jobs\CleanupStaleMultiplexedConnections;
use App\Jobs\DatabaseBackupJob;
use App\Jobs\DockerCleanupJob;
use App\Jobs\CleanupSshKeysJob;
use App\Jobs\PullHelperImageJob;
use App\Jobs\PullSentinelImageJob;
use App\Jobs\PullTemplatesFromCDN;
Expand Down Expand Up @@ -43,6 +44,8 @@ protected function schedule(Schedule $schedule): void
$schedule->command('uploads:clear')->everyTwoMinutes();

$schedule->command('telescope:prune')->daily();

$schedule->job(new CleanupSshKeysJob)->weekly()->onOneServer();
} else {
// Instance Jobs
$schedule->command('horizon:snapshot')->everyFiveMinutes();
Expand All @@ -59,6 +62,8 @@ protected function schedule(Schedule $schedule): void

$schedule->command('cleanup:database --yes')->daily();
$schedule->command('uploads:clear')->everyTwoMinutes();

$schedule->job(new CleanupSshKeysJob)->weekly()->onOneServer();
}
}

Expand Down
204 changes: 204 additions & 0 deletions app/Helpers/SshMultiplexingHelper.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
<?php

namespace App\Helpers;

use App\Models\Server;
use App\Models\PrivateKey;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Facades\Hash;

class SshMultiplexingHelper
{
public static function serverSshConfiguration(Server $server)
{
$privateKey = PrivateKey::findOrFail($server->private_key_id);
$sshKeyLocation = $privateKey->getKeyLocation();
$muxFilename = '/var/www/html/storage/app/ssh/mux/mux_' . $server->uuid;

return [
'sshKeyLocation' => $sshKeyLocation,
'muxFilename' => $muxFilename,
];
}

public static function ensureMultiplexedConnection(Server $server)
{
if (!self::isMultiplexingEnabled()) {
// ray('SSH Multiplexing: DISABLED')->red();
return;
}

// ray('SSH Multiplexing: ENABLED')->green();
// ray('Ensuring multiplexed connection for server:', $server);

$sshConfig = self::serverSshConfiguration($server);
$muxSocket = $sshConfig['muxFilename'];
$sshKeyLocation = $sshConfig['sshKeyLocation'];

self::validateSshKey($sshKeyLocation);

$checkCommand = "ssh -O check -o ControlPath=$muxSocket {$server->user}@{$server->ip}";
$process = Process::run($checkCommand);

if ($process->exitCode() !== 0) {
// ray('SSH Multiplexing: Existing connection check failed or not found')->orange();
// ray('Establishing new connection');
self::establishNewMultiplexedConnection($server);
} else {
// ray('SSH Multiplexing: Existing connection is valid')->green();
}
}

public static function establishNewMultiplexedConnection(Server $server)
{
$sshConfig = self::serverSshConfiguration($server);
$sshKeyLocation = $sshConfig['sshKeyLocation'];
$muxSocket = $sshConfig['muxFilename'];

// ray('Establishing new multiplexed connection')->blue();
// ray('SSH Key Location:', $sshKeyLocation);
// ray('Mux Socket:', $muxSocket);

$connectionTimeout = config('constants.ssh.connection_timeout');
$serverInterval = config('constants.ssh.server_interval');
$muxPersistTime = config('constants.ssh.mux_persist_time');

$establishCommand = "ssh -fNM -o ControlMaster=auto -o ControlPath=$muxSocket -o ControlPersist={$muxPersistTime} "
. self::getCommonSshOptions($server, $sshKeyLocation, $connectionTimeout, $serverInterval)
. "{$server->user}@{$server->ip}";

// ray('Establish Command:', $establishCommand);

$establishProcess = Process::run($establishCommand);

// ray('Establish Process Exit Code:', $establishProcess->exitCode());
// ray('Establish Process Output:', $establishProcess->output());
// ray('Establish Process Error Output:', $establishProcess->errorOutput());

if ($establishProcess->exitCode() !== 0) {
// ray('Failed to establish multiplexed connection')->red();
throw new \RuntimeException('Failed to establish multiplexed connection: ' . $establishProcess->errorOutput());
}

// ray('Successfully established multiplexed connection')->green();

// Check if the mux socket file was created
if (!file_exists($muxSocket)) {
// ray('Mux socket file not found after connection establishment')->orange();
}
}

public static function removeMuxFile(Server $server)
{
$sshConfig = self::serverSshConfiguration($server);
$muxSocket = $sshConfig['muxFilename'];

$closeCommand = "ssh -O exit -o ControlPath=$muxSocket {$server->user}@{$server->ip}";
$process = Process::run($closeCommand);

// ray('Closing multiplexed connection')->blue();
// ray('Close command:', $closeCommand);
// ray('Close process exit code:', $process->exitCode());
// ray('Close process output:', $process->output());
// ray('Close process error output:', $process->errorOutput());

if ($process->exitCode() !== 0) {
// ray('Failed to close multiplexed connection')->orange();
} else {
// ray('Successfully closed multiplexed connection')->green();
}
}

public static function generateScpCommand(Server $server, string $source, string $dest)
{
$sshConfig = self::serverSshConfiguration($server);
$sshKeyLocation = $sshConfig['sshKeyLocation'];
$muxSocket = $sshConfig['muxFilename'];

$timeout = config('constants.ssh.command_timeout');

$scp_command = "timeout $timeout scp ";

if (self::isMultiplexingEnabled()) {
$muxPersistTime = config('constants.ssh.mux_persist_time');
$scp_command .= "-o ControlMaster=auto -o ControlPath=$muxSocket -o ControlPersist={$muxPersistTime} ";
self::ensureMultiplexedConnection($server);
}

self::addCloudflareProxyCommand($scp_command, $server);

$scp_command .= self::getCommonSshOptions($server, $sshKeyLocation, config('constants.ssh.connection_timeout'), config('constants.ssh.server_interval'));
$scp_command .= "{$source} {$server->user}@{$server->ip}:{$dest}";

return $scp_command;
}

public static function generateSshCommand(Server $server, string $command)
{
if ($server->settings->force_disabled) {
throw new \RuntimeException('Server is disabled.');
}

$sshConfig = self::serverSshConfiguration($server);
$sshKeyLocation = $sshConfig['sshKeyLocation'];
$muxSocket = $sshConfig['muxFilename'];

$timeout = config('constants.ssh.command_timeout');

$ssh_command = "timeout $timeout ssh ";

if (self::isMultiplexingEnabled()) {
$muxPersistTime = config('constants.ssh.mux_persist_time');
$ssh_command .= "-o ControlMaster=auto -o ControlPath=$muxSocket -o ControlPersist={$muxPersistTime} ";
self::ensureMultiplexedConnection($server);
}

self::addCloudflareProxyCommand($ssh_command, $server);

$ssh_command .= self::getCommonSshOptions($server, $sshKeyLocation, config('constants.ssh.connection_timeout'), config('constants.ssh.server_interval'));

$command = "PATH=\$PATH:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/host/usr/local/sbin:/host/usr/local/bin:/host/usr/sbin:/host/usr/bin:/host/sbin:/host/bin && $command";
$delimiter = Hash::make($command);
$command = str_replace($delimiter, '', $command);

$ssh_command .= "{$server->user}@{$server->ip} 'bash -se' << \\$delimiter".PHP_EOL
.$command.PHP_EOL
.$delimiter;

return $ssh_command;
}

private static function isMultiplexingEnabled(): bool
{
return config('constants.ssh.mux_enabled') && !config('coolify.is_windows_docker_desktop');
}

private static function validateSshKey(string $sshKeyLocation): void
{
$checkKeyCommand = "ls $sshKeyLocation 2>/dev/null";
$keyCheckProcess = Process::run($checkKeyCommand);

if ($keyCheckProcess->exitCode() !== 0) {
throw new \RuntimeException("SSH key file not accessible: $sshKeyLocation");
}
}

private static function addCloudflareProxyCommand(string &$command, Server $server): void
{
if (data_get($server, 'settings.is_cloudflare_tunnel')) {
$command .= '-o ProxyCommand="/usr/local/bin/cloudflared access ssh --hostname %h" ';
}
}

private static function getCommonSshOptions(Server $server, string $sshKeyLocation, int $connectionTimeout, int $serverInterval): string
{
return "-i {$sshKeyLocation} "
.'-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null '
.'-o PasswordAuthentication=no '
."-o ConnectTimeout=$connectionTimeout "
."-o ServerAliveInterval=$serverInterval "
.'-o RequestTTY=no '
.'-o LogLevel=ERROR '
."-p {$server->port} ";
}
}
17 changes: 3 additions & 14 deletions app/Jobs/ApplicationDeploymentJob.php
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,6 @@ public function __construct(int $application_deployment_queue_id)
}
ray('New container name: ', $this->container_name)->green();

savePrivateKeyToFs($this->server);
$this->saved_outputs = collect();

// Set preview fqdn
Expand Down Expand Up @@ -970,7 +969,7 @@ private function save_environment_variables()
}
}
if ($this->application->environment_variables->where('key', 'COOLIFY_URL')->isEmpty()) {
$url = str($this->application->fqdn)->replace('http://', '')->replace('https://', '');
$url = str($this->application->fqdn)->replace('http://', '').replace('https://', '');
if ($this->application->compose_parsing_version === '3') {
$envs->push("COOLIFY_FQDN={$url}");
} else {
Expand Down Expand Up @@ -1443,21 +1442,11 @@ private function check_git_if_build_needed()
if ($this->pull_request_id !== 0) {
$local_branch = "pull/{$this->pull_request_id}/head";
}
$private_key = data_get($this->application, 'private_key.private_key');
$private_key = $this->application->privateKey->getKeyLocation();
if ($private_key) {
$private_key = base64_encode($private_key);
$this->execute_remote_command(
[
executeInDocker($this->deployment_uuid, 'mkdir -p /root/.ssh'),
],
[
executeInDocker($this->deployment_uuid, "echo '{$private_key}' | base64 -d | tee /root/.ssh/id_rsa > /dev/null"),
],
[
executeInDocker($this->deployment_uuid, 'chmod 600 /root/.ssh/id_rsa'),
],
[
executeInDocker($this->deployment_uuid, "GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$this->customPort} -o Port={$this->customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" git ls-remote {$this->fullRepoUrl} {$local_branch}"),
executeInDocker($this->deployment_uuid, "GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$this->customPort} -o Port={$this->customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i {$private_key}\" git ls-remote {$this->fullRepoUrl} {$local_branch}"),
'hidden' => true,
'save' => 'git_commit_sha',
],
Expand Down
27 changes: 27 additions & 0 deletions app/Jobs/CleanupSshKeysJob.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<?php

namespace App\Jobs;

use App\Models\PrivateKey;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Carbon\Carbon;

class CleanupSshKeysJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

public function handle()
{
$oneWeekAgo = Carbon::now()->subWeek();

PrivateKey::where('created_at', '<', $oneWeekAgo)
->get()
->each(function ($privateKey) {
$privateKey->safeDelete();
});
}
}
Loading

0 comments on commit cfd9ae9

Please sign in to comment.