fix(proxy): only emit Caddy log_append on caddy-docker-proxy 2.9+

Caddy 2.7.6, shipped in the caddy-docker-proxy 2.8 image, rejects the
whole Caddyfile when it contains log_append. Add
Server::caddySupportsLogAppend(), which reads the image from the applied
proxy configuration. Traffic analytics labels now add log_append only
when the server runs 2.9 or newer and has no pending proxy change.

- Change the default Caddy proxy image from 2.8-alpine to 2.13-alpine
- ProxyPortParser now validates Docker Compose port ranges and random
  host ports. It returns only fixed host ports for the availability
  check and has a clearer validation message
- After mkdir, chown only root-owned files and remove other-user access
  from the top directory only. Files owned by container users and the
  modes of mounted files no longer change
- Add tests for log_append support and the new parser/sudo behaviour
- Note in the lessons file that tests must flush the Server identity map
  between dataset cases
This commit is contained in:
Andras Bacsai
2026-09-25 13:59:30 +02:00
parent 82bb574760
commit 84b596f7ca
12 changed files with 248 additions and 30 deletions
+1
View File
@@ -62,6 +62,7 @@
## Test the real runtime image
- Deployment shell commands run in the Alpine/BusyBox helper image and pass through the non-root sudo parser. Verify new flags and shell syntax in that image and with `parseCommandsByLineForSudo()`; faked command output hides both failures.
- Put multi-step remote shell logic in one `sh -c '<script>' sh <args>` line. The non-root parser then only puts sudo in front of it; it rewrites `x=$(...)`, `&&`, `|` and shell keywords in any other line.
- `Server` has an identity map. Tests that create servers in several dataset cases with `RefreshDatabase` must call `Server::flushIdentityMap()` in `beforeEach`/`afterEach`, or a case reads the cached server of the previous case.
- Dev QEMU servers from `dev:qemu` are seeded, not validated: they have no `coolify` Docker network, and Alpine has no bash until `InstallPrerequisites` runs.
## Format only your own files
+24
View File
@@ -41,6 +41,7 @@ use Spatie\SchemalessAttributes\Casts\SchemalessAttributes;
use Spatie\SchemalessAttributes\SchemalessAttributesTrait;
use Spatie\Url\Url;
use Stevebauman\Purify\Facades\Purify;
use Symfony\Component\Yaml\Exception\ParseException;
use Symfony\Component\Yaml\Yaml;
/**
@@ -1060,6 +1061,29 @@ $siteAddress {
return (bool) data_get($this, 'settings.is_traffic_analytics_enabled', false);
}
/**
* 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.
*/
public function caddySupportsLogAppend(): bool
{
if ($this->proxyType() !== ProxyTypes::CADDY->value || $this->hasPendingProxyConfiguration()) {
return false;
}
try {
$image = data_get(Yaml::parse((string) $this->proxy->get('last_saved_proxy_configuration')), 'services.caddy.image');
} catch (ParseException) {
return false;
}
return is_string($image)
&& preg_match('#(?:^|/)caddy-docker-proxy:(\d+)\.(\d+)#', $image, $version) === 1
&& [(int) $version[1], (int) $version[2]] >= [2, 9];
}
public function isServerApiEnabled(): bool
{
return $this->settings->is_sentinel_enabled;
+46 -20
View File
@@ -8,6 +8,10 @@ use Symfony\Component\Yaml\Yaml;
class ProxyPortParser
{
/**
* Validates the proxy ports like Docker Compose does and returns the fixed host ports.
* Ports that Docker publishes to a random host port or to a port range are validated
* but not returned: the caller checks each returned port over its own SSH connection.
*
* @return list<int>
*/
public static function fromConfiguration(string $configuration): array
@@ -37,14 +41,17 @@ class ProxyPortParser
}
foreach ($configuredPorts as $configuredPort) {
$ports[] = self::publishedPort($configuredPort);
$publishedPort = self::publishedPort($configuredPort);
if ($publishedPort !== null) {
$ports[] = $publishedPort;
}
}
}
return array_values(array_unique($ports));
}
private static function publishedPort(mixed $configuredPort): int
private static function publishedPort(mixed $configuredPort): ?int
{
if (is_array($configuredPort)) {
if (array_is_list($configuredPort) || ! array_key_exists('target', $configuredPort)) {
@@ -58,7 +65,7 @@ class ProxyPortParser
$target = self::portNumber($configuredPort['target']);
return array_key_exists('published', $configuredPort)
? self::portNumber($configuredPort['published'])
? self::portOrRange($configuredPort['published'])
: $target;
}
@@ -77,36 +84,54 @@ class ProxyPortParser
$portDefinition = substr($portDefinition, 0, $protocolSeparator);
}
// [HOST_IP:][HOST_PORT:]CONTAINER_PORT, where each port may be a range and an
// empty HOST_PORT after a host IP means a random host port.
if (str_starts_with($portDefinition, '[')) {
if (! preg_match('/^\[([^]]+)]:(\d+):(\d+)$/D', $portDefinition, $matches)) {
if (! preg_match('/^\[([^]]+)]:([^:]*):([^:]+)$/D', $portDefinition, $matches)) {
self::invalid();
}
self::validateHostIp($matches[1]);
self::portNumber($matches[3]);
return self::portNumber($matches[2]);
return self::hostPort($matches[2], $matches[3]);
}
$parts = explode(':', $portDefinition);
if (count($parts) < 1 || count($parts) > 3) {
if (count($parts) > 3 || (count($parts) > 1 && $parts[0] === '')) {
self::invalid();
}
if (count($parts) === 3 && $parts[0] === '') {
self::invalid();
}
if (count($parts) === 3) {
self::validateHostIp($parts[0]);
self::validateHostIp(array_shift($parts));
}
$portParts = count($parts) === 3 ? array_slice($parts, 1) : $parts;
foreach ($portParts as $part) {
self::portNumber($part);
return count($parts) === 1
? self::portOrRange($parts[0])
: self::hostPort($parts[0], $parts[1]);
}
/**
* Returns the fixed host port, or null for a random host port or a port range.
*/
private static function hostPort(string $hostPort, string $containerPort): ?int
{
self::portOrRange($containerPort);
return $hostPort === '' ? null : self::portOrRange($hostPort);
}
/**
* Returns the port, or null for a valid port range such as 10000-10100.
*/
private static function portOrRange(mixed $value): ?int
{
if (is_string($value) && preg_match('/^(\d+)-(\d+)$/D', $value, $range)) {
if (self::portNumber($range[1]) > self::portNumber($range[2])) {
self::invalid();
}
return null;
}
return self::portNumber($portParts[0]);
return self::portNumber($value);
}
private static function portNumber(mixed $port): int
@@ -133,7 +158,8 @@ class ProxyPortParser
private static function validateProtocol(mixed $protocol): void
{
if ($protocol !== null && (! is_string($protocol) || ! in_array($protocol, ['tcp', 'udp'], true))) {
// Docker Compose accepts the protocol in any letter case.
if ($protocol !== null && (! is_string($protocol) || ! in_array(strtolower($protocol), ['tcp', 'udp', 'sctp'], true))) {
self::invalid();
}
}
@@ -147,6 +173,6 @@ class ProxyPortParser
private static function invalid(): never
{
throw new \InvalidArgumentException('Proxy ports must be integers from 1 through 65535.');
throw new \InvalidArgumentException('Proxy ports must use Docker Compose port syntax with ports from 1 through 65535.');
}
}
+9 -2
View File
@@ -545,7 +545,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 = [])
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)
{
$labels = collect([]);
if ($serviceLabels) {
@@ -621,7 +621,10 @@ function fqdnLabelsForCaddy(string $network, string $uuid, Collection $domains,
$labels->push("caddy_{$loop}.log.output.roll_keep=5");
$labels->push("caddy_{$loop}.log.output.roll_keep_for=168h");
$labels->push("caddy_{$loop}.log.format=json");
$labels->push("caddy_{$loop}.log_append=coolify_app_id {$uuid}");
// Only Caddy 2.8+ knows log_append; see Server::caddySupportsLogAppend().
if ($supports_log_append) {
$labels->push("caddy_{$loop}.log_append=coolify_app_id {$uuid}");
}
}
}
@@ -996,6 +999,7 @@ function generateLabelsApplication(Application $application, ?ApplicationPreview
http_basic_auth_password: $application->http_basic_auth_password,
noindex_domains: $noindexDomains,
is_traffic_analytics_enabled: $application->destination->server->isTrafficAnalyticsEnabled(),
supports_log_append: $application->destination->server->caddySupportsLogAppend(),
domainPortOverrides: $application->domain_port_overrides ?? [],
));
break;
@@ -1030,6 +1034,7 @@ function generateLabelsApplication(Application $application, ?ApplicationPreview
http_basic_auth_password: $application->http_basic_auth_password,
noindex_domains: $noindexDomains,
is_traffic_analytics_enabled: $application->destination->server->isTrafficAnalyticsEnabled(),
supports_log_append: $application->destination->server->caddySupportsLogAppend(),
domainPortOverrides: $application->domain_port_overrides ?? [],
));
}
@@ -1075,6 +1080,7 @@ function generateLabelsApplication(Application $application, ?ApplicationPreview
http_basic_auth_password: $application->http_basic_auth_password,
noindex_domains: $noindexDomains,
is_traffic_analytics_enabled: $application->destination->server->isTrafficAnalyticsEnabled(),
supports_log_append: $application->destination->server->caddySupportsLogAppend(),
domainPortOverrides: $preview->domain_port_overrides ?? [],
));
break;
@@ -1107,6 +1113,7 @@ function generateLabelsApplication(Application $application, ?ApplicationPreview
http_basic_auth_password: $application->http_basic_auth_password,
noindex_domains: $noindexDomains,
is_traffic_analytics_enabled: $application->destination->server->isTrafficAnalyticsEnabled(),
supports_log_append: $application->destination->server->caddySupportsLogAppend(),
domainPortOverrides: $preview->domain_port_overrides ?? [],
));
}
+1 -1
View File
@@ -463,7 +463,7 @@ function generateDefaultProxyConfiguration(Server $server, array $custom_command
'services' => [
'caddy' => [
'container_name' => 'coolify-proxy',
'image' => 'lucaslorentz/caddy-docker-proxy:2.8-alpine',
'image' => 'lucaslorentz/caddy-docker-proxy:2.13-alpine',
'restart' => RESTART_MODE,
'extra_hosts' => [
'host.docker.internal:host-gateway',
+12 -2
View File
@@ -20,6 +20,16 @@ function shouldChangeOwnership(string $path): bool
return $isCoolifyPath;
}
/**
* Gives the SSH user the Coolify-created (root-owned) files in a directory and closes the
* directory to other users. Files that belong to container users (such as a database data
* folder) and the modes of mounted files stay as they are, so containers can still read them.
*/
function ownershipCommand(string $path, Server $server): string
{
return "find $path -user root -exec chown $server->user:$server->user {} + && chmod o-rwx $path";
}
function parseCommandsByLineForSudo(Collection $commands, Server $server): array
{
$commands = $commands->map(function ($line) {
@@ -85,7 +95,7 @@ function parseCommandsByLineForSudo(Collection $commands, Server $server): array
$path = trim(Str::after($line, 'sudo mkdir -p'));
if (shouldChangeOwnership($path)) {
// No sudo here: the && rule below adds it. `sudo sudo` fails where root is not in sudoers (Alpine).
return "$line && chown -R $server->user:$server->user $path && chmod -R o-rwx $path";
return "$line && ".ownershipCommand($path, $server);
}
return $line;
@@ -143,7 +153,7 @@ function parseLineForSudo(string $command, Server $server): string
$path = trim(Str::after($command, 'sudo mkdir -p'));
if (shouldChangeOwnership($path)) {
// No sudo here: the && rule below adds it.
$command = "$command && chown -R $server->user:$server->user $path && chmod -R o-rwx $path";
$command = "$command && ".ownershipCommand($path, $server);
}
}
if (str($command)->contains('$(') || str($command)->contains('`')) {
+1 -1
View File
@@ -218,7 +218,7 @@ test('PUT /api/v1/servers/{uuid}/proxy/configuration rejects command injection w
'configuration' => base64_encode($configuration),
])
->assertUnprocessable()
->assertJsonPath('errors.configuration.0', 'Proxy ports must be integers from 1 through 65535.');
->assertJsonPath('errors.configuration.0', 'Proxy ports must use Docker Compose port syntax with ports from 1 through 65535.');
expect($this->server->fresh()->proxy->toArray())->toBe($originalProxy);
});
@@ -0,0 +1,78 @@
<?php
use App\Enums\ProxyTypes;
use App\Models\Application;
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 caddyProxy(?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('uses log_append only when the saved Caddy image supports it', function (?string $image, bool $expected) {
$server = Server::factory()->make(['proxy' => caddyProxy($image)]);
expect($server->caddySupportsLogAppend())->toBe($expected);
})->with([
'default caddy-docker-proxy 2.8 (Caddy 2.7.6)' => ['lucaslorentz/caddy-docker-proxy:2.8-alpine', false],
'caddy-docker-proxy 2.9' => ['lucaslorentz/caddy-docker-proxy:2.9-alpine', true],
'caddy-docker-proxy 2.10' => ['lucaslorentz/caddy-docker-proxy:2.10', true],
'caddy-docker-proxy with a patch version' => ['docker.io/lucaslorentz/caddy-docker-proxy:2.11.4-alpine', true],
'caddy-docker-proxy 3.0' => ['lucaslorentz/caddy-docker-proxy:3.0', true],
'unknown version tag' => ['lucaslorentz/caddy-docker-proxy:latest', false],
'other image' => ['caddy:2.11', false],
'no saved configuration' => [null, false],
]);
it('does not use log_append while a saved proxy change is not applied yet', function () {
$server = Server::factory()->make(['proxy' => caddyProxy('lucaslorentz/caddy-docker-proxy:2.11-alpine', ['last_saved_settings' => 'new'])]);
expect($server->caddySupportsLogAppend())->toBeFalse();
});
it('does not use log_append for invalid YAML or other proxies', function () {
$invalid = Server::factory()->make(['proxy' => caddyProxy(null, ['last_saved_proxy_configuration' => "services: [\n"])]);
$traefik = Server::factory()->make(['proxy' => caddyProxy('lucaslorentz/caddy-docker-proxy:2.11-alpine', ['type' => ProxyTypes::TRAEFIK->value])]);
expect($invalid->caddySupportsLogAppend())->toBeFalse()
->and($traefik->caddySupportsLogAppend())->toBeFalse();
});
it('adds log_append to application labels only when the server Caddy supports it', function (string $image, bool $expected) {
$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' => caddyProxy($image)]);
$server->settings->update(['is_traffic_analytics_enabled' => true]);
$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',
]);
$labels = collect(generateLabelsApplication($application->fresh()));
expect($labels->contains(fn (string $label) => str_contains($label, 'log_append=coolify_app_id')))->toBe($expected)
->and($labels->contains(fn (string $label) => str_ends_with($label, 'log.output=file /traffic/access.log')))->toBeTrue();
})->with([
'caddy-docker-proxy 2.8' => ['lucaslorentz/caddy-docker-proxy:2.8-alpine', false],
'caddy-docker-proxy 2.11' => ['lucaslorentz/caddy-docker-proxy:2.11-alpine', true],
]);
@@ -46,3 +46,15 @@ it('mounts the traffic volume for caddy when traffic analytics is enabled', func
expect($config['services']['caddy']['volumes'])
->toContain($server->proxyPath().':/traffic');
});
it('uses a default caddy image that supports per-app traffic attribution', function () {
$server = Server::factory()->create(['team_id' => $this->team->id, 'private_key_id' => $this->privateKey->id]);
$server->proxy->set('type', 'CADDY');
$server->save();
$config = Yaml::parse(generateDefaultProxyConfiguration($server->fresh()));
// caddy-docker-proxy 2.13 ships Caddy 2.11, which knows log_append; the old 2.8 default shipped Caddy 2.7.6.
expect($config['services']['caddy']['image'])->toBe('lucaslorentz/caddy-docker-proxy:2.13-alpine')
->and($server->fresh()->caddySupportsLogAppend())->toBeTrue();
});
@@ -6,12 +6,20 @@ it('adds no traffic labels to caddy sites when disabled', function () {
});
it('stamps coolify_app_id and JSON access log on each caddy site when enabled', function () {
$labels = fqdnLabelsForCaddy('coolify', 'app-uuid', collect(['https://example.com']), is_traffic_analytics_enabled: true);
$labels = fqdnLabelsForCaddy('coolify', 'app-uuid', collect(['https://example.com']), is_traffic_analytics_enabled: true, supports_log_append: true);
expect($labels->contains('caddy_0.log_append=coolify_app_id app-uuid'))->toBeTrue();
expect($labels->contains('caddy_0.log.output=file /traffic/access.log'))->toBeTrue();
expect($labels->contains('caddy_0.log.format=json'))->toBeTrue();
});
it('keeps the JSON access log but omits log_append when caddy does not support it', function () {
// caddy-docker-proxy 2.8 runs Caddy 2.7.6, which rejects the whole Caddyfile for log_append.
$labels = fqdnLabelsForCaddy('coolify', 'app-uuid', collect(['https://example.com']), is_traffic_analytics_enabled: true);
expect($labels->filter(fn ($l) => str_contains($l, 'log_append'))->isEmpty())->toBeTrue();
expect($labels->contains('caddy_0.log.output=file /traffic/access.log'))->toBeTrue();
expect($labels->contains('caddy_0.log.format=json'))->toBeTrue();
});
it('emits lumberjack roll directives on each caddy site when enabled', function () {
$labels = fqdnLabelsForCaddy('coolify', 'app-uuid', collect(['https://example.com']), is_traffic_analytics_enabled: true);
expect($labels->contains('caddy_0.log.output.roll_size=20MiB'))->toBeTrue();
+43 -3
View File
@@ -276,7 +276,7 @@ test('adds ownership changes for Coolify data paths', function () {
$result = parseCommandsByLineForSudo($commands, $this->server);
expect($result[0])->toBe('sudo mkdir -p /data/coolify/logs && sudo chown -R ubuntu:ubuntu /data/coolify/logs && sudo chmod -R o-rwx /data/coolify/logs');
expect($result[0])->toBe('sudo mkdir -p /data/coolify/logs && sudo find /data/coolify/logs -user root -exec chown ubuntu:ubuntu {} + && sudo chmod o-rwx /data/coolify/logs');
});
test('adds ownership changes for Coolify tmp paths', function () {
@@ -286,7 +286,7 @@ test('adds ownership changes for Coolify tmp paths', function () {
$result = parseCommandsByLineForSudo($commands, $this->server);
expect($result[0])->toBe('sudo mkdir -p /tmp/coolify/cache && sudo chown -R ubuntu:ubuntu /tmp/coolify/cache && sudo chmod -R o-rwx /tmp/coolify/cache');
expect($result[0])->toBe('sudo mkdir -p /tmp/coolify/cache && sudo find /tmp/coolify/cache -user root -exec chown ubuntu:ubuntu {} + && sudo chmod o-rwx /tmp/coolify/cache');
});
test('ownership changes work where root may not use sudo', function (string $parser) {
@@ -310,7 +310,47 @@ test('ownership changes work where root may not use sudo', function (string $par
->and($process->getErrorOutput())->toBe('')
->and($process->isSuccessful())->toBeTrue()
->and(is_dir($path))->toBeTrue()
->and(file_get_contents("{$directory}/log"))->toBe("chown -R ubuntu:ubuntu {$path}\nchmod -R o-rwx {$path}\n");
->and(file_get_contents("{$directory}/log"))->toContain("chmod o-rwx {$path}\n");
(new Process(['rm', '-rf', $directory, $path]))->run();
})->with(['command list', 'deployment line']);
test('ownership changes keep container-owned files and file modes', function (string $parser) {
if (posix_geteuid() !== 0 || posix_getpwnam('daemon') === false) {
$this->markTestSkipped('Needs root and a daemon user to change file owners.');
}
$directory = sys_get_temp_dir().'/coolify-sudo-'.bin2hex(random_bytes(4));
mkdir($directory);
file_put_contents("{$directory}/sudo", "#!/bin/sh\nexec \"\$@\"\n");
chmod("{$directory}/sudo", 0755);
$path = '/tmp/coolify/ownership-test-'.bin2hex(random_bytes(4));
// A file mount that Coolify wrote with sudo, and a database folder that belongs to the container user.
mkdir("{$path}/pgdata", 0700, true);
file_put_contents("{$path}/index.html", 'mounted');
chmod("{$path}/index.html", 0644);
file_put_contents("{$path}/pgdata/PG_VERSION", '17');
chmod("{$path}/pgdata/PG_VERSION", 0600);
chown("{$path}/pgdata", 999);
chown("{$path}/pgdata/PG_VERSION", 999);
$server = Mockery::mock(Server::class)->makePartial();
$server->shouldReceive('getAttribute')->with('user')->andReturn('daemon');
$server->shouldReceive('setAttribute')->andReturnSelf();
$command = $parser === 'command list'
? parseCommandsByLineForSudo(collect(["mkdir -p {$path}"]), $server)[0]
: parseLineForSudo("mkdir -p {$path}", $server);
$process = new Process(['/bin/sh', '-c', $command], env: ['PATH' => "{$directory}:".getenv('PATH')]);
$process->run();
clearstatcache();
expect($process->getErrorOutput())->toBe('')
->and(fileowner($path))->toBe(1)
->and(fileperms($path) & 0007)->toBe(0)
->and(fileowner("{$path}/index.html"))->toBe(1)
->and(fileperms("{$path}/index.html") & 0777)->toBe(0644)
->and(fileowner("{$path}/pgdata"))->toBe(999)
->and(fileowner("{$path}/pgdata/PG_VERSION"))->toBe(999)
->and(fileperms("{$path}/pgdata/PG_VERSION") & 0777)->toBe(0600);
(new Process(['rm', '-rf', $directory, $path]))->run();
})->with(['command list', 'deployment line']);
+12
View File
@@ -10,6 +10,11 @@ it('extracts normalized published proxy ports from valid compose syntax', functi
'caddy long syntax' => ["services:\n caddy:\n ports:\n - target: 80\n published: '8080'\n protocol: tcp\n - target: 443\n", [8080, 443]],
'both supported proxies' => ["services:\n traefik:\n ports: ['80:80']\n caddy:\n ports: ['443:443/udp']\n", [80, 443]],
'range boundaries' => ["services:\n traefik:\n ports: ['1:1', '65535:65535']\n", [1, 65535]],
'port ranges are accepted but not checked one by one' => ["services:\n traefik:\n ports: ['80:80', '10000-10100:10000-10100/udp', '127.0.0.1:5000-5010:5000-5010', '3000-3005', '8000-8010:80']\n", [80]],
'protocols in any letter case' => ["services:\n traefik:\n ports: ['53:53/UDP', '9000:9000/TCP', '5432:5432/sctp']\n", [53, 9000, 5432]],
'random host port' => ["services:\n traefik:\n ports: ['127.0.0.1::5000', '[::1]::6000']\n", []],
'IPv6 host with a range' => ["services:\n traefik:\n ports: ['[::1]:6000-6001:6000-6001']\n", []],
'long syntax with a published range' => ["services:\n caddy:\n ports:\n - target: 443\n published: '8443-8444'\n protocol: UDP\n", []],
]);
it('rejects malformed proxy port values', function (mixed $port) {
@@ -41,7 +46,13 @@ it('rejects malformed proxy port values', function (mixed $port) {
'nested sequence' => [['80']],
'nested map' => [['target' => ['80']]],
'invalid protocol' => '80:80/http',
'empty protocol' => '80:80/',
'host injection' => '`id`:8080:80',
'reversed range' => '10-5:10-5',
'range above the limit' => '65535-65536:1-2',
'range with command substitution' => '80-$(id):80',
'open range' => '80-:80',
'empty host port without a host IP' => ':80',
]);
it('rejects malformed proxy ports in long compose syntax', function (array $port) {
@@ -55,6 +66,7 @@ it('rejects malformed proxy ports in long compose syntax', function (array $port
'invalid published type' => [['target' => 80, 'published' => true]],
'missing target' => [['published' => 8080]],
'invalid protocol' => [['target' => 80, 'published' => 8080, 'protocol' => 'http']],
'reversed published range' => [['target' => 80, 'published' => '90-80']],
'host injection' => [['target' => 80, 'published' => 8080, 'host_ip' => '$(id)']],
]);