diff --git a/app/Actions/Server/StartSentinel.php b/app/Actions/Server/StartSentinel.php index 914ec7e099..504ffa7af4 100644 --- a/app/Actions/Server/StartSentinel.php +++ b/app/Actions/Server/StartSentinel.php @@ -13,7 +13,7 @@ class StartSentinel public static function trafficLogDirectory(Server $server): string { return isDev() - ? '/var/lib/docker/volumes/coolify_dev_coolify_data/_data/proxy' + ? devCoolifyDataPath().'/proxy' : rtrim($server->proxyPath(), '/'); } @@ -108,7 +108,7 @@ class StartSentinel if ($customImage && ! empty($customImage)) { $image = $customImage; } - $mountDir = '/var/lib/docker/volumes/coolify_dev_coolify_data/_data/sentinel'; + $mountDir = devCoolifyDataPath().'/sentinel'; } $dockerEnvironments = implode(' ', array_map(fn ($key, $value) => '-e '.escapeshellarg("$key=$value"), array_keys($environments), $environments)); $dockerLabels = implode(' ', array_map(fn ($key, $value) => "$key=$value", array_keys($labels), $labels)); diff --git a/app/Livewire/Server/Proxy.php b/app/Livewire/Server/Proxy.php index af30b3a02b..567eefe3c4 100644 --- a/app/Livewire/Server/Proxy.php +++ b/app/Livewire/Server/Proxy.php @@ -219,6 +219,14 @@ class Proxy extends Component 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 = []; diff --git a/app/Livewire/Server/TrafficAnalyticsSettings.php b/app/Livewire/Server/TrafficAnalyticsSettings.php index 221e724a2a..d2cc5f76eb 100644 --- a/app/Livewire/Server/TrafficAnalyticsSettings.php +++ b/app/Livewire/Server/TrafficAnalyticsSettings.php @@ -112,6 +112,7 @@ class TrafficAnalyticsSettings extends Component return view('livewire.server.traffic-analytics-settings', [ 'unsupportedReason' => $this->server->trafficAnalyticsUnsupportedReason(), 'caddyRedeployNote' => $this->caddyRedeployNote(), + 'outdatedCaddyImage' => $this->server->outdatedCaddyProxyImage(), ]); } diff --git a/app/Models/Server.php b/app/Models/Server.php index f4a40c4787..9c54ee27d7 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -125,6 +125,17 @@ class Server extends BaseModel public const PLACEHOLDER_IPS = [self::PLACEHOLDER_IP, '0.0.0.0', '::']; + /** + * Default Caddy proxy image. caddy-docker-proxy 2.13 ships Caddy 2.11. + */ + public const RECOMMENDED_CADDY_PROXY_IMAGE = 'lucaslorentz/caddy-docker-proxy:2.13-alpine'; + + /** + * First caddy-docker-proxy version that ships Caddy 2.8+ (`log_append`, `basic_auth`). + * Version 2.8 of the image still runs Caddy 2.7.6. + */ + public const MINIMUM_CURRENT_CADDY_PROXY_VERSION = [2, 9]; + public static $batch_counter = 0; /** @@ -1091,26 +1102,81 @@ $siteAddress { } /** - * Caddy's `log_append` tags access-log lines with the app UUID for traffic analytics. It needs - * Caddy 2.8+, which caddy-docker-proxy ships from 2.9: the 2.8 image (the default before 2.13) - * runs Caddy 2.7.6, which rejects the whole Caddyfile. A saved change that is not applied yet may still run the - * old image, so it counts as unsupported. + * Major and minor version from a caddy-docker-proxy image tag, for example [2, 8] for + * `lucaslorentz/caddy-docker-proxy:2.8-alpine`. Other images, `latest`, and digests without a tag give null. + * + * @return array{0: int, 1: int}|null */ - public function caddySupportsLogAppend(): bool + public static function caddyDockerProxyImageVersion(?string $image): ?array { - if ($this->proxyType() !== ProxyTypes::CADDY->value || $this->hasPendingProxyConfiguration()) { - return false; + if ($image === null || preg_match('#(?:^|/)caddy-docker-proxy:(\d+)\.(\d+)#', $image, $version) !== 1) { + return null; + } + + return [(int) $version[1], (int) $version[2]]; + } + + /** + * Caddy image in the saved proxy configuration. Null for other proxies or a configuration that cannot be read. + */ + public function configuredCaddyProxyImage(): ?string + { + if ($this->proxyType() !== ProxyTypes::CADDY->value) { + return null; } try { $image = data_get(Yaml::parse((string) $this->proxy->get('last_saved_proxy_configuration')), 'services.caddy.image'); } catch (ParseException) { + return null; + } + + return is_string($image) && $image !== '' ? $image : null; + } + + /** + * The saved Caddy image when it is caddy-docker-proxy older than 2.9 (Caddy 2.7), else null. + * Unknown versions (custom images, `latest`, digests) are not reported. + */ + public function outdatedCaddyProxyImage(): ?string + { + $image = $this->configuredCaddyProxyImage(); + $version = self::caddyDockerProxyImageVersion($image); + + return $version !== null && $version < self::MINIMUM_CURRENT_CADDY_PROXY_VERSION ? $image : null; + } + + /** + * True when the Caddy proxy runs caddy-docker-proxy 2.9+ (Caddy 2.8+). The 2.8 image (the default before 2.13) + * runs Caddy 2.7.6, which rejects the whole Caddyfile when it contains newer directives. A saved change that + * is not applied yet may still run the old image, so it counts as unsupported. + */ + private function caddyRunsCurrentVersion(): bool + { + if ($this->hasPendingProxyConfiguration()) { return false; } - return is_string($image) - && preg_match('#(?:^|/)caddy-docker-proxy:(\d+)\.(\d+)#', $image, $version) === 1 - && [(int) $version[1], (int) $version[2]] >= [2, 9]; + $version = self::caddyDockerProxyImageVersion($this->configuredCaddyProxyImage()); + + return $version !== null && $version >= self::MINIMUM_CURRENT_CADDY_PROXY_VERSION; + } + + /** + * Caddy's `log_append` tags access-log lines with the app UUID for traffic analytics. It needs Caddy 2.8+. + */ + public function caddySupportsLogAppend(): bool + { + return $this->caddyRunsCurrentVersion(); + } + + /** + * Caddy 2.8 renamed `basicauth` to `basic_auth`. Caddy 2.7 knows only `basicauth`, and Caddy 2.8+ still + * accepts it as a deprecated name, so `basicauth` is the safe fallback. + */ + public function caddySupportsBasicAuthDirective(): bool + { + return $this->caddyRunsCurrentVersion(); } public function isServerApiEnabled(): bool diff --git a/bootstrap/helpers/docker.php b/bootstrap/helpers/docker.php index 746d2c2691..0afca06475 100644 --- a/bootstrap/helpers/docker.php +++ b/bootstrap/helpers/docker.php @@ -558,7 +558,7 @@ function isNoindexDomain(string $domain, ?Collection $noindex_domains): bool ->contains(ValidationPatterns::normalizeApplicationDomainUrl($domain)); } -function fqdnLabelsForCaddy(string $network, string $uuid, Collection $domains, bool $is_force_https_enabled = false, $onlyPort = null, ?Collection $serviceLabels = null, ?bool $is_gzip_enabled = true, ?bool $is_stripprefix_enabled = true, ?string $service_name = null, ?string $image = null, string $redirect_direction = 'both', ?string $predefinedPort = null, bool $is_http_basic_auth_enabled = false, ?string $http_basic_auth_username = null, ?string $http_basic_auth_password = null, ?Collection $noindex_domains = null, bool $is_traffic_analytics_enabled = false, array $domainPortOverrides = [], bool $supports_log_append = false) +function fqdnLabelsForCaddy(string $network, string $uuid, Collection $domains, bool $is_force_https_enabled = false, $onlyPort = null, ?Collection $serviceLabels = null, ?bool $is_gzip_enabled = true, ?bool $is_stripprefix_enabled = true, ?string $service_name = null, ?string $image = null, string $redirect_direction = 'both', ?string $predefinedPort = null, bool $is_http_basic_auth_enabled = false, ?string $http_basic_auth_username = null, ?string $http_basic_auth_password = null, ?Collection $noindex_domains = null, bool $is_traffic_analytics_enabled = false, array $domainPortOverrides = [], bool $supports_log_append = false, bool $supports_basic_auth_directive = false) { $labels = collect([]); if ($serviceLabels) { @@ -624,7 +624,9 @@ function fqdnLabelsForCaddy(string $network, string $uuid, Collection $domains, $labels->push("caddy_{$loop}.redir={$redirect_schema}://{$host_without_www}{uri}"); } if ($is_http_basic_auth_enabled) { - $labels->push("caddy_{$loop}.basicauth.{$http_basic_auth_username}=\"{$hashedPassword}\""); + // Caddy 2.8 renamed basicauth to basic_auth; see Server::caddySupportsBasicAuthDirective(). + $basicAuthDirective = $supports_basic_auth_directive ? 'basic_auth' : 'basicauth'; + $labels->push("caddy_{$loop}.{$basicAuthDirective}.{$http_basic_auth_username}=\"{$hashedPassword}\""); } if ($is_traffic_analytics_enabled) { $labels->push("caddy_{$loop}.log.output=file /traffic/access.log"); @@ -1015,6 +1017,7 @@ function generateLabelsApplication(Application $application, ?ApplicationPreview noindex_domains: $noindexDomains, is_traffic_analytics_enabled: $application->destination->server->isTrafficAnalyticsEnabled(), supports_log_append: $application->destination->server->caddySupportsLogAppend(), + supports_basic_auth_directive: $application->destination->server->caddySupportsBasicAuthDirective(), domainPortOverrides: $application->domain_port_overrides ?? [], )); break; @@ -1050,6 +1053,7 @@ function generateLabelsApplication(Application $application, ?ApplicationPreview noindex_domains: $noindexDomains, is_traffic_analytics_enabled: $application->destination->server->isTrafficAnalyticsEnabled(), supports_log_append: $application->destination->server->caddySupportsLogAppend(), + supports_basic_auth_directive: $application->destination->server->caddySupportsBasicAuthDirective(), domainPortOverrides: $application->domain_port_overrides ?? [], )); } @@ -1096,6 +1100,7 @@ function generateLabelsApplication(Application $application, ?ApplicationPreview noindex_domains: $noindexDomains, is_traffic_analytics_enabled: $application->destination->server->isTrafficAnalyticsEnabled(), supports_log_append: $application->destination->server->caddySupportsLogAppend(), + supports_basic_auth_directive: $application->destination->server->caddySupportsBasicAuthDirective(), domainPortOverrides: $preview->domain_port_overrides ?? [], )); break; @@ -1129,6 +1134,7 @@ function generateLabelsApplication(Application $application, ?ApplicationPreview noindex_domains: $noindexDomains, is_traffic_analytics_enabled: $application->destination->server->isTrafficAnalyticsEnabled(), supports_log_append: $application->destination->server->caddySupportsLogAppend(), + supports_basic_auth_directive: $application->destination->server->caddySupportsBasicAuthDirective(), domainPortOverrides: $preview->domain_port_overrides ?? [], )); } diff --git a/bootstrap/helpers/proxy.php b/bootstrap/helpers/proxy.php index 6e70b2fcb1..ea45cd2c58 100644 --- a/bootstrap/helpers/proxy.php +++ b/bootstrap/helpers/proxy.php @@ -1,6 +1,7 @@ proxyType() === ProxyTypes::CADDY->value) { - $trafficVolume = $server->proxyPath().':/traffic'; + // Caddy writes /traffic/access.log and Sentinel reads /access.log, so both use one path. + $trafficVolume = StartSentinel::trafficLogDirectory($server).':/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)); + // Coolify owns /traffic: replace an older mount with a different source path. + $volumes = array_values(array_filter($volumes, fn (mixed $volume): bool => ! isCaddyTrafficVolume($volume))); if ($enabled) { $volumes[] = $trafficVolume; } @@ -199,6 +202,24 @@ function applyTrafficAnalyticsToProxyConfigArray(Server $server, array $config): return $config; } +/** + * True for a Caddy volume that mounts to the /traffic access-log directory (short or long syntax). + */ +function isCaddyTrafficVolume(mixed $volume): bool +{ + if (is_array($volume)) { + return rtrim((string) data_get($volume, 'target'), '/') === '/traffic'; + } + + if (! is_string($volume)) { + return false; + } + + $parts = explode(':', $volume); + + return count($parts) >= 2 && rtrim($parts[1], '/') === '/traffic'; +} + /** * Check if a network name is a Docker predefined system network. * These networks cannot be created, modified, or managed by docker network commands. @@ -612,7 +633,7 @@ function generateDefaultProxyConfiguration(Server $server, array $custom_command $config['services']['traefik']['command'][] = '--api.insecure=true'; $config['services']['traefik']['command'][] = '--log.level=debug'; $config['services']['traefik']['command'][] = '--accesslog.bufferingsize=100'; - $config['services']['traefik']['volumes'][] = '/var/lib/docker/volumes/coolify_dev_coolify_data/_data/proxy/:/traefik'; + $config['services']['traefik']['volumes'][] = devCoolifyDataPath().'/proxy/:/traefik'; } else { $config['services']['traefik']['command'][] = '--api.insecure=false'; $config['services']['traefik']['volumes'][] = "{$proxy_path}:/traefik"; @@ -650,7 +671,7 @@ function generateDefaultProxyConfiguration(Server $server, array $custom_command 'services' => [ 'caddy' => [ 'container_name' => 'coolify-proxy', - 'image' => 'lucaslorentz/caddy-docker-proxy:2.13-alpine', + 'image' => Server::RECOMMENDED_CADDY_PROXY_IMAGE, 'restart' => RESTART_MODE, 'extra_hosts' => [ 'host.docker.internal:host-gateway', diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index 228427a48f..a7218bb9ad 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -969,6 +969,20 @@ function isDev(): bool return config('app.env') === 'local'; } +/** + * Host path of the Coolify data volume in development. The proxy and Sentinel on one server both mount + * paths below it, so they must use this value. An invalid volume name falls back to the legacy name. + */ +function devCoolifyDataPath(): string +{ + $volume = (string) config('constants.coolify.dev_data_volume'); + if (preg_match('/^[A-Za-z0-9][A-Za-z0-9_.-]*$/', $volume) !== 1) { + $volume = 'coolify_dev_coolify_data'; + } + + return "/var/lib/docker/volumes/{$volume}/_data"; +} + function isCloud(): bool { return ! config('constants.coolify.self_hosted'); diff --git a/config/constants.php b/config/constants.php index 9ca003f3b0..0d63ef113e 100644 --- a/config/constants.php +++ b/config/constants.php @@ -8,6 +8,8 @@ return [ 'self_hosted' => env('SELF_HOSTED', true), 'autoupdate' => env('AUTOUPDATE'), 'base_config_path' => env('BASE_CONFIG_PATH', '/data/coolify'), + // Development only: Docker volume that holds /data/coolify; the proxy and Sentinel share traffic logs below it. + 'dev_data_volume' => env('DEV_COOLIFY_DATA_VOLUME', 'coolify_dev_coolify_data'), 'registry_url' => env('REGISTRY_URL', 'ghcr.io'), 'helper_image' => env('HELPER_IMAGE', env('REGISTRY_URL', 'ghcr.io').'/coollabsio/coolify-helper'), 'is_windows_docker_desktop' => env('IS_WINDOWS_DOCKER_DESKTOP', false), diff --git a/resources/views/components/server/caddy-image-outdated-callout.blade.php b/resources/views/components/server/caddy-image-outdated-callout.blade.php new file mode 100644 index 0000000000..396c731eac --- /dev/null +++ b/resources/views/components/server/caddy-image-outdated-callout.blade.php @@ -0,0 +1,9 @@ +@props(['image']) + +@php use App\Models\Server; @endphp + + + The proxy configuration uses {{ $image }}. Per-resource traffic analytics and + current Caddy features need version 2.9 or newer. To fix this, change the image in the proxy configuration to + {{ Server::RECOMMENDED_CADDY_PROXY_IMAGE }}, then restart the proxy. + diff --git a/resources/views/livewire/server/proxy.blade.php b/resources/views/livewire/server/proxy.blade.php index bfb6f2a44f..f228e0b8df 100644 --- a/resources/views/livewire/server/proxy.blade.php +++ b/resources/views/livewire/server/proxy.blade.php @@ -223,6 +223,8 @@ changes before upgrading. @endif + @elseif ($this->outdatedCaddyImage) + @endif
+ @if ($outdatedCaddyImage) + + @endif + @if ($isTrafficAnalyticsEnabled) @if ($caddyRedeployNote) {{ $caddyRedeployNote }} diff --git a/tests/Feature/Proxy/CaddyBasicAuthLabelsTest.php b/tests/Feature/Proxy/CaddyBasicAuthLabelsTest.php new file mode 100644 index 0000000000..359abef073 --- /dev/null +++ b/tests/Feature/Proxy/CaddyBasicAuthLabelsTest.php @@ -0,0 +1,108 @@ + Server::flushIdentityMap()); + +afterEach(fn () => Server::flushIdentityMap()); + +function basicAuthCaddyProxy(string $image, array $overrides = []): array +{ + return array_merge([ + 'type' => ProxyTypes::CADDY->value, + 'status' => 'running', + 'last_saved_settings' => 'applied', + 'last_applied_settings' => 'applied', + 'last_saved_proxy_configuration' => "services:\n caddy:\n image: '{$image}'\n", + ], $overrides); +} + +function caddyBasicAuthLabel(iterable $labels): ?string +{ + return collect($labels)->first(fn (string $label) => preg_match('/^caddy_\d+\.basic_?auth\./', $label) === 1); +} + +it('uses the directive name that the Caddy version knows', function (bool $supportsBasicAuthDirective, string $directive) { + $labels = fqdnLabelsForCaddy('coolify', 'app-uuid', collect(['https://example.com']), + is_http_basic_auth_enabled: true, + http_basic_auth_username: 'admin', + http_basic_auth_password: 'secret', + supports_basic_auth_directive: $supportsBasicAuthDirective, + ); + + $label = caddyBasicAuthLabel($labels); + + expect($label)->toStartWith("caddy_0.{$directive}.admin=\"") + ->and(password_verify('secret', trim(str($label)->after('=')->value(), '"')))->toBeTrue(); +})->with([ + 'Caddy 2.8+' => [true, 'basic_auth'], + 'Caddy 2.7' => [false, 'basicauth'], +]); + +it('keeps the deprecated directive by default', function () { + $labels = fqdnLabelsForCaddy('coolify', 'app-uuid', collect(['https://example.com']), + is_http_basic_auth_enabled: true, + http_basic_auth_username: 'admin', + http_basic_auth_password: 'secret', + ); + + expect(caddyBasicAuthLabel($labels))->toStartWith('caddy_0.basicauth.admin='); +}); + +it('uses basic_auth only when the saved Caddy image runs Caddy 2.8 or newer', function (array $proxy, bool $expected) { + $server = Server::factory()->make(['proxy' => $proxy]); + + expect($server->caddySupportsBasicAuthDirective())->toBe($expected); +})->with([ + 'caddy-docker-proxy 2.8 (Caddy 2.7.6)' => [basicAuthCaddyProxy('lucaslorentz/caddy-docker-proxy:2.8-alpine'), false], + 'caddy-docker-proxy 2.9' => [basicAuthCaddyProxy('lucaslorentz/caddy-docker-proxy:2.9'), true], + 'caddy-docker-proxy 2.13' => [basicAuthCaddyProxy('lucaslorentz/caddy-docker-proxy:2.13-alpine'), true], + 'latest tag' => [basicAuthCaddyProxy('lucaslorentz/caddy-docker-proxy:latest'), false], + 'custom image' => [basicAuthCaddyProxy('caddy:2.11'), false], + 'new image saved but not applied' => [basicAuthCaddyProxy('lucaslorentz/caddy-docker-proxy:2.13-alpine', ['last_saved_settings' => 'new']), false], +]); + +it('emits the matching basic auth directive in application labels', function (string $image, bool $exactLabels, bool $preview, string $directive) { + $team = Team::factory()->create(); + $environment = Environment::factory()->create(['project_id' => Project::factory()->create(['team_id' => $team->id])->id]); + $server = Server::factory()->create(['team_id' => $team->id, 'proxy' => basicAuthCaddyProxy($image)]); + $server->settings->update(['generate_exact_labels' => $exactLabels]); + $destination = StandaloneDocker::query()->where('server_id', $server->id)->firstOrFail(); + $application = Application::factory()->createOne([ + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + 'fqdn' => 'https://example.com', + 'is_http_basic_auth_enabled' => true, + 'http_basic_auth_username' => 'admin', + 'http_basic_auth_password' => 'secret', + ]); + Server::flushIdentityMap(); + + $applicationPreview = $preview + ? (new ApplicationPreview)->forceFill(['pull_request_id' => 7, 'fqdn' => 'https://pr-7.example.com']) + : null; + + $label = caddyBasicAuthLabel(generateLabelsApplication($application->fresh(), $applicationPreview)); + + expect($label)->toStartWith("caddy_0.{$directive}.admin=\""); +})->with([ + 'caddy-docker-proxy 2.8, all proxies' => ['lucaslorentz/caddy-docker-proxy:2.8-alpine', false, false, 'basicauth'], + 'caddy-docker-proxy 2.8, exact labels' => ['lucaslorentz/caddy-docker-proxy:2.8-alpine', true, false, 'basicauth'], + 'caddy-docker-proxy 2.8, preview, all proxies' => ['lucaslorentz/caddy-docker-proxy:2.8-alpine', false, true, 'basicauth'], + 'caddy-docker-proxy 2.8, preview, exact labels' => ['lucaslorentz/caddy-docker-proxy:2.8-alpine', true, true, 'basicauth'], + 'caddy-docker-proxy 2.13, all proxies' => ['lucaslorentz/caddy-docker-proxy:2.13-alpine', false, false, 'basic_auth'], + 'caddy-docker-proxy 2.13, exact labels' => ['lucaslorentz/caddy-docker-proxy:2.13-alpine', true, false, 'basic_auth'], + 'caddy-docker-proxy 2.13, preview, all proxies' => ['lucaslorentz/caddy-docker-proxy:2.13-alpine', false, true, 'basic_auth'], + 'caddy-docker-proxy 2.13, preview, exact labels' => ['lucaslorentz/caddy-docker-proxy:2.13-alpine', true, true, 'basic_auth'], +]); diff --git a/tests/Feature/Proxy/CaddyProxyImageVersionTest.php b/tests/Feature/Proxy/CaddyProxyImageVersionTest.php new file mode 100644 index 0000000000..5d72b1d1da --- /dev/null +++ b/tests/Feature/Proxy/CaddyProxyImageVersionTest.php @@ -0,0 +1,144 @@ + 0]); + $this->user = User::factory()->create(); + $this->team = $this->user->teams()->first(); + $this->actingAs($this->user); + session(['currentTeam' => $this->team]); +}); + +afterEach(fn () => Server::flushIdentityMap()); + +function caddyImageProxy(?string $image, array $overrides = []): array +{ + return array_merge([ + 'type' => ProxyTypes::CADDY->value, + 'status' => 'running', + 'last_saved_settings' => 'applied', + 'last_applied_settings' => 'applied', + 'last_saved_proxy_configuration' => $image === null ? null : "services:\n caddy:\n image: '{$image}'\n", + ], $overrides); +} + +it('parses the caddy-docker-proxy version from the image', function (?string $image, ?array $expected) { + expect(Server::caddyDockerProxyImageVersion($image))->toBe($expected); +})->with([ + 'old default 2.8-alpine' => ['lucaslorentz/caddy-docker-proxy:2.8-alpine', [2, 8]], + '2.9' => ['lucaslorentz/caddy-docker-proxy:2.9', [2, 9]], + 'current default 2.13-alpine' => ['lucaslorentz/caddy-docker-proxy:2.13-alpine', [2, 13]], + 'registry prefix and patch version' => ['docker.io/lucaslorentz/caddy-docker-proxy:2.11.4-alpine', [2, 11]], + 'latest tag' => ['lucaslorentz/caddy-docker-proxy:latest', null], + 'no tag' => ['lucaslorentz/caddy-docker-proxy', null], + 'digest' => ['lucaslorentz/caddy-docker-proxy@sha256:0a3f8e2b1c4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e6f708192a3b4c5d6e7f', null], + 'custom image' => ['caddy:2.11', null], + 'custom image with a similar name' => ['example/my-caddy-docker-proxy:2.8', null], + 'no image' => [null, null], +]); + +it('reports an outdated Caddy image only for caddy-docker-proxy older than 2.9', function (?string $image, ?string $expected) { + $server = Server::factory()->make(['proxy' => caddyImageProxy($image)]); + + expect($server->outdatedCaddyProxyImage())->toBe($expected); +})->with([ + 'old default 2.8-alpine' => ['lucaslorentz/caddy-docker-proxy:2.8-alpine', 'lucaslorentz/caddy-docker-proxy:2.8-alpine'], + '2.7' => ['lucaslorentz/caddy-docker-proxy:2.7', 'lucaslorentz/caddy-docker-proxy:2.7'], + '2.9' => ['lucaslorentz/caddy-docker-proxy:2.9', null], + 'current default 2.13-alpine' => ['lucaslorentz/caddy-docker-proxy:2.13-alpine', null], + 'latest tag' => ['lucaslorentz/caddy-docker-proxy:latest', null], + 'digest' => ['lucaslorentz/caddy-docker-proxy@sha256:0a3f8e2b1c4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e6f708192a3b4c5d6e7f', null], + 'custom image' => ['caddy:2.7', null], + 'no saved configuration' => [null, null], +]); + +it('does not report an outdated Caddy image for other proxies or invalid YAML', function () { + $traefik = Server::factory()->make(['proxy' => caddyImageProxy('lucaslorentz/caddy-docker-proxy:2.8-alpine', ['type' => ProxyTypes::TRAEFIK->value])]); + $invalid = Server::factory()->make(['proxy' => caddyImageProxy(null, ['last_saved_proxy_configuration' => "services: [\n"])]); + + expect($traefik->outdatedCaddyProxyImage())->toBeNull() + ->and($invalid->outdatedCaddyProxyImage())->toBeNull(); +}); + +it('reports the saved image as outdated also while the change is not applied', function () { + $server = Server::factory()->make(['proxy' => caddyImageProxy('lucaslorentz/caddy-docker-proxy:2.8-alpine', ['last_saved_settings' => 'new'])]); + + expect($server->outdatedCaddyProxyImage())->toBe('lucaslorentz/caddy-docker-proxy:2.8-alpine') + ->and($server->caddySupportsLogAppend())->toBeFalse(); +}); + +it('uses the recommended Caddy image as the default proxy image', function () { + expect(Server::RECOMMENDED_CADDY_PROXY_IMAGE)->toBe('lucaslorentz/caddy-docker-proxy:2.13-alpine') + ->and(Server::caddyDockerProxyImageVersion(Server::RECOMMENDED_CADDY_PROXY_IMAGE))->toBe([2, 13]); +}); + +it('shows the outdated Caddy image warning on the proxy page', function () { + $server = Server::factory()->create([ + 'team_id' => $this->team->id, + 'proxy' => caddyImageProxy('lucaslorentz/caddy-docker-proxy:2.8-alpine'), + ]); + + Livewire::test(Proxy::class, ['server' => $server]) + ->assertSee('Caddy proxy image is outdated') + ->assertSee('lucaslorentz/caddy-docker-proxy:2.8-alpine') + ->assertSee('2.9 or newer') + ->assertSee('lucaslorentz/caddy-docker-proxy:2.13-alpine') + ->assertSee('restart the proxy'); +}); + +it('hides the outdated Caddy image warning on the proxy page', function (string $type, string $image) { + $server = Server::factory()->create([ + 'team_id' => $this->team->id, + 'proxy' => caddyImageProxy($image, ['type' => $type]), + ]); + + Livewire::test(Proxy::class, ['server' => $server]) + ->assertDontSee('Caddy proxy image is outdated'); +})->with([ + 'current Caddy image' => [ProxyTypes::CADDY->value, 'lucaslorentz/caddy-docker-proxy:2.13-alpine'], + 'Caddy latest tag' => [ProxyTypes::CADDY->value, 'lucaslorentz/caddy-docker-proxy:latest'], + 'custom Caddy image' => [ProxyTypes::CADDY->value, 'caddy:2.7'], + 'Traefik' => [ProxyTypes::TRAEFIK->value, 'lucaslorentz/caddy-docker-proxy:2.8-alpine'], +]); + +it('shows the outdated Caddy image warning on the traffic analytics settings', function (bool $analyticsEnabled) { + $server = Server::factory()->create([ + 'team_id' => $this->team->id, + 'proxy' => caddyImageProxy('lucaslorentz/caddy-docker-proxy:2.8-alpine'), + ]); + $server->settings->update(['is_traffic_analytics_enabled' => $analyticsEnabled]); + + Livewire::test(TrafficAnalyticsSettings::class, ['server' => $server->fresh()]) + ->assertSee('Caddy proxy image is outdated') + ->assertSee('lucaslorentz/caddy-docker-proxy:2.8-alpine') + ->assertSee('lucaslorentz/caddy-docker-proxy:2.13-alpine'); +})->with([ + 'analytics enabled' => [true], + 'analytics disabled' => [false], +]); + +it('hides the outdated Caddy image warning on the traffic analytics settings', function (string $type, string $image) { + $server = Server::factory()->create([ + 'team_id' => $this->team->id, + 'proxy' => caddyImageProxy($image, ['type' => $type]), + ]); + $server->settings->update(['is_traffic_analytics_enabled' => true]); + + Livewire::test(TrafficAnalyticsSettings::class, ['server' => $server->fresh()]) + ->assertDontSee('Caddy proxy image is outdated'); +})->with([ + 'current Caddy image' => [ProxyTypes::CADDY->value, 'lucaslorentz/caddy-docker-proxy:2.13-alpine'], + 'custom Caddy image' => [ProxyTypes::CADDY->value, 'example/caddy:1.0'], + 'Traefik' => [ProxyTypes::TRAEFIK->value, 'lucaslorentz/caddy-docker-proxy:2.8-alpine'], +]); diff --git a/tests/Feature/TrafficAnalytics/CaddyProxyVolumeTest.php b/tests/Feature/TrafficAnalytics/CaddyProxyVolumeTest.php index 3ee07caa73..eadd8fa9ff 100644 --- a/tests/Feature/TrafficAnalytics/CaddyProxyVolumeTest.php +++ b/tests/Feature/TrafficAnalytics/CaddyProxyVolumeTest.php @@ -1,5 +1,6 @@ toBe('lucaslorentz/caddy-docker-proxy:2.13-alpine') ->and($server->fresh()->caddySupportsLogAppend())->toBeTrue(); }); + +function caddyTrafficServer(object $test, bool $analyticsEnabled = true): Server +{ + $server = Server::factory()->create(['team_id' => $test->team->id, 'private_key_id' => $test->privateKey->id]); + $server->proxy->set('type', 'CADDY'); + $server->save(); + $server->settings->is_traffic_analytics_enabled = $analyticsEnabled; + $server->settings->save(); + + return $server->fresh(); +} + +it('mounts the Sentinel traffic log directory into Caddy in production', function () { + $server = caddyTrafficServer($this); + + $config = Yaml::parse(generateDefaultProxyConfiguration($server)); + + expect(StartSentinel::trafficLogDirectory($server))->toBe('/data/coolify/proxy/caddy') + ->and($config['services']['caddy']['volumes'])->toContain('/data/coolify/proxy/caddy:/traffic'); +}); + +it('mounts the Sentinel traffic log directory into Caddy in development', function () { + config()->set('app.env', 'local'); + $server = caddyTrafficServer($this); + + $volumes = Yaml::parse(generateDefaultProxyConfiguration($server))['services']['caddy']['volumes']; + $trafficVolumes = array_values(array_filter($volumes, fn (string $volume): bool => str_ends_with($volume, ':/traffic'))); + + // Caddy writes /traffic/access.log, Sentinel reads /access.log. + expect($trafficVolumes)->toBe([StartSentinel::trafficLogDirectory($server).':/traffic']) + ->and($trafficVolumes[0])->toBe('/var/lib/docker/volumes/coolify_dev_coolify_data/_data/proxy:/traffic'); +}); + +it('uses the configured dev data volume for Caddy, Traefik, and Sentinel', function () { + config()->set('app.env', 'local'); + config()->set('constants.coolify.dev_data_volume', 'coolify-dev-feature_coolify_data'); + $caddy = caddyTrafficServer($this); + + $caddyVolumes = Yaml::parse(generateDefaultProxyConfiguration($caddy))['services']['caddy']['volumes']; + + $traefik = Server::factory()->create(['team_id' => $this->team->id, 'private_key_id' => $this->privateKey->id]); + $traefik->proxy->set('type', 'TRAEFIK'); + $traefik->save(); + $traefikVolumes = Yaml::parse(generateDefaultProxyConfiguration($traefik->fresh()))['services']['traefik']['volumes']; + + expect(StartSentinel::trafficLogDirectory($caddy))->toBe('/var/lib/docker/volumes/coolify-dev-feature_coolify_data/_data/proxy') + ->and($caddyVolumes)->toContain('/var/lib/docker/volumes/coolify-dev-feature_coolify_data/_data/proxy:/traffic') + ->and($traefikVolumes)->toContain('/var/lib/docker/volumes/coolify-dev-feature_coolify_data/_data/proxy/:/traefik'); +}); + +it('falls back to the legacy dev data volume for an invalid volume name', function (?string $volume) { + config()->set('app.env', 'local'); + config()->set('constants.coolify.dev_data_volume', $volume); + + expect(devCoolifyDataPath())->toBe('/var/lib/docker/volumes/coolify_dev_coolify_data/_data'); +})->with([ + 'empty' => [''], + 'null' => [null], + 'path traversal' => ['../../etc'], + 'shell characters' => ['vol;rm -rf /'], +]); + +it('replaces a stale Caddy traffic mount and removes it when analytics is disabled', function () { + config()->set('app.env', 'local'); + $server = caddyTrafficServer($this); + $config = ['services' => ['caddy' => ['volumes' => [ + '/var/run/docker.sock:/var/run/docker.sock:ro', + '/data/coolify/proxy/caddy:/traffic', + ]]]]; + + $enabled = applyTrafficAnalyticsToProxyConfigArray($server, $config); + + $server->settings->is_traffic_analytics_enabled = false; + $server->settings->save(); + $disabled = applyTrafficAnalyticsToProxyConfigArray($server->fresh(), $enabled); + + expect($enabled['services']['caddy']['volumes'])->toBe([ + '/var/run/docker.sock:/var/run/docker.sock:ro', + StartSentinel::trafficLogDirectory($server).':/traffic', + ])->and($disabled['services']['caddy']['volumes'])->toBe([ + '/var/run/docker.sock:/var/run/docker.sock:ro', + ]); +}); diff --git a/tests/Feature/TrafficAnalytics/StartSentinelTrafficTest.php b/tests/Feature/TrafficAnalytics/StartSentinelTrafficTest.php index b85fbd86de..39a4bde635 100644 --- a/tests/Feature/TrafficAnalytics/StartSentinelTrafficTest.php +++ b/tests/Feature/TrafficAnalytics/StartSentinelTrafficTest.php @@ -206,3 +206,18 @@ it('keeps the access log commands valid for non-root servers', function () { expect($syntax->isSuccessful())->toBeTrue($syntax->getErrorOutput()); }); + +it('mounts the configured dev data volume for traffic logs and Sentinel data', function () { + config()->set('app.env', 'local'); + config()->set('constants.coolify.dev_data_volume', 'coolify-dev-feature_coolify_data'); + $server = sentinelTrafficServer($this, 'CADDY', analyticsEnabled: true); + $directory = '/var/lib/docker/volumes/coolify-dev-feature_coolify_data/_data/proxy'; + + $script = runStartSentinelAndCaptureScript($server); + + expect(StartSentinel::trafficLogDirectory($server))->toBe($directory) + ->and($script)->toContain(escapeshellarg("{$directory}:{$directory}:ro")) + ->toContain(escapeshellarg("TRAFFIC_ACCESS_LOG_PATH={$directory}/access.log")) + ->toContain('-v /var/lib/docker/volumes/coolify-dev-feature_coolify_data/_data/sentinel:/app/db') + ->not->toContain('coolify_dev_coolify_data'); +});