cloudflare-ddns-sync
Version:
A simple module to update DNS records on Cloudflare whenever you want
224 lines (223 loc) • 12.2 kB
JavaScript
import { ParseResultType, fromUrl, parseDomain } from 'parse-domain';
import Cloudflare from 'cloudflare';
import IPUtils from './ip-utils.js';
const ipv4Regex = /^(([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\.){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])$/u;
const ipv6Regex = /^(?:(?:(?:(?:(?:(?:(?:[0-9a-fA-F]{1,4})):){6})(?:(?:(?:(?:(?:[0-9a-fA-F]{1,4})):(?:(?:[0-9a-fA-F]{1,4})))|(?:(?:(?:(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9]))\.){3}(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9])))))))|(?:(?:::(?:(?:(?:[0-9a-fA-F]{1,4})):){5})(?:(?:(?:(?:(?:[0-9a-fA-F]{1,4})):(?:(?:[0-9a-fA-F]{1,4})))|(?:(?:(?:(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9]))\.){3}(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9])))))))|(?:(?:(?:(?:(?:[0-9a-fA-F]{1,4})))?::(?:(?:(?:[0-9a-fA-F]{1,4})):){4})(?:(?:(?:(?:(?:[0-9a-fA-F]{1,4})):(?:(?:[0-9a-fA-F]{1,4})))|(?:(?:(?:(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9]))\.){3}(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9])))))))|(?:(?:(?:(?:(?:(?:[0-9a-fA-F]{1,4})):){0,1}(?:(?:[0-9a-fA-F]{1,4})))?::(?:(?:(?:[0-9a-fA-F]{1,4})):){3})(?:(?:(?:(?:(?:[0-9a-fA-F]{1,4})):(?:(?:[0-9a-fA-F]{1,4})))|(?:(?:(?:(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9]))\.){3}(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9])))))))|(?:(?:(?:(?:(?:(?:[0-9a-fA-F]{1,4})):){0,2}(?:(?:[0-9a-fA-F]{1,4})))?::(?:(?:(?:[0-9a-fA-F]{1,4})):){2})(?:(?:(?:(?:(?:[0-9a-fA-F]{1,4})):(?:(?:[0-9a-fA-F]{1,4})))|(?:(?:(?:(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9]))\.){3}(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9])))))))|(?:(?:(?:(?:(?:(?:[0-9a-fA-F]{1,4})):){0,3}(?:(?:[0-9a-fA-F]{1,4})))?::(?:(?:[0-9a-fA-F]{1,4})):)(?:(?:(?:(?:(?:[0-9a-fA-F]{1,4})):(?:(?:[0-9a-fA-F]{1,4})))|(?:(?:(?:(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9]))\.){3}(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9])))))))|(?:(?:(?:(?:(?:(?:[0-9a-fA-F]{1,4})):){0,4}(?:(?:[0-9a-fA-F]{1,4})))?::)(?:(?:(?:(?:(?:[0-9a-fA-F]{1,4})):(?:(?:[0-9a-fA-F]{1,4})))|(?:(?:(?:(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9]))\.){3}(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9])))))))|(?:(?:(?:(?:(?:(?:[0-9a-fA-F]{1,4})):){0,5}(?:(?:[0-9a-fA-F]{1,4})))?::)(?:(?:[0-9a-fA-F]{1,4})))|(?:(?:(?:(?:(?:(?:[0-9a-fA-F]{1,4})):){0,6}(?:(?:[0-9a-fA-F]{1,4})))?::))))$/u;
export default class CloudflareClient {
cloudflare;
zoneMap = new Map();
constructor(auth) {
this.cloudflare = new Cloudflare(auth);
this.updateZoneMap();
}
async syncRecord(record, ip) {
const recordIds = await this.getRecordIdsForRecords([record]);
const ipToUse = ip ? ip : await IPUtils.getIpv4();
const zoneId = await this.getZoneIdByRecordName(record.name);
const recordId = recordIds.get(this.getRecordIdMapKey(record));
const recordExists = recordId !== undefined;
if (recordExists) {
const result = await this.updateRecord(zoneId, recordId, record, ipToUse);
return result;
}
const result = await this.createRecord(zoneId, record, ipToUse);
return result;
}
async syncRecords(records, ip) {
const recordIds = await this.getRecordIdsForRecords(records);
const ipToUse = ip ? ip : await IPUtils.getIpv4();
const resultPromises = records.map(async (record) => {
const zoneId = await this.getZoneIdByRecordName(record.name);
const recordId = recordIds.get(this.getRecordIdMapKey(record));
const recordExists = recordId !== undefined;
if (recordExists) {
const currentResult = await this.updateRecord(zoneId, recordId, record, ipToUse);
return currentResult;
}
const currentResult = await this.createRecord(zoneId, record, ipToUse);
return currentResult;
});
const results = await Promise.all(resultPromises);
return results;
}
async removeRecordByNameAndType(recordName, recordType) {
const recordTypeToUse = recordType ? recordType : 'A';
const zoneId = await this.getZoneIdByRecordName(recordName);
const recordId = await this.getRecordIdByNameAndType(recordName, recordTypeToUse);
await this.cloudflare.dnsRecords.del(zoneId, recordId);
}
async getRecordDataForRecord(record) {
const domain = this.getDomainByRecordName(record.name);
const recordDataForDomain = await this.getRecordsByDomain(domain);
const recordData = recordDataForDomain.find((singleRecordData) => record.name.toLowerCase() === singleRecordData.name.toLowerCase());
return recordData;
}
async getRecordDataForRecords(records) {
const domains = this.getDomainsFromRecords(records);
const recordDataPromises = domains.map(async (domain) => {
const recordDataForDomain = await this.getRecordsByDomain(domain);
const recordDataForDomainFilteredByRecords = recordDataForDomain.filter((singleRecordData) => records.some((record) => record.name.toLowerCase() === singleRecordData.name.toLowerCase()));
return recordDataForDomainFilteredByRecords;
});
const recordDataForDomains = await Promise.all(recordDataPromises);
const recordData = [].concat(...recordDataForDomains);
return recordData;
}
async getRecordDataForDomains(domains) {
const recordDataPromises = domains.map((domain) => this.getRecordDataForDomain(domain));
const recordDataForDomains = await Promise.all(recordDataPromises);
const recordData = {};
recordDataForDomains.forEach((recordDataForDomain, index) => {
recordData[domains[index]] = recordDataForDomain;
});
return recordData;
}
async getRecordDataForDomain(domain) {
const recordData = await this.getRecordsByDomain(domain);
return recordData;
}
async createRecord(zoneId, record, ip) {
const dnsRecord = {
...record,
name: record.name.toLowerCase(),
content: record.content ? record.content : ip,
type: record.type ? record.type : 'A',
ttl: record.ttl ? record.ttl : 1,
};
if (!dnsRecord.content) {
throw Error(`Could not create Record "${dnsRecord.name}": Content is missing!`);
}
if (dnsRecord.type === 'A') {
if (!dnsRecord.content.match(ipv4Regex)) {
throw Error(`Could not create Record "${dnsRecord.name}": '${dnsRecord.content}' is not a valid ipv4!`);
}
}
else if (dnsRecord.type === 'AAAA') {
if (!dnsRecord.content.match(ipv6Regex)) {
throw Error(`Could not create Record "${dnsRecord.name}": '${dnsRecord.content}' is not a valid ipv6!`);
}
}
else if (dnsRecord.type === 'CNAME') {
const parsedDomain = parseDomain(fromUrl(dnsRecord.content));
if (parsedDomain.type !== ParseResultType.Listed || !parsedDomain.domain) {
throw Error(`Could not create Record "${dnsRecord.name}": '${dnsRecord.content}' is not a valid domain name!`);
}
}
const response = (await this.cloudflare.dnsRecords.add(zoneId, dnsRecord));
return response.result;
}
async updateRecord(zoneId, recordId, record, ip) {
const dnsRecord = {
...record,
name: record.name.toLowerCase(),
content: record.content ? record.content : ip,
type: record.type ? record.type : 'A',
ttl: record.ttl ? record.ttl : 1,
};
if (!dnsRecord.content) {
throw Error(`Could not update Record "${dnsRecord.name}": Content is missing!`);
}
if (dnsRecord.type === 'A') {
if (!dnsRecord.content.match(ipv4Regex)) {
throw Error(`Could not update Record "${dnsRecord.name}": '${dnsRecord.content}' is not a valid ipv4!`);
}
}
else if (dnsRecord.type === 'AAAA') {
if (!dnsRecord.content.match(ipv6Regex)) {
throw Error(`Could not update Record "${dnsRecord.name}": '${dnsRecord.content}' is not a valid ipv6!`);
}
}
else if (dnsRecord.type === 'CNAME') {
const parsedDomain = parseDomain(fromUrl(dnsRecord.content));
if (parsedDomain.type !== ParseResultType.Listed || !parsedDomain.domain) {
throw Error(`Could not update Record "${dnsRecord.name}": '${dnsRecord.content}' is not a valid domain name!`);
}
}
const response = (await this.cloudflare.dnsRecords.edit(zoneId, recordId, dnsRecord));
return response.result;
}
async updateZoneMap() {
const response = (await this.cloudflare.zones.browse());
const zones = response.result;
this.zoneMap = new Map();
for (const zone of zones) {
this.zoneMap.set(zone.name, zone.id);
}
}
async getRecordIdByNameAndType(recordName, recordType) {
const record = await this.getRecordByNameAndType(recordName, recordType);
return record.id;
}
getZoneIdByRecordName(recordName) {
const domain = this.getDomainByRecordName(recordName);
return this.getZoneIdByDomain(domain);
}
async getRecordByNameAndType(recordName, recordType) {
const domain = this.getDomainByRecordName(recordName);
const records = await this.getRecordsByDomain(domain);
const record = records.find((currentRecord) => currentRecord.name.toLowerCase() === recordName.toLowerCase() && currentRecord.type.toLowerCase() === recordType.toLowerCase());
const recordNotFound = record === undefined;
if (recordNotFound) {
throw new Error(`Record '${recordName}' not found.`);
}
return record;
}
async getRecordIdsForRecords(records) {
const recordIdMap = new Map();
const recordData = await this.getRecordDataForRecords(records);
for (const record of recordData) {
recordIdMap.set(this.getRecordIdMapKey(record), record.id);
}
return recordIdMap;
}
getRecordIdMapKey(record) {
const recordName = record.name.toLowerCase();
const recordType = record.type ? record.type.toLowerCase() : 'a';
return `"${recordName}"_"${recordType}"`;
}
async getRecordsByDomain(domain) {
const zoneId = await this.getZoneIdByDomain(domain);
const records = [];
let pageIndex = 1;
let allRecordsFound = false;
const recordsPerPage = 5000;
while (!allRecordsFound) {
const response = (await this.cloudflare.dnsRecords.browse(zoneId, {
page: pageIndex,
per_page: recordsPerPage,
}));
records.push(...response.result);
allRecordsFound = response.result.length < recordsPerPage;
pageIndex++;
}
return records;
}
async getZoneIdByDomain(domain) {
if (this.zoneMap.has(domain)) {
const zoneId = this.zoneMap.get(domain.toLowerCase());
return zoneId;
}
await this.updateZoneMap();
if (!this.zoneMap.has(domain)) {
throw new Error(`Could not find domain '${domain}'. Make sure the domain is set up for your cloudflare account.`);
}
const zoneId = this.zoneMap.get(domain.toLowerCase());
return zoneId;
}
getDomainsFromRecords(records) {
const domains = records.map((record) => this.getDomainByRecordName(record.name)).filter((domain, index, domainList) => domainList.indexOf(domain.toLowerCase()) === index);
return domains;
}
getDomainByRecordName(recordName) {
const parsedDomain = parseDomain(fromUrl(recordName));
if (parsedDomain.type !== ParseResultType.Listed || !parsedDomain.domain) {
throw new Error(`Could not parse domain. '${JSON.stringify(recordName)}' is not a valid record name.`);
}
let domain = '';
domain += parsedDomain.domain;
for (const tld of parsedDomain.topLevelDomains) {
domain += `.${tld}`;
}
return domain.toLowerCase();
}
}