239 lines
6.2 KiB
TypeScript
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);
|