fix(database): auto-generate missing CA cert on SSL regeneration

Prevent null CA certificate access during database SSL certificate regeneration
across KeyDB, MariaDB, MongoDB, MySQL, PostgreSQL, and Redis components.

If no CA certificate exists, attempt to generate one and re-query; if still
missing, dispatch a clear error and stop regeneration gracefully.

Add `SslCertificateRegenerationTest` coverage for missing-CA and CA-query
scenarios to prevent regressions.
This commit is contained in:
Andras Bacsai 2026-03-30 13:10:49 +02:00
parent eb3d88a9ea
commit 850c37bedd
11 changed files with 223 additions and 10 deletions

View file

@ -269,6 +269,17 @@ public function regenerateSslCertificate()
->where('is_ca_certificate', true)
->first();
if (! $caCert) {
$this->server->generateCaCertificate();
$caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first();
}
if (! $caCert) {
$this->dispatch('error', 'No CA certificate found for this database. Please generate a CA certificate for this server in the server/advanced page.');
return;
}
SslHelper::generateSslCertificate(
commonName: $existingCert->commonName,
subjectAlternativeNames: $existingCert->subjectAlternativeNames ?? [],

View file

@ -289,6 +289,17 @@ public function regenerateSslCertificate()
$caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first();
if (! $caCert) {
$this->server->generateCaCertificate();
$caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first();
}
if (! $caCert) {
$this->dispatch('error', 'No CA certificate found for this database. Please generate a CA certificate for this server in the server/advanced page.');
return;
}
SslHelper::generateSslCertificate(
commonName: $existingCert->common_name,
subjectAlternativeNames: $existingCert->subject_alternative_names ?? [],

View file

@ -297,6 +297,17 @@ public function regenerateSslCertificate()
$caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first();
if (! $caCert) {
$this->server->generateCaCertificate();
$caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first();
}
if (! $caCert) {
$this->dispatch('error', 'No CA certificate found for this database. Please generate a CA certificate for this server in the server/advanced page.');
return;
}
SslHelper::generateSslCertificate(
commonName: $existingCert->common_name,
subjectAlternativeNames: $existingCert->subject_alternative_names ?? [],

View file

@ -301,6 +301,17 @@ public function regenerateSslCertificate()
$caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first();
if (! $caCert) {
$this->server->generateCaCertificate();
$caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first();
}
if (! $caCert) {
$this->dispatch('error', 'No CA certificate found for this database. Please generate a CA certificate for this server in the server/advanced page.');
return;
}
SslHelper::generateSslCertificate(
commonName: $existingCert->common_name,
subjectAlternativeNames: $existingCert->subject_alternative_names ?? [],

View file

@ -264,6 +264,17 @@ public function regenerateSslCertificate()
$caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first();
if (! $caCert) {
$this->server->generateCaCertificate();
$caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first();
}
if (! $caCert) {
$this->dispatch('error', 'No CA certificate found for this database. Please generate a CA certificate for this server in the server/advanced page.');
return;
}
SslHelper::generateSslCertificate(
commonName: $existingCert->common_name,
subjectAlternativeNames: $existingCert->subject_alternative_names ?? [],

View file

@ -282,6 +282,17 @@ public function regenerateSslCertificate()
$caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first();
if (! $caCert) {
$this->server->generateCaCertificate();
$caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first();
}
if (! $caCert) {
$this->dispatch('error', 'No CA certificate found for this database. Please generate a CA certificate for this server in the server/advanced page.');
return;
}
SslHelper::generateSslCertificate(
commonName: $existingCert->commonName,
subjectAlternativeNames: $existingCert->subjectAlternativeNames ?? [],

View file

@ -2722,8 +2722,7 @@
},
"is_preserve_repository_enabled": {
"type": "boolean",
"default": false,
"description": "Preserve repository during deployment."
"description": "Preserve git repository during application update. If false, the existing repository will be removed and replaced with the new one. If true, the existing repository will be kept and the new one will be ignored. Default is false."
}
},
"type": "object"
@ -7275,6 +7274,22 @@
"schema": {
"type": "integer"
}
},
{
"name": "pull_request_id",
"in": "query",
"description": "Preview deployment identifier. Alias of pr.",
"schema": {
"type": "integer"
}
},
{
"name": "docker_tag",
"in": "query",
"description": "Docker image tag for Docker Image preview deployments. Requires pull_request_id.",
"schema": {
"type": "string"
}
}
],
"responses": {
@ -12735,6 +12750,10 @@
"pull_request_id": {
"type": "integer"
},
"docker_registry_image_tag": {
"type": "string",
"nullable": true
},
"force_rebuild": {
"type": "boolean"
},

View file

@ -1755,8 +1755,7 @@ paths:
description: 'Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off.'
is_preserve_repository_enabled:
type: boolean
default: false
description: 'Preserve repository during deployment.'
description: 'Preserve git repository during application update. If false, the existing repository will be removed and replaced with the new one. If true, the existing repository will be kept and the new one will be ignored. Default is false.'
type: object
responses:
'200':
@ -4711,6 +4710,18 @@ paths:
description: 'Pull Request Id for deploying specific PR builds. Cannot be used with tag parameter.'
schema:
type: integer
-
name: pull_request_id
in: query
description: 'Preview deployment identifier. Alias of pr.'
schema:
type: integer
-
name: docker_tag
in: query
description: 'Docker image tag for Docker Image preview deployments. Requires pull_request_id.'
schema:
type: string
responses:
'200':
description: "Get deployment(s) UUID's"
@ -8106,6 +8117,9 @@ components:
type: string
pull_request_id:
type: integer
docker_registry_image_tag:
type: string
nullable: true
force_rebuild:
type: boolean
commit:

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,82 @@
<?php
use App\Models\Server;
use App\Models\SslCertificate;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
$this->actingAs($this->user);
session(['currentTeam' => $this->team]);
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
});
test('server with no CA certificate returns null from sslCertificates query', function () {
$caCert = $this->server->sslCertificates()
->where('is_ca_certificate', true)
->first();
expect($caCert)->toBeNull();
});
test('accessing property on null CA cert throws an error', function () {
// This test verifies the exact scenario that caused the 500 error:
// querying for a CA cert on a server that has none, then trying to access properties
$caCert = $this->server->sslCertificates()
->where('is_ca_certificate', true)
->first();
expect($caCert)->toBeNull();
// Without the fix, the code would do:
// caCert: $caCert->ssl_certificate <-- 500 error
expect(fn () => $caCert->ssl_certificate)
->toThrow(ErrorException::class);
});
test('CA certificate can be retrieved when it exists on the server', function () {
// Create a CA certificate directly (simulating what generateCaCertificate does)
SslCertificate::create([
'server_id' => $this->server->id,
'is_ca_certificate' => true,
'ssl_certificate' => 'test-ca-cert',
'ssl_private_key' => 'test-ca-key',
'common_name' => 'Coolify CA Certificate',
'valid_until' => now()->addYears(10),
]);
$caCert = $this->server->sslCertificates()
->where('is_ca_certificate', true)
->first();
expect($caCert)->not->toBeNull()
->and($caCert->is_ca_certificate)->toBeTruthy()
->and($caCert->ssl_certificate)->toBe('test-ca-cert')
->and($caCert->ssl_private_key)->toBe('test-ca-key');
});
test('non-CA certificate is not returned when querying for CA certificate', function () {
// Create only a regular (non-CA) certificate
SslCertificate::create([
'server_id' => $this->server->id,
'is_ca_certificate' => false,
'ssl_certificate' => 'test-cert',
'ssl_private_key' => 'test-key',
'common_name' => 'test-db-uuid',
'valid_until' => now()->addYear(),
]);
$caCert = $this->server->sslCertificates()
->where('is_ca_certificate', true)
->first();
// The CA cert query should return null since only a regular cert exists
expect($caCert)->toBeNull();
});