Files
coolify/app/Livewire/Server/Proxy.php
T
Andras BacsaiandClaude Opus 5.5 3452faec87 fix(livewire): bind team broadcast listeners to the mounted team
Livewire rebuilds listener names from the session team on every event
call. After a team switch in another tab, open pages still listen on the
old team channel, so events failed with EventHandlerDoesNotExist (500),
or ran handlers that load resources scoped to the new team (404).

Store the team at mount and build team channel listeners from it. Events
for a team that is no longer the session team are ignored.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 10:31:32 +02:00

455 lines
15 KiB
PHP

<?php
namespace App\Livewire\Server;
use App\Actions\Proxy\DeleteTraefikCertificate;
use App\Actions\Proxy\GetProxyConfiguration;
use App\Actions\Proxy\GetTraefikCertificates;
use App\Actions\Proxy\SaveProxyConfiguration;
use App\Enums\ProxyTypes;
use App\Models\Server;
use App\Rules\SafeExternalUrl;
use App\Traits\ListensToTeamChannel;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
class Proxy extends Component
{
use AuthorizesRequests;
use ListensToTeamChannel;
public Server $server;
public ?string $selectedProxy = null;
public $proxySettings = null;
public bool $redirectEnabled = true;
public ?string $redirectUrl = null;
public bool $generateExactLabels = false;
public array $traefikCertificates = [];
public bool $traefikCertificatesLoaded = false;
/**
* Cache the versions.json file data in memory for this component instance.
* This avoids multiple file reads during a single request/render cycle.
*/
protected ?array $cachedVersionsFile = null;
public function getListeners()
{
return [
'saveConfiguration' => 'submit',
...$this->teamChannelListeners([
'ProxyStatusChangedUI' => '$refresh',
]),
];
}
protected function rules()
{
return [
'generateExactLabels' => 'required|boolean',
'redirectUrl' => ['nullable', new SafeExternalUrl],
];
}
public function mount()
{
$this->selectedProxy = $this->server->proxyType();
$this->redirectEnabled = data_get($this->server, 'proxy.redirect_enabled', true);
$this->redirectUrl = data_get($this->server, 'proxy.redirect_url');
$this->syncData(false);
$this->clearAppliedTraefikBranchWarning();
}
private function syncData(bool $toModel = false): void
{
if ($toModel) {
$this->server->settings->generate_exact_labels = $this->generateExactLabels;
} else {
$this->generateExactLabels = $this->server->settings->generate_exact_labels ?? false;
}
}
/**
* Get Traefik versions from cached data with in-memory optimization.
* Returns array like: ['v3.5' => '3.5.6', 'v3.6' => '3.6.2']
*
* This method adds an in-memory cache layer on top of the global
* get_traefik_versions() helper to avoid multiple calls during
* a single component lifecycle/render.
*/
protected function getTraefikVersions(): ?array
{
// In-memory cache for this component instance (per-request)
if ($this->cachedVersionsFile !== null) {
return data_get($this->cachedVersionsFile, 'traefik');
}
// Load from global cached helper (Redis + filesystem)
$versionsData = get_versions_data();
if (! $versionsData) {
return null;
}
$this->cachedVersionsFile = $versionsData;
$traefikVersions = data_get($versionsData, 'traefik');
return is_array($traefikVersions) ? $traefikVersions : null;
}
public function getConfigurationFilePathProperty(): string
{
return rtrim($this->server->proxyPath(), '/').'/docker-compose.yml';
}
public function changeProxy()
{
try {
$this->authorize('update', $this->server);
$this->server->proxy = null;
$this->server->detected_traefik_version = null;
$this->server->traefik_outdated_info = null;
$this->server->save();
$this->dispatch('reloadWindow');
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function selectProxy($proxy_type)
{
try {
$this->authorize('update', $this->server);
$this->server->changeProxy($proxy_type, async: false);
$this->selectedProxy = $this->server->proxy->type;
$this->dispatch('reloadWindow');
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function instantSave()
{
try {
$this->authorize('update', $this->server);
$this->validate();
$this->syncData(true);
$this->server->settings->save();
$this->dispatch('success', 'Settings saved.');
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function instantSaveRedirect()
{
try {
$this->authorize('update', $this->server);
$this->server->proxy->redirect_enabled = $this->redirectEnabled;
$this->server->save();
$this->server->setupDefaultRedirect();
$this->dispatch('success', 'Proxy configuration saved.');
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function submit()
{
try {
$this->authorize('update', $this->server);
$this->validate();
SaveProxyConfiguration::run($this->server, $this->proxySettings);
$this->server->proxy->redirect_url = $this->redirectUrl;
$this->server->save();
$this->server->setupDefaultRedirect();
$this->dispatch('refreshServerShow');
$this->dispatch('success', 'Proxy configuration saved.');
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function resetProxyConfiguration()
{
try {
$this->authorize('update', $this->server);
// Explicitly regenerate default configuration
$this->proxySettings = GetProxyConfiguration::run($this->server, forceRegenerate: true);
SaveProxyConfiguration::run($this->server, $this->proxySettings);
$this->server->save();
$this->dispatch('refreshServerShow');
$this->dispatch('success', 'Proxy configuration reset to default.');
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function loadProxyConfiguration()
{
try {
$this->proxySettings = GetProxyConfiguration::run($this->server);
$this->clearAppliedTraefikBranchWarning();
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function getTraefikVersionForWarningProperty(): ?string
{
if ($this->server->detected_traefik_version) {
return $this->server->detected_traefik_version;
}
if ($this->server->proxy->get('status') !== 'running' || $this->server->hasPendingProxyConfiguration()) {
return null;
}
$configuration = $this->server->proxy->get('last_saved_proxy_configuration');
if (! is_string($configuration) || ! preg_match('/^\s*image:\s*[\'\"]?traefik:(v?\d+\.\d+(?:\.\d+)?|latest)[\'\"]?\s*$/mi', $configuration, $matches)) {
return null;
}
return $matches[1];
}
/**
* The saved caddy-docker-proxy image when it is older than 2.9 (Caddy 2.7), else null.
*/
public function getOutdatedCaddyImageProperty(): ?string
{
return $this->server->outdatedCaddyProxyImage();
}
public function loadTraefikCertificates(): void
{
$this->traefikCertificates = [];
try {
$this->authorize('view', $this->server);
$this->traefikCertificates = GetTraefikCertificates::run($this->server);
$this->traefikCertificatesLoaded = true;
} catch (\Throwable $e) {
$this->traefikCertificatesLoaded = true;
handleError($e, $this);
}
}
public function deleteTraefikCertificate(string $certificateId, string $password = ''): void
{
try {
$this->authorize('update', $this->server);
$certificate = DeleteTraefikCertificate::run($this->server, $certificateId);
auditLog('ui.proxy.certificate_deleted', [
'team_id' => $this->server->team_id,
'server_uuid' => $this->server->uuid,
'server_name' => $this->server->name,
'domain' => $certificate['main_domain'],
'resolver' => $certificate['resolver'],
]);
$this->traefikCertificates = GetTraefikCertificates::run($this->server);
$this->dispatch('refreshServerShow');
$this->dispatch('success', 'TLS certificate deleted. Restart Traefik to remove it from the running proxy.');
} catch (\Throwable $e) {
handleError($e, $this);
}
}
/**
* Get the latest Traefik version for this server's current branch.
*
* This compares the server's detected version against available versions
* in versions.json to determine the latest patch for the current branch,
* or the newest available version if no current version is detected.
*/
public function getLatestTraefikVersionProperty(): ?string
{
try {
$traefikVersions = $this->getTraefikVersions();
if (! $traefikVersions) {
return null;
}
// Get this server's current version
$currentVersion = $this->traefikVersionForWarning;
// If we have a current version, try to find matching branch
if ($currentVersion && $currentVersion !== 'latest') {
$current = ltrim($currentVersion, 'v');
if (preg_match('/^(\d+\.\d+)/', $current, $matches)) {
$branch = "v{$matches[1]}";
if (isset($traefikVersions[$branch])) {
$version = $traefikVersions[$branch];
return str_starts_with($version, 'v') ? $version : "v{$version}";
}
}
}
// Return the newest available version
$newestVersion = collect($traefikVersions)
->map(fn ($v) => ltrim($v, 'v'))
->sortBy(fn ($v) => $v, SORT_NATURAL)
->last();
return $newestVersion ? "v{$newestVersion}" : null;
} catch (\Throwable $e) {
return null;
}
}
public function getIsTraefikOutdatedProperty(): bool
{
if ($this->server->proxyType() !== ProxyTypes::TRAEFIK->value) {
return false;
}
$currentVersion = $this->traefikVersionForWarning;
if (! $currentVersion || $currentVersion === 'latest') {
return false;
}
$latestVersion = $this->latestTraefikVersion;
if (! $latestVersion) {
return false;
}
// Compare versions (strip 'v' prefix)
$current = ltrim($currentVersion, 'v');
$latest = ltrim($latestVersion, 'v');
return version_compare($current, $latest, '<');
}
/**
* Check if a newer Traefik branch (minor version) is available for this server.
* Returns the branch identifier (e.g., "v3.6") if a newer branch exists.
*/
public function getNewerTraefikBranchAvailableProperty(): ?string
{
try {
if ($this->server->proxyType() !== ProxyTypes::TRAEFIK->value) {
return null;
}
// Get this server's current version
$currentVersion = $this->traefikVersionForWarning;
if (! $currentVersion || $currentVersion === 'latest') {
return null;
}
$configuredBranch = $this->getConfiguredTraefikBranch();
// Check if we have outdated info stored for this server (faster than computing)
$outdatedInfo = $this->server->traefik_outdated_info;
$storedCurrentVersion = ltrim((string) data_get($outdatedInfo, 'current'), 'v');
$detectedCurrentVersion = ltrim($currentVersion, 'v');
if ($storedCurrentVersion === $detectedCurrentVersion && data_get($outdatedInfo, 'type') === 'minor_upgrade') {
// Use the upgrade_target field if available (e.g., "v3.6")
if (isset($outdatedInfo['upgrade_target'])) {
$upgradeTarget = str_starts_with($outdatedInfo['upgrade_target'], 'v')
? $outdatedInfo['upgrade_target']
: "v{$outdatedInfo['upgrade_target']}";
if ($configuredBranch && version_compare($configuredBranch, ltrim($upgradeTarget, 'v'), '>=')) {
return null;
}
return $upgradeTarget;
}
}
// Fallback: compute from cached versions data
$traefikVersions = $this->getTraefikVersions();
if (! $traefikVersions) {
return null;
}
// Extract current branch (e.g., "3.5" from "3.5.6")
$current = ltrim($currentVersion, 'v');
if (! preg_match('/^(\d+\.\d+)/', $current, $matches)) {
return null;
}
$currentBranch = $matches[1];
// Find the newest branch that's greater than current
$newestBranch = null;
foreach ($traefikVersions as $branch => $version) {
$branchNum = ltrim($branch, 'v');
if (version_compare($branchNum, $currentBranch, '>')) {
if (! $newestBranch || version_compare($branchNum, $newestBranch, '>')) {
$newestBranch = $branchNum;
}
}
}
if (! $newestBranch || ($configuredBranch && version_compare($configuredBranch, $newestBranch, '>='))) {
return null;
}
return "v{$newestBranch}";
} catch (\Throwable $e) {
return null;
}
}
public function getLatestNewerTraefikVersionProperty(): ?string
{
$branch = $this->newerTraefikBranchAvailable;
$version = $branch ? ($this->getTraefikVersions()[$branch] ?? null) : null;
return $version ? 'v'.ltrim($version, 'v') : null;
}
private function getConfiguredTraefikBranch(): ?string
{
if ($this->server->proxy->get('status') !== 'running' || $this->server->hasPendingProxyConfiguration()) {
return null;
}
if (! is_string($this->proxySettings)) {
return null;
}
if (! preg_match('/^\s*image:\s*[\'\"]?traefik:v?(\d+\.\d+)(?:\.\d+)?[\'\"]?\s*$/mi', $this->proxySettings, $matches)) {
return null;
}
return $matches[1];
}
private function clearAppliedTraefikBranchWarning(): void
{
$outdatedInfo = $this->server->traefik_outdated_info;
if (data_get($outdatedInfo, 'type') !== 'minor_upgrade') {
return;
}
$configuredBranch = $this->getConfiguredTraefikBranch();
$upgradeTarget = ltrim((string) data_get($outdatedInfo, 'upgrade_target'), 'v');
if (! $configuredBranch || ! $upgradeTarget || version_compare($configuredBranch, $upgradeTarget, '<')) {
return;
}
Server::query()
->whereKey($this->server->id)
->where('traefik_outdated_info->type', data_get($outdatedInfo, 'type'))
->where('traefik_outdated_info->current', data_get($outdatedInfo, 'current'))
->where('traefik_outdated_info->upgrade_target', data_get($outdatedInfo, 'upgrade_target'))
->update(['traefik_outdated_info' => null]);
}
}