Merge pull request #5027 from peaklabs-dev/feat-db-ssl

feat: Full SSL Support for all Databases
This commit is contained in:
Andras Bacsai
2025-03-17 15:22:01 +01:00
committed by GitHub
57 changed files with 2765 additions and 954 deletions

View File

@@ -2,6 +2,8 @@
namespace App\Actions\Database;
use App\Helpers\SslHelper;
use App\Models\SslCertificate;
use App\Models\StandaloneDragonfly;
use Lorisleiva\Actions\Concerns\AsAction;
use Symfony\Component\Yaml\Yaml;
@@ -16,24 +18,70 @@ class StartDragonfly
public string $configuration_dir;
private ?SslCertificate $ssl_certificate = null;
public function handle(StandaloneDragonfly $database)
{
$this->database = $database;
$startCommand = "dragonfly --requirepass {$this->database->dragonfly_password}";
$container_name = $this->database->uuid;
$this->configuration_dir = database_configuration_dir().'/'.$container_name;
$this->commands = [
"echo 'Starting database.'",
"echo 'Creating directories.'",
"mkdir -p $this->configuration_dir",
"echo 'Directories created successfully.'",
];
if (! $this->database->enable_ssl) {
$this->commands[] = "rm -rf $this->configuration_dir/ssl";
$this->database->sslCertificates()->delete();
$this->database->fileStorages()
->where('resource_type', $this->database->getMorphClass())
->where('resource_id', $this->database->id)
->get()
->filter(function ($storage) {
return in_array($storage->mount_path, [
'/etc/dragonfly/certs/server.crt',
'/etc/dragonfly/certs/server.key',
]);
})
->each(function ($storage) {
$storage->delete();
});
} else {
$this->commands[] = "echo 'Setting up SSL for this database.'";
$this->commands[] = "mkdir -p $this->configuration_dir/ssl";
$server = $this->database->destination->server;
$caCert = SslCertificate::where('server_id', $server->id)->where('is_ca_certificate', true)->first();
$this->ssl_certificate = $this->database->sslCertificates()->first();
if (! $this->ssl_certificate) {
$this->commands[] = "echo 'No SSL certificate found, generating new SSL certificate for this database.'";
$this->ssl_certificate = SslHelper::generateSslCertificate(
commonName: $this->database->uuid,
resourceType: $this->database->getMorphClass(),
resourceId: $this->database->id,
serverId: $server->id,
caCert: $caCert->ssl_certificate,
caKey: $caCert->ssl_private_key,
configurationDir: $this->configuration_dir,
mountPath: '/etc/dragonfly/certs',
);
}
}
$container_name = $this->database->uuid;
$this->configuration_dir = database_configuration_dir().'/'.$container_name;
$persistent_storages = $this->generate_local_persistent_volumes();
$persistent_file_volumes = $this->database->fileStorages()->get();
$volume_names = $this->generate_local_persistent_volumes_only_volume_names();
$environment_variables = $this->generate_environment_variables();
$startCommand = $this->buildStartCommand();
$docker_compose = [
'services' => [
@@ -70,27 +118,55 @@ class StartDragonfly
],
],
];
if (! is_null($this->database->limits_cpuset)) {
data_set($docker_compose, "services.{$container_name}.cpuset", $this->database->limits_cpuset);
}
if ($this->database->destination->server->isLogDrainEnabled() && $this->database->isLogDrainEnabled()) {
$docker_compose['services'][$container_name]['logging'] = generate_fluentd_configuration();
}
if (count($this->database->ports_mappings_array) > 0) {
$docker_compose['services'][$container_name]['ports'] = $this->database->ports_mappings_array;
}
$docker_compose['services'][$container_name]['volumes'] ??= [];
if (count($persistent_storages) > 0) {
$docker_compose['services'][$container_name]['volumes'] = $persistent_storages;
$docker_compose['services'][$container_name]['volumes'] = array_merge(
$docker_compose['services'][$container_name]['volumes'],
$persistent_storages
);
}
if (count($persistent_file_volumes) > 0) {
$docker_compose['services'][$container_name]['volumes'] = $persistent_file_volumes->map(function ($item) {
return "$item->fs_path:$item->mount_path";
})->toArray();
$docker_compose['services'][$container_name]['volumes'] = array_merge(
$docker_compose['services'][$container_name]['volumes'],
$persistent_file_volumes->map(function ($item) {
return "$item->fs_path:$item->mount_path";
})->toArray()
);
}
if (count($volume_names) > 0) {
$docker_compose['volumes'] = $volume_names;
}
if ($this->database->enable_ssl) {
$docker_compose['services'][$container_name]['volumes'] = array_merge(
$docker_compose['services'][$container_name]['volumes'] ?? [],
[
[
'type' => 'bind',
'source' => '/data/coolify/ssl/coolify-ca.crt',
'target' => '/etc/dragonfly/certs/coolify-ca.crt',
'read_only' => true,
],
]
);
}
// Add custom docker run options
$docker_run_options = convertDockerRunToCompose($this->database->custom_docker_run_options);
$docker_compose = generateCustomDockerRunOptionsForDatabases($docker_run_options, $docker_compose, $container_name, $this->database->destination->network);
@@ -102,12 +178,32 @@ class StartDragonfly
$this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md";
$this->commands[] = "echo 'Pulling {$database->image} image.'";
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml pull";
if ($this->database->enable_ssl) {
$this->commands[] = "chown -R 999:999 $this->configuration_dir/ssl/server.key $this->configuration_dir/ssl/server.crt";
}
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
$this->commands[] = "echo 'Database started.'";
return remote_process($this->commands, $database->destination->server, callEventOnFinish: 'DatabaseStatusChanged');
}
private function buildStartCommand(): string
{
$command = "dragonfly --requirepass {$this->database->dragonfly_password}";
if ($this->database->enable_ssl) {
$sslArgs = [
'--tls',
'--tls_cert_file /etc/dragonfly/certs/server.crt',
'--tls_key_file /etc/dragonfly/certs/server.key',
'--tls_ca_cert_file /etc/dragonfly/certs/coolify-ca.crt',
];
$command .= ' '.implode(' ', $sslArgs);
}
return $command;
}
private function generate_local_persistent_volumes()
{
$local_persistent_volumes = [];

View File

@@ -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;
}
}

View File

@@ -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');
}

View File

@@ -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');

View File

@@ -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');

View File

@@ -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');

View File

@@ -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;
}

View File

@@ -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: <a target="_blank" class="underline" href="https://coolify.io/docs/installation#manually">documentation</a>.');
}
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": {

View File

@@ -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();
}

233
app/Helpers/SslHelper.php Normal file
View File

@@ -0,0 +1,233 @@
<?php
namespace App\Helpers;
use App\Models\Server;
use App\Models\SslCertificate;
use Carbon\CarbonImmutable;
class SslHelper
{
private const DEFAULT_ORGANIZATION_NAME = 'Coolify';
private const DEFAULT_COUNTRY_NAME = 'XX';
private const DEFAULT_STATE_NAME = 'Default';
public static function generateSslCertificate(
string $commonName,
array $subjectAlternativeNames = [],
?string $resourceType = null,
?int $resourceId = null,
?int $serverId = null,
int $validityDays = 365,
?string $caCert = null,
?string $caKey = null,
bool $isCaCertificate = false,
?string $configurationDir = null,
?string $mountPath = null,
bool $isPemKeyFileRequired = false,
): SslCertificate {
$organizationName = self::DEFAULT_ORGANIZATION_NAME;
$countryName = self::DEFAULT_COUNTRY_NAME;
$stateName = self::DEFAULT_STATE_NAME;
try {
$privateKey = openssl_pkey_new([
'private_key_type' => 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 = <<<CONF
[ req ]
prompt = no
distinguished_name = distinguished_name
req_extensions = req_ext
[ distinguished_name ]
CN = $commonName
[ req_ext ]
basicConstraints = $basicConstraints
keyUsage = $keyUsage
{$extendedKeyUsageSection}
[ v3_req ]
basicConstraints = $basicConstraints
keyUsage = $keyUsage
{$extendedKeyUsageSection}
subjectKeyIdentifier = hash
{$subjectAltNameSection}
CONF;
$tempConfig = tmpfile();
fwrite($tempConfig, $config);
$tempConfigPath = stream_get_meta_data($tempConfig)['uri'];
$csr = openssl_csr_new([
'commonName' => $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);
}
}
}

View File

@@ -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);

View File

@@ -0,0 +1,73 @@
<?php
namespace App\Jobs;
use App\Helpers\SSLHelper;
use App\Models\SslCertificate;
use App\Models\Team;
use App\Notifications\SslExpirationNotification;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
class RegenerateSslCertJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public $tries = 3;
public $backoff = 60;
public function __construct(
protected ?Team $team = null,
protected ?int $server_id = null,
protected bool $force_regeneration = false,
) {}
public function handle()
{
$query = SslCertificate::query();
if ($this->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));
}
}
}

