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(); +});