mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-06 13:10:37 -04:00
feat(server): set up Cloudflare tunnels with API tokens (#12157)
This commit is contained in:
+1
-1
@@ -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'],
|
||||
];
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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}",
|
||||
|
||||
+31
@@ -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&accountId=%2A&zoneId=all&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&accountId=%2A&zoneId=all&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']);
|
||||
});
|
||||
});
|
||||
@@ -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&accountId=%2A&zoneId=all&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');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user