diff --git a/.ai/lessons.md b/.ai/lessons.md index 03824d9748..69790b0ee1 100644 --- a/.ai/lessons.md +++ b/.ai/lessons.md @@ -12,7 +12,7 @@ - SSH retries replay the full command batch. Only exit code 255 is an SSH transport failure; other exit codes come from the remote command, so do not retry them. Keep batches safe to run twice (for example `docker rm -f || true` before `docker run --name `). - Redirect browser test output to a file (`> /tmp/x.log 2>&1`); piping it (`| tail`) hangs because the Playwright server keeps the pipe open. - Browser `click('Text')` can match a hidden element first (for example inactive modal steps) and wait forever; use `button:visible:has-text("Text")`. -- A new worktree has no `vendor/` or `public/build/`. Run `composer install --ignore-platform-req=ext-zip` and copy `public/build` from the main checkout before page-rendering tests; otherwise they fail with a Vite manifest error. +- A new worktree has no `vendor/` or `public/build/`. Run `composer install --ignore-platform-req=ext-zip` on the host and copy `public/build` from the main checkout before page-rendering tests; otherwise they fail with a Vite manifest error. - Never run `php artisan test` inside a dev container (`./scripts/dev exec` or `docker exec coolify-dev-*`). The container sets `DB_CONNECTION=pgsql` as a real environment variable, which wins over `phpunit.xml`, so `RefreshDatabase` runs `migrate:fresh` on the dev database and wipes it. Run tests on the host only. - Do not symlink `vendor` into another worktree. Composer resolves the base path from the real `vendor` path, so the tests load the app classes of the other checkout. Run `composer install --ignore-platform-req=ext-zip` in the worktree. - Call `visit()` directly in each `tests/v4/Browser` test body; Pest does not mark a test that only uses helper-wrapped `visit()` as a browser test, so it fails with `sendText() on null`. diff --git a/app/Actions/Development/StartDevelopmentQemuVm.php b/app/Actions/Development/StartDevelopmentQemuVm.php index 52570dc42e..5815185508 100644 --- a/app/Actions/Development/StartDevelopmentQemuVm.php +++ b/app/Actions/Development/StartDevelopmentQemuVm.php @@ -73,7 +73,24 @@ class StartDevelopmentQemuVm return; } - $this->runOrFail(($state === 'paused' ? 'virsh resume ' : 'virsh start ').escapeshellarg($domain)); + if ($state === 'paused') { + $this->runOrFail('virsh resume '.escapeshellarg($domain)); + + return; + } + + $this->applyMemory($domain); + $this->runOrFail('virsh start '.escapeshellarg($domain)); + } + + /** + * Sets the configured memory on a stopped VM, so VMs created with a larger size shrink on their next start. + */ + private function applyMemory(string $domain): void + { + $memory = (int) config('development-qemu.memory').'M'; + $this->runOrFail(sprintf('virsh setmaxmem %s %s --config', escapeshellarg($domain), $memory)); + $this->runOrFail(sprintf('virsh setmem %s %s --config', escapeshellarg($domain), $memory)); } private function destroyVm(string $domain): void diff --git a/app/Livewire/Security/IntegrationTokenEditor.php b/app/Livewire/Security/IntegrationTokenEditor.php index 16a7f0ad9b..6a4c7ce2e6 100644 --- a/app/Livewire/Security/IntegrationTokenEditor.php +++ b/app/Livewire/Security/IntegrationTokenEditor.php @@ -48,15 +48,27 @@ class IntegrationTokenEditor extends Component $this->proxiedDns = $this->integrationToken->proxiedDnsEnabled(); } + /** + * Cloudflare Tunnel setup also writes DNS records, so selecting `tunnel` also selects `dns`. + */ + public function updatedCapabilities(): void + { + if (in_array('tunnel', $this->capabilities, true) && ! in_array('dns', $this->capabilities, true)) { + $this->capabilities[] = 'dns'; + } + } + protected function rules(): array { - $allowedCapability = $this->integrationToken->provider === 'cloudflare' ? 'dns' : 'secrets'; - $rules = [ 'name' => ['required', 'string', 'max:255'], 'newToken' => ['nullable', 'string'], - 'capabilities' => ['required', 'array', 'min:1'], - 'capabilities.*' => ['required', 'in:'.$allowedCapability], + 'capabilities' => ['required', 'array', 'min:1', function (string $attribute, mixed $value, \Closure $fail): void { + if (is_array($value) && in_array('tunnel', $value, true) && ! in_array('dns', $value, true)) { + $fail('Cloudflare Tunnel also needs the DNS capability.'); + } + }], + 'capabilities.*' => ['required', 'in:'.implode(',', IntegrationToken::allowedCapabilities($this->integrationToken->provider))], 'automaticDns' => ['boolean'], 'proxiedDns' => ['boolean'], ]; diff --git a/app/Livewire/Security/IntegrationTokenForm.php b/app/Livewire/Security/IntegrationTokenForm.php index 3cfbc75aa7..54ce0b704e 100644 --- a/app/Livewire/Security/IntegrationTokenForm.php +++ b/app/Livewire/Security/IntegrationTokenForm.php @@ -49,16 +49,28 @@ class IntegrationTokenForm extends Component } } + /** + * Cloudflare Tunnel setup also writes DNS records, so selecting `tunnel` also selects `dns`. + */ + public function updatedCapabilities(): void + { + if (in_array('tunnel', $this->capabilities, true) && ! in_array('dns', $this->capabilities, true)) { + $this->capabilities[] = 'dns'; + } + } + protected function rules(): array { - $allowedCapability = $this->provider === 'cloudflare' ? 'dns' : 'secrets'; - $rules = [ 'provider' => ['required', 'in:'.implode(',', array_keys(IntegrationToken::PROVIDER_NAMES))], 'name' => ['required', 'string', 'max:255'], 'token' => ['required', 'string'], - 'capabilities' => ['required', 'array', 'min:1'], - 'capabilities.*' => ['required', 'in:'.$allowedCapability], + 'capabilities' => ['required', 'array', 'min:1', function (string $attribute, mixed $value, \Closure $fail): void { + if (is_array($value) && in_array('tunnel', $value, true) && ! in_array('dns', $value, true)) { + $fail('Cloudflare Tunnel also needs the DNS capability.'); + } + }], + 'capabilities.*' => ['required', 'in:'.implode(',', IntegrationToken::allowedCapabilities($this->provider))], 'automaticDns' => ['boolean'], 'proxiedDns' => ['boolean'], ]; @@ -129,7 +141,7 @@ class IntegrationTokenForm extends Component ]); $this->reset(['name', 'token']); - $this->dispatch('integrationTokenAdded')->to(IntegrationTokens::class); + $this->dispatch('integrationTokenAdded'); if ($this->modal_mode) { $this->dispatch('close-modal'); diff --git a/app/Livewire/Security/IntegrationTokens.php b/app/Livewire/Security/IntegrationTokens.php index 34b2b38a07..0b02593726 100644 --- a/app/Livewire/Security/IntegrationTokens.php +++ b/app/Livewire/Security/IntegrationTokens.php @@ -20,6 +20,7 @@ class IntegrationTokens extends Component } #[On('integrationTokenAdded')] + #[On('integration-token-deleted')] public function loadTokens(): void { $this->tokens = IntegrationToken::ownedByCurrentTeam()->withCount('dnsZones')->latest()->get(); diff --git a/app/Livewire/Server/CloudflareTunnel.php b/app/Livewire/Server/CloudflareTunnel.php index bf0172a6d9..31bca417f6 100644 --- a/app/Livewire/Server/CloudflareTunnel.php +++ b/app/Livewire/Server/CloudflareTunnel.php @@ -4,9 +4,17 @@ namespace App\Livewire\Server; use App\Actions\Server\ConfigureCloudflared; use App\Actions\Server\UpdateCloudflared; +use App\Exceptions\DnsRecordConflictException; +use App\Models\IntegrationToken; use App\Models\Server; +use App\Rules\ValidHostname; +use App\Services\CloudflareTunnelService; use App\Traits\ListensToTeamChannel; +use Illuminate\Contracts\Cache\LockTimeoutException; +use Illuminate\Database\Eloquent\Collection; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; +use Livewire\Attributes\Locked; +use Livewire\Attributes\On; use Livewire\Attributes\Validate; use Livewire\Component; @@ -26,6 +34,18 @@ class CloudflareTunnel extends Component #[Validate(['required', 'boolean'])] public bool $isCloudflareTunnelsEnabled; + public ?int $cloudflare_integration_token_id = null; + + public string $api_ssh_domain = ''; + + /** + * An existing DNS record on the hostname that blocks the tunnel CNAME, waiting for the user to confirm its replacement. + * + * @var array{record_id: string, current: string, hostname: string, token_id: int}|null + */ + #[Locked] + public ?array $dnsConflict = null; + public function getListeners() { return $this->teamChannelListeners([ @@ -33,6 +53,18 @@ class CloudflareTunnel extends Component ]); } + /** + * Selects the token that the user just added from this page, when it is the only one that can manage tunnels. + */ + #[On('integrationTokenAdded')] + public function selectAddedToken(): void + { + $tunnelTokens = $this->tunnelTokens(); + if ($this->cloudflare_integration_token_id === null && $tunnelTokens->count() === 1) { + $this->cloudflare_integration_token_id = $tunnelTokens->first()->id; + } + } + public function refresh() { $this->server->refresh(); @@ -52,11 +84,17 @@ class CloudflareTunnel extends Component } } - public function toggleCloudflareTunnels() + public function toggleCloudflareTunnels(CloudflareTunnelService $tunnels) { try { $this->authorize('update', $this->server); - remote_process(['docker rm -f coolify-cloudflared'], $this->server, false, 10); + if (filled($this->server->settings->cloudflare_tunnel_id)) { + foreach ($tunnels->tearDown($this->server) as $warning) { + $this->dispatch('warning', $warning); + } + } else { + remote_process(['docker rm -f coolify-cloudflared'], $this->server, false, 10); + } $this->isCloudflareTunnelsEnabled = false; $this->server->settings->is_cloudflare_tunnel = false; $this->server->settings->save(); @@ -91,10 +129,7 @@ class CloudflareTunnel extends Component { try { $this->authorize('update', $this->server); - if (str($this->ssh_domain)->contains('https://')) { - $this->ssh_domain = str($this->ssh_domain)->replace('https://', '')->replace('http://', '')->trim(); - $this->ssh_domain = str($this->ssh_domain)->replace('/', ''); - } + $this->ssh_domain = $this->normalizeSshDomain($this->ssh_domain); $activity = ConfigureCloudflared::run($this->server, $this->cloudflare_token, $this->ssh_domain); auditLog('ui.server.cloudflare_tunnel.configuration_started', $this->auditContext([ 'ssh_domain' => (string) $this->ssh_domain, @@ -118,9 +153,101 @@ class CloudflareTunnel extends Component } } + public function apiCloudflareConfig(CloudflareTunnelService $tunnels): void + { + $this->authorize('update', $this->server); + $this->api_ssh_domain = $this->normalizeSshDomain($this->api_ssh_domain); + $this->validate([ + 'cloudflare_integration_token_id' => ['required', 'integer'], + 'api_ssh_domain' => ['required', 'string', new ValidHostname], + ], [], ['cloudflare_integration_token_id' => 'Cloudflare token', 'api_ssh_domain' => 'SSH hostname']); + + $token = $this->tunnelTokens()->firstWhere('id', $this->cloudflare_integration_token_id); + if ($token === null) { + $this->addError('cloudflare_integration_token_id', 'Select a Cloudflare token of this server team with the Cloudflare Tunnel capability.'); + + return; + } + + $this->dnsConflict = null; + $this->startApiSetup($tunnels, $token, $this->api_ssh_domain); + } + + public function replaceDnsAndConfigure(CloudflareTunnelService $tunnels): void + { + $this->authorize('update', $this->server); + $conflict = $this->dnsConflict; + $token = $conflict === null ? null : $this->tunnelTokens()->firstWhere('id', $conflict['token_id']); + if ($conflict === null || $token === null) { + $this->dnsConflict = null; + $this->dispatch('error', 'The DNS conflict is no longer available. Set up the tunnel again.'); + + return; + } + + $this->dnsConflict = null; + $this->startApiSetup($tunnels, $token, $conflict['hostname'], $conflict); + } + public function render() { - return view('livewire.server.cloudflare-tunnel'); + $cloudflareTokens = IntegrationToken::query() + ->where('team_id', $this->server->team_id) + ->where('provider', 'cloudflare') + ->count(); + + return view('livewire.server.cloudflare-tunnel', [ + 'tunnelTokens' => $this->tunnelTokens(), + 'hasCloudflareTokens' => $cloudflareTokens > 0, + ]); + } + + /** + * Cloudflare tokens of the server's team (not the session team) that can manage tunnels. + * + * @return Collection + */ + private function tunnelTokens(): Collection + { + return IntegrationToken::query() + ->where('team_id', $this->server->team_id) + ->where('provider', 'cloudflare') + ->orderBy('name') + ->get() + ->filter(fn (IntegrationToken $token): bool => $token->hasCapability('tunnel')) + ->values(); + } + + /** + * @param array{record_id: string, current: string}|null $confirmedConflict + */ + private function startApiSetup(CloudflareTunnelService $tunnels, IntegrationToken $token, string $hostname, ?array $confirmedConflict = null): void + { + try { + $activity = $tunnels->setUp($this->server, $token, $hostname, $confirmedConflict); + auditLog('ui.server.cloudflare_tunnel.api_configuration_started', $this->auditContext([ + 'ssh_domain' => $hostname, + 'integration_token_uuid' => $token->uuid, + ])); + $this->dispatch('automated'); + $this->dispatch('activityMonitor', $activity->id); + } catch (DnsRecordConflictException $e) { + $this->dnsConflict = [ + 'record_id' => $e->providerRecordId, + 'current' => $e->currentValue, + 'hostname' => $hostname, + 'token_id' => $token->id, + ]; + } catch (LockTimeoutException) { + $this->dispatch('error', "Another DNS change for {$hostname} is in progress. Try again in a few seconds."); + } catch (\Throwable $e) { + handleError($e, $this); + } + } + + private function normalizeSshDomain(string $domain): string + { + return str($domain)->replace(['https://', 'http://'], '')->replace('/', '')->trim()->lower()->value(); } /** diff --git a/app/Models/IntegrationToken.php b/app/Models/IntegrationToken.php index 119c8d95df..05f8174c70 100644 --- a/app/Models/IntegrationToken.php +++ b/app/Models/IntegrationToken.php @@ -71,6 +71,45 @@ class IntegrationToken extends BaseModel return self::PROVIDER_NAMES[$this->provider] ?? ucfirst($this->provider); } + /** + * Capabilities a token of the provider can have. Cloudflare Tunnel setup also writes DNS records, so `tunnel` needs `dns`. + * + * @return array + */ + public static function allowedCapabilities(string $provider): array + { + return $provider === 'cloudflare' ? ['dns', 'tunnel'] : ['secrets']; + } + + /** + * Cloudflare dashboard link that pre-fills a user API token with the permissions the capabilities need. + * + * @param array $capabilities + */ + public static function cloudflareTokenTemplateUrl(array $capabilities): string + { + $permissions = [ + ['key' => 'dns', 'type' => 'edit'], + ['key' => 'zone', 'type' => 'read'], + ]; + if (in_array('tunnel', $capabilities, true)) { + // `argotunnel` is Cloudflare's internal key for the "Cloudflare Tunnel" permission group. + $permissions[] = ['key' => 'argotunnel', 'type' => 'edit']; + } + + return 'https://dash.cloudflare.com/profile/api-tokens?'.http_build_query([ + 'permissionGroupKeys' => json_encode($permissions), + 'accountId' => '*', + 'zoneId' => 'all', + 'name' => in_array('tunnel', $capabilities, true) ? 'Coolify DNS and Tunnel Management' : 'Coolify DNS Management', + ], '', '&', PHP_QUERY_RFC3986); + } + + public function hasCapability(string $capability): bool + { + return in_array($capability, $this->capabilities ?? [], true); + } + public function automaticDnsEnabled(): bool { return $this->provider === 'cloudflare' && data_get($this->metadata, 'automatic_dns', true) !== false; diff --git a/app/Models/ServerSetting.php b/app/Models/ServerSetting.php index 79b541a328..f2c6116c06 100644 --- a/app/Models/ServerSetting.php +++ b/app/Models/ServerSetting.php @@ -98,6 +98,9 @@ class ServerSetting extends Model 'is_usable', 'wildcard_domain', 'is_cloudflare_tunnel', + 'cloudflare_tunnel_id', + 'cloudflare_tunnel_account_id', + 'cloudflare_tunnel_integration_token_id', 'is_logdrain_newrelic_enabled', 'logdrain_newrelic_license_key', 'logdrain_newrelic_base_uri', diff --git a/app/Services/CloudflareTokenValidator.php b/app/Services/CloudflareTokenValidator.php index 2a4a761027..a48efda741 100644 --- a/app/Services/CloudflareTokenValidator.php +++ b/app/Services/CloudflareTokenValidator.php @@ -16,17 +16,28 @@ class CloudflareTokenValidator return false; } - if (in_array('dns', $capabilities, true)) { - $zones = $client->get('https://api.cloudflare.com/client/v4/zones', ['per_page' => 1]); - $zoneId = $zones->json('result.0.id'); + if (! in_array('dns', $capabilities, true)) { + return true; + } - if (! $zones->successful() || ! is_string($zoneId)) { - return false; - } + $zones = $client->get('https://api.cloudflare.com/client/v4/zones', ['per_page' => 1]); + $zoneId = $zones->json('result.0.id'); - return $client->get("https://api.cloudflare.com/client/v4/zones/{$zoneId}/dns_records", [ - 'per_page' => 1, - ])->successful(); + if (! $zones->successful() || ! is_string($zoneId)) { + return false; + } + + if (! $client->get("https://api.cloudflare.com/client/v4/zones/{$zoneId}/dns_records", ['per_page' => 1])->successful()) { + return false; + } + + if (in_array('tunnel', $capabilities, true)) { + $accountId = $zones->json('result.0.account.id'); + + return is_string($accountId) && $client->get( + "https://api.cloudflare.com/client/v4/accounts/{$accountId}/cfd_tunnel", + ['per_page' => 1, 'is_deleted' => 'false'], + )->successful(); } return true; diff --git a/app/Services/CloudflareTunnelService.php b/app/Services/CloudflareTunnelService.php new file mode 100644 index 0000000000..358b4075d4 --- /dev/null +++ b/app/Services/CloudflareTunnelService.php @@ -0,0 +1,283 @@ +uuid}"; + } + + /** + * Creates (or reuses) the tunnel, routes the hostname to the server SSH port, points a proxied CNAME at the tunnel, + * and starts cloudflared. The CloudflareTunnelChanged listener enables the tunnel when cloudflared is healthy. + * + * @param array{record_id: string, current: string}|null $confirmedConflict An existing DNS record the user agreed to replace. + * + * @throws DnsRecordConflictException when another DNS record already uses the hostname + * @throws LockTimeoutException when another DNS change for the hostname does not finish in time + * @throws RuntimeException when a Cloudflare call fails + */ + public function setUp(Server $server, IntegrationToken $token, string $hostname, ?array $confirmedConflict = null): Activity + { + $zone = $this->zoneFor($server, $token, $hostname); + $accountId = $zone->account_id; + if (blank($accountId)) { + throw new RuntimeException("Cloudflare returned no account for the zone {$zone->name}."); + } + + [$tunnelId, $tunnelToken, $created] = $this->findOrCreateTunnel($token, $accountId, self::tunnelName($server)); + + try { + $this->request($token, 'put', "/accounts/{$accountId}/cfd_tunnel/{$tunnelId}/configurations", [ + 'config' => ['ingress' => [ + ['hostname' => $hostname, 'service' => "ssh://localhost:{$server->port}"], + ['service' => 'http_status:404'], + ]], + ]); + + $target = "{$tunnelId}.cfargotunnel.com"; + if ($confirmedConflict !== null) { + $this->dns->replaceRecord($zone, $confirmedConflict['record_id'], $hostname, $target, $server, $confirmedConflict['current'], proxied: true); + } else { + $this->dns->createRecord($zone, $hostname, $target, $server, proxied: true); + } + } catch (Throwable $e) { + // Coolify does not track the tunnel yet, so a tunnel created by this run would stay in Cloudflare. + if ($created) { + $this->deleteUntrackedTunnel($token, $accountId, $tunnelId); + } + + throw $e; + } + $this->releaseDnsRecords($server, except: $hostname); + + // Saved before cloudflared starts, so disabling the tunnel can clean up even when cloudflared does not become healthy. + $server->settings->update([ + 'cloudflare_tunnel_id' => $tunnelId, + 'cloudflare_tunnel_account_id' => $accountId, + 'cloudflare_tunnel_integration_token_id' => $token->id, + ]); + + return ConfigureCloudflared::run($server, $tunnelToken, $hostname); + } + + /** + * Stops cloudflared and deletes the tunnel and the DNS record that setUp() created. + * Never throws; returns warnings for the user and always forgets the tunnel. + * + * @return array + */ + public function tearDown(Server $server): array + { + $settings = $server->settings; + if (blank($settings->cloudflare_tunnel_id)) { + return []; + } + + $warnings = []; + $name = self::tunnelName($server); + $token = IntegrationToken::query() + ->where('team_id', $server->team_id) + ->whereKey($settings->cloudflare_tunnel_integration_token_id) + ->first(); + + // Coolify usually reaches the server through this tunnel. Stopping cloudflared in the SSH session would cut + // that session and make SSH retry, so the stop runs detached and the session ends first. + instant_remote_process([ + "nohup setsid sh -c 'sleep 2; docker rm -f coolify-cloudflared' > /dev/null 2>&1 < /dev/null &", + ], $server, false); + + // The DNS record does not depend on the connector, so it goes first while cloudflared stops. + array_push($warnings, ...$this->releaseDnsRecords($server)); + + if ($token === null) { + $warnings[] = "The Cloudflare token of the tunnel no longer exists. Delete the tunnel {$name} in Cloudflare."; + } elseif (($warning = $this->deleteStoppedTunnel($token, $settings->cloudflare_tunnel_account_id, $settings->cloudflare_tunnel_id, $name)) !== null) { + $warnings[] = $warning; + } + + $settings->update([ + 'cloudflare_tunnel_id' => null, + 'cloudflare_tunnel_account_id' => null, + 'cloudflare_tunnel_integration_token_id' => null, + ]); + + return $warnings; + } + + /** + * Deletes the tunnel once its connector is gone. Removing the connections first lets Cloudflare forget a connector + * that already stopped; a connector that is still stopping keeps the delete failing until it disconnects. + */ + private function deleteStoppedTunnel(IntegrationToken $token, string $accountId, string $tunnelId, string $name): ?string + { + $path = self::API_URL."/accounts/{$accountId}/cfd_tunnel/{$tunnelId}"; + + try { + for ($attempt = 1; $attempt <= self::DELETE_ATTEMPTS; $attempt++) { + $this->client($token)->delete("{$path}/connections"); + $response = $this->client($token)->delete($path); + if ($response->successful() || $response->status() === 404) { + return null; + } + if ($response->status() === 403) { + return CloudflareDnsProvider::missingPermissionMessage(self::TUNNEL_PERMISSION)." Delete the tunnel {$name} in Cloudflare."; + } + if ($attempt < self::DELETE_ATTEMPTS) { + Sleep::for(2)->seconds(); + } + } + } catch (Throwable) { + return "Cloudflare did not respond. Delete the tunnel {$name} in Cloudflare."; + } + + return "Cloudflare could not delete the tunnel {$name}. Delete it in Cloudflare."; + } + + private function zoneFor(Server $server, IntegrationToken $token, string $hostname): DnsProviderZone + { + $find = fn (): ?DnsProviderZone => $this->dns->findZones($server->team_id, $hostname) + ->firstWhere('integration_token_id', $token->id); + + $zone = $find(); + if ($zone === null) { + $this->dns->syncZones($token); + $zone = $find(); + } + + return $zone ?? throw new RuntimeException("No Cloudflare zone of the token {$token->name} manages {$hostname}."); + } + + /** + * @return array{0: string, 1: string, 2: bool} tunnel ID, tunnel token, and whether this call created the tunnel + */ + private function findOrCreateTunnel(IntegrationToken $token, string $accountId, string $name): array + { + $existing = collect($this->request($token, 'get', "/accounts/{$accountId}/cfd_tunnel", [ + 'name' => $name, + 'is_deleted' => 'false', + ])->json('result', []))->first(fn ($tunnel): bool => is_array($tunnel) && ($tunnel['name'] ?? null) === $name); + + if (is_string($existing['id'] ?? null)) { + $tunnelId = $existing['id']; + $tunnelToken = $this->request($token, 'get', "/accounts/{$accountId}/cfd_tunnel/{$tunnelId}/token")->json('result'); + $created = false; + } else { + $response = $this->request($token, 'post', "/accounts/{$accountId}/cfd_tunnel", [ + 'name' => $name, + 'config_src' => 'cloudflare', + ]); + $tunnelId = $response->json('result.id'); + $tunnelToken = $response->json('result.token'); + $created = true; + } + + if (! is_string($tunnelId) || ! is_string($tunnelToken) || $tunnelToken === '') { + throw new RuntimeException('Cloudflare returned no tunnel token.'); + } + + return [$tunnelId, $tunnelToken, $created]; + } + + /** + * Best effort: the tunnel has no connector yet, so Cloudflare deletes it directly. + */ + private function deleteUntrackedTunnel(IntegrationToken $token, string $accountId, string $tunnelId): void + { + try { + $this->client($token)->delete(self::API_URL."/accounts/{$accountId}/cfd_tunnel/{$tunnelId}"); + } catch (Throwable) { + // The original error matters more; a re-run reuses the tunnel by name anyway. + } + } + + /** + * Releases the server's managed DNS records (except the given hostname). Records are deleted only when + * Coolify created them and nothing else uses the hostname. + * + * @return array + */ + private function releaseDnsRecords(Server $server, ?string $except = null): array + { + $key = [$server->getMorphClass(), $server->getKey()]; + $records = ManagedDnsRecord::query() + ->where('team_id', $server->team_id) + ->when($except !== null, fn ($query) => $query->where('name', '!=', $except)) + ->whereHas('references', fn ($query) => $query->where('resource_type', $key[0])->where('resource_id', $key[1])) + ->get(); + + return $records->map(function (ManagedDnsRecord $record) use ($key): ?string { + try { + $result = $this->cleanup->release($record, [$key], true); + } catch (Throwable) { + $result = ManagedDnsDeletionResult::Failed; + } + + return match ($result) { + ManagedDnsDeletionResult::ChangedExternally => "The DNS record {$record->name} changed outside Coolify and was left untouched.", + ManagedDnsDeletionResult::Failed, ManagedDnsDeletionResult::Busy => "The DNS record {$record->name} could not be deleted. Delete it in Cloudflare.", + default => null, + }; + })->filter()->values()->all(); + } + + /** + * @param array $data + */ + private function request(IntegrationToken $token, string $method, string $path, array $data = []): Response + { + $response = $this->client($token)->{$method}(self::API_URL.$path, $data); + + if ($response->status() === 403) { + throw new RuntimeException(CloudflareDnsProvider::missingPermissionMessage(self::TUNNEL_PERMISSION)); + } + if (! $response->successful() || $response->json('success') === false) { + $message = $response->json('errors.0.message'); + + throw new RuntimeException('Cloudflare Tunnel request failed'.(is_string($message) ? ": {$message}" : '.')); + } + + return $response; + } + + private function client(IntegrationToken $token): PendingRequest + { + return Http::withToken($token->token)->acceptJson()->connectTimeout(5)->timeout(15); + } +} diff --git a/app/Services/Dns/CloudflareDnsProvider.php b/app/Services/Dns/CloudflareDnsProvider.php index 995a632f8a..d3c57b78b5 100644 --- a/app/Services/Dns/CloudflareDnsProvider.php +++ b/app/Services/Dns/CloudflareDnsProvider.php @@ -31,6 +31,9 @@ class CloudflareDnsProvider $page = 1; do { $response = $this->client($token)->get('https://api.cloudflare.com/client/v4/zones', ['page' => $page, 'per_page' => 50]); + if ($response->status() === 403) { + throw new RuntimeException(self::missingPermissionMessage('Zone → Zone → Read')); + } if (! $response->successful() || $response->json('success') !== true) { throw new RuntimeException('Cloudflare zones could not be synchronized.'); } @@ -78,29 +81,54 @@ class CloudflareDnsProvider ->get(); } + public static function missingPermissionMessage(string $permission): string + { + return "The Cloudflare token needs the {$permission} permission."; + } + /** - * All records of the given type for a hostname (several records form a round-robin set). + * All records of the given type (all types when null) for a hostname (several records form a round-robin set). * * @return array */ - public function findRecords(DnsProviderZone $zone, string $hostname, string $type): array + public function findRecords(DnsProviderZone $zone, string $hostname, ?string $type): array { $hostname = strtolower(rtrim($hostname, '.')); $response = $this->client($zone->integrationToken)->get( "https://api.cloudflare.com/client/v4/zones/{$zone->provider_zone_id}/dns_records", - ['type' => $type, 'name' => $hostname, 'per_page' => 100], + array_filter(['type' => $type, 'name' => $hostname, 'per_page' => 100]), ); + if ($response->status() === 403) { + throw new RuntimeException(self::missingPermissionMessage('Zone → DNS → Edit')); + } if (! $response->successful()) { throw new RuntimeException('Cloudflare DNS records could not be checked.'); } return collect($response->json('result', [])) ->filter(fn ($remote): bool => is_array($remote)) - ->map(fn (array $remote): array => $this->normalizeRemoteRecord($remote, $hostname, $type)) + ->map(fn (array $remote): array => $this->normalizeRemoteRecord($remote, $hostname, $type ?? '')) ->values() ->all(); } + /** + * Records that block a new record of the type. A CNAME cannot share its name with A, AAAA, or other CNAME records. + * + * @return array + */ + private function conflictingRecords(DnsProviderZone $zone, string $hostname, string $type): array + { + if ($type !== 'CNAME') { + return $this->findRecords($zone, $hostname, $type); + } + + return array_values(array_filter( + $this->findRecords($zone, $hostname, null), + fn (array $remote): bool => in_array($remote['type'], ['A', 'AAAA', 'CNAME'], true), + )); + } + /** * Creates the record with Coolify's ownership comment, or references an existing record that already has the wanted content. * Existing records that Coolify did not create are only referenced and never marked as owned. @@ -111,15 +139,15 @@ class CloudflareDnsProvider * * @throws LockTimeoutException when another change for the hostname does not finish in time */ - public function createRecord(DnsProviderZone $zone, string $hostname, string $content, ?Model $resource = null, array $additionalResources = []): ManagedDnsRecord + public function createRecord(DnsProviderZone $zone, string $hostname, string $content, ?Model $resource = null, array $additionalResources = [], ?bool $proxied = null): ManagedDnsRecord { $hostname = strtolower(rtrim($hostname, '.')); return $this->hostnameLock->run( (int) $zone->integrationToken->team_id, $hostname, - function () use ($zone, $hostname, $content, $resource, $additionalResources): ManagedDnsRecord { - $record = $this->createRecordLocked($zone, $hostname, $content, $resource); + function () use ($zone, $hostname, $content, $resource, $additionalResources, $proxied): ManagedDnsRecord { + $record = $this->createRecordLocked($zone, $hostname, $content, $resource, $proxied); foreach ($additionalResources as $additionalResource) { $record->addReference($additionalResource); } @@ -129,11 +157,11 @@ class CloudflareDnsProvider ); } - private function createRecordLocked(DnsProviderZone $zone, string $hostname, string $content, ?Model $resource): ManagedDnsRecord + private function createRecordLocked(DnsProviderZone $zone, string $hostname, string $content, ?Model $resource, ?bool $proxied): ManagedDnsRecord { $type = $this->recordType($content); - $remoteRecords = $this->findRecords($zone, $hostname, $type); - $matching = collect($remoteRecords)->first(fn (array $remote): bool => DnsRecordHints::sameAddress($remote['content'], $content)); + $remoteRecords = $this->conflictingRecords($zone, $hostname, $type); + $matching = collect($remoteRecords)->first(fn (array $remote): bool => $remote['type'] === $type && DnsRecordHints::sameAddress($remote['content'], $content)); if ($matching !== null) { if ($matching['id'] === '') { throw new RuntimeException('Cloudflare DNS records could not be checked.'); @@ -154,9 +182,12 @@ class CloudflareDnsProvider $uuid = new_public_id(); $response = $this->client($zone->integrationToken)->post("https://api.cloudflare.com/client/v4/zones/{$zone->provider_zone_id}/dns_records", [ 'type' => $type, 'name' => $hostname, 'content' => $content, 'ttl' => 1, - 'proxied' => $zone->integrationToken->proxiedDnsEnabled(), + 'proxied' => $proxied ?? $zone->integrationToken->proxiedDnsEnabled(), 'comment' => ManagedDnsRecord::ownershipCommentFor($uuid), ]); + if ($response->status() === 403) { + throw new RuntimeException(self::missingPermissionMessage('Zone → DNS → Edit')); + } if (! $response->successful() || ! is_string($response->json('result.id'))) { throw new RuntimeException('Cloudflare could not create the DNS record.'); } @@ -176,7 +207,8 @@ class CloudflareDnsProvider /** * Points an existing (conflicting) record at the new content after explicit user confirmation. - * Only the content changes: proxy status, TTL, comment and other settings stay as they are. + * Only the content (and the type, when it differs) changes: TTL, comment and other settings stay as they are. + * The proxy status changes only when $proxied is set. * * @param array $additionalResources Other resources that use the hostname and also reference the record. * @@ -190,14 +222,15 @@ class CloudflareDnsProvider ?Model $resource = null, ?string $expectedCurrent = null, array $additionalResources = [], + bool $proxied = false, ): ManagedDnsRecord { $hostname = strtolower(rtrim($hostname, '.')); return $this->hostnameLock->run( (int) $zone->integrationToken->team_id, $hostname, - function () use ($zone, $recordId, $hostname, $content, $resource, $expectedCurrent, $additionalResources): ManagedDnsRecord { - $record = $this->replaceRecordLocked($zone, $recordId, $hostname, $content, $resource, $expectedCurrent); + function () use ($zone, $recordId, $hostname, $content, $resource, $expectedCurrent, $additionalResources, $proxied): ManagedDnsRecord { + $record = $this->replaceRecordLocked($zone, $recordId, $hostname, $content, $resource, $expectedCurrent, $proxied); foreach ($additionalResources as $additionalResource) { $record->addReference($additionalResource); } @@ -214,9 +247,10 @@ class CloudflareDnsProvider string $content, ?Model $resource, ?string $expectedCurrent, + bool $proxied, ): ManagedDnsRecord { $type = $this->recordType($content); - $remoteRecords = $this->findRecords($zone, $hostname, $type); + $remoteRecords = $this->conflictingRecords($zone, $hostname, $type); if (count($remoteRecords) > 1) { throw new RuntimeException("Several DNS records already exist for {$hostname}. Update them in Cloudflare."); } @@ -229,10 +263,20 @@ class CloudflareDnsProvider throw new RuntimeException('The DNS conflict is no longer available. Check the record again.'); } + $changes = ['content' => $content]; + if ($remote['type'] !== $type) { + $changes['type'] = $type; + } + if ($proxied) { + $changes['proxied'] = true; + } $response = $this->client($zone->integrationToken)->patch( "https://api.cloudflare.com/client/v4/zones/{$zone->provider_zone_id}/dns_records/{$remote['id']}", - ['content' => $content], + $changes, ); + if ($response->status() === 403) { + throw new RuntimeException(self::missingPermissionMessage('Zone → DNS → Edit')); + } if (! $response->successful()) { throw new RuntimeException('Cloudflare could not replace the conflicting DNS record.'); } @@ -337,7 +381,11 @@ class CloudflareDnsProvider private function recordType(string $content): string { - return filter_var($content, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) ? 'AAAA' : 'A'; + return match (true) { + filter_var($content, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false => 'AAAA', + filter_var($content, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) !== false => 'A', + default => 'CNAME', + }; } private function auditDnsRecord(string $action, DnsProviderZone $zone, string $hostname, ?Model $resource): void diff --git a/config/development-qemu.php b/config/development-qemu.php index ed93362258..983cd587aa 100644 --- a/config/development-qemu.php +++ b/config/development-qemu.php @@ -145,7 +145,8 @@ return [ 'subnet' => $developmentQemuInstance === null ? '192.168.122.0/24' : "10.221.{$developmentQemuSlot}.0/24", 'prefix' => 24, 'dns' => '1.1.1.1', - 'memory' => 2048, + // MiB per VM. Many dev instances share one host, so keep this small; existing VMs get it on their next start. + 'memory' => (int) env('DEVELOPMENT_QEMU_MEMORY', 1024), 'vcpus' => 2, 'disk_size' => '20G', 'libvirt_network' => $developmentQemuInstance === null ? 'default' : "coolify-dev-{$developmentQemuSlot}", diff --git a/database/migrations/2026_10_07_125311_add_cloudflare_tunnel_api_fields_to_server_settings.php b/database/migrations/2026_10_07_125311_add_cloudflare_tunnel_api_fields_to_server_settings.php new file mode 100644 index 0000000000..41cf9e0094 --- /dev/null +++ b/database/migrations/2026_10_07_125311_add_cloudflare_tunnel_api_fields_to_server_settings.php @@ -0,0 +1,31 @@ +string('cloudflare_tunnel_id')->nullable(); + $table->string('cloudflare_tunnel_account_id')->nullable(); + $table->foreignId('cloudflare_tunnel_integration_token_id')->nullable()->constrained('integration_tokens')->nullOnDelete(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('server_settings', function (Blueprint $table) { + $table->dropConstrainedForeignId('cloudflare_tunnel_integration_token_id'); + $table->dropColumn(['cloudflare_tunnel_id', 'cloudflare_tunnel_account_id']); + }); + } +}; diff --git a/resources/views/livewire/security/integration-token-editor.blade.php b/resources/views/livewire/security/integration-token-editor.blade.php index ed7d108aed..46882ba513 100644 --- a/resources/views/livewire/security/integration-token-editor.blade.php +++ b/resources/views/livewire/security/integration-token-editor.blade.php @@ -34,10 +34,15 @@ Capabilities
+ :disabled="in_array('tunnel', $capabilities, true)" wire:model.live="capabilities" />

