diff --git a/app/Actions/Proxy/CheckProxy.php b/app/Actions/Proxy/CheckProxy.php index 47bc002d68..6ac4487082 100644 --- a/app/Actions/Proxy/CheckProxy.php +++ b/app/Actions/Proxy/CheckProxy.php @@ -53,6 +53,17 @@ class CheckProxy 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; + } + $portsToCheck = []; try { @@ -65,17 +76,6 @@ class CheckProxy return false; } - - $status = getContainerStatus($server, $proxyContainerName); - if ($status === 'running') { - $server->proxy->set('status', 'running'); - $server->save(); - - return false; - } - if ($server->settings->is_cloudflare_tunnel) { - return false; - } if (count($portsToCheck) === 0) { return false; } diff --git a/app/Services/ProxyPortParser.php b/app/Services/ProxyPortParser.php index 5d118ed278..efc5a551f5 100644 --- a/app/Services/ProxyPortParser.php +++ b/app/Services/ProxyPortParser.php @@ -3,6 +3,7 @@ namespace App\Services; use Symfony\Component\Yaml\Exception\ParseException; +use Symfony\Component\Yaml\Tag\TaggedValue; use Symfony\Component\Yaml\Yaml; class ProxyPortParser @@ -31,12 +32,14 @@ class ProxyPortParser * a port invalid: a variable with a default is checked as its default, and a port that still * depends on a variable (or whose default is not a valid port) is accepted but not returned. * + * Docker Compose merge tags such as `!reset` and `!override` are accepted; their values are validated. + * * @return list */ public static function fromConfiguration(string $configuration): array { try { - $parsed = Yaml::parse($configuration); + $parsed = self::withoutTags(Yaml::parse($configuration, Yaml::PARSE_CUSTOM_TAGS)); } catch (ParseException $exception) { throw new \InvalidArgumentException('The proxy configuration must contain valid YAML.', previous: $exception); } @@ -70,6 +73,19 @@ class ProxyPortParser return array_values(array_unique($ports)); } + private static function withoutTags(mixed $value): mixed + { + if ($value instanceof TaggedValue) { + return self::withoutTags($value->getValue()); + } + + if (is_array($value)) { + return array_map(self::withoutTags(...), $value); + } + + return $value; + } + private static function publishedPort(mixed $configuredPort): ?int { if (is_string($configuredPort) && self::containsVariable($configuredPort)) { diff --git a/tests/Feature/Proxy/ProxyPortSecurityTest.php b/tests/Feature/Proxy/ProxyPortSecurityTest.php index 88ec25dc9d..4d4c77f6f3 100644 --- a/tests/Feature/Proxy/ProxyPortSecurityTest.php +++ b/tests/Feature/Proxy/ProxyPortSecurityTest.php @@ -4,6 +4,7 @@ use App\Actions\Proxy\CheckProxy; use App\Actions\Proxy\SaveProxyConfiguration; use App\Enums\ProxyTypes; use App\Models\InstanceSettings; +use App\Models\PrivateKey; use App\Models\Server; use App\Models\Team; use Illuminate\Foundation\Testing\RefreshDatabase; @@ -44,7 +45,19 @@ it('does not execute remote commands for a malformed legacy stored port', functi $this->server->save(); expect(CheckProxy::run($this->server))->toBeFalse(); - Process::assertNothingRan(); + Process::assertDidntRun(fn ($process): bool => str_contains((string) $process->command, '$(id)')); +}); + +it('refreshes the status of a running proxy even when its stored configuration is invalid', function () { + Process::fake([ + '*' => Process::result(output: json_encode(['State' => ['Status' => 'running']])), + ]); + $this->server->private_key_id = PrivateKey::factory()->create(['team_id' => $this->server->team_id])->id; + $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->fresh()))->toBeFalse() + ->and($this->server->fresh()->proxy->status)->toBe('running'); }); it('builds port checks only from normalized integer ports', function () { diff --git a/tests/Unit/ProxyPortValidationTest.php b/tests/Unit/ProxyPortValidationTest.php index 2e8b13a4a9..ef2edcadaf 100644 --- a/tests/Unit/ProxyPortValidationTest.php +++ b/tests/Unit/ProxyPortValidationTest.php @@ -113,3 +113,17 @@ it('rejects invalid proxy port collection shapes', function (mixed $ports) { 'null' => [null], 'boolean' => [true], ]); + +it('accepts Docker Compose merge tags in proxy configurations', function (string $configuration, array $expected) { + expect(ProxyPortParser::fromConfiguration($configuration))->toBe($expected); +})->with([ + 'override ports' => ["services:\n traefik:\n ports: !override\n - '80:80'\n - '443:443'\n", [80, 443]], + 'reset ports' => ["services:\n traefik:\n ports: !reset []\n", []], + 'reset labels next to ports' => ["services:\n caddy:\n labels: !reset []\n ports: ['8080:80']\n", [8080]], + 'override a long syntax port' => ["services:\n traefik:\n ports: !override\n - target: 80\n published: !!str 8080\n", [8080]], +]); + +it('still rejects malformed ports inside Docker Compose merge tags', function () { + expect(fn () => ProxyPortParser::fromConfiguration("services:\n traefik:\n ports: !override\n - '\$(id):80'\n")) + ->toThrow(InvalidArgumentException::class); +});