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

6
.gitattributes vendored Normal file
View File

@@ -0,0 +1,6 @@
# Skip files when using git archive
.gitattributes export-ignore
.gitignore export-ignore
/scripts export-ignore
test export-ignore

13
.gitignore vendored Normal file
View File

@@ -0,0 +1,13 @@
node_modules/
coverage/
docs/
webadmin/dist/
setup/splash/website/
# vim swam files
*.swp
# supervisor
supervisord.pid
supervisord.log

7
.jshintrc Normal file
View File

@@ -0,0 +1,7 @@
{
"node": true,
"browser": true,
"unused": true,
"globalstrict": true,
"predef": [ "angular", "$" ]
}

11
README.md Normal file
View File

@@ -0,0 +1,11 @@
The Box
=======
Development setup
-----------------
* sudo useradd -m yellowtent
** This dummy user is required for supervisor 'box' configs
** Add admin-localhost as 127.0.0.1 in /etc/hosts
** All apps will be installed as hypened-subdomains of localhost. You should add
hyphened-subdomains of your apps into /etc/hosts

47
app.js Executable file
View File

@@ -0,0 +1,47 @@
#!/usr/bin/env node
'use strict';
require('supererror')({ splatchError: true });
var server = require('./src/server.js'),
ldap = require('./src/ldap.js'),
config = require('./src/config.js');
console.log();
console.log('==========================================');
console.log(' Cloudron will use the following settings ');
console.log('==========================================');
console.log();
console.log(' Environment: ', config.CLOUDRON ? 'CLOUDRON' : 'TEST');
console.log(' Version: ', config.version());
console.log(' Admin Origin: ', config.adminOrigin());
console.log(' Appstore token: ', config.token());
console.log(' Appstore API server origin: ', config.apiServerOrigin());
console.log(' Appstore Web server origin: ', config.webServerOrigin());
console.log();
console.log('==========================================');
console.log();
server.start(function (err) {
if (err) {
console.error('Error starting server', err);
process.exit(1);
}
console.log('Server listening on port ' + config.get('port'));
ldap.start(function (error) {
if (error) {
console.error('Error LDAP starting server', err);
process.exit(1);
}
console.log('LDAP server listen on port ' + config.get('ldapPort'));
});
});
var NOOP_CALLBACK = function () { };
process.on('SIGINT', function () { server.stop(NOOP_CALLBACK); });
process.on('SIGTERM', function () { server.stop(NOOP_CALLBACK); });

147
apphealthtask.js Executable file
View File

@@ -0,0 +1,147 @@
#!/usr/bin/env node
'use strict';
require('supererror')({ splatchError: true });
var appdb = require('./src/appdb.js'),
assert = require('assert'),
async = require('async'),
database = require('./src/database.js'),
DatabaseError = require('./src/databaseerror.js'),
debug = require('debug')('box:apphealthtask'),
docker = require('./src/docker.js'),
mailer = require('./src/mailer.js'),
superagent = require('superagent'),
util = require('util');
exports = module.exports = {
run: run
};
var HEALTHCHECK_INTERVAL = 10 * 1000; // every 10 seconds. this needs to be small since the UI makes only healthy apps clickable
var UNHEALTHY_THRESHOLD = 3 * 60 * 1000; // 3 minutes
var gHealthInfo = { }; // { time, emailSent }
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 initialize(callback) {
assert.strictEqual(typeof callback, 'function');
async.series([
database.initialize,
mailer.initialize
], callback);
}
function setHealth(app, health, callback) {
assert.strictEqual(typeof app, 'object');
assert.strictEqual(typeof health, 'string');
assert.strictEqual(typeof callback, 'function');
var now = new Date();
if (!(app.id in gHealthInfo)) { // add new apps to list
gHealthInfo[app.id] = { time: now, emailSent: false };
}
if (health === appdb.HEALTH_HEALTHY) {
gHealthInfo[app.id].time = now;
} else if (Math.abs(now - gHealthInfo[app.id].time) > UNHEALTHY_THRESHOLD) {
if (gHealthInfo[app.id].emailSent) return callback(null);
debugApp(app, 'marking as unhealthy since not seen for more than %s minutes', UNHEALTHY_THRESHOLD/(60 * 1000));
mailer.appDied(app);
gHealthInfo[app.id].emailSent = true;
} else {
debugApp(app, 'waiting for sometime to update the app health');
return callback(null);
}
appdb.setHealth(app.id, health, function (error) {
if (error && error.reason === DatabaseError.NOT_FOUND) return callback(null); // app uninstalled?
if (error) return callback(error);
app.health = health;
callback(null);
});
}
// callback is called with error for fatal errors and not if health check failed
function checkAppHealth(app, callback) {
if (app.installationState !== appdb.ISTATE_INSTALLED || app.runState !== appdb.RSTATE_RUNNING) {
debugApp(app, 'skipped. istate:%s rstate:%s', app.installationState, app.runState);
return callback(null);
}
var container = docker.getContainer(app.containerId),
manifest = app.manifest;
container.inspect(function (err, data) {
if (err || !data || !data.State) {
debugApp(app, 'Error inspecting container');
return setHealth(app, appdb.HEALTH_ERROR, callback);
}
if (data.State.Running !== true) {
debugApp(app, 'exited');
return setHealth(app, appdb.HEALTH_DEAD, callback);
}
// poll through docker network instead of nginx to bypass any potential oauth proxy
var healthCheckUrl = 'http://127.0.0.1:' + app.httpPort + manifest.healthCheckPath;
superagent
.get(healthCheckUrl)
.redirects(0)
.timeout(HEALTHCHECK_INTERVAL)
.end(function (error, res) {
if (error || res.status >= 400) { // 2xx and 3xx are ok
debugApp(app, 'not alive : %s', error || res.status);
setHealth(app, appdb.HEALTH_UNHEALTHY, callback);
} else {
debugApp(app, 'alive');
setHealth(app, appdb.HEALTH_HEALTHY, callback);
}
});
});
}
function processApps(callback) {
appdb.getAll(function (error, apps) {
if (error) return callback(error);
async.each(apps, checkAppHealth, function (error) {
if (error) console.error(error);
callback(null);
});
});
}
function run() {
processApps(function (error) {
if (error) console.error(error);
setTimeout(run, HEALTHCHECK_INTERVAL);
});
}
if (require.main === module) {
initialize(function (error) {
if (error) {
console.error('apphealth task exiting with error', error);
process.exit(1);
}
run();
});
}

BIN
assets/avatar.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

BIN
assets/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

78
crashnotifier.js Normal file
View File

@@ -0,0 +1,78 @@
#!/usr/bin/env node
'use strict';
// WARNING This is a supervisor eventlistener!
// The communication happens via stdin/stdout
// !! No console.log() allowed
// !! Do not set DEBUG
var supervisor = require('supervisord-eventlistener'),
assert = require('assert'),
exec = require('child_process').exec,
util = require('util'),
fs = require('fs'),
mailer = require('./src/mailer.js');
var gLastNotifyTime = {};
var gCooldownTime = 1000 * 60 * 5; // 5 min
function collectLogs(program, callback) {
assert.strictEqual(typeof program, 'string');
assert.strictEqual(typeof callback, 'function');
var logFilePath = util.format('/var/log/supervisor/%s.log', program);
if (!fs.existsSync(logFilePath)) return callback(new Error(util.format('Log file %s does not exist.', logFilePath)));
fs.readFile(logFilePath, 'utf-8', function (error, data) {
if (error) return callback(error);
var lines = data.split('\n');
var boxLogLines = lines.slice(-100);
exec('dmesg', function (error, stdout /*, stderr */) {
if (error) console.error(error);
var lines = stdout.split('\n');
var dmesgLogLines = lines.slice(-100);
var result = '';
result += program + '.log\n';
result += '-------------------------------------\n';
result += boxLogLines.join('\n');
result += '\n\n';
result += 'dmesg\n';
result += '-------------------------------------\n';
result += dmesgLogLines.join('\n');
callback(null, result);
});
});
}
supervisor.on('PROCESS_STATE_EXITED', function (headers, data) {
if (data.expected === '1') return console.error('Normal app %s exit', data.processname);
console.error('%s exited unexpectedly', data.processname);
collectLogs(data.processname, function (error, result) {
if (error) {
console.error('Failed to collect logs.', error);
result = util.format('Failed to collect logs.', error);
}
if (!gLastNotifyTime[data.processname] || gLastNotifyTime[data.processname] < Date.now() - gCooldownTime) {
console.error('Send mail.');
mailer.sendCrashNotification(data.processname, result);
gLastNotifyTime[data.processname] = Date.now();
} else {
console.error('Do not send mail, already sent one recently.');
}
});
});
mailer.initialize(function () {
supervisor.listen(process.stdin, process.stdout);
console.error('Crashnotifier listening...');
});

5
docs/makeDocs Executable file
View File

@@ -0,0 +1,5 @@
#!/bin/sh
set -eu
./node_modules/.bin/apidoc -i src/routes -o docs

159
gulpfile.js Normal file
View File

