Merge remote-tracking branch 'origin/release/v4.5' into automation/sync-main-to-release-v4.5

# Conflicts:
#	config/constants.php
#	tests/Feature/Api/ApplicationSettingsApiTest.php
This commit is contained in:
Andras Bacsai
2026-10-08 08:36:19 +02:00
108 changed files with 8429 additions and 856 deletions
@@ -28,6 +28,12 @@ class CleanupCancelledDeployment
if (str($containerExists)->trim()->isNotEmpty()) {
instant_remote_process(["docker rm -f {$deploymentUuid}"], $server);
$containerStopped = true;
// A removed helper cannot stop the Railpack builder, which keeps the memory of its builds.
if ($deployment->application?->build_pack === 'railpack') {
$helperImage = coolifyHelperImage().':'.getHelperVersion();
instant_remote_process([railpackBuilderHelperCommand(railpackBuildxMetadataVolume($server), $helperImage, 'true')], $server, false);
}
}
} catch (\Throwable $e) {
$firstError ??= $e;
+31
View File
@@ -0,0 +1,31 @@
<?php
namespace App\Actions\Database;
use App\Models\StandaloneDragonfly;
use App\Models\StandaloneKeydb;
use App\Models\StandaloneRedis;
use Lorisleiva\Actions\Concerns\AsAction;
use RuntimeException;
class FlushDatabase
{
use AsAction;
public function handle(StandaloneRedis|StandaloneKeydb|StandaloneDragonfly $database): void
{
$cli = $database instanceof StandaloneKeydb ? 'keydb-cli' : 'redis-cli';
if ($database instanceof StandaloneDragonfly && $database->enable_ssl) {
$cli .= ' --tls --cacert /etc/dragonfly/certs/coolify-ca.crt --cert /etc/dragonfly/certs/server.crt --key /etc/dragonfly/certs/server.key';
}
$output = instant_remote_process(
["docker exec {$database->uuid} sh -c 'REDISCLI_AUTH=\"\$REDIS_PASSWORD\" {$cli} FLUSHALL ASYNC'"],
$database->destination->server,
);
if ($output !== 'OK') {
throw new RuntimeException($output ?: 'Failed to flush the database.');
}
}
}
+3 -2
View File
@@ -56,10 +56,11 @@ class StartProxy
"echo 'Successfully started coolify-proxy.'",
]);
} else {
$caddyfile = 'import /dynamic/*.caddy';
// The base Caddyfile also holds the maintenance blocks (see Server::caddyBaseCaddyfile()).
$caddyfile = base64_encode($server->caddyBaseCaddyfile());
$commands = $commands->merge([
"mkdir -p $proxy_path/dynamic",
"echo '$caddyfile' | tee $proxy_path/dynamic/Caddyfile > /dev/null",
"echo '$caddyfile' | base64 -d | tee $proxy_path/dynamic/Caddyfile > /dev/null",
"echo 'Creating required Docker Compose file.'",
"echo 'Pulling docker image.'",
"docker compose -f $compose_file pull",
+5
View File
@@ -23,11 +23,16 @@ class StopProxy
ProxyStatusChangedUI::dispatch($server->team_id);
}
$errorPageCommands = $server->isSwarm() ? [] : [
'docker rm -f '.Server::PROXY_ERROR_PAGE_CONTAINER.' 2>/dev/null || true',
];
instant_remote_process(command: [
dockerStopCommand($timeout, $containerName, $server).' 2>/dev/null || true',
"docker rm -f $containerName 2>/dev/null || true",
// The Traefik traffic analytics log rotation sidecar must not outlive the proxy.
'docker rm -f '.TRAEFIK_LOGROTATE_CONTAINER.' 2>/dev/null || true',
...$errorPageCommands,
'# Wait for container to be fully removed',
'for i in {1..10}; do',
" if ! docker ps -a --format \"{{.Names}}\" | grep -q \"^$containerName$\"; then",
+2 -4
View File
@@ -135,9 +135,6 @@ class CleanupDocker implements ShouldBeUnique
$helperImageWithVersion = "$helperImage:$helperImageVersion";
$helperImageWithoutPrefix = 'coollabsio/coolify-helper';
$helperImageWithoutPrefixVersion = "coollabsio/coolify-helper:$helperImageVersion";
$buildxMetadataVolume = isDev() && $server->isLocalhost()
? 'coolify-buildx'
: '$HOME/.docker/buildx';
$cleanupLog = [];
@@ -164,7 +161,8 @@ class CleanupDocker implements ShouldBeUnique
'docker container prune -f --filter "label=coolify.managed=true" --filter "label!=coolify.proxy=true" --filter "label!=coolify.type=database" --filter "label!=coolify.type=application" --filter "label!=coolify.type=service"',
$imagePruneCmd,
'docker builder prune -af',
"docker run --rm -v {$buildxMetadataVolume}:/root/.docker/buildx -v /var/run/docker.sock:/var/run/docker.sock {$helperImageWithVersion} docker buildx prune --builder coolify-railpack -af 2>/dev/null || true",
// The prune fails on a stopped builder, so start it first.
railpackBuilderHelperCommand(railpackBuildxMetadataVolume($server), $helperImageWithVersion, 'docker buildx inspect --bootstrap coolify-railpack >/dev/null 2>&1; docker buildx prune --builder coolify-railpack -af'),
"docker images --filter before=$helperImageWithVersion --filter reference=$helperImage | grep $helperImage | awk '{print $3}' | xargs -r docker rmi -f",
"docker images --filter before=$helperImageWithoutPrefixVersion --filter reference=$helperImageWithoutPrefix | grep $helperImageWithoutPrefix | awk '{print $3}' | xargs -r docker rmi -f",
];
+56 -1
View File
@@ -8,6 +8,7 @@ use App\Models\Team;
use App\Notifications\Server\HetznerDeletionFailed;
use App\Services\DigitalOceanService;
use App\Services\HetznerService;
use App\Services\HostingerService;
use App\Services\VultrService;
use Lorisleiva\Actions\Concerns\AsAction;
@@ -15,7 +16,7 @@ class DeleteServer
{
use AsAction;
public function handle(int $serverId, bool $deleteFromHetzner = false, ?int $hetznerServerId = null, ?int $cloudProviderTokenId = null, ?int $teamId = null, bool $deleteFromVultr = false, ?string $vultrInstanceId = null, bool $deleteFromDigitalOcean = false, ?int $digitalOceanDropletId = null)
public function handle(int $serverId, bool $deleteFromHetzner = false, ?int $hetznerServerId = null, ?int $cloudProviderTokenId = null, ?int $teamId = null, bool $deleteFromVultr = false, ?string $vultrInstanceId = null, bool $deleteFromDigitalOcean = false, ?int $digitalOceanDropletId = null, bool $deleteFromHostinger = false, ?int $hostingerVirtualMachineId = null)
{
$server = Server::withTrashed()->find($serverId);
@@ -44,6 +45,14 @@ class DeleteServer
);
}
if ($deleteFromHostinger && ($hostingerVirtualMachineId || ($server && $server->hostinger_virtual_machine_id))) {
$this->deleteFromHostingerById(
$hostingerVirtualMachineId ?? $server->hostinger_virtual_machine_id,
$cloudProviderTokenId ?? $server->cloud_provider_token_id,
$teamId ?? $server->team_id
);
}
logger()->debug($server ? 'Deleting server from Coolify' : 'Server already deleted from Coolify, skipping Coolify deletion');
// If server is already deleted from Coolify, skip this part
@@ -182,4 +191,50 @@ class DeleteServer
throw $e;
}
}
private function deleteFromHostingerById(int $hostingerVirtualMachineId, ?int $cloudProviderTokenId, int $teamId): void
{
try {
$token = null;
if ($cloudProviderTokenId) {
$token = CloudProviderToken::where('id', $cloudProviderTokenId)
->where('team_id', $teamId)
->where('provider', 'hostinger')
->first();
}
if (! $token) {
$token = CloudProviderToken::where('team_id', $teamId)
->where('provider', 'hostinger')
->first();
}
if (! $token) {
throw new \RuntimeException('No Hostinger token found for the server team.');
}
$hostingerService = new HostingerService($token->token);
$subscriptionId = $hostingerService->getVirtualMachine($hostingerVirtualMachineId)['subscription_id'] ?? null;
if (! $subscriptionId) {
throw new \RuntimeException('The Hostinger VPS has no subscription.');
}
$hostingerService->disableAutoRenewal($subscriptionId);
logger()->debug('Disabled Hostinger auto-renewal, so Hostinger deletes the VPS at the end of the paid period', [
'hostinger_virtual_machine_id' => $hostingerVirtualMachineId,
'team_id' => $teamId,
]);
} catch (\Throwable $e) {
logger()->error('Failed to delete VPS from Hostinger', [
'error' => $e->getMessage(),
'hostinger_virtual_machine_id' => $hostingerVirtualMachineId,
'team_id' => $teamId,
]);
throw $e;
}
}
}
+9
View File
@@ -63,6 +63,15 @@ class ValidateServer
}
}
if ($server->hostinger_virtual_machine_id) {
$status = $server->refreshHostingerState();
if (in_array($status, ['stopping', 'stopped', 'suspending', 'suspended', 'destroying', 'destroyed', 'error'], true)) {
$this->error = 'Hostinger VPS is '.($status ?? 'not running').'. Power it on before validating.';
$server->update(['validation_logs' => $this->error]);
throw new \Exception($this->error);
}
}
['uptime' => $this->uptime, 'error' => $error] = $server->validateConnection();
if (! $this->uptime) {
$sanitizedError = htmlspecialchars($error ?? '', ENT_QUOTES, 'UTF-8');
+9
View File
@@ -8,6 +8,7 @@ use App\Models\Service;
use App\Models\ServiceApplication;
use App\Models\ServiceDatabase;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
use Lorisleiva\Actions\Concerns\AsAction;
use Lorisleiva\Actions\Decorators\JobDecorator;
@@ -66,6 +67,14 @@ class StartService
}
}
$commands = array_merge($commands, $this->logDrainNetworkConnectCommands($service));
if ($service->is_maintenance_enabled) {
// Domains can change with the compose file, so the maintenance routes follow them.
try {
$service->syncMaintenancePage();
} catch (\Throwable $e) {
Log::warning('Maintenance page update failed while starting service.', ['service_uuid' => $service->uuid, 'error' => $e->getMessage()]);
}
}
return remote_process($commands, $service->server, type_uuid: $service->uuid, callEventOnFinish: 'ServiceStartFinished', callEventData: $service->id, queue: deployment_queue());
}
+7 -6
View File
@@ -17,7 +17,8 @@ class OpenApi extends Command
// Generate OpenAPI documentation
echo "Generating OpenAPI documentation.\n";
// https://github.com/OAI/OpenAPI-Specification/releases
$process = Process::run([
// swagger-php 6 leaves PHP warnings to PHP itself; with Xdebug each one carries a stack trace and exhausts memory.
$process = Process::env(['XDEBUG_MODE' => 'off'])->run([
'./vendor/bin/openapi',
'app',
// Server transfer endpoints are development-only (isDev()), so they stay out of the public docs.
@@ -28,11 +29,11 @@ class OpenApi extends Command
'--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();
foreach ([$process->errorOutput(), $process->output()] as $output) {
$output = preg_replace('/^.*an object literal,.*$/m', '', $output);
$output = preg_replace('/^\h*\v+/m', '', $output);
echo $output;
}
$yaml = file_get_contents('openapi.yaml');
+9
View File
@@ -0,0 +1,9 @@
<?php
namespace App\Enums;
enum HttpBasicAuthHashAlgorithm: string
{
case BCRYPT = 'bcrypt';
case ARGON2ID = 'argon2id';
}
@@ -6,6 +6,7 @@ use App\Actions\Application\CleanupPreviewDeployment;
use App\Actions\Application\LoadComposeFile;
use App\Actions\Application\StopApplication;
use App\Enums\BuildPackTypes;
use App\Enums\HttpBasicAuthHashAlgorithm;
use App\Http\Controllers\Controller;
use App\Jobs\DeleteResourceJob;
use App\Models\Application;
@@ -445,6 +446,10 @@ class ApplicationsController extends Controller
'is_http_basic_auth_enabled' => ['type' => 'boolean', 'description' => 'HTTP Basic Authentication enabled.'],
'http_basic_auth_username' => ['type' => 'string', 'nullable' => true, 'description' => 'Username for HTTP Basic Authentication'],
'http_basic_auth_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password for HTTP Basic Authentication'],
'http_basic_auth_hash_algorithm' => ['type' => 'string', 'enum' => ['bcrypt', 'argon2id'], 'default' => 'bcrypt', 'description' => 'Hash algorithm for the HTTP Basic Authentication password. Argon2id needs the Caddy proxy, version 2.11 or newer.'],
'http_basic_auth_bcrypt_cost' => ['type' => 'integer', 'minimum' => 4, 'maximum' => 14, 'default' => 10, 'description' => 'Bcrypt cost for the HTTP Basic Authentication password.'],
'http_basic_auth_argon2id_memory_cost' => ['type' => 'integer', 'minimum' => 8192, 'maximum' => 262144, 'default' => 65536, 'description' => 'Argon2id memory cost in KiB for the HTTP Basic Authentication password.'],
'http_basic_auth_argon2id_time_cost' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 12, 'default' => 4, 'description' => 'Argon2id time cost (iterations) for the HTTP Basic Authentication password.'],
'connect_to_docker_network' => ['type' => 'boolean', 'description' => 'The flag to connect the service to the predefined Docker network.'],
'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'],
'autogenerate_domain' => ['type' => 'boolean', 'default' => true, 'description' => 'If true and domains is empty, auto-generate a domain using the server\'s wildcard domain or sslip.io fallback. Ignored for the dockercompose build pack, which uses docker_compose_domains. Default: true.'],
@@ -640,6 +645,10 @@ class ApplicationsController extends Controller
'is_http_basic_auth_enabled' => ['type' => 'boolean', 'description' => 'HTTP Basic Authentication enabled.'],
'http_basic_auth_username' => ['type' => 'string', 'nullable' => true, 'description' => 'Username for HTTP Basic Authentication'],
'http_basic_auth_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password for HTTP Basic Authentication'],
'http_basic_auth_hash_algorithm' => ['type' => 'string', 'enum' => ['bcrypt', 'argon2id'], 'default' => 'bcrypt', 'description' => 'Hash algorithm for the HTTP Basic Authentication password. Argon2id needs the Caddy proxy, version 2.11 or newer.'],
'http_basic_auth_bcrypt_cost' => ['type' => 'integer', 'minimum' => 4, 'maximum' => 14, 'default' => 10, 'description' => 'Bcrypt cost for the HTTP Basic Authentication password.'],
'http_basic_auth_argon2id_memory_cost' => ['type' => 'integer', 'minimum' => 8192, 'maximum' => 262144, 'default' => 65536, 'description' => 'Argon2id memory cost in KiB for the HTTP Basic Authentication password.'],
'http_basic_auth_argon2id_time_cost' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 12, 'default' => 4, 'description' => 'Argon2id time cost (iterations) for the HTTP Basic Authentication password.'],
'connect_to_docker_network' => ['type' => 'boolean', 'description' => 'The flag to connect the service to the predefined Docker network.'],
'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'],
'autogenerate_domain' => ['type' => 'boolean', 'default' => true, 'description' => 'If true and domains is empty, auto-generate a domain using the server\'s wildcard domain or sslip.io fallback. Ignored for the dockercompose build pack, which uses docker_compose_domains. Default: true.'],
@@ -835,6 +844,10 @@ class ApplicationsController extends Controller
'is_http_basic_auth_enabled' => ['type' => 'boolean', 'description' => 'HTTP Basic Authentication enabled.'],
'http_basic_auth_username' => ['type' => 'string', 'nullable' => true, 'description' => 'Username for HTTP Basic Authentication'],
'http_basic_auth_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password for HTTP Basic Authentication'],
'http_basic_auth_hash_algorithm' => ['type' => 'string', 'enum' => ['bcrypt', 'argon2id'], 'default' => 'bcrypt', 'description' => 'Hash algorithm for the HTTP Basic Authentication password. Argon2id needs the Caddy proxy, version 2.11 or newer.'],
'http_basic_auth_bcrypt_cost' => ['type' => 'integer', 'minimum' => 4, 'maximum' => 14, 'default' => 10, 'description' => 'Bcrypt cost for the HTTP Basic Authentication password.'],
'http_basic_auth_argon2id_memory_cost' => ['type' => 'integer', 'minimum' => 8192, 'maximum' => 262144, 'default' => 65536, 'description' => 'Argon2id memory cost in KiB for the HTTP Basic Authentication password.'],
'http_basic_auth_argon2id_time_cost' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 12, 'default' => 4, 'description' => 'Argon2id time cost (iterations) for the HTTP Basic Authentication password.'],
'connect_to_docker_network' => ['type' => 'boolean', 'description' => 'The flag to connect the service to the predefined Docker network.'],
'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'],
'autogenerate_domain' => ['type' => 'boolean', 'default' => true, 'description' => 'If true and domains is empty, auto-generate a domain using the server\'s wildcard domain or sslip.io fallback. Ignored for the dockercompose build pack, which uses docker_compose_domains. Default: true.'],
@@ -1001,6 +1014,10 @@ class ApplicationsController extends Controller
'is_http_basic_auth_enabled' => ['type' => 'boolean', 'description' => 'HTTP Basic Authentication enabled.'],
'http_basic_auth_username' => ['type' => 'string', 'nullable' => true, 'description' => 'Username for HTTP Basic Authentication'],
'http_basic_auth_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password for HTTP Basic Authentication'],
'http_basic_auth_hash_algorithm' => ['type' => 'string', 'enum' => ['bcrypt', 'argon2id'], 'default' => 'bcrypt', 'description' => 'Hash algorithm for the HTTP Basic Authentication password. Argon2id needs the Caddy proxy, version 2.11 or newer.'],
'http_basic_auth_bcrypt_cost' => ['type' => 'integer', 'minimum' => 4, 'maximum' => 14, 'default' => 10, 'description' => 'Bcrypt cost for the HTTP Basic Authentication password.'],
'http_basic_auth_argon2id_memory_cost' => ['type' => 'integer', 'minimum' => 8192, 'maximum' => 262144, 'default' => 65536, 'description' => 'Argon2id memory cost in KiB for the HTTP Basic Authentication password.'],
'http_basic_auth_argon2id_time_cost' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 12, 'default' => 4, 'description' => 'Argon2id time cost (iterations) for the HTTP Basic Authentication password.'],
'connect_to_docker_network' => ['type' => 'boolean', 'description' => 'The flag to connect the service to the predefined Docker network.'],
'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'],
'autogenerate_domain' => ['type' => 'boolean', 'default' => true, 'description' => 'If true and domains is empty, auto-generate a domain using the server\'s wildcard domain or sslip.io fallback. Ignored for the dockercompose build pack, which uses docker_compose_domains. Default: true.'],
@@ -1163,6 +1180,10 @@ class ApplicationsController extends Controller
'is_http_basic_auth_enabled' => ['type' => 'boolean', 'description' => 'HTTP Basic Authentication enabled.'],
'http_basic_auth_username' => ['type' => 'string', 'nullable' => true, 'description' => 'Username for HTTP Basic Authentication'],
'http_basic_auth_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password for HTTP Basic Authentication'],
'http_basic_auth_hash_algorithm' => ['type' => 'string', 'enum' => ['bcrypt', 'argon2id'], 'default' => 'bcrypt', 'description' => 'Hash algorithm for the HTTP Basic Authentication password. Argon2id needs the Caddy proxy, version 2.11 or newer.'],
'http_basic_auth_bcrypt_cost' => ['type' => 'integer', 'minimum' => 4, 'maximum' => 14, 'default' => 10, 'description' => 'Bcrypt cost for the HTTP Basic Authentication password.'],
'http_basic_auth_argon2id_memory_cost' => ['type' => 'integer', 'minimum' => 8192, 'maximum' => 262144, 'default' => 65536, 'description' => 'Argon2id memory cost in KiB for the HTTP Basic Authentication password.'],
'http_basic_auth_argon2id_time_cost' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 12, 'default' => 4, 'description' => 'Argon2id time cost (iterations) for the HTTP Basic Authentication password.'],
'connect_to_docker_network' => ['type' => 'boolean', 'description' => 'The flag to connect the service to the predefined Docker network.'],
'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'],
'autogenerate_domain' => ['type' => 'boolean', 'default' => true, 'description' => 'If true and domains is empty, auto-generate a domain using the server\'s wildcard domain or sslip.io fallback. Ignored for the dockercompose build pack, which uses docker_compose_domains. Default: true.'],
@@ -1248,7 +1269,7 @@ class ApplicationsController extends Controller
if ($return instanceof JsonResponse) {
return $return;
}
$allowedFields = ['project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'type', 'name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'is_preview_deployments_enabled', 'domains', 'noindex_domains', 'git_repository', 'git_branch', 'git_commit_sha', 'private_key_uuid', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'custom_network_aliases', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_type', 'health_check_command', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'redirect', 'github_app_uuid', 'instant_deploy', 'dockerfile', 'dockerfile_location', 'docker_compose_location', 'docker_compose_raw', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'watch_paths', 'use_build_server', 'use_build_secrets', 'static_image', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'connect_to_docker_network', 'force_domain_override', 'autogenerate_domain', 'is_container_label_escape_enabled', 'tags', 'is_preserve_repository_enabled', 'preview_url_template', 'max_restart_count', ...self::APPLICATION_SETTING_FIELDS];
$allowedFields = ['project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'type', 'name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'is_preview_deployments_enabled', 'domains', 'noindex_domains', 'git_repository', 'git_branch', 'git_commit_sha', 'private_key_uuid', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'custom_network_aliases', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_type', 'health_check_command', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'redirect', 'github_app_uuid', 'instant_deploy', 'dockerfile', 'dockerfile_location', 'docker_compose_location', 'docker_compose_raw', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'watch_paths', 'use_build_server', 'use_build_secrets', 'static_image', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'http_basic_auth_hash_algorithm', 'http_basic_auth_bcrypt_cost', 'http_basic_auth_argon2id_memory_cost', 'http_basic_auth_argon2id_time_cost', 'connect_to_docker_network', 'force_domain_override', 'autogenerate_domain', 'is_container_label_escape_enabled', 'tags', 'is_preserve_repository_enabled', 'preview_url_template', 'max_restart_count', ...self::APPLICATION_SETTING_FIELDS];
$validator = customApiValidator($request->all(), [
'name' => 'string|max:255',
@@ -1260,7 +1281,9 @@ class ApplicationsController extends Controller
'destination_uuid' => 'string',
'is_http_basic_auth_enabled' => 'boolean',
'http_basic_auth_username' => 'string|nullable',
'http_basic_auth_password' => 'string|nullable',
// bcrypt ignores everything after 72 bytes. Labels for Traefik always use bcrypt and are also generated on
// Caddy servers when "Labels for all supported proxies" is selected, so that the proxy can be switched easily.
'http_basic_auth_password' => 'string|nullable|max:72',
'autogenerate_domain' => 'boolean',
'tags' => 'array|nullable',
'tags.*' => 'string|min:2',
@@ -3130,6 +3153,10 @@ class ApplicationsController extends Controller
'watch_paths' => ['type' => 'string', 'description' => 'The watch paths.'],
'use_build_server' => ['type' => 'boolean', 'nullable' => true, 'description' => 'Use build server.'],
'use_build_secrets' => ['type' => 'boolean', 'description' => 'Use Docker Build Secrets for build-time environment variables.'],
'http_basic_auth_hash_algorithm' => ['type' => 'string', 'enum' => ['bcrypt', 'argon2id'], 'description' => 'Hash algorithm for the HTTP Basic Authentication password. Argon2id needs the Caddy proxy, version 2.11 or newer.'],
'http_basic_auth_bcrypt_cost' => ['type' => 'integer', 'minimum' => 4, 'maximum' => 14, 'description' => 'Bcrypt cost for the HTTP Basic Authentication password.'],
'http_basic_auth_argon2id_memory_cost' => ['type' => 'integer', 'minimum' => 8192, 'maximum' => 262144, 'description' => 'Argon2id memory cost in KiB for the HTTP Basic Authentication password.'],
'http_basic_auth_argon2id_time_cost' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 12, 'description' => 'Argon2id time cost (iterations) for the HTTP Basic Authentication password.'],
'is_git_submodules_enabled' => ['type' => 'boolean', 'description' => 'Clone Git submodules.'],
'is_git_lfs_enabled' => ['type' => 'boolean', 'description' => 'Enable Git LFS.'],
'is_git_shallow_clone_enabled' => ['type' => 'boolean', 'description' => 'Use a shallow Git clone.'],
@@ -3250,7 +3277,7 @@ class ApplicationsController extends Controller
$dnsCleanup = app(ManagedDnsRecordCleanup::class);
$previousDnsHostnames = $dnsCleanup->hostnamesOf($application);
$server = $application->destination->server;
$allowedFields = ['name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'is_preview_deployments_enabled', 'domains', 'noindex_domains', 'git_repository', 'git_branch', 'git_commit_sha', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'static_image', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'custom_network_aliases', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_type', 'health_check_command', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'watch_paths', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'dockerfile_location', 'dockerfile_target_build', 'docker_compose_location', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'redirect', 'instant_deploy', 'use_build_server', 'use_build_secrets', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'connect_to_docker_network', 'force_domain_override', 'is_container_label_escape_enabled', 'is_preserve_repository_enabled', 'preview_url_template', 'max_restart_count', ...self::APPLICATION_SETTING_FIELDS];
$allowedFields = ['name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'is_preview_deployments_enabled', 'domains', 'noindex_domains', 'git_repository', 'git_branch', 'git_commit_sha', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'static_image', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'custom_network_aliases', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_type', 'health_check_command', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'watch_paths', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'dockerfile_location', 'dockerfile_target_build', 'docker_compose_location', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'redirect', 'instant_deploy', 'use_build_server', 'use_build_secrets', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'http_basic_auth_hash_algorithm', 'http_basic_auth_bcrypt_cost', 'http_basic_auth_argon2id_memory_cost', 'http_basic_auth_argon2id_time_cost', 'connect_to_docker_network', 'force_domain_override', 'is_container_label_escape_enabled', 'is_preserve_repository_enabled', 'preview_url_template', 'max_restart_count', ...self::APPLICATION_SETTING_FIELDS];
$validationRules = [
'name' => 'string|max:255',
@@ -3265,7 +3292,9 @@ class ApplicationsController extends Controller
'is_http_basic_auth_enabled' => 'boolean|nullable',
'is_preview_deployments_enabled' => 'boolean|nullable',
'http_basic_auth_username' => 'string',
'http_basic_auth_password' => 'string',
// bcrypt ignores everything after 72 bytes. Labels for Traefik always use bcrypt and are also generated on
// Caddy servers when "Labels for all supported proxies" is selected, so that the proxy can be switched easily.
'http_basic_auth_password' => 'string|max:72',
'include_source_commit_in_build' => 'boolean',
'ports_exposes' => 'nullable|string|regex:/^(\d+)(,\d+)*$/',
];
@@ -3347,9 +3376,15 @@ class ApplicationsController extends Controller
], 422);
}
$requestHasHttpBasicAuth = $request->has('is_http_basic_auth_enabled')
|| $request->has('http_basic_auth_username')
|| $request->has('http_basic_auth_password');
$requestHasHttpBasicAuth = $request->hasAny([
'is_http_basic_auth_enabled',
'http_basic_auth_username',
'http_basic_auth_password',
'http_basic_auth_hash_algorithm',
'http_basic_auth_bcrypt_cost',
'http_basic_auth_argon2id_memory_cost',
'http_basic_auth_argon2id_time_cost',
]);
if ($request->has('is_http_basic_auth_enabled') && $request->is_http_basic_auth_enabled === true) {
if (blank($application->http_basic_auth_username) || blank($application->http_basic_auth_password)) {
@@ -4894,6 +4929,15 @@ class ApplicationsController extends Controller
{
$teamId = getTeamIdFromToken();
if ($request->input('http_basic_auth_hash_algorithm') === HttpBasicAuthHashAlgorithm::ARGON2ID->value && ! $server->caddySupportsArgon2idBasicAuth()) {
return response()->json([
'message' => 'Validation failed.',
'errors' => [
'http_basic_auth_hash_algorithm' => 'Argon2id needs a server that runs the Caddy proxy, version 2.11 or newer.',
],
], 422);
}
// Validate ports_mappings
if ($request->has('ports_mappings')) {
$ports = [];
@@ -45,6 +45,10 @@ class CloudProviderTokensController extends Controller
'vultr' => Http::withHeaders([
'Authorization' => 'Bearer '.$token,
])->timeout(10)->get('https://api.vultr.com/v2/account'),
'hostinger' => Http::withToken($token)
->acceptJson()
->timeout(10)
->get('https://developers.hostinger.com/api/vps/v1/virtual-machines'),
default => null,
};
@@ -90,7 +94,7 @@ class CloudProviderTokensController extends Controller
properties: [
'uuid' => ['type' => 'string'],
'name' => ['type' => 'string'],
'provider' => ['type' => 'string', 'enum' => ['hetzner', 'digitalocean', 'vultr']],
'provider' => ['type' => 'string', 'enum' => ['hetzner', 'digitalocean', 'vultr', 'hostinger']],
'team_id' => ['type' => 'integer'],
'servers_count' => ['type' => 'integer'],
'created_at' => ['type' => 'string'],
@@ -208,7 +212,7 @@ class CloudProviderTokensController extends Controller
type: 'object',
required: ['provider', 'token', 'name'],
properties: [
'provider' => ['type' => 'string', 'enum' => ['hetzner', 'digitalocean', 'vultr'], 'example' => 'hetzner', 'description' => 'The cloud provider.'],
'provider' => ['type' => 'string', 'enum' => ['hetzner', 'digitalocean', 'vultr', 'hostinger'], 'example' => 'hetzner', 'description' => 'The cloud provider.'],
'token' => ['type' => 'string', 'example' => 'your-api-token-here', 'description' => 'The API token for the cloud provider.'],
'name' => ['type' => 'string', 'example' => 'My Hetzner Token', 'description' => 'A friendly name for the token.'],
],
@@ -263,7 +267,7 @@ class CloudProviderTokensController extends Controller
$body = $request->json()->all();
$validator = customApiValidator($body, [
'provider' => 'required|string|in:hetzner,digitalocean,vultr',
'provider' => 'required|string|in:hetzner,digitalocean,vultr,hostinger',
'token' => 'required|string',
'name' => 'required|string|max:255',
]);
@@ -0,0 +1,365 @@
<?php
namespace App\Http\Controllers\Api;
use App\Actions\Server\ValidateServer;
use App\Enums\ProxyTypes;
use App\Exceptions\RateLimitException;
use App\Http\Controllers\Controller;
use App\Models\CloudProviderToken;
use App\Models\PrivateKey;
use App\Models\Server;
use App\Models\Team;
use App\Rules\ValidHostname;
use App\Services\HostingerService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use OpenApi\Attributes as OA;
class HostingerController extends Controller
{
#[OA\Get(
path: '/hostinger/data-centers',
operationId: 'get-hostinger-data-centers',
summary: 'Get Hostinger VPS data centers',
security: [['bearerAuth' => []]],
tags: ['Hostinger'],
responses: [
new OA\Response(response: 200, description: 'List of Hostinger VPS data centers.'),
new OA\Response(response: 401, ref: '#/components/responses/401'),
new OA\Response(response: 403, description: 'Hostinger denied access to this resource for the cloud provider token.'),
new OA\Response(response: 422, description: 'Validation failed.'),
new OA\Response(response: 429, description: 'Hostinger rate limit exceeded.'),
]
)]
public function dataCenters(Request $request): JsonResponse
{
return $this->providerData($request, 'getDataCenters', 'data centers');
}
#[OA\Get(
path: '/hostinger/catalog',
operationId: 'get-hostinger-catalog',
summary: 'Get Hostinger VPS plans and prices',
security: [['bearerAuth' => []]],
tags: ['Hostinger'],
responses: [
new OA\Response(response: 200, description: 'List of Hostinger VPS plans and prices.'),
new OA\Response(response: 401, ref: '#/components/responses/401'),
new OA\Response(response: 403, description: 'Hostinger denied access to this resource for the cloud provider token.'),
new OA\Response(response: 422, description: 'Validation failed.'),
new OA\Response(response: 429, description: 'Hostinger rate limit exceeded.'),
]
)]
public function catalog(Request $request): JsonResponse
{
return $this->providerData($request, 'getCatalogItems', 'catalog');
}
#[OA\Get(
path: '/hostinger/templates',
operationId: 'get-hostinger-templates',
summary: 'Get Hostinger VPS operating system templates',
security: [['bearerAuth' => []]],
tags: ['Hostinger'],
responses: [
new OA\Response(response: 200, description: 'List of Hostinger VPS templates.'),
new OA\Response(response: 401, ref: '#/components/responses/401'),
new OA\Response(response: 403, description: 'Hostinger denied access to this resource for the cloud provider token.'),
new OA\Response(response: 422, description: 'Validation failed.'),
new OA\Response(response: 429, description: 'Hostinger rate limit exceeded.'),
]
)]
public function templates(Request $request): JsonResponse
{
return $this->providerData($request, 'getTemplates', 'templates');
}
#[OA\Get(
path: '/hostinger/ssh-keys',
operationId: 'get-hostinger-ssh-keys',
summary: 'Get Hostinger account SSH keys',
security: [['bearerAuth' => []]],
tags: ['Hostinger'],
responses: [
new OA\Response(response: 200, description: 'List of Hostinger account SSH keys.'),
new OA\Response(response: 401, ref: '#/components/responses/401'),
new OA\Response(response: 403, description: 'Hostinger denied access to this resource for the cloud provider token.'),
new OA\Response(response: 422, description: 'Validation failed.'),
new OA\Response(response: 429, description: 'Hostinger rate limit exceeded.'),
]
)]
public function sshKeys(Request $request): JsonResponse
{
return $this->providerData($request, 'getPublicKeys', 'SSH keys');
}
#[OA\Get(
path: '/hostinger/post-install-scripts',
operationId: 'get-hostinger-post-install-scripts',
summary: 'Get Hostinger post-install scripts',
security: [['bearerAuth' => []]],
tags: ['Hostinger'],
responses: [
new OA\Response(response: 200, description: 'List of Hostinger post-install scripts.'),
new OA\Response(response: 401, ref: '#/components/responses/401'),
new OA\Response(response: 403, description: 'Hostinger denied access to this resource for the cloud provider token.'),
new OA\Response(response: 422, description: 'Validation failed.'),
new OA\Response(response: 429, description: 'Hostinger rate limit exceeded.'),
]
)]
public function postInstallScripts(Request $request): JsonResponse
{
return $this->providerData($request, 'getPostInstallScripts', 'post-install scripts');
}
#[OA\Post(
path: '/servers/hostinger',
operationId: 'create-hostinger-server',
summary: 'Purchase a Hostinger VPS and create a linked server',
security: [['bearerAuth' => []]],
tags: ['Hostinger'],
responses: [
new OA\Response(response: 201, description: 'Hostinger VPS purchased and linked to a Coolify server.'),
new OA\Response(response: 401, ref: '#/components/responses/401'),
new OA\Response(response: 422, description: 'Validation failed.'),
new OA\Response(response: 429, description: 'Hostinger rate limit exceeded.'),
]
)]
public function createServer(Request $request): JsonResponse
{
$allowedFields = [
'cloud_provider_token_uuid',
'cloud_provider_token_id',
'item_id',
'data_center_id',
'template_id',
'name',
'private_key_uuid',
'enable_backups',
'public_key_ids',
'post_install_script_id',
'instant_validate',
];
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$this->authorize('create', [Server::class]);
$return = validateIncomingRequest($request);
if ($return instanceof JsonResponse) {
return $return;
}
$validator = customApiValidator($request->all(), [
'cloud_provider_token_uuid' => 'required_without:cloud_provider_token_id|string',
'cloud_provider_token_id' => 'required_without:cloud_provider_token_uuid|string',
'item_id' => 'required|string',
'data_center_id' => 'required|integer',
'template_id' => 'required|integer',
'name' => ['nullable', 'string', 'max:253', new ValidHostname],
'private_key_uuid' => 'required|string',
'enable_backups' => 'nullable|boolean',
'public_key_ids' => 'nullable|array',
'public_key_ids.*' => 'integer',
'post_install_script_id' => 'nullable|integer',
'instant_validate' => 'nullable|boolean',
]);
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
if ($validator->fails() || ! empty($extraFields)) {
$errors = $validator->errors();
foreach ($extraFields as $field) {
$errors->add($field, 'This field is not allowed.');
}
return response()->json([
'message' => 'Validation failed.',
'errors' => $errors,
], 422);
}
$team = Team::find($teamId);
if (Team::serverLimitReached($team)) {
return response()->json(['message' => 'Server limit reached for your subscription.'], 400);
}
$token = $this->hostingerToken($request, $teamId);
if ($token instanceof JsonResponse) {
return $token;
}
$privateKey = PrivateKey::whereTeamId($teamId)->whereUuid($request->private_key_uuid)->first();
if (! $privateKey) {
return response()->json(['message' => 'Private key not found.'], 404);
}
$virtualMachineId = null;
try {
$hostingerService = new HostingerService($token->token);
$normalizedServerName = strtolower(trim($request->name ?: generate_random_name()));
$setup = [
'data_center_id' => $request->integer('data_center_id'),
'template_id' => $request->integer('template_id'),
'hostname' => $normalizedServerName,
'enable_backups' => $request->boolean('enable_backups'),
'public_key' => [
'name' => $privateKey->name,
'key' => $privateKey->getPublicKey(),
],
];
if ($request->filled('post_install_script_id')) {
$setup['post_install_script_id'] = $request->integer('post_install_script_id');
}
$virtualMachine = $hostingerService->purchaseVirtualMachine([
'item_id' => $request->item_id,
'setup' => $setup,
]);
$virtualMachineId = (int) $virtualMachine['id'];
$server = Server::create([
'name' => $normalizedServerName,
'ip' => Server::PLACEHOLDER_IP,
'user' => 'root',
'port' => 22,
'team_id' => $teamId,
'private_key_id' => $privateKey->id,
'cloud_provider_token_id' => $token->id,
'hostinger_virtual_machine_id' => $virtualMachine['id'],
'hostinger_virtual_machine_status' => $virtualMachine['state'] ?? null,
]);
$server->proxy->set('status', 'exited');
$server->proxy->set('type', ProxyTypes::TRAEFIK->value);
$server->save();
try {
if ($request->array('public_key_ids')) {
$hostingerService->attachPublicKeys((int) $virtualMachine['id'], $request->array('public_key_ids'));
}
$virtualMachine = $hostingerService->waitForPublicIp($virtualMachine);
$ipAddress = $hostingerService->getPublicIpAddress($virtualMachine);
if ($ipAddress) {
$server->update([
'ip' => $ipAddress,
'hostinger_virtual_machine_status' => $virtualMachine['state'] ?? $server->hostinger_virtual_machine_status,
]);
}
} catch (\Throwable $e) {
report($e);
}
$ipAddress = $server->fresh()->ip;
if ($request->boolean('instant_validate') && ! $server->hasPlaceholderIp()) {
ValidateServer::dispatch($server);
}
auditLog('api.hostinger_virtual_machine.created', [
'team_id' => $teamId,
'server_uuid' => $server->uuid,
'server_name' => $server->name,
'hostinger_virtual_machine_id' => $virtualMachine['id'],
'ip' => $ipAddress,
]);
return response()->json([
'uuid' => $server->uuid,
'hostinger_virtual_machine_id' => $virtualMachine['id'],
'ip' => $ipAddress,
'provisioning' => $ipAddress === Server::PLACEHOLDER_IP,
])->setStatusCode(201);
} catch (RateLimitException $e) {
return $this->rateLimitResponse($e);
} catch (\Throwable $e) {
logger()->error('Failed to create Hostinger server', [
'error' => $e->getMessage(),
'team_id' => $teamId,
'hostinger_virtual_machine_id' => $virtualMachineId,
]);
if ($e->getCode() === 202) {
return response()->json(['message' => $e->getMessage()], 202);
}
return response()->json(array_filter([
'message' => $virtualMachineId
? 'The Hostinger VPS was purchased but could not be saved in Coolify. Manage it in hPanel.'
: 'Failed to create Hostinger server.',
'hostinger_virtual_machine_id' => $virtualMachineId,
]), 500);
}
}
private function providerData(Request $request, string $method, string $resource): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$token = $this->hostingerToken($request, $teamId);
if ($token instanceof JsonResponse) {
return $token;
}
try {
return response()->json((new HostingerService($token->token))->{$method}());
} catch (RateLimitException $e) {
return $this->rateLimitResponse($e);
} catch (\Throwable $e) {
$hostingerErrorPrefix = 'Hostinger API error: ';
if (in_array($e->getCode(), [401, 403], true) && str_starts_with($e->getMessage(), $hostingerErrorPrefix)) {
$hostingerMessage = substr($e->getMessage(), strlen($hostingerErrorPrefix));
return response()->json(['message' => "Hostinger denied access to {$resource}: {$hostingerMessage}"], 403);
}
return response()->json(['message' => "Failed to fetch Hostinger {$resource}."], 500);
}
}
private function rateLimitResponse(RateLimitException $e): JsonResponse
{
$response = response()->json(['message' => $e->getMessage()], 429);
if ($e->retryAfter !== null) {
$response->header('Retry-After', $e->retryAfter);
}
return $response;
}
private function hostingerToken(Request $request, int $teamId): CloudProviderToken|JsonResponse
{
$validator = customApiValidator($request->all(), [
'cloud_provider_token_uuid' => 'required_without:cloud_provider_token_id|string',
'cloud_provider_token_id' => 'required_without:cloud_provider_token_uuid|string',
]);
if ($validator->fails()) {
return response()->json([
'message' => 'Validation failed.',
'errors' => $validator->errors(),
], 422);
}
$tokenUuid = $request->cloud_provider_token_uuid ?? $request->cloud_provider_token_id;
$token = CloudProviderToken::whereTeamId($teamId)
->whereUuid($tokenUuid)
->where('provider', 'hostinger')
->first();
if (! $token) {
return response()->json(['message' => 'Hostinger cloud provider token not found.'], 404);
}
$this->authorize('view', $token);
return $token;
}
}
+18 -1
View File
@@ -639,6 +639,15 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
\Log::warning('Post deployment command failed for '.$this->deployment_uuid.': '.$e->getMessage());
}
// Domains can change with a deployment, so the maintenance routes follow them.
if ($this->pull_request_id === 0 && $this->application->is_maintenance_enabled) {
try {
$this->application->syncMaintenancePage();
} catch (Exception $e) {
\Log::warning('Maintenance page update failed for '.$this->deployment_uuid.': '.$e->getMessage());
}
}
}
private function deploy_simple_dockerfile()
@@ -3357,12 +3366,20 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
return $variables;
}
/**
* Without the `...` entry, the list replaces the packages that Railpack generates
* (for example libatomic1, which Node.js 25+ needs at runtime).
*/
private function merge_railpack_deploy_apt_packages(Collection $variables): Collection
{
$packages = collect(preg_split('/\s+/', trim((string) $variables->get('RAILPACK_DEPLOY_APT_PACKAGES', ''))) ?: [])
->filter()
->values();
if (! $packages->contains('...')) {
$packages->prepend('...');
}
foreach (['curl', 'wget'] as $package) {
if (! $packages->contains($package)) {
$packages->push($package);
@@ -3446,7 +3463,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
." {$this->workdir}";
return 'DOCKER_CONFIG=/root/.docker docker buildx create --name coolify-railpack --driver docker-container 2>/dev/null || true'
.' && '.$this->wrap_build_command_with_env_export($buildxBuildCommand);
."\n".railpackBuilderLockedScript('('.$this->wrap_build_command_with_env_export($buildxBuildCommand).')');
}
private function decode_railpack_config(string $config, string $source): array
+13
View File
@@ -131,8 +131,21 @@ class DeleteResourceJob implements ShouldBeEncrypted, ShouldQueue
]);
}
$maintenanceServers = data_get($this->resource, 'is_maintenance_enabled') ? $this->resource->maintenanceServers() : null;
$this->deleteLocalResource();
if ($maintenanceServers) {
try {
// Removes the maintenance route of the deleted resource.
$this->resource->syncMaintenancePage($maintenanceServers);
} catch (\Throwable $e) {
Log::warning('Maintenance page cleanup failed while deleting resource.', [
'resource_id' => $this->resource->id,
'error' => $e->getMessage(),
]);
}
}
}
private function deleteLocalResource(): void
@@ -47,6 +47,9 @@ class ServerCloudProviderStatusCheckJob implements ShouldBeEncrypted, ShouldQueu
'digitalocean' => $this->server->digitalocean_droplet_id
? $this->server->refreshDigitalOceanState()
: null,
'hostinger' => $this->server->hostinger_virtual_machine_id
? $this->server->refreshHostingerState()
: null,
default => null,
};
} catch (\Throwable $e) {
+2 -1
View File
@@ -99,7 +99,8 @@ class ServerManagerJob implements ShouldBeEncrypted, ShouldQueue
$servers->each(function (Server $server) {
$hasCloudResource = $server->hetzner_server_id
|| $server->vultr_instance_id
|| $server->digitalocean_droplet_id;
|| $server->digitalocean_droplet_id
|| $server->hostinger_virtual_machine_id;
if ($hasCloudResource && $server->cloudProviderToken) {
ServerCloudProviderStatusCheckJob::dispatch($server);
@@ -33,6 +33,7 @@ class ProxyStatusChangedNotification implements ShouldQueueAfterCommit
if ($status === 'running') {
$server->setupDefaultRedirect();
$server->setupMaintenancePages();
$server->setupDynamicProxyConfiguration();
$server->proxy->force_stop = false;
$server->save();
+28 -2
View File
@@ -2,6 +2,7 @@
namespace App\Livewire\Project\Application;
use App\Enums\HttpBasicAuthHashAlgorithm;
use App\Enums\StaticImageTypes;
use App\Jobs\ApplicationDeploymentJob;
use App\Livewire\Project\Service\Storage;
@@ -118,6 +119,14 @@ class General extends Component
public ?string $httpBasicAuthPassword = null;
public string $httpBasicAuthHashAlgorithm = HttpBasicAuthHashAlgorithm::BCRYPT->value;
public int $httpBasicAuthBcryptCost = 10;
public int $httpBasicAuthArgon2idMemoryCost = 65536;
public int $httpBasicAuthArgon2idTimeCost = 4;
public ?string $watchPaths = null;
public string $redirect;
@@ -193,7 +202,13 @@ class General extends Component
'isPreserveRepositoryEnabled' => 'boolean|required',
'isHttpBasicAuthEnabled' => 'boolean|required',
'httpBasicAuthUsername' => 'string|nullable',
'httpBasicAuthPassword' => 'string|nullable',
// bcrypt ignores everything after 72 bytes. Labels for Traefik always use bcrypt and are also generated on
// Caddy servers when "Labels for all supported proxies" is selected, so that the proxy can be switched easily.
'httpBasicAuthPassword' => 'string|nullable|max:72',
'httpBasicAuthHashAlgorithm' => ['required', Rule::enum(HttpBasicAuthHashAlgorithm::class)],
'httpBasicAuthBcryptCost' => 'required|integer|min:4|max:14',
'httpBasicAuthArgon2idMemoryCost' => 'required|integer|min:8192|max:262144',
'httpBasicAuthArgon2idTimeCost' => 'required|integer|min:1|max:12',
'watchPaths' => 'nullable',
'redirect' => 'string|required',
];
@@ -367,6 +382,9 @@ class General extends Component
if ($toModel) {
$this->validate();
$this->validateChangedDockerComposeRaw();
if ($this->httpBasicAuthHashAlgorithm === HttpBasicAuthHashAlgorithm::ARGON2ID->value && ! $this->application->destination->server->caddySupportsArgon2idBasicAuth()) {
throw new Exception('Argon2id needs a server that runs the Caddy proxy, version 2.11 or newer.');
}
// Application properties
$this->application->name = $this->name;
@@ -407,6 +425,10 @@ class General extends Component
$this->application->is_http_basic_auth_enabled = $this->isHttpBasicAuthEnabled;
$this->application->http_basic_auth_username = $this->httpBasicAuthUsername;
$this->application->http_basic_auth_password = $this->httpBasicAuthPassword;
$this->application->http_basic_auth_hash_algorithm = $this->httpBasicAuthHashAlgorithm;
$this->application->http_basic_auth_bcrypt_cost = $this->httpBasicAuthBcryptCost;
$this->application->http_basic_auth_argon2id_memory_cost = $this->httpBasicAuthArgon2idMemoryCost;
$this->application->http_basic_auth_argon2id_time_cost = $this->httpBasicAuthArgon2idTimeCost;
$this->application->watch_paths = $this->watchPaths;
$this->application->redirect = $this->redirect;
@@ -460,6 +482,10 @@ class General extends Component
$this->httpBasicAuthPassword = auth()->user()->can('update', $this->application)
? $this->application->http_basic_auth_password
: null;
$this->httpBasicAuthHashAlgorithm = ($this->application->usesArgon2idBasicAuth() ? HttpBasicAuthHashAlgorithm::ARGON2ID : HttpBasicAuthHashAlgorithm::BCRYPT)->value;
$this->httpBasicAuthBcryptCost = $this->application->http_basic_auth_bcrypt_cost;
$this->httpBasicAuthArgon2idMemoryCost = $this->application->http_basic_auth_argon2id_memory_cost;
$this->httpBasicAuthArgon2idTimeCost = $this->application->http_basic_auth_argon2id_time_cost;
$this->watchPaths = $this->application->watch_paths;
$this->redirect = $this->application->redirect;
@@ -497,7 +523,7 @@ class General extends Component
if ($oldIsSpa !== $this->isSpa) {
$this->generateNginxConfiguration($this->isSpa ? 'spa' : 'static');
}
if ($oldIsHttpBasicAuthEnabled !== $this->isHttpBasicAuthEnabled) {
if ($oldIsHttpBasicAuthEnabled !== $this->isHttpBasicAuthEnabled || $this->application->isDirty(['http_basic_auth_hash_algorithm', 'http_basic_auth_bcrypt_cost', 'http_basic_auth_argon2id_memory_cost', 'http_basic_auth_argon2id_time_cost'])) {
$this->application->save();
}
+26
View File
@@ -2,6 +2,7 @@
namespace App\Livewire\Project\Shared;
use App\Actions\Database\FlushDatabase;
use App\Jobs\DeleteResourceJob;
use App\Models\Service;
use App\Models\ServiceApplication;
@@ -157,6 +158,31 @@ class Danger extends Component
}
}
public function flush($password)
{
try {
$this->authorize('manage', $this->resource);
if (! verifyPasswordConfirmation($password, $this)) {
return 'The provided password is incorrect.';
}
FlushDatabase::run($this->resource);
auditLog('ui.database.flushed', [
'team_id' => $this->resource->team()?->id,
'database_uuid' => $this->resource->uuid,
'database_name' => $this->resource->name,
]);
$this->dispatch('success', 'Database flushed.');
return true;
} catch (\Throwable $e) {
handleError($e, $this);
return false;
}
}
public function render()
{
return view('livewire.project.shared.danger', [
@@ -0,0 +1,93 @@
<?php
namespace App\Livewire\Project\Shared;
use App\Enums\ProxyTypes;
use App\Models\Application;
use App\Models\Server;
use App\Models\Service;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
/**
* Maintenance mode of an application or service: the proxy shows a maintenance page with
* status 503 instead of the resource, and the containers keep running.
*/
class Maintenance extends Component
{
use AuthorizesRequests;
public Application|Service $resource;
public bool $isMaintenanceEnabled = false;
public ?string $maintenancePage = null;
public function mount(): void
{
$this->isMaintenanceEnabled = (bool) $this->resource->is_maintenance_enabled;
$this->maintenancePage = $this->resource->maintenance_page;
}
/**
* Servers that run the resource with a supported proxy (Traefik or Caddy, not Swarm).
*/
public function getIsSupportedProperty(): bool
{
return $this->resource->maintenanceServers()->contains(fn (Server $server) => ! $server->isSwarm()
&& in_array($server->proxyType(), [ProxyTypes::TRAEFIK->value, ProxyTypes::CADDY->value], true));
}
public function instantSaveMaintenance(): void
{
try {
$this->authorize('update', $this->resource);
$this->resource->is_maintenance_enabled = $this->isMaintenanceEnabled;
$this->resource->save();
$this->resource->syncMaintenancePage();
$this->dispatch('success', $this->isMaintenanceEnabled ? 'Maintenance mode enabled.' : 'Maintenance mode disabled.');
} catch (\Throwable $e) {
$this->isMaintenanceEnabled = (bool) $this->resource->fresh()?->is_maintenance_enabled;
handleError($e, $this);
}
}
public function submit(): void
{
try {
$this->authorize('update', $this->resource);
$this->validate([
'maintenancePage' => ['nullable', 'string', proxyPageSizeRule('maintenance page')],
]);
$this->resource->maintenance_page = filled($this->maintenancePage) ? $this->maintenancePage : null;
$this->resource->save();
if ($this->resource->is_maintenance_enabled) {
$this->resource->syncMaintenancePage();
}
$this->dispatch('success', 'Maintenance page saved.');
} catch (\Throwable $e) {
handleError($e, $this);
}
}
public function resetMaintenancePage(): void
{
try {
$this->authorize('update', $this->resource);
$this->resource->maintenance_page = null;
$this->resource->save();
$this->maintenancePage = null;
if ($this->resource->is_maintenance_enabled) {
$this->resource->syncMaintenancePage();
}
$this->dispatch('success', 'Default maintenance page restored.');
} catch (\Throwable $e) {
handleError($e, $this);
}
}
public function render()
{
return view('livewire.project.shared.maintenance');
}
}
@@ -85,6 +85,7 @@ class Show extends Component
'hetzner' => $this->validateHetznerToken($this->cloudProviderToken->token),
'digitalocean' => $this->validateDigitalOceanToken($this->cloudProviderToken->token),
'vultr' => $this->validateVultrToken($this->cloudProviderToken->token),
'hostinger' => $this->validateHostingerToken($this->cloudProviderToken->token),
default => false,
};
@@ -141,6 +142,7 @@ class Show extends Component
return match ($this->cloudProviderToken->provider) {
'digitalocean' => 'DigitalOcean',
'vultr' => 'Vultr',
'hostinger' => 'Hostinger',
default => 'Hetzner',
};
}
@@ -181,6 +183,19 @@ class Show extends Component
}
}
private function validateHostingerToken(string $token): bool
{
try {
return Http::withToken($token)
->acceptJson()
->timeout(10)
->get('https://developers.hostinger.com/api/vps/v1/virtual-machines')
->successful();
} catch (\Throwable) {
return false;
}
}
public function render()
{
return view('livewire.security.cloud-provider-token.show');
@@ -5,6 +5,7 @@ namespace App\Livewire\Security;
use App\Livewire\Server\CloudProviderToken\Show as ServerCloudProviderTokenShow;
use App\Livewire\Server\New\ByDigitalOcean;
use App\Livewire\Server\New\ByHetzner;
use App\Livewire\Server\New\ByHostinger;
use App\Models\CloudProviderToken;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Http;
@@ -41,7 +42,7 @@ class CloudProviderTokenForm extends Component
protected function rules(): array
{
return [
'provider' => 'required|string|in:hetzner,digitalocean,vultr',
'provider' => 'required|string|in:hetzner,digitalocean,vultr,hostinger',
'token' => 'required|string',
'name' => 'required|string|max:255',
'description' => 'nullable|string|max:1000',
@@ -86,6 +87,15 @@ class CloudProviderTokenForm extends Component
return $response->successful();
}
if ($provider === 'hostinger') {
$response = Http::withToken($token)
->acceptJson()
->timeout(10)
->get('https://developers.hostinger.com/api/vps/v1/virtual-machines');
return $response->successful();
}
return false;
} catch (\Throwable $e) {
return false;
@@ -128,6 +138,7 @@ class CloudProviderTokenForm extends Component
if ($savedToken->provider === 'digitalocean') {
$this->dispatch('tokenAdded.digitalocean', tokenId: $savedToken->id)->to(ByDigitalOcean::class);
$this->dispatch('tokenAdded.digitalocean', tokenId: $savedToken->id)->to(ServerCloudProviderTokenShow::class);
}
if ($savedToken->provider === 'hetzner') {
@@ -135,6 +146,11 @@ class CloudProviderTokenForm extends Component
$this->dispatch('tokenAdded.hetzner', tokenId: $savedToken->id)->to(ServerCloudProviderTokenShow::class);
}
if ($savedToken->provider === 'hostinger') {
$this->dispatch('tokenAdded.hostinger', tokenId: $savedToken->id)->to(ByHostinger::class);
$this->dispatch('tokenAdded.hostinger', tokenId: $savedToken->id)->to(ServerCloudProviderTokenShow::class);
}
if ($this->modal_mode) {
$this->dispatch('close-modal');
}
@@ -59,6 +59,13 @@ class CloudProviderTokens extends Component
} else {
$this->dispatch('error', 'Vultr token validation failed. Please check the token.');
}
} elseif ($token->provider === 'hostinger') {
$isValid = $this->validateHostingerToken($token->token);
if ($isValid) {
$this->dispatch('success', 'Hostinger token is valid.');
} else {
$this->dispatch('error', 'Hostinger token validation failed. Please check the token.');
}
} else {
$this->dispatch('error', 'Unknown provider.');
}
@@ -114,6 +121,19 @@ class CloudProviderTokens extends Component
}
}
private function validateHostingerToken(string $token): bool
{
try {
return Http::withToken($token)
->acceptJson()
->timeout(10)
->get('https://developers.hostinger.com/api/vps/v1/virtual-machines')
->successful();
} catch (\Throwable) {
return false;
}
}
public function deleteToken(int $tokenId)
{
try {
@@ -36,13 +36,25 @@ class Show extends Component
{
return [
'tokenAdded.hetzner' => 'handleTokenAdded',
'tokenAdded.digitalocean' => 'handleTokenAdded',
'tokenAdded.hostinger' => 'handleTokenAdded',
];
}
public function loadTokens()
{
$this->provider = $this->server->vultr_instance_id ? 'vultr' : 'hetzner';
$this->providerName = $this->provider === 'vultr' ? 'Vultr' : 'Hetzner';
$this->provider = match (true) {
filled($this->server->vultr_instance_id) => 'vultr',
filled($this->server->digitalocean_droplet_id) => 'digitalocean',
filled($this->server->hostinger_virtual_machine_id) => 'hostinger',
default => 'hetzner',
};
$this->providerName = match ($this->provider) {
'vultr' => 'Vultr',
'digitalocean' => 'DigitalOcean',
'hostinger' => 'Hostinger',
default => 'Hetzner',
};
$this->cloudProviderTokens = CloudProviderToken::where('team_id', $this->server->team_id)
->where('provider', $this->provider)
@@ -96,9 +108,7 @@ class Show extends Component
private function validateTokenForServer(CloudProviderToken $token): array
{
try {
$endpoint = $token->provider === 'vultr'
? 'https://api.vultr.com/v2/account'
: 'https://api.hetzner.cloud/v1/servers';
$endpoint = $this->validationEndpoint($token->provider);
$response = Http::withHeaders([
'Authorization' => 'Bearer '.$token->token,
@@ -137,6 +147,33 @@ class Show extends Component
}
}
if ($this->server->digitalocean_droplet_id) {
$serverResponse = Http::withToken($token->token)
->timeout(10)
->get("https://api.digitalocean.com/v2/droplets/{$this->server->digitalocean_droplet_id}");
if (! $serverResponse->successful()) {
return [
'valid' => false,
'error' => 'This token cannot access this droplet. It may belong to a different DigitalOcean account.',
];
}
}
if ($this->server->hostinger_virtual_machine_id) {
$serverResponse = Http::withToken($token->token)
->acceptJson()
->timeout(10)
->get("https://developers.hostinger.com/api/vps/v1/virtual-machines/{$this->server->hostinger_virtual_machine_id}");
if (! $serverResponse->successful()) {
return [
'valid' => false,
'error' => 'This token cannot access this VPS. It may belong to a different Hostinger account.',
];
}
}
return ['valid' => true];
} catch (\Throwable $e) {
return [
@@ -156,9 +193,7 @@ class Show extends Component
return;
}
$endpoint = $token->provider === 'vultr'
? 'https://api.vultr.com/v2/account'
: 'https://api.hetzner.cloud/v1/servers';
$endpoint = $this->validationEndpoint($token->provider);
$response = Http::withHeaders([
'Authorization' => 'Bearer '.$token->token,
@@ -184,6 +219,16 @@ class Show extends Component
}
}
private function validationEndpoint(string $provider): string
{
return match ($provider) {
'vultr' => 'https://api.vultr.com/v2/account',
'digitalocean' => 'https://api.digitalocean.com/v2/account',
'hostinger' => 'https://developers.hostinger.com/api/vps/v1/virtual-machines',
default => 'https://api.hetzner.cloud/v1/servers',
};
}
public function render()
{
return view('livewire.server.cloud-provider-token.show');
+1 -1
View File
@@ -21,7 +21,7 @@ class Create extends Component
public function mount(?string $selectedType = null, ?string $selectedTokenUuid = null): void
{
$this->selectedType = in_array($selectedType, ['hetzner', 'vultr', 'digital-ocean', 'manual'], true)
$this->selectedType = in_array($selectedType, ['hetzner', 'vultr', 'digital-ocean', 'hostinger', 'manual'], true)
? $selectedType
: null;
$this->selectedTokenUuid = $this->selectedType && $this->selectedType !== 'manual' ? $selectedTokenUuid : null;
+3
View File
@@ -28,12 +28,14 @@ class CreatePage extends Component
'hetzner' => 'hetzner',
'vultr' => 'vultr',
'digital-ocean' => 'digitalocean',
'hostinger' => 'hostinger',
default => null,
};
$this->tokenProviderName = match ($type) {
'hetzner' => 'Hetzner',
'vultr' => 'Vultr',
'digital-ocean' => 'DigitalOcean',
'hostinger' => 'Hostinger',
default => null,
};
$this->hasProviderTokens = $this->tokenProvider
@@ -43,6 +45,7 @@ class CreatePage extends Component
'hetzner' => 'Hetzner',
'vultr' => 'Vultr',
'digital-ocean' => 'DigitalOcean',
'hostinger' => 'Hostinger',
'manual' => 'Manual',
default => 'New Server',
};
+14 -1
View File
@@ -20,6 +20,8 @@ class Delete extends Component
public bool $delete_from_digitalocean = false;
public bool $delete_from_hostinger = false;
public bool $force_delete_resources = false;
public function mount(string $server_uuid)
@@ -41,6 +43,7 @@ class Delete extends Component
$this->delete_from_hetzner = in_array('delete_from_hetzner', $selectedActions);
$this->delete_from_vultr = in_array('delete_from_vultr', $selectedActions);
$this->delete_from_digitalocean = in_array('delete_from_digitalocean', $selectedActions);
$this->delete_from_hostinger = in_array('delete_from_hostinger', $selectedActions);
$this->force_delete_resources = in_array('force_delete_resources', $selectedActions);
}
try {
@@ -72,7 +75,9 @@ class Delete extends Component
$this->delete_from_vultr,
$this->server->vultr_instance_id,
$this->delete_from_digitalocean,
$this->server->digitalocean_droplet_id
$this->server->digitalocean_droplet_id,
$this->delete_from_hostinger,
$this->server->hostinger_virtual_machine_id
);
return redirectRoute($this, 'server.index');
@@ -118,6 +123,14 @@ class Delete extends Component
];
}
if ($this->server->hostinger_virtual_machine_id) {
$checkboxes[] = [
'id' => 'delete_from_hostinger',
'label' => 'Also delete VPS from Hostinger (at the end of the paid period)',
'default_warning' => 'The VPS on Hostinger will NOT be deleted, and Hostinger will keep billing for it.',
];
}
return view('livewire.server.delete', [
'checkboxes' => $checkboxes,
]);
+514
View File
@@ -0,0 +1,514 @@
<?php
namespace App\Livewire\Server\New;
use App\Enums\ProxyTypes;
use App\Models\CloudProviderToken;
use App\Models\PrivateKey;
use App\Models\Server;
use App\Models\Team;
use App\Rules\ValidHostname;
use App\Services\HostingerService;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Collection;
use Livewire\Attributes\Locked;
use Livewire\Component;
class ByHostinger extends Component
{
use AuthorizesRequests;
public int $current_step = 1;
#[Locked]
public Collection $available_tokens;
#[Locked]
public $private_keys;
#[Locked]
public $limit_reached;
public ?int $selected_token_id = null;
public ?string $selectedTokenUuid = null;
public array $data_centers = [];
public array $templates = [];
public array $catalog_items = [];
public array $hostinger_public_keys = [];
public array $post_install_scripts = [];
public ?int $selected_data_center_id = null;
public ?int $selected_template_id = null;
public ?int $selected_os_template_id = null;
public ?int $selected_app_template_id = null;
public ?string $selected_price_id = null;
public array $selected_public_key_ids = [];
public ?int $selected_post_install_script_id = null;
public string $server_name = '';
public ?int $private_key_id = null;
public bool $enable_backups = false;
public bool $loading_data = false;
public ?string $provider_data_error = null;
public bool $from_onboarding = false;
public function mount(?string $selectedTokenUuid = null): mixed
{
try {
$this->authorize('viewAny', CloudProviderToken::class);
$this->loadTokens();
$this->selectTokenFromUrl($selectedTokenUuid);
$this->server_name = generate_random_name();
$this->private_keys = PrivateKey::ownedAndOnlySShKeys()->where('id', '!=', 0)->get();
if ($this->private_keys->isNotEmpty()) {
$this->private_key_id = $this->private_keys->first()->id;
}
if ($this->selectedTokenUuid) {
$this->current_step = 2;
$this->loading_data = true;
}
return null;
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function getListeners(): array
{
return [
'tokenAdded.hostinger' => 'handleTokenAdded',
'privateKeyCreated' => 'handlePrivateKeyCreated',
'modalClosed' => 'resetSelection',
];
}
protected function rules(): array
{
$rules = [
'selected_token_id' => 'required|integer|exists:cloud_provider_tokens,id',
];
if ($this->current_step === 2) {
$rules = array_merge($rules, [
'server_name' => ['required', 'string', 'max:253', new ValidHostname],
'selected_data_center_id' => 'required|integer',
'selected_template_id' => 'required|integer',
'selected_price_id' => 'required|string',
'private_key_id' => 'required|integer|exists:private_keys,id,team_id,'.currentTeam()->id,
'enable_backups' => 'required|boolean',
'selected_public_key_ids' => 'array',
'selected_public_key_ids.*' => 'integer',
'selected_post_install_script_id' => 'nullable|integer',
]);
}
return $rules;
}
protected function messages(): array
{
return [
'selected_token_id.required' => 'Please select a Hostinger token.',
'selected_token_id.exists' => 'Selected token not found.',
];
}
public function loadTokens(): void
{
$this->available_tokens = CloudProviderToken::ownedByCurrentTeam()
->where('provider', 'hostinger')
->get();
}
public function handleTokenAdded(int $tokenId): mixed
{
$this->loadTokens();
$this->selected_token_id = $tokenId;
return $this->nextStep();
}
public function handlePrivateKeyCreated(int $keyId): void
{
$this->private_keys = PrivateKey::ownedAndOnlySShKeys()->where('id', '!=', 0)->get();
$this->private_key_id = $keyId;
$this->resetErrorBag('private_key_id');
}
public function resetSelection(): void
{
$this->selected_token_id = null;
$this->current_step = 1;
}
public function nextStep(): mixed
{
$this->validate([
'selected_token_id' => 'required|integer|exists:cloud_provider_tokens,id',
]);
try {
if (! $this->selectedTokenUuid) {
$token = $this->available_tokens->firstWhere('id', $this->selected_token_id);
if ($token) {
return $this->redirectRoute('server.create.token', [
'type' => 'hostinger',
'token_uuid' => $token->uuid,
], navigate: true);
}
}
$this->current_step = 2;
$this->loading_data = true;
return null;
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function loadHostingerData(): void
{
$token = $this->getHostingerToken();
if (! $token) {
$this->loading_data = false;
$this->dispatch('error', 'Please select a valid Hostinger token.');
return;
}
$this->loading_data = true;
$this->provider_data_error = null;
try {
$hostingerService = new HostingerService($token);
$this->data_centers = $hostingerService->getDataCenters();
$this->templates = collect($hostingerService->getTemplates())
->sortBy('name')
->values()
->toArray();
$this->catalog_items = $hostingerService->getCatalogItems();
$this->hostinger_public_keys = $this->optionalProviderData(fn () => $hostingerService->getPublicKeys());
$this->post_install_scripts = $this->optionalProviderData(fn () => $hostingerService->getPostInstallScripts());
$this->selectDefaultTemplate();
} catch (\Throwable $e) {
$this->provider_data_error = $e->getMessage();
$this->dispatch('error', $this->provider_data_error);
} finally {
$this->loading_data = false;
}
}
public function updatedSelectedOsTemplateId(): void
{
if ($this->selected_os_template_id !== null) {
$this->selected_app_template_id = null;
}
$this->selected_template_id = $this->selected_os_template_id ?? $this->selected_app_template_id;
}
public function updatedSelectedAppTemplateId(): void
{
if ($this->selected_app_template_id !== null) {
$this->selected_os_template_id = null;
}
$this->selected_template_id = $this->selected_app_template_id ?? $this->selected_os_template_id;
}
/**
* Plain operating system templates.
*/
public function getOsTemplatesProperty(): array
{
return collect($this->templates)
->reject(fn (array $template): bool => $this->templateIncludesApplication($template))
->values()
->all();
}
/**
* Operating system templates that also install an application or control panel.
*/
public function getAppTemplatesProperty(): array
{
return collect($this->templates)
->filter(fn (array $template): bool => $this->templateIncludesApplication($template))
->values()
->all();
}
public function getPriceOptionsProperty(): array
{
return collect($this->catalog_items)
->flatMap(function (array $item): array {
$planSize = $this->planSize($item);
return collect($item['prices'] ?? [])
->map(fn (array $price): array => array_merge($price, [
'plan_name' => $item['name'] ?? $item['id'] ?? 'VPS',
'plan_size' => $planSize,
]))
->all();
})
->sort(fn (array $a, array $b): int => [$a['plan_size'], $this->periodInMonths($a)] <=> [$b['plan_size'], $this->periodInMonths($b)])
->values()
->toArray();
}
public function getSelectedPriceProperty(): ?array
{
return collect($this->priceOptions)->firstWhere('id', $this->selected_price_id);
}
public function priceLabel(array $price): string
{
$currency = strtoupper($price['currency'] ?? 'USD');
$firstPeriodPrice = (int) ($price['first_period_price'] ?? $price['price'] ?? 0);
$renewalPrice = (int) ($price['price'] ?? $firstPeriodPrice);
$period = (int) ($price['period'] ?? 1);
$periodUnit = $price['period_unit'] ?? 'month';
$periodLabel = $period.' '.str($periodUnit)->plural($period);
return sprintf(
'%s — %s %.2f for %s, then %s %.2f',
$price['plan_name'] ?? 'VPS',
$currency,
$firstPeriodPrice / 100,
$periodLabel,
$currency,
$renewalPrice / 100
);
}
public function submit(): mixed
{
$this->validate();
$virtualMachineId = null;
try {
$this->authorize('create', Server::class);
if (Team::serverLimitReached()) {
return $this->dispatch('error', 'You have reached the server limit for your subscription.');
}
$privateKey = PrivateKey::ownedByCurrentTeam()->findOrFail($this->private_key_id);
$hostingerService = new HostingerService($this->getHostingerToken());
if (! $this->providerSelectionsAreValid($hostingerService)) {
return null;
}
$normalizedServerName = strtolower(trim($this->server_name));
$setup = [
'data_center_id' => $this->selected_data_center_id,
'template_id' => $this->selected_template_id,
'hostname' => $normalizedServerName,
'enable_backups' => $this->enable_backups,
'public_key' => [
'name' => $privateKey->name,
'key' => $privateKey->getPublicKey(),
],
];
if ($this->selected_post_install_script_id) {
$setup['post_install_script_id'] = $this->selected_post_install_script_id;
}
$virtualMachine = $hostingerService->purchaseVirtualMachine([
'item_id' => $this->selected_price_id,
'setup' => $setup,
]);
$virtualMachineId = (int) $virtualMachine['id'];
$server = Server::create([
'name' => $normalizedServerName,
'ip' => Server::PLACEHOLDER_IP,
'user' => 'root',
'port' => 22,
'team_id' => currentTeam()->id,
'private_key_id' => $privateKey->id,
'cloud_provider_token_id' => $this->selected_token_id,
'hostinger_virtual_machine_id' => $virtualMachine['id'],
'hostinger_virtual_machine_status' => $virtualMachine['state'] ?? null,
]);
$server->proxy->set('status', 'exited');
$server->proxy->set('type', ProxyTypes::TRAEFIK->value);
$server->save();
try {
if ($this->selected_public_key_ids) {
$hostingerService->attachPublicKeys((int) $virtualMachine['id'], $this->selected_public_key_ids);
}
$virtualMachine = $hostingerService->waitForPublicIp($virtualMachine);
$ipAddress = $hostingerService->getPublicIpAddress($virtualMachine);
if ($ipAddress) {
$server->update([
'ip' => $ipAddress,
'hostinger_virtual_machine_status' => $virtualMachine['state'] ?? $server->hostinger_virtual_machine_status,
]);
}
} catch (\Throwable $e) {
report($e);
}
if ($this->from_onboarding) {
currentTeam()->update(['show_boarding' => false]);
refreshSession();
}
return redirectRoute($this, 'server.show', [$server->uuid]);
} catch (\Throwable $e) {
if ($virtualMachineId) {
logger()->warning('Hostinger VPS was purchased but could not be saved in Coolify', [
'team_id' => currentTeam()->id,
'hostinger_virtual_machine_id' => $virtualMachineId,
]);
$this->dispatch('error', "Hostinger VPS {$virtualMachineId} was purchased but could not be saved in Coolify. Manage it in hPanel.");
}
return handleError($e, $this);
}
}
private function selectTokenFromUrl(?string $selectedTokenUuid): void
{
if (! $selectedTokenUuid) {
return;
}
$token = $this->available_tokens->firstWhere('uuid', $selectedTokenUuid);
if ($token) {
$this->selectedTokenUuid = $selectedTokenUuid;
$this->selected_token_id = $token->id;
}
}
private function getHostingerToken(): string
{
$token = $this->available_tokens->firstWhere('id', $this->selected_token_id);
return $token?->token ?? '';
}
private function selectDefaultTemplate(): void
{
if ($this->selected_template_id !== null) {
return;
}
$defaultTemplate = collect($this->osTemplates)->firstWhere('name', 'Ubuntu 24.04 LTS');
if ($defaultTemplate) {
$this->selected_os_template_id = (int) $defaultTemplate['id'];
$this->selected_template_id = $this->selected_os_template_id;
}
}
private function templateIncludesApplication(array $template): bool
{
return str_contains((string) ($template['name'] ?? ''), ' with ');
}
/**
* The plan size from the KVM plan id (hostingercom-vps-kvm4 is 4), falling back to its CPU count.
*/
private function planSize(array $item): int
{
if (preg_match('/-vps-kvm(\d+)$/', (string) ($item['id'] ?? ''), $matches) === 1) {
return (int) $matches[1];
}
return (int) ($item['metadata']['cpus'] ?? 0);
}
private function periodInMonths(array $price): float
{
$period = (int) ($price['period'] ?? 0);
return match ($price['period_unit'] ?? 'month') {
'day' => $period / 30,
'week' => $period * 7 / 30,
'year' => $period * 12,
'none' => PHP_INT_MAX,
default => $period,
};
}
/**
* Load an optional Hostinger list. Some tokens cannot read these lists, so a failure must not block the form.
*/
private function optionalProviderData(callable $callback): array
{
try {
return $callback();
} catch (\Throwable $e) {
report($e);
return [];
}
}
private function providerSelectionsAreValid(HostingerService $hostingerService): bool
{
$priceIds = collect($hostingerService->getCatalogItems())
->flatMap(fn (array $item) => collect($item['prices'] ?? [])->pluck('id'));
$dataCenterIds = collect($hostingerService->getDataCenters())->pluck('id')->map(fn ($id) => (int) $id);
$templateIds = collect($hostingerService->getTemplates())->pluck('id')->map(fn ($id) => (int) $id);
$publicKeyIds = collect($this->selected_public_key_ids ? $hostingerService->getPublicKeys() : [])->pluck('id')->map(fn ($id) => (int) $id);
$postInstallScriptIds = collect($this->selected_post_install_script_id ? $hostingerService->getPostInstallScripts() : [])->pluck('id')->map(fn ($id) => (int) $id);
if (! $priceIds->contains($this->selected_price_id)) {
$this->addError('selected_price_id', 'The selected Hostinger plan or billing period is no longer available.');
}
if (! $dataCenterIds->contains($this->selected_data_center_id)) {
$this->addError('selected_data_center_id', 'The selected Hostinger data center is no longer available.');
}
if (! $templateIds->contains($this->selected_template_id)) {
$this->addError('selected_template_id', 'The selected Hostinger operating system is no longer available.');
}
if (collect($this->selected_public_key_ids)->contains(fn ($id) => ! $publicKeyIds->contains((int) $id))) {
$this->addError('selected_public_key_ids', 'One or more selected Hostinger SSH keys are no longer available.');
}
if ($this->selected_post_install_script_id && ! $postInstallScriptIds->contains($this->selected_post_install_script_id)) {
$this->addError('selected_post_install_script_id', 'The selected Hostinger post-install script is no longer available.');
}
return ! $this->getErrorBag()->isNotEmpty();
}
public function render()
{
return view('livewire.server.new.by-hostinger');
}
}
+19
View File
@@ -26,6 +26,8 @@ class Proxy extends Component
public ?string $redirectUrl = null;
public ?string $customErrorPage = null;
public bool $generateExactLabels = false;
/**
@@ -49,6 +51,7 @@ class Proxy extends Component
return [
'generateExactLabels' => 'required|boolean',
'redirectUrl' => ['nullable', new SafeExternalUrl],
'customErrorPage' => ['nullable', 'string', proxyPageSizeRule('custom error page')],
];
}
@@ -57,6 +60,7 @@ class Proxy extends Component
$this->selectedProxy = $this->server->proxyType();
$this->redirectEnabled = data_get($this->server, 'proxy.redirect_enabled', true);
$this->redirectUrl = data_get($this->server, 'proxy.redirect_url');
$this->customErrorPage = $this->server->proxy_error_page;
$this->syncData(false);
$this->clearAppliedTraefikBranchWarning();
}
@@ -187,6 +191,20 @@ class Proxy extends Component
}
}
public function resetCustomErrorPage(): void
{
try {
$this->authorize('update', $this->server);
$this->server->proxy_error_page = null;
$this->server->save();
$this->customErrorPage = null;
$this->server->setupDefaultRedirect();
$this->dispatch('success', 'Default error page restored.');
} catch (\Throwable $e) {
handleError($e, $this);
}
}
public function submit()
{
try {
@@ -194,6 +212,7 @@ class Proxy extends Component
$this->validate();
SaveProxyConfiguration::run($this->server, $this->proxySettings);
$this->server->proxy->redirect_url = $this->redirectUrl;
$this->server->proxy_error_page = filled($this->customErrorPage) ? $this->customErrorPage : null;
$this->server->save();
auditLog('ui.server.proxy.configuration_saved', $this->proxyAuditContext());
$this->server->setupDefaultRedirect();
+205
View File
@@ -10,6 +10,7 @@ use App\Models\Server;
use App\Rules\ValidServerIp;
use App\Services\DigitalOceanService;
use App\Services\HetznerService;
use App\Services\HostingerService;
use App\Services\ServerTransfer\ServerTransferClaimer;
use App\Services\VultrService;
use App\Support\ValidationPatterns;
@@ -82,12 +83,16 @@ class Show extends Component
public ?string $digitalOceanDropletStatus = null;
public ?string $hostingerVirtualMachineStatus = null;
public bool $hetznerServerManuallyStarted = false;
public bool $vultrInstanceManuallyStarted = false;
public bool $digitalOceanDropletManuallyStarted = false;
public bool $hostingerVirtualMachineManuallyStarted = false;
public bool $isValidating = false;
// Hetzner linking properties
@@ -127,6 +132,18 @@ class Show extends Component
public bool $digitalOceanNoMatchFound = false;
public Collection $availableHostingerTokens;
public ?int $selectedHostingerTokenId = null;
public ?string $manualHostingerVirtualMachineId = null;
public ?array $matchedHostingerVirtualMachine = null;
public ?string $hostingerSearchError = null;
public bool $hostingerNoMatchFound = false;
public function getListeners()
{
$teamId = $this->server->team_id ?? auth()->user()->currentTeam()->id;
@@ -206,12 +223,14 @@ class Show extends Component
$this->hetznerServerStatus = $this->server->hetzner_server_status;
$this->vultrInstanceStatus = $this->server->vultr_instance_status;
$this->digitalOceanDropletStatus = $this->server->digitalocean_droplet_status;
$this->hostingerVirtualMachineStatus = $this->server->hostinger_virtual_machine_status;
$this->isValidating = $this->server->is_validating ?? false;
// Load cloud provider tokens for linking
$this->loadHetznerTokens();
$this->loadVultrTokens();
$this->loadDigitalOceanTokens();
$this->loadHostingerTokens();
} catch (\Throwable $e) {
return handleError($e, $this);
@@ -658,6 +677,30 @@ class Show extends Component
}
}
public function checkHostingerVirtualMachineStatus(bool $manual = false): mixed
{
try {
$this->authorize('view', $this->server);
if (! $this->server->hostinger_virtual_machine_id || ! $this->server->cloudProviderToken) {
$this->dispatch('error', 'This server is not associated with a Hostinger VPS or token.');
return null;
}
$this->hostingerVirtualMachineStatus = $this->server->refreshHostingerState();
$this->server->refresh();
$this->ip = $this->server->ip;
if ($manual) {
$this->dispatch('success', 'VPS status refreshed: '.ucfirst($this->hostingerVirtualMachineStatus ?? 'unknown'));
}
return null;
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function handleServerValidated($event = null)
{
// Check if event is for this server
@@ -679,6 +722,7 @@ class Show extends Component
$this->loadHetznerTokens();
$this->loadVultrTokens();
$this->loadDigitalOceanTokens();
$this->loadHostingerTokens();
$this->dispatch('refreshServerShow');
$this->dispatch('refreshServer');
@@ -750,6 +794,30 @@ class Show extends Component
}
}
public function startHostingerVirtualMachine(): mixed
{
try {
$this->authorize('update', $this->server);
if (! $this->server->hostinger_virtual_machine_id || ! $this->server->cloudProviderToken) {
$this->dispatch('error', 'This server is not associated with a Hostinger VPS or token.');
return null;
}
$hostingerService = new HostingerService($this->server->cloudProviderToken->token);
$hostingerService->startVirtualMachine((int) $this->server->hostinger_virtual_machine_id);
$this->hostingerVirtualMachineStatus = 'starting';
$this->server->update(['hostinger_virtual_machine_status' => 'starting']);
$this->hostingerVirtualMachineManuallyStarted = true;
$this->dispatch('success', 'Hostinger VPS is starting...');
return null;
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function refreshServerMetadata(): void
{
try {
@@ -804,6 +872,13 @@ class Show extends Component
->get();
}
public function loadHostingerTokens(): void
{
$this->availableHostingerTokens = CloudProviderToken::ownedByCurrentTeam()
->where('provider', 'hostinger')
->get();
}
#[Computed]
public function limaStartCommand(): ?string
{
@@ -1077,6 +1152,136 @@ class Show extends Component
}
}
public function searchHostingerVirtualMachine(): void
{
$this->hostingerSearchError = null;
$this->hostingerNoMatchFound = false;
$this->matchedHostingerVirtualMachine = null;
if (! $this->selectedHostingerTokenId) {
$this->hostingerSearchError = 'Please select a Hostinger token.';
return;
}
try {
$this->authorize('update', $this->server);
$token = $this->availableHostingerTokens->firstWhere('id', $this->selectedHostingerTokenId);
if (! $token) {
$this->hostingerSearchError = 'Invalid token selected.';
return;
}
$matched = (new HostingerService($token->token))->findVirtualMachineByIp($this->server->ip);
if ($matched) {
$this->matchedHostingerVirtualMachine = $matched;
} else {
$this->hostingerNoMatchFound = true;
}
} catch (\Throwable $e) {
$this->hostingerSearchError = 'Failed to search Hostinger virtual machines: '.$e->getMessage();
}
}
public function searchHostingerVirtualMachineById(): void
{
$this->hostingerSearchError = null;
$this->hostingerNoMatchFound = false;
$this->matchedHostingerVirtualMachine = null;
if (! $this->selectedHostingerTokenId) {
$this->hostingerSearchError = 'Please select a Hostinger token first.';
return;
}
if (! $this->manualHostingerVirtualMachineId) {
$this->hostingerSearchError = 'Please enter a Hostinger Virtual Machine ID.';
return;
}
try {
$this->authorize('update', $this->server);
$token = $this->availableHostingerTokens->firstWhere('id', $this->selectedHostingerTokenId);
if (! $token) {
$this->hostingerSearchError = 'Invalid token selected.';
return;
}
$virtualMachine = (new HostingerService($token->token))
->getVirtualMachine((int) $this->manualHostingerVirtualMachineId);
if ($virtualMachine) {
$this->matchedHostingerVirtualMachine = $virtualMachine;
} else {
$this->hostingerNoMatchFound = true;
}
} catch (\Throwable $e) {
$this->hostingerSearchError = 'Failed to fetch Hostinger virtual machine: '.$e->getMessage();
}
}
public function linkToHostinger(): mixed
{
if (! $this->matchedHostingerVirtualMachine) {
$this->dispatch('error', 'No Hostinger virtual machine selected.');
return null;
}
try {
$this->authorize('update', $this->server);
$token = $this->availableHostingerTokens->firstWhere('id', $this->selectedHostingerTokenId);
if (! $token) {
$this->dispatch('error', 'Invalid token selected.');
return null;
}
$hostingerService = new HostingerService($token->token);
$virtualMachine = $hostingerService->getVirtualMachine((int) $this->matchedHostingerVirtualMachine['id']);
if (! $virtualMachine) {
$this->dispatch('error', 'Could not find Hostinger virtual machine with ID: '.$this->matchedHostingerVirtualMachine['id']);
return null;
}
$updates = [
'cloud_provider_token_id' => $this->selectedHostingerTokenId,
'hostinger_virtual_machine_id' => $virtualMachine['id'],
'hostinger_virtual_machine_status' => $virtualMachine['state'] ?? null,
];
$ip = $hostingerService->getPublicIpAddress($virtualMachine);
if ($ip) {
$updates['ip'] = $ip;
}
$this->server->update($updates);
$this->hostingerVirtualMachineStatus = $virtualMachine['state'] ?? null;
$this->matchedHostingerVirtualMachine = null;
$this->selectedHostingerTokenId = null;
$this->manualHostingerVirtualMachineId = null;
$this->hostingerNoMatchFound = false;
$this->hostingerSearchError = null;
$this->dispatch('success', 'Server successfully linked to Hostinger!');
$this->dispatch('close-modal');
$this->dispatch('refreshServerShow');
return null;
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function searchVultrInstance(): void
{
$this->vultrSearchError = null;
@@ -145,6 +145,18 @@ class ValidateAndInstall extends Component
}
}
if ($this->server->hostinger_virtual_machine_id) {
$status = $this->server->refreshHostingerState();
$this->server->refresh();
if (in_array($status, ['stopping', 'stopped', 'suspending', 'suspended', 'destroying', 'destroyed', 'error'], true)) {
$this->error = 'Hostinger VPS is '.($status ?? 'not running').'. Power it on before validating.';
$this->server->update(['validation_logs' => $this->error]);
return;
}
}
['uptime' => $this->uptime, 'error' => $error] = $this->server->validateConnection();
if (! $this->uptime) {
$sanitizedError = htmlspecialchars($error ?? '', ENT_QUOTES, 'UTF-8');
+1 -1
View File
@@ -24,7 +24,7 @@ trait BuildsResponse
// raw IDs / morph types (uuid is the public identifier)
'id', 'team_id', 'tokenable_id', 'tokenable_type',
'server_id', 'private_key_id', 'cloud_provider_token_id',
'hetzner_server_id', 'digitalocean_droplet_id', 'environment_id', 'destination_id',
'hetzner_server_id', 'digitalocean_droplet_id', 'hostinger_virtual_machine_id', 'environment_id', 'destination_id',
'source_id', 'repository_project_id', 'application_id',
'service_id', 'project_id', 'parent_id',
'resourceable', 'resourceable_id', 'resourceable_type',
+52 -1
View File
@@ -4,6 +4,7 @@ namespace App\Models;
use App\Enums\ApplicationDeploymentStatus;
use App\Enums\BuildPackTypes;
use App\Enums\HttpBasicAuthHashAlgorithm;
use App\Enums\ProxyTypes;
use App\Exceptions\DeploymentException;
use App\Services\ConfigurationGenerator;
@@ -16,6 +17,7 @@ use App\Traits\Auditable;
use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasComposeVolumeWarnings;
use App\Traits\HasConfiguration;
use App\Traits\HasMaintenancePage;
use App\Traits\HasMetrics;
use App\Traits\HasNoindexDomains;
use App\Traits\HasSafeStringAttribute;
@@ -125,6 +127,10 @@ use Symfony\Component\Yaml\Yaml;
'is_http_basic_auth_enabled' => ['type' => 'boolean', 'description' => 'HTTP Basic Authentication enabled.'],
'http_basic_auth_username' => ['type' => 'string', 'nullable' => true, 'description' => 'Username for HTTP Basic Authentication'],
'http_basic_auth_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password for HTTP Basic Authentication'],
'http_basic_auth_hash_algorithm' => ['type' => 'string', 'enum' => ['bcrypt', 'argon2id'], 'description' => 'Hash algorithm for the HTTP Basic Authentication password. Argon2id needs the Caddy proxy, version 2.11 or newer.'],
'http_basic_auth_bcrypt_cost' => ['type' => 'integer', 'minimum' => 4, 'maximum' => 14, 'description' => 'Bcrypt cost for the HTTP Basic Authentication password.'],
'http_basic_auth_argon2id_memory_cost' => ['type' => 'integer', 'minimum' => 8192, 'maximum' => 262144, 'description' => 'Argon2id memory cost in KiB for the HTTP Basic Authentication password.'],
'http_basic_auth_argon2id_time_cost' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 12, 'description' => 'Argon2id time cost (iterations) for the HTTP Basic Authentication password.'],
new OA\Property(property: 'settings', ref: '#/components/schemas/ApplicationSetting'),
]
)]
@@ -132,7 +138,7 @@ use Symfony\Component\Yaml\Yaml;
class Application extends BaseModel
{
/** @use HasFactory<ApplicationFactory> */
use Auditable, ClearsGlobalSearchCache, HasComposeVolumeWarnings, HasConfiguration, HasFactory, HasMetrics, HasNoindexDomains, HasSafeStringAttribute, HasSecretManager, ReleasesManagedDnsRecords, SoftDeletes;
use Auditable, ClearsGlobalSearchCache, HasComposeVolumeWarnings, HasConfiguration, HasFactory, HasMaintenancePage, HasMetrics, HasNoindexDomains, HasSafeStringAttribute, HasSecretManager, ReleasesManagedDnsRecords, SoftDeletes;
public const MAX_DOCKER_COMPOSE_SIZE_BYTES = 5 * 1024 * 1024;
@@ -220,6 +226,10 @@ class Application extends BaseModel
'is_http_basic_auth_enabled',
'http_basic_auth_username',
'http_basic_auth_password',
'http_basic_auth_hash_algorithm',
'http_basic_auth_bcrypt_cost',
'http_basic_auth_argon2id_memory_cost',
'http_basic_auth_argon2id_time_cost',
'connect_to_docker_network',
'force_domain_override',
'is_container_label_escape_enabled',
@@ -268,6 +278,7 @@ class Application extends BaseModel
{
return [
'http_basic_auth_password' => 'encrypted',
'http_basic_auth_hash_algorithm' => HttpBasicAuthHashAlgorithm::class,
'manual_webhook_secret_github' => 'encrypted',
'manual_webhook_secret_gitlab' => 'encrypted',
'manual_webhook_secret_bitbucket' => 'encrypted',
@@ -280,6 +291,7 @@ class Application extends BaseModel
'restart_limit_reached' => 'boolean',
'container_present' => 'boolean',
'last_restart_at' => 'datetime',
'is_maintenance_enabled' => 'boolean',
];
}
@@ -575,6 +587,39 @@ class Application extends BaseModel
instant_remote_process(["docker network rm {$uuid}"], $server, false);
}
/**
* Domains of the application, or of every service of a Docker Compose application.
*
* @return Collection<int, array{url: string, force_https: bool}>
*/
public function maintenanceDomains(): Collection
{
if ($this->build_pack === 'dockercompose') {
$urls = collect(json_decode($this->docker_compose_domains ?: '[]', true) ?: [])
->flatMap(fn ($service) => explode(',', (string) data_get($service, 'domain', '')));
} else {
$urls = collect($this->fqdns);
}
$forceHttps = (bool) $this->isForceHttpsEnabled();
return $urls->map(fn ($url) => trim((string) $url))
->filter()
->map(fn (string $url) => ['url' => $url, 'force_https' => $forceHttps])
->values();
}
/**
* @return Collection<int, Server>
*/
public function maintenanceServers(): Collection
{
return collect([$this->destination?->server])
->concat($this->additional_servers)
->filter()
->unique('id')
->values();
}
public function additional_servers()
{
return $this->belongsToMany(Server::class, 'additional_destinations')
@@ -657,6 +702,12 @@ class Application extends BaseModel
return $this->source instanceof GithubApp;
}
public function usesArgon2idBasicAuth(): bool
{
return $this->http_basic_auth_hash_algorithm === HttpBasicAuthHashAlgorithm::ARGON2ID
&& $this->destination->server->caddySupportsArgon2idBasicAuth();
}
public function isForceHttpsEnabled()
{
return data_get($this, 'settings.is_force_https_enabled', false);
+393 -58
View File
@@ -21,6 +21,7 @@ use App\Notifications\Server\Reachable;
use App\Notifications\Server\Unreachable;
use App\Services\DigitalOceanService;
use App\Services\HetznerService;
use App\Services\HostingerService;
use App\Services\VultrService;
use App\Support\ValidationPatterns;
use App\Traits\Auditable;
@@ -129,6 +130,19 @@ class Server extends BaseModel
public const PLACEHOLDER_IPS = [self::PLACEHOLDER_IP, '0.0.0.0', '::'];
/**
* Serves the proxy error pages (503.html) for the Traefik and Caddy catch-all.
*/
public const PROXY_ERROR_PAGE_CONTAINER = 'coolify-proxy-error-pages';
public const PROXY_ERROR_PAGE_IMAGE = 'busybox:musl';
/**
* Maximum size of a custom error or maintenance page. The page goes to the server with scp
* (see uploadProxyFile()), so this limit only keeps the Livewire payload reasonable.
*/
public const PROXY_ERROR_PAGE_MAX_BYTES = 1048576;
/**
* Default Caddy proxy image. caddy-docker-proxy 2.13 ships Caddy 2.11.
*/
@@ -324,6 +338,7 @@ class Server extends BaseModel
protected $hidden = [
'logdrain_axiom_api_key',
'logdrain_newrelic_license_key',
'proxy_error_page',
];
protected $schemalessAttributes = [
@@ -345,6 +360,8 @@ class Server extends BaseModel
'vultr_instance_status',
'digitalocean_droplet_id',
'digitalocean_droplet_status',
'hostinger_virtual_machine_id',
'hostinger_virtual_machine_status',
'is_validating',
'validation_logs',
'detected_traefik_version',
@@ -523,6 +540,27 @@ class Server extends BaseModel
return $status;
}
public function refreshHostingerState(): ?string
{
if (! $this->hostinger_virtual_machine_id || ! $this->cloudProviderToken || $this->cloudProviderToken->provider !== 'hostinger') {
return $this->hostinger_virtual_machine_status;
}
$hostingerService = new HostingerService($this->cloudProviderToken->token);
$virtualMachine = $hostingerService->getVirtualMachine((int) $this->hostinger_virtual_machine_id);
if (empty($virtualMachine)) {
return $this->hostinger_virtual_machine_status;
}
$status = $virtualMachine['state'] ?? null;
$ip = $hostingerService->getPublicIpAddress($virtualMachine);
$this->persistProviderState(['hostinger_virtual_machine_status' => $status]);
$this->backfillPlaceholderIp($ip);
return $status;
}
protected function isCoolifyHost(): Attribute
{
return Attribute::make(
@@ -621,6 +659,10 @@ class Server extends BaseModel
return $this->proxyType() && $this->proxyType() !== 'NONE' && $this->isFunctional() && ! $this->isSwarmWorker() && $this->canHostResources();
}
/**
* Writes the catch-all proxy configuration for requests that no resource handles.
* Priority: redirect URL > custom error page > Coolify default error page.
*/
public function setupDefaultRedirect()
{
$banner =
@@ -629,7 +671,6 @@ class Server extends BaseModel
$dynamic_conf_path = $this->proxyPath().'/dynamic';
$proxy_type = $this->proxyType();
$redirect_enabled = $this->proxy->redirect_enabled ?? true;
$redirect_url = $this->proxy->redirect_url;
if ($proxy_type === ProxyTypes::TRAEFIK->value) {
$default_redirect_file = "$dynamic_conf_path/default_redirect_503.yaml";
} elseif ($proxy_type === ProxyTypes::CADDY->value) {
@@ -641,67 +682,13 @@ class Server extends BaseModel
"rm -f $dynamic_conf_path/default_redirect_404.yaml",
"rm -f $dynamic_conf_path/default_redirect_404.caddy",
], $this);
// The error page container must run before the catch-all points to it.
$this->setupProxyErrorPageContainer();
if ($redirect_enabled === false) {
instant_remote_process(["rm -f $default_redirect_file"], $this);
} else {
if ($proxy_type === ProxyTypes::CADDY->value) {
if (filled($redirect_url)) {
$conf = ":80, :443 {
redir $redirect_url
}";
} else {
$conf = ':80, :443 {
respond 503
}';
}
} elseif ($proxy_type === ProxyTypes::TRAEFIK->value) {
$dynamic_conf = [
'http' => [
'routers' => [
'catchall' => [
'entryPoints' => [
0 => 'http',
1 => 'https',
],
'service' => 'noop',
'rule' => 'PathPrefix(`/`)',
'tls' => [
'certResolver' => 'letsencrypt',
],
'priority' => -1000,
],
],
'services' => [
'noop' => [
'loadBalancer' => [
'servers' => [],
],
],
],
],
];
if (filled($redirect_url)) {
$dynamic_conf['http']['routers']['catchall']['middlewares'] = [
0 => 'redirect-regexp',
];
$dynamic_conf['http']['services']['noop']['loadBalancer']['servers'][0] = [
'url' => '',
];
$dynamic_conf['http']['middlewares'] = [
'redirect-regexp' => [
'redirectRegex' => [
'regex' => '(.*)',
'replacement' => $redirect_url,
'permanent' => false,
],
],
];
}
$conf = Yaml::dump($dynamic_conf, 12, 2);
}
$conf = $banner.$conf;
$conf = $banner.$this->defaultRedirectConfiguration();
$base64 = base64_encode($conf);
instant_remote_process([
"echo '$base64' | base64 -d | tee $default_redirect_file > /dev/null",
@@ -713,6 +700,348 @@ class Server extends BaseModel
}
}
/**
* Catch-all proxy configuration (without the banner) for an enabled default redirect.
*/
public function defaultRedirectConfiguration(): string
{
$redirect_url = $this->proxy->redirect_url;
$usesErrorPage = $this->usesProxyErrorPage();
if ($this->proxyType() === ProxyTypes::CADDY->value) {
if (filled($redirect_url)) {
return ":80, :443 {
redir $redirect_url
}";
}
if ($usesErrorPage) {
// busybox httpd answers 200, so copy_response sets the 503 status.
return ':80, :443 {
reverse_proxy '.self::PROXY_ERROR_PAGE_CONTAINER.':80 {
rewrite /503.html
handle_response {
copy_response 503
}
}
}';
}
return ':80, :443 {
respond 503
}';
}
$dynamic_conf = [
'http' => [
'routers' => [
'catchall' => [
'entryPoints' => [
0 => 'http',
1 => 'https',
],
'service' => 'noop',
'rule' => 'PathPrefix(`/`)',
'tls' => [
'certResolver' => 'letsencrypt',
],
'priority' => -1000,
],
],
'services' => [
'noop' => [
'loadBalancer' => [
'servers' => [],
],
],
],
],
];
if (filled($redirect_url)) {
$dynamic_conf['http']['routers']['catchall']['middlewares'] = [
0 => 'redirect-regexp',
];
$dynamic_conf['http']['services']['noop']['loadBalancer']['servers'][0] = [
'url' => '',
];
$dynamic_conf['http']['middlewares'] = [
'redirect-regexp' => [
'redirectRegex' => [
'regex' => '(.*)',
'replacement' => $redirect_url,
'permanent' => false,
],
],
];
} elseif ($usesErrorPage) {
// The noop service (no servers) keeps the 503; the errors middleware only replaces the body.
$dynamic_conf['http']['routers']['catchall']['middlewares'] = [
0 => 'error-pages',
];
$dynamic_conf['http']['middlewares'] = [
'error-pages' => [
'errors' => [
'status' => ['503'],
'service' => 'error-pages',
'query' => '/503.html',
],
],
];
$dynamic_conf['http']['services']['error-pages'] = [
'loadBalancer' => [
'servers' => [
['url' => 'http://'.self::PROXY_ERROR_PAGE_CONTAINER.':80'],
],
],
];
}
// A router with `tls` matches only HTTPS, so plain HTTP needs its own catch-all router.
if (! $this->isSwarm()) {
$catchall = $dynamic_conf['http']['routers']['catchall'];
$dynamic_conf['http']['routers']['catchall']['entryPoints'] = ['https'];
unset($catchall['tls']);
$dynamic_conf['http']['routers']['catchall-http'] = array_merge($catchall, ['entryPoints' => ['http']]);
}
return Yaml::dump($dynamic_conf, 12, 2);
}
/**
* True when the catch-all shows the error page: a standalone Traefik or Caddy proxy with
* the default redirect enabled and no redirect URL. Swarm servers keep the plain 503.
*/
public function usesProxyErrorPage(): bool
{
return ! $this->isSwarm()
&& in_array($this->proxyType(), [ProxyTypes::TRAEFIK->value, ProxyTypes::CADDY->value], true)
&& ($this->proxy->redirect_enabled ?? true) !== false
&& blank($this->proxy->redirect_url);
}
public function proxyErrorPagesPath(): string
{
return base_configuration_dir().'/proxy/error-pages';
}
/**
* The custom error page HTML, or the Coolify default page.
*/
public function proxyErrorPageHtml(): string
{
return filled($this->proxy_error_page) ? $this->proxy_error_page : view('proxy.error-page')->render();
}
/**
* Applications and services on this server that are in maintenance mode.
*
* @return Collection<int, Application|Service>
*/
public function maintenanceResources(): Collection
{
if ($this->isSwarm() || ! in_array($this->proxyType(), [ProxyTypes::TRAEFIK->value, ProxyTypes::CADDY->value], true)) {
return collect();
}
return $this->applications()->where('is_maintenance_enabled', true)->values()
->concat($this->services()->where('is_maintenance_enabled', true)->get());
}
/**
* Writes 503.html and starts the error page container, or removes the container when
* neither the catch-all nor a maintenance page uses it. Swarm servers are not supported.
*/
public function setupProxyErrorPageContainer(): void
{
if ($this->isSwarm()) {
return;
}
$container = self::PROXY_ERROR_PAGE_CONTAINER;
if (! $this->usesProxyErrorPage() && $this->maintenanceResources()->isEmpty()) {
instant_remote_process(["docker rm -f $container >/dev/null 2>&1 || true"], $this);
return;
}
$path = $this->proxyErrorPagesPath();
$mount = devHostDockerPath($this, $path);
$restart = RESTART_MODE;
instant_remote_process(["mkdir -p $path"], $this);
$this->uploadProxyFile("$path/503.html", $this->proxyErrorPageHtml());
instant_remote_process([
dockerNetworkEnsureCommand('coolify'),
"docker start $container >/dev/null 2>&1 || docker run -d --name $container --network coolify --restart $restart --label coolify.managed=true -v $mount:/www:ro ".self::PROXY_ERROR_PAGE_IMAGE.' httpd -f -p 80 -h /www',
// A proxy on custom networks only must still reach the error page container.
'docker network connect coolify coolify-proxy >/dev/null 2>&1 || true',
], $this);
}
/**
* Writes $contents to $path on this server with scp. A shell command is limited to 128 KiB,
* so large pages and proxy files cannot go inside one command.
*/
public function uploadProxyFile(string $path, string $contents): void
{
$uploadPath = '/tmp/coolify-upload-'.bin2hex(random_bytes(8));
$localPath = tempnam(sys_get_temp_dir(), 'coolify-proxy-file-');
try {
file_put_contents($localPath, $contents);
instant_scp($localPath, $uploadPath, $this);
} finally {
@unlink($localPath);
}
// The proxy and the page container read the file as root, other users do not need it.
instant_remote_process(["mv -f $uploadPath $path", "chmod 644 $path"], $this);
}
/**
* Writes the maintenance pages and the proxy configuration that shows them instead of the
* applications and services in maintenance mode. Swarm servers are not supported.
*/
public function setupMaintenancePages(): void
{
$proxyType = $this->proxyType();
if ($this->isSwarm() || ! in_array($proxyType, [ProxyTypes::TRAEFIK->value, ProxyTypes::CADDY->value], true)) {
return;
}
$resources = $this->maintenanceResources();
$path = $this->proxyErrorPagesPath();
$keep = $resources->map(fn ($resource) => "! -name '{$resource->maintenancePageFile()}' ")->implode('');
instant_remote_process([
"mkdir -p $path",
"find $path -maxdepth 1 -name 'maintenance-*.html' {$keep}-delete",
], $this);
$this->setupProxyErrorPageContainer();
foreach ($resources as $resource) {
$this->uploadProxyFile("$path/{$resource->maintenancePageFile()}", $resource->maintenancePageHtml());
}
$dynamicPath = $this->proxyPath().'/dynamic';
instant_remote_process(["mkdir -p $dynamicPath"], $this);
if ($proxyType === ProxyTypes::TRAEFIK->value) {
$file = "$dynamicPath/coolify-maintenance.yaml";
$configuration = $this->maintenanceTraefikConfiguration($resources);
if ($configuration === null) {
instant_remote_process(["rm -f $file"], $this);
} else {
$this->uploadProxyFile($file, $configuration);
}
} else {
// caddy-docker-proxy reads the base Caddyfile again on its next poll.
$this->uploadProxyFile("$dynamicPath/Caddyfile", $this->caddyBaseCaddyfile($resources));
}
}
/**
* Traefik routers that use the same rule as the resource routers, with a priority one
* higher. Traefik uses the rule length as the default priority, so a resource on a longer
* path of the same host keeps its own router.
*
* @param Collection<int, Application|Service> $resources
*/
public function maintenanceTraefikConfiguration(Collection $resources): ?string
{
$routers = [];
$middlewares = [];
foreach ($resources as $resource) {
$routes = $resource->maintenanceRoutes();
if ($routes->isEmpty()) {
continue;
}
$middleware = "coolify-maintenance-{$resource->uuid}";
$middlewares[$middleware] = [
'errors' => [
'status' => ['503'],
'service' => 'coolify-maintenance-pages',
'query' => '/'.$resource->maintenancePageFile(),
],
];
foreach ($routes as $index => $route) {
$rule = "Host(`{$route['host']}`) && PathPrefix(`{$route['path']}`)";
$router = [
'entryPoints' => ['http'],
'rule' => $rule,
'service' => 'coolify-maintenance-noop',
'middlewares' => ['coolify-maintenance-headers', $middleware],
'priority' => strlen($rule) + 1,
];
$routers["coolify-maintenance-{$resource->uuid}-{$index}-http"] = $router;
if ($route['scheme'] === 'https') {
$routers["coolify-maintenance-{$resource->uuid}-{$index}-https"] = array_merge($router, [
'entryPoints' => ['https'],
'tls' => ['certResolver' => 'letsencrypt'],
]);
}
}
}
if ($routers === []) {
return null;
}
$middlewares['coolify-maintenance-headers'] = [
'headers' => ['customResponseHeaders' => ['Retry-After' => '300']],
];
return "# This file is generated by Coolify, do not edit it manually.\n\n".Yaml::dump([
'http' => [
'routers' => $routers,
'middlewares' => $middlewares,
'services' => [
// No servers: Traefik answers 503 and the errors middleware adds the page.
'coolify-maintenance-noop' => ['loadBalancer' => ['servers' => []]],
'coolify-maintenance-pages' => [
'loadBalancer' => ['servers' => [['url' => 'http://'.self::PROXY_ERROR_PAGE_CONTAINER.':80']]],
],
],
],
], 12, 2);
}
/**
* The base Caddyfile of caddy-docker-proxy. A maintenance block uses the same site address
* as the resource labels, so caddy-docker-proxy merges both into one site block. Its named
* matcher has the same path as the resource handle, so Caddy runs it first.
*
* @param Collection<int, Application|Service>|null $resources
*/
public function caddyBaseCaddyfile(?Collection $resources = null): string
{
$resources ??= $this->maintenanceResources();
$sites = [];
foreach ($resources as $resource) {
foreach ($resource->maintenanceRoutes() as $index => $route) {
$address = caddySiteAddress($route['scheme'], $route['host'], $route['force_https']);
$matcher = "@coolify-maintenance-{$resource->uuid}-{$index}";
$sites[$address][] = implode("\n", [
"\t{$matcher} path {$route['path']}*",
"\thandle {$matcher} {",
"\t\treverse_proxy ".self::PROXY_ERROR_PAGE_CONTAINER.':80 {',
"\t\t\trewrite /{$resource->maintenancePageFile()}",
"\t\t\theader_down Retry-After 300",
"\t\t\thandle_response {",
"\t\t\t\tcopy_response 503",
"\t\t\t}",
"\t\t}",
"\t}",
]);
}
}
$caddyfile = 'import /dynamic/*.caddy';
foreach ($sites as $address => $blocks) {
$caddyfile .= "\n\n{$address} {\n".implode("\n", $blocks)."\n}";
}
return $caddyfile."\n";
}
public function setupDynamicProxyConfiguration()
{
$settings = instanceSettings();
@@ -1272,6 +1601,12 @@ $siteAddress {
return $this->caddyRunsCurrentVersion();
}
public function caddySupportsArgon2idBasicAuth(): bool
{
return $this->caddyRunsCurrentVersion()
&& self::caddyDockerProxyImageVersion($this->configuredCaddyProxyImage()) >= [2, 11];
}
public function isServerApiEnabled(): bool
{
return $this->settings->is_sentinel_enabled;
+32 -1
View File
@@ -9,6 +9,7 @@ use App\Support\ResourceStartActivity;
use App\Traits\Auditable;
use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasComposeVolumeWarnings;
use App\Traits\HasMaintenancePage;
use App\Traits\HasSafeStringAttribute;
use App\Traits\HasSecretManager;
use Illuminate\Database\Eloquent\Casts\Attribute;
@@ -44,7 +45,7 @@ use OpenApi\Attributes as OA;
)]
class Service extends BaseModel
{
use Auditable, ClearsGlobalSearchCache, HasComposeVolumeWarnings, HasFactory, HasSafeStringAttribute, HasSecretManager, SoftDeletes;
use Auditable, ClearsGlobalSearchCache, HasComposeVolumeWarnings, HasFactory, HasMaintenancePage, HasSafeStringAttribute, HasSecretManager, SoftDeletes;
private static $parserVersion = '5';
@@ -78,6 +79,13 @@ class Service extends BaseModel
'docker_compose_raw',
];
protected function casts(): array
{
return [
'is_maintenance_enabled' => 'boolean',
];
}
protected static function booted()
{
static::creating(function ($service) {
@@ -1515,6 +1523,29 @@ class Service extends BaseModel
return $this->hasMany(ServiceApplication::class);
}
/**
* Domains of every application in the service stack.
*
* @return Collection<int, array{url: string, force_https: bool}>
*/
public function maintenanceDomains(): Collection
{
return $this->applications()->get()
->flatMap(fn (ServiceApplication $application) => collect($application->fqdns)
->map(fn ($url) => trim((string) $url))
->filter()
->map(fn (string $url) => ['url' => $url, 'force_https' => $application->isForceHttpsEnabled()]))
->values();
}
/**
* @return Collection<int, Server>
*/
public function maintenanceServers(): Collection
{
return collect([$this->server])->filter()->values();
}
public function databases()
{
return $this->hasMany(ServiceDatabase::class);
@@ -208,6 +208,10 @@ class ApplicationConfigurationSnapshot
$this->item('is_http_basic_auth_enabled', 'HTTP basic auth', $this->application->is_http_basic_auth_enabled, 'redeploy'),
$this->item('http_basic_auth_username', 'HTTP basic auth username', $this->application->http_basic_auth_username, 'redeploy'),
$this->item('http_basic_auth_password', 'HTTP basic auth password', $this->application->http_basic_auth_password, 'redeploy', sensitive: true),
$this->item('http_basic_auth_hash_algorithm', 'HTTP basic auth hash algorithm', $this->application->http_basic_auth_hash_algorithm?->value, 'redeploy'),
$this->item('http_basic_auth_bcrypt_cost', 'HTTP basic auth bcrypt cost factor', $this->application->http_basic_auth_bcrypt_cost, 'redeploy'),
$this->item('http_basic_auth_argon2id_memory_cost', 'HTTP basic auth Argon2id memory (KiB)', $this->application->http_basic_auth_argon2id_memory_cost, 'redeploy'),
$this->item('http_basic_auth_argon2id_time_cost', 'HTTP basic auth Argon2id iterations', $this->application->http_basic_auth_argon2id_time_cost, 'redeploy'),
];
}
+224
View File
@@ -0,0 +1,224 @@
<?php
namespace App\Services;
use App\Exceptions\RateLimitException;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Http;
class HostingerService
{
private string $baseUrl = 'https://developers.hostinger.com';
public function __construct(private string $token) {}
private function request(string $method, string $endpoint, array $data = []): array
{
$response = Http::withToken($this->token)
->acceptJson()
->asJson()
->timeout(30)
->connectTimeout(10)
->retry(
$method === 'get' ? 3 : 1,
fn (int $attempt) => $attempt * 100,
fn (\Exception $exception) => ! $exception instanceof RequestException || $exception->response->serverError(),
throw: false
)
->{$method}($this->baseUrl.$endpoint, $data);
if (! $response->successful()) {
if ($response->status() === 429) {
throw new RateLimitException(
'Rate limit exceeded. Please try again later.',
$response->header('Retry-After') !== null ? (int) $response->header('Retry-After') : null
);
}
$message = $response->json('message')
?? $response->json('error')
?? 'Unknown error';
throw new \Exception('Hostinger API error: '.$message, $response->status());
}
return $response->json() ?? [];
}
public function getDataCenters(): array
{
return $this->request('get', '/api/vps/v1/data-centers');
}
public function getTemplates(): array
{
return $this->request('get', '/api/vps/v1/templates');
}
/**
* Get the KVM VPS plans. Game Panel plans share the VPS category but are not offered for Coolify servers.
*/
public function getCatalogItems(): array
{
return collect($this->request('get', '/api/billing/v1/catalog', ['category' => 'VPS']))
->filter(fn ($item) => is_array($item) && preg_match('/-vps-kvm\d+$/', (string) ($item['id'] ?? '')) === 1)
->values()
->all();
}
public function getPublicKeys(): array
{
return $this->request('get', '/api/vps/v1/public-keys')['data'] ?? [];
}
public function getPostInstallScripts(): array
{
return $this->request('get', '/api/vps/v1/post-install-scripts')['data'] ?? [];
}
public function attachPublicKeys(int $virtualMachineId, array $publicKeyIds): array
{
return $this->request('post', "/api/vps/v1/public-keys/attach/{$virtualMachineId}", [
'ids' => array_map('intval', array_values($publicKeyIds)),
]);
}
public function purchaseVirtualMachine(array $params): array
{
// Hostinger accepts only a fully qualified hostname and uses its own default otherwise.
if (! str_contains($params['setup']['hostname'] ?? '', '.')) {
unset($params['setup']['hostname']);
}
$existingVirtualMachineIds = collect($this->getVirtualMachines())->pluck('id')->all();
try {
$response = $this->request('post', '/api/vps/v1/virtual-machines', $params);
} catch (\Throwable $e) {
// Hostinger can charge for the VPS before its setup fails, so keep the VPS if one was created.
$virtualMachine = $this->findPurchasedVirtualMachine($existingVirtualMachineIds);
if (! $virtualMachine) {
throw $e;
}
logger()->warning('Hostinger charged for a VPS but its setup failed', [
'hostinger_virtual_machine_id' => $virtualMachine['id'],
'error' => $e->getMessage(),
]);
return $this->setupPurchasedVirtualMachine($virtualMachine, $params['setup']);
}
if (empty($response['virtual_machine']['id'])) {
logger()->warning('Hostinger VPS order did not return a virtual machine', Arr::only($response, ['id', 'subscription_id', 'status', 'message']));
throw new \Exception('Hostinger order '.($response['id'] ?? 'unknown').' is waiting for payment. Finish the VPS setup in hPanel.', 202);
}
return $response['virtual_machine'];
}
private function findPurchasedVirtualMachine(array $existingVirtualMachineIds): ?array
{
try {
return collect($this->getVirtualMachines())
->first(fn (array $virtualMachine) => ! in_array($virtualMachine['id'] ?? null, $existingVirtualMachineIds, true));
} catch (\Throwable) {
return null;
}
}
private function setupPurchasedVirtualMachine(array $virtualMachine, array $setup): array
{
if (($virtualMachine['state'] ?? null) !== 'initial') {
return $virtualMachine;
}
try {
return $this->request('post', $this->virtualMachineEndpoint((int) $virtualMachine['id']).'/setup', $setup);
} catch (\Throwable $e) {
logger()->warning('Hostinger VPS setup retry failed', [
'hostinger_virtual_machine_id' => $virtualMachine['id'],
'error' => $e->getMessage(),
]);
return $virtualMachine;
}
}
public function getVirtualMachine(int $virtualMachineId): array
{
return $this->request('get', $this->virtualMachineEndpoint($virtualMachineId));
}
public function getVirtualMachines(): array
{
return $this->request('get', '/api/vps/v1/virtual-machines');
}
/**
* Briefly wait for a public IP. VPS setup usually takes longer, so the server status check fills in the IP later.
*/
public function waitForPublicIp(array $virtualMachine, int $attempts = 10, int $sleepMilliseconds = 1000): array
{
if ($this->getPublicIpAddress($virtualMachine) || empty($virtualMachine['id'])) {
return $virtualMachine;
}
for ($attempt = 0; $attempt < $attempts; $attempt++) {
usleep($sleepMilliseconds * 1000);
$virtualMachine = $this->getVirtualMachine((int) $virtualMachine['id']);
if ($this->getPublicIpAddress($virtualMachine)) {
return $virtualMachine;
}
}
return $virtualMachine;
}
public function getPublicIpAddress(array $virtualMachine): ?string
{
foreach (['ipv4', 'ipv6'] as $version) {
foreach ($virtualMachine[$version] ?? [] as $ipAddress) {
if (filled($ipAddress['address'] ?? null)) {
return $ipAddress['address'];
}
}
}
return null;
}
public function disableAutoRenewal(string $subscriptionId): array
{
return $this->request('delete', "/api/billing/v1/subscriptions/{$subscriptionId}/auto-renewal/disable");
}
public function startVirtualMachine(int $virtualMachineId): array
{
return $this->request('post', $this->virtualMachineEndpoint($virtualMachineId).'/start');
}
public function findVirtualMachineByIp(string $ip): ?array
{
foreach ($this->getVirtualMachines() as $virtualMachine) {
foreach (['ipv4', 'ipv6'] as $version) {
foreach ($virtualMachine[$version] ?? [] as $ipAddress) {
if (($ipAddress['address'] ?? null) === $ip) {
return $virtualMachine;
}
}
}
}
return null;
}
private function virtualMachineEndpoint(int $virtualMachineId): string
{
return '/api/vps/v1/virtual-machines/'.$virtualMachineId;
}
}
+85
View File
@@ -0,0 +1,85 @@
<?php
namespace App\Traits;
use App\Models\Server;
use Illuminate\Support\Collection;
use Spatie\Url\Url;
/**
* Maintenance mode for a resource with domains: the proxy shows a maintenance page with
* status 503 instead of the resource. The resource containers keep running.
*
* @property bool $is_maintenance_enabled
* @property string|null $maintenance_page
*/
trait HasMaintenancePage
{
/**
* @return Collection<int, array{url: string, force_https: bool}>
*/
abstract public function maintenanceDomains(): Collection;
/**
* @return Collection<int, Server>
*/
abstract public function maintenanceServers(): Collection;
/**
* Proxy routes for the maintenance page. Domains go into the Traefik and Caddy configuration
* as plain text, so a domain with an unexpected host or path is skipped.
*
* @return Collection<int, array{scheme: string, host: string, path: string, force_https: bool}>
*/
public function maintenanceRoutes(): Collection
{
return $this->maintenanceDomains()
->map(function (array $domain): ?array {
try {
$url = Url::fromString(trim($domain['url']));
} catch (\Throwable) {
return null;
}
// Host and scheme stay as the proxy labels use them, so the Caddy site address is the same.
$scheme = $url->getScheme();
$host = $url->getHost();
$path = $url->getPath() ?: '/';
if (! in_array($scheme, ['http', 'https'], true)
|| ! preg_match('/^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$/i', $host)
|| ! preg_match('#^/[A-Za-z0-9/._~%-]*$#', $path)) {
return null;
}
return ['scheme' => $scheme, 'host' => $host, 'path' => $path, 'force_https' => $domain['force_https']];
})
->filter()
->unique(fn (array $route) => "{$route['scheme']}://{$route['host']}{$route['path']}")
->values();
}
/**
* The custom maintenance page HTML, or the Coolify default page.
*/
public function maintenancePageHtml(): string
{
return filled($this->maintenance_page) ? $this->maintenance_page : view('proxy.maintenance-page')->render();
}
public function maintenancePageFile(): string
{
return "maintenance-{$this->uuid}.html";
}
/**
* Applies the maintenance state of this resource on every server that runs it.
*/
public function syncMaintenancePage(?Collection $servers = null): void
{
foreach ($servers ?? $this->maintenanceServers() as $server) {
if ($server->isFunctional()) {
$server->setupMaintenancePages();
}
}
}
}
+5
View File
@@ -2,6 +2,7 @@
use App\Actions\Shared\MigrateResourceToDestination;
use App\Enums\BuildPackTypes;
use App\Enums\HttpBasicAuthHashAlgorithm;
use App\Enums\RedirectTypes;
use App\Enums\StaticImageTypes;
use App\Models\Application;
@@ -276,6 +277,10 @@ function sharedDataApplications(?Application $application = null): array
'docker_compose_custom_build_command' => ValidationPatterns::shellSafeCommandRules(),
'is_container_label_escape_enabled' => 'boolean',
'is_preserve_repository_enabled' => 'boolean',
'http_basic_auth_hash_algorithm' => Rule::enum(HttpBasicAuthHashAlgorithm::class),
'http_basic_auth_bcrypt_cost' => 'integer|min:4|max:14',
'http_basic_auth_argon2id_memory_cost' => 'integer|min:8192|max:262144',
'http_basic_auth_argon2id_time_cost' => 'integer|min:1|max:12',
];
}
+73 -8
View File
@@ -766,7 +766,20 @@ function isNoindexDomain(string $domain, ?Collection $noindex_domains): bool
->contains(ValidationPatterns::normalizeApplicationDomainUrl($domain));
}
function fqdnLabelsForCaddy(string $network, string $uuid, Collection $domains, bool $is_force_https_enabled = false, $onlyPort = null, ?Collection $serviceLabels = null, ?bool $is_gzip_enabled = true, ?bool $is_stripprefix_enabled = true, ?string $service_name = null, ?string $image = null, string $redirect_direction = 'both', ?string $predefinedPort = null, bool $is_http_basic_auth_enabled = false, ?string $http_basic_auth_username = null, ?string $http_basic_auth_password = null, ?Collection $noindex_domains = null, bool $is_traffic_analytics_enabled = false, array $domainPortOverrides = [], bool $supports_log_append = false, bool $supports_basic_auth_directive = false)
/**
* Caddy site address of a domain. Maintenance blocks in the base Caddyfile must use the same
* address as the labels, so caddy-docker-proxy merges them into one site block.
*/
function caddySiteAddress(string $scheme, string $host, bool $isForceHttpsEnabled): string
{
if ($scheme === 'https' && ! $isForceHttpsEnabled) {
return "http://{$host}, https://{$host}";
}
return "{$scheme}://{$host}";
}
function fqdnLabelsForCaddy(string $network, string $uuid, Collection $domains, bool $is_force_https_enabled = false, $onlyPort = null, ?Collection $serviceLabels = null, ?bool $is_gzip_enabled = true, ?bool $is_stripprefix_enabled = true, ?string $service_name = null, ?string $image = null, string $redirect_direction = 'both', ?string $predefinedPort = null, bool $is_http_basic_auth_enabled = false, ?string $http_basic_auth_username = null, ?string $http_basic_auth_password = null, ?Collection $noindex_domains = null, bool $is_traffic_analytics_enabled = false, array $domainPortOverrides = [], bool $supports_log_append = false, bool $supports_basic_auth_directive = false, ?int $http_basic_auth_bcrypt_cost = null, array $http_basic_auth_argon2id_options = [])
{
$labels = collect([]);
if ($serviceLabels) {
@@ -777,7 +790,9 @@ function fqdnLabelsForCaddy(string $network, string $uuid, Collection $domains,
$is_http_basic_auth_enabled = $is_http_basic_auth_enabled && $http_basic_auth_username !== null && $http_basic_auth_password !== null;
if ($is_http_basic_auth_enabled) {
$hashedPassword = password_hash($http_basic_auth_password, PASSWORD_BCRYPT, ['cost' => 10]);
$hashedPassword = $http_basic_auth_argon2id_options
? password_hash($http_basic_auth_password, PASSWORD_ARGON2ID, $http_basic_auth_argon2id_options)
: password_hash($http_basic_auth_password, PASSWORD_BCRYPT, ['cost' => $http_basic_auth_bcrypt_cost ?? 10]);
}
$trafficAppKey = caddyTrafficAppKey($uuid, $service_name);
@@ -788,10 +803,7 @@ function fqdnLabelsForCaddy(string $network, string $uuid, Collection $domains,
$path = $url->getPath();
$host_without_www = str($host)->replace('www.', '');
$schema = $url->getScheme();
$siteAddress = "{$schema}://{$host}";
if ($schema === 'https' && ! $is_force_https_enabled) {
$siteAddress = "http://{$host}, https://{$host}";
}
$siteAddress = caddySiteAddress($schema, $host, $is_force_https_enabled);
$portlessDomain = ServiceApplication::withoutPort($domain);
$port = $url->getPort() ?? ($domainPortOverrides[$portlessDomain] ?? null);
$handle = 'handle_path';
@@ -834,6 +846,9 @@ function fqdnLabelsForCaddy(string $network, string $uuid, Collection $domains,
if ($is_http_basic_auth_enabled) {
// Caddy 2.8 renamed basicauth to basic_auth; see Server::caddySupportsBasicAuthDirective().
$basicAuthDirective = $supports_basic_auth_directive ? 'basic_auth' : 'basicauth';
if ($http_basic_auth_argon2id_options) {
$labels->push("caddy_{$loop}.{$basicAuthDirective}=argon2id");
}
$labels->push("caddy_{$loop}.{$basicAuthDirective}.{$http_basic_auth_username}=\"{$hashedPassword}\"");
}
if ($is_traffic_analytics_enabled) {
@@ -907,7 +922,7 @@ function dockerComposeServicePorts(?string $compose, ?string $serviceName): arra
->unique()->values()->all();
}
function fqdnLabelsForTraefik(string $uuid, Collection $domains, bool $is_force_https_enabled = false, $onlyPort = null, ?Collection $serviceLabels = null, ?bool $is_gzip_enabled = true, ?bool $is_stripprefix_enabled = true, ?string $service_name = null, bool $generate_unique_uuid = false, ?string $image = null, string $redirect_direction = 'both', bool $is_http_basic_auth_enabled = false, ?string $http_basic_auth_username = null, ?string $http_basic_auth_password = null, ?Collection $noindex_domains = null, bool $escape_redirect_replacement_for_compose = true, array $domainPortOverrides = [])
function fqdnLabelsForTraefik(string $uuid, Collection $domains, bool $is_force_https_enabled = false, $onlyPort = null, ?Collection $serviceLabels = null, ?bool $is_gzip_enabled = true, ?bool $is_stripprefix_enabled = true, ?string $service_name = null, bool $generate_unique_uuid = false, ?string $image = null, string $redirect_direction = 'both', bool $is_http_basic_auth_enabled = false, ?string $http_basic_auth_username = null, ?string $http_basic_auth_password = null, ?Collection $noindex_domains = null, bool $escape_redirect_replacement_for_compose = true, array $domainPortOverrides = [], ?int $http_basic_auth_bcrypt_cost = null)
{
$labels = collect([]);
$labels->push('traefik.enable=true');
@@ -919,7 +934,7 @@ function fqdnLabelsForTraefik(string $uuid, Collection $domains, bool $is_force_
$is_http_basic_auth_enabled = $is_http_basic_auth_enabled && $http_basic_auth_username !== null && $http_basic_auth_password !== null;
$http_basic_auth_label = "http-basic-auth-{$uuid}";
if ($is_http_basic_auth_enabled) {
$hashedPassword = password_hash($http_basic_auth_password, PASSWORD_BCRYPT, ['cost' => 10]);
$hashedPassword = password_hash($http_basic_auth_password, PASSWORD_BCRYPT, ['cost' => $http_basic_auth_bcrypt_cost ?? 10]);
}
if ($is_http_basic_auth_enabled) {
@@ -1186,6 +1201,10 @@ function generateLabelsApplication(Application $application, ?ApplicationPreview
$appUuid = $appUuid.'-pr-'.$pull_request_id;
}
$labels = collect([]);
$httpBasicAuthArgon2idOptions = $application->usesArgon2idBasicAuth() ? [
'memory_cost' => $application->http_basic_auth_argon2id_memory_cost,
'time_cost' => $application->http_basic_auth_argon2id_time_cost,
] : [];
if ($pull_request_id === 0) {
if ($application->fqdn) {
$domains = str(data_get($application, 'fqdn'))->explode(',');
@@ -1205,6 +1224,7 @@ function generateLabelsApplication(Application $application, ?ApplicationPreview
is_http_basic_auth_enabled: $application->is_http_basic_auth_enabled,
http_basic_auth_username: $application->http_basic_auth_username,
http_basic_auth_password: $application->http_basic_auth_password,
http_basic_auth_bcrypt_cost: $application->http_basic_auth_bcrypt_cost,
noindex_domains: $noindexDomains,
domainPortOverrides: $application->domain_port_overrides ?? [],
));
@@ -1222,6 +1242,8 @@ function generateLabelsApplication(Application $application, ?ApplicationPreview
is_http_basic_auth_enabled: $application->is_http_basic_auth_enabled,
http_basic_auth_username: $application->http_basic_auth_username,
http_basic_auth_password: $application->http_basic_auth_password,
http_basic_auth_bcrypt_cost: $application->http_basic_auth_bcrypt_cost,
http_basic_auth_argon2id_options: $httpBasicAuthArgon2idOptions,
noindex_domains: $noindexDomains,
is_traffic_analytics_enabled: $application->destination->server->isTrafficAnalyticsEnabled(),
supports_log_append: $application->destination->server->caddySupportsLogAppend(),
@@ -1242,6 +1264,7 @@ function generateLabelsApplication(Application $application, ?ApplicationPreview
is_http_basic_auth_enabled: $application->is_http_basic_auth_enabled,
http_basic_auth_username: $application->http_basic_auth_username,
http_basic_auth_password: $application->http_basic_auth_password,
http_basic_auth_bcrypt_cost: $application->http_basic_auth_bcrypt_cost,
noindex_domains: $noindexDomains,
escape_redirect_replacement_for_compose: false,
domainPortOverrides: $application->domain_port_overrides ?? [],
@@ -1258,6 +1281,8 @@ function generateLabelsApplication(Application $application, ?ApplicationPreview
is_http_basic_auth_enabled: $application->is_http_basic_auth_enabled,
http_basic_auth_username: $application->http_basic_auth_username,
http_basic_auth_password: $application->http_basic_auth_password,
http_basic_auth_bcrypt_cost: $application->http_basic_auth_bcrypt_cost,
http_basic_auth_argon2id_options: $httpBasicAuthArgon2idOptions,
noindex_domains: $noindexDomains,
is_traffic_analytics_enabled: $application->destination->server->isTrafficAnalyticsEnabled(),
supports_log_append: $application->destination->server->caddySupportsLogAppend(),
@@ -1288,6 +1313,7 @@ function generateLabelsApplication(Application $application, ?ApplicationPreview
is_http_basic_auth_enabled: $application->is_http_basic_auth_enabled,
http_basic_auth_username: $application->http_basic_auth_username,
http_basic_auth_password: $application->http_basic_auth_password,
http_basic_auth_bcrypt_cost: $application->http_basic_auth_bcrypt_cost,
noindex_domains: $noindexDomains,
escape_redirect_replacement_for_compose: false,
domainPortOverrides: $preview->domain_port_overrides ?? [],
@@ -1305,6 +1331,8 @@ function generateLabelsApplication(Application $application, ?ApplicationPreview
is_http_basic_auth_enabled: $application->is_http_basic_auth_enabled,
http_basic_auth_username: $application->http_basic_auth_username,
http_basic_auth_password: $application->http_basic_auth_password,
http_basic_auth_bcrypt_cost: $application->http_basic_auth_bcrypt_cost,
http_basic_auth_argon2id_options: $httpBasicAuthArgon2idOptions,
noindex_domains: $noindexDomains,
is_traffic_analytics_enabled: $application->destination->server->isTrafficAnalyticsEnabled(),
supports_log_append: $application->destination->server->caddySupportsLogAppend(),
@@ -1324,6 +1352,7 @@ function generateLabelsApplication(Application $application, ?ApplicationPreview
is_http_basic_auth_enabled: $application->is_http_basic_auth_enabled,
http_basic_auth_username: $application->http_basic_auth_username,
http_basic_auth_password: $application->http_basic_auth_password,
http_basic_auth_bcrypt_cost: $application->http_basic_auth_bcrypt_cost,
noindex_domains: $noindexDomains,
escape_redirect_replacement_for_compose: false,
domainPortOverrides: $preview->domain_port_overrides ?? [],
@@ -1339,6 +1368,8 @@ function generateLabelsApplication(Application $application, ?ApplicationPreview
is_http_basic_auth_enabled: $application->is_http_basic_auth_enabled,
http_basic_auth_username: $application->http_basic_auth_username,
http_basic_auth_password: $application->http_basic_auth_password,
http_basic_auth_bcrypt_cost: $application->http_basic_auth_bcrypt_cost,
http_basic_auth_argon2id_options: $httpBasicAuthArgon2idOptions,
noindex_domains: $noindexDomains,
is_traffic_analytics_enabled: $application->destination->server->isTrafficAnalyticsEnabled(),
supports_log_append: $application->destination->server->caddySupportsLogAppend(),
@@ -2078,3 +2109,37 @@ function injectDockerComposeBuildArgs(string $command, string $buildArgsString):
return $modifiedCommand ?? $command;
}
/**
* Run a command that uses the Railpack BuildKit builder, then stop the builder when no other
* process uses it. An idle buildkitd keeps the memory of earlier builds until it restarts.
*
* The lock file is in the buildx metadata directory, which all helper containers of a server share.
* The script has no `&&`, `||`, pipe, or `$(`, because the sudo parser would put sudo into it.
*/
function railpackBuilderLockedScript(string $command): string
{
return 'exec 9>/root/.docker/buildx/coolify-railpack.lock; if flock -s 9; then :; else exit $?; fi; '
."{$command}; status=\$?; "
.'if flock -n -x 9; then DOCKER_CONFIG=/root/.docker docker buildx stop coolify-railpack >/dev/null 2>&1; fi; '
.'exit $status';
}
/**
* The buildx metadata of a server, as the `docker run -v` source that the deployment helper uses.
*/
function railpackBuildxMetadataVolume(Server $server): string
{
return isDev() && $server->isLocalhost() ? 'coolify-buildx' : '$HOME/.docker/buildx';
}
/**
* Run a command for the Railpack builder from a temporary helper container, then stop the builder
* when no build uses it.
*/
function railpackBuilderHelperCommand(string $buildxMetadataVolume, string $helperImage, string $command): string
{
$script = railpackBuilderLockedScript($command);
return "docker run --rm -v {$buildxMetadataVolume}:/root/.docker/buildx -v /var/run/docker.sock:/var/run/docker.sock {$helperImage} bash -c '{$script}' 2>/dev/null || true";
}
+13
View File
@@ -950,3 +950,16 @@ function getTraefikVersionFromDockerCompose(Server $server): ?string
return null;
}
}
/**
* Validation rule for an HTML page that Coolify sends to the proxy in one shell command
* (see Server::PROXY_ERROR_PAGE_MAX_BYTES).
*/
function proxyPageSizeRule(string $name): Closure
{
return function (string $attribute, mixed $value, Closure $fail) use ($name): void {
if (strlen((string) $value) > Server::PROXY_ERROR_PAGE_MAX_BYTES) {
$fail("The {$name} must not be larger than ".(Server::PROXY_ERROR_PAGE_MAX_BYTES / 1024).' KB.');
}
};
}
+62 -60
View File
@@ -12,67 +12,68 @@
],
"require": {
"php": "^8.4",
"danharrin/livewire-rate-limiting": "2.2.1",
"doctrine/dbal": "4.4.4",
"firebase/php-jwt": "7.1.0",
"guzzlehttp/guzzle": "7.15.3",
"laravel/fortify": "1.37.3",
"laravel/framework": "12.69.3",
"laravel/horizon": "5.48.2",
"laravel/mcp": "1.0.1",
"laravel/pail": "1.2.7",
"laravel/prompts": "0.3.22",
"laravel/reverb": "1.11.1",
"laravel/sanctum": "4.3.3",
"laravel/socialite": "5.29.0",
"laravel/tinker": "2.11.1",
"laravel/ui": "4.6.3",
"lcobucci/jwt": "5.6.0",
"league/flysystem-aws-s3-v3": "3.35.2",
"league/flysystem-sftp-v3": "3.33.0",
"livewire/livewire": "3.8.7",
"log1x/laravel-webfonts": "2.0.1",
"lorisleiva/laravel-actions": "2.10.2",
"nubs/random-name-generator": "2.2.0",
"phpseclib/phpseclib": "3.0.57",
"pion/laravel-chunk-upload": "1.6.3",
"poliander/cron": "3.3.1",
"purplepixie/phpdns": "2.3.6",
"pusher/pusher-php-server": "7.3.0",
"resend/resend-laravel": "1.5.0",
"sentry/sentry-laravel": "4.27.0",
"socialiteproviders/authentik": "5.3.0",
"socialiteproviders/clerk": "5.1.0",
"socialiteproviders/discord": "4.2.0",
"socialiteproviders/google": "4.1.0",
"socialiteproviders/infomaniak": "4.0.0",
"socialiteproviders/microsoft-azure": "5.2.0",
"socialiteproviders/zitadel": "4.2.0",
"spatie/laravel-activitylog": "4.12.3",
"spatie/laravel-data": "4.23.0",
"spatie/laravel-markdown": "2.8.0",
"spatie/laravel-schemaless-attributes": "2.6.0",
"spatie/url": "2.4.0",
"stevebauman/purify": "6.3.2",
"stripe/stripe-php": "16.6.0",
"symfony/yaml": "7.4.15",
"visus/cuid2": "6.0.0",
"yosymfony/toml": "1.0.4",
"zircote/swagger-php": "5.8.3"
"danharrin/livewire-rate-limiting": "^2.3.0",
"doctrine/dbal": "^4.5.0",
"firebase/php-jwt": "^7.2.1",
"guzzlehttp/guzzle": "^7.15.5",
"laravel/fortify": "^1.40.0",
"laravel/framework": "^12.69.3",
"laravel/horizon": "^5.50.0",
"laravel/mcp": "^1.0.1",
"laravel/pail": "^1.2.7",
"laravel/prompts": "^0.3.24",
"laravel/reverb": "^1.12.0",
"laravel/sanctum": "^4.3.3",
"laravel/socialite": "^5.30.0",
"laravel/tinker": "^3.0.2",
"laravel/ui": "^4.6.3",
"lcobucci/jwt": "^5.6.0",
"league/flysystem-aws-s3-v3": "^3.35.3",
"league/flysystem-sftp-v3": "^3.36.0",
"livewire/livewire": "^3.8.10",
"log1x/laravel-webfonts": "^2.0.1",
"lorisleiva/laravel-actions": "^2.12.0",
"nubs/random-name-generator": "^2.2.0",
"phpseclib/phpseclib": "^3.0.57",
"pion/laravel-chunk-upload": "^1.6.3",
"poliander/cron": "^3.3.1",
"purplepixie/phpdns": "^2.4.0",
"pusher/pusher-php-server": "^7.3.0",
"resend/resend-laravel": "^1.6.0",
"sentry/sentry-laravel": "^4.28.0",
"socialiteproviders/authentik": "^5.3.0",
"socialiteproviders/clerk": "^5.1.0",
"socialiteproviders/discord": "^4.2.0",
"socialiteproviders/google": "^4.1.0",
"socialiteproviders/infomaniak": "^4.0.0",
"socialiteproviders/microsoft-azure": "^5.2.1",
"socialiteproviders/zitadel": "^4.2.0",
"spatie/laravel-activitylog": "^4.12.3",
"spatie/laravel-data": "^4.23.0",
"spatie/laravel-markdown": "^2.8.0",
"spatie/laravel-schemaless-attributes": "^2.6.0",
"spatie/url": "^2.4.0",
"stevebauman/purify": "^6.3.2",
"stripe/stripe-php": "^16.6.0",
"symfony/yaml": "^8.1.8",
"visus/cuid2": "^7.0.0",
"yosymfony/toml": "^1.0.4",
"zircote/swagger-php": "^6.11.0"
},
"require-dev": {
"driftingly/rector-laravel": "2.5.0",
"fakerphp/faker": "1.24.1",
"laravel/boost": "2.10.0",
"laravel/pint": "1.30.4",
"mockery/mockery": "1.6.12",
"nunomaduro/collision": "8.9.5",
"pestphp/pest": "4.7.8",
"pestphp/pest-plugin-browser": "4.3.1",
"phpstan/phpstan": "2.2.8",
"rector/rector": "2.6.1",
"serversideup/spin": "3.3.0",
"symfony/http-client": "7.4.16"
"driftingly/rector-laravel": "^2.6.2",
"fakerphp/faker": "^1.24.1",
"laravel/boost": "^2.10.0",
"laravel/pint": "^1.32.1",
"laravel/vet": "^0.2.1",
"mockery/mockery": "^1.6.15",
"nunomaduro/collision": "^8.9.5",
"pestphp/pest": "^4.7.8",
"pestphp/pest-plugin-browser": "^4.3.1",
"phpstan/phpstan": "^2.2.16",
"rector/rector": "^2.6.7",
"serversideup/spin": "^3.5.2",
"symfony/http-client": "^8.1.8"
},
"minimum-stability": "stable",
"prefer-stable": true,
@@ -94,7 +95,8 @@
"config": {
"allow-plugins": {
"pestphp/pest-plugin": true,
"php-http/discovery": true
"php-http/discovery": true,
"laravel/vet": true
},
"optimize-autoloader": true,
"preferred-install": "dist",
Generated
+1002 -649
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -6,8 +6,8 @@ $sshCommandTimeout = (int) env('SSH_COMMAND_TIMEOUT', 3600);
return [
'coolify' => [
'version' => env('COOLIFY_VERSION') ?: '4.4.3',
'helper_version' => '1.0.17',
'railpack_version' => '0.23.0',
'helper_version' => '1.0.18',
'railpack_version' => '0.40.1',
'self_hosted' => env('SELF_HOSTED', true),
'autoupdate' => env('AUTOUPDATE'),
'base_config_path' => env('BASE_CONFIG_PATH', '/data/coolify'),
@@ -21,6 +21,9 @@ class ApplicationFactory extends Factory
'ports_exposes' => '3000',
'environment_id' => 1,
'destination_id' => 1,
'http_basic_auth_bcrypt_cost' => 10,
'http_basic_auth_argon2id_memory_cost' => 65536,
'http_basic_auth_argon2id_time_cost' => 4,
];
}
}
@@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('servers', function (Blueprint $table) {
$table->bigInteger('hostinger_virtual_machine_id')->nullable()->after('digitalocean_droplet_status');
$table->string('hostinger_virtual_machine_status')->nullable()->after('hostinger_virtual_machine_id');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('servers', function (Blueprint $table) {
$table->dropColumn([
'hostinger_virtual_machine_status',
'hostinger_virtual_machine_id',
]);
});
}
};
@@ -0,0 +1,25 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Custom HTML that the proxy shows (with status 503) for requests that no resource handles.
*/
public function up(): void
{
Schema::table('servers', function (Blueprint $table) {
$table->text('proxy_error_page')->nullable();
});
}
public function down(): void
{
Schema::table('servers', function (Blueprint $table) {
$table->dropColumn('proxy_error_page');
});
}
};
@@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Maintenance mode: the proxy shows a maintenance page (status 503) instead of the resource.
*/
public function up(): void
{
foreach (['applications', 'services'] as $table) {
Schema::table($table, function (Blueprint $table) {
$table->boolean('is_maintenance_enabled')->default(false);
$table->text('maintenance_page')->nullable();
});
}
}
public function down(): void
{
foreach (['applications', 'services'] as $table) {
Schema::table($table, function (Blueprint $table) {
$table->dropColumn(['is_maintenance_enabled', 'maintenance_page']);
});
}
}
};
@@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('applications', function (Blueprint $table) {
$table->string('http_basic_auth_hash_algorithm')->default('bcrypt');
$table->unsignedTinyInteger('http_basic_auth_bcrypt_cost')->default(10);
$table->unsignedInteger('http_basic_auth_argon2id_memory_cost')->default(65536);
$table->unsignedTinyInteger('http_basic_auth_argon2id_time_cost')->default(4);
});
}
public function down(): void
{
Schema::table('applications', function (Blueprint $table) {
$table->dropColumn([
'http_basic_auth_hash_algorithm',
'http_basic_auth_bcrypt_cost',
'http_basic_auth_argon2id_memory_cost',
'http_basic_auth_argon2id_time_cost',
]);
});
}
};
+4 -4
View File
@@ -12,9 +12,9 @@ ARG PACK_VERSION=0.38.2
# https://github.com/railwayapp/nixpacks/releases
ARG NIXPACKS_VERSION=1.41.0
# https://github.com/railwayapp/railpack/releases
ARG RAILPACK_VERSION=0.23.0
# https://github.com/jdx/mise/releases — must match railpack's pinned version (https://raw.githubusercontent.com/railwayapp/railpack/refs/heads/main/core/mise/version.txt)
ARG MISE_VERSION=2026.3.17
ARG RAILPACK_VERSION=0.40.1
# https://github.com/jdx/mise/releases — must match the version pinned at that railpack tag (https://raw.githubusercontent.com/railwayapp/railpack/v${RAILPACK_VERSION}/core/mise/version.txt)
ARG MISE_VERSION=2026.9.15
FROM quay.io/minio/aistor/mc:RELEASE.2026-09-06T02-44-40Z AS minio-client
FROM ${BASE_IMAGE} AS base
@@ -36,7 +36,7 @@ RUN apk upgrade --no-cache && \
RUN mkdir -p ~/.docker/cli-plugins
# Install mise (musl build) at the path railpack expects (/tmp/railpack/mise/mise-VERSION).
# Railpack hardcodes a glibc mise download that fails on Alpine, so we pre-place a musl binary.
# Railpack skips its own mise download when this file exists.
RUN mkdir -p /tmp/railpack/mise && \
if [[ ${TARGETPLATFORM} == 'linux/amd64' ]]; then \
curl -sSL "https://github.com/jdx/mise/releases/download/v${MISE_VERSION}/mise-v${MISE_VERSION}-linux-x64-musl.tar.gz" | tar xz && \
+393 -2
View File
@@ -980,6 +980,36 @@
"nullable": true,
"description": "Password for HTTP Basic Authentication"
},
"http_basic_auth_hash_algorithm": {
"type": "string",
"enum": [
"bcrypt",
"argon2id"
],
"default": "bcrypt",
"description": "Hash algorithm for the HTTP Basic Authentication password. Argon2id needs the Caddy proxy, version 2.11 or newer."
},
"http_basic_auth_bcrypt_cost": {
"type": "integer",
"minimum": 4,
"maximum": 14,
"default": 10,
"description": "Bcrypt cost for the HTTP Basic Authentication password."
},
"http_basic_auth_argon2id_memory_cost": {
"type": "integer",
"minimum": 8192,
"maximum": 262144,
"default": 65536,
"description": "Argon2id memory cost in KiB for the HTTP Basic Authentication password."
},
"http_basic_auth_argon2id_time_cost": {
"type": "integer",
"minimum": 1,
"maximum": 12,
"default": 4,
"description": "Argon2id time cost (iterations) for the HTTP Basic Authentication password."
},
"connect_to_docker_network": {
"type": "boolean",
"description": "The flag to connect the service to the predefined Docker network."
@@ -1571,6 +1601,36 @@
"nullable": true,
"description": "Password for HTTP Basic Authentication"
},
"http_basic_auth_hash_algorithm": {
"type": "string",
"enum": [
"bcrypt",
"argon2id"
],
"default": "bcrypt",
"description": "Hash algorithm for the HTTP Basic Authentication password. Argon2id needs the Caddy proxy, version 2.11 or newer."
},
"http_basic_auth_bcrypt_cost": {
"type": "integer",
"minimum": 4,
"maximum": 14,
"default": 10,
"description": "Bcrypt cost for the HTTP Basic Authentication password."
},
"http_basic_auth_argon2id_memory_cost": {
"type": "integer",
"minimum": 8192,
"maximum": 262144,
"default": 65536,
"description": "Argon2id memory cost in KiB for the HTTP Basic Authentication password."
},
"http_basic_auth_argon2id_time_cost": {
"type": "integer",
"minimum": 1,
"maximum": 12,
"default": 4,
"description": "Argon2id time cost (iterations) for the HTTP Basic Authentication password."
},
"connect_to_docker_network": {
"type": "boolean",
"description": "The flag to connect the service to the predefined Docker network."
@@ -2162,6 +2222,36 @@
"nullable": true,
"description": "Password for HTTP Basic Authentication"
},
"http_basic_auth_hash_algorithm": {
"type": "string",
"enum": [
"bcrypt",
"argon2id"
],
"default": "bcrypt",
"description": "Hash algorithm for the HTTP Basic Authentication password. Argon2id needs the Caddy proxy, version 2.11 or newer."
},
"http_basic_auth_bcrypt_cost": {
"type": "integer",
"minimum": 4,
"maximum": 14,
"default": 10,
"description": "Bcrypt cost for the HTTP Basic Authentication password."
},
"http_basic_auth_argon2id_memory_cost": {
"type": "integer",
"minimum": 8192,
"maximum": 262144,
"default": 65536,
"description": "Argon2id memory cost in KiB for the HTTP Basic Authentication password."
},
"http_basic_auth_argon2id_time_cost": {
"type": "integer",
"minimum": 1,
"maximum": 12,
"default": 4,
"description": "Argon2id time cost (iterations) for the HTTP Basic Authentication password."
},
"connect_to_docker_network": {
"type": "boolean",
"description": "The flag to connect the service to the predefined Docker network."
@@ -2648,6 +2738,36 @@
"nullable": true,
"description": "Password for HTTP Basic Authentication"
},
"http_basic_auth_hash_algorithm": {
"type": "string",
"enum": [
"bcrypt",
"argon2id"
],
"default": "bcrypt",
"description": "Hash algorithm for the HTTP Basic Authentication password. Argon2id needs the Caddy proxy, version 2.11 or newer."
},
"http_basic_auth_bcrypt_cost": {
"type": "integer",
"minimum": 4,
"maximum": 14,
"default": 10,
"description": "Bcrypt cost for the HTTP Basic Authentication password."
},
"http_basic_auth_argon2id_memory_cost": {
"type": "integer",
"minimum": 8192,
"maximum": 262144,
"default": 65536,
"description": "Argon2id memory cost in KiB for the HTTP Basic Authentication password."
},
"http_basic_auth_argon2id_time_cost": {
"type": "integer",
"minimum": 1,
"maximum": 12,
"default": 4,
"description": "Argon2id time cost (iterations) for the HTTP Basic Authentication password."
},
"connect_to_docker_network": {
"type": "boolean",
"description": "The flag to connect the service to the predefined Docker network."
@@ -3114,6 +3234,36 @@
"nullable": true,
"description": "Password for HTTP Basic Authentication"
},
"http_basic_auth_hash_algorithm": {
"type": "string",
"enum": [
"bcrypt",
"argon2id"
],
"default": "bcrypt",
"description": "Hash algorithm for the HTTP Basic Authentication password. Argon2id needs the Caddy proxy, version 2.11 or newer."
},
"http_basic_auth_bcrypt_cost": {
"type": "integer",
"minimum": 4,
"maximum": 14,
"default": 10,
"description": "Bcrypt cost for the HTTP Basic Authentication password."
},
"http_basic_auth_argon2id_memory_cost": {
"type": "integer",
"minimum": 8192,
"maximum": 262144,
"default": 65536,
"description": "Argon2id memory cost in KiB for the HTTP Basic Authentication password."
},
"http_basic_auth_argon2id_time_cost": {
"type": "integer",
"minimum": 1,
"maximum": 12,
"default": 4,
"description": "Argon2id time cost (iterations) for the HTTP Basic Authentication password."
},
"connect_to_docker_network": {
"type": "boolean",
"description": "The flag to connect the service to the predefined Docker network."
@@ -3703,6 +3853,32 @@
"type": "boolean",
"description": "Use Docker Build Secrets for build-time environment variables."
},
"http_basic_auth_hash_algorithm": {
"type": "string",
"enum": [
"bcrypt",
"argon2id"
],
"description": "Hash algorithm for the HTTP Basic Authentication password. Argon2id needs the Caddy proxy, version 2.11 or newer."
},
"http_basic_auth_bcrypt_cost": {
"type": "integer",
"minimum": 4,
"maximum": 14,
"description": "Bcrypt cost for the HTTP Basic Authentication password."
},
"http_basic_auth_argon2id_memory_cost": {
"type": "integer",
"minimum": 8192,
"maximum": 262144,
"description": "Argon2id memory cost in KiB for the HTTP Basic Authentication password."
},
"http_basic_auth_argon2id_time_cost": {
"type": "integer",
"minimum": 1,
"maximum": 12,
"description": "Argon2id time cost (iterations) for the HTTP Basic Authentication password."
},
"is_git_submodules_enabled": {
"type": "boolean",
"description": "Clone Git submodules."
@@ -6142,7 +6318,8 @@
"enum": [
"hetzner",
"digitalocean",
"vultr"
"vultr",
"hostinger"
]
},
"team_id": {
@@ -6201,7 +6378,8 @@
"enum": [
"hetzner",
"digitalocean",
"vultr"
"vultr",
"hostinger"
],
"example": "hetzner",
"description": "The cloud provider."
@@ -12697,6 +12875,189 @@
]
}
},
"\/hostinger\/data-centers": {
"get": {
"tags": [
"Hostinger"
],
"summary": "Get Hostinger VPS data centers",
"operationId": "get-hostinger-data-centers",
"responses": {
"200": {
"description": "List of Hostinger VPS data centers."
},
"401": {
"$ref": "#\/components\/responses\/401"
},
"403": {
"description": "Hostinger denied access to this resource for the cloud provider token."
},
"422": {
"description": "Validation failed."
},
"429": {
"description": "Hostinger rate limit exceeded."
}
},
"security": [
{
"bearerAuth": []
}
]
}
},
"\/hostinger\/catalog": {
"get": {
"tags": [
"Hostinger"
],
"summary": "Get Hostinger VPS plans and prices",
"operationId": "get-hostinger-catalog",
"responses": {
"200": {
"description": "List of Hostinger VPS plans and prices."
},
"401": {
"$ref": "#\/components\/responses\/401"
},
"403": {
"description": "Hostinger denied access to this resource for the cloud provider token."
},
"422": {
"description": "Validation failed."
},
"429": {
"description": "Hostinger rate limit exceeded."
}
},
"security": [
{
"bearerAuth": []
}
]
}
},
"\/hostinger\/templates": {
"get": {
"tags": [
"Hostinger"
],
"summary": "Get Hostinger VPS operating system templates",
"operationId": "get-hostinger-templates",
"responses": {
"200": {
"description": "List of Hostinger VPS templates."
},
"401": {
"$ref": "#\/components\/responses\/401"
},
"403": {
"description": "Hostinger denied access to this resource for the cloud provider token."
},
"422": {
"description": "Validation failed."
},
"429": {
"description": "Hostinger rate limit exceeded."
}
},
"security": [
{
"bearerAuth": []
}
]
}
},
"\/hostinger\/ssh-keys": {
"get": {
"tags": [
"Hostinger"
],
"summary": "Get Hostinger account SSH keys",
"operationId": "get-hostinger-ssh-keys",
"responses": {
"200": {
"description": "List of Hostinger account SSH keys."
},
"401": {
"$ref": "#\/components\/responses\/401"
},
"403": {
"description": "Hostinger denied access to this resource for the cloud provider token."
},
"422": {
"description": "Validation failed."
},
"429": {
"description": "Hostinger rate limit exceeded."
}
},
"security": [
{
"bearerAuth": []
}
]
}
},
"\/hostinger\/post-install-scripts": {
"get": {
"tags": [
"Hostinger"
],
"summary": "Get Hostinger post-install scripts",
"operationId": "get-hostinger-post-install-scripts",
"responses": {
"200": {
"description": "List of Hostinger post-install scripts."
},
"401": {
"$ref": "#\/components\/responses\/401"
},
"403": {
"description": "Hostinger denied access to this resource for the cloud provider token."
},
"422": {
"description": "Validation failed."
},
"429": {
"description": "Hostinger rate limit exceeded."
}
},
"security": [
{
"bearerAuth": []
}
]
}
},
"\/servers\/hostinger": {
"post": {
"tags": [
"Hostinger"
],
"summary": "Purchase a Hostinger VPS and create a linked server",
"operationId": "create-hostinger-server",
"responses": {
"201": {
"description": "Hostinger VPS purchased and linked to a Coolify server."
},
"401": {
"$ref": "#\/components\/responses\/401"
},
"422": {
"description": "Validation failed."
},
"429": {
"description": "Hostinger rate limit exceeded."
}
},
"security": [
{
"bearerAuth": []
}
]
}
},
"\/settings\/email": {
"get": {
"tags": [
@@ -23753,6 +24114,32 @@
"nullable": true,
"description": "Password for HTTP Basic Authentication"
},
"http_basic_auth_hash_algorithm": {
"type": "string",
"enum": [
"bcrypt",
"argon2id"
],
"description": "Hash algorithm for the HTTP Basic Authentication password. Argon2id needs the Caddy proxy, version 2.11 or newer."
},
"http_basic_auth_bcrypt_cost": {
"type": "integer",
"minimum": 4,
"maximum": 14,
"description": "Bcrypt cost for the HTTP Basic Authentication password."
},
"http_basic_auth_argon2id_memory_cost": {
"type": "integer",
"minimum": 8192,
"maximum": 262144,
"description": "Argon2id memory cost in KiB for the HTTP Basic Authentication password."
},
"http_basic_auth_argon2id_time_cost": {
"type": "integer",
"minimum": 1,
"maximum": 12,
"description": "Argon2id time cost (iterations) for the HTTP Basic Authentication password."
},
"settings": {
"$ref": "#\/components\/schemas\/ApplicationSetting"
}
@@ -24875,6 +25262,10 @@
"name": "Hetzner",
"description": "Hetzner"
},
{
"name": "Hostinger",
"description": "Hostinger"
},
{
"name": "Settings",
"description": "Settings"
+278 -2
View File
@@ -646,6 +646,29 @@ paths:
type: string
nullable: true
description: 'Password for HTTP Basic Authentication'
http_basic_auth_hash_algorithm:
type: string
enum: [bcrypt, argon2id]
default: bcrypt
description: 'Hash algorithm for the HTTP Basic Authentication password. Argon2id needs the Caddy proxy, version 2.11 or newer.'
http_basic_auth_bcrypt_cost:
type: integer
minimum: 4
maximum: 14
default: 10
description: 'Bcrypt cost for the HTTP Basic Authentication password.'
http_basic_auth_argon2id_memory_cost:
type: integer
minimum: 8192
maximum: 262144
default: 65536
description: 'Argon2id memory cost in KiB for the HTTP Basic Authentication password.'
http_basic_auth_argon2id_time_cost:
type: integer
minimum: 1
maximum: 12
default: 4
description: 'Argon2id time cost (iterations) for the HTTP Basic Authentication password.'
connect_to_docker_network:
type: boolean
description: 'The flag to connect the service to the predefined Docker network.'
@@ -1032,6 +1055,29 @@ paths:
type: string
nullable: true
description: 'Password for HTTP Basic Authentication'
http_basic_auth_hash_algorithm:
type: string
enum: [bcrypt, argon2id]
default: bcrypt
description: 'Hash algorithm for the HTTP Basic Authentication password. Argon2id needs the Caddy proxy, version 2.11 or newer.'
http_basic_auth_bcrypt_cost:
type: integer
minimum: 4
maximum: 14
default: 10
description: 'Bcrypt cost for the HTTP Basic Authentication password.'
http_basic_auth_argon2id_memory_cost:
type: integer
minimum: 8192
maximum: 262144
default: 65536
description: 'Argon2id memory cost in KiB for the HTTP Basic Authentication password.'
http_basic_auth_argon2id_time_cost:
type: integer
minimum: 1
maximum: 12
default: 4
description: 'Argon2id time cost (iterations) for the HTTP Basic Authentication password.'
connect_to_docker_network:
type: boolean
description: 'The flag to connect the service to the predefined Docker network.'
@@ -1418,6 +1464,29 @@ paths:
type: string
nullable: true
description: 'Password for HTTP Basic Authentication'
http_basic_auth_hash_algorithm:
type: string
enum: [bcrypt, argon2id]
default: bcrypt
description: 'Hash algorithm for the HTTP Basic Authentication password. Argon2id needs the Caddy proxy, version 2.11 or newer.'
http_basic_auth_bcrypt_cost:
type: integer
minimum: 4
maximum: 14
default: 10
description: 'Bcrypt cost for the HTTP Basic Authentication password.'
http_basic_auth_argon2id_memory_cost:
type: integer
minimum: 8192
maximum: 262144
default: 65536
description: 'Argon2id memory cost in KiB for the HTTP Basic Authentication password.'
http_basic_auth_argon2id_time_cost:
type: integer
minimum: 1
maximum: 12
default: 4
description: 'Argon2id time cost (iterations) for the HTTP Basic Authentication password.'
connect_to_docker_network:
type: boolean
description: 'The flag to connect the service to the predefined Docker network.'
@@ -1745,6 +1814,29 @@ paths:
type: string
nullable: true
description: 'Password for HTTP Basic Authentication'
http_basic_auth_hash_algorithm:
type: string
enum: [bcrypt, argon2id]
default: bcrypt
description: 'Hash algorithm for the HTTP Basic Authentication password. Argon2id needs the Caddy proxy, version 2.11 or newer.'
http_basic_auth_bcrypt_cost:
type: integer
minimum: 4
maximum: 14
default: 10
description: 'Bcrypt cost for the HTTP Basic Authentication password.'
http_basic_auth_argon2id_memory_cost:
type: integer
minimum: 8192
maximum: 262144
default: 65536
description: 'Argon2id memory cost in KiB for the HTTP Basic Authentication password.'
http_basic_auth_argon2id_time_cost:
type: integer
minimum: 1
maximum: 12
default: 4
description: 'Argon2id time cost (iterations) for the HTTP Basic Authentication password.'
connect_to_docker_network:
type: boolean
description: 'The flag to connect the service to the predefined Docker network.'
@@ -2058,6 +2150,29 @@ paths:
type: string
nullable: true
description: 'Password for HTTP Basic Authentication'
http_basic_auth_hash_algorithm:
type: string
enum: [bcrypt, argon2id]
default: bcrypt
description: 'Hash algorithm for the HTTP Basic Authentication password. Argon2id needs the Caddy proxy, version 2.11 or newer.'
http_basic_auth_bcrypt_cost:
type: integer
minimum: 4
maximum: 14
default: 10
description: 'Bcrypt cost for the HTTP Basic Authentication password.'
http_basic_auth_argon2id_memory_cost:
type: integer
minimum: 8192
maximum: 262144
default: 65536
description: 'Argon2id memory cost in KiB for the HTTP Basic Authentication password.'
http_basic_auth_argon2id_time_cost:
type: integer
minimum: 1
maximum: 12
default: 4
description: 'Argon2id time cost (iterations) for the HTTP Basic Authentication password.'
connect_to_docker_network:
type: boolean
description: 'The flag to connect the service to the predefined Docker network.'
@@ -2430,6 +2545,25 @@ paths:
use_build_secrets:
type: boolean
description: 'Use Docker Build Secrets for build-time environment variables.'
http_basic_auth_hash_algorithm:
type: string
enum: [bcrypt, argon2id]
description: 'Hash algorithm for the HTTP Basic Authentication password. Argon2id needs the Caddy proxy, version 2.11 or newer.'
http_basic_auth_bcrypt_cost:
type: integer
minimum: 4
maximum: 14
description: 'Bcrypt cost for the HTTP Basic Authentication password.'
http_basic_auth_argon2id_memory_cost:
type: integer
minimum: 8192
maximum: 262144
description: 'Argon2id memory cost in KiB for the HTTP Basic Authentication password.'
http_basic_auth_argon2id_time_cost:
type: integer
minimum: 1
maximum: 12
description: 'Argon2id time cost (iterations) for the HTTP Basic Authentication password.'
is_git_submodules_enabled:
type: boolean
description: 'Clone Git submodules.'
@@ -3941,7 +4075,7 @@ paths:
schema:
type: array
items:
properties: { uuid: { type: string }, name: { type: string }, provider: { type: string, enum: [hetzner, digitalocean, vultr] }, team_id: { type: integer }, servers_count: { type: integer }, created_at: { type: string }, updated_at: { type: string } }
properties: { uuid: { type: string }, name: { type: string }, provider: { type: string, enum: [hetzner, digitalocean, vultr, hostinger] }, team_id: { type: integer }, servers_count: { type: integer }, created_at: { type: string }, updated_at: { type: string } }
type: object
'401':
$ref: '#/components/responses/401'
@@ -3969,7 +4103,7 @@ paths:
properties:
provider:
type: string
enum: [hetzner, digitalocean, vultr]
enum: [hetzner, digitalocean, vultr, hostinger]
example: hetzner
description: 'The cloud provider.'
token:
@@ -8113,6 +8247,124 @@ paths:
security:
-
bearerAuth: []
/hostinger/data-centers:
get:
tags:
- Hostinger
summary: 'Get Hostinger VPS data centers'
operationId: get-hostinger-data-centers
responses:
'200':
description: 'List of Hostinger VPS data centers.'
'401':
$ref: '#/components/responses/401'
'403':
description: 'Hostinger denied access to this resource for the cloud provider token.'
'422':
description: 'Validation failed.'
'429':
description: 'Hostinger rate limit exceeded.'
security:
-
bearerAuth: []
/hostinger/catalog:
get:
tags:
- Hostinger
summary: 'Get Hostinger VPS plans and prices'
operationId: get-hostinger-catalog
responses:
'200':
description: 'List of Hostinger VPS plans and prices.'
'401':
$ref: '#/components/responses/401'
'403':
description: 'Hostinger denied access to this resource for the cloud provider token.'
'422':
description: 'Validation failed.'
'429':
description: 'Hostinger rate limit exceeded.'
security:
-
bearerAuth: []
/hostinger/templates:
get:
tags:
- Hostinger
summary: 'Get Hostinger VPS operating system templates'
operationId: get-hostinger-templates
responses:
'200':
description: 'List of Hostinger VPS templates.'
'401':
$ref: '#/components/responses/401'
'403':
description: 'Hostinger denied access to this resource for the cloud provider token.'
'422':
description: 'Validation failed.'
'429':
description: 'Hostinger rate limit exceeded.'
security:
-
bearerAuth: []
/hostinger/ssh-keys:
get:
tags:
- Hostinger
summary: 'Get Hostinger account SSH keys'
operationId: get-hostinger-ssh-keys
responses:
'200':
description: 'List of Hostinger account SSH keys.'
'401':
$ref: '#/components/responses/401'
'403':
description: 'Hostinger denied access to this resource for the cloud provider token.'
'422':
description: 'Validation failed.'
'429':
description: 'Hostinger rate limit exceeded.'
security:
-
bearerAuth: []
/hostinger/post-install-scripts:
get:
tags:
- Hostinger
summary: 'Get Hostinger post-install scripts'
operationId: get-hostinger-post-install-scripts
responses:
'200':
description: 'List of Hostinger post-install scripts.'
'401':
$ref: '#/components/responses/401'
'403':
description: 'Hostinger denied access to this resource for the cloud provider token.'
'422':
description: 'Validation failed.'
'429':
description: 'Hostinger rate limit exceeded.'
security:
-
bearerAuth: []
/servers/hostinger:
post:
tags:
- Hostinger
summary: 'Purchase a Hostinger VPS and create a linked server'
operationId: create-hostinger-server
responses:
'201':
description: 'Hostinger VPS purchased and linked to a Coolify server.'
'401':
$ref: '#/components/responses/401'
'422':
description: 'Validation failed.'
'429':
description: 'Hostinger rate limit exceeded.'
security:
-
bearerAuth: []
/settings/email:
get:
tags:
@@ -15139,6 +15391,27 @@ components:
type: string
nullable: true
description: 'Password for HTTP Basic Authentication'
http_basic_auth_hash_algorithm:
type: string
enum:
- bcrypt
- argon2id
description: 'Hash algorithm for the HTTP Basic Authentication password. Argon2id needs the Caddy proxy, version 2.11 or newer.'
http_basic_auth_bcrypt_cost:
type: integer
minimum: 4
maximum: 14
description: 'Bcrypt cost for the HTTP Basic Authentication password.'
http_basic_auth_argon2id_memory_cost:
type: integer
minimum: 8192
maximum: 262144
description: 'Argon2id memory cost in KiB for the HTTP Basic Authentication password.'
http_basic_auth_argon2id_time_cost:
type: integer
minimum: 1
maximum: 12
description: 'Argon2id time cost (iterations) for the HTTP Basic Authentication password.'
settings:
$ref: '#/components/schemas/ApplicationSetting'
type: object
@@ -15923,6 +16196,9 @@ tags:
-
name: Hetzner
description: Hetzner
-
name: Hostinger
description: Hostinger
-
name: Settings
description: Settings
+1 -1
View File
@@ -7,7 +7,7 @@
"version": "4.5-rc.1"
},
"helper": {
"version": "1.0.17"
"version": "1.0.18"
},
"sentinel": {
"version": "1.0.2"
@@ -0,0 +1,7 @@
<svg {{ $attributes }} viewBox="0 0 26 30" fill="none" xmlns="http://www.w3.org/2000/svg" role="img"
aria-label="Hostinger">
<title>Hostinger</title>
<path fill-rule="evenodd" clip-rule="evenodd"
d="M0.000249566 14.046V0.000497794L6.99464 3.78046V10.1086L16.2538 10.1132L23.3617 14.046H0.000249566ZM18.1472 8.95059V0L25.3302 3.68591V13.1797L18.1472 8.95059ZM18.1472 26.1177V19.8441L8.81663 19.8376C8.82534 19.8793 1.59081 15.8418 1.59081 15.8418L25.3302 15.9547V30L18.1472 26.1177ZM0 26.1177L0.000248849 16.9393L6.99464 21.0683V29.8033L0 26.1177Z"
fill="currentColor" />
</svg>

After

Width:  |  Height:  |  Size: 606 B

@@ -41,7 +41,7 @@
'active' => $activeMenu === 'cloud-provider-token',
'icon' => 'subscription',
'group' => 'Settings',
'visible' => (bool) ($server->hetzner_server_id || $server->vultr_instance_id),
'visible' => (bool) ($server->hetzner_server_id || $server->vultr_instance_id || $server->digitalocean_droplet_id || $server->hostinger_virtual_machine_id),
],
[
'label' => 'CA Certificate',
@@ -207,6 +207,23 @@
</x-slot:content>
<livewire:server.new.by-digital-ocean :limit_reached="false" :from_onboarding="true" />
</x-modal-input>
<x-modal-input title="Connect a Hostinger Server" isFullWidth>
<x-slot:content>
<div
class="group relative cursor-pointer flex h-full min-h-36 flex-col rounded-[10px] border border-neutral-200 bg-white p-4 text-left shadow-sm transition-all hover:-translate-y-px hover:border-neutral-300 hover:shadow-md dark:border-white/[0.08] dark:bg-white/[0.025] dark:hover:border-white/[0.14]">
<div class="flex h-full flex-col gap-4 text-left">
<x-hostinger-icon class="size-10 shrink-0 text-[#673de6]" />
<div class="min-h-0 flex-1">
<h3 class="mb-1 text-[14px] font-semibold">Hostinger</h3>
<p class="text-sm dark:text-neutral-400">
Deploy servers directly from your Hostinger account.
</p>
</div>
</div>
</div>
</x-slot:content>
<livewire:server.new.by-hostinger :limit_reached="false" :from_onboarding="true" />
</x-modal-input>
@endif
</div>
</section>
@@ -14,6 +14,7 @@
<livewire:project.application.general :application="$application" />
@elseif ($currentRoute === 'project.application.domains')
<livewire:project.application.domains :application="$application" />
<livewire:project.shared.maintenance :resource="$application" />
@elseif ($currentRoute === 'project.application.swarm' && $application->destination->server->isSwarm())
<livewire:project.application.swarm :application="$application" />
@elseif ($currentRoute === 'project.application.advanced')
@@ -1,3 +1,4 @@
@php use App\Enums\HttpBasicAuthHashAlgorithm; use App\Enums\ProxyTypes; @endphp
<div x-data="{
initLoadingCompose: $wire.entangle('initLoadingCompose'),
canUpdate: @js(auth()->user()->can('update', $application)),
@@ -572,6 +573,44 @@
<x-forms.input id="httpBasicAuthPassword" type="password" label="Password" required
x-bind:disabled="!canUpdate" />
</div>
@php
$server = $application->destination->server;
$supportsArgon2id = $server->caddySupportsArgon2idBasicAuth();
$usesArgon2id = $httpBasicAuthHashAlgorithm === HttpBasicAuthHashAlgorithm::ARGON2ID->value;
@endphp
<div class="mt-5 border-t border-neutral-200 pt-5 dark:border-white/[0.07]">
<h3 class="mb-3 text-sm font-semibold text-black dark:text-fg">Password hashing</h3>
<div class="grid w-full gap-4 {{ $usesArgon2id ? 'sm:grid-cols-3' : 'sm:grid-cols-2' }}">
<x-forms.listbox id="httpBasicAuthHashAlgorithm" label="Algorithm" onChange="instantSave"
helper="Algorithm that hashes the password for the proxy labels.<br><br>Traefik supports only bcrypt. Argon2id needs the Caddy proxy, version 2.11 or newer. With Argon2id, labels generated for Traefik use bcrypt."
:options="[
['value' => HttpBasicAuthHashAlgorithm::BCRYPT->value, 'label' => 'bcrypt', 'description' => 'Works with every proxy'],
['value' => HttpBasicAuthHashAlgorithm::ARGON2ID->value, 'label' => 'Argon2id', 'disabled' => ! $supportsArgon2id, 'description' => match (true) {
$supportsArgon2id => 'Memory-hard, more resistant to cracking',
$server->proxyType() === ProxyTypes::CADDY->value => 'Needs Caddy 2.11 or newer on this server',
default => 'Only supported by the Caddy proxy',
}],
]" x-bind:disabled="!canUpdate" />
@if ($usesArgon2id)
<div wire:key="http-basic-auth-argon2id-memory-cost" class="min-w-0">
<x-forms.listbox id="httpBasicAuthArgon2idMemoryCost" label="Memory" onChange="instantSave"
helper="Memory that the proxy needs to check a password. More memory makes the hash harder to crack, but every login attempt that the proxy has not seen before uses this much memory on the server."
:options="collect([8, 16, 32, 64, 128, 256])->map(fn (int $mebibytes) => ['value' => $mebibytes * 1024, 'label' => $mebibytes.' MiB'])->all()" x-bind:disabled="!canUpdate" />
</div>
<div wire:key="http-basic-auth-argon2id-time-cost" class="min-w-0">
<x-forms.listbox id="httpBasicAuthArgon2idTimeCost" label="Iterations" onChange="instantSave"
helper="Number of passes over the memory. More iterations make the hash harder to crack and a password check slower."
:options="collect(range(1, 12))->map(fn (int $iterations) => ['value' => $iterations, 'label' => (string) $iterations])->all()" x-bind:disabled="!canUpdate" />
</div>
@else
<div wire:key="http-basic-auth-bcrypt-cost" class="min-w-0">
<x-forms.listbox id="httpBasicAuthBcryptCost" label="Cost factor" onChange="instantSave"
helper="Each step doubles the work to check or crack the password. Traefik checks the password on every request, so a higher cost makes every request slower."
:options="collect(range(4, 14))->map(fn (int $cost) => ['value' => $cost, 'label' => (string) $cost])->all()" x-bind:disabled="!canUpdate" />
</div>
@endif
</div>
</div>
@endif
@endif
</x-application.settings-section>
@@ -197,6 +197,7 @@
:lazy="(bool) ($service->server ?? $service->destination?->server)?->isTrafficAnalyticsEnabled()" />
@elseif ($currentRoute === 'project.service.domains')
<livewire:project.service.domains :service="$service" />
<livewire:project.shared.maintenance :resource="$service" />
@elseif ($currentRoute === 'project.service.environment-variables')
<livewire:project.shared.environment-variable.all :resource="$service" />
<livewire:project.shared.secret-manager-links :resource="$service" />
@@ -17,6 +17,23 @@
@endif
<x-application.settings-section id="danger-zone-section" title="Danger zone"
helper="Destructive resource actions cannot be undone.">
@if (in_array($resource?->type(), ['standalone-redis', 'standalone-keydb', 'standalone-dragonfly']))
<x-danger-zone title="Flush all data" class="mb-4">
<p>
Permanently remove every key from
<strong class="font-semibold text-black dark:text-fg">{{ $resourceName }}</strong>.
The database keeps running.
</p>
<x-slot:action>
<x-modal-confirmation title="Flush all data?" buttonTitle="Flush all data" isErrorButton
submitAction="flush" :actions="['Permanently remove every key from every logical database.']"
confirmationText="{{ $resourceName }}"
confirmationLabel="Enter the database name to confirm flushing all data"
shortConfirmationLabel="Database name" :disabled="auth()->user()->cannot('manage', $resource)"
disabledTooltip="You do not have permission to flush this database." />
</x-slot:action>
</x-danger-zone>
@endif
<x-danger-zone title="Delete {{ $resourceLabel }}">
<p>
Permanently delete
@@ -0,0 +1,30 @@
<form wire:submit="submit">
<x-unsaved-bar action="submit" targets="maintenancePage" />
<x-application.settings-section id="maintenance-section" title="Maintenance mode"
helper="Show a maintenance page with a 503 status on all domains of this resource. The containers keep running, and the change applies without a redeploy.">
@if ($this->isSupported)
<div class="flex flex-col gap-4">
<div class="grid gap-4 lg:grid-cols-2">
<x-forms.listbox canGate="update" :canResource="$resource" id="isMaintenanceEnabled"
label="Maintenance mode" onChange="instantSaveMaintenance" :options="[
['value' => false, 'label' => 'Off: serve the resource'],
['value' => true, 'label' => 'On: show the maintenance page'],
]" />
</div>
<x-forms.textarea canGate="update" :canResource="$resource" id="maintenancePage" rows="10" monospace
label="Custom maintenance page (HTML)"
placeholder="Leave empty to use the Coolify default maintenance page."
helper="One self-contained HTML file (max {{ \App\Models\Server::PROXY_ERROR_PAGE_MAX_BYTES / 1024 }} KB) with inline CSS and images." />
@can('update', $resource)
<div class="flex gap-2">
<x-forms.button type="button" wire:click="resetMaintenancePage">Reset to
default</x-forms.button>
</div>
@endcan
</div>
@else
<p class="text-sm text-neutral-500 dark:text-neutral-400">Maintenance mode needs a Traefik or Caddy proxy on a
server that is not part of a Docker Swarm.</p>
@endif
</x-application.settings-section>
</form>
@@ -9,6 +9,7 @@
get providerConsoleUrl() {
if (this.selectedProvider === 'hetzner') return 'https://console.hetzner.com/projects';
if (this.selectedProvider === 'vultr') return 'https://console.vultr.com/user/apiaccess/';
if (this.selectedProvider === 'hostinger') return 'https://hpanel.hostinger.com/profile/api';
return 'https://cloud.digitalocean.com/account/api/tokens';
}
}">
@@ -18,6 +19,7 @@
['value' => 'hetzner', 'label' => 'Hetzner'],
['value' => 'digitalocean', 'label' => 'DigitalOcean'],
['value' => 'vultr', 'label' => 'Vultr'],
['value' => 'hostinger', 'label' => 'Hostinger'],
]" />
@else
<input type="hidden" wire:model="provider" />
@@ -10,7 +10,7 @@
<x-server.sidebar :server="$server" activeMenu="cloud-provider-token" />
<div class="application-settings-form w-full">
@if ($server->hetzner_server_id || $server->vultr_instance_id)
@if ($server->hetzner_server_id || $server->vultr_instance_id || $server->digitalocean_droplet_id || $server->hostinger_virtual_machine_id)
<x-application.settings-section id="server-cloud-token-section"
title="{{ $providerName }} token"
helper="Choose the cloud credential used to manage this server." flush>
@@ -67,7 +67,7 @@
<x-application.settings-section title="Cloud token"
helper="Cloud credentials are available for servers created through a supported provider.">
<x-empty size="sm" title="No cloud provider integration"
description="This server was not created through Hetzner or Vultr, so it does not require a managed cloud token."
description="This server was not created through a supported cloud provider, so it does not require a managed cloud token."
icon-name="keys" />
</x-application.settings-section>
@endif
@@ -50,7 +50,7 @@
<p class="application-settings-section-description">Create a server with a cloud provider.</p>
</div>
<div class="application-settings-section-body is-flush">
<div class="grid grid-cols-1 gap-3 p-3 sm:grid-cols-2 lg:grid-cols-3">
<div class="grid grid-cols-1 gap-3 p-3 sm:grid-cols-2 lg:grid-cols-4">
<a href="{{ route('server.create.type', ['type' => 'hetzner']) }}"
class="group flex min-h-32 flex-col rounded-xl border border-neutral-200 bg-white p-3 shadow-sm transition-all hover:-translate-y-px hover:border-neutral-300 hover:no-underline hover:shadow-md dark:border-white/[0.08] dark:bg-white/[0.05] dark:hover:border-white/[0.14]"
{{ wireNavigate() }}>
@@ -94,6 +94,20 @@
</div>
</a>
<a href="{{ route('server.create.type', ['type' => 'hostinger']) }}"
class="group flex min-h-32 flex-col rounded-xl border border-neutral-200 bg-white p-3 shadow-sm transition-all hover:-translate-y-px hover:border-neutral-300 hover:no-underline hover:shadow-md dark:border-white/[0.08] dark:bg-white/[0.025] dark:hover:border-white/[0.14]"
{{ wireNavigate() }}>
<div class="flex items-start">
<x-hostinger-icon class="size-8 text-[#673de6]" />
</div>
<div class="mt-auto pt-5">
<h3 class="text-[13px]! font-semibold! text-black dark:text-fg">Hostinger</h3>
<p class="mt-1 text-[11px] leading-4 text-neutral-500 dark:text-fg-faint">
Purchase and provision a new VPS.
</p>
</div>
</a>
</div>
</div>
</section>
@@ -113,6 +127,10 @@
<livewire:server.new.by-digital-ocean :private_keys="$private_keys" :limit_reached="$limit_reached"
:selected-token-uuid="$selectedTokenUuid"
wire:key="new-server-digital-ocean-{{ $selectedTokenUuid ?? 'select' }}" />
@elseif ($selectedType === 'hostinger')
<livewire:server.new.by-hostinger :private_keys="$private_keys" :limit_reached="$limit_reached"
:selected-token-uuid="$selectedTokenUuid"
wire:key="new-server-hostinger-{{ $selectedTokenUuid ?? 'select' }}" />
@else
<livewire:server.new.by-ip :private_keys="$private_keys" :limit_reached="$limit_reached"
key="new-server-manual" />
@@ -21,12 +21,28 @@
It currently contains managed resources. Enable force deletion in the confirmation only
if those resources should also be removed.
@endif
@if ($server->hostinger_virtual_machine_id)
Hostinger deletes the VPS only at the end of its paid period, and only if you select this in the confirmation dialog.
<a class="underline" href="https://hpanel.hostinger.com/" target="_blank"
rel="noopener noreferrer">Manage the VPS and billing in hPanel.</a>
@endif
</p>
<p>Type the server name in the confirmation dialog to continue.</p>
<x-slot:action>
@php
$deleteActions = ['This server will be permanently deleted from Coolify.'];
$deleteWarningMessage = null;
if ($server->hostinger_virtual_machine_id) {
$deleteWarningMessage = '<strong>Hostinger deletes a VPS only at the end of its paid period.</strong><br>'
.'If you select to delete the VPS from Hostinger, Coolify turns off its auto-renewal. '
.'The VPS keeps running until the paid period ends. Then Hostinger deletes it with all its data. '
.'To delete it now or to request a refund, '
.'<a class="underline font-semibold" href="https://hpanel.hostinger.com/" target="_blank" rel="noopener noreferrer">cancel the VPS in hPanel</a>.';
}
@endphp
<x-modal-confirmation title="Confirm Server Deletion?" isErrorButton
buttonTitle="Delete server" submitAction="delete"
:actions="['This server will be permanently deleted from Coolify.']"
:actions="$deleteActions" :warningMessage="$deleteWarningMessage"
:checkboxes="$checkboxes" confirmationText="{{ $server->name }}"
confirmationLabel="Please confirm by entering the Server Name below"
shortConfirmationLabel="Server Name" />
@@ -0,0 +1,147 @@
<div class="w-full">
@if ($limit_reached)
<x-limit-reached name="servers" />
@elseif ($current_step === 1)
<div class="flex flex-col gap-6">
<x-server.provider-token-picker provider="hostinger" providerLabel="Hostinger"
:tokens="$available_tokens" />
<p class="text-[11px] text-neutral-500 dark:text-fg-faint">
New to Hostinger?
<a href="https://www.hostinger.com/vps/coolify-hosting?ref=coolify.io" target="_blank"
rel="noopener noreferrer"
class="font-medium text-coollabs hover:underline dark:text-warning">Create an account</a>
through Coolify's affiliate link.
</p>
</div>
@elseif ($current_step === 2)
<div wire:init="loadHostingerData">
@if ($loading_data)
<x-application.settings-section title="Loading Hostinger"
description="Fetching data centers, plans, billing periods, and operating systems.">
<div class="flex min-h-40 items-center justify-center">
<x-loading text="Loading Hostinger data..." />
</div>
</x-application.settings-section>
@elseif ($provider_data_error)
<x-application.settings-section title="Unable to load Hostinger"
description="The selected token could not access the provider API.">
<x-callout type="error" title="Provider request failed">
<pre class="mt-2 whitespace-pre-wrap break-words text-[11px]">{{ $provider_data_error }}</pre>
</x-callout>
<div class="mt-4">
<a class="button" href="{{ route('server.create.type', ['type' => 'hostinger']) }}"
{{ wireNavigate() }}>Select another token</a>
</div>
</x-application.settings-section>
@else
@php
$dataCenterOptions = collect($data_centers)->map(fn ($dataCenter) => [
'value' => $dataCenter['id'],
'label' => ($dataCenter['city'] ?? $dataCenter['name'] ?? $dataCenter['id'])
. (!empty($dataCenter['location']) ? ' · ' . strtoupper($dataCenter['location']) : ''),
])->values()->all();
$priceOptions = collect($this->priceOptions)->map(fn ($price) => [
'value' => $price['id'],
'label' => $this->priceLabel($price),
])->values()->all();
$templateOption = fn ($template) => [
'value' => $template['id'],
'label' => $template['name'] ?? $template['description'] ?? $template['id'],
];
$osTemplateOptions = collect($this->osTemplates)->map($templateOption)->values()->all();
$appTemplateOptions = collect($this->appTemplates)->map($templateOption)->values()->all();
$privateKeyOptions = $private_keys->map(fn ($key) => [
'value' => $key->id,
'label' => $key->name,
])->values()->all();
$postInstallScriptOptions = collect($post_install_scripts)->map(fn ($script) => [
'value' => $script['id'],
'label' => $script['name'],
])->values()->all();
@endphp
<form wire:submit="submit" class="flex flex-col gap-6">
<x-application.settings-section title="Hostinger server"
description="Choose the data center, plan, operating system, and Coolify SSH key.">
<x-slot:actions>
<button type="submit" class="button button-highlighted"
wire:loading.attr="disabled" wire:target="submit"
@disabled(!$private_key_id)>
Buy and create
<x-loading-on-button wire:loading wire:target="submit" />
</button>
</x-slot:actions>
<div class="grid gap-4 lg:grid-cols-2">
<div class="lg:col-span-2">
<x-forms.input id="server_name" label="Server name"
helper="A friendly name shown in Coolify and used as the VPS hostname." />
</div>
<x-forms.listbox id="selected_data_center_id" label="Data center" required live
placeholder="Select a data center" :options="$dataCenterOptions" />
<x-forms.listbox id="selected_price_id" label="Plan and billing period" required live
:disabled="!$selected_data_center_id" placeholder="Select a plan"
:options="$priceOptions" />
<x-forms.listbox id="selected_os_template_id" label="Operating system" required live
:disabled="!$selected_price_id" placeholder="Select an operating system"
:options="$osTemplateOptions" />
<x-forms.listbox id="selected_app_template_id" label="Operating system with application"
live :disabled="!$selected_price_id" placeholder="Or select an operating system with an application"
:options="$appTemplateOptions"
helper="Replaces the operating system selected above. Applications and panels, such as another Coolify or CyberPanel, can conflict with the Coolify proxy on ports 80 and 443." />
@if ($private_keys->isEmpty())
<div>
<label class="mb-1.5 flex w-fit items-center gap-1.5">Private key
<x-highlighted text="*" />
</label>
<div
class="flex min-h-8 items-center justify-between gap-3 rounded-lg border border-warning/30 bg-warning/5 px-3 py-2">
<span class="text-[11px] text-neutral-600 dark:text-fg-dim">A private key is required.</span>
<x-modal-input title="New Private Key">
<x-slot:content>
<button type="button" class="button">Create key</button>
</x-slot:content>
<livewire:security.private-key.create :modal_mode="true" from="server" />
</x-modal-input>
</div>
</div>
@else
<x-forms.listbox id="private_key_id" label="Private key" required
placeholder="Select a private key" :options="$privateKeyOptions"
helper="This key is added to the Hostinger VPS automatically." />
@endif
</div>
</x-application.settings-section>
<x-application.settings-section title="Advanced options"
description="Provider backups, account SSH keys, and post-install automation.">
<div class="flex flex-col gap-4">
<div class="-mx-2.5 grid gap-3 lg:grid-cols-2">
<x-forms.checkbox id="enable_backups" label="Enable weekly Hostinger backups (extra cost)" fullWidth
helper="Hostinger charges extra for backups. Check the price in hPanel before you enable them." />
</div>
<x-forms.datalist label="Extra SSH keys" id="selected_public_key_ids"
helper="Existing keys from the Hostinger account." :multiple="true"
:disabled="count($hostinger_public_keys) === 0"
:placeholder="count($hostinger_public_keys) ? 'Search SSH keys' : 'No account keys found'">
@foreach ($hostinger_public_keys as $publicKey)
<option value="{{ $publicKey['id'] }}">{{ $publicKey['name'] }}</option>
@endforeach
</x-forms.datalist>
@if ($post_install_scripts)
<x-forms.listbox id="selected_post_install_script_id"
label="Hostinger post-install script"
placeholder="No post-install script" :options="$postInstallScriptOptions"
helper="Hostinger runs this as /post_install after setup and writes output to /post_install.log." />
@endif
<x-callout type="warning" title="This purchase is billed by Hostinger">
The VPS is purchased with your Hostinger account's default payment method. Review the
selected plan and billing period before continuing.
</x-callout>
</div>
</x-application.settings-section>
</form>
@endif
</div>
@endif
</div>
@@ -69,7 +69,7 @@
['value' => true, 'label' => 'Labels for the active proxy only'],
]" />
<x-forms.listbox id="redirectEnabled" label="Unknown requests"
helper="Override the default 503 response for unknown hosts and stopped services."
helper="Control the 503 response for unknown hosts and stopped services. Custom request handling shows an error page or redirects to a URL."
onChange="instantSaveRedirect" :options="[
['value' => false, 'label' => 'Return the default 503 response'],
['value' => true, 'label' => 'Use custom request handling'],
@@ -78,9 +78,23 @@
<x-forms.input canGate="update" :canResource="$server"
placeholder="https://app.coolify.io" id="redirectUrl"
label="Redirect URL"
helper="Leave empty to keep a custom 503 response without redirecting." />
helper="Leave empty to return a 503 response instead of redirecting." />
@endif
</div>
@if ($redirectEnabled && ! $server->isSwarm())
<div class="mt-4 flex flex-col gap-2">
<x-forms.textarea canGate="update" :canResource="$server" id="customErrorPage" rows="10"
monospace label="Custom error page (HTML)"
placeholder="Leave empty to use the Coolify default error page."
helper="One self-contained HTML file (max {{ \App\Models\Server::PROXY_ERROR_PAGE_MAX_BYTES / 1024 }} KB) with inline CSS and images. Visitors see it with a 503 status for unknown domains and for stopped, deploying or unhealthy resources. A Redirect URL takes priority over this page." />
@can('update', $server)
<div class="flex gap-2">
<x-forms.button type="button" wire:click="resetCustomErrorPage">Reset to
default</x-forms.button>
</div>
@endcan
</div>
@endif
</x-application.settings-section>
@php
+32 -4
View File
@@ -1,5 +1,5 @@
<div x-data
x-init="@if ($server->hetzner_server_id && $server->cloudProviderToken && !$hetznerServerStatus) $wire.checkHetznerServerStatus(); @endif @if ($server->vultr_instance_id && $server->cloudProviderToken) $wire.checkVultrInstanceStatus(); @endif @if ($server->digitalocean_droplet_id && $server->cloudProviderToken && !$digitalOceanDropletStatus) $wire.checkDigitalOceanDropletStatus(); @endif">
x-init="@if ($server->hetzner_server_id && $server->cloudProviderToken && !$hetznerServerStatus) $wire.checkHetznerServerStatus(); @endif @if ($server->vultr_instance_id && $server->cloudProviderToken) $wire.checkVultrInstanceStatus(); @endif @if ($server->digitalocean_droplet_id && $server->cloudProviderToken && !$digitalOceanDropletStatus) $wire.checkDigitalOceanDropletStatus(); @endif @if ($server->hostinger_virtual_machine_id && $server->cloudProviderToken && !$hostingerVirtualMachineStatus) $wire.checkHostingerVirtualMachineStatus(); @endif">
<x-slot:title>
{{ data_get_str($server, 'name')->limit(24) }} | Server | Coolify
</x-slot>
@@ -18,23 +18,26 @@
filled($server->hetzner_server_id) => 'Hetzner',
filled($server->digitalocean_droplet_id) => 'DigitalOcean',
filled($server->vultr_instance_id) => 'Vultr',
filled($server->hostinger_virtual_machine_id) => 'Hostinger',
default => null,
};
$providerStatus = match ($provider) {
'Hetzner' => $hetznerServerStatus,
'DigitalOcean' => $digitalOceanDropletStatus,
'Vultr' => $vultrInstanceStatus,
'Hostinger' => $hostingerVirtualMachineStatus,
default => null,
};
$providerStatusType = match (true) {
in_array($providerStatus, ['running', 'active']) => 'success',
in_array($providerStatus, ['starting', 'initializing', 'pending', 'new']) => 'warning',
in_array($providerStatus, ['off', 'stopped', 'suspended', 'archive', 'deleted']) => 'error',
in_array($providerStatus, ['starting', 'initializing', 'pending', 'new', 'initial', 'creating', 'unsuspending', 'recreating', 'restoring']) => 'warning',
in_array($providerStatus, ['off', 'stopped', 'suspended', 'archive', 'deleted', 'destroyed', 'error']) => 'error',
default => 'neutral',
};
$hasLinkableCloudProviders = (!$server->hetzner_server_id && $availableHetznerTokens->isNotEmpty())
|| (!$server->vultr_instance_id && $availableVultrTokens->isNotEmpty())
|| (!$server->digitalocean_droplet_id && $availableDigitalOceanTokens->isNotEmpty());
|| (!$server->digitalocean_droplet_id && $availableDigitalOceanTokens->isNotEmpty())
|| (!$server->hostinger_virtual_machine_id && $availableHostingerTokens->isNotEmpty());
$hetznerMatch = $matchedHetznerServer
? [
'name' => $matchedHetznerServer['name'] ?? 'Hetzner server',
@@ -56,6 +59,13 @@
'status' => $matchedVultrInstance['status'] ?? null,
]
: null;
$hostingerMatch = $matchedHostingerVirtualMachine
? [
'name' => $matchedHostingerVirtualMachine['hostname'] ?? 'Hostinger VPS',
'id' => $matchedHostingerVirtualMachine['id'] ?? null,
'status' => $matchedHostingerVirtualMachine['state'] ?? null,
]
: null;
@endphp
<form wire:submit.prevent="submit" class="application-settings-form flex flex-col gap-6">
@@ -107,6 +117,18 @@
:searchError="$vultrSearchError" :noMatch="$vultrNoMatchFound"
:matched="$vultrMatch" />
@endif
@if (!$server->hostinger_virtual_machine_id && $availableHostingerTokens->isNotEmpty())
<x-server.provider-link-modal :server="$server" provider="hostinger"
providerLabel="Hostinger" tokenModel="selectedHostingerTokenId"
:tokens="$availableHostingerTokens"
manualModel="manualHostingerVirtualMachineId"
manualLabel="Virtual Machine ID" manualPlaceholder="17923"
searchByIdMethod="searchHostingerVirtualMachineById"
searchByIpMethod="searchHostingerVirtualMachine"
linkMethod="linkToHostinger"
:searchError="$hostingerSearchError" :noMatch="$hostingerNoMatchFound"
:matched="$hostingerMatch" />
@endif
</div>
</div>
@endif
@@ -272,6 +294,12 @@
title="Refresh provider status">
<x-reicon name="refresh" class="size-3.5" />
</x-forms.button>
@elseif ($provider === 'Hostinger')
<x-forms.button type="button" class="size-8! px-0!"
wire:click.prevent="checkHostingerVirtualMachineStatus(true)"
title="Refresh provider status">
<x-reicon name="refresh" class="size-3.5" />
</x-forms.button>
@endif
@if ($server->cloudProviderToken)
@if ($provider === 'Hetzner' && !$server->isFunctional() && $hetznerServerStatus === 'off')
@@ -0,0 +1,18 @@
@extends('proxy.layout')
@section('title', 'Site not available')
@section('content')
<p class="status">503 Service Unavailable</p>
<h1>This site is not available at the moment</h1>
<p><span id="host">This site</span> cannot answer your request right now. Please try again later.</p>
<section>
<h2>Are you the site owner?</h2>
<ul>
<li>The domain is not configured on a resource in Coolify, or its DNS points to another server.</li>
<li>The application is stopped, still deploying, or has crashed.</li>
<li>The application health check is failing.</li>
</ul>
<a href="https://coolify.io/docs/troubleshoot/applications/no-available-server" rel="noopener noreferrer">Read the troubleshooting guide</a>
</section>
@endsection
+116
View File
@@ -0,0 +1,116 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex">
<title>@yield('title')</title>
<style>
:root {
color-scheme: light dark;
--canvas: #fafafa;
--card: #ffffff;
--hairline: #d4d4d4;
--text: #171717;
--subtle: #737373;
--accent: #6b16ed;
}
@media (prefers-color-scheme: dark) {
:root {
--canvas: #0a0a0a;
--card: #1c1c1c;
--hairline: #333333;
--text: #f5f5f5;
--subtle: #a3a3a3;
--accent: #fcd452;
}
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
background: var(--canvas);
color: var(--text);
font: 14px/1.6 'Geist Sans', Inter, ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif;
-webkit-font-smoothing: antialiased;
}
main {
width: 100%;
max-width: 560px;
padding: 32px;
background: var(--card);
border-radius: 12px;
box-shadow: 0 0 0 1px var(--hairline), 0 1px 2px rgb(0 0 0 / 0.05);
}
.status {
margin: 0 0 8px;
color: var(--subtle);
font: 12px/1.4 'Geist Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
h1 {
margin: 0 0 8px;
font-size: 20px;
line-height: 1.3;
font-weight: 600;
}
h2 {
margin: 0 0 8px;
font-size: 14px;
font-weight: 600;
}
p {
margin: 0;
color: var(--subtle);
}
section {
margin-top: 24px;
padding-top: 24px;
border-top: 1px solid var(--hairline);
}
ul {
margin: 0 0 16px;
padding-left: 20px;
color: var(--subtle);
}
li + li {
margin-top: 4px;
}
a {
color: var(--accent);
font-weight: 500;
}
#host {
color: var(--text);
font-weight: 500;
word-break: break-all;
}
</style>
</head>
<body>
<main>
@yield('content')
</main>
<script>
document.getElementById('host').textContent = location.hostname || 'This site';
</script>
</body>
</html>
@@ -0,0 +1,9 @@
@extends('proxy.layout')
@section('title', 'Under maintenance')
@section('content')
<p class="status">503 Service Unavailable</p>
<h1>We are doing some maintenance</h1>
<p><span id="host">This site</span> is temporarily unavailable for maintenance. Please check back soon.</p>
@endsection
+8
View File
@@ -13,6 +13,7 @@ use App\Http\Controllers\Api\DigitalOceanController;
use App\Http\Controllers\Api\GithubController;
use App\Http\Controllers\Api\GitlabController;
use App\Http\Controllers\Api\HetznerController;
use App\Http\Controllers\Api\HostingerController;
use App\Http\Controllers\Api\InstanceEmailSettingsController;
use App\Http\Controllers\Api\IntegrationTokensController;
use App\Http\Controllers\Api\NotificationsController;
@@ -232,6 +233,13 @@ Route::group([
Route::get('/digitalocean/ssh-keys', [DigitalOceanController::class, 'sshKeys'])->middleware(['api.ability:read']);
Route::post('/servers/digitalocean', [DigitalOceanController::class, 'createServer'])->middleware(['api.ability:write']);
Route::get('/hostinger/data-centers', [HostingerController::class, 'dataCenters'])->middleware(['api.ability:read']);
Route::get('/hostinger/catalog', [HostingerController::class, 'catalog'])->middleware(['api.ability:read']);
Route::get('/hostinger/templates', [HostingerController::class, 'templates'])->middleware(['api.ability:read']);
Route::get('/hostinger/ssh-keys', [HostingerController::class, 'sshKeys'])->middleware(['api.ability:read']);
Route::get('/hostinger/post-install-scripts', [HostingerController::class, 'postInstallScripts'])->middleware(['api.ability:read']);
Route::post('/servers/hostinger', [HostingerController::class, 'createServer'])->middleware(['api.ability:write']);
Route::get('/resources', [ResourcesController::class, 'resources'])->middleware(['api.ability:read']);
Route::get('/tags', [TagsController::class, 'tags'])->middleware(['api.ability:read']);
+2 -2
View File
@@ -377,8 +377,8 @@ Route::middleware(['auth', 'verified'])->group(function () {
Route::get('/registries', ServerDockerRegistries::class)->name('registries.index');
Route::get('/servers/import', ServerTransferImport::class)->name('server.transfer.import')->middleware('can:create,'.Server::class);
Route::get('/servers/new', ServerCreatePage::class)->name('server.create')->middleware('can:create,'.Server::class);
Route::get('/servers/new/{type}/{token_uuid}', ServerCreatePage::class)->name('server.create.token')->middleware('can:create,'.Server::class)->whereIn('type', ['hetzner', 'vultr', 'digital-ocean']);
Route::get('/servers/new/{type}', ServerCreatePage::class)->name('server.create.type')->middleware('can:create,'.Server::class)->whereIn('type', ['hetzner', 'vultr', 'digital-ocean', 'manual']);
Route::get('/servers/new/{type}/{token_uuid}', ServerCreatePage::class)->name('server.create.token')->middleware('can:create,'.Server::class)->whereIn('type', ['hetzner', 'vultr', 'digital-ocean', 'hostinger']);
Route::get('/servers/new/{type}', ServerCreatePage::class)->name('server.create.type')->middleware('can:create,'.Server::class)->whereIn('type', ['hetzner', 'vultr', 'digital-ocean', 'hostinger', 'manual']);
Route::prefix('server/{server_uuid}')->group(function () {
Route::get('/', ServerShow::class)->name('server.show');
+4 -3
View File
@@ -382,9 +382,10 @@ start_backend() {
local host="${DEV_BIND_ADDRESS:-127.0.0.1}"
[[ "$host" == 0.0.0.0 ]] && host=127.0.0.1
echo "Using KVM profile: $profile"
# The host command must reach this instance's database and encrypt with this instance's
# APP_KEY, not the ones in .env, and must not create root-owned files in storage/logs.
LOG_CHANNEL=stderr APP_KEY="$(env_value "$instance" APP_KEY)" \
# Use this instance database and encryption key, without a stale environment or config cache.
# Send logs to stderr to avoid root-owned files in storage/logs.
APP_ENV=local APP_CONFIG_CACHE="$ROOT/bootstrap/cache/dev-qemu-$$.php" \
LOG_CHANNEL=stderr APP_KEY="$(env_value "$instance" APP_KEY)" \
DB_HOST="$host" DB_PORT="$(env_value "$instance" FORWARD_DB_PORT)" \
REDIS_HOST="$host" REDIS_PORT="$(env_value "$instance" FORWARD_REDIS_PORT)" \
DEVELOPMENT_QEMU_INSTANCE="$instance" \
@@ -295,6 +295,35 @@ test('deployment parsing keeps commas in raw custom label values', function () {
->and($this->application->fresh()->custom_labels)->toBe(base64_encode($labels));
});
test('http basic auth hash settings are saved and applied to managed labels', function () {
$this->application->update([
'fqdn' => 'https://app.example.com',
'is_http_basic_auth_enabled' => true,
'http_basic_auth_username' => 'api-user',
'http_basic_auth_password' => 'api-password',
]);
$this->withHeaders(applicationSettingsApiHeaders($this->bearerToken))
->patchJson("/api/v1/applications/{$this->application->uuid}", ['http_basic_auth_bcrypt_cost' => 4])
->assertOk();
expect($this->application->fresh()->parseContainerLabels())->toContain('.basicauth.users=api-user:$2y$04$');
});
test('rejects http basic auth settings that are not supported', function (string $field, mixed $value) {
$this->withHeaders(applicationSettingsApiHeaders($this->bearerToken))
->patchJson("/api/v1/applications/{$this->application->uuid}", [$field => $value])
->assertUnprocessable()
->assertJsonValidationErrors($field);
})->with([
'Argon2id on a proxy without it' => ['http_basic_auth_hash_algorithm', 'argon2id'],
'unknown algorithm' => ['http_basic_auth_hash_algorithm', 'md5'],
'bcrypt cost above 14' => ['http_basic_auth_bcrypt_cost', 15],
'Argon2id memory above 256 MiB' => ['http_basic_auth_argon2id_memory_cost', 262145],
'Argon2id iterations above 12' => ['http_basic_auth_argon2id_time_cost', 13],
'password longer than 72 characters' => ['http_basic_auth_password', str_repeat('a', 73)],
]);
test('rejects invalid boolean application settings', function () {
$this->withHeaders(applicationSettingsApiHeaders($this->bearerToken))
->patchJson("/api/v1/applications/{$this->application->uuid}", [
@@ -1372,7 +1372,7 @@ it('builds railpack variables from generic buildtime vars railpack vars and cool
expect($variables->get('APP_ENV'))->toBe('production');
expect($variables->get('RAILPACK_NODE_VERSION'))->toBe('20');
expect($variables->get('RAILPACK_INSTALL_CMD'))->toBe('pnpm install --frozen-lockfile');
expect($variables->get('RAILPACK_DEPLOY_APT_PACKAGES'))->toBe('curl wget');
expect($variables->get('RAILPACK_DEPLOY_APT_PACKAGES'))->toBe('... curl wget');
expect($variables->get('COOLIFY_RESOURCE_UUID'))->toBe($application->uuid);
expect($variables->has('NIXPACKS_NODE_VERSION'))->toBeFalse();
expect($variables->has('RUNTIME_ONLY'))->toBeFalse();
@@ -1424,7 +1424,7 @@ it('builds preview railpack variables without leaking stale nixpacks vars', func
expect($variables->get('PREVIEW_BUILD_FLAG'))->toBe('enabled');
expect($variables->get('RAILPACK_NODE_VERSION'))->toBe('20');
expect($variables->get('RAILPACK_DEPLOY_APT_PACKAGES'))->toBe('curl wget');
expect($variables->get('RAILPACK_DEPLOY_APT_PACKAGES'))->toBe('... curl wget');
expect($variables->get('COOLIFY_RESOURCE_UUID'))->toBe($application->uuid);
expect($variables->has('NIXPACKS_NODE_VERSION'))->toBeFalse();
expect($variables->has('PREVIEW_RUNTIME_ONLY'))->toBeFalse();
@@ -1,5 +1,6 @@
<?php
use App\Enums\ProxyTypes;
use App\Livewire\Project\Application\General;
use App\Models\Application;
use App\Models\Environment;
@@ -16,6 +17,7 @@ use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
Server::flushIdentityMap();
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
@@ -47,9 +49,12 @@ uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA==
?? StandaloneDocker::factory()->create(['server_id' => $this->server->id, 'network' => 'coolify-test']);
});
afterEach(fn () => Server::flushIdentityMap());
function createApplicationForHttpBasicAuth(array $overrides = []): Application
{
return Application::factory()->create(array_merge([
'name' => 'Basic Auth App',
'environment_id' => test()->environment->id,
'destination_id' => test()->destination->id,
'destination_type' => StandaloneDocker::class,
@@ -112,3 +117,68 @@ test('disabling http basic auth is saved instantly', function () {
expect((bool) $application->is_http_basic_auth_enabled)->toBeFalse();
});
function useCaddyProxyForHttpBasicAuth(string $image): void
{
test()->server->forceFill(['proxy' => [
'type' => ProxyTypes::CADDY->value,
'status' => 'running',
'last_saved_settings' => 'applied',
'last_applied_settings' => 'applied',
'last_saved_proxy_configuration' => "services:\n caddy:\n image: '{$image}'\n",
]])->save();
}
test('changing the bcrypt cost factor is saved instantly and applied to the proxy labels', function () {
$application = createApplicationForHttpBasicAuth(['fqdn' => 'https://app.example.com']);
Livewire::test(General::class, ['application' => $application])
->set('httpBasicAuthBcryptCost', 4)
->call('instantSave')
->assertDispatched('success');
$application->refresh();
expect($application->http_basic_auth_bcrypt_cost)->toBe(4)
->and(base64_decode($application->custom_labels))->toContain('.basicauth.users=admin:$2y$04$');
});
test('selecting Argon2id is saved instantly and applied to the Caddy labels', function () {
useCaddyProxyForHttpBasicAuth('lucaslorentz/caddy-docker-proxy:2.13-alpine');
$application = createApplicationForHttpBasicAuth(['fqdn' => 'https://app.example.com']);
Livewire::test(General::class, ['application' => $application])
->set('httpBasicAuthHashAlgorithm', 'argon2id')
->set('httpBasicAuthArgon2idMemoryCost', 8192)
->call('instantSave')
->assertDispatched('success')
->assertSee('Iterations')
->assertDontSee('Cost factor');
expect(base64_decode($application->refresh()->custom_labels))
->toContain('caddy_0.basic_auth=argon2id', 'caddy_0.basic_auth.admin="$argon2id$v=19$m=8192,t=4,p=1$');
});
test('http basic auth form explains why Argon2id cannot be selected', function (?string $image, string $reason) {
if ($image !== null) {
useCaddyProxyForHttpBasicAuth($image);
}
Livewire::test(General::class, ['application' => createApplicationForHttpBasicAuth()])
->assertSee($reason);
})->with([
'Traefik' => [null, 'Only supported by the Caddy proxy'],
'Caddy 2.10' => ['lucaslorentz/caddy-docker-proxy:2.10-alpine', 'Needs Caddy 2.11 or newer on this server'],
]);
test('an unsupported http basic auth setting is rejected', function (string $property, mixed $value) {
Livewire::test(General::class, ['application' => createApplicationForHttpBasicAuth()])
->set($property, $value)
->call('instantSave')
->assertDispatched('error')
->assertNotDispatched('success');
})->with([
'Argon2id on a proxy without it' => ['httpBasicAuthHashAlgorithm', 'argon2id'],
'bcrypt cost above 14' => ['httpBasicAuthBcryptCost', 15],
'password longer than 72 characters' => ['httpBasicAuthPassword', str_repeat('a', 73)],
]);
@@ -0,0 +1,84 @@
<?php
test('boarding utility actions are centered', function () {
$view = file_get_contents(resource_path('views/livewire/boarding/index.blade.php'));
expect($view)->toContain('class="mx-auto mt-6 flex w-full max-w-3xl flex-col items-center gap-3"');
});
test('server validation opens in the centered process dialog', function () {
$view = file_get_contents(resource_path('views/livewire/boarding/index.blade.php'));
expect($view)
->toContain('<x-process-dialog closeWithX size="xl">')
->toContain('@click="processDialogOpen = true"')
->not->toContain('<x-slide-over closeWithX fullScreen>');
});
test('DigitalOcean is available as an onboarding server provider', function () {
$view = file_get_contents(resource_path('views/livewire/boarding/index.blade.php'));
expect($view)
->toContain('<x-modal-input title="Connect a DigitalOcean Server" isFullWidth>')
->toContain('<x-digital-ocean-icon class="size-10 shrink-0" />')
->toContain('Deploy servers directly from your DigitalOcean account.')
->toContain('<livewire:server.new.by-digital-ocean :limit_reached="false" :from_onboarding="true" />');
});
test('Hostinger is available as an onboarding server provider', function () {
$view = file_get_contents(resource_path('views/livewire/boarding/index.blade.php'));
expect($view)
->toContain('<x-modal-input title="Connect a Hostinger Server" isFullWidth>')
->toContain('<x-hostinger-icon class="size-10 shrink-0 text-[#673de6]" />')
->toContain('Deploy servers directly from your Hostinger account.')
->toContain('<livewire:server.new.by-hostinger :limit_reached="false" :from_onboarding="true" />');
});
test('server type details are shown on the relevant cards instead of a technical details panel', function () {
$view = file_get_contents(resource_path('views/livewire/boarding/index.blade.php'));
expect($view)
->toContain('aria-label="About this machine"')
->toContain('aria-label="About remote servers"')
->toContain('Not recommended for production workloads due to resource contention.')
->toContain('Any SSH-accessible server, including cloud VPS, bare metal, and self-hosted infrastructure.')
->not->toContain('<x-highlighted text="Servers" />')
->not->toContain('<x-highlighted text="Localhost:" />')
->not->toContain('<x-highlighted text="Remote Server:" />');
});
test('server type cards use the standard card hover treatment', function () {
$view = file_get_contents(resource_path('views/livewire/boarding/index.blade.php'));
$hoverClasses = 'shadow-sm transition-all hover:-translate-y-px hover:border-neutral-300 hover:shadow-md';
preg_match_all('/min-h-36[^\"]*'.preg_quote($hoverClasses, '/').'/', $view, $matches);
expect($matches[0])->toHaveCount(6)
->and(substr_count($view, 'group relative cursor-pointer'))->toBe(6)
->and($view)->not->toContain('hover:border-coollabs/35 hover:bg-coollabs/[0.03]')
->and($view)->not->toContain('dark:hover:border-warning/25 dark:hover:bg-warning/[0.04]');
});
test('existing SSH key selection does not show a redundant value tooltip', function () {
$view = file_get_contents(resource_path('views/livewire/boarding/index.blade.php'));
expect($view)->toContain('label="Existing SSH key" :options="$privateKeyOptions" :tooltip="false"');
});
test('server connection step does not render a technical details panel', function () {
$view = file_get_contents(resource_path('views/livewire/boarding/index.blade.php'));
expect($view)
->not->toContain('<x-highlighted text="Connection Requirements:" />')
->not->toContain('<x-highlighted text="Hostname Resolution:" />')
->not->toContain('<x-highlighted text="User Permissions:" />');
});
test('project step does not render a technical details panel', function () {
$view = file_get_contents(resource_path('views/livewire/boarding/index.blade.php'));
expect($view)
->not->toContain('<x-highlighted text="Project Organization:" />')
->not->toContain('<x-highlighted text="Environments:" />')
->not->toContain('<x-highlighted text="Team Access:" />');
});
@@ -59,19 +59,66 @@ it('shows link cloud provider dropdown with available unlinked providers', funct
'name' => 'Test DigitalOcean Token',
]);
CloudProviderToken::query()->create([
'team_id' => $this->team->id,
'provider' => 'hostinger',
'token' => 'test-hostinger-token',
'name' => 'Test Hostinger Token',
]);
Livewire::test(Show::class, ['server_uuid' => $this->server->uuid])
->assertSee('Link provider')
->assertSee('Hetzner')
->assertSee('DigitalOcean')
->assertSee('Vultr')
->assertSee('Hostinger')
->assertSee('Hetzner Token')
->assertSee('DigitalOcean Token')
->assertSee('Vultr Token')
->assertSee('Hostinger Token')
->assertSee('Server ID')
->assertSee('Droplet ID')
->assertSee('Instance ID')
->assertSee('Virtual Machine ID')
->assertSee('Search by server IP')
->assertSee('Search');
->assertSee('Search ID');
});
it('links a manually added server to a Hostinger VPS by IP', function () {
$token = CloudProviderToken::query()->create([
'team_id' => $this->team->id,
'provider' => 'hostinger',
'token' => 'test-hostinger-token',
'name' => 'Test Hostinger Token',
]);
Http::fake([
'https://developers.hostinger.com/api/vps/v1/virtual-machines' => Http::response([
[
'id' => 17923,
'hostname' => 'hostinger.example.com',
'state' => 'running',
'ipv4' => [['address' => '1.2.3.4']],
],
]),
'https://developers.hostinger.com/api/vps/v1/virtual-machines/17923' => Http::response([
'id' => 17923,
'hostname' => 'hostinger.example.com',
'state' => 'running',
'ipv4' => [['address' => '1.2.3.4']],
]),
]);
Livewire::test(Show::class, ['server_uuid' => $this->server->uuid])
->set('selectedHostingerTokenId', $token->id)
->call('searchHostingerVirtualMachine')
->assertSet('matchedHostingerVirtualMachine.id', 17923)
->call('linkToHostinger')
->assertDispatched('success');
expect($this->server->fresh()->hostinger_virtual_machine_id)->toBe(17923)
->and($this->server->fresh()->hostinger_virtual_machine_status)->toBe('running')
->and($this->server->fresh()->cloud_provider_token_id)->toBe($token->id);
});
it('hides link cloud provider dropdown when no providers can be linked', function () {
@@ -49,6 +49,24 @@ test('server cloud provider token cards show token descriptions', function () {
->assertSee('Used for production servers in the EU region.');
});
test('server cloud provider token page supports Hostinger VPS servers', function () {
$server = Server::factory()->create([
'team_id' => $this->team->id,
'hostinger_virtual_machine_id' => 17923,
]);
CloudProviderToken::factory()->create([
'team_id' => $this->team->id,
'provider' => 'hostinger',
'name' => 'Production Hostinger',
]);
Livewire::test(Show::class, ['server_uuid' => $server->uuid])
->assertSet('provider', 'hostinger')
->assertSet('providerName', 'Hostinger')
->assertSee('Production Hostinger');
});
describe('after the user switches the session team', function () {
beforeEach(function () {
$this->withoutDefer();
+112
View File
@@ -0,0 +1,112 @@
<?php
use App\Livewire\Project\Shared\Danger;
use App\Models\AuditEvent;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\PrivateKey;
use App\Models\Project;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\StandaloneDragonfly;
use App\Models\StandaloneKeydb;
use App\Models\StandaloneRedis;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Process;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::forceCreate(['id' => 0]);
$this->withoutDefer();
});
function createFlushableDatabase(string $model, array $attributes = []): StandaloneRedis|StandaloneKeydb|StandaloneDragonfly
{
$team = Team::factory()->create();
$server = Server::factory()->create([
'team_id' => $team->id,
'private_key_id' => PrivateKey::factory()->create(['team_id' => $team->id])->id,
'user' => 'deploy',
]);
$destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail();
$project = Project::factory()->create(['team_id' => $team->id]);
return $model::create([
'name' => 'cache',
'image' => 'redis:7',
'environment_id' => Environment::factory()->create(['project_id' => $project->id])->id,
'destination_id' => $destination->id,
'destination_type' => $destination->getMorphClass(),
...$attributes,
]);
}
function actingAsFlushUser(Team $team, string $role): void
{
$user = User::factory()->create();
$team->members()->attach($user->id, ['role' => $role]);
test()->actingAs($user);
session(['currentTeam' => $team]);
}
it('flushes every key with the engine CLI', function (string $model, array $attributes, string $cli) {
$database = createFlushableDatabase($model, $attributes);
actingAsFlushUser($database->team(), 'owner');
Process::fake(['*' => Process::result(output: 'OK')]);
Livewire::test(Danger::class, ['resource' => $database])
->call('flush', 'password')
->assertReturned(true)
->assertDispatched('success');
Process::assertRan(fn ($process) => str_contains($process->command, "sudo bash -c 'docker exec {$database->uuid} sh -c '\\''REDISCLI_AUTH=\"\$REDIS_PASSWORD\" {$cli} FLUSHALL ASYNC'\\'''"));
expect(AuditEvent::query()->where('event', 'ui.database.flushed')->where('resource_uuid', $database->uuid)->exists())->toBeTrue();
})->with([
'redis' => [StandaloneRedis::class, [], 'redis-cli'],
'keydb' => [StandaloneKeydb::class, ['keydb_password' => 'secret'], 'keydb-cli'],
'dragonfly with tls' => [StandaloneDragonfly::class, ['dragonfly_password' => 'secret', 'enable_ssl' => true], 'redis-cli --tls --cacert /etc/dragonfly/certs/coolify-ca.crt --cert /etc/dragonfly/certs/server.crt --key /etc/dragonfly/certs/server.key'],
]);
it('reports an error when the database does not reply OK', function () {
$database = createFlushableDatabase(StandaloneRedis::class);
actingAsFlushUser($database->team(), 'owner');
Process::fake(['*' => Process::result(output: 'NOAUTH Authentication required.')]);
Livewire::test(Danger::class, ['resource' => $database])
->call('flush', 'password')
->assertReturned(false)
->assertDispatched('error', 'NOAUTH Authentication required.');
expect(AuditEvent::query()->where('event', 'ui.database.flushed')->exists())->toBeFalse();
});
it('rejects an incorrect password without flushing', function () {
$database = createFlushableDatabase(StandaloneRedis::class);
actingAsFlushUser($database->team(), 'owner');
Process::fake();
Livewire::test(Danger::class, ['resource' => $database])
->call('flush', 'wrong-password')
->assertReturned('The provided password is incorrect.');
Process::assertNothingRan();
});
it('forbids flushing without permission to manage the database', function (string $role, bool $sameTeam) {
$database = createFlushableDatabase(StandaloneRedis::class);
actingAsFlushUser($sameTeam ? $database->team() : Team::factory()->create(), $role);
Process::fake();
Livewire::test(Danger::class, ['resource' => $database])
->call('flush', 'password')
->assertDispatched('error');
Process::assertNothingRan();
})->with([
'team member' => ['member', true],
'admin of another team' => ['admin', false],
]);
@@ -179,3 +179,16 @@ test('cancelling after switching to another team still cleans up and starts the
assertCancelCleanupRanCorrectly($this->remoteCommands, $this->deployment);
expect($queued->fresh()->status)->toBe(ApplicationDeploymentStatus::IN_PROGRESS->value);
});
test('cancelling a Railpack deployment stops the idle Railpack builder on the build server', function (string $buildPack, bool $stopsBuilder) {
$this->application->update(['build_pack' => $buildPack]);
$this->actingAs($this->user);
Livewire::test(DeploymentNavbar::class, ['application_deployment_queue' => $this->deployment])
->call('cancel');
expect(cancelCleanupCommandsOn($this->remoteCommands, '203.0.113.20', 'docker buildx stop coolify-railpack') !== [])->toBe($stopsBuilder);
})->with([
'railpack' => ['railpack', true],
'nixpacks' => ['nixpacks', false],
]);
+24
View File
@@ -245,6 +245,30 @@ BASH);
->not->toContain('--profile testing-host up');
});
it('runs the host qemu command with a clean local environment', function () {
if (! function_exists('posix_geteuid') || posix_geteuid() !== 0 || ! file_exists('/dev/kvm') || filetype('/dev/kvm') !== 'char' || ! is_readable('/dev/kvm') || ! is_writable('/dev/kvm')) {
$this->markTestSkipped('KVM and root access are required for this launcher branch.');
}
file_put_contents($this->devRoot.'/bin/php', <<<'BASH'
#!/usr/bin/env bash
[[ "$APP_ENV" == local && "$APP_CONFIG_CACHE" == */bootstrap/cache/dev-qemu-*.php ]] || {
echo 'This command may only run in development mode.' >&2
exit 1
}
printf 'php %s ENV=%s CACHE=%s\n' "$*" "$APP_ENV" "$APP_CONFIG_CACHE" >> "$DEV_TEST_LOG"
BASH);
$process = runDevScript('wt', ['start', 'ubuntu-root'], [
'COOLIFY_DEV_SERVER_BACKEND' => 'auto',
'APP_ENV' => 'production',
'APP_CONFIG_CACHE' => '/tmp/stale-config.php',
]);
expect($process->isSuccessful())->toBeTrue($process->getErrorOutput())
->and(devScriptLog())->toContain('php artisan dev:qemu ubuntu-root --as-localhost ENV=local CACHE=');
});
it('starts a lima vm as localhost on macos and seeds its forwarded ssh port', function () {
($this->fakeLima)();
runDevScript('main', ['start']);
+273
View File
@@ -0,0 +1,273 @@
<?php
use App\Models\CloudProviderToken;
use App\Models\InstanceSettings;
use App\Models\PrivateKey;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Once;
uses(RefreshDatabase::class);
beforeEach(function () {
Http::preventStrayRequests();
InstanceSettings::query()->whereKey(0)->delete();
InstanceSettings::unguarded(fn () => InstanceSettings::query()->create([
'id' => 0,
'is_api_enabled' => true,
]));
Once::flush();
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
session(['currentTeam' => $this->team]);
$this->bearerToken = $this->user->createToken('test-token', ['*'])->plainTextToken;
$this->hostingerToken = CloudProviderToken::factory()->create([
'team_id' => $this->team->id,
'provider' => 'hostinger',
'token' => 'hostinger-api-token',
]);
$this->privateKey = PrivateKey::factory()->create(['team_id' => $this->team->id]);
});
it('gets Hostinger data centers', function () {
Http::fake([
'https://developers.hostinger.com/api/vps/v1/data-centers' => Http::response([
['id' => 19, 'city' => 'Amsterdam', 'location' => 'nl'],
]),
]);
$this->withToken($this->bearerToken)
->getJson('/api/v1/hostinger/data-centers?cloud_provider_token_id='.$this->hostingerToken->uuid)
->assertSuccessful()
->assertJsonFragment(['id' => 19, 'city' => 'Amsterdam']);
});
it('gets Hostinger SSH keys and post-install scripts', function () {
Http::fake([
'https://developers.hostinger.com/api/vps/v1/public-keys' => Http::response([
'data' => [['id' => 42, 'name' => 'Operations']],
]),
'https://developers.hostinger.com/api/vps/v1/post-install-scripts' => Http::response([
'data' => [['id' => 73, 'name' => 'Bootstrap Coolify']],
]),
]);
$query = '?cloud_provider_token_id='.$this->hostingerToken->uuid;
$this->withToken($this->bearerToken)
->getJson('/api/v1/hostinger/ssh-keys'.$query)
->assertSuccessful()
->assertJsonFragment(['id' => 42, 'name' => 'Operations']);
$this->withToken($this->bearerToken)
->getJson('/api/v1/hostinger/post-install-scripts'.$query)
->assertSuccessful()
->assertJsonFragment(['id' => 73, 'name' => 'Bootstrap Coolify']);
});
it('gets only Hostinger KVM plans through the API', function () {
Http::fake([
'https://developers.hostinger.com/api/billing/v1/catalog*' => Http::response([
['id' => 'hostingercom-vps-kvm1', 'name' => 'KVM 1', 'prices' => []],
['id' => 'hostingercom-vps-kvmminecraftalex', 'name' => 'Game Panel 1', 'prices' => []],
]),
]);
$this->withToken($this->bearerToken)
->getJson('/api/v1/hostinger/catalog?cloud_provider_token_id='.$this->hostingerToken->uuid)
->assertSuccessful()
->assertJsonCount(1)
->assertJsonFragment(['id' => 'hostingercom-vps-kvm1'])
->assertJsonMissing(['id' => 'hostingercom-vps-kvmminecraftalex']);
});
it('returns 403 with the Hostinger message when Hostinger denies a provider list', function (int $hostingerStatus) {
Http::fake([
'https://developers.hostinger.com/api/vps/v1/post-install-scripts' => Http::response([
'message' => '[VPS:2000] Unauthorized',
], $hostingerStatus),
]);
$this->withToken($this->bearerToken)
->getJson('/api/v1/hostinger/post-install-scripts?cloud_provider_token_id='.$this->hostingerToken->uuid)
->assertForbidden()
->assertExactJson(['message' => 'Hostinger denied access to post-install scripts: [VPS:2000] Unauthorized']);
})->with([401, 403]);
it('returns 429 when Hostinger rate limits a provider list', function () {
Http::fake([
'https://developers.hostinger.com/api/vps/v1/templates' => Http::response([
'message' => 'Too many requests.',
], 429, ['Retry-After' => '30']),
]);
$this->withToken($this->bearerToken)
->getJson('/api/v1/hostinger/templates?cloud_provider_token_id='.$this->hostingerToken->uuid)
->assertStatus(429)
->assertHeader('Retry-After', '30')
->assertExactJson(['message' => 'Rate limit exceeded. Please try again later.']);
});
it('returns a generic error when a Hostinger provider list fails', function () {
Http::fake([
'https://developers.hostinger.com/api/vps/v1/data-centers' => Http::response([
'message' => 'Invalid request',
], 422),
]);
$this->withToken($this->bearerToken)
->getJson('/api/v1/hostinger/data-centers?cloud_provider_token_id='.$this->hostingerToken->uuid)
->assertServerError()
->assertExactJson(['message' => 'Failed to fetch Hostinger data centers.']);
});
it('creates a Hostinger VPS server through the API', function () {
Http::fake([
'https://developers.hostinger.com/api/vps/v1/virtual-machines' => Http::response([
'order' => ['id' => 2957086, 'status' => 'completed'],
'virtual_machine' => [
'id' => 17923,
'state' => 'creating',
'ipv4' => [['address' => '203.0.113.10']],
],
]),
]);
$this->withToken($this->bearerToken)
->postJson('/api/v1/servers/hostinger', [
'cloud_provider_token_id' => $this->hostingerToken->uuid,
'item_id' => 'hostingercom-vps-kvm2-usd-1m',
'data_center_id' => 19,
'template_id' => 1130,
'name' => 'api-hostinger.example.com',
'private_key_uuid' => $this->privateKey->uuid,
'enable_backups' => true,
])
->assertCreated()
->assertJsonFragment([
'hostinger_virtual_machine_id' => 17923,
'ip' => '203.0.113.10',
]);
$this->assertDatabaseHas('servers', [
'team_id' => $this->team->id,
'hostinger_virtual_machine_id' => 17923,
'ip' => '203.0.113.10',
]);
Http::assertSent(fn ($request) => $request->method() === 'POST'
&& $request->url() === 'https://developers.hostinger.com/api/vps/v1/virtual-machines'
&& $request['setup']['enable_backups'] === true);
});
it('turns paid Hostinger backups off when the API request does not enable them', function () {
Http::fake([
'https://developers.hostinger.com/api/vps/v1/virtual-machines' => Http::response([
'virtual_machine' => [
'id' => 17923,
'state' => 'creating',
'ipv4' => [['address' => '203.0.113.10']],
],
]),
]);
$this->withToken($this->bearerToken)
->postJson('/api/v1/servers/hostinger', [
'cloud_provider_token_uuid' => $this->hostingerToken->uuid,
'item_id' => 'hostingercom-vps-kvm1-usd-1m',
'data_center_id' => 19,
'template_id' => 1130,
'private_key_uuid' => $this->privateKey->uuid,
])
->assertCreated();
Http::assertSent(fn ($request) => $request->method() === 'POST'
&& $request->url() === 'https://developers.hostinger.com/api/vps/v1/virtual-machines'
&& $request['setup']['enable_backups'] === false);
});
it('keeps a charged Hostinger VPS when the purchase returns an error through the API', function () {
$listRequests = 0;
Http::fake([
'https://developers.hostinger.com/api/vps/v1/virtual-machines/2008976/setup' => Http::response([
'id' => 2008976,
'state' => 'creating',
'ipv4' => [['address' => '72.61.180.163']],
]),
'https://developers.hostinger.com/api/vps/v1/virtual-machines' => function ($request) use (&$listRequests) {
if ($request->method() === 'POST') {
return Http::response(['message' => '[VPS:2004] Wrong hostname FQDN format'], 422);
}
return Http::response($listRequests++ === 0 ? [] : [['id' => 2008976, 'state' => 'initial']]);
},
]);
$this->withToken($this->bearerToken)
->postJson('/api/v1/servers/hostinger', [
'cloud_provider_token_id' => $this->hostingerToken->uuid,
'item_id' => 'hostingercom-vps-kvm1-usd-1m',
'data_center_id' => 19,
'template_id' => 1077,
'private_key_uuid' => $this->privateKey->uuid,
])
->assertCreated()
->assertJsonFragment([
'hostinger_virtual_machine_id' => 2008976,
'ip' => '72.61.180.163',
]);
$this->assertDatabaseHas('servers', [
'team_id' => $this->team->id,
'hostinger_virtual_machine_id' => 2008976,
'ip' => '72.61.180.163',
]);
});
it('reports a Hostinger order that is waiting for payment through the API', function () {
Http::fake([
'https://developers.hostinger.com/api/vps/v1/virtual-machines' => Http::response([
'id' => 2957086,
'status' => 'payment_initiated',
], 202),
]);
$this->withToken($this->bearerToken)
->postJson('/api/v1/servers/hostinger', [
'cloud_provider_token_id' => $this->hostingerToken->uuid,
'item_id' => 'hostingercom-vps-kvm2-usd-1m',
'data_center_id' => 19,
'template_id' => 1130,
'private_key_uuid' => $this->privateKey->uuid,
])
->assertStatus(202)
->assertJsonFragment(['message' => 'Hostinger order 2957086 is waiting for payment. Finish the VPS setup in hPanel.']);
$this->assertDatabaseMissing('servers', ['team_id' => $this->team->id]);
});
it('accepts Hostinger cloud provider tokens through the API', function () {
Http::fake([
'https://developers.hostinger.com/api/vps/v1/virtual-machines' => Http::response([]),
]);
$this->withToken($this->bearerToken)
->postJson('/api/v1/cloud-tokens', [
'provider' => 'hostinger',
'token' => 'another-hostinger-token',
'name' => 'Another Hostinger Token',
])
->assertCreated();
$this->assertDatabaseHas('cloud_provider_tokens', [
'team_id' => $this->team->id,
'provider' => 'hostinger',
'name' => 'Another Hostinger Token',
]);
});
@@ -0,0 +1,484 @@
<?php
use App\Actions\Server\DeleteServer;
use App\Livewire\Server\Create;
use App\Livewire\Server\CreatePage;
use App\Livewire\Server\Delete;
use App\Livewire\Server\New\ByHostinger;
use App\Livewire\Server\Show;
use App\Livewire\Server\ValidateAndInstall;
use App\Models\CloudProviderToken;
use App\Models\InstanceSettings;
use App\Models\PrivateKey;
use App\Models\Server;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Queue;
use Livewire\Livewire;
use Lorisleiva\Actions\Decorators\JobDecorator;
uses(RefreshDatabase::class);
beforeEach(function () {
Http::preventStrayRequests();
config([
'cache.default' => 'array',
'session.driver' => 'array',
]);
InstanceSettings::unguarded(fn () => InstanceSettings::query()->create([
'id' => 0,
]));
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
$this->privateKey = PrivateKey::factory()->create(['team_id' => $this->team->id]);
$this->token = CloudProviderToken::factory()->create([
'team_id' => $this->team->id,
'provider' => 'hostinger',
'token' => 'hostinger-token',
'name' => 'Production Hostinger',
]);
$this->actingAs($this->user);
session(['currentTeam' => $this->team]);
});
it('offers Hostinger as a server provider', function () {
Livewire::test(Create::class)
->assertSee('Hostinger')
->assertSee(route('server.create.type', ['type' => 'hostinger']), false);
Livewire::test(CreatePage::class, ['type' => 'hostinger'])
->assertSet('title', 'Hostinger')
->assertSet('tokenProvider', 'hostinger');
});
it('uses the current cloud provider UI and Hostinger affiliate link', function () {
Livewire::test(ByHostinger::class)
->assertSee('Hostinger account')
->assertSee('https://www.hostinger.com/vps/coolify-hosting?ref=coolify.io', false)
->assertSee("through Coolify's affiliate link.", false);
Livewire::test(ByHostinger::class, ['selectedTokenUuid' => $this->token->uuid])
->set('loading_data', false)
->assertSee('Hostinger server')
->assertSee('Advanced options')
->assertSee('Buy and create');
});
it('leaves paid Hostinger backups off by default', function () {
Livewire::test(ByHostinger::class, ['selectedTokenUuid' => $this->token->uuid])
->assertSet('enable_backups', false)
->set('loading_data', false)
->assertSee('Enable weekly Hostinger backups (extra cost)')
->assertSee('Hostinger charges extra for backups. Check the price in hPanel before you enable them.');
});
it('selects Hostinger account SSH keys with the same dropdown as Hetzner', function () {
Livewire::test(ByHostinger::class, ['selectedTokenUuid' => $this->token->uuid])
->set('loading_data', false)
->set('hostinger_public_keys', [['id' => 42, 'name' => '<b>Operations</b>']])
->assertSee('Extra SSH keys')
->assertSee('Existing keys from the Hostinger account.')
->assertSee('Search SSH keys')
->assertSeeHtml('<option value="42">&lt;b&gt;Operations&lt;/b&gt;</option>')
->assertDontSeeHtml('<b>Operations</b>')
->assertDontSeeHtml('id="hostinger-public-key-42-');
Livewire::test(ByHostinger::class, ['selectedTokenUuid' => $this->token->uuid])
->set('loading_data', false)
->set('hostinger_public_keys', [])
->assertSee('No account keys found');
});
it('purchases a Hostinger VPS and creates the linked Coolify server', function () {
Http::fake([
'https://developers.hostinger.com/api/vps/v1/data-centers' => Http::response([
['id' => 19, 'name' => 'nl-ams', 'city' => 'Amsterdam', 'location' => 'nl'],
]),
'https://developers.hostinger.com/api/vps/v1/templates' => Http::response([
['id' => 1130, 'name' => 'Ubuntu 24.04 LTS'],
]),
'https://developers.hostinger.com/api/billing/v1/catalog*' => Http::response([
[
'id' => 'hostingercom-vps-kvm2',
'name' => 'KVM 2',
'category' => 'VPS',
'prices' => [
[
'id' => 'hostingercom-vps-kvm2-usd-1m',
'currency' => 'USD',
'price' => 1799,
'first_period_price' => 899,
'period' => 1,
'period_unit' => 'month',
],
],
],
]),
'https://developers.hostinger.com/api/vps/v1/public-keys' => Http::response(['data' => []]),
'https://developers.hostinger.com/api/vps/v1/post-install-scripts' => Http::response(['data' => []]),
'https://developers.hostinger.com/api/vps/v1/virtual-machines' => Http::response([
'order' => ['id' => 2957086, 'status' => 'completed'],
'virtual_machine' => [
'id' => 17923,
'hostname' => 'coolify-hostinger.example.com',
'state' => 'creating',
'ipv4' => [['address' => '203.0.113.10']],
],
]),
]);
Livewire::test(ByHostinger::class, ['selectedTokenUuid' => $this->token->uuid])
->call('loadHostingerData')
->set('server_name', 'coolify-hostinger.example.com')
->set('selected_data_center_id', 19)
->set('selected_template_id', 1130)
->set('selected_price_id', 'hostingercom-vps-kvm2-usd-1m')
->set('private_key_id', $this->privateKey->id)
->set('enable_backups', true)
->call('submit')
->assertHasNoErrors();
$server = Server::query()->where('hostinger_virtual_machine_id', 17923)->firstOrFail();
expect($server->name)->toBe('coolify-hostinger.example.com')
->and($server->ip)->toBe('203.0.113.10')
->and($server->hostinger_virtual_machine_status)->toBe('creating')
->and($server->cloud_provider_token_id)->toBe($this->token->id);
Http::assertSent(fn ($request) => $request->method() === 'POST'
&& $request->url() === 'https://developers.hostinger.com/api/vps/v1/virtual-machines'
&& $request['item_id'] === 'hostingercom-vps-kvm2-usd-1m'
&& $request['setup']['hostname'] === 'coolify-hostinger.example.com'
&& $request['setup']['public_key']['key'] === $this->privateKey->getPublicKey());
});
it('keeps a purchased Hostinger VPS linked while its public IP is pending', function () {
Http::fake([
'https://developers.hostinger.com/api/vps/v1/data-centers' => Http::response([['id' => 19, 'name' => 'nl-ams', 'city' => null]]),
'https://developers.hostinger.com/api/vps/v1/templates' => Http::response([['id' => 1130, 'name' => 'Ubuntu']]),
'https://developers.hostinger.com/api/billing/v1/catalog*' => Http::response([
['id' => 'hostingercom-vps-kvm2', 'name' => 'KVM 2', 'prices' => [['id' => 'kvm2-monthly']]],
]),
'https://developers.hostinger.com/api/vps/v1/public-keys' => Http::response(['data' => []]),
'https://developers.hostinger.com/api/vps/v1/post-install-scripts' => Http::response(['data' => []]),
'https://developers.hostinger.com/api/vps/v1/virtual-machines' => Http::response([
'virtual_machine' => ['id' => 17923, 'state' => 'creating', 'ipv4' => []],
]),
'https://developers.hostinger.com/api/vps/v1/virtual-machines/17923' => Http::response([
'message' => 'Still provisioning',
], 503),
]);
Livewire::test(ByHostinger::class, ['selectedTokenUuid' => $this->token->uuid])
->call('loadHostingerData')
->set('server_name', 'pending-hostinger.example.com')
->set('selected_data_center_id', 19)
->set('selected_template_id', 1130)
->set('selected_price_id', 'kvm2-monthly')
->set('private_key_id', $this->privateKey->id)
->call('submit')
->assertHasNoErrors();
expect(Server::query()->where('hostinger_virtual_machine_id', 17923)->firstOrFail()->ip)
->toBe(Server::PLACEHOLDER_IP);
});
it('loads the Hostinger form when optional provider lists are not authorized', function () {
Http::fake([
'https://developers.hostinger.com/api/vps/v1/data-centers' => Http::response([['id' => 19, 'name' => 'nl-ams', 'city' => 'Amsterdam']]),
'https://developers.hostinger.com/api/vps/v1/templates' => Http::response([['id' => 1130, 'name' => 'Ubuntu']]),
'https://developers.hostinger.com/api/billing/v1/catalog*' => Http::response([
['id' => 'hostingercom-vps-kvm2', 'name' => 'KVM 2', 'prices' => [['id' => 'kvm2-monthly']]],
]),
'https://developers.hostinger.com/api/vps/v1/public-keys' => Http::response(['message' => '[VPS:2000] Unauthorized'], 403),
'https://developers.hostinger.com/api/vps/v1/post-install-scripts' => Http::response(['message' => '[VPS:2000] Unauthorized'], 403),
'https://developers.hostinger.com/api/vps/v1/virtual-machines' => Http::response([
'virtual_machine' => ['id' => 17923, 'state' => 'creating', 'ipv4' => [['address' => '203.0.113.10']]],
]),
]);
Livewire::test(ByHostinger::class, ['selectedTokenUuid' => $this->token->uuid])
->call('loadHostingerData')
->assertSet('provider_data_error', null)
->assertSet('hostinger_public_keys', [])
->assertSet('post_install_scripts', [])
->assertSee('Buy and create')
->set('server_name', 'optional-lists.example.com')
->set('selected_data_center_id', 19)
->set('selected_template_id', 1130)
->set('selected_price_id', 'kvm2-monthly')
->set('private_key_id', $this->privateKey->id)
->call('submit')
->assertHasNoErrors();
expect(Server::query()->where('hostinger_virtual_machine_id', 17923)->exists())->toBeTrue();
});
it('revalidates the selected Hostinger price before making a purchase', function () {
Http::fake([
'https://developers.hostinger.com/api/vps/v1/data-centers' => Http::response([
['id' => 19, 'name' => 'nl-ams', 'city' => 'Amsterdam'],
]),
'https://developers.hostinger.com/api/vps/v1/templates' => Http::response([
['id' => 1130, 'name' => 'Ubuntu 24.04 LTS'],
]),
'https://developers.hostinger.com/api/billing/v1/catalog*' => Http::response([
[
'id' => 'hostingercom-vps-kvm2',
'name' => 'KVM 2',
'prices' => [['id' => 'hostingercom-vps-kvm2-usd-1m']],
],
]),
'https://developers.hostinger.com/api/vps/v1/public-keys' => Http::response(['data' => []]),
'https://developers.hostinger.com/api/vps/v1/post-install-scripts' => Http::response(['data' => []]),
]);
Livewire::test(ByHostinger::class, ['selectedTokenUuid' => $this->token->uuid])
->call('loadHostingerData')
->set('server_name', 'coolify-hostinger.example.com')
->set('selected_data_center_id', 19)
->set('selected_template_id', 1130)
->set('selected_price_id', 'tampered-price-id')
->set('private_key_id', $this->privateKey->id)
->call('submit')
->assertHasErrors('selected_price_id');
Http::assertNotSent(fn ($request) => $request->method() === 'POST');
});
function fakeHostingerProvisioningOptions(array $overrides = []): void
{
$price = fn (string $plan, int $period, string $unit, string $suffix) => [
'id' => "hostingercom-vps-{$plan}-usd-{$suffix}",
'currency' => 'USD',
'price' => 1000,
'first_period_price' => 500,
'period' => $period,
'period_unit' => $unit,
];
$prices = fn (string $plan) => [
$price($plan, 2, 'year', '2y'),
$price($plan, 1, 'month', '1m'),
$price($plan, 1, 'year', '1y'),
];
Http::fake(array_merge([
'https://developers.hostinger.com/api/vps/v1/data-centers' => Http::response([
['id' => 19, 'name' => 'nl-ams', 'city' => 'Amsterdam', 'location' => 'nl'],
]),
'https://developers.hostinger.com/api/vps/v1/templates' => Http::response([
['id' => 1121, 'name' => 'AlmaLinux 9 with CyberPanel'],
['id' => 1130, 'name' => 'Ubuntu 24.04 LTS'],
['id' => 1077, 'name' => 'Debian 13'],
['id' => 1150, 'name' => 'Ubuntu 24.04 with Coolify'],
]),
'https://developers.hostinger.com/api/billing/v1/catalog*' => Http::response([
['id' => 'hostingercom-vps-kvm8', 'name' => 'KVM 8', 'metadata' => ['cpus' => 8], 'prices' => $prices('kvm8')],
['id' => 'hostingercom-vps-kvmminecraftalex', 'name' => 'Game Panel 1', 'metadata' => ['cpus' => 1], 'prices' => $prices('kvmminecraftalex')],
['id' => 'hostingercom-vps-kvm1', 'name' => 'KVM 1', 'metadata' => ['cpus' => 1], 'prices' => $prices('kvm1')],
['id' => 'hostingercom-vps-kvm2', 'name' => 'KVM 2', 'metadata' => ['cpus' => 2], 'prices' => $prices('kvm2')],
]),
'https://developers.hostinger.com/api/vps/v1/public-keys' => Http::response(['data' => []]),
'https://developers.hostinger.com/api/vps/v1/post-install-scripts' => Http::response(['data' => []]),
], $overrides));
}
it('lists only Hostinger KVM plans sorted by size and billing period', function () {
fakeHostingerProvisioningOptions();
$component = Livewire::test(ByHostinger::class, ['selectedTokenUuid' => $this->token->uuid])
->call('loadHostingerData');
expect(collect($component->instance()->priceOptions)->pluck('id')->all())->toBe([
'hostingercom-vps-kvm1-usd-1m',
'hostingercom-vps-kvm1-usd-1y',
'hostingercom-vps-kvm1-usd-2y',
'hostingercom-vps-kvm2-usd-1m',
'hostingercom-vps-kvm2-usd-1y',
'hostingercom-vps-kvm2-usd-2y',
'hostingercom-vps-kvm8-usd-1m',
'hostingercom-vps-kvm8-usd-1y',
'hostingercom-vps-kvm8-usd-2y',
]);
$component->assertDontSee('Game Panel');
});
it('splits Hostinger templates into plain operating systems and operating systems with applications', function () {
fakeHostingerProvisioningOptions();
$component = Livewire::test(ByHostinger::class, ['selectedTokenUuid' => $this->token->uuid])
->call('loadHostingerData')
->assertSet('selected_os_template_id', 1130)
->assertSet('selected_app_template_id', null)
->assertSet('selected_template_id', 1130)
->assertSee('Operating system with application')
->assertSee('can conflict with the Coolify proxy on ports 80 and 443', false);
expect(collect($component->instance()->osTemplates)->pluck('name')->all())
->toBe(['Debian 13', 'Ubuntu 24.04 LTS'])
->and(collect($component->instance()->appTemplates)->pluck('name')->all())
->toBe(['AlmaLinux 9 with CyberPanel', 'Ubuntu 24.04 with Coolify']);
});
it('keeps only one Hostinger operating system selection', function () {
fakeHostingerProvisioningOptions();
Livewire::test(ByHostinger::class, ['selectedTokenUuid' => $this->token->uuid])
->call('loadHostingerData')
->set('selected_app_template_id', 1150)
->assertSet('selected_os_template_id', null)
->assertSet('selected_template_id', 1150)
->set('selected_os_template_id', 1077)
->assertSet('selected_app_template_id', null)
->assertSet('selected_template_id', 1077);
});
it('purchases a Hostinger VPS with an operating system that includes an application', function () {
fakeHostingerProvisioningOptions([
'https://developers.hostinger.com/api/vps/v1/virtual-machines' => Http::response([
'virtual_machine' => ['id' => 17923, 'state' => 'creating', 'ipv4' => [['address' => '203.0.113.10']]],
]),
]);
Livewire::test(ByHostinger::class, ['selectedTokenUuid' => $this->token->uuid])
->call('loadHostingerData')
->set('server_name', 'app-template.example.com')
->set('selected_data_center_id', 19)
->set('selected_price_id', 'hostingercom-vps-kvm2-usd-1m')
->set('selected_app_template_id', 1150)
->set('private_key_id', $this->privateKey->id)
->call('submit')
->assertHasNoErrors();
Http::assertSent(fn ($request) => $request->method() === 'POST'
&& $request->url() === 'https://developers.hostinger.com/api/vps/v1/virtual-machines'
&& $request['setup']['template_id'] === 1150);
});
it('rejects a Game Panel Hostinger plan before making a purchase', function () {
fakeHostingerProvisioningOptions();
Livewire::test(ByHostinger::class, ['selectedTokenUuid' => $this->token->uuid])
->call('loadHostingerData')
->set('server_name', 'game-panel.example.com')
->set('selected_data_center_id', 19)
->set('selected_price_id', 'hostingercom-vps-kvmminecraftalex-usd-1m')
->set('private_key_id', $this->privateKey->id)
->call('submit')
->assertHasErrors('selected_price_id');
Http::assertNotSent(fn ($request) => $request->method() === 'POST');
});
it('starts a stopped Hostinger VPS from the server page', function () {
$server = Server::factory()->create([
'team_id' => $this->team->id,
'private_key_id' => $this->privateKey->id,
'cloud_provider_token_id' => $this->token->id,
'hostinger_virtual_machine_id' => 17923,
'hostinger_virtual_machine_status' => 'stopped',
]);
Http::fake([
'https://developers.hostinger.com/api/vps/v1/virtual-machines/17923/start' => Http::response([
'id' => 456,
'state' => 'starting',
]),
]);
Livewire::test(Show::class, ['server_uuid' => $server->uuid])
->call('startHostingerVirtualMachine')
->assertSet('hostingerVirtualMachineStatus', 'starting')
->assertDispatched('success');
expect($server->fresh()->hostinger_virtual_machine_status)->toBe('starting');
});
it('labels the Hostinger status refresh button on the server page', function () {
$server = Server::factory()->create([
'team_id' => $this->team->id,
'private_key_id' => $this->privateKey->id,
'cloud_provider_token_id' => $this->token->id,
'hostinger_virtual_machine_id' => 17923,
'hostinger_virtual_machine_status' => 'running',
]);
Livewire::test(Show::class, ['server_uuid' => $server->uuid])
->assertSee('Hostinger · Running')
->assertSee('Refresh status')
->assertSeeHtml('wire:click.prevent="checkHostingerVirtualMachineStatus(true)"');
});
it('does not validate a stopped Hostinger VPS over SSH', function () {
$server = Server::factory()->create([
'team_id' => $this->team->id,
'private_key_id' => $this->privateKey->id,
'cloud_provider_token_id' => $this->token->id,
'hostinger_virtual_machine_id' => 17923,
'hostinger_virtual_machine_status' => 'stopped',
]);
Http::fake([
'https://developers.hostinger.com/api/vps/v1/virtual-machines/17923' => Http::response([
'id' => 17923,
'state' => 'stopped',
'ipv4' => [['address' => $server->ip]],
]),
]);
Livewire::test(ValidateAndInstall::class, ['server' => $server])
->call('validateConnection')
->assertSet('error', 'Hostinger VPS is stopped. Power it on before validating.');
});
it('warns that deleting from Coolify does not cancel the Hostinger VPS', function () {
$server = Server::factory()->create([
'team_id' => $this->team->id,
'private_key_id' => $this->privateKey->id,
'cloud_provider_token_id' => $this->token->id,
'hostinger_virtual_machine_id' => 17923,
]);
Livewire::test(Delete::class, ['server_uuid' => $server->uuid])
->assertSee('Hostinger deletes the VPS only at the end of its paid period, and only if you select this in the confirmation dialog.')
->assertSee('https://hpanel.hostinger.com/', false)
->assertSeeHtml('<strong>Hostinger deletes a VPS only at the end of its paid period.</strong>')
->assertSee('The VPS on Hostinger will NOT be deleted, and Hostinger will keep billing for it.');
});
it('passes the Hostinger deletion choice to the server deletion', function () {
Queue::fake();
$server = Server::factory()->create([
'team_id' => $this->team->id,
'private_key_id' => $this->privateKey->id,
'cloud_provider_token_id' => $this->token->id,
'hostinger_virtual_machine_id' => 17923,
]);
Livewire::test(Delete::class, ['server_uuid' => $server->uuid])
->assertSee('Also delete VPS from Hostinger (at the end of the paid period)')
->call('delete', 'password', ['delete_from_hostinger']);
Queue::assertPushed(JobDecorator::class, fn (JobDecorator $job) => $job->getAction() instanceof DeleteServer
&& $job->getParameters()[9] === true
&& $job->getParameters()[10] === 17923);
});
it('keeps the default deletion warning for servers without a Hostinger VPS', function () {
$server = Server::factory()->create([
'team_id' => $this->team->id,
'private_key_id' => $this->privateKey->id,
]);
Livewire::test(Delete::class, ['server_uuid' => $server->uuid])
->assertSee('This operation is permanent and cannot be undone.')
->assertDontSee('Hostinger deletes a VPS');
});
@@ -87,6 +87,8 @@ it('creates Server with all fillable attributes', function () {
'hetzner_server_status' => 'running',
'digitalocean_droplet_id' => 987654,
'digitalocean_droplet_status' => 'active',
'hostinger_virtual_machine_id' => 17923,
'hostinger_virtual_machine_status' => 'running',
'is_validating' => false,
'detected_traefik_version' => 'v2.10.0',
'traefik_outdated_info' => 'Up to date',
@@ -106,6 +108,8 @@ it('creates Server with all fillable attributes', function () {
expect($server->hetzner_server_status)->toBe('running');
expect($server->digitalocean_droplet_id)->toBe(987654);
expect($server->digitalocean_droplet_status)->toBe('active');
expect($server->hostinger_virtual_machine_id)->toBe(17923);
expect($server->hostinger_virtual_machine_status)->toBe('running');
expect($server->ip_previous)->toBe('10.0.0.1');
});
@@ -106,3 +106,55 @@ it('emits the matching basic auth directive in application labels', function (st
'caddy-docker-proxy 2.13, preview, all proxies' => ['lucaslorentz/caddy-docker-proxy:2.13-alpine', false, true, 'basic_auth'],
'caddy-docker-proxy 2.13, preview, exact labels' => ['lucaslorentz/caddy-docker-proxy:2.13-alpine', true, true, 'basic_auth'],
]);
it('hashes the password in application labels with the selected settings', function (array $proxy, bool $exactLabels, bool $preview, array $hashSettings, array $expectedLabels) {
$team = Team::factory()->create();
$environment = Environment::factory()->create(['project_id' => Project::factory()->create(['team_id' => $team->id])->id]);
$server = Server::factory()->create(['team_id' => $team->id, 'proxy' => $proxy]);
$server->settings->update(['generate_exact_labels' => $exactLabels]);
$destination = StandaloneDocker::query()->where('server_id', $server->id)->firstOrFail();
$application = Application::factory()->createOne([
'environment_id' => $environment->id,
'destination_id' => $destination->id,
'destination_type' => $destination->getMorphClass(),
'fqdn' => 'https://example.com',
'is_http_basic_auth_enabled' => true,
'http_basic_auth_username' => 'admin',
'http_basic_auth_password' => 'secret',
...$hashSettings,
]);
Server::flushIdentityMap();
$applicationPreview = $preview
? (new ApplicationPreview)->forceFill(['pull_request_id' => 7, 'fqdn' => 'https://pr-7.example.com'])
: null;
$labels = collect(generateLabelsApplication($application->fresh(), $applicationPreview))->implode("\n");
expect($labels)->toContain(...$expectedLabels);
})->with(function () {
$caddy = basicAuthCaddyProxy('lucaslorentz/caddy-docker-proxy:2.13-alpine');
$traefik = basicAuthCaddyProxy('traefik:v3.7', ['type' => ProxyTypes::TRAEFIK->value]);
$bcrypt = ['http_basic_auth_bcrypt_cost' => 4];
$argon2id = ['http_basic_auth_hash_algorithm' => 'argon2id', 'http_basic_auth_argon2id_memory_cost' => 8192, 'http_basic_auth_argon2id_time_cost' => 2];
$caddyBcrypt = 'caddy_0.basic_auth.admin="$2y$04$';
$traefikBcrypt = '.basicauth.users=admin:$2y$04$';
return [
'bcrypt, all proxies' => [$caddy, false, false, $bcrypt, [$caddyBcrypt, $traefikBcrypt]],
'bcrypt, all proxies, preview' => [$caddy, false, true, $bcrypt, [$caddyBcrypt, $traefikBcrypt]],
'bcrypt, exact Caddy labels' => [$caddy, true, false, $bcrypt, [$caddyBcrypt]],
'bcrypt, exact Caddy labels, preview' => [$caddy, true, true, $bcrypt, [$caddyBcrypt]],
'bcrypt, exact Traefik labels' => [$traefik, true, false, $bcrypt, [$traefikBcrypt]],
'bcrypt, exact Traefik labels, preview' => [$traefik, true, true, $bcrypt, [$traefikBcrypt]],
'Argon2id for Caddy, bcrypt for Traefik' => [$caddy, false, false, $argon2id, [
'caddy_0.basic_auth=argon2id',
'caddy_0.basic_auth.admin="$argon2id$v=19$m=8192,t=2,p=1$',
'.basicauth.users=admin:$2y$10$',
]],
'bcrypt when Caddy is too old for the stored Argon2id' => [basicAuthCaddyProxy('lucaslorentz/caddy-docker-proxy:2.10-alpine'), false, false, $argon2id, [
'caddy_0.basic_auth.admin="$2y$10$',
'.basicauth.users=admin:$2y$10$',
]],
];
});
+289
View File
@@ -0,0 +1,289 @@
<?php
use App\Enums\ProxyTypes;
use App\Events\ProxyStatusChanged;
use App\Events\ProxyStatusChangedUI;
use App\Livewire\Project\Shared\Maintenance;
use App\Models\Application;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\PrivateKey;
use App\Models\Project;
use App\Models\Server;
use App\Models\Service;
use App\Models\ServiceApplication;
use App\Models\StandaloneDocker;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Process;
use Livewire\Livewire;
use Symfony\Component\Yaml\Yaml;
uses(RefreshDatabase::class);
// Servers stay in a static identity map; do not leak them into later test files.
afterEach(fn () => Server::flushIdentityMap());
beforeEach(function () {
Server::flushIdentityMap();
Event::fake([ProxyStatusChanged::class, ProxyStatusChangedUI::class]);
InstanceSettings::forceCreate(['id' => 0]);
config(['constants.ssh.mux_enabled' => false]);
fakeProxyFileUploads();
$this->team = Team::factory()->create();
$this->server = Server::factory()->create([
'team_id' => $this->team->id,
'user' => 'cooluser',
'private_key_id' => PrivateKey::factory()->create(['team_id' => $this->team->id])->id,
]);
$this->server->settings->update(['is_reachable' => true, 'is_usable' => true, 'force_disabled' => false]);
$this->server->proxy->type = ProxyTypes::TRAEFIK->value;
$this->server->save();
$this->destination = StandaloneDocker::where('server_id', $this->server->id)->first()
?? StandaloneDocker::factory()->create(['server_id' => $this->server->id]);
$project = Project::factory()->create(['team_id' => $this->team->id]);
$this->environment = Environment::factory()->create(['project_id' => $project->id]);
});
function maintenanceApplication(array $attributes = []): Application
{
$test = test();
return Application::factory()->create(array_merge([
'environment_id' => $test->environment->id,
'destination_id' => $test->destination->id,
'destination_type' => $test->destination->getMorphClass(),
'fqdn' => 'https://app.example.com',
], $attributes));
}
function maintenanceService(array $domains): Service
{
$test = test();
$service = Service::factory()->create([
'environment_id' => $test->environment->id,
'server_id' => $test->server->id,
'destination_id' => $test->destination->id,
'destination_type' => $test->destination->getMorphClass(),
]);
foreach ($domains as $name => $fqdn) {
ServiceApplication::create(['name' => $name, 'service_id' => $service->id, 'image' => 'nginx', 'fqdn' => $fqdn]);
}
return $service;
}
function maintenanceServer(): Server
{
return Server::find(test()->server->id);
}
/**
* @return array<int, string>
*/
function maintenanceScriptLines(): array
{
$lines = [];
Process::assertRan(function ($process) use (&$lines) {
array_push($lines, ...explode("\n", $process->command));
return true;
});
return $lines;
}
it('routes the maintenance domains through Traefik with a priority just above the resource router', function () {
$application = maintenanceApplication(['fqdn' => 'https://app.example.com,http://app.example.com/api', 'is_maintenance_enabled' => true]);
$config = Yaml::parse(maintenanceServer()->maintenanceTraefikConfiguration(collect([$application])));
$routers = $config['http']['routers'];
$rootRule = 'Host(`app.example.com`) && PathPrefix(`/`)';
$apiRule = 'Host(`app.example.com`) && PathPrefix(`/api`)';
expect($routers["coolify-maintenance-{$application->uuid}-0-http"])->toMatchArray([
'entryPoints' => ['http'],
'rule' => $rootRule,
'service' => 'coolify-maintenance-noop',
'middlewares' => ['coolify-maintenance-headers', "coolify-maintenance-{$application->uuid}"],
'priority' => strlen($rootRule) + 1,
])
->and($routers["coolify-maintenance-{$application->uuid}-0-https"]['tls'])->toBe(['certResolver' => 'letsencrypt'])
// http:// domains get no TLS router.
->and($routers)->not->toHaveKey("coolify-maintenance-{$application->uuid}-1-https")
->and($routers["coolify-maintenance-{$application->uuid}-1-http"]['priority'])->toBe(strlen($apiRule) + 1)
// A resource router on a longer path of the same host keeps a higher priority.
->and(strlen($apiRule))->toBeGreaterThan($routers["coolify-maintenance-{$application->uuid}-0-http"]['priority'])
->and($config['http']['middlewares']["coolify-maintenance-{$application->uuid}"]['errors'])->toBe([
'status' => ['503'],
'service' => 'coolify-maintenance-pages',
'query' => "/maintenance-{$application->uuid}.html",
])
->and($config['http']['middlewares']['coolify-maintenance-headers']['headers']['customResponseHeaders'])->toBe(['Retry-After' => '300'])
->and($config['http']['services']['coolify-maintenance-noop']['loadBalancer']['servers'])->toBe([])
->and($config['http']['services']['coolify-maintenance-pages']['loadBalancer']['servers'])->toBe([['url' => 'http://coolify-proxy-error-pages:80']]);
});
it('writes Caddy maintenance blocks with the same site address as the resource labels', function (bool $forceHttps, string $domain) {
$application = maintenanceApplication(['fqdn' => $domain, 'is_maintenance_enabled' => true]);
$application->settings->update(['is_force_https_enabled' => $forceHttps]);
$application->refresh();
$labelAddress = str(fqdnLabelsForCaddy('coolify', $application->uuid, collect([$domain]), $forceHttps)->first(fn ($label) => str_starts_with($label, 'caddy_0=')))->after('caddy_0=')->value();
$matcher = "@coolify-maintenance-{$application->uuid}-0";
$path = parse_url($domain, PHP_URL_PATH) ?: '/';
$caddyfile = maintenanceServer()->caddyBaseCaddyfile(collect([$application]));
expect($caddyfile)
->toStartWith("import /dynamic/*.caddy\n")
->toContain("\n\n{$labelAddress} {\n\t{$matcher} path {$path}*\n\thandle {$matcher} {")
->toContain("rewrite /maintenance-{$application->uuid}.html")
->toContain('copy_response 503');
})->with([
'https' => [false, 'https://app.example.com'],
'forced https' => [true, 'https://app.example.com'],
'http with path' => [false, 'http://app.example.com/api'],
]);
it('keeps the base Caddyfile to the import line without maintenance resources', function () {
expect(maintenanceServer()->caddyBaseCaddyfile(collect()))->toBe("import /dynamic/*.caddy\n");
});
it('skips domains that could inject proxy configuration', function () {
$application = maintenanceApplication(['fqdn' => 'https://ok.example.com,https://bad.example.com/a b,https://bad.example.com/{x},https://evil.example.com`)', 'is_maintenance_enabled' => true]);
expect($application->maintenanceRoutes()->pluck('host')->all())->toBe(['ok.example.com']);
});
it('collects the domains of Docker Compose applications and services', function () {
$compose = maintenanceApplication([
'build_pack' => 'dockercompose',
'fqdn' => null,
'docker_compose_domains' => json_encode(['web' => ['domain' => 'https://web.example.com,https://www.example.com'], 'api' => ['domain' => 'https://web.example.com/api'], 'db' => ['domain' => '']]),
]);
$service = maintenanceService(['ghost' => 'https://blog.example.com', 'admin' => 'https://admin.example.com/ghost']);
expect($compose->maintenanceRoutes()->map(fn ($route) => $route['host'].$route['path'])->all())->toBe(['web.example.com/', 'www.example.com/', 'web.example.com/api'])
->and($service->maintenanceRoutes()->map(fn ($route) => $route['host'].$route['path'])->all())->toBe(['blog.example.com/', 'admin.example.com/ghost']);
});
it('writes the pages and the Traefik routes through sudo, and removes them when maintenance ends', function () {
$application = maintenanceApplication(['is_maintenance_enabled' => true]);
$service = maintenanceService(['ghost' => 'https://blog.example.com']);
$service->forceFill(['is_maintenance_enabled' => true])->save();
$pages = '/data/coolify/proxy/error-pages';
maintenanceServer()->setupMaintenancePages();
$lines = maintenanceScriptLines();
expect($lines)->toContain("sudo find $pages -maxdepth 1 -name 'maintenance-*.html' ! -name 'maintenance-{$application->uuid}.html' ! -name 'maintenance-{$service->uuid}.html' -delete")
->and(collect($lines)->contains(fn ($line) => str_starts_with($line, 'sudo docker start coolify-proxy-error-pages')))->toBeTrue()
->and(writtenProxyFile($lines, "$pages/maintenance-{$application->uuid}.html"))->toContain('We are doing some maintenance')
->and(writtenProxyFile($lines, "$pages/maintenance-{$service->uuid}.html"))->toContain('We are doing some maintenance')
->and(writtenProxyFile($lines, '/data/coolify/proxy//dynamic/coolify-maintenance.yaml'))
->toContain('app.example.com')->toContain('blog.example.com');
$application->forceFill(['is_maintenance_enabled' => false])->save();
$service->forceFill(['is_maintenance_enabled' => false])->save();
maintenanceServer()->setupMaintenancePages();
expect(maintenanceScriptLines())->toContain('sudo rm -f /data/coolify/proxy//dynamic/coolify-maintenance.yaml')
->toContain("sudo find $pages -maxdepth 1 -name 'maintenance-*.html' -delete");
});
it('writes the maintenance blocks into the base Caddyfile on Caddy servers', function () {
$this->server->proxy->type = ProxyTypes::CADDY->value;
$this->server->save();
$application = maintenanceApplication(['is_maintenance_enabled' => true]);
maintenanceServer()->setupMaintenancePages();
expect(writtenProxyFile(maintenanceScriptLines(), '/data/coolify/proxy/caddy/dynamic/Caddyfile'))
->toContain("@coolify-maintenance-{$application->uuid}-0 path /*");
});
it('keeps the page container for maintenance when a redirect URL replaces the catch-all page', function () {
maintenanceApplication(['is_maintenance_enabled' => true]);
$server = maintenanceServer();
$server->proxy->redirect_url = 'https://example.com';
$server->save();
$server->setupProxyErrorPageContainer();
expect(collect(maintenanceScriptLines())->contains(fn ($line) => str_contains($line, 'docker run -d --name coolify-proxy-error-pages')))->toBeTrue();
});
it('does not touch Swarm servers', function () {
maintenanceApplication(['is_maintenance_enabled' => true]);
$this->server->settings->update(['is_swarm_manager' => true]);
$server = maintenanceServer();
$server->setupMaintenancePages();
expect($server->maintenanceResources())->toBeEmpty();
Process::assertNothingRan();
});
function maintenanceUser(Team $team, string $role): User
{
$user = User::factory()->create();
$user->teams()->attach($team, ['role' => $role]);
return $user;
}
it('lets an admin enable maintenance and save a custom page', function () {
$application = maintenanceApplication();
$this->actingAs(maintenanceUser($this->team, 'admin'));
session(['currentTeam' => $this->team]);
Livewire::test(Maintenance::class, ['resource' => $application])
->set('maintenancePage', '<h1>Back soon</h1>')
->call('submit')
->assertDispatched('success')
->set('isMaintenanceEnabled', true)
->call('instantSaveMaintenance')
->assertDispatched('success');
$application->refresh();
expect($application->is_maintenance_enabled)->toBeTrue()
->and($application->maintenance_page)->toBe('<h1>Back soon</h1>')
->and(writtenProxyFile(maintenanceScriptLines(), "/data/coolify/proxy/error-pages/maintenance-{$application->uuid}.html"))->toBe('<h1>Back soon</h1>');
});
it('rejects a maintenance page larger than the limit', function () {
$application = maintenanceApplication();
$this->actingAs(maintenanceUser($this->team, 'owner'));
session(['currentTeam' => $this->team]);
Livewire::test(Maintenance::class, ['resource' => $application])
->set('maintenancePage', str_repeat('a', Server::PROXY_ERROR_PAGE_MAX_BYTES + 1))
->call('submit')
->assertDispatched('error');
expect($application->fresh()->maintenance_page)->toBeNull();
});
it('does not let a member or another team change maintenance mode', function (string $who) {
$service = maintenanceService(['ghost' => 'https://blog.example.com']);
$team = $who === 'member' ? $this->team : Team::factory()->create();
$this->actingAs(maintenanceUser($team, $who === 'member' ? 'member' : 'owner'));
session(['currentTeam' => $team]);
Livewire::test(Maintenance::class, ['resource' => $service])
->set('isMaintenanceEnabled', true)
->call('instantSaveMaintenance')
->assertDispatched('error')
->set('maintenancePage', '<h1>Hacked</h1>')
->call('submit')
->assertDispatched('error');
$service->refresh();
expect($service->is_maintenance_enabled)->toBeFalse()
->and($service->maintenance_page)->toBeNull();
Process::assertNothingRan();
})->with(['member', 'other team']);
@@ -93,8 +93,10 @@ it('writes the Caddyfile through sudo', function () {
StartProxy::run($this->server, async: false, force: true);
$base64 = base64_encode("import /dynamic/*.caddy\n");
expect(nonRootProxyScriptLines())
->toContain("echo 'import /dynamic/*.caddy' | sudo tee /data/coolify/proxy/caddy/dynamic/Caddyfile > /dev/null");
->toContain("echo '{$base64}' | sudo base64 -d | sudo tee /data/coolify/proxy/caddy/dynamic/Caddyfile > /dev/null");
});
it('deploys the swarm proxy stack with an absolute compose file path', function () {
+286
View File
@@ -0,0 +1,286 @@
<?php
use App\Actions\Proxy\StopProxy;
use App\Enums\ProxyTypes;
use App\Events\ProxyStatusChanged;
use App\Events\ProxyStatusChangedUI;
use App\Models\InstanceSettings;
use App\Models\PrivateKey;
use App\Models\Server;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Process;
use Livewire\Livewire;
use Symfony\Component\Yaml\Yaml;
uses(RefreshDatabase::class);
// Servers stay in a static identity map; do not leak them into later test files.
afterEach(fn () => Server::flushIdentityMap());
beforeEach(function () {
Server::flushIdentityMap();
Event::fake([ProxyStatusChanged::class, ProxyStatusChangedUI::class]);
InstanceSettings::forceCreate(['id' => 0]);
config(['constants.ssh.mux_enabled' => false]);
fakeProxyFileUploads();
$this->team = Team::factory()->create();
$this->server = Server::factory()->create([
'team_id' => $this->team->id,
'user' => 'cooluser',
'private_key_id' => PrivateKey::factory()->create(['team_id' => $this->team->id])->id,
]);
$this->server->settings->update(['is_reachable' => true, 'is_usable' => true, 'force_disabled' => false]);
});
function useErrorPageProxy(Server $server, string $proxyType, array $proxy = []): Server
{
$server->proxy->type = $proxyType;
foreach ($proxy as $key => $value) {
$server->proxy->{$key} = $value;
}
$server->save();
return $server->refresh();
}
/**
* @return array<int, string>
*/
function errorPageScriptLines(): array
{
$lines = [];
Process::assertRan(function ($process) use (&$lines) {
array_push($lines, ...explode("\n", $process->command));
return true;
});
return $lines;
}
it('routes the Traefik catch-all through the errors middleware and keeps the 503', function () {
$server = useErrorPageProxy($this->server, ProxyTypes::TRAEFIK->value);
$config = Yaml::parse($server->defaultRedirectConfiguration());
expect($config['http']['routers']['catchall']['service'])->toBe('noop')
->and($config['http']['routers']['catchall']['middlewares'])->toBe(['error-pages'])
->and($config['http']['services']['noop']['loadBalancer']['servers'])->toBe([])
->and($config['http']['middlewares']['error-pages']['errors'])->toBe([
'status' => ['503'],
'service' => 'error-pages',
'query' => '/503.html',
])
->and($config['http']['services']['error-pages']['loadBalancer']['servers'])
->toBe([['url' => 'http://coolify-proxy-error-pages:80']]);
});
it('catches plain HTTP with a router without TLS', function (array $proxy, array $middlewares) {
$server = useErrorPageProxy($this->server, ProxyTypes::TRAEFIK->value, $proxy);
$routers = Yaml::parse($server->defaultRedirectConfiguration())['http']['routers'];
// A router with `tls` matches only HTTPS, so plain HTTP needs the catchall-http router.
expect($routers['catchall']['entryPoints'])->toBe(['https'])
->and($routers['catchall']['tls'])->toBe(['certResolver' => 'letsencrypt'])
->and($routers['catchall-http'])->toBe([
'entryPoints' => ['http'],
'service' => 'noop',
'rule' => 'PathPrefix(`/`)',
'priority' => -1000,
'middlewares' => $middlewares,
]);
})->with([
'error page' => [[], ['error-pages']],
'redirect URL' => [['redirect_url' => 'https://example.com'], ['redirect-regexp']],
]);
it('serves the error page through Caddy with a 503 status', function () {
$server = useErrorPageProxy($this->server, ProxyTypes::CADDY->value);
expect($server->defaultRedirectConfiguration())
->toContain('reverse_proxy coolify-proxy-error-pages:80 {')
->toContain('rewrite /503.html')
->toContain('copy_response 503')
->not->toContain('respond 503');
});
it('keeps the redirect URL ahead of the error page', function (string $proxyType) {
$server = useErrorPageProxy($this->server, $proxyType, ['redirect_url' => 'https://example.com']);
$server->proxy_error_page = '<h1>custom</h1>';
$server->save();
$configuration = $server->defaultRedirectConfiguration();
$server->setupDefaultRedirect();
$lines = errorPageScriptLines();
expect($configuration)->toContain('https://example.com')->not->toContain('error-pages')
->and($lines)->toContain('sudo docker rm -f coolify-proxy-error-pages >/dev/null 2>&1 || sudo true')
->and(implode("\n", $lines))->not->toContain('docker run');
})->with([ProxyTypes::TRAEFIK->value, ProxyTypes::CADDY->value]);
it('removes the catch-all and the page container when the default redirect is disabled', function () {
$server = useErrorPageProxy($this->server, ProxyTypes::TRAEFIK->value, ['redirect_enabled' => false]);
$server->setupDefaultRedirect();
$lines = errorPageScriptLines();
expect($lines)
->toContain('sudo rm -f /data/coolify/proxy//dynamic/default_redirect_503.yaml')
->toContain('sudo docker rm -f coolify-proxy-error-pages >/dev/null 2>&1 || sudo true')
->and(writtenProxyFile($lines, '/data/coolify/proxy//dynamic/default_redirect_503.yaml'))->toBeNull();
});
it('starts the page container through sudo before it writes the catch-all', function (string $proxyType, string $catchAll) {
$server = useErrorPageProxy($this->server, $proxyType);
$server->setupDefaultRedirect();
$lines = errorPageScriptLines();
$runLine = collect($lines)->search(fn (string $line) => str_contains($line, 'docker run -d --name coolify-proxy-error-pages'));
$catchAllLine = collect($lines)->search(fn (string $line) => str_contains($line, "tee $catchAll"));
expect($runLine)->toBeInt()
->and($catchAllLine)->toBeInt()
->and($runLine)->toBeLessThan($catchAllLine)
->and($lines[$runLine])->toBe('sudo docker start coolify-proxy-error-pages >/dev/null 2>&1 || sudo docker run -d --name coolify-proxy-error-pages --network coolify --restart unless-stopped --label coolify.managed=true -v /data/coolify/proxy/error-pages:/www:ro busybox:musl httpd -f -p 80 -h /www')
->and($lines)->toContain('sudo mkdir -p /data/coolify/proxy/error-pages && sudo find /data/coolify/proxy/error-pages -user root -exec chown -h cooluser:cooluser {} + && sudo chmod o-rwx /data/coolify/proxy/error-pages')
// The page goes through scp, then sudo moves it into place.
->and(collect($lines)->contains(fn (string $line) => preg_match('#^sudo mv -f /tmp/coolify-upload-[0-9a-f]+ /data/coolify/proxy/error-pages/503.html$#', $line) === 1))->toBeTrue()
->and($lines)->toContain('sudo chmod 644 /data/coolify/proxy/error-pages/503.html')
->and(writtenProxyFile($lines, '/data/coolify/proxy/error-pages/503.html'))->toContain('This site is not available at the moment')
->and(writtenProxyFile($lines, $catchAll))->toContain('coolify-proxy-error-pages');
})->with([
'traefik' => [ProxyTypes::TRAEFIK->value, '/data/coolify/proxy//dynamic/default_redirect_503.yaml'],
'caddy' => [ProxyTypes::CADDY->value, '/data/coolify/proxy/caddy/dynamic/default_redirect_503.caddy'],
]);
it('pushes the custom page and falls back to the default page after a reset', function () {
$server = useErrorPageProxy($this->server, ProxyTypes::TRAEFIK->value);
$server->proxy_error_page = '<h1>Maintenance</h1>';
$server->save();
$server->setupDefaultRedirect();
expect(writtenProxyFile(errorPageScriptLines(), '/data/coolify/proxy/error-pages/503.html'))->toBe('<h1>Maintenance</h1>');
$server->proxy_error_page = null;
$server->save();
$server->setupDefaultRedirect();
expect(writtenProxyFile(errorPageScriptLines(), '/data/coolify/proxy/error-pages/503.html'))
->toContain('This site is not available at the moment')
->toContain('https://coolify.io/docs/troubleshoot/applications/no-available-server')
->not->toContain($server->name)
->not->toContain($server->ip)
->not->toContain($this->team->name);
});
it('keeps Swarm servers on the plain catch-all without a page container', function (string $swarmSetting) {
$server = useErrorPageProxy($this->server, ProxyTypes::TRAEFIK->value);
$server->settings->update([$swarmSetting => true]);
$server->refresh();
$config = Yaml::parse($server->defaultRedirectConfiguration());
$server->setupDefaultRedirect();
expect($config['http']['routers'])->toBe(['catchall' => [
'entryPoints' => ['http', 'https'],
'service' => 'noop',
'rule' => 'PathPrefix(`/`)',
'tls' => ['certResolver' => 'letsencrypt'],
'priority' => -1000,
]])
->and($config['http'])->not->toHaveKey('middlewares')
->and(array_keys($config['http']['services']))->toBe(['noop'])
->and(implode("\n", errorPageScriptLines()))->not->toContain('coolify-proxy-error-pages');
})->with(['is_swarm_manager', 'is_swarm_worker']);
it('removes the page container when the proxy stops', function () {
$server = useErrorPageProxy($this->server, ProxyTypes::TRAEFIK->value);
StopProxy::run($server);
expect(errorPageScriptLines())->toContain('sudo docker rm -f coolify-proxy-error-pages 2>/dev/null || sudo true');
});
function errorPageUser(Team $team, string $role): User
{
$user = User::factory()->create();
$user->teams()->attach($team, ['role' => $role]);
return $user;
}
it('lets an admin save and reset the custom error page', function () {
$server = useErrorPageProxy($this->server, ProxyTypes::TRAEFIK->value);
$this->actingAs(errorPageUser($this->team, 'admin'));
session(['currentTeam' => $this->team]);
// The page loads the proxy configuration before the global save bar submits it.
$component = Livewire::test('server.proxy', ['server' => $server])
->set('proxySettings', "services:\n traefik:\n image: traefik:v3.6\n ports:\n - '80:80'\n")
->set('customErrorPage', '<h1>Maintenance</h1>')
->call('submit')
->assertDispatched('success');
expect($server->fresh()->proxy_error_page)->toBe('<h1>Maintenance</h1>')
->and(writtenProxyFile(errorPageScriptLines(), '/data/coolify/proxy/error-pages/503.html'))->toBe('<h1>Maintenance</h1>');
$component->call('resetCustomErrorPage')
->assertDispatched('success')
->assertSet('customErrorPage', null);
expect($server->fresh()->proxy_error_page)->toBeNull();
});
it('uploads a custom page larger than one shell argument', function () {
$server = useErrorPageProxy($this->server, ProxyTypes::TRAEFIK->value);
$page = '<h1>Big</h1>'.str_repeat('a', 300 * 1024);
$server->proxy_error_page = $page;
$server->save();
$server->setupDefaultRedirect();
$lines = errorPageScriptLines();
// Linux limits one shell argument to 128 KiB, so the page must not be inside a command.
expect(writtenProxyFile($lines, '/data/coolify/proxy/error-pages/503.html'))->toBe($page)
->and(collect($lines)->max(fn (string $line) => strlen($line)))->toBeLessThan(128 * 1024);
});
it('rejects a custom error page larger than the limit', function () {
$server = useErrorPageProxy($this->server, ProxyTypes::TRAEFIK->value);
$this->actingAs(errorPageUser($this->team, 'owner'));
session(['currentTeam' => $this->team]);
Livewire::test('server.proxy', ['server' => $server])
->set('customErrorPage', str_repeat('a', Server::PROXY_ERROR_PAGE_MAX_BYTES + 1))
->call('submit')
->assertDispatched('error');
expect($server->fresh()->proxy_error_page)->toBeNull();
Process::assertNothingRan();
});
it('does not let a member or another team change the custom error page', function (string $who) {
$server = useErrorPageProxy($this->server, ProxyTypes::TRAEFIK->value);
$server->proxy_error_page = '<h1>Original</h1>';
$server->save();
$team = $who === 'member' ? $this->team : Team::factory()->create();
$this->actingAs(errorPageUser($team, $who === 'member' ? 'member' : 'owner'));
session(['currentTeam' => $team]);
Livewire::test('server.proxy', ['server' => $server])
->set('customErrorPage', '<h1>Hacked</h1>')
->call('submit')
->assertDispatched('error')
->call('resetCustomErrorPage')
->assertDispatched('error');
expect($server->fresh()->proxy_error_page)->toBe('<h1>Original</h1>');
Process::assertNothingRan();
})->with(['member', 'other team']);
@@ -60,7 +60,35 @@ test('security cloud token form lets users choose every supported provider', fun
->assertSee('Provider')
->assertSee('Hetzner')
->assertSee('DigitalOcean')
->assertSee('Vultr');
->assertSee('Vultr')
->assertSee('Hostinger');
});
test('adding a hostinger token from a modal validates and refreshes hostinger token selectors', function () {
Http::fake([
'https://developers.hostinger.com/api/vps/v1/virtual-machines' => Http::response([], 200),
]);
$component = Livewire::test(CloudProviderTokenForm::class, [
'modal_mode' => true,
'provider' => 'hostinger',
])
->set('name', 'Production Hostinger')
->set('token', 'hostinger-token')
->call('addToken')
->assertHasNoErrors()
->assertDispatched('close-modal');
$dispatches = collect(data_get($component->effects, 'dispatches', []));
expect($dispatches->contains(fn (array $dispatch) => $dispatch['name'] === 'tokenAdded.hostinger'
&& data_get($dispatch, 'to') === 'server.new.by-hostinger'))->toBeTrue();
$this->assertDatabaseHas('cloud_provider_tokens', [
'team_id' => $this->team->id,
'provider' => 'hostinger',
'name' => 'Production Hostinger',
]);
});
test('adding a cloud provider token stores an optional description', function () {
@@ -94,6 +94,21 @@ test('cloud token detail page validates the token', function () {
->assertDispatched('success');
});
test('cloud token detail page validates Hostinger tokens', function () {
Http::fake([
'https://developers.hostinger.com/api/vps/v1/virtual-machines' => Http::response([], 200),
]);
$token = CloudProviderToken::factory()->create([
'team_id' => $this->team->id,
'provider' => 'hostinger',
]);
Livewire::test(Show::class, ['cloud_token_uuid' => $token->uuid])
->call('validateToken')
->assertDispatched('success');
});
test('cloud token detail page deletes unused tokens', function () {
$token = CloudProviderToken::factory()->create([
'team_id' => $this->team->id,
@@ -60,6 +60,36 @@ it('syncs provider state for a placeholder server without scheduling SSH', funct
Queue::assertNotPushed(ServerConnectionCheckJob::class);
});
it('schedules Hostinger provider state checks', function () {
$server = createCloudServerForStatusCheckJobTest('hostinger', [
'hostinger_virtual_machine_id' => 17923,
'hostinger_virtual_machine_status' => 'creating',
]);
Queue::fake();
(new ServerManagerJob)->handle();
Queue::assertPushed(ServerCloudProviderStatusCheckJob::class, fn (ServerCloudProviderStatusCheckJob $job) => $job->server->is($server));
});
it('runs Hostinger provider state checks', function () {
$server = createCloudServerForStatusCheckJobTest('hostinger', [
'hostinger_virtual_machine_id' => 17923,
'hostinger_virtual_machine_status' => 'creating',
]);
Http::fake([
'https://developers.hostinger.com/api/vps/v1/virtual-machines/17923' => Http::response([
'id' => 17923,
'state' => 'running',
'ipv4' => [['address' => '203.0.113.23']],
]),
]);
(new ServerCloudProviderStatusCheckJob($server))->handle();
expect($server->fresh()->ip)->toBe('203.0.113.23')
->and($server->fresh()->hostinger_virtual_machine_status)->toBe('running');
});
it('backfills a DigitalOcean placeholder without changing reachability', function () {
$server = createCloudServerForStatusCheckJobTest('digitalocean', [
'digitalocean_droplet_id' => 987,
+38
View File
@@ -1,6 +1,8 @@
<?php
use App\Models\Server;
use Illuminate\Process\PendingProcess;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Once;
use Livewire\Features\SupportTesting\Testable;
use Tests\TestCase;
@@ -141,3 +143,39 @@ function loginAndSkipBoarding(string $email = 'test@example.com', string $passwo
// {
// // ..
// }
/**
* Fakes all processes and keeps the content of every file that Server::uploadProxyFile()
* copies with scp, keyed by the remote upload path.
*/
function fakeProxyFileUploads(): void
{
$GLOBALS['proxyFileUploads'] = [];
Process::fake(function (PendingProcess $process) {
if (preg_match("#'(/[^']*coolify-proxy-file-[^']+)'.*'(/tmp/coolify-upload-[0-9a-f]+)'#", (string) $process->command, $matches)) {
$GLOBALS['proxyFileUploads'][$matches[2]] = file_get_contents($matches[1]);
}
return Process::result();
});
}
/**
* The last content written to $file on the server, with Server::uploadProxyFile() or with
* `echo '<base64>' | base64 -d | tee $file`.
*
* @param array<int, string> $lines remote script lines (see Process::assertRan)
*/
function writtenProxyFile(array $lines, string $file): ?string
{
$content = null;
foreach ($lines as $line) {
if (preg_match('#mv -f (/tmp/coolify-upload-[0-9a-f]+) '.preg_quote($file, '#').'$#', $line, $matches)) {
$content = $GLOBALS['proxyFileUploads'][$matches[1]] ?? null;
} elseif (str_contains($line, "tee $file") && preg_match("/echo '([A-Za-z0-9+\/=]+)'/", $line, $matches)) {
$content = base64_decode($matches[1]);
}
}
return $content;
}
@@ -260,6 +260,23 @@ it('pins docker config while running railpack buildx commands', function () {
->toContain('DOCKER_CONFIG=/root/.docker docker buildx build --builder coolify-railpack');
});
it('stops the railpack builder after the build when no other build uses it', function () {
[$job, $reflection] = makeRailpackDeploymentJob([
'uuid' => 'application-uuid',
]);
$command = invokeRailpackMethod($job, $reflection, 'railpack_build_command', ['coollabsio/coolify:test', collect([])]);
$sharedLock = strpos($command, 'flock -s 9');
$build = strpos($command, 'docker buildx build --builder coolify-railpack');
$stop = strpos($command, 'if flock -n -x 9; then DOCKER_CONFIG=/root/.docker docker buildx stop coolify-railpack');
expect($sharedLock)->not->toBeFalse()
->and($build)->toBeGreaterThan($sharedLock)
->and($stop)->toBeGreaterThan($build)
->and($command)->toEndWith('exit $status');
});
it('filters reserved docker client variables from railpack build secrets', function () {
[$job, $reflection] = makeRailpackDeploymentJob([
'uuid' => 'application-uuid',
@@ -81,13 +81,13 @@ it('generates escaped railpack env args from resolved values and includes instal
'RAILPACK_CUSTOM_FLAG' => 'hello world',
'RAILPACK_JSON' => '{"token":"abc"}',
'RAILPACK_INSTALL_CMD' => 'npm ci && npm run postinstall',
'RAILPACK_DEPLOY_APT_PACKAGES' => 'curl wget',
'RAILPACK_DEPLOY_APT_PACKAGES' => '... curl wget',
]);
expect($envArgs)->toContain("--env 'RAILPACK_NODE_VERSION=22'");
expect($envArgs)->toContain("--env 'RAILPACK_CUSTOM_FLAG=hello world'");
expect($envArgs)->toContain("--env 'RAILPACK_JSON={\"token\":\"abc\"}'");
expect($envArgs)->toContain("--env 'RAILPACK_INSTALL_CMD=npm ci && npm run postinstall'");
expect($envArgs)->toContain("--env 'RAILPACK_DEPLOY_APT_PACKAGES=curl wget'");
expect($envArgs)->toContain("--env 'RAILPACK_DEPLOY_APT_PACKAGES=... curl wget'");
expect($envArgs)->not->toContain('RAILPACK_NULL');
});
@@ -136,7 +136,7 @@ it('uses preview railpack environment variables for preview deployments', functi
expect($variables->all())->toBe([
'RAILPACK_PREVIEW_ONLY' => 'preview-value',
'RAILPACK_DEPLOY_APT_PACKAGES' => 'curl wget',
'RAILPACK_DEPLOY_APT_PACKAGES' => '... curl wget',
]);
});
@@ -195,7 +195,7 @@ it('merges coolify env variables into railpack build variables', function () {
expect($variables->all())->toBe([
'MY_BUILD_VAR' => 'hello',
'RAILPACK_DEPLOY_APT_PACKAGES' => 'curl wget',
'RAILPACK_DEPLOY_APT_PACKAGES' => '... curl wget',
'COOLIFY_URL' => 'https://app.example.com',
'COOLIFY_FQDN' => 'app.example.com',
'COOLIFY_BRANCH' => 'main',
@@ -209,7 +209,7 @@ it('merges coolify env variables into railpack build variables', function () {
expect($envArgs)->toContain("--env 'COOLIFY_URL=https://app.example.com'");
expect($envArgs)->toContain("--env 'SOURCE_COMMIT=abc123'");
expect($envArgs)->toContain("--env 'RAILPACK_DEPLOY_APT_PACKAGES=curl wget'");
expect($envArgs)->toContain("--env 'RAILPACK_DEPLOY_APT_PACKAGES=... curl wget'");
expect($envArgs)->not->toContain('EMPTY_VAR');
expect($envArgs)->not->toContain('NULL_VAR');
});
@@ -257,11 +257,11 @@ it('preserves user railpack deploy apt packages while adding healthcheck tools o
$method->setAccessible(true);
$variables = $method->invoke($job);
expect($variables->get('RAILPACK_DEPLOY_APT_PACKAGES'))->toBe('ffmpeg curl wget');
expect($variables->get('RAILPACK_DEPLOY_APT_PACKAGES'))->toBe('... ffmpeg curl wget');
$envArgsProperty = $reflection->getProperty('env_railpack_args');
$envArgsProperty->setAccessible(true);
$envArgs = $envArgsProperty->getValue($job);
expect($envArgs)->toContain("--env 'RAILPACK_DEPLOY_APT_PACKAGES=ffmpeg curl wget'");
expect($envArgs)->toContain("--env 'RAILPACK_DEPLOY_APT_PACKAGES=... ffmpeg curl wget'");
});
@@ -1,5 +1,47 @@
<?php
it('allows selecting Vultr in cloud provider token forms', function () {
$view = file_get_contents(__DIR__.'/../../resources/views/livewire/security/cloud-provider-token-form.blade.php');
$component = file_get_contents(__DIR__.'/../../app/Livewire/Security/CloudProviderTokenForm.php');
expect($view)->toContain('<x-forms.listbox required id="provider" label="Provider"')
->and($view)->toContain("['value' => 'vultr', 'label' => 'Vultr']")
->and($view)->toContain("['value' => 'digitalocean', 'label' => 'DigitalOcean']")
->and($view)->toContain("['value' => 'hostinger', 'label' => 'Hostinger']")
->and(substr_count($view, 'https://console.vultr.com/user/apiaccess/'))->toBe(1)
->and($view)->not->toContain('cloudProviderTokens->where(\'provider\', $provider)->isEmpty()')
->and($view)->not->toContain('<x-forms.select required id="provider" label="Provider" disabled>')
->and($component)->toContain("'provider' => 'required|string|in:hetzner,digitalocean,vultr,hostinger'");
});
it('keeps provider affiliate links on server provider views', function () {
$tokenFormView = file_get_contents(__DIR__.'/../../resources/views/livewire/security/cloud-provider-token-form.blade.php');
$hetznerView = file_get_contents(__DIR__.'/../../resources/views/livewire/server/new/by-hetzner.blade.php');
$vultrView = file_get_contents(__DIR__.'/../../resources/views/livewire/server/new/by-vultr.blade.php');
$digitalOceanView = file_get_contents(__DIR__.'/../../resources/views/livewire/server/new/by-digital-ocean.blade.php');
expect($tokenFormView)
->not->toContain('https://coolify.io/hetzner')
->not->toContain('https://coolify.io/vultr')
->and($hetznerView)->toContain('https://coolify.io/hetzner')
->and($vultrView)->toContain('https://coolify.io/vultr')
->and($digitalOceanView)->toContain('https://coolify.io/digitalocean');
});
it('uses the shared modal form on the cloud provider tokens page', function () {
$view = file_get_contents(__DIR__.'/../../resources/views/livewire/security/cloud-provider-tokens.blade.php');
expect($view)->toContain('New token')
->and($view)->toContain('<x-application.settings-section title="Cloud tokens"')
->and($view)->toContain('<livewire:security.cloud-provider-token-form :modal_mode="true"')
->and($view)->toContain("\$savedToken->provider === 'digitalocean' ? 'DigitalOcean' : ucfirst(\$savedToken->provider)")
->and($view)->not->toContain('<h3>New Token</h3>')
->and($view)->not->toContain(':modal_mode="false"')
->and($view)->not->toContain('wire:click="validateToken')
->and($view)->not->toContain('submitAction="deleteToken')
->and($view)->not->toContain('Created {{ $savedToken->created_at->diffForHumans() }}');
});
it('gates new cloud tokens and keys saved token rows by id', function () {
$view = file_get_contents(__DIR__.'/../../resources/views/livewire/security/cloud-provider-tokens.blade.php');
@@ -148,6 +148,22 @@ it('detects deployment hook changes as redeploy-only changes', function (string
'post-deployment container' => ['post_deployment_command_container', 'Post-deployment command container', 'worker'],
]);
it('detects HTTP basic auth hash setting changes as redeploy-only changes', function (string $field, string $label, string|int $newValue) {
$application = snapshotTestApplication();
markSnapshotTestApplicationDeployed($application);
$application->update([$field => $newValue]);
$diff = $application->refresh()->pendingDeploymentConfigurationDiff();
expect($diff->requiresBuild())->toBeFalse()
->and(collect($diff->changes())->pluck('label'))->toContain($label);
})->with([
'hash algorithm' => ['http_basic_auth_hash_algorithm', 'HTTP basic auth hash algorithm', 'argon2id'],
'bcrypt cost factor' => ['http_basic_auth_bcrypt_cost', 'HTTP basic auth bcrypt cost factor', 4],
'Argon2id memory' => ['http_basic_auth_argon2id_memory_cost', 'HTTP basic auth Argon2id memory (KiB)', 8192],
'Argon2id iterations' => ['http_basic_auth_argon2id_time_cost', 'HTTP basic auth Argon2id iterations', 2],
]);
it('detects source integration changes as build changes', function (string $field, string $label, mixed $newValue) {
$application = snapshotTestApplication([
'source_id' => 1,
+90
View File
@@ -0,0 +1,90 @@
<?php
use App\Actions\Server\DeleteServer;
use App\Models\CloudProviderToken;
use App\Models\PrivateKey;
use App\Models\Server;
use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
beforeEach(function () {
Http::preventStrayRequests();
$this->team = Team::factory()->create();
$this->token = CloudProviderToken::factory()->create([
'team_id' => $this->team->id,
'provider' => 'hostinger',
'token' => 'test-hostinger-token',
]);
$privateKey = PrivateKey::factory()->create(['team_id' => $this->team->id]);
$this->server = Server::factory()->create([
'team_id' => $this->team->id,
'private_key_id' => $privateKey->id,
'cloud_provider_token_id' => $this->token->id,
'hostinger_virtual_machine_id' => 17923,
]);
$this->server->delete();
});
it('deletes the Hostinger VPS by disabling auto-renewal when requested', function () {
Http::fake([
'https://developers.hostinger.com/api/vps/v1/virtual-machines/17923' => Http::response([
'id' => 17923,
'subscription_id' => 'Azz2Y9VWEjUO69lF',
]),
'https://developers.hostinger.com/api/billing/v1/subscriptions/Azz2Y9VWEjUO69lF/auto-renewal/disable' => Http::response([
'id' => 'Azz2Y9VWEjUO69lF',
'is_auto_renewed' => false,
]),
]);
DeleteServer::run(
serverId: $this->server->id,
cloudProviderTokenId: $this->token->id,
teamId: $this->team->id,
deleteFromHostinger: true,
hostingerVirtualMachineId: 17923,
);
Http::assertSent(fn ($request) => $request->method() === 'DELETE'
&& $request->url() === 'https://developers.hostinger.com/api/billing/v1/subscriptions/Azz2Y9VWEjUO69lF/auto-renewal/disable');
expect(Server::withTrashed()->find($this->server->id))->toBeNull();
});
it('retains the server when the Hostinger VPS cannot be deleted', function () {
Http::fake([
'https://developers.hostinger.com/api/vps/v1/virtual-machines/17923' => Http::response([
'id' => 17923,
'subscription_id' => 'Azz2Y9VWEjUO69lF',
]),
'https://developers.hostinger.com/api/billing/v1/subscriptions/Azz2Y9VWEjUO69lF/auto-renewal/disable' => Http::response([
'message' => 'Unauthorized',
], 403),
]);
expect(fn () => DeleteServer::run(
serverId: $this->server->id,
cloudProviderTokenId: $this->token->id,
teamId: $this->team->id,
deleteFromHostinger: true,
hostingerVirtualMachineId: 17923,
))->toThrow(Exception::class, 'Hostinger API error: Unauthorized');
expect(Server::withTrashed()->find($this->server->id))->not->toBeNull();
});
it('does not call Hostinger when the VPS should be kept', function () {
DeleteServer::run(
serverId: $this->server->id,
cloudProviderTokenId: $this->token->id,
teamId: $this->team->id,
hostingerVirtualMachineId: 17923,
);
Http::assertNothingSent();
expect(Server::withTrashed()->find($this->server->id))->toBeNull();
});

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