2
0
Files
2024-08-09 00:39:27 +02:00

239 lines
6.2 KiB
TypeScript

import z from "zod";
import { HttpError } from "@calcom/lib/http-error";
import { safeStringify } from "@calcom/lib/safeStringify";
import logger from "../../logger";
const log = logger.getSubLogger({ prefix: ["cloudflare"] });
// TODO: This and other settings should really come from DB when admin allows configuring which deployment services to use for the organization
const IS_RECORD_PROXIED = true;
const AUTOMATIC_TTL = 1;
const ERROR_CODE_CNAME_ALREADY_EXISTS = 81053;
const ERROR_CODE_RECORD_ALREADY_EXISTS = 81057;
const ERROR_CODE_RECORD_DOES_NOT_EXIST = 81044;
const cloudflareApiForZoneUrl = `https://api.cloudflare.com/client/v4/zones/${process.env.CLOUDFLARE_ZONE_ID}`;
const cloudflareDnsRecordApiResponseSchema = z
.object({
success: z.boolean().optional(),
errors: z
.array(
z
.object({
code: z.number(),
})
.passthrough()
)
.nullish(),
result: z
.object({
id: z.string(),
})
.nullish(),
})
.passthrough();
export const addDnsRecord = async (domain: string) => {
assertCloudflareEnvVars(process.env);
log.info(`Creating dns-record in Cloudflare: ${domain}`);
const data = await api(
`${cloudflareApiForZoneUrl}/dns_records`,
{
method: "POST",
body: JSON.stringify({
type: "CNAME",
proxied: IS_RECORD_PROXIED,
name: domain,
content: process.env.CLOUDFLARE_VERCEL_CNAME,
ttl: AUTOMATIC_TTL,
}),
},
cloudflareDnsRecordApiResponseSchema
);
if (!data.success) {
if (isRecordAlreadyExistError(data.errors)) {
log.info(`CNAME already exists in Cloudflare: ${domain}`);
return true;
}
const errorMessage = `Failed to create dns-record in Cloudflare: ${domain}`;
log.error(
safeStringify({
errorMessage,
response: data,
})
);
throw new HttpError({
message: errorMessage,
statusCode: 400,
});
}
log.info(`Created dns-record in Cloudflare: ${domain}`);
return true;
};
export const deleteDnsRecord = async (domain: string) => {
log.info(`Deleting dns-record in Cloudflare: ${domain}`);
assertCloudflareEnvVars(process.env);
const dnsRecordToDelete = await getDnsRecordToDelete();
if (dnsRecordToDelete) {
await deleteDnsRecord(dnsRecordToDelete);
log.info(`Deleted dns-record in Cloudflare: ${domain}`);
} else {
log.info(`CNAME not found in Cloudflare: ${domain}. Nothing to delete`);
}
return true;
async function getDnsRecordToDelete() {
// Get the dns-record id from dns_records list API
const searchResult = await api(
`${cloudflareApiForZoneUrl}/dns_records?name=${domain}`,
{
method: "GET",
},
z
.object({
success: z.boolean().optional(),
result: z
.array(
z
.object({
id: z.string(),
})
.passthrough()
)
.nullish(),
})
.passthrough()
);
if (!searchResult.success || !searchResult.result) {
log.error(
safeStringify({
errorMessage: `Failed to search for dns-record in Cloudflare for ${domain}`,
searchData: searchResult,
})
);
throw new HttpError({
message: `Something went wrong.`,
statusCode: 500,
});
}
if (searchResult.result.length > 1) {
log.error(
safeStringify({
errorMessage: `Found more than one dns-record in Cloudflare for ${domain}`,
searchData: searchResult,
})
);
throw new HttpError({
message: `Something went wrong.`,
statusCode: 400,
});
}
return searchResult.result[0] as (typeof searchResult.result)[0] | null;
}
async function deleteDnsRecord(dnsRecordToDelete: { id: string }) {
const deletionResult = await api(
`${cloudflareApiForZoneUrl}/dns_records/${dnsRecordToDelete.id}`,
{
method: "DELETE",
},
cloudflareDnsRecordApiResponseSchema
);
if (!deletionResult.success) {
if (isRecordNotExistingError(deletionResult.errors)) {
log.info(`CNAME already deleted: ${domain}`);
return true;
}
log.error(
`Failed to delete dns-record in Cloudflare: ${domain}`,
safeStringify({
deletionResult,
})
);
throw new HttpError({
message: "Something went wrong.",
statusCode: 400,
});
}
log.info(`Deleted dns-record in Cloudflare: ${domain}`);
return true;
}
};
async function api<T extends z.ZodType<unknown>>(
url: string,
{
method,
body,
}: {
body?: string;
method: "POST" | "GET" | "DELETE";
},
responseSchema: T
): Promise<z.infer<T>> {
const response = await fetch(url, {
method: method,
headers: {
Authorization: `Bearer ${process.env.AUTH_BEARER_TOKEN_CLOUDFLARE}`,
"Content-Type": "application/json",
},
body,
});
const result = await response.json();
const dataParsed = responseSchema.safeParse(result);
if (!dataParsed.success) {
log.error(
"Error parsing",
safeStringify({
dnsAddResult: result,
})
);
throw new HttpError({
message: "Something went wrong",
statusCode: 500,
});
}
return dataParsed.data;
}
function assertCloudflareEnvVars(env: typeof process.env): asserts env is {
CLOUDFLARE_VERCEL_CNAME: string;
CLOUDFLARE_ZONE_ID: string;
AUTH_BEARER_TOKEN_CLOUDFLARE: string;
} & typeof process.env {
if (!env.CLOUDFLARE_VERCEL_CNAME) {
throw new Error("Missing env var: CLOUDFLARE_VERCEL_CNAME");
}
if (!env.CLOUDFLARE_ZONE_ID) {
throw new Error("Missing env var: CLOUDFLARE_ZONE_ID");
}
if (!env.AUTH_BEARER_TOKEN_CLOUDFLARE) {
throw new Error("Missing env var: AUTH_BEARER_TOKEN_CLOUDFLARE");
}
}
const isRecordAlreadyExistError = (errors: { code: number }[] | undefined | null) =>
errors?.every(
(error) =>
error.code === ERROR_CODE_CNAME_ALREADY_EXISTS || error.code === ERROR_CODE_RECORD_ALREADY_EXISTS
);
const isRecordNotExistingError = (errors: { code: number }[] | undefined | null) =>
errors?.every((error) => error.code === ERROR_CODE_RECORD_DOES_NOT_EXIST);