markTestSkipped('pcntl is required to fake a Flux Unix socket.'); } // Flux rejects verbs the node's coold did not advertise with HTTP 501 and // "primitive is not supported by host" (coold repo: // flux/src/routing.rs:50-53, flux/src/unix_bridge.rs:227-245). $body = json_encode([ 'request_id' => 'test-request', 'status' => 'error', 'code' => 501, 'message' => 'primitive firewall.allow is not supported by host', ], JSON_THROW_ON_ERROR); $caught = null; withCooldVerbContractFluxSocket( "HTTP/1.1 501 Not Implemented\r\n". "Content-Type: application/json\r\n". 'Content-Length: '.strlen($body)."\r\n". "\r\n". $body, function () use (&$caught): void { try { (new FluxClient)->applyFirewallRule('100.64.0.10', [ 'id' => 'rule-1', 'namespace' => 'default', 'src' => '0.0.0.0/0', 'dst' => 'coolify-v5-caddy', 'proto' => 'tcp', 'port' => 80, ]); } catch (RuntimeException $exception) { $caught = $exception; } } ); expect($caught)->toBeInstanceOf(UnsupportedCooldVerb::class) ->and($caught->verb)->toBe('firewall.allow') ->and($caught->getMessage())->toBe('primitive firewall.allow is not supported by host'); }); it('detects unsupported coold verbs from the flux message even without a 501 status', function () { if (! function_exists('pcntl_fork')) { $this->markTestSkipped('pcntl is required to fake a Flux Unix socket.'); } $body = json_encode([ 'request_id' => 'test-request', 'status' => 'error', 'code' => 502, 'message' => 'primitive coold.logs is not supported by host', ], JSON_THROW_ON_ERROR); $caught = null; withCooldVerbContractFluxSocket( "HTTP/1.1 502 Bad Gateway\r\n". "Content-Type: application/json\r\n". 'Content-Length: '.strlen($body)."\r\n". "\r\n". $body, function () use (&$caught): void { try { (new FluxClient)->cooldLogs('100.64.0.10'); } catch (RuntimeException $exception) { $caught = $exception; } } ); expect($caught)->toBeInstanceOf(UnsupportedCooldVerb::class) ->and($caught->verb)->toBe('coold.logs'); }); it('keeps generic flux dispatch failures as plain runtime exceptions', function () { if (! function_exists('pcntl_fork')) { $this->markTestSkipped('pcntl is required to fake a Flux Unix socket.'); } $body = json_encode([ 'request_id' => 'test-request', 'status' => 'error', 'code' => 500, 'message' => 'apply firewall rule: nft exited with status 1', ], JSON_THROW_ON_ERROR); $caught = null; withCooldVerbContractFluxSocket( "HTTP/1.1 500 Internal Server Error\r\n". "Content-Type: application/json\r\n". 'Content-Length: '.strlen($body)."\r\n". "\r\n". $body, function () use (&$caught): void { try { (new FluxClient)->applyFirewallRule('100.64.0.10', [ 'id' => 'rule-1', 'namespace' => 'default', 'src' => '0.0.0.0/0', 'dst' => 'coolify-v5-caddy', 'proto' => 'tcp', 'port' => 80, ]); } catch (RuntimeException $exception) { $caught = $exception; } } ); expect($caught)->toBeInstanceOf(RuntimeException::class) ->not->toBeInstanceOf(UnsupportedCooldVerb::class) ->and($caught->getMessage())->toBe('apply firewall rule: nft exited with status 1'); }); it('marks caddy ingress running with a warning when the node coold lacks firewall support', function () { Event::fake(); Schema::dropIfExists('v5_applications'); Schema::dropIfExists('v5_servers'); V5TestSchema::createServersTable(); V5TestSchema::createApplicationsTable(); $server = Server::query()->create([ 'uuid' => 'cooldverb-ingress-server-uuid', 'team_id' => 1, 'created_by_user_id' => 1, 'name' => 'ingress-01', 'host' => '203.0.113.10', 'ssh_user' => 'root', 'ssh_port' => 22, 'capabilities' => ['ingress'], 'uuid' => 'test-server-uuid', 'wireguard_management_ip' => '100.64.0.10', ]); $fluxClient = Mockery::mock(FluxClient::class); $fluxClient ->shouldReceive('applyIngress') ->once() ->andReturn('Caddy ingress applied.'); $fluxClient ->shouldReceive('applyFirewallRule') ->once() ->andThrow(new UnsupportedCooldVerb('firewall.allow', 'primitive firewall.allow is not supported by host')); app()->instance(FluxClient::class, $fluxClient); $result = StartCaddyIngress::run($server); $server->refresh(); expect($result)->toBe('Caddy ingress applied.') ->and($server->ingress_type)->toBe('caddy') ->and($server->ingress_status)->toBe('running') ->and($server->last_status_check)->toBe('flux') ->and($server->last_status_output)->toContain('does not support firewall.allow'); Schema::dropIfExists('v5_applications'); Schema::dropIfExists('v5_servers'); }); it('still surfaces supported-but-failed firewall errors when starting caddy ingress', function () { $server = new Server([ 'uuid' => 'test-server-uuid', 'wireguard_management_ip' => '100.64.0.10', 'capabilities' => ['ingress'], ]); $fluxClient = Mockery::mock(FluxClient::class); $fluxClient ->shouldReceive('applyIngress') ->once() ->andReturn('Caddy ingress applied.'); $fluxClient ->shouldReceive('applyFirewallRule') ->once() ->andThrow(new RuntimeException('apply firewall rule: nft exited with status 1')); app()->instance(FluxClient::class, $fluxClient); expect(fn () => StartCaddyIngress::run($server)) ->toThrow(RuntimeException::class, 'apply firewall rule: nft exited with status 1'); }); it('stops caddy ingress even when the node coold lacks firewall support', function () { $server = new Server([ 'uuid' => 'test-server-uuid', 'wireguard_management_ip' => '100.64.0.10', 'capabilities' => ['ingress'], ]); $fluxClient = Mockery::mock(FluxClient::class); $fluxClient ->shouldReceive('revokeFirewallRule') ->once() ->andThrow(new UnsupportedCooldVerb('firewall.revoke', 'primitive firewall.revoke is not supported by host')); $fluxClient ->shouldReceive('stopIngress') ->once() ->with(Mockery::type('string'), 'caddy') ->andReturn('Caddy ingress stopped.'); app()->instance(FluxClient::class, $fluxClient); expect(StopCaddyIngress::run($server))->toBe('Caddy ingress stopped.'); }); it('skips resource connection firewall sync rules when the node coold lacks firewall support', function () { $sync = new ConnectionFirewallSync; $oldRule = [ 'id' => 'v5-resource-connection:1:1:2:tcp:5432', 'hostId' => '100.64.0.11', 'rule' => [ 'id' => 'v5-resource-connection:1:1:2:tcp:5432', 'namespace' => 'default', 'src' => 'coolify-v5-api', 'dst' => 'coolify-v5-postgres', 'proto' => 'tcp', 'port' => 5432, ], ]; $newRule = [ 'id' => 'v5-resource-connection:1:1:2:tcp:6379', 'hostId' => '100.64.0.10', 'rule' => [ 'id' => 'v5-resource-connection:1:1:2:tcp:6379', 'namespace' => 'default', 'src' => 'coolify-v5-api', 'dst' => 'coolify-v5-redis', 'proto' => 'tcp', 'port' => 6379, ], ]; $fluxClient = Mockery::mock(FluxClient::class); $fluxClient ->shouldReceive('revokeFirewallRule') ->once() ->with(Mockery::type('string'), $oldRule['id']) ->andThrow(new UnsupportedCooldVerb('firewall.revoke', 'primitive firewall.revoke is not supported by host')); $fluxClient ->shouldReceive('applyFirewallRule') ->once() ->with(Mockery::type('string'), $newRule['rule']) ->andThrow(new UnsupportedCooldVerb('firewall.allow', 'primitive firewall.allow is not supported by host')); $sync->sync($fluxClient, collect([$oldRule]), collect([$newRule])); expect(true)->toBeTrue(); }); function withCooldVerbContractFluxSocket(string $response, Closure $callback): void { $directory = storage_path('framework/testing'); if (! is_dir($directory)) { mkdir($directory, 0777, true); } $socketPath = $directory.'/flux-'.bin2hex(random_bytes(8)).'.sock'; $socketServer = stream_socket_server("unix://{$socketPath}", $errorCode, $errorMessage); expect($socketServer)->not->toBeFalse("Could not create fake Flux socket: {$errorMessage} ({$errorCode})"); $pid = pcntl_fork(); if ($pid === 0) { $connection = stream_socket_accept($socketServer, 5); if ($connection !== false) { $request = ''; while (! str_contains($request, "\r\n\r\n") && ! feof($connection)) { $request .= fread($connection, 8192); } fwrite($connection, $response); fclose($connection); } fclose($socketServer); exit(0); } fclose($socketServer); Config::set('flux.unix_socket_path', $socketPath); Config::set('flux.connection_timeout_seconds', 1.0); Config::set('flux.dispatch_timeout_seconds', 1.0); try { $callback(); } finally { pcntl_waitpid($pid, $status); @unlink($socketPath); } }