From 0bf7719491d779e47d26feb8485c37965be5165a Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 7 Oct 2026 14:54:17 +0200 Subject: [PATCH] feat(cloudflared): add tunnel updates with background container restart Add an authorized update button with activity logs to pull the latest image and recreate the tunnel container in the background. Persist new Compose files under /data/coolify/cloudflared and support existing /tmp configurations. Cover updates, missing configuration, and member restrictions with tests. --- app/Actions/Server/ConfigureCloudflared.php | 6 +- app/Actions/Server/UpdateCloudflared.php | 48 +++++++++++ app/Livewire/Server/CloudflareTunnel.php | 14 ++++ .../server/cloudflare-tunnel.blade.php | 29 ++++--- tests/Feature/CloudflareTunnelUpdateTest.php | 81 +++++++++++++++++++ 5 files changed, 166 insertions(+), 12 deletions(-) create mode 100644 app/Actions/Server/UpdateCloudflared.php create mode 100644 tests/Feature/CloudflareTunnelUpdateTest.php diff --git a/app/Actions/Server/ConfigureCloudflared.php b/app/Actions/Server/ConfigureCloudflared.php index 15079cd89a..0f7786d7cf 100644 --- a/app/Actions/Server/ConfigureCloudflared.php +++ b/app/Actions/Server/ConfigureCloudflared.php @@ -11,6 +11,8 @@ class ConfigureCloudflared { use AsAction; + public const DIRECTORY = '/data/coolify/cloudflared'; + public function handle(Server $server, string $cloudflare_token, string $ssh_domain): Activity { try { @@ -39,8 +41,8 @@ class ConfigureCloudflared $config = Yaml::dump($config, 12, 2); $docker_compose_yml_base64 = base64_encode($config); $commands = collect([ - 'mkdir -p /tmp/cloudflared', - 'cd /tmp/cloudflared', + 'mkdir -p '.self::DIRECTORY, + 'cd '.self::DIRECTORY, "echo '$docker_compose_yml_base64' | base64 -d | tee docker-compose.yml > /dev/null", 'echo Pulling latest Cloudflare Tunnel image.', 'docker compose pull', diff --git a/app/Actions/Server/UpdateCloudflared.php b/app/Actions/Server/UpdateCloudflared.php new file mode 100644 index 0000000000..2bc25f746c --- /dev/null +++ b/app/Actions/Server/UpdateCloudflared.php @@ -0,0 +1,48 @@ +findConfigurationDirectory($server); + + return remote_process([ + "cd {$directory}", + 'echo Pulling latest Cloudflare Tunnel image.', + 'docker compose pull', + 'echo Recreating the Cloudflare Tunnel container in the background. SSH through the tunnel reconnects in a few seconds.', + "nohup setsid sh -c 'sleep 2; docker compose up -d --force-recreate --remove-orphans' > /dev/null 2>&1 < /dev/null &", + ], $server); + } + + /** + * Older Coolify versions wrote the Compose file to /tmp, which some systems clear on reboot. + */ + private function findConfigurationDirectory(Server $server): string + { + $directory = instant_remote_process([ + 'for dir in '.ConfigureCloudflared::DIRECTORY.' /tmp/cloudflared; do if [ -f "$dir/docker-compose.yml" ]; then echo "$dir"; break; fi; done', + ], $server, false); + + if (blank($directory)) { + throw new RuntimeException('The cloudflared configuration was not found on the server. Configure the tunnel again with the automated setup.'); + } + + return trim($directory); + } +} diff --git a/app/Livewire/Server/CloudflareTunnel.php b/app/Livewire/Server/CloudflareTunnel.php index 17cbfa2398..bf0172a6d9 100644 --- a/app/Livewire/Server/CloudflareTunnel.php +++ b/app/Livewire/Server/CloudflareTunnel.php @@ -3,6 +3,7 @@ namespace App\Livewire\Server; use App\Actions\Server\ConfigureCloudflared; +use App\Actions\Server\UpdateCloudflared; use App\Models\Server; use App\Traits\ListensToTeamChannel; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; @@ -104,6 +105,19 @@ class CloudflareTunnel extends Component } } + public function updateCloudflareTunnel() + { + try { + $this->authorize('update', $this->server); + $activity = UpdateCloudflared::run($this->server); + auditLog('ui.server.cloudflare_tunnel.update_started', $this->auditContext()); + $this->dispatch('cloudflare-tunnel-update-started'); + $this->dispatch('activityMonitor', $activity->id); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + public function render() { return view('livewire.server.cloudflare-tunnel'); diff --git a/resources/views/livewire/server/cloudflare-tunnel.blade.php b/resources/views/livewire/server/cloudflare-tunnel.blade.php index 2e7ea504ff..255260ca18 100644 --- a/resources/views/livewire/server/cloudflare-tunnel.blade.php +++ b/resources/views/livewire/server/cloudflare-tunnel.blade.php @@ -13,15 +13,15 @@ - - - - @if ($isCloudflareTunnelsEnabled) - - The server IP must be restored to its direct address after disabling the tunnel. - -
+ @if ($isCloudflareTunnelsEnabled && auth()->user()->can('update', $server)) + Update Cloudflare Tunnel + + Cloudflare Tunnel Update + + + + -
+ @else + + @endif + + + @if ($isCloudflareTunnelsEnabled) + + The server IP must be restored to its direct address after disabling the tunnel. + @elseif (!$server->isFunctional()) Automated configuration requires a validated server, a Cloudflare token, and an SSH domain. diff --git a/tests/Feature/CloudflareTunnelUpdateTest.php b/tests/Feature/CloudflareTunnelUpdateTest.php new file mode 100644 index 0000000000..746884a92a --- /dev/null +++ b/tests/Feature/CloudflareTunnelUpdateTest.php @@ -0,0 +1,81 @@ + false]); + InstanceSettings::forceCreate(['id' => 0]); + + $this->team = Team::factory()->create(); + $this->server = Server::factory()->create([ + 'team_id' => $this->team->id, + 'private_key_id' => PrivateKey::factory()->create(['team_id' => $this->team->id])->id, + 'ip' => 'ssh.example.com', + ]); + $this->server->settings->update(['is_cloudflare_tunnel' => true]); +}); + +function actingAsCloudflareTunnelUpdateUser(Team $team, string $role): void +{ + $user = User::factory()->create(); + $team->members()->attach($user->id, ['role' => $role]); + test()->actingAs($user); + session(['currentTeam' => $team]); +} + +test('update pulls and recreates cloudflared from the compose file that older versions wrote to /tmp', function () { + actingAsCloudflareTunnelUpdateUser($this->team, 'owner'); + Process::fake(['*' => Process::result(output: '/tmp/cloudflared')]); + + Livewire::test(CloudflareTunnel::class, ['server_uuid' => $this->server->uuid]) + ->call('updateCloudflareTunnel') + ->assertDispatched('cloudflare-tunnel-update-started'); + + Queue::assertPushed(CoolifyTask::class); + $command = RemoteProcessCommand::read(Activity::query()->latest('id')->firstOrFail()); + expect($command)->toContain('cd /tmp/cloudflared') + ->toContain('docker compose pull') + ->toContain('nohup setsid sh -c'); +}); + +test('update stops with an error when the cloudflared compose file is missing', function () { + actingAsCloudflareTunnelUpdateUser($this->team, 'owner'); + Process::fake(['*' => Process::result(output: '')]); + + Livewire::test(CloudflareTunnel::class, ['server_uuid' => $this->server->uuid]) + ->call('updateCloudflareTunnel') + ->assertNotDispatched('cloudflare-tunnel-update-started'); + + Queue::assertNothingPushed(); +}); + +test('members without update rights cannot update the tunnel', function () { + actingAsCloudflareTunnelUpdateUser($this->team, 'member'); + Process::fake(); + + Livewire::test(CloudflareTunnel::class, ['server_uuid' => $this->server->uuid]) + ->call('updateCloudflareTunnel') + ->assertNotDispatched('cloudflare-tunnel-update-started'); + + Process::assertNothingRan(); + Queue::assertNothingPushed(); +});