Merge remote-tracking branch 'origin/main' into next

This commit is contained in:
github-actions[bot]
2026-10-07 13:00:32 +00:00
5 changed files with 166 additions and 12 deletions
+4 -2
View File
@@ -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',
+48
View File
@@ -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);
}
}
+14
View File
@@ -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();
});