From 9bae1530bf98259349015f912f3ae5a0db1b0dca Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 21 Sep 2026 13:07:29 +0200 Subject: [PATCH] fix(proxy): preserve custom config when toggling traffic analytics Apply analytics settings to existing proxy configurations while retaining custom options, including managed Traefik log rotation and Caddy traffic volumes. Upgrade new Traefik configurations to v3.7. --- .../Server/ConfigureTrafficAnalytics.php | 6 +- bootstrap/helpers/proxy.php | 98 ++++++++++++++----- .../ConfigureTrafficAnalyticsTest.php | 25 ++++- .../TraefikLogrotateSidecarTest.php | 10 ++ 4 files changed, 108 insertions(+), 31 deletions(-) diff --git a/app/Actions/Server/ConfigureTrafficAnalytics.php b/app/Actions/Server/ConfigureTrafficAnalytics.php index 9121443c62..acfddd677a 100644 --- a/app/Actions/Server/ConfigureTrafficAnalytics.php +++ b/app/Actions/Server/ConfigureTrafficAnalytics.php @@ -3,6 +3,7 @@ namespace App\Actions\Server; use App\Actions\Proxy\GetProxyConfiguration; +use App\Actions\Proxy\SaveProxyConfiguration; use App\Jobs\RestartProxyJob; use App\Models\Server; use Lorisleiva\Actions\Concerns\AsAction; @@ -19,8 +20,9 @@ class ConfigureTrafficAnalytics $server->settings->save(); $server->refresh(); - // Regenerate proxy config so the (Traefik) access-log flags / (Caddy) log labels take effect. - GetProxyConfiguration::run($server, forceRegenerate: true); + $configuration = GetProxyConfiguration::run($server); + $configuration = applyTrafficAnalyticsToProxyConfiguration($server, $configuration); + SaveProxyConfiguration::run($server, $configuration); RestartProxyJob::dispatch($server); // Recreate Sentinel so it picks up (enabling) or drops (disabling) the traffic env + proxy-log mount. diff --git a/bootstrap/helpers/proxy.php b/bootstrap/helpers/proxy.php index c5c0c391d0..cc214eb81c 100644 --- a/bootstrap/helpers/proxy.php +++ b/bootstrap/helpers/proxy.php @@ -31,6 +31,76 @@ function traefikAccessLogCommands(bool $enabled): array ]; } +function applyTrafficAnalyticsToProxyConfiguration(Server $server, string $configuration): string +{ + $config = Yaml::parse($configuration); + + if (! is_array($config)) { + throw new RuntimeException('Proxy configuration must be a YAML mapping.'); + } + + $config = applyTrafficAnalyticsToProxyConfigArray($server, $config); + + return Yaml::dump($config, 12, 2); +} + +function applyTrafficAnalyticsToProxyConfigArray(Server $server, array $config): array +{ + $enabled = $server->isTrafficAnalyticsEnabled(); + + if ($server->proxyType() === ProxyTypes::TRAEFIK->value) { + $managedCommands = traefikAccessLogCommands(true); + $commands = data_get($config, 'services.traefik.command', []); + + if (! is_array($commands)) { + throw new RuntimeException('Traefik commands must be a YAML list.'); + } + + $commands = array_values(array_filter( + $commands, + fn (mixed $command): bool => ! in_array($command, $managedCommands, true) + )); + + if ($enabled) { + $commands = [...$commands, ...$managedCommands]; + } + + data_set($config, 'services.traefik.command', $commands); + unset($config['services']['traefik-logrotate']); + + if ($enabled && ! $server->isSwarm() && ! isDev()) { + $proxyPath = $server->proxyPath(); + $config['services']['traefik-logrotate'] = [ + 'image' => 'alpine:3.20', + 'restart' => RESTART_MODE, + 'volumes' => [ + "{$proxyPath}:/traefik", + ], + 'labels' => [ + 'coolify.managed=true', + ], + 'entrypoint' => 'sh -c \'apk add --no-cache logrotate >/dev/null 2>&1; printf "/traefik/access.log {\n copytruncate\n size 20M\n rotate 5\n compress\n missingok\n notifempty\n}\n" > /etc/logrotate.d/traefik-access; while true; do logrotate -s /traefik/.logrotate.state /etc/logrotate.d/traefik-access; sleep 3600; done\'', + ]; + } + } elseif ($server->proxyType() === ProxyTypes::CADDY->value) { + $trafficVolume = $server->proxyPath().':/traffic'; + $volumes = data_get($config, 'services.caddy.volumes', []); + + if (! is_array($volumes)) { + throw new RuntimeException('Caddy volumes must be a YAML list.'); + } + + $volumes = array_values(array_filter($volumes, fn (mixed $volume): bool => $volume !== $trafficVolume)); + if ($enabled) { + $volumes[] = $trafficVolume; + } + + data_set($config, 'services.caddy.volumes', $volumes); + } + + return $config; +} + /** * Check if a network name is a Docker predefined system network. * These networks cannot be created, modified, or managed by docker network commands. @@ -302,7 +372,7 @@ function generateDefaultProxyConfiguration(Server $server, array $custom_command 'services' => [ 'traefik' => [ 'container_name' => 'coolify-proxy', - 'image' => 'traefik:v3.6', + 'image' => 'traefik:v3.7', 'restart' => RESTART_MODE, 'extra_hosts' => [ 'host.docker.internal:host-gateway', @@ -354,11 +424,6 @@ function generateDefaultProxyConfiguration(Server $server, array $custom_command $config['services']['traefik']['command'][] = '--api.insecure=false'; $config['services']['traefik']['volumes'][] = "{$proxy_path}:/traefik"; } - // Access logging + analytics header capture (JSON log, real-IP/UA/referrer headers) - // applies to both dev and production so traffic analytics can be exercised locally. - foreach (traefikAccessLogCommands($server->isTrafficAnalyticsEnabled()) as $cmd) { - $config['services']['traefik']['command'][] = $cmd; - } if ($server->isSwarm()) { data_forget($config, 'services.traefik.container_name'); data_forget($config, 'services.traefik.restart'); @@ -386,23 +451,6 @@ function generateDefaultProxyConfiguration(Server $server, array $custom_command } } - // Traefik has no native access-log rotation. Add a minimal logrotate sidecar that - // rotates /traefik/access.log in copytruncate mode so the file keeps the same inode - // and Sentinel keeps its file handle (the tailer handles len < pos by seeking to 0). - // Only for the non-swarm, non-dev production path (dev uses a different access-log path). - if ($server->isTrafficAnalyticsEnabled() && ! $server->isSwarm() && ! isDev()) { - $config['services']['traefik-logrotate'] = [ - 'image' => 'alpine:3.20', - 'restart' => RESTART_MODE, - 'volumes' => [ - "{$proxy_path}:/traefik", - ], - 'labels' => [ - 'coolify.managed=true', - ], - 'entrypoint' => 'sh -c \'apk add --no-cache logrotate >/dev/null 2>&1; printf "/traefik/access.log {\n copytruncate\n size 20M\n rotate 5\n compress\n missingok\n notifempty\n}\n" > /etc/logrotate.d/traefik-access; while true; do logrotate -s /traefik/.logrotate.state /etc/logrotate.d/traefik-access; sleep 3600; done\'', - ]; - } } elseif ($proxy_type === 'CADDY') { $config = [ 'networks' => $array_of_networks->toArray(), @@ -437,13 +485,11 @@ function generateDefaultProxyConfiguration(Server $server, array $custom_command ], ], ]; - if ($server->isTrafficAnalyticsEnabled()) { - $config['services']['caddy']['volumes'][] = "{$proxy_path}:/traffic"; - } } else { return null; } + $config = applyTrafficAnalyticsToProxyConfigArray($server, $config); $config = Yaml::dump($config, 12, 2); SaveProxyConfiguration::run($server, $config); diff --git a/tests/Feature/TrafficAnalytics/ConfigureTrafficAnalyticsTest.php b/tests/Feature/TrafficAnalytics/ConfigureTrafficAnalyticsTest.php index c73edde46d..54ac92fdaf 100644 --- a/tests/Feature/TrafficAnalytics/ConfigureTrafficAnalyticsTest.php +++ b/tests/Feature/TrafficAnalytics/ConfigureTrafficAnalyticsTest.php @@ -1,6 +1,7 @@ team = $user->teams()->first(); }); -it('enables analytics, regenerates proxy config and recreates sentinel', function () { +it('enables analytics without replacing custom proxy configuration', function () { Queue::fake(); StartSentinel::partialMock()->shouldReceive('handle')->atLeast()->once(); - GetProxyConfiguration::partialMock()->shouldReceive('handle')->atLeast()->once(); + GetProxyConfiguration::partialMock()->shouldReceive('handle')->once()->andReturn(<<<'YAML' +services: + traefik: + image: traefik:v3.7 + env_file: + - .env + command: + - '--providers.docker=true' +YAML); + SaveProxyConfiguration::partialMock()->shouldReceive('handle')->once()->withArgs( + fn (Server $server, string $configuration): bool => str_contains($configuration, 'traefik:v3.7') + && str_contains($configuration, 'env_file:') + && str_contains($configuration, '--accesslog=true') + ); $server = Server::factory()->create(['team_id' => $this->team->id]); + $server->proxy->set('type', 'TRAEFIK'); + $server->save(); ConfigureTrafficAnalytics::run($server, true); expect($server->fresh()->isTrafficAnalyticsEnabled())->toBeTrue(); @@ -31,9 +47,12 @@ it('enables analytics, regenerates proxy config and recreates sentinel', functio it('disables analytics', function () { Queue::fake(); StartSentinel::partialMock()->shouldReceive('handle')->atLeast()->once(); - GetProxyConfiguration::partialMock()->shouldReceive('handle')->atLeast()->once(); + GetProxyConfiguration::partialMock()->shouldReceive('handle')->twice()->andReturn("services:\n traefik:\n command: []\n"); + SaveProxyConfiguration::partialMock()->shouldReceive('handle')->twice(); $server = Server::factory()->create(['team_id' => $this->team->id]); + $server->proxy->set('type', 'TRAEFIK'); + $server->save(); ConfigureTrafficAnalytics::run($server, true); ConfigureTrafficAnalytics::run($server, false); diff --git a/tests/Feature/TrafficAnalytics/TraefikLogrotateSidecarTest.php b/tests/Feature/TrafficAnalytics/TraefikLogrotateSidecarTest.php index e629a284bc..0556fc2472 100644 --- a/tests/Feature/TrafficAnalytics/TraefikLogrotateSidecarTest.php +++ b/tests/Feature/TrafficAnalytics/TraefikLogrotateSidecarTest.php @@ -21,6 +21,16 @@ beforeEach(function () { $this->privateKey = PrivateKey::factory()->create(['team_id' => $this->team->id]); }); +it('uses the latest stable traefik branch for new proxy configurations', function () { + $server = Server::factory()->create(['team_id' => $this->team->id, 'private_key_id' => $this->privateKey->id]); + $server->proxy->set('type', 'TRAEFIK'); + $server->save(); + + $config = Yaml::parse(generateDefaultProxyConfiguration($server->fresh())); + + expect($config['services']['traefik']['image'])->toBe('traefik:v3.7'); +}); + it('does not add a traefik-logrotate sidecar when traffic analytics is disabled', function () { $server = Server::factory()->create(['team_id' => $this->team->id, 'private_key_id' => $this->privateKey->id]); $server->proxy->set('type', 'TRAEFIK');