feat(server): set up Cloudflare tunnels with API tokens (#12157)

This commit is contained in:
Andras Bacsai
2026-10-13 11:32:26 +02:00
committed by GitHub
parent c35d3d5c8c
commit f37761253d
19 changed files with 1284 additions and 121 deletions
+1 -1
View File
@@ -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 <name> || true` before `docker run --name <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`.
@@ -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
@@ -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'],
];
+17 -5
View File
@@ -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');
@@ -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();
+134 -7
View File
@@ -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<int, IntegrationToken>
*/
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();
}
/**
+39
View File
@@ -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<int, string>
*/
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<int, string> $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;
+3
View File
@@ -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',
+20 -9
View File
@@ -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;
+283
View File
@@ -0,0 +1,283 @@
<?php
namespace App\Services;
use App\Actions\Server\ConfigureCloudflared;
use App\Enums\ManagedDnsDeletionResult;
use App\Exceptions\DnsRecordConflictException;
use App\Models\DnsProviderZone;
use App\Models\IntegrationToken;
use App\Models\ManagedDnsRecord;
use App\Models\Server;
use App\Services\Dns\CloudflareDnsProvider;
use App\Services\Dns\ManagedDnsRecordCleanup;
use Illuminate\Contracts\Cache\LockTimeoutException;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Sleep;
use RuntimeException;
use Spatie\Activitylog\Models\Activity;
use Throwable;
/**
* Sets up and removes a remotely managed Cloudflare Tunnel for server SSH with a stored Cloudflare API token.
* The tunnel token is never stored: Cloudflare returns it on every setup run.
*/
class CloudflareTunnelService
{
private const API_URL = 'https://api.cloudflare.com/client/v4';
private const TUNNEL_PERMISSION = 'Account → Cloudflare Tunnel → Edit';
/** Cloudflare does not delete a tunnel while the stopping connector is still connected; wait at most this often. */
private const DELETE_ATTEMPTS = 5;
public function __construct(
private CloudflareDnsProvider $dns,
private ManagedDnsRecordCleanup $cleanup,
) {}
public static function tunnelName(Server $server): string
{
return "coolify-{$server->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<int, string>
*/
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<int, string>
*/
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<string, mixed> $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);
}
}
+65 -17
View File
@@ -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<int, array{id: string, type: string, name: string, content: string, proxied: bool|null, ttl: int|null, comment: string|null}>
*/
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<int, array{id: string, type: string, name: string, content: string, proxied: bool|null, ttl: int|null, comment: string|null}>
*/
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<int, Model> $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
+2 -1
View File
@@ -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}",
@@ -0,0 +1,31 @@
<?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('server_settings', function (Blueprint $table) {
$table->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']);
});
}
};
@@ -34,10 +34,15 @@
<legend class="text-sm font-medium text-black dark:text-fg">Capabilities</legend>
<div class="mt-3 rounded-lg border border-neutral-200 p-1 dark:border-white/[0.08]">
<x-forms.checkbox id="edit-dns-capability" label="DNS" domValue="dns" fullWidth
wire:model.live="capabilities" />
:disabled="in_array('tunnel', $capabilities, true)" wire:model.live="capabilities" />
<p class="px-2.5 pb-2 text-[11px] text-neutral-500 dark:text-fg-dim">
Manage Cloudflare DNS records.
</p>
<x-forms.checkbox id="edit-tunnel-capability" label="Cloudflare Tunnel" domValue="tunnel" fullWidth
wire:model.live="capabilities" />
<p class="px-2.5 pb-2 text-[11px] text-neutral-500 dark:text-fg-dim">
Create and manage tunnels for server SSH. Needs DNS.
</p>
</div>
@error('capabilities')
<span class="text-xs text-red-500">{{ $message }}</span>
@@ -71,8 +76,11 @@
<ul class="list-inside list-disc">
<li>Zone - DNS - Edit</li>
<li>Zone - Zone - Read</li>
@if (in_array('tunnel', $capabilities, true))
<li>Account - Cloudflare Tunnel - Edit</li>
@endif
</ul>
<a href="https://dash.cloudflare.com/profile/api-tokens?permissionGroupKeys=%5B%7B%22key%22%3A%22dns%22%2C%22type%22%3A%22edit%22%7D%5D&amp;accountId=%2A&amp;zoneId=all&amp;name=Coolify%20DNS%20Management"
<a href="{{ \App\Models\IntegrationToken::cloudflareTokenTemplateUrl($capabilities) }}"
target="_blank" rel="noopener noreferrer"
class="font-medium text-coollabs hover:underline dark:text-warning">
Create a replacement token in Cloudflare
@@ -42,10 +42,15 @@
<legend class="text-sm font-medium text-black dark:text-fg">Capabilities</legend>
<div class="mt-3 rounded-lg border border-neutral-200 p-1 dark:border-white/[0.08]">
<x-forms.checkbox id="dns-capability" label="DNS" domValue="dns" fullWidth
wire:model.live="capabilities" />
:disabled="in_array('tunnel', $capabilities, true)" wire:model.live="capabilities" />
<p class="px-2.5 pb-2 text-[11px] text-neutral-500 dark:text-fg-dim">
Manage Cloudflare DNS records.
</p>
<x-forms.checkbox id="tunnel-capability" label="Cloudflare Tunnel" domValue="tunnel" fullWidth
wire:model.live="capabilities" />
<p class="px-2.5 pb-2 text-[11px] text-neutral-500 dark:text-fg-dim">
Create and manage tunnels for server SSH. Needs DNS.
</p>
</div>
@error('capabilities')
<span class="text-xs text-red-500">{{ $message }}</span>
@@ -79,9 +84,12 @@
<ul class="list-inside list-disc">
<li>Zone - DNS - Edit</li>
<li>Zone - Zone - Read</li>
@if (in_array('tunnel', $capabilities, true))
<li>Account - Cloudflare Tunnel - Edit</li>
@endif
</ul>
<p>Limit zone resources to the zones Coolify should manage.</p>
<a href="https://dash.cloudflare.com/profile/api-tokens?permissionGroupKeys=%5B%7B%22key%22%3A%22dns%22%2C%22type%22%3A%22edit%22%7D%5D&amp;accountId=%2A&amp;zoneId=all&amp;name=Coolify%20DNS%20Management"
<a href="{{ \App\Models\IntegrationToken::cloudflareTokenTemplateUrl($capabilities) }}"
target="_blank" rel="noopener noreferrer"
class="button mt-2">
Create token at Cloudflare
@@ -22,31 +22,60 @@
<livewire:activity-monitor header="Logs" fullHeight />
</x-slot:content>
</x-process-dialog>
<x-modal-confirmation title="Disable Cloudflare Tunnel?"
buttonTitle="Disable Cloudflare Tunnel" isErrorButton
submitAction="toggleCloudflareTunnels" :actions="$server->ip_previous
? [
'Cloudflare Tunnel will be disabled for this server.',
'The server IP address will be restored to its previous value.',
]
: [
'Cloudflare Tunnel will be disabled for this server.',
'You must manually restore the direct server IP address.',
'The server may become inaccessible until the IP is corrected.',
]"
confirmationText="DISABLE CLOUDFLARE TUNNEL"
confirmationLabel="Type the confirmation text to disable Cloudflare Tunnel."
@endif
@if (!$isCloudflareTunnelsEnabled)
<a class="button" href="https://coolify.io/docs/knowledge-base/cloudflare/tunnels/server-ssh"
target="_blank">
Documentation
<x-external-link />
</a>
@endif
@if (!$isCloudflareTunnelsEnabled && $server->isFunctional() && auth()->user()->can('update', $server))
<x-modal-confirmation title="Confirm manual Cloudflare Tunnel configuration"
buttonTitle="I configured the tunnel manually"
submitAction="manualCloudflareConfig" :actions="[
'Cloudflare and cloudflared have already been configured.',
'An incomplete setup can make the server unreachable.',
]" confirmationText="I manually configured Cloudflare Tunnel"
confirmationLabel="Type the confirmation text to continue."
shortConfirmationLabel="Confirmation text" />
@else
<x-status-badge :status="$isCloudflareTunnelsEnabled ? 'Enabled' : 'Disabled'"
:type="$isCloudflareTunnelsEnabled ? 'success' : 'neutral'" />
@endif
@if ($isCloudflareTunnelsEnabled)
<x-status-badge status="Enabled" type="success" />
@endif
</x-slot:actions>
@if ($isCloudflareTunnelsEnabled)
@if (filled($server->settings->cloudflare_tunnel_id))
<p class="mb-4 text-sm leading-6 text-neutral-600 dark:text-fg-dim">
Coolify manages this tunnel through the Cloudflare API (tunnel
<code>{{ \App\Services\CloudflareTunnelService::tunnelName($server) }}</code>).
</p>
@endif
<x-callout type="warning" title="Disabling the tunnel can interrupt server access">
The server IP must be restored to its direct address after disabling the tunnel.
</x-callout>
@can('update', $server)
<div class="mt-4">
<x-modal-confirmation title="Disable Cloudflare Tunnel?"
buttonTitle="Disable Cloudflare Tunnel" isErrorButton
submitAction="toggleCloudflareTunnels" :actions="array_merge(
['Cloudflare Tunnel will be disabled for this server.'],
filled($server->settings->cloudflare_tunnel_id)
? ['The Cloudflare tunnel and the DNS record that Coolify created will be deleted.']
: [],
$server->ip_previous
? ['The server IP address will be restored to its previous value.']
: [
'You must manually restore the direct server IP address.',
'The server may become inaccessible until the IP is corrected.',
],
)"
confirmationText="DISABLE CLOUDFLARE TUNNEL"
confirmationLabel="Type the confirmation text to disable Cloudflare Tunnel."
shortConfirmationLabel="Confirmation text" />
</div>
@endcan
@elseif (!$server->isFunctional())
<x-callout type="info" title="Validate the server for automated setup">
Automated configuration requires a validated server, a Cloudflare token, and an SSH domain.
@@ -56,25 +85,6 @@
</button>.
</x-callout>
@else
<p class="text-sm leading-6 text-neutral-600 dark:text-fg-dim">
Choose automated setup to install and configure cloudflared, or confirm that you already
configured the tunnel manually.
</p>
@endif
</x-application.settings-section>
@if (!$isCloudflareTunnelsEnabled && $server->isFunctional())
<x-application.settings-section id="server-cloudflare-automated-section" title="Automated setup"
helper="Let Coolify configure the Cloudflare SSH tunnel on this server.">
<x-slot:actions>
<a class="button"
href="https://coolify.io/docs/knowledge-base/cloudflare/tunnels/server-ssh"
target="_blank">
Documentation
<x-external-link />
</a>
</x-slot:actions>
@cannot('update', $server)
<x-callout type="danger" title="Insufficient permissions">
You do not have permission to configure Cloudflare Tunnel for this server.
@@ -86,34 +96,88 @@
<livewire:activity-monitor header="Logs" fullHeight />
</x-slot:content>
</x-process-dialog>
<form @submit.prevent="$wire.dispatch('automatedCloudflareConfig')">
<div class="grid gap-4 lg:grid-cols-2">
<x-forms.input id="cloudflare_token" required label="Cloudflare token"
type="password" />
<x-forms.input id="ssh_domain" label="SSH domain" required
helper="Enter the hostname configured in Cloudflare without a protocol." />
</div>
<div class="mt-4 flex justify-end">
<x-forms.button type="submit" isHighlighted>Configure tunnel</x-forms.button>
</div>
</form>
@if ($tunnelTokens->isEmpty())
<x-callout type="info" title="No Cloudflare token can manage tunnels">
@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
<a href="{{ route('security.integration-tokens') }}" class="font-medium underline"
{{ wireNavigate() }}>Integration tokens</a>.
The token needs Account - Cloudflare Tunnel - Edit, Zone - DNS - Edit, and Zone - Zone - Read.
</x-callout>
@can('create', App\Models\IntegrationToken::class)
<div class="mt-4">
<x-modal-input title="New Integration Token">
<x-slot:content>
<button type="button" class="button button-highlighted">
<x-reicon name="plus" class="size-3.5" />
Add Cloudflare token
</button>
</x-slot:content>
<livewire:security.integration-token-form :modal_mode="true"
:capabilities="['dns', 'tunnel']" wire:key="new-cloudflare-tunnel-token" />
</x-modal-input>
</div>
@endcan
@else
<form wire:submit="apiCloudflareConfig">
<div class="grid gap-4 lg:grid-cols-2">
<x-forms.listbox id="cloudflare_integration_token_id" label="Cloudflare token"
required placeholder="Select a token"
:options="$tunnelTokens->map(fn ($token) => ['value' => $token->id, 'label' => $token->name])->all()" />
<x-forms.input id="api_ssh_domain" label="SSH hostname" required
placeholder="ssh.example.com"
helper="A hostname in a zone of the selected token. Cloudflare routes it to SSH port {{ $server->port }} of this server." />
</div>
<div class="mt-4 flex justify-end">
<x-forms.button type="submit" isHighlighted>Set up tunnel</x-forms.button>
</div>
</form>
@if ($dnsConflict)
<div class="mt-4">
<x-callout type="warning" title="A DNS record already exists">
{{ $dnsConflict['hostname'] }} already points to {{ $dnsConflict['current'] }}.
Replace it with the tunnel record to continue.
</x-callout>
<div class="mt-4">
<x-modal-confirmation title="Replace DNS record?" buttonTitle="Replace DNS record"
submitAction="replaceDnsAndConfigure" :actions="[
'The DNS record of '.$dnsConflict['hostname'].' will point to the Cloudflare tunnel.',
'Traffic that uses the current record value will stop.',
]" :confirmWithText="false" :confirmWithPassword="false" />
</div>
</div>
@endif
@endif
<div class="mt-4 border-t border-neutral-200 pt-4 dark:border-white/[0.08]">
<x-forms.collapsible title="Use an existing tunnel with its tunnel token">
<p class="text-sm leading-6 text-neutral-600 dark:text-fg-dim">
For a tunnel you created in the Cloudflare dashboard. Coolify does not need a
Cloudflare API token for this.
</p>
<form class="mt-4" @submit.prevent="$wire.dispatch('automatedCloudflareConfig')">
<div class="grid gap-4 lg:grid-cols-2">
<x-forms.input id="cloudflare_token" required label="Tunnel token" type="password"
helper="The token of the tunnel connector, from the Cloudflare dashboard." />
<x-forms.input id="ssh_domain" label="SSH domain" required
helper="The public hostname of the tunnel that routes to SSH on this server, without a protocol." />
</div>
<div class="mt-4 flex justify-end">
<x-forms.button type="submit">Configure tunnel</x-forms.button>
</div>
</form>
</x-forms.collapsible>
</div>
@endcannot
</x-application.settings-section>
<x-application.settings-section id="server-cloudflare-manual-section" title="Manual setup"
helper="Use this only after cloudflared and the Cloudflare tunnel are already configured.">
@can('update', $server)
<x-modal-confirmation title="Confirm manual Cloudflare Tunnel configuration"
buttonTitle="I configured the tunnel manually"
submitAction="manualCloudflareConfig" :actions="[
'Cloudflare and cloudflared have already been configured.',
'An incomplete setup can make the server unreachable.',
]" confirmationText="I manually configured Cloudflare Tunnel"
confirmationLabel="Type the confirmation text to continue."
shortConfirmationLabel="Confirmation text" />
@endcan
</x-application.settings-section>
@endif
</x-application.settings-section>
@if (!$isCloudflareTunnelsEnabled && $server->isFunctional())
@script
<script>
$wire.$on('automatedCloudflareConfig', () => {
@@ -0,0 +1,470 @@
<?php
use App\Actions\Server\ConfigureCloudflared;
use App\Livewire\Security\IntegrationTokenEditor;
use App\Livewire\Security\IntegrationTokenForm;
use App\Livewire\Server\CloudflareTunnel;
use App\Models\DnsProviderZone;
use App\Models\InstanceSettings;
use App\Models\IntegrationToken;
use App\Models\ManagedDnsRecord;
use App\Models\PrivateKey;
use App\Models\Server;
use App\Models\Team;
use App\Models\User;
use App\Services\CloudflareTokenValidator;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Once;
use Illuminate\Support\Sleep;
use Livewire\Livewire;
use Spatie\Activitylog\Models\Activity;
uses(RefreshDatabase::class);
const CF_TUNNEL_API = '/client/v4/accounts/acc-1/cfd_tunnel';
const CF_TUNNEL_DNS = '/client/v4/zones/zone-1/dns_records';
beforeEach(function () {
$this->withoutDefer();
$this->withoutVite();
Server::flushIdentityMap();
Queue::fake();
config(['constants.ssh.mux_enabled' => false]);
InstanceSettings::forceCreate(['id' => 0]);
Once::flush();
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
$this->actingAs($this->user);
session(['currentTeam' => $this->team]);
$this->server = Server::factory()->create(['team_id' => $this->team->id, 'port' => 2222]);
$this->server->settings->update(['is_reachable' => true, 'is_usable' => true, 'force_disabled' => false]);
$this->token = IntegrationToken::factory()->for($this->team)->create([
'name' => 'Cloudflare tunnel', 'token' => 'cf-api-secret', 'capabilities' => ['dns', 'tunnel'],
]);
DnsProviderZone::factory()->for($this->token)->create([
'provider_zone_id' => 'zone-1', 'name' => 'example.com', 'account_id' => 'acc-1',
]);
$this->tunnelName = "coolify-{$this->server->uuid}";
});
afterEach(function () {
Server::flushIdentityMap();
});
/**
* Fakes Cloudflare by "METHOD path" routes; any other request fails the test.
*
* @param array<string, mixed> $routes
*/
function fakeCloudflareTunnelApi(array $routes): void
{
Http::fake(function (Request $request) use ($routes) {
$key = $request->method().' '.parse_url($request->url(), PHP_URL_PATH);
if (! array_key_exists($key, $routes)) {
throw new RuntimeException("Unexpected Cloudflare request: {$key}");
}
return $routes[$key];
});
}
function newCloudflareTunnelRoutes(array $overrides = []): array
{
return array_merge([
'GET '.CF_TUNNEL_API => Http::response(['success' => true, 'result' => []]),
'POST '.CF_TUNNEL_API => Http::response(['success' => true, 'result' => ['id' => 'tun-1', 'token' => 'tunnel-secret']]),
'PUT '.CF_TUNNEL_API.'/tun-1/configurations' => Http::response(['success' => true, 'result' => []]),
'GET '.CF_TUNNEL_DNS => Http::response(['success' => true, 'result' => []]),
'POST '.CF_TUNNEL_DNS => Http::response(['success' => true, 'result' => ['id' => 'rec-1']]),
], $overrides);
}
function fakeCloudflaredActivity(string $expectedTunnelToken): void
{
$activity = Activity::create(['log_name' => 'default', 'description' => 'queued', 'properties' => ['status' => 'queued']]);
ConfigureCloudflared::shouldRun()->once()
->withArgs(fn (Server $server, string $tunnelToken, string $hostname) => $tunnelToken === $expectedTunnelToken && $hostname === 'ssh.example.com')
->andReturn($activity);
}
test('a new tunnel is created, routed to the server ssh port, and gets a proxied cname', function () {
fakeCloudflareTunnelApi(newCloudflareTunnelRoutes());
fakeCloudflaredActivity('tunnel-secret');
Livewire::test(CloudflareTunnel::class, ['server_uuid' => $this->server->uuid])
->set('cloudflare_integration_token_id', $this->token->id)
->set('api_ssh_domain', 'https://SSH.example.com/')
->call('apiCloudflareConfig')
->assertHasNoErrors()
->assertDispatched('activityMonitor');
Http::assertSent(fn (Request $request) => $request->method() === 'POST'
&& str_ends_with($request->url(), CF_TUNNEL_API)
&& $request['name'] === $this->tunnelName && $request['config_src'] === 'cloudflare'
&& $request->hasHeader('Authorization', 'Bearer cf-api-secret'));
Http::assertSent(fn (Request $request) => $request->method() === 'PUT'
&& $request['config']['ingress'] === [
['hostname' => 'ssh.example.com', 'service' => 'ssh://localhost:2222'],
['service' => 'http_status:404'],
]);
Http::assertSent(fn (Request $request) => $request->method() === 'POST'
&& str_ends_with($request->url(), CF_TUNNEL_DNS)
&& $request['type'] === 'CNAME' && $request['content'] === 'tun-1.cfargotunnel.com' && $request['proxied'] === true);
$settings = $this->server->settings->fresh();
$record = ManagedDnsRecord::query()->sole();
expect($settings->cloudflare_tunnel_id)->toBe('tun-1')
->and($settings->cloudflare_tunnel_account_id)->toBe('acc-1')
->and($settings->cloudflare_tunnel_integration_token_id)->toBe($this->token->id)
->and($record->owned)->toBeTrue()
->and($record->references()->sole()->resource_id)->toBe($this->server->id);
});
test('a re-run reuses the existing tunnel and its dns record', function () {
$record = ManagedDnsRecord::factory()->create([
'team_id' => $this->team->id, 'integration_token_id' => $this->token->id,
'dns_provider_zone_id' => DnsProviderZone::query()->sole()->id, 'provider_record_id' => 'rec-1',
'type' => 'CNAME', 'name' => 'ssh.example.com', 'content' => 'tun-1.cfargotunnel.com', 'owned' => true,
]);
fakeCloudflareTunnelApi(newCloudflareTunnelRoutes([
'GET '.CF_TUNNEL_API => Http::response(['success' => true, 'result' => [['id' => 'tun-1', 'name' => $this->tunnelName]]]),
'GET '.CF_TUNNEL_API.'/tun-1/token' => Http::response(['success' => true, 'result' => 'tunnel-secret-again']),
'GET '.CF_TUNNEL_DNS => Http::response(['success' => true, 'result' => [[
'id' => 'rec-1', 'type' => 'CNAME', 'name' => 'ssh.example.com', 'content' => 'tun-1.cfargotunnel.com',
'proxied' => true, 'comment' => $record->ownershipComment(),
]]]),
]));
fakeCloudflaredActivity('tunnel-secret-again');
Livewire::test(CloudflareTunnel::class, ['server_uuid' => $this->server->uuid])
->set('cloudflare_integration_token_id', $this->token->id)
->set('api_ssh_domain', 'ssh.example.com')
->call('apiCloudflareConfig')
->assertHasNoErrors();
Http::assertNotSent(fn (Request $request) => $request->method() === 'POST');
expect($record->fresh()->owned)->toBeTrue()
->and($this->server->settings->fresh()->cloudflare_tunnel_id)->toBe('tun-1');
});
test('an existing dns record is replaced only after confirmation', function () {
fakeCloudflareTunnelApi(newCloudflareTunnelRoutes([
'GET '.CF_TUNNEL_DNS => Http::response(['success' => true, 'result' => [[
'id' => 'rec-old', 'type' => 'A', 'name' => 'ssh.example.com', 'content' => '203.0.113.10', 'proxied' => false,
]]]),
'PATCH '.CF_TUNNEL_DNS.'/rec-old' => Http::response(['success' => true, 'result' => ['id' => 'rec-old']]),
'DELETE '.CF_TUNNEL_API.'/tun-1' => Http::response(['success' => true, 'result' => ['id' => 'tun-1']]),
]));
$component = Livewire::test(CloudflareTunnel::class, ['server_uuid' => $this->server->uuid])
->set('cloudflare_integration_token_id', $this->token->id)
->set('api_ssh_domain', 'ssh.example.com')
->call('apiCloudflareConfig')
->assertSet('dnsConflict.current', '203.0.113.10')
->assertSee('ssh.example.com already points to 203.0.113.10.');
Http::assertNotSent(fn (Request $request) => in_array($request->method(), ['PATCH', 'POST'], true) && str_contains($request->url(), 'dns_records'));
Http::assertSent(fn (Request $request) => $request->method() === 'DELETE' && str_ends_with($request->url(), CF_TUNNEL_API.'/tun-1'));
expect($this->server->settings->fresh()->cloudflare_tunnel_id)->toBeNull();
fakeCloudflaredActivity('tunnel-secret');
$component->call('replaceDnsAndConfigure')->assertSet('dnsConflict', null);
Http::assertSent(fn (Request $request) => $request->method() === 'PATCH'
&& $request->data() === ['content' => 'tun-1.cfargotunnel.com', 'type' => 'CNAME', 'proxied' => true]);
expect($this->server->settings->fresh()->cloudflare_tunnel_id)->toBe('tun-1');
});
test('a token added from the tunnel page is selected when it is the only tunnel token', function () {
$this->token->update(['capabilities' => ['dns']]);
$component = Livewire::test(CloudflareTunnel::class, ['server_uuid' => $this->server->uuid])
->assertSee('Add Cloudflare token');
$this->token->update(['capabilities' => ['dns', 'tunnel']]);
$component->dispatch('integrationTokenAdded')
->assertSet('cloudflare_integration_token_id', $this->token->id)
->assertDontSee('Add Cloudflare token');
});
test('a missing tunnel permission is reported and nothing is saved', function () {
fakeCloudflareTunnelApi(newCloudflareTunnelRoutes([
'GET '.CF_TUNNEL_API => Http::response(['success' => false, 'errors' => [['code' => 10000, 'message' => 'Authentication error']]], 403),
]));
ConfigureCloudflared::shouldRun()->never();
Livewire::test(CloudflareTunnel::class, ['server_uuid' => $this->server->uuid])
->set('cloudflare_integration_token_id', $this->token->id)
->set('api_ssh_domain', 'ssh.example.com')
->call('apiCloudflareConfig')
->assertDispatched('error', 'The Cloudflare token needs the Account → Cloudflare Tunnel → Edit permission.');
expect($this->server->settings->fresh()->cloudflare_tunnel_id)->toBeNull()
->and(ManagedDnsRecord::query()->count())->toBe(0);
});
test('a failed dns step deletes only a tunnel that the same run created', function (bool $tunnelExists, bool $deleted) {
fakeCloudflareTunnelApi(newCloudflareTunnelRoutes([
'GET '.CF_TUNNEL_API => Http::response(['success' => true, 'result' => $tunnelExists ? [['id' => 'tun-1', 'name' => $this->tunnelName]] : []]),
'GET '.CF_TUNNEL_API.'/tun-1/token' => Http::response(['success' => true, 'result' => 'tunnel-secret']),
'POST '.CF_TUNNEL_DNS => Http::response(['success' => false], 403),
'DELETE '.CF_TUNNEL_API.'/tun-1' => Http::response(['success' => true, 'result' => ['id' => 'tun-1']]),
]));
ConfigureCloudflared::shouldRun()->never();
Livewire::test(CloudflareTunnel::class, ['server_uuid' => $this->server->uuid])
->set('cloudflare_integration_token_id', $this->token->id)
->set('api_ssh_domain', 'ssh.example.com')
->call('apiCloudflareConfig')
->assertDispatched('error', 'The Cloudflare token needs the Zone → DNS → Edit permission.');
$deleteSent = fn (Request $request) => $request->method() === 'DELETE' && str_ends_with($request->url(), CF_TUNNEL_API.'/tun-1');
$deleted ? Http::assertSent($deleteSent) : Http::assertNotSent($deleteSent);
expect($this->server->settings->fresh()->cloudflare_tunnel_id)->toBeNull();
})->with([
'new tunnel' => [false, true],
'existing tunnel' => [true, false],
]);
describe('disable', function () {
beforeEach(function () {
Process::fake();
Sleep::fake();
Storage::fake('ssh-keys');
Storage::fake('ssh-mux');
$this->server->update(['private_key_id' => PrivateKey::factory()->create(['team_id' => $this->team->id])->id]);
$this->server->settings->update([
'is_cloudflare_tunnel' => true, 'cloudflare_tunnel_id' => 'tun-1',
'cloudflare_tunnel_account_id' => 'acc-1', 'cloudflare_tunnel_integration_token_id' => $this->token->id,
]);
$this->record = ManagedDnsRecord::factory()->create([
'team_id' => $this->team->id, 'integration_token_id' => $this->token->id,
'dns_provider_zone_id' => DnsProviderZone::query()->sole()->id, 'provider_record_id' => 'rec-1',
'type' => 'CNAME', 'name' => 'ssh.example.com', 'content' => 'tun-1.cfargotunnel.com', 'owned' => true,
]);
$this->record->addReference($this->server);
$this->server->update(['ip' => 'ssh.example.com', 'ip_previous' => '203.0.113.20']);
});
test('deletes the tunnel and the owned dns record', function () {
fakeCloudflareTunnelApi([
'DELETE '.CF_TUNNEL_API.'/tun-1/connections' => Http::response(['success' => true, 'result' => null]),
'DELETE '.CF_TUNNEL_API.'/tun-1' => Http::response(['success' => true, 'result' => ['id' => 'tun-1']]),
'GET '.CF_TUNNEL_DNS.'/rec-1' => Http::response(['success' => true, 'result' => [
'id' => 'rec-1', 'type' => 'CNAME', 'name' => 'ssh.example.com', 'content' => 'tun-1.cfargotunnel.com',
'comment' => $this->record->ownershipComment(),
]]),
'DELETE '.CF_TUNNEL_DNS.'/rec-1' => Http::response(['success' => true, 'result' => ['id' => 'rec-1']]),
]);
Livewire::test(CloudflareTunnel::class, ['server_uuid' => $this->server->uuid])
->call('toggleCloudflareTunnels')
->assertNotDispatched('warning');
// Detached, so stopping cloudflared does not cut the SSH session that runs through the tunnel.
Process::assertRan(fn ($process) => str_contains($process->command, "nohup setsid sh -c 'sleep 2; docker rm -f coolify-cloudflared' > /dev/null 2>&1 < /dev/null &"));
Sleep::assertNeverSlept();
Http::assertSent(fn (Request $request) => $request->method() === 'DELETE' && str_ends_with($request->url(), CF_TUNNEL_API.'/tun-1'));
Http::assertSent(fn (Request $request) => $request->method() === 'DELETE' && str_ends_with($request->url(), CF_TUNNEL_DNS.'/rec-1'));
$settings = $this->server->settings->fresh();
expect($settings->is_cloudflare_tunnel)->toBeFalsy()
->and($settings->cloudflare_tunnel_id)->toBeNull()
->and($settings->cloudflare_tunnel_integration_token_id)->toBeNull()
->and(ManagedDnsRecord::query()->count())->toBe(0);
});
test('still disables locally and warns when cloudflare fails', function () {
fakeCloudflareTunnelApi([
'DELETE '.CF_TUNNEL_API.'/tun-1/connections' => Http::response([], 500),
'DELETE '.CF_TUNNEL_API.'/tun-1' => Http::response([], 500),
'GET '.CF_TUNNEL_DNS.'/rec-1' => Http::response([], 500),
]);
Livewire::test(CloudflareTunnel::class, ['server_uuid' => $this->server->uuid])
->call('toggleCloudflareTunnels')
->assertDispatched('warning', "Cloudflare could not delete the tunnel {$this->tunnelName}. Delete it in Cloudflare.")
->assertDispatched('warning', 'The DNS record ssh.example.com could not be deleted. Delete it in Cloudflare.');
$settings = $this->server->settings->fresh();
expect($settings->is_cloudflare_tunnel)->toBeFalsy()
->and($settings->cloudflare_tunnel_id)->toBeNull();
Sleep::assertSleptTimes(4);
});
test('waits until the stopping connector disconnects before the tunnel is deleted', function () {
$deletes = 0;
Http::fake(function (Request $request) use (&$deletes) {
$path = parse_url($request->url(), PHP_URL_PATH);
return match (true) {
$request->method() === 'DELETE' && $path === CF_TUNNEL_API.'/tun-1' => ++$deletes === 1
? Http::response(['success' => false, 'errors' => [['code' => 1022, 'message' => 'Cannot delete tunnel because it has active connections']]], 400)
: Http::response(['success' => true, 'result' => ['id' => 'tun-1']]),
$request->method() === 'GET' && $path === CF_TUNNEL_DNS.'/rec-1' => Http::response(['success' => true, 'result' => [
'id' => 'rec-1', 'type' => 'CNAME', 'name' => 'ssh.example.com', 'content' => 'tun-1.cfargotunnel.com',
'comment' => $this->record->ownershipComment(),
]]),
default => Http::response(['success' => true, 'result' => null]),
};
});
Livewire::test(CloudflareTunnel::class, ['server_uuid' => $this->server->uuid])
->call('toggleCloudflareTunnels')
->assertNotDispatched('warning');
expect($deletes)->toBe(2);
Sleep::assertSleptTimes(1);
});
test('stops cloudflared detached for a non-root ssh user', function () {
$this->server->update(['user' => 'ubuntu']);
Http::fake(['*' => Http::response(['success' => true, 'result' => null])]);
Livewire::test(CloudflareTunnel::class, ['server_uuid' => $this->server->uuid])
->call('toggleCloudflareTunnels');
Process::assertRan(fn ($process) => str_contains($process->command,
"sudo bash -c 'nohup setsid sh -c '\\''sleep 2; docker rm -f coolify-cloudflared'\\'' > /dev/null 2>&1 < /dev/null &'"));
});
test('a tunnel that coolify did not create is disabled without cloudflare calls', function () {
Http::fake();
$this->server->settings->update(['cloudflare_tunnel_id' => null, 'cloudflare_tunnel_account_id' => null]);
Livewire::test(CloudflareTunnel::class, ['server_uuid' => $this->server->uuid])
->call('toggleCloudflareTunnels');
Http::assertNothingSent();
expect($this->server->settings->fresh()->is_cloudflare_tunnel)->toBeFalsy();
});
});
describe('authorization', function () {
test('members without update rights cannot start the setup', function () {
$member = User::factory()->create();
$this->team->members()->attach($member->id, ['role' => 'member']);
$this->actingAs($member);
Http::fake();
Livewire::test(CloudflareTunnel::class, ['server_uuid' => $this->server->uuid])
->set('cloudflare_integration_token_id', $this->token->id)
->set('api_ssh_domain', 'ssh.example.com')
->call('apiCloudflareConfig')
->assertForbidden();
Http::assertNothingSent();
});
test('a token of another team is rejected', function () {
$otherToken = IntegrationToken::factory()->create(['capabilities' => ['dns', 'tunnel']]);
Http::fake();
Livewire::test(CloudflareTunnel::class, ['server_uuid' => $this->server->uuid])
->set('cloudflare_integration_token_id', $otherToken->id)
->set('api_ssh_domain', 'ssh.example.com')
->call('apiCloudflareConfig')
->assertHasErrors('cloudflare_integration_token_id');
Http::assertNothingSent();
});
test('a token without the tunnel capability is not listed or accepted', function () {
$this->token->update(['capabilities' => ['dns']]);
Http::fake();
Livewire::test(CloudflareTunnel::class, ['server_uuid' => $this->server->uuid])
->assertSee('Enable the Cloudflare Tunnel capability on a Cloudflare token')
->set('cloudflare_integration_token_id', $this->token->id)
->set('api_ssh_domain', 'ssh.example.com')
->call('apiCloudflareConfig')
->assertHasErrors('cloudflare_integration_token_id');
Http::assertNothingSent();
});
test('the server team decides the tokens after the session team changes', function () {
$admin = User::factory()->create();
$this->team->members()->attach($admin->id, ['role' => 'admin']);
$otherTeam = Team::factory()->create();
$otherTeam->members()->attach($admin->id, ['role' => 'owner']);
$otherTeamToken = IntegrationToken::factory()->for($otherTeam)->create(['capabilities' => ['dns', 'tunnel']]);
$this->actingAs($admin);
$component = Livewire::test(CloudflareTunnel::class, ['server_uuid' => $this->server->uuid]);
session(['currentTeam' => $otherTeam]);
fakeCloudflareTunnelApi(newCloudflareTunnelRoutes());
$component->set('cloudflare_integration_token_id', $otherTeamToken->id)
->set('api_ssh_domain', 'ssh.example.com')
->call('apiCloudflareConfig')
->assertHasErrors('cloudflare_integration_token_id');
Http::assertNothingSent();
fakeCloudflaredActivity('tunnel-secret');
$component->set('cloudflare_integration_token_id', $this->token->id)
->call('apiCloudflareConfig')
->assertHasNoErrors();
expect($this->server->settings->fresh()->cloudflare_tunnel_id)->toBe('tun-1');
});
});
describe('tunnel capability', function () {
test('the validator checks tunnel access of the zone account', function (int $tunnelStatus, bool $valid) {
Http::fake([
'api.cloudflare.com/client/v4/user/tokens/verify' => Http::response(['result' => ['status' => 'active']]),
'api.cloudflare.com/client/v4/zones/zone-1/dns_records*' => Http::response(['success' => true, 'result' => []]),
'api.cloudflare.com/client/v4/zones*' => Http::response(['success' => true, 'result' => [['id' => 'zone-1', 'account' => ['id' => 'acc-1']]]]),
'api.cloudflare.com/client/v4/accounts/acc-1/cfd_tunnel*' => Http::response(['success' => $valid], $tunnelStatus),
]);
expect(app(CloudflareTokenValidator::class)->validate('cf-api-secret', ['dns', 'tunnel']))->toBe($valid);
})->with([
'permitted' => [200, true],
'missing permission' => [403, false],
]);
test('selecting the tunnel capability also selects dns', function () {
Livewire::test(IntegrationTokenForm::class)
->set('capabilities', [])
->set('capabilities', ['tunnel'])
->assertSet('capabilities', ['tunnel', 'dns']);
$this->token->update(['capabilities' => ['dns']]);
Livewire::test(IntegrationTokenEditor::class, ['integration_token_uuid' => $this->token->uuid])
->set('capabilities', ['tunnel'])
->assertSet('capabilities', ['tunnel', 'dns']);
});
});
describe('cloudflare token template url', function () {
$permissionsOf = function (string $url): array {
parse_str(parse_url($url, PHP_URL_QUERY), $query);
return json_decode($query['permissionGroupKeys'], true);
};
test('a dns token asks for dns edit and zone read', function () use ($permissionsOf) {
expect($permissionsOf(IntegrationToken::cloudflareTokenTemplateUrl(['dns'])))->toBe([
['key' => 'dns', 'type' => 'edit'],
['key' => 'zone', 'type' => 'read'],
]);
});
test('a tunnel token also asks for cloudflare tunnel edit', function () use ($permissionsOf) {
expect($permissionsOf(IntegrationToken::cloudflareTokenTemplateUrl(['dns', 'tunnel'])))
->toContain(['key' => 'argotunnel', 'type' => 'edit']);
});
});
+19 -9
View File
@@ -235,22 +235,31 @@ it('replaces the seeded qemu server with the selected non-root equivalent', func
it('reuses an existing vm without touching other managed vms', function () {
$storagePath = sys_get_temp_dir().'/coolify-qemu-reuse-test-'.uniqid();
config(['development-qemu.storage_path' => $storagePath]);
config(['development-qemu.storage_path' => $storagePath, 'development-qemu.memory' => 1024]);
File::ensureDirectoryExists($storagePath);
File::put("{$storagePath}/coolify-dev-ubuntu-root.qcow2", 'other vm data');
File::put("{$storagePath}/coolify-dev-ubuntu-non-root.qcow2", 'vm data');
Process::fake([
'* net-dumpxml *' => Process::result(output: '<network></network>'),
'* network inspect *' => Process::result(output: "172.18.0.0/16\n"),
'* dominfo *' => Process::result(output: 'exists'),
'* domstate *' => Process::result(output: "shut off\n"),
'*' => Process::result(),
]);
$ran = [];
Process::fake(function ($process) use (&$ran) {
$ran[] = $process->command;
return match (true) {
str_contains($process->command, ' net-dumpxml ') => Process::result(output: '<network></network>'),
str_contains($process->command, ' network inspect ') => Process::result(output: "172.18.0.0/16\n"),
str_contains($process->command, ' dominfo ') => Process::result(output: 'exists'),
str_contains($process->command, ' domstate ') => Process::result(output: "shut off\n"),
default => Process::result(),
};
});
StartDevelopmentQemuVm::run('ubuntu-non-root');
Process::assertRan(fn ($process) => $process->command === "virsh start 'coolify-dev-ubuntu-non-root'");
$commands = collect($ran);
$resize = $commands->search("virsh setmaxmem 'coolify-dev-ubuntu-non-root' 1024M --config");
expect($resize)->not->toBeFalse()
->and($commands->search("virsh setmem 'coolify-dev-ubuntu-non-root' 1024M --config"))->toBeGreaterThan($resize)
->and($commands->search("virsh start 'coolify-dev-ubuntu-non-root'"))->toBeGreaterThan($resize);
Process::assertNotRan(fn ($process) => str_starts_with($process->command, 'virt-install '));
Process::assertNotRan(fn ($process) => str_contains($process->command, 'virsh destroy'));
Process::assertNotRan(fn ($process) => str_contains($process->command, 'virsh undefine'));
@@ -274,6 +283,7 @@ it('does not restart an already running vm', function () {
StartDevelopmentQemuVm::run('ubuntu-root');
Process::assertNotRan(fn ($process) => str_contains($process->command, 'virsh start'));
Process::assertNotRan(fn ($process) => str_contains($process->command, 'virsh setmem'));
Process::assertNotRan(fn ($process) => str_starts_with($process->command, 'virt-install '));
Process::assertRan(fn ($process) => str_contains($process->command, 'docker exec') && str_contains($process->command, 'fsockopen'));
});
@@ -205,7 +205,7 @@ test('cloudflare dns scope guidance and token creation link are shown', function
->set('capabilities', ['dns'])
->assertSee('Zone - DNS - Edit')
->assertSee('Zone - Zone - Read')
->assertSeeHtml('https://dash.cloudflare.com/profile/api-tokens?permissionGroupKeys=%5B%7B%22key%22%3A%22dns%22%2C%22type%22%3A%22edit%22%7D%5D&amp;accountId=%2A&amp;zoneId=all&amp;name=Coolify%20DNS%20Management');
->assertSeeHtml(e(IntegrationToken::cloudflareTokenTemplateUrl(['dns'])));
});
test('saved integration token rows render modal editors with a gear button', function () {
@@ -469,3 +469,21 @@ test('saving a vault token with unchanged connection settings and a blank token
->and($savedToken->token)->toBe('hvs.stored-secret')
->and($savedToken->metadata)->toBe(['base_url' => 'https://example.com:8200', 'namespace' => 'team-a']);
});
test('the token list shows its empty state after the editor deletes the last token', function () {
$token = IntegrationToken::query()->create([
'team_id' => $this->team->id,
'provider' => 'cloudflare',
'name' => 'Production DNS',
'token' => 'secret',
'capabilities' => ['dns'],
]);
$list = Livewire::test(IntegrationTokens::class)->assertDontSee('No integration tokens');
Livewire::test(IntegrationTokenEditor::class, ['integration_token_uuid' => $token->uuid])
->call('delete')
->assertDispatched('integration-token-deleted', uuid: $token->uuid);
$list->dispatch('integration-token-deleted', uuid: $token->uuid)
->assertSee('No integration tokens');
});