@@ -0,0 +1,159 @@
/* jslint node:true */
'use strict';
var ejs = require('gulp-ejs'),
gulp = require('gulp'),
del = require('del'),
concat = require('gulp-concat'),
uglify = require('gulp-uglify'),
serve = require('gulp-serve'),
sass = require('gulp-sass'),
sourcemaps = require('gulp-sourcemaps'),
minifyCSS = require('gulp-minify-css'),
autoprefixer = require('gulp-autoprefixer'),
argv = require('yargs').argv;
gulp.task('3rdparty', function () {
gulp.src([
'webadmin/src/3rdparty/**/*.js',
'webadmin/src/3rdparty/**/*.map',
'webadmin/src/3rdparty/**/*.css',
'webadmin/src/3rdparty/**/*.otf',
'webadmin/src/3rdparty/**/*.eot',
'webadmin/src/3rdparty/**/*.svg',
'webadmin/src/3rdparty/**/*.ttf',
'webadmin/src/3rdparty/**/*.woff',
'webadmin/src/3rdparty/**/*.woff2'
])
.pipe(gulp.dest('webadmin/dist/3rdparty/'))
.pipe(gulp.dest('setup/splash/website/3rdparty'));
gulp.src('node_modules/bootstrap-sass/assets/javascripts/bootstrap.min.js')
.pipe(gulp.dest('webadmin/dist/3rdparty/js'))
.pipe(gulp.dest('setup/splash/website/3rdparty/js'));
});
// --------------
// JavaScript
// --------------
gulp.task('js', ['js-index', 'js-setup', 'js-update', 'js-error'], function () {});
var oauth = {
clientId: argv.clientId || 'cid-webadmin',
clientSecret: argv.clientSecret || 'unused',
apiOrigin: argv.apiOrigin || ''
};
console.log();
console.log('Using OAuth credentials:');
console.log(' ClientId: %s', oauth.clientId);
console.log(' ClientSecret: %s', oauth.clientSecret);
console.log(' Cloudron API: %s', oauth.apiOrigin || 'default');
console.log();
gulp.task('js-index', function () {
gulp.src([
'webadmin/src/js/index.js',
'webadmin/src/js/client.js',
'webadmin/src/js/appstore.js',
'webadmin/src/js/main.js',
'webadmin/src/views/*.js'
])
.pipe(ejs({ oauth: oauth }, { ext: '.js' }))
.pipe(sourcemaps.init())
.pipe(concat('index.js', { newLine: ';' }))
.pipe(uglify())
.pipe(sourcemaps.write())
.pipe(gulp.dest('webadmin/dist/js'));
});
gulp.task('js-setup', function () {
gulp.src(['webadmin/src/js/setup.js', 'webadmin/src/js/client.js'])
.pipe(ejs({ oauth: oauth }, { ext: '.js' }))
.pipe(sourcemaps.init())
.pipe(concat('setup.js', { newLine: ';' }))
.pipe(uglify())
.pipe(sourcemaps.write())
.pipe(gulp.dest('webadmin/dist/js'));
});
gulp.task('js-error', function () {
gulp.src(['webadmin/src/js/error.js'])
.pipe(sourcemaps.init())
.pipe(uglify())
.pipe(sourcemaps.write())
.pipe(gulp.dest('webadmin/dist/js'));
});
gulp.task('js-update', function () {
gulp.src(['webadmin/src/js/update.js'])
.pipe(sourcemaps.init())
.pipe(uglify())
.pipe(sourcemaps.write())
.pipe(gulp.dest('webadmin/dist/js'))
.pipe(gulp.dest('setup/splash/website/js'));
});
// --------------
// HTML
// --------------
gulp.task('html', ['html-views', 'html-update'], function () {
return gulp.src('webadmin/src/*.html').pipe(gulp.dest('webadmin/dist'));
});
gulp.task('html-update', function () {
return gulp.src(['webadmin/src/update.html']).pipe(gulp.dest('setup/splash/website'));
});
gulp.task('html-views', function () {
return gulp.src('webadmin/src/views/**/*.html').pipe(gulp.dest('webadmin/dist/views'));
});
// --------------
// CSS
// --------------
gulp.task('css', function () {
return gulp.src('webadmin/src/*.scss')
.pipe(sourcemaps.init())
.pipe(sass({ includePaths: ['node_modules/bootstrap-sass/assets/stylesheets/'] }).on('error', sass.logError))
.pipe(autoprefixer())
.pipe(minifyCSS())
.pipe(sourcemaps.write())
.pipe(gulp.dest('webadmin/dist'))
.pipe(gulp.dest('setup/splash/website'));
});
gulp.task('images', function () {
return gulp.src('webadmin/src/img/**')
.pipe(gulp.dest('webadmin/dist/img'));
});
// --------------
// Utilities
// --------------
gulp.task('watch', ['default'], function () {
gulp.watch(['webadmin/src/*.scss'], ['css']);
gulp.watch(['webadmin/src/img/*'], ['images']);
gulp.watch(['webadmin/src/**/*.html'], ['html']);
gulp.watch(['webadmin/src/views/*.html'], ['html-views']);
gulp.watch(['webadmin/src/js/update.js'], ['js-update']);
gulp.watch(['webadmin/src/js/error.js'], ['js-error']);
gulp.watch(['webadmin/src/js/setup.js', 'webadmin/src/js/client.js'], ['js-setup']);
gulp.watch(['webadmin/src/js/index.js', 'webadmin/src/js/client.js', 'webadmin/src/js/appstore.js', 'webadmin/src/js/main.js', 'webadmin/src/views/*.js'], ['js-index']);
gulp.watch(['webadmin/src/3rdparty/**/*'], ['3rdparty']);
});
gulp.task('clean', function () {
del.sync(['webadmin/dist', 'setup/splash/website']);
});
gulp.task('default', ['clean', 'html', 'js', '3rdparty', 'images', 'css'], function () {});
gulp.task('develop', ['watch'], serve({ root: 'webadmin/dist', port: 4000 }));

70
janitor.js Executable file
View File

@@ -0,0 +1,70 @@
#!/usr/bin/env node
'use strict';
require('supererror')({ splatchError: true });
var assert = require('assert'),
debug = require('debug')('box:janitor'),
async = require('async'),
tokendb = require('./src/tokendb.js'),
authcodedb = require('./src/authcodedb.js'),
database = require('./src/database.js');
var TOKEN_CLEANUP_INTERVAL = 30000;
function initialize(callback) {
assert.strictEqual(typeof callback, 'function');
async.series([
database.initialize
], callback);
}
function cleanupExpiredTokens(callback) {
assert.strictEqual(typeof callback, 'function');
tokendb.delExpired(function (error, result) {
if (error) return callback(error);
debug('Cleaned up %s expired tokens.', result);
callback(null);
});
}
function cleanupExpiredAuthCodes(callback) {
assert.strictEqual(typeof callback, 'function');
authcodedb.delExpired(function (error, result) {
if (error) return callback(error);
debug('Cleaned up %s expired authcodes.', result);
callback(null);
});
}
function run() {
cleanupExpiredTokens(function (error) {
if (error) console.error(error);
cleanupExpiredAuthCodes(function (error) {
if (error) console.error(error);
setTimeout(run, TOKEN_CLEANUP_INTERVAL);
});
});
}
if (require.main === module) {
initialize(function (error) {
if (error) {
console.error('janitor task exiting with error', error);
process.exit(1);
}
run();
});
}

View File

@@ -0,0 +1,14 @@
var dbm = require('db-migrate');
var type = dbm.dataType;
var url = require('url');
exports.up = function(db, callback) {
var dbName = url.parse(process.env.DATABASE_URL).path.substr(1); // remove slash
// by default, mysql collates case insensitively. 'utf8_general_cs' is not available
db.runSql('ALTER DATABASE ' + dbName + ' DEFAULT CHARACTER SET=utf8 DEFAULT COLLATE utf8_bin', callback);
};
exports.down = function(db, callback) {
callback();
};

View File

@@ -0,0 +1,19 @@
var dbm = require('db-migrate');
var type = dbm.dataType;
var fs = require('fs'),
async = require('async'),
path = require('path');
exports.up = function(db, callback) {
var schema = fs.readFileSync(path.join(__dirname, 'initial-schema.sql')).toString('utf8');
var statements = schema.split(';');
async.eachSeries(statements, function (statement, callback) {
if (statement.trim().length === 0) return callback(null);
db.runSql(statement, callback);
}, callback);
};
exports.down = function(db, callback) {
db.runSql('DROP TABLE users, tokens, clients, apps, appPortBindings, authcodes, settings', callback);
};

View File

@@ -0,0 +1,17 @@
dbm = dbm || require('db-migrate');
var type = dbm.dataType;
exports.up = function(db, callback) {
db.runSql('ALTER TABLE users ADD COLUMN resetToken VARCHAR(128) DEFAULT ""', function (error) {
if (error) console.error(error);
callback(error);
});
};
exports.down = function(db, callback) {
db.runSql('ALTER TABLE users DROP COLUMN resetToken', function (error) {
if (error) console.error(error);
callback(error);
});
};

View File

@@ -0,0 +1,20 @@
dbm = dbm || require('db-migrate');
var type = dbm.dataType;
exports.up = function(db, callback) {
db.runSql('DELETE FROM tokens', [], function (error) {
if (error) console.error(error);
db.runSql('ALTER TABLE tokens MODIFY expires BIGINT', [], function (error) {
if (error) console.error(error);
callback(error);
});
});
};
exports.down = function(db, callback) {
db.runSql('ALTER TABLE tokens MODIFY expires VARCHAR(512)', [], function (error) {
if (error) console.error(error);
callback(error);
});
};

View File

@@ -0,0 +1,16 @@
dbm = dbm || require('db-migrate');
var type = dbm.dataType;
exports.up = function(db, callback) {
db.runSql('ALTER TABLE authcodes ADD COLUMN expiresAt BIGINT NOT NULL', function (error) {
if (error) console.error(error);
callback(error);
});
};
exports.down = function(db, callback) {
db.runSql('ALTER TABLE authcodes DROP COLUMN expiresAt', function (error) {
if (error) console.error(error);
callback(error);
});
};

View File

@@ -0,0 +1,17 @@
dbm = dbm || require('db-migrate');
var type = dbm.dataType;
exports.up = function(db, callback) {
db.runSql('ALTER TABLE appPortBindings ADD COLUMN environmentVariable VARCHAR(128) NOT NULL', function (error) {
if (error) console.error(error);
callback(error);
});
};
exports.down = function(db, callback) {
db.runSql('ALTER TABLE appPortBindings DROP COLUMN environmentVariable', function (error) {
if (error) console.error(error);
callback(error);
});
};

View File

@@ -0,0 +1,17 @@
dbm = dbm || require('db-migrate');
var type = dbm.dataType;
exports.up = function(db, callback) {
db.runSql('ALTER TABLE appPortBindings DROP COLUMN containerPort', function (error) {
if (error) console.error(error);
callback(error);
});
};
exports.down = function(db, callback) {
db.runSql('ALTER TABLE appPortBindings ADD COLUMN containerPort VARCHAR(5) NOT NULL', function (error) {
if (error) console.error(error);
callback(error);
});
};

View File

@@ -0,0 +1,20 @@
dbm = dbm || require('db-migrate');
var type = dbm.dataType;
exports.up = function(db, callback) {
db.runSql('DELETE FROM tokens', [], function (error) {
if (error) console.error(error);
db.runSql('ALTER TABLE tokens CHANGE userId identifier VARCHAR(128) NOT NULL', [], function (error) {
if (error) console.error(error);
callback(error);
});
});
};
exports.down = function(db, callback) {
db.runSql('ALTER TABLE tokens CHANGE identifier userId VARCHAR(128) NOT NULL', [], function (error) {
if (error) console.error(error);
callback(error);
});
};

View File

