mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-06 13:10:37 -04:00
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:
@@ -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;
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
];
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
@@ -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
@@ -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'),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
+32
@@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
+30
@@ -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']);
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
+30
@@ -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',
|
||||
]);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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();
|
||||
|
||||
@@ -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],
|
||||
]);
|
||||
|
||||
@@ -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']);
|
||||
|
||||
@@ -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"><b>Operations</b></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$',
|
||||
]],
|
||||
];
|
||||
});
|
||||
|
||||
@@ -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 () {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user