418 lines
16 KiB
PHP
418 lines
16 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;
|
|
count=\$(echo \"\$ss_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 \"\$ss_output\" | grep -q 'docker\\|coolify'); then
|
|
echo 'port_free';
|
|
exit 0;
|
|
fi;
|
|
echo \"port_conflict|\$ss_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;
|
|
count=\$(echo \"\$netstat_output\" | grep -c 'LISTEN');
|
|
if [ \$count -eq 0 ]; then
|
|
echo 'port_free';
|
|
exit 0;
|
|
fi;
|
|
if [ \$count -le 2 ] && (echo \"\$netstat_output\" | grep -q 'docker\\|coolify'); then
|
|
echo 'port_free';
|
|
exit 0;
|
|
fi;
|
|
echo \"port_conflict|\$netstat_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;
|
|
}
|
|
|
|
// 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;
|
|
}
|
|
}
|
|
}
|