Manage Cloudflare DNS records.

+ +

+ Create and manage tunnels for server SSH. Needs DNS. +

@error('capabilities') {{ $message }} @@ -71,8 +76,11 @@
  • Zone - DNS - Edit
  • Zone - Zone - Read
  • + @if (in_array('tunnel', $capabilities, true)) +
  • Account - Cloudflare Tunnel - Edit
  • + @endif
- Create a replacement token in Cloudflare diff --git a/resources/views/livewire/security/integration-token-form.blade.php b/resources/views/livewire/security/integration-token-form.blade.php index a085ebbe1a..004c642f03 100644 --- a/resources/views/livewire/security/integration-token-form.blade.php +++ b/resources/views/livewire/security/integration-token-form.blade.php @@ -42,10 +42,15 @@ Capabilities
+ :disabled="in_array('tunnel', $capabilities, true)" wire:model.live="capabilities" />

Manage Cloudflare DNS records.

+ +

+ Create and manage tunnels for server SSH. Needs DNS. +

@error('capabilities') {{ $message }} @@ -79,9 +84,12 @@
  • Zone - DNS - Edit
  • Zone - Zone - Read
  • + @if (in_array('tunnel', $capabilities, true)) +
  • Account - Cloudflare Tunnel - Edit
  • + @endif

