diff --git a/app/Actions/Database/StartDragonfly.php b/app/Actions/Database/StartDragonfly.php index 4f9f45b7c..882ed3c2e 100644 --- a/app/Actions/Database/StartDragonfly.php +++ b/app/Actions/Database/StartDragonfly.php @@ -2,6 +2,8 @@ namespace App\Actions\Database; +use App\Helpers\SslHelper; +use App\Models\SslCertificate; use App\Models\StandaloneDragonfly; use Lorisleiva\Actions\Concerns\AsAction; use Symfony\Component\Yaml\Yaml; @@ -16,24 +18,70 @@ class StartDragonfly public string $configuration_dir; + private ?SslCertificate $ssl_certificate = null; + public function handle(StandaloneDragonfly $database) { $this->database = $database; - $startCommand = "dragonfly --requirepass {$this->database->dragonfly_password}"; - $container_name = $this->database->uuid; $this->configuration_dir = database_configuration_dir().'/'.$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"; + $this->database->sslCertificates()->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/dragonfly/certs/server.crt', + '/etc/dragonfly/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 = $this->database->sslCertificates()->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/dragonfly/certs', + ); + } + } + + $container_name = $this->database->uuid; + $this->configuration_dir = database_configuration_dir().'/'.$container_name; + $persistent_storages = $this->generate_local_persistent_volumes(); $persistent_file_volumes = $this->database->fileStorages()->get(); $volume_names = $this->generate_local_persistent_volumes_only_volume_names(); $environment_variables = $this->generate_environment_variables(); + $startCommand = $this->buildStartCommand(); $docker_compose = [ 'services' => [ @@ -70,27 +118,55 @@ class StartDragonfly ], ], ]; + 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/dragonfly/certs/coolify-ca.crt', + '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); @@ -102,12 +178,32 @@ class StartDragonfly $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.'"; return remote_process($this->commands, $database->destination->server, callEventOnFinish: 'DatabaseStatusChanged'); } + private function buildStartCommand(): string + { + $command = "dragonfly --requirepass {$this->database->dragonfly_password}"; + + if ($this->database->enable_ssl) { + $sslArgs = [ + '--tls', + '--tls_cert_file /etc/dragonfly/certs/server.crt', + '--tls_key_file /etc/dragonfly/certs/server.key', + '--tls_ca_cert_file /etc/dragonfly/certs/coolify-ca.crt', + ]; + $command .= ' '.implode(' ', $sslArgs); + } + + return $command; + } + private function generate_local_persistent_volumes() { $local_persistent_volumes = []; diff --git a/app/Actions/Database/StartKeydb.php b/app/Actions/Database/StartKeydb.php index 6c733d318..311b5094a 100644 --- a/app/Actions/Database/StartKeydb.php +++ b/app/Actions/Database/StartKeydb.php @@ -2,6 +2,8 @@ namespace App\Actions\Database; +use App\Helpers\SslHelper; +use App\Models\SslCertificate; use App\Models\StandaloneKeydb; use Illuminate\Support\Facades\Storage; use Lorisleiva\Actions\Concerns\AsAction; @@ -17,26 +19,73 @@ class StartKeydb public string $configuration_dir; + private ?SslCertificate $ssl_certificate = null; + public function handle(StandaloneKeydb $database) { $this->database = $database; - $startCommand = "keydb-server --requirepass {$this->database->keydb_password} --appendonly yes"; - $container_name = $this->database->uuid; $this->configuration_dir = database_configuration_dir().'/'.$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"; + $this->database->sslCertificates()->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/keydb/certs/server.crt', + '/etc/keydb/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 = $this->database->sslCertificates()->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/keydb/certs', + ); + } + } + + $container_name = $this->database->uuid; + $this->configuration_dir = database_configuration_dir().'/'.$container_name; + $persistent_storages = $this->generate_local_persistent_volumes(); $persistent_file_volumes = $this->database->fileStorages()->get(); $volume_names = $this->generate_local_persistent_volumes_only_volume_names(); $environment_variables = $this->generate_environment_variables(); $this->add_custom_keydb(); + $startCommand = $this->buildStartCommand(); + $docker_compose = [ 'services' => [ $container_name => [ @@ -72,34 +121,67 @@ class StartKeydb ], ], ]; + 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->keydb_conf) || ! empty($this->database->keydb_conf)) { - $docker_compose['services'][$container_name]['volumes'][] = [ - 'type' => 'bind', - 'source' => $this->configuration_dir.'/keydb.conf', - 'target' => '/etc/keydb/keydb.conf', - 'read_only' => true, - ]; - $docker_compose['services'][$container_name]['command'] = "keydb-server /etc/keydb/keydb.conf --requirepass {$this->database->keydb_password} --appendonly yes"; + $docker_compose['services'][$container_name]['volumes'] = array_merge( + $docker_compose['services'][$container_name]['volumes'] ?? [], + [ + [ + 'type' => 'bind', + 'source' => $this->configuration_dir.'/keydb.conf', + 'target' => '/etc/keydb/keydb.conf', + 'read_only' => true, + ], + ] + ); + } + + 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/keydb/certs/coolify-ca.crt', + 'read_only' => true, + ], + ] + ); } // Add custom docker run options @@ -112,6 +194,9 @@ class StartKeydb $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.'"; @@ -177,4 +262,36 @@ class StartKeydb instant_scp($path, "{$this->configuration_dir}/{$filename}", $this->database->destination->server); Storage::disk('local')->delete("tmp/keydb.conf_{$this->database->uuid}"); } + + private function buildStartCommand(): string + { + $hasKeydbConf = ! is_null($this->database->keydb_conf) && ! empty($this->database->keydb_conf); + $keydbConfPath = '/etc/keydb/keydb.conf'; + + if ($hasKeydbConf) { + $confContent = $this->database->keydb_conf; + $hasRequirePass = str_contains($confContent, 'requirepass'); + + if ($hasRequirePass) { + $command = "keydb-server $keydbConfPath"; + } else { + $command = "keydb-server $keydbConfPath --requirepass {$this->database->keydb_password}"; + } + } else { + $command = "keydb-server --requirepass {$this->database->keydb_password} --appendonly yes"; + } + + if ($this->database->enable_ssl) { + $sslArgs = [ + '--tls-port 6380', + '--tls-cert-file /etc/keydb/certs/server.crt', + '--tls-key-file /etc/keydb/certs/server.key', + '--tls-ca-cert-file /etc/keydb/certs/coolify-ca.crt', + '--tls-auth-clients optional', + ]; + $command .= ' '.implode(' ', $sslArgs); + } + + return $command; + } } diff --git a/app/Actions/Database/StartMariadb.php b/app/Actions/Database/StartMariadb.php index 299b07385..14df9b017 100644 --- a/app/Actions/Database/StartMariadb.php +++ b/app/Actions/Database/StartMariadb.php @@ -2,6 +2,8 @@ namespace App\Actions\Database; +use App\Helpers\SslHelper; +use App\Models\SslCertificate; use App\Models\StandaloneMariadb; use Lorisleiva\Actions\Concerns\AsAction; use Symfony\Component\Yaml\Yaml; @@ -16,6 +18,8 @@ class StartMariadb public string $configuration_dir; + private ?SslCertificate $ssl_certificate = null; + public function handle(StandaloneMariadb $database) { $this->database = $database; @@ -25,9 +29,53 @@ class StartMariadb $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"; + + $this->database->sslCertificates()->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[] = "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 = $this->database->sslCertificates()->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(); @@ -67,38 +115,81 @@ class StartMariadb ], ], ]; + 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; } - if (count($persistent_storages) > 0) { - $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(); - } + if (count($volume_names) > 0) { $docker_compose['volumes'] = $volume_names; } + + $docker_compose['services'][$container_name]['volumes'] ??= []; + + if (count($persistent_storages) > 0) { + $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'] = array_merge( + $docker_compose['services'][$container_name]['volumes'], + $persistent_file_volumes->map(function ($item) { + return "$item->fs_path:$item->mount_path"; + })->toArray() + ); + } + + 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/mysql/certs/coolify-ca.crt', + 'read_only' => true, + ], + ] + ); + } + if (! is_null($this->database->mariadb_conf) || ! empty($this->database->mariadb_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', + '--ssl-ca=/etc/mysql/certs/coolify-ca.crt', + '--require-secure-transport=1', + ]; + } $docker_compose = Yaml::dump($docker_compose, 10); $docker_compose_base64 = base64_encode($docker_compose); @@ -109,6 +200,9 @@ class StartMariadb $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"; $this->commands[] = "echo 'Database started.'"; + if ($this->database->enable_ssl) { + $this->commands[] = executeInDocker($this->database->uuid, 'chown mysql:mysql /etc/mysql/certs/server.crt /etc/mysql/certs/server.key'); + } return remote_process($this->commands, $database->destination->server, callEventOnFinish: 'DatabaseStatusChanged'); } diff --git a/app/Actions/Database/StartMongodb.php b/app/Actions/Database/StartMongodb.php index 89d35ca7b..3ea8287ac 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,59 @@ 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"; + + $this->database->sslCertificates()->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.pem', + ]); + }) + ->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 = $this->database->sslCertificates()->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', + isPemKeyFileRequired: true, + ); + } + } + $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 +126,118 @@ 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, + ]] + ); + + 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/mongo/certs/ca.pem', + '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']; + + $sslConfig = match ($this->database->ssl_mode) { + 'allow' => [ + '--tlsMode=allowTLS', + '--tlsAllowConnectionsWithoutCertificates', + '--tlsAllowInvalidHostnames', + ], + 'prefer' => [ + '--tlsMode=preferTLS', + '--tlsAllowConnectionsWithoutCertificates', + '--tlsAllowInvalidHostnames', + ], + 'require' => [ + '--tlsMode=requireTLS', + '--tlsAllowConnectionsWithoutCertificates', + '--tlsAllowInvalidHostnames', + ], + 'verify-full' => [ + '--tlsMode=requireTLS', + '--tlsAllowInvalidHostnames', + ], + default => [], + }; + + $commandParts = [...$commandParts, ...$sslConfig]; + $commandParts[] = '--tlsCAFile'; + $commandParts[] = '/etc/mongo/certs/ca.pem'; + $commandParts[] = '--tlsCertificateKeyFile'; + $commandParts[] = '/etc/mongo/certs/server.pem'; + + $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 +246,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 mongodb:mongodb /etc/mongo/certs/server.pem'); + } $this->commands[] = "echo 'Database started.'"; return remote_process($this->commands, $database->destination->server, callEventOnFinish: 'DatabaseStatusChanged'); diff --git a/app/Actions/Database/StartMysql.php b/app/Actions/Database/StartMysql.php index 73db1512a..a2e08c316 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,53 @@ 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"; + + $this->database->sslCertificates()->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[] = "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 = $this->database->sslCertificates()->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(); @@ -67,39 +115,83 @@ 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 ($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/mysql/certs/coolify-ca.crt', + 'read_only' => true, + ], + ] + ); + } + 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', + '--ssl-ca=/etc/mysql/certs/coolify-ca.crt', + '--require-secure-transport=1', + ]; + } + $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 +200,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/Actions/Database/StartPostgresql.php b/app/Actions/Database/StartPostgresql.php index 035849340..97e565ec8 100644 --- a/app/Actions/Database/StartPostgresql.php +++ b/app/Actions/Database/StartPostgresql.php @@ -2,6 +2,8 @@ namespace App\Actions\Database; +use App\Helpers\SslHelper; +use App\Models\SslCertificate; use App\Models\StandalonePostgresql; use Lorisleiva\Actions\Concerns\AsAction; use Symfony\Component\Yaml\Yaml; @@ -18,6 +20,8 @@ class StartPostgresql public string $configuration_dir; + private ?SslCertificate $ssl_certificate = null; + public function handle(StandalonePostgresql $database) { $this->database = $database; @@ -29,10 +33,54 @@ class StartPostgresql $this->commands = [ "echo 'Starting database.'", + "echo 'Creating directories.'", "mkdir -p $this->configuration_dir", "mkdir -p $this->configuration_dir/docker-entrypoint-initdb.d/", + "echo 'Directories created successfully.'", ]; + if (! $this->database->enable_ssl) { + $this->commands[] = "rm -rf $this->configuration_dir/ssl"; + + $this->database->sslCertificates()->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, [ + '/var/lib/postgresql/certs/server.crt', + '/var/lib/postgresql/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 = $this->database->sslCertificates()->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: '/var/lib/postgresql/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(); @@ -77,49 +125,84 @@ class StartPostgresql ], ], ]; + if (filled($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 (count($this->init_scripts) > 0) { foreach ($this->init_scripts as $init_script) { - $docker_compose['services'][$container_name]['volumes'][] = [ - 'type' => 'bind', - 'source' => $init_script, - 'target' => '/docker-entrypoint-initdb.d/'.basename($init_script), - 'read_only' => true, - ]; + $docker_compose['services'][$container_name]['volumes'] = array_merge( + $docker_compose['services'][$container_name]['volumes'], + [[ + 'type' => 'bind', + 'source' => $init_script, + 'target' => '/docker-entrypoint-initdb.d/'.basename($init_script), + 'read_only' => true, + ]] + ); } } + if (filled($this->database->postgres_conf)) { - $docker_compose['services'][$container_name]['volumes'][] = [ - 'type' => 'bind', - 'source' => $this->configuration_dir.'/custom-postgres.conf', - 'target' => '/etc/postgresql/postgresql.conf', - 'read_only' => true, - ]; + $docker_compose['services'][$container_name]['volumes'] = array_merge( + $docker_compose['services'][$container_name]['volumes'], + [[ + 'type' => 'bind', + 'source' => $this->configuration_dir.'/custom-postgres.conf', + 'target' => '/etc/postgresql/postgresql.conf', + 'read_only' => true, + ]] + ); $docker_compose['services'][$container_name]['command'] = [ 'postgres', '-c', 'config_file=/etc/postgresql/postgresql.conf', ]; } + + if ($this->database->enable_ssl) { + $docker_compose['services'][$container_name]['command'] = [ + 'postgres', + '-c', + 'ssl=on', + '-c', + 'ssl_cert_file=/var/lib/postgresql/certs/server.crt', + '-c', + 'ssl_key_file=/var/lib/postgresql/certs/server.key', + ]; + } + // 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); @@ -132,6 +215,9 @@ class StartPostgresql $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->postgres_user}:{$this->database->postgres_user} /var/lib/postgresql/certs/server.key /var/lib/postgresql/certs/server.crt"); + } $this->commands[] = "echo 'Database started.'"; return remote_process($this->commands, $database->destination->server, callEventOnFinish: 'DatabaseStatusChanged'); diff --git a/app/Actions/Database/StartRedis.php b/app/Actions/Database/StartRedis.php index 1beebd134..9e7a2a084 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,51 @@ 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"; + $this->database->sslCertificates()->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 = $this->database->sslCertificates()->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(); @@ -76,26 +122,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 +191,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 +280,20 @@ class StartRedis $command = "redis-server --requirepass {$this->database->redis_password} --appendonly yes"; } + if ($this->database->enable_ssl) { + $sslArgs = [ + '--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 optional', + ]; + } + + if (! empty($sslArgs)) { + $command .= ' '.implode(' ', $sslArgs); + } + return $command; } diff --git a/app/Actions/Server/InstallDocker.php b/app/Actions/Server/InstallDocker.php index cbcb20368..5410b1cbd 100644 --- a/app/Actions/Server/InstallDocker.php +++ b/app/Actions/Server/InstallDocker.php @@ -2,7 +2,9 @@ namespace App\Actions\Server; +use App\Helpers\SslHelper; use App\Models\Server; +use App\Models\SslCertificate; use App\Models\StandaloneDocker; use Lorisleiva\Actions\Concerns\AsAction; @@ -17,6 +19,27 @@ class InstallDocker if (! $supported_os_type) { throw new \Exception('Server OS type is not supported for automated installation. Please install Docker manually before continuing: documentation.'); } + + if (! SslCertificate::where('server_id', $server->id)->where('is_ca_certificate', true)->exists()) { + $serverCert = SslHelper::generateSslCertificate( + commonName: 'Coolify CA Certificate', + serverId: $server->id, + isCaCertificate: true, + validityDays: 10 * 365 + ); + $caCertPath = config('constants.coolify.base_config_path').'/ssl/'; + + $commands = collect([ + "mkdir -p $caCertPath", + "chown -R 9999:root $caCertPath", + "chmod -R 700 $caCertPath", + "rm -rf $caCertPath/coolify-ca.crt", + "echo '{$serverCert->ssl_certificate}' > $caCertPath/coolify-ca.crt", + "chmod 644 $caCertPath/coolify-ca.crt", + ]); + remote_process($commands, $server); + } + $config = base64_encode('{ "log-driver": "json-file", "log-opts": { diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index 122d72c39..a6f24aaad 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -9,6 +9,7 @@ use App\Jobs\CleanupInstanceStuffsJob; use App\Jobs\DatabaseBackupJob; use App\Jobs\DockerCleanupJob; use App\Jobs\PullTemplatesFromCDN; +use App\Jobs\RegenerateSslCertJob; use App\Jobs\ScheduledTaskJob; use App\Jobs\ServerCheckJob; use App\Jobs\ServerStorageCheckJob; @@ -83,6 +84,8 @@ class Kernel extends ConsoleKernel $this->checkScheduledBackups(); $this->checkScheduledTasks(); + $this->scheduleInstance->job(new RegenerateSslCertJob)->twiceDaily(); + $this->scheduleInstance->command('cleanup:database --yes')->daily(); $this->scheduleInstance->command('uploads:clear')->everyTwoMinutes(); } diff --git a/app/Helpers/SslHelper.php b/app/Helpers/SslHelper.php new file mode 100644 index 000000000..6397c330d --- /dev/null +++ b/app/Helpers/SslHelper.php @@ -0,0 +1,233 @@ + OPENSSL_KEYTYPE_EC, + 'curve_name' => 'secp521r1', + ]); + + if ($privateKey === false) { + throw new \RuntimeException('Failed to generate private key: '.openssl_error_string()); + } + + if (! openssl_pkey_export($privateKey, $privateKeyStr)) { + throw new \RuntimeException('Failed to export private key: '.openssl_error_string()); + } + + if (! is_null($serverId) && ! $isCaCertificate) { + $server = Server::find($serverId); + if ($server) { + $ip = $server->getIp; + if ($ip) { + $type = filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 | FILTER_FLAG_IPV6) + ? 'IP' + : 'DNS'; + $subjectAlternativeNames = array_unique( + array_merge($subjectAlternativeNames, ["$type:$ip"]) + ); + } + } + } + + $basicConstraints = $isCaCertificate ? 'critical, CA:TRUE, pathlen:0' : 'critical, CA:FALSE'; + $keyUsage = $isCaCertificate ? 'critical, keyCertSign, cRLSign' : 'critical, digitalSignature, keyAgreement'; + + $subjectAltNameSection = ''; + $extendedKeyUsageSection = ''; + + if (! $isCaCertificate) { + $extendedKeyUsageSection = "\nextendedKeyUsage = serverAuth, clientAuth"; + + $subjectAlternativeNames = array_values( + array_unique( + array_merge(["DNS:$commonName"], $subjectAlternativeNames) + ) + ); + + $formattedSubjectAltNames = array_map( + function ($index, $san) { + [$type, $value] = explode(':', $san, 2); + + return "{$type}.".($index + 1)." = $value"; + }, + array_keys($subjectAlternativeNames), + $subjectAlternativeNames + ); + + $subjectAltNameSection = "subjectAltName = @subject_alt_names\n\n[ subject_alt_names ]\n" + .implode("\n", $formattedSubjectAltNames); + } + + $config = << $commonName, + 'organizationName' => $organizationName, + 'countryName' => $countryName, + 'stateOrProvinceName' => $stateName, + ], $privateKey, [ + 'digest_alg' => 'sha512', + 'config' => $tempConfigPath, + 'req_extensions' => 'req_ext', + ]); + + if ($csr === false) { + throw new \RuntimeException('Failed to generate CSR: '.openssl_error_string()); + } + + $certificate = openssl_csr_sign( + $csr, + $caCert ?? null, + $caKey ?? $privateKey, + $validityDays, + [ + 'digest_alg' => 'sha512', + 'config' => $tempConfigPath, + 'x509_extensions' => 'v3_req', + ], + random_int(1, PHP_INT_MAX) + ); + + if ($certificate === false) { + throw new \RuntimeException('Failed to sign certificate: '.openssl_error_string()); + } + + if (! openssl_x509_export($certificate, $certificateStr)) { + throw new \RuntimeException('Failed to export certificate: '.openssl_error_string()); + } + + SslCertificate::query() + ->where('resource_type', $resourceType) + ->where('resource_id', $resourceId) + ->where('server_id', $serverId) + ->delete(); + + $sslCertificate = SslCertificate::create([ + 'ssl_certificate' => $certificateStr, + 'ssl_private_key' => $privateKeyStr, + 'resource_type' => $resourceType, + 'resource_id' => $resourceId, + 'server_id' => $serverId, + 'configuration_dir' => $configurationDir, + 'mount_path' => $mountPath, + 'valid_until' => CarbonImmutable::now()->addDays($validityDays), + 'is_ca_certificate' => $isCaCertificate, + 'common_name' => $commonName, + 'subject_alternative_names' => $subjectAlternativeNames, + ]); + + if ($configurationDir && $mountPath && $resourceType && $resourceId) { + $model = app($resourceType)->find($resourceId); + + $model->fileStorages() + ->where('resource_type', $model->getMorphClass()) + ->where('resource_id', $model->id) + ->get() + ->filter(function ($storage) use ($mountPath) { + return in_array($storage->mount_path, [ + $mountPath.'/server.crt', + $mountPath.'/server.key', + $mountPath.'/server.pem', + ]); + }) + ->each(function ($storage) { + $storage->delete(); + }); + + if ($isPemKeyFileRequired) { + $model->fileStorages()->create([ + 'fs_path' => $configurationDir.'/ssl/server.pem', + 'mount_path' => $mountPath.'/server.pem', + 'content' => $certificateStr."\n".$privateKeyStr, + 'is_directory' => false, + 'chmod' => '600', + 'resource_type' => $resourceType, + 'resource_id' => $resourceId, + ]); + } else { + $model->fileStorages()->create([ + 'fs_path' => $configurationDir.'/ssl/server.crt', + 'mount_path' => $mountPath.'/server.crt', + 'content' => $certificateStr, + 'is_directory' => false, + 'chmod' => '644', + 'resource_type' => $resourceType, + 'resource_id' => $resourceId, + ]); + + $model->fileStorages()->create([ + 'fs_path' => $configurationDir.'/ssl/server.key', + 'mount_path' => $mountPath.'/server.key', + 'content' => $privateKeyStr, + 'is_directory' => false, + 'chmod' => '600', + 'resource_type' => $resourceType, + 'resource_id' => $resourceId, + ]); + } + } + + return $sslCertificate; + } catch (\Throwable $e) { + throw new \RuntimeException('SSL Certificate generation failed: '.$e->getMessage(), 0, $e); + } finally { + fclose($tempConfig); + } + } +} diff --git a/app/Jobs/DeleteResourceJob.php b/app/Jobs/DeleteResourceJob.php index 8b9228e5f..9fd46db77 100644 --- a/app/Jobs/DeleteResourceJob.php +++ b/app/Jobs/DeleteResourceJob.php @@ -66,12 +66,9 @@ class DeleteResourceJob implements ShouldBeEncrypted, ShouldQueue } if ($this->deleteVolumes && $this->resource->type() !== 'service') { - $this->resource?->delete_volumes($persistentStorages); + $this->resource->delete_volumes($persistentStorages); + $this->resource->persistentStorages()->delete(); } - if ($this->deleteConfigurations) { - $this->resource?->delete_configurations(); - } - $isDatabase = $this->resource instanceof StandalonePostgresql || $this->resource instanceof StandaloneRedis || $this->resource instanceof StandaloneMongodb @@ -80,6 +77,18 @@ class DeleteResourceJob implements ShouldBeEncrypted, ShouldQueue || $this->resource instanceof StandaloneKeydb || $this->resource instanceof StandaloneDragonfly || $this->resource instanceof StandaloneClickhouse; + + if ($this->deleteConfigurations) { + $this->resource->delete_configurations(); // rename to FileStorages + $this->resource->fileStorages()->delete(); + } + if ($isDatabase) { + $this->resource->sslCertificates()->delete(); + $this->resource->scheduledBackups()->delete(); + $this->resource->environment_variables()->delete(); + $this->resource->tags()->detach(); + } + $server = data_get($this->resource, 'server') ?? data_get($this->resource, 'destination.server'); if (($this->dockerCleanup || $isDatabase) && $server) { CleanupDocker::dispatch($server, true); diff --git a/app/Jobs/RegenerateSslCertJob.php b/app/Jobs/RegenerateSslCertJob.php new file mode 100644 index 000000000..0570227b6 --- /dev/null +++ b/app/Jobs/RegenerateSslCertJob.php @@ -0,0 +1,73 @@ +server_id) { + $query->where('server_id', $this->server_id); + } + + if (! $this->force_regeneration) { + $query->where('valid_until', '<=', now()->addDays(14)); + } + + $query->where('is_ca_certificate', false); + + $regenerated = collect(); + + $query->cursor()->each(function ($certificate) use ($regenerated) { + try { + $caCert = SslCertificate::where('server_id', $certificate->server_id) + ->where('is_ca_certificate', true) + ->first(); + + SSLHelper::generateSslCertificate( + commonName: $certificate->common_name, + subjectAlternativeNames: $certificate->subject_alternative_names, + resourceType: $certificate->resource_type, + resourceId: $certificate->resource_id, + serverId: $certificate->server_id, + configurationDir: $certificate->configuration_dir, + mountPath: $certificate->mount_path, + caCert: $caCert->ssl_certificate, + caKey: $caCert->ssl_private_key, + ); + $regenerated->push($certificate); + } catch (\Exception $e) { + Log::error('Failed to regenerate SSL certificate: '.$e->getMessage()); + } + }); + + if ($regenerated->isNotEmpty()) { + $this->team?->notify(new SslExpirationNotification($regenerated)); + } + } +} diff --git a/app/Livewire/Project/Database/Dragonfly/General.php b/app/Livewire/Project/Database/Dragonfly/General.php index ea6cd46b0..51f8b5a66 100644 --- a/app/Livewire/Project/Database/Dragonfly/General.php +++ b/app/Livewire/Project/Database/Dragonfly/General.php @@ -4,8 +4,11 @@ namespace App\Livewire\Project\Database\Dragonfly; 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\StandaloneDragonfly; +use Carbon\Carbon; use Exception; use Illuminate\Support\Facades\Auth; use Livewire\Attributes\Validate; @@ -50,6 +53,11 @@ class General extends Component #[Validate(['nullable', 'boolean'])] public bool $isLogDrainEnabled = false; + public ?Carbon $certificateValidUntil = null; + + #[Validate(['nullable', 'boolean'])] + public bool $enable_ssl = false; + public function getListeners() { $teamId = Auth::user()->currentTeam()->id; @@ -64,6 +72,12 @@ class General extends Component try { $this->syncData(); $this->server = data_get($this->database, 'destination.server'); + + $existingCert = $this->database->sslCertificates()->first(); + + if ($existingCert) { + $this->certificateValidUntil = $existingCert->valid_until; + } } catch (\Throwable $e) { return handleError($e, $this); } @@ -82,6 +96,7 @@ class General extends Component $this->database->public_port = $this->publicPort; $this->database->custom_docker_run_options = $this->customDockerRunOptions; $this->database->is_log_drain_enabled = $this->isLogDrainEnabled; + $this->database->enable_ssl = $this->enable_ssl; $this->database->save(); $this->dbUrl = $this->database->internal_db_url; @@ -96,6 +111,7 @@ class General extends Component $this->publicPort = $this->database->public_port; $this->customDockerRunOptions = $this->database->custom_docker_run_options; $this->isLogDrainEnabled = $this->database->is_log_drain_enabled; + $this->enable_ssl = $this->database->enable_ssl; $this->dbUrl = $this->database->internal_db_url; $this->dbUrlPublic = $this->database->external_db_url; } @@ -174,4 +190,47 @@ class General extends Component } } } + + public function instantSaveSSL() + { + try { + $this->syncData(true); + $this->dispatch('success', 'SSL configuration updated.'); + } catch (Exception $e) { + return handleError($e, $this); + } + } + + public function regenerateSslCertificate() + { + try { + $existingCert = $this->database->sslCertificates()->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); + } + } } diff --git a/app/Livewire/Project/Database/Keydb/General.php b/app/Livewire/Project/Database/Keydb/General.php index e768495eb..213b0d2d3 100644 --- a/app/Livewire/Project/Database/Keydb/General.php +++ b/app/Livewire/Project/Database/Keydb/General.php @@ -4,8 +4,11 @@ namespace App\Livewire\Project\Database\Keydb; 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\StandaloneKeydb; +use Carbon\Carbon; use Exception; use Illuminate\Support\Facades\Auth; use Livewire\Attributes\Validate; @@ -53,6 +56,11 @@ class General extends Component #[Validate(['nullable', 'boolean'])] public bool $isLogDrainEnabled = false; + public ?Carbon $certificateValidUntil = null; + + #[Validate(['boolean'])] + public bool $enable_ssl = false; + public function getListeners() { $teamId = Auth::user()->currentTeam()->id; @@ -67,6 +75,12 @@ class General extends Component try { $this->syncData(); $this->server = data_get($this->database, 'destination.server'); + + $existingCert = $this->database->sslCertificates()->first(); + + if ($existingCert) { + $this->certificateValidUntil = $existingCert->valid_until; + } } catch (\Throwable $e) { return handleError($e, $this); } @@ -86,6 +100,7 @@ class General extends Component $this->database->public_port = $this->publicPort; $this->database->custom_docker_run_options = $this->customDockerRunOptions; $this->database->is_log_drain_enabled = $this->isLogDrainEnabled; + $this->database->enable_ssl = $this->enable_ssl; $this->database->save(); $this->dbUrl = $this->database->internal_db_url; @@ -101,6 +116,7 @@ class General extends Component $this->publicPort = $this->database->public_port; $this->customDockerRunOptions = $this->database->custom_docker_run_options; $this->isLogDrainEnabled = $this->database->is_log_drain_enabled; + $this->enable_ssl = $this->database->enable_ssl; $this->dbUrl = $this->database->internal_db_url; $this->dbUrlPublic = $this->database->external_db_url; } @@ -179,4 +195,47 @@ class General extends Component } } } + + public function instantSaveSSL() + { + try { + $this->syncData(true); + $this->dispatch('success', 'SSL configuration updated.'); + } catch (Exception $e) { + return handleError($e, $this); + } + } + + public function regenerateSslCertificate() + { + try { + $existingCert = $this->database->sslCertificates()->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); + } + } } diff --git a/app/Livewire/Project/Database/Mariadb/General.php b/app/Livewire/Project/Database/Mariadb/General.php index c9d473223..06dffdc22 100644 --- a/app/Livewire/Project/Database/Mariadb/General.php +++ b/app/Livewire/Project/Database/Mariadb/General.php @@ -4,8 +4,11 @@ namespace App\Livewire\Project\Database\Mariadb; 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\StandaloneMariadb; +use Carbon\Carbon; use Exception; use Livewire\Component; @@ -21,6 +24,8 @@ class General extends Component public ?string $db_url_public = null; + public ?Carbon $certificateValidUntil = null; + protected $rules = [ 'database.name' => 'required', 'database.description' => 'nullable', @@ -35,6 +40,7 @@ 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', ]; protected $validationAttributes = [ @@ -50,6 +56,7 @@ class General extends Component 'database.is_public' => 'Is Public', 'database.public_port' => 'Public Port', 'database.custom_docker_run_options' => 'Custom Docker Options', + 'database.enable_ssl' => 'Enable SSL', ]; public function mount() @@ -57,6 +64,12 @@ 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 = $this->database->sslCertificates()->first(); + + if ($existingCert) { + $this->certificateValidUntil = $existingCert->valid_until; + } } public function instantSaveAdvanced() @@ -127,6 +140,47 @@ class General extends Component } } + public function instantSaveSSL() + { + try { + $this->database->save(); + $this->dispatch('success', 'SSL configuration updated.'); + } catch (Exception $e) { + return handleError($e, $this); + } + } + + public function regenerateSslCertificate() + { + try { + $existingCert = $this->database->sslCertificates()->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->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, + mountPath: $existingCert->mount_path, + ); + + $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/Livewire/Project/Database/Mongodb/General.php b/app/Livewire/Project/Database/Mongodb/General.php index e19895dae..282547283 100644 --- a/app/Livewire/Project/Database/Mongodb/General.php +++ b/app/Livewire/Project/Database/Mongodb/General.php @@ -4,8 +4,11 @@ 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 Carbon\Carbon; use Exception; use Livewire\Component; @@ -21,6 +24,8 @@ class General extends Component public ?string $db_url_public = null; + public ?Carbon $certificateValidUntil = null; + protected $rules = [ 'database.name' => 'required', 'database.description' => 'nullable', @@ -34,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:allow,prefer,require,verify-full', ]; protected $validationAttributes = [ @@ -48,6 +55,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 +64,12 @@ 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 = $this->database->sslCertificates()->first(); + + if ($existingCert) { + $this->certificateValidUntil = $existingCert->valid_until; + } } public function instantSaveAdvanced() @@ -128,6 +143,52 @@ class General extends Component } } + public function updatedDatabaseSslMode() + { + $this->instantSaveSSL(); + } + + public function instantSaveSSL() + { + try { + $this->database->save(); + $this->dispatch('success', 'SSL configuration updated.'); + } catch (Exception $e) { + return handleError($e, $this); + } + } + + public function regenerateSslCertificate() + { + try { + $existingCert = $this->database->sslCertificates()->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->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, + mountPath: $existingCert->mount_path, + ); + + $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/Livewire/Project/Database/Mysql/General.php b/app/Livewire/Project/Database/Mysql/General.php index 7d5270ddf..c9424b506 100644 --- a/app/Livewire/Project/Database/Mysql/General.php +++ b/app/Livewire/Project/Database/Mysql/General.php @@ -4,8 +4,11 @@ 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 Carbon\Carbon; use Exception; use Livewire\Component; @@ -21,6 +24,8 @@ class General extends Component public ?string $db_url_public = null; + public ?Carbon $certificateValidUntil = null; + protected $rules = [ 'database.name' => 'required', 'database.description' => 'nullable', @@ -35,6 +40,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 +57,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 +66,12 @@ 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 = $this->database->sslCertificates()->first(); + + if ($existingCert) { + $this->certificateValidUntil = $existingCert->valid_until; + } } public function instantSaveAdvanced() @@ -127,6 +142,52 @@ class General extends Component } } + public function updatedDatabaseSslMode() + { + $this->instantSaveSSL(); + } + + public function instantSaveSSL() + { + try { + $this->database->save(); + $this->dispatch('success', 'SSL configuration updated.'); + } catch (Exception $e) { + return handleError($e, $this); + } + } + + public function regenerateSslCertificate() + { + try { + $existingCert = $this->database->sslCertificates()->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->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, + mountPath: $existingCert->mount_path, + ); + + $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/Livewire/Project/Database/Postgresql/General.php b/app/Livewire/Project/Database/Postgresql/General.php index 88dd5c1a8..3057f5316 100644 --- a/app/Livewire/Project/Database/Postgresql/General.php +++ b/app/Livewire/Project/Database/Postgresql/General.php @@ -4,8 +4,11 @@ namespace App\Livewire\Project\Database\Postgresql; 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\StandalonePostgresql; +use Carbon\Carbon; use Exception; use Livewire\Component; @@ -23,6 +26,8 @@ class General extends Component public ?string $db_url_public = null; + public ?Carbon $certificateValidUntil = null; + public function getListeners() { return [ @@ -48,6 +53,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:allow,prefer,require,verify-ca,verify-full', ]; protected $validationAttributes = [ @@ -65,6 +72,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() @@ -72,6 +81,12 @@ 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 = $this->database->sslCertificates()->first(); + + if ($existingCert) { + $this->certificateValidUntil = $existingCert->valid_until; + } } public function instantSaveAdvanced() @@ -91,6 +106,52 @@ class General extends Component } } + public function updatedDatabaseSslMode() + { + $this->instantSaveSSL(); + } + + public function instantSaveSSL() + { + try { + $this->database->save(); + $this->dispatch('success', 'SSL configuration updated.'); + } catch (Exception $e) { + return handleError($e, $this); + } + } + + public function regenerateSslCertificate() + { + try { + $existingCert = $this->database->sslCertificates()->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->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, + mountPath: $existingCert->mount_path, + ); + + $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 instantSave() { try { diff --git a/app/Livewire/Project/Database/Redis/General.php b/app/Livewire/Project/Database/Redis/General.php index 05babeaec..16519e287 100644 --- a/app/Livewire/Project/Database/Redis/General.php +++ b/app/Livewire/Project/Database/Redis/General.php @@ -4,8 +4,11 @@ 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 Carbon\Carbon; use Exception; use Livewire\Component; @@ -30,6 +33,8 @@ class General extends Component public ?string $db_url_public = null; + public ?Carbon $certificateValidUntil = null; + protected $rules = [ 'database.name' => 'required', 'database.description' => 'nullable', @@ -42,6 +47,7 @@ class General extends Component 'database.custom_docker_run_options' => 'nullable', 'redis_username' => 'required', 'redis_password' => 'required', + 'database.enable_ssl' => 'boolean', ]; protected $validationAttributes = [ @@ -55,12 +61,18 @@ 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', ]; public function mount() { $this->server = data_get($this->database, 'destination.server'); $this->refreshView(); + $existingCert = $this->database->sslCertificates()->first(); + + if ($existingCert) { + $this->certificateValidUntil = $existingCert->valid_until; + } } public function instantSaveAdvanced() @@ -136,6 +148,47 @@ class General extends Component } } + public function instantSaveSSL() + { + try { + $this->database->save(); + $this->dispatch('success', 'SSL configuration updated.'); + } catch (Exception $e) { + return handleError($e, $this); + } + } + + public function regenerateSslCertificate() + { + try { + $existingCert = $this->database->sslCertificates()->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/Livewire/Server/Advanced.php b/app/Livewire/Server/Advanced.php index b269c916f..b2b8b1518 100644 --- a/app/Livewire/Server/Advanced.php +++ b/app/Livewire/Server/Advanced.php @@ -2,7 +2,11 @@ namespace App\Livewire\Server; +use App\Helpers\SslHelper; +use App\Jobs\RegenerateSslCertJob; use App\Models\Server; +use App\Models\SslCertificate; +use Carbon\Carbon; use Livewire\Attributes\Validate; use Livewire\Component; @@ -10,6 +14,14 @@ class Advanced extends Component { public Server $server; + public ?SslCertificate $caCertificate = null; + + public $showCertificate = false; + + public $certificateContent = ''; + + public ?Carbon $certificateValidUntil = null; + public array $parameters = []; #[Validate(['string'])] @@ -30,11 +42,99 @@ class Advanced extends Component $this->server = Server::ownedByCurrentTeam()->whereUuid($server_uuid)->firstOrFail(); $this->parameters = get_route_parameters(); $this->syncData(); + $this->loadCaCertificate(); } catch (\Throwable) { return redirect()->route('server.index'); } } + public function loadCaCertificate() + { + $this->caCertificate = SslCertificate::where('server_id', $this->server->id)->where('is_ca_certificate', true)->first(); + + if ($this->caCertificate) { + $this->certificateContent = $this->caCertificate->ssl_certificate; + $this->certificateValidUntil = $this->caCertificate->valid_until; + } + } + + public function toggleCertificate() + { + $this->showCertificate = ! $this->showCertificate; + } + + public function saveCaCertificate() + { + try { + if (! $this->certificateContent) { + throw new \Exception('Certificate content cannot be empty.'); + } + + if (! openssl_x509_read($this->certificateContent)) { + throw new \Exception('Invalid certificate format.'); + } + + if ($this->caCertificate) { + $this->caCertificate->ssl_certificate = $this->certificateContent; + $this->caCertificate->save(); + + $this->loadCaCertificate(); + + $this->writeCertificateToServer(); + + dispatch(new RegenerateSslCertJob( + server_id: $this->server->id, + force_regeneration: true + )); + } + $this->dispatch('success', 'CA Certificate saved successfully.'); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + public function regenerateCaCertificate() + { + try { + SslHelper::generateSslCertificate( + commonName: 'Coolify CA Certificate', + serverId: $this->server->id, + isCaCertificate: true, + validityDays: 10 * 365 + ); + + $this->loadCaCertificate(); + + $this->writeCertificateToServer(); + + dispatch(new RegenerateSslCertJob( + server_id: $this->server->id, + force_regeneration: true + )); + + $this->loadCaCertificate(); + $this->dispatch('success', 'CA Certificate regenerated successfully.'); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + private function writeCertificateToServer() + { + $caCertPath = config('constants.coolify.base_config_path').'/ssl/'; + + $commands = collect([ + "mkdir -p $caCertPath", + "chown -R 9999:root $caCertPath", + "chmod -R 700 $caCertPath", + "rm -rf $caCertPath/coolify-ca.crt", + "echo '{$this->certificateContent}' > $caCertPath/coolify-ca.crt", + "chmod 644 $caCertPath/coolify-ca.crt", + ]); + + remote_process($commands, $this->server); + } + public function syncData(bool $toModel = false) { if ($toModel) { diff --git a/app/Models/LocalFileVolume.php b/app/Models/LocalFileVolume.php index d96f7125e..8189d6d74 100644 --- a/app/Models/LocalFileVolume.php +++ b/app/Models/LocalFileVolume.php @@ -8,6 +8,12 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; class LocalFileVolume extends BaseModel { + protected $casts = [ + 'fs_path' => 'encrypted', + 'mount_path' => 'encrypted', + 'content' => 'encrypted', + ]; + use HasFactory; protected $guarded = []; diff --git a/app/Models/LocalPersistentVolume.php b/app/Models/LocalPersistentVolume.php index 68e476365..b5dfd9663 100644 --- a/app/Models/LocalPersistentVolume.php +++ b/app/Models/LocalPersistentVolume.php @@ -24,11 +24,6 @@ class LocalPersistentVolume extends Model return $this->morphTo('resource'); } - public function standalone_postgresql() - { - return $this->morphTo('resource'); - } - protected function name(): Attribute { return Attribute::make( diff --git a/app/Models/SslCertificate.php b/app/Models/SslCertificate.php new file mode 100644 index 000000000..eb2175d44 --- /dev/null +++ b/app/Models/SslCertificate.php @@ -0,0 +1,49 @@ + 'encrypted', + 'ssl_private_key' => 'encrypted', + 'subject_alternative_names' => 'array', + 'valid_until' => 'datetime', + ]; + + public function application() + { + return $this->morphTo('resource'); + } + + public function service() + { + return $this->morphTo('resource'); + } + + public function database() + { + return $this->morphTo('resource'); + } + + public function server() + { + return $this->belongsTo(Server::class); + } +} diff --git a/app/Models/StandaloneClickhouse.php b/app/Models/StandaloneClickhouse.php index 60198115d..bc1f9b4b3 100644 --- a/app/Models/StandaloneClickhouse.php +++ b/app/Models/StandaloneClickhouse.php @@ -163,6 +163,11 @@ class StandaloneClickhouse extends BaseModel return data_get($this, 'environment.project'); } + public function sslCertificates() + { + return $this->morphMany(SslCertificate::class, 'resource'); + } + public function link() { if (data_get($this, 'environment.project.uuid')) { @@ -218,7 +223,12 @@ class StandaloneClickhouse extends BaseModel protected function internalDbUrl(): Attribute { return new Attribute( - get: fn () => "clickhouse://{$this->clickhouse_admin_user}:{$this->clickhouse_admin_password}@{$this->uuid}:9000/{$this->clickhouse_db}", + get: function () { + $encodedUser = rawurlencode($this->clickhouse_admin_user); + $encodedPass = rawurlencode($this->clickhouse_admin_password); + + return "clickhouse://{$encodedUser}:{$encodedPass}@{$this->uuid}:9000/{$this->clickhouse_db}"; + }, ); } @@ -227,7 +237,10 @@ class StandaloneClickhouse extends BaseModel return new Attribute( get: function () { if ($this->is_public && $this->public_port) { - return "clickhouse://{$this->clickhouse_admin_user}:{$this->clickhouse_admin_password}@{$this->destination->server->getIp}:{$this->public_port}/{$this->clickhouse_db}"; + $encodedUser = rawurlencode($this->clickhouse_admin_user); + $encodedPass = rawurlencode($this->clickhouse_admin_password); + + return "clickhouse://{$encodedUser}:{$encodedPass}@{$this->destination->server->getIp}:{$this->public_port}/{$this->clickhouse_db}"; } return null; diff --git a/app/Models/StandaloneDragonfly.php b/app/Models/StandaloneDragonfly.php index 3c1127d8d..a14c5e378 100644 --- a/app/Models/StandaloneDragonfly.php +++ b/app/Models/StandaloneDragonfly.php @@ -168,6 +168,11 @@ class StandaloneDragonfly extends BaseModel return data_get($this, 'environment.project.team'); } + public function sslCertificates() + { + return $this->morphMany(SslCertificate::class, 'resource'); + } + public function link() { if (data_get($this, 'environment.project.uuid')) { @@ -218,7 +223,18 @@ class StandaloneDragonfly extends BaseModel protected function internalDbUrl(): Attribute { return new Attribute( - get: fn () => "redis://:{$this->dragonfly_password}@{$this->uuid}:6379/0", + get: function () { + $scheme = $this->enable_ssl ? 'rediss' : 'redis'; + $port = $this->enable_ssl ? 6380 : 6379; + $encodedPass = rawurlencode($this->dragonfly_password); + $url = "{$scheme}://:{$encodedPass}@{$this->uuid}:{$port}/0"; + + if ($this->enable_ssl && $this->ssl_mode === 'verify-ca') { + $url .= '?cacert=/etc/ssl/certs/coolify-ca.crt'; + } + + return $url; + } ); } @@ -227,7 +243,15 @@ class StandaloneDragonfly extends BaseModel return new Attribute( get: function () { if ($this->is_public && $this->public_port) { - return "redis://:{$this->dragonfly_password}@{$this->destination->server->getIp}:{$this->public_port}/0"; + $scheme = $this->enable_ssl ? 'rediss' : 'redis'; + $encodedPass = rawurlencode($this->dragonfly_password); + $url = "{$scheme}://:{$encodedPass}@{$this->destination->server->getIp}:{$this->public_port}/0"; + + if ($this->enable_ssl && $this->ssl_mode === 'verify-ca') { + $url .= '?cacert=/etc/ssl/certs/coolify-ca.crt'; + } + + return $url; } return null; diff --git a/app/Models/StandaloneKeydb.php b/app/Models/StandaloneKeydb.php index ebf1c22e9..2d3aea755 100644 --- a/app/Models/StandaloneKeydb.php +++ b/app/Models/StandaloneKeydb.php @@ -168,6 +168,11 @@ class StandaloneKeydb extends BaseModel return data_get($this, 'environment.project.team'); } + public function sslCertificates() + { + return $this->morphMany(SslCertificate::class, 'resource'); + } + public function link() { if (data_get($this, 'environment.project.uuid')) { @@ -218,7 +223,18 @@ class StandaloneKeydb extends BaseModel protected function internalDbUrl(): Attribute { return new Attribute( - get: fn () => "redis://:{$this->keydb_password}@{$this->uuid}:6379/0", + get: function () { + $scheme = $this->enable_ssl ? 'rediss' : 'redis'; + $port = $this->enable_ssl ? 6380 : 6379; + $encodedPass = rawurlencode($this->keydb_password); + $url = "{$scheme}://:{$encodedPass}@{$this->uuid}:{$port}/0"; + + if ($this->enable_ssl && $this->ssl_mode === 'verify-ca') { + $url .= '?cacert=/etc/ssl/certs/coolify-ca.crt'; + } + + return $url; + } ); } @@ -227,7 +243,15 @@ class StandaloneKeydb extends BaseModel return new Attribute( get: function () { if ($this->is_public && $this->public_port) { - return "redis://:{$this->keydb_password}@{$this->destination->server->getIp}:{$this->public_port}/0"; + $scheme = $this->enable_ssl ? 'rediss' : 'redis'; + $encodedPass = rawurlencode($this->keydb_password); + $url = "{$scheme}://:{$encodedPass}@{$this->destination->server->getIp}:{$this->public_port}/0"; + + if ($this->enable_ssl && $this->ssl_mode === 'verify-ca') { + $url .= '?cacert=/etc/ssl/certs/coolify-ca.crt'; + } + + return $url; } return null; diff --git a/app/Models/StandaloneMariadb.php b/app/Models/StandaloneMariadb.php index 004ead4d9..7549ace3e 100644 --- a/app/Models/StandaloneMariadb.php +++ b/app/Models/StandaloneMariadb.php @@ -218,7 +218,12 @@ class StandaloneMariadb extends BaseModel protected function internalDbUrl(): Attribute { return new Attribute( - get: fn () => "mysql://{$this->mariadb_user}:{$this->mariadb_password}@{$this->uuid}:3306/{$this->mariadb_database}", + get: function () { + $encodedUser = rawurlencode($this->mariadb_user); + $encodedPass = rawurlencode($this->mariadb_password); + + return "mysql://{$encodedUser}:{$encodedPass}@{$this->uuid}:3306/{$this->mariadb_database}"; + }, ); } @@ -227,7 +232,10 @@ class StandaloneMariadb extends BaseModel return new Attribute( get: function () { if ($this->is_public && $this->public_port) { - return "mysql://{$this->mariadb_user}:{$this->mariadb_password}@{$this->destination->server->getIp}:{$this->public_port}/{$this->mariadb_database}"; + $encodedUser = rawurlencode($this->mariadb_user); + $encodedPass = rawurlencode($this->mariadb_password); + + return "mysql://{$encodedUser}:{$encodedPass}@{$this->destination->server->getIp}:{$this->public_port}/{$this->mariadb_database}"; } return null; @@ -271,6 +279,11 @@ class StandaloneMariadb extends BaseModel return $this->morphMany(ScheduledDatabaseBackup::class, 'database'); } + public function sslCertificates() + { + return $this->morphMany(SslCertificate::class, 'resource'); + } + public function getCpuMetrics(int $mins = 5) { $server = $this->destination->server; diff --git a/app/Models/StandaloneMongodb.php b/app/Models/StandaloneMongodb.php index aba0f6123..1b181e7d5 100644 --- a/app/Models/StandaloneMongodb.php +++ b/app/Models/StandaloneMongodb.php @@ -177,6 +177,11 @@ class StandaloneMongodb extends BaseModel return data_get($this, 'is_log_drain_enabled', false); } + public function sslCertificates() + { + return $this->morphMany(SslCertificate::class, 'resource'); + } + public function link() { if (data_get($this, 'environment.project.uuid')) { @@ -238,7 +243,19 @@ 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 () { + $encodedUser = rawurlencode($this->mongo_initdb_root_username); + $encodedPass = rawurlencode($this->mongo_initdb_root_password); + $url = "mongodb://{$encodedUser}:{$encodedPass}@{$this->uuid}:27017/?directConnection=true"; + if ($this->enable_ssl) { + $url .= '&tls=true'; + if (in_array($this->ssl_mode, ['verify-full'])) { + $url .= '&tlsCAFile=/etc/ssl/certs/coolify-ca.crt'; + } + } + + return $url; + }, ); } @@ -247,7 +264,17 @@ 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"; + $encodedUser = rawurlencode($this->mongo_initdb_root_username); + $encodedPass = rawurlencode($this->mongo_initdb_root_password); + $url = "mongodb://{$encodedUser}:{$encodedPass}@{$this->destination->server->getIp}:{$this->public_port}/?directConnection=true"; + if ($this->enable_ssl) { + $url .= '&tls=true'; + if (in_array($this->ssl_mode, ['verify-full'])) { + $url .= '&tlsCAFile=/etc/ssl/certs/coolify-ca.crt'; + } + } + + return $url; } return null; diff --git a/app/Models/StandaloneMysql.php b/app/Models/StandaloneMysql.php index 9ae0fdcae..dbb5b1ae6 100644 --- a/app/Models/StandaloneMysql.php +++ b/app/Models/StandaloneMysql.php @@ -169,6 +169,11 @@ class StandaloneMysql extends BaseModel return data_get($this, 'environment.project.team'); } + public function sslCertificates() + { + return $this->morphMany(SslCertificate::class, 'resource'); + } + public function link() { if (data_get($this, 'environment.project.uuid')) { @@ -219,7 +224,19 @@ 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 () { + $encodedUser = rawurlencode($this->mysql_user); + $encodedPass = rawurlencode($this->mysql_password); + $url = "mysql://{$encodedUser}:{$encodedPass}@{$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 +245,17 @@ 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}"; + $encodedUser = rawurlencode($this->mysql_user); + $encodedPass = rawurlencode($this->mysql_password); + $url = "mysql://{$encodedUser}:{$encodedPass}@{$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/app/Models/StandalonePostgresql.php b/app/Models/StandalonePostgresql.php index dd92ae7c9..a74d567a0 100644 --- a/app/Models/StandalonePostgresql.php +++ b/app/Models/StandalonePostgresql.php @@ -219,7 +219,19 @@ class StandalonePostgresql extends BaseModel protected function internalDbUrl(): Attribute { return new Attribute( - get: fn () => "postgres://{$this->postgres_user}:{$this->postgres_password}@{$this->uuid}:5432/{$this->postgres_db}", + get: function () { + $encodedUser = rawurlencode($this->postgres_user); + $encodedPass = rawurlencode($this->postgres_password); + $url = "postgres://{$encodedUser}:{$encodedPass}@{$this->uuid}:5432/{$this->postgres_db}"; + if ($this->enable_ssl) { + $url .= "?sslmode={$this->ssl_mode}"; + if (in_array($this->ssl_mode, ['verify-ca', 'verify-full'])) { + $url .= '&sslrootcert=/etc/ssl/certs/coolify-ca.crt'; + } + } + + return $url; + }, ); } @@ -228,7 +240,17 @@ class StandalonePostgresql extends BaseModel return new Attribute( get: function () { if ($this->is_public && $this->public_port) { - return "postgres://{$this->postgres_user}:{$this->postgres_password}@{$this->destination->server->getIp}:{$this->public_port}/{$this->postgres_db}"; + $encodedUser = rawurlencode($this->postgres_user); + $encodedPass = rawurlencode($this->postgres_password); + $url = "postgres://{$encodedUser}:{$encodedPass}@{$this->destination->server->getIp}:{$this->public_port}/{$this->postgres_db}"; + if ($this->enable_ssl) { + $url .= "?sslmode={$this->ssl_mode}"; + if (in_array($this->ssl_mode, ['verify-ca', 'verify-full'])) { + $url .= '&sslrootcert=/etc/ssl/certs/coolify-ca.crt'; + } + } + + return $url; } return null; @@ -241,11 +263,21 @@ class StandalonePostgresql extends BaseModel return $this->belongsTo(Environment::class); } + public function persistentStorages() + { + return $this->morphMany(LocalPersistentVolume::class, 'resource'); + } + public function fileStorages() { return $this->morphMany(LocalFileVolume::class, 'resource'); } + public function sslCertificates() + { + return $this->morphMany(SslCertificate::class, 'resource'); + } + public function destination() { return $this->morphTo(); @@ -256,16 +288,17 @@ class StandalonePostgresql extends BaseModel return $this->morphMany(EnvironmentVariable::class, 'resourceable'); } - public function persistentStorages() - { - return $this->morphMany(LocalPersistentVolume::class, 'resource'); - } - public function scheduledBackups() { return $this->morphMany(ScheduledDatabaseBackup::class, 'database'); } + public function environment_variables() + { + return $this->morphMany(EnvironmentVariable::class, 'resourceable') + ->orderBy('key', 'asc'); + } + public function isBackupSolutionAvailable() { return true; @@ -314,10 +347,4 @@ class StandalonePostgresql extends BaseModel return $parsedCollection->toArray(); } - - public function environment_variables() - { - return $this->morphMany(EnvironmentVariable::class, 'resourceable') - ->orderBy('key', 'asc'); - } } diff --git a/app/Models/StandaloneRedis.php b/app/Models/StandaloneRedis.php index 6037364fe..fccbb24a5 100644 --- a/app/Models/StandaloneRedis.php +++ b/app/Models/StandaloneRedis.php @@ -170,6 +170,11 @@ class StandaloneRedis extends BaseModel return data_get($this, 'environment.project.team'); } + public function sslCertificates() + { + return $this->morphMany(SslCertificate::class, 'resource'); + } + public function link() { if (data_get($this, 'environment.project.uuid')) { @@ -222,9 +227,17 @@ class StandaloneRedis extends BaseModel return new Attribute( get: function () { $redis_version = $this->getRedisVersion(); - $username_part = version_compare($redis_version, '6.0', '>=') ? "{$this->redis_username}:" : ''; + $username_part = version_compare($redis_version, '6.0', '>=') ? rawurlencode($this->redis_username).':' : ''; + $encodedPass = rawurlencode($this->redis_password); + $scheme = $this->enable_ssl ? 'rediss' : 'redis'; + $port = $this->enable_ssl ? 6380 : 6379; + $url = "{$scheme}://{$username_part}{$encodedPass}@{$this->uuid}:{$port}/0"; - return "redis://{$username_part}{$this->redis_password}@{$this->uuid}:6379/0"; + if ($this->enable_ssl && $this->ssl_mode === 'verify-ca') { + $url .= '?cacert=/etc/ssl/certs/coolify-ca.crt'; + } + + return $url; } ); } @@ -235,9 +248,16 @@ class StandaloneRedis extends BaseModel get: function () { if ($this->is_public && $this->public_port) { $redis_version = $this->getRedisVersion(); - $username_part = version_compare($redis_version, '6.0', '>=') ? "{$this->redis_username}:" : ''; + $username_part = version_compare($redis_version, '6.0', '>=') ? rawurlencode($this->redis_username).':' : ''; + $encodedPass = rawurlencode($this->redis_password); + $scheme = $this->enable_ssl ? 'rediss' : 'redis'; + $url = "{$scheme}://{$username_part}{$encodedPass}@{$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-ca') { + $url .= '?cacert=/etc/ssl/certs/coolify-ca.crt'; + } + + return $url; } return null; diff --git a/app/Notifications/SslExpirationNotification.php b/app/Notifications/SslExpirationNotification.php new file mode 100644 index 000000000..78e1e8be9 --- /dev/null +++ b/app/Notifications/SslExpirationNotification.php @@ -0,0 +1,151 @@ +onQueue('high'); + $this->resources = collect($resources); + + // Collect URLs for each resource + $this->resources->each(function ($resource) { + if (data_get($resource, 'environment.project.uuid')) { + $routeName = match ($resource->type()) { + 'application' => 'project.application.configuration', + 'database' => 'project.database.configuration', + 'service' => 'project.service.configuration', + default => null + }; + + if ($routeName) { + $route = route($routeName, [ + 'project_uuid' => data_get($resource, 'environment.project.uuid'), + 'environment_uuid' => data_get($resource, 'environment.uuid'), + $resource->type().'_uuid' => data_get($resource, 'uuid'), + ]); + + $settings = instanceSettings(); + if (data_get($settings, 'fqdn')) { + $url = Url::fromString($route); + $url = $url->withPort(null); + $fqdn = data_get($settings, 'fqdn'); + $fqdn = str_replace(['http://', 'https://'], '', $fqdn); + $url = $url->withHost($fqdn); + + $this->urls[$resource->name] = $url->__toString(); + } else { + $this->urls[$resource->name] = $route; + } + } + } + }); + } + + public function via(object $notifiable): array + { + return $notifiable->getEnabledChannels('ssl_certificate_renewal'); + } + + public function toMail(): MailMessage + { + $mail = new MailMessage; + $mail->subject('Coolify: [Action Required] SSL Certificates Renewed - Manual Redeployment Needed'); + $mail->view('emails.ssl-certificate-renewed', [ + 'resources' => $this->resources, + 'urls' => $this->urls, + ]); + + return $mail; + } + + public function toDiscord(): DiscordMessage + { + $resourceNames = $this->resources->pluck('name')->join(', '); + + $message = new DiscordMessage( + title: '🔒 SSL Certificates Renewed', + description: "SSL certificates have been renewed for: {$resourceNames}.\n\n**Action Required:** These resources need to be redeployed manually.", + color: DiscordMessage::warningColor(), + ); + + foreach ($this->urls as $name => $url) { + $message->addField($name, "[View Resource]({$url})"); + } + + return $message; + } + + public function toTelegram(): array + { + $resourceNames = $this->resources->pluck('name')->join(', '); + $message = "Coolify: SSL certificates have been renewed for: {$resourceNames}.\n\nAction Required: These resources need to be redeployed manually for the new SSL certificates to take effect."; + + $buttons = []; + foreach ($this->urls as $name => $url) { + $buttons[] = [ + 'text' => "View {$name}", + 'url' => $url, + ]; + } + + return [ + 'message' => $message, + 'buttons' => $buttons, + ]; + } + + public function toPushover(): PushoverMessage + { + $resourceNames = $this->resources->pluck('name')->join(', '); + $message = "SSL certificates have been renewed for: {$resourceNames}

"; + $message .= 'Action Required: These resources need to be redeployed manually for the new SSL certificates to take effect.'; + + $buttons = []; + foreach ($this->urls as $name => $url) { + $buttons[] = [ + 'text' => "View {$name}", + 'url' => $url, + ]; + } + + return new PushoverMessage( + title: 'SSL Certificates Renewed', + level: 'warning', + message: $message, + buttons: $buttons, + ); + } + + public function toSlack(): SlackMessage + { + $resourceNames = $this->resources->pluck('name')->join(', '); + $description = "SSL certificates have been renewed for: {$resourceNames}\n\n"; + $description .= '**Action Required:** These resources need to be redeployed manually for the new SSL certificates to take effect.'; + + if (! empty($this->urls)) { + $description .= "\n\n**Resource URLs:**\n"; + foreach ($this->urls as $name => $url) { + $description .= "• {$name}: {$url}\n"; + } + } + + return new SlackMessage( + title: '🔒 SSL Certificates Renewed', + description: $description, + color: SlackMessage::warningColor() + ); + } +} diff --git a/app/Traits/HasNotificationSettings.php b/app/Traits/HasNotificationSettings.php index bb088896a..236e4d97c 100644 --- a/app/Traits/HasNotificationSettings.php +++ b/app/Traits/HasNotificationSettings.php @@ -16,6 +16,7 @@ trait HasNotificationSettings 'server_force_disabled', 'general', 'test', + 'ssl_certificate_renewal', ]; /** diff --git a/app/View/Components/Forms/Select.php b/app/View/Components/Forms/Select.php index dd5ba66b7..b75cedaae 100644 --- a/app/View/Components/Forms/Select.php +++ b/app/View/Components/Forms/Select.php @@ -4,7 +4,6 @@ namespace App\View\Components\Forms; use Closure; use Illuminate\Contracts\View\View; -use Illuminate\Support\Str; use Illuminate\View\Component; use Visus\Cuid2\Cuid2; @@ -19,7 +18,7 @@ class Select extends Component public ?string $label = null, public ?string $helper = null, public bool $required = false, - public string $defaultClass = 'select' + public string $defaultClass = 'select w-full' ) { // } @@ -36,8 +35,6 @@ class Select extends Component $this->name = $this->id; } - $this->label = Str::title($this->label); - return view('components.forms.select'); } } diff --git a/bootstrap/helpers/databases.php b/bootstrap/helpers/databases.php index 2fd85337d..48962f89c 100644 --- a/bootstrap/helpers/databases.php +++ b/bootstrap/helpers/databases.php @@ -16,16 +16,12 @@ use Illuminate\Support\Collection; use Illuminate\Support\Facades\Storage; use Visus\Cuid2\Cuid2; -function generate_database_name(string $type): string -{ - return $type.'-database-'.(new Cuid2); -} - function create_standalone_postgresql($environmentId, $destinationUuid, ?array $otherData = null, string $databaseImage = 'postgres:16-alpine'): StandalonePostgresql { $destination = StandaloneDocker::where('uuid', $destinationUuid)->firstOrFail(); $database = new StandalonePostgresql; - $database->name = generate_database_name('postgresql'); + $database->uuid = (new Cuid2); + $database->name = 'postgresql-database-'.$database->uuid; $database->image = $databaseImage; $database->postgres_password = \Illuminate\Support\Str::password(length: 64, symbols: false); $database->environment_id = $environmentId; @@ -43,7 +39,8 @@ function create_standalone_redis($environment_id, $destination_uuid, ?array $oth { $destination = StandaloneDocker::where('uuid', $destination_uuid)->firstOrFail(); $database = new StandaloneRedis; - $database->name = generate_database_name('redis'); + $database->uuid = (new Cuid2); + $database->name = 'redis-database-'.$database->uuid; $redis_password = \Illuminate\Support\Str::password(length: 64, symbols: false); $database->environment_id = $environment_id; $database->destination_id = $destination->id; @@ -76,7 +73,8 @@ function create_standalone_mongodb($environment_id, $destination_uuid, ?array $o { $destination = StandaloneDocker::where('uuid', $destination_uuid)->firstOrFail(); $database = new StandaloneMongodb; - $database->name = generate_database_name('mongodb'); + $database->uuid = (new Cuid2); + $database->name = 'mongodb-database-'.$database->uuid; $database->mongo_initdb_root_password = \Illuminate\Support\Str::password(length: 64, symbols: false); $database->environment_id = $environment_id; $database->destination_id = $destination->id; @@ -93,7 +91,8 @@ function create_standalone_mysql($environment_id, $destination_uuid, ?array $oth { $destination = StandaloneDocker::where('uuid', $destination_uuid)->firstOrFail(); $database = new StandaloneMysql; - $database->name = generate_database_name('mysql'); + $database->uuid = (new Cuid2); + $database->name = 'mysql-database-'.$database->uuid; $database->mysql_root_password = \Illuminate\Support\Str::password(length: 64, symbols: false); $database->mysql_password = \Illuminate\Support\Str::password(length: 64, symbols: false); $database->environment_id = $environment_id; @@ -111,7 +110,8 @@ function create_standalone_mariadb($environment_id, $destination_uuid, ?array $o { $destination = StandaloneDocker::where('uuid', $destination_uuid)->firstOrFail(); $database = new StandaloneMariadb; - $database->name = generate_database_name('mariadb'); + $database->uuid = (new Cuid2); + $database->name = 'mariadb-database-'.$database->uuid; $database->mariadb_root_password = \Illuminate\Support\Str::password(length: 64, symbols: false); $database->mariadb_password = \Illuminate\Support\Str::password(length: 64, symbols: false); $database->environment_id = $environment_id; @@ -129,7 +129,8 @@ function create_standalone_keydb($environment_id, $destination_uuid, ?array $oth { $destination = StandaloneDocker::where('uuid', $destination_uuid)->firstOrFail(); $database = new StandaloneKeydb; - $database->name = generate_database_name('keydb'); + $database->uuid = (new Cuid2); + $database->name = 'keydb-database-'.$database->uuid; $database->keydb_password = \Illuminate\Support\Str::password(length: 64, symbols: false); $database->environment_id = $environment_id; $database->destination_id = $destination->id; @@ -146,7 +147,8 @@ function create_standalone_dragonfly($environment_id, $destination_uuid, ?array { $destination = StandaloneDocker::where('uuid', $destination_uuid)->firstOrFail(); $database = new StandaloneDragonfly; - $database->name = generate_database_name('dragonfly'); + $database->uuid = (new Cuid2); + $database->name = 'dragonfly-database-'.$database->uuid; $database->dragonfly_password = \Illuminate\Support\Str::password(length: 64, symbols: false); $database->environment_id = $environment_id; $database->destination_id = $destination->id; @@ -163,7 +165,8 @@ function create_standalone_clickhouse($environment_id, $destination_uuid, ?array { $destination = StandaloneDocker::where('uuid', $destination_uuid)->firstOrFail(); $database = new StandaloneClickhouse; - $database->name = generate_database_name('clickhouse'); + $database->uuid = (new Cuid2); + $database->name = 'clickhouse-database-'.$database->uuid; $database->clickhouse_admin_password = \Illuminate\Support\Str::password(length: 64, symbols: false); $database->environment_id = $environment_id; $database->destination_id = $destination->id; diff --git a/database/migrations/2025_01_27_102616_add_ssl_fields_to_database_tables.php b/database/migrations/2025_01_27_102616_add_ssl_fields_to_database_tables.php new file mode 100644 index 000000000..14162133a --- /dev/null +++ b/database/migrations/2025_01_27_102616_add_ssl_fields_to_database_tables.php @@ -0,0 +1,70 @@ +boolean('enable_ssl')->default(false); + $table->enum('ssl_mode', ['allow', 'prefer', 'require', 'verify-ca', 'verify-full'])->default('require'); + }); + Schema::table('standalone_mysqls', function (Blueprint $table) { + $table->boolean('enable_ssl')->default(false); + $table->enum('ssl_mode', ['PREFERRED', 'REQUIRED', 'VERIFY_CA', 'VERIFY_IDENTITY'])->default('REQUIRED'); + }); + Schema::table('standalone_mariadbs', function (Blueprint $table) { + $table->boolean('enable_ssl')->default(false); + }); + Schema::table('standalone_redis', function (Blueprint $table) { + $table->boolean('enable_ssl')->default(false); + }); + Schema::table('standalone_keydbs', function (Blueprint $table) { + $table->boolean('enable_ssl')->default(false); + }); + Schema::table('standalone_dragonflies', function (Blueprint $table) { + $table->boolean('enable_ssl')->default(false); + }); + Schema::table('standalone_mongodbs', function (Blueprint $table) { + $table->boolean('enable_ssl')->default(true); + $table->enum('ssl_mode', ['allow', 'prefer', 'require', 'verify-full'])->default('require'); + }); + } + + /** + * Reverse the migrations. + */ + public function down() + { + Schema::table('standalone_postgresqls', function (Blueprint $table) { + $table->dropColumn('enable_ssl'); + $table->dropColumn('ssl_mode'); + }); + Schema::table('standalone_mysqls', function (Blueprint $table) { + $table->dropColumn('enable_ssl'); + $table->dropColumn('ssl_mode'); + }); + Schema::table('standalone_mariadbs', function (Blueprint $table) { + $table->dropColumn('enable_ssl'); + }); + Schema::table('standalone_redis', function (Blueprint $table) { + $table->dropColumn('enable_ssl'); + }); + Schema::table('standalone_keydbs', function (Blueprint $table) { + $table->dropColumn('enable_ssl'); + }); + Schema::table('standalone_dragonflies', function (Blueprint $table) { + $table->dropColumn('enable_ssl'); + }); + Schema::table('standalone_mongodbs', function (Blueprint $table) { + $table->dropColumn('enable_ssl'); + $table->dropColumn('ssl_mode'); + }); + } +}; diff --git a/database/migrations/2025_01_27_153741_create_ssl_certificates_table.php b/database/migrations/2025_01_27_153741_create_ssl_certificates_table.php new file mode 100644 index 000000000..7907fb090 --- /dev/null +++ b/database/migrations/2025_01_27_153741_create_ssl_certificates_table.php @@ -0,0 +1,34 @@ +id(); + $table->text('ssl_certificate'); + $table->text('ssl_private_key'); + $table->text('configuration_dir')->nullable(); + $table->text('mount_path')->nullable(); + $table->string('resource_type')->nullable(); + $table->unsignedBigInteger('resource_id')->nullable(); + $table->unsignedBigInteger('server_id'); + $table->text('common_name'); + $table->json('subject_alternative_names')->nullable(); + $table->timestamp('valid_until'); + $table->boolean('is_ca_certificate')->default(false); + $table->timestamps(); + + $table->foreign('server_id')->references('id')->on('servers'); + }); + } + + public function down() + { + Schema::dropIfExists('ssl_certificates'); + } +}; diff --git a/database/migrations/2025_01_30_125223_encrypt_local_file_volumes_fields.php b/database/migrations/2025_01_30_125223_encrypt_local_file_volumes_fields.php new file mode 100644 index 000000000..c6b4f8514 --- /dev/null +++ b/database/migrations/2025_01_30_125223_encrypt_local_file_volumes_fields.php @@ -0,0 +1,69 @@ +text('mount_path')->nullable()->change(); + }); + + if (DB::table('local_file_volumes')->exists()) { + DB::table('local_file_volumes') + ->orderBy('id') + ->chunk(100, function ($volumes) { + foreach ($volumes as $volume) { + try { + DB::table('local_file_volumes')->where('id', $volume->id)->update([ + 'fs_path' => $volume->fs_path ? Crypt::encryptString($volume->fs_path) : null, + 'mount_path' => $volume->mount_path ? Crypt::encryptString($volume->mount_path) : null, + 'content' => $volume->content ? Crypt::encryptString($volume->content) : null, + ]); + } catch (\Exception $e) { + Log::error('Error encrypting local file volume fields: '.$e->getMessage()); + } + } + }); + } + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('local_file_volumes', function (Blueprint $table) { + $table->string('fs_path')->change(); + $table->string('mount_path')->nullable()->change(); + $table->longText('content')->nullable()->change(); + }); + + if (DB::table('local_file_volumes')->exists()) { + DB::table('local_file_volumes') + ->orderBy('id') + ->chunk(100, function ($volumes) { + foreach ($volumes as $volume) { + try { + DB::table('local_file_volumes')->where('id', $volume->id)->update([ + 'fs_path' => $volume->fs_path ? Crypt::decryptString($volume->fs_path) : null, + 'mount_path' => $volume->mount_path ? Crypt::decryptString($volume->mount_path) : null, + 'content' => $volume->content ? Crypt::decryptString($volume->content) : null, + ]); + } catch (\Exception $e) { + Log::error('Error decrypting local file volume fields: '.$e->getMessage()); + } + } + }); + } + } +}; diff --git a/database/seeders/CaSslCertSeeder.php b/database/seeders/CaSslCertSeeder.php new file mode 100644 index 000000000..09f6cc984 --- /dev/null +++ b/database/seeders/CaSslCertSeeder.php @@ -0,0 +1,43 @@ +id)->where('is_ca_certificate', true)->first(); + + if (! $existingCaCert) { + $caCert = SslHelper::generateSslCertificate( + commonName: 'Coolify CA Certificate', + serverId: $server->id, + isCaCertificate: true, + validityDays: 10 * 365 + ); + } else { + $caCert = $existingCaCert; + } + $caCertPath = config('constants.coolify.base_config_path').'/ssl/'; + + $commands = collect([ + "mkdir -p $caCertPath", + "chown -R 9999:root $caCertPath", + "chmod -R 700 $caCertPath", + "rm -rf $caCertPath/coolify-ca.crt", + "echo '{$caCert->ssl_certificate}' > $caCertPath/coolify-ca.crt", + "chmod 644 $caCertPath/coolify-ca.crt", + ]); + + remote_process($commands, $server); + } + }); + } +} diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 6e66c64f4..e0e7a3ba5 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -28,6 +28,7 @@ class DatabaseSeeder extends Seeder OauthSettingSeeder::class, DisableTwoStepConfirmationSeeder::class, SentinelSeeder::class, + CaSslCertSeeder::class, ]); } } diff --git a/database/seeders/ProductionSeeder.php b/database/seeders/ProductionSeeder.php index bbb9fcb75..058d4c8e4 100644 --- a/database/seeders/ProductionSeeder.php +++ b/database/seeders/ProductionSeeder.php @@ -193,5 +193,6 @@ uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA== $this->call(PopulateSshKeysDirectorySeeder::class); $this->call(SentinelSeeder::class); $this->call(RootUserSeeder::class); + $this->call(CaSslCertSeeder::class); } } diff --git a/resources/js/app.js b/resources/js/app.js index 613b80069..4dcae5f8e 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -1,10 +1,3 @@ -// import { createApp } from "vue"; -// import MagicBar from "./components/MagicBar.vue"; - -// const app = createApp({}); -// app.component("magic-bar", MagicBar); -// app.mount("#vue"); - import { initializeTerminalComponent } from './terminal.js'; ['livewire:navigated', 'alpine:init'].forEach((event) => { diff --git a/resources/js/components/MagicBar.vue b/resources/js/components/MagicBar.vue deleted file mode 100644 index 22af9dff4..000000000 --- a/resources/js/components/MagicBar.vue +++ /dev/null @@ -1,682 +0,0 @@ - - diff --git a/resources/views/components/forms/copy-button.blade.php b/resources/views/components/forms/copy-button.blade.php new file mode 100644 index 000000000..5ed41fe1a --- /dev/null +++ b/resources/views/components/forms/copy-button.blade.php @@ -0,0 +1,17 @@ +@props(['text']) + +
+ + +
diff --git a/resources/views/components/modal-confirmation.blade.php b/resources/views/components/modal-confirmation.blade.php index e98a494c5..cc15de99e 100644 --- a/resources/views/components/modal-confirmation.blade.php +++ b/resources/views/components/modal-confirmation.blade.php @@ -40,7 +40,6 @@ userConfirmationText: '', confirmWithText: @js($confirmWithText && !$disableTwoStepConfirmation), confirmWithPassword: @js($confirmWithPassword && !$disableTwoStepConfirmation), - copied: false, submitAction: @js($submitAction), passwordError: '', selectedActions: @js(collect($checkboxes)->pluck('id')->filter(fn($id) => $this->$id)->values()->all()), @@ -91,13 +90,6 @@ } }); }, - copyConfirmationText() { - navigator.clipboard.writeText(this.confirmationText); - this.copied = true; - setTimeout(() => { - this.copied = false; - }, 2000); - }, toggleAction(id) { const index = this.selectedActions.indexOf(id); if (index > -1) { @@ -255,29 +247,9 @@

Confirm Actions

{{ $confirmationLabel }}

- - +