mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-04 18:52:49 +00:00
171 lines
7.7 KiB
PHP
171 lines
7.7 KiB
PHP
<?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", remoteProcessScript($process)));
|
|
|
|
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_CONTEXT=default DOCKER_CONFIG=\"\$(sudo find \"\$HOME/.docker/config.json\" -maxdepth 0 -exec dirname {} \\; 2>/dev/null)\" docker compose -f {$proxyPath}/docker-compose.yml pull")
|
|
->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'],
|
|
]);
|
|
|
|
it('writes the Caddyfile through sudo', function () {
|
|
useNonRootProxy($this->server, ProxyTypes::CADDY->value);
|
|
|
|
StartProxy::run($this->server, async: false, force: true);
|
|
|
|
$base64 = base64_encode("import /dynamic/*.caddy\n");
|
|
|
|
expect(nonRootProxyScriptLines())
|
|
->toContain("echo '{$base64}' | sudo base64 -d | 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 container_name: coolify-proxy\n image: lucaslorentz/caddy-docker-proxy:2.13-alpine\n"
|
|
: "services:\n traefik:\n container_name: coolify-proxy\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_CONTEXT=default DOCKER_CONFIG=\"\$(sudo find \"\$HOME/.docker/config.json\" -maxdepth 0 -exec dirname {} \\; 2>/dev/null)\" 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 container_name: coolify-proxy\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'],
|
|
]);
|