app.portBindings and newManifest.tcpPorts may be null

This commit is contained in:
Girish Ramakrishnan
2015-07-20 00:09:47 -07:00
commit df9d321ac3
243 changed files with 42623 additions and 0 deletions

768
src/addons.js Normal file
View File

@@ -0,0 +1,768 @@
'use strict';
exports = module.exports = {
setupAddons: setupAddons,
teardownAddons: teardownAddons,
backupAddons: backupAddons,
restoreAddons: restoreAddons,
getEnvironment: getEnvironment,
getLinksSync: getLinksSync,
getBindsSync: getBindsSync,
// exported for testing
_allocateOAuthCredentials: allocateOAuthCredentials,
_removeOAuthCredentials: removeOAuthCredentials
};
var appdb = require('./appdb.js'),
assert = require('assert'),
async = require('async'),
child_process = require('child_process'),
clientdb = require('./clientdb.js'),
config = require('./config.js'),
DatabaseError = require('./databaseerror.js'),
debug = require('debug')('box:addons'),
docker = require('./docker.js'),
fs = require('fs'),
generatePassword = require('password-generator'),
hat = require('hat'),
MemoryStream = require('memorystream'),
once = require('once'),
os = require('os'),
path = require('path'),
paths = require('./paths.js'),
safe = require('safetydance'),
shell = require('./shell.js'),
spawn = child_process.spawn,
tokendb = require('./tokendb.js'),
util = require('util'),
uuid = require('node-uuid'),
vbox = require('./vbox.js'),
_ = require('underscore');
var NOOP = function (app, callback) { return callback(); };
// setup can be called multiple times for the same app (configure crash restart) and existing data must not be lost
// teardown is destructive. app data stored with the addon is lost
var KNOWN_ADDONS = {
oauth: {
setup: allocateOAuthCredentials,
teardown: removeOAuthCredentials,
backup: NOOP,
restore: allocateOAuthCredentials
},
token: {
setup: allocateAccessToken,
teardown: removeAccessToken,
backup: NOOP,
restore: allocateAccessToken
},
ldap: {
setup: setupLdap,
teardown: teardownLdap,
backup: NOOP,
restore: setupLdap
},
sendmail: {
setup: setupSendMail,
teardown: teardownSendMail,
backup: NOOP,
restore: setupSendMail
},
mysql: {
setup: setupMySql,
teardown: teardownMySql,
backup: backupMySql,
restore: restoreMySql,
},
postgresql: {
setup: setupPostgreSql,
teardown: teardownPostgreSql,
backup: backupPostgreSql,
restore: restorePostgreSql
},
mongodb: {
setup: setupMongoDb,
teardown: teardownMongoDb,
backup: backupMongoDb,
restore: restoreMongoDb
},
redis: {
setup: setupRedis,
teardown: teardownRedis,
backup: NOOP, // no backup because we store redis as part of app's volume
restore: setupRedis // same thing
},
localstorage: {
setup: NOOP, // docker creates the directory for us
teardown: NOOP,
backup: NOOP, // no backup because it's already inside app data
restore: NOOP
},
_docker: {
setup: NOOP,
teardown: NOOP,
backup: NOOP,
restore: NOOP
}
};
var RMAPPDIR_CMD = path.join(__dirname, 'scripts/rmappdir.sh');
function debugApp(app, args) {
assert(!app || typeof app === 'object');
var prefix = app ? (app.location || 'naked_domain') : '(no app)';
debug(prefix + ' ' + util.format.apply(util, Array.prototype.slice.call(arguments, 1)));
}
function setupAddons(app, addons, callback) {
assert.strictEqual(typeof app, 'object');
assert(!addons || typeof addons === 'object');
assert.strictEqual(typeof callback, 'function');
if (!addons) return callback(null);
async.eachSeries(Object.keys(addons), function iterator(addon, iteratorCallback) {
if (!(addon in KNOWN_ADDONS)) return iteratorCallback(new Error('No such addon:' + addon));
debugApp(app, 'Setting up addon %s', addon);
KNOWN_ADDONS[addon].setup(app, iteratorCallback);
}, callback);
}
function teardownAddons(app, addons, callback) {
assert.strictEqual(typeof app, 'object');
assert(!addons || typeof addons === 'object');
assert.strictEqual(typeof callback, 'function');
if (!addons) return callback(null);
async.eachSeries(Object.keys(addons), function iterator(addon, iteratorCallback) {
if (!(addon in KNOWN_ADDONS)) return iteratorCallback(new Error('No such addon:' + addon));
debugApp(app, 'Tearing down addon %s', addon);
KNOWN_ADDONS[addon].teardown(app, iteratorCallback);
}, callback);
}
function backupAddons(app, addons, callback) {
assert.strictEqual(typeof app, 'object');
assert(!addons || typeof addons === 'object');
assert.strictEqual(typeof callback, 'function');
debugApp(app, 'backupAddons');
if (!addons) return callback(null);
async.eachSeries(Object.keys(addons), function iterator (addon, iteratorCallback) {
if (!(addon in KNOWN_ADDONS)) return iteratorCallback(new Error('No such addon:' + addon));
KNOWN_ADDONS[addon].backup(app, iteratorCallback);
}, callback);
}
function restoreAddons(app, addons, callback) {
assert.strictEqual(typeof app, 'object');
assert(!addons || typeof addons === 'object');
assert.strictEqual(typeof callback, 'function');
debugApp(app, 'restoreAddons');
if (!addons) return callback(null);
async.eachSeries(Object.keys(addons), function iterator (addon, iteratorCallback) {
if (!(addon in KNOWN_ADDONS)) return iteratorCallback(new Error('No such addon:' + addon));
KNOWN_ADDONS[addon].restore(app, iteratorCallback);
}, callback);
}
function getEnvironment(app, callback) {
assert.strictEqual(typeof app, 'object');
assert.strictEqual(typeof callback, 'function');
appdb.getAddonConfigByAppId(app.id, callback);
}
function getLinksSync(app, addons) {
assert.strictEqual(typeof app, 'object');
assert(!addons || typeof addons === 'object');
var links = [ ];
if (!addons) return links;
for (var addon in addons) {
switch (addon) {
case 'mysql': links.push('mysql:mysql'); break;
case 'postgresql': links.push('postgresql:postgresql'); break;
case 'sendmail': links.push('mail:mail'); break;
case 'redis': links.push('redis-' + app.id + ':redis-' + app.id); break;
case 'mongodb': links.push('mongodb:mongodb'); break;
default: break;
}
}
return links;
}
function getBindsSync(app, addons) {
assert.strictEqual(typeof app, 'object');
assert(!addons || typeof addons === 'object');
var binds = [ ];
if (!addons) return binds;
for (var addon in addons) {
switch (addon) {
case '_docker': binds.push('/var/run/docker.sock:/var/run/docker.sock:rw'); break;
case 'localstorage': binds.push(path.join(paths.DATA_DIR, app.id, 'data') + ':/app/data:rw'); break;
default: break;
}
}
return binds;
}
function allocateOAuthCredentials(app, callback) {
assert.strictEqual(typeof app, 'object');
assert.strictEqual(typeof callback, 'function');
var appId = app.id;
var id = 'cid-addon-' + uuid.v4();
var clientSecret = hat(256);
var redirectURI = 'https://' + config.appFqdn(app.location);
var scope = 'profile,roleUser';
debugApp(app, 'allocateOAuthCredentials: id:%s clientSecret:%s', id, clientSecret);
clientdb.delByAppId('addon-' + appId, function (error) { // remove existing creds
if (error && error.reason !== DatabaseError.NOT_FOUND) return callback(error);
clientdb.add(id, 'addon-' + appId, clientSecret, redirectURI, scope, function (error) {
if (error) return callback(error);
var env = [
'OAUTH_CLIENT_ID=' + id,
'OAUTH_CLIENT_SECRET=' + clientSecret,
'OAUTH_ORIGIN=' + config.adminOrigin()
];
debugApp(app, 'Setting oauth addon config to %j', env);
appdb.setAddonConfig(appId, 'oauth', env, callback);
});
});
}
function removeOAuthCredentials(app, callback) {
assert.strictEqual(typeof app, 'object');
assert.strictEqual(typeof callback, 'function');
debugApp(app, 'removeOAuthCredentials');
clientdb.delByAppId('addon-' + app.id, function (error) {
if (error && error.reason !== DatabaseError.NOT_FOUND) console.error(error);
appdb.unsetAddonConfig(app.id, 'oauth', callback);
});
}
function setupLdap(app, callback) {
assert.strictEqual(typeof app, 'object');
assert.strictEqual(typeof callback, 'function');
var env = [
'LDAP_SERVER=172.17.42.1',
'LDAP_PORT=3002',
'LDAP_URL=ldap://172.17.42.1:3002',
'LDAP_USERS_BASE_DN=ou=users,dc=cloudron',
'LDAP_GROUPS_BASE_DN=ou=groups,dc=cloudron'
];
debugApp(app, 'Setting up LDAP');
appdb.setAddonConfig(app.id, 'ldap', env, callback);
}
function teardownLdap(app, callback) {
assert.strictEqual(typeof app, 'object');
assert.strictEqual(typeof callback, 'function');
debugApp(app, 'Tearing down LDAP');
appdb.unsetAddonConfig(app.id, 'ldap', callback);
}
function setupSendMail(app, callback) {
assert.strictEqual(typeof app, 'object');
assert.strictEqual(typeof callback, 'function');
var env = [
'MAIL_SMTP_SERVER=mail',
'MAIL_SMTP_PORT=25',
'MAIL_SMTP_USERNAME=' + (app.location || app.id), // use app.id for bare domains
'MAIL_DOMAIN=' + config.fqdn()
];
debugApp(app, 'Setting up sendmail');
appdb.setAddonConfig(app.id, 'sendmail', env, callback);
}
function teardownSendMail(app, callback) {
assert.strictEqual(typeof app, 'object');
assert.strictEqual(typeof callback, 'function');
debugApp(app, 'Tearing down sendmail');
appdb.unsetAddonConfig(app.id, 'sendmail', callback);
}
function setupMySql(app, callback) {
assert.strictEqual(typeof app, 'object');
assert.strictEqual(typeof callback, 'function');
debugApp(app, 'Setting up mysql');
var container = docker.getContainer('mysql');
var cmd = [ '/addons/mysql/service.sh', 'add', app.id ];
container.exec({ Cmd: cmd, AttachStdout: true, AttachStderr: true }, function (error, execContainer) {
if (error) return callback(error);
execContainer.start(function (error, stream) {
if (error) return callback(error);
var stdout = new MemoryStream();
var stderr = new MemoryStream();
execContainer.modem.demuxStream(stream, stdout, stderr);
stderr.on('data', function (data) { debugApp(app, data.toString('utf8')); }); // set -e output
var chunks = [ ];
stdout.on('data', function (chunk) { chunks.push(chunk); });
stream.on('error', callback);
stream.on('end', function () {
var env = Buffer.concat(chunks).toString('utf8').split('\n').slice(0, -1); // remove trailing newline
debugApp(app, 'Setting mysql addon config to %j', env);
appdb.setAddonConfig(app.id, 'mysql', env, callback);
});
});
});
}
function teardownMySql(app, callback) {
var container = docker.getContainer('mysql');
var cmd = [ '/addons/mysql/service.sh', 'remove', app.id ];
debugApp(app, 'Tearing down mysql');
container.exec({ Cmd: cmd, AttachStdout: true, AttachStderr: true }, function (error, execContainer) {
if (error) return callback(error);
execContainer.start(function (error, stream) {
if (error) return callback(error);
var data = '';
stream.on('error', callback);
stream.on('data', function (d) { data += d.toString('utf8'); });
stream.on('end', function () {
appdb.unsetAddonConfig(app.id, 'mysql', callback);
});
});
});
}
function backupMySql(app, callback) {
debugApp(app, 'Backing up mysql');
callback = once(callback); // ChildProcess exit may or may not be called after error
var output = fs.createWriteStream(path.join(paths.DATA_DIR, app.id, 'mysqldump'));
output.on('error', callback);
var cp = spawn('/usr/bin/docker', [ 'exec', 'mysql', '/addons/mysql/service.sh', 'backup', app.id ]);
cp.on('error', callback);
cp.on('exit', function (code, signal) {
debugApp(app, 'backupMySql: done. code:%s signal:%s', code, signal);
if (!callback.called) callback(code ? 'backupMySql failed with status ' + code : null);
});
cp.stdout.pipe(output);
cp.stderr.pipe(process.stderr);
}
function restoreMySql(app, callback) {
callback = once(callback); // ChildProcess exit may or may not be called after error
setupMySql(app, function (error) {
if (error) return callback(error);
debugApp(app, 'restoreMySql');
var input = fs.createReadStream(path.join(paths.DATA_DIR, app.id, 'mysqldump'));
input.on('error', callback);
// cannot get this to work through docker.exec
var cp = spawn('/usr/bin/docker', [ 'exec', '-i', 'mysql', '/addons/mysql/service.sh', 'restore', app.id ]);
cp.on('error', callback);
cp.on('exit', function (code, signal) {
debugApp(app, 'restoreMySql: done %s %s', code, signal);
if (!callback.called) callback(code ? 'restoreMySql failed with status ' + code : null);
});
cp.stdout.pipe(process.stdout);
cp.stderr.pipe(process.stderr);
input.pipe(cp.stdin).on('error', callback);
});
}
function setupPostgreSql(app, callback) {
assert.strictEqual(typeof app, 'object');
assert.strictEqual(typeof callback, 'function');
debugApp(app, 'Setting up postgresql');
var container = docker.getContainer('postgresql');
var cmd = [ '/addons/postgresql/service.sh', 'add', app.id ];
container.exec({ Cmd: cmd, AttachStdout: true, AttachStderr: true }, function (error, execContainer) {
if (error) return callback(error);
execContainer.start(function (error, stream) {
if (error) return callback(error);
var stdout = new MemoryStream();
var stderr = new MemoryStream();
execContainer.modem.demuxStream(stream, stdout, stderr);
stderr.on('data', function (data) { debugApp(app, data.toString('utf8')); }); // set -e output
var chunks = [ ];
stdout.on('data', function (chunk) { chunks.push(chunk); });
stream.on('error', callback);
stream.on('end', function () {
var env = Buffer.concat(chunks).toString('utf8').split('\n').slice(0, -1); // remove trailing newline
debugApp(app, 'Setting postgresql addon config to %j', env);
appdb.setAddonConfig(app.id, 'postgresql', env, callback);
});
});
});
}
function teardownPostgreSql(app, callback) {
var container = docker.getContainer('postgresql');
var cmd = [ '/addons/postgresql/service.sh', 'remove', app.id ];
debugApp(app, 'Tearing down postgresql');
container.exec({ Cmd: cmd, AttachStdout: true, AttachStderr: true }, function (error, execContainer) {
if (error) return callback(error);
execContainer.start(function (error, stream) {
if (error) return callback(error);
var data = '';
stream.on('error', callback);
stream.on('data', function (d) { data += d.toString('utf8'); });
stream.on('end', function () {
appdb.unsetAddonConfig(app.id, 'postgresql', callback);
});
});
});
}
function backupPostgreSql(app, callback) {
debugApp(app, 'Backing up postgresql');
callback = once(callback); // ChildProcess exit may or may not be called after error
var output = fs.createWriteStream(path.join(paths.DATA_DIR, app.id, 'postgresqldump'));
output.on('error', callback);
var cp = spawn('/usr/bin/docker', [ 'exec', 'postgresql', '/addons/postgresql/service.sh', 'backup', app.id ]);
cp.on('error', callback);
cp.on('exit', function (code, signal) {
debugApp(app, 'backupPostgreSql: done %s %s', code, signal);
if (!callback.called) callback(code ? 'backupPostgreSql failed with status ' + code : null);
});
cp.stdout.pipe(output);
cp.stderr.pipe(process.stderr);
}
function restorePostgreSql(app, callback) {
callback = once(callback); // ChildProcess exit may or may not be called after error
setupPostgreSql(app, function (error) {
if (error) return callback(error);
debugApp(app, 'restorePostgreSql');
var input = fs.createReadStream(path.join(paths.DATA_DIR, app.id, 'postgresqldump'));
input.on('error', callback);
// cannot get this to work through docker.exec
var cp = spawn('/usr/bin/docker', [ 'exec', '-i', 'postgresql', '/addons/postgresql/service.sh', 'restore', app.id ]);
cp.on('error', callback);
cp.on('exit', function (code, signal) {
debugApp(app, 'restorePostgreSql: done %s %s', code, signal);
if (!callback.called) callback(code ? 'restorePostgreSql failed with status ' + code : null);
});
cp.stdout.pipe(process.stdout);
cp.stderr.pipe(process.stderr);
input.pipe(cp.stdin).on('error', callback);
});
}
function setupMongoDb(app, callback) {
assert.strictEqual(typeof app, 'object');
assert.strictEqual(typeof callback, 'function');
debugApp(app, 'Setting up mongodb');
var container = docker.getContainer('mongodb');
var cmd = [ '/addons/mongodb/service.sh', 'add', app.id ];
container.exec({ Cmd: cmd, AttachStdout: true, AttachStderr: true }, function (error, execContainer) {
if (error) return callback(error);
execContainer.start(function (error, stream) {
if (error) return callback(error);
var stdout = new MemoryStream();
var stderr = new MemoryStream();
execContainer.modem.demuxStream(stream, stdout, stderr);
stderr.on('data', function (data) { debugApp(app, data.toString('utf8')); }); // set -e output
var chunks = [ ];
stdout.on('data', function (chunk) { chunks.push(chunk); });
stream.on('error', callback);
stream.on('end', function () {
var env = Buffer.concat(chunks).toString('utf8').split('\n').slice(0, -1); // remove trailing newline
debugApp(app, 'Setting mongodb addon config to %j', env);
appdb.setAddonConfig(app.id, 'mongodb', env, callback);
});
});
});
}
function teardownMongoDb(app, callback) {
var container = docker.getContainer('mongodb');
var cmd = [ '/addons/mongodb/service.sh', 'remove', app.id ];
debugApp(app, 'Tearing down mongodb');
container.exec({ Cmd: cmd, AttachStdout: true, AttachStderr: true }, function (error, execContainer) {
if (error) return callback(error);
execContainer.start(function (error, stream) {
if (error) return callback(error);
var data = '';
stream.on('error', callback);
stream.on('data', function (d) { data += d.toString('utf8'); });
stream.on('end', function () {
appdb.unsetAddonConfig(app.id, 'mongodb', callback);
});
});
});
}
function backupMongoDb(app, callback) {
debugApp(app, 'Backing up mongodb');
callback = once(callback); // ChildProcess exit may or may not be called after error
var output = fs.createWriteStream(path.join(paths.DATA_DIR, app.id, 'mongodbdump'));
output.on('error', callback);
var cp = spawn('/usr/bin/docker', [ 'exec', 'mongodb', '/addons/mongodb/service.sh', 'backup', app.id ]);
cp.on('error', callback);
cp.on('exit', function (code, signal) {
debugApp(app, 'backupMongoDb: done %s %s', code, signal);
if (!callback.called) callback(code ? 'backupMongoDb failed with status ' + code : null);
});
cp.stdout.pipe(output);
cp.stderr.pipe(process.stderr);
}
function restoreMongoDb(app, callback) {
callback = once(callback); // ChildProcess exit may or may not be called after error
setupMongoDb(app, function (error) {
if (error) return callback(error);
debugApp(app, 'restoreMongoDb');
var input = fs.createReadStream(path.join(paths.DATA_DIR, app.id, 'mongodbdump'));
input.on('error', callback);
// cannot get this to work through docker.exec
var cp = spawn('/usr/bin/docker', [ 'exec', '-i', 'mongodb', '/addons/mongodb/service.sh', 'restore', app.id ]);
cp.on('error', callback);
cp.on('exit', function (code, signal) {
debugApp(app, 'restoreMongoDb: done %s %s', code, signal);
if (!callback.called) callback(code ? 'restoreMongoDb failed with status ' + code : null);
});
cp.stdout.pipe(process.stdout);
cp.stderr.pipe(process.stderr);
input.pipe(cp.stdin).on('error', callback);
});
}
function forwardRedisPort(appId, callback) {
assert.strictEqual(typeof appId, 'string');
assert.strictEqual(typeof callback, 'function');
docker.getContainer('redis-' + appId).inspect(function (error, data) {
if (error) return callback(new Error('Unable to inspect container:' + error));
var redisPort = parseInt(safe.query(data, 'NetworkSettings.Ports.6379/tcp[0].HostPort'), 10);
if (!Number.isInteger(redisPort)) return callback(new Error('Unable to get container port mapping'));
vbox.forwardFromHostToVirtualBox('redis-' + appId, redisPort);
return callback(null);
});
}
// Ensures that app's addon redis container is running. Can be called when named container already exists/running
function setupRedis(app, callback) {
var redisPassword = generatePassword(64, false /* memorable */);
var redisVarsFile = path.join(paths.ADDON_CONFIG_DIR, 'redis-' + app.id + '_vars.sh');
var redisDataDir = path.join(paths.DATA_DIR, app.id + '/redis');
if (!safe.fs.writeFileSync(redisVarsFile, 'REDIS_PASSWORD=' + redisPassword)) {
return callback(new Error('Error writing redis config'));
}
if (!safe.fs.mkdirSync(redisDataDir) && safe.error.code !== 'EEXIST') return callback(new Error('Error creating redis data dir:' + safe.error));
var createOptions = {
name: 'redis-' + app.id,
Hostname: config.appFqdn(app.location),
Tty: true,
Image: 'cloudron/redis:0.3.0',
Cmd: null,
Volumes: {},
VolumesFrom: []
};
var isMac = os.platform() === 'darwin';
var startOptions = {
Binds: [
redisVarsFile + ':/etc/redis/redis_vars.sh:r',
redisDataDir + ':/var/lib/redis:rw'
],
// On Mac (boot2docker), we have to export the port to external world for port forwarding from Mac to work
// On linux, export to localhost only for testing purposes and not for the app itself
PortBindings: {
'6379/tcp': [{ HostPort: '0', HostIp: isMac ? '0.0.0.0' : '127.0.0.1' }]
},
RestartPolicy: {
'Name': 'always',
'MaximumRetryCount': 0
}
};
var env = [
'REDIS_URL=redis://redisuser:' + redisPassword + '@redis-' + app.id,
'REDIS_PASSWORD=' + redisPassword,
'REDIS_HOST=redis-' + app.id,
'REDIS_PORT=6379'
];
var redisContainer = docker.getContainer(createOptions.name);
redisContainer.remove({ force: true, v: false }, function (ignoredError) {
docker.createContainer(createOptions, function (error) {
if (error && error.statusCode !== 409) return callback(error); // if not already created
redisContainer.start(startOptions, function (error) {
if (error && error.statusCode !== 304) return callback(error); // if not already running
appdb.setAddonConfig(app.id, 'redis', env, function (error) {
if (error) return callback(error);
forwardRedisPort(app.id, callback);
});
});
});
});
}
function teardownRedis(app, callback) {
var container = docker.getContainer('redis-' + app.id);
var removeOptions = {
force: true, // kill container if it's running
v: false // removes volumes associated with the container
};
container.remove(removeOptions, function (error) {
if (error && error.statusCode !== 404) return callback(new Error('Error removing container:' + error));
vbox.unforwardFromHostToVirtualBox('redis-' + app.id);
safe.fs.unlinkSync(paths.ADDON_CONFIG_DIR, 'redis-' + app.id + '_vars.sh');
shell.sudo('teardownRedis', [ RMAPPDIR_CMD, app.id + '/redis' ], function (error, stdout, stderr) {
if (error) return callback(new Error('Error removing redis data:' + error));
appdb.unsetAddonConfig(app.id, 'redis', callback);
});
});
}
function allocateAccessToken(app, callback) {
assert.strictEqual(typeof app, 'object');
assert.strictEqual(typeof callback, 'function');
var token = tokendb.generateToken();
var expiresAt = Number.MAX_SAFE_INTEGER; // basically never expire
var scopes = 'profile,users'; // TODO This should be put into the manifest and the user should know those
var clientId = ''; // meaningless for apps so far
tokendb.delByIdentifier(tokendb.PREFIX_APP + app.id, function (error) {
if (error && error.reason !== DatabaseError.NOT_FOUND) return callback(error);
tokendb.add(token, tokendb.PREFIX_APP + app.id, clientId, expiresAt, scopes, function (error) {
if (error) return callback(error);
var env = [
'CLOUDRON_TOKEN=' + token
];
debugApp(app, 'Setting token addon config to %j', env);
appdb.setAddonConfig(appId, 'token', env, callback);
});
});
}
function removeAccessToken(app, callback) {
assert.strictEqual(typeof app, 'object');
assert.strictEqual(typeof callback, 'function');
tokendb.delByIdentifier(tokendb.PREFIX_APP + app.id, function (error) {
if (error && error.reason !== DatabaseError.NOT_FOUND) console.error(error);
appdb.unsetAddonConfig(app.id, 'token', callback);
});
}

49
src/aes-helper.js Normal file
View File

@@ -0,0 +1,49 @@
'use strict';
var crypto = require('crypto');
// This code is taken from https://github.com/fabdrol/node-aes-helper
module.exports = {
algorithm: 'AES-256-CBC',
key: function (password, salt) {
var key = salt.toString('utf8') + password;
var hash = crypto.createHash('sha1');
hash.update(key, 'utf8');
return hash.digest('hex');
},
encrypt: function (plain, password, salt) {
var key = this.key(password, salt);
var cipher = crypto.createCipher(this.algorithm, key);
var crypted;
try {
crypted = cipher.update(plain, 'utf8', 'hex');
crypted += cipher.final('hex');
} catch (e) {
console.error('Encryption error:', e);
crypted = '';
}
return crypted;
},
decrypt: function (crypted, password, salt) {
var key = this.key(password, salt);
var decipher = crypto.createDecipher(this.algorithm, key);
var decoded;
try {
decoded = decipher.update(crypted, 'hex', 'utf8');
decoded += decipher.final('utf8');
} catch (e) {
console.error('Decryption error:', e);
decoded = '';
}
return decoded;
}
};

448
src/appdb.js Normal file
View File

@@ -0,0 +1,448 @@
/* jslint node:true */
'use strict';
exports = module.exports = {
get: get,
getBySubdomain: getBySubdomain,
getByHttpPort: getByHttpPort,
add: add,
exists: exists,
del: del,
update: update,
getAll: getAll,
getPortBindings: getPortBindings,
setAddonConfig: setAddonConfig,
getAddonConfig: getAddonConfig,
getAddonConfigByAppId: getAddonConfigByAppId,
unsetAddonConfig: unsetAddonConfig,
unsetAddonConfigByAppId: unsetAddonConfigByAppId,
setHealth: setHealth,
setInstallationCommand: setInstallationCommand,
setRunCommand: setRunCommand,
getAppStoreIds: getAppStoreIds,
// installation codes (keep in sync in UI)
ISTATE_PENDING_INSTALL: 'pending_install', // installs and fresh reinstalls
ISTATE_PENDING_CONFIGURE: 'pending_configure', // config (location, port) changes and on infra update
ISTATE_PENDING_UNINSTALL: 'pending_uninstall', // uninstallation
ISTATE_PENDING_RESTORE: 'pending_restore', // restore to previous backup or on upgrade
ISTATE_PENDING_UPDATE: 'pending_update', // update from installed state preserving data
ISTATE_PENDING_FORCE_UPDATE: 'pending_force_update', // update from any state preserving data
ISTATE_PENDING_BACKUP: 'pending_backup', // backup the app
ISTATE_ERROR: 'error', // error executing last pending_* command
ISTATE_INSTALLED: 'installed', // app is installed
// run codes (keep in sync in UI)
RSTATE_RUNNING: 'running',
RSTATE_PENDING_START: 'pending_start',
RSTATE_PENDING_STOP: 'pending_stop',
RSTATE_STOPPED: 'stopped', // app stopped by use
HEALTH_HEALTHY: 'healthy',
HEALTH_UNHEALTHY: 'unhealthy',
HEALTH_ERROR: 'error',
HEALTH_DEAD: 'dead',
_clear: clear
};
var assert = require('assert'),
async = require('async'),
database = require('./database.js'),
DatabaseError = require('./databaseerror'),
safe = require('safetydance'),
util = require('util');
var APPS_FIELDS = [ 'id', 'appStoreId', 'installationState', 'installationProgress', 'runState',
'health', 'containerId', 'manifestJson', 'httpPort', 'location', 'dnsRecordId',
'accessRestriction', 'lastBackupId', 'lastBackupConfigJson', 'oldConfigJson' ].join(',');
var APPS_FIELDS_PREFIXED = [ 'apps.id', 'apps.appStoreId', 'apps.installationState', 'apps.installationProgress', 'apps.runState',
'apps.health', 'apps.containerId', 'apps.manifestJson', 'apps.httpPort', 'apps.location', 'apps.dnsRecordId',
'apps.accessRestriction', 'apps.lastBackupId', 'apps.lastBackupConfigJson', 'apps.oldConfigJson' ].join(',');
var PORT_BINDINGS_FIELDS = [ 'hostPort', 'environmentVariable', 'appId' ].join(',');
function postProcess(result) {
assert.strictEqual(typeof result, 'object');
assert(result.manifestJson === null || typeof result.manifestJson === 'string');
result.manifest = safe.JSON.parse(result.manifestJson);
delete result.manifestJson;
assert(result.lastBackupConfigJson === null || typeof result.lastBackupConfigJson === 'string');
result.lastBackupConfig = safe.JSON.parse(result.lastBackupConfigJson);
delete result.lastBackupConfigJson;
assert(result.oldConfigJson === null || typeof result.oldConfigJson === 'string');
result.oldConfig = safe.JSON.parse(result.oldConfigJson);
delete result.oldConfigJson;
assert(result.hostPorts === null || typeof result.hostPorts === 'string');
assert(result.environmentVariables === null || typeof result.environmentVariables === 'string');
result.portBindings = { };
var hostPorts = result.hostPorts === null ? [ ] : result.hostPorts.split(',');
var environmentVariables = result.environmentVariables === null ? [ ] : result.environmentVariables.split(',');
delete result.hostPorts;
delete result.environmentVariables;
for (var i = 0; i < environmentVariables.length; i++) {
result.portBindings[environmentVariables[i]] = parseInt(hostPorts[i], 10);
}
}
function get(id, callback) {
assert.strictEqual(typeof id, 'string');
assert.strictEqual(typeof callback, 'function');
database.query('SELECT ' + APPS_FIELDS_PREFIXED + ','
+ 'GROUP_CONCAT(CAST(appPortBindings.hostPort AS CHAR(6))) AS hostPorts, GROUP_CONCAT(appPortBindings.environmentVariable) AS environmentVariables'
+ ' FROM apps LEFT OUTER JOIN appPortBindings ON apps.id = appPortBindings.appId WHERE apps.id = ? GROUP BY apps.id', [ id ], function (error, result) {
if (error) return callback(new DatabaseError(DatabaseError.INTERNAL_ERROR, error));
if (result.length === 0) return callback(new DatabaseError(DatabaseError.NOT_FOUND));
postProcess(result[0]);
callback(null, result[0]);
});
}
function getBySubdomain(subdomain, callback) {
assert.strictEqual(typeof subdomain, 'string');
assert.strictEqual(typeof callback, 'function');
database.query('SELECT ' + APPS_FIELDS_PREFIXED + ','
+ 'GROUP_CONCAT(CAST(appPortBindings.hostPort AS CHAR(6))) AS hostPorts, GROUP_CONCAT(appPortBindings.environmentVariable) AS environmentVariables'
+ ' FROM apps LEFT OUTER JOIN appPortBindings ON apps.id = appPortBindings.appId WHERE location = ? GROUP BY apps.id', [ subdomain ], function (error, result) {
if (error) return callback(new DatabaseError(DatabaseError.INTERNAL_ERROR, error));
if (result.length === 0) return callback(new DatabaseError(DatabaseError.NOT_FOUND));
postProcess(result[0]);
callback(null, result[0]);
});
}
function getByHttpPort(httpPort, callback) {
assert.strictEqual(typeof httpPort, 'number');
assert.strictEqual(typeof callback, 'function');
database.query('SELECT ' + APPS_FIELDS_PREFIXED + ','
+ 'GROUP_CONCAT(CAST(appPortBindings.hostPort AS CHAR(6))) AS hostPorts, GROUP_CONCAT(appPortBindings.environmentVariable) AS environmentVariables'
+ ' FROM apps LEFT OUTER JOIN appPortBindings ON apps.id = appPortBindings.appId WHERE httpPort = ? GROUP BY apps.id', [ httpPort ], function (error, result) {
if (error) return callback(new DatabaseError(DatabaseError.INTERNAL_ERROR, error));
if (result.length === 0) return callback(new DatabaseError(DatabaseError.NOT_FOUND));
postProcess(result[0]);
callback(null, result[0]);
});
}
function getAll(callback) {
assert.strictEqual(typeof callback, 'function');
database.query('SELECT ' + APPS_FIELDS_PREFIXED + ','
+ 'GROUP_CONCAT(CAST(appPortBindings.hostPort AS CHAR(6))) AS hostPorts, GROUP_CONCAT(appPortBindings.environmentVariable) AS environmentVariables'
+ ' FROM apps LEFT OUTER JOIN appPortBindings ON apps.id = appPortBindings.appId'
+ ' GROUP BY apps.id ORDER BY apps.id', function (error, results) {
if (error) return callback(new DatabaseError(DatabaseError.INTERNAL_ERROR, error));
results.forEach(postProcess);
callback(null, results);
});
}
function add(id, appStoreId, manifest, location, portBindings, accessRestriction, callback) {
assert.strictEqual(typeof id, 'string');
assert.strictEqual(typeof appStoreId, 'string');
assert(manifest && typeof manifest === 'object');
assert.strictEqual(typeof manifest.version, 'string');
assert.strictEqual(typeof location, 'string');
assert.strictEqual(typeof portBindings, 'object');
assert.strictEqual(typeof accessRestriction, 'string');
assert.strictEqual(typeof callback, 'function');
portBindings = portBindings || { };
var manifestJson = JSON.stringify(manifest);
var queries = [ ];
queries.push({
query: 'INSERT INTO apps (id, appStoreId, manifestJson, installationState, location, accessRestriction) VALUES (?, ?, ?, ?, ?, ?)',
args: [ id, appStoreId, manifestJson, exports.ISTATE_PENDING_INSTALL, location, accessRestriction ]
});
Object.keys(portBindings).forEach(function (env) {
queries.push({
query: 'INSERT INTO appPortBindings (environmentVariable, hostPort, appId) VALUES (?, ?, ?)',
args: [ env, portBindings[env], id ]
});
});
database.transaction(queries, function (error) {
if (error && error.code === 'ER_DUP_ENTRY') return callback(new DatabaseError(DatabaseError.ALREADY_EXISTS, error.message));
if (error) return callback(new DatabaseError(DatabaseError.INTERNAL_ERROR, error));
callback(null);
});
}
function exists(id, callback) {
assert.strictEqual(typeof id, 'string');
assert.strictEqual(typeof callback, 'function');
database.query('SELECT 1 FROM apps WHERE id=?', [ id ], function (error, result) {
if (error) return callback(new DatabaseError(DatabaseError.INTERNAL_ERROR, error));
return callback(null, result.length !== 0);
});
}
function getPortBindings(id, callback) {
assert.strictEqual(typeof id, 'string');
assert.strictEqual(typeof callback, 'function');
database.query('SELECT ' + PORT_BINDINGS_FIELDS + ' FROM appPortBindings WHERE appId = ?', [ id ], function (error, results) {
if (error) return callback(new DatabaseError(DatabaseError.INTERNAL_ERROR, error));
var portBindings = { };
for (var i = 0; i < results.length; i++) {
portBindings[results[i].environmentVariable] = results[i].hostPort;
}
callback(null, portBindings);
});
}
function del(id, callback) {
assert.strictEqual(typeof id, 'string');
assert.strictEqual(typeof callback, 'function');
var queries = [
{ query: 'DELETE FROM appPortBindings WHERE appId = ?', args: [ id ] },
{ query: 'DELETE FROM apps WHERE id = ?', args: [ id ] }
];
database.transaction(queries, function (error, results) {
if (error) return callback(new DatabaseError(DatabaseError.INTERNAL_ERROR, error));
if (results[1].affectedRows !== 1) return callback(new DatabaseError(DatabaseError.NOT_FOUND));
callback(null);
});
}
function clear(callback) {
assert.strictEqual(typeof callback, 'function');
async.series([
database.query.bind(null, 'DELETE FROM appPortBindings'),
database.query.bind(null, 'DELETE FROM appAddonConfigs'),
database.query.bind(null, 'DELETE FROM apps')
], function (error) {
if (error) return callback(new DatabaseError(DatabaseError.INTERNAL_ERROR, error));
return callback(null);
});
}
function update(id, app, callback) {
updateWithConstraints(id, app, '', callback);
}
function updateWithConstraints(id, app, constraints, callback) {
assert.strictEqual(typeof id, 'string');
assert.strictEqual(typeof app, 'object');
assert.strictEqual(typeof constraints, 'string');
assert.strictEqual(typeof callback, 'function');
assert(!('portBindings' in app) || typeof app.portBindings === 'object');
var queries = [ ];
if ('portBindings' in app) {
var portBindings = app.portBindings || { };
// replace entries by app id
queries.push({ query: 'DELETE FROM appPortBindings WHERE appId = ?', args: [ id ] });
Object.keys(portBindings).forEach(function (env) {
var values = [ portBindings[env], env, id ];
queries.push({ query: 'INSERT INTO appPortBindings (hostPort, environmentVariable, appId) VALUES(?, ?, ?)', args: values });
});
}
var fields = [ ], values = [ ];
for (var p in app) {
if (p === 'manifest') {
fields.push('manifestJson = ?');
values.push(JSON.stringify(app[p]));
} else if (p === 'lastBackupConfig') {
fields.push('lastBackupConfigJson = ?');
values.push(JSON.stringify(app[p]));
} else if (p === 'oldConfig') {
fields.push('oldConfigJson = ?');
values.push(JSON.stringify(app[p]));
} else if (p !== 'portBindings') {
fields.push(p + ' = ?');
values.push(app[p]);
}
}
if (values.length !== 0) {
values.push(id);
queries.push({ query: 'UPDATE apps SET ' + fields.join(', ') + ' WHERE id = ? ' + constraints, args: values });
}
database.transaction(queries, function (error, results) {
if (error && error.code === 'ER_DUP_ENTRY') return callback(new DatabaseError(DatabaseError.ALREADY_EXISTS, error.message));
if (error) return callback(new DatabaseError(DatabaseError.INTERNAL_ERROR, error));
if (results[results.length - 1].affectedRows !== 1) return callback(new DatabaseError(DatabaseError.NOT_FOUND));
return callback(null);
});
}
// not sure if health should influence runState
function setHealth(appId, health, callback) {
assert.strictEqual(typeof appId, 'string');
assert.strictEqual(typeof health, 'string');
assert.strictEqual(typeof callback, 'function');
var values = { health: health };
var constraints = 'AND runState NOT LIKE "pending_%" AND installationState = "installed"';
updateWithConstraints(appId, values, constraints, callback);
}
function setInstallationCommand(appId, installationState, values, callback) {
assert.strictEqual(typeof appId, 'string');
assert.strictEqual(typeof installationState, 'string');
if (typeof values === 'function') {
callback = values;
values = { };
} else {
assert.strictEqual(typeof values, 'object');
assert.strictEqual(typeof callback, 'function');
}
values.installationState = installationState;
values.installationProgress = '';
// Rules are:
// uninstall is allowed in any state
// restore is allowed from installed or error state
// update and configure are allowed only in installed state
if (installationState === exports.ISTATE_PENDING_UNINSTALL || installationState === exports.ISTATE_PENDING_FORCE_UPDATE) {
updateWithConstraints(appId, values, '', callback);
} else if (installationState === exports.ISTATE_PENDING_RESTORE) {
updateWithConstraints(appId, values, 'AND (installationState = "installed" OR installationState = "error")', callback);
} else if (installationState === exports.ISTATE_PENDING_UPDATE || exports.ISTATE_PENDING_CONFIGURE || installationState == exports.ISTATE_PENDING_BACKUP) {
updateWithConstraints(appId, values, 'AND installationState = "installed"', callback);
} else {
callback(new DatabaseError(DatabaseError.INTERNAL_ERROR, 'invalid installationState'));
}
}
function setRunCommand(appId, runState, callback) {
assert.strictEqual(typeof appId, 'string');
assert.strictEqual(typeof runState, 'string');
assert.strictEqual(typeof callback, 'function');
var values = { runState: runState };
updateWithConstraints(appId, values, 'AND runState NOT LIKE "pending_%" AND installationState = "installed"', callback);
}
function getAppStoreIds(callback) {
assert.strictEqual(typeof callback, 'function');
database.query('SELECT id, appStoreId FROM apps', function (error, results) {
if (error) return callback(new DatabaseError(DatabaseError.INTERNAL_ERROR, error));
callback(null, results);
});
}
function setAddonConfig(appId, addonId, env, callback) {
assert.strictEqual(typeof appId, 'string');
assert.strictEqual(typeof addonId, 'string');
assert(util.isArray(env));
assert.strictEqual(typeof callback, 'function');
unsetAddonConfig(appId, addonId, function (error) {
if (error) return callback(error);
if (env.length === 0) return callback(null);
var query = 'INSERT INTO appAddonConfigs(appId, addonId, value) VALUES ';
var args = [ ], queryArgs = [ ];
for (var i = 0; i < env.length; i++) {
args.push(appId, addonId, env[i]);
queryArgs.push('(?, ?, ?)');
}
database.query(query + queryArgs.join(','), args, function (error) {
if (error) return callback(new DatabaseError(DatabaseError.INTERNAL_ERROR, error));
return callback(null);
});
});
}
function unsetAddonConfig(appId, addonId, callback) {
assert.strictEqual(typeof appId, 'string');
assert.strictEqual(typeof addonId, 'string');
assert.strictEqual(typeof callback, 'function');
database.query('DELETE FROM appAddonConfigs WHERE appId = ? AND addonId = ?', [ appId, addonId ], function (error) {
if (error) return callback(new DatabaseError(DatabaseError.INTERNAL_ERROR, error));
callback(null);
});
}
function unsetAddonConfigByAppId(appId, callback) {
assert.strictEqual(typeof appId, 'string');
assert.strictEqual(typeof callback, 'function');
database.query('DELETE FROM appAddonConfigs WHERE appId = ?', [ appId ], function (error) {
if (error) return callback(new DatabaseError(DatabaseError.INTERNAL_ERROR, error));
callback(null);
});
}
function getAddonConfig(appId, addonId, callback) {
assert.strictEqual(typeof appId, 'string');
assert.strictEqual(typeof addonId, 'string');
assert.strictEqual(typeof callback, 'function');
database.query('SELECT value FROM appAddonConfigs WHERE appId = ? AND addonId = ?', [ appId, addonId ], function (error, results) {
if (error) return callback(new DatabaseError(DatabaseError.INTERNAL_ERROR, error));
var config = [ ];
results.forEach(function (v) { config.push(v.value); });
callback(null, config);
});
}
function getAddonConfigByAppId(appId, callback) {
assert.strictEqual(typeof appId, 'string');
assert.strictEqual(typeof callback, 'function');
database.query('SELECT value FROM appAddonConfigs WHERE appId = ?', [ appId ], function (error, results) {
if (error) return callback(new DatabaseError(DatabaseError.INTERNAL_ERROR, error));
var config = [ ];
results.forEach(function (v) { config.push(v.value); });
callback(null, config);
});
}

765
src/apps.js Normal file
View File

@@ -0,0 +1,765 @@
/* jslint node:true */
'use strict';
exports = module.exports = {
AppsError: AppsError,
get: get,
getBySubdomain: getBySubdomain,
getAll: getAll,
purchase: purchase,
install: install,
configure: configure,
uninstall: uninstall,
restore: restore,
restoreApp: restoreApp,
update: update,
backup: backup,
backupApp: backupApp,
getLogStream: getLogStream,
getLogs: getLogs,
start: start,
stop: stop,
exec: exec,
checkManifestConstraints: checkManifestConstraints,
setRestorePoint: setRestorePoint,
autoupdateApps: autoupdateApps,
// exported for testing
_validateHostname: validateHostname,
_validatePortBindings: validatePortBindings
};
var addons = require('./addons.js'),
appdb = require('./appdb.js'),
assert = require('assert'),
async = require('async'),
backups = require('./backups.js'),
BackupsError = require('./backups.js').BackupsError,
config = require('./config.js'),
constants = require('./constants.js'),
DatabaseError = require('./databaseerror.js'),
debug = require('debug')('box:apps'),
docker = require('./docker.js'),
fs = require('fs'),
manifestFormat = require('cloudron-manifestformat'),
path = require('path'),
paths = require('./paths.js'),
safe = require('safetydance'),
semver = require('semver'),
shell = require('./shell.js'),
split = require('split'),
superagent = require('superagent'),
taskmanager = require('./taskmanager.js'),
util = require('util'),
validator = require('validator');
var BACKUP_APP_CMD = path.join(__dirname, 'scripts/backupapp.sh'),
RESTORE_APP_CMD = path.join(__dirname, 'scripts/restoreapp.sh'),
BACKUP_SWAP_CMD = path.join(__dirname, 'scripts/backupswap.sh');
function debugApp(app, args) {
assert(!app || typeof app === 'object');
var prefix = app ? app.location : '(no app)';
debug(prefix + ' ' + util.format.apply(util, Array.prototype.slice.call(arguments, 1)));
}
function ignoreError(func) {
return function (callback) {
func(function (error) {
if (error) console.error('Ignored error:', error);
callback();
});
};
}
// http://dustinsenos.com/articles/customErrorsInNode
// http://code.google.com/p/v8/wiki/JavaScriptStackTraceApi
function AppsError(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(AppsError, Error);
AppsError.INTERNAL_ERROR = 'Internal Error';
AppsError.EXTERNAL_ERROR = 'External Error';
AppsError.ALREADY_EXISTS = 'Already Exists';
AppsError.NOT_FOUND = 'Not Found';
AppsError.BAD_FIELD = 'Bad Field';
AppsError.BAD_STATE = 'Bad State';
AppsError.PORT_RESERVED = 'Port Reserved';
AppsError.PORT_CONFLICT = 'Port Conflict';
AppsError.BILLING_REQUIRED = 'Billing Required';
// Hostname validation comes from RFC 1123 (section 2.1)
// Domain name validation comes from RFC 2181 (Name syntax)
// https://en.wikipedia.org/wiki/Hostname#Restrictions_on_valid_host_names
// We are validating the validity of the location-fqdn as host name
function validateHostname(location, fqdn) {
var RESERVED_LOCATIONS = [ constants.ADMIN_LOCATION, constants.API_LOCATION ];
if (RESERVED_LOCATIONS.indexOf(location) !== -1) return new Error(location + ' is reserved');
if (location === '') return null; // bare location
if ((location.length + 1 /*+ hyphen */ + fqdn.indexOf('.')) > 63) return new Error('Hostname length cannot be greater than 63');
if (location.match(/^[A-Za-z0-9-]+$/) === null) return new Error('Hostname can only contain alphanumerics and hyphen');
if (location[0] === '-' || location[location.length-1] === '-') return new Error('Hostname cannot start or end with hyphen');
if (location.length + 1 /* hyphen */ + fqdn.length > 253) return new Error('FQDN length exceeds 253 characters');
return null;
}
// validate the port bindings
function validatePortBindings(portBindings, tcpPorts) {
// keep the public ports in sync with firewall rules in scripts/initializeBaseUbuntuImage.sh
// these ports are reserved even if we listen only on 127.0.0.1 because we setup HostIp to be 127.0.0.1
// for custom tcp ports
var RESERVED_PORTS = [
22, /* ssh */
25, /* smtp */
53, /* dns */
80, /* http */
443, /* https */
2003, /* graphite (lo) */
2004, /* graphite (lo) */
2020, /* install server */
config.get('port'), /* app server (lo) */
config.get('internalPort'), /* internal app server (lo) */
3306, /* mysql (lo) */
8000 /* graphite (lo) */
];
if (!portBindings) return null;
var env;
for (env in portBindings) {
if (!/^[a-zA-Z0-9_]+$/.test(env)) return new AppsError(AppsError.BAD_FIELD, env + ' is not valid environment variable');
if (!Number.isInteger(portBindings[env])) return new Error(portBindings[env] + ' is not an integer');
if (portBindings[env] <= 0 || portBindings[env] > 65535) return new Error(portBindings[env] + ' is out of range');
if (RESERVED_PORTS.indexOf(portBindings[env]) !== -1) return new AppsError(AppsError.PORT_RESERVED, + portBindings[env]);
}
// it is OK if there is no 1-1 mapping between values in manifest.tcpPorts and portBindings. missing values implies
// that the user wants the service disabled
tcpPorts = tcpPorts || { };
for (env in portBindings) {
if (!(env in tcpPorts)) return new AppsError(AppsError.BAD_FIELD, 'Invalid portBindings ' + env);
}
return null;
}
function getDuplicateErrorDetails(location, portBindings, error) {
assert.strictEqual(typeof location, 'string');
assert.strictEqual(typeof portBindings, 'object');
assert.strictEqual(error.reason, DatabaseError.ALREADY_EXISTS);
var match = error.message.match(/ER_DUP_ENTRY: Duplicate entry '(.*)' for key/);
if (!match) {
console.error('Unexpected SQL error message.', error);
return new AppsError(AppsError.INTERNAL_ERROR);
}
// check if the location conflicts
if (match[1] === location) return new AppsError(AppsError.ALREADY_EXISTS);
// check if any of the port bindings conflict
for (var env in portBindings) {
if (portBindings[env] === parseInt(match[1])) return new AppsError(AppsError.PORT_CONFLICT, match[1]);
}
return new AppsError(AppsError.ALREADY_EXISTS);
}
function getIconUrlSync(app) {
var iconPath = paths.APPICONS_DIR + '/' + app.id + '.png';
return fs.existsSync(iconPath) ? '/api/v1/apps/' + app.id + '/icon' : null;
}
function get(appId, callback) {
assert.strictEqual(typeof appId, 'string');
assert.strictEqual(typeof callback, 'function');
appdb.get(appId, function (error, app) {
if (error && error.reason === DatabaseError.NOT_FOUND) return callback(new AppsError(AppsError.NOT_FOUND, 'No such app'));
if (error) return callback(new AppsError(AppsError.INTERNAL_ERROR, error));
app.iconUrl = getIconUrlSync(app);
app.fqdn = config.appFqdn(app.location);
callback(null, app);
});
}
function getBySubdomain(subdomain, callback) {
assert.strictEqual(typeof subdomain, 'string');
assert.strictEqual(typeof callback, 'function');
appdb.getBySubdomain(subdomain, function (error, app) {
if (error && error.reason === DatabaseError.NOT_FOUND) return callback(new AppsError(AppsError.NOT_FOUND, 'No such app'));
if (error) return callback(new AppsError(AppsError.INTERNAL_ERROR, error));
app.iconUrl = getIconUrlSync(app);
app.fqdn = config.appFqdn(app.location);
callback(null, app);
});
}
function getAll(callback) {
assert.strictEqual(typeof callback, 'function');
appdb.getAll(function (error, apps) {
if (error) return callback(new AppsError(AppsError.INTERNAL_ERROR, error));
apps.forEach(function (app) {
app.iconUrl = getIconUrlSync(app);
app.fqdn = config.appFqdn(app.location);
});
callback(null, apps);
});
}
function validateAccessRestriction(accessRestriction) {
// TODO: make the values below enumerations in the oauth code
switch (accessRestriction) {
case '':
case 'roleUser':
case 'roleAdmin':
return null;
default:
return new Error('Invalid accessRestriction');
}
}
function purchase(appStoreId, callback) {
assert.strictEqual(typeof appStoreId, 'string');
assert.strictEqual(typeof callback, 'function');
// Skip purchase if appStoreId is empty
if (appStoreId === '') return callback(null);
var url = config.apiServerOrigin() + '/api/v1/apps/' + appStoreId + '/purchase';
superagent.post(url).query({ token: config.token() }).end(function (error, res) {
if (error) return callback(new AppsError(AppsError.INTERNAL_ERROR, error));
if (res.status === 402) return callback(new AppsError(AppsError.BILLING_REQUIRED));
if (res.status !== 201 && res.status !== 200) return callback(new Error(util.format('App purchase failed. %s %j', res.status, res.body)));
callback(null);
});
}
function install(appId, appStoreId, manifest, location, portBindings, accessRestriction, icon, callback) {
assert.strictEqual(typeof appId, 'string');
assert.strictEqual(typeof appStoreId, 'string');
assert(manifest && typeof manifest === 'object');
assert.strictEqual(typeof location, 'string');
assert.strictEqual(typeof portBindings, 'object');
assert.strictEqual(typeof accessRestriction, 'string');
assert(!icon || typeof icon === 'string');
assert.strictEqual(typeof callback, 'function');
var error = manifestFormat.parse(manifest);
if (error) return callback(new AppsError(AppsError.BAD_FIELD, 'Manifest error: ' + error.message));
error = checkManifestConstraints(manifest);
if (error) return callback(new AppsError(AppsError.BAD_FIELD, 'Manifest cannot be installed: ' + error.message));
error = validateHostname(location, config.fqdn());
if (error) return callback(new AppsError(AppsError.BAD_FIELD, error.message));
error = validatePortBindings(portBindings, manifest.tcpPorts);
if (error) return callback(error);
error = validateAccessRestriction(accessRestriction);
if (error) return callback(new AppsError(AppsError.BAD_FIELD, error.message));
if (icon) {
if (!validator.isBase64(icon)) return callback(new AppsError(AppsError.BAD_FIELD, 'icon is not base64'));
if (!safe.fs.writeFileSync(path.join(paths.APPICONS_DIR, appId + '.png'), new Buffer(icon, 'base64'))) {
return callback(new AppsError(AppsError.INTERNAL_ERROR, 'Error saving icon:' + safe.error.message));
}
}
debug('Will install app with id : ' + appId);
purchase(appStoreId, function (error) {
if (error) return callback(error);
appdb.add(appId, appStoreId, manifest, location.toLowerCase(), portBindings, accessRestriction, function (error) {
if (error && error.reason === DatabaseError.ALREADY_EXISTS) return callback(getDuplicateErrorDetails(location.toLowerCase(), portBindings, error));
if (error) return callback(new AppsError(AppsError.INTERNAL_ERROR, error));
taskmanager.restartAppTask(appId);
callback(null);
});
});
}
function configure(appId, location, portBindings, accessRestriction, callback) {
assert.strictEqual(typeof appId, 'string');
assert.strictEqual(typeof location, 'string');
assert.strictEqual(typeof portBindings, 'object');
assert.strictEqual(typeof accessRestriction, 'string');
assert.strictEqual(typeof callback, 'function');
var error = validateHostname(location, config.fqdn());
if (error) return callback(new AppsError(AppsError.BAD_FIELD, error.message));
error = validateAccessRestriction(accessRestriction);
if (error) return callback(new AppsError(AppsError.BAD_FIELD, error.message));
appdb.get(appId, function (error, app) {
if (error && error.reason === DatabaseError.NOT_FOUND) return callback(new AppsError(AppsError.NOT_FOUND, 'No such app'));
if (error) return callback(new AppsError(AppsError.INTERNAL_ERROR, error));
error = validatePortBindings(portBindings, app.manifest.tcpPorts);
if (error) return callback(new AppsError(AppsError.BAD_FIELD, error.message));
var values = {
location: location.toLowerCase(),
accessRestriction: accessRestriction,
portBindings: portBindings,
oldConfig: {
location: app.location,
accessRestriction: app.accessRestriction,
portBindings: app.portBindings
}
};
debug('Will configure app with id:%s values:%j', appId, values);
appdb.setInstallationCommand(appId, appdb.ISTATE_PENDING_CONFIGURE, values, function (error) {
if (error && error.reason === DatabaseError.ALREADY_EXISTS) return callback(getDuplicateErrorDetails(location.toLowerCase(), portBindings, error));
if (error && error.reason === DatabaseError.NOT_FOUND) return callback(new AppsError(AppsError.BAD_STATE));
if (error) return callback(new AppsError(AppsError.INTERNAL_ERROR, error));
taskmanager.restartAppTask(appId);
callback(null);
});
});
}
function update(appId, force, manifest, portBindings, icon, callback) {
assert.strictEqual(typeof appId, 'string');
assert.strictEqual(typeof force, 'boolean');
assert(manifest && typeof manifest === 'object');
assert(!portBindings || typeof portBindings === 'object');
assert(!icon || typeof icon === 'string');
assert.strictEqual(typeof callback, 'function');
debug('Will update app with id:%s', appId);
var error = manifestFormat.parse(manifest);
if (error) return callback(new AppsError(AppsError.BAD_FIELD, 'Manifest error:' + error.message));
error = checkManifestConstraints(manifest);
if (error) return callback(new AppsError(AppsError.BAD_FIELD, 'Manifest cannot be installed:' + error.message));
error = validatePortBindings(portBindings, manifest.tcpPorts);
if (error) return callback(new AppsError(AppsError.BAD_FIELD, error.message));
if (icon) {
if (!validator.isBase64(icon)) return callback(new AppsError(AppsError.BAD_FIELD, 'icon is not base64'));
if (!safe.fs.writeFileSync(path.join(paths.APPICONS_DIR, appId + '.png'), new Buffer(icon, 'base64'))) {
return callback(new AppsError(AppsError.INTERNAL_ERROR, 'Error saving icon:' + safe.error.message));
}
}
appdb.get(appId, function (error, app) {
if (error && error.reason === DatabaseError.NOT_FOUND) return callback(new AppsError(AppsError.NOT_FOUND, 'No such app'));
if (error) return callback(new AppsError(AppsError.INTERNAL_ERROR, error));
var values = {
manifest: manifest,
portBindings: portBindings,
oldConfig: {
manifest: app.manifest,
portBindings: app.portBindings
}
};
appdb.setInstallationCommand(appId, force ? appdb.ISTATE_PENDING_FORCE_UPDATE : appdb.ISTATE_PENDING_UPDATE, values, function (error) {
if (error && error.reason === DatabaseError.NOT_FOUND) return callback(new AppsError(AppsError.BAD_STATE)); // might be a bad guess
if (error && error.reason === DatabaseError.ALREADY_EXISTS) return callback(getDuplicateErrorDetails('' /* location cannot conflict */, portBindings, error));
if (error) return callback(new AppsError(AppsError.INTERNAL_ERROR, error));
taskmanager.restartAppTask(appId);
callback(null);
});
});
}
function getLogStream(appId, fromLine, callback) {
assert.strictEqual(typeof appId, 'string');
assert.strictEqual(typeof fromLine, 'number'); // behaves like tail -n
assert.strictEqual(typeof callback, 'function');
debug('Getting logs for %s', appId);
appdb.get(appId, function (error, app) {
if (error && error.reason === DatabaseError.NOT_FOUND) return callback(new AppsError(AppsError.NOT_FOUND));
if (error) return callback(new AppsError(AppsError.INTERNAL_ERROR, error));
if (app.installationState !== appdb.ISTATE_INSTALLED) return callback(new AppsError(AppsError.BAD_STATE, util.format('App is in %s state.', app.installationState)));
var container = docker.getContainer(app.containerId);
var tail = fromLine < 0 ? -fromLine : 'all';
// note: cannot access docker file directly because it needs root access
container.logs({ stdout: true, stderr: true, follow: true, timestamps: true, tail: tail }, function (error, logStream) {
if (error && error.statusCode === 404) return callback(new AppsError(AppsError.NOT_FOUND, 'No such app'));
if (error) return callback(new AppsError(AppsError.INTERNAL_ERROR, error));
var lineCount = 0;
var skipLinesStream = split(function mapper(line) {
if (++lineCount < fromLine) return undefined;
var timestamp = line.substr(0, line.indexOf(' ')); // sometimes this has square brackets around it
return JSON.stringify({ lineNumber: lineCount, timestamp: timestamp.replace(/[[\]]/g,''), log: line.substr(timestamp.length + 1) });
});
skipLinesStream.close = logStream.req.abort;
logStream.pipe(skipLinesStream);
return callback(null, skipLinesStream);
});
});
}
function getLogs(appId, callback) {
assert.strictEqual(typeof appId, 'string');
assert.strictEqual(typeof callback, 'function');
debug('Getting logs for %s', appId);
appdb.get(appId, function (error, app) {
if (error && error.reason === DatabaseError.NOT_FOUND) return callback(new AppsError(AppsError.NOT_FOUND));
if (error) return callback(new AppsError(AppsError.INTERNAL_ERROR, error));
if (app.installationState !== appdb.ISTATE_INSTALLED) return callback(new AppsError(AppsError.BAD_STATE, util.format('App is in %s state.', app.installationState)));
var container = docker.getContainer(app.containerId);
// note: cannot access docker file directly because it needs root access
container.logs({ stdout: true, stderr: true, follow: false, timestamps: true, tail: 'all' }, function (error, logStream) {
if (error && error.statusCode === 404) return callback(new AppsError(AppsError.NOT_FOUND, 'No such app'));
if (error) return callback(new AppsError(AppsError.INTERNAL_ERROR, error));
return callback(null, logStream);
});
});
}
function restore(appId, callback) {
assert.strictEqual(typeof appId, 'string');
assert.strictEqual(typeof callback, 'function');
debug('Will restore app with id:%s', appId);
appdb.get(appId, function (error, app) {
if (error && error.reason === DatabaseError.NOT_FOUND) return callback(new AppsError(AppsError.NOT_FOUND));
if (error) return callback(new AppsError(AppsError.INTERNAL_ERROR, error));
var restoreConfig = app.lastBackupConfig;
if (!restoreConfig) return callback(new AppsError(AppsError.BAD_STATE, 'No restore point'));
// re-validate because this new box version may not accept old configs. if we restore location, it should be validated here as well
error = checkManifestConstraints(restoreConfig.manifest);
if (error) return callback(new AppsError(AppsError.BAD_FIELD, 'Manifest cannot be installed: ' + error.message));
error = validatePortBindings(restoreConfig.portBindings, restoreConfig.manifest.tcpPorts); // maybe new ports got reserved now
if (error) return callback(error);
// ## should probably query new location, access restriction from user
var values = {
manifest: restoreConfig.manifest,
portBindings: restoreConfig.portBindings,
oldConfig: {
location: app.location,
accessRestriction: app.accessRestriction,
portBindings: app.portBindings,
manifest: app.manifest
}
};
appdb.setInstallationCommand(appId, appdb.ISTATE_PENDING_RESTORE, values, function (error) {
if (error && error.reason === DatabaseError.NOT_FOUND) return callback(new AppsError(AppsError.BAD_STATE)); // might be a bad guess
if (error) return callback(new AppsError(AppsError.INTERNAL_ERROR, error));
taskmanager.restartAppTask(appId);
callback(null);
});
});
}
function uninstall(appId, callback) {
assert.strictEqual(typeof appId, 'string');
assert.strictEqual(typeof callback, 'function');
debug('Will uninstall app with id:%s', appId);
appdb.setInstallationCommand(appId, appdb.ISTATE_PENDING_UNINSTALL, function (error) {
if (error && error.reason === DatabaseError.NOT_FOUND) return callback(new AppsError(AppsError.NOT_FOUND, 'No such app'));
if (error) return callback(new AppsError(AppsError.INTERNAL_ERROR, error));
taskmanager.restartAppTask(appId); // since uninstall is allowed from any state, kill current task
callback(null);
});
}
function start(appId, callback) {
assert.strictEqual(typeof appId, 'string');
assert.strictEqual(typeof callback, 'function');
debug('Will start app with id:%s', appId);
appdb.setRunCommand(appId, appdb.RSTATE_PENDING_START, function (error) {
if (error && error.reason === DatabaseError.NOT_FOUND) return callback(new AppsError(AppsError.BAD_STATE)); // might be a bad guess
if (error) return callback(new AppsError(AppsError.INTERNAL_ERROR, error));
taskmanager.restartAppTask(appId);
callback(null);
});
}
function stop(appId, callback) {
assert.strictEqual(typeof appId, 'string');
assert.strictEqual(typeof callback, 'function');
debug('Will stop app with id:%s', appId);
appdb.setRunCommand(appId, appdb.RSTATE_PENDING_STOP, function (error) {
if (error && error.reason === DatabaseError.NOT_FOUND) return callback(new AppsError(AppsError.BAD_STATE)); // might be a bad guess
if (error) return callback(new AppsError(AppsError.INTERNAL_ERROR, error));
taskmanager.restartAppTask(appId);
callback(null);
});
}
function checkManifestConstraints(manifest) {
if (semver.valid(manifest.maxBoxVersion) && semver.gt(config.version(), manifest.maxBoxVersion)) {
return new Error('Box version exceeds Apps maxBoxVersion');
}
if (semver.valid(manifest.minBoxVersion) && semver.gt(manifest.minBoxVersion, config.version())) {
return new Error('minBoxVersion exceeds Box version');
}
return null;
}
function exec(appId, options, callback) {
assert.strictEqual(typeof appId, 'string');
assert(options && typeof options === 'object');
assert.strictEqual(typeof callback, 'function');
var cmd = options.cmd || [ '/bin/bash' ];
assert(util.isArray(cmd) && cmd.length > 0);
appdb.get(appId, function (error, app) {
if (error && error.reason === DatabaseError.NOT_FOUND) return callback(new AppsError(AppsError.NOT_FOUND, 'No such app'));
if (error) return callback(new AppsError(AppsError.INTERNAL_ERROR, error));
var container = docker.getContainer(app.containerId);
var execOptions = {
AttachStdin: true,
AttachStdout: true,
AttachStderr: true,
Tty: true,
Cmd: cmd
};
container.exec(execOptions, function (error, exec) {
if (error) return callback(new AppsError(AppsError.INTERNAL_ERROR, error));
var startOptions = {
Detach: false,
Tty: true,
stdin: true // this is a dockerode option that enabled openStdin in the modem
};
exec.start(startOptions, function(error, stream) {
if (error) return callback(new AppsError(AppsError.INTERNAL_ERROR, error));
if (options.rows && options.columns) {
exec.resize({ h: options.rows, w: options.columns }, function (error) { if (error) debug('Error resizing console', error); });
}
return callback(null, stream);
});
});
});
}
function setRestorePoint(appId, lastBackupId, lastBackupConfig, callback) {
assert.strictEqual(typeof appId, 'string');
assert.strictEqual(typeof lastBackupId, 'string');
assert.strictEqual(typeof lastBackupConfig, 'object');
assert.strictEqual(typeof callback, 'function');
appdb.update(appId, { lastBackupId: lastBackupId, lastBackupConfig: lastBackupConfig }, function (error) {
if (error && error.reason === DatabaseError.NOT_FOUND) return callback(new AppsError(AppsError.NOT_FOUND, 'No such app'));
if (error) return callback(new AppsError(AppsError.INTERNAL_ERROR, error));
return callback(null);
});
}
function autoupdateApps(updateInfo, callback) { // updateInfo is { appId -> { manifest } }
assert.strictEqual(typeof updateInfo, 'object');
assert.strictEqual(typeof callback, 'function');
function canAutoupdateApp(app, newManifest) {
// TODO: maybe check the description as well?
if (!newManifest.tcpPorts && !app.portBindings) return true;
if (!newManifest.tcpPorts || !app.portBindings) return false;
for (var env in newManifest.tcpPorts) {
if (!(env in app.portBindings)) return false;
}
return true;
}
if (!updateInfo) return callback(null);
async.eachSeries(Object.keys(updateInfo), function iterator(appId, iteratorDone) {
get(appId, function (error, app) {
if (!canAutoupdateApp(app, updateInfo.manifest)) {
return iteratorDone();
}
update(appId, updateInfo.manifest, app.portBindings, null /* icon */, function (error) {
if (error) debug('Error initiating autoupdate of %s', appId);
iteratorDone(null);
});
});
}, callback);
}
function backupApp(app, callback) {
assert.strictEqual(typeof app, 'object');
assert.strictEqual(typeof callback, 'function');
function canBackupApp(app) {
// only backup apps that are installed or pending configure. Rest of them are in some
// state not good for consistent backup
return (app.installationState === appdb.ISTATE_INSTALLED && app.health === appdb.HEALTH_HEALTHY)
|| app.installationState === appdb.ISTATE_PENDING_CONFIGURE
|| app.installationState === appdb.ISTATE_PENDING_BACKUP
|| app.installationState === appdb.ISTATE_PENDING_UPDATE; // called from apptask
}
if (!canBackupApp(app)) return callback(new AppsError(AppsError.BAD_STATE, 'App not healthy'));
var appConfig = {
manifest: app.manifest,
location: app.location,
portBindings: app.portBindings,
accessRestriction: app.accessRestriction
};
if (!safe.fs.writeFileSync(path.join(paths.DATA_DIR, app.id + '/config.json'), JSON.stringify(appConfig), 'utf8')) {
return callback(safe.error);
}
backups.getBackupUrl(app, null, function (error, result) {
if (error && error.reason === BackupsError.EXTERNAL_ERROR) return callback(new AppsError(AppsError.EXTERNAL_ERROR, error.message));
if (error) return callback(new AppsError(AppsError.INTERNAL_ERROR, error));
debugApp(app, 'backupApp: backup url:%s backup id:%s', result.url, result.id);
async.series([
ignoreError(shell.sudo.bind(null, 'mountSwap', [ BACKUP_SWAP_CMD, '--on' ])),
addons.backupAddons.bind(null, app, app.manifest.addons),
shell.sudo.bind(null, 'backupApp', [ BACKUP_APP_CMD, app.id, result.url, result.backupKey ]),
ignoreError(shell.sudo.bind(null, 'unmountSwap', [ BACKUP_SWAP_CMD, '--off' ])),
], function (error) {
if (error) return callback(new AppsError(AppsError.INTERNAL_ERROR, error));
debugApp(app, 'backupApp: successful id:%s', result.id);
setRestorePoint(app.id, result.id, appConfig, function (error) {
if (error) return callback(new AppsError(AppsError.INTERNAL_ERROR, error));
return callback(null, result.id);
});
});
});
}
function backup(appId, callback) {
assert.strictEqual(typeof appId, 'string');
assert.strictEqual(typeof callback, 'function');
get(appId, function (error, app) {
if (error && error.reason === AppsError.NOT_FOUND) return callback(new AppsError(AppsError.NOT_FOUND));
if (error) return callback(new AppsError(AppsError.INTERNAL_ERROR, error));
appdb.setInstallationCommand(appId, appdb.ISTATE_PENDING_BACKUP, function (error) {
if (error && error.reason === DatabaseError.NOT_FOUND) return callback(new AppsError(AppsError.BAD_STATE)); // might be a bad guess
if (error) return callback(new AppsError(AppsError.INTERNAL_ERROR, error));
taskmanager.restartAppTask(appId);
callback(null);
});
});
}
function restoreApp(app, callback) {
assert.strictEqual(typeof app, 'object');
assert.strictEqual(typeof callback, 'function');
assert(app.lastBackupId);
backups.getRestoreUrl(app.lastBackupId, function (error, result) {
if (error && error.reason == BackupsError.EXTERNAL_ERROR) return callback(new AppsError(AppsError.EXTERNAL_ERROR, error.message));
if (error) return callback(new AppsError(AppsError.INTERNAL_ERROR, error));
debugApp(app, 'restoreApp: restoreUrl:%s', result.url);
shell.sudo('restoreApp', [ RESTORE_APP_CMD, app.id, result.url, result.backupKey ], function (error) {
if (error) return callback(new AppsError(AppsError.INTERNAL_ERROR, error));
addons.restoreAddons(app, app.manifest.addons, callback);
});
});
}

929
src/apptask.js Normal file
View File

@@ -0,0 +1,929 @@
#!/usr/bin/env node
/* jslint node:true */
'use strict';
exports = module.exports = {
initialize: initialize,
startTask: startTask,
// exported for testing
_getFreePort: getFreePort,
_configureNginx: configureNginx,
_unconfigureNginx: unconfigureNginx,
_createVolume: createVolume,
_deleteVolume: deleteVolume,
_allocateOAuthProxyCredentials: allocateOAuthProxyCredentials,
_removeOAuthProxyCredentials: removeOAuthProxyCredentials,
_verifyManifest: verifyManifest,
_registerSubdomain: registerSubdomain,
_unregisterSubdomain: unregisterSubdomain,
_reloadNginx: reloadNginx,
_waitForDnsPropagation: waitForDnsPropagation
};
require('supererror')({ splatchError: true });
var addons = require('./addons.js'),
appdb = require('./appdb.js'),
apps = require('./apps.js'),
assert = require('assert'),
async = require('async'),
clientdb = require('./clientdb.js'),
config = require('./config.js'),
database = require('./database.js'),
DatabaseError = require('./databaseerror.js'),
debug = require('debug')('box:apptask'),
docker = require('./docker.js'),
ejs = require('ejs'),
fs = require('fs'),
hat = require('hat'),
manifestFormat = require('cloudron-manifestformat'),
net = require('net'),
os = require('os'),
path = require('path'),
paths = require('./paths.js'),
safe = require('safetydance'),
shell = require('./shell.js'),
superagent = require('superagent'),
sysinfo = require('./sysinfo.js'),
util = require('util'),
uuid = require('node-uuid'),
vbox = require('./vbox.js'),
_ = require('underscore');
var NGINX_APPCONFIG_EJS = fs.readFileSync(__dirname + '/../setup/start/nginx/appconfig.ejs', { encoding: 'utf8' }),
COLLECTD_CONFIG_EJS = fs.readFileSync(__dirname + '/collectd.config.ejs', { encoding: 'utf8' }),
RELOAD_NGINX_CMD = path.join(__dirname, 'scripts/reloadnginx.sh'),
RELOAD_COLLECTD_CMD = path.join(__dirname, 'scripts/reloadcollectd.sh'),
RMAPPDIR_CMD = path.join(__dirname, 'scripts/rmappdir.sh'),
CREATEAPPDIR_CMD = path.join(__dirname, 'scripts/createappdir.sh');
function initialize(callback) {
database.initialize(callback);
}
function debugApp(app, args) {
assert(!app || typeof app === 'object');
var prefix = app ? (app.location || '(bare)') : '(no app)';
debug(prefix + ' ' + util.format.apply(util, Array.prototype.slice.call(arguments, 1)));
}
// We expect conflicts to not happen despite closing the port (parallel app installs, app update does not reconfigure nginx etc)
// https://tools.ietf.org/html/rfc6056#section-3.5 says linux uses random ephemeral port allocation
function getFreePort(callback) {
var server = net.createServer();
server.listen(0, function () {
var port = server.address().port;
server.close(function () {
return callback(null, port);
});
});
}
function reloadNginx(callback) {
shell.sudo('reloadNginx', [ RELOAD_NGINX_CMD ], callback);
}
function configureNginx(app, callback) {
getFreePort(function (error, freePort) {
if (error) return callback(error);
var sourceDir = path.resolve(__dirname, '..');
var endpoint = app.accessRestriction ? 'oauthproxy' : 'app';
var nginxConf = ejs.render(NGINX_APPCONFIG_EJS, { sourceDir: sourceDir, vhost: config.appFqdn(app.location), port: freePort, endpoint: endpoint });
var nginxConfigFilename = path.join(paths.NGINX_APPCONFIG_DIR, app.id + '.conf');
debugApp(app, 'writing config to %s', nginxConfigFilename);
if (!safe.fs.writeFileSync(nginxConfigFilename, nginxConf)) {
debugApp(app, 'Error creating nginx config : %s', safe.error.message);
return callback(safe.error);
}
async.series([
exports._reloadNginx,
updateApp.bind(null, app, { httpPort: freePort })
], callback);
vbox.forwardFromHostToVirtualBox(app.id + '-http', freePort);
});
}
function unconfigureNginx(app, callback) {
var nginxConfigFilename = path.join(paths.NGINX_APPCONFIG_DIR, app.id + '.conf');
if (!safe.fs.unlinkSync(nginxConfigFilename)) {
debugApp(app, 'Error removing nginx configuration : %s', safe.error.message);
return callback(null);
}
exports._reloadNginx(callback);
vbox.unforwardFromHostToVirtualBox(app.id + '-http');
}
function downloadImage(app, callback) {
debugApp(app, 'downloadImage %s', app.manifest.dockerImage);
docker.pull(app.manifest.dockerImage, function (err, stream) {
if (err) return callback(new Error('Error connecting to docker'));
// https://github.com/dotcloud/docker/issues/1074 says each status message
// is emitted as a chunk
stream.on('data', function (chunk) {
var data = safe.JSON.parse(chunk) || { };
debugApp(app, 'downloadImage data: %j', data);
// The information here is useless because this is per layer as opposed to per image
if (data.status) {
debugApp(app, 'progress: %s', data.status); // progressDetail { current, total }
} else if (data.error) {
debugApp(app, 'error detail: %s', data.errorDetail.message);
}
});
stream.on('end', function () {
debugApp(app, 'download image successfully');
var image = docker.getImage(app.manifest.dockerImage);
image.inspect(function (err, data) {
if (err) {
return callback(new Error('Error inspecting image:' + err.message));
}
if (!data || !data.Config) {
return callback(new Error('Missing Config in image:' + JSON.stringify(data, null, 4)));
}
if (!data.Config.Entrypoint && !data.Config.Cmd) {
return callback(new Error('Only images with entry point are allowed'));
}
debugApp(app, 'This image exposes ports: %j', data.Config.ExposedPorts);
return callback(null);
});
});
});
}
function createContainer(app, callback) {
appdb.getPortBindings(app.id, function (error, portBindings) {
if (error) return callback(error);
var manifest = app.manifest;
var exposedPorts = {};
var env = [];
// docker portBindings requires ports to be exposed
exposedPorts[manifest.httpPort + '/tcp'] = {};
for (var e in portBindings) {
var hostPort = portBindings[e];
var containerPort = manifest.tcpPorts[e].containerPort || hostPort;
exposedPorts[containerPort + '/tcp'] = {};
env.push(e + '=' + hostPort);
}
env.push('CLOUDRON=1');
env.push('ADMIN_ORIGIN' + '=' + config.adminOrigin()); // ## remove
env.push('WEBADMIN_ORIGIN' + '=' + config.adminOrigin());
env.push('API_ORIGIN' + '=' + config.adminOrigin());
addons.getEnvironment(app, function (error, addonEnv) {
if (error) return callback(new Error('Error getting addon env: ' + error));
var containerOptions = {
name: app.id,
Hostname: config.appFqdn(app.location),
Tty: true,
Image: app.manifest.dockerImage,
Cmd: null,
Volumes: {},
VolumesFrom: [],
Env: env.concat(addonEnv),
ExposedPorts: exposedPorts
};
debugApp(app, 'Creating container for %s', app.manifest.dockerImage);
docker.createContainer(containerOptions, function (error, container) {
if (error) return callback(new Error('Error creating container: ' + error));
updateApp(app, { containerId: container.id }, callback);
});
});
});
}
function deleteContainer(app, callback) {
if (app.containerId === null) return callback(null);
var container = docker.getContainer(app.containerId);
var removeOptions = {
force: true, // kill container if it's running
v: false // removes volumes associated with the container
};
container.remove(removeOptions, function (error) {
if (error && error.statusCode === 404) return updateApp(app, { containerId: null }, callback);
if (error) debugApp(app, 'Error removing container', error);
callback(error);
});
}
function deleteImage(app, manifest, callback) {
var dockerImage = manifest ? manifest.dockerImage : null;
if (!dockerImage) return callback(null);
docker.getImage(dockerImage).inspect(function (error, result) {
if (error && error.statusCode === 404) return callback(null);
if (error) return callback(error);
var removeOptions = {
force: true,
noprune: false
};
// delete image by id because docker pull pulls down all the tags and this is the only way to delete all tags
docker.getImage(result.Id).remove(removeOptions, function (error) {
if (error && error.statusCode === 404) return callback(null);
if (error && error.statusCode === 409) return callback(null); // another container using the image
if (error) debugApp(app, 'Error removing image', error);
callback(error);
});
});
}
function createVolume(app, callback) {
shell.sudo('createVolume', [ CREATEAPPDIR_CMD, app.id ], callback);
}
function deleteVolume(app, callback) {
shell.sudo('deleteVolume', [ RMAPPDIR_CMD, app.id ], callback);
}
function allocateOAuthProxyCredentials(app, callback) {
assert.strictEqual(typeof app, 'object');
assert.strictEqual(typeof callback, 'function');
if (!app.accessRestriction) return callback(null);
var appId = 'proxy-' + app.id;
var id = 'cid-proxy-' + uuid.v4();
var clientSecret = hat(256);
var redirectURI = 'https://' + config.appFqdn(app.location);
var scope = 'profile,' + app.accessRestriction;
clientdb.add(id, appId, clientSecret, redirectURI, scope, callback);
}
function removeOAuthProxyCredentials(app, callback) {
assert.strictEqual(typeof app, 'object');
assert.strictEqual(typeof callback, 'function');
clientdb.delByAppId('proxy-' + app.id, function (error) {
if (error && error.reason !== DatabaseError.NOT_FOUND) {
debugApp(app, 'Error removing OAuth client id', error);
return callback(error);
}
callback(null);
});
}
function addCollectdProfile(app, callback) {
var collectdConf = ejs.render(COLLECTD_CONFIG_EJS, { appId: app.id, containerId: app.containerId });
fs.writeFile(path.join(paths.COLLECTD_APPCONFIG_DIR, app.id + '.conf'), collectdConf, function (error) {
if (error) return callback(error);
shell.sudo('addCollectdProfile', [ RELOAD_COLLECTD_CMD ], callback);
});
}
function removeCollectdProfile(app, callback) {
fs.unlink(path.join(paths.COLLECTD_APPCONFIG_DIR, app.id + '.conf'), function (error) {
if (error && error.code !== 'ENOENT') debugApp(app, 'Error removing collectd profile', error);
shell.sudo('removeCollectdProfile', [ RELOAD_COLLECTD_CMD ], callback);
});
}
function startContainer(app, callback) {
appdb.getPortBindings(app.id, function (error, portBindings) {
if (error) return callback(error);
var manifest = app.manifest;
var dockerPortBindings = { };
var isMac = os.platform() === 'darwin';
// On Mac (boot2docker), we have to export the port to external world for port forwarding from Mac to work
dockerPortBindings[manifest.httpPort + '/tcp'] = [ { HostIp: isMac ? '0.0.0.0' : '127.0.0.1', HostPort: app.httpPort + '' } ];
for (var env in portBindings) {
var hostPort = portBindings[env];
var containerPort = manifest.tcpPorts[env].containerPort || hostPort;
dockerPortBindings[containerPort + '/tcp'] = [ { HostIp: '0.0.0.0', HostPort: hostPort + '' } ];
vbox.forwardFromHostToVirtualBox(app.id + '-tcp' + containerPort, hostPort);
}
var startOptions = {
Binds: addons.getBindsSync(app, app.manifest.addons),
PortBindings: dockerPortBindings,
PublishAllPorts: false,
Links: addons.getLinksSync(app, app.manifest.addons),
RestartPolicy: {
"Name": "always",
"MaximumRetryCount": 0
}
};
var container = docker.getContainer(app.containerId);
debugApp(app, 'Starting container %s with options: %j', container.id, JSON.stringify(startOptions));
container.start(startOptions, function (error, data) {
if (error && error.statusCode !== 304) return callback(new Error('Error starting container:' + error));
return callback(null);
});
});
}
function stopContainer(app, callback) {
var container = docker.getContainer(app.containerId);
debugApp(app, 'Stopping container %s', container.id);
var options = {
t: 10 // wait for 10 seconds before killing it
};
container.stop(options, function (error) {
if (error && (error.statusCode !== 304 && error.statusCode !== 404)) return callback(new Error('Error stopping container:' + error));
var tcpPorts = safe.query(app, 'manifest.tcpPorts', { });
for (var containerPort in tcpPorts) {
vbox.unforwardFromHostToVirtualBox(app.id + '-tcp' + containerPort);
}
debugApp(app, 'Waiting for container ' + container.id);
container.wait(function (error, data) {
if (error && (error.statusCode !== 304 && error.statusCode !== 404)) return callback(new Error('Error waiting on container:' + error));
debugApp(app, 'Container stopped with status code [%s]', data ? String(data.StatusCode) : '');
return callback(null);
});
});
}
function verifyManifest(app, callback) {
debugApp(app, 'Verifying manifest');
var manifest = app.manifest;
var error = manifestFormat.parse(manifest);
if (error) return callback(new Error(util.format('Manifest error: %s', error.message)));
error = apps.checkManifestConstraints(manifest);
if (error) return callback(error);
return callback(null);
}
function downloadIcon(app, callback) {
debugApp(app, 'Downloading icon of %s@%s', app.appStoreId, app.manifest.version);
var iconUrl = config.apiServerOrigin() + '/api/v1/apps/' + app.appStoreId + '/versions/' + app.manifest.version + '/icon';
superagent
.get(iconUrl)
.buffer(true)
.end(function (error, res) {
if (error) return callback(new Error('Error downloading icon:' + error.message));
if (res.status !== 200) return callback(null); // ignore error. this can also happen for apps installed with cloudron-cli
if (!safe.fs.writeFileSync(path.join(paths.APPICONS_DIR, app.id + '.png'), res.body)) return callback(new Error('Error saving icon:' + safe.error.message));
callback(null);
});
}
function registerSubdomain(app, callback) {
debugApp(app, 'Registering subdomain location [%s]', app.location);
// even though the bare domain is already registered in the appstore, we still
// 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);
debugApp(app, 'Registered subdomain status: %s', res.status);
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);
});
}
function unregisterSubdomain(app, callback) {
debugApp(app, 'Unregistering subdomain: dnsRecordId=%s', app.dnsRecordId);
if (!app.dnsRecordId) return callback(null);
// do not unregister bare domain because we show a error/cloudron info page there
if (app.location === '') return updateApp(app, { dnsRecordId: null }, callback);
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);
}
updateApp(app, { dnsRecordId: null }, callback);
});
}
function removeIcon(app, callback) {
fs.unlink(path.join(paths.APPICONS_DIR, app.id + '.png'), function (error) {
if (error && error.code !== 'ENOENT') debugApp(app, 'cannot remove icon : %s', error);
callback(null);
});
}
function waitForDnsPropagation(app, callback) {
if (!config.CLOUDRON) {
debugApp(app, 'Skipping dns propagation check for development');
return callback(null);
}
function retry(error) {
debugApp(app, 'waitForDnsPropagation: ', error);
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));
debugApp(app, 'waitForDnsPropagation: dnsRecordId:%s status:%s', app.dnsRecordId, res.status);
if (res.status !== 200) return retry(new Error(util.format('Error getting record status: %s %j', res.status, res.body)));
if (res.body.status !== 'done') return retry(new Error(util.format('app:%s not ready yet: %s', app.id, res.body.status)));
callback(null);
});
}
// updates the app object and the database
function updateApp(app, values, callback) {
debugApp(app, 'installationState: %s progress: %s', app.installationState, app.installationProgress);
appdb.update(app.id, values, function (error) {
if (error) return callback(error);
for (var value in values) {
app[value] = values[value];
}
return callback(null);
});
}
// Ordering is based on the following rationale:
// - configure nginx, icon, oauth
// - register subdomain.
// at this point, the user can visit the site and the above nginx config can show some install screen.
// the icon can be displayed in this nginx page and oauth proxy means the page can be protected
// - download image
// - setup volumes
// - setup addons (requires the above volume)
// - setup the container (requires image, volumes, addons)
// - setup collectd (requires container id)
function install(app, callback) {
async.series([
verifyManifest.bind(null, app),
// teardown for re-installs
updateApp.bind(null, app, { installationProgress: '10, Cleaning up old install' }),
removeCollectdProfile.bind(null, app),
stopApp.bind(null, app),
deleteContainer.bind(null, app),
addons.teardownAddons.bind(null, app, app.manifest.addons),
deleteVolume.bind(null, app),
unregisterSubdomain.bind(null, app),
removeOAuthProxyCredentials.bind(null, app),
removeIcon.bind(null, app),
unconfigureNginx.bind(null, app),
updateApp.bind(null, app, { installationProgress: '15, Configure nginx' }),
configureNginx.bind(null, app),
updateApp.bind(null, app, { installationProgress: '20, Downloading icon' }),
downloadIcon.bind(null, app),
updateApp.bind(null, app, { installationProgress: '25, Creating OAuth proxy credentials' }),
allocateOAuthProxyCredentials.bind(null, app),
updateApp.bind(null, app, { installationProgress: '30, Registering subdomain' }),
registerSubdomain.bind(null, app),
updateApp.bind(null, app, { installationProgress: '40, Downloading image' }),
downloadImage.bind(null, app),
updateApp.bind(null, app, { installationProgress: '50, Creating volume' }),
createVolume.bind(null, app),
updateApp.bind(null, app, { installationProgress: '60, Setting up addons' }),
addons.setupAddons.bind(null, app, app.manifest.addons),
updateApp.bind(null, app, { installationProgress: '70, Creating container' }),
createContainer.bind(null, app),
updateApp.bind(null, app, { installationProgress: '80, Setting up collectd profile' }),
addCollectdProfile.bind(null, app),
runApp.bind(null, app),
updateApp.bind(null, app, { installationProgress: '90, Waiting for DNS propagation' }),
exports._waitForDnsPropagation.bind(null, app),
// done!
function (callback) {
debugApp(app, 'installed');
updateApp(app, { installationState: appdb.ISTATE_INSTALLED, installationProgress: '', health: null }, callback);
}
], function seriesDone(error) {
if (error) {
debugApp(app, 'error installing app: %s', error);
return updateApp(app, { installationState: appdb.ISTATE_ERROR, installationProgress: error.message }, callback.bind(null, error));
}
callback(null);
});
}
function backup(app, callback) {
async.series([
updateApp.bind(null, app, { installationProgress: '10, Backing up' }),
apps.backupApp.bind(null, app),
// done!
function (callback) {
debugApp(app, 'installed');
updateApp(app, { installationState: appdb.ISTATE_INSTALLED, installationProgress: '' }, callback);
}
], function seriesDone(error) {
if (error) {
debugApp(app, 'error backing up app: %s', error);
return updateApp(app, { installationState: appdb.ISTATE_INSTALLED, installationProgress: error.message }, callback.bind(null, error)); // return to installed state intentionally
}
callback(null);
});
}
// restore is also called for upgrades and infra updates. note that in those cases it is possible there is no backup
function restore(app, callback) {
// we don't have a backup, same as re-install
if (!app.lastBackupId) return install(app, callback);
async.series([
updateApp.bind(null, app, { installationProgress: '10, Cleaning up old install' }),
removeCollectdProfile.bind(null, app),
stopApp.bind(null, app),
deleteContainer.bind(null, app),
// oldConfig can be null during upgrades
addons.teardownAddons.bind(null, app, app.oldConfig ? app.oldConfig.manifest.addons : null),
deleteVolume.bind(null, app),
deleteImage.bind(null, app, app.manifest),
removeOAuthProxyCredentials.bind(null, app),
removeIcon.bind(null, app),
unconfigureNginx.bind(null, app),
updateApp.bind(null, app, { installationProgress: '30, Configuring Nginx' }),
configureNginx.bind(null, app),
updateApp.bind(null, app, { installationProgress: '40, Downloading icon' }),
downloadIcon.bind(null, app),
updateApp.bind(null, app, { installationProgress: '50, Create OAuth proxy credentials' }),
allocateOAuthProxyCredentials.bind(null, app),
updateApp.bind(null, app, { installationProgress: '55, Registering subdomain' }), // ip might change during upgrades
registerSubdomain.bind(null, app),
updateApp.bind(null, app, { installationProgress: '60, Downloading image' }),
downloadImage.bind(null, app),
updateApp.bind(null, app, { installationProgress: '65, Creating volume' }),
createVolume.bind(null, app),
updateApp.bind(null, app, { installationProgress: '70, Download backup and restore addons' }),
apps.restoreApp.bind(null, app),
updateApp.bind(null, app, { installationProgress: '75, Creating container' }),
createContainer.bind(null, app),
updateApp.bind(null, app, { installationProgress: '80, Setting up collectd profile' }),
addCollectdProfile.bind(null, app),
runApp.bind(null, app),
updateApp.bind(null, app, { installationProgress: '90, Waiting for DNS propagation' }),
exports._waitForDnsPropagation.bind(null, app),
// done!
function (callback) {
debugApp(app, 'restored');
updateApp(app, { installationState: appdb.ISTATE_INSTALLED, installationProgress: '', health: null }, callback);
}
], function seriesDone(error) {
if (error) {
debugApp(app, 'Error installing app: %s', error);
return updateApp(app, { installationState: appdb.ISTATE_ERROR, installationProgress: error.message }, callback.bind(null, error));
}
callback(null);
});
}
// note that configure is called after an infra update as well
function configure(app, callback) {
var locationChanged = app.oldConfig.location !== app.location;
async.series([
updateApp.bind(null, app, { installationProgress: '10, Cleaning up old install' }),
removeCollectdProfile.bind(null, app),
stopApp.bind(null, app),
deleteContainer.bind(null, app),
function (next) {
if (!locationChanged) return next();
unregisterSubdomain(app, next);
},
removeOAuthProxyCredentials.bind(null, app),
unconfigureNginx.bind(null, app),
updateApp.bind(null, app, { installationProgress: '25, Configuring Nginx' }),
configureNginx.bind(null, app),
updateApp.bind(null, app, { installationProgress: '30, Create OAuth proxy credentials' }),
allocateOAuthProxyCredentials.bind(null, app),
function (next) {
if (!locationChanged) return next();
async.series([
updateApp.bind(null, app, { installationProgress: '35, Registering subdomain' }),
registerSubdomain.bind(null, app)
], next);
},
// re-setup addons since they rely on the app's fqdn (e.g oauth)
updateApp.bind(null, app, { installationProgress: '50, Setting up addons' }),
addons.setupAddons.bind(null, app, app.manifest.addons),
updateApp.bind(null, app, { installationProgress: '60, Creating container' }),
createContainer.bind(null, app),
updateApp.bind(null, app, { installationProgress: '70, Add collectd profile' }),
addCollectdProfile.bind(null, app),
runApp.bind(null, app),
function (next) {
if (!locationChanged) return next();
async.series([
updateApp.bind(null, app, { installationProgress: '80, Waiting for DNS propagation' }),
exports._waitForDnsPropagation.bind(null, app)
], next);
},
// done!
function (callback) {
debugApp(app, 'configured');
updateApp(app, { installationState: appdb.ISTATE_INSTALLED, installationProgress: '', health: null }, callback);
}
], function seriesDone(error) {
if (error) {
debugApp(app, 'error reconfiguring : %s', error);
return updateApp(app, { installationState: appdb.ISTATE_ERROR, installationProgress: error.message }, callback.bind(null, error));
}
callback(null);
});
}
// nginx configuration is skipped because app.httpPort is expected to be available
function update(app, callback) {
debugApp(app, 'Updating to %s', safe.query(app, 'manifest.version'));
// app does not want these addons anymore
var unusedAddons = _.difference(Object.keys(app.oldConfig.manifest.addons), Object.keys(app.manifest.addons));
async.series([
updateApp.bind(null, app, { installationProgress: '0, Verify manifest' }),
verifyManifest.bind(null, app),
updateApp.bind(null, app, { installationProgress: '10, Backup app' }),
function (done) {
apps.backupApp(app, function (error) {
if (error) error.backupError = true;
done(error);
});
},
updateApp.bind(null, app, { installationProgress: '10, Cleaning up old install' }),
removeCollectdProfile.bind(null, app),
stopApp.bind(null, app),
deleteContainer.bind(null, app),
addons.teardownAddons.bind(null, app, unusedAddons),
deleteImage.bind(null, app, app.manifest), // delete image even if did not change (see df158b111f)
removeIcon.bind(null, app),
updateApp.bind(null, app, { installationProgress: '35, Downloading icon' }),
downloadIcon.bind(null, app),
updateApp.bind(null, app, { installationProgress: '45, Downloading image' }),
downloadImage.bind(null, app),
updateApp.bind(null, app, { installationProgress: '70, Updating addons' }),
addons.setupAddons.bind(null, app, app.manifest.addons),
updateApp.bind(null, app, { installationProgress: '80, Creating container' }),
createContainer.bind(null, app),
updateApp.bind(null, app, { installationProgress: '90, Add collectd profile' }),
addCollectdProfile.bind(null, app),
runApp.bind(null, app),
// done!
function (callback) {
debugApp(app, 'updated');
updateApp(app, { installationState: appdb.ISTATE_INSTALLED, installationProgress: '', health: null }, callback);
}
], function seriesDone(error) {
if (error && error.backupError) {
// on a backup error, just abort the update
debugApp(app, 'Error backing up app: %s', error.backupError);
return updateApp(app, { installationState: appdb.ISTATE_INSTALLED, installationProgress: '', health: null }, callback.bind(null, error));
} else if (error) {
debugApp(app, 'Error updating app: %s', error);
return updateApp(app, { installationState: appdb.ISTATE_ERROR, installationProgress: error.message }, callback.bind(null, error));
}
callback(null);
});
}
function uninstall(app, callback) {
debugApp(app, 'uninstalling');
async.series([
updateApp.bind(null, app, { installationProgress: '0, Remove collectd profile' }),
removeCollectdProfile.bind(null, app),
updateApp.bind(null, app, { installationProgress: '10, Stopping app' }),
stopApp.bind(null, app),
updateApp.bind(null, app, { installationProgress: '20, Deleting container' }),
deleteContainer.bind(null, app),
updateApp.bind(null, app, { installationProgress: '30, Teardown addons' }),
addons.teardownAddons.bind(null, app, app.manifest.addons),
updateApp.bind(null, app, { installationProgress: '40, Deleting volume' }),
deleteVolume.bind(null, app),
updateApp.bind(null, app, { installationProgress: '50, Deleting image' }),
deleteImage.bind(null, app, app.manifest),
updateApp.bind(null, app, { installationProgress: '60, Unregistering subdomain' }),
unregisterSubdomain.bind(null, app),
updateApp.bind(null, app, { installationProgress: '70, Remove OAuth credentials' }),
removeOAuthProxyCredentials.bind(null, app),
updateApp.bind(null, app, { installationProgress: '80, Cleanup icon' }),
removeIcon.bind(null, app),
updateApp.bind(null, app, { installationProgress: '90, Unconfiguring Nginx' }),
unconfigureNginx.bind(null, app),
updateApp.bind(null, app, { installationProgress: '95, Remove app from database' }),
appdb.del.bind(null, app.id)
], callback);
}
function runApp(app, callback) {
startContainer(app, function (error) {
if (error) {
debugApp(app, 'Error starting container : %s', error);
return updateApp(app, { runState: appdb.RSTATE_ERROR }, callback);
}
updateApp(app, { runState: appdb.RSTATE_RUNNING }, callback);
});
}
function stopApp(app, callback) {
stopContainer(app, function (error) {
if (error) return callback(error);
updateApp(app, { runState: appdb.RSTATE_STOPPED }, callback);
});
}
function handleRunCommand(app, callback) {
if (app.runState === appdb.RSTATE_PENDING_STOP) {
return stopApp(app, callback);
}
if (app.runState === appdb.RSTATE_PENDING_START || app.runState === appdb.RSTATE_RUNNING) {
debugApp(app, 'Resuming app with state : %s', app.runState);
return runApp(app, callback);
}
debugApp(app, 'handleRunCommand - doing nothing: %s', app.runState);
return callback(null);
}
function startTask(appId, callback) {
// determine what to do
appdb.get(appId, function (error, app) {
if (error) return callback(error);
debugApp(app, 'startTask installationState: %s runState: %s', app.installationState, app.runState);
if (app.installationState === appdb.ISTATE_PENDING_UNINSTALL) {
return uninstall(app, callback);
}
if (app.installationState === appdb.ISTATE_PENDING_CONFIGURE) {
return configure(app, callback);
}
if (app.installationState === appdb.ISTATE_PENDING_UPDATE) {
return update(app, callback);
}
if (app.installationState === appdb.ISTATE_PENDING_RESTORE) {
return restore(app, callback);
}
if (app.installationState === appdb.ISTATE_PENDING_BACKUP) {
return backup(app, callback);
}
if (app.installationState === appdb.ISTATE_INSTALLED) {
return handleRunCommand(app, callback);
}
if (app.installationState === appdb.ISTATE_PENDING_INSTALL) {
return install(app, callback);
}
debugApp(app, 'Apptask launched but nothing to do.');
return callback(null);
});
}
if (require.main === module) {
assert.strictEqual(process.argv.length, 3, 'Pass the appid as argument');
debug('Apptask for %s', process.argv[2]);
initialize(function (error) {
if (error) throw error;
startTask(process.argv[2], function (error) {
if (error) console.error(error);
debug('Apptask completed for %s', process.argv[2]);
// https://nodejs.org/api/process.html are exit codes used by node. apps.js uses the value below
// to check apptask crashes
process.exit(error ? 50 : 0);
});
});
}

139
src/auth.js Normal file
View File

@@ -0,0 +1,139 @@
/* jslint node:true */
'use strict';
exports = module.exports = {
initialize: initialize,
uninitialize: uninitialize
};
var assert = require('assert'),
BasicStrategy = require('passport-http').BasicStrategy,
BearerStrategy = require('passport-http-bearer').Strategy,
clientdb = require('./clientdb'),
ClientPasswordStrategy = require('passport-oauth2-client-password').Strategy,
DatabaseError = require('./databaseerror'),
debug = require('debug')('box:auth'),
LocalStrategy = require('passport-local').Strategy,
crypto = require('crypto'),
passport = require('passport'),
tokendb = require('./tokendb'),
user = require('./user'),
userdb = require('./userdb'),
UserError = user.UserError,
_ = require('underscore');
function initialize(callback) {
assert.strictEqual(typeof callback, 'function');
passport.serializeUser(function (user, callback) {
callback(null, user.username);
});
passport.deserializeUser(function(username, callback) {
userdb.get(username, function (error, result) {
if (error) return callback(error);
var md5 = crypto.createHash('md5').update(result.email.toLowerCase()).digest('hex');
result.gravatar = 'https://www.gravatar.com/avatar/' + md5 + '.jpg?s=24&d=mm';
callback(null, result);
});
});
passport.use(new LocalStrategy(function (username, password, callback) {
if (username.indexOf('@') === -1) {
user.verify(username, password, function (error, result) {
if (error && error.reason === UserError.NOT_FOUND) return callback(null, false);
if (error && error.reason === UserError.WRONG_PASSWORD) return callback(null, false);
if (error) return callback(error);
if (!result) return callback(null, false);
callback(null, _.pick(result, 'id', 'username', 'email', 'admin'));
});
} else {
user.verifyWithEmail(username, password, function (error, result) {
if (error && error.reason === UserError.NOT_FOUND) return callback(null, false);
if (error && error.reason === UserError.WRONG_PASSWORD) return callback(null, false);
if (error) return callback(error);
if (!result) return callback(null, false);
callback(null, _.pick(result, 'id', 'username', 'email', 'admin'));
});
}
}));
passport.use(new BasicStrategy(function (username, password, callback) {
if (username.indexOf('cid-') === 0) {
debug('BasicStrategy: detected client id %s instead of username:password', username);
// username is actually client id here
// password is client secret
clientdb.get(username, function (error, client) {
if (error && error.reason === DatabaseError.NOT_FOUND) return callback(null, false);
if (error) return callback(error);
if (client.clientSecret != password) return callback(null, false);
return callback(null, client);
});
} else {
user.verify(username, password, function (error, result) {
if (error && error.reason === UserError.NOT_FOUND) return callback(null, false);
if (error && error.reason === UserError.WRONG_PASSWORD) return callback(null, false);
if (error) return callback(error);
if (!result) return callback(null, false);
callback(null, result);
});
}
}));
passport.use(new ClientPasswordStrategy(function (clientId, clientSecret, callback) {
clientdb.get(clientId, function(error, client) {
if (error && error.reason === DatabaseError.NOT_FOUND) return callback(null, false);
if (error) { return callback(error); }
if (client.clientSecret != clientSecret) { return callback(null, false); }
return callback(null, client);
});
}));
passport.use(new BearerStrategy(function (accessToken, callback) {
tokendb.get(accessToken, function (error, token) {
if (error && error.reason === DatabaseError.NOT_FOUND) return callback(null, false);
if (error) return callback(error);
// scopes here can define what capabilities that token carries
// passport put the 'info' object into req.authInfo, where we can further validate the scopes
var info = { scope: token.scope };
var tokenType;
if (token.identifier.indexOf(tokendb.PREFIX_DEV) === 0) {
token.identifier = token.identifier.slice(tokendb.PREFIX_DEV.length);
tokenType = tokendb.TYPE_DEV;
} else if (token.identifier.indexOf(tokendb.PREFIX_APP) === 0) {
tokenType = tokendb.TYPE_APP;
return callback(null, { id: token.identifier.slice(tokendb.PREFIX_APP.length), tokenType: tokenType }, info);
} else if (token.identifier.indexOf(tokendb.PREFIX_USER) === 0) {
tokenType = tokendb.TYPE_USER;
token.identifier = token.identifier.slice(tokendb.PREFIX_USER.length);
} else {
// legacy tokens assuming a user access token
tokenType = tokendb.TYPE_USER;
}
userdb.get(token.identifier, function (error, user) {
if (error && error.reason === DatabaseError.NOT_FOUND) return callback(null, false);
if (error) return callback(error);
// amend the tokenType of the token owner
user.tokenType = tokenType;
callback(null, user, info);
});
});
}));
callback(null);
}
function uninitialize(callback) {
assert.strictEqual(typeof callback, 'function');
callback(null);
}

78
src/authcodedb.js Normal file
View File

@@ -0,0 +1,78 @@
/* jslint node:true */
'use strict';
exports = module.exports = {
get: get,
add: add,
del: del,
delExpired: delExpired,
_clear: clear
};
var assert = require('assert'),
database = require('./database.js'),
DatabaseError = require('./databaseerror');
var AUTHCODES_FIELDS = [ 'authCode', 'userId', 'clientId', 'expiresAt' ].join(',');
function get(authCode, callback) {
assert.strictEqual(typeof authCode, 'string');
assert.strictEqual(typeof callback, 'function');
database.query('SELECT ' + AUTHCODES_FIELDS + ' FROM authcodes WHERE authCode = ? AND expiresAt > ?', [ authCode, Date.now() ], function (error, result) {
if (error) return callback(new DatabaseError(DatabaseError.INTERNAL_ERROR, error));
if (result.length === 0) return callback(new DatabaseError(DatabaseError.NOT_FOUND));
callback(null, result[0]);
});
}
function add(authCode, clientId, userId, expiresAt, callback) {
assert.strictEqual(typeof authCode, 'string');
assert.strictEqual(typeof clientId, 'string');
assert.strictEqual(typeof userId, 'string');
assert.strictEqual(typeof expiresAt, 'number');
assert.strictEqual(typeof callback, 'function');
database.query('INSERT INTO authcodes (authCode, clientId, userId, expiresAt) VALUES (?, ?, ?, ?)',
[ authCode, clientId, userId, expiresAt ], function (error, result) {
if (error && error.code === 'ER_DUP_ENTRY') return callback(new DatabaseError(DatabaseError.ALREADY_EXISTS));
if (error || result.affectedRows !== 1) return callback(new DatabaseError(DatabaseError.INTERNAL_ERROR, error));
callback(null);
});
}
function del(authCode, callback) {
assert.strictEqual(typeof authCode, 'string');
assert.strictEqual(typeof callback, 'function');
database.query('DELETE FROM authcodes WHERE authCode = ?', [ authCode ], function (error, result) {
if (error) return callback(new DatabaseError(DatabaseError.INTERNAL_ERROR, error));
if (result.affectedRows !== 1) return callback(new DatabaseError(DatabaseError.NOT_FOUND));
callback(null);
});
}
function delExpired(callback) {
assert.strictEqual(typeof callback, 'function');
database.query('DELETE FROM authcodes WHERE expiresAt <= ?', [ Date.now() ], function (error, result) {
if (error) return callback(new DatabaseError(DatabaseError.INTERNAL_ERROR, error));
return callback(null, result.affectedRows);
});
}
function clear(callback) {
assert.strictEqual(typeof callback, 'function');
database.query('DELETE FROM authcodes', function (error) {
if (error) return callback(new DatabaseError(DatabaseError.INTERNAL_ERROR, error));
callback(null);
});
}

95
src/backups.js Normal file
View File

@@ -0,0 +1,95 @@
'use strict';
exports = module.exports = {
BackupsError: BackupsError,
getAllPaged: getAllPaged,
getBackupUrl: getBackupUrl,
getRestoreUrl: getRestoreUrl
};
var assert = require('assert'),
config = require('./config.js'),
debug = require('debug')('box:backups'),
superagent = require('superagent'),
util = require('util');
function BackupsError(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(BackupsError, Error);
BackupsError.EXTERNAL_ERROR = 'external error';
BackupsError.INTERNAL_ERROR = 'internal error';
function getAllPaged(page, perPage, callback) {
assert.strictEqual(typeof page, 'number');
assert.strictEqual(typeof perPage, 'number');
assert.strictEqual(typeof callback, 'function');
var url = config.apiServerOrigin() + '/api/v1/boxes/' + config.fqdn() + '/backups';
superagent.get(url).query({ token: config.token() }).end(function (error, result) {
if (error) return callback(new BackupsError(BackupsError.EXTERNAL_ERROR, error));
if (result.statusCode !== 200) return callback(new BackupsError(BackupsError.EXTERNAL_ERROR, result.text));
if (!result.body || !util.isArray(result.body.backups)) return callback(new BackupsError(BackupsError.EXTERNAL_ERROR, 'Unexpected response'));
// [ { creationTime, boxVersion, restoreKey, dependsOn: [ ] } ] sorted by time (latest first)
return callback(null, result.body.backups);
});
}
function getBackupUrl(app, appBackupIds, callback) {
assert(!app || typeof app === 'object');
assert(!appBackupIds || util.isArray(appBackupIds));
assert.strictEqual(typeof callback, 'function');
var url = config.apiServerOrigin() + '/api/v1/boxes/' + config.fqdn() + '/backupurl';
var data = {
boxVersion: config.version(),
appId: app ? app.id : null,
appVersion: app ? app.manifest.version : null,
appBackupIds: appBackupIds
};
superagent.put(url).query({ token: config.token() }).send(data).end(function (error, result) {
if (error) return callback(new BackupsError(BackupsError.EXTERNAL_ERROR, error));
if (result.statusCode !== 201) return callback(new BackupsError(BackupsError.EXTERNAL_ERROR, result.text));
if (!result.body || !result.body.url) return callback(new BackupsError(BackupsError.EXTERNAL_ERROR, 'Unexpected response'));
return callback(null, result.body);
});
}
function getRestoreUrl(backupId, callback) {
assert.strictEqual(typeof backupId, 'string');
assert.strictEqual(typeof callback, 'function');
var url = config.apiServerOrigin() + '/api/v1/boxes/' + config.fqdn() + '/restoreurl';
superagent.put(url).query({ token: config.token(), backupId: backupId }).end(function (error, result) {
if (error) return callback(new BackupsError(BackupsError.EXTERNAL_ERROR, error));
if (result.statusCode !== 201) return callback(new BackupsError(BackupsError.EXTERNAL_ERROR, result.text));
if (!result.body || !result.body.url) return callback(new BackupsError(BackupsError.EXTERNAL_ERROR, 'Unexpected response'));
return callback(null, result.body);
});
}

139
src/clientdb.js Normal file
View File

@@ -0,0 +1,139 @@
/* jslint node:true */
'use strict';
exports = module.exports = {
get: get,
getAll: getAll,
getAllWithTokenCountByIdentifier: getAllWithTokenCountByIdentifier,
add: add,
del: del,
update: update,
getByAppId: getByAppId,
delByAppId: delByAppId,
_clear: clear
};
var assert = require('assert'),
database = require('./database.js'),
DatabaseError = require('./databaseerror.js');
var CLIENTS_FIELDS = [ 'id', 'appId', 'clientSecret', 'redirectURI', 'scope' ].join(',');
var CLIENTS_FIELDS_PREFIXED = [ 'clients.id', 'clients.appId', 'clients.clientSecret', 'clients.redirectURI', 'clients.scope' ].join(',');
function get(id, callback) {
assert.strictEqual(typeof id, 'string');
assert.strictEqual(typeof callback, 'function');
database.query('SELECT ' + CLIENTS_FIELDS + ' FROM clients WHERE id = ?', [ id ], function (error, result) {
if (error) return callback(new DatabaseError(DatabaseError.INTERNAL_ERROR, error));
if (result.length === 0) return callback(new DatabaseError(DatabaseError.NOT_FOUND));
callback(null, result[0]);
});
}
function getAll(callback) {
assert.strictEqual(typeof callback, 'function');
database.query('SELECT ' + CLIENTS_FIELDS + ' FROM clients ORDER BY appId', function (error, results) {
if (error) return callback(new DatabaseError(DatabaseError.INTERNAL_ERROR, error));
callback(null, results);
});
}
function getAllWithTokenCountByIdentifier(identifier, callback) {
assert.strictEqual(typeof identifier, 'string');
assert.strictEqual(typeof callback, 'function');
database.query('SELECT ' + CLIENTS_FIELDS_PREFIXED + ',COUNT(tokens.clientId) AS tokenCount FROM clients LEFT OUTER JOIN tokens ON clients.id=tokens.clientId WHERE tokens.identifier=? GROUP BY clients.id', [ identifier ], function (error, results) {
if (error) return callback(new DatabaseError(DatabaseError.INTERNAL_ERROR, error));
callback(null, results);
});
}
function getByAppId(appId, callback) {
assert.strictEqual(typeof appId, 'string');
assert.strictEqual(typeof callback, 'function');
database.query('SELECT ' + CLIENTS_FIELDS + ' FROM clients WHERE appId = ? LIMIT 1', [ appId ], function (error, result) {
if (error) return callback(new DatabaseError(DatabaseError.INTERNAL_ERROR, error));
if (result.length === 0) return callback(new DatabaseError(DatabaseError.NOT_FOUND));
return callback(null, result[0]);
});
}
function add(id, appId, clientSecret, redirectURI, scope, callback) {
assert.strictEqual(typeof id, 'string');
assert.strictEqual(typeof appId, 'string');
assert.strictEqual(typeof clientSecret, 'string');
assert.strictEqual(typeof redirectURI, 'string');
assert.strictEqual(typeof scope, 'string');
assert.strictEqual(typeof callback, 'function');
var data = [ id, appId, clientSecret, redirectURI, scope ];
database.query('INSERT INTO clients (id, appId, clientSecret, redirectURI, scope) VALUES (?, ?, ?, ?, ?)', data, function (error, result) {
if (error && error.code === 'ER_DUP_ENTRY') return callback(new DatabaseError(DatabaseError.ALREADY_EXISTS));
if (error || result.affectedRows === 0) return callback(new DatabaseError(DatabaseError.INTERNAL_ERROR, error));
callback(null);
});
}
function update(id, appId, clientSecret, redirectURI, scope, callback) {
assert.strictEqual(typeof id, 'string');
assert.strictEqual(typeof appId, 'string');
assert.strictEqual(typeof clientSecret, 'string');
assert.strictEqual(typeof redirectURI, 'string');
assert.strictEqual(typeof scope, 'string');
assert.strictEqual(typeof callback, 'function');
var data = [ appId, clientSecret, redirectURI, scope, id ];
database.query('UPDATE clients SET appId = ?, clientSecret = ?, redirectURI = ?, scope = ? WHERE id = ?', data, function (error, result) {
if (error) return callback(new DatabaseError(DatabaseError.INTERNAL_ERROR, error));
if (result.affectedRows !== 1) return callback(new DatabaseError(DatabaseError.NOT_FOUND));
callback(null);
});
}
function del(id, callback) {
assert.strictEqual(typeof id, 'string');
assert.strictEqual(typeof callback, 'function');
database.query('DELETE FROM clients WHERE id = ?', [ id ], function (error, result) {
if (error) return callback(new DatabaseError(DatabaseError.INTERNAL_ERROR, error));
if (result.affectedRows !== 1) return callback(new DatabaseError(DatabaseError.NOT_FOUND));
callback(null);
});
}
function delByAppId(appId, callback) {
assert.strictEqual(typeof appId, 'string');
assert.strictEqual(typeof callback, 'function');
database.query('DELETE FROM clients WHERE appId=?', [ appId ], function (error, result) {
if (error) return callback(new DatabaseError(DatabaseError.INTERNAL_ERROR, error));
if (result.affectedRows !== 1) return callback(new DatabaseError(DatabaseError.NOT_FOUND));
return callback(null);
});
}
function clear(callback) {
assert.strictEqual(typeof callback, 'function');
database.query('DELETE FROM clients WHERE appId!="webadmin"', function (error) {
if (error) return callback(new DatabaseError(DatabaseError.INTERNAL_ERROR, error));
return callback(null);
});
}

226
src/clients.js Normal file
View File

@@ -0,0 +1,226 @@
'use strict';
exports = module.exports = {
ClientsError: ClientsError,
add: add,
get: get,
update: update,
del: del,
getAllWithDetailsByUserId: getAllWithDetailsByUserId,
getClientTokensByUserId: getClientTokensByUserId,
delClientTokensByUserId: delClientTokensByUserId
};
var assert = require('assert'),
util = require('util'),
hat = require('hat'),
appdb = require('./appdb.js'),
tokendb = require('./tokendb.js'),
constants = require('./constants.js'),
async = require('async'),
clientdb = require('./clientdb.js'),
DatabaseError = require('./databaseerror.js'),
uuid = require('node-uuid');
function ClientsError(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(ClientsError, Error);
ClientsError.INVALID_SCOPE = 'Invalid scope';
function validateScope(scope) {
assert.strictEqual(typeof scope, 'string');
if (scope === '') return new ClientsError(ClientsError.INVALID_SCOPE);
if (scope === '*') return null;
// TODO maybe validate all individual scopes if they exist
return null;
}
function add(appIdentifier, redirectURI, scope, callback) {
assert.strictEqual(typeof appIdentifier, 'string');
assert.strictEqual(typeof redirectURI, 'string');
assert.strictEqual(typeof scope, 'string');
assert.strictEqual(typeof callback, 'function');
var error = validateScope(scope);
if (error) return callback(error);
var id = 'cid-' + uuid.v4();
var clientSecret = hat(256);
clientdb.add(id, appIdentifier, clientSecret, redirectURI, scope, function (error) {
if (error) return callback(error);
var client = {
id: id,
appId: appIdentifier,
clientSecret: clientSecret,
redirectURI: redirectURI,
scope: scope
};
callback(null, client);
});
}
function get(id, callback) {
assert.strictEqual(typeof id, 'string');
assert.strictEqual(typeof callback, 'function');
clientdb.get(id, function (error, result) {
if (error) return callback(error);
callback(null, result);
});
}
// we only allow appIdentifier and redirectURI to be updated
function update(id, appIdentifier, redirectURI, callback) {
assert.strictEqual(typeof id, 'string');
assert.strictEqual(typeof appIdentifier, 'string');
assert.strictEqual(typeof redirectURI, 'string');
assert.strictEqual(typeof callback, 'function');
clientdb.get(id, function (error, result) {
if (error) return callback(error);
clientdb.update(id, appIdentifier, result.clientSecret, redirectURI, result.scope, function (error, result) {
if (error) return callback(error);
callback(null, result);
});
});
}
function del(id, callback) {
assert.strictEqual(typeof id, 'string');
assert.strictEqual(typeof callback, 'function');
clientdb.del(id, function (error, result) {
if (error) return callback(error);
callback(null, result);
});
}
function getAllWithDetailsByUserId(userId, callback) {
assert.strictEqual(typeof userId, 'string');
assert.strictEqual(typeof callback, 'function');
clientdb.getAllWithTokenCountByIdentifier(tokendb.PREFIX_USER + userId, function (error, results) {
if (error && error.reason === DatabaseError.NOT_FOUND) return callback(null, []);
if (error) return callback(error);
// We have several types of records here
// 1) webadmin has an app id of 'webadmin'
// 2) oauth proxy records are always the app id prefixed with 'proxy-'
// 3) addon oauth records for apps prefixed with 'addon-'
// 4) external app records prefixed with 'external-'
// 5) normal apps on the cloudron without a prefix
var tmp = [];
async.each(results, function (record, callback) {
if (record.appId === constants.ADMIN_CLIENT_ID) {
record.name = constants.ADMIN_NAME;
record.location = constants.ADMIN_LOCATION;
record.type = 'webadmin';
tmp.push(record);
return callback(null);
} else if (record.appId === constants.TEST_CLIENT_ID) {
record.name = constants.TEST_NAME;
record.location = constants.TEST_LOCATION;
record.type = 'test';
tmp.push(record);
return callback(null);
}
var appId = record.appId;
var type = 'app';
// Handle our different types of oauth clients
if (record.appId.indexOf('addon-') === 0) {
appId = record.appId.slice('addon-'.length);
type = 'addon';
} else if (record.appId.indexOf('proxy-') === 0) {
appId = record.appId.slice('proxy-'.length);
type = 'proxy';
}
appdb.get(appId, function (error, result) {
if (error) {
console.error('Failed to get app details for oauth client', result, error);
return callback(null); // ignore error so we continue listing clients
}
record.name = result.manifest.title + (record.appId.indexOf('proxy-') === 0 ? 'OAuth Proxy' : '');
record.location = result.location;
record.type = type;
tmp.push(record);
callback(null);
});
}, function (error) {
if (error) return callback(error);
callback(null, tmp);
});
});
}
function getClientTokensByUserId(clientId, userId, callback) {
assert.strictEqual(typeof clientId, 'string');
assert.strictEqual(typeof userId, 'string');
assert.strictEqual(typeof callback, 'function');
tokendb.getByIdentifierAndClientId(tokendb.PREFIX_USER + userId, clientId, function (error, result) {
if (error && error.reason === DatabaseError.NOT_FOUND) {
// this can mean either that there are no tokens or the clientId is actually unknown
clientdb.get(clientId, function (error/*, result*/) {
if (error) return callback(error);
callback(null, []);
});
return;
}
if (error) return callback(error);
callback(null, result || []);
});
}
function delClientTokensByUserId(clientId, userId, callback) {
assert.strictEqual(typeof clientId, 'string');
assert.strictEqual(typeof userId, 'string');
assert.strictEqual(typeof callback, 'function');
tokendb.delByIdentifierAndClientId(tokendb.PREFIX_USER + userId, clientId, function (error) {
if (error && error.reason === DatabaseError.NOT_FOUND) {
// this can mean either that there are no tokens or the clientId is actually unknown
clientdb.get(clientId, function (error/*, result*/) {
if (error) return callback(error);
callback(null);
});
return;
}
if (error) return callback(error);
callback(null);
});
}

615
src/cloudron.js Normal file
View File

@@ -0,0 +1,615 @@
/* jslint node: true */
'use strict';
exports = module.exports = {
CloudronError: CloudronError,
initialize: initialize,
uninitialize: uninitialize,
activate: activate,
getConfig: getConfig,
getStatus: getStatus,
setCertificate: setCertificate,
sendHeartbeat: sendHeartbeat,
update: update,
reboot: reboot,
migrate: migrate,
backup: backup,
ensureBackup: ensureBackup
};
var apps = require('./apps.js'),
AppsError = require('./apps.js').AppsError,
assert = require('assert'),
async = require('async'),
backups = require('./backups.js'),
BackupsError = require('./backups.js').BackupsError,
clientdb = require('./clientdb.js'),
config = require('./config.js'),
debug = require('debug')('box:cloudron'),
fs = require('fs'),
locker = require('./locker.js'),
path = require('path'),
paths = require('./paths.js'),
progress = require('./progress.js'),
safe = require('safetydance'),
settings = require('./settings.js'),
SettingsError = settings.SettingsError,
shell = require('./shell.js'),
superagent = require('superagent'),
sysinfo = require('./sysinfo.js'),
tokendb = require('./tokendb.js'),
updateChecker = require('./updatechecker.js'),
user = require('./user.js'),
UserError = user.UserError,
userdb = require('./userdb.js'),
util = require('util');
var RELOAD_NGINX_CMD = path.join(__dirname, 'scripts/reloadnginx.sh'),
REBOOT_CMD = path.join(__dirname, 'scripts/reboot.sh'),
BACKUP_BOX_CMD = path.join(__dirname, 'scripts/backupbox.sh'),
BACKUP_SWAP_CMD = path.join(__dirname, 'scripts/backupswap.sh'),
INSTALLER_UPDATE_URL = 'http://127.0.0.1:2020/api/v1/installer/update';
var gAddMailDnsRecordsTimerId = null,
gCloudronDetails = null; // cached cloudron details like region,size...
function debugApp(app, args) {
assert(!app || typeof app === 'object');
var prefix = app ? app.location : '(no app)';
debug(prefix + ' ' + util.format.apply(util, Array.prototype.slice.call(arguments, 1)));
}
function ignoreError(func) {
return function (callback) {
func(function (error) {
if (error) console.error('Ignored error:', error);
callback();
});
};
}
function CloudronError(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(CloudronError, Error);
CloudronError.BAD_FIELD = 'Field error';
CloudronError.INTERNAL_ERROR = 'Internal Error';
CloudronError.EXTERNAL_ERROR = 'External Error';
CloudronError.ALREADY_PROVISIONED = 'Already Provisioned';
CloudronError.BAD_USERNAME = 'Bad username';
CloudronError.BAD_EMAIL = 'Bad email';
CloudronError.BAD_PASSWORD = 'Bad password';
CloudronError.BAD_NAME = 'Bad name';
CloudronError.BAD_STATE = 'Bad state';
CloudronError.NOT_FOUND = 'Not found';
function initialize(callback) {
assert.strictEqual(typeof callback, 'function');
if (process.env.NODE_ENV !== 'test') {
addMailDnsRecords();
}
// Send heartbeat once we are up and running, this speeds up the Cloudron creation, as otherwise we are bound to the cron.js settings
sendHeartbeat();
callback(null);
}
function uninitialize(callback) {
assert.strictEqual(typeof callback, 'function');
clearTimeout(gAddMailDnsRecordsTimerId);
gAddMailDnsRecordsTimerId = null;
callback(null);
}
function setTimeZone(ip, callback) {
assert.strictEqual(typeof ip, 'string');
assert.strictEqual(typeof callback, 'function');
debug('setTimeZone ip:%s', ip);
superagent.get('http://www.telize.com/geoip/' + ip).end(function (error, result) {
if (error || result.statusCode !== 200) {
debug('Failed to get geo location', error);
return callback(null);
}
if (!result.body.timezone) {
debug('No timezone in geoip response');
return callback(null);
}
debug('Setting timezone to ', result.body.timezone);
settings.setTimeZone(result.body.timezone, callback);
});
}
function activate(username, password, email, name, ip, callback) {
assert.strictEqual(typeof username, 'string');
assert.strictEqual(typeof password, 'string');
assert.strictEqual(typeof email, 'string');
assert.strictEqual(typeof ip, 'string');
assert(!name || typeof name, 'string');
assert.strictEqual(typeof callback, 'function');
debug('activating user:%s email:%s', username, email);
setTimeZone(ip, function () { });
if (!name) name = settings.getDefaultSync(settings.CLOUDRON_NAME_KEY);
settings.setCloudronName(name, function (error) {
if (error && error.reason === SettingsError.BAD_FIELD) return callback(new CloudronError(CloudronError.BAD_NAME));
if (error) return callback(new CloudronError(CloudronError.INTERNAL_ERROR, error));
user.createOwner(username, password, email, function (error, userObject) {
if (error && error.reason === UserError.ALREADY_EXISTS) return callback(new CloudronError(CloudronError.ALREADY_PROVISIONED));
if (error && error.reason === UserError.BAD_USERNAME) return callback(new CloudronError(CloudronError.BAD_USERNAME));
if (error && error.reason === UserError.BAD_PASSWORD) return callback(new CloudronError(CloudronError.BAD_PASSWORD));
if (error && error.reason === UserError.BAD_EMAIL) return callback(new CloudronError(CloudronError.BAD_EMAIL));
if (error) return callback(new CloudronError(CloudronError.INTERNAL_ERROR, error));
clientdb.getByAppId('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() + 24 * 60 * 60 * 1000; // 1 day
tokendb.add(token, tokendb.PREFIX_USER + userObject.id, result.id, expires, '*', function (error) {
if (error) return callback(new CloudronError(CloudronError.INTERNAL_ERROR, error));
callback(null, { token: token, expires: expires });
});
});
});
});
}
function getStatus(callback) {
assert.strictEqual(typeof callback, 'function');
userdb.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(),
cloudronName: cloudronName
});
});
});
}
function getCloudronDetails(callback) {
assert.strictEqual(typeof callback, 'function');
if (gCloudronDetails) return callback(null, gCloudronDetails);
superagent
.get(config.apiServerOrigin() + '/api/v1/boxes/' + config.fqdn())
.query({ token: config.token() })
.end(function (error, result) {
if (error) return callback(error);
if (result.status !== 200) return callback(new CloudronError(CloudronError.EXTERNAL_ERROR, util.format('%s %j', result.status, result.body)));
gCloudronDetails = result.body.box;
return callback(null, gCloudronDetails);
});
}
function getConfig(callback) {
assert.strictEqual(typeof callback, 'function');
getCloudronDetails(function (error, result) {
if (error) {
console.error('Failed to fetch cloudron details.', error);
// set fallback values to avoid dependency on appstore
result = {
region: result ? result.region : null,
size: result ? result.size : null
};
}
settings.getCloudronName(function (error, cloudronName) {
if (error) return callback(new CloudronError(CloudronError.INTERNAL_ERROR, error));
callback(null, {
apiServerOrigin: config.apiServerOrigin(),
webServerOrigin: config.webServerOrigin(),
isDev: config.isDev(),
fqdn: config.fqdn(),
ip: sysinfo.getIp(),
version: config.version(),
update: updateChecker.getUpdateInfo(),
progress: progress.get(),
isCustomDomain: config.isCustomDomain(),
developerMode: config.developerMode(),
region: result.region,
size: result.size,
cloudronName: cloudronName
});
});
});
}
function sendHeartbeat() {
var url = config.apiServerOrigin() + '/api/v1/boxes/' + config.fqdn() + '/heartbeat';
debug('Sending heartbeat ' + url);
// TODO: this must be a POST
superagent.get(url).query({ token: config.token(), version: config.version() }).timeout(10000).end(function (error, result) {
if (error) debug('Error sending heartbeat.', error);
else if (result.statusCode !== 200) debug('Server responded to heartbeat with %s %s', result.statusCode, result.text);
else debug('Heartbeat successful');
});
}
function sendMailDnsRecordsRequest(callback) {
assert.strictEqual(typeof callback, 'function');
var DKIM_SELECTOR = 'mail';
var DMARC_REPORT_EMAIL = 'dmarc-report@cloudron.io';
var dkimPublicKeyFile = path.join(paths.MAIL_DATA_DIR, 'dkim/' + config.fqdn() + '/public');
var publicKey = safe.fs.readFileSync(dkimPublicKeyFile, 'utf8');
if (publicKey === null) return callback(new Error('Error reading dkim public key'));
// remove header, footer and new lines
publicKey = publicKey.split('\n').slice(1, -2).join('');
// note that dmarc requires special DNS records for external RUF and RUA
var records = [
// softfail all mails not from our IP. Note that this uses IP instead of 'a' should we use a load balancer in the future
{ subdomain: '', type: 'TXT', value: '"v=spf1 ip4:' + sysinfo.getIp() + ' ~all"' },
// t=s limits the domainkey to this domain and not it's subdomains
{ subdomain: DKIM_SELECTOR + '._domainkey', type: 'TXT', value: '"v=DKIM1; t=s; p=' + publicKey + '"' },
// DMARC requires special setup if report email id is in different domain
{ subdomain: '_dmarc', type: 'TXT', value: '"v=DMARC1; p=none; pct=100; rua=mailto:' + DMARC_REPORT_EMAIL + '; ruf=' + DMARC_REPORT_EMAIL + '"' }
];
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);
});
}
function addMailDnsRecords() {
if (config.get('mailDnsRecordIds').length !== 0) return; // already registered
sendMailDnsRecordsRequest(function (error, ids) {
if (error) {
console.error('Mail DNS record addition failed', error);
gAddMailDnsRecordsTimerId = setTimeout(addMailDnsRecords, 30000);
return;
}
debug('Added Mail DNS records successfully');
config.set('mailDnsRecordIds', ids);
});
}
function setCertificate(certificate, key, callback) {
assert.strictEqual(typeof certificate, 'string');
assert.strictEqual(typeof key, 'string');
assert.strictEqual(typeof callback, 'function');
debug('Updating certificates');
if (!safe.fs.writeFileSync(path.join(paths.NGINX_CERT_DIR, 'host.cert'), certificate)) {
return callback(new CloudronError(CloudronError.INTERNAL_ERROR, safe.error.message));
}
if (!safe.fs.writeFileSync(path.join(paths.NGINX_CERT_DIR, 'host.key'), key)) {
return callback(new CloudronError(CloudronError.INTERNAL_ERROR, safe.error.message));
}
shell.sudo('setCertificate', [ RELOAD_NGINX_CMD ], function (error) {
if (error) return callback(new CloudronError(CloudronError.INTERNAL_ERROR, error));
return callback(null);
});
}
function reboot(callback) {
shell.sudo('reboot', [ REBOOT_CMD ], callback);
}
function migrate(size, region, callback) {
assert.strictEqual(typeof size, 'string');
assert.strictEqual(typeof region, 'string');
assert.strictEqual(typeof callback, 'function');
var error = locker.lock(locker.OP_MIGRATE);
if (error) return callback(new CloudronError(CloudronError.BAD_STATE, error.message));
function unlock(error) {
if (error) {
debug('Failed to migrate', error);
locker.unlock(locker.OP_MIGRATE);
} else {
debug('Migration initiated successfully');
// do not unlock; cloudron is migrating
}
return;
}
// initiate the migration in the background
backupBoxAndApps(function (error, restoreKey) {
if (error) return unlock(error);
debug('migrate: size %s region %s restoreKey %s', size, region, restoreKey);
superagent
.post(config.apiServerOrigin() + '/api/v1/boxes/' + config.fqdn() + '/migrate')
.query({ token: config.token() })
.send({ size: size, region: region, restoreKey: restoreKey })
.end(function (error, result) {
if (error) return unlock(error);
if (result.status === 409) return unlock(new CloudronError(CloudronError.BAD_STATE));
if (result.status === 404) return unlock(new CloudronError(CloudronError.NOT_FOUND));
if (result.status !== 202) return unlock(new CloudronError(CloudronError.EXTERNAL_ERROR, util.format('%s %j', result.status, result.body)));
return unlock(null);
});
});
callback(null);
}
function update(boxUpdateInfo, callback) {
assert.strictEqual(typeof boxUpdateInfo, 'object');
assert.strictEqual(typeof callback, 'function');
if (!boxUpdateInfo) return callback(null);
var error = locker.lock(locker.OP_BOX_UPDATE);
if (error) return callback(new CloudronError(CloudronError.BAD_STATE, error.message));
progress.set(progress.UPDATE, 0, 'Begin ' + (boxUpdateInfo.update ? 'upgrade': 'update'));
// initiate the update/upgrade but do not wait for it
if (boxUpdateInfo.upgrade) {
doUpgrade(boxUpdateInfo, function (error) {
locker.unlock(locker.OP_BOX_UPDATE);
});
} else {
doUpdate(boxUpdateInfo, function (error) {
locker.unlock(locker.OP_BOX_UPDATE);
});
}
callback(null);
}
function doUpgrade(boxUpdateInfo, callback) {
assert(boxUpdateInfo !== null && typeof boxUpdateInfo === 'object');
progress.set(progress.UPDATE, 5, 'Create app and box backup');
backupBoxAndApps(function (error) {
if (error) return callback(error);
superagent.post(config.apiServerOrigin() + '/api/v1/boxes/' + config.fqdn() + '/upgrade')
.query({ token: config.token() })
.send({ version: boxUpdateInfo.version })
.end(function (error, result) {
if (error) return callback(new Error('Error making upgrade request: ' + error));
if (result.status !== 202) return callback(new Error('Server not ready to upgrade: ' + result.body));
progress.set(progress.UPDATE, 10, 'Updating base system');
// no need to unlock since this is the last thing we ever do on this box
callback(null);
});
});
}
function doUpdate(boxUpdateInfo, callback) {
assert(boxUpdateInfo && typeof boxUpdateInfo === 'object');
progress.set(progress.UPDATE, 5, 'Create box backup');
backupBox(function (error) {
if (error) return callback(error);
// fetch a signed sourceTarballUrl
superagent.get(config.apiServerOrigin() + '/api/v1/boxes/' + config.fqdn() + '/sourcetarballurl')
.query({ token: config.token(), boxVersion: boxUpdateInfo.version })
.end(function (error, result) {
if (error) return callback(new Error('Error fetching sourceTarballUrl: ' + error));
if (result.status !== 200) return callback(new Error('Error fetching sourceTarballUrl status: ' + result.status));
if (!safe.query(result, 'body.url')) return callback(new Error('Error fetching sourceTarballUrl response: ' + result.body));
// NOTE: the args here are tied to the installer revision, box code and appstore provisioning logic
var args = {
sourceTarballUrl: result.body.url,
// this data is opaque to the installer
data: {
boxVersionsUrl: config.get('boxVersionsUrl'),
version: boxUpdateInfo.version,
apiServerOrigin: config.apiServerOrigin(),
webServerOrigin: config.webServerOrigin(),
fqdn: config.fqdn(),
token: config.token(),
tlsCert: fs.readFileSync(path.join(paths.NGINX_CERT_DIR, 'host.cert'), 'utf8'),
tlsKey: fs.readFileSync(path.join(paths.NGINX_CERT_DIR, 'host.key'), 'utf8'),
isCustomDomain: config.isCustomDomain(),
restoreUrl: null,
restoreKey: null,
developerMode: config.developerMode() // this survives updates but not upgrades
}
};
debug('updating box %j', args);
superagent.post(INSTALLER_UPDATE_URL).send(args).end(function (error, result) {
if (error) return callback(error);
if (result.status !== 202) return callback(new Error('Error initiating update: ' + result.body));
progress.set(progress.UPDATE, 10, 'Updating cloudron software');
callback(null);
});
});
// Do not add any code here. The installer script will stop the box code any instant
});
}
function backup(callback) {
assert.strictEqual(typeof callback, 'function');
var error = locker.lock(locker.OP_FULL_BACKUP);
if (error) return callback(new CloudronError(CloudronError.BAD_STATE, error.message));
// start the backup operation in the background
backupBoxAndApps(function (error) {
if (error) console.error('backup failed.', error);
locker.unlock(locker.OP_FULL_BACKUP);
});
callback(null);
}
function ensureBackup(callback) {
callback = callback || function () { };
backups.getAllPaged(1, 1, function (error, backups) {
if (error) {
debug('Unable to list backups', error);
return callback(error); // no point trying to backup if appstore is down
}
if (backups.length !== 0 && (new Date() - new Date(backups[0].creationTime) < 23 * 60 * 60 * 1000)) { // ~1 day ago
debug('Previous backup was %j, no need to backup now', backups[0]);
return callback(null);
}
backup(callback);
});
}
function backupBoxWithAppBackupIds(appBackupIds, callback) {
assert(util.isArray(appBackupIds));
backups.getBackupUrl(null /* app */, appBackupIds, function (error, result) {
if (error && error.reason === BackupsError.EXTERNAL_ERROR) return callback(new CloudronError(CloudronError.EXTERNAL_ERROR, error.message));
if (error) return callback(new CloudronError.INTERNAL_ERROR, error);
debug('backup: url %s', result.url);
async.series([
ignoreError(shell.sudo.bind(null, 'mountSwap', [ BACKUP_SWAP_CMD, '--on' ])),
shell.sudo.bind(null, 'backupBox', [ BACKUP_BOX_CMD, result.url, result.backupKey ]),
ignoreError(shell.sudo.bind(null, 'unmountSwap', [ BACKUP_SWAP_CMD, '--off' ])),
], function (error) {
if (error) return callback(new CloudronError(CloudronError.INTERNAL_ERROR, error));
debug('backup: successful');
callback(null, result.id);
});
});
}
// this function expects you to have a lock
function backupBox(callback) {
apps.getAll(function (error, allApps) {
if (error) return callback(new CloudronError(CloudronError.INTERNAL_ERROR, error));
var appBackupIds = allApps.map(function (app) { return app.lastBackupId; });
appBackupIds = appBackupIds.filter(function (id) { return id !== null }); // remove apps that were never backed up
backupBoxWithAppBackupIds(appBackupIds, callback);
});
}
// this function expects you to have a lock
function backupBoxAndApps(callback) {
callback = callback || function () { }; // callback can be empty for timer triggered backup
apps.getAll(function (error, allApps) {
if (error) return callback(new CloudronError(CloudronError.INTERNAL_ERROR, error));
var processed = 0;
var step = 100/(allApps.length+1);
progress.set(progress.BACKUP, processed, '');
async.mapSeries(allApps, function iterator(app, iteratorCallback) {
++processed;
apps.backupApp(app, function (error, backupId) {
progress.set(progress.BACKUP, step * processed, app.location);
if (error && error.reason === AppsError.BAD_STATE) {
debugApp(app, 'Skipping backup (istate:%s health%s). Reusing %s', app.installationState, app.health, app.lastBackupId);
backupId = app.lastBackupId;
}
return iteratorCallback(null, backupId);
});
}, function appsBackedUp(error, backupIds) {
if (error) return callback(error);
backupIds = backupIds.filter(function (id) { return id !== null; }); // remove apps that were never backed up
backupBoxWithAppBackupIds(backupIds, function (error, restoreKey) {
progress.set(progress.BACKUP, 100, '');
callback(error, restoreKey);
});
});
});
}

32
src/collectd.config.ejs Normal file
View File

@@ -0,0 +1,32 @@
LoadPlugin "table"
<Plugin table>
<Table "/sys/fs/cgroup/memory/docker/<%= containerId %>/memory.stat">
Instance "<%= appId %>-memory"
Separator " \\n"
<Result>
Type gauge
InstancesFrom 0
ValuesFrom 1
</Result>
</Table>
<Table "/sys/fs/cgroup/memory/docker/<%= containerId %>/memory.max_usage_in_bytes">
Instance "<%= appId %>-memory"
Separator "\\n"
<Result>
Type gauge
InstancePrefix "max_usage_in_bytes"
ValuesFrom 0
</Result>
</Table>
<Table "/sys/fs/cgroup/cpuacct/docker/<%= containerId %>/cpuacct.stat">
Instance "<%= appId %>-cpu"
Separator " \\n"
<Result>
Type gauge
InstancesFrom 0
ValuesFrom 1
</Result>
</Table>
</Plugin>

181
src/config.js Normal file
View File

@@ -0,0 +1,181 @@
/* jslint node: true */
'use strict';
exports = module.exports = {
baseDir: baseDir,
// values set here will be lost after a upgrade/update. use the sqlite database
// for persistent values that need to be backed up
get: get,
set: set,
// ifdefs to check environment
CLOUDRON: process.env.NODE_ENV === 'cloudron',
TEST: process.env.NODE_ENV === 'test',
// convenience getters
apiServerOrigin: apiServerOrigin,
webServerOrigin: webServerOrigin,
fqdn: fqdn,
token: token,
version: version,
isCustomDomain: isCustomDomain,
database: database,
developerMode: developerMode,
// these values are derived
adminOrigin: adminOrigin,
appFqdn: appFqdn,
zoneName: zoneName,
isDev: isDev,
// for testing resets to defaults
_reset: initConfig
};
var assert = require('assert'),
constants = require('./constants.js'),
fs = require('fs'),
path = require('path'),
safe = require('safetydance'),
_ = require('underscore');
var homeDir = process.env.HOME || process.env.HOMEPATH || process.env.USERPROFILE;
var data = { };
function baseDir() {
if (exports.CLOUDRON) return homeDir;
if (exports.TEST) return path.join(homeDir, '.cloudron_test');
}
var cloudronConfigFileName = path.join(baseDir(), 'configs/cloudron.conf');
function saveSync() {
fs.writeFileSync(cloudronConfigFileName, JSON.stringify(data, null, 4)); // functions are ignored by JSON.stringify
}
function initConfig() {
// setup defaults
data.fqdn = 'localhost';
data.token = null;
data.mailServer = null;
data.adminEmail = null;
data.mailDnsRecordIds = [ ];
data.boxVersionsUrl = null;
data.version = null;
data.isCustomDomain = false;
data.webServerOrigin = null;
data.internalPort = 3001;
data.ldapPort = 3002;
if (exports.CLOUDRON) {
data.port = 3000;
data.apiServerOrigin = null;
data.database = null;
data.developerMode = false;
} else if (exports.TEST) {
data.port = 5454;
data.apiServerOrigin = 'http://localhost:6060'; // hock doesn't support https
data.database = {
hostname: 'localhost',
username: 'root',
password: '',
port: 3306,
name: 'boxtest'
};
data.token = 'APPSTORE_TOKEN';
data.developerMode = false;
} else {
assert(false, 'Unknown environment. This should not happen!');
}
if (safe.fs.existsSync(cloudronConfigFileName)) {
var existingData = safe.JSON.parse(safe.fs.readFileSync(cloudronConfigFileName, 'utf8'));
_.extend(data, existingData); // overwrite defaults with saved config
return;
}
saveSync();
}
initConfig();
// set(obj) or set(key, value)
function set(key, value) {
if (typeof key === 'object') {
var obj = key;
for (var k in obj) {
assert(k in data, 'config.js is missing key "' + k + '"');
data[k] = obj[k];
}
} else {
data = safe.set(data, key, value);
}
saveSync();
}
function get(key) {
assert.strictEqual(typeof key, 'string');
return safe.query(data, key);
}
function apiServerOrigin() {
return get('apiServerOrigin');
}
function webServerOrigin() {
return get('webServerOrigin');
}
function fqdn() {
return get('fqdn');
}
// keep this in sync with start.sh admin.conf generation code
function appFqdn(location) {
assert.strictEqual(typeof location, 'string');
if (location === '') return fqdn();
return isCustomDomain() ? location + '.' + fqdn() : location + '-' + fqdn();
}
function adminOrigin() {
return 'https://' + appFqdn(constants.ADMIN_LOCATION);
}
function token() {
return get('token');
}
function version() {
return get('version');
}
function isCustomDomain() {
return get('isCustomDomain');
}
function zoneName() {
if (isCustomDomain()) return fqdn(); // the appstore sets up the custom domain as a zone
// for shared domain name, strip out the hostname
return fqdn().substr(fqdn().indexOf('.') + 1);
}
function database() {
return get('database');
}
function developerMode() {
return get('developerMode');
}
function isDev() {
return /dev/i.test(get('boxVersionsUrl'));
}

16
src/constants.js Normal file
View File

@@ -0,0 +1,16 @@
'use strict';
// default admin installation location. keep in sync with ADMIN_LOCATION in setup/start.sh and BOX_ADMIN_LOCATION in appstore constants.js
exports = module.exports = {
ADMIN_LOCATION: 'my',
API_LOCATION: 'api', // this is unused but reserved for future use (#403)
ADMIN_NAME: 'Settings',
ADMIN_CLIENT_ID: 'webadmin', // oauth client id
ADMIN_APPID: 'admin', // admin appid (settingsdb)
TEST_NAME: 'Test',
TEST_LOCATION: '',
TEST_CLIENT_ID: 'test'
};

138
src/cron.js Normal file
View File

@@ -0,0 +1,138 @@
'use strict';
exports = module.exports = {
initialize: initialize,
uninitialize: uninitialize
};
var apps = require('./apps.js'),
assert = require('assert'),
cloudron = require('./cloudron.js'),
CronJob = require('cron').CronJob,
debug = require('debug')('box:cron'),
settings = require('./settings.js'),
updateChecker = require('./updatechecker.js');
var gAutoupdaterJob = null,
gBoxUpdateCheckerJob = null,
gAppUpdateCheckerJob = null,
gHeartbeatJob = null,
gBackupJob = null;
var gInitialized = false;
var NOOP_CALLBACK = function (error) { console.error(error); };
// cron format
// Seconds: 0-59
// Minutes: 0-59
// Hours: 0-23
// Day of Month: 1-31
// Months: 0-11
// Day of Week: 0-6
function initialize(callback) {
assert.strictEqual(typeof callback, 'function');
if (gInitialized) return callback();
settings.events.on(settings.TIME_ZONE_KEY, recreateJobs);
settings.events.on(settings.AUTOUPDATE_PATTERN_KEY, autoupdatePatternChanged);
gInitialized = true;
recreateJobs(callback);
}
function recreateJobs(unusedTimeZone, callback) {
if (typeof unusedTimeZone === 'function') callback = unusedTimeZone;
settings.getAll(function (error, allSettings) {
if (gHeartbeatJob) gHeartbeatJob.stop();
gHeartbeatJob = new CronJob({
cronTime: '00 */1 * * * *', // every minute
onTick: cloudron.sendHeartbeat,
start: true,
timeZone: allSettings[settings.TIME_ZONE_KEY]
});
if (gBackupJob) gBackupJob.stop();
gBackupJob = new CronJob({
cronTime: '00 00 */4 * * *', // every 4 hours
onTick: cloudron.ensureBackup,
start: true,
timeZone: allSettings[settings.TIME_ZONE_KEY]
});
if (gBoxUpdateCheckerJob) gBoxUpdateCheckerJob.stop();
gBoxUpdateCheckerJob = new CronJob({
cronTime: '00 */10 * * * *', // every 10 minutes
onTick: updateChecker.checkBoxUpdates,
start: true,
timeZone: allSettings[settings.TIME_ZONE_KEY]
});
if (gAppUpdateCheckerJob) gAppUpdateCheckerJob.stop();
gAppUpdateCheckerJob = new CronJob({
cronTime: '00 */10 * * * *', // every 10 minutes
onTick: updateChecker.checkAppUpdates,
start: true,
timeZone: allSettings[settings.TIME_ZONE_KEY]
});
autoupdatePatternChanged(allSettings[settings.AUTOUPDATE_PATTERN_KEY]);
if (callback) callback();
});
}
function autoupdatePatternChanged(pattern) {
assert.strictEqual(typeof pattern, 'string');
debug('Auto update pattern changed to %s', pattern);
if (gAutoupdaterJob) gAutoupdaterJob.stop();
if (pattern === 'never') return;
gAutoupdaterJob = new CronJob({
cronTime: pattern,
onTick: function() {
debug('Starting autoupdate');
var updateInfo = updateChecker.getUpdateInfo();
if (updateInfo.box) {
cloudron.update(updateInfo.box, NOOP_CALLBACK);
} else if (updateInfo.apps) {
apps.autoupdateApps(updateInfo.apps, NOOP_CALLBACK);
}
},
start: true,
timeZone: gBoxUpdateCheckerJob.cronTime.timeZone // hack
});
}
function uninitialize(callback) {
assert.strictEqual(typeof callback, 'function');
if (!gInitialized) return callback();
if (gAutoupdaterJob) gAutoupdaterJob.stop();
gAutoupdaterJob = null;
gBoxUpdateCheckerJob.stop();
gBoxUpdateCheckerJob = null;
gAppUpdateCheckerJob.stop();
gAppUpdateCheckerJob = null;
gHeartbeatJob.stop();
gHeartbeatJob = null;
gBackupJob.stop();
gBackupJob = null;
gInitialized = false;
callback();
}

202
src/database.js Normal file
View File

@@ -0,0 +1,202 @@
/* jslint node: true */
'use strict';
exports = module.exports = {
initialize: initialize,
uninitialize: uninitialize,
query: query,
transaction: transaction,
beginTransaction: beginTransaction,
rollback: rollback,
commit: commit,
_clear: clear
};
var assert = require('assert'),
async = require('async'),
once = require('once'),
config = require('./config.js'),
mysql = require('mysql'),
util = require('util');
var gConnectionPool = null,
gDefaultConnection = null;
function initialize(options, callback) {
if (typeof options === 'function') {
callback = options;
options = {
connectionLimit: 5
};
}
assert.strictEqual(typeof options.connectionLimit, 'number');
assert.strictEqual(typeof callback, 'function');
if (gConnectionPool !== null) return callback(null);
gConnectionPool = mysql.createPool({
connectionLimit: options.connectionLimit,
host: config.database().hostname,
user: config.database().username,
password: config.database().password,
port: config.database().port,
database: config.database().name,
multipleStatements: false,
ssl: false
});
reconnect(callback);
}
function uninitialize(callback) {
if (gConnectionPool) {
gConnectionPool.end(callback);
gConnectionPool = null;
} else {
callback(null);
}
}
function setupConnection(connection, callback) {
assert.strictEqual(typeof connection, 'object');
assert.strictEqual(typeof callback, 'function');
connection.on('error', console.error);
async.series([
connection.query.bind(connection, 'USE ' + config.database().name),
connection.query.bind(connection, 'SET SESSION sql_mode = \'strict_all_tables\'')
], function (error) {
connection.removeListener('error', console.error);
if (error) connection.release();
callback(error);
});
}
function reconnect(callback) {
callback = callback ? once(callback) : function () {};
gConnectionPool.getConnection(function (error, connection) {
if (error) {
console.error('Unable to reestablish connection to database. Try again in a bit.', error.message);
return setTimeout(reconnect.bind(null, callback), 1000);
}
connection.on('error', function (error) {
// by design, we catch all normal errors by providing callbacks.
// this function should be invoked only when we have no callbacks pending and we have a fatal error
assert(error.fatal, 'Non-fatal error on connection object');
console.error('Unhandled mysql connection error.', error);
// This is most likely an issue an can cause double callbacks from reconnect()
setTimeout(reconnect.bind(null, callback), 1000);
});
setupConnection(connection, function (error) {
if (error) return setTimeout(reconnect.bind(null, callback), 1000);
gDefaultConnection = connection;
callback(null);
});
});
}
function clear(callback) {
assert.strictEqual(typeof callback, 'function');
// the clear funcs don't completely clear the db, they leave the migration code defaults
async.series([
require('./appdb.js')._clear,
require('./authcodedb.js')._clear,
require('./clientdb.js')._clear,
require('./tokendb.js')._clear,
require('./userdb.js')._clear,
require('./settingsdb.js')._clear
], callback);
}
function beginTransaction(callback) {
assert.strictEqual(typeof callback, 'function');
gConnectionPool.getConnection(function (error, connection) {
if (error) return callback(error);
setupConnection(connection, function (error) {
if (error) return callback(error);
connection.beginTransaction(function (error) {
if (error) return callback(error);
return callback(null, connection);
});
});
});
}
function rollback(connection, callback) {
assert.strictEqual(typeof callback, 'function');
connection.rollback(function (error) {
if (error) console.error(error); // can this happen?
connection.release();
callback(null);
});
}
// FIXME: if commit fails, is it supposed to return an error ?
function commit(connection, callback) {
assert.strictEqual(typeof callback, 'function');
connection.commit(function (error) {
if (error) return rollback(connection, callback);
connection.release();
return callback(null);
});
}
function query() {
var args = Array.prototype.slice.call(arguments);
var callback = args[args.length - 1];
assert.strictEqual(typeof callback, 'function');
if (gDefaultConnection === null) return callback(new Error('No connection to database'));
args[args.length -1 ] = function (error, result) {
if (error && error.fatal) {
gDefaultConnection = null;
setTimeout(reconnect, 1000);
}
callback(error, result);
};
gDefaultConnection.query.apply(gDefaultConnection, args);
}
function transaction(queries, callback) {
assert(util.isArray(queries));
assert.strictEqual(typeof callback, 'function');
beginTransaction(function (error, conn) {
if (error) return callback(error);
async.mapSeries(queries, function iterator(query, done) {
conn.query(query.query, query.args, done);
}, function seriesDone(error, results) {
if (error) return rollback(conn, callback.bind(null, error));
commit(conn, callback.bind(null, null, results));
});
});
}

32
src/databaseerror.js Normal file
View File

@@ -0,0 +1,32 @@
/* jslint node:true */
'use strict';
exports = module.exports = DatabaseError;
var assert = require('assert'),
util = require('util');
function DatabaseError(reason, errorOrMessage) {
assert.strictEqual(typeof reason, 'string');
assert(errorOrMessage instanceof Error || typeof errorOrMessage === 'string' || typeof errorOrMessage === 'undefined' || errorOrMessage === null);
Error.call(this);
Error.captureStackTrace(this, this.constructor);
this.reason = reason;
if (typeof errorOrMessage === 'undefined' || errorOrMessage === null) {
this.message = reason;
} else if (typeof errorOrMessage === 'string') {
this.message = errorOrMessage;
} else {
this.message = 'Internal error';
this.nestedError = errorOrMessage;
}
}
util.inherits(DatabaseError, Error);
DatabaseError.INTERNAL_ERROR = 'Internal error';
DatabaseError.ALREADY_EXISTS = 'Entry already exist';
DatabaseError.NOT_FOUND = 'Record not found';
DatabaseError.BAD_FIELD = 'Invalid field';

66
src/developer.js Normal file
View File

@@ -0,0 +1,66 @@
/* jslint node: true */
'use strict';
exports = module.exports = {
DeveloperError: DeveloperError,
enabled: enabled,
setEnabled: setEnabled,
issueDeveloperToken: issueDeveloperToken
};
var assert = require('assert'),
tokendb = require('./tokendb.js'),
config = require('./config.js'),
util = require('util');
function DeveloperError(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(DeveloperError, Error);
DeveloperError.INTERNAL_ERROR = 'Internal Error';
function enabled(callback) {
assert.strictEqual(typeof callback, 'function');
callback(null, config.developerMode());
}
function setEnabled(enabled, callback) {
assert.strictEqual(typeof enabled, 'boolean');
assert.strictEqual(typeof callback, 'function');
config.set('developerMode', enabled);
callback(null);
}
function issueDeveloperToken(user, callback) {
assert.strictEqual(typeof user, 'object');
assert.strictEqual(typeof callback, 'function');
var token = tokendb.generateToken();
var expiresAt = Date.now() + 24 * 60 * 60 * 1000; // 1 day
tokendb.add(token, tokendb.PREFIX_DEV + user.id, '', expiresAt, 'apps,settings,roleDeveloper', function (error) {
if (error) return callback(new DeveloperError(DeveloperError.INTERNAL_ERROR, error));
callback(null, { token: token, expiresAt: expiresAt });
});
}

46
src/digitalocean.js Normal file
View File

@@ -0,0 +1,46 @@
/* jslint node:true */
'use strict';
exports = module.exports = {
checkPtrRecord: checkPtrRecord
};
var assert = require('assert'),
debug = require('debug')('box:digitalocean'),
dns = require('native-dns');
function checkPtrRecord(ip, fqdn, callback) {
assert(ip === null || typeof ip === 'string');
assert.strictEqual(typeof fqdn, 'string');
assert.strictEqual(typeof callback, 'function');
debug('checkPtrRecord: ' + ip);
if (!ip) return callback(new Error('Network down'));
dns.resolve4('ns1.digitalocean.com', function (error, rdnsIps) {
if (error || rdnsIps.length === 0) return callback(new Error('Failed to query DO DNS'));
var reversedIp = ip.split('.').reverse().join('.');
var req = dns.Request({
question: dns.Question({ name: reversedIp + '.in-addr.arpa', type: 'PTR' }),
server: { address: rdnsIps[0] },
timeout: 5000
});
req.on('timeout', function () { return callback(new Error('Timedout')); });
req.on('message', function (error, message) {
if (error || !message.answer || message.answer.length === 0) return callback(new Error('Failed to query PTR'));
debug('checkPtrRecord: Actual:%s Expecting:%s', message.answer[0].data, fqdn);
callback(null, message.answer[0].data === fqdn);
});
req.send();
});
}

42
src/docker.js Normal file
View File

@@ -0,0 +1,42 @@
'use strict';
var Docker = require('dockerode'),
fs = require('fs'),
os = require('os'),
path = require('path'),
url = require('url');
exports = module.exports = (function () {
var docker;
var options = connectOptions(); // the real docker
if (process.env.NODE_ENV === 'test') {
// test code runs a docker proxy on this port
docker = new Docker({ host: 'http://localhost', port: 5687 });
} else {
docker = new Docker(options);
}
// proxy code uses this to route to the real docker
docker.options = options;
return docker;
})();
function connectOptions() {
if (os.platform() === 'linux') return { socketPath: '/var/run/docker.sock' };
// boot2docker configuration
var DOCKER_CERT_PATH = process.env.DOCKER_CERT_PATH || path.join(process.env.HOME, '.boot2docker/certs/boot2docker-vm');
var DOCKER_HOST = process.env.DOCKER_HOST || 'tcp://192.168.59.103:2376';
return {
protocol: 'https',
host: url.parse(DOCKER_HOST).hostname,
port: url.parse(DOCKER_HOST).port,
ca: fs.readFileSync(path.join(DOCKER_CERT_PATH, 'ca.pem')),
cert: fs.readFileSync(path.join(DOCKER_CERT_PATH, 'cert.pem')),
key: fs.readFileSync(path.join(DOCKER_CERT_PATH, 'key.pem'))
};
}

106
src/ldap.js Normal file
View File

@@ -0,0 +1,106 @@
'use strict';
exports = module.exports = {
start: start
};
var assert = require('assert'),
config = require('./config.js'),
debug = require('debug')('box:ldap'),
user = require('./user.js'),
UserError = user.UserError,
ldap = require('ldapjs');
var gServer = null;
var NOOP = function () {};
var gLogger = {
trace: NOOP,
debug: NOOP,
info: debug,
warn: debug,
error: console.error,
fatal: console.error
};
function start(callback) {
assert(typeof callback === 'function');
gServer = ldap.createServer({ log: gLogger });
gServer.search('ou=users,dc=cloudron', function (req, res, next) {
debug('ldap user search: dn %s, scope %s, filter %s', req.dn.toString(), req.scope, req.filter.toString());
user.list(function (error, result){
if (error) return next(new ldap.OperationsError(error.toString()));
// send user objects
result.forEach(function (entry) {
var dn = ldap.parseDN('cn=' + entry.id + ',ou=users,dc=cloudron');
var tmp = {
dn: dn.toString(),
attributes: {
objectclass: ['user'],
cn: entry.id,
uid: entry.id,
mail: entry.email,
displayname: entry.username,
username: entry.username
}
};
if ((req.dn.equals(dn) || req.dn.parentOf(dn)) && req.filter.matches(tmp.attributes)) {
res.send(tmp);
debug('ldap user send:', tmp);
}
});
res.end();
});
});
gServer.search('ou=groups,dc=cloudron', function (req, res, next) {
debug('ldap group search: dn %s, scope %s, filter %s', req.dn.toString(), req.scope, req.filter.toString());
user.list(function (error, result){
if (error) return next(new ldap.OperationsError(error.toString()));
// we only have an admin group
var dn = ldap.parseDN('cn=admin,ou=groups,dc=cloudron');
var tmp = {
dn: dn.toString(),
attributes: {
objectclass: ['group'],
cn: 'admin',
memberuid: result.filter(function (entry) { return entry.admin; }).map(function(entry) { return entry.id; })
}
};
if ((req.dn.equals(dn) || req.dn.parentOf(dn)) && req.filter.matches(tmp.attributes)) {
res.send(tmp);
debug('ldap group send:', tmp);
}
res.end();
});
});
gServer.bind('dc=cloudron', function(req, res, next) {
debug('ldap bind: %s', req.dn.toString());
if (!req.dn.rdns[0].cn) return next(new ldap.NoSuchObjectError(req.dn.toString()));
user.verify(req.dn.rdns[0].cn, req.credentials || '', function (error, result) {
if (error && error.reason === UserError.NOT_FOUND) return next(new ldap.NoSuchObjectError(req.dn.toString()));
if (error && error.reason === UserError.WRONG_PASSWORD) return next(new ldap.InvalidCredentialsError(req.dn.toString()));
if (error) return next(new ldap.OperationsError(error));
res.end();
});
});
gServer.listen(config.get('ldapPort'), callback);
}

55
src/locker.js Normal file
View File

@@ -0,0 +1,55 @@
'use strict';
var assert = require('assert'),
debug = require('debug')('box:locker'),
EventEmitter = require('events').EventEmitter,
util = require('util');
function Locker() {
this._operation = null;
this._timestamp = null;
this._watcherId = -1;
}
util.inherits(Locker, EventEmitter);
// these are mutually exclusive operations
Locker.prototype.OP_BOX_UPDATE = 'box_update';
Locker.prototype.OP_FULL_BACKUP = 'full_backup';
Locker.prototype.OP_APPTASK = 'apptask';
Locker.prototype.OP_MIGRATE = 'migrate';
Locker.prototype.lock = function (operation) {
assert.strictEqual(typeof operation, 'string');
if (this._operation !== null) return new Error('Already locked for ' + this._operation);
this._operation = operation;
this._timestamp = new Date();
var that = this;
this._watcherId = setInterval(function () { debug('Lock unreleased %s', that._operation); }, 1000 * 60 * 5);
debug('Acquired : %s', this._operation);
this.emit('locked', this._operation);
return null;
};
Locker.prototype.unlock = function (operation) {
assert.strictEqual(typeof operation, 'string');
if (this._operation !== operation) throw new Error('Mismatched unlock. Current lock is for ' + this._operation); // throw because this is a programming error
debug('Released : %s', this._operation);
this._operation = null;
this._timestamp = null;
clearInterval(this._watcherId);
this._watcherId = -1;
this.emit('unlocked', operation);
return null;
}
exports = module.exports = new Locker();

View File

@@ -0,0 +1,19 @@
<%if (format === 'text') { %>
Dear Admin,
The application titled '<%= title %>' that you installed at <%= appFqdn %>
is not responding.
This is most likely a problem in the application. Please report this issue to
support@cloudron.io (by forwarding this email).
You are receiving this email because you are an Admin of the Cloudron at <%= fqdn %>.
Thank you,
Application WatchDog
<% } else { %>
<% } %>

View File

@@ -0,0 +1,15 @@
<%if (format === 'text') { %>
Dear Admin,
A new version of the app '<%= app.manifest.title %>' installed at <%= app.fqdn %> is available!
Please update at your convenience at <%= webadminUrl %>.
Thank you,
Update Manager
<% } else { %>
<% } %>

View File

@@ -0,0 +1,20 @@
<%if (format === 'text') { %>
Dear Admin,
A new version of Cloudron <%= fqdn %> is available!
Please update at your convenience at <%= webadminUrl %>.
Changelog:
<% for (var i = 0; i < changelog.length; i++) { %>
* <%- changelog[i] %>
<% } %>
Thank you,
Update Manager
<% } else { %>
<% } %>

View File

@@ -0,0 +1,19 @@
<%if (format === 'text') { %>
Dear Cloudron Team,
unfortunately the <%= program %> on <%= fqdn %> crashed unexpectedly!
Please see some excerpt of the logs below.
Thank you,
Your Cloudron
-------------------------------------
<%= context %>
<% } else { %>
<% } %>

View File

@@ -0,0 +1,20 @@
<%if (format === 'text') { %>
Dear <%= username %>,
Someone, hopefully you, has requested your <%= fqdn %>'s account password
be reset. If you did not request this reset, please ignore this message.
To reset your password, please visit the following page:
<%= resetLink %>
When you visit the above page, you will be prompted to enter a new password.
After you have submitted the form, you can login using the new password.
Thank you,
<%= fqdn %> Admin
<% } else { %>
<% } %>

View File

@@ -0,0 +1,15 @@
<%if (format === 'text') { %>
Dear Admin,
User with name '<%= username %>' (<%= email %>) <%= event %> in the Cloudron at <%= fqdn %>.
You are receiving this email because you are an Admin of the Cloudron at <%= fqdn %>.
Thank you,
User Manager
<% } else { %>
<% } %>

View File

@@ -0,0 +1,24 @@
<%if (format === 'text') { %>
Dear <%= user.username %>,
I am excited to welcome you to my Cloudron <%= fqdn %>!
The Cloudron is our own Private Cloud. You can read more about it
at https://www.cloudron.io.
You username is '<%= user.username %>'
To get started, create your account by visiting the following page:
<%= setupLink %>
When you visit the above page, you will be prompted to enter a new password.
After you have submitted the form, you can login using the new password.
Thank you,
<%= invitor.email %>
<% } else { %>
<% } %>

279
src/mailer.js Normal file
View File

@@ -0,0 +1,279 @@
/* jslint node: true */
'use strict';
exports = module.exports = {
initialize: initialize,
uninitialize: uninitialize,
userAdded: userAdded,
userRemoved: userRemoved,
adminChanged: adminChanged,
passwordReset: passwordReset,
boxUpdateAvailable: boxUpdateAvailable,
appUpdateAvailable: appUpdateAvailable,
sendCrashNotification: sendCrashNotification,
appDied: appDied
};
var assert = require('assert'),
async = require('async'),
config = require('./config.js'),
debug = require('debug')('box:mailer'),
digitalocean = require('./digitalocean.js'),
docker = require('./docker.js'),
ejs = require('ejs'),
nodemailer = require('nodemailer'),
path = require('path'),
safe = require('safetydance'),
smtpTransport = require('nodemailer-smtp-transport'),
sysinfo = require('./sysinfo.js'),
userdb = require('./userdb.js'),
util = require('util'),
_ = require('underscore');
var MAIL_TEMPLATES_DIR = path.join(__dirname, 'mail_templates');
var gMailQueue = [ ],
gDnsReady = false,
gCheckDnsTimerId = null;
function initialize(callback) {
assert.strictEqual(typeof callback, 'function');
checkDns();
callback(null);
}
function uninitialize(callback) {
assert.strictEqual(typeof callback, 'function');
// TODO: interrupt processQueue as well
clearTimeout(gCheckDnsTimerId);
gCheckDnsTimerId = null;
debug(gMailQueue.length + ' mail items dropped');
gMailQueue = [ ];
callback(null);
}
function checkDns() {
digitalocean.checkPtrRecord(sysinfo.getIp(), config.fqdn(), function (error, ok) {
if (error || !ok) {
debug('PTR record not setup yet');
gCheckDnsTimerId = setTimeout(checkDns, 10000);
return;
}
gDnsReady = true;
processQueue();
});
}
function processQueue() {
docker.getContainer('mail').inspect(function (error, data) {
if (error) return console.error(error);
var mailServerIp = safe.query(data, 'NetworkSettings.IPAddress');
if (!mailServerIp) return debug('Error querying mail server IP');
var transport = nodemailer.createTransport(smtpTransport({
host: mailServerIp,
port: 25
}));
var mailQueueCopy = gMailQueue;
gMailQueue = [ ];
debug('Processing mail queue of size %d', mailQueueCopy.length);
async.mapSeries(mailQueueCopy, function iterator(mailOptions, callback) {
transport.sendMail(mailOptions, function (error) {
if (error) return console.error(error); // TODO: requeue?
debug('Email sent to ' + mailOptions.to);
});
callback(null);
}, function done() {
debug('Done processing mail queue');
});
});
}
function enqueue(mailOptions) {
assert.strictEqual(typeof mailOptions, 'object');
debug('Queued mail for ' + mailOptions.from + ' to ' + mailOptions.to);
gMailQueue.push(mailOptions);
if (gDnsReady) processQueue();
}
function render(templateFile, params) {
assert.strictEqual(typeof templateFile, 'string');
assert.strictEqual(typeof params, 'object');
return ejs.render(safe.fs.readFileSync(path.join(MAIL_TEMPLATES_DIR, templateFile), 'utf8'), params);
}
function getAdminEmails(callback) {
userdb.getAllAdmins(function (error, admins) {
if (error) return callback(error);
var adminEmails = [ ];
admins.forEach(function (admin) { adminEmails.push(admin.email); });
callback(null, adminEmails);
});
}
function mailUserEventToAdmins(user, event) {
assert.strictEqual(typeof user, 'object');
assert.strictEqual(typeof event, 'string');
getAdminEmails(function (error, adminEmails) {
if (error) return console.log('Error getting admins', error);
adminEmails = _.difference(adminEmails, [ user.email ]);
var mailOptions = {
from: config.get('adminEmail'),
to: adminEmails.join(', '),
subject: util.format('%s %s in Cloudron %s', user.username, event, config.fqdn()),
text: render('user_event.ejs', { fqdn: config.fqdn(), username: user.username, email: user.email, event: event, format: 'text' }),
};
enqueue(mailOptions);
});
}
function userAdded(user, invitor) {
assert.strictEqual(typeof user, 'object');
assert(typeof invitor === 'object');
debug('Sending mail for userAdded');
var templateData = {
user: user,
webadminUrl: config.adminOrigin(),
setupLink: config.adminOrigin() + '/api/v1/session/password/setup.html?reset_token=' + user.resetToken,
format: 'text',
fqdn: config.fqdn(),
invitor: invitor
};
var mailOptions = {
from: config.get('adminEmail'),
to: user.email,
subject: util.format('Welcome to Cloudron %s', config.fqdn()),
text: render('welcome_user.ejs', templateData)
};
enqueue(mailOptions);
mailUserEventToAdmins(user, 'was added');
}
function userRemoved(username) {
assert.strictEqual(typeof username, 'string');
debug('Sending mail for userRemoved');
mailUserEventToAdmins({ username: username }, 'was removed');
}
function adminChanged(user) {
assert.strictEqual(typeof user, 'object');
debug('Sending mail for adminChanged');
mailUserEventToAdmins(user, user.admin ? 'is now an admin' : 'is no more an admin');
}
function passwordReset(user) {
assert.strictEqual(typeof user, 'object');
debug('Sending mail for password reset for user %s.', user.username);
var resetLink = config.adminOrigin() + '/api/v1/session/password/reset.html?reset_token=' + user.resetToken;
var mailOptions = {
from: config.get('adminEmail'),
to: user.email,
subject: 'Password Reset Request',
text: render('password_reset.ejs', { fqdn: config.fqdn(), username: user.username, resetLink: resetLink, format: 'text' })
};
enqueue(mailOptions);
}
function appDied(app) {
assert.strictEqual(typeof app, 'object');
debug('Sending mail for app %s @ %s died', app.id, app.location);
getAdminEmails(function (error, adminEmails) {
if (error) return console.log('Error getting admins', error);
var mailOptions = {
from: config.get('adminEmail'),
to: adminEmails.join(', '),
subject: util.format('App %s is down', app.location),
text: render('app_down.ejs', { fqdn: config.fqdn(), title: app.manifest.title, appFqdn: config.appFqdn(app.location), format: 'text' })
};
enqueue(mailOptions);
});
}
function boxUpdateAvailable(newBoxVersion, changelog) {
assert.strictEqual(typeof newBoxVersion, 'string');
assert(util.isArray(changelog));
getAdminEmails(function (error, adminEmails) {
if (error) return console.log('Error getting admins', error);
var mailOptions = {
from: config.get('adminEmail'),
to: adminEmails.join(', '),
subject: util.format('%s has a new update available', config.fqdn()),
text: render('box_update_available.ejs', { fqdn: config.fqdn(), webadminUrl: config.adminOrigin(), newBoxVersion: newBoxVersion, changelog: changelog, format: 'text' })
};
enqueue(mailOptions);
});
}
function appUpdateAvailable(app, updateInfo) {
assert.strictEqual(typeof app, 'object');
assert.strictEqual(typeof updateInfo, 'object');
getAdminEmails(function (error, adminEmails) {
if (error) return console.log('Error getting admins', error);
var mailOptions = {
from: config.get('adminEmail'),
to: adminEmails.join(', '),
subject: util.format('%s has a new update available', app.fqdn),
text: render('app_update_available.ejs', { fqdn: config.fqdn(), webadminUrl: config.adminOrigin(), app: app, format: 'text' })
};
enqueue(mailOptions);
});
}
function sendCrashNotification(program, context) {
assert.strictEqual(typeof program, 'string');
assert.strictEqual(typeof context, 'string');
var mailOptions = {
from: config.get('adminEmail'),
to: 'admin@cloudron.io',
subject: util.format('[%s] %s exited unexpectedly', config.fqdn(), program),
text: render('crash_notification.ejs', { fqdn: config.fqdn(), program: program, context: context, format: 'text' })
};
enqueue(mailOptions);
}

View File

@@ -0,0 +1,8 @@
'use strict';
module.exports = function contentType(type) {
return function (req, res, next) {
res.setHeader('Content-Type', type);
next();
};
};

55
src/middleware/cors.js Normal file
View File

@@ -0,0 +1,55 @@
/* jshint node:true */
'use strict';
var url = require('url');
/*
* CORS middleware
*
* options can contains a list of origins
*/
module.exports = function cors(options) {
options = options || { };
var maxAge = options.maxAge || 60 * 60 * 25 * 5; // 5 days
var origins = options.origins || [ '*' ];
var allowCredentials = options.allowCredentials || false; // cookies
return function (req, res, next) {
var requestOrigin = req.headers.origin;
if (!requestOrigin) return next();
requestOrigin = url.parse(requestOrigin);
var hostname = requestOrigin.host.split(':')[0]; // remove any port
var originAllowed = origins.some(function (o) { return o === '*' || o === hostname; });
if (!originAllowed) {
return res.status(405).send('CORS not allowed from this domain');
}
// respond back with req.headers.origin which might contain the scheme
res.header('Access-Control-Allow-Origin', req.headers.origin);
res.header('Access-Control-Allow-Credentials', allowCredentials);
// handle preflighted requests
if (req.method === 'OPTIONS') {
if (req.headers['access-control-request-method']) {
res.header('Access-Control-Allow-Methods', 'GET, PUT, DELETE, POST, OPTIONS');
}
if (req.headers['access-control-request-headers']) {
res.header('Access-Control-Allow-Headers', req.headers['access-control-request-headers']);
}
res.header('Access-Control-Max-Age', maxAge);
return res.status(200).send();
}
if (req.headers['access-control-request-headers']) {
res.header('Access-Control-Allow-Headers', req.headers['access-control-request-headers']);
}
next();
};
};

17
src/middleware/index.js Normal file
View File

@@ -0,0 +1,17 @@
'use strict';
exports = module.exports = {
contentType: require('./contentType'),
cookieParser: require('cookie-parser'),
cors: require('./cors'),
csrf: require('csurf'),
favicon: require('serve-favicon'),
json: require('body-parser').json,
morgan: require('morgan'),
proxy: require('proxy-middleware'),
lastMile: require('connect-lastmile'),
multipart: require('./multipart.js'),
session: require('express-session'),
timeout: require('connect-timeout'),
urlencoded: require('body-parser').urlencoded
};

View File

@@ -0,0 +1,47 @@
/* jshint node:true */
'use strict';
var multiparty = require('multiparty'),
timeout = require('connect-timeout');
function _mime(req) {
var str = req.headers['content-type'] || '';
return str.split(';')[0];
}
module.exports = function multipart(options) {
return function (req, res, next) {
if (_mime(req) !== 'multipart/form-data') return res.status(400).send('Invalid content-type. Expecting multipart');
var form = new multiparty.Form({
uploadDir: '/tmp',
keepExtensions: true,
maxFieldsSize: options.maxFieldsSize || (2 * 1024), // only field size, not files
limit: options.limit || '8mb', // file sizes
autoFiles: true
});
// increase timeout of file uploads by default to 3 mins
if (req.clearTimeout) req.clearTimeout(); // clear any previous installed timeout middleware
timeout(options.timeout || (3 * 60 * 1000))(req, res, function () {
req.fields = { };
req.files = { };
form.parse(req, function (err, fields, files) {
if (err) return res.status(400).send('Error parsing request');
next(null);
});
form.on('file', function (name, file) {
req.files[name] = file;
});
form.on('field', function (name, value) {
req.fields[name] = value; // otherwise fields.name is an array
});
});
};
};

View File

@@ -0,0 +1,47 @@
<% include header %>
<script>
'use strict';
var code = null;
var redirectURI = null;
var accessToken = null;
var tokenType = null;
var state = null;
var args = window.location.search.slice(1).split('&');
args.forEach(function (arg) {
var tmp = arg.split('=');
if (tmp[0] === 'redirectURI') {
redirectURI = window.decodeURIComponent(tmp[1]);
} else if (tmp[0] === 'code') {
code = window.decodeURIComponent(tmp[1]);
} else if (tmp[0] === 'state') {
state = window.decodeURIComponent(tmp[1]);
}
});
args = window.location.hash.slice(1).split('&');
args.forEach(function (arg) {
var tmp = arg.split('=');
if (tmp[0] === 'access_token') {
accessToken = window.decodeURIComponent(tmp[1]);
} else if (tmp[0] === 'token_type') {
tokenType = window.decodeURIComponent(tmp[1]);
} else if (tmp[0] === 'state') {
state = window.decodeURIComponent(tmp[1]);
}
});
if (code && redirectURI) {
window.location.href = redirectURI + '?code=' + code + (state ? '&state=' + state : '');
} else if (redirectURI && accessToken) {
window.location.href = redirectURI + '?token=' + accessToken + (state ? '&state=' + state : '');
} else {
window.location.href = '/api/v1/session/login';
}
</script>
<% include footer %>;

View File

@@ -0,0 +1,38 @@
<% include header %>
<form action="/api/v1/oauth/dialog/authorize/decision" method="post">
<input name="transaction_id" type="hidden" value="<%= transactionID %>">
<input type="hidden" name="_csrf" value="<%= csrf %>"/>
<div class="row">
<div class="col-md-3"></div>
<div class="col-md-6">
<div class="container-fluid">
<div class="row">
<div class="col-sm-12">
Hi <%= user.username %>!
</div>
</div>
<div class="row">
<div class="col-sm-12">
<b><%= client.name %></b> is requesting access to your account.
</div>
</div>
<div class="row">
<div class="col-sm-12">
Do you approve?
</div>
</div>
<div class="row">
<div class="col-sm-12">
<input class="btn btn-danger btn-outline" type="submit" value="Deny" name="cancel" id="deny"/>
<input class="btn btn-success btn-outline" type="submit" value="Allow"/>
</div>
</div>
</div>
</div>
<div class="col-md-3"></div>
</div>
</form>
<% include footer %>

24
src/oauth2views/error.ejs Normal file
View File

@@ -0,0 +1,24 @@
<% include header %>
<br/>
<div class="container">
<div class="row">
<div class="col-md-2"></div>
<div class="col-md-8">
<div class="alert alert-danger">
<%- message %>
</div>
</div>
<div class="col-md-2"></div>
</div>
<div class="row">
<div class="col-md-2"></div>
<div class="col-md-8 text-center">
<a href="<%- adminOrigin %>">Back</a>
</div>
<div class="col-md-2"></div>
</div>
</div>
<% include footer %>

View File

@@ -0,0 +1,3 @@
</body>
</html>

View File

@@ -0,0 +1,28 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="user-scalable=no, initial-scale=1, maximum-scale=1, minimum-scale=1, width=device-width, height=device-height" />
<title> Cloudron Login </title>
<!-- Custom Fonts -->
<link href="//maxcdn.bootstrapcdn.com/font-awesome/4.3.0/css/font-awesome.min.css" rel="stylesheet" type="text/css">
<link href="https://fonts.googleapis.com/css?family=Roboto:300" rel="stylesheet" type="text/css">
<!-- jQuery-->
<script src="<%= adminOrigin %>/3rdparty/js/jquery.min.js"></script>
<!-- Bootstrap Core JavaScript -->
<script src="<%= adminOrigin %>/3rdparty/js/bootstrap.min.js"></script>
<!-- Angularjs scripts -->
<script src="<%= adminOrigin %>/3rdparty/js/angular.min.js"></script>
<script src="<%= adminOrigin %>/3rdparty/js/angular-loader.min.js"></script>
<!-- Theme CSS -->
<link href="<%= adminOrigin %>/theme.css" rel="stylesheet">
</head>
<body>

44
src/oauth2views/login.ejs Normal file
View File

@@ -0,0 +1,44 @@
<% include header %>
<center>
<h1>Login to <%= applicationName %></h1>
</center>
<% if (error) { %>
<center>
<br/><br/>
<h4 class="has-error"><%= error %></h4>
</center>
<% } %>
<div class="container">
<div class="row">
<div class="col-md-6 col-md-offset-3">
<form id="loginForm" action="" method="post">
<input type="hidden" name="_csrf" value="<%= csrf %>"/>
<div class="form-group">
<label class="control-label" for="inputUsername">Username or Email</label>
<input type="text" class="form-control" id="inputUsername" name="username" autofocus required>
</div>
<div class="form-group">
<label class="control-label" for="inputPassword">Password</label>
<input type="password" class="form-control" name="password" id="inputPassword" required>
</div>
<input class="btn btn-primary btn-outline pull-right" type="submit" value="Sign in"/>
</form>
<a href="/api/v1/session/password/resetRequest.html">Reset your password</a>
</div>
</div>
</div>
<script>
(function () {
var search = window.location.search.slice(1).split('&').map(function (item) { return item.split('='); }).reduce(function (o, k) { o[k[0]] = k[1]; return o; }, {});
document.getElementById('loginForm').action = '/api/v1/session/login?returnTo=' + search.returnTo;
})();
</script>
<% include footer %>

View File

@@ -0,0 +1,40 @@
<% include header %>
<!-- tester -->
<script>
'use strict';
// very basic angular app
var app = angular.module('Application', []);
app.controller('Controller', [function () {}]);
</script>
<center>
<h1>Hello <%= user.username %> create a new password</h1>
</center>
<div class="container" ng-app="Application" ng-controller="Controller">
<div class="row">
<div class="col-md-6 col-md-offset-3">
<form action="/api/v1/session/password/reset" method="post" name="resetForm" autocomplete="off" role="form" novalidate>
<input type="hidden" name="_csrf" value="<%= csrf %>"/>
<input type="hidden" name="resetToken" value="<%= resetToken %>"/>
<div class="form-group" ng-class="{ 'has-error': resetForm.password.$dirty && resetForm.password.$invalid }">
<label class="control-label" for="inputPassword">New Password</label>
<input type="password" class="form-control" id="inputPassword" ng-model="password" name="password" ng-maxlength="512" ng-minlength="5" autofocus required>
</div>
<div class="form-group" ng-class="{ 'has-error': resetForm.passwordRepeat.$dirty && (resetForm.passwordRepeat.$invalid || password !== passwordRepeat) }">
<label class="control-label" for="inputPasswordRepeat">Repeat Password</label>
<input type="password" class="form-control" id="inputPasswordRepeat" ng-model="passwordRepeat" name="passwordRepeat" required>
</div>
<input class="btn btn-primary btn-outline pull-right" type="submit" value="Create" ng-disabled="resetForm.$invalid || password !== passwordRepeat"/>
</form>
</div>
</div>
</div>
<% include footer %>

View File

@@ -0,0 +1,25 @@
<% include header %>
<!-- tester -->
<center>
<h1>Reset your password</h1>
</center>
<div class="container">
<div class="row">
<div class="col-md-6 col-md-offset-3">
<form action="/api/v1/session/password/resetRequest" method="post" autocomplete="off">
<input type="hidden" name="_csrf" value="<%= csrf %>"/>
<div class="form-group">
<label class="control-label" for="inputIdentifier">Username or Email</label>
<input type="text" class="form-control" id="inputIdentifier" name="identifier" autofocus required>
</div>
<input class="btn btn-primary btn-outline pull-right" type="submit" value="Reset"/>
</form>
<a href="/api/v1/session/login">Login</a>
</div>
</div>
</div>
<% include footer %>

View File

@@ -0,0 +1,18 @@
<% include header %>
<!-- tester -->
<center>
<h1>Reset your password successful</h1>
</center>
<div class="container">
<div class="row">
<div class="col-md-6 col-md-offset-3">
<p>An email was sent to you with a link to create a new password.</p>
If you have not received any email after some time, maybe you have misspelled your email address, simply try again <a href="/api/v1/session/password/resetRequest.html">here</a>.
</div>
</div>
</div>
<% include footer %>

View File

@@ -0,0 +1,40 @@
<% include header %>
<!-- tester -->
<script>
'use strict';
// very basic angular app
var app = angular.module('Application', []);
app.controller('Controller', [function () {}]);
</script>
<center>
<h1>Hello <%= user.username %> create a password</h1>
</center>
<div class="container" ng-app="Application" ng-controller="Controller">
<div class="row">
<div class="col-md-6 col-md-offset-3">
<form action="/api/v1/session/password/reset" method="post" name="setupForm" autocomplete="off" role="form" novalidate>
<input type="hidden" name="_csrf" value="<%= csrf %>"/>
<input type="hidden" name="resetToken" value="<%= resetToken %>"/>
<div class="form-group" ng-class="{ 'has-error': setupForm.password.$dirty && setupForm.password.$invalid }">
<label class="control-label" for="inputPassword">New Password</label>
<input type="password" class="form-control" id="inputPassword" ng-model="password" name="password" ng-maxlength="512" ng-minlength="5" autofocus required>
</div>
<div class="form-group" ng-class="{ 'has-error': setupForm.passwordRepeat.$dirty && (setupForm.passwordRepeat.$invalid || password !== passwordRepeat) }">
<label class="control-label" for="inputPasswordRepeat">Repeat Password</label>
<input type="password" class="form-control" id="inputPasswordRepeat" ng-model="passwordRepeat" name="passwordRepeat" required>
</div>
<input class="btn btn-primary btn-outline pull-right" type="submit" value="Create" ng-disabled="setupForm.$invalid || password !== passwordRepeat"/>
</form>
</div>
</div>
</div>
<% include footer %>

28
src/paths.js Normal file
View File

@@ -0,0 +1,28 @@
/* jslint node:true */
'use strict';
var config = require('./config.js'),
path = require('path');
// keep these values in sync with start.sh
exports = module.exports = {
NGINX_CONFIG_DIR: path.join(config.baseDir(), 'data/nginx'),
NGINX_APPCONFIG_DIR: path.join(config.baseDir(), 'data/nginx/applications'),
NGINX_CERT_DIR: path.join(config.baseDir(), 'data/nginx/cert'),
ADDON_CONFIG_DIR: path.join(config.baseDir(), 'data/addons'),
COLLECTD_APPCONFIG_DIR: path.join(config.baseDir(), 'data/collectd/collectd.conf.d'),
DATA_DIR: path.join(config.baseDir(), 'data'),
BOX_DATA_DIR: path.join(config.baseDir(), 'data/box'),
// this is not part of appdata because an icon may be set before install
APPICONS_DIR: path.join(config.baseDir(), 'data/box/appicons'),
MAIL_DATA_DIR: path.join(config.baseDir(), 'data/box/mail'),
CLOUDRON_AVATAR_FILE: path.join(config.baseDir(), 'data/box/avatar.png'),
CLOUDRON_DEFAULT_AVATAR_FILE: path.join(__dirname + '/../assets/avatar.png'),
FAVICON_FILE: path.join(__dirname + '/../assets/favicon.ico')
};

47
src/progress.js Normal file
View File

@@ -0,0 +1,47 @@
/* jslint node: true */
'use strict';
exports = module.exports = {
set: set,
clear: clear,
get: get,
UPDATE: 'update',
BACKUP: 'backup'
};
var assert = require('assert'),
debug = require('debug')('box:progress');
// if progress.update or progress.backup are object, they will contain 'percent' and 'message' properties
// otherwise no such operation is currently ongoing
var progress = {
update: null,
backup: null
};
function set(tag, percent, message) {
assert(tag === exports.UPDATE || tag === exports.BACKUP);
assert.strictEqual(typeof percent, 'number');
assert.strictEqual(typeof message, 'string');
progress[tag] = {
percent: percent,
message: message
};
debug('%s: %s %s', tag, percent, message);
}
function clear(tag) {
assert(tag === exports.UPDATE || tag === exports.BACKUP);
progress[tag] = null;
debug('clearing %s', tag);
}
function get() {
return progress;
}

356
src/routes/apps.js Normal file
View File

@@ -0,0 +1,356 @@
/* jslint node:true */
'use strict';
exports = module.exports = {
getApp: getApp,
getAppBySubdomain: getAppBySubdomain,
getApps: getApps,
getAppIcon: getAppIcon,
installApp: installApp,
configureApp: configureApp,
uninstallApp: uninstallApp,
restoreApp: restoreApp,
backupApp: backupApp,
updateApp: updateApp,
getLogs: getLogs,
getLogStream: getLogStream,
stopApp: stopApp,
startApp: startApp,
exec: exec
};
var apps = require('../apps.js'),
AppsError = apps.AppsError,
assert = require('assert'),
debug = require('debug')('box:routes/apps'),
fs = require('fs'),
HttpError = require('connect-lastmile').HttpError,
HttpSuccess = require('connect-lastmile').HttpSuccess,
paths = require('../paths.js'),
safe = require('safetydance'),
util = require('util'),
uuid = require('node-uuid');
function removeInternalAppFields(app) {
return {
id: app.id,
appStoreId: app.appStoreId,
installationState: app.installationState,
installationProgress: app.installationProgress,
runState: app.runState,
health: app.health,
location: app.location,
accessRestriction: app.accessRestriction,
lastBackupId: app.lastBackupId,
manifest: app.manifest,
portBindings: app.portBindings,
iconUrl: app.iconUrl,
fqdn: app.fqdn
};
}
function getApp(req, res, next) {
assert.strictEqual(typeof req.params.id, 'string');
apps.get(req.params.id, function (error, app) {
if (error && error.reason === AppsError.NOT_FOUND) return next(new HttpError(404, 'No such app'));
if (error) return next(new HttpError(500, error));
next(new HttpSuccess(200, removeInternalAppFields(app)));
});
}
function getAppBySubdomain(req, res, next) {
assert.strictEqual(typeof req.params.subdomain, 'string');
apps.getBySubdomain(req.params.subdomain, function (error, app) {
if (error && error.reason === AppsError.NOT_FOUND) return next(new HttpError(404, 'No such subdomain'));
if (error) return next(new HttpError(500, error));
next(new HttpSuccess(200, removeInternalAppFields(app)));
});
}
function getApps(req, res, next) {
apps.getAll(function (error, allApps) {
if (error) return next(new HttpError(500, error));
allApps = allApps.map(removeInternalAppFields);
next(new HttpSuccess(200, { apps: allApps }));
});
}
function getAppIcon(req, res, next) {
assert.strictEqual(typeof req.params.id, 'string');
var iconPath = paths.APPICONS_DIR + '/' + req.params.id + '.png';
fs.exists(iconPath, function (exists) {
if (!exists) return next(new HttpError(404, 'No such icon'));
res.sendFile(iconPath);
});
}
/*
* Installs an app
* @bodyparam {string} appStoreId The id of the app to be installed
* @bodyparam {manifest} manifest The app manifest
* @bodyparam {string} password The user's password
* @bodyparam {string} location The subdomain where the app is to be installed
* @bodyparam {object} portBindings map from environment variable name to (public) host port. can be null.
If a value in manifest.tcpPorts is missing in portBindings, the port/service is disabled
* @bodyparam {icon} icon Base64 encoded image
*/
function installApp(req, res, next) {
assert.strictEqual(typeof req.body, 'object');
var data = req.body;
if (!data) return next(new HttpError(400, 'Cannot parse data field'));
if (!data.manifest || typeof data.manifest !== 'object') return next(new HttpError(400, 'manifest is required'));
if (typeof data.appStoreId !== 'string') return next(new HttpError(400, 'appStoreId is required'));
if (typeof data.location !== 'string') return next(new HttpError(400, 'location is required'));
if (('portBindings' in data) && typeof data.portBindings !== 'object') return next(new HttpError(400, 'portBindings must be an object'));
if (typeof data.accessRestriction !== 'string') return next(new HttpError(400, 'accessRestriction is required'));
if ('icon' in data && typeof data.icon !== 'string') return next(new HttpError(400, 'icon is not a string'));
// allow tests to provide an appId for testing
var appId = (process.env.NODE_ENV === 'test' && typeof data.appId === 'string') ? data.appId : uuid.v4();
debug('Installing app id:%s storeid:%s loc:%s port:%j restrict:%s manifest:%j', appId, data.appStoreId, data.location, data.portBindings, data.accessRestriction, data.manifest);
apps.install(appId, data.appStoreId, data.manifest, data.location, data.portBindings || null, data.accessRestriction, data.icon || null, function (error) {
if (error && error.reason === AppsError.ALREADY_EXISTS) return next(new HttpError(409, error.message));
if (error && error.reason === AppsError.PORT_RESERVED) return next(new HttpError(409, 'Port ' + error.message + ' is reserved.'));
if (error && error.reason === AppsError.PORT_CONFLICT) return next(new HttpError(409, 'Port ' + error.message + ' is already in use.'));
if (error && error.reason === AppsError.BAD_FIELD) return next(new HttpError(400, error.message));
if (error && error.reason === AppsError.BILLING_REQUIRED) return next(new HttpError(402, 'Billing required'));
if (error) return next(new HttpError(500, error));
next(new HttpSuccess(202, { id: appId } ));
});
}
/*
* Configure an app
* @bodyparam {string} password The user's password
* @bodyparam {string} location The subdomain where the app is to be installed
* @bodyparam {object} portBindings map from env to (public) host port. can be null.
If a value in manifest.tcpPorts is missing in portBindings, the port/service is disabled
*/
function configureApp(req, res, next) {
assert.strictEqual(typeof req.body, 'object');
assert.strictEqual(typeof req.params.id, 'string');
var data = req.body;
if (!data) return next(new HttpError(400, 'Cannot parse data field'));
if (typeof data.location !== 'string') return next(new HttpError(400, 'location is required'));
if (('portBindings' in data) && typeof data.portBindings !== 'object') return next(new HttpError(400, 'portBindings must be an object'));
if (typeof data.accessRestriction !== 'string') return next(new HttpError(400, 'accessRestriction is required'));
debug('Configuring app id:%s location:%s bindings:%j', req.params.id, data.location, data.portBindings);
apps.configure(req.params.id, data.location, data.portBindings || null, data.accessRestriction, function (error) {
if (error && error.reason === AppsError.ALREADY_EXISTS) return next(new HttpError(409, error.message));
if (error && error.reason === AppsError.PORT_RESERVED) return next(new HttpError(409, 'Port ' + error.message + ' is reserved.'));
if (error && error.reason === AppsError.PORT_CONFLICT) return next(new HttpError(409, 'Port ' + error.message + ' is already in use.'));
if (error && error.reason === AppsError.NOT_FOUND) return next(new HttpError(404, 'No such app'));
if (error && error.reason === AppsError.BAD_STATE) return next(new HttpError(409, error.message));
if (error && error.reason === AppsError.BAD_FIELD) return next(new HttpError(400, error.message));
if (error) return next(new HttpError(500, error));
next(new HttpSuccess(202, { }));
});
}
function restoreApp(req, res, next) {
assert.strictEqual(typeof req.params.id, 'string');
debug('Restore app id:%s', req.params.id);
apps.restore(req.params.id, function (error) {
if (error && error.reason === AppsError.NOT_FOUND) return next(new HttpError(404, 'No such app'));
if (error && error.reason === AppsError.BAD_FIELD) return next(new HttpError(400, error.message));
if (error && error.reason === AppsError.BAD_STATE) return next(new HttpError(409, error.message));
if (error) return next(new HttpError(500, error));
next(new HttpSuccess(202, { }));
});
}
function backupApp(req, res, next) {
assert.strictEqual(typeof req.params.id, 'string');
debug('Backup app id:%s', req.params.id);
apps.backup(req.params.id, function (error) {
if (error && error.reason === AppsError.NOT_FOUND) return next(new HttpError(404, 'No such app'));
if (error && error.reason === AppsError.BAD_STATE) return next(new HttpError(409, error.message));
if (error && error.reason === AppsError.EXTERNAL_ERROR) return next(new HttpError(503, error));
if (error) return next(new HttpError(500, error));
next(new HttpSuccess(202, { }));
});
}
/*
* Uninstalls an app
* @bodyparam {string} id The id of the app to be uninstalled
*/
function uninstallApp(req, res, next) {
assert.strictEqual(typeof req.params.id, 'string');
debug('Uninstalling app id:%s', req.params.id);
apps.uninstall(req.params.id, function (error) {
if (error && error.reason === AppsError.NOT_FOUND) return next(new HttpError(404, 'No such app'));
if (error) return next(new HttpError(500, error));
next(new HttpSuccess(202, { }));
});
}
function startApp(req, res, next) {
assert.strictEqual(typeof req.params.id, 'string');
debug('Start app id:%s', req.params.id);
apps.start(req.params.id, function (error) {
if (error && error.reason === AppsError.NOT_FOUND) return next(new HttpError(404, 'No such app'));
if (error && error.reason === AppsError.BAD_STATE) return next(new HttpError(409, error.message));
if (error) return next(new HttpError(500, error));
next(new HttpSuccess(202, { }));
});
}
function stopApp(req, res, next) {
assert.strictEqual(typeof req.params.id, 'string');
debug('Stop app id:%s', req.params.id);
apps.stop(req.params.id, function (error) {
if (error && error.reason === AppsError.NOT_FOUND) return next(new HttpError(404, 'No such app'));
if (error && error.reason === AppsError.BAD_STATE) return next(new HttpError(409, error.message));
if (error) return next(new HttpError(500, error));
next(new HttpSuccess(202, { }));
});
}
function updateApp(req, res, next) {
assert.strictEqual(typeof req.params.id, 'string');
assert.strictEqual(typeof req.body, 'object');
var data = req.body;
if (!data) return next(new HttpError(400, 'Cannot parse data field'));
if (!data.manifest || typeof data.manifest !== 'object') return next(new HttpError(400, 'manifest is required'));
if (('portBindings' in data) && typeof data.portBindings !== 'object') return next(new HttpError(400, 'portBindings must be an object'));
if ('icon' in data && typeof data.icon !== 'string') return next(new HttpError(400, 'icon is not a string'));
if ('force' in data && typeof data.force !== 'boolean') return next(new HttpError(400, 'force must be a boolean'));
debug('Update app id:%s to manifest:%j', req.params.id, data.manifest);
apps.update(req.params.id, data.force || false, data.manifest, data.portBindings, data.icon, function (error) {
if (error && error.reason === AppsError.NOT_FOUND) return next(new HttpError(404, 'No such app'));
if (error && error.reason === AppsError.BAD_FIELD) return next(new HttpError(400, error.message));
if (error && error.reason === AppsError.BAD_STATE) return next(new HttpError(409, error.message));
if (error && error.reason === AppsError.PORT_CONFLICT) return next(new HttpError(409, 'Port ' + error.message + ' is already in use.'));
if (error) return next(new HttpError(500, error));
next(new HttpSuccess(202, { }));
});
}
// this route is for streaming logs
function getLogStream(req, res, next) {
assert.strictEqual(typeof req.params.id, 'string');
debug('Getting logstream of app id:%s', req.params.id);
var fromLine = req.query.fromLine ? parseInt(req.query.fromLine, 10) : -10; // we ignore last-event-id
if (isNaN(fromLine)) return next(new HttpError(400, 'fromLine must be a valid number'));
function sse(id, data) { return 'id: ' + id + '\ndata: ' + data + '\n\n'; }
if (req.headers.accept !== 'text/event-stream') return next(new HttpError(400, 'This API call requires EventStream'));
apps.getLogStream(req.params.id, fromLine, function (error, logStream) {
if (error && error.reason === AppsError.NOT_FOUND) return next(new HttpError(404, 'No such app'));
if (error && error.reason === AppsError.BAD_STATE) return next(new HttpError(412, error.message));
if (error) return next(new HttpError(500, error));
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'X-Accel-Buffering': 'no', // disable nginx buffering
'Access-Control-Allow-Origin': '*'
});
res.write('retry: 3000\n');
res.on('close', logStream.close);
logStream.on('data', function (data) {
var obj = JSON.parse(data);
res.write(sse(obj.lineNumber, JSON.stringify(obj)));
});
logStream.on('end', res.end.bind(res));
logStream.on('error', res.end.bind(res, null));
});
}
function getLogs(req, res, next) {
assert.strictEqual(typeof req.params.id, 'string');
debug('Getting logs of app id:%s', req.params.id);
apps.getLogs(req.params.id, function (error, logStream) {
if (error && error.reason === AppsError.NOT_FOUND) return next(new HttpError(404, 'No such app'));
if (error && error.reason === AppsError.BAD_STATE) return next(new HttpError(412, error.message));
if (error) return next(new HttpError(500, error));
res.writeHead(200, {
'Content-Type': 'application/x-logs',
'Content-Disposition': 'attachment; filename="log.txt"',
'Cache-Control': 'no-cache',
'X-Accel-Buffering': 'no' // disable nginx buffering
});
logStream.pipe(res);
});
}
function exec(req, res, next) {
assert.strictEqual(typeof req.params.id, 'string');
debug('Execing into app id:%s', req.params.id);
var cmd = null;
if (req.query.cmd) {
cmd = safe.JSON.parse(req.query.cmd);
if (!util.isArray(cmd) && cmd.length < 1) return next(new HttpError(400, 'cmd must be array with atleast size 1'));
}
var columns = req.query.columns ? parseInt(req.query.columns, 10) : null;
if (isNaN(columns)) return next(new HttpError(400, 'columns must be a number'));
var rows = req.query.rows ? parseInt(req.query.rows, 10) : null;
if (isNaN(rows)) return next(new HttpError(400, 'rows must be a number'));
apps.exec(req.params.id, { cmd: cmd, rows: rows, columns: columns }, function (error, duplexStream) {
if (error && error.reason === AppsError.NOT_FOUND) return next(new HttpError(404, 'No such app'));
if (error && error.reason === AppsError.BAD_STATE) return next(new HttpError(409, error.message));
if (error) return next(new HttpError(500, error));
if (req.headers['upgrade'] !== 'tcp') return next(new HttpError(404, 'exec requires TCP upgrade'));
req.clearTimeout();
res.sendUpgradeHandshake();
duplexStream.pipe(res.socket);
res.socket.pipe(duplexStream);
});
}

36
src/routes/backups.js Normal file
View File

@@ -0,0 +1,36 @@
/* jslint node:true */
'use strict';
exports = module.exports = {
get: get,
create: create
};
var backups = require('../backups.js'),
BackupsError = require('../backups.js').BackupsError,
cloudron = require('../cloudron.js'),
CloudronError = require('../cloudron.js').CloudronError,
debug = require('debug')('box:routes/backups'),
HttpError = require('connect-lastmile').HttpError,
HttpSuccess = require('connect-lastmile').HttpSuccess;
function get(req, res, next) {
backups.getAllPaged(1, 5, function (error, result) {
if (error && error.reason === BackupsError.EXTERNAL_ERROR) return next(new HttpError(503, error.message));
if (error) return next(new HttpError(500, error));
next(new HttpSuccess(200, { backups: result }));
});
}
function create(req, res, next) {
// note that cloudron.backup only waits for backup initiation and not for backup to complete
// backup progress can be checked up ny polling the progress api call
cloudron.backup(function (error) {
if (error && error.reason === CloudronError.BAD_STATE) return next(new HttpError(409, error.message));
if (error) return next(new HttpError(500, error));
next(new HttpSuccess(202, {}));
});
}

105
src/routes/clients.js Normal file
View File

@@ -0,0 +1,105 @@
/* jslint node:true */
'use strict';
exports = module.exports = {
add: add,
get: get,
update: update,
del: del,
getAllByUserId: getAllByUserId,
getClientTokens: getClientTokens,
delClientTokens: delClientTokens
};
var assert = require('assert'),
validUrl = require('valid-url'),
clients = require('../clients.js'),
ClientsError = clients.ClientsError,
DatabaseError = require('../databaseerror.js'),
HttpError = require('connect-lastmile').HttpError,
HttpSuccess = require('connect-lastmile').HttpSuccess;
function add(req, res, next) {
var data = req.body;
if (!data) return next(new HttpError(400, 'Cannot parse data field'));
if (typeof data.appId !== 'string' || !data.appId) return next(new HttpError(400, 'appId is required'));
if (typeof data.redirectURI !== 'string' || !data.redirectURI) return next(new HttpError(400, 'redirectURI is required'));
if (typeof data.scope !== 'string' || !data.scope) return next(new HttpError(400, 'scope is required'));
if (!validUrl.isWebUri(data.redirectURI)) return next(new HttpError(400, 'redirectURI must be a valid uri'));
// prefix as this route only allows external apps for developers
var appId = 'external-' + data.appId;
clients.add(appId, data.redirectURI, data.scope, function (error, result) {
if (error && error.reason === ClientsError.INVALID_SCOPE) return next(new HttpError(400, 'Invalid scope'));
if (error) return next(new HttpError(500, error));
next(new HttpSuccess(201, result));
});
}
function get(req, res, next) {
assert.strictEqual(typeof req.params.clientId, 'string');
clients.get(req.params.clientId, function (error, result) {
if (error && error.reason === DatabaseError.NOT_FOUND) return next(new HttpError(404, 'No such client'));
if (error) return next(new HttpError(500, error));
next(new HttpSuccess(200, result));
});
}
function update(req, res, next) {
assert.strictEqual(typeof req.params.clientId, 'string');
var data = req.body;
if (!data) return next(new HttpError(400, 'Cannot parse data field'));
if (typeof data.appId !== 'string' || !data.appId) return next(new HttpError(400, 'appId is required'));
if (typeof data.redirectURI !== 'string' || !data.redirectURI) return next(new HttpError(400, 'redirectURI is required'));
if (!validUrl.isWebUri(data.redirectURI)) return next(new HttpError(400, 'redirectURI must be a valid uri'));
clients.update(req.params.clientId, data.appId, data.redirectURI, function (error, result) {
if (error) return next(new HttpError(500, error));
next(new HttpSuccess(202, result));
});
}
function del(req, res, next) {
assert.strictEqual(typeof req.params.clientId, 'string');
clients.del(req.params.clientId, function (error, result) {
if (error && error.reason === DatabaseError.NOT_FOUND) return next(new HttpError(404, 'no such client'));
if (error) return next(new HttpError(500, error));
next(new HttpSuccess(204, result));
});
}
function getAllByUserId(req, res, next) {
clients.getAllWithDetailsByUserId(req.user.id, function (error, result) {
if (error && error.reason !== DatabaseError.NOT_FOUND) return next(new HttpError(500, error));
next(new HttpSuccess(200, { clients: result }));
});
}
function getClientTokens(req, res, next) {
assert.strictEqual(typeof req.params.clientId, 'string');
assert.strictEqual(typeof req.user, 'object');
clients.getClientTokensByUserId(req.params.clientId, req.user.id, function (error, result) {
if (error && error.reason === DatabaseError.NOT_FOUND) return next(new HttpError(404, 'no such client'));
if (error) return next(new HttpError(500, error));
next(new HttpSuccess(200, { tokens: result }));
});
}
function delClientTokens(req, res, next) {
assert.strictEqual(typeof req.params.clientId, 'string');
assert.strictEqual(typeof req.user, 'object');
clients.delClientTokensByUserId(req.params.clientId, req.user.id, function (error) {
if (error && error.reason === DatabaseError.NOT_FOUND) return next(new HttpError(404, 'no such client'));
if (error) return next(new HttpError(500, error));
next(new HttpSuccess(204));
});
}

159
src/routes/cloudron.js Normal file
View File

@@ -0,0 +1,159 @@
/* jslint node:true */
'use strict';
exports = module.exports = {
activate: activate,
setupTokenAuth: setupTokenAuth,
getStatus: getStatus,
reboot: reboot,
getProgress: getProgress,
getConfig: getConfig,
update: update,
migrate: migrate,
setCertificate: setCertificate
};
var assert = require('assert'),
cloudron = require('../cloudron.js'),
config = require('../config.js'),
progress = require('../progress.js'),
CloudronError = cloudron.CloudronError,
debug = require('debug')('box:routes/cloudron'),
HttpError = require('connect-lastmile').HttpError,
HttpSuccess = require('connect-lastmile').HttpSuccess,
superagent = require('superagent'),
safe = require('safetydance'),
updateChecker = require('../updatechecker.js');
/**
* Creating an admin user and activate the cloudron.
*
* @apiParam {string} username The administrator's user name
* @apiParam {string} password The administrator's password
* @apiParam {string} email The administrator's email address
*
* @apiSuccess (Created 201) {string} token A valid access token
*/
function activate(req, res, next) {
assert.strictEqual(typeof req.body, 'object');
assert.strictEqual(typeof req.query.setupToken, 'string');
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 ('name' in req.body && typeof req.body.name !== 'string') return next(new HttpError(400, 'name must be a string'));
var username = req.body.username;
var password = req.body.password;
var email = req.body.email;
var name = req.body.name || null;
var ip = req.headers['x-forwarded-for'] || req.connection.remoteAddress;
debug('activate: username:%s ip:%s', username, ip);
cloudron.activate(username, password, email, name, ip, function (error, info) {
if (error && error.reason === CloudronError.ALREADY_PROVISIONED) return next(new HttpError(409, 'Already setup'));
if (error && error.reason === CloudronError.BAD_USERNAME) return next(new HttpError(400, 'Bad username'));
if (error && error.reason === CloudronError.BAD_PASSWORD) return next(new HttpError(400, 'Bad password'));
if (error && error.reason === CloudronError.BAD_EMAIL) return next(new HttpError(400, 'Bad email'));
if (error && error.reason === CloudronError.BAD_NAME) return next(new HttpError(400, 'Bad name'));
if (error) return next(new HttpError(500, error));
// Now let the api server know we got activated
superagent.post(config.apiServerOrigin() + '/api/v1/boxes/' + config.fqdn() + '/setup/done').query({ setupToken:req.query.setupToken }).end(function (error, result) {
if (error) return next(new HttpError(500, error));
if (result.statusCode === 403) return next(new HttpError(403, 'Invalid token'));
if (result.statusCode === 409) return next(new HttpError(409, 'Already setup'));
if (result.statusCode !== 201) return next(new HttpError(500, result.text ? result.text.message : 'Internal error'));
next(new HttpSuccess(201, info));
});
});
}
function setupTokenAuth(req, res, next) {
assert.strictEqual(typeof req.query, 'object');
if (typeof req.query.setupToken !== 'string') return next(new HttpError(400, 'no setupToken provided'));
superagent.get(config.apiServerOrigin() + '/api/v1/boxes/' + config.fqdn() + '/setup/verify').query({ setupToken:req.query.setupToken }).end(function (error, result) {
if (error) return next(new HttpError(500, error));
if (result.statusCode === 403) return next(new HttpError(403, 'Invalid token'));
if (result.statusCode === 409) return next(new HttpError(409, 'Already setup'));
if (result.statusCode !== 200) return next(new HttpError(500, result.text ? result.text.message : 'Internal error'));
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.get()));
}
function reboot(req, res, next) {
// Finish the request, to let the appstore know we triggered the restore it
next(new HttpSuccess(202, {}));
cloudron.reboot();
}
function getConfig(req, res, next) {
cloudron.getConfig(function (error, cloudronConfig) {
if (error) return next(new HttpError(500, error));
next(new HttpSuccess(200, cloudronConfig));
});
}
function update(req, res, next) {
var boxUpdateInfo = updateChecker.getUpdateInfo().box;
if (!boxUpdateInfo) return next(new HttpError(422, 'No update available'));
// this only initiates the update, progress can be checked via the progress route
cloudron.update(boxUpdateInfo, function (error) {
if (error && error.reason === CloudronError.BAD_STATE) return next(new HttpError(409, error.message));
if (error) return next(new HttpError(500, error));
next(new HttpSuccess(202, {}));
});
}
function migrate(req, res, next) {
if (typeof req.body.size !== 'string') return next(new HttpError(400, 'size must be string'));
if (typeof req.body.region !== 'string') return next(new HttpError(400, 'region must be string'));
debug('Migration requested', req.body.size, req.body.region);
cloudron.migrate(req.body.size, req.body.region, function (error) {
if (error && error.reason === CloudronError.BAD_STATE) return next(new HttpError(409, error.message));
if (error) return next(new HttpError(500, error));
next(new HttpSuccess(202, {}));
});
}
function setCertificate(req, res, next) {
assert.strictEqual(typeof req.files, 'object');
if (!req.files.certificate) return next(new HttpError(400, 'certificate must be provided'));
var certificate = safe.fs.readFileSync(req.files.certificate.path, 'utf8');
if (!req.files.key) return next(new HttpError(400, 'key must be provided'));
var key = safe.fs.readFileSync(req.files.key.path, 'utf8');
cloudron.setCertificate(certificate, key, function (error) {
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(202, {}));
});
}

48
src/routes/developer.js Normal file
View File

@@ -0,0 +1,48 @@
/* jslint node:true */
'use strict';
exports = module.exports = {
enabled: enabled,
setEnabled: setEnabled,
status: status,
login: login
};
var developer = require('../developer.js'),
passport = require('passport'),
HttpError = require('connect-lastmile').HttpError,
HttpSuccess = require('connect-lastmile').HttpSuccess;
function enabled(req, res, next) {
developer.enabled(function (error, enabled) {
if (enabled) return next();
next(new HttpError(412, 'Developer mode not enabled'));
});
}
function setEnabled(req, res, next) {
if (typeof req.body.enabled !== 'boolean') return next(new HttpError(400, 'enabled must be boolean'));
developer.setEnabled(req.body.enabled, function (error) {
if (error) return next(new HttpError(500, error));
next(new HttpSuccess(200, {}));
});
}
function status(req, res, next) {
next(new HttpSuccess(200, {}));
}
function login(req, res, next) {
passport.authenticate('local', function (error, user) {
if (error) return next(new HttpError(500, error));
if (!user) return next(new HttpError(401, 'Invalid credentials'));
developer.issueDeveloperToken(user, function (error, result) {
if (error) return next(new HttpError(500, error));
next(new HttpSuccess(200, { token: result.token, expiresAt: result.expiresAt }));
});
})(req, res, next);
}

21
src/routes/graphs.js Normal file
View File

@@ -0,0 +1,21 @@
'use strict';
exports = module.exports = {
getGraphs: getGraphs
};
var middleware = require('../middleware/index.js'),
url = require('url');
var graphiteProxy = middleware.proxy(url.parse('http://127.0.0.1:8000'));
function getGraphs(req, res, next) {
var parsedUrl = url.parse(req.url, true /* parseQueryString */);
delete parsedUrl.query['access_token'];
delete req.headers['authorization'];
delete req.headers['cookies'];
req.url = url.format({ pathname: 'render', query: parsedUrl.query });
graphiteProxy(req, res, next);
}

15
src/routes/index.js Normal file
View File

@@ -0,0 +1,15 @@
'use strict';
exports = module.exports = {
apps: require('./apps.js'),
cloudron: require('./cloudron.js'),
developer: require('./developer.js'),
graphs: require('./graphs.js'),
oauth2: require('./oauth2.js'),
settings: require('./settings.js'),
clients: require('./clients.js'),
backups: require('./backups.js'),
internal: require('./internal.js'),
user: require('./user.js')
};

23
src/routes/internal.js Normal file
View File

@@ -0,0 +1,23 @@
/* jslint node:true */
'use strict';
exports = module.exports = {
backup: backup
};
var cloudron = require('../cloudron.js'),
debug = require('debug')('box:routes/internal'),
HttpError = require('connect-lastmile').HttpError,
HttpSuccess = require('connect-lastmile').HttpSuccess;
function backup(req, res, next) {
debug('trigger backup');
cloudron.backup(function (error) {
if (error) debug('Internal route backup failed', error);
});
// we always succeed to trigger a backup
next(new HttpSuccess(202, {}));
}

503
src/routes/oauth2.js Normal file
View File

@@ -0,0 +1,503 @@
/* jslint node:true */
'use strict';
var assert = require('assert'),
authcodedb = require('../authcodedb'),
clientdb = require('../clientdb'),
config = require('../config.js'),
constants = require('../constants.js'),
DatabaseError = require('../databaseerror'),
debug = require('debug')('box:routes/oauth2'),
HttpError = require('connect-lastmile').HttpError,
middleware = require('../middleware/index.js'),
oauth2orize = require('oauth2orize'),
passport = require('passport'),
querystring = require('querystring'),
util = require('util'),
session = require('connect-ensure-login'),
tokendb = require('../tokendb'),
appdb = require('../appdb'),
url = require('url'),
user = require('../user.js'),
UserError = user.UserError,
hat = require('hat');
// create OAuth 2.0 server
var gServer = oauth2orize.createServer();
// Register serialialization and deserialization functions.
//
// When a client redirects a user to user authorization endpoint, an
// authorization transaction is initiated. To complete the transaction, the
// user must authenticate and approve the authorization request. Because this
// may involve multiple HTTP request/response exchanges, the transaction is
// stored in the session.
//
// An application must supply serialization functions, which determine how the
// client object is serialized into the session. Typically this will be a
// simple matter of serializing the client's ID, and deserializing by finding
// the client by ID from the database.
gServer.serializeClient(function (client, callback) {
debug('server serialize:', client);
return callback(null, client.id);
});
gServer.deserializeClient(function (id, callback) {
debug('server deserialize:', id);
clientdb.get(id, function (error, client) {
if (error) { return callback(error); }
return callback(null, client);
});
});
// Register supported grant types.
// Grant authorization codes. The callback takes the `client` requesting
// authorization, the `redirectURI` (which is used as a verifier in the
// subsequent exchange), the authenticated `user` granting access, and
// their response, which contains approved scope, duration, etc. as parsed by
// the application. The application issues a code, which is bound to these
// values, and will be exchanged for an access token.
// we use , (comma) as scope separator
gServer.grant(oauth2orize.grant.code({ scopeSeparator: ',' }, function (client, redirectURI, user, ares, callback) {
debug('grant code:', client, redirectURI, user.id, ares);
var code = hat(256);
var expiresAt = Date.now() + 60 * 60000; // 1 hour
var scopes = client.scope ? client.scope.split(',') : ['profile','roleUser'];
if (scopes.indexOf('roleAdmin') !== -1 && !user.admin) {
debug('grant code: not allowed, you need to be admin');
return callback(new Error('Admin capabilities required'));
}
authcodedb.add(code, client.id, user.username, expiresAt, function (error) {
if (error) return callback(error);
callback(null, code);
});
}));
gServer.grant(oauth2orize.grant.token({ scopeSeparator: ',' }, function (client, user, ares, callback) {
debug('grant token:', client.id, user.id, ares);
var token = tokendb.generateToken();
var expires = Date.now() + 24 * 60 * 60 * 1000; // 1 day
tokendb.add(token, tokendb.PREFIX_USER + user.id, client.id, expires, client.scope, function (error) {
if (error) return callback(error);
debug('new access token for client ' + client.id + ' token ' + token);
callback(null, token);
});
}));
// Exchange authorization codes for access tokens. The callback accepts the
// `client`, which is exchanging `code` and any `redirectURI` from the
// authorization request for verification. If these values are validated, the
// application issues an access token on behalf of the user who authorized the
// code.
gServer.exchange(oauth2orize.exchange.code(function (client, code, redirectURI, callback) {
debug('exchange:', client, code, redirectURI);
authcodedb.get(code, function (error, authCode) {
if (error && error.reason === DatabaseError.NOT_FOUND) return callback(null, false);
if (error) return callback(error);
if (client.id !== authCode.clientId) return callback(null, false);
authcodedb.del(code, function (error) {
if(error) return callback(error);
var token = tokendb.generateToken();
var expires = Date.now() + 24 * 60 * 60 * 1000; // 1 day
tokendb.add(token, tokendb.PREFIX_USER + authCode.userId, authCode.clientId, expires, client.scope, function (error) {
if (error) return callback(error);
debug('new access token for client ' + client.id + ' token ' + token);
callback(null, token);
});
});
});
}));
// overwrite the session.ensureLoggedIn to not use res.redirect() due to a chrome bug not sending cookies on redirects
session.ensureLoggedIn = function (redirectTo) {
assert.strictEqual(typeof redirectTo, 'string');
return function (req, res, next) {
if (!req.isAuthenticated || !req.isAuthenticated()) {
if (req.session) {
req.session.returnTo = req.originalUrl || req.url;
}
res.status(200).send(util.format('<script>window.location.href = "%s";</script>', redirectTo));
} else {
next();
}
};
};
function sendErrorPageOrRedirect(req, res, message) {
assert.strictEqual(typeof req, 'object');
assert.strictEqual(typeof res, 'object');
assert.strictEqual(typeof message, 'string');
debug('sendErrorPageOrRedirect: returnTo "%s".', req.query.returnTo, message);
if (typeof req.query.returnTo !== 'string') {
res.render('error', {
adminOrigin: config.adminOrigin(),
message: message
});
} else {
var u = url.parse(req.query.returnTo);
if (!u.protocol || !u.host) return res.render('error', {
adminOrigin: config.adminOrigin(),
message: 'Invalid request. returnTo query is not a valid URI. ' + message
});
res.redirect(util.format('%s//%s', u.protocol, u.host));
}
}
function sendError(req, res, message) {
assert.strictEqual(typeof req, 'object');
assert.strictEqual(typeof res, 'object');
assert.strictEqual(typeof message, 'string');
res.render('error', {
adminOrigin: config.adminOrigin(),
message: message
});
}
// Main login form username and password
function loginForm(req, res) {
if (typeof req.session.returnTo !== 'string') return sendErrorPageOrRedirect(req, res, 'Invalid login request. No returnTo provided.');
var u = url.parse(req.session.returnTo, true);
if (!u.query.client_id) return sendErrorPageOrRedirect(req, res, 'Invalid login request. No client_id provided.');
function render(applicationName) {
res.render('login', {
adminOrigin: config.adminOrigin(),
csrf: req.csrfToken(),
applicationName: applicationName,
error: req.query.error || null
});
}
clientdb.get(u.query.client_id, function (error, result) {
if (error) return sendError(req, res, 'Unknown OAuth client');
// Handle our different types of oauth clients
var appId = result.appId;
if (appId === constants.ADMIN_CLIENT_ID) {
return render(constants.ADMIN_NAME);
} else if (appId === constants.TEST_CLIENT_ID) {
return render(constants.TEST_NAME);
} else if (appId.indexOf('external-') === 0) {
return render('External Application');
} else if (appId.indexOf('addon-') === 0) {
appId = appId.slice('addon-'.length);
} else if (appId.indexOf('proxy-') === 0) {
appId = appId.slice('proxy-'.length);
}
appdb.get(appId, function (error, result) {
if (error) return sendErrorPageOrRedirect(req, res, 'Unknown Application for those OAuth credentials');
var applicationName = result.location || config.fqdn();
render(applicationName);
});
});
}
// performs the login POST from the login form
function login(req, res) {
var returnTo = req.session.returnTo || req.query.returnTo;
var failureQuery = querystring.stringify({ error: 'Invalid username or password', returnTo: returnTo });
passport.authenticate('local', {
failureRedirect: '/api/v1/session/login?' + failureQuery
})(req, res, function () {
res.redirect(returnTo);
});
}
// ends the current session
function logout(req, res) {
req.logout();
if (req.query && req.query.redirect) res.redirect(req.query.redirect);
else res.redirect('/');
}
// Form to enter email address to send a password reset request mail
// -> GET /api/v1/session/password/resetRequest.html
function passwordResetRequestSite(req, res) {
res.render('password_reset_request', { adminOrigin: config.adminOrigin(), csrf: req.csrfToken() });
}
// This route is used for above form submission
// -> POST /api/v1/session/password/resetRequest
function passwordResetRequest(req, res, next) {
assert.strictEqual(typeof req.body, 'object');
if (typeof req.body.identifier !== 'string') return next(new HttpError(400, 'Missing identifier'));
debug('passwordResetRequest: email or username %s.', req.body.identifier);
user.resetPasswordByIdentifier(req.body.identifier, function (error) {
if (error && error.reason !== UserError.NOT_FOUND) {
console.error(error);
return sendErrorPageOrRedirect(req, res, 'User not found');
}
res.redirect('/api/v1/session/password/sent.html');
});
}
// -> GET /api/v1/session/password/sent.html
function passwordSentSite(req, res) {
res.render('password_reset_sent', { adminOrigin: config.adminOrigin() });
}
// -> GET /api/v1/session/password/setup.html
function passwordSetupSite(req, res, next) {
if (!req.query.reset_token) return next(new HttpError(400, 'Missing reset_token'));
debug('passwordSetupSite: with token %s.', req.query.reset_token);
user.getByResetToken(req.query.reset_token, function (error, user) {
if (error) return next(new HttpError(401, 'Invalid reset_token'));
res.render('password_setup', { adminOrigin: config.adminOrigin(), user: user, csrf: req.csrfToken(), resetToken: req.query.reset_token });
});
}
// -> GET /api/v1/session/password/reset.html
function passwordResetSite(req, res, next) {
if (!req.query.reset_token) return next(new HttpError(400, 'Missing reset_token'));
debug('passwordResetSite: with token %s.', req.query.reset_token);
user.getByResetToken(req.query.reset_token, function (error, user) {
if (error) return next(new HttpError(401, 'Invalid reset_token'));
res.render('password_reset', { adminOrigin: config.adminOrigin(), user: user, csrf: req.csrfToken(), resetToken: req.query.reset_token });
});
}
// -> POST /api/v1/session/password/reset
function passwordReset(req, res, next) {
assert.strictEqual(typeof req.body, 'object');
if (typeof req.body.resetToken !== 'string') return next(new HttpError(400, 'Missing resetToken'));
if (typeof req.body.password !== 'string') return next(new HttpError(400, 'Missing password'));
debug('passwordReset: with token %s.', req.body.resetToken);
user.getByResetToken(req.body.resetToken, function (error, userObject) {
if (error) return next(new HttpError(401, 'Invalid resetToken'));
// setPassword clears the resetToken
user.setPassword(userObject.id, req.body.password, function (error, result) {
if (error) return next(new HttpError(500, error));
res.redirect(util.format('%s?accessToken=%s&expiresAt=%s', config.adminOrigin(), result.token, result.expiresAt));
});
});
}
/*
The callback page takes the redirectURI and the authCode and redirects the browser accordingly
*/
var callback = [
session.ensureLoggedIn('/api/v1/session/login'),
function (req, res) {
debug('callback: with callback server ' + req.query.redirectURI);
res.render('callback', { adminOrigin: config.adminOrigin(), callbackServer: req.query.redirectURI });
}
];
/*
This indicates a missing OAuth client session or invalid client ID
*/
var error = [
session.ensureLoggedIn('/api/v1/session/login'),
function (req, res) {
sendErrorPageOrRedirect(req, res, 'Invalid OAuth Client');
}
];
/*
The authorization endpoint is the entry point for an OAuth login.
Each app would start OAuth by redirecting the user to:
/api/v1/oauth/dialog/authorize?response_type=code&client_id=<clientId>&redirect_uri=<callbackURL>&scope=<ignored>
- First, this will ensure the user is logged in.
- Then in normal OAuth it would ask the user for permissions to the scopes, which we will do on app installation
- Then it will redirect the browser to the given <callbackURL> containing the authcode in the query
Scopes are set by the app during installation, the ones given on OAuth transaction start are simply ignored.
*/
var authorization = [
// extract the returnTo origin and set as query param
function (req, res, next) {
if (!req.query.redirect_uri) return sendErrorPageOrRedirect(req, res, 'Invalid request. redirect_uri query param is not set.');
if (!req.query.client_id) return sendErrorPageOrRedirect(req, res, 'Invalid request. client_id query param is not set.');
if (!req.query.response_type) return sendErrorPageOrRedirect(req, res, 'Invalid request. response_type query param is not set.');
if (req.query.response_type !== 'code' && req.query.response_type !== 'token') return sendErrorPageOrRedirect(req, res, 'Invalid request. Only token and code response types are supported.');
session.ensureLoggedIn('/api/v1/session/login?returnTo=' + req.query.redirect_uri)(req, res, next);
},
gServer.authorization(function (clientID, redirectURI, callback) {
debug('authorization: client %s with callback to %s.', clientID, redirectURI);
clientdb.get(clientID, function (error, client) {
if (error && error.reason === DatabaseError.NOT_FOUND) return callback(null, false);
if (error) return callback(error);
// ignore the origin passed into form the client, but use the one from the clientdb
var redirectPath = url.parse(redirectURI).path;
var redirectOrigin = client.redirectURI;
callback(null, client, '/api/v1/session/callback?redirectURI=' + url.resolve(redirectOrigin, redirectPath));
});
}),
// Until we have OAuth scopes, skip decision dialog
// OAuth sopes skip START
function (req, res, next) {
assert.strictEqual(typeof req.body, 'object');
assert.strictEqual(typeof req.oauth2, 'object');
var scopes = req.oauth2.client.scope ? req.oauth2.client.scope.split(',') : ['profile','roleUser'];
if (scopes.indexOf('roleAdmin') !== -1 && !req.user.admin) {
return sendErrorPageOrRedirect(req, res, 'Admin capabilities required');
}
req.body.transaction_id = req.oauth2.transactionID;
next();
},
gServer.decision(function(req, done) {
debug('decision: with scope', req.oauth2.req.scope);
return done(null, { scope: req.oauth2.req.scope });
})
// OAuth sopes skip END
// function (req, res) {
// res.render('dialog', { transactionID: req.oauth2.transactionID, user: req.user, client: req.oauth2.client, csrf: req.csrfToken() });
// }
];
// this triggers the above grant middleware and handles the user's decision if he accepts the access
var decision = [
session.ensureLoggedIn('/api/v1/session/login'),
gServer.decision()
];
/*
The token endpoint allows an OAuth client to exchange an authcode with an accesstoken.
Authcodes are obtained using the authorization endpoint. The route is authenticated by
providing a Basic auth with clientID as username and clientSecret as password.
An authcode is only good for one such exchange to an accesstoken.
*/
var token = [
passport.authenticate(['basic', 'oauth2-client-password'], { session: false }),
gServer.token(),
gServer.errorHandler()
];
/*
The scope middleware provides an auth middleware for routes.
It is used for API routes, which are authenticated using accesstokens.
Those accesstokens carry OAuth scopes and the middleware takes the required
scope as an argument and will verify the accesstoken against it.
See server.js:
var profileScope = routes.oauth2.scope('profile');
*/
function scope(requestedScope) {
assert.strictEqual(typeof requestedScope, 'string');
var requestedScopes = requestedScope.split(',');
debug('scope: add routes with requested scopes', requestedScopes);
return [
passport.authenticate(['bearer'], { session: false }),
function (req, res, next) {
if (!req.authInfo || !req.authInfo.scope) return next(new HttpError(401, 'No scope found'));
if (req.authInfo.scope === '*') return next();
var scopes = req.authInfo.scope.split(',');
for (var i = 0; i < requestedScopes.length; ++i) {
if (scopes.indexOf(requestedScopes[i]) === -1) {
debug('scope: missing scope "%s".', requestedScopes[i]);
return next(new HttpError(401, 'Missing required scope "' + requestedScopes[i] + '"'));
}
}
next();
}
];
}
// Cross-site request forgery protection middleware for login form
var csrf = [
middleware.csrf(),
function (err, req, res, next) {
if (err.code !== 'EBADCSRFTOKEN') return next(err);
sendErrorPageOrRedirect(req, res, 'Form expired');
}
];
exports = module.exports = {
loginForm: loginForm,
login: login,
logout: logout,
callback: callback,
error: error,
passwordResetRequestSite: passwordResetRequestSite,
passwordResetRequest: passwordResetRequest,
passwordSentSite: passwordSentSite,
passwordResetSite: passwordResetSite,
passwordSetupSite: passwordSetupSite,
passwordReset: passwordReset,
authorization: authorization,
decision: decision,
token: token,
scope: scope,
csrf: csrf
};

82
src/routes/settings.js Normal file
View File

@@ -0,0 +1,82 @@
/* jslint node:true */
'use strict';
exports = module.exports = {
getAutoupdatePattern: getAutoupdatePattern,
setAutoupdatePattern: setAutoupdatePattern,
getCloudronName: getCloudronName,
setCloudronName: setCloudronName,
getCloudronAvatar: getCloudronAvatar,
setCloudronAvatar: setCloudronAvatar
};
var assert = require('assert'),
HttpError = require('connect-lastmile').HttpError,
HttpSuccess = require('connect-lastmile').HttpSuccess,
safe = require('safetydance'),
settings = require('../settings.js'),
SettingsError = settings.SettingsError;
function getAutoupdatePattern(req, res, next) {
settings.getAutoupdatePattern(function (error, pattern) {
if (error) return next(new HttpError(500, error));
next(new HttpSuccess(200, { pattern: pattern }));
});
}
function setAutoupdatePattern(req, res, next) {
assert.strictEqual(typeof req.body, 'object');
if (typeof req.body.pattern !== 'string') return next(new HttpError(400, 'pattern is required'));
settings.setAutoupdatePattern(req.body.pattern, function (error) {
if (error && error.reason === SettingsError.BAD_FIELD) return next(new HttpError(400, 'Invalid pattern'));
if (error) return next(new HttpError(500, error));
next(new HttpSuccess(200));
});
}
function setCloudronName(req, res, next) {
assert.strictEqual(typeof req.body, 'object');
if (typeof req.body.name !== 'string') return next(new HttpError(400, 'name is required'));
settings.setCloudronName(req.body.name, function (error) {
if (error && error.reason === SettingsError.BAD_FIELD) return next(new HttpError(400, 'Invalid name'));
if (error) return next(new HttpError(500, error));
next(new HttpSuccess(200));
});
}
function getCloudronName(req, res, next) {
settings.getCloudronName(function (error, name) {
if (error) return next(new HttpError(500, error));
next(new HttpSuccess(200, { name: name }));
});
}
function setCloudronAvatar(req, res, next) {
assert.strictEqual(typeof req.files, 'object');
if (!req.files.avatar) return next(new HttpError(400, 'avatar must be provided'));
var avatar = safe.fs.readFileSync(req.files.avatar.path);
settings.setCloudronAvatar(avatar, function (error) {
if (error) return next(new HttpError(500, error));
next(new HttpSuccess(202, {}));
});
}
function getCloudronAvatar(req, res, next) {
settings.getCloudronAvatar(function (error, avatar) {
if (error) return next(new HttpError(500, error));
res.set('Content-Type', 'image/png');
res.status(200).send(avatar);
});
}

1319
src/routes/test/apps-test.js Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,144 @@
/* jslint node:true */
/* global it:false */
/* global describe:false */
/* global before:false */
/* global after:false */
'use strict';
var appdb = require('../../appdb.js'),
async = require('async'),
config = require('../../config.js'),
database = require('../../database.js'),
expect = require('expect.js'),
request = require('superagent'),
server = require('../../server.js'),
nock = require('nock'),
userdb = require('../../userdb.js');
var SERVER_URL = 'http://localhost:' + config.get('port');
var USERNAME = 'admin', PASSWORD = 'password', EMAIL ='silly@me.com';
var token = null;
var server;
function setup(done) {
async.series([
server.start.bind(server),
userdb._clear,
function createAdmin(callback) {
var scope1 = nock(config.apiServerOrigin()).get('/api/v1/boxes/' + config.fqdn() + '/setup/verify?setupToken=somesetuptoken').reply(200, {});
var scope2 = nock(config.apiServerOrigin()).post('/api/v1/boxes/' + config.fqdn() + '/setup/done?setupToken=somesetuptoken').reply(201, {});
request.post(SERVER_URL + '/api/v1/cloudron/activate')
.query({ setupToken: 'somesetuptoken' })
.send({ username: USERNAME, password: PASSWORD, email: EMAIL })
.end(function (error, result) {
expect(error).to.not.be.ok();
expect(result).to.be.ok();
expect(result.statusCode).to.eql(201);
expect(scope1.isDone()).to.be.ok();
expect(scope2.isDone()).to.be.ok();
// stash token for further use
token = result.body.token;
callback();
});
},
function addApp(callback) {
var manifest = { version: '0.0.1', manifestVersion: 1, dockerImage: 'foo', healthCheckPath: '/', httpPort: 3, title: 'ok' };
appdb.add('appid', 'appStoreId', manifest, 'location', [ ] /* portBindings */, '' /* accessRestriction */, callback);
}
], done);
}
function cleanup(done) {
database._clear(function (error) {
expect(!error).to.be.ok();
server.stop(done);
});
}
describe('Backups API', function () {
before(setup);
after(cleanup);
describe('get', function () {
it('cannot get backups with appstore request failing', function (done) {
var req = nock(config.apiServerOrigin()).get('/api/v1/boxes/' + config.fqdn() + '/backups?token=APPSTORE_TOKEN').reply(401, {});
request.get(SERVER_URL + '/api/v1/backups')
.query({ access_token: token })
.end(function (err, res) {
expect(res.statusCode).to.equal(503);
expect(req.isDone()).to.be.ok();
done(err);
});
});
it('can get backups', function (done) {
var req = nock(config.apiServerOrigin()).get('/api/v1/boxes/' + config.fqdn() + '/backups?token=APPSTORE_TOKEN').reply(200, { backups: ['foo', 'bar']});
request.get(SERVER_URL + '/api/v1/backups')
.query({ access_token: token })
.end(function (err, res) {
expect(res.statusCode).to.equal(200);
expect(req.isDone()).to.be.ok();
expect(res.body.backups).to.be.an(Array);
expect(res.body.backups[0]).to.eql('foo');
expect(res.body.backups[1]).to.eql('bar');
done(err);
});
});
});
describe('create', function () {
it('fails due to mising token', function (done) {
request.post(SERVER_URL + '/api/v1/backups')
.end(function (error, result) {
expect(error).to.not.be.ok();
expect(result.statusCode).to.equal(401);
done();
});
});
it('fails due to wrong token', function (done) {
request.post(SERVER_URL + '/api/v1/backups')
.query({ access_token: token.toUpperCase() })
.end(function (error, result) {
expect(error).to.not.be.ok();
expect(result.statusCode).to.equal(401);
done();
});
});
it('succeeds', function (done) {
var scope = nock(config.apiServerOrigin())
.put('/api/v1/boxes/' + config.fqdn() + '/backupurl?token=APPSTORE_TOKEN', { boxVersion: '0.5.0', appId: null, appVersion: null, appBackupIds: [] })
.reply(201, { id: 'someid', url: 'http://foobar', backupKey: 'somerestorekey' });
request.post(SERVER_URL + '/api/v1/backups')
.query({ access_token: token })
.end(function (error, result) {
expect(error).to.not.be.ok();
expect(result.statusCode).to.equal(202);
function checkAppstoreServerCalled() {
if (scope.isDone()) {
return done();
}
setTimeout(checkAppstoreServerCalled, 100);
}
checkAppstoreServerCalled();
});
});
});
});

View File

@@ -0,0 +1,817 @@
'use strict';
/* jslint node:true */
/* global it:false */
/* global describe:false */
/* global before:false */
/* global after:false */
var async = require('async'),
config = require('../../config.js'),
database = require('../../database.js'),
oauth2 = require('../oauth2.js'),
expect = require('expect.js'),
uuid = require('node-uuid'),
nock = require('nock'),
hat = require('hat'),
superagent = require('superagent'),
server = require('../../server.js');
var SERVER_URL = 'http://localhost:' + config.get('port');
var USERNAME = 'admin', PASSWORD = 'password', EMAIL ='silly@me.com';
var token = null; // authentication token
function cleanup(done) {
database._clear(function (error) {
expect(error).to.not.be.ok();
server.stop(done);
});
}
describe('OAuth Clients API', function () {
describe('add', function () {
before(function (done) {
async.series([
server.start.bind(null),
database._clear.bind(null),
function (callback) {
var scope1 = nock(config.apiServerOrigin()).get('/api/v1/boxes/' + config.fqdn() + '/setup/verify?setupToken=somesetuptoken').reply(200, {});
var scope2 = nock(config.apiServerOrigin()).post('/api/v1/boxes/' + config.fqdn() + '/setup/done?setupToken=somesetuptoken').reply(201, {});
superagent.post(SERVER_URL + '/api/v1/cloudron/activate')
.query({ setupToken: 'somesetuptoken' })
.send({ username: USERNAME, password: PASSWORD, email: EMAIL })
.end(function (error, result) {
expect(error).to.not.be.ok();
expect(result).to.be.ok();
expect(result.statusCode).to.equal(201);
expect(scope1.isDone()).to.be.ok();
expect(scope2.isDone()).to.be.ok();
// stash token for further use
token = result.body.token;
callback();
});
},
], done);
});
after(cleanup);
it('fails without token', function (done) {
config.set('developerMode', true);
superagent.post(SERVER_URL + '/api/v1/oauth/clients')
.send({ appId: 'someApp', redirectURI: 'http://foobar.com', scope: 'profile,roleUser' })
.end(function (error, result) {
expect(error).to.not.be.ok();
expect(result.statusCode).to.equal(401);
done();
});
});
it('fails if not in developerMode', function (done) {
config.set('developerMode', false);
superagent.post(SERVER_URL + '/api/v1/oauth/clients')
.query({ access_token: token })
.send({ appId: 'someApp', redirectURI: 'http://foobar.com', scope: 'profile,roleUser' })
.end(function (error, result) {
expect(error).to.not.be.ok();
expect(result.statusCode).to.equal(412);
done();
});
});
it('fails without appId', function (done) {
config.set('developerMode', true);
superagent.post(SERVER_URL + '/api/v1/oauth/clients')
.query({ access_token: token })
.send({ redirectURI: 'http://foobar.com', scope: 'profile,roleUser' })
.end(function (error, result) {
expect(error).to.not.be.ok();
expect(result.statusCode).to.equal(400);
done();
});
});
it('fails with empty appId', function (done) {
config.set('developerMode', true);
superagent.post(SERVER_URL + '/api/v1/oauth/clients')
.query({ access_token: token })
.send({ appId: '', redirectURI: 'http://foobar.com', scope: 'profile,roleUser' })
.end(function (error, result) {
expect(error).to.not.be.ok();
expect(result.statusCode).to.equal(400);
done();
});
});
it('fails without scope', function (done) {
config.set('developerMode', true);
superagent.post(SERVER_URL + '/api/v1/oauth/clients')
.query({ access_token: token })
.send({ appId: 'someApp', redirectURI: 'http://foobar.com' })
.end(function (error, result) {
expect(error).to.not.be.ok();
expect(result.statusCode).to.equal(400);
done();
});
});
it('fails with empty scope', function (done) {
config.set('developerMode', true);
superagent.post(SERVER_URL + '/api/v1/oauth/clients')
.query({ access_token: token })
.send({ appId: 'someApp', redirectURI: 'http://foobar.com', scope: '' })
.end(function (error, result) {
expect(error).to.not.be.ok();
expect(result.statusCode).to.equal(400);
done();
});
});
it('fails without redirectURI', function (done) {
config.set('developerMode', true);
superagent.post(SERVER_URL + '/api/v1/oauth/clients')
.query({ access_token: token })
.send({ appId: 'someApp', scope: 'profile,roleUser' })
.end(function (error, result) {
expect(error).to.not.be.ok();
expect(result.statusCode).to.equal(400);
done();
});
});
it('fails with empty redirectURI', function (done) {
config.set('developerMode', true);
superagent.post(SERVER_URL + '/api/v1/oauth/clients')
.query({ access_token: token })
.send({ appId: 'someApp', redirectURI: '', scope: 'profile,roleUser' })
.end(function (error, result) {
expect(error).to.not.be.ok();
expect(result.statusCode).to.equal(400);
done();
});
});
it('fails with malformed redirectURI', function (done) {
config.set('developerMode', true);
superagent.post(SERVER_URL + '/api/v1/oauth/clients')
.query({ access_token: token })
.send({ appId: 'someApp', redirectURI: 'foobar', scope: 'profile,roleUser' })
.end(function (error, result) {
expect(error).to.not.be.ok();
expect(result.statusCode).to.equal(400);
done();
});
});
it('succeeds', function (done) {
config.set('developerMode', true);
superagent.post(SERVER_URL + '/api/v1/oauth/clients')
.query({ access_token: token })
.send({ appId: 'someApp', redirectURI: 'http://foobar.com', scope: 'profile,roleUser' })
.end(function (error, result) {
expect(error).to.not.be.ok();
expect(result.statusCode).to.equal(201);
expect(result.body.id).to.be.a('string');
expect(result.body.appId).to.be.a('string');
expect(result.body.redirectURI).to.be.a('string');
expect(result.body.clientSecret).to.be.a('string');
expect(result.body.scope).to.be.a('string');
done();
});
});
});
describe('get', function () {
var CLIENT_0 = {
id: '',
appId: 'someAppId-0',
redirectURI: 'http://some.callback0',
scope: 'profile,roleUser'
};
before(function (done) {
async.series([
server.start.bind(null),
database._clear.bind(null),
function (callback) {
var scope1 = nock(config.apiServerOrigin()).get('/api/v1/boxes/' + config.fqdn() + '/setup/verify?setupToken=somesetuptoken').reply(200, {});
var scope2 = nock(config.apiServerOrigin()).post('/api/v1/boxes/' + config.fqdn() + '/setup/done?setupToken=somesetuptoken').reply(201, {});
superagent.post(SERVER_URL + '/api/v1/cloudron/activate')
.query({ setupToken: 'somesetuptoken' })
.send({ username: USERNAME, password: PASSWORD, email: EMAIL })
.end(function (error, result) {
expect(error).to.not.be.ok();
expect(result).to.be.ok();
expect(scope1.isDone()).to.be.ok();
expect(scope2.isDone()).to.be.ok();
// stash token for further use
token = result.body.token;
callback();
});
},
function (callback) {
config.set('developerMode', true);
superagent.post(SERVER_URL + '/api/v1/oauth/clients')
.query({ access_token: token })
.send({ appId: CLIENT_0.appId, redirectURI: CLIENT_0.redirectURI, scope: CLIENT_0.scope })
.end(function (error, result) {
expect(error).to.not.be.ok();
expect(result.statusCode).to.equal(201);
CLIENT_0 = result.body;
callback();
});
}
], done);
});
after(cleanup);
it('fails without token', function (done) {
config.set('developerMode', true);
superagent.get(SERVER_URL + '/api/v1/oauth/clients/' + CLIENT_0.id)
.end(function (error, result) {
expect(error).to.not.be.ok();
expect(result.statusCode).to.equal(401);
done();
});
});
it('fails if not in developerMode', function (done) {
config.set('developerMode', false);
superagent.get(SERVER_URL + '/api/v1/oauth/clients/' + CLIENT_0.id)
.query({ access_token: token })
.end(function (error, result) {
expect(error).to.not.be.ok();
expect(result.statusCode).to.equal(412);
done();
});
});
it('fails with unknown id', function (done) {
config.set('developerMode', true);
superagent.get(SERVER_URL + '/api/v1/oauth/clients/' + CLIENT_0.id.toUpperCase())
.query({ access_token: token })
.end(function (error, result) {
expect(error).to.not.be.ok();
expect(result.statusCode).to.equal(404);
done();
});
});
it('succeeds', function (done) {
config.set('developerMode', true);
superagent.get(SERVER_URL + '/api/v1/oauth/clients/' + CLIENT_0.id)
.query({ access_token: token })
.end(function (error, result) {
expect(error).to.not.be.ok();
expect(result.statusCode).to.equal(200);
expect(result.body).to.eql(CLIENT_0);
done();
});
});
});
describe('update', function () {
var CLIENT_0 = {
id: '',
appId: 'someAppId-0',
redirectURI: 'http://some.callback0',
scope: 'profile,roleUser'
};
var CLIENT_1 = {
id: '',
appId: 'someAppId-1',
redirectURI: 'http://some.callback1',
scope: 'profile,roleUser'
};
before(function (done) {
async.series([
server.start.bind(null),
database._clear.bind(null),
function (callback) {
var scope1 = nock(config.apiServerOrigin()).get('/api/v1/boxes/' + config.fqdn() + '/setup/verify?setupToken=somesetuptoken').reply(200, {});
var scope2 = nock(config.apiServerOrigin()).post('/api/v1/boxes/' + config.fqdn() + '/setup/done?setupToken=somesetuptoken').reply(201, {});
superagent.post(SERVER_URL + '/api/v1/cloudron/activate')
.query({ setupToken: 'somesetuptoken' })
.send({ username: USERNAME, password: PASSWORD, email: EMAIL })
.end(function (error, result) {
expect(error).to.not.be.ok();
expect(result).to.be.ok();
expect(result.statusCode).to.equal(201);
expect(scope1.isDone()).to.be.ok();
expect(scope2.isDone()).to.be.ok();
// stash token for further use
token = result.body.token;
callback();
});
},
function (callback) {
config.set('developerMode', true);
superagent.post(SERVER_URL + '/api/v1/oauth/clients')
.query({ access_token: token })
.send({ appId: CLIENT_0.appId, redirectURI: CLIENT_0.redirectURI, scope: CLIENT_0.scope })
.end(function (error, result) {
expect(error).to.not.be.ok();
expect(result.statusCode).to.equal(201);
CLIENT_0 = result.body;
callback();
});
}
], done);
});
after(cleanup);
it('fails without token', function (done) {
config.set('developerMode', true);
superagent.put(SERVER_URL + '/api/v1/oauth/clients/' + CLIENT_0.id)
.send({ appId: 'someApp', redirectURI: 'http://foobar.com' })
.end(function (error, result) {
expect(error).to.not.be.ok();
expect(result.statusCode).to.equal(401);
done();
});
});
it('fails if not in developerMode', function (done) {
config.set('developerMode', false);
superagent.put(SERVER_URL + '/api/v1/oauth/clients/' + CLIENT_0.id)
.query({ access_token: token })
.send({ appId: CLIENT_1.appId, redirectURI: CLIENT_1.redirectURI })
.end(function (error, result) {
expect(error).to.not.be.ok();
expect(result.statusCode).to.equal(412);
done();
});
});
it('fails without appId', function (done) {
config.set('developerMode', true);
superagent.put(SERVER_URL + '/api/v1/oauth/clients/' + CLIENT_0.id)
.query({ access_token: token })
.send({ redirectURI: CLIENT_1.redirectURI })
.end(function (error, result) {
expect(error).to.not.be.ok();
expect(result.statusCode).to.equal(400);
done();
});
});
it('fails with empty appId', function (done) {
config.set('developerMode', true);
superagent.put(SERVER_URL + '/api/v1/oauth/clients/' + CLIENT_0.id)
.query({ access_token: token })
.send({ appId: '', redirectURI: CLIENT_1.redirectURI })
.end(function (error, result) {
expect(error).to.not.be.ok();
expect(result.statusCode).to.equal(400);
done();
});
});
it('fails without redirectURI', function (done) {
config.set('developerMode', true);
superagent.put(SERVER_URL + '/api/v1/oauth/clients/' + CLIENT_0.id)
.query({ access_token: token })
.send({ appId: CLIENT_1.appId })
.end(function (error, result) {
expect(error).to.not.be.ok();
expect(result.statusCode).to.equal(400);
done();
});
});
it('fails with empty redirectURI', function (done) {
config.set('developerMode', true);
superagent.put(SERVER_URL + '/api/v1/oauth/clients/' + CLIENT_0.id)
.query({ access_token: token })
.send({ appId: CLIENT_1.appId, redirectURI: '' })
.end(function (error, result) {
expect(error).to.not.be.ok();
expect(result.statusCode).to.equal(400);
done();
});
});
it('fails with malformed redirectURI', function (done) {
config.set('developerMode', true);
superagent.put(SERVER_URL + '/api/v1/oauth/clients/' + CLIENT_0.id)
.query({ access_token: token })
.send({ appId: CLIENT_1.appId, redirectURI: 'foobar' })
.end(function (error, result) {
expect(error).to.not.be.ok();
expect(result.statusCode).to.equal(400);
done();
});
});
it('succeeds', function (done) {
config.set('developerMode', true);
superagent.put(SERVER_URL + '/api/v1/oauth/clients/' + CLIENT_0.id)
.query({ access_token: token })
.send({ appId: CLIENT_1.appId, redirectURI: CLIENT_1.redirectURI })
.end(function (error, result) {
expect(error).to.not.be.ok();
expect(result.statusCode).to.equal(202);
superagent.get(SERVER_URL + '/api/v1/oauth/clients/' + CLIENT_0.id)
.query({ access_token: token })
.end(function (error, result) {
expect(error).to.be(null);
expect(result.statusCode).to.equal(200);
expect(result.body.appId).to.equal(CLIENT_1.appId);
expect(result.body.redirectURI).to.equal(CLIENT_1.redirectURI);
done();
});
});
});
});
describe('del', function () {
var CLIENT_0 = {
id: '',
appId: 'someAppId-0',
redirectURI: 'http://some.callback0',
scope: 'profile,roleUser'
};
before(function (done) {
async.series([
server.start.bind(null),
database._clear.bind(null),
function (callback) {
var scope1 = nock(config.apiServerOrigin()).get('/api/v1/boxes/' + config.fqdn() + '/setup/verify?setupToken=somesetuptoken').reply(200, {});
var scope2 = nock(config.apiServerOrigin()).post('/api/v1/boxes/' + config.fqdn() + '/setup/done?setupToken=somesetuptoken').reply(201, {});
superagent.post(SERVER_URL + '/api/v1/cloudron/activate')
.query({ setupToken: 'somesetuptoken' })
.send({ username: USERNAME, password: PASSWORD, email: EMAIL })
.end(function (error, result) {
expect(error).to.not.be.ok();
expect(result).to.be.ok();
expect(scope1.isDone()).to.be.ok();
expect(scope2.isDone()).to.be.ok();
// stash token for further use
token = result.body.token;
callback();
});
},
function (callback) {
config.set('developerMode', true);
superagent.post(SERVER_URL + '/api/v1/oauth/clients')
.query({ access_token: token })
.send({ appId: CLIENT_0.appId, redirectURI: CLIENT_0.redirectURI, scope: CLIENT_0.scope })
.end(function (error, result) {
expect(error).to.not.be.ok();
expect(result.statusCode).to.equal(201);
CLIENT_0 = result.body;
callback();
});
}
], done);
});
after(cleanup);
it('fails without token', function (done) {
config.set('developerMode', true);
superagent.del(SERVER_URL + '/api/v1/oauth/clients/' + CLIENT_0.id)
.end(function (error, result) {
expect(error).to.not.be.ok();
expect(result.statusCode).to.equal(401);
done();
});
});
it('fails if not in developerMode', function (done) {
config.set('developerMode', false);
superagent.del(SERVER_URL + '/api/v1/oauth/clients/' + CLIENT_0.id)
.query({ access_token: token })
.end(function (error, result) {
expect(error).to.not.be.ok();
expect(result.statusCode).to.equal(412);
done();
});
});
it('fails with unknown id', function (done) {
config.set('developerMode', true);
superagent.del(SERVER_URL + '/api/v1/oauth/clients/' + CLIENT_0.id.toUpperCase())
.query({ access_token: token })
.end(function (error, result) {
expect(error).to.not.be.ok();
expect(result.statusCode).to.equal(404);
done();
});
});
it('succeeds', function (done) {
config.set('developerMode', true);
superagent.del(SERVER_URL + '/api/v1/oauth/clients/' + CLIENT_0.id)
.query({ access_token: token })
.end(function (error, result) {
expect(error).to.not.be.ok();
expect(result.statusCode).to.equal(204);
superagent.get(SERVER_URL + '/api/v1/oauth/clients/' + CLIENT_0.id)
.query({ access_token: token })
.end(function (error, result) {
expect(error).to.be(null);
expect(result.statusCode).to.equal(404);
done();
});
});
});
});
});
describe('Clients', function () {
var USER_0 = {
userId: uuid.v4(),
username: 'someusername',
password: 'somepassword',
email: 'some@email.com',
admin: true,
salt: 'somesalt',
createdAt: (new Date()).toUTCString(),
modifiedAt: (new Date()).toUTCString(),
resetToken: hat(256)
};
// make csrf always succeed for testing
oauth2.csrf = function (req, res, next) {
req.csrfToken = function () { return hat(256); };
next();
};
function setup(done) {
async.series([
server.start.bind(server),
database._clear.bind(null),
function (callback) {
var scope1 = nock(config.apiServerOrigin()).get('/api/v1/boxes/' + config.fqdn() + '/setup/verify?setupToken=somesetuptoken').reply(200, {});
var scope2 = nock(config.apiServerOrigin()).post('/api/v1/boxes/' + config.fqdn() + '/setup/done?setupToken=somesetuptoken').reply(201, {});
superagent.post(SERVER_URL + '/api/v1/cloudron/activate')
.query({ setupToken: 'somesetuptoken' })
.send({ username: USER_0.username, password: USER_0.password, email: USER_0.email })
.end(function (error, result) {
expect(error).to.not.be.ok();
expect(result).to.be.ok();
expect(result.statusCode).to.eql(201);
expect(scope1.isDone()).to.be.ok();
expect(scope2.isDone()).to.be.ok();
// stash for further use
token = result.body.token;
callback();
});
}
], done);
}
function cleanup(done) {
database._clear(function (error) {
expect(error).to.not.be.ok();
server.stop(done);
});
}
describe('get', function () {
before(setup);
after(cleanup);
it('fails due to missing token', function (done) {
superagent.get(SERVER_URL + '/api/v1/oauth/clients')
.end(function (error, result) {
expect(error).to.not.be.ok();
expect(result.statusCode).to.equal(401);
done();
});
});
it('fails due to empty token', function (done) {
superagent.get(SERVER_URL + '/api/v1/oauth/clients')
.query({ access_token: '' })
.end(function (error, result) {
expect(error).to.not.be.ok();
expect(result.statusCode).to.equal(401);
done();
});
});
it('fails due to wrong token', function (done) {
superagent.get(SERVER_URL + '/api/v1/oauth/clients')
.query({ access_token: token.toUpperCase() })
.end(function (error, result) {
expect(error).to.not.be.ok();
expect(result.statusCode).to.equal(401);
done();
});
});
it('succeeds', function (done) {
superagent.get(SERVER_URL + '/api/v1/oauth/clients')
.query({ access_token: token })
.end(function (error, result) {
expect(error).to.not.be.ok();
expect(result.statusCode).to.equal(200);
expect(result.body.clients.length).to.eql(1);
expect(result.body.clients[0].tokenCount).to.eql(1);
done();
});
});
});
describe('get tokens by client', function () {
before(setup);
after(cleanup);
it('fails due to missing token', function (done) {
superagent.get(SERVER_URL + '/api/v1/oauth/clients/cid-webadmin/tokens')
.end(function (error, result) {
expect(error).to.not.be.ok();
expect(result.statusCode).to.equal(401);
done();
});
});
it('fails due to empty token', function (done) {
superagent.get(SERVER_URL + '/api/v1/oauth/clients/cid-webadmin/tokens')
.query({ access_token: '' })
.end(function (error, result) {
expect(error).to.not.be.ok();
expect(result.statusCode).to.equal(401);
done();
});
});
it('fails due to wrong token', function (done) {
superagent.get(SERVER_URL + '/api/v1/oauth/clients/cid-webadmin/tokens')
.query({ access_token: token.toUpperCase() })
.end(function (error, result) {
expect(error).to.not.be.ok();
expect(result.statusCode).to.equal(401);
done();
});
});
it('fails due to unkown client', function (done) {
superagent.get(SERVER_URL + '/api/v1/oauth/clients/CID-WEBADMIN/tokens')
.query({ access_token: token })
.end(function (error, result) {
expect(error).to.not.be.ok();
expect(result.statusCode).to.equal(404);
done();
});
});
it('succeeds', function (done) {
superagent.get(SERVER_URL + '/api/v1/oauth/clients/cid-webadmin/tokens')
.query({ access_token: token })
.end(function (error, result) {
expect(error).to.not.be.ok();
expect(result.statusCode).to.equal(200);
expect(result.body.tokens.length).to.eql(1);
expect(result.body.tokens[0].identifier).to.eql('user-' + USER_0.username);
done();
});
});
});
describe('delete tokens by client', function () {
before(setup);
after(cleanup);
it('fails due to missing token', function (done) {
superagent.del(SERVER_URL + '/api/v1/oauth/clients/cid-webadmin/tokens')
.end(function (error, result) {
expect(error).to.not.be.ok();
expect(result.statusCode).to.equal(401);
done();
});
});
it('fails due to empty token', function (done) {
superagent.del(SERVER_URL + '/api/v1/oauth/clients/cid-webadmin/tokens')
.query({ access_token: '' })
.end(function (error, result) {
expect(error).to.not.be.ok();
expect(result.statusCode).to.equal(401);
done();
});
});
it('fails due to wrong token', function (done) {
superagent.del(SERVER_URL + '/api/v1/oauth/clients/cid-webadmin/tokens')
.query({ access_token: token.toUpperCase() })
.end(function (error, result) {
expect(error).to.not.be.ok();
expect(result.statusCode).to.equal(401);
done();
});
});
it('fails due to unkown client', function (done) {
superagent.del(SERVER_URL + '/api/v1/oauth/clients/CID-WEBADMIN/tokens')
.query({ access_token: token })
.end(function (error, result) {
expect(error).to.not.be.ok();
expect(result.statusCode).to.equal(404);
done();
});
});
it('succeeds', function (done) {
superagent.get(SERVER_URL + '/api/v1/oauth/clients/cid-webadmin/tokens')
.query({ access_token: token })
.end(function (error, result) {
expect(error).to.not.be.ok();
expect(result.statusCode).to.equal(200);
expect(result.body.tokens.length).to.eql(1);
expect(result.body.tokens[0].identifier).to.eql('user-' + USER_0.username);
superagent.del(SERVER_URL + '/api/v1/oauth/clients/cid-webadmin/tokens')
.query({ access_token: token })
.end(function (error, result) {
expect(error).to.not.be.ok();
expect(result.statusCode).to.equal(204);
// further calls with this token should not work
superagent.get(SERVER_URL + '/api/v1/profile')
.query({ access_token: token })
.end(function (error, result) {
expect(error).to.not.be.ok();
expect(result.statusCode).to.equal(401);
done();
});
});
});
});
});
});

View File

@@ -0,0 +1,506 @@
'use strict';
/* jslint node:true */
/* global it:false */
/* global describe:false */
/* global before:false */
/* global after:false */
var async = require('async'),
config = require('../../config.js'),
database = require('../../database.js'),
expect = require('expect.js'),
fs = require('fs'),
os = require('os'),
path = require('path'),
nock = require('nock'),
paths = require('../../paths.js'),
request = require('superagent'),
server = require('../../server.js'),
shell = require('../../shell.js');
var SERVER_URL = 'http://localhost:' + config.get('port');
var USERNAME = 'admin', PASSWORD = 'password', EMAIL ='silly@me.com';
var token = null; // authentication token
var server;
function setup(done) {
config.set('version', '0.5.0');
server.start(done);
}
function cleanup(done) {
database._clear(function (error) {
expect(error).to.not.be.ok();
server.stop(done);
});
}
var gSudoOriginal = null;
function injectShellMock() {
gSudoOriginal = shell.sudo;
shell.sudo = function (tag, options, callback) { callback(null); };
}
function restoreShellMock() {
shell.sudo = gSudoOriginal;
}
describe('Cloudron', function () {
describe('activate', function () {
before(setup);
after(cleanup);
it('fails due to missing setupToken', function (done) {
request.post(SERVER_URL + '/api/v1/cloudron/activate')
.send({ username: '', password: 'somepassword', email: 'admin@foo.bar' })
.end(function (error, result) {
expect(error).to.not.be.ok();
expect(result.statusCode).to.equal(400);
done();
});
});
it('fails due to empty username', function (done) {
var scope = nock(config.apiServerOrigin()).get('/api/v1/boxes/' + config.fqdn() + '/setup/verify?setupToken=somesetuptoken').reply(200, {});
request.post(SERVER_URL + '/api/v1/cloudron/activate')
.query({ setupToken: 'somesetuptoken' })
.send({ username: '', password: 'somepassword', email: 'admin@foo.bar' })
.end(function (error, result) {
expect(error).to.not.be.ok();
expect(result.statusCode).to.equal(400);
expect(scope.isDone()).to.be.ok();
done();
});
});
it('fails due to empty password', function (done) {
var scope = nock(config.apiServerOrigin()).get('/api/v1/boxes/' + config.fqdn() + '/setup/verify?setupToken=somesetuptoken').reply(200, {});
request.post(SERVER_URL + '/api/v1/cloudron/activate')
.query({ setupToken: 'somesetuptoken' })
.send({ username: 'someuser', password: '', email: 'admin@foo.bar' })
.end(function (error, result) {
expect(error).to.not.be.ok();
expect(result.statusCode).to.equal(400);
expect(scope.isDone()).to.be.ok();
done();
});
});
it('fails due to empty email', function (done) {
var scope = nock(config.apiServerOrigin()).get('/api/v1/boxes/' + config.fqdn() + '/setup/verify?setupToken=somesetuptoken').reply(200, {});
request.post(SERVER_URL + '/api/v1/cloudron/activate')
.query({ setupToken: 'somesetuptoken' })
.send({ username: 'someuser', password: 'somepassword', email: '' })
.end(function (error, result) {
expect(error).to.not.be.ok();
expect(result.statusCode).to.equal(400);
expect(scope.isDone()).to.be.ok();
done();
});
});
it('fails due to empty name', function (done) {
var scope = nock(config.apiServerOrigin()).get('/api/v1/boxes/' + config.fqdn() + '/setup/verify?setupToken=somesetuptoken').reply(200, {});
request.post(SERVER_URL + '/api/v1/cloudron/activate')
.query({ setupToken: 'somesetuptoken' })
.send({ username: 'someuser', password: '', email: 'admin@foo.bar', name: '' })
.end(function (error, result) {
expect(error).to.not.be.ok();
expect(result.statusCode).to.equal(400);
expect(scope.isDone()).to.be.ok();
done();
});
});
it('fails due to invalid email', function (done) {
var scope = nock(config.apiServerOrigin()).get('/api/v1/boxes/' + config.fqdn() + '/setup/verify?setupToken=somesetuptoken').reply(200, {});
request.post(SERVER_URL + '/api/v1/cloudron/activate')
.query({ setupToken: 'somesetuptoken' })
.send({ username: 'someuser', password: 'somepassword', email: 'invalidemail' })
.end(function (error, result) {
expect(error).to.not.be.ok();
expect(result.statusCode).to.equal(400);
expect(scope.isDone()).to.be.ok();
done();
});
});
it('succeeds', function (done) {
var scope1 = nock(config.apiServerOrigin()).get('/api/v1/boxes/' + config.fqdn() + '/setup/verify?setupToken=somesetuptoken').reply(200, {});
var scope2 = nock(config.apiServerOrigin()).post('/api/v1/boxes/' + config.fqdn() + '/setup/done?setupToken=somesetuptoken').reply(201, {});
request.post(SERVER_URL + '/api/v1/cloudron/activate')
.query({ setupToken: 'somesetuptoken' })
.send({ username: 'someuser', password: 'somepassword', email: 'admin@foo.bar', name: 'tester' })
.end(function (error, result) {
expect(error).to.not.be.ok();
expect(result.statusCode).to.equal(201);
expect(scope1.isDone()).to.be.ok();
expect(scope2.isDone()).to.be.ok();
done();
});
});
it('fails the second time', function (done) {
var scope = nock(config.apiServerOrigin()).get('/api/v1/boxes/' + config.fqdn() + '/setup/verify?setupToken=somesetuptoken').reply(200, {});
request.post(SERVER_URL + '/api/v1/cloudron/activate')
.query({ setupToken: 'somesetuptoken' })
.send({ username: 'someuser', password: 'somepassword', email: 'admin@foo.bar' })
.end(function (error, result) {
expect(error).to.not.be.ok();
expect(result.statusCode).to.equal(409);
expect(scope.isDone()).to.be.ok();
done();
});
});
});
describe('Certificates API', function () {
var certFile, keyFile;
before(function (done) {
certFile = path.join(os.tmpdir(), 'host.cert');
fs.writeFileSync(certFile, 'test certificate');
keyFile = path.join(os.tmpdir(), 'host.key');
fs.writeFileSync(keyFile, 'test key');
async.series([
setup,
function (callback) {
var scope1 = nock(config.apiServerOrigin()).get('/api/v1/boxes/' + config.fqdn() + '/setup/verify?setupToken=somesetuptoken').reply(200, {});
var scope2 = nock(config.apiServerOrigin()).post('/api/v1/boxes/' + config.fqdn() + '/setup/done?setupToken=somesetuptoken').reply(201, {});
request.post(SERVER_URL + '/api/v1/cloudron/activate')
.query({ setupToken: 'somesetuptoken' })
.send({ username: USERNAME, password: PASSWORD, email: EMAIL })
.end(function (error, result) {
expect(error).to.not.be.ok();
expect(result).to.be.ok();
expect(scope1.isDone()).to.be.ok();
expect(scope2.isDone()).to.be.ok();
// stash token for further use
token = result.body.token;
callback();
});
},
], done);
});
after(function (done) {
fs.unlinkSync(certFile);
fs.unlinkSync(keyFile);
cleanup(done);
});
it('cannot set certificate without token', function (done) {
request.post(SERVER_URL + '/api/v1/cloudron/certificate')
.end(function (error, result) {
expect(error).to.not.be.ok();
expect(result.statusCode).to.equal(401);
done();
});
});
it('cannot set certificate without certificate', function (done) {
request.post(SERVER_URL + '/api/v1/cloudron/certificate')
.query({ access_token: token })
.attach('key', keyFile, 'key')
.end(function (error, result) {
expect(error).to.not.be.ok();
expect(result.statusCode).to.equal(400);
done();
});
});
it('cannot set certificate without key', function (done) {
request.post(SERVER_URL + '/api/v1/cloudron/certificate')
.query({ access_token: token })
.attach('certificate', certFile, 'certificate')
.end(function (error, result) {
expect(error).to.not.be.ok();
expect(result.statusCode).to.equal(400);
done();
});
});
it('can set certificate', function (done) {
request.post(SERVER_URL + '/api/v1/cloudron/certificate')
.query({ access_token: token })
.attach('key', keyFile, 'key')
.attach('certificate', certFile, 'certificate')
.end(function (error, result) {
expect(error).to.not.be.ok();
expect(result.statusCode).to.equal(202);
done();
});
});
it('did set the certificate', function (done) {
var cert = fs.readFileSync(path.join(paths.NGINX_CERT_DIR, 'host.cert'));
expect(cert).to.eql(fs.readFileSync(certFile));
var key = fs.readFileSync(path.join(paths.NGINX_CERT_DIR, 'host.key'));
expect(key).to.eql(fs.readFileSync(keyFile));
done();
});
});
describe('get config', function () {
before(function (done) {
async.series([
setup,
function (callback) {
var scope1 = nock(config.apiServerOrigin()).get('/api/v1/boxes/' + config.fqdn() + '/setup/verify?setupToken=somesetuptoken').reply(200, {});
var scope2 = nock(config.apiServerOrigin()).post('/api/v1/boxes/' + config.fqdn() + '/setup/done?setupToken=somesetuptoken').reply(201, {});
config._reset();
request.post(SERVER_URL + '/api/v1/cloudron/activate')
.query({ setupToken: 'somesetuptoken' })
.send({ username: USERNAME, password: PASSWORD, email: EMAIL })
.end(function (error, result) {
expect(error).to.not.be.ok();
expect(result).to.be.ok();
expect(scope1.isDone()).to.be.ok();
expect(scope2.isDone()).to.be.ok();
// stash token for further use
token = result.body.token;
callback();
});
},
], done);
});
after(cleanup);
it('cannot get without token', function (done) {
request.get(SERVER_URL + '/api/v1/cloudron/config')
.end(function (error, result) {
expect(error).to.not.be.ok();
expect(result.statusCode).to.equal(401);
done();
});
});
it('succeeds without appstore', function (done) {
request.get(SERVER_URL + '/api/v1/cloudron/config')
.query({ access_token: token })
.end(function (error, result) {
expect(error).to.not.be.ok();
expect(result.statusCode).to.equal(200);
expect(result.body.apiServerOrigin).to.eql('http://localhost:6060');
expect(result.body.webServerOrigin).to.eql(null);
expect(result.body.fqdn).to.eql('localhost');
expect(result.body.isCustomDomain).to.eql(false);
expect(result.body.progress).to.be.an('object');
expect(result.body.update).to.be.an('object');
expect(result.body.version).to.eql('0.5.0');
expect(result.body.developerMode).to.be.a('boolean');
expect(result.body.size).to.eql(null);
expect(result.body.region).to.eql(null);
expect(result.body.cloudronName).to.be.a('string');
done();
});
});
it('succeeds', function (done) {
var scope = nock(config.apiServerOrigin()).get('/api/v1/boxes/localhost?token=' + config.token()).reply(200, { box: { region: 'sfo', size: 'small' }});
request.get(SERVER_URL + '/api/v1/cloudron/config')
.query({ access_token: token })
.end(function (error, result) {
expect(error).to.not.be.ok();
expect(result.statusCode).to.equal(200);
expect(result.body.apiServerOrigin).to.eql('http://localhost:6060');
expect(result.body.webServerOrigin).to.eql(null);
expect(result.body.fqdn).to.eql('localhost');
expect(result.body.isCustomDomain).to.eql(false);
expect(result.body.progress).to.be.an('object');
expect(result.body.update).to.be.an('object');
expect(result.body.version).to.eql('0.5.0');
expect(result.body.developerMode).to.be.a('boolean');
expect(result.body.size).to.eql('small');
expect(result.body.region).to.eql('sfo');
expect(result.body.cloudronName).to.be.a('string');
expect(scope.isDone()).to.be.ok();
done();
});
});
});
describe('migrate', function () {
before(function (done) {
async.series([
setup,
function (callback) {
var scope1 = nock(config.apiServerOrigin()).get('/api/v1/boxes/' + config.fqdn() + '/setup/verify?setupToken=somesetuptoken').reply(200, {});
var scope2 = nock(config.apiServerOrigin()).post('/api/v1/boxes/' + config.fqdn() + '/setup/done?setupToken=somesetuptoken').reply(201, {});
config._reset();
request.post(SERVER_URL + '/api/v1/cloudron/activate')
.query({ setupToken: 'somesetuptoken' })
.send({ username: USERNAME, password: PASSWORD, email: EMAIL })
.end(function (error, result) {
expect(error).to.not.be.ok();
expect(result).to.be.ok();
expect(scope1.isDone()).to.be.ok();
expect(scope2.isDone()).to.be.ok();
// stash token for further use
token = result.body.token;
callback();
});
},
], done);
});
after(cleanup);
it('fails without token', function (done) {
request.post(SERVER_URL + '/api/v1/cloudron/migrate')
.send({ size: 'small', region: 'sfo'})
.end(function (error, result) {
expect(error).to.not.be.ok();
expect(result.statusCode).to.equal(401);
done();
});
});
it('fails without password', function (done) {
request.post(SERVER_URL + '/api/v1/cloudron/migrate')
.send({ size: 'small', region: 'sfo'})
.query({ access_token: token })
.end(function (error, result) {
expect(error).to.not.be.ok();
expect(result.statusCode).to.equal(400);
done();
});
});
it('fails with missing size', function (done) {
request.post(SERVER_URL + '/api/v1/cloudron/migrate')
.send({ region: 'sfo', password: PASSWORD })
.query({ access_token: token })
.end(function (error, result) {
expect(error).to.not.be.ok();
expect(result.statusCode).to.equal(400);
done();
});
});
it('fails with wrong size type', function (done) {
request.post(SERVER_URL + '/api/v1/cloudron/migrate')
.send({ size: 4, region: 'sfo', password: PASSWORD })
.query({ access_token: token })
.end(function (error, result) {
expect(error).to.not.be.ok();
expect(result.statusCode).to.equal(400);
done();
});
});
it('fails with missing region', function (done) {
request.post(SERVER_URL + '/api/v1/cloudron/migrate')
.send({ size: 'small', password: PASSWORD })
.query({ access_token: token })
.end(function (error, result) {
expect(error).to.not.be.ok();
expect(result.statusCode).to.equal(400);
done();
});
});
it('fails with wrong region type', function (done) {
request.post(SERVER_URL + '/api/v1/cloudron/migrate')
.send({ size: 'small', region: 4, password: PASSWORD })
.query({ access_token: token })
.end(function (error, result) {
expect(error).to.not.be.ok();
expect(result.statusCode).to.equal(400);
done();
});
});
it('fails when in wrong state', function (done) {
var scope1 = nock(config.apiServerOrigin()).post('/api/v1/boxes/' + config.fqdn() + '/migrate?token=APPSTORE_TOKEN', { size: 'small', region: 'sfo', restoreKey: 'someid' }).reply(409, {});
var scope2 = nock(config.apiServerOrigin()).put('/api/v1/boxes/' + config.fqdn() + '/backupurl?token=APPSTORE_TOKEN', { boxVersion: '0.5.0', appId: null, appVersion: null, appBackupIds: [] }).reply(201, { id: 'someid', url: 'http://foobar', backupKey: 'somerestorekey' });
injectShellMock();
request.post(SERVER_URL + '/api/v1/cloudron/migrate')
.send({ size: 'small', region: 'sfo', password: PASSWORD })
.query({ access_token: token })
.end(function (error, result) {
expect(error).to.not.be.ok();
expect(result.statusCode).to.equal(202);
function checkAppstoreServerCalled() {
if (scope1.isDone() && scope2.isDone()) {
restoreShellMock();
return done();
}
setTimeout(checkAppstoreServerCalled, 100);
}
checkAppstoreServerCalled();
});
});
it('succeeds', function (done) {
var scope1 = nock(config.apiServerOrigin()).post('/api/v1/boxes/' + config.fqdn() + '/migrate?token=APPSTORE_TOKEN', { size: 'small', region: 'sfo', restoreKey: 'someid' }).reply(202, {});
var scope2 = nock(config.apiServerOrigin()).put('/api/v1/boxes/' + config.fqdn() + '/backupurl?token=APPSTORE_TOKEN', { boxVersion: '0.5.0', appId: null, appVersion: null, appBackupIds: [] }).reply(201, { id: 'someid', url: 'http://foobar', backupKey: 'somerestorekey' });
injectShellMock();
request.post(SERVER_URL + '/api/v1/cloudron/migrate')
.send({ size: 'small', region: 'sfo', password: PASSWORD })
.query({ access_token: token })
.end(function (error, result) {
expect(error).to.not.be.ok();
expect(result.statusCode).to.equal(202);
function checkAppstoreServerCalled() {
if (scope1.isDone() && scope2.isDone()) {
restoreShellMock();
return done();
}
setTimeout(checkAppstoreServerCalled, 100);
}
checkAppstoreServerCalled();
});
});
});
});

View File

@@ -0,0 +1,356 @@
'use strict';
/* jslint node:true */
/* global it:false */
/* global describe:false */
/* global before:false */
/* global after:false */
var async = require('async'),
config = require('../../config.js'),
database = require('../../database.js'),
expect = require('expect.js'),
nock = require('nock'),
request = require('superagent'),
server = require('../../server.js');
var SERVER_URL = 'http://localhost:' + config.get('port');
var USERNAME = 'admin', PASSWORD = 'password', EMAIL ='silly@me.com';
var token = null; // authentication token
var server;
function setup(done) {
server.start(done);
}
function cleanup(done) {
database._clear(function (error) {
expect(error).to.not.be.ok();
server.stop(done);
});
}
describe('Developer API', function () {
describe('isEnabled', function () {
before(function (done) {
async.series([
setup,
function (callback) {
var scope1 = nock(config.apiServerOrigin()).get('/api/v1/boxes/' + config.fqdn() + '/setup/verify?setupToken=somesetuptoken').reply(200, {});
var scope2 = nock(config.apiServerOrigin()).post('/api/v1/boxes/' + config.fqdn() + '/setup/done?setupToken=somesetuptoken').reply(201, {});
request.post(SERVER_URL + '/api/v1/cloudron/activate')
.query({ setupToken: 'somesetuptoken' })
.send({ username: USERNAME, password: PASSWORD, email: EMAIL })
.end(function (error, result) {
expect(error).to.not.be.ok();
expect(result).to.be.ok();
expect(scope1.isDone()).to.be.ok();
expect(scope2.isDone()).to.be.ok();
// stash token for further use
token = result.body.token;
callback();
});
},
], done);
});
after(cleanup);
it('fails without token', function (done) {
config.set('developerMode', true);
request.get(SERVER_URL + '/api/v1/developer')
.end(function (error, result) {
expect(error).to.not.be.ok();
expect(result.statusCode).to.equal(401);
done();
});
});
it('succeeds (enabled)', function (done) {
config.set('developerMode', true);
request.get(SERVER_URL + '/api/v1/developer')
.query({ access_token: token })
.end(function (error, result) {
expect(error).to.not.be.ok();
expect(result.statusCode).to.equal(200);
done();
});
});
it('succeeds (not enabled)', function (done) {
config.set('developerMode', false);
request.get(SERVER_URL + '/api/v1/developer')
.query({ access_token: token })
.end(function (error, result) {
expect(error).to.not.be.ok();
expect(result.statusCode).to.equal(412);
done();
});
});
});
describe('setEnabled', function () {
before(function (done) {
async.series([
setup,
function (callback) {
var scope1 = nock(config.apiServerOrigin()).get('/api/v1/boxes/' + config.fqdn() + '/setup/verify?setupToken=somesetuptoken').reply(200, {});
var scope2 = nock(config.apiServerOrigin()).post('/api/v1/boxes/' + config.fqdn() + '/setup/done?setupToken=somesetuptoken').reply(201, {});
request.post(SERVER_URL + '/api/v1/cloudron/activate')
.query({ setupToken: 'somesetuptoken' })
.send({ username: USERNAME, password: PASSWORD, email: EMAIL })
.end(function (error, result) {
expect(error).to.not.be.ok();
expect(result).to.be.ok();
expect(scope1.isDone()).to.be.ok();
expect(scope2.isDone()).to.be.ok();
// stash token for further use
token = result.body.token;
callback();
});
},
], done);
});
after(cleanup);
it('fails without token', function (done) {
request.post(SERVER_URL + '/api/v1/developer')
.send({ enabled: true })
.end(function (error, result) {
expect(error).to.not.be.ok();
expect(result.statusCode).to.equal(401);
done();
});
});
it('fails due to missing password', function (done) {
request.post(SERVER_URL + '/api/v1/developer')
.query({ access_token: token })
.send({ enabled: true })
.end(function (error, result) {
expect(error).to.not.be.ok();
expect(result.statusCode).to.equal(400);
done();
});
});
it('fails due to empty password', function (done) {
request.post(SERVER_URL + '/api/v1/developer')
.query({ access_token: token })
.send({ password: '', enabled: true })
.end(function (error, result) {
expect(error).to.not.be.ok();
expect(result.statusCode).to.equal(403);
done();
});
});
it('fails due to wrong password', function (done) {
request.post(SERVER_URL + '/api/v1/developer')
.query({ access_token: token })
.send({ password: PASSWORD.toUpperCase(), enabled: true })
.end(function (error, result) {
expect(error).to.not.be.ok();
expect(result.statusCode).to.equal(403);
done();
});
});
it('fails due to missing enabled property', function (done) {
request.post(SERVER_URL + '/api/v1/developer')
.query({ access_token: token })
.send({ password: PASSWORD })
.end(function (error, result) {
expect(error).to.not.be.ok();
expect(result.statusCode).to.equal(400);
done();
});
});
it('fails due to wrong enabled property type', function (done) {
request.post(SERVER_URL + '/api/v1/developer')
.query({ access_token: token })
.send({ password: PASSWORD, enabled: 'true' })
.end(function (error, result) {
expect(error).to.not.be.ok();
expect(result.statusCode).to.equal(400);
done();
});
});
it('succeeds enabling', function (done) {
request.post(SERVER_URL + '/api/v1/developer')
.query({ access_token: token })
.send({ password: PASSWORD, enabled: true })
.end(function (error, result) {
expect(error).to.not.be.ok();
expect(result.statusCode).to.equal(200);
request.get(SERVER_URL + '/api/v1/developer')
.query({ access_token: token })
.end(function (error, result) {
expect(error).to.not.be.ok();
expect(result.statusCode).to.equal(200);
done();
});
});
});
it('succeeds disabling', function (done) {
request.post(SERVER_URL + '/api/v1/developer')
.query({ access_token: token })
.send({ password: PASSWORD, enabled: false })
.end(function (error, result) {
expect(error).to.not.be.ok();
expect(result.statusCode).to.equal(200);
request.get(SERVER_URL + '/api/v1/developer')
.query({ access_token: token })
.end(function (error, result) {
expect(error).to.not.be.ok();
expect(result.statusCode).to.equal(412);
done();
});
});
});
});
describe('login', function () {
before(function (done) {
config.set('developerMode', true);
async.series([
setup,
function (callback) {
var scope1 = nock(config.apiServerOrigin()).get('/api/v1/boxes/' + config.fqdn() + '/setup/verify?setupToken=somesetuptoken').reply(200, {});
var scope2 = nock(config.apiServerOrigin()).post('/api/v1/boxes/' + config.fqdn() + '/setup/done?setupToken=somesetuptoken').reply(201, {});
request.post(SERVER_URL + '/api/v1/cloudron/activate')
.query({ setupToken: 'somesetuptoken' })
.send({ username: USERNAME, password: PASSWORD, email: EMAIL })
.end(function (error, result) {
expect(error).to.not.be.ok();
expect(result).to.be.ok();
expect(scope1.isDone()).to.be.ok();
expect(scope2.isDone()).to.be.ok();
// stash token for further use
token = result.body.token;
callback();
});
},
], done);
});
after(cleanup);
it('fails without body', function (done) {
request.post(SERVER_URL + '/api/v1/developer/login')
.end(function (error, result) {
expect(error).to.not.be.ok();
expect(result.statusCode).to.equal(401);
done();
});
});
it('fails without username', function (done) {
request.post(SERVER_URL + '/api/v1/developer/login')
.send({ password: PASSWORD })
.end(function (error, result) {
expect(error).to.not.be.ok();
expect(result.statusCode).to.equal(401);
done();
});
});
it('fails without password', function (done) {
request.post(SERVER_URL + '/api/v1/developer/login')
.send({ username: USERNAME })
.end(function (error, result) {
expect(error).to.not.be.ok();
expect(result.statusCode).to.equal(401);
done();
});
});
it('fails with empty username', function (done) {
request.post(SERVER_URL + '/api/v1/developer/login')
.send({ username: '', password: PASSWORD })
.end(function (error, result) {
expect(error).to.not.be.ok();
expect(result.statusCode).to.equal(401);
done();
});
});
it('fails with empty password', function (done) {
request.post(SERVER_URL + '/api/v1/developer/login')
.send({ username: USERNAME, password: '' })
.end(function (error, result) {
expect(error).to.not.be.ok();
expect(result.statusCode).to.equal(401);
done();
});
});
it('fails with unknown username', function (done) {
request.post(SERVER_URL + '/api/v1/developer/login')
.send({ username: USERNAME.toUpperCase(), password: PASSWORD })
.end(function (error, result) {
expect(error).to.not.be.ok();
expect(result.statusCode).to.equal(401);
done();
});
});
it('fails with wrong password', function (done) {
request.post(SERVER_URL + '/api/v1/developer/login')
.send({ username: USERNAME, password: PASSWORD.toUpperCase() })
.end(function (error, result) {
expect(error).to.not.be.ok();
expect(result.statusCode).to.equal(401);
done();
});
});
it('with username succeeds', function (done) {
request.post(SERVER_URL + '/api/v1/developer/login')
.send({ username: USERNAME, password: PASSWORD })
.end(function (error, result) {
expect(error).to.not.be.ok();
expect(result.statusCode).to.equal(200);
expect(result.body.expiresAt).to.be.a('number');
expect(result.body.token).to.be.a('string');
done();
});
});
it('with email succeeds', function (done) {
request.post(SERVER_URL + '/api/v1/developer/login')
.send({ username: EMAIL, password: PASSWORD })
.end(function (error, result) {
expect(error).to.not.be.ok();
expect(result.statusCode).to.equal(200);
expect(result.body.expiresAt).to.be.a('number');
expect(result.body.token).to.be.a('string');
done();
});
});
});
});

View File

@@ -0,0 +1,334 @@
/* jslint node:true */
/* global it:false */
/* global describe:false */
/* global before:false */
/* global after:false */
'use strict';
var expect = require('expect.js'),
uuid = require('node-uuid'),
hat = require('hat'),
nock = require('nock'),
HttpError = require('connect-lastmile').HttpError,
oauth2 = require('../oauth2.js'),
server = require('../../server.js'),
database = require('../../database.js'),
userdb = require('../../userdb.js'),
config = require('../../config.js'),
superagent = require('superagent'),
passport = require('passport');
var SERVER_URL = 'http://localhost:' + config.get('port');
describe('OAuth2', function () {
var passportAuthenticateSave = null;
before(function () {
passportAuthenticateSave = passport.authenticate;
passport.authenticate = function () {
return function (req, res, next) { next(); };
};
});
after(function () {
passport.authenticate = passportAuthenticateSave;
});
describe('scopes middleware', function () {
it('fails due to missing authInfo', function (done) {
var mw = oauth2.scope('admin')[1];
var req = {};
mw(req, null, function (error) {
expect(error).to.be.a(HttpError);
done();
});
});
it('fails due to missing scope property in authInfo', function (done) {
var mw = oauth2.scope('admin')[1];
var req = { authInfo: {} };
mw(req, null, function (error) {
expect(error).to.be.a(HttpError);
done();
});
});
it('fails due to missing scope in request', function (done) {
var mw = oauth2.scope('admin')[1];
var req = { authInfo: { scope: '' } };
mw(req, null, function (error) {
expect(error).to.be.a(HttpError);
done();
});
});
it('fails due to wrong scope in request', function (done) {
var mw = oauth2.scope('admin')[1];
var req = { authInfo: { scope: 'foobar,something' } };
mw(req, null, function (error) {
expect(error).to.be.a(HttpError);
done();
});
});
it('fails due to wrong scope in request', function (done) {
var mw = oauth2.scope('admin,users')[1];
var req = { authInfo: { scope: 'foobar,admin' } };
mw(req, null, function (error) {
expect(error).to.be.a(HttpError);
done();
});
});
it('succeeds with one requested scope and one provided scope', function (done) {
var mw = oauth2.scope('admin')[1];
var req = { authInfo: { scope: 'admin' } };
mw(req, null, function (error) {
expect(error).to.not.be.ok();
done();
});
});
it('succeeds with one requested scope and two provided scopes', function (done) {
var mw = oauth2.scope('admin')[1];
var req = { authInfo: { scope: 'foobar,admin' } };
mw(req, null, function (error) {
expect(error).to.not.be.ok();
done();
});
});
it('succeeds with two requested scope and two provided scopes', function (done) {
var mw = oauth2.scope('admin,foobar')[1];
var req = { authInfo: { scope: 'foobar,admin' } };
mw(req, null, function (error) {
expect(error).to.not.be.ok();
done();
});
});
it('succeeds with two requested scope and provided wildcard scope', function (done) {
var mw = oauth2.scope('admin,foobar')[1];
var req = { authInfo: { scope: '*' } };
mw(req, null, function (error) {
expect(error).to.not.be.ok();
done();
});
});
});
});
describe('Password', function () {
var USER_0 = {
userId: uuid.v4(),
username: 'someusername',
password: 'somepassword',
email: 'some@email.com',
admin: true,
salt: 'somesalt',
createdAt: (new Date()).toUTCString(),
modifiedAt: (new Date()).toUTCString(),
resetToken: hat(256)
};
// make csrf always succeed for testing
oauth2.csrf = function (req, res, next) {
req.csrfToken = function () { return hat(256); };
next();
};
function setup(done) {
server.start(function (error) {
expect(error).to.not.be.ok();
database._clear(function (error) {
expect(error).to.not.be.ok();
userdb.add(USER_0.userId, USER_0, done);
});
});
}
function cleanup(done) {
database._clear(function (error) {
expect(error).to.not.be.ok();
server.stop(done);
});
}
describe('pages', function () {
before(setup);
after(cleanup);
it('reset request succeeds', function (done) {
superagent.get(SERVER_URL + '/api/v1/session/password/resetRequest.html')
.end(function (error, result) {
expect(error).to.not.be.ok();
expect(result.text.indexOf('<!-- tester -->')).to.not.equal(-1);
expect(result.statusCode).to.equal(200);
done();
});
});
it('setup fails due to missing reset_token', function (done) {
superagent.get(SERVER_URL + '/api/v1/session/password/setup.html')
.end(function (error, result) {
expect(error).to.not.be.ok();
expect(result.statusCode).to.equal(400);
done();
});
});
it('setup fails due to invalid reset_token', function (done) {
superagent.get(SERVER_URL + '/api/v1/session/password/setup.html')
.query({ reset_token: hat(256) })
.end(function (error, result) {
expect(error).to.not.be.ok();
expect(result.statusCode).to.equal(401);
done();
});
});
it('setup succeeds', function (done) {
superagent.get(SERVER_URL + '/api/v1/session/password/setup.html')
.query({ reset_token: USER_0.resetToken })
.end(function (error, result) {
expect(error).to.not.be.ok();
expect(result.statusCode).to.equal(200);
expect(result.text.indexOf('<!-- tester -->')).to.not.equal(-1);
done();
});
});
it('reset fails due to missing reset_token', function (done) {
superagent.get(SERVER_URL + '/api/v1/session/password/reset.html')
.end(function (error, result) {
expect(error).to.not.be.ok();
expect(result.statusCode).to.equal(400);
done();
});
});
it('reset fails due to invalid reset_token', function (done) {
superagent.get(SERVER_URL + '/api/v1/session/password/reset.html')
.query({ reset_token: hat(256) })
.end(function (error, result) {
expect(error).to.not.be.ok();
expect(result.statusCode).to.equal(401);
done();
});
});
it('reset succeeds', function (done) {
superagent.get(SERVER_URL + '/api/v1/session/password/reset.html')
.query({ reset_token: USER_0.resetToken })
.end(function (error, result) {
expect(error).to.not.be.ok();
expect(result.text.indexOf('<!-- tester -->')).to.not.equal(-1);
expect(result.statusCode).to.equal(200);
done();
});
});
it('sent succeeds', function (done) {
superagent.get(SERVER_URL + '/api/v1/session/password/sent.html')
.end(function (error, result) {
expect(error).to.not.be.ok();
expect(result.text.indexOf('<!-- tester -->')).to.not.equal(-1);
expect(result.statusCode).to.equal(200);
done();
});
});
});
describe('reset request handler', function () {
before(setup);
after(cleanup);
it('succeeds', function (done) {
superagent.post(SERVER_URL + '/api/v1/session/password/resetRequest')
.send({ identifier: USER_0.email })
.end(function (error, result) {
expect(error).to.not.be.ok();
expect(result.text.indexOf('<!-- tester -->')).to.not.equal(-1);
expect(result.statusCode).to.equal(200);
done();
});
});
});
describe('reset handler', function () {
before(setup);
after(cleanup);
it('fails due to missing resetToken', function (done) {
superagent.post(SERVER_URL + '/api/v1/session/password/reset')
.send({ password: 'somepassword' })
.end(function (error, result) {
expect(error).to.not.be.ok();
expect(result.statusCode).to.equal(400);
done();
});
});
it('fails due to missing password', function (done) {
superagent.post(SERVER_URL + '/api/v1/session/password/reset')
.send({ resetToken: hat(256) })
.end(function (error, result) {
expect(error).to.not.be.ok();
expect(result.statusCode).to.equal(400);
done();
});
});
it('fails due to empty password', function (done) {
superagent.post(SERVER_URL + '/api/v1/session/password/reset')
.send({ password: '', resetToken: hat(256) })
.end(function (error, result) {
expect(error).to.not.be.ok();
expect(result.statusCode).to.equal(401);
done();
});
});
it('fails due to empty resetToken', function (done) {
superagent.post(SERVER_URL + '/api/v1/session/password/reset')
.send({ password: '', resetToken: '' })
.end(function (error, result) {
expect(error).to.not.be.ok();
expect(result.statusCode).to.equal(401);
done();
});
});
it('succeeds', function (done) {
var scope = nock(config.adminOrigin())
.filteringPath(function (path) {
path = path.replace(/accessToken=[^&]*/, 'accessToken=token');
path = path.replace(/expiresAt=[^&]*/, 'expiresAt=1234');
return path;
})
.get('/?accessToken=token&expiresAt=1234').reply(200, {});
superagent.post(SERVER_URL + '/api/v1/session/password/reset')
.send({ password: 'somepassword', resetToken: USER_0.resetToken })
.end(function (error, result) {
expect(error).to.not.be.ok();
expect(scope.isDone()).to.be.ok();
expect(result.statusCode).to.equal(200);
done();
});
});
});
});

View File

@@ -0,0 +1,233 @@
/* jslint node:true */
/* global it:false */
/* global describe:false */
/* global before:false */
/* global after:false */
'use strict';
var appdb = require('../../appdb.js'),
async = require('async'),
config = require('../../config.js'),
database = require('../../database.js'),
expect = require('expect.js'),
paths = require('../../paths.js'),
request = require('superagent'),
server = require('../../server.js'),
settings = require('../../settings.js'),
fs = require('fs'),
nock = require('nock'),
userdb = require('../../userdb.js');
var SERVER_URL = 'http://localhost:' + config.get('port');
var USERNAME = 'admin', PASSWORD = 'password', EMAIL ='silly@me.com';
var token = null;
var server;
function setup(done) {
async.series([
server.start.bind(server),
userdb._clear,
function createAdmin(callback) {
var scope1 = nock(config.apiServerOrigin()).get('/api/v1/boxes/' + config.fqdn() + '/setup/verify?setupToken=somesetuptoken').reply(200, {});
var scope2 = nock(config.apiServerOrigin()).post('/api/v1/boxes/' + config.fqdn() + '/setup/done?setupToken=somesetuptoken').reply(201, {});
request.post(SERVER_URL + '/api/v1/cloudron/activate')
.query({ setupToken: 'somesetuptoken' })
.send({ username: USERNAME, password: PASSWORD, email: EMAIL })
.end(function (error, result) {
expect(error).to.not.be.ok();
expect(result).to.be.ok();
expect(result.statusCode).to.eql(201);
expect(scope1.isDone()).to.be.ok();
expect(scope2.isDone()).to.be.ok();
// stash token for further use
token = result.body.token;
callback();
});
},
function addApp(callback) {
var manifest = { version: '0.0.1', manifestVersion: 1, dockerImage: 'foo', healthCheckPath: '/', httpPort: 3, title: 'ok' };
appdb.add('appid', 'appStoreId', manifest, 'location', [ ] /* portBindings */, '' /* accessRestriction */, callback);
}
], done);
}
function cleanup(done) {
database._clear(function (error) {
expect(!error).to.be.ok();
server.stop(done);
});
}
describe('Settings API', function () {
this.timeout(10000);
before(setup);
after(cleanup);
describe('autoupdate_pattern', function () {
it('can get auto update pattern (default)', function (done) {
request.get(SERVER_URL + '/api/v1/settings/autoupdate_pattern')
.query({ access_token: token })
.end(function (err, res) {
expect(res.statusCode).to.equal(200);
expect(res.body.pattern).to.be.ok();
done(err);
});
});
it('cannot set autoupdate_pattern without pattern', function (done) {
request.post(SERVER_URL + '/api/v1/settings/autoupdate_pattern')
.query({ access_token: token })
.end(function (err, res) {
expect(res.statusCode).to.equal(400);
done();
});
});
it('can set autoupdate_pattern', function (done) {
var eventPattern = null;
settings.events.on(settings.AUTOUPDATE_PATTERN_KEY, function (pattern) {
eventPattern = pattern;
});
request.post(SERVER_URL + '/api/v1/settings/autoupdate_pattern')
.query({ access_token: token })
.send({ pattern: '00 30 11 * * 1-5' })
.end(function (err, res) {
expect(res.statusCode).to.equal(200);
expect(eventPattern === '00 30 11 * * 1-5').to.be.ok();
done();
});
});
it('can set autoupdate_pattern to never', function (done) {
var eventPattern = null;
settings.events.on(settings.AUTOUPDATE_PATTERN_KEY, function (pattern) {
eventPattern = pattern;
});
request.post(SERVER_URL + '/api/v1/settings/autoupdate_pattern')
.query({ access_token: token })
.send({ pattern: 'never' })
.end(function (err, res) {
expect(res.statusCode).to.equal(200);
expect(eventPattern).to.eql('never');
done();
});
});
it('cannot set invalid autoupdate_pattern', function (done) {
request.post(SERVER_URL + '/api/v1/settings/autoupdate_pattern')
.query({ access_token: token })
.send({ pattern: '1 3 x 5 6' })
.end(function (err, res) {
expect(res.statusCode).to.equal(400);
done();
});
});
});
describe('cloudron_name', function () {
var name = 'foobar';
it('get default succeeds', function (done) {
request.get(SERVER_URL + '/api/v1/settings/cloudron_name')
.query({ access_token: token })
.end(function (err, res) {
expect(res.statusCode).to.equal(200);
expect(res.body.name).to.be.ok();
done(err);
});
});
it('cannot set without name', function (done) {
request.post(SERVER_URL + '/api/v1/settings/cloudron_name')
.query({ access_token: token })
.end(function (err, res) {
expect(res.statusCode).to.equal(400);
done();
});
});
it('cannot set empty name', function (done) {
request.post(SERVER_URL + '/api/v1/settings/cloudron_name')
.query({ access_token: token })
.send({ name: '' })
.end(function (err, res) {
expect(res.statusCode).to.equal(400);
done();
});
});
it('set succeeds', function (done) {
request.post(SERVER_URL + '/api/v1/settings/cloudron_name')
.query({ access_token: token })
.send({ name: name })
.end(function (err, res) {
expect(res.statusCode).to.equal(200);
done();
});
});
it('get succeeds', function (done) {
request.get(SERVER_URL + '/api/v1/settings/cloudron_name')
.query({ access_token: token })
.end(function (err, res) {
expect(res.statusCode).to.equal(200);
expect(res.body.name).to.eql(name);
done(err);
});
});
});
describe('cloudron_avatar', function () {
it('get default succeeds', function (done) {
request.get(SERVER_URL + '/api/v1/settings/cloudron_avatar')
.query({ access_token: token })
.end(function (err, res) {
expect(res.statusCode).to.equal(200);
expect(res.body).to.be.a(Buffer);
done(err);
});
});
it('cannot set without data', function (done) {
request.post(SERVER_URL + '/api/v1/settings/cloudron_avatar')
.query({ access_token: token })
.end(function (err, res) {
expect(res.statusCode).to.equal(400);
done();
});
});
it('set succeeds', function (done) {
request.post(SERVER_URL + '/api/v1/settings/cloudron_avatar')
.query({ access_token: token })
.attach('avatar', paths.FAVICON_FILE)
.end(function (err, res) {
expect(res.statusCode).to.equal(202);
done();
});
});
it('get succeeds', function (done) {
request.get(SERVER_URL + '/api/v1/settings/cloudron_avatar')
.query({ access_token: token })
.end(function (err, res) {
expect(res.statusCode).to.equal(200);
expect(res.body.toString()).to.eql(fs.readFileSync(paths.FAVICON_FILE, 'utf-8'));
done(err);
});
});
});
});

68
src/routes/test/start_addons.sh Executable file
View File

@@ -0,0 +1,68 @@
#!/bin/bash
set -eu -o pipefail
readonly mysqldatadir="/tmp/mysqldata-$(date +%s)"
readonly postgresqldatadir="/tmp/postgresqldata-$(date +%s)"
readonly mongodbdatadir="/tmp/mongodbdata-$(date +%s)"
root_password=secret
start_postgresql() {
postgresql_vars="POSTGRESQL_ROOT_PASSWORD=${root_password}; POSTGRESQL_ROOT_HOST=172.17.0.0/255.255.0.0"
if which boot2docker >/dev/null; then
boot2docker ssh "sudo rm -rf /tmp/postgresql_vars.sh"
boot2docker ssh "echo \"${postgresql_vars}\" > /tmp/postgresql_vars.sh"
else
rm -rf /tmp/postgresql_vars.sh
echo "${postgresql_vars}" > /tmp/postgresql_vars.sh
fi
docker rm -f postgresql 2>/dev/null 1>&2 || true
docker run -dtP --name=postgresql -v "${postgresqldatadir}:/var/lib/postgresql" -v /tmp/postgresql_vars.sh:/etc/postgresql/postgresql_vars.sh cloudron/postgresql:0.3.0 >/dev/null
}
start_mysql() {
local mysql_vars="MYSQL_ROOT_PASSWORD=${root_password}; MYSQL_ROOT_HOST=172.17.0.0/255.255.0.0"
if which boot2docker >/dev/null; then
boot2docker ssh "sudo rm -rf /tmp/mysql_vars.sh"
boot2docker ssh "echo \"${mysql_vars}\" > /tmp/mysql_vars.sh"
else
rm -rf /tmp/mysql_vars.sh
echo "${mysql_vars}" > /tmp/mysql_vars.sh
fi
docker rm -f mysql 2>/dev/null 1>&2 || true
docker run -dP --name=mysql -v "${mysqldatadir}:/var/lib/mysql" -v /tmp/mysql_vars.sh:/etc/mysql/mysql_vars.sh cloudron/mysql:0.3.0 >/dev/null
}
start_mongodb() {
local mongodb_vars="MONGODB_ROOT_PASSWORD=${root_password}"
if which boot2docker >/dev/null; then
boot2docker ssh "sudo rm -rf /tmp/mongodb_vars.sh"
boot2docker ssh "echo \"${mongodb_vars}\" > /tmp/mongodb_vars.sh"
else
rm -rf /tmp/mongodb_vars.sh
echo "${mongodb_vars}" > /tmp/mongodb_vars.sh
fi
docker rm -f mongodb 2>/dev/null 1>&2 || true
docker run -dP --name=mongodb -v "${mongodbdatadir}:/var/lib/mongodb" -v /tmp/mongodb_vars.sh:/etc/mongodb_vars.sh cloudron/mongodb:0.3.0 >/dev/null
}
start_mysql
start_postgresql
start_mongodb
echo -n "Waiting for addons to start"
for i in {1..10}; do
echo -n "."
sleep 1
done
echo ""

View File

@@ -0,0 +1,528 @@
/* jslint node:true */
/* global it:false */
/* global describe:false */
/* global before:false */
/* global after:false */
'use strict';
var config = require('../../config.js'),
database = require('../../database.js'),
tokendb = require('../../tokendb.js'),
expect = require('expect.js'),
request = require('superagent'),
nock = require('nock'),
server = require('../../server.js'),
userdb = require('../../userdb.js');
var SERVER_URL = 'http://localhost:' + config.get('port');
var USERNAME_0 = 'admin', PASSWORD = 'password', EMAIL = 'silly@me.com', EMAIL_0_NEW = 'stupid@me.com';
var USERNAME_1 = 'userTheFirst', EMAIL_1 = 'tao@zen.mac';
var USERNAME_2 = 'userTheSecond', EMAIL_2 = 'user@foo.bar';
var USERNAME_3 = 'userTheThird', EMAIL_3 = 'user3@foo.bar';
var server;
function setup(done) {
server.start(function (error) {
expect(!error).to.be.ok();
userdb._clear(done);
});
}
function cleanup(done) {
database._clear(function (error) {
expect(!error).to.be.ok();
server.stop(done);
});
}
describe('User API', function () {
this.timeout(5000);
var user_0 = null;
var token = null;
var token_1 = tokendb.generateToken();
var token_2 = tokendb.generateToken();
before(setup);
after(cleanup);
it('device is in first time mode', function (done) {
request.get(SERVER_URL + '/api/v1/cloudron/status')
.end(function (err, res) {
expect(res.statusCode).to.equal(200);
expect(res.body.activated).to.not.be.ok();
done(err);
});
});
it('create admin fails due to missing parameters', function (done) {
var scope = nock(config.apiServerOrigin()).get('/api/v1/boxes/' + config.fqdn() + '/setup/verify?setupToken=somesetuptoken').reply(200, {});
request.post(SERVER_URL + '/api/v1/cloudron/activate')
.query({ setupToken: 'somesetuptoken' })
.send({ username: USERNAME_0 })
.end(function (err, res) {
expect(res.statusCode).to.equal(400);
expect(scope.isDone()).to.be.ok();
done(err);
});
});
it('create admin fails because only POST is allowed', function (done) {
request.get(SERVER_URL + '/api/v1/cloudron/activate')
.end(function (err, res) {
expect(res.statusCode).to.equal(404);
done(err);
});
});
it('create admin', function (done) {
var scope1 = nock(config.apiServerOrigin()).get('/api/v1/boxes/' + config.fqdn() + '/setup/verify?setupToken=somesetuptoken').reply(200, {});
var scope2 = nock(config.apiServerOrigin()).post('/api/v1/boxes/' + config.fqdn() + '/setup/done?setupToken=somesetuptoken').reply(201, {});
request.post(SERVER_URL + '/api/v1/cloudron/activate')
.query({ setupToken: 'somesetuptoken' })
.send({ username: USERNAME_0, password: PASSWORD, email: EMAIL })
.end(function (err, res) {
expect(res.statusCode).to.equal(201);
// stash for later use
token = res.body.token;
expect(scope1.isDone()).to.be.ok();
expect(scope2.isDone()).to.be.ok();
done(err);
});
});
it('device left first time mode', function (done) {
request.get(SERVER_URL + '/api/v1/cloudron/status')
.end(function (err, res) {
expect(res.statusCode).to.equal(200);
expect(res.body.activated).to.be.ok();
done(err);
});
});
it('can get userInfo with token', function (done) {
request.get(SERVER_URL + '/api/v1/users/' + USERNAME_0)
.query({ access_token: token })
.end(function (err, res) {
expect(res.statusCode).to.equal(200);
expect(res.body.username).to.equal(USERNAME_0);
expect(res.body.email).to.equal(EMAIL);
expect(res.body.admin).to.be.ok();
// stash for further use
user_0 = res.body;
done(err);
});
});
it('cannot get userInfo with expired token', function (done) {
var token = tokendb.generateToken();
var expires = Date.now() + 2000; // 1 sec
tokendb.add(token, tokendb.PREFIX_USER + user_0.id, null, expires, '*', function (error) {
expect(error).to.not.be.ok();
setTimeout(function () {
request.get(SERVER_URL + '/api/v1/users/' + user_0.username)
.query({ access_token: token })
.end(function (error, result) {
expect(error).to.not.be.ok();
expect(result.statusCode).to.equal(401);
done();
});
}, 2000);
});
});
it('can get userInfo with token', function (done) {
request.get(SERVER_URL + '/api/v1/users/' + USERNAME_0)
.query({ access_token: token })
.end(function (err, res) {
expect(res.statusCode).to.equal(200);
expect(res.body.username).to.equal(USERNAME_0);
expect(res.body.email).to.equal(EMAIL);
expect(res.body.admin).to.be.ok();
done(err);
});
});
it('cannot get userInfo only with basic auth', function (done) {
request.get(SERVER_URL + '/api/v1/users/' + USERNAME_0)
.auth(USERNAME_0, PASSWORD)
.end(function (err, res) {
expect(res.statusCode).to.equal(401);
done(err);
});
});
it('cannot get userInfo with invalid token (token length)', function (done) {
request.get(SERVER_URL + '/api/v1/users/' + USERNAME_0)
.query({ access_token: 'x' + token })
.end(function (err, res) {
expect(res.statusCode).to.equal(401);
done(err);
});
});
it('cannot get userInfo with invalid token (wrong token)', function (done) {
request.get(SERVER_URL + '/api/v1/users/' + USERNAME_0)
.query({ access_token: token.toUpperCase() })
.end(function (err, res) {
expect(res.statusCode).to.equal(401);
done(err);
});
});
it('can get userInfo with token in auth header', function (done) {
request.get(SERVER_URL + '/api/v1/users/' + USERNAME_0)
.set('Authorization', 'Bearer ' + token)
.end(function (err, res) {
expect(res.statusCode).to.equal(200);
expect(res.body.username).to.equal(USERNAME_0);
expect(res.body.email).to.equal(EMAIL);
expect(res.body.admin).to.be.ok();
expect(res.body.password).to.not.be.ok();
expect(res.body.salt).to.not.be.ok();
done(err);
});
});
it('cannot get userInfo with invalid token in auth header', function (done) {
request.get(SERVER_URL + '/api/v1/users/' + USERNAME_0)
.set('Authorization', 'Bearer ' + 'x' + token)
.end(function (err, res) {
expect(res.statusCode).to.equal(401);
done(err);
});
});
it('cannot get userInfo with invalid token (wrong token)', function (done) {
request.get(SERVER_URL + '/api/v1/users/' + USERNAME_0)
.set('Authorization', 'Bearer ' + 'x' + token.toUpperCase())
.end(function (err, res) {
expect(res.statusCode).to.equal(401);
done(err);
});
});
it('create second user succeeds', function (done) {
request.post(SERVER_URL + '/api/v1/users')
.query({ access_token: token })
.send({ username: USERNAME_1, email: EMAIL_1 })
.end(function (err, res) {
expect(err).to.not.be.ok();
expect(res.statusCode).to.equal(201);
// HACK to get a token for second user (passwords are generated and the user should have gotten a password setup link...)
tokendb.add(token_1, tokendb.PREFIX_USER + USERNAME_1, 'test-client-id', Date.now() + 10000, '*', done);
});
});
it('set second user as admin succeeds', function (done) {
// TODO is USERNAME_1 in body and url redundant?
request.post(SERVER_URL + '/api/v1/users/' + USERNAME_1 + '/admin')
.query({ access_token: token })
.send({ username: USERNAME_1, admin: true })
.end(function (err, res) {
expect(res.statusCode).to.equal(204);
done(err);
});
});
it('remove first user from admins succeeds', function (done) {
request.post(SERVER_URL + '/api/v1/users/' + USERNAME_0 + '/admin')
.query({ access_token: token_1 })
.send({ username: USERNAME_0, admin: false })
.end(function (err, res) {
expect(res.statusCode).to.equal(204);
done(err);
});
});
it('remove second user by first, now normal, user fails', function (done) {
request.del(SERVER_URL + '/api/v1/users/' + USERNAME_1)
.query({ access_token: token })
.send({ password: PASSWORD })
.end(function (err, res) {
expect(res.statusCode).to.equal(403);
done(err);
});
});
it('remove second user from admins and thus last admin fails', function (done) {
request.post(SERVER_URL + '/api/v1/users/' + USERNAME_1 + '/admin')
.query({ access_token: token_1 })
.send({ username: USERNAME_1, admin: false })
.end(function (err, res) {
expect(res.statusCode).to.equal(403);
done(err);
});
});
it('reset first user as admin succeeds', function (done) {
request.post(SERVER_URL + '/api/v1/users/' + USERNAME_0 + '/admin')
.query({ access_token: token_1 })
.send({ username: USERNAME_0, admin: true })
.end(function (err, res) {
expect(res.statusCode).to.equal(204);
done(err);
});
});
it('create user missing username fails', function (done) {
request.post(SERVER_URL + '/api/v1/users')
.query({ access_token: token })
.send({ email: EMAIL_2 })
.end(function (error, result) {
expect(error).to.not.be.ok();
expect(result.statusCode).to.equal(400);
done();
});
});
it('create user missing email fails', function (done) {
request.post(SERVER_URL + '/api/v1/users')
.query({ access_token: token })
.send({ username: USERNAME_2 })
.end(function (error, result) {
expect(error).to.not.be.ok();
expect(result.statusCode).to.equal(400);
done();
});
});
it('create second and third user', function (done) {
request.post(SERVER_URL + '/api/v1/users')
.query({ access_token: token })
.send({ username: USERNAME_2, email: EMAIL_2 })
.end(function (error, res) {
expect(error).to.not.be.ok();
expect(res.statusCode).to.equal(201);
request.post(SERVER_URL + '/api/v1/users')
.query({ access_token: token })
.send({ username: USERNAME_3, email: EMAIL_3 })
.end(function (error, res) {
expect(error).to.not.be.ok();
expect(res.statusCode).to.equal(201);
// HACK to get a token for second user (passwords are generated and the user should have gotten a password setup link...)
tokendb.add(token_2, tokendb.PREFIX_USER + USERNAME_2, 'test-client-id', Date.now() + 10000, '*', done);
});
});
});
it('second user userInfo', function (done) {
request.get(SERVER_URL + '/api/v1/users/' + USERNAME_2)
.query({ access_token: token_1 })
.end(function (error, result) {
expect(error).to.be(null);
expect(result.statusCode).to.equal(200);
expect(result.body.username).to.equal(USERNAME_2);
expect(result.body.email).to.equal(EMAIL_2);
expect(result.body.admin).to.not.be.ok();
done();
});
});
it('create user with same username should fail', function (done) {
request.post(SERVER_URL + '/api/v1/users')
.query({ access_token: token })
.send({ username: USERNAME_2, email: EMAIL })
.end(function (err, res) {
expect(res.statusCode).to.equal(409);
done(err);
});
});
it('list users', function (done) {
request.get(SERVER_URL + '/api/v1/users')
.query({ access_token: token_2 })
.end(function (error, res) {
expect(error).to.be(null);
expect(res.statusCode).to.equal(200);
expect(res.body.users).to.be.an('array');
expect(res.body.users.length).to.equal(4);
res.body.users.forEach(function (user) {
expect(user).to.be.an('object');
expect(user.id).to.be.ok();
expect(user.username).to.be.ok();
expect(user.email).to.be.ok();
expect(user.password).to.not.be.ok();
expect(user.salt).to.not.be.ok();
});
done();
});
});
it('user removes himself is not allowed', function (done) {
request.del(SERVER_URL + '/api/v1/users/' + USERNAME_0)
.query({ access_token: token })
.send({ password: PASSWORD })
.end(function (err, res) {
expect(res.statusCode).to.equal(403);
done(err);
});
});
it('admin cannot remove normal user without giving a password', function (done) {
request.del(SERVER_URL + '/api/v1/users/' + USERNAME_3)
.query({ access_token: token })
.end(function (err, res) {
expect(res.statusCode).to.equal(400);
done(err);
});
});
it('admin cannot remove normal user with empty password', function (done) {
request.del(SERVER_URL + '/api/v1/users/' + USERNAME_3)
.query({ access_token: token })
.send({ password: '' })
.end(function (err, res) {
expect(res.statusCode).to.equal(403);
done(err);
});
});
it('admin cannot remove normal user with giving wrong password', function (done) {
request.del(SERVER_URL + '/api/v1/users/' + USERNAME_3)
.query({ access_token: token })
.send({ password: PASSWORD + PASSWORD })
.end(function (err, res) {
expect(res.statusCode).to.equal(403);
done(err);
});
});
it('admin removes normal user', function (done) {
request.del(SERVER_URL + '/api/v1/users/' + USERNAME_3)
.query({ access_token: token })
.send({ password: PASSWORD })
.end(function (err, res) {
expect(res.statusCode).to.equal(204);
done(err);
});
});
it('admin removes himself should not be allowed', function (done) {
request.del(SERVER_URL + '/api/v1/users/' + USERNAME_0)
.query({ access_token: token })
.send({ password: PASSWORD })
.end(function (err, res) {
expect(res.statusCode).to.equal(403);
done(err);
});
});
// Change email
it('change email fails due to missing token', function (done) {
request.put(SERVER_URL + '/api/v1/users/' + USERNAME_0)
.send({ password: PASSWORD, email: EMAIL_0_NEW })
.end(function (error, result) {
expect(result.statusCode).to.equal(401);
done(error);
});
});
it('change email fails due to missing password', function (done) {
request.put(SERVER_URL + '/api/v1/users/' + USERNAME_0)
.query({ access_token: token })
.send({ email: EMAIL_0_NEW })
.end(function (error, result) {
expect(result.statusCode).to.equal(400);
done(error);
});
});
it('change email fails due to wrong password', function (done) {
request.put(SERVER_URL + '/api/v1/users/' + USERNAME_0)
.query({ access_token: token })
.send({ password: PASSWORD+PASSWORD, email: EMAIL_0_NEW })
.end(function (error, result) {
expect(result.statusCode).to.equal(403);
done(error);
});
});
it('change email fails due to invalid email', function (done) {
request.put(SERVER_URL + '/api/v1/users/' + USERNAME_0)
.query({ access_token: token })
.send({ password: PASSWORD, email: 'foo@bar' })
.end(function (error, result) {
expect(result.statusCode).to.equal(400);
done(error);
});
});
it('change email succeeds', function (done) {
request.put(SERVER_URL + '/api/v1/users/' + USERNAME_0)
.query({ access_token: token })
.send({ password: PASSWORD, email: EMAIL_0_NEW })
.end(function (error, result) {
expect(result.statusCode).to.equal(204);
done(error);
});
});
// Change password
it('change password fails due to missing current password', function (done) {
request.post(SERVER_URL + '/api/v1/users/' + USERNAME_0 + '/password')
.query({ access_token: token })
.send({ newPassword: 'some wrong password' })
.end(function (err, res) {
expect(res.statusCode).to.equal(400);
done(err);
});
});
it('change password fails due to missing new password', function (done) {
request.post(SERVER_URL + '/api/v1/users/' + USERNAME_0 + '/password')
.query({ access_token: token })
.send({ password: PASSWORD })
.end(function (err, res) {
expect(res.statusCode).to.equal(400);
done(err);
});
});
it('change password fails due to wrong password', function (done) {
request.post(SERVER_URL + '/api/v1/users/' + USERNAME_0 + '/password')
.query({ access_token: token })
.send({ password: 'some wrong password', newPassword: 'newpassword' })
.end(function (err, res) {
expect(res.statusCode).to.equal(403);
done(err);
});
});
it('change password fails due to invalid password', function (done) {
request.post(SERVER_URL + '/api/v1/users/' + USERNAME_0 + '/password')
.query({ access_token: token })
.send({ password: PASSWORD, newPassword: 'five' })
.end(function (err, res) {
expect(res.statusCode).to.equal(400);
done(err);
});
});
it('change password succeeds', function (done) {
request.post(SERVER_URL + '/api/v1/users/' + USERNAME_0 + '/password')
.query({ access_token: token })
.send({ password: PASSWORD, newPassword: 'new_password' })
.end(function (err, res) {
expect(res.statusCode).to.equal(204);
done(err);
});
});
});

200
src/routes/user.js Normal file
View File

@@ -0,0 +1,200 @@
/* jslint node:true */
'use strict';
exports = module.exports = {
profile: profile,
info: info,
update: update,
list: listUser,
create: createUser,
changePassword: changePassword,
changeAdmin: changeAdmin,
remove: removeUser,
verifyPassword: verifyPassword,
requireAdmin: requireAdmin
};
var assert = require('assert'),
HttpError = require('connect-lastmile').HttpError,
HttpSuccess = require('connect-lastmile').HttpSuccess,
user = require('../user.js'),
tokendb = require('../tokendb.js'),
UserError = user.UserError;
// http://stackoverflow.com/questions/1497481/javascript-password-generator#1497512
function generatePassword() {
var length = 8,
charset = 'abcdefghijklnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789',
retVal = '';
for (var i = 0, n = charset.length; i < length; ++i) {
retVal += charset.charAt(Math.floor(Math.random() * n));
}
return retVal;
}
function profile(req, res, next) {
assert.strictEqual(typeof req.user, 'object');
var result = {};
result.id = req.user.id;
result.tokenType = req.user.tokenType;
if (req.user.tokenType === tokendb.TYPE_USER || req.user.tokenType === tokendb.TYPE_DEV) {
result.username = req.user.username;
result.email = req.user.email;
result.admin = req.user.admin;
}
next(new HttpSuccess(200, result));
}
function createUser(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.email !== 'string') return next(new HttpError(400, 'email must be string'));
var username = req.body.username;
var password = generatePassword();
var email = req.body.email;
user.create(username, password, email, false /* admin */, req.user /* creator */, function (error, user) {
if (error && error.reason === UserError.BAD_USERNAME) return next(new HttpError(400, 'Invalid username'));
if (error && error.reason === UserError.BAD_EMAIL) return next(new HttpError(400, 'Invalid email'));
if (error && error.reason === UserError.BAD_PASSWORD) return next(new HttpError(400, 'Invalid password'));
if (error && error.reason === UserError.BAD_FIELD) return next(new HttpError(400, error.message));
if (error && error.reason === UserError.ALREADY_EXISTS) return next(new HttpError(409, 'Already exists'));
if (error) return next(new HttpError(500, error));
var userInfo = {
id: user.id,
username: user.username,
email: user.email,
admin: user.admin,
resetToken: user.resetToken
};
next(new HttpSuccess(201, { userInfo: userInfo }));
});
}
function update(req, res, next) {
assert.strictEqual(typeof req.user, 'object');
assert.strictEqual(typeof req.body, 'object');
if (typeof req.body.email !== 'string') return next(new HttpError(400, 'email must be string'));
if (req.user.tokenType !== tokendb.TYPE_USER) return next(new HttpError(403, 'Token type not allowed'));
user.update(req.user.id, req.user.username, req.body.email, function (error) {
if (error && error.reason === UserError.BAD_EMAIL) return next(new HttpError(400, error.message));
if (error && error.reason === UserError.NOT_FOUND) return next(new HttpError(404, 'User not found'));
if (error) return next(new HttpError(500, error));
next(new HttpSuccess(204));
});
}
function changeAdmin(req, res, next) {
assert.strictEqual(typeof req.body, 'object');
if (typeof req.body.username !== 'string') return next(new HttpError(400, 'API call requires a username.'));
if (typeof req.body.admin !== 'boolean') return next(new HttpError(400, 'API call requires an admin setting.'));
user.changeAdmin(req.body.username, req.body.admin, function (error) {
if (error && error.reason === UserError.NOT_ALLOWED) return next(new HttpError(403, 'Last admin'));
if (error) return next(new HttpError(500, error));
next(new HttpSuccess(204));
});
}
function changePassword(req, res, next) {
assert.strictEqual(typeof req.body, 'object');
assert.strictEqual(typeof req.user, 'object');
if (typeof req.body.password !== 'string') return next(new HttpError(400, 'API call requires the users old password.'));
if (typeof req.body.newPassword !== 'string') return next(new HttpError(400, 'API call requires the users new password.'));
if (req.user.tokenType !== tokendb.TYPE_USER) return next(new HttpError(403, 'Token type not allowed'));
user.changePassword(req.user.username, req.body.password, req.body.newPassword, function (error) {
if (error && error.reason === UserError.BAD_PASSWORD) return next(new HttpError(400, error.message));
if (error && error.reason === UserError.WRONG_PASSWORD) return next(new HttpError(403, 'Wrong password'));
if (error && error.reason === UserError.NOT_FOUND) return next(new HttpError(403, 'Wrong password'));
if (error) return next(new HttpError(500, error));
next(new HttpSuccess(204));
});
}
function listUser(req, res, next) {
user.list(function (error, result) {
if (error) return next(new HttpError(500, error));
next(new HttpSuccess(200, { users: result }));
});
}
function info(req, res, next) {
assert.strictEqual(typeof req.params.userId, 'string');
user.get(req.params.userId, function (error, result) {
if (error && error.reason === UserError.NOT_FOUND) return next(new HttpError(404, 'No such user'));
if (error) return next(new HttpError(500, error));
next(new HttpSuccess(200, {
id: result.id,
username: result.username,
email: result.email,
admin: result.admin
}));
});
}
function removeUser(req, res, next) {
assert.strictEqual(typeof req.params.userId, 'string');
// rules:
// - admin can remove any user
// - admin cannot remove admin
// - user cannot remove himself <- TODO should this actually work?
if (req.user.id === req.params.userId) return next(new HttpError(403, 'Not allowed to remove yourself.'));
user.remove(req.params.userId, function (error) {
if (error && error.reason === UserError.NOT_FOUND) return next(new HttpError(404, 'User not found'));
if (error) return next(new HttpError(500, error));
next(new HttpSuccess(204));
});
}
function verifyPassword(req, res, next) {
assert.strictEqual(typeof req.body, 'object');
// developers are allowed to through without password
if (req.user.tokenType === tokendb.TYPE_DEV) return next();
if (typeof req.body.password !== 'string') return next(new HttpError(400, 'API call requires user password'));
user.verify(req.user.username, req.body.password, function (error) {
if (error && error.reason === UserError.WRONG_PASSWORD) return next(new HttpError(403, 'Password incorrect'));
if (error && error.reason === UserError.NOT_FOUND) return next(new HttpError(403, 'Password incorrect'));
if (error) return next(new HttpError(500, error));
next();
});
}
/*
Middleware which makes the route only accessable for the admin user.
*/
function requireAdmin(req, res, next) {
assert.strictEqual(typeof req.user, 'object');
if (!req.user.admin) return next(new HttpError(403, 'API call requires admin rights.'));
next();
}

50
src/scripts/backupapp.sh Executable file
View File

@@ -0,0 +1,50 @@
#!/bin/bash
set -eu -o pipefail
if [[ $EUID -ne 0 ]]; then
echo "This script should be run as root." >&2
exit 1
fi
if [[ $# == 1 && "$1" == "--check" ]]; then
echo "OK"
exit 0
fi
if [ $# -lt 3 ]; then
echo "Usage: backup.sh <appid> <url> <key>"
exit 1
fi
readonly DATA_DIR="${HOME}/data"
app_id="$1"
backup_url="$2"
backup_key="$3"
readonly now=$(date "+%Y-%m-%dT%H:%M:%S")
readonly app_data_dir="${DATA_DIR}/${app_id}"
readonly app_data_snapshot="${DATA_DIR}/snapshots/${app_id}-${now}"
btrfs subvolume snapshot -r "${app_data_dir}" "${app_data_snapshot}"
for try in `seq 1 5`; do
echo "Uploading backup to ${backup_url} (try ${try})"
error_log=$(mktemp)
if tar -cvzf - -C "${app_data_snapshot}" . \
| openssl aes-256-cbc -e -pass "pass:${backup_key}" \
| curl --fail -H "Content-Type:" -X PUT --data-binary @- "${backup_url}" 2>"${error_log}"; then
break
fi
cat "${error_log}" && rm "${error_log}"
done
btrfs subvolume delete "${app_data_snapshot}"
if [[ ${try} -eq 5 ]]; then
echo "Backup failed"
exit 1
else
echo "Backup successful"
fi

52
src/scripts/backupbox.sh Executable file
View File

@@ -0,0 +1,52 @@
#!/bin/bash
set -eu -o pipefail
if [[ $EUID -ne 0 ]]; then
echo "This script should be run as root." >&2
exit 1
fi
if [[ $# == 1 && "$1" == "--check" ]]; then
echo "OK"
exit 0
fi
if [ $# -lt 2 ]; then
echo "Usage: backupbox.sh <url> <key>"
exit 1
fi
backup_url="$1"
backup_key="$2"
now=$(date "+%Y-%m-%dT%H:%M:%S")
BOX_DATA_DIR="${HOME}/data/box"
box_snapshot_dir="${HOME}/data/snapshots/box-${now}"
echo "Creating MySQL dump"
mysqldump -u root -ppassword --single-transaction --routines --triggers box > "${BOX_DATA_DIR}/box.mysqldump"
echo "Snapshoting backup as backup-${now}"
btrfs subvolume snapshot -r "${BOX_DATA_DIR}" "${box_snapshot_dir}"
for try in `seq 1 5`; do
echo "Uploading backup to ${backup_url} (try ${try})"
error_log=$(mktemp)
if tar -cvzf - -C "${box_snapshot_dir}" . \
| openssl aes-256-cbc -e -pass "pass:${backup_key}" \
| curl --fail -H "Content-Type:" -X PUT --data-binary @- "${backup_url}" 2>"${error_log}"; then
break
fi
cat "${error_log}" && rm "${error_log}"
done
echo "Deleting backup snapshot"
btrfs subvolume delete "${box_snapshot_dir}"
if [[ ${try} -eq 5 ]]; then
echo "Backup failed"
exit 1
else
echo "Backup successful"
fi

41
src/scripts/backupswap.sh Executable file
View File

@@ -0,0 +1,41 @@
#!/bin/bash
set -eu -o pipefail
if [[ ${EUID} -ne 0 ]]; then
echo "This script should be run as root." > /dev/stderr
exit 1
fi
if [[ $# -eq 0 ]]; then
echo "No arguments supplied"
exit 1
fi
if [[ "$1" == "--check" ]]; then
echo "OK"
exit 0
fi
BACKUP_SWAP_FILE="/backup.swap"
if [[ "$1" == "--on" ]]; then
echo "Mounting backup swap"
if ! swapon -s | grep -q "${BACKUP_SWAP_FILE}"; then
swapon "${BACKUP_SWAP_FILE}"
else
echo "Backup swap already mounted"
fi
fi
if [[ "$1" == "--off" ]]; then
echo "Unmounting backup swap"
if swapon -s | grep -q "${BACKUP_SWAP_FILE}"; then
swapoff "${BACKUP_SWAP_FILE}"
else
echo "Backup swap was not mounted"
fi
fi

30
src/scripts/createappdir.sh Executable file
View File

@@ -0,0 +1,30 @@
#!/bin/bash
set -eu -o pipefail
if [[ ${EUID} -ne 0 ]]; then
echo "This script should be run as root." > /dev/stderr
exit 1
fi
if [[ $# -eq 0 ]]; then
echo "No arguments supplied"
exit 1
fi
if [[ "$1" == "--check" ]]; then
echo "OK"
exit 0
fi
if [[ "${NODE_ENV}" == "cloudron" ]]; then
readonly app_data_dir="${HOME}/data/$1"
btrfs subvolume create "${app_data_dir}"
mkdir -p "${app_data_dir}/data"
chown -R yellowtent:yellowtent "${app_data_dir}"
else
readonly app_data_dir="${HOME}/.cloudron_test/data/$1"
mkdir -p "${app_data_dir}/data"
chown -R ${SUDO_USER}:${SUDO_USER} "${app_data_dir}"
fi

18
src/scripts/reboot.sh Executable file
View File

@@ -0,0 +1,18 @@
#!/bin/bash
set -eu -o pipefail
if [[ ${EUID} -ne 0 ]]; then
echo "This script should be run as root." > /dev/stderr
exit 1
fi
if [[ $# == 1 && "$1" == "--check" ]]; then
echo "OK"
exit 0
fi
if [[ "${NODE_ENV}" == "cloudron" ]]; then
shutdown -r now
fi

18
src/scripts/reloadcollectd.sh Executable file
View File

@@ -0,0 +1,18 @@
#!/bin/bash
set -eu -o pipefail
if [[ ${EUID} -ne 0 ]]; then
echo "This script should be run as root." > /dev/stderr
exit 1
fi
if [[ $# == 1 && "$1" == "--check" ]]; then
echo "OK"
exit 0
fi
if [[ "${NODE_ENV}" == "cloudron" ]]; then
/etc/init.d/collectd restart
fi

23
src/scripts/reloadnginx.sh Executable file
View File

@@ -0,0 +1,23 @@
#!/bin/bash
set -eu -o pipefail
if [[ ${EUID} -ne 0 ]]; then
echo "This script should be run as root." > /dev/stderr
exit 1
fi
if [[ $# == 1 && "$1" == "--check" ]]; then
echo "OK"
exit 0
fi
if [[ "${OSTYPE}" == "darwin"* ]]; then
# On Mac, brew installs supervisor in /usr/local/bin
export PATH=$PATH:/usr/local/bin
fi
if [[ "${NODE_ENV}" == "cloudron" ]]; then
nginx -s reload
fi

48
src/scripts/restoreapp.sh Executable file
View File

@@ -0,0 +1,48 @@
#!/bin/bash
set -eu -o pipefail
if [[ $EUID -ne 0 ]]; then
echo "This script should be run as root." >&2
exit 1
fi
if [[ $# == 1 && "$1" == "--check" ]]; then
echo "OK"
exit 0
fi
if [ $# -lt 3 ]; then
echo "Usage: restoreapp.sh <appid> <url> <key>"
exit 1
fi
readonly DATA_DIR="${HOME}/data"
readonly curl="curl --fail --connect-timeout 20 --retry 10 --retry-delay 2 --max-time 2400"
app_id="$1"
restore_url="$2"
restore_key="$3"
echo "Downloading backup: ${restore_url} and key: ${restore_key}"
for try in `seq 1 5`; do
echo "Download backup from ${restore_url} (try ${try})"
error_log=$(mktemp)
if $curl -L "${restore_url}" \
| openssl aes-256-cbc -d -pass "pass:${restore_key}" \
| tar -zxf - -C "${DATA_DIR}/${app_id}" 2>"${error_log}"; then
chown -R yellowtent:yellowtent "${DATA_DIR}/${app_id}"
break
fi
cat "${error_log}" && rm "${error_log}"
done
if [[ ${try} -eq 5 ]]; then
echo "restore failed"
exit 1
else
echo "restore successful"
fi

30
src/scripts/rmappdir.sh Executable file
View File

@@ -0,0 +1,30 @@
#!/bin/bash
set -eu -o pipefail
if [[ ${EUID} -ne 0 ]]; then
echo "This script should be run as root." > /dev/stderr
exit 1
fi
if [[ $# -eq 0 ]]; then
echo "No arguments supplied"
exit 1
fi
if [[ "$1" == "--check" ]]; then
echo "OK"
exit 0
fi
if [[ "${NODE_ENV}" == "cloudron" ]]; then
readonly app_data_dir="${HOME}/data/$1"
if [[ -d "${app_data_dir}" ]]; then
find "${app_data_dir}" -mindepth 1 -delete
rm -rf "${app_data_dir}" || btrfs subvolume delete "${app_data_dir}"
fi
else
readonly app_data_dir="${HOME}/.cloudron_test/data/$1"
rm -rf "${app_data_dir}"
fi

267
src/server.js Normal file
View File

@@ -0,0 +1,267 @@
/* jslint node: true */
'use strict';
exports = module.exports = {
start: start,
stop: stop
};
var assert = require('assert'),
async = require('async'),
auth = require('./auth.js'),
cloudron = require('./cloudron.js'),
cron = require('./cron.js'),
config = require('./config.js'),
database = require('./database.js'),
express = require('express'),
http = require('http'),
mailer = require('./mailer.js'),
middleware = require('./middleware'),
passport = require('passport'),
path = require('path'),
paths = require('./paths.js'),
routes = require('./routes/index.js'),
taskmanager = require('./taskmanager.js');
var gHttpServer = null;
var gInternalHttpServer = null;
function initializeExpressSync() {
var app = express();
var httpServer = http.createServer(app);
var QUERY_LIMIT = '10mb', // max size for json and urlencoded queries
FIELD_LIMIT = 2 * 1024 * 1024; // max fields that can appear in multipart
var REQUEST_TIMEOUT = 10000; // timeout for all requests
var json = middleware.json({ strict: true, limit: QUERY_LIMIT }), // application/json
urlencoded = middleware.urlencoded({ extended: false, limit: QUERY_LIMIT }); // application/x-www-form-urlencoded
app.set('views', path.join(__dirname, 'oauth2views'));
app.set('view options', { layout: true, debug: true });
app.set('view engine', 'ejs');
if (process.env.NODE_ENV === 'test') {
app.use(express.static(path.join(__dirname, '/../webadmin')));
} else {
app.use(middleware.morgan('dev', { immediate: false }));
}
var router = new express.Router();
router.del = router.delete; // amend router.del for readability further on
app
.use(middleware.timeout(REQUEST_TIMEOUT))
.use(json)
.use(urlencoded)
.use(middleware.cookieParser())
.use(middleware.favicon(paths.FAVICON_FILE)) // used when serving oauth login page
.use(middleware.cors({ origins: [ '*' ], allowCredentials: true }))
.use(middleware.session({ secret: 'yellow is blue', resave: true, saveUninitialized: true, cookie: { path: '/', httpOnly: true, secure: false, maxAge: 600000 } }))
.use(passport.initialize())
.use(passport.session())
.use(router)
.use(middleware.lastMile());
// NOTE: these limits have to be in sync with nginx limits
var FILE_SIZE_LIMIT = '1mb', // max file size that can be uploaded
FILE_TIMEOUT = 60 * 1000; // increased timeout for file uploads (1 min)
var multipart = middleware.multipart({ maxFieldsSize: FIELD_LIMIT, limit: FILE_SIZE_LIMIT, timeout: FILE_TIMEOUT });
// scope middleware implicitly also adds bearer token verification
var rootScope = routes.oauth2.scope('root');
var profileScope = routes.oauth2.scope('profile');
var usersScope = routes.oauth2.scope('users');
var appsScope = routes.oauth2.scope('apps');
var developerScope = routes.oauth2.scope('developer');
var settingsScope = routes.oauth2.scope('settings');
// csrf protection
var csrf = routes.oauth2.csrf;
// public routes
router.post('/api/v1/cloudron/activate', routes.cloudron.setupTokenAuth, routes.cloudron.activate);
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
router.post('/api/v1/developer', developerScope, routes.user.requireAdmin, routes.user.verifyPassword, routes.developer.setEnabled);
router.get ('/api/v1/developer', developerScope, routes.developer.enabled, routes.developer.status);
router.post('/api/v1/developer/login', routes.developer.enabled, routes.developer.login);
// private routes
router.get ('/api/v1/cloudron/config', rootScope, routes.cloudron.getConfig);
router.post('/api/v1/cloudron/update', rootScope, routes.user.requireAdmin, routes.user.verifyPassword, routes.cloudron.update);
router.get ('/api/v1/cloudron/reboot', rootScope, routes.cloudron.reboot);
router.post('/api/v1/cloudron/migrate', rootScope, routes.user.requireAdmin, routes.user.verifyPassword, routes.cloudron.migrate);
router.post('/api/v1/cloudron/certificate', rootScope, multipart, routes.cloudron.setCertificate);
router.get ('/api/v1/cloudron/graphs', rootScope, routes.graphs.getGraphs);
router.get ('/api/v1/profile', profileScope, routes.user.profile);
router.get ('/api/v1/users', usersScope, routes.user.list);
router.post('/api/v1/users', usersScope, routes.user.requireAdmin, routes.user.create);
router.get ('/api/v1/users/:userId', usersScope, routes.user.info);
router.put ('/api/v1/users/:userId', usersScope, routes.user.verifyPassword, routes.user.update);
router.del ('/api/v1/users/:userId', usersScope, routes.user.requireAdmin, routes.user.verifyPassword, routes.user.remove);
router.post('/api/v1/users/:userId/password', usersScope, routes.user.changePassword); // changePassword verifies password
router.post('/api/v1/users/:userId/admin', usersScope, routes.user.requireAdmin, routes.user.changeAdmin);
// form based login routes used by oauth2 frame
router.get ('/api/v1/session/login', csrf, routes.oauth2.loginForm);
router.post('/api/v1/session/login', csrf, routes.oauth2.login);
router.get ('/api/v1/session/logout', routes.oauth2.logout);
router.get ('/api/v1/session/callback', routes.oauth2.callback);
router.get ('/api/v1/session/error', routes.oauth2.error);
router.get ('/api/v1/session/password/resetRequest.html', csrf, routes.oauth2.passwordResetRequestSite);
router.post('/api/v1/session/password/resetRequest', csrf, routes.oauth2.passwordResetRequest);
router.get ('/api/v1/session/password/sent.html', routes.oauth2.passwordSentSite);
router.get ('/api/v1/session/password/setup.html', csrf, routes.oauth2.passwordSetupSite);
router.get ('/api/v1/session/password/reset.html', csrf, routes.oauth2.passwordResetSite);
router.post('/api/v1/session/password/reset', csrf, routes.oauth2.passwordReset);
// oauth2 routes
router.get ('/api/v1/oauth/dialog/authorize', routes.oauth2.authorization);
router.post('/api/v1/oauth/dialog/authorize/decision', csrf, routes.oauth2.decision);
router.post('/api/v1/oauth/token', routes.oauth2.token);
router.get ('/api/v1/oauth/clients', settingsScope, routes.clients.getAllByUserId);
router.post('/api/v1/oauth/clients', routes.developer.enabled, settingsScope, routes.clients.add);
router.get ('/api/v1/oauth/clients/:clientId', routes.developer.enabled, settingsScope, routes.clients.get);
router.post('/api/v1/oauth/clients/:clientId', routes.developer.enabled, settingsScope, routes.clients.add);
router.put ('/api/v1/oauth/clients/:clientId', routes.developer.enabled, settingsScope, routes.clients.update);
router.del ('/api/v1/oauth/clients/:clientId', routes.developer.enabled, settingsScope, routes.clients.del);
router.get ('/api/v1/oauth/clients/:clientId/tokens', settingsScope, routes.clients.getClientTokens);
router.del ('/api/v1/oauth/clients/:clientId/tokens', settingsScope, routes.clients.delClientTokens);
// app routes
router.get ('/api/v1/apps', appsScope, routes.apps.getApps);
router.get ('/api/v1/apps/:id', appsScope, routes.apps.getApp);
router.get ('/api/v1/apps/:id/icon', appsScope, routes.apps.getAppIcon);
router.post('/api/v1/apps/install', appsScope, routes.user.requireAdmin, routes.apps.installApp);
router.post('/api/v1/apps/:id/uninstall', appsScope, routes.user.requireAdmin, routes.user.verifyPassword, routes.apps.uninstallApp);
router.post('/api/v1/apps/:id/configure', appsScope, routes.user.requireAdmin, routes.user.verifyPassword, routes.apps.configureApp);
router.post('/api/v1/apps/:id/update', appsScope, routes.user.requireAdmin, routes.user.verifyPassword, routes.apps.updateApp);
router.post('/api/v1/apps/:id/restore', appsScope, routes.user.requireAdmin, routes.user.verifyPassword, routes.apps.restoreApp);
router.post('/api/v1/apps/:id/backup', appsScope, routes.user.requireAdmin, routes.apps.backupApp);
router.post('/api/v1/apps/:id/stop', appsScope, routes.user.requireAdmin, routes.apps.stopApp);
router.post('/api/v1/apps/:id/start', appsScope, routes.user.requireAdmin, routes.apps.startApp);
router.get ('/api/v1/apps/:id/logstream', appsScope, routes.user.requireAdmin, routes.apps.getLogStream);
router.get ('/api/v1/apps/:id/logs', appsScope, routes.user.requireAdmin, routes.apps.getLogs);
router.get ('/api/v1/apps/:id/exec', routes.developer.enabled, appsScope, routes.user.requireAdmin, routes.apps.exec);
// subdomain routes
router.get ('/api/v1/subdomains/:subdomain', routes.apps.getAppBySubdomain);
// settings routes
router.get ('/api/v1/settings/autoupdate_pattern', settingsScope, routes.settings.getAutoupdatePattern);
router.post('/api/v1/settings/autoupdate_pattern', settingsScope, routes.settings.setAutoupdatePattern);
router.get ('/api/v1/settings/cloudron_name', settingsScope, routes.settings.getCloudronName);
router.post('/api/v1/settings/cloudron_name', settingsScope, routes.settings.setCloudronName);
router.get ('/api/v1/settings/cloudron_avatar', settingsScope, routes.settings.getCloudronAvatar);
router.post('/api/v1/settings/cloudron_avatar', settingsScope, multipart, routes.settings.setCloudronAvatar);
// backup routes
router.get ('/api/v1/backups', settingsScope, routes.backups.get);
router.post('/api/v1/backups', settingsScope, routes.backups.create);
// upgrade handler
httpServer.on('upgrade', function (req, socket, head) {
if (req.headers['upgrade'] !== 'tcp') return req.end('Only TCP upgrades are possible');
// create a node response object for express
var res = new http.ServerResponse({});
res.assignSocket(socket);
res.sendUpgradeHandshake = function () { // could extend express.response as well
socket.write('HTTP/1.1 101 TCP Handshake\r\n' +
'Upgrade: tcp\r\n' +
'Connection: Upgrade\r\n' +
'\r\n');
};
// route through express middleware
app(req, res, function (error) {
if (error) {
console.error(error);
socket.destroy();
}
});
});
return httpServer;
}
function initializeInternalExpressSync() {
var app = express();
var httpServer = http.createServer(app);
var QUERY_LIMIT = '10mb'; // max size for json and urlencoded queries
var REQUEST_TIMEOUT = 10000; // timeout for all requests
var json = middleware.json({ strict: true, limit: QUERY_LIMIT }), // application/json
urlencoded = middleware.urlencoded({ extended: false, limit: QUERY_LIMIT }); // application/x-www-form-urlencoded
app.use(middleware.morgan('dev', { immediate: false }));
var router = new express.Router();
router.del = router.delete; // amend router.del for readability further on
app
.use(middleware.timeout(REQUEST_TIMEOUT))
.use(json)
.use(urlencoded)
.use(router)
.use(middleware.lastMile());
// internal routes
router.post('/api/v1/backup', routes.internal.backup);
return httpServer;
}
function start(callback) {
assert.strictEqual(typeof callback, 'function');
assert.strictEqual(gHttpServer, null, 'Server is already up and running.');
gHttpServer = initializeExpressSync();
gInternalHttpServer = initializeInternalExpressSync();
async.series([
auth.initialize,
database.initialize,
taskmanager.initialize,
cloudron.initialize,
mailer.initialize,
cron.initialize,
gHttpServer.listen.bind(gHttpServer, config.get('port'), '127.0.0.1'),
gInternalHttpServer.listen.bind(gInternalHttpServer, config.get('internalPort'), '127.0.0.1')
], callback);
}
function stop(callback) {
assert.strictEqual(typeof callback, 'function');
if (!gHttpServer) return callback(null);
async.series([
auth.uninitialize,
cloudron.uninitialize,
taskmanager.uninitialize,
cron.uninitialize,
mailer.uninitialize,
database.uninitialize,
gHttpServer.close.bind(gHttpServer),
gInternalHttpServer.close.bind(gInternalHttpServer)
], function (error) {
if (error) console.error(error);
gHttpServer = null;
gInternalHttpServer = null;
callback(null);
});
}

199
src/settings.js Normal file
View File

@@ -0,0 +1,199 @@
/* jslint node:true */
'use strict';
exports = module.exports = {
SettingsError: SettingsError,
getAutoupdatePattern: getAutoupdatePattern,
setAutoupdatePattern: setAutoupdatePattern,
getTimeZone: getTimeZone,
setTimeZone: setTimeZone,
getCloudronName: getCloudronName,
setCloudronName: setCloudronName,
getCloudronAvatar: getCloudronAvatar,
setCloudronAvatar: setCloudronAvatar,
getDefaultSync: getDefaultSync,
getAll: getAll,
AUTOUPDATE_PATTERN_KEY: 'autoupdate_pattern',
TIME_ZONE_KEY: 'time_zone',
CLOUDRON_NAME_KEY: 'cloudron_name',
events: new (require('events').EventEmitter)()
};
var assert = require('assert'),
config = require('./config.js'),
CronJob = require('cron').CronJob,
DatabaseError = require('./databaseerror.js'),
paths = require('./paths.js'),
safe = require('safetydance'),
settingsdb = require('./settingsdb.js'),
util = require('util'),
_ = require('underscore');
var gDefaults = (function () {
var tz = safe.fs.readFileSync('/etc/timezone', 'utf8');
tz = tz ? tz.trim() : 'America/Los_Angeles';
var result = { };
result[exports.AUTOUPDATE_PATTERN_KEY] = '00 00 1,3,5,23 * * *';
result[exports.TIME_ZONE_KEY] = tz;
result[exports.CLOUDRON_NAME_KEY] = 'Cloudron';
return result;
})();
if (config.TEST) {
// avoid noisy warnings during npm test
exports.events.setMaxListeners(100);
}
function SettingsError(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(SettingsError, Error);
SettingsError.INTERNAL_ERROR = 'Internal Error';
SettingsError.NOT_FOUND = 'Not Found';
SettingsError.BAD_FIELD = 'Bad Field';
function setAutoupdatePattern(pattern, callback) {
assert.strictEqual(typeof pattern, 'string');
assert.strictEqual(typeof callback, 'function');
if (pattern !== 'never') { // check if pattern is valid
var job = safe.safeCall(function () { return new CronJob(pattern); });
if (!job) return callback(new SettingsError(SettingsError.BAD_FIELD, 'Invalid pattern'));
}
settingsdb.set(exports.AUTOUPDATE_PATTERN_KEY, pattern, function (error) {
if (error) return callback(new SettingsError(SettingsError.INTERNAL_ERROR, error));
exports.events.emit(exports.AUTOUPDATE_PATTERN_KEY, pattern);
return callback(null);
});
}
function getAutoupdatePattern(callback) {
assert.strictEqual(typeof callback, 'function');
settingsdb.get(exports.AUTOUPDATE_PATTERN_KEY, function (error, pattern) {
if (error && error.reason === DatabaseError.NOT_FOUND) return callback(null, gDefaults[exports.AUTOUPDATE_PATTERN_KEY]);
if (error) return callback(new SettingsError(SettingsError.INTERNAL_ERROR, error));
callback(null, pattern);
});
}
function setTimeZone(tz, callback) {
assert.strictEqual(typeof tz, 'string');
assert.strictEqual(typeof callback, 'function');
settingsdb.set(exports.TIME_ZONE_KEY, tz, function (error) {
if (error) return callback(new SettingsError(SettingsError.INTERNAL_ERROR, error));
exports.events.emit(exports.TIME_ZONE_KEY, tz);
return callback(null);
});
}
function getTimeZone(callback) {
assert.strictEqual(typeof callback, 'function');
settingsdb.get(exports.TIME_ZONE_KEY, function (error, tz) {
if (error && error.reason === DatabaseError.NOT_FOUND) return callback(null, gDefaults[exports.AUTOUPDATE_PATTERN_KEY]);
if (error) return callback(new SettingsError(SettingsError.INTERNAL_ERROR, error));
callback(null, tz);
});
}
function getCloudronName(callback) {
assert.strictEqual(typeof callback, 'function');
settingsdb.get(exports.CLOUDRON_NAME_KEY, function (error, name) {
if (error && error.reason === DatabaseError.NOT_FOUND) return callback(null, gDefaults[exports.CLOUDRON_NAME_KEY]);
if (error) return callback(new SettingsError(SettingsError.INTERNAL_ERROR, error));
callback(null, name);
});
}
function setCloudronName(name, callback) {
assert.strictEqual(typeof name, 'string');
assert.strictEqual(typeof callback, 'function');
if (!name) return callback(new SettingsError(SettingsError.BAD_FIELD));
settingsdb.set(exports.CLOUDRON_NAME_KEY, name, function (error) {
if (error) return callback(new SettingsError(SettingsError.INTERNAL_ERROR, error));
exports.events.emit(exports.CLOUDRON_NAME_KEY, name);
return callback(null);
});
}
function getCloudronAvatar(callback) {
assert.strictEqual(typeof callback, 'function');
var avatar = safe.fs.readFileSync(paths.CLOUDRON_AVATAR_FILE);
if (avatar) return callback(null, avatar);
// try default fallback
avatar = safe.fs.readFileSync(paths.CLOUDRON_DEFAULT_AVATAR_FILE);
if (avatar) return callback(null, avatar);
callback(new SettingsError(SettingsError.INTERNAL_ERROR, safe.error));
}
function setCloudronAvatar(avatar, callback) {
assert(util.isBuffer(avatar));
assert.strictEqual(typeof callback, 'function');
if (!safe.fs.writeFileSync(paths.CLOUDRON_AVATAR_FILE, avatar)) {
return callback(new SettingsError(SettingsError.INTERNAL_ERROR, safe.error));
}
return callback(null);
}
function getDefaultSync(name) {
assert.strictEqual(typeof name, 'string');
return gDefaults[name];
}
function getAll(callback) {
assert.strictEqual(typeof callback, 'function');
settingsdb.getAll(function (error, settings) {
if (error) return callback(new SettingsError(SettingsError.INTERNAL_ERROR, error));
var result = _.extend({ }, gDefaults);
settings.forEach(function (setting) { result[setting.name] = setting.value; });
callback(null, result);
});
}

54
src/settingsdb.js Normal file
View File

@@ -0,0 +1,54 @@
/* jslint node:true */
'use strict';
exports = module.exports = {
get: get,
getAll: getAll,
set: set,
_clear: clear
};
var assert = require('assert'),
database = require('./database.js'),
DatabaseError = require('./databaseerror');
function get(key, callback) {
assert.strictEqual(typeof key, 'string');
assert.strictEqual(typeof callback, 'function');
database.query('SELECT * FROM settings WHERE name = ?', [ key ], function (error, result) {
if (error) return callback(new DatabaseError(DatabaseError.INTERNAL_ERROR, error));
if (result.length === 0) return callback(new DatabaseError(DatabaseError.NOT_FOUND));
callback(null, result[0].value);
});
}
function getAll(callback) {
database.query('SELECT * FROM settings ORDER BY name', function (error, results) {
if (error) return callback(new DatabaseError(DatabaseError.INTERNAL_ERROR, error));
callback(null, results);
});
}
function set(key, value, callback) {
assert.strictEqual(typeof key, 'string');
assert(value === null || typeof value === 'string');
assert.strictEqual(typeof callback, 'function');
database.query('INSERT INTO settings (name, value) VALUES (?, ?) ON DUPLICATE KEY UPDATE value=VALUES(value)', [ key, value ], function (error) {
if (error) return callback(new DatabaseError(DatabaseError.INTERNAL_ERROR, error)); // don't rely on affectedRows here since it gives 2
callback(null);
});
}
function clear(callback) {
database.query('DELETE FROM settings', function (error) {
if (error) return callback(new DatabaseError(DatabaseError.INTERNAL_ERROR, error));
callback(error);
});
}

58
src/shell.js Normal file
View File

@@ -0,0 +1,58 @@
'use strict';
exports = module.exports = {
sudo: sudo,
exec: exec
};
var assert = require('assert'),
child_process = require('child_process'),
debug = require('debug')('box:shell.js'),
once = require('once'),
util = require('util');
var SUDO = '/usr/bin/sudo';
function exec(tag, file, args, callback) {
assert.strictEqual(typeof tag, 'string');
assert.strictEqual(typeof file, 'string');
assert(util.isArray(args));
assert.strictEqual(typeof callback, 'function');
callback = once(callback); // exit may or may not be called after an 'error'
debug(tag + ' execFile: %s %s', file, args.join(' '));
var cp = child_process.spawn(file, args);
cp.stdout.on('data', function (data) {
debug(tag + ' (stdout): %s', data.toString('utf8'));
});
cp.stderr.on('data', function (data) {
debug(tag + ' (stderr): %s', data.toString('utf8'));
});
cp.on('exit', function (code, signal) {
if (code || signal) debug(tag + ' code: %s, signal: %s', code, signal);
callback(code === 0 ? null : new Error(util.format('Exited with error %s signal %s', code, signal)));
});
cp.on('error', function (error) {
debug(tag + ' code: %s, signal: %s', error.code, error.signal);
callback(error);
});
return cp;
}
function sudo(tag, args, callback) {
assert.strictEqual(typeof tag, 'string');
assert(util.isArray(args));
assert.strictEqual(typeof callback, 'function');
// -S makes sudo read stdin for password
var cp = exec(tag, SUDO, [ '-S' ].concat(args), callback);
cp.stdin.end();
}

28
src/sysinfo.js Normal file
View File

@@ -0,0 +1,28 @@
'use strict';
exports = module.exports = {
getIp: getIp
};
var os = require('os');
var gCachedIp = null;
function getIp() {
if (gCachedIp) return gCachedIp;
var ifaces = os.networkInterfaces();
for (var dev in ifaces) {
if (dev.match(/^(en|eth|wlp).*/) === null) continue;
for (var i = 0; i < ifaces[dev].length; i++) {
if (ifaces[dev][i].family === 'IPv4') {
gCachedIp = ifaces[dev][i].address;
return gCachedIp;
}
}
}
return null;
}

102
src/taskmanager.js Normal file
View File

@@ -0,0 +1,102 @@
'use strict';
exports = module.exports = {
initialize: initialize,
uninitialize: uninitialize,
restartAppTask: restartAppTask
};
var appdb = require('./appdb.js'),
assert = require('assert'),
child_process = require('child_process'),
debug = require('debug')('box:taskmanager'),
locker = require('./locker.js'),
_ = require('underscore');
var gActiveTasks = { };
var gPendingTasks = [ ];
// Task concurrency is 1 for two reasons:
// 1. The backup scripts (app and box) turn off swap after finish disregarding other backup processes
// 2. apptask getFreePort has race with multiprocess
var TASK_CONCURRENCY = 1;
var NOOP_CALLBACK = function (error) { console.error(error); };
function initialize(callback) {
assert.strictEqual(typeof callback, 'function');
// resume app installs and uninstalls
appdb.getAll(function (error, apps) {
if (error) return callback(error);
apps.forEach(function (app) {
debug('Creating process for %s (%s) with state %s', app.location, app.id, app.installationState);
startAppTask(app.id);
});
callback(null);
});
locker.on('unlocked', startNextTask);
}
function uninitialize(callback) {
assert.strictEqual(typeof callback, 'function');
gPendingTasks = [ ]; // clear this first, otherwise stopAppTask will resume them
for (var appId in gActiveTasks) {
stopAppTask(appId);
}
callback(null);
}
function startNextTask() {
if (gPendingTasks.length === 0) return;
assert(Object.keys(gActiveTasks).length === 0); // since we allow only one task at a time
startAppTask(gPendingTasks.shift());
}
function startAppTask(appId) {
assert.strictEqual(typeof appId, 'string');
assert(!(appId in gActiveTasks));
var lockError = locker.lock(locker.OP_APPTASK);
if (lockError || Object.keys(gActiveTasks).length >= TASK_CONCURRENCY) {
debug('Reached concurrency limit, queueing task for %s', appId);
gPendingTasks.push(appId);
return;
}
gActiveTasks[appId] = child_process.fork(__dirname + '/apptask.js', [ appId ]);
gActiveTasks[appId].once('exit', function (code) {
debug('Task for %s completed with status %s', appId, code);
if (code && code !== 50) { // apptask crashed
appdb.update(appId, { installationState: appdb.ISTATE_ERROR, installationProgress: 'Apptask crashed with code ' + code }, NOOP_CALLBACK);
}
delete gActiveTasks[appId];
locker.unlock(locker.OP_APPTASK); // unlock event will trigger next task
});
}
function stopAppTask(appId) {
assert.strictEqual(typeof appId, 'string');
if (gActiveTasks[appId]) {
debug('stopAppTask : Killing existing task of %s with pid %s: ', appId, gActiveTasks[appId].pid);
gActiveTasks[appId].kill(); // this will end up calling the 'exit' handler
delete gActiveTasks[appId];
} else if (gPendingTasks.indexOf(appId) !== -1) {
debug('stopAppTask: Removing existing pending task : %s', appId);
gPendingTasks = _.without(gPendingTasks, appId);
}
}
function restartAppTask(appId) {
stopAppTask(appId);
startAppTask(appId);
}

161
src/test/apps-test.js Normal file
View File

@@ -0,0 +1,161 @@
/* jslint node:true */
/* global it:false */
/* global describe:false */
/* global before:false */
/* global after:false */
'use strict';
var appdb = require('../appdb.js'),
apps = require('../apps.js'),
AppsError = apps.AppsError,
async = require('async'),
config = require('../config.js'),
constants = require('../constants.js'),
database = require('../database.js'),
expect = require('expect.js');
describe('Apps', function () {
var APP_0 = {
id: 'appid-0',
appStoreId: 'appStoreId-0',
installationState: appdb.ISTATE_PENDING_INSTALL,
installationProgress: null,
runState: null,
location: 'some-location-0',
manifest: {
version: '0.1', dockerImage: 'docker/app0', healthCheckPath: '/', httpPort: 80, title: 'app0',
tcpPorts: {
PORT: {
description: 'this is a port that i expose',
containerPort: '1234'
}
}
},
httpPort: null,
containerId: null,
portBindings: { PORT: 5678 },
healthy: null,
accessRestriction: ''
};
before(function (done) {
async.series([
database.initialize,
database._clear,
appdb.add.bind(null, APP_0.id, APP_0.appStoreId, APP_0.manifest, APP_0.location, APP_0.portBindings, APP_0.accessRestriction)
], done);
});
after(function (done) {
database._clear(done);
});
describe('validateHostname', function () {
it('does not allow admin subdomain', function () {
expect(apps._validateHostname(constants.ADMIN_LOCATION, 'cloudron.us')).to.be.an(Error);
});
it('cannot have >63 length subdomains', function () {
var s = '';
for (var i = 0; i < 64; i++) s += 's';
expect(apps._validateHostname(s, 'cloudron.us')).to.be.an(Error);
});
it('allows only alphanumerics and hypen', function () {
expect(apps._validateHostname('#2r', 'cloudron.us')).to.be.an(Error);
expect(apps._validateHostname('a%b', 'cloudron.us')).to.be.an(Error);
expect(apps._validateHostname('ab_', 'cloudron.us')).to.be.an(Error);
expect(apps._validateHostname('a.b', 'cloudron.us')).to.be.an(Error);
expect(apps._validateHostname('-ab', 'cloudron.us')).to.be.an(Error);
expect(apps._validateHostname('ab-', 'cloudron.us')).to.be.an(Error);
});
it('total length cannot exceed 255', function () {
var s = '';
for (var i = 0; i < (255 - 'cloudron.us'.length); i++) s += 's';
expect(apps._validateHostname(s, 'cloudron.us')).to.be.an(Error);
});
it('allow valid domains', function () {
expect(apps._validateHostname('a', 'cloudron.us')).to.be(null);
expect(apps._validateHostname('a0-x', 'cloudron.us')).to.be(null);
expect(apps._validateHostname('01', 'cloudron.us')).to.be(null);
});
});
describe('validatePortBindings', function () {
it('does not allow invalid host port', function () {
expect(apps._validatePortBindings({ port: -1 })).to.be.an(Error);
expect(apps._validatePortBindings({ port: 0 })).to.be.an(Error);
expect(apps._validatePortBindings({ port: 'text' })).to.be.an(Error);
expect(apps._validatePortBindings({ port: 65536 })).to.be.an(Error);
expect(apps._validatePortBindings({ port: 1024 })).to.be.an(Error);
});
it('does not allow ports not as part of manifest', function () {
expect(apps._validatePortBindings({ port: 1567 })).to.be.an(Error);
expect(apps._validatePortBindings({ port: 1567 }, { port3: null })).to.be.an(Error);
});
it('allows valid bindings', function () {
expect(apps._validatePortBindings({ port: 1025 }, { port: null })).to.be(null);
expect(apps._validatePortBindings({
port1: 4033,
port2: 3242,
port3: 1234
}, { port1: null, port2: null, port3: null })).to.be(null);
});
});
describe('getters', function () {
it('cannot get invalid app', function (done) {
apps.get('nope', function (error, app) {
expect(error).to.be.ok();
expect(error.reason).to.be(AppsError.NOT_FOUND);
done();
});
});
it('can get valid app', function (done) {
apps.get(APP_0.id, function (error, app) {
expect(error).to.be(null);
expect(app).to.be.ok();
expect(app.iconUrl).to.be(null);
expect(app.fqdn).to.eql(APP_0.location + '-' + config.fqdn());
done();
});
});
it('cannot getBySubdomain', function (done) {
apps.getBySubdomain('moang', function (error, app) {
expect(error).to.be.ok();
expect(error.reason).to.be(AppsError.NOT_FOUND);
done();
});
});
it('can getBySubdomain', function (done) {
apps.getBySubdomain(APP_0.location, function (error, app) {
expect(error).to.be(null);
expect(app).to.be.ok();
expect(app.iconUrl).to.eql(null);
expect(app.fqdn).to.eql(APP_0.location + '-' + config.fqdn());
done();
});
});
it('can getAll', function (done) {
apps.getAll(function (error, apps) {
expect(error).to.be(null);
expect(apps).to.be.an(Array);
expect(apps[0].id).to.be(APP_0.id);
expect(apps[0].iconUrl).to.be(null);
expect(apps[0].fqdn).to.eql(APP_0.location + '-' + config.fqdn());
done();
});
});
});
});

212
src/test/apptask-test.js Normal file
View File

@@ -0,0 +1,212 @@
/* jslint node:true */
/* global it:false */
/* global describe:false */
/* global before:false */
/* global after:false */
'use strict';
var addons = require('../addons.js'),
appdb = require('../appdb.js'),
apptask = require('../apptask.js'),
config = require('../config.js'),
database = require('../database.js'),
expect = require('expect.js'),
fs = require('fs'),
net = require('net'),
nock = require('nock'),
paths = require('../paths.js'),
sysinfo = require('../sysinfo.js'),
_ = require('underscore');
var MANIFEST = {
"id": "io.cloudron.test",
"author": "The Presidents Of the United States Of America",
"title": "test title",
"description": "test description",
"tagline": "test rocks",
"website": "http://test.cloudron.io",
"contactEmail": "support@cloudron.io",
"version": "0.1.0",
"manifestVersion": 1,
"dockerImage": "girish/test:0.2.0",
"healthCheckPath": "/",
"httpPort": 7777,
"tcpPorts": {
"ECHO_SERVER_PORT": {
"title": "Echo Server Port",
"description": "Echo server",
"containerPort": 7778
}
},
"addons": {
"oauth": { },
"redis": { },
"mysql": { },
"postgresql": { }
}
};
var APP = {
id: 'appid',
appStoreId: 'appStoreId',
installationState: appdb.ISTATE_PENDING_INSTALL,
runState: null,
location: 'applocation',
manifest: MANIFEST,
containerId: null,
httpPort: 4567,
portBindings: null,
accessRestriction: '',
dnsRecordId: 'someDnsRecordId'
};
describe('apptask', function () {
before(function (done) {
config.set('version', '0.5.0');
database.initialize(function (error) {
expect(error).to.be(null);
appdb.add(APP.id, APP.appStoreId, APP.manifest, APP.location, APP.portBindings, APP.accessRestriction, done);
});
});
after(function (done) {
database._clear(done);
});
it('initializes succesfully', function (done) {
apptask.initialize(done);
});
it('free port', function (done) {
apptask._getFreePort(function (error, port) {
expect(error).to.be(null);
expect(port).to.be.a('number');
var client = net.connect(port);
client.on('connect', function () { done(new Error('Port is not free:' + port)); });
client.on('error', function (error) { done(); });
});
});
it('configure nginx correctly', function (done) {
apptask._configureNginx(APP, function (error) {
expect(fs.existsSync(paths.NGINX_APPCONFIG_DIR + '/' + APP.id + '.conf'));
// expect(error).to.be(null); // this fails because nginx cannot be restarted
done();
});
});
it('unconfigure nginx', function (done) {
apptask._unconfigureNginx(APP, function (error) {
expect(!fs.existsSync(paths.NGINX_APPCONFIG_DIR + '/' + APP.id + '.conf'));
// expect(error).to.be(null); // this fails because nginx cannot be restarted
done();
});
});
it('create volume', function (done) {
apptask._createVolume(APP, function (error) {
expect(fs.existsSync(paths.DATA_DIR + '/' + APP.id + '/data')).to.be(true);
expect(error).to.be(null);
done();
});
});
it('delete volume', function (done) {
apptask._deleteVolume(APP, function (error) {
expect(!fs.existsSync(paths.DATA_DIR + '/' + APP.id + '/data')).to.be(true);
expect(error).to.be(null);
done();
});
});
it('allocate OAuth credentials', function (done) {
addons._allocateOAuthCredentials(APP, function (error) {
expect(error).to.be(null);
done();
});
});
it('remove OAuth credentials', function (done) {
addons._removeOAuthCredentials(APP, function (error) {
expect(error).to.be(null);
done();
});
});
it('remove OAuth credentials twice succeeds', function (done) {
addons._removeOAuthCredentials(APP, function (error) {
expect(!error).to.be.ok();
done();
});
});
it('barfs on empty manifest', function (done) {
var badApp = _.extend({ }, APP);
badApp.manifest = { };
apptask._verifyManifest(badApp, function (error) {
expect(error).to.be.ok();
done();
});
});
it('barfs on bad manifest', function (done) {
var badApp = _.extend({ }, APP);
badApp.manifest = _.extend({ }, APP.manifest);
delete badApp.manifest['id'];
apptask._verifyManifest(badApp, function (error) {
expect(error).to.be.ok();
done();
});
});
it('barfs on incompatible manifest', function (done) {
var badApp = _.extend({ }, APP);
badApp.manifest = _.extend({ }, APP.manifest);
badApp.manifest.maxBoxVersion = '0.0.0'; // max box version is too small
apptask._verifyManifest(badApp, function (error) {
expect(error).to.be.ok();
done();
});
});
it('verifies manifest', function (done) {
var goodApp = _.extend({ }, APP);
apptask._verifyManifest(goodApp, function (error) {
expect(error).to.be(null);
done();
});
});
it('registers subdomain', function (done) {
nock.cleanAll();
var scope = nock(config.apiServerOrigin())
.post('/api/v1/subdomains?token=' + config.token(), { records: [ { subdomain: APP.location, type: 'A', value: sysinfo.getIp() } ] })
.reply(201, { ids: [ APP.dnsRecordId ] });
apptask._registerSubdomain(APP, function (error) {
expect(error).to.be(null);
expect(scope.isDone()).to.be.ok();
done();
});
});
it('unregisters subdomain', function (done) {
nock.cleanAll();
var scope = nock(config.apiServerOrigin())
.delete('/api/v1/subdomains/' + APP.dnsRecordId + '?token=' + config.token())
.reply(204, {});
apptask._unregisterSubdomain(APP, function (error) {
expect(error).to.be(null);
expect(scope.isDone()).to.be.ok();
done();
});
});
});

58
src/test/checkInstall Executable file
View File

@@ -0,0 +1,58 @@
#!/bin/bash
set -eu
readonly SOURCE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# reset sudo timestamp to avoid wrong success
sudo -k || sudo --reset-timestamp
# checks if all scripts are sudo access
scripts=("${SOURCE_DIR}/scripts/rmappdir.sh" \
"${SOURCE_DIR}/scripts/createappdir.sh" \
"${SOURCE_DIR}/scripts/reloadnginx.sh" \
"${SOURCE_DIR}/scripts/backupbox.sh" \
"${SOURCE_DIR}/scripts/backupapp.sh" \
"${SOURCE_DIR}/scripts/restoreapp.sh" \
"${SOURCE_DIR}/scripts/reboot.sh" \
"${SOURCE_DIR}/scripts/backupswap.sh" \
"${SOURCE_DIR}/scripts/reloadcollectd.sh")
for script in "${scripts[@]}"; do
if [[ $(sudo -n "${script}" --check 2>/dev/null) != "OK" ]]; then
echo ""
echo "${script} does not have sudo access."
echo "You have to add the lines below to /etc/sudoers.d/yellowtent."
echo ""
echo "Defaults!${script} env_keep=\"HOME NODE_ENV\""
echo "${USER} ALL=(ALL) NOPASSWD: ${script}"
echo ""
exit 1
fi
done
if ! docker inspect girish/test:0.2.0 >/dev/null 2>/dev/null; then
echo "docker pull girish/test:0.2.0 for tests to run"
exit 1
fi
if ! docker inspect cloudron/redis:0.3.0 >/dev/null 2>/dev/null; then
echo "docker pull cloudron/redis:0.3.0 for tests to run"
exit 1
fi
if ! docker inspect cloudron/mysql:0.3.0 >/dev/null 2>/dev/null; then
echo "docker pull cloudron/mysql:0.3.0 for tests to run"
exit 1
fi
if ! docker inspect cloudron/postgresql:0.3.0 >/dev/null 2>/dev/null; then
echo "docker pull cloudron/postgresql:0.3.0 for tests to run"
exit 1
fi
if ! docker inspect cloudron/mongodb:0.3.0 >/dev/null 2>/dev/null; then
echo "docker pull cloudron/mongodb:0.3.0 for tests to run"
exit 1
fi

95
src/test/config-test.js Normal file
View File

@@ -0,0 +1,95 @@
/* jslint node:true */
/* global it:false */
/* global describe:false */
/* global after:false */
/* global before:false */
'use strict';
var constants = require('../constants.js'),
expect = require('expect.js'),
fs = require('fs'),
path = require('path');
var config = null;
describe('config', function () {
before(function () {
delete require.cache[require.resolve('../config.js')];
config = require('../config.js');
});
after(function () {
delete require.cache[require.resolve('../config.js')];
});
it('baseDir() is set', function (done) {
expect(config.baseDir()).to.be.ok();
done();
});
it('cloudron.conf generated automatically', function (done) {
expect(fs.existsSync(path.join(config.baseDir(), 'configs/cloudron.conf'))).to.be.ok();
done();
});
it('did set default values', function () {
expect(config.isCustomDomain()).to.equal(false);
expect(config.fqdn()).to.equal('localhost');
expect(config.adminOrigin()).to.equal('https://' + constants.ADMIN_LOCATION + '-localhost');
expect(config.appFqdn('app')).to.equal('app-localhost');
expect(config.zoneName()).to.equal('localhost');
});
it('set saves value in file', function (done) {
config.set('foobar', 'somevalue');
expect(JSON.parse(fs.readFileSync(path.join(config.baseDir(), 'configs/cloudron.conf'))).foobar).to.eql('somevalue');
done();
});
it('set - simple key value', function (done) {
config.set('foobar', 'somevalue2');
expect(config.get('foobar')).to.eql('somevalue2');
done();
});
it('set - object', function (done) {
config.set( { fqdn: 'something.com' } );
expect(config.fqdn()).to.eql('something.com');
done();
});
it('uses dotted locations with custom domain', function () {
config.set('fqdn', 'example.com');
config.set('isCustomDomain', true);
expect(config.isCustomDomain()).to.equal(true);
expect(config.fqdn()).to.equal('example.com');
expect(config.adminOrigin()).to.equal('https://' + constants.ADMIN_LOCATION + '.example.com');
expect(config.appFqdn('app')).to.equal('app.example.com');
expect(config.zoneName()).to.equal('example.com');
});
it('uses hyphen locations with non-custom domain', function () {
config.set('fqdn', 'test.example.com');
config.set('isCustomDomain', false);
expect(config.isCustomDomain()).to.equal(false);
expect(config.fqdn()).to.equal('test.example.com');
expect(config.adminOrigin()).to.equal('https://' + constants.ADMIN_LOCATION + '-test.example.com');
expect(config.appFqdn('app')).to.equal('app-test.example.com');
expect(config.zoneName()).to.equal('example.com');
});
it('can set arbitrary values', function (done) {
config.set('random', 'value');
expect(config.get('random')).to.equal('value');
config.set('this.is.madness', 42);
expect(config.get('this.is.madness')).to.equal(42);
done();
});
});

933
src/test/database-test.js Normal file
View File

@@ -0,0 +1,933 @@
/* jslint node:true */
/* global it:false */
/* global describe:false */
/* global before:false */
/* global after:false */
'use strict';
var appdb = require('../appdb.js'),
authcodedb = require('../authcodedb.js'),
clientdb = require('../clientdb.js'),
hat = require('hat'),
database = require('../database'),
DatabaseError = require('../databaseerror.js'),
expect = require('expect.js'),
async = require('async'),
settingsdb = require('../settingsdb.js'),
tokendb = require('../tokendb.js'),
userdb = require('../userdb.js');
describe('database', function () {
before(function (done) {
async.series([
database.initialize,
database._clear
], done);
});
after(function (done) {
database._clear(done);
});
describe('userdb', function () {
var USER_0 = {
id: 'uuid213',
username: 'uuid213',
password: 'secret',
email: 'safe@me.com',
admin: false,
salt: 'morton',
createdAt: 'sometime back',
modifiedAt: 'now',
resetToken: hat(256)
};
var ADMIN_0 = {
id: 'uuid456',
username: 'uuid456',
password: 'secret',
email: 'safe2@me.com',
admin: true,
salt: 'tata',
createdAt: 'sometime back',
modifiedAt: 'now',
resetToken: ''
};
it('can add user', function (done) {
userdb.add(USER_0.id, USER_0, function (error) {
expect(!error).to.be.ok();
done();
});
});
it('can add admin user', function (done) {
userdb.add(ADMIN_0.id, ADMIN_0, function (error) {
expect(!error).to.be.ok();
done();
});
});
it('cannot add same user again', function (done) {
userdb.add(USER_0.id, USER_0, function (error) {
expect(error).to.be.ok();
expect(error.reason).to.be(DatabaseError.ALREADY_EXISTS);
done();
});
});
it('can get by user id', function (done) {
userdb.get(USER_0.id, function (error, user) {
expect(error).to.not.be.ok();
expect(user).to.eql(USER_0);
done();
});
});
it('can get by user name', function (done) {
userdb.getByUsername(USER_0.username, function (error, user) {
expect(error).to.not.be.ok();
expect(user).to.eql(USER_0);
done();
});
});
it('can get by email', function (done) {
userdb.getByEmail(USER_0.email, function (error, user) {
expect(error).to.not.be.ok();
expect(user).to.eql(USER_0);
done();
});
});
it('can get by resetToken fails for empty resetToken', function (done) {
userdb.getByResetToken('', function (error, user) {
expect(error).to.be.ok();
expect(error.reason).to.be(DatabaseError.INTERNAL_ERROR);
expect(user).to.not.be.ok();
done();
});
});
it('can get by resetToken', function (done) {
userdb.getByResetToken(USER_0.resetToken, function (error, user) {
expect(error).to.not.be.ok();
expect(user).to.eql(USER_0);
done();
});
});
it('can get all', function (done) {
userdb.getAll(function (error, all) {
expect(error).to.not.be.ok();
expect(all.length).to.equal(2);
expect(all[0]).to.eql(USER_0);
expect(all[1]).to.eql(ADMIN_0);
done();
});
});
it('can get all admins', function (done) {
userdb.getAllAdmins(function (error, all) {
expect(error).to.not.be.ok();
expect(all.length).to.equal(1);
expect(all[0]).to.eql(ADMIN_0);
done();
});
});
it('counts the users', function (done) {
userdb.count(function (error, count) {
expect(error).to.not.be.ok();
expect(count).to.equal(2);
done();
});
});
it('counts the admin users', function (done) {
userdb.adminCount(function (error, count) {
expect(error).to.not.be.ok();
expect(count).to.equal(1);
done();
});
});
it('can update the user', function (done) {
userdb.update(USER_0.id, { email: 'some@thing.com' }, function (error) {
expect(error).to.not.be.ok();
userdb.get(USER_0.id, function (error, user) {
expect(user.email).to.equal('some@thing.com');
done();
});
});
});
it('cannot update with null field', function (done) {
userdb.update(USER_0.id, { email: null }, function (error) {
expect(error).to.be.ok();
done();
});
});
it('cannot del non-existing user', function (done) {
userdb.del(USER_0.id + USER_0.id, function (error) {
expect(error).to.be.ok();
expect(error.reason).to.be(DatabaseError.NOT_FOUND);
done();
});
});
it('can del existing user', function (done) {
userdb.del(USER_0.id, function (error) {
expect(error).to.not.be.ok();
done();
});
});
it('did remove the user', function (done) {
userdb.count(function (error, count) {
expect(count).to.equal(1);
done();
});
});
it('can clear table', function (done) {
userdb._clear(function (error) {
expect(error).to.not.be.ok();
userdb.count(function (error, count) {
expect(error).to.not.be.ok();
expect(count).to.equal(0);
done();
});
});
});
});
describe('authcode', function () {
var AUTHCODE_0 = {
authCode: 'authcode-0',
clientId: 'clientid-0',
userId: 'userid-0',
expiresAt: Date.now() + 5000
};
var AUTHCODE_1 = {
authCode: 'authcode-1',
clientId: 'clientid-1',
userId: 'userid-1',
expiresAt: Date.now() + 5000
};
var AUTHCODE_2 = {
authCode: 'authcode-2',
clientId: 'clientid-2',
userId: 'userid-2',
expiresAt: Date.now()
};
it('add fails due to missing arguments', function () {
expect(function () { authcodedb.add(AUTHCODE_0.authCode, AUTHCODE_0.clientId, AUTHCODE_0.userId); }).to.throwError();
expect(function () { authcodedb.add(AUTHCODE_0.authCode, AUTHCODE_0.clientId, function () {}); }).to.throwError();
expect(function () { authcodedb.add(AUTHCODE_0.authCode, function () {}); }).to.throwError();
});
it('add succeeds', function (done) {
authcodedb.add(AUTHCODE_0.authCode, AUTHCODE_0.clientId, AUTHCODE_0.userId, AUTHCODE_0.expiresAt, function (error) {
expect(error).to.be(null);
done();
});
});
it('add of same authcode fails', function (done) {
authcodedb.add(AUTHCODE_0.authCode, AUTHCODE_0.clientId, AUTHCODE_0.userId, AUTHCODE_0.expiresAt, function (error) {
expect(error).to.be.a(DatabaseError);
expect(error.reason).to.be(DatabaseError.ALREADY_EXISTS);
done();
});
});
it('get succeeds', function (done) {
authcodedb.get(AUTHCODE_0.authCode, function (error, result) {
expect(error).to.be(null);
expect(result).to.be.an('object');
expect(result).to.be.eql(AUTHCODE_0);
done();
});
});
it('get of nonexisting code fails', function (done) {
authcodedb.get(AUTHCODE_1.authCode, function (error, result) {
expect(error).to.be.a(DatabaseError);
expect(error.reason).to.be(DatabaseError.NOT_FOUND);
expect(result).to.not.be.ok();
done();
});
});
it('get of expired code fails', function (done) {
authcodedb.add(AUTHCODE_2.authCode, AUTHCODE_2.clientId, AUTHCODE_2.userId, AUTHCODE_2.expiresAt, function (error) {
expect(error).to.be(null);
authcodedb.get(AUTHCODE_2.authCode, function (error, result) {
expect(error).to.be.a(DatabaseError);
expect(error.reason).to.be(DatabaseError.NOT_FOUND);
expect(result).to.not.be.ok();
done();
});
});
});
it('delExpired succeeds', function (done) {
authcodedb.delExpired(function (error, result) {
expect(error).to.not.be.ok();
expect(result).to.eql(1);
authcodedb.get(AUTHCODE_2.authCode, function (error, result) {
expect(error).to.be.a(DatabaseError);
expect(error.reason).to.be(DatabaseError.NOT_FOUND);
expect(result).to.not.be.ok();
done();
});
});
});
it('delete succeeds', function (done) {
authcodedb.del(AUTHCODE_0.authCode, function (error) {
expect(error).to.be(null);
done();
});
});
it('cannot delete previously delete record', function (done) {
authcodedb.del(AUTHCODE_0.authCode, function (error) {
expect(error).to.be.a(DatabaseError);
expect(error.reason).to.be(DatabaseError.NOT_FOUND);
done();
});
});
});
describe('token', function () {
var TOKEN_0 = {
accessToken: tokendb.generateToken(),
identifier: tokendb.PREFIX_USER + '0',
clientId: 'clientid-0',
expires: Date.now() + 60 * 60000,
scope: '*'
};
var TOKEN_1 = {
accessToken: tokendb.generateToken(),
identifier: tokendb.PREFIX_USER + '1',
clientId: 'clientid-1',
expires: Number.MAX_SAFE_INTEGER,
scope: '*'
};
var TOKEN_2 = {
accessToken: tokendb.generateToken(),
identifier: tokendb.PREFIX_USER + '2',
clientId: 'clientid-2',
expires: Date.now(),
scope: '*'
};
it('add fails due to missing arguments', function () {
expect(function () { tokendb.add(TOKEN_0.accessToken, TOKEN_0.identifier, TOKEN_0.clientId, TOKEN_0.scope); }).to.throwError();
expect(function () { tokendb.add(TOKEN_0.accessToken, TOKEN_0.identifier, TOKEN_0.clientId, function () {}); }).to.throwError();
expect(function () { tokendb.add(TOKEN_0.accessToken, TOKEN_0.identifier, function () {}); }).to.throwError();
expect(function () { tokendb.add(TOKEN_0.accessToken, function () {}); }).to.throwError();
});
it('add succeeds', function (done) {
tokendb.add(TOKEN_0.accessToken, TOKEN_0.identifier, TOKEN_0.clientId, TOKEN_0.expires, TOKEN_0.scope, function (error) {
expect(error).to.be(null);
done();
});
});
it('add of same token fails', function (done) {
tokendb.add(TOKEN_0.accessToken, TOKEN_0.identifier, TOKEN_0.clientId, TOKEN_0.expires, TOKEN_0.scope, function (error) {
expect(error).to.be.a(DatabaseError);
expect(error.reason).to.be(DatabaseError.ALREADY_EXISTS);
done();
});
});
it('get succeeds', function (done) {
tokendb.get(TOKEN_0.accessToken, function (error, result) {
expect(error).to.be(null);
expect(result).to.be.an('object');
expect(result).to.be.eql(TOKEN_0);
done();
});
});
it('get of nonexisting token fails', function (done) {
tokendb.get(TOKEN_1.accessToken, function (error, result) {
expect(error).to.be.a(DatabaseError);
expect(error.reason).to.be(DatabaseError.NOT_FOUND);
expect(result).to.not.be.ok();
done();
});
});
it('getByIdentifier succeeds', function (done) {
tokendb.getByIdentifier(TOKEN_0.identifier, function (error, result) {
expect(error).to.be(null);
expect(result).to.be.an(Array);
expect(result.length).to.equal(1);
expect(result[0]).to.be.an('object');
expect(result[0]).to.be.eql(TOKEN_0);
done();
});
});
it('delete succeeds', function (done) {
tokendb.del(TOKEN_0.accessToken, function (error) {
expect(error).to.be(null);
done();
});
});
it('getByIdentifier succeeds after token deletion', function (done) {
tokendb.getByIdentifier(TOKEN_0.identifier, function (error, result) {
expect(error).to.be(null);
expect(result).to.be.an(Array);
expect(result.length).to.equal(0);
done();
});
});
it('delByIdentifier succeeds', function (done) {
tokendb.add(TOKEN_1.accessToken, TOKEN_1.identifier, TOKEN_1.clientId, TOKEN_1.expires, TOKEN_1.scope, function (error) {
expect(error).to.be(null);
tokendb.delByIdentifier(TOKEN_1.identifier, function (error) {
expect(error).to.be(null);
done();
});
});
});
it('cannot delete previously delete record', function (done) {
tokendb.del(TOKEN_0.accessToken, function (error) {
expect(error).to.be.a(DatabaseError);
expect(error.reason).to.be(DatabaseError.NOT_FOUND);
done();
});
});
it('getByIdentifierAndClientId succeeds', function (done) {
tokendb.add(TOKEN_0.accessToken, TOKEN_0.identifier, TOKEN_0.clientId, TOKEN_0.expires, TOKEN_0.scope, function (error) {
expect(error).to.be(null);
tokendb.getByIdentifierAndClientId(TOKEN_0.identifier, TOKEN_0.clientId, function (error, result) {
expect(error).to.be(null);
expect(result).to.be.an(Array);
expect(result.length).to.equal(1);
expect(result[0]).to.eql(TOKEN_0);
done();
});
});
});
it('delExpired succeeds', function (done) {
tokendb.add(TOKEN_2.accessToken, TOKEN_2.identifier, TOKEN_2.clientId, TOKEN_2.expires, TOKEN_2.scope, function (error) {
expect(error).to.be(null);
tokendb.delExpired(function (error, result) {
expect(error).to.not.be.ok();
expect(result).to.eql(1);
tokendb.get(TOKEN_2.accessToken, function (error, result) {
expect(error).to.be.a(DatabaseError);
expect(error.reason).to.be(DatabaseError.NOT_FOUND);
expect(result).to.not.be.ok();
done();
});
});
});
});
it('delByIdentifierAndClientId succeeds', function (done) {
tokendb.delByIdentifierAndClientId(TOKEN_0.identifier, TOKEN_0.clientId, function (error) {
expect(error).to.be(null);
done();
});
});
it('get of previously deleted token fails', function (done) {
tokendb.get(TOKEN_0.accessToken, function (error, result) {
expect(error).to.be.a(DatabaseError);
expect(error.reason).to.be(DatabaseError.NOT_FOUND);
expect(result).to.not.be.ok();
done();
});
});
});
describe('app', function () {
var APP_0 = {
id: 'appid-0',
appStoreId: 'appStoreId-0',
dnsRecordId: null,
installationState: appdb.ISTATE_PENDING_INSTALL,
installationProgress: null,
runState: null,
location: 'some-location-0',
manifest: { version: '0.1', dockerImage: 'docker/app0', healthCheckPath: '/', httpPort: 80, title: 'app0' },
httpPort: null,
containerId: null,
portBindings: { port: 5678 },
health: null,
accessRestriction: '',
lastBackupId: null,
lastBackupConfig: null,
oldConfig: null
};
var APP_1 = {
id: 'appid-1',
appStoreId: 'appStoreId-1',
dnsRecordId: null,
installationState: appdb.ISTATE_PENDING_INSTALL, // app health tests rely on this initial state
installationProgress: null,
runState: null,
location: 'some-location-1',
manifest: { version: '0.2', dockerImage: 'docker/app1', healthCheckPath: '/', httpPort: 80, title: 'app1' },
httpPort: null,
containerId: null,
portBindings: { },
health: null,
accessRestriction: 'roleAdmin',
lastBackupId: null,
lastBackupConfig: null,
oldConfig: null
};
it('add fails due to missing arguments', function () {
expect(function () { appdb.add(APP_0.id, APP_0.manifest, APP_0.installationState, function () {}); }).to.throwError();
expect(function () { appdb.add(APP_0.id, function () {}); }).to.throwError();
});
it('exists returns false', function (done) {
appdb.exists(APP_0.id, function (error, exists) {
expect(error).to.be(null);
expect(exists).to.be(false);
done();
});
});
it('add succeeds', function (done) {
appdb.add(APP_0.id, APP_0.appStoreId, APP_0.manifest, APP_0.location, APP_0.portBindings, APP_0.accessRestriction, function (error) {
expect(error).to.be(null);
done();
});
});
it('exists succeeds', function (done) {
appdb.exists(APP_0.id, function (error, exists) {
expect(error).to.be(null);
expect(exists).to.be(true);
done();
});
});
it('getPortBindings succeeds', function (done) {
appdb.getPortBindings(APP_0.id, function (error, bindings) {
expect(error).to.be(null);
expect(bindings).to.be.an(Object);
expect(bindings).to.be.eql({ port: '5678' });
done();
});
});
it('add of same app fails', function (done) {
appdb.add(APP_0.id, APP_0.appStoreId, APP_0.manifest, APP_0.location, [ ], APP_0.accessRestriction, function (error) {
expect(error).to.be.a(DatabaseError);
expect(error.reason).to.be(DatabaseError.ALREADY_EXISTS);
done();
});
});
it('get succeeds', function (done) {
appdb.get(APP_0.id, function (error, result) {
expect(error).to.be(null);
expect(result).to.be.an('object');
expect(result).to.be.eql(APP_0);
done();
});
});
it('get of nonexisting code fails', function (done) {
appdb.get(APP_1.id, function (error, result) {
expect(error).to.be.a(DatabaseError);
expect(error.reason).to.be(DatabaseError.NOT_FOUND);
expect(result).to.not.be.ok();
done();
});
});
it('update succeeds', function (done) {
APP_0.installationState = 'some-other-status';
APP_0.location = 'some-other-location';
APP_0.manifest.version = '0.2';
APP_0.accessRestriction = true;
APP_0.httpPort = 1337;
appdb.update(APP_0.id, { installationState: APP_0.installationState, location: APP_0.location, manifest: APP_0.manifest, accessRestriction: APP_0.accessRestriction, httpPort: APP_0.httpPort }, function (error) {
expect(error).to.be(null);
appdb.get(APP_0.id, function (error, result) {
expect(error).to.be(null);
expect(result).to.be.an('object');
expect(result).to.be.eql(APP_0);
done();
});
});
});
it('getByHttpPort succeeds', function (done) {
appdb.getByHttpPort(APP_0.httpPort, function (error, result) {
expect(error).to.be(null);
expect(result).to.be.an('object');
expect(result).to.be.eql(APP_0);
done();
});
});
it('update of nonexisting app fails', function (done) {
appdb.update(APP_1.id, { installationState: APP_1.installationState, location: APP_1.location }, function (error) {
expect(error).to.be.a(DatabaseError);
expect(error.reason).to.be(DatabaseError.NOT_FOUND);
done();
});
});
it('add second app succeeds', function (done) {
appdb.add(APP_1.id, APP_1.appStoreId, APP_1.manifest, APP_1.location, [ ], APP_1.accessRestriction, function (error) {
expect(error).to.be(null);
done();
});
});
it('getAll succeeds', function (done) {
appdb.getAll(function (error, result) {
expect(error).to.be(null);
expect(result).to.be.an(Array);
expect(result.length).to.be(2);
expect(result[0]).to.be.eql(APP_0);
expect(result[1]).to.be.eql(APP_1);
done();
});
});
it('getAppStoreIds succeeds', function (done) {
appdb.getAppStoreIds(function (error, results) {
expect(error).to.be(null);
expect(results).to.be.an(Array);
expect(results.length).to.be(2);
expect(results[0].appStoreId).to.equal(APP_0.appStoreId);
expect(results[1].appStoreId).to.equal(APP_1.appStoreId);
done();
});
});
it('delete succeeds', function (done) {
appdb.del(APP_0.id, function (error) {
expect(error).to.be(null);
done();
});
});
it('getPortBindings should be empty', function (done) {
appdb.getPortBindings(APP_0.id, function (error, bindings) {
expect(error).to.be(null);
expect(bindings).to.be.an(Object);
expect(bindings).to.be.eql({ });
done();
});
});
it('cannot delete previously delete record', function (done) {
appdb.del(APP_0.id, function (error) {
expect(error).to.be.a(DatabaseError);
expect(error.reason).to.be(DatabaseError.NOT_FOUND);
done();
});
});
it('cannot set app as healthy because app is not installed', function (done) {
appdb.setHealth(APP_1.id, appdb.HEALTH_HEALTHY, function (error) {
expect(error).to.be.ok();
done();
});
});
it('cannot set app as healthy because app has pending run state', function (done) {
appdb.update(APP_1.id, { runState: appdb.RSTATE_PENDING_STOP, installationState: appdb.ISTATE_INSTALLED }, function (error) {
expect(error).to.be(null);
appdb.setHealth(APP_1.id, appdb.HEALTH_HEALTHY, function (error) {
expect(error).to.be.ok();
done();
});
});
});
it('cannot set app as healthy because app has null run state', function (done) {
appdb.update(APP_1.id, { runState: null, installationState: appdb.ISTATE_INSTALLED }, function (error) {
expect(error).to.be(null);
appdb.setHealth(APP_1.id, appdb.HEALTH_HEALTHY, function (error) {
expect(error).to.be.ok();
done();
});
});
});
it('can set app as healthy when installed and no pending runState', function (done) {
appdb.update(APP_1.id, { runState: appdb.RSTATE_RUNNING, installationState: appdb.ISTATE_INSTALLED }, function (error) {
expect(error).to.be(null);
appdb.setHealth(APP_1.id, appdb.HEALTH_HEALTHY, function (error) {
expect(error).to.be(null);
appdb.get(APP_1.id, function (error, app) {
expect(error).to.be(null);
expect(app.health).to.be(appdb.HEALTH_HEALTHY);
done();
});
});
});
});
it('cannot set health of unknown app', function (done) {
appdb.setHealth('randomId', appdb.HEALTH_HEALTHY, function (error) {
expect(error).to.be.ok();
done();
});
});
it('return empty addon config array for invalid app', function (done) {
appdb.getAddonConfigByAppId('randomid', function (error, results) {
expect(error).to.be(null);
expect(results).to.eql([ ]);
done();
});
});
it('setAddonConfig succeeds', function (done) {
appdb.setAddonConfig(APP_1.id, 'addonid1', [ 'ENV1=env', 'ENV2=env' ], function (error) {
expect(error).to.be(null);
done();
});
});
it('setAddonConfig succeeds', function (done) {
appdb.setAddonConfig(APP_1.id, 'addonid2', [ 'ENV3=env' ], function (error) {
expect(error).to.be(null);
done();
});
});
it('getAddonConfig succeeds', function (done) {
appdb.getAddonConfig(APP_1.id, 'addonid1', function (error, results) {
expect(error).to.be(null);
expect(results).to.eql([ 'ENV1=env', 'ENV2=env' ]);
done();
});
});
it('getAddonConfigByAppId succeeds', function (done) {
appdb.getAddonConfigByAppId(APP_1.id, function (error, results) {
expect(error).to.be(null);
expect(results).to.eql([ 'ENV1=env', 'ENV2=env', 'ENV3=env' ]);
done();
});
});
it('unsetAddonConfig succeeds', function (done) {
appdb.unsetAddonConfig(APP_1.id, 'addonid1', function (error) {
expect(error).to.be(null);
done();
});
});
it('unsetAddonConfig did remove configs', function (done) {
appdb.getAddonConfigByAppId(APP_1.id, function (error, results) {
expect(error).to.be(null);
expect(results).to.eql([ 'ENV3=env' ]);
done();
});
});
it('unsetAddonConfigByAppId succeeds', function (done) {
appdb.unsetAddonConfigByAppId(APP_1.id, function (error) {
expect(error).to.be(null);
done();
});
});
it('unsetAddonConfigByAppId did remove configs', function (done) {
appdb.getAddonConfigByAppId(APP_1.id, function (error, results) {
expect(error).to.be(null);
expect(results).to.eql([ ]);
done();
});
});
});
describe('client', function () {
var CLIENT_0 = {
id: 'cid-0',
appId: 'someappid_0',
clientSecret: 'secret-0',
redirectURI: 'http://foo.bar',
scope: '*'
};
var CLIENT_1 = {
id: 'cid-1',
appId: 'someappid_1',
clientSecret: 'secret-',
redirectURI: 'http://foo.bar',
scope: '*'
};
var CLIENT_2 = {
id: 'cid-2',
appId: 'someappid_2',
clientSecret: 'secret-2',
redirectURI: 'http://foo.bar.baz',
scope: 'profile,roleUser'
};
it('add succeeds', function (done) {
clientdb.add(CLIENT_0.id, CLIENT_0.appId, CLIENT_0.clientSecret, CLIENT_0.redirectURI, CLIENT_0.scope, function (error) {
expect(error).to.be(null);
clientdb.add(CLIENT_1.id, CLIENT_1.appId, CLIENT_1.clientSecret, CLIENT_1.redirectURI, CLIENT_1.scope, function (error) {
expect(error).to.be(null);
done();
});
});
});
it('add same client id fails', function (done) {
clientdb.add(CLIENT_0.id, CLIENT_0.appId, CLIENT_0.clientSecret, CLIENT_0.redirectURI, CLIENT_0.scope, function (error) {
expect(error).to.be.a(DatabaseError);
expect(error.reason).to.equal(DatabaseError.ALREADY_EXISTS);
done();
});
});
it('get succeeds', function (done) {
clientdb.get(CLIENT_0.id, function (error, result) {
expect(error).to.be(null);
expect(result).to.eql(CLIENT_0);
done();
});
});
it('getByAppId succeeds', function (done) {
clientdb.getByAppId(CLIENT_0.appId, function (error, result) {
expect(error).to.be(null);
expect(result).to.eql(CLIENT_0);
done();
});
});
it('getByAppId fails for unknown client id', function (done) {
clientdb.getByAppId(CLIENT_0.appId + CLIENT_0.appId, function (error, result) {
expect(error).to.be.a(DatabaseError);
expect(error.reason).to.equal(DatabaseError.NOT_FOUND);
expect(result).to.not.be.ok();
done();
});
});
it('getAll succeeds', function (done) {
clientdb.getAll(function (error, result) {
expect(error).to.be(null);
expect(result).to.be.an(Array);
expect(result.length).to.equal(3); // one of them is webadmin
expect(result[0]).to.eql(CLIENT_0);
expect(result[1]).to.eql(CLIENT_1);
done();
});
});
it('update client fails due to unknown client id', function (done) {
clientdb.update(CLIENT_2.id, CLIENT_2.appId, CLIENT_2.clientSecret, CLIENT_2.redirectURI, CLIENT_2.scope, function (error) {
expect(error).to.be.a(DatabaseError);
expect(error.reason).to.equal(DatabaseError.NOT_FOUND);
done();
});
});
it('update client succeeds', function (done) {
clientdb.update(CLIENT_1.id, CLIENT_2.appId, CLIENT_2.clientSecret, CLIENT_2.redirectURI, CLIENT_2.scope, function (error) {
expect(error).to.be(null);
clientdb.get(CLIENT_1.id, function (error, result) {
expect(error).to.be(null);
expect(result.appId).to.eql(CLIENT_2.appId);
expect(result.clientSecret).to.eql(CLIENT_2.clientSecret);
expect(result.redirectURI).to.eql(CLIENT_2.redirectURI);
expect(result.scope).to.eql(CLIENT_2.scope);
done();
});
});
});
it('delByAppId succeeds', function (done) {
clientdb.delByAppId(CLIENT_0.appId, function (error) {
expect(error).to.be(null);
clientdb.getByAppId(CLIENT_0.appId, function (error, result) {
expect(error).to.be.a(DatabaseError);
expect(error.reason).to.equal(DatabaseError.NOT_FOUND);
expect(result).to.not.be.ok();
done();
});
});
});
});
describe('settings', function () {
it('can set value', function (done) {
settingsdb.set('somekey', 'somevalue', function (error) {
expect(error).to.be(null);
done();
});
});
it('can get the set value', function (done) {
settingsdb.get('somekey', function (error, value) {
expect(error).to.be(null);
expect(value).to.be('somevalue');
done();
});
});
it('can get all values', function (done) {
settingsdb.getAll(function (error, result) {
expect(error).to.be(null);
expect(result).to.be.an(Array);
expect(result[0].name).to.be('somekey');
expect(result[0].value).to.be('somevalue');
expect(result.length).to.be(1); // the value set above
done();
});
});
it('can update a value', function (done) {
settingsdb.set('somekey', 'someothervalue', function (error) {
expect(error).to.be(null);
done();
});
});
it('can get updated value', function (done) {
settingsdb.get('somekey', function (error, value) {
expect(error).to.be(null);
expect(value).to.be('someothervalue');
done();
});
});
});
});

273
src/test/server-test.js Normal file
View File

@@ -0,0 +1,273 @@
/* jslint node:true */
/* global it:false */
/* global describe:false */
/* global before:false */
/* global after:false */
'use strict';
var progress = require('../progress.js'),
config = require('../config.js'),
database = require('../database.js'),
expect = require('expect.js'),
nock = require('nock'),
request = require('superagent'),
server = require('../server.js');
var SERVER_URL = 'http://localhost:' + config.get('port');
function cleanup(done) {
done();
}
describe('Server', function () {
this.timeout(5000);
before(function () {
config.set('version', '0.5.0');
});
after(cleanup);
describe('startup', function () {
it('start fails due to wrong arguments', function (done) {
expect(function () { server.start(); }).to.throwException();
expect(function () { server.start('foobar', function () {}); }).to.throwException();
expect(function () { server.start(1337, function () {}); }).to.throwException();
done();
});
it('succeeds', function (done) {
server.start(function (error) {
expect(error).to.not.be.ok();
done();
});
});
it('is reachable', function (done) {
request.get(SERVER_URL + '/api/v1/cloudron/status', function (err, res) {
expect(res.statusCode).to.equal(200);
done(err);
});
});
it('should fail because already running', function (done) {
expect(server.start).to.throwException(function () {
done();
});
});
after(function (done) {
server.stop(function () {
done();
});
});
});
describe('runtime', function () {
before(function (done) {
server.start(done);
});
after(function (done) {
database._clear(function (error) {
expect(!error).to.be.ok();
server.stop(function () {
done();
});
});
});
it('random bad requests', function (done) {
request.get(SERVER_URL + '/random', function (err, res) {
expect(err).to.not.be.ok();
expect(res.statusCode).to.equal(404);
done(err);
});
});
it('version', function (done) {
request.get(SERVER_URL + '/api/v1/cloudron/status', function (err, res) {
expect(err).to.not.be.ok();
expect(res.statusCode).to.equal(200);
expect(res.body.version).to.equal('0.5.0');
done(err);
});
});
it('status route is GET', function (done) {
request.post(SERVER_URL + '/api/v1/cloudron/status')
.end(function (err, res) {
expect(res.statusCode).to.equal(404);
request.get(SERVER_URL + '/api/v1/cloudron/status')
.end(function (err, res) {
expect(res.statusCode).to.equal(200);
done(err);
});
});
});
});
describe('config', function () {
before(function (done) {
server.start(done);
});
after(function (done) {
server.stop(function () {
done();
});
});
it('config fails due missing token', function (done) {
request.get(SERVER_URL + '/api/v1/cloudron/config', function (err, res) {
expect(err).to.not.be.ok();
expect(res.statusCode).to.equal(401);
done(err);
});
});
it('config fails due wrong token', function (done) {
request.get(SERVER_URL + '/api/v1/cloudron/config').query({ access_token: 'somewrongtoken' }).end(function (err, res) {
expect(err).to.not.be.ok();
expect(res.statusCode).to.equal(401);
done(err);
});
});
});
describe('progress', function () {
before(function (done) {
server.start(done);
});
after(function (done) {
server.stop(function () {
done();
});
});
it('succeeds with no progress', function (done) {
request.get(SERVER_URL + '/api/v1/cloudron/progress', function (error, result) {
expect(error).to.not.be.ok();
expect(result.statusCode).to.equal(200);
expect(result.body.update).to.be(null);
expect(result.body.backup).to.be(null);
done();
});
});
it('succeeds with update progress', function (done) {
progress.set(progress.UPDATE, 13, 'This is some status string');
request.get(SERVER_URL + '/api/v1/cloudron/progress', function (error, result) {
expect(error).to.not.be.ok();
expect(result.statusCode).to.equal(200);
expect(result.body.update).to.be.an('object');
expect(result.body.update.percent).to.be.a('number');
expect(result.body.update.percent).to.equal(13);
expect(result.body.update.message).to.be.a('string');
expect(result.body.update.message).to.equal('This is some status string');
expect(result.body.backup).to.be(null);
done();
});
});
it('succeeds with no progress after clearing the update', function (done) {
progress.clear(progress.UPDATE);
request.get(SERVER_URL + '/api/v1/cloudron/progress', function (error, result) {
expect(error).to.not.be.ok();
expect(result.statusCode).to.equal(200);
expect(result.body.update).to.be(null);
expect(result.body.backup).to.be(null);
done();
});
});
});
describe('shutdown', function () {
before(function (done) {
server.start(done);
});
it('fails due to wrong arguments', function (done) {
expect(function () { server.stop(); }).to.throwException();
expect(function () { server.stop('foobar'); }).to.throwException();
expect(function () { server.stop(1337); }).to.throwException();
expect(function () { server.stop({}); }).to.throwException();
expect(function () { server.stop({ httpServer: {} }); }).to.throwException();
done();
});
it('succeeds', function (done) {
server.stop(function () {
done();
});
});
it('is not reachable anymore', function (done) {
request.get(SERVER_URL + '/api/v1/cloudron/status', function (error, result) {
expect(error).to.not.be(null);
done();
});
});
});
describe('cors', function () {
before(function (done) {
server.start(function (error) {
done(error);
});
});
it('responds to OPTIONS', function (done) {
request('OPTIONS', SERVER_URL + '/api/v1/cloudron/status')
.set('Access-Control-Request-Method', 'GET')
.set('Access-Control-Request-Headers', 'accept, origin, x-requested-with')
.set('Origin', 'http://localhost')
.end(function (res) {
expect(res.headers['access-control-allow-methods']).to.be('GET, PUT, DELETE, POST, OPTIONS');
expect(res.headers['access-control-allow-credentials']).to.be('true');
expect(res.headers['access-control-allow-headers']).to.be('accept, origin, x-requested-with'); // mirrored from request
expect(res.headers['access-control-allow-origin']).to.be('http://localhost'); // mirrors from request
done();
});
});
after(function (done) {
server.stop(function () {
done();
});
});
});
describe('heartbeat', function () {
var successfulHeartbeatGet;
before(function (done) {
server.start(done);
var scope = nock(config.apiServerOrigin());
successfulHeartbeatGet = scope.get('/api/v1/boxes/' + config.fqdn() + '/heartbeat');
successfulHeartbeatGet.reply(200);
});
after(function (done) {
server.stop(done);
nock.cleanAll();
});
it('sends heartbeat', function (done) {
setTimeout(function () {
expect(successfulHeartbeatGet.counter).to.equal(1);
done();
}, 100);
});
});
});

66
src/test/settings-test.js Normal file
View File

@@ -0,0 +1,66 @@
/* jslint node:true */
/* global it:false */
/* global describe:false */
/* global before:false */
/* global after:false */
'use strict';
var database = require('../database.js'),
expect = require('expect.js'),
settings = require('../settings.js');
function setup(done) {
// ensure data/config/mount paths
database.initialize(function (error) {
expect(error).to.be(null);
done();
});
}
function cleanup(done) {
database._clear(done);
}
describe('Settings', function () {
before(setup);
after(cleanup);
it('can get default timezone', function (done) {
settings.getTimeZone(function (error, tz) {
expect(error).to.be(null);
expect(tz.length).to.not.be(0);
done();
});
});
it('can get default autoupdate_pattern', function (done) {
settings.getAutoupdatePattern(function (error, pattern) {
expect(error).to.be(null);
expect(pattern).to.be('00 00 1,3,5,23 * * *');
done();
});
});
it ('can get default cloudron name', function (done) {
settings.getCloudronName(function (error, name) {
expect(error).to.be(null);
expect(name).to.be('Cloudron');
done();
});
});
it('can get default cloudron avatar', function (done) {
settings.getCloudronAvatar(function (error, gravatar) {
expect(error).to.be(null);
expect(gravatar).to.be.a(Buffer);
done();
});
});
it('can get all values', function (done) {
settings.getAll(function (error, allSettings) {
expect(error).to.be(null);
expect(allSettings[settings.TIME_ZONE_KEY]).to.be.a('string');
expect(allSettings[settings.AUTOUPDATE_PATTERN_KEY]).to.be.a('string');
expect(allSettings[settings.CLOUDRON_NAME_KEY]).to.be.a('string');
done();
});
});
});

20
src/test/setupTest Executable file
View File

@@ -0,0 +1,20 @@
#!/bin/bash
set -eu
readonly ADMIN_LOCATION=admin
readonly source_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")"/../.. && pwd)"
! "${source_dir}/src/test/checkInstall" && exit 1
# create dir structure
rm -rf $HOME/.cloudron_test
mkdir -p $HOME/.cloudron_test
cd $HOME/.cloudron_test
mkdir -p data/appdata data/box/appicons data/mail data/nginx/cert data/nginx/applications data/collectd/collectd.conf.d data/addons configs
webadmin_scopes="root,profile,users,apps,settings,roleAdmin"
webadmin_origin="https://${ADMIN_LOCATION}-localhost"
mysql --user=root --password="" \
-e "REPLACE INTO clients (id, appId, clientSecret, redirectURI, scope) VALUES (\"cid-webadmin\", \"webadmin\", \"secret-webadmin\", \"${webadmin_origin}\", \"${webadmin_scopes}\")" boxtest

51
src/test/shell-test.js Normal file
View File

@@ -0,0 +1,51 @@
/* jslint node:true */
/* global it:false */
/* global describe:false */
/* global after:false */
/* global before:false */
'use strict';
var expect = require('expect.js'),
path = require('path'),
shell = require('../shell.js');
describe('shell', function () {
it('can run valid program', function (done) {
var cp = shell.exec('test', 'ls', [ '-l' ], function (error) {
expect(cp).to.be.ok();
expect(error).to.be(null);
done();
});
});
it('fails on invalid program', function (done) {
var cp = shell.exec('test', 'randomprogram', [ ], function (error) {
expect(error).to.be.ok();
done();
});
});
it('fails on failing program', function (done) {
var cp = shell.exec('test', '/usr/bin/false', [ ], function (error) {
expect(error).to.be.ok();
done();
});
});
it('cannot sudo invalid program', function (done) {
var cp = shell.sudo('test', [ 'randomprogram' ], function (error) {
expect(error).to.be.ok();
done();
});
});
it('can sudo valid program', function (done) {
var RELOAD_NGINX_CMD = path.join(__dirname, '../src/scripts/reloadnginx.sh');
var cp = shell.sudo('test', [ RELOAD_NGINX_CMD ], function (error) {
expect(error).to.be.ok();
done();
});
});
});

401
src/test/user-test.js Normal file
View File

@@ -0,0 +1,401 @@
/* jslint node:true */
/* global it:false */
/* global describe:false */
/* global before:false */
/* global after:false */
'use strict';
var database = require('../database.js'),
expect = require('expect.js'),
user = require('../user.js'),
userdb = require('../userdb.js'),
UserError = user.UserError;
var USERNAME = 'nobody';
var USERNAME_NEW = 'nobodynew';
var EMAIL = 'nobody@no.body';
var EMAIL_NEW = 'nobodynew@no.body';
var PASSWORD = 'foobar';
var NEW_PASSWORD = 'somenewpassword';
var IS_ADMIN = true;
function cleanupUsers(done) {
userdb._clear(function () {
done();
});
}
function createUser(done) {
user.create(USERNAME, PASSWORD, EMAIL, IS_ADMIN, null /* invitor */, function (error, result) {
expect(error).to.not.be.ok();
expect(result).to.be.ok();
done();
});
}
function setup(done) {
// ensure data/config/mount paths
database.initialize(function (error) {
expect(error).to.be(null);
done();
});
}
function cleanup(done) {
database._clear(done);
}
describe('User', function () {
before(setup);
after(cleanup);
describe('create', function() {
before(cleanupUsers);
after(cleanupUsers);
it('succeeds', function (done) {
user.create(USERNAME, PASSWORD, EMAIL, IS_ADMIN, null /* invitor */, function (error, result) {
expect(error).not.to.be.ok();
expect(result).to.be.ok();
expect(result.username).to.equal(USERNAME);
expect(result.email).to.equal(EMAIL);
done();
});
});
it('fails because of invalid BAD_FIELD', function (done) {
expect(function () {
user.create(EMAIL, {}, function () {});
}).to.throwException();
expect(function () {
user.create(12345, PASSWORD, EMAIL, function () {});
}).to.throwException();
expect(function () {
user.create(USERNAME, PASSWORD, EMAIL, {});
}).to.throwException();
expect(function () {
user.create(USERNAME, PASSWORD, EMAIL, {}, function () {});
}).to.throwException();
expect(function () {
user.create(USERNAME, PASSWORD, EMAIL, {});
}).to.throwException();
done();
});
it('fails because user exists', function (done) {
user.create(USERNAME, PASSWORD, EMAIL, IS_ADMIN, null /* invitor */, function (error, result) {
expect(error).to.be.ok();
expect(result).not.to.be.ok();
expect(error.reason).to.equal(UserError.ALREADY_EXISTS);
done();
});
});
it('fails because password is empty', function (done) {
user.create(USERNAME, '', EMAIL, IS_ADMIN, null /* invitor */, function (error, result) {
expect(error).to.be.ok();
expect(result).not.to.be.ok();
expect(error.reason).to.equal(UserError.BAD_PASSWORD);
done();
});
});
});
describe('verify', function () {
before(createUser);
after(cleanupUsers);
it('fails due to non existing username', function (done) {
user.verify(USERNAME+USERNAME, PASSWORD, function (error, result) {
expect(error).to.be.ok();
expect(result).to.not.be.ok();
expect(error.reason).to.equal(UserError.NOT_FOUND);
done();
});
});
it('fails due to empty password', function (done) {
user.verify(USERNAME, '', function (error, result) {
expect(error).to.be.ok();
expect(result).to.not.be.ok();
expect(error.reason).to.equal(UserError.WRONG_PASSWORD);
done();
});
});
it('fails due to wrong password', function (done) {
user.verify(USERNAME, PASSWORD+PASSWORD, function (error, result) {
expect(error).to.be.ok();
expect(result).to.not.be.ok();
expect(error.reason).to.equal(UserError.WRONG_PASSWORD);
done();
});
});
it('succeeds', function (done) {
user.verify(USERNAME, PASSWORD, function (error, result) {
expect(error).to.not.be.ok();
expect(result).to.be.ok();
done();
});
});
});
describe('verifyWithEmail', function () {
before(createUser);
after(cleanupUsers);
it('fails due to non existing user', function (done) {
user.verifyWithEmail(EMAIL+EMAIL, PASSWORD, function (error, result) {
expect(error).to.be.ok();
expect(result).to.not.be.ok();
expect(error.reason).to.equal(UserError.NOT_FOUND);
done();
});
});
it('fails due to empty password', function (done) {
user.verifyWithEmail(EMAIL, '', function (error, result) {
expect(error).to.be.ok();
expect(result).to.not.be.ok();
expect(error.reason).to.equal(UserError.WRONG_PASSWORD);
done();
});
});
it('fails due to wrong password', function (done) {
user.verifyWithEmail(EMAIL, PASSWORD+PASSWORD, function (error, result) {
expect(error).to.be.ok();
expect(result).to.not.be.ok();
expect(error.reason).to.equal(UserError.WRONG_PASSWORD);
done();
});
});
it('succeeds', function (done) {
user.verifyWithEmail(EMAIL, PASSWORD, function (error, result) {
expect(error).to.not.be.ok();
expect(result).to.be.ok();
done();
});
});
});
describe('retrieving', function () {
before(createUser);
after(cleanupUsers);
it('fails due to non existing user', function (done) {
user.get('some non existing username', function (error, result) {
expect(error).to.be.ok();
expect(result).to.not.be.ok();
done();
});
});
it('succeeds', function (done) {
user.get(USERNAME, function (error, result) {
expect(error).to.not.be.ok();
expect(result).to.be.ok();
done();
});
});
});
describe('update', function () {
before(createUser);
after(cleanupUsers);
it('fails due to unknown userid', function (done) {
user.update(USERNAME+USERNAME, USERNAME_NEW, EMAIL_NEW, function (error) {
expect(error).to.be.a(UserError);
expect(error.reason).to.equal(UserError.NOT_FOUND);
done();
});
});
it('fails due to invalid username', function (done) {
user.update(USERNAME, '', EMAIL_NEW, function (error) {
expect(error).to.be.a(UserError);
expect(error.reason).to.equal(UserError.BAD_USERNAME);
done();
});
});
it('fails due to invalid email', function (done) {
user.update(USERNAME, USERNAME_NEW, 'brokenemailaddress', function (error) {
expect(error).to.be.a(UserError);
expect(error.reason).to.equal(UserError.BAD_EMAIL);
done();
});
});
it('succeeds', function (done) {
user.update(USERNAME, USERNAME_NEW, EMAIL_NEW, function (error) {
expect(error).to.not.be.ok();
user.get(USERNAME, function (error, result) {
expect(error).to.not.be.ok();
expect(result).to.be.ok();
expect(result.email).to.equal(EMAIL_NEW);
expect(result.username).to.equal(USERNAME_NEW);
done();
});
});
});
});
describe('admin change', function () {
before(createUser);
after(cleanupUsers);
it('fails to remove admin flag of only admin', function (done) {
user.changeAdmin(USERNAME, false, function (error) {
expect(error).to.be.an('object');
done();
});
});
it('make second user admin succeeds', function (done) {
var user1 = {
username: 'seconduser',
password: 'foobar',
email: 'some@thi.ng'
};
user.create(user1.username, user1.password, user1.email, false, { username: USERNAME, email: EMAIL } /* invitor */, function (error, result) {
expect(error).to.not.be.ok();
expect(result).to.be.ok();
user.changeAdmin(user1.username, true, function (error) {
expect(error).to.not.be.ok();
done();
});
});
});
it('succeeds to remove admin flag of first user', function (done) {
user.changeAdmin(USERNAME, false, function (error) {
expect(error).to.not.be.ok();
done();
});
});
});
describe('password change', function () {
before(createUser);
after(cleanupUsers);
it('fails due to wrong arumgent count', function () {
expect(function () { user.changePassword(); }).to.throwError();
expect(function () { user.changePassword(USERNAME); }).to.throwError();
expect(function () { user.changePassword(USERNAME, PASSWORD, NEW_PASSWORD); }).to.throwError();
});
it('fails due to wrong arumgents', function () {
expect(function () { user.changePassword(USERNAME, {}, NEW_PASSWORD, function () {}); }).to.throwError();
expect(function () { user.changePassword(1337, PASSWORD, NEW_PASSWORD, function () {}); }).to.throwError();
expect(function () { user.changePassword(USERNAME, PASSWORD, 1337, function () {}); }).to.throwError();
expect(function () { user.changePassword(USERNAME, PASSWORD, NEW_PASSWORD, 'some string'); }).to.throwError();
});
it('fails due to wrong password', function (done) {
user.changePassword(USERNAME, 'wrongpassword', NEW_PASSWORD, function (error) {
expect(error).to.be.ok();
done();
});
});
it('fails due to empty new password', function (done) {
user.changePassword(USERNAME, PASSWORD, '', function (error) {
expect(error).to.be.ok();
done();
});
});
it('fails due to unknown user', function (done) {
user.changePassword('somerandomuser', PASSWORD, NEW_PASSWORD, function (error) {
expect(error).to.be.ok();
done();
});
});
it('succeeds', function (done) {
user.changePassword(USERNAME, PASSWORD, NEW_PASSWORD, function (error) {
expect(error).to.not.be.ok();
done();
});
});
it('actually changed the password (unable to login with old pasword)', function (done) {
user.verify(USERNAME, PASSWORD, function (error, result) {
expect(error).to.be.ok();
expect(result).to.not.be.ok();
expect(error.reason).to.equal(UserError.WRONG_PASSWORD);
done();
});
});
it('actually changed the password (login with new password)', function (done) {
user.verify(USERNAME, NEW_PASSWORD, function (error, result) {
expect(error).to.not.be.ok();
expect(result).to.be.ok();
done();
});
});
});
describe('resetPasswordByIdentifier', function () {
before(createUser);
after(cleanupUsers);
it('fails due to unkown email', function (done) {
user.resetPasswordByIdentifier('unknown@mail.com', function (error) {
expect(error).to.be.an(UserError);
expect(error.reason).to.eql(UserError.NOT_FOUND);
done();
});
});
it('fails due to unkown username', function (done) {
user.resetPasswordByIdentifier('unknown', function (error) {
expect(error).to.be.an(UserError);
expect(error.reason).to.eql(UserError.NOT_FOUND);
done();
});
});
it('succeeds with email', function (done) {
user.resetPasswordByIdentifier(EMAIL, function (error) {
expect(error).to.not.be.ok();
done();
});
});
it('succeeds with username', function (done) {
user.resetPasswordByIdentifier(USERNAME, function (error) {
expect(error).to.not.be.ok();
done();
});
});
});
});

147
src/tokendb.js Normal file
View File

@@ -0,0 +1,147 @@
/* jslint node: true */
'use strict';
exports = module.exports = {
generateToken: generateToken,
get: get,
add: add,
del: del,
getByIdentifier: getByIdentifier,
delByIdentifier: delByIdentifier,
getByIdentifierAndClientId: getByIdentifierAndClientId,
delByIdentifierAndClientId: delByIdentifierAndClientId,
delExpired: delExpired,
TYPE_USER: 'user',
TYPE_DEV: 'developer',
TYPE_APP: 'appliation',
PREFIX_USER: 'user-',
PREFIX_DEV: 'dev-',
PREFIX_APP: 'app-',
_clear: clear
};
var assert = require('assert'),
database = require('./database.js'),
DatabaseError = require('./databaseerror'),
hat = require('hat');
var TOKENS_FIELDS = [ 'accessToken', 'identifier', 'clientId', 'scope', 'expires' ].join(',');
function generateToken() {
return hat(256);
}
function get(accessToken, callback) {
assert.strictEqual(typeof accessToken, 'string');
assert.strictEqual(typeof callback, 'function');
database.query('SELECT ' + TOKENS_FIELDS + ' FROM tokens WHERE accessToken = ? AND expires > ?', [ accessToken, Date.now() ], function (error, result) {
if (error) return callback(new DatabaseError(DatabaseError.INTERNAL_ERROR, error));
if (result.length === 0) return callback(new DatabaseError(DatabaseError.NOT_FOUND));
callback(null, result[0]);
});
}
function add(accessToken, identifier, clientId, expires, scope, callback) {
assert.strictEqual(typeof accessToken, 'string');
assert.strictEqual(typeof identifier, 'string');
assert(typeof clientId === 'string' || clientId === null);
assert.strictEqual(typeof expires, 'number');
assert.strictEqual(typeof scope, 'string');
assert.strictEqual(typeof callback, 'function');
database.query('INSERT INTO tokens (accessToken, identifier, clientId, expires, scope) VALUES (?, ?, ?, ?, ?)',
[ accessToken, identifier, clientId, expires, scope ], function (error, result) {
if (error && error.code === 'ER_DUP_ENTRY') return callback(new DatabaseError(DatabaseError.ALREADY_EXISTS));
if (error || result.affectedRows !== 1) return callback(new DatabaseError(DatabaseError.INTERNAL_ERROR, error));
callback(null);
});
}
function del(accessToken, callback) {
assert.strictEqual(typeof accessToken, 'string');
assert.strictEqual(typeof callback, 'function');
database.query('DELETE FROM tokens WHERE accessToken = ?', [ accessToken ], function (error, result) {
if (error) return callback(new DatabaseError(DatabaseError.INTERNAL_ERROR, error));
if (result.affectedRows !== 1) return callback(new DatabaseError(DatabaseError.NOT_FOUND));
callback(error);
});
}
function getByIdentifier(identifier, callback) {
assert.strictEqual(typeof identifier, 'string');
assert.strictEqual(typeof callback, 'function');
database.query('SELECT ' + TOKENS_FIELDS + ' FROM tokens WHERE identifier = ?', [ identifier ], function (error, results) {
if (error) return callback(new DatabaseError(DatabaseError.INTERNAL_ERROR, error));
callback(null, results);
});
}
function delByIdentifier(identifier, callback) {
assert.strictEqual(typeof identifier, 'string');
assert.strictEqual(typeof callback, 'function');
database.query('DELETE FROM tokens WHERE identifier = ?', [ identifier ], function (error, result) {
if (error) return callback(new DatabaseError(DatabaseError.INTERNAL_ERROR, error));
if (result.affectedRows !== 1) return callback(new DatabaseError(DatabaseError.NOT_FOUND));
return callback(null);
});
}
function getByIdentifierAndClientId(identifier, clientId, callback) {
assert.strictEqual(typeof identifier, 'string');
assert.strictEqual(typeof clientId, 'string');
assert.strictEqual(typeof callback, 'function');
database.query('SELECT ' + TOKENS_FIELDS + ' FROM tokens WHERE identifier=? AND clientId=?', [ identifier, clientId ], function (error, results) {
if (error) return callback(new DatabaseError(DatabaseError.INTERNAL_ERROR, error));
if (results.length === 0) return callback(new DatabaseError(DatabaseError.NOT_FOUND));
callback(null, results);
});
}
function delByIdentifierAndClientId(identifier, clientId, callback) {
assert.strictEqual(typeof identifier, 'string');
assert.strictEqual(typeof clientId, 'string');
assert.strictEqual(typeof callback, 'function');
database.query('DELETE FROM tokens WHERE identifier = ? AND clientId = ?', [ identifier, clientId ], function (error, result) {
if (error) return callback(new DatabaseError(DatabaseError.INTERNAL_ERROR, error));
if (result.affectedRows !== 1) return callback(new DatabaseError(DatabaseError.NOT_FOUND));
return callback(null);
});
}
function delExpired(callback) {
assert.strictEqual(typeof callback, 'function');
database.query('DELETE FROM tokens WHERE expires <= ?', [ Date.now() ], function (error, result) {
if (error) return callback(new DatabaseError(DatabaseError.INTERNAL_ERROR, error));
return callback(null, result.affectedRows);
});
}
function clear(callback) {
assert.strictEqual(typeof callback, 'function');
database.query('DELETE FROM tokens', function (error) {
if (error) return callback(new DatabaseError(DatabaseError.INTERNAL_ERROR, error));
return callback(null);
});
}

160
src/updatechecker.js Normal file
View File

@@ -0,0 +1,160 @@
/* jslint node:true */
'use strict';
exports = module.exports = {
checkAppUpdates: checkAppUpdates,
checkBoxUpdates: checkBoxUpdates,
getUpdateInfo: getUpdateInfo
};
var apps = require('./apps.js'),
assert = require('assert'),
async = require('async'),
config = require('./config.js'),
debug = require('debug')('box:updatechecker'),
fs = require('fs'),
mailer = require('./mailer.js'),
path = require('path'),
paths = require('./paths.js'),
safe = require('safetydance'),
semver = require('semver'),
superagent = require('superagent'),
util = require('util');
var NOOP_CALLBACK = function (error) { console.error(error); };
var gAppUpdateInfo = { }, // id -> update info { creationDate, manifest }
gBoxUpdateInfo = null,
gMailedUser = { };
function getUpdateInfo() {
return {
apps: gAppUpdateInfo,
box: gBoxUpdateInfo
};
}
function getAppUpdates(callback) {
apps.getAll(function (error, apps) {
if (error) return callback(error);
var appUpdateInfo = { };
// appStoreId can be '' for dev apps
var appStoreIds = apps.map(function (app) { return app.appStoreId; }).filter(function (id) { return id !== ''; });
superagent
.post(config.apiServerOrigin() + '/api/v1/appupdates')
.send({ appIds: appStoreIds, boxVersion: config.version() })
.timeout(10 * 1000)
.end(function (error, result) {
if (error) return callback(error);
if (result.statusCode !== 200 || !result.body.appVersions) {
return callback(new Error(util.format('Error checking app update: %s %s', result.statusCode, result.text)));
}
var latestAppVersions = result.body.appVersions;
for (var i = 0; i < apps.length; i++) {
if (!(apps[i].appStoreId in latestAppVersions)) continue;
var oldVersion = apps[i].manifest.version;
var newManifest = latestAppVersions[apps[i].appStoreId].manifest;
var newVersion = newManifest.version;
if (newVersion !== oldVersion) {
appUpdateInfo[apps[i].id] = latestAppVersions[apps[i].appStoreId];
debug('Update available for %s (%s) from %s to %s', apps[i].location, apps[i].id, oldVersion, newVersion);
}
}
callback(null, appUpdateInfo);
});
});
}
function getBoxUpdates(callback) {
var currentVersion = config.version();
superagent
.get(config.get('boxVersionsUrl'))
.timeout(10 * 1000)
.end(function (error, result) {
if (error) return callback(error);
if (result.status !== 200) return callback(new Error(util.format('Bad status: %s %s', result.status, result.text)));
var versions = safe.JSON.parse(result.text);
if (!versions || typeof versions !== 'object') return callback(new Error('versions is not in valid format:' + safe.error));
var latestVersion = Object.keys(versions).sort(semver.compare).pop();
debug('checkBoxUpdates: Latest version is %s etag:%s', latestVersion, result.header['etag']);
if (!latestVersion) return callback(new Error('No version available'));
var nextVersion = null, nextVersionInfo = null;
var currentVersionInfo = versions[currentVersion];
if (!currentVersionInfo) {
debug('Cloudron runs on unknown version %s. Offering to update to latest version', currentVersion);
nextVersion = latestVersion;
nextVersionInfo = versions[latestVersion];
} else {
nextVersion = currentVersionInfo.next;
nextVersionInfo = nextVersion ? versions[nextVersion] : null;
}
if (nextVersionInfo && typeof nextVersionInfo === 'object') {
debug('new version %s available. imageId: %d code: %s', nextVersion, nextVersionInfo.imageId, nextVersionInfo.sourceTarballUrl);
callback(null, {
version: nextVersion,
changelog: nextVersionInfo.changelog,
upgrade: nextVersionInfo.upgrade
});
} else {
debug('no new version available.');
callback(null, null);
}
});
}
function checkAppUpdates() {
debug('Checking App Updates');
getAppUpdates(function (error, result) {
if (error) debug('Error checking app updates: ', error);
gAppUpdateInfo = error ? {} : result;
async.eachSeries(Object.keys(gAppUpdateInfo), function iterator(id, iteratorDone) {
if (gMailedUser[id]) return iteratorDone();
apps.get(id, function (error, app) {
if (error) {
debug('Error getting app %s %s', id, error);
return iteratorDone();
}
mailer.appUpdateAvailable(app, gAppUpdateInfo[id]);
gMailedUser[id] = true;
});
});
});
}
function checkBoxUpdates() {
debug('Checking Box Updates');
getBoxUpdates(function (error, result) {
if (error) debug('Error checking box updates: ', error);
gBoxUpdateInfo = error ? null : result;
if (gBoxUpdateInfo && !gMailedUser['box']) {
mailer.boxUpdateAvailable(gBoxUpdateInfo.version, gBoxUpdateInfo.changelog);
gMailedUser['box'] = true;
}
});
}

384
src/user.js Normal file
View File

@@ -0,0 +1,384 @@
/* jshint node:true */
'use strict';
exports = module.exports = {
UserError: UserError,
list: listUsers,
create: createUser,
verify: verify,
verifyWithEmail: verifyWithEmail,
remove: removeUser,
get: getUser,
getByResetToken: getByResetToken,
changeAdmin: changeAdmin,
resetPasswordByIdentifier: resetPasswordByIdentifier,
setPassword: setPassword,
changePassword: changePassword,
update: updateUser,
createOwner: createOwner
};
var assert = require('assert'),
crypto = require('crypto'),
DatabaseError = require('./databaseerror.js'),
mailer = require('./mailer.js'),
hat = require('hat'),
userdb = require('./userdb.js'),
tokendb = require('./tokendb.js'),
clientdb = require('./clientdb.js'),
util = require('util'),
validator = require('validator'),
_ = require('underscore');
var CRYPTO_SALT_SIZE = 64; // 512-bit salt
var CRYPTO_ITERATIONS = 10000; // iterations
var CRYPTO_KEY_LENGTH = 512; // bits
// http://dustinsenos.com/articles/customErrorsInNode
// http://code.google.com/p/v8/wiki/JavaScriptStackTraceApi
function UserError(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(UserError, Error);
UserError.INTERNAL_ERROR = 'Internal Error';
UserError.ALREADY_EXISTS = 'Already Exists';
UserError.NOT_FOUND = 'Not Found';
UserError.WRONG_PASSWORD = 'Wrong User or Password';
UserError.BAD_FIELD = 'Bad field';
UserError.BAD_USERNAME = 'Bad username';
UserError.BAD_EMAIL = 'Bad email';
UserError.BAD_PASSWORD = 'Bad password';
UserError.BAD_TOKEN = 'Bad token';
UserError.NOT_ALLOWED = 'Not Allowed';
function listUsers(callback) {
assert.strictEqual(typeof callback, 'function');
userdb.getAll(function (error, result) {
if (error) return callback(new UserError(UserError.INTERNAL_ERROR, error));
return callback(null, result.map(function (obj) { return _.pick(obj, 'id', 'username', 'email', 'admin'); }));
});
}
function validateUsername(username) {
assert.strictEqual(typeof username, 'string');
if (username.length <= 2) return new UserError(UserError.BAD_USERNAME, 'Username must be atleast 3 chars');
if (username.length > 256) return new UserError(UserError.BAD_USERNAME, 'Username too long');
return null;
}
function validatePassword(password) {
assert.strictEqual(typeof password, 'string');
if (password.length < 5) return new UserError(UserError.BAD_PASSWORD, 'Password must be atleast 5 chars');
return null;
}
function validateEmail(email) {
assert.strictEqual(typeof email, 'string');
if (!validator.isEmail(email)) return new UserError(UserError.BAD_EMAIL, 'Invalid email');
return null;
}
function validateToken(token) {
assert.strictEqual(typeof token, 'string');
if (token.length !== 64) return new UserError(UserError.BAD_TOKEN, 'Invalid token'); // 256-bit hex coded token
return null;
}
function createUser(username, password, email, admin, invitor, callback) {
assert.strictEqual(typeof username, 'string');
assert.strictEqual(typeof password, 'string');
assert.strictEqual(typeof email, 'string');
assert.strictEqual(typeof admin, 'boolean');
assert(invitor || admin);
assert.strictEqual(typeof callback, 'function');
var error = validateUsername(username);
if (error) return callback(error);
error = validatePassword(password);
if (error) return callback(error);
error = validateEmail(email);
if (error) return callback(error);
crypto.randomBytes(CRYPTO_SALT_SIZE, function (error, salt) {
if (error) return callback(new UserError(UserError.INTERNAL_ERROR, error));
crypto.pbkdf2(password, salt, CRYPTO_ITERATIONS, CRYPTO_KEY_LENGTH, function (error, derivedKey) {
if (error) return callback(new UserError(UserError.INTERNAL_ERROR, error));
var now = (new Date()).toUTCString();
var user = {
id: username,
username: username,
email: email,
password: new Buffer(derivedKey, 'binary').toString('hex'),
admin: admin,
salt: salt.toString('hex'),
createdAt: now,
modifiedAt: now,
resetToken: hat(256)
};
userdb.add(user.id, user, function (error) {
if (error && error.reason === DatabaseError.ALREADY_EXISTS) return callback(new UserError(UserError.ALREADY_EXISTS));
if (error) return callback(new UserError(UserError.INTERNAL_ERROR, error));
callback(null, user);
// only send welcome mail if user is not an admin. This is only the case for the first user!
// The welcome email contains a link to create a new password
if (!user.admin) mailer.userAdded(user, invitor);
});
});
});
}
function verify(username, password, callback) {
assert.strictEqual(typeof username, 'string');
assert.strictEqual(typeof password, 'string');
assert.strictEqual(typeof callback, 'function');
userdb.get(username, function (error, user) {
if (error && error.reason == DatabaseError.NOT_FOUND) return callback(new UserError(UserError.NOT_FOUND));
if (error) return callback(new UserError(UserError.INTERNAL_ERROR, error));
var saltBinary = new Buffer(user.salt, 'hex');
crypto.pbkdf2(password, saltBinary, CRYPTO_ITERATIONS, CRYPTO_KEY_LENGTH, function (error, derivedKey) {
if (error) return callback(new UserError(UserError.INTERNAL_ERROR, error));
var derivedKeyHex = new Buffer(derivedKey, 'binary').toString('hex');
if (derivedKeyHex !== user.password) return callback(new UserError(UserError.WRONG_PASSWORD));
callback(null, user);
});
});
}
function verifyWithEmail(email, password, callback) {
assert.strictEqual(typeof email, 'string');
assert.strictEqual(typeof password, 'string');
assert.strictEqual(typeof callback, 'function');
userdb.getByEmail(email, function (error, user) {
if (error && error.reason == DatabaseError.NOT_FOUND) return callback(new UserError(UserError.NOT_FOUND));
if (error) return callback(new UserError(UserError.INTERNAL_ERROR, error));
var saltBinary = new Buffer(user.salt, 'hex');
crypto.pbkdf2(password, saltBinary, CRYPTO_ITERATIONS, CRYPTO_KEY_LENGTH, function (error, derivedKey) {
if (error) return callback(new UserError(UserError.INTERNAL_ERROR, error));
var derivedKeyHex = new Buffer(derivedKey, 'binary').toString('hex');
if (derivedKeyHex !== user.password) return callback(new UserError(UserError.WRONG_PASSWORD));
callback(null, user);
});
});
}
function removeUser(username, callback) {
assert.strictEqual(typeof username, 'string');
assert.strictEqual(typeof callback, 'function');
userdb.del(username, function (error) {
if (error && error.reason === DatabaseError.NOT_FOUND) return callback(new UserError(UserError.NOT_FOUND));
if (error) return callback(new UserError(UserError.INTERNAL_ERROR, error));
callback(null);
mailer.userRemoved(username);
});
}
function getUser(userId, callback) {
assert.strictEqual(typeof userId, 'string');
assert.strictEqual(typeof callback, 'function');
userdb.get(userId, function (error, result) {
if (error && error.reason === DatabaseError.NOT_FOUND) return callback(new UserError(UserError.NOT_FOUND));
if (error) return callback(new UserError(UserError.INTERNAL_ERROR, error));
return callback(null, result);
});
}
function getByResetToken(resetToken, callback) {
assert.strictEqual(typeof resetToken, 'string');
assert.strictEqual(typeof callback, 'function');
var error = validateToken(resetToken);
if (error) return callback(error);
userdb.getByResetToken(resetToken, function (error, result) {
if (error && error.reason === DatabaseError.NOT_FOUND) return callback(new UserError(UserError.NOT_FOUND));
if (error) return callback(new UserError(UserError.INTERNAL_ERROR, error));
callback(null, result);
});
}
function updateUser(userId, username, email, callback) {
assert.strictEqual(typeof userId, 'string');
assert.strictEqual(typeof username, 'string');
assert.strictEqual(typeof email, 'string');
assert.strictEqual(typeof callback, 'function');
var error = validateUsername(username);
if (error) return callback(error);
error = validateEmail(email);
if (error) return callback(error);
userdb.update(userId, { username: username, email: email }, function (error) {
if (error && error.reason === DatabaseError.NOT_FOUND) return callback(new UserError(UserError.NOT_FOUND, error));
if (error) return callback(new UserError(UserError.INTERNAL_ERROR, error));
callback(null);
});
}
function changeAdmin(username, admin, callback) {
assert.strictEqual(typeof username, 'string');
assert.strictEqual(typeof admin, 'boolean');
assert.strictEqual(typeof callback, 'function');
getUser(username, function (error, user) {
if (error) return callback(error);
userdb.getAllAdmins(function (error, result) {
if (error) return callback(new UserError(UserError.INTERNAL_ERROR, error));
// protect from a system where there is no admin left
if (result.length <= 1 && !admin) return callback(new UserError(UserError.NOT_ALLOWED, 'Only admin'));
user.admin = admin;
userdb.update(username, user, function (error) {
if (error) return callback(new UserError(UserError.INTERNAL_ERROR, error));
callback(null);
mailer.adminChanged(user);
});
});
});
}
function resetPasswordByIdentifier(identifier, callback) {
assert.strictEqual(typeof identifier, 'string');
assert.strictEqual(typeof callback, 'function');
var getter;
if (identifier.indexOf('@') === -1) getter = userdb.getByUsername;
else getter = userdb.getByEmail;
getter(identifier, function (error, result) {
if (error && error.reason === DatabaseError.NOT_FOUND) return callback(new UserError(UserError.NOT_FOUND));
if (error) return callback(new UserError(UserError.INTERNAL_ERROR, error));
result.resetToken = hat(256);
userdb.update(result.id, result, function (error) {
if (error && error.reason === DatabaseError.NOT_FOUND) return callback(new UserError(UserError.NOT_FOUND));
if (error) return callback(new UserError(UserError.INTERNAL_ERROR, error));
mailer.passwordReset(result);
callback(null);
});
});
}
function setPassword(userId, newPassword, callback) {
assert.strictEqual(typeof userId, 'string');
assert.strictEqual(typeof newPassword, 'string');
assert.strictEqual(typeof callback, 'function');
var error = validatePassword(newPassword);
if (error) return callback(error);
userdb.get(userId, function (error, user) {
if (error && error.reason === DatabaseError.NOT_FOUND) return callback(new UserError(UserError.NOT_FOUND));
if (error) return callback(new UserError(UserError.INTERNAL_ERROR, error));
var saltBuffer = new Buffer(user.salt, 'hex');
crypto.pbkdf2(newPassword, saltBuffer, CRYPTO_ITERATIONS, CRYPTO_KEY_LENGTH, function (error, derivedKey) {
if (error) return callback(new UserError(UserError.INTERNAL_ERROR, error));
user.modifiedAt = (new Date()).toUTCString();
user.password = new Buffer(derivedKey, 'binary').toString('hex');
user.resetToken = '';
userdb.update(userId, user, function (error) {
if (error && error.reason === DatabaseError.NOT_FOUND) return callback(new UserError(UserError.NOT_FOUND));
if (error) return callback(new UserError(UserError.INTERNAL_ERROR, error));
// Also generate a token so the new user can get logged in immediately
clientdb.getByAppId('webadmin', function (error, result) {
if (error) return callback(new UserError(UserError.INTERNAL_ERROR, error));
var token = tokendb.generateToken();
var expiresAt = Date.now() + 24 * 60 * 60 * 1000; // 1 day
tokendb.add(token, tokendb.PREFIX_USER + user.id, result.id, expiresAt, '*', function (error) {
if (error) return callback(new UserError(UserError.INTERNAL_ERROR, error));
callback(null, { token: token, expiresAt: expiresAt });
});
});
});
});
});
}
function changePassword(username, oldPassword, newPassword, callback) {
assert.strictEqual(typeof username, 'string');
assert.strictEqual(typeof oldPassword, 'string');
assert.strictEqual(typeof newPassword, 'string');
assert.strictEqual(typeof callback, 'function');
var error = validatePassword(newPassword);
if (error) return callback(error);
verify(username, oldPassword, function (error, user) {
if (error) return callback(error);
setPassword(user.id, newPassword, callback);
});
}
function createOwner(username, password, email, callback) {
userdb.count(function (error, count) {
if (error) return callback(new UserError(UserError.INTERNAL_ERROR, error));
if (count !== 0) return callback(new UserError(UserError.ALREADY_EXISTS));
createUser(username, password, email, true /* admin */, null /* invitor */, callback);
});
}

189
src/userdb.js Normal file
View File

@@ -0,0 +1,189 @@
'use strict';
exports = module.exports = {
get: get,
getByUsername: getByUsername,
getByEmail: getByEmail,
getByAccessToken: getByAccessToken,
getByResetToken: getByResetToken,
getAll: getAll,
getAllAdmins: getAllAdmins,
add: add,
del: del,
update: update,
count: count,
adminCount: adminCount,
_clear: clear
};
var assert = require('assert'),
database = require('./database.js'),
debug = require('debug')('box:userdb'),
DatabaseError = require('./databaseerror');
var USERS_FIELDS = [ 'id', 'username', 'email', 'password', 'salt', 'createdAt', 'modifiedAt', 'admin', 'resetToken' ].join(',');
function get(userId, callback) {
assert.strictEqual(typeof userId, 'string');
assert.strictEqual(typeof callback, 'function');
database.query('SELECT ' + USERS_FIELDS + ' FROM users WHERE id = ?', [ userId ], function (error, result) {
if (error) return callback(new DatabaseError(DatabaseError.INTERNAL_ERROR, error));
if (result.length === 0) return callback(new DatabaseError(DatabaseError.NOT_FOUND));
callback(null, result[0]);
});
}
function getByUsername(username, callback) {
assert.strictEqual(typeof username, 'string');
assert.strictEqual(typeof callback, 'function');
// currently username is also our id
get(username, callback);
}
function getByEmail(email, callback) {
assert.strictEqual(typeof email, 'string');
assert.strictEqual(typeof callback, 'function');
database.query('SELECT ' + USERS_FIELDS + ' FROM users WHERE email = ?', [ email ], function (error, result) {
if (error) return callback(new DatabaseError(DatabaseError.INTERNAL_ERROR, error));
if (result.length === 0) return callback(new DatabaseError(DatabaseError.NOT_FOUND));
callback(null, result[0]);
});
}
function getByResetToken(resetToken, callback) {
assert.strictEqual(typeof resetToken, 'string');
assert.strictEqual(typeof callback, 'function');
if (resetToken.length === 0) return callback(new DatabaseError(DatabaseError.INTERNAL_ERROR, 'Empty resetToken not allowed'));
database.query('SELECT ' + USERS_FIELDS + ' FROM users WHERE resetToken=?', [ resetToken ], function (error, result) {
if (error) return callback(new DatabaseError(DatabaseError.INTERNAL_ERROR, error));
if (result.length === 0) return callback(new DatabaseError(DatabaseError.NOT_FOUND));
callback(null, result[0]);
});
}
function getAll(callback) {
assert.strictEqual(typeof callback, 'function');
database.query('SELECT ' + USERS_FIELDS + ' FROM users', function (error, results) {
if (error) return callback(new DatabaseError(DatabaseError.INTERNAL_ERROR, error));
callback(null, results);
});
}
function getAllAdmins(callback) {
assert.strictEqual(typeof callback, 'function');
database.query('SELECT ' + USERS_FIELDS + ' FROM users WHERE admin=1', function (error, results) {
if (error) return callback(new DatabaseError(DatabaseError.INTERNAL_ERROR, error));
callback(null, results);
});
}
function add(userId, user, callback) {
assert.strictEqual(typeof userId, 'string');
assert.strictEqual(typeof user.username, 'string');
assert.strictEqual(typeof user.password, 'string');
assert.strictEqual(typeof user.email, 'string');
assert.strictEqual(typeof user.admin, 'boolean');
assert.strictEqual(typeof user.salt, 'string');
assert.strictEqual(typeof user.createdAt, 'string');
assert.strictEqual(typeof user.modifiedAt, 'string');
assert.strictEqual(typeof user.resetToken, 'string');
assert.strictEqual(typeof callback, 'function');
var data = [ userId, user.username, user.password, user.email, user.admin, user.salt, user.createdAt, user.modifiedAt, user.resetToken ];
database.query('INSERT INTO users (id, username, password, email, admin, salt, createdAt, modifiedAt, resetToken) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
data, function (error, result) {
if (error && error.code === 'ER_DUP_ENTRY') return callback(new DatabaseError(DatabaseError.ALREADY_EXISTS, error));
if (error || result.affectedRows !== 1) return callback(new DatabaseError(DatabaseError.INTERNAL_ERROR, error));
callback(null);
});
}
function del(userId, callback) {
assert.strictEqual(typeof userId, 'string');
assert.strictEqual(typeof callback, 'function');
database.query('DELETE FROM users WHERE id = ?', [ userId ], function (error, result) {
if (error) return callback(new DatabaseError(DatabaseError.INTERNAL_ERROR, error));
if (result.affectedRows !== 1) return callback(new DatabaseError(DatabaseError.NOT_FOUND));
callback(error);
});
}
function getByAccessToken(accessToken, callback) {
assert.strictEqual(typeof accessToken, 'string');
assert.strictEqual(typeof callback, 'function');
debug('getByAccessToken: ' + accessToken);
database.query('SELECT ' + USERS_FIELDS + ' FROM users, tokens WHERE tokens.accessToken = ?', [ accessToken ], function (error, result) {
if (error) return callback(new DatabaseError(DatabaseError.INTERNAL_ERROR, error));
if (result.length === 0) return callback(new DatabaseError(DatabaseError.NOT_FOUND));
callback(null, result[0]);
});
}
function clear(callback) {
database.query('DELETE FROM users', function (error) {
if (error) return callback(new DatabaseError(DatabaseError.INTERNAL_ERROR, error));
callback(error);
});
}
function update(userId, user, callback) {
assert.strictEqual(typeof userId, 'string');
assert.strictEqual(typeof user, 'object');
assert.strictEqual(typeof callback, 'function');
var args = [ ];
var fields = [ ];
for (var k in user) {
fields.push(k + ' = ?');
args.push(user[k]);
}
args.push(userId);
database.query('UPDATE users SET ' + fields.join(', ') + ' WHERE id = ?', args, function (error, result) {
if (error) return callback(new DatabaseError(DatabaseError.INTERNAL_ERROR, error));
if (result.affectedRows !== 1) return callback(new DatabaseError(DatabaseError.NOT_FOUND));
return callback(null);
});
}
function count(callback) {
assert.strictEqual(typeof callback, 'function');
database.query('SELECT COUNT(*) AS total FROM users', function (error, result) {
if (error) return callback(new DatabaseError(DatabaseError.INTERNAL_ERROR, error));
return callback(null, result[0].total);
});
}
function adminCount(callback) {
assert.strictEqual(typeof callback, 'function');
database.query('SELECT COUNT(*) AS total FROM users WHERE admin=1', function (error, result) {
if (error) return callback(new DatabaseError(DatabaseError.INTERNAL_ERROR, error));
return callback(null, result[0].total);
});
}

40
src/vbox.js Normal file
View File

@@ -0,0 +1,40 @@
'use strict';
// we can possibly remove this entire file and make our tests
// smarter to just use the host interface provided by boot2docker
// https://github.com/boot2docker/boot2docker#container-port-redirection
// https://github.com/boot2docker/boot2docker/pull/93
// https://github.com/docker/docker/issues/4007
exports = module.exports = {
forwardFromHostToVirtualBox: forwardFromHostToVirtualBox,
unforwardFromHostToVirtualBox: unforwardFromHostToVirtualBox
};
var assert = require('assert'),
child_process = require('child_process'),
debug = require('debug')('box:vbox'),
os = require('os');
function forwardFromHostToVirtualBox(rulename, port) {
assert.strictEqual(typeof rulename, 'string');
assert.strictEqual(typeof port, 'number');
if (os.platform() === 'darwin') {
debug('Setting up VirtualBox port forwarding for '+ rulename + ' at ' + port);
child_process.exec(
'VBoxManage controlvm boot2docker-vm natpf1 delete ' + rulename + ';' +
'VBoxManage controlvm boot2docker-vm natpf1 ' + rulename + ',tcp,127.0.0.1,' + port + ',,' + port);
}
}
function unforwardFromHostToVirtualBox(rulename) {
assert.strictEqual(typeof rulename, 'string');
if (os.platform() === 'darwin') {
debug('Removing VirtualBox port forwarding for '+ rulename);
child_process.exec('VBoxManage controlvm boot2docker-vm natpf1 delete ' + rulename);
}
}