UNPKG

cloudflare-ddns-sync

Version:

A simple module to update DNS records on Cloudflare whenever you want

224 lines (223 loc) 12.2 kB
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(); } }