diff --git a/src/apptask.js b/src/apptask.js index bdbe583b8..65c84052e 100644 --- a/src/apptask.js +++ b/src/apptask.js @@ -46,6 +46,7 @@ var addons = require('./addons.js'), paths = require('./paths.js'), safe = require('safetydance'), shell = require('./shell.js'), + subdomains = require('./subdomains.js'), superagent = require('superagent'), sysinfo = require('./sysinfo.js'), util = require('util'), @@ -429,43 +430,27 @@ function registerSubdomain(app, callback) { // need to register it so that we have a dnsRecordId to wait for it to complete var record = { subdomain: app.location, type: 'A', value: sysinfo.getIp() }; - superagent - .post(config.apiServerOrigin() + '/api/v1/subdomains') - .set('Accept', 'application/json') - .query({ token: config.token() }) - .send({ records: [ record ] }) - .end(function (error, res) { - if (error) return callback(error); + subdomains.add(record, function (error) { + if (error) return callback(error); - debugApp(app, 'Registered subdomain status: %s', res.status); + debugApp(app, 'Registered subdomain.'); - if (res.status === 409) return callback(null); // already registered - if (res.status !== 201) return callback(new Error(util.format('Subdomain Registration failed. %s %j', res.status, res.body))); - - updateApp(app, { dnsRecordId: res.body.ids[0] }, callback); - }); + callback(null); + }); } function unregisterSubdomain(app, callback) { - debugApp(app, 'Unregistering subdomain: dnsRecordId=%s', app.dnsRecordId); - - if (!app.dnsRecordId) return callback(null); + debugApp(app, 'Unregistering subdomain: %s', app.location); // do not unregister bare domain because we show a error/cloudron info page there - if (app.location === '') return updateApp(app, { dnsRecordId: null }, callback); + if (app.location === '') return callback(null); - superagent - .del(config.apiServerOrigin() + '/api/v1/subdomains/' + app.dnsRecordId) - .query({ token: config.token() }) - .end(function (error, res) { - if (error) { - debugApp(app, 'Error making request: %s', error); - } else if (res.status !== 204) { - debugApp(app, 'Error unregistering subdomain:', res.status, res.body); - } + var record = { subdomain: app.location, type: 'A', value: sysinfo.getIp() }; + subdomains.remove(record, function (error) { + if (error) debugApp(app, 'Error unregistering subdomain: %s', error); - updateApp(app, { dnsRecordId: null }, callback); - }); + updateApp(app, { dnsRecordId: null }, callback); + }); } function removeIcon(app, callback) { @@ -486,21 +471,16 @@ function waitForDnsPropagation(app, callback) { setTimeout(waitForDnsPropagation.bind(null, app, callback), 5000); } - superagent - .get(config.apiServerOrigin() + '/api/v1/subdomains/' + app.dnsRecordId + '/status') - .set('Accept', 'application/json') - .query({ token: config.token() }) - .end(function (error, res) { - if (error) return retry(new Error('Failed to get dns record status : ' + error.message)); + var record = { subdomain: app.location, type: 'A', value: sysinfo.getIp() }; + subdomains.status(record, function (error, result) { + if (error) return retry(new Error('Failed to get dns record status : ' + error.message)); - debugApp(app, 'waitForDnsPropagation: dnsRecordId:%s status:%s', app.dnsRecordId, res.status); + debugApp(app, 'waitForDnsPropagation: dnsRecordId:%s status:%s', app.dnsRecordId, result); - if (res.status !== 200) return retry(new Error(util.format('Error getting record status: %s %j', res.status, res.body))); + if (result !== 'done') return retry(new Error(util.format('app:%s not ready yet: %s', app.id, result))); - if (res.body.status !== 'done') return retry(new Error(util.format('app:%s not ready yet: %s', app.id, res.body.status))); - - callback(null); - }); + callback(null); + }); } // updates the app object and the database diff --git a/src/aws.js b/src/aws.js index a3af48fec..0483e1d0a 100644 --- a/src/aws.js +++ b/src/aws.js @@ -8,13 +8,18 @@ exports = module.exports = { getAWSCredentials: getAWSCredentials, getSignedUploadUrl: getSignedUploadUrl, - getSignedDownloadUrl: getSignedDownloadUrl + getSignedDownloadUrl: getSignedDownloadUrl, + + addSubdomain: addSubdomain, + delSubdomain: delSubdomain, + getChangeStatus: getChangeStatus }; var assert = require('assert'), AWS = require('aws-sdk'), config = require('./config.js'), debug = require('debug')('box:aws'), + SubdomainError = require('./subdomainerror.js'), superagent = require('superagent'), util = require('util'); @@ -120,3 +125,160 @@ function getSignedDownloadUrl(filename, callback) { callback(null, { url: url, sessionToken: credentials.sessionToken }); }); } + +function getZoneByName(zoneName, callback) { + assert.strictEqual(typeof zoneName, 'string'); + assert.strictEqual(typeof callback, 'function'); + + debug('getZoneByName: %s', zoneName); + + getAWSCredentials(function (error, credentials) { + if (error) return callback(error); + + var route53 = new AWS.Route53(credentials); + route53.listHostedZones({}, function (error, result) { + if (error) return callback(new SubdomainError(SubdomainError.EXTERNAL_ERROR, new Error(error))); + + var zone = result.HostedZones.filter(function (zone) { + return zone.Name === zoneName; + })[0]; + + if (!zone) return callback(new SubdomainError(SubdomainError.NOT_FOUND, 'no such zone')); + + callback(null, zone); + }); + }); +} + +function addSubdomain(zoneName, subdomain, type, value, callback) { + assert.strictEqual(typeof zoneName, 'string'); + assert.strictEqual(typeof subdomain, 'string'); + assert.strictEqual(typeof type, 'string'); + assert.strictEqual(typeof value, 'string'); + assert.strictEqual(typeof callback, 'function'); + + debug('addSubdomain: ' + subdomain + ' for domain ' + zoneName + ' with value ' + value); + + getZoneByName(zoneName, function (error, zone) { + if (error) return callback(error); + + var fqdn = subdomain === '' ? zoneName : (subdomain + '.' + zoneName); + var params = { + ChangeBatch: { + Changes: [{ + Action: 'UPSERT', + ResourceRecordSet: { + Type: type, + Name: fqdn, + ResourceRecords: [{ + Value: value + }], + Weight: 0, + SetIdentifier: fqdn, + TTL: 1 + } + }] + }, + HostedZoneId: zone.id + }; + + getAWSCredentials(function (error, credentials) { + if (error) return callback(error); + + var route53 = new AWS.Route53(credentials); + route53.changeResourceRecordSets(params, function(error, result) { + if (error && error.code === 'PriorRequestNotComplete') { + return callback(new SubdomainError(SubdomainError.STILL_BUSY, new Error(error))); + } else if (error) { + return callback(new SubdomainError(SubdomainError.EXTERNAL_ERROR, new Error(error))); + } + + debug('addSubdomain: success. changeInfoId:%j', result); + + callback(null, result.ChangeInfo.Id); + }); + }); + }); +} + +function delSubdomain(zoneName, subdomain, type, value, callback) { + assert.strictEqual(typeof zoneName, 'string'); + assert.strictEqual(typeof subdomain, 'string'); + assert.strictEqual(typeof type, 'string'); + assert.strictEqual(typeof value, 'string'); + assert.strictEqual(typeof callback, 'function'); + + debug('delSubdomain: %s for domain %s.', subdomain, zoneName); + + getZoneByName(zoneName, function (error, zone) { + if (error) return callback(error); + + var fqdn = subdomain === '' ? zoneName : (subdomain + '.' + zoneName); + var resourceRecordSet = { + Name: fqdn, + Type: type, + ResourceRecords: [{ + Value: value + }], + Weight: 0, + SetIdentifier: fqdn, + TTL: 1 + }; + + var params = { + ChangeBatch: { + Changes: [{ + Action: 'DELETE', + ResourceRecordSet: resourceRecordSet + }] + }, + HostedZoneId: zone.id + }; + + getAWSCredentials(function (error, credentials) { + if (error) return callback(error); + + var route53 = new AWS.Route53(credentials); + route53.changeResourceRecordSets(params, function(error, result) { + if (error && error.message && error.message.indexOf('it was not found') !== -1) { + debug('delSubdomain: resource record set not found.', error); + return callback(new SubdomainError(SubdomainError.NOT_FOUND, new Error(error))); + } else if (error && error.code === 'NoSuchHostedZone') { + debug('delSubdomain: hosted zone not found.', error); + return callback(new SubdomainError(SubdomainError.NOT_FOUND, new Error(error))); + } else if (error && error.code === 'PriorRequestNotComplete') { + debug('delSubdomain: resource is still busy', error); + return callback(new SubdomainError(SubdomainError.STILL_BUSY, new Error(error))); + } else if (error && error.code === 'InvalidChangeBatch') { + debug('delSubdomain: invalid change batch. No such record to be deleted.'); + return callback(new SubdomainError(SubdomainError.NOT_FOUND, new Error(error))); + } else if (error) { + debug('delSubdomain: error', error); + return callback(new SubdomainError(SubdomainError.EXTERNAL_ERROR, new Error(error))); + } + + debug('delSubdomain: success'); + + callback(null); + }); + }); + }); +} + +function getChangeStatus(changeId, callback) { + assert.strictEqual(typeof changeId, 'string'); + assert.strictEqual(typeof callback, 'function'); + + if (changeId === '') return callback(null, 'INSYNC'); + + getAWSCredentials(function (error, credentials) { + if (error) return callback(error); + + var route53 = new AWS.Route53(credentials); + route53.getChange({ Id: changeId }, function (error, result) { + if (error) return callback(error); + + callback(null, result.ChangeInfo.Status); + }); + }); +} diff --git a/src/cloudron.js b/src/cloudron.js index a6aa96cb2..715dc295e 100644 --- a/src/cloudron.js +++ b/src/cloudron.js @@ -25,7 +25,6 @@ var apps = require('./apps.js'), AppsError = require('./apps.js').AppsError, assert = require('assert'), async = require('async'), - aws = require('./aws.js'), backups = require('./backups.js'), BackupsError = require('./backups.js').BackupsError, clientdb = require('./clientdb.js'), @@ -40,6 +39,7 @@ var apps = require('./apps.js'), settings = require('./settings.js'), SettingsError = settings.SettingsError, shell = require('./shell.js'), + subdomains = require('./subdomains.js'), superagent = require('superagent'), sysinfo = require('./sysinfo.js'), tokendb = require('./tokendb.js'), @@ -306,22 +306,10 @@ function sendMailDnsRecordsRequest(callback) { debug('sendMailDnsRecords request:%s', JSON.stringify(records)); - superagent - .post(config.apiServerOrigin() + '/api/v1/subdomains') - .set('Accept', 'application/json') - .query({ token: config.token() }) - .send({ records: records }) - .end(function (error, res) { - if (error) return callback(error); - - debug('sendMailDnsRecords status: %s', res.status); - - if (res.status === 409) return callback(null); // already registered - - if (res.status !== 201) return callback(new Error(util.format('Failed to add Mail DNS records: %s %j', res.status, res.body))); - - return callback(null, res.body.ids); - }); + subdomains.addMany(records, function (error, result) { + if (error) return callback(error); + callback(null, result); + }); } function addMailDnsRecords() { @@ -335,6 +323,7 @@ function addMailDnsRecords() { } debug('Added Mail DNS records successfully'); + config.set('mailDnsRecordIds', ids); }); } diff --git a/src/subdomainerror.js b/src/subdomainerror.js new file mode 100644 index 000000000..1ded49378 --- /dev/null +++ b/src/subdomainerror.js @@ -0,0 +1,39 @@ +/* jslint node:true */ + +'use strict'; + +var assert = require('assert'), + util = require('util'); + +exports = module.exports = SubdomainError; + +function SubdomainError(reason, errorOrMessage) { + assert.strictEqual(typeof reason, 'string'); + assert(errorOrMessage instanceof Error || typeof errorOrMessage === 'string' || typeof errorOrMessage === 'undefined'); + + Error.call(this); + Error.captureStackTrace(this, this.constructor); + + this.name = this.constructor.name; + this.reason = reason; + if (typeof errorOrMessage === 'undefined') { + this.message = reason; + } else if (typeof errorOrMessage === 'string') { + this.message = errorOrMessage; + } else { + this.message = 'Internal error'; + this.nestedError = errorOrMessage; + } +} +util.inherits(SubdomainError, Error); + +SubdomainError.NOT_FOUND = 'No such domain'; +SubdomainError.INTERNAL_ERROR = 'Internal error'; +SubdomainError.EXTERNAL_ERROR = 'External error'; +SubdomainError.STILL_BUSY = 'Still busy'; +SubdomainError.FAILED_TOO_OFTEN = 'Failed too often'; +SubdomainError.ALREADY_EXISTS = 'Domain already exists'; +SubdomainError.BAD_FIELD = 'Bad Field'; +SubdomainError.BAD_STATE = 'Bad State'; +SubdomainError.INVALID_ZONE_NAME = 'Invalid domain name'; +SubdomainError.INVALID_TASK = 'Invalid task'; diff --git a/src/subdomains.js b/src/subdomains.js new file mode 100644 index 000000000..a65e94e45 --- /dev/null +++ b/src/subdomains.js @@ -0,0 +1,68 @@ +/* jslint node:true */ + +'use strict'; + +var assert = require('assert'), + async = require('async'), + aws = require('./aws.js'), + config = require('./config.js'), + debug = require('debug')('server:subdomains'), + util = require('util'), + SubdomainError = require('./subdomainerror.js'); + +module.exports = exports = { + add: add, + addMany: addMany, + remove: remove, + status: status +}; + +function add(record, callback) { + assert.strictEqual(typeof record, 'object'); + assert.strictEqual(typeof callback, 'function'); + + debug('add: ', record); + + aws.addSubdomain(config.zoneName(), record.subdomain, record.type, record.value, function (error, changeId) { + if (error) return callback(error); + callback(null, changeId); + }); +} + +function addMany(records, callback) { + assert(util.isArray(records)); + assert.strictEqual(typeof callback, 'function'); + + debug('addMany: ', records); + + async.eachSeries(function (record, callback) { + add(record, callback); + }, callback); +} + +function remove(record, callback) { + assert.strictEqual(typeof record, 'object'); + assert.strictEqual(typeof callback, 'function'); + + debug('remove: ', record); + + aws.delSubdomain(config.zoneName(), record.subdomain, record.type, record.value, function (error) { + if (error && error.reason !== SubdomainError.NOT_FOUND) return callback(error); + + debug('deleteSubdomain: successfully deleted subdomain from aws.'); + + callback(null); + }); +} + +function status(changeId, callback) { + assert.strictEqual(typeof changeId, 'string'); + assert.strictEqual(typeof callback, 'function'); + + debug('status: ', changeId); + + aws.getChangeStatus(changeId, function (error, status) { + if (error) return callback(new SubdomainError(SubdomainError.EXTERNAL_ERROR, error)); + callback(null, status === 'INSYNC' ? 'done' : 'pending'); + }); +}