feat: add database import API and Cloudflare DNS management

Queue database imports from upload, S3, or server paths via REST, and
manage Cloudflare DNS records from integration tokens and domain UIs.
This commit is contained in:
Andras Bacsai
2026-09-09 11:14:22 +02:00
parent c779251bfe
commit ab3b3926aa
48 changed files with 2721 additions and 277 deletions
@@ -0,0 +1,155 @@
<?php
namespace App\Actions\Database;
use App\Enums\ProcessStatus;
use App\Models\S3Storage;
use App\Models\ServiceDatabase;
use App\Models\SwarmDocker;
use App\Rules\SafeWebhookUrl;
use App\Support\DatabaseBackupFileValidator;
use App\Support\DatabaseImport\DatabaseImportCommandBuilder;
use App\Support\DatabaseImport\DatabaseImportException;
use App\Support\DatabaseImport\DatabaseImportSource;
use App\Support\ValidationPatterns;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Lorisleiva\Actions\Concerns\AsAction;
use Spatie\Activitylog\Models\Activity;
class StartDatabaseImport
{
use AsAction;
public const MAX_BYTES = 10 * 1024 * 1024 * 1024;
public function __construct(private readonly DatabaseImportCommandBuilder $commands) {}
public function handle(Model $resource, DatabaseImportSource $source, int $teamId): Activity
{
if (! $this->commands->supports($resource)) {
throw new DatabaseImportException('Database imports are not supported for this database type.');
}
if (! str($resource->status)->startsWith('running')) {
throw new DatabaseImportException('The database must be running before an import can start.');
}
[$server, $container, $network] = $this->target($resource);
$destination = $resource instanceof ServiceDatabase ? $resource->service?->destination : $resource->destination;
if ($destination instanceof SwarmDocker) {
throw new DatabaseImportException('Database imports are not supported for Swarm servers yet.', 501);
}
if (! $server || ! ValidationPatterns::isValidContainerName($container)) {
throw new DatabaseImportException('The database server or container is invalid.', 400);
}
$active = Activity::query()->where('properties->team_id', $teamId)
->where('properties->type_uuid', $resource->uuid)
->where('properties->operation', 'database_import')
->whereIn('properties->status', [ProcessStatus::QUEUED->value, ProcessStatus::IN_PROGRESS->value])
->exists();
if ($active) {
throw new DatabaseImportException('A database import is already running.', 409);
}
$operation = (string) Str::uuid();
$containerPath = "/tmp/restore_{$operation}";
$scriptPath = "/tmp/restore_{$operation}.sh";
$commandList = [];
$cleanup = ['container' => $container, 'containerTmpPath' => $containerPath, 'scriptPath' => $scriptPath, 'serverId' => $server->id];
if ($source->type === 'upload') {
$staged = $source->uploadId
? "upload/imports/{$teamId}/{$resource->uuid}/{$source->uploadId}/restore"
: "upload/{$resource->uuid}/restore";
if (! Storage::exists($staged)) {
throw new DatabaseImportException('The completed upload was not found.');
}
$local = Storage::path($staged);
if ($this->commands->databaseType($resource) === 'postgresql' && DatabaseBackupFileValidator::fileContainsPostgresqlProgramExecution($local)) {
Storage::delete($staged);
throw new DatabaseImportException('The uploaded backup contains disallowed PostgreSQL restore directives.');
}
$serverPath = "/tmp/database-import-{$operation}";
instant_scp($local, $serverPath, $server);
$source->uploadId ? Storage::deleteDirectory(dirname($staged)) : Storage::delete($staged);
$commandList[] = 'docker cp '.escapeshellarg($serverPath).' '.escapeshellarg("{$container}:{$containerPath}");
$commandList[] = 'rm -f '.escapeshellarg($serverPath);
$cleanup['serverTmpPath'] = $serverPath;
} elseif ($source->type === 'server') {
$this->assertServerPath($source->path);
$size = (int) trim((string) instant_remote_process(['stat -c %s -- '.escapeshellarg($source->path)], $server));
if ($size < 1 || $size > self::MAX_BYTES) {
throw new DatabaseImportException('The backup file is empty or exceeds the 10 GiB limit.');
}
$commandList[] = 'docker cp '.escapeshellarg($source->path).' '.escapeshellarg("{$container}:{$containerPath}");
} else {
$storage = S3Storage::ownedByCurrentTeamAPI($teamId)
->where(fn ($query) => $query->whereUuid($source->s3StorageUuid)->orWhere('id', ctype_digit((string) $source->s3StorageUuid) ? (int) $source->s3StorageUuid : -1))
->where('is_usable', true)->first();
if (! $storage || ! ValidationPatterns::isValidS3BucketName($storage->bucket)) {
throw new DatabaseImportException('S3 storage was not found or has an invalid bucket.');
}
$key = ltrim((string) $source->path, '/');
$this->assertS3Path($key);
$disk = Storage::build(['driver' => 's3', 'region' => $storage->region, 'key' => $storage->key, 'secret' => $storage->secret, 'bucket' => $storage->bucket, 'endpoint' => $storage->endpoint, 'use_path_style_endpoint' => true, 'http' => SafeWebhookUrl::httpClientOptions($storage->endpoint)]);
if (! $disk->exists($key) || $disk->size($key) > self::MAX_BYTES) {
throw new DatabaseImportException('The S3 backup was not found or exceeds the 10 GiB limit.');
}
$helper = "s3-restore-{$operation}";
$serverPath = "/tmp/s3-restore-{$operation}";
$sourceArg = escapeshellarg("s3temp/{$storage->bucket}/{$key}");
$commandList = [
'docker rm -f '.escapeshellarg($helper).' 2>/dev/null || true',
'docker run -d --network '.escapeshellarg($network).' --name '.escapeshellarg($helper).' '.escapeshellarg(coolifyHelperImage().':'.getHelperVersion()).' sleep 3600',
'docker exec '.escapeshellarg($helper).' mc alias set s3temp '.escapeshellarg($storage->endpoint).' '.escapeshellarg($storage->key).' '.escapeshellarg($storage->secret),
'docker exec '.escapeshellarg($helper).' mc cp '.$sourceArg.' /tmp/restore',
'docker cp '.escapeshellarg("{$helper}:/tmp/restore").' '.escapeshellarg($serverPath),
'docker cp '.escapeshellarg($serverPath).' '.escapeshellarg("{$container}:{$containerPath}"),
'docker rm -f '.escapeshellarg($helper).' 2>/dev/null || true',
'rm -f '.escapeshellarg($serverPath),
];
$cleanup += ['containerName' => $helper, 'serverTmpPath' => $serverPath];
}
if ($safety = $this->commands->buildPostgresSafetyCommand($resource, $container, $containerPath)) {
$commandList[] = $safety;
}
$restore = base64_encode($this->commands->buildRestoreCommand($resource, $containerPath, $source->dumpAll));
$commandList[] = 'echo '.escapeshellarg($restore).' | base64 -d > '.escapeshellarg($scriptPath);
$commandList[] = 'chmod +x '.escapeshellarg($scriptPath);
$commandList[] = 'docker cp '.escapeshellarg($scriptPath).' '.escapeshellarg("{$container}:{$scriptPath}");
$commandList[] = 'rm -f '.escapeshellarg($scriptPath);
$commandList[] = 'docker exec '.escapeshellarg($container).' sh -c '.escapeshellarg($scriptPath);
$activity = remote_process($commandList, $server, type_uuid: $resource->uuid, model: $resource, callEventOnFinish: 'DatabaseImportFinished', callEventData: $cleanup);
$activity->properties = $activity->properties->merge(['operation' => 'database_import', 'resource_kind' => $resource instanceof ServiceDatabase ? 'service_database' : 'standalone_database', 'operation_uuid' => $operation]);
$activity->save();
return $activity;
}
private function target(Model $resource): array
{
if ($resource instanceof ServiceDatabase) {
return [$resource->service?->server, $resource->name.'-'.$resource->service?->uuid, $resource->service?->destination?->network ?? 'coolify'];
}
return [$resource->destination?->server, $resource->uuid, $resource->destination?->network ?? 'coolify'];
}
private function assertServerPath(?string $path): void
{
if (! $path || ! str_starts_with($path, '/') || preg_match('/\.\.|[$()`|;&><\r\n\0\'"\\\\]/', $path) || ! DatabaseBackupFileValidator::hasAllowedExtension(basename($path))) {
throw new DatabaseImportException('The server path is invalid.');
}
}
private function assertS3Path(string $path): void
{
if ($path === '' || preg_match('/\.\.|[$()`|;&><\r\n\0\'"\\\\]/', $path) || ! DatabaseBackupFileValidator::hasAllowedExtension(basename($path))) {
throw new DatabaseImportException('The S3 path is invalid.');
}
}
}
+34
View File
@@ -0,0 +1,34 @@
<?php
namespace App\Events;
use App\Models\Server;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class DatabaseImportFinished
{
use Dispatchable, SerializesModels;
public function __construct(array $data)
{
$commands = [];
if (filled($data['containerName'] ?? null)) {
$commands[] = 'docker rm -f '.escapeshellarg($data['containerName']).' 2>/dev/null || true';
}
if (isSafeTmpPath($data['serverTmpPath'] ?? null)) {
$commands[] = 'rm -f '.escapeshellarg($data['serverTmpPath']).' 2>/dev/null || true';
}
if (filled($data['container'] ?? null)) {
foreach (['containerTmpPath', 'scriptPath'] as $key) {
if (isSafeTmpPath($data[$key] ?? null)) {
$commands[] = 'docker exec '.escapeshellarg($data['container']).' rm -f '.escapeshellarg($data[$key]).' 2>/dev/null || true';
}
}
}
$server = Server::find($data['serverId'] ?? null);
if ($server && $commands !== []) {
instant_remote_process($commands, $server, throwError: false);
}
}
}
@@ -0,0 +1,28 @@
<?php
namespace App\Events;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class DnsRecordConfigurationFinished implements ShouldBroadcastNow
{
use Dispatchable, SerializesModels;
public function __construct(
public int $teamId,
public ?string $resourceType,
public int|string|null $resourceId,
public string $hostname,
public bool $successful,
public string $credential,
public string $message,
) {}
public function broadcastOn(): array
{
return [new PrivateChannel("team.{$this->teamId}")];
}
}
@@ -0,0 +1,16 @@
<?php
namespace App\Exceptions;
use RuntimeException;
class DnsRecordConflictException extends RuntimeException
{
public function __construct(
public readonly string $providerRecordId,
public readonly string $currentValue,
public readonly string $proposedValue,
) {
parent::__construct("DNS record already points to {$currentValue}.");
}
}
@@ -0,0 +1,120 @@
<?php
namespace App\Http\Controllers\Api\Concerns;
use App\Actions\CoolifyTask\RunRemoteProcess;
use App\Actions\Database\StartDatabaseImport;
use App\Support\DatabaseBackupFileValidator;
use App\Support\DatabaseImport\DatabaseImportException;
use App\Support\DatabaseImport\DatabaseImportSource;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
use Pion\Laravel\ChunkUpload\Handler\HandlerFactory;
use Pion\Laravel\ChunkUpload\Receiver\FileReceiver;
use Spatie\Activitylog\Models\Activity;
trait HandlesDatabaseImportsApi
{
protected function uploadDatabaseImport(Request $request, Model $resource, int $teamId): JsonResponse
{
$this->authorize('uploadBackup', $resource);
$validator = Validator::make($request->all(), ['upload_id' => ['required', 'uuid'], 'file' => ['required', 'file']]);
if ($validator->fails()) {
return response()->json(['message' => 'Validation failed.', 'errors' => $validator->errors()], 422);
}
$originalName = $request->file('file')?->getClientOriginalName();
if (! $originalName || ! DatabaseBackupFileValidator::hasAllowedExtension($originalName)) {
return response()->json(['message' => 'Validation failed.', 'errors' => ['file' => ['Unsupported backup file extension.']]], 422);
}
if ((int) $request->input('dzTotalFilesize', 0) > StartDatabaseImport::MAX_BYTES) {
return response()->json(['message' => 'Validation failed.', 'errors' => ['file' => ['The backup exceeds the 10 GiB limit.']]], 422);
}
$request->merge(['dzuuid' => $request->input('dzuuid', $request->string('upload_id')->value())]);
$receiver = new FileReceiver('file', $request, HandlerFactory::classFromRequest($request));
$save = $receiver->receive();
if (! $save->isFinished()) {
return response()->json(['upload_id' => $request->string('upload_id')->value(), 'done' => $save->handler()->getPercentageDone(), 'status' => true]);
}
$file = $save->getFile();
if (! $file instanceof UploadedFile || ! DatabaseBackupFileValidator::isUploadAllowed($file, StartDatabaseImport::MAX_BYTES)) {
@unlink($file->getPathname());
return response()->json(['message' => 'Validation failed.', 'errors' => ['file' => ['Uploaded file failed validation.']]], 422);
}
$mimeType = $file->getMimeType();
$size = $file->getSize();
$directory = "upload/imports/{$teamId}/{$resource->uuid}/{$request->string('upload_id')->value()}";
Storage::makeDirectory($directory);
$file->move(Storage::path($directory), 'restore');
return response()->json(['upload_id' => $request->string('upload_id')->value(), 'filename' => $originalName, 'mime_type' => $mimeType, 'size' => $size], 201);
}
protected function startDatabaseImport(Request $request, Model $resource, int $teamId, string $statusRoute, array $routeParameters): JsonResponse
{
$this->authorize('update', $resource);
$payload = $request->json()->all() ?: $request->request->all();
$allowed = ['source', 'upload_id', 's3_storage_uuid', 'path', 'dump_all'];
$validator = Validator::make($payload, [
'source' => ['required', Rule::in(['upload', 's3', 'server'])],
'upload_id' => ['required_if:source,upload', 'prohibited_unless:source,upload', 'uuid'],
's3_storage_uuid' => ['required_if:source,s3', 'prohibited_unless:source,s3', 'string'],
'path' => ['required_if:source,s3,server', 'prohibited_if:source,upload', 'string', 'max:4096'],
'dump_all' => ['sometimes', 'boolean'],
]);
foreach (array_diff(array_keys($payload), $allowed) as $field) {
$validator->errors()->add($field, 'This field is not allowed.');
}
if ($validator->fails() || $validator->errors()->isNotEmpty()) {
return response()->json(['message' => 'Validation failed.', 'errors' => $validator->errors()], 422);
}
try {
$source = new DatabaseImportSource((string) $payload['source'], $payload['upload_id'] ?? null, $payload['path'] ?? null, $payload['s3_storage_uuid'] ?? null, (bool) ($payload['dump_all'] ?? false));
$activity = app(StartDatabaseImport::class)->handle($resource, $source, $teamId);
} catch (DatabaseImportException $exception) {
return response()->json(['message' => $exception->getMessage()], $exception->status);
}
auditLog('api.database.import_started', [
'team_id' => $teamId,
'database_uuid' => $resource->uuid,
'database_name' => $resource->name,
'source' => $source->type,
'activity_id' => $activity->id,
]);
$url = route($statusRoute, [...$routeParameters, 'activity_id' => $activity->id], false);
return response()->json(['id' => $activity->id, 'status' => data_get($activity, 'properties.status'), 'message' => 'Database import queued.', 'status_url' => $url], 202)->header('Location', $url);
}
protected function showDatabaseImport(Model $resource, int $teamId, int $activityId): JsonResponse
{
$this->authorize('view', $resource);
$activity = Activity::query()->whereKey($activityId)
->where('properties->team_id', $teamId)
->where('properties->type_uuid', $resource->uuid)
->where('properties->operation', 'database_import')->first();
if (! $activity) {
return response()->json(['message' => 'Database import not found.'], 404);
}
$status = data_get($activity, 'properties.status');
$terminal = in_array($status, ['finished', 'error', 'killed', 'cancelled', 'closed'], true);
return response()->json([
'id' => $activity->id,
'status' => $status,
'exit_code' => data_get($activity, 'properties.exitCode'),
'output' => remove_iip(RunRemoteProcess::decodeOutput($activity)),
'created_at' => $activity->created_at,
'updated_at' => $activity->updated_at,
'finished_at' => $terminal ? $activity->updated_at : null,
]);
}
}
@@ -32,8 +32,36 @@ use OpenApi\Attributes as OA;
class DatabasesController extends Controller
{
use Concerns\HandlesDatabaseImportsApi;
use Concerns\HandlesTagsApi;
#[OA\Post(path: '/databases/{uuid}/imports/uploads', operationId: 'upload-database-import', summary: 'Upload database import', security: [['bearerAuth' => []]], tags: ['Databases'], responses: [new OA\Response(response: 201, description: 'Upload completed'), new OA\Response(response: 422, ref: '#/components/responses/422')])]
public function upload_import(Request $request, string $uuid): JsonResponse
{
$teamId = getTeamIdFromToken();
$database = $teamId === null ? null : queryDatabaseByUuidWithinTeam($uuid, $teamId);
return $database ? $this->uploadDatabaseImport($request, $database, $teamId) : response()->json(['message' => 'Database not found.'], 404);
}
#[OA\Post(path: '/databases/{uuid}/imports', operationId: 'create-database-import', summary: 'Import database backup', requestBody: new OA\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/DatabaseImportRequest')), security: [['bearerAuth' => []]], tags: ['Databases'], responses: [new OA\Response(response: 202, description: 'Import queued'), new OA\Response(response: 409, description: 'Import already active'), new OA\Response(response: 422, ref: '#/components/responses/422')])]
public function create_import(Request $request, string $uuid): JsonResponse
{
$teamId = getTeamIdFromToken();
$database = $teamId === null ? null : queryDatabaseByUuidWithinTeam($uuid, $teamId);
return $database ? $this->startDatabaseImport($request, $database, $teamId, 'api.databases.imports.show', ['uuid' => $uuid]) : response()->json(['message' => 'Database not found.'], 404);
}
#[OA\Get(path: '/databases/{uuid}/imports/{activity_id}', operationId: 'get-database-import', summary: 'Get database import status', security: [['bearerAuth' => []]], tags: ['Databases'], responses: [new OA\Response(response: 200, description: 'Import status'), new OA\Response(response: 404, ref: '#/components/responses/404')])]
public function show_import(Request $request, string $uuid, int $activity_id): JsonResponse
{
$teamId = getTeamIdFromToken();
$database = $teamId === null ? null : queryDatabaseByUuidWithinTeam($uuid, $teamId);
return $database ? $this->showDatabaseImport($database, $teamId, $activity_id) : response()->json(['message' => 'Database not found.'], 404);
}
protected function findTaggableResource(string $uuid, int|string $teamId): mixed
{
return queryDatabaseByUuidWithinTeam($uuid, $teamId);
+25
View File
@@ -12,6 +12,31 @@ use OpenApi\Attributes as OA;
securityScheme: 'bearerAuth',
description: 'Go to `Keys & Tokens` / `API tokens` and create a new token. Use the token as the bearer token.')]
#[OA\Components(
schemas: [
new OA\Schema(
schema: 'DatabaseImportRequest',
oneOf: [
new OA\Schema(required: ['source', 'upload_id'], properties: [new OA\Property(property: 'source', type: 'string', enum: ['upload']), new OA\Property(property: 'upload_id', type: 'string', format: 'uuid'), new OA\Property(property: 'dump_all', type: 'boolean', default: false)]),
new OA\Schema(required: ['source', 's3_storage_uuid', 'path'], properties: [new OA\Property(property: 'source', type: 'string', enum: ['s3']), new OA\Property(property: 's3_storage_uuid', type: 'string'), new OA\Property(property: 'path', type: 'string'), new OA\Property(property: 'dump_all', type: 'boolean', default: false)]),
new OA\Schema(required: ['source', 'path'], properties: [new OA\Property(property: 'source', type: 'string', enum: ['server']), new OA\Property(property: 'path', type: 'string', example: '/var/backups/database.sql.gz'), new OA\Property(property: 'dump_all', type: 'boolean', default: false)]),
],
type: 'object',
additionalProperties: false,
),
new OA\Schema(
schema: 'DatabaseImportStatus',
type: 'object',
properties: [
new OA\Property(property: 'id', type: 'integer'),
new OA\Property(property: 'status', type: 'string', enum: ['queued', 'in_progress', 'finished', 'error', 'killed', 'cancelled', 'closed']),
new OA\Property(property: 'exit_code', type: 'integer', nullable: true),
new OA\Property(property: 'output', type: 'string'),
new OA\Property(property: 'created_at', type: 'string', format: 'date-time'),
new OA\Property(property: 'updated_at', type: 'string', format: 'date-time'),
new OA\Property(property: 'finished_at', type: 'string', format: 'date-time', nullable: true),
],
),
],
responses: [
new OA\Response(
response: 400,
@@ -18,6 +18,35 @@ use OpenApi\Attributes as OA;
class ServiceDatabasesController extends Controller
{
use Concerns\HandlesDatabaseImportsApi;
#[OA\Post(path: '/services/{uuid}/databases/{database_uuid}/imports/uploads', operationId: 'upload-service-database-import', summary: 'Upload service database import', security: [['bearerAuth' => []]], tags: ['Service databases'], responses: [new OA\Response(response: 201, description: 'Upload completed'), new OA\Response(response: 422, ref: '#/components/responses/422')])]
public function upload_import(Request $request): JsonResponse
{
return $this->withImportDatabase($request, fn (ServiceDatabase $database, int $teamId) => $this->uploadDatabaseImport($request, $database, $teamId));
}
#[OA\Post(path: '/services/{uuid}/databases/{database_uuid}/imports', operationId: 'create-service-database-import', summary: 'Import service database backup', requestBody: new OA\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/DatabaseImportRequest')), security: [['bearerAuth' => []]], tags: ['Service databases'], responses: [new OA\Response(response: 202, description: 'Import queued'), new OA\Response(response: 409, description: 'Import already active'), new OA\Response(response: 422, ref: '#/components/responses/422')])]
public function create_import(Request $request): JsonResponse
{
return $this->withImportDatabase($request, fn (ServiceDatabase $database, int $teamId) => $this->startDatabaseImport($request, $database, $teamId, 'api.service-databases.imports.show', ['uuid' => $request->route('uuid'), 'database_uuid' => $database->uuid]));
}
#[OA\Get(path: '/services/{uuid}/databases/{database_uuid}/imports/{activity_id}', operationId: 'get-service-database-import', summary: 'Get service database import status', security: [['bearerAuth' => []]], tags: ['Service databases'], responses: [new OA\Response(response: 200, description: 'Import status'), new OA\Response(response: 404, ref: '#/components/responses/404')])]
public function show_import(Request $request): JsonResponse
{
return $this->withImportDatabase($request, fn (ServiceDatabase $database, int $teamId) => $this->showDatabaseImport($database, $teamId, (int) $request->route('activity_id')));
}
private function withImportDatabase(Request $request, callable $callback): JsonResponse
{
$teamId = getTeamIdFromToken();
$service = $teamId === null ? null : $this->resolveService($request, $teamId);
$database = $service ? $this->resolveServiceDatabase($request, $service) : null;
return $database ? $callback($database, $teamId) : response()->json(['message' => 'Service database not found.'], 404);
}
private function removeSensitiveData(ServiceDatabase $serviceDatabase): array
{
$serviceDatabase->makeHidden([
+74
View File
@@ -0,0 +1,74 @@
<?php
namespace App\Jobs;
use App\Events\DnsRecordConfigurationFinished;
use App\Models\DnsProviderZone;
use App\Services\Dns\CloudflareDnsProvider;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Queue\SerializesModels;
use Throwable;
class ConfigureDnsRecordJob implements ShouldQueue
{
use Queueable, SerializesModels;
public int $tries = 1;
public int $timeout = 30;
public function __construct(
public int $teamId,
public int $zoneId,
public ?string $resourceType,
public int|string|null $resourceId,
public string $hostname,
public string $content,
) {}
public function handle(CloudflareDnsProvider $provider): void
{
$zone = DnsProviderZone::query()
->with('integrationToken')
->whereKey($this->zoneId)
->whereHas('integrationToken', fn ($query) => $query->where('team_id', $this->teamId))
->firstOrFail();
try {
$provider->createRecord($zone, $this->hostname, $this->content, $this->resource());
DnsRecordConfigurationFinished::dispatch(
$this->teamId,
$this->resourceType,
$this->resourceId,
$this->hostname,
true,
$zone->integrationToken->name,
"DNS record added for {$this->hostname}.",
);
} catch (Throwable $exception) {
DnsRecordConfigurationFinished::dispatch(
$this->teamId,
$this->resourceType,
$this->resourceId,
$this->hostname,
false,
$zone->integrationToken->name,
$exception->getMessage(),
);
}
}
private function resource(): ?Model
{
$resourceClass = $this->resourceType === null ? null : (Relation::getMorphedModel($this->resourceType) ?? $this->resourceType);
if ($resourceClass === null || $this->resourceId === null || ! is_subclass_of($resourceClass, Model::class)) {
return null;
}
return $resourceClass::query()->find($this->resourceId);
}
}
@@ -0,0 +1,237 @@
<?php
namespace App\Livewire\Concerns;
use App\Exceptions\DnsRecordConflictException;
use App\Jobs\ConfigureDnsRecordJob;
use App\Models\DnsProviderZone;
use App\Models\ManagedDnsRecord;
use App\Services\Dns\CloudflareDnsProvider;
use Illuminate\Database\Eloquent\Model;
trait InteractsWithDnsProviders
{
public bool $showDnsProviderModal = false;
public array $dnsProviderProposals = [];
public array $dnsProviderConflicts = [];
public bool $deleteManagedDns = true;
public function openDnsProviderModal(): void
{
$this->authorizeDnsProviderChange();
$this->loadDnsProviderProposals();
if ($this->dnsProviderProposals === []) {
$this->dispatch('error', 'No connected DNS provider can manage the configured domains.');
return;
}
$this->showDnsProviderModal = true;
}
public function closeDnsProviderModal(): void
{
$this->showDnsProviderModal = false;
}
public function createManagedDnsRecord(string $hostname, int $zoneId, ?string $content = null): void
{
$this->authorizeDnsProviderChange();
$cloudflare = app(CloudflareDnsProvider::class);
$zone = $this->findTeamZone($zoneId);
$content ??= $this->serverIp;
if ($zone === null || blank($content) || filter_var($content, FILTER_VALIDATE_IP) === false) {
$this->dispatch('error', 'No connected DNS provider or public server IP is available for this domain.');
return;
}
try {
$cloudflare->createRecord($zone, $hostname, $content, $this->dnsResourceForHostname($hostname));
$this->markDnsManaged($hostname, $zone->integrationToken->name);
$this->dispatch('success', "DNS record created for {$hostname}.");
$this->loadDnsProviderProposals();
} catch (DnsRecordConflictException $e) {
$this->dnsProviderConflicts[$hostname.'|'.$zoneId] = [
'record_id' => $e->providerRecordId, 'current' => $e->currentValue, 'proposed' => $e->proposedValue,
];
} catch (\Throwable $e) {
$this->dispatch('error', $e->getMessage());
}
}
/** @param array<int, string> $urls */
protected function hasDnsProviderForUrls(array $urls): bool
{
$provider = app(CloudflareDnsProvider::class);
return collect($urls)->contains(function (string $url) use ($provider): bool {
$hostname = parse_url($url, PHP_URL_HOST);
return is_string($hostname) && $provider->findZones(currentTeam()->id, $hostname)->isNotEmpty();
});
}
/** @param array<int, string> $urls */
protected function configureDnsAfterDomainAdd(array $urls): bool
{
$hostnames = collect($urls)->map(fn (string $url) => parse_url($url, PHP_URL_HOST))
->filter(fn ($hostname) => is_string($hostname))->map(fn (string $hostname) => strtolower($hostname))
->unique()->values()->all();
$this->loadDnsProviderProposals($hostnames);
if ($this->dnsProviderProposals === []) {
return false;
}
$this->markDnsPending($hostnames);
$proposalsByHostname = collect($this->dnsProviderProposals)->groupBy('hostname');
$canConfigureAutomatically = $proposalsByHostname->every(function ($proposals): bool {
if ($proposals->count() !== 1) {
return false;
}
$zone = $this->findTeamZone((int) $proposals->first()['zone_id']);
return $zone?->integrationToken->automaticDnsEnabled() === true;
});
if (! $canConfigureAutomatically) {
$this->showDnsProviderModal = true;
return true;
}
foreach ($this->dnsProviderProposals as $proposal) {
$zone = $this->findTeamZone((int) $proposal['zone_id']);
if ($zone === null) {
continue;
}
$resource = $this->dnsResourceForHostname($proposal['hostname']);
ConfigureDnsRecordJob::dispatch(
currentTeam()->id,
$zone->id,
$resource?->getMorphClass(),
$resource?->getKey(),
$proposal['hostname'],
$this->serverIp,
);
$this->dispatch('info', "Adding DNS record for {$proposal['hostname']}.");
}
return true;
}
public function openManualDnsRecords(): void
{
$this->authorizeDnsProviderChange();
$this->loadDnsProviderProposals();
$this->dispatch('open-dns-records-modal');
}
public function replaceManagedDnsRecord(string $hostname, int $zoneId, string $password = ''): void
{
$this->authorizeDnsProviderChange();
$key = $hostname.'|'.$zoneId;
$conflict = $this->dnsProviderConflicts[$key] ?? null;
$zone = $this->findTeamZone($zoneId);
if ($conflict === null || $zone === null) {
$this->dispatch('error', 'The DNS conflict is no longer available. Check the record again.');
return;
}
try {
app(CloudflareDnsProvider::class)->replaceRecord(
$zone, $conflict['record_id'], $hostname, $conflict['proposed'], $this->dnsResourceForHostname($hostname),
);
unset($this->dnsProviderConflicts[$key]);
$this->dispatch('success', "DNS record replaced for {$hostname}.");
$this->loadDnsProviderProposals();
} catch (\Throwable $e) {
$this->dispatch('error', $e->getMessage());
}
}
protected function loadDnsProviderProposals(?array $hostnames = null): void
{
$provider = app(CloudflareDnsProvider::class);
$hostnames ??= $this->allDomainHostnames();
$managed = ManagedDnsRecord::query()->where('team_id', currentTeam()->id)->whereIn('name', $hostnames)->pluck('id', 'name');
$this->dnsProviderProposals = collect($hostnames)->flatMap(fn (string $hostname) => $provider->findZones(currentTeam()->id, $hostname)
->map(fn (DnsProviderZone $zone) => [
'hostname' => $hostname, 'zone_id' => $zone->id, 'zone' => $zone->name,
'credential' => $zone->integrationToken->name, 'target' => (string) $this->serverIp,
'managed' => $managed->has($hostname),
])->all())->values()->all();
}
protected function markDnsPending(array $hostnames): void
{
foreach ($this->domainRows as $index => $row) {
$hostname = parse_url((string) ($row['url'] ?? ''), PHP_URL_HOST);
if (is_string($hostname) && in_array(strtolower($hostname), $hostnames, true)) {
$this->domainRows[$index]['dns_status'] = 'pending';
$this->domainRows[$index]['dns_message'] = 'A connected DNS provider can create this record.';
$this->domainRows[$index]['checked_at'] = now()->toIso8601String();
}
}
$this->persistDomainDnsStatuses();
}
protected function markDnsManaged(string $hostname, string $credential): void
{
foreach ($this->domainRows as $index => $row) {
$rowHostname = parse_url((string) ($row['url'] ?? ''), PHP_URL_HOST);
if (is_string($rowHostname) && strtolower($rowHostname) === strtolower($hostname)) {
$this->domainRows[$index]['dns_status'] = 'ok';
$this->domainRows[$index]['dns_message'] = "DNS record created through {$credential}.";
$this->domainRows[$index]['checked_at'] = now()->toIso8601String();
}
}
$this->persistDomainDnsStatuses();
}
public function dnsRecordConfigurationFinished(array $event): void
{
$resource = $this->dnsResourceForHostname($event['hostname']);
if ($resource === null || $resource->getMorphClass() !== $event['resourceType']
|| (string) $resource->getKey() !== (string) $event['resourceId']) {
return;
}
if ($event['successful']) {
$this->markDnsManaged($event['hostname'], $event['credential']);
$this->dispatch('success', $event['message']);
return;
}
$this->dispatch('error', "DNS record could not be added for {$event['hostname']}: {$event['message']}");
}
protected function deleteManagedDnsForUrl(string $url): void
{
$hostname = parse_url($url, PHP_URL_HOST);
if (! is_string($hostname)) {
return;
}
$record = ManagedDnsRecord::query()->where('team_id', currentTeam()->id)->where('name', strtolower($hostname))->first();
if ($record !== null && ! app(CloudflareDnsProvider::class)->deleteRecord($record)) {
$this->dispatch('warning', 'The domain was removed, but its DNS record changed externally and was left untouched.');
}
}
protected function authorizeDnsProviderChange(): void
{
$this->authorize('update', property_exists($this, 'application') ? $this->application : $this->service);
}
protected function findTeamZone(int $zoneId): ?DnsProviderZone
{
return DnsProviderZone::query()->whereKey($zoneId)
->whereHas('integrationToken', fn ($query) => $query->where('team_id', currentTeam()->id))->first();
}
abstract protected function dnsResourceForHostname(string $hostname): ?Model;
}
+28 -3
View File
@@ -5,12 +5,14 @@ namespace App\Livewire\Project\Application;
use App\Actions\Shared\CheckDomainDns;
use App\Jobs\CheckDomainDnsJob;
use App\Livewire\Concerns\InteractsWithCloudflareDomainConnect;
use App\Livewire\Concerns\InteractsWithDnsProviders;
use App\Livewire\Project\Shared\ConfigurationChecker;
use App\Models\Application;
use App\Models\Server;
use App\Support\DomainPortOverrides;
use App\Support\DomainUrlParts;
use App\Support\ValidationPatterns;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
@@ -20,6 +22,7 @@ class Domains extends Component
{
use AuthorizesRequests;
use InteractsWithCloudflareDomainConnect;
use InteractsWithDnsProviders;
protected bool $notifyRedirectUpdate = true;
@@ -117,6 +120,13 @@ class Domains extends Component
'confirmDomainUsage',
];
public function getListeners(): array
{
return array_merge($this->listeners, [
'echo-private:team.'.currentTeam()->id.',DnsRecordConfigurationFinished' => 'dnsRecordConfigurationFinished',
]);
}
protected function rules(): array
{
return [
@@ -1058,8 +1068,14 @@ class Domains extends Component
$this->resetAddDomainForm();
$this->dispatch('close-modal');
$this->refreshDomains();
$urlsToCheck = array_values(array_unique(array_merge($newUrls, $pairedUrls)));
$dnsChecks = collect($this->dnsEntriesForUrls($urlsToCheck, $serviceForCheck))
$addedUrls = array_values(array_unique(array_merge($newUrls, $pairedUrls)));
if ($this->configureDnsAfterDomainAdd($addedUrls)) {
$this->dispatch('success', 'Domain added.');
return;
}
$dnsChecks = collect($this->dnsEntriesForUrls($addedUrls, $serviceForCheck))
->map(fn (string $url, string $statusKey) => [
'status_key' => $statusKey,
'url' => $url,
@@ -1480,7 +1496,7 @@ class Domains extends Component
}
}
public function removeDomain(int $index): void
public function removeDomain(int $index, string $password = '', array $selectedActions = []): void
{
try {
$this->authorize('update', $this->application);
@@ -1503,6 +1519,10 @@ class Domains extends Component
return;
}
if (in_array('deleteManagedDns', $selectedActions, true)) {
$this->deleteManagedDnsForUrl($url);
}
if ($this->editingIndex === $index) {
$this->cancelEdit();
}
@@ -1537,6 +1557,11 @@ class Domains extends Component
return hash('sha256', $row['url'].'|'.($row['service'] ?? ''));
}
protected function dnsResourceForHostname(string $hostname): ?Model
{
return $this->application;
}
public function generateDomain(?string $serviceName = null): void
{
try {
+25 -245
View File
@@ -2,6 +2,7 @@
namespace App\Livewire\Project\Database;
use App\Actions\Database\StartDatabaseImport;
use App\Models\S3Storage;
use App\Models\Server;
use App\Models\Service;
@@ -10,12 +11,13 @@ use App\Models\StandaloneClickhouse;
use App\Models\StandaloneDragonfly;
use App\Models\StandaloneKeydb;
use App\Models\StandaloneMariadb;
use App\Models\StandaloneMongodb;
use App\Models\StandaloneMysql;
use App\Models\StandalonePostgresql;
use App\Models\StandaloneRedis;
use App\Rules\SafeWebhookUrl;
use App\Support\DatabaseBackupFileValidator;
use App\Support\DatabaseImport\DatabaseImportCommandBuilder;
use App\Support\DatabaseImport\DatabaseImportException;
use App\Support\DatabaseImport\DatabaseImportSource;
use App\Support\ValidationPatterns;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Storage;
@@ -446,77 +448,21 @@ EOD;
try {
$this->importRunning = true;
$this->importCommands = [];
$backupFileName = "upload/{$this->resourceUuid}/restore";
// Check if an uploaded file exists first (takes priority over custom location)
if (Storage::exists($backupFileName)) {
$path = Storage::path($backupFileName);
// Reject malicious PostgreSQL payloads before transferring the file anywhere.
if ($this->isPostgresqlRestore() && DatabaseBackupFileValidator::fileContainsPostgresqlProgramExecution($path)) {
Storage::delete($backupFileName);
$this->dispatch('error', 'The uploaded backup contains disallowed PostgreSQL restore directives (COPY ... PROGRAM or psql shell commands) and was rejected.');
return true;
}
$tmpPath = '/tmp/'.basename($backupFileName).'_'.$this->resourceUuid;
instant_scp($path, $tmpPath, $this->server);
Storage::delete($backupFileName);
$this->importCommands[] = "docker cp {$tmpPath} {$this->container}:{$tmpPath}";
$this->addRestoreSafetyCheckCommand($this->importCommands, $tmpPath);
} elseif (filled($this->customLocation)) {
// Validate the custom location to prevent command injection
if (! $this->validateServerPath($this->customLocation)) {
$this->dispatch('error', 'Invalid file path. Path must be absolute and contain only safe characters.');
return true;
}
$tmpPath = '/tmp/restore_'.$this->resourceUuid;
$escapedCustomLocation = escapeshellarg($this->customLocation);
$this->importCommands[] = "docker cp {$escapedCustomLocation} {$this->container}:{$tmpPath}";
$this->addRestoreSafetyCheckCommand($this->importCommands, $tmpPath);
} else {
$this->dispatch('error', 'The file does not exist or has been deleted.');
return true;
}
// Copy the restore command to a script file
$scriptPath = "/tmp/restore_{$this->resourceUuid}.sh";
$restoreCommand = $this->buildRestoreCommand($tmpPath);
$restoreCommandBase64 = base64_encode($restoreCommand);
$this->importCommands[] = "echo \"{$restoreCommandBase64}\" | base64 -d > {$scriptPath}";
$this->importCommands[] = "chmod +x {$scriptPath}";
$this->importCommands[] = "docker cp {$scriptPath} {$this->container}:{$scriptPath}";
$this->importCommands[] = "docker exec {$this->container} sh -c '{$scriptPath}'";
$this->importCommands[] = "docker exec {$this->container} sh -c 'echo \"Import finished with exit code $?\"'";
if (! empty($this->importCommands)) {
$activity = remote_process($this->importCommands, $this->server, ignore_errors: true, callEventOnFinish: 'RestoreJobFinished', callEventData: [
'scriptPath' => $scriptPath,
'tmpPath' => $tmpPath,
'container' => $this->container,
'serverId' => $this->server->id,
]);
// Track the activity ID
$this->activityId = $activity->id;
// Dispatch activity to the monitor and open slide-over
$this->dispatch('activityMonitor', $activity->id);
$this->dispatch('databaserestore');
auditLog('ui.database.import_started', [
'team_id' => $this->resource->team()?->id,
'database_uuid' => $this->resource->uuid,
'database_name' => $this->resource->name,
'source' => 'file',
]);
}
$source = Storage::exists("upload/{$this->resourceUuid}/restore")
? new DatabaseImportSource('upload', dumpAll: $this->dumpAll)
: new DatabaseImportSource('server', path: $this->customLocation, dumpAll: $this->dumpAll);
$activity = StartDatabaseImport::run($this->resource, $source, (int) currentTeam()->id);
$this->activityId = $activity->id;
$this->dispatch('activityMonitor', $activity->id);
$this->dispatch('databaserestore');
auditLog('ui.database.import_started', [
'team_id' => $this->resource->team()?->id,
'database_uuid' => $this->resource->uuid,
'database_name' => $this->resource->name,
'source' => 'file',
]);
} catch (DatabaseImportException $e) {
$this->dispatch('error', $e->getMessage());
} catch (\Throwable $e) {
handleError($e, $this);
@@ -660,118 +606,9 @@ EOD;
try {
$this->importRunning = true;
$s3Storage = S3Storage::ownedByCurrentTeam()->findOrFail($this->s3StorageId);
$key = $s3Storage->key;
$secret = $s3Storage->secret;
$bucket = $s3Storage->bucket;
$endpoint = $s3Storage->endpoint;
// Validate bucket name to prevent command injection
if (! $this->validateBucketName($bucket)) {
$this->dispatch('error', 'Invalid S3 bucket name. Bucket name must contain only lowercase letters, numbers, dots, and dashes, and must follow S3 bucket naming rules.');
return true;
}
// Clean the S3 path
$cleanPath = ltrim($this->s3Path, '/');
// Validate the S3 path to prevent command injection
if (! $this->validateS3Path($cleanPath)) {
$this->dispatch('error', 'Invalid S3 path. Path must contain only safe characters (alphanumerics, dots, dashes, underscores, slashes).');
return true;
}
// Get helper image
$helperImage = coolifyHelperImage();
$latestVersion = getHelperVersion();
$fullImageName = "{$helperImage}:{$latestVersion}";
// Get the database destination network
if ($this->resource->getMorphClass() === ServiceDatabase::class) {
$destinationNetwork = $this->resource->service->destination->network ?? 'coolify';
} else {
$destinationNetwork = $this->resource->destination->network ?? 'coolify';
}
// Generate unique names for this operation
$containerName = "s3-restore-{$this->resourceUuid}";
$helperTmpPath = '/tmp/'.basename($cleanPath);
$serverTmpPath = "/tmp/s3-restore-{$this->resourceUuid}-".basename($cleanPath);
$containerTmpPath = "/tmp/restore_{$this->resourceUuid}-".basename($cleanPath);
$scriptPath = "/tmp/restore_{$this->resourceUuid}.sh";
$escapedServerTmpPath = escapeshellarg($serverTmpPath);
$escapedContainerTmpPath = escapeshellarg($containerTmpPath);
$escapedScriptPath = escapeshellarg($scriptPath);
$escapedHelperContainerPath = escapeshellarg("{$containerName}:{$helperTmpPath}");
$escapedDatabaseContainerTmpPath = escapeshellarg("{$this->container}:{$containerTmpPath}");
$escapedDatabaseContainerScriptPath = escapeshellarg("{$this->container}:{$scriptPath}");
$restoreAndCleanupCommand = escapeshellarg("{$escapedScriptPath} && rm -f {$escapedContainerTmpPath} {$escapedScriptPath}");
// Prepare all commands in sequence
$commands = [];
// 1. Clean up any existing helper container and temp files from previous runs
$commands[] = "docker rm -f {$containerName} 2>/dev/null || true";
$commands[] = "rm -f {$escapedServerTmpPath} 2>/dev/null || true";
$commands[] = "docker exec {$this->container} rm -f {$escapedContainerTmpPath} {$escapedScriptPath} 2>/dev/null || true";
// 2. Start helper container on the database network
$commands[] = "docker run -d --network {$destinationNetwork} --name {$containerName} {$fullImageName} sleep 3600";
// 3. Configure S3 access in helper container
$escapedEndpoint = escapeshellarg($endpoint);
$escapedKey = escapeshellarg($key);
$escapedSecret = escapeshellarg($secret);
$commands[] = "docker exec {$containerName} mc alias set s3temp {$escapedEndpoint} {$escapedKey} {$escapedSecret}";
// 4. Check file exists in S3 (bucket and path already validated above)
$escapedS3Source = escapeshellarg("s3temp/{$bucket}/{$cleanPath}");
$commands[] = "docker exec {$containerName} mc stat {$escapedS3Source}";
// 5. Download from S3 to helper container (progress shown by default)
$escapedHelperTmpPath = escapeshellarg($helperTmpPath);
$commands[] = "docker exec {$containerName} mc cp {$escapedS3Source} {$escapedHelperTmpPath}";
// 6. Copy from helper to server, then immediately to database container
$commands[] = "docker cp {$escapedHelperContainerPath} {$escapedServerTmpPath}";
$commands[] = "docker cp {$escapedServerTmpPath} {$escapedDatabaseContainerTmpPath}";
$this->addRestoreSafetyCheckCommand($commands, $containerTmpPath);
// 7. Cleanup helper container and server temp file immediately (no longer needed)
$commands[] = "docker rm -f {$containerName} 2>/dev/null || true";
$commands[] = "rm -f {$escapedServerTmpPath} 2>/dev/null || true";
// 8. Build and execute restore command inside database container
$restoreCommand = $this->buildRestoreCommand($containerTmpPath);
$restoreCommandBase64 = base64_encode($restoreCommand);
$commands[] = "echo \"{$restoreCommandBase64}\" | base64 -d > {$escapedScriptPath}";
$commands[] = "chmod +x {$escapedScriptPath}";
$commands[] = "docker cp {$escapedScriptPath} {$escapedDatabaseContainerScriptPath}";
// 9. Execute restore and cleanup temp files immediately after completion
$commands[] = "docker exec {$this->container} sh -c {$restoreAndCleanupCommand}";
$commands[] = "docker exec {$this->container} sh -c 'echo \"Import finished with exit code $?\"'";
// Execute all commands with cleanup event (as safety net for edge cases)
$activity = remote_process($commands, $this->server, ignore_errors: true, callEventOnFinish: 'S3RestoreJobFinished', callEventData: [
'containerName' => $containerName,
'serverTmpPath' => $serverTmpPath,
'scriptPath' => $scriptPath,
'containerTmpPath' => $containerTmpPath,
'container' => $this->container,
'serverId' => $this->server->id,
]);
// Track the activity ID
$source = new DatabaseImportSource('s3', path: $this->s3Path, s3StorageUuid: (string) $this->s3StorageId, dumpAll: $this->dumpAll);
$activity = StartDatabaseImport::run($this->resource, $source, (int) currentTeam()->id);
$this->activityId = $activity->id;
// Dispatch activity to the monitor and open slide-over
$this->dispatch('activityMonitor', $activity->id);
$this->dispatch('databaserestore');
auditLog('ui.database.restore_started', [
@@ -782,6 +619,8 @@ EOD;
'storage_id' => $this->s3StorageId,
]);
$this->dispatch('info', 'Restoring database from S3. Progress will be shown in the activity monitor...');
} catch (DatabaseImportException $e) {
$this->dispatch('error', $e->getMessage());
} catch (\Throwable $e) {
$this->importRunning = false;
handleError($e, $this);
@@ -794,13 +633,7 @@ EOD;
public function buildRestoreSafetyCheckCommand(string $tmpPath): ?string
{
$script = $this->buildPostgresRestoreScanScript($tmpPath);
if ($script === null) {
return null;
}
return "docker exec {$this->container} sh -c ".escapeshellarg($script);
return app(DatabaseImportCommandBuilder::class)->buildPostgresSafetyCommand($this->resource, $this->container, $tmpPath);
}
/**
@@ -880,59 +713,6 @@ SH;
public function buildRestoreCommand(string $tmpPath): string
{
$escapedTmpPath = escapeshellarg($tmpPath);
$morphClass = $this->resource->getMorphClass();
// Handle ServiceDatabase by checking the database type
if ($morphClass === ServiceDatabase::class) {
$dbType = $this->resource->databaseType();
if (str_contains($dbType, 'mysql')) {
$morphClass = 'mysql';
} elseif (str_contains($dbType, 'mariadb')) {
$morphClass = 'mariadb';
} elseif (str_contains($dbType, 'postgres')) {
$morphClass = 'postgresql';
} elseif (str_contains($dbType, 'mongo')) {
$morphClass = 'mongodb';
}
}
switch ($morphClass) {
case StandaloneMariadb::class:
case 'mariadb':
$restoreCommand = $this->mariadbRestoreCommand;
if ($this->dumpAll) {
$restoreCommand .= " && (gunzip -cf {$escapedTmpPath} 2>/dev/null || cat {$escapedTmpPath}) | mariadb -u root -p\$MARIADB_ROOT_PASSWORD \${MARIADB_DATABASE:-default}";
} else {
$restoreCommand .= " < {$escapedTmpPath}";
}
break;
case StandaloneMysql::class:
case 'mysql':
$restoreCommand = $this->mysqlRestoreCommand;
if ($this->dumpAll) {
$restoreCommand .= " && (gunzip -cf {$escapedTmpPath} 2>/dev/null || cat {$escapedTmpPath}) | mysql -u root -p\$MYSQL_ROOT_PASSWORD \${MYSQL_DATABASE:-default}";
} else {
$restoreCommand .= " < {$escapedTmpPath}";
}
break;
case StandalonePostgresql::class:
case 'postgresql':
$restoreCommand = $this->postgresqlRestoreCommand;
if ($this->dumpAll) {
$restoreCommand .= " && if [ \"\$({ gunzip -cf {$escapedTmpPath} 2>/dev/null || cat {$escapedTmpPath}; } | head -c 5)\" = 'PGDMP' ]; then pg_restore -U \${POSTGRES_USER} -d \${POSTGRES_DB:-\${POSTGRES_USER:-postgres}} {$escapedTmpPath}; else (gunzip -cf {$escapedTmpPath} 2>/dev/null || cat {$escapedTmpPath}) | psql -U \${POSTGRES_USER} -d \${POSTGRES_DB:-\${POSTGRES_USER:-postgres}}; fi";
} else {
$restoreCommand .= " {$escapedTmpPath}";
}
break;
case StandaloneMongodb::class:
case 'mongodb':
$restoreCommand = $this->mongodbRestoreCommand.$escapedTmpPath;
break;
default:
$restoreCommand = '';
}
return $restoreCommand;
return app(DatabaseImportCommandBuilder::class)->buildRestoreCommand($this->resource, $tmpPath, $this->dumpAll);
}
}
+35 -3
View File
@@ -5,6 +5,7 @@ namespace App\Livewire\Project\Service;
use App\Actions\Shared\CheckDomainDns;
use App\Jobs\CheckDomainDnsJob;
use App\Livewire\Concerns\InteractsWithCloudflareDomainConnect;
use App\Livewire\Concerns\InteractsWithDnsProviders;
use App\Livewire\Project\Shared\ConfigurationChecker;
use App\Models\Server;
use App\Models\Service;
@@ -12,6 +13,7 @@ use App\Models\ServiceApplication;
use App\Support\DomainPortOverrides;
use App\Support\DomainUrlParts;
use App\Support\ValidationPatterns;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
@@ -21,6 +23,7 @@ class Domains extends Component
{
use AuthorizesRequests;
use InteractsWithCloudflareDomainConnect;
use InteractsWithDnsProviders;
protected bool $notifyRedirectUpdate = true;
@@ -108,6 +111,13 @@ class Domains extends Component
'confirmDomainUsage',
];
public function getListeners(): array
{
return array_merge($this->listeners, [
'echo-private:team.'.currentTeam()->id.',DnsRecordConfigurationFinished' => 'dnsRecordConfigurationFinished',
]);
}
protected function rules(): array
{
return [
@@ -1085,9 +1095,15 @@ class Domains extends Component
$this->pendingAction = null;
$this->dispatch('close-modal');
$this->refreshDomains();
$urlsToCheck = array_values(array_unique(array_merge($newUrls, $pairedUrls)));
$addedUrls = array_values(array_unique(array_merge($newUrls, $pairedUrls)));
if ($this->configureDnsAfterDomainAdd($addedUrls)) {
$this->dispatch('success', 'Domain added.');
return;
}
$serviceApplicationId = (int) $app->id;
$dnsChecks = collect($urlsToCheck)->map(fn (string $url) => [
$dnsChecks = collect($addedUrls)->map(fn (string $url) => [
'url' => $url,
'check_id' => new_public_id(),
]);
@@ -1295,7 +1311,7 @@ class Domains extends Component
}
}
public function removeDomain(int $index): void
public function removeDomain(int $index, string $password = '', array $selectedActions = []): void
{
try {
$this->authorize('update', $this->service);
@@ -1318,6 +1334,10 @@ class Domains extends Component
return;
}
if (in_array('deleteManagedDns', $selectedActions, true)) {
$this->deleteManagedDnsForUrl($url);
}
$this->forceSaveDomains = false;
$this->forceRemovePort = false;
$this->dispatch('success', 'Domain removed.');
@@ -1350,6 +1370,18 @@ class Domains extends Component
return hash('sha256', $row['url'].'|'.$row['service_application_id']);
}
protected function dnsResourceForHostname(string $hostname): ?Model
{
foreach ($this->domainRows as $row) {
$rowHostname = parse_url((string) ($row['url'] ?? ''), PHP_URL_HOST);
if (is_string($rowHostname) && strtolower($rowHostname) === strtolower($hostname)) {
return $this->findServiceApp((int) $row['service_application_id']);
}
}
return null;
}
public function addSuggestedDomain(int $index): void
{
try {
@@ -3,8 +3,10 @@
namespace App\Livewire\Security;
use App\Models\IntegrationToken;
use App\Services\Dns\CloudflareDnsProvider;
use App\Services\IntegrationTokenValidator;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\DB;
use Livewire\Component;
class IntegrationTokenEditor extends Component
@@ -21,6 +23,13 @@ class IntegrationTokenEditor extends Component
public array $metadata = [];
public int $zoneCount = 0;
/** @var array<int, array{id: int, name: string, account_name: ?string, managed_records_count: int}> */
public array $zones = [];
public bool $automaticDns = true;
public function mount(string $integration_token_uuid): void
{
$this->integrationToken = IntegrationToken::ownedByCurrentTeam()
@@ -32,6 +41,8 @@ class IntegrationTokenEditor extends Component
$this->name = $this->integrationToken->name;
$this->capabilities = $this->integrationToken->capabilities;
$this->metadata = $this->integrationToken->metadata ?? [];
$this->loadZones();
$this->automaticDns = $this->integrationToken->automaticDnsEnabled();
}
protected function rules(): array
@@ -43,6 +54,7 @@ class IntegrationTokenEditor extends Component
'newToken' => ['nullable', 'string'],
'capabilities' => ['required', 'array', 'min:1'],
'capabilities.*' => ['required', 'in:'.$allowedCapability],
'automaticDns' => ['boolean'],
];
if ($this->integrationToken->provider === 'infisical') {
@@ -66,13 +78,20 @@ class IntegrationTokenEditor extends Component
];
}
public function save(IntegrationTokenValidator $validator): void
public function save(IntegrationTokenValidator $validator, CloudflareDnsProvider $cloudflare): void
{
$this->authorize('update', $this->integrationToken);
$validated = $this->validate();
$provider = $this->integrationToken->provider;
$token = filled($validated['newToken']) ? $validated['newToken'] : $this->integrationToken->token;
$metadata = array_filter(data_get($validated, 'metadata', []), fn ($value) => filled($value));
if ($provider === 'cloudflare') {
if ($validated['automaticDns']) {
unset($metadata['automatic_dns']);
} else {
$metadata['automatic_dns'] = false;
}
}
$capabilitiesChanged = collect($validated['capabilities'])->sort()->values()->all()
!== collect($this->integrationToken->capabilities)->sort()->values()->all();
$metadataChanged = $metadata != ($this->integrationToken->metadata ?? []);
@@ -95,8 +114,14 @@ class IntegrationTokenEditor extends Component
$updates['token'] = $validated['newToken'];
}
$this->integrationToken->update($updates);
DB::transaction(function () use ($updates, $provider, $validated, $capabilitiesChanged, $cloudflare): void {
$this->integrationToken->update($updates);
if ($provider === 'cloudflare' && (filled($validated['newToken']) || $capabilitiesChanged)) {
$cloudflare->syncZones($this->integrationToken);
}
});
$this->newToken = '';
$this->loadZones();
auditLog('ui.integration_token.updated', [
'team_id' => currentTeam()->id,
@@ -128,6 +153,12 @@ class IntegrationTokenEditor extends Component
return;
}
if ($this->integrationToken->managedDnsRecords()->exists()) {
$this->dispatch('error', 'This token manages DNS records. Remove those domains or records first.');
return;
}
$uuid = $this->integrationToken->uuid;
$name = $this->integrationToken->name;
$provider = $this->integrationToken->provider;
@@ -145,8 +176,38 @@ class IntegrationTokenEditor extends Component
$this->dispatch('success', 'Integration token deleted successfully.');
}
public function refreshZones(CloudflareDnsProvider $cloudflare): void
{
$this->authorize('update', $this->integrationToken);
try {
$cloudflare->syncZones($this->integrationToken);
$this->integrationToken->refresh();
$this->loadZones();
$this->dispatch('success', "Cloudflare zones refreshed. {$this->zoneCount} accessible zones found.");
} catch (\Throwable $e) {
handleError($e, $this);
}
}
public function render()
{
return view('livewire.security.integration-token-editor');
}
private function loadZones(): void
{
$this->zones = $this->integrationToken->dnsZones()
->select(['id', 'integration_token_id', 'name', 'account_name'])
->withCount('managedRecords')
->orderBy('name')
->get()
->map(fn ($zone) => [
'id' => $zone->id,
'name' => $zone->name,
'account_name' => $zone->account_name,
'managed_records_count' => $zone->managed_records_count,
])
->all();
$this->zoneCount = count($this->zones);
}
}
+21 -9
View File
@@ -3,8 +3,10 @@
namespace App\Livewire\Security;
use App\Models\IntegrationToken;
use App\Services\Dns\CloudflareDnsProvider;
use App\Services\IntegrationTokenValidator;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\DB;
use Livewire\Component;
class IntegrationTokenForm extends Component
@@ -23,6 +25,8 @@ class IntegrationTokenForm extends Component
public array $metadata = [];
public bool $automaticDns = true;
public function mount(): void
{
$this->authorize('create', IntegrationToken::class);
@@ -33,6 +37,7 @@ class IntegrationTokenForm extends Component
if ($this->provider === 'cloudflare') {
$this->capabilities = ['dns'];
$this->metadata = [];
$this->automaticDns = true;
} else {
$this->capabilities = ['secrets'];
$this->metadata = $this->provider === 'infisical'
@@ -51,6 +56,7 @@ class IntegrationTokenForm extends Component
'token' => ['required', 'string'],
'capabilities' => ['required', 'array', 'min:1'],
'capabilities.*' => ['required', 'in:'.$allowedCapability],
'automaticDns' => ['boolean'],
];
if ($this->provider === 'infisical') {
@@ -79,10 +85,13 @@ class IntegrationTokenForm extends Component
];
}
public function addToken(IntegrationTokenValidator $validator): void
public function addToken(IntegrationTokenValidator $validator, CloudflareDnsProvider $cloudflare): void
{
$validated = $this->validate();
$metadata = array_filter(data_get($validated, 'metadata', []), fn ($value) => filled($value));
if ($validated['provider'] === 'cloudflare' && ! $validated['automaticDns']) {
$metadata['automatic_dns'] = false;
}
try {
if (! $validator->validate($validated['provider'], $validated['token'], $validated['capabilities'], $metadata)) {
@@ -91,14 +100,17 @@ class IntegrationTokenForm extends Component
return;
}
$integrationToken = IntegrationToken::query()->create([
'provider' => $validated['provider'],
'name' => $validated['name'],
'token' => $validated['token'],
'capabilities' => $validated['capabilities'],
'metadata' => $metadata ?: null,
'team_id' => currentTeam()->id,
]);
$integrationToken = DB::transaction(function () use ($validated, $metadata, $cloudflare): IntegrationToken {
$token = IntegrationToken::query()->create([
'provider' => $validated['provider'], 'name' => $validated['name'], 'token' => $validated['token'],
'capabilities' => $validated['capabilities'], 'metadata' => $metadata ?: null, 'team_id' => currentTeam()->id,
]);
if ($token->provider === 'cloudflare') {
$cloudflare->syncZones($token);
}
return $token;
});
auditLog('ui.integration_token.created', [
'team_id' => currentTeam()->id,
+7 -1
View File
@@ -22,7 +22,7 @@ class IntegrationTokens extends Component
#[On('integrationTokenAdded')]
public function loadTokens(): void
{
$this->tokens = IntegrationToken::ownedByCurrentTeam()->latest()->get();
$this->tokens = IntegrationToken::ownedByCurrentTeam()->withCount('dnsZones')->latest()->get();
}
public function deleteToken(int $tokenId, string $password = ''): void
@@ -36,6 +36,12 @@ class IntegrationTokens extends Component
return;
}
if ($token->managedDnsRecords()->exists()) {
$this->dispatch('error', 'This token manages DNS records. Remove those domains or records first.');
return;
}
$tokenUuid = $token->uuid;
$tokenName = $token->name;
$provider = $token->provider;
+24
View File
@@ -0,0 +1,24 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class DnsProviderZone extends BaseModel
{
use HasFactory;
protected $fillable = ['integration_token_id', 'provider_zone_id', 'name', 'account_id', 'account_name'];
public function integrationToken(): BelongsTo
{
return $this->belongsTo(IntegrationToken::class);
}
public function managedRecords(): HasMany
{
return $this->hasMany(ManagedDnsRecord::class);
}
}
+18
View File
@@ -2,11 +2,14 @@
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class IntegrationToken extends BaseModel
{
use HasFactory;
public const SECRET_MANAGER_PROVIDERS = ['doppler', 'infisical', 'vault'];
public const PROVIDER_NAMES = [
@@ -48,6 +51,16 @@ class IntegrationToken extends BaseModel
return $this->hasMany(SecretManagerLink::class);
}
public function dnsZones(): HasMany
{
return $this->hasMany(DnsProviderZone::class);
}
public function managedDnsRecords(): HasMany
{
return $this->hasMany(ManagedDnsRecord::class);
}
public function isSecretManager(): bool
{
return in_array($this->provider, self::SECRET_MANAGER_PROVIDERS, true);
@@ -58,6 +71,11 @@ class IntegrationToken extends BaseModel
return self::PROVIDER_NAMES[$this->provider] ?? ucfirst($this->provider);
}
public function automaticDnsEnabled(): bool
{
return $this->provider === 'cloudflare' && data_get($this->metadata, 'automatic_dns', true) !== false;
}
public function dopplerTokenType(): ?string
{
if ($this->provider !== 'doppler') {
+32
View File
@@ -0,0 +1,32 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphTo;
class ManagedDnsRecord extends BaseModel
{
use HasFactory;
protected $fillable = [
'team_id', 'integration_token_id', 'dns_provider_zone_id', 'resource_type', 'resource_id',
'provider_record_id', 'type', 'name', 'content',
];
public function zone(): BelongsTo
{
return $this->belongsTo(DnsProviderZone::class, 'dns_provider_zone_id');
}
public function integrationToken(): BelongsTo
{
return $this->belongsTo(IntegrationToken::class);
}
public function resource(): MorphTo
{
return $this->morphTo();
}
}
+137
View File
@@ -0,0 +1,137 @@
<?php
namespace App\Services\Dns;
use App\Exceptions\DnsRecordConflictException;
use App\Models\DnsProviderZone;
use App\Models\IntegrationToken;
use App\Models\ManagedDnsRecord;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use RuntimeException;
class CloudflareDnsProvider
{
public function syncZones(IntegrationToken $token): int
{
$zones = [];
$page = 1;
do {
$response = $this->client($token)->get('https://api.cloudflare.com/client/v4/zones', ['page' => $page, 'per_page' => 50]);
if (! $response->successful() || $response->json('success') !== true) {
throw new RuntimeException('Cloudflare zones could not be synchronized.');
}
array_push($zones, ...$response->json('result', []));
$totalPages = max(1, (int) $response->json('result_info.total_pages', 1));
$page++;
} while ($page <= $totalPages);
DB::transaction(function () use ($token, $zones): void {
$ids = [];
foreach ($zones as $zone) {
$ids[] = $zone['id'];
$token->dnsZones()->updateOrCreate(['provider_zone_id' => $zone['id']], [
'name' => strtolower($zone['name']), 'account_id' => data_get($zone, 'account.id'),
'account_name' => data_get($zone, 'account.name'),
]);
}
$token->dnsZones()->whereNotIn('provider_zone_id', $ids)->whereDoesntHave('managedRecords')->delete();
$metadata = $token->metadata ?? [];
$metadata['zones_synced_at'] = now()->toIso8601String();
$token->update(['metadata' => $metadata]);
});
return count($zones);
}
/** @return Collection<int, DnsProviderZone> */
public function findZones(int $teamId, string $hostname): Collection
{
$hostname = strtolower(rtrim($hostname, '.'));
$matches = DnsProviderZone::query()
->whereHas('integrationToken', fn ($query) => $query->where('team_id', $teamId)->where('provider', 'cloudflare'))
->with('integrationToken')->get()
->filter(fn (DnsProviderZone $zone) => $hostname === $zone->name || str_ends_with($hostname, '.'.$zone->name));
$longest = $matches->max(fn (DnsProviderZone $zone) => strlen($zone->name));
return $matches->filter(fn (DnsProviderZone $zone) => strlen($zone->name) === $longest)->values();
}
public function createRecord(DnsProviderZone $zone, string $hostname, string $content, ?Model $resource = null): ManagedDnsRecord
{
$hostname = strtolower(rtrim($hostname, '.'));
$type = filter_var($content, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) ? 'AAAA' : 'A';
$token = $zone->integrationToken;
$existing = $this->client($token)->get("https://api.cloudflare.com/client/v4/zones/{$zone->provider_zone_id}/dns_records", [
'type' => $type, 'name' => $hostname, 'per_page' => 100,
]);
if (! $existing->successful()) {
throw new RuntimeException('Cloudflare DNS records could not be checked.');
}
$remote = collect($existing->json('result', []))->first();
if ($remote !== null) {
if (($remote['content'] ?? null) === $content) {
throw new RuntimeException('DNS is already configured. Coolify left the existing record unchanged.');
}
throw new DnsRecordConflictException((string) ($remote['id'] ?? ''), (string) ($remote['content'] ?? ''), $content);
}
$response = $this->client($token)->post("https://api.cloudflare.com/client/v4/zones/{$zone->provider_zone_id}/dns_records", [
'type' => $type, 'name' => $hostname, 'content' => $content, 'ttl' => 1, 'proxied' => false,
]);
if (! $response->successful() || ! is_string($response->json('result.id'))) {
throw new RuntimeException('Cloudflare could not create the DNS record.');
}
return $this->trackRecord($zone, $response->json('result.id'), $type, $hostname, $content, $resource);
}
public function replaceRecord(DnsProviderZone $zone, string $recordId, string $hostname, string $content, ?Model $resource = null): ManagedDnsRecord
{
$type = filter_var($content, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) ? 'AAAA' : 'A';
$response = $this->client($zone->integrationToken)->put(
"https://api.cloudflare.com/client/v4/zones/{$zone->provider_zone_id}/dns_records/{$recordId}",
['type' => $type, 'name' => $hostname, 'content' => $content, 'ttl' => 1, 'proxied' => false],
);
if (! $response->successful()) {
throw new RuntimeException('Cloudflare could not replace the conflicting DNS record.');
}
return $this->trackRecord($zone, $recordId, $type, strtolower($hostname), $content, $resource);
}
public function deleteRecord(ManagedDnsRecord $record): bool
{
$record->loadMissing(['zone', 'integrationToken']);
$url = "https://api.cloudflare.com/client/v4/zones/{$record->zone->provider_zone_id}/dns_records/{$record->provider_record_id}";
$response = $this->client($record->integrationToken)->get($url);
$remote = $response->json('result');
if (! $response->successful() || ($remote['type'] ?? null) !== $record->type
|| strtolower((string) ($remote['name'] ?? '')) !== $record->name || ($remote['content'] ?? null) !== $record->content) {
return false;
}
if (! $this->client($record->integrationToken)->delete($url)->successful()) {
return false;
}
$record->delete();
return true;
}
private function trackRecord(DnsProviderZone $zone, string $recordId, string $type, string $name, string $content, ?Model $resource): ManagedDnsRecord
{
return ManagedDnsRecord::query()->updateOrCreate(
['dns_provider_zone_id' => $zone->id, 'provider_record_id' => $recordId],
['team_id' => $zone->integrationToken->team_id, 'integration_token_id' => $zone->integration_token_id,
'resource_type' => $resource?->getMorphClass(), 'resource_id' => $resource?->getKey(),
'type' => $type, 'name' => $name, 'content' => $content],
);
}
private function client(IntegrationToken $token): PendingRequest
{
return Http::withToken($token->token)->acceptJson()->connectTimeout(5)->timeout(10);
}
}
@@ -0,0 +1,67 @@
<?php
namespace App\Support\DatabaseImport;
use App\Models\ServiceDatabase;
use InvalidArgumentException;
class DatabaseImportCommandBuilder
{
public function buildRestoreCommand(object $resource, string $path, bool $dumpAll): string
{
$path = escapeshellarg($path);
return match ($this->databaseType($resource)) {
'postgresql' => $dumpAll
? 'psql -U ${POSTGRES_USER} -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname IS NOT NULL AND pid <> pg_backend_pid()" && psql -U ${POSTGRES_USER} -t -c "SELECT datname FROM pg_database WHERE NOT datistemplate" | xargs -I {} dropdb -U ${POSTGRES_USER} --if-exists {} && createdb -U ${POSTGRES_USER} ${POSTGRES_DB:-${POSTGRES_USER:-postgres}} && (gunzip -cf '.$path.' 2>/dev/null || cat '.$path.') | psql -U ${POSTGRES_USER} -d ${POSTGRES_DB:-${POSTGRES_USER:-postgres}}'
: 'pg_restore -U $POSTGRES_USER -d ${POSTGRES_DB:-${POSTGRES_USER:-postgres}} '.$path,
'mysql' => $dumpAll
? $this->mysqlDumpAll('mysql', 'MYSQL', $path)
: 'mysql -u $MYSQL_USER -p$MYSQL_PASSWORD $MYSQL_DATABASE < '.$path,
'mariadb' => $dumpAll
? $this->mysqlDumpAll('mariadb', 'MARIADB', $path)
: 'mariadb -u $MARIADB_USER -p$MARIADB_PASSWORD $MARIADB_DATABASE < '.$path,
'mongodb' => 'mongorestore --authenticationDatabase=admin --username $MONGO_INITDB_ROOT_USERNAME --password $MONGO_INITDB_ROOT_PASSWORD --uri mongodb://localhost:27017 --gzip --archive='.$path,
default => throw new InvalidArgumentException('Database import is not supported for this database type.'),
};
}
public function buildPostgresSafetyCommand(object $resource, string $container, string $path): ?string
{
if ($this->databaseType($resource) !== 'postgresql') {
return null;
}
$path = escapeshellarg($path);
$contents = "{ gunzip -cf {$path} 2>/dev/null || cat {$path}; }";
$script = "header=\$({$contents} | head -c 5); if [ \"\$header\" = 'PGDMP' ]; then exit 0; fi; if {$contents} | sed 's/--.*//' | grep -Eiq '(^|;)[[:space:]]*copy[[:space:]]+[^;]*(from|to)[[:space:]]+program|^[[:space:]]*\\\\(!|copy.*program|(o|g)[[:space:]]*\\|)'; then echo 'Blocked PostgreSQL restore: COPY ... PROGRAM and psql shell commands are not allowed.'; exit 1; fi";
return 'docker exec '.$container.' sh -c '.escapeshellarg($script);
}
public function supports(object $resource): bool
{
return in_array($this->databaseType($resource), ['postgresql', 'mysql', 'mariadb', 'mongodb'], true);
}
public function databaseType(object $resource): string
{
$class = $resource->getMorphClass();
$type = ($resource instanceof ServiceDatabase || str_contains(strtolower($class), 'service'))
? strtolower($resource->databaseType())
: strtolower($class);
return match (true) {
str_contains($type, 'postgres') => 'postgresql',
str_contains($type, 'mariadb') => 'mariadb',
str_contains($type, 'mysql') => 'mysql',
str_contains($type, 'mongo') => 'mongodb',
default => 'unsupported',
};
}
private function mysqlDumpAll(string $binary, string $prefix, string $path): string
{
return "for pid in \$({$binary} -u root -p\${{$prefix}_ROOT_PASSWORD} -N -e \"SELECT id FROM information_schema.processlist WHERE user != 'root';\"); do {$binary} -u root -p\${{$prefix}_ROOT_PASSWORD} -e \"KILL \$pid\" 2>/dev/null || true; done && {$binary} -u root -p\${{$prefix}_ROOT_PASSWORD} -N -e \"SELECT CONCAT('DROP DATABASE IF EXISTS \\`',schema_name,'\\`;') FROM information_schema.schemata WHERE schema_name NOT IN ('information_schema','mysql','performance_schema','sys');\" | {$binary} -u root -p\${{$prefix}_ROOT_PASSWORD} && {$binary} -u root -p\${{$prefix}_ROOT_PASSWORD} -e \"CREATE DATABASE IF NOT EXISTS \\`\${{{$prefix}_DATABASE:-default}}\\`;\" && (gunzip -cf {$path} 2>/dev/null || cat {$path}) | {$binary} -u root -p\${{{$prefix}_ROOT_PASSWORD}} \${{{$prefix}_DATABASE:-default}}";
}
}
@@ -0,0 +1,13 @@
<?php
namespace App\Support\DatabaseImport;
use RuntimeException;
class DatabaseImportException extends RuntimeException
{
public function __construct(string $message, public readonly int $status = 422)
{
parent::__construct($message);
}
}
@@ -0,0 +1,20 @@
<?php
namespace App\Support\DatabaseImport;
use InvalidArgumentException;
readonly class DatabaseImportSource
{
public function __construct(
public string $type,
public ?string $uploadId = null,
public ?string $path = null,
public ?string $s3StorageUuid = null,
public bool $dumpAll = false,
) {
if (! in_array($type, ['upload', 's3', 'server'], true)) {
throw new InvalidArgumentException('Invalid database import source.');
}
}
}
@@ -0,0 +1,20 @@
<?php
namespace Database\Factories;
use App\Models\DnsProviderZone;
use App\Models\IntegrationToken;
use Illuminate\Database\Eloquent\Factories\Factory;
class DnsProviderZoneFactory extends Factory
{
protected $model = DnsProviderZone::class;
public function definition(): array
{
return [
'integration_token_id' => IntegrationToken::factory(), 'provider_zone_id' => fake()->uuid(),
'name' => fake()->unique()->domainName(), 'account_id' => fake()->uuid(), 'account_name' => fake()->company(),
];
}
}
@@ -0,0 +1,20 @@
<?php
namespace Database\Factories;
use App\Models\IntegrationToken;
use App\Models\Team;
use Illuminate\Database\Eloquent\Factories\Factory;
class IntegrationTokenFactory extends Factory
{
protected $model = IntegrationToken::class;
public function definition(): array
{
return [
'team_id' => Team::factory(), 'provider' => 'cloudflare', 'name' => fake()->words(2, true),
'token' => fake()->sha256(), 'capabilities' => ['dns'],
];
}
}
@@ -0,0 +1,22 @@
<?php
namespace Database\Factories;
use App\Models\DnsProviderZone;
use App\Models\ManagedDnsRecord;
use Illuminate\Database\Eloquent\Factories\Factory;
class ManagedDnsRecordFactory extends Factory
{
protected $model = ManagedDnsRecord::class;
public function definition(): array
{
return [
'dns_provider_zone_id' => DnsProviderZone::factory(),
'integration_token_id' => fn (array $attributes) => DnsProviderZone::query()->findOrFail($attributes['dns_provider_zone_id'])->integration_token_id,
'team_id' => fn (array $attributes) => DnsProviderZone::query()->findOrFail($attributes['dns_provider_zone_id'])->integrationToken->team_id,
'provider_record_id' => fake()->uuid(), 'type' => 'A', 'name' => fake()->domainName(), 'content' => fake()->ipv4(),
];
}
}
@@ -0,0 +1,29 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('dns_provider_zones', function (Blueprint $table) {
$table->id();
$table->string('uuid')->unique();
$table->foreignId('integration_token_id')->constrained()->cascadeOnDelete();
$table->string('provider_zone_id');
$table->string('name');
$table->string('account_id')->nullable();
$table->string('account_name')->nullable();
$table->timestamps();
$table->unique(['integration_token_id', 'provider_zone_id']);
$table->index('name');
});
}
public function down(): void
{
Schema::dropIfExists('dns_provider_zones');
}
};
@@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('managed_dns_records', function (Blueprint $table) {
$table->id();
$table->string('uuid')->unique();
$table->foreignId('team_id')->constrained()->cascadeOnDelete();
$table->foreignId('integration_token_id')->constrained()->cascadeOnDelete();
$table->foreignId('dns_provider_zone_id')->constrained()->cascadeOnDelete();
$table->nullableMorphs('resource');
$table->string('provider_record_id');
$table->string('type', 16);
$table->string('name');
$table->string('content');
$table->timestamps();
$table->unique(['dns_provider_zone_id', 'provider_record_id']);
$table->index(['team_id', 'name']);
});
}
public function down(): void
{
Schema::dropIfExists('managed_dns_records');
}
};
+496 -3
View File
@@ -11,6 +11,66 @@
}
],
"paths": {
"\/applications\/{uuid}\/secret-manager": {
"patch": {
"tags": [
"Secret Managers"
],
"summary": "Configure Application Secret Manager",
"description": "Configure the secret manager source used by an application.",
"operationId": "configure-application-secret-manager",
"parameters": [
{
"name": "uuid",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"required": true,
"content": {
"application\/json": {
"schema": {
"required": [
"integration_token_uuid"
],
"properties": {
"integration_token_uuid": {
"type": "string"
},
"settings": {
"type": "object"
}
},
"type": "object"
}
}
}
},
"responses": {
"200": {
"description": "Secret manager configured."
},
"401": {
"$ref": "#\/components\/responses\/401"
},
"404": {
"$ref": "#\/components\/responses\/404"
},
"422": {
"$ref": "#\/components\/responses\/422"
}
},
"security": [
{
"bearerAuth": []
}
]
}
},
"\/applications": {
"get": {
"tags": [
@@ -5778,6 +5838,85 @@
]
}
},
"\/databases\/{uuid}\/imports\/uploads": {
"post": {
"tags": [
"Databases"
],
"summary": "Upload database import",
"operationId": "upload-database-import",
"responses": {
"201": {
"description": "Upload completed"
},
"422": {
"$ref": "#\/components\/responses\/422"
}
},
"security": [
{
"bearerAuth": []
}
]
}
},
"\/databases\/{uuid}\/imports": {
"post": {
"tags": [
"Databases"
],
"summary": "Import database backup",
"operationId": "create-database-import",
"requestBody": {
"required": true,
"content": {
"application\/json": {
"schema": {
"$ref": "#\/components\/schemas\/DatabaseImportRequest"
}
}
}
},
"responses": {
"202": {
"description": "Import queued"
},
"409": {
"description": "Import already active"
},
"422": {
"$ref": "#\/components\/responses\/422"
}
},
"security": [
{
"bearerAuth": []
}
]
}
},
"\/databases\/{uuid}\/imports\/{activity_id}": {
"get": {
"tags": [
"Databases"
],
"summary": "Get database import status",
"operationId": "get-database-import",
"responses": {
"200": {
"description": "Import status"
},
"404": {
"$ref": "#\/components\/responses\/404"
}
},
"security": [
{
"bearerAuth": []
}
]
}
},
"\/databases": {
"get": {
"tags": [
@@ -11689,13 +11828,129 @@
]
}
},
"\/settings\/email": {
"get": {
"tags": [
"Settings"
],
"summary": "Get instance email settings",
"description": "Get instance-wide SMTP and Resend settings. Requires a root-team token belonging to a root-team admin or owner. Sensitive fields require the `read:sensitive` or `root` token ability.",
"operationId": "get-instance-email-settings",
"responses": {
"200": {
"description": "Instance email settings."
},
"401": {
"$ref": "#\/components\/responses\/401"
},
"403": {
"description": "Forbidden."
}
},
"security": [
{
"bearerAuth": []
}
]
},
"patch": {
"tags": [
"Settings"
],
"summary": "Update instance email settings",
"description": "Update instance-wide SMTP and Resend settings. Requires `write:sensitive` and a root-team token belonging to a root-team admin or owner.",
"operationId": "update-instance-email-settings",
"responses": {
"200": {
"description": "Updated instance email settings."
},
"401": {
"$ref": "#\/components\/responses\/401"
},
"403": {
"description": "Forbidden."
},
"422": {
"$ref": "#\/components\/responses\/422"
}
},
"security": [
{
"bearerAuth": []
}
]
}
},
"\/security\/integration-tokens": {
"post": {
"tags": [
"Secret Managers"
],
"summary": "Create Secret Manager Token",
"description": "Create and validate a Doppler, Infisical, or Vault integration token.",
"operationId": "create-secret-manager-integration-token",
"requestBody": {
"required": true,
"content": {
"application\/json": {
"schema": {
"required": [
"provider",
"name",
"token"
],
"properties": {
"provider": {
"type": "string",
"enum": [
"doppler",
"infisical",
"vault"
]
},
"name": {
"type": "string"
},
"token": {
"type": "string"
},
"metadata": {
"type": "object"
}
},
"type": "object"
}
}
}
},
"responses": {
"201": {
"description": "Integration token created."
},
"400": {
"$ref": "#\/components\/responses\/400"
},
"401": {
"$ref": "#\/components\/responses\/401"
},
"422": {
"$ref": "#\/components\/responses\/422"
}
},
"security": [
{
"bearerAuth": []
}
]
}
},
"\/notifications\/email": {
"get": {
"tags": [
"Notifications"
],
"summary": "Get email notification settings",
"description": "Get the current team email notification settings. Encrypted secrets are only returned when the token has `read:sensitive` (or `root`) and the user is a team admin\/owner.",
"description": "Get the current team email notification settings, including `smtp_ehlo_domain`, the hostname sent with SMTP EHLO. Encrypted secrets are only returned when the token has `read:sensitive` (or `root`) and the user is a team admin\/owner.",
"operationId": "get-current-team-email-notifications",
"responses": {
"200": {
@@ -11719,7 +11974,7 @@
"Notifications"
],
"summary": "Update email notification settings",
"description": "Update the current team email notification settings.",
"description": "Update the current team email notification settings. Set `smtp_ehlo_domain` to a valid hostname to control the SMTP EHLO domain, or `null` to use the system default.",
"operationId": "update-current-team-email-notifications",
"responses": {
"200": {
@@ -16816,6 +17071,12 @@
"boolean",
"null"
]
},
"is_force_https_enabled": {
"type": [
"boolean",
"null"
]
}
},
"type": "object"
@@ -17203,6 +17464,85 @@
]
}
},
"\/services\/{uuid}\/databases\/{database_uuid}\/imports\/uploads": {
"post": {
"tags": [
"Service databases"
],
"summary": "Upload service database import",
"operationId": "upload-service-database-import",
"responses": {
"201": {
"description": "Upload completed"
},
"422": {
"$ref": "#\/components\/responses\/422"
}
},
"security": [
{
"bearerAuth": []
}
]
}
},
"\/services\/{uuid}\/databases\/{database_uuid}\/imports": {
"post": {
"tags": [
"Service databases"
],
"summary": "Import service database backup",
"operationId": "create-service-database-import",
"requestBody": {
"required": true,
"content": {
"application\/json": {
"schema": {
"$ref": "#\/components\/schemas\/DatabaseImportRequest"
}
}
}
},
"responses": {
"202": {
"description": "Import queued"
},
"409": {
"description": "Import already active"
},
"422": {
"$ref": "#\/components\/responses\/422"
}
},
"security": [
{
"bearerAuth": []
}
]
}
},
"\/services\/{uuid}\/databases\/{database_uuid}\/imports\/{activity_id}": {
"get": {
"tags": [
"Service databases"
],
"summary": "Get service database import status",
"operationId": "get-service-database-import",
"responses": {
"200": {
"description": "Import status"
},
"404": {
"$ref": "#\/components\/responses\/404"
}
},
"security": [
{
"bearerAuth": []
}
]
}
},
"\/services\/{uuid}\/databases": {
"get": {
"tags": [
@@ -21402,6 +21742,128 @@
},
"components": {
"schemas": {
"DatabaseImportRequest": {
"type": "object",
"oneOf": [
{
"required": [
"source",
"upload_id"
],
"properties": {
"source": {
"type": "string",
"enum": [
"upload"
]
},
"upload_id": {
"type": "string",
"format": "uuid"
},
"dump_all": {
"type": "boolean",
"default": false
}
},
"type": "object"
},
{
"required": [
"source",
"s3_storage_uuid",
"path"
],
"properties": {
"source": {
"type": "string",
"enum": [
"s3"
]
},
"s3_storage_uuid": {
"type": "string"
},
"path": {
"type": "string"
},
"dump_all": {
"type": "boolean",
"default": false
}
},
"type": "object"
},
{
"required": [
"source",
"path"
],
"properties": {
"source": {
"type": "string",
"enum": [
"server"
]
},
"path": {
"type": "string",
"example": "\/var\/backups\/database.sql.gz"
},
"dump_all": {
"type": "boolean",
"default": false
}
},
"type": "object"
}
],
"additionalProperties": false
},
"DatabaseImportStatus": {
"properties": {
"id": {
"type": "integer"
},
"status": {
"type": "string",
"enum": [
"queued",
"in_progress",
"finished",
"error",
"killed",
"cancelled",
"closed"
]
},
"exit_code": {
"type": [
"integer",
"null"
]
},
"output": {
"type": "string"
},
"created_at": {
"type": "string",
"format": "date-time"
},
"updated_at": {
"type": "string",
"format": "date-time"
},
"finished_at": {
"type": [
"string",
"null"
],
"format": "date-time"
}
},
"type": "object"
},
"VolumeBackupScheduleRequest": {
"required": [
"frequency"
@@ -21474,7 +21936,7 @@
},
"timeout": {
"type": "integer",
"default": 3600,
"default": 36000,
"maximum": 36000,
"minimum": 60
}
@@ -22520,6 +22982,9 @@
"deployment_queue_limit": {
"type": "integer"
},
"backup_compression_cpu_percentage": {
"type": "integer"
},
"dynamic_timeout": {
"type": "integer"
},
@@ -22630,6 +23095,26 @@
"connection_timeout": {
"type": "integer",
"description": "SSH connection timeout in seconds."
},
"docker_version": {
"type": "string",
"nullable": true,
"description": "Detected Docker Engine version on the server."
},
"docker_version_checked_at": {
"type": "string",
"nullable": true,
"description": "When Docker Engine version was last detected."
},
"compose_version": {
"type": "string",
"nullable": true,
"description": "Detected Docker Compose plugin version on the server."
},
"compose_version_checked_at": {
"type": "string",
"nullable": true,
"description": "When Docker Compose version was last detected."
}
},
"type": "object"
@@ -22969,6 +23454,10 @@
}
},
"tags": [
{
"name": "Secret Managers",
"description": "Secret Managers"
},
{
"name": "Applications",
"description": "Applications"
@@ -23009,6 +23498,10 @@
"name": "Hetzner",
"description": "Hetzner"
},
{
"name": "Settings",
"description": "Settings"
},
{
"name": "Notifications",
"description": "Notifications"
+328 -3
View File
@@ -7,6 +7,45 @@ servers:
url: 'https://app.coolify.io/api/v1'
description: 'Coolify Cloud API. Change the host to your own instance if you are self-hosting.'
paths:
'/applications/{uuid}/secret-manager':
patch:
tags:
- 'Secret Managers'
summary: 'Configure Application Secret Manager'
description: 'Configure the secret manager source used by an application.'
operationId: configure-application-secret-manager
parameters:
-
name: uuid
in: path
required: true
schema:
type: string
requestBody:
required: true
content:
application/json:
schema:
required:
- integration_token_uuid
properties:
integration_token_uuid:
type: string
settings:
type: object
type: object
responses:
'200':
description: 'Secret manager configured.'
'401':
$ref: '#/components/responses/401'
'404':
$ref: '#/components/responses/404'
'422':
$ref: '#/components/responses/422'
security:
-
bearerAuth: []
/applications:
get:
tags:
@@ -3720,6 +3759,56 @@ paths:
security:
-
bearerAuth: []
'/databases/{uuid}/imports/uploads':
post:
tags:
- Databases
summary: 'Upload database import'
operationId: upload-database-import
responses:
'201':
description: 'Upload completed'
'422':
$ref: '#/components/responses/422'
security:
-
bearerAuth: []
'/databases/{uuid}/imports':
post:
tags:
- Databases
summary: 'Import database backup'
operationId: create-database-import
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/DatabaseImportRequest'
responses:
'202':
description: 'Import queued'
'409':
description: 'Import already active'
'422':
$ref: '#/components/responses/422'
security:
-
bearerAuth: []
'/databases/{uuid}/imports/{activity_id}':
get:
tags:
- Databases
summary: 'Get database import status'
operationId: get-database-import
responses:
'200':
description: 'Import status'
'404':
$ref: '#/components/responses/404'
security:
-
bearerAuth: []
/databases:
get:
tags:
@@ -7490,12 +7579,86 @@ paths:
security:
-
bearerAuth: []
/settings/email:
get:
tags:
- Settings
summary: 'Get instance email settings'
description: 'Get instance-wide SMTP and Resend settings. Requires a root-team token belonging to a root-team admin or owner. Sensitive fields require the `read:sensitive` or `root` token ability.'
operationId: get-instance-email-settings
responses:
'200':
description: 'Instance email settings.'
'401':
$ref: '#/components/responses/401'
'403':
description: Forbidden.
security:
-
bearerAuth: []
patch:
tags:
- Settings
summary: 'Update instance email settings'
description: 'Update instance-wide SMTP and Resend settings. Requires `write:sensitive` and a root-team token belonging to a root-team admin or owner.'
operationId: update-instance-email-settings
responses:
'200':
description: 'Updated instance email settings.'
'401':
$ref: '#/components/responses/401'
'403':
description: Forbidden.
'422':
$ref: '#/components/responses/422'
security:
-
bearerAuth: []
/security/integration-tokens:
post:
tags:
- 'Secret Managers'
summary: 'Create Secret Manager Token'
description: 'Create and validate a Doppler, Infisical, or Vault integration token.'
operationId: create-secret-manager-integration-token
requestBody:
required: true
content:
application/json:
schema:
required:
- provider
- name
- token
properties:
provider:
type: string
enum: [doppler, infisical, vault]
name:
type: string
token:
type: string
metadata:
type: object
type: object
responses:
'201':
description: 'Integration token created.'
'400':
$ref: '#/components/responses/400'
'401':
$ref: '#/components/responses/401'
'422':
$ref: '#/components/responses/422'
security:
-
bearerAuth: []
/notifications/email:
get:
tags:
- Notifications
summary: 'Get email notification settings'
description: 'Get the current team email notification settings. Encrypted secrets are only returned when the token has `read:sensitive` (or `root`) and the user is a team admin/owner.'
description: 'Get the current team email notification settings, including `smtp_ehlo_domain`, the hostname sent with SMTP EHLO. Encrypted secrets are only returned when the token has `read:sensitive` (or `root`) and the user is a team admin/owner.'
operationId: get-current-team-email-notifications
responses:
'200':
@@ -7511,7 +7674,7 @@ paths:
tags:
- Notifications
summary: 'Update email notification settings'
description: 'Update the current team email notification settings.'
description: 'Update the current team email notification settings. Set `smtp_ehlo_domain` to a valid hostname to control the SMTP EHLO domain, or `null` to use the system default.'
operationId: update-current-team-email-notifications
responses:
'200':
@@ -10658,6 +10821,8 @@ paths:
type: [boolean, 'null']
is_stripprefix_enabled:
type: [boolean, 'null']
is_force_https_enabled:
type: [boolean, 'null']
type: object
responses:
'200':
@@ -10909,6 +11074,56 @@ paths:
security:
-
bearerAuth: []
'/services/{uuid}/databases/{database_uuid}/imports/uploads':
post:
tags:
- 'Service databases'
summary: 'Upload service database import'
operationId: upload-service-database-import
responses:
'201':
description: 'Upload completed'
'422':
$ref: '#/components/responses/422'
security:
-
bearerAuth: []
'/services/{uuid}/databases/{database_uuid}/imports':
post:
tags:
- 'Service databases'
summary: 'Import service database backup'
operationId: create-service-database-import
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/DatabaseImportRequest'
responses:
'202':
description: 'Import queued'
'409':
description: 'Import already active'
'422':
$ref: '#/components/responses/422'
security:
-
bearerAuth: []
'/services/{uuid}/databases/{database_uuid}/imports/{activity_id}':
get:
tags:
- 'Service databases'
summary: 'Get service database import status'
operationId: get-service-database-import
responses:
'200':
description: 'Import status'
'404':
$ref: '#/components/responses/404'
security:
-
bearerAuth: []
'/services/{uuid}/databases':
get:
tags:
@@ -13601,6 +13816,92 @@ paths:
bearerAuth: []
components:
schemas:
DatabaseImportRequest:
type: object
oneOf:
-
required:
- source
- upload_id
properties:
source:
type: string
enum:
- upload
upload_id:
type: string
format: uuid
dump_all:
type: boolean
default: false
type: object
-
required:
- source
- s3_storage_uuid
- path
properties:
source:
type: string
enum:
- s3
s3_storage_uuid:
type: string
path:
type: string
dump_all:
type: boolean
default: false
type: object
-
required:
- source
- path
properties:
source:
type: string
enum:
- server
path:
type: string
example: /var/backups/database.sql.gz
dump_all:
type: boolean
default: false
type: object
additionalProperties: false
DatabaseImportStatus:
properties:
id:
type: integer
status:
type: string
enum:
- queued
- in_progress
- finished
- error
- killed
- cancelled
- closed
exit_code:
type:
- integer
- 'null'
output:
type: string
created_at:
type: string
format: date-time
updated_at:
type: string
format: date-time
finished_at:
type:
- string
- 'null'
format: date-time
type: object
VolumeBackupScheduleRequest:
required:
- frequency
@@ -13659,7 +13960,7 @@ components:
minimum: 0
timeout:
type: integer
default: 3600
default: 36000
maximum: 36000
minimum: 60
type: object
@@ -14429,6 +14730,8 @@ components:
type: integer
deployment_queue_limit:
type: integer
backup_compression_cpu_percentage:
type: integer
dynamic_timeout:
type: integer
force_disabled:
@@ -14504,6 +14807,22 @@ components:
connection_timeout:
type: integer
description: 'SSH connection timeout in seconds.'
docker_version:
type: string
nullable: true
description: 'Detected Docker Engine version on the server.'
docker_version_checked_at:
type: string
nullable: true
description: 'When Docker Engine version was last detected.'
compose_version:
type: string
nullable: true
description: 'Detected Docker Compose plugin version on the server.'
compose_version_checked_at:
type: string
nullable: true
description: 'When Docker Compose version was last detected.'
type: object
Service:
description: 'Service model'
@@ -14733,6 +15052,9 @@ components:
description: 'Go to `Keys & Tokens` / `API tokens` and create a new token. Use the token as the bearer token.'
scheme: bearer
tags:
-
name: 'Secret Managers'
description: 'Secret Managers'
-
name: Applications
description: Applications
@@ -14763,6 +15085,9 @@ tags:
-
name: Hetzner
description: Hetzner
-
name: Settings
description: Settings
-
name: Notifications
description: Notifications
@@ -420,4 +420,5 @@
</template>
</div>
@endif
@include('livewire.project.shared.dns-provider-management')
</div>
@@ -156,7 +156,8 @@
isErrorButton submitAction="removeDomainByKey({{ $domainKey }})" :actions="[
'This domain will be removed from the application.',
'Redeploy or restart may be required for proxy changes.',
]" :confirmWithPassword="false" :confirmWithText="false" step2ButtonText="Remove domain">
]" :checkboxes="[['id' => 'deleteManagedDns', 'label' => 'Also delete the DNS record created by Coolify, if present.']]"
:confirmWithPassword="false" :confirmWithText="false" step2ButtonText="Remove domain">
<x-slot:trigger>
<button type="button" class="icon-button shrink-0 text-red-500 hover:text-red-600 dark:text-red-400 dark:hover:text-red-300"
title="Remove domain" aria-label="Remove domain">
@@ -362,4 +362,5 @@
</template>
</div>
@endif
@include('livewire.project.shared.dns-provider-management')
</div>
@@ -192,7 +192,8 @@
submitAction="removeDomainByKey({{ $domainKey }})" :actions="[
'This domain will be removed from the service application.',
'Redeploy or restart may be required for proxy changes.',
]" :confirmWithPassword="false" :confirmWithText="false"
]" :checkboxes="[['id' => 'deleteManagedDns', 'label' => 'Also delete the DNS record created by Coolify, if present.']]"
:confirmWithPassword="false" :confirmWithText="false"
step2ButtonText="Remove domain">
<x-slot:trigger>
<button type="button"
@@ -8,10 +8,12 @@
x-bind:aria-expanded="dnsEntriesOpen" title="DNS entries for this server">
<x-reicon name="globe" class="size-3.5" />
DNS entries
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2"
stroke="currentColor" class="size-3.5 shrink-0 opacity-60">
<path stroke-linecap="round" stroke-linejoin="round" d="m8 9 4-4 4 4m0 6-4 4-4-4" />
</svg>
<span class="inline-flex transition-transform" :class="dnsEntriesOpen && 'rotate-180'">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2"
stroke="currentColor" class="size-3.5 shrink-0 opacity-60">
<path stroke-linecap="round" stroke-linejoin="round" d="m8 9 4-4 4 4m0 6-4 4-4-4" />
</svg>
</span>
</button>
<div x-show="dnsEntriesOpen" x-cloak role="menu" x-transition.origin.top.right
class="listbox-panel left-auto! right-0! z-[90]! w-56! min-w-56!">
@@ -23,7 +25,7 @@
</button>
@endif
<button type="button" class="listbox-option justify-start! gap-2.5!" role="menuitem"
@click="dnsEntriesOpen = false; $dispatch('open-dns-records-modal')">
wire:click="openManualDnsRecords" @click="dnsEntriesOpen = false">
<x-reicon name="documentation" class="size-3.5 shrink-0 opacity-70" />
Manual records
</button>
@@ -176,6 +178,7 @@
<th class="px-3 py-2 font-medium">Type</th>
<th class="px-3 py-2 font-medium">Name</th>
<th class="px-3 py-2 font-medium">Value</th>
<th class="px-3 py-2 font-medium"><span class="sr-only">Action</span></th>
</tr>
</thead>
<tbody class="divide-y divide-neutral-200 dark:divide-coolgray-300">
@@ -196,6 +199,16 @@
'break' => true,
])
</td>
<td class="px-3 py-2.5 text-right">
@php($recordProviders = collect($dnsProviderProposals)->where('hostname', $record['name'])->where('managed', false))
@foreach ($recordProviders as $provider)
<x-forms.button type="button"
wire:click="createManagedDnsRecord({{ \Illuminate\Support\Js::from($record['name']) }}, {{ $provider['zone_id'] }}, {{ \Illuminate\Support\Js::from($record['value']) }})"
wire:target="createManagedDnsRecord">
Add with {{ $provider['credential'] }}
</x-forms.button>
@endforeach
</td>
</tr>
@endforeach
</tbody>
@@ -0,0 +1,44 @@
@if ($showDnsProviderModal)
<div x-data="{ modalOpen: @entangle('showDnsProviderModal') }" class="relative h-auto w-auto"
:class="{ 'z-40': modalOpen }" @keydown.escape.window="modalOpen = false; $wire.closeDnsProviderModal()">
<template x-teleport="body">
<div x-show="modalOpen" class="fixed inset-0 z-99 overflow-y-auto" x-cloak>
<div class="absolute inset-0 bg-black/50 backdrop-blur-[2px]" @click="modalOpen = false; $wire.closeDnsProviderModal()"></div>
<div class="relative flex min-h-full items-start justify-center p-4 sm:items-center">
<div x-show="modalOpen" x-trap.inert.noscroll="modalOpen"
class="application-settings-form application-settings-section relative flex w-full max-w-2xl flex-col overflow-hidden">
<header class="flex-nowrap!"><h3 class="min-w-0 flex-1 truncate">Configure DNS</h3>
<button type="button" wire:click="closeDnsProviderModal" class="icon-button" aria-label="Close"><x-reicon name="x" class="size-4" /></button>
</header>
<div class="application-settings-section-body flex flex-col gap-3">
<p class="text-sm text-neutral-600 dark:text-fg-dim">Coolify matched each hostname to a zone available through your connected DNS credentials.</p>
@foreach ($dnsProviderProposals as $proposal)
@php($key = $proposal['hostname'].'|'.$proposal['zone_id'])
<div wire:key="dns-proposal-{{ $key }}" class="flex flex-col gap-3 rounded-lg border border-neutral-200 p-3 dark:border-white/[0.08] sm:flex-row sm:items-center sm:justify-between">
<div class="min-w-0 text-sm"><div class="truncate font-medium text-black dark:text-fg">{{ $proposal['hostname'] }}</div>
<div class="text-xs text-neutral-500 dark:text-fg-dim">{{ $proposal['credential'] }} · {{ $proposal['zone'] }} · {{ $proposal['target'] }}</div>
</div>
@if ($proposal['managed'])
<x-status-badge status="Managed by Coolify" type="success" />
@elseif (isset($dnsProviderConflicts[$key]))
@php($conflict = $dnsProviderConflicts[$key])
<div class="flex flex-col items-end gap-2 text-xs"><span class="text-red-600 dark:text-red-400">Currently {{ $conflict['current'] }}</span>
<x-modal-confirmation title="Replace conflicting DNS record?" isErrorButton buttonTitle="Replace record"
submitAction="replaceManagedDnsRecord({{ \Illuminate\Support\Js::from($proposal['hostname']) }}, {{ $proposal['zone_id'] }})"
:actions="['Replace '.$proposal['hostname'].' value '.$conflict['current'].' with '.$conflict['proposed'], 'Coolify will manage the replaced record.']"
:confirmWithPassword="false" :confirmWithText="false" step2ButtonText="Replace record" />
</div>
@else
<x-forms.button type="button"
wire:click="createManagedDnsRecord({{ \Illuminate\Support\Js::from($proposal['hostname']) }}, {{ $proposal['zone_id'] }})"
wire:target="createManagedDnsRecord" isHighlighted>Create DNS record</x-forms.button>
@endif
</div>
@endforeach
</div>
</div>
</div>
</div>
</template>
</div>
@endif
@@ -39,6 +39,13 @@
<span class="text-xs text-red-500">{{ $message }}</span>
@enderror
</fieldset>
<div class="rounded-lg border border-neutral-200 p-1 dark:border-white/[0.08]">
<x-forms.checkbox id="edit-automatic-dns" label="Automatically configure DNS" fullWidth
wire:model.live="automaticDns" />
<p class="px-2.5 pb-2 text-[11px] text-neutral-500 dark:text-fg-dim">
Create DNS records automatically when a new domain has one unambiguous matching credential.
</p>
</div>
@else
<div class="rounded-lg border border-neutral-200 bg-neutral-50 p-3 text-[11px] leading-5 text-neutral-600 dark:border-white/[0.08] dark:bg-white/[0.025] dark:text-fg-dim">
<div class="font-medium text-black dark:text-fg">Capability: Secrets (read-only)</div>
@@ -60,6 +67,38 @@
Create a replacement token in Cloudflare
</a>
</div>
<div class="rounded-lg border border-neutral-200 dark:border-white/[0.08]">
<div class="flex items-center justify-between gap-3 p-3">
<div class="text-xs text-neutral-600 dark:text-fg-dim">
<div class="font-medium text-black dark:text-fg">Domains this token can manage</div>
<div>{{ $zoneCount }} accessible {{ Str::plural('zone', $zoneCount) }}</div>
@if (data_get($integrationToken->metadata, 'zones_synced_at'))
<div>Last refreshed {{ \Carbon\Carbon::parse(data_get($integrationToken->metadata, 'zones_synced_at'))->diffForHumans() }}</div>
@endif
</div>
<x-forms.button type="button" wire:click="refreshZones" wire:target="refreshZones">Refresh zones</x-forms.button>
</div>
@if ($zones !== [])
<div class="max-h-48 overflow-y-auto border-t border-neutral-200 dark:border-white/[0.08]">
@foreach ($zones as $zone)
<div wire:key="integration-token-zone-{{ $zone['id'] }}"
class="flex items-center justify-between gap-3 border-b border-neutral-200 px-3 py-2.5 last:border-b-0 dark:border-white/[0.08]">
<div class="min-w-0">
<div class="truncate text-sm font-medium text-black dark:text-fg">{{ $zone['name'] }}</div>
@if ($zone['account_name'])
<div class="truncate text-[11px] text-neutral-500 dark:text-fg-dim">{{ $zone['account_name'] }}</div>
@endif
</div>
@if ($zone['managed_records_count'] > 0)
<div class="shrink-0 text-[11px] text-neutral-500 dark:text-fg-dim">
{{ $zone['managed_records_count'] }} managed {{ Str::plural('record', $zone['managed_records_count']) }}
</div>
@endif
</div>
@endforeach
</div>
@endif
</div>
@endif
<div class="flex items-center justify-between gap-2 border-t border-neutral-200 pt-4 dark:border-white/[0.08]">
@@ -51,6 +51,13 @@
<span class="text-xs text-red-500">{{ $message }}</span>
@enderror
</fieldset>
<div class="rounded-lg border border-neutral-200 p-1 dark:border-white/[0.08]">
<x-forms.checkbox id="automatic-dns" label="Automatically configure DNS" fullWidth
wire:model.live="automaticDns" />
<p class="px-2.5 pb-2 text-[11px] text-neutral-500 dark:text-fg-dim">
Create DNS records automatically when a new domain has one unambiguous matching credential.
</p>
</div>
@else
<div class="rounded-lg border border-neutral-200 bg-neutral-50 p-3 text-[11px] leading-5 text-neutral-600 dark:border-white/[0.08] dark:bg-white/[0.025] dark:text-fg-dim">
<div class="font-medium text-black dark:text-fg">Capability: Secrets (read-only)</div>
@@ -63,6 +63,16 @@
<span x-text="capability"
class="rounded-full bg-neutral-100 px-2 py-0.5 text-[10px] font-medium uppercase text-neutral-600 dark:bg-white/[0.06] dark:text-fg-dim"></span>
</template>
@if ($savedToken->provider === 'cloudflare')
@if ($savedToken->automaticDnsEnabled())
<span class="rounded-full bg-green-100 px-2 py-0.5 text-[10px] font-medium uppercase text-green-700 dark:bg-green-500/10 dark:text-green-400">
Auto DNS
</span>
@endif
<span class="text-[11px] text-neutral-500 dark:text-fg-dim">
{{ $savedToken->dns_zones_count }} {{ Str::plural('zone', $savedToken->dns_zones_count) }}
</span>
@endif
</div>
<button type="button" class="icon-button" title="Edit integration token"
:aria-label="`Edit ${tokenName}`" @click="modalOpen=true">
+6
View File
@@ -309,6 +309,9 @@ Route::group([
Route::post('/databases/keydb', [DatabasesController::class, 'create_database_keydb'])->middleware(['api.ability:write']);
Route::get('/databases/{uuid}', [DatabasesController::class, 'database_by_uuid'])->middleware(['api.ability:read']);
Route::post('/databases/{uuid}/imports/uploads', [DatabasesController::class, 'upload_import'])->middleware(['api.ability:deploy'])->name('api.databases.imports.upload');
Route::post('/databases/{uuid}/imports', [DatabasesController::class, 'create_import'])->middleware(['api.ability:deploy'])->name('api.databases.imports.store');
Route::get('/databases/{uuid}/imports/{activity_id}', [DatabasesController::class, 'show_import'])->middleware(['api.ability:read'])->name('api.databases.imports.show');
Route::get('/databases/{uuid}/backups', [DatabasesController::class, 'database_backup_details_uuid'])->middleware(['api.ability:read']);
Route::get('/databases/{uuid}/backups/{scheduled_backup_uuid}/executions', [DatabasesController::class, 'list_backup_executions'])->middleware(['api.ability:read']);
Route::patch('/databases/{uuid}', [DatabasesController::class, 'update_by_uuid'])->middleware(['api.ability:write']);
@@ -408,6 +411,9 @@ Route::group([
Route::get('/services/{uuid}/databases', [ServiceDatabasesController::class, 'index'])->middleware(['api.ability:read']);
Route::get('/services/{uuid}/databases/{database_uuid}', [ServiceDatabasesController::class, 'show'])->middleware(['api.ability:read']);
Route::post('/services/{uuid}/databases/{database_uuid}/imports/uploads', [ServiceDatabasesController::class, 'upload_import'])->middleware(['api.ability:deploy'])->name('api.service-databases.imports.upload');
Route::post('/services/{uuid}/databases/{database_uuid}/imports', [ServiceDatabasesController::class, 'create_import'])->middleware(['api.ability:deploy'])->name('api.service-databases.imports.store');
Route::get('/services/{uuid}/databases/{database_uuid}/imports/{activity_id}', [ServiceDatabasesController::class, 'show_import'])->middleware(['api.ability:read'])->name('api.service-databases.imports.show');
Route::patch('/services/{uuid}/databases/{database_uuid}', [ServiceDatabasesController::class, 'update'])->middleware(['api.ability:write']);
Route::get('/services/{uuid}/databases/{database_uuid}/logs', [ServiceDatabasesController::class, 'logs'])->middleware(['api.ability:read']);
Route::post('/services/{uuid}/databases/{database_uuid}/start', [ServiceDatabasesController::class, 'start'])->middleware(['api.ability:deploy']);
@@ -0,0 +1,85 @@
<?php
use App\Actions\Database\StartDatabaseImport;
use App\Models\AuditEvent;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\StandalonePostgresql;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Str;
use Spatie\Activitylog\Models\Activity;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::forceCreate(['id' => 0, 'is_api_enabled' => true]);
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user, ['role' => 'owner']);
session(['currentTeam' => $this->team]);
$this->token = $this->user->tokens()->create(['name' => 'imports', 'token' => hash('sha256', 'secret'), 'abilities' => ['deploy', 'read'], 'team_id' => $this->team->id]);
$this->headers = ['Authorization' => 'Bearer '.$this->token->id.'|secret'];
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
$this->destination = StandaloneDocker::firstOrCreate(['server_id' => $this->server->id, 'network' => 'coolify'], ['uuid' => (string) Str::uuid(), 'name' => 'docker']);
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
});
test('validates standalone import source and hides foreign databases', function () {
$database = StandalonePostgresql::create(['uuid' => (string) Str::uuid(), 'name' => 'db', 'postgres_user' => 'postgres', 'postgres_password' => 'password', 'postgres_db' => 'db', 'image' => 'postgres:17', 'status' => 'running', 'environment_id' => $this->environment->id, 'destination_id' => $this->destination->id, 'destination_type' => $this->destination->getMorphClass()]);
$this->withHeaders($this->headers)->postJson("/api/v1/databases/{$database->uuid}/imports", ['source' => 'upload', 'path' => '../bad'])
->assertUnprocessable()->assertJsonValidationErrors(['upload_id', 'path']);
$otherTeam = Team::factory()->create();
$otherProject = Project::factory()->create(['team_id' => $otherTeam->id]);
$otherEnvironment = Environment::factory()->create(['project_id' => $otherProject->id]);
$foreign = StandalonePostgresql::create(['uuid' => (string) Str::uuid(), 'name' => 'foreign', 'postgres_user' => 'postgres', 'postgres_password' => 'password', 'postgres_db' => 'db', 'image' => 'postgres:17', 'status' => 'running', 'environment_id' => $otherEnvironment->id, 'destination_id' => $this->destination->id, 'destination_type' => $this->destination->getMorphClass()]);
$this->withHeaders($this->headers)->postJson("/api/v1/databases/{$foreign->uuid}/imports", ['source' => 'server', 'path' => '/tmp/a.sql'])->assertNotFound();
});
test('requires deploy ability to start standalone import', function () {
$database = StandalonePostgresql::create(['uuid' => (string) Str::uuid(), 'name' => 'db', 'postgres_user' => 'postgres', 'postgres_password' => 'password', 'postgres_db' => 'db', 'image' => 'postgres:17', 'status' => 'running', 'environment_id' => $this->environment->id, 'destination_id' => $this->destination->id, 'destination_type' => $this->destination->getMorphClass()]);
$read = $this->user->createToken('read', ['read']);
$this->withToken($read->plainTextToken)->postJson("/api/v1/databases/{$database->uuid}/imports", ['source' => 'server', 'path' => '/tmp/a.sql'])->assertForbidden();
});
test('audits a successfully queued standalone import', function () {
$database = StandalonePostgresql::create(['uuid' => (string) Str::uuid(), 'name' => 'db', 'postgres_user' => 'postgres', 'postgres_password' => 'password', 'postgres_db' => 'db', 'image' => 'postgres:17', 'status' => 'running', 'environment_id' => $this->environment->id, 'destination_id' => $this->destination->id, 'destination_type' => $this->destination->getMorphClass()]);
$activity = Activity::create(['log_name' => 'default', 'description' => 'queued', 'properties' => ['status' => 'queued']]);
$action = Mockery::mock(StartDatabaseImport::class);
$action->shouldReceive('handle')->once()->andReturn($activity);
app()->instance(StartDatabaseImport::class, $action);
$this->withHeaders($this->headers)
->postJson("/api/v1/databases/{$database->uuid}/imports", ['source' => 'server', 'path' => '/tmp/backup.sql'])
->assertAccepted();
$event = AuditEvent::query()->where('event', 'api.database.import_started')->sole();
expect($event->team_id)->toBe($this->team->id)
->and($event->resource_uuid)->toBe($database->uuid)
->and($event->resource_name)->toBe($database->name)
->and($event->metadata['source'])->toBe('server')
->and($event->metadata['activity_id'])->toBe($activity->id);
});
test('returns only a team and resource scoped import activity', function () {
$database = StandalonePostgresql::create(['uuid' => (string) Str::uuid(), 'name' => 'db', 'postgres_user' => 'postgres', 'postgres_password' => 'password', 'postgres_db' => 'db', 'image' => 'postgres:17', 'status' => 'running', 'environment_id' => $this->environment->id, 'destination_id' => $this->destination->id, 'destination_type' => $this->destination->getMorphClass()]);
$activity = Activity::create(['log_name' => 'default', 'description' => json_encode([['order' => 1, 'output' => 'restored', 'type' => 'stdout']]), 'properties' => ['team_id' => $this->team->id, 'type_uuid' => $database->uuid, 'operation' => 'database_import', 'status' => 'finished', 'exitCode' => 0]]);
$this->withHeaders($this->headers)->getJson("/api/v1/databases/{$database->uuid}/imports/{$activity->id}")
->assertOk()->assertJson(['id' => $activity->id, 'status' => 'finished', 'exit_code' => 0, 'output' => 'restored'])
->assertJsonMissingPath('command');
$activity->properties = $activity->properties->merge(['team_id' => $this->team->id + 1]);
$activity->save();
$this->withHeaders($this->headers)->getJson("/api/v1/databases/{$database->uuid}/imports/{$activity->id}")->assertNotFound();
});
@@ -0,0 +1,14 @@
<?php
use Illuminate\Support\Facades\Route;
test('registers standalone and service database import routes with abilities', function () {
$routes = collect(Route::getRoutes()->getRoutes())->keyBy(fn ($route) => $route->getName());
foreach (['api.databases.imports.upload', 'api.databases.imports.store', 'api.databases.imports.show', 'api.service-databases.imports.upload', 'api.service-databases.imports.store', 'api.service-databases.imports.show'] as $name) {
expect($routes)->toHaveKey($name);
}
expect($routes['api.databases.imports.store']->gatherMiddleware())->toContain('api.ability:deploy')
->and($routes['api.databases.imports.show']->gatherMiddleware())->toContain('api.ability:read');
});
@@ -0,0 +1,42 @@
<?php
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Str;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::forceCreate(['id' => 0, 'is_api_enabled' => true]);
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user, ['role' => 'owner']);
session(['currentTeam' => $this->team]);
$this->token = $this->user->tokens()->create(['name' => 'imports', 'token' => hash('sha256', 'secret'), 'abilities' => ['deploy', 'read'], 'team_id' => $this->team->id]);
$this->headers = ['Authorization' => 'Bearer '.$this->token->id.'|secret'];
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
$this->destination = StandaloneDocker::firstOrCreate(['server_id' => $this->server->id, 'network' => 'coolify'], ['uuid' => (string) Str::uuid(), 'name' => 'docker']);
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
});
use App\Models\Service;
use App\Models\ServiceDatabase;
test('validates service database imports and binds database to service', function () {
$service = Service::factory()->create(['environment_id' => $this->environment->id, 'server_id' => $this->server->id, 'destination_id' => $this->destination->id, 'destination_type' => $this->destination->getMorphClass(), 'docker_compose_raw' => "services:\n postgres:\n image: postgres:17\n"]);
$database = ServiceDatabase::create(['uuid' => (string) Str::uuid(), 'name' => 'postgres', 'service_id' => $service->id, 'image' => 'postgres:17']);
$url = "/api/v1/services/{$service->uuid}/databases/{$database->uuid}/imports";
$this->withHeaders($this->headers)->postJson($url, ['source' => 's3', 'upload_id' => (string) Str::uuid()])
->assertUnprocessable()->assertJsonValidationErrors(['upload_id', 's3_storage_uuid', 'path']);
$otherService = Service::factory()->create(['environment_id' => $this->environment->id, 'server_id' => $this->server->id, 'destination_id' => $this->destination->id, 'destination_type' => $this->destination->getMorphClass(), 'docker_compose_raw' => "services: {}\n"]);
$this->withHeaders($this->headers)->postJson("/api/v1/services/{$otherService->uuid}/databases/{$database->uuid}/imports", ['source' => 'server', 'path' => '/tmp/a.sql'])->assertNotFound();
});
+1
View File
@@ -872,6 +872,7 @@ test('critical operational events persist with their source action and actor', f
'api.database.started',
'api.database.stopped',
'api.database.restarted',
'api.database.import_started',
]);
test('audit log uses the standard horizontally scrollable table layout on mobile', function () {
+142
View File
@@ -0,0 +1,142 @@
<?php
use App\Events\DnsRecordConfigurationFinished;
use App\Jobs\ConfigureDnsRecordJob;
use App\Models\DnsProviderZone;
use App\Models\IntegrationToken;
use App\Models\ManagedDnsRecord;
use App\Models\Team;
use App\Services\Dns\CloudflareDnsProvider;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Http;
uses(RefreshDatabase::class);
test('cloudflare automatic dns is enabled by default and can be disabled per credential', function () {
$defaultToken = IntegrationToken::factory()->create(['metadata' => null]);
$manualToken = IntegrationToken::factory()->create(['metadata' => ['automatic_dns' => false]]);
expect($defaultToken->automaticDnsEnabled())->toBeTrue()
->and($manualToken->automaticDnsEnabled())->toBeFalse();
});
test('cloudflare zones are discovered and cached for a token', function () {
$token = IntegrationToken::factory()->create(['provider' => 'cloudflare', 'token' => 'secret', 'capabilities' => ['dns']]);
Http::fake(['https://api.cloudflare.com/client/v4/zones*' => Http::response([
'success' => true,
'result' => [
['id' => 'zone-1', 'name' => 'example.com', 'account' => ['id' => 'account-1', 'name' => 'Production']],
['id' => 'zone-2', 'name' => 'example.org', 'account' => ['id' => 'account-1', 'name' => 'Production']],
],
'result_info' => ['page' => 1, 'total_pages' => 1],
])]);
app(CloudflareDnsProvider::class)->syncZones($token);
expect($token->dnsZones()->pluck('name')->all())->toBe(['example.com', 'example.org'])
->and($token->fresh()->metadata['zones_synced_at'])->not->toBeNull();
});
test('all credentials for the most specific zone are returned without suffix false positives', function () {
$team = Team::factory()->create();
$firstToken = IntegrationToken::factory()->for($team)->create(['provider' => 'cloudflare']);
$secondToken = IntegrationToken::factory()->for($team)->create(['provider' => 'cloudflare']);
DnsProviderZone::factory()->for($firstToken)->create(['name' => 'example.com']);
DnsProviderZone::factory()->for($secondToken)->create(['name' => 'example.com']);
DnsProviderZone::factory()->for($firstToken)->create(['name' => 'customer.example.com']);
$provider = app(CloudflareDnsProvider::class);
expect($provider->findZones($team->id, 'api.customer.example.com'))->toHaveCount(1)
->and($provider->findZones($team->id, 'app.example.com'))->toHaveCount(2)
->and($provider->findZones($team->id, 'notexample.com'))->toBeEmpty();
});
test('a cloudflare record is created and tracked as managed by coolify', function () {
$token = IntegrationToken::factory()->create(['provider' => 'cloudflare', 'token' => 'secret']);
$zone = DnsProviderZone::factory()->for($token)->create(['provider_zone_id' => 'zone-1', 'name' => 'example.com']);
Http::fake([
'https://api.cloudflare.com/client/v4/zones/zone-1/dns_records?*' => Http::response(['success' => true, 'result' => []]),
'https://api.cloudflare.com/client/v4/zones/zone-1/dns_records' => Http::response(['success' => true, 'result' => ['id' => 'record-1']]),
]);
$record = app(CloudflareDnsProvider::class)->createRecord($zone, 'app.example.com', '203.0.113.10');
expect($record->provider_record_id)->toBe('record-1')->and($record->content)->toBe('203.0.113.10');
Http::assertSent(fn ($request) => $request->method() === 'POST'
&& $request->data()['name'] === 'app.example.com'
&& $request->data()['content'] === '203.0.113.10');
});
test('queued dns configuration creates the record and broadcasts completion', function () {
Event::fake([DnsRecordConfigurationFinished::class]);
$token = IntegrationToken::factory()->create(['provider' => 'cloudflare', 'token' => 'secret']);
$zone = DnsProviderZone::factory()->for($token)->create(['provider_zone_id' => 'zone-1', 'name' => 'example.com']);
Http::fake([
'https://api.cloudflare.com/client/v4/zones/zone-1/dns_records?*' => Http::response(['success' => true, 'result' => []]),
'https://api.cloudflare.com/client/v4/zones/zone-1/dns_records' => Http::response(['success' => true, 'result' => ['id' => 'record-1']]),
]);
$job = new ConfigureDnsRecordJob(
teamId: $token->team_id,
zoneId: $zone->id,
resourceType: null,
resourceId: null,
hostname: 'app.example.com',
content: '203.0.113.10',
);
$job->handle(app(CloudflareDnsProvider::class));
expect(ManagedDnsRecord::query()->where('name', 'app.example.com')->exists())->toBeTrue();
Event::assertDispatched(DnsRecordConfigurationFinished::class, fn ($event) => $event->successful
&& $event->hostname === 'app.example.com');
});
test('a managed record changed outside coolify is not deleted', function () {
$record = ManagedDnsRecord::factory()->create([
'provider_record_id' => 'record-1', 'type' => 'A', 'name' => 'app.example.com', 'content' => '203.0.113.10',
]);
Http::fake(['https://api.cloudflare.com/client/v4/zones/*/dns_records/record-1' => Http::response([
'success' => true,
'result' => ['id' => 'record-1', 'type' => 'A', 'name' => 'app.example.com', 'content' => '203.0.113.99'],
])]);
expect(app(CloudflareDnsProvider::class)->deleteRecord($record))->toBeFalse()->and($record->fresh())->not->toBeNull();
});
test('an unchanged managed record is deleted from cloudflare and coolify', function () {
$record = ManagedDnsRecord::factory()->create([
'provider_record_id' => 'record-1', 'type' => 'A', 'name' => 'app.example.com', 'content' => '203.0.113.10',
]);
Http::fake(['https://api.cloudflare.com/client/v4/zones/*/dns_records/record-1' => Http::sequence()
->push(['success' => true, 'result' => ['id' => 'record-1', 'type' => 'A', 'name' => 'app.example.com', 'content' => '203.0.113.10']])
->push(['success' => true, 'result' => ['id' => 'record-1']])]);
expect(app(CloudflareDnsProvider::class)->deleteRecord($record))->toBeTrue()
->and(ManagedDnsRecord::query()->find($record->id))->toBeNull();
});
test('domain dns ux prompts after add and keeps later provider actions with manual records', function () {
$applicationComponent = file_get_contents(app_path('Livewire/Project/Application/Domains.php'));
$serviceComponent = file_get_contents(app_path('Livewire/Project/Service/Domains.php'));
$dnsMenu = file_get_contents(resource_path('views/livewire/project/shared/cloudflare-autoconfigure.blade.php'));
$dnsProviderConcern = file_get_contents(app_path('Livewire/Concerns/InteractsWithDnsProviders.php'));
expect($applicationComponent)->toContain('configureDnsAfterDomainAdd($addedUrls)')
->and($serviceComponent)->toContain('configureDnsAfterDomainAdd($addedUrls)')
->and($dnsMenu)->toContain('wire:click="openManualDnsRecords"')
->and($dnsMenu)->toContain('Add with {{ $provider[\'credential\'] }}')
->and($dnsMenu)->not->toContain('Connected provider')
->and($dnsProviderConcern)->toContain('ConfigureDnsRecordJob::dispatch(')
->and($dnsProviderConcern)->toContain('Adding DNS record for {$proposal[\'hostname\']}')
->and($dnsProviderConcern)->toContain('dispatch(\'success\', $event[\'message\'])');
$providerModal = file_get_contents(resource_path('views/livewire/project/shared/dns-provider-management.blade.php'));
expect($providerModal)->toContain('Illuminate\\Support\\Js::from($proposal[\'hostname\'])')
->and($providerModal)->not->toContain('createManagedDnsRecord(@js');
});
@@ -4,6 +4,7 @@ use App\Livewire\Security\IntegrationTokenEditor;
use App\Livewire\Security\IntegrationTokenForm;
use App\Livewire\Security\IntegrationTokens;
use App\Models\AuditEvent;
use App\Models\DnsProviderZone;
use App\Models\InstanceSettings;
use App\Models\IntegrationToken;
use App\Models\Team;
@@ -46,6 +47,11 @@ test('a cloudflare dns token is validated with read only requests before it is s
'success' => true,
'result' => [],
]),
'https://api.cloudflare.com/client/v4/zones?page=1&per_page=50' => Http::response([
'success' => true,
'result' => [['id' => 'zone-id', 'name' => 'example.com', 'account' => ['id' => 'account-id', 'name' => 'Production']]],
'result_info' => ['total_pages' => 1],
]),
]);
Livewire::test(IntegrationTokenForm::class, ['modal_mode' => true])
@@ -68,11 +74,34 @@ test('a cloudflare dns token is validated with read only requests before it is s
'resource_name' => 'Production DNS',
]);
Http::assertSentCount(3);
Http::assertSentCount(4);
Http::assertSent(fn ($request) => $request->method() === 'GET'
&& $request->url() === 'https://api.cloudflare.com/client/v4/zones/zone-id/dns_records?per_page=1');
});
test('automatic dns is enabled by default and can be disabled when saving a cloudflare token', function () {
Http::fake([
'https://api.cloudflare.com/client/v4/user/tokens/verify' => Http::response(['success' => true, 'result' => ['status' => 'active']]),
'https://api.cloudflare.com/client/v4/zones?per_page=1' => Http::response(['success' => true, 'result' => [['id' => 'zone-id']]]),
'https://api.cloudflare.com/client/v4/zones/zone-id/dns_records?per_page=1' => Http::response(['success' => true, 'result' => []]),
'https://api.cloudflare.com/client/v4/zones?page=1&per_page=50' => Http::response([
'success' => true,
'result' => [['id' => 'zone-id', 'name' => 'example.com', 'account' => ['id' => 'account-id', 'name' => 'Production']]],
'result_info' => ['total_pages' => 1],
]),
]);
Livewire::test(IntegrationTokenForm::class)
->assertSet('automaticDns', true)
->set('name', 'Manual DNS')
->set('token', 'cloudflare-token')
->set('automaticDns', false)
->call('addToken')
->assertHasNoErrors();
expect(IntegrationToken::query()->sole()->automaticDnsEnabled())->toBeFalse();
});
test('deleting an integration token is audited without storing its value', function () {
$token = IntegrationToken::query()->create([
'team_id' => $this->team->id,
@@ -204,6 +233,11 @@ test('an integration token can be rotated after validating its capabilities', fu
'success' => true,
'result' => [],
]),
'https://api.cloudflare.com/client/v4/zones?page=1&per_page=50' => Http::response([
'success' => true,
'result' => [['id' => 'zone-id', 'name' => 'example.com', 'account' => ['id' => 'account-id', 'name' => 'Production']]],
'result_info' => ['total_pages' => 1],
]),
]);
$savedToken = IntegrationToken::query()->create([
@@ -252,6 +286,25 @@ test('leaving the token field blank keeps the existing integration token', funct
Http::assertNothingSent();
});
test('cloudflare token editor lists the zones managed by that token', function () {
$savedToken = IntegrationToken::factory()->for($this->team)->create([
'provider' => 'cloudflare',
'name' => 'Production DNS',
'capabilities' => ['dns'],
]);
DnsProviderZone::factory()->for($savedToken)->create([
'name' => 'example.com',
'account_name' => 'Production Account',
]);
DnsProviderZone::factory()->create(['name' => 'other-team.example']);
Livewire::test(IntegrationTokenEditor::class, ['integration_token_uuid' => $savedToken->uuid])
->assertSee('Domains this token can manage')
->assertSee('example.com')
->assertSee('Production Account')
->assertDontSee('other-team.example');
});
test('an invalid replacement does not rotate the integration token', function () {
Http::fake([
'https://api.cloudflare.com/client/v4/user/tokens/verify' => Http::response([
@@ -0,0 +1,57 @@
<?php
use App\Models\StandaloneRedis;
use App\Support\DatabaseImport\DatabaseImportCommandBuilder;
use AppModels\ServiceDatabase;
use AppModels\StandaloneMariadb;
use AppModels\StandaloneMongodb;
use AppModels\StandaloneMysql;
use AppModels\StandalonePostgresql;
function importResource(string $class, ?string $databaseType = null): object
{
$resource = Mockery::mock($class);
$resource->shouldReceive('getMorphClass')->andReturn($class);
if ($class === ServiceDatabase::class) {
$resource->shouldReceive('databaseType')->andReturn($databaseType);
}
return $resource;
}
test('builds database-specific restore commands', function (string $class, ?string $type, string $needle) {
$builder = new DatabaseImportCommandBuilder;
$command = $builder->buildRestoreCommand(importResource($class, $type), '/tmp/restore file', false);
expect($command)->toContain($needle)->toContain("'/tmp/restore file'");
})->with([
'postgresql' => [StandalonePostgresql::class, null, 'pg_restore'],
'mysql' => [StandaloneMysql::class, null, 'mysql -u $MYSQL_USER'],
'mariadb' => [StandaloneMariadb::class, null, 'mariadb -u $MARIADB_USER'],
'mongodb' => [StandaloneMongodb::class, null, 'mongorestore'],
'service postgres' => [ServiceDatabase::class, 'postgresql', 'pg_restore'],
'service mysql' => [ServiceDatabase::class, 'mysql', 'mysql -u $MYSQL_USER'],
'service mariadb' => [ServiceDatabase::class, 'mariadb', 'mariadb -u $MARIADB_USER'],
'service mongo' => [ServiceDatabase::class, 'mongodb', 'mongorestore'],
]);
test('builds dump-all commands and postgres safety scan', function () {
$builder = new DatabaseImportCommandBuilder;
$postgres = importResource(StandalonePostgresql::class);
expect($builder->buildRestoreCommand($postgres, '/tmp/dump.sql.gz', true))
->toContain('pg_terminate_backend')
->toContain("gunzip -cf '/tmp/dump.sql.gz'")
->and($builder->buildPostgresSafetyCommand($postgres, 'postgres-safe', '/tmp/dump.sql.gz'))
->toContain('COPY ... PROGRAM')
->toContain('docker exec postgres-safe');
});
test('rejects unsupported database types', function () {
$builder = new DatabaseImportCommandBuilder;
$redis = importResource(StandaloneRedis::class);
expect(fn () => $builder->buildRestoreCommand($redis, '/tmp/backup', false))
->toThrow(InvalidArgumentException::class, 'not supported');
});
+13
View File
@@ -0,0 +1,13 @@
<?php
test('documents standalone and service database import endpoints', function () {
$document = json_decode(file_get_contents(__DIR__.'/../../openapi.json'), true, flags: JSON_THROW_ON_ERROR);
expect($document['paths'])
->toHaveKey('/databases/{uuid}/imports/uploads')
->toHaveKey('/databases/{uuid}/imports')
->toHaveKey('/databases/{uuid}/imports/{activity_id}')
->toHaveKey('/services/{uuid}/databases/{database_uuid}/imports/uploads')
->toHaveKey('/services/{uuid}/databases/{database_uuid}/imports')
->toHaveKey('/services/{uuid}/databases/{database_uuid}/imports/{activity_id}');
});