@@ -0,0 +1,17 @@
dbm = dbm || require('db-migrate');
var type = dbm.dataType;
exports.up = function(db, callback) {
db.runSql('ALTER TABLE apps DROP COLUMN version', function (error) {
if (error) console.error(error);
callback(error);
});
};
exports.down = function(db, callback) {
db.runSql('ALTER TABLE apps ADD COLUMN version VARCHAR(32)', function (error) {
if (error) console.error(error);
callback(error);
});
};

View File

@@ -0,0 +1,16 @@
dbm = dbm || require('db-migrate');
var type = dbm.dataType;
exports.up = function(db, callback) {
db.runSql('ALTER TABLE apps DROP COLUMN healthy, ADD COLUMN health VARCHAR(128)', [], function (error) {
if (error) console.error(error);
callback(error);
});
};
exports.down = function(db, callback) {
db.runSql('ALTER TABLE apps DROP COLUMN health, ADD COLUMN healthy INTEGER', [], function (error) {
if (error) console.error(error);
callback(error);
});
};

View File

@@ -0,0 +1,17 @@
dbm = dbm || require('db-migrate');
var type = dbm.dataType;
exports.up = function(db, callback) {
db.runSql('ALTER TABLE apps ADD COLUMN lastBackupId VARCHAR(128)', function (error) {
if (error) console.error(error);
callback(error);
});
};
exports.down = function(db, callback) {
db.runSql('ALTER TABLE apps DROP COLUMN lastBackupId', function (error) {
if (error) console.error(error);
callback(error);
});
};

View File

@@ -0,0 +1,17 @@
dbm = dbm || require('db-migrate');
var type = dbm.dataType;
exports.up = function(db, callback) {
db.runSql('ALTER TABLE apps ADD COLUMN createdAt TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP', function (error) {
if (error) console.error(error);
callback(error);
});
};
exports.down = function(db, callback) {
db.runSql('ALTER TABLE apps DROP COLUMN createdAt', function (error) {
if (error) console.error(error);
callback(error);
});
};

View File

@@ -0,0 +1,12 @@
dbm = dbm || require('db-migrate');
var type = dbm.dataType;
exports.up = function(db, callback) {
// everyday at 1am
db.runSql('INSERT settings (name, value) VALUES("autoupdate_pattern", ?)', [ '00 00 1 * * *' ], callback);
};
exports.down = function(db, callback) {
db.runSql('DELETE * FROM settings WHERE name="autoupdate_pattern"', [ ], callback);
}

View File

@@ -0,0 +1,15 @@
dbm = dbm || require('db-migrate');
var safe = require('safetydance');
var type = dbm.dataType;
exports.up = function(db, callback) {
var tz = safe.fs.readFileSync('/etc/timezone', 'utf8');
tz = tz ? tz.trim() : 'America/Los_Angeles';
db.runSql('INSERT settings (name, value) VALUES("time_zone", ?)', [ tz ], callback);
};
exports.down = function(db, callback) {
db.runSql('DELETE * FROM settings WHERE name="time_zone"', [ ], callback);
};

View File

@@ -0,0 +1,24 @@
dbm = dbm || require('db-migrate');
var type = dbm.dataType;
var async = require('async');
exports.up = function(db, callback) {
// http://stackoverflow.com/questions/386294/what-is-the-maximum-length-of-a-valid-email-address
async.series([
db.runSql.bind(db, 'ALTER TABLE users MODIFY username VARCHAR(254)'),
db.runSql.bind(db, 'ALTER TABLE users ADD CONSTRAINT users_username UNIQUE (username)'),
db.runSql.bind(db, 'ALTER TABLE users MODIFY email VARCHAR(254)'),
db.runSql.bind(db, 'ALTER TABLE users ADD CONSTRAINT users_email UNIQUE (email)'),
], callback);
};
exports.down = function(db, callback) {
async.series([
db.runSql.bind(db, 'ALTER TABLE users DROP INDEX users_username'),
db.runSql.bind(db, 'ALTER TABLE users MODIFY username VARCHAR(512)'),
db.runSql.bind(db, 'ALTER TABLE users DROP INDEX users_email'),
db.runSql.bind(db, 'ALTER TABLE users MODIFY email VARCHAR(512)'),
], callback);
};

View File

@@ -0,0 +1,17 @@
dbm = dbm || require('db-migrate');
var type = dbm.dataType;
var async = require('async');
exports.up = function(db, callback) {
async.series([
db.runSql.bind(db, 'ALTER TABLE users MODIFY username VARCHAR(254) NOT NULL'),
db.runSql.bind(db, 'ALTER TABLE users MODIFY email VARCHAR(254) NOT NULL'),
], callback);
};
exports.down = function(db, callback) {
async.series([
db.runSql.bind(db, 'ALTER TABLE users MODIFY username VARCHAR(254)'),
db.runSql.bind(db, 'ALTER TABLE users MODIFY email VARCHAR(254)'),
], callback);
};

View File

@@ -0,0 +1,17 @@
dbm = dbm || require('db-migrate');
var type = dbm.dataType;
exports.up = function(db, callback) {
db.runSql('ALTER TABLE apps ADD COLUMN lastManifestJson VARCHAR(2048)', function (error) {
if (error) console.error(error);
callback(error);
});
};
exports.down = function(db, callback) {
db.runSql('ALTER TABLE apps DROP COLUMN lastManifestJson', function (error) {
if (error) console.error(error);
callback(error);
});
};

View File

@@ -0,0 +1,17 @@
var dbm = global.dbm || require('db-migrate');
var type = dbm.dataType;
exports.up = function(db, callback) {
db.runSql('ALTER TABLE apps CHANGE lastManifestJson lastBackupConfigJson VARCHAR(2048)', [], function (error) {
if (error) console.error(error);
callback(error);
});
};
exports.down = function(db, callback) {
db.runSql('ALTER TABLE apps CHANGE lastBackupConfigJson lastManifestJson VARCHAR(2048)', [], function (error) {
if (error) console.error(error);
callback(error);
});
};

View File

@@ -0,0 +1,17 @@
dbm = dbm || require('db-migrate');
var type = dbm.dataType;
exports.up = function(db, callback) {
db.runSql('ALTER TABLE apps ADD COLUMN oldConfigJson VARCHAR(2048)', function (error) {
if (error) console.error(error);
callback(error);
});
};
exports.down = function(db, callback) {
db.runSql('ALTER TABLE apps DROP COLUMN oldConfigJson', function (error) {
if (error) console.error(error);
callback(error);
});
};

View File

@@ -0,0 +1,10 @@
var dbm = global.dbm || require('db-migrate');
var type = dbm.dataType;
exports.up = function(db, callback) {
db.runSql('DELETE FROM settings', [ ], callback);
};
exports.down = function(db, callback) {
callback();
};

View File

@@ -0,0 +1,67 @@
CREATE TABLE IF NOT EXISTS users(
id VARCHAR(128) NOT NULL UNIQUE,
username VARCHAR(512) NOT NULL,
email VARCHAR(512) NOT NULL,
password VARCHAR(1024) NOT NULL,
salt VARCHAR(512) NOT NULL,
createdAt VARCHAR(512) NOT NULL,
modifiedAt VARCHAR(512) NOT NULL,
admin INTEGER NOT NULL,
PRIMARY KEY(id));
CREATE TABLE IF NOT EXISTS tokens(
accessToken VARCHAR(128) NOT NULL UNIQUE,
userId VARCHAR(128) NOT NULL,
clientId VARCHAR(128),
scope VARCHAR(512) NOT NULL,
expires VARCHAR(512) NOT NULL,
PRIMARY KEY(accessToken));
CREATE TABLE IF NOT EXISTS clients(
id VARCHAR(128) NOT NULL UNIQUE,
appId VARCHAR(128) NOT NULL,
clientSecret VARCHAR(512) NOT NULL,
redirectURI VARCHAR(512) NOT NULL,
scope VARCHAR(512) NOT NULL,
PRIMARY KEY(id));
CREATE TABLE IF NOT EXISTS apps(
id VARCHAR(128) NOT NULL UNIQUE,
appStoreId VARCHAR(128) NOT NULL,
version VARCHAR(32),
installationState VARCHAR(512) NOT NULL,
installationProgress VARCHAR(512),
runState VARCHAR(512),
healthy INTEGER,
containerId VARCHAR(128),
manifestJson VARCHAR(2048),
httpPort INTEGER,
location VARCHAR(128) NOT NULL UNIQUE,
dnsRecordId VARCHAR(512),
accessRestriction VARCHAR(512),
PRIMARY KEY(id));
CREATE TABLE IF NOT EXISTS appPortBindings(
hostPort INTEGER NOT NULL UNIQUE,
containerPort VARCHAR(5) NOT NULL,
appId VARCHAR(128) NOT NULL,
FOREIGN KEY(appId) REFERENCES apps(id),
PRIMARY KEY(hostPort));
CREATE TABLE IF NOT EXISTS authcodes(
authCode VARCHAR(128) NOT NULL UNIQUE,
userId VARCHAR(128) NOT NULL,
clientId VARCHAR(128) NOT NULL,
PRIMARY KEY(authCode));
CREATE TABLE IF NOT EXISTS settings(
name VARCHAR(128) NOT NULL UNIQUE,
value VARCHAR(512),
PRIMARY KEY(name));
CREATE TABLE IF NOT EXISTS appAddonConfigs(
appId VARCHAR(128) NOT NULL,
addonId VARCHAR(32) NOT NULL,
value VARCHAR(512) NOT NULL,
FOREIGN KEY(appId) REFERENCES apps(id));

82
migrations/schema.sql Normal file
View File