View File

@@ -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);
}
}
}

View File

@@ -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);
}
}
}

View File

@@ -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();

View File

@@ -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();

View File

@@ -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();

View File

@@ -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 {

View File

@@ -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();

View File

@@ -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) {

View File

@@ -8,6 +8,12 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
class LocalFileVolume extends BaseModel
{
protected $casts = [
'fs_path' => 'encrypted',
'mount_path' => 'encrypted',
'content' => 'encrypted',
];
use HasFactory;
protected $guarded = [];

View File

@@ -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(

View File

@@ -0,0 +1,49 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class SslCertificate extends Model
{
protected $fillable = [
'ssl_certificate',
'ssl_private_key',
'configuration_dir',
'mount_path',
'resource_type',
'resource_id',
'server_id',
'common_name',
'subject_alternative_names',
'valid_until',
'is_ca_certificate',
];
protected $casts = [
'ssl_certificate' => '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);
}
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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');
}
}

View File

@@ -170,6 +170,11 @@ class StandaloneRedis extends BaseModel
return data_get($this, 'environment.project.team');
}
public function sslCertificates()
{
return $this->morphMany(SslCertificate::class, 'resource');
}
public function link()
{
if (data_get($this, 'environment.project.uuid')) {
@@ -222,9 +227,17 @@ class StandaloneRedis extends BaseModel
return new Attribute(
get: function () {
$redis_version = $this->getRedisVersion();
$username_part = version_compare($redis_version, '6.0', '>=') ? "{$this->redis_username}:" : '';
$username_part = version_compare($redis_version, '6.0', '>=') ? rawurlencode($this->redis_username).':' : '';
$encodedPass = rawurlencode($this->redis_password);
$scheme = $this->enable_ssl ? 'rediss' : 'redis';
$port = $this->enable_ssl ? 6380 : 6379;
$url = "{$scheme}://{$username_part}{$encodedPass}@{$this->uuid}:{$port}/0";
return "redis://{$username_part}{$this->redis_password}@{$this->uuid}:6379/0";
if ($this->enable_ssl && $this->ssl_mode === 'verify-ca') {
$url .= '?cacert=/etc/ssl/certs/coolify-ca.crt';
}
return $url;
}
);
}
@@ -235,9 +248,16 @@ class StandaloneRedis extends BaseModel
get: function () {
if ($this->is_public && $this->public_port) {
$redis_version = $this->getRedisVersion();
$username_part = version_compare($redis_version, '6.0', '>=') ? "{$this->redis_username}:" : '';
$username_part = version_compare($redis_version, '6.0', '>=') ? rawurlencode($this->redis_username).':' : '';
$encodedPass = rawurlencode($this->redis_password);
$scheme = $this->enable_ssl ? 'rediss' : 'redis';
$url = "{$scheme}://{$username_part}{$encodedPass}@{$this->destination->server->getIp}:{$this->public_port}/0";
return "redis://{$username_part}{$this->redis_password}@{$this->destination->server->getIp}:{$this->public_port}/0";
if ($this->enable_ssl && $this->ssl_mode === 'verify-ca') {
$url .= '?cacert=/etc/ssl/certs/coolify-ca.crt';
}
return $url;
}
return null;

View File

@@ -0,0 +1,151 @@
<?php
namespace App\Notifications;
use App\Notifications\Dto\DiscordMessage;
use App\Notifications\Dto\PushoverMessage;
use App\Notifications\Dto\SlackMessage;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Support\Collection;
use Spatie\Url\Url;
class SslExpirationNotification extends CustomEmailNotification
{
protected Collection $resources;
protected array $urls = [];
public function __construct(array|Collection $resources)
{
$this->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}<br/><br/>";
$message .= '<b>Action Required:</b> 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()
);
}
}

View File

@@ -16,6 +16,7 @@ trait HasNotificationSettings
'server_force_disabled',
'general',
'test',
'ssl_certificate_renewal',
];
/**

View File

@@ -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');
}
}

View File

@@ -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;

View File

@@ -0,0 +1,70 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up()
{
Schema::table('standalone_postgresqls', function (Blueprint $table) {
$table->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');
});
}
};

View File

@@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up()
{
Schema::create('ssl_certificates', function (Blueprint $table) {
$table->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');
}
};

View File

@@ -0,0 +1,69 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('local_file_volumes', function (Blueprint $table) {
$table->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());
}
}
});
}
}
};

View File

@@ -0,0 +1,43 @@
<?php
namespace Database\Seeders;
use App\Helpers\SslHelper;
use App\Models\Server;
use App\Models\SslCertificate;
use Illuminate\Database\Seeder;
class CaSslCertSeeder extends Seeder
{
public function run()
{
Server::chunk(200, function ($servers) {
foreach ($servers as $server) {
$existingCaCert = SslCertificate::where('server_id', $server->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);
}
});
}
}

View File

@@ -28,6 +28,7 @@ class DatabaseSeeder extends Seeder
OauthSettingSeeder::class,
DisableTwoStepConfirmationSeeder::class,
SentinelSeeder::class,
CaSslCertSeeder::class,
]);
}
}

View File

@@ -193,5 +193,6 @@ uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA==
$this->call(PopulateSshKeysDirectorySeeder::class);
$this->call(SentinelSeeder::class);
$this->call(RootUserSeeder::class);
$this->call(CaSslCertSeeder::class);
}
}

View File

@@ -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) => {

View File

@@ -1,682 +0,0 @@
<template>
<Transition name="fade">
<div>
<div class="flex items-center p-1 px-2 overflow-hidden transition-all transform rounded cursor-pointer bg-coolgray-100"
@click="showCommandPalette = true">
<svg xmlns="http://www.w3.org/2000/svg" class="w-6 h-6 icon" viewBox="0 0 24 24" stroke-width="2"
stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M10 10m-7 0a7 7 0 1 0 14 0a7 7 0 1 0 -14 0" />
<path d="M21 21l-6 -6" />
</svg>
<span class="flex-1"></span>
<span class="ml-2 kbd-custom">/</span>
</div>
<div class="relative" role="dialog" aria-modal="true" v-if="showCommandPalette" @keyup.esc="resetState">
<div class="fixed inset-0 transition-opacity bg-opacity-90 bg-coolgray-100" @click.self="resetState">
</div>
<div class="fixed inset-0 p-4 mx-auto overflow-y-auto lg:w-[70rem] sm:p-10 md:px-20"
@click.self="resetState">
<div class="overflow-hidden transition-all transform bg-coolgray-200 ring-1 ring-black ring-opacity-5">
<div class="relative">
<svg class="absolute w-5 h-5 text-gray-400 pointer-events-none left-3 top-2.5"
viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd"
d="M9 3.5a5.5 5.5 0 100 11 5.5 5.5 0 000-11zM2 9a7 7 0 1112.452 4.391l3.328 3.329a.75.75 0 11-1.06 1.06l-3.329-3.328A7 7 0 012 9z"
clip-rule="evenodd" />
</svg>
<input type="text" v-model="search" ref="searchInput" @keydown.down="focusNext(magic.length)"
@keydown.up="focusPrev(magic.length)" @keyup.enter="callAction"
class="w-full h-10 pr-4 rounded outline-none dark:text-white bg-coolgray-400 pl-11 placeholder:text-neutral-700 sm:text-sm focus:outline-none"
placeholder="Search, jump or create... magically... 🪄" role="combobox"
aria-expanded="false" aria-controls="options">
</div>
<ul class="px-4 pb-2 overflow-y-auto max-h-96 scroll-py-10 scroll-pb-2 scrollbar" id="options"
role="listbox">
<li v-if="sequenceState.sequence.length !== 0">
<h2 v-if="sequenceState.sequence[sequenceState.currentActionIndex] && possibleSequences[sequenceState.sequence[sequenceState.currentActionIndex]]"
class="mt-4 mb-2 text-xs font-semibold text-neutral-500">{{
possibleSequences[sequenceState.sequence[sequenceState.currentActionIndex]].newTitle }}
</h2>
<ul class="mt-2 -mx-4 dark:text-white">
<li class="flex items-center px-4 py-2 cursor-pointer select-none group hover:bg-coolgray-400"
id="option-1" role="option" tabindex="-1"
@click="addNew(sequenceState.sequence[sequenceState.currentActionIndex])">
<svg xmlns=" http://www.w3.org/2000/svg" class="w-6 h-6" viewBox="0 0 24 24"
stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round"
stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M12 5l0 14" />
<path d="M5 12l14 0" />
</svg>
<span class="flex-auto ml-3 truncate">
<span v-if="search"><span class="capitalize ">{{
sequenceState.sequence[sequenceState.currentActionIndex] }}</span> name
will be:
<span class="inline-block dark:text-warning">{{ search }}</span>
</span>
<span v-else><span class="capitalize ">{{
sequenceState.sequence[sequenceState.currentActionIndex] }}</span> name
will be:
<span class="inline-block dark:text-warning">randomly generated (type to
change)</span>
</span>
</span>
</li>
</ul>
</li>
<li>
<ul v-if="magic.length == 0" class="mt-2 -mx-4 dark:text-white">
<li class="flex items-center px-4 py-2 select-none group">
<svg xmlns="http://www.w3.org/2000/svg" class="w-6 h-6 icon" viewBox="0 0 24 24"
stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round"
stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M12 12m-9 0a9 9 0 1 0 18 0a9 9 0 1 0 -18 0" />
<path d="M9 10l.01 0" />
<path d="M15 10l.01 0" />
<path d="M9 15l6 0" />
</svg>
<span class="flex-auto ml-3 truncate">Nothing found. Ooops.</span>
</li>
</ul>
<h2 v-if="magic.length !== 0 && sequenceState.sequence[sequenceState.currentActionIndex] && possibleSequences[sequenceState.sequence[sequenceState.currentActionIndex]]"
class="mt-4 mb-2 text-xs font-semibold text-neutral-500">{{
possibleSequences[sequenceState.sequence[sequenceState.currentActionIndex]].title }}
</h2>
<ul v-if="magic.length != 0" class="mt-2 -mx-4 dark:text-white">
<li class="flex items-center px-4 py-2 transition-all cursor-pointer select-none group hover:bg-coolgray-400"
:class="{ 'bg-coollabs': currentFocus === index }" id="option-1" role="option"
tabindex="-1" v-for="action, index in magic" @click="goThroughSequence(index)"
ref="magicItems">
<div class="relative">
<svg xmlns="http://www.w3.org/2000/svg" class="w-6 h-6 icon" viewBox="0 0 24 24"
stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round"
stroke-linejoin="round">
<template
v-if="action.icon === 'git' || sequenceState.sequence[sequenceState.currentActionIndex] === 'git'">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M16 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
<path d="M12 8m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
<path d="M12 16m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
<path d="M12 15v-6" />
<path d="M15 11l-2 -2" />
<path d="M11 7l-1.9 -1.9" />
<path
d="M13.446 2.6l7.955 7.954a2.045 2.045 0 0 1 0 2.892l-7.955 7.955a2.045 2.045 0 0 1 -2.892 0l-7.955 -7.955a2.045 2.045 0 0 1 0 -2.892l7.955 -7.955a2.045 2.045 0 0 1 2.892 0z" />
</template>
<template
v-if="action.icon === 'server' || sequenceState.sequence[sequenceState.currentActionIndex] === 'server'">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path
d="M3 4m0 3a3 3 0 0 1 3 -3h12a3 3 0 0 1 3 3v2a3 3 0 0 1 -3 3h-12a3 3 0 0 1 -3 -3z" />
<path d="M15 20h-9a3 3 0 0 1 -3 -3v-2a3 3 0 0 1 3 -3h12" />
<path d="M7 8v.01" />
<path d="M7 16v.01" />
<path d="M20 15l-2 3h3l-2 3" />
</template>
<template
v-if="action.icon === 'destination' || sequenceState.sequence[sequenceState.currentActionIndex] === 'destination'">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path
d="M22 12.54c-1.804 -.345 -2.701 -1.08 -3.523 -2.94c-.487 .696 -1.102 1.568 -.92 2.4c.028 .238 -.32 1 -.557 1h-14c0 5.208 3.164 7 6.196 7c4.124 .022 7.828 -1.376 9.854 -5c1.146 -.101 2.296 -1.505 2.95 -2.46z" />
<path d="M5 10h3v3h-3z" />
<path d="M8 10h3v3h-3z" />
<path d="M11 10h3v3h-3z" />
<path d="M8 7h3v3h-3z" />
<path d="M11 7h3v3h-3z" />
<path d="M11 4h3v3h-3z" />
<path d="M4.571 18c1.5 0 2.047 -.074 2.958 -.78" />
<path d="M10 16l0 .01" />
</template>
<template
v-if="action.icon === 'storage' || sequenceState.sequence[sequenceState.currentActionIndex] === 'storage'">
<g fill="none" stroke="currentColor" stroke-linecap="round"
stroke-linejoin="round" stroke-width="2">
<path d="M4 6a8 3 0 1 0 16 0A8 3 0 1 0 4 6" />
<path d="M4 6v6a8 3 0 0 0 16 0V6" />
<path d="M4 12v6a8 3 0 0 0 16 0v-6" />
</g>
</template>
<template
v-if="action.icon === 'project' || sequenceState.sequence[sequenceState.currentActionIndex] === 'project'">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path
d="M9 4h3l2 2h5a2 2 0 0 1 2 2v7a2 2 0 0 1 -2 2h-10a2 2 0 0 1 -2 -2v-9a2 2 0 0 1 2 -2" />
<path
d="M17 17v2a2 2 0 0 1 -2 2h-10a2 2 0 0 1 -2 -2v-9a2 2 0 0 1 2 -2h2" />
</template>
<template
v-if="action.icon === 'environment' || sequenceState.sequence[sequenceState.currentActionIndex] === 'environment'">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M16 5l3 3l-2 1l4 4l-3 1l4 4h-9" />
<path d="M15 21l0 -3" />
<path d="M8 13l-2 -2" />
<path d="M8 12l2 -2" />
<path d="M8 21v-13" />
<path
d="M5.824 16a3 3 0 0 1 -2.743 -3.69a3 3 0 0 1 .304 -4.833a3 3 0 0 1 4.615 -3.707a3 3 0 0 1 4.614 3.707a3 3 0 0 1 .305 4.833a3 3 0 0 1 -2.919 3.695h-4z" />
</template>
<template
v-if="action.icon === 'key' || sequenceState.sequence[sequenceState.currentActionIndex] === 'key'">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M14 10m-2 0a2 2 0 1 0 4 0a2 2 0 1 0 -4 0" />
<path d="M21 12a9 9 0 1 1 -18 0a9 9 0 0 1 18 0z" />
<path d="M12.5 11.5l-4 4l1.5 1.5" />
<path d="M12 15l-1.5 -1.5" />
</template>
<template
v-if="action.icon === 'goto' || sequenceState.sequence[sequenceState.currentActionIndex] === 'goto'">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M10 18h4" />
<path
d="M3 8a9 9 0 0 1 9 9v1l1.428 -4.285a12 12 0 0 1 6.018 -6.938l.554 -.277" />
<path d="M15 6h5v5" />
</template>
<template
v-if="action.icon === 'team' || sequenceState.sequence[sequenceState.currentActionIndex] === 'team'">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M10 13a2 2 0 1 0 4 0a2 2 0 0 0 -4 0" />
<path d="M8 21v-1a2 2 0 0 1 2 -2h4a2 2 0 0 1 2 2v1" />
<path d="M15 5a2 2 0 1 0 4 0a2 2 0 0 0 -4 0" />
<path d="M17 10h2a2 2 0 0 1 2 2v1" />
<path d="M5 5a2 2 0 1 0 4 0a2 2 0 0 0 -4 0" />
<path d="M3 13v-1a2 2 0 0 1 2 -2h2" />
</template>
</svg>
<div v-if="action.new"
class="absolute top-0 right-0 -mt-2 -mr-2 font-bold dark:text-warning">+
</div>
</div>
<span class="flex-auto ml-3 truncate">{{ action.name }}</span>
</li>
</ul>
</li>
</ul>
</div>
</div>
</div>
</div>
</Transition>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
import axios from "axios";
const currentFocus = ref(0)
const magicItems = ref()
function focusNext(length) {
if (currentFocus.value === undefined) {
currentFocus.value = 0
} else {
if (length > currentFocus.value + 1) {
currentFocus.value = currentFocus.value + 1
}
}
if (currentFocus.value > 4) {
magicItems.value[currentFocus.value].scrollIntoView({ block: "center", inline: "center", behavior: 'auto' })
}
}
function focusPrev(length) {
if (currentFocus.value === undefined) {
currentFocus.value = length - 1
} else {
if (currentFocus.value > 0) {
currentFocus.value = currentFocus.value - 1
}
}
if (currentFocus.value < length - 4) {
magicItems.value[currentFocus.value].scrollIntoView({ block: "center", inline: "center", behavior: 'auto' })
}
}
async function callAction() {
await goThroughSequence(currentFocus.value)
}
const showCommandPalette = ref(false)
const search = ref()
const searchInput = ref()
const baseUrl = '/magic'
const uuidSelector = ['project', 'destination']
const nameSelector = ['environment']
const possibleSequences = {
server: {
newTitle: 'Create a new Server',
title: 'Select a server'
},
destination: {
newTitle: 'Create a new Destination',
title: 'Select a destination'
},
project: {
newTitle: 'Create a new Project',
title: 'Select a project'
},
environment: {
newTitle: 'Create a new Environment',
title: 'Select an environment'
},
}
const magicActions = [{
id: 1,
name: 'Deploy: Public Repository',
tags: 'git,github,public',
icon: 'git',
new: true,
sequence: ['main', 'server', 'destination', 'project', 'environment', 'redirect']
},
{
id: 2,
name: 'Deploy: Private Repository (with GitHub Apps)',
tags: 'git,github,private',
icon: 'git',
new: true,
sequence: ['main', 'server', 'destination', 'project', 'environment', 'redirect']
},
{
id: 3,
name: 'Deploy: Private Repository (with Deploy Key)',
tags: 'git,github,private,deploy,key',
icon: 'git',
new: true,
sequence: ['main', 'server', 'destination', 'project', 'environment', 'redirect']
},
{
id: 4,
name: 'Deploy: Dockerfile',
tags: 'dockerfile,deploy',
icon: 'destination',
new: true,
sequence: ['main', 'server', 'destination', 'project', 'environment', 'redirect']
},
{
id: 5,
name: 'Create: Server',
tags: 'server,ssh,new,create',
icon: 'server',
new: true,
sequence: ['main', 'redirect']
},
{
id: 6,
name: 'Create: Source',
tags: 'source,git,gitlab,github,bitbucket,gitea,new,create',
icon: 'git',
new: true,
sequence: ['main', 'redirect']
},
{
id: 7,
name: 'Create: Private Key',
tags: 'private,key,ssh,new,create',
icon: 'key',
new: true,
sequence: ['main', 'redirect']
},
{
id: 8,
name: 'Create: Destination',
tags: 'destination,docker,network,new,create',
icon: 'destination',
new: true,
sequence: ['main', 'server', 'redirect']
},
{
id: 9,
name: 'Create: Team',
tags: 'team,member,new,create',
icon: 'team',
new: true,
sequence: ['main', 'redirect']
},
{
id: 10,
name: 'Create: S3 Storage',
tags: 's3,storage,new,create',
icon: 'storage',
new: true,
sequence: ['main', 'redirect']
},
{
id: 11,
name: 'Goto: S3 Storage',
tags: 's3,storage',
icon: 'goto',
sequence: ['main', 'redirect']
},
{
id: 12,
name: 'Goto: Dashboard',
icon: 'goto',
sequence: ['main', 'redirect']
},
{
id: 13,
name: 'Goto: Servers',
icon: 'goto',
sequence: ['main', 'redirect']
},
{
id: 14,
name: 'Goto: Private Keys',
tags: 'destination,docker,network,new,create,ssh,private,key',
icon: 'goto',
sequence: ['main', 'redirect']
},
{
id: 15,
name: 'Goto: Projects',
icon: 'goto',
sequence: ['main', 'redirect']
},
{
id: 16,
name: 'Goto: Sources',
icon: 'goto',
sequence: ['main', 'redirect']
},
{
id: 17,
name: 'Goto: Destinations',
icon: 'goto',
sequence: ['main', 'redirect']
},
{
id: 18,
name: 'Goto: Settings',
icon: 'goto',
sequence: ['main', 'redirect']
},
{
id: 19,
name: 'Goto: Terminal',
icon: 'goto',
sequence: ['main', 'redirect']
},
{
id: 20,
name: 'Goto: Notifications',
icon: 'goto',
sequence: ['main', 'redirect']
},
{
id: 21,
name: 'Goto: Profile',
icon: 'goto',
sequence: ['main', 'redirect']
},
{
id: 22,
name: 'Goto: Teams',
icon: 'goto',
sequence: ['main', 'redirect']
},
{
id: 23,
name: 'Goto: Switch Teams',
icon: 'goto',
sequence: ['main', 'redirect']
},
{
id: 24,
name: 'Goto: Onboarding process',
icon: 'goto',
sequence: ['main', 'redirect']
},
{
id: 25,
name: 'Goto: API Tokens',
tags: 'api,tokens,rest',
icon: 'goto',
sequence: ['main', 'redirect']
},
{
id: 26,
name: 'Goto: Team Shared Variables',
tags: 'team,shared,variables',
icon: 'goto',
sequence: ['main', 'redirect']
}
]
const initialState = {
sequence: [],
currentActionIndex: 0,
magicActions,
selected: {}
}
const sequenceState = ref({ ...initialState })
function focusSearch(event) {
if (event.target.nodeName === 'BODY') {
if (event.key === '/') {
event.preventDefault();
showCommandPalette.value = true;
}
}
}
onMounted(() => {
window.addEventListener("keydown", focusSearch);
})
onUnmounted(() => {
window.removeEventListener("keydown", focusSearch);
})
watch(showCommandPalette, async (value) => {
if (value) {
await nextTick();
searchInput.value.focus();
}
})
watch(search, async () => {
currentFocus.value = 0
})
const magic = computed(() => {
if (search.value) {
return sequenceState.value.magicActions.filter(action => {
return action.name.toLowerCase().includes(search.value.toLowerCase()) || action.tags?.toLowerCase().includes(search.value.toLowerCase())
})
}
return sequenceState.value.magicActions
})
async function addNew(name) {
let targetUrl = new URL(window.location.origin)
let newUrl = new URL(`${window.location.origin}${baseUrl}/${name}/new`);
if (search.value) {
targetUrl.searchParams.append('name', search.value)
newUrl.searchParams.append('name', search.value)
}
switch (name) {
case 'server':
targetUrl.pathname = '/server/new'
window.location.href = targetUrl.href
break;
case 'destination':
targetUrl.pathname = '/destination/new'
window.location.href = targetUrl.href
break;
case 'project':
const { data: { project_uuid } } = await axios(newUrl.href)
search.value = ''
sequenceState.value.selected['project'] = project_uuid
sequenceState.value.magicActions = await getEnvironments(project_uuid)
sequenceState.value.currentActionIndex += 1
break;
case 'environment':
newUrl.searchParams.append('project_uuid', sequenceState.value.selected.project)
const { data: { environment_name } } = await axios(newUrl.href)
search.value = ''
sequenceState.value.selected['environment'] = environment_name
redirect()
break;
}
}
function resetState() {
showCommandPalette.value = false
sequenceState.value = { ...initialState }
search.value = ''
}
async function goThroughSequence(actionId) {
let currentSequence = null;
let nextSequence = null;
if (sequenceState.value.selected.main === undefined) {
const { sequence, id } = magic.value[actionId];
currentSequence = sequence[sequenceState.value.currentActionIndex]
nextSequence = sequence[sequenceState.value.currentActionIndex + 1]
sequenceState.value.sequence = sequence
sequenceState.value.selected = {
main: id
}
} else {
currentSequence = sequenceState.value.sequence[sequenceState.value.currentActionIndex]
nextSequence = sequenceState.value.sequence[sequenceState.value.currentActionIndex + 1]
let selectedId = sequenceState.value.magicActions[actionId].id
if (uuidSelector.includes(currentSequence)) {
selectedId = sequenceState.value.magicActions[actionId].uuid
}
if (nameSelector.includes(currentSequence)) {
selectedId = sequenceState.value.magicActions[actionId].name
}
sequenceState.value.selected = {
...sequenceState.value.selected,
[currentSequence]: selectedId
}
}
switch (nextSequence) {
case 'server':
sequenceState.value.magicActions = await getServers();
break;
case 'destination':
sequenceState.value.magicActions = await getDestinations(sequenceState.value.selected[currentSequence]);
break;
case 'project':
sequenceState.value.magicActions = await getProjects()
break;
case 'environment':
sequenceState.value.magicActions = await getEnvironments(sequenceState.value.selected[currentSequence])
break;
case 'redirect':
redirect()
break;
default:
break;
}
sequenceState.value.currentActionIndex += 1
search.value = ''
searchInput.value.focus()
currentFocus.value = 0
}
async function getServers() {
const { data: { servers } } = await axios.get(`${baseUrl}/servers`);
return servers;
}
async function getDestinations(serverId) {
const { data: { destinations } } = await axios.get(`${baseUrl}/destinations?server_id=${serverId}`);
return destinations;
}
async function getProjects() {
const { data: { projects } } = await axios.get(`${baseUrl}/projects`);
return projects;
}
async function getEnvironments(project_uuid) {
const { data: { environments } } = await axios.get(`${baseUrl}/environments?project_uuid=${project_uuid}`);
return environments;
}
async function redirect() {
let targetUrl = new URL(window.location.origin)
const selected = sequenceState.value.selected
const { main, destination = null, project = null, environment = null, server = null } = selected
switch (main) {
case 1:
targetUrl.pathname = `/project/${project}/${environment}/new`
targetUrl.searchParams.append('type', 'public')
targetUrl.searchParams.append('destination', destination)
break;
case 2:
targetUrl.pathname = `/project/${project}/${environment}/new`
targetUrl.searchParams.append('type', 'private-gh-app')
targetUrl.searchParams.append('destination', destination)
break;
case 3:
targetUrl.pathname = `/project/${project}/${environment}/new`
targetUrl.searchParams.append('type', 'private-deploy-key')
targetUrl.searchParams.append('destination', destination)
break;
case 4:
targetUrl.pathname = `/project/${project}/${environment}/new`
targetUrl.searchParams.append('type', 'dockerfile')
targetUrl.searchParams.append('destination', destination)
break;
case 5:
targetUrl.pathname = `/server/new`
break;
case 6:
targetUrl.pathname = `/source/new`
break;
case 7:
targetUrl.pathname = `/security/private-key/new`
break;
case 8:
targetUrl.pathname = `/destination/new`
targetUrl.searchParams.append('server', server)
break;
case 9:
targetUrl.pathname = `/team/new`
break;
case 10:
targetUrl.pathname = `/team/storages/new`
break;
case 11:
targetUrl.pathname = `/team/storages/`
break;
case 12:
targetUrl.pathname = `/`
break;
case 13:
targetUrl.pathname = `/servers`
break;
case 14:
targetUrl.pathname = `/security/private-key`
break;
case 15:
targetUrl.pathname = `/projects`
break;
case 16:
targetUrl.pathname = `/sources`
break;
case 17:
targetUrl.pathname = `/destinations`
break;
case 18:
targetUrl.pathname = `/settings`
break;
case 19:
targetUrl.pathname = `/terminal`
break;
case 20:
targetUrl.pathname = `/team/notifications`
break;
case 21:
targetUrl.pathname = `/profile`
break;
case 22:
targetUrl.pathname = `/team`
break;
case 23:
targetUrl.pathname = `/team`
break;
case 24:
targetUrl.pathname = `/onboarding`
break;
case 25:
targetUrl.pathname = `/security/api-tokens`
break;
case 26:
targetUrl.pathname = `/team/shared-variables`
break;
}
window.location.href = targetUrl;
}
</script>

View File

@@ -0,0 +1,17 @@
@props(['text'])
<div class="relative" x-data="{ copied: false }">
<input type="text" value="{{ $text }}" readonly class="input">
<button
@click.prevent="copied = true; navigator.clipboard.writeText('{{ $text }}'); setTimeout(() => copied = false, 1000)"
class="absolute right-2 top-1/2 -translate-y-1/2 p-1.5 text-gray-400 hover:text-gray-300 transition-colors"
title="Copy to clipboard">
<svg x-show="!copied" class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
<svg x-show="copied" class="w-5 h-5 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
</button>
</div>

View File

@@ -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 @@
<h4 class="mb-2 text-lg font-semibold">Confirm Actions</h4>
<p class="mb-2 text-sm">{{ $confirmationLabel }}</p>
<div class="relative mb-2">
<input type="text" x-model="confirmationText"
class="p-2 pr-10 w-full text-black rounded cursor-text input" readonly>
<button @click="copyConfirmationText()"
x-show="window.isSecureContext"
class="absolute right-2 top-1/2 text-gray-500 transform -translate-y-1/2 hover:text-gray-700"
title="Copy confirmation text" x-ref="copyButton">
<template x-if="!copied">
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5"
viewBox="0 0 20 20" fill="currentColor">
<path d="M8 3a1 1 0 011-1h2a1 1 0 110 2H9a1 1 0 01-1-1z" />
<path
d="M6 3a2 2 0 00-2 2v11a2 2 0 002 2h8a2 2 0 002-2V5a2 2 0 00-2-2 3 3 0 01-3 3H9a3 3 0 01-3-3z" />
</svg>
</template>
<template x-if="copied">
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 text-green-500"
viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
clip-rule="evenodd" />
</svg>
</template>
</button>
<x-forms.copy-button
text="{{ $confirmationText }}"
/>
</div>
<label for="userConfirmationText"

View File

@@ -1,6 +1,11 @@
<div class="flex flex-col items-start gap-2 min-w-fit">
<a wire:navigate class="menu-item {{ $activeMenu === 'general' ? 'menu-item-active' : '' }}"
href="{{ route('server.show', ['server_uuid' => $server->uuid]) }}">General</a>
@if ($server->isFunctional())
<a wire:navigate class="menu-item {{ $activeMenu === 'advanced' ? 'menu-item-active' : '' }}"
href="{{ route('server.advanced', ['server_uuid' => $server->uuid]) }}">Advanced
</a>
@endif
<a wire:navigate class="menu-item {{ $activeMenu === 'private-key' ? 'menu-item-active' : '' }}"
href="{{ route('server.private-key', ['server_uuid' => $server->uuid]) }}">Private Key
</a>
@@ -16,9 +21,6 @@
<a wire:navigate class="menu-item {{ $activeMenu === 'destinations' ? 'menu-item-active' : '' }}"
href="{{ route('server.destinations', ['server_uuid' => $server->uuid]) }}">Destinations
</a>
<a wire:navigate class="menu-item {{ $activeMenu === 'advanced' ? 'menu-item-active' : '' }}"
href="{{ route('server.advanced', ['server_uuid' => $server->uuid]) }}">Advanced
</a>
<a wire:navigate class="menu-item {{ $activeMenu === 'log-drains' ? 'menu-item-active' : '' }}"
href="{{ route('server.log-drains', ['server_uuid' => $server->uuid]) }}">Log
Drains</a>

View File

@@ -0,0 +1,28 @@
<x-emails.layout>
<h2>SSL Certificates Renewed</h2>
<p>SSL certificates have been renewed for the following resources:</p>
<ul>
@foreach($resources as $resource)
<li>{{ $resource->name }}</li>
@endforeach
</ul>
<div style="margin: 20px 0; padding: 15px; background-color: #fff3cd; border: 1px solid #ffeeba; border-radius: 4px;">
<strong>⚠️ Action Required:</strong> These resources need to be redeployed manually for the new SSL certificates to take effect. Please do this in the next few days to ensure your database connections remain accessible.
</div>
<p>The old SSL certificates will remain valid for approximately 14 more days, as we renew certificates 14 days before their expiration.</p>
@if(isset($urls) && count($urls) > 0)
<div style="margin-top: 20px;">
<p>You can redeploy these resources here:</p>
<ul>
@foreach($urls as $name => $url)
<li><a href="{{ $url }}">{{ $name }}</a></li>
@endforeach
</ul>
</div>
@endif
</x-emails.layout>

View File

@@ -45,52 +45,14 @@
</div>
<div x-data="{
showCode: false,
secretKey: '{{ decrypt(request()->user()->two_factor_secret) }}',
otpUrl: '{{ request()->user()->twoFactorQrCodeUrl() }}',
copiedSecretKey: false,
copiedOtpUrl: false
}" class="py-4 w-full">
<div class="flex flex-col gap-2" x-show="showCode">
<div class="relative">
<x-forms.input
x-model="secretKey"
label="Secret Key"
readonly
class="font-mono pr-10"
/>
<button
x-show="window.isSecureContext"
@click="navigator.clipboard.writeText(secretKey); copiedSecretKey = true; setTimeout(() => copiedSecretKey = false, 2000)"
class="absolute right-2 bottom-1 p-1 rounded hover:bg-gray-200 dark:hover:bg-gray-700"
>
<svg x-show="!copiedSecretKey" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 17.25v3.375c0 .621-.504 1.125-1.125 1.125h-9.75a1.125 1.125 0 01-1.125-1.125V7.875c0-.621.504-1.125 1.125-1.125H6.75a9.06 9.06 0 011.5.124m7.5 10.376h3.375c.621 0 1.125-.504 1.125-1.125V11.25c0-4.46-3.243-8.161-7.5-8.876a9.06 9.06 0 00-1.5-.124H9.375c-.621 0-1.125.504-1.125 1.125v3.5m7.5 10.375H9.375a1.125 1.125 0 01-1.125-1.125v-9.25m12 6.625v-1.875a3.375 3.375 0 00-3.375-3.375h-1.5a1.125 1.125 0 01-1.125-1.125v-1.5a3.375 3.375 0 00-3.375-3.375H9.75" />
</svg>
<svg x-show="copiedSecretKey" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5 text-green-500">
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
</button>
</div>
<div class="relative" >
<x-forms.input
x-model="otpUrl"
label="OTP URL"
readonly
class="font-mono pr-10"
/>
<button
x-show="window.isSecureContext"
@click="navigator.clipboard.writeText(otpUrl); copiedOtpUrl = true; setTimeout(() => copiedOtpUrl = false, 2000)"
class="absolute right-2 bottom-1 p-1 rounded hover:bg-gray-200 dark:hover:bg-gray-700"
>
<svg x-show="!copiedOtpUrl" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 17.25v3.375c0 .621-.504 1.125-1.125 1.125h-9.75a1.125 1.125 0 01-1.125-1.125V7.875c0-.621.504-1.125 1.125-1.125H6.75a9.06 9.06 0 011.5.124m7.5 10.376h3.375c.621 0 1.125-.504 1.125-1.125V11.25c0-4.46-3.243-8.161-7.5-8.876a9.06 9.06 0 00-1.5-.124H9.375c-.621 0-1.125.504-1.125 1.125v3.5m7.5 10.375H9.375a1.125 1.125 0 01-1.125-1.125v-9.25m12 6.625v-1.875a3.375 3.375 0 00-3.375-3.375h-1.5a1.125 1.125 0 01-1.125-1.125v-1.5a3.375 3.375 0 00-3.375-3.375H9.75" />
</svg>
<svg x-show="copiedOtpUrl" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5 text-green-500">
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
</button>
</div>
<div class="flex flex-col gap-2 pb-2" x-show="showCode">
<x-forms.copy-button
text="{{ decrypt(request()->user()->two_factor_secret) }}"
/>
<x-forms.copy-button
text="{{ request()->user()->twoFactorQrCodeUrl() }}"
/>
</div>
<x-forms.button x-on:click="showCode = !showCode" class="mt-2">
<span x-text="showCode ? 'Hide Secret Key and OTP URL' : 'Show Secret Key and OTP URL'"></span>

View File

@@ -49,6 +49,37 @@
readonly value="Starting the database will generate this." />
@endif
</div>
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between py-2">
<div class="flex items-center justify-between w-full">
<h3>SSL Configuration</h3>
@if ($database->enable_ssl && $certificateValidUntil)
<x-modal-confirmation title="Regenerate SSL Certificates"
buttonTitle="Regenerate SSL Certificates" :actions="[
'The SSL certificate of this database will be regenerated.',
'You must restart the database after regenerating the certificate to start using the new certificate.',
]"
submitAction="regenerateSslCertificate" :confirmWithText="false" :confirmWithPassword="false" />
@endif
</div>
</div>
@if ($database->enable_ssl && $certificateValidUntil)
<span class="text-sm">Valid until:
@if (now()->gt($certificateValidUntil))
<span class="text-red-500">{{ $certificateValidUntil->format('d.m.Y H:i:s') }} - Expired</span>
@elseif(now()->addDays(30)->gt($certificateValidUntil))
<span class="text-red-500">{{ $certificateValidUntil->format('d.m.Y H:i:s') }} - Expiring
soon</span>
@else
<span>{{ $certificateValidUntil->format('d.m.Y H:i:s') }}</span>
@endif
</span>
@endif
<div class="flex flex-col gap-2">
<x-forms.checkbox id="enable_ssl" label="Enable SSL" wire:model.live="enable_ssl"
instantSave="instantSaveSSL" />
</div>
</div>
<div>
<div class="flex flex-col py-2 w-64">
<div class="flex items-center gap-2 pb-2">

View File

@@ -49,6 +49,40 @@
readonly value="Starting the database will generate this." />
@endif
</div>
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between py-2">
<div class="flex items-center justify-between w-full">
<h3>SSL Configuration</h3>
@if($database->enable_ssl && $certificateValidUntil)
<x-modal-confirmation
title="Regenerate SSL Certificates"
buttonTitle="Regenerate SSL Certificates"
:actions="[
'The SSL certificate of this database will be regenerated.',
'You must restart the database after regenerating the certificate to start using the new certificate.'
]"
submitAction="regenerateSslCertificate"
:confirmWithText="false"
:confirmWithPassword="false"
/>
@endif
</div>
</div>
@if($database->enable_ssl && $certificateValidUntil)
<span class="text-sm">Valid until:
@if(now()->gt($certificateValidUntil))
<span class="text-red-500">{{ $certificateValidUntil->format('d.m.Y H:i:s') }} - Expired</span>
@elseif(now()->addDays(30)->gt($certificateValidUntil))
<span class="text-red-500">{{ $certificateValidUntil->format('d.m.Y H:i:s') }} - Expiring soon</span>
@else
<span>{{ $certificateValidUntil->format('d.m.Y H:i:s') }}</span>
@endif
</span>
@endif
<div class="flex flex-col gap-2">
<x-forms.checkbox id="enable_ssl" label="Enable SSL" wire:model.live="enable_ssl" instantSave="instantSaveSSL" />
</div>
</div>
<div>
<div class="flex flex-col py-2 w-64">
<div class="flex items-center gap-2 pb-2">

View File

@@ -65,6 +65,41 @@
type="password" readonly wire:model="db_url_public" />
@endif
</div>
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between py-2">
<div class="flex items-center justify-between w-full">
<h3>SSL Configuration</h3>
@if($database->enable_ssl && $certificateValidUntil)
<x-modal-confirmation
title="Regenerate SSL Certificates"
buttonTitle="Regenerate SSL Certificates"
:actions="['The SSL certificate of this database will be regenerated.','You must restart the database after regenerating the certificate to start using the new certificate.']"
submitAction="regenerateSslCertificate"
:confirmWithText="false"
:confirmWithPassword="false"
/>
@endif
</div>
</div>
@if($database->enable_ssl && $certificateValidUntil)
<span class="text-sm">Valid until:
@if(now()->gt($certificateValidUntil))
<span class="text-red-500">{{ $certificateValidUntil->format('d.m.Y H:i:s') }} - Expired</span>
@elseif(now()->addDays(30)->gt($certificateValidUntil))
<span class="text-red-500">{{ $certificateValidUntil->format('d.m.Y H:i:s') }} - Expiring soon</span>
@else
<span>{{ $certificateValidUntil->format('d.m.Y H:i:s') }}</span>
@endif
</span>
@endif
</div>
<div class="flex flex-col gap-2">
<div class="flex flex-col gap-2">
<x-forms.checkbox id="database.enable_ssl" label="Enable SSL" wire:model.live="database.enable_ssl" instantSave="instantSaveSSL" />
</div>
</div>
<div>
<div class="flex flex-col py-2 w-64">
<div class="flex items-center gap-2 pb-2">

View File

@@ -55,6 +55,53 @@
type="password" readonly wire:model="db_url_public" />
@endif
</div>
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between py-2">
<div class="flex items-center justify-between w-full">
<h3>SSL Configuration</h3>
@if ($database->enable_ssl)
<x-modal-confirmation title="Regenerate SSL Certificates"
buttonTitle="Regenerate SSL Certificates" :actions="[
'The SSL certificate of this database will be regenerated.',
'You must restart the database after regenerating the certificate to start using the new certificate.',
]"
submitAction="regenerateSslCertificate" :confirmWithText="false" :confirmWithPassword="false" />
@endif
</div>
</div>
@if ($database->enable_ssl && $certificateValidUntil)
<span class="text-sm">Valid until:
@if (now()->gt($certificateValidUntil))
<span class="text-red-500">{{ $certificateValidUntil->format('d.m.Y H:i:s') }} - Expired</span>
@elseif(now()->addDays(30)->gt($certificateValidUntil))
<span class="text-red-500">{{ $certificateValidUntil->format('d.m.Y H:i:s') }} - Expiring
soon</span>
@else
<span>{{ $certificateValidUntil->format('d.m.Y H:i:s') }}</span>
@endif
</span>
@endif
</div>
<div class="flex flex-col gap-2">
<div class="flex flex-col gap-2">
<x-forms.checkbox id="database.enable_ssl" label="Enable SSL" wire:model.live="database.enable_ssl"
instantSave="instantSaveSSL" />
@if ($database->enable_ssl)
<div class="mx-2">
<x-forms.select id="database.ssl_mode" label="SSL Mode" wire:model.live="database.ssl_mode"
instantSave="instantSaveSSL"
helper="Choose the SSL verification mode for MongoDB connections">
<option value="allow" title="Allow insecure connections">allow (insecure)</option>
<option value="prefer" title="Prefer secure connections">prefer (secure)</option>
<option value="require" title="Require secure connections">require (secure)</option>
<option value="verify-full" title="Verify full certificate">verify-full (secure)</option>
</x-forms.select>
</div>
@endif
</div>
</div>
<div>
<div class="flex flex-col py-2 w-64">
<div class="flex items-center gap-2 pb-2">

View File

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

View File

@@ -73,58 +73,112 @@
type="password" readonly wire:model="db_url_public" />
@endif
</div>
<div>
<div class="flex flex-col py-2 w-64">
<div class="flex items-center gap-2 pb-2">
<div class="flex items-center">
<h3>Proxy</h3>
<x-loading wire:loading wire:target="instantSave" />
</div>
@if (data_get($database, 'is_public'))
<x-slide-over fullScreen>
<x-slot:title>Proxy Logs</x-slot:title>
<x-slot:content>
<livewire:project.shared.get-logs :server="$server" :resource="$database"
container="{{ data_get($database, 'uuid') }}-proxy" lazy />
</x-slot:content>
<x-forms.button disabled="{{ !data_get($database, 'is_public') }}"
@click="slideOverOpen=true">Logs</x-forms.button>
</x-slide-over>
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between py-2">
<div class="flex items-center justify-between w-full">
<h3>SSL Configuration</h3>
@if ($database->enable_ssl && $certificateValidUntil)
<x-modal-confirmation title="Regenerate SSL Certificates"
buttonTitle="Regenerate SSL Certificates" :actions="[
'The SSL certificate of this database will be regenerated.',
'You must restart the database after regenerating the certificate to start using the new certificate.',
]"
submitAction="regenerateSslCertificate" :confirmWithText="false" :confirmWithPassword="false" />
@endif
</div>
<x-forms.checkbox instantSave id="database.is_public" label="Make it publicly available" />
</div>
<x-forms.input placeholder="5432" disabled="{{ data_get($database, 'is_public') }}"
id="database.public_port" label="Public Port" />
</div>
<x-forms.textarea label="Custom PostgreSQL Configuration" rows="10" id="database.postgres_conf" />
</form>
<h3 class="pt-4">Advanced</h3>
<div class="flex flex-col">
<x-forms.checkbox helper="Drain logs to your configured log drain endpoint in your Server settings."
instantSave="instantSaveAdvanced" id="database.is_log_drain_enabled" label="Drain Logs" />
</div>
<div class="pb-16">
<div class="flex gap-2 pt-4 pb-2">
<h3>Initialization scripts</h3>
<x-modal-input buttonTitle="+ Add" title="New Init Script">
<form class="flex flex-col w-full gap-2 rounded" wire:submit='save_new_init_script'>
<x-forms.input placeholder="create_test_db.sql" id="new_filename" label="Filename" required />
<x-forms.textarea rows="20" placeholder="CREATE DATABASE test;" id="new_content"
label="Content" required />
<x-forms.button type="submit">
Save
</x-forms.button>
</form>
</x-modal-input>
@if ($database->enable_ssl && $certificateValidUntil)
<span class="text-sm">Valid until:
@if (now()->gt($certificateValidUntil))
<span class="text-red-500">{{ $certificateValidUntil->format('d.m.Y H:i:s') }} - Expired</span>
@elseif(now()->addDays(30)->gt($certificateValidUntil))
<span class="text-red-500">{{ $certificateValidUntil->format('d.m.Y H:i:s') }} - Expiring
soon</span>
@else
<span>{{ $certificateValidUntil->format('d.m.Y H:i:s') }}</span>
@endif
</span>
@endif
</div>
<div class="flex flex-col gap-2">
@forelse(data_get($database,'init_scripts', []) as $script)
<livewire:project.database.init-script :script="$script" :wire:key="$script['index']" />
@empty
<div>No initialization scripts found.</div>
@endforelse
<div class="flex flex-col gap-2">
<x-forms.checkbox id="database.enable_ssl" label="Enable SSL" wire:model.live="database.enable_ssl"
instantSave="instantSaveSSL" />
@if ($database->enable_ssl)
<div class="mx-2">
<x-forms.select id="database.ssl_mode" label="SSL Mode" wire:model.live="database.ssl_mode"
instantSave="instantSaveSSL"
helper="Choose the SSL verification mode for PostgreSQL connections">
<option value="allow" title="Allow insecure connections">allow (insecure)</option>
<option value="prefer" title="Prefer secure connections">prefer (secure)</option>
<option value="require" title="Require secure connections">require (secure)</option>
<option value="verify-ca" title="Verify CA certificate">verify-ca (secure)</option>
<option value="verify-full" title="Verify full certificate">verify-full (secure)</option>
</x-forms.select>
</div>
@endif
</div>
</div>
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between py-2">
<div class="flex items-center gap-2">
<h3>Proxy</h3>
<x-loading wire:loading wire:target="instantSave" />
</div>
@if (data_get($database, 'is_public'))
<x-slide-over fullScreen>
<x-slot:title>Proxy Logs</x-slot:title>
<x-slot:content>
<livewire:project.shared.get-logs :server="$server" :resource="$database"
container="{{ data_get($database, 'uuid') }}-proxy" lazy />
</x-slot:content>
<x-forms.button disabled="{{ !data_get($database, 'is_public') }}"
@click="slideOverOpen=true">Logs</x-forms.button>
</x-slide-over>
@endif
</div>
<div class="flex flex-col gap-2">
<x-forms.checkbox instantSave id="database.is_public" label="Make it publicly available" />
<x-forms.input placeholder="5432" disabled="{{ data_get($database, 'is_public') }}"
id="database.public_port" label="Public Port" />
</div>
</div>
<div class="flex flex-col gap-2">
<x-forms.textarea label="Custom PostgreSQL Configuration" rows="10" id="database.postgres_conf" />
</div>
</form>
<div class="flex flex-col gap-4 pt-4">
<h3>Advanced</h3>
<div class="flex flex-col">
<x-forms.checkbox helper="Drain logs to your configured log drain endpoint in your Server settings."
instantSave="instantSaveAdvanced" id="database.is_log_drain_enabled" label="Drain Logs" />
</div>
<div class="pb-16">
<div class="flex items-center gap-2 pb-2">
<h3>Initialization scripts</h3>
<x-modal-input buttonTitle="+ Add" title="New Init Script">
<form class="flex flex-col w-full gap-2 rounded" wire:submit='save_new_init_script'>
<x-forms.input placeholder="create_test_db.sql" id="new_filename" label="Filename"
required />
<x-forms.textarea rows="20" placeholder="CREATE DATABASE test;" id="new_content"
label="Content" required />
<x-forms.button type="submit">
Save
</x-forms.button>
</form>
</x-modal-input>
</div>
<div class="flex flex-col gap-2">
@forelse(data_get($database,'init_scripts', []) as $script)
<livewire:project.database.init-script :script="$script" :wire:key="$script['index']" />
@empty
<div>No initialization scripts found.</div>
@endforelse
</div>
</div>
</div>
</div>

View File

@@ -49,6 +49,40 @@
type="password" readonly wire:model="db_url_public" />
@endif
</div>
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between py-2">
<div class="flex items-center justify-between w-full">
<h3>SSL Configuration</h3>
@if($database->enable_ssl && $certificateValidUntil)
<x-modal-confirmation
title="Regenerate SSL Certificates"
buttonTitle="Regenerate SSL Certificates"
:actions="[
'The SSL certificate of this database will be regenerated.',
'You must restart the database after regenerating the certificate to start using the new certificate.'
]"
submitAction="regenerateSslCertificate"
:confirmWithText="false"
:confirmWithPassword="false"
/>
@endif
</div>
</div>
@if($database->enable_ssl && $certificateValidUntil)
<span class="text-sm">Valid until:
@if(now()->gt($certificateValidUntil))
<span class="text-red-500">{{ $certificateValidUntil->format('d.m.Y H:i:s') }} - Expired</span>
@elseif(now()->addDays(30)->gt($certificateValidUntil))
<span class="text-red-500">{{ $certificateValidUntil->format('d.m.Y H:i:s') }} - Expiring soon</span>
@else
<span>{{ $certificateValidUntil->format('d.m.Y H:i:s') }}</span>
@endif
</span>
@endif
<div class="flex flex-col gap-2">
<x-forms.checkbox id="database.enable_ssl" label="Enable SSL" wire:model.live="database.enable_ssl" instantSave="instantSaveSSL" />
</div>
</div>
<div>
<div class="flex flex-col py-2 w-64">
<div class="flex items-center gap-2 pb-2">

View File

@@ -38,6 +38,95 @@
</div>
</div>
</div>
<div class="flex flex-col gap-4 pt-8">
<h3>CA SSL Certificate</h3>
<div class="flex gap-2">
<x-modal-confirmation
title="Confirm changing of CA Certificate?"
buttonTitle="Save Certificate"
submitAction="saveCaCertificate"
:actions="[
'This will overwrite the existing CA certificate at /data/coolify/ssl/coolify-ca.crt with your custom CA certificate.',
'This will regenerate all SSL certificates for databases on this server and it will sign them with your custom CA.',
'You must manually redeploy all your databases on this server so that they use the new SSL certificates singned with your new CA certificate.',
'Because of caching, you probably also need to redeploy all your resources on this server that are using this CA certificate.'
]"
confirmationText="/data/coolify/ssl/coolify-ca.crt"
shortConfirmationLabel="CA Certificate Path"
step3ButtonText="Save Certificate">
</x-modal-confirmation>
<x-modal-confirmation
title="Confirm Regenerate Certificate?"
buttonTitle="Regenerate Certificate"
submitAction="regenerateCaCertificate"
:actions="[
'This will generate a new CA certificate at /data/coolify/ssl/coolify-ca.crt and replace the existing one.',
'This will regenerate all SSL certificates for databases on this server and it will sign them with the new CA certificate.',
'You must manually redeploy all your databases on this server so that they use the new SSL certificates singned with the new CA certificate.',
'Because of caching, you probably also need to redeploy all your resources on this server that are using this CA certificate.'
]"
confirmationText="/data/coolify/ssl/coolify-ca.crt"
shortConfirmationLabel="CA Certificate Path"
step3ButtonText="Regenerate Certificate">
</x-modal-confirmation>
</div>
<div class="space-y-4">
<div class="text-sm">
<p class="font-medium mb-2">Recommended Configuration:</p>
<ul class="list-disc pl-5 space-y-1">
<li>Mount this CA certificate of Coolify into all containers that need to connect to one of your databases over SSL. You can see and copy the bind mount below.</li>
<li>Read more when and why this is needed <a class="underline" href="https://coolify.io/docs/databases/ssl" target="_blank">here</a>.</li>
</ul>
</div>
<div class="relative">
<x-forms.copy-button
text="- /data/coolify/ssl/coolify-ca.crt:/etc/ssl/certs/coolify-ca.crt:ro"
/>
</div>
</div>
<div>
<div class="flex items-center justify-between mb-2">
<div class="flex items-center gap-2">
<span class="text-sm">CA Certificate</span>
@if($certificateValidUntil)
<span class="text-sm">(Valid until:
@if(now()->gt($certificateValidUntil))
<span class="text-red-500">{{ $certificateValidUntil->format('d.m.Y H:i:s') }} - Expired)</span>
@elseif(now()->addDays(30)->gt($certificateValidUntil))
<span class="text-red-500">{{ $certificateValidUntil->format('d.m.Y H:i:s') }} - Expiring soon)</span>
@else
<span>{{ $certificateValidUntil->format('d.m.Y H:i:s') }})</span>
@endif
</span>
@endif
</div>
<x-forms.button
wire:click="toggleCertificate"
type="button"
class="!py-1 !px-2 text-sm">
{{ $showCertificate ? 'Hide' : 'Show' }}
</x-forms.button>
</div>
@if($showCertificate)
<textarea
class="w-full h-[370px] input"
wire:model="certificateContent"
placeholder="Paste or edit CA certificate content here..."></textarea>
@else
<div class="w-full h-[370px] input">
<div class="h-full flex flex-col items-center justify-center text-gray-300">
<div class="mb-2">
━━━━━━━━ CERTIFICATE CONTENT ━━━━━━━━
</div>
<div class="text-sm">
Click "Show" to view or edit
</div>
</div>
</div>
@endif
</div>
</div>
</form>
</div>
</div>

View File

@@ -209,7 +209,7 @@ if [ "$WARNING_SPACE" = true ]; then
sleep 5
fi
mkdir -p /data/coolify/{source,ssh,applications,databases,backups,services,proxy,webhooks-during-maintenance,sentinel}
mkdir -p /data/coolify/{source,ssh,applications,databases,backups,services,proxy,ssl,webhooks-during-maintenance,sentinel}
mkdir -p /data/coolify/ssh/{keys,mux}
mkdir -p /data/coolify/proxy/dynamic