$user, 'team' => $team, 'source' => $source, 'target' => $target, 'connection' => $connection] = $context; $connection->rules()->create([ 'source_resource_type' => $source->getMorphClass(), 'source_resource_id' => $source->id, 'target_resource_type' => $target->getMorphClass(), 'target_resource_id' => $target->id, 'protocol' => 'tcp', 'port' => 5432, ]); $oldRuleId = "v5-resource-connection:{$connection->id}:{$source->id}:{$target->id}:tcp:5432"; $newRuleId = "v5-resource-connection:{$connection->id}:{$source->id}:{$target->id}:tcp:6379"; $oldRule = connectionFirewallConsistencyRule($oldRuleId, 5432); $newRule = connectionFirewallConsistencyRule($newRuleId, 6379); $fluxClient = Mockery::mock(FluxClient::class); // Forward sync: revoke the removed old rule, then fail applying the new one. $fluxClient->shouldReceive('revokeFirewallRule')->once()->with(Mockery::type('string'), $oldRuleId)->andReturn('Firewall rule removed.'); $fluxClient->shouldReceive('applyFirewallRule')->once()->with(Mockery::type('string'), $newRule)->andThrow(new RuntimeException('apply firewall rule: nft exited with status 1')); // Compensation: revoke the attempted new rule and re-apply the restored old rule. $fluxClient->shouldReceive('revokeFirewallRule')->once()->with(Mockery::type('string'), $newRuleId)->andReturn('Firewall rule removed.'); $fluxClient->shouldReceive('applyFirewallRule')->once()->with(Mockery::type('string'), $oldRule)->andReturn('Firewall rule applied.'); app()->instance(FluxClient::class, $fluxClient); patchConnectionFirewallConsistency($this, $user, $team, $connection, [ "{$source->uuid}->{$target->uuid}" => [6379], ]) ->assertStatus(502) ->assertJsonPath('message', 'Could not sync firewall rules through Flux.') ->assertJsonPath('detail', 'The previous rules were restored. Check the server diagnostics and try again.'); expect($connection->rules()->pluck('port')->all())->toBe([5432]); Exceptions::assertReported(fn (RuntimeException $exception): bool => $exception->getMessage() === 'apply firewall rule: nft exited with status 1'); }); it('keeps committed db rules and succeeds when the node coold lacks firewall support', function () { $context = createConnectionFirewallConsistencyContext(); ['user' => $user, 'team' => $team, 'source' => $source, 'target' => $target, 'connection' => $connection] = $context; $connection->rules()->create([ 'source_resource_type' => $source->getMorphClass(), 'source_resource_id' => $source->id, 'target_resource_type' => $target->getMorphClass(), 'target_resource_id' => $target->id, 'protocol' => 'tcp', 'port' => 5432, ]); $fluxClient = Mockery::mock(FluxClient::class); $fluxClient->shouldReceive('revokeFirewallRule')->once()->andThrow(new UnsupportedCooldVerb('firewall.revoke', 'primitive firewall.revoke is not supported by host')); $fluxClient->shouldReceive('applyFirewallRule')->once()->andThrow(new UnsupportedCooldVerb('firewall.allow', 'primitive firewall.allow is not supported by host')); app()->instance(FluxClient::class, $fluxClient); patchConnectionFirewallConsistency($this, $user, $team, $connection, [ "{$source->uuid}->{$target->uuid}" => [6379], ]) ->assertSuccessful() ->assertJsonPath("connection.portsByDirection.{$source->uuid}->{$target->uuid}.0", '6379'); expect($connection->rules()->pluck('port')->all())->toBe([6379]); }); it('still surfaces the original flux error when the firewall rollback also fails', function () { Exceptions::fake(); Log::spy(); $context = createConnectionFirewallConsistencyContext(); ['user' => $user, 'team' => $team, 'source' => $source, 'target' => $target, 'connection' => $connection] = $context; $connection->rules()->create([ 'source_resource_type' => $source->getMorphClass(), 'source_resource_id' => $source->id, 'target_resource_type' => $target->getMorphClass(), 'target_resource_id' => $target->id, 'protocol' => 'tcp', 'port' => 5432, ]); $oldRuleId = "v5-resource-connection:{$connection->id}:{$source->id}:{$target->id}:tcp:5432"; $newRuleId = "v5-resource-connection:{$connection->id}:{$source->id}:{$target->id}:tcp:6379"; $newRule = connectionFirewallConsistencyRule($newRuleId, 6379); $fluxClient = Mockery::mock(FluxClient::class); $fluxClient->shouldReceive('revokeFirewallRule')->once()->with(Mockery::type('string'), $oldRuleId)->andReturn('Firewall rule removed.'); $fluxClient->shouldReceive('applyFirewallRule')->once()->with(Mockery::type('string'), $newRule)->andThrow(new RuntimeException('apply firewall rule: nft exited with status 1')); $fluxClient->shouldReceive('revokeFirewallRule')->once()->with(Mockery::type('string'), $newRuleId)->andThrow(new RuntimeException('revoke firewall rule: coold connection reset')); app()->instance(FluxClient::class, $fluxClient); patchConnectionFirewallConsistency($this, $user, $team, $connection, [ "{$source->uuid}->{$target->uuid}" => [6379], ]) ->assertStatus(502) ->assertJsonPath('detail', 'The previous rules were restored. Check the server diagnostics and try again.'); expect($connection->rules()->pluck('port')->all())->toBe([5432]); Log::shouldHaveReceived('warning') ->withArgs(fn (string $message): bool => str_contains($message, 'firewall rollback failed')) ->once(); }); it('does not delete the connection when revoking firewall rules fails with a real error', function () { Exceptions::fake(); $context = createConnectionFirewallConsistencyContext(); ['user' => $user, 'team' => $team, 'source' => $source, 'target' => $target, 'connection' => $connection] = $context; $connection->rules()->create([ 'source_resource_type' => $source->getMorphClass(), 'source_resource_id' => $source->id, 'target_resource_type' => $target->getMorphClass(), 'target_resource_id' => $target->id, 'protocol' => 'tcp', 'port' => 5432, ]); $fluxClient = Mockery::mock(FluxClient::class); $fluxClient->shouldReceive('revokeFirewallRule')->once()->andThrow(new RuntimeException('revoke firewall rule: nft exited with status 1')); app()->instance(FluxClient::class, $fluxClient); deleteConnectionFirewallConsistency($this, $user, $team, $connection) ->assertStatus(502) ->assertJsonPath('detail', 'The connection was not deleted. Check the server diagnostics and try again.'); expect(ResourceConnection::query()->whereKey($connection->id)->exists())->toBeTrue() ->and($connection->rules()->count())->toBe(1); Exceptions::assertReported(fn (RuntimeException $exception): bool => $exception->getMessage() === 'revoke firewall rule: nft exited with status 1'); }); it('reports but tolerates an unbuildable firewall snapshot when deleting the connection', function () { Exceptions::fake(); $context = createConnectionFirewallConsistencyContext(sourceServerless: true); ['user' => $user, 'team' => $team, 'source' => $source, 'target' => $target, 'connection' => $connection] = $context; $connection->rules()->create([ 'source_resource_type' => $source->getMorphClass(), 'source_resource_id' => $source->id, 'target_resource_type' => $target->getMorphClass(), 'target_resource_id' => $target->id, 'protocol' => 'tcp', 'port' => 5432, ]); // The node cannot be addressed without a host id, so no flux call may happen. $fluxClient = Mockery::mock(FluxClient::class); app()->instance(FluxClient::class, $fluxClient); deleteConnectionFirewallConsistency($this, $user, $team, $connection) ->assertNoContent(); expect(ResourceConnection::query()->whereKey($connection->id)->exists())->toBeFalse(); Exceptions::assertReported(fn (RuntimeException $exception): bool => str_contains($exception->getMessage(), 'has no reachable server host id')); }); it('deletes the connection when the node coold lacks firewall revoke support', function () { $context = createConnectionFirewallConsistencyContext(); ['user' => $user, 'team' => $team, 'source' => $source, 'target' => $target, 'connection' => $connection] = $context; $connection->rules()->create([ 'source_resource_type' => $source->getMorphClass(), 'source_resource_id' => $source->id, 'target_resource_type' => $target->getMorphClass(), 'target_resource_id' => $target->id, 'protocol' => 'tcp', 'port' => 5432, ]); $fluxClient = Mockery::mock(FluxClient::class); $fluxClient->shouldReceive('revokeFirewallRule')->once()->andThrow(new UnsupportedCooldVerb('firewall.revoke', 'primitive firewall.revoke is not supported by host')); app()->instance(FluxClient::class, $fluxClient); deleteConnectionFirewallConsistency($this, $user, $team, $connection) ->assertNoContent(); expect(ResourceConnection::query()->whereKey($connection->id)->exists())->toBeFalse(); }); /** * @param array> $portsByDirection */ function patchConnectionFirewallConsistency(mixed $test, User $user, Team $team, ResourceConnection $connection, array $portsByDirection): mixed { return $test ->actingAs($user) ->withSession(['currentTeam' => $team, '_token' => 'test-csrf-token']) ->withHeader('X-CSRF-TOKEN', 'test-csrf-token') ->patchJson("/v5/resource-connections/{$connection->uuid}", [ 'ports_by_direction' => $portsByDirection, ]); } function deleteConnectionFirewallConsistency(mixed $test, User $user, Team $team, ResourceConnection $connection): mixed { return $test ->actingAs($user) ->withSession(['currentTeam' => $team, '_token' => 'test-csrf-token']) ->withHeader('X-CSRF-TOKEN', 'test-csrf-token') ->deleteJson("/v5/resource-connections/{$connection->uuid}"); } /** * @return array{id: string, namespace: string, src: string, dst: string, proto: string, port: int} */ function connectionFirewallConsistencyRule(string $ruleId, int $port): array { return [ 'id' => $ruleId, 'namespace' => 'default', 'src' => 'coolify-v5-consistency-api', 'dst' => 'coolify-v5-consistency-postgres', 'proto' => 'tcp', 'port' => $port, ]; } /** * @return array{user: User, team: Team, source: V5Application, target: V5Application, connection: ResourceConnection} */ function createConnectionFirewallConsistencyContext(bool $sourceServerless = false): array { $user = User::withoutEvents(fn () => User::query()->create([ 'name' => 'Margaret Hamilton', 'email' => 'margaret@example.com', 'email_verified_at' => now(), 'password' => 'password', ])); $team = Team::withoutEvents(fn () => Team::query()->create([ 'name' => 'V5 Firewall Team', 'description' => null, 'personal_team' => false, 'show_boarding' => false, ])); $user->teams()->attach($team, ['role' => 'owner']); $server = V5Server::query()->create([ 'team_id' => $team->id, 'created_by_user_id' => $user->id, 'name' => 'edge-01', 'host' => '203.0.113.10', 'ssh_user' => 'root', 'ssh_port' => 22, 'status' => 'installed', 'wireguard_management_ip' => '100.64.0.10', ]); $source = V5Application::query()->create([ 'team_id' => $team->id, 'project_id' => 1, 'environment_id' => 1, 'server_id' => $sourceServerless ? null : $server->id, 'created_by_user_id' => $user->id, 'name' => 'api', 'image' => 'docker.io/library/nginx:alpine', 'container_name' => 'coolify-v5-consistency-api', 'mesh_namespace' => 'default', 'status' => 'running', ]); $target = V5Application::query()->create([ 'team_id' => $team->id, 'project_id' => 1, 'environment_id' => 1, 'server_id' => $server->id, 'created_by_user_id' => $user->id, 'name' => 'postgres', 'image' => 'docker.io/library/postgres:16', 'container_name' => 'coolify-v5-consistency-postgres', 'mesh_namespace' => 'default', 'status' => 'running', ]); $connection = ResourceConnection::query()->create([ 'team_id' => $team->id, 'project_id' => 1, 'environment_id' => 1, 'resource_one_type' => $source->getMorphClass(), 'resource_one_id' => $source->id, 'resource_two_type' => $target->getMorphClass(), 'resource_two_id' => $target->id, 'resource_pair_key' => "application:{$source->id}|application:{$target->id}", 'created_by_user_id' => $user->id, ]); return [ 'user' => $user, 'team' => $team, 'source' => $source, 'target' => $target, 'connection' => $connection, ]; } function createConnectionFirewallConsistencyTables(): void { Schema::create('users', function ($table) { $table->id(); $table->string('name')->default('Anonymous'); $table->string('email'); $table->timestamp('email_verified_at')->nullable(); $table->string('password')->nullable(); $table->rememberToken(); $table->timestamps(); }); Schema::create('teams', function ($table) { $table->id(); $table->string('name'); $table->string('description')->nullable(); $table->boolean('personal_team')->default(false); $table->boolean('show_boarding')->default(false); $table->timestamps(); }); Schema::create('team_user', function ($table) { $table->id(); $table->foreignId('team_id'); $table->foreignId('user_id'); $table->string('role')->default('member'); $table->timestamps(); $table->unique(['team_id', 'user_id']); }); V5TestSchema::createServersTable(); V5TestSchema::createApplicationsTable(); V5TestSchema::createResourceConnectionsTable(); V5TestSchema::createResourceConnectionRulesTable(); } function dropConnectionFirewallConsistencyTables(): void { Schema::dropIfExists('v5_resource_connection_rules'); Schema::dropIfExists('v5_resource_connections'); Schema::dropIfExists('v5_applications'); Schema::dropIfExists('v5_servers'); Schema::dropIfExists('team_user'); Schema::dropIfExists('teams'); Schema::dropIfExists('users'); }