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:
+106
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
+45
@@ -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 {}
|
||||
+33
@@ -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>;
|
||||
}
|
||||
+27
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+18
@@ -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);
|
||||
+44
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+64
@@ -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);
|
||||
});
|
||||
});
|
||||
+358
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
+261
@@ -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),
|
||||
});
|
||||
}
|
||||
}
|
||||
+28
@@ -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,
|
||||
};
|
||||
Reference in New Issue
Block a user