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)); +});