@@ -0,0 +1,82 @@
#### WARNING WARNING WARNING WARNING WARNING WARNING WARNING WARNING
#### This file is not used by any code and is here to document the latest schema
#### General ideas
#### Default char set is utf8 and DEFAULT COLLATE is utf8_bin. Collate affects comparisons in WHERE and ORDER
#### Strict mode is enabled
#### VARCHAR - stored as part of table row (use for strings)
#### TEXT - stored offline from table row (use for strings)
#### BLOB - stored offline from table row (use for binary data)
#### https://dev.mysql.com/doc/refman/5.0/en/storage-requirements.html
CREATE TABLE IF NOT EXISTS users(
id VARCHAR(128) NOT NULL UNIQUE,
username VARCHAR(254) NOT NULL UNIQUE,
email VARCHAR(254) NOT NULL UNIQUE,
password VARCHAR(1024) NOT NULL,
salt VARCHAR(512) NOT NULL,
createdAt VARCHAR(512) NOT NULL,
modifiedAt VARCHAR(512) NOT NULL,
admin INTEGER NOT NULL,
PRIMARY KEY(id));
CREATE TABLE IF NOT EXISTS tokens(
accessToken VARCHAR(128) NOT NULL UNIQUE,
identifier VARCHAR(128) NOT NULL,
clientId VARCHAR(128),
scope VARCHAR(512) NOT NULL,
expires BIGINT NOT NULL,
PRIMARY KEY(accessToken));
CREATE TABLE IF NOT EXISTS clients(
id VARCHAR(128) NOT NULL UNIQUE,
appId VARCHAR(128) NOT NULL,
clientSecret VARCHAR(512) NOT NULL,
redirectURI VARCHAR(512) NOT NULL,
scope VARCHAR(512) NOT NULL,
PRIMARY KEY(id));
CREATE TABLE IF NOT EXISTS apps(
id VARCHAR(128) NOT NULL UNIQUE,
appStoreId VARCHAR(128) NOT NULL,
installationState VARCHAR(512) NOT NULL,
installationProgress VARCHAR(512),
runState VARCHAR(512),
health VARCHAR(128),
containerId VARCHAR(128),
manifestJson VARCHAR(2048),
httpPort INTEGER, // this is the nginx proxy port and not manifest.httpPort
location VARCHAR(128) NOT NULL UNIQUE,
dnsRecordId VARCHAR(512),
accessRestriction VARCHAR(512),
createdAt TIMESTAMP(2) NOT NULL DEFAULT CURRENT_TIMESTAMP,
lastBackupId VARCHAR(128),
lastBackupConfigJson VARCHAR(2048), // used for appstore and non-appstore installs. it's here so it's easy to do REST validation
PRIMARY KEY(id));
CREATE TABLE IF NOT EXISTS appPortBindings(
hostPort INTEGER NOT NULL UNIQUE,
environmentVariable VARCHAR(128) NOT NULL,
appId VARCHAR(128) NOT NULL,
FOREIGN KEY(appId) REFERENCES apps(id),
PRIMARY KEY(hostPort));
CREATE TABLE IF NOT EXISTS authcodes(
authCode VARCHAR(128) NOT NULL UNIQUE,
userId VARCHAR(128) NOT NULL,
clientId VARCHAR(128) NOT NULL,
expiresAt BIGINT NOT NULL,
PRIMARY KEY(authCode));
CREATE TABLE IF NOT EXISTS settings(
name VARCHAR(128) NOT NULL UNIQUE,
value VARCHAR(512),
PRIMARY KEY(name));
CREATE TABLE IF NOT EXISTS appAddonConfigs(
appId VARCHAR(128) NOT NULL,
addonId VARCHAR(32) NOT NULL,
value VARCHAR(512) NOT NULL,
FOREIGN KEY(appId) REFERENCES apps(id));

2477
npm-shrinkwrap.json generated Normal file

File diff suppressed because it is too large Load Diff

185
oauthproxy.js Executable file
View File

@@ -0,0 +1,185 @@
#!/usr/bin/env node
'use strict';
require('supererror')({ splatchError: true });
var express = require('express'),
url = require('url'),
uuid = require('node-uuid'),
async = require('async'),
superagent = require('superagent'),
assert = require('assert'),
debug = require('debug')('box:proxy'),
proxy = require('proxy-middleware'),
session = require('cookie-session'),
database = require('./src/database.js'),
appdb = require('./src/appdb.js'),
clientdb = require('./src/clientdb.js'),
config = require('./src/config.js'),
http = require('http');
// Allow self signed certs!
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
var gSessions = {};
var gProxyMiddlewareCache = {};
var gApp = express();
var gHttpServer = http.createServer(gApp);
var CALLBACK_URI = '/callback';
var PORT = 4000;
function startServer(callback) {
assert.strictEqual(typeof callback, 'function');
gHttpServer.on('error', console.error);
gApp.use(session({
keys: ['blue', 'cheese', 'is', 'something']
}));
// ensure we have a in memory store for the session to cache client information
gApp.use(function (req, res, next) {
assert.strictEqual(typeof req.session, 'object');
if (!req.session.id || !gSessions[req.session.id]) {
req.session.id = uuid.v4();
gSessions[req.session.id] = {};
}
// attach the session data to the requeset
req.sessionData = gSessions[req.session.id];
next();
});
gApp.use(function verifySession(req, res, next) {
assert.strictEqual(typeof req.sessionData, 'object');
if (!req.sessionData.accessToken) {
req.authenticated = false;
return next();
}
superagent.get(config.adminOrigin() + '/api/v1/profile').query({ access_token: req.sessionData.accessToken}).end(function (error, result) {
if (error) {
console.error(error);
req.authenticated = false;
} else if (result.statusCode !== 200) {
req.sessionData.accessToken = null;
req.authenticated = false;
} else {
req.authenticated = true;
}
next();
});
});
gApp.use(function (req, res, next) {
// proceed if we are authenticated
if (req.authenticated) return next();
if (req.path === CALLBACK_URI && req.sessionData.returnTo) {
// exchange auth code for an access token
var query = {
response_type: 'token',
client_id: req.sessionData.clientId
};
var data = {
grant_type: 'authorization_code',
code: req.query.code,
redirect_uri: req.sessionData.returnTo,
client_id: req.sessionData.clientId,
client_secret: req.sessionData.clientSecret
};
superagent.post(config.adminOrigin() + '/api/v1/oauth/token').query(query).send(data).end(function (error, result) {
if (error) {
console.error(error);
return res.send(500, 'Unable to contact the oauth server.');
}
if (result.statusCode !== 200) {
console.error('Failed to exchange auth code for a token.', result.statusCode, result.body);
return res.send(500, 'Failed to exchange auth code for a token.');
}
req.sessionData.accessToken = result.body.access_token;
debug('user verified.');
// now redirect to the actual initially requested URL
res.redirect(req.sessionData.returnTo);
});
} else {
var port = parseInt(req.headers['x-cloudron-proxy-port'], 10);
if (!Number.isFinite(port)) {
console.error('Failed to parse nginx proxy header to get app port.');
return res.send(500, 'Routing error. No forwarded port.');
}
debug('begin verifying user for app on port %s.', port);
appdb.getByHttpPort(port, function (error, result) {
if (error) {
console.error('Unknown app.', error);
return res.send(500, 'Unknown app.');
}
clientdb.getByAppId('proxy-' + result.id, function (error, result) {
if (error) {
console.error('Unkonwn OAuth client.', error);
return res.send(500, 'Unknown OAuth client.');
}
req.sessionData.port = port;
req.sessionData.returnTo = result.redirectURI + req.path;
req.sessionData.clientId = result.id;
req.sessionData.clientSecret = result.clientSecret;
var callbackUrl = result.redirectURI + CALLBACK_URI;
var scope = 'profile,roleUser';
var oauthLogin = config.adminOrigin() + '/api/v1/oauth/dialog/authorize?response_type=code&client_id=' + result.id + '&redirect_uri=' + callbackUrl + '&scope=' + scope;
debug('begin OAuth flow for client %s.', result.name);
// begin the OAuth flow
res.redirect(oauthLogin);
});
});
}
});
gApp.use(function (req, res, next) {
var port = req.sessionData.port;
debug('proxy request for port %s with path %s.', port, req.path);
var proxyMiddleware = gProxyMiddlewareCache[port];
if (!proxyMiddleware) {
console.log('Adding proxy middleware for port %d', port);
proxyMiddleware = proxy(url.parse('http://127.0.0.1:' + port));
gProxyMiddlewareCache[port] = proxyMiddleware;
}
proxyMiddleware(req, res, next);
});
gHttpServer.listen(PORT, callback);
}
async.series([
database.initialize,
startServer
], function (error) {
if (error) {
console.error('Failed to start proxy server.', error);
process.exit(1);
}
console.log('Proxy server listening...');
});

103
package.json Normal file
View File

@@ -0,0 +1,103 @@
{
"name": "Cloudron",
"description": "Main code for a cloudron",
"version": "0.0.1",
"private": "true",
"author": {
"name": "Cloudron authors"
},
"repository": {
"type": "git"
},
"engines": [
"node >= 0.12.0"
],
"bin": {
"cloudron": "./app.js"
},
"dependencies": {
"async": "^1.2.1",
"body-parser": "^1.13.1",
"cloudron-manifestformat": "^1.4.0",
"connect-ensure-login": "^0.1.1",
"connect-lastmile": "0.0.12",
"connect-timeout": "^1.5.0",
"cookie-parser": "^1.3.5",
"cookie-session": "^1.1.0",
"cron": "^1.0.9",
"csurf": "^1.6.6",
"db-migrate": "^0.9.2",
"debug": "^2.2.0",
"dockerode": "^2.2.2",
"ejs": "^2.2.4",
"ejs-cli": "^1.0.1",
"express": "^4.12.4",
"express-session": "^1.11.3",
"hat": "0.0.3",
"json": "^9.0.3",
"ldapjs": "^0.7.1",
"memorystream": "^0.3.0",
"mime": "^1.3.4",
"morgan": "^1.6.0",
"multiparty": "^4.1.2",
"mysql": "^2.7.0",
"native-dns": "^0.7.0",
"node-uuid": "^1.4.3",
"nodejs-disks": "^0.2.1",
"nodemailer": "^1.3.0",
"nodemailer-smtp-transport": "^1.0.3",
"oauth2orize": "^1.0.1",
"once": "^1.3.2",
"passport": "^0.2.2",
"passport-http": "^0.2.2",
"passport-http-bearer": "^1.0.1",
"passport-local": "^1.0.0",
"passport-oauth2-client-password": "^0.1.2",
"password-generator": "^1.0.0",
"proxy-middleware": "^0.13.0",
"safetydance": "0.0.16",
"semver": "^4.3.6",
"serve-favicon": "^2.2.0",
"split": "^1.0.0",
"superagent": "~0.21.0",
"supererror": "^0.7.0",
"supervisord-eventlistener": "^0.1.0",
"tail-stream": "https://registry.npmjs.org/tail-stream/-/tail-stream-0.2.1.tgz",
"underscore": "^1.7.0",
"valid-url": "^1.0.9",
"validator": "^3.30.0"
},
"devDependencies": {
"apidoc": "*",
"aws-sdk": "^2.1.10",
"bootstrap-sass": "^3.3.3",
"del": "^1.1.1",
"expect.js": "*",
"gulp": "^3.8.11",
"gulp-autoprefixer": "^2.3.0",
"gulp-concat": "^2.4.3",
"gulp-ejs": "^1.0.0",
"gulp-minify-css": "^1.1.3",
"gulp-sass": "^2.0.1",
"gulp-serve": "^1.0.0",
"gulp-sourcemaps": "^1.5.2",
"gulp-uglify": "^1.1.0",
"hock": "~1.2.0",
"istanbul": "*",
"mocha": "*",
"nock": "^2.6.0",
"node-sass": "^3.0.0-alpha.0",
"redis": "^0.12.1",
"sinon": "^1.12.2",
"yargs": "^3.15.0"
},
"scripts": {
"migrate_local": "NODE_ENV=local DATABASE_URL=mysql://root:@localhost/box node_modules/.bin/db-migrate up",
"migrate_test": "NODE_ENV=test DATABASE_URL=mysql://root:@localhost/boxtest node_modules/.bin/db-migrate up",
"test": "npm run migrate_test && src/test/setupTest && NODE_ENV=test ./node_modules/istanbul/lib/cli.js test $1 ./node_modules/mocha/bin/_mocha -- -R spec ./src/test ./src/routes/test",
"postmerge": "/bin/true",
"precommit": "/bin/true",
"prepush": "npm test",
"webadmin": "node_modules/.bin/gulp"
}
}

