fix(proxy): normalize port configuration handling

This commit is contained in:
Andras Bacsai
2026-09-21 17:03:07 +02:00
parent c1a962ae03
commit a7bf6d1cfe
10 changed files with 398 additions and 65 deletions
+52 -53
View File
@@ -3,11 +3,12 @@
namespace App\Actions\Proxy;
use App\Enums\ProxyTypes;
use App\Helpers\SshMultiplexingHelper;
use App\Models\Server;
use App\Services\ProxyPortParser;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Process;
use Lorisleiva\Actions\Concerns\AsAction;
use Symfony\Component\Yaml\Yaml;
class CheckProxy
{
@@ -52,6 +53,19 @@ class CheckProxy
return true;
} else {
$portsToCheck = [];
try {
if ($server->proxyType() !== ProxyTypes::NONE->value) {
$proxyCompose = GetProxyConfiguration::run($server);
$portsToCheck = ProxyPortParser::fromConfiguration($proxyCompose);
}
} catch (\Throwable $e) {
Log::error('Error checking proxy: '.$e->getMessage());
return false;
}
$status = getContainerStatus($server, $proxyContainerName);
if ($status === 'running') {
$server->proxy->set('status', 'running');
@@ -62,37 +76,6 @@ class CheckProxy
if ($server->settings->is_cloudflare_tunnel) {
return false;
}
$ip = $server->ip;
if ($server->id === 0) {
$ip = 'host.docker.internal';
}
$portsToCheck = [];
try {
if ($server->proxyType() !== ProxyTypes::NONE->value) {
$proxyCompose = GetProxyConfiguration::run($server);
if (isset($proxyCompose)) {
$yaml = Yaml::parse($proxyCompose);
$configPorts = [];
if ($server->proxyType() === ProxyTypes::TRAEFIK->value) {
$ports = data_get($yaml, 'services.traefik.ports');
} elseif ($server->proxyType() === ProxyTypes::CADDY->value) {
$ports = data_get($yaml, 'services.caddy.ports');
}
if (isset($ports)) {
foreach ($ports as $port) {
$configPorts[] = str($port)->before(':')->value();
}
}
// Combine default ports with config ports
$portsToCheck = array_merge($portsToCheck, $configPorts);
}
} else {
$portsToCheck = [];
}
} catch (\Exception $e) {
Log::error('Error checking proxy: '.$e->getMessage());
}
if (count($portsToCheck) === 0) {
return false;
}
@@ -163,14 +146,31 @@ class CheckProxy
/**
* Build the SSH command for checking a specific port
*/
private function buildPortCheckCommands(Server $server, string $port, string $proxyContainerName): array
private function buildPortCheckCommands(Server $server, int $port, string $proxyContainerName): array
{
$portCheckScript = $this->buildPortCheckScript($port, $proxyContainerName);
$sshCommand = SshMultiplexingHelper::generateSshCommand($server, $portCheckScript);
return [
'ssh_command' => $sshCommand,
'script' => $portCheckScript,
];
}
private function buildPortCheckScript(int $port, string $proxyContainerName): string
{
$dockerPortPattern = escapeshellarg('"'.$port.'/tcp"');
$socketPort = escapeshellarg(':'.$port);
$portSuffixPattern = escapeshellarg(':'.$port.' ');
$portArgument = escapeshellarg((string) $port);
// First check if our own proxy is using this port (which is fine)
$getProxyContainerId = "docker ps -a --filter name=$proxyContainerName --format '{{.ID}}'";
$checkProxyPortScript = "
CONTAINER_ID=\$($getProxyContainerId);
if [ ! -z \"\$CONTAINER_ID\" ]; then
if docker inspect \$CONTAINER_ID --format '{{json .NetworkSettings.Ports}}' | grep -q '\"$port/tcp\"'; then
if docker inspect \$CONTAINER_ID --format '{{json .NetworkSettings.Ports}}' | grep -q $dockerPortPattern; then
echo 'proxy_using_port';
exit 0;
fi;
@@ -183,12 +183,12 @@ class CheckProxy
# Try ss command first
if command -v ss >/dev/null 2>&1; then
ss_output=\$(ss -Htuln state listening sport = :$port 2>/dev/null);
ss_output=\$(ss -Htuln state listening sport = $socketPort 2>/dev/null);
if [ -z \"\$ss_output\" ]; then
echo 'port_free';
exit 0;
fi;
count=\$(echo \"\$ss_output\" | grep -c ':$port ');
count=\$(echo \"\$ss_output\" | grep -c $portSuffixPattern);
if [ \$count -eq 0 ]; then
echo 'port_free';
exit 0;
@@ -204,7 +204,7 @@ class CheckProxy
# Try netstat as fallback
if command -v netstat >/dev/null 2>&1; then
netstat_output=\$(netstat -tuln 2>/dev/null | grep ':$port ');
netstat_output=\$(netstat -tuln 2>/dev/null | grep $portSuffixPattern);
if [ -z \"\$netstat_output\" ]; then
echo 'port_free';
exit 0;
@@ -223,25 +223,20 @@ class CheckProxy
fi;
# Final fallback using nc
if nc -z -w1 127.0.0.1 $port >/dev/null 2>&1; then
if nc -z -w1 127.0.0.1 $portArgument >/dev/null 2>&1; then
echo 'port_conflict|nc_detected';
else
echo 'port_free';
fi;
";
$sshCommand = \App\Helpers\SshMultiplexingHelper::generateSshCommand($server, $portCheckScript);
return [
'ssh_command' => $sshCommand,
'script' => $portCheckScript,
];
return $portCheckScript;
}
/**
* Parse the result from port check command
*/
private function parsePortCheckResult($processResult, string $port, string $proxyContainerName): bool
private function parsePortCheckResult($processResult, int $port, string $proxyContainerName): bool
{
$exitCode = $processResult->exitCode();
$output = trim($processResult->output());
@@ -282,15 +277,19 @@ class CheckProxy
* Smart port checker that handles dual-stack configurations
* Returns true only if there's a real port conflict (not just dual-stack)
*/
private function isPortConflict(Server $server, string $port, string $proxyContainerName): bool
private function isPortConflict(Server $server, int $port, string $proxyContainerName): bool
{
$dockerPortPattern = escapeshellarg('"'.$port.'/tcp"');
$socketPort = escapeshellarg(':'.$port);
$portSuffixPattern = escapeshellarg(':'.$port.' ');
// First check if our own proxy is using this port (which is fine)
try {
$getProxyContainerId = "docker ps -a --filter name=$proxyContainerName --format '{{.ID}}'";
$containerId = trim(instant_remote_process([$getProxyContainerId], $server));
if (! empty($containerId)) {
$checkProxyPort = "docker inspect $containerId --format '{{json .NetworkSettings.Ports}}' | grep '\"$port/tcp\"'";
$checkProxyPort = "docker inspect $containerId --format '{{json .NetworkSettings.Ports}}' | grep $dockerPortPattern";
try {
instant_remote_process([$checkProxyPort], $server);
@@ -311,9 +310,9 @@ class CheckProxy
'available' => 'command -v ss >/dev/null 2>&1',
'check' => [
// Get listening process details
"ss_output=\$(ss -Htuln state listening sport = :$port 2>/dev/null) && echo \"\$ss_output\"",
"ss_output=\$(ss -Htuln state listening sport = $socketPort 2>/dev/null) && echo \"\$ss_output\"",
// Count IPv4 listeners
"echo \"\$ss_output\" | grep -c ':$port '",
"echo \"\$ss_output\" | grep -c $portSuffixPattern",
],
],
// Set 2: Use netstat as alternative to ss
@@ -321,9 +320,9 @@ class CheckProxy
'available' => 'command -v netstat >/dev/null 2>&1',
'check' => [
// Get listening process details
"netstat_output=\$(netstat -tuln 2>/dev/null) && echo \"\$netstat_output\" | grep ':$port '",
"netstat_output=\$(netstat -tuln 2>/dev/null) && echo \"\$netstat_output\" | grep $portSuffixPattern",
// Count listeners
"echo \"\$netstat_output\" | grep ':$port ' | grep -c 'LISTEN'",
"echo \"\$netstat_output\" | grep $portSuffixPattern | grep -c 'LISTEN'",
],
],
// Set 3: Use lsof as last resort
@@ -331,9 +330,9 @@ class CheckProxy
'available' => 'command -v lsof >/dev/null 2>&1',
'check' => [
// Get process using the port
"lsof -i :$port -P -n | grep 'LISTEN'",
"lsof -i $socketPort -P -n | grep 'LISTEN'",
// Count listeners
"lsof -i :$port -P -n | grep 'LISTEN' | wc -l",
"lsof -i $socketPort -P -n | grep 'LISTEN' | wc -l",
],
],
];
@@ -5,6 +5,7 @@ namespace App\Actions\Proxy;
use App\Enums\ProxyTypes;
use App\Models\Server;
use App\Services\ProxyDashboardCacheService;
use App\Services\ProxyPortParser;
use Illuminate\Support\Facades\Log;
use Lorisleiva\Actions\Concerns\AsAction;
use Symfony\Component\Yaml\Yaml;
@@ -112,6 +113,7 @@ class GetProxyConfiguration
}
if (! empty(trim($result ?? ''))) {
ProxyPortParser::fromConfiguration($result);
$server->proxy->last_saved_proxy_configuration = $result;
$server->save();
@@ -3,6 +3,8 @@
namespace App\Actions\Proxy;
use App\Models\Server;
use App\Services\ProxyPortParser;
use Illuminate\Validation\ValidationException;
use Lorisleiva\Actions\Concerns\AsAction;
class SaveProxyConfiguration
@@ -13,6 +15,14 @@ class SaveProxyConfiguration
public function handle(Server $server, string $configuration): void
{
try {
ProxyPortParser::fromConfiguration($configuration);
} catch (\InvalidArgumentException $exception) {
throw ValidationException::withMessages([
'configuration' => [$exception->getMessage()],
]);
}
$proxy_path = $server->proxyPath();
$docker_compose_yml_base64 = base64_encode($configuration);
$new_hash = str($docker_compose_yml_base64)->pipe('md5')->value;
+7 -4
View File
@@ -6,6 +6,7 @@ use App\Enums\ProxyTypes;
use App\Events\ProxyStatusChanged;
use App\Events\ProxyStatusChangedUI;
use App\Models\Server;
use App\Services\ProxyPortParser;
use Lorisleiva\Actions\Concerns\AsAction;
use Spatie\Activitylog\Models\Activity;
@@ -19,6 +20,12 @@ class StartProxy
if ((is_null($proxyType) || $proxyType === 'NONE' || $server->proxy->force_stop || $server->isBuildServer()) && $force === false) {
return 'OK';
}
$configuration = GetProxyConfiguration::run($server);
if (! $configuration) {
throw new \Exception('Configuration is not synced');
}
ProxyPortParser::fromConfiguration($configuration);
$server->proxy->set('status', 'starting');
$server->save();
$server->refresh();
@@ -29,10 +36,6 @@ class StartProxy
$commands = collect([]);
$proxy_path = $server->proxyPath();
$configuration = GetProxyConfiguration::run($server);
if (! $configuration) {
throw new \Exception('Configuration is not synced');
}
SaveProxyConfiguration::run($server, $configuration);
$docker_compose_yml_base64 = base64_encode($configuration);
$server->proxy->last_applied_settings = str($docker_compose_yml_base64)->pipe('md5')->value();
@@ -6,6 +6,7 @@ use App\Actions\Proxy\GetProxyConfiguration;
use App\Actions\Proxy\SaveProxyConfiguration;
use App\Jobs\RestartProxyJob;
use App\Models\Server;
use App\Services\ProxyPortParser;
use Lorisleiva\Actions\Concerns\AsAction;
class ConfigureTrafficAnalytics
@@ -14,13 +15,15 @@ class ConfigureTrafficAnalytics
public function handle(Server $server, bool $enable): void
{
$configuration = GetProxyConfiguration::run($server);
ProxyPortParser::fromConfiguration($configuration);
$sentinelWasEnabled = (bool) $server->settings->is_sentinel_enabled;
$server->settings->is_traffic_analytics_enabled = $enable;
$server->settings->save();
$server->refresh();
$configuration = GetProxyConfiguration::run($server);
$configuration = applyTrafficAnalyticsToProxyConfiguration($server, $configuration);
SaveProxyConfiguration::run($server, $configuration);
RestartProxyJob::dispatch($server);
+11 -7
View File
@@ -8,6 +8,7 @@ use App\Enums\ProxyTypes;
use App\Events\ProxyStatusChangedUI;
use App\Models\Server;
use App\Services\ProxyDashboardCacheService;
use App\Services\ProxyPortParser;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
use Illuminate\Contracts\Queue\ShouldQueue;
@@ -36,13 +37,19 @@ class RestartProxyJob implements ShouldBeEncrypted, ShouldQueue
public function handle()
{
try {
$configuration = GetProxyConfiguration::run($this->server);
if (! $configuration) {
throw new \Exception('Configuration is not synced');
}
ProxyPortParser::fromConfiguration($configuration);
// Set status to restarting
$this->server->proxy->status = 'restarting';
$this->server->proxy->force_stop = false;
$this->server->save();
// Build combined stop + start commands for a single activity
$commands = $this->buildRestartCommands();
$commands = $this->buildRestartCommands($configuration);
// Create activity and dispatch immediately - returns Activity right away
// The remote_process runs asynchronously, so UI gets activity ID instantly
@@ -57,6 +64,8 @@ class RestartProxyJob implements ShouldBeEncrypted, ShouldQueue
$this->activity_id = $activity->id;
ProxyStatusChangedUI::dispatch($this->server->team_id, $this->activity_id);
} catch (\InvalidArgumentException $e) {
return handleError($e);
} catch (\Throwable $e) {
// Set error status
$this->server->proxy->status = 'error';
@@ -76,18 +85,13 @@ class RestartProxyJob implements ShouldBeEncrypted, ShouldQueue
* Build combined stop + start commands for proxy restart.
* This creates a single command sequence that shows all logs in one activity.
*/
private function buildRestartCommands(): array
private function buildRestartCommands(string $configuration): array
{
$proxyType = $this->server->proxyType();
$containerName = $this->server->isSwarm() ? 'coolify-proxy_traefik' : 'coolify-proxy';
$proxy_path = $this->server->proxyPath();
$stopTimeout = 30;
// Get proxy configuration
$configuration = GetProxyConfiguration::run($this->server);
if (! $configuration) {
throw new \Exception('Configuration is not synced');
}
SaveProxyConfiguration::run($this->server, $configuration);
$docker_compose_yml_base64 = base64_encode($configuration);
$this->server->proxy->last_applied_settings = str($docker_compose_yml_base64)->pipe('md5')->value();
+152
View File
@@ -0,0 +1,152 @@
<?php
namespace App\Services;
use Symfony\Component\Yaml\Exception\ParseException;
use Symfony\Component\Yaml\Yaml;
class ProxyPortParser
{
/**
* @return list<int>
*/
public static function fromConfiguration(string $configuration): array
{
try {
$parsed = Yaml::parse($configuration);
} catch (ParseException $exception) {
throw new \InvalidArgumentException('The proxy configuration must contain valid YAML.', previous: $exception);
}
if (! is_array($parsed)) {
return [];
}
$ports = [];
foreach (['traefik', 'caddy'] as $proxyService) {
$path = "services.{$proxyService}.ports";
if (! data_has($parsed, $path)) {
continue;
}
$configuredPorts = data_get($parsed, $path);
if (! is_array($configuredPorts) || ! array_is_list($configuredPorts)) {
self::invalid();
}
foreach ($configuredPorts as $configuredPort) {
$ports[] = self::publishedPort($configuredPort);
}
}
return array_values(array_unique($ports));
}
private static function publishedPort(mixed $configuredPort): int
{
if (is_array($configuredPort)) {
if (array_is_list($configuredPort) || ! array_key_exists('target', $configuredPort)) {
self::invalid();
}
self::validateProtocol($configuredPort['protocol'] ?? null);
if (array_key_exists('host_ip', $configuredPort)) {
self::validateHostIp($configuredPort['host_ip']);
}
$target = self::portNumber($configuredPort['target']);
return array_key_exists('published', $configuredPort)
? self::portNumber($configuredPort['published'])
: $target;
}
if (! is_int($configuredPort) && ! is_string($configuredPort)) {
self::invalid();
}
if (is_int($configuredPort)) {
return self::portNumber($configuredPort);
}
$portDefinition = $configuredPort;
$protocolSeparator = strrpos($portDefinition, '/');
if ($protocolSeparator !== false) {
self::validateProtocol(substr($portDefinition, $protocolSeparator + 1));
$portDefinition = substr($portDefinition, 0, $protocolSeparator);
}
if (str_starts_with($portDefinition, '[')) {
if (! preg_match('/^\[([^]]+)]:(\d+):(\d+)$/D', $portDefinition, $matches)) {
self::invalid();
}
self::validateHostIp($matches[1]);
self::portNumber($matches[3]);
return self::portNumber($matches[2]);
}
$parts = explode(':', $portDefinition);
if (count($parts) < 1 || count($parts) > 3) {
self::invalid();
}
if (count($parts) === 3 && $parts[0] === '') {
self::invalid();
}
if (count($parts) === 3) {
self::validateHostIp($parts[0]);
}
$portParts = count($parts) === 3 ? array_slice($parts, 1) : $parts;
foreach ($portParts as $part) {
self::portNumber($part);
}
return self::portNumber($portParts[0]);
}
private static function portNumber(mixed $port): int
{
if (is_int($port)) {
if ($port < 1 || $port > 65535) {
self::invalid();
}
return $port;
}
if (! is_string($port) || preg_match('/^\d+$/D', $port) !== 1) {
self::invalid();
}
$normalized = (int) $port;
if ($normalized < 1 || $normalized > 65535) {
self::invalid();
}
return $normalized;
}
private static function validateProtocol(mixed $protocol): void
{
if ($protocol !== null && (! is_string($protocol) || ! in_array($protocol, ['tcp', 'udp'], true))) {
self::invalid();
}
}
private static function validateHostIp(mixed $hostIp): void
{
if (! is_string($hostIp) || filter_var($hostIp, FILTER_VALIDATE_IP) === false) {
self::invalid();
}
}
private static function invalid(): never
{
throw new \InvalidArgumentException('Proxy ports must be integers from 1 through 65535.');
}
}
+28
View File
@@ -209,6 +209,34 @@ test('PUT /api/v1/servers/{uuid}/proxy/configuration rejects missing configurati
->assertUnprocessable();
});
test('PUT /api/v1/servers/{uuid}/proxy/configuration rejects command injection without mutation', function () {
$originalProxy = $this->server->proxy->toArray();
$configuration = "services:\n traefik:\n ports:\n - '`id>/tmp/pwned`:443'\n";
$this->withHeaders(serverProxyApiHeaders($this->bearerToken))
->putJson("/api/v1/servers/{$this->server->uuid}/proxy/configuration", [
'configuration' => base64_encode($configuration),
])
->assertUnprocessable()
->assertJsonPath('errors.configuration.0', 'Proxy ports must be integers from 1 through 65535.');
expect($this->server->fresh()->proxy->toArray())->toBe($originalProxy);
});
test('PUT /api/v1/servers/{uuid}/proxy/configuration forbids normal members', function () {
$member = User::factory()->create();
$this->team->members()->attach($member->id, ['role' => 'member']);
$memberToken = serverProxyApiToken($member, $this->team, ['*']);
SaveProxyConfiguration::shouldNotRun();
$this->withHeaders(serverProxyApiHeaders($memberToken))
->putJson("/api/v1/servers/{$this->server->uuid}/proxy/configuration", [
'configuration' => base64_encode("services:\n traefik:\n ports: ['80:80']\n"),
])
->assertForbidden();
});
test('POST /api/v1/servers/{uuid}/proxy/restart queues RestartProxyJob', function () {
Queue::fake();
@@ -0,0 +1,61 @@
<?php
use App\Actions\Proxy\CheckProxy;
use App\Actions\Proxy\SaveProxyConfiguration;
use App\Enums\ProxyTypes;
use App\Models\InstanceSettings;
use App\Models\Server;
use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Process;
use Illuminate\Validation\ValidationException;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::forceCreate(['id' => 0]);
Process::fake();
$this->server = Server::factory()->create(['team_id' => Team::factory()]);
$this->server->settings()->update([
'is_reachable' => true,
'is_usable' => true,
'force_disabled' => false,
]);
$this->server->proxy->type = ProxyTypes::TRAEFIK->value;
$this->server->proxy->status = 'exited';
$this->server->save();
$this->server->refresh();
});
it('rejects a malicious configuration before database or remote writes', function () {
$originalProxy = $this->server->proxy->toArray();
$configuration = "services:\n traefik:\n ports:\n - '`id>/tmp/pwned`:443'\n";
expect(fn () => SaveProxyConfiguration::run($this->server, $configuration))
->toThrow(ValidationException::class);
expect($this->server->fresh()->proxy->toArray())->toBe($originalProxy);
Process::assertNothingRan();
});
it('does not execute remote commands for a malformed legacy stored port', function () {
$this->server->proxy->last_saved_proxy_configuration = "services:\n traefik:\n image: traefik:v3.7\n ports:\n - '$(id):80'\n";
$this->server->save();
expect(CheckProxy::run($this->server))->toBeFalse();
Process::assertNothingRan();
});
it('builds port checks only from normalized integer ports', function () {
$method = new ReflectionMethod(CheckProxy::class, 'buildPortCheckScript');
$script = $method->invoke(new CheckProxy, 443, 'coolify-proxy');
expect($script)
->toContain("grep -q '\"443/tcp\"'")
->toContain("sport = ':443'")
->toContain("nc -z -w1 127.0.0.1 '443'")
->not->toContain('$(', '`', ';id')
->and(fn () => $method->invoke(new CheckProxy, '80;id', 'coolify-proxy'))
->toThrow(TypeError::class);
});
+71
View File
@@ -0,0 +1,71 @@
<?php
use App\Services\ProxyPortParser;
use Symfony\Component\Yaml\Yaml;
it('extracts normalized published proxy ports from valid compose syntax', function (string $configuration, array $expected) {
expect(ProxyPortParser::fromConfiguration($configuration))->toBe($expected);
})->with([
'traefik short syntax' => ["services:\n traefik:\n ports: [80, '443:443', '127.0.0.1:8080:80', '0443:443/udp']\n", [80, 443, 8080]],
'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]],
]);
it('rejects malformed proxy port values', function (mixed $port) {
$configuration = Yaml::dump(['services' => ['traefik' => ['ports' => [$port]]]], 8, 2);
expect(fn () => ProxyPortParser::fromConfiguration($configuration))
->toThrow(InvalidArgumentException::class);
})->with([
'zero' => 0,
'too large' => 65536,
'negative' => -1,
'leading plus' => '+80',
'decimal' => 80.5,
'scientific notation' => '8e1',
'comma separated' => '80,443',
'environment variable' => '${HTTP_PORT:-80}:80',
'leading whitespace' => ' 80',
'trailing whitespace' => '80 ',
'newline' => "80\nid",
'semicolon' => '80;id',
'command substitution' => '$(id):80',
'backticks' => '`id`:80',
'pipe' => '80|id',
'logical operator' => '80&&id',
'quote' => "80'",
'option prefix' => '--help',
'null' => null,
'boolean' => true,
'nested sequence' => [['80']],
'nested map' => [['target' => ['80']]],
'invalid protocol' => '80:80/http',
'host injection' => '`id`:8080:80',
]);
it('rejects malformed proxy ports in long compose syntax', function (array $port) {
$configuration = Yaml::dump(['services' => ['caddy' => ['ports' => [$port]]]], 8, 2);
expect(fn () => ProxyPortParser::fromConfiguration($configuration))
->toThrow(InvalidArgumentException::class);
})->with([
'malicious published port' => [['target' => 80, 'published' => '$(id)']],
'malicious target port' => [['target' => '80;id', 'published' => 8080]],
'invalid published type' => [['target' => 80, 'published' => true]],
'missing target' => [['published' => 8080]],
'invalid protocol' => [['target' => 80, 'published' => 8080, 'protocol' => 'http']],
'host injection' => [['target' => 80, 'published' => 8080, 'host_ip' => '$(id)']],
]);
it('rejects invalid proxy port collection shapes', function (mixed $ports) {
$configuration = Yaml::dump(['services' => ['traefik' => ['ports' => $ports]]], 8, 2);
expect(fn () => ProxyPortParser::fromConfiguration($configuration))
->toThrow(InvalidArgumentException::class);
})->with([
'scalar' => ['80:80'],
'map' => [['published' => 80]],
'null' => [null],
'boolean' => [true],
]);