diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index dba3676cf..c055f1009 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -136,6 +136,7 @@ After installing Docker (or Orbstack) and Spin, verify the installation: - Password: `password` 2. Additional development tools: + | Tool | URL | Note | |------|-----|------| | Laravel Horizon (scheduler) | `http://localhost:8000/horizon` | Only accessible when logged in as root user | diff --git a/app/Actions/Database/StartDatabaseProxy.php b/app/Actions/Database/StartDatabaseProxy.php index d9272356c..c4a40f020 100644 --- a/app/Actions/Database/StartDatabaseProxy.php +++ b/app/Actions/Database/StartDatabaseProxy.php @@ -22,74 +22,27 @@ class StartDatabaseProxy public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse|ServiceDatabase $database) { - $internalPort = null; - $type = $database->getMorphClass(); + $databaseType = $database->database_type; $network = data_get($database, 'destination.network'); $server = data_get($database, 'destination.server'); $containerName = data_get($database, 'uuid'); $proxyContainerName = "{$database->uuid}-proxy"; if ($database->getMorphClass() === \App\Models\ServiceDatabase::class) { $databaseType = $database->databaseType(); - // $connectPredefined = data_get($database, 'service.connect_to_docker_network'); $network = $database->service->uuid; $server = data_get($database, 'service.destination.server'); $proxyContainerName = "{$database->service->uuid}-proxy"; - switch ($databaseType) { - case 'standalone-mariadb': - $type = \App\Models\StandaloneMariadb::class; - $containerName = "mariadb-{$database->service->uuid}"; - break; - case 'standalone-mongodb': - $type = \App\Models\StandaloneMongodb::class; - $containerName = "mongodb-{$database->service->uuid}"; - break; - case 'standalone-mysql': - $type = \App\Models\StandaloneMysql::class; - $containerName = "mysql-{$database->service->uuid}"; - break; - case 'standalone-postgresql': - $type = \App\Models\StandalonePostgresql::class; - $containerName = "postgresql-{$database->service->uuid}"; - break; - case 'standalone-redis': - $type = \App\Models\StandaloneRedis::class; - $containerName = "redis-{$database->service->uuid}"; - break; - case 'standalone-keydb': - $type = \App\Models\StandaloneKeydb::class; - $containerName = "keydb-{$database->service->uuid}"; - break; - case 'standalone-dragonfly': - $type = \App\Models\StandaloneDragonfly::class; - $containerName = "dragonfly-{$database->service->uuid}"; - break; - case 'standalone-clickhouse': - $type = \App\Models\StandaloneClickhouse::class; - $containerName = "clickhouse-{$database->service->uuid}"; - break; - case 'standalone-supabase/postgres': - $type = \App\Models\StandalonePostgresql::class; - $containerName = "supabase-db-{$database->service->uuid}"; - break; - } - } - if ($type === \App\Models\StandaloneRedis::class) { - $internalPort = 6379; - } elseif ($type === \App\Models\StandalonePostgresql::class) { - $internalPort = 5432; - } elseif ($type === \App\Models\StandaloneMongodb::class) { - $internalPort = 27017; - } elseif ($type === \App\Models\StandaloneMysql::class) { - $internalPort = 3306; - } elseif ($type === \App\Models\StandaloneMariadb::class) { - $internalPort = 3306; - } elseif ($type === \App\Models\StandaloneKeydb::class) { - $internalPort = 6379; - } elseif ($type === \App\Models\StandaloneDragonfly::class) { - $internalPort = 6379; - } elseif ($type === \App\Models\StandaloneClickhouse::class) { - $internalPort = 9000; + $containerName = "{$database->name}-{$database->service->uuid}"; } + $internalPort = match ($databaseType) { + 'standalone-mariadb', 'standalone-mysql' => 3306, + 'standalone-postgresql', 'standalone-supabase/postgres' => 5432, + 'standalone-redis', 'standalone-keydb', 'standalone-dragonfly' => 6379, + 'standalone-clickhouse' => 9000, + 'standalone-mongodb' => 27017, + default => throw new \Exception("Unsupported database type: $databaseType"), + }; + $configuration_dir = database_proxy_dir($database->uuid); $nginxconf = <<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/Database/StopDatabase.php b/app/Actions/Database/StopDatabase.php index e4cea7cee..de4eaa31f 100644 --- a/app/Actions/Database/StopDatabase.php +++ b/app/Actions/Database/StopDatabase.php @@ -26,7 +26,7 @@ class StopDatabase } $this->stopContainer($database, $database->uuid, 300); - if (! $isDeleteOperation) { + if ($isDeleteOperation) { if ($dockerCleanup) { CleanupDocker::dispatch($server, true); } 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/Actions/Service/StopService.php b/app/Actions/Service/StopService.php index 95b08b437..e16dd5616 100644 --- a/app/Actions/Service/StopService.php +++ b/app/Actions/Service/StopService.php @@ -23,7 +23,7 @@ class StopService $containersToStop = $service->getContainersToStop(); $service->stopContainers($containersToStop, $server); - if (! $isDeleteOperation) { + if ($isDeleteOperation) { $service->delete_connected_networks($service->uuid); if ($dockerCleanup) { CleanupDocker::dispatch($server, true); 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/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index aef19af23..2aa656320 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -1310,7 +1310,6 @@ class ApplicationsController extends Controller $service->destination_type = $destination->getMorphClass(); $service->save(); - $service->name = "service-$service->uuid"; $service->parse(isNew: true); if ($instantDeploy) { StartService::dispatch($service); diff --git a/app/Http/Controllers/Webhook/Gitea.php b/app/Http/Controllers/Webhook/Gitea.php index dbdd0b27d..87fd2255f 100644 --- a/app/Http/Controllers/Webhook/Gitea.php +++ b/app/Http/Controllers/Webhook/Gitea.php @@ -152,7 +152,7 @@ class Gitea extends Controller } } if ($x_gitea_event === 'pull_request') { - if ($action === 'opened' || $action === 'synchronize' || $action === 'reopened') { + if ($action === 'opened' || $action === 'synchronized' || $action === 'reopened') { if ($application->isPRDeployable()) { $deployment_uuid = new Cuid2; $found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first(); 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/Application/Configuration.php b/app/Livewire/Project/Application/Configuration.php index 56e0caf75..267ca72ad 100644 --- a/app/Livewire/Project/Application/Configuration.php +++ b/app/Livewire/Project/Application/Configuration.php @@ -22,6 +22,7 @@ class Configuration extends Component public function mount() { $this->currentRoute = request()->route()->getName(); + $project = currentTeam() ->projects() ->select('id', 'uuid', 'team_id') @@ -39,6 +40,9 @@ class Configuration extends Component $this->project = $project; $this->environment = $environment; $this->application = $application; + if ($this->application->build_pack === 'dockercompose' && $this->currentRoute === 'project.application.healthcheck') { + return redirect()->route('project.application.configuration', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid, 'application_uuid' => $application->uuid]); + } } public function render() 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/Project/Service/Database.php b/app/Livewire/Project/Service/Database.php index 9f02db05c..c3b7577e9 100644 --- a/app/Livewire/Project/Service/Database.php +++ b/app/Livewire/Project/Service/Database.php @@ -4,7 +4,10 @@ namespace App\Livewire\Project\Service; use App\Actions\Database\StartDatabaseProxy; use App\Actions\Database\StopDatabaseProxy; +use App\Models\InstanceSettings; use App\Models\ServiceDatabase; +use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\Hash; use Livewire\Component; class Database extends Component @@ -15,6 +18,8 @@ class Database extends Component public $fileStorages; + public $parameters; + protected $listeners = ['refreshFileStorages']; protected $rules = [ @@ -34,12 +39,33 @@ class Database extends Component public function mount() { + $this->parameters = get_route_parameters(); if ($this->database->is_public) { $this->db_url_public = $this->database->getServiceDatabaseUrl(); } $this->refreshFileStorages(); } + public function delete($password) + { + if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) { + if (! Hash::check($password, Auth::user()->password)) { + $this->addError('password', 'The provided password is incorrect.'); + + return; + } + } + + try { + $this->database->delete(); + $this->dispatch('success', 'Database deleted.'); + + return redirect()->route('project.service.configuration', $this->parameters); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + public function instantSaveExclude() { $this->submit(); diff --git a/app/Livewire/Project/Service/EditDomain.php b/app/Livewire/Project/Service/EditDomain.php index e89aeda85..fb1c05255 100644 --- a/app/Livewire/Project/Service/EditDomain.php +++ b/app/Livewire/Project/Service/EditDomain.php @@ -43,12 +43,11 @@ class EditDomain extends Component updateCompose($this->application); if (str($this->application->fqdn)->contains(',')) { $this->dispatch('warning', 'Some services do not support multiple domains, which can lead to problems and is NOT RECOMMENDED.

Only use multiple domains if you know what you are doing.'); - } else { - ! $warning && $this->dispatch('success', 'Service saved.'); } $this->application->service->parse(); $this->dispatch('refresh'); $this->dispatch('configurationChanged'); + $this->dispatch('refreshStatus'); } catch (\Throwable $e) { $originalFqdn = $this->application->getOriginal('fqdn'); if ($originalFqdn !== $this->application->fqdn) { diff --git a/app/Livewire/Project/Shared/ScheduledTask/Add.php b/app/Livewire/Project/Shared/ScheduledTask/Add.php index 8ab5f9f27..c286fee5a 100644 --- a/app/Livewire/Project/Shared/ScheduledTask/Add.php +++ b/app/Livewire/Project/Shared/ScheduledTask/Add.php @@ -75,7 +75,7 @@ class Add extends Component public function saveScheduledTask() { try { - $task = new ScheduledTask(); + $task = new ScheduledTask; $task->name = $this->name; $task->command = $this->command; $task->frequency = $this->frequency; diff --git a/app/Livewire/Project/Shared/ScheduledTask/All.php b/app/Livewire/Project/Shared/ScheduledTask/All.php index 1782f3f27..b58e4f97a 100644 --- a/app/Livewire/Project/Shared/ScheduledTask/All.php +++ b/app/Livewire/Project/Shared/ScheduledTask/All.php @@ -2,7 +2,6 @@ namespace App\Livewire\Project\Shared\ScheduledTask; -use App\Models\ScheduledTask; use Illuminate\Support\Collection; use Livewire\Attributes\Locked; use Livewire\Attributes\On; @@ -42,5 +41,4 @@ class All extends Component { $this->resource->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/Livewire/SettingsEmail.php b/app/Livewire/SettingsEmail.php index 15e68306f..4205594a5 100644 --- a/app/Livewire/SettingsEmail.php +++ b/app/Livewire/SettingsEmail.php @@ -114,19 +114,24 @@ class SettingsEmail extends Component public function instantSave(string $type) { try { + $currentSmtpEnabled = $this->settings->smtp_enabled; + $currentResendEnabled = $this->settings->resend_enabled; $this->resetErrorBag(); if ($type === 'SMTP') { $this->submitSmtp(); + $this->resendEnabled = $this->settings->resend_enabled = false; } elseif ($type === 'Resend') { $this->submitResend(); + $this->smtpEnabled = $this->settings->smtp_enabled = false; } + $this->settings->save(); } catch (\Throwable $e) { if ($type === 'SMTP') { - $this->smtpEnabled = false; + $this->smtpEnabled = $currentSmtpEnabled; } elseif ($type === 'Resend') { - $this->resendEnabled = false; + $this->resendEnabled = $currentResendEnabled; } return handleError($e, $this); @@ -156,9 +161,6 @@ class SettingsEmail extends Component 'smtpEncryption.required' => 'Encryption type is required.', ]); - $this->resendEnabled = false; - $this->settings->resend_enabled = false; - $this->settings->smtp_enabled = $this->smtpEnabled; $this->settings->smtp_host = $this->smtpHost; $this->settings->smtp_port = $this->smtpPort; @@ -194,9 +196,6 @@ class SettingsEmail extends Component 'smtpFromName.required' => 'From Name is required.', ]); - $this->smtpEnabled = false; - $this->settings->smtp_enabled = false; - $this->settings->resend_enabled = $this->resendEnabled; $this->settings->resend_api_key = $this->resendApiKey; $this->settings->smtp_from_address = $this->smtpFromAddress; diff --git a/app/Livewire/Source/Github/Change.php b/app/Livewire/Source/Github/Change.php index 20f52c322..e73c9dc73 100644 --- a/app/Livewire/Source/Github/Change.php +++ b/app/Livewire/Source/Github/Change.php @@ -37,6 +37,8 @@ class Change extends Component public $applications; + public $privateKeys; + protected $rules = [ 'github_app.name' => 'required|string', 'github_app.organization' => 'nullable|string', @@ -54,6 +56,7 @@ class Change extends Component 'github_app.metadata' => 'nullable|string', 'github_app.pull_requests' => 'nullable|string', 'github_app.administration' => 'nullable|string', + 'github_app.private_key_id' => 'required|int', ]; public function boot() @@ -65,9 +68,13 @@ class Change extends Component public function checkPermissions() { - GithubAppPermissionJob::dispatchSync($this->github_app); - $this->github_app->refresh()->makeVisible('client_secret')->makeVisible('webhook_secret'); - $this->dispatch('success', 'Github App permissions updated.'); + try { + GithubAppPermissionJob::dispatchSync($this->github_app); + $this->github_app->refresh()->makeVisible('client_secret')->makeVisible('webhook_secret'); + $this->dispatch('success', 'Github App permissions updated.'); + } catch (\Throwable $e) { + return handleError($e, $this); + } } // public function check() @@ -109,6 +116,7 @@ class Change extends Component $github_app_uuid = request()->github_app_uuid; $this->github_app = GithubApp::ownedByCurrentTeam()->whereUuid($github_app_uuid)->firstOrFail(); $this->github_app->makeVisible(['client_secret', 'webhook_secret']); + $this->privateKeys = PrivateKey::ownedByCurrentTeam()->get(); $this->applications = $this->github_app->applications; $settings = instanceSettings(); @@ -243,6 +251,7 @@ class Change extends Component 'github_app.client_secret' => 'required|string', 'github_app.webhook_secret' => 'required|string', 'github_app.is_system_wide' => 'required|bool', + 'github_app.private_key_id' => 'required|int', ]); $this->github_app->save(); $this->dispatch('success', 'Github App updated.'); @@ -251,6 +260,15 @@ class Change extends Component } } + public function createGithubAppManually() + { + $this->github_app->makeVisible('client_secret')->makeVisible('webhook_secret'); + $this->github_app->app_id = '1234567890'; + $this->github_app->installation_id = '1234567890'; + $this->github_app->save(); + $this->dispatch('success', 'Github App updated.'); + } + public function instantSave() { try { diff --git a/app/Models/Application.php b/app/Models/Application.php index 3913ce37a..ad688c93f 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -1065,7 +1065,6 @@ class Application extends BaseModel if ($this->deploymentType() === 'other') { $fullRepoUrl = $customRepository; $base_command = "{$base_command} {$customRepository}"; - $base_command = $this->setGitImportSettings($deployment_uuid, $base_command, public: true); if ($exec_in_docker) { $commands->push(executeInDocker($deployment_uuid, $base_command)); diff --git a/app/Models/LocalFileVolume.php b/app/Models/LocalFileVolume.php index 2c223be77..8189d6d74 100644 --- a/app/Models/LocalFileVolume.php +++ b/app/Models/LocalFileVolume.php @@ -3,14 +3,23 @@ namespace App\Models; use App\Events\FileStorageChanged; +use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; class LocalFileVolume extends BaseModel { + protected $casts = [ + 'fs_path' => 'encrypted', + 'mount_path' => 'encrypted', + 'content' => 'encrypted', + ]; + use HasFactory; protected $guarded = []; + public $appends = ['is_binary']; + protected static function booted() { static::created(function (LocalFileVolume $fileVolume) { @@ -19,6 +28,15 @@ class LocalFileVolume extends BaseModel }); } + protected function isBinary(): Attribute + { + return Attribute::make( + get: function () { + return $this->content === '[binary file]'; + } + ); + } + public function service() { return $this->morphTo('resource'); @@ -44,6 +62,10 @@ class LocalFileVolume extends BaseModel $isFile = instant_remote_process(["test -f $path && echo OK || echo NOK"], $server); if ($isFile === 'OK') { $content = instant_remote_process(["cat $path"], $server, false); + // Check if content contains binary data by looking for null bytes or non-printable characters + if (str_contains($content, "\0") || preg_match('/[\x00-\x08\x0B\x0C\x0E-\x1F]/', $content)) { + $content = '[binary file]'; + } $this->content = $content; $this->is_directory = false; $this->save(); 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 ed5cf9870..fccbb24a5 100644 --- a/app/Models/StandaloneRedis.php +++ b/app/Models/StandaloneRedis.php @@ -38,6 +38,12 @@ class StandaloneRedis extends BaseModel $database->forceFill(['last_online_at' => now()]); } }); + + static::retrieved(function ($database) { + if (! $database->redis_username) { + $database->redis_username = 'default'; + } + }); } protected function serverStatus(): Attribute @@ -164,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')) { @@ -193,8 +204,8 @@ class StandaloneRedis extends BaseModel { return Attribute::make( get: fn () => is_null($this->ports_mappings) - ? [] - : explode(',', $this->ports_mappings), + ? [] + : explode(',', $this->ports_mappings), ); } @@ -216,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; } ); } @@ -229,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; @@ -346,7 +372,12 @@ class StandaloneRedis extends BaseModel get: function () { $username = $this->runtime_environment_variables()->where('key', 'REDIS_USERNAME')->first(); if (! $username) { - return null; + $this->runtime_environment_variables()->create([ + 'key' => 'REDIS_USERNAME', + 'value' => 'default', + ]); + + return 'default'; } return $username->value; diff --git a/app/Notifications/Channels/EmailChannel.php b/app/Notifications/Channels/EmailChannel.php index b8589cd8e..215fae4ea 100644 --- a/app/Notifications/Channels/EmailChannel.php +++ b/app/Notifications/Channels/EmailChannel.php @@ -53,6 +53,7 @@ class EmailChannel if (blank($type)) { throw new Exception('No email settings found.'); } + return; } 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/Providers/EventServiceProvider.php b/app/Providers/EventServiceProvider.php index 428f78cb5..d76ec3037 100644 --- a/app/Providers/EventServiceProvider.php +++ b/app/Providers/EventServiceProvider.php @@ -11,6 +11,7 @@ use Illuminate\Foundation\Events\MaintenanceModeEnabled; use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider; use SocialiteProviders\Authentik\AuthentikExtendSocialite; use SocialiteProviders\Azure\AzureExtendSocialite; +use SocialiteProviders\Google\GoogleExtendSocialite; use SocialiteProviders\Infomaniak\InfomaniakExtendSocialite; use SocialiteProviders\Manager\SocialiteWasCalled; @@ -26,6 +27,7 @@ class EventServiceProvider extends ServiceProvider SocialiteWasCalled::class => [ AzureExtendSocialite::class.'@handle', AuthentikExtendSocialite::class.'@handle', + GoogleExtendSocialite::class.'@handle', InfomaniakExtendSocialite::class.'@handle', ], ProxyStarted::class => [ 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 f2c069ac4..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; @@ -233,15 +236,29 @@ function deleteEmptyBackupFolder($folderPath, Server $server): void function removeOldBackups($backup): void { try { - $processedBackups = deleteOldBackupsLocally($backup); - - if ($backup->save_s3) { - $processedBackups = $processedBackups->merge(deleteOldBackupsFromS3($backup)); + if ($backup->executions) { + $localBackupsToDelete = deleteOldBackupsLocally($backup); + if ($localBackupsToDelete->isNotEmpty()) { + $backup->executions() + ->whereIn('id', $localBackupsToDelete->pluck('id')) + ->update(['local_storage_deleted' => true]); + } } - if ($processedBackups->isNotEmpty()) { - $backup->executions()->whereIn('id', $processedBackups->pluck('id'))->delete(); + if ($backup->save_s3 && $backup->executions) { + $s3BackupsToDelete = deleteOldBackupsFromS3($backup); + if ($s3BackupsToDelete->isNotEmpty()) { + $backup->executions() + ->whereIn('id', $s3BackupsToDelete->pluck('id')) + ->update(['s3_storage_deleted' => true]); + } } + + $backup->executions() + ->where('local_storage_deleted', true) + ->where('s3_storage_deleted', true) + ->delete(); + } catch (\Exception $e) { throw $e; } @@ -255,6 +272,7 @@ function deleteOldBackupsLocally($backup): Collection $successfulBackups = $backup->executions() ->where('status', 'success') + ->where('local_storage_deleted', false) ->orderBy('created_at', 'desc') ->get(); @@ -338,6 +356,7 @@ function deleteOldBackupsFromS3($backup): Collection $successfulBackups = $backup->executions() ->where('status', 'success') + ->where('s3_storage_deleted', false) ->orderBy('created_at', 'desc') ->get(); diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index 17ddcbda0..db3085649 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -4054,29 +4054,24 @@ function defaultNginxConfiguration(): string { return 'server { location / { - root /usr/share/nginx/html; - index index.html index.htm; - try_files $uri $uri.html $uri/index.html $uri/index.htm $uri/ /index.html /index.htm =404; + root /usr/share/nginx/html; + index index.html index.htm; + try_files $uri $uri.html $uri/index.html $uri/index.htm $uri/ =404; } + # Handle 404 errors + error_page 404 /404.html; + location = /404.html { + root /usr/share/nginx/html; + internal; + } + + # Handle server errors (50x) error_page 500 502 503 504 /50x.html; location = /50x.html { root /usr/share/nginx/html; - try_files $uri @redirect_to_index; internal; } - - error_page 404 = @handle_404; - - location @handle_404 { - root /usr/share/nginx/html; - try_files /404.html @redirect_to_index; - internal; - } - - location @redirect_to_index { - return 302 /; - } }'; } diff --git a/bootstrap/helpers/socialite.php b/bootstrap/helpers/socialite.php index 09dffb78a..16870e33d 100644 --- a/bootstrap/helpers/socialite.php +++ b/bootstrap/helpers/socialite.php @@ -29,6 +29,18 @@ function get_socialite_provider(string $provider) return Socialite::driver('authentik')->setConfig($authentik_config); } + if ($provider == 'google') { + $google_config = new \SocialiteProviders\Manager\Config( + $oauth_setting->client_id, + $oauth_setting->client_secret, + $oauth_setting->redirect_uri + ); + + return Socialite::driver('google') + ->setConfig($google_config) + ->with(['hd' => $oauth_setting->tenant]); + } + $config = [ 'client_id' => $oauth_setting->client_id, 'client_secret' => $oauth_setting->client_secret, @@ -39,7 +51,6 @@ function get_socialite_provider(string $provider) 'bitbucket' => \Laravel\Socialite\Two\BitbucketProvider::class, 'github' => \Laravel\Socialite\Two\GithubProvider::class, 'gitlab' => \Laravel\Socialite\Two\GitlabProvider::class, - 'google' => \Laravel\Socialite\Two\GoogleProvider::class, 'infomaniak' => \SocialiteProviders\Infomaniak\Provider::class, ]; diff --git a/composer.json b/composer.json index f01913b5f..e5aeb6126 100644 --- a/composer.json +++ b/composer.json @@ -40,6 +40,7 @@ "resend/resend-laravel": "^0.15.0", "sentry/sentry-laravel": "^4.6", "socialiteproviders/authentik": "^5.2", + "socialiteproviders/google": "^4.1", "socialiteproviders/infomaniak": "^4.0", "socialiteproviders/microsoft-azure": "^5.1", "spatie/laravel-activitylog": "^4.7.3", @@ -125,4 +126,4 @@ "@php artisan key:generate --ansi" ] } -} \ No newline at end of file +} diff --git a/composer.lock b/composer.lock index 65271cb1a..2a3fc1ddb 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "9c1a0833be38d1f058f216dcaa522077", + "content-hash": "dcf6b2f554372a570628d7f85184df7b", "packages": [ { "name": "3sidedcube/laravel-redoc", @@ -1079,16 +1079,16 @@ }, { "name": "brick/math", - "version": "0.12.1", + "version": "0.12.3", "source": { "type": "git", "url": "https://github.com/brick/math.git", - "reference": "f510c0a40911935b77b86859eb5223d58d660df1" + "reference": "866551da34e9a618e64a819ee1e01c20d8a588ba" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/brick/math/zipball/f510c0a40911935b77b86859eb5223d58d660df1", - "reference": "f510c0a40911935b77b86859eb5223d58d660df1", + "url": "https://api.github.com/repos/brick/math/zipball/866551da34e9a618e64a819ee1e01c20d8a588ba", + "reference": "866551da34e9a618e64a819ee1e01c20d8a588ba", "shasum": "" }, "require": { @@ -1097,7 +1097,7 @@ "require-dev": { "php-coveralls/php-coveralls": "^2.2", "phpunit/phpunit": "^10.1", - "vimeo/psalm": "5.16.0" + "vimeo/psalm": "6.8.8" }, "type": "library", "autoload": { @@ -1127,7 +1127,7 @@ ], "support": { "issues": "https://github.com/brick/math/issues", - "source": "https://github.com/brick/math/tree/0.12.1" + "source": "https://github.com/brick/math/tree/0.12.3" }, "funding": [ { @@ -1135,7 +1135,7 @@ "type": "github" } ], - "time": "2023-11-29T23:19:16+00:00" + "time": "2025-02-28T13:11:00+00:00" }, { "name": "carbonphp/carbon-doctrine-types", @@ -2732,16 +2732,16 @@ }, { "name": "laravel/framework", - "version": "v11.44.0", + "version": "v11.44.1", "source": { "type": "git", "url": "https://github.com/laravel/framework.git", - "reference": "e9a33da34815ac1ed46c7e4c477a775f4592f0a7" + "reference": "0883d4175f4e2b5c299e7087ad3c74f2ce195c6d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/framework/zipball/e9a33da34815ac1ed46c7e4c477a775f4592f0a7", - "reference": "e9a33da34815ac1ed46c7e4c477a775f4592f0a7", + "url": "https://api.github.com/repos/laravel/framework/zipball/0883d4175f4e2b5c299e7087ad3c74f2ce195c6d", + "reference": "0883d4175f4e2b5c299e7087ad3c74f2ce195c6d", "shasum": "" }, "require": { @@ -2849,7 +2849,7 @@ "league/flysystem-read-only": "^3.25.1", "league/flysystem-sftp-v3": "^3.25.1", "mockery/mockery": "^1.6.10", - "orchestra/testbench-core": "^9.9.4", + "orchestra/testbench-core": "^9.11.2", "pda/pheanstalk": "^5.0.6", "php-http/discovery": "^1.15", "phpstan/phpstan": "^2.0", @@ -2943,7 +2943,7 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2025-02-24T13:08:54+00:00" + "time": "2025-03-05T15:34:10+00:00" }, { "name": "laravel/horizon", @@ -6944,16 +6944,16 @@ }, { "name": "ramsey/collection", - "version": "2.0.0", + "version": "2.1.0", "source": { "type": "git", "url": "https://github.com/ramsey/collection.git", - "reference": "a4b48764bfbb8f3a6a4d1aeb1a35bb5e9ecac4a5" + "reference": "3c5990b8a5e0b79cd1cf11c2dc1229e58e93f109" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ramsey/collection/zipball/a4b48764bfbb8f3a6a4d1aeb1a35bb5e9ecac4a5", - "reference": "a4b48764bfbb8f3a6a4d1aeb1a35bb5e9ecac4a5", + "url": "https://api.github.com/repos/ramsey/collection/zipball/3c5990b8a5e0b79cd1cf11c2dc1229e58e93f109", + "reference": "3c5990b8a5e0b79cd1cf11c2dc1229e58e93f109", "shasum": "" }, "require": { @@ -6961,25 +6961,22 @@ }, "require-dev": { "captainhook/plugin-composer": "^5.3", - "ergebnis/composer-normalize": "^2.28.3", - "fakerphp/faker": "^1.21", + "ergebnis/composer-normalize": "^2.45", + "fakerphp/faker": "^1.24", "hamcrest/hamcrest-php": "^2.0", - "jangregor/phpstan-prophecy": "^1.0", - "mockery/mockery": "^1.5", + "jangregor/phpstan-prophecy": "^2.1", + "mockery/mockery": "^1.6", "php-parallel-lint/php-console-highlighter": "^1.0", - "php-parallel-lint/php-parallel-lint": "^1.3", - "phpcsstandards/phpcsutils": "^1.0.0-rc1", - "phpspec/prophecy-phpunit": "^2.0", - "phpstan/extension-installer": "^1.2", - "phpstan/phpstan": "^1.9", - "phpstan/phpstan-mockery": "^1.1", - "phpstan/phpstan-phpunit": "^1.3", - "phpunit/phpunit": "^9.5", - "psalm/plugin-mockery": "^1.1", - "psalm/plugin-phpunit": "^0.18.4", - "ramsey/coding-standard": "^2.0.3", - "ramsey/conventional-commits": "^1.3", - "vimeo/psalm": "^5.4" + "php-parallel-lint/php-parallel-lint": "^1.4", + "phpspec/prophecy-phpunit": "^2.3", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-mockery": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^10.5", + "ramsey/coding-standard": "^2.3", + "ramsey/conventional-commits": "^1.6", + "roave/security-advisories": "dev-latest" }, "type": "library", "extra": { @@ -7017,19 +7014,9 @@ ], "support": { "issues": "https://github.com/ramsey/collection/issues", - "source": "https://github.com/ramsey/collection/tree/2.0.0" + "source": "https://github.com/ramsey/collection/tree/2.1.0" }, - "funding": [ - { - "url": "https://github.com/ramsey", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/ramsey/collection", - "type": "tidelift" - } - ], - "time": "2022-12-31T21:50:55+00:00" + "time": "2025-03-02T04:48:29+00:00" }, { "name": "ramsey/uuid", @@ -7549,6 +7536,47 @@ }, "time": "2023-11-07T22:21:16+00:00" }, + { + "name": "socialiteproviders/google", + "version": "4.1.0", + "source": { + "type": "git", + "url": "https://github.com/SocialiteProviders/Google-Plus.git", + "reference": "1cb8f6fb2c0dd0fc8b34e95f69865663fdf0b401" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/SocialiteProviders/Google-Plus/zipball/1cb8f6fb2c0dd0fc8b34e95f69865663fdf0b401", + "reference": "1cb8f6fb2c0dd0fc8b34e95f69865663fdf0b401", + "shasum": "" + }, + "require": { + "ext-json": "*", + "php": "^7.2 || ^8.0", + "socialiteproviders/manager": "~4.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "SocialiteProviders\\Google\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "xstoop", + "email": "myenglishnameisx@gmail.com" + } + ], + "description": "Google OAuth2 Provider for Laravel Socialite", + "support": { + "source": "https://github.com/SocialiteProviders/Google-Plus/tree/4.1.0" + }, + "time": "2020-12-01T23:10:59+00:00" + }, { "name": "socialiteproviders/infomaniak", "version": "4.0.0", @@ -8887,16 +8915,16 @@ }, { "name": "symfony/error-handler", - "version": "v7.2.3", + "version": "v7.2.4", "source": { "type": "git", "url": "https://github.com/symfony/error-handler.git", - "reference": "959a74d044a6db21f4caa6d695648dcb5584cb49" + "reference": "aabf79938aa795350c07ce6464dd1985607d95d5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/error-handler/zipball/959a74d044a6db21f4caa6d695648dcb5584cb49", - "reference": "959a74d044a6db21f4caa6d695648dcb5584cb49", + "url": "https://api.github.com/repos/symfony/error-handler/zipball/aabf79938aa795350c07ce6464dd1985607d95d5", + "reference": "aabf79938aa795350c07ce6464dd1985607d95d5", "shasum": "" }, "require": { @@ -8942,7 +8970,7 @@ "description": "Provides tools to manage errors and ease debugging PHP code", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/error-handler/tree/v7.2.3" + "source": "https://github.com/symfony/error-handler/tree/v7.2.4" }, "funding": [ { @@ -8958,7 +8986,7 @@ "type": "tidelift" } ], - "time": "2025-01-07T09:39:55+00:00" + "time": "2025-02-02T20:27:07+00:00" }, { "name": "symfony/event-dispatcher", @@ -9260,16 +9288,16 @@ }, { "name": "symfony/http-kernel", - "version": "v7.2.3", + "version": "v7.2.4", "source": { "type": "git", "url": "https://github.com/symfony/http-kernel.git", - "reference": "caae9807f8e25a9b43ce8cc6fafab6cf91f0cc9b" + "reference": "9f1103734c5789798fefb90e91de4586039003ed" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-kernel/zipball/caae9807f8e25a9b43ce8cc6fafab6cf91f0cc9b", - "reference": "caae9807f8e25a9b43ce8cc6fafab6cf91f0cc9b", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/9f1103734c5789798fefb90e91de4586039003ed", + "reference": "9f1103734c5789798fefb90e91de4586039003ed", "shasum": "" }, "require": { @@ -9354,7 +9382,7 @@ "description": "Provides a structured process for converting a Request into a Response", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-kernel/tree/v7.2.3" + "source": "https://github.com/symfony/http-kernel/tree/v7.2.4" }, "funding": [ { @@ -9370,7 +9398,7 @@ "type": "tidelift" } ], - "time": "2025-01-29T07:40:13+00:00" + "time": "2025-02-26T11:01:22+00:00" }, { "name": "symfony/mailer", @@ -9454,16 +9482,16 @@ }, { "name": "symfony/mime", - "version": "v7.2.3", + "version": "v7.2.4", "source": { "type": "git", "url": "https://github.com/symfony/mime.git", - "reference": "2fc3b4bd67e4747e45195bc4c98bea4628476204" + "reference": "87ca22046b78c3feaff04b337f33b38510fd686b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mime/zipball/2fc3b4bd67e4747e45195bc4c98bea4628476204", - "reference": "2fc3b4bd67e4747e45195bc4c98bea4628476204", + "url": "https://api.github.com/repos/symfony/mime/zipball/87ca22046b78c3feaff04b337f33b38510fd686b", + "reference": "87ca22046b78c3feaff04b337f33b38510fd686b", "shasum": "" }, "require": { @@ -9518,7 +9546,7 @@ "mime-type" ], "support": { - "source": "https://github.com/symfony/mime/tree/v7.2.3" + "source": "https://github.com/symfony/mime/tree/v7.2.4" }, "funding": [ { @@ -9534,7 +9562,7 @@ "type": "tidelift" } ], - "time": "2025-01-27T11:08:17+00:00" + "time": "2025-02-19T08:51:20+00:00" }, { "name": "symfony/options-resolver", @@ -10321,16 +10349,16 @@ }, { "name": "symfony/process", - "version": "v7.2.0", + "version": "v7.2.4", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "d34b22ba9390ec19d2dd966c40aa9e8462f27a7e" + "reference": "d8f411ff3c7ddc4ae9166fb388d1190a2df5b5cf" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/d34b22ba9390ec19d2dd966c40aa9e8462f27a7e", - "reference": "d34b22ba9390ec19d2dd966c40aa9e8462f27a7e", + "url": "https://api.github.com/repos/symfony/process/zipball/d8f411ff3c7ddc4ae9166fb388d1190a2df5b5cf", + "reference": "d8f411ff3c7ddc4ae9166fb388d1190a2df5b5cf", "shasum": "" }, "require": { @@ -10362,7 +10390,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v7.2.0" + "source": "https://github.com/symfony/process/tree/v7.2.4" }, "funding": [ { @@ -10378,7 +10406,7 @@ "type": "tidelift" } ], - "time": "2024-11-06T14:24:19+00:00" + "time": "2025-02-05T08:33:46+00:00" }, { "name": "symfony/psr-http-message-bridge", @@ -10778,16 +10806,16 @@ }, { "name": "symfony/translation", - "version": "v7.2.2", + "version": "v7.2.4", "source": { "type": "git", "url": "https://github.com/symfony/translation.git", - "reference": "e2674a30132b7cc4d74540d6c2573aa363f05923" + "reference": "283856e6981286cc0d800b53bd5703e8e363f05a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation/zipball/e2674a30132b7cc4d74540d6c2573aa363f05923", - "reference": "e2674a30132b7cc4d74540d6c2573aa363f05923", + "url": "https://api.github.com/repos/symfony/translation/zipball/283856e6981286cc0d800b53bd5703e8e363f05a", + "reference": "283856e6981286cc0d800b53bd5703e8e363f05a", "shasum": "" }, "require": { @@ -10853,7 +10881,7 @@ "description": "Provides tools to internationalize your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/translation/tree/v7.2.2" + "source": "https://github.com/symfony/translation/tree/v7.2.4" }, "funding": [ { @@ -10869,7 +10897,7 @@ "type": "tidelift" } ], - "time": "2024-12-07T08:18:10+00:00" + "time": "2025-02-13T10:27:23+00:00" }, { "name": "symfony/translation-contracts", @@ -15569,12 +15597,12 @@ ], "aliases": [], "minimum-stability": "stable", - "stability-flags": {}, + "stability-flags": [], "prefer-stable": true, "prefer-lowest": false, "platform": { "php": "^8.4" }, - "platform-dev": {}, - "plugin-api-version": "2.6.0" + "platform-dev": [], + "plugin-api-version": "2.3.0" } diff --git a/config/services.php b/config/services.php index 46fd12ec3..d1c4a3699 100644 --- a/config/services.php +++ b/config/services.php @@ -45,4 +45,12 @@ return [ 'client_secret' => env('AUTHENTIK_CLIENT_SECRET'), 'redirect' => env('AUTHENTIK_REDIRECT_URI'), ], + + 'google' => [ + 'client_id' => env('GOOGLE_CLIENT_ID'), + 'client_secret' => env('GOOGLE_CLIENT_SECRET'), + 'redirect' => env('GOOGLE_REDIRECT_URI'), + 'tenant' => env('GOOGLE_TENANT'), + ], + ]; 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/migrations/2025_03_14_140150_add_storage_deletion_tracking_to_backup_executions.php b/database/migrations/2025_03_14_140150_add_storage_deletion_tracking_to_backup_executions.php new file mode 100644 index 000000000..c6af6fc49 --- /dev/null +++ b/database/migrations/2025_03_14_140150_add_storage_deletion_tracking_to_backup_executions.php @@ -0,0 +1,19 @@ +boolean('local_storage_deleted')->default(false); + $table->boolean('s3_storage_deleted')->default(false); + }); + } +}; 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/GithubAppSeeder.php b/database/seeders/GithubAppSeeder.php index 3cfb82e64..b34c00473 100644 --- a/database/seeders/GithubAppSeeder.php +++ b/database/seeders/GithubAppSeeder.php @@ -21,7 +21,7 @@ class GithubAppSeeder extends Seeder 'team_id' => 0, ]); GithubApp::create([ - 'name' => 'coolify-laravel-development-public', + 'name' => 'coolify-laravel-dev-public', 'uuid' => '69420', 'organization' => 'coollabsio', 'api_url' => 'https://api.github.com', 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/other/nightly/install.sh b/other/nightly/install.sh index 31d0a1759..944012f86 100755 --- a/other/nightly/install.sh +++ b/other/nightly/install.sh @@ -1,6 +1,15 @@ #!/bin/bash ## Do not modify this file. You will lose the ability to install and auto-update! +## Environment variables that can be set: +## ROOT_USERNAME - Predefined root username +## ROOT_USER_EMAIL - Predefined root user email +## ROOT_USER_PASSWORD - Predefined root user password +## DOCKER_ADDRESS_POOL_BASE - Custom Docker address pool base (default: 10.0.0.0/8) +## DOCKER_ADDRESS_POOL_SIZE - Custom Docker address pool size (default: 24) +## DOCKER_POOL_FORCE_OVERRIDE - Force override Docker address pool configuration (default: false) +## AUTOUPDATE - Set to "false" to disable auto-updates + set -e # Exit immediately if a command exits with a non-zero status ## $1 could be empty, so we need to disable this check #set -u # Treat unset variables as an error and exit @@ -8,7 +17,7 @@ set -o pipefail # Cause a pipeline to return the status of the last command that CDN="https://cdn.coollabs.io/coolify-nightly" DATE=$(date +"%Y%m%d-%H%M%S") -VERSION="1.7" +VERSION="1.8" DOCKER_VERSION="27.0" # TODO: Ask for a user CURRENT_USER=$USER @@ -27,6 +36,144 @@ ROOT_USERNAME=${ROOT_USERNAME:-} ROOT_USER_EMAIL=${ROOT_USER_EMAIL:-} ROOT_USER_PASSWORD=${ROOT_USER_PASSWORD:-} +# Docker address pool configuration defaults +DOCKER_ADDRESS_POOL_BASE_DEFAULT="10.0.0.0/8" +DOCKER_ADDRESS_POOL_SIZE_DEFAULT=24 + +# Check if environment variables were explicitly provided +DOCKER_POOL_BASE_PROVIDED=false +DOCKER_POOL_SIZE_PROVIDED=false +DOCKER_POOL_FORCE_OVERRIDE=${DOCKER_POOL_FORCE_OVERRIDE:-false} + +if [ -n "${DOCKER_ADDRESS_POOL_BASE+x}" ]; then + DOCKER_POOL_BASE_PROVIDED=true +fi + +if [ -n "${DOCKER_ADDRESS_POOL_SIZE+x}" ]; then + DOCKER_POOL_SIZE_PROVIDED=true +fi + +restart_docker_service() { + # Check if systemctl is available + if command -v systemctl >/dev/null 2>&1; then + systemctl restart docker + if [ $? -eq 0 ]; then + echo " - Docker daemon restarted successfully" + else + echo " - Failed to restart Docker daemon" + return 1 + fi + # Check if service command is available + elif command -v service >/dev/null 2>&1; then + service docker restart + if [ $? -eq 0 ]; then + echo " - Docker daemon restarted successfully" + else + echo " - Failed to restart Docker daemon" + return 1 + fi + # If neither systemctl nor service is available + else + echo " - Error: No service management system found" + return 1 + fi +} + +# Function to compare address pools +compare_address_pools() { + local base1="$1" + local size1="$2" + local base2="$3" + local size2="$4" + + # Normalize CIDR notation for comparison + local ip1=$(echo "$base1" | cut -d'/' -f1) + local prefix1=$(echo "$base1" | cut -d'/' -f2) + local ip2=$(echo "$base2" | cut -d'/' -f1) + local prefix2=$(echo "$base2" | cut -d'/' -f2) + + # Compare IPs and prefixes + if [ "$ip1" = "$ip2" ] && [ "$prefix1" = "$prefix2" ] && [ "$size1" = "$size2" ]; then + return 0 # Pools are the same + else + return 1 # Pools are different + fi +} + +# Docker address pool configuration +DOCKER_ADDRESS_POOL_BASE=${DOCKER_ADDRESS_POOL_BASE:-"$DOCKER_ADDRESS_POOL_BASE_DEFAULT"} +DOCKER_ADDRESS_POOL_SIZE=${DOCKER_ADDRESS_POOL_SIZE:-$DOCKER_ADDRESS_POOL_SIZE_DEFAULT} + +# Load Docker address pool configuration from .env file if it exists and environment variables were not provided +if [ -f "/data/coolify/source/.env" ] && [ "$DOCKER_POOL_BASE_PROVIDED" = false ] && [ "$DOCKER_POOL_SIZE_PROVIDED" = false ]; then + ENV_DOCKER_ADDRESS_POOL_BASE=$(grep -E "^DOCKER_ADDRESS_POOL_BASE=" /data/coolify/source/.env | cut -d '=' -f2 || true) + ENV_DOCKER_ADDRESS_POOL_SIZE=$(grep -E "^DOCKER_ADDRESS_POOL_SIZE=" /data/coolify/source/.env | cut -d '=' -f2 || true) + + if [ -n "$ENV_DOCKER_ADDRESS_POOL_BASE" ]; then + DOCKER_ADDRESS_POOL_BASE="$ENV_DOCKER_ADDRESS_POOL_BASE" + fi + + if [ -n "$ENV_DOCKER_ADDRESS_POOL_SIZE" ]; then + DOCKER_ADDRESS_POOL_SIZE="$ENV_DOCKER_ADDRESS_POOL_SIZE" + fi +fi + +# Check if daemon.json exists and extract existing address pool configuration +EXISTING_POOL_CONFIGURED=false +if [ -f /etc/docker/daemon.json ]; then + if jq -e '.["default-address-pools"]' /etc/docker/daemon.json >/dev/null 2>&1; then + EXISTING_POOL_BASE=$(jq -r '.["default-address-pools"][0].base' /etc/docker/daemon.json 2>/dev/null || true) + EXISTING_POOL_SIZE=$(jq -r '.["default-address-pools"][0].size' /etc/docker/daemon.json 2>/dev/null || true) + + if [ -n "$EXISTING_POOL_BASE" ] && [ -n "$EXISTING_POOL_SIZE" ] && [ "$EXISTING_POOL_BASE" != "null" ] && [ "$EXISTING_POOL_SIZE" != "null" ]; then + echo "Found existing Docker network pool: $EXISTING_POOL_BASE/$EXISTING_POOL_SIZE" + EXISTING_POOL_CONFIGURED=true + + # Check if environment variables were explicitly provided + if [ "$DOCKER_POOL_BASE_PROVIDED" = false ] && [ "$DOCKER_POOL_SIZE_PROVIDED" = false ]; then + DOCKER_ADDRESS_POOL_BASE="$EXISTING_POOL_BASE" + DOCKER_ADDRESS_POOL_SIZE="$EXISTING_POOL_SIZE" + else + # Check if force override is enabled + if [ "$DOCKER_POOL_FORCE_OVERRIDE" = true ]; then + echo "Force override enabled - network pool will be updated with $DOCKER_ADDRESS_POOL_BASE/$DOCKER_ADDRESS_POOL_SIZE." + else + echo "Custom pool provided but force override not enabled - using existing configuration." + echo "To force override, set DOCKER_POOL_FORCE_OVERRIDE=true" + echo "This won't change the existing docker networks, only the pool configuration for the newly created networks." + DOCKER_ADDRESS_POOL_BASE="$EXISTING_POOL_BASE" + DOCKER_ADDRESS_POOL_SIZE="$EXISTING_POOL_SIZE" + DOCKER_POOL_BASE_PROVIDED=false + DOCKER_POOL_SIZE_PROVIDED=false + fi + fi + fi + fi +fi + +# Validate Docker address pool configuration +if ! [[ $DOCKER_ADDRESS_POOL_BASE =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+/[0-9]+$ ]]; then + echo "Warning: Invalid network pool base format: $DOCKER_ADDRESS_POOL_BASE" + if [ "$EXISTING_POOL_CONFIGURED" = true ]; then + echo "Using existing configuration: $EXISTING_POOL_BASE" + DOCKER_ADDRESS_POOL_BASE="$EXISTING_POOL_BASE" + else + echo "Using default configuration: $DOCKER_ADDRESS_POOL_BASE_DEFAULT" + DOCKER_ADDRESS_POOL_BASE="$DOCKER_ADDRESS_POOL_BASE_DEFAULT" + fi +fi + +if ! [[ $DOCKER_ADDRESS_POOL_SIZE =~ ^[0-9]+$ ]] || [ "$DOCKER_ADDRESS_POOL_SIZE" -lt 16 ] || [ "$DOCKER_ADDRESS_POOL_SIZE" -gt 28 ]; then + echo "Warning: Invalid network pool size: $DOCKER_ADDRESS_POOL_SIZE (must be 16-28)" + if [ "$EXISTING_POOL_CONFIGURED" = true ]; then + echo "Using existing configuration: $EXISTING_POOL_SIZE" + DOCKER_ADDRESS_POOL_SIZE="$EXISTING_POOL_SIZE" + else + echo "Using default configuration: $DOCKER_ADDRESS_POOL_SIZE_DEFAULT" + DOCKER_ADDRESS_POOL_SIZE=$DOCKER_ADDRESS_POOL_SIZE_DEFAULT + fi +fi + TOTAL_SPACE=$(df -BG / | awk 'NR==2 {print $2}' | sed 's/G//') AVAILABLE_SPACE=$(df -BG / | awk 'NR==2 {print $4}' | sed 's/G//') REQUIRED_TOTAL_SPACE=30 @@ -35,7 +182,7 @@ WARNING_SPACE=false if [ "$TOTAL_SPACE" -lt "$REQUIRED_TOTAL_SPACE" ]; then WARNING_SPACE=true - cat << EOF + cat <&1 + if ! [ -x "$(command -v docker)" ]; then + curl -s https://get.docker.com | sh -s -- --version ${DOCKER_VERSION} 2>&1 + if ! [ -x "$(command -v docker)" ]; then + echo " - Docker installation failed." + echo " Maybe your OS is not supported?" + echo " - Please visit https://docs.docker.com/engine/install/ and install Docker manually to continue." + exit 1 + fi + fi +} echo -e "3. Check Docker Installation. " if ! [ -x "$(command -v docker)" ]; then echo " - Docker is not installed. Installing Docker. It may take a while." getAJoke case "$OS_TYPE" in - "almalinux") - dnf config-manager --add-repo=https://download.docker.com/linux/centos/docker-ce.repo >/dev/null 2>&1 - dnf install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin >/dev/null 2>&1 + "almalinux") + dnf config-manager --add-repo=https://download.docker.com/linux/centos/docker-ce.repo >/dev/null 2>&1 + dnf install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin >/dev/null 2>&1 + if ! [ -x "$(command -v docker)" ]; then + echo " - Docker could not be installed automatically. Please visit https://docs.docker.com/engine/install/ and install Docker manually to continue." + exit 1 + fi + systemctl start docker >/dev/null 2>&1 + systemctl enable docker >/dev/null 2>&1 + ;; + "alpine") + apk add docker docker-cli-compose >/dev/null 2>&1 + rc-update add docker default >/dev/null 2>&1 + service docker start >/dev/null 2>&1 + if ! [ -x "$(command -v docker)" ]; then + echo " - Failed to install Docker with apk. Try to install it manually." + echo " Please visit https://wiki.alpinelinux.org/wiki/Docker for more information." + exit 1 + fi + ;; + "arch") + pacman -Sy docker docker-compose --noconfirm >/dev/null 2>&1 + systemctl enable docker.service >/dev/null 2>&1 + if ! [ -x "$(command -v docker)" ]; then + echo " - Failed to install Docker with pacman. Try to install it manually." + echo " Please visit https://wiki.archlinux.org/title/docker for more information." + exit 1 + fi + ;; + "amzn") + dnf install docker -y >/dev/null 2>&1 + DOCKER_CONFIG=${DOCKER_CONFIG:-/usr/local/lib/docker} + mkdir -p $DOCKER_CONFIG/cli-plugins >/dev/null 2>&1 + curl -sL https://github.com/docker/compose/releases/latest/download/docker-compose-$(uname -s)-$(uname -m) -o $DOCKER_CONFIG/cli-plugins/docker-compose >/dev/null 2>&1 + chmod +x $DOCKER_CONFIG/cli-plugins/docker-compose >/dev/null 2>&1 + systemctl start docker >/dev/null 2>&1 + systemctl enable docker >/dev/null 2>&1 + if ! [ -x "$(command -v docker)" ]; then + echo " - Failed to install Docker with dnf. Try to install it manually." + echo " Please visit https://www.cyberciti.biz/faq/how-to-install-docker-on-amazon-linux-2/ for more information." + exit 1 + fi + ;; + "centos" | "fedora" | "rhel") + if [ -x "$(command -v dnf5)" ]; then + # dnf5 is available + dnf config-manager addrepo --from-repofile=https://download.docker.com/linux/$OS_TYPE/docker-ce.repo --overwrite >/dev/null 2>&1 + else + # dnf5 is not available, use dnf + dnf config-manager --add-repo=https://download.docker.com/linux/$OS_TYPE/docker-ce.repo >/dev/null 2>&1 + fi + dnf install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin >/dev/null 2>&1 + if ! [ -x "$(command -v docker)" ]; then + echo " - Docker could not be installed automatically. Please visit https://docs.docker.com/engine/install/ and install Docker manually to continue." + exit 1 + fi + systemctl start docker >/dev/null 2>&1 + systemctl enable docker >/dev/null 2>&1 + ;; + "ubuntu" | "debian" | "raspbian") + if [ "$OS_TYPE" = "ubuntu" ] && [ "$OS_VERSION" = "24.10" ]; then + echo " - Installing Docker for Ubuntu 24.10..." + apt-get update >/dev/null + apt-get install -y ca-certificates curl >/dev/null + install -m 0755 -d /etc/apt/keyrings + curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc + chmod a+r /etc/apt/keyrings/docker.asc + + # Add the repository to Apt sources + echo \ + "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu \ + $(. /etc/os-release && echo "${UBUNTU_CODENAME:-$VERSION_CODENAME}") stable" | + tee /etc/apt/sources.list.d/docker.list >/dev/null + apt-get update >/dev/null + apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin >/dev/null + if ! [ -x "$(command -v docker)" ]; then - echo " - Docker could not be installed automatically. Please visit https://docs.docker.com/engine/install/ and install Docker manually to continue." + echo " - Docker installation failed." + echo " Please visit https://docs.docker.com/engine/install/ubuntu/ and install Docker manually to continue." exit 1 fi - systemctl start docker >/dev/null 2>&1 - systemctl enable docker >/dev/null 2>&1 - ;; - "alpine") - apk add docker docker-cli-compose >/dev/null 2>&1 - rc-update add docker default >/dev/null 2>&1 - service docker start >/dev/null 2>&1 - if ! [ -x "$(command -v docker)" ]; then - echo " - Failed to install Docker with apk. Try to install it manually." - echo " Please visit https://wiki.alpinelinux.org/wiki/Docker for more information." - exit 1 - fi - ;; - "arch") - pacman -Sy docker docker-compose --noconfirm >/dev/null 2>&1 - systemctl enable docker.service >/dev/null 2>&1 - if ! [ -x "$(command -v docker)" ]; then - echo " - Failed to install Docker with pacman. Try to install it manually." - echo " Please visit https://wiki.archlinux.org/title/docker for more information." - exit 1 - fi - ;; - "amzn") - dnf install docker -y >/dev/null 2>&1 - DOCKER_CONFIG=${DOCKER_CONFIG:-/usr/local/lib/docker} - mkdir -p $DOCKER_CONFIG/cli-plugins >/dev/null 2>&1 - curl -sL https://github.com/docker/compose/releases/latest/download/docker-compose-$(uname -s)-$(uname -m) -o $DOCKER_CONFIG/cli-plugins/docker-compose >/dev/null 2>&1 - chmod +x $DOCKER_CONFIG/cli-plugins/docker-compose >/dev/null 2>&1 - systemctl start docker >/dev/null 2>&1 - systemctl enable docker >/dev/null 2>&1 - if ! [ -x "$(command -v docker)" ]; then - echo " - Failed to install Docker with dnf. Try to install it manually." - echo " Please visit https://www.cyberciti.biz/faq/how-to-install-docker-on-amazon-linux-2/ for more information." - exit 1 - fi - ;; - "fedora") - if [ -x "$(command -v dnf5)" ]; then - # dnf5 is available - dnf config-manager addrepo --from-repofile=https://download.docker.com/linux/fedora/docker-ce.repo --overwrite >/dev/null 2>&1 - else - # dnf5 is not available, use dnf - dnf config-manager --add-repo=https://download.docker.com/linux/fedora/docker-ce.repo >/dev/null 2>&1 - fi - dnf install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin >/dev/null 2>&1 - if ! [ -x "$(command -v docker)" ]; then - echo " - Docker could not be installed automatically. Please visit https://docs.docker.com/engine/install/ and install Docker manually to continue." - exit 1 - fi - systemctl start docker >/dev/null 2>&1 - systemctl enable docker >/dev/null 2>&1 - ;; - *) - if [ "$OS_TYPE" = "ubuntu" ] && [ "$OS_VERSION" = "24.10" ]; then - echo "Docker automated installation is not supported on Ubuntu 24.10 (non-LTS release)." - echo "Please install Docker manually." - exit 1 - fi - curl -s https://releases.rancher.com/install-docker/${DOCKER_VERSION}.sh | sh 2>&1 - if ! [ -x "$(command -v docker)" ]; then - curl -s https://get.docker.com | sh -s -- --version ${DOCKER_VERSION} 2>&1 - if ! [ -x "$(command -v docker)" ]; then - echo " - Docker installation failed." - echo " Maybe your OS is not supported?" - echo " - Please visit https://docs.docker.com/engine/install/ and install Docker manually to continue." - exit 1 - fi - fi + echo " - Docker installed successfully for Ubuntu 24.10." + else + install_docker + fi + ;; + *) + install_docker + ;; esac echo " - Docker installed successfully." else @@ -375,82 +544,132 @@ else fi echo -e "4. Check Docker Configuration. " + +echo " - Network pool configuration: ${DOCKER_ADDRESS_POOL_BASE}/${DOCKER_ADDRESS_POOL_SIZE}" +echo " - To override existing configuration: DOCKER_POOL_FORCE_OVERRIDE=true" + mkdir -p /etc/docker -# shellcheck disable=SC2015 -test -s /etc/docker/daemon.json && cp /etc/docker/daemon.json /etc/docker/daemon.json.original-"$DATE" || cat >/etc/docker/daemon.json </etc/docker/daemon.json.coolify <"$TEMP_FILE"; then - echo "Error merging JSON files" - exit 1 + +# Backup original daemon.json if it exists +if [ -f /etc/docker/daemon.json ]; then + cp /etc/docker/daemon.json /etc/docker/daemon.json.original-"$DATE" fi -mv "$TEMP_FILE" /etc/docker/daemon.json -restart_docker_service() { - # Check if systemctl is available - if command -v systemctl >/dev/null 2>&1; then - echo " - Using systemctl to restart Docker." - systemctl restart docker +# Create coolify configuration with or without address pools based on whether they were explicitly provided +if [ "$DOCKER_POOL_FORCE_OVERRIDE" = true ] || [ "$EXISTING_POOL_CONFIGURED" = false ]; then + # First check if the configuration would actually change anything + if [ -f /etc/docker/daemon.json ]; then + CURRENT_POOL_BASE=$(jq -r '.["default-address-pools"][0].base' /etc/docker/daemon.json 2>/dev/null) + CURRENT_POOL_SIZE=$(jq -r '.["default-address-pools"][0].size' /etc/docker/daemon.json 2>/dev/null) - if [ $? -eq 0 ]; then - echo " - Docker restarted successfully using systemctl." + if [ "$CURRENT_POOL_BASE" = "$DOCKER_ADDRESS_POOL_BASE" ] && [ "$CURRENT_POOL_SIZE" = "$DOCKER_ADDRESS_POOL_SIZE" ]; then + echo " - Network pool configuration unchanged, skipping update" + NEED_MERGE=false else - echo " - Failed to restart Docker using systemctl." - return 1 - fi - - # Check if service command is available - elif command -v service >/dev/null 2>&1; then - echo " - Using service command to restart Docker." - service docker restart - - if [ $? -eq 0 ]; then - echo " - Docker restarted successfully using service." - else - echo " - Failed to restart Docker using service." - return 1 - fi - - # If neither systemctl nor service is available - else - echo " - Neither systemctl nor service command is available on this system." - return 1 - fi + # If force override is enabled or no existing configuration exists, + # create a new configuration with the specified address pools + echo " - Creating new Docker configuration with network pool: ${DOCKER_ADDRESS_POOL_BASE}/${DOCKER_ADDRESS_POOL_SIZE}" + cat >/etc/docker/daemon.json </etc/docker/daemon.json </dev/null 2>&1; then + echo " - Log configuration is up to date" + NEED_MERGE=false + else + # Create a configuration without address pools to preserve existing ones + cat >/etc/docker/daemon.json.coolify </etc/docker/daemon.json < $ENV_FILE +awk -F '=' '!seen[$1]++' "$ENV_FILE-$DATE" /data/coolify/source/.env.production >$ENV_FILE if [ "$AUTOUPDATE" = "false" ]; then if ! grep -q "AUTOUPDATE=" /data/coolify/source/.env; then @@ -510,6 +729,26 @@ if [ "$AUTOUPDATE" = "false" ]; then sed -i "s|AUTOUPDATE=.*|AUTOUPDATE=false|g" /data/coolify/source/.env fi fi + +# Save Docker address pool configuration to .env file +if ! grep -q "DOCKER_ADDRESS_POOL_BASE=" /data/coolify/source/.env; then + echo "DOCKER_ADDRESS_POOL_BASE=$DOCKER_ADDRESS_POOL_BASE" >>/data/coolify/source/.env +else + # Only update if explicitly provided + if [ "$DOCKER_POOL_BASE_PROVIDED" = true ]; then + sed -i "s|DOCKER_ADDRESS_POOL_BASE=.*|DOCKER_ADDRESS_POOL_BASE=$DOCKER_ADDRESS_POOL_BASE|g" /data/coolify/source/.env + fi +fi + +if ! grep -q "DOCKER_ADDRESS_POOL_SIZE=" /data/coolify/source/.env; then + echo "DOCKER_ADDRESS_POOL_SIZE=$DOCKER_ADDRESS_POOL_SIZE" >>/data/coolify/source/.env +else + # Only update if explicitly provided + if [ "$DOCKER_POOL_SIZE_PROVIDED" = true ]; then + sed -i "s|DOCKER_ADDRESS_POOL_SIZE=.*|DOCKER_ADDRESS_POOL_SIZE=$DOCKER_ADDRESS_POOL_SIZE|g" /data/coolify/source/.env + fi +fi + echo -e "8. Checking for SSH key for localhost access." if [ ! -f ~/.ssh/authorized_keys ]; then mkdir -p ~/.ssh @@ -527,7 +766,7 @@ if [ "$IS_COOLIFY_VOLUME_EXISTS" -eq 0 ]; then ssh-keygen -t ed25519 -a 100 -f /data/coolify/ssh/keys/id.$CURRENT_USER@host.docker.internal -q -N "" -C coolify chown 9999 /data/coolify/ssh/keys/id.$CURRENT_USER@host.docker.internal sed -i "/coolify/d" ~/.ssh/authorized_keys - cat /data/coolify/ssh/keys/id.$CURRENT_USER@host.docker.internal.pub >> ~/.ssh/authorized_keys + cat /data/coolify/ssh/keys/id.$CURRENT_USER@host.docker.internal.pub >>~/.ssh/authorized_keys rm -f /data/coolify/ssh/keys/id.$CURRENT_USER@host.docker.internal.pub fi diff --git a/public/coolify-logo.svg b/public/coolify-logo.svg new file mode 100644 index 000000000..6f4f641f5 --- /dev/null +++ b/public/coolify-logo.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/public/svgs/denoKV.svg b/public/svgs/denoKV.svg new file mode 100644 index 000000000..799fcf865 --- /dev/null +++ b/public/svgs/denoKV.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/svgs/freescout.png b/public/svgs/freescout.png new file mode 100644 index 000000000..ff282fbc4 Binary files /dev/null and b/public/svgs/freescout.png differ diff --git a/public/svgs/wakapi.svg b/public/svgs/wakapi.svg new file mode 100644 index 000000000..1f5dfb0c0 --- /dev/null +++ b/public/svgs/wakapi.svg @@ -0,0 +1,150 @@ + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/resources/css/app.css b/resources/css/app.css index f89d65d80..175ac3259 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -25,11 +25,6 @@ body { @apply hidden !important; } -.input, -.select { - @apply text-black dark:bg-coolgray-100 dark:text-white ring-neutral-200 dark:ring-coolgray-300; -} - .input-sticky { @apply text-black dark:bg-coolgray-100 dark:text-white ring-neutral-200 dark:ring-coolgray-300 focus:ring-2 dark:focus:ring-coolgray-300 focus:ring-neutral-400 block w-full py-1.5 rounded border-0 text-sm ring-1 ring-inset; } @@ -51,7 +46,11 @@ body { .input, .select { - @apply block w-full py-1.5 rounded border-0 text-sm ring-1 ring-inset; + @apply text-black dark:bg-coolgray-100 dark:text-white ring-neutral-200 dark:ring-coolgray-300 block w-full py-1.5 rounded border-0 text-sm ring-1 ring-inset; +} + +.select { + @apply w-full; } .input[type="password"] { 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 }}

- - +