mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-06 13:10:37 -04:00
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:
+3
-1
@@ -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.
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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}",
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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 () {
|
||||
|
||||
Reference in New Issue
Block a user