mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-06 13:10:37 -04:00
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.
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Server;
|
||||
|
||||
use App\Models\Server;
|
||||
use Lorisleiva\Actions\Concerns\AsAction;
|
||||
use RuntimeException;
|
||||
use Spatie\Activitylog\Models\Activity;
|
||||
|
||||
class UpdateCloudflared
|
||||
{
|
||||
use AsAction;
|
||||
|
||||
/**
|
||||
* Pulls the latest cloudflared image and recreates the container from its existing Compose file.
|
||||
*
|
||||
* Coolify usually reaches the server through this tunnel, so the recreate runs detached on the
|
||||
* server: the SSH session ends before the old connector stops, and the tunnel reconnects after it.
|
||||
*/
|
||||
public function handle(Server $server): Activity
|
||||
{
|
||||
$directory = $this->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);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
@@ -13,15 +13,15 @@
|
||||
<x-application.settings-section id="server-cloudflare-overview-section" title="Cloudflare Tunnel"
|
||||
helper="Proxy SSH traffic through Cloudflare so the server SSH port can remain closed.">
|
||||
<x-slot:actions>
|
||||
<x-status-badge :status="$isCloudflareTunnelsEnabled ? 'Enabled' : 'Disabled'"
|
||||
:type="$isCloudflareTunnelsEnabled ? 'success' : 'neutral'" />
|
||||
</x-slot:actions>
|
||||
|
||||
@if ($isCloudflareTunnelsEnabled)
|
||||
<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>
|
||||
<div class="mt-4">
|
||||
@if ($isCloudflareTunnelsEnabled && auth()->user()->can('update', $server))
|
||||
<x-forms.button wire:click="updateCloudflareTunnel">Update Cloudflare Tunnel</x-forms.button>
|
||||
<x-process-dialog @cloudflare-tunnel-update-started.window="processDialogOpen = true"
|
||||
closeWithX size="xl">
|
||||
<x-slot:title>Cloudflare Tunnel Update</x-slot:title>
|
||||
<x-slot:content>
|
||||
<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
|
||||
@@ -37,7 +37,16 @@
|
||||
confirmationText="DISABLE CLOUDFLARE TUNNEL"
|
||||
confirmationLabel="Type the confirmation text to disable Cloudflare Tunnel."
|
||||
shortConfirmationLabel="Confirmation text" />
|
||||
</div>
|
||||
@else
|
||||
<x-status-badge :status="$isCloudflareTunnelsEnabled ? 'Enabled' : 'Disabled'"
|
||||
:type="$isCloudflareTunnelsEnabled ? 'success' : 'neutral'" />
|
||||
@endif
|
||||
</x-slot:actions>
|
||||
|
||||
@if ($isCloudflareTunnelsEnabled)
|
||||
<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>
|
||||
@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.
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
use App\Jobs\CoolifyTask;
|
||||
use App\Livewire\Server\CloudflareTunnel;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use App\Support\RemoteProcessCommand;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Process;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Livewire\Livewire;
|
||||
use Spatie\Activitylog\Models\Activity;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
Server::flushIdentityMap();
|
||||
Queue::fake();
|
||||
Storage::fake('ssh-keys');
|
||||
Storage::fake('ssh-mux');
|
||||
config(['constants.ssh.mux_enabled' => 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();
|
||||
});
|
||||
Reference in New Issue
Block a user