diff --git a/src/cloudron.js b/src/cloudron.js index 06d05fad1..d3e43e01d 100644 --- a/src/cloudron.js +++ b/src/cloudron.js @@ -5,38 +5,29 @@ exports = module.exports = { initialize: initialize, uninitialize: uninitialize, - activate: activate, getConfig: getConfig, - getStatus: getStatus, getDisks: getDisks, - dnsSetup: dnsSetup, getLogs: getLogs, updateToLatest: updateToLatest, - restore: restore, reboot: reboot, + onActivated: onActivated, + checkDiskSpace: checkDiskSpace }; var assert = require('assert'), async = require('async'), backups = require('./backups.js'), - BackupsError = require('./backups.js').BackupsError, caas = require('./caas.js'), - certificates = require('./certificates.js'), - clients = require('./clients.js'), config = require('./config.js'), - constants = require('./constants.js'), cron = require('./cron.js'), debug = require('debug')('box:cloudron'), df = require('@sindresorhus/df'), - domains = require('./domains.js'), - DomainError = domains.DomainError, eventlog = require('./eventlog.js'), fs = require('fs'), locker = require('./locker.js'), - mail = require('./mail.js'), mailer = require('./mailer.js'), nginx = require('./nginx.js'), os = require('os'), @@ -45,31 +36,20 @@ var assert = require('assert'), platform = require('./platform.js'), progress = require('./progress.js'), safe = require('safetydance'), - semver = require('semver'), settings = require('./settings.js'), - settingsdb = require('./settingsdb.js'), - SettingsError = settings.SettingsError, shell = require('./shell.js'), spawn = require('child_process').spawn, split = require('split'), - superagent = require('superagent'), - sysinfo = require('./sysinfo.js'), - tld = require('tldjs'), - tokendb = require('./tokendb.js'), updateChecker = require('./updatechecker.js'), user = require('./user.js'), - UserError = user.UserError, util = require('util'), _ = require('underscore'); var REBOOT_CMD = path.join(__dirname, 'scripts/reboot.sh'), - UPDATE_CMD = path.join(__dirname, 'scripts/update.sh'), - RESTART_CMD = path.join(__dirname, 'scripts/restart.sh'); + UPDATE_CMD = path.join(__dirname, 'scripts/update.sh'); var NOOP_CALLBACK = function (error) { if (error) debug(error); }; -var gWebadminStatus = { dns: false, tls: false, configuring: false, restoring: false }; - function CloudronError(reason, errorOrMessage) { assert.strictEqual(typeof reason, 'string'); assert(errorOrMessage instanceof Error || typeof errorOrMessage === 'string' || typeof errorOrMessage === 'undefined'); @@ -92,8 +72,6 @@ util.inherits(CloudronError, Error); CloudronError.BAD_FIELD = 'Field error'; CloudronError.INTERNAL_ERROR = 'Internal Error'; CloudronError.EXTERNAL_ERROR = 'External Error'; -CloudronError.ALREADY_PROVISIONED = 'Already Provisioned'; -CloudronError.ALREADY_SETUP = 'Already Setup'; CloudronError.BAD_STATE = 'Bad state'; CloudronError.ALREADY_UPTODATE = 'No Update Available'; CloudronError.NOT_FOUND = 'Not found'; @@ -102,20 +80,12 @@ CloudronError.SELF_UPGRADE_NOT_SUPPORTED = 'Self upgrade not supported'; function initialize(callback) { assert.strictEqual(typeof callback, 'function'); - gWebadminStatus = { dns: false, tls: false, configuring: false, restoring: false }; - async.series([ settings.initialize, configureDefaultServer, cron.initialize, // required for caas heartbeat before activation onActivated - ], function (error) { - if (error) return callback(error); - - configureWebadmin(NOOP_CALLBACK); // for restore() and caas initial setup. do not block - - callback(); - }); + ], callback); } function uninitialize(callback) { @@ -142,85 +112,6 @@ function onActivated(callback) { }); } -function autoprovision(callback) { - assert.strictEqual(typeof callback, 'function'); - - const confJson = safe.fs.readFileSync(paths.AUTO_PROVISION_FILE, 'utf8'); - if (!confJson) return callback(); - - const conf = safe.JSON.parse(confJson); - if (!conf) return callback(); - - async.eachSeries(Object.keys(conf), function (key, iteratorDone) { - var name; - switch (key) { - case 'appstoreConfig': name = settings.APPSTORE_CONFIG_KEY; break; - case 'caasConfig': name = settings.CAAS_CONFIG_KEY; break; - case 'tlsConfig': name = settings.TLS_CONFIG_KEY; break; - case 'backupConfig': name = settings.BACKUP_CONFIG_KEY; break; - case 'tlsCert': - debug(`autoprovision: ${key}`); - return fs.writeFile(path.join(paths.NGINX_CERT_DIR, 'host.cert'), conf[key], iteratorDone); - case 'tlsKey': - debug(`autoprovision: ${key}`); - return fs.writeFile(path.join(paths.NGINX_CERT_DIR, 'host.key'), conf[key], iteratorDone); - default: - debug(`autoprovision: ${key} ignored`); - return iteratorDone(); - } - - debug(`autoprovision: ${name}`); - settingsdb.set(name, JSON.stringify(conf[key]), iteratorDone); - }, callback); -} - -function dnsSetup(adminFqdn, domain, zoneName, provider, dnsConfig, callback) { - assert.strictEqual(typeof adminFqdn, 'string'); - assert.strictEqual(typeof domain, 'string'); - assert.strictEqual(typeof zoneName, 'string'); - assert.strictEqual(typeof provider, 'string'); - assert.strictEqual(typeof dnsConfig, 'object'); - assert.strictEqual(typeof callback, 'function'); - - if (config.adminDomain()) return callback(new CloudronError(CloudronError.ALREADY_SETUP)); - - if (!zoneName) zoneName = tld.getDomain(domain) || domain; - - debug('dnsSetup: Setting up Cloudron with domain %s and zone %s', domain, zoneName); - - function done(error) { - if (error && error.reason === DomainError.BAD_FIELD) return callback(new CloudronError(CloudronError.BAD_FIELD, error.message)); - if (error) return callback(new CloudronError(CloudronError.INTERNAL_ERROR, error)); - - autoprovision(function (error) { - if (error) return callback(new CloudronError(CloudronError.INTERNAL_ERROR, error)); - - config.setAdminDomain(domain); // set fqdn only after dns config is valid, otherwise cannot re-setup if we failed - config.setAdminFqdn(adminFqdn); - config.setAdminLocation('my'); - - clients.addDefaultClients(config.adminOrigin(), callback); - - async.series([ // do not block - configureWebadmin - ], NOOP_CALLBACK); - }); - } - - domains.get(domain, function (error, result) { - if (error && error.reason !== DomainError.NOT_FOUND) return callback(new SettingsError(SettingsError.INTERNAL_ERROR, error)); - - if (!result) { - async.series([ - domains.add.bind(null, domain, zoneName, provider, dnsConfig, null /* cert */), - mail.add.bind(null, domain) - ], done); - } else { - domains.update(domain, provider, dnsConfig, null /* cert */, done); - } - }); -} - function configureDefaultServer(callback) { callback = callback || NOOP_CALLBACK; @@ -246,154 +137,6 @@ function configureDefaultServer(callback) { }); } -function configureWebadmin(callback) { - callback = callback || NOOP_CALLBACK; - - debug('configureWebadmin: adminFqdn:%s status:%j', config.adminFqdn(), gWebadminStatus); - - if (process.env.BOX_ENV === 'test' || !config.adminFqdn() || gWebadminStatus.configuring) return callback(); - - gWebadminStatus.configuring = true; // re-entracy guard - - function done(error) { - gWebadminStatus.configuring = false; - debug('configureWebadmin: done error: %j', error || {}); - callback(error); - } - - function configureNginx(error) { - debug('configureNginx: dns update: %j', error || {}); - - certificates.ensureCertificate({ domain: config.adminDomain(), location: config.adminLocation(), intrinsicFqdn: config.adminFqdn() }, function (error, certFilePath, keyFilePath) { - if (error) return done(error); - - gWebadminStatus.tls = true; - - nginx.configureAdmin(certFilePath, keyFilePath, constants.NGINX_ADMIN_CONFIG_FILE_NAME, config.adminFqdn(), done); - }); - } - - function addWebadminDnsRecord(ip, domain, callback) { - assert.strictEqual(typeof ip, 'string'); - assert.strictEqual(typeof domain, 'string'); - assert.strictEqual(typeof callback, 'function'); - - if (process.env.BOX_ENV === 'test') return callback(); - - async.retry({ times: 10, interval: 20000 }, function (retryCallback) { - domains.upsertDNSRecords(config.adminLocation(), domain, 'A', [ ip ], retryCallback); - }, function (error) { - if (error) debug('addWebadminDnsRecord: done updating records with error:', error); - else debug('addWebadminDnsRecord: done'); - - callback(error); - }); - } - - // update the DNS. configure nginx regardless of whether it succeeded so that - // box is accessible even if dns creds are invalid - sysinfo.getPublicIp(function (error, ip) { - if (error) return configureNginx(error); - - addWebadminDnsRecord(ip, config.adminDomain(), function (error) { - if (error) return configureNginx(error); - - domains.waitForDNSRecord(config.adminFqdn(), config.adminDomain(), ip, 'A', { interval: 30000, times: 50000 }, function (error) { - if (error) return configureNginx(error); - - gWebadminStatus.dns = true; - - configureNginx(); - }); - }); - }); -} - -function setTimeZone(ip, callback) { - assert.strictEqual(typeof ip, 'string'); - assert.strictEqual(typeof callback, 'function'); - - debug('setTimeZone ip:%s', ip); - - superagent.get('https://geolocation.cloudron.io/json').query({ ip: ip }).timeout(10 * 1000).end(function (error, result) { - if ((error && !error.response) || result.statusCode !== 200) { - debug('Failed to get geo location: %s', error.message); - return callback(null); - } - - var timezone = safe.query(result.body, 'location.time_zone'); - - if (!timezone || typeof timezone !== 'string') { - debug('No timezone in geoip response : %j', result.body); - return callback(null); - } - - debug('Setting timezone to ', timezone); - - settings.setTimeZone(timezone, callback); - }); -} - -function activate(username, password, email, displayName, ip, auditSource, callback) { - assert.strictEqual(typeof username, 'string'); - assert.strictEqual(typeof password, 'string'); - assert.strictEqual(typeof email, 'string'); - assert.strictEqual(typeof displayName, 'string'); - assert.strictEqual(typeof ip, 'string'); - assert.strictEqual(typeof auditSource, 'object'); - assert.strictEqual(typeof callback, 'function'); - - debug('activating user:%s email:%s', username, email); - - setTimeZone(ip, function () { }); // TODO: get this from user. note that timezone is detected based on the browser location and not the cloudron region - - user.createOwner(username, password, email, displayName, auditSource, function (error, userObject) { - if (error && error.reason === UserError.ALREADY_EXISTS) return callback(new CloudronError(CloudronError.ALREADY_PROVISIONED)); - if (error && error.reason === UserError.BAD_FIELD) return callback(new CloudronError(CloudronError.BAD_FIELD, error.message)); - if (error) return callback(new CloudronError(CloudronError.INTERNAL_ERROR, error)); - - clients.get('cid-webadmin', function (error, result) { - if (error) return callback(new CloudronError(CloudronError.INTERNAL_ERROR, error)); - - // Also generate a token so the admin creation can also act as a login - var token = tokendb.generateToken(); - var expires = Date.now() + constants.DEFAULT_TOKEN_EXPIRATION; - - tokendb.add(token, userObject.id, result.id, expires, '*', function (error) { - if (error) return callback(new CloudronError(CloudronError.INTERNAL_ERROR, error)); - - eventlog.add(eventlog.ACTION_ACTIVATE, auditSource, { }); - - onActivated(); - - callback(null, { token: token, expires: expires }); - }); - }); - }); -} - -function getStatus(callback) { - assert.strictEqual(typeof callback, 'function'); - - user.count(function (error, count) { - if (error) return callback(new CloudronError(CloudronError.INTERNAL_ERROR, error)); - - settings.getCloudronName(function (error, cloudronName) { - if (error) return callback(new CloudronError(CloudronError.INTERNAL_ERROR, error)); - - callback(null, { - activated: count !== 0, - version: config.version(), - apiServerOrigin: config.apiServerOrigin(), // used by CaaS tool - provider: config.provider(), - cloudronName: cloudronName, - adminFqdn: config.adminDomain() ? config.adminFqdn() : null, - webadminStatus: gWebadminStatus - }); - }); - }); -} - function getDisks(callback) { assert.strictEqual(typeof callback, 'function'); @@ -468,42 +211,6 @@ function getConfig(callback) { }); } -function restore(backupConfig, backupId, version, callback) { - assert.strictEqual(typeof backupConfig, 'object'); - assert.strictEqual(typeof backupId, 'string'); - assert.strictEqual(typeof version, 'string'); - assert.strictEqual(typeof callback, 'function'); - - if (!semver.valid(version)) return callback(new CloudronError(CloudronError.BAD_STATE, 'version is not a valid semver')); - if (semver.major(config.version()) !== semver.major(version) || semver.minor(config.version()) !== semver.minor(version)) return callback(new CloudronError(CloudronError.BAD_STATE, `Run cloudron-setup with --version ${version} to restore from this backup`)); - - user.count(function (error, count) { - if (error) return callback(new CloudronError(CloudronError.INTERNAL_ERROR, error)); - if (count) return callback(new CloudronError(CloudronError.ALREADY_PROVISIONED, 'Already activated')); - - backups.testConfig(backupConfig, function (error) { - if (error && error.reason === BackupsError.BAD_FIELD) return callback(new CloudronError(CloudronError.BAD_FIELD, error.message)); - if (error && error.reason === BackupsError.EXTERNAL_ERROR) return callback(new CloudronError(CloudronError.EXTERNAL_ERROR, error.message)); - if (error) return callback(new CloudronError(CloudronError.INTERNAL_ERROR, error)); - - debug(`restore: restoring from ${backupId} from provider ${backupConfig.provider}`); - - gWebadminStatus.restoring = true; - - callback(null); // do no block - - async.series([ - backups.restore.bind(null, backupConfig, backupId), - autoprovision, - shell.sudo.bind(null, 'restart', [ RESTART_CMD ]) - ], function (error) { - debug('restore:', error); - gWebadminStatus.restoring = false; - }); - }); - }); -} - function reboot(callback) { shell.sudo('reboot', [ REBOOT_CMD ], callback); } diff --git a/src/routes/cloudron.js b/src/routes/cloudron.js index 4cef505a6..72009d60d 100644 --- a/src/routes/cloudron.js +++ b/src/routes/cloudron.js @@ -1,12 +1,6 @@ 'use strict'; exports = module.exports = { - activate: activate, - dnsSetup: dnsSetup, - setupTokenAuth: setupTokenAuth, - providerTokenAuth: providerTokenAuth, - getStatus: getStatus, - restore: restore, reboot: reboot, getProgress: getProgress, getConfig: getConfig, @@ -22,16 +16,11 @@ var appstore = require('../appstore.js'), AppstoreError = require('../appstore.js').AppstoreError, assert = require('assert'), async = require('async'), - caas = require('../caas.js'), - CaasError = require('../caas.js').CaasError, cloudron = require('../cloudron.js'), CloudronError = cloudron.CloudronError, - config = require('../config.js'), - debug = require('debug')('box:routes/cloudron'), HttpError = require('connect-lastmile').HttpError, HttpSuccess = require('connect-lastmile').HttpSuccess, progress = require('../progress.js'), - superagent = require('superagent'), updateChecker = require('../updatechecker.js'), _ = require('underscore'); @@ -40,131 +29,6 @@ function auditSource(req) { return { ip: ip, username: req.user ? req.user.username : null, userId: req.user ? req.user.id : null }; } -function activate(req, res, next) { - assert.strictEqual(typeof req.body, 'object'); - - if (typeof req.body.username !== 'string') return next(new HttpError(400, 'username must be string')); - if (typeof req.body.password !== 'string') return next(new HttpError(400, 'password must be string')); - if (typeof req.body.email !== 'string') return next(new HttpError(400, 'email must be string')); - if ('displayName' in req.body && typeof req.body.displayName !== 'string') return next(new HttpError(400, 'displayName must be string')); - - var username = req.body.username; - var password = req.body.password; - var email = req.body.email; - var displayName = req.body.displayName || ''; - - var ip = req.headers['x-forwarded-for'] || req.connection.remoteAddress; - debug('activate: username:%s ip:%s', username, ip); - - cloudron.activate(username, password, email, displayName, ip, auditSource(req), function (error, info) { - if (error && error.reason === CloudronError.ALREADY_PROVISIONED) return next(new HttpError(409, 'Already setup')); - if (error && error.reason === CloudronError.BAD_FIELD) return next(new HttpError(400, error.message)); - if (error) return next(new HttpError(500, error)); - - // only in caas case do we have to notify the api server about activation - if (config.provider() !== 'caas') return next(new HttpSuccess(201, info)); - - caas.setupDone(req.query.setupToken, function (error) { - if (error && error.reason === CaasError.BAD_STATE) return next(new HttpError(409, 'Already setup')); - if (error && error.reason === CaasError.INVALID_TOKEN) return next(new HttpError(403, 'Invalid token')); - if (error && error.reason === CaasError.EXTERNAL_ERROR) return next(new HttpError(503, error.message)); - - if (error) return next(new HttpError(500, error)); - - next(new HttpSuccess(201, info)); - }); - }); -} - -function restore(req, res, next) { - assert.strictEqual(typeof req.body, 'object'); - - if (!req.body.backupConfig || typeof req.body.backupConfig !== 'object') return next(new HttpError(400, 'backupConfig is required')); - - var backupConfig = req.body.backupConfig; - if (typeof backupConfig.provider !== 'string') return next(new HttpError(400, 'provider is required')); - if ('key' in backupConfig && typeof backupConfig.key !== 'string') return next(new HttpError(400, 'key must be a string')); - if (typeof backupConfig.format !== 'string') return next(new HttpError(400, 'format must be a string')); - if ('acceptSelfSignedCerts' in backupConfig && typeof backupConfig.acceptSelfSignedCerts !== 'boolean') return next(new HttpError(400, 'format must be a boolean')); - - if (typeof req.body.backupId !== 'string') return next(new HttpError(400, 'backupId must be a string or null')); - if (typeof req.body.version !== 'string') return next(new HttpError(400, 'version must be a string')); - - cloudron.restore(backupConfig, req.body.backupId, req.body.version, function (error) { - if (error && error.reason === CloudronError.ALREADY_SETUP) return next(new HttpError(409, error.message)); - if (error && error.reason === CloudronError.BAD_FIELD) return next(new HttpError(400, error.message)); - if (error && error.reason === CloudronError.BAD_STATE) return next(new HttpError(409, error.message)); - if (error && error.reason === CloudronError.EXTERNAL_ERROR) return next(new HttpError(402, error.message)); - if (error) return next(new HttpError(500, error)); - - next(new HttpSuccess(200)); - }); -} - -function dnsSetup(req, res, next) { - assert.strictEqual(typeof req.body, 'object'); - - if (typeof req.body.provider !== 'string' || !req.body.provider) return next(new HttpError(400, 'provider is required')); - if (typeof req.body.domain !== 'string' || !req.body.domain) return next(new HttpError(400, 'domain is required')); - if (typeof req.body.adminFqdn !== 'string' || !req.body.domain) return next(new HttpError(400, 'adminFqdn is required')); - - if ('zoneName' in req.body && typeof req.body.zoneName !== 'string') return next(new HttpError(400, 'zoneName must be a string')); - if (!req.body.config || typeof req.body.config !== 'object') return next(new HttpError(400, 'config must be an object')); - - cloudron.dnsSetup(req.body.adminFqdn.toLowerCase(), req.body.domain.toLowerCase(), req.body.zoneName || '', req.body.provider, req.body.config, function (error) { - if (error && error.reason === CloudronError.ALREADY_SETUP) return next(new HttpError(409, error.message)); - if (error && error.reason === CloudronError.BAD_FIELD) return next(new HttpError(400, error.message)); - if (error) return next(new HttpError(500, error)); - - next(new HttpSuccess(200)); - }); -} - -function setupTokenAuth(req, res, next) { - assert.strictEqual(typeof req.query, 'object'); - - if (config.provider() !== 'caas') return next(); - - if (typeof req.query.setupToken !== 'string' || !req.query.setupToken) return next(new HttpError(400, 'setupToken must be a non empty string')); - - caas.verifySetupToken(req.query.setupToken, function (error) { - if (error && error.reason === CaasError.BAD_STATE) return next(new HttpError(409, 'Already setup')); - if (error && error.reason === CaasError.INVALID_TOKEN) return next(new HttpError(403, 'Invalid token')); - if (error && error.reason === CaasError.EXTERNAL_ERROR) return next(new HttpError(503, error.message)); - - if (error) return next(new HttpError(500, error)); - - next(); - }); -} - -function providerTokenAuth(req, res, next) { - assert.strictEqual(typeof req.body, 'object'); - - if (config.provider() === 'ami') { - if (typeof req.body.providerToken !== 'string' || !req.body.providerToken) return next(new HttpError(400, 'providerToken must be a non empty string')); - - superagent.get('http://169.254.169.254/latest/meta-data/instance-id').timeout(30 * 1000).end(function (error, result) { - if (error && !error.response) return next(new HttpError(500, error)); - if (result.statusCode !== 200) return next(new HttpError(500, 'Unable to get meta data')); - - if (result.text !== req.body.providerToken) return next(new HttpError(403, 'Invalid providerToken')); - - next(); - }); - } else { - next(); - } -} - -function getStatus(req, res, next) { - cloudron.getStatus(function (error, status) { - if (error) return next(new HttpError(500, error)); - - next(new HttpSuccess(200, status)); - }); -} - function getProgress(req, res, next) { return next(new HttpSuccess(200, progress.getAll())); } diff --git a/src/routes/index.js b/src/routes/index.js index 1e112aacc..99d50a44b 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -14,6 +14,7 @@ exports = module.exports = { oauth2: require('./oauth2.js'), mail: require('./mail.js'), profile: require('./profile.js'), + setup: require('./setup.js'), sysadmin: require('./sysadmin.js'), settings: require('./settings.js'), ssh: require('./ssh.js'), diff --git a/src/routes/setup.js b/src/routes/setup.js new file mode 100644 index 000000000..b8d9aac5b --- /dev/null +++ b/src/routes/setup.js @@ -0,0 +1,151 @@ +'use strict'; + +exports = module.exports = { + providerTokenAuth: providerTokenAuth, + setupTokenAuth: setupTokenAuth, + dnsSetup: dnsSetup, + activate: activate, + restore: restore, + getStatus: getStatus, +}; + +var assert = require('assert'), + caas = require('../caas.js'), + CaasError = require('../caas.js').CaasError, + config = require('../config.js'), + debug = require('debug')('box:routes/setup'), + HttpError = require('connect-lastmile').HttpError, + HttpSuccess = require('connect-lastmile').HttpSuccess, + setup = require('../setup.js'), + SetupError = require('../setup.js').SetupError, + superagent = require('superagent'); + +function auditSource(req) { + var ip = req.headers['x-forwarded-for'] || req.connection.remoteAddress || null; + return { ip: ip, username: req.user ? req.user.username : null, userId: req.user ? req.user.id : null }; +} + +function providerTokenAuth(req, res, next) { + assert.strictEqual(typeof req.body, 'object'); + + if (config.provider() === 'ami') { + if (typeof req.body.providerToken !== 'string' || !req.body.providerToken) return next(new HttpError(400, 'providerToken must be a non empty string')); + + superagent.get('http://169.254.169.254/latest/meta-data/instance-id').timeout(30 * 1000).end(function (error, result) { + if (error && !error.response) return next(new HttpError(500, error)); + if (result.statusCode !== 200) return next(new HttpError(500, 'Unable to get meta data')); + + if (result.text !== req.body.providerToken) return next(new HttpError(403, 'Invalid providerToken')); + + next(); + }); + } else { + next(); + } +} + +function setupTokenAuth(req, res, next) { + assert.strictEqual(typeof req.query, 'object'); + + if (config.provider() !== 'caas') return next(); + + if (typeof req.query.setupToken !== 'string' || !req.query.setupToken) return next(new HttpError(400, 'setupToken must be a non empty string')); + + caas.verifySetupToken(req.query.setupToken, function (error) { + if (error && error.reason === CaasError.BAD_STATE) return next(new HttpError(409, 'Already setup')); + if (error && error.reason === CaasError.INVALID_TOKEN) return next(new HttpError(403, 'Invalid token')); + if (error && error.reason === CaasError.EXTERNAL_ERROR) return next(new HttpError(503, error.message)); + + if (error) return next(new HttpError(500, error)); + + next(); + }); +} + +function dnsSetup(req, res, next) { + assert.strictEqual(typeof req.body, 'object'); + + if (typeof req.body.provider !== 'string' || !req.body.provider) return next(new HttpError(400, 'provider is required')); + if (typeof req.body.domain !== 'string' || !req.body.domain) return next(new HttpError(400, 'domain is required')); + if (typeof req.body.adminFqdn !== 'string' || !req.body.domain) return next(new HttpError(400, 'adminFqdn is required')); + + if ('zoneName' in req.body && typeof req.body.zoneName !== 'string') return next(new HttpError(400, 'zoneName must be a string')); + if (!req.body.config || typeof req.body.config !== 'object') return next(new HttpError(400, 'config must be an object')); + + setup.dnsSetup(req.body.adminFqdn.toLowerCase(), req.body.domain.toLowerCase(), req.body.zoneName || '', req.body.provider, req.body.config, function (error) { + if (error && error.reason === SetupError.ALREADY_SETUP) return next(new HttpError(409, error.message)); + if (error && error.reason === SetupError.BAD_FIELD) return next(new HttpError(400, error.message)); + if (error) return next(new HttpError(500, error)); + + next(new HttpSuccess(200)); + }); +} + +function getStatus(req, res, next) { + setup.getStatus(function (error, status) { + if (error) return next(new HttpError(500, error)); + + next(new HttpSuccess(200, status)); + }); +} + +function activate(req, res, next) { + assert.strictEqual(typeof req.body, 'object'); + + if (typeof req.body.username !== 'string') return next(new HttpError(400, 'username must be string')); + if (typeof req.body.password !== 'string') return next(new HttpError(400, 'password must be string')); + if (typeof req.body.email !== 'string') return next(new HttpError(400, 'email must be string')); + if ('displayName' in req.body && typeof req.body.displayName !== 'string') return next(new HttpError(400, 'displayName must be string')); + + var username = req.body.username; + var password = req.body.password; + var email = req.body.email; + var displayName = req.body.displayName || ''; + + var ip = req.headers['x-forwarded-for'] || req.connection.remoteAddress; + debug('activate: username:%s ip:%s', username, ip); + + setup.activate(username, password, email, displayName, ip, auditSource(req), function (error, info) { + if (error && error.reason === SetupError.ALREADY_PROVISIONED) return next(new HttpError(409, 'Already setup')); + if (error && error.reason === SetupError.BAD_FIELD) return next(new HttpError(400, error.message)); + if (error) return next(new HttpError(500, error)); + + // only in caas case do we have to notify the api server about activation + if (config.provider() !== 'caas') return next(new HttpSuccess(201, info)); + + caas.setupDone(req.query.setupToken, function (error) { + if (error && error.reason === CaasError.BAD_STATE) return next(new HttpError(409, 'Already setup')); + if (error && error.reason === CaasError.INVALID_TOKEN) return next(new HttpError(403, 'Invalid token')); + if (error && error.reason === CaasError.EXTERNAL_ERROR) return next(new HttpError(503, error.message)); + + if (error) return next(new HttpError(500, error)); + + next(new HttpSuccess(201, info)); + }); + }); +} + +function restore(req, res, next) { + assert.strictEqual(typeof req.body, 'object'); + + if (!req.body.backupConfig || typeof req.body.backupConfig !== 'object') return next(new HttpError(400, 'backupConfig is required')); + + var backupConfig = req.body.backupConfig; + if (typeof backupConfig.provider !== 'string') return next(new HttpError(400, 'provider is required')); + if ('key' in backupConfig && typeof backupConfig.key !== 'string') return next(new HttpError(400, 'key must be a string')); + if (typeof backupConfig.format !== 'string') return next(new HttpError(400, 'format must be a string')); + if ('acceptSelfSignedCerts' in backupConfig && typeof backupConfig.acceptSelfSignedCerts !== 'boolean') return next(new HttpError(400, 'format must be a boolean')); + + if (typeof req.body.backupId !== 'string') return next(new HttpError(400, 'backupId must be a string or null')); + if (typeof req.body.version !== 'string') return next(new HttpError(400, 'version must be a string')); + + setup.restore(backupConfig, req.body.backupId, req.body.version, function (error) { + if (error && error.reason === SetupError.ALREADY_SETUP) return next(new HttpError(409, error.message)); + if (error && error.reason === SetupError.BAD_FIELD) return next(new HttpError(400, error.message)); + if (error && error.reason === SetupError.BAD_STATE) return next(new HttpError(409, error.message)); + if (error && error.reason === SetupError.EXTERNAL_ERROR) return next(new HttpError(402, error.message)); + if (error) return next(new HttpError(500, error)); + + next(new HttpSuccess(200)); + }); +} diff --git a/src/server.js b/src/server.js index 71f0ab252..b6faccd25 100644 --- a/src/server.js +++ b/src/server.js @@ -97,12 +97,12 @@ function initializeExpressSync() { var csrf = routes.oauth2.csrf; // public routes - router.post('/api/v1/cloudron/dns_setup', routes.cloudron.providerTokenAuth, routes.cloudron.dnsSetup); // only available until no-domain - router.post('/api/v1/cloudron/activate', routes.cloudron.setupTokenAuth, routes.cloudron.activate); - router.post('/api/v1/cloudron/restore', routes.cloudron.restore); // only available until activated + router.post('/api/v1/cloudron/dns_setup', routes.setup.providerTokenAuth, routes.setup.dnsSetup); // only available until no-domain + router.post('/api/v1/cloudron/restore', routes.setup.restore); // only available until activated + router.post('/api/v1/cloudron/activate', routes.setup.setupTokenAuth, routes.setup.activate); + router.get ('/api/v1/cloudron/status', routes.setup.getStatus); router.get ('/api/v1/cloudron/progress', routes.cloudron.getProgress); - router.get ('/api/v1/cloudron/status', routes.cloudron.getStatus); router.get ('/api/v1/cloudron/avatar', routes.settings.getCloudronAvatar); // this is a public alias for /api/v1/settings/cloudron_avatar // developer routes diff --git a/src/setup.js b/src/setup.js new file mode 100644 index 000000000..060b46c57 --- /dev/null +++ b/src/setup.js @@ -0,0 +1,342 @@ +'use strict'; + +exports = module.exports = { + dnsSetup: dnsSetup, + restore: restore, + getStatus: getStatus, + activate: activate, + + SetupError: SetupError +}; + +var assert = require('assert'), + async = require('async'), + backups = require('./backups.js'), + BackupsError = require('./backups.js').BackupsError, + certificates = require('./certificates.js'), + config = require('./config.js'), + constants = require('./constants.js'), + clients = require('./clients.js'), + cloudron = require('./cloudron.js'), + debug = require('debug')('box:setup'), + domains = require('./domains.js'), + DomainError = domains.DomainError, + eventlog = require('./eventlog.js'), + fs = require('fs'), + mail = require('./mail.js'), + nginx = require('./nginx.js'), + path = require('path'), + paths = require('./paths.js'), + safe = require('safetydance'), + semver = require('semver'), + settingsdb = require('./settingsdb.js'), + settings = require('./settings.js'), + SettingsError = settings.SettingsError, + shell = require('./shell.js'), + superagent = require('superagent'), + sysinfo = require('./sysinfo.js'), + tokendb = require('./tokendb.js'), + user = require('./user.js'), + UserError = user.UserError, + tld = require('tldjs'), + util = require('util'); + +var RESTART_CMD = path.join(__dirname, 'scripts/restart.sh'); + +var NOOP_CALLBACK = function (error) { if (error) debug(error); }; + +var gWebadminStatus = { + dns: false, + tls: false, + configuring: false, + restoring: false +}; + +function SetupError(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(SetupError, Error); +SetupError.BAD_FIELD = 'Field error'; +SetupError.BAD_STATE = 'Field error'; +SetupError.ALREADY_SETUP = 'Already Setup'; +SetupError.INTERNAL_ERROR = 'Internal Error'; +SetupError.EXTERNAL_ERROR = 'External Error'; +SetupError.ALREADY_PROVISIONED = 'Already Provisioned'; + +function autoprovision(callback) { + assert.strictEqual(typeof callback, 'function'); + + const confJson = safe.fs.readFileSync(paths.AUTO_PROVISION_FILE, 'utf8'); + if (!confJson) return callback(); + + const conf = safe.JSON.parse(confJson); + if (!conf) return callback(); + + async.eachSeries(Object.keys(conf), function (key, iteratorDone) { + var name; + switch (key) { + case 'appstoreConfig': name = settings.APPSTORE_CONFIG_KEY; break; + case 'caasConfig': name = settings.CAAS_CONFIG_KEY; break; + case 'tlsConfig': name = settings.TLS_CONFIG_KEY; break; + case 'backupConfig': name = settings.BACKUP_CONFIG_KEY; break; + case 'tlsCert': + debug(`autoprovision: ${key}`); + return fs.writeFile(path.join(paths.NGINX_CERT_DIR, 'host.cert'), conf[key], iteratorDone); + case 'tlsKey': + debug(`autoprovision: ${key}`); + return fs.writeFile(path.join(paths.NGINX_CERT_DIR, 'host.key'), conf[key], iteratorDone); + default: + debug(`autoprovision: ${key} ignored`); + return iteratorDone(); + } + + debug(`autoprovision: ${name}`); + settingsdb.set(name, JSON.stringify(conf[key]), iteratorDone); + }, callback); +} + +function configureWebadmin(callback) { + callback = callback || NOOP_CALLBACK; + + debug('configureWebadmin: adminDomain:%s status:%j', config.adminDomain(), gWebadminStatus); + + if (process.env.BOX_ENV === 'test' || !config.adminDomain() || gWebadminStatus.configuring) return callback(); + + gWebadminStatus.configuring = true; // re-entracy guard + + function done(error) { + gWebadminStatus.configuring = false; + debug('configureWebadmin: done error: %j', error || {}); + callback(error); + } + + function configureNginx(error) { + debug('configureNginx: dns update: %j', error || {}); + + certificates.ensureCertificate({ domain: config.adminDomain(), location: config.adminLocation(), intrinsicFqdn: config.adminFqdn() }, function (error, certFilePath, keyFilePath) { + if (error) return done(error); + + gWebadminStatus.tls = true; + + nginx.configureAdmin(certFilePath, keyFilePath, constants.NGINX_ADMIN_CONFIG_FILE_NAME, config.adminFqdn(), done); + }); + } + + function addWebadminDnsRecord(ip, domain, callback) { + assert.strictEqual(typeof ip, 'string'); + assert.strictEqual(typeof domain, 'string'); + assert.strictEqual(typeof callback, 'function'); + + if (process.env.BOX_ENV === 'test') return callback(); + + async.retry({ times: 10, interval: 20000 }, function (retryCallback) { + domains.upsertDNSRecords(config.adminLocation(), domain, 'A', [ ip ], retryCallback); + }, function (error) { + if (error) debug('addWebadminDnsRecord: done updating records with error:', error); + else debug('addWebadminDnsRecord: done'); + + callback(error); + }); + } + + // update the DNS. configure nginx regardless of whether it succeeded so that + // box is accessible even if dns creds are invalid + sysinfo.getPublicIp(function (error, ip) { + if (error) return configureNginx(error); + + addWebadminDnsRecord(ip, config.adminDomain(), function (error) { + if (error) return configureNginx(error); + + domains.waitForDNSRecord(config.adminFqdn(), config.adminDomain(), ip, 'A', { interval: 30000, times: 50000 }, function (error) { + if (error) return configureNginx(error); + + gWebadminStatus.dns = true; + + configureNginx(); + }); + }); + }); +} + +function dnsSetup(adminFqdn, domain, zoneName, provider, dnsConfig, callback) { + assert.strictEqual(typeof adminFqdn, 'string'); + assert.strictEqual(typeof domain, 'string'); + assert.strictEqual(typeof zoneName, 'string'); + assert.strictEqual(typeof provider, 'string'); + assert.strictEqual(typeof dnsConfig, 'object'); + assert.strictEqual(typeof callback, 'function'); + + if (config.adminDomain()) return callback(new SetupError(SetupError.ALREADY_SETUP)); + + if (!zoneName) zoneName = tld.getDomain(domain) || domain; + + debug('dnsSetup: Setting up Cloudron with domain %s and zone %s', domain, zoneName); + + function done(error) { + if (error && error.reason === DomainError.BAD_FIELD) return callback(new SetupError(SetupError.BAD_FIELD, error.message)); + if (error) return callback(new SetupError(SetupError.INTERNAL_ERROR, error)); + + autoprovision(function (error) { + if (error) return callback(new SetupError(SetupError.INTERNAL_ERROR, error)); + + config.setAdminDomain(domain); // set fqdn only after dns config is valid, otherwise cannot re-setup if we failed + config.setAdminFqdn(adminFqdn); + config.setAdminLocation('my'); + + clients.addDefaultClients(config.adminOrigin(), callback); + + async.series([ // do not block + configureWebadmin + ], NOOP_CALLBACK); + }); + } + + domains.get(domain, function (error, result) { + if (error && error.reason !== DomainError.NOT_FOUND) return callback(new SettingsError(SettingsError.INTERNAL_ERROR, error)); + + if (!result) { + async.series([ + domains.add.bind(null, domain, zoneName, provider, dnsConfig, null /* cert */), + mail.add.bind(null, domain) + ], done); + } else { + domains.update(domain, provider, dnsConfig, null /* cert */, done); + } + }); +} + +function setTimeZone(ip, callback) { + assert.strictEqual(typeof ip, 'string'); + assert.strictEqual(typeof callback, 'function'); + + debug('setTimeZone ip:%s', ip); + + superagent.get('https://geolocation.cloudron.io/json').query({ ip: ip }).timeout(10 * 1000).end(function (error, result) { + if ((error && !error.response) || result.statusCode !== 200) { + debug('Failed to get geo location: %s', error.message); + return callback(null); + } + + var timezone = safe.query(result.body, 'location.time_zone'); + + if (!timezone || typeof timezone !== 'string') { + debug('No timezone in geoip response : %j', result.body); + return callback(null); + } + + debug('Setting timezone to ', timezone); + + settings.setTimeZone(timezone, callback); + }); +} + +function activate(username, password, email, displayName, ip, auditSource, callback) { + assert.strictEqual(typeof username, 'string'); + assert.strictEqual(typeof password, 'string'); + assert.strictEqual(typeof email, 'string'); + assert.strictEqual(typeof displayName, 'string'); + assert.strictEqual(typeof ip, 'string'); + assert.strictEqual(typeof auditSource, 'object'); + assert.strictEqual(typeof callback, 'function'); + + debug('activating user:%s email:%s', username, email); + + setTimeZone(ip, function () { }); // TODO: get this from user. note that timezone is detected based on the browser location and not the cloudron region + + user.createOwner(username, password, email, displayName, auditSource, function (error, userObject) { + if (error && error.reason === UserError.ALREADY_EXISTS) return callback(new SetupError(SetupError.ALREADY_PROVISIONED)); + if (error && error.reason === UserError.BAD_FIELD) return callback(new SetupError(SetupError.BAD_FIELD, error.message)); + if (error) return callback(new SetupError(SetupError.INTERNAL_ERROR, error)); + + clients.get('cid-webadmin', function (error, result) { + if (error) return callback(new SetupError(SetupError.INTERNAL_ERROR, error)); + + // Also generate a token so the admin creation can also act as a login + var token = tokendb.generateToken(); + var expires = Date.now() + constants.DEFAULT_TOKEN_EXPIRATION; + + tokendb.add(token, userObject.id, result.id, expires, '*', function (error) { + if (error) return callback(new SetupError(SetupError.INTERNAL_ERROR, error)); + + eventlog.add(eventlog.ACTION_ACTIVATE, auditSource, { }); + + cloudron.onActivated(); + + callback(null, { token: token, expires: expires }); + }); + }); + }); +} + +function restore(backupConfig, backupId, version, callback) { + assert.strictEqual(typeof backupConfig, 'object'); + assert.strictEqual(typeof backupId, 'string'); + assert.strictEqual(typeof version, 'string'); + assert.strictEqual(typeof callback, 'function'); + + if (!semver.valid(version)) return callback(new SetupError(SetupError.BAD_STATE, 'version is not a valid semver')); + if (semver.major(config.version()) !== semver.major(version) || semver.minor(config.version()) !== semver.minor(version)) return callback(new SetupError(SetupError.BAD_STATE, `Run cloudron-setup with --version ${version} to restore from this backup`)); + + user.count(function (error, count) { + if (error) return callback(new SetupError(SetupError.INTERNAL_ERROR, error)); + if (count) return callback(new SetupError(SetupError.ALREADY_PROVISIONED, 'Already activated')); + + backups.testConfig(backupConfig, function (error) { + if (error && error.reason === BackupsError.BAD_FIELD) return callback(new SetupError(SetupError.BAD_FIELD, error.message)); + if (error && error.reason === BackupsError.EXTERNAL_ERROR) return callback(new SetupError(SetupError.EXTERNAL_ERROR, error.message)); + if (error) return callback(new SetupError(SetupError.INTERNAL_ERROR, error)); + + debug(`restore: restoring from ${backupId} from provider ${backupConfig.provider}`); + + gWebadminStatus.restoring = true; + + callback(null); // do no block + + async.series([ + backups.restore.bind(null, backupConfig, backupId), + autoprovision, + shell.sudo.bind(null, 'restart', [ RESTART_CMD ]) + ], function (error) { + debug('restore:', error); + gWebadminStatus.restoring = false; + }); + }); + }); +} + +function getStatus(callback) { + assert.strictEqual(typeof callback, 'function'); + + user.count(function (error, count) { + if (error) return callback(new SetupError(SetupError.INTERNAL_ERROR, error)); + + settings.getCloudronName(function (error, cloudronName) { + if (error) return callback(new SetupError(SetupError.INTERNAL_ERROR, error)); + + callback(null, { + version: config.version(), + apiServerOrigin: config.apiServerOrigin(), // used by CaaS tool + provider: config.provider(), + cloudronName: cloudronName, + adminFqdn: config.adminDomain() ? config.adminFqdn() : null, + activated: count !== 0, + webadminStatus: gWebadminStatus // only valid when !activated + }); + }); + }); +}