Merge branch 'next' into docker-network-aliases

This commit is contained in:
Andras Bacsai
2025-04-08 13:27:59 +02:00
committed by GitHub
193 changed files with 11890 additions and 3393 deletions

View File

@@ -14,3 +14,5 @@ PUSHER_APP_SECRET=
ROOT_USERNAME= ROOT_USERNAME=
ROOT_USER_EMAIL= ROOT_USER_EMAIL=
ROOT_USER_PASSWORD= ROOT_USER_PASSWORD=
REGISTRY_URL=ghcr.io

File diff suppressed because it is too large Load Diff

View File

@@ -136,6 +136,7 @@ After installing Docker (or Orbstack) and Spin, verify the installation:
- Password: `password` - Password: `password`
2. Additional development tools: 2. Additional development tools:
| Tool | URL | Note | | Tool | URL | Note |
|------|-----|------| |------|-----|------|
| Laravel Horizon (scheduler) | `http://localhost:8000/horizon` | Only accessible when logged in as root user | | Laravel Horizon (scheduler) | `http://localhost:8000/horizon` | Only accessible when logged in as root user |
@@ -237,9 +238,9 @@ After completing these steps, you'll have a fresh development setup.
### Contributing a New Service ### Contributing a New Service
To add a new service to Coolify, please refer to our documentation: To add a new service to Coolify, please refer to our documentation:
[Adding a New Service](https://coolify.io/docs/knowledge-base/contribute/service) [Adding a New Service](https://coolify.io/docs/get-started/contribute/service)
### Contributing to Documentation ### Contributing to Documentation
To contribute to the Coolify documentation, please refer to this guide: To contribute to the Coolify documentation, please refer to this guide:
[Contributing to the Coolify Documentation](https://github.com/coollabsio/documentation-coolify/blob/main/CONTRIBUTING.md) [Contributing to the Coolify Documentation](https://github.com/coollabsio/documentation-coolify/blob/main/readme.md)

View File

@@ -2,6 +2,8 @@
namespace App\Actions\Database; namespace App\Actions\Database;
use App\Helpers\SslHelper;
use App\Models\SslCertificate;
use App\Models\StandaloneDragonfly; use App\Models\StandaloneDragonfly;
use Lorisleiva\Actions\Concerns\AsAction; use Lorisleiva\Actions\Concerns\AsAction;
use Symfony\Component\Yaml\Yaml; use Symfony\Component\Yaml\Yaml;
@@ -16,24 +18,81 @@ class StartDragonfly
public string $configuration_dir; public string $configuration_dir;
private ?SslCertificate $ssl_certificate = null;
public function handle(StandaloneDragonfly $database) public function handle(StandaloneDragonfly $database)
{ {
$this->database = $database; $this->database = $database;
$startCommand = "dragonfly --requirepass {$this->database->dragonfly_password}";
$container_name = $this->database->uuid; $container_name = $this->database->uuid;
$this->configuration_dir = database_configuration_dir().'/'.$container_name; $this->configuration_dir = database_configuration_dir().'/'.$container_name;
$this->commands = [ $this->commands = [
"echo 'Starting database.'", "echo 'Starting database.'",
"echo 'Creating directories.'",
"mkdir -p $this->configuration_dir", "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();
if (! $caCert) {
$server->generateCaCertificate();
$caCert = SslCertificate::where('server_id', $server->id)->where('is_ca_certificate', true)->first();
}
if (! $caCert) {
$this->dispatch('error', 'No CA certificate found for this database. Please generate a CA certificate for this server in the server/advanced page.');
return;
}
$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_storages = $this->generate_local_persistent_volumes();
$persistent_file_volumes = $this->database->fileStorages()->get(); $persistent_file_volumes = $this->database->fileStorages()->get();
$volume_names = $this->generate_local_persistent_volumes_only_volume_names(); $volume_names = $this->generate_local_persistent_volumes_only_volume_names();
$environment_variables = $this->generate_environment_variables(); $environment_variables = $this->generate_environment_variables();
$startCommand = $this->buildStartCommand();
$docker_compose = [ $docker_compose = [
'services' => [ 'services' => [
@@ -70,27 +129,55 @@ class StartDragonfly
], ],
], ],
]; ];
if (! is_null($this->database->limits_cpuset)) { if (! is_null($this->database->limits_cpuset)) {
data_set($docker_compose, "services.{$container_name}.cpuset", $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()) { if ($this->database->destination->server->isLogDrainEnabled() && $this->database->isLogDrainEnabled()) {
$docker_compose['services'][$container_name]['logging'] = generate_fluentd_configuration(); $docker_compose['services'][$container_name]['logging'] = generate_fluentd_configuration();
} }
if (count($this->database->ports_mappings_array) > 0) { if (count($this->database->ports_mappings_array) > 0) {
$docker_compose['services'][$container_name]['ports'] = $this->database->ports_mappings_array; $docker_compose['services'][$container_name]['ports'] = $this->database->ports_mappings_array;
} }
$docker_compose['services'][$container_name]['volumes'] ??= [];
if (count($persistent_storages) > 0) { 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) { if (count($persistent_file_volumes) > 0) {
$docker_compose['services'][$container_name]['volumes'] = $persistent_file_volumes->map(function ($item) { $docker_compose['services'][$container_name]['volumes'] = array_merge(
return "$item->fs_path:$item->mount_path"; $docker_compose['services'][$container_name]['volumes'],
})->toArray(); $persistent_file_volumes->map(function ($item) {
return "$item->fs_path:$item->mount_path";
})->toArray()
);
} }
if (count($volume_names) > 0) { if (count($volume_names) > 0) {
$docker_compose['volumes'] = $volume_names; $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 // Add custom docker run options
$docker_run_options = convertDockerRunToCompose($this->database->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); $docker_compose = generateCustomDockerRunOptionsForDatabases($docker_run_options, $docker_compose, $container_name, $this->database->destination->network);
@@ -102,12 +189,32 @@ class StartDragonfly
$this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md"; $this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md";
$this->commands[] = "echo 'Pulling {$database->image} image.'"; $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 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[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
$this->commands[] = "echo 'Database started.'"; $this->commands[] = "echo 'Database started.'";
return remote_process($this->commands, $database->destination->server, callEventOnFinish: 'DatabaseStatusChanged'); 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() private function generate_local_persistent_volumes()
{ {
$local_persistent_volumes = []; $local_persistent_volumes = [];

View File

@@ -2,6 +2,8 @@
namespace App\Actions\Database; namespace App\Actions\Database;
use App\Helpers\SslHelper;
use App\Models\SslCertificate;
use App\Models\StandaloneKeydb; use App\Models\StandaloneKeydb;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Lorisleiva\Actions\Concerns\AsAction; use Lorisleiva\Actions\Concerns\AsAction;
@@ -17,26 +19,84 @@ class StartKeydb
public string $configuration_dir; public string $configuration_dir;
private ?SslCertificate $ssl_certificate = null;
public function handle(StandaloneKeydb $database) public function handle(StandaloneKeydb $database)
{ {
$this->database = $database; $this->database = $database;
$startCommand = "keydb-server --requirepass {$this->database->keydb_password} --appendonly yes";
$container_name = $this->database->uuid; $container_name = $this->database->uuid;
$this->configuration_dir = database_configuration_dir().'/'.$container_name; $this->configuration_dir = database_configuration_dir().'/'.$container_name;
$this->commands = [ $this->commands = [
"echo 'Starting database.'", "echo 'Starting database.'",
"echo 'Creating directories.'",
"mkdir -p $this->configuration_dir", "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();
if (! $caCert) {
$server->generateCaCertificate();
$caCert = SslCertificate::where('server_id', $server->id)->where('is_ca_certificate', true)->first();
}
if (! $caCert) {
$this->dispatch('error', 'No CA certificate found for this database. Please generate a CA certificate for this server in the server/advanced page.');
return;
}
$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_storages = $this->generate_local_persistent_volumes();
$persistent_file_volumes = $this->database->fileStorages()->get(); $persistent_file_volumes = $this->database->fileStorages()->get();
$volume_names = $this->generate_local_persistent_volumes_only_volume_names(); $volume_names = $this->generate_local_persistent_volumes_only_volume_names();
$environment_variables = $this->generate_environment_variables(); $environment_variables = $this->generate_environment_variables();
$this->add_custom_keydb(); $this->add_custom_keydb();
$startCommand = $this->buildStartCommand();
$docker_compose = [ $docker_compose = [
'services' => [ 'services' => [
$container_name => [ $container_name => [
@@ -72,34 +132,67 @@ class StartKeydb
], ],
], ],
]; ];
if (! is_null($this->database->limits_cpuset)) { if (! is_null($this->database->limits_cpuset)) {
data_set($docker_compose, "services.{$container_name}.cpuset", $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()) { if ($this->database->destination->server->isLogDrainEnabled() && $this->database->isLogDrainEnabled()) {
$docker_compose['services'][$container_name]['logging'] = generate_fluentd_configuration(); $docker_compose['services'][$container_name]['logging'] = generate_fluentd_configuration();
} }
if (count($this->database->ports_mappings_array) > 0) { if (count($this->database->ports_mappings_array) > 0) {
$docker_compose['services'][$container_name]['ports'] = $this->database->ports_mappings_array; $docker_compose['services'][$container_name]['ports'] = $this->database->ports_mappings_array;
} }
$docker_compose['services'][$container_name]['volumes'] ??= [];
if (count($persistent_storages) > 0) { 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) { if (count($persistent_file_volumes) > 0) {
$docker_compose['services'][$container_name]['volumes'] = $persistent_file_volumes->map(function ($item) { $docker_compose['services'][$container_name]['volumes'] = array_merge(
return "$item->fs_path:$item->mount_path"; $docker_compose['services'][$container_name]['volumes'] ?? [],
})->toArray(); $persistent_file_volumes->map(function ($item) {
return "$item->fs_path:$item->mount_path";
})->toArray()
);
} }
if (count($volume_names) > 0) { if (count($volume_names) > 0) {
$docker_compose['volumes'] = $volume_names; $docker_compose['volumes'] = $volume_names;
} }
if (! is_null($this->database->keydb_conf) || ! empty($this->database->keydb_conf)) { if (! is_null($this->database->keydb_conf) || ! empty($this->database->keydb_conf)) {
$docker_compose['services'][$container_name]['volumes'][] = [ $docker_compose['services'][$container_name]['volumes'] = array_merge(
'type' => 'bind', $docker_compose['services'][$container_name]['volumes'] ?? [],
'source' => $this->configuration_dir.'/keydb.conf', [
'target' => '/etc/keydb/keydb.conf', [
'read_only' => true, 'type' => 'bind',
]; 'source' => $this->configuration_dir.'/keydb.conf',
$docker_compose['services'][$container_name]['command'] = "keydb-server /etc/keydb/keydb.conf --requirepass {$this->database->keydb_password} --appendonly yes"; '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 // Add custom docker run options
@@ -112,6 +205,9 @@ class StartKeydb
$this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md"; $this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md";
$this->commands[] = "echo 'Pulling {$database->image} image.'"; $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 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[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
$this->commands[] = "echo 'Database started.'"; $this->commands[] = "echo 'Database started.'";
@@ -177,4 +273,36 @@ class StartKeydb
instant_scp($path, "{$this->configuration_dir}/{$filename}", $this->database->destination->server); instant_scp($path, "{$this->configuration_dir}/{$filename}", $this->database->destination->server);
Storage::disk('local')->delete("tmp/keydb.conf_{$this->database->uuid}"); 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; namespace App\Actions\Database;
use App\Helpers\SslHelper;
use App\Models\SslCertificate;
use App\Models\StandaloneMariadb; use App\Models\StandaloneMariadb;
use Lorisleiva\Actions\Concerns\AsAction; use Lorisleiva\Actions\Concerns\AsAction;
use Symfony\Component\Yaml\Yaml; use Symfony\Component\Yaml\Yaml;
@@ -16,6 +18,8 @@ class StartMariadb
public string $configuration_dir; public string $configuration_dir;
private ?SslCertificate $ssl_certificate = null;
public function handle(StandaloneMariadb $database) public function handle(StandaloneMariadb $database)
{ {
$this->database = $database; $this->database = $database;
@@ -25,9 +29,64 @@ class StartMariadb
$this->commands = [ $this->commands = [
"echo 'Starting database.'", "echo 'Starting database.'",
"echo 'Creating directories.'",
"mkdir -p $this->configuration_dir", "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();
if (! $caCert) {
$server->generateCaCertificate();
$caCert = SslCertificate::where('server_id', $server->id)->where('is_ca_certificate', true)->first();
}
if (! $caCert) {
$this->dispatch('error', 'No CA certificate found for this database. Please generate a CA certificate for this server in the server/advanced page.');
return;
}
$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_storages = $this->generate_local_persistent_volumes();
$persistent_file_volumes = $this->database->fileStorages()->get(); $persistent_file_volumes = $this->database->fileStorages()->get();
$volume_names = $this->generate_local_persistent_volumes_only_volume_names(); $volume_names = $this->generate_local_persistent_volumes_only_volume_names();
@@ -67,38 +126,81 @@ class StartMariadb
], ],
], ],
]; ];
if (! is_null($this->database->limits_cpuset)) { if (! is_null($this->database->limits_cpuset)) {
data_set($docker_compose, "services.{$container_name}.cpuset", $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()) { if ($this->database->destination->server->isLogDrainEnabled() && $this->database->isLogDrainEnabled()) {
$docker_compose['services'][$container_name]['logging'] = generate_fluentd_configuration(); $docker_compose['services'][$container_name]['logging'] = generate_fluentd_configuration();
} }
if (count($this->database->ports_mappings_array) > 0) { if (count($this->database->ports_mappings_array) > 0) {
$docker_compose['services'][$container_name]['ports'] = $this->database->ports_mappings_array; $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) { if (count($volume_names) > 0) {
$docker_compose['volumes'] = $volume_names; $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)) { if (! is_null($this->database->mariadb_conf) || ! empty($this->database->mariadb_conf)) {
$docker_compose['services'][$container_name]['volumes'][] = [ $docker_compose['services'][$container_name]['volumes'] = array_merge(
'type' => 'bind', $docker_compose['services'][$container_name]['volumes'],
'source' => $this->configuration_dir.'/custom-config.cnf', [
'target' => '/etc/mysql/conf.d/custom-config.cnf', [
'read_only' => true, '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 // Add custom docker run options
$docker_run_options = convertDockerRunToCompose($this->database->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); $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'] = [
'mariadbd',
'--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 = Yaml::dump($docker_compose, 10);
$docker_compose_base64 = base64_encode($docker_compose); $docker_compose_base64 = base64_encode($docker_compose);
@@ -109,6 +211,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 pull";
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d"; $this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
$this->commands[] = "echo 'Database started.'"; $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'); return remote_process($this->commands, $database->destination->server, callEventOnFinish: 'DatabaseStatusChanged');
} }

View File

@@ -2,6 +2,8 @@
namespace App\Actions\Database; namespace App\Actions\Database;
use App\Helpers\SslHelper;
use App\Models\SslCertificate;
use App\Models\StandaloneMongodb; use App\Models\StandaloneMongodb;
use Lorisleiva\Actions\Concerns\AsAction; use Lorisleiva\Actions\Concerns\AsAction;
use Symfony\Component\Yaml\Yaml; use Symfony\Component\Yaml\Yaml;
@@ -16,6 +18,8 @@ class StartMongodb
public string $configuration_dir; public string $configuration_dir;
private ?SslCertificate $ssl_certificate = null;
public function handle(StandaloneMongodb $database) public function handle(StandaloneMongodb $database)
{ {
$this->database = $database; $this->database = $database;
@@ -24,16 +28,69 @@ class StartMongodb
$container_name = $this->database->uuid; $container_name = $this->database->uuid;
$this->configuration_dir = database_configuration_dir().'/'.$container_name; $this->configuration_dir = database_configuration_dir().'/'.$container_name;
if (isDev()) { if (isDev()) {
$this->configuration_dir = '/var/lib/docker/volumes/coolify_dev_coolify_data/_data/databases/'.$container_name; $this->configuration_dir = '/var/lib/docker/volumes/coolify_dev_coolify_data/_data/databases/'.$container_name;
} }
$this->commands = [ $this->commands = [
"echo 'Starting database.'", "echo 'Starting database.'",
"echo 'Creating directories.'",
"mkdir -p $this->configuration_dir", "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();
if (! $caCert) {
$server->generateCaCertificate();
$caCert = SslCertificate::where('server_id', $server->id)->where('is_ca_certificate', true)->first();
}
if (! $caCert) {
$this->dispatch('error', 'No CA certificate found for this database. Please generate a CA certificate for this server in the server/advanced page.');
return;
}
$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_storages = $this->generate_local_persistent_volumes();
$persistent_file_volumes = $this->database->fileStorages()->get(); $persistent_file_volumes = $this->database->fileStorages()->get();
$volume_names = $this->generate_local_persistent_volumes_only_volume_names(); $volume_names = $this->generate_local_persistent_volumes_only_volume_names();
@@ -79,47 +136,119 @@ class StartMongodb
], ],
], ],
]; ];
if (! is_null($this->database->limits_cpuset)) { if (! is_null($this->database->limits_cpuset)) {
data_set($docker_compose, "services.{$container_name}.cpuset", $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()) { if ($this->database->destination->server->isLogDrainEnabled() && $this->database->isLogDrainEnabled()) {
$docker_compose['services'][$container_name]['logging'] = generate_fluentd_configuration(); $docker_compose['services'][$container_name]['logging'] = generate_fluentd_configuration();
} }
if (count($this->database->ports_mappings_array) > 0) { if (count($this->database->ports_mappings_array) > 0) {
$docker_compose['services'][$container_name]['ports'] = $this->database->ports_mappings_array; $docker_compose['services'][$container_name]['ports'] = $this->database->ports_mappings_array;
} }
$docker_compose['services'][$container_name]['volumes'] ??= [];
if (count($persistent_storages) > 0) { 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) { if (count($persistent_file_volumes) > 0) {
$docker_compose['services'][$container_name]['volumes'] = $persistent_file_volumes->map(function ($item) { $docker_compose['services'][$container_name]['volumes'] = array_merge(
return "$item->fs_path:$item->mount_path"; $docker_compose['services'][$container_name]['volumes'] ?? [],
})->toArray(); $persistent_file_volumes->map(function ($item) {
return "$item->fs_path:$item->mount_path";
})->toArray()
);
} }
if (count($volume_names) > 0) { if (count($volume_names) > 0) {
$docker_compose['volumes'] = $volume_names; $docker_compose['volumes'] = $volume_names;
} }
if (! is_null($this->database->mongo_conf) || ! empty($this->database->mongo_conf)) {
$docker_compose['services'][$container_name]['volumes'][] = [ if (! empty($this->database->mongo_conf)) {
'type' => 'bind', $docker_compose['services'][$container_name]['volumes'] = array_merge(
'source' => $this->configuration_dir.'/mongod.conf', $docker_compose['services'][$container_name]['volumes'] ?? [],
'target' => '/etc/mongo/mongod.conf', [[
'read_only' => true, 'type' => 'bind',
]; 'source' => $this->configuration_dir.'/mongod.conf',
$docker_compose['services'][$container_name]['command'] = $startCommand.' --config /etc/mongo/mongod.conf'; 'target' => '/etc/mongo/mongod.conf',
'read_only' => true,
]]
);
$docker_compose['services'][$container_name]['command'] = ['mongod', '--config', '/etc/mongo/mongod.conf'];
} }
$this->add_default_database(); $this->add_default_database();
$docker_compose['services'][$container_name]['volumes'][] = [
'type' => 'bind', $docker_compose['services'][$container_name]['volumes'] = array_merge(
'source' => $this->configuration_dir.'/docker-entrypoint-initdb.d', $docker_compose['services'][$container_name]['volumes'] ?? [],
'target' => '/docker-entrypoint-initdb.d', [[
'read_only' => true, '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 // Add custom docker run options
$docker_run_options = convertDockerRunToCompose($this->database->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); $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 = Yaml::dump($docker_compose, 10);
$docker_compose_base64 = base64_encode($docker_compose); $docker_compose_base64 = base64_encode($docker_compose);
$this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null"; $this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null";
@@ -128,6 +257,9 @@ class StartMongodb
$this->commands[] = "echo 'Pulling {$database->image} image.'"; $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 pull";
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d"; $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.'"; $this->commands[] = "echo 'Database started.'";
return remote_process($this->commands, $database->destination->server, callEventOnFinish: 'DatabaseStatusChanged'); return remote_process($this->commands, $database->destination->server, callEventOnFinish: 'DatabaseStatusChanged');

View File

@@ -2,6 +2,8 @@
namespace App\Actions\Database; namespace App\Actions\Database;
use App\Helpers\SslHelper;
use App\Models\SslCertificate;
use App\Models\StandaloneMysql; use App\Models\StandaloneMysql;
use Lorisleiva\Actions\Concerns\AsAction; use Lorisleiva\Actions\Concerns\AsAction;
use Symfony\Component\Yaml\Yaml; use Symfony\Component\Yaml\Yaml;
@@ -16,6 +18,8 @@ class StartMysql
public string $configuration_dir; public string $configuration_dir;
private ?SslCertificate $ssl_certificate = null;
public function handle(StandaloneMysql $database) public function handle(StandaloneMysql $database)
{ {
$this->database = $database; $this->database = $database;
@@ -25,9 +29,64 @@ class StartMysql
$this->commands = [ $this->commands = [
"echo 'Starting database.'", "echo 'Starting database.'",
"echo 'Creating directories.'",
"mkdir -p $this->configuration_dir", "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();
if (! $caCert) {
$server->generateCaCertificate();
$caCert = SslCertificate::where('server_id', $server->id)->where('is_ca_certificate', true)->first();
}
if (! $caCert) {
$this->dispatch('error', 'No CA certificate found for this database. Please generate a CA certificate for this server in the server/advanced page.');
return;
}
$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_storages = $this->generate_local_persistent_volumes();
$persistent_file_volumes = $this->database->fileStorages()->get(); $persistent_file_volumes = $this->database->fileStorages()->get();
$volume_names = $this->generate_local_persistent_volumes_only_volume_names(); $volume_names = $this->generate_local_persistent_volumes_only_volume_names();
@@ -67,39 +126,83 @@ class StartMysql
], ],
], ],
]; ];
if (! is_null($this->database->limits_cpuset)) { if (! is_null($this->database->limits_cpuset)) {
data_set($docker_compose, "services.{$container_name}.cpuset", $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()) { if ($this->database->destination->server->isLogDrainEnabled() && $this->database->isLogDrainEnabled()) {
$docker_compose['services'][$container_name]['logging'] = generate_fluentd_configuration(); $docker_compose['services'][$container_name]['logging'] = generate_fluentd_configuration();
} }
if (count($this->database->ports_mappings_array) > 0) { if (count($this->database->ports_mappings_array) > 0) {
$docker_compose['services'][$container_name]['ports'] = $this->database->ports_mappings_array; $docker_compose['services'][$container_name]['ports'] = $this->database->ports_mappings_array;
} }
$docker_compose['services'][$container_name]['volumes'] ??= [];
if (count($persistent_storages) > 0) { 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) { if (count($persistent_file_volumes) > 0) {
$docker_compose['services'][$container_name]['volumes'] = $persistent_file_volumes->map(function ($item) { $docker_compose['services'][$container_name]['volumes'] = array_merge(
return "$item->fs_path:$item->mount_path"; $docker_compose['services'][$container_name]['volumes'] ?? [],
})->toArray(); $persistent_file_volumes->map(function ($item) {
return "$item->fs_path:$item->mount_path";
})->toArray()
);
} }
if (count($volume_names) > 0) { if (count($volume_names) > 0) {
$docker_compose['volumes'] = $volume_names; $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)) { if (! is_null($this->database->mysql_conf) || ! empty($this->database->mysql_conf)) {
$docker_compose['services'][$container_name]['volumes'][] = [ $docker_compose['services'][$container_name]['volumes'] = array_merge(
'type' => 'bind', $docker_compose['services'][$container_name]['volumes'] ?? [],
'source' => $this->configuration_dir.'/custom-config.cnf', [
'target' => '/etc/mysql/conf.d/custom-config.cnf', [
'read_only' => true, '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 // Add custom docker run options
$docker_run_options = convertDockerRunToCompose($this->database->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); $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 = Yaml::dump($docker_compose, 10);
$docker_compose_base64 = base64_encode($docker_compose); $docker_compose_base64 = base64_encode($docker_compose);
$this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null"; $this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null";
@@ -108,6 +211,11 @@ class StartMysql
$this->commands[] = "echo 'Pulling {$database->image} image.'"; $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 pull";
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d"; $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.'"; $this->commands[] = "echo 'Database started.'";
return remote_process($this->commands, $database->destination->server, callEventOnFinish: 'DatabaseStatusChanged'); return remote_process($this->commands, $database->destination->server, callEventOnFinish: 'DatabaseStatusChanged');

View File

@@ -2,6 +2,8 @@
namespace App\Actions\Database; namespace App\Actions\Database;
use App\Helpers\SslHelper;
use App\Models\SslCertificate;
use App\Models\StandalonePostgresql; use App\Models\StandalonePostgresql;
use Lorisleiva\Actions\Concerns\AsAction; use Lorisleiva\Actions\Concerns\AsAction;
use Symfony\Component\Yaml\Yaml; use Symfony\Component\Yaml\Yaml;
@@ -18,6 +20,8 @@ class StartPostgresql
public string $configuration_dir; public string $configuration_dir;
private ?SslCertificate $ssl_certificate = null;
public function handle(StandalonePostgresql $database) public function handle(StandalonePostgresql $database)
{ {
$this->database = $database; $this->database = $database;
@@ -29,10 +33,65 @@ class StartPostgresql
$this->commands = [ $this->commands = [
"echo 'Starting database.'", "echo 'Starting database.'",
"echo 'Creating directories.'",
"mkdir -p $this->configuration_dir", "mkdir -p $this->configuration_dir",
"mkdir -p $this->configuration_dir/docker-entrypoint-initdb.d/", "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();
if (! $caCert) {
$server->generateCaCertificate();
$caCert = SslCertificate::where('server_id', $server->id)->where('is_ca_certificate', true)->first();
}
if (! $caCert) {
$this->dispatch('error', 'No CA certificate found for this database. Please generate a CA certificate for this server in the server/advanced page.');
return;
}
$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_storages = $this->generate_local_persistent_volumes();
$persistent_file_volumes = $this->database->fileStorages()->get(); $persistent_file_volumes = $this->database->fileStorages()->get();
$volume_names = $this->generate_local_persistent_volumes_only_volume_names(); $volume_names = $this->generate_local_persistent_volumes_only_volume_names();
@@ -77,49 +136,84 @@ class StartPostgresql
], ],
], ],
]; ];
if (filled($this->database->limits_cpuset)) { if (filled($this->database->limits_cpuset)) {
data_set($docker_compose, "services.{$container_name}.cpuset", $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()) { if ($this->database->destination->server->isLogDrainEnabled() && $this->database->isLogDrainEnabled()) {
$docker_compose['services'][$container_name]['logging'] = generate_fluentd_configuration(); $docker_compose['services'][$container_name]['logging'] = generate_fluentd_configuration();
} }
if (count($this->database->ports_mappings_array) > 0) { if (count($this->database->ports_mappings_array) > 0) {
$docker_compose['services'][$container_name]['ports'] = $this->database->ports_mappings_array; $docker_compose['services'][$container_name]['ports'] = $this->database->ports_mappings_array;
} }
$docker_compose['services'][$container_name]['volumes'] ??= [];
if (count($persistent_storages) > 0) { 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) { if (count($persistent_file_volumes) > 0) {
$docker_compose['services'][$container_name]['volumes'] = $persistent_file_volumes->map(function ($item) { $docker_compose['services'][$container_name]['volumes'] = array_merge(
return "$item->fs_path:$item->mount_path"; $docker_compose['services'][$container_name]['volumes'],
})->toArray(); $persistent_file_volumes->map(function ($item) {
return "$item->fs_path:$item->mount_path";
})->toArray()
);
} }
if (count($volume_names) > 0) { if (count($volume_names) > 0) {
$docker_compose['volumes'] = $volume_names; $docker_compose['volumes'] = $volume_names;
} }
if (count($this->init_scripts) > 0) { if (count($this->init_scripts) > 0) {
foreach ($this->init_scripts as $init_script) { foreach ($this->init_scripts as $init_script) {
$docker_compose['services'][$container_name]['volumes'][] = [ $docker_compose['services'][$container_name]['volumes'] = array_merge(
'type' => 'bind', $docker_compose['services'][$container_name]['volumes'],
'source' => $init_script, [[
'target' => '/docker-entrypoint-initdb.d/'.basename($init_script), 'type' => 'bind',
'read_only' => true, 'source' => $init_script,
]; 'target' => '/docker-entrypoint-initdb.d/'.basename($init_script),
'read_only' => true,
]]
);
} }
} }
if (filled($this->database->postgres_conf)) { if (filled($this->database->postgres_conf)) {
$docker_compose['services'][$container_name]['volumes'][] = [ $docker_compose['services'][$container_name]['volumes'] = array_merge(
'type' => 'bind', $docker_compose['services'][$container_name]['volumes'],
'source' => $this->configuration_dir.'/custom-postgres.conf', [[
'target' => '/etc/postgresql/postgresql.conf', 'type' => 'bind',
'read_only' => true, 'source' => $this->configuration_dir.'/custom-postgres.conf',
]; 'target' => '/etc/postgresql/postgresql.conf',
'read_only' => true,
]]
);
$docker_compose['services'][$container_name]['command'] = [ $docker_compose['services'][$container_name]['command'] = [
'postgres', 'postgres',
'-c', '-c',
'config_file=/etc/postgresql/postgresql.conf', '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 // Add custom docker run options
$docker_run_options = convertDockerRunToCompose($this->database->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); $docker_compose = generateCustomDockerRunOptionsForDatabases($docker_run_options, $docker_compose, $container_name, $this->database->destination->network);
@@ -132,6 +226,9 @@ class StartPostgresql
$this->commands[] = "echo 'Pulling {$database->image} image.'"; $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 pull";
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d"; $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.'"; $this->commands[] = "echo 'Database started.'";
return remote_process($this->commands, $database->destination->server, callEventOnFinish: 'DatabaseStatusChanged'); return remote_process($this->commands, $database->destination->server, callEventOnFinish: 'DatabaseStatusChanged');

View File

@@ -2,6 +2,8 @@
namespace App\Actions\Database; namespace App\Actions\Database;
use App\Helpers\SslHelper;
use App\Models\SslCertificate;
use App\Models\StandaloneRedis; use App\Models\StandaloneRedis;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Lorisleiva\Actions\Concerns\AsAction; use Lorisleiva\Actions\Concerns\AsAction;
@@ -17,6 +19,8 @@ class StartRedis
public string $configuration_dir; public string $configuration_dir;
private ?SslCertificate $ssl_certificate = null;
public function handle(StandaloneRedis $database) public function handle(StandaloneRedis $database)
{ {
$this->database = $database; $this->database = $database;
@@ -26,9 +30,62 @@ class StartRedis
$this->commands = [ $this->commands = [
"echo 'Starting database.'", "echo 'Starting database.'",
"echo 'Creating directories.'",
"mkdir -p $this->configuration_dir", "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();
if (! $caCert) {
$server->generateCaCertificate();
$caCert = SslCertificate::where('server_id', $server->id)->where('is_ca_certificate', true)->first();
}
if (! $caCert) {
$this->dispatch('error', 'No CA certificate found for this database. Please generate a CA certificate for this server in the server/advanced page.');
return;
}
$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_storages = $this->generate_local_persistent_volumes();
$persistent_file_volumes = $this->database->fileStorages()->get(); $persistent_file_volumes = $this->database->fileStorages()->get();
$volume_names = $this->generate_local_persistent_volumes_only_volume_names(); $volume_names = $this->generate_local_persistent_volumes_only_volume_names();
@@ -76,26 +133,55 @@ class StartRedis
], ],
], ],
]; ];
if (! is_null($this->database->limits_cpuset)) { if (! is_null($this->database->limits_cpuset)) {
data_set($docker_compose, "services.{$container_name}.cpuset", $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()) { if ($this->database->destination->server->isLogDrainEnabled() && $this->database->isLogDrainEnabled()) {
$docker_compose['services'][$container_name]['logging'] = generate_fluentd_configuration(); $docker_compose['services'][$container_name]['logging'] = generate_fluentd_configuration();
} }
if (count($this->database->ports_mappings_array) > 0) { if (count($this->database->ports_mappings_array) > 0) {
$docker_compose['services'][$container_name]['ports'] = $this->database->ports_mappings_array; $docker_compose['services'][$container_name]['ports'] = $this->database->ports_mappings_array;
} }
$docker_compose['services'][$container_name]['volumes'] ??= [];
if (count($persistent_storages) > 0) { 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) { if (count($persistent_file_volumes) > 0) {
$docker_compose['services'][$container_name]['volumes'] = $persistent_file_volumes->map(function ($item) { $docker_compose['services'][$container_name]['volumes'] = array_merge(
return "$item->fs_path:$item->mount_path"; $docker_compose['services'][$container_name]['volumes'],
})->toArray(); $persistent_file_volumes->map(function ($item) {
return "$item->fs_path:$item->mount_path";
})->toArray()
);
} }
if (count($volume_names) > 0) { if (count($volume_names) > 0) {
$docker_compose['volumes'] = $volume_names; $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)) { if (! is_null($this->database->redis_conf) || ! empty($this->database->redis_conf)) {
$docker_compose['services'][$container_name]['volumes'][] = [ $docker_compose['services'][$container_name]['volumes'][] = [
'type' => 'bind', 'type' => 'bind',
@@ -116,6 +202,9 @@ class StartRedis
$this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md"; $this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md";
$this->commands[] = "echo 'Pulling {$database->image} image.'"; $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 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[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
$this->commands[] = "echo 'Database started.'"; $this->commands[] = "echo 'Database started.'";
@@ -202,6 +291,20 @@ class StartRedis
$command = "redis-server --requirepass {$this->database->redis_password} --appendonly yes"; $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; return $command;
} }

View File

@@ -26,7 +26,7 @@ class StopDatabase
} }
$this->stopContainer($database, $database->uuid, 300); $this->stopContainer($database, $database->uuid, 300);
if (! $isDeleteOperation) { if ($isDeleteOperation) {
if ($dockerCleanup) { if ($dockerCleanup) {
CleanupDocker::dispatch($server, true); CleanupDocker::dispatch($server, true);
} }

View File

@@ -24,5 +24,6 @@ class ResetUserPassword implements ResetsUserPasswords
$user->forceFill([ $user->forceFill([
'password' => Hash::make($input['password']), 'password' => Hash::make($input['password']),
])->save(); ])->save();
$user->deleteAllSessions();
} }
} }

View File

@@ -27,13 +27,9 @@ class CheckProxy
return false; return false;
} }
$proxyType = $server->proxyType(); $proxyType = $server->proxyType();
if (is_null($proxyType) || $proxyType === 'NONE' || $server->proxy->force_stop) { if ((is_null($proxyType) || $proxyType === 'NONE' || $server->proxy->force_stop) && ! $fromUI) {
return false; return false;
} }
['uptime' => $uptime, 'error' => $error] = $server->validateConnection();
if (! $uptime) {
throw new \Exception($error);
}
if (! $server->isProxyShouldRun()) { if (! $server->isProxyShouldRun()) {
if ($fromUI) { if ($fromUI) {
throw new \Exception('Proxy should not run. You selected the Custom Proxy.'); throw new \Exception('Proxy should not run. You selected the Custom Proxy.');
@@ -41,8 +37,12 @@ class CheckProxy
return false; return false;
} }
} }
// Determine proxy container name based on environment
$proxyContainerName = $server->isSwarm() ? 'coolify-proxy_traefik' : 'coolify-proxy';
if ($server->isSwarm()) { if ($server->isSwarm()) {
$status = getContainerStatus($server, 'coolify-proxy_traefik'); $status = getContainerStatus($server, $proxyContainerName);
$server->proxy->set('status', $status); $server->proxy->set('status', $status);
$server->save(); $server->save();
if ($status === 'running') { if ($status === 'running') {
@@ -51,7 +51,7 @@ class CheckProxy
return true; return true;
} else { } else {
$status = getContainerStatus($server, 'coolify-proxy'); $status = getContainerStatus($server, $proxyContainerName);
if ($status === 'running') { if ($status === 'running') {
$server->proxy->set('status', 'running'); $server->proxy->set('status', 'running');
$server->save(); $server->save();
@@ -65,9 +65,18 @@ class CheckProxy
if ($server->id === 0) { if ($server->id === 0) {
$ip = 'host.docker.internal'; $ip = 'host.docker.internal';
} }
$portsToCheck = ['80', '443']; $portsToCheck = ['80', '443'];
foreach ($portsToCheck as $port) {
// Use the smart port checker that handles dual-stack properly
if ($this->isPortConflict($server, $port, $proxyContainerName)) {
if ($fromUI) {
throw new \Exception("Port $port is in use.<br>You must stop the process using this port.<br><br>Docs: <a target='_blank' class='dark:text-white hover:underline' href='https://coolify.io/docs'>https://coolify.io/docs</a><br>Discord: <a target='_blank' class='dark:text-white hover:underline' href='https://coolify.io/discord'>https://coolify.io/discord</a>");
} else {
return false;
}
}
}
try { try {
if ($server->proxyType() !== ProxyTypes::NONE->value) { if ($server->proxyType() !== ProxyTypes::NONE->value) {
$proxyCompose = CheckConfiguration::run($server); $proxyCompose = CheckConfiguration::run($server);
@@ -94,18 +103,148 @@ class CheckProxy
if (count($portsToCheck) === 0) { if (count($portsToCheck) === 0) {
return false; return false;
} }
foreach ($portsToCheck as $port) {
$connection = @fsockopen($ip, $port);
if (is_resource($connection) && fclose($connection)) {
if ($fromUI) {
throw new \Exception("Port $port is in use.<br>You must stop the process using this port.<br>Docs: <a target='_blank' href='https://coolify.io/docs'>https://coolify.io/docs</a><br>Discord: <a target='_blank' href='https://coollabs.io/discord'>https://coollabs.io/discord</a>");
} else {
return false;
}
}
}
return true; return true;
} }
} }
/**
* Smart port checker that handles dual-stack configurations
* Returns true only if there's a real port conflict (not just dual-stack)
*/
private function isPortConflict(Server $server, string $port, string $proxyContainerName): bool
{
// First check if our own proxy is using this port (which is fine)
try {
$getProxyContainerId = "docker ps -a --filter name=$proxyContainerName --format '{{.ID}}'";
$containerId = trim(instant_remote_process([$getProxyContainerId], $server));
if (! empty($containerId)) {
$checkProxyPort = "docker inspect $containerId --format '{{json .NetworkSettings.Ports}}' | grep '\"$port/tcp\"'";
try {
instant_remote_process([$checkProxyPort], $server);
// Our proxy is using the port, which is fine
return false;
} catch (\Throwable $e) {
// Our container exists but not using this port
}
}
} catch (\Throwable $e) {
// Container not found or error checking, continue with regular checks
}
// Command sets for different ways to check ports, ordered by preference
$commandSets = [
// Set 1: Use ss to check listener counts by protocol stack
[
'available' => 'command -v ss >/dev/null 2>&1',
'check' => [
// Get listening process details
"ss_output=\$(ss -Htuln state listening sport = :$port 2>/dev/null) && echo \"\$ss_output\"",
// Count IPv4 listeners
"echo \"\$ss_output\" | grep -c ':$port '",
],
],
// Set 2: Use netstat as alternative to ss
[
'available' => 'command -v netstat >/dev/null 2>&1',
'check' => [
// Get listening process details
"netstat_output=\$(netstat -tuln 2>/dev/null) && echo \"\$netstat_output\" | grep ':$port '",
// Count listeners
"echo \"\$netstat_output\" | grep ':$port ' | grep -c 'LISTEN'",
],
],
// Set 3: Use lsof as last resort
[
'available' => 'command -v lsof >/dev/null 2>&1',
'check' => [
// Get process using the port
"lsof -i :$port -P -n | grep 'LISTEN'",
// Count listeners
"lsof -i :$port -P -n | grep 'LISTEN' | wc -l",
],
],
];
// Try each command set until we find one available
foreach ($commandSets as $set) {
try {
// Check if the command is available
instant_remote_process([$set['available']], $server);
// Run the actual check commands
$output = instant_remote_process($set['check'], $server, true);
// Parse the output lines
$lines = explode("\n", trim($output));
// Get the detailed output and listener count
$details = trim($lines[0] ?? '');
$count = intval(trim($lines[1] ?? '0'));
// If no listeners or empty result, port is free
if ($count == 0 || empty($details)) {
return false;
}
// Try to detect if this is our coolify-proxy
if (strpos($details, 'docker') !== false || strpos($details, $proxyContainerName) !== false) {
// It's likely our docker or proxy, which is fine
return false;
}
// Check for dual-stack scenario - typically 1-2 listeners (IPv4+IPv6)
// If exactly 2 listeners and both have same port, likely dual-stack
if ($count <= 2) {
// Check if it looks like a standard dual-stack setup
$isDualStack = false;
// Look for IPv4 and IPv6 in the listing (ss output format)
if (preg_match('/LISTEN.*:'.$port.'\s/', $details) &&
(preg_match('/\*:'.$port.'\s/', $details) ||
preg_match('/:::'.$port.'\s/', $details))) {
$isDualStack = true;
}
// For netstat format
if (strpos($details, '0.0.0.0:'.$port) !== false &&
strpos($details, ':::'.$port) !== false) {
$isDualStack = true;
}
// For lsof format (IPv4 and IPv6)
if (strpos($details, '*:'.$port) !== false &&
preg_match('/\*:'.$port.'.*IPv4/', $details) &&
preg_match('/\*:'.$port.'.*IPv6/', $details)) {
$isDualStack = true;
}
if ($isDualStack) {
return false; // This is just a normal dual-stack setup
}
}
// If we get here, it's likely a real port conflict
return true;
} catch (\Throwable $e) {
// This command set failed, try the next one
continue;
}
}
// Fallback to simpler check if all above methods fail
try {
// Just try to bind to the port directly to see if it's available
$checkCommand = "nc -z -w1 127.0.0.1 $port >/dev/null 2>&1 && echo 'in-use' || echo 'free'";
$result = instant_remote_process([$checkCommand], $server, true);
return trim($result) === 'in-use';
} catch (\Throwable $e) {
// If everything fails, assume the port is free to avoid false positives
return false;
}
}
} }

View File

@@ -0,0 +1,56 @@
<?php
namespace App\Actions\Proxy;
use App\Models\Server;
use Carbon\Carbon;
use Illuminate\Process\InvokedProcess;
use Illuminate\Support\Facades\Process;
use Lorisleiva\Actions\Concerns\AsAction;
class StopProxy
{
use AsAction;
public function handle(Server $server, bool $forceStop = true)
{
try {
$containerName = $server->isSwarm() ? 'coolify-proxy_traefik' : 'coolify-proxy';
$timeout = 30;
$process = $this->stopContainer($containerName, $timeout);
$startTime = Carbon::now()->getTimestamp();
while ($process->running()) {
if (Carbon::now()->getTimestamp() - $startTime >= $timeout) {
$this->forceStopContainer($containerName, $server);
break;
}
usleep(100000);
}
$this->removeContainer($containerName, $server);
} catch (\Throwable $e) {
return handleError($e);
} finally {
$server->proxy->force_stop = $forceStop;
$server->proxy->status = 'exited';
$server->save();
}
}
private function stopContainer(string $containerName, int $timeout): InvokedProcess
{
return Process::timeout($timeout)->start("docker stop --time=$timeout $containerName");
}
private function forceStopContainer(string $containerName, Server $server)
{
instant_remote_process(["docker kill $containerName"], $server, throwError: false);
}
private function removeContainer(string $containerName, Server $server)
{
instant_remote_process(["docker rm -f $containerName"], $server, throwError: false);
}
}

View File

@@ -2,7 +2,9 @@
namespace App\Actions\Server; namespace App\Actions\Server;
use App\Helpers\SslHelper;
use App\Models\Server; use App\Models\Server;
use App\Models\SslCertificate;
use App\Models\StandaloneDocker; use App\Models\StandaloneDocker;
use Lorisleiva\Actions\Concerns\AsAction; use Lorisleiva\Actions\Concerns\AsAction;
@@ -17,6 +19,27 @@ class InstallDocker
if (! $supported_os_type) { 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>.'); 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('{ $config = base64_encode('{
"log-driver": "json-file", "log-driver": "json-file",
"log-opts": { "log-opts": {

View File

@@ -25,7 +25,7 @@ class StartSentinel
$endpoint = data_get($server, 'settings.sentinel_custom_url'); $endpoint = data_get($server, 'settings.sentinel_custom_url');
$debug = data_get($server, 'settings.is_sentinel_debug_enabled'); $debug = data_get($server, 'settings.is_sentinel_debug_enabled');
$mountDir = '/data/coolify/sentinel'; $mountDir = '/data/coolify/sentinel';
$image = "ghcr.io/coollabsio/sentinel:$version"; $image = config('constants.coolify.registry_url').'/coollabsio/sentinel:'.$version;
if (! $endpoint) { if (! $endpoint) {
throw new \Exception('You should set FQDN in Instance Settings.'); throw new \Exception('You should set FQDN in Instance Settings.');
} }

View File

@@ -52,7 +52,8 @@ class UpdateCoolify
{ {
PullHelperImageJob::dispatch($this->server); PullHelperImageJob::dispatch($this->server);
instant_remote_process(["docker pull -q ghcr.io/coollabsio/coolify:{$this->latestVersion}"], $this->server, false); $image = config('constants.coolify.registry_url').'/coollabsio/coolify:'.$this->latestVersion;
instant_remote_process(["docker pull -q $image"], $this->server, false);
remote_process([ remote_process([
'curl -fsSL https://cdn.coollabs.io/coolify/upgrade.sh -o /data/coolify/source/upgrade.sh', 'curl -fsSL https://cdn.coollabs.io/coolify/upgrade.sh -o /data/coolify/source/upgrade.sh',

View File

@@ -23,7 +23,7 @@ class StopService
$containersToStop = $service->getContainersToStop(); $containersToStop = $service->getContainersToStop();
$service->stopContainers($containersToStop, $server); $service->stopContainers($containersToStop, $server);
if (! $isDeleteOperation) { if ($isDeleteOperation) {
$service->delete_connected_networks($service->uuid); $service->delete_connected_networks($service->uuid);
if ($dockerCleanup) { if ($dockerCleanup) {
CleanupDocker::dispatch($server, true); CleanupDocker::dispatch($server, true);

View File

@@ -5,12 +5,10 @@ namespace App\Console\Commands;
use App\Models\InstanceSettings; use App\Models\InstanceSettings;
use Illuminate\Console\Command; use Illuminate\Console\Command;
use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Process;
use Symfony\Component\Yaml\Yaml;
class Dev extends Command class Dev extends Command
{ {
protected $signature = 'dev {--init} {--generate-openapi}'; protected $signature = 'dev {--init}';
protected $description = 'Helper commands for development.'; protected $description = 'Helper commands for development.';
@@ -21,36 +19,6 @@ class Dev extends Command
return; return;
} }
if ($this->option('generate-openapi')) {
$this->generateOpenApi();
return;
}
}
public function generateOpenApi()
{
// Generate OpenAPI documentation
echo "Generating OpenAPI documentation.\n";
// https://github.com/OAI/OpenAPI-Specification/releases
$process = Process::run([
'/var/www/html/vendor/bin/openapi',
'app',
'-o',
'openapi.yaml',
'--version',
'3.1.0',
]);
$error = $process->errorOutput();
$error = preg_replace('/^.*an object literal,.*$/m', '', $error);
$error = preg_replace('/^\h*\v+/m', '', $error);
echo $error;
echo $process->output();
// Convert YAML to JSON
$yaml = file_get_contents('openapi.yaml');
$json = json_encode(Yaml::parse($yaml), JSON_PRETTY_PRINT);
file_put_contents('openapi.json', $json);
echo "Converted OpenAPI YAML to JSON.\n";
} }
public function init() public function init()

View File

@@ -4,6 +4,7 @@ namespace App\Console\Commands;
use Illuminate\Console\Command; use Illuminate\Console\Command;
use Illuminate\Support\Facades\Process; use Illuminate\Support\Facades\Process;
use Symfony\Component\Yaml\Yaml;
class OpenApi extends Command class OpenApi extends Command
{ {
@@ -29,5 +30,10 @@ class OpenApi extends Command
$error = preg_replace('/^\h*\v+/m', '', $error); $error = preg_replace('/^\h*\v+/m', '', $error);
echo $error; echo $error;
echo $process->output(); echo $process->output();
$yaml = file_get_contents('openapi.yaml');
$json = json_encode(Yaml::parse($yaml), JSON_PRETTY_PRINT);
file_put_contents('openapi.json', $json);
echo "Converted OpenAPI YAML to JSON.\n";
} }
} }

View File

@@ -39,7 +39,13 @@ class RootResetPassword extends Command
} }
$this->info('Updating root password...'); $this->info('Updating root password...');
try { try {
User::find(0)->update(['password' => Hash::make($password)]); $user = User::find(0);
if (! $user) {
$this->error('Root user not found.');
return;
}
$user->update(['password' => Hash::make($password)]);
$this->info('Root password updated successfully.'); $this->info('Root password updated successfully.');
} catch (\Exception $e) { } catch (\Exception $e) {
$this->error('Failed to update root password.'); $this->error('Failed to update root password.');

View File

@@ -9,6 +9,7 @@ use App\Jobs\CleanupInstanceStuffsJob;
use App\Jobs\DatabaseBackupJob; use App\Jobs\DatabaseBackupJob;
use App\Jobs\DockerCleanupJob; use App\Jobs\DockerCleanupJob;
use App\Jobs\PullTemplatesFromCDN; use App\Jobs\PullTemplatesFromCDN;
use App\Jobs\RegenerateSslCertJob;
use App\Jobs\ScheduledTaskJob; use App\Jobs\ScheduledTaskJob;
use App\Jobs\ServerCheckJob; use App\Jobs\ServerCheckJob;
use App\Jobs\ServerStorageCheckJob; use App\Jobs\ServerStorageCheckJob;
@@ -83,6 +84,8 @@ class Kernel extends ConsoleKernel
$this->checkScheduledBackups(); $this->checkScheduledBackups();
$this->checkScheduledTasks(); $this->checkScheduledTasks();
$this->scheduleInstance->job(new RegenerateSslCertJob)->twiceDaily();
$this->scheduleInstance->command('cleanup:database --yes')->daily(); $this->scheduleInstance->command('cleanup:database --yes')->daily();
$this->scheduleInstance->command('uploads:clear')->everyTwoMinutes(); $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

@@ -932,10 +932,31 @@ class ApplicationsController extends Controller
if (! $githubApp) { if (! $githubApp) {
return response()->json(['message' => 'Github App not found.'], 404); return response()->json(['message' => 'Github App not found.'], 404);
} }
$token = generateGithubInstallationToken($githubApp);
if (! $token) {
return response()->json(['message' => 'Failed to generate Github App token.'], 400);
}
$repositories = collect();
$page = 1;
$repositories = loadRepositoryByPage($githubApp, $token, $page);
if ($repositories['total_count'] > 0) {
while (count($repositories['repositories']) < $repositories['total_count']) {
$page++;
$repositories = loadRepositoryByPage($githubApp, $token, $page);
}
}
$gitRepository = $request->git_repository; $gitRepository = $request->git_repository;
if (str($gitRepository)->startsWith('http') || str($gitRepository)->contains('github.com')) { if (str($gitRepository)->startsWith('http') || str($gitRepository)->contains('github.com')) {
$gitRepository = str($gitRepository)->replace('https://', '')->replace('http://', '')->replace('github.com/', ''); $gitRepository = str($gitRepository)->replace('https://', '')->replace('http://', '')->replace('github.com/', '');
} }
$gitRepositoryFound = collect($repositories['repositories'])->firstWhere('full_name', $gitRepository);
if (! $gitRepositoryFound) {
return response()->json(['message' => 'Repository not found.'], 404);
}
$repository_project_id = data_get($gitRepositoryFound, 'id');
$application = new Application; $application = new Application;
removeUnnecessaryFieldsFromRequest($request); removeUnnecessaryFieldsFromRequest($request);
@@ -966,6 +987,8 @@ class ApplicationsController extends Controller
$application->environment_id = $environment->id; $application->environment_id = $environment->id;
$application->source_type = $githubApp->getMorphClass(); $application->source_type = $githubApp->getMorphClass();
$application->source_id = $githubApp->id; $application->source_id = $githubApp->id;
$application->repository_project_id = $repository_project_id;
$application->save(); $application->save();
$application->refresh(); $application->refresh();
if (isset($useBuildServer)) { if (isset($useBuildServer)) {
@@ -1310,7 +1333,6 @@ class ApplicationsController extends Controller
$service->destination_type = $destination->getMorphClass(); $service->destination_type = $destination->getMorphClass();
$service->save(); $service->save();
$service->name = "service-$service->uuid";
$service->parse(isNew: true); $service->parse(isNew: true);
if ($instantDeploy) { if ($instantDeploy) {
StartService::dispatch($service); StartService::dispatch($service);
@@ -2859,198 +2881,198 @@ class ApplicationsController extends Controller
); );
} }
#[OA\Post( // #[OA\Post(
summary: 'Execute Command', // summary: 'Execute Command',
description: "Execute a command on the application's current container.", // description: "Execute a command on the application's current container.",
path: '/applications/{uuid}/execute', // path: '/applications/{uuid}/execute',
operationId: 'execute-command-application', // operationId: 'execute-command-application',
security: [ // security: [
['bearerAuth' => []], // ['bearerAuth' => []],
], // ],
tags: ['Applications'], // tags: ['Applications'],
parameters: [ // parameters: [
new OA\Parameter( // new OA\Parameter(
name: 'uuid', // name: 'uuid',
in: 'path', // in: 'path',
description: 'UUID of the application.', // description: 'UUID of the application.',
required: true, // required: true,
schema: new OA\Schema( // schema: new OA\Schema(
type: 'string', // type: 'string',
format: 'uuid', // format: 'uuid',
) // )
), // ),
], // ],
requestBody: new OA\RequestBody( // requestBody: new OA\RequestBody(
required: true, // required: true,
description: 'Command to execute.', // description: 'Command to execute.',
content: new OA\MediaType( // content: new OA\MediaType(
mediaType: 'application/json', // mediaType: 'application/json',
schema: new OA\Schema( // schema: new OA\Schema(
type: 'object', // type: 'object',
properties: [ // properties: [
'command' => ['type' => 'string', 'description' => 'Command to execute.'], // 'command' => ['type' => 'string', 'description' => 'Command to execute.'],
], // ],
), // ),
), // ),
), // ),
responses: [ // responses: [
new OA\Response( // new OA\Response(
response: 200, // response: 200,
description: "Execute a command on the application's current container.", // description: "Execute a command on the application's current container.",
content: [ // content: [
new OA\MediaType( // new OA\MediaType(
mediaType: 'application/json', // mediaType: 'application/json',
schema: new OA\Schema( // schema: new OA\Schema(
type: 'object', // type: 'object',
properties: [ // properties: [
'message' => ['type' => 'string', 'example' => 'Command executed.'], // 'message' => ['type' => 'string', 'example' => 'Command executed.'],
'response' => ['type' => 'string'], // 'response' => ['type' => 'string'],
] // ]
) // )
), // ),
] // ]
), // ),
new OA\Response( // new OA\Response(
response: 401, // response: 401,
ref: '#/components/responses/401', // ref: '#/components/responses/401',
), // ),
new OA\Response( // new OA\Response(
response: 400, // response: 400,
ref: '#/components/responses/400', // ref: '#/components/responses/400',
), // ),
new OA\Response( // new OA\Response(
response: 404, // response: 404,
ref: '#/components/responses/404', // ref: '#/components/responses/404',
), // ),
] // ]
)] // )]
public function execute_command_by_uuid(Request $request) // public function execute_command_by_uuid(Request $request)
{ // {
// TODO: Need to review this from security perspective, to not allow arbitrary command execution // // TODO: Need to review this from security perspective, to not allow arbitrary command execution
$allowedFields = ['command']; // $allowedFields = ['command'];
$teamId = getTeamIdFromToken(); // $teamId = getTeamIdFromToken();
if (is_null($teamId)) { // if (is_null($teamId)) {
return invalidTokenResponse(); // return invalidTokenResponse();
} // }
$uuid = $request->route('uuid'); // $uuid = $request->route('uuid');
if (! $uuid) { // if (! $uuid) {
return response()->json(['message' => 'UUID is required.'], 400); // return response()->json(['message' => 'UUID is required.'], 400);
} // }
$application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->uuid)->first(); // $application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->uuid)->first();
if (! $application) { // if (! $application) {
return response()->json(['message' => 'Application not found.'], 404); // return response()->json(['message' => 'Application not found.'], 404);
} // }
$return = validateIncomingRequest($request); // $return = validateIncomingRequest($request);
if ($return instanceof \Illuminate\Http\JsonResponse) { // if ($return instanceof \Illuminate\Http\JsonResponse) {
return $return; // return $return;
} // }
$validator = customApiValidator($request->all(), [ // $validator = customApiValidator($request->all(), [
'command' => 'string|required', // 'command' => 'string|required',
]); // ]);
$extraFields = array_diff(array_keys($request->all()), $allowedFields); // $extraFields = array_diff(array_keys($request->all()), $allowedFields);
if ($validator->fails() || ! empty($extraFields)) { // if ($validator->fails() || ! empty($extraFields)) {
$errors = $validator->errors(); // $errors = $validator->errors();
if (! empty($extraFields)) { // if (! empty($extraFields)) {
foreach ($extraFields as $field) { // foreach ($extraFields as $field) {
$errors->add($field, 'This field is not allowed.'); // $errors->add($field, 'This field is not allowed.');
} // }
} // }
return response()->json([ // return response()->json([
'message' => 'Validation failed.', // 'message' => 'Validation failed.',
'errors' => $errors, // 'errors' => $errors,
], 422); // ], 422);
} // }
$container = getCurrentApplicationContainerStatus($application->destination->server, $application->id)->firstOrFail(); // $container = getCurrentApplicationContainerStatus($application->destination->server, $application->id)->firstOrFail();
$status = getContainerStatus($application->destination->server, $container['Names']); // $status = getContainerStatus($application->destination->server, $container['Names']);
if ($status !== 'running') { // if ($status !== 'running') {
return response()->json([ // return response()->json([
'message' => 'Application is not running.', // 'message' => 'Application is not running.',
], 400); // ], 400);
} // }
$commands = collect([ // $commands = collect([
executeInDocker($container['Names'], $request->command), // executeInDocker($container['Names'], $request->command),
]); // ]);
$res = instant_remote_process(command: $commands, server: $application->destination->server); // $res = instant_remote_process(command: $commands, server: $application->destination->server);
return response()->json([ // return response()->json([
'message' => 'Command executed.', // 'message' => 'Command executed.',
'response' => $res, // 'response' => $res,
]); // ]);
} // }
private function validateDataApplications(Request $request, Server $server) private function validateDataApplications(Request $request, Server $server)
{ {
$teamId = getTeamIdFromToken(); $teamId = getTeamIdFromToken();
// Validate ports_mappings // Validate ports_mappings
if ($request->has('ports_mappings')) { if ($request->has('ports_mappings')) {
$ports = []; $ports = [];
foreach (explode(',', $request->ports_mappings) as $portMapping) { foreach (explode(',', $request->ports_mappings) as $portMapping) {
$port = explode(':', $portMapping); $port = explode(':', $portMapping);
if (in_array($port[0], $ports)) { if (in_array($port[0], $ports)) {
return response()->json([ return response()->json([
'message' => 'Validation failed.', 'message' => 'Validation failed.',
'errors' => [ 'errors' => [
'ports_mappings' => 'The first number before : should be unique between mappings.', 'ports_mappings' => 'The first number before : should be unique between mappings.',
], ],
], 422); ], 422);
} }
$ports[] = $port[0]; $ports[] = $port[0];
} }
} }
// Validate custom_labels // Validate custom_labels
if ($request->has('custom_labels')) { if ($request->has('custom_labels')) {
if (! isBase64Encoded($request->custom_labels)) { if (! isBase64Encoded($request->custom_labels)) {
return response()->json([ return response()->json([
'message' => 'Validation failed.', 'message' => 'Validation failed.',
'errors' => [ 'errors' => [
'custom_labels' => 'The custom_labels should be base64 encoded.', 'custom_labels' => 'The custom_labels should be base64 encoded.',
], ],
], 422); ], 422);
} }
$customLabels = base64_decode($request->custom_labels); $customLabels = base64_decode($request->custom_labels);
if (mb_detect_encoding($customLabels, 'ASCII', true) === false) { if (mb_detect_encoding($customLabels, 'ASCII', true) === false) {
return response()->json([ return response()->json([
'message' => 'Validation failed.', 'message' => 'Validation failed.',
'errors' => [ 'errors' => [
'custom_labels' => 'The custom_labels should be base64 encoded.', 'custom_labels' => 'The custom_labels should be base64 encoded.',
], ],
], 422); ], 422);
} }
} }
if ($request->has('domains') && $server->isProxyShouldRun()) { if ($request->has('domains') && $server->isProxyShouldRun()) {
$uuid = $request->uuid; $uuid = $request->uuid;
$fqdn = $request->domains; $fqdn = $request->domains;
$fqdn = str($fqdn)->replaceEnd(',', '')->trim(); $fqdn = str($fqdn)->replaceEnd(',', '')->trim();
$fqdn = str($fqdn)->replaceStart(',', '')->trim(); $fqdn = str($fqdn)->replaceStart(',', '')->trim();
$errors = []; $errors = [];
$fqdn = str($fqdn)->trim()->explode(',')->map(function ($domain) use (&$errors) { $fqdn = str($fqdn)->trim()->explode(',')->map(function ($domain) use (&$errors) {
if (filter_var($domain, FILTER_VALIDATE_URL) === false) { if (filter_var($domain, FILTER_VALIDATE_URL) === false) {
$errors[] = 'Invalid domain: '.$domain; $errors[] = 'Invalid domain: '.$domain;
} }
return str($domain)->trim()->lower(); return str($domain)->trim()->lower();
}); });
if (count($errors) > 0) { if (count($errors) > 0) {
return response()->json([ return response()->json([
'message' => 'Validation failed.', 'message' => 'Validation failed.',
'errors' => $errors, 'errors' => $errors,
], 422); ], 422);
} }
if (checkIfDomainIsAlreadyUsed($fqdn, $teamId, $uuid)) { if (checkIfDomainIsAlreadyUsed($fqdn, $teamId, $uuid)) {
return response()->json([ return response()->json([
'message' => 'Validation failed.', 'message' => 'Validation failed.',
'errors' => [ 'errors' => [
'domains' => 'One of the domain is already used.', 'domains' => 'One of the domain is already used.',
], ],
], 422); ], 422);
} }
} }
} }
} }

View File

@@ -5,6 +5,7 @@ namespace App\Http\Controllers\Api;
use App\Actions\Database\StartDatabase; use App\Actions\Database\StartDatabase;
use App\Actions\Service\StartService; use App\Actions\Service\StartService;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\Application;
use App\Models\ApplicationDeploymentQueue; use App\Models\ApplicationDeploymentQueue;
use App\Models\Server; use App\Models\Server;
use App\Models\Tag; use App\Models\Tag;
@@ -142,6 +143,7 @@ class DeployController extends Controller
new OA\Parameter(name: 'tag', in: 'query', description: 'Tag name(s). Comma separated list is also accepted.', schema: new OA\Schema(type: 'string')), new OA\Parameter(name: 'tag', in: 'query', description: 'Tag name(s). Comma separated list is also accepted.', schema: new OA\Schema(type: 'string')),
new OA\Parameter(name: 'uuid', in: 'query', description: 'Resource UUID(s). Comma separated list is also accepted.', schema: new OA\Schema(type: 'string')), new OA\Parameter(name: 'uuid', in: 'query', description: 'Resource UUID(s). Comma separated list is also accepted.', schema: new OA\Schema(type: 'string')),
new OA\Parameter(name: 'force', in: 'query', description: 'Force rebuild (without cache)', schema: new OA\Schema(type: 'boolean')), new OA\Parameter(name: 'force', in: 'query', description: 'Force rebuild (without cache)', schema: new OA\Schema(type: 'boolean')),
new OA\Parameter(name: 'pr', in: 'query', description: 'Pull Request Id for deploying specific PR builds. Cannot be used with tag parameter.', schema: new OA\Schema(type: 'integer')),
], ],
responses: [ responses: [
@@ -184,26 +186,32 @@ class DeployController extends Controller
public function deploy(Request $request) public function deploy(Request $request)
{ {
$teamId = getTeamIdFromToken(); $teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$uuids = $request->query->get('uuid'); $uuids = $request->query->get('uuid');
$tags = $request->query->get('tag'); $tags = $request->query->get('tag');
$force = $request->query->get('force') ?? false; $force = $request->query->get('force') ?? false;
$pr = $request->query->get('pr') ? max((int) $request->query->get('pr'), 0) : 0;
if ($uuids && $tags) { if ($uuids && $tags) {
return response()->json(['message' => 'You can only use uuid or tag, not both.'], 400); return response()->json(['message' => 'You can only use uuid or tag, not both.'], 400);
} }
if (is_null($teamId)) { if ($tags && $pr) {
return invalidTokenResponse(); return response()->json(['message' => 'You can only use tag or pr, not both.'], 400);
} }
if ($tags) { if ($tags) {
return $this->by_tags($tags, $teamId, $force); return $this->by_tags($tags, $teamId, $force);
} elseif ($uuids) { } elseif ($uuids) {
return $this->by_uuids($uuids, $teamId, $force); return $this->by_uuids($uuids, $teamId, $force, $pr);
} }
return response()->json(['message' => 'You must provide uuid or tag.'], 400); return response()->json(['message' => 'You must provide uuid or tag.'], 400);
} }
private function by_uuids(string $uuid, int $teamId, bool $force = false) private function by_uuids(string $uuid, int $teamId, bool $force = false, int $pr = 0)
{ {
$uuids = explode(',', $uuid); $uuids = explode(',', $uuid);
$uuids = collect(array_filter($uuids)); $uuids = collect(array_filter($uuids));
@@ -216,7 +224,7 @@ class DeployController extends Controller
foreach ($uuids as $uuid) { foreach ($uuids as $uuid) {
$resource = getResourceByUuid($uuid, $teamId); $resource = getResourceByUuid($uuid, $teamId);
if ($resource) { if ($resource) {
['message' => $return_message, 'deployment_uuid' => $deployment_uuid] = $this->deploy_resource($resource, $force); ['message' => $return_message, 'deployment_uuid' => $deployment_uuid] = $this->deploy_resource($resource, $force, $pr);
if ($deployment_uuid) { if ($deployment_uuid) {
$deployments->push(['message' => $return_message, 'resource_uuid' => $uuid, 'deployment_uuid' => $deployment_uuid->toString()]); $deployments->push(['message' => $return_message, 'resource_uuid' => $uuid, 'deployment_uuid' => $deployment_uuid->toString()]);
} else { } else {
@@ -281,7 +289,7 @@ class DeployController extends Controller
return response()->json(['message' => 'No resources found with this tag.'], 404); return response()->json(['message' => 'No resources found with this tag.'], 404);
} }
public function deploy_resource($resource, bool $force = false): array public function deploy_resource($resource, bool $force = false, int $pr = 0): array
{ {
$message = null; $message = null;
$deployment_uuid = null; $deployment_uuid = null;
@@ -295,6 +303,7 @@ class DeployController extends Controller
application: $resource, application: $resource,
deployment_uuid: $deployment_uuid, deployment_uuid: $deployment_uuid,
force_rebuild: $force, force_rebuild: $force,
pull_request_id: $pr,
); );
$message = "Application {$resource->name} deployment queued."; $message = "Application {$resource->name} deployment queued.";
break; break;
@@ -314,4 +323,68 @@ class DeployController extends Controller
return ['message' => $message, 'deployment_uuid' => $deployment_uuid]; return ['message' => $message, 'deployment_uuid' => $deployment_uuid];
} }
#[OA\Get(
summary: 'List application deployments',
description: 'List application deployments by using the app uuid',
path: '/deployments/applications/{uuid}',
operationId: 'list-deployments-by-app-uuid',
security: [
['bearerAuth' => []],
],
tags: ['Deployments'],
responses: [
new OA\Response(
response: 200,
description: 'List application deployments by using the app uuid.',
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'array',
items: new OA\Items(ref: '#/components/schemas/Application'),
)
),
]),
new OA\Response(
response: 401,
ref: '#/components/responses/401',
),
new OA\Response(
response: 400,
ref: '#/components/responses/400',
),
]
)]
public function get_application_deployments(Request $request)
{
$request->validate([
'skip' => ['nullable', 'integer', 'min:0'],
'take' => ['nullable', 'integer', 'min:1'],
]);
$app_uuid = $request->route('uuid', null);
$skip = $request->get('skip', 0);
$take = $request->get('take', 10);
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$servers = Server::whereTeamId($teamId)->get();
if (is_null($app_uuid)) {
return response()->json(['message' => 'Application uuid is required'], 400);
}
$application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $app_uuid)->first();
if (is_null($application)) {
return response()->json(['message' => 'Application not found'], 404);
}
$deployments = $application->deployments($skip, $take);
return response()->json($deployments);
}
} }

View File

@@ -368,6 +368,20 @@ class SecurityController extends Controller
response: 404, response: 404,
description: 'Private Key not found.', description: 'Private Key not found.',
), ),
new OA\Response(
response: 422,
description: 'Private Key is in use and cannot be deleted.',
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
properties: [
'message' => ['type' => 'string', 'example' => 'Private Key is in use and cannot be deleted.'],
]
)
),
]),
] ]
)] )]
public function delete_key(Request $request) public function delete_key(Request $request)
@@ -384,6 +398,14 @@ class SecurityController extends Controller
if (is_null($key)) { if (is_null($key)) {
return response()->json(['message' => 'Private Key not found.'], 404); return response()->json(['message' => 'Private Key not found.'], 404);
} }
if ($key->isInUse()) {
return response()->json([
'message' => 'Private Key is in use and cannot be deleted.',
'details' => 'This private key is currently being used by servers, applications, or Git integrations.',
], 422);
}
$key->forceDelete(); $key->forceDelete();
return response()->json([ return response()->json([

View File

@@ -13,6 +13,7 @@ use App\Models\Server;
use App\Models\Service; use App\Models\Service;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use OpenApi\Attributes as OA; use OpenApi\Attributes as OA;
use Symfony\Component\Yaml\Yaml;
class ServicesController extends Controller class ServicesController extends Controller
{ {
@@ -88,8 +89,8 @@ class ServicesController extends Controller
} }
#[OA\Post( #[OA\Post(
summary: 'Create', summary: 'Create service',
description: 'Create a one-click service', description: 'Create a one-click / custom service',
path: '/services', path: '/services',
operationId: 'create-service', operationId: 'create-service',
security: [ security: [
@@ -102,7 +103,7 @@ class ServicesController extends Controller
mediaType: 'application/json', mediaType: 'application/json',
schema: new OA\Schema( schema: new OA\Schema(
type: 'object', type: 'object',
required: ['server_uuid', 'project_uuid', 'environment_name', 'environment_uuid', 'type'], required: ['server_uuid', 'project_uuid', 'environment_name', 'environment_uuid'],
properties: [ properties: [
'type' => [ 'type' => [
'description' => 'The one-click service type', 'description' => 'The one-click service type',
@@ -204,6 +205,7 @@ class ServicesController extends Controller
'server_uuid' => ['type' => 'string', 'description' => 'Server UUID.'], 'server_uuid' => ['type' => 'string', 'description' => 'Server UUID.'],
'destination_uuid' => ['type' => 'string', 'description' => 'Destination UUID. Required if server has multiple destinations.'], 'destination_uuid' => ['type' => 'string', 'description' => 'Destination UUID. Required if server has multiple destinations.'],
'instant_deploy' => ['type' => 'boolean', 'default' => false, 'description' => 'Start the service immediately after creation.'], 'instant_deploy' => ['type' => 'boolean', 'default' => false, 'description' => 'Start the service immediately after creation.'],
'docker_compose_raw' => ['type' => 'string', 'description' => 'The Docker Compose raw content.'],
], ],
), ),
), ),
@@ -211,7 +213,7 @@ class ServicesController extends Controller
responses: [ responses: [
new OA\Response( new OA\Response(
response: 201, response: 201,
description: 'Create a service.', description: 'Service created successfully.',
content: [ content: [
new OA\MediaType( new OA\MediaType(
mediaType: 'application/json', mediaType: 'application/json',
@@ -237,7 +239,7 @@ class ServicesController extends Controller
)] )]
public function create_service(Request $request) public function create_service(Request $request)
{ {
$allowedFields = ['type', 'name', 'description', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy']; $allowedFields = ['type', 'name', 'description', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'docker_compose_raw'];
$teamId = getTeamIdFromToken(); $teamId = getTeamIdFromToken();
if (is_null($teamId)) { if (is_null($teamId)) {
@@ -249,12 +251,13 @@ class ServicesController extends Controller
return $return; return $return;
} }
$validator = customApiValidator($request->all(), [ $validator = customApiValidator($request->all(), [
'type' => 'string|required', 'type' => 'string|required_without:docker_compose_raw',
'docker_compose_raw' => 'string|required_without:type',
'project_uuid' => 'string|required', 'project_uuid' => 'string|required',
'environment_name' => 'string|nullable', 'environment_name' => 'string|nullable',
'environment_uuid' => 'string|nullable', 'environment_uuid' => 'string|nullable',
'server_uuid' => 'string|required', 'server_uuid' => 'string|required',
'destination_uuid' => 'string', 'destination_uuid' => 'string|nullable',
'name' => 'string|max:255', 'name' => 'string|max:255',
'description' => 'string|nullable', 'description' => 'string|nullable',
'instant_deploy' => 'boolean', 'instant_deploy' => 'boolean',
@@ -372,12 +375,16 @@ class ServicesController extends Controller
]); ]);
} }
return response()->json(['message' => 'Service not found.'], 404); return response()->json(['message' => 'Service not found.', 'valid_service_types' => $serviceKeys], 404);
} else { } elseif (filled($request->docker_compose_raw)) {
return response()->json(['message' => 'Invalid service type.', 'valid_service_types' => $serviceKeys], 400);
}
return response()->json(['message' => 'Invalid service type.'], 400); $service = new Service;
$result = $this->upsert_service($request, $service, $teamId);
return response()->json(serializeApiResponse($result))->setStatusCode(201);
} else {
return response()->json(['message' => 'No service type or docker_compose_raw provided.'], 400);
}
} }
#[OA\Get( #[OA\Get(
@@ -511,6 +518,206 @@ class ServicesController extends Controller
]); ]);
} }
#[OA\Patch(
summary: 'Update',
description: 'Update service by UUID.',
path: '/services/{uuid}',
operationId: 'update-service-by-uuid',
security: [
['bearerAuth' => []],
],
tags: ['Services'],
requestBody: new OA\RequestBody(
description: 'Service updated.',
required: true,
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
required: ['server_uuid', 'project_uuid', 'environment_name', 'environment_uuid', 'docker_compose_raw'],
properties: [
'name' => ['type' => 'string', 'description' => 'The service name.'],
'description' => ['type' => 'string', 'description' => 'The service description.'],
'project_uuid' => ['type' => 'string', 'description' => 'The project UUID.'],
'environment_name' => ['type' => 'string', 'description' => 'The environment name.'],
'environment_uuid' => ['type' => 'string', 'description' => 'The environment UUID.'],
'server_uuid' => ['type' => 'string', 'description' => 'The server UUID.'],
'destination_uuid' => ['type' => 'string', 'description' => 'The destination UUID.'],
'instant_deploy' => ['type' => 'boolean', 'description' => 'The flag to indicate if the service should be deployed instantly.'],
'connect_to_docker_network' => ['type' => 'boolean', 'default' => false, 'description' => 'Connect the service to the predefined docker network.'],
'docker_compose_raw' => ['type' => 'string', 'description' => 'The Docker Compose raw content.'],
],
)
),
]
),
responses: [
new OA\Response(
response: 200,
description: 'Service updated.',
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
properties: [
'uuid' => ['type' => 'string', 'description' => 'Service UUID.'],
'domains' => ['type' => 'array', 'items' => ['type' => 'string'], 'description' => 'Service domains.'],
]
)
),
]
),
new OA\Response(
response: 401,
ref: '#/components/responses/401',
),
new OA\Response(
response: 400,
ref: '#/components/responses/400',
),
new OA\Response(
response: 404,
ref: '#/components/responses/404',
),
]
)]
public function update_by_uuid(Request $request)
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$return = validateIncomingRequest($request);
if ($return instanceof \Illuminate\Http\JsonResponse) {
return $return;
}
$service = Service::whereRelation('environment.project.team', 'id', $teamId)->whereUuid($request->uuid)->first();
if (! $service) {
return response()->json(['message' => 'Service not found.'], 404);
}
$result = $this->upsert_service($request, $service, $teamId);
return response()->json(serializeApiResponse($result))->setStatusCode(200);
}
private function upsert_service(Request $request, Service $service, string $teamId)
{
$allowedFields = ['name', 'description', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'docker_compose_raw', 'connect_to_docker_network'];
$validator = customApiValidator($request->all(), [
'project_uuid' => 'string|required',
'environment_name' => 'string|nullable',
'environment_uuid' => 'string|nullable',
'server_uuid' => 'string|required',
'destination_uuid' => 'string',
'name' => 'string|max:255',
'description' => 'string|nullable',
'instant_deploy' => 'boolean',
'connect_to_docker_network' => 'boolean',
'docker_compose_raw' => 'string|required',
]);
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
if ($validator->fails() || ! empty($extraFields)) {
$errors = $validator->errors();
if (! empty($extraFields)) {
foreach ($extraFields as $field) {
$errors->add($field, 'This field is not allowed.');
}
}
return response()->json([
'message' => 'Validation failed.',
'errors' => $errors,
], 422);
}
$environmentUuid = $request->environment_uuid;
$environmentName = $request->environment_name;
if (blank($environmentUuid) && blank($environmentName)) {
return response()->json(['message' => 'You need to provide at least one of environment_name or environment_uuid.'], 422);
}
$serverUuid = $request->server_uuid;
$instantDeploy = $request->instant_deploy ?? false;
$project = Project::whereTeamId($teamId)->whereUuid($request->project_uuid)->first();
if (! $project) {
return response()->json(['message' => 'Project not found.'], 404);
}
$environment = $project->environments()->where('name', $environmentName)->first();
if (! $environment) {
$environment = $project->environments()->where('uuid', $environmentUuid)->first();
}
if (! $environment) {
return response()->json(['message' => 'Environment not found.'], 404);
}
$server = Server::whereTeamId($teamId)->whereUuid($serverUuid)->first();
if (! $server) {
return response()->json(['message' => 'Server not found.'], 404);
}
$destinations = $server->destinations();
if ($destinations->count() == 0) {
return response()->json(['message' => 'Server has no destinations.'], 400);
}
if ($destinations->count() > 1 && ! $request->has('destination_uuid')) {
return response()->json(['message' => 'Server has multiple destinations and you do not set destination_uuid.'], 400);
}
$destination = $destinations->first();
if (! isBase64Encoded($request->docker_compose_raw)) {
return response()->json([
'message' => 'Validation failed.',
'errors' => [
'docker_compose_raw' => 'The docker_compose_raw should be base64 encoded.',
],
], 422);
}
$dockerComposeRaw = base64_decode($request->docker_compose_raw);
if (mb_detect_encoding($dockerComposeRaw, 'ASCII', true) === false) {
return response()->json([
'message' => 'Validation failed.',
'errors' => [
'docker_compose_raw' => 'The docker_compose_raw should be base64 encoded.',
],
], 422);
}
$dockerCompose = base64_decode($request->docker_compose_raw);
$dockerComposeRaw = Yaml::dump(Yaml::parse($dockerCompose), 10, 2, Yaml::DUMP_MULTI_LINE_LITERAL_BLOCK);
$connectToDockerNetwork = $request->connect_to_docker_network ?? false;
$service->name = $request->name ?? null;
$service->description = $request->description ?? null;
$service->docker_compose_raw = $dockerComposeRaw;
$service->environment_id = $environment->id;
$service->server_id = $server->id;
$service->destination_id = $destination->id;
$service->destination_type = $destination->getMorphClass();
$service->connect_to_docker_network = $connectToDockerNetwork;
$service->save();
$service->parse();
if ($instantDeploy) {
StartService::dispatch($service);
}
$domains = $service->applications()->get()->pluck('fqdn')->sort();
$domains = $domains->map(function ($domain) {
if (count(explode(':', $domain)) > 2) {
return str($domain)->beforeLast(':')->value();
}
return $domain;
})->values();
return [
'uuid' => $service->uuid,
'domains' => $domains,
];
}
#[OA\Get( #[OA\Get(
summary: 'List Envs', summary: 'List Envs',
description: 'List all envs by service UUID.', description: 'List all envs by service UUID.',

View File

@@ -329,13 +329,8 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
} else { } else {
$this->write_deployment_configurations(); $this->write_deployment_configurations();
} }
$this->execute_remote_command( $this->application_deployment_queue->addLogEntry("Starting graceful shutdown container: {$this->deployment_uuid}");
[ $this->graceful_shutdown_container($this->deployment_uuid);
"docker rm -f {$this->deployment_uuid} >/dev/null 2>&1",
'hidden' => true,
'ignore_errors' => true,
]
);
ApplicationStatusChanged::dispatch(data_get($this->application, 'environment.project.team.id')); ApplicationStatusChanged::dispatch(data_get($this->application, 'environment.project.team.id'));
} }
@@ -1211,7 +1206,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
if ($this->container_name) { if ($this->container_name) {
$counter = 1; $counter = 1;
$this->application_deployment_queue->addLogEntry('Waiting for healthcheck to pass on the new container.'); $this->application_deployment_queue->addLogEntry('Waiting for healthcheck to pass on the new container.');
if ($this->full_healthcheck_url) { if ($this->full_healthcheck_url && ! $this->application->custom_healthcheck_found) {
$this->application_deployment_queue->addLogEntry("Healthcheck URL (inside the container): {$this->full_healthcheck_url}"); $this->application_deployment_queue->addLogEntry("Healthcheck URL (inside the container): {$this->full_healthcheck_url}");
} }
$this->application_deployment_queue->addLogEntry("Waiting for the start period ({$this->application->health_check_start_period} seconds) before starting healthcheck."); $this->application_deployment_queue->addLogEntry("Waiting for the start period ({$this->application->health_check_start_period} seconds) before starting healthcheck.");
@@ -1366,13 +1361,8 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
} }
} }
$this->application_deployment_queue->addLogEntry("Preparing container with helper image: $helperImage."); $this->application_deployment_queue->addLogEntry("Preparing container with helper image: $helperImage.");
$this->execute_remote_command( $this->application_deployment_queue->addLogEntry("Starting graceful shutdown container: {$this->deployment_uuid}");
[ $this->graceful_shutdown_container($this->deployment_uuid);
'command' => "docker rm -f {$this->deployment_uuid}",
'ignore_errors' => true,
'hidden' => true,
]
);
$this->execute_remote_command( $this->execute_remote_command(
[ [
$runCommand, $runCommand,
@@ -1718,8 +1708,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
'save' => 'dockerfile_from_repo', 'save' => 'dockerfile_from_repo',
'ignore_errors' => true, 'ignore_errors' => true,
]); ]);
$dockerfile = collect(str($this->saved_outputs->get('dockerfile_from_repo'))->trim()->explode("\n")); $this->application->parseHealthcheckFromDockerfile($this->saved_outputs->get('dockerfile_from_repo'));
$this->application->parseHealthcheckFromDockerfile($dockerfile);
} }
$docker_compose = [ $docker_compose = [
'services' => [ 'services' => [
@@ -2029,7 +2018,11 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
if (str($this->application->custom_nginx_configuration)->isNotEmpty()) { if (str($this->application->custom_nginx_configuration)->isNotEmpty()) {
$nginx_config = base64_encode($this->application->custom_nginx_configuration); $nginx_config = base64_encode($this->application->custom_nginx_configuration);
} else { } else {
$nginx_config = base64_encode(defaultNginxConfiguration()); if ($this->application->settings->is_spa) {
$nginx_config = base64_encode(defaultNginxConfiguration('spa'));
} else {
$nginx_config = base64_encode(defaultNginxConfiguration());
}
} }
} else { } else {
if ($this->application->build_pack === 'nixpacks') { if ($this->application->build_pack === 'nixpacks') {
@@ -2096,7 +2089,11 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
if (str($this->application->custom_nginx_configuration)->isNotEmpty()) { if (str($this->application->custom_nginx_configuration)->isNotEmpty()) {
$nginx_config = base64_encode($this->application->custom_nginx_configuration); $nginx_config = base64_encode($this->application->custom_nginx_configuration);
} else { } else {
$nginx_config = base64_encode(defaultNginxConfiguration()); if ($this->application->settings->is_spa) {
$nginx_config = base64_encode(defaultNginxConfiguration('spa'));
} else {
$nginx_config = base64_encode(defaultNginxConfiguration());
}
} }
} }
$build_command = "docker build {$this->addHosts} --network host -f {$this->workdir}/Dockerfile {$this->build_args} --progress plain -t {$this->production_image_name} {$this->workdir}"; $build_command = "docker build {$this->addHosts} --network host -f {$this->workdir}/Dockerfile {$this->build_args} --progress plain -t {$this->production_image_name} {$this->workdir}";

View File

@@ -20,7 +20,7 @@ class CleanupHelperContainersJob implements ShouldBeEncrypted, ShouldBeUnique, S
public function handle(): void public function handle(): void
{ {
try { try {
$containers = instant_remote_process_with_timeout(['docker container ps --format \'{{json .}}\' | jq -s \'map(select(.Image | contains("ghcr.io/coollabsio/coolify-helper")))\''], $this->server, false); $containers = instant_remote_process_with_timeout(['docker container ps --format \'{{json .}}\' | jq -s \'map(select(.Image | contains("'.config('constants.coolify.registry_url').'/coollabsio/coolify-helper")))\''], $this->server, false);
$containerIds = collect(json_decode($containers))->pluck('ID'); $containerIds = collect(json_decode($containers))->pluck('ID');
if ($containerIds->count() > 0) { if ($containerIds->count() > 0) {
foreach ($containerIds as $containerId) { foreach ($containerIds as $containerId) {

View File

@@ -484,6 +484,11 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
$fullImageName = $this->getFullImageName(); $fullImageName = $this->getFullImageName();
$containerExists = instant_remote_process(["docker ps -a -q -f name=backup-of-{$this->backup->uuid}"], $this->server, false);
if (filled($containerExists)) {
instant_remote_process(["docker rm -f backup-of-{$this->backup->uuid}"], $this->server, false);
}
if (isDev()) { if (isDev()) {
if ($this->database->name === 'coolify-db') { if ($this->database->name === 'coolify-db') {
$backup_location_from = '/var/lib/docker/volumes/coolify_dev_backups_data/_data/coolify/coolify-db-'.$this->server->ip.$this->backup_file; $backup_location_from = '/var/lib/docker/volumes/coolify_dev_backups_data/_data/coolify/coolify-db-'.$this->server->ip.$this->backup_file;

View File

@@ -66,12 +66,9 @@ class DeleteResourceJob implements ShouldBeEncrypted, ShouldQueue
} }
if ($this->deleteVolumes && $this->resource->type() !== 'service') { 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 $isDatabase = $this->resource instanceof StandalonePostgresql
|| $this->resource instanceof StandaloneRedis || $this->resource instanceof StandaloneRedis
|| $this->resource instanceof StandaloneMongodb || $this->resource instanceof StandaloneMongodb
@@ -80,6 +77,18 @@ class DeleteResourceJob implements ShouldBeEncrypted, ShouldQueue
|| $this->resource instanceof StandaloneKeydb || $this->resource instanceof StandaloneKeydb
|| $this->resource instanceof StandaloneDragonfly || $this->resource instanceof StandaloneDragonfly
|| $this->resource instanceof StandaloneClickhouse; || $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'); $server = data_get($this->resource, 'server') ?? data_get($this->resource, 'destination.server');
if (($this->dockerCleanup || $isDatabase) && $server) { if (($this->dockerCleanup || $isDatabase) && $server) {
CleanupDocker::dispatch($server, true); CleanupDocker::dispatch($server, true);

View File

@@ -0,0 +1,78 @@
<?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();
if (! $caCert) {
Log::error("No CA certificate found for server_id: {$certificate->server_id}");
return;
}
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

@@ -0,0 +1,46 @@
<?php
namespace App\Jobs;
use App\Actions\Proxy\CheckProxy;
use App\Actions\Proxy\StartProxy;
use App\Actions\Proxy\StopProxy;
use App\Models\Server;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\Middleware\WithoutOverlapping;
use Illuminate\Queue\SerializesModels;
class RestartProxyJob implements ShouldBeEncrypted, ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public $tries = 1;
public $timeout = 60;
public function middleware(): array
{
return [(new WithoutOverlapping($this->server->uuid))->dontRelease()];
}
public function __construct(public Server $server) {}
public function handle()
{
try {
StopProxy::run($this->server);
$this->server->proxy->force_stop = false;
$this->server->save();
StartProxy::run($this->server, force: true);
CheckProxy::run($this->server, true);
} catch (\Throwable $e) {
return handleError($e);
}
}
}

View File

@@ -7,6 +7,7 @@ use App\Models\PrivateKey;
use App\Models\Project; use App\Models\Project;
use App\Models\Server; use App\Models\Server;
use App\Models\Team; use App\Models\Team;
use App\Services\ConfigurationRepository;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Livewire\Component; use Livewire\Component;
use Visus\Cuid2\Cuid2; use Visus\Cuid2\Cuid2;
@@ -266,7 +267,7 @@ uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA==
public function validateServer() public function validateServer()
{ {
try { try {
config()->set('constants.ssh.mux_enabled', false); $this->disableSshMux();
// EC2 does not have `uptime` command, lol // EC2 does not have `uptime` command, lol
instant_remote_process(['ls /'], $this->createdServer, true); instant_remote_process(['ls /'], $this->createdServer, true);
@@ -376,6 +377,12 @@ uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA==
['private' => $this->privateKey, 'public' => $this->publicKey] = generateSSHKey(); ['private' => $this->privateKey, 'public' => $this->publicKey] = generateSSHKey();
} }
private function disableSshMux(): void
{
$configRepository = app(ConfigurationRepository::class);
$configRepository->disableSshMux();
}
public function render() public function render()
{ {
return view('livewire.boarding.index')->layout('layouts.boarding'); return view('livewire.boarding.index')->layout('layouts.boarding');

View File

@@ -56,6 +56,9 @@ class Discord extends Component
#[Validate(['boolean'])] #[Validate(['boolean'])]
public bool $serverUnreachableDiscordNotifications = true; public bool $serverUnreachableDiscordNotifications = true;
#[Validate(['boolean'])]
public bool $discordPingEnabled = true;
public function mount() public function mount()
{ {
try { try {
@@ -87,6 +90,8 @@ class Discord extends Component
$this->settings->server_reachable_discord_notifications = $this->serverReachableDiscordNotifications; $this->settings->server_reachable_discord_notifications = $this->serverReachableDiscordNotifications;
$this->settings->server_unreachable_discord_notifications = $this->serverUnreachableDiscordNotifications; $this->settings->server_unreachable_discord_notifications = $this->serverUnreachableDiscordNotifications;
$this->settings->discord_ping_enabled = $this->discordPingEnabled;
$this->settings->save(); $this->settings->save();
refreshSession(); refreshSession();
} else { } else {
@@ -105,12 +110,30 @@ class Discord extends Component
$this->serverDiskUsageDiscordNotifications = $this->settings->server_disk_usage_discord_notifications; $this->serverDiskUsageDiscordNotifications = $this->settings->server_disk_usage_discord_notifications;
$this->serverReachableDiscordNotifications = $this->settings->server_reachable_discord_notifications; $this->serverReachableDiscordNotifications = $this->settings->server_reachable_discord_notifications;
$this->serverUnreachableDiscordNotifications = $this->settings->server_unreachable_discord_notifications; $this->serverUnreachableDiscordNotifications = $this->settings->server_unreachable_discord_notifications;
$this->discordPingEnabled = $this->settings->discord_ping_enabled;
}
}
public function instantSaveDiscordPingEnabled()
{
try {
$original = $this->discordPingEnabled;
$this->validate([
'discordPingEnabled' => 'required',
]);
$this->saveModel();
} catch (\Throwable $e) {
$this->discordPingEnabled = $original;
return handleError($e, $this);
} }
} }
public function instantSaveDiscordEnabled() public function instantSaveDiscordEnabled()
{ {
try { try {
$original = $this->discordEnabled;
$this->validate([ $this->validate([
'discordWebhookUrl' => 'required', 'discordWebhookUrl' => 'required',
], [ ], [
@@ -118,7 +141,7 @@ class Discord extends Component
]); ]);
$this->saveModel(); $this->saveModel();
} catch (\Throwable $e) { } catch (\Throwable $e) {
$this->discordEnabled = false; $this->discordEnabled = $original;
return handleError($e, $this); return handleError($e, $this);
} }

View File

@@ -70,6 +70,7 @@ class Index extends Component
$this->current_password = ''; $this->current_password = '';
$this->new_password = ''; $this->new_password = '';
$this->new_password_confirmation = ''; $this->new_password_confirmation = '';
$this->dispatch('reloadWindow');
} catch (\Throwable $e) { } catch (\Throwable $e) {
return handleError($e, $this); return handleError($e, $this);
} }

View File

@@ -22,6 +22,7 @@ class Configuration extends Component
public function mount() public function mount()
{ {
$this->currentRoute = request()->route()->getName(); $this->currentRoute = request()->route()->getName();
$project = currentTeam() $project = currentTeam()
->projects() ->projects()
->select('id', 'uuid', 'team_id') ->select('id', 'uuid', 'team_id')
@@ -39,6 +40,9 @@ class Configuration extends Component
$this->project = $project; $this->project = $project;
$this->environment = $environment; $this->environment = $environment;
$this->application = $application; $this->application = $application;
if ($this->application->build_pack === 'dockercompose' && $this->currentRoute === 'project.application.healthcheck') {
return redirect()->route('project.application.configuration', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid, 'application_uuid' => $application->uuid]);
}
} }
public function render() public function render()

View File

@@ -87,6 +87,7 @@ class General extends Component
'application.post_deployment_command_container' => 'nullable', 'application.post_deployment_command_container' => 'nullable',
'application.custom_nginx_configuration' => 'nullable', 'application.custom_nginx_configuration' => 'nullable',
'application.settings.is_static' => 'boolean|required', 'application.settings.is_static' => 'boolean|required',
'application.settings.is_spa' => 'boolean|required',
'application.settings.is_build_server_enabled' => 'boolean|required', 'application.settings.is_build_server_enabled' => 'boolean|required',
'application.settings.is_container_label_escape_enabled' => 'boolean|required', 'application.settings.is_container_label_escape_enabled' => 'boolean|required',
'application.settings.is_container_label_readonly_enabled' => 'boolean|required', 'application.settings.is_container_label_readonly_enabled' => 'boolean|required',
@@ -126,6 +127,7 @@ class General extends Component
'application.docker_compose_custom_build_command' => 'Docker compose custom build command', 'application.docker_compose_custom_build_command' => 'Docker compose custom build command',
'application.custom_nginx_configuration' => 'Custom Nginx configuration', 'application.custom_nginx_configuration' => 'Custom Nginx configuration',
'application.settings.is_static' => 'Is static', 'application.settings.is_static' => 'Is static',
'application.settings.is_spa' => 'Is SPA',
'application.settings.is_build_server_enabled' => 'Is build server enabled', 'application.settings.is_build_server_enabled' => 'Is build server enabled',
'application.settings.is_container_label_escape_enabled' => 'Is container label escape enabled', 'application.settings.is_container_label_escape_enabled' => 'Is container label escape enabled',
'application.settings.is_container_label_readonly_enabled' => 'Is container label readonly', 'application.settings.is_container_label_readonly_enabled' => 'Is container label readonly',
@@ -173,6 +175,9 @@ class General extends Component
public function instantSave() public function instantSave()
{ {
if ($this->application->settings->isDirty('is_spa')) {
$this->generateNginxConfiguration($this->application->settings->is_spa ? 'spa' : 'static');
}
$this->application->settings->save(); $this->application->settings->save();
$this->dispatch('success', 'Settings saved.'); $this->dispatch('success', 'Settings saved.');
$this->application->refresh(); $this->application->refresh();
@@ -192,6 +197,7 @@ class General extends Component
if ($this->application->settings->is_container_label_readonly_enabled) { if ($this->application->settings->is_container_label_readonly_enabled) {
$this->resetDefaultLabels(false); $this->resetDefaultLabels(false);
} }
} }
public function loadComposeFile($isInit = false) public function loadComposeFile($isInit = false)
@@ -289,9 +295,9 @@ class General extends Component
} }
} }
public function generateNginxConfiguration() public function generateNginxConfiguration($type = 'static')
{ {
$this->application->custom_nginx_configuration = defaultNginxConfiguration(); $this->application->custom_nginx_configuration = defaultNginxConfiguration($type);
$this->application->save(); $this->application->save();
$this->dispatch('success', 'Nginx configuration generated.'); $this->dispatch('success', 'Nginx configuration generated.');
} }
@@ -371,6 +377,9 @@ class General extends Component
if ($this->application->isDirty('redirect')) { if ($this->application->isDirty('redirect')) {
$this->setRedirect(); $this->setRedirect();
} }
if ($this->application->isDirty('dockerfile')) {
$this->application->parseHealthcheckFromDockerfile($this->application->dockerfile);
}
$this->checkFqdns(); $this->checkFqdns();
@@ -448,7 +457,6 @@ class General extends Component
{ {
$config = GenerateConfig::run($this->application, true); $config = GenerateConfig::run($this->application, true);
$fileName = str($this->application->name)->slug()->append('_config.json'); $fileName = str($this->application->name)->slug()->append('_config.json');
dd($config);
return response()->streamDownload(function () use ($config) { return response()->streamDownload(function () use ($config) {
echo $config; echo $config;

View File

@@ -4,8 +4,11 @@ namespace App\Livewire\Project\Database\Dragonfly;
use App\Actions\Database\StartDatabaseProxy; use App\Actions\Database\StartDatabaseProxy;
use App\Actions\Database\StopDatabaseProxy; use App\Actions\Database\StopDatabaseProxy;
use App\Helpers\SslHelper;
use App\Models\Server; use App\Models\Server;
use App\Models\SslCertificate;
use App\Models\StandaloneDragonfly; use App\Models\StandaloneDragonfly;
use Carbon\Carbon;
use Exception; use Exception;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Livewire\Attributes\Validate; use Livewire\Attributes\Validate;
@@ -50,12 +53,19 @@ class General extends Component
#[Validate(['nullable', 'boolean'])] #[Validate(['nullable', 'boolean'])]
public bool $isLogDrainEnabled = false; public bool $isLogDrainEnabled = false;
public ?Carbon $certificateValidUntil = null;
#[Validate(['nullable', 'boolean'])]
public bool $enable_ssl = false;
public function getListeners() public function getListeners()
{ {
$userId = Auth::id();
$teamId = Auth::user()->currentTeam()->id; $teamId = Auth::user()->currentTeam()->id;
return [ return [
"echo-private:team.{$teamId},DatabaseProxyStopped" => 'databaseProxyStopped', "echo-private:team.{$teamId},DatabaseProxyStopped" => 'databaseProxyStopped',
"echo-private:user.{$userId},DatabaseStatusChanged" => '$refresh',
]; ];
} }
@@ -64,6 +74,12 @@ class General extends Component
try { try {
$this->syncData(); $this->syncData();
$this->server = data_get($this->database, 'destination.server'); $this->server = data_get($this->database, 'destination.server');
$existingCert = $this->database->sslCertificates()->first();
if ($existingCert) {
$this->certificateValidUntil = $existingCert->valid_until;
}
} catch (\Throwable $e) { } catch (\Throwable $e) {
return handleError($e, $this); return handleError($e, $this);
} }
@@ -82,6 +98,7 @@ class General extends Component
$this->database->public_port = $this->publicPort; $this->database->public_port = $this->publicPort;
$this->database->custom_docker_run_options = $this->customDockerRunOptions; $this->database->custom_docker_run_options = $this->customDockerRunOptions;
$this->database->is_log_drain_enabled = $this->isLogDrainEnabled; $this->database->is_log_drain_enabled = $this->isLogDrainEnabled;
$this->database->enable_ssl = $this->enable_ssl;
$this->database->save(); $this->database->save();
$this->dbUrl = $this->database->internal_db_url; $this->dbUrl = $this->database->internal_db_url;
@@ -96,6 +113,7 @@ class General extends Component
$this->publicPort = $this->database->public_port; $this->publicPort = $this->database->public_port;
$this->customDockerRunOptions = $this->database->custom_docker_run_options; $this->customDockerRunOptions = $this->database->custom_docker_run_options;
$this->isLogDrainEnabled = $this->database->is_log_drain_enabled; $this->isLogDrainEnabled = $this->database->is_log_drain_enabled;
$this->enable_ssl = $this->database->enable_ssl;
$this->dbUrl = $this->database->internal_db_url; $this->dbUrl = $this->database->internal_db_url;
$this->dbUrlPublic = $this->database->external_db_url; $this->dbUrlPublic = $this->database->external_db_url;
} }
@@ -174,4 +192,61 @@ 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;
}
$server = $this->database->destination->server;
$caCert = SslCertificate::where('server_id', $server->id)
->where('is_ca_certificate', true)
->first();
if (! $caCert) {
$server->generateCaCertificate();
$caCert = SslCertificate::where('server_id', $server->id)->where('is_ca_certificate', true)->first();
}
if (! $caCert) {
$this->dispatch('error', 'No CA certificate found for this database. Please generate a CA certificate for this server in the server/advanced page.');
return;
}
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,
isPemKeyFileRequired: true,
);
$this->dispatch('success', 'SSL certificates regenerated. Restart database to apply changes.');
} catch (Exception $e) {
handleError($e, $this);
}
}
} }

View File

@@ -31,8 +31,8 @@ class Heading extends Component
$this->database->update([ $this->database->update([
'started_at' => now(), 'started_at' => now(),
]); ]);
$this->dispatch('refresh');
$this->check_status(); $this->check_status();
if (is_null($this->database->config_hash) || $this->database->isConfigurationChanged()) { if (is_null($this->database->config_hash) || $this->database->isConfigurationChanged()) {
$this->database->isConfigurationChanged(true); $this->database->isConfigurationChanged(true);
$this->dispatch('configurationChanged'); $this->dispatch('configurationChanged');
@@ -44,7 +44,7 @@ class Heading extends Component
public function check_status($showNotification = false) public function check_status($showNotification = false)
{ {
if ($this->database->destination->server->isFunctional()) { if ($this->database->destination->server->isFunctional()) {
GetContainersStatus::dispatch($this->database->destination->server); GetContainersStatus::run($this->database->destination->server);
} }
if ($showNotification) { if ($showNotification) {
@@ -63,6 +63,7 @@ class Heading extends Component
$this->database->status = 'exited'; $this->database->status = 'exited';
$this->database->save(); $this->database->save();
$this->check_status(); $this->check_status();
$this->dispatch('refresh');
} }
public function restart() public function restart()

View File

@@ -4,8 +4,11 @@ namespace App\Livewire\Project\Database\Keydb;
use App\Actions\Database\StartDatabaseProxy; use App\Actions\Database\StartDatabaseProxy;
use App\Actions\Database\StopDatabaseProxy; use App\Actions\Database\StopDatabaseProxy;
use App\Helpers\SslHelper;
use App\Models\Server; use App\Models\Server;
use App\Models\SslCertificate;
use App\Models\StandaloneKeydb; use App\Models\StandaloneKeydb;
use Carbon\Carbon;
use Exception; use Exception;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Livewire\Attributes\Validate; use Livewire\Attributes\Validate;
@@ -53,12 +56,20 @@ class General extends Component
#[Validate(['nullable', 'boolean'])] #[Validate(['nullable', 'boolean'])]
public bool $isLogDrainEnabled = false; public bool $isLogDrainEnabled = false;
public ?Carbon $certificateValidUntil = null;
#[Validate(['boolean'])]
public bool $enable_ssl = false;
public function getListeners() public function getListeners()
{ {
$userId = Auth::id();
$teamId = Auth::user()->currentTeam()->id; $teamId = Auth::user()->currentTeam()->id;
return [ return [
"echo-private:team.{$teamId},DatabaseProxyStopped" => 'databaseProxyStopped', "echo-private:team.{$teamId},DatabaseProxyStopped" => 'databaseProxyStopped',
"echo-private:user.{$userId},DatabaseStatusChanged" => '$refresh',
'refresh' => '$refresh',
]; ];
} }
@@ -67,6 +78,12 @@ class General extends Component
try { try {
$this->syncData(); $this->syncData();
$this->server = data_get($this->database, 'destination.server'); $this->server = data_get($this->database, 'destination.server');
$existingCert = $this->database->sslCertificates()->first();
if ($existingCert) {
$this->certificateValidUntil = $existingCert->valid_until;
}
} catch (\Throwable $e) { } catch (\Throwable $e) {
return handleError($e, $this); return handleError($e, $this);
} }
@@ -86,6 +103,7 @@ class General extends Component
$this->database->public_port = $this->publicPort; $this->database->public_port = $this->publicPort;
$this->database->custom_docker_run_options = $this->customDockerRunOptions; $this->database->custom_docker_run_options = $this->customDockerRunOptions;
$this->database->is_log_drain_enabled = $this->isLogDrainEnabled; $this->database->is_log_drain_enabled = $this->isLogDrainEnabled;
$this->database->enable_ssl = $this->enable_ssl;
$this->database->save(); $this->database->save();
$this->dbUrl = $this->database->internal_db_url; $this->dbUrl = $this->database->internal_db_url;
@@ -101,6 +119,7 @@ class General extends Component
$this->publicPort = $this->database->public_port; $this->publicPort = $this->database->public_port;
$this->customDockerRunOptions = $this->database->custom_docker_run_options; $this->customDockerRunOptions = $this->database->custom_docker_run_options;
$this->isLogDrainEnabled = $this->database->is_log_drain_enabled; $this->isLogDrainEnabled = $this->database->is_log_drain_enabled;
$this->enable_ssl = $this->database->enable_ssl;
$this->dbUrl = $this->database->internal_db_url; $this->dbUrl = $this->database->internal_db_url;
$this->dbUrlPublic = $this->database->external_db_url; $this->dbUrlPublic = $this->database->external_db_url;
} }
@@ -179,4 +198,48 @@ 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,
isPemKeyFileRequired: true,
);
$this->dispatch('success', 'SSL certificates regenerated. Restart database to apply changes.');
} catch (Exception $e) {
handleError($e, $this);
}
}
} }

View File

@@ -4,9 +4,13 @@ namespace App\Livewire\Project\Database\Mariadb;
use App\Actions\Database\StartDatabaseProxy; use App\Actions\Database\StartDatabaseProxy;
use App\Actions\Database\StopDatabaseProxy; use App\Actions\Database\StopDatabaseProxy;
use App\Helpers\SslHelper;
use App\Models\Server; use App\Models\Server;
use App\Models\SslCertificate;
use App\Models\StandaloneMariadb; use App\Models\StandaloneMariadb;
use Carbon\Carbon;
use Exception; use Exception;
use Illuminate\Support\Facades\Auth;
use Livewire\Component; use Livewire\Component;
class General extends Component class General extends Component
@@ -21,6 +25,18 @@ class General extends Component
public ?string $db_url_public = null; public ?string $db_url_public = null;
public ?Carbon $certificateValidUntil = null;
public function getListeners()
{
$userId = Auth::id();
return [
"echo-private:user.{$userId},DatabaseStatusChanged" => '$refresh',
'refresh' => '$refresh',
];
}
protected $rules = [ protected $rules = [
'database.name' => 'required', 'database.name' => 'required',
'database.description' => 'nullable', 'database.description' => 'nullable',
@@ -35,6 +51,7 @@ class General extends Component
'database.public_port' => 'nullable|integer', 'database.public_port' => 'nullable|integer',
'database.is_log_drain_enabled' => 'nullable|boolean', 'database.is_log_drain_enabled' => 'nullable|boolean',
'database.custom_docker_run_options' => 'nullable', 'database.custom_docker_run_options' => 'nullable',
'database.enable_ssl' => 'boolean',
]; ];
protected $validationAttributes = [ protected $validationAttributes = [
@@ -50,6 +67,7 @@ class General extends Component
'database.is_public' => 'Is Public', 'database.is_public' => 'Is Public',
'database.public_port' => 'Public Port', 'database.public_port' => 'Public Port',
'database.custom_docker_run_options' => 'Custom Docker Options', 'database.custom_docker_run_options' => 'Custom Docker Options',
'database.enable_ssl' => 'Enable SSL',
]; ];
public function mount() public function mount()
@@ -57,6 +75,12 @@ class General extends Component
$this->db_url = $this->database->internal_db_url; $this->db_url = $this->database->internal_db_url;
$this->db_url_public = $this->database->external_db_url; $this->db_url_public = $this->database->external_db_url;
$this->server = data_get($this->database, 'destination.server'); $this->server = data_get($this->database, 'destination.server');
$existingCert = $this->database->sslCertificates()->first();
if ($existingCert) {
$this->certificateValidUntil = $existingCert->valid_until;
}
} }
public function instantSaveAdvanced() public function instantSaveAdvanced()
@@ -127,6 +151,48 @@ 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,
isPemKeyFileRequired: true,
);
$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 public function refresh(): void
{ {
$this->database->refresh(); $this->database->refresh();

View File

@@ -4,9 +4,13 @@ namespace App\Livewire\Project\Database\Mongodb;
use App\Actions\Database\StartDatabaseProxy; use App\Actions\Database\StartDatabaseProxy;
use App\Actions\Database\StopDatabaseProxy; use App\Actions\Database\StopDatabaseProxy;
use App\Helpers\SslHelper;
use App\Models\Server; use App\Models\Server;
use App\Models\SslCertificate;
use App\Models\StandaloneMongodb; use App\Models\StandaloneMongodb;
use Carbon\Carbon;
use Exception; use Exception;
use Illuminate\Support\Facades\Auth;
use Livewire\Component; use Livewire\Component;
class General extends Component class General extends Component
@@ -21,6 +25,18 @@ class General extends Component
public ?string $db_url_public = null; public ?string $db_url_public = null;
public ?Carbon $certificateValidUntil = null;
public function getListeners()
{
$userId = Auth::id();
return [
"echo-private:user.{$userId},DatabaseStatusChanged" => '$refresh',
'refresh' => '$refresh',
];
}
protected $rules = [ protected $rules = [
'database.name' => 'required', 'database.name' => 'required',
'database.description' => 'nullable', 'database.description' => 'nullable',
@@ -34,6 +50,8 @@ class General extends Component
'database.public_port' => 'nullable|integer', 'database.public_port' => 'nullable|integer',
'database.is_log_drain_enabled' => 'nullable|boolean', 'database.is_log_drain_enabled' => 'nullable|boolean',
'database.custom_docker_run_options' => 'nullable', 'database.custom_docker_run_options' => 'nullable',
'database.enable_ssl' => 'boolean',
'database.ssl_mode' => 'nullable|string|in:allow,prefer,require,verify-full',
]; ];
protected $validationAttributes = [ protected $validationAttributes = [
@@ -48,6 +66,8 @@ class General extends Component
'database.is_public' => 'Is Public', 'database.is_public' => 'Is Public',
'database.public_port' => 'Public Port', 'database.public_port' => 'Public Port',
'database.custom_docker_run_options' => 'Custom Docker Run Options', 'database.custom_docker_run_options' => 'Custom Docker Run Options',
'database.enable_ssl' => 'Enable SSL',
'database.ssl_mode' => 'SSL Mode',
]; ];
public function mount() public function mount()
@@ -55,6 +75,12 @@ class General extends Component
$this->db_url = $this->database->internal_db_url; $this->db_url = $this->database->internal_db_url;
$this->db_url_public = $this->database->external_db_url; $this->db_url_public = $this->database->external_db_url;
$this->server = data_get($this->database, 'destination.server'); $this->server = data_get($this->database, 'destination.server');
$existingCert = $this->database->sslCertificates()->first();
if ($existingCert) {
$this->certificateValidUntil = $existingCert->valid_until;
}
} }
public function instantSaveAdvanced() public function instantSaveAdvanced()
@@ -128,6 +154,53 @@ 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,
isPemKeyFileRequired: true,
);
$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 public function refresh(): void
{ {
$this->database->refresh(); $this->database->refresh();

View File

@@ -4,9 +4,13 @@ namespace App\Livewire\Project\Database\Mysql;
use App\Actions\Database\StartDatabaseProxy; use App\Actions\Database\StartDatabaseProxy;
use App\Actions\Database\StopDatabaseProxy; use App\Actions\Database\StopDatabaseProxy;
use App\Helpers\SslHelper;
use App\Models\Server; use App\Models\Server;
use App\Models\SslCertificate;
use App\Models\StandaloneMysql; use App\Models\StandaloneMysql;
use Carbon\Carbon;
use Exception; use Exception;
use Illuminate\Support\Facades\Auth;
use Livewire\Component; use Livewire\Component;
class General extends Component class General extends Component
@@ -21,6 +25,18 @@ class General extends Component
public ?string $db_url_public = null; public ?string $db_url_public = null;
public ?Carbon $certificateValidUntil = null;
public function getListeners()
{
$userId = Auth::id();
return [
"echo-private:user.{$userId},DatabaseStatusChanged" => '$refresh',
'refresh' => '$refresh',
];
}
protected $rules = [ protected $rules = [
'database.name' => 'required', 'database.name' => 'required',
'database.description' => 'nullable', 'database.description' => 'nullable',
@@ -35,6 +51,8 @@ class General extends Component
'database.public_port' => 'nullable|integer', 'database.public_port' => 'nullable|integer',
'database.is_log_drain_enabled' => 'nullable|boolean', 'database.is_log_drain_enabled' => 'nullable|boolean',
'database.custom_docker_run_options' => 'nullable', 'database.custom_docker_run_options' => 'nullable',
'database.enable_ssl' => 'boolean',
'database.ssl_mode' => 'nullable|string|in:PREFERRED,REQUIRED,VERIFY_CA,VERIFY_IDENTITY',
]; ];
protected $validationAttributes = [ protected $validationAttributes = [
@@ -50,6 +68,8 @@ class General extends Component
'database.is_public' => 'Is Public', 'database.is_public' => 'Is Public',
'database.public_port' => 'Public Port', 'database.public_port' => 'Public Port',
'database.custom_docker_run_options' => 'Custom Docker Run Options', 'database.custom_docker_run_options' => 'Custom Docker Run Options',
'database.enable_ssl' => 'Enable SSL',
'database.ssl_mode' => 'SSL Mode',
]; ];
public function mount() public function mount()
@@ -57,6 +77,12 @@ class General extends Component
$this->db_url = $this->database->internal_db_url; $this->db_url = $this->database->internal_db_url;
$this->db_url_public = $this->database->external_db_url; $this->db_url_public = $this->database->external_db_url;
$this->server = data_get($this->database, 'destination.server'); $this->server = data_get($this->database, 'destination.server');
$existingCert = $this->database->sslCertificates()->first();
if ($existingCert) {
$this->certificateValidUntil = $existingCert->valid_until;
}
} }
public function instantSaveAdvanced() public function instantSaveAdvanced()
@@ -127,6 +153,53 @@ 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,
isPemKeyFileRequired: true,
);
$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 public function refresh(): void
{ {
$this->database->refresh(); $this->database->refresh();

View File

@@ -4,9 +4,13 @@ namespace App\Livewire\Project\Database\Postgresql;
use App\Actions\Database\StartDatabaseProxy; use App\Actions\Database\StartDatabaseProxy;
use App\Actions\Database\StopDatabaseProxy; use App\Actions\Database\StopDatabaseProxy;
use App\Helpers\SslHelper;
use App\Models\Server; use App\Models\Server;
use App\Models\SslCertificate;
use App\Models\StandalonePostgresql; use App\Models\StandalonePostgresql;
use Carbon\Carbon;
use Exception; use Exception;
use Illuminate\Support\Facades\Auth;
use Livewire\Component; use Livewire\Component;
class General extends Component class General extends Component
@@ -23,10 +27,15 @@ class General extends Component
public ?string $db_url_public = null; public ?string $db_url_public = null;
public ?Carbon $certificateValidUntil = null;
public function getListeners() public function getListeners()
{ {
$userId = Auth::id();
return [ return [
'refresh', "echo-private:user.{$userId},DatabaseStatusChanged" => '$refresh',
'refresh' => '$refresh',
'save_init_script', 'save_init_script',
'delete_init_script', 'delete_init_script',
]; ];
@@ -48,6 +57,8 @@ class General extends Component
'database.public_port' => 'nullable|integer', 'database.public_port' => 'nullable|integer',
'database.is_log_drain_enabled' => 'nullable|boolean', 'database.is_log_drain_enabled' => 'nullable|boolean',
'database.custom_docker_run_options' => 'nullable', '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 = [ protected $validationAttributes = [
@@ -65,6 +76,8 @@ class General extends Component
'database.is_public' => 'Is Public', 'database.is_public' => 'Is Public',
'database.public_port' => 'Public Port', 'database.public_port' => 'Public Port',
'database.custom_docker_run_options' => 'Custom Docker Run Options', 'database.custom_docker_run_options' => 'Custom Docker Run Options',
'database.enable_ssl' => 'Enable SSL',
'database.ssl_mode' => 'SSL Mode',
]; ];
public function mount() public function mount()
@@ -72,6 +85,12 @@ class General extends Component
$this->db_url = $this->database->internal_db_url; $this->db_url = $this->database->internal_db_url;
$this->db_url_public = $this->database->external_db_url; $this->db_url_public = $this->database->external_db_url;
$this->server = data_get($this->database, 'destination.server'); $this->server = data_get($this->database, 'destination.server');
$existingCert = $this->database->sslCertificates()->first();
if ($existingCert) {
$this->certificateValidUntil = $existingCert->valid_until;
}
} }
public function instantSaveAdvanced() public function instantSaveAdvanced()
@@ -91,6 +110,53 @@ 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,
isPemKeyFileRequired: true,
);
$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() public function instantSave()
{ {
try { try {
@@ -143,7 +209,7 @@ class General extends Component
$delete_command = "rm -f $old_file_path"; $delete_command = "rm -f $old_file_path";
try { try {
instant_remote_process([$delete_command], $this->server); instant_remote_process([$delete_command], $this->server);
} catch (\Exception $e) { } catch (Exception $e) {
$this->dispatch('error', 'Failed to remove old init script from server: '.$e->getMessage()); $this->dispatch('error', 'Failed to remove old init script from server: '.$e->getMessage());
return; return;
@@ -184,7 +250,7 @@ class General extends Component
$command = "rm -f $file_path"; $command = "rm -f $file_path";
try { try {
instant_remote_process([$command], $this->server); instant_remote_process([$command], $this->server);
} catch (\Exception $e) { } catch (Exception $e) {
$this->dispatch('error', 'Failed to remove init script from server: '.$e->getMessage()); $this->dispatch('error', 'Failed to remove init script from server: '.$e->getMessage());
return; return;
@@ -201,16 +267,11 @@ class General extends Component
$this->database->init_scripts = $updatedScripts; $this->database->init_scripts = $updatedScripts;
$this->database->save(); $this->database->save();
$this->refresh(); $this->dispatch('refresh')->self();
$this->dispatch('success', 'Init script deleted from the database and the server.'); $this->dispatch('success', 'Init script deleted from the database and the server.');
} }
} }
public function refresh(): void
{
$this->database->refresh();
}
public function save_new_init_script() public function save_new_init_script()
{ {
$this->validate([ $this->validate([

View File

@@ -4,25 +4,24 @@ namespace App\Livewire\Project\Database\Redis;
use App\Actions\Database\StartDatabaseProxy; use App\Actions\Database\StartDatabaseProxy;
use App\Actions\Database\StopDatabaseProxy; use App\Actions\Database\StopDatabaseProxy;
use App\Helpers\SslHelper;
use App\Models\Server; use App\Models\Server;
use App\Models\SslCertificate;
use App\Models\StandaloneRedis; use App\Models\StandaloneRedis;
use Carbon\Carbon;
use Exception; use Exception;
use Illuminate\Support\Facades\Auth;
use Livewire\Component; use Livewire\Component;
class General extends Component class General extends Component
{ {
protected $listeners = [
'envsUpdated' => 'refresh',
'refresh',
];
public Server $server; public Server $server;
public StandaloneRedis $database; public StandaloneRedis $database;
public string $redis_username; public string $redis_username;
public string $redis_password; public ?string $redis_password;
public string $redis_version; public string $redis_version;
@@ -30,6 +29,19 @@ class General extends Component
public ?string $db_url_public = null; public ?string $db_url_public = null;
public ?Carbon $certificateValidUntil = null;
public function getListeners()
{
$userId = Auth::id();
return [
"echo-private:user.{$userId},DatabaseStatusChanged" => '$refresh',
'envsUpdated' => 'refresh',
'refresh',
];
}
protected $rules = [ protected $rules = [
'database.name' => 'required', 'database.name' => 'required',
'database.description' => 'nullable', 'database.description' => 'nullable',
@@ -42,6 +54,7 @@ class General extends Component
'database.custom_docker_run_options' => 'nullable', 'database.custom_docker_run_options' => 'nullable',
'redis_username' => 'required', 'redis_username' => 'required',
'redis_password' => 'required', 'redis_password' => 'required',
'database.enable_ssl' => 'boolean',
]; ];
protected $validationAttributes = [ protected $validationAttributes = [
@@ -55,12 +68,18 @@ class General extends Component
'database.custom_docker_run_options' => 'Custom Docker Options', 'database.custom_docker_run_options' => 'Custom Docker Options',
'redis_username' => 'Redis Username', 'redis_username' => 'Redis Username',
'redis_password' => 'Redis Password', 'redis_password' => 'Redis Password',
'database.enable_ssl' => 'Enable SSL',
]; ];
public function mount() public function mount()
{ {
$this->server = data_get($this->database, 'destination.server'); $this->server = data_get($this->database, 'destination.server');
$this->refreshView(); $this->refreshView();
$existingCert = $this->database->sslCertificates()->first();
if ($existingCert) {
$this->certificateValidUntil = $existingCert->valid_until;
}
} }
public function instantSaveAdvanced() public function instantSaveAdvanced()
@@ -136,6 +155,48 @@ 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,
isPemKeyFileRequired: true,
);
$this->dispatch('success', 'SSL certificates regenerated. Restart database to apply changes.');
} catch (Exception $e) {
handleError($e, $this);
}
}
public function refresh(): void public function refresh(): void
{ {
$this->database->refresh(); $this->database->refresh();

View File

@@ -7,7 +7,6 @@ use App\Models\Project;
use App\Models\Service; use App\Models\Service;
use App\Models\StandaloneDocker; use App\Models\StandaloneDocker;
use App\Models\SwarmDocker; use App\Models\SwarmDocker;
use Illuminate\Support\Str;
use Livewire\Component; use Livewire\Component;
use Symfony\Component\Yaml\Yaml; use Symfony\Component\Yaml\Yaml;
@@ -66,7 +65,6 @@ class DockerCompose extends Component
$destination_class = $destination->getMorphClass(); $destination_class = $destination->getMorphClass();
$service = Service::create([ $service = Service::create([
'name' => 'service'.Str::random(10),
'docker_compose_raw' => $this->dockerComposeRaw, 'docker_compose_raw' => $this->dockerComposeRaw,
'environment_id' => $environment->id, 'environment_id' => $environment->id,
'server_id' => (int) $server_id, 'server_id' => (int) $server_id,
@@ -85,8 +83,6 @@ class DockerCompose extends Component
'resourceable_type' => $service->getMorphClass(), 'resourceable_type' => $service->getMorphClass(),
]); ]);
} }
$service->name = "service-$service->uuid";
$service->parse(isNew: true); $service->parse(isNew: true);
return redirect()->route('project.service.configuration', [ return redirect()->route('project.service.configuration', [

View File

@@ -106,11 +106,15 @@ class GithubPrivateRepository extends Component
$this->selected_github_app_id = $github_app_id; $this->selected_github_app_id = $github_app_id;
$this->github_app = GithubApp::where('id', $github_app_id)->first(); $this->github_app = GithubApp::where('id', $github_app_id)->first();
$this->token = generateGithubInstallationToken($this->github_app); $this->token = generateGithubInstallationToken($this->github_app);
$this->loadRepositoryByPage(); $repositories = loadRepositoryByPage($this->github_app, $this->token, $this->page);
$this->total_repositories_count = $repositories['total_count'];
$this->repositories = $this->repositories->concat(collect($repositories['repositories']));
if ($this->repositories->count() < $this->total_repositories_count) { if ($this->repositories->count() < $this->total_repositories_count) {
while ($this->repositories->count() < $this->total_repositories_count) { while ($this->repositories->count() < $this->total_repositories_count) {
$this->page++; $this->page++;
$this->loadRepositoryByPage(); $repositories = loadRepositoryByPage($this->github_app, $this->token, $this->page);
$this->total_repositories_count = $repositories['total_count'];
$this->repositories = $this->repositories->concat(collect($repositories['repositories']));
} }
} }
$this->repositories = $this->repositories->sortBy('name'); $this->repositories = $this->repositories->sortBy('name');
@@ -120,21 +124,6 @@ class GithubPrivateRepository extends Component
$this->current_step = 'repository'; $this->current_step = 'repository';
} }
protected function loadRepositoryByPage()
{
$response = Http::withToken($this->token)->get("{$this->github_app->api_url}/installation/repositories?per_page=100&page={$this->page}");
$json = $response->json();
if ($response->status() !== 200) {
return $this->dispatch('error', $json['message']);
}
if ($json['total_count'] === 0) {
return;
}
$this->total_repositories_count = $json['total_count'];
$this->repositories = $this->repositories->concat(collect($json['repositories']));
}
public function loadBranches() public function loadBranches()
{ {
$this->selected_repository_owner = $this->repositories->where('id', $this->selected_repository_id)->first()['owner']['login']; $this->selected_repository_owner = $this->repositories->where('id', $this->selected_repository_id)->first()['owner']['login'];

View File

@@ -74,7 +74,7 @@ CMD ["nginx", "-g", "daemon off;"]
'fqdn' => $fqdn, 'fqdn' => $fqdn,
]); ]);
$application->parseHealthcheckFromDockerfile(dockerfile: collect(str($this->dockerfile)->trim()->explode("\n")), isInit: true); $application->parseHealthcheckFromDockerfile(dockerfile: $this->dockerfile, isInit: true);
return redirect()->route('project.application.configuration', [ return redirect()->route('project.application.configuration', [
'application_uuid' => $application->uuid, 'application_uuid' => $application->uuid,

View File

@@ -73,7 +73,6 @@ class Create extends Component
if ($oneClickService) { if ($oneClickService) {
$destination = StandaloneDocker::whereUuid($destination_uuid)->first(); $destination = StandaloneDocker::whereUuid($destination_uuid)->first();
$service_payload = [ $service_payload = [
'name' => "$oneClickServiceName-".str()->random(10),
'docker_compose_raw' => base64_decode($oneClickService), 'docker_compose_raw' => base64_decode($oneClickService),
'environment_id' => $environment->id, 'environment_id' => $environment->id,
'service_type' => $oneClickServiceName, 'service_type' => $oneClickServiceName,

View File

@@ -49,7 +49,6 @@ class FileStorage extends Component
$this->workdir = null; $this->workdir = null;
$this->fs_path = $this->fileStorage->fs_path; $this->fs_path = $this->fileStorage->fs_path;
} }
$this->fileStorage->loadStorageOnServer();
} }
public function convertToDirectory() public function convertToDirectory()
@@ -68,6 +67,18 @@ class FileStorage extends Component
} }
} }
public function loadStorageOnServer()
{
try {
$this->fileStorage->loadStorageOnServer();
$this->dispatch('success', 'File storage loaded from server.');
} catch (\Throwable $e) {
return handleError($e, $this);
} finally {
$this->dispatch('refreshStorages');
}
}
public function convertToFile() public function convertToFile()
{ {
try { try {

View File

@@ -3,10 +3,13 @@
namespace App\Livewire\Project\Shared\EnvironmentVariable; namespace App\Livewire\Project\Shared\EnvironmentVariable;
use App\Models\EnvironmentVariable; use App\Models\EnvironmentVariable;
use App\Traits\EnvironmentVariableProtection;
use Livewire\Component; use Livewire\Component;
class All extends Component class All extends Component
{ {
use EnvironmentVariableProtection;
public $resource; public $resource;
public string $resourceClass; public string $resourceClass;
@@ -138,17 +141,57 @@ class All extends Component
private function handleBulkSubmit() private function handleBulkSubmit()
{ {
$variables = parseEnvFormatToArray($this->variables); $variables = parseEnvFormatToArray($this->variables);
$changesMade = false;
$errorOccurred = false;
$this->deleteRemovedVariables(false, $variables); // Try to delete removed variables
$this->updateOrCreateVariables(false, $variables); $deletedCount = $this->deleteRemovedVariables(false, $variables);
if ($deletedCount > 0) {
$changesMade = true;
} elseif ($deletedCount === 0 && $this->resource->environment_variables()->whereNotIn('key', array_keys($variables))->exists()) {
// If we tried to delete but couldn't (due to Docker Compose), mark as error
$errorOccurred = true;
}
// Update or create variables
$updatedCount = $this->updateOrCreateVariables(false, $variables);
if ($updatedCount > 0) {
$changesMade = true;
}
if ($this->showPreview) { if ($this->showPreview) {
$previewVariables = parseEnvFormatToArray($this->variablesPreview); $previewVariables = parseEnvFormatToArray($this->variablesPreview);
$this->deleteRemovedVariables(true, $previewVariables);
$this->updateOrCreateVariables(true, $previewVariables); // Try to delete removed preview variables
$deletedPreviewCount = $this->deleteRemovedVariables(true, $previewVariables);
if ($deletedPreviewCount > 0) {
$changesMade = true;
} elseif ($deletedPreviewCount === 0 && $this->resource->environment_variables_preview()->whereNotIn('key', array_keys($previewVariables))->exists()) {
// If we tried to delete but couldn't (due to Docker Compose), mark as error
$errorOccurred = true;
}
// Update or create preview variables
$updatedPreviewCount = $this->updateOrCreateVariables(true, $previewVariables);
if ($updatedPreviewCount > 0) {
$changesMade = true;
}
} }
$this->dispatch('success', 'Environment variables updated.'); // Debug information
\Log::info('Environment variables update status', [
'deletedCount' => $deletedCount,
'updatedCount' => $updatedCount,
'deletedPreviewCount' => $deletedPreviewCount ?? 0,
'updatedPreviewCount' => $updatedPreviewCount ?? 0,
'changesMade' => $changesMade,
'errorOccurred' => $errorOccurred,
]);
// Only show success message if changes were actually made and no errors occurred
if ($changesMade && ! $errorOccurred) {
$this->dispatch('success', 'Environment variables updated.');
}
} }
private function handleSingleSubmit($data) private function handleSingleSubmit($data)
@@ -184,11 +227,46 @@ class All extends Component
private function deleteRemovedVariables($isPreview, $variables) private function deleteRemovedVariables($isPreview, $variables)
{ {
$method = $isPreview ? 'environment_variables_preview' : 'environment_variables'; $method = $isPreview ? 'environment_variables_preview' : 'environment_variables';
// Get all environment variables that will be deleted
$variablesToDelete = $this->resource->$method()->whereNotIn('key', array_keys($variables))->get();
// If there are no variables to delete, return 0
if ($variablesToDelete->isEmpty()) {
return 0;
}
// Check for system variables that shouldn't be deleted
foreach ($variablesToDelete as $envVar) {
if ($this->isProtectedEnvironmentVariable($envVar->key)) {
$this->dispatch('error', "Cannot delete system environment variable '{$envVar->key}'.");
return 0;
}
}
// Check if any of these variables are used in Docker Compose
if ($this->resource->type() === 'service' || $this->resource->build_pack === 'dockercompose') {
foreach ($variablesToDelete as $envVar) {
[$isUsed, $reason] = $this->isEnvironmentVariableUsedInDockerCompose($envVar->key, $this->resource->docker_compose);
if ($isUsed) {
$this->dispatch('error', "Cannot delete environment variable '{$envVar->key}' <br><br>Please remove it from the Docker Compose file first.");
return 0;
}
}
}
// If we get here, no variables are used in Docker Compose, so we can delete them
$this->resource->$method()->whereNotIn('key', array_keys($variables))->delete(); $this->resource->$method()->whereNotIn('key', array_keys($variables))->delete();
return $variablesToDelete->count();
} }
private function updateOrCreateVariables($isPreview, $variables) private function updateOrCreateVariables($isPreview, $variables)
{ {
$count = 0;
foreach ($variables as $key => $value) { foreach ($variables as $key => $value) {
if (str($key)->startsWith('SERVICE_FQDN') || str($key)->startsWith('SERVICE_URL')) { if (str($key)->startsWith('SERVICE_FQDN') || str($key)->startsWith('SERVICE_URL')) {
continue; continue;
@@ -198,8 +276,12 @@ class All extends Component
if ($found) { if ($found) {
if (! $found->is_shown_once && ! $found->is_multiline) { if (! $found->is_shown_once && ! $found->is_multiline) {
$found->value = $value; // Only count as a change if the value actually changed
$found->save(); if ($found->value !== $value) {
$found->value = $value;
$found->save();
$count++;
}
} }
} else { } else {
$environment = new EnvironmentVariable; $environment = new EnvironmentVariable;
@@ -212,8 +294,11 @@ class All extends Component
$environment->resourceable_type = $this->resource->getMorphClass(); $environment->resourceable_type = $this->resource->getMorphClass();
$environment->save(); $environment->save();
$count++;
} }
} }
return $count;
} }
public function refreshEnvs() public function refreshEnvs()

View File

@@ -4,10 +4,13 @@ namespace App\Livewire\Project\Shared\EnvironmentVariable;
use App\Models\EnvironmentVariable as ModelsEnvironmentVariable; use App\Models\EnvironmentVariable as ModelsEnvironmentVariable;
use App\Models\SharedEnvironmentVariable; use App\Models\SharedEnvironmentVariable;
use App\Traits\EnvironmentVariableProtection;
use Livewire\Component; use Livewire\Component;
class Show extends Component class Show extends Component
{ {
use EnvironmentVariableProtection;
public $parameters; public $parameters;
public ModelsEnvironmentVariable|SharedEnvironmentVariable $env; public ModelsEnvironmentVariable|SharedEnvironmentVariable $env;
@@ -40,6 +43,8 @@ class Show extends Component
public bool $is_really_required = false; public bool $is_really_required = false;
public bool $is_redis_credential = false;
protected $listeners = [ protected $listeners = [
'refreshEnvs' => 'refresh', 'refreshEnvs' => 'refresh',
'refresh', 'refresh',
@@ -65,7 +70,9 @@ class Show extends Component
} }
$this->parameters = get_route_parameters(); $this->parameters = get_route_parameters();
$this->checkEnvs(); $this->checkEnvs();
if ($this->type === 'standalone-redis' && ($this->env->key === 'REDIS_PASSWORD' || $this->env->key === 'REDIS_USERNAME')) {
$this->is_redis_credential = true;
}
} }
public function refresh() public function refresh()
@@ -171,6 +178,24 @@ class Show extends Component
public function delete() public function delete()
{ {
try { try {
// Check if the variable is protected
if ($this->isProtectedEnvironmentVariable($this->env->key)) {
$this->dispatch('error', "Cannot delete system environment variable '{$this->env->key}'.");
return;
}
// Check if the variable is used in Docker Compose
if ($this->type === 'service' || $this->type === 'application' && $this->env->resource()?->docker_compose) {
[$isUsed, $reason] = $this->isEnvironmentVariableUsedInDockerCompose($this->env->key, $this->env->resource()?->docker_compose);
if ($isUsed) {
$this->dispatch('error', "Cannot delete environment variable '{$this->env->key}' <br><br>Please remove it from the Docker Compose file first.");
return;
}
}
$this->env->delete(); $this->env->delete();
$this->dispatch('environmentVariableDeleted'); $this->dispatch('environmentVariableDeleted');
$this->dispatch('success', 'Environment variable deleted successfully.'); $this->dispatch('success', 'Environment variable deleted successfully.');

View File

@@ -2,7 +2,11 @@
namespace App\Livewire\Server; namespace App\Livewire\Server;
use App\Helpers\SslHelper;
use App\Jobs\RegenerateSslCertJob;
use App\Models\Server; use App\Models\Server;
use App\Models\SslCertificate;
use Carbon\Carbon;
use Livewire\Attributes\Validate; use Livewire\Attributes\Validate;
use Livewire\Component; use Livewire\Component;
@@ -10,6 +14,14 @@ class Advanced extends Component
{ {
public Server $server; public Server $server;
public ?SslCertificate $caCertificate = null;
public $showCertificate = false;
public $certificateContent = '';
public ?Carbon $certificateValidUntil = null;
public array $parameters = []; public array $parameters = [];
#[Validate(['string'])] #[Validate(['string'])]
@@ -30,11 +42,99 @@ class Advanced extends Component
$this->server = Server::ownedByCurrentTeam()->whereUuid($server_uuid)->firstOrFail(); $this->server = Server::ownedByCurrentTeam()->whereUuid($server_uuid)->firstOrFail();
$this->parameters = get_route_parameters(); $this->parameters = get_route_parameters();
$this->syncData(); $this->syncData();
$this->loadCaCertificate();
} catch (\Throwable) { } catch (\Throwable) {
return redirect()->route('server.index'); 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) public function syncData(bool $toModel = false)
{ {
if ($toModel) { if ($toModel) {

View File

@@ -4,11 +4,10 @@ namespace App\Livewire\Server\Proxy;
use App\Actions\Proxy\CheckProxy; use App\Actions\Proxy\CheckProxy;
use App\Actions\Proxy\StartProxy; use App\Actions\Proxy\StartProxy;
use App\Actions\Proxy\StopProxy;
use App\Events\ProxyStatusChanged; use App\Events\ProxyStatusChanged;
use App\Jobs\RestartProxyJob;
use App\Models\Server; use App\Models\Server;
use Carbon\Carbon;
use Illuminate\Process\InvokedProcess;
use Illuminate\Support\Facades\Process;
use Livewire\Component; use Livewire\Component;
class Deploy extends Component class Deploy extends Component
@@ -65,7 +64,7 @@ class Deploy extends Component
public function restart() public function restart()
{ {
try { try {
$this->stop(); RestartProxyJob::dispatch($this->server);
$this->dispatch('checkProxy'); $this->dispatch('checkProxy');
} catch (\Throwable $e) { } catch (\Throwable $e) {
return handleError($e, $this); return handleError($e, $this);
@@ -98,43 +97,10 @@ class Deploy extends Component
public function stop(bool $forceStop = true) public function stop(bool $forceStop = true)
{ {
try { try {
$containerName = $this->server->isSwarm() ? 'coolify-proxy_traefik' : 'coolify-proxy'; StopProxy::run($this->server, $forceStop);
$timeout = 30; $this->dispatch('proxyStatusUpdated');
$process = $this->stopContainer($containerName, $timeout);
$startTime = Carbon::now()->getTimestamp();
while ($process->running()) {
if (Carbon::now()->getTimestamp() - $startTime >= $timeout) {
$this->forceStopContainer($containerName);
break;
}
usleep(100000);
}
$this->removeContainer($containerName);
} catch (\Throwable $e) { } catch (\Throwable $e) {
return handleError($e, $this); return handleError($e, $this);
} finally {
$this->server->proxy->force_stop = $forceStop;
$this->server->proxy->status = 'exited';
$this->server->save();
$this->dispatch('proxyStatusUpdated');
} }
} }
private function stopContainer(string $containerName, int $timeout): InvokedProcess
{
return Process::timeout($timeout)->start("docker stop --time=$timeout $containerName");
}
private function forceStopContainer(string $containerName)
{
instant_remote_process(["docker kill $containerName"], $this->server, throwError: false);
}
private function removeContainer(string $containerName)
{
instant_remote_process(["docker rm -f $containerName"], $this->server, throwError: false);
}
} }

View File

@@ -15,6 +15,8 @@ class SettingsBackup extends Component
{ {
public InstanceSettings $settings; public InstanceSettings $settings;
public Server $server;
public ?StandalonePostgresql $database = null; public ?StandalonePostgresql $database = null;
public ScheduledDatabaseBackup|null|array $backup = []; public ScheduledDatabaseBackup|null|array $backup = [];
@@ -46,6 +48,7 @@ class SettingsBackup extends Component
return redirect()->route('dashboard'); return redirect()->route('dashboard');
} else { } else {
$settings = instanceSettings(); $settings = instanceSettings();
$this->server = Server::findOrFail(0);
$this->database = StandalonePostgresql::whereName('coolify-db')->first(); $this->database = StandalonePostgresql::whereName('coolify-db')->first();
$s3s = S3Storage::whereTeamId(0)->get() ?? []; $s3s = S3Storage::whereTeamId(0)->get() ?? [];
if ($this->database) { if ($this->database) {
@@ -60,6 +63,10 @@ class SettingsBackup extends Component
$this->database->save(); $this->database->save();
} }
$this->backup = $this->database->scheduledBackups->first(); $this->backup = $this->database->scheduledBackups->first();
if ($this->backup && ! $this->server->isFunctional()) {
$this->backup->enabled = false;
$this->backup->save();
}
$this->executions = $this->backup->executions; $this->executions = $this->backup->executions;
} }
$this->settings = $settings; $this->settings = $settings;

View File

@@ -4,7 +4,7 @@ namespace App\Livewire;
use App\Models\InstanceSettings; use App\Models\InstanceSettings;
use App\Models\Team; use App\Models\Team;
use App\Notifications\Test; use App\Notifications\TransactionalEmails\Test;
use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\Facades\RateLimiter;
use Livewire\Attributes\Locked; use Livewire\Attributes\Locked;
use Livewire\Attributes\Validate; use Livewire\Attributes\Validate;
@@ -225,7 +225,7 @@ class SettingsEmail extends Component
'test-email:'.$this->team->id, 'test-email:'.$this->team->id,
$perMinute = 0, $perMinute = 0,
function () { function () {
$this->team?->notify(new Test($this->testEmailAddress, 'email')); $this->team?->notify(new Test($this->testEmailAddress));
$this->dispatch('success', 'Test Email sent.'); $this->dispatch('success', 'Test Email sent.');
}, },
$decaySeconds = 10, $decaySeconds = 10,
@@ -235,7 +235,7 @@ class SettingsEmail extends Component
throw new \Exception('Too many messages sent!'); throw new \Exception('Too many messages sent!');
} }
} catch (\Throwable $e) { } catch (\Throwable $e) {
return handleError($e); return handleError($e, $this);
} }
} }
} }

View File

@@ -1068,7 +1068,6 @@ class Application extends BaseModel
if ($this->deploymentType() === 'other') { if ($this->deploymentType() === 'other') {
$fullRepoUrl = $customRepository; $fullRepoUrl = $customRepository;
$base_command = "{$base_command} {$customRepository}"; $base_command = "{$base_command} {$customRepository}";
$base_command = $this->setGitImportSettings($deployment_uuid, $base_command, public: true);
if ($exec_in_docker) { if ($exec_in_docker) {
$commands->push(executeInDocker($deployment_uuid, $base_command)); $commands->push(executeInDocker($deployment_uuid, $base_command));
@@ -1511,6 +1510,7 @@ class Application extends BaseModel
public function parseHealthcheckFromDockerfile($dockerfile, bool $isInit = false) public function parseHealthcheckFromDockerfile($dockerfile, bool $isInit = false)
{ {
$dockerfile = str($dockerfile)->trim()->explode("\n");
if (str($dockerfile)->contains('HEALTHCHECK') && ($this->isHealthcheckDisabled() || $isInit)) { if (str($dockerfile)->contains('HEALTHCHECK') && ($this->isHealthcheckDisabled() || $isInit)) {
$healthcheckCommand = null; $healthcheckCommand = null;
$lines = $dockerfile->toArray(); $lines = $dockerfile->toArray();
@@ -1530,27 +1530,24 @@ class Application extends BaseModel
} }
} }
if (str($healthcheckCommand)->isNotEmpty()) { if (str($healthcheckCommand)->isNotEmpty()) {
$interval = str($healthcheckCommand)->match('/--interval=(\d+)/'); $interval = str($healthcheckCommand)->match('/--interval=([0-9]+[a-zµ]*)/');
$timeout = str($healthcheckCommand)->match('/--timeout=(\d+)/'); $timeout = str($healthcheckCommand)->match('/--timeout=([0-9]+[a-zµ]*)/');
$start_period = str($healthcheckCommand)->match('/--start-period=(\d+)/'); $start_period = str($healthcheckCommand)->match('/--start-period=([0-9]+[a-zµ]*)/');
$start_interval = str($healthcheckCommand)->match('/--start-interval=(\d+)/');
$retries = str($healthcheckCommand)->match('/--retries=(\d+)/'); $retries = str($healthcheckCommand)->match('/--retries=(\d+)/');
if ($interval->isNotEmpty()) { if ($interval->isNotEmpty()) {
$this->health_check_interval = $interval->toInteger(); $this->health_check_interval = parseDockerfileInterval($interval);
} }
if ($timeout->isNotEmpty()) { if ($timeout->isNotEmpty()) {
$this->health_check_timeout = $timeout->toInteger(); $this->health_check_timeout = parseDockerfileInterval($timeout);
} }
if ($start_period->isNotEmpty()) { if ($start_period->isNotEmpty()) {
$this->health_check_start_period = $start_period->toInteger(); $this->health_check_start_period = parseDockerfileInterval($start_period);
} }
// if ($start_interval) {
// $this->health_check_start_interval = $start_interval->value();
// }
if ($retries->isNotEmpty()) { if ($retries->isNotEmpty()) {
$this->health_check_retries = $retries->toInteger(); $this->health_check_retries = $retries->toInteger();
} }
if ($interval || $timeout || $start_period || $start_interval || $retries) { if ($interval || $timeout || $start_period || $retries) {
$this->custom_healthcheck_found = true; $this->custom_healthcheck_found = true;
$this->save(); $this->save();
} }

View File

@@ -28,6 +28,7 @@ class DiscordNotificationSettings extends Model
'server_disk_usage_discord_notifications', 'server_disk_usage_discord_notifications',
'server_reachable_discord_notifications', 'server_reachable_discord_notifications',
'server_unreachable_discord_notifications', 'server_unreachable_discord_notifications',
'discord_ping_enabled',
]; ];
protected $casts = [ protected $casts = [
@@ -45,6 +46,7 @@ class DiscordNotificationSettings extends Model
'server_disk_usage_discord_notifications' => 'boolean', 'server_disk_usage_discord_notifications' => 'boolean',
'server_reachable_discord_notifications' => 'boolean', 'server_reachable_discord_notifications' => 'boolean',
'server_unreachable_discord_notifications' => 'boolean', 'server_unreachable_discord_notifications' => 'boolean',
'discord_ping_enabled' => 'boolean',
]; ];
public function team() public function team()
@@ -56,4 +58,9 @@ class DiscordNotificationSettings extends Model
{ {
return $this->discord_enabled; return $this->discord_enabled;
} }
public function isPingEnabled()
{
return $this->discord_ping_enabled;
}
} }

View File

@@ -70,10 +70,6 @@ class EmailNotificationSettings extends Model
public function isEnabled() public function isEnabled()
{ {
if (isCloud()) {
return true;
}
return $this->smtp_enabled || $this->resend_enabled || $this->use_instance_email_settings; return $this->smtp_enabled || $this->resend_enabled || $this->use_instance_email_settings;
} }
} }

View File

@@ -3,16 +3,12 @@
namespace App\Models; namespace App\Models;
use App\Jobs\PullHelperImageJob; use App\Jobs\PullHelperImageJob;
use App\Notifications\Channels\SendsEmail;
use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Notifications\Notifiable;
use Spatie\Url\Url; use Spatie\Url\Url;
class InstanceSettings extends Model implements SendsEmail class InstanceSettings extends Model
{ {
use Notifiable;
protected $guarded = []; protected $guarded = [];
protected $casts = [ protected $casts = [
@@ -92,15 +88,15 @@ class InstanceSettings extends Model implements SendsEmail
return InstanceSettings::findOrFail(0); return InstanceSettings::findOrFail(0);
} }
public function getRecipients($notification) // public function getRecipients($notification)
{ // {
$recipients = data_get($notification, 'emails', null); // $recipients = data_get($notification, 'emails', null);
if (is_null($recipients) || $recipients === '') { // if (is_null($recipients) || $recipients === '') {
return []; // return [];
} // }
return explode(',', $recipients); // return explode(',', $recipients);
} // }
public function getTitleDisplayName(): string public function getTitleDisplayName(): string
{ {

View File

@@ -8,6 +8,13 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
class LocalFileVolume extends BaseModel class LocalFileVolume extends BaseModel
{ {
protected $casts = [
// 'fs_path' => 'encrypted',
// 'mount_path' => 'encrypted',
'content' => 'encrypted',
'is_directory' => 'boolean',
];
use HasFactory; use HasFactory;
protected $guarded = []; protected $guarded = [];
@@ -169,4 +176,19 @@ class LocalFileVolume extends BaseModel
return instant_remote_process($commands, $server); return instant_remote_process($commands, $server);
} }
// Accessor for convenient access
protected function plainMountPath(): Attribute
{
return Attribute::make(
get: fn () => $this->mount_path,
set: fn ($value) => $this->mount_path = $value
);
}
// Scope for searching
public function scopeWherePlainMountPath($query, $path)
{
return $query->get()->where('plain_mount_path', $path);
}
} }

View File

@@ -24,11 +24,6 @@ class LocalPersistentVolume extends Model
return $this->morphTo('resource'); return $this->morphTo('resource');
} }
public function standalone_postgresql()
{
return $this->morphTo('resource');
}
protected function name(): Attribute protected function name(): Attribute
{ {
return Attribute::make( return Attribute::make(

View File

@@ -7,9 +7,12 @@ use App\Actions\Server\InstallDocker;
use App\Actions\Server\StartSentinel; use App\Actions\Server\StartSentinel;
use App\Enums\ProxyTypes; use App\Enums\ProxyTypes;
use App\Events\ServerReachabilityChanged; use App\Events\ServerReachabilityChanged;
use App\Helpers\SslHelper;
use App\Jobs\CheckAndStartSentinelJob; use App\Jobs\CheckAndStartSentinelJob;
use App\Jobs\RegenerateSslCertJob;
use App\Notifications\Server\Reachable; use App\Notifications\Server\Reachable;
use App\Notifications\Server\Unreachable; use App\Notifications\Server\Unreachable;
use App\Services\ConfigurationRepository;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
@@ -484,7 +487,7 @@ $schema://$host {
$base_path = config('constants.coolify.base_config_path'); $base_path = config('constants.coolify.base_config_path');
$proxyType = $this->proxyType(); $proxyType = $this->proxyType();
$proxy_path = "$base_path/proxy"; $proxy_path = "$base_path/proxy";
// TODO: should use /traefik for already exisiting configurations? // TODO: should use /traefik for already existing configurations?
// Should move everything except /caddy and /nginx to /traefik // Should move everything except /caddy and /nginx to /traefik
// The code needs to be modified as well, so maybe it does not worth it // The code needs to be modified as well, so maybe it does not worth it
if ($proxyType === ProxyTypes::TRAEFIK->value) { if ($proxyType === ProxyTypes::TRAEFIK->value) {
@@ -543,7 +546,7 @@ $schema://$host {
$this->settings->save(); $this->settings->save();
$sshKeyFileLocation = "id.root@{$this->uuid}"; $sshKeyFileLocation = "id.root@{$this->uuid}";
Storage::disk('ssh-keys')->delete($sshKeyFileLocation); Storage::disk('ssh-keys')->delete($sshKeyFileLocation);
Storage::disk('ssh-mux')->delete($this->muxFilename()); $this->disableSshMux();
} }
public function sentinelHeartbeat(bool $isReset = false) public function sentinelHeartbeat(bool $isReset = false)
@@ -922,7 +925,7 @@ $schema://$host {
public function isFunctional() public function isFunctional()
{ {
$isFunctional = $this->settings->is_reachable && $this->settings->is_usable && $this->settings->force_disabled === false && $this->ip !== '1.2.3.4'; $isFunctional = data_get($this->settings, 'is_reachable') && data_get($this->settings, 'is_usable') && data_get($this->settings, 'force_disabled') === false && $this->ip !== '1.2.3.4';
if ($isFunctional === false) { if ($isFunctional === false) {
Storage::disk('ssh-mux')->delete($this->muxFilename()); Storage::disk('ssh-mux')->delete($this->muxFilename());
@@ -1103,7 +1106,7 @@ $schema://$host {
public function validateConnection(bool $justCheckingNewKey = false) public function validateConnection(bool $justCheckingNewKey = false)
{ {
config()->set('constants.ssh.mux_enabled', false); $this->disableSshMux();
if ($this->skipServer()) { if ($this->skipServer()) {
return ['uptime' => false, 'error' => 'Server skipped.']; return ['uptime' => false, 'error' => 'Server skipped.'];
@@ -1330,4 +1333,47 @@ $schema://$host {
$this->databases()->count() == 0 && $this->databases()->count() == 0 &&
$this->services()->count() == 0; $this->services()->count() == 0;
} }
private function disableSshMux(): void
{
$configRepository = app(ConfigurationRepository::class);
$configRepository->disableSshMux();
}
public function generateCaCertificate()
{
try {
ray('Generating CA certificate for server', $this->id);
SslHelper::generateSslCertificate(
commonName: 'Coolify CA Certificate',
serverId: $this->id,
isCaCertificate: true,
validityDays: 10 * 365
);
$caCertificate = SslCertificate::where('server_id', $this->id)->where('is_ca_certificate', true)->first();
ray('CA certificate generated', $caCertificate);
if ($caCertificate) {
$certificateContent = $caCertificate->ssl_certificate;
$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 '{$certificateContent}' > $caCertPath/coolify-ca.crt",
"chmod 644 $caCertPath/coolify-ca.crt",
]);
instant_remote_process($commands, $this, false);
dispatch(new RegenerateSslCertJob(
server_id: $this->id,
force_regeneration: true
));
}
} catch (\Throwable $e) {
return handleError($e);
}
}
} }

View File

@@ -50,6 +50,11 @@ class Service extends BaseModel
protected static function booted() protected static function booted()
{ {
static::creating(function ($service) {
if (blank($service->name)) {
$service->name = 'service-'.(new Cuid2);
}
});
static::created(function ($service) { static::created(function ($service) {
$service->compose_parsing_version = self::$parserVersion; $service->compose_parsing_version = self::$parserVersion;
$service->save(); $service->save();

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'); return data_get($this, 'environment.project');
} }
public function sslCertificates()
{
return $this->morphMany(SslCertificate::class, 'resource');
}
public function link() public function link()
{ {
if (data_get($this, 'environment.project.uuid')) { if (data_get($this, 'environment.project.uuid')) {
@@ -218,7 +223,12 @@ class StandaloneClickhouse extends BaseModel
protected function internalDbUrl(): Attribute protected function internalDbUrl(): Attribute
{ {
return new 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( return new Attribute(
get: function () { get: function () {
if ($this->is_public && $this->public_port) { 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; return null;

View File

@@ -168,6 +168,11 @@ class StandaloneDragonfly extends BaseModel
return data_get($this, 'environment.project.team'); return data_get($this, 'environment.project.team');
} }
public function sslCertificates()
{
return $this->morphMany(SslCertificate::class, 'resource');
}
public function link() public function link()
{ {
if (data_get($this, 'environment.project.uuid')) { if (data_get($this, 'environment.project.uuid')) {
@@ -218,7 +223,18 @@ class StandaloneDragonfly extends BaseModel
protected function internalDbUrl(): Attribute protected function internalDbUrl(): Attribute
{ {
return new 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( return new Attribute(
get: function () { get: function () {
if ($this->is_public && $this->public_port) { 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; return null;

View File

@@ -168,6 +168,11 @@ class StandaloneKeydb extends BaseModel
return data_get($this, 'environment.project.team'); return data_get($this, 'environment.project.team');
} }
public function sslCertificates()
{
return $this->morphMany(SslCertificate::class, 'resource');
}
public function link() public function link()
{ {
if (data_get($this, 'environment.project.uuid')) { if (data_get($this, 'environment.project.uuid')) {
@@ -218,7 +223,18 @@ class StandaloneKeydb extends BaseModel
protected function internalDbUrl(): Attribute protected function internalDbUrl(): Attribute
{ {
return new 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( return new Attribute(
get: function () { get: function () {
if ($this->is_public && $this->public_port) { 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; return null;

View File

@@ -218,7 +218,12 @@ class StandaloneMariadb extends BaseModel
protected function internalDbUrl(): Attribute protected function internalDbUrl(): Attribute
{ {
return new 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( return new Attribute(
get: function () { get: function () {
if ($this->is_public && $this->public_port) { 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; return null;
@@ -271,6 +279,11 @@ class StandaloneMariadb extends BaseModel
return $this->morphMany(ScheduledDatabaseBackup::class, 'database'); return $this->morphMany(ScheduledDatabaseBackup::class, 'database');
} }
public function sslCertificates()
{
return $this->morphMany(SslCertificate::class, 'resource');
}
public function getCpuMetrics(int $mins = 5) public function getCpuMetrics(int $mins = 5)
{ {
$server = $this->destination->server; $server = $this->destination->server;

View File

@@ -177,6 +177,11 @@ class StandaloneMongodb extends BaseModel
return data_get($this, 'is_log_drain_enabled', false); return data_get($this, 'is_log_drain_enabled', false);
} }
public function sslCertificates()
{
return $this->morphMany(SslCertificate::class, 'resource');
}
public function link() public function link()
{ {
if (data_get($this, 'environment.project.uuid')) { if (data_get($this, 'environment.project.uuid')) {
@@ -238,7 +243,19 @@ class StandaloneMongodb extends BaseModel
protected function internalDbUrl(): Attribute protected function internalDbUrl(): Attribute
{ {
return new 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&tlsCAFile=/etc/mongo/certs/ca.pem';
if (in_array($this->ssl_mode, ['verify-full'])) {
$url .= '&tlsCertificateKeyFile=/etc/mongo/certs/server.pem';
}
}
return $url;
},
); );
} }
@@ -247,7 +264,17 @@ class StandaloneMongodb extends BaseModel
return new Attribute( return new Attribute(
get: function () { get: function () {
if ($this->is_public && $this->public_port) { 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&tlsCAFile=/etc/mongo/certs/ca.pem';
if (in_array($this->ssl_mode, ['verify-full'])) {
$url .= '&tlsCertificateKeyFile=/etc/mongo/certs/server.pem';
}
}
return $url;
} }
return null; return null;

View File

@@ -169,6 +169,11 @@ class StandaloneMysql extends BaseModel
return data_get($this, 'environment.project.team'); return data_get($this, 'environment.project.team');
} }
public function sslCertificates()
{
return $this->morphMany(SslCertificate::class, 'resource');
}
public function link() public function link()
{ {
if (data_get($this, 'environment.project.uuid')) { if (data_get($this, 'environment.project.uuid')) {
@@ -219,7 +224,19 @@ class StandaloneMysql extends BaseModel
protected function internalDbUrl(): Attribute protected function internalDbUrl(): Attribute
{ {
return new 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( return new Attribute(
get: function () { get: function () {
if ($this->is_public && $this->public_port) { 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; return null;

View File

@@ -219,7 +219,19 @@ class StandalonePostgresql extends BaseModel
protected function internalDbUrl(): Attribute protected function internalDbUrl(): Attribute
{ {
return new 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( return new Attribute(
get: function () { get: function () {
if ($this->is_public && $this->public_port) { 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; return null;
@@ -241,11 +263,21 @@ class StandalonePostgresql extends BaseModel
return $this->belongsTo(Environment::class); return $this->belongsTo(Environment::class);
} }
public function persistentStorages()
{
return $this->morphMany(LocalPersistentVolume::class, 'resource');
}
public function fileStorages() public function fileStorages()
{ {
return $this->morphMany(LocalFileVolume::class, 'resource'); return $this->morphMany(LocalFileVolume::class, 'resource');
} }
public function sslCertificates()
{
return $this->morphMany(SslCertificate::class, 'resource');
}
public function destination() public function destination()
{ {
return $this->morphTo(); return $this->morphTo();
@@ -256,16 +288,17 @@ class StandalonePostgresql extends BaseModel
return $this->morphMany(EnvironmentVariable::class, 'resourceable'); return $this->morphMany(EnvironmentVariable::class, 'resourceable');
} }
public function persistentStorages()
{
return $this->morphMany(LocalPersistentVolume::class, 'resource');
}
public function scheduledBackups() public function scheduledBackups()
{ {
return $this->morphMany(ScheduledDatabaseBackup::class, 'database'); return $this->morphMany(ScheduledDatabaseBackup::class, 'database');
} }
public function environment_variables()
{
return $this->morphMany(EnvironmentVariable::class, 'resourceable')
->orderBy('key', 'asc');
}
public function isBackupSolutionAvailable() public function isBackupSolutionAvailable()
{ {
return true; return true;
@@ -314,10 +347,4 @@ class StandalonePostgresql extends BaseModel
return $parsedCollection->toArray(); 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'); return data_get($this, 'environment.project.team');
} }
public function sslCertificates()
{
return $this->morphMany(SslCertificate::class, 'resource');
}
public function link() public function link()
{ {
if (data_get($this, 'environment.project.uuid')) { if (data_get($this, 'environment.project.uuid')) {
@@ -222,9 +227,17 @@ class StandaloneRedis extends BaseModel
return new Attribute( return new Attribute(
get: function () { get: function () {
$redis_version = $this->getRedisVersion(); $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 () { get: function () {
if ($this->is_public && $this->public_port) { if ($this->is_public && $this->public_port) {
$redis_version = $this->getRedisVersion(); $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; return null;

View File

@@ -163,14 +163,17 @@ class Team extends Model implements SendsDiscord, SendsEmail, SendsPushover, Sen
]; ];
} }
public function getRecipients($notification) public function getRecipients(): array
{ {
$recipients = data_get($notification, 'emails', null); $recipients = $this->members()->pluck('email')->toArray();
if (is_null($recipients)) { $validatedEmails = array_filter($recipients, function ($email) {
return $this->members()->pluck('email')->toArray(); return filter_var($email, FILTER_VALIDATE_EMAIL);
});
if (is_null($validatedEmails)) {
return [];
} }
return explode(',', $recipients); return array_values($validatedEmails);
} }
public function isAnyNotificationEnabled() public function isAnyNotificationEnabled()

View File

@@ -4,6 +4,7 @@ namespace App\Models;
use App\Notifications\Channels\SendsEmail; use App\Notifications\Channels\SendsEmail;
use App\Notifications\TransactionalEmails\ResetPassword as TransactionalEmailsResetPassword; use App\Notifications\TransactionalEmails\ResetPassword as TransactionalEmailsResetPassword;
use App\Traits\DeletesUserSessions;
use DateTimeInterface; use DateTimeInterface;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Foundation\Auth\User as Authenticatable;
@@ -37,7 +38,7 @@ use OpenApi\Attributes as OA;
)] )]
class User extends Authenticatable implements SendsEmail class User extends Authenticatable implements SendsEmail
{ {
use HasApiTokens, HasFactory, Notifiable, TwoFactorAuthenticatable; use DeletesUserSessions, HasApiTokens, HasFactory, Notifiable, TwoFactorAuthenticatable;
protected $guarded = []; protected $guarded = [];
@@ -57,6 +58,7 @@ class User extends Authenticatable implements SendsEmail
protected static function boot() protected static function boot()
{ {
parent::boot(); parent::boot();
static::created(function (User $user) { static::created(function (User $user) {
$team = [ $team = [
'name' => $user->name."'s Team", 'name' => $user->name."'s Team",
@@ -114,9 +116,9 @@ class User extends Authenticatable implements SendsEmail
return $this->belongsToMany(Team::class)->withPivot('role'); return $this->belongsToMany(Team::class)->withPivot('role');
} }
public function getRecipients($notification) public function getRecipients(): array
{ {
return $this->email; return [$this->email];
} }
public function sendVerificationEmail() public function sendVerificationEmail()

View File

@@ -20,6 +20,10 @@ class DiscordChannel
return; return;
} }
if (! $discordSettings->discord_ping_enabled) {
$message->isCritical = false;
}
SendMessageToDiscordJob::dispatch($message, $discordSettings->discord_webhook_url); SendMessageToDiscordJob::dispatch($message, $discordSettings->discord_webhook_url);
} }
} }

View File

@@ -2,89 +2,69 @@
namespace App\Notifications\Channels; namespace App\Notifications\Channels;
use Exception;
use Illuminate\Mail\Message;
use Illuminate\Notifications\Notification; use Illuminate\Notifications\Notification;
use Illuminate\Support\Facades\Mail; use Resend;
class EmailChannel class EmailChannel
{ {
public function __construct() {}
public function send(SendsEmail $notifiable, Notification $notification): void public function send(SendsEmail $notifiable, Notification $notification): void
{ {
try { $useInstanceEmailSettings = $notifiable->emailNotificationSettings->use_instance_email_settings;
$this->bootConfigs($notifiable); $isTransactionalEmail = data_get($notification, 'isTransactionalEmail', false);
$recipients = $notifiable->getRecipients($notification); $customEmails = data_get($notification, 'emails', null);
if (count($recipients) === 0) { if ($useInstanceEmailSettings || $isTransactionalEmail) {
throw new Exception('No email recipients found'); $settings = instanceSettings();
} } else {
$settings = $notifiable->emailNotificationSettings;
$mailMessage = $notification->toMail($notifiable);
Mail::send(
[],
[],
fn (Message $message) => $message
->to($recipients)
->subject($mailMessage->subject)
->html((string) $mailMessage->render())
);
} catch (Exception $e) {
$error = $e->getMessage();
if ($error === 'No email settings found.') {
throw $e;
}
$message = "EmailChannel error: {$e->getMessage()}. Failed to send email to:";
if (isset($recipients)) {
$message .= implode(', ', $recipients);
}
if (isset($mailMessage)) {
$message .= " with subject: {$mailMessage->subject}";
}
send_internal_notification($message);
throw $e;
} }
} $isResendEnabled = $settings->resend_enabled;
$isSmtpEnabled = $settings->smtp_enabled;
private function bootConfigs($notifiable): void if ($customEmails) {
{ $recipients = [$customEmails];
$emailSettings = $notifiable->emailNotificationSettings; } else {
$recipients = $notifiable->getRecipients();
if ($emailSettings->use_instance_email_settings) {
$type = set_transanctional_email_settings();
if (blank($type)) {
throw new Exception('No email settings found.');
}
return;
} }
$mailMessage = $notification->toMail($notifiable);
config()->set('mail.from.address', $emailSettings->smtp_from_address ?? 'test@example.com'); if ($isResendEnabled) {
config()->set('mail.from.name', $emailSettings->smtp_from_name ?? 'Test'); $resend = Resend::client($settings->resend_api_key);
$from = "{$settings->smtp_from_name} <{$settings->smtp_from_address}>";
if ($emailSettings->resend_enabled) { $resend->emails->send([
config()->set('mail.default', 'resend'); 'from' => $from,
config()->set('resend.api_key', $emailSettings->resend_api_key); 'to' => $recipients,
} 'subject' => $mailMessage->subject,
'html' => (string) $mailMessage->render(),
if ($emailSettings->smtp_enabled) { ]);
$encryption = match (strtolower($emailSettings->smtp_encryption)) { } elseif ($isSmtpEnabled) {
$encryption = match (strtolower($settings->smtp_encryption)) {
'starttls' => null, 'starttls' => null,
'tls' => 'tls', 'tls' => 'tls',
'none' => null, 'none' => null,
default => null, default => null,
}; };
config()->set('mail.default', 'smtp'); $transport = new \Symfony\Component\Mailer\Transport\Smtp\EsmtpTransport(
config()->set('mail.mailers.smtp', [ $settings->smtp_host,
'transport' => 'smtp', $settings->smtp_port,
'host' => $emailSettings->smtp_host, $encryption
'port' => $emailSettings->smtp_port, );
'encryption' => $encryption, $transport->setUsername($settings->smtp_username ?? '');
'username' => $emailSettings->smtp_username, $transport->setPassword($settings->smtp_password ?? '');
'password' => $emailSettings->smtp_password,
'timeout' => $emailSettings->smtp_timeout, $mailer = new \Symfony\Component\Mailer\Mailer($transport);
'local_domain' => null,
'auto_tls' => $emailSettings->smtp_encryption === 'none' ? '0' : '', // If encryption is "none", it will not try to upgrade to TLS via StartTLS to make sure it is unencrypted. $fromEmail = $settings->smtp_from_address ?? 'noreply@localhost';
]); $fromName = $settings->smtp_from_name ?? 'System';
$from = new \Symfony\Component\Mime\Address($fromEmail, $fromName);
$email = (new \Symfony\Component\Mime\Email)
->from($from)
->to(...$recipients)
->subject($mailMessage->subject)
->html((string) $mailMessage->render());
$mailer->send($email);
} }
} }
} }

View File

@@ -4,5 +4,5 @@ namespace App\Notifications\Channels;
interface SendsEmail interface SendsEmail
{ {
public function getRecipients($notification); public function getRecipients(): array;
} }

View File

@@ -0,0 +1,22 @@
<?php
namespace Illuminate\Notifications;
use App\Notifications\Channels\SendsEmail;
use App\Notifications\Dto\DiscordMessage;
use App\Notifications\Dto\PushoverMessage;
use App\Notifications\Dto\SlackMessage;
use Illuminate\Notifications\Messages\MailMessage;
interface Notification
{
public function toMail(SendsEmail $notifiable): MailMessage;
public function toPushover(): PushoverMessage;
public function toDiscord(): DiscordMessage;
public function toSlack(): SlackMessage;
public function toTelegram();
}

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

@@ -22,7 +22,7 @@ class Test extends Notification implements ShouldQueue
public $tries = 5; public $tries = 5;
public function __construct(public ?string $emails = null, public ?string $channel = null) public function __construct(public ?string $emails = null, public ?string $channel = null, public ?bool $ping = false)
{ {
$this->onQueue('high'); $this->onQueue('high');
} }
@@ -68,6 +68,7 @@ class Test extends Notification implements ShouldQueue
title: ':white_check_mark: Test Success', title: ':white_check_mark: Test Success',
description: 'This is a test Discord notification from Coolify. :cross_mark: :warning: :information_source:', description: 'This is a test Discord notification from Coolify. :cross_mark: :warning: :information_source:',
color: DiscordMessage::successColor(), color: DiscordMessage::successColor(),
isCritical: $this->ping,
); );
$message->addField(name: 'Dashboard', value: '[Link]('.base_url().')', inline: true); $message->addField(name: 'Dashboard', value: '[Link]('.base_url().')', inline: true);
@@ -82,7 +83,7 @@ class Test extends Notification implements ShouldQueue
'buttons' => [ 'buttons' => [
[ [
'text' => 'Go to your dashboard', 'text' => 'Go to your dashboard',
'url' => base_url(), 'url' => isDev() ? 'https://staging-but-dev.coolify.io' : base_url(),
], ],
], ],
]; ];

View File

@@ -16,7 +16,7 @@ class InvitationLink extends CustomEmailNotification
return [TransactionalEmailChannel::class]; return [TransactionalEmailChannel::class];
} }
public function __construct(public User $user) public function __construct(public User $user, public bool $isTransactionalEmail = true)
{ {
$this->onQueue('high'); $this->onQueue('high');
} }

View File

@@ -17,7 +17,7 @@ class ResetPassword extends Notification
public InstanceSettings $settings; public InstanceSettings $settings;
public function __construct($token) public function __construct($token, public bool $isTransactionalEmail = true)
{ {
$this->settings = instanceSettings(); $this->settings = instanceSettings();
$this->token = $token; $this->token = $token;

View File

@@ -8,7 +8,7 @@ use Illuminate\Notifications\Messages\MailMessage;
class Test extends CustomEmailNotification class Test extends CustomEmailNotification
{ {
public function __construct(public string $emails) public function __construct(public string $emails, public bool $isTransactionalEmail = true)
{ {
$this->onQueue('high'); $this->onQueue('high');
} }

View File

@@ -0,0 +1,21 @@
<?php
namespace App\Providers;
use App\Services\ConfigurationRepository;
use Illuminate\Support\ServiceProvider;
class ConfigurationServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->singleton(ConfigurationRepository::class, function ($app) {
return new ConfigurationRepository($app['config']);
});
}
public function boot(): void
{
//
}
}

View File

@@ -0,0 +1,56 @@
<?php
namespace App\Services;
use Illuminate\Config\Repository;
class ConfigurationRepository
{
private Repository $config;
public function __construct(Repository $config)
{
$this->config = $config;
}
public function updateMailConfig($settings): void
{
if ($settings->resend_enabled) {
$this->config->set('mail.default', 'resend');
$this->config->set('mail.from.address', $settings->smtp_from_address ?? 'test@example.com');
$this->config->set('mail.from.name', $settings->smtp_from_name ?? 'Test');
$this->config->set('resend.api_key', $settings->resend_api_key);
return;
}
if ($settings->smtp_enabled) {
$encryption = match (strtolower($settings->smtp_encryption)) {
'starttls' => null,
'tls' => 'tls',
'none' => null,
default => null,
};
$this->config->set('mail.default', 'smtp');
$this->config->set('mail.from.address', $settings->smtp_from_address ?? 'test@example.com');
$this->config->set('mail.from.name', $settings->smtp_from_name ?? 'Test');
$this->config->set('mail.mailers.smtp', [
'transport' => 'smtp',
'host' => $settings->smtp_host,
'port' => $settings->smtp_port,
'encryption' => $encryption,
'username' => $settings->smtp_username,
'password' => $settings->smtp_password,
'timeout' => $settings->smtp_timeout,
'local_domain' => null,
'auto_tls' => $settings->smtp_encryption === 'none' ? '0' : '',
]);
}
}
public function disableSshMux(): void
{
$this->config->set('constants.ssh.mux_enabled', false);
}
}

View File

@@ -0,0 +1,34 @@
<?php
namespace App\Traits;
use DB;
use Illuminate\Support\Facades\Session;
trait DeletesUserSessions
{
/**
* Delete all sessions for the current user.
* This will force the user to log in again on all devices.
*/
public function deleteAllSessions(): void
{
// Invalidate the current session
Session::invalidate();
Session::regenerateToken();
DB::table('sessions')->where('user_id', $this->id)->delete();
}
/**
* Boot the trait.
*/
protected static function bootDeletesUserSessions()
{
static::updated(function ($user) {
// Check if password was changed
if ($user->isDirty('password')) {
$user->deleteAllSessions();
}
});
}
}

View File

@@ -0,0 +1,63 @@
<?php
namespace App\Traits;
use Symfony\Component\Yaml\Yaml;
trait EnvironmentVariableProtection
{
/**
* Check if an environment variable is protected from deletion
*
* @param string $key The environment variable key to check
* @return bool True if the variable is protected, false otherwise
*/
protected function isProtectedEnvironmentVariable(string $key): bool
{
return str($key)->startsWith('SERVICE_FQDN') || str($key)->startsWith('SERVICE_URL');
}
/**
* Check if an environment variable is used in Docker Compose
*
* @param string $key The environment variable key to check
* @param string|null $dockerCompose The Docker Compose YAML content
* @return array [bool $isUsed, string $reason] Whether the variable is used and the reason if it is
*/
protected function isEnvironmentVariableUsedInDockerCompose(string $key, ?string $dockerCompose): array
{
if (empty($dockerCompose)) {
return [false, ''];
}
try {
$dockerComposeData = Yaml::parse($dockerCompose);
$dockerEnvVars = data_get($dockerComposeData, 'services.*.environment');
foreach ($dockerEnvVars as $serviceEnvs) {
if (! is_array($serviceEnvs)) {
continue;
}
// Check for direct variable usage
foreach ($serviceEnvs as $env => $value) {
if ($env === $key) {
return [true, "Environment variable '{$key}' is used directly in the Docker Compose file."];
}
}
// Check for variable references in values
foreach ($serviceEnvs as $env => $value) {
if (is_string($value) && str_contains($value, '$'.$key)) {
return [true, "Environment variable '{$key}' is referenced in the Docker Compose file."];
}
}
}
} catch (\Exception $e) {
// If there's an error parsing the Docker Compose file, we'll assume it's not used
return [false, ''];
}
return [false, ''];
}
}

View File

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

View File

@@ -4,7 +4,6 @@ namespace App\View\Components\Forms;
use Closure; use Closure;
use Illuminate\Contracts\View\View; use Illuminate\Contracts\View\View;
use Illuminate\Support\Str;
use Illuminate\View\Component; use Illuminate\View\Component;
use Visus\Cuid2\Cuid2; use Visus\Cuid2\Cuid2;
@@ -19,7 +18,8 @@ class Select extends Component
public ?string $label = null, public ?string $label = null,
public ?string $helper = null, public ?string $helper = null,
public bool $required = false, public bool $required = false,
public string $defaultClass = 'select' public bool $disabled = false,
public string $defaultClass = 'select w-full'
) { ) {
// //
} }
@@ -36,8 +36,6 @@ class Select extends Component
$this->name = $this->id; $this->name = $this->id;
} }
$this->label = Str::title($this->label);
return view('components.forms.select'); return view('components.forms.select');
} }
} }

View File

@@ -16,16 +16,12 @@ use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Visus\Cuid2\Cuid2; 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 function create_standalone_postgresql($environmentId, $destinationUuid, ?array $otherData = null, string $databaseImage = 'postgres:16-alpine'): StandalonePostgresql
{ {
$destination = StandaloneDocker::where('uuid', $destinationUuid)->firstOrFail(); $destination = StandaloneDocker::where('uuid', $destinationUuid)->firstOrFail();
$database = new StandalonePostgresql; $database = new StandalonePostgresql;
$database->name = generate_database_name('postgresql'); $database->uuid = (new Cuid2);
$database->name = 'postgresql-database-'.$database->uuid;
$database->image = $databaseImage; $database->image = $databaseImage;
$database->postgres_password = \Illuminate\Support\Str::password(length: 64, symbols: false); $database->postgres_password = \Illuminate\Support\Str::password(length: 64, symbols: false);
$database->environment_id = $environmentId; $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(); $destination = StandaloneDocker::where('uuid', $destination_uuid)->firstOrFail();
$database = new StandaloneRedis; $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); $redis_password = \Illuminate\Support\Str::password(length: 64, symbols: false);
$database->environment_id = $environment_id; $database->environment_id = $environment_id;
$database->destination_id = $destination->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(); $destination = StandaloneDocker::where('uuid', $destination_uuid)->firstOrFail();
$database = new StandaloneMongodb; $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->mongo_initdb_root_password = \Illuminate\Support\Str::password(length: 64, symbols: false);
$database->environment_id = $environment_id; $database->environment_id = $environment_id;
$database->destination_id = $destination->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(); $destination = StandaloneDocker::where('uuid', $destination_uuid)->firstOrFail();
$database = new StandaloneMysql; $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_root_password = \Illuminate\Support\Str::password(length: 64, symbols: false);
$database->mysql_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; $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(); $destination = StandaloneDocker::where('uuid', $destination_uuid)->firstOrFail();
$database = new StandaloneMariadb; $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_root_password = \Illuminate\Support\Str::password(length: 64, symbols: false);
$database->mariadb_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; $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(); $destination = StandaloneDocker::where('uuid', $destination_uuid)->firstOrFail();
$database = new StandaloneKeydb; $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->keydb_password = \Illuminate\Support\Str::password(length: 64, symbols: false);
$database->environment_id = $environment_id; $database->environment_id = $environment_id;
$database->destination_id = $destination->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(); $destination = StandaloneDocker::where('uuid', $destination_uuid)->firstOrFail();
$database = new StandaloneDragonfly; $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->dragonfly_password = \Illuminate\Support\Str::password(length: 64, symbols: false);
$database->environment_id = $environment_id; $database->environment_id = $environment_id;
$database->destination_id = $destination->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(); $destination = StandaloneDocker::where('uuid', $destination_uuid)->firstOrFail();
$database = new StandaloneClickhouse; $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->clickhouse_admin_password = \Illuminate\Support\Str::password(length: 64, symbols: false);
$database->environment_id = $environment_id; $database->environment_id = $environment_id;
$database->destination_id = $destination->id; $database->destination_id = $destination->id;

View File

@@ -8,6 +8,7 @@ use App\Models\ServiceApplication;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Spatie\Url\Url; use Spatie\Url\Url;
use Symfony\Component\Yaml\Yaml;
use Visus\Cuid2\Cuid2; use Visus\Cuid2\Cuid2;
function getCurrentApplicationContainerStatus(Server $server, int $id, ?int $pullRequestId = null, ?bool $includePullrequests = false): Collection function getCurrentApplicationContainerStatus(Server $server, int $id, ?int $pullRequestId = null, ?bool $includePullrequests = false): Collection
@@ -834,7 +835,15 @@ function validateComposeFile(string $compose, int $server_id): string|Throwable
if (! $server) { if (! $server) {
throw new \Exception('Server not found'); throw new \Exception('Server not found');
} }
$base64_compose = base64_encode($compose); $yaml_compose = Yaml::parse($compose);
foreach ($yaml_compose['services'] as $service_name => $service) {
foreach ($service['volumes'] as $volume_name => $volume) {
if (data_get($volume, 'type') === 'bind' && data_get($volume, 'content')) {
unset($yaml_compose['services'][$service_name]['volumes'][$volume_name]['content']);
}
}
}
$base64_compose = base64_encode(Yaml::dump($yaml_compose));
instant_remote_process([ instant_remote_process([
"echo {$base64_compose} | base64 -d | tee /tmp/{$uuid}.yml > /dev/null", "echo {$base64_compose} | base64 -d | tee /tmp/{$uuid}.yml > /dev/null",
"chmod 600 /tmp/{$uuid}.yml", "chmod 600 /tmp/{$uuid}.yml",

View File

@@ -129,3 +129,27 @@ function getPermissionsPath(GithubApp $source)
return "$github->html_url/settings/apps/$name/permissions"; return "$github->html_url/settings/apps/$name/permissions";
} }
function loadRepositoryByPage(GithubApp $source, string $token, int $page)
{
$response = Http::withToken($token)->get("{$source->api_url}/installation/repositories?per_page=100&page={$page}");
$json = $response->json();
if ($response->status() !== 200) {
return [
'total_count' => 0,
'repositories' => [],
];
}
if ($json['total_count'] === 0) {
return [
'total_count' => 0,
'repositories' => [],
];
}
return [
'total_count' => $json['total_count'],
'repositories' => $json['repositories'],
];
}

View File

@@ -1,6 +1,5 @@
<?php <?php
use App\Models\InstanceSettings;
use App\Models\Team; use App\Models\Team;
use App\Notifications\Internal\GeneralNotification; use App\Notifications\Internal\GeneralNotification;
use Illuminate\Mail\Message; use Illuminate\Mail\Message;
@@ -54,7 +53,7 @@ function send_user_an_email(MailMessage $mail, string $email, ?string $cc = null
} }
} }
function set_transanctional_email_settings(?InstanceSettings $settings = null): ?string // returns null|resend|smtp and defaults to array based on mail.php config function set_transanctional_email_settings($settings = null)
{ {
if (! $settings) { if (! $settings) {
$settings = instanceSettings(); $settings = instanceSettings();
@@ -63,38 +62,16 @@ function set_transanctional_email_settings(?InstanceSettings $settings = null):
return null; return null;
} }
if (data_get($settings, 'resend_enabled')) { $configRepository = app('App\Services\ConfigurationRepository'::class);
config()->set('mail.default', 'resend'); $configRepository->updateMailConfig($settings);
config()->set('mail.from.address', data_get($settings, 'smtp_from_address'));
config()->set('mail.from.name', data_get($settings, 'smtp_from_name'));
config()->set('resend.api_key', data_get($settings, 'resend_api_key'));
if (data_get($settings, 'resend_enabled')) {
return 'resend'; return 'resend';
} }
$encryption = match (strtolower(data_get($settings, 'smtp_encryption'))) {
'starttls' => null,
'tls' => 'tls',
'none' => null,
default => null,
};
if (data_get($settings, 'smtp_enabled')) { if (data_get($settings, 'smtp_enabled')) {
config()->set('mail.from.address', data_get($settings, 'smtp_from_address'));
config()->set('mail.from.name', data_get($settings, 'smtp_from_name'));
config()->set('mail.default', 'smtp');
config()->set('mail.mailers.smtp', [
'transport' => 'smtp',
'host' => data_get($settings, 'smtp_host'),
'port' => data_get($settings, 'smtp_port'),
'encryption' => $encryption,
'username' => data_get($settings, 'smtp_username'),
'password' => data_get($settings, 'smtp_password'),
'timeout' => data_get($settings, 'smtp_timeout'),
'local_domain' => null,
'auto_tls' => data_get($settings, 'smtp_encryption') === 'none' ? '0' : '', // If encryption is "none", it will not try to upgrade to TLS via StartTLS to make sure it is unencrypted.
]);
return 'smtp'; return 'smtp';
} }
return null;
} }

View File

@@ -1250,13 +1250,23 @@ function get_public_ips()
function isAnyDeploymentInprogress() function isAnyDeploymentInprogress()
{ {
$runningJobs = ApplicationDeploymentQueue::where('horizon_job_worker', gethostname())->where('status', ApplicationDeploymentStatus::IN_PROGRESS->value)->get(); $runningJobs = ApplicationDeploymentQueue::where('horizon_job_worker', gethostname())->where('status', ApplicationDeploymentStatus::IN_PROGRESS->value)->get();
$basicDetails = $runningJobs->map(function ($job) {
return [
'id' => $job->id,
'created_at' => $job->created_at,
'application_id' => $job->application_id,
'server_id' => $job->server_id,
'horizon_job_id' => $job->horizon_job_id,
'status' => $job->status,
];
});
echo 'Running jobs: '.json_encode($basicDetails)."\n";
$horizonJobIds = []; $horizonJobIds = [];
foreach ($runningJobs as $runningJob) { foreach ($runningJobs as $runningJob) {
$horizonJobStatus = getJobStatus($runningJob->horizon_job_id); $horizonJobStatus = getJobStatus($runningJob->horizon_job_id);
if ($horizonJobStatus === 'unknown') { if ($horizonJobStatus === 'unknown' || $horizonJobStatus === 'reserved') {
return true; $horizonJobIds[] = $runningJob->horizon_job_id;
} }
$horizonJobIds[] = $runningJob->horizon_job_id;
} }
if (count($horizonJobIds) === 0) { if (count($horizonJobIds) === 0) {
echo "No deployments in progress.\n"; echo "No deployments in progress.\n";
@@ -1665,6 +1675,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
if ($source->value() === '/tmp' || $source->value() === '/tmp/') { if ($source->value() === '/tmp' || $source->value() === '/tmp/') {
return $volume; return $volume;
} }
LocalFileVolume::updateOrCreate( LocalFileVolume::updateOrCreate(
[ [
'mount_path' => $target, 'mount_path' => $target,
@@ -3164,6 +3175,13 @@ function newParser(Application|Service $resource, int $pull_request_id = 0, ?int
} }
} }
$serviceAppsLogDrainEnabledMap = collect([]);
if ($resource instanceof Service) {
$serviceAppsLogDrainEnabledMap = $resource->applications()->get()->keyBy('name')->map(function ($app) {
return $app->isLogDrainEnabled();
});
}
// Parse the rest of the services // Parse the rest of the services
foreach ($services as $serviceName => $service) { foreach ($services as $serviceName => $service) {
$image = data_get_str($service, 'image'); $image = data_get_str($service, 'image');
@@ -3174,6 +3192,9 @@ function newParser(Application|Service $resource, int $pull_request_id = 0, ?int
if ($resource instanceof Application && $resource->isLogDrainEnabled()) { if ($resource instanceof Application && $resource->isLogDrainEnabled()) {
$logging = generate_fluentd_configuration(); $logging = generate_fluentd_configuration();
} }
if ($resource instanceof Service && $serviceAppsLogDrainEnabledMap->get($serviceName)) {
$logging = generate_fluentd_configuration();
}
} }
$volumes = collect(data_get($service, 'volumes', [])); $volumes = collect(data_get($service, 'volumes', []));
$networks = collect(data_get($service, 'networks', [])); $networks = collect(data_get($service, 'networks', []));
@@ -4050,9 +4071,35 @@ function isEmailRateLimited(string $limiterKey, int $decaySeconds = 3600, ?calla
return $rateLimited; return $rateLimited;
} }
function defaultNginxConfiguration(): string function defaultNginxConfiguration(string $type = 'static'): string
{ {
return 'server { if ($type === 'spa') {
return <<<'NGINX'
server {
location / {
root /usr/share/nginx/html;
index index.html;
try_files $uri $uri/ /index.html;
}
# Handle 404 errors
error_page 404 /404.html;
location = /404.html {
root /usr/share/nginx/html;
internal;
}
# Handle server errors (50x)
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;
internal;
}
}
NGINX;
} else {
return <<<'NGINX'
server {
location / { location / {
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.html index.htm; index index.html index.htm;
@@ -4072,7 +4119,9 @@ function defaultNginxConfiguration(): string
root /usr/share/nginx/html; root /usr/share/nginx/html;
internal; internal;
} }
}'; }
NGINX;
}
} }
function convertGitUrl(string $gitRepository, string $deploymentType, ?GithubApp $source = null): array function convertGitUrl(string $gitRepository, string $deploymentType, ?GithubApp $source = null): array
@@ -4137,3 +4186,35 @@ function getJobStatus(?string $jobId = null)
return $jobFound->first()->status; return $jobFound->first()->status;
} }
function parseDockerfileInterval(string $something)
{
$value = preg_replace('/[^0-9]/', '', $something);
$unit = preg_replace('/[0-9]/', '', $something);
// Default to seconds if no unit specified
$unit = $unit ?: 's';
// Convert to seconds based on unit
$seconds = (int) $value;
switch ($unit) {
case 'ns':
$seconds = (int) ($value / 1000000000);
break;
case 'us':
case 'µs':
$seconds = (int) ($value / 1000000);
break;
case 'ms':
$seconds = (int) ($value / 1000);
break;
case 'm':
$seconds = (int) ($value * 60);
break;
case 'h':
$seconds = (int) ($value * 3600);
break;
}
return $seconds;
}

View File

@@ -12,65 +12,63 @@
], ],
"require": { "require": {
"php": "^8.4", "php": "^8.4",
"3sidedcube/laravel-redoc": "^1.0", "danharrin/livewire-rate-limiting": "2.1.0",
"danharrin/livewire-rate-limiting": "2.0.0", "doctrine/dbal": "^4.2.2",
"doctrine/dbal": "^4.2", "guzzlehttp/guzzle": "^7.9.2",
"guzzlehttp/guzzle": "^7.5.0", "laravel/fortify": "^1.25.4",
"laravel/fortify": "^1.16.0", "laravel/framework": "12.4.1",
"laravel/framework": "^11.0", "laravel/horizon": "^5.30.3",
"laravel/horizon": "^5.29.1", "laravel/pail": "^1.2.2",
"laravel/pail": "^1.1", "laravel/prompts": "^0.3.5|^0.3.5|^0.3.5",
"laravel/prompts": "^0.1.18|^0.2.0|^0.3.0", "laravel/sanctum": "^4.0.8",
"laravel/sanctum": "^4.0", "laravel/socialite": "^5.18.0",
"laravel/socialite": "^5.14.0", "laravel/tinker": "^2.10.1",
"laravel/tinker": "^2.8.1", "laravel/ui": "^4.6.1",
"laravel/ui": "^4.2", "lcobucci/jwt": "^5.5.0",
"lcobucci/jwt": "^5.0.0", "league/flysystem-aws-s3-v3": "^3.29",
"league/flysystem-aws-s3-v3": "^3.0", "league/flysystem-sftp-v3": "^3.29",
"league/flysystem-sftp-v3": "^3.0", "livewire/livewire": "^3.5.20",
"livewire/livewire": "^3.5", "log1x/laravel-webfonts": "^2.0.1",
"log1x/laravel-webfonts": "^1.0", "lorisleiva/laravel-actions": "^2.8.6",
"lorisleiva/laravel-actions": "^2.8",
"nubs/random-name-generator": "^2.2", "nubs/random-name-generator": "^2.2",
"phpseclib/phpseclib": "^3.0", "phpseclib/phpseclib": "^3.0.43",
"pion/laravel-chunk-upload": "^1.5", "pion/laravel-chunk-upload": "^1.5.4",
"poliander/cron": "^3.0", "poliander/cron": "^3.2.1",
"purplepixie/phpdns": "^2.1", "purplepixie/phpdns": "^2.2",
"pusher/pusher-php-server": "^7.2", "pusher/pusher-php-server": "^7.2.7",
"resend/resend-laravel": "^0.15.0", "resend/resend-laravel": "^0.17.0",
"sentry/sentry-laravel": "^4.6", "sentry/sentry-laravel": "^4.13",
"socialiteproviders/authentik": "^5.2", "socialiteproviders/authentik": "^5.2",
"socialiteproviders/google": "^4.1", "socialiteproviders/google": "^4.1",
"socialiteproviders/infomaniak": "^4.0", "socialiteproviders/infomaniak": "^4.0",
"socialiteproviders/microsoft-azure": "^5.1", "socialiteproviders/microsoft-azure": "^5.2",
"spatie/laravel-activitylog": "^4.7.3", "spatie/laravel-activitylog": "^4.10.1",
"spatie/laravel-data": "^4.11", "spatie/laravel-data": "^4.13.1",
"spatie/laravel-ray": "^1.37", "spatie/laravel-ray": "^1.39.1",
"spatie/laravel-schemaless-attributes": "^2.4", "spatie/laravel-schemaless-attributes": "^2.5.1",
"spatie/url": "^2.2", "spatie/url": "^2.4",
"stevebauman/purify": "^6.2", "stevebauman/purify": "^6.3",
"stripe/stripe-php": "^16.2.0", "stripe/stripe-php": "^16.5.1",
"symfony/yaml": "^7.1.6", "symfony/yaml": "^7.2.3",
"visus/cuid2": "^4.1.0", "visus/cuid2": "^4.1.0",
"yosymfony/toml": "^1.0", "yosymfony/toml": "^1.0.4",
"zircote/swagger-php": "^5.0" "zircote/swagger-php": "^5.0.5"
}, },
"require-dev": { "require-dev": {
"barryvdh/laravel-debugbar": "^3.13", "barryvdh/laravel-debugbar": "^3.15.1",
"driftingly/rector-laravel": "^2.0", "driftingly/rector-laravel": "^2.0.2",
"fakerphp/faker": "^1.21.0", "fakerphp/faker": "^1.24.1",
"laravel/dusk": "^8.0", "laravel/dusk": "^8.3.1",
"laravel/pint": "^1.16", "laravel/pint": "^1.21",
"laravel/telescope": "^5.2", "laravel/telescope": "^5.5",
"mockery/mockery": "^1.5.1", "mockery/mockery": "^1.6.12",
"nunomaduro/collision": "^8.1", "nunomaduro/collision": "^8.6.1",
"pestphp/pest": "^3.5", "pestphp/pest": "^3.8.0",
"phpstan/phpstan": "^2.1", "phpstan/phpstan": "^2.1.6",
"phpunit/phpunit": "^11.5", "rector/rector": "^2.0.9",
"rector/rector": "^2.0", "serversideup/spin": "^3.0.2",
"serversideup/spin": "^3.0", "spatie/laravel-ignition": "^2.9.1",
"spatie/laravel-ignition": "^2.1.0", "symfony/http-client": "^7.2.3"
"symfony/http-client": "^7.1"
}, },
"minimum-stability": "stable", "minimum-stability": "stable",
"prefer-stable": true, "prefer-stable": true,

919
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -199,6 +199,7 @@ return [
App\Providers\EventServiceProvider::class, App\Providers\EventServiceProvider::class,
App\Providers\HorizonServiceProvider::class, App\Providers\HorizonServiceProvider::class,
App\Providers\RouteServiceProvider::class, App\Providers\RouteServiceProvider::class,
App\Providers\ConfigurationServiceProvider::class,
], ],
/* /*

View File

@@ -2,13 +2,14 @@
return [ return [
'coolify' => [ 'coolify' => [
'version' => '4.0.0-beta.399', 'version' => '4.0.0-beta.407',
'helper_version' => '1.0.7', 'helper_version' => '1.0.8',
'realtime_version' => '1.0.6', 'realtime_version' => '1.0.6',
'self_hosted' => env('SELF_HOSTED', true), 'self_hosted' => env('SELF_HOSTED', true),
'autoupdate' => env('AUTOUPDATE'), 'autoupdate' => env('AUTOUPDATE'),
'base_config_path' => env('BASE_CONFIG_PATH', '/data/coolify'), 'base_config_path' => env('BASE_CONFIG_PATH', '/data/coolify'),
'helper_image' => env('HELPER_IMAGE', 'ghcr.io/coollabsio/coolify-helper'), 'registry_url' => env('REGISTRY_URL', 'ghcr.io'),
'helper_image' => env('HELPER_IMAGE', env('REGISTRY_URL', 'ghcr.io').'/coollabsio/coolify-helper'),
'is_windows_docker_desktop' => env('IS_WINDOWS_DOCKER_DESKTOP', false), 'is_windows_docker_desktop' => env('IS_WINDOWS_DOCKER_DESKTOP', false),
], ],

Some files were not shown because too many files have changed in this diff Show More