feat(ssl): Add full MySQL SSL Support
This commit is contained in:
		@@ -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) {
 | 
			
		||||
            $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();
 | 
			
		||||
                })->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'][] = [
 | 
			
		||||
            $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');
 | 
			
		||||
 
 | 
			
		||||
@@ -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();
 | 
			
		||||
 
 | 
			
		||||
@@ -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;
 | 
			
		||||
 
 | 
			
		||||
@@ -65,6 +65,50 @@
 | 
			
		||||
                    type="password" readonly wire:model="db_url_public" />
 | 
			
		||||
            @endif
 | 
			
		||||
        </div>
 | 
			
		||||
 | 
			
		||||
        <div class="flex flex-col gap-2">
 | 
			
		||||
            <div class="flex items-center justify-between py-2">
 | 
			
		||||
                <div class="flex items-center justify-between w-full">
 | 
			
		||||
                    <h3>SSL Configuration</h3>
 | 
			
		||||
                    @if($database->enable_ssl)
 | 
			
		||||
                        <x-modal-confirmation
 | 
			
		||||
                            title="Regenerate SSL Certificates"
 | 
			
		||||
                            buttonTitle="Regenerate SSL Certificates"
 | 
			
		||||
                            :actions="['The SSL certificate of this database will be regenerated.','You must restart the database after regenerating the certificate to start using the new certificate.']"
 | 
			
		||||
                            submitAction="regenerateSslCertificate"
 | 
			
		||||
                            :confirmWithText="false"
 | 
			
		||||
                            :confirmWithPassword="false"
 | 
			
		||||
                        />
 | 
			
		||||
                    @endif
 | 
			
		||||
                </div>
 | 
			
		||||
            </div>
 | 
			
		||||
            @if($database->enable_ssl && $certificateValidUntil)
 | 
			
		||||
                <span class="text-sm">Valid until: 
 | 
			
		||||
                @if(now()->gt($certificateValidUntil))
 | 
			
		||||
                    <span class="text-red-500">{{ $certificateValidUntil->format('d.m.Y H:i:s') }} - Expired</span>
 | 
			
		||||
                @elseif(now()->addDays(30)->gt($certificateValidUntil))
 | 
			
		||||
                    <span class="text-red-500">{{ $certificateValidUntil->format('d.m.Y H:i:s') }} - Expiring soon</span>
 | 
			
		||||
                @else
 | 
			
		||||
                    <span>{{ $certificateValidUntil->format('d.m.Y H:i:s') }}</span>
 | 
			
		||||
                @endif
 | 
			
		||||
                </span>
 | 
			
		||||
            @endif
 | 
			
		||||
        </div>
 | 
			
		||||
        <div class="flex flex-col gap-2">
 | 
			
		||||
            <div class="flex flex-col gap-2">
 | 
			
		||||
                <x-forms.checkbox id="database.enable_ssl" label="Enable SSL" wire:model.live="database.enable_ssl" instantSave="instantSaveSSL" />
 | 
			
		||||
                @if($database->enable_ssl)
 | 
			
		||||
                    <x-forms.select id="database.ssl_mode" label="SSL Mode" wire:model.live="database.ssl_mode" instantSave="instantSaveSSL"
 | 
			
		||||
                        helper="Choose the SSL verification mode for MySQL connections">
 | 
			
		||||
                        <option value="PREFERRED">PREFERRED</option>
 | 
			
		||||
                        <option value="REQUIRED">REQUIRED</option>
 | 
			
		||||
                        <option value="VERIFY_CA">VERIFY_CA</option>
 | 
			
		||||
                        <option value="VERIFY_IDENTITY">VERIFY_IDENTITY</option>
 | 
			
		||||
                    </x-forms.select>
 | 
			
		||||
                @endif
 | 
			
		||||
            </div>
 | 
			
		||||
        </div>
 | 
			
		||||
 | 
			
		||||
        <div>
 | 
			
		||||
            <div class="flex flex-col py-2 w-64">
 | 
			
		||||
                <div class="flex items-center gap-2 pb-2">
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user