Files
coolify/tests/Feature/NonRootDataDirectoryCommandsTest.php
T
Andras Bacsai a599c9e8b7 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
2026-09-29 20:46:54 +02:00

140 lines
5.7 KiB
PHP

<?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'",
]);
});