57
setup/DESIGN.md Normal file
View File

@@ -0,0 +1,57 @@
This document gives the design of this setup code.
box code should be delivered in the form of a (docker) container.
This is not the case currently but we want to do structure the code
in spirit that way.
### container.sh
This contains code that essential goes into Dockerfile.
This file contains static configuration over a base image. Currently,
the yellowtent user is created in the installer base image but it
could very well be placed here.
The idea is that the installer would simply remove the old box container
and replace it with a new one for an update.
Because we do not package things as Docker yet, we should be careful
about the code here. We have to expect remains of an older setup code.
For example, older supervisor or nginx configs might be around.
The config directory is _part_ of the container and is not a VOLUME.
Which is to say that the files will be nuked from one update to the next.
The data directory is a VOLUME. Contents of this directory are expected
to survive an update. This is a good place to place config files that
are "dynamic" and need to survive restarts. For example, the infra
version (see below) or the mysql/postgresql data etc.
### start.sh
* It is called in 3 modes - new, update, restore.
* The first thing this does is to do the static container.sh setup.
* It then downloads any box restore data and restores the box db from the
backup.
* It then proceeds to call the db-migrate script.
* It then does dynamic configuration like setting up nginx, collectd.
* It then setups up the cloud infra (setup_infra.sh) and creates cloudron.conf.
* supervisor is then started
setup_infra.sh
This setups containers like graphite, mail and the addons containers.
Containers are relaunched based on the INFRA_VERSION. The script compares
the version here with the version in the file DATA_DIR/INFRA_VERSION.
If they match, the containers are not recreated and nothing is to be done.
nginx, collectd configs are part of data already and containers are running.
If they do not match, it deletes all containers (including app containers) and starts
them all afresh. Important thing here is that, DATA_DIR is never removed across
updates. So, it is only the containers being recreated and not the data.

6
setup/INFRA_VERSION Normal file
View File

@@ -0,0 +1,6 @@
#!/bin/bash
# If you change the infra version, be sure to put a warning
# in the change log
INFRA_VERSION=4

71
setup/argparser.sh Normal file
View File

@@ -0,0 +1,71 @@
#!/bin/bash
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
json="${script_dir}/../node_modules/.bin/json"
arg_restore_url=""
arg_restore_key=""
arg_box_versions_url=""
arg_tls_cert=""
arg_tls_key=""
arg_api_server_origin=""
arg_web_server_origin=""
arg_fqdn=""
arg_token=""
arg_version=""
arg_is_custom_domain="false"
arg_developer_mode=""
arg_retire="false"
arg_model=""
args=$(getopt -o "" -l "data:,retire" -n "$0" -- "$@")
eval set -- "${args}"
while true; do
case "$1" in
--retire)
arg_retire="true"
shift
;;
--data)
# only read mandatory non-empty parameters here
read -r arg_api_server_origin arg_web_server_origin arg_fqdn arg_token arg_is_custom_domain arg_box_versions_url arg_version <<EOF
$(echo "$2" | $json apiServerOrigin webServerOrigin fqdn token isCustomDomain boxVersionsUrl version | tr '\n' ' ')
EOF
# read possibly empty parameters here
arg_tls_cert=$(echo "$2" | $json tlsCert)
arg_tls_key=$(echo "$2" | $json tlsKey)
arg_developer_mode=$(echo "$2" | $json developerMode)
[[ "${arg_developer_mode}" == "" ]] && arg_developer_mode="false"
arg_restore_url=$(echo "$2" | $json restoreUrl)
[[ "${arg_restore_url}" == "null" ]] && arg_restore_url=""
arg_restore_key=$(echo "$2" | $json restoreKey)
[[ "${arg_restore_key}" == "null" ]] && arg_restore_key=""
arg_model=$(echo "$2" | $json model)
[[ "${arg_model}" == "null" ]] && arg_model=""
shift 2
;;
--) break;;
*) echo "Unknown option $1"; exit 1;;
esac
done
echo "Parsed arguments:"
echo "restore url: ${arg_restore_url}"
echo "restore key: ${arg_restore_key}"
echo "box versions url: ${arg_box_versions_url}"
echo "api server: ${arg_api_server_origin}"
echo "web server: ${arg_web_server_origin}"
echo "fqdn: ${arg_fqdn}"
echo "token: ${arg_token}"
echo "version: ${arg_version}"
echo "custom domain: ${arg_is_custom_domain}"
echo "tls cert: ${arg_tls_cert}"
echo "tls key: ${arg_tls_key}"
echo "developer mode: ${arg_developer_mode}"
echo "model: ${arg_model}"

39
setup/container.sh Executable file
View File

