fix(storage): resolve relative file paths, keep volume options on clone, use random clone dirs

- Resolve relative LocalFileVolume paths against the resource directory.
- Create cloned volumes with the source driver and options.
- Stage clone archives for non-root users in a mktemp directory (mode 700)
  instead of a predictable path in /var/tmp.
This commit is contained in:
Andras Bacsai
2026-09-30 18:10:10 +02:00
parent 6dc332c203
commit d8bbd8d196
8 changed files with 720 additions and 104 deletions
+8 -17
View File
@@ -63,10 +63,8 @@ class HostPathCloneJob implements ShouldBeEncrypted, ShouldQueue
{
$archiveName = 'hostpath-data.tar.gz';
$token = Str::uuid()->toString();
$sourceCloneDir = $this->cloneArchiveDirectory($this->sourceServer, "hostpath-{$token}");
$targetCloneDir = $this->cloneArchiveDirectory($this->targetServer, "hostpath-{$token}");
$srcDir = escapeshellarg($sourceCloneDir);
$tgtDir = escapeshellarg($targetCloneDir);
$sourceCloneDir = null;
$targetCloneDir = null;
$srcPath = escapeshellarg($this->sourcePath);
$tgtPath = escapeshellarg($this->targetPath);
$tgtParent = escapeshellarg(dirname($this->targetPath));
@@ -76,13 +74,15 @@ class HostPathCloneJob implements ShouldBeEncrypted, ShouldQueue
try {
File::ensureDirectoryExists($localTempDir, 0755);
$sourceCloneDir = $this->createCloneArchiveDirectory($this->sourceServer, "hostpath-{$token}");
$srcDir = escapeshellarg($sourceCloneDir);
instant_remote_process([
...$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($this->prepareCloneArchiveDirectory($this->targetServer, $targetCloneDir), $this->targetServer);
$targetCloneDir = $this->createCloneArchiveDirectory($this->targetServer, "hostpath-{$token}");
$tgtDir = escapeshellarg($targetCloneDir);
instant_scp_from_server(
"{$sourceCloneDir}/{$archiveName}",
@@ -111,17 +111,8 @@ class HostPathCloneJob implements ShouldBeEncrypted, ShouldQueue
\Log::warning('Failed to clean up local host-path clone directory: '.$e->getMessage());
}
try {
instant_remote_process(["rm -rf {$srcDir}"], $this->sourceServer, false);
} catch (\Exception $e) {
\Log::warning('Failed to clean up source host-path clone directory: '.$e->getMessage());
}
try {
instant_remote_process(["rm -rf {$tgtDir}"], $this->targetServer, false);
} catch (\Exception $e) {
\Log::warning('Failed to clean up target host-path clone directory: '.$e->getMessage());
}
$this->removeCloneArchiveDirectory($this->sourceServer, $sourceCloneDir);
$this->removeCloneArchiveDirectory($this->targetServer, $targetCloneDir);
}
}
}
+123 -25
View File
@@ -54,32 +54,53 @@ class VolumeCloneJob implements ShouldBeEncrypted, ShouldQueue
return;
}
$sourceConfiguration = $this->sourceVolumeConfiguration();
$createTargetVolume = $this->createTargetVolumeCommand($sourceConfiguration);
if ($this->isBindVolume($sourceConfiguration)) {
instant_remote_process([$createTargetVolume], $this->sourceServer);
$this->logSkippedBindVolumeCopy($sourceConfiguration);
return;
}
instant_remote_process([
"docker volume create {$tgtVol}",
$createTargetVolume,
"docker run --rm -v {$srcVol}:/source -v {$tgtVol}:/target alpine sh -c 'cp -a /source/. /target/ && chown -R 1000:1000 /target'",
], $this->sourceServer);
$this->rememberTargetVolumeWithoutDriverOptions($sourceConfiguration);
}
protected function cloneRemoteVolume(): void
{
$srcVol = escapeshellarg($this->sourceVolume);
$tgtVol = escapeshellarg($this->targetVolume);
$sourceCloneDir = $this->cloneArchiveDirectory($this->sourceServer, $this->sourceVolume);
$targetCloneDir = $this->cloneArchiveDirectory($this->targetServer, $this->targetVolume);
$srcDir = escapeshellarg($sourceCloneDir);
$tgtDir = escapeshellarg($targetCloneDir);
$sourceConfiguration = $this->sourceVolumeConfiguration();
$createTargetVolume = $this->createTargetVolumeCommand($sourceConfiguration);
if ($this->isBindVolume($sourceConfiguration)) {
instant_remote_process([$createTargetVolume], $this->targetServer);
$this->logSkippedBindVolumeCopy($sourceConfiguration);
return;
}
$sourceCloneDir = null;
$targetCloneDir = null;
$localTempDir = storage_path('app/tmp/volume-clones/'.Str::uuid()->toString());
$localArchive = $localTempDir.'/volume-data.tar.gz';
try {
File::ensureDirectoryExists($localTempDir, 0755);
$sourceCloneDir = $this->createCloneArchiveDirectory($this->sourceServer, $this->sourceVolume);
$srcDir = escapeshellarg($sourceCloneDir);
instant_remote_process([
...$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($this->prepareCloneArchiveDirectory($this->targetServer, $targetCloneDir), $this->targetServer);
$targetCloneDir = $this->createCloneArchiveDirectory($this->targetServer, $this->targetVolume);
$tgtDir = escapeshellarg($targetCloneDir);
// Coolify host is the intermediary: download from source, upload to target.
instant_scp_from_server(
@@ -95,9 +116,10 @@ class VolumeCloneJob implements ShouldBeEncrypted, ShouldQueue
);
instant_remote_process([
"docker volume create {$tgtVol}",
$createTargetVolume,
"docker run --rm -v {$tgtVol}:/target -v {$tgtDir}:/clone alpine sh -c 'cd /target && tar xzf /clone/volume-data.tar.gz && chown -R 1000:1000 /target'",
], $this->targetServer);
$this->rememberTargetVolumeWithoutDriverOptions($sourceConfiguration);
} catch (\Exception $e) {
\Log::error("Failed to clone volume {$this->sourceVolume} to {$this->targetVolume}: ".$e->getMessage());
throw $e;
@@ -108,23 +130,99 @@ class VolumeCloneJob implements ShouldBeEncrypted, ShouldQueue
\Log::warning('Failed to clean up local volume clone directory: '.$e->getMessage());
}
try {
instant_remote_process([
"rm -rf {$srcDir}",
], $this->sourceServer, false);
} catch (\Exception $e) {
\Log::warning('Failed to clean up source server clone directory: '.$e->getMessage());
}
try {
if ($this->targetServer) {
instant_remote_process([
"rm -rf {$tgtDir}",
], $this->targetServer, false);
}
} catch (\Exception $e) {
\Log::warning('Failed to clean up target server clone directory: '.$e->getMessage());
}
$this->removeCloneArchiveDirectory($this->sourceServer, $sourceCloneDir);
$this->removeCloneArchiveDirectory($this->targetServer, $targetCloneDir);
}
}
/**
* The driver and driver options of the source volume. A missing source volume gives a plain
* `local` volume, as Docker creates an empty source volume for the copy.
*
* @return array{driver: string, options: array<string, string>}
*/
protected function sourceVolumeConfiguration(): array
{
$output = instant_remote_process([
'docker volume inspect '.escapeshellarg($this->sourceVolume)." 2>/dev/null || echo '[]'",
], $this->sourceServer);
$inspected = json_decode((string) $output, true);
$volume = is_array($inspected) && is_array($inspected[0] ?? null) ? $inspected[0] : [];
$driver = data_get($volume, 'Driver');
$options = [];
foreach ((array) data_get($volume, 'Options', []) as $key => $value) {
if (is_string($key) && $key !== '' && is_scalar($value)) {
$options[$key] = (string) $value;
}
}
return [
'driver' => is_string($driver) && $driver !== '' ? $driver : 'local',
'options' => $options,
];
}
/**
* Creates the target volume like the source volume. The Compose file of the copy declares the same
* `driver` and `driver_opts`, and Docker Compose does not change the options of an existing volume.
* One `sh -c` line, so the non-root sudo parser only puts sudo in front of it and never changes
* an option value.
*
* @param array{driver: string, options: array<string, string>} $configuration
*/
protected function createTargetVolumeCommand(array $configuration): string
{
$command = 'docker volume create';
if ($configuration['driver'] !== 'local') {
$command .= ' --driver '.escapeshellarg($configuration['driver']);
}
foreach ($configuration['options'] as $key => $value) {
$command .= ' --opt '.escapeshellarg("{$key}={$value}");
}
$command .= ' '.escapeshellarg($this->targetVolume);
return 'sh -c '.escapeshellarg($command);
}
/**
* A `local` volume with `type: none` and `o: bind` is a host folder. Copying the data would write
* into that folder (on the same server, the folder of the source volume itself), so the job only
* creates the volume.
*
* @param array{driver: string, options: array<string, string>} $configuration
*/
protected function isBindVolume(array $configuration): bool
{
return $configuration['driver'] === 'local'
&& ($configuration['options']['type'] ?? null) === 'none'
&& in_array('bind', array_map('trim', explode(',', $configuration['options']['o'] ?? '')), true);
}
/**
* @param array{driver: string, options: array<string, string>} $configuration
*/
protected function logSkippedBindVolumeCopy(array $configuration): void
{
$device = $configuration['options']['device'] ?? 'unknown';
\Log::info("Volume {$this->sourceVolume} is a bind mount of the host folder {$device}. Created {$this->targetVolume} with the same options and did not copy the data.");
}
/**
* A target volume without driver options gets the name-only Compose declaration, like any volume
* that Docker created without the options (see composeRenamedVolumeDeclarationFor()).
*
* @param array{driver: string, options: array<string, string>} $configuration
*/
protected function rememberTargetVolumeWithoutDriverOptions(array $configuration): void
{
if ($configuration['driver'] !== 'local' || $configuration['options'] !== []) {
return;
}
if ($this->persistentVolume->name !== $this->targetVolume || $this->persistentVolume->ignores_compose_driver_options) {
return;
}
$this->persistentVolume->forceFill(['ignores_compose_driver_options' => true])->saveQuietly();
}
}
+30 -28
View File
@@ -10,6 +10,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Database\Eloquent\Relations\MorphTo;
use Illuminate\Support\Collection;
use Illuminate\Support\Stringable;
use Symfony\Component\Yaml\Yaml;
class LocalFileVolume extends BaseModel
@@ -170,11 +171,7 @@ class LocalFileVolume extends BaseModel
$server = $this->resource->destination->server;
}
$commands = collect([]);
$path = data_get_str($this, 'fs_path');
if ($path->startsWith('.')) {
$path = $path->after('.');
$path = $workdir.$path;
}
$path = $this->resolvedFsPath($workdir);
if (! $this->isAdminControlledComposeMount()) {
$path = str(confinePathToBase($workdir, $path->value(), 'storage path'));
@@ -254,11 +251,7 @@ class LocalFileVolume extends BaseModel
$isService = data_get($this->resource, 'service');
$workdir = $isService ? $this->resource->service->workdir() : $this->resource->workdir();
$commands = collect([]);
$path = data_get_str($this, 'fs_path');
if ($path->startsWith('.')) {
$path = $path->after('.');
$path = $workdir.$path;
}
$path = $this->resolvedFsPath($workdir);
if (! $this->isAdminControlledComposeMount()) {
$path = str(confinePathToBase($workdir, $path->value(), 'storage path'));
@@ -302,20 +295,9 @@ class LocalFileVolume extends BaseModel
$commands = collect([]);
$escapedWorkdir = escapeshellarg($workdir);
if ($this->is_directory) {
// Validate fs_path early before any shell interpolation
validateShellSafePath($this->fs_path, 'storage path');
$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");
}
$path = data_get_str($this, 'fs_path');
$path = $this->resolvedFsPath($workdir);
$content = data_get($this, 'content');
$writesContent = $this->writesContentOnServer();
if ($path->startsWith('.')) {
$path = $path->after('.');
$path = $workdir.$path;
}
if ($writesContent) {
$path = str($this->confinedContentPath($path->value(), $server));
@@ -324,9 +306,14 @@ class LocalFileVolume extends BaseModel
$this->assertRemotePathIsConfined($workdir, $path->value(), $server);
}
$pathForParentDirectory = $writesContent ? $path : str($this->fs_path);
if ($pathForParentDirectory->startsWith('.') || $pathForParentDirectory->startsWith('/') || $pathForParentDirectory->startsWith('~')) {
$parent_dir = $pathForParentDirectory->beforeLast('/');
if ($this->is_directory) {
validateShellSafePath($path, 'storage path');
$commands->push('mkdir -p '.escapeshellarg($path).' > /dev/null 2>&1 || true');
$commands->push("mkdir -p {$escapedWorkdir} > /dev/null 2>&1 || true");
}
if ($path->startsWith('/') || $path->startsWith('~')) {
$parent_dir = $path->beforeLast('/');
if ($parent_dir != '') {
$escapedParentDir = escapeshellarg($parent_dir);
$commands->push("mkdir -p {$escapedParentDir} > /dev/null 2>&1 || true");
@@ -432,12 +419,27 @@ class LocalFileVolume extends BaseModel
*/
public function contentPathOnServer(): string
{
$path = (string) $this->fs_path;
return $this->localConfinedContentPath($this->resolvedFsPath($this->ownerResource()->workdir())->value())[1];
}
/**
* The host path of this mount. Like a relative bind source in Compose, a relative path is inside
* the resource directory. Remote commands run in the home directory of the SSH user, so they
* must never get the relative path. A `~` path stays in the home directory.
*/
public function resolvedFsPath(string $workdir): Stringable
{
$path = trim((string) $this->fs_path);
if (str_starts_with($path, '/') || str_starts_with($path, '~')) {
return str($path);
}
if (str_starts_with($path, '.')) {
$path = $this->ownerResource()->workdir().substr($path, 1);
$path = substr($path, 1);
}
return $this->localConfinedContentPath($path)[1];
$path = ltrim($path, '/');
return str(rtrim($workdir, '/').($path === '' ? '' : '/'.$path));
}
/**
+50 -19
View File
@@ -3,39 +3,70 @@
namespace App\Traits;
use App\Models\Server;
use RuntimeException;
use Throwable;
/**
* 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.
* a directory in /var/tmp (on disk, not tmpfs) that only that user and root can open. /var/tmp is
* writable by every local user, so the directory gets a random name from mktemp: a fixed name could
* be created beforehand, or be a symlink that the sudo chown and chmod would follow.
*/
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>
* Creates the directory for the clone archive on the server and returns its path.
*/
protected function prepareCloneArchiveDirectory(Server $server, string $directory): array
protected function createCloneArchiveDirectory(Server $server, string $name): string
{
$escapedDirectory = escapeshellarg($directory);
if ($server->isNonRoot()) {
return [
if (! $server->isNonRoot()) {
$directory = "{$this->cloneDir}/{$name}";
$escapedDirectory = escapeshellarg($directory);
instant_remote_process([
"mkdir -p {$escapedDirectory}",
'chown '.escapeshellarg($server->user)." {$escapedDirectory}",
"chmod 700 {$escapedDirectory}",
];
"chmod 777 {$escapedDirectory}",
], $server);
return $directory;
}
return [
"mkdir -p {$escapedDirectory}",
"chmod 777 {$escapedDirectory}",
];
// The sudo parser runs mktemp as root, so the new directory belongs to root with mode 700.
// mktemp creates a new directory and never reuses an existing path or symlink. BusyBox
// (Alpine) randomizes only the last 6 X, which still matches the pattern below.
$directory = trim((string) instant_remote_process(['mktemp -d /var/tmp/coolify-clone.XXXXXXXXXX'], $server));
if (preg_match('#\A/var/tmp/coolify-clone\.[A-Za-z0-9]{10}\z#', $directory) !== 1) {
throw new RuntimeException('Could not create a temporary clone directory on server '.$server->name.'.');
}
$escapedDirectory = escapeshellarg($directory);
try {
// scp reads and writes the archive as the SSH user.
instant_remote_process([
'chown '.escapeshellarg($server->user)." {$escapedDirectory}",
"chmod 700 {$escapedDirectory}",
], $server);
} catch (Throwable $e) {
$this->removeCloneArchiveDirectory($server, $directory);
throw $e;
}
return $directory;
}
protected function removeCloneArchiveDirectory(?Server $server, ?string $directory): void
{
if (! $server || $directory === null) {
return;
}
try {
instant_remote_process(['rm -rf '.escapeshellarg($directory)], $server, false);
} catch (Throwable $e) {
\Log::warning("Failed to clean up clone directory {$directory} on server {$server->name}: ".$e->getMessage());
}
}
}
+155
View File
@@ -0,0 +1,155 @@
<?php
use App\Jobs\HostPathCloneJob;
use App\Jobs\VolumeCloneJob;
use App\Models\Application;
use App\Models\InstanceSettings;
use App\Models\LocalPersistentVolume;
use App\Models\PrivateKey;
use App\Models\Server;
use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Str;
uses(RefreshDatabase::class);
/**
* A non-root SSH user stages clone archives in /var/tmp, which every local user can write. The jobs
* must use a random directory from mktemp (never a name derived from the volume or path), give it to
* the SSH user with mode 700, use it in every step, and remove it afterwards.
*/
beforeEach(function () {
Server::flushIdentityMap();
InstanceSettings::forceCreate(['id' => 0]);
config(['app.maintenance.store' => 'array', 'constants.ssh.mux_enabled' => false]);
$team = Team::factory()->create();
$privateKey = PrivateKey::factory()->create(['team_id' => $team->id]);
$this->makeServer = fn (string $user) => Server::factory()->create([
'team_id' => $team->id,
'user' => $user,
'private_key_id' => $privateKey->id,
]);
$this->volume = LocalPersistentVolume::forceCreate([
'name' => 'clone-target',
'mount_path' => '/data',
'resource_type' => Application::class,
'resource_id' => 1,
]);
$this->mktempDirectories = [];
});
/**
* Fakes the servers: mktemp prints a new random directory, `docker volume inspect` prints nothing,
* and scp fails when $failScp is set.
*/
function fakeCloneArchiveServers(object $test, bool $failScp = false): void
{
Process::fake(function ($process) use ($test, $failScp) {
if (str_contains($process->command, 'mktemp -d')) {
$directory = '/var/tmp/coolify-clone.'.Str::random(10);
$test->mktempDirectories[] = $directory;
return Process::result(output: $directory);
}
if ($failScp && str_contains($process->command, ' scp ')) {
return Process::result(output: '', errorOutput: 'scp failed', exitCode: 1);
}
return Process::result(output: str_contains($process->command, 'docker volume inspect') ? '[]' : '');
});
}
/**
* @return list<string>
*/
function cloneArchiveCommands(): array
{
$commands = [];
Process::assertRan(function ($process) use (&$commands) {
$commands[] = $process->command;
return true;
});
return $commands;
}
dataset('clone jobs', [
'volume clone' => [fn (Server $source, Server $target) => new VolumeCloneJob('clone-source', 'clone-target', $source, $target, test()->volume), 'volume-data.tar.gz'],
'host path clone' => [fn (Server $source, Server $target) => new HostPathCloneJob('/srv/app-data', '/srv/app-copy', $source, $target), 'hostpath-data.tar.gz'],
]);
it('stages a non-root clone in random mktemp directories with mode 700 and removes them', function (Closure $makeJob, string $archive) {
$source = ($this->makeServer)('cooluser');
$target = ($this->makeServer)('cooluser');
fakeCloneArchiveServers($this);
$makeJob($source, $target)->handle();
$commands = implode("\n", cloneArchiveCommands());
[$sourceDirectory, $targetDirectory] = $this->mktempDirectories;
expect($this->mktempDirectories)->toHaveCount(2)
->and($sourceDirectory)->not->toBe($targetDirectory)
->and(substr_count($commands, 'sudo mktemp -d /var/tmp/coolify-clone.XXXXXXXXXX'))->toBe(2)
->and($commands)->not->toContain('/var/tmp/coolify-clone/')
->and($commands)->not->toContain('/data/coolify/clone');
foreach ([$sourceDirectory, $targetDirectory] as $directory) {
$escaped = escapeshellarg($directory);
expect($commands)->toContain("sudo chown 'cooluser' {$escaped}")
->and($commands)->toContain("sudo chmod 700 {$escaped}")
->and($commands)->toContain("{$directory}/{$archive}")
->and($commands)->toContain("sudo rm -rf {$escaped}");
}
// The sudo parser wraps each docker run line in `sudo bash -c '...'`.
expect($commands)->toContain("-v '\\''{$sourceDirectory}'\\'':/clone")
->and($commands)->toContain("-v '\\''{$targetDirectory}'\\'':/clone");
})->with('clone jobs');
it('removes the random non-root clone directories when the copy fails', function (Closure $makeJob) {
$source = ($this->makeServer)('cooluser');
$target = ($this->makeServer)('cooluser');
fakeCloneArchiveServers($this, failScp: true);
expect(fn () => $makeJob($source, $target)->handle())->toThrow(RuntimeException::class);
$commands = implode("\n", cloneArchiveCommands());
expect($this->mktempDirectories)->toHaveCount(2);
foreach ($this->mktempDirectories as $directory) {
expect($commands)->toContain('sudo rm -rf '.escapeshellarg($directory));
}
expect($commands)->not->toContain('tar xzf');
})->with('clone jobs');
it('keeps the /data/coolify/clone directory for a root SSH user and removes it', function (Closure $makeJob, string $archive, bool $fail) {
$source = ($this->makeServer)('root');
$target = ($this->makeServer)('root');
fakeCloneArchiveServers($this, failScp: $fail);
$run = fn () => $makeJob($source, $target)->handle();
if ($fail) {
expect($run)->toThrow(RuntimeException::class);
} else {
$run();
}
$commands = cloneArchiveCommands();
$all = implode("\n", $commands);
$directories = [];
foreach ($commands as $command) {
if (preg_match("#mkdir -p '(/data/coolify/clone/[^']+)'#", $command, $matches)) {
$directories[] = $matches[1];
}
}
expect($this->mktempDirectories)->toBe([])
->and($all)->not->toContain('/var/tmp')
->and($directories)->toHaveCount(2);
foreach ($directories as $directory) {
expect($all)->toContain('chmod 777 '.escapeshellarg($directory))
->and($all)->toContain('rm -rf '.escapeshellarg($directory));
}
})->with('clone jobs')->with(['success' => false, 'failure' => true]);
@@ -0,0 +1,123 @@
<?php
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\LocalFileVolume;
use App\Models\PrivateKey;
use App\Models\Project;
use App\Models\Server;
use App\Models\Service;
use App\Models\ServiceApplication;
use App\Models\StandaloneDocker;
use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\Process;
uses(RefreshDatabase::class);
beforeEach(function () {
Bus::fake();
InstanceSettings::forceCreate(['id' => 0]);
config([
'app.maintenance.store' => 'array',
'constants.ssh.mux_enabled' => false,
]);
$team = Team::factory()->create();
$server = Server::factory()->create([
'team_id' => $team->id,
'private_key_id' => PrivateKey::factory()->create(['team_id' => $team->id])->id,
]);
$destination = StandaloneDocker::query()->where('server_id', $server->id)->firstOrFail();
$project = Project::factory()->create(['team_id' => $team->id]);
$environment = Environment::factory()->create(['project_id' => $project->id]);
$this->service = Service::factory()->create([
'environment_id' => $environment->id,
'server_id' => $server->id,
'destination_id' => $destination->id,
'destination_type' => $destination->getMorphClass(),
'docker_compose_raw' => "services:\n app:\n image: nginx:alpine\n",
]);
$this->serviceApplication = ServiceApplication::create(['name' => 'app', 'service_id' => $this->service->id]);
Process::fake(fn ($process) => Process::result(output: match (true) {
str_contains($process->command, 'readlink -f') => 'OK',
str_contains($process->command, 'test -') => 'NOK',
default => '',
}));
});
function relativePathFileVolume(string $fsPath, bool $isDirectory, ?string $content = null): LocalFileVolume
{
return LocalFileVolume::create([
'fs_path' => $fsPath,
'mount_path' => '/data',
'content' => $content,
'is_directory' => $isDirectory,
'resource_id' => test()->serviceApplication->id,
'resource_type' => test()->serviceApplication->getMorphClass(),
])->fresh();
}
/**
* Remote commands run from the SSH user's home directory, so a relative path in a command
* would create files and directories in `~` instead of the resource directory.
*/
function assertNoRelativeRemotePath(): void
{
Process::assertDidntRun(fn ($process) => str_contains($process->command, "'./"));
}
test('a relative directory path is created inside the resource directory, not in the home directory', function () {
$workdir = $this->service->workdir();
relativePathFileVolume('./data/sub', isDirectory: true)->saveStorageOnServer();
Process::assertRan(fn ($process) => str_contains($process->command, "mkdir -p '{$workdir}/data/sub'"));
Process::assertRan(fn ($process) => str_contains($process->command, "mkdir -p '{$workdir}/data'"));
assertNoRelativeRemotePath();
});
test('a relative file path without content gets its parent directory inside the resource directory', function () {
$workdir = $this->service->workdir();
relativePathFileVolume('./config/app.conf', isDirectory: false)->saveStorageOnServer();
Process::assertRan(fn ($process) => str_contains($process->command, "mkdir -p '{$workdir}/config'"));
Process::assertRan(fn ($process) => str_contains($process->command, "touch '{$workdir}/config/app.conf'"));
assertNoRelativeRemotePath();
});
test('a relative file path with content is written inside the resource directory', function () {
$workdir = $this->service->workdir();
relativePathFileVolume('./config/app.conf', isDirectory: false, content: 'listen 8080;')->saveStorageOnServer();
Process::assertRan(fn ($process) => str_contains($process->command, "| base64 -d | tee '{$workdir}/config/app.conf' > /dev/null"));
assertNoRelativeRemotePath();
});
test('an absolute directory path keeps working unchanged', function () {
$workdir = $this->service->workdir();
relativePathFileVolume($workdir.'/data', isDirectory: true)->saveStorageOnServer();
Process::assertRan(fn ($process) => str_contains($process->command, "mkdir -p '{$workdir}/data'"));
assertNoRelativeRemotePath();
});
test('deleting a relative path removes it from the resource directory', function () {
Process::fake(fn ($process) => Process::result(output: match (true) {
str_contains($process->command, 'readlink -f') => 'OK',
str_contains($process->command, 'test -f') => 'OK',
default => 'NOK',
}));
$workdir = $this->service->workdir();
relativePathFileVolume('./config/app.conf', isDirectory: false)->deleteStorageOnServer();
Process::assertRan(fn ($process) => str_contains($process->command, "rm -rf '{$workdir}/config/app.conf'"));
assertNoRelativeRemotePath();
});
test('the content path on the server resolves a relative path against the resource directory', function () {
expect(relativePathFileVolume('./config/app.conf', isDirectory: false, content: 'x')->contentPathOnServer())
->toBe($this->service->workdir().'/config/app.conf');
});
@@ -113,27 +113,57 @@ it('writes a database dump and its redirect in one root shell', function () {
]);
});
it('stages clone archives outside /data/coolify only for a non-root SSH user', function () {
it('stages clone archives in a random mktemp directory only for a non-root SSH user', function () {
$stager = new class
{
use StagesCloneArchives {
cloneArchiveDirectory as public;
prepareCloneArchiveDirectory as public;
createCloneArchiveDirectory as public;
}
};
$rootServer = Server::factory()->create(['team_id' => $this->server->team_id, 'user' => 'root', 'private_key_id' => $this->server->private_key_id]);
Process::fake(fn ($process) => Process::result(
output: str_contains($process->command, 'mktemp -d') ? '/var/tmp/coolify-clone.Ab3dE6gH9k' : '',
));
$nonRootDirectory = $stager->cloneArchiveDirectory($this->server, 'volume-a');
$nonRootDirectory = $stager->createCloneArchiveDirectory($this->server, 'volume-a');
$rootDirectory = $stager->createCloneArchiveDirectory($rootServer, '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'",
]);
$commands = [];
Process::assertRan(function ($process) use (&$commands) {
$commands[] = $process->command;
return true;
});
$commands = implode("\n", $commands);
expect($nonRootDirectory)->toBe('/var/tmp/coolify-clone.Ab3dE6gH9k')
->and($commands)->toContain('sudo mktemp -d /var/tmp/coolify-clone.XXXXXXXXXX')
->and($commands)->toContain("sudo chown 'cooluser' '/var/tmp/coolify-clone.Ab3dE6gH9k'")
->and($commands)->toContain("sudo chmod 700 '/var/tmp/coolify-clone.Ab3dE6gH9k'")
->and($commands)->not->toContain('/var/tmp/coolify-clone/')
->and($rootDirectory)->toBe('/data/coolify/clone/volume-a')
->and($commands)->toContain("mkdir -p '/data/coolify/clone/volume-a'")
->and($commands)->toContain("chmod 777 '/data/coolify/clone/volume-a'");
});
it('rejects a clone archive directory that mktemp did not create', function (string $output) {
$stager = new class
{
use StagesCloneArchives {
createCloneArchiveDirectory as public;
}
};
Process::fake(fn ($process) => Process::result(
output: str_contains($process->command, 'mktemp -d') ? $output : '',
));
expect(fn () => $stager->createCloneArchiveDirectory($this->server, 'volume-a'))->toThrow(RuntimeException::class);
Process::assertNotRan(fn ($process) => str_contains($process->command, 'chown') || str_contains($process->command, 'chmod'));
})->with([
'empty' => [''],
'predictable path' => ['/var/tmp/coolify-clone/volume-a'],
'traversal' => ['/var/tmp/coolify-clone.Ab3dE6gH9k/../../etc'],
'other directory' => ['/tmp/coolify-clone.Ab3dE6gH9k'],
'extra line' => ["/var/tmp/coolify-clone.Ab3dE6gH9k\n/etc"],
'shell characters' => ['/var/tmp/coolify-clone.Ab3d$(id)k'],
]);
+186
View File
@@ -0,0 +1,186 @@
<?php
use App\Jobs\VolumeCloneJob;
use App\Models\Application;
use App\Models\InstanceSettings;
use App\Models\LocalPersistentVolume;
use App\Models\PrivateKey;
use App\Models\Server;
use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Process;
uses(RefreshDatabase::class);
beforeEach(function () {
Server::flushIdentityMap();
InstanceSettings::forceCreate(['id' => 0]);
config(['app.maintenance.store' => 'array', 'constants.ssh.mux_enabled' => false]);
$this->team = Team::factory()->create();
$this->privateKey = PrivateKey::factory()->create(['team_id' => $this->team->id]);
$this->server = Server::factory()->create([
'team_id' => $this->team->id,
'user' => 'root',
'private_key_id' => $this->privateKey->id,
]);
$this->volume = LocalPersistentVolume::forceCreate([
'name' => 'clone-target',
'mount_path' => '/data',
'resource_type' => Application::class,
'resource_id' => 1,
'ignores_compose_driver_options' => false,
]);
});
/**
* Fakes the server: `docker volume inspect` prints the given source volume, everything else succeeds.
*
* @param array<string, mixed>|null $sourceVolume
*/
function fakeVolumeCloneServer(?array $sourceVolume): void
{
Process::fake(fn ($process) => Process::result(
output: str_contains($process->command, 'docker volume inspect')
? json_encode($sourceVolume === null ? [] : [$sourceVolume])
: '',
));
}
function volumeCloneCommands(): string
{
$commands = [];
Process::assertRan(function ($process) use (&$commands) {
$commands[] = $process->command;
return true;
});
return implode("\n", $commands);
}
test('a local clone creates the target volume with the driver options of the source volume', function () {
fakeVolumeCloneServer([
'Name' => 'clone-source',
'Driver' => 'local',
'Options' => ['type' => 'nfs', 'o' => 'addr=10.0.0.5,rw,nfsvers=4', 'device' => ':/exports/data'],
]);
(new VolumeCloneJob('clone-source', 'clone-target', $this->server, $this->server, $this->volume))->handle();
$commands = volumeCloneCommands();
expect($commands)->toContain('docker volume inspect '.escapeshellarg('clone-source'))
->and($commands)->toContain(escapeshellarg("docker volume create --opt 'type=nfs' --opt 'o=addr=10.0.0.5,rw,nfsvers=4' --opt 'device=:/exports/data' 'clone-target'"))
->and($commands)->toContain('cp -a /source/. /target/')
->and($this->volume->fresh()->ignores_compose_driver_options)->toBeFalse();
});
test('a local clone keeps a driver that is not local', function () {
fakeVolumeCloneServer(['Name' => 'clone-source', 'Driver' => 'rclone', 'Options' => ['remote' => 'minio:bucket']]);
(new VolumeCloneJob('clone-source', 'clone-target', $this->server, $this->server, $this->volume))->handle();
expect(volumeCloneCommands())->toContain(escapeshellarg("docker volume create --driver 'rclone' --opt 'remote=minio:bucket' 'clone-target'"));
});
test('driver options are escaped for the shell', function () {
fakeVolumeCloneServer([
'Name' => 'clone-source',
'Driver' => 'local',
'Options' => ['type' => 'cifs', 'o' => "username=a,password=x'; touch /tmp/pwned; echo '\$(id)&&b"],
]);
(new VolumeCloneJob('clone-source', 'clone-target', $this->server, $this->server, $this->volume))->handle();
$inner = 'docker volume create --opt '.escapeshellarg('type=cifs').' --opt '
.escapeshellarg("o=username=a,password=x'; touch /tmp/pwned; echo '\$(id)&&b").' '.escapeshellarg('clone-target');
expect(volumeCloneCommands())->toContain('sh -c '.escapeshellarg($inner));
});
test('a source volume without driver options gives a plain target volume and a name-only declaration', function () {
fakeVolumeCloneServer(['Name' => 'clone-source', 'Driver' => 'local', 'Options' => null]);
(new VolumeCloneJob('clone-source', 'clone-target', $this->server, $this->server, $this->volume))->handle();
expect(volumeCloneCommands())->toContain(escapeshellarg("docker volume create 'clone-target'"))
->and($this->volume->fresh()->ignores_compose_driver_options)->toBeTrue();
});
test('a missing source volume still creates an empty target volume', function () {
fakeVolumeCloneServer(null);
(new VolumeCloneJob('clone-source', 'clone-target', $this->server, $this->server, $this->volume))->handle();
expect(volumeCloneCommands())->toContain(escapeshellarg("docker volume create 'clone-target'"));
});
test('a bind-type source volume is created with its options but its host folder is not copied', function () {
fakeVolumeCloneServer([
'Name' => 'clone-source',
'Driver' => 'local',
'Options' => ['type' => 'none', 'o' => 'bind', 'device' => '/srv/app-data'],
]);
(new VolumeCloneJob('clone-source', 'clone-target', $this->server, $this->server, $this->volume))->handle();
$commands = volumeCloneCommands();
expect($commands)->toContain(escapeshellarg("docker volume create --opt 'type=none' --opt 'o=bind' --opt 'device=/srv/app-data' 'clone-target'"))
->and($commands)->not->toContain('cp -a')
->and($commands)->not->toContain('chown -R');
});
test('a remote clone creates the target volume with the source driver options on the target server', function () {
$targetServer = Server::factory()->create([
'team_id' => $this->team->id,
'user' => 'root',
'private_key_id' => $this->privateKey->id,
]);
fakeVolumeCloneServer([
'Name' => 'clone-source',
'Driver' => 'local',
'Options' => ['type' => 'nfs', 'o' => 'addr=10.0.0.5', 'device' => ':/exports/data'],
]);
(new VolumeCloneJob('clone-source', 'clone-target', $this->server, $targetServer, $this->volume))->handle();
$commands = volumeCloneCommands();
expect($commands)->toContain(escapeshellarg("docker volume create --opt 'type=nfs' --opt 'o=addr=10.0.0.5' --opt 'device=:/exports/data' 'clone-target'"))
->and($commands)->toContain('tar xzf /clone/volume-data.tar.gz');
});
test('a remote clone of a bind-type volume does not copy the host folder', function () {
$targetServer = Server::factory()->create([
'team_id' => $this->team->id,
'user' => 'root',
'private_key_id' => $this->privateKey->id,
]);
fakeVolumeCloneServer([
'Name' => 'clone-source',
'Driver' => 'local',
'Options' => ['type' => 'none', 'o' => 'bind,rw', 'device' => '/srv/app-data'],
]);
(new VolumeCloneJob('clone-source', 'clone-target', $this->server, $targetServer, $this->volume))->handle();
$commands = volumeCloneCommands();
expect($commands)->toContain(escapeshellarg("docker volume create --opt 'type=none' --opt 'o=bind,rw' --opt 'device=/srv/app-data' 'clone-target'"))
->and($commands)->not->toContain('tar czf')
->and($commands)->not->toContain('tar xzf');
});
test('a non-root server runs the volume create as one sudo shell script', function () {
$this->server->update(['user' => 'cooluser']);
Server::flushIdentityMap();
$server = $this->server->fresh();
fakeVolumeCloneServer([
'Name' => 'clone-source',
'Driver' => 'local',
'Options' => ['type' => 'cifs', 'o' => 'password=a&&b|c $(x)'],
]);
(new VolumeCloneJob('clone-source', 'clone-target', $server, $server, $this->volume))->handle();
$inner = "docker volume create --opt 'type=cifs' --opt 'o=password=a&&b|c \$(x)' 'clone-target'";
expect(volumeCloneCommands())->toContain('sudo sh -c '.escapeshellarg($inner))
->and(volumeCloneCommands())->toContain('sudo docker volume inspect');
});