diff --git a/CHANGES b/CHANGES index 47766e294..49929586a 100644 --- a/CHANGES +++ b/CHANGES @@ -1838,4 +1838,5 @@ [5.0.4] * Fix potential previlige escalation because of ghost file +* linode: dns backend diff --git a/src/apptask.js b/src/apptask.js index e85c2f2c1..b623765d8 100644 --- a/src/apptask.js +++ b/src/apptask.js @@ -431,12 +431,12 @@ function waitForDnsPropagation(app, callback) { sysinfo.getServerIp(function (error, ip) { if (error) return callback(new BoxError(BoxError.NETWORK_ERROR, `Error getting public IP: ${error.message}`)); - domains.waitForDnsRecord(app.location, app.domain, 'A', ip, { interval: 5000, times: 240 }, function (error) { + domains.waitForDnsRecord(app.location, app.domain, 'A', ip, { times: 240 }, function (error) { if (error) return callback(new BoxError(BoxError.DNS_ERROR, `DNS Record is not synced yet: ${error.message}`, { ip: ip, subdomain: app.location, domain: app.domain })); // now wait for alternateDomains, if any async.eachSeries(app.alternateDomains, function (domain, iteratorCallback) { - domains.waitForDnsRecord(domain.subdomain, domain.domain, 'A', ip, { interval: 5000, times: 240 }, function (error) { + domains.waitForDnsRecord(domain.subdomain, domain.domain, 'A', ip, { times: 240 }, function (error) { if (error) return callback(new BoxError(BoxError.DNS_ERROR, `DNS Record is not synced yet: ${error.message}`, { ip: ip, subdomain: domain.subdomain, domain: domain.domain })); iteratorCallback(); diff --git a/src/cert/acme2.js b/src/cert/acme2.js index 54d72bc20..38a4580fc 100644 --- a/src/cert/acme2.js +++ b/src/cert/acme2.js @@ -452,7 +452,7 @@ Acme2.prototype.prepareDnsChallenge = function (hostname, domain, authorization, domains.upsertDnsRecords(challengeSubdomain, domain, 'TXT', [ `"${txtValue}"` ], function (error) { if (error) return callback(error); - domains.waitForDnsRecord(challengeSubdomain, domain, 'TXT', txtValue, { interval: 5000, times: 200 }, function (error) { + domains.waitForDnsRecord(challengeSubdomain, domain, 'TXT', txtValue, { times: 200 }, function (error) { if (error) return callback(error); callback(null, challenge); diff --git a/src/dns/linode.js b/src/dns/linode.js new file mode 100644 index 000000000..426ca3b63 --- /dev/null +++ b/src/dns/linode.js @@ -0,0 +1,309 @@ +'use strict'; + +exports = module.exports = { + removePrivateFields: removePrivateFields, + injectPrivateFields: injectPrivateFields, + upsert: upsert, + get: get, + del: del, + wait: wait, + verifyDnsConfig: verifyDnsConfig +}; + +let async = require('async'), + assert = require('assert'), + BoxError = require('../boxerror.js'), + debug = require('debug')('box:dns/linode'), + dns = require('../native-dns.js'), + domains = require('../domains.js'), + superagent = require('superagent'), + util = require('util'), + waitForDns = require('./waitfordns.js'); + +const LINODE_ENDPOINT = 'https://api.linode.com/v4'; + +function formatError(response) { + return util.format('Linode DNS error [%s] %j', response.statusCode, response.body); +} + +function removePrivateFields(domainObject) { + domainObject.config.token = domains.SECRET_PLACEHOLDER; + return domainObject; +} + +function injectPrivateFields(newConfig, currentConfig) { + if (newConfig.token === domains.SECRET_PLACEHOLDER) newConfig.token = currentConfig.token; +} + +function getZoneId(dnsConfig, zoneName, callback) { + assert.strictEqual(typeof dnsConfig, 'object'); + assert.strictEqual(typeof zoneName, 'string'); + assert.strictEqual(typeof callback, 'function'); + + // returns 100 at a time + superagent.get(`${LINODE_ENDPOINT}/domains`) + .set('Authorization', 'Bearer ' + dnsConfig.token) + .timeout(30 * 1000) + .retry(5) + .end(function (error, result) { + if (error && !error.response) return callback(new BoxError(BoxError.NETWORK_ERROR, error.message)); + if (result.statusCode === 403 || result.statusCode === 401) return callback(new BoxError(BoxError.ACCESS_DENIED, formatError(result))); + if (result.statusCode !== 200) return callback(new BoxError(BoxError.EXTERNAL_ERROR, formatError(result))); + + if (!Array.isArray(result.body.data)) return callback(new BoxError(BoxError.EXTERNAL_ERROR, 'Invalid response')); + + const zone = result.body.data.find(d => d.domain === zoneName); + + if (!zone || !zone.id) return callback(new BoxError(BoxError.NOT_FOUND, 'Zone not found')); + + debug(`getZoneId: zone id of ${zoneName} is ${zone.id}`); + + callback(null, zone.id); + }); +} + +function getZoneRecords(dnsConfig, zoneName, name, type, callback) { + assert.strictEqual(typeof dnsConfig, 'object'); + assert.strictEqual(typeof zoneName, 'string'); + assert.strictEqual(typeof name, 'string'); + assert.strictEqual(typeof type, 'string'); + assert.strictEqual(typeof callback, 'function'); + + debug(`getInternal: getting dns records of ${zoneName} with ${name} and type ${type}`); + + getZoneId(dnsConfig, zoneName, function (error, zoneId) { + if (error) return callback(error); + + let page = 0, more = false; + let records = []; + + async.doWhilst(function (iteratorDone) { + const url = `${LINODE_ENDPOINT}/domains/${zoneId}/records?page=${++page}`; + + superagent.get(url) + .set('Authorization', 'Bearer ' + dnsConfig.token) + .timeout(30 * 1000) + .retry(5) + .end(function (error, result) { + if (error && !error.response) return iteratorDone(new BoxError(BoxError.NETWORK_ERROR, error.message)); + if (result.statusCode === 404) return iteratorDone(new BoxError(BoxError.NOT_FOUND, formatError(result))); + if (result.statusCode === 403 || result.statusCode === 401) return iteratorDone(new BoxError(BoxError.ACCESS_DENIED, formatError(result))); + if (result.statusCode !== 200) return iteratorDone(new BoxError(BoxError.EXTERNAL_ERROR, formatError(result))); + + records = records.concat(result.body.data.filter(function (record) { + return (record.type === type && record.name === name); + })); + + more = result.body.page !== result.body.pages; + + iteratorDone(); + }); + }, function () { return more; }, function (error) { + debug('getZoneRecords:', error, JSON.stringify(records)); + + if (error) return callback(error); + + callback(null, { zoneId, records }); + }); + }); +} + +function get(domainObject, location, type, callback) { + assert.strictEqual(typeof domainObject, 'object'); + assert.strictEqual(typeof location, 'string'); + assert.strictEqual(typeof type, 'string'); + assert.strictEqual(typeof callback, 'function'); + + const dnsConfig = domainObject.config, + zoneName = domainObject.zoneName, + name = domains.getName(domainObject, location, type) || ''; + + getZoneRecords(dnsConfig, zoneName, name, type, function (error, { records }) { + if (error) return callback(error); + + var tmp = records.map(function (record) { return record.target; }); + + debug('get: %j', tmp); + + return callback(null, tmp); + }); +} + +function upsert(domainObject, location, type, values, callback) { + assert.strictEqual(typeof domainObject, 'object'); + assert.strictEqual(typeof location, 'string'); + assert.strictEqual(typeof type, 'string'); + assert(util.isArray(values)); + assert.strictEqual(typeof callback, 'function'); + + const dnsConfig = domainObject.config, + zoneName = domainObject.zoneName, + name = domains.getName(domainObject, location, type) || ''; + + debug('upsert: %s for zone %s of type %s with values %j', name, zoneName, type, values); + + getZoneRecords(dnsConfig, zoneName, name, type, function (error, { zoneId, records }) { + if (error) return callback(error); + + let i = 0, recordIds = []; // used to track available records to update instead of create + + async.eachSeries(values, function (value, iteratorCallback) { + let data = { + type: type, + ttl_sec: 300 // lowest + }; + + if (type === 'MX') { + data.priority = parseInt(value.split(' ')[0], 10); + data.target = value.split(' ')[1]; + } else if (type === 'TXT') { + data.target = value.replace(/^"(.*)"$/, '$1'); // strip any double quotes + } else { + data.target = value; + } + + if (i >= records.length) { + data.name = name; // only set for new records + + superagent.post(`${LINODE_ENDPOINT}/domains/${zoneId}/records`) + .set('Authorization', 'Bearer ' + dnsConfig.token) + .send(data) + .timeout(30 * 1000) + .retry(5) + .end(function (error, result) { + if (error && !error.response) return iteratorCallback(new BoxError(BoxError.NETWORK_ERROR, error.message)); + if (result.statusCode === 400) return iteratorCallback(new BoxError(BoxError.BAD_FIELD, formatError(result))); + if (result.statusCode === 403 || result.statusCode === 401) return iteratorCallback(new BoxError(BoxError.ACCESS_DENIED, formatError(result))); + if (result.statusCode !== 200) return iteratorCallback(new BoxError(BoxError.EXTERNAL_ERROR, formatError(result))); + + recordIds.push(result.body.id); + + return iteratorCallback(null); + }); + } else { + superagent.put(`${LINODE_ENDPOINT}/domains/${zoneId}/records/${records[i].id}`) + .set('Authorization', 'Bearer ' + dnsConfig.token) + .send(data) + .timeout(30 * 1000) + .retry(5) + .end(function (error, result) { + // increment, as we have consumed the record + ++i; + + if (error && !error.response) return iteratorCallback(new BoxError(BoxError.NETWORK_ERROR, error.message)); + if (result.statusCode === 400) return iteratorCallback(new BoxError(BoxError.BAD_FIELD, formatError(result))); + if (result.statusCode === 403 || result.statusCode === 401) return iteratorCallback(new BoxError(BoxError.ACCESS_DENIED, formatError(result))); + if (result.statusCode !== 200) return iteratorCallback(new BoxError(BoxError.EXTERNAL_ERROR, formatError(result))); + + recordIds.push(result.body.id); + + return iteratorCallback(null); + }); + } + }, function (error) { + if (error) return callback(error); + + debug('upsert: completed with recordIds:%j', recordIds); + + callback(); + }); + }); +} + +function del(domainObject, location, type, values, callback) { + assert.strictEqual(typeof domainObject, 'object'); + assert.strictEqual(typeof location, 'string'); + assert.strictEqual(typeof type, 'string'); + assert(util.isArray(values)); + assert.strictEqual(typeof callback, 'function'); + + const dnsConfig = domainObject.config, + zoneName = domainObject.zoneName, + name = domains.getName(domainObject, location, type) || ''; + + getZoneRecords(dnsConfig, zoneName, name, type, function (error, { zoneId, records }) { + if (error) return callback(error); + + if (records.length === 0) return callback(null); + + var tmp = records.filter(function (record) { return values.some(function (value) { return value === record.target; }); }); + + debug('del: %j', tmp); + + if (tmp.length === 0) return callback(null); + + // FIXME we only handle the first one currently + + superagent.del(`${LINODE_ENDPOINT}/domains/${zoneId}/records/${tmp[0].id}`) + .set('Authorization', 'Bearer ' + dnsConfig.token) + .timeout(30 * 1000) + .retry(5) + .end(function (error, result) { + if (error && !error.response) return callback(new BoxError(BoxError.NETWORK_ERROR, error.message)); + if (result.statusCode === 404) return callback(null); + if (result.statusCode === 403 || result.statusCode === 401) return callback(new BoxError(BoxError.ACCESS_DENIED, formatError(result))); + if (result.statusCode !== 200) return callback(new BoxError(BoxError.EXTERNAL_ERROR, formatError(result))); + + debug('del: done'); + + return callback(null); + }); + }); +} + +function wait(domainObject, location, type, value, options, callback) { + assert.strictEqual(typeof domainObject, 'object'); + assert.strictEqual(typeof location, 'string'); + assert.strictEqual(typeof type, 'string'); + assert.strictEqual(typeof value, 'string'); + assert(options && typeof options === 'object'); // { interval: 5000, times: 50000 } + assert.strictEqual(typeof callback, 'function'); + + const fqdn = domains.fqdn(location, domainObject); + + waitForDns(fqdn, domainObject.zoneName, type, value, options, callback); +} + +function verifyDnsConfig(domainObject, callback) { + assert.strictEqual(typeof domainObject, 'object'); + assert.strictEqual(typeof callback, 'function'); + + const dnsConfig = domainObject.config, + zoneName = domainObject.zoneName; + + if (!dnsConfig.token || typeof dnsConfig.token !== 'string') return callback(new BoxError(BoxError.BAD_FIELD, 'token must be a non-empty string', { field: 'token' })); + + const ip = '127.0.0.1'; + + var credentials = { + token: dnsConfig.token + }; + + if (process.env.BOX_ENV === 'test') return callback(null, credentials); // this shouldn't be here + + dns.resolve(zoneName, 'NS', { timeout: 5000 }, function (error, nameservers) { + if (error && error.code === 'ENOTFOUND') return callback(new BoxError(BoxError.BAD_FIELD, 'Unable to resolve nameservers for this domain', { field: 'nameservers' })); + if (error || !nameservers) return callback(new BoxError(BoxError.BAD_FIELD, error ? error.message : 'Unable to get nameservers', { field: 'nameservers' })); + + if (nameservers.map(function (n) { return n.toLowerCase(); }).indexOf('ns1.linode.com') === -1) { + debug('verifyDnsConfig: %j does not contains DO NS', nameservers); + return callback(new BoxError(BoxError.BAD_FIELD, 'Domain nameservers are not set to Linode', { field: 'nameservers' })); + } + + const location = 'cloudrontestdns'; + + upsert(domainObject, location, 'A', [ ip ], function (error) { + if (error) return callback(error); + + debug('verifyDnsConfig: Test A record added'); + + del(domainObject, location, 'A', [ ip ], function (error) { + if (error) return callback(error); + + debug('verifyDnsConfig: Test A record removed again'); + + callback(null, credentials); + }); + }); + }); +} diff --git a/src/domains.js b/src/domains.js index 2271e7627..a0dde7453 100644 --- a/src/domains.js +++ b/src/domains.js @@ -60,6 +60,7 @@ function api(provider) { case 'digitalocean': return require('./dns/digitalocean.js'); case 'gandi': return require('./dns/gandi.js'); case 'godaddy': return require('./dns/godaddy.js'); + case 'linode': return require('./dns/linode.js'); case 'namecom': return require('./dns/namecom.js'); case 'namecheap': return require('./dns/namecheap.js'); case 'noop': return require('./dns/noop.js'); @@ -436,6 +437,9 @@ function waitForDnsRecord(location, domain, type, value, options, callback) { get(domain, function (error, domainObject) { if (error) return callback(error); + // linode DNS takes ~15mins + if (!options.interval) options.interval = domainObject.provider === 'linode' ? 20000 : 5000; + api(domainObject.provider).wait(domainObject, location, type, value, options, callback); }); }