mirror of
https://github.com/coollabsio/coolify.git
synced 2026-10-06 22:17:23 -04:00
fix(proxy): warn about old Caddy images and share the Caddy traffic log path
- Servers with caddy-docker-proxy older than 2.9 show a warning on the Proxy page and the traffic analytics settings, with the fix. - Caddy 2.9+ images get the basic_auth label; older ones keep the deprecated basicauth, which Caddy 2.7 still needs. - Caddy mounts StartSentinel::trafficLogDirectory() as /traffic, so Caddy and Sentinel use the same access log, also in development. - The development data volume is configurable (DEV_COOLIFY_DATA_VOLUME, same default as before) and shared by Traefik, Caddy, and Sentinel. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
fbfe342f89
commit
dc20ffd92b
@@ -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));
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
@@ -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(),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
+76
-10
@@ -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
|
||||
|
||||
@@ -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 ?? [],
|
||||
));
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<?php
|
||||
|
||||
use App\Actions\Proxy\SaveProxyConfiguration;
|
||||
use App\Actions\Server\StartSentinel;
|
||||
use App\Enums\ProxyTypes;
|
||||
use App\Models\Application;
|
||||
use App\Models\Server;
|
||||
@@ -181,14 +182,16 @@ function applyTrafficAnalyticsToProxyConfigArray(Server $server, array $config):
|
||||
];
|
||||
}
|
||||
} elseif ($server->proxyType() === ProxyTypes::CADDY->value) {
|
||||
$trafficVolume = $server->proxyPath().':/traffic';
|
||||
// Caddy writes /traffic/access.log and Sentinel reads <trafficLogDirectory>/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',
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
@props(['image'])
|
||||
|
||||
@php use App\Models\Server; @endphp
|
||||
|
||||
<x-callout type="warning" title="Caddy proxy image is outdated" {{ $attributes }}>
|
||||
The proxy configuration uses <span class="font-mono">{{ $image }}</span>. 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
|
||||
<span class="font-mono">{{ Server::RECOMMENDED_CADDY_PROXY_IMAGE }}</span>, then restart the proxy.
|
||||
</x-callout>
|
||||
@@ -223,6 +223,8 @@
|
||||
changes before upgrading.
|
||||
</x-callout>
|
||||
@endif
|
||||
@elseif ($this->outdatedCaddyImage)
|
||||
<x-server.caddy-image-outdated-callout :image="$this->outdatedCaddyImage" />
|
||||
@endif
|
||||
|
||||
<div wire:loading.flex wire:target="loadProxyConfiguration"
|
||||
|
||||
@@ -26,6 +26,10 @@
|
||||
@endif
|
||||
</x-slot:actions>
|
||||
|
||||
@if ($outdatedCaddyImage)
|
||||
<x-server.caddy-image-outdated-callout :image="$outdatedCaddyImage" />
|
||||
@endif
|
||||
|
||||
@if ($isTrafficAnalyticsEnabled)
|
||||
@if ($caddyRedeployNote)
|
||||
<x-callout type="info" title="Redeploy to start logging">{{ $caddyRedeployNote }}</x-callout>
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
<?php
|
||||
|
||||
use App\Enums\ProxyTypes;
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationPreview;
|
||||
use App\Models\Environment;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\Team;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(fn () => 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'],
|
||||
]);
|
||||
@@ -0,0 +1,144 @@
|
||||
<?php
|
||||
|
||||
use App\Enums\ProxyTypes;
|
||||
use App\Livewire\Server\Proxy;
|
||||
use App\Livewire\Server\TrafficAnalyticsSettings;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Server;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
Server::flushIdentityMap();
|
||||
InstanceSettings::forceCreate(['id' => 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'],
|
||||
]);
|
||||
@@ -1,5 +1,6 @@
|
||||
<?php
|
||||
|
||||
use App\Actions\Server\StartSentinel;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Server;
|
||||
use App\Models\User;
|
||||
@@ -58,3 +59,86 @@ it('uses a default caddy image that supports per-app traffic attribution', funct
|
||||
expect($config['services']['caddy']['image'])->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 <trafficLogDirectory>/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',
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user