mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-06 13:10:37 -04:00
fix(proxy): run proxy commands without cd for non-root SSH users
The proxy directory is 9999:root 0700 on the Coolify host, so a non-root SSH user cannot cd into it, open redirects in it, or expand globs there (#4255). - Start/restart proxy with absolute `docker compose -f` and `docker stack deploy -c` paths instead of `cd $proxy_path` - Write the Caddyfile via `tee` instead of a shell redirect - Check for and prune config backups with `find` instead of globs - Read the config size with `stat -c %s` instead of `wc -c <` - Add feature tests for non-root proxy commands and record the lesson
This commit is contained in:
@@ -87,3 +87,7 @@
|
||||
- Container ownership labels are `coolify.applicationUuid`, `coolify.serviceUuid`, `coolify.service.subUuid`, and `coolify.databaseUuid`. Numeric ids change when a server moves to another instance.
|
||||
- Use `resolveContainerOwner()`, `resolveServiceContainerOwner()`, and `containersOwnedBy()` / `dockerPsByOwnerCommands()` from `bootstrap/helpers/docker.php`. Order: UUID label, then `com.docker.compose.project` (the UUID only since July 2024), then the local numeric id label of older containers.
|
||||
- Read owners from the flat label list. `Arr::undot()` breaks `com.docker.compose.project` because `com.docker.compose.project.config_files` is nested under it.
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -113,7 +113,7 @@ class GetProxyConfiguration
|
||||
$readLimit = self::MAX_CONFIGURATION_SIZE_BYTES + 1;
|
||||
$result = instant_remote_process([
|
||||
"mkdir -p $proxy_path",
|
||||
"if [ ! -f {$configurationPath} ]; then exit 0; elif [ \"$(wc -c < {$configurationPath})\" -gt ".self::MAX_CONFIGURATION_SIZE_BYTES." ]; then echo '__COOLIFY_PROXY_CONFIG_TOO_LARGE__'; else head -c {$readLimit} {$configurationPath}; fi",
|
||||
"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",
|
||||
], $server, false);
|
||||
|
||||
if ($result === '__COOLIFY_PROXY_CONFIG_TOO_LARGE__' || strlen($result ?? '') > self::MAX_CONFIGURATION_SIZE_BYTES) {
|
||||
|
||||
@@ -46,10 +46,14 @@ class SaveProxyConfiguration
|
||||
$timestamp = now()->format('Y-m-d_H-i-s');
|
||||
$backup_file = "docker-compose.{$timestamp}.{$short_hash}.yml";
|
||||
$commands[] = "mkdir -p $backup_path";
|
||||
// Skip backup if a file with the same hash already exists (identical content)
|
||||
$commands[] = "ls $backup_path/docker-compose.*.$short_hash.yml 1>/dev/null 2>&1 || cp -f $proxy_path/docker-compose.yml $backup_path/$backup_file 2>/dev/null || true";
|
||||
// Prune old backups, keep only the most recent ones
|
||||
$commands[] = 'cd '.$backup_path.' && ls -1t docker-compose.*.yml 2>/dev/null | tail -n +'.((int) self::MAX_BACKUPS + 1).' | xargs rm -f 2>/dev/null || true';
|
||||
// Skip backup if a file with the same hash already exists (identical content).
|
||||
// find instead of a shell glob, and no `|` with `||`: the non-root sudo parser would need bash -c,
|
||||
// and the SSH user may not be able to read the directory (#4255).
|
||||
$commands[] = "if [ -z \"$(find $backup_path -maxdepth 1 -name 'docker-compose.*.$short_hash.yml')\" ]; then";
|
||||
$commands[] = " cp -f $proxy_path/docker-compose.yml $backup_path/$backup_file 2>/dev/null || true";
|
||||
$commands[] = 'fi';
|
||||
// Prune old backups, keep only the most recent ones (the timestamp in the name sorts by age)
|
||||
$commands[] = "find $backup_path -maxdepth 1 -name 'docker-compose.*.yml' | sort -r | tail -n +".((int) self::MAX_BACKUPS + 1).' | xargs -r rm -f';
|
||||
}
|
||||
|
||||
$commands[] = "echo '$docker_compose_yml_base64' | base64 -d | tee $proxy_path/docker-compose.yml > /dev/null";
|
||||
|
||||
@@ -35,27 +35,27 @@ class StartProxy
|
||||
|
||||
$commands = collect([]);
|
||||
$proxy_path = $server->proxyPath();
|
||||
// Absolute paths: a non-root SSH user may not be able to enter the proxy directory (#4255).
|
||||
$compose_file = rtrim($proxy_path, '/').'/docker-compose.yml';
|
||||
SaveProxyConfiguration::run($server, $configuration);
|
||||
$server->markProxyConfigurationApplied($configuration);
|
||||
|
||||
if ($server->isSwarmManager()) {
|
||||
$commands = $commands->merge([
|
||||
"mkdir -p $proxy_path/dynamic",
|
||||
"cd $proxy_path",
|
||||
"echo 'Creating required Docker Compose file.'",
|
||||
"echo 'Starting coolify-proxy.'",
|
||||
'docker stack deploy --detach=true -c docker-compose.yml coolify-proxy',
|
||||
"docker stack deploy --detach=true -c $compose_file coolify-proxy",
|
||||
"echo 'Successfully started coolify-proxy.'",
|
||||
]);
|
||||
} else {
|
||||
$caddyfile = 'import /dynamic/*.caddy';
|
||||
$commands = $commands->merge([
|
||||
"mkdir -p $proxy_path/dynamic",
|
||||
"cd $proxy_path",
|
||||
"echo '$caddyfile' > $proxy_path/dynamic/Caddyfile",
|
||||
"echo '$caddyfile' | tee $proxy_path/dynamic/Caddyfile > /dev/null",
|
||||
"echo 'Creating required Docker Compose file.'",
|
||||
"echo 'Pulling docker image.'",
|
||||
'docker compose pull',
|
||||
"docker compose -f $compose_file pull",
|
||||
'if docker ps -a --format "{{.Names}}" | grep -q "^coolify-proxy$"; then',
|
||||
" echo 'Stopping and removing existing coolify-proxy.'",
|
||||
' docker stop coolify-proxy 2>/dev/null || true',
|
||||
@@ -75,7 +75,7 @@ class StartProxy
|
||||
$commands = $commands->merge(ensureProxyNetworksExist($server));
|
||||
$commands = $commands->merge([
|
||||
"echo 'Starting coolify-proxy.'",
|
||||
'docker compose up -d --wait --remove-orphans',
|
||||
"docker compose -f $compose_file up -d --wait --remove-orphans",
|
||||
"echo 'Successfully started coolify-proxy.'",
|
||||
]);
|
||||
$commands = $commands->merge(connectProxyToNetworks($server));
|
||||
|
||||
@@ -88,6 +88,8 @@ class RestartProxyJob implements ShouldBeEncrypted, ShouldQueue
|
||||
{
|
||||
$containerName = $this->server->isSwarm() ? 'coolify-proxy_traefik' : 'coolify-proxy';
|
||||
$proxy_path = $this->server->proxyPath();
|
||||
// Absolute paths: a non-root SSH user may not be able to enter the proxy directory (#4255).
|
||||
$compose_file = rtrim($proxy_path, '/').'/docker-compose.yml';
|
||||
$stopTimeout = 30;
|
||||
|
||||
SaveProxyConfiguration::run($this->server, $configuration);
|
||||
@@ -125,10 +127,9 @@ class RestartProxyJob implements ShouldBeEncrypted, ShouldQueue
|
||||
$commands = $commands->merge([
|
||||
"echo 'Starting proxy (Swarm mode)...'",
|
||||
"mkdir -p $proxy_path/dynamic",
|
||||
"cd $proxy_path",
|
||||
"echo 'Creating required Docker Compose file.'",
|
||||
"echo 'Starting coolify-proxy.'",
|
||||
'docker stack deploy --detach=true -c docker-compose.yml coolify-proxy',
|
||||
"docker stack deploy --detach=true -c $compose_file coolify-proxy",
|
||||
"echo 'Successfully started coolify-proxy.'",
|
||||
]);
|
||||
} else {
|
||||
@@ -136,17 +137,16 @@ class RestartProxyJob implements ShouldBeEncrypted, ShouldQueue
|
||||
$commands = $commands->merge([
|
||||
"echo 'Starting proxy...'",
|
||||
"mkdir -p $proxy_path/dynamic",
|
||||
"cd $proxy_path",
|
||||
"echo '$caddyfile' > $proxy_path/dynamic/Caddyfile",
|
||||
"echo '$caddyfile' | tee $proxy_path/dynamic/Caddyfile > /dev/null",
|
||||
"echo 'Creating required Docker Compose file.'",
|
||||
"echo 'Pulling docker image.'",
|
||||
'docker compose pull',
|
||||
"docker compose -f $compose_file pull",
|
||||
]);
|
||||
// Ensure required networks exist BEFORE docker compose up
|
||||
$commands = $commands->merge(ensureProxyNetworksExist($this->server));
|
||||
$commands = $commands->merge([
|
||||
"echo 'Starting coolify-proxy.'",
|
||||
'docker compose up -d --wait --remove-orphans',
|
||||
"docker compose -f $compose_file up -d --wait --remove-orphans",
|
||||
"echo 'Successfully started coolify-proxy.'",
|
||||
]);
|
||||
$commands = $commands->merge(connectProxyToNetworks($this->server));
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
<?php
|
||||
|
||||
use App\Actions\Proxy\SaveProxyConfiguration;
|
||||
use App\Actions\Proxy\StartProxy;
|
||||
use App\Enums\ProxyTypes;
|
||||
use App\Events\ProxyStatusChanged;
|
||||
use App\Events\ProxyStatusChangedUI;
|
||||
use App\Jobs\RestartProxyJob;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\Facades\Process;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
/**
|
||||
* On the Coolify host, install.sh makes /data/coolify and /data/coolify/proxy `9999:root 0700`, so a
|
||||
* non-root SSH user cannot enter them. Proxy commands must reach proxy files only through sudo (#4255).
|
||||
*/
|
||||
beforeEach(function () {
|
||||
Server::flushIdentityMap();
|
||||
Event::fake([ProxyStatusChanged::class, ProxyStatusChangedUI::class]);
|
||||
InstanceSettings::forceCreate(['id' => 0]);
|
||||
config(['constants.ssh.mux_enabled' => false]);
|
||||
Process::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]);
|
||||
});
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
function nonRootProxyScriptLines(): array
|
||||
{
|
||||
$lines = [];
|
||||
Process::assertRan(function ($process) use (&$lines) {
|
||||
array_push($lines, ...explode("\n", $process->command));
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
return $lines;
|
||||
}
|
||||
|
||||
function expectNoUnprivilegedProxyFileAccess(array $lines): void
|
||||
{
|
||||
foreach ($lines as $line) {
|
||||
expect(trim($line))->not->toStartWith('cd ');
|
||||
// A redirect or a glob outside `sudo bash -c` runs as the SSH user.
|
||||
expect($line)->not->toMatch('/<\s*\S*\/data\/coolify|>\s*\/data\/coolify/');
|
||||
if (! str_starts_with(trim($line), 'sudo bash -c')) {
|
||||
expect($line)->not->toMatch('/\/data\/coolify\S*\*/');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function useNonRootProxy(Server $server, string $proxyType): void
|
||||
{
|
||||
$server->proxy->type = $proxyType;
|
||||
$server->proxy->status = 'exited';
|
||||
$server->save();
|
||||
$server->refresh();
|
||||
}
|
||||
|
||||
it('starts the proxy without entering proxy directories as the SSH user', function (string $proxyType, string $proxyPath) {
|
||||
useNonRootProxy($this->server, $proxyType);
|
||||
|
||||
StartProxy::run($this->server, async: false, force: true);
|
||||
|
||||
$lines = nonRootProxyScriptLines();
|
||||
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");
|
||||
})->with([
|
||||
'traefik' => [ProxyTypes::TRAEFIK->value, '/data/coolify/proxy'],
|
||||
'caddy' => [ProxyTypes::CADDY->value, '/data/coolify/proxy/caddy'],
|
||||
]);
|
||||
|
||||
it('writes the Caddyfile through sudo', function () {
|
||||
useNonRootProxy($this->server, ProxyTypes::CADDY->value);
|
||||
|
||||
StartProxy::run($this->server, async: false, force: true);
|
||||
|
||||
expect(nonRootProxyScriptLines())
|
||||
->toContain("echo 'import /dynamic/*.caddy' | sudo tee /data/coolify/proxy/caddy/dynamic/Caddyfile > /dev/null");
|
||||
});
|
||||
|
||||
it('deploys the swarm proxy stack with an absolute compose file path', function () {
|
||||
useNonRootProxy($this->server, ProxyTypes::TRAEFIK->value);
|
||||
$this->server->settings->update(['is_swarm_manager' => true]);
|
||||
$this->server->refresh();
|
||||
|
||||
StartProxy::run($this->server, async: false, force: true);
|
||||
|
||||
$lines = nonRootProxyScriptLines();
|
||||
expectNoUnprivilegedProxyFileAccess($lines);
|
||||
expect($lines)->toContain('sudo docker stack deploy --detach=true -c /data/coolify/proxy/docker-compose.yml coolify-proxy');
|
||||
});
|
||||
|
||||
it('builds restart commands without entering proxy directories as the SSH user', function (string $proxyType, string $proxyPath) {
|
||||
useNonRootProxy($this->server, $proxyType);
|
||||
$configuration = $this->server->proxyType() === ProxyTypes::CADDY->value
|
||||
? "services:\n caddy:\n image: lucaslorentz/caddy-docker-proxy:2.13-alpine\n"
|
||||
: "services:\n traefik:\n image: traefik:v3.6\n";
|
||||
|
||||
$method = new ReflectionMethod(RestartProxyJob::class, 'buildRestartCommands');
|
||||
$commands = $method->invoke(new RestartProxyJob($this->server), $configuration);
|
||||
$lines = parseCommandsByLineForSudo(collect($commands), $this->server);
|
||||
|
||||
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");
|
||||
})->with([
|
||||
'traefik' => [ProxyTypes::TRAEFIK->value, '/data/coolify/proxy'],
|
||||
'caddy' => [ProxyTypes::CADDY->value, '/data/coolify/proxy/caddy'],
|
||||
]);
|
||||
|
||||
it('backs up and prunes proxy configurations through sudo without bash', function () {
|
||||
useNonRootProxy($this->server, ProxyTypes::CADDY->value);
|
||||
$this->server->proxy->last_saved_settings = 'a1b2c3d4e5f6';
|
||||
$this->server->save();
|
||||
|
||||
SaveProxyConfiguration::run($this->server, "services:\n caddy:\n image: lucaslorentz/caddy-docker-proxy:2.13-alpine\n");
|
||||
|
||||
$lines = nonRootProxyScriptLines();
|
||||
$backups = '/data/coolify/proxy/caddy/backups';
|
||||
expectNoUnprivilegedProxyFileAccess($lines);
|
||||
// Alpine servers can run without bash, so the parser must not need `sudo bash -c` here.
|
||||
expect(collect($lines)->filter(fn (string $line) => str_contains($line, 'bash -c')))->toBeEmpty()
|
||||
->and($lines)
|
||||
->toContain("if sudo [ -z \"$(sudo find {$backups} -maxdepth 1 -name 'docker-compose.*.a1b2c3d4.yml')\" ]; then")
|
||||
->toContain("sudo find {$backups} -maxdepth 1 -name 'docker-compose.*.yml' | sudo sort -r | sudo tail -n +11 | sudo xargs -r rm -f")
|
||||
->and(collect($lines)->first(fn (string $line) => str_contains($line, "cp -f /data/coolify/proxy/caddy/docker-compose.yml {$backups}/")))
|
||||
->toStartWith('sudo ');
|
||||
});
|
||||
|
||||
it('runs the same proxy commands without sudo for a root SSH user', function (string $proxyType, string $proxyPath) {
|
||||
$this->server->update(['user' => 'root']);
|
||||
useNonRootProxy($this->server, $proxyType);
|
||||
$this->server->proxy->last_saved_settings = 'a1b2c3d4e5f6';
|
||||
$this->server->save();
|
||||
|
||||
StartProxy::run($this->server, async: false, force: true);
|
||||
|
||||
$lines = nonRootProxyScriptLines();
|
||||
expect(collect($lines)->filter(fn (string $line) => str_contains($line, 'sudo')))->toBeEmpty()
|
||||
->and(collect($lines)->filter(fn (string $line) => str_starts_with(trim($line), 'cd ')))->toBeEmpty()
|
||||
->and($lines)
|
||||
->toContain("docker compose -f {$proxyPath}/docker-compose.yml pull")
|
||||
->toContain("docker compose -f {$proxyPath}/docker-compose.yml up -d --wait --remove-orphans")
|
||||
->and(collect($lines)->filter(fn (string $line) => str_starts_with($line, 'find ') && str_contains($line, '/backups -maxdepth 1')))->not->toBeEmpty();
|
||||
})->with([
|
||||
'traefik' => [ProxyTypes::TRAEFIK->value, '/data/coolify/proxy'],
|
||||
'caddy' => [ProxyTypes::CADDY->value, '/data/coolify/proxy/caddy'],
|
||||
]);
|
||||
Reference in New Issue
Block a user