diff --git a/app/Actions/Database/StartMysql.php b/app/Actions/Database/StartMysql.php index 73db1512a..c122c8c6f 100644 --- a/app/Actions/Database/StartMysql.php +++ b/app/Actions/Database/StartMysql.php @@ -2,6 +2,8 @@ namespace App\Actions\Database; +use App\Helpers\SslHelper; +use App\Models\SslCertificate; use App\Models\StandaloneMysql; use Lorisleiva\Actions\Concerns\AsAction; use Symfony\Component\Yaml\Yaml; @@ -16,6 +18,8 @@ class StartMysql public string $configuration_dir; + private ?SslCertificate $ssl_certificate = null; + public function handle(StandaloneMysql $database) { $this->database = $database; @@ -25,9 +29,57 @@ class StartMysql $this->commands = [ "echo 'Starting database.'", + "echo 'Creating directories.'", "mkdir -p $this->configuration_dir", + "echo 'Directories created successfully.'", ]; + if (! $this->database->enable_ssl) { + $this->commands[] = "rm -rf $this->configuration_dir/ssl"; + + SslCertificate::where('resource_type', $this->database->getMorphClass()) + ->where('resource_id', $this->database->id) + ->delete(); + + $this->database->fileStorages() + ->where('resource_type', $this->database->getMorphClass()) + ->where('resource_id', $this->database->id) + ->get() + ->filter(function ($storage) { + return in_array($storage->mount_path, [ + '/etc/mysql/certs/server.crt', + '/etc/mysql/certs/server.key', + ]); + }) + ->each(function ($storage) { + $storage->delete(); + }); + } else { + $this->commands[] = "echo 'Setting up SSL for this database.'"; + $this->commands[] = "rm -rf $this->configuration_dir/ssl"; + $this->commands[] = "mkdir -p $this->configuration_dir/ssl"; + $server = $this->database->destination->server; + $caCert = SslCertificate::where('server_id', $server->id)->firstOrFail(); + + $this->ssl_certificate = SslCertificate::where('resource_type', $this->database->getMorphClass()) + ->where('resource_id', $this->database->id) + ->first(); + + if (! $this->ssl_certificate) { + $this->commands[] = "echo 'No SSL certificate found, generating new SSL certificate for this database.'"; + $this->ssl_certificate = SslHelper::generateSslCertificate( + commonName: $this->database->uuid, + resourceType: $this->database->getMorphClass(), + resourceId: $this->database->id, + serverId: $server->id, + caCert: $caCert->ssl_certificate, + caKey: $caCert->ssl_private_key, + configurationDir: $this->configuration_dir, + mountPath: '/etc/mysql/certs', + ); + } + } + $persistent_storages = $this->generate_local_persistent_volumes(); $persistent_file_volumes = $this->database->fileStorages()->get(); $volume_names = $this->generate_local_persistent_volumes_only_volume_names(); @@ -70,36 +122,61 @@ class StartMysql if (! is_null($this->database->limits_cpuset)) { data_set($docker_compose, "services.{$container_name}.cpuset", $this->database->limits_cpuset); } + if ($this->database->destination->server->isLogDrainEnabled() && $this->database->isLogDrainEnabled()) { $docker_compose['services'][$container_name]['logging'] = generate_fluentd_configuration(); } + if (count($this->database->ports_mappings_array) > 0) { $docker_compose['services'][$container_name]['ports'] = $this->database->ports_mappings_array; } + + $docker_compose['services'][$container_name]['volumes'] ??= []; + if (count($persistent_storages) > 0) { - $docker_compose['services'][$container_name]['volumes'] = $persistent_storages; + $docker_compose['services'][$container_name]['volumes'] = array_merge( + $docker_compose['services'][$container_name]['volumes'] ?? [], + $persistent_storages + ); } if (count($persistent_file_volumes) > 0) { - $docker_compose['services'][$container_name]['volumes'] = $persistent_file_volumes->map(function ($item) { - return "$item->fs_path:$item->mount_path"; - })->toArray(); + $docker_compose['services'][$container_name]['volumes'] = array_merge( + $docker_compose['services'][$container_name]['volumes'] ?? [], + $persistent_file_volumes->map(function ($item) { + return "$item->fs_path:$item->mount_path"; + })->toArray() + ); } if (count($volume_names) > 0) { $docker_compose['volumes'] = $volume_names; } if (! is_null($this->database->mysql_conf) || ! empty($this->database->mysql_conf)) { - $docker_compose['services'][$container_name]['volumes'][] = [ - 'type' => 'bind', - 'source' => $this->configuration_dir.'/custom-config.cnf', - 'target' => '/etc/mysql/conf.d/custom-config.cnf', - 'read_only' => true, - ]; + $docker_compose['services'][$container_name]['volumes'] = array_merge( + $docker_compose['services'][$container_name]['volumes'] ?? [], + [ + [ + 'type' => 'bind', + 'source' => $this->configuration_dir.'/custom-config.cnf', + 'target' => '/etc/mysql/conf.d/custom-config.cnf', + 'read_only' => true, + ], + ] + ); } // Add custom docker run options $docker_run_options = convertDockerRunToCompose($this->database->custom_docker_run_options); $docker_compose = generateCustomDockerRunOptionsForDatabases($docker_run_options, $docker_compose, $container_name, $this->database->destination->network); + if ($this->database->enable_ssl) { + $docker_compose['services'][$container_name]['command'] = [ + 'mysqld', + '--ssl-cert=/etc/mysql/certs/server.crt', + '--ssl-key=/etc/mysql/certs/server.key', + '--require-secure-transport=ON', + ]; + } + $docker_compose = Yaml::dump($docker_compose, 10); $docker_compose_base64 = base64_encode($docker_compose); $this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null"; @@ -108,6 +185,11 @@ class StartMysql $this->commands[] = "echo 'Pulling {$database->image} image.'"; $this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml pull"; $this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d"; + + if ($this->database->enable_ssl) { + $this->commands[] = executeInDocker($this->database->uuid, "chown {$this->database->mysql_user}:{$this->database->mysql_user} /etc/mysql/certs/server.crt /etc/mysql/certs/server.key"); + } + $this->commands[] = "echo 'Database started.'"; return remote_process($this->commands, $database->destination->server, callEventOnFinish: 'DatabaseStatusChanged'); diff --git a/app/Livewire/Project/Database/Mysql/General.php b/app/Livewire/Project/Database/Mysql/General.php index 7d5270ddf..9ae54e60a 100644 --- a/app/Livewire/Project/Database/Mysql/General.php +++ b/app/Livewire/Project/Database/Mysql/General.php @@ -4,7 +4,9 @@ namespace App\Livewire\Project\Database\Mysql; use App\Actions\Database\StartDatabaseProxy; use App\Actions\Database\StopDatabaseProxy; +use App\Helpers\SslHelper; use App\Models\Server; +use App\Models\SslCertificate; use App\Models\StandaloneMysql; use Exception; use Livewire\Component; @@ -21,6 +23,8 @@ class General extends Component public ?string $db_url_public = null; + public $certificateValidUntil = null; + protected $rules = [ 'database.name' => 'required', 'database.description' => 'nullable', @@ -35,6 +39,8 @@ class General extends Component 'database.public_port' => 'nullable|integer', 'database.is_log_drain_enabled' => 'nullable|boolean', 'database.custom_docker_run_options' => 'nullable', + 'database.enable_ssl' => 'boolean', + 'database.ssl_mode' => 'nullable|string|in:PREFERRED,REQUIRED,VERIFY_CA,VERIFY_IDENTITY', ]; protected $validationAttributes = [ @@ -50,6 +56,8 @@ class General extends Component 'database.is_public' => 'Is Public', 'database.public_port' => 'Public Port', 'database.custom_docker_run_options' => 'Custom Docker Run Options', + 'database.enable_ssl' => 'Enable SSL', + 'database.ssl_mode' => 'SSL Mode', ]; public function mount() @@ -57,6 +65,14 @@ class General extends Component $this->db_url = $this->database->internal_db_url; $this->db_url_public = $this->database->external_db_url; $this->server = data_get($this->database, 'destination.server'); + + $existingCert = SslCertificate::where('resource_type', $this->database->getMorphClass()) + ->where('resource_id', $this->database->id) + ->first(); + + if ($existingCert) { + $this->certificateValidUntil = $existingCert->valid_until; + } } public function instantSaveAdvanced() @@ -127,6 +143,52 @@ class General extends Component } } + public function instantSaveSSL() + { + try { + $this->database->enable_ssl = $this->database->enable_ssl; + $this->database->ssl_mode = $this->database->ssl_mode; + $this->database->save(); + $this->dispatch('success', 'SSL configuration updated.'); + } catch (Exception $e) { + return handleError($e, $this); + } + } + + public function regenerateSslCertificate() + { + try { + $server = $this->database->destination->server; + + $existingCert = SslCertificate::where('resource_type', $this->database->getMorphClass()) + ->where('resource_id', $this->database->id) + ->first(); + + if (! $existingCert) { + $this->dispatch('error', 'No existing SSL certificate found for this database.'); + + return; + } + + $caCert = SslCertificate::where('server_id', $server->id)->firstOrFail(); + + SslHelper::generateSslCertificate( + commonName: $existingCert->common_name, + subjectAlternativeNames: $existingCert->subject_alternative_names ?? [], + resourceType: $existingCert->resource_type, + resourceId: $existingCert->resource_id, + serverId: $existingCert->server_id, + caCert: $caCert->ssl_certificate, + caKey: $caCert->ssl_private_key, + configurationDir: $existingCert->configuration_dir, + ); + + $this->dispatch('success', 'SSL certificates have been regenerated. Please restart the database for changes to take effect.'); + } catch (Exception $e) { + return handleError($e, $this); + } + } + public function refresh(): void { $this->database->refresh(); diff --git a/app/Models/StandaloneMysql.php b/app/Models/StandaloneMysql.php index 9ae0fdcae..1bd75995c 100644 --- a/app/Models/StandaloneMysql.php +++ b/app/Models/StandaloneMysql.php @@ -219,7 +219,17 @@ class StandaloneMysql extends BaseModel protected function internalDbUrl(): Attribute { return new Attribute( - get: fn () => "mysql://{$this->mysql_user}:{$this->mysql_password}@{$this->uuid}:3306/{$this->mysql_database}", + get: function () { + $url = "mysql://{$this->mysql_user}:{$this->mysql_password}@{$this->uuid}:3306/{$this->mysql_database}"; + if ($this->enable_ssl) { + $url .= "?ssl-mode={$this->ssl_mode}"; + if (in_array($this->ssl_mode, ['VERIFY_CA', 'VERIFY_IDENTITY'])) { + $url .= '&ssl-ca=/etc/ssl/certs/coolify-ca.crt'; + } + } + + return $url; + }, ); } @@ -228,7 +238,15 @@ class StandaloneMysql extends BaseModel return new Attribute( get: function () { if ($this->is_public && $this->public_port) { - return "mysql://{$this->mysql_user}:{$this->mysql_password}@{$this->destination->server->getIp}:{$this->public_port}/{$this->mysql_database}"; + $url = "mysql://{$this->mysql_user}:{$this->mysql_password}@{$this->destination->server->getIp}:{$this->public_port}/{$this->mysql_database}"; + if ($this->enable_ssl) { + $url .= "?ssl-mode={$this->ssl_mode}"; + if (in_array($this->ssl_mode, ['VERIFY_CA', 'VERIFY_IDENTITY'])) { + $url .= '&ssl-ca=/etc/ssl/certs/coolify-ca.crt'; + } + } + + return $url; } return null; diff --git a/resources/views/livewire/project/database/mysql/general.blade.php b/resources/views/livewire/project/database/mysql/general.blade.php index c4ac7221a..8da378399 100644 --- a/resources/views/livewire/project/database/mysql/general.blade.php +++ b/resources/views/livewire/project/database/mysql/general.blade.php @@ -65,6 +65,50 @@ type="password" readonly wire:model="db_url_public" /> @endif + +
+
+
+

SSL Configuration

+ @if($database->enable_ssl) + + @endif +
+
+ @if($database->enable_ssl && $certificateValidUntil) + Valid until: + @if(now()->gt($certificateValidUntil)) + {{ $certificateValidUntil->format('d.m.Y H:i:s') }} - Expired + @elseif(now()->addDays(30)->gt($certificateValidUntil)) + {{ $certificateValidUntil->format('d.m.Y H:i:s') }} - Expiring soon + @else + {{ $certificateValidUntil->format('d.m.Y H:i:s') }} + @endif + + @endif +
+
+
+ + @if($database->enable_ssl) + + + + + + + @endif +
+
+