diff --git a/.babelrc b/.babelrc new file mode 100644 index 0000000..4a7be0d --- /dev/null +++ b/.babelrc @@ -0,0 +1,4 @@ +{ + "presets": ["node6"], + "plugins": ["syntax-async-functions", "transform-async-to-generator", "transform-object-rest-spread"] +} diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000..e4853b9 --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,93 @@ +{ + "rules": { + "camelcase": 0, + "consistent-return": 0, + "curly": [ + 2, + "all" + ], + "dot-notation": 0, + "eol-last": 0, + "eqeqeq": [ + 2, + "always", + { + "null": "ignore" + } + ], + "handle-callback-err": 0, + "key-spacing": [ + 2, + { + "mode": "strict" + } + ], + "indent": [ + 2, + "tab", + { + "SwitchCase": 1 + } + ], + "object-shorthand": 0, + "one-var": 0, + "quotes": [ + 2, + "single" + ], + "semi": [ + 2, + "always" + ], + "strict": [ + 2, + "never" + ], + "wrap-iife": 0, + "new-cap": [ + 2, + { + "capIsNew": false + } + ], + "no-alert": 0, + "no-caller": 2, + "no-bitwise": 2, + "no-debugger": 2, + "no-empty": 2, + "no-eval": 2, + "no-extra-semi": 2, + "no-irregular-whitespace": 0, + "no-multi-spaces": 0, + "no-new": 2, + "no-plusplus": 0, + "no-process-exit": 0, + "no-redeclare": 2, + "no-shadow": 0, + "no-trailing-spaces": [ + 2, + { + "skipBlankLines": true + } + ], + "no-underscore-dangle": 0, + "no-undef": 2, + "no-unused-vars": [ + 2, + { + "vars": "local", + "args": "none" + } + ], + "no-use-before-define": 0 + }, + "env": { + "node": true, + "es6": true + }, + "parser": "babel-eslint", + "parserOptions": { + "ecmaVersion": 6, + "sourceType": "module" + } +} \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5148e52 --- /dev/null +++ b/.gitignore @@ -0,0 +1,37 @@ +# Logs +logs +*.log +npm-debug.log* + +# Runtime data +pids +*.pid +*.seed + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (http://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules +jspm_packages + +# Optional npm cache directory +.npm + +# Optional REPL history +.node_repl_history diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..1c10955 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,6 @@ +language: node_js +node_js: + - "7" + - "6" + - "5" + - "4" \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..8055ce6 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2017 [Debitoor](https://debitoor.com/) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..2e8fd13 --- /dev/null +++ b/README.md @@ -0,0 +1,41 @@ +# cloudflare-zone +Syncronizes a [zone bind file](https://en.wikipedia.org/wiki/Zone_file) with [Cloudflare](https://cloudflare.com/). + +[![Build Status](https://travis-ci.org/debitoor/cloudflare-zone.svg?branch=master)](https://travis-ci.org/debitoor/cloudflare-zone) +[![NPM Version](https://img.shields.io/npm/v/cloudflare-zone.svg)](https://www.npmjs.com/package/css-bingo) + +## Install +``` bash +$ npm install cloudflare-zone --save +``` + +## Usage + +``` bash +$ cloudflare-zone --file ./debitoor.com.bind --authEmail ... --authKey ... +``` + +Defaults to environment variables `CLOUDFLARE_AUTH_EMAIL` and `CLOUDFLARE_AUTH_KEY`. + +## License +MIT License + +Copyright (c) 2017 [Debitoor](https://debitoor.com/) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/bin/cloudflare-zone.js b/bin/cloudflare-zone.js new file mode 100644 index 0000000..7f023cd --- /dev/null +++ b/bin/cloudflare-zone.js @@ -0,0 +1,21 @@ +#!/usr/bin/env node +'use strict'; + +var _commandLineArgs = require('command-line-args'); + +var _commandLineArgs2 = _interopRequireDefault(_commandLineArgs); + +var _cloudflareZone = require('../lib/cloudflare-zone'); + +function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } + +const optionDefinitions = [{ name: 'file', type: String }, { name: 'authEmail', type: String, defaultValue: process.env.CLOUDFLARE_AUTH_EMAIL }, { name: 'authKey', type: String, defaultValue: process.env.CLOUDFLARE_AUTH_KEY }]; + +const options = (0, _commandLineArgs2.default)(optionDefinitions); + +(0, _cloudflareZone.main)(options).catch(err => { + console.error(err); + process.exit(1); +}).then(() => { + process.exit(0); +}); \ No newline at end of file diff --git a/lib/cloudflare-zone.js b/lib/cloudflare-zone.js new file mode 100644 index 0000000..3d7bfb3 --- /dev/null +++ b/lib/cloudflare-zone.js @@ -0,0 +1,309 @@ +'use strict'; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.main = undefined; + +let callCloudflareApi = (() => { + var _ref = _asyncToGenerator(function* ({ method, path, query, body, auth }) { + method = method || 'GET'; + + if (method === 'GET') { + query = query || {}; + query.per_page = 100; + } + + let queryString = query ? '?' + _qs2.default.stringify(query) : ''; + let url = `https://api.cloudflare.com${path}${queryString}`; + + console.log(`fetching: ${url}`); + + return (0, _nodeFetch2.default)(url, { + method, + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json', + 'X-Auth-Email': auth.email, + 'X-Auth-Key': auth.key + }, + body: body && typeof body === 'string' ? body : JSON.stringify(body) + }).then(parseResponseBody).then(handlePaging); + + function parseResponseBody(response) { + return response.json(); + } + + function handlePaging(body) { + if (body.errors.length > 0) { + throw body.errors; + } + + let result = body.result; + let { page, total_pages } = body.result_info || {}; + + if (page < total_pages) { + let nextPage = page + 1; + let nextQuery = Object.assign({}, query, { page: nextPage }); + + return callCloudflareApi({ method, path, query: nextQuery, body, auth }).then(nextResult => [...result, ...nextResult]); + } else { + return result; + } + } + }); + + return function callCloudflareApi(_x) { + return _ref.apply(this, arguments); + }; +})(); + +let getCloudflareZone = (() => { + var _ref2 = _asyncToGenerator(function* (name, auth) { + console.log('getCloudflareZone', name); + return callCloudflareApi({ + method: 'GET', + path: '/client/v4/zones', + query: { name }, + auth: auth + }).then(function (result) { + return result[0]; + }); + }); + + return function getCloudflareZone(_x2, _x3) { + return _ref2.apply(this, arguments); + }; +})(); + +let getCloudflareZoneDnsRecords = (() => { + var _ref3 = _asyncToGenerator(function* (cloudflareZoneId, auth) { + console.log('getCloudflareZoneDnsRecords', cloudflareZoneId); + return callCloudflareApi({ method: 'GET', path: `/client/v4/zones/${cloudflareZoneId}/dns_records`, auth: auth }); + }); + + return function getCloudflareZoneDnsRecords(_x4, _x5) { + return _ref3.apply(this, arguments); + }; +})(); + +let createCloudflareZoneDnsRecord = (() => { + var _ref4 = _asyncToGenerator(function* (cloudflareZoneId, parameters, auth) { + console.log('createCloudflareZoneDnsRecord', cloudflareZoneId, parameters); + return callCloudflareApi({ method: 'POST', path: `/client/v4/zones/${cloudflareZoneId}/dns_records`, body: parameters, auth: auth }); + }); + + return function createCloudflareZoneDnsRecord(_x6, _x7, _x8) { + return _ref4.apply(this, arguments); + }; +})(); + +let updateCloudflareZoneDnsRecord = (() => { + var _ref5 = _asyncToGenerator(function* (cloudflareZoneId, dnsRecordId, parameters, auth) { + console.log('updateCloudflareZoneDnsRecord', cloudflareZoneId, dnsRecordId, parameters); + return callCloudflareApi({ method: 'PUT', path: `/client/v4/zones/${cloudflareZoneId}/dns_records/${dnsRecordId}`, body: parameters, auth: auth }); + }); + + return function updateCloudflareZoneDnsRecord(_x9, _x10, _x11, _x12) { + return _ref5.apply(this, arguments); + }; +})(); + +let deleteCloudflareZoneDnsRecord = (() => { + var _ref6 = _asyncToGenerator(function* (cloudflareZoneId, dnsRecordId, auth) { + console.log('deleteCloudflareZoneDnsRecord', cloudflareZoneId, dnsRecordId); + return callCloudflareApi({ method: 'DELETE', path: `/client/v4/zones/${cloudflareZoneId}/dns_records/${dnsRecordId}`, auth: auth }); + }); + + return function deleteCloudflareZoneDnsRecord(_x13, _x14, _x15) { + return _ref6.apply(this, arguments); + }; +})(); + +// Alias records are not supported by dns-zonefile + + +let main = exports.main = (() => { + var _ref7 = _asyncToGenerator(function* ({ file, authEmail, authKey }) { + try { + const auth = { + email: authEmail, + key: authKey + }; + + let localZone = getLocalZone(file); + let localZoneDnsRecords = getLocalZoneDnsRecords(localZone); + let localZoneName = getLocalZoneName(localZone); + let cloudflareZone = yield getCloudflareZone(localZoneName, auth); + let cloudflareZoneId = cloudflareZone.id; + let cloudflareZoneDnsRecords = yield getCloudflareZoneDnsRecords(cloudflareZoneId, auth); + + // Create + let cloudflareZoneDnsRecordsToCreate = localZoneDnsRecords.reduce(function (cloudflareZoneDnsRecordsToCreate, localZoneDnsRecord) { + let cloudflareZoneDnsRecord = cloudflareZoneDnsRecords.find(function (cloudflareZoneDnsRecord) { + return compareDnsRecords(cloudflareZoneDnsRecord, localZoneDnsRecord); + }); + + if (cloudflareZoneDnsRecord === undefined) { + cloudflareZoneDnsRecordsToCreate.push(function () { + return createCloudflareZoneDnsRecord(cloudflareZoneId, { + type: localZoneDnsRecord.type, + name: localZoneDnsRecord.name, + priority: localZoneDnsRecord.priority, + content: localZoneDnsRecord.content, + ttl: localZoneDnsRecord.ttl + }, auth); + }); + } + + return cloudflareZoneDnsRecordsToCreate; + }, []); + + // Update + let cloudflareZoneDnsRecordsToUpdate = cloudflareZoneDnsRecords.reduce(function (cloudflareZoneDnsRecordsToUpdate, cloudflareZoneDnsRecord) { + let localZoneDnsRecord = localZoneDnsRecords.find(function (localZoneDnsRecord) { + return compareDnsRecords(cloudflareZoneDnsRecord, localZoneDnsRecord); + }); + + if (localZoneDnsRecord !== undefined && (cloudflareZoneDnsRecord.content !== localZoneDnsRecord.content || cloudflareZoneDnsRecord.ttl !== localZoneDnsRecord.ttl)) { + cloudflareZoneDnsRecordsToUpdate.push(function () { + return updateCloudflareZoneDnsRecord(cloudflareZoneId, cloudflareZoneDnsRecord.id, { + type: cloudflareZoneDnsRecord.type, + name: cloudflareZoneDnsRecord.name, + priority: localZoneDnsRecord.priority, + content: localZoneDnsRecord.content, + ttl: localZoneDnsRecord.ttl, + proxied: cloudflareZoneDnsRecord.proxied + }, auth); + }); + } + + return cloudflareZoneDnsRecordsToUpdate; + }, []); + + // Delete + let cloudflareZoneDnsRecordsToDelete = cloudflareZoneDnsRecords.reduce(function (cloudflareZoneDnsRecordsToDelete, cloudflareZoneDnsRecord) { + let localZoneDnsRecord = localZoneDnsRecords.find(function (localZoneDnsRecord) { + return compareDnsRecords(cloudflareZoneDnsRecord, localZoneDnsRecord); + }); + + if (localZoneDnsRecord === undefined) { + cloudflareZoneDnsRecordsToDelete.push(function () { + return deleteCloudflareZoneDnsRecord(cloudflareZoneId, cloudflareZoneDnsRecord.id, auth); + }); + } + + return cloudflareZoneDnsRecordsToDelete; + }, []); + + let tasks = [...cloudflareZoneDnsRecordsToCreate, ...cloudflareZoneDnsRecordsToUpdate, ...cloudflareZoneDnsRecordsToDelete].map(function (action) { + return action(); + }); + + yield Promise.all(tasks); + } catch (err) { + throw err; + } + }); + + return function main(_x16) { + return _ref7.apply(this, arguments); + }; +})(); + +var _fs = require('fs'); + +var _fs2 = _interopRequireDefault(_fs); + +var _nodeFetch = require('node-fetch'); + +var _nodeFetch2 = _interopRequireDefault(_nodeFetch); + +var _qs = require('qs'); + +var _qs2 = _interopRequireDefault(_qs); + +var _dnsZonefile = require('dns-zonefile'); + +var _dnsZonefile2 = _interopRequireDefault(_dnsZonefile); + +function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } + +function _asyncToGenerator(fn) { return function () { var gen = fn.apply(this, arguments); return new Promise(function (resolve, reject) { function step(key, arg) { try { var info = gen[key](arg); var value = info.value; } catch (error) { reject(error); return; } if (info.done) { resolve(value); } else { return Promise.resolve(value).then(function (value) { step("next", value); }, function (err) { step("throw", err); }); } } return step("next"); }); }; } + +function stripAliasRecords(string) { + return string.split(/[\r\n]/g).filter(function (record) { + return record.indexOf(' ALIAS ') === -1; + }).join('\n'); +} + +function removeTrailingDot(str) { + return str.replace(/\.$/, ''); +} + +function fixName(name, zoneName) { + return (name + '.' + zoneName).replace(/^@./, '').toLowerCase(); +} + +function getLocalZone(zoneFile) { + let zoneFileContents = stripAliasRecords(_fs2.default.readFileSync(zoneFile, 'utf8')); + let zone = _dnsZonefile2.default.parse(zoneFileContents); + + return zone; +} + +function getLocalZoneName(zone) { + return removeTrailingDot(zone.$origin); +} + +function getLocalZoneDnsRecords(zone) { + let dnsRecords = []; + let zoneName = removeTrailingDot(zone.$origin); + + zone.a && zone.a.forEach(a => { + dnsRecords.push({ + type: 'A', + name: fixName(a.name, zoneName), + content: a.ip, + ttl: a.ttl + }); + }); + + zone.cname && zone.cname.forEach(cname => { + dnsRecords.push({ + type: 'CNAME', + name: fixName(cname.name, zoneName), + content: removeTrailingDot(cname.alias).toLowerCase(), + ttl: cname.ttl + }); + }); + + zone.mx && zone.mx.forEach(mx => { + dnsRecords.push({ + type: 'MX', + name: fixName(mx.name, zoneName), + priority: mx.preference, + content: removeTrailingDot(mx.host).toLowerCase(), + ttl: mx.ttl + }); + }); + + zone.txt && zone.txt.forEach(txt => { + dnsRecords.push({ + type: 'TXT', + name: fixName(txt.name, zoneName), + content: txt.txt, + ttl: txt.ttl + }); + }); + + return dnsRecords; +} + +function compareDnsRecords(a, b) { + if (a.type === 'MX' && b.type === 'MX') { + return a.name === b.name && a.content === b.content; + } else { + return a.type === b.type && a.name === b.name; + } +} \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..c1bb04d --- /dev/null +++ b/package.json @@ -0,0 +1,48 @@ +{ + "name": "cloudflare-zone", + "version": "0.0.0", + "description": "Cloudflare Zone Updater", + "scripts": { + "build": "babel src --out-dir .", + "postversion": "git push && git push --tags", + "preversion": "npm test", + "test": "mocha" + }, + "bin": { + "cloudflare-zone": "bin/cloudflare-zone.js" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/debitoor/cloudflare-zone.git" + }, + "keywords": [ + "cloudflare", + "zone", + "upload", + "bind", + "sync" + ], + "main": "lib/", + "author": "Jonatan Pedersen", + "license": "MIT", + "bugs": { + "url": "https://github.com/debitoor/cloudflare-zone/issues" + }, + "homepage": "https://github.com/debitoor/cloudflare-zone#readme", + "devDependencies": { + "babel-cli": "6.24.1", + "babel-eslint": "^7.2.3", + "babel-plugin-syntax-async-functions": "6.13.0", + "babel-plugin-transform-async-to-generator": "6.24.1", + "babel-plugin-transform-object-rest-spread": "6.23.0", + "babel-preset-node6": "11.0.0", + "mocha": "3.4.1", + "mocha-eslint": "3.0.1" + }, + "dependencies": { + "command-line-args": "4.0.4", + "dns-zonefile": "0.2.2", + "node-fetch": "1.6.3", + "qs": "6.4.0" + } +} diff --git a/src/bin/cloudflare-zone.js b/src/bin/cloudflare-zone.js new file mode 100644 index 0000000..ff0f802 --- /dev/null +++ b/src/bin/cloudflare-zone.js @@ -0,0 +1,19 @@ +#!/usr/bin/env node +import commandLineArgs from 'command-line-args'; +import { main } from '../lib/cloudflare-zone'; + +const optionDefinitions = [ + { name: 'file', type: String }, + { name: 'authEmail', type: String, defaultValue: process.env.CLOUDFLARE_AUTH_EMAIL }, + { name: 'authKey', type: String, defaultValue: process.env.CLOUDFLARE_AUTH_KEY } +]; + +const options = commandLineArgs(optionDefinitions); + +main(options) + .catch(err => { + console.error(err); + process.exit(1); + }).then(() => { + process.exit(0); + }); diff --git a/src/lib/cloudflare-zone.js b/src/lib/cloudflare-zone.js new file mode 100644 index 0000000..22c1b72 --- /dev/null +++ b/src/lib/cloudflare-zone.js @@ -0,0 +1,237 @@ +import fs from 'fs'; +import nodeFetch from 'node-fetch'; +import qs from 'qs'; +import dnsZonefile from 'dns-zonefile'; + +async function callCloudflareApi ({method, path, query, body, auth}) { + method = method || 'GET'; + + if (method === 'GET') { + query = query || {}; + query.per_page = 100; + } + + let queryString = query ? '?' + qs.stringify(query) : ''; + let url = `https://api.cloudflare.com${path}${queryString}`; + + console.log(`fetching: ${url}`); + + return nodeFetch(url, { + method, + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json', + 'X-Auth-Email': auth.email, + 'X-Auth-Key': auth.key + }, + body: body && typeof body === 'string' ? body : JSON.stringify(body) + }) + .then(parseResponseBody) + .then(handlePaging); + + function parseResponseBody (response) { + return response.json(); + } + + function handlePaging (body) { + if (body.errors.length > 0) { + throw body.errors; + } + + let result = body.result; + let {page, total_pages} = body.result_info || {}; + + if (page < total_pages) { + let nextPage = page + 1; + let nextQuery = Object.assign({}, query, {page: nextPage}); + + return callCloudflareApi({method, path, query: nextQuery, body, auth}) + .then(nextResult => [...result, ...nextResult]); + } else { + return result; + } + } +} + +async function getCloudflareZone (name, auth) { + console.log('getCloudflareZone', name); + return callCloudflareApi({ + method: 'GET', + path: '/client/v4/zones', + query: {name}, + auth: auth + }).then(result => result[0]); +} + +async function getCloudflareZoneDnsRecords (cloudflareZoneId, auth) { + console.log('getCloudflareZoneDnsRecords', cloudflareZoneId); + return callCloudflareApi({method: 'GET', path: `/client/v4/zones/${cloudflareZoneId}/dns_records`, auth: auth}); +} + +async function createCloudflareZoneDnsRecord (cloudflareZoneId, parameters, auth) { + console.log('createCloudflareZoneDnsRecord', cloudflareZoneId, parameters); + return callCloudflareApi({method: 'POST', path: `/client/v4/zones/${cloudflareZoneId}/dns_records`, body: parameters, auth: auth}); +} + +async function updateCloudflareZoneDnsRecord (cloudflareZoneId, dnsRecordId, parameters, auth) { + console.log('updateCloudflareZoneDnsRecord', cloudflareZoneId, dnsRecordId, parameters); + return callCloudflareApi({method: 'PUT', path: `/client/v4/zones/${cloudflareZoneId}/dns_records/${dnsRecordId}`, body: parameters, auth: auth}); +} + +async function deleteCloudflareZoneDnsRecord (cloudflareZoneId, dnsRecordId, auth) { + console.log('deleteCloudflareZoneDnsRecord', cloudflareZoneId, dnsRecordId); + return callCloudflareApi({method: 'DELETE', path: `/client/v4/zones/${cloudflareZoneId}/dns_records/${dnsRecordId}`, auth: auth}); +} + +// Alias records are not supported by dns-zonefile +function stripAliasRecords(string) { + return string + .split(/[\r\n]/g) + .filter(function(record) { + return record.indexOf(' ALIAS ') === -1; + }) + .join('\n'); +} + +function removeTrailingDot (str) { + return str.replace(/\.$/, ''); +} + +function fixName (name, zoneName) { + return (name + '.' + zoneName).replace(/^@./, '').toLowerCase(); +} + +function getLocalZone (zoneFile) { + let zoneFileContents = stripAliasRecords(fs.readFileSync(zoneFile, 'utf8')); + let zone = dnsZonefile.parse(zoneFileContents); + + return zone; +} + +function getLocalZoneName (zone) { + return removeTrailingDot(zone.$origin); +} + +function getLocalZoneDnsRecords (zone) { + let dnsRecords = []; + let zoneName = removeTrailingDot(zone.$origin); + + zone.a && zone.a.forEach(a => { + dnsRecords.push({ + type: 'A', + name: fixName(a.name, zoneName), + content: a.ip, + ttl: a.ttl + }); + }); + + zone.cname && zone.cname.forEach(cname => { + dnsRecords.push({ + type: 'CNAME', + name: fixName(cname.name, zoneName), + content: removeTrailingDot(cname.alias).toLowerCase(), + ttl: cname.ttl + }); + }); + + zone.mx && zone.mx.forEach(mx => { + dnsRecords.push({ + type: 'MX', + name: fixName(mx.name, zoneName), + priority: mx.preference, + content: removeTrailingDot(mx.host).toLowerCase(), + ttl: mx.ttl + }); + }); + + zone.txt && zone.txt.forEach(txt => { + dnsRecords.push({ + type: 'TXT', + name: fixName(txt.name, zoneName), + content: txt.txt, + ttl: txt.ttl + }); + }); + + return dnsRecords; +} + +function compareDnsRecords(a, b) { + if (a.type === 'MX' && b.type === 'MX') { + return a.name === b.name && a.content === b.content; + } else { + return a.type === b.type && a.name === b.name; + } +} + +export async function main ({file, authEmail, authKey}) { + try { + const auth = { + email: authEmail, + key: authKey + }; + + let localZone = getLocalZone(file); + let localZoneDnsRecords = getLocalZoneDnsRecords(localZone); + let localZoneName = getLocalZoneName(localZone); + let cloudflareZone = await getCloudflareZone(localZoneName, auth); + let cloudflareZoneId = cloudflareZone.id; + let cloudflareZoneDnsRecords = await getCloudflareZoneDnsRecords(cloudflareZoneId, auth); + + // Create + let cloudflareZoneDnsRecordsToCreate = localZoneDnsRecords.reduce((cloudflareZoneDnsRecordsToCreate, localZoneDnsRecord) => { + let cloudflareZoneDnsRecord = cloudflareZoneDnsRecords.find(cloudflareZoneDnsRecord => compareDnsRecords(cloudflareZoneDnsRecord, localZoneDnsRecord)); + + if (cloudflareZoneDnsRecord === undefined) { + cloudflareZoneDnsRecordsToCreate.push(() => createCloudflareZoneDnsRecord(cloudflareZoneId, { + type: localZoneDnsRecord.type, + name: localZoneDnsRecord.name, + priority: localZoneDnsRecord.priority, + content: localZoneDnsRecord.content, + ttl: localZoneDnsRecord.ttl + }, auth)); + } + + return cloudflareZoneDnsRecordsToCreate; + }, []); + + // Update + let cloudflareZoneDnsRecordsToUpdate = cloudflareZoneDnsRecords.reduce((cloudflareZoneDnsRecordsToUpdate, cloudflareZoneDnsRecord) => { + let localZoneDnsRecord = localZoneDnsRecords.find(localZoneDnsRecord => compareDnsRecords(cloudflareZoneDnsRecord, localZoneDnsRecord)); + + if (localZoneDnsRecord !== undefined && (cloudflareZoneDnsRecord.content !== localZoneDnsRecord.content || cloudflareZoneDnsRecord.ttl !== localZoneDnsRecord.ttl)) { + cloudflareZoneDnsRecordsToUpdate.push(() => updateCloudflareZoneDnsRecord(cloudflareZoneId, cloudflareZoneDnsRecord.id, { + type: cloudflareZoneDnsRecord.type, + name: cloudflareZoneDnsRecord.name, + priority: localZoneDnsRecord.priority, + content: localZoneDnsRecord.content, + ttl: localZoneDnsRecord.ttl, + proxied: cloudflareZoneDnsRecord.proxied + }, auth)); + } + + return cloudflareZoneDnsRecordsToUpdate; + }, []); + + // Delete + let cloudflareZoneDnsRecordsToDelete = cloudflareZoneDnsRecords.reduce((cloudflareZoneDnsRecordsToDelete, cloudflareZoneDnsRecord) => { + let localZoneDnsRecord = localZoneDnsRecords.find(localZoneDnsRecord => compareDnsRecords(cloudflareZoneDnsRecord, localZoneDnsRecord)); + + if (localZoneDnsRecord === undefined) { + cloudflareZoneDnsRecordsToDelete.push(() => deleteCloudflareZoneDnsRecord(cloudflareZoneId, cloudflareZoneDnsRecord.id, auth)); + } + + return cloudflareZoneDnsRecordsToDelete; + }, []); + + let tasks = [ + ...cloudflareZoneDnsRecordsToCreate, + ...cloudflareZoneDnsRecordsToUpdate, + ...cloudflareZoneDnsRecordsToDelete + ].map(action => action()); + + await Promise.all(tasks); + } catch (err){ + throw err; + } +} diff --git a/test/eslint.spec.js b/test/eslint.spec.js new file mode 100644 index 0000000..5d3e210 --- /dev/null +++ b/test/eslint.spec.js @@ -0,0 +1,6 @@ +const mochaEslint = require('mocha-eslint'); + +mochaEslint([ + './src/**/*.js', + './test/**/*.js' +]); \ No newline at end of file diff --git a/test/mocha.opts b/test/mocha.opts new file mode 100644 index 0000000..44c9f40 --- /dev/null +++ b/test/mocha.opts @@ -0,0 +1,2 @@ +--timeout 20000 +./test/**/*.spec.js