mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-12 15:53:46 -04:00
Merge remote-tracking branch 'origin/main' into next
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