Files
coolify/app/Http/Controllers/Api/ServersController.php
T

1227 lines
53 KiB
PHP

<?php
namespace App\Http\Controllers\Api;
use App\Actions\Server\DeleteServer;
use App\Actions\Server\ValidateServer;
use App\Enums\ProxyStatus;
use App\Enums\ProxyTypes;
use App\Enums\ServerRole;
use App\Http\Controllers\Api\Concerns\RequiresDeployAbility;
use App\Http\Controllers\Controller;
use App\Jobs\DeleteResourceJob;
use App\Jobs\ValidateAndInstallServerJob;
use App\Models\Application;
use App\Models\CloudProviderToken;
use App\Models\PrivateKey;
use App\Models\Project;
use App\Models\Server as ModelsServer;
use App\Models\Team;
use App\Rules\ValidServerIp;
use App\Services\ServerTransfer\ServerTransferClaimer;
use App\Support\ValidationPatterns;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Arr;
use Illuminate\Validation\ValidationException;
use OpenApi\Attributes as OA;
use Stringable;
class ServersController extends Controller
{
use RequiresDeployAbility;
/**
* The server name goes into one-line config files on the server (for example the log drain config), so it must not contain control characters such as new lines.
*/
private const SERVER_NAME_CONTROL_CHARACTERS_PATTERN = '/[\x00-\x1F\x7F]/';
private function removeSensitiveDataFromSettings($settings)
{
if (request()->attributes->get('can_read_sensitive', false) === true) {
$settings = $settings->makeVisible([
'sentinel_token',
'sentinel_custom_url',
'logdrain_newrelic_license_key',
'logdrain_axiom_api_key',
'logdrain_custom_config',
'logdrain_custom_config_parser',
]);
}
return serializeApiResponse($settings)
->put('is_build_server', $settings->effectiveServerRole() === ServerRole::BUILD);
}
/**
* Another instance manages a transferred server. Changing its host or credentials would let
* "Enable management" connect to a different host, so these fields stay as they are.
*
* @return array<string, array<int, string>>
*/
private function transferredServerConnectionChanges(Request $request, ModelsServer $server): array
{
$changed = [
'ip' => $request->filled('ip') && ModelsServer::normalizeIp($request->input('ip')) !== ModelsServer::normalizeIp($server->getRawOriginal('ip')),
'user' => $request->filled('user') && (string) $request->input('user') !== (string) $server->user,
'port' => $request->filled('port') && (int) $request->input('port') !== (int) $server->port,
'private_key_uuid' => $request->filled('private_key_uuid') && $request->input('private_key_uuid') !== $server->privateKey?->uuid,
];
return collect($changed)
->filter()
->map(fn () => ['This field cannot be changed on a transferred server.'])
->all();
}
/**
* Rejects requests where the deprecated is_build_server flag disagrees with server_role.
*/
private function legacyBuildServerRoleConflict(Request $request): ?JsonResponse
{
if (! $request->filled('is_build_server') || ! $request->filled('server_role')) {
return null;
}
$wantsBuildOnly = $request->boolean('is_build_server');
$roleIsBuildOnly = $request->string('server_role')->toString() === ServerRole::BUILD->value;
if ($wantsBuildOnly === $roleIsBuildOnly) {
return null;
}
return response()->json([
'message' => 'Validation failed.',
'errors' => ['is_build_server' => ['is_build_server is deprecated and conflicts with server_role. Send only server_role.']],
], 422);
}
private function removeSensitiveData($server)
{
$server->makeHidden([
'id',
]);
$canReadSensitive = request()->attributes->get('can_read_sensitive', false) === true;
if ($canReadSensitive) {
$server->makeVisible([
'logdrain_axiom_api_key',
'logdrain_newrelic_license_key',
]);
}
$serialized = serializeApiResponse($server);
if (! $canReadSensitive && is_array($serialized->get('proxy'))) {
$serialized->put('proxy', Arr::except($serialized->get('proxy'), ['last_saved_proxy_configuration']));
}
return $serialized;
}
#[OA\Get(
summary: 'List',
description: 'List all servers.',
path: '/servers',
operationId: 'list-servers',
security: [
['bearerAuth' => []],
],
tags: ['Servers'],
responses: [
new OA\Response(
response: 200,
description: 'Get all servers.',
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'array',
items: new OA\Items(ref: '#/components/schemas/Server')
)
),
]),
new OA\Response(
response: 401,
ref: '#/components/responses/401',
),
new OA\Response(
response: 400,
ref: '#/components/responses/400',
),
]
)]
public function servers(Request $request)
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$servers = ModelsServer::whereTeamId($teamId)->select('id', 'name', 'uuid', 'ip', 'user', 'port', 'description')->get()->load(['settings'])->map(function ($server) {
$server['is_reachable'] = $server->settings->is_reachable;
$server['is_usable'] = $server->settings->is_usable;
return $server;
});
$servers = $servers->map(function ($server) {
$settings = $this->removeSensitiveDataFromSettings($server->settings);
$server = $this->removeSensitiveData($server);
data_set($server, 'settings', $settings);
return $server;
});
return response()->json($servers);
}
#[OA\Get(
summary: 'Get',
description: 'Get server by UUID.',
path: '/servers/{uuid}',
operationId: 'get-server-by-uuid',
security: [
['bearerAuth' => []],
],
tags: ['Servers'],
parameters: [
new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Server\'s UUID', schema: new OA\Schema(type: 'string')),
],
responses: [
new OA\Response(
response: 200,
description: 'Get server by UUID',
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
ref: '#/components/schemas/Server'
)
),
]),
new OA\Response(
response: 401,
ref: '#/components/responses/401',
),
new OA\Response(
response: 400,
ref: '#/components/responses/400',
),
new OA\Response(
response: 404,
ref: '#/components/responses/404',
),
]
)]
public function server_by_uuid(Request $request)
{
$with_resources = $request->query('resources');
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$server = ModelsServer::whereTeamId($teamId)->whereUuid(request()->uuid)->first();
if (is_null($server)) {
return response()->json(['message' => 'Server not found.'], 404);
}
$this->authorize('view', $server);
if ($with_resources) {
$server['resources'] = $server->definedResources()->map(function ($resource) {
$payload = [
'id' => $resource->id,
'uuid' => $resource->uuid,
'name' => $resource->name,
'type' => $resource->type(),
'created_at' => $resource->created_at,
'updated_at' => $resource->updated_at,
];
$payload['status'] = $resource->status;
return $payload;
});
} else {
$server->load(['settings']);
}
$server->append('unreachable_notification_sent');
$settings = $this->removeSensitiveDataFromSettings($server->settings);
$server = $this->removeSensitiveData($server);
data_set($server, 'settings', $settings);
return response()->json(serializeApiResponse($server));
}
#[OA\Get(
summary: 'Resources',
description: 'Get resources by server.',
path: '/servers/{uuid}/resources',
operationId: 'get-resources-by-server-uuid',
security: [
['bearerAuth' => []],
],
tags: ['Servers'],
parameters: [
new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Server\'s UUID', schema: new OA\Schema(type: 'string')),
],
responses: [
new OA\Response(
response: 200,
description: 'Get resources by server',
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'array',
items: new OA\Items(
type: 'object',
properties: [
'id' => ['type' => 'integer'],
'uuid' => ['type' => 'string'],
'name' => ['type' => 'string'],
'type' => ['type' => 'string'],
'created_at' => ['type' => 'string'],
'updated_at' => ['type' => 'string'],
'status' => ['type' => 'string'],
]
)
)),
]),
new OA\Response(
response: 401,
ref: '#/components/responses/401',
),
new OA\Response(
response: 400,
ref: '#/components/responses/400',
),
]
)]
public function resources_by_server(Request $request)
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$server = ModelsServer::whereTeamId($teamId)->whereUuid(request()->uuid)->first();
if (is_null($server)) {
return response()->json(['message' => 'Server not found.'], 404);
}
$server['resources'] = $server->definedResources()->map(function ($resource) {
$payload = [
'id' => $resource->id,
'uuid' => $resource->uuid,
'name' => $resource->name,
'type' => $resource->type(),
'created_at' => $resource->created_at,
'updated_at' => $resource->updated_at,
];
$payload['status'] = $resource->status;
return $payload;
});
$server = $this->removeSensitiveData($server);
return response()->json(serializeApiResponse(data_get($server, 'resources')));
}
#[OA\Get(
summary: 'Domains',
description: 'Get domains by server.',
path: '/servers/{uuid}/domains',
operationId: 'get-domains-by-server-uuid',
security: [
['bearerAuth' => []],
],
tags: ['Servers'],
parameters: [
new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Server\'s UUID', schema: new OA\Schema(type: 'string')),
],
responses: [
new OA\Response(
response: 200,
description: 'Get domains by server',
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'array',
items: new OA\Items(
type: 'object',
properties: [
'ip' => ['type' => 'string'],
'domains' => ['type' => 'array', 'items' => ['type' => 'string']],
]
)
)),
]),
new OA\Response(
response: 401,
ref: '#/components/responses/401',
),
new OA\Response(
response: 400,
ref: '#/components/responses/400',
),
]
)]
public function domains_by_server(Request $request)
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$server = ModelsServer::whereTeamId($teamId)->whereUuid($request->route('uuid'))->first();
if (is_null($server)) {
return response()->json(['message' => 'Server not found.'], 404);
}
$uuid = $request->query('uuid');
if ($uuid) {
$application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $uuid)->first();
if (! $application) {
return response()->json(['message' => 'Application not found.'], 404);
}
return response()->json(serializeApiResponse($application->fqdns));
}
$projects = Project::where('team_id', $teamId)->get();
$domains = collect();
$applications = $projects->pluck('applications')->flatten()->filter(function ($application) use ($server) {
return $application->destination?->server?->id === $server->id;
});
$settings = instanceSettings();
if ($applications->count() > 0) {
foreach ($applications as $application) {
$ip = $application->destination->server->ip;
$fqdn = str($application->fqdn)->explode(',')->map(fn ($fqdn) => self::domainHostWithoutPort((string) $fqdn))->filter(function (Stringable $fqdn) {
return $fqdn->isNotEmpty();
});
if ($ip === 'host.docker.internal') {
if ($settings->public_ipv4) {
$domains->push([
'domain' => $fqdn,
'ip' => $settings->public_ipv4,
]);
}
if ($settings->public_ipv6) {
$domains->push([
'domain' => $fqdn,
'ip' => $settings->public_ipv6,
]);
}
if (! $settings->public_ipv4 && ! $settings->public_ipv6) {
$domains->push([
'domain' => $fqdn,
'ip' => $ip,
]);
}
} else {
$domains->push([
'domain' => $fqdn,
'ip' => $ip,
]);
}
}
}
$services = $projects->pluck('services')->flatten()->filter(function ($service) use ($server) {
return $service->server_id === $server->id;
});
if ($services->count() > 0) {
foreach ($services as $service) {
$service_applications = $service->applications;
if ($service_applications->count() > 0) {
foreach ($service_applications as $application) {
$fqdn = str($application->fqdn)->explode(',')->map(fn ($fqdn) => self::domainHostWithoutPort((string) $fqdn))->filter(function (Stringable $fqdn) {
return $fqdn->isNotEmpty();
});
$serviceIp = $server->ip;
if ($serviceIp === 'host.docker.internal') {
if ($settings->public_ipv4) {
$domains->push([
'domain' => $fqdn,
'ip' => $settings->public_ipv4,
]);
}
if ($settings->public_ipv6) {
$domains->push([
'domain' => $fqdn,
'ip' => $settings->public_ipv6,
]);
}
if (! $settings->public_ipv4 && ! $settings->public_ipv6) {
$domains->push([
'domain' => $fqdn,
'ip' => $serviceIp,
]);
}
} else {
$domains->push([
'domain' => $fqdn,
'ip' => $serviceIp,
]);
}
}
}
}
}
$domains = $domains->groupBy('ip')->map(function ($domain) {
return $domain->pluck('domain')->flatten();
})->map(function ($domain, $ip) {
return [
'ip' => $ip,
'domains' => $domain,
];
})->values();
return response()->json(serializeApiResponse($domains));
}
#[OA\Post(
summary: 'Create',
description: 'Create Server.',
path: '/servers',
operationId: 'create-server',
security: [
['bearerAuth' => []],
],
tags: ['Servers'],
requestBody: new OA\RequestBody(
required: true,
description: 'Server created.',
content: new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
properties: [
'name' => ['type' => 'string', 'example' => 'My Server', 'description' => 'The name of the server. Must not contain control characters such as new lines.'],
'description' => ['type' => 'string', 'example' => 'My Server Description', 'description' => 'The description of the server.'],
'ip' => ['type' => 'string', 'example' => '127.0.0.1', 'description' => 'The IP of the server.'],
'port' => ['type' => 'integer', 'example' => 22, 'description' => 'The port of the server.'],
'user' => ['type' => 'string', 'example' => 'root', 'description' => 'The user of the server.'],
'private_key_uuid' => ['type' => 'string', 'example' => 'og888os', 'description' => 'The UUID of the private key.'],
'server_role' => ['type' => 'string', 'enum' => ['deployment', 'build', 'both'], 'example' => 'both', 'description' => 'Server role.'],
'is_build_server' => ['type' => 'boolean', 'deprecated' => true, 'description' => 'Deprecated: use server_role instead. true sets server_role to build, false sets it to both. Must not conflict with server_role.'],
'instant_validate' => ['type' => 'boolean', 'example' => false, 'description' => 'Instant validate.'],
'proxy_type' => ['type' => 'string', 'enum' => ['traefik', 'caddy', 'none'], 'example' => 'traefik', 'description' => 'The proxy type.'],
],
),
),
),
responses: [
new OA\Response(
response: 201,
description: 'Server created.',
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
properties: [
'uuid' => ['type' => 'string', 'example' => 'og888os', 'description' => 'The UUID of the server.'],
]
)
),
]),
new OA\Response(
response: 401,
ref: '#/components/responses/401',
),
new OA\Response(
response: 400,
ref: '#/components/responses/400',
),
new OA\Response(
response: 404,
ref: '#/components/responses/404',
),
new OA\Response(
response: 422,
ref: '#/components/responses/422',
),
]
)]
public function create_server(Request $request)
{
$allowedFields = ['name', 'description', 'ip', 'port', 'user', 'private_key_uuid', 'server_role', 'is_build_server', 'instant_validate', 'proxy_type'];
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$this->authorize('create', [ModelsServer::class]);
$return = validateIncomingRequest($request);
if ($return instanceof JsonResponse) {
return $return;
}
$validator = customApiValidator($request->all(), [
'name' => ['string', 'max:255', 'not_regex:'.self::SERVER_NAME_CONTROL_CHARACTERS_PATTERN],
'description' => 'string|nullable',
'ip' => ['string', 'required', new ValidServerIp],
'port' => 'integer|nullable|between:1,65535',
'private_key_uuid' => 'string|required',
'user' => ValidationPatterns::serverUsernameRules(required: false),
'server_role' => 'string|nullable|in:deployment,build,both',
'is_build_server' => 'boolean|nullable',
'instant_validate' => 'boolean|nullable',
'proxy_type' => 'string|nullable',
], [
...ValidationPatterns::serverUsernameMessages(),
'name.not_regex' => 'The name must not contain control characters such as new lines.',
]);
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
if ($validator->fails() || ! empty($extraFields)) {
$errors = $validator->errors();
if (! empty($extraFields)) {
foreach ($extraFields as $field) {
$errors->add($field, 'This field is not allowed.');
}
}
return response()->json([
'message' => 'Validation failed.',
'errors' => $errors,
], 422);
}
if (! $request->name) {
$request->offsetSet('name', generate_random_name());
}
if (! $request->user) {
$request->offsetSet('user', 'root');
}
if (is_null($request->port)) {
$request->offsetSet('port', 22);
}
$legacyRoleConflict = $this->legacyBuildServerRoleConflict($request);
if ($legacyRoleConflict) {
return $legacyRoleConflict;
}
$serverRole = match (true) {
$request->filled('server_role') => ServerRole::from($request->string('server_role')->toString()),
$request->filled('is_build_server') && $request->boolean('is_build_server') => ServerRole::BUILD,
default => ServerRole::BOTH,
};
if ($serverRole === ServerRole::DEPLOYMENT && ! ModelsServer::buildServers($teamId)->exists()) {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['server_role' => ['Add a usable build server before you set this server to deployments only.']],
], 422);
}
if (is_null($request->instant_validate)) {
$request->offsetSet('instant_validate', false);
}
if ($request->proxy_type) {
$validProxyTypes = collect(ProxyTypes::cases())->map(function ($proxyType) {
return str($proxyType->value)->lower();
});
if (! $validProxyTypes->contains(str($request->proxy_type)->lower())) {
return response()->json(['message' => 'Invalid proxy type.'], 422);
}
}
$privateKey = PrivateKey::whereTeamId($teamId)->whereUuid($request->private_key_uuid)->first();
if (! $privateKey) {
return response()->json(['message' => 'Private key not found.'], 404);
}
if (ModelsServer::sshEndpointInUse($request->ip, $request->port)) {
return response()->json(['message' => 'A server with this IP/Domain and SSH port is already in use.'], 400);
}
if ($request->instant_validate) {
ModelsServer::throttleValidation($teamId, $request->ip);
}
$proxyType = $request->proxy_type ? str($request->proxy_type)->upper() : ProxyTypes::TRAEFIK->value;
try {
$server = Team::createServerWithinLimit($teamId, [
'name' => $request->name,
'description' => $request->description,
'ip' => $request->ip,
'port' => $request->port,
'user' => $request->user,
'private_key_id' => $privateKey->id,
'team_id' => $teamId,
]);
} catch (ValidationException) {
return response()->json(['message' => 'Server limit reached for your subscription.'], 400);
}
$server->proxy->set('type', $proxyType);
$server->proxy->set('status', ProxyStatus::EXITED->value);
$server->save();
$server->settings()->update([
'server_role' => $serverRole,
'is_build_server' => $serverRole === ServerRole::BUILD,
]);
if ($request->instant_validate) {
ValidateServer::dispatch($server);
}
auditLog('api.server.created', [
'team_id' => $teamId,
'server_uuid' => $server->uuid,
'server_name' => $server->name,
'ip' => $server->ip,
'server_role' => $serverRole->value,
]);
return response()->json([
'uuid' => $server->uuid,
])->setStatusCode(201);
}
#[OA\Patch(
summary: 'Update',
description: 'Update Server. Changing proxy_type needs a token with the write and deploy permissions.',
path: '/servers/{uuid}',
operationId: 'update-server-by-uuid',
security: [
['bearerAuth' => []],
],
tags: ['Servers'],
parameters: [
new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Server UUID', schema: new OA\Schema(type: 'string')),
],
requestBody: new OA\RequestBody(
required: true,
description: 'Server updated.',
content: new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
properties: [
'name' => ['type' => 'string', 'description' => 'The name of the server. Must not contain control characters such as new lines.'],
'description' => ['type' => 'string', 'description' => 'The description of the server.'],
'ip' => ['type' => 'string', 'description' => 'The IP of the server.'],
'port' => ['type' => 'integer', 'description' => 'The port of the server.'],
'user' => ['type' => 'string', 'description' => 'The user of the server.'],
'private_key_uuid' => ['type' => 'string', 'description' => 'The UUID of the private key.'],
'server_role' => ['type' => 'string', 'enum' => ['deployment', 'build', 'both'], 'description' => 'Server role.'],
'is_build_server' => ['type' => 'boolean', 'deprecated' => true, 'description' => 'Deprecated: use server_role instead. true sets server_role to build, false changes a build server to both and leaves other roles unchanged. Must not conflict with server_role.'],
'instant_validate' => ['type' => 'boolean', 'description' => 'Instant validate.'],
'proxy_type' => ['type' => 'string', 'enum' => ['traefik', 'caddy', 'none'], 'description' => 'The proxy type. Changing it starts the new proxy on the server and needs the deploy permission.'],
'concurrent_builds' => ['type' => 'integer', 'description' => 'Number of concurrent builds.'],
'dynamic_timeout' => ['type' => 'integer', 'description' => 'Deployment timeout in seconds.'],
'deployment_queue_limit' => ['type' => 'integer', 'description' => 'Maximum number of queued deployments.'],
'server_disk_usage_notification_threshold' => ['type' => 'integer', 'description' => 'Server disk usage notification threshold (%).'],
'server_disk_usage_check_frequency' => ['type' => 'string', 'description' => 'Cron expression for disk usage check frequency.'],
'server_disk_usage_notification_interval_hours' => ['type' => 'integer', 'description' => 'Minimum hours between high disk usage notifications (1-720). Default: 24.'],
'connection_timeout' => ['type' => 'integer', 'description' => 'SSH connection timeout in seconds (1-300). Default: 10.'],
'wildcard_domain' => ['type' => 'string', 'nullable' => true, 'description' => 'Wildcard domain (URL) used to generate domains for new resources on this server. Null removes it.'],
'server_timezone' => ['type' => 'string', 'description' => 'Timezone of the server, for example Europe/Berlin. Scheduled tasks and backups use it.'],
'backup_compression_cpu_percentage' => ['type' => 'integer', 'enum' => [25, 50, 75, 100], 'description' => 'Maximum CPU percentage that backup compression can use. Default: 25.'],
],
),
),
),
responses: [
new OA\Response(response: 403, description: 'The token needs the deploy permission to change the proxy type.'),
new OA\Response(
response: 201,
description: 'Server updated.',
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
ref: '#/components/schemas/Server'
)
),
]),
new OA\Response(
response: 401,
ref: '#/components/responses/401',
),
new OA\Response(
response: 400,
ref: '#/components/responses/400',
),
new OA\Response(
response: 404,
ref: '#/components/responses/404',
),
new OA\Response(
response: 422,
ref: '#/components/responses/422',
),
]
)]
public function update_server(Request $request)
{
$allowedFields = ['name', 'description', 'ip', 'port', 'user', 'private_key_uuid', 'server_role', 'is_build_server', 'instant_validate', 'proxy_type', 'concurrent_builds', 'dynamic_timeout', 'deployment_queue_limit', 'server_disk_usage_notification_threshold', 'server_disk_usage_check_frequency', 'server_disk_usage_notification_interval_hours', 'connection_timeout', 'is_terminal_enabled', 'wildcard_domain', 'server_timezone', 'backup_compression_cpu_percentage'];
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$return = validateIncomingRequest($request);
if ($return instanceof JsonResponse) {
return $return;
}
$validator = customApiValidator($request->all(), [
'name' => ['string', 'max:255', 'nullable', 'not_regex:'.self::SERVER_NAME_CONTROL_CHARACTERS_PATTERN],
'description' => 'string|nullable',
'ip' => ['string', 'nullable', new ValidServerIp],
'port' => 'integer|nullable|between:1,65535',
'private_key_uuid' => 'string|nullable',
'user' => ValidationPatterns::serverUsernameRules(required: false),
'server_role' => 'string|nullable|in:deployment,build,both',
'is_build_server' => 'boolean|nullable',
'instant_validate' => 'boolean|nullable',
'proxy_type' => 'string|nullable',
'concurrent_builds' => 'integer|min:1',
'dynamic_timeout' => 'integer|min:1',
'deployment_queue_limit' => 'integer|min:1',
'server_disk_usage_notification_threshold' => 'integer|min:1|max:100',
'server_disk_usage_check_frequency' => 'string',
'server_disk_usage_notification_interval_hours' => 'integer|min:1|max:720',
'connection_timeout' => 'integer|min:1|max:300',
'is_terminal_enabled' => 'boolean|nullable',
'wildcard_domain' => 'nullable|url',
'server_timezone' => ['bail', 'filled', 'string', function (string $attribute, mixed $value, \Closure $fail): void {
if (! is_string($value) || ! validate_timezone($value)) {
$fail('The server_timezone must be a valid timezone.');
}
}],
'backup_compression_cpu_percentage' => 'integer|in:25,50,75,100',
], [
...ValidationPatterns::serverUsernameMessages(),
'name.not_regex' => 'The name must not contain control characters such as new lines.',
]);
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
if ($validator->fails() || ! empty($extraFields)) {
$errors = $validator->errors();
if (! empty($extraFields)) {
foreach ($extraFields as $field) {
$errors->add($field, 'This field is not allowed.');
}
}
return response()->json([
'message' => 'Validation failed.',
'errors' => $errors,
], 422);
}
$server = ModelsServer::whereTeamId($teamId)->whereUuid($request->uuid)->first();
if (! $server) {
return response()->json(['message' => 'Server not found.'], 404);
}
$this->authorize('update', $server);
if ($server->isTransferredAway() && ($connectionErrors = $this->transferredServerConnectionChanges($request, $server))) {
return response()->json([
'message' => 'Validation failed.',
'errors' => $connectionErrors,
], 422);
}
$newIp = $request->filled('ip') ? $request->input('ip') : $server->getRawOriginal('ip');
$newPort = $request->filled('port') ? (int) $request->input('port') : (int) $server->port;
$endpointChanged = ModelsServer::normalizeIp($newIp) !== ModelsServer::normalizeIp($server->getRawOriginal('ip'))
|| $newPort !== (int) $server->port;
if ($endpointChanged && ModelsServer::sshEndpointInUse($newIp, $newPort, $server->id)) {
return response()->json(['message' => 'A server with this IP/Domain and SSH port is already in use.'], 400);
}
if ($request->instant_validate) {
ModelsServer::throttleValidation($teamId, $request->ip ?? $server->ip);
}
if ($request->proxy_type) {
$validProxyTypes = collect(ProxyTypes::cases())->map(function ($proxyType) {
return str($proxyType->value)->lower();
});
if (! $validProxyTypes->contains(str($request->proxy_type)->lower())) {
return response()->json(['message' => 'Invalid proxy type.'], 422);
}
}
$updateFields = $request->only(['name', 'description', 'ip', 'port', 'user']);
if ($request->filled('private_key_uuid')) {
$privateKey = PrivateKey::whereTeamId($teamId)->whereUuid($request->private_key_uuid)->first();
if (! $privateKey) {
return response()->json(['message' => 'Private key not found.'], 404);
}
$updateFields['private_key_id'] = $privateKey->id;
}
if ($request->has('server_disk_usage_check_frequency') && ! validate_cron_expression($request->server_disk_usage_check_frequency)) {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['server_disk_usage_check_frequency' => ['Invalid Cron / Human expression for Disk Usage Check Frequency.']],
], 422);
}
$legacyRoleConflict = $this->legacyBuildServerRoleConflict($request);
if ($legacyRoleConflict) {
return $legacyRoleConflict;
}
$serverRole = null;
if ($request->filled('server_role')) {
$serverRole = ServerRole::from($request->string('server_role')->toString());
} elseif ($request->filled('is_build_server')) {
// Legacy flag: true makes the server build only, false turns a build only server back into a regular one.
if ($request->boolean('is_build_server')) {
$serverRole = ServerRole::BUILD;
} elseif ($server->isBuildServer()) {
$serverRole = ServerRole::BOTH;
}
}
if ($serverRole === ServerRole::BUILD && ! $server->isBuildServer() && ! $server->isEmpty()) {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['server_role' => ['A server with existing resources cannot be configured as build only.']],
], 422);
}
if ($serverRole === ServerRole::DEPLOYMENT && $server->hasEnabledGithubRunners()) {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['server_role' => ['Disable the GitHub runners before you set this server to deployments only.']],
], 422);
}
if ($serverRole === ServerRole::DEPLOYMENT && ! ModelsServer::buildServers($teamId)->whereKeyNot($server->id)->exists()) {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['server_role' => ['Add a usable build server before you set this server to deployments only.']],
], 422);
}
$proxyTypeChanged = $request->proxy_type && ! str($request->proxy_type)->lower()->exactly(str($server->proxyType())->lower());
if ($proxyTypeChanged && ($response = $this->deployAbilityForbiddenResponse($request, 'Changing the proxy type starts the new proxy on the server, so it needs a token with the deploy permission.'))) {
return $response;
}
$server->update($updateFields);
if ($serverRole !== null) {
$server->settings()->update([
'server_role' => $serverRole,
'is_build_server' => $serverRole === ServerRole::BUILD,
]);
}
if ($request->has('is_terminal_enabled')) {
$server->settings()->update([
'is_terminal_enabled' => $request->boolean('is_terminal_enabled'),
]);
}
$advancedSettings = $request->only(['concurrent_builds', 'dynamic_timeout', 'deployment_queue_limit', 'server_disk_usage_notification_threshold', 'server_disk_usage_check_frequency', 'server_disk_usage_notification_interval_hours', 'connection_timeout', 'server_timezone', 'backup_compression_cpu_percentage']);
if (! empty($advancedSettings)) {
$server->settings()->update(array_filter($advancedSettings, fn ($value) => ! is_null($value)));
}
if ($request->has('wildcard_domain')) {
$server->settings()->update(['wildcard_domain' => $request->input('wildcard_domain')]);
}
if ($proxyTypeChanged) {
$server->changeProxy($request->proxy_type, async: true);
}
if ($request->instant_validate) {
ValidateServer::dispatch($server);
}
auditLog('api.server.updated', [
'team_id' => $teamId,
'server_uuid' => $server->uuid,
'server_name' => $server->name,
'changed_fields' => array_values(array_intersect($allowedFields, array_keys($request->all()))),
]);
return response()->json([
'uuid' => $server->uuid,
])->setStatusCode(201);
}
#[OA\Delete(
summary: 'Delete',
description: 'Delete server by UUID. With force or delete_from_provider, needs a token with the write and deploy permissions.',
path: '/servers/{uuid}',
operationId: 'delete-server-by-uuid',
security: [
['bearerAuth' => []],
],
tags: ['Servers'],
parameters: [
new OA\Parameter(
name: 'uuid',
in: 'path',
description: 'UUID of the server.',
required: true,
schema: new OA\Schema(
type: 'string',
)
),
new OA\Parameter(
name: 'force',
in: 'query',
description: 'Also delete all resources whose main destination is on the server. Applications that use the server only as an additional destination are not deleted. They are removed from the server and keep running on their other servers. Needs the deploy permission.',
required: false,
schema: new OA\Schema(type: 'boolean', default: false)
),
new OA\Parameter(
name: 'delete_from_provider',
in: 'query',
description: 'Also delete the server from its cloud provider (Hetzner, Vultr or DigitalOcean). This cannot be undone. Needs the deploy permission.',
required: false,
schema: new OA\Schema(type: 'boolean', default: false)
),
],
responses: [
new OA\Response(response: 403, description: 'The token needs the deploy permission to delete the resources or the cloud server.'),
new OA\Response(
response: 200,
description: 'Server deleted.',
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
properties: [
'message' => ['type' => 'string', 'example' => 'Server deleted.'],
]
)
),
]),
new OA\Response(
response: 401,
ref: '#/components/responses/401',
),
new OA\Response(
response: 400,
ref: '#/components/responses/400',
),
new OA\Response(
response: 404,
ref: '#/components/responses/404',
),
new OA\Response(
response: 422,
ref: '#/components/responses/422',
),
]
)]
public function delete_server(Request $request)
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
if (! $request->uuid) {
return response()->json(['message' => 'Uuid is required.'], 422);
}
$server = ModelsServer::whereTeamId($teamId)->whereUuid($request->uuid)->first();
if (! $server) {
return response()->json(['message' => 'Server not found.'], 404);
}
$this->authorize('delete', $server);
$force = filter_var($request->query('force', false), FILTER_VALIDATE_BOOLEAN);
$deleteFromProvider = filter_var($request->query('delete_from_provider', false), FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
if (is_null($deleteFromProvider)) {
return response()->json(['message' => 'delete_from_provider must be a boolean.'], 422);
}
// Another Coolify instance manages a transferred server. This instance deletes only its own records:
// never the cloud server, the containers, or the DNS records that the other instance uses.
if ($server->isTransferredAway()) {
if ($deleteFromProvider) {
return response()->json(['message' => 'This server is managed by another Coolify instance. Delete its cloud server from that instance.'], 422);
}
app(ServerTransferClaimer::class)->deleteArchive($server);
auditLog('api.server.deleted', ['team_id' => $teamId, 'server_uuid' => $server->uuid, 'server_name' => $server->name, 'ip' => $server->ip, 'archive' => true]);
return response()->json(['message' => 'Server deleted.']);
}
$resources = $server->primaryResources();
if ($resources->isNotEmpty() && ! $force) {
return response()->json(['message' => 'Server has resources. Use ?force=true to delete all resources and the server, or delete resources manually first.'], 400);
}
if ($server->is_coolify_host) {
return response()->json(['message' => 'Local server cannot be deleted.'], 400);
}
if ($deleteFromProvider) {
$linkedProviders = array_keys(array_filter([
'hetzner' => $server->hetzner_server_id,
'vultr' => $server->vultr_instance_id,
'digitalocean' => $server->digitalocean_droplet_id,
]));
if (empty($linkedProviders)) {
return response()->json(['message' => 'Server is not linked to a cloud provider.'], 422);
}
foreach ($linkedProviders as $provider) {
if (! CloudProviderToken::whereTeamId($server->team_id)->whereProvider($provider)->exists()) {
return response()->json(['message' => "No {$provider} token found for this team. Add one before deleting the server from the cloud provider."], 422);
}
}
}
if (($force || $deleteFromProvider) && ($response = $this->deployAbilityForbiddenResponse($request, 'Deleting the resources or the cloud server needs a token with the deploy permission.'))) {
return $response;
}
// Applications that use the server only as an additional destination keep running on their other servers.
$server->detachAdditionalApplications();
if ($force) {
foreach ($resources as $resource) {
DeleteResourceJob::dispatch($resource);
}
}
$deletedUuid = $server->uuid;
$deletedName = $server->name;
$deletedIp = $server->ip;
$server->delete();
DeleteServer::dispatch(
$server->id,
$deleteFromProvider,
$server->hetzner_server_id,
$server->cloud_provider_token_id,
$server->team_id,
$deleteFromProvider,
$server->vultr_instance_id,
$deleteFromProvider,
$server->digitalocean_droplet_id
);
auditLog('api.server.deleted', [
'team_id' => $teamId,
'server_uuid' => $deletedUuid,
'server_name' => $deletedName,
'ip' => $deletedIp,
'force' => $force,
'delete_from_provider' => $deleteFromProvider,
]);
return response()->json(['message' => 'Server deleted.']);
}
#[OA\Post(
summary: 'Validate',
description: 'Validate server by UUID. With install=true, needs a token with the write and deploy permissions.',
path: '/servers/{uuid}/validate',
operationId: 'validate-server-by-uuid',
security: [
['bearerAuth' => []],
],
tags: ['Servers'],
parameters: [
new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Server UUID', schema: new OA\Schema(type: 'string')),
],
requestBody: new OA\RequestBody(
required: false,
content: new OA\JsonContent(
properties: [
new OA\Property(
property: 'install',
description: 'Install missing prerequisites and Docker, then start the proxy. This can restart the Docker daemon. Needs the deploy permission.',
type: 'boolean',
default: false,
),
],
),
),
responses: [
new OA\Response(response: 403, description: 'The token needs the deploy permission to install the server.'),
new OA\Response(
response: 201,
description: 'Server validation started.',
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
properties: [
'message' => ['type' => 'string', 'example' => 'Validation started.'],
]
)
),
]),
new OA\Response(
response: 401,
ref: '#/components/responses/401',
),
new OA\Response(
response: 400,
ref: '#/components/responses/400',
),
new OA\Response(
response: 404,
ref: '#/components/responses/404',
),
new OA\Response(
response: 422,
ref: '#/components/responses/422',
),
]
)]
public function validate_server(Request $request)
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
if (! $request->uuid) {
return response()->json(['message' => 'Uuid is required.'], 422);
}
$server = ModelsServer::whereTeamId($teamId)->whereUuid($request->uuid)->first();
if (! $server) {
return response()->json(['message' => 'Server not found.'], 404);
}
$this->authorize('update', $server);
if (! $server->canBeValidated()) {
return response()->json([
'message' => 'This server was transferred to another Coolify instance and cannot be revalidated here.',
], 422);
}
$validator = customApiValidator($request->all(), [
'install' => 'boolean',
]);
if ($validator->fails()) {
return response()->json([
'message' => 'Validation failed.',
'errors' => $validator->errors(),
], 422);
}
$install = $request->boolean('install', false);
if ($install && ($response = $this->deployAbilityForbiddenResponse($request, 'Installing the server runs commands on it and starts the proxy, so it needs a token with the deploy permission.'))) {
return $response;
}
ModelsServer::throttleValidation($teamId, $server->ip);
if ($install) {
ValidateAndInstallServerJob::dispatch($server);
} else {
ValidateServer::dispatch($server);
}
auditLog('api.server.validated', [
'team_id' => $teamId,
'server_uuid' => $server->uuid,
'server_name' => $server->name,
'install' => $install,
]);
$message = $install ? 'Validation and installation started.' : 'Validation started.';
return response()->json(['message' => $message], 201);
}
/**
* The host of a domain without the port: "[2a01:4f8::1]:8080" gives "[2a01:4f8::1]", "example.com:3000" gives "example.com".
*/
private static function domainHostWithoutPort(string $fqdn): Stringable
{
$authority = (string) str($fqdn)->replace('http://', '')->replace('https://', '')->explode('/')[0];
if (str_starts_with($authority, '[') && str_contains($authority, ']')) {
return str(substr($authority, 0, strpos($authority, ']') + 1));
}
return str(str($authority)->explode(':')[0]);
}
}