mirror of
https://github.com/coollabsio/coolify.git
synced 2026-09-26 09:20:54 -04:00
fix(proxy): normalize port configuration handling (#11923)
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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],
|
||||
]);
|
||||
Reference in New Issue
Block a user