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
This commit is contained in:
Andras Bacsai
2026-09-29 20:46:54 +02:00
parent 8b8022bac4
commit a599c9e8b7
32 changed files with 336 additions and 79 deletions
+3 -1
View File
@@ -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 <dir>/docker-compose.yml`), `echo ... | tee <file> > /dev/null`, and `find` instead of globs.
- In remote commands, use absolute paths (`docker compose -f <dir>/docker-compose.yml`, `--project-directory <dir>`), `echo ... | tee <file> > /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.
+1 -1
View File
@@ -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';
+1 -1
View File
@@ -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) {
+1 -1
View File
@@ -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) {
+1 -1
View File
@@ -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) {
+1 -1
View File
@@ -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) {
+1 -1
View File
@@ -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) {
+3 -2
View File
@@ -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;
+1 -1
View File
@@ -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) {
+1 -1
View File
@@ -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';
+8 -1
View File
@@ -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) {
+1 -1
View File
@@ -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));
+8 -2
View File
@@ -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) {
+24 -6
View File
@@ -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<string> $commands
* @return list<string>
*/
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);
+6 -11
View File
@@ -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}",
+6 -11
View File
@@ -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(
+15 -5
View File
@@ -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<string>
*/
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()
+2 -1
View File
@@ -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;
+1 -2
View File
@@ -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');
+8 -16
View File
@@ -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);
}
@@ -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')
@@ -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);
+41
View File
@@ -0,0 +1,41 @@
<?php
namespace App\Traits;
use App\Models\Server;
/**
* Clone jobs move an archive between servers with scp, which reads and writes as the SSH user.
* On the Coolify host, /data/coolify is closed to a non-root SSH user, so the archive goes through
* a directory in /var/tmp (on disk, not tmpfs) that only that user and root can open.
*/
trait StagesCloneArchives
{
protected string $cloneDir = '/data/coolify/clone';
protected function cloneArchiveDirectory(Server $server, string $name): string
{
return ($server->isNonRoot() ? '/var/tmp/coolify-clone' : $this->cloneDir)."/{$name}";
}
/**
* @return list<string>
*/
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}",
];
}
}
-1
View File
@@ -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) {
+18
View File
@@ -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;
}
+3 -1
View File
@@ -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");
});
@@ -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');
});
@@ -0,0 +1,139 @@
<?php
use App\Actions\Database\StartPostgresql;
use App\Jobs\DatabaseBackupJob;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\PrivateKey;
use App\Models\Project;
use App\Models\Server;
use App\Models\Service;
use App\Models\StandaloneDocker;
use App\Models\Team;
use App\Traits\StagesCloneArchives;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Facades\Queue;
uses(RefreshDatabase::class);
/**
* On the Coolify host, install.sh makes /data/coolify `9999:root 0700`, so a non-root SSH user cannot
* enter it. `cd`, redirects, globs, and scp run as that user, so they must not touch /data/coolify.
*/
beforeEach(function () {
Server::flushIdentityMap();
InstanceSettings::forceCreate(['id' => 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<int, string>
*/
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'",
]);
});
@@ -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'],
@@ -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 --'));
@@ -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');
});
+12 -4
View File
@@ -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 () {