Limit zone resources to the zones Coolify should manage.

-
Create token at Cloudflare diff --git a/resources/views/livewire/server/cloudflare-tunnel.blade.php b/resources/views/livewire/server/cloudflare-tunnel.blade.php index 255260ca18..7ae00efde6 100644 --- a/resources/views/livewire/server/cloudflare-tunnel.blade.php +++ b/resources/views/livewire/server/cloudflare-tunnel.blade.php @@ -22,31 +22,60 @@ - + Documentation + + + @endif + @if (!$isCloudflareTunnelsEnabled && $server->isFunctional() && auth()->user()->can('update', $server)) + - @else - + @endif + @if ($isCloudflareTunnelsEnabled) + @endif @if ($isCloudflareTunnelsEnabled) + @if (filled($server->settings->cloudflare_tunnel_id)) +

+ Coolify manages this tunnel through the Cloudflare API (tunnel + {{ \App\Services\CloudflareTunnelService::tunnelName($server) }}). +

+ @endif The server IP must be restored to its direct address after disabling the tunnel. + @can('update', $server) +
+ +
+ @endcan @elseif (!$server->isFunctional()) Automated configuration requires a validated server, a Cloudflare token, and an SSH domain. @@ -56,25 +85,6 @@ . @else -

- Choose automated setup to install and configure cloudflared, or confirm that you already - configured the tunnel manually. -

