From 367eebc9fcddaef46a10a20b061f9b46351d5ebd Mon Sep 17 00:00:00 2001 From: peaklabs-dev <122374094+peaklabs-dev@users.noreply.github.com> Date: Wed, 5 Feb 2025 22:56:29 +0100 Subject: [PATCH] feat: Add full SSL support to MongoDB --- app/Actions/Database/StartMongodb.php | 141 +++++++++++++++--- .../Project/Database/Mongodb/General.php | 65 ++++++++ app/Models/StandaloneMongodb.php | 28 +++- .../database/mongodb/general.blade.php | 47 ++++++ 4 files changed, 260 insertions(+), 21 deletions(-) diff --git a/app/Actions/Database/StartMongodb.php b/app/Actions/Database/StartMongodb.php index 89d35ca7b..bf36d3802 100644 --- a/app/Actions/Database/StartMongodb.php +++ b/app/Actions/Database/StartMongodb.php @@ -2,6 +2,8 @@ namespace App\Actions\Database; +use App\Helpers\SslHelper; +use App\Models\SslCertificate; use App\Models\StandaloneMongodb; use Lorisleiva\Actions\Concerns\AsAction; use Symfony\Component\Yaml\Yaml; @@ -16,6 +18,8 @@ class StartMongodb public string $configuration_dir; + private ?SslCertificate $ssl_certificate = null; + public function handle(StandaloneMongodb $database) { $this->database = $database; @@ -24,16 +28,62 @@ class StartMongodb $container_name = $this->database->uuid; $this->configuration_dir = database_configuration_dir().'/'.$container_name; - if (isDev()) { $this->configuration_dir = '/var/lib/docker/volumes/coolify_dev_coolify_data/_data/databases/'.$container_name; } $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/mongo/certs/server.crt', + '/etc/mongo/certs/server.key', + ]); + }) + ->each(function ($storage) { + $storage->delete(); + }); + } else { + $this->commands[] = "echo 'Setting up SSL for this database.'"; + $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/mongo/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(); @@ -79,47 +129,97 @@ class StartMongodb ], ], ]; + 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->mongo_conf) || ! empty($this->database->mongo_conf)) { - $docker_compose['services'][$container_name]['volumes'][] = [ - 'type' => 'bind', - 'source' => $this->configuration_dir.'/mongod.conf', - 'target' => '/etc/mongo/mongod.conf', - 'read_only' => true, - ]; - $docker_compose['services'][$container_name]['command'] = $startCommand.' --config /etc/mongo/mongod.conf'; + + if (! empty($this->database->mongo_conf)) { + $docker_compose['services'][$container_name]['volumes'] = array_merge( + $docker_compose['services'][$container_name]['volumes'] ?? [], + [[ + 'type' => 'bind', + 'source' => $this->configuration_dir.'/mongod.conf', + 'target' => '/etc/mongo/mongod.conf', + 'read_only' => true, + ]] + ); } + $this->add_default_database(); - $docker_compose['services'][$container_name]['volumes'][] = [ - 'type' => 'bind', - 'source' => $this->configuration_dir.'/docker-entrypoint-initdb.d', - 'target' => '/docker-entrypoint-initdb.d', - 'read_only' => true, - ]; + + $docker_compose['services'][$container_name]['volumes'] = array_merge( + $docker_compose['services'][$container_name]['volumes'] ?? [], + [[ + 'type' => 'bind', + 'source' => $this->configuration_dir.'/docker-entrypoint-initdb.d', + 'target' => '/docker-entrypoint-initdb.d', + '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) { + $commandParts = ['mongod']; + + $commandParts[] = '--sslPEMKeyFile'; + $commandParts[] = '/etc/mongo/certs/server.pem'; + $commandParts[] = '--sslCAFile'; + $commandParts[] = '/etc/mongo/certs/ca.pem'; + + $sslConfig = match ($this->database->ssl_mode) { + 'verifyCA' => [ + '--sslMode=requireSSL', + '--tlsAllowInvalidCertificates=false', + ], + 'verifyFull' => [ + '--sslMode=requireSSL', + '--tlsAllowInvalidCertificates=false', + '--tlsAllowInvalidHostnames=false', + ], + 'requireSSL' => ['--sslMode=requireSSL'], + 'preferSSL' => ['--sslMode=preferSSL'], + 'allowSSL' => ['--sslMode=allowSSL'], + default => [] + }; + + $commandParts = [...$commandParts, ...$sslConfig]; + $docker_compose['services'][$container_name]['command'] = $commandParts; + } + $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"; @@ -128,6 +228,9 @@ class StartMongodb $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->mongo_initdb_root_username}:{$this->database->mongo_initdb_root_username} /etc/mongo/certs/server.pem /etc/mongo/certs/ca.pem"); + } $this->commands[] = "echo 'Database started.'"; return remote_process($this->commands, $database->destination->server, callEventOnFinish: 'DatabaseStatusChanged'); diff --git a/app/Livewire/Project/Database/Mongodb/General.php b/app/Livewire/Project/Database/Mongodb/General.php index e19895dae..911b74f55 100644 --- a/app/Livewire/Project/Database/Mongodb/General.php +++ b/app/Livewire/Project/Database/Mongodb/General.php @@ -4,7 +4,9 @@ namespace App\Livewire\Project\Database\Mongodb; 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\StandaloneMongodb; 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', @@ -34,6 +38,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:allowSSL,preferSSL,requireSSL,verifyCA,verifyFull', ]; protected $validationAttributes = [ @@ -48,6 +54,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() @@ -55,6 +63,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() @@ -128,6 +144,55 @@ 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 { + $existingCert = SslCertificate::where('resource_type', $this->database->getMorphClass()) + ->where('resource_id', $this->database->id) + ->where('server_id', $this->server->id) + ->first(); + + if (! $existingCert) { + $this->dispatch('error', 'No existing SSL certificate found for this database.'); + + return; + } + + $caCertificate = SslCertificate::where('server_id', $this->server->id) + ->where('resource_type', null) + ->where('resource_id', null) + ->first(); + + SslHelper::generateSslCertificate( + commonName: $existingCert->common_name, + subjectAlternativeNames: $existingCert->subject_alternative_names ?? [], + resourceType: $existingCert->resource_type, + resourceId: $existingCert->resource_id, + serverId: $existingCert->server_id, + caCert: $caCertificate->ssl_certificate, + caKey: $caCertificate->ssl_private_key, + configurationDir: $existingCert->configuration_dir, + mountPath: '/etc/mongo/certs', + ); + + $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/StandaloneMongodb.php b/app/Models/StandaloneMongodb.php index dfc6ec13b..0b282eea1 100644 --- a/app/Models/StandaloneMongodb.php +++ b/app/Models/StandaloneMongodb.php @@ -243,7 +243,20 @@ class StandaloneMongodb extends BaseModel protected function internalDbUrl(): Attribute { return new Attribute( - get: fn () => "mongodb://{$this->mongo_initdb_root_username}:{$this->mongo_initdb_root_password}@{$this->uuid}:27017/?directConnection=true", + get: function () { + $url = "mongodb://{$this->mongo_initdb_root_username}:{$this->mongo_initdb_root_password}@{$this->uuid}:27017/?directConnection=true"; + if ($this->enable_ssl) { + $url .= '&ssl=true'; + if (in_array($this->ssl_mode, ['verifyCA', 'verifyFull'])) { + $url .= '&tlsAllowInvalidCertificates=false'; + } + if ($this->ssl_mode === 'verifyFull') { + $url .= '&tlsAllowInvalidHostnames=false'; + } + } + + return $url; + }, ); } @@ -252,7 +265,18 @@ class StandaloneMongodb extends BaseModel return new Attribute( get: function () { if ($this->is_public && $this->public_port) { - return "mongodb://{$this->mongo_initdb_root_username}:{$this->mongo_initdb_root_password}@{$this->destination->server->getIp}:{$this->public_port}/?directConnection=true"; + $url = "mongodb://{$this->mongo_initdb_root_username}:{$this->mongo_initdb_root_password}@{$this->destination->server->getIp}:{$this->public_port}/?directConnection=true"; + if ($this->enable_ssl) { + $url .= '&ssl=true'; + if (in_array($this->ssl_mode, ['verifyCA', 'verifyFull'])) { + $url .= '&tlsAllowInvalidCertificates=false'; + } + if ($this->ssl_mode === 'verifyFull') { + $url .= '&tlsAllowInvalidHostnames=false'; + } + } + + return $url; } return null; diff --git a/resources/views/livewire/project/database/mongodb/general.blade.php b/resources/views/livewire/project/database/mongodb/general.blade.php index 72fd2f75d..b64884525 100644 --- a/resources/views/livewire/project/database/mongodb/general.blade.php +++ b/resources/views/livewire/project/database/mongodb/general.blade.php @@ -55,6 +55,53 @@ type="password" readonly wire:model="db_url_public" /> @endif + +