@@ -0,0 +1,39 @@
#!/bin/bash
set -eu -o pipefail
# This file can be used in Dockerfile
readonly container_files="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/container"
readonly CONFIG_DIR="/home/yellowtent/configs"
readonly DATA_DIR="/home/yellowtent/data"
########## create config directory
rm -rf "${CONFIG_DIR}"
sudo -u yellowtent mkdir "${CONFIG_DIR}"
########## logrotate (default ubuntu runs this daily)
rm -rf /etc/logrotate.d/*
cp -r "${container_files}/logrotate/." /etc/logrotate.d/
########## supervisor
rm -rf /etc/supervisor/*
cp -r "${container_files}/supervisor/." /etc/supervisor/
########## sudoers
rm /etc/sudoers.d/*
cp "${container_files}/sudoers" /etc/sudoers.d/yellowtent
########## collectd
rm -rf /etc/collectd
ln -sfF "${DATA_DIR}/collectd" /etc/collectd
########## nginx
# link nginx config to system config
unlink /etc/nginx 2>/dev/null || rm -rf /etc/nginx
ln -s "${DATA_DIR}/nginx" /etc/nginx
########## Enable services
update-rc.d -f collectd defaults

View File

@@ -0,0 +1,6 @@
/var/log/cloudron/*log {
missingok
notifempty
size 100k
nocompress
}

View File

@@ -0,0 +1,7 @@
/var/log/supervisor/*log {
missingok
copytruncate
notifempty
size 100k
nocompress
}

26
setup/container/sudoers Normal file
View File

@@ -0,0 +1,26 @@
Defaults!/home/yellowtent/box/src/scripts/createappdir.sh env_keep="HOME NODE_ENV"
yellowtent ALL=(root) NOPASSWD: /home/yellowtent/box/src/scripts/createappdir.sh
Defaults!/home/yellowtent/box/src/scripts/rmappdir.sh env_keep="HOME NODE_ENV"
yellowtent ALL=(root) NOPASSWD: /home/yellowtent/box/src/scripts/rmappdir.sh
Defaults!/home/yellowtent/box/src/scripts/reloadnginx.sh env_keep="HOME NODE_ENV"
yellowtent ALL=(root) NOPASSWD: /home/yellowtent/box/src/scripts/reloadnginx.sh
Defaults!/home/yellowtent/box/src/scripts/backupbox.sh env_keep="HOME NODE_ENV"
yellowtent ALL=(root) NOPASSWD: /home/yellowtent/box/src/scripts/backupbox.sh
Defaults!/home/yellowtent/box/src/scripts/backupapp.sh env_keep="HOME NODE_ENV"
yellowtent ALL=(root) NOPASSWD: /home/yellowtent/box/src/scripts/backupapp.sh
Defaults!/home/yellowtent/box/src/scripts/restoreapp.sh env_keep="HOME NODE_ENV"
yellowtent ALL=(root) NOPASSWD: /home/yellowtent/box/src/scripts/restoreapp.sh
Defaults!/home/yellowtent/box/src/scripts/reboot.sh env_keep="HOME NODE_ENV"
yellowtent ALL=(root) NOPASSWD: /home/yellowtent/box/src/scripts/reboot.sh
Defaults!/home/yellowtent/box/src/scripts/reloadcollectd.sh env_keep="HOME NODE_ENV"
yellowtent ALL=(root) NOPASSWD: /home/yellowtent/box/src/scripts/reloadcollectd.sh
Defaults!/home/yellowtent/box/src/scripts/backupswap.sh env_keep="HOME NODE_ENV"
yellowtent ALL=(root) NOPASSWD: /home/yellowtent/box/src/scripts/backupswap.sh

View File

@@ -0,0 +1,10 @@
[program:apphealthtask]
command=/usr/bin/node "/home/yellowtent/box/apphealthtask.js"
autostart=true
autorestart=true
redirect_stderr=true
stdout_logfile=/var/log/supervisor/apphealthtask.log
stdout_logfile_maxbytes=50MB
stdout_logfile_backups=2
user=yellowtent
environment=HOME="/home/yellowtent",USER="yellowtent",DEBUG="box*",NODE_ENV="cloudron"

View File

@@ -0,0 +1,10 @@
[program:box]
command=/usr/bin/node "/home/yellowtent/box/app.js"
autostart=true
autorestart=true
redirect_stderr=true
stdout_logfile=/var/log/supervisor/box.log
stdout_logfile_maxbytes=50MB
stdout_logfile_backups=2
user=yellowtent
environment=HOME="/home/yellowtent",USER="yellowtent",DEBUG="box*,connect-lastmile",NODE_ENV="cloudron"

View File

@@ -0,0 +1,11 @@
[eventlistener:crashnotifier]
command=/usr/bin/node "/home/yellowtent/box/crashnotifier.js"
events=PROCESS_STATE
autostart=true
autorestart=true
redirect_stderr=false
stderr_logfile=/var/log/supervisor/crashnotifier.log
stderr_logfile_maxbytes=50MB
stderr_logfile_backups=2
user=yellowtent
environment=HOME="/home/yellowtent",USER="yellowtent",NODE_ENV="cloudron"

View File

@@ -0,0 +1,10 @@
[program:janitor]
command=/usr/bin/node "/home/yellowtent/box/janitor.js"
autostart=true
autorestart=true
redirect_stderr=true
stdout_logfile=/var/log/supervisor/janitor.log
stdout_logfile_maxbytes=50MB
stdout_logfile_backups=2
user=yellowtent
environment=HOME="/home/yellowtent",USER="yellowtent",DEBUG="box*",NODE_ENV="cloudron"

View File

@@ -0,0 +1,10 @@
[program:oauthproxy]
command=/usr/bin/node "/home/yellowtent/box/oauthproxy.js"
autostart=true
autorestart=true
redirect_stderr=true
stdout_logfile=/var/log/supervisor/oauthproxy.log
stdout_logfile_maxbytes=50MB
stdout_logfile_backups=2
user=yellowtent
environment=HOME="/home/yellowtent",USER="yellowtent",DEBUG="box*",NODE_ENV="cloudron"

View File

@@ -0,0 +1,33 @@
; supervisor config file
; http://coffeeonthekeyboard.com/using-supervisorctl-with-linux-permissions-but-without-root-or-sudo-977/
[inet_http_server]
port = 127.0.0.1:9001
[supervisord]
logfile=/var/log/supervisor/supervisord.log ; (main log file;default $CWD/supervisord.log)
pidfile=/var/run/supervisord.pid ; (supervisord pidfile;default supervisord.pid)
logfile_maxbytes = 50MB
logfile_backups=10
loglevel = info
nodaemon = false
childlogdir = /var/log/supervisor/
; the below section must remain in the config file for RPC
; (supervisorctl/web interface) to work, additional interfaces may be
; added by defining them in separate rpcinterface: sections
[rpcinterface:supervisor]
supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface
[supervisorctl]
serverurl=http://127.0.0.1:9001
; The [include] section can just contain the "files" setting. This
; setting can list multiple files (separated by whitespace or
; newlines). It can also contain wildcards. The filenames are
; interpreted as relative to this file. Included files *cannot*
; include files themselves.
[include]
files = conf.d/*.conf

39
setup/splashpage.sh Executable file
View File

@@ -0,0 +1,39 @@
#!/bin/bash
set -eu -o pipefail
readonly SETUP_WEBSITE_DIR="/home/yellowtent/setup/website"
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
readonly BOX_SRC_DIR="/home/yellowtent/box"
readonly DATA_DIR="/home/yellowtent/data"
source "${script_dir}/INFRA_VERSION" # this injects INFRA_VERSION
echo "Setting up nginx update page"
source "${script_dir}/argparser.sh" "$@" # this injects the arg_* variables used below
# copy the website
rm -rf "${SETUP_WEBSITE_DIR}" && mkdir -p "${SETUP_WEBSITE_DIR}"
cp -r "${script_dir}/splash/website/"* "${SETUP_WEBSITE_DIR}"
# create nginx config
infra_version="none"
[[ -f "${DATA_DIR}/INFRA_VERSION" ]] && infra_version=$(cat "${DATA_DIR}/INFRA_VERSION")
if [[ "${arg_retire}" == "true" || "${infra_version}" != "${INFRA_VERSION}" ]]; then
rm -f ${DATA_DIR}/nginx/applications/*
${BOX_SRC_DIR}/node_modules/.bin/ejs-cli -f "${script_dir}/start/nginx/appconfig.ejs" \
-O "{ \"vhost\": \"~^(.+)\$\", \"endpoint\": \"splash\", \"sourceDir\": \"${SETUP_WEBSITE_DIR}\" }" > "${DATA_DIR}/nginx/applications/admin.conf"
else
# keep this is sync with config.js appFqdn()
readonly ADMIN_LOCATION="my" # keep this in sync with constants.js
admin_fqdn=$([[ "${arg_is_custom_domain}" == "true" ]] && echo "${ADMIN_LOCATION}.${arg_fqdn}" || echo "${ADMIN_LOCATION}-${arg_fqdn}")
${BOX_SRC_DIR}/node_modules/.bin/ejs-cli -f "${script_dir}/start/nginx/appconfig.ejs" \
-O "{ \"vhost\": \"${admin_fqdn}\", \"endpoint\": \"splash\", \"sourceDir\": \"${SETUP_WEBSITE_DIR}\" }" > "${DATA_DIR}/nginx/applications/admin.conf"
fi
echo '{ "update": { "percent": "10", "message": "Updating cloudron software" }, "backup": null }' > "${SETUP_WEBSITE_DIR}/progress.json"
nginx -s reload

179
setup/start.sh Executable file
View File

@@ -0,0 +1,179 @@
#!/bin/bash
set -eu -o pipefail
echo "==== Cloudron Start ===="
readonly USER="yellowtent"
readonly BOX_SRC_DIR="/home/${USER}/box"
readonly DATA_DIR="/home/${USER}/data"
readonly CONFIG_DIR="/home/${USER}/configs"
readonly SETUP_PROGRESS_JSON="/home/yellowtent/setup/website/progress.json"
readonly ADMIN_LOCATION="my" # keep this in sync with constants.js
readonly curl="curl --fail --connect-timeout 20 --retry 10 --retry-delay 2 --max-time 2400"
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "${script_dir}/argparser.sh" "$@" # this injects the arg_* variables used below
# keep this is sync with config.js appFqdn()
admin_fqdn=$([[ "${arg_is_custom_domain}" == "true" ]] && echo "${ADMIN_LOCATION}.${arg_fqdn}" || echo "${ADMIN_LOCATION}-${arg_fqdn}")
readonly is_update=$([[ -d "${DATA_DIR}/box" ]] && echo "true" || echo "false")
set_progress() {
local percent="$1"
local message="$2"
echo "==== ${percent} - ${message} ===="
(echo "{ \"update\": { \"percent\": \"${percent}\", \"message\": \"${message}\" }, \"backup\": {} }" > "${SETUP_PROGRESS_JSON}") 2> /dev/null || true # as this will fail in non-update mode
}
set_progress "1" "Create container"
$script_dir/container.sh
set_progress "10" "Ensuring directories"
# keep these in sync with paths.js
[[ "${is_update}" == "false" ]] && btrfs subvolume create "${DATA_DIR}/box"
mkdir -p "${DATA_DIR}/box/appicons"
mkdir -p "${DATA_DIR}/box/mail"
mkdir -p "${DATA_DIR}/graphite"
mkdir -p "${DATA_DIR}/snapshots"
mkdir -p "${DATA_DIR}/addons"
mkdir -p "${DATA_DIR}/collectd/collectd.conf.d"
# bookkeep the version as part of data
echo "{ \"version\": \"${arg_version}\", \"boxVersionsUrl\": \"${arg_box_versions_url}\" }" > "${DATA_DIR}/box/version"
# remove old snapshots. if we do want to keep this around, we will have to fix the chown -R below
# which currently fails because these are readonly fs
echo "Cleaning up snapshots"
find "${DATA_DIR}/snapshots" -mindepth 1 -maxdepth 1 | xargs --no-run-if-empty btrfs subvolume delete
readonly mysql_root_password="password"
mysqladmin -u root -ppassword password password # reset default root password
mysql -u root -p${mysql_root_password} -e 'CREATE DATABASE IF NOT EXISTS box'
if [[ -n "${arg_restore_url}" ]]; then
set_progress "15" "Downloading restore data"
echo "Downloading backup: ${arg_restore_url} and key: ${arg_restore_key}"
while true; do
if $curl -L "${arg_restore_url}" | openssl aes-256-cbc -d -pass "pass:${arg_restore_key}" | tar -zxf - -C "${DATA_DIR}/box"; then break; fi
echo "Failed to download data, trying again"
done
set_progress "21" "Setting up MySQL"
if [[ -f "${DATA_DIR}/box/box.mysqldump" ]]; then
echo "Importing existing database into MySQL"
mysql -u root -p${mysql_root_password} box < "${DATA_DIR}/box/box.mysqldump"
fi
fi
set_progress "25" "Migrating data"
sudo -u "${USER}" -H bash <<EOF
set -eu
cd "${BOX_SRC_DIR}"
NODE_ENV=cloudron DATABASE_URL=mysql://root:${mysql_root_password}@localhost/box "${BOX_SRC_DIR}/node_modules/.bin/db-migrate" up
EOF
set_progress "28" "Setup collectd"
cp "${script_dir}/start/collectd.conf" "${DATA_DIR}/collectd/collectd.conf"
service collectd restart
set_progress "30" "Setup nginx"
# setup naked domain to use admin by default. app restoration will overwrite this config
mkdir -p "${DATA_DIR}/nginx/applications"
cp "${script_dir}/start/nginx/mime.types" "${DATA_DIR}/nginx/mime.types"
# generate the main nginx config file
${BOX_SRC_DIR}/node_modules/.bin/ejs-cli -f "${script_dir}/start/nginx/nginx.ejs" \
-O "{ \"sourceDir\": \"${BOX_SRC_DIR}\" }" > "${DATA_DIR}/nginx/nginx.conf"
# generate these for update code paths as well to overwrite splash
${BOX_SRC_DIR}/node_modules/.bin/ejs-cli -f "${script_dir}/start/nginx/appconfig.ejs" \
-O "{ \"vhost\": \"${admin_fqdn}\", \"endpoint\": \"admin\", \"sourceDir\": \"${BOX_SRC_DIR}\" }" > "${DATA_DIR}/nginx/applications/admin.conf"
mkdir -p "${DATA_DIR}/nginx/cert"
echo "${arg_tls_cert}" > ${DATA_DIR}/nginx/cert/host.cert
echo "${arg_tls_key}" > ${DATA_DIR}/nginx/cert/host.key
set_progress "33" "Changing ownership"
chown "${USER}:${USER}" -R "${DATA_DIR}/box" "${DATA_DIR}/nginx" "${DATA_DIR}/collectd" "${DATA_DIR}/addons"
set_progress "40" "Setting up infra"
${script_dir}/start/setup_infra.sh "${arg_fqdn}"
set_progress "65" "Creating cloudron.conf"
admin_origin="https://${admin_fqdn}"
sudo -u yellowtent -H bash <<EOF
set -eu
echo "Creating cloudron.conf"
cat > "${CONFIG_DIR}/cloudron.conf" <<CONF_END
{
"version": "${arg_version}",
"token": "${arg_token}",
"apiServerOrigin": "${arg_api_server_origin}",
"webServerOrigin": "${arg_web_server_origin}",
"fqdn": "${arg_fqdn}",
"isCustomDomain": ${arg_is_custom_domain},
"boxVersionsUrl": "${arg_box_versions_url}",
"adminEmail": "admin@${arg_fqdn}",
"database": {
"hostname": "localhost",
"username": "root",
"password": "${mysql_root_password}",
"port": 3306,
"name": "box"
},
"model": "${arg_model}",
"developerMode": ${arg_developer_mode}
}
CONF_END
echo "Creating config.json for webadmin"
cat > "${BOX_SRC_DIR}/webadmin/dist/config.json" <<CONF_END
{
"webServerOrigin": "${arg_web_server_origin}"
}
CONF_END
EOF
# Add webadmin oauth client
# The domain might have changed, therefor we have to update the record
# !!! This needs to be in sync with the webadmin, specifically login_callback.js
echo "Add webadmin oauth cient"
ADMIN_SCOPES="root,developer,profile,users,apps,settings,roleUser"
mysql -u root -p${mysql_root_password} \
-e "REPLACE INTO clients (id, appId, clientSecret, redirectURI, scope) VALUES (\"cid-webadmin\", \"webadmin\", \"secret-webadmin\", \"${admin_origin}\", \"${ADMIN_SCOPES}\")" box
echo "Add localhost test oauth cient"
ADMIN_SCOPES="root,developer,profile,users,apps,settings,roleUser"
mysql -u root -p${mysql_root_password} \
-e "REPLACE INTO clients (id, appId, clientSecret, redirectURI, scope) VALUES (\"cid-test\", \"test\", \"secret-test\", \"http://127.0.0.1:5000\", \"${ADMIN_SCOPES}\")" box
set_progress "80" "Reloading supervisor"
# looks like restarting supervisor completely is the only way to reload it
service supervisor stop || true
echo -n "Waiting for supervisord to stop"
while test -e "/var/run/supervisord.pid" && kill -0 `cat /var/run/supervisord.pid`; do
echo -n "."
sleep 1
done
echo ""
echo "Starting supervisor"
service supervisor start
sleep 2 # give supervisor sometime to start the processes
set_progress "85" "Reloading nginx"
nginx -s reload
set_progress "100" "Done"

279
setup/start/collectd.conf Normal file
View File

@@ -0,0 +1,279 @@
# Config file for collectd(1).
#
# Some plugins need additional configuration and are disabled by default.
# Please read collectd.conf(5) for details.
#
# You should also read /usr/share/doc/collectd-core/README.Debian.plugins
# before enabling any more plugins.
##############################################################################
# Global #
#----------------------------------------------------------------------------#
# Global settings for the daemon. #
##############################################################################
Hostname "localhost"
#FQDNLookup true
#BaseDir "/var/lib/collectd"
#PluginDir "/usr/lib/collectd"
#TypesDB "/usr/share/collectd/types.db" "/etc/collectd/my_types.db"
#----------------------------------------------------------------------------#
# When enabled, plugins are loaded automatically with the default options #
# when an appropriate <Plugin ...> block is encountered. #
# Disabled by default. #
#----------------------------------------------------------------------------#
#AutoLoadPlugin false
#----------------------------------------------------------------------------#
# Interval at which to query values. This may be overwritten on a per-plugin #
# base by using the 'Interval' option of the LoadPlugin block: #
# <LoadPlugin foo> #
# Interval 60 #
# </LoadPlugin> #
#----------------------------------------------------------------------------#
# IMPORTANT: changing this value requires a change in whisper schema as well
Interval 20
#Timeout 2
#ReadThreads 5
#WriteThreads 5
# Limit the size of the write queue. Default is no limit. Setting up a limit
# is recommended for servers handling a high volume of traffic.
#WriteQueueLimitHigh 1000000
#WriteQueueLimitLow 800000
##############################################################################
# Logging #
#----------------------------------------------------------------------------#
# Plugins which provide logging functions should be loaded first, so log #
# messages generated when loading or configuring other plugins can be #
# accessed. #
##############################################################################
LoadPlugin logfile
#LoadPlugin syslog
<Plugin logfile>
LogLevel "info"
File "/var/log/collectd.log"
Timestamp true
PrintSeverity false
</Plugin>
#<Plugin syslog>
# LogLevel info
#</Plugin>
##############################################################################
# LoadPlugin section #
#----------------------------------------------------------------------------#
# Specify what features to activate. #
##############################################################################
LoadPlugin aggregation
#LoadPlugin amqp
#LoadPlugin apache
#LoadPlugin apcups
#LoadPlugin ascent
#LoadPlugin battery
#LoadPlugin bind
#LoadPlugin cgroups
#LoadPlugin conntrack
#LoadPlugin contextswitch
LoadPlugin cpu
#LoadPlugin cpufreq
#LoadPlugin csv
#LoadPlugin curl
#LoadPlugin curl_json
#LoadPlugin curl_xml
#LoadPlugin dbi
LoadPlugin df
#LoadPlugin disk
#LoadPlugin dns
#LoadPlugin email
#LoadPlugin entropy
#LoadPlugin ethstat
#LoadPlugin exec
#LoadPlugin filecount
#LoadPlugin fscache
#LoadPlugin gmond
#LoadPlugin hddtemp
LoadPlugin interface
#LoadPlugin ipmi
#LoadPlugin iptables
#LoadPlugin ipvs
#LoadPlugin irq
#LoadPlugin java
#LoadPlugin libvirt
LoadPlugin load
#LoadPlugin lvm
#LoadPlugin madwifi
#LoadPlugin mbmon
#LoadPlugin md
#LoadPlugin memcachec
#LoadPlugin memcached
LoadPlugin memory
#LoadPlugin modbus
#LoadPlugin multimeter
#LoadPlugin mysql
#LoadPlugin netlink
#LoadPlugin network
#LoadPlugin nfs
LoadPlugin nginx
#LoadPlugin notify_desktop
#LoadPlugin notify_email
#LoadPlugin ntpd
#LoadPlugin numa
#LoadPlugin nut
#LoadPlugin olsrd
#LoadPlugin openvpn
#<LoadPlugin perl>
# Globals true
#</LoadPlugin>
#LoadPlugin pinba
LoadPlugin ping
#LoadPlugin postgresql
#LoadPlugin powerdns
LoadPlugin processes
#LoadPlugin protocols
#<LoadPlugin python>
# Globals true
#</LoadPlugin>
#LoadPlugin rrdcached
#LoadPlugin rrdtool
#LoadPlugin sensors
#LoadPlugin serial
#LoadPlugin snmp
#LoadPlugin statsd
LoadPlugin swap
#LoadPlugin table
LoadPlugin tail
#LoadPlugin tail_csv
#LoadPlugin tcpconns
#LoadPlugin teamspeak2
#LoadPlugin ted
#LoadPlugin thermal
#LoadPlugin tokyotyrant
#LoadPlugin unixsock
#LoadPlugin uptime
#LoadPlugin users
#LoadPlugin uuid
#LoadPlugin varnish
LoadPlugin vmem
#LoadPlugin vserver
#LoadPlugin wireless
LoadPlugin write_graphite
#LoadPlugin write_http
#LoadPlugin write_riemann
##############################################################################
# Plugin configuration #
#----------------------------------------------------------------------------#
# In this section configuration stubs for each plugin are provided. A desc- #
# ription of those options is available in the collectd.conf(5) manual page. #
##############################################################################
<Plugin "aggregation">
<Aggregation>
Plugin "cpu"
Type "cpu"
GroupBy "Host"
GroupBy "TypeInstance"
CalculateNum false
CalculateSum true
CalculateAverage true
CalculateMinimum false
CalculateMaximum true
CalculateStddev false
</Aggregation>
</Plugin>
<Plugin df>
Device "/dev/vda1"
Device "/dev/loop0"
Device "/dev/loop1"
ReportByDevice true
IgnoreSelected false
ValuesAbsolute true
ValuesPercentage true
</Plugin>
<Plugin interface>
Interface "eth0"
IgnoreSelected false
</Plugin>
<Plugin nginx>
URL "http://127.0.0.1/nginx_status"
</Plugin>
<Plugin ping>
Host "google.com"
Interval 1.0
Timeout 0.9
TTL 255
</Plugin>
<Plugin processes>
ProcessMatch "app" "node app.js"
</Plugin>
<Plugin swap>
ReportByDevice false
ReportBytes true
</Plugin>
<Plugin "tail">
<File "/var/log/nginx/error.log">
Instance "nginx"
<Match>
Regex ".*"
DSType "CounterInc"
Type counter
Instance "errors"
</Match>
</File>
<File "/var/log/nginx/access.log">
Instance "nginx"
<Match>
Regex ".*"
DSType "CounterInc"
Type counter
Instance "requests"
</Match>
<Match>
Regex " \".*\" [0-9]+ [0-9]+ ([0-9]+)"
DSType GaugeAverage
Type delay
Instance "response"
</Match>
</File>
</Plugin>
<Plugin vmem>
Verbose false
</Plugin>
<Plugin write_graphite>
<Node "graphing">
Host "localhost"
Port "2003"
Protocol "tcp"
LogSendErrors true
Prefix "collectd."
StoreRates true
AlwaysAppendDS false
EscapeCharacter "_"
</Node>
</Plugin>
<Include "/etc/collectd/collectd.conf.d">
Filter "*.conf"
</Include>

View File

@@ -0,0 +1,115 @@
# http://nginx.org/en/docs/http/websocket.html
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 443;
server_name <%= vhost %>;
ssl on;
# paths are relative to prefix and not to this file
ssl_certificate cert/host.cert;
ssl_certificate_key cert/host.key;
ssl_session_timeout 5m;
ssl_session_cache shared:SSL:50m;
# https://bettercrypto.org/static/applied-crypto-hardening.pdf
# https://mozilla.github.io/server-side-tls/ssl-config-generator/
# https://cipherli.st/
ssl_prefer_server_ciphers on;
ssl_protocols TLSv1 TLSv1.1 TLSv1.2; # don't use SSLv3 ref: POODLE
ssl_ciphers 'AES128+EECDH:AES128+EDH';
add_header Strict-Transport-Security "max-age=15768000; includeSubDomains";
proxy_http_version 1.1;
proxy_intercept_errors on;
proxy_read_timeout 3500;
proxy_connect_timeout 3250;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto https;
# upgrade is a hop-by-hop header (http://nginx.org/en/docs/http/websocket.html)
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
error_page 500 502 503 504 =200 @appstatus;
location @appstatus {
internal;
root <%= sourceDir %>/webadmin/dist;
rewrite ^/$ /appstatus.html break;
}
location / {
# increase the proxy buffer sizes to not run into buffer issues (http://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_buffers)
proxy_buffer_size 128k;
proxy_buffers 4 256k;
proxy_busy_buffers_size 256k;
# Disable check to allow unlimited body sizes
client_max_body_size 0;
<% if ( endpoint === 'admin' ) { %>
location /api/ {
proxy_pass http://127.0.0.1:3000;
client_max_body_size 1m;
}
# graphite paths
location ~ ^/(graphite|content|metrics|dashboard|render|browser|composer)/ {
proxy_pass http://127.0.0.1:8000;
client_max_body_size 1m;
}
location / {
root <%= sourceDir %>/webadmin/dist;
index index.html index.htm;
}
<% } else if ( endpoint === 'oauthproxy' ) { %>
proxy_pass http://127.0.0.1:4000;
proxy_set_header X-Cloudron-Proxy-Port <%= port %>;
<% } else if ( endpoint === 'app' ) { %>
proxy_pass http://127.0.0.1:<%= port %>;
<% } else if ( endpoint === 'splash' ) { %>
root <%= sourceDir %>;
error_page 503 /update.html;
location /update.html {
add_header Cache-Control no-cache;
}
location /theme.css {
add_header Cache-Control no-cache;
}
location /3rdparty/ {
add_header Cache-Control no-cache;
}
location /js/ {
add_header Cache-Control no-cache;
}
location /progress.json {
add_header Cache-Control no-cache;
}
location /api/v1/cloudron/progress {
add_header Cache-Control no-cache;
default_type application/json;
alias <%= sourceDir %>/progress.json;
}
location / {
return 503;
}
<% } %>
}
}

View File

@@ -0,0 +1,80 @@
types {
text/html html htm shtml;
text/css css;
text/xml xml;
image/gif gif;
image/jpeg jpeg jpg;
application/x-javascript js;
application/atom+xml atom;
application/rss+xml rss;
text/mathml mml;
text/plain txt;
text/vnd.sun.j2me.app-descriptor jad;
text/vnd.wap.wml wml;
text/x-component htc;
image/png png;
image/tiff tif tiff;
image/vnd.wap.wbmp wbmp;
image/x-icon ico;
image/x-jng jng;
image/x-ms-bmp bmp;
image/svg+xml svg svgz;
image/webp webp;
application/java-archive jar war ear;
application/mac-binhex40 hqx;
application/msword doc;
application/pdf pdf;
application/postscript ps eps ai;
application/rtf rtf;
application/vnd.ms-excel xls;
application/vnd.ms-powerpoint ppt;
application/vnd.wap.wmlc wmlc;
application/vnd.google-earth.kml+xml kml;
application/vnd.google-earth.kmz kmz;
application/x-7z-compressed 7z;
application/x-cocoa cco;
application/x-java-archive-diff jardiff;
application/x-java-jnlp-file jnlp;
application/x-makeself run;
application/x-perl pl pm;
application/x-pilot prc pdb;
application/x-rar-compressed rar;
application/x-redhat-package-manager rpm;
application/x-sea sea;
application/x-shockwave-flash swf;
application/x-stuffit sit;
application/x-tcl tcl tk;
application/x-x509-ca-cert der pem crt;
application/x-xpinstall xpi;
application/xhtml+xml xhtml;
application/zip zip;
application/octet-stream bin exe dll;
application/octet-stream deb;
application/octet-stream dmg;
application/octet-stream eot;
application/octet-stream iso img;
application/octet-stream msi msp msm;
audio/midi mid midi kar;
audio/mpeg mp3;
audio/ogg ogg;
audio/x-m4a m4a;
audio/x-realaudio ra;
video/3gpp 3gpp 3gp;
video/mp4 mp4;
video/mpeg mpeg mpg;
video/quicktime mov;
video/webm webm;
video/x-flv flv;
video/x-m4v m4v;
video/x-mng mng;
video/x-ms-asf asx asf;
video/x-ms-wmv wmv;
video/x-msvideo avi;
}

View File

@@ -0,0 +1,64 @@
user www-data;
worker_processes 1;
pid /run/nginx.pid;
events {
worker_connections 1024;
}
http {
include mime.types;
default_type application/octet-stream;
# the collectd config depends on this log format
log_format combined2 '$remote_addr - [$time_local] '
'"$request" $status $body_bytes_sent $request_time '
'"$http_referer" "$http_user_agent"';
access_log access.log combined2;
sendfile on;
keepalive_timeout 65;
# HTTP server
server {
listen 80;
# collectd
location /nginx_status {
stub_status on;
access_log off;
allow 127.0.0.1;
deny all;
}
location / {
# redirect everything to HTTPS
return 301 https://$host$request_uri;
}
}
# We have to enable https for nginx to read in the vhost in http request
# and send a 404. This is a side-effect of using wildcard DNS
server {
listen 443 default_server;
ssl on;
ssl_certificate cert/host.cert;
ssl_certificate_key cert/host.key;
error_page 404 = @fallback;
location @fallback {
internal;
root <%= sourceDir %>/webadmin/dist;
rewrite ^/$ /nakeddomain.html break;
}
return 404;
}
include applications/*.conf;
}

93
setup/start/setup_infra.sh Executable file
View File

@@ -0,0 +1,93 @@
#!/bin/bash
set -eu -o pipefail
readonly DATA_DIR="/home/yellowtent/data"
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "${script_dir}/../INFRA_VERSION" # this injects INFRA_VERSION
arg_fqdn="$1"
# removing containers ensures containers are launched with latest config updates
# restore code in appatask does not delete old containers
infra_version="none"
[[ -f "${DATA_DIR}/INFRA_VERSION" ]] && infra_version=$(cat "${DATA_DIR}/INFRA_VERSION")
if [[ "${infra_version}" == "${INFRA_VERSION}" ]]; then
echo "Infrastructure is upto date"
exit 0
fi
echo "Upgrading infrastructure from ${infra_version} to ${INFRA_VERSION}"
existing_containers=$(docker ps -qa)
echo "Remove containers: ${existing_containers}"
if [[ -n "${existing_containers}" ]]; then
echo "${existing_containers}" | xargs docker rm -f
fi
# graphite
docker run --restart=always -d --name="graphite" \
-p 127.0.0.1:2003:2003 \
-p 127.0.0.1:2004:2004 \
-p 127.0.0.1:8000:8000 \
-v "${DATA_DIR}/graphite:/app/data" cloudron/graphite:0.3.1
# mail
mail_container_id=$(docker run --restart=always -d --name="mail" \
-p 127.0.0.1:25:25 \
-h "${arg_fqdn}" \
-e "DOMAIN_NAME=${arg_fqdn}" \
-v "${DATA_DIR}/box/mail:/app/data" \
cloudron/mail:0.3.0)
echo "Mail container id: ${mail_container_id}"
# mysql
mysql_addon_root_password=$(pwgen -1 -s)
docker0_ip=$(/sbin/ifconfig docker0 | grep "inet addr" | awk -F: '{print $2}' | awk '{print $1}')
cat > "${DATA_DIR}/addons/mysql_vars.sh" <<EOF
readonly MYSQL_ROOT_PASSWORD='${mysql_addon_root_password}'
readonly MYSQL_ROOT_HOST='${docker0_ip}'
EOF
mysql_container_id=$(docker run --restart=always -d --name="mysql" \
-h "${arg_fqdn}" \
-v "${DATA_DIR}/mysql:/var/lib/mysql" \
-v "${DATA_DIR}/addons/mysql_vars.sh:/etc/mysql/mysql_vars.sh:r" \
cloudron/mysql:0.3.0)
echo "MySQL container id: ${mysql_container_id}"
# postgresql
postgresql_addon_root_password=$(pwgen -1 -s)
cat > "${DATA_DIR}/addons/postgresql_vars.sh" <<EOF
readonly POSTGRESQL_ROOT_PASSWORD='${postgresql_addon_root_password}'
EOF
postgresql_container_id=$(docker run --restart=always -d --name="postgresql" \
-h "${arg_fqdn}" \
-v "${DATA_DIR}/postgresql:/var/lib/postgresql" \
-v "${DATA_DIR}/addons/postgresql_vars.sh:/etc/postgresql/postgresql_vars.sh:r" \
cloudron/postgresql:0.3.0)
echo "PostgreSQL container id: ${postgresql_container_id}"
# mongodb
mongodb_addon_root_password=$(pwgen -1 -s)
cat > "${DATA_DIR}/addons/mongodb_vars.sh" <<EOF
readonly MONGODB_ROOT_PASSWORD='${mongodb_addon_root_password}'
EOF
mongodb_container_id=$(docker run --restart=always -d --name="mongodb" \
-h "${arg_fqdn}" \
-v "${DATA_DIR}/mongodb:/var/lib/mongodb" \
-v "${DATA_DIR}/addons/mongodb_vars.sh:/etc/mongodb_vars.sh:r" \
cloudron/mongodb:0.3.0)
echo "Mongodb container id: ${mongodb_container_id}"
if [[ "${infra_version}" == "none" ]]; then
# if no existing infra was found (for new and restoring cloudons), download app backups
echo "Marking installed apps for restore"
mysql -u root -ppassword -e 'UPDATE apps SET installationState = "pending_restore" WHERE installationState = "installed"' box
else
# if existing infra was found, just mark apps for reconfiguration
mysql -u root -ppassword -e 'UPDATE apps SET installationState = "pending_configure" WHERE installationState = "installed"' box
fi
echo -n "${INFRA_VERSION}" > "${DATA_DIR}/INFRA_VERSION"

15
setup/stop.sh Executable file
View File

@@ -0,0 +1,15 @@
#!/bin/bash
set -eu -o pipefail
echo "Stopping box code"
service supervisor stop || true
echo -n "Waiting for supervisord to stop"
while test -e "/var/run/supervisord.pid" && kill -0 `cat /var/run/supervisord.pid`; do
echo -n "."
sleep 1
done
echo ""

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 %>

Some files were not shown because too many files have changed in this diff Show More