From a599c9e8b73f8d1053760266d4467ca09953f7d3 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 29 Sep 2026 20:46:54 +0200 Subject: [PATCH] fix(server): keep data directory commands working for non-root SSH users A non-root SSH user cannot open redirects, expand globs, or cd into /data/coolify (9999:root 0700). Several remote commands relied on that and failed on non-root servers. - Write README files with `tee` instead of shell redirects in database starts and application deployments - Clear Postgres init scripts with `find` instead of a glob - Split the proxy config backfill if/else so each branch gets sudo - Start the log drain with `docker compose -f` instead of `cd` - Wrap the preserved-repository compose start in `sh -c` for non-root - Stage clone archives outside /data/coolify via StagesCloneArchives - Extend the sudo parser and add non-root command tests --- .ai/lessons.md | 4 +- app/Actions/Database/StartClickhouse.php | 2 +- app/Actions/Database/StartDragonfly.php | 2 +- app/Actions/Database/StartKeydb.php | 2 +- app/Actions/Database/StartMariadb.php | 2 +- app/Actions/Database/StartMongodb.php | 2 +- app/Actions/Database/StartMysql.php | 2 +- app/Actions/Database/StartPostgresql.php | 5 +- app/Actions/Database/StartRedis.php | 2 +- app/Actions/Database/StartSqlite.php | 2 +- app/Actions/Proxy/GetProxyConfiguration.php | 9 +- app/Actions/Server/StartLogDrain.php | 2 +- app/Jobs/ApplicationDeploymentJob.php | 10 +- app/Jobs/DatabaseBackupJob.php | 30 +++- app/Jobs/HostPathCloneJob.php | 17 +-- app/Jobs/VolumeCloneJob.php | 17 +-- app/Livewire/Settings/Updates.php | 20 ++- app/Models/Application.php | 3 +- app/Models/LocalFileVolume.php | 3 +- app/Models/Service.php | 24 +-- app/Services/DatabaseStartCommandExecutor.php | 2 +- .../ServerTransfer/ServerTransferClaimer.php | 3 +- app/Traits/StagesCloneArchives.php | 41 ++++++ bootstrap/helpers/services.php | 1 - bootstrap/helpers/sudo.php | 18 +++ tests/Feature/ComposeExternalVolumeTest.php | 4 +- .../DeleteResourceJobAtomicityTest.php | 2 +- .../NonRootDataDirectoryCommandsTest.php | 139 ++++++++++++++++++ .../Proxy/NonRootProxyCommandsTest.php | 4 +- .../TraefikCertificateRemoteFileTest.php | 4 +- tests/Unit/ParseCommandsByLineForSudoTest.php | 21 +++ tests/Unit/UpdateCoolifyTest.php | 16 +- 32 files changed, 336 insertions(+), 79 deletions(-) create mode 100644 app/Traits/StagesCloneArchives.php create mode 100644 tests/Feature/NonRootDataDirectoryCommandsTest.php diff --git a/.ai/lessons.md b/.ai/lessons.md index 03429efdad..01d0d48671 100644 --- a/.ai/lessons.md +++ b/.ai/lessons.md @@ -90,4 +90,6 @@ ## Non-root SSH users: keep file access behind sudo - `parseCommandsByLineForSudo()` does not prefix `cd` or `echo`, and the SSH user's shell opens redirects (`>`, `<`) and expands globs. On the Coolify host, `/data/coolify` is `9999:root 0700`, so these fail for a non-root user. -- In remote commands, use absolute paths (`docker compose -f /docker-compose.yml`), `echo ... | tee > /dev/null`, and `find` instead of globs. +- In remote commands, use absolute paths (`docker compose -f /docker-compose.yml`, `--project-directory `), `echo ... | tee > /dev/null`, and `find` instead of globs. scp also runs as the SSH user; stage files outside `/data/coolify`. +- The parsers add sudo only to line starts (and after `&&`, `||`, `|`, `$(`). Put `if`/`else` branches on their own lines. When a redirect or `cd` must stay, make the whole line one `sh -c '...'` script; it runs as `sudo sh -c` without inner sudo or bash. +- Tests that replace `DatabaseStartCommandExecutor` or use root servers miss non-root bugs. Test the parsed commands of a non-root server. diff --git a/app/Actions/Database/StartClickhouse.php b/app/Actions/Database/StartClickhouse.php index 4332050317..912612ae00 100644 --- a/app/Actions/Database/StartClickhouse.php +++ b/app/Actions/Database/StartClickhouse.php @@ -107,7 +107,7 @@ class StartClickhouse $docker_compose_base64 = base64_encode($docker_compose); $this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null"; $readme = generate_readme_file($this->database->name, now()); - $this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md"; + $this->commands[] = "echo '{$readme}' | tee $this->configuration_dir/README.md > /dev/null"; $this->commands[] = 'echo '.escapeshellarg("Pulling {$database->image} image."); $this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml pull"; $this->commands[] = dockerStopCommand(10, $container_name, $this->database->destination->server).' 2>/dev/null || true'; diff --git a/app/Actions/Database/StartDragonfly.php b/app/Actions/Database/StartDragonfly.php index 1c4f0ca486..2667b944a6 100644 --- a/app/Actions/Database/StartDragonfly.php +++ b/app/Actions/Database/StartDragonfly.php @@ -179,7 +179,7 @@ class StartDragonfly $docker_compose_base64 = base64_encode($docker_compose); $this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null"; $readme = generate_readme_file($this->database->name, now()); - $this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md"; + $this->commands[] = "echo '{$readme}' | tee $this->configuration_dir/README.md > /dev/null"; $this->commands[] = 'echo '.escapeshellarg("Pulling {$database->image} image."); $this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml pull"; if ($this->database->enable_ssl) { diff --git a/app/Actions/Database/StartKeydb.php b/app/Actions/Database/StartKeydb.php index 1b434345aa..38fc9d0f2d 100644 --- a/app/Actions/Database/StartKeydb.php +++ b/app/Actions/Database/StartKeydb.php @@ -194,7 +194,7 @@ class StartKeydb $docker_compose_base64 = base64_encode($docker_compose); $this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null"; $readme = generate_readme_file($this->database->name, now()); - $this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md"; + $this->commands[] = "echo '{$readme}' | tee $this->configuration_dir/README.md > /dev/null"; $this->commands[] = 'echo '.escapeshellarg("Pulling {$database->image} image."); $this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml pull"; if ($this->database->enable_ssl) { diff --git a/app/Actions/Database/StartMariadb.php b/app/Actions/Database/StartMariadb.php index 0479703dae..7e88b10cf5 100644 --- a/app/Actions/Database/StartMariadb.php +++ b/app/Actions/Database/StartMariadb.php @@ -197,7 +197,7 @@ class StartMariadb $docker_compose_base64 = base64_encode($docker_compose); $this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null"; $readme = generate_readme_file($this->database->name, now()); - $this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md"; + $this->commands[] = "echo '{$readme}' | tee $this->configuration_dir/README.md > /dev/null"; $this->commands[] = 'echo '.escapeshellarg("Pulling {$database->image} image."); $this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml pull"; if ($this->database->enable_ssl) { diff --git a/app/Actions/Database/StartMongodb.php b/app/Actions/Database/StartMongodb.php index 388bda0b30..df9bbd7db0 100644 --- a/app/Actions/Database/StartMongodb.php +++ b/app/Actions/Database/StartMongodb.php @@ -249,7 +249,7 @@ class StartMongodb $docker_compose_base64 = base64_encode($docker_compose); $this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null"; $readme = generate_readme_file($this->database->name, now()); - $this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md"; + $this->commands[] = "echo '{$readme}' | tee $this->configuration_dir/README.md > /dev/null"; $this->commands[] = 'echo '.escapeshellarg("Pulling {$database->image} image."); $this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml pull"; if ($this->database->enable_ssl) { diff --git a/app/Actions/Database/StartMysql.php b/app/Actions/Database/StartMysql.php index ff06e0f464..dc6b8311ce 100644 --- a/app/Actions/Database/StartMysql.php +++ b/app/Actions/Database/StartMysql.php @@ -200,7 +200,7 @@ class StartMysql $docker_compose_base64 = base64_encode($docker_compose); $this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null"; $readme = generate_readme_file($this->database->name, now()); - $this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md"; + $this->commands[] = "echo '{$readme}' | tee $this->configuration_dir/README.md > /dev/null"; $this->commands[] = 'echo '.escapeshellarg("Pulling {$database->image} image."); $this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml pull"; if ($this->database->enable_ssl) { diff --git a/app/Actions/Database/StartPostgresql.php b/app/Actions/Database/StartPostgresql.php index 940e1abdde..5db827be07 100644 --- a/app/Actions/Database/StartPostgresql.php +++ b/app/Actions/Database/StartPostgresql.php @@ -209,7 +209,7 @@ class StartPostgresql $docker_compose_base64 = base64_encode($docker_compose); $this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null"; $readme = generate_readme_file($this->database->name, now()); - $this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md"; + $this->commands[] = "echo '{$readme}' | tee $this->configuration_dir/README.md > /dev/null"; $this->commands[] = 'echo '.escapeshellarg("Pulling {$database->image} image."); $this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml pull"; if ($this->database->enable_ssl) { @@ -293,7 +293,8 @@ class StartPostgresql private function generate_init_scripts() { - $this->commands[] = "rm -rf $this->configuration_dir/docker-entrypoint-initdb.d/*"; + // find instead of a shell glob: a non-root SSH user cannot read the directory to expand it. + $this->commands[] = "find $this->configuration_dir/docker-entrypoint-initdb.d -mindepth 1 -maxdepth 1 -exec rm -rf {} + 2>/dev/null || true"; if (blank($this->database->init_scripts) || count($this->database->init_scripts) === 0) { return; diff --git a/app/Actions/Database/StartRedis.php b/app/Actions/Database/StartRedis.php index 2fb74e3e45..c17936a128 100644 --- a/app/Actions/Database/StartRedis.php +++ b/app/Actions/Database/StartRedis.php @@ -191,7 +191,7 @@ class StartRedis $docker_compose_base64 = base64_encode($docker_compose); $this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null"; $readme = generate_readme_file($this->database->name, now()); - $this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md"; + $this->commands[] = "echo '{$readme}' | tee $this->configuration_dir/README.md > /dev/null"; $this->commands[] = 'echo '.escapeshellarg("Pulling {$database->image} image."); $this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml pull"; if ($this->database->enable_ssl) { diff --git a/app/Actions/Database/StartSqlite.php b/app/Actions/Database/StartSqlite.php index 1e602c1d1a..377a680594 100644 --- a/app/Actions/Database/StartSqlite.php +++ b/app/Actions/Database/StartSqlite.php @@ -95,7 +95,7 @@ class StartSqlite $docker_compose_base64 = base64_encode($docker_compose); $this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null"; $readme = generate_readme_file($this->database->name, now()); - $this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md"; + $this->commands[] = "echo '{$readme}' | tee $this->configuration_dir/README.md > /dev/null"; $this->commands[] = 'echo '.escapeshellarg("Pulling {$database->image} image."); $this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml pull"; $this->commands[] = dockerStopCommand(10, $container_name, $this->database->destination->server).' 2>/dev/null || true'; diff --git a/app/Actions/Proxy/GetProxyConfiguration.php b/app/Actions/Proxy/GetProxyConfiguration.php index c1249ba496..32f5ed5f1f 100644 --- a/app/Actions/Proxy/GetProxyConfiguration.php +++ b/app/Actions/Proxy/GetProxyConfiguration.php @@ -113,7 +113,14 @@ class GetProxyConfiguration $readLimit = self::MAX_CONFIGURATION_SIZE_BYTES + 1; $result = instant_remote_process([ "mkdir -p $proxy_path", - "if [ ! -f {$configurationPath} ]; then exit 0; elif [ \"$(stat -c %s {$configurationPath})\" -gt ".self::MAX_CONFIGURATION_SIZE_BYTES." ]; then echo '__COOLIFY_PROXY_CONFIG_TOO_LARGE__'; else head -c {$readLimit} {$configurationPath}; fi", + // One command per line: the non-root sudo parser adds sudo to line starts, not to `then`/`else` branches. + "if [ -f {$configurationPath} ]; then", + " if [ \"$(stat -c %s {$configurationPath})\" -gt ".self::MAX_CONFIGURATION_SIZE_BYTES.' ]; then', + " echo '__COOLIFY_PROXY_CONFIG_TOO_LARGE__'", + ' else', + " head -c {$readLimit} {$configurationPath}", + ' fi', + 'fi', ], $server, false); if ($result === '__COOLIFY_PROXY_CONFIG_TOO_LARGE__' || strlen($result ?? '') > self::MAX_CONFIGURATION_SIZE_BYTES) { diff --git a/app/Actions/Server/StartLogDrain.php b/app/Actions/Server/StartLogDrain.php index eb419992d5..8c0922642f 100644 --- a/app/Actions/Server/StartLogDrain.php +++ b/app/Actions/Server/StartLogDrain.php @@ -200,7 +200,7 @@ Files: "echo '{$readme}' | base64 -d | tee $readme_path > /dev/null", "echo '{$envEncoded}' | base64 -d | tee $config_path/.env > /dev/null", "echo 'Starting Fluent Bit'", - "cd $config_path && docker compose up -d", + "docker compose -f $compose_path up -d", ]; $command = array_merge($command, $this->logDrainNetworkConnectCommands($server)); diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index 5b405036ba..f21e2bdb39 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -997,8 +997,14 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue $this->write_deployment_configurations(); if ($this->preserveRepository) { + $start_in_workdir = "cd {$server_workdir} && {$start_command}"; + if ($this->server->isNonRoot()) { + // A non-root SSH user cannot cd into the resource directory on the Coolify host. As one + // `sh -c` script, the sudo parser runs the cd and the start command as root. + $start_in_workdir = 'sh -c '.escapeshellarg($start_in_workdir); + } $this->execute_remote_command( - ['command' => "cd {$server_workdir} && {$start_command}", 'hidden' => false, 'type' => 'stdout', 'command_hidden' => true], + ['command' => $start_in_workdir, 'hidden' => false, 'type' => 'stdout', 'command_hidden' => true], ); } else { $this->execute_remote_command( @@ -1233,7 +1239,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue 'skip_command_log' => true, ], [ - "echo '{$readme}' > $mainDir/README.md", + "echo '{$readme}' | tee $mainDir/README.md > /dev/null", ] ); if ($this->use_build_server) { diff --git a/app/Jobs/DatabaseBackupJob.php b/app/Jobs/DatabaseBackupJob.php index 31fa878f85..bc28f4a968 100644 --- a/app/Jobs/DatabaseBackupJob.php +++ b/app/Jobs/DatabaseBackupJob.php @@ -610,7 +610,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue } } } - $this->backup_output = instant_remote_process($commands, $this->server, true, false, $this->timeout, disableMultiplexing: true); + $this->backup_output = instant_remote_process($this->writeBackupFileAsRoot($commands), $this->server, true, false, $this->timeout, disableMultiplexing: true); $this->backup_output = trim($this->backup_output); if ($this->backup_output === '') { $this->backup_output = null; @@ -667,7 +667,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue } $commands[] = $backupCommand; - $this->backup_output = instant_remote_process($commands, $this->server, true, false, $this->timeout, disableMultiplexing: true); + $this->backup_output = instant_remote_process($this->writeBackupFileAsRoot($commands), $this->server, true, false, $this->timeout, disableMultiplexing: true); $this->backup_output = trim($this->backup_output); if ($this->backup_output === '') { $this->backup_output = null; @@ -694,7 +694,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue $escapedDatabase = escapeshellarg($database); $commands[] = "docker exec {$escapedContainerName} mysqldump -u root -p$escapedPassword $escapedDatabase > {$escapedBackupLocation}"; } - $this->backup_output = instant_remote_process($commands, $this->server, true, false, $this->timeout, disableMultiplexing: true); + $this->backup_output = instant_remote_process($this->writeBackupFileAsRoot($commands), $this->server, true, false, $this->timeout, disableMultiplexing: true); $this->backup_output = trim($this->backup_output); if ($this->backup_output === '') { $this->backup_output = null; @@ -721,7 +721,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue $escapedDatabase = escapeshellarg($database); $commands[] = "docker exec {$escapedContainerName} mariadb-dump -u root -p$escapedPassword $escapedDatabase > {$escapedBackupLocation}"; } - $this->backup_output = instant_remote_process($commands, $this->server, true, false, $this->timeout, disableMultiplexing: true); + $this->backup_output = instant_remote_process($this->writeBackupFileAsRoot($commands), $this->server, true, false, $this->timeout, disableMultiplexing: true); $this->backup_output = trim($this->backup_output); if ($this->backup_output === '') { $this->backup_output = null; @@ -744,7 +744,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue backupDirectory: $this->backup_dir, ); - $this->backup_output = instant_remote_process($commands, $this->server, true, false, $this->timeout, disableMultiplexing: true); + $this->backup_output = instant_remote_process($this->writeBackupFileAsRoot($commands), $this->server, true, false, $this->timeout, disableMultiplexing: true); $this->backup_output = trim($this->backup_output); if ($this->backup_output === '') { $this->backup_output = null; @@ -768,7 +768,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue $script = 'f=$(mktemp) && sqlite3 -readonly '.escapeshellarg(StandaloneSqlite::DATA_DIRECTORY.'/'.$database).' "VACUUM INTO \'$f\'" && cat "$f"; s=$?; rm -f "$f"; exit $s'; $dumpCommand = 'docker exec '.escapeshellarg($this->container_name).' sh -c '.escapeshellarg($script); $commands[] = $this->buildCompressedDumpCommand($dumpCommand).' > '.escapeshellarg($this->backup_location); - $this->backup_output = instant_remote_process($commands, $this->server, true, false, $this->timeout, disableMultiplexing: true); + $this->backup_output = instant_remote_process($this->writeBackupFileAsRoot($commands), $this->server, true, false, $this->timeout, disableMultiplexing: true); $this->backup_output = trim($this->backup_output); if ($this->backup_output === '') { $this->backup_output = null; @@ -882,6 +882,24 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue return "{$helperImage}:{$latestVersion}"; } + /** + * The SSH user's shell opens a `>` redirect. For a non-root SSH user, the dump and the redirect into + * the backup directory must run in one root shell, so each line that writes the backup file becomes + * one `sh -c` script. The exit status of the dump stays the exit status of the line. + * + * @param list $commands + * @return list + */ + private function writeBackupFileAsRoot(array $commands): array + { + $redirect = '> '.escapeshellarg($this->backup_location); + + return array_map( + fn (string $command): string => str_contains($command, $redirect) ? 'sh -c '.escapeshellarg($command) : $command, + $commands, + ); + } + private function buildCompressedDumpCommand(string $dumpCommand): string { $cpuPercentage = BackupCompression::cpuPercentage($this->server->settings->backup_compression_cpu_percentage); diff --git a/app/Jobs/HostPathCloneJob.php b/app/Jobs/HostPathCloneJob.php index 2b26fc5516..1a7300b880 100644 --- a/app/Jobs/HostPathCloneJob.php +++ b/app/Jobs/HostPathCloneJob.php @@ -3,6 +3,7 @@ namespace App\Jobs; use App\Models\Server; +use App\Traits\StagesCloneArchives; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldBeEncrypted; use Illuminate\Contracts\Queue\ShouldQueue; @@ -17,9 +18,7 @@ use Illuminate\Support\Str; */ class HostPathCloneJob implements ShouldBeEncrypted, ShouldQueue { - use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; - - protected string $cloneDir = '/data/coolify/clone'; + use Dispatchable, InteractsWithQueue, Queueable, SerializesModels, StagesCloneArchives; public int $timeout = 3600; @@ -64,8 +63,8 @@ class HostPathCloneJob implements ShouldBeEncrypted, ShouldQueue { $archiveName = 'hostpath-data.tar.gz'; $token = Str::uuid()->toString(); - $sourceCloneDir = "{$this->cloneDir}/hostpath-{$token}"; - $targetCloneDir = "{$this->cloneDir}/hostpath-{$token}"; + $sourceCloneDir = $this->cloneArchiveDirectory($this->sourceServer, "hostpath-{$token}"); + $targetCloneDir = $this->cloneArchiveDirectory($this->targetServer, "hostpath-{$token}"); $srcDir = escapeshellarg($sourceCloneDir); $tgtDir = escapeshellarg($targetCloneDir); $srcPath = escapeshellarg($this->sourcePath); @@ -78,16 +77,12 @@ class HostPathCloneJob implements ShouldBeEncrypted, ShouldQueue File::ensureDirectoryExists($localTempDir, 0755); instant_remote_process([ - "mkdir -p {$srcDir}", - "chmod 777 {$srcDir}", + ...$this->prepareCloneArchiveDirectory($this->sourceServer, $sourceCloneDir), "test -e {$srcPath}", "docker run --rm -v {$srcPath}:/source:ro -v {$srcDir}:/clone alpine sh -c 'cd /source && tar czf /clone/{$archiveName} .'", ], $this->sourceServer); - instant_remote_process([ - "mkdir -p {$tgtDir}", - "chmod 777 {$tgtDir}", - ], $this->targetServer); + instant_remote_process($this->prepareCloneArchiveDirectory($this->targetServer, $targetCloneDir), $this->targetServer); instant_scp_from_server( "{$sourceCloneDir}/{$archiveName}", diff --git a/app/Jobs/VolumeCloneJob.php b/app/Jobs/VolumeCloneJob.php index f511d2546b..d43705bc5a 100644 --- a/app/Jobs/VolumeCloneJob.php +++ b/app/Jobs/VolumeCloneJob.php @@ -4,6 +4,7 @@ namespace App\Jobs; use App\Models\LocalPersistentVolume; use App\Models\Server; +use App\Traits\StagesCloneArchives; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldBeEncrypted; use Illuminate\Contracts\Queue\ShouldQueue; @@ -15,9 +16,7 @@ use Illuminate\Support\Str; class VolumeCloneJob implements ShouldBeEncrypted, ShouldQueue { - use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; - - protected string $cloneDir = '/data/coolify/clone'; + use Dispatchable, InteractsWithQueue, Queueable, SerializesModels, StagesCloneArchives; public int $timeout = 3600; @@ -65,8 +64,8 @@ class VolumeCloneJob implements ShouldBeEncrypted, ShouldQueue { $srcVol = escapeshellarg($this->sourceVolume); $tgtVol = escapeshellarg($this->targetVolume); - $sourceCloneDir = "{$this->cloneDir}/{$this->sourceVolume}"; - $targetCloneDir = "{$this->cloneDir}/{$this->targetVolume}"; + $sourceCloneDir = $this->cloneArchiveDirectory($this->sourceServer, $this->sourceVolume); + $targetCloneDir = $this->cloneArchiveDirectory($this->targetServer, $this->targetVolume); $srcDir = escapeshellarg($sourceCloneDir); $tgtDir = escapeshellarg($targetCloneDir); $localTempDir = storage_path('app/tmp/volume-clones/'.Str::uuid()->toString()); @@ -76,15 +75,11 @@ class VolumeCloneJob implements ShouldBeEncrypted, ShouldQueue File::ensureDirectoryExists($localTempDir, 0755); instant_remote_process([ - "mkdir -p {$srcDir}", - "chmod 777 {$srcDir}", + ...$this->prepareCloneArchiveDirectory($this->sourceServer, $sourceCloneDir), "docker run --rm -v {$srcVol}:/source -v {$srcDir}:/clone alpine sh -c 'cd /source && tar czf /clone/volume-data.tar.gz .'", ], $this->sourceServer); - instant_remote_process([ - "mkdir -p {$tgtDir}", - "chmod 777 {$tgtDir}", - ], $this->targetServer); + instant_remote_process($this->prepareCloneArchiveDirectory($this->targetServer, $targetCloneDir), $this->targetServer); // Coolify host is the intermediary: download from source, upload to target. instant_scp_from_server( diff --git a/app/Livewire/Settings/Updates.php b/app/Livewire/Settings/Updates.php index 856b7e2a45..615cc99246 100644 --- a/app/Livewire/Settings/Updates.php +++ b/app/Livewire/Settings/Updates.php @@ -80,9 +80,7 @@ class Updates extends Component } try { - instant_remote_process([ - $this->registryEnvSyncCommand($registryUrl), - ], $this->server); + instant_remote_process($this->registryEnvSyncCommand($registryUrl), $this->server); } catch (\Exception $e) { Log::warning('Failed to sync REGISTRY_URL to .env', [ 'error' => $e->getMessage(), @@ -92,13 +90,25 @@ class Updates extends Component } } - private function registryEnvSyncCommand(string $registryUrl): string + /** + * One command per line: the non-root sudo parser adds sudo to line starts, not to `then`/`else` branches, + * and a `>>` redirect would be opened by the SSH user. + * + * @return list + */ + private function registryEnvSyncCommand(string $registryUrl): array { $envFile = '/data/coolify/source/.env'; $sedExpression = escapeshellarg("s|^REGISTRY_URL=.*|REGISTRY_URL={$registryUrl}|"); $registryLine = escapeshellarg("REGISTRY_URL={$registryUrl}"); - return "if grep -q '^REGISTRY_URL=' {$envFile}; then sed -i {$sedExpression} {$envFile}; else printf '%s\\n' {$registryLine} >> {$envFile}; fi"; + return [ + "if grep -q '^REGISTRY_URL=' {$envFile}; then", + " sed -i {$sedExpression} {$envFile}", + 'else', + " printf '%s\\n' {$registryLine} | tee -a {$envFile} > /dev/null", + 'fi', + ]; } public function submit() diff --git a/app/Models/Application.php b/app/Models/Application.php index 76a11f3b3a..e2694d01c1 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -551,7 +551,8 @@ class Application extends BaseModel $persistentStorages = $this->persistentStorages()->get() ?? collect(); if ($this->build_pack === 'dockercompose') { $server = data_get($this, 'destination.server'); - instant_remote_process(["cd {$this->dirOnServer()} && docker compose down -v"], $server, false); + // --project-directory instead of cd: a non-root SSH user cannot enter the directory on the Coolify host. + instant_remote_process(["docker compose --project-directory {$this->dirOnServer()} down -v"], $server, false); } else { if ($persistentStorages->count() === 0) { return; diff --git a/app/Models/LocalFileVolume.php b/app/Models/LocalFileVolume.php index 1e01d4f43f..072f51cd9e 100644 --- a/app/Models/LocalFileVolume.php +++ b/app/Models/LocalFileVolume.php @@ -208,7 +208,7 @@ class LocalFileVolume extends BaseModel protected function remoteFileExceedsLimit(string $escapedPath, $server): bool { $sizeOutput = instant_remote_process( - ["stat -c%s {$escapedPath} 2>/dev/null || wc -c < {$escapedPath}"], + ["stat -c%s {$escapedPath} 2>/dev/null"], $server, false, ); @@ -308,7 +308,6 @@ class LocalFileVolume extends BaseModel $escapedFsPath = escapeshellarg($this->fs_path); $commands->push("mkdir -p {$escapedFsPath} > /dev/null 2>&1 || true"); $commands->push("mkdir -p {$escapedWorkdir} > /dev/null 2>&1 || true"); - $commands->push("cd {$escapedWorkdir}"); } $path = data_get_str($this, 'fs_path'); $content = data_get($this, 'content'); diff --git a/app/Models/Service.php b/app/Models/Service.php index 2ced7ad2b9..545250013b 100644 --- a/app/Models/Service.php +++ b/app/Models/Service.php @@ -15,7 +15,6 @@ 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\Storage; use OpenApi\Attributes as OA; use Symfony\Component\Yaml\Yaml; @@ -1573,20 +1572,12 @@ class Service extends BaseModel } $workdir = $this->workdir(); - - instant_remote_process([ + // Absolute paths and tee, no cd or scp: a non-root SSH user cannot enter /data/coolify on the Coolify host. + $commands = [ "mkdir -p $workdir", - "cd $workdir", - ], $this->server); - - $filename = new_public_id().'-docker-compose.yml'; - Storage::disk('local')->put("tmp/{$filename}", $this->docker_compose); - $path = Storage::path("tmp/{$filename}"); - instant_scp($path, "{$workdir}/docker-compose.yml", $this->server); - Storage::disk('local')->delete("tmp/{$filename}"); - - $commands[] = "cd $workdir"; - $environmentFilename = new_public_id().'.env.tmp'; + "echo '".base64_encode($this->docker_compose)."' | base64 -d | tee $workdir/docker-compose.yml > /dev/null", + ]; + $environmentFile = "$workdir/".new_public_id().'.env.tmp'; $envs = collect([]); @@ -1617,11 +1608,12 @@ class Service extends BaseModel $envs->push("{$env->key}={$this->resolveSecretManagerEnvironmentVariable($env)}"); } if ($envs->count() === 0) { - $commands[] = "touch {$environmentFilename} && mv {$environmentFilename} .env"; + $commands[] = "touch {$environmentFile}"; } else { $envs_base64 = base64_encode($envs->implode("\n")); - $commands[] = "echo '$envs_base64' | base64 -d | tee {$environmentFilename} > /dev/null && mv {$environmentFilename} .env"; + $commands[] = "echo '$envs_base64' | base64 -d | tee {$environmentFile} > /dev/null"; } + $commands[] = "mv {$environmentFile} $workdir/.env"; instant_remote_process($commands, $this->server); } diff --git a/app/Services/DatabaseStartCommandExecutor.php b/app/Services/DatabaseStartCommandExecutor.php index dab3599101..96e2f500bd 100644 --- a/app/Services/DatabaseStartCommandExecutor.php +++ b/app/Services/DatabaseStartCommandExecutor.php @@ -14,7 +14,7 @@ class DatabaseStartCommandExecutor { $server = $database->destination->server; if ($server->isNonRoot()) { - $commands = parseCommandsByLineForSudo(collect($commands), $server)->all(); + $commands = parseCommandsByLineForSudo(collect($commands), $server); } $secrets = method_exists($database, 'resolvedSecretManagerValuesForRedaction') diff --git a/app/Services/ServerTransfer/ServerTransferClaimer.php b/app/Services/ServerTransfer/ServerTransferClaimer.php index dcdfb01bb4..593bdd91ed 100644 --- a/app/Services/ServerTransfer/ServerTransferClaimer.php +++ b/app/Services/ServerTransfer/ServerTransferClaimer.php @@ -165,7 +165,8 @@ class ServerTransferClaimer try { instant_remote_process([ 'mkdir -p '.escapeshellarg(ServerTransferBundle::MAILBOX_DIR), - 'echo '.escapeshellarg($b64).' | base64 -d > '.escapeshellarg($path), + // tee, not a redirect: the SSH user's shell opens a redirect, and a non-root user cannot enter /data/coolify. + 'echo '.escapeshellarg($b64).' | base64 -d | tee '.escapeshellarg($path).' > /dev/null', 'chmod 600 '.escapeshellarg($path), 'chown 9999:root '.escapeshellarg($path).' || true', ], $server, true); diff --git a/app/Traits/StagesCloneArchives.php b/app/Traits/StagesCloneArchives.php new file mode 100644 index 0000000000..68e08798a6 --- /dev/null +++ b/app/Traits/StagesCloneArchives.php @@ -0,0 +1,41 @@ +isNonRoot() ? '/var/tmp/coolify-clone' : $this->cloneDir)."/{$name}"; + } + + /** + * @return list + */ + protected function prepareCloneArchiveDirectory(Server $server, string $directory): array + { + $escapedDirectory = escapeshellarg($directory); + + if ($server->isNonRoot()) { + return [ + "mkdir -p {$escapedDirectory}", + 'chown '.escapeshellarg($server->user)." {$escapedDirectory}", + "chmod 700 {$escapedDirectory}", + ]; + } + + return [ + "mkdir -p {$escapedDirectory}", + "chmod 777 {$escapedDirectory}", + ]; + } +} diff --git a/bootstrap/helpers/services.php b/bootstrap/helpers/services.php index cce4be5742..0b81342c77 100644 --- a/bootstrap/helpers/services.php +++ b/bootstrap/helpers/services.php @@ -201,7 +201,6 @@ function getFilesystemVolumesFromServer(ServiceApplication|ServiceDatabase|Appli $escapedWorkdir = escapeshellarg($workdir); $commands = collect([ "mkdir -p -- {$escapedWorkdir} > /dev/null 2>&1 || true", - "cd {$escapedWorkdir}", ]); instant_remote_process($commands, $server); foreach ($fileVolumes as $fileVolume) { diff --git a/bootstrap/helpers/sudo.php b/bootstrap/helpers/sudo.php index 25ab683b4e..a53889ffdf 100644 --- a/bootstrap/helpers/sudo.php +++ b/bootstrap/helpers/sudo.php @@ -30,6 +30,15 @@ function ownershipCommand(string $path, Server $server): string return "find $path -user root -exec chown $server->user:$server->user {} + && chmod o-rwx $path"; } +/** + * A line that is one `sudo sh -c '...'` call already runs completely as root. Adding sudo inside the script + * fails where root is not in sudoers (Alpine), and wrapping it again in `bash -c` needs bash. + */ +function isSingleSudoShellScript(string $line): bool +{ + return preg_match("/^\\s*sudo (?:ba)?sh -c '(?:[^']|'\\\\'')*'\\s*$/", $line) === 1; +} + function parseCommandsByLineForSudo(Collection $commands, Server $server): array { $commands = $commands->map(function ($line) { @@ -105,6 +114,10 @@ function parseCommandsByLineForSudo(Collection $commands, Server $server): array }); $commands = $commands->map(function ($line) { + if (isSingleSudoShellScript($line)) { + return $line; + } + $line = str($line); // Detect complex piped commands that should be wrapped in bash -c @@ -156,6 +169,9 @@ function parseLineForSudo(string $command, Server $server): string $command = "$command && ".ownershipCommand($path, $server); } } + if (isSingleSudoShellScript($command)) { + return $command; + } if (str($command)->contains('$(') || str($command)->contains('`')) { $command = str($command)->replace('$(', '$(sudo ')->replace('`', '`sudo ')->value(); } @@ -165,6 +181,8 @@ function parseLineForSudo(string $command, Server $server): string if (str($command)->contains('&&')) { $command = str($command)->replace('&&', '&& sudo ')->value(); } + // Each pipe stage is a separate process; without sudo, `| tee file` writes as the SSH user. + $command = preg_replace('/ \| (?!sudo )/', ' | sudo ', $command); return $command; } diff --git a/tests/Feature/ComposeExternalVolumeTest.php b/tests/Feature/ComposeExternalVolumeTest.php index 9c2597bd19..e66797826d 100644 --- a/tests/Feature/ComposeExternalVolumeTest.php +++ b/tests/Feature/ComposeExternalVolumeTest.php @@ -683,7 +683,9 @@ describe('delete', function () { (new DeleteResourceJob($application))->handle(); $all = implode("\n", $commands); - expect($all)->toContain('docker compose down -v') + // --project-directory, not cd: a non-root SSH user cannot enter the directory on the Coolify host. + expect($all)->toContain("docker compose --project-directory {$application->dirOnServer()} down -v") + ->not->toContain("cd {$application->dirOnServer()}") ->not->toContain('docker volume rm') ->and($application->docker_compose)->toContain("shared-data:\n external: true"); }); diff --git a/tests/Feature/DeleteResourceJobAtomicityTest.php b/tests/Feature/DeleteResourceJobAtomicityTest.php index beb96cb5cd..7696a2ffbd 100644 --- a/tests/Feature/DeleteResourceJobAtomicityTest.php +++ b/tests/Feature/DeleteResourceJobAtomicityTest.php @@ -304,7 +304,7 @@ it('removes service containers with a command that works for non-root SSH users' // A leading variable assignment becomes "sudo container_ids=...", which sudo rejects. expect($commands->implode("\n")) ->not->toContain('sudo container_ids=') - ->toContain("sudo bash -c 'sh -c") + ->toContain("sudo sh -c '") ->toContain("label=coolify.service.subUuid={$application->uuid}") ->toContain('docker rm -f $container_ids'); }); diff --git a/tests/Feature/NonRootDataDirectoryCommandsTest.php b/tests/Feature/NonRootDataDirectoryCommandsTest.php new file mode 100644 index 0000000000..def281e0b2 --- /dev/null +++ b/tests/Feature/NonRootDataDirectoryCommandsTest.php @@ -0,0 +1,139 @@ + 0]); + config(['app.maintenance.store' => 'array', 'constants.ssh.mux_enabled' => false]); + Process::fake(); + Queue::fake(); + + $team = Team::factory()->create(); + $this->server = Server::factory()->create([ + 'team_id' => $team->id, + 'user' => 'cooluser', + 'private_key_id' => PrivateKey::factory()->create(['team_id' => $team->id])->id, + ]); + $this->server->settings->update(['is_reachable' => true, 'is_usable' => true, 'force_disabled' => false]); + $this->destination = StandaloneDocker::query()->where('server_id', $this->server->id)->firstOrFail(); + $project = Project::factory()->create(['team_id' => $team->id]); + $this->environment = Environment::factory()->create(['project_id' => $project->id]); +}); + +/** + * @return array + */ +function nonRootDataDirectoryRemoteLines(): array +{ + $lines = []; + Process::assertRan(function ($process) use (&$lines) { + array_push($lines, ...explode("\n", $process->command)); + + return true; + }); + + return $lines; +} + +function expectNoUnprivilegedDataDirectoryAccess(array $lines): void +{ + foreach ($lines as $line) { + expect(trim($line))->not->toStartWith('cd ') + ->and($line)->not->toMatch('/[^2&]>>?\s*\'?\/data\/coolify/') + ->and($line)->not->toStartWith('scp '); + } +} + +it('starts a database through the queued executor on a non-root server', function () { + $database = create_standalone_postgresql($this->environment->id, $this->destination); + $configurationDirectory = database_configuration_dir().'/'.$database->uuid; + + StartPostgresql::run($database->fresh(), activity()->log('Starting database')); + + $lines = nonRootDataDirectoryRemoteLines(); + expectNoUnprivilegedDataDirectoryAccess($lines); + expect(implode("\n", $lines)) + ->toContain("| sudo tee {$configurationDirectory}/README.md > /dev/null") + ->toContain("sudo find {$configurationDirectory}/docker-entrypoint-initdb.d -mindepth 1 -maxdepth 1 -exec rm -rf {} +") + ->toContain("sudo docker compose -f {$configurationDirectory}/docker-compose.yml up -d"); +}); + +it('writes service compose files without cd or scp on a non-root server', function () { + $service = Service::factory()->create([ + 'environment_id' => $this->environment->id, + 'server_id' => $this->server->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + 'docker_compose' => "services:\n app:\n image: nginx\n", + ]); + $workdir = $service->workdir(); + + $service->saveComposeConfigs(); + + $lines = nonRootDataDirectoryRemoteLines(); + expectNoUnprivilegedDataDirectoryAccess($lines); + expect(implode("\n", $lines)) + ->toContain("| sudo tee {$workdir}/docker-compose.yml > /dev/null") + ->toMatch('/^sudo mv '.preg_quote($workdir, '/').'\/\S+\.env\.tmp '.preg_quote($workdir, '/').'\/\.env$/m'); +}); + +it('writes a database dump and its redirect in one root shell', function () { + $backupLocation = '/data/coolify/backups/databases/team-1/db/pg-dump-db-1.dmp'; + $job = (new ReflectionClass(DatabaseBackupJob::class))->newInstanceWithoutConstructor(); + (new ReflectionProperty(DatabaseBackupJob::class, 'backup_location'))->setValue($job, $backupLocation); + + $commands = (new ReflectionMethod(DatabaseBackupJob::class, 'writeBackupFileAsRoot'))->invoke($job, [ + "mkdir -p '/data/coolify/backups/databases/team-1/db'", + "docker exec 'db' pg_dump --format=custom 'app' > '{$backupLocation}'", + ]); + + expect(parseCommandsByLineForSudo(collect($commands), $this->server))->toBe([ + "sudo mkdir -p '/data/coolify/backups/databases/team-1/db'", + "sudo sh -c 'docker exec '\\''db'\\'' pg_dump --format=custom '\\''app'\\'' > '\\''{$backupLocation}'\\'''", + ]); +}); + +it('stages clone archives outside /data/coolify only for a non-root SSH user', function () { + $stager = new class + { + use StagesCloneArchives { + cloneArchiveDirectory as public; + prepareCloneArchiveDirectory as public; + } + }; + $rootServer = Server::factory()->create(['team_id' => $this->server->team_id, 'user' => 'root', 'private_key_id' => $this->server->private_key_id]); + + $nonRootDirectory = $stager->cloneArchiveDirectory($this->server, 'volume-a'); + + expect($nonRootDirectory)->toBe('/var/tmp/coolify-clone/volume-a') + ->and($stager->prepareCloneArchiveDirectory($this->server, $nonRootDirectory))->toBe([ + "mkdir -p '/var/tmp/coolify-clone/volume-a'", + "chown 'cooluser' '/var/tmp/coolify-clone/volume-a'", + "chmod 700 '/var/tmp/coolify-clone/volume-a'", + ]) + ->and($stager->cloneArchiveDirectory($rootServer, 'volume-a'))->toBe('/data/coolify/clone/volume-a') + ->and($stager->prepareCloneArchiveDirectory($rootServer, '/data/coolify/clone/volume-a'))->toBe([ + "mkdir -p '/data/coolify/clone/volume-a'", + "chmod 777 '/data/coolify/clone/volume-a'", + ]); +}); diff --git a/tests/Feature/Proxy/NonRootProxyCommandsTest.php b/tests/Feature/Proxy/NonRootProxyCommandsTest.php index 49cd73584f..f1cda7eb28 100644 --- a/tests/Feature/Proxy/NonRootProxyCommandsTest.php +++ b/tests/Feature/Proxy/NonRootProxyCommandsTest.php @@ -80,7 +80,9 @@ it('starts the proxy without entering proxy directories as the SSH user', functi expectNoUnprivilegedProxyFileAccess($lines); expect($lines) ->toContain("sudo docker compose -f {$proxyPath}/docker-compose.yml pull") - ->toContain("sudo docker compose -f {$proxyPath}/docker-compose.yml up -d --wait --remove-orphans"); + ->toContain("sudo docker compose -f {$proxyPath}/docker-compose.yml up -d --wait --remove-orphans") + // The backfill reads a stored configuration in an `else` branch, which must run through sudo too. + ->and(implode("\n", $lines))->toMatch("/^sudo\\s+head -c \\d+ '\\S+\\/docker-compose\\.yml'$/m"); })->with([ 'traefik' => [ProxyTypes::TRAEFIK->value, '/data/coolify/proxy'], 'caddy' => [ProxyTypes::CADDY->value, '/data/coolify/proxy/caddy'], diff --git a/tests/Feature/TraefikCertificateRemoteFileTest.php b/tests/Feature/TraefikCertificateRemoteFileTest.php index 12e4e08939..ae50e45236 100644 --- a/tests/Feature/TraefikCertificateRemoteFileTest.php +++ b/tests/Feature/TraefikCertificateRemoteFileTest.php @@ -56,7 +56,7 @@ it('reads the ACME file with sudo on non-root servers', function () { $certificates = GetTraefikCertificates::run($this->server); expect($certificates)->toHaveCount(40); - Process::assertRan(fn ($process) => str_contains($process->command, "\nsudo bash -c 'sh -c '\\''if [ ! -f") + Process::assertRan(fn ($process) => str_contains($process->command, "\nsudo sh -c 'if [ ! -f") && str_contains($process->command, 'head -c')); }); @@ -70,7 +70,7 @@ it('uploads a large ACME file instead of passing it as a shell argument', functi ->and($remaining)->toHaveCount(39) ->and(collect($remaining)->pluck('domain.main'))->not->toContain('app1.example.com'); - Process::assertRan(fn ($process) => str_contains($process->command, "\nsudo bash -c 'sh -c '\\''set -e;") + Process::assertRan(fn ($process) => str_contains($process->command, "\nsudo sh -c 'set -e;") && str_contains($process->command, 'umask 077') && str_contains($process->command, 'chmod 600') && str_contains($process->command, 'mv --')); diff --git a/tests/Unit/ParseCommandsByLineForSudoTest.php b/tests/Unit/ParseCommandsByLineForSudoTest.php index 55deb7b12d..ec13295438 100644 --- a/tests/Unit/ParseCommandsByLineForSudoTest.php +++ b/tests/Unit/ParseCommandsByLineForSudoTest.php @@ -777,3 +777,24 @@ test('do keyword with word boundary is not given sudo', function () { expect($result[0])->toBe('do'); }); + +test('keeps a single sudo sh -c script unchanged in both parsers', function () { + // A redirect inside the script is opened by root. Inner sudo fails where root is not in sudoers (Alpine). + $line = 'sh -c '.escapeshellarg("docker exec 'db' pg_dump 'app' > '/data/coolify/backups/app.dmp' && echo it's done | cat"); + + expect(parseCommandsByLineForSudo(collect([$line]), $this->server)[0])->toBe("sudo {$line}") + ->and(parseLineForSudo($line, $this->server))->toBe("sudo {$line}"); +}); + +test('still wraps a sh -c call that is not the whole line', function () { + $line = "docker exec db pg_dumpall | docker run --rm -i helper sh -c 'gzip' > /data/coolify/backups/all.gz"; + + expect(parseCommandsByLineForSudo(collect([$line]), $this->server)[0])->toStartWith("sudo bash -c '"); +}); + +test('parseLineForSudo adds sudo to each pipe stage without doubling it', function () { + expect(parseLineForSudo("echo 'ZW52' | base64 -d | tee /data/coolify/applications/app/.env > /dev/null", $this->server)) + ->toBe("sudo echo 'ZW52' | sudo base64 -d | sudo tee /data/coolify/applications/app/.env > /dev/null") + ->and(parseLineForSudo('docker ps | sudo grep app', $this->server)) + ->toBe('sudo docker ps | sudo grep app'); +}); diff --git a/tests/Unit/UpdateCoolifyTest.php b/tests/Unit/UpdateCoolifyTest.php index ffb8766e01..d27a634018 100644 --- a/tests/Unit/UpdateCoolifyTest.php +++ b/tests/Unit/UpdateCoolifyTest.php @@ -273,10 +273,18 @@ it('appends registry url to env file when the key is missing', function () { $component = new Updates; $method = new ReflectionMethod(Updates::class, 'registryEnvSyncCommand'); - expect($method->invoke($component, 'ghcr.io')) - ->toContain("grep -q '^REGISTRY_URL=' /data/coolify/source/.env") - ->toContain("sed -i 's|^REGISTRY_URL=.*|REGISTRY_URL=ghcr.io|' /data/coolify/source/.env") - ->toContain("printf '%s\\n' 'REGISTRY_URL=ghcr.io' >> /data/coolify/source/.env"); + $server = new Server; + $server->user = 'cooluser'; + $lines = parseCommandsByLineForSudo(collect($method->invoke($component, 'ghcr.io')), $server); + + // Each branch runs through sudo for a non-root SSH user; nothing is opened by the SSH user's shell. + expect($lines)->toBe([ + "if sudo grep -q '^REGISTRY_URL=' /data/coolify/source/.env; then", + "sudo sed -i 's|^REGISTRY_URL=.*|REGISTRY_URL=ghcr.io|' /data/coolify/source/.env", + 'else', + "sudo printf '%s\\n' 'REGISTRY_URL=ghcr.io' | sudo tee -a /data/coolify/source/.env > /dev/null", + 'fi', + ]); }); it('prevents downgrade even with manual update', function () {