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 new file mode 100644 index 000000000..f58e2ee71 --- /dev/null +++ b/app/Http/Controllers/Api/DestinationsController.php @@ -0,0 +1,239 @@ + $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 an invalid-token response. + */ + private function teamIdOrAbort(): int|JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + 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): array + { + return [ + '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(), + ]; + } + + 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)) { + return $teamId; + } + $sets = $this->teamScopedDockers($teamId); + + return response()->json( + $sets['standalone']->concat($sets['swarm']) + ->map(fn ($destination) => $this->transform($destination)) + ->values() + ); + } + + public function index_by_server(Request $request, string $server_uuid): JsonResponse + { + $teamId = $this->teamIdOrAbort(); + if (! is_int($teamId)) { + return $teamId; + } + $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 ($destination) => $this->transform($destination))->values()); + } + + public function show(Request $request, string $uuid): JsonResponse + { + $teamId = $this->teamIdOrAbort(); + if (! is_int($teamId)) { + return $teamId; + } + $destination = $this->findDestinationForTeam($teamId, $uuid); + + return response()->json($this->transform($destination)); + } + + public function create(Request $request, string $server_uuid): JsonResponse + { + $teamId = $this->teamIdOrAbort(); + 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']; + + $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', + ]); + $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'; + $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; + + $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); + } + + 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, + '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); + } + + 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(); + if (! is_int($teamId)) { + return $teamId; + } + $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($destination, 'attachedTo')) { + if ($destination->attachedTo()) { + return response()->json(['message' => 'Destination has attached resources, detach first.'], 409); + } + } else { + $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); + } + } + 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/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/routes/api.php b/routes/api.php index ec6bde2e6..e44070d25 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; @@ -86,6 +87,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']); diff --git a/tests/Feature/Api/DestinationsApiTest.php b/tests/Feature/Api/DestinationsApiTest.php new file mode 100644 index 000000000..027f4acdb --- /dev/null +++ b/tests/Feature/Api/DestinationsApiTest.php @@ -0,0 +1,282 @@ + '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($response->json('0'))->not->toHaveKey('id') + ->and($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 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, + ])->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->assertUnprocessable(); + $response->assertJsonValidationErrors(['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->assertUnprocessable(); + $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->assertUnprocessable(); + 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); + }); + + 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 () { + 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(); + + 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); + }); +});