Files

1218 lines
52 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);
}
$foundServer = ModelsServer::whereIp(ModelsServer::normalizeIp($request->ip))->first();
if ($foundServer) {
return response()->json(['message' => 'A server with this IP/Domain 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);
}
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 on the server. 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.']);
}
if ($server->definedResources()->count() > 0 && ! $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;
}
if ($force) {
foreach ($server->definedResources() 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]);
}
}