From fe855cf8d0930d2b0e76994b2ed72634ff8240c8 Mon Sep 17 00:00:00 2001 From: Poul Date: Mon, 25 May 2026 11:15:08 +0000 Subject: [PATCH 1/6] feat(api): add REST endpoints for destinations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Destinations section is exposed in the Coolify UI but not via the REST API. The destination_uuid field is required when creating applications via create-public-application, but no way to enumerate or create destinations programmatically existed — this blocks IaC tools (e.g. an Aspire publisher targeting Coolify). Adds, scoped to the existing v1 auth:sanctum + ApiAllowed + api.sensitive group: GET /api/v1/destinations GET /api/v1/destinations/{uuid} DELETE /api/v1/destinations/{uuid} GET /api/v1/servers/{server_uuid}/destinations POST /api/v1/servers/{server_uuid}/destinations The controller uses the existing inline-Validator convention (no Form Request classes per the API surface's house style), reuses StandaloneDocker::ownedByCurrentTeamAPI / SwarmDocker::ownedByCurrentTeamAPI for team scoping (matching ScheduledTasksController etc.), and respects the `attachedTo()` guard on delete. No migrations needed — both standalone_dockers and swarm_dockers tables already carry uuid/name/network/server_id/timestamps. OpenAPI @OA\ annotations omitted in this commit to keep the diff minimal; a follow-up can add them in the style of ServersController. --- .../Api/DestinationsController.php | 105 ++++++++++++++++++ routes/api.php | 8 ++ 2 files changed, 113 insertions(+) create mode 100644 app/Http/Controllers/Api/DestinationsController.php diff --git a/app/Http/Controllers/Api/DestinationsController.php b/app/Http/Controllers/Api/DestinationsController.php new file mode 100644 index 000000000..43fb0cba8 --- /dev/null +++ b/app/Http/Controllers/Api/DestinationsController.php @@ -0,0 +1,105 @@ + $d->id, + 'uuid' => $d->uuid, + 'name' => $d->name, + 'network' => $d->network, + 'type' => $d instanceof SwarmDocker ? 'swarm' : 'standalone', + 'server_uuid' => $d->server?->uuid, + 'created_at' => $d->created_at, + 'updated_at' => $d->updated_at, + ]; + } + + public function index(Request $request) + { + $teamId = auth()->user()->currentTeam()->id; + $standalone = StandaloneDocker::ownedByCurrentTeamAPI($teamId)->get(); + $swarm = SwarmDocker::ownedByCurrentTeamAPI($teamId)->get(); + + return response()->json($standalone->concat($swarm)->map(fn ($d) => $this->transform($d))->values()); + } + + public function index_by_server(Request $request, string $server_uuid) + { + $teamId = auth()->user()->currentTeam()->id; + $server = Server::ownedByCurrentTeamAPI($teamId)->whereUuid($server_uuid)->firstOrFail(); + $list = $server->standaloneDockers->concat($server->swarmDockers); + + return response()->json($list->map(fn ($d) => $this->transform($d))->values()); + } + + public function show(Request $request, string $uuid) + { + $teamId = auth()->user()->currentTeam()->id; + $d = StandaloneDocker::ownedByCurrentTeamAPI($teamId)->whereUuid($uuid)->first() + ?? SwarmDocker::ownedByCurrentTeamAPI($teamId)->whereUuid($uuid)->firstOrFail(); + + return response()->json($this->transform($d)); + } + + public function create(Request $request, string $server_uuid) + { + $teamId = auth()->user()->currentTeam()->id; + $server = Server::ownedByCurrentTeamAPI($teamId)->whereUuid($server_uuid)->firstOrFail(); + + $allowed = ['name', 'network', 'type']; + $extra = array_diff(array_keys($request->all()), $allowed); + if (! empty($extra)) { + return response()->json(['message' => 'Unknown fields', 'fields' => array_values($extra)], 422); + } + + $validator = Validator::make($request->all(), [ + 'name' => 'nullable|string|max:255', + 'network' => ['required', 'string', 'max:255', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/'], + 'type' => 'nullable|in:standalone,swarm', + ]); + if ($validator->fails()) { + return response()->json(['message' => 'Validation failed', 'errors' => $validator->errors()], 422); + } + + $type = $request->input('type', 'standalone'); + $name = $request->input('name') ?: ($server->name.'-'.$request->input('network')); + $class = $type === 'swarm' ? SwarmDocker::class : StandaloneDocker::class; + + $exists = $class::where('server_id', $server->id)->where('network', $request->input('network'))->exists(); + if ($exists) { + return response()->json(['message' => 'A destination with this network already exists on the server.'], 409); + } + + $d = $class::create([ + 'name' => $name, + 'network' => $request->input('network'), + 'server_id' => $server->id, + ]); + + return response()->json(['uuid' => $d->uuid], 201); + } + + public function delete(Request $request, string $uuid) + { + $teamId = auth()->user()->currentTeam()->id; + $d = StandaloneDocker::ownedByCurrentTeamAPI($teamId)->whereUuid($uuid)->first() + ?? SwarmDocker::ownedByCurrentTeamAPI($teamId)->whereUuid($uuid)->firstOrFail(); + if ($d->attachedTo()) { + return response()->json(['message' => 'Destination has attached resources, detach first.'], 409); + } + $d->delete(); + + return response()->json(['message' => 'Deleted.']); + } +} diff --git a/routes/api.php b/routes/api.php index cc380b2be..cd98df9ec 100644 --- a/routes/api.php +++ b/routes/api.php @@ -4,6 +4,7 @@ use App\Http\Controllers\Api\CloudProviderTokensController; use App\Http\Controllers\Api\DatabasesController; use App\Http\Controllers\Api\DeployController; +use App\Http\Controllers\Api\DestinationsController; use App\Http\Controllers\Api\GithubController; use App\Http\Controllers\Api\HetznerController; use App\Http\Controllers\Api\OtherController; @@ -87,6 +88,13 @@ Route::get('/servers/{uuid}/domains', [ServersController::class, 'domains_by_server'])->middleware(['api.ability:read']); Route::get('/servers/{uuid}/resources', [ServersController::class, 'resources_by_server'])->middleware(['api.ability:read']); + // Destinations — REST surface for the Coolify "Destinations" UI section (added). + Route::get('/destinations', [DestinationsController::class, 'index'])->middleware(['api.ability:read']); + Route::get('/destinations/{uuid}', [DestinationsController::class, 'show'])->middleware(['api.ability:read']); + Route::delete('/destinations/{uuid}', [DestinationsController::class, 'delete'])->middleware(['api.ability:write']); + Route::get('/servers/{server_uuid}/destinations', [DestinationsController::class, 'index_by_server'])->middleware(['api.ability:read']); + Route::post('/servers/{server_uuid}/destinations', [DestinationsController::class, 'create'])->middleware(['api.ability:write']); + Route::get('/servers/{uuid}/validate', [ServersController::class, 'validate_server'])->middleware(['api.ability:write']); Route::post('/servers', [ServersController::class, 'create_server'])->middleware(['api.ability:write']); From 789e2c5cab41242659b59e425671a7bc0bf9891e Mon Sep 17 00:00:00 2001 From: Poul Date: Mon, 25 May 2026 11:36:26 +0000 Subject: [PATCH 2/6] fix(api/destinations): use getTeamIdFromToken() like other Api controllers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Initial draft called auth()->user()->currentTeam() which returns null in the API context (Sanctum tokens don't carry the per-user currentTeam state — that's a session/Livewire concept). Other Api controllers (ServersController, ScheduledTasksController, etc.) use the canonical helper getTeamIdFromToken() with a null guard returning 403. This swap makes all five endpoints work against a real token. --- .../Api/DestinationsController.php | 35 ++++++++++++++++--- 1 file changed, 30 insertions(+), 5 deletions(-) diff --git a/app/Http/Controllers/Api/DestinationsController.php b/app/Http/Controllers/Api/DestinationsController.php index 43fb0cba8..cd6c187b6 100644 --- a/app/Http/Controllers/Api/DestinationsController.php +++ b/app/Http/Controllers/Api/DestinationsController.php @@ -25,9 +25,22 @@ private function transform($d): array ]; } + private function teamIdOrAbort(): int|\Illuminate\Http\JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return response()->json(['message' => 'You are not allowed to access the API.'], 403); + } + + return $teamId; + } + public function index(Request $request) { - $teamId = auth()->user()->currentTeam()->id; + $teamId = $this->teamIdOrAbort(); + if (! is_int($teamId)) { + return $teamId; + } $standalone = StandaloneDocker::ownedByCurrentTeamAPI($teamId)->get(); $swarm = SwarmDocker::ownedByCurrentTeamAPI($teamId)->get(); @@ -36,7 +49,10 @@ public function index(Request $request) public function index_by_server(Request $request, string $server_uuid) { - $teamId = auth()->user()->currentTeam()->id; + $teamId = $this->teamIdOrAbort(); + if (! is_int($teamId)) { + return $teamId; + } $server = Server::ownedByCurrentTeamAPI($teamId)->whereUuid($server_uuid)->firstOrFail(); $list = $server->standaloneDockers->concat($server->swarmDockers); @@ -45,7 +61,10 @@ public function index_by_server(Request $request, string $server_uuid) public function show(Request $request, string $uuid) { - $teamId = auth()->user()->currentTeam()->id; + $teamId = $this->teamIdOrAbort(); + if (! is_int($teamId)) { + return $teamId; + } $d = StandaloneDocker::ownedByCurrentTeamAPI($teamId)->whereUuid($uuid)->first() ?? SwarmDocker::ownedByCurrentTeamAPI($teamId)->whereUuid($uuid)->firstOrFail(); @@ -54,7 +73,10 @@ public function show(Request $request, string $uuid) public function create(Request $request, string $server_uuid) { - $teamId = auth()->user()->currentTeam()->id; + $teamId = $this->teamIdOrAbort(); + if (! is_int($teamId)) { + return $teamId; + } $server = Server::ownedByCurrentTeamAPI($teamId)->whereUuid($server_uuid)->firstOrFail(); $allowed = ['name', 'network', 'type']; @@ -92,7 +114,10 @@ public function create(Request $request, string $server_uuid) public function delete(Request $request, string $uuid) { - $teamId = auth()->user()->currentTeam()->id; + $teamId = $this->teamIdOrAbort(); + if (! is_int($teamId)) { + return $teamId; + } $d = StandaloneDocker::ownedByCurrentTeamAPI($teamId)->whereUuid($uuid)->first() ?? SwarmDocker::ownedByCurrentTeamAPI($teamId)->whereUuid($uuid)->firstOrFail(); if ($d->attachedTo()) { From 68e9184b57962c7c5c8f30e531e9ec69a73290b9 Mon Sep 17 00:00:00 2001 From: Poul Date: Mon, 25 May 2026 11:47:10 +0000 Subject: [PATCH 3/6] fix(api/destinations): use whereHas instead of ownedByCurrentTeamAPI for back-compat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ownedByCurrentTeamAPI scope was added to StandaloneDocker/SwarmDocker *after* 4.0.0-beta.470 — running containers on that beta hit a BadMethodCallException. Rewrites all team scoping to use whereHas('server', whereTeamId) which works against any v4.x of Coolify (StandaloneDocker.server_id -> Server.team_id has been there since the multi-team change). Also guards attachedTo() with method_exists and falls back to a manual attached-resource check covering applications + every standalone DB relation, so delete() doesn't crash on older versions either. --- .../Api/DestinationsController.php | 62 +++++++++++++++---- 1 file changed, 51 insertions(+), 11 deletions(-) diff --git a/app/Http/Controllers/Api/DestinationsController.php b/app/Http/Controllers/Api/DestinationsController.php index cd6c187b6..5db11624b 100644 --- a/app/Http/Controllers/Api/DestinationsController.php +++ b/app/Http/Controllers/Api/DestinationsController.php @@ -25,6 +25,9 @@ private function transform($d): array ]; } + /** + * Resolve the calling token's team id, or return a 403 response. + */ private function teamIdOrAbort(): int|\Illuminate\Http\JsonResponse { $teamId = getTeamIdFromToken(); @@ -35,16 +38,33 @@ private function teamIdOrAbort(): int|\Illuminate\Http\JsonResponse return $teamId; } + /** + * StandaloneDocker / SwarmDocker scoped to a team via their parent server. + * Uses whereHas instead of the model's ownedByCurrentTeamAPI() scope so the + * controller works on Coolify versions that pre-date that scope being added + * to the destination models (e.g. 4.0.0-beta.470). + */ + private function teamScopedDockers(int $teamId) + { + return [ + 'standalone' => StandaloneDocker::whereHas('server', fn ($q) => $q->whereTeamId($teamId))->get(), + 'swarm' => SwarmDocker::whereHas('server', fn ($q) => $q->whereTeamId($teamId))->get(), + ]; + } + public function index(Request $request) { $teamId = $this->teamIdOrAbort(); if (! is_int($teamId)) { return $teamId; } - $standalone = StandaloneDocker::ownedByCurrentTeamAPI($teamId)->get(); - $swarm = SwarmDocker::ownedByCurrentTeamAPI($teamId)->get(); + $sets = $this->teamScopedDockers($teamId); - return response()->json($standalone->concat($swarm)->map(fn ($d) => $this->transform($d))->values()); + return response()->json( + $sets['standalone']->concat($sets['swarm']) + ->map(fn ($d) => $this->transform($d)) + ->values() + ); } public function index_by_server(Request $request, string $server_uuid) @@ -53,7 +73,7 @@ public function index_by_server(Request $request, string $server_uuid) if (! is_int($teamId)) { return $teamId; } - $server = Server::ownedByCurrentTeamAPI($teamId)->whereUuid($server_uuid)->firstOrFail(); + $server = Server::whereTeamId($teamId)->whereUuid($server_uuid)->firstOrFail(); $list = $server->standaloneDockers->concat($server->swarmDockers); return response()->json($list->map(fn ($d) => $this->transform($d))->values()); @@ -65,8 +85,8 @@ public function show(Request $request, string $uuid) if (! is_int($teamId)) { return $teamId; } - $d = StandaloneDocker::ownedByCurrentTeamAPI($teamId)->whereUuid($uuid)->first() - ?? SwarmDocker::ownedByCurrentTeamAPI($teamId)->whereUuid($uuid)->firstOrFail(); + $d = StandaloneDocker::whereHas('server', fn ($q) => $q->whereTeamId($teamId))->whereUuid($uuid)->first() + ?? SwarmDocker::whereHas('server', fn ($q) => $q->whereTeamId($teamId))->whereUuid($uuid)->firstOrFail(); return response()->json($this->transform($d)); } @@ -77,7 +97,7 @@ public function create(Request $request, string $server_uuid) if (! is_int($teamId)) { return $teamId; } - $server = Server::ownedByCurrentTeamAPI($teamId)->whereUuid($server_uuid)->firstOrFail(); + $server = Server::whereTeamId($teamId)->whereUuid($server_uuid)->firstOrFail(); $allowed = ['name', 'network', 'type']; $extra = array_diff(array_keys($request->all()), $allowed); @@ -118,10 +138,30 @@ public function delete(Request $request, string $uuid) if (! is_int($teamId)) { return $teamId; } - $d = StandaloneDocker::ownedByCurrentTeamAPI($teamId)->whereUuid($uuid)->first() - ?? SwarmDocker::ownedByCurrentTeamAPI($teamId)->whereUuid($uuid)->firstOrFail(); - if ($d->attachedTo()) { - return response()->json(['message' => 'Destination has attached resources, detach first.'], 409); + $d = StandaloneDocker::whereHas('server', fn ($q) => $q->whereTeamId($teamId))->whereUuid($uuid)->first() + ?? SwarmDocker::whereHas('server', fn ($q) => $q->whereTeamId($teamId))->whereUuid($uuid)->firstOrFail(); + + // Guard against deleting destinations with attached resources. attachedTo() + // is recent on the destination models; fall back to a manual check for + // older Coolify versions (e.g. 4.0.0-beta.470). + if (method_exists($d, 'attachedTo')) { + if ($d->attachedTo()) { + return response()->json(['message' => 'Destination has attached resources, detach first.'], 409); + } + } else { + $hasAttached = $d->applications()->exists() + || $d->postgresqls()->exists() + || (method_exists($d, 'mysqls') && $d->mysqls()->exists()) + || (method_exists($d, 'mariadbs') && $d->mariadbs()->exists()) + || (method_exists($d, 'mongodbs') && $d->mongodbs()->exists()) + || (method_exists($d, 'redis') && $d->redis()->exists()) + || (method_exists($d, 'keydbs') && $d->keydbs()->exists()) + || (method_exists($d, 'dragonflies') && $d->dragonflies()->exists()) + || (method_exists($d, 'clickhouses') && $d->clickhouses()->exists()) + || (method_exists($d, 'services') && $d->services()->exists()); + if ($hasAttached) { + return response()->json(['message' => 'Destination has attached resources, detach first.'], 409); + } } $d->delete(); From 9665aa292c935004781b90bec0b79270ded873d6 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 15 Jun 2026 17:03:01 +0200 Subject: [PATCH 4/6] fix(api): block invalid destination types and service deletions --- .../Api/DestinationsController.php | 16 +- app/Models/StandaloneDocker.php | 2 +- app/Models/SwarmDocker.php | 2 +- tests/Feature/Api/DestinationsApiTest.php | 200 ++++++++++++++++++ 4 files changed, 216 insertions(+), 4 deletions(-) create mode 100644 tests/Feature/Api/DestinationsApiTest.php diff --git a/app/Http/Controllers/Api/DestinationsController.php b/app/Http/Controllers/Api/DestinationsController.php index 5db11624b..8b54913e7 100644 --- a/app/Http/Controllers/Api/DestinationsController.php +++ b/app/Http/Controllers/Api/DestinationsController.php @@ -6,6 +6,7 @@ use App\Models\Server; use App\Models\StandaloneDocker; use App\Models\SwarmDocker; +use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Validator; @@ -28,7 +29,7 @@ private function transform($d): array /** * Resolve the calling token's team id, or return a 403 response. */ - private function teamIdOrAbort(): int|\Illuminate\Http\JsonResponse + private function teamIdOrAbort(): int|JsonResponse { $teamId = getTeamIdFromToken(); if (is_null($teamId)) { @@ -97,6 +98,12 @@ public function create(Request $request, string $server_uuid) if (! is_int($teamId)) { return $teamId; } + + $return = validateIncomingRequest($request); + if ($return instanceof JsonResponse) { + return $return; + } + $server = Server::whereTeamId($teamId)->whereUuid($server_uuid)->firstOrFail(); $allowed = ['name', 'network', 'type']; @@ -114,7 +121,12 @@ public function create(Request $request, string $server_uuid) return response()->json(['message' => 'Validation failed', 'errors' => $validator->errors()], 422); } - $type = $request->input('type', 'standalone'); + $expectedType = $server->isSwarm() ? 'swarm' : 'standalone'; + $type = $request->input('type', $expectedType); + if ($type !== $expectedType) { + return response()->json(['message' => "Destination type must be {$expectedType} for this server."], 422); + } + $name = $request->input('name') ?: ($server->name.'-'.$request->input('network')); $class = $type === 'swarm' ? SwarmDocker::class : StandaloneDocker::class; diff --git a/app/Models/StandaloneDocker.php b/app/Models/StandaloneDocker.php index 1c5cfd342..c1dd4bf67 100644 --- a/app/Models/StandaloneDocker.php +++ b/app/Models/StandaloneDocker.php @@ -144,6 +144,6 @@ public function databases(): Collection public function attachedTo() { - return $this->applications?->count() > 0 || $this->databases()->count() > 0; + return $this->applications()->exists() || $this->databases()->count() > 0 || $this->services()->exists(); } } diff --git a/app/Models/SwarmDocker.php b/app/Models/SwarmDocker.php index 0e9620457..02b8381d9 100644 --- a/app/Models/SwarmDocker.php +++ b/app/Models/SwarmDocker.php @@ -124,6 +124,6 @@ public function databases() public function attachedTo() { - return $this->applications?->count() > 0 || $this->databases()->count() > 0; + return $this->applications()->exists() || $this->databases()->count() > 0 || $this->services()->exists(); } } diff --git a/tests/Feature/Api/DestinationsApiTest.php b/tests/Feature/Api/DestinationsApiTest.php new file mode 100644 index 000000000..cbbd6d0a6 --- /dev/null +++ b/tests/Feature/Api/DestinationsApiTest.php @@ -0,0 +1,200 @@ + 'array', + 'session.driver' => 'array', + 'queue.default' => 'sync', + 'app.maintenance.driver' => 'file', + ]); + + InstanceSettings::unguarded(fn () => InstanceSettings::firstOrCreate( + ['id' => 0], + ['is_api_enabled' => true], + )); + + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + session(['currentTeam' => $this->team]); + + $this->bearerToken = destinationsApiToken($this->user, $this->team, ['*']); + $this->server = Server::factory()->create(['team_id' => $this->team->id]); + $this->destination = StandaloneDocker::where('server_id', $this->server->id)->first(); +}); + +function destinationsApiHeaders(string $bearerToken): array +{ + return [ + 'Authorization' => 'Bearer '.$bearerToken, + 'Content-Type' => 'application/json', + ]; +} + +function destinationsApiToken(User $user, Team $team, array $abilities): string +{ + $plainTextToken = Str::random(40); + $token = $user->tokens()->create([ + 'name' => 'destinations-api-test-'.Str::random(6), + 'token' => hash('sha256', $plainTextToken), + 'abilities' => $abilities, + 'team_id' => $team->id, + ]); + + return $token->getKey().'|'.$plainTextToken; +} + +describe('GET /api/v1/destinations', function () { + test('lists only destinations owned by the token team', function () { + $otherTeam = Team::factory()->create(); + $otherServer = Server::factory()->create(['team_id' => $otherTeam->id]); + $otherDestination = StandaloneDocker::where('server_id', $otherServer->id)->first(); + + $response = $this->withHeaders(destinationsApiHeaders($this->bearerToken)) + ->getJson('/api/v1/destinations'); + + $response->assertOk(); + $uuids = collect($response->json())->pluck('uuid'); + + expect($uuids)->toContain($this->destination->uuid) + ->not->toContain($otherDestination->uuid); + }); +}); + +describe('GET /api/v1/destinations/{uuid}', function () { + test('does not expose another team destination', function () { + $otherTeam = Team::factory()->create(); + $otherServer = Server::factory()->create(['team_id' => $otherTeam->id]); + $otherDestination = StandaloneDocker::where('server_id', $otherServer->id)->first(); + + $response = $this->withHeaders(destinationsApiHeaders($this->bearerToken)) + ->getJson("/api/v1/destinations/{$otherDestination->uuid}"); + + $response->assertNotFound(); + }); +}); + +describe('GET /api/v1/servers/{server_uuid}/destinations', function () { + test('lists destinations for a team server', function () { + $response = $this->withHeaders(destinationsApiHeaders($this->bearerToken)) + ->getJson("/api/v1/servers/{$this->server->uuid}/destinations"); + + $response->assertOk(); + expect($response->json())->toHaveCount(1) + ->and($response->json('0.uuid'))->toBe($this->destination->uuid); + }); +}); + +describe('POST /api/v1/servers/{server_uuid}/destinations', function () { + test('requires a write token', function () { + $readOnlyToken = destinationsApiToken($this->user, $this->team, ['read']); + + $response = $this->withHeaders(destinationsApiHeaders($readOnlyToken)) + ->postJson("/api/v1/servers/{$this->server->uuid}/destinations", [ + 'network' => 'new-network', + ]); + + $response->assertForbidden(); + }); + + test('rejects non-json requests before creating a destination', function () { + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + ])->post("/api/v1/servers/{$this->server->uuid}/destinations", [ + 'network' => 'api-swarm-network', + 'type' => 'swarm', + ]); + + $response->assertStatus(400); + expect(SwarmDocker::where('server_id', $this->server->id)->where('network', 'api-swarm-network')->exists())->toBeFalse(); + }); + + test('rejects unknown fields', function () { + $response = $this->withHeaders(destinationsApiHeaders($this->bearerToken)) + ->postJson("/api/v1/servers/{$this->server->uuid}/destinations", [ + 'network' => 'new-network', + 'unexpected' => 'value', + ]); + + $response->assertStatus(422); + $response->assertJsonPath('fields.0', 'unexpected'); + }); + + test('rejects unsafe docker network names', function () { + $response = $this->withHeaders(destinationsApiHeaders($this->bearerToken)) + ->postJson("/api/v1/servers/{$this->server->uuid}/destinations", [ + 'network' => 'bad;network', + ]); + + $response->assertStatus(422); + $response->assertJsonValidationErrors(['network']); + }); + + test('rejects a destination type that does not match the server mode', function () { + $response = $this->withHeaders(destinationsApiHeaders($this->bearerToken)) + ->postJson("/api/v1/servers/{$this->server->uuid}/destinations", [ + 'network' => 'wrong-type-network', + 'type' => 'swarm', + ]); + + $response->assertStatus(422); + expect(SwarmDocker::where('server_id', $this->server->id)->where('network', 'wrong-type-network')->exists())->toBeFalse(); + }); + + test('creates a swarm destination on a swarm server', function () { + $this->server->settings()->update(['is_swarm_manager' => true]); + + $response = $this->withHeaders(destinationsApiHeaders($this->bearerToken)) + ->postJson("/api/v1/servers/{$this->server->uuid}/destinations", [ + 'name' => 'API Swarm', + 'network' => 'api-swarm-network', + 'type' => 'swarm', + ]); + + $response->assertCreated(); + $response->assertJsonStructure(['uuid']); + expect(SwarmDocker::where('server_id', $this->server->id)->where('network', 'api-swarm-network')->exists())->toBeTrue(); + }); + + test('rejects duplicate networks on the same server and type', function () { + $response = $this->withHeaders(destinationsApiHeaders($this->bearerToken)) + ->postJson("/api/v1/servers/{$this->server->uuid}/destinations", [ + 'network' => $this->destination->network, + ]); + + $response->assertStatus(409); + }); +}); + +describe('DELETE /api/v1/destinations/{uuid}', function () { + test('blocks deleting a destination with an attached service', function () { + $project = Project::factory()->create(['team_id' => $this->team->id]); + $environment = $project->environments()->first(); + + Service::factory()->create([ + 'environment_id' => $environment->id, + 'server_id' => $this->server->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + $response = $this->withHeaders(destinationsApiHeaders($this->bearerToken)) + ->deleteJson("/api/v1/destinations/{$this->destination->uuid}"); + + $response->assertStatus(409); + $this->assertModelExists($this->destination); + }); +}); From 9e021c4037ff9591511bb02491e9ea9413d929bd Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 15 Jun 2026 17:15:56 +0200 Subject: [PATCH 5/6] fix(api): enforce destination access and cleanup networks Require admin team membership for destination mutations, return invalid-token responses for tokenless requests, and remove standalone Docker networks when deleting destinations. --- .../RemoveStandaloneDockerNetwork.php | 16 ++ .../Api/DestinationsController.php | 139 +++++++++++------- tests/Feature/Api/DestinationsApiTest.php | 62 +++++++- 3 files changed, 162 insertions(+), 55 deletions(-) create mode 100644 app/Actions/Destination/RemoveStandaloneDockerNetwork.php diff --git a/app/Actions/Destination/RemoveStandaloneDockerNetwork.php b/app/Actions/Destination/RemoveStandaloneDockerNetwork.php new file mode 100644 index 000000000..21c40a50a --- /dev/null +++ b/app/Actions/Destination/RemoveStandaloneDockerNetwork.php @@ -0,0 +1,16 @@ +network); + + instant_remote_process(["docker network disconnect {$safeNetwork} coolify-proxy"], $destination->server, throwError: false); + instant_remote_process(["docker network rm -f {$safeNetwork}"], $destination->server); + } +} diff --git a/app/Http/Controllers/Api/DestinationsController.php b/app/Http/Controllers/Api/DestinationsController.php index 8b54913e7..26836b89a 100644 --- a/app/Http/Controllers/Api/DestinationsController.php +++ b/app/Http/Controllers/Api/DestinationsController.php @@ -2,38 +2,37 @@ namespace App\Http\Controllers\Api; +use App\Actions\Destination\RemoveStandaloneDockerNetwork; use App\Http\Controllers\Controller; use App\Models\Server; use App\Models\StandaloneDocker; use App\Models\SwarmDocker; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; -use Illuminate\Support\Facades\Validator; class DestinationsController extends Controller { - private function transform($d): array + private function transform(StandaloneDocker|SwarmDocker $destination): array { return [ - 'id' => $d->id, - 'uuid' => $d->uuid, - 'name' => $d->name, - 'network' => $d->network, - 'type' => $d instanceof SwarmDocker ? 'swarm' : 'standalone', - 'server_uuid' => $d->server?->uuid, - 'created_at' => $d->created_at, - 'updated_at' => $d->updated_at, + 'uuid' => $destination->uuid, + 'name' => $destination->name, + 'network' => $destination->network, + 'type' => $destination instanceof SwarmDocker ? 'swarm' : 'standalone', + 'server_uuid' => $destination->server?->uuid, + 'created_at' => $destination->created_at, + 'updated_at' => $destination->updated_at, ]; } /** - * Resolve the calling token's team id, or return a 403 response. + * Resolve the calling token's team id, or return an invalid-token response. */ private function teamIdOrAbort(): int|JsonResponse { $teamId = getTeamIdFromToken(); if (is_null($teamId)) { - return response()->json(['message' => 'You are not allowed to access the API.'], 403); + return invalidTokenResponse(); } return $teamId; @@ -45,15 +44,21 @@ private function teamIdOrAbort(): int|JsonResponse * controller works on Coolify versions that pre-date that scope being added * to the destination models (e.g. 4.0.0-beta.470). */ - private function teamScopedDockers(int $teamId) + private function teamScopedDockers(int $teamId): array { return [ - 'standalone' => StandaloneDocker::whereHas('server', fn ($q) => $q->whereTeamId($teamId))->get(), - 'swarm' => SwarmDocker::whereHas('server', fn ($q) => $q->whereTeamId($teamId))->get(), + 'standalone' => StandaloneDocker::with('server:id,uuid')->whereHas('server', fn ($query) => $query->whereTeamId($teamId))->get(), + 'swarm' => SwarmDocker::with('server:id,uuid')->whereHas('server', fn ($query) => $query->whereTeamId($teamId))->get(), ]; } - public function index(Request $request) + private function findDestinationForTeam(int $teamId, string $uuid): StandaloneDocker|SwarmDocker + { + return StandaloneDocker::with('server:id,uuid,team_id,ip,user,port,private_key_id')->whereHas('server', fn ($query) => $query->whereTeamId($teamId))->whereUuid($uuid)->first() + ?? SwarmDocker::with('server:id,uuid,team_id')->whereHas('server', fn ($query) => $query->whereTeamId($teamId))->whereUuid($uuid)->firstOrFail(); + } + + public function index(Request $request): JsonResponse { $teamId = $this->teamIdOrAbort(); if (! is_int($teamId)) { @@ -63,36 +68,38 @@ public function index(Request $request) return response()->json( $sets['standalone']->concat($sets['swarm']) - ->map(fn ($d) => $this->transform($d)) + ->map(fn ($destination) => $this->transform($destination)) ->values() ); } - public function index_by_server(Request $request, string $server_uuid) + public function index_by_server(Request $request, string $server_uuid): JsonResponse { $teamId = $this->teamIdOrAbort(); if (! is_int($teamId)) { return $teamId; } - $server = Server::whereTeamId($teamId)->whereUuid($server_uuid)->firstOrFail(); + $server = Server::with(['standaloneDockers.server:id,uuid', 'swarmDockers.server:id,uuid']) + ->whereTeamId($teamId) + ->whereUuid($server_uuid) + ->firstOrFail(); $list = $server->standaloneDockers->concat($server->swarmDockers); - return response()->json($list->map(fn ($d) => $this->transform($d))->values()); + return response()->json($list->map(fn ($destination) => $this->transform($destination))->values()); } - public function show(Request $request, string $uuid) + public function show(Request $request, string $uuid): JsonResponse { $teamId = $this->teamIdOrAbort(); if (! is_int($teamId)) { return $teamId; } - $d = StandaloneDocker::whereHas('server', fn ($q) => $q->whereTeamId($teamId))->whereUuid($uuid)->first() - ?? SwarmDocker::whereHas('server', fn ($q) => $q->whereTeamId($teamId))->whereUuid($uuid)->firstOrFail(); + $destination = $this->findDestinationForTeam($teamId, $uuid); - return response()->json($this->transform($d)); + return response()->json($this->transform($destination)); } - public function create(Request $request, string $server_uuid) + public function create(Request $request, string $server_uuid): JsonResponse { $teamId = $this->teamIdOrAbort(); if (! is_int($teamId)) { @@ -107,18 +114,22 @@ public function create(Request $request, string $server_uuid) $server = Server::whereTeamId($teamId)->whereUuid($server_uuid)->firstOrFail(); $allowed = ['name', 'network', 'type']; - $extra = array_diff(array_keys($request->all()), $allowed); - if (! empty($extra)) { - return response()->json(['message' => 'Unknown fields', 'fields' => array_values($extra)], 422); - } - $validator = Validator::make($request->all(), [ + $validator = customApiValidator($request->all(), [ 'name' => 'nullable|string|max:255', 'network' => ['required', 'string', 'max:255', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/'], 'type' => 'nullable|in:standalone,swarm', ]); - if ($validator->fails()) { - return response()->json(['message' => 'Validation failed', 'errors' => $validator->errors()], 422); + $extra = array_diff(array_keys($request->all()), $allowed); + if ($validator->fails() || ! empty($extra)) { + $errors = $validator->errors(); + if (! empty($extra)) { + foreach ($extra as $field) { + $errors->add($field, 'This field is not allowed.'); + } + } + + return response()->json(['message' => 'Validation failed.', 'errors' => $errors], 422); } $expectedType = $server->isSwarm() ? 'swarm' : 'standalone'; @@ -130,52 +141,80 @@ public function create(Request $request, string $server_uuid) $name = $request->input('name') ?: ($server->name.'-'.$request->input('network')); $class = $type === 'swarm' ? SwarmDocker::class : StandaloneDocker::class; + $this->authorize('create', $class); + $exists = $class::where('server_id', $server->id)->where('network', $request->input('network'))->exists(); if ($exists) { return response()->json(['message' => 'A destination with this network already exists on the server.'], 409); } - $d = $class::create([ + $destination = $class::create([ 'name' => $name, 'network' => $request->input('network'), 'server_id' => $server->id, ]); - return response()->json(['uuid' => $d->uuid], 201); + auditLog('api.destination.created', [ + 'team_id' => $teamId, + 'destination_uuid' => $destination->uuid, + 'destination_name' => $destination->name, + 'destination_type' => $type, + 'server_uuid' => $server->uuid, + ]); + + return response()->json($this->transform($destination->load('server:id,uuid')), 201); } - public function delete(Request $request, string $uuid) + public function delete(Request $request, string $uuid): JsonResponse { $teamId = $this->teamIdOrAbort(); if (! is_int($teamId)) { return $teamId; } - $d = StandaloneDocker::whereHas('server', fn ($q) => $q->whereTeamId($teamId))->whereUuid($uuid)->first() - ?? SwarmDocker::whereHas('server', fn ($q) => $q->whereTeamId($teamId))->whereUuid($uuid)->firstOrFail(); + $destination = $this->findDestinationForTeam($teamId, $uuid); + + $this->authorize('delete', $destination); // Guard against deleting destinations with attached resources. attachedTo() // is recent on the destination models; fall back to a manual check for // older Coolify versions (e.g. 4.0.0-beta.470). - if (method_exists($d, 'attachedTo')) { - if ($d->attachedTo()) { + if (method_exists($destination, 'attachedTo')) { + if ($destination->attachedTo()) { return response()->json(['message' => 'Destination has attached resources, detach first.'], 409); } } else { - $hasAttached = $d->applications()->exists() - || $d->postgresqls()->exists() - || (method_exists($d, 'mysqls') && $d->mysqls()->exists()) - || (method_exists($d, 'mariadbs') && $d->mariadbs()->exists()) - || (method_exists($d, 'mongodbs') && $d->mongodbs()->exists()) - || (method_exists($d, 'redis') && $d->redis()->exists()) - || (method_exists($d, 'keydbs') && $d->keydbs()->exists()) - || (method_exists($d, 'dragonflies') && $d->dragonflies()->exists()) - || (method_exists($d, 'clickhouses') && $d->clickhouses()->exists()) - || (method_exists($d, 'services') && $d->services()->exists()); + $hasAttached = $destination->applications()->exists() + || $destination->postgresqls()->exists() + || (method_exists($destination, 'mysqls') && $destination->mysqls()->exists()) + || (method_exists($destination, 'mariadbs') && $destination->mariadbs()->exists()) + || (method_exists($destination, 'mongodbs') && $destination->mongodbs()->exists()) + || (method_exists($destination, 'redis') && $destination->redis()->exists()) + || (method_exists($destination, 'keydbs') && $destination->keydbs()->exists()) + || (method_exists($destination, 'dragonflies') && $destination->dragonflies()->exists()) + || (method_exists($destination, 'clickhouses') && $destination->clickhouses()->exists()) + || (method_exists($destination, 'services') && $destination->services()->exists()); if ($hasAttached) { return response()->json(['message' => 'Destination has attached resources, detach first.'], 409); } } - $d->delete(); + if ($destination instanceof StandaloneDocker) { + app(RemoveStandaloneDockerNetwork::class)->handle($destination); + } + + $destinationUuid = $destination->uuid; + $destinationName = $destination->name; + $destinationType = $destination instanceof SwarmDocker ? 'swarm' : 'standalone'; + $serverUuid = $destination->server?->uuid; + + $destination->delete(); + + auditLog('api.destination.deleted', [ + 'team_id' => $teamId, + 'destination_uuid' => $destinationUuid, + 'destination_name' => $destinationName, + 'destination_type' => $destinationType, + 'server_uuid' => $serverUuid, + ]); return response()->json(['message' => 'Deleted.']); } diff --git a/tests/Feature/Api/DestinationsApiTest.php b/tests/Feature/Api/DestinationsApiTest.php index cbbd6d0a6..9639c5d9e 100644 --- a/tests/Feature/Api/DestinationsApiTest.php +++ b/tests/Feature/Api/DestinationsApiTest.php @@ -1,5 +1,6 @@ assertOk(); $uuids = collect($response->json())->pluck('uuid'); - expect($uuids)->toContain($this->destination->uuid) + expect($response->json('0'))->not->toHaveKey('id') + ->and($uuids)->toContain($this->destination->uuid) ->not->toContain($otherDestination->uuid); }); }); @@ -110,6 +112,20 @@ function destinationsApiToken(User $user, Team $team, array $abilities): string $response->assertForbidden(); }); + test('rejects create requests from non-admin team members', function () { + $member = User::factory()->create(); + $this->team->members()->attach($member->id, ['role' => 'member']); + $memberToken = destinationsApiToken($member, $this->team, ['*']); + + $response = $this->withHeaders(destinationsApiHeaders($memberToken)) + ->postJson("/api/v1/servers/{$this->server->uuid}/destinations", [ + 'network' => 'member-network', + ]); + + $response->assertForbidden(); + expect(StandaloneDocker::where('server_id', $this->server->id)->where('network', 'member-network')->exists())->toBeFalse(); + }); + test('rejects non-json requests before creating a destination', function () { $response = $this->withHeaders([ 'Authorization' => 'Bearer '.$this->bearerToken, @@ -129,8 +145,8 @@ function destinationsApiToken(User $user, Team $team, array $abilities): string 'unexpected' => 'value', ]); - $response->assertStatus(422); - $response->assertJsonPath('fields.0', 'unexpected'); + $response->assertUnprocessable(); + $response->assertJsonValidationErrors(['unexpected']); }); test('rejects unsafe docker network names', function () { @@ -139,7 +155,7 @@ function destinationsApiToken(User $user, Team $team, array $abilities): string 'network' => 'bad;network', ]); - $response->assertStatus(422); + $response->assertUnprocessable(); $response->assertJsonValidationErrors(['network']); }); @@ -150,7 +166,7 @@ function destinationsApiToken(User $user, Team $team, array $abilities): string 'type' => 'swarm', ]); - $response->assertStatus(422); + $response->assertUnprocessable(); expect(SwarmDocker::where('server_id', $this->server->id)->where('network', 'wrong-type-network')->exists())->toBeFalse(); }); @@ -180,6 +196,42 @@ function destinationsApiToken(User $user, Team $team, array $abilities): string }); describe('DELETE /api/v1/destinations/{uuid}', function () { + test('requires a write token', function () { + $readOnlyToken = destinationsApiToken($this->user, $this->team, ['read']); + + $response = $this->withHeaders(destinationsApiHeaders($readOnlyToken)) + ->deleteJson("/api/v1/destinations/{$this->destination->uuid}"); + + $response->assertForbidden(); + $this->assertModelExists($this->destination); + }); + + test('rejects delete requests from non-admin team members', function () { + $member = User::factory()->create(); + $this->team->members()->attach($member->id, ['role' => 'member']); + $memberToken = destinationsApiToken($member, $this->team, ['*']); + + $response = $this->withHeaders(destinationsApiHeaders($memberToken)) + ->deleteJson("/api/v1/destinations/{$this->destination->uuid}"); + + $response->assertForbidden(); + $this->assertModelExists($this->destination); + }); + + test('deletes standalone destinations after removing the docker network', function () { + $cleanup = Mockery::mock(RemoveStandaloneDockerNetwork::class); + $cleanup->shouldReceive('handle') + ->once() + ->with(Mockery::on(fn (StandaloneDocker $destination) => $destination->is($this->destination))); + $this->app->instance(RemoveStandaloneDockerNetwork::class, $cleanup); + + $response = $this->withHeaders(destinationsApiHeaders($this->bearerToken)) + ->deleteJson("/api/v1/destinations/{$this->destination->uuid}"); + + $response->assertOk(); + $this->assertModelMissing($this->destination); + }); + test('blocks deleting a destination with an attached service', function () { $project = Project::factory()->create(['team_id' => $this->team->id]); $environment = $project->environments()->first(); From 70021c8d5e662a881da94de477f2dfc93fcb42c7 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 2 Jul 2026 15:33:01 +0200 Subject: [PATCH 6/6] fix(api): handle destination create races as conflicts --- .../Api/DestinationsController.php | 28 +++++++++++++---- tests/Feature/Api/DestinationsApiTest.php | 30 +++++++++++++++++++ 2 files changed, 53 insertions(+), 5 deletions(-) diff --git a/app/Http/Controllers/Api/DestinationsController.php b/app/Http/Controllers/Api/DestinationsController.php index 26836b89a..f58e2ee71 100644 --- a/app/Http/Controllers/Api/DestinationsController.php +++ b/app/Http/Controllers/Api/DestinationsController.php @@ -7,6 +7,7 @@ use App\Models\Server; use App\Models\StandaloneDocker; use App\Models\SwarmDocker; +use Illuminate\Database\QueryException; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -148,11 +149,19 @@ public function create(Request $request, string $server_uuid): JsonResponse return response()->json(['message' => 'A destination with this network already exists on the server.'], 409); } - $destination = $class::create([ - 'name' => $name, - 'network' => $request->input('network'), - 'server_id' => $server->id, - ]); + try { + $destination = $class::create([ + 'name' => $name, + 'network' => $request->input('network'), + 'server_id' => $server->id, + ]); + } catch (QueryException $exception) { + if ($this->isUniqueConstraintViolation($exception)) { + return response()->json(['message' => 'A destination with this network already exists on the server.'], 409); + } + + throw $exception; + } auditLog('api.destination.created', [ 'team_id' => $teamId, @@ -165,6 +174,15 @@ public function create(Request $request, string $server_uuid): JsonResponse return response()->json($this->transform($destination->load('server:id,uuid')), 201); } + private function isUniqueConstraintViolation(QueryException $exception): bool + { + $sqlState = $exception->errorInfo[0] ?? null; + $driverCode = (string) ($exception->errorInfo[1] ?? $exception->getCode()); + + return in_array($sqlState, ['23000', '23505'], true) + || in_array($driverCode, ['19', '1062', '2067'], true); + } + public function delete(Request $request, string $uuid): JsonResponse { $teamId = $this->teamIdOrAbort(); diff --git a/tests/Feature/Api/DestinationsApiTest.php b/tests/Feature/Api/DestinationsApiTest.php index 9639c5d9e..027f4acdb 100644 --- a/tests/Feature/Api/DestinationsApiTest.php +++ b/tests/Feature/Api/DestinationsApiTest.php @@ -10,6 +10,7 @@ use App\Models\Team; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Str; uses(RefreshDatabase::class); @@ -193,6 +194,35 @@ function destinationsApiToken(User $user, Team $team, array $abilities): string $response->assertStatus(409); }); + + test('returns conflict when the database unique constraint wins a create race', function () { + $network = 'raced-network'; + + StandaloneDocker::creating(function (StandaloneDocker $destination) use ($network) { + if ($destination->network !== $network) { + return; + } + + DB::table('standalone_dockers')->insert([ + 'name' => 'Concurrent destination', + 'uuid' => (string) Str::uuid(), + 'network' => $network, + 'server_id' => $destination->server_id, + 'created_at' => now(), + 'updated_at' => now(), + ]); + }); + + $response = $this->withHeaders(destinationsApiHeaders($this->bearerToken)) + ->postJson("/api/v1/servers/{$this->server->uuid}/destinations", [ + 'network' => $network, + ]); + + $response->assertStatus(409) + ->assertJson(['message' => 'A destination with this network already exists on the server.']); + + expect(StandaloneDocker::where('server_id', $this->server->id)->where('network', $network)->count())->toBe(1); + }); }); describe('DELETE /api/v1/destinations/{uuid}', function () {