Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion app/Actions/Proxy/GetProxyConfiguration.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ class GetProxyConfiguration
{
use AsAction;

public const MAX_CONFIGURATION_SIZE_BYTES = 5 * 1024 * 1024;

public function handle(Server $server, bool $forceRegenerate = false): string
{
$proxyType = $server->proxyType();
Expand Down Expand Up @@ -98,11 +100,17 @@ private function configMatchesProxyType(string $proxyType, string $configuration
private function backfillFromDisk(Server $server): ?string
{
$proxy_path = $server->proxyPath();
$configurationPath = escapeshellarg("$proxy_path/docker-compose.yml");
$readLimit = self::MAX_CONFIGURATION_SIZE_BYTES + 1;
$result = instant_remote_process([
"mkdir -p $proxy_path",
"cat $proxy_path/docker-compose.yml 2>/dev/null",
"if [ ! -f {$configurationPath} ]; then exit 0; elif [ \"$(wc -c < {$configurationPath})\" -gt ".self::MAX_CONFIGURATION_SIZE_BYTES." ]; then echo '__COOLIFY_PROXY_CONFIG_TOO_LARGE__'; else head -c {$readLimit} {$configurationPath}; fi",
], $server, false);

if ($result === '__COOLIFY_PROXY_CONFIG_TOO_LARGE__') {
throw new \RuntimeException('Proxy configuration exceeds the 5 MiB size limit.');
}

if (! empty(trim($result ?? ''))) {
$server->proxy->last_saved_proxy_configuration = $result;
$server->save();
Expand Down
15 changes: 13 additions & 2 deletions app/Jobs/ScheduledTaskJob.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ class ScheduledTaskJob implements ShouldBeEncrypted, ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

public const MAX_OUTPUT_SIZE_BYTES = 5 * 1024 * 1024;

/**
* The number of times the job may be attempted.
*/
Expand Down Expand Up @@ -148,10 +150,12 @@ public function handle(): void
foreach ($this->containers as $containerName) {
if (count($this->containers) == 1 || str_starts_with($containerName, $this->task->container.'-'.$this->resource->uuid)) {
$cmd = "sh -c '".str_replace("'", "'\''", $this->task->command)."'";
$exec = "docker exec {$containerName} {$cmd}";
$dockerCommand = $this->server->isNonRoot() ? 'sudo docker' : 'docker';
$execCommand = "{$dockerCommand} exec {$containerName} {$cmd}";
$exec = $this->boundedTaskCommand($execCommand);
// Disable SSH multiplexing to prevent race conditions when multiple tasks run concurrently
// See: https://github.com/coollabsio/coolify/issues/6736
$this->task_output = instant_remote_process([$exec], $this->server, true, false, $this->timeout, disableMultiplexing: true);
$this->task_output = instant_remote_process([$exec], $this->server, throwError: true, no_sudo: true, timeout: $this->timeout, disableMultiplexing: true);
$this->task_log->update([
'status' => 'success',
'message' => $this->task_output,
Expand Down Expand Up @@ -204,6 +208,13 @@ public function handle(): void
}
}

private function boundedTaskCommand(string $command): string
{
$maxOutputBytes = self::MAX_OUTPUT_SIZE_BYTES;

return "output_file=\$(mktemp); trap 'rm -f \"\$output_file\"' EXIT; set +e; set -o pipefail; {$command} 2>&1 | { head -c {$maxOutputBytes} > \"\$output_file\"; if IFS= read -r -n 1 extra_byte; then printf '\n\n[... Output truncated at 5MB limit ...]' >> \"\$output_file\"; fi; cat > /dev/null; }; exit_code=\${PIPESTATUS[0]}; if [ \"\$exit_code\" -eq 0 ]; then cat \"\$output_file\"; else cat \"\$output_file\" >&2; fi; exit \$exit_code";
}

/**
* Calculate the number of seconds to wait before retrying the job.
*/
Expand Down
42 changes: 34 additions & 8 deletions app/Livewire/Project/Shared/GetLogs.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ class GetLogs extends Component
{
public const MAX_LOG_LINES = 50000;

public const MAX_DISPLAY_SIZE_BYTES = 5 * 1024 * 1024;

public const MAX_DOWNLOAD_SIZE_BYTES = 50 * 1024 * 1024; // 50MB

public string $outputs = '';
Expand Down Expand Up @@ -154,14 +156,12 @@ public function getLogs($refresh = false)
$command = parseCommandsByLineForSudo(collect($command), $this->server);
$command = $command[0];
}
$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 = SshMultiplexingHelper::generateSshCommand($this->server, $command);
}
} else {
if ($this->server->isSwarm()) {
Expand All @@ -170,22 +170,39 @@ public function getLogs($refresh = false)
$command = parseCommandsByLineForSudo(collect($command), $this->server);
$command = $command[0];
}
$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 = SshMultiplexingHelper::generateSshCommand($this->server, $command);
}
}
$command = $this->boundedLogCommand($command, self::MAX_DISPLAY_SIZE_BYTES);
$sshCommand = SshMultiplexingHelper::generateSshCommand($this->server, $command);

// Collect new logs into temporary variable first to prevent flickering
// (avoids clearing output before new data is ready)
// Use array accumulation + implode for O(n) instead of O(n²) string concatenation
$logChunks = [];
Process::timeout(config('constants.ssh.command_timeout'))->run($sshCommand, function (string $type, string $output) use (&$logChunks) {
$accumulatedBytes = 0;
$truncated = false;
Process::timeout(config('constants.ssh.command_timeout'))->run($sshCommand, function (string $type, string $output) use (&$logChunks, &$accumulatedBytes, &$truncated) {
if ($truncated) {
return;
}

$remainingBytes = self::MAX_DISPLAY_SIZE_BYTES - $accumulatedBytes;
$outputBytes = strlen($output);
if ($outputBytes > $remainingBytes) {
$logChunks[] = removeAnsiColors(substr($output, 0, max(0, $remainingBytes)));
$truncated = true;

return;
}

$logChunks[] = removeAnsiColors($output);
$accumulatedBytes += $outputBytes;
});
$newOutputs = implode('', $logChunks);

Expand All @@ -198,6 +215,10 @@ public function getLogs($refresh = false)
})->join("\n");
}

if ($truncated) {
$newOutputs .= "\n\n[... Output truncated at 5MB limit ...]";
}

// Only update outputs after new data is ready (atomic update prevents flicker)
$this->outputs = $newOutputs;
}
Expand Down Expand Up @@ -239,6 +260,7 @@ public function downloadAllLogs(): string
$command = $command[0];
}

$command = $this->boundedLogCommand($command, self::MAX_DOWNLOAD_SIZE_BYTES);
$sshCommand = SshMultiplexingHelper::generateSshCommand($this->server, $command);

// Use array accumulation + implode for O(n) instead of O(n²) string concatenation
Expand All @@ -252,20 +274,19 @@ public function downloadAllLogs(): string
return;
}

