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..d23568a8f --- /dev/null +++ b/src/dns/gcdns.js @@ -0,0 +1,225 @@ +'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/gcdns'), + 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.NOT_FOUND, err.message)); + if (err) { + debug('gcdns.getZones', 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 domain = (subdomain ? subdomain + '.' : '') + zoneName + '.'; + + zone.getRecords({type: type, name: domain}, function(error, oldRecords, apiResponse) { + if (error && error.code == 403) return callback(new SubdomainError(SubdomainError.ACCESS_DENIED, error.message)); + if (error) { + debug('upsert->zone.getRecords', error); + return callback(new SubdomainError(SubdomainError.EXTERNAL_ERROR, error.message)); + } + assert(oldRecords.length <= 1); + + var oldData = oldRecords.length > 0 ? oldRecords[0].metadata.rrdatas : []; + var newData = oldData.concat(values).sort().filter(function(el,i,a){if(i==a.indexOf(el))return 1;return 0}); + + var newRecord = zone.record(type, { + name: domain, + data: newData, + ttl: 1 + }); + + zone.createChange({delete: oldRecords, add: newRecord}, function(error, change, apiResponse) { + if (error && error.code == 403) return callback(new SubdomainError(SubdomainError.ACCESS_DENIED, error.message)); + if (error && error.code == 412) return callback(new SubdomainError(SubdomainError.STILL_BUSY, error.message)); + if (error) { + debug('upsert->zone.createChange', 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 domain = (subdomain ? subdomain + '.' : '') + zoneName + '.'; + + zone.getRecords({type: type, name: domain}, function(error, oldRecords, apiResponse) { + if (error && error.code == 403) return callback(new SubdomainError(SubdomainError.ACCESS_DENIED, error.message)); + if (error) { + debug('del->zone.getRecords', error); + return callback(new SubdomainError(SubdomainError.EXTERNAL_ERROR, error.message)); + } + assert(oldRecords.length <= 1); + + + var oldData = oldRecords.length > 0 ? oldRecords[0].metadata.rrdatas : []; + var newData = oldData.filter(function(e){ return values.indexOf(e) == -1;}); //element is not in the list of values to delete + + var newRecord = newData.length == 0 ? null : zone.record(type, { + name: domain, + data: newData, + ttl: 1 + }); + + zone.createChange({delete: oldRecords, add: newRecord}, function(error, change, apiResponse) { + if (error && error.code == 403) return callback(new SubdomainError(SubdomainError.ACCESS_DENIED, error.message)); + if (error && error.code == 412) return callback(new SubdomainError(SubdomainError.STILL_BUSY, error.message)); + if (error) { + debug('del->zone.createChange', error); + return callback(new SubdomainError(SubdomainError.EXTERNAL_ERROR, error.message)); + } + + callback(null, change.id); + }); + }); + }); +} + +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, resolvedNS) { + if (error && error.code === 'ENOTFOUND') return callback(new SubdomainError(SubdomainError.BAD_FIELD, 'Unable to resolve nameservers for this domain')); + if (error || !resolvedNS) return callback(new SubdomainError(SubdomainError.BAD_FIELD, error ? error.message : 'Unable to get nameservers')); + + getZoneByName(credentials, zoneName, function (error, zone) { + if (error) return callback(error); + + var definedNS = zone.metadata.nameServers.sort().map(function(r){ return r.replace(/\.$/, '')}); + if (!_.isEqual(definedNS, resolvedNS.sort())) { + debug('verifyDnsConfig: %j and %j do not match', resolvedNS, definedNS); + 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(); + }); + }); + }); }); diff --git a/webadmin/src/js/setupdns.js b/webadmin/src/js/setupdns.js index 22c981798..48f8cfcb2 100644 --- a/webadmin/src/js/setupdns.js +++ b/webadmin/src/js/setupdns.js @@ -40,6 +40,7 @@ app.controller('SetupDNSController', ['$scope', '$http', 'Client', function ($sc // keep in sync with certs.js $scope.dnsProvider = [ { name: 'AWS Route53', value: 'route53' }, + { name: 'Google Cloud DNS', value: 'gcdns' }, { name: 'Digital Ocean', value: 'digitalocean' }, { name: 'Cloudflare (DNS only)', value: 'cloudflare' }, { name: 'Wildcard', value: 'wildcard' }, @@ -52,10 +53,29 @@ app.controller('SetupDNSController', ['$scope', '$http', 'Client', function ($sc domain: '', accessKeyId: '', secretAccessKey: '', + gcdnsKey: {keyFileName: "", content: ""}, digitalOceanToken: '', provider: 'route53' }; + function readFileLocally(obj, file, fileName) { + return function (event) { + $scope.$apply(function () { + obj[file] = null; + obj[fileName] = event.target.files[0].name; + + var reader = new FileReader(); + reader.onload = function (result) { + if (!result.target || !result.target.result) return console.error('Unable to read local file'); + obj[file] = result.target.result; + }; + reader.readAsText(event.target.files[0]); + }); + }; + } + + document.getElementById('gcdnsKeyFileInput').onchange = readFileLocally($scope.dnsCredentials.gcdnsKey, 'content', 'keyFileName'); + $scope.setDnsCredentials = function () { $scope.dnsCredentials.busy = true; $scope.dnsCredentials.error = null; @@ -77,6 +97,23 @@ app.controller('SetupDNSController', ['$scope', '$http', 'Client', function ($sc if (data.provider === 'route53') { data.accessKeyId = $scope.dnsCredentials.accessKeyId; data.secretAccessKey = $scope.dnsCredentials.secretAccessKey; + } else if (data.provider === 'gcdns'){ + try { + var serviceAccountKey = JSON.parse($scope.dnsCredentials.gcdnsKey.content); + data.projectId = serviceAccountKey.project_id; + data.credentials = { + client_email: serviceAccountKey.client_email, + private_key: serviceAccountKey.private_key + }; + + if (!data.projectId || !data.credentials || !data.credentials.client_email || !data.credentials.private_key) { + throw "fields_missing"; + } + } catch(e) { + $scope.dnsCredentials.error = "Cannot parse Google Service Account Key"; + $scope.dnsCredentials.busy = false; + return; + } } else if (data.provider === 'digitalocean') { data.token = $scope.dnsCredentials.digitalOceanToken; } else if (data.provider === 'cloudflare') { @@ -123,6 +160,7 @@ app.controller('SetupDNSController', ['$scope', '$http', 'Client', function ($sc if (status.adminFqdn) return waitForDnsSetup(); if (status.provider === 'digitalocean') $scope.dnsCredentials.provider = 'digitalocean'; + if (status.provider === 'gcp') $scope.dnsCredentials.provider = 'gcdns'; if (status.provider === 'ami') { // remove route53 on ami $scope.dnsProvider.shift(); diff --git a/webadmin/src/setupdns.html b/webadmin/src/setupdns.html index a590dcbeb..63fc43fbf 100644 --- a/webadmin/src/setupdns.html +++ b/webadmin/src/setupdns.html @@ -80,6 +80,18 @@ {{ explicitZone ? explicitZone : (dnsCredentials.domain | zoneName) }} must be hosted on AWS Route53. + +
+
+ + + + + +
+ {{ explicitZone ? explicitZone : (dnsCredentials.domain | zoneName) }} must be hosted on Google Cloud DNS. +
+
diff --git a/webadmin/src/views/certs.html b/webadmin/src/views/certs.html index 713e8b1a0..25e23b0d7 100644 --- a/webadmin/src/views/certs.html +++ b/webadmin/src/views/certs.html @@ -29,6 +29,17 @@
+ +
+
+ + + + + +
+
+
@@ -62,6 +73,10 @@ This domain must be hosted on AWS Route53.

+

+ This domain must be hosted on Google Cloud DNS. +

+

This domain must be hosted on DigitalOcean.

diff --git a/webadmin/src/views/certs.js b/webadmin/src/views/certs.js index 0783caeaf..58cc4f4e9 100644 --- a/webadmin/src/views/certs.js +++ b/webadmin/src/views/certs.js @@ -9,6 +9,7 @@ angular.module('Application').controller('CertsController', ['$scope', '$locatio // keep in sync with setupdns.js $scope.dnsProvider = [ { name: 'AWS Route53', value: 'route53' }, + { name: 'Google Cloud DNS', value: 'gcdns' }, { name: 'Digital Ocean', value: 'digitalocean' }, { name: 'Cloudflare (DNS only)', value: 'cloudflare' }, { name: 'Wildcard', value: 'wildcard' }, @@ -43,6 +44,7 @@ angular.module('Application').controller('CertsController', ['$scope', '$locatio customDomain: '', accessKeyId: '', secretAccessKey: '', + gcdnsKey: {keyFileName: "", content: ""}, digitalOceanToken: '', cloudflareToken: '', cloudflareEmail: '', @@ -71,6 +73,8 @@ angular.module('Application').controller('CertsController', ['$scope', '$locatio document.getElementById('adminCertFileInput').onchange = readFileLocally($scope.adminCert, 'certificateFile', 'certificateFileName'); document.getElementById('adminKeyFileInput').onchange = readFileLocally($scope.adminCert, 'keyFile', 'keyFileName'); + document.getElementById('gcdnsKeyFileInput').onchange = readFileLocally($scope.dnsCredentials.gcdnsKey, 'content', 'keyFileName'); + $scope.setDefaultCert = function () { $scope.defaultCert.busy = true; $scope.defaultCert.error = null; @@ -130,6 +134,23 @@ angular.module('Application').controller('CertsController', ['$scope', '$locatio if (data.provider === 'route53') { data.accessKeyId = $scope.dnsCredentials.accessKeyId; data.secretAccessKey = $scope.dnsCredentials.secretAccessKey; + } else if (data.provider === 'gcdns'){ + try { + var serviceAccountKey = JSON.parse($scope.dnsCredentials.gcdnsKey.content); + data.projectId = serviceAccountKey.project_id; + data.credentials = { + client_email: serviceAccountKey.client_email, + private_key: serviceAccountKey.private_key + }; + + if (!data.projectId || !data.credentials || !data.credentials.client_email || !data.credentials.private_key) { + throw "fields_missing"; + } + } catch(e) { + $scope.dnsCredentials.error = "Cannot parse Google Service Account Key"; + $scope.dnsCredentials.busy = false; + return; + } } else if (data.provider === 'digitalocean') { data.token = $scope.dnsCredentials.digitalOceanToken; } else if (data.provider === 'cloudflare') { @@ -178,6 +199,8 @@ angular.module('Application').controller('CertsController', ['$scope', '$locatio $scope.dnsCredentials.customDomain = ''; $scope.dnsCredentials.accessKeyId = ''; $scope.dnsCredentials.secretAccessKey = ''; + $scope.dnsCredentials.gcdnsKey.keyFileName = ''; + $scope.dnsCredentials.gcdnsKey.content = ''; $scope.dnsCredentials.digitalOceanToken = ''; $scope.dnsCredentials.cloudflareToken = ''; $scope.dnsCredentials.cloudflareEmail = ''; @@ -196,6 +219,16 @@ angular.module('Application').controller('CertsController', ['$scope', '$locatio $scope.dnsCredentials.customDomain = $scope.config.isCustomDomain ? $scope.config.fqdn : ''; $scope.dnsCredentials.accessKeyId = $scope.dnsConfig.accessKeyId; $scope.dnsCredentials.secretAccessKey = $scope.dnsConfig.secretAccessKey; + + $scope.dnsCredentials.gcdnsKey.keyFileName = ''; + $scope.dnsCredentials.gcdnsKey.content = ''; + if($scope.dnsConfig.provider === 'gcdns'){ + $scope.dnsCredentials.gcdnsKey.keyFileName = $scope.dnsConfig.credentials.client_email; + $scope.dnsCredentials.gcdnsKey.content = JSON.stringify({ + "project_id": $scope.dnsConfig.projectId, + "credentials": $scope.dnsConfig.credentials + }); + } $scope.dnsCredentials.digitalOceanToken = $scope.dnsConfig.provider === 'digitalocean' ? $scope.dnsConfig.token : ''; $scope.dnsCredentials.cloudflareToken = $scope.dnsConfig.provider === 'cloudflare' ? $scope.dnsConfig.token : ''; $scope.dnsCredentials.cloudflareEmail = $scope.dnsConfig.email;