mirror of
https://github.com/coollabsio/coolify.git
synced 2026-03-11 08:55:47 +00:00
Coolify's proxy uses Docker's 0.0.0.0 binding, which can coexist with specific-IP bindings (e.g., Tailscale 100.x.x.x) via SO_REUSEADDR. Previously, the port conflict check incorrectly flagged all port listeners as conflicts, regardless of which IP address they bound to. This caused false positives when services like Tailscale were running on internal network interfaces. This fix filters port conflict detection to only consider listeners on wildcard addresses (0.0.0.0, *, [::], ::) as real conflicts, while allowing specific-IP bindings to coexist with the proxy. The filtering is applied across all port check paths: - ss command path (buildPortCheckCommands) - netstat fallback path (buildPortCheckCommands) - isPortConflict() method for sequential checks
443 lines
18 KiB
PHP
443 lines
18 KiB
PHP
<?php
|
|
|
|
namespace App\Actions\Proxy;
|
|
|
|
use App\Enums\ProxyTypes;
|
|
use App\Models\Server;
|
|
use Illuminate\Support\Facades\Log;
|
|
use Illuminate\Support\Facades\Process;
|
|
use Lorisleiva\Actions\Concerns\AsAction;
|
|
use Symfony\Component\Yaml\Yaml;
|
|
|
|
class CheckProxy
|
|
{
|
|
use AsAction;
|
|
|
|
// It should return if the proxy should be started (true) or not (false)
|
|
public function handle(Server $server, $fromUI = false): bool
|
|
{
|
|
if (! $server->isFunctional()) {
|
|
return false;
|
|
}
|
|
if ($server->isBuildServer()) {
|
|
if ($server->proxy) {
|
|
$server->proxy = null;
|
|
$server->save();
|
|
}
|
|
|
|
return false;
|
|
}
|
|
$proxyType = $server->proxyType();
|
|
if ((is_null($proxyType) || $proxyType === 'NONE' || $server->proxy->force_stop) && ! $fromUI) {
|
|
return false;
|
|
}
|
|
if (! $server->isProxyShouldRun()) {
|
|
if ($fromUI) {
|
|
throw new \Exception('Proxy should not run. You selected the Custom Proxy.');
|
|
} else {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// Determine proxy container name based on environment
|
|
$proxyContainerName = $server->isSwarm() ? 'coolify-proxy_traefik' : 'coolify-proxy';
|
|
|
|
if ($server->isSwarm()) {
|
|
$status = getContainerStatus($server, $proxyContainerName);
|
|
$server->proxy->set('status', $status);
|
|
$server->save();
|
|
if ($status === 'running') {
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
} else {
|
|
$status = getContainerStatus($server, $proxyContainerName);
|
|
if ($status === 'running') {
|
|
$server->proxy->set('status', 'running');
|
|
$server->save();
|
|
|
|
return false;
|
|
}
|
|
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;
|
|
}
|
|
$portsToCheck = array_values(array_unique($portsToCheck));
|
|
// Check port conflicts in parallel
|
|
$conflicts = $this->checkPortConflictsInParallel($server, $portsToCheck, $proxyContainerName);
|
|
foreach ($conflicts as $port => $conflict) {
|
|
if ($conflict) {
|
|
if ($fromUI) {
|
|
throw new \Exception("Port $port is in use.<br>You must stop the process using this port.<br><br>Docs: <a target='_blank' class='dark:text-white hover:underline' href='https://coolify.io/docs'>https://coolify.io/docs</a><br>Discord: <a target='_blank' class='dark:text-white hover:underline' href='https://coolify.io/discord'>https://coolify.io/discord</a>");
|
|
} else {
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check multiple ports for conflicts in parallel
|
|
* Returns an array with port => conflict_status mapping
|
|
*/
|
|
private function checkPortConflictsInParallel(Server $server, array $ports, string $proxyContainerName): array
|
|
{
|
|
if (empty($ports)) {
|
|
return [];
|
|
}
|
|
|
|
try {
|
|
// Build concurrent port check commands
|
|
$results = Process::concurrently(function ($pool) use ($server, $ports, $proxyContainerName) {
|
|
foreach ($ports as $port) {
|
|
$commands = $this->buildPortCheckCommands($server, $port, $proxyContainerName);
|
|
$pool->command($commands['ssh_command'])->timeout(10);
|
|
}
|
|
});
|
|
|
|
// Process results
|
|
$conflicts = [];
|
|
|
|
foreach ($ports as $index => $port) {
|
|
$result = $results[$index] ?? null;
|
|
|
|
if ($result) {
|
|
$conflicts[$port] = $this->parsePortCheckResult($result, $port, $proxyContainerName);
|
|
} else {
|
|
// If process failed, assume no conflict to avoid false positives
|
|
$conflicts[$port] = false;
|
|
}
|
|
}
|
|
|
|
return $conflicts;
|
|
} catch (\Throwable $e) {
|
|
Log::warning('Parallel port checking failed: '.$e->getMessage().'. Falling back to sequential checking.');
|
|
|
|
// Fallback to sequential checking if parallel fails
|
|
$conflicts = [];
|
|
foreach ($ports as $port) {
|
|
$conflicts[$port] = $this->isPortConflict($server, $port, $proxyContainerName);
|
|
}
|
|
|
|
return $conflicts;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Build the SSH command for checking a specific port
|
|
*/
|
|
private function buildPortCheckCommands(Server $server, string $port, string $proxyContainerName): array
|
|
{
|
|
// 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
|
|
echo 'proxy_using_port';
|
|
exit 0;
|
|
fi;
|
|
fi;
|
|
";
|
|
|
|
// Command sets for different ways to check ports, ordered by preference
|
|
$portCheckScript = "
|
|
$checkProxyPortScript
|
|
|
|
# Try ss command first
|
|
if command -v ss >/dev/null 2>&1; then
|
|
ss_output=\$(ss -Htuln state listening sport = :$port 2>/dev/null);
|
|
if [ -z \"\$ss_output\" ]; then
|
|
echo 'port_free';
|
|
exit 0;
|
|
fi;
|
|
# Filter: only wildcard-bound listeners (0.0.0.0, *, [::], ::) conflict with Docker's 0.0.0.0 binding
|
|
# Specific IP bindings (e.g., Tailscale 100.x.x.x) coexist via SO_REUSEADDR
|
|
wildcard_output=\$(echo \"\$ss_output\" | grep -E '(0\\.0\\.0\\.0|\\*|\\[::\\]):{$port}[[:space:]]|:::{$port}[[:space:]]');
|
|
if [ -z \"\$wildcard_output\" ]; then
|
|
echo 'port_free';
|
|
exit 0;
|
|
fi;
|
|
count=\$(echo \"\$wildcard_output\" | grep -c ':$port ');
|
|
if [ \$count -eq 0 ]; then
|
|
echo 'port_free';
|
|
exit 0;
|
|
fi;
|
|
# Check for dual-stack or docker processes
|
|
if [ \$count -le 2 ] && (echo \"\$wildcard_output\" | grep -q 'docker\\|coolify'); then
|
|
echo 'port_free';
|
|
exit 0;
|
|
fi;
|
|
echo \"port_conflict|\$wildcard_output\";
|
|
exit 0;
|
|
fi;
|
|
|
|
# Try netstat as fallback
|
|
if command -v netstat >/dev/null 2>&1; then
|
|
netstat_output=\$(netstat -tuln 2>/dev/null | grep ':$port ');
|
|
if [ -z \"\$netstat_output\" ]; then
|
|
echo 'port_free';
|
|
exit 0;
|
|
fi;
|
|
# Filter: only wildcard-bound listeners conflict with Docker's 0.0.0.0 binding
|
|
wildcard_output=\$(echo \"\$netstat_output\" | grep -E '(0\\.0\\.0\\.0|\\*|\\[::\\]):{$port}[[:space:]]|:::{$port}[[:space:]]');
|
|
if [ -z \"\$wildcard_output\" ]; then
|
|
echo 'port_free';
|
|
exit 0;
|
|
fi;
|
|
count=\$(echo \"\$wildcard_output\" | grep -c 'LISTEN');
|
|
if [ \$count -eq 0 ]; then
|
|
echo 'port_free';
|
|
exit 0;
|
|
fi;
|
|
if [ \$count -le 2 ] && (echo \"\$wildcard_output\" | grep -q 'docker\\|coolify'); then
|
|
echo 'port_free';
|
|
exit 0;
|
|
fi;
|
|
echo \"port_conflict|\$wildcard_output\";
|
|
exit 0;
|
|
fi;
|
|
|
|
# Final fallback using nc
|
|
if nc -z -w1 127.0.0.1 $port >/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,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Parse the result from port check command
|
|
*/
|
|
private function parsePortCheckResult($processResult, string $port, string $proxyContainerName): bool
|
|
{
|
|
$exitCode = $processResult->exitCode();
|
|
$output = trim($processResult->output());
|
|
$errorOutput = trim($processResult->errorOutput());
|
|
|
|
if ($exitCode !== 0) {
|
|
return false;
|
|
}
|
|
|
|
if ($output === 'proxy_using_port' || $output === 'port_free') {
|
|
return false; // No conflict
|
|
}
|
|
|
|
if (str_starts_with($output, 'port_conflict|')) {
|
|
$details = substr($output, strlen('port_conflict|'));
|
|
|
|
// Additional logic to detect dual-stack scenarios
|
|
if ($details !== 'nc_detected') {
|
|
// Check for dual-stack scenario - typically 1-2 listeners (IPv4+IPv6)
|
|
$lines = explode("\n", $details);
|
|
if (count($lines) <= 2) {
|
|
// Look for IPv4 and IPv6 in the listing
|
|
if ((strpos($details, '0.0.0.0:'.$port) !== false && strpos($details, ':::'.$port) !== false) ||
|
|
(strpos($details, '*:'.$port) !== false && preg_match('/\*:'.$port.'.*IPv[46]/', $details))) {
|
|
|
|
return false; // This is just a normal dual-stack setup
|
|
}
|
|
}
|
|
}
|
|
|
|
return true; // Real port conflict
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
{
|
|
// 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\"'";
|
|
try {
|
|
instant_remote_process([$checkProxyPort], $server);
|
|
|
|
// Our proxy is using the port, which is fine
|
|
return false;
|
|
} catch (\Throwable $e) {
|
|
// Our container exists but not using this port
|
|
}
|
|
}
|
|
} catch (\Throwable $e) {
|
|
// Container not found or error checking, continue with regular checks
|
|
}
|
|
|
|
// Command sets for different ways to check ports, ordered by preference
|
|
$commandSets = [
|
|
// Set 1: Use ss to check listener counts by protocol stack
|
|
[
|
|
'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\"",
|
|
// Count IPv4 listeners
|
|
"echo \"\$ss_output\" | grep -c ':$port '",
|
|
],
|
|
],
|
|
// Set 2: Use netstat as alternative to ss
|
|
[
|
|
'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 '",
|
|
// Count listeners
|
|
"echo \"\$netstat_output\" | grep ':$port ' | grep -c 'LISTEN'",
|
|
],
|
|
],
|
|
// Set 3: Use lsof as last resort
|
|
[
|
|
'available' => 'command -v lsof >/dev/null 2>&1',
|
|
'check' => [
|
|
// Get process using the port
|
|
"lsof -i :$port -P -n | grep 'LISTEN'",
|
|
// Count listeners
|
|
"lsof -i :$port -P -n | grep 'LISTEN' | wc -l",
|
|
],
|
|
],
|
|
];
|
|
|
|
// Try each command set until we find one available
|
|
foreach ($commandSets as $set) {
|
|
try {
|
|
// Check if the command is available
|
|
instant_remote_process([$set['available']], $server);
|
|
|
|
// Run the actual check commands
|
|
$output = instant_remote_process($set['check'], $server, true);
|
|
// Parse the output lines
|
|
$lines = explode("\n", trim($output));
|
|
// Get the detailed output and listener count
|
|
$details = trim(implode("\n", array_slice($lines, 0, -1)));
|
|
$count = intval(trim($lines[count($lines) - 1] ?? '0'));
|
|
// If no listeners or empty result, port is free
|
|
if ($count == 0 || empty($details)) {
|
|
return false;
|
|
}
|
|
|
|
// Filter: only wildcard-bound listeners (0.0.0.0, *, [::], ::) conflict with Docker's 0.0.0.0 binding
|
|
// Specific IP bindings (e.g., Tailscale 100.x.x.x) coexist via SO_REUSEADDR
|
|
$detailLines = explode("\n", $details);
|
|
$wildcardPattern = '/(0\.0\.0\.0|\*|\[::\]):'.preg_quote($port, '/').'(\s|$)|:::'.preg_quote($port, '/').'(\s|$)/';
|
|
$wildcardLines = array_filter($detailLines, function ($line) use ($wildcardPattern) {
|
|
return preg_match($wildcardPattern, $line);
|
|
});
|
|
if (empty($wildcardLines)) {
|
|
return false;
|
|
}
|
|
$details = trim(implode("\n", $wildcardLines));
|
|
$count = count($wildcardLines);
|
|
|
|
// Try to detect if this is our coolify-proxy
|
|
if (strpos($details, 'docker') !== false || strpos($details, $proxyContainerName) !== false) {
|
|
// It's likely our docker or proxy, which is fine
|
|
return false;
|
|
}
|
|
|
|
// Check for dual-stack scenario - typically 1-2 listeners (IPv4+IPv6)
|
|
// If exactly 2 listeners and both have same port, likely dual-stack
|
|
if ($count <= 2) {
|
|
// Check if it looks like a standard dual-stack setup
|
|
$isDualStack = false;
|
|
|
|
// Look for IPv4 and IPv6 in the listing (ss output format)
|
|
if (preg_match('/LISTEN.*:'.$port.'\s/', $details) &&
|
|
(preg_match('/\*:'.$port.'\s/', $details) ||
|
|
preg_match('/:::'.$port.'\s/', $details))) {
|
|
$isDualStack = true;
|
|
}
|
|
|
|
// For netstat format
|
|
if (strpos($details, '0.0.0.0:'.$port) !== false &&
|
|
strpos($details, ':::'.$port) !== false) {
|
|
$isDualStack = true;
|
|
}
|
|
|
|
// For lsof format (IPv4 and IPv6)
|
|
if (strpos($details, '*:'.$port) !== false &&
|
|
preg_match('/\*:'.$port.'.*IPv4/', $details) &&
|
|
preg_match('/\*:'.$port.'.*IPv6/', $details)) {
|
|
$isDualStack = true;
|
|
}
|
|
|
|
if ($isDualStack) {
|
|
return false; // This is just a normal dual-stack setup
|
|
}
|
|
}
|
|
|
|
// If we get here, it's likely a real port conflict
|
|
return true;
|
|
|
|
} catch (\Throwable $e) {
|
|
// This command set failed, try the next one
|
|
continue;
|
|
}
|
|
}
|
|
|
|
// Fallback to simpler check if all above methods fail
|
|
try {
|
|
// Just try to bind to the port directly to see if it's available
|
|
$checkCommand = "nc -z -w1 127.0.0.1 $port >/dev/null 2>&1 && echo 'in-use' || echo 'free'";
|
|
$result = instant_remote_process([$checkCommand], $server, true);
|
|
|
|
return trim($result) === 'in-use';
|
|
} catch (\Throwable $e) {
|
|
// If everything fails, assume the port is free to avoid false positives
|
|
return false;
|
|
}
|
|
}
|
|
}
|