$output = removeAnsiColors($output);
$outputBytes = strlen($output);

if ($accumulatedBytes + $outputBytes > self::MAX_DOWNLOAD_SIZE_BYTES) {
$remaining = self::MAX_DOWNLOAD_SIZE_BYTES - $accumulatedBytes;
if ($remaining > 0) {
$logChunks[] = substr($output, 0, $remaining);
$logChunks[] = removeAnsiColors(substr($output, 0, $remaining));
}
$truncated = true;

return;
}

$logChunks[] = $output;
$logChunks[] = removeAnsiColors($output);
$accumulatedBytes += $outputBytes;
});

Expand All @@ -287,6 +308,11 @@ public function downloadAllLogs(): string
return sanitizeLogsForExport($allLogs);
}

private function boundedLogCommand(string $command, int $maxBytes): string
{
return "({$command}) 2>&1 | head -c ".($maxBytes + 1);
}

public function render()
{
return view('livewire.project.shared.get-logs');
Expand Down
35 changes: 31 additions & 4 deletions app/Livewire/Server/Proxy/DynamicConfigurations.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@ class DynamicConfigurations extends Component
{
use AuthorizesRequests;

public const MAX_CONFIGURATION_FILE_SIZE_BYTES = 1024 * 1024;

public const MAX_TOTAL_CONFIGURATION_SIZE_BYTES = 5 * 1024 * 1024;

public const MAX_CONFIGURATION_FILES = 100;

public ?Server $server = null;

public $parameters = [];
Expand Down Expand Up @@ -44,15 +50,36 @@ public function loadDynamicConfigurations()
return handleError($e, $this);
}
$proxy_path = $this->server->proxyPath();
$files = instant_remote_process(["mkdir -p $proxy_path/dynamic && ls -1 {$proxy_path}/dynamic"], $this->server);
$fileLimit = self::MAX_CONFIGURATION_FILES + 1;
$files = instant_remote_process(["mkdir -p $proxy_path/dynamic && ls -1 {$proxy_path}/dynamic | head -n {$fileLimit}"], $this->server);
$files = collect(explode("\n", $files))->filter(fn ($file) => ! empty($file));
$files = $files->map(fn ($file) => trim($file));
$files = $files->sort();
$contents = collect([]);
foreach ($files as $file) {
$skippedFiles = collect([]);
$totalBytes = 0;
if ($files->count() > self::MAX_CONFIGURATION_FILES) {
$skippedFiles->push('additional files');
}
foreach ($files->take(self::MAX_CONFIGURATION_FILES) as $file) {
$without_extension = str_replace('.', '|', $file);
$content = instant_remote_process(["cat {$proxy_path}/dynamic/{$file}"], $this->server);
$contents[$without_extension] = $content ?? '';
$filePath = escapeshellarg("{$proxy_path}/dynamic/{$file}");
$readLimit = self::MAX_CONFIGURATION_FILE_SIZE_BYTES + 1;
$content = instant_remote_process(["head -c {$readLimit} {$filePath}"], $this->server);
$content = $content ?? '';
$contentBytes = strlen($content);

if ($contentBytes > self::MAX_CONFIGURATION_FILE_SIZE_BYTES || $totalBytes + $contentBytes > self::MAX_TOTAL_CONFIGURATION_SIZE_BYTES) {
$skippedFiles->push($file);

continue;
}

$contents[$without_extension] = $content;
$totalBytes += $contentBytes;
}
if ($skippedFiles->isNotEmpty()) {
$this->dispatch('warning', 'Some dynamic configurations were not loaded because they exceed the safe display limits: '.$skippedFiles->implode(', '));
}
$this->contents = $contents;
$this->dispatch('$refresh');
Expand Down
15 changes: 13 additions & 2 deletions app/Models/Application.php
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,8 @@ class Application extends BaseModel
{
use ClearsGlobalSearchCache, HasConfiguration, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes;

public const MAX_DOCKER_COMPOSE_SIZE_BYTES = 5 * 1024 * 1024;

private static $parserVersion = '5';

protected $fillable = [
Expand Down Expand Up @@ -1936,6 +1938,9 @@ public function loadComposeFile($isInit = false, ?string $restoreBaseDirectory =
$workdir = rtrim($this->base_directory, '/');
$composeFile = $this->docker_compose_location;
$fileList = collect([".$workdir$composeFile"]);
$composeFilePath = escapeshellarg(".$workdir$composeFile");
$composeReadLimit = self::MAX_DOCKER_COMPOSE_SIZE_BYTES + 1;
$readComposeFile = "if [ \"$(wc -c < {$composeFilePath})\" -gt ".self::MAX_DOCKER_COMPOSE_SIZE_BYTES." ]; then echo '__COOLIFY_COMPOSE_TOO_LARGE__'; else head -c {$composeReadLimit} {$composeFilePath}; fi";
$gitRemoteStatus = $this->getGitRemoteStatus(deployment_uuid: $uuid);
if (! $gitRemoteStatus['is_accessible']) {
throw new RuntimeException('Failed to read Git source. Please verify repository access and try again.');
Expand Down Expand Up @@ -1966,7 +1971,7 @@ public function loadComposeFile($isInit = false, ?string $restoreBaseDirectory =
'git sparse-checkout init',
"git sparse-checkout set {$fileList->implode(' ')}",
'git read-tree -mu HEAD',
"cat .$workdir$composeFile",
$readComposeFile,
]);
} else {
$commands = collect([
Expand All @@ -1978,11 +1983,14 @@ public function loadComposeFile($isInit = false, ?string $restoreBaseDirectory =
'git sparse-checkout init --cone',
"git sparse-checkout set {$fileList->implode(' ')}",
'git read-tree -mu HEAD',
"cat .$workdir$composeFile",
$readComposeFile,
]);
}
try {
$composeFileContent = instant_remote_process($commands, $this->destination->server);
if ($composeFileContent === '__COOLIFY_COMPOSE_TOO_LARGE__') {
throw new RuntimeException('Docker Compose file exceeds the 5 MiB size limit.');
}
} catch (\Exception $e) {
// Restore original values on failure only
$this->docker_compose_location = $initialDockerComposeLocation;
Expand All @@ -1998,6 +2006,9 @@ public function loadComposeFile($isInit = false, ?string $restoreBaseDirectory =
}
throw new RuntimeException('Repository does not exist. Please check your repository URL and try again.');
}
if (str($e->getMessage())->contains('exceeds the 5 MiB size limit')) {
throw $e;
}
throw new RuntimeException('Failed to read the Docker Compose file from the repository.');
} finally {
// Cleanup only - restoration happens in catch block
Expand Down
18 changes: 8 additions & 10 deletions bootstrap/helpers/services.php
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ function replaceVariables(string $variable): Stringable
function getFilesystemVolumesFromServer(ServiceApplication|ServiceDatabase|Application $oneService, bool $isInit = false)
{
try {
if ($oneService->getMorphClass() === \App\Models\Application::class) {
if ($oneService->getMorphClass() === Application::class) {
$workdir = $oneService->workdir();
$server = $oneService->destination->server;
} else {
Expand Down Expand Up @@ -167,13 +167,11 @@ function getFilesystemVolumesFromServer(ServiceApplication|ServiceDatabase|Appli
$isDir = instant_remote_process(["test -d $fileLocation && echo OK || echo NOK"], $server);

if ($isFile === 'OK') {
// If its a file & exists
$filesystemContent = instant_remote_process(["cat $fileLocation"], $server);
if ($fileVolume->is_based_on_git) {
$fileVolume->content = $filesystemContent;
}
$fileVolume->is_directory = false;
$fileVolume->save();
if ($fileVolume->is_based_on_git) {
$fileVolume->loadStorageOnServer();
}
} elseif ($isDir === 'OK') {
// If its a directory & exists
$fileVolume->content = null;
Expand Down Expand Up @@ -204,7 +202,7 @@ function getFilesystemVolumesFromServer(ServiceApplication|ServiceDatabase|Appli
instant_remote_process(["mkdir -p $fileLocation"], $server);
}
}
} catch (\Throwable $e) {
} catch (Throwable $e) {
return handleError($e);
}
}
Expand All @@ -214,7 +212,7 @@ function updateCompose(ServiceApplication|ServiceDatabase $resource)
$name = data_get($resource, 'name');
$dockerComposeRaw = data_get($resource, 'service.docker_compose_raw');
if (! $dockerComposeRaw) {
throw new \Exception('No compose file found or not a valid YAML file.');
throw new Exception('No compose file found or not a valid YAML file.');
}
$dockerCompose = Yaml::parse($dockerComposeRaw);

Expand Down Expand Up @@ -396,7 +394,7 @@ function updateCompose(ServiceApplication|ServiceDatabase $resource)
}
}
}
} catch (\Throwable $e) {
} catch (Throwable $e) {
return handleError($e);
}
}
Expand Down Expand Up @@ -495,7 +493,7 @@ function applyServiceApplicationPrerequisites(Service $service): void
}
}
}
} catch (\Throwable $e) {
} catch (Throwable $e) {
// Log error but don't throw - prerequisites are nice-to-have, not critical
Log::error('Failed to apply service application prerequisites', [
'service_id' => $service->id,
Expand Down
Loading
Loading