diff --git a/package.json b/package.json index d03e6b34e..2ef749baf 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "node": ">=4.0.0 <=4.1.1" }, "dependencies": { + "@google-cloud/dns": "^0.6.2", "@sindresorhus/df": "^2.1.0", "async": "^2.5.0", "aws-sdk": "^2.97.0", diff --git a/src/dns/gcdns.js b/src/dns/gcdns.js new file mode 100644 index 000000000..7ba1fe9ef --- /dev/null +++ b/src/dns/gcdns.js @@ -0,0 +1,184 @@ +'use strict'; + +exports = module.exports = { + upsert: upsert, + get: get, + del: del, + waitForDns: require('./waitfordns.js'), + verifyDnsConfig: verifyDnsConfig +}; + +var assert = require('assert'), + GCDNS = require('@google-cloud/dns'), + constants = require('../constants.js'), + debug = require('debug')('box:dns/route53'), + dns = require('dns'), + SubdomainError = require('../subdomains.js').SubdomainError, + util = require('util'), + _ = require('underscore'); + +function getDnsCredentials(dnsConfig) { + assert.strictEqual(typeof dnsConfig, 'object'); + + var config = { + provider: dnsConfig.provider, + projectId: dnsConfig.projectId, + keyFilename: dnsConfig.keyFilename, + email: dnsConfig.email + }; + + if(dnsConfig.credentials){ + config.credentials = { + client_email: dnsConfig.credentials.client_email, + private_key: dnsConfig.credentials.private_key + }; + } + return config; +} + +function getZoneByName(dnsConfig, zoneName, callback) { + assert.strictEqual(typeof dnsConfig, 'object'); + assert.strictEqual(typeof zoneName, 'string'); + assert.strictEqual(typeof callback, 'function'); + + var gcdns = GCDNS(getDnsCredentials(dnsConfig)); + + gcdns.getZones(function(err, zones, apiResponse) { + if (err && err.message == 'invalid_grant') return callback(new SubdomainError(SubdomainError.ACCESS_DENIED, "The key was probably revoked")); + if (err && err.reason == 'No such domain') return callback(new SubdomainError(SubdomainError.NOT_FOUND, err.message)); + if (err && err.code == 403) return callback(new SubdomainError(SubdomainError.ACCESS_DENIED, err.message)); + if (err && err.code == 404) return callback(new SubdomainError(SubdomainError.MISSING_CREDENTIALS, err.message)); + if (err) return callback(new SubdomainError(SubdomainError.EXTERNAL_ERROR, err)); + + var zone = zones.filter(function (zone) { + return zone.metadata.dnsName.slice(0, -1) === zoneName; // the zone name contains a '.' at the end + })[0]; + + if (!zone) return callback(new SubdomainError(SubdomainError.NOT_FOUND, 'no such zone')); + + callback(null, zone); //zone.metadata ~= {name="", dnsName="", nameServers:[]} + }); +} + +function upsert(dnsConfig, zoneName, subdomain, type, values, callback) { + assert.strictEqual(typeof dnsConfig, 'object'); + assert.strictEqual(typeof zoneName, 'string'); + assert.strictEqual(typeof subdomain, 'string'); + assert.strictEqual(typeof type, 'string'); + assert(util.isArray(values)); + assert.strictEqual(typeof callback, 'function'); + + debug('add: %s for zone %s of type %s with values %j', subdomain, zoneName, type, values); + + getZoneByName(getDnsCredentials(dnsConfig), zoneName, function (error, zone) { + if (error) return callback(error); + + var params = zone.record(type, { + name: (subdomain ? subdomain + '.' : '') + zoneName + '.', + data: values, + ttl: 1 + }); + + zone.replaceRecords(type, [params], function(error, change, apiResponse) { + if (error && error.code == 403) return callback(new SubdomainError(SubdomainError.ACCESS_DENIED, error.message)); + if (error) return callback(new SubdomainError(SubdomainError.EXTERNAL_ERROR, error.message)); + + callback(null, change.id); + }); + }); +} + +function get(dnsConfig, zoneName, subdomain, type, callback) { + assert.strictEqual(typeof dnsConfig, 'object'); + assert.strictEqual(typeof zoneName, 'string'); + assert.strictEqual(typeof subdomain, 'string'); + assert.strictEqual(typeof type, 'string'); + assert.strictEqual(typeof callback, 'function'); + + getZoneByName(getDnsCredentials(dnsConfig), zoneName, function (error, zone) { + if (error) return callback(error); + + var params = { + name: (subdomain ? subdomain + '.' : '') + zoneName + '.', + type: type + }; + + var allValues = []; + var recursiveRetriever = function(err, records, nextQuery, apiResponse) { + if (err) { + if (error && error.code == 403) return callback(new SubdomainError(SubdomainError.ACCESS_DENIED, error.message)); + if (error) return callback(new SubdomainError(SubdomainError.EXTERNAL_ERROR, error)); + } + if (records.length > 0) { + allValues = allValues.concat(records[0].data); + } + if (nextQuery) { + return zone.getRecords(nextQuery, recursiveRetriever); + } + + callback(null, allValues); + }; + zone.getRecords(params, recursiveRetriever); + }); +} + +function del(dnsConfig, zoneName, subdomain, type, values, callback) { + assert.strictEqual(typeof dnsConfig, 'object'); + assert.strictEqual(typeof zoneName, 'string'); + assert.strictEqual(typeof subdomain, 'string'); + assert.strictEqual(typeof type, 'string'); + assert(util.isArray(values)); + assert.strictEqual(typeof callback, 'function'); + + getZoneByName(getDnsCredentials(dnsConfig), zoneName, function (error, zone) { + if (error) return callback(error); + + var rec = zone.record('a', { + name: (subdomain ? subdomain + '.' : '') + zoneName + '.', + data: values, + ttl: 1 + }); + + zone.deleteRecords(rec, function(error, change, apiResponse) { + if (error && error.code == 403) return callback(new SubdomainError(SubdomainError.ACCESS_DENIED, error.message)); + if (error) return callback(new SubdomainError(SubdomainError.EXTERNAL_ERROR, error)); + + callback(null); + }); + }); +} + +function verifyDnsConfig(dnsConfig, fqdn, zoneName, ip, callback) { + assert.strictEqual(typeof dnsConfig, 'object'); + assert.strictEqual(typeof fqdn, 'string'); + assert.strictEqual(typeof zoneName, 'string'); + assert.strictEqual(typeof ip, 'string'); + assert.strictEqual(typeof callback, 'function'); + + var credentials = getDnsCredentials(dnsConfig); + if (process.env.BOX_ENV === 'test') return callback(null, credentials); // this shouldn't be here + + dns.resolveNs(zoneName, function (error, nameservers) { + if (error && error.code === 'ENOTFOUND') return callback(new SubdomainError(SubdomainError.BAD_FIELD, 'Unable to resolve nameservers for this domain')); + if (error || !nameservers) return callback(new SubdomainError(SubdomainError.BAD_FIELD, error ? error.message : 'Unable to get nameservers')); + + getZoneByName(credentials, zoneName, function (error, records) { + if (error) return callback(error); + + if (!_.isEqual(zone.metadata.nameServers.sort(), nameservers.sort())) { + debug('verifyDnsConfig: %j and %j do not match', nameservers, zone.DelegationSet.NameServers); + return callback(new SubdomainError(SubdomainError.BAD_FIELD, 'Domain nameservers are not set to Google Cloud DNS')); + } + + const name = constants.ADMIN_LOCATION + (fqdn === zoneName ? '' : '.' + fqdn.slice(0, - zoneName.length - 1)); + + upsert(credentials, zoneName, name, 'A', [ ip ], function (error, changeId) { + if (error) return callback(error); + + debug('verifyDnsConfig: A record added with change id %s', changeId); + + callback(null, credentials); + }); + }); + }); +} diff --git a/src/subdomains.js b/src/subdomains.js index 9544fe0f1..83b78a1df 100644 --- a/src/subdomains.js +++ b/src/subdomains.js @@ -42,7 +42,7 @@ SubdomainError.BAD_FIELD = 'Bad Field'; SubdomainError.STILL_BUSY = 'Still busy'; SubdomainError.INTERNAL_ERROR = 'Internal error'; SubdomainError.ACCESS_DENIED = 'Access denied'; -SubdomainError.INVALID_PROVIDER = 'provider must be route53, digitalocean, cloudflare, noop, manual or caas'; +SubdomainError.INVALID_PROVIDER = 'provider must be route53, gcdns, digitalocean, cloudflare, noop, manual or caas'; // choose which subdomain backend we use for test purpose we use route53 function api(provider) { @@ -52,6 +52,7 @@ function api(provider) { case 'caas': return require('./dns/caas.js'); case 'cloudflare': return require('./dns/cloudflare.js'); case 'route53': return require('./dns/route53.js'); + case 'gcdns': return require('./dns/gcdns.js'); case 'digitalocean': return require('./dns/digitalocean.js'); case 'noop': return require('./dns/noop.js'); case 'manual': return require('./dns/manual.js'); diff --git a/src/test/dns-test.js b/src/test/dns-test.js index 75e79d228..a320c8c1a 100644 --- a/src/test/dns-test.js +++ b/src/test/dns-test.js @@ -8,6 +8,7 @@ var async = require('async'), AWS = require('aws-sdk'), + GCDNS = require('@google-cloud/dns'), config = require('../config.js'), database = require('../database.js'), expect = require('expect.js'), @@ -517,4 +518,130 @@ describe('dns provider', function () { }); }); }); + + describe('gcdns', function () { + var HOSTED_ZONES = []; + var zoneQueue = []; + var _OriginalGCDNS; + + before(function (done) { + var domain = 'example.com'; + config.setFqdn(domain); + config.setZoneName(domain); + var dnsConfig = { + provider: 'gcdns', + projectId: 'my-dns-proj', + keyFilename: 'syn-im-1ec6f9f870bf.json' + }; + + function mockery (queue) { + return function() { + var callback = arguments[--arguments.length]; + + var elem = queue.shift(); + if (!util.isArray(elem)) throw(new Error('Mock answer required')); + + // if no callback passed, return a req object with send(); + if (typeof callback !== 'function') { + return { + httpRequest: { headers: {} }, + send: function (callback) { + expect(callback).to.be.a(Function); + callback.apply(callback, elem); + } + }; + } else { + callback.apply(callback, elem); + } + }; + } + + function fakeZone(name, ns, recordQueue) { + var zone = GCDNS().zone(name.replace('.', '-')); + zone.metadata.dnsName = name + '.'; + zone.metadata.nameServers = ns || ['8.8.8.8', '8.8.4.4']; + zone.getRecords = mockery(recordQueue || zoneQueue); + zone.replaceRecords = mockery(recordQueue || zoneQueue); + zone.deleteRecords = mockery(recordQueue || zoneQueue); + return zone; + } + HOSTED_ZONES = [fakeZone(domain), fakeZone('cloudron.us')]; + + _OriginalGCDNS = GCDNS.prototype.getZones; + GCDNS.prototype.getZones = mockery(zoneQueue); + + settings.setDnsConfig(dnsConfig, config.fqdn(), config.zoneName(), done); + }); + + after(function () { + GCDNS.prototype.getZones = _OriginalGCDNS; + _OriginalGCDNS = null; + }); + + it('upsert non-existing record succeeds', function (done) { + zoneQueue.push([null, HOSTED_ZONES]); + zoneQueue.push([null, {id: '1'}]); + subdomains.upsert('test', 'A', [ '1.2.3.4' ], function (error, result) { + expect(error).to.eql(null); + expect(result).to.eql('1'); + expect(zoneQueue.length).to.eql(0); + + done(); + }); + }); + + it('upsert existing record succeeds', function (done) { + zoneQueue.push([null, HOSTED_ZONES]); + zoneQueue.push([null, {id: '2'}]); + + subdomains.upsert('test', 'A', [ '1.2.3.4' ], function (error, result) { + expect(error).to.eql(null); + expect(result).to.eql('2'); + expect(zoneQueue.length).to.eql(0); + + done(); + }); + }); + + it('upsert multiple record succeeds', function (done) { + zoneQueue.push([null, HOSTED_ZONES]); + zoneQueue.push([null, {id: '3'}]); + + subdomains.upsert('', 'TXT', [ 'first', 'second', 'third' ], function (error, result) { + expect(error).to.eql(null); + expect(result).to.eql('3'); + expect(zoneQueue.length).to.eql(0); + + done(); + }); + }); + + it('get succeeds', function (done) { + zoneQueue.push([null, HOSTED_ZONES]); + zoneQueue.push([null, [], true]); + zoneQueue.push([null, [GCDNS().zone('test').record('A', {'name': 'test', data:['1.2.3.4'], ttl: 1})]]); + + subdomains.get('test', 'A', function (error, result) { + expect(error).to.eql(null); + expect(result).to.be.an(Array); + expect(result.length).to.eql(1); + expect(result[0]).to.eql('1.2.3.4'); + expect(zoneQueue.length).to.eql(0); + + done(); + }); + }); + + it('del succeeds', function (done) { + zoneQueue.push([null, HOSTED_ZONES]); + zoneQueue.push([null, {id: '5'}]); + + subdomains.remove('test', 'A', ['1.2.3.4'], function (error) { + expect(error).to.eql(null); + expect(zoneQueue.length).to.eql(0); + + done(); + }); + }); + }); });