diff --git a/.env.development.example b/.env.development.example index 4de434df2..d4daed4f7 100644 --- a/.env.development.example +++ b/.env.development.example @@ -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 diff --git a/.github/workflows/remove-labels-and-assignees-on-close.yml b/.github/workflows/remove-labels-and-assignees-on-close.yml index 04d62623c..ea097e328 100644 --- a/.github/workflows/remove-labels-and-assignees-on-close.yml +++ b/.github/workflows/remove-labels-and-assignees-on-close.yml @@ -18,7 +18,7 @@ jobs: github-token: ${{ secrets.GITHUB_TOKEN }} script: | const { owner, repo } = context.repo; - + async function processIssue(issueNumber) { try { const { data: currentLabels } = await github.rest.issues.listLabelsOnIssue({ @@ -65,11 +65,14 @@ jobs: } if (context.eventName === 'pull_request' || context.eventName === 'pull_request_target') { - const { data: closedIssues } = await github.rest.search.issuesAndPullRequests({ - q: `repo:${owner}/${repo} is:issue is:closed linked:${context.payload.pull_request.number}`, - per_page: 100 - }); - for (const issue of closedIssues.items) { - await processIssue(issue.number); + const pr = context.payload.pull_request; + if (pr.body) { + const issueReferences = pr.body.match(/#(\d+)/g); + if (issueReferences) { + for (const reference of issueReferences) { + const issueNumber = parseInt(reference.substring(1)); + await processIssue(issueNumber); + } + } } } diff --git a/app/Actions/Application/StopApplication.php b/app/Actions/Application/StopApplication.php index 7155f9a0a..3ed435049 100644 --- a/app/Actions/Application/StopApplication.php +++ b/app/Actions/Application/StopApplication.php @@ -3,50 +3,40 @@ namespace App\Actions\Application; use App\Models\Application; +use App\Actions\Server\CleanupDocker; use Lorisleiva\Actions\Concerns\AsAction; class StopApplication { use AsAction; - public function handle(Application $application, bool $previewDeployments = false) + public function handle(Application $application, bool $previewDeployments = false, bool $dockerCleanup = true) { - if ($application->destination->server->isSwarm()) { - instant_remote_process(["docker stack rm {$application->uuid}"], $application->destination->server); - - return; - } - - $servers = collect([]); - $servers->push($application->destination->server); - $application->additional_servers->map(function ($server) use ($servers) { - $servers->push($server); - }); - foreach ($servers as $server) { - if (! $server->isFunctional()) { + try { + $server = $application->destination->server; + if (!$server->isFunctional()) { return 'Server is not functional'; } - if ($previewDeployments) { - $containers = getCurrentApplicationContainerStatus($server, $application->id, includePullrequests: true); - } else { - $containers = getCurrentApplicationContainerStatus($server, $application->id, 0); - } - if ($containers->count() > 0) { - foreach ($containers as $container) { - $containerName = data_get($container, 'Names'); - if ($containerName) { - instant_remote_process(command: ["docker stop --time=30 $containerName"], server: $server, throwError: false); - instant_remote_process(command: ["docker rm $containerName"], server: $server, throwError: false); - instant_remote_process(command: ["docker rm -f {$containerName}"], server: $server, throwError: false); - } - } + ray('Stopping application: ' . $application->name); + + if ($server->isSwarm()) { + instant_remote_process(["docker stack rm {$application->uuid}"], $server); + return; } + + $containersToStop = $application->getContainersToStop($previewDeployments); + $application->stopContainers($containersToStop, $server); + if ($application->build_pack === 'dockercompose') { - // remove network - $uuid = $application->uuid; - instant_remote_process(["docker network disconnect {$uuid} coolify-proxy"], $server, false); - instant_remote_process(["docker network rm {$uuid}"], $server, false); + $application->delete_connected_networks($application->uuid); } + + if ($dockerCleanup) { + CleanupDocker::run($server, true); + } + } catch (\Exception $e) { + ray($e->getMessage()); + return $e->getMessage(); } } } diff --git a/app/Actions/CoolifyTask/RunRemoteProcess.php b/app/Actions/CoolifyTask/RunRemoteProcess.php index 63e3afe2f..a5dfa9226 100644 --- a/app/Actions/CoolifyTask/RunRemoteProcess.php +++ b/app/Actions/CoolifyTask/RunRemoteProcess.php @@ -10,6 +10,7 @@ use Illuminate\Process\ProcessResult; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Process; use Spatie\Activitylog\Models\Activity; +use App\Helpers\SshMultiplexingHelper; class RunRemoteProcess { @@ -137,7 +138,7 @@ class RunRemoteProcess $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) diff --git a/app/Actions/Database/StopDatabase.php b/app/Actions/Database/StopDatabase.php index d562ec56f..0f074bea9 100644 --- a/app/Actions/Database/StopDatabase.php +++ b/app/Actions/Database/StopDatabase.php @@ -2,6 +2,7 @@ namespace App\Actions\Database; +use App\Actions\Server\CleanupDocker; use App\Models\StandaloneClickhouse; use App\Models\StandaloneDragonfly; use App\Models\StandaloneKeydb; @@ -10,25 +11,65 @@ use App\Models\StandaloneMongodb; use App\Models\StandaloneMysql; use App\Models\StandalonePostgresql; use App\Models\StandaloneRedis; +use Illuminate\Support\Facades\Process; use Lorisleiva\Actions\Concerns\AsAction; class StopDatabase { use AsAction; - public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse $database) + public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse $database, bool $isDeleteOperation = false, bool $dockerCleanup = true) { $server = $database->destination->server; if (! $server->isFunctional()) { return 'Server is not functional'; } - instant_remote_process(command: ["docker stop --time=30 $database->uuid"], server: $server, throwError: false); - instant_remote_process(command: ["docker rm $database->uuid"], server: $server, throwError: false); - instant_remote_process(command: ["docker rm -f $database->uuid"], server: $server, throwError: false); + $this->stopContainer($database, $database->uuid, 300); + if (! $isDeleteOperation) { + if ($dockerCleanup) { + CleanupDocker::run($server, true); + } + } if ($database->is_public) { StopDatabaseProxy::run($database); } + + return 'Database stopped successfully'; + } + + private function stopContainer($database, string $containerName, int $timeout = 300): void + { + $server = $database->destination->server; + + $process = Process::timeout($timeout)->start("docker stop --time=$timeout $containerName"); + + $startTime = time(); + while ($process->running()) { + if (time() - $startTime >= $timeout) { + $this->forceStopContainer($containerName, $server); + break; + } + usleep(100000); + } + + $this->removeContainer($containerName, $server); + } + + private function forceStopContainer(string $containerName, $server): void + { + instant_remote_process(command: ["docker kill $containerName"], server: $server, throwError: false); + } + + private function removeContainer(string $containerName, $server): void + { + instant_remote_process(command: ["docker rm -f $containerName"], server: $server, throwError: false); + } + + private function deleteConnectedNetworks($uuid, $server) + { + instant_remote_process(["docker network disconnect {$uuid} coolify-proxy"], $server, false); + instant_remote_process(["docker network rm {$uuid}"], $server, false); } } diff --git a/app/Actions/Proxy/CheckProxy.php b/app/Actions/Proxy/CheckProxy.php index 735b972af..cf0f6015c 100644 --- a/app/Actions/Proxy/CheckProxy.php +++ b/app/Actions/Proxy/CheckProxy.php @@ -26,7 +26,7 @@ class CheckProxy 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); } diff --git a/app/Actions/Service/DeleteService.php b/app/Actions/Service/DeleteService.php index 194cf4db9..93c79383e 100644 --- a/app/Actions/Service/DeleteService.php +++ b/app/Actions/Service/DeleteService.php @@ -3,17 +3,18 @@ namespace App\Actions\Service; use App\Models\Service; +use App\Actions\Server\CleanupDocker; use Lorisleiva\Actions\Concerns\AsAction; class DeleteService { use AsAction; - public function handle(Service $service) + public function handle(Service $service, bool $deleteConfigurations, bool $deleteVolumes, bool $dockerCleanup, bool $deleteConnectedNetworks) { try { $server = data_get($service, 'server'); - if ($server->isFunctional()) { + if ($deleteVolumes && $server->isFunctional()) { $storagesToDelete = collect([]); $service->environment_variables()->delete(); @@ -33,13 +34,29 @@ class DeleteService foreach ($storagesToDelete as $storage) { $commands[] = "docker volume rm -f $storage->name"; } - $commands[] = "docker rm -f $service->uuid"; - instant_remote_process($commands, $server, false); + // Execute volume deletion first, this must be done first otherwise volumes will not be deleted. + if (!empty($commands)) { + foreach ($commands as $command) { + $result = instant_remote_process([$command], $server, false); + if ($result !== 0) { + ray("Failed to execute: $command"); + } + } + } } + + if ($deleteConnectedNetworks) { + $service->delete_connected_networks($service->uuid); + } + + instant_remote_process(["docker rm -f $service->uuid"], $server, throwError: false); } catch (\Exception $e) { throw new \Exception($e->getMessage()); } finally { + if ($deleteConfigurations) { + $service->delete_configurations(); + } foreach ($service->applications()->get() as $application) { $application->forceDelete(); } @@ -50,6 +67,11 @@ class DeleteService $task->delete(); } $service->tags()->detach(); + $service->forceDelete(); + + if ($dockerCleanup) { + CleanupDocker::run($server, true); + } } } } diff --git a/app/Actions/Service/StopService.php b/app/Actions/Service/StopService.php index 82b0b3ece..d69898a58 100644 --- a/app/Actions/Service/StopService.php +++ b/app/Actions/Service/StopService.php @@ -3,46 +3,33 @@ namespace App\Actions\Service; use App\Models\Service; +use App\Actions\Server\CleanupDocker; use Lorisleiva\Actions\Concerns\AsAction; class StopService { use AsAction; - public function handle(Service $service) + public function handle(Service $service, bool $isDeleteOperation = false, bool $dockerCleanup = true) { try { $server = $service->destination->server; - if (! $server->isFunctional()) { + if (!$server->isFunctional()) { return 'Server is not functional'; } - ray('Stopping service: '.$service->name); - $applications = $service->applications()->get(); - foreach ($applications as $application) { - if ($applications->count() < 6) { - instant_remote_process(command: ["docker stop --time=10 {$application->name}-{$service->uuid}"], server: $server, throwError: false); - } - instant_remote_process(command: ["docker rm {$application->name}-{$service->uuid}"], server: $server, throwError: false); - instant_remote_process(command: ["docker rm -f {$application->name}-{$service->uuid}"], server: $server, throwError: false); - $application->update(['status' => 'exited']); - } - $dbs = $service->databases()->get(); - foreach ($dbs as $db) { - if ($dbs->count() < 6) { - instant_remote_process(command: ["docker stop --time=10 {$db->name}-{$service->uuid}"], server: $server, throwError: false); + $containersToStop = $service->getContainersToStop(); + $service->stopContainers($containersToStop, $server); + + if (!$isDeleteOperation) { + $service->delete_connected_networks($service->uuid); + if ($dockerCleanup) { + CleanupDocker::run($server, true); } - instant_remote_process(command: ["docker rm {$db->name}-{$service->uuid}"], server: $server, throwError: false); - instant_remote_process(command: ["docker rm -f {$db->name}-{$service->uuid}"], server: $server, throwError: false); - $db->update(['status' => 'exited']); } - instant_remote_process(["docker network disconnect {$service->uuid} coolify-proxy"], $service->server); - instant_remote_process(["docker network rm {$service->uuid}"], $service->server); } catch (\Exception $e) { ray($e->getMessage()); - return $e->getMessage(); } - } } diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index b960a4a8b..03d479400 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -43,6 +43,8 @@ class Kernel extends ConsoleKernel $schedule->command('uploads:clear')->everyTwoMinutes(); $schedule->command('telescope:prune')->daily(); + + $schedule->job(new PullHelperImageJob)->everyFiveMinutes()->onOneServer(); } else { // Instance Jobs $schedule->command('horizon:snapshot')->everyFiveMinutes(); @@ -77,11 +79,11 @@ class Kernel extends ConsoleKernel } })->cron($settings->update_check_frequency)->timezone($settings->instance_timezone)->onOneServer(); } - $schedule->job(new PullHelperImageJob($server)) - ->cron($settings->update_check_frequency) - ->timezone($settings->instance_timezone) - ->onOneServer(); } + $schedule->job(new PullHelperImageJob) + ->cron($settings->update_check_frequency) + ->timezone($settings->instance_timezone) + ->onOneServer(); } private function schedule_updates($schedule) diff --git a/app/Helpers/SshMultiplexingHelper.php b/app/Helpers/SshMultiplexingHelper.php new file mode 100644 index 000000000..71be77506 --- /dev/null +++ b/app/Helpers/SshMultiplexingHelper.php @@ -0,0 +1,212 @@ +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'), isScp: true); + $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, bool $isScp = false): string + { + $options = "-i {$sshKeyLocation} " + .'-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null ' + .'-o PasswordAuthentication=no ' + ."-o ConnectTimeout=$connectionTimeout " + ."-o ServerAliveInterval=$serverInterval " + .'-o RequestTTY=no ' + .'-o LogLevel=ERROR '; + + // Bruh + if ($isScp) { + $options .= "-P {$server->port} "; + } else { + $options .= "-p {$server->port} "; + } + + return $options; + } +} diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index 32db4ac32..96313d285 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -29,6 +29,7 @@ use Illuminate\Queue\SerializesModels; use Illuminate\Support\Collection; use Illuminate\Support\Sleep; use Illuminate\Support\Str; +use Illuminate\Support\Facades\Process; use RuntimeException; use Symfony\Component\Yaml\Yaml; use Throwable; @@ -210,7 +211,6 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue } ray('New container name: ', $this->container_name)->green(); - savePrivateKeyToFs($this->server); $this->saved_outputs = collect(); // Set preview fqdn @@ -1442,21 +1442,11 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue 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', ], @@ -2211,20 +2201,40 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); $this->application_deployment_queue->addLogEntry('Building docker image completed.'); } - /** - * @param int $timeout in seconds - */ - private function graceful_shutdown_container(string $containerName, int $timeout = 30) + private function graceful_shutdown_container(string $containerName, int $timeout = 300) { try { - $this->execute_remote_command( - ["docker stop --time=$timeout $containerName", 'hidden' => true, 'ignore_errors' => true], - ["docker rm $containerName", 'hidden' => true, 'ignore_errors' => true] - ); + $process = Process::timeout($timeout)->start("docker stop --time=$timeout $containerName"); + + $startTime = time(); + while ($process->running()) { + if (time() - $startTime >= $timeout) { + $this->execute_remote_command( + ["docker kill $containerName", 'hidden' => true, 'ignore_errors' => true] + ); + break; + } + usleep(100000); + } + + $isRunning = $this->execute_remote_command( + ["docker inspect -f '{{.State.Running}}' $containerName", 'hidden' => true, 'ignore_errors' => true] + ) === 'true'; + + if ($isRunning) { + $this->execute_remote_command( + ["docker kill $containerName", 'hidden' => true, 'ignore_errors' => true] + ); + } } catch (\Exception $error) { - // report error if needed + $this->application_deployment_queue->addLogEntry("Error stopping container $containerName: " . $error->getMessage(), 'stderr'); } + $this->remove_container($containerName); + } + + private function remove_container(string $containerName) + { $this->execute_remote_command( ["docker rm -f $containerName", 'hidden' => true, 'ignore_errors' => true] ); @@ -2240,7 +2250,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); $containers = getCurrentApplicationContainerStatus($this->server, $this->application->id, $this->pull_request_id); if ($this->pull_request_id === 0) { $containers = $containers->filter(function ($container) { - return data_get($container, 'Names') !== $this->container_name && data_get($container, 'Names') !== $this->container_name.'-pr-'.$this->pull_request_id; + return data_get($container, 'Names') !== $this->container_name && data_get($container, 'Names') !== $this->container_name . '-pr-' . $this->pull_request_id; }); } $containers->each(function ($container) { diff --git a/app/Jobs/CleanupHelperContainersJob.php b/app/Jobs/CleanupHelperContainersJob.php index 7b064a464..b8ca8b7ed 100644 --- a/app/Jobs/CleanupHelperContainersJob.php +++ b/app/Jobs/CleanupHelperContainersJob.php @@ -21,11 +21,10 @@ class CleanupHelperContainersJob implements ShouldBeEncrypted, ShouldBeUnique, S { try { ray('Cleaning up helper containers on '.$this->server->name); - $containers = instant_remote_process(['docker container ps --filter "ancestor=ghcr.io/coollabsio/coolify-helper:next" --filter "ancestor=ghcr.io/coollabsio/coolify-helper:latest" --format \'{{json .}}\''], $this->server, false); - $containers = format_docker_command_output_to_json($containers); - if ($containers->count() > 0) { - foreach ($containers as $container) { - $containerId = data_get($container, 'ID'); + $containers = instant_remote_process(['docker container ps --format \'{{json .}}\' | jq -s \'map(select(.Image | contains("ghcr.io/coollabsio/coolify-helper")))\''], $this->server, false); + $containerIds = collect(json_decode($containers))->pluck('ID'); + if ($containerIds->count() > 0) { + foreach ($containerIds as $containerId) { ray('Removing container '.$containerId); instant_remote_process(['docker container rm -f '.$containerId], $this->server, false); } diff --git a/app/Jobs/CleanupStaleMultiplexedConnections.php b/app/Jobs/CleanupStaleMultiplexedConnections.php index bcca77c18..acb28c2f4 100644 --- a/app/Jobs/CleanupStaleMultiplexedConnections.php +++ b/app/Jobs/CleanupStaleMultiplexedConnections.php @@ -9,6 +9,8 @@ use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; use Illuminate\Support\Facades\Process; +use Illuminate\Support\Facades\Storage; +use Carbon\Carbon; class CleanupStaleMultiplexedConnections implements ShouldQueue { @@ -16,22 +18,64 @@ class CleanupStaleMultiplexedConnections implements ShouldQueue public function handle() { - Server::chunk(100, function ($servers) { - foreach ($servers as $server) { - $this->cleanupStaleConnection($server); - } - }); + $this->cleanupStaleConnections(); + $this->cleanupNonExistentServerConnections(); } - private function cleanupStaleConnection(Server $server) + private function cleanupStaleConnections() { - $muxSocket = "/tmp/mux_{$server->id}"; - $checkCommand = "ssh -O check -o ControlPath=$muxSocket {$server->user}@{$server->ip} 2>/dev/null"; - $checkProcess = Process::run($checkCommand); + $muxFiles = Storage::disk('ssh-mux')->files(); - if ($checkProcess->exitCode() !== 0) { - $closeCommand = "ssh -O exit -o ControlPath=$muxSocket {$server->user}@{$server->ip} 2>/dev/null"; - Process::run($closeCommand); + foreach ($muxFiles as $muxFile) { + $serverUuid = $this->extractServerUuidFromMuxFile($muxFile); + $server = Server::where('uuid', $serverUuid)->first(); + + if (!$server) { + $this->removeMultiplexFile($muxFile); + continue; + } + + $muxSocket = "/var/www/html/storage/app/ssh/mux/{$muxFile}"; + $checkCommand = "ssh -O check -o ControlPath={$muxSocket} {$server->user}@{$server->ip} 2>/dev/null"; + $checkProcess = Process::run($checkCommand); + + if ($checkProcess->exitCode() !== 0) { + $this->removeMultiplexFile($muxFile); + } else { + $muxContent = Storage::disk('ssh-mux')->get($muxFile); + $establishedAt = Carbon::parse(substr($muxContent, 37)); + $expirationTime = $establishedAt->addSeconds(config('constants.ssh.mux_persist_time')); + + if (Carbon::now()->isAfter($expirationTime)) { + $this->removeMultiplexFile($muxFile); + } + } } } + + private function cleanupNonExistentServerConnections() + { + $muxFiles = Storage::disk('ssh-mux')->files(); + $existingServerUuids = Server::pluck('uuid')->toArray(); + + foreach ($muxFiles as $muxFile) { + $serverUuid = $this->extractServerUuidFromMuxFile($muxFile); + if (!in_array($serverUuid, $existingServerUuids)) { + $this->removeMultiplexFile($muxFile); + } + } + } + + private function extractServerUuidFromMuxFile($muxFile) + { + return substr($muxFile, 4); + } + + private function removeMultiplexFile($muxFile) + { + $muxSocket = "/var/www/html/storage/app/ssh/mux/{$muxFile}"; + $closeCommand = "ssh -O exit -o ControlPath={$muxSocket} localhost 2>/dev/null"; + Process::run($closeCommand); + Storage::disk('ssh-mux')->delete($muxFile); + } } diff --git a/app/Jobs/DeleteResourceJob.php b/app/Jobs/DeleteResourceJob.php index dbf44dd5d..37300593e 100644 --- a/app/Jobs/DeleteResourceJob.php +++ b/app/Jobs/DeleteResourceJob.php @@ -4,6 +4,7 @@ namespace App\Jobs; use App\Actions\Application\StopApplication; use App\Actions\Database\StopDatabase; +use App\Actions\Server\CleanupDocker; use App\Actions\Service\DeleteService; use App\Actions\Service\StopService; use App\Models\Application; @@ -30,8 +31,11 @@ class DeleteResourceJob implements ShouldBeEncrypted, ShouldQueue public function __construct( public Application|Service|StandalonePostgresql|StandaloneRedis|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse $resource, - public bool $deleteConfigurations = false, - public bool $deleteVolumes = false) {} + public bool $deleteConfigurations, + public bool $deleteVolumes, + public bool $dockerCleanup, + public bool $deleteConnectedNetworks + ) {} public function handle() { @@ -51,11 +55,11 @@ class DeleteResourceJob implements ShouldBeEncrypted, ShouldQueue case 'standalone-dragonfly': case 'standalone-clickhouse': $persistentStorages = $this->resource?->persistentStorages()?->get(); - StopDatabase::run($this->resource); + StopDatabase::run($this->resource, true); break; case 'service': - StopService::run($this->resource); - DeleteService::run($this->resource); + StopService::run($this->resource, true); + DeleteService::run($this->resource, $this->deleteConfigurations, $this->deleteVolumes, $this->dockerCleanup, $this->deleteConnectedNetworks); break; } @@ -65,12 +69,31 @@ class DeleteResourceJob implements ShouldBeEncrypted, ShouldQueue if ($this->deleteConfigurations) { $this->resource?->delete_configurations(); } + + $isDatabase = $this->resource instanceof StandalonePostgresql + || $this->resource instanceof StandaloneRedis + || $this->resource instanceof StandaloneMongodb + || $this->resource instanceof StandaloneMysql + || $this->resource instanceof StandaloneMariadb + || $this->resource instanceof StandaloneKeydb + || $this->resource instanceof StandaloneDragonfly + || $this->resource instanceof StandaloneClickhouse; + $server = data_get($this->resource, 'server') ?? data_get($this->resource, 'destination.server'); + if (($this->dockerCleanup || $isDatabase) && $server) { + CleanupDocker::run($server, true); + } + + if ($this->deleteConnectedNetworks && ! $isDatabase) { + $this->resource?->delete_connected_networks($this->resource->uuid); + } } catch (\Throwable $e) { - ray($e->getMessage()); send_internal_notification('ContainerStoppingJob failed with: '.$e->getMessage()); throw $e; } finally { $this->resource->forceDelete(); + if ($this->dockerCleanup) { + CleanupDocker::run($server, true); + } Artisan::queue('cleanup:stucked-resources'); } } diff --git a/app/Jobs/PullHelperImageJob.php b/app/Jobs/PullHelperImageJob.php index 63b7fa920..ef1659680 100644 --- a/app/Jobs/PullHelperImageJob.php +++ b/app/Jobs/PullHelperImageJob.php @@ -9,7 +9,6 @@ use Illuminate\Contracts\Queue\ShouldBeEncrypted; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; -use Illuminate\Queue\Middleware\WithoutOverlapping; use Illuminate\Queue\SerializesModels; use Illuminate\Support\Facades\Http; @@ -19,17 +18,7 @@ class PullHelperImageJob implements ShouldBeEncrypted, ShouldQueue public $timeout = 1000; - public function middleware(): array - { - return [(new WithoutOverlapping($this->server->uuid))]; - } - - public function uniqueId(): string - { - return $this->server->uuid; - } - - public function __construct(public Server $server) {} + public function __construct() {} public function handle(): void { diff --git a/app/Jobs/ServerCheckJob.php b/app/Jobs/ServerCheckJob.php index 540085385..be5ec20f9 100644 --- a/app/Jobs/ServerCheckJob.php +++ b/app/Jobs/ServerCheckJob.php @@ -93,7 +93,7 @@ class ServerCheckJob implements ShouldBeEncrypted, ShouldQueue private function serverStatus() { - ['uptime' => $uptime] = $this->server->validateConnection(); + ['uptime' => $uptime] = $this->server->validateConnection(false); if ($uptime) { if ($this->server->unreachable_notification_sent === true) { $this->server->update(['unreachable_notification_sent' => false]); diff --git a/app/Livewire/Boarding/Index.php b/app/Livewire/Boarding/Index.php index af05ad767..21dcda50f 100644 --- a/app/Livewire/Boarding/Index.php +++ b/app/Livewire/Boarding/Index.php @@ -141,7 +141,7 @@ uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA== if (! $this->createdServer) { return $this->dispatch('error', 'Localhost server is not found. Something went wrong during installation. Please try to reinstall or contact support.'); } - $this->serverPublicKey = $this->createdServer->privateKey->publicKey(); + $this->serverPublicKey = $this->createdServer->privateKey->getPublicKey(); return $this->validateServer('localhost'); } elseif ($this->selectedServerType === 'remote') { @@ -175,7 +175,7 @@ uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA== return; } $this->selectedExistingPrivateKey = $this->createdServer->privateKey->id; - $this->serverPublicKey = $this->createdServer->privateKey->publicKey(); + $this->serverPublicKey = $this->createdServer->privateKey->getPublicKey(); $this->updateServerDetails(); $this->currentState = 'validate-server'; } @@ -231,17 +231,24 @@ uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA== public function savePrivateKey() { $this->validate([ - 'privateKeyName' => 'required', - 'privateKey' => 'required', + 'privateKeyName' => 'required|string|max:255', + 'privateKeyDescription' => 'nullable|string|max:255', + 'privateKey' => 'required|string', ]); - $this->createdPrivateKey = PrivateKey::create([ - 'name' => $this->privateKeyName, - 'description' => $this->privateKeyDescription, - 'private_key' => $this->privateKey, - 'team_id' => currentTeam()->id, - ]); - $this->createdPrivateKey->save(); - $this->currentState = 'create-server'; + + try { + $privateKey = PrivateKey::createAndStore([ + 'name' => $this->privateKeyName, + 'description' => $this->privateKeyDescription, + 'private_key' => $this->privateKey, + 'team_id' => currentTeam()->id, + ]); + + $this->createdPrivateKey = $privateKey; + $this->currentState = 'create-server'; + } catch (\Exception $e) { + $this->addError('privateKey', 'Failed to save private key: ' . $e->getMessage()); + } } public function saveServer() diff --git a/app/Livewire/Dashboard.php b/app/Livewire/Dashboard.php index 68555d26c..1f0b68dd3 100644 --- a/app/Livewire/Dashboard.php +++ b/app/Livewire/Dashboard.php @@ -30,7 +30,6 @@ class Dashboard extends Component public function cleanup_queue() { - $this->dispatch('success', 'Cleanup started.'); Artisan::queue('cleanup:application-deployment-queue', [ '--team-id' => currentTeam()->id, ]); diff --git a/app/Livewire/Destination/Form.php b/app/Livewire/Destination/Form.php index 7125f2120..5b3115dea 100644 --- a/app/Livewire/Destination/Form.php +++ b/app/Livewire/Destination/Form.php @@ -1,7 +1,6 @@ destination->delete(); - return redirect()->route('dashboard'); + return redirect()->route('destination.all'); } catch (\Throwable $e) { return handleError($e, $this); } diff --git a/app/Livewire/NavbarDeleteTeam.php b/app/Livewire/NavbarDeleteTeam.php index ec196c154..6b3ea3fd4 100644 --- a/app/Livewire/NavbarDeleteTeam.php +++ b/app/Livewire/NavbarDeleteTeam.php @@ -4,11 +4,25 @@ namespace App\Livewire; use Illuminate\Support\Facades\DB; use Livewire\Component; +use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\Hash; class NavbarDeleteTeam extends Component { - public function delete() + public $team; + + public function mount() { + $this->team = currentTeam()->name; + } + + public function delete($password) + { + if (!Hash::check($password, Auth::user()->password)) { + $this->addError('password', 'The provided password is incorrect.'); + return; + } + $currentTeam = currentTeam(); $currentTeam->delete(); diff --git a/app/Livewire/Project/Application/Heading.php b/app/Livewire/Project/Application/Heading.php index c02949e17..1082b48cd 100644 --- a/app/Livewire/Project/Application/Heading.php +++ b/app/Livewire/Project/Application/Heading.php @@ -21,6 +21,8 @@ class Heading extends Component protected string $deploymentUuid; + public bool $docker_cleanup = true; + public function getListeners() { $teamId = auth()->user()->currentTeam()->id; @@ -102,7 +104,7 @@ class Heading extends Component public function stop() { - StopApplication::run($this->application); + StopApplication::run($this->application, false, $this->docker_cleanup); $this->application->status = 'exited'; $this->application->save(); if ($this->application->additional_servers->count() > 0) { @@ -135,4 +137,13 @@ class Heading extends Component 'environment_name' => $this->parameters['environment_name'], ]); } + + public function render() + { + return view('livewire.project.application.heading', [ + 'checkboxes' => [ + ['id' => 'docker_cleanup', 'label' => __('resource.docker_cleanup')], + ], + ]); + } } diff --git a/app/Livewire/Project/Application/Previews.php b/app/Livewire/Project/Application/Previews.php index 317a2ae51..397e159ad 100644 --- a/app/Livewire/Project/Application/Previews.php +++ b/app/Livewire/Project/Application/Previews.php @@ -9,6 +9,8 @@ use Illuminate\Support\Collection; use Livewire\Component; use Spatie\Url\Url; use Visus\Cuid2\Cuid2; +use Illuminate\Process\InvokedProcess; +use Illuminate\Support\Facades\Process; class Previews extends Component { @@ -184,17 +186,20 @@ class Previews extends Component public function stop(int $pull_request_id) { try { + $server = $this->application->destination->server; + $timeout = 300; + if ($this->application->destination->server->isSwarm()) { - instant_remote_process(["docker stack rm {$this->application->uuid}-{$pull_request_id}"], $this->application->destination->server); + instant_remote_process(["docker stack rm {$this->application->uuid}-{$pull_request_id}"], $server); } else { - $containers = getCurrentApplicationContainerStatus($this->application->destination->server, $this->application->id, $pull_request_id); - foreach ($containers as $container) { - $name = str_replace('/', '', $container['Names']); - instant_remote_process(["docker rm -f $name"], $this->application->destination->server, throwError: false); - } + $containers = getCurrentApplicationContainerStatus($server, $this->application->id, $pull_request_id)->toArray(); + $this->stopContainers($containers, $server, $timeout); } - GetContainersStatus::dispatchSync($this->application->destination->server)->onQueue('high'); - $this->dispatch('reloadWindow'); + + GetContainersStatus::run($server); + $this->application->refresh(); + $this->dispatch('containerStatusUpdated'); + $this->dispatch('success', 'Preview Deployment stopped.'); } catch (\Throwable $e) { return handleError($e, $this); } @@ -203,16 +208,21 @@ class Previews extends Component public function delete(int $pull_request_id) { try { + $server = $this->application->destination->server; + $timeout = 300; + if ($this->application->destination->server->isSwarm()) { - instant_remote_process(["docker stack rm {$this->application->uuid}-{$pull_request_id}"], $this->application->destination->server); + instant_remote_process(["docker stack rm {$this->application->uuid}-{$pull_request_id}"], $server); } else { - $containers = getCurrentApplicationContainerStatus($this->application->destination->server, $this->application->id, $pull_request_id); - foreach ($containers as $container) { - $name = str_replace('/', '', $container['Names']); - instant_remote_process(["docker rm -f $name"], $this->application->destination->server, throwError: false); - } + $containers = getCurrentApplicationContainerStatus($server, $this->application->id, $pull_request_id)->toArray(); + $this->stopContainers($containers, $server, $timeout); } - ApplicationPreview::where('application_id', $this->application->id)->where('pull_request_id', $pull_request_id)->first()->delete(); + + ApplicationPreview::where('application_id', $this->application->id) + ->where('pull_request_id', $pull_request_id) + ->first() + ->delete(); + $this->application->refresh(); $this->dispatch('update_links'); $this->dispatch('success', 'Preview deleted.'); @@ -220,4 +230,49 @@ class Previews extends Component return handleError($e, $this); } } + + private function stopContainers(array $containers, $server, int $timeout) + { + $processes = []; + foreach ($containers as $container) { + $containerName = str_replace('/', '', $container['Names']); + $processes[$containerName] = $this->stopContainer($containerName, $timeout); + } + + $startTime = time(); + while (count($processes) > 0) { + $finishedProcesses = array_filter($processes, function ($process) { + return !$process->running(); + }); + foreach (array_keys($finishedProcesses) as $containerName) { + unset($processes[$containerName]); + $this->removeContainer($containerName, $server); + } + + if (time() - $startTime >= $timeout) { + $this->forceStopRemainingContainers(array_keys($processes), $server); + break; + } + + usleep(100000); + } + } + + private function stopContainer(string $containerName, int $timeout): InvokedProcess + { + return Process::timeout($timeout)->start("docker stop --time=$timeout $containerName"); + } + + private function removeContainer(string $containerName, $server) + { + instant_remote_process(["docker rm -f $containerName"], $server, throwError: false); + } + + private function forceStopRemainingContainers(array $containerNames, $server) + { + foreach ($containerNames as $containerName) { + instant_remote_process(["docker kill $containerName"], $server, throwError: false); + $this->removeContainer($containerName, $server); + } + } } diff --git a/app/Livewire/Project/Database/BackupEdit.php b/app/Livewire/Project/Database/BackupEdit.php index 59f2f9a39..9efb2d9fc 100644 --- a/app/Livewire/Project/Database/BackupEdit.php +++ b/app/Livewire/Project/Database/BackupEdit.php @@ -5,6 +5,8 @@ namespace App\Livewire\Project\Database; use App\Models\ScheduledDatabaseBackup; use Livewire\Component; use Spatie\Url\Url; +use Illuminate\Support\Facades\Hash; +use Illuminate\Support\Facades\Auth; class BackupEdit extends Component { @@ -12,6 +14,10 @@ class BackupEdit extends Component public $s3s; + public bool $delete_associated_backups_locally = false; + public bool $delete_associated_backups_s3 = false; + public bool $delete_associated_backups_sftp = false; + public ?string $status = null; public array $parameters; @@ -46,16 +52,29 @@ class BackupEdit extends Component } } - public function delete() + public function delete($password) { + if (!Hash::check($password, Auth::user()->password)) { + $this->addError('password', 'The provided password is incorrect.'); + return; + } + try { + if ($this->delete_associated_backups_locally) { + $this->deleteAssociatedBackupsLocally(); + } + if ($this->delete_associated_backups_s3) { + $this->deleteAssociatedBackupsS3(); + } + $this->backup->delete(); + if ($this->backup->database->getMorphClass() === 'App\Models\ServiceDatabase') { $previousUrl = url()->previous(); $url = Url::fromString($previousUrl); $url = $url->withoutQueryParameter('selectedBackupId'); $url = $url->withFragment('backups'); - $url = $url->getPath()."#{$url->getFragment()}"; + $url = $url->getPath() . "#{$url->getFragment()}"; return redirect($url); } else { @@ -104,4 +123,66 @@ class BackupEdit extends Component $this->dispatch('error', $e->getMessage()); } } + + public function deleteAssociatedBackupsLocally() + { + $executions = $this->backup->executions; + $backupFolder = null; + + foreach ($executions as $execution) { + if ($this->backup->database->getMorphClass() === 'App\Models\ServiceDatabase') { + $server = $this->backup->database->service->destination->server; + } else { + $server = $this->backup->database->destination->server; + } + + if (!$backupFolder) { + $backupFolder = dirname($execution->filename); + } + + delete_backup_locally($execution->filename, $server); + $execution->delete(); + } + + if ($backupFolder) { + $this->deleteEmptyBackupFolder($backupFolder, $server); + } + } + + public function deleteAssociatedBackupsS3() + { + //Add function to delete backups from S3 + } + + public function deleteAssociatedBackupsSftp() + { + //Add function to delete backups from SFTP + } + + private function deleteEmptyBackupFolder($folderPath, $server) + { + $checkEmpty = instant_remote_process(["[ -z \"$(ls -A '$folderPath')\" ] && echo 'empty' || echo 'not empty'"], $server); + + if (trim($checkEmpty) === 'empty') { + instant_remote_process(["rmdir '$folderPath'"], $server); + + $parentFolder = dirname($folderPath); + $checkParentEmpty = instant_remote_process(["[ -z \"$(ls -A '$parentFolder')\" ] && echo 'empty' || echo 'not empty'"], $server); + + if (trim($checkParentEmpty) === 'empty') { + instant_remote_process(["rmdir '$parentFolder'"], $server); + } + } + } + + public function render() + { + return view('livewire.project.database.backup-edit', [ + 'checkboxes' => [ + ['id' => 'delete_associated_backups_locally', 'label' => 'All backups associated with this backup job from this database will be permanently deleted from local storage.'], + // ['id' => 'delete_associated_backups_s3', 'label' => 'All backups associated with this backup job from this database will be permanently deleted from the selected S3 Storage.'] + // ['id' => 'delete_associated_backups_sftp', 'label' => 'All backups associated with this backup job from this database will be permanently deleted from the selected SFTP Storage.'] + ] + ]); + } } diff --git a/app/Livewire/Project/Database/BackupExecutions.php b/app/Livewire/Project/Database/BackupExecutions.php index 5d56ea53d..e16397652 100644 --- a/app/Livewire/Project/Database/BackupExecutions.php +++ b/app/Livewire/Project/Database/BackupExecutions.php @@ -4,6 +4,9 @@ namespace App\Livewire\Project\Database; use App\Models\ScheduledDatabaseBackup; use Livewire\Component; +use Illuminate\Support\Facades\Hash; +use Illuminate\Support\Facades\Auth; +use Livewire\Attributes\On; class BackupExecutions extends Component { @@ -12,9 +15,12 @@ class BackupExecutions extends Component public $executions = []; public $setDeletableBackup; + public $delete_backup_s3 = true; + public $delete_backup_sftp = true; + public function getListeners() { - $userId = auth()->user()->id; + $userId = Auth::id(); return [ "echo-private:team.{$userId},BackupCreated" => 'refreshBackupExecutions', @@ -31,19 +37,34 @@ class BackupExecutions extends Component } } - public function deleteBackup($exeuctionId) + #[On('deleteBackup')] + public function deleteBackup($executionId, $password) { - $execution = $this->backup->executions()->where('id', $exeuctionId)->first(); - if (is_null($execution)) { - $this->dispatch('error', 'Backup execution not found.'); - + if (!Hash::check($password, Auth::user()->password)) { + $this->addError('password', 'The provided password is incorrect.'); return; } + + $execution = $this->backup->executions()->where('id', $executionId)->first(); + if (is_null($execution)) { + $this->dispatch('error', 'Backup execution not found.'); + return; + } + if ($execution->scheduledDatabaseBackup->database->getMorphClass() === 'App\Models\ServiceDatabase') { delete_backup_locally($execution->filename, $execution->scheduledDatabaseBackup->database->service->destination->server); } else { delete_backup_locally($execution->filename, $execution->scheduledDatabaseBackup->database->destination->server); } + + if ($this->delete_backup_s3) { + // Add logic to delete from S3 + } + + if ($this->delete_backup_sftp) { + // Add logic to delete from SFTP + } + $execution->delete(); $this->dispatch('success', 'Backup deleted.'); $this->refreshBackupExecutions(); @@ -106,4 +127,14 @@ class BackupExecutions extends Component } return $dateObj->format('Y-m-d H:i:s T'); } + + public function render() + { + return view('livewire.project.database.backup-executions', [ + 'checkboxes' => [ + ['id' => 'delete_backup_s3', 'label' => 'Delete the selected backup permanently form S3 Storage'], + ['id' => 'delete_backup_sftp', 'label' => 'Delete the selected backup permanently form SFTP Storage'], + ] + ]); + } } diff --git a/app/Livewire/Project/Database/Heading.php b/app/Livewire/Project/Database/Heading.php index 6435f6781..765213f60 100644 --- a/app/Livewire/Project/Database/Heading.php +++ b/app/Livewire/Project/Database/Heading.php @@ -14,6 +14,8 @@ class Heading extends Component public array $parameters; + public $docker_cleanup = true; + public function getListeners() { $userId = auth()->user()->id; @@ -54,7 +56,7 @@ class Heading extends Component public function stop() { - StopDatabase::run($this->database); + StopDatabase::run($this->database, false, $this->docker_cleanup); $this->database->status = 'exited'; $this->database->save(); $this->check_status(); @@ -71,4 +73,13 @@ class Heading extends Component $activity = StartDatabase::run($this->database); $this->dispatch('activityMonitor', $activity->id); } + + public function render() + { + return view('livewire.project.database.heading', [ + 'checkboxes' => [ + ['id' => 'docker_cleanup', 'label' => 'Cleanup docker build cache and unused images (next deployment could take longer).'], + ], + ]); + } } diff --git a/app/Livewire/Project/DeleteEnvironment.php b/app/Livewire/Project/DeleteEnvironment.php index 22478916f..e01741770 100644 --- a/app/Livewire/Project/DeleteEnvironment.php +++ b/app/Livewire/Project/DeleteEnvironment.php @@ -13,9 +13,12 @@ class DeleteEnvironment extends Component public bool $disabled = false; + public string $environmentName = ''; + public function mount() { $this->parameters = get_route_parameters(); + $this->environmentName = Environment::findOrFail($this->environment_id)->name; } public function delete() diff --git a/app/Livewire/Project/DeleteProject.php b/app/Livewire/Project/DeleteProject.php index 499b86e3e..360fad10a 100644 --- a/app/Livewire/Project/DeleteProject.php +++ b/app/Livewire/Project/DeleteProject.php @@ -13,9 +13,12 @@ class DeleteProject extends Component public bool $disabled = false; + public string $projectName = ''; + public function mount() { $this->parameters = get_route_parameters(); + $this->projectName = Project::findOrFail($this->project_id)->name; } public function delete() diff --git a/app/Livewire/Project/Service/Configuration.php b/app/Livewire/Project/Service/Configuration.php index fa44fdfbf..a2e48fee7 100644 --- a/app/Livewire/Project/Service/Configuration.php +++ b/app/Livewire/Project/Service/Configuration.php @@ -52,7 +52,7 @@ class Configuration extends Component $application = $this->service->applications->find($id); if ($application) { $application->restart(); - $this->dispatch('success', 'Application restarted successfully.'); + $this->dispatch('success', 'Service application restarted successfully.'); } } catch (\Exception $e) { return handleError($e, $this); @@ -65,7 +65,7 @@ class Configuration extends Component $database = $this->service->databases->find($id); if ($database) { $database->restart(); - $this->dispatch('success', 'Database restarted successfully.'); + $this->dispatch('success', 'Service database restarted successfully.'); } } catch (\Exception $e) { return handleError($e, $this); diff --git a/app/Livewire/Project/Service/FileStorage.php b/app/Livewire/Project/Service/FileStorage.php index 6cd54883e..9fac897bf 100644 --- a/app/Livewire/Project/Service/FileStorage.php +++ b/app/Livewire/Project/Service/FileStorage.php @@ -15,6 +15,8 @@ use App\Models\StandaloneMysql; use App\Models\StandalonePostgresql; use App\Models\StandaloneRedis; use Livewire\Component; +use Illuminate\Support\Facades\Hash; +use Illuminate\Support\Facades\Auth; class FileStorage extends Component { @@ -83,8 +85,13 @@ class FileStorage extends Component } } - public function delete() + public function delete($password) { + if (!Hash::check($password, Auth::user()->password)) { + $this->addError('password', 'The provided password is incorrect.'); + return; + } + try { $message = 'File deleted.'; if ($this->fileStorage->is_directory) { @@ -127,8 +134,15 @@ class FileStorage extends Component $this->submit(); } - public function render() + public function render() { - return view('livewire.project.service.file-storage'); + return view('livewire.project.service.file-storage', [ + 'directoryDeletionCheckboxes' => [ + ['id' => 'permanently_delete', 'label' => 'The selected directory and all its contents will be permantely deleted form the server.'], + ], + 'fileDeletionCheckboxes' => [ + ['id' => 'permanently_delete', 'label' => 'The selected file will be permanently deleted form the server.'], + ] + ]); } } diff --git a/app/Livewire/Project/Service/Navbar.php b/app/Livewire/Project/Service/Navbar.php index e6bb6d9bf..42c9357fd 100644 --- a/app/Livewire/Project/Service/Navbar.php +++ b/app/Livewire/Project/Service/Navbar.php @@ -20,6 +20,8 @@ class Navbar extends Component public $isDeploymentProgress = false; + public $docker_cleanup = true; + public $title = 'Configuration'; public function mount() @@ -42,7 +44,7 @@ class Navbar extends Component public function serviceStarted() { - $this->dispatch('success', 'Service status changed.'); + // $this->dispatch('success', 'Service status changed.'); if (is_null($this->service->config_hash) || $this->service->isConfigurationChanged()) { $this->service->isConfigurationChanged(true); $this->dispatch('configurationChanged'); @@ -62,11 +64,6 @@ class Navbar extends Component $this->dispatch('success', 'Service status updated.'); } - public function render() - { - return view('livewire.project.service.navbar'); - } - public function checkDeployments() { try { @@ -97,14 +94,9 @@ class Navbar extends Component $this->dispatch('activityMonitor', $activity->id); } - public function stop(bool $forceCleanup = false) + public function stop() { - StopService::run($this->service); - if ($forceCleanup) { - $this->dispatch('success', 'Containers cleaned up.'); - } else { - $this->dispatch('success', 'Service stopped.'); - } + StopService::run($this->service, false, $this->docker_cleanup); ServiceStatusChanged::dispatch(); } @@ -123,4 +115,13 @@ class Navbar extends Component $activity = StartService::run($this->service); $this->dispatch('activityMonitor', $activity->id); } + + public function render() + { + return view('livewire.project.service.navbar', [ + 'checkboxes' => [ + ['id' => 'docker_cleanup', 'label' => __('resource.docker_cleanup')], + ], + ]); + } } diff --git a/app/Livewire/Project/Service/ServiceApplicationView.php b/app/Livewire/Project/Service/ServiceApplicationView.php index e7d00c3dd..56b506043 100644 --- a/app/Livewire/Project/Service/ServiceApplicationView.php +++ b/app/Livewire/Project/Service/ServiceApplicationView.php @@ -3,6 +3,8 @@ namespace App\Livewire\Project\Service; use App\Models\ServiceApplication; +use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\Hash; use Livewire\Component; class ServiceApplicationView extends Component @@ -11,6 +13,10 @@ class ServiceApplicationView extends Component public $parameters; + public $docker_cleanup = true; + + public $delete_volumes = true; + protected $rules = [ 'application.human_name' => 'nullable', 'application.description' => 'nullable', @@ -23,11 +29,6 @@ class ServiceApplicationView extends Component 'application.is_stripprefix_enabled' => 'nullable|boolean', ]; - public function render() - { - return view('livewire.project.service.service-application-view'); - } - public function updatedApplicationFqdn() { $this->application->fqdn = str($this->application->fqdn)->replaceEnd(',', '')->trim(); @@ -56,8 +57,14 @@ class ServiceApplicationView extends Component $this->dispatch('success', 'You need to restart the service for the changes to take effect.'); } - public function delete() + public function delete($password) { + if (! Hash::check($password, Auth::user()->password)) { + $this->addError('password', 'The provided password is incorrect.'); + + return; + } + try { $this->application->delete(); $this->dispatch('success', 'Application deleted.'); @@ -91,4 +98,17 @@ class ServiceApplicationView extends Component $this->dispatch('generateDockerCompose'); } } + + public function render() + { + return view('livewire.project.service.service-application-view', [ + 'checkboxes' => [ + ['id' => 'delete_volumes', 'label' => __('resource.delete_volumes')], + ['id' => 'docker_cleanup', 'label' => __('resource.docker_cleanup')], + // ['id' => 'delete_associated_backups_locally', 'label' => 'All backups associated with this Ressource will be permanently deleted from local storage.'], + // ['id' => 'delete_associated_backups_s3', 'label' => 'All backups associated with this Ressource will be permanently deleted from the selected S3 Storage.'], + // ['id' => 'delete_associated_backups_sftp', 'label' => 'All backups associated with this Ressource will be permanently deleted from the selected SFTP Storage.'] + ], + ]); + } } diff --git a/app/Livewire/Project/Shared/Danger.php b/app/Livewire/Project/Shared/Danger.php index 5f0178be4..543e64539 100644 --- a/app/Livewire/Project/Shared/Danger.php +++ b/app/Livewire/Project/Shared/Danger.php @@ -3,6 +3,11 @@ namespace App\Livewire\Project\Shared; use App\Jobs\DeleteResourceJob; +use App\Models\Service; +use App\Models\ServiceApplication; +use App\Models\ServiceDatabase; +use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\Hash; use Livewire\Component; use Visus\Cuid2\Cuid2; @@ -10,6 +15,8 @@ class Danger extends Component { public $resource; + public $resourceName; + public $projectUuid; public $environmentName; @@ -18,22 +25,93 @@ class Danger extends Component public bool $delete_volumes = true; + public bool $docker_cleanup = true; + + public bool $delete_connected_networks = true; + public ?string $modalId = null; + public string $resourceDomain = ''; + public function mount() { - $this->modalId = new Cuid2; $parameters = get_route_parameters(); + $this->modalId = new Cuid2; $this->projectUuid = data_get($parameters, 'project_uuid'); $this->environmentName = data_get($parameters, 'environment_name'); + + if ($this->resource === null) { + if (isset($parameters['service_uuid'])) { + $this->resource = Service::where('uuid', $parameters['service_uuid'])->first(); + } elseif (isset($parameters['stack_service_uuid'])) { + $this->resource = ServiceApplication::where('uuid', $parameters['stack_service_uuid'])->first() + ?? ServiceDatabase::where('uuid', $parameters['stack_service_uuid'])->first(); + } + } + + if ($this->resource === null) { + $this->resourceName = 'Unknown Resource'; + + return; + } + + if (! method_exists($this->resource, 'type')) { + $this->resourceName = 'Unknown Resource'; + + return; + } + + switch ($this->resource->type()) { + case 'application': + $this->resourceName = $this->resource->name ?? 'Application'; + break; + case 'standalone-postgresql': + case 'standalone-redis': + case 'standalone-mongodb': + case 'standalone-mysql': + case 'standalone-mariadb': + case 'standalone-keydb': + case 'standalone-dragonfly': + case 'standalone-clickhouse': + $this->resourceName = $this->resource->name ?? 'Database'; + break; + case 'service': + $this->resourceName = $this->resource->name ?? 'Service'; + break; + case 'service-application': + $this->resourceName = $this->resource->name ?? 'Service Application'; + break; + case 'service-database': + $this->resourceName = $this->resource->name ?? 'Service Database'; + break; + default: + $this->resourceName = 'Unknown Resource'; + } } - public function delete() + public function delete($password) { + if (! Hash::check($password, Auth::user()->password)) { + $this->addError('password', 'The provided password is incorrect.'); + + return; + } + + if (! $this->resource) { + $this->addError('resource', 'Resource not found.'); + + return; + } + try { - // $this->authorize('delete', $this->resource); $this->resource->delete(); - DeleteResourceJob::dispatch($this->resource, $this->delete_configurations, $this->delete_volumes); + DeleteResourceJob::dispatch( + $this->resource, + $this->delete_configurations, + $this->delete_volumes, + $this->docker_cleanup, + $this->delete_connected_networks + ); return redirect()->route('project.resource.index', [ 'project_uuid' => $this->projectUuid, @@ -43,4 +121,19 @@ class Danger extends Component return handleError($e, $this); } } + + public function render() + { + return view('livewire.project.shared.danger', [ + 'checkboxes' => [ + ['id' => 'delete_volumes', 'label' => __('resource.delete_volumes')], + ['id' => 'delete_connected_networks', 'label' => __('resource.delete_connected_networks')], + ['id' => 'delete_configurations', 'label' => __('resource.delete_configurations')], + ['id' => 'docker_cleanup', 'label' => __('resource.docker_cleanup')], + // ['id' => 'delete_associated_backups_locally', 'label' => 'All backups associated with this Ressource will be permanently deleted from local storage.'], + // ['id' => 'delete_associated_backups_s3', 'label' => 'All backups associated with this Ressource will be permanently deleted from the selected S3 Storage.'], + // ['id' => 'delete_associated_backups_sftp', 'label' => 'All backups associated with this Ressource will be permanently deleted from the selected SFTP Storage.'] + ], + ]); + } } diff --git a/app/Livewire/Project/Shared/Destination.php b/app/Livewire/Project/Shared/Destination.php index a2c018beb..1be724568 100644 --- a/app/Livewire/Project/Shared/Destination.php +++ b/app/Livewire/Project/Shared/Destination.php @@ -10,6 +10,8 @@ use App\Models\Server; use App\Models\StandaloneDocker; use Livewire\Component; use Visus\Cuid2\Cuid2; +use Illuminate\Support\Facades\Hash; +use Illuminate\Support\Facades\Auth; class Destination extends Component { @@ -115,8 +117,13 @@ class Destination extends Component ApplicationStatusChanged::dispatch(data_get($this->resource, 'environment.project.team.id')); } - public function removeServer(int $network_id, int $server_id) + public function removeServer(int $network_id, int $server_id, $password) { + if (!Hash::check($password, Auth::user()->password)) { + $this->addError('password', 'The provided password is incorrect.'); + return; + } + if ($this->resource->destination->server->id == $server_id && $this->resource->destination->id == $network_id) { $this->dispatch('error', 'You cannot remove this destination server.', 'You are trying to remove the main server.'); diff --git a/app/Livewire/Project/Shared/GetLogs.php b/app/Livewire/Project/Shared/GetLogs.php index deccc875c..b48ee7e23 100644 --- a/app/Livewire/Project/Shared/GetLogs.php +++ b/app/Livewire/Project/Shared/GetLogs.php @@ -17,6 +17,7 @@ use App\Models\StandalonePostgresql; use App\Models\StandaloneRedis; use Illuminate\Support\Facades\Process; use Livewire\Component; +use App\Helpers\SshMultiplexingHelper; class GetLogs extends Component { @@ -108,14 +109,14 @@ class GetLogs extends Component $command = parseCommandsByLineForSudo(collect($command), $this->server); $command = $command[0]; } - $sshCommand = generateSshCommand($this->server, $command); + $sshCommand = SshMultiplexingHelper::generateSshCommand($this->server, $command); } else { $command = "docker logs -n {$this->numberOfLines} -t {$this->container}"; if ($this->server->isNonRoot()) { $command = parseCommandsByLineForSudo(collect($command), $this->server); $command = $command[0]; } - $sshCommand = generateSshCommand($this->server, $command); + $sshCommand = SshMultiplexingHelper::generateSshCommand($this->server, $command); } } else { if ($this->server->isSwarm()) { @@ -124,14 +125,14 @@ class GetLogs extends Component $command = parseCommandsByLineForSudo(collect($command), $this->server); $command = $command[0]; } - $sshCommand = generateSshCommand($this->server, $command); + $sshCommand = SshMultiplexingHelper::generateSshCommand($this->server, $command); } else { $command = "docker logs -n {$this->numberOfLines} {$this->container}"; if ($this->server->isNonRoot()) { $command = parseCommandsByLineForSudo(collect($command), $this->server); $command = $command[0]; } - $sshCommand = generateSshCommand($this->server, $command); + $sshCommand = SshMultiplexingHelper::generateSshCommand($this->server, $command); } } if ($refresh) { diff --git a/app/Livewire/Project/Shared/ScheduledTask/Show.php b/app/Livewire/Project/Shared/ScheduledTask/Show.php index 8be4ff643..37f50dd32 100644 --- a/app/Livewire/Project/Shared/ScheduledTask/Show.php +++ b/app/Livewire/Project/Shared/ScheduledTask/Show.php @@ -20,6 +20,8 @@ class Show extends Component public string $type; + public string $scheduledTaskName; + protected $rules = [ 'task.enabled' => 'required|boolean', 'task.name' => 'required|string', @@ -49,6 +51,7 @@ class Show extends Component $this->modalId = new Cuid2; $this->task = ModelsScheduledTask::where('uuid', request()->route('task_uuid'))->first(); + $this->scheduledTaskName = $this->task->name; } public function instantSave() @@ -75,9 +78,9 @@ class Show extends Component $this->task->delete(); if ($this->type == 'application') { - return redirect()->route('project.application.configuration', $this->parameters); + return redirect()->route('project.application.configuration', $this->parameters, $this->scheduledTaskName); } else { - return redirect()->route('project.service.configuration', $this->parameters); + return redirect()->route('project.service.configuration', $this->parameters, $this->scheduledTaskName); } } catch (\Exception $e) { return handleError($e); diff --git a/app/Livewire/Project/Shared/Storages/Show.php b/app/Livewire/Project/Shared/Storages/Show.php index 08f51ce08..a0cb54aa0 100644 --- a/app/Livewire/Project/Shared/Storages/Show.php +++ b/app/Livewire/Project/Shared/Storages/Show.php @@ -3,6 +3,8 @@ namespace App\Livewire\Project\Shared\Storages; use App\Models\LocalPersistentVolume; +use Illuminate\Support\Facades\Hash; +use Illuminate\Support\Facades\Auth; use Livewire\Component; class Show extends Component @@ -36,8 +38,13 @@ class Show extends Component $this->dispatch('success', 'Storage updated successfully'); } - public function delete() + public function delete($password) { + if (!Hash::check($password, Auth::user()->password)) { + $this->addError('password', 'The provided password is incorrect.'); + return; + } + $this->storage->delete(); $this->dispatch('refreshStorages'); } diff --git a/app/Livewire/Project/Shared/Terminal.php b/app/Livewire/Project/Shared/Terminal.php index 802e65a30..5fd098e9f 100644 --- a/app/Livewire/Project/Shared/Terminal.php +++ b/app/Livewire/Project/Shared/Terminal.php @@ -2,6 +2,7 @@ namespace App\Livewire\Project\Shared; +use App\Helpers\SshMultiplexingHelper; use App\Models\Server; use Livewire\Attributes\On; use Livewire\Component; @@ -19,9 +20,9 @@ class Terminal extends Component if ($status !== 'running') { return; } - $command = generateSshCommand($server, "docker exec -it {$identifier} sh -c 'if [ -f ~/.profile ]; then . ~/.profile; fi; if [ -n \"\$SHELL\" ]; then exec \$SHELL; else sh; fi'"); + $command = SshMultiplexingHelper::generateSshCommand($server, "docker exec -it {$identifier} sh -c 'if [ -f ~/.profile ]; then . ~/.profile; fi; if [ -n \"\$SHELL\" ]; then exec \$SHELL; else sh; fi'"); } else { - $command = generateSshCommand($server, "sh -c 'if [ -f ~/.profile ]; then . ~/.profile; fi; if [ -n \"\$SHELL\" ]; then exec \$SHELL; else sh; fi'"); + $command = SshMultiplexingHelper::generateSshCommand($server, "sh -c 'if [ -f ~/.profile ]; then . ~/.profile; fi; if [ -n \"\$SHELL\" ]; then exec \$SHELL; else sh; fi'"); } // ssh command is sent back to frontend then to websocket diff --git a/app/Livewire/Security/PrivateKey/Create.php b/app/Livewire/Security/PrivateKey/Create.php index 32a67bbea..95f6a71bf 100644 --- a/app/Livewire/Security/PrivateKey/Create.php +++ b/app/Livewire/Security/PrivateKey/Create.php @@ -3,22 +3,14 @@ namespace App\Livewire\Security\PrivateKey; use App\Models\PrivateKey; -use DanHarrin\LivewireRateLimiting\WithRateLimiting; use Livewire\Component; -use phpseclib3\Crypt\PublicKeyLoader; class Create extends Component { - use WithRateLimiting; - - public string $name; - - public string $value; - + public string $name = ''; + public string $value = ''; public ?string $from = null; - public ?string $description = null; - public ?string $publicKey = null; protected $rules = [ @@ -26,72 +18,69 @@ class Create extends Component 'value' => 'required|string', ]; - protected $validationAttributes = [ - 'name' => 'name', - 'value' => 'private Key', - ]; - public function generateNewRSAKey() { - try { - $this->rateLimit(10); - $this->name = generate_random_name(); - $this->description = 'Created by Coolify'; - ['private' => $this->value, 'public' => $this->publicKey] = generateSSHKey(); - } catch (\Throwable $e) { - return handleError($e, $this); - } + $this->generateNewKey('rsa'); } public function generateNewEDKey() { - try { - $this->rateLimit(10); - $this->name = generate_random_name(); - $this->description = 'Created by Coolify'; - ['private' => $this->value, 'public' => $this->publicKey] = generateSSHKey('ed25519'); - } catch (\Throwable $e) { - return handleError($e, $this); - } + $this->generateNewKey('ed25519'); } - public function updated($updateProperty) + private function generateNewKey($type) { - if ($updateProperty === 'value') { - try { - $this->publicKey = PublicKeyLoader::load($this->$updateProperty)->getPublicKey()->toString('OpenSSH', ['comment' => '']); - } catch (\Throwable $e) { - if ($this->$updateProperty === '') { - $this->publicKey = ''; - } else { - $this->publicKey = 'Invalid private key'; - } - } + $keyData = PrivateKey::generateNewKeyPair($type); + $this->setKeyData($keyData); + } + + public function updated($property) + { + if ($property === 'value') { + $this->validatePrivateKey(); } - $this->validateOnly($updateProperty); } public function createPrivateKey() { $this->validate(); + try { - $this->value = trim($this->value); - if (! str_ends_with($this->value, "\n")) { - $this->value .= "\n"; - } - $private_key = PrivateKey::create([ + $privateKey = PrivateKey::createAndStore([ 'name' => $this->name, 'description' => $this->description, - 'private_key' => $this->value, + 'private_key' => trim($this->value) . "\n", 'team_id' => currentTeam()->id, ]); - if ($this->from === 'server') { - return redirect()->route('dashboard'); - } - return redirect()->route('security.private-key.show', ['private_key_uuid' => $private_key->uuid]); + return $this->redirectAfterCreation($privateKey); } catch (\Throwable $e) { return handleError($e, $this); } } + + private function setKeyData(array $keyData) + { + $this->name = $keyData['name']; + $this->description = $keyData['description']; + $this->value = $keyData['private_key']; + $this->publicKey = $keyData['public_key']; + } + + private function validatePrivateKey() + { + $validationResult = PrivateKey::validateAndExtractPublicKey($this->value); + $this->publicKey = $validationResult['publicKey']; + + if (!$validationResult['isValid']) { + $this->addError('value', 'Invalid private key'); + } + } + + private function redirectAfterCreation(PrivateKey $privateKey) + { + return $this->from === 'server' + ? redirect()->route('dashboard') + : redirect()->route('security.private-key.show', ['private_key_uuid' => $privateKey->uuid]); + } } diff --git a/app/Livewire/Security/PrivateKey/Index.php b/app/Livewire/Security/PrivateKey/Index.php new file mode 100644 index 000000000..0b50ba1d8 --- /dev/null +++ b/app/Livewire/Security/PrivateKey/Index.php @@ -0,0 +1,24 @@ +get(); + + return view('livewire.security.private-key.index', [ + 'privateKeys' => $privateKeys, + ])->layout('components.layout'); + } + + public function cleanupUnusedKeys() + { + PrivateKey::cleanupUnusedKeys(); + $this->dispatch('success', 'Unused keys have been cleaned up.'); + } +} diff --git a/app/Livewire/Security/PrivateKey/Show.php b/app/Livewire/Security/PrivateKey/Show.php index d86bd5d1e..249c84f14 100644 --- a/app/Livewire/Security/PrivateKey/Show.php +++ b/app/Livewire/Security/PrivateKey/Show.php @@ -29,25 +29,27 @@ class Show extends Component try { $this->private_key = PrivateKey::ownedByCurrentTeam(['name', 'description', 'private_key', 'is_git_related'])->whereUuid(request()->private_key_uuid)->firstOrFail(); } catch (\Throwable $e) { - return handleError($e, $this); + abort(404); } } public function loadPublicKey() { - $this->public_key = $this->private_key->publicKey(); + $this->public_key = $this->private_key->getPublicKey(); + if ($this->public_key === 'Error loading private key') { + $this->dispatch('error', 'Failed to load public key. The private key may be invalid.'); + } } public function delete() { try { - if ($this->private_key->isEmpty()) { - $this->private_key->delete(); - currentTeam()->privateKeys = PrivateKey::where('team_id', currentTeam()->id)->get(); + $this->private_key->safeDelete(); + currentTeam()->privateKeys = PrivateKey::where('team_id', currentTeam()->id)->get(); - return redirect()->route('security.private-key.index'); - } - $this->dispatch('error', 'This private key is in use and cannot be deleted. Please delete all servers, applications, and GitHub/GitLab apps that use this private key before deleting it.'); + return redirect()->route('security.private-key.index'); + } catch (\Exception $e) { + $this->dispatch('error', $e->getMessage()); } catch (\Throwable $e) { return handleError($e, $this); } @@ -56,8 +58,9 @@ class Show extends Component public function changePrivateKey() { try { - $this->private_key->private_key = formatPrivateKey($this->private_key->private_key); - $this->private_key->save(); + $this->private_key->updatePrivateKey([ + 'private_key' => formatPrivateKey($this->private_key->private_key), + ]); refresh_server_connection($this->private_key); $this->dispatch('success', 'Private key updated.'); } catch (\Throwable $e) { diff --git a/app/Livewire/Server/Delete.php b/app/Livewire/Server/Delete.php index 3beec0c91..08e91a4c7 100644 --- a/app/Livewire/Server/Delete.php +++ b/app/Livewire/Server/Delete.php @@ -4,6 +4,8 @@ namespace App\Livewire\Server; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; +use Illuminate\Support\Facades\Hash; +use Illuminate\Support\Facades\Auth; class Delete extends Component { @@ -11,8 +13,12 @@ class Delete extends Component public $server; - public function delete() + public function delete($password) { + if (!Hash::check($password, Auth::user()->password)) { + $this->addError('password', 'The provided password is incorrect.'); + return; + } try { $this->authorize('delete', $this->server); if ($this->server->hasDefinedResources()) { diff --git a/app/Livewire/Server/Proxy.php b/app/Livewire/Server/Proxy.php index 123b29d70..55d0c4966 100644 --- a/app/Livewire/Server/Proxy.php +++ b/app/Livewire/Server/Proxy.php @@ -39,6 +39,7 @@ class Proxy extends Component { $this->server->proxy = null; $this->server->save(); + $this->dispatch('proxyChanged'); } public function selectProxy($proxy_type) @@ -47,7 +48,7 @@ class Proxy extends Component $this->server->proxy->set('type', $proxy_type); $this->server->save(); $this->selectedProxy = $this->server->proxy->type; - if ($this->selectedProxy !== 'NONE') { + if ($this->server->proxySet()) { StartProxy::run($this->server, false); } $this->dispatch('proxyStatusUpdated'); diff --git a/app/Livewire/Server/Proxy/Deploy.php b/app/Livewire/Server/Proxy/Deploy.php index 2279951ee..c66ed1795 100644 --- a/app/Livewire/Server/Proxy/Deploy.php +++ b/app/Livewire/Server/Proxy/Deploy.php @@ -7,6 +7,8 @@ use App\Actions\Proxy\StartProxy; use App\Events\ProxyStatusChanged; use App\Models\Server; use Livewire\Component; +use Illuminate\Support\Facades\Process; +use Illuminate\Process\InvokedProcess; class Deploy extends Component { @@ -29,6 +31,7 @@ class Deploy extends Component 'serverRefresh' => 'proxyStatusUpdated', 'checkProxy', 'startProxy', + 'proxyChanged' => 'proxyStatusUpdated', ]; } @@ -94,21 +97,43 @@ class Deploy extends Component public function stop(bool $forceStop = true) { try { - if ($this->server->isSwarm()) { - instant_remote_process([ - 'docker service rm coolify-proxy_traefik', - ], $this->server); - } else { - instant_remote_process([ - 'docker rm -f coolify-proxy', - ], $this->server); + $containerName = $this->server->isSwarm() ? 'coolify-proxy_traefik' : 'coolify-proxy'; + $timeout = 30; + + $process = $this->stopContainer($containerName, $timeout); + + $startTime = time(); + while ($process->running()) { + if (time() - $startTime >= $timeout) { + $this->forceStopContainer($containerName); + break; + } + usleep(100000); } - $this->server->proxy->status = 'exited'; - $this->server->proxy->force_stop = $forceStop; - $this->server->save(); - $this->dispatch('proxyStatusUpdated'); + + $this->removeContainer($containerName); } catch (\Throwable $e) { return handleError($e, $this); + } finally { + $this->server->proxy->force_stop = $forceStop; + $this->server->proxy->status = 'exited'; + $this->server->save(); + $this->dispatch('proxyStatusUpdated'); } } + + private function stopContainer(string $containerName, int $timeout): InvokedProcess + { + return Process::timeout($timeout)->start("docker stop --time=$timeout $containerName"); + } + + private function forceStopContainer(string $containerName) + { + instant_remote_process(["docker kill $containerName"], $this->server, throwError: false); + } + + private function removeContainer(string $containerName) + { + instant_remote_process(["docker rm -f $containerName"], $this->server, throwError: false); + } } diff --git a/app/Livewire/Server/Proxy/Show.php b/app/Livewire/Server/Proxy/Show.php index cef909a45..d70e44e55 100644 --- a/app/Livewire/Server/Proxy/Show.php +++ b/app/Livewire/Server/Proxy/Show.php @@ -11,7 +11,7 @@ class Show extends Component public $parameters = []; - protected $listeners = ['proxyStatusUpdated']; + protected $listeners = ['proxyStatusUpdated', 'proxyChanged' => 'proxyStatusUpdated']; public function proxyStatusUpdated() { diff --git a/app/Livewire/Server/ShowPrivateKey.php b/app/Livewire/Server/ShowPrivateKey.php index 578a08967..92869c44b 100644 --- a/app/Livewire/Server/ShowPrivateKey.php +++ b/app/Livewire/Server/ShowPrivateKey.php @@ -2,6 +2,7 @@ namespace App\Livewire\Server; +use App\Models\PrivateKey; use App\Models\Server; use Livewire\Component; @@ -13,25 +14,15 @@ class ShowPrivateKey extends Component public $parameters; - public function setPrivateKey($newPrivateKeyId) + public function setPrivateKey($privateKeyId) { try { - $oldPrivateKeyId = $this->server->private_key_id; - refresh_server_connection($this->server->privateKey); - $this->server->update([ - 'private_key_id' => $newPrivateKeyId, - ]); + $privateKey = PrivateKey::findOrFail($privateKeyId); + $this->server->update(['private_key_id' => $privateKey->id]); $this->server->refresh(); - refresh_server_connection($this->server->privateKey); - $this->checkConnection(); - } catch (\Throwable $e) { - $this->server->update([ - 'private_key_id' => $oldPrivateKeyId, - ]); - $this->server->refresh(); - refresh_server_connection($this->server->privateKey); - - return handleError($e, $this); + $this->dispatch('success', 'Private key updated successfully.'); + } catch (\Exception $e) { + $this->dispatch('error', 'Failed to update private key: '.$e->getMessage()); } } @@ -43,7 +34,7 @@ class ShowPrivateKey extends Component $this->dispatch('success', 'Server is reachable.'); } else { ray($error); - $this->dispatch('error', 'Server is not reachable.
Please validate your configuration and connection.

Check this documentation for further help.'); + $this->dispatch('error', 'Server is not reachable.

Check this documentation for further help.

Error: '.$error); return; } diff --git a/app/Livewire/Team/AdminView.php b/app/Livewire/Team/AdminView.php index 97d4fcdbf..ee5e673a5 100644 --- a/app/Livewire/Team/AdminView.php +++ b/app/Livewire/Team/AdminView.php @@ -5,6 +5,8 @@ namespace App\Livewire\Team; use App\Models\Team; use App\Models\User; use Livewire\Component; +use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\Hash; class AdminView extends Component { @@ -73,8 +75,12 @@ class AdminView extends Component $team->delete(); } - public function delete($id) + public function delete($id, $password) { + if (!Hash::check($password, Auth::user()->password)) { + $this->addError('password', 'The provided password is incorrect.'); + return; + } if (! auth()->user()->isInstanceAdmin()) { return $this->dispatch('error', 'You are not authorized to delete users'); } diff --git a/app/Models/Application.php b/app/Models/Application.php index d0cc34a06..8c7520eaf 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -8,6 +8,8 @@ use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Support\Collection; use Illuminate\Support\Str; +use Illuminate\Support\Facades\Process; +use Illuminate\Process\InvokedProcess; use OpenApi\Attributes as OA; use RuntimeException; use Spatie\Activitylog\Models\Activity; @@ -149,13 +151,65 @@ class Application extends BaseModel return Application::whereRelation('environment.project.team', 'id', $teamId)->orderBy('name'); } + public function getContainersToStop(bool $previewDeployments = false): array + { + $containers = $previewDeployments + ? getCurrentApplicationContainerStatus($this->destination->server, $this->id, includePullrequests: true) + : getCurrentApplicationContainerStatus($this->destination->server, $this->id, 0); + + return $containers->pluck('Names')->toArray(); + } + + public function stopContainers(array $containerNames, $server, int $timeout = 600) + { + $processes = []; + foreach ($containerNames as $containerName) { + $processes[$containerName] = $this->stopContainer($containerName, $server, $timeout); + } + + $startTime = time(); + while (count($processes) > 0) { + $finishedProcesses = array_filter($processes, function ($process) { + return !$process->running(); + }); + foreach ($finishedProcesses as $containerName => $process) { + unset($processes[$containerName]); + $this->removeContainer($containerName, $server); + } + + if (time() - $startTime >= $timeout) { + $this->forceStopRemainingContainers(array_keys($processes), $server); + break; + } + + usleep(100000); + } + } + + public function stopContainer(string $containerName, $server, int $timeout): InvokedProcess + { + return Process::timeout($timeout)->start("docker stop --time=$timeout $containerName"); + } + + public function removeContainer(string $containerName, $server) + { + instant_remote_process(command: ["docker rm -f $containerName"], server: $server, throwError: false); + } + + public function forceStopRemainingContainers(array $containerNames, $server) + { + foreach ($containerNames as $containerName) { + instant_remote_process(command: ["docker kill $containerName"], server: $server, throwError: false); + $this->removeContainer($containerName, $server); + } + } + public function delete_configurations() { $server = data_get($this, 'destination.server'); $workdir = $this->workdir(); if (str($workdir)->endsWith($this->uuid)) { - ray('Deleting workdir'); - instant_remote_process(['rm -rf '.$this->workdir()], $server, false); + instant_remote_process(['rm -rf ' . $this->workdir()], $server, false); } } @@ -176,6 +230,14 @@ class Application extends BaseModel } } + public function delete_connected_networks($uuid) + { + $server = data_get($this, 'destination.server'); + instant_remote_process(["docker network disconnect {$uuid} coolify-proxy"], $server, false); + instant_remote_process(["docker network rm {$uuid}"], $server, false); + } + + public function additional_servers() { return $this->belongsToMany(Server::class, 'additional_destinations') @@ -283,7 +345,7 @@ class Application extends BaseModel public function publishDirectory(): Attribute { return Attribute::make( - set: fn ($value) => $value ? '/'.ltrim($value, '/') : null, + set: fn ($value) => $value ? '/' . ltrim($value, '/') : null, ); } @@ -291,7 +353,7 @@ class Application extends BaseModel { return Attribute::make( get: function () { - if (! is_null($this->source?->html_url) && ! is_null($this->git_repository) && ! is_null($this->git_branch)) { + if (!is_null($this->source?->html_url) && !is_null($this->git_repository) && !is_null($this->git_branch)) { if (str($this->git_repository)->contains('bitbucket')) { return "{$this->source->html_url}/{$this->git_repository}/src/{$this->git_branch}"; } @@ -318,7 +380,7 @@ class Application extends BaseModel { return Attribute::make( get: function () { - if (! is_null($this->source?->html_url) && ! is_null($this->git_repository) && ! is_null($this->git_branch)) { + if (!is_null($this->source?->html_url) && !is_null($this->git_repository) && !is_null($this->git_branch)) { return "{$this->source->html_url}/{$this->git_repository}/settings/hooks"; } // Convert the SSH URL to HTTPS URL @@ -337,7 +399,7 @@ class Application extends BaseModel { return Attribute::make( get: function () { - if (! is_null($this->source?->html_url) && ! is_null($this->git_repository) && ! is_null($this->git_branch)) { + if (!is_null($this->source?->html_url) && !is_null($this->git_repository) && !is_null($this->git_branch)) { return "{$this->source->html_url}/{$this->git_repository}/commits/{$this->git_branch}"; } // Convert the SSH URL to HTTPS URL @@ -354,7 +416,7 @@ class Application extends BaseModel public function gitCommitLink($link): string { - if (! is_null(data_get($this, 'source.html_url')) && ! is_null(data_get($this, 'git_repository')) && ! is_null(data_get($this, 'git_branch'))) { + if (!is_null(data_get($this, 'source.html_url')) && !is_null(data_get($this, 'git_repository')) && !is_null(data_get($this, 'git_branch'))) { if (str($this->source->html_url)->contains('bitbucket')) { return "{$this->source->html_url}/{$this->git_repository}/commits/{$link}"; } @@ -365,7 +427,7 @@ class Application extends BaseModel $git_repository = str_replace('.git', '', $this->git_repository); $url = Url::fromString($git_repository); $url = $url->withUserInfo(''); - $url = $url->withPath($url->getPath().'/commits/'.$link); + $url = $url->withPath($url->getPath() . '/commits/' . $link); return $url->__toString(); } @@ -418,7 +480,7 @@ class Application extends BaseModel public function baseDirectory(): Attribute { return Attribute::make( - set: fn ($value) => '/'.ltrim($value, '/'), + set: fn ($value) => '/' . ltrim($value, '/'), ); } @@ -761,7 +823,7 @@ class Application extends BaseModel public function workdir() { - return application_configuration_dir()."/{$this->uuid}"; + return application_configuration_dir() . "/{$this->uuid}"; } public function isLogDrainEnabled() @@ -771,7 +833,7 @@ class Application extends BaseModel public function isConfigurationChanged(bool $save = false) { - $newConfigHash = $this->fqdn.$this->git_repository.$this->git_branch.$this->git_commit_sha.$this->build_pack.$this->static_image.$this->install_command.$this->build_command.$this->start_command.$this->ports_exposes.$this->ports_mappings.$this->base_directory.$this->publish_directory.$this->dockerfile.$this->dockerfile_location.$this->custom_labels.$this->custom_docker_run_options.$this->dockerfile_target_build.$this->redirect; + $newConfigHash = $this->fqdn . $this->git_repository . $this->git_branch . $this->git_commit_sha . $this->build_pack . $this->static_image . $this->install_command . $this->build_command . $this->start_command . $this->ports_exposes . $this->ports_mappings . $this->base_directory . $this->publish_directory . $this->dockerfile . $this->dockerfile_location . $this->custom_labels . $this->custom_docker_run_options . $this->dockerfile_target_build . $this->redirect; if ($this->pull_request_id === 0 || $this->pull_request_id === null) { $newConfigHash .= json_encode($this->environment_variables()->get('value')->sort()); } else { @@ -825,7 +887,7 @@ class Application extends BaseModel public function dirOnServer() { - return application_configuration_dir()."/{$this->uuid}"; + return application_configuration_dir() . "/{$this->uuid}"; } public function setGitImportSettings(string $deployment_uuid, string $git_clone_command, bool $public = false) @@ -871,7 +933,7 @@ class Application extends BaseModel if ($this->source->is_public) { $fullRepoUrl = "{$this->source->html_url}/{$customRepository}"; $git_clone_command = "{$git_clone_command} {$this->source->html_url}/{$customRepository} {$baseDir}"; - if (! $only_checkout) { + if (!$only_checkout) { $git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command, public: true); } if ($exec_in_docker) { @@ -888,7 +950,7 @@ class Application extends BaseModel $git_clone_command = "{$git_clone_command} $source_html_url_scheme://x-access-token:$github_access_token@$source_html_url_host/{$customRepository} {$baseDir}"; $fullRepoUrl = "$source_html_url_scheme://x-access-token:$github_access_token@$source_html_url_host/{$customRepository}"; } - if (! $only_checkout) { + if (!$only_checkout) { $git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command, public: false); } if ($exec_in_docker) { @@ -949,7 +1011,7 @@ class Application extends BaseModel } else { $commands->push("echo 'Checking out $branch'"); } - $git_clone_command = "{$git_clone_command} && cd {$baseDir} && GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name); + $git_clone_command = "{$git_clone_command} && cd {$baseDir} && GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" git fetch origin $branch && " . $this->buildGitCheckoutCommand($pr_branch_name); } elseif ($git_type === 'github' || $git_type === 'gitea') { $branch = "pull/{$pull_request_id}/head:$pr_branch_name"; if ($exec_in_docker) { @@ -957,14 +1019,14 @@ class Application extends BaseModel } else { $commands->push("echo 'Checking out $branch'"); } - $git_clone_command = "{$git_clone_command} && cd {$baseDir} && GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name); + $git_clone_command = "{$git_clone_command} && cd {$baseDir} && GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" git fetch origin $branch && " . $this->buildGitCheckoutCommand($pr_branch_name); } elseif ($git_type === 'bitbucket') { if ($exec_in_docker) { $commands->push(executeInDocker($deployment_uuid, "echo 'Checking out $branch'")); } else { $commands->push("echo 'Checking out $branch'"); } - $git_clone_command = "{$git_clone_command} && cd {$baseDir} && GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" ".$this->buildGitCheckoutCommand($commit); + $git_clone_command = "{$git_clone_command} && cd {$baseDir} && GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" " . $this->buildGitCheckoutCommand($commit); } } @@ -993,7 +1055,7 @@ class Application extends BaseModel } else { $commands->push("echo 'Checking out $branch'"); } - $git_clone_command = "{$git_clone_command} && cd {$baseDir} && GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name); + $git_clone_command = "{$git_clone_command} && cd {$baseDir} && GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" git fetch origin $branch && " . $this->buildGitCheckoutCommand($pr_branch_name); } elseif ($git_type === 'github' || $git_type === 'gitea') { $branch = "pull/{$pull_request_id}/head:$pr_branch_name"; if ($exec_in_docker) { @@ -1001,14 +1063,14 @@ class Application extends BaseModel } else { $commands->push("echo 'Checking out $branch'"); } - $git_clone_command = "{$git_clone_command} && cd {$baseDir} && GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name); + $git_clone_command = "{$git_clone_command} && cd {$baseDir} && GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" git fetch origin $branch && " . $this->buildGitCheckoutCommand($pr_branch_name); } elseif ($git_type === 'bitbucket') { if ($exec_in_docker) { $commands->push(executeInDocker($deployment_uuid, "echo 'Checking out $branch'")); } else { $commands->push("echo 'Checking out $branch'"); } - $git_clone_command = "{$git_clone_command} && cd {$baseDir} && GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" ".$this->buildGitCheckoutCommand($commit); + $git_clone_command = "{$git_clone_command} && cd {$baseDir} && GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" " . $this->buildGitCheckoutCommand($commit); } } @@ -1060,20 +1122,20 @@ class Application extends BaseModel } if ($source->startsWith('.')) { $source = $source->after('.'); - $source = $workdir.$source; + $source = $workdir . $source; } $commands->push("mkdir -p $source > /dev/null 2>&1 || true"); } } } $labels = collect(data_get($service, 'labels', [])); - if (! $labels->contains('coolify.managed')) { + if (!$labels->contains('coolify.managed')) { $labels->push('coolify.managed=true'); } - if (! $labels->contains('coolify.applicationId')) { - $labels->push('coolify.applicationId='.$this->id); + if (!$labels->contains('coolify.applicationId')) { + $labels->push('coolify.applicationId=' . $this->id); } - if (! $labels->contains('coolify.type')) { + if (!$labels->contains('coolify.type')) { $labels->push('coolify.type=application'); } data_set($service, 'labels', $labels->toArray()); @@ -1149,7 +1211,7 @@ class Application extends BaseModel $jsonNames = $json->keys()->toArray(); $diff = array_diff($jsonNames, $names); $json = $json->filter(function ($value, $key) use ($diff) { - return ! in_array($key, $diff); + return !in_array($key, $diff); }); if ($json) { $this->docker_compose_domains = json_encode($json); @@ -1166,13 +1228,12 @@ class Application extends BaseModel } else { throw new \RuntimeException("Docker Compose file not found at: $workdir$composeFile

Check if you used the right extension (.yaml or .yml) in the compose file name."); } - } public function parseContainerLabels(?ApplicationPreview $preview = null) { $customLabels = data_get($this, 'custom_labels'); - if (! $customLabels) { + if (!$customLabels) { return; } if (base64_encode(base64_decode($customLabels, true)) !== $customLabels) { @@ -1255,10 +1316,10 @@ class Application extends BaseModel continue; } if (isset($healthcheckCommand) && str_contains($trimmedLine, '\\')) { - $healthcheckCommand .= ' '.trim($trimmedLine, '\\ '); + $healthcheckCommand .= ' ' . trim($trimmedLine, '\\ '); } - if (isset($healthcheckCommand) && ! str_contains($trimmedLine, '\\') && ! empty($healthcheckCommand)) { - $healthcheckCommand .= ' '.$trimmedLine; + if (isset($healthcheckCommand) && !str_contains($trimmedLine, '\\') && !empty($healthcheckCommand)) { + $healthcheckCommand .= ' ' . $trimmedLine; break; } } diff --git a/app/Models/PrivateKey.php b/app/Models/PrivateKey.php index 45bc6bc84..065746ede 100644 --- a/app/Models/PrivateKey.php +++ b/app/Models/PrivateKey.php @@ -2,6 +2,9 @@ namespace App\Models; +use DanHarrin\LivewireRateLimiting\WithRateLimiting; +use Illuminate\Support\Facades\Storage; +use Illuminate\Validation\ValidationException; use OpenApi\Attributes as OA; use phpseclib3\Crypt\PublicKeyLoader; @@ -22,48 +25,144 @@ use phpseclib3\Crypt\PublicKeyLoader; )] class PrivateKey extends BaseModel { + use WithRateLimiting; + protected $fillable = [ 'name', 'description', 'private_key', 'is_git_related', 'team_id', + 'fingerprint', + ]; + + protected $casts = [ + 'private_key' => 'encrypted', ]; protected static function booted() { static::saving(function ($key) { - $privateKey = data_get($key, 'private_key'); - if (substr($privateKey, -1) !== "\n") { - $key->private_key = $privateKey."\n"; + $key->private_key = formatPrivateKey($key->private_key); + + if (! self::validatePrivateKey($key->private_key)) { + throw ValidationException::withMessages([ + 'private_key' => ['The private key is invalid.'], + ]); + } + + $key->fingerprint = self::generateFingerprint($key->private_key); + if (self::fingerprintExists($key->fingerprint, $key->id)) { + throw ValidationException::withMessages([ + 'private_key' => ['This private key already exists.'], + ]); } }); + static::deleted(function ($key) { + self::deleteFromStorage($key); + }); + } + + public function getPublicKey() + { + return self::extractPublicKeyFromPrivate($this->private_key) ?? 'Error loading private key'; } public static function ownedByCurrentTeam(array $select = ['*']) { $selectArray = collect($select)->concat(['id']); - return PrivateKey::whereTeamId(currentTeam()->id)->select($selectArray->all()); + return self::whereTeamId(currentTeam()->id)->select($selectArray->all()); } - public function publicKey() + public static function validatePrivateKey($privateKey) { try { - return PublicKeyLoader::load($this->private_key)->getPublicKey()->toString('OpenSSH', ['comment' => '']); + PublicKeyLoader::load($privateKey); + + return true; } catch (\Throwable $e) { - return 'Error loading private key'; + return false; } } - public function isEmpty() + public static function createAndStore(array $data) { - if ($this->servers()->count() === 0 && $this->applications()->count() === 0 && $this->githubApps()->count() === 0 && $this->gitlabApps()->count() === 0) { - return true; - } + $privateKey = new self($data); + $privateKey->save(); + $privateKey->storeInFileSystem(); - return false; + return $privateKey; + } + + public static function generateNewKeyPair($type = 'rsa') + { + try { + $instance = new self; + $instance->rateLimit(10); + $name = generate_random_name(); + $description = 'Created by Coolify'; + $keyPair = generateSSHKey($type === 'ed25519' ? 'ed25519' : 'rsa'); + + return [ + 'name' => $name, + 'description' => $description, + 'private_key' => $keyPair['private'], + 'public_key' => $keyPair['public'], + ]; + } catch (\Throwable $e) { + throw new \Exception("Failed to generate new {$type} key: ".$e->getMessage()); + } + } + + public static function extractPublicKeyFromPrivate($privateKey) + { + try { + $key = PublicKeyLoader::load($privateKey); + + return $key->getPublicKey()->toString('OpenSSH', ['comment' => '']); + } catch (\Throwable $e) { + return null; + } + } + + public static function validateAndExtractPublicKey($privateKey) + { + $isValid = self::validatePrivateKey($privateKey); + $publicKey = $isValid ? self::extractPublicKeyFromPrivate($privateKey) : ''; + + return [ + 'isValid' => $isValid, + 'publicKey' => $publicKey, + ]; + } + + public function storeInFileSystem() + { + $filename = "ssh_key@{$this->uuid}"; + Storage::disk('ssh-keys')->put($filename, $this->private_key); + + return "/var/www/html/storage/app/ssh/keys/{$filename}"; + } + + public static function deleteFromStorage(self $privateKey) + { + $filename = "ssh_key@{$privateKey->uuid}"; + Storage::disk('ssh-keys')->delete($filename); + } + + public function getKeyLocation() + { + return "/var/www/html/storage/app/ssh/keys/ssh_key@{$this->uuid}"; + } + + public function updatePrivateKey(array $data) + { + $this->update($data); + $this->storeInFileSystem(); + + return $this; } public function servers() @@ -85,4 +184,53 @@ class PrivateKey extends BaseModel { return $this->hasMany(GitlabApp::class); } + + public function isInUse() + { + return $this->servers()->exists() + || $this->applications()->exists() + || $this->githubApps()->exists() + || $this->gitlabApps()->exists(); + } + + public function safeDelete() + { + if (! $this->isInUse()) { + $this->delete(); + + return true; + } + + return false; + } + + public static function generateFingerprint($privateKey) + { + try { + $key = PublicKeyLoader::load($privateKey); + $publicKey = $key->getPublicKey(); + + return $publicKey->getFingerprint('sha256'); + } catch (\Throwable $e) { + return null; + } + } + + private static function fingerprintExists($fingerprint, $excludeId = null) + { + $query = self::where('fingerprint', $fingerprint); + + if (! is_null($excludeId)) { + $query->where('id', '!=', $excludeId); + } + + return $query->exists(); + } + + public static function cleanupUnusedKeys() + { + self::ownedByCurrentTeam()->each(function ($privateKey) { + $privateKey->safeDelete(); + }); + } } diff --git a/app/Models/Server.php b/app/Models/Server.php index 90e6ade14..c5b66d9c6 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -5,7 +5,6 @@ namespace App\Models; use App\Actions\Server\InstallDocker; use App\Enums\ProxyTypes; use App\Jobs\PullSentinelImageJob; -use App\Notifications\Server\Revived; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Support\Collection; @@ -156,11 +155,17 @@ class Server extends BaseModel return $this->hasOne(ServerSetting::class); } + public function proxySet() + { + return $this->proxyType() && $this->proxyType() !== 'NONE' && $this->isFunctional() && ! $this->isSwarmWorker() && ! $this->settings->is_build_server; + } + public function setupDefault404Redirect() { $dynamic_conf_path = $this->proxyPath().'/dynamic'; $proxy_type = $this->proxyType(); $redirect_url = $this->proxy->redirect_url; + ray($proxy_type); if ($proxy_type === ProxyTypes::TRAEFIK->value) { $default_redirect_file = "$dynamic_conf_path/default_redirect_404.yaml"; } elseif ($proxy_type === 'CADDY') { @@ -833,9 +838,9 @@ $schema://$host { $clickhouses = data_get($standaloneDocker, 'clickhouses', collect([])); return $postgresqls->concat($redis)->concat($mongodbs)->concat($mysqls)->concat($mariadbs)->concat($keydbs)->concat($dragonflies)->concat($clickhouses); - })->filter(function ($item) { + })->flatten()->filter(function ($item) { return data_get($item, 'name') !== 'coolify-db'; - })->flatten(); + }); } public function applications() @@ -879,6 +884,35 @@ $schema://$host { return $this->hasMany(Service::class); } + public function port(): Attribute + { + return Attribute::make( + get: function ($value) { + return preg_replace('/[^0-9]/', '', $value); + } + ); + } + + public function user(): Attribute + { + return Attribute::make( + get: function ($value) { + $sanitizedValue = preg_replace('/[^A-Za-z0-9\-_]/', '', $value); + + return $sanitizedValue; + } + ); + } + + public function ip(): Attribute + { + return Attribute::make( + get: function ($value) { + return preg_replace('/[^0-9a-zA-Z.-]/', '', $value); + } + ); + } + public function getIp(): Attribute { return Attribute::make( @@ -951,10 +985,9 @@ $schema://$host { public function isFunctional() { $isFunctional = $this->settings->is_reachable && $this->settings->is_usable && ! $this->settings->force_disabled; - ['private_key_filename' => $private_key_filename, 'mux_filename' => $mux_filename] = server_ssh_configuration($this); + if (! $isFunctional) { - Storage::disk('ssh-keys')->delete($private_key_filename); - Storage::disk('ssh-mux')->delete($mux_filename); + Storage::disk('ssh-mux')->delete($this->muxFilename()); } return $isFunctional; @@ -1006,9 +1039,10 @@ $schema://$host { return data_get($this, 'settings.is_swarm_worker'); } - public function validateConnection() + public function validateConnection($isManualCheck = true) { - config()->set('constants.ssh.mux_enabled', false); + config()->set('constants.ssh.mux_enabled', ! $isManualCheck); + // ray('Manual Check: ' . ($isManualCheck ? 'true' : 'false')); $server = Server::find($this->id); if (! $server) { @@ -1018,7 +1052,6 @@ $schema://$host { return ['uptime' => false, 'error' => 'Server skipped.']; } try { - // EC2 does not have `uptime` command, lol instant_remote_process(['ls /'], $server); $server->settings()->update([ 'is_reachable' => true, @@ -1027,7 +1060,6 @@ $schema://$host { 'unreachable_count' => 0, ]); if (data_get($server, 'unreachable_notification_sent') === true) { - // $server->team?->notify(new Revived($server)); $server->update(['unreachable_notification_sent' => false]); } @@ -1156,4 +1188,24 @@ $schema://$host { { return $this->settings->is_build_server; } + + public static function createWithPrivateKey(array $data, PrivateKey $privateKey) + { + $server = new self($data); + $server->privateKey()->associate($privateKey); + $server->save(); + + return $server; + } + + public function updateWithPrivateKey(array $data, ?PrivateKey $privateKey = null) + { + $this->update($data); + if ($privateKey) { + $this->privateKey()->associate($privateKey); + $this->save(); + } + + return $this; + } } diff --git a/app/Models/Service.php b/app/Models/Service.php index d8def6663..80464db7d 100644 --- a/app/Models/Service.php +++ b/app/Models/Service.php @@ -7,6 +7,8 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Support\Collection; +use Illuminate\Support\Facades\Process; +use Illuminate\Process\InvokedProcess; use Illuminate\Support\Facades\Storage; use OpenApi\Attributes as OA; use Spatie\Url\Url; @@ -68,7 +70,7 @@ class Service extends BaseModel $databaseStorages = $this->databases()->get()->pluck('persistentStorages')->flatten()->sortBy('id'); $storages = $applicationStorages->merge($databaseStorages)->implode('updated_at'); - $newConfigHash = $images.$domains.$images.$storages; + $newConfigHash = $images . $domains . $images . $storages; $newConfigHash .= json_encode($this->environment_variables()->get('value')->sort()); $newConfigHash = md5($newConfigHash); $oldConfigHash = data_get($this, 'config_hash'); @@ -131,15 +133,80 @@ class Service extends BaseModel return $this->morphToMany(Tag::class, 'taggable'); } + public function getContainersToStop(): array + { + $containersToStop = []; + $applications = $this->applications()->get(); + foreach ($applications as $application) { + $containersToStop[] = "{$application->name}-{$this->uuid}"; + } + $dbs = $this->databases()->get(); + foreach ($dbs as $db) { + $containersToStop[] = "{$db->name}-{$this->uuid}"; + } + return $containersToStop; + } + + public function stopContainers(array $containerNames, $server, int $timeout = 300) + { + $processes = []; + foreach ($containerNames as $containerName) { + $processes[$containerName] = $this->stopContainer($containerName, $timeout); + } + + $startTime = time(); + while (count($processes) > 0) { + $finishedProcesses = array_filter($processes, function ($process) { + return !$process->running(); + }); + foreach (array_keys($finishedProcesses) as $containerName) { + unset($processes[$containerName]); + $this->removeContainer($containerName, $server); + } + + if (time() - $startTime >= $timeout) { + $this->forceStopRemainingContainers(array_keys($processes), $server); + break; + } + + usleep(100000); + } + } + + public function stopContainer(string $containerName, int $timeout): InvokedProcess + { + return Process::timeout($timeout)->start("docker stop --time=$timeout $containerName"); + } + + public function removeContainer(string $containerName, $server) + { + instant_remote_process(command: ["docker rm -f $containerName"], server: $server, throwError: false); + } + + public function forceStopRemainingContainers(array $containerNames, $server) + { + foreach ($containerNames as $containerName) { + instant_remote_process(command: ["docker kill $containerName"], server: $server, throwError: false); + $this->removeContainer($containerName, $server); + } + } + public function delete_configurations() { - $server = data_get($this, 'server'); + $server = data_get($this, 'destination.server'); $workdir = $this->workdir(); if (str($workdir)->endsWith($this->uuid)) { - instant_remote_process(['rm -rf '.$this->workdir()], $server, false); + instant_remote_process(['rm -rf ' . $this->workdir()], $server, false); } } + public function delete_connected_networks($uuid) + { + $server = data_get($this, 'destination.server'); + instant_remote_process(["docker network disconnect {$uuid} coolify-proxy"], $server, false); + instant_remote_process(["docker network rm {$uuid}"], $server, false); + } + public function status() { $applications = $this->applications; @@ -994,7 +1061,7 @@ class Service extends BaseModel public function workdir() { - return service_configuration_dir()."/{$this->uuid}"; + return service_configuration_dir() . "/{$this->uuid}"; } public function saveComposeConfigs() diff --git a/app/Models/ServiceApplication.php b/app/Models/ServiceApplication.php index d312fab96..f0c78cc08 100644 --- a/app/Models/ServiceApplication.php +++ b/app/Models/ServiceApplication.php @@ -23,7 +23,7 @@ class ServiceApplication extends BaseModel public function restart() { - $container_id = $this->name.'-'.$this->service->uuid; + $container_id = $this->name . '-' . $this->service->uuid; instant_remote_process(["docker restart {$container_id}"], $this->service->server); } @@ -69,7 +69,7 @@ class ServiceApplication extends BaseModel public function workdir() { - return service_configuration_dir()."/{$this->service->uuid}"; + return service_configuration_dir() . "/{$this->service->uuid}"; } public function serviceType() diff --git a/app/Models/ServiceDatabase.php b/app/Models/ServiceDatabase.php index 6b96738e8..2e25f4402 100644 --- a/app/Models/ServiceDatabase.php +++ b/app/Models/ServiceDatabase.php @@ -21,7 +21,7 @@ class ServiceDatabase extends BaseModel public function restart() { - $container_id = $this->name.'-'.$this->service->uuid; + $container_id = $this->name . '-' . $this->service->uuid; remote_process(["docker restart {$container_id}"], $this->service->server); } @@ -88,7 +88,7 @@ class ServiceDatabase extends BaseModel public function workdir() { - return service_configuration_dir()."/{$this->service->uuid}"; + return service_configuration_dir() . "/{$this->service->uuid}"; } public function service() diff --git a/app/Traits/ExecuteRemoteCommand.php b/app/Traits/ExecuteRemoteCommand.php index 9b58882eb..03726f095 100644 --- a/app/Traits/ExecuteRemoteCommand.php +++ b/app/Traits/ExecuteRemoteCommand.php @@ -7,6 +7,7 @@ use App\Models\Server; use Carbon\Carbon; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Process; +use App\Helpers\SshMultiplexingHelper; trait ExecuteRemoteCommand { @@ -42,7 +43,7 @@ trait ExecuteRemoteCommand $command = parseLineForSudo($command, $this->server); } } - $remote_command = generateSshCommand($this->server, $command); + $remote_command = SshMultiplexingHelper::generateSshCommand($this->server, $command); $process = Process::timeout(3600)->idleTimeout(3600)->start($remote_command, function (string $type, string $output) use ($command, $hidden, $customType, $append) { $output = str($output)->trim(); if ($output->startsWith('â•”')) { diff --git a/bootstrap/helpers/remoteProcess.php b/bootstrap/helpers/remoteProcess.php index 4ba378e67..67b60d6b7 100644 --- a/bootstrap/helpers/remoteProcess.php +++ b/bootstrap/helpers/remoteProcess.php @@ -3,6 +3,7 @@ use App\Actions\CoolifyTask\PrepareCoolifyTask; use App\Data\CoolifyTaskArgs; use App\Enums\ActivityTypes; +use App\Helpers\SshMultiplexingHelper; use App\Models\Application; use App\Models\ApplicationDeploymentQueue; use App\Models\PrivateKey; @@ -10,9 +11,8 @@ use App\Models\Server; use Carbon\Carbon; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Collection; -use Illuminate\Support\Facades\Hash; +use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Process; -use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str; use Spatie\Activitylog\Contracts\Activity; @@ -26,29 +26,28 @@ function remote_process( $callEventOnFinish = null, $callEventData = null ): Activity { - if (is_null($type)) { - $type = ActivityTypes::INLINE->value; - } - if ($command instanceof Collection) { - $command = $command->toArray(); - } + $type = $type ?? ActivityTypes::INLINE->value; + $command = $command instanceof Collection ? $command->toArray() : $command; + if ($server->isNonRoot()) { $command = parseCommandsByLineForSudo(collect($command), $server); } + $command_string = implode("\n", $command); - if (auth()->user()) { - $teams = auth()->user()->teams->pluck('id'); + + if (Auth::check()) { + $teams = Auth::user()->teams->pluck('id'); if (! $teams->contains($server->team_id) && ! $teams->contains(0)) { throw new \Exception('User is not part of the team that owns this server'); } } + SshMultiplexingHelper::ensureMultiplexedConnection($server); + return resolve(PrepareCoolifyTask::class, [ 'remoteProcessArgs' => new CoolifyTaskArgs( server_uuid: $server->uuid, - command: <<muxFilename(); - return [ - 'location' => $location, - 'mux_filename' => $mux_filename, - 'private_key_filename' => $private_key_filename, - ]; -} -function savePrivateKeyToFs(Server $server) -{ - if (data_get($server, 'privateKey.private_key') === null) { - throw new \Exception("Server {$server->name} does not have a private key"); - } - ['location' => $location, 'private_key_filename' => $private_key_filename] = server_ssh_configuration($server); - Storage::disk('ssh-keys')->makeDirectory('.'); - Storage::disk('ssh-mux')->makeDirectory('.'); - Storage::disk('ssh-keys')->put($private_key_filename, $server->privateKey->private_key); - - return $location; -} - -function generateScpCommand(Server $server, string $source, string $dest) -{ - $user = $server->user; - $port = $server->port; - $privateKeyLocation = savePrivateKeyToFs($server); - $timeout = config('constants.ssh.command_timeout'); - $connectionTimeout = config('constants.ssh.connection_timeout'); - $serverInterval = config('constants.ssh.server_interval'); - $muxPersistTime = config('constants.ssh.mux_persist_time'); - - $scp_command = "timeout $timeout scp "; - $muxEnabled = config('constants.ssh.mux_enabled', true) && config('coolify.is_windows_docker_desktop') == false; - // ray('SSH Multiplexing Enabled:', $muxEnabled)->blue(); - - if ($muxEnabled) { - $muxSocket = "/var/www/html/storage/app/ssh/mux/{$server->muxFilename()}"; - $scp_command .= "-o ControlMaster=auto -o ControlPath=$muxSocket -o ControlPersist={$muxPersistTime} "; - ensureMultiplexedConnection($server); - // ray('Using SSH Multiplexing')->green(); - } else { - // ray('Not using SSH Multiplexing')->red(); - } - - if (data_get($server, 'settings.is_cloudflare_tunnel')) { - $scp_command .= '-o ProxyCommand="/usr/local/bin/cloudflared access ssh --hostname %h" '; - } - $scp_command .= "-i {$privateKeyLocation} " - .'-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null ' - .'-o PasswordAuthentication=no ' - ."-o ConnectTimeout=$connectionTimeout " - ."-o ServerAliveInterval=$serverInterval " - .'-o RequestTTY=no ' - .'-o LogLevel=ERROR ' - ."-P {$port} " - ."{$source} " - ."{$user}@{$server->ip}:{$dest}"; - - return $scp_command; -} function instant_scp(string $source, string $dest, Server $server, $throwError = true) { - $timeout = config('constants.ssh.command_timeout'); - $scp_command = generateScpCommand($server, $source, $dest); - $process = Process::timeout($timeout)->run($scp_command); + $scp_command = SshMultiplexingHelper::generateScpCommand($server, $source, $dest); + $process = Process::timeout(config('constants.ssh.command_timeout'))->run($scp_command); $output = trim($process->output()); $exitCode = $process->exitCode(); if ($exitCode !== 0) { - if (! $throwError) { - return null; - } - - return excludeCertainErrors($process->errorOutput(), $exitCode); - } - if ($output === 'null') { - $output = null; + return $throwError ? excludeCertainErrors($process->errorOutput(), $exitCode) : null; } - return $output; -} -function generateSshCommand(Server $server, string $command) -{ - if ($server->settings->force_disabled) { - throw new \RuntimeException('Server is disabled.'); - } - $user = $server->user; - $port = $server->port; - $privateKeyLocation = savePrivateKeyToFs($server); - $timeout = config('constants.ssh.command_timeout'); - $connectionTimeout = config('constants.ssh.connection_timeout'); - $serverInterval = config('constants.ssh.server_interval'); - $muxPersistTime = config('constants.ssh.mux_persist_time'); - - $ssh_command = "timeout $timeout ssh "; - - $muxEnabled = config('constants.ssh.mux_enabled') && config('coolify.is_windows_docker_desktop') == false; - // ray('SSH Multiplexing Enabled:', $muxEnabled)->blue(); - if ($muxEnabled) { - // Always use multiplexing when enabled - $muxSocket = "/var/www/html/storage/app/ssh/mux/{$server->muxFilename()}"; - $ssh_command .= "-o ControlMaster=auto -o ControlPath=$muxSocket -o ControlPersist={$muxPersistTime} "; - ensureMultiplexedConnection($server); - // ray('Using SSH Multiplexing')->green(); - } else { - // ray('Not using SSH Multiplexing')->red(); - } - - if (data_get($server, 'settings.is_cloudflare_tunnel')) { - $ssh_command .= '-o ProxyCommand="/usr/local/bin/cloudflared access ssh --hostname %h" '; - } - $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 .= "-i {$privateKeyLocation} " - .'-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null ' - .'-o PasswordAuthentication=no ' - ."-o ConnectTimeout=$connectionTimeout " - ."-o ServerAliveInterval=$serverInterval " - .'-o RequestTTY=no ' - .'-o LogLevel=ERROR ' - ."-p {$port} " - ."{$user}@{$server->ip} " - ." 'bash -se' << \\$delimiter".PHP_EOL - .$command.PHP_EOL - .$delimiter; - - return $ssh_command; -} - -function ensureMultiplexedConnection(Server $server) -{ - if (! (config('constants.ssh.mux_enabled') && config('coolify.is_windows_docker_desktop') == false)) { - return; - } - - static $ensuredConnections = []; - - if (isset($ensuredConnections[$server->id])) { - if (! shouldResetMultiplexedConnection($server)) { - // ray('Using Existing Multiplexed Connection')->green(); - - return; - } - } - - $muxSocket = "/var/www/html/storage/app/ssh/mux/{$server->muxFilename()}"; - $checkCommand = "ssh -O check -o ControlPath=$muxSocket "; - if (data_get($server, 'settings.is_cloudflare_tunnel')) { - $checkCommand .= '-o ProxyCommand="/usr/local/bin/cloudflared access ssh --hostname %h" '; - } - $checkCommand .= " {$server->user}@{$server->ip}"; - - $process = Process::run($checkCommand); - - if ($process->exitCode() === 0) { - // ray('Existing Multiplexed Connection is Valid')->green(); - $ensuredConnections[$server->id] = [ - 'timestamp' => now(), - 'muxSocket' => $muxSocket, - ]; - - return; - } - - // ray('Establishing New Multiplexed Connection')->orange(); - - $privateKeyLocation = savePrivateKeyToFs($server); - $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} "; - - if (data_get($server, 'settings.is_cloudflare_tunnel')) { - $establishCommand .= '-o ProxyCommand="/usr/local/bin/cloudflared access ssh --hostname %h" '; - } - $establishCommand .= "-i {$privateKeyLocation} " - .'-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} " - ."{$server->user}@{$server->ip}"; - - $establishProcess = Process::run($establishCommand); - - if ($establishProcess->exitCode() !== 0) { - throw new \RuntimeException('Failed to establish multiplexed connection: '.$establishProcess->errorOutput()); - } - - $ensuredConnections[$server->id] = [ - 'timestamp' => now(), - 'muxSocket' => $muxSocket, - ]; - - // ray('Established New Multiplexed Connection')->green(); -} - -function shouldResetMultiplexedConnection(Server $server) -{ - if (! (config('constants.ssh.mux_enabled') && config('coolify.is_windows_docker_desktop') == false)) { - return false; - } - - static $ensuredConnections = []; - - if (! isset($ensuredConnections[$server->id])) { - return true; - } - - $lastEnsured = $ensuredConnections[$server->id]['timestamp']; - $muxPersistTime = config('constants.ssh.mux_persist_time'); - $resetInterval = strtotime($muxPersistTime) - time(); - - return $lastEnsured->addSeconds($resetInterval)->isPast(); -} - -function resetMultiplexedConnection(Server $server) -{ - if (! (config('constants.ssh.mux_enabled') && config('coolify.is_windows_docker_desktop') == false)) { - return; - } - - static $ensuredConnections = []; - - if (isset($ensuredConnections[$server->id])) { - $muxSocket = $ensuredConnections[$server->id]['muxSocket']; - $closeCommand = "ssh -O exit -o ControlPath=$muxSocket {$server->user}@{$server->ip}"; - Process::run($closeCommand); - unset($ensuredConnections[$server->id]); - } + return $output === 'null' ? null : $output; } function instant_remote_process(Collection|array $command, Server $server, bool $throwError = true, bool $no_sudo = false): ?string { - static $processCount = 0; - $processCount++; - - $timeout = config('constants.ssh.command_timeout'); - if ($command instanceof Collection) { - $command = $command->toArray(); - } + $command = $command instanceof Collection ? $command->toArray() : $command; if ($server->isNonRoot() && ! $no_sudo) { $command = parseCommandsByLineForSudo(collect($command), $server); } $command_string = implode("\n", $command); - $start_time = microtime(true); - $sshCommand = generateSshCommand($server, $command_string); - $process = Process::timeout($timeout)->run($sshCommand); - $end_time = microtime(true); + // $start_time = microtime(true); + $sshCommand = SshMultiplexingHelper::generateSshCommand($server, $command_string); + $process = Process::timeout(config('constants.ssh.command_timeout'))->run($sshCommand); + // $end_time = microtime(true); - $execution_time = ($end_time - $start_time) * 1000; // Convert to milliseconds + // $execution_time = ($end_time - $start_time) * 1000; // Convert to milliseconds // ray('SSH command execution time:', $execution_time.' ms')->orange(); $output = trim($process->output()); $exitCode = $process->exitCode(); if ($exitCode !== 0) { - if (! $throwError) { - return null; - } - - return excludeCertainErrors($process->errorOutput(), $exitCode); - } - if ($output === 'null') { - $output = null; + return $throwError ? excludeCertainErrors($process->errorOutput(), $exitCode) : null; } - return $output; + return $output === 'null' ? null : $output; } + function excludeCertainErrors(string $errorOutput, ?int $exitCode = null) { $ignoredErrors = collect([ 'Permission denied (publickey', 'Could not resolve hostname', ]); - $ignored = false; - foreach ($ignoredErrors as $ignoredError) { - if (Str::contains($errorOutput, $ignoredError)) { - $ignored = true; - break; - } - } + $ignored = $ignoredErrors->contains(fn ($error) => Str::contains($errorOutput, $error)); if ($ignored) { // TODO: Create new exception and disable in sentry throw new \RuntimeException($errorOutput, $exitCode); } throw new \RuntimeException($errorOutput, $exitCode); } + function decode_remote_command_output(?ApplicationDeploymentQueue $application_deployment_queue = null): Collection { - $application = Application::find(data_get($application_deployment_queue, 'application_id')); - $is_debug_enabled = data_get($application, 'settings.is_debug_enabled'); if (is_null($application_deployment_queue)) { return collect([]); } + $application = Application::find(data_get($application_deployment_queue, 'application_id')); + $is_debug_enabled = data_get($application, 'settings.is_debug_enabled'); try { $decoded = json_decode( data_get($application_deployment_queue, 'logs'), @@ -379,7 +132,8 @@ function decode_remote_command_output(?ApplicationDeploymentQueue $application_d if (! $is_debug_enabled) { $formatted = $formatted->filter(fn ($i) => $i['hidden'] === false ?? false); } - $formatted = $formatted + + return $formatted ->sortBy(fn ($i) => data_get($i, 'order')) ->map(function ($i) { data_set($i, 'timestamp', Carbon::parse(data_get($i, 'timestamp'))->format('Y-M-d H:i:s.u')); @@ -421,36 +175,22 @@ function decode_remote_command_output(?ApplicationDeploymentQueue $application_d return $deploymentLogLines; }, collect()); - - return $formatted; } + function remove_iip($text) { $text = preg_replace('/x-access-token:.*?(?=@)/', 'x-access-token:'.REDACTED, $text); return preg_replace('/\x1b\[[0-9;]*m/', '', $text); } -function remove_mux_and_private_key(Server $server) -{ - $muxFilename = $server->muxFilename(); - $privateKeyLocation = savePrivateKeyToFs($server); - $closeCommand = "ssh -O exit -o ControlPath=/var/www/html/storage/app/ssh/mux/{$muxFilename} {$server->user}@{$server->ip}"; - Process::run($closeCommand); - - Storage::disk('ssh-mux')->delete($muxFilename); - Storage::disk('ssh-keys')->delete($privateKeyLocation); -} function refresh_server_connection(?PrivateKey $private_key = null) { if (is_null($private_key)) { return; } foreach ($private_key->servers as $server) { - $muxFilename = $server->muxFilename(); - $closeCommand = "ssh -O exit -o ControlPath=/var/www/html/storage/app/ssh/mux/{$muxFilename} {$server->user}@{$server->ip}"; - Process::run($closeCommand); - Storage::disk('ssh-mux')->delete($muxFilename); + SshMultiplexingHelper::removeMuxFile($server); } } @@ -468,9 +208,8 @@ function checkRequiredCommands(Server $server) break; } $commandFound = instant_remote_process(["docker run --rm --privileged --net=host --pid=host --ipc=host --volume /:/host busybox chroot /host bash -c 'command -v {$command}'"], $server, false); - if ($commandFound) { - continue; + if (! $commandFound) { + break; } - break; } } diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index cd2779466..6b3ef10dc 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -3631,6 +3631,14 @@ function newParser(Application|Service $resource, int $pull_request_id = 0, ?int data_forget($service, 'volumes.*.is_directory'); data_forget($service, 'exclude_from_hc'); + $volumesParsed = $volumesParsed->map(function ($volume) { + data_forget($volume, 'content'); + data_forget($volume, 'is_directory'); + data_forget($volume, 'isDirectory'); + + return $volume; + }); + $payload = collect($service)->merge([ 'container_name' => $containerName, 'restart' => $restart->value(), @@ -3661,6 +3669,7 @@ function newParser(Application|Service $resource, int $pull_request_id = 0, ?int $parsedServices->put($serviceName, $payload); } $topLevel->put('services', $parsedServices); + $customOrder = ['services', 'volumes', 'networks', 'configs', 'secrets']; $topLevel = $topLevel->sortBy(function ($value, $key) use ($customOrder) { diff --git a/config/constants.php b/config/constants.php index 906ef3ba2..5792b358c 100644 --- a/config/constants.php +++ b/config/constants.php @@ -6,9 +6,8 @@ return [ 'contact' => 'https://coolify.io/docs/contact', ], 'ssh' => [ - // Using MUX - 'mux_enabled' => env('MUX_ENABLED', env('SSH_MUX_ENABLED', true), true), - 'mux_persist_time' => env('SSH_MUX_PERSIST_TIME', '1h'), + 'mux_enabled' => env('MUX_ENABLED', env('SSH_MUX_ENABLED', true)), + 'mux_persist_time' => env('SSH_MUX_PERSIST_TIME', 3600), 'connection_timeout' => 10, 'server_interval' => 20, 'command_timeout' => 7200, diff --git a/config/sentry.php b/config/sentry.php index 471a1e0fc..c65d3d1ff 100644 --- a/config/sentry.php +++ b/config/sentry.php @@ -7,7 +7,7 @@ return [ // The release version of your application // Example with dynamic git hash: trim(exec('git --git-dir ' . base_path('.git') . ' log --pretty="%h" -n1 HEAD')) - 'release' => '4.0.0-beta.341', + 'release' => '4.0.0-beta.342', // When left empty or `null` the Laravel environment will be used 'environment' => config('app.env'), diff --git a/config/version.php b/config/version.php index 32eb01cd0..633c71d60 100644 --- a/config/version.php +++ b/config/version.php @@ -1,3 +1,3 @@ chunkById(100, function ($keys) { + foreach ($keys as $key) { + DB::table('private_keys') + ->where('id', $key->id) + ->update(['private_key' => Crypt::encryptString($key->private_key)]); + } + }); + } +} diff --git a/database/migrations/2024_09_16_170001_populate_ssh_keys_and_clear_mux_directory.php b/database/migrations/2024_09_16_170001_populate_ssh_keys_and_clear_mux_directory.php new file mode 100644 index 000000000..aece86c88 --- /dev/null +++ b/database/migrations/2024_09_16_170001_populate_ssh_keys_and_clear_mux_directory.php @@ -0,0 +1,25 @@ +deleteDirectory(''); + // Storage::disk('ssh-keys')->makeDirectory(''); + + // Storage::disk('ssh-mux')->deleteDirectory(''); + // Storage::disk('ssh-mux')->makeDirectory(''); + // PrivateKey::chunk(100, function ($keys) { + // foreach ($keys as $key) { + // $key->storeInFileSystem(); + // if ($key->id === 0) { + // Storage::disk('ssh-keys')->put('id.root@host.docker.internal', $key->private_key); + // } + // } + // }); + } +} diff --git a/database/migrations/2024_09_17_111226_add_ssh_key_fingerprint_to_private_keys_table.php b/database/migrations/2024_09_17_111226_add_ssh_key_fingerprint_to_private_keys_table.php new file mode 100644 index 000000000..f16eccb5c --- /dev/null +++ b/database/migrations/2024_09_17_111226_add_ssh_key_fingerprint_to_private_keys_table.php @@ -0,0 +1,31 @@ +string('fingerprint')->after('private_key')->nullable(); + }); + + PrivateKey::whereNull('fingerprint')->each(function ($key) { + $fingerprint = PrivateKey::generateFingerprint($key->private_key); + if ($fingerprint) { + $key->fingerprint = $fingerprint; + $key->save(); + } + }); + } + + public function down() + { + Schema::table('private_keys', function (Blueprint $table) { + $table->dropColumn('fingerprint'); + }); + } +} diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index b3fac350f..874762aef 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -13,6 +13,7 @@ class DatabaseSeeder extends Seeder UserSeeder::class, TeamSeeder::class, PrivateKeySeeder::class, + PopulateSshKeysDirectorySeeder::class, ServerSeeder::class, ServerSettingSeeder::class, ProjectSeeder::class, diff --git a/database/seeders/GithubAppSeeder.php b/database/seeders/GithubAppSeeder.php index 4aa5ec753..2ece7a05b 100644 --- a/database/seeders/GithubAppSeeder.php +++ b/database/seeders/GithubAppSeeder.php @@ -31,7 +31,7 @@ class GithubAppSeeder extends Seeder 'client_id' => 'Iv1.220e564d2b0abd8c', 'client_secret' => '116d1d80289f378410dd70ab4e4b81dd8d2c52b6', 'webhook_secret' => '326a47b49054f03288f800d81247ec9414d0abf3', - 'private_key_id' => 1, + 'private_key_id' => 2, 'team_id' => 0, ]); } diff --git a/database/seeders/GitlabAppSeeder.php b/database/seeders/GitlabAppSeeder.php index af63f2ed7..ec2b7ec5e 100644 --- a/database/seeders/GitlabAppSeeder.php +++ b/database/seeders/GitlabAppSeeder.php @@ -20,19 +20,5 @@ class GitlabAppSeeder extends Seeder 'is_public' => true, 'team_id' => 0, ]); - GitlabApp::create([ - 'id' => 2, - 'name' => 'coolify-laravel-development-private-gitlab', - 'api_url' => 'https://gitlab.com/api/v4', - 'html_url' => 'https://gitlab.com', - 'app_id' => 1234, - 'app_secret' => '1234', - 'oauth_id' => 1234, - 'deploy_key_id' => '1234', - 'public_key' => 'dfjasiourj', - 'webhook_token' => '4u3928u4y392', - 'private_key_id' => 2, - 'team_id' => 0, - ]); } } diff --git a/database/seeders/PopulateSshKeysDirectorySeeder.php b/database/seeders/PopulateSshKeysDirectorySeeder.php new file mode 100644 index 000000000..a097505e5 --- /dev/null +++ b/database/seeders/PopulateSshKeysDirectorySeeder.php @@ -0,0 +1,35 @@ +deleteDirectory(''); + Storage::disk('ssh-keys')->makeDirectory(''); + Storage::disk('ssh-mux')->deleteDirectory(''); + Storage::disk('ssh-mux')->makeDirectory(''); + + PrivateKey::chunk(100, function ($keys) { + foreach ($keys as $key) { + echo 'Storing key: '.$key->name."\n"; + $key->storeInFileSystem(); + } + }); + + if (isDev()) { + $user = env('PUID').':'.env('PGID'); + Process::run("chown -R $user ".storage_path('app/ssh/keys')); + Process::run("chown -R $user ".storage_path('app/ssh/mux')); + } else { + Process::run('chown -R 9999:9999 '.storage_path('app/ssh/keys')); + Process::run('chown -R 9999:9999 '.storage_path('app/ssh/mux')); + } + } +} diff --git a/database/seeders/PrivateKeySeeder.php b/database/seeders/PrivateKeySeeder.php index 8a70cf56d..6b44d0867 100644 --- a/database/seeders/PrivateKeySeeder.php +++ b/database/seeders/PrivateKeySeeder.php @@ -13,9 +13,8 @@ class PrivateKeySeeder extends Seeder public function run(): void { PrivateKey::create([ - 'id' => 0, 'team_id' => 0, - 'name' => 'Testing-host', + 'name' => 'Testing Host Key', 'description' => 'This is a test docker container', 'private_key' => '-----BEGIN OPENSSH PRIVATE KEY----- b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW @@ -25,10 +24,9 @@ AAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA== -----END OPENSSH PRIVATE KEY----- ', - ]); + PrivateKey::create([ - 'id' => 1, 'team_id' => 0, 'name' => 'development-github-app', 'description' => 'This is the key for using the development GitHub app', @@ -61,12 +59,5 @@ a1C8EDKapCw5hAhizEFOUQKOygL8Ipn+tmEUkORYdZ8Q8cWFCv9nIw== -----END RSA PRIVATE KEY-----', 'is_git_related' => true, ]); - PrivateKey::create([ - 'id' => 2, - 'team_id' => 0, - 'name' => 'development-gitlab-app', - 'description' => 'This is the key for using the development Gitlab app', - 'private_key' => 'asdf', - ]); } } diff --git a/database/seeders/ProductionSeeder.php b/database/seeders/ProductionSeeder.php index 799dd0440..17a85f7b6 100644 --- a/database/seeders/ProductionSeeder.php +++ b/database/seeders/ProductionSeeder.php @@ -64,64 +64,82 @@ class ProductionSeeder extends Seeder 'team_id' => 0, ]); } + // Add Coolify host (localhost) as Server if it doesn't exist + if (Server::find(0) == null) { + $server_details = [ + 'id' => 0, + 'name' => 'localhost', + 'description' => "This is the server where Coolify is running on. Don't delete this!", + 'user' => 'root', + 'ip' => 'host.docker.internal', + 'team_id' => 0, + 'private_key_id' => 0, + ]; + $server_details['proxy'] = ServerMetadata::from([ + 'type' => ProxyTypes::TRAEFIK->value, + 'status' => ProxyStatus::EXITED->value, + ]); + $server = Server::create($server_details); + $server->settings->is_reachable = true; + $server->settings->is_usable = true; + $server->settings->save(); + } else { + $server = Server::find(0); + $server->settings->is_reachable = true; + $server->settings->is_usable = true; + $server->settings->save(); + } + if (StandaloneDocker::find(0) == null) { + StandaloneDocker::create([ + 'id' => 0, + 'name' => 'localhost-coolify', + 'network' => 'coolify', + 'server_id' => 0, + ]); + } if (! isCloud() && config('coolify.is_windows_docker_desktop') == false) { echo "Checking localhost key.\n"; - // Save SSH Keys for the Coolify Host - $coolify_key_name = 'id.root@host.docker.internal'; - $coolify_key = Storage::disk('ssh-keys')->get("{$coolify_key_name}"); + $coolify_key_name = '@host.docker.internal'; + $ssh_keys_directory = Storage::disk('ssh-keys')->files(); + $coolify_key = collect($ssh_keys_directory)->firstWhere(fn ($item) => str($item)->contains($coolify_key_name)); - if ($coolify_key) { - PrivateKey::updateOrCreate( - [ + $found = PrivateKey::find(0); + if ($found) { + echo 'Private Key found in database.\n'; + if ($coolify_key) { + echo "SSH key found for the Coolify host machine (localhost).\n"; + } + } else { + if ($coolify_key) { + $coolify_key = Storage::disk('ssh-keys')->get($coolify_key); + $user = str($coolify_key)->before('@')->after('id.'); + PrivateKey::create([ 'id' => 0, 'team_id' => 0, - ], - [ 'name' => 'localhost\'s key', 'description' => 'The private key for the Coolify host machine (localhost).', 'private_key' => $coolify_key, - ] - ); - } else { - echo "No SSH key found for the Coolify host machine (localhost).\n"; - echo "Please generate one and save it in /data/coolify/ssh/keys/{$coolify_key_name}\n"; - echo "Then try to install again.\n"; - exit(1); - } - // Add Coolify host (localhost) as Server if it doesn't exist - if (Server::find(0) == null) { - $server_details = [ - 'id' => 0, - 'name' => 'localhost', - 'description' => "This is the server where Coolify is running on. Don't delete this!", - 'user' => 'root', - 'ip' => 'host.docker.internal', - 'team_id' => 0, - 'private_key_id' => 0, - ]; - $server_details['proxy'] = ServerMetadata::from([ - 'type' => ProxyTypes::TRAEFIK->value, - 'status' => ProxyStatus::EXITED->value, - ]); - $server = Server::create($server_details); - $server->settings->is_reachable = true; - $server->settings->is_usable = true; - $server->settings->save(); - } else { - $server = Server::find(0); - $server->settings->is_reachable = true; - $server->settings->is_usable = true; - $server->settings->save(); - } - if (StandaloneDocker::find(0) == null) { - StandaloneDocker::create([ - 'id' => 0, - 'name' => 'localhost-coolify', - 'network' => 'coolify', - 'server_id' => 0, - ]); + ]); + $server->update(['user' => $user]); + echo "SSH key found for the Coolify host machine (localhost).\n"; + + } else { + PrivateKey::create( + [ + 'id' => 0, + 'team_id' => 0, + 'name' => 'localhost\'s key', + 'description' => 'The private key for the Coolify host machine (localhost).', + 'private_key' => 'Paste here you private key!!', + ] + ); + echo "No SSH key found for the Coolify host machine (localhost).\n"; + echo "Please read the following documentation (point 3) to fix it: https://coolify.io/docs/knowledge-base/server/openssh/\n"; + echo "Your localhost connection won't work until then."; + } } + } if (config('coolify.is_windows_docker_desktop')) { PrivateKey::updateOrCreate( @@ -179,8 +197,8 @@ uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA== get_public_ips(); - $oauth_settings_seeder = new OauthSettingSeeder; - $oauth_settings_seeder->run(); + $this->call(OauthSettingSeeder::class); + $this->call(PopulateSshKeysDirectorySeeder::class); } } diff --git a/database/seeders/ServerSeeder.php b/database/seeders/ServerSeeder.php index 12594bcb9..d32843107 100644 --- a/database/seeders/ServerSeeder.php +++ b/database/seeders/ServerSeeder.php @@ -2,6 +2,8 @@ namespace Database\Seeders; +use App\Enums\ProxyStatus; +use App\Enums\ProxyTypes; use App\Models\Server; use Illuminate\Database\Seeder; @@ -15,7 +17,11 @@ class ServerSeeder extends Seeder 'description' => 'This is a test docker container in development mode', 'ip' => 'coolify-testing-host', 'team_id' => 0, - 'private_key_id' => 0, + 'private_key_id' => 1, + 'proxy' => [ + 'type' => ProxyTypes::TRAEFIK->value, + 'status' => ProxyStatus::EXITED->value, + ], ]); } } diff --git a/lang/en.json b/lang/en.json index 461a96e9a..e1603c303 100644 --- a/lang/en.json +++ b/lang/en.json @@ -26,5 +26,11 @@ "input.code": "One-time code", "input.recovery_code": "Recovery code", "button.save": "Save", - "repository.url": "Examples
For Public repositories, use https://....
For Private repositories, use git@....

https://github.com/coollabsio/coolify-examples main branch will be selected
https://github.com/coollabsio/coolify-examples/tree/nodejs-fastify nodejs-fastify branch will be selected.
https://gitea.com/sedlav/expressjs.git main branch will be selected.
https://gitlab.com/andrasbacsai/nodejs-example.git main branch will be selected." + "repository.url": "Examples
For Public repositories, use https://....
For Private repositories, use git@....

https://github.com/coollabsio/coolify-examples main branch will be selected
https://github.com/coollabsio/coolify-examples/tree/nodejs-fastify nodejs-fastify branch will be selected.
https://gitea.com/sedlav/expressjs.git main branch will be selected.
https://gitlab.com/andrasbacsai/nodejs-example.git main branch will be selected.", + "service.stop": "This service will be stopped.", + "resource.docker_cleanup": "Run Docker Cleanup (remove unused images and builder cache).", + "resource.non_persistent": "All non-persistent data will be deleted.", + "resource.delete_volumes": "All volumes associated with this resource will be permanently deleted.", + "resource.delete_connected_networks": "All non-predefined networks associated with this resource will be permanently deleted.", + "resource.delete_configurations": "All configuration files will be permanently deleted from the server." } diff --git a/other/nightly/install.sh b/other/nightly/install.sh index 09613e12b..61acbd333 100755 --- a/other/nightly/install.sh +++ b/other/nightly/install.sh @@ -10,6 +10,8 @@ DATE=$(date +"%Y%m%d-%H%M%S") VERSION="1.5" DOCKER_VERSION="26.0" +# TODO: Ask for a user +CURRENT_USER=$USER mkdir -p /data/coolify/{source,ssh,applications,databases,backups,services,proxy,webhooks-during-maintenance,metrics,logs} mkdir -p /data/coolify/ssh/{keys,mux} @@ -23,7 +25,7 @@ INSTALLATION_LOG_WITH_DATE="/data/coolify/source/installation-${DATE}.log" exec > >(tee -a $INSTALLATION_LOG_WITH_DATE) 2>&1 getAJoke() { - JOKES=$(curl -s --max-time 2 https://v2.jokeapi.dev/joke/Programming?format=txt&type=single&amount=1 || true) + JOKES=$(curl -s --max-time 2 "https://v2.jokeapi.dev/joke/Programming?blacklistFlags=nsfw,religious,political,racist,sexist,explicit&format=txt&type=single" || true) if [ "$JOKES" != "" ]; then echo -e " - Until then, here's a joke for you:\n" echo -e "$JOKES\n" @@ -477,7 +479,17 @@ syncSshKeys() { fi } -syncSshKeys || true +set +e +IS_COOLIFY_VOLUME_EXISTS=$(docker volume ls | grep coolify-db | wc -l) +set -e +if [ "$IS_COOLIFY_VOLUME_EXISTS" -eq 0 ]; then + echo " - Generating SSH key." + ssh-keygen -t ed25519 -a 100 -f /data/coolify/ssh/keys/id.$CURRENT_USER@host.docker.internal -q -N "" -C coolify + chown 9999 /data/coolify/ssh/keys/id.$CURRENT_USER@host.docker.internal + sed -i "/coolify/d" ~/.ssh/authorized_keys + cat /data/coolify/ssh/keys/id.$CURRENT_USER@host.docker.internal.pub >> ~/.ssh/authorized_keys + rm -f /data/coolify/ssh/keys/id.$CURRENT_USER@host.docker.internal.pub +fi chown -R 9999:root /data/coolify chmod -R 700 /data/coolify diff --git a/resources/views/components/forms/checkbox.blade.php b/resources/views/components/forms/checkbox.blade.php index f3e3d5c9e..84c6b7e32 100644 --- a/resources/views/components/forms/checkbox.blade.php +++ b/resources/views/components/forms/checkbox.blade.php @@ -1,4 +1,15 @@ +@props([ + 'id', + 'label' => null, + 'helper' => null, + 'disabled' => false, + 'instantSave' => false, + 'value' => null, + 'hideLabel' => false, +]) +
+ @if(!$hideLabel) + @endif merge(['class' => $defaultClass]) }} @if ($instantSave) wire:loading.attr="disabled" wire:click='{{ $instantSave === 'instantSave' || $instantSave == '1' ? 'instantSave' : $instantSave }}' diff --git a/resources/views/components/modal-confirmation.blade.php b/resources/views/components/modal-confirmation.blade.php index 7910d2eb2..6b46f2fb6 100644 --- a/resources/views/components/modal-confirmation.blade.php +++ b/resources/views/components/modal-confirmation.blade.php @@ -1,14 +1,105 @@ @props([ 'title' => 'Are you sure?', 'isErrorButton' => false, - 'buttonTitle' => 'REWRITE THIS BUTTON TITLE PLEASSSSEEEE', + 'buttonTitle' => 'Confirm Action', 'buttonFullWidth' => false, 'customButton' => null, 'disabled' => false, - 'action' => 'delete', + 'submitAction' => 'delete', 'content' => null, + 'checkboxes' => [], + 'actions' => [], + 'confirmWithText' => true, + 'confirmationText' => 'Confirm Deletion', + 'confirmationLabel' => 'Please confirm the execution of the actions by entering the Name below', + 'shortConfirmationLabel' => 'Name', + 'confirmWithPassword' => true, + 'step1ButtonText' => 'Continue', + 'step2ButtonText' => 'Continue', + 'step3ButtonText' => 'Confirm', + 'dispatchEvent' => false, + 'dispatchEventType' => 'success', + 'dispatchEventMessage' => '', ]) -
@if ($customButton) @if ($buttonFullWidth) @@ -60,11 +151,9 @@ @endif @endif