mirror of
https://github.com/coollabsio/coolify.git
synced 2026-03-11 08:55:47 +00:00
edge cases and just safer code
This commit is contained in:
parent
35a5a77526
commit
91ea235b5f
2 changed files with 325 additions and 8 deletions
|
|
@ -86,8 +86,28 @@ class EdgeProxyRemoteRouteService
|
|||
->filter();
|
||||
|
||||
foreach ($domains as $domain) {
|
||||
$unsupportedProtocol = $this->detectUnsupportedDomainProtocol($domain);
|
||||
if (! is_null($unsupportedProtocol)) {
|
||||
$warnings[] = sprintf(
|
||||
'Edge proxy route skipped for service %s (%s, domain %s): protocol "%s" is not supported for edge remote routing. Only http:// and https:// domains are currently supported.',
|
||||
$service->uuid,
|
||||
$application->name,
|
||||
$domain,
|
||||
$unsupportedProtocol
|
||||
);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$url = $this->parseDomainUrl($domain);
|
||||
if (! $url instanceof Url) {
|
||||
$warnings[] = sprintf(
|
||||
'Edge proxy route skipped for service %s (%s, domain %s): domain format is invalid. Use a valid hostname/domain with optional scheme, port and path.',
|
||||
$service->uuid,
|
||||
$application->name,
|
||||
$domain
|
||||
);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
@ -241,21 +261,27 @@ class EdgeProxyRemoteRouteService
|
|||
$banner = "# This file is generated by Coolify, do not edit it manually.\n\n";
|
||||
$payload = base64_encode($banner.$yaml);
|
||||
|
||||
$routeFilePath = $this->routeFilePath($edgeProxyServer, $serviceUuid);
|
||||
$temporaryRouteFilePath = $routeFilePath.'.tmp';
|
||||
|
||||
$escapedDirectory = escapeshellarg($this->routeDirectoryPath($edgeProxyServer));
|
||||
$escapedFilePath = escapeshellarg($this->routeFilePath($edgeProxyServer, $serviceUuid));
|
||||
$escapedFilePath = escapeshellarg($routeFilePath);
|
||||
$escapedTemporaryFilePath = escapeshellarg($temporaryRouteFilePath);
|
||||
|
||||
$this->runRemoteCommands($edgeProxyServer, [
|
||||
"mkdir -p $escapedDirectory",
|
||||
"echo '$payload' | base64 -d | tee $escapedFilePath > /dev/null",
|
||||
"echo '$payload' | base64 -d | tee $escapedTemporaryFilePath > /dev/null",
|
||||
"mv $escapedTemporaryFilePath $escapedFilePath",
|
||||
]);
|
||||
}
|
||||
|
||||
private function deleteRouteFile(Server $edgeProxyServer, string $serviceUuid): void
|
||||
{
|
||||
$escapedFilePath = escapeshellarg($this->routeFilePath($edgeProxyServer, $serviceUuid));
|
||||
$escapedTemporaryFilePath = escapeshellarg($this->routeFilePath($edgeProxyServer, $serviceUuid).'.tmp');
|
||||
|
||||
$this->runRemoteCommands($edgeProxyServer, [
|
||||
"rm -f $escapedFilePath",
|
||||
"rm -f $escapedFilePath $escapedTemporaryFilePath",
|
||||
], false);
|
||||
}
|
||||
|
||||
|
|
@ -389,6 +415,21 @@ class EdgeProxyRemoteRouteService
|
|||
->all();
|
||||
}
|
||||
|
||||
private function detectUnsupportedDomainProtocol(string $domain): ?string
|
||||
{
|
||||
$trimmedDomain = trim($domain);
|
||||
if ($trimmedDomain === '' || ! preg_match('/^[a-z][a-z0-9+\-.]*:\/\//i', $trimmedDomain)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$protocol = strtolower((string) parse_url($trimmedDomain, PHP_URL_SCHEME));
|
||||
if ($protocol === '' || in_array($protocol, ['http', 'https'], true)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $protocol;
|
||||
}
|
||||
|
||||
private function parseDomainUrl(string $domain): ?Url
|
||||
{
|
||||
$normalizedDomain = trim($domain);
|
||||
|
|
@ -414,7 +455,7 @@ class EdgeProxyRemoteRouteService
|
|||
|
||||
private function resolvePublishedPort(array $compose, string $serviceName, ?int $requestedInternalPort, array $environmentMap): ?int
|
||||
{
|
||||
$ports = data_get($compose, "services.$serviceName.ports", []);
|
||||
$ports = $this->resolveComposeServicePorts($compose, $serviceName);
|
||||
if (! is_array($ports)) {
|
||||
return null;
|
||||
}
|
||||
|
|
@ -446,12 +487,42 @@ class EdgeProxyRemoteRouteService
|
|||
return null;
|
||||
}
|
||||
|
||||
private function resolveComposeServicePorts(array $compose, string $serviceName): ?array
|
||||
{
|
||||
$services = data_get($compose, 'services', []);
|
||||
if (! is_array($services) || empty($services)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (array_key_exists($serviceName, $services)) {
|
||||
$ports = data_get($services[$serviceName], 'ports');
|
||||
|
||||
return is_array($ports) ? $ports : null;
|
||||
}
|
||||
|
||||
// Defensive fallback for templates where application name does not match compose key.
|
||||
$servicesWithPorts = collect($services)
|
||||
->filter(fn (mixed $serviceConfig) => is_array($serviceConfig) && is_array(data_get($serviceConfig, 'ports')) && ! empty(data_get($serviceConfig, 'ports')))
|
||||
->values();
|
||||
|
||||
if ($servicesWithPorts->count() === 1) {
|
||||
return data_get($servicesWithPorts->first(), 'ports');
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function parsePortMappings(array $ports, array $environmentMap): Collection
|
||||
{
|
||||
$mappings = collect();
|
||||
|
||||
foreach ($ports as $portDefinition) {
|
||||
if (is_array($portDefinition)) {
|
||||
$protocol = strtolower((string) data_get($portDefinition, 'protocol', 'tcp'));
|
||||
if ($protocol === 'udp') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$target = $this->resolvePortValue(data_get($portDefinition, 'target'), $environmentMap);
|
||||
$published = $this->resolvePortValue(data_get($portDefinition, 'published'), $environmentMap);
|
||||
|
||||
|
|
@ -483,7 +554,13 @@ class EdgeProxyRemoteRouteService
|
|||
return null;
|
||||
}
|
||||
|
||||
$normalizedPortDefinition = preg_replace('/\/(tcp|udp)$/i', '', $normalizedPortDefinition) ?? $normalizedPortDefinition;
|
||||
if (preg_match('/\/(tcp|udp)$/i', $normalizedPortDefinition, $protocolMatches)) {
|
||||
if (strtolower($protocolMatches[1]) === 'udp') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$normalizedPortDefinition = preg_replace('/\/(tcp|udp)$/i', '', $normalizedPortDefinition) ?? $normalizedPortDefinition;
|
||||
}
|
||||
|
||||
if (str_contains($normalizedPortDefinition, ':')) {
|
||||
$segments = explode(':', $normalizedPortDefinition);
|
||||
|
|
|
|||
|
|
@ -4,6 +4,24 @@ use App\Models\Server;
|
|||
use App\Models\Service;
|
||||
use App\Models\ServiceApplication;
|
||||
use App\Services\EdgeProxyRemoteRouteService;
|
||||
use Illuminate\Container\Container;
|
||||
use Psr\Log\NullLogger;
|
||||
|
||||
$originalLogger = null;
|
||||
|
||||
beforeEach(function () use (&$originalLogger) {
|
||||
$container = Container::getInstance();
|
||||
$originalLogger = $container->bound('log') ? $container->make('log') : null;
|
||||
$container->instance('log', new NullLogger);
|
||||
});
|
||||
|
||||
afterEach(function () use (&$originalLogger) {
|
||||
if (is_null($originalLogger)) {
|
||||
return;
|
||||
}
|
||||
|
||||
Container::getInstance()->instance('log', $originalLogger);
|
||||
});
|
||||
|
||||
it('generates edge traefik config for a remote domain route', function () {
|
||||
$service = new EdgeProxyRemoteRouteService;
|
||||
|
|
@ -72,10 +90,13 @@ YAML;
|
|||
->and($manager->calls)->toHaveCount(1);
|
||||
|
||||
$expectedPath = '/tmp/proxy/dynamic/service-remote-service-test-uuid.yaml';
|
||||
$expectedTempPath = '/tmp/proxy/dynamic/service-remote-service-test-uuid.yaml.tmp';
|
||||
$firstWriteCommands = implode("\n", $manager->calls[0]['commands']);
|
||||
|
||||
expect($firstWriteCommands)->toContain($expectedPath)
|
||||
->and($firstWriteCommands)->toContain('tee');
|
||||
->and($firstWriteCommands)->toContain($expectedTempPath)
|
||||
->and($firstWriteCommands)->toContain('tee')
|
||||
->and($firstWriteCommands)->toContain('mv');
|
||||
|
||||
preg_match("/echo '([^']+)' \\| base64 -d/", $manager->calls[0]['commands'][1], $firstPayloadMatches);
|
||||
$firstPayload = base64_decode($firstPayloadMatches[1]);
|
||||
|
|
@ -95,7 +116,9 @@ YAML;
|
|||
|
||||
$secondWriteCommands = implode("\n", $manager->calls[1]['commands']);
|
||||
expect($secondWriteCommands)->toContain($expectedPath)
|
||||
->and($secondWriteCommands)->toContain('tee');
|
||||
->and($secondWriteCommands)->toContain($expectedTempPath)
|
||||
->and($secondWriteCommands)->toContain('tee')
|
||||
->and($secondWriteCommands)->toContain('mv');
|
||||
|
||||
preg_match("/echo '([^']+)' \\| base64 -d/", $manager->calls[1]['commands'][1], $secondPayloadMatches);
|
||||
$secondPayload = base64_decode($secondPayloadMatches[1]);
|
||||
|
|
@ -105,7 +128,7 @@ YAML;
|
|||
|
||||
expect($manager->calls)->toHaveCount(3);
|
||||
$deleteCommands = implode("\n", $manager->calls[2]['commands']);
|
||||
expect($deleteCommands)->toContain("rm -f '$expectedPath'");
|
||||
expect($deleteCommands)->toContain("rm -f '$expectedPath' '$expectedTempPath'");
|
||||
});
|
||||
|
||||
it('does not generate edge route file when published port cannot be resolved and returns actionable warning', function () {
|
||||
|
|
@ -372,3 +395,220 @@ YAML;
|
|||
expect($payload)->toContain('http://10.8.0.20:9010')
|
||||
->and($payload)->not->toContain('9443');
|
||||
});
|
||||
|
||||
it('resolves published port when application name differs but compose has a single service with ports', function () {
|
||||
$manager = new class extends EdgeProxyRemoteRouteService
|
||||
{
|
||||
public array $calls = [];
|
||||
|
||||
protected function runRemoteCommands(Server $server, array $commands, bool $throwError = true): ?string
|
||||
{
|
||||
$this->calls[] = [
|
||||
'commands' => $commands,
|
||||
'throw_error' => $throwError,
|
||||
];
|
||||
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
$edgeProxyServer = Mockery::mock(Server::class)->makePartial();
|
||||
$edgeProxyServer->id = 0;
|
||||
$edgeProxyServer->shouldReceive('proxyType')->andReturn('TRAEFIK');
|
||||
$edgeProxyServer->shouldReceive('proxyPath')->andReturn('/tmp/proxy');
|
||||
|
||||
$deploymentServer = Mockery::mock(Server::class)->makePartial();
|
||||
$deploymentServer->id = 16;
|
||||
$deploymentServer->ip = '10.8.0.21';
|
||||
$deploymentServer->proxy = ['type' => 'NONE'];
|
||||
|
||||
$service = new Service;
|
||||
$service->uuid = 'service-single-compose-fallback';
|
||||
$service->docker_compose_raw = <<<'YAML'
|
||||
services:
|
||||
app:
|
||||
ports:
|
||||
- "9030:3000"
|
||||
YAML;
|
||||
|
||||
$application = new ServiceApplication;
|
||||
$application->name = 'web';
|
||||
$application->fqdn = 'https://single-fallback.example.com:3000';
|
||||
|
||||
$service->setRelation('applications', collect([$application]));
|
||||
$application->setRelation('service', $service);
|
||||
|
||||
$warnings = $manager->syncServiceWithServers($service, $edgeProxyServer, $deploymentServer);
|
||||
expect($warnings)->toBe([]);
|
||||
|
||||
preg_match("/echo '([^']+)' \\| base64 -d/", $manager->calls[0]['commands'][1], $payloadMatches);
|
||||
$payload = base64_decode($payloadMatches[1]);
|
||||
|
||||
expect($payload)->toContain('http://10.8.0.21:9030');
|
||||
});
|
||||
|
||||
it('ignores udp published ports when resolving upstream for edge routes', function () {
|
||||
$manager = new class extends EdgeProxyRemoteRouteService
|
||||
{
|
||||
public array $calls = [];
|
||||
|
||||
protected function runRemoteCommands(Server $server, array $commands, bool $throwError = true): ?string
|
||||
{
|
||||
$this->calls[] = [
|
||||
'commands' => $commands,
|
||||
'throw_error' => $throwError,
|
||||
];
|
||||
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
$edgeProxyServer = Mockery::mock(Server::class)->makePartial();
|
||||
$edgeProxyServer->id = 0;
|
||||
$edgeProxyServer->shouldReceive('proxyType')->andReturn('TRAEFIK');
|
||||
$edgeProxyServer->shouldReceive('proxyPath')->andReturn('/tmp/proxy');
|
||||
|
||||
$deploymentServer = Mockery::mock(Server::class)->makePartial();
|
||||
$deploymentServer->id = 17;
|
||||
$deploymentServer->ip = '10.8.0.22';
|
||||
$deploymentServer->proxy = ['type' => 'NONE'];
|
||||
|
||||
$service = new Service;
|
||||
$service->uuid = 'service-udp-filtering';
|
||||
$service->docker_compose_raw = <<<'YAML'
|
||||
services:
|
||||
app:
|
||||
ports:
|
||||
- "9010:3000/udp"
|
||||
- "9020:3000/tcp"
|
||||
YAML;
|
||||
|
||||
$application = new ServiceApplication;
|
||||
$application->name = 'app';
|
||||
$application->fqdn = 'https://udp-filtering.example.com:3000';
|
||||
|
||||
$service->setRelation('applications', collect([$application]));
|
||||
$application->setRelation('service', $service);
|
||||
|
||||
$warnings = $manager->syncServiceWithServers($service, $edgeProxyServer, $deploymentServer);
|
||||
expect($warnings)->toBe([]);
|
||||
|
||||
preg_match("/echo '([^']+)' \\| base64 -d/", $manager->calls[0]['commands'][1], $payloadMatches);
|
||||
$payload = base64_decode($payloadMatches[1]);
|
||||
|
||||
expect($payload)->toContain('http://10.8.0.22:9020')
|
||||
->and($payload)->not->toContain('http://10.8.0.22:9010');
|
||||
});
|
||||
|
||||
it('returns warning for invalid domains while keeping valid remote edge routes', function () {
|
||||
$manager = new class extends EdgeProxyRemoteRouteService
|
||||
{
|
||||
public array $calls = [];
|
||||
|
||||
protected function runRemoteCommands(Server $server, array $commands, bool $throwError = true): ?string
|
||||
{
|
||||
$this->calls[] = [
|
||||
'commands' => $commands,
|
||||
'throw_error' => $throwError,
|
||||
];
|
||||
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
$edgeProxyServer = Mockery::mock(Server::class)->makePartial();
|
||||
$edgeProxyServer->id = 0;
|
||||
$edgeProxyServer->shouldReceive('proxyType')->andReturn('TRAEFIK');
|
||||
$edgeProxyServer->shouldReceive('proxyPath')->andReturn('/tmp/proxy');
|
||||
|
||||
$deploymentServer = Mockery::mock(Server::class)->makePartial();
|
||||
$deploymentServer->id = 18;
|
||||
$deploymentServer->ip = '10.8.0.23';
|
||||
$deploymentServer->proxy = ['type' => 'NONE'];
|
||||
|
||||
$service = new Service;
|
||||
$service->uuid = 'service-invalid-domain-warning';
|
||||
$service->docker_compose_raw = <<<'YAML'
|
||||
services:
|
||||
app:
|
||||
ports:
|
||||
- "9040:3000"
|
||||
YAML;
|
||||
|
||||
$application = new ServiceApplication;
|
||||
$application->name = 'app';
|
||||
$application->fqdn = 'https://valid.example.com:3000,https://:3000';
|
||||
|
||||
$service->setRelation('applications', collect([$application]));
|
||||
$application->setRelation('service', $service);
|
||||
|
||||
$warnings = $manager->syncServiceWithServers($service, $edgeProxyServer, $deploymentServer);
|
||||
|
||||
expect($warnings)->not->toBeEmpty()
|
||||
->and(collect($warnings)->contains(fn (string $warning) => str_contains($warning, 'domain format is invalid')))
|
||||
->and($manager->calls)->toHaveCount(1);
|
||||
|
||||
preg_match("/echo '([^']+)' \\| base64 -d/", $manager->calls[0]['commands'][1], $payloadMatches);
|
||||
$payload = base64_decode($payloadMatches[1]);
|
||||
|
||||
expect($payload)->toContain('Host(`valid.example.com`)')
|
||||
->and($payload)->toContain('http://10.8.0.23:9040')
|
||||
->and($payload)->not->toContain('https://:3000');
|
||||
});
|
||||
|
||||
it('returns warning for unsupported domain protocols while keeping valid http routes', function () {
|
||||
$manager = new class extends EdgeProxyRemoteRouteService
|
||||
{
|
||||
public array $calls = [];
|
||||
|
||||
protected function runRemoteCommands(Server $server, array $commands, bool $throwError = true): ?string
|
||||
{
|
||||
$this->calls[] = [
|
||||
'commands' => $commands,
|
||||
'throw_error' => $throwError,
|
||||
];
|
||||
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
$edgeProxyServer = Mockery::mock(Server::class)->makePartial();
|
||||
$edgeProxyServer->id = 0;
|
||||
$edgeProxyServer->shouldReceive('proxyType')->andReturn('TRAEFIK');
|
||||
$edgeProxyServer->shouldReceive('proxyPath')->andReturn('/tmp/proxy');
|
||||
|
||||
$deploymentServer = Mockery::mock(Server::class)->makePartial();
|
||||
$deploymentServer->id = 19;
|
||||
$deploymentServer->ip = '10.8.0.24';
|
||||
$deploymentServer->proxy = ['type' => 'NONE'];
|
||||
|
||||
$service = new Service;
|
||||
$service->uuid = 'service-unsupported-protocol-warning';
|
||||
$service->docker_compose_raw = <<<'YAML'
|
||||
services:
|
||||
app:
|
||||
ports:
|
||||
- "9050:3000"
|
||||
- "25565:25565"
|
||||
YAML;
|
||||
|
||||
$application = new ServiceApplication;
|
||||
$application->name = 'app';
|
||||
$application->fqdn = 'https://valid-http.example.com:3000,tcp://minecraft.example.com:25565';
|
||||
|
||||
$service->setRelation('applications', collect([$application]));
|
||||
$application->setRelation('service', $service);
|
||||
|
||||
$warnings = $manager->syncServiceWithServers($service, $edgeProxyServer, $deploymentServer);
|
||||
|
||||
expect($warnings)->not->toBeEmpty()
|
||||
->and(collect($warnings)->contains(fn (string $warning) => str_contains($warning, 'protocol "tcp" is not supported for edge remote routing')))
|
||||
->and($manager->calls)->toHaveCount(1);
|
||||
|
||||
preg_match("/echo '([^']+)' \\| base64 -d/", $manager->calls[0]['commands'][1], $payloadMatches);
|
||||
$payload = base64_decode($payloadMatches[1]);
|
||||
|
||||
expect($payload)->toContain('Host(`valid-http.example.com`)')
|
||||
->and($payload)->toContain('http://10.8.0.24:9050')
|
||||
->and($payload)->not->toContain('minecraft.example.com');
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue