|
|
|
@@ -1,6 +1,7 @@
|
|
|
|
|
import { env } from '@api/config/env';
|
|
|
|
|
import { AppError, ErrorCodes } from '@api/utils/errors';
|
|
|
|
|
|
|
|
|
|
import { env } from '@api/config/env';
|
|
|
|
|
|
|
|
|
|
export type DomainSetupStatus =
|
|
|
|
|
| 'reconnect_required'
|
|
|
|
|
| 'pending_dns'
|
|
|
|
@@ -10,7 +11,7 @@ export type DomainSetupStatus =
|
|
|
|
|
| 'error';
|
|
|
|
|
|
|
|
|
|
export interface DomainDnsRecord {
|
|
|
|
|
type: 'CNAME' | 'TXT';
|
|
|
|
|
type: 'A' | 'CNAME' | 'TXT';
|
|
|
|
|
name: string;
|
|
|
|
|
value: string;
|
|
|
|
|
status: 'pending' | 'active';
|
|
|
|
@@ -22,83 +23,67 @@ export interface DomainSetupResult {
|
|
|
|
|
verified: boolean;
|
|
|
|
|
reconnectRequired: boolean;
|
|
|
|
|
message: string;
|
|
|
|
|
provider: 'cloudflare';
|
|
|
|
|
provider: 'approximated';
|
|
|
|
|
dnsRecords: DomainDnsRecord[];
|
|
|
|
|
validationErrors: string[];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface CloudflareApiError {
|
|
|
|
|
code: number;
|
|
|
|
|
message: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface CloudflareEnvelope<T> {
|
|
|
|
|
success: boolean;
|
|
|
|
|
errors: CloudflareApiError[];
|
|
|
|
|
messages: Array<{ code: number; message: string }>;
|
|
|
|
|
result: T;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface CloudflareValidationRecord {
|
|
|
|
|
interface ApproximatedVhost {
|
|
|
|
|
incoming_address: string;
|
|
|
|
|
target_address?: string;
|
|
|
|
|
dns_pointed_at?: string;
|
|
|
|
|
has_ssl?: boolean;
|
|
|
|
|
is_resolving?: boolean;
|
|
|
|
|
apx_hit?: boolean;
|
|
|
|
|
status?: string;
|
|
|
|
|
txt_name?: string;
|
|
|
|
|
txt_value?: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface CloudflareCustomHostname {
|
|
|
|
|
id: string;
|
|
|
|
|
hostname: string;
|
|
|
|
|
status?: string;
|
|
|
|
|
ownership_verification?: {
|
|
|
|
|
type?: string;
|
|
|
|
|
name?: string;
|
|
|
|
|
value?: string;
|
|
|
|
|
};
|
|
|
|
|
ssl?: {
|
|
|
|
|
status?: string;
|
|
|
|
|
validation_records?: CloudflareValidationRecord[];
|
|
|
|
|
};
|
|
|
|
|
ssl_active_from?: string | null;
|
|
|
|
|
ssl_active_until?: string | null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const SUPPORTED_CUSTOM_DOMAIN_PATTERN = /^(?=.{4,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.){2,}[a-z]{2,63}$/i;
|
|
|
|
|
|
|
|
|
|
function ensureCloudflareConfig() {
|
|
|
|
|
const APPROXIMATED_BASE_URL = 'https://cloud.approximated.app/api';
|
|
|
|
|
|
|
|
|
|
function ensureApproximatedConfig() {
|
|
|
|
|
const missing = [
|
|
|
|
|
['CLOUDFLARE_CUSTOM_HOSTNAMES_API_TOKEN', env.CLOUDFLARE_CUSTOM_HOSTNAMES_API_TOKEN],
|
|
|
|
|
['CLOUDFLARE_CUSTOM_HOSTNAMES_ZONE_ID', env.CLOUDFLARE_CUSTOM_HOSTNAMES_ZONE_ID],
|
|
|
|
|
['CLOUDFLARE_CUSTOM_HOSTNAME_CNAME_TARGET', env.CLOUDFLARE_CUSTOM_HOSTNAME_CNAME_TARGET]
|
|
|
|
|
['APPROXIMATED_API_KEY', env.APPROXIMATED_API_KEY],
|
|
|
|
|
['APPROXIMATED_TARGET_ADDRESS', env.APPROXIMATED_TARGET_ADDRESS]
|
|
|
|
|
].filter(([, value]) => !value);
|
|
|
|
|
|
|
|
|
|
if (missing.length > 0) {
|
|
|
|
|
throw new AppError(
|
|
|
|
|
`Missing Cloudflare custom domain config: ${missing.map(([key]) => key).join(', ')}`,
|
|
|
|
|
`Missing Approximated config: ${missing.map(([key]) => key).join(', ')}`,
|
|
|
|
|
ErrorCodes.INTERNAL_ERROR,
|
|
|
|
|
500
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getCloudflareBasePath() {
|
|
|
|
|
ensureCloudflareConfig();
|
|
|
|
|
return `https://api.cloudflare.com/client/v4/zones/${env.CLOUDFLARE_CUSTOM_HOSTNAMES_ZONE_ID}/custom_hostnames`;
|
|
|
|
|
}
|
|
|
|
|
async function approximatedRequest<T>(path: string, init: RequestInit = {}): Promise<T | null> {
|
|
|
|
|
ensureApproximatedConfig();
|
|
|
|
|
|
|
|
|
|
async function cloudflareRequest<T>(path: string, init: RequestInit = {}): Promise<T> {
|
|
|
|
|
const response = await fetch(`${getCloudflareBasePath()}${path}`, {
|
|
|
|
|
const response = await fetch(`${APPROXIMATED_BASE_URL}${path}`, {
|
|
|
|
|
...init,
|
|
|
|
|
headers: {
|
|
|
|
|
Authorization: `Bearer ${env.CLOUDFLARE_CUSTOM_HOSTNAMES_API_TOKEN}`,
|
|
|
|
|
'api-key': env.APPROXIMATED_API_KEY!,
|
|
|
|
|
'Content-Type': 'application/json',
|
|
|
|
|
Accept: 'application/json',
|
|
|
|
|
...(init.headers ?? {})
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const payload = (await response.json()) as CloudflareEnvelope<T>;
|
|
|
|
|
if (response.status === 404) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!response.ok || !payload.success) {
|
|
|
|
|
const text = await response.text();
|
|
|
|
|
const payload = text ? (JSON.parse(text) as unknown) : null;
|
|
|
|
|
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
const message =
|
|
|
|
|
payload.errors?.map((error) => error.message).join(', ') ||
|
|
|
|
|
payload.messages?.map((item) => item.message).join(', ') ||
|
|
|
|
|
'Cloudflare domain request failed';
|
|
|
|
|
(payload as { message?: string } | null)?.message ??
|
|
|
|
|
(payload as { error?: string } | null)?.error ??
|
|
|
|
|
`Approximated ${init.method ?? 'GET'} ${path} failed`;
|
|
|
|
|
|
|
|
|
|
throw new AppError(
|
|
|
|
|
message,
|
|
|
|
@@ -107,94 +92,47 @@ async function cloudflareRequest<T>(path: string, init: RequestInit = {}): Promi
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return payload.result;
|
|
|
|
|
return payload as T;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function dedupeDnsRecords(records: DomainDnsRecord[]) {
|
|
|
|
|
const seen = new Set<string>();
|
|
|
|
|
function buildDnsRecords(hostname: string, vhost?: ApproximatedVhost | null): DomainDnsRecord[] {
|
|
|
|
|
if (!vhost?.dns_pointed_at) return [];
|
|
|
|
|
|
|
|
|
|
return records.filter((record) => {
|
|
|
|
|
const key = `${record.type}:${record.name}:${record.value}`;
|
|
|
|
|
if (seen.has(key)) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
seen.add(key);
|
|
|
|
|
return true;
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function buildDnsRecords(hostname: string, customHostname?: CloudflareCustomHostname | null): DomainDnsRecord[] {
|
|
|
|
|
const records: DomainDnsRecord[] = [
|
|
|
|
|
return [
|
|
|
|
|
{
|
|
|
|
|
type: 'CNAME',
|
|
|
|
|
type: 'A',
|
|
|
|
|
name: hostname,
|
|
|
|
|
value: env.CLOUDFLARE_CUSTOM_HOSTNAME_CNAME_TARGET!,
|
|
|
|
|
status: customHostname?.status === 'active' ? 'active' : 'pending'
|
|
|
|
|
value: vhost.dns_pointed_at,
|
|
|
|
|
status: vhost.is_resolving ? 'active' : 'pending'
|
|
|
|
|
}
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
if (customHostname?.ownership_verification?.name && customHostname?.ownership_verification?.value) {
|
|
|
|
|
records.push({
|
|
|
|
|
type: 'TXT',
|
|
|
|
|
name: customHostname.ownership_verification.name,
|
|
|
|
|
value: customHostname.ownership_verification.value,
|
|
|
|
|
status: customHostname.status === 'active' ? 'active' : 'pending'
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
customHostname?.ssl?.validation_records?.forEach((record) => {
|
|
|
|
|
if (!record.txt_name || !record.txt_value) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
records.push({
|
|
|
|
|
type: 'TXT',
|
|
|
|
|
name: record.txt_name,
|
|
|
|
|
value: record.txt_value,
|
|
|
|
|
status: record.status === 'active' ? 'active' : 'pending'
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return dedupeDnsRecords(records);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function mapDomainStatus(customHostname?: CloudflareCustomHostname | null): DomainSetupStatus {
|
|
|
|
|
if (!customHostname) {
|
|
|
|
|
function mapDomainStatus(vhost?: ApproximatedVhost | null): DomainSetupStatus {
|
|
|
|
|
if (!vhost) {
|
|
|
|
|
return 'reconnect_required';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (customHostname.status === 'active' || customHostname.ssl?.status === 'active') {
|
|
|
|
|
if (vhost.has_ssl && vhost.is_resolving && vhost.apx_hit) {
|
|
|
|
|
return 'verified';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const hasOwnershipRecord = Boolean(
|
|
|
|
|
customHostname.ownership_verification?.name && customHostname.ownership_verification?.value
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (hasOwnershipRecord) {
|
|
|
|
|
if (!vhost.is_resolving) {
|
|
|
|
|
return 'pending_dns';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const hasSslValidationRecords = Boolean(
|
|
|
|
|
customHostname.ssl?.validation_records?.some((record) => record.txt_name && record.txt_value)
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (hasSslValidationRecords || customHostname.ssl?.status) {
|
|
|
|
|
return 'pending_verification';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return 'error';
|
|
|
|
|
// Resolving but cert/proxy still finalizing.
|
|
|
|
|
return 'pending_verification';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getStatusMessage(status: DomainSetupStatus) {
|
|
|
|
|
switch (status) {
|
|
|
|
|
case 'reconnect_required':
|
|
|
|
|
return 'Reconnect this custom domain to generate new Cloudflare setup records.';
|
|
|
|
|
return 'Reconnect this custom domain to generate new setup records.';
|
|
|
|
|
case 'pending_dns':
|
|
|
|
|
return 'Add the DNS records below, then refresh verification.';
|
|
|
|
|
return 'Add the DNS A record below, then refresh verification.';
|
|
|
|
|
case 'pending_verification':
|
|
|
|
|
return 'DNS records were found. SSL issuance or validation is still in progress.';
|
|
|
|
|
return 'DNS resolves correctly. SSL issuance is still in progress.';
|
|
|
|
|
case 'verified':
|
|
|
|
|
return 'Custom domain verified.';
|
|
|
|
|
case 'removed':
|
|
|
|
@@ -205,8 +143,8 @@ function getStatusMessage(status: DomainSetupStatus) {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function toDomainSetupResult(hostname: string, customHostname?: CloudflareCustomHostname | null): DomainSetupResult {
|
|
|
|
|
const status = mapDomainStatus(customHostname);
|
|
|
|
|
function toDomainSetupResult(hostname: string, vhost?: ApproximatedVhost | null): DomainSetupResult {
|
|
|
|
|
const status = mapDomainStatus(vhost);
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
hostname,
|
|
|
|
@@ -214,8 +152,9 @@ function toDomainSetupResult(hostname: string, customHostname?: CloudflareCustom
|
|
|
|
|
verified: status === 'verified',
|
|
|
|
|
reconnectRequired: status === 'reconnect_required',
|
|
|
|
|
message: getStatusMessage(status),
|
|
|
|
|
provider: 'cloudflare',
|
|
|
|
|
dnsRecords: status === 'reconnect_required' ? [] : buildDnsRecords(hostname, customHostname)
|
|
|
|
|
provider: 'approximated',
|
|
|
|
|
dnsRecords: status === 'reconnect_required' ? [] : buildDnsRecords(hostname, vhost),
|
|
|
|
|
validationErrors: []
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
@@ -238,74 +177,48 @@ export function assertSupportedCustomDomain(domain: string) {
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (domain.includes('classroomio.com') || domain.includes('classroomio.school')) {
|
|
|
|
|
if (domain.includes('classroomio.com') || domain.includes('myclassroomio.com')) {
|
|
|
|
|
throw new AppError("Domain cannot contain 'classroomio'", ErrorCodes.VALIDATION_ERROR, 400, 'domain');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function getCustomHostnameByDomain(hostname: string) {
|
|
|
|
|
const results = await cloudflareRequest<CloudflareCustomHostname[]>(`?hostname=${encodeURIComponent(hostname)}`, {
|
|
|
|
|
async function getVhost(hostname: string): Promise<ApproximatedVhost | null> {
|
|
|
|
|
return approximatedRequest<ApproximatedVhost>(`/vhosts/by/incoming/${encodeURIComponent(hostname)}`, {
|
|
|
|
|
method: 'GET'
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return results.find((result) => result.hostname.toLowerCase() === hostname.toLowerCase()) ?? null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function createCustomHostname(hostname: string) {
|
|
|
|
|
return cloudflareRequest<CloudflareCustomHostname>('', {
|
|
|
|
|
async function createVhost(hostname: string): Promise<ApproximatedVhost> {
|
|
|
|
|
const created = await approximatedRequest<ApproximatedVhost>('/vhosts', {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
body: JSON.stringify({
|
|
|
|
|
hostname,
|
|
|
|
|
ssl: {
|
|
|
|
|
method: 'txt',
|
|
|
|
|
type: 'dv'
|
|
|
|
|
}
|
|
|
|
|
incoming_address: hostname,
|
|
|
|
|
target_address: env.APPROXIMATED_TARGET_ADDRESS!,
|
|
|
|
|
target_ports: '443'
|
|
|
|
|
})
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function revalidateCustomHostname(customHostname: CloudflareCustomHostname) {
|
|
|
|
|
return cloudflareRequest<CloudflareCustomHostname>(`/${customHostname.id}`, {
|
|
|
|
|
method: 'PATCH',
|
|
|
|
|
body: JSON.stringify({
|
|
|
|
|
ssl: {
|
|
|
|
|
method: 'txt',
|
|
|
|
|
type: 'dv'
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
});
|
|
|
|
|
if (!created) {
|
|
|
|
|
throw new AppError('Approximated did not return a vhost', ErrorCodes.INTERNAL_ERROR, 500);
|
|
|
|
|
}
|
|
|
|
|
return created;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function connectDomain(hostname: string): Promise<DomainSetupResult> {
|
|
|
|
|
const existing = await getCustomHostnameByDomain(hostname);
|
|
|
|
|
const customHostname = existing ?? (await createCustomHostname(hostname));
|
|
|
|
|
|
|
|
|
|
return toDomainSetupResult(hostname, customHostname);
|
|
|
|
|
const existing = await getVhost(hostname);
|
|
|
|
|
const vhost = existing ?? (await createVhost(hostname));
|
|
|
|
|
return toDomainSetupResult(hostname, vhost);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function refreshDomain(hostname: string): Promise<DomainSetupResult> {
|
|
|
|
|
const existing = await getCustomHostnameByDomain(hostname);
|
|
|
|
|
|
|
|
|
|
if (!existing) {
|
|
|
|
|
return toDomainSetupResult(hostname, null);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (existing.status === 'active' || existing.ssl?.status === 'active') {
|
|
|
|
|
return toDomainSetupResult(hostname, existing);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const refreshed = await revalidateCustomHostname(existing);
|
|
|
|
|
return toDomainSetupResult(hostname, refreshed);
|
|
|
|
|
const vhost = await getVhost(hostname);
|
|
|
|
|
return toDomainSetupResult(hostname, vhost);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function removeDomain(hostname: string): Promise<DomainSetupResult> {
|
|
|
|
|
const existing = await getCustomHostnameByDomain(hostname);
|
|
|
|
|
|
|
|
|
|
if (existing) {
|
|
|
|
|
await cloudflareRequest<null>(`/${existing.id}`, {
|
|
|
|
|
method: 'DELETE'
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
await approximatedRequest<null>(`/vhosts/by/incoming/${encodeURIComponent(hostname)}`, {
|
|
|
|
|
method: 'DELETE'
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
hostname,
|
|
|
|
@@ -313,7 +226,8 @@ export async function removeDomain(hostname: string): Promise<DomainSetupResult>
|
|
|
|
|
verified: false,
|
|
|
|
|
reconnectRequired: false,
|
|
|
|
|
message: getStatusMessage('removed'),
|
|
|
|
|
provider: 'cloudflare',
|
|
|
|
|
dnsRecords: []
|
|
|
|
|
provider: 'approximated',
|
|
|
|
|
dnsRecords: [],
|
|
|
|
|
validationErrors: []
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|