feat(api): add DNS, integration token and secret manager management (#12374)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andras Bacsai
2026-10-30 13:37:32 +01:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 76b89fa8e9
commit 5116a93f42
38 changed files with 5985 additions and 698 deletions
+2
View File
@@ -40,6 +40,8 @@
- The Compose parsers (`parse()`, `applicationParser`, `serviceParser`) run on page mounts, refreshes, and deployments. Do not dispatch jobs or run server commands from them; let the caller that changes data start the work. Code that only needs the service names reads `docker_compose_raw` (`Application::composeServicesWithoutParse()`).
- MCP tools that call REST routes remove response keys from `BuildsResponse::$sensitiveKeys` (for example `content`, `client_id`, `logs`) when the token has no `read:sensitive`, and `CallsRestApi::$blockedRestPaths` blocks some routes. Check both lists when you name the fields of a new API response or add an MCP tool for a route.
## Verify the complete user flow
- Do not use a passing unit test, a successful build, or a healthy process as proof for a reported UI failure.
- Verify the exact live flow, persisted state, relevant logs, and queue state when they affect the result.
@@ -0,0 +1,99 @@
<?php
namespace App\Actions\IntegrationToken;
use App\Models\IntegrationToken;
use App\Services\Dns\CloudflareDnsProvider;
use App\Services\IntegrationTokenValidator;
use Illuminate\Support\Facades\DB;
use Lorisleiva\Actions\Concerns\AsAction;
use RuntimeException;
/**
* Creates an integration token after the provider accepted it. A Cloudflare token also gets its zones in the same
* transaction, so a token without zone access is never stored. The security page and the API use this action.
*/
class CreateIntegrationToken
{
use AsAction;
public function __construct(
private IntegrationTokenValidator $validator,
private CloudflareDnsProvider $cloudflare,
) {}
/**
* Validation rules for the token fields. `automatic_dns` and `proxied_dns` are validated by the caller.
*
* @return array<string, array<int, mixed>>
*/
public static function rules(string $provider): array
{
$rules = [
'provider' => ['required', 'in:'.implode(',', array_keys(IntegrationToken::PROVIDER_NAMES))],
'name' => ['required', 'string', 'max:255'],
'token' => ['required', 'string'],
'capabilities' => ['required', 'array', 'min:1', function (string $attribute, mixed $value, \Closure $fail): void {
if (is_array($value) && in_array('tunnel', $value, true) && ! in_array('dns', $value, true)) {
$fail('Cloudflare Tunnel also needs the DNS capability.');
}
}],
'capabilities.*' => ['required', 'in:'.implode(',', IntegrationToken::allowedCapabilities($provider))],
];
if ($provider === 'doppler') {
$rules['token'][] = 'regex:/^dp\.(st|sa)\./';
} elseif ($provider === 'infisical') {
$rules['metadata.base_url'] = ['required', 'url:http,https'];
$rules['metadata.client_id'] = ['required', 'string'];
$rules['metadata.timeout'] = ['nullable', 'integer', 'min:1', 'max:300'];
} elseif ($provider === 'vault') {
$rules['metadata.base_url'] = ['required', 'url:http,https'];
$rules['metadata.namespace'] = ['nullable', 'string'];
$rules['metadata.timeout'] = ['nullable', 'integer', 'min:1', 'max:300'];
}
return $rules;
}
/**
* @param array<int, string> $capabilities
* @param array<string, mixed> $metadata Provider settings (base URL, client ID, ...); empty values are dropped.
*
* @throws RuntimeException when the provider rejects the token or Cloudflare zones cannot be read
*/
public function handle(
int $teamId,
string $provider,
string $name,
string $token,
array $capabilities,
array $metadata = [],
bool $automaticDns = true,
bool $proxiedDns = false,
): IntegrationToken {
$metadata = array_filter($metadata, fn ($value) => filled($value));
if ($provider === 'cloudflare' && ! $automaticDns) {
$metadata['automatic_dns'] = false;
}
if ($provider === 'cloudflare' && $proxiedDns) {
$metadata['proxied_dns'] = true;
}
if (! $this->validator->validate($provider, $token, $capabilities, $metadata)) {
throw new RuntimeException($this->validator->errorMessage($provider));
}
return DB::transaction(function () use ($teamId, $provider, $name, $token, $capabilities, $metadata): IntegrationToken {
$integrationToken = IntegrationToken::query()->create([
'team_id' => $teamId, 'provider' => $provider, 'name' => $name, 'token' => $token,
'capabilities' => $capabilities, 'metadata' => $metadata ?: null,
]);
if ($provider === 'cloudflare') {
$this->cloudflare->syncZones($integrationToken);
}
return $integrationToken;
});
}
}
@@ -0,0 +1,176 @@
<?php
namespace App\Actions\IntegrationToken;
use App\Models\IntegrationToken;
use App\Services\Dns\CloudflareDnsProvider;
use App\Services\IntegrationTokenValidator;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
use Lorisleiva\Actions\Concerns\AsAction;
use RuntimeException;
/**
* Updates an integration token. The provider checks the token again when the secret, the capabilities, or the
* settings change; a Cloudflare token gets its zones again when the secret or the capabilities change.
* The security page and the API use this action.
*/
class UpdateIntegrationToken
{
use AsAction;
public function __construct(
private IntegrationTokenValidator $validator,
private CloudflareDnsProvider $cloudflare,
) {}
/**
* Validation rules for an update. `token` is the new secret and is optional.
*
* @return array<string, array<int, mixed>>
*/
public static function rules(string $provider): array
{
$rules = CreateIntegrationToken::rules($provider);
unset($rules['provider']);
$rules['token'][0] = 'nullable';
return $rules;
}
/**
* @param array<int, string> $capabilities
* @param array<string, mixed> $metadata The complete provider settings; empty values are dropped.
*
* @throws ValidationException when a connection setting changes without a new secret
* @throws RuntimeException when the provider rejects the token or Cloudflare zones cannot be read
*/
public function handle(
IntegrationToken $integrationToken,
string $name,
?string $newToken,
array $capabilities,
array $metadata,
bool $automaticDns,
bool $proxiedDns,
string $auditSource,
): IntegrationToken {
$provider = $integrationToken->provider;
$newToken = filled($newToken) ? $newToken : null;
$metadata = array_filter($metadata, fn ($value) => filled($value));
if ($provider === 'cloudflare') {
// Cloudflare has no user settings in metadata; keep system keys such as zones_synced_at.
$metadata = $integrationToken->metadata ?? [];
unset($metadata['automatic_dns'], $metadata['proxied_dns']);
if (! $automaticDns) {
$metadata['automatic_dns'] = false;
}
if ($proxiedDns) {
$metadata['proxied_dns'] = true;
}
}
$storedMetadata = $integrationToken->metadata ?? [];
$changedConnectionFields = $this->changedConnectionFields($provider, $storedMetadata, $metadata);
if ($changedConnectionFields !== [] && $newToken === null) {
throw ValidationException::withMessages(['token' => $this->reenterSecretMessage($provider)]);
}
$capabilitiesChanged = collect($capabilities)->sort()->values()->all()
!== collect($integrationToken->capabilities)->sort()->values()->all();
if (($newToken !== null || $capabilitiesChanged || $metadata != $storedMetadata)
&& ! $this->validator->validate($provider, $newToken ?? $integrationToken->token, $capabilities, $metadata)) {
throw new RuntimeException($this->validator->errorMessage($provider));
}
$updates = ['name' => $name, 'capabilities' => $capabilities, 'metadata' => $metadata ?: null];
if ($newToken !== null) {
$updates['token'] = $newToken;
}
DB::transaction(function () use ($integrationToken, $updates, $provider, $newToken, $capabilitiesChanged): void {
$integrationToken->update($updates);
if ($provider === 'cloudflare' && ($newToken !== null || $capabilitiesChanged)) {
$this->cloudflare->syncZones($integrationToken);
}
});
auditLog("{$auditSource}.integration_token.updated", [
'team_id' => $integrationToken->team_id,
'integration_token_uuid' => $integrationToken->uuid,
'integration_token_name' => $integrationToken->name,
'provider' => $provider,
'rotated' => $newToken !== null,
'connection_changed_fields' => $changedConnectionFields,
]);
if (in_array('base_url', $changedConnectionFields, true)) {
auditLog("{$auditSource}.integration_token.base_url_changed", [
'team_id' => $integrationToken->team_id,
'integration_token_uuid' => $integrationToken->uuid,
'integration_token_name' => $integrationToken->name,
'provider' => $provider,
'previous_base_url' => $this->baseUrlForAudit(data_get($storedMetadata, 'base_url')),
'base_url' => $this->baseUrlForAudit(data_get($metadata, 'base_url')),
], 'warning');
}
return $integrationToken;
}
/**
* Metadata keys that decide where or how the stored secret is sent.
* Changing any of them must require the secret to be entered again.
*
* @return array<int, string>
*/
private function connectionFields(string $provider): array
{
return match ($provider) {
'vault' => ['base_url', 'namespace'],
'infisical' => ['base_url', 'client_id'],
default => [],
};
}
/**
* @param array<string, mixed> $storedMetadata
* @param array<string, mixed> $metadata
* @return array<int, string>
*/
private function changedConnectionFields(string $provider, array $storedMetadata, array $metadata): array
{
return array_values(array_filter(
$this->connectionFields($provider),
fn (string $field): bool => (string) data_get($storedMetadata, $field, '') !== (string) data_get($metadata, $field, ''),
));
}
/**
* Base URL without user info, query, or fragment, which can carry credentials.
*/
private function baseUrlForAudit(mixed $url): ?string
{
if (! is_string($url) || $url === '') {
return null;
}
$parts = parse_url($url);
if (! is_array($parts) || ! isset($parts['scheme'], $parts['host'])) {
return '[invalid-url]';
}
return $parts['scheme'].'://'.$parts['host']
.(isset($parts['port']) ? ':'.$parts['port'] : '')
.($parts['path'] ?? '');
}
private function reenterSecretMessage(string $provider): string
{
return match ($provider) {
'infisical' => 'Enter the client secret again when you change the base URL or the client ID.',
'vault' => 'Enter the token again when you change the base URL or the namespace.',
default => 'Enter the token again when you change the connection settings.',
};
}
}
@@ -1,123 +0,0 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Application;
use App\Models\IntegrationToken;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use OpenApi\Attributes as OA;
class ApplicationSecretManagerController extends Controller
{
#[OA\Patch(
summary: 'Configure Application Secret Manager',
description: 'Configure the secret manager source used by an application.',
path: '/applications/{uuid}/secret-manager',
operationId: 'configure-application-secret-manager',
security: [['bearerAuth' => []]],
tags: ['Secret Managers'],
parameters: [new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string'))],
requestBody: new OA\RequestBody(
required: true,
content: new OA\JsonContent(
required: ['integration_token_uuid'],
properties: [
new OA\Property(property: 'integration_token_uuid', type: 'string'),
new OA\Property(property: 'settings', type: 'object'),
],
),
),
responses: [
new OA\Response(response: 200, description: 'Secret manager configured.'),
new OA\Response(response: 401, ref: '#/components/responses/401'),
new OA\Response(response: 404, ref: '#/components/responses/404'),
new OA\Response(response: 422, ref: '#/components/responses/422'),
],
)]
public function update(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$return = validateIncomingRequest($request);
if ($return instanceof JsonResponse) {
return $return;
}
$application = Application::ownedByCurrentTeamAPI($teamId)
->where('uuid', $request->route('uuid'))
->first();
if (! $application) {
return response()->json(['message' => 'Application not found.'], 404);
}
$this->authorize('update', $application);
$body = $request->json()->all();
$token = IntegrationToken::query()
->where('team_id', $teamId)
->where('uuid', $body['integration_token_uuid'] ?? '')
->whereIn('provider', IntegrationToken::SECRET_MANAGER_PROVIDERS)
->first();
if (! $token || ! in_array('secrets', $token->capabilities ?? [], true)) {
return response()->json(['message' => 'Secret manager integration token not found.'], 404);
}
$rules = [
'integration_token_uuid' => ['required', 'string'],
'settings' => ['sometimes', 'array'],
];
$rules += match ($token->provider) {
'doppler' => $token->dopplerTokenType() === 'service_account' ? [
'settings.project' => ['required', 'string'],
'settings.config' => ['required', 'string'],
] : [],
'infisical' => [
'settings.project_id' => ['required', 'string'],
'settings.environment' => ['required', 'string'],
'settings.secret_path' => ['nullable', 'string'],
],
'vault' => [
'settings.mount' => ['required', 'string'],
'settings.path' => ['required', 'string'],
],
default => [],
};
$validator = customApiValidator($body, $rules);
$extraFields = array_diff(array_keys($body), ['integration_token_uuid', 'settings']);
if ($validator->fails() || $extraFields !== []) {
$errors = $validator->errors();
foreach ($extraFields as $field) {
$errors->add($field, 'This field is not allowed.');
}
return response()->json(['message' => 'Validation failed.', 'errors' => $errors], 422);
}
$settings = array_filter($validator->validated()['settings'] ?? [], fn ($value) => filled($value));
$application->secretManagerLink()->updateOrCreate([], [
'integration_token_id' => $token->id,
'settings' => $settings ?: null,
]);
auditLog('api.application.secret_manager.updated', [
'team_id' => $teamId,
'application_uuid' => $application->uuid,
'integration_token_uuid' => $token->uuid,
]);
return response()->json([
'integration_token_uuid' => $token->uuid,
'provider' => $token->provider,
'settings' => $settings ?: null,
]);
}
}
@@ -27,6 +27,7 @@ use App\Rules\DockerImageFormat;
use App\Rules\ValidGitBranch;
use App\Rules\ValidGitRepositoryUrl;
use App\Services\Dns\ManagedDnsRecordCleanup;
use App\Services\Dns\ResourceDnsRecords;
use App\Services\DockerImageParser;
use App\Support\DomainPortOverrides;
use App\Support\ValidationPatterns;
@@ -3867,6 +3868,7 @@ class ApplicationsController extends Controller
$application->parseHealthcheckFromDockerfile($application->dockerfile);
}
$dnsCleanup->queueReleaseOfRemovedHostnames($application, $previousDnsHostnames, (int) $teamId);
app(ResourceDnsRecords::class)->queueAutomaticRecords($application, $previousDnsHostnames);
auditLog('api.application.updated', [
'team_id' => $teamId,
+500
View File
@@ -0,0 +1,500 @@
<?php
namespace App\Http\Controllers\Api;
use App\Enums\ManagedDnsDeletionResult;
use App\Exceptions\DnsRecordConflictException;
use App\Http\Controllers\Controller;
use App\Models\Application;
use App\Models\DnsProviderZone;
use App\Models\IntegrationToken;
use App\Models\Service;
use App\Services\Dns\ResourceDnsRecords;
use Illuminate\Contracts\Cache\LockTimeoutException;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
use OpenApi\Attributes as OA;
use RuntimeException;
#[OA\Schema(
schema: 'ManagedDnsRecord',
description: 'DNS record that Coolify manages at a DNS provider.',
type: 'object',
properties: [
new OA\Property(property: 'uuid', type: 'string'),
new OA\Property(property: 'hostname', type: 'string'),
new OA\Property(property: 'type', type: 'string', enum: ['A', 'AAAA']),
new OA\Property(property: 'address', type: 'string', description: 'Server IP address that the record points to.'),
new OA\Property(property: 'owned', type: 'boolean', description: 'Coolify created the record, so it can delete it.'),
new OA\Property(property: 'integration_token_uuid', type: 'string'),
],
)]
#[OA\Schema(
schema: 'ResourceDnsStatus',
description: 'DNS providers and managed records for the domains of a resource.',
type: 'object',
properties: [
new OA\Property(property: 'default_address', type: 'string', nullable: true, description: 'Address of a new record when none is given: the first public server address. Null when the server has no public address.'),
new OA\Property(property: 'public_addresses', type: 'array', items: new OA\Items(type: 'string')),
new OA\Property(property: 'hostnames', type: 'array', items: new OA\Items(type: 'object', properties: [
new OA\Property(property: 'hostname', type: 'string'),
new OA\Property(property: 'providers', type: 'array', items: new OA\Items(type: 'object', properties: [
new OA\Property(property: 'integration_token_uuid', type: 'string'),
new OA\Property(property: 'integration_token_name', type: 'string'),
new OA\Property(property: 'zone', type: 'string'),
new OA\Property(property: 'automatic_dns', type: 'boolean'),
new OA\Property(property: 'proxied_dns', type: 'boolean'),
])),
new OA\Property(property: 'records', type: 'array', items: new OA\Items(ref: '#/components/schemas/ManagedDnsRecord')),
])),
],
)]
#[OA\Schema(
schema: 'CreateDnsRecordRequest',
type: 'object',
required: ['hostname'],
properties: [
new OA\Property(property: 'hostname', type: 'string', description: 'A saved domain of the resource.'),
new OA\Property(property: 'integration_token_uuid', type: 'string', description: 'Cloudflare token to use. Required when several tokens manage the zone of the hostname.'),
new OA\Property(property: 'address', type: 'string', description: 'A public address of the server. Default: default_address of the DNS status.'),
],
)]
#[OA\Schema(
schema: 'ReplaceDnsRecordRequest',
type: 'object',
required: ['hostname', 'record_id', 'current'],
properties: [
new OA\Property(property: 'hostname', type: 'string', description: 'A saved domain of the resource.'),
new OA\Property(property: 'integration_token_uuid', type: 'string', description: 'Cloudflare token to use. Required when several tokens manage the zone of the hostname.'),
new OA\Property(property: 'record_id', type: 'string', description: 'conflict.record_id from the 409 response of the create request.'),
new OA\Property(property: 'current', type: 'string', description: 'conflict.current from the 409 response. The record is replaced only when it still has this value.'),
new OA\Property(property: 'address', type: 'string', description: 'A public address of the server. Default: default_address of the DNS status.'),
],
)]
#[OA\Schema(
schema: 'DnsRecordConflict',
type: 'object',
properties: [
new OA\Property(property: 'message', type: 'string'),
new OA\Property(property: 'conflict', type: 'object', properties: [
new OA\Property(property: 'record_id', type: 'string'),
new OA\Property(property: 'current', type: 'string'),
new OA\Property(property: 'proposed', type: 'string'),
]),
],
)]
class DnsController extends Controller
{
public function __construct(private ResourceDnsRecords $dns) {}
#[OA\Get(
summary: 'List DNS Zones',
description: 'List the DNS zones that the Cloudflare tokens of the team can access. Requires admin or owner.',
path: '/dns/zones',
operationId: 'list-dns-zones',
security: [['bearerAuth' => []]],
tags: ['DNS'],
responses: [
new OA\Response(response: 200, description: 'DNS zones.', content: new OA\JsonContent(type: 'array', items: new OA\Items(type: 'object', properties: [
new OA\Property(property: 'zone', type: 'string'),
new OA\Property(property: 'account_name', type: 'string', nullable: true),
new OA\Property(property: 'integration_token_uuid', type: 'string'),
new OA\Property(property: 'integration_token_name', type: 'string'),
new OA\Property(property: 'managed_records_count', type: 'integer'),
]))),
new OA\Response(response: 401, ref: '#/components/responses/401'),
new OA\Response(response: 403, description: 'Not an admin or owner of the team.'),
],
)]
public function zones(): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$this->authorize('viewAny', IntegrationToken::class);
$zones = DnsProviderZone::query()
->whereHas('integrationToken', fn ($query) => $query->where('team_id', $teamId)->where('provider', 'cloudflare'))
->with('integrationToken')->withCount('managedRecords')->orderBy('name')->get();
return response()->json($zones->map(fn (DnsProviderZone $zone): array => [
'zone' => $zone->name,
'account_name' => $zone->account_name,
'integration_token_uuid' => $zone->integrationToken->uuid,
'integration_token_name' => $zone->integrationToken->name,
'managed_records_count' => $zone->managed_records_count,
])->values());
}
#[OA\Get(
summary: 'Get Application DNS',
description: 'DNS providers and managed DNS records for the domains of an application.',
path: '/applications/{uuid}/dns',
operationId: 'get-application-dns',
security: [['bearerAuth' => []]],
tags: ['DNS'],
parameters: [new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Application UUID.', schema: new OA\Schema(type: 'string'))],
responses: [
new OA\Response(response: 200, description: 'DNS status.', content: new OA\JsonContent(ref: '#/components/schemas/ResourceDnsStatus')),
new OA\Response(response: 401, ref: '#/components/responses/401'),
new OA\Response(response: 403, description: 'No access to the application.'),
new OA\Response(response: 404, ref: '#/components/responses/404'),
],
)]
public function applicationStatus(Request $request): JsonResponse
{
return $this->status($this->findResource($request, Application::class, 'view'));
}
#[OA\Get(
summary: 'Get Service DNS',
description: 'DNS providers and managed DNS records for the domains of a service.',
path: '/services/{uuid}/dns',
operationId: 'get-service-dns',
security: [['bearerAuth' => []]],
tags: ['DNS'],
parameters: [new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Service UUID.', schema: new OA\Schema(type: 'string'))],
responses: [
new OA\Response(response: 200, description: 'DNS status.', content: new OA\JsonContent(ref: '#/components/schemas/ResourceDnsStatus')),
new OA\Response(response: 401, ref: '#/components/responses/401'),
new OA\Response(response: 403, description: 'No access to the service.'),
new OA\Response(response: 404, ref: '#/components/responses/404'),
],
)]
public function serviceStatus(Request $request): JsonResponse
{
return $this->status($this->findResource($request, Service::class, 'view'));
}
#[OA\Post(
summary: 'Create Application DNS Record',
description: 'Create a DNS record at Cloudflare that points a domain of the application to its server. An existing record with the same address is adopted.',
path: '/applications/{uuid}/dns',
operationId: 'create-application-dns-record',
security: [['bearerAuth' => []]],
tags: ['DNS'],
parameters: [new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Application UUID.', schema: new OA\Schema(type: 'string'))],
requestBody: new OA\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/CreateDnsRecordRequest')),
responses: [
new OA\Response(response: 201, description: 'DNS record created.', content: new OA\JsonContent(properties: [new OA\Property(property: 'record', ref: '#/components/schemas/ManagedDnsRecord')])),
new OA\Response(response: 400, description: 'Cloudflare rejected the change.'),
new OA\Response(response: 401, ref: '#/components/responses/401'),
new OA\Response(response: 403, description: 'No write access to the application.'),
new OA\Response(response: 404, ref: '#/components/responses/404'),
new OA\Response(response: 409, description: 'Another record uses the hostname. Replace it with the replace endpoint.', content: new OA\JsonContent(ref: '#/components/schemas/DnsRecordConflict')),
new OA\Response(response: 422, ref: '#/components/responses/422'),
new OA\Response(response: 423, description: 'Another DNS change for the hostname is in progress. Try again.'),
],
)]
public function createApplicationRecord(Request $request): JsonResponse
{
return $this->create($request, $this->findResource($request, Application::class, 'update'));
}
#[OA\Post(
summary: 'Create Service DNS Record',
description: 'Create a DNS record at Cloudflare that points a domain of the service to its server. An existing record with the same address is adopted.',
path: '/services/{uuid}/dns',
operationId: 'create-service-dns-record',
security: [['bearerAuth' => []]],
tags: ['DNS'],
parameters: [new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Service UUID.', schema: new OA\Schema(type: 'string'))],
requestBody: new OA\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/CreateDnsRecordRequest')),
responses: [
new OA\Response(response: 201, description: 'DNS record created.', content: new OA\JsonContent(properties: [new OA\Property(property: 'record', ref: '#/components/schemas/ManagedDnsRecord')])),
new OA\Response(response: 400, description: 'Cloudflare rejected the change.'),
new OA\Response(response: 401, ref: '#/components/responses/401'),
new OA\Response(response: 403, description: 'No write access to the service.'),
new OA\Response(response: 404, ref: '#/components/responses/404'),
new OA\Response(response: 409, description: 'Another record uses the hostname. Replace it with the replace endpoint.', content: new OA\JsonContent(ref: '#/components/schemas/DnsRecordConflict')),
new OA\Response(response: 422, ref: '#/components/responses/422'),
new OA\Response(response: 423, description: 'Another DNS change for the hostname is in progress. Try again.'),
],
)]
public function createServiceRecord(Request $request): JsonResponse
{
return $this->create($request, $this->findResource($request, Service::class, 'update'));
}
#[OA\Post(
summary: 'Replace Application DNS Record',
description: 'Point the conflicting record from a 409 create response to the server of the application.',
path: '/applications/{uuid}/dns/replace',
operationId: 'replace-application-dns-record',
security: [['bearerAuth' => []]],
tags: ['DNS'],
parameters: [new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Application UUID.', schema: new OA\Schema(type: 'string'))],
requestBody: new OA\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/ReplaceDnsRecordRequest')),
responses: [
new OA\Response(response: 200, description: 'DNS record replaced.', content: new OA\JsonContent(properties: [new OA\Property(property: 'record', ref: '#/components/schemas/ManagedDnsRecord')])),
new OA\Response(response: 400, description: 'Cloudflare rejected the change, or the record changed since the conflict.'),
new OA\Response(response: 401, ref: '#/components/responses/401'),
new OA\Response(response: 403, description: 'No write access to the application.'),
new OA\Response(response: 404, ref: '#/components/responses/404'),
new OA\Response(response: 422, ref: '#/components/responses/422'),
new OA\Response(response: 423, description: 'Another DNS change for the hostname is in progress. Try again.'),
],
)]
public function replaceApplicationRecord(Request $request): JsonResponse
{
return $this->replace($request, $this->findResource($request, Application::class, 'update'));
}
#[OA\Post(
summary: 'Replace Service DNS Record',
description: 'Point the conflicting record from a 409 create response to the server of the service.',
path: '/services/{uuid}/dns/replace',
operationId: 'replace-service-dns-record',
security: [['bearerAuth' => []]],
tags: ['DNS'],
parameters: [new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Service UUID.', schema: new OA\Schema(type: 'string'))],
requestBody: new OA\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/ReplaceDnsRecordRequest')),
responses: [
new OA\Response(response: 200, description: 'DNS record replaced.', content: new OA\JsonContent(properties: [new OA\Property(property: 'record', ref: '#/components/schemas/ManagedDnsRecord')])),
new OA\Response(response: 400, description: 'Cloudflare rejected the change, or the record changed since the conflict.'),
new OA\Response(response: 401, ref: '#/components/responses/401'),
new OA\Response(response: 403, description: 'No write access to the service.'),
new OA\Response(response: 404, ref: '#/components/responses/404'),
new OA\Response(response: 422, ref: '#/components/responses/422'),
new OA\Response(response: 423, description: 'Another DNS change for the hostname is in progress. Try again.'),
],
)]
public function replaceServiceRecord(Request $request): JsonResponse
{
return $this->replace($request, $this->findResource($request, Service::class, 'update'));
}
#[OA\Delete(
summary: 'Release Application DNS Record',
description: 'Release the managed DNS record of a hostname that the application no longer uses. Coolify deletes the record at Cloudflare only when it created the record, the record did not change, and no other resource uses the hostname. Removing a domain through the API already queues this release; use this endpoint to retry it or to keep the record.',
path: '/applications/{uuid}/dns/{hostname}',
operationId: 'release-application-dns-record',
security: [['bearerAuth' => []]],
tags: ['DNS'],
parameters: [
new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Application UUID.', schema: new OA\Schema(type: 'string')),
new OA\Parameter(name: 'hostname', in: 'path', required: true, description: 'Hostname of the record.', schema: new OA\Schema(type: 'string')),
new OA\Parameter(name: 'delete_record', in: 'query', required: false, description: 'Delete the record at Cloudflare. Default: true.', schema: new OA\Schema(type: 'boolean')),
],
responses: [
new OA\Response(response: 200, description: 'Record released.', content: new OA\JsonContent(properties: [
new OA\Property(property: 'result', type: 'string', enum: ['deleted', 'already_gone', 'not_owned', 'changed_externally', 'kept']),
new OA\Property(property: 'message', type: 'string'),
])),
new OA\Response(response: 401, ref: '#/components/responses/401'),
new OA\Response(response: 403, description: 'No write access to the application.'),
new OA\Response(response: 404, description: 'No managed record of the application for the hostname.'),
new OA\Response(response: 422, description: 'The application still uses the hostname.'),
new OA\Response(response: 423, description: 'Another DNS change for the hostname is in progress. Try again.'),
new OA\Response(response: 502, description: 'Cloudflare did not respond. Try again.'),
],
)]
public function releaseApplicationRecord(Request $request): JsonResponse
{
return $this->release($request, $this->findResource($request, Application::class, 'update'));
}
#[OA\Delete(
summary: 'Release Service DNS Record',
description: 'Release the managed DNS record of a hostname that the service no longer uses. Coolify deletes the record at Cloudflare only when it created the record, the record did not change, and no other resource uses the hostname. Removing a domain through the API already queues this release; use this endpoint to retry it or to keep the record.',
path: '/services/{uuid}/dns/{hostname}',
operationId: 'release-service-dns-record',
security: [['bearerAuth' => []]],
tags: ['DNS'],
parameters: [
new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Service UUID.', schema: new OA\Schema(type: 'string')),
new OA\Parameter(name: 'hostname', in: 'path', required: true, description: 'Hostname of the record.', schema: new OA\Schema(type: 'string')),
new OA\Parameter(name: 'delete_record', in: 'query', required: false, description: 'Delete the record at Cloudflare. Default: true.', schema: new OA\Schema(type: 'boolean')),
],
responses: [
new OA\Response(response: 200, description: 'Record released.', content: new OA\JsonContent(properties: [
new OA\Property(property: 'result', type: 'string', enum: ['deleted', 'already_gone', 'not_owned', 'changed_externally', 'kept']),
new OA\Property(property: 'message', type: 'string'),
])),
new OA\Response(response: 401, ref: '#/components/responses/401'),
new OA\Response(response: 403, description: 'No write access to the service.'),
new OA\Response(response: 404, description: 'No managed record of the service for the hostname.'),
new OA\Response(response: 422, description: 'The service still uses the hostname.'),
new OA\Response(response: 423, description: 'Another DNS change for the hostname is in progress. Try again.'),
new OA\Response(response: 502, description: 'Cloudflare did not respond. Try again.'),
],
)]
public function releaseServiceRecord(Request $request): JsonResponse
{
return $this->release($request, $this->findResource($request, Service::class, 'update'));
}
/**
* The resource of the token team, authorized for the ability. Errors are returned as a response.
*
* @param class-string<Application|Service> $class
*/
private function findResource(Request $request, string $class, string $ability): Application|Service|JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$query = $class === Application::class
? Application::ownedByCurrentTeamAPI($teamId)
: Service::whereRelation('environment.project.team', 'id', $teamId);
$resource = $query->where('uuid', $request->route('uuid'))->first();
if ($resource === null) {
return response()->json(['message' => class_basename($class).' not found.'], 404);
}
$this->authorize($ability, $resource);
return $resource;
}
private function status(Application|Service|JsonResponse $resource): JsonResponse
{
if ($resource instanceof JsonResponse) {
return $resource;
}
return response()->json($this->dns->status($resource));
}
private function create(Request $request, Application|Service|JsonResponse $resource): JsonResponse
{
if ($resource instanceof JsonResponse) {
return $resource;
}
$body = $this->validateBody($request, [
'hostname' => ['required', 'string', 'max:255'],
'integration_token_uuid' => ['nullable', 'string'],
'address' => ['nullable', 'string'],
]);
if ($body instanceof JsonResponse) {
return $body;
}
return $this->runDnsChange($body['hostname'], function () use ($resource, $body): JsonResponse {
$record = $this->dns->create($resource, $body['hostname'], $body['integration_token_uuid'] ?? null, $body['address'] ?? null);
return response()->json(['record' => $this->dns->recordData($record->load('integrationToken'))], 201);
});
}
private function replace(Request $request, Application|Service|JsonResponse $resource): JsonResponse
{
if ($resource instanceof JsonResponse) {
return $resource;
}
$body = $this->validateBody($request, [
'hostname' => ['required', 'string', 'max:255'],
'integration_token_uuid' => ['nullable', 'string'],
'record_id' => ['required', 'string'],
'current' => ['required', 'string'],
'address' => ['nullable', 'string'],
]);
if ($body instanceof JsonResponse) {
return $body;
}
return $this->runDnsChange($body['hostname'], function () use ($resource, $body): JsonResponse {
$record = $this->dns->replace(
$resource,
$body['hostname'],
$body['integration_token_uuid'] ?? null,
$body['record_id'],
$body['current'],
$body['address'] ?? null,
);
return response()->json(['record' => $this->dns->recordData($record->load('integrationToken'))]);
});
}
private function release(Request $request, Application|Service|JsonResponse $resource): JsonResponse
{
if ($resource instanceof JsonResponse) {
return $resource;
}
$hostname = (string) $request->route('hostname');
try {
$result = $this->dns->release($resource, $hostname, $request->boolean('delete_record', true));
} catch (ValidationException $e) {
return response()->json(['message' => 'Validation failed.', 'errors' => $e->errors()], 422);
} catch (ModelNotFoundException) {
return response()->json(['message' => "No managed DNS record of this resource for {$hostname}."], 404);
}
return match ($result) {
ManagedDnsDeletionResult::Busy => response()->json(['message' => $this->busyMessage($hostname)], 423),
ManagedDnsDeletionResult::Failed => response()->json(['message' => 'Cloudflare did not respond. The record was left untouched; try again.'], 502),
default => response()->json([
'result' => $result?->value ?? 'kept',
'message' => match ($result) {
ManagedDnsDeletionResult::Deleted => 'The DNS record was deleted.',
ManagedDnsDeletionResult::AlreadyGone => 'The DNS record no longer existed.',
ManagedDnsDeletionResult::NotOwned => 'Coolify did not create the DNS record, so it was kept.',
ManagedDnsDeletionResult::ChangedExternally => 'The DNS record changed outside Coolify, so it was kept.',
default => 'The DNS record was kept: another resource uses the hostname, or delete_record was false.',
},
]),
};
}
/**
* Runs a create or replace and turns DNS errors into responses.
*/
private function runDnsChange(string $hostname, callable $change): JsonResponse
{
try {
return $change();
} catch (ValidationException $e) {
return response()->json(['message' => 'Validation failed.', 'errors' => $e->errors()], 422);
} catch (DnsRecordConflictException $e) {
return response()->json([
'message' => $e->getMessage(),
'conflict' => ['record_id' => $e->providerRecordId, 'current' => $e->currentValue, 'proposed' => $e->proposedValue],
], 409);
} catch (LockTimeoutException) {
return response()->json(['message' => $this->busyMessage(strtolower($hostname))], 423);
} catch (RuntimeException $e) {
return response()->json(['message' => $e->getMessage()], 400);
}
}
/**
* @param array<string, array<int, string>> $rules
* @return array<string, mixed>|JsonResponse
*/
private function validateBody(Request $request, array $rules): array|JsonResponse
{
$return = validateIncomingRequest($request);
if ($return instanceof JsonResponse) {
return $return;
}
$body = $request->json()->all();
$validator = customApiValidator($body, $rules);
$extraFields = array_diff(array_keys($body), array_keys($rules));
if ($validator->fails() || $extraFields !== []) {
$errors = $validator->errors();
foreach ($extraFields as $field) {
$errors->add($field, 'This field is not allowed.');
}
return response()->json(['message' => 'Validation failed.', 'errors' => $errors], 422);
}
return $validator->validated();
}
private function busyMessage(string $hostname): string
{
return "Another DNS change for {$hostname} is in progress. Try again in a few seconds.";
}
}
@@ -2,42 +2,126 @@
namespace App\Http\Controllers\Api;
use App\Actions\IntegrationToken\CreateIntegrationToken;
use App\Actions\IntegrationToken\UpdateIntegrationToken;
use App\Http\Controllers\Controller;
use App\Models\IntegrationToken;
use App\Services\Dns\CloudflareDnsProvider;
use App\Services\IntegrationTokenValidator;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
use OpenApi\Attributes as OA;
use RuntimeException;
#[OA\Schema(
schema: 'IntegrationToken',
description: 'Integration token. The secret is never returned.',
type: 'object',
properties: [
new OA\Property(property: 'uuid', type: 'string'),
new OA\Property(property: 'name', type: 'string'),
new OA\Property(property: 'provider', type: 'string', enum: ['cloudflare', 'doppler', 'infisical', 'vault']),
new OA\Property(property: 'capabilities', type: 'array', items: new OA\Items(type: 'string', enum: ['dns', 'tunnel', 'secrets'])),
new OA\Property(property: 'automatic_dns', type: 'boolean', description: 'Cloudflare only. Create DNS records when a domain is added.'),
new OA\Property(property: 'proxied_dns', type: 'boolean', description: 'Cloudflare only. Create new records proxied (orange cloud).'),
new OA\Property(property: 'dns_zones_count', type: 'integer', description: 'Cloudflare only.'),
new OA\Property(property: 'zones_synced_at', type: 'string', nullable: true, description: 'Cloudflare only.'),
new OA\Property(property: 'created_at', type: 'string'),
new OA\Property(property: 'updated_at', type: 'string'),
],
)]
class IntegrationTokensController extends Controller
{
#[OA\Get(
summary: 'List Integration Tokens',
description: 'List the integration tokens (Cloudflare, Doppler, Infisical, Vault) of the team. Requires admin or owner.',
path: '/security/integration-tokens',
operationId: 'list-integration-tokens',
security: [['bearerAuth' => []]],
tags: ['Integration Tokens'],
responses: [
new OA\Response(response: 200, description: 'Integration tokens.', content: new OA\JsonContent(type: 'array', items: new OA\Items(ref: '#/components/schemas/IntegrationToken'))),
new OA\Response(response: 401, ref: '#/components/responses/401'),
new OA\Response(response: 403, description: 'Not an admin or owner of the team.'),
],
)]
public function index(): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$this->authorize('viewAny', IntegrationToken::class);
$tokens = IntegrationToken::query()->where('team_id', $teamId)->withCount('dnsZones')->latest()->get();
return response()->json($tokens->map(fn (IntegrationToken $token): array => $this->tokenData($token))->values());
}
#[OA\Get(
summary: 'Get Integration Token',
description: 'Get an integration token of the team by UUID. Requires admin or owner.',
path: '/security/integration-tokens/{uuid}',
operationId: 'get-integration-token-by-uuid',
security: [['bearerAuth' => []]],
tags: ['Integration Tokens'],
parameters: [
new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Integration token UUID.', schema: new OA\Schema(type: 'string')),
],
responses: [
new OA\Response(response: 200, description: 'Integration token.', content: new OA\JsonContent(ref: '#/components/schemas/IntegrationToken')),
new OA\Response(response: 401, ref: '#/components/responses/401'),
new OA\Response(response: 403, description: 'Not an admin or owner of the team.'),
new OA\Response(response: 404, ref: '#/components/responses/404'),
],
)]
public function show(Request $request): JsonResponse
{
$token = $this->findToken($request);
if ($token instanceof JsonResponse) {
return $token;
}
$this->authorize('view', $token);
return response()->json($this->tokenData($token->loadCount('dnsZones')));
}
#[OA\Post(
summary: 'Create Secret Manager Token',
description: 'Create and validate a Doppler, Infisical, or Vault integration token.',
summary: 'Create Integration Token',
description: 'Create and validate a Cloudflare, Doppler, Infisical, or Vault integration token. A Cloudflare token also gets its DNS zones; it is not stored when the zones cannot be read. Requires admin or owner.',
path: '/security/integration-tokens',
operationId: 'create-secret-manager-integration-token',
security: [['bearerAuth' => []]],
tags: ['Secret Managers'],
tags: ['Integration Tokens'],
requestBody: new OA\RequestBody(
required: true,
content: new OA\JsonContent(
required: ['provider', 'name', 'token'],
properties: [
new OA\Property(property: 'provider', type: 'string', enum: ['doppler', 'infisical', 'vault']),
new OA\Property(property: 'provider', type: 'string', enum: ['cloudflare', 'doppler', 'infisical', 'vault']),
new OA\Property(property: 'name', type: 'string'),
new OA\Property(property: 'token', type: 'string'),
new OA\Property(property: 'metadata', type: 'object'),
new OA\Property(property: 'capabilities', type: 'array', items: new OA\Items(type: 'string', enum: ['dns', 'tunnel', 'secrets']), description: 'Cloudflare: dns, tunnel (tunnel needs dns). Default: dns. Secret managers: secrets.'),
new OA\Property(property: 'automatic_dns', type: 'boolean', description: 'Cloudflare only. Create DNS records when a domain is added. Default: true.'),
new OA\Property(property: 'proxied_dns', type: 'boolean', description: 'Cloudflare only. Create new records proxied (orange cloud). Default: false.'),
new OA\Property(property: 'metadata', type: 'object', description: 'Secret managers only: base_url, client_id (Infisical), namespace (Vault), timeout.'),
],
),
),
responses: [
new OA\Response(response: 201, description: 'Integration token created.'),
new OA\Response(response: 400, ref: '#/components/responses/400'),
new OA\Response(response: 201, description: 'Integration token created.', content: new OA\JsonContent(properties: [new OA\Property(property: 'uuid', type: 'string')])),
new OA\Response(response: 400, description: 'The provider rejected the token, or the Cloudflare zones could not be read.'),
new OA\Response(response: 401, ref: '#/components/responses/401'),
new OA\Response(response: 403, description: 'Not an admin or owner of the team.'),
new OA\Response(response: 422, ref: '#/components/responses/422'),
new OA\Response(response: 502, description: 'Coolify could not connect to the provider API.'),
],
)]
public function store(Request $request, IntegrationTokenValidator $tokenValidator): JsonResponse
public function store(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
@@ -52,27 +136,18 @@ class IntegrationTokensController extends Controller
}
$body = $request->json()->all();
$rules = [
'provider' => ['required', 'string', 'in:'.implode(',', IntegrationToken::SECRET_MANAGER_PROVIDERS)],
'name' => ['required', 'string', 'max:255'],
'token' => ['required', 'string'],
'metadata' => ['sometimes', 'array'],
$provider = is_string($body['provider'] ?? null) ? $body['provider'] : '';
$isCloudflare = $provider === 'cloudflare';
$body['capabilities'] ??= $isCloudflare ? ['dns'] : ['secrets'];
$rules = CreateIntegrationToken::rules($provider) + [
'metadata' => $isCloudflare ? ['prohibited'] : ['sometimes', 'array'],
'automatic_dns' => $isCloudflare ? ['sometimes', 'boolean'] : ['prohibited'],
'proxied_dns' => $isCloudflare ? ['sometimes', 'boolean'] : ['prohibited'],
];
if (($body['provider'] ?? null) === 'doppler') {
$rules['token'][] = 'regex:/^dp\.(st|sa)\./';
} elseif (($body['provider'] ?? null) === 'infisical') {
$rules['metadata.base_url'] = ['required', 'url:http,https'];
$rules['metadata.client_id'] = ['required', 'string'];
$rules['metadata.timeout'] = ['nullable', 'integer', 'min:1', 'max:300'];
} elseif (($body['provider'] ?? null) === 'vault') {
$rules['metadata.base_url'] = ['required', 'url:http,https'];
$rules['metadata.namespace'] = ['nullable', 'string'];
$rules['metadata.timeout'] = ['nullable', 'integer', 'min:1', 'max:300'];
}
$validator = customApiValidator($body, $rules);
$extraFields = array_diff(array_keys($body), ['provider', 'name', 'token', 'metadata']);
$extraFields = array_diff(array_keys($body), ['provider', 'name', 'token', 'capabilities', 'automatic_dns', 'proxied_dns', 'metadata']);
if ($validator->fails() || $extraFields !== []) {
$errors = $validator->errors();
@@ -84,21 +159,24 @@ class IntegrationTokensController extends Controller
}
$validated = $validator->validated();
$metadata = array_filter($validated['metadata'] ?? [], fn ($value) => filled($value));
if (! $tokenValidator->validate($validated['provider'], $validated['token'], ['secrets'], $metadata)) {
return response()->json(['message' => $tokenValidator->errorMessage($validated['provider'])], 400);
try {
$integrationToken = CreateIntegrationToken::run(
(int) $teamId,
$validated['provider'],
$validated['name'],
$validated['token'],
array_values(array_unique($validated['capabilities'])),
$validated['metadata'] ?? [],
$validated['automatic_dns'] ?? true,
$validated['proxied_dns'] ?? false,
);
} catch (ConnectionException) {
return $this->connectionErrorResponse($validated['provider']);
} catch (RuntimeException $e) {
return response()->json(['message' => $e->getMessage()], 400);
}
$integrationToken = IntegrationToken::query()->create([
'team_id' => $teamId,
'provider' => $validated['provider'],
'name' => $validated['name'],
'token' => $validated['token'],
'capabilities' => ['secrets'],
'metadata' => $metadata ?: null,
]);
auditLog('api.integration_token.created', [
'team_id' => $teamId,
'integration_token_uuid' => $integrationToken->uuid,
@@ -107,4 +185,234 @@ class IntegrationTokensController extends Controller
return response()->json(['uuid' => $integrationToken->uuid], 201);
}
#[OA\Patch(
summary: 'Update Integration Token',
description: 'Rename an integration token, rotate its secret, or change its capabilities and settings. Omitted fields keep their value. The provider checks the token again when the secret, the capabilities, or the settings change. Requires admin or owner.',
path: '/security/integration-tokens/{uuid}',
operationId: 'update-integration-token-by-uuid',
security: [['bearerAuth' => []]],
tags: ['Integration Tokens'],
parameters: [
new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Integration token UUID.', schema: new OA\Schema(type: 'string')),
],
requestBody: new OA\RequestBody(
required: true,
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'name', type: 'string'),
new OA\Property(property: 'token', type: 'string', description: 'New secret. Required when base_url, client_id (Infisical), or namespace (Vault) changes.'),
new OA\Property(property: 'capabilities', type: 'array', items: new OA\Items(type: 'string', enum: ['dns', 'tunnel', 'secrets'])),
new OA\Property(property: 'automatic_dns', type: 'boolean', description: 'Cloudflare only.'),
new OA\Property(property: 'proxied_dns', type: 'boolean', description: 'Cloudflare only.'),
new OA\Property(property: 'metadata', type: 'object', description: 'Secret managers only. Fields to change: base_url, client_id, namespace, timeout. An empty value removes a field.'),
],
),
),
responses: [
new OA\Response(response: 200, description: 'Integration token updated.', content: new OA\JsonContent(ref: '#/components/schemas/IntegrationToken')),
new OA\Response(response: 400, description: 'The provider rejected the token, or the Cloudflare zones could not be read.'),
new OA\Response(response: 401, ref: '#/components/responses/401'),
new OA\Response(response: 403, description: 'Not an admin or owner of the team.'),
new OA\Response(response: 404, ref: '#/components/responses/404'),
new OA\Response(response: 422, ref: '#/components/responses/422'),
new OA\Response(response: 502, description: 'Coolify could not connect to the provider API.'),
],
)]
public function update(Request $request): JsonResponse
{
$token = $this->findToken($request);
if ($token instanceof JsonResponse) {
return $token;
}
$this->authorize('update', $token);
$return = validateIncomingRequest($request);
if ($return instanceof JsonResponse) {
return $return;
}
$isCloudflare = $token->provider === 'cloudflare';
$storedMetadata = $token->metadata ?? [];
$body = $request->json()->all();
$merged = [
'name' => $body['name'] ?? $token->name,
'token' => $body['token'] ?? null,
'capabilities' => $body['capabilities'] ?? $token->capabilities,
'metadata' => is_array($body['metadata'] ?? null) ? array_merge($storedMetadata, $body['metadata']) : $storedMetadata,
];
$rules = UpdateIntegrationToken::rules($token->provider);
$validator = customApiValidator($merged, $rules);
$flags = customApiValidator($body, [
'metadata' => $isCloudflare ? ['prohibited'] : ['sometimes', 'array'],
'automatic_dns' => $isCloudflare ? ['sometimes', 'boolean'] : ['prohibited'],
'proxied_dns' => $isCloudflare ? ['sometimes', 'boolean'] : ['prohibited'],
]);
$extraFields = array_diff(array_keys($body), ['name', 'token', 'capabilities', 'automatic_dns', 'proxied_dns', 'metadata']);
if ($validator->fails() || $flags->fails() || $extraFields !== []) {
$errors = $validator->errors()->merge($flags->errors());
foreach ($extraFields as $field) {
$errors->add($field, 'This field is not allowed.');
}
return response()->json(['message' => 'Validation failed.', 'errors' => $errors], 422);
}
$validated = $validator->validated();
try {
UpdateIntegrationToken::run(
$token,
$validated['name'],
$validated['token'] ?? null,
array_values(array_unique($validated['capabilities'])),
$merged['metadata'],
$body['automatic_dns'] ?? $token->automaticDnsEnabled(),
$body['proxied_dns'] ?? $token->proxiedDnsEnabled(),
'api',
);
} catch (ValidationException $e) {
return response()->json(['message' => 'Validation failed.', 'errors' => $e->errors()], 422);
} catch (ConnectionException) {
return $this->connectionErrorResponse($token->provider);
} catch (RuntimeException $e) {
return response()->json(['message' => $e->getMessage()], 400);
}
return response()->json($this->tokenData($token->refresh()->loadCount('dnsZones')));
}
#[OA\Delete(
summary: 'Delete Integration Token',
description: 'Delete an integration token. A token that a resource uses as secret manager source, or that manages DNS records, cannot be deleted. Requires admin or owner.',
path: '/security/integration-tokens/{uuid}',
operationId: 'delete-integration-token-by-uuid',
security: [['bearerAuth' => []]],
tags: ['Integration Tokens'],
parameters: [
new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Integration token UUID.', schema: new OA\Schema(type: 'string')),
],
responses: [
new OA\Response(response: 200, description: 'Integration token deleted.', content: new OA\JsonContent(properties: [new OA\Property(property: 'message', type: 'string')])),
new OA\Response(response: 401, ref: '#/components/responses/401'),
new OA\Response(response: 403, description: 'Not an admin or owner of the team.'),
new OA\Response(response: 404, ref: '#/components/responses/404'),
new OA\Response(response: 409, description: 'The token is still in use.'),
],
)]
public function destroy(Request $request): JsonResponse
{
$token = $this->findToken($request);
if ($token instanceof JsonResponse) {
return $token;
}
$this->authorize('delete', $token);
if ($blocker = $token->deletionBlocker()) {
return response()->json(['message' => $blocker], 409);
}
$token->delete();
auditLog('api.integration_token.deleted', [
'team_id' => $token->team_id,
'integration_token_uuid' => $token->uuid,
'integration_token_name' => $token->name,
'provider' => $token->provider,
]);
return response()->json(['message' => 'Integration token deleted.']);
}
#[OA\Post(
summary: 'Sync Cloudflare Zones',
description: 'Read the DNS zones that a Cloudflare token can access again. Requires admin or owner.',
path: '/security/integration-tokens/{uuid}/sync-zones',
operationId: 'sync-integration-token-zones',
security: [['bearerAuth' => []]],
tags: ['Integration Tokens'],
parameters: [
new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Integration token UUID.', schema: new OA\Schema(type: 'string')),
],
responses: [
new OA\Response(response: 200, description: 'Zones synchronized.', content: new OA\JsonContent(properties: [new OA\Property(property: 'zones_count', type: 'integer')])),
new OA\Response(response: 400, description: 'Cloudflare did not return the zones.'),
new OA\Response(response: 401, ref: '#/components/responses/401'),
new OA\Response(response: 403, description: 'Not an admin or owner of the team.'),
new OA\Response(response: 404, ref: '#/components/responses/404'),
new OA\Response(response: 422, description: 'The token is not a Cloudflare token.'),
new OA\Response(response: 502, description: 'Coolify could not connect to the Cloudflare API.'),
],
)]
public function syncZones(Request $request, CloudflareDnsProvider $cloudflare): JsonResponse
{
$token = $this->findToken($request);
if ($token instanceof JsonResponse) {
return $token;
}
$this->authorize('update', $token);
if ($token->provider !== 'cloudflare') {
return response()->json(['message' => 'Only Cloudflare tokens have DNS zones.'], 422);
}
try {
$count = $cloudflare->syncZones($token);
} catch (ConnectionException) {
return $this->connectionErrorResponse($token->provider);
} catch (RuntimeException $e) {
return response()->json(['message' => $e->getMessage()], 400);
}
return response()->json(['zones_count' => $count]);
}
private function findToken(Request $request): IntegrationToken|JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
return IntegrationToken::query()->where('team_id', $teamId)->where('uuid', $request->route('uuid'))->first()
?? response()->json(['message' => 'Integration token not found.'], 404);
}
private function connectionErrorResponse(string $provider): JsonResponse
{
return response()->json(['message' => app(IntegrationTokenValidator::class)->connectionErrorMessage($provider)], 502);
}
/**
* Fields that are safe to return. The secret and the provider settings (a base URL can carry credentials) are left out.
*
* @return array<string, mixed>
*/
private function tokenData(IntegrationToken $token): array
{
$data = [
'uuid' => $token->uuid,
'name' => $token->name,
'provider' => $token->provider,
'capabilities' => $token->capabilities ?? [],
];
if ($token->provider === 'cloudflare') {
$data += [
'automatic_dns' => $token->automaticDnsEnabled(),
'proxied_dns' => $token->proxiedDnsEnabled(),
'dns_zones_count' => (int) $token->dns_zones_count,
'zones_synced_at' => data_get($token->metadata, 'zones_synced_at'),
];
}
return $data + [
'created_at' => $token->created_at,
'updated_at' => $token->updated_at,
];
}
}
@@ -0,0 +1,391 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Application;
use App\Models\IntegrationToken;
use App\Models\SecretManagerLink;
use App\Models\Service;
use App\Services\SecretManagers\ResourceSecretManager;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use OpenApi\Attributes as OA;
use Throwable;
#[OA\Schema(
schema: 'SecretManagerSource',
description: 'The secret manager source of an application, service, or database. Routes start with the resource type (`applications`, `services`, `databases`), so tokens limited to some resources can use them.',
type: 'object',
properties: [
new OA\Property(property: 'linked', type: 'boolean'),
new OA\Property(property: 'integration_token_uuid', type: 'string', nullable: true),
new OA\Property(property: 'provider', type: 'string', nullable: true, enum: ['doppler', 'infisical', 'vault', null]),
new OA\Property(property: 'settings', type: 'object', nullable: true, description: 'Doppler: project, config. Infisical: project_id, environment, secret_path. Vault: mount, path.'),
new OA\Property(property: 'available_tokens', type: 'array', items: new OA\Items(type: 'object', properties: [
new OA\Property(property: 'uuid', type: 'string'),
new OA\Property(property: 'name', type: 'string'),
new OA\Property(property: 'provider', type: 'string'),
])),
],
)]
class SecretManagerController extends Controller
{
public function __construct(private ResourceSecretManager $secretManager) {}
#[OA\Get(
summary: 'Get Secret Manager Source',
description: 'The secret manager source of an application, service, or database, and the secret manager tokens of its team that can be linked.',
path: '/{resource_type}/{uuid}/secret-manager',
operationId: 'get-secret-manager-source',
security: [['bearerAuth' => []]],
tags: ['Secret Managers'],
parameters: [new OA\Parameter(name: 'resource_type', in: 'path', required: true, description: 'Resource type.', schema: new OA\Schema(type: 'string', enum: ['applications', 'services', 'databases'])), new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Resource UUID.', schema: new OA\Schema(type: 'string'))],
responses: [
new OA\Response(response: 200, description: 'Secret manager source.', content: new OA\JsonContent(ref: '#/components/schemas/SecretManagerSource')),
new OA\Response(response: 401, ref: '#/components/responses/401'),
new OA\Response(response: 403, description: 'No access to the resource.'),
new OA\Response(response: 404, ref: '#/components/responses/404'),
],
)]
public function show(Request $request): JsonResponse
{
$resource = $this->findResource($request, 'view');
if ($resource instanceof JsonResponse) {
return $resource;
}
return response()->json($this->sourceData($resource));
}
#[OA\Patch(
summary: 'Set Secret Manager Source',
description: 'Link a secret manager token of the team to an application, service, or database, or change the token or settings of the link. References resolve at the next deployment.',
path: '/{resource_type}/{uuid}/secret-manager',
operationId: 'set-secret-manager-source',
security: [['bearerAuth' => []]],
tags: ['Secret Managers'],
parameters: [new OA\Parameter(name: 'resource_type', in: 'path', required: true, description: 'Resource type.', schema: new OA\Schema(type: 'string', enum: ['applications', 'services', 'databases'])), new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Resource UUID.', schema: new OA\Schema(type: 'string'))],
requestBody: new OA\RequestBody(
required: true,
content: new OA\JsonContent(
required: ['integration_token_uuid'],
properties: [
new OA\Property(property: 'integration_token_uuid', type: 'string'),
new OA\Property(property: 'settings', type: 'object', description: 'Doppler service account: project, config. Infisical: project_id, environment, secret_path. Vault: mount, path.'),
],
),
),
responses: [
new OA\Response(response: 200, description: 'Secret manager configured.'),
new OA\Response(response: 401, ref: '#/components/responses/401'),
new OA\Response(response: 403, description: 'No write access to the resource.'),
new OA\Response(response: 404, ref: '#/components/responses/404'),
new OA\Response(response: 422, ref: '#/components/responses/422'),
],
)]
#[OA\Patch(
summary: 'Configure Application Secret Manager',
description: 'Configure the secret manager source used by an application. Same as Set Secret Manager Source with resource_type applications.',
path: '/applications/{uuid}/secret-manager',
operationId: 'configure-application-secret-manager',
security: [['bearerAuth' => []]],
tags: ['Secret Managers'],
parameters: [new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string'))],
requestBody: new OA\RequestBody(
required: true,
content: new OA\JsonContent(
required: ['integration_token_uuid'],
properties: [
new OA\Property(property: 'integration_token_uuid', type: 'string'),
new OA\Property(property: 'settings', type: 'object'),
],
),
),
responses: [
new OA\Response(response: 200, description: 'Secret manager configured.'),
new OA\Response(response: 401, ref: '#/components/responses/401'),
new OA\Response(response: 403, description: 'No write access to the resource.'),
new OA\Response(response: 404, ref: '#/components/responses/404'),
new OA\Response(response: 422, ref: '#/components/responses/422'),
],
)]
public function update(Request $request): JsonResponse
{
$resource = $this->findResource($request, 'update');
if ($resource instanceof JsonResponse) {
return $resource;
}
$return = validateIncomingRequest($request);
if ($return instanceof JsonResponse) {
return $return;
}
$body = $request->json()->all();
$token = is_string($body['integration_token_uuid'] ?? null)
? $this->secretManager->findToken($resource, $body['integration_token_uuid'])
: null;
if (! $token) {
return response()->json(['message' => 'Secret manager integration token not found.'], 404);
}
$settingsRules = $this->secretManager->settingsRules($token);
$body = $this->validateBody($body, [
'integration_token_uuid' => ['required', 'string'],
'settings' => ['sometimes', 'array'],
] + $settingsRules);
if ($body instanceof JsonResponse) {
return $body;
}
$settingKeys = array_map(fn (string $field): string => Str::after($field, 'settings.'), array_keys($settingsRules));
$link = $this->secretManager->link($resource, $token, Arr::only($body['settings'] ?? [], $settingKeys), 'api');
return response()->json([
'integration_token_uuid' => $token->uuid,
'provider' => $token->provider,
'settings' => $link->settings,
]);
}
#[OA\Delete(
summary: 'Remove Secret Manager Source',
description: 'Remove the secret manager source. Existing {{vault.*}} variables stay and fail the next deployment until they are removed.',
path: '/{resource_type}/{uuid}/secret-manager',
operationId: 'remove-secret-manager-source',
security: [['bearerAuth' => []]],
tags: ['Secret Managers'],
parameters: [new OA\Parameter(name: 'resource_type', in: 'path', required: true, description: 'Resource type.', schema: new OA\Schema(type: 'string', enum: ['applications', 'services', 'databases'])), new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Resource UUID.', schema: new OA\Schema(type: 'string'))],
responses: [
new OA\Response(response: 200, description: 'Source removed.'),
new OA\Response(response: 401, ref: '#/components/responses/401'),
new OA\Response(response: 403, description: 'No write access to the resource.'),
new OA\Response(response: 404, ref: '#/components/responses/404'),
],
)]
public function destroy(Request $request): JsonResponse
{
$resource = $this->findResource($request, 'update');
if ($resource instanceof JsonResponse) {
return $resource;
}
if ($this->sourceLink($resource) === null) {
return $this->noSourceResponse();
}
$this->secretManager->unlink($resource, 'api');
return response()->json(['message' => 'Secret manager source removed. Existing {{vault.*}} variables fail the next deployment until they are removed.']);
}
#[OA\Get(
summary: 'List Secret Manager Keys',
description: 'Key names of the secrets in the linked source. Values are never returned. Requires write access, like the resource page, because the stored secret is sent to the secret manager.',
path: '/{resource_type}/{uuid}/secret-manager/keys',
operationId: 'list-secret-manager-keys',
security: [['bearerAuth' => []]],
tags: ['Secret Managers'],
parameters: [new OA\Parameter(name: 'resource_type', in: 'path', required: true, description: 'Resource type.', schema: new OA\Schema(type: 'string', enum: ['applications', 'services', 'databases'])), new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Resource UUID.', schema: new OA\Schema(type: 'string'))],
responses: [
new OA\Response(response: 200, description: 'Key names.', content: new OA\JsonContent(properties: [new OA\Property(property: 'keys', type: 'array', items: new OA\Items(type: 'string'))])),
new OA\Response(response: 400, description: 'The secret manager did not return the keys.'),
new OA\Response(response: 401, ref: '#/components/responses/401'),
new OA\Response(response: 403, description: 'No write access to the resource.'),
new OA\Response(response: 404, ref: '#/components/responses/404'),
],
)]
public function keys(Request $request): JsonResponse
{
$resource = $this->findResource($request, 'update');
if ($resource instanceof JsonResponse) {
return $resource;
}
$link = $this->sourceLink($resource);
if ($link === null) {
return $this->noSourceResponse();
}
try {
return response()->json(['keys' => $this->secretManager->keyNames($resource, $link, 'api')]);
} catch (Throwable $e) {
return response()->json(['message' => 'Could not fetch keys: '.$e->getMessage()], 400);
}
}
#[OA\Post(
summary: 'Add Secret Manager Reference',
description: 'Add a {{vault.KEY}} environment variable for one key of the linked source.',
path: '/{resource_type}/{uuid}/secret-manager/references',
operationId: 'add-secret-manager-reference',
security: [['bearerAuth' => []]],
tags: ['Secret Managers'],
parameters: [new OA\Parameter(name: 'resource_type', in: 'path', required: true, description: 'Resource type.', schema: new OA\Schema(type: 'string', enum: ['applications', 'services', 'databases'])), new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Resource UUID.', schema: new OA\Schema(type: 'string'))],
requestBody: new OA\RequestBody(required: true, content: new OA\JsonContent(required: ['key'], properties: [new OA\Property(property: 'key', type: 'string')])),
responses: [
new OA\Response(response: 201, description: 'Variable added.'),
new OA\Response(response: 400, description: 'The secret manager did not return the keys.'),
new OA\Response(response: 401, ref: '#/components/responses/401'),
new OA\Response(response: 403, description: 'No write access to the resource.'),
new OA\Response(response: 404, ref: '#/components/responses/404'),
new OA\Response(response: 422, description: 'Not a key of the source, not a valid variable name, or the variable exists.'),
],
)]
public function addReference(Request $request): JsonResponse
{
$resource = $this->findResource($request, 'update');
if ($resource instanceof JsonResponse) {
return $resource;
}
$link = $this->sourceLink($resource);
if ($link === null) {
return $this->noSourceResponse();
}
$return = validateIncomingRequest($request);
if ($return instanceof JsonResponse) {
return $return;
}
$body = $this->validateBody($request->json()->all(), ['key' => ['required', 'string', 'max:255']]);
if ($body instanceof JsonResponse) {
return $body;
}
try {
$keys = array_map('strval', array_keys($link->fetchSecrets()));
} catch (Throwable $e) {
return response()->json(['message' => 'Could not fetch keys: '.$e->getMessage()], 400);
}
try {
if (! in_array($body['key'], $keys, true)) {
throw ValidationException::withMessages(['key' => "{$body['key']} is not a key of the secret manager source."]);
}
$this->secretManager->addReference($resource, $body['key'], 'api');
} catch (ValidationException $e) {
return response()->json(['message' => 'Validation failed.', 'errors' => $e->errors()], 422);
}
return response()->json(['key' => $body['key'], 'value' => '{{vault.'.$body['key'].'}}'], 201);
}
#[OA\Post(
summary: 'Import Secret Manager References',
description: 'Add a {{vault.KEY}} environment variable for every key of the linked source that has no variable yet. Keys that are not valid variable names are skipped.',
path: '/{resource_type}/{uuid}/secret-manager/import',
operationId: 'import-secret-manager-references',
security: [['bearerAuth' => []]],
tags: ['Secret Managers'],
parameters: [new OA\Parameter(name: 'resource_type', in: 'path', required: true, description: 'Resource type.', schema: new OA\Schema(type: 'string', enum: ['applications', 'services', 'databases'])), new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Resource UUID.', schema: new OA\Schema(type: 'string'))],
responses: [
new OA\Response(response: 200, description: 'Import result.', content: new OA\JsonContent(properties: [
new OA\Property(property: 'imported', type: 'array', items: new OA\Items(type: 'string')),
new OA\Property(property: 'skipped', type: 'array', items: new OA\Items(type: 'string')),
])),
new OA\Response(response: 400, description: 'The secret manager did not return the keys.'),
new OA\Response(response: 401, ref: '#/components/responses/401'),
new OA\Response(response: 403, description: 'No write access to the resource.'),
new OA\Response(response: 404, ref: '#/components/responses/404'),
],
)]
public function import(Request $request): JsonResponse
{
$resource = $this->findResource($request, 'update');
if ($resource instanceof JsonResponse) {
return $resource;
}
$link = $this->sourceLink($resource);
if ($link === null) {
return $this->noSourceResponse();
}
try {
return response()->json($this->secretManager->importAll($resource, $link, 'api'));
} catch (Throwable $e) {
return response()->json(['message' => 'Could not fetch keys: '.$e->getMessage()], 400);
}
}
/**
* The resource of the token team, authorized for the ability. Errors are returned as a response.
*/
private function findResource(Request $request, string $ability): Model|JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$uuid = (string) $request->route('uuid');
[$resource, $name] = match ($request->route('resource_type')) {
'applications' => [Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $uuid)->first(), 'Application'],
'services' => [Service::whereRelation('environment.project.team', 'id', $teamId)->where('uuid', $uuid)->first(), 'Service'],
default => [queryDatabaseByUuidWithinTeam($uuid, (string) $teamId), 'Database'],
};
if ($resource === null) {
return response()->json(['message' => "{$name} not found."], 404);
}
$this->authorize($ability, $resource);
return $resource;
}
private function sourceLink(Model $resource): ?SecretManagerLink
{
return $resource->secretManagerLink()->with('integrationToken')->first();
}
private function noSourceResponse(): JsonResponse
{
return response()->json(['message' => 'The resource has no secret manager source.'], 404);
}
/**
* @return array<string, mixed>
*/
private function sourceData(Model $resource): array
{
$link = $this->sourceLink($resource);
return [
'linked' => $link !== null,
'integration_token_uuid' => $link?->integrationToken?->uuid,
'provider' => $link?->integrationToken?->provider,
'settings' => $link?->settings,
'available_tokens' => $this->secretManager->availableTokens($resource)
->map(fn (IntegrationToken $token): array => ['uuid' => $token->uuid, 'name' => $token->name, 'provider' => $token->provider])
->all(),
];
}
/**
* @param array<string, mixed> $body
* @param array<string, array<int, string>> $rules
* @return array<string, mixed>|JsonResponse
*/
private function validateBody(array $body, array $rules): array|JsonResponse
{
$validator = customApiValidator($body, $rules);
$allowed = array_unique(array_map(fn (string $field): string => explode('.', $field)[0], array_keys($rules)));
$extraFields = array_diff(array_keys($body), $allowed);
if ($validator->fails() || $extraFields !== []) {
$errors = $validator->errors();
foreach ($extraFields as $field) {
$errors->add($field, 'This field is not allowed.');
}
return response()->json(['message' => 'Validation failed.', 'errors' => $errors], 422);
}
return $validator->validated();
}
}
@@ -18,6 +18,7 @@ use App\Models\ServiceApplication;
use App\Models\StandaloneDocker;
use App\Models\SwarmDocker;
use App\Services\Dns\ManagedDnsRecordCleanup;
use App\Services\Dns\ResourceDnsRecords;
use App\Support\ValidationPatterns;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\JsonResponse;
@@ -1337,6 +1338,7 @@ class ServicesController extends Controller
}
// URLs of some containers can be saved before a later container fails, so release before any error response.
$this->queueReleaseOfRemovedServiceHostnames($service, $previousDnsHostnames, (int) $teamId);
app(ResourceDnsRecords::class)->queueAutomaticRecords($service, array_merge(...array_values($previousDnsHostnames)));
if ($urlResult !== null) {
if (isset($urlResult['errors'])) {
return response()->json([
@@ -2,6 +2,7 @@
namespace App\Livewire\Concerns;
use App\Services\Dns\ServerDnsAddresses;
use App\Support\DnsRecordHints;
use App\Support\DomainConnect\CloudflareDomainConnect;
use Illuminate\Support\Js;
@@ -211,35 +212,12 @@ trait InteractsWithCloudflareDomainConnect
*/
protected function serverIpsForDnsHints(): array
{
$ipv4 = null;
$ipv6 = null;
return $this->serverDnsAddresses()->addresses();
}
$ip = $this->serverIpForDomainConnect();
if (filled($ip)) {
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) !== false) {
$ipv4 = $ip;
} elseif (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false) {
$ipv6 = $ip;
}
}
if ($this->usesInstanceNetworkAddressesForDnsHints()) {
try {
$settings = instanceSettings();
$publicV4 = data_get($settings, 'public_ipv4');
$publicV6 = data_get($settings, 'public_ipv6');
if ($ipv4 === null && is_string($publicV4) && filter_var($publicV4, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
$ipv4 = $publicV4;
}
if ($ipv6 === null && is_string($publicV6) && filter_var($publicV6, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) {
$ipv6 = $publicV6;
}
} catch (\Throwable) {
//
}
}
return [$ipv4, $ipv6];
protected function serverDnsAddresses(): ServerDnsAddresses
{
return new ServerDnsAddresses($this->serverIp, $this->serverIpConfigured, $this->usesInstanceNetworkAddressesForDnsHints());
}
protected function defaultDnsHostname(): string
@@ -265,13 +243,7 @@ trait InteractsWithCloudflareDomainConnect
*/
protected function publicServerIpForDomainConnect(): ?string
{
foreach ($this->serverIpsForDnsHints() as $address) {
if (is_string($address) && DnsRecordHints::isPublicAddress($address)) {
return $address;
}
}
return null;
return $this->serverDnsAddresses()->publicAddresses()[0] ?? null;
}
protected function serverIpForDomainConnect(): ?string
@@ -397,33 +397,15 @@ trait InteractsWithDnsProviders
->whereHas('integrationToken', fn ($query) => $query->where('team_id', $this->dnsTeamId()))->first();
}
/**
* Server addresses that a DNS provider may publish. Private, reserved, CGNAT, and link-local addresses are excluded:
* a public DNS record must not expose them, and the record would not be reachable anyway.
*
* @return array<int, string>
*/
/** @return array<int, string> */
protected function publicServerIpsForDnsProvider(): array
{
return array_values(array_filter(
$this->serverIpsForDnsHints(),
fn (?string $address): bool => is_string($address) && DnsRecordHints::isPublicAddress($address),
));
return $this->serverDnsAddresses()->publicAddresses();
}
/**
* Content of a DNS record for this resource: one of its public server addresses (IPv4 or IPv6),
* the first one when none is requested. Any other address is rejected.
*/
protected function dnsRecordContent(?string $requested = null): ?string
{
$addresses = $this->publicServerIpsForDnsProvider();
if ($requested === null) {
return $addresses[0] ?? null;
}
return collect($addresses)->first(fn (string $address): bool => DnsRecordHints::sameAddress($address, $requested));
return $this->serverDnsAddresses()->recordContent($requested);
}
abstract protected function persistDomainDnsStatuses(): void;
+5 -19
View File
@@ -9,6 +9,7 @@ use App\Livewire\Concerns\InteractsWithDnsProviders;
use App\Livewire\Project\Shared\ConfigurationChecker;
use App\Models\Application;
use App\Models\Server;
use App\Services\Dns\ServerDnsAddresses;
use App\Support\DomainPortOverrides;
use App\Support\DomainUrlParts;
use App\Support\ValidationPatterns;
@@ -294,25 +295,10 @@ class Domains extends Component
$settings = instanceSettings();
$this->dnsValidationEnabled = (bool) data_get($settings, 'is_dns_validation_enabled', true);
$server = $this->application->destination?->server;
if ($server) {
if ($server->id === 0) {
$configured = data_get($settings, 'public_ipv4')
?: data_get($settings, 'public_ipv6')
?: $server->ip;
} else {
$configured = $server->ip;
}
$resolved = resolveServerIpAddress(is_string($configured) ? $configured : null);
$this->serverIpConfigured = $resolved['configured'];
// Prefer real IP so users know what DNS record to set even when the
// server address is stored as a hostname (e.g. coolify-testing-host).
$this->serverIp = $resolved['ip'] ?? $resolved['configured'];
} else {
$this->serverIp = null;
$this->serverIpConfigured = null;
}
$addresses = ServerDnsAddresses::forServer($this->application->destination?->server);
$this->serverIpConfigured = $addresses->configured;
// Prefer the real IP so users know what DNS record to set when the server address is a hostname.
$this->serverIp = $addresses->address;
$pendingRedirect = $this->pendingRedirectService !== null
? ($this->serviceRedirects[$this->serviceRedirectWireKey($this->pendingRedirectService)] ?? null)
+5 -16
View File
@@ -10,6 +10,7 @@ use App\Livewire\Project\Shared\ConfigurationChecker;
use App\Models\Server;
use App\Models\Service;
use App\Models\ServiceApplication;
use App\Services\Dns\ServerDnsAddresses;
use App\Support\DomainPortOverrides;
use App\Support\DomainUrlParts;
use App\Support\ValidationPatterns;
@@ -269,22 +270,10 @@ class Domains extends Component
$settings = instanceSettings();
$this->dnsValidationEnabled = (bool) data_get($settings, 'is_dns_validation_enabled', true);
$server = $this->service->server;
if ($server) {
if ($server->id === 0) {
$configured = data_get($settings, 'public_ipv4')
?: data_get($settings, 'public_ipv6')
?: $server->ip;
} else {
$configured = $server->ip;
}
$resolved = resolveServerIpAddress(is_string($configured) ? $configured : null);
$this->serverIpConfigured = $resolved['configured'];
$this->serverIp = $resolved['ip'] ?? $resolved['configured'];
} else {
$this->serverIp = null;
$this->serverIpConfigured = null;
}
$addresses = ServerDnsAddresses::forServer($this->service->server);
$this->serverIpConfigured = $addresses->configured;
// Prefer the real IP so users know what DNS record to set when the server address is a hostname.
$this->serverIp = $addresses->address;
$this->forceHttpsRedirects = $this->service->applications
->mapWithKeys(fn (ServiceApplication $app) => [$app->id => $app->isForceHttpsEnabled()])
@@ -4,9 +4,9 @@ namespace App\Livewire\Project\Shared;
use App\Livewire\Project\Shared\EnvironmentVariable\All;
use App\Models\IntegrationToken;
use App\Services\SecretManagers\ResourceSecretManager;
use App\Support\ValidationPatterns;
use Illuminate\Contracts\View\View;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Validation\ValidationException;
use Livewire\Component;
@@ -45,10 +45,7 @@ class SecretManagerLinks extends Component
private function loadData(): void
{
$this->link = $this->resource->secretManagerLink()->with('integrationToken')->first();
$this->availableTokens = $this->resourceTeamTokens()
->get()
->filter(fn (IntegrationToken $token) => in_array('secrets', $token->capabilities ?? [], true))
->values();
$this->availableTokens = $this->secretManager()->availableTokens($this->resource);
if ($this->link) {
$this->integration_token_uuid = $this->link->integrationToken->uuid;
@@ -62,19 +59,12 @@ class SecretManagerLinks extends Component
return null;
}
return $this->resourceTeamTokens()->where('uuid', $this->integration_token_uuid)->get()
->first(fn (IntegrationToken $token) => in_array('secrets', $token->capabilities ?? [], true));
return $this->secretManager()->findToken($this->resource, $this->integration_token_uuid);
}
/**
* Secret manager tokens of the resource's team. The session team can differ: a user can
* switch teams in another tab while this component is still open.
*/
private function resourceTeamTokens(): Builder
private function secretManager(): ResourceSecretManager
{
return IntegrationToken::query()
->where('team_id', $this->resource->team()?->id)
->whereIn('provider', IntegrationToken::SECRET_MANAGER_PROVIDERS);
return app(ResourceSecretManager::class);
}
protected function rules(): array
@@ -83,24 +73,10 @@ class SecretManagerLinks extends Component
'integration_token_uuid' => ['required', 'string'],
];
$rules += match ($this->selectedToken?->provider) {
'doppler' => $this->selectedToken->dopplerTokenType() === 'service_account'
? [
'settings.project' => ['required', 'string'],
'settings.config' => ['required', 'string'],
]
: [],
'infisical' => [
'settings.project_id' => ['required', 'string'],
'settings.environment' => ['required', 'string'],
'settings.secret_path' => ['nullable', 'string'],
],
'vault' => [
'settings.mount' => ['required', 'string'],
'settings.path' => ['required', 'string'],
],
default => [],
};
$token = $this->selectedToken;
if ($token !== null) {
$rules += $this->secretManager()->settingsRules($token);
}
return $rules;
}
@@ -126,16 +102,7 @@ class SecretManagerLinks extends Component
}
$validated = $this->validate($this->tokenChangeRules());
$settings = array_filter(data_get($validated, 'settings', []), fn ($value) => filled($value));
$this->resource->secretManagerLink()->updateOrCreate([], [
'integration_token_id' => $token->id,
'settings' => $settings ?: null,
]);
$this->auditSecretManagerAction('source_updated', [
'integration_token_uuid' => $token->uuid,
'provider' => $token->provider,
]);
$this->secretManager()->link($this->resource, $token, data_get($validated, 'settings', []), 'ui');
$this->resetKeys();
$this->loadData();
@@ -180,7 +147,7 @@ class SecretManagerLinks extends Component
$settings = array_filter(data_get($validated, 'settings', []), fn ($value) => filled($value));
$this->link->update(['settings' => $settings ?: null]);
$this->auditSecretManagerAction('settings_updated');
$this->secretManager()->audit('ui', $this->resource, 'settings_updated');
$this->resetKeys();
$this->loadData();
$this->dispatch('success', 'Secret manager settings saved.');
@@ -193,12 +160,7 @@ class SecretManagerLinks extends Component
{
try {
$this->authorize('update', $this->resource);
$token = $this->link?->integrationToken;
$this->resource->secretManagerLink()->delete();
$this->auditSecretManagerAction('source_removed', [
'integration_token_uuid' => $token?->uuid,
'provider' => $token?->provider,
]);
$this->secretManager()->unlink($this->resource, 'ui');
$this->link = null;
$this->integration_token_uuid = '';
$this->settings = [];
@@ -219,12 +181,8 @@ class SecretManagerLinks extends Component
return;
}
// Values are fetched into memory, reduced to key names, and discarded.
$keys = array_keys($this->link->fetchSecrets());
sort($keys);
$this->keys = $keys;
$this->keys = $this->secretManager()->keyNames($this->resource, $this->link, 'ui');
$this->keysLoaded = true;
$this->auditSecretManagerAction('keys_viewed', ['key_count' => count($keys)]);
} catch (\Throwable $e) {
$this->dispatch('error', 'Could not fetch keys: '.$e->getMessage());
}
@@ -239,24 +197,14 @@ class SecretManagerLinks extends Component
return;
}
if (! ValidationPatterns::isValidEnvironmentVariableKey($key)) {
$this->dispatch('error', "{$key} is not a valid variable name.");
try {
$this->secretManager()->addReference($this->resource, $key, 'ui');
} catch (ValidationException $e) {
$this->dispatch('error', $e->validator->errors()->first('key'));
return;
}
if ($this->resource->environment_variables()->where('key', $key)->exists()) {
$this->dispatch('error', "A variable with the key {$key} already exists.");
return;
}
$this->resource->environment_variables()->create([
'key' => $key,
'value' => '{{vault.'.$key.'}}',
]);
$this->auditSecretManagerAction('reference_created', ['secret_key' => $key]);
$this->dispatch('refreshEnvs')->to(All::class);
$this->dispatch('success', "Added {$key} as {{vault.{$key}}}.");
} catch (\Throwable $e) {
@@ -273,12 +221,7 @@ class SecretManagerLinks extends Component
return;
}
['imported' => $imported, 'skipped' => $skipped] = $this->link->importMissingReferences();
$this->auditSecretManagerAction('references_imported', [
'key_count' => count($imported),
'secret_keys' => $imported,
'skipped_key_count' => count($skipped),
]);
['imported' => $imported, 'skipped' => $skipped] = $this->secretManager()->importAll($this->resource, $this->link, 'ui');
$this->dispatch('refreshEnvs')->to(All::class);
$this->dispatch('success', $this->importResultMessage($imported, $skipped));
@@ -321,18 +264,6 @@ class SecretManagerLinks extends Component
$this->search = '';
}
/** @param array<string, mixed> $context */
private function auditSecretManagerAction(string $action, array $context = []): void
{
$resourceType = str(class_basename($this->resource))->snake()->value();
auditLog("ui.{$resourceType}.secret_manager.{$action}", array_merge([
'team_id' => $this->resource->team()?->id,
"{$resourceType}_uuid" => $this->resource->uuid,
"{$resourceType}_name" => $this->resource->name,
], $context));
}
public function getFilteredKeysProperty(): array
{
if (blank($this->search)) {
+41 -170
View File
@@ -2,12 +2,13 @@
namespace App\Livewire\Security;
use App\Actions\IntegrationToken\UpdateIntegrationToken;
use App\Models\IntegrationToken;
use App\Services\Dns\CloudflareDnsProvider;
use App\Services\IntegrationTokenValidator;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
use Livewire\Component;
class IntegrationTokenEditor extends Component
@@ -61,32 +62,14 @@ class IntegrationTokenEditor extends Component
protected function rules(): array
{
$rules = [
'name' => ['required', 'string', 'max:255'],
'newToken' => ['nullable', 'string'],
'capabilities' => ['required', 'array', 'min:1', function (string $attribute, mixed $value, \Closure $fail): void {
if (is_array($value) && in_array('tunnel', $value, true) && ! in_array('dns', $value, true)) {
$fail('Cloudflare Tunnel also needs the DNS capability.');
}
}],
'capabilities.*' => ['required', 'in:'.implode(',', IntegrationToken::allowedCapabilities($this->integrationToken->provider))],
$rules = UpdateIntegrationToken::rules($this->integrationToken->provider);
$rules['newToken'] = $rules['token'];
unset($rules['name'], $rules['token']);
return ['name' => ['required', 'string', 'max:255']] + $rules + [
'automaticDns' => ['boolean'],
'proxiedDns' => ['boolean'],
];
if ($this->integrationToken->provider === 'infisical') {
$rules['metadata.base_url'] = ['required', 'url:http,https'];
$rules['metadata.client_id'] = ['required', 'string'];
$rules['metadata.timeout'] = ['nullable', 'integer', 'min:1', 'max:300'];
}
if ($this->integrationToken->provider === 'vault') {
$rules['metadata.base_url'] = ['required', 'url:http,https'];
$rules['metadata.namespace'] = ['nullable', 'string'];
$rules['metadata.timeout'] = ['nullable', 'integer', 'min:1', 'max:300'];
}
return $rules;
}
protected function messages(): array
@@ -97,165 +80,53 @@ class IntegrationTokenEditor extends Component
];
}
public function save(IntegrationTokenValidator $validator, CloudflareDnsProvider $cloudflare): void
public function save(IntegrationTokenValidator $validator): 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;
}
if ($validated['proxiedDns']) {
$metadata['proxied_dns'] = true;
} else {
unset($metadata['proxied_dns']);
}
}
$storedMetadata = $this->integrationToken->metadata ?? [];
$changedConnectionFields = $this->changedConnectionFields($provider, $storedMetadata, $metadata);
if ($changedConnectionFields !== [] && blank($validated['newToken'])) {
$this->addError('newToken', $this->reenterSecretMessage($provider));
try {
UpdateIntegrationToken::run(
$this->integrationToken,
$validated['name'],
$validated['newToken'],
$validated['capabilities'],
data_get($validated, 'metadata', []),
$validated['automaticDns'],
$validated['proxiedDns'],
'ui',
);
} catch (ValidationException $e) {
$this->addError('newToken', $e->validator->errors()->first('token'));
return;
} catch (ConnectionException) {
$this->dispatch('error', $validator->connectionErrorMessage($this->integrationToken->provider));
return;
} catch (\Throwable $e) {
handleError($e, $this);
return;
}
$capabilitiesChanged = collect($validated['capabilities'])->sort()->values()->all()
!== collect($this->integrationToken->capabilities)->sort()->values()->all();
$metadataChanged = $metadata != $storedMetadata;
try {
if ((filled($validated['newToken']) || $capabilitiesChanged || $metadataChanged)
&& ! $validator->validate($provider, $token, $validated['capabilities'], $metadata)) {
$this->dispatch('error', $validator->errorMessage($provider));
return;
}
$updates = [
'name' => $validated['name'],
'capabilities' => $validated['capabilities'],
'metadata' => $metadata ?: null,
];
if (filled($validated['newToken'])) {
$updates['token'] = $validated['newToken'];
}
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' => $this->integrationToken->team_id,
'integration_token_uuid' => $this->integrationToken->uuid,
'integration_token_name' => $this->integrationToken->name,
'provider' => $this->integrationToken->provider,
'rotated' => array_key_exists('token', $updates),
'connection_changed_fields' => $changedConnectionFields,
]);
if (in_array('base_url', $changedConnectionFields, true)) {
auditLog('ui.integration_token.base_url_changed', [
'team_id' => $this->integrationToken->team_id,
'integration_token_uuid' => $this->integrationToken->uuid,
'integration_token_name' => $this->integrationToken->name,
'provider' => $provider,
'previous_base_url' => $this->baseUrlForAudit(data_get($storedMetadata, 'base_url')),
'base_url' => $this->baseUrlForAudit(data_get($metadata, 'base_url')),
], 'warning');
}
$this->dispatch(
'integration-token-updated',
uuid: $this->integrationToken->uuid,
name: $this->integrationToken->name,
capabilities: $this->integrationToken->capabilities,
);
$this->dispatch('success', 'Integration token updated successfully.');
} catch (ConnectionException) {
$this->dispatch('error', $validator->connectionErrorMessage($provider));
} catch (\Throwable $e) {
handleError($e, $this);
}
}
/**
* Metadata keys that decide where or how the stored secret is sent.
* Changing any of them must require the secret to be entered again.
*
* @return array<int, string>
*/
private function connectionFields(string $provider): array
{
return match ($provider) {
'vault' => ['base_url', 'namespace'],
'infisical' => ['base_url', 'client_id'],
default => [],
};
}
/**
* @param array<string, mixed> $storedMetadata
* @param array<string, mixed> $metadata
* @return array<int, string>
*/
private function changedConnectionFields(string $provider, array $storedMetadata, array $metadata): array
{
return array_values(array_filter(
$this->connectionFields($provider),
fn (string $field): bool => (string) data_get($storedMetadata, $field, '') !== (string) data_get($metadata, $field, ''),
));
}
/**
* Base URL without user info, query, or fragment, which can carry credentials.
*/
private function baseUrlForAudit(mixed $url): ?string
{
if (! is_string($url) || $url === '') {
return null;
}
$parts = parse_url($url);
if (! is_array($parts) || ! isset($parts['scheme'], $parts['host'])) {
return '[invalid-url]';
}
return $parts['scheme'].'://'.$parts['host']
.(isset($parts['port']) ? ':'.$parts['port'] : '')
.($parts['path'] ?? '');
}
private function reenterSecretMessage(string $provider): string
{
return match ($provider) {
'infisical' => 'Enter the client secret again when you change the base URL or the client ID.',
'vault' => 'Enter the token again when you change the base URL or the namespace.',
default => 'Enter the token again when you change the connection settings.',
};
$this->newToken = '';
$this->loadZones();
$this->dispatch(
'integration-token-updated',
uuid: $this->integrationToken->uuid,
name: $this->integrationToken->name,
capabilities: $this->integrationToken->capabilities,
);
$this->dispatch('success', 'Integration token updated successfully.');
}
public function delete(string $password = ''): void
{
$this->authorize('delete', $this->integrationToken);
if ($this->integrationToken->secretManagerLinks()->exists()) {
$this->dispatch('error', 'This token is used by one or more resources as a secret manager source. Remove those links first.');
return;
}
if ($this->integrationToken->managedDnsRecords()->exists()) {
$this->dispatch('error', 'This token manages DNS records. Remove those domains or records first.');
if ($blocker = $this->integrationToken->deletionBlocker()) {
$this->dispatch('error', $blocker);
return;
}
+13 -55
View File
@@ -2,12 +2,11 @@
namespace App\Livewire\Security;
use App\Actions\IntegrationToken\CreateIntegrationToken;
use App\Models\IntegrationToken;
use App\Services\Dns\CloudflareDnsProvider;
use App\Services\IntegrationTokenValidator;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\DB;
use Livewire\Component;
class IntegrationTokenForm extends Component
@@ -62,37 +61,10 @@ class IntegrationTokenForm extends Component
protected function rules(): array
{
$rules = [
'provider' => ['required', 'in:'.implode(',', array_keys(IntegrationToken::PROVIDER_NAMES))],
'name' => ['required', 'string', 'max:255'],
'token' => ['required', 'string'],
'capabilities' => ['required', 'array', 'min:1', function (string $attribute, mixed $value, \Closure $fail): void {
if (is_array($value) && in_array('tunnel', $value, true) && ! in_array('dns', $value, true)) {
$fail('Cloudflare Tunnel also needs the DNS capability.');
}
}],
'capabilities.*' => ['required', 'in:'.implode(',', IntegrationToken::allowedCapabilities($this->provider))],
return CreateIntegrationToken::rules($this->provider) + [
'automaticDns' => ['boolean'],
'proxiedDns' => ['boolean'],
];
if ($this->provider === 'infisical') {
$rules['metadata.base_url'] = ['required', 'url:http,https'];
$rules['metadata.client_id'] = ['required', 'string'];
$rules['metadata.timeout'] = ['nullable', 'integer', 'min:1', 'max:300'];
}
if ($this->provider === 'doppler') {
$rules['token'][] = 'regex:/^dp\.(st|sa)\./';
}
if ($this->provider === 'vault') {
$rules['metadata.base_url'] = ['required', 'url:http,https'];
$rules['metadata.namespace'] = ['nullable', 'string'];
$rules['metadata.timeout'] = ['nullable', 'integer', 'min:1', 'max:300'];
}
return $rules;
}
protected function messages(): array
@@ -104,37 +76,23 @@ class IntegrationTokenForm extends Component
];
}
public function addToken(IntegrationTokenValidator $validator, CloudflareDnsProvider $cloudflare): void
public function addToken(IntegrationTokenValidator $validator): void
{
$this->authorize('create', IntegrationToken::class);
$validated = $this->validate();
$metadata = array_filter(data_get($validated, 'metadata', []), fn ($value) => filled($value));
if ($validated['provider'] === 'cloudflare' && ! $validated['automaticDns']) {
$metadata['automatic_dns'] = false;
}
if ($validated['provider'] === 'cloudflare' && $validated['proxiedDns']) {
$metadata['proxied_dns'] = true;
}
try {
if (! $validator->validate($validated['provider'], $validated['token'], $validated['capabilities'], $metadata)) {
$this->dispatch('error', $validator->errorMessage($validated['provider']));
return;
}
$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;
});
$integrationToken = CreateIntegrationToken::run(
currentTeam()->id,
$validated['provider'],
$validated['name'],
$validated['token'],
$validated['capabilities'],
data_get($validated, 'metadata', []),
$validated['automaticDns'],
$validated['proxiedDns'],
);
auditLog('ui.integration_token.created', [
'team_id' => currentTeam()->id,
+2 -8
View File
@@ -31,14 +31,8 @@ class IntegrationTokens extends Component
$token = IntegrationToken::ownedByCurrentTeam()->findOrFail($tokenId);
$this->authorize('delete', $token);
if ($token->secretManagerLinks()->exists()) {
$this->dispatch('error', 'This token is used by one or more resources as a secret manager source. Remove those links first.');
return;
}
if ($token->managedDnsRecords()->exists()) {
$this->dispatch('error', 'This token manages DNS records. Remove those domains or records first.');
if ($blocker = $token->deletionBlocker()) {
$this->dispatch('error', $blocker);
return;
}
-1
View File
@@ -32,7 +32,6 @@ trait CallsRestApi
'api/v1/sentinel/',
'api/v1/settings/',
'api/v1/teams',
'api/v1/security/integration-tokens',
];
/**
+10
View File
@@ -64,10 +64,13 @@ use App\Mcp\Tools\ListTeamMembers;
use App\Mcp\Tools\ListUnhealthyResources;
use App\Mcp\Tools\ManageApplication;
use App\Mcp\Tools\ManageBackup;
use App\Mcp\Tools\ManageCloudflareTunnel;
use App\Mcp\Tools\ManageDatabase;
use App\Mcp\Tools\ManageDestination;
use App\Mcp\Tools\ManageDns;
use App\Mcp\Tools\ManageEnv;
use App\Mcp\Tools\ManageGitSource;
use App\Mcp\Tools\ManageIntegrationTokens;
use App\Mcp\Tools\ManageNotifications;
use App\Mcp\Tools\ManagePreview;
use App\Mcp\Tools\ManagePrivateKey;
@@ -76,6 +79,7 @@ use App\Mcp\Tools\ManageProxy;
use App\Mcp\Tools\ManageRegistry;
use App\Mcp\Tools\ManageS3Storage;
use App\Mcp\Tools\ManageScheduledTask;
use App\Mcp\Tools\ManageSecretManager;
use App\Mcp\Tools\ManageServer;
use App\Mcp\Tools\ManageService;
use App\Mcp\Tools\ManageSharedEnv;
@@ -198,14 +202,18 @@ MD;
ManageBackup::class,
ManageStorage::class,
ManageTags::class,
ManageDns::class,
ManageSecretManager::class,
ManageServer::class,
ManageProxy::class,
ManageCloudflareTunnel::class,
DockerCleanup::class,
ManageDestination::class,
ManageRegistry::class,
ManagePrivateKey::class,
ManageS3Storage::class,
ManageGitSource::class,
ManageIntegrationTokens::class,
CloudProvider::class,
ManageNotifications::class,
ListAuditEvents::class,
@@ -236,12 +244,14 @@ MD;
ManageTags::class,
ManageServer::class,
ManageProxy::class,
ManageCloudflareTunnel::class,
DockerCleanup::class,
ManageDestination::class,
ManageRegistry::class,
ManagePrivateKey::class,
ManageS3Storage::class,
ManageGitSource::class,
ManageIntegrationTokens::class,
CloudProvider::class,
ManageNotifications::class,
];
+8
View File
@@ -82,6 +82,14 @@ class CoolifyHelp extends Tool
['tool' => 'deploy', 'note' => 'Redeploy after the fix; force=true to rebuild.'],
],
],
'configure_dns' => [
'description' => 'Point the domains of an application or service to its server with Cloudflare DNS',
'steps' => [
['tool' => 'manage_integration_tokens', 'action' => 'create', 'note' => 'Add a Cloudflare token once (provider=cloudflare, capabilities=[dns]); the secret is never returned.'],
['tool' => 'manage_dns', 'action' => 'status', 'note' => 'Check the providers and records of each domain.'],
['tool' => 'manage_dns', 'action' => 'create', 'note' => 'Create the record for one hostname. On a conflict, ask the user before replace.'],
],
],
'backup_database' => [
'description' => 'Back up a database to S3',
'steps' => [
+37
View File
@@ -0,0 +1,37 @@
<?php
namespace App\Mcp\Tools;
use Laravel\Mcp\Server\Tools\Annotations\IsDestructive;
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
#[IsReadOnly(false)]
#[IsDestructive]
class ManageCloudflareTunnel extends RestActionTool
{
protected string $name = 'manage_cloudflare_tunnel';
protected string $description = 'Read the Cloudflare Tunnel settings of a server of the authenticated team, or mark the tunnel as enabled or disabled. These actions do not install or remove cloudflared on the server. Requires read ability for get, write ability for changes. Same validation and permissions as the REST API.';
protected function actions(): array
{
return [
'get' => ['method' => 'GET', 'path' => 'servers/{uuid}/cloudflare-tunnel', 'ability' => 'read', 'description' => 'tunnel settings and the server address'],
'update' => ['method' => 'PATCH', 'path' => 'servers/{uuid}/cloudflare-tunnel', 'ability' => 'write', 'confirm' => true, 'description' => 'change the stored setting (data: is_cloudflare_tunnel)'],
'enable' => ['method' => 'POST', 'path' => 'servers/{uuid}/cloudflare-tunnel/enable', 'ability' => 'write', 'confirm' => true, 'description' => 'mark the tunnel as enabled after cloudflared was set up by hand'],
'disable' => ['method' => 'POST', 'path' => 'servers/{uuid}/cloudflare-tunnel/disable', 'ability' => 'write', 'confirm' => true, 'description' => 'mark the tunnel as disabled and restore the previous server address when known'],
];
}
protected function pathParams(): array
{
return [
'uuid' => 'Server UUID.',
];
}
protected function dataDescription(): string
{
return 'Request body for update: is_cloudflare_tunnel.';
}
}
+50
View File
@@ -0,0 +1,50 @@
<?php
namespace App\Mcp\Tools;
use Laravel\Mcp\Server\Tools\Annotations\IsDestructive;
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
#[IsReadOnly(false)]
#[IsDestructive]
class ManageDns extends RestActionTool
{
protected string $name = 'manage_dns';
protected string $description = 'Show, create, replace, or release Cloudflare DNS records for the domains of an application or a service. Records point only to the public addresses of the resource server, and only zones of the team are used. Requires read ability for status, write ability for create/replace, delete ability for release. Same validation and permissions as the REST API.';
protected function actions(): array
{
return [
'status' => ['method' => 'GET', 'paths' => [
'application' => 'applications/{uuid}/dns',
'service' => 'services/{uuid}/dns',
], 'ability' => 'read', 'description' => 'DNS providers, managed records, and the default record address for each domain of the resource'],
'create' => ['method' => 'POST', 'paths' => [
'application' => 'applications/{uuid}/dns',
'service' => 'services/{uuid}/dns',
], 'ability' => 'write', 'description' => 'create the DNS record for a domain (data: hostname, integration_token_uuid when several providers match, address). A conflict returns the record_id and current value: ask the user before replace'],
'replace' => ['method' => 'POST', 'paths' => [
'application' => 'applications/{uuid}/dns/replace',
'service' => 'services/{uuid}/dns/replace',
], 'ability' => 'write', 'confirm' => true, 'description' => 'overwrite the conflicting record (data: hostname, record_id, current, integration_token_uuid, address)'],
'release' => ['method' => 'DELETE', 'paths' => [
'application' => 'applications/{uuid}/dns/{hostname}',
'service' => 'services/{uuid}/dns/{hostname}',
], 'ability' => 'delete', 'confirm' => true, 'description' => 'release the record of a hostname the resource no longer uses; Coolify deletes it at Cloudflare only when it created it and nothing else uses it (data: delete_record=false keeps it)'],
];
}
protected function pathParams(): array
{
return [
'uuid' => 'Application or service UUID.',
'hostname' => 'Hostname of the record (release).',
];
}
protected function dataDescription(): string
{
return 'Request body, same fields as the REST API: hostname, integration_token_uuid, address (create); plus record_id, current (replace); delete_record (release).';
}
}
+40
View File
@@ -0,0 +1,40 @@
<?php
namespace App\Mcp\Tools;
use Laravel\Mcp\Server\Tools\Annotations\IsDestructive;
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
#[IsReadOnly(false)]
#[IsDestructive]
class ManageIntegrationTokens extends RestActionTool
{
protected string $name = 'manage_integration_tokens';
protected string $description = 'List, get, add, update, or delete integration tokens (Cloudflare DNS/Tunnel, Doppler, Infisical, Vault) of the authenticated team, list the DNS zones of its Cloudflare tokens, or read the zones of a Cloudflare token again. The token secret is never returned. Requires admin or owner, read ability for list/get/zones, write ability for changes. Same validation and permissions as the REST API.';
protected function actions(): array
{
return [
'list' => ['method' => 'GET', 'path' => 'security/integration-tokens', 'ability' => 'read', 'description' => 'integration tokens of the team'],
'get' => ['method' => 'GET', 'path' => 'security/integration-tokens/{uuid}', 'ability' => 'read', 'description' => 'one integration token'],
'zones' => ['method' => 'GET', 'path' => 'dns/zones', 'ability' => 'read', 'description' => 'DNS zones that the Cloudflare tokens of the team can access'],
'create' => ['method' => 'POST', 'path' => 'security/integration-tokens', 'ability' => 'write', 'description' => 'add a token after the provider accepts it (data: provider cloudflare|doppler|infisical|vault, name, token; Cloudflare: capabilities [dns, tunnel], automatic_dns, proxied_dns; secret managers: metadata)'],
'update' => ['method' => 'PATCH', 'path' => 'security/integration-tokens/{uuid}', 'ability' => 'write', 'description' => 'rename, rotate the secret, or change settings; omitted fields keep their value (data: name, token, capabilities, automatic_dns, proxied_dns, metadata)'],
'sync_zones' => ['method' => 'POST', 'path' => 'security/integration-tokens/{uuid}/sync-zones', 'ability' => 'write', 'description' => 'read the zones of a Cloudflare token again'],
'delete' => ['method' => 'DELETE', 'path' => 'security/integration-tokens/{uuid}', 'ability' => 'delete', 'confirm' => true, 'description' => 'delete a token that no resource uses and that manages no DNS records'],
];
}
protected function pathParams(): array
{
return [
'uuid' => 'Integration token UUID (get, update, sync_zones, delete).',
];
}
protected function dataDescription(): string
{
return 'Request body for create and update, same fields as the REST API: provider, name, token, capabilities, automatic_dns, proxied_dns, metadata.';
}
}
+51
View File
@@ -0,0 +1,51 @@
<?php
namespace App\Mcp\Tools;
use Laravel\Mcp\Server\Tools\Annotations\IsDestructive;
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
#[IsReadOnly(false)]
#[IsDestructive]
class ManageSecretManager extends RestActionTool
{
protected string $name = 'manage_secret_manager';
protected string $description = 'Show, set, or remove the secret manager source (Doppler, Infisical, Vault) of an application, service, or database, list the key names of its secrets, and add {{vault.KEY}} reference variables. Secret values are never returned. Requires read ability for get, write ability for changes and keys. Same validation and permissions as the REST API.';
protected function actions(): array
{
return [
'get' => ['method' => 'GET', 'paths' => $this->paths(''), 'ability' => 'read', 'description' => 'the source and the secret manager tokens of the team that can be linked'],
'set' => ['method' => 'PATCH', 'paths' => $this->paths(''), 'ability' => 'write', 'description' => 'link a token or change the settings (data: integration_token_uuid, settings)'],
'remove' => ['method' => 'DELETE', 'paths' => $this->paths(''), 'ability' => 'delete', 'confirm' => true, 'description' => 'remove the source; existing {{vault.*}} variables then fail the next deployment'],
'keys' => ['method' => 'GET', 'paths' => $this->paths('/keys'), 'ability' => 'write', 'description' => 'key names of the remote secrets'],
'add_reference' => ['method' => 'POST', 'paths' => $this->paths('/references'), 'ability' => 'write', 'description' => 'add a {{vault.KEY}} variable for one remote key (data: key)'],
'import' => ['method' => 'POST', 'paths' => $this->paths('/import'), 'ability' => 'write', 'description' => 'add a {{vault.KEY}} variable for every remote key that has no variable yet'],
];
}
/**
* @return array<string, string>
*/
private function paths(string $suffix): array
{
return [
'application' => "applications/{uuid}/secret-manager{$suffix}",
'service' => "services/{uuid}/secret-manager{$suffix}",
'database' => "databases/{uuid}/secret-manager{$suffix}",
];
}
protected function pathParams(): array
{
return [
'uuid' => 'Application, service, or database UUID.',
];
}
protected function dataDescription(): string
{
return 'Request body, same fields as the REST API: integration_token_uuid, settings (set); key (add_reference). Settings: Doppler service account project, config; Infisical project_id, environment, secret_path; Vault mount, path.';
}
}
+3
View File
@@ -135,6 +135,9 @@ abstract class RestActionTool extends Tool
if (isset($body['errors']) && is_array($body['errors'])) {
$message .= ' '.json_encode($body['errors']);
}
if (isset($body['conflict']) && is_array($body['conflict'])) {
$message .= ' '.json_encode(['conflict' => $body['conflict']]);
}
return $this->mcpError($request, $message, $context);
}
+16
View File
@@ -61,6 +61,22 @@ class IntegrationToken extends BaseModel
return $this->hasMany(ManagedDnsRecord::class);
}
/**
* Why the token cannot be deleted yet, or null when it can.
*/
public function deletionBlocker(): ?string
{
if ($this->secretManagerLinks()->exists()) {
return 'This token is used by one or more resources as a secret manager source. Remove those links first.';
}
if ($this->managedDnsRecords()->exists()) {
return 'This token manages DNS records. Remove those domains or records first.';
}
return null;
}
public function isSecretManager(): bool
{
return in_array($this->provider, self::SECRET_MANAGER_PROVIDERS, true);
+5 -1
View File
@@ -498,7 +498,11 @@ class CloudflareDnsProvider
{
$resourceType = $resource ? str(class_basename($resource))->snake()->value() : 'dns_record';
$source = auth()->check() ? 'ui' : 'system';
$source = match (true) {
auth()->user()?->currentAccessToken() !== null => 'api',
auth()->check() => 'ui',
default => 'system',
};
auditLog("{$source}.dns_record.{$action}", [
'team_id' => $zone->integrationToken->team_id,
'resource' => $resourceType,
+10 -5
View File
@@ -400,10 +400,11 @@ class ManagedDnsRecordCleanup
private function worseResult(?ManagedDnsDeletionResult $current, ?ManagedDnsDeletionResult $next): ?ManagedDnsDeletionResult
{
$rank = fn (?ManagedDnsDeletionResult $result): int => match ($result) {
ManagedDnsDeletionResult::Failed => 3,
ManagedDnsDeletionResult::Busy => 2,
ManagedDnsDeletionResult::ChangedExternally => 1,
default => 0,
ManagedDnsDeletionResult::Failed => 4,
ManagedDnsDeletionResult::Busy => 3,
ManagedDnsDeletionResult::ChangedExternally => 2,
null => 0,
default => 1,
};
return $rank($next) > $rank($current) ? $next : $current;
@@ -473,7 +474,11 @@ class ManagedDnsRecordCleanup
private function auditSkipped(ManagedDnsRecord $record, string $reason, string $level = 'warning'): void
{
$source = auth()->check() ? 'ui' : 'system';
$source = match (true) {
auth()->user()?->currentAccessToken() !== null => 'api',
auth()->check() => 'ui',
default => 'system',
};
auditLog("{$source}.dns_record.delete_skipped", [
'team_id' => $record->team_id,
'hostname' => $record->name,
+258
View File
@@ -0,0 +1,258 @@
<?php
namespace App\Services\Dns;
use App\Enums\ManagedDnsDeletionResult;
use App\Exceptions\DnsRecordConflictException;
use App\Jobs\ConfigureDnsRecordJob;
use App\Models\Application;
use App\Models\DnsProviderZone;
use App\Models\ManagedDnsRecord;
use App\Models\Service;
use App\Support\DnsRecordHints;
use Illuminate\Contracts\Cache\LockTimeoutException;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Validation\ValidationException;
use RuntimeException;
/**
* Managed DNS records for the saved domains of an application or a service (the DNS API).
* A service domain belongs to one of its service applications; that application references the record.
* Only zones of the resource's team are used, and records only point to the public addresses of the resource's server.
*/
class ResourceDnsRecords
{
public function __construct(
private CloudflareDnsProvider $provider,
private ManagedDnsRecordCleanup $cleanup,
) {}
/**
* Hostnames of the resource with the applications that use them (the application, or service applications).
*
* @return array<string, array<int, Model>>
*/
public function hostnameUsers(Application|Service $resource): array
{
$users = [];
foreach ($this->domainModels($resource) as $model) {
foreach ($this->cleanup->hostnamesOf($model) as $hostname) {
$users[$hostname][] = $model;
}
}
ksort($users);
return $users;
}
public function addresses(Application|Service $resource): ServerDnsAddresses
{
return ServerDnsAddresses::forServer($resource instanceof Service ? $resource->server : $resource->destination?->server);
}
/**
* DNS providers and managed records for every hostname of the resource. Reads only the database.
*
* @return array{default_address: ?string, public_addresses: array<int, string>, hostnames: array<int, array<string, mixed>>}
*/
public function status(Application|Service $resource): array
{
$teamId = $this->teamId($resource);
$hostnames = array_keys($this->hostnameUsers($resource));
$records = ManagedDnsRecord::query()->with('integrationToken')
->where('team_id', $teamId)->whereIn('name', $hostnames)->get()->groupBy('name');
$addresses = $this->addresses($resource);
return [
'default_address' => $addresses->recordContent(),
'public_addresses' => $addresses->publicAddresses(),
'hostnames' => collect($hostnames)->map(fn (string $hostname): array => [
'hostname' => $hostname,
'providers' => $this->provider->findZones($teamId, $hostname)->map(fn (DnsProviderZone $zone): array => [
'integration_token_uuid' => $zone->integrationToken->uuid,
'integration_token_name' => $zone->integrationToken->name,
'zone' => $zone->name,
'automatic_dns' => $zone->integrationToken->automaticDnsEnabled(),
'proxied_dns' => $zone->integrationToken->proxiedDnsEnabled(),
])->values()->all(),
'records' => $records->get($hostname, collect())->map(fn (ManagedDnsRecord $record): array => $this->recordData($record))->values()->all(),
])->all(),
];
}
/**
* @throws ValidationException when the hostname, provider, or content is not valid for the resource
* @throws DnsRecordConflictException when another record already uses the hostname
* @throws LockTimeoutException when another DNS change for the hostname is in progress
* @throws RuntimeException when Cloudflare rejects the change
*/
public function create(Application|Service $resource, string $hostname, ?string $integrationTokenUuid, ?string $content): ManagedDnsRecord
{
[$hostname, $users, $zone, $content] = $this->prepare($resource, $hostname, $integrationTokenUuid, $content);
return $this->provider->createRecord($zone, $hostname, $content, $users[0], array_slice($users, 1));
}
/**
* Points the conflicting record from a create response at the server. The record must still have the ID and
* the content that the conflict reported.
*
* @throws ValidationException|LockTimeoutException|RuntimeException
*/
public function replace(
Application|Service $resource,
string $hostname,
?string $integrationTokenUuid,
string $recordId,
string $currentContent,
?string $content,
): ManagedDnsRecord {
[$hostname, $users, $zone, $content] = $this->prepare($resource, $hostname, $integrationTokenUuid, $content);
return $this->provider->replaceRecord($zone, $recordId, $hostname, $content, $users[0], $currentContent, array_slice($users, 1));
}
/**
* Releases the resource's references to the managed record of a hostname that the resource no longer uses.
* The provider record is deleted only when $deleteRecord is set, Coolify created it, and nothing else uses the hostname.
* Null means the reference was released and the provider record was kept.
*
* @throws ValidationException when the resource still uses the hostname
* @throws ModelNotFoundException when no managed record of the hostname references the resource
*/
public function release(Application|Service $resource, string $hostname, bool $deleteRecord): ?ManagedDnsDeletionResult
{
$hostname = $this->normalizeHostname($hostname);
if (isset($this->hostnameUsers($resource)[$hostname])) {
throw ValidationException::withMessages([
'hostname' => "The resource still uses {$hostname}. Remove the domain first; its DNS record is then released.",
]);
}
$teamId = $this->teamId($resource);
$models = collect($this->domainModels($resource))->filter(fn (Model $model): bool => ManagedDnsRecord::query()
->where('team_id', $teamId)->where('name', $hostname)
->whereHas('references', fn (Builder $query) => $query
->where('resource_type', $model->getMorphClass())->where('resource_id', $model->getKey()))
->exists());
if ($models->isEmpty()) {
throw (new ModelNotFoundException)->setModel(ManagedDnsRecord::class);
}
$results = $models->map(fn (Model $model) => $this->cleanup->releaseHostname($model, $hostname, $teamId, $deleteRecord))->filter();
return $results->first(fn (ManagedDnsDeletionResult $result): bool => $result->shouldRetry())
?? $results->first(fn (ManagedDnsDeletionResult $result): bool => $result === ManagedDnsDeletionResult::ChangedExternally)
?? $results->first();
}
/**
* Queues a DNS record for each hostname added since $previousHostnames, like the Domains page does, when exactly
* one zone of the team manages the hostname and its token has automatic DNS on.
*
* @param array<int, string> $previousHostnames
* @return array<int, string> the hostnames that were queued
*/
public function queueAutomaticRecords(Application|Service $resource, array $previousHostnames): array
{
$addedUsers = array_diff_key($this->hostnameUsers($resource), array_flip($previousHostnames));
if ($addedUsers === []) {
return [];
}
$teamId = $this->teamId($resource);
$automaticZones = collect($addedUsers)
->map(fn (array $users, string $hostname) => $this->provider->findZones($teamId, $hostname))
->filter(fn ($zones): bool => $zones->count() === 1 && $zones->first()->integrationToken->automaticDnsEnabled())
->map(fn ($zones): DnsProviderZone => $zones->first());
$content = $automaticZones->isEmpty() ? null : $this->addresses($resource)->recordContent();
if ($content === null) {
return [];
}
foreach ($automaticZones as $hostname => $zone) {
$user = $addedUsers[$hostname][0];
ConfigureDnsRecordJob::dispatch($teamId, $zone->id, $user->getMorphClass(), $user->getKey(), $hostname, $content)->afterCommit();
}
return $automaticZones->keys()->all();
}
/**
* @return array{uuid: string, hostname: string, type: string, address: string, owned: bool, integration_token_uuid: ?string}
*/
public function recordData(ManagedDnsRecord $record): array
{
return [
'uuid' => $record->uuid,
'hostname' => $record->name,
'type' => $record->type,
'address' => $record->content,
'owned' => $record->owned,
'integration_token_uuid' => $record->integrationToken?->uuid,
];
}
/**
* @return array{0: string, 1: array<int, Model>, 2: DnsProviderZone, 3: string}
*
* @throws ValidationException
*/
private function prepare(Application|Service $resource, string $hostname, ?string $integrationTokenUuid, ?string $content): array
{
$hostname = $this->normalizeHostname($hostname);
$users = $this->hostnameUsers($resource)[$hostname] ?? null;
if ($users === null) {
throw ValidationException::withMessages(['hostname' => "{$hostname} is not a domain of this resource."]);
}
$addresses = $this->addresses($resource);
if ($addresses->publicAddresses() === []) {
throw ValidationException::withMessages(['address' => DnsRecordHints::NO_PUBLIC_ADDRESS_MESSAGE]);
}
$recordContent = $addresses->recordContent($content);
if ($recordContent === null) {
throw ValidationException::withMessages([
'address' => 'The address must be a public address of the server: '.implode(', ', $addresses->publicAddresses()).'.',
]);
}
$zones = $this->provider->findZones($this->teamId($resource), $hostname);
if ($integrationTokenUuid !== null) {
$zones = $zones->filter(fn (DnsProviderZone $zone): bool => $zone->integrationToken->uuid === $integrationTokenUuid)->values();
}
if ($zones->isEmpty()) {
throw ValidationException::withMessages(['integration_token_uuid' => "No connected DNS provider of the team manages {$hostname}."]);
}
if ($zones->count() > 1) {
throw ValidationException::withMessages([
'integration_token_uuid' => "Several DNS providers manage {$hostname}. Set integration_token_uuid to one of: "
.$zones->map(fn (DnsProviderZone $zone): string => $zone->integrationToken->uuid)->implode(', ').'.',
]);
}
return [$hostname, $users, $zones->first(), $recordContent];
}
/**
* Models that hold the domains: the application, or the applications of a service.
*
* @return array<int, Model>
*/
private function domainModels(Application|Service $resource): array
{
return $resource instanceof Service ? $resource->applications()->get()->all() : [$resource];
}
private function teamId(Application|Service $resource): int
{
return $resource->team()?->id ?? throw new RuntimeException('The resource has no team.');
}
private function normalizeHostname(string $hostname): string
{
return strtolower(rtrim(trim($hostname), '.'));
}
}
+102
View File
@@ -0,0 +1,102 @@
<?php
namespace App\Services\Dns;
use App\Models\Server;
use App\Support\DnsRecordHints;
/**
* Addresses of a server that DNS records of its resources point to. The Domains pages and the DNS API use the same rules.
*/
final class ServerDnsAddresses
{
/**
* @param string|null $address The resolved IP, or the configured value when it does not resolve.
* @param string|null $configured The address as configured (can be a hostname).
* @param bool $isInstanceServer The server that runs Coolify: the instance public IPv4/IPv6 settings also apply.
*/
public function __construct(
public readonly ?string $address,
public readonly ?string $configured,
public readonly bool $isInstanceServer,
) {}
public static function forServer(?Server $server): self
{
if ($server === null) {
return new self(null, null, false);
}
$settings = instanceSettings();
$configured = $server->id === 0
? (data_get($settings, 'public_ipv4') ?: data_get($settings, 'public_ipv6') ?: $server->ip)
: $server->ip;
$resolved = resolveServerIpAddress(is_string($configured) ? $configured : null);
return new self($resolved['ip'] ?? $resolved['configured'], $resolved['configured'], $server->id === 0);
}
/**
* @return array{0: ?string, 1: ?string} IPv4 and IPv6 address
*/
public function addresses(): array
{
$ipv4 = null;
$ipv6 = null;
if (filled($this->address)) {
if (filter_var($this->address, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) !== false) {
$ipv4 = $this->address;
} elseif (filter_var($this->address, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false) {
$ipv6 = $this->address;
}
}
if ($this->isInstanceServer) {
try {
$settings = instanceSettings();
$publicV4 = data_get($settings, 'public_ipv4');
$publicV6 = data_get($settings, 'public_ipv6');
if ($ipv4 === null && is_string($publicV4) && filter_var($publicV4, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
$ipv4 = $publicV4;
}
if ($ipv6 === null && is_string($publicV6) && filter_var($publicV6, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) {
$ipv6 = $publicV6;
}
} catch (\Throwable) {
//
}
}
return [$ipv4, $ipv6];
}
/**
* Addresses that a DNS provider may publish (IPv4 first). Private, reserved, CGNAT, and link-local addresses are
* excluded: a public DNS record must not expose them, and the record would not be reachable anyway.
*
* @return array<int, string>
*/
public function publicAddresses(): array
{
return array_values(array_filter(
$this->addresses(),
fn (?string $address): bool => is_string($address) && DnsRecordHints::isPublicAddress($address),
));
}
/**
* Content of a DNS record: one of the public addresses, the first one when none is requested.
* Any other address is rejected (null).
*/
public function recordContent(?string $requested = null): ?string
{
$addresses = $this->publicAddresses();
if ($requested === null) {
return $addresses[0] ?? null;
}
return collect($addresses)->first(fn (string $address): bool => DnsRecordHints::sameAddress($address, $requested));
}
}
@@ -0,0 +1,169 @@
<?php
namespace App\Services\SecretManagers;
use App\Models\IntegrationToken;
use App\Models\SecretManagerLink;
use App\Support\ValidationPatterns;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Validation\ValidationException;
/**
* The secret manager source of an application, service, or database, and its {{vault.KEY}} reference variables.
* Only tokens of the resource's team can be linked. Secret values are read into memory only to list or import
* key names; they are never stored or returned. The resource page and the API use this service.
*/
class ResourceSecretManager
{
/**
* Secret manager tokens of the resource's team that have the secrets capability.
*
* @return Collection<int, IntegrationToken>
*/
public function availableTokens(Model $resource): Collection
{
return $this->teamTokens($resource)->get()
->filter(fn (IntegrationToken $token): bool => $token->hasCapability('secrets'))
->values();
}
public function findToken(Model $resource, string $uuid): ?IntegrationToken
{
$token = $this->teamTokens($resource)->where('uuid', $uuid)->first();
return $token?->hasCapability('secrets') ? $token : null;
}
/**
* Rules for the provider settings of a link to the token.
*
* @return array<string, array<int, string>>
*/
public function settingsRules(IntegrationToken $token): array
{
return match ($token->provider) {
'doppler' => $token->dopplerTokenType() === 'service_account'
? [
'settings.project' => ['required', 'string'],
'settings.config' => ['required', 'string'],
]
: [],
'infisical' => [
'settings.project_id' => ['required', 'string'],
'settings.environment' => ['required', 'string'],
'settings.secret_path' => ['nullable', 'string'],
],
'vault' => [
'settings.mount' => ['required', 'string'],
'settings.path' => ['required', 'string'],
],
default => [],
};
}
/**
* Links the token as the source of the resource, or changes the token or settings of the existing link.
*
* @param array<string, mixed> $settings
*/
public function link(Model $resource, IntegrationToken $token, array $settings, string $auditSource): SecretManagerLink
{
$settings = array_filter($settings, fn ($value) => filled($value));
$link = $resource->secretManagerLink()->updateOrCreate([], [
'integration_token_id' => $token->id,
'settings' => $settings ?: null,
]);
$this->audit($auditSource, $resource, 'source_updated', [
'integration_token_uuid' => $token->uuid,
'provider' => $token->provider,
]);
return $link;
}
/**
* Removes the source. Existing {{vault.*}} variables stay and fail the next deployment until they are removed.
*/
public function unlink(Model $resource, string $auditSource): void
{
$token = $resource->secretManagerLink()->with('integrationToken')->first()?->integrationToken;
$resource->secretManagerLink()->delete();
$this->audit($auditSource, $resource, 'source_removed', [
'integration_token_uuid' => $token?->uuid,
'provider' => $token?->provider,
]);
}
/**
* Key names of the remote secrets, sorted. The values are discarded.
*
* @return list<string>
*/
public function keyNames(Model $resource, SecretManagerLink $link, string $auditSource): array
{
$keys = array_map('strval', array_keys($link->fetchSecrets()));
sort($keys);
$this->audit($auditSource, $resource, 'keys_viewed', ['key_count' => count($keys)]);
return $keys;
}
/**
* Adds a {{vault.KEY}} variable for one remote key.
*
* @throws ValidationException when the key is not a valid variable name or a variable with the key exists
*/
public function addReference(Model $resource, string $key, string $auditSource): void
{
if (! ValidationPatterns::isValidEnvironmentVariableKey($key)) {
throw ValidationException::withMessages(['key' => "{$key} is not a valid variable name."]);
}
if ($resource->environment_variables()->where('key', $key)->exists()) {
throw ValidationException::withMessages(['key' => "A variable with the key {$key} already exists."]);
}
$resource->environment_variables()->create(['key' => $key, 'value' => '{{vault.'.$key.'}}']);
$this->audit($auditSource, $resource, 'reference_created', ['secret_key' => $key]);
}
/**
* Adds a {{vault.KEY}} variable for every remote key that has no variable yet.
*
* @return array{imported: list<string>, skipped: list<string>}
*/
public function importAll(Model $resource, SecretManagerLink $link, string $auditSource): array
{
$result = $link->importMissingReferences();
$this->audit($auditSource, $resource, 'references_imported', [
'key_count' => count($result['imported']),
'secret_keys' => $result['imported'],
'skipped_key_count' => count($result['skipped']),
]);
return $result;
}
/** @param array<string, mixed> $context */
public function audit(string $source, Model $resource, string $action, array $context = []): void
{
$resourceType = str(class_basename($resource))->snake()->value();
auditLog("{$source}.{$resourceType}.secret_manager.{$action}", array_merge([
'team_id' => $resource->team()?->id,
"{$resourceType}_uuid" => $resource->uuid,
"{$resourceType}_name" => $resource->name,
], $context));
}
/**
* Secret manager tokens of the resource's team. The session team can differ from it.
*/
private function teamTokens(Model $resource): Builder
{
return IntegrationToken::query()
->where('team_id', $resource->team()?->id)
->whereIn('provider', IntegrationToken::SECRET_MANAGER_PROVIDERS);
}
}
+1717 -69
View File
File diff suppressed because it is too large Load Diff
+1094 -47
View File
File diff suppressed because it is too large Load Diff
+27 -2
View File
@@ -2,7 +2,6 @@
use App\Http\Controllers\Api\ApplicationPreviewsController;
use App\Http\Controllers\Api\ApplicationsController;
use App\Http\Controllers\Api\ApplicationSecretManagerController;
use App\Http\Controllers\Api\AuditEventsController;
use App\Http\Controllers\Api\CloudInitScriptsController;
use App\Http\Controllers\Api\CloudProviderTokensController;
@@ -10,6 +9,7 @@ use App\Http\Controllers\Api\DatabasesController;
use App\Http\Controllers\Api\DeployController;
use App\Http\Controllers\Api\DestinationsController;
use App\Http\Controllers\Api\DigitalOceanController;
use App\Http\Controllers\Api\DnsController;
use App\Http\Controllers\Api\GithubController;
use App\Http\Controllers\Api\GitlabController;
use App\Http\Controllers\Api\HetznerController;
@@ -22,6 +22,7 @@ use App\Http\Controllers\Api\ProjectController;
use App\Http\Controllers\Api\ResourcesController;
use App\Http\Controllers\Api\S3StoragesController;
use App\Http\Controllers\Api\ScheduledTasksController;
use App\Http\Controllers\Api\SecretManagerController;
use App\Http\Controllers\Api\SecurityController;
use App\Http\Controllers\Api\SentinelController;
use App\Http\Controllers\Api\ServerCloudflareTunnelController;
@@ -126,7 +127,13 @@ Route::group([
Route::get('/security/keys/{uuid}', [SecurityController::class, 'key_by_uuid'])->middleware(['api.ability:read']);
Route::patch('/security/keys/{uuid}', [SecurityController::class, 'update_key'])->middleware(['api.ability:write']);
Route::delete('/security/keys/{uuid}', [SecurityController::class, 'delete_key'])->middleware(['api.ability:delete']);
Route::get('/security/integration-tokens', [IntegrationTokensController::class, 'index'])->middleware(['api.ability:read']);
Route::post('/security/integration-tokens', [IntegrationTokensController::class, 'store'])->middleware(['api.ability:write']);
Route::get('/security/integration-tokens/{uuid}', [IntegrationTokensController::class, 'show'])->middleware(['api.ability:read']);
Route::patch('/security/integration-tokens/{uuid}', [IntegrationTokensController::class, 'update'])->middleware(['api.ability:write']);
Route::delete('/security/integration-tokens/{uuid}', [IntegrationTokensController::class, 'destroy'])->middleware(['api.ability:delete']);
Route::post('/security/integration-tokens/{uuid}/sync-zones', [IntegrationTokensController::class, 'syncZones'])->middleware(['api.ability:write']);
Route::get('/dns/zones', [DnsController::class, 'zones'])->middleware(['api.ability:read']);
Route::get('/cloud-tokens', [CloudProviderTokensController::class, 'index'])->middleware(['api.ability:read']);
Route::post('/cloud-tokens', [CloudProviderTokensController::class, 'store'])->middleware(['api.ability:write']);
@@ -255,7 +262,20 @@ Route::group([
Route::get('/applications/{uuid}', [ApplicationsController::class, 'application_by_uuid'])->middleware(['api.ability:read']);
Route::patch('/applications/{uuid}', [ApplicationsController::class, 'update_by_uuid'])->middleware(['api.ability:write']);
Route::delete('/applications/{uuid}', [ApplicationsController::class, 'delete_by_uuid'])->middleware(['api.ability:delete']);
Route::patch('/applications/{uuid}/secret-manager', [ApplicationSecretManagerController::class, 'update'])->middleware(['api.ability:write']);
Route::whereIn('resource_type', ['applications', 'services', 'databases'])->group(function () {
Route::get('/{resource_type}/{uuid}/secret-manager', [SecretManagerController::class, 'show'])->middleware(['api.ability:read']);
Route::patch('/{resource_type}/{uuid}/secret-manager', [SecretManagerController::class, 'update'])->middleware(['api.ability:write']);
Route::delete('/{resource_type}/{uuid}/secret-manager', [SecretManagerController::class, 'destroy'])->middleware(['api.ability:delete']);
Route::get('/{resource_type}/{uuid}/secret-manager/keys', [SecretManagerController::class, 'keys'])->middleware(['api.ability:write']);
Route::post('/{resource_type}/{uuid}/secret-manager/references', [SecretManagerController::class, 'addReference'])->middleware(['api.ability:write']);
Route::post('/{resource_type}/{uuid}/secret-manager/import', [SecretManagerController::class, 'import'])->middleware(['api.ability:write']);
});
Route::get('/applications/{uuid}/dns', [DnsController::class, 'applicationStatus'])->middleware(['api.ability:read']);
Route::post('/applications/{uuid}/dns', [DnsController::class, 'createApplicationRecord'])->middleware(['api.ability:write']);
Route::post('/applications/{uuid}/dns/replace', [DnsController::class, 'replaceApplicationRecord'])->middleware(['api.ability:write']);
Route::delete('/applications/{uuid}/dns/{hostname}', [DnsController::class, 'releaseApplicationRecord'])->middleware(['api.ability:delete']);
Route::get('/applications/{uuid}/envs', [ApplicationsController::class, 'envs'])->middleware(['api.ability:read']);
Route::post('/applications/{uuid}/envs', [ApplicationsController::class, 'create_env'])->middleware(['api.ability:write']);
@@ -395,6 +415,11 @@ Route::group([
->defaults('resource_type', 'service')
->middleware(['api.ability:delete']);
Route::get('/services/{uuid}/dns', [DnsController::class, 'serviceStatus'])->middleware(['api.ability:read']);
Route::post('/services/{uuid}/dns', [DnsController::class, 'createServiceRecord'])->middleware(['api.ability:write']);
Route::post('/services/{uuid}/dns/replace', [DnsController::class, 'replaceServiceRecord'])->middleware(['api.ability:write']);
Route::delete('/services/{uuid}/dns/{hostname}', [DnsController::class, 'releaseServiceRecord'])->middleware(['api.ability:delete']);
Route::get('/services/{uuid}/envs', [ServicesController::class, 'envs'])->middleware(['api.ability:read']);
Route::post('/services/{uuid}/envs', [ServicesController::class, 'create_env'])->middleware(['api.ability:write']);
Route::patch('/services/{uuid}/envs/bulk', [ServicesController::class, 'create_bulk_envs'])->middleware(['api.ability:write']);
+505
View File
@@ -0,0 +1,505 @@
<?php
use App\Jobs\CheckDomainDnsJob;
use App\Jobs\ConfigureDnsRecordJob;
use App\Models\Application;
use App\Models\DnsProviderZone;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\IntegrationToken;
use App\Models\ManagedDnsRecord;
use App\Models\Project;
use App\Models\Server;
use App\Models\Service;
use App\Models\ServiceApplication;
use App\Models\StandaloneDocker;
use App\Models\Team;
use App\Models\User;
use App\Services\Dns\CloudflareDnsProvider;
use App\Services\Dns\ManagedDnsHostnameLock;
use App\Support\DnsRecordHints;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\Request as HttpRequest;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Str;
uses(RefreshDatabase::class);
beforeEach(function () {
Server::flushIdentityMap();
Queue::fake([CheckDomainDnsJob::class]);
config()->set('app.maintenance.store', 'array');
config()->set('app.id', 'test-instance');
InstanceSettings::unguarded(fn () => InstanceSettings::updateOrCreate(['id' => 0], ['id' => 0, 'is_api_enabled' => true]));
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
$this->member = User::factory()->create();
$this->team->members()->attach($this->member->id, ['role' => 'member']);
session(['currentTeam' => $this->team]);
$this->server = Server::factory()->create(['team_id' => $this->team->id, 'ip' => '203.0.113.10']);
$this->destination = StandaloneDocker::query()->where('server_id', $this->server->id)->firstOrFail();
$this->environment = Environment::factory()->create(['project_id' => Project::factory()->create(['team_id' => $this->team->id])->id]);
$this->application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
'fqdn' => 'https://app.example.com',
]);
$this->token = IntegrationToken::factory()->for($this->team)->create(['provider' => 'cloudflare', 'token' => 'cf-secret-value', 'capabilities' => ['dns']]);
$this->zone = DnsProviderZone::factory()->for($this->token)->create(['provider_zone_id' => 'zone-1', 'name' => 'example.com']);
});
afterEach(function () {
Server::flushIdentityMap();
});
function dnsApiToken(User $user, Team $team, array $abilities = ['*']): string
{
$plainTextToken = Str::random(40);
$token = $user->tokens()->create([
'name' => 'dns-api-'.Str::random(6),
'token' => hash('sha256', $plainTextToken),
'abilities' => $abilities,
'team_id' => $team->id,
]);
return $token->getKey().'|'.$plainTextToken;
}
/**
* Fakes the Cloudflare DNS records API of zone-1 with the given existing records.
*
* @param array<int, array<string, mixed>> $records
*/
function dnsApiFakeCloudflare(array $records = []): void
{
Http::fake([
'https://api.cloudflare.com/client/v4/zones/zone-1/dns_records?*' => Http::response(['success' => true, 'result' => $records]),
'https://api.cloudflare.com/client/v4/zones/zone-1/dns_records/*' => Http::response(['success' => true, 'result' => ['id' => 'record-old']]),
'https://api.cloudflare.com/client/v4/zones/zone-1/dns_records' => Http::response(['success' => true, 'result' => ['id' => 'record-new']]),
]);
}
function dnsApiService(object $test, string $fqdn): ServiceApplication
{
$service = Service::factory()->create([
'server_id' => $test->server->id,
'destination_id' => $test->destination->id,
'destination_type' => $test->destination->getMorphClass(),
'environment_id' => $test->environment->id,
'docker_compose_raw' => "services:\n web:\n image: nginx:alpine\n",
]);
return ServiceApplication::create([
'uuid' => (string) Str::uuid(), 'service_id' => $service->id, 'name' => 'web', 'human_name' => 'Web',
'image' => 'nginx:alpine', 'fqdn' => $fqdn,
]);
}
describe('integration tokens', function () {
test('an owner creates a cloudflare token with its zones and options', function () {
Http::fake([
'https://api.cloudflare.com/client/v4/user/tokens/verify' => Http::response(['result' => ['status' => 'active']]),
'https://api.cloudflare.com/client/v4/zones/zone-9/dns_records*' => Http::response(['success' => true, 'result' => []]),
'https://api.cloudflare.com/client/v4/accounts/account-1/cfd_tunnel*' => Http::response(['success' => true, 'result' => []]),
'https://api.cloudflare.com/client/v4/zones*' => Http::response([
'success' => true,
'result' => [['id' => 'zone-9', 'name' => 'example.org', 'account' => ['id' => 'account-1', 'name' => 'Main']]],
'result_info' => ['total_pages' => 1],
]),
]);
$uuid = $this->withToken(dnsApiToken($this->user, $this->team))->postJson('/api/v1/security/integration-tokens', [
'provider' => 'cloudflare',
'name' => 'Cloudflare',
'token' => 'new-cf-secret',
'capabilities' => ['dns', 'tunnel'],
'automatic_dns' => false,
'proxied_dns' => true,
])->assertCreated()->json('uuid');
$token = IntegrationToken::query()->where('uuid', $uuid)->firstOrFail();
expect($token->team_id)->toBe($this->team->id)
->and($token->capabilities)->toBe(['dns', 'tunnel'])
->and($token->automaticDnsEnabled())->toBeFalse()
->and($token->proxiedDnsEnabled())->toBeTrue()
->and($token->dnsZones()->pluck('name')->all())->toBe(['example.org']);
});
test('a cloudflare token is not stored when cloudflare rejects it', function () {
Http::fake(['https://api.cloudflare.com/client/v4/user/tokens/verify' => Http::response([], 401)]);
$this->withToken(dnsApiToken($this->user, $this->team))->postJson('/api/v1/security/integration-tokens', [
'provider' => 'cloudflare', 'name' => 'Rejected', 'token' => 'bad',
])->assertStatus(400)->assertJson(['message' => 'The token could not access the selected Cloudflare capabilities. Check its permissions and zone resources.']);
expect(IntegrationToken::query()->where('name', 'Rejected')->exists())->toBeFalse();
});
test('a tunnel capability without dns is rejected before cloudflare is called', function () {
Http::fake();
$this->withToken(dnsApiToken($this->user, $this->team))->postJson('/api/v1/security/integration-tokens', [
'provider' => 'cloudflare', 'name' => 'Tunnel', 'token' => 'x', 'capabilities' => ['tunnel'],
])->assertUnprocessable()->assertJsonPath('errors.capabilities.0', 'Cloudflare Tunnel also needs the DNS capability.');
Http::assertNothingSent();
});
test('list and get return the token settings but never the secret', function () {
$bearer = dnsApiToken($this->user, $this->team);
$list = $this->withToken($bearer)->getJson('/api/v1/security/integration-tokens')->assertOk();
$one = $this->withToken($bearer)->getJson("/api/v1/security/integration-tokens/{$this->token->uuid}")->assertOk();
expect($list->getContent())->not->toContain('cf-secret-value')
->and($one->getContent())->not->toContain('cf-secret-value')
->and($one->json())->toMatchArray([
'uuid' => $this->token->uuid, 'provider' => 'cloudflare', 'capabilities' => ['dns'],
'automatic_dns' => true, 'proxied_dns' => false, 'dns_zones_count' => 1,
]);
});
test('a member cannot list or read integration tokens', function () {
$bearer = dnsApiToken($this->member, $this->team, ['read']);
$this->withToken($bearer)->getJson('/api/v1/security/integration-tokens')->assertForbidden();
$this->withToken($bearer)->getJson("/api/v1/security/integration-tokens/{$this->token->uuid}")->assertForbidden();
$this->withToken($bearer)->getJson('/api/v1/dns/zones')->assertForbidden();
});
test('tokens of another team are not found', function (string $method, string $suffix) {
$foreign = IntegrationToken::factory()->for(Team::factory()->create())->create(['provider' => 'cloudflare']);
$this->withToken(dnsApiToken($this->user, $this->team))
->json($method, "/api/v1/security/integration-tokens/{$foreign->uuid}{$suffix}")
->assertNotFound();
expect($foreign->fresh())->not->toBeNull();
})->with([
'get' => ['GET', ''],
'delete' => ['DELETE', ''],
'update' => ['PATCH', ''],
'sync zones' => ['POST', '/sync-zones'],
]);
test('a token that manages dns records cannot be deleted', function () {
ManagedDnsRecord::factory()->create(['team_id' => $this->team->id, 'integration_token_id' => $this->token->id, 'dns_provider_zone_id' => $this->zone->id]);
$this->withToken(dnsApiToken($this->user, $this->team))
->deleteJson("/api/v1/security/integration-tokens/{$this->token->uuid}")
->assertConflict()
->assertJson(['message' => 'This token manages DNS records. Remove those domains or records first.']);
expect($this->token->fresh())->not->toBeNull();
});
test('an unused token is deleted', function () {
$this->withToken(dnsApiToken($this->user, $this->team))
->deleteJson("/api/v1/security/integration-tokens/{$this->token->uuid}")
->assertOk();
expect($this->token->fresh())->toBeNull();
});
test('a cloudflare token is renamed and its automatic dns turned off after cloudflare accepts it', function () {
Http::fake([
'https://api.cloudflare.com/client/v4/user/tokens/verify' => Http::response(['result' => ['status' => 'active']]),
'https://api.cloudflare.com/client/v4/zones/zone-1/dns_records*' => Http::response(['success' => true, 'result' => []]),
'https://api.cloudflare.com/client/v4/zones*' => Http::response(['success' => true, 'result' => [['id' => 'zone-1', 'name' => 'example.com']]]),
]);
$response = $this->withToken(dnsApiToken($this->user, $this->team))
->patchJson("/api/v1/security/integration-tokens/{$this->token->uuid}", ['name' => 'Renamed', 'automatic_dns' => false])
->assertOk()
->assertJson(['name' => 'Renamed', 'automatic_dns' => false, 'capabilities' => ['dns']]);
expect($response->getContent())->not->toContain('cf-secret-value')
->and($this->token->fresh()->automaticDnsEnabled())->toBeFalse()
->and($this->token->fresh()->token)->toBe('cf-secret-value');
Http::assertSent(fn (HttpRequest $request): bool => $request->url() === 'https://api.cloudflare.com/client/v4/user/tokens/verify'
&& $request->header('Authorization') === ['Bearer cf-secret-value']);
});
test('a vault base url cannot change without the token again', function () {
Http::fake();
$vault = IntegrationToken::factory()->for($this->team)->create([
'provider' => 'vault', 'token' => 'vault-secret', 'capabilities' => ['secrets'], 'metadata' => ['base_url' => 'https://vault.example.com'],
]);
$this->withToken(dnsApiToken($this->user, $this->team))
->patchJson("/api/v1/security/integration-tokens/{$vault->uuid}", ['metadata' => ['base_url' => 'https://attacker.example.com']])
->assertUnprocessable()
->assertJsonPath('errors.token.0', 'Enter the token again when you change the base URL or the namespace.');
expect($vault->fresh()->metadata['base_url'])->toBe('https://vault.example.com');
Http::assertNothingSent();
});
test('a member cannot update a token', function () {
$this->withToken(dnsApiToken($this->member, $this->team, ['read']))
->patchJson("/api/v1/security/integration-tokens/{$this->token->uuid}", ['name' => 'Renamed'])
->assertForbidden();
expect($this->token->fresh()->name)->not->toBe('Renamed');
});
test('zones of a cloudflare token are synchronized again', function () {
Http::fake(['https://api.cloudflare.com/client/v4/zones*' => Http::response([
'success' => true,
'result' => [['id' => 'zone-1', 'name' => 'example.com'], ['id' => 'zone-2', 'name' => 'example.net']],
'result_info' => ['total_pages' => 1],
])]);
$this->withToken(dnsApiToken($this->user, $this->team))
->postJson("/api/v1/security/integration-tokens/{$this->token->uuid}/sync-zones")
->assertOk()
->assertJson(['zones_count' => 2]);
expect($this->token->dnsZones()->orderBy('name')->pluck('name')->all())->toBe(['example.com', 'example.net']);
});
test('a cloudflare connection failure returns 502', function (string $method, string $path, array $body) {
Http::fake(fn () => throw new ConnectionException('Connection refused'));
$this->withToken(dnsApiToken($this->user, $this->team))
->json($method, str_replace('{uuid}', $this->token->uuid, $path), $body)
->assertStatus(502)
->assertJson(['message' => 'Could not connect to the Cloudflare API. Check the network connection of Coolify and try again.']);
expect(IntegrationToken::query()->where('name', 'Offline')->exists())->toBeFalse();
})->with([
'create' => ['POST', '/api/v1/security/integration-tokens', ['provider' => 'cloudflare', 'name' => 'Offline', 'token' => 'x']],
'update' => ['PATCH', '/api/v1/security/integration-tokens/{uuid}', ['name' => 'Offline', 'token' => 'new-secret']],
'sync zones' => ['POST', '/api/v1/security/integration-tokens/{uuid}/sync-zones', []],
]);
});
describe('resource dns', function () {
test('the zones and the dns status of an application are listed', function () {
$bearer = dnsApiToken($this->user, $this->team);
$this->withToken($bearer)->getJson('/api/v1/dns/zones')->assertOk()
->assertExactJson([[
'zone' => 'example.com', 'account_name' => $this->zone->account_name,
'integration_token_uuid' => $this->token->uuid, 'integration_token_name' => $this->token->name,
'managed_records_count' => 0,
]]);
$this->withToken($bearer)->getJson("/api/v1/applications/{$this->application->uuid}/dns")->assertOk()
->assertJson([
'default_address' => '203.0.113.10',
'public_addresses' => ['203.0.113.10'],
'hostnames' => [[
'hostname' => 'app.example.com',
'providers' => [['integration_token_uuid' => $this->token->uuid, 'zone' => 'example.com', 'automatic_dns' => true]],
'records' => [],
]],
]);
});
test('a record is created for a domain of the application', function () {
dnsApiFakeCloudflare();
$this->withToken(dnsApiToken($this->user, $this->team))
->postJson("/api/v1/applications/{$this->application->uuid}/dns", ['hostname' => 'APP.example.com'])
->assertCreated()
->assertJson(['record' => ['hostname' => 'app.example.com', 'type' => 'A', 'address' => '203.0.113.10', 'owned' => true]]);
$record = ManagedDnsRecord::query()->where('name', 'app.example.com')->firstOrFail();
expect($record->references()->pluck('resource_id')->all())->toBe([$this->application->id]);
Http::assertSent(fn (HttpRequest $request): bool => $request->method() === 'POST' && $request['content'] === '203.0.113.10');
$this->assertDatabaseHas('audit_events', ['team_id' => $this->team->id, 'event' => 'api.dns_record.created']);
});
test('a record is created for a domain of a service application', function () {
dnsApiFakeCloudflare();
$serviceApplication = dnsApiService($this, 'https://web.example.com');
$this->withToken(dnsApiToken($this->user, $this->team))
->postJson("/api/v1/services/{$serviceApplication->service->uuid}/dns", ['hostname' => 'web.example.com'])
->assertCreated();
$reference = ManagedDnsRecord::query()->where('name', 'web.example.com')->firstOrFail()->references()->firstOrFail();
expect($reference->resource_type)->toBe($serviceApplication->getMorphClass())
->and($reference->resource_id)->toBe($serviceApplication->id);
});
test('a conflicting record returns 409 and can then be replaced', function () {
dnsApiFakeCloudflare([['id' => 'record-old', 'type' => 'A', 'name' => 'app.example.com', 'content' => '198.51.100.7']]);
$bearer = dnsApiToken($this->user, $this->team);
$conflict = $this->withToken($bearer)
->postJson("/api/v1/applications/{$this->application->uuid}/dns", ['hostname' => 'app.example.com'])
->assertConflict()
->assertJson(['conflict' => ['record_id' => 'record-old', 'current' => '198.51.100.7', 'proposed' => '203.0.113.10']])
->json('conflict');
$this->withToken($bearer)->postJson("/api/v1/applications/{$this->application->uuid}/dns/replace", [
'hostname' => 'app.example.com', 'record_id' => $conflict['record_id'], 'current' => $conflict['current'],
])->assertOk()->assertJson(['record' => ['address' => '203.0.113.10', 'owned' => false]]);
Http::assertSent(fn (HttpRequest $request): bool => $request->method() === 'PATCH'
&& str_ends_with($request->url(), '/dns_records/record-old') && $request['content'] === '203.0.113.10');
});
test('a busy hostname returns 423 without a cloudflare call', function () {
Http::fake();
app()->instance(ManagedDnsHostnameLock::class, new ManagedDnsHostnameLock(waitSeconds: 0));
$lock = Cache::lock(ManagedDnsHostnameLock::key($this->team->id, 'app.example.com'), 60);
$lock->get();
$this->withToken(dnsApiToken($this->user, $this->team))
->postJson("/api/v1/applications/{$this->application->uuid}/dns", ['hostname' => 'app.example.com'])
->assertStatus(423)
->assertJson(['message' => 'Another DNS change for app.example.com is in progress. Try again in a few seconds.']);
$lock->release();
Http::assertNothingSent();
});
test('no record is created when the server has no public address', function () {
Http::fake();
$this->server->update(['ip' => '10.0.0.5']);
$this->withToken(dnsApiToken($this->user, $this->team))
->postJson("/api/v1/applications/{$this->application->uuid}/dns", ['hostname' => 'app.example.com'])
->assertUnprocessable()
->assertJsonPath('errors.address.0', DnsRecordHints::NO_PUBLIC_ADDRESS_MESSAGE);
Http::assertNothingSent();
});
test('a record is only created for a public address of the server and a domain of the resource', function (array $payload, string $field, string $message) {
Http::fake();
$this->withToken(dnsApiToken($this->user, $this->team))
->postJson("/api/v1/applications/{$this->application->uuid}/dns", $payload)
->assertUnprocessable()
->assertJsonPath("errors.{$field}.0", $message);
Http::assertNothingSent();
})->with([
'other hostname' => [['hostname' => 'other.example.com'], 'hostname', 'other.example.com is not a domain of this resource.'],
'other address' => [['hostname' => 'app.example.com', 'address' => '198.51.100.7'], 'address', 'The address must be a public address of the server: 203.0.113.10.'],
'other token' => [['hostname' => 'app.example.com', 'integration_token_uuid' => 'unknown'], 'integration_token_uuid', 'No connected DNS provider of the team manages app.example.com.'],
]);
test('only zones of the resource team are used', function () {
Http::fake();
$this->zone->delete();
$foreignToken = IntegrationToken::factory()->for(Team::factory()->create())->create(['provider' => 'cloudflare']);
DnsProviderZone::factory()->for($foreignToken)->create(['name' => 'example.com']);
$this->withToken(dnsApiToken($this->user, $this->team))
->postJson("/api/v1/applications/{$this->application->uuid}/dns", ['hostname' => 'app.example.com', 'integration_token_uuid' => $foreignToken->uuid])
->assertUnprocessable();
Http::assertNothingSent();
});
test('a member with read access can see the dns status but not change it', function () {
Http::fake();
$bearer = dnsApiToken($this->member, $this->team, ['read']);
$this->withToken($bearer)->getJson("/api/v1/applications/{$this->application->uuid}/dns")->assertOk();
$this->withToken($bearer)
->postJson("/api/v1/applications/{$this->application->uuid}/dns", ['hostname' => 'app.example.com'])
->assertForbidden();
Http::assertNothingSent();
});
test('resources of another team are not found', function (string $method, string $path) {
Http::fake();
$otherTeam = Team::factory()->create();
$otherTeam->members()->attach($this->user->id, ['role' => 'owner']);
$this->withToken(dnsApiToken($this->user, $otherTeam))
->json($method, str_replace('{uuid}', $this->application->uuid, $path), ['hostname' => 'app.example.com'])
->assertNotFound();
Http::assertNothingSent();
})->with([
'status' => ['GET', '/api/v1/applications/{uuid}/dns'],
'create' => ['POST', '/api/v1/applications/{uuid}/dns'],
'release' => ['DELETE', '/api/v1/applications/{uuid}/dns/app.example.com'],
]);
test('the record of a removed domain is released and deleted', function () {
$record = ManagedDnsRecord::factory()->create([
'team_id' => $this->team->id, 'integration_token_id' => $this->token->id, 'dns_provider_zone_id' => $this->zone->id,
'provider_record_id' => 'record-new', 'type' => 'A', 'name' => 'app.example.com', 'content' => '203.0.113.10', 'owned' => true,
]);
$record->addReference($this->application);
$this->application->update(['fqdn' => 'https://new.example.com']);
Http::fake(['https://api.cloudflare.com/client/v4/zones/zone-1/dns_records/record-new' => Http::response(['success' => true, 'result' => [
'id' => 'record-new', 'type' => 'A', 'name' => 'app.example.com', 'content' => '203.0.113.10', 'comment' => $record->ownershipComment(),
]])]);
$this->withToken(dnsApiToken($this->user, $this->team))
->deleteJson("/api/v1/applications/{$this->application->uuid}/dns/app.example.com")
->assertOk()
->assertJson(['result' => 'deleted']);
expect($record->fresh())->toBeNull();
Http::assertSent(fn (HttpRequest $request): bool => $request->method() === 'DELETE');
});
test('the record of a domain that the application still uses is not released', function () {
dnsApiFakeCloudflare();
$record = app(CloudflareDnsProvider::class)->createRecord($this->zone, 'app.example.com', '203.0.113.10', $this->application);
$this->withToken(dnsApiToken($this->user, $this->team))
->deleteJson("/api/v1/applications/{$this->application->uuid}/dns/app.example.com")
->assertUnprocessable();
expect($record->fresh())->not->toBeNull();
Http::assertNotSent(fn (HttpRequest $request): bool => $request->method() === 'DELETE');
});
});
describe('automatic dns on api domain changes', function () {
test('an added application domain queues a record when the zone has automatic dns', function () {
Queue::fake([CheckDomainDnsJob::class, ConfigureDnsRecordJob::class]);
$this->withToken(dnsApiToken($this->user, $this->team))
->patchJson("/api/v1/applications/{$this->application->uuid}", ['domains' => 'https://app.example.com,https://new.example.com'])
->assertOk();
Queue::assertPushed(ConfigureDnsRecordJob::class, 1);
Queue::assertPushed(ConfigureDnsRecordJob::class, fn (ConfigureDnsRecordJob $job): bool => $job->hostname === 'new.example.com'
&& $job->content === '203.0.113.10' && $job->zoneId === $this->zone->id && $job->resourceId === $this->application->id);
});
test('an added service domain queues a record when the zone has automatic dns', function () {
Queue::fake([CheckDomainDnsJob::class, ConfigureDnsRecordJob::class]);
$serviceApplication = dnsApiService($this, 'https://web.example.com');
$this->withToken(dnsApiToken($this->user, $this->team))
->patchJson("/api/v1/services/{$serviceApplication->service->uuid}", ['urls' => [['name' => 'web', 'url' => 'https://web.example.com,https://shop.example.com']]])
->assertOk();
Queue::assertPushed(ConfigureDnsRecordJob::class, fn (ConfigureDnsRecordJob $job): bool => $job->hostname === 'shop.example.com'
&& $job->resourceId === $serviceApplication->id);
Queue::assertNotPushed(ConfigureDnsRecordJob::class, fn (ConfigureDnsRecordJob $job): bool => $job->hostname === 'web.example.com');
});
test('no record is queued when automatic dns is off', function () {
Queue::fake([CheckDomainDnsJob::class, ConfigureDnsRecordJob::class]);
$this->token->update(['metadata' => ['automatic_dns' => false]]);
$this->withToken(dnsApiToken($this->user, $this->team))
->patchJson("/api/v1/applications/{$this->application->uuid}", ['domains' => 'https://app.example.com,https://new.example.com'])
->assertOk();
Queue::assertNotPushed(ConfigureDnsRecordJob::class);
});
});
@@ -315,6 +315,11 @@ describe('UI service domain edit', function () {
});
describe('API domain edit', function () {
beforeEach(function () {
// A new domain of an automatic DNS zone also queues a record; DnsApiTest covers that.
Queue::fake([CheckDomainDnsJob::class, ConfigureDnsRecordJob::class]);
});
test('an application domain update releases the removed hostname', function () {
$cloudflare = fakeCloudflareDns();
$application = createDnsTestApplication($this, 'https://app.example.com,https://keep.example.com');
+131
View File
@@ -3,13 +3,19 @@
use App\Actions\Destination\RemoveStandaloneDockerNetwork;
use App\Jobs\DockerCleanupJob;
use App\Jobs\RestartProxyJob;
use App\Models\Application;
use App\Models\AuditEvent;
use App\Models\CloudProviderToken;
use App\Models\DiscordNotificationSettings;
use App\Models\DnsProviderZone;
use App\Models\Environment;
use App\Models\GithubApp;
use App\Models\GitlabApp;
use App\Models\InstanceSettings;
use App\Models\IntegrationToken;
use App\Models\ManagedDnsRecord;
use App\Models\PrivateKey;
use App\Models\Project;
use App\Models\S3Storage;
use App\Models\Server;
use App\Models\StandaloneDocker;
@@ -233,6 +239,13 @@ test('server group destructive actions need confirm=true', function (string $too
'manage_git_source delete' => ['manage_git_source', ['action' => 'delete', 'resource' => 'github', 'source_id' => '1']],
'cloud_provider delete_token' => ['cloud_provider', ['action' => 'delete_token', 'uuid' => 'x']],
'cloud_provider provision_server' => ['cloud_provider', ['action' => 'provision_server', 'resource' => 'hetzner']],
'manage_integration_tokens delete' => ['manage_integration_tokens', ['action' => 'delete', 'uuid' => 'x']],
'manage_dns replace' => ['manage_dns', ['action' => 'replace', 'resource' => 'application', 'uuid' => 'x']],
'manage_dns release' => ['manage_dns', ['action' => 'release', 'resource' => 'application', 'uuid' => 'x', 'hostname' => 'app.example.com']],
'manage_secret_manager remove' => ['manage_secret_manager', ['action' => 'remove', 'resource' => 'application', 'uuid' => 'x']],
'manage_cloudflare_tunnel update' => ['manage_cloudflare_tunnel', ['action' => 'update', 'uuid' => 'x', 'data' => ['is_cloudflare_tunnel' => false]]],
'manage_cloudflare_tunnel enable' => ['manage_cloudflare_tunnel', ['action' => 'enable', 'uuid' => 'x']],
'manage_cloudflare_tunnel disable' => ['manage_cloudflare_tunnel', ['action' => 'disable', 'uuid' => 'x']],
]);
// ---------------------------------------------------------------- manage_server
@@ -881,3 +894,121 @@ test('get_server_metrics explains when metrics are disabled', function () {
expect(mcpSrvIsError($response))->toBeTrue()
->and(mcpSrvText($response))->toContain('Metrics are not enabled');
});
// ---------------------------------------------------------------- manage_integration_tokens and manage_dns
test('manage_integration_tokens lists team tokens without the secret and cannot read another team token', function () {
$token = IntegrationToken::factory()->for($this->team)->create(['provider' => 'cloudflare', 'token' => 'CF_SECRET_VALUE', 'capabilities' => ['dns']]);
$foreign = IntegrationToken::factory()->for($this->otherTeam)->create(['provider' => 'cloudflare']);
$bearer = mcpSrvToken($this->user, ['read']);
$list = mcpSrvCall($bearer, 'manage_integration_tokens', ['action' => 'list']);
$other = mcpSrvCall($bearer, 'manage_integration_tokens', ['action' => 'get', 'uuid' => $foreign->uuid]);
expect(mcpSrvIsError($list))->toBeFalse()
->and(collect(mcpSrvData($list))->pluck('uuid')->all())->toBe([$token->uuid])
->and($list->getContent())->not->toContain('CF_SECRET_VALUE')
->and(mcpSrvIsError($other))->toBeTrue()
->and(mcpSrvText($other))->toContain('Integration token not found.');
});
test('manage_dns creates the dns record of an application domain', function () {
$this->server->update(['ip' => '203.0.113.10']);
$destination = StandaloneDocker::query()->where('server_id', $this->server->id)->firstOrFail();
$application = Application::factory()->create([
'environment_id' => Environment::factory()->create(['project_id' => Project::factory()->create(['team_id' => $this->team->id])->id])->id,
'destination_id' => $destination->id,
'destination_type' => $destination->getMorphClass(),
'fqdn' => 'https://app.example.com',
]);
$token = IntegrationToken::factory()->for($this->team)->create(['provider' => 'cloudflare', 'capabilities' => ['dns']]);
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']]),
]);
$response = mcpSrvCall(mcpSrvToken($this->user, ['read', 'write']), 'manage_dns', [
'action' => 'create', 'resource' => 'application', 'uuid' => $application->uuid, 'data' => ['hostname' => 'app.example.com'],
]);
expect(mcpSrvIsError($response))->toBeFalse()
->and(mcpSrvData($response)['record'])->toMatchArray(['hostname' => 'app.example.com', 'address' => '203.0.113.10'])
->and(ManagedDnsRecord::query()->where('name', 'app.example.com')->exists())->toBeTrue();
});
test('manage_dns returns the conflict of a create so the agent can replace the record', function () {
$this->server->update(['ip' => '203.0.113.10']);
$destination = StandaloneDocker::query()->where('server_id', $this->server->id)->firstOrFail();
$application = Application::factory()->create([
'environment_id' => Environment::factory()->create(['project_id' => Project::factory()->create(['team_id' => $this->team->id])->id])->id,
'destination_id' => $destination->id,
'destination_type' => $destination->getMorphClass(),
'fqdn' => 'https://app.example.com',
]);
$token = IntegrationToken::factory()->for($this->team)->create(['provider' => 'cloudflare', 'capabilities' => ['dns']]);
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' => [
['id' => 'record-old', 'type' => 'A', 'name' => 'app.example.com', 'content' => '198.51.100.7'],
]]),
'https://api.cloudflare.com/client/v4/zones/zone-1/dns_records/*' => Http::response(['success' => true, 'result' => ['id' => 'record-old']]),
]);
$bearer = mcpSrvToken($this->user, ['read', 'write']);
$create = mcpSrvCall($bearer, 'manage_dns', [
'action' => 'create', 'resource' => 'application', 'uuid' => $application->uuid, 'data' => ['hostname' => 'app.example.com'],
]);
$text = mcpSrvText($create);
$conflict = json_decode(substr($text, strpos($text, '{"conflict"')), true)['conflict'];
expect(mcpSrvIsError($create))->toBeTrue()
->and($conflict)->toBe(['record_id' => 'record-old', 'current' => '198.51.100.7', 'proposed' => '203.0.113.10']);
$replace = mcpSrvCall($bearer, 'manage_dns', [
'action' => 'replace', 'resource' => 'application', 'uuid' => $application->uuid, 'confirm' => true,
'data' => ['hostname' => 'app.example.com', 'record_id' => $conflict['record_id'], 'current' => $conflict['current']],
]);
expect(mcpSrvIsError($replace))->toBeFalse(mcpSrvText($replace))
->and(mcpSrvData($replace)['record'])->toMatchArray(['address' => '203.0.113.10', 'owned' => false]);
});
test('manage_integration_tokens renames a token', function () {
$token = IntegrationToken::factory()->for($this->team)->create(['provider' => 'doppler', 'token' => 'dp.st.secret', 'capabilities' => ['secrets']]);
$response = mcpSrvCall(mcpSrvToken($this->user, ['read', 'write']), 'manage_integration_tokens', [
'action' => 'update', 'uuid' => $token->uuid, 'data' => ['name' => 'Renamed'],
]);
expect(mcpSrvIsError($response))->toBeFalse(mcpSrvText($response))
->and($token->fresh()->name)->toBe('Renamed');
});
test('manage_secret_manager links a token of the team to an application', function () {
$destination = StandaloneDocker::query()->where('server_id', $this->server->id)->firstOrFail();
$application = Application::factory()->create([
'environment_id' => Environment::factory()->create(['project_id' => Project::factory()->create(['team_id' => $this->team->id])->id])->id,
'destination_id' => $destination->id,
'destination_type' => $destination->getMorphClass(),
]);
$token = IntegrationToken::factory()->for($this->team)->create(['provider' => 'doppler', 'token' => 'dp.st.secret', 'capabilities' => ['secrets']]);
$bearer = mcpSrvToken($this->user, ['read', 'write']);
$set = mcpSrvCall($bearer, 'manage_secret_manager', ['action' => 'set', 'resource' => 'application', 'uuid' => $application->uuid, 'data' => ['integration_token_uuid' => $token->uuid]]);
$get = mcpSrvCall($bearer, 'manage_secret_manager', ['action' => 'get', 'resource' => 'application', 'uuid' => $application->uuid]);
expect(mcpSrvIsError($set))->toBeFalse(mcpSrvText($set))
->and(mcpSrvData($get))->toMatchArray(['linked' => true, 'integration_token_uuid' => $token->uuid, 'provider' => 'doppler']);
});
test('manage_cloudflare_tunnel reads the tunnel of its own server only', function () {
$bearer = mcpSrvToken($this->user, ['read']);
$own = mcpSrvCall($bearer, 'manage_cloudflare_tunnel', ['action' => 'get', 'uuid' => $this->server->uuid]);
$foreign = mcpSrvCall($bearer, 'manage_cloudflare_tunnel', ['action' => 'get', 'uuid' => $this->otherServer->uuid]);
expect(mcpSrvIsError($own))->toBeFalse(mcpSrvText($own))
->and(mcpSrvData($own))->toHaveKey('is_cloudflare_tunnel')
->and(mcpSrvIsError($foreign))->toBeTrue();
});
+135 -1
View File
@@ -6,11 +6,14 @@ use App\Models\InstanceSettings;
use App\Models\IntegrationToken;
use App\Models\Project;
use App\Models\Server;
use App\Models\Service;
use App\Models\StandaloneDocker;
use App\Models\StandalonePostgresql;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
uses(RefreshDatabase::class);
@@ -29,6 +32,8 @@ beforeEach(function () {
$destination = StandaloneDocker::query()->where('server_id', $server->id)->firstOrFail();
$project = Project::factory()->create(['team_id' => $this->team->id]);
$environment = Environment::factory()->create(['project_id' => $project->id]);
$this->environment = $environment;
$this->destination = $destination;
$this->application = Application::factory()->create([
'environment_id' => $environment->id,
'destination_id' => $destination->id,
@@ -113,7 +118,136 @@ test('an application can be configured to use a secret manager through the api',
$this->assertDatabaseHas('audit_events', [
'team_id' => $this->team->id,
'event' => 'api.application.secret_manager.updated',
'event' => 'api.application.secret_manager.source_updated',
'resource_uuid' => $this->application->uuid,
]);
});
function secretManagerApiDopplerToken(object $test): IntegrationToken
{
return IntegrationToken::query()->create([
'team_id' => $test->team->id, 'provider' => 'doppler', 'name' => 'Doppler', 'token' => 'dp.st.secret-value', 'capabilities' => ['secrets'],
]);
}
function secretManagerApiResource(object $test, string $type): Application|Service|StandalonePostgresql
{
return match ($type) {
'applications' => $test->application,
'services' => Service::factory()->create([
'server_id' => $test->destination->server_id,
'destination_id' => $test->destination->id,
'destination_type' => $test->destination->getMorphClass(),
'environment_id' => $test->environment->id,
]),
'databases' => StandalonePostgresql::create([
'uuid' => (string) Str::uuid(), 'name' => 'db', 'postgres_user' => 'postgres', 'postgres_password' => 'pg-secret',
'postgres_db' => 'app', 'image' => 'postgres:17', 'environment_id' => $test->environment->id,
'destination_id' => $test->destination->id, 'destination_type' => $test->destination->getMorphClass(),
]),
};
}
test('a secret manager source is linked, read, and removed for each resource type', function (string $type) {
$token = secretManagerApiDopplerToken($this);
$resource = secretManagerApiResource($this, $type);
$headers = secretManagerApiHeaders($this->bearerToken);
$update = $this->withHeaders($headers)->patchJson("/api/v1/{$type}/{$resource->uuid}/secret-manager", ['integration_token_uuid' => $token->uuid])->assertOk();
expect($update->getContent())->not->toContain('secret-value');
$read = $this->withHeaders($headers)->getJson("/api/v1/{$type}/{$resource->uuid}/secret-manager")
->assertOk()
->assertJson(['linked' => true, 'integration_token_uuid' => $token->uuid, 'provider' => 'doppler', 'available_tokens' => [['uuid' => $token->uuid]]]);
expect($read->getContent())->not->toContain('secret-value');
$this->withHeaders($headers)->deleteJson("/api/v1/{$type}/{$resource->uuid}/secret-manager")->assertOk();
expect($resource->secretManagerLink()->exists())->toBeFalse();
})->with(['applications', 'services', 'databases']);
test('remote key names are listed and imported as references, and values are never returned', function () {
$token = secretManagerApiDopplerToken($this);
$this->application->secretManagerLink()->create(['integration_token_id' => $token->id]);
$this->application->environment_variables()->create(['key' => 'EXISTING', 'value' => 'x']);
Http::fake(['https://api.doppler.com/v3/configs/config/secrets/download*' => Http::response([
'API_KEY' => 'remote-value-1', 'EXISTING' => 'remote-value-2', 'db-password' => 'remote-value-3',
])]);
$headers = secretManagerApiHeaders($this->bearerToken);
$base = "/api/v1/applications/{$this->application->uuid}/secret-manager";
$keys = $this->withHeaders($headers)->getJson("{$base}/keys")->assertOk()->assertExactJson(['keys' => ['API_KEY', 'EXISTING', 'db-password']]);
$import = $this->withHeaders($headers)->postJson("{$base}/import")->assertOk()->assertExactJson(['imported' => ['API_KEY'], 'skipped' => ['db-password']]);
expect($keys->getContent())->not->toContain('remote-value')->not->toContain('secret-value')
->and($import->getContent())->not->toContain('remote-value')->not->toContain('secret-value')
->and($this->application->environment_variables()->where('key', 'API_KEY')->value('value'))->toBe('{{vault.API_KEY}}');
});
test('a failing secret manager returns 400 without the token secret', function (string $method, string $path) {
$token = secretManagerApiDopplerToken($this);
$this->application->secretManagerLink()->create(['integration_token_id' => $token->id]);
Http::fake(['https://api.doppler.com/*' => Http::response(['messages' => ['Internal error']], 500)]);
$response = $this->withHeaders(secretManagerApiHeaders($this->bearerToken))
->json($method, "/api/v1/applications/{$this->application->uuid}/secret-manager/{$path}", ['key' => 'API_KEY'])
->assertStatus(400);
expect($response->getContent())->not->toContain('secret-value');
})->with([
'keys' => ['GET', 'keys'],
'references' => ['POST', 'references'],
'import' => ['POST', 'import'],
]);
test('a reference is added only for a remote key with a valid name that has no variable yet', function (string $key, ?string $error) {
$token = secretManagerApiDopplerToken($this);
$this->application->secretManagerLink()->create(['integration_token_id' => $token->id]);
$this->application->environment_variables()->create(['key' => 'EXISTING', 'value' => 'x']);
Http::fake(['https://api.doppler.com/*' => Http::response(['API_KEY' => 'v', 'EXISTING' => 'v', 'db-password' => 'v'])]);
$response = $this->withHeaders(secretManagerApiHeaders($this->bearerToken))
->postJson("/api/v1/applications/{$this->application->uuid}/secret-manager/references", ['key' => $key]);
if ($error === null) {
$response->assertCreated();
expect($this->application->environment_variables()->where('key', $key)->value('value'))->toBe("{{vault.{$key}}}");
} else {
$response->assertUnprocessable()->assertJsonPath('errors.key.0', $error);
}
})->with([
'remote key' => ['API_KEY', null],
'unknown key' => ['MISSING', 'MISSING is not a key of the secret manager source.'],
'invalid name' => ['db-password', 'db-password is not a valid variable name.'],
'existing variable' => ['EXISTING', 'A variable with the key EXISTING already exists.'],
]);
test('a member with read access can read the source but not list keys or remove it', function () {
$member = User::factory()->create();
$this->team->members()->attach($member, ['role' => 'member']);
Http::fake();
$base = "/api/v1/applications/{$this->application->uuid}/secret-manager";
$readOnly = $member->createToken('member-read', ['read'])->plainTextToken;
$this->withHeaders(secretManagerApiHeaders($readOnly))->getJson($base)->assertOk();
$this->withHeaders(secretManagerApiHeaders($readOnly))->getJson("{$base}/keys")->assertForbidden();
$this->withHeaders(secretManagerApiHeaders($readOnly))->deleteJson($base)->assertForbidden();
Http::assertNothingSent();
});
test('a token or resource of another team cannot be used', function () {
$otherTeam = Team::factory()->create();
$foreignToken = IntegrationToken::query()->create([
'team_id' => $otherTeam->id, 'provider' => 'doppler', 'name' => 'Foreign', 'token' => 'dp.st.x', 'capabilities' => ['secrets'],
]);
$otherTeam->members()->attach($this->user, ['role' => 'owner']);
$otherBearer = $this->user->createToken('other', ['*'], teamId: $otherTeam->id)->plainTextToken;
$this->withHeaders(secretManagerApiHeaders($this->bearerToken))
->patchJson("/api/v1/applications/{$this->application->uuid}/secret-manager", ['integration_token_uuid' => $foreignToken->uuid])
->assertNotFound();
app('auth')->forgetGuards();
$this->withHeaders(secretManagerApiHeaders($otherBearer))
->getJson("/api/v1/applications/{$this->application->uuid}/secret-manager")
->assertNotFound();
expect($this->application->secretManagerLink()->exists())->toBeFalse();
});