14240 extensibility ability to create multiple custom domains for each workspace 2 (#14307)

Adds a public-domain core-module. 
Reorganize custom-domain files properly
This commit is contained in:
martmull
2025-09-11 12:24:57 +02:00
committed by GitHub
parent ceffc82b9c
commit b84f4075e5
48 changed files with 1215 additions and 797 deletions
@@ -0,0 +1,106 @@
import { Test, type TestingModule } from '@nestjs/testing';
import { type Request } from 'express';
import { DnsCloudflareController } from 'src/engine/core-modules/dns-manager/controllers/dns-cloudflare.controller';
import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service';
import { HttpExceptionHandlerService } from 'src/engine/core-modules/exception-handler/http-exception-handler.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { DnsManagerService } from 'src/engine/core-modules/dns-manager/services/dns-manager.service';
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service';
describe('DnsCloudflareController - customHostnameWebhooks', () => {
let controller: DnsCloudflareController;
let dnsManagerService: DnsManagerService;
let domainManagerService: DomainManagerService;
beforeEach(async () => {
jest.clearAllMocks();
const module: TestingModule = await Test.createTestingModule({
controllers: [DnsCloudflareController],
providers: [
{
provide: DomainManagerService,
useValue: {
handleCustomDomainActivation: jest.fn(),
},
},
{
provide: DnsManagerService,
useValue: {
isHostnameWorking: jest.fn(),
},
},
{
provide: HttpExceptionHandlerService,
useValue: {
handleError: jest.fn(),
},
},
{
provide: ExceptionHandlerService,
useValue: {
captureExceptions: jest.fn(),
},
},
{
provide: TwentyConfigService,
useValue: {
get: jest.fn(),
},
},
],
}).compile();
controller = module.get<DnsCloudflareController>(DnsCloudflareController);
dnsManagerService = module.get<DnsManagerService>(DnsManagerService);
domainManagerService =
module.get<DomainManagerService>(DomainManagerService);
});
it('should return if hostname is missing', async () => {
const req = {
headers: { 'cf-webhook-auth': 'correct-secret' },
body: { data: { data: {} } },
} as unknown as Request;
await controller.customHostnameWebhooks(req);
expect(dnsManagerService.isHostnameWorking).not.toHaveBeenCalled();
});
it('should return if wrong alert_type', async () => {
const req = {
headers: { 'cf-webhook-auth': 'correct-secret' },
body: { alert_type: 'wrong_alert_type', data: { data: {} } },
} as unknown as Request;
await controller.customHostnameWebhooks(req);
expect(dnsManagerService.isHostnameWorking).not.toHaveBeenCalled();
});
it('should update workspace for a valid hostname and save changes', async () => {
const req = {
headers: { 'cf-webhook-auth': 'correct-secret' },
body: {
alert_type: 'custom_ssl_certificate_event_type',
data: { data: { hostname: 'example.com' } },
},
} as unknown as Request;
jest.spyOn(dnsManagerService, 'isHostnameWorking').mockResolvedValue(true);
await controller.customHostnameWebhooks(req);
expect(dnsManagerService.isHostnameWorking).toHaveBeenCalled();
expect(
domainManagerService.handleCustomDomainActivation,
).toHaveBeenCalledWith({
customDomain: 'example.com',
isCustomDomainWorking: true,
});
});
});
@@ -0,0 +1,45 @@
/* @license Enterprise */
import { Controller, Post, Req, UseFilters, UseGuards } from '@nestjs/common';
import { Request } from 'express';
import { AuthRestApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-rest-api-exception.filter';
import { PublicEndpointGuard } from 'src/engine/guards/public-endpoint.guard';
import { CloudflareSecretMatchGuard } from 'src/engine/core-modules/dns-manager/guards/cloudflare-secret.guard';
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service';
import { DnsManagerService } from 'src/engine/core-modules/dns-manager/services/dns-manager.service';
import { DnsManagerExceptionFilter } from 'src/engine/core-modules/dns-manager/exceptions/dns-manager-exception-filter';
@Controller()
@UseFilters(AuthRestApiExceptionFilter, DnsManagerExceptionFilter)
export class DnsCloudflareController {
constructor(
protected readonly domainManagerService: DomainManagerService,
protected readonly dnsManagerService: DnsManagerService,
) {}
@Post(['cloudflare/custom-hostname-webhooks', 'webhooks/cloudflare'])
@UseGuards(CloudflareSecretMatchGuard, PublicEndpointGuard)
async customHostnameWebhooks(@Req() req: Request) {
const alertType = req.body?.alert_type;
const hostname = req.body?.data?.data?.hostname;
if (alertType !== 'custom_ssl_certificate_event_type' || !hostname) {
return;
}
try {
const isCustomDomainWorking =
await this.dnsManagerService.isHostnameWorking(hostname);
await this.domainManagerService.handleCustomDomainActivation({
customDomain: hostname,
isCustomDomainWorking,
});
} catch {
return;
}
}
}
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { DnsManagerService } from 'src/engine/core-modules/dns-manager/services/dns-manager.service';
import { DomainManagerModule } from 'src/engine/core-modules/domain-manager/domain-manager.module';
import { DnsCloudflareController } from 'src/engine/core-modules/dns-manager/controllers/dns-cloudflare.controller';
@Module({
imports: [DomainManagerModule],
providers: [DnsManagerService],
controllers: [DnsCloudflareController],
exports: [DnsManagerService],
})
export class DnsManagerModule {}
@@ -0,0 +1,33 @@
import { Field, ObjectType } from '@nestjs/graphql';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
@ObjectType()
class DomainRecord {
@Field(() => String)
validationType: 'ssl' | 'redirection';
@Field(() => String)
type: 'cname';
@Field(() => String)
status: string;
@Field(() => String)
key: string;
@Field(() => String)
value: string;
}
@ObjectType()
export class DomainValidRecords {
@Field(() => UUIDScalarType)
id: string;
@Field(() => String)
domain: string;
@Field(() => [DomainRecord])
records: Array<DomainRecord>;
}
@@ -0,0 +1,27 @@
import { Catch, type ExceptionFilter } from '@nestjs/common';
import { assertUnreachable } from 'twenty-shared/utils';
import {
DnsManagerException,
DnsManagerExceptionCode,
} from 'src/engine/core-modules/dns-manager/exceptions/dns-manager.exception';
@Catch(DnsManagerException)
export class DnsManagerExceptionFilter implements ExceptionFilter {
catch(exception: DnsManagerException) {
switch (exception.code) {
case DnsManagerExceptionCode.INTERNAL_SERVER_ERROR:
case DnsManagerExceptionCode.HOSTNAME_ALREADY_REGISTERED:
case DnsManagerExceptionCode.HOSTNAME_NOT_REGISTERED:
case DnsManagerExceptionCode.INVALID_INPUT_DATA:
case DnsManagerExceptionCode.CLOUDFLARE_CLIENT_NOT_INITIALIZED:
case DnsManagerExceptionCode.MULTIPLE_HOSTNAMES_FOUND:
case DnsManagerExceptionCode.MISSING_PUBLIC_DOMAIN_URL:
throw exception;
default: {
assertUnreachable(exception.code);
}
}
}
}
@@ -0,0 +1,18 @@
import {
appendCommonExceptionCode,
CustomException,
} from 'src/utils/custom-exception';
export class DnsManagerException extends CustomException<
keyof typeof DnsManagerExceptionCode,
true
> {}
export const DnsManagerExceptionCode = appendCommonExceptionCode({
HOSTNAME_ALREADY_REGISTERED: 'HOSTNAME_ALREADY_REGISTERED',
HOSTNAME_NOT_REGISTERED: 'HOSTNAME_NOT_REGISTERED',
INVALID_INPUT_DATA: 'INVALID_INPUT_DATA',
CLOUDFLARE_CLIENT_NOT_INITIALIZED: 'CLOUDFLARE_CLIENT_NOT_INITIALIZED',
MULTIPLE_HOSTNAMES_FOUND: 'MULTIPLE_HOSTNAMES_FOUND',
MISSING_PUBLIC_DOMAIN_URL: 'MISSING_PUBLIC_DOMAIN_URL',
} as const);
@@ -0,0 +1,44 @@
/* @license Enterprise */
import {
type CanActivate,
type ExecutionContext,
Injectable,
} from '@nestjs/common';
import { timingSafeEqual } from 'crypto';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
@Injectable()
export class CloudflareSecretMatchGuard implements CanActivate {
constructor(private readonly twentyConfigService: TwentyConfigService) {}
canActivate(context: ExecutionContext): boolean {
try {
const request = context.switchToHttp().getRequest<Request>();
const cloudflareWebhookSecret = this.twentyConfigService.get(
'CLOUDFLARE_WEBHOOK_SECRET',
);
if (
!cloudflareWebhookSecret ||
(cloudflareWebhookSecret &&
// @ts-expect-error legacy noImplicitAny
(typeof request.headers['cf-webhook-auth'] === 'string' ||
timingSafeEqual(
// @ts-expect-error legacy noImplicitAny
Buffer.from(request.headers['cf-webhook-auth']),
Buffer.from(cloudflareWebhookSecret),
)))
) {
return true;
}
return false;
} catch {
return false;
}
}
}
@@ -0,0 +1,64 @@
import { type ExecutionContext } from '@nestjs/common';
import * as crypto from 'crypto';
import { type TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { CloudflareSecretMatchGuard } from 'src/engine/core-modules/dns-manager/guards/cloudflare-secret.guard';
describe('CloudflareSecretMatchGuard.canActivate', () => {
let guard: CloudflareSecretMatchGuard;
let twentyConfigService: TwentyConfigService;
beforeEach(() => {
twentyConfigService = {
get: jest.fn(),
} as unknown as TwentyConfigService;
guard = new CloudflareSecretMatchGuard(twentyConfigService);
});
it('should return true when the webhook secret matches', () => {
const mockRequest = { headers: { 'cf-webhook-auth': 'valid-secret' } };
jest.spyOn(twentyConfigService, 'get').mockReturnValue('valid-secret');
const mockContext = {
switchToHttp: () => ({
getRequest: () => mockRequest,
}),
} as unknown as ExecutionContext;
jest.spyOn(crypto, 'timingSafeEqual').mockReturnValue(true);
expect(guard.canActivate(mockContext)).toBe(true);
});
it('should return true when env is not set', () => {
const mockRequest = { headers: { 'cf-webhook-auth': 'valid-secret' } };
jest.spyOn(twentyConfigService, 'get').mockReturnValue(undefined);
const mockContext = {
switchToHttp: () => ({
getRequest: () => mockRequest,
}),
} as unknown as ExecutionContext;
jest.spyOn(crypto, 'timingSafeEqual').mockReturnValue(true);
expect(guard.canActivate(mockContext)).toBe(true);
});
it('should return false if an error occurs', () => {
const mockRequest = { headers: {} };
jest.spyOn(twentyConfigService, 'get').mockReturnValue('valid-secret');
const mockContext = {
switchToHttp: () => ({
getRequest: () => mockRequest,
}),
} as unknown as ExecutionContext;
expect(guard.canActivate(mockContext)).toBe(false);
});
});
@@ -0,0 +1,358 @@
import { Test, type TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import Cloudflare from 'cloudflare';
import { type CustomHostnameCreateResponse } from 'cloudflare/resources/custom-hostnames/custom-hostnames';
import { AuditContextMock } from 'test/utils/audit-context.mock';
import { AuditService } from 'src/engine/core-modules/audit/services/audit.service';
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { DnsManagerService } from 'src/engine/core-modules/dns-manager/services/dns-manager.service';
import { DnsManagerException } from 'src/engine/core-modules/dns-manager/exceptions/dns-manager.exception';
jest.mock('cloudflare');
describe('DnsManagerService', () => {
let dnsManagerService: DnsManagerService;
let twentyConfigService: TwentyConfigService;
let domainManagerService: DomainManagerService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
DnsManagerService,
{
provide: TwentyConfigService,
useValue: {
get: jest.fn(),
},
},
{
provide: AuditService,
useValue: {
createContext: AuditContextMock,
},
},
{
provide: DomainManagerService,
useValue: {
getBaseUrl: jest.fn(),
getPublicDomainUrl: jest.fn(),
},
},
{
provide: getRepositoryToken(Workspace),
useValue: {
save: jest.fn(),
},
},
],
}).compile();
dnsManagerService = module.get<DnsManagerService>(DnsManagerService);
twentyConfigService = module.get<TwentyConfigService>(TwentyConfigService);
domainManagerService =
module.get<DomainManagerService>(DomainManagerService);
(dnsManagerService as any).cloudflareClient = {
customHostnames: {
list: jest.fn(),
create: jest.fn(),
},
};
jest.clearAllMocks();
});
it('should initialize cloudflareClient when CLOUDFLARE_API_KEY is defined', () => {
const mockApiKey = 'test-api-key';
jest.spyOn(twentyConfigService, 'get').mockReturnValue(mockApiKey);
const instance = new DnsManagerService(twentyConfigService, {} as any);
expect(twentyConfigService.get).toHaveBeenCalledWith('CLOUDFLARE_API_KEY');
expect(Cloudflare).toHaveBeenCalledWith({ apiToken: mockApiKey });
expect(instance.cloudflareClient).toBeDefined();
});
describe('registerHostname', () => {
it('should throw an error when the hostname is already registered', async () => {
const customDomain = 'example.com';
jest
.spyOn(dnsManagerService, 'getHostnameId')
.mockResolvedValueOnce('hostname-id');
await expect(
dnsManagerService.registerHostname(customDomain),
).rejects.toThrow(DnsManagerException);
expect(dnsManagerService.getHostnameId).toHaveBeenCalledWith(
customDomain,
undefined,
);
});
it('should register a custom domain successfully', async () => {
const customDomain = 'example.com';
const createMock = jest.fn().mockResolvedValueOnce({});
const cloudflareMock = {
customHostnames: {
create: createMock,
},
};
jest
.spyOn(dnsManagerService, 'getHostnameId')
.mockResolvedValueOnce(undefined);
jest.spyOn(twentyConfigService, 'get').mockReturnValue('test-zone-id');
(dnsManagerService as any).cloudflareClient = cloudflareMock;
await dnsManagerService.registerHostname(customDomain);
expect(createMock).toHaveBeenCalledWith({
zone_id: 'test-zone-id',
hostname: customDomain,
ssl: expect.any(Object),
});
});
});
describe('getHostnameWithRecords', () => {
it('should return undefined if no custom domain details are found', async () => {
const customDomain = 'example.com';
const cloudflareMock = {
customHostnames: {
list: jest.fn().mockResolvedValueOnce({ result: [] }),
},
};
jest.spyOn(twentyConfigService, 'get').mockReturnValue('test-zone-id');
(dnsManagerService as any).cloudflareClient = cloudflareMock;
const result = await dnsManagerService.getHostnameWithRecords(
customDomain,
{ isPublicDomain: false },
);
expect(result).toBeUndefined();
expect(cloudflareMock.customHostnames.list).toHaveBeenCalledWith({
zone_id: 'test-zone-id',
hostname: customDomain,
});
});
it('should return even if no record found', async () => {
const customDomain = 'example.com';
const mockResult = {
id: 'custom-id',
hostname: customDomain,
ownership_verification: undefined,
verification_errors: [],
ssl: {
dcv_delegation_records: [],
},
};
const cloudflareMock = {
customHostnames: {
list: jest.fn().mockResolvedValueOnce({ result: [mockResult] }),
},
};
jest.spyOn(twentyConfigService, 'get').mockReturnValue('test-zone-id');
jest
.spyOn(domainManagerService, 'getBaseUrl')
.mockReturnValue(new URL('https://front.domain'));
(dnsManagerService as any).cloudflareClient = cloudflareMock;
const result = await dnsManagerService.getHostnameWithRecords(
customDomain,
{ isPublicDomain: false },
);
expect(result).toEqual({
id: 'custom-id',
domain: customDomain,
records: expect.any(Array),
});
});
it('should return domain details if a single result is found', async () => {
const customDomain = 'example.com';
const mockResult = {
id: 'custom-id',
hostname: customDomain,
ownership_verification: {
type: 'txt',
name: 'ownership',
value: 'value',
},
ssl: {
validation_records: [{ txt_name: 'ssl', txt_value: 'validation' }],
},
verification_errors: [],
};
const cloudflareMock = {
customHostnames: {
list: jest.fn().mockResolvedValueOnce({ result: [mockResult] }),
},
};
jest.spyOn(twentyConfigService, 'get').mockReturnValue('test-zone-id');
jest
.spyOn(domainManagerService, 'getBaseUrl')
.mockReturnValue(new URL('https://front.domain'));
(dnsManagerService as any).cloudflareClient = cloudflareMock;
const result = await dnsManagerService.getHostnameWithRecords(
customDomain,
{ isPublicDomain: false },
);
expect(result).toEqual({
id: 'custom-id',
domain: customDomain,
records: expect.any(Array),
});
expect(result?.records[0].value === 'https://front.domain');
});
it('should return public domain details', async () => {
const customDomain = 'example.com';
const mockResult = {
id: 'custom-id',
hostname: customDomain,
ownership_verification: {
type: 'txt',
name: 'ownership',
value: 'value',
},
ssl: {
validation_records: [{ txt_name: 'ssl', txt_value: 'validation' }],
},
verification_errors: [],
};
const cloudflareMock = {
customHostnames: {
list: jest.fn().mockResolvedValueOnce({ result: [mockResult] }),
},
};
jest.spyOn(twentyConfigService, 'get').mockReturnValue('test-zone-id');
jest
.spyOn(domainManagerService, 'getBaseUrl')
.mockReturnValue(new URL('https://front.domain'));
jest
.spyOn(domainManagerService, 'getPublicDomainUrl')
.mockReturnValue(new URL('https://front.public-domain'));
(dnsManagerService as any).cloudflareClient = cloudflareMock;
const result = await dnsManagerService.getHostnameWithRecords(
customDomain,
{ isPublicDomain: true },
);
expect(result).toEqual({
id: 'custom-id',
domain: customDomain,
records: expect.any(Array),
});
expect(result?.records[0].value === 'https://front.public-domain');
});
it('should throw an error if multiple results are found', async () => {
const customDomain = 'example.com';
const cloudflareMock = {
customHostnames: {
list: jest.fn().mockResolvedValueOnce({ result: [{}, {}] }),
},
};
jest.spyOn(twentyConfigService, 'get').mockReturnValue('test-zone-id');
(dnsManagerService as any).cloudflareClient = cloudflareMock;
await expect(
dnsManagerService.getHostnameWithRecords(customDomain, {
isPublicDomain: false,
}),
).rejects.toThrow(Error);
});
});
describe('updateHostname', () => {
it('should update a custom domain and register a new one', async () => {
const fromHostname = 'old.com';
const toHostname = 'new.com';
jest
.spyOn(dnsManagerService, 'getHostnameId')
.mockResolvedValueOnce('old-id');
jest
.spyOn(dnsManagerService, 'deleteHostname')
.mockResolvedValueOnce(undefined);
const registerSpy = jest
.spyOn(dnsManagerService, 'registerHostname')
.mockResolvedValueOnce({} as unknown as CustomHostnameCreateResponse);
await dnsManagerService.updateHostname(fromHostname, toHostname);
expect(dnsManagerService.getHostnameId).toHaveBeenCalledWith(
fromHostname,
undefined,
);
expect(dnsManagerService.deleteHostname).toHaveBeenCalledWith(
'old-id',
undefined,
);
expect(registerSpy).toHaveBeenCalledWith(toHostname, undefined);
});
});
describe('deleteHostnameSilently', () => {
it('should delete the custom hostname silently', async () => {
const customDomain = 'example.com';
jest
.spyOn(dnsManagerService, 'getHostnameId')
.mockResolvedValueOnce('custom-id');
const deleteMock = jest.fn();
const cloudflareMock = {
customHostnames: {
delete: deleteMock,
},
};
jest.spyOn(twentyConfigService, 'get').mockReturnValue('test-zone-id');
(dnsManagerService as any).cloudflareClient = cloudflareMock;
await expect(
dnsManagerService.deleteHostnameSilently(customDomain),
).resolves.toBeUndefined();
expect(deleteMock).toHaveBeenCalledWith('custom-id', {
zone_id: 'test-zone-id',
});
});
it('should silently handle errors', async () => {
const customDomain = 'example.com';
jest
.spyOn(dnsManagerService, 'getHostnameId')
.mockRejectedValueOnce(new Error('Failure'));
await expect(
dnsManagerService.deleteHostnameSilently(customDomain),
).resolves.toBeUndefined();
});
});
});
@@ -0,0 +1,261 @@
/* @license Enterprise */
import { Injectable } from '@nestjs/common';
import Cloudflare from 'cloudflare';
import {
type CustomHostnameCreateParams,
type CustomHostnameListResponse,
} from 'cloudflare/resources/custom-hostnames/custom-hostnames';
import { isDefined } from 'twenty-shared/utils';
import {
DnsManagerException,
DnsManagerExceptionCode,
} from 'src/engine/core-modules/dns-manager/exceptions/dns-manager.exception';
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service';
import { dnsManagerValidator } from 'src/engine/core-modules/dns-manager/validator/cloudflare.validate';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { type DomainValidRecords } from 'src/engine/core-modules/dns-manager/dtos/domain-valid-records';
type DnsManagerOptions = {
isPublicDomain?: boolean;
};
@Injectable()
export class DnsManagerService {
cloudflareClient?: Cloudflare;
constructor(
private readonly twentyConfigService: TwentyConfigService,
private readonly domainManagerService: DomainManagerService,
) {
if (this.twentyConfigService.get('CLOUDFLARE_API_KEY')) {
this.cloudflareClient = new Cloudflare({
apiToken: this.twentyConfigService.get('CLOUDFLARE_API_KEY'),
});
}
}
async registerHostname(customDomain: string, options?: DnsManagerOptions) {
dnsManagerValidator.isCloudflareInstanceDefined(this.cloudflareClient);
if (isDefined(await this.getHostnameId(customDomain, options))) {
throw new DnsManagerException(
'Hostname already registered',
DnsManagerExceptionCode.HOSTNAME_ALREADY_REGISTERED,
{ userFriendlyMessage: 'Domain is already registered' },
);
}
return this.cloudflareClient.customHostnames.create({
zone_id: this.getZoneId(options),
hostname: customDomain,
ssl: this.sslParams,
});
}
async getHostnameWithRecords(
domain: string,
options?: DnsManagerOptions,
): Promise<DomainValidRecords | undefined> {
if (
options?.isPublicDomain &&
!isDefined(this.domainManagerService.getPublicDomainUrl().hostname)
) {
throw new DnsManagerException(
'Missing public domain URL',
DnsManagerExceptionCode.MISSING_PUBLIC_DOMAIN_URL,
{
userFriendlyMessage:
'Public domain URL is not defined. Please set the PUBLIC_DOMAIN_URL environment variable',
},
);
}
const customHostname = await this.getHostnameDetails(domain, options);
if (!isDefined(customHostname)) {
return undefined;
}
const { hostname, id, ssl } = customHostname;
const statuses = this.getHostnameStatuses(customHostname);
// @ts-expect-error - type definition doesn't reflect the real API
const dcvRecords = ssl?.dcv_delegation_records?.[0];
return {
id: id,
domain: hostname,
records: [
{
validationType: 'redirection' as const,
type: 'cname',
status: statuses.redirection,
key: hostname,
value: options?.isPublicDomain
? this.domainManagerService.getPublicDomainUrl().hostname
: this.domainManagerService.getBaseUrl().hostname,
},
{
validationType: 'ssl' as const,
type: 'cname',
status: statuses.ssl,
key: dcvRecords?.cname ?? `_acme-challenge.${hostname}`,
value:
dcvRecords?.cname_target ??
`${hostname}.${this.twentyConfigService.get('CLOUDFLARE_DCV_DELEGATION_ID')}.dcv.cloudflare.com`,
},
],
};
}
async updateHostname(
fromHostname: string,
toHostname: string,
options?: DnsManagerOptions,
) {
dnsManagerValidator.isCloudflareInstanceDefined(this.cloudflareClient);
const fromCustomHostnameId = await this.getHostnameId(
fromHostname,
options,
);
if (fromCustomHostnameId) {
await this.deleteHostname(fromCustomHostnameId, options);
}
return this.registerHostname(toHostname, options);
}
async refreshHostname(
domainValidRecords: DomainValidRecords,
options?: DnsManagerOptions,
) {
dnsManagerValidator.isCloudflareInstanceDefined(this.cloudflareClient);
await this.cloudflareClient.customHostnames.edit(domainValidRecords.id, {
zone_id: this.getZoneId(options),
ssl: this.sslParams,
});
}
async deleteHostnameSilently(hostname: string, options?: DnsManagerOptions) {
dnsManagerValidator.isCloudflareInstanceDefined(this.cloudflareClient);
try {
const customHostnameId = await this.getHostnameId(hostname, options);
if (customHostnameId) {
await this.deleteHostname(customHostnameId, options);
}
} catch {
return;
}
}
async isHostnameWorking(hostname: string, options?: DnsManagerOptions) {
const hostnameDetails = await this.getHostnameDetails(hostname, options);
if (!isDefined(hostnameDetails)) {
return false;
}
const statuses = this.getHostnameStatuses(hostnameDetails);
return statuses.redirection === 'success' && statuses.ssl === 'success';
}
private get sslParams(): CustomHostnameCreateParams['ssl'] {
return {
method: 'txt',
type: 'dv',
settings: {
http2: 'on',
min_tls_version: '1.2',
tls_1_3: 'on',
ciphers: ['ECDHE-RSA-AES128-GCM-SHA256', 'AES128-SHA'],
early_hints: 'on',
},
bundle_method: 'ubiquitous',
wildcard: false,
};
}
private getZoneId(options?: DnsManagerOptions): string {
return options?.isPublicDomain
? this.twentyConfigService.get('CLOUDFLARE_PUBLIC_DOMAIN_ZONE_ID')
: this.twentyConfigService.get('CLOUDFLARE_ZONE_ID');
}
private async getHostnameDetails(
hostname: string,
options?: DnsManagerOptions,
) {
dnsManagerValidator.isCloudflareInstanceDefined(this.cloudflareClient);
const customHostnames = await this.cloudflareClient.customHostnames.list({
zone_id: this.getZoneId(options),
hostname: hostname,
});
if (customHostnames.result.length === 0) {
return undefined;
}
if (customHostnames.result.length === 1) {
return customHostnames.result[0];
}
// should never happen. error 5xx
throw new DnsManagerException(
'More than one custom hostname found in cloudflare',
DnsManagerExceptionCode.MULTIPLE_HOSTNAMES_FOUND,
{
userFriendlyMessage: `${customHostnames.result.length} hostnames found for domain '${hostname}'. Expect 1`,
},
);
}
async getHostnameId(hostname: string, options?: DnsManagerOptions) {
const customHostname = await this.getHostnameDetails(hostname, options);
if (!isDefined(customHostname)) {
return undefined;
}
return customHostname.id;
}
private getHostnameStatuses(customHostname: CustomHostnameListResponse) {
const { ssl, verification_errors, created_at } = customHostname;
return {
// wait 10s before starting the real check
redirection:
created_at &&
new Date().getTime() - new Date(created_at).getTime() < 1000 * 10
? 'pending'
: verification_errors?.[0] ===
'custom hostname does not CNAME to this zone.'
? 'error'
: 'success',
ssl:
!ssl.status || ssl.status.startsWith('pending')
? 'pending'
: ssl.status === 'active'
? 'success'
: ssl.status,
};
}
async deleteHostname(customHostnameId: string, options?: DnsManagerOptions) {
dnsManagerValidator.isCloudflareInstanceDefined(this.cloudflareClient);
await this.cloudflareClient.customHostnames.delete(customHostnameId, {
zone_id: this.getZoneId(options),
});
}
}
@@ -0,0 +1,28 @@
import { t } from '@lingui/core/macro';
import type Cloudflare from 'cloudflare';
import {
DnsManagerException,
DnsManagerExceptionCode,
} from 'src/engine/core-modules/dns-manager/exceptions/dns-manager.exception';
const isCloudflareInstanceDefined = (
cloudflareInstance: Cloudflare | undefined | null,
): asserts cloudflareInstance is Cloudflare => {
if (!cloudflareInstance) {
throw new DnsManagerException(
'Cloudflare instance is not defined',
DnsManagerExceptionCode.CLOUDFLARE_CLIENT_NOT_INITIALIZED,
{
userFriendlyMessage: t`Environment variable CLOUDFLARE_API_KEY must be defined to use this feature.`,
},
);
}
};
export const dnsManagerValidator: {
isCloudflareInstanceDefined: typeof isCloudflareInstanceDefined;
} = {
isCloudflareInstanceDefined,
};