mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-14 16:52:45 -04:00
Require deploy permission for API changes that trigger server side effects, including resource deletion, storage access, provider operations, and database imports. Also escape Docker cleanup filters and sanitize log drain server names.
474 lines
20 KiB
PHP
474 lines
20 KiB
PHP
<?php
|
|
|
|
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\Database\QueryException;
|
|
use Illuminate\Http\JsonResponse;
|
|
use Illuminate\Http\Request;
|
|
use OpenApi\Attributes as OA;
|
|
|
|
class DestinationsController extends Controller
|
|
{
|
|
/**
|
|
* Docker's built-in networks. A destination must not use them.
|
|
*/
|
|
private const RESERVED_NETWORK_NAMES = ['bridge', 'host', 'none'];
|
|
|
|
private function transform(StandaloneDocker|SwarmDocker $destination): array
|
|
{
|
|
return [
|
|
'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 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();
|
|
}
|
|
|
|
#[OA\Get(
|
|
summary: 'List destinations',
|
|
description: 'List all Docker network destinations for the authenticated team.',
|
|
path: '/destinations',
|
|
operationId: 'list-destinations',
|
|
security: [['bearerAuth' => []]],
|
|
tags: ['Destinations'],
|
|
responses: [
|
|
new OA\Response(
|
|
response: 200,
|
|
description: 'Destinations for the authenticated team.',
|
|
content: new OA\JsonContent(type: 'array', items: new OA\Items(ref: '#/components/schemas/Destination')),
|
|
),
|
|
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
|
],
|
|
)]
|
|
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()
|
|
);
|
|
}
|
|
|
|
#[OA\Get(
|
|
summary: 'List destinations by server',
|
|
description: 'List Docker network destinations attached to a server owned by the authenticated team.',
|
|
path: '/servers/{server_uuid}/destinations',
|
|
operationId: 'list-server-destinations',
|
|
security: [['bearerAuth' => []]],
|
|
tags: ['Destinations'],
|
|
parameters: [
|
|
new OA\Parameter(name: 'server_uuid', in: 'path', required: true, description: 'Server UUID', schema: new OA\Schema(type: 'string')),
|
|
],
|
|
responses: [
|
|
new OA\Response(
|
|
response: 200,
|
|
description: 'Destinations attached to the server.',
|
|
content: new OA\JsonContent(type: 'array', items: new OA\Items(ref: '#/components/schemas/Destination')),
|
|
),
|
|
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
|
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
|
],
|
|
)]
|
|
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());
|
|
}
|
|
|
|
#[OA\Get(
|
|
summary: 'Get destination',
|
|
description: 'Get a Docker network destination by UUID.',
|
|
path: '/destinations/{uuid}',
|
|
operationId: 'get-destination-by-uuid',
|
|
security: [['bearerAuth' => []]],
|
|
tags: ['Destinations'],
|
|
parameters: [
|
|
new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Destination UUID', schema: new OA\Schema(type: 'string')),
|
|
],
|
|
responses: [
|
|
new OA\Response(
|
|
response: 200,
|
|
description: 'Destination details.',
|
|
content: new OA\JsonContent(ref: '#/components/schemas/Destination'),
|
|
),
|
|
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
|
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
|
],
|
|
)]
|
|
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));
|
|
}
|
|
|
|
#[OA\Post(
|
|
summary: 'Create destination',
|
|
description: 'Create a Docker network destination on a server owned by the authenticated team. The network must be new: Docker\'s built-in networks (bridge, host, none) and networks that already exist on a standalone server are rejected, because Coolify connects the proxy to the destination network.',
|
|
path: '/servers/{server_uuid}/destinations',
|
|
operationId: 'create-server-destination',
|
|
security: [['bearerAuth' => []]],
|
|
tags: ['Destinations'],
|
|
parameters: [
|
|
new OA\Parameter(name: 'server_uuid', in: 'path', required: true, description: 'Server UUID', schema: new OA\Schema(type: 'string')),
|
|
],
|
|
requestBody: new OA\RequestBody(
|
|
required: true,
|
|
content: new OA\JsonContent(
|
|
required: ['network'],
|
|
properties: [
|
|
new OA\Property(property: 'name', type: 'string', maxLength: 255),
|
|
new OA\Property(property: 'network', type: 'string', maxLength: 255, pattern: '^[a-zA-Z0-9][a-zA-Z0-9._-]*$', description: 'Name of a new Docker network. Must not be bridge, host, none, or a network that already exists on the server.'),
|
|
new OA\Property(property: 'type', type: 'string', enum: ['standalone', 'swarm']),
|
|
],
|
|
type: 'object',
|
|
),
|
|
),
|
|
responses: [
|
|
new OA\Response(
|
|
response: 201,
|
|
description: 'Destination created.',
|
|
content: new OA\JsonContent(ref: '#/components/schemas/Destination'),
|
|
),
|
|
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
|
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
|
new OA\Response(response: 409, description: 'A destination with this network already exists.'),
|
|
new OA\Response(response: 422, description: 'Validation failed, the network is reserved or already exists on the server, or the server could not be checked.'),
|
|
],
|
|
)]
|
|
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);
|
|
}
|
|
|
|
if (in_array(strtolower($request->input('network')), self::RESERVED_NETWORK_NAMES, true)) {
|
|
return response()->json([
|
|
'message' => 'Validation failed.',
|
|
'errors' => ['network' => ['The network name is reserved by Docker. Use a different name.']],
|
|
], 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);
|
|
}
|
|
|
|
// Creating a standalone destination connects the proxy to the network, so it must not be an existing network.
|
|
if ($class === StandaloneDocker::class && ($response = $this->existingServerNetworkResponse($server, $request->input('network')))) {
|
|
return $response;
|
|
}
|
|
|
|
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 existingServerNetworkResponse(Server $server, string $network): ?JsonResponse
|
|
{
|
|
try {
|
|
$output = instant_remote_process(["docker network ls --format '{{.Name}}'"], $server);
|
|
} catch (\Throwable) {
|
|
return response()->json([
|
|
'message' => 'Could not read the Docker networks on the server. Check that the server is reachable and try again.',
|
|
], 422);
|
|
}
|
|
|
|
$existingNetworks = array_map('trim', explode("\n", (string) $output));
|
|
if (in_array($network, $existingNetworks, true)) {
|
|
return response()->json([
|
|
'message' => 'Validation failed.',
|
|
'errors' => ['network' => ['A Docker network with this name already exists on the server. Use a new network name.']],
|
|
], 422);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
#[OA\Patch(
|
|
summary: 'Update destination',
|
|
description: 'Update a Docker network destination name. Network cannot be changed via the API.',
|
|
path: '/destinations/{uuid}',
|
|
operationId: 'update-destination-by-uuid',
|
|
security: [['bearerAuth' => []]],
|
|
tags: ['Destinations'],
|
|
parameters: [
|
|
new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Destination UUID', schema: new OA\Schema(type: 'string')),
|
|
],
|
|
requestBody: new OA\RequestBody(
|
|
required: true,
|
|
content: new OA\JsonContent(
|
|
properties: [
|
|
new OA\Property(property: 'name', type: 'string', maxLength: 255),
|
|
],
|
|
type: 'object',
|
|
),
|
|
),
|
|
responses: [
|
|
new OA\Response(
|
|
response: 200,
|
|
description: 'Destination updated.',
|
|
content: new OA\JsonContent(ref: '#/components/schemas/Destination'),
|
|
),
|
|
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
|
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
|
new OA\Response(response: 422, ref: '#/components/responses/422'),
|
|
],
|
|
)]
|
|
public function update(Request $request, string $uuid): JsonResponse
|
|
{
|
|
$teamId = $this->teamIdOrAbort();
|
|
if (! is_int($teamId)) {
|
|
return $teamId;
|
|
}
|
|
|
|
$return = validateIncomingRequest($request);
|
|
if ($return instanceof JsonResponse) {
|
|
return $return;
|
|
}
|
|
|
|
$allowed = ['name'];
|
|
|
|
$validator = customApiValidator($request->all(), [
|
|
'name' => 'required|string|max:255',
|
|
]);
|
|
$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);
|
|
}
|
|
|
|
$destination = $this->findDestinationForTeam($teamId, $uuid);
|
|
|
|
$this->authorize('update', $destination);
|
|
|
|
$destination->update(['name' => $request->input('name')]);
|
|
$destination->load('server:id,uuid');
|
|
|
|
auditLog('api.destination.updated', [
|
|
'team_id' => $teamId,
|
|
'destination_uuid' => $destination->uuid,
|
|
'destination_name' => $destination->name,
|
|
'destination_type' => $destination instanceof SwarmDocker ? 'swarm' : 'standalone',
|
|
'server_uuid' => $destination->server?->uuid,
|
|
'changed_fields' => ['name'],
|
|
]);
|
|
|
|
return response()->json($this->transform($destination));
|
|
}
|
|
|
|
#[OA\Delete(
|
|
summary: 'Delete destination',
|
|
description: 'Delete an unused Docker network destination.',
|
|
path: '/destinations/{uuid}',
|
|
operationId: 'delete-destination-by-uuid',
|
|
security: [['bearerAuth' => []]],
|
|
tags: ['Destinations'],
|
|
parameters: [
|
|
new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Destination UUID', schema: new OA\Schema(type: 'string')),
|
|
],
|
|
responses: [
|
|
new OA\Response(
|
|
response: 200,
|
|
description: 'Destination deleted.',
|
|
content: new OA\JsonContent(
|
|
properties: [
|
|
new OA\Property(property: 'message', type: 'string', example: 'Deleted.'),
|
|
],
|
|
type: 'object',
|
|
),
|
|
),
|
|
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
|
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
|
new OA\Response(response: 409, description: 'Destination has attached resources.'),
|
|
],
|
|
)]
|
|
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, 'sqlites') && $destination->sqlites()->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.']);
|
|
}
|
|
}
|