- @endif - - - @if (!$isCloudflareTunnelsEnabled && $server->isFunctional()) - - - - Documentation - - - - @cannot('update', $server) You do not have permission to configure Cloudflare Tunnel for this server. @@ -86,34 +96,88 @@ -
-
- - -
-
- Configure tunnel -
-
+ + @if ($tunnelTokens->isEmpty()) + + @if ($hasCloudflareTokens) + Enable the Cloudflare Tunnel capability on a Cloudflare token + @else + Add a Cloudflare token with the DNS and Cloudflare Tunnel capabilities + @endif + in + Integration tokens. + The token needs Account - Cloudflare Tunnel - Edit, Zone - DNS - Edit, and Zone - Zone - Read. + + @can('create', App\Models\IntegrationToken::class) +
+ + + + + + +
+ @endcan + @else +
+
+ + +
+
+ Set up tunnel +
+
+ @if ($dnsConflict) +
+ + {{ $dnsConflict['hostname'] }} already points to {{ $dnsConflict['current'] }}. + Replace it with the tunnel record to continue. + +
+ +
+
+ @endif + @endif + +
+ +

+ For a tunnel you created in the Cloudflare dashboard. Coolify does not need a + Cloudflare API token for this. +

+
+
+ + +
+
+ Configure tunnel +
+
+
+
@endcannot -
- - - @can('update', $server) - - @endcan - + @endif + + @if (!$isCloudflareTunnelsEnabled && $server->isFunctional()) @script