From 0e3b6e5aa6904f6db03ad299d49e454213b9018c Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Fri, 16 Oct 2026 21:28:21 +0200 Subject: [PATCH] fix(databases): apply custom configs on servers with a strict umask A custom config file written with tee gets the server umask. With umask 027 or 077 the file is not readable by the container user, so MySQL and MariaDB ignored the whole config without an error. - Set mode 644 on the MySQL, MariaDB, PostgreSQL, and MongoDB custom config files after each write. - Close the multiplexed SSH connection when the server user, IP, or port changes, so the next command does not run as the old user. Closes #11600 --- .ai/lessons.md | 1 + app/Actions/Database/StartMariadb.php | 2 + app/Actions/Database/StartMongodb.php | 2 + app/Actions/Database/StartMysql.php | 2 + app/Actions/Database/StartPostgresql.php | 2 + app/Models/Server.php | 4 ++ .../Feature/DatabaseStartContentFilesTest.php | 20 ++++++++++ tests/Feature/GithubRunnerFlowTest.php | 2 +- .../ServerConnectionChangeClosesMuxTest.php | 40 +++++++++++++++++++ 9 files changed, 74 insertions(+), 1 deletion(-) create mode 100644 tests/Feature/ServerConnectionChangeClosesMuxTest.php diff --git a/.ai/lessons.md b/.ai/lessons.md index edfabe8dcf..20057d1493 100644 --- a/.ai/lessons.md +++ b/.ai/lessons.md @@ -8,6 +8,7 @@ - Several dev instances can run from one checkout (see `./scripts/dev urls`). After you add a migration, run it on each running instance of that checkout, or those instances fail on the new code. - The checkout is shared with other sessions. When the full suite fails, rerun each failing file alone and compare with a clean `git archive HEAD` copy before you connect a failure to your change. The dev instance of the checkout runs its live files, so a live repro also runs the uncommitted edits of other sessions: run `git diff` on the code under test before you trust the result. - Run seeders and other artisan commands that write SSH keys in a dev container as `www-data` (`docker exec -u www-data`). Root-owned files in `storage/app/ssh` make every SSH call from the web process fail. +- Files written on a server with `tee` get the server umask (027/077 on hardened hosts). Set the mode explicitly (`chmod 644`) for files that a non-root container user reads through a read-only bind mount. - `sshd` keeps the login shell of an open multiplexed SSH connection. Call `SshMultiplexingHelper::removeMuxFile()` before a live test of SSH or login-shell behavior. - SSH retries replay the full command batch. Only exit code 255 is an SSH transport failure; other exit codes come from the remote command, so do not retry them. Keep batches safe to run twice (for example `docker rm -f || true` before `docker run --name `). - Redirect browser test output to a file (`> /tmp/x.log 2>&1`); piping it (`| tail`) hangs because the Playwright server keeps the pipe open. diff --git a/app/Actions/Database/StartMariadb.php b/app/Actions/Database/StartMariadb.php index bf5739a86b..80163b0475 100644 --- a/app/Actions/Database/StartMariadb.php +++ b/app/Actions/Database/StartMariadb.php @@ -282,5 +282,7 @@ class StartMariadb $content = $this->database->mariadb_conf; $content_base64 = base64_encode($content); $this->commands[] = "echo '{$content_base64}' | base64 -d | tee $this->configuration_dir/{$filename} > /dev/null"; + // The container user must read the read-only mount; a strict server umask leaves the file unreadable. + $this->commands[] = "chmod 644 $this->configuration_dir/{$filename}"; } } diff --git a/app/Actions/Database/StartMongodb.php b/app/Actions/Database/StartMongodb.php index 0afb0c4a6f..7a3a56fa7d 100644 --- a/app/Actions/Database/StartMongodb.php +++ b/app/Actions/Database/StartMongodb.php @@ -372,6 +372,8 @@ class StartMongodb $content = $this->database->mongo_conf; $content_base64 = base64_encode($content); $this->commands[] = "echo '{$content_base64}' | base64 -d | tee $this->configuration_dir/{$filename} > /dev/null"; + // The container user must read the read-only mount; a strict server umask leaves the file unreadable. + $this->commands[] = "chmod 644 $this->configuration_dir/{$filename}"; } private function add_default_database() diff --git a/app/Actions/Database/StartMysql.php b/app/Actions/Database/StartMysql.php index ea723aada7..d4f5a93147 100644 --- a/app/Actions/Database/StartMysql.php +++ b/app/Actions/Database/StartMysql.php @@ -294,5 +294,7 @@ class StartMysql $content = $this->database->mysql_conf; $content_base64 = base64_encode($content); $this->commands[] = "echo '{$content_base64}' | base64 -d | tee $this->configuration_dir/{$filename} > /dev/null"; + // The container user must read the read-only mount; a strict server umask leaves the file unreadable. + $this->commands[] = "chmod 644 $this->configuration_dir/{$filename}"; } } diff --git a/app/Actions/Database/StartPostgresql.php b/app/Actions/Database/StartPostgresql.php index c0949e51b8..faf8f80be1 100644 --- a/app/Actions/Database/StartPostgresql.php +++ b/app/Actions/Database/StartPostgresql.php @@ -342,5 +342,7 @@ class StartPostgresql } $content_base64 = base64_encode($content); $this->commands[] = "echo '{$content_base64}' | base64 -d | tee $config_file_path > /dev/null"; + // The container user must read the read-only mount; a strict server umask leaves the file unreadable. + $this->commands[] = "chmod 644 $config_file_path"; } } diff --git a/app/Models/Server.php b/app/Models/Server.php index 086b9897f0..560dc601b6 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -11,6 +11,7 @@ use App\Enums\ApplicationDeploymentStatus; use App\Enums\ProxyTypes; use App\Enums\ServerRole; use App\Events\ServerReachabilityChanged; +use App\Helpers\SshMultiplexingHelper; use App\Helpers\SslHelper; use App\Jobs\CheckAndStartSentinelJob; use App\Jobs\CheckTraefikVersionForServerJob; @@ -175,6 +176,9 @@ class Server extends BaseModel static::saved(function ($server) { if ($server->wasChanged('private_key_id') || $server->privateKey?->isDirty()) { refresh_server_connection($server->privateKey); + } elseif ($server->wasChanged(['user', 'ip', 'port'])) { + // An open multiplexed connection keeps the old user and host until it is closed. + SshMultiplexingHelper::removeMuxFile($server); } }); static::created(function ($server) { diff --git a/tests/Feature/DatabaseStartContentFilesTest.php b/tests/Feature/DatabaseStartContentFilesTest.php index 644e129ccd..88de7023b8 100644 --- a/tests/Feature/DatabaseStartContentFilesTest.php +++ b/tests/Feature/DatabaseStartContentFilesTest.php @@ -97,3 +97,23 @@ it('writes the MongoDB server.pem before the certificate owner change mounts it' expect($commands)->toContain('Writing 1 missing configuration file.') ->and(strpos($commands, 'Writing 1 missing configuration file.'))->toBeLessThan(strpos($commands, '--entrypoint chown')); }); + +dataset('database-start-actions-with-custom-config-files', [ + 'postgresql' => ['create_standalone_postgresql', StartPostgresql::class, 'postgres_conf', 'custom-postgres.conf'], + 'mysql' => ['create_standalone_mysql', StartMysql::class, 'mysql_conf', 'custom-config.cnf'], + 'mariadb' => ['create_standalone_mariadb', StartMariadb::class, 'mariadb_conf', 'custom-config.cnf'], + 'mongodb' => ['create_standalone_mongodb', StartMongodb::class, 'mongo_conf', 'mongod.conf'], +]); + +it('makes the custom config file readable by the container user before the container starts', function (string $createDatabase, string $startAction, string $column, string $file) { + $database = $createDatabase($this->environment->id, $this->destination); + $database->update([$column => "# custom\n"]); + + $startAction::run($database->fresh(), new Activity); + + $path = $database->workdir()."/{$file}"; + $commands = implode("\n", $this->executor->commands); + expect($commands)->toContain("chmod 644 {$path}") + ->and(strpos($commands, "tee {$path}"))->toBeLessThan(strpos($commands, "chmod 644 {$path}")) + ->and(strpos($commands, "chmod 644 {$path}"))->toBeLessThan(strpos($commands, 'docker-compose.yml up -d')); +})->with('database-start-actions-with-custom-config-files'); diff --git a/tests/Feature/GithubRunnerFlowTest.php b/tests/Feature/GithubRunnerFlowTest.php index d6d0ea49bd..8884763e08 100644 --- a/tests/Feature/GithubRunnerFlowTest.php +++ b/tests/Feature/GithubRunnerFlowTest.php @@ -603,11 +603,11 @@ describe('provisioning', function () { it('does not start runners on the Coolify host', function () { Queue::fake(); - Process::fake(); $server = runnerTestServer($this->team); $server->update(['ip' => 'host.docker.internal']); runnerTestConfig($server, $this->githubApp); $execution = runnerTestExecution($this->githubApp); + Process::fake(); (new ProvisionGithubRunnerJob($execution->id))->handle(); diff --git a/tests/Feature/ServerConnectionChangeClosesMuxTest.php b/tests/Feature/ServerConnectionChangeClosesMuxTest.php new file mode 100644 index 0000000000..57ad88639b --- /dev/null +++ b/tests/Feature/ServerConnectionChangeClosesMuxTest.php @@ -0,0 +1,40 @@ + 0]); + $team = Team::factory()->create(); + $privateKey = PrivateKey::factory()->create(['team_id' => $team->id]); + $this->server = Server::factory()->create(['team_id' => $team->id, 'private_key_id' => $privateKey->id, 'user' => 'root']); + Process::fake(); +}); + +function closesServerMux(Server $server): Closure +{ + return fn ($process) => str_contains($process->command, 'ssh -O stop') && str_contains($process->command, "mux_{$server->uuid}"); +} + +it('closes the multiplexed SSH connection when the connection details change', function (array $changes) { + $this->server->update($changes); + + Process::assertRan(closesServerMux($this->server)); +})->with([ + 'user' => [['user' => 'deploy']], + 'ip' => [['ip' => '203.0.113.20']], + 'port' => [['port' => 2222]], +]); + +it('keeps the multiplexed SSH connection when other server fields change', function () { + $this->server->update(['name' => 'renamed', 'description' => 'changed']); + + Process::assertDidntRun(closesServerMux($this->server)); +});