From 7b30b1aff1863fb90fcf5d901d66529f3abdf577 Mon Sep 17 00:00:00 2001 From: peaklabs-dev <122374094+peaklabs-dev@users.noreply.github.com> Date: Fri, 7 Feb 2025 22:36:36 +0100 Subject: [PATCH] feat(ssl): Full SSL support for Redis --- app/Actions/Database/StartRedis.php | 133 ++++++++++++++++-- .../Project/Database/Redis/General.php | 61 ++++++++ app/Models/StandaloneRedis.php | 17 ++- .../project/database/redis/general.blade.php | 41 ++++++ 4 files changed, 241 insertions(+), 11 deletions(-) diff --git a/app/Actions/Database/StartRedis.php b/app/Actions/Database/StartRedis.php index 1beebd134..11534e87d 100644 --- a/app/Actions/Database/StartRedis.php +++ b/app/Actions/Database/StartRedis.php @@ -2,6 +2,8 @@ namespace App\Actions\Database; +use App\Helpers\SslHelper; +use App\Models\SslCertificate; use App\Models\StandaloneRedis; use Illuminate\Support\Facades\Storage; use Lorisleiva\Actions\Concerns\AsAction; @@ -17,6 +19,8 @@ class StartRedis public string $configuration_dir; + private ?SslCertificate $ssl_certificate = null; + public function handle(StandaloneRedis $database) { $this->database = $database; @@ -26,9 +30,53 @@ class StartRedis $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/redis/certs/server.crt', + '/etc/redis/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)->where('is_ca_certificate', true)->first(); + + $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/redis/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(); @@ -50,11 +98,22 @@ class StartRedis ], 'labels' => defaultDatabaseLabels($this->database)->toArray(), 'healthcheck' => [ - 'test' => [ - 'CMD-SHELL', - 'redis-cli', - 'ping', - ], + 'test' => $this->database->enable_ssl + ? [ + 'CMD-SHELL', + 'redis-cli', + '--tls', + '--cacert /etc/redis/certs/coolify-ca.crt', + '--cert /etc/redis/certs/server.crt', + '--key /etc/redis/certs/server.key', + '-p 6380', + 'ping', + ] + : [ + 'CMD-SHELL', + 'redis-cli', + 'ping', + ], 'interval' => '5s', 'timeout' => '5s', 'retries' => 10, @@ -76,26 +135,55 @@ class StartRedis ], ], ]; + 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 ($this->database->enable_ssl) { + $docker_compose['services'][$container_name]['volumes'] = array_merge( + $docker_compose['services'][$container_name]['volumes'] ?? [], + [ + [ + 'type' => 'bind', + 'source' => '/data/coolify/ssl/coolify-ca.crt', + 'target' => '/etc/redis/certs/coolify-ca.crt', + 'read_only' => true, + ], + ] + ); + } + if (! is_null($this->database->redis_conf) || ! empty($this->database->redis_conf)) { $docker_compose['services'][$container_name]['volumes'][] = [ 'type' => 'bind', @@ -116,6 +204,9 @@ class StartRedis $this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md"; $this->commands[] = "echo 'Pulling {$database->image} image.'"; $this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml pull"; + if ($this->database->enable_ssl) { + $this->commands[] = "chown -R 999:999 $this->configuration_dir/ssl/server.key $this->configuration_dir/ssl/server.crt"; + } $this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d"; $this->commands[] = "echo 'Database started.'"; @@ -202,6 +293,30 @@ class StartRedis $command = "redis-server --requirepass {$this->database->redis_password} --appendonly yes"; } + if ($this->database->enable_ssl) { + $sslArgs = match ($this->database->ssl_mode) { + 'require' => [ + '--tls-port 6380', + '--tls-cert-file /etc/redis/certs/server.crt', + '--tls-key-file /etc/redis/certs/server.key', + '--tls-ca-cert-file /etc/redis/certs/coolify-ca.crt', + '--tls-auth-clients no', + ], + 'verify-full' => [ + '--tls-port 6380', + '--tls-cert-file /etc/redis/certs/server.crt', + '--tls-key-file /etc/redis/certs/server.key', + '--tls-ca-cert-file /etc/redis/certs/coolify-ca.crt', + '--tls-auth-clients yes', + ], + default => [] + }; + } + + if (! empty($sslArgs)) { + $command .= ' '.implode(' ', $sslArgs); + } + return $command; } diff --git a/app/Livewire/Project/Database/Redis/General.php b/app/Livewire/Project/Database/Redis/General.php index 05babeaec..32ba0a9ad 100644 --- a/app/Livewire/Project/Database/Redis/General.php +++ b/app/Livewire/Project/Database/Redis/General.php @@ -4,7 +4,9 @@ namespace App\Livewire\Project\Database\Redis; 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\StandaloneRedis; use Exception; use Livewire\Component; @@ -30,6 +32,8 @@ class General extends Component public ?string $db_url_public = null; + public $certificateValidUntil = null; + protected $rules = [ 'database.name' => 'required', 'database.description' => 'nullable', @@ -42,6 +46,8 @@ class General extends Component 'database.custom_docker_run_options' => 'nullable', 'redis_username' => 'required', 'redis_password' => 'required', + 'database.enable_ssl' => 'boolean', + 'database.ssl_mode' => 'nullable|string|in:require,verify-full', ]; protected $validationAttributes = [ @@ -55,12 +61,21 @@ class General extends Component 'database.custom_docker_run_options' => 'Custom Docker Options', 'redis_username' => 'Redis Username', 'redis_password' => 'Redis Password', + 'database.enable_ssl' => 'Enable SSL', + 'database.ssl_mode' => 'SSL Mode', ]; public function mount() { $this->server = data_get($this->database, 'destination.server'); $this->refreshView(); + $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() @@ -136,6 +151,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 { + $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; + } + + $caCert = SslCertificate::where('server_id', $existingCert->server_id)->where('is_ca_certificate', true)->first(); + + SslHelper::generateSslCertificate( + commonName: $existingCert->commonName, + subjectAlternativeNames: $existingCert->subjectAlternativeNames ?? [], + 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, + mountPath: $existingCert->mount_path, + ); + + $this->dispatch('success', 'SSL certificates regenerated. Restart database to apply changes.'); + } catch (Exception $e) { + handleError($e, $this); + } + } + public function refresh(): void { $this->database->refresh(); diff --git a/app/Models/StandaloneRedis.php b/app/Models/StandaloneRedis.php index ebb0fbe30..b7835b4c9 100644 --- a/app/Models/StandaloneRedis.php +++ b/app/Models/StandaloneRedis.php @@ -222,8 +222,15 @@ class StandaloneRedis extends BaseModel get: function () { $redis_version = $this->getRedisVersion(); $username_part = version_compare($redis_version, '6.0', '>=') ? "{$this->redis_username}:" : ''; + $scheme = $this->enable_ssl ? 'rediss' : 'redis'; + $port = $this->enable_ssl ? 6380 : 6379; + $url = "{$scheme}://{$username_part}{$this->redis_password}@{$this->uuid}:{$port}/0"; - return "redis://{$username_part}{$this->redis_password}@{$this->uuid}:6379/0"; + if ($this->enable_ssl && $this->ssl_mode === 'verify-full') { + $url .= '?cacert=/etc/ssl/certs/coolify-ca.crt'; + } + + return $url; } ); } @@ -235,8 +242,14 @@ class StandaloneRedis extends BaseModel if ($this->is_public && $this->public_port) { $redis_version = $this->getRedisVersion(); $username_part = version_compare($redis_version, '6.0', '>=') ? "{$this->redis_username}:" : ''; + $scheme = $this->enable_ssl ? 'rediss' : 'redis'; + $url = "{$scheme}://{$username_part}{$this->redis_password}@{$this->destination->server->getIp}:{$this->public_port}/0"; - return "redis://{$username_part}{$this->redis_password}@{$this->destination->server->getIp}:{$this->public_port}/0"; + if ($this->enable_ssl && $this->ssl_mode === 'verify-full') { + $url .= '?cacert=/etc/ssl/certs/coolify-ca.crt'; + } + + return $url; } return null; diff --git a/resources/views/livewire/project/database/redis/general.blade.php b/resources/views/livewire/project/database/redis/general.blade.php index a274fa62e..eb9d7af23 100644 --- a/resources/views/livewire/project/database/redis/general.blade.php +++ b/resources/views/livewire/project/database/redis/general.blade.php @@ -49,6 +49,47 @@ type="password" readonly wire:model="db_url_public" /> @endif +
+
+
+

SSL Configuration

+ @if($database->enable_ssl && $certificateValidUntil) + + @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 +
+