Compare commits

..

119 Commits

Author SHA1 Message Date
Girish Ramakrishnan 2a39526a4c Remove old app ids from updatechecker state
Fixes #472
2015-09-22 22:46:14 -07:00
Girish Ramakrishnan ded5d4c98b debug message when notification is skipped 2015-09-22 22:41:42 -07:00
Girish Ramakrishnan a0ca59c3f2 Fix typo 2015-09-22 20:22:17 -07:00
Girish Ramakrishnan 53cfc49807 Save version instead of boolean so we get notified when version changes
part of #472
2015-09-22 16:11:15 -07:00
Girish Ramakrishnan 942eb579e4 save/restore notification state of updatechecker
part of #472
2015-09-22 16:11:04 -07:00
Girish Ramakrishnan 5819cfe412 Fix progress message 2015-09-22 13:02:09 -07:00
Johannes Zellner 5cb62ca412 Remove start/stop buttons in webadmin
Fixes #495
2015-09-22 22:00:42 +02:00
Johannes Zellner df10c245de app.js is no more 2015-09-22 22:00:42 +02:00
Girish Ramakrishnan 4a804dc52b Do a complete backup for updates
The backup cron job ensures backups every 4 hours which simply does
a 'box' backup listing. If we do only a 'box' backup during update,
this means that this cron job skips doing a backup and thus the apps
are not backed up.

This results in the janitor on the CaaS side complaining that the
app backups are too old.

Since we don't stop apps anymore during updates, it makes sense
to simply backup everything for updates as well. This is probably
what the user wants anyway.
2015-09-22 12:51:58 -07:00
Girish Ramakrishnan ed2f25a998 better debugs 2015-09-21 16:02:58 -07:00
Girish Ramakrishnan 7510c9fe29 Fix typo 2015-09-21 15:57:06 -07:00
Girish Ramakrishnan 78a1d53728 copy old backup as failed/errored apps
This ensures that
a) we don't get emails from janitor about bad app backups
b) that the backups are persisted over the s3 lifecycle

Fixes #493
2015-09-21 15:03:10 -07:00
Girish Ramakrishnan e9b078cd58 add backups.copyLastBackup 2015-09-21 14:14:43 -07:00
Girish Ramakrishnan dd8b928684 aws: add copyObject 2015-09-21 14:02:00 -07:00
Girish Ramakrishnan 185b574bdc Add custom apparmor profile for cloudron apps
Docker generates an apparmor profile on the fly under /etc/apparmor.d/docker.
This profile gets overwritten on every docker daemon start.

This profile allows processes to ptrace themselves. This is required by
circus (python process manager) for reasons unknown to me. It floods the logs
with
    audit[7623]: <audit-1400> apparmor="DENIED" operation="ptrace" profile="docker-default" pid=7623 comm="python3.4" requested_mask="trace" denied_mask="trace" peer="docker-default"

This is easily tested using:
    docker run -it cloudron/base:0.3.3 /bin/bash
        a) now do ps
        b) journalctl should show error log as above

    docker run --security-opt=apparmor:docker-cloudron-app -it cloudron/base:0.3.3 /bin/bash
        a) now do ps
        b) no error!

Note that despite this, the process may not have ability to ptrace since it does not
have CAP_PTRACE. Also, security-opt is the profile name (inside the apparmor config file)
and not the filename.

References:
    https://groups.google.com/forum/#!topic/docker-user/xvxpaceTCyw
    https://github.com/docker/docker/issues/7276
    https://bugs.launchpad.net/ubuntu/+source/docker.io/+bug/1320869

This is an infra update because we need to recreate containers to get the right profile.

Fixes #492
2015-09-21 11:01:44 -07:00
Girish Ramakrishnan a89726a8c6 Add custom debug.formatArgs to remove timestamp prefix in logs
Fixes #490

See also:
https://github.com/visionmedia/debug/issues/216
2015-09-21 09:05:14 -07:00
Girish Ramakrishnan c80aca27e6 remove unnecessary supererror call 2015-09-21 09:04:16 -07:00
Girish Ramakrishnan 029acab333 use correct timezone in updater
fixes #491
2015-09-18 14:46:44 -07:00
Girish Ramakrishnan 4f9f10e130 timezone detection is based on browser location/ip and not cloudron region intentionally 2015-09-18 13:40:22 -07:00
Girish Ramakrishnan 9ba11d2e14 print body on failure 2015-09-18 12:03:48 -07:00
Girish Ramakrishnan 23a5a1f79f timezone is already determined automatically using activation 2015-09-18 12:02:36 -07:00
Girish Ramakrishnan e8dc617d40 print tz for debugging 2015-09-18 10:51:52 -07:00
Girish Ramakrishnan d56794e846 clear backup progress when initiating backup
this ensures that tools can do:
1. backup
2. wait_for_backup

without the synchronous clear, we might get the progress state of
an earlier backup.
2015-09-17 21:17:59 -07:00
Girish Ramakrishnan 2663ec7da0 cloudron.backup does not wait for backup to complete 2015-09-17 16:35:59 -07:00
Girish Ramakrishnan eec4ae98cd add comment for purpose on internal server 2015-09-17 16:27:46 -07:00
Girish Ramakrishnan c31a0f4e09 Store dates as iso strings in database
ideally, the database schema should be TIMESTAMP
2015-09-17 13:51:55 -07:00
Girish Ramakrishnan 739db23514 Use the default timezone in settings
Fixes #485
2015-09-16 16:36:08 -07:00
Girish Ramakrishnan 8598fb444b store timezone in config.js (part of provision data) 2015-09-16 15:54:56 -07:00
Girish Ramakrishnan 0b630ff504 Remove debug that is flooding the logs 2015-09-16 10:50:15 -07:00
Girish Ramakrishnan 84169dea3d Do not set process.env.NODE_TLS_REJECT_UNAUTHORIZED
Doing so will affect all https requests which is dangerous.

We have these options to solve this:
1. Use superagent.ca(). Appstore already provides wildcard certs
   for dev, staging signed with appstore_ca. But we then need to
   send across the appstore_ca cert across in the provision call.
   This is a bit of work.

2. Convert superagent into https.request calls and use the
   rejectUnauthorized option.

3. Simply use http. This is what is done in this commit.

Fixes #488
2015-09-16 10:36:03 -07:00
Girish Ramakrishnan d83b5de47a reserve the ldap and oauthproxy port 2015-09-16 10:12:59 -07:00
Girish Ramakrishnan 2719c4240f Get oauth proxy port from the configs 2015-09-16 10:06:34 -07:00
Johannes Zellner d749756b53 Do not show the update action button in non mobile view 2015-09-16 09:36:46 +02:00
Johannes Zellner 0401c61c15 Add tooltip text for the app action icons 2015-09-16 09:36:22 +02:00
Johannes Zellner 34f45da2de Show indicator when app update is available
Fixes #489
2015-09-16 09:28:43 +02:00
Girish Ramakrishnan baecbf783c journalctl seems to barf on this debug 2015-09-15 20:50:22 -07:00
Girish Ramakrishnan 2f141cd6e0 Make the times absurdly high but that is how long in takes 2015-09-15 18:56:25 -07:00
Girish Ramakrishnan 1296299d02 error is undefined 2015-09-15 18:27:09 -07:00
Girish Ramakrishnan 998ac74d32 oldConfig.location can be null
If we had an update, location is not part of oldConfig. if we now do
an infra update, location is undefined.
2015-09-15 18:08:29 -07:00
Girish Ramakrishnan b4a34e6432 Explicity debug the fields
for some reason, journalctl barfs on this line
2015-09-15 14:55:20 -07:00
Girish Ramakrishnan e70c9d55db apptask: retry for external error as well 2015-09-14 21:45:27 -07:00
Girish Ramakrishnan 268aee6265 Return busy code for 420 response 2015-09-14 21:44:44 -07:00
Girish Ramakrishnan 1ba7b0e0fb context is raw text 2015-09-14 17:25:27 -07:00
Girish Ramakrishnan 72788fdb11 add note on how to test the oom 2015-09-14 17:20:30 -07:00
Girish Ramakrishnan 435afec13c Print OOM context 2015-09-14 17:18:11 -07:00
Girish Ramakrishnan 2cb1877669 Do not reconnect for now 2015-09-14 17:10:49 -07:00
Girish Ramakrishnan edd672cba7 fix typo 2015-09-14 17:07:44 -07:00
Girish Ramakrishnan 991f37fe05 Provide app information if possible 2015-09-14 17:06:04 -07:00
Girish Ramakrishnan c147d8004b Add appdb.getByContainerId 2015-09-14 17:01:04 -07:00
Girish Ramakrishnan cdcc4dfda8 Get notification on app oom
currently, oom events arrive a little late :
https://github.com/docker/docker/issues/16074

fixes #489
2015-09-14 16:51:32 -07:00
Girish Ramakrishnan 2eaba686fb apphealthmonitor.js is not executable 2015-09-14 16:51:32 -07:00
Girish Ramakrishnan 236032b4a6 Remove supererror setup in oauthproxy and apphealthmonitor 2015-09-14 16:49:10 -07:00
Girish Ramakrishnan 5fcba59b3e set memory limits for addons
mysql, postgresql, mongodb - 100m each
mail, graphite, redis (each instance) - 75m

For reference, in yellowtent:
mongo - 5m
postgresql - 33m
mysql - 3.5m
mail: 26m
graphite - 26m
redis - 32m
2015-09-14 13:47:45 -07:00
Girish Ramakrishnan 6efd8fddeb fix require paths 2015-09-14 13:00:03 -07:00
Girish Ramakrishnan 8aff2b9e74 remove oauthproxy systemd configs 2015-09-14 12:02:38 -07:00
Girish Ramakrishnan fbae432b98 merge oauthproxy server into box server 2015-09-14 11:58:28 -07:00
Girish Ramakrishnan 9cad7773ff refactor code to prepare for merge into box server 2015-09-14 11:28:49 -07:00
Girish Ramakrishnan 4adf122486 oauthproxy: refactor for readability 2015-09-14 11:22:33 -07:00
Girish Ramakrishnan ea47c26d3f apphealthmonitor is not a executable anymore 2015-09-14 11:09:58 -07:00
Girish Ramakrishnan f57aae9545 Fix typo in assert 2015-09-14 11:09:41 -07:00
Girish Ramakrishnan cdeb830706 Add apphealthmonitor.stop 2015-09-14 11:02:06 -07:00
Girish Ramakrishnan 0c9618f19a Add ldap.stop 2015-09-14 11:01:35 -07:00
Girish Ramakrishnan 1cd9d07d8c Merge apphealthtask into box server
We used to run this as a separate process but no amount of node/v8 tweaking
makes them run as standalone with 50M RSS.

Three solutions were considered for the memory issue:
1. Use systemd timer. apphealthtask needs to run quiet frequently (10 sec)
   for the ui to get the app health update immediately after install.

2. Merge into box server (this commit)

3. Increase memory to 80M. This seems to make apphealthtask run as-is.
2015-09-14 10:52:11 -07:00
Girish Ramakrishnan f028649582 Rename app.js to box.js 2015-09-14 10:43:47 -07:00
Johannes Zellner d57236959a choose aws subdomain backend for test purpose 2015-09-13 22:02:04 +02:00
Johannes Zellner ebe975f463 Also send data with the domain deletion 2015-09-13 22:02:04 +02:00
Johannes Zellner a94267fc98 Use caas.js for subdomain business 2015-09-13 22:02:04 +02:00
Johannes Zellner f186ea7cc3 Add initial caas.js 2015-09-13 22:02:04 +02:00
Girish Ramakrishnan 29e05b1caa make janitor a systemd timer
one process lesser
2015-09-11 18:43:51 -07:00
Girish Ramakrishnan 6945a712df limit node memory usage
node needs to be told how much space it can usage, otherwise it keeps
allocating and we cannot keep it under 50M. keeping old space to 30M,
lets the memory hover around 40M

there are many options to v8 but I haven't explored them all:
--expose_gc - allows scripts to call gc()
--max_old_space_size=30 --max_semi_space_size=2048 (old/new space)
    node first allocates new objects in new space. if these objects are in use
    around for some time, it moves them to old space. the idea here is that it
    runs gc aggressively on new space since new objects die more than old ones.

    the new space is split into two halves of equal size called semi spaces.

--gc_interval=100 --optimize_for_size --max_executable_size=5 --gc_global --stack_size=1024

http://erikcorry.blogspot.com/2012/11/memory-management-flags-in-v8.html
http://jayconrod.com/posts/55/a-tour-of-v8-garbage-collection
https://code.google.com/p/chromium/issues/detail?id=280984
http://stackoverflow.com/questions/30252905/nodejs-decrease-v8-garbage-collector-memory-usage
http://www.appfruits.com/2014/08/running-node-js-on-arduino-yun/

note: this is not part of shebang because linux shebang does not support args! so we cannot
pass node args as part of shebang.
2015-09-10 21:24:36 -07:00
Girish Ramakrishnan 03048d7d2f set memorylimit for crashnotifier as well 2015-09-10 14:19:44 -07:00
Girish Ramakrishnan 28b768b146 Fix app autoupdater logic
The main issue was that app.portBindings is never null but { }
2015-09-10 11:39:29 -07:00
Girish Ramakrishnan c1e4dceb01 ssh is now on port 919 2015-09-10 10:08:40 -07:00
Johannes Zellner 954d14cd66 Warn the user when he performs an upgrade instead of update
Fixes #481
2015-09-10 14:33:00 +02:00
Johannes Zellner 2f5e9e2e26 We do have global rest error handler which take care of re-login 2015-09-10 14:16:59 +02:00
Johannes Zellner b3c058593f Force reload page if version has changed
Fixes #480
2015-09-10 13:58:27 +02:00
Johannes Zellner 3e47e11992 Ensure the stylesheets are in correct order
Fixes #484
2015-09-10 13:32:33 +02:00
Girish Ramakrishnan 8c7dfdcef2 Wait upto 3 seconds for the app to quit
Otherwise systemd will kill us and we get crash emails.

Fixes #483
2015-09-09 16:57:43 -07:00
Girish Ramakrishnan c88591489d make apps test work 2015-09-09 15:51:56 -07:00
Girish Ramakrishnan 719404b6cf lint 2015-09-09 15:03:43 -07:00
Girish Ramakrishnan f2c27489c8 test: make unregister subdomain test work 2015-09-09 14:36:09 -07:00
Girish Ramakrishnan d6a0c93f2f test: make register subdomain work 2015-09-09 14:32:05 -07:00
Girish Ramakrishnan c64d5fd2e3 error is already Error 2015-09-09 14:26:53 -07:00
Girish Ramakrishnan 5b62aeb73a make aws endpoint configurable for tests 2015-09-09 12:03:47 -07:00
Girish Ramakrishnan 7e83f2dd4a intercept delete calls to test image 2015-09-09 11:32:09 -07:00
Girish Ramakrishnan ed48f84355 give taskmanager couple of seconds to kill all processes 2015-09-09 10:39:38 -07:00
Girish Ramakrishnan f3d15cd4a5 fix initialization of apps-test 2015-09-09 10:22:17 -07:00
Girish Ramakrishnan 8c270269db remove dead code 2015-09-09 09:28:06 -07:00
Johannes Zellner bea605310a Use memoryLimit from manifest for graphs if specified 2015-09-09 17:11:54 +02:00
Johannes Zellner 8184894563 Remove upgrade view altogether 2015-09-09 16:47:13 +02:00
Johannes Zellner 47a87cc298 Remove upgrade link in the menu 2015-09-09 16:46:28 +02:00
Johannes Zellner 553a6347e6 Actually hand the backupKey over in an update 2015-09-09 12:37:09 +02:00
Girish Ramakrishnan a35ebd57f9 call iteratorDone when finished 2015-09-09 00:43:42 -07:00
Girish Ramakrishnan 97174d7af0 make cloudron-test pass 2015-09-08 22:13:50 -07:00
Girish Ramakrishnan 659268c04a provide default backupPrefix for tests 2015-09-08 21:16:50 -07:00
Girish Ramakrishnan 67d06c5efa better debug messages 2015-09-08 21:11:46 -07:00
Girish Ramakrishnan 6e6d8c0bc5 awscredentials is now POST 2015-09-08 21:02:21 -07:00
Girish Ramakrishnan 658af3edcf disable failing subdomains test
This needs aws mock
2015-09-08 20:38:52 -07:00
Girish Ramakrishnan 9753d9dc7e removeUser takes a userId and not username 2015-09-08 16:38:02 -07:00
Girish Ramakrishnan 4e331cfb35 retry registering and unregistering subdomain 2015-09-08 12:51:25 -07:00
Girish Ramakrishnan a1fa94707b Remove ununsed error codes 2015-09-08 11:28:29 -07:00
Girish Ramakrishnan 88f1107ed6 Remove unused AWSError 2015-09-08 11:26:35 -07:00
Girish Ramakrishnan e97b9fcc60 Do not start apptask for apps that are installed and running 2015-09-08 10:24:39 -07:00
Girish Ramakrishnan 71fe643099 Check if we have reached concurrency limit before locking 2015-09-08 10:20:34 -07:00
Johannes Zellner 74874a459d Remove ... for labels while showing the progress bar 2015-09-08 15:49:10 +02:00
Johannes Zellner 7c5fc17500 Cleanup linter issues in updatechecker.js 2015-09-08 10:03:37 +02:00
Girish Ramakrishnan 26aefadfba systemd: fix crashnotifier 2015-09-07 21:40:01 -07:00
Girish Ramakrishnan 51a28842cf systemd: pass the instance name as argument 2015-09-07 21:16:22 -07:00
Girish Ramakrishnan 210c2f3cc1 Output some logs in crashnotifier 2015-09-07 21:10:00 -07:00
Girish Ramakrishnan 773c326eb7 systemd: just wait for 5 seconds for box to die 2015-09-07 20:58:14 -07:00
Girish Ramakrishnan cb2fb026c5 systemd: do not restart crashnotifier 2015-09-07 20:54:58 -07:00
Girish Ramakrishnan a4731ad054 200m is a more sane memory limit 2015-09-07 20:48:29 -07:00
Girish Ramakrishnan aa33938fb5 systemd: fix config files 2015-09-07 20:46:32 -07:00
Girish Ramakrishnan edfe8f1ad0 disable pager when collecting logs 2015-09-07 20:27:27 -07:00
Girish Ramakrishnan 41399a2593 Make crashnotifier.js executable 2015-09-07 20:15:13 -07:00
Girish Ramakrishnan 2a4c467ab8 systemd: Fix crashnotifier 2015-09-07 20:14:37 -07:00
Girish Ramakrishnan 6be6092c0e Add memory limits on services 2015-09-07 19:16:34 -07:00
Girish Ramakrishnan e76584b0da Move from supervisor to systemd
This removes logrotate as well since we use systemd logging
2015-09-07 14:31:25 -07:00
Girish Ramakrishnan b3816615db run upto 5 apptasks in parallel
fixes #482
2015-09-05 09:17:46 -07:00
73 changed files with 1180 additions and 1066 deletions
+1 -5
View File
@@ -4,10 +4,6 @@ docs/
webadmin/dist/
setup/splash/website/
# vim swam files
# vim swap files
*.swp
# supervisor
supervisord.pid
supervisord.log
-1
View File
@@ -4,7 +4,6 @@ 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
View File
@@ -1,47 +0,0 @@
#!/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); });
Executable
+61
View File
@@ -0,0 +1,61 @@
#!/usr/bin/env node
'use strict';
require('supererror')({ splatchError: true });
// remove timestamp from debug() based output
require('debug').formatArgs = function formatArgs() {
arguments[0] = this.namespace + ' ' + arguments[0];
return arguments;
};
var appHealthMonitor = require('./src/apphealthmonitor.js'),
async = require('async'),
config = require('./src/config.js'),
ldap = require('./src/ldap.js'),
oauthproxy = require('./src/oauthproxy.js'),
server = require('./src/server.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();
async.series([
server.start,
ldap.start,
appHealthMonitor.start,
oauthproxy.start
], function (error) {
if (error) {
console.error('Error starting server', error);
process.exit(1);
}
});
var NOOP_CALLBACK = function () { };
process.on('SIGINT', function () {
server.stop(NOOP_CALLBACK);
ldap.stop(NOOP_CALLBACK);
oauthproxy.stop(NOOP_CALLBACK);
setTimeout(process.exit.bind(process), 3000);
});
process.on('SIGTERM', function () {
server.stop(NOOP_CALLBACK);
ldap.stop(NOOP_CALLBACK);
oauthproxy.stop(NOOP_CALLBACK);
setTimeout(process.exit.bind(process), 3000);
});
Regular → Executable
+20 -26
View File
@@ -2,20 +2,12 @@
'use strict';
// WARNING This is a supervisor eventlistener!
// The communication happens via stdin/stdout
// !! No console.log() allowed
// !! Do not set DEBUG
var assert = require('assert'),
mailer = require('./src/mailer.js'),
safe = require('safetydance'),
supervisor = require('supervisord-eventlistener'),
path = require('path'),
util = require('util');
var gLastNotifyTime = {};
var gCooldownTime = 1000 * 60 * 5; // 5 min
var COLLECT_LOGS_CMD = path.join(__dirname, 'src/scripts/collectlogs.sh');
function collectLogs(program, callback) {
@@ -26,28 +18,30 @@ function collectLogs(program, callback) {
callback(null, logs);
}
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) {
function sendCrashNotification(processName) {
collectLogs(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.');
}
console.log('Sending crash notification email for', processName);
mailer.sendCrashNotification(processName, result);
});
});
}
function main() {
if (process.argv.length !== 3) return console.error('Usage: crashnotifier.js <processName>');
var processName = process.argv[2];
console.log('Started crash notifier for', processName);
mailer.initialize(function (error) {
if (error) return console.error(error);
sendCrashNotification(processName);
});
}
main();
mailer.initialize(function () {
supervisor.listen(process.stdin, process.stdout);
console.error('Crashnotifier listening...');
});
+7 -3
View File
@@ -4,6 +4,12 @@
require('supererror')({ splatchError: true });
// remove timestamp from debug() based output
require('debug').formatArgs = function formatArgs() {
arguments[0] = this.namespace + ' ' + arguments[0];
return arguments;
};
var assert = require('assert'),
debug = require('debug')('box:janitor'),
async = require('async'),
@@ -11,8 +17,6 @@ var assert = require('assert'),
authcodedb = require('./src/authcodedb.js'),
database = require('./src/database.js');
var TOKEN_CLEANUP_INTERVAL = 30000;
function initialize(callback) {
assert.strictEqual(typeof callback, 'function');
@@ -52,7 +56,7 @@ function run() {
cleanupExpiredAuthCodes(function (error) {
if (error) console.error(error);
setTimeout(run, TOKEN_CLEANUP_INTERVAL);
process.exit(0);
});
});
}
+7 -13
View File
@@ -9,22 +9,22 @@
},
"aws-sdk": {
"version": "2.1.46",
"from": "aws-sdk@*",
"from": "https://registry.npmjs.org/aws-sdk/-/aws-sdk-2.1.46.tgz",
"resolved": "https://registry.npmjs.org/aws-sdk/-/aws-sdk-2.1.46.tgz",
"dependencies": {
"sax": {
"version": "0.5.3",
"from": "sax@0.5.3",
"from": "http://registry.npmjs.org/sax/-/sax-0.5.3.tgz",
"resolved": "http://registry.npmjs.org/sax/-/sax-0.5.3.tgz"
},
"xml2js": {
"version": "0.2.8",
"from": "xml2js@0.2.8",
"from": "https://registry.npmjs.org/xml2js/-/xml2js-0.2.8.tgz",
"resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.2.8.tgz"
},
"xmlbuilder": {
"version": "0.4.2",
"from": "xmlbuilder@0.4.2",
"from": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-0.4.2.tgz",
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-0.4.2.tgz"
}
}
@@ -156,16 +156,15 @@
"connect-lastmile": {
"version": "0.0.13",
"from": "connect-lastmile@0.0.13",
"resolved": "https://registry.npmjs.org/connect-lastmile/-/connect-lastmile-0.0.13.tgz",
"dependencies": {
"debug": {
"version": "2.1.3",
"from": "https://registry.npmjs.org/debug/-/debug-2.1.3.tgz",
"from": "debug@>=2.1.0 <2.2.0",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.1.3.tgz",
"dependencies": {
"ms": {
"version": "0.7.0",
"from": "http://registry.npmjs.org/ms/-/ms-0.7.0.tgz",
"from": "ms@0.7.0",
"resolved": "http://registry.npmjs.org/ms/-/ms-0.7.0.tgz"
}
}
@@ -2268,7 +2267,7 @@
},
"safetydance": {
"version": "0.0.19",
"from": "https://registry.npmjs.org/safetydance/-/safetydance-0.0.19.tgz",
"from": "safetydance@0.0.19",
"resolved": "https://registry.npmjs.org/safetydance/-/safetydance-0.0.19.tgz"
},
"semver": {
@@ -2442,11 +2441,6 @@
}
}
},
"supervisord-eventlistener": {
"version": "0.1.0",
"from": "https://registry.npmjs.org/supervisord-eventlistener/-/supervisord-eventlistener-0.1.0.tgz",
"resolved": "https://registry.npmjs.org/supervisord-eventlistener/-/supervisord-eventlistener-0.1.0.tgz"
},
"tail-stream": {
"version": "0.2.1",
"from": "https://registry.npmjs.org/tail-stream/-/tail-stream-0.2.1.tgz",
-185
View File
@@ -1,185 +0,0 @@
#!/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...');
});
+1 -4
View File
@@ -12,9 +12,6 @@
"engines": [
"node >= 0.12.0"
],
"bin": {
"cloudron": "./app.js"
},
"dependencies": {
"async": "^1.2.1",
"aws-sdk": "^2.1.46",
@@ -61,7 +58,6 @@
"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",
@@ -83,6 +79,7 @@
"gulp-uglify": "^1.1.0",
"hock": "~1.2.0",
"istanbul": "*",
"js2xmlparser": "^1.0.0",
"mocha": "*",
"nock": "^2.6.0",
"node-sass": "^3.0.0-alpha.0",
+2 -2
View File
@@ -16,7 +16,7 @@ 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.
For example, older systemd 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.
@@ -40,7 +40,7 @@ version (see below) or the mysql/postgresql data etc.
* It then setups up the cloud infra (setup_infra.sh) and creates cloudron.conf.
* supervisor is then started
* box services are then started
setup_infra.sh
This setups containers like graphite, mail and the addons containers.
+1 -1
View File
@@ -3,7 +3,7 @@
# If you change the infra version, be sure to put a warning
# in the change log
INFRA_VERSION=8
INFRA_VERSION=10
# WARNING WARNING WARNING WARNING WARNING WARNING WARNING WARNING
# These constants are used in the installer script as well
+8 -7
View File
@@ -13,13 +13,10 @@ readonly DATA_DIR="/home/yellowtent/data"
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/
########## systemd
cp -r "${container_files}/systemd/." /etc/systemd/system/
systemctl daemon-reload
systemctl enable cloudron.target
########## sudoers
rm /etc/sudoers.d/*
@@ -29,6 +26,10 @@ cp "${container_files}/sudoers" /etc/sudoers.d/yellowtent
rm -rf /etc/collectd
ln -sfF "${DATA_DIR}/collectd" /etc/collectd
########## apparmor docker profile
cp "${container_files}/docker-cloudron-app.apparmor" /etc/apparmor.d/docker-cloudron-app
systemctl restart apparmor
########## nginx
# link nginx config to system config
unlink /etc/nginx 2>/dev/null || rm -rf /etc/nginx
@@ -0,0 +1,32 @@
#include <tunables/global>
profile docker-cloudron-app flags=(attach_disconnected,mediate_deleted) {
#include <abstractions/base>
ptrace peer=@{profile_name},
network,
capability,
file,
umount,
deny @{PROC}/sys/fs/** wklx,
deny @{PROC}/sysrq-trigger rwklx,
deny @{PROC}/mem rwklx,
deny @{PROC}/kmem rwklx,
deny @{PROC}/sys/kernel/[^s][^h][^m]* wklx,
deny @{PROC}/sys/kernel/*/** wklx,
deny mount,
deny /sys/[^f]*/** wklx,
deny /sys/f[^s]*/** wklx,
deny /sys/fs/[^c]*/** wklx,
deny /sys/fs/c[^g]*/** wklx,
deny /sys/fs/cg[^r]*/** wklx,
deny /sys/firmware/efi/efivars/** rwklx,
deny /sys/kernel/security/** rwklx,
}
-6
View File
@@ -1,6 +0,0 @@
/var/log/cloudron/*log {
missingok
notifempty
size 100k
nocompress
}
-7
View File
@@ -1,7 +0,0 @@
/var/log/supervisor/*log {
missingok
copytruncate
notifempty
size 100k
nocompress
}
@@ -1,10 +0,0 @@
[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*",BOX_ENV="cloudron",NODE_ENV="production"
@@ -1,10 +0,0 @@
[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",BOX_ENV="cloudron",NODE_ENV="production"
@@ -1,11 +0,0 @@
[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",BOX_ENV="cloudron",NODE_ENV="production"
@@ -1,10 +0,0 @@
[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*",BOX_ENV="cloudron",NODE_ENV="production"
@@ -1,10 +0,0 @@
[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*",BOX_ENV="cloudron",NODE_ENV="production"
@@ -1,33 +0,0 @@
; 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
+17
View File
@@ -0,0 +1,17 @@
[Unit]
Description=Cloudron Admin
OnFailure=crashnotifier@%n.service
StopWhenUnneeded=true
[Service]
Type=idle
WorkingDirectory=/home/yellowtent/box
Restart=always
ExecStart=/usr/bin/node --max_old_space_size=150 /home/yellowtent/box/box.js
Environment="HOME=/home/yellowtent" "USER=yellowtent" "DEBUG=box*,connect-lastmile" "BOX_ENV=cloudron" "NODE_ENV=production"
KillMode=process
User=yellowtent
Group=yellowtent
MemoryLimit=200M
TimeoutStopSec=5s
+10
View File
@@ -0,0 +1,10 @@
[Unit]
Description=Cloudron Smart Cloud
Documentation=https://cloudron.io/documentation.html
StopWhenUnneeded=true
Requires=box.service janitor.timer
After=box.service janitor.timer
# AllowIsolate=yes
[Install]
WantedBy=multi-user.target
@@ -0,0 +1,15 @@
# http://northernlightlabs.se/systemd.status.mail.on.unit.failure
[Unit]
Description=Cloudron Crash Notifier for %i
# otherwise, systemd will kill this unit immediately as nobody requires it
StopWhenUnneeded=false
[Service]
Type=idle
WorkingDirectory=/home/yellowtent/box
ExecStart="/home/yellowtent/box/crashnotifier.js" %I
Environment="HOME=/home/yellowtent" "USER=yellowtent" "DEBUG=box*,connect-lastmile" "BOX_ENV=cloudron" "NODE_ENV=production"
KillMode=process
User=yellowtent
Group=yellowtent
MemoryLimit=50M
+15
View File
@@ -0,0 +1,15 @@
[Unit]
Description=Cloudron Janitor
OnFailure=crashnotifier@%n.service
[Service]
Type=simple
WorkingDirectory=/home/yellowtent/box
Restart=no
ExecStart=/usr/bin/node /home/yellowtent/box/janitor.js
Environment="HOME=/home/yellowtent" "USER=yellowtent" "DEBUG=box*,connect-lastmile" "BOX_ENV=cloudron" "NODE_ENV=production"
KillMode=process
User=yellowtent
Group=yellowtent
MemoryLimit=50M
WatchdogSec=30
+10
View File
@@ -0,0 +1,10 @@
[Unit]
Description=Cloudron Janitor
StopWhenUnneeded=true
[Timer]
# this activates it immediately
OnBootSec=0
OnUnitActiveSec=30min
Unit=janitor.service
+3 -15
View File
@@ -166,22 +166,10 @@ 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
set_progress "80" "Starting Cloudron"
systemctl start cloudron.target
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
sleep 2 # give systemd sometime to start the processes
set_progress "85" "Reloading nginx"
nginx -s reload
+1 -1
View File
@@ -220,7 +220,7 @@ LoadPlugin write_graphite
</Plugin>
<Plugin processes>
ProcessMatch "app" "node app.js"
ProcessMatch "app" "node box.js"
</Plugin>
<Plugin swap>
+1 -1
View File
@@ -69,7 +69,7 @@ server {
}
<% } else if ( endpoint === 'oauthproxy' ) { %>
proxy_pass http://127.0.0.1:4000;
proxy_pass http://127.0.0.1:3003;
proxy_set_header X-Cloudron-Proxy-Port <%= port %>;
<% } else if ( endpoint === 'app' ) { %>
proxy_pass http://127.0.0.1:<%= port %>;
+10
View File
@@ -28,6 +28,8 @@ fi
# graphite
graphite_container_id=$(docker run --restart=always -d --name="graphite" \
-m 75m \
--memory-swap 150m \
-p 127.0.0.1:2003:2003 \
-p 127.0.0.1:2004:2004 \
-p 127.0.0.1:8000:8000 \
@@ -37,6 +39,8 @@ echo "Graphite container id: ${graphite_container_id}"
# mail
mail_container_id=$(docker run --restart=always -d --name="mail" \
-m 75m \
--memory-swap 150m \
-p 127.0.0.1:25:25 \
-h "${arg_fqdn}" \
-e "DOMAIN_NAME=${arg_fqdn}" \
@@ -52,6 +56,8 @@ 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" \
-m 100m \
--memory-swap 200m \
-h "${arg_fqdn}" \
-v "${DATA_DIR}/mysql:/var/lib/mysql" \
-v "${DATA_DIR}/addons/mysql_vars.sh:/etc/mysql/mysql_vars.sh:ro" \
@@ -64,6 +70,8 @@ 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" \
-m 100m \
--memory-swap 200m \
-h "${arg_fqdn}" \
-v "${DATA_DIR}/postgresql:/var/lib/postgresql" \
-v "${DATA_DIR}/addons/postgresql_vars.sh:/etc/postgresql/postgresql_vars.sh:ro" \
@@ -76,6 +84,8 @@ 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" \
-m 100m \
--memory-swap 200m \
-h "${arg_fqdn}" \
-v "${DATA_DIR}/mongodb:/var/lib/mongodb" \
-v "${DATA_DIR}/addons/mongodb_vars.sh:/etc/mongodb_vars.sh:ro" \
+2 -10
View File
@@ -2,14 +2,6 @@
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 ""
echo "Stopping cloudron"
systemctl stop cloudron.target
+2
View File
@@ -677,6 +677,8 @@ function setupRedis(app, callback) {
redisVarsFile + ':/etc/redis/redis_vars.sh:ro',
redisDataDir + ':/var/lib/redis:rw'
],
Memory: 1024 * 1024 * 75, // 100mb
MemorySwap: 1024 * 1024 * 75 * 2, // 150mb
// 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: {
+17
View File
@@ -6,6 +6,7 @@ exports = module.exports = {
get: get,
getBySubdomain: getBySubdomain,
getByHttpPort: getByHttpPort,
getByContainerId: getByContainerId,
add: add,
exists: exists,
del: del,
@@ -145,6 +146,22 @@ function getByHttpPort(httpPort, callback) {
});
}
function getByContainerId(containerId, callback) {
assert.strictEqual(typeof containerId, '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 containerId = ? GROUP BY apps.id', [ containerId ], 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');
+68 -29
View File
@@ -1,44 +1,33 @@
#!/usr/bin/env node
'use strict';
require('supererror')({ splatchError: true });
var appdb = require('./src/appdb.js'),
var appdb = require('./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'),
DatabaseError = require('./databaseerror.js'),
debug = require('debug')('box:apphealthmonitor'),
docker = require('./docker.js'),
mailer = require('./mailer.js'),
superagent = require('superagent'),
util = require('util');
exports = module.exports = {
run: run
start: start,
stop: stop
};
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 }
var gRunTimeout = null;
var gDockerEventStream = null;
function debugApp(app, args) {
function debugApp(app) {
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');
@@ -130,18 +119,68 @@ function run() {
processApps(function (error) {
if (error) console.error(error);
setTimeout(run, HEALTHCHECK_INTERVAL);
gRunTimeout = setTimeout(run, HEALTHCHECK_INTERVAL);
});
}
if (require.main === module) {
initialize(function (error) {
if (error) {
console.error('apphealth task exiting with error', error);
process.exit(1);
}
/*
OOM can be tested using stress tool like so:
docker run -ti -m 100M cloudron/base:0.3.3 /bin/bash
apt-get update && apt-get install stress
stress --vm 1 --vm-bytes 200M --vm-hang 0
*/
function processDockerEvents() {
// note that for some reason, the callback is called only on the first event
debug('Listening for docker events');
docker.getEvents({ filters: JSON.stringify({ event: [ 'oom' ] }) }, function (error, stream) {
if (error) return console.error(error);
run();
gDockerEventStream = stream;
stream.setEncoding('utf8');
stream.on('data', function (data) {
var ev = JSON.parse(data);
debug('Container ' + ev.id + ' went OOM');
appdb.getByContainerId(ev.id, function (error, app) {
var program = error || !app.appStoreId ? ev.id : app.appStoreId;
var context = JSON.stringify(ev);
if (app) context = context + '\n\n' + JSON.stringify(app, null, 4) + '\n';
debug('OOM Context: %s', context);
mailer.sendCrashNotification(program, context); // app can be null if it's an addon crash
});
});
stream.on('error', function (error) {
console.error('Error reading docker events', error);
gDockerEventStream = null; // TODO: reconnect?
});
stream.on('end', function () {
console.error('Docke event stream ended');
gDockerEventStream = null; // TODO: reconnect?
stream.end();
});
});
}
function start(callback) {
assert.strictEqual(typeof callback, 'function');
debug('Starting apphealthmonitor');
processDockerEvents();
run();
callback();
}
function stop(callback) {
assert.strictEqual(typeof callback, 'function');
clearTimeout(gRunTimeout);
gDockerEventStream.end();
callback();
}
+89 -38
View File
@@ -140,16 +140,18 @@ function validatePortBindings(portBindings, tcpPorts) {
// 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 */
919, /* ssh */
2003, /* graphite (lo) */
2004, /* graphite (lo) */
2020, /* install server */
config.get('port'), /* app server (lo) */
config.get('internalPort'), /* internal app server (lo) */
config.get('ldapPort'), /* ldap server (lo) */
config.get('oauthProxyPort'), /* oauth proxy server (lo) */
3306, /* mysql (lo) */
8000 /* graphite (lo) */
];
@@ -649,27 +651,38 @@ function autoupdateApps(updateInfo, callback) { // updateInfo is { appId -> { ma
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;
var tcpPorts = newManifest.tcpPorts || { };
var portBindings = app.portBindings; // this is never null
for (var env in newManifest.tcpPorts) {
if (!(env in app.portBindings)) return false;
}
if (Object.keys(tcpPorts).length === 0 && Object.keys(portBindings).length === 0) return null;
if (Object.keys(tcpPorts).length === 0) return new Error('tcpPorts is now empty but portBindings is not');
if (Object.keys(portBindings).length === 0) return new Error('portBindings is now empty but tcpPorts is not');
return true;
for (var env in tcpPorts) {
if (!(env in portBindings)) return new Error(env + ' is required from user');
}
// it's fine if one or more keys got removed
return null;
}
if (!updateInfo) return callback(null);
async.eachSeries(Object.keys(updateInfo), function iterator(appId, iteratorDone) {
get(appId, function (error, app) {
if (!canAutoupdateApp(app, updateInfo[appId].manifest)) {
if (error) {
debug('Cannot autoupdate app %s : %s', appId, error.message);
return iteratorDone();
}
error = canAutoupdateApp(app, updateInfo[appId].manifest);
if (error) {
debug('app %s requires manual update. %s', appId, error.message);
return iteratorDone();
}
update(appId, false /* force */, updateInfo[appId].manifest, app.portBindings, null /* icon */, function (error) {
if (error) debug('Error initiating autoupdate of %s', appId);
if (error) debug('Error initiating autoupdate of %s. %s', appId, error.message);
iteratorDone(null);
});
@@ -677,33 +690,35 @@ function autoupdateApps(updateInfo, callback) { // updateInfo is { appId -> { ma
}, callback);
}
function backupApp(app, addonsToBackup, callback) {
function canBackupApp(app) {
// only backup apps that are installed or pending configure. Rest of them are in some
// state not good for consistent backup (i.e addons may not have been setup completely)
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
}
// set the 'creation' date of lastBackup so that the backup persists across time based archival rules
// s3 does not allow changing creation time, so copying the last backup is easy way out for now
function reuseOldBackup(app, callback) {
assert.strictEqual(typeof app.lastBackupId, 'string');
assert.strictEqual(typeof callback, 'function');
backups.copyLastBackup(app, function (error, newBackupId) {
if (error) return callback(new AppsError(AppsError.INTERNAL_ERROR, error));
debugApp(app, 'reuseOldBackup: reused old backup %s as %s', app.lastBackupId, newBackupId);
callback(null, newBackupId);
});
}
function createNewBackup(app, addonsToBackup, callback) {
assert.strictEqual(typeof app, 'object');
assert(!addonsToBackup || typeof addonsToBackup, '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 (i.e addons may not have been setup completely)
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, 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));
@@ -718,13 +733,49 @@ function backupApp(app, addonsToBackup, callback) {
], function (error) {
if (error) return callback(new AppsError(AppsError.INTERNAL_ERROR, error));
debugApp(app, 'backupApp: successful id:%s', result.id);
callback(null, result.id);
});
});
}
setRestorePoint(app.id, result.id, appConfig, function (error) {
if (error) return callback(new AppsError(AppsError.INTERNAL_ERROR, error));
function backupApp(app, addonsToBackup, callback) {
assert.strictEqual(typeof app, 'object');
assert(!addonsToBackup || typeof addonsToBackup, 'object');
assert.strictEqual(typeof callback, 'function');
return callback(null, result.id);
});
var appConfig = null, backupFunction;
if (!canBackupApp(app)) {
if (!app.lastBackupId) {
debugApp(app, 'backupApp: cannot backup app');
return callback(new AppsError(AppsError.BAD_STATE, 'App not healthy and never backed up previously'));
}
appConfig = app.lastBackupConfig;
backupFunction = reuseOldBackup.bind(null, app);
} else {
appConfig = {
manifest: app.manifest,
location: app.location,
portBindings: app.portBindings,
accessRestriction: app.accessRestriction
};
backupFunction = createNewBackup.bind(null, app, addonsToBackup);
if (!safe.fs.writeFileSync(path.join(paths.DATA_DIR, app.id + '/config.json'), JSON.stringify(appConfig), 'utf8')) {
return callback(safe.error);
}
}
backupFunction(function (error, backupId) {
if (error) return callback(new AppsError(AppsError.INTERNAL_ERROR, error));
debugApp(app, 'backupApp: successful id:%s', backupId);
setRestorePoint(app.id, backupId, appConfig, function (error) {
if (error) return callback(new AppsError(AppsError.INTERNAL_ERROR, error));
return callback(null, backupId);
});
});
}
+35 -13
View File
@@ -25,6 +25,12 @@ exports = module.exports = {
require('supererror')({ splatchError: true });
// remove timestamp from debug() based output
require('debug').formatArgs = function formatArgs() {
arguments[0] = this.namespace + ' ' + arguments[0];
return arguments;
};
var addons = require('./addons.js'),
appdb = require('./appdb.js'),
apps = require('./apps.js'),
@@ -46,6 +52,7 @@ var addons = require('./addons.js'),
paths = require('./paths.js'),
safe = require('safetydance'),
shell = require('./shell.js'),
SubdomainError = require('./subdomainerror.js'),
subdomains = require('./subdomains.js'),
superagent = require('superagent'),
sysinfo = require('./sysinfo.js'),
@@ -345,7 +352,8 @@ function startContainer(app, callback) {
"Name": "always",
"MaximumRetryCount": 0
},
CpuShares: 512 // relative to 1024 for system processes
CpuShares: 512, // relative to 1024 for system processes
SecurityOpt: config.CLOUDRON ? [ "apparmor:docker-cloudron-app" ] : null // profile available only on cloudron
};
var container = docker.getContainer(app.containerId);
@@ -424,29 +432,43 @@ function downloadIcon(app, callback) {
}
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() };
subdomains.add(record, function (error, changeId) {
if (error) return callback(error);
async.retry({ times: 200, interval: 5000 }, function (retryCallback) {
debugApp(app, 'Registering subdomain location [%s]', app.location);
debugApp(app, 'Registered subdomain.');
subdomains.add(record, function (error, changeId) {
if (error && (error.reason === SubdomainError.STILL_BUSY || error.reason === SubdomainError.EXTERNAL_ERROR)) return retryCallback(error); // try again
updateApp(app, { dnsRecordId: changeId }, callback);
retryCallback(null, error || changeId);
});
}, function (error, result) {
if (error || result instanceof Error) return callback(error || result);
updateApp(app, { dnsRecordId: result }, callback);
});
}
function unregisterSubdomain(app, location, callback) {
debugApp(app, 'Unregistering subdomain: %s', location);
// do not unregister bare domain because we show a error/cloudron info page there
if (location === '') return callback(null);
if (location === '') {
debugApp(app, 'Skip unregister of empty subdomain');
return callback(null);
}
var record = { subdomain: location, type: 'A', value: sysinfo.getIp() };
subdomains.remove(record, function (error) {
async.retry({ times: 30, interval: 5000 }, function (retryCallback) {
debugApp(app, 'Unregistering subdomain: %s', location);
subdomains.remove(record, function (error) {
if (error && (error.reason === SubdomainError.STILL_BUSY || error.reason === SubdomainError.EXTERNAL_ERROR))return retryCallback(error); // try again
retryCallback(error);
});
}, function (error) {
if (error) debugApp(app, 'Error unregistering subdomain: %s', error);
updateApp(app, { dnsRecordId: null }, callback);
@@ -669,8 +691,8 @@ function configure(app, callback) {
stopApp.bind(null, app),
deleteContainer.bind(null, app),
function (next) {
// oldConfig can be null during an infra update
if (!app.oldConfig || app.oldConfig.location === app.location) return next();
// oldConfig can be null during an infra update. location can be null when infra updated for an updated app
if (!app.oldConfig || !app.oldConfig.location || app.oldConfig.location === app.location) return next();
unregisterSubdomain(app, app.oldConfig.location, next);
},
removeOAuthProxyCredentials.bind(null, app),
+41 -41
View File
@@ -3,16 +3,14 @@
'use strict';
exports = module.exports = {
AWSError: AWSError,
getAWSCredentials: getAWSCredentials,
getSignedUploadUrl: getSignedUploadUrl,
getSignedDownloadUrl: getSignedDownloadUrl,
addSubdomain: addSubdomain,
delSubdomain: delSubdomain,
getChangeStatus: getChangeStatus
getChangeStatus: getChangeStatus,
copyObject: copyObject
};
var assert = require('assert'),
@@ -20,32 +18,7 @@ var assert = require('assert'),
config = require('./config.js'),
debug = require('debug')('box:aws'),
SubdomainError = require('./subdomainerror.js'),
superagent = require('superagent'),
util = require('util');
// http://dustinsenos.com/articles/customErrorsInNode
// http://code.google.com/p/v8/wiki/JavaScriptStackTraceApi
function AWSError(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(AWSError, Error);
AWSError.INTERNAL_ERROR = 'Internal Error';
AWSError.MISSING_CREDENTIALS = 'Missing AWS credentials';
superagent = require('superagent');
function getAWSCredentials(callback) {
assert.strictEqual(typeof callback, 'function');
@@ -53,26 +26,34 @@ function getAWSCredentials(callback) {
// CaaS
if (config.token()) {
var url = config.apiServerOrigin() + '/api/v1/boxes/' + config.fqdn() + '/awscredentials';
superagent.get(url).query({ token: config.token() }).end(function (error, result) {
superagent.post(url).query({ token: config.token() }).end(function (error, result) {
if (error) return callback(error);
if (result.statusCode !== 201) return callback(new Error(result.text));
if (!result.body || !result.body.credentials) return callback(new Error('Unexpected response'));
return callback(null, {
var credentials = {
accessKeyId: result.body.credentials.AccessKeyId,
secretAccessKey: result.body.credentials.SecretAccessKey,
sessionToken: result.body.credentials.SessionToken,
region: 'us-east-1'
});
};
if (config.aws().endpoint) credentials.endpoint = new AWS.Endpoint(config.aws().endpoint);
callback(null, credentials);
});
} else {
if (!config.aws().accessKeyId || !config.aws().secretAccessKey) return callback(new AWSError(AWSError.MISSING_CREDENTIALS));
if (!config.aws().accessKeyId || !config.aws().secretAccessKey) return callback(new SubdomainError(SubdomainError.MISSING_CREDENTIALS));
callback(null, {
var credentials = {
accessKeyId: config.aws().accessKeyId,
secretAccessKey: config.aws().secretAccessKey,
region: 'us-east-1'
});
};
if (config.aws().endpoint) credentials.endpoint = new AWS.Endpoint(config.aws().endpoint);
callback(null, credentials);
}
}
@@ -80,7 +61,7 @@ function getSignedUploadUrl(filename, callback) {
assert.strictEqual(typeof filename, 'string');
assert.strictEqual(typeof callback, 'function');
debug('getSignedUploadUrl()');
debug('getSignedUploadUrl: %s', filename);
getAWSCredentials(function (error, credentials) {
if (error) return callback(error);
@@ -103,7 +84,7 @@ function getSignedDownloadUrl(filename, callback) {
assert.strictEqual(typeof filename, 'string');
assert.strictEqual(typeof callback, 'function');
debug('getSignedDownloadUrl()');
debug('getSignedDownloadUrl: %s', filename);
getAWSCredentials(function (error, credentials) {
if (error) return callback(error);
@@ -186,9 +167,9 @@ function addSubdomain(zoneName, subdomain, type, value, callback) {
var route53 = new AWS.Route53(credentials);
route53.changeResourceRecordSets(params, function(error, result) {
if (error && error.code === 'PriorRequestNotComplete') {
return callback(new SubdomainError(SubdomainError.STILL_BUSY, new Error(error)));
return callback(new SubdomainError(SubdomainError.STILL_BUSY, error.message));
} else if (error) {
return callback(new SubdomainError(SubdomainError.EXTERNAL_ERROR, new Error(error)));
return callback(new SubdomainError(SubdomainError.EXTERNAL_ERROR, error.message));
}
debug('addSubdomain: success. changeInfoId:%j', result);
@@ -280,3 +261,22 @@ function getChangeStatus(changeId, callback) {
});
});
}
function copyObject(from, to, callback) {
assert.strictEqual(typeof from, 'string');
assert.strictEqual(typeof to, 'string');
assert.strictEqual(typeof callback, 'function');
getAWSCredentials(function (error, credentials) {
if (error) return callback(error);
var params = {
Bucket: config.aws().backupBucket, // target bucket
Key: config.aws().backupPrefix + '/' + to, // target file
CopySource: config.aws().backupBucket + '/' + config.aws().backupPrefix + '/' + from, // source
};
var s3 = new AWS.S3(credentials);
s3.copyObject(params, callback);
});
}
+18 -3
View File
@@ -6,7 +6,9 @@ exports = module.exports = {
getAllPaged: getAllPaged,
getBackupUrl: getBackupUrl,
getRestoreUrl: getRestoreUrl
getRestoreUrl: getRestoreUrl,
copyLastBackup: copyLastBackup
};
var assert = require('assert'),
@@ -76,7 +78,7 @@ function getBackupUrl(app, callback) {
backupKey: config.backupKey()
};
debug('getBackupUrl: ', obj);
debug('getBackupUrl: id:%s url:%s sessionToken:%s backupKey:%s', obj.id, obj.url, obj.sessionToken, obj.backupKey);
callback(null, obj);
});
@@ -97,8 +99,21 @@ function getRestoreUrl(backupId, callback) {
backupKey: config.backupKey()
};
debug('getRestoreUrl: ', obj);
debug('getRestoreUrl: id:%s url:%s sessionToken:%s backupKey:%s', obj.id, obj.url, obj.sessionToken, obj.backupKey);
callback(null, obj);
});
}
function copyLastBackup(app, callback) {
assert(app && typeof app === 'object');
assert.strictEqual(typeof app.lastBackupId, 'string');
assert.strictEqual(typeof callback, 'function');
var toFilename = util.format('appbackup_%s_%s-v%s.tar.gz', app.id, (new Date()).toISOString(), app.manifest.version);
aws.copyObject(app.lastBackupId, toFilename, function (error) {
if (error) return callback(new BackupsError(BackupsError.EXTERNAL_ERROR, error));
return callback(null, toFilename);
});
}
+88
View File
@@ -0,0 +1,88 @@
/* jslint node:true */
'use strict';
exports = module.exports = {
addSubdomain: addSubdomain,
delSubdomain: delSubdomain,
getChangeStatus: getChangeStatus
};
var assert = require('assert'),
config = require('./config.js'),
debug = require('debug')('box:caas'),
SubdomainError = require('./subdomainerror.js'),
superagent = require('superagent'),
util = require('util');
function addSubdomain(zoneName, subdomain, type, value, callback) {
assert.strictEqual(typeof zoneName, 'string');
assert.strictEqual(typeof subdomain, 'string');
assert.strictEqual(typeof type, 'string');
assert.strictEqual(typeof value, 'string');
assert.strictEqual(typeof callback, 'function');
debug('addSubdomain: ' + subdomain + ' for domain ' + zoneName + ' with value ' + value);
var data = {
type: type,
value: value
};
superagent
.post(config.apiServerOrigin() + '/api/v1/domains/' + config.appFqdn(subdomain))
.query({ token: config.token() })
.send(data)
.end(function (error, result) {
if (error) return callback(error);
if (result.status === 420) return callback(new SubdomainError(SubdomainError.STILL_BUSY));
if (result.status !== 201) return callback(new SubdomainError(SubdomainError.EXTERNAL_ERROR, util.format('%s %j', result.status, result.body)));
return callback(null, result.body.changeId);
});
}
function delSubdomain(zoneName, subdomain, type, value, callback) {
assert.strictEqual(typeof zoneName, 'string');
assert.strictEqual(typeof subdomain, 'string');
assert.strictEqual(typeof type, 'string');
assert.strictEqual(typeof value, 'string');
assert.strictEqual(typeof callback, 'function');
debug('delSubdomain: %s for domain %s.', subdomain, zoneName);
var data = {
type: type,
value: value
};
superagent
.del(config.apiServerOrigin() + '/api/v1/domains/' + config.appFqdn(subdomain))
.query({ token: config.token() })
.send(data)
.end(function (error, result) {
if (error) return callback(error);
if (result.status === 420) return callback(new SubdomainError(SubdomainError.STILL_BUSY));
if (result.status !== 204) return callback(new SubdomainError(SubdomainError.EXTERNAL_ERROR, util.format('%s %j', result.status, result.body)));
return callback(null);
});
}
function getChangeStatus(changeId, callback) {
assert.strictEqual(typeof changeId, 'string');
assert.strictEqual(typeof callback, 'function');
if (changeId === '') return callback(null, 'INSYNC');
superagent
.get(config.apiServerOrigin() + '/api/v1/domains/' + config.fqdn() + '/status/' + changeId)
.query({ token: config.token() })
.end(function (error, result) {
if (error) return callback(error);
if (result.status !== 200) return callback(new SubdomainError(SubdomainError.EXTERNAL_ERROR, util.format('%s %j', result.status, result.body)));
return callback(null, result.body.status);
});
}
+21 -17
View File
@@ -138,7 +138,7 @@ function setTimeZone(ip, callback) {
}
if (!result.body.timezone) {
debug('No timezone in geoip response');
debug('No timezone in geoip response : %j', result.body);
return callback(null);
}
@@ -158,7 +158,7 @@ function activate(username, password, email, name, ip, callback) {
debug('activating user:%s email:%s', username, email);
setTimeZone(ip, function () { });
setTimeZone(ip, function () { }); // TODO: get this from user. note that timezone is detected based on the browser location and not the cloudron region
if (!name) name = settings.getDefaultSync(settings.CLOUDRON_NAME_KEY);
@@ -463,7 +463,7 @@ function doUpgrade(boxUpdateInfo, callback) {
callback(e);
}
progress.set(progress.UPDATE, 5, 'Create app and box backup for upgrade');
progress.set(progress.UPDATE, 5, 'Backing up for upgrade');
backupBoxAndApps(function (error) {
if (error) return upgradeError(error);
@@ -492,9 +492,9 @@ function doUpdate(boxUpdateInfo, callback) {
callback(e);
}
progress.set(progress.UPDATE, 5, 'Create box backup for update');
progress.set(progress.UPDATE, 5, 'Backing up for update');
backupBox(function (error) {
backupBoxAndApps(function (error) {
if (error) return updateError(error);
// fetch a signed sourceTarballUrl
@@ -511,18 +511,19 @@ function doUpdate(boxUpdateInfo, callback) {
// this data is opaque to the installer
data: {
boxVersionsUrl: config.get('boxVersionsUrl'),
version: boxUpdateInfo.version,
apiServerOrigin: config.apiServerOrigin(),
webServerOrigin: config.webServerOrigin(),
aws: config.aws(),
backupKey: config.backupKey(),
boxVersionsUrl: config.get('boxVersionsUrl'),
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,
aws: config.aws()
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'),
version: boxUpdateInfo.version,
webServerOrigin: config.webServerOrigin()
}
};
@@ -548,6 +549,9 @@ function backup(callback) {
var error = locker.lock(locker.OP_FULL_BACKUP);
if (error) return callback(new CloudronError(CloudronError.BAD_STATE, error.message));
// clearing backup ensures tools can 'wait' on progress
progress.clear(progress.BACKUP);
// start the backup operation in the background
backupBoxAndApps(function (error) {
if (error) console.error('backup failed.', error);
@@ -632,12 +636,12 @@ function backupBoxAndApps(callback) {
apps.backupApp(app, app.manifest.addons, function (error, backupId) {
progress.set(progress.BACKUP, step * processed, 'Backed up app at ' + app.location);
if (error && error.reason === AppsError.BAD_STATE) {
debugApp(app, 'Skipping backup (istate:%s health:%s). using lastBackupId:%s', app.installationState, app.health, app.lastBackupId);
backupId = app.lastBackupId;
if (error && error.reason !== AppsError.BAD_STATE) {
debugApp(app, 'Unable to backup', error);
return iteratorCallback(error);
}
return iteratorCallback(null, backupId);
iteratorCallback(null, backupId || null); // clear backupId if is in BAD_STATE and never backed up
});
}, function appsBackedUp(error, backupIds) {
if (error) {
@@ -645,7 +649,7 @@ function backupBoxAndApps(callback) {
return callback(error);
}
backupIds = backupIds.filter(function (id) { return id !== null; }); // remove apps that were never backed up
backupIds = backupIds.filter(function (id) { return id !== null; }); // remove apps in bad state that were never backed up
backupBoxWithAppBackupIds(backupIds, function (error, restoreKey) {
progress.set(progress.BACKUP, 100, error ? error.message : '');
+8
View File
@@ -25,6 +25,7 @@ exports = module.exports = {
// these values are derived
adminOrigin: adminOrigin,
internalAdminOrigin: internalAdminOrigin,
appFqdn: appFqdn,
zoneName: zoneName,
@@ -73,6 +74,7 @@ function initConfig() {
data.webServerOrigin = null;
data.internalPort = 3001;
data.ldapPort = 3002;
data.oauthProxyPort = 3003;
data.backupKey = 'backupKey';
data.aws = {
backupBucket: null,
@@ -98,6 +100,8 @@ function initConfig() {
};
data.token = 'APPSTORE_TOKEN';
data.aws.backupBucket = 'testbucket';
data.aws.backupPrefix = 'testprefix';
data.aws.endpoint = 'http://localhost:5353';
} else {
assert(false, 'Unknown environment. This should not happen!');
}
@@ -160,6 +164,10 @@ function adminOrigin() {
return 'https://' + appFqdn(constants.ADMIN_LOCATION);
}
function internalAdminOrigin() {
return 'http://127.0.0.1:' + get('port');
}
function token() {
return get('token');
}
+3 -1
View File
@@ -48,6 +48,8 @@ function recreateJobs(unusedTimeZone, callback) {
if (typeof unusedTimeZone === 'function') callback = unusedTimeZone;
settings.getAll(function (error, allSettings) {
debug('Creating jobs with timezone %s', allSettings[settings.TIME_ZONE_KEY]);
if (gHeartbeatJob) gHeartbeatJob.stop();
gHeartbeatJob = new CronJob({
cronTime: '00 */1 * * * *', // every minute
@@ -110,7 +112,7 @@ function autoupdatePatternChanged(pattern) {
}
},
start: true,
timeZone: gBoxUpdateCheckerJob.cronTime.timeZone // hack
timeZone: gBoxUpdateCheckerJob.cronTime.zone // hack
});
}
+11 -2
View File
@@ -1,7 +1,8 @@
'use strict';
exports = module.exports = {
start: start
start: start,
stop: stop
};
var assert = require('assert'),
@@ -28,7 +29,7 @@ var GROUP_USERS_DN = 'cn=users,ou=groups,dc=cloudron';
var GROUP_ADMINS_DN = 'cn=admins,ou=groups,dc=cloudron';
function start(callback) {
assert(typeof callback === 'function');
assert.strictEqual(typeof callback, 'function');
gServer = ldap.createServer({ log: gLogger });
@@ -123,3 +124,11 @@ function start(callback) {
gServer.listen(config.get('ldapPort'), callback);
}
function stop(callback) {
assert.strictEqual(typeof callback, 'function');
gServer.close();
callback();
}
+21 -5
View File
@@ -9,6 +9,7 @@ function Locker() {
this._operation = null;
this._timestamp = null;
this._watcherId = -1;
this._lockDepth = 0; // recursive locks
}
util.inherits(Locker, EventEmitter);
@@ -24,6 +25,7 @@ Locker.prototype.lock = function (operation) {
if (this._operation !== null) return new Error('Already locked for ' + this._operation);
this._operation = operation;
++this._lockDepth;
this._timestamp = new Date();
var that = this;
this._watcherId = setInterval(function () { debug('Lock unreleased %s', that._operation); }, 1000 * 60 * 5);
@@ -35,17 +37,31 @@ Locker.prototype.lock = function (operation) {
return null;
};
Locker.prototype.recursiveLock = function (operation) {
if (this._operation === operation) {
++this._lockDepth;
debug('Re-acquired : %s Depth : %s', this._operation, this._lockDepth);
return null;
}
return this.lock(operation);
};
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);
if (--this._lockDepth === 0) {
debug('Released : %s', this._operation);
this._operation = null;
this._timestamp = null;
clearInterval(this._watcherId);
this._watcherId = -1;
this._operation = null;
this._timestamp = null;
clearInterval(this._watcherId);
this._watcherId = -1;
} else {
debug('Recursive lock released : %s. Depth : %s', this._operation, this._lockDepth);
}
this.emit('unlocked', operation);
+2 -2
View File
@@ -2,7 +2,7 @@
Dear Cloudron Team,
unfortunately the <%= program %> on <%= fqdn %> crashed unexpectedly!
Unfortunately <%= program %> on <%= fqdn %> crashed unexpectedly!
Please see some excerpt of the logs below.
@@ -11,7 +11,7 @@ Your Cloudron
-------------------------------------
<%= context %>
<%- context %>
<% } else { %>
+192
View File
@@ -0,0 +1,192 @@
'use strict';
exports = module.exports = {
start: start,
stop: stop
};
var appdb = require('./appdb.js'),
assert = require('assert'),
clientdb = require('./clientdb.js'),
config = require('./config.js'),
debug = require('debug')('box:proxy'),
express = require('express'),
http = require('http'),
proxy = require('proxy-middleware'),
session = require('cookie-session'),
superagent = require('superagent'),
url = require('url'),
uuid = require('node-uuid');
var gSessions = {};
var gProxyMiddlewareCache = {};
var gHttpServer = null;
var CALLBACK_URI = '/callback';
function attachSessionData(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();
}
function verifySession(req, res, next) {
assert.strictEqual(typeof req.sessionData, 'object');
if (!req.sessionData.accessToken) {
req.authenticated = false;
return next();
}
// use http admin origin so that it works with self-signed certs
superagent
.get(config.internalAdminOrigin() + '/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();
});
}
function authenticate(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
};
// use http admin origin so that it works with self-signed certs
superagent
.post(config.internalAdminOrigin() + '/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);
});
});
}
}
function forwardRequestToApp(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);
}
function initializeServer() {
var app = express();
var httpServer = http.createServer(app);
httpServer.on('error', console.error);
app
.use(session({ keys: ['blue', 'cheese', 'is', 'something'] }))
.use(attachSessionData)
.use(verifySession)
.use(authenticate)
.use(forwardRequestToApp);
return httpServer;
}
function start(callback) {
assert.strictEqual(typeof callback, 'function');
gHttpServer = initializeServer();
gHttpServer.listen(config.get('oauthProxyPort'), callback);
}
function stop(callback) {
assert.strictEqual(typeof callback, 'function');
gHttpServer.close(callback);
}
+3 -1
View File
@@ -24,5 +24,7 @@ exports = module.exports = {
CLOUDRON_AVATAR_FILE: path.join(config.baseDir(), 'data/box/avatar.png'),
CLOUDRON_DEFAULT_AVATAR_FILE: path.join(__dirname + '/../assets/avatar.png'),
FAVICON_FILE: path.join(__dirname + '/../assets/favicon.ico')
FAVICON_FILE: path.join(__dirname + '/../assets/favicon.ico'),
UPDATE_CHECKER_FILE: path.join(config.baseDir(), 'data/box/updatechecker.json')
};
+7 -4
View File
@@ -7,6 +7,7 @@ exports = module.exports = {
};
var cloudron = require('../cloudron.js'),
CloudronError = require('../cloudron.js').CloudronError,
debug = require('debug')('box:routes/internal'),
HttpError = require('connect-lastmile').HttpError,
HttpSuccess = require('connect-lastmile').HttpSuccess;
@@ -14,10 +15,12 @@ var cloudron = require('../cloudron.js'),
function backup(req, res, next) {
debug('trigger backup');
// note that cloudron.backup only waits for backup initiation and not for backup to complete
// backup progress can be checked up ny polling the progress api call
cloudron.backup(function (error) {
if (error) debug('Internal route backup failed', error);
});
if (error && error.reason === CloudronError.BAD_STATE) return next(new HttpError(409, error.message));
if (error) return next(new HttpError(500, error));
// we always succeed to trigger a backup
next(new HttpSuccess(202, {}));
next(new HttpSuccess(202, {}));
});
}
+103 -46
View File
@@ -22,6 +22,7 @@ var appdb = require('../../appdb.js'),
hock = require('hock'),
http = require('http'),
https = require('https'),
js2xml = require('js2xmlparser'),
net = require('net'),
nock = require('nock'),
os = require('os'),
@@ -31,7 +32,6 @@ var appdb = require('../../appdb.js'),
safe = require('safetydance'),
server = require('../../server.js'),
settings = require('../../settings.js'),
sysinfo = require('../../sysinfo.js'),
tokendb = require('../../tokendb.js'),
url = require('url'),
util = require('util'),
@@ -51,6 +51,21 @@ var USERNAME_1 = 'user', PASSWORD_1 = 'password', EMAIL_1 ='user@me.com';
var token = null; // authentication token
var token_1 = null;
var awsHostedZones = {
HostedZones: [{
Id: '/hostedzone/ZONEID',
Name: 'localhost.',
CallerReference: '305AFD59-9D73-4502-B020-F4E6F889CB30',
ResourceRecordSetCount: 2,
ChangeInfo: {
Id: '/change/CKRTFJA0ANHXB',
Status: 'INSYNC'
}
}],
IsTruncated: false,
MaxItems: '100'
};
function startDockerProxy(interceptor, callback) {
assert.strictEqual(typeof interceptor, 'function');
@@ -79,10 +94,12 @@ function startDockerProxy(interceptor, callback) {
function setup(done) {
async.series([
server.start.bind(server),
// first clear, then start server. otherwise, taskmanager spins up tasks for obsolete appIds
database.initialize,
database._clear,
server.start.bind(server),
function (callback) {
var scope1 = nock(config.apiServerOrigin()).get('/api/v1/boxes/' + config.fqdn() + '/setup/verify?setupToken=somesetuptoken').reply(200, {});
var scope2 = nock(config.apiServerOrigin()).post('/api/v1/boxes/' + config.fqdn() + '/setup/done?setupToken=somesetuptoken').reply(201, {});
@@ -104,11 +121,11 @@ function setup(done) {
});
},
child_process.exec.bind(null, __dirname + '/start_addons.sh'),
function (callback) {
callback(null);
console.log('Starting addons, this can take 10 seconds');
child_process.exec(__dirname + '/start_addons.sh', callback);
},
function (callback) {
request.post(SERVER_URL + '/api/v1/users')
.query({ access_token: token })
@@ -129,11 +146,12 @@ function setup(done) {
}
function cleanup(done) {
// db is not cleaned up here since it's too late to call it after server.stop. if called before server.stop taskmanager apptasks are unhappy :/
async.series([
database._clear,
server.stop,
function (callback) { setTimeout(callback, 2000); }, // give taskmanager tasks couple of seconds to finish
child_process.exec.bind(null, 'docker rm -f mysql; docker rm -f postgresql; docker rm -f mongodb')
], done);
}
@@ -143,10 +161,19 @@ describe('App API', function () {
var dockerProxy;
before(function (done) {
dockerProxy = startDockerProxy(function interceptor() { return false; }, function () {
dockerProxy = startDockerProxy(function interceptor(req, res) {
if (req.method === 'DELETE' && req.url === '/images/c7ddfc8fb7cd8a14d4d70153a199ff0c6e9b709807aeec5a7b799d60618731d1?force=true&noprune=false') {
res.writeHead(200);
res.end();
return true;
}
return false;
}, function () {
setup(done);
});
});
after(function (done) {
APP_ID = null;
cleanup(function () {
@@ -473,7 +500,8 @@ describe('App API', function () {
describe('App installation', function () {
this.timeout(50000);
var hockInstance = hock.createHock({ throwOnUnmatched: false }), hockServer, dockerProxy;
var apiHockInstance = hock.createHock({ throwOnUnmatched: false }), apiHockServer, dockerProxy;
var awsHockInstance = hock.createHock({ throwOnUnmatched: false }), awsHockServer;
var imageDeleted = false, imageCreated = false;
before(function (done) {
@@ -500,28 +528,42 @@ describe('App installation', function () {
setup,
function (callback) {
hockInstance
apiHockInstance
.get('/api/v1/apps/' + APP_STORE_ID + '/versions/' + APP_MANIFEST.version + '/icon')
.replyWithFile(200, path.resolve(__dirname, '../../../webadmin/src/img/appicon_fallback.png'))
.post('/api/v1/subdomains?token=' + config.token(), { records: [ { subdomain: APP_LOCATION, type: 'A', value: sysinfo.getIp() } ] })
.reply(201, { ids: [ 'dnsrecordid' ] }, { 'Content-Type': 'application/json' })
.delete('/api/v1/subdomains/dnsrecordid?token=' + config.token())
.reply(204, { }, { 'Content-Type': 'application/json' });
.post('/api/v1/boxes/' + config.fqdn() + '/awscredentials?token=APPSTORE_TOKEN')
.max(Infinity)
.reply(201, { credentials: { AccessKeyId: 'accessKeyId', SecretAccessKey: 'secretAccessKey', SessionToken: 'sessionToken' } }, { 'Content-Type': 'application/json' });
var port = parseInt(url.parse(config.apiServerOrigin()).port, 10);
hockServer = http.createServer(hockInstance.handler).listen(port, callback);
apiHockServer = http.createServer(apiHockInstance.handler).listen(port, callback);
},
function (callback) {
awsHockInstance
.get('/2013-04-01/hostedzone')
.max(Infinity)
.reply(200, js2xml('ListHostedZonesResponse', awsHostedZones, { arrayMap: { HostedZones: 'HostedZone'} }), { 'Content-Type': 'application/xml' })
.filteringRequestBody(function (unusedBody) { return ''; }) // strip out body
.post('/2013-04-01/hostedzone/ZONEID/rrset/')
.max(Infinity)
.reply(200, js2xml('ChangeResourceRecordSetsResponse', { ChangeInfo: { Id: 'dnsrecordid', Status: 'INSYNC' } }), { 'Content-Type': 'application/xml' });
var port = parseInt(url.parse(config.aws().endpoint).port, 10);
awsHockServer = http.createServer(awsHockInstance.handler).listen(port, callback);
}
], done);
});
after(function (done) {
APP_ID = null;
cleanup(function (error) {
if (error) return done(error);
hockServer.close(function () {
dockerProxy.close(done);
});
});
async.series([
cleanup,
apiHockServer.close.bind(apiHockServer),
awsHockServer.close.bind(awsHockServer),
dockerProxy.close.bind(dockerProxy)
], done);
});
var appResult = null /* the json response */, appEntry = null /* entry from database */;
@@ -882,9 +924,13 @@ describe('App installation', function () {
});
it('uninstalled - unregistered subdomain', function (done) {
hockInstance.done(function (error) { // checks if all the hockServer APIs were called
apiHockInstance.done(function (error) { // checks if all the apiHockServer APIs were called
expect(!error).to.be.ok();
done();
awsHockInstance.done(function (error) {
expect(!error).to.be.ok();
done();
});
});
});
@@ -904,7 +950,8 @@ describe('App installation', function () {
describe('App installation - port bindings', function () {
this.timeout(50000);
var hockInstance = hock.createHock({ throwOnUnmatched: false }), hockServer, dockerProxy;
var apiHockInstance = hock.createHock({ throwOnUnmatched: false }), apiHockServer, dockerProxy;
var awsHockInstance = hock.createHock({ throwOnUnmatched: false }), awsHockServer;
var imageDeleted = false, imageCreated = false;
before(function (done) {
@@ -930,35 +977,41 @@ describe('App installation - port bindings', function () {
setup,
function (callback) {
hockInstance
// app install
apiHockInstance
.get('/api/v1/apps/' + APP_STORE_ID + '/versions/' + APP_MANIFEST.version + '/icon')
.replyWithFile(200, path.resolve(__dirname, '../../../webadmin/src/img/appicon_fallback.png'))
.post('/api/v1/subdomains?token=' + config.token(), { records: [ { subdomain: APP_LOCATION, type: 'A', value: sysinfo.getIp() } ] })
.reply(201, { ids: [ 'dnsrecordid' ] }, { 'Content-Type': 'application/json' })
// app configure
.delete('/api/v1/subdomains/dnsrecordid?token=' + config.token())
.reply(204, { }, { 'Content-Type': 'application/json' })
.post('/api/v1/subdomains?token=' + config.token(), { records: [ { subdomain: APP_LOCATION_NEW, type: 'A', value: sysinfo.getIp() } ] })
.reply(201, { ids: [ 'anotherdnsid' ] }, { 'Content-Type': 'application/json' })
// app remove
.delete('/api/v1/subdomains/anotherdnsid?token=' + config.token())
.reply(204, { }, { 'Content-Type': 'application/json' });
.post('/api/v1/boxes/' + config.fqdn() + '/awscredentials?token=APPSTORE_TOKEN')
.max(Infinity)
.reply(201, { credentials: { AccessKeyId: 'accessKeyId', SecretAccessKey: 'secretAccessKey', SessionToken: 'sessionToken' } }, { 'Content-Type': 'application/json' });
var port = parseInt(url.parse(config.apiServerOrigin()).port, 10);
hockServer = http.createServer(hockInstance.handler).listen(port, callback);
apiHockServer = http.createServer(apiHockInstance.handler).listen(port, callback);
},
function (callback) {
awsHockInstance
.get('/2013-04-01/hostedzone')
.max(Infinity)
.reply(200, js2xml('ListHostedZonesResponse', awsHostedZones, { arrayMap: { HostedZones: 'HostedZone'} }), { 'Content-Type': 'application/xml' })
.filteringRequestBody(function (unusedBody) { return ''; }) // strip out body
.post('/2013-04-01/hostedzone/ZONEID/rrset/')
.max(Infinity)
.reply(200, js2xml('ChangeResourceRecordSetsResponse', { ChangeInfo: { Id: 'dnsrecordid', Status: 'INSYNC' } }), { 'Content-Type': 'application/xml' });
var port = parseInt(url.parse(config.aws().endpoint).port, 10);
awsHockServer = http.createServer(awsHockInstance.handler).listen(port, callback);
}
], done);
});
after(function (done) {
APP_ID = null;
cleanup(function (error) {
if (error) return done(error);
hockServer.close(function () {
dockerProxy.close(done);
});
});
async.series([
cleanup,
apiHockServer.close.bind(apiHockServer),
awsHockServer.close.bind(awsHockServer),
dockerProxy.close.bind(dockerProxy)
], done);
});
var appResult = null, appEntry = null;
@@ -1302,9 +1355,13 @@ describe('App installation - port bindings', function () {
});
it('uninstalled - unregistered subdomain', function (done) {
hockInstance.done(function (error) { // checks if all the hockServer APIs were called
apiHockInstance.done(function (error) { // checks if all the apiHockServer APIs were called
expect(!error).to.be.ok();
done();
awsHockInstance.done(function (error) {
expect(!error).to.be.ok();
done();
});
});
});
+2 -2
View File
@@ -119,8 +119,8 @@ describe('Backups API', function () {
it('succeeds', function (done) {
var scope = nock(config.apiServerOrigin())
.get('/api/v1/boxes/' + config.fqdn() + '/awscredentials?token=APPSTORE_TOKEN')
.reply(201, { accessKeyId: 'accessKeyId', secretAccessKey: 'secretAccessKey', sessionToken: 'sessionToken' });
.post('/api/v1/boxes/' + config.fqdn() + '/awscredentials?token=APPSTORE_TOKEN')
.reply(201, { credentials: { AccessKeyId: 'accessKeyId', SecretAccessKey: 'secretAccessKey', SessionToken: 'sessionToken' } });
request.post(SERVER_URL + '/api/v1/backups')
.query({ access_token: token })
+2 -2
View File
@@ -591,8 +591,8 @@ describe('Clients', function () {
email: 'some@email.com',
admin: true,
salt: 'somesalt',
createdAt: (new Date()).toUTCString(),
modifiedAt: (new Date()).toUTCString(),
createdAt: (new Date()).toISOString(),
modifiedAt: (new Date()).toISOString(),
resetToken: hat(256)
};
+22 -9
View File
@@ -451,17 +451,19 @@ describe('Cloudron', function () {
it('fails when in wrong state', function (done) {
var scope2 = nock(config.apiServerOrigin())
.get('/api/v1/boxes/' + config.fqdn() + '/awscredentials?token=APPSTORE_TOKEN')
.reply(201, { credentials: { accessKeyId: 'accessKeyId', secretAccessKey: 'secretAccessKey', sessionToken: 'sessionToken' } });
.post('/api/v1/boxes/' + config.fqdn() + '/awscredentials?token=APPSTORE_TOKEN')
.reply(201, { credentials: { AccessKeyId: 'accessKeyId', SecretAccessKey: 'secretAccessKey', SessionToken: 'sessionToken' } });
var scope3 = nock(config.apiServerOrigin())
.filteringRequestBody(function () { return false; })
.post('/api/v1/boxes/' + config.fqdn() + '/backupDone?token=APPSTORE_TOKEN')
.post('/api/v1/boxes/' + config.fqdn() + '/backupDone?token=APPSTORE_TOKEN', function (body) {
return body.boxVersion && body.restoreKey && !body.appId && !body.appVersion && body.appBackupIds.length === 0;
})
.reply(200, { id: 'someid' });
var scope1 = nock(config.apiServerOrigin())
.filteringRequestBody(function () { return false; })
.post('/api/v1/boxes/' + config.fqdn() + '/migrate?token=APPSTORE_TOKEN', { }).reply(409, {});
.post('/api/v1/boxes/' + config.fqdn() + '/migrate?token=APPSTORE_TOKEN', function (body) {
return body.size && body.region && body.restoreKey;
}).reply(409, {});
injectShellMock();
@@ -487,8 +489,19 @@ describe('Cloudron', function () {
it('succeeds', function (done) {
var scope1 = nock(config.apiServerOrigin()).post('/api/v1/boxes/' + config.fqdn() + '/migrate?token=APPSTORE_TOKEN', { size: 'small', region: 'sfo', restoreKey: 'someid' }).reply(202, {});
var scope2 = nock(config.apiServerOrigin()).put('/api/v1/boxes/' + config.fqdn() + '/backupurl?token=APPSTORE_TOKEN', { boxVersion: '0.5.0', appId: null, appVersion: null, appBackupIds: [] }).reply(201, { id: 'someid', url: 'http://foobar', backupKey: 'somerestorekey' });
var scope1 = nock(config.apiServerOrigin()).post('/api/v1/boxes/' + config.fqdn() + '/migrate?token=APPSTORE_TOKEN', function (body) {
return body.size && body.region && body.restoreKey;
}).reply(202, {});
var scope2 = nock(config.apiServerOrigin())
.post('/api/v1/boxes/' + config.fqdn() + '/backupDone?token=APPSTORE_TOKEN', function (body) {
return body.boxVersion && body.restoreKey && !body.appId && !body.appVersion && body.appBackupIds.length === 0;
})
.reply(200, { id: 'someid' });
var scope3 = nock(config.apiServerOrigin())
.post('/api/v1/boxes/' + config.fqdn() + '/awscredentials?token=APPSTORE_TOKEN')
.reply(201, { credentials: { AccessKeyId: 'accessKeyId', SecretAccessKey: 'secretAccessKey', SessionToken: 'sessionToken' } });
injectShellMock();
@@ -500,7 +513,7 @@ describe('Cloudron', function () {
expect(result.statusCode).to.equal(202);
function checkAppstoreServerCalled() {
if (scope1.isDone() && scope2.isDone()) {
if (scope1.isDone() && scope2.isDone() && scope3.isDone()) {
restoreShellMock();
return done();
}
+2 -2
View File
@@ -21,7 +21,7 @@ readonly program_name=$1
echo "${program_name}.log"
echo "-------------------"
tail --lines=100 /var/log/supervisor/${program_name}.log
journalctl --no-pager -u ${program_name} -n 100
echo
echo
echo "dmesg"
@@ -31,7 +31,7 @@ echo
echo
echo "docker"
echo "------"
tail --lines=100 /var/log/upstart/docker.log
journalctl --no-pager -u docker -n 50
echo
echo
-6
View File
@@ -12,12 +12,6 @@ if [[ $# == 1 && "$1" == "--check" ]]; then
exit 0
fi
if [[ "${OSTYPE}" == "darwin"* ]]; then
# On Mac, brew installs supervisor in /usr/local/bin
export PATH=$PATH:/usr/local/bin
fi
if [[ "${BOX_ENV}" == "cloudron" ]]; then
nginx -s reload
fi
+1
View File
@@ -199,6 +199,7 @@ function initializeExpressSync() {
return httpServer;
}
// provides hooks for the 'installer'
function initializeInternalExpressSync() {
var app = express();
var httpServer = http.createServer(app);
+1 -4
View File
@@ -42,12 +42,9 @@ var assert = require('assert'),
_ = require('underscore');
var gDefaults = (function () {
var tz = safe.fs.readFileSync('/etc/timezone', 'utf8');
tz = tz ? tz.trim() : 'America/Los_Angeles';
var result = { };
result[exports.AUTOUPDATE_PATTERN_KEY] = '00 00 1,3,5,23 * * *';
result[exports.TIME_ZONE_KEY] = tz;
result[exports.TIME_ZONE_KEY] = 'America/Los_Angeles';
result[exports.CLOUDRON_NAME_KEY] = 'Cloudron';
result[exports.DEVELOPER_MODE_KEY] = false;
+1 -7
View File
@@ -28,12 +28,6 @@ function SubdomainError(reason, errorOrMessage) {
util.inherits(SubdomainError, Error);
SubdomainError.NOT_FOUND = 'No such domain';
SubdomainError.INTERNAL_ERROR = 'Internal error';
SubdomainError.EXTERNAL_ERROR = 'External error';
SubdomainError.STILL_BUSY = 'Still busy';
SubdomainError.FAILED_TOO_OFTEN = 'Failed too often';
SubdomainError.ALREADY_EXISTS = 'Domain already exists';
SubdomainError.BAD_FIELD = 'Bad Field';
SubdomainError.BAD_STATE = 'Bad State';
SubdomainError.INVALID_ZONE_NAME = 'Invalid domain name';
SubdomainError.INVALID_TASK = 'Invalid task';
SubdomainError.MISSING_CREDENTIALS = 'Missing credentials';
+10 -5
View File
@@ -5,6 +5,7 @@
var assert = require('assert'),
async = require('async'),
aws = require('./aws.js'),
caas = require('./caas.js'),
config = require('./config.js'),
debug = require('debug')('box:subdomains'),
util = require('util'),
@@ -17,6 +18,12 @@ module.exports = exports = {
status: status
};
// choose which subdomain backend we use
// for test purpose we use aws
function api() {
return config.token() && !config.TEST ? caas : aws;
}
function add(record, callback) {
assert.strictEqual(typeof record, 'object');
assert.strictEqual(typeof record.subdomain, 'string');
@@ -26,7 +33,7 @@ function add(record, callback) {
debug('add: ', record);
aws.addSubdomain(config.zoneName(), record.subdomain, record.type, record.value, function (error, changeId) {
api().addSubdomain(config.zoneName(), record.subdomain, record.type, record.value, function (error, changeId) {
if (error) return callback(error);
callback(null, changeId);
});
@@ -60,7 +67,7 @@ function remove(record, callback) {
debug('remove: ', record);
aws.delSubdomain(config.zoneName(), record.subdomain, record.type, record.value, function (error) {
api().delSubdomain(config.zoneName(), record.subdomain, record.type, record.value, function (error) {
if (error && error.reason !== SubdomainError.NOT_FOUND) return callback(error);
debug('deleteSubdomain: successfully deleted subdomain from aws.');
@@ -73,9 +80,7 @@ function status(changeId, callback) {
assert.strictEqual(typeof changeId, 'string');
assert.strictEqual(typeof callback, 'function');
debug('status: ', changeId);
aws.getChangeStatus(changeId, function (error, status) {
api().getChangeStatus(changeId, function (error, status) {
if (error) return callback(new SubdomainError(SubdomainError.EXTERNAL_ERROR, error));
callback(null, status === 'INSYNC' ? 'done' : 'pending');
});
+14 -8
View File
@@ -17,10 +17,7 @@ var appdb = require('./appdb.js'),
var gActiveTasks = { };
var gPendingTasks = [ ];
// Task concurrency is 1 for two reasons:
// 1. The backup scripts (app and box) turn off swap after finish disregarding other backup processes
// 2. apptask getFreePort has race with multiprocess
var TASK_CONCURRENCY = 1;
var TASK_CONCURRENCY = 5;
var NOOP_CALLBACK = function (error) { console.error(error); };
function initialize(callback) {
@@ -31,6 +28,8 @@ function initialize(callback) {
if (error) return callback(error);
apps.forEach(function (app) {
if (app.installationState === appdb.ISTATE_INSTALLED && app.runState === appdb.RSTATE_RUNNING) return;
debug('Creating process for %s (%s) with state %s', app.location, app.id, app.installationState);
startAppTask(app.id);
});
@@ -54,7 +53,8 @@ function uninitialize(callback) {
function startNextTask() {
if (gPendingTasks.length === 0) return;
assert.strictEqual(Object.keys(gActiveTasks).length, 0); // since we allow only one task at a time
assert(Object.keys(gActiveTasks).length < TASK_CONCURRENCY);
startAppTask(gPendingTasks.shift());
}
@@ -63,14 +63,20 @@ function startAppTask(appId) {
assert.strictEqual(typeof appId, 'string');
assert(!(appId in gActiveTasks));
var lockError = locker.lock(locker.OP_APPTASK);
if (lockError || Object.keys(gActiveTasks).length >= TASK_CONCURRENCY) {
if (Object.keys(gActiveTasks).length >= TASK_CONCURRENCY) {
debug('Reached concurrency limit, queueing task for %s', appId);
gPendingTasks.push(appId);
return;
}
var lockError = locker.recursiveLock(locker.OP_APPTASK);
if (lockError) {
debug('Locked for another operation, queueing task for %s', appId);
gPendingTasks.push(appId);
return;
}
gActiveTasks[appId] = child_process.fork(__dirname + '/apptask.js', [ appId ]);
gActiveTasks[appId].once('exit', function (code) {
debug('Task for %s completed with status %s', appId, code);
+38 -7
View File
@@ -13,10 +13,10 @@ var addons = require('../addons.js'),
database = require('../database.js'),
expect = require('expect.js'),
fs = require('fs'),
js2xml = require('js2xmlparser'),
net = require('net'),
nock = require('nock'),
paths = require('../paths.js'),
sysinfo = require('../sysinfo.js'),
_ = require('underscore');
var MANIFEST = {
@@ -61,6 +61,21 @@ var APP = {
dnsRecordId: 'someDnsRecordId'
};
var awsHostedZones = {
HostedZones: [{
Id: '/hostedzone/ZONEID',
Name: 'localhost.',
CallerReference: '305AFD59-9D73-4502-B020-F4E6F889CB30',
ResourceRecordSetCount: 2,
ChangeInfo: {
Id: '/change/CKRTFJA0ANHXB',
Status: 'INSYNC'
}
}],
IsTruncated: false,
MaxItems: '100'
};
describe('apptask', function () {
before(function (done) {
config.set('version', '0.5.0');
@@ -154,7 +169,7 @@ describe('apptask', function () {
it('barfs on bad manifest', function (done) {
var badApp = _.extend({ }, APP);
badApp.manifest = _.extend({ }, APP.manifest);
delete badApp.manifest['id'];
delete badApp.manifest.id;
apptask._verifyManifest(badApp, function (error) {
expect(error).to.be.ok();
@@ -185,12 +200,20 @@ describe('apptask', function () {
it('registers subdomain', function (done) {
nock.cleanAll();
var scope = nock(config.apiServerOrigin())
.post('/api/v1/subdomains?token=' + config.token(), { records: [ { subdomain: APP.location, type: 'A', value: sysinfo.getIp() } ] })
.reply(201, { ids: [ APP.dnsRecordId ] });
.post('/api/v1/boxes/' + config.fqdn() + '/awscredentials?token=APPSTORE_TOKEN')
.times(2)
.reply(201, { credentials: { AccessKeyId: 'accessKeyId', SecretAccessKey: 'secretAccessKey', SessionToken: 'sessionToken' } });
var awsScope = nock(config.aws().endpoint)
.get('/2013-04-01/hostedzone')
.reply(200, js2xml('ListHostedZonesResponse', awsHostedZones, { arrayMap: { HostedZones: 'HostedZone'} }))
.post('/2013-04-01/hostedzone/ZONEID/rrset/')
.reply(200, js2xml('ChangeResourceRecordSetsResponse', { ChangeInfo: { Id: 'RRID', Status: 'INSYNC' } }));
apptask._registerSubdomain(APP, function (error) {
expect(error).to.be(null);
expect(scope.isDone()).to.be.ok();
expect(awsScope.isDone()).to.be.ok();
done();
});
});
@@ -198,12 +221,20 @@ describe('apptask', function () {
it('unregisters subdomain', function (done) {
nock.cleanAll();
var scope = nock(config.apiServerOrigin())
.delete('/api/v1/subdomains/' + APP.dnsRecordId + '?token=' + config.token())
.reply(204, {});
.post('/api/v1/boxes/' + config.fqdn() + '/awscredentials?token=APPSTORE_TOKEN')
.times(2)
.reply(201, { credentials: { AccessKeyId: 'accessKeyId', SecretAccessKey: 'secretAccessKey', SessionToken: 'sessionToken' } });
apptask._unregisterSubdomain(APP, function (error) {
var awsScope = nock(config.aws().endpoint)
.get('/2013-04-01/hostedzone')
.reply(200, js2xml('ListHostedZonesResponse', awsHostedZones, { arrayMap: { HostedZones: 'HostedZone'} }))
.post('/2013-04-01/hostedzone/ZONEID/rrset/')
.reply(200, js2xml('ChangeResourceRecordSetsResponse', { ChangeInfo: { Id: 'RRID', Status: 'INSYNC' } }));
apptask._unregisterSubdomain(APP, APP.location, function (error) {
expect(error).to.be(null);
expect(scope.isDone()).to.be.ok();
expect(awsScope.isDone()).to.be.ok();
done();
});
});
-2
View File
@@ -6,8 +6,6 @@
'use strict';
require('supererror', { splatchError: true});
var database = require('../database.js'),
expect = require('expect.js'),
EventEmitter = require('events').EventEmitter,
+29 -12
View File
@@ -10,24 +10,27 @@ exports = module.exports = {
};
var apps = require('./apps.js'),
assert = require('assert'),
async = require('async'),
config = require('./config.js'),
debug = require('debug')('box:updatechecker'),
fs = require('fs'),
mailer = require('./mailer.js'),
path = require('path'),
paths = require('./paths.js'),
safe = require('safetydance'),
semver = require('semver'),
superagent = require('superagent'),
util = require('util');
var NOOP_CALLBACK = function (error) { console.error(error); };
var gAppUpdateInfo = { }, // id -> update info { creationDate, manifest }
gBoxUpdateInfo = null,
gMailedUser = { };
gBoxUpdateInfo = null;
function loadState() {
var state = safe.JSON.parse(safe.fs.readFileSync(paths.UPDATE_CHECKER_FILE, 'utf8'));
return state || { };
}
function saveState(mailedUser) {
safe.fs.writeFileSync(paths.UPDATE_CHECKER_FILE, JSON.stringify(mailedUser, null, 4), 'utf8');
}
function getUpdateInfo() {
return {
@@ -122,13 +125,21 @@ function getBoxUpdates(callback) {
function checkAppUpdates() {
debug('Checking App Updates');
var oldState = loadState();
var newState = { box: oldState.box }; // creaee new state so that old app ids are removed
getAppUpdates(function (error, result) {
if (error) debug('Error checking app updates: ', error);
gAppUpdateInfo = error ? {} : result;
async.eachSeries(Object.keys(gAppUpdateInfo), function iterator(id, iteratorDone) {
if (gMailedUser[id]) return iteratorDone();
newState[id] = gAppUpdateInfo[id].manifest.version;
if (oldState[id] === gAppUpdateInfo[id].manifest.version) {
debug('Skipping notification of app update %s since user was already notified', id);
return iteratorDone();
}
apps.get(id, function (error, app) {
if (error) {
@@ -137,8 +148,10 @@ function checkAppUpdates() {
}
mailer.appUpdateAvailable(app, gAppUpdateInfo[id]);
gMailedUser[id] = true;
iteratorDone();
});
}, function () {
saveState(newState);
});
});
}
@@ -146,15 +159,19 @@ function checkAppUpdates() {
function checkBoxUpdates() {
debug('Checking Box Updates');
var state = loadState();
getBoxUpdates(function (error, result) {
if (error) debug('Error checking box updates: ', error);
gBoxUpdateInfo = error ? null : result;
if (gBoxUpdateInfo && !gMailedUser['box']) {
if (gBoxUpdateInfo && state.box !== gBoxUpdateInfo.version) {
mailer.boxUpdateAvailable(gBoxUpdateInfo.version, gBoxUpdateInfo.changelog);
gMailedUser['box'] = true;
state.box = gBoxUpdateInfo.version;
saveState(state);
} else {
debug('Skipping notification of box update as user was already notified');
}
});
}
+6 -6
View File
@@ -134,7 +134,7 @@ function createUser(username, password, email, admin, invitor, callback) {
crypto.pbkdf2(password, salt, CRYPTO_ITERATIONS, CRYPTO_KEY_LENGTH, function (error, derivedKey) {
if (error) return callback(new UserError(UserError.INTERNAL_ERROR, error));
var now = (new Date()).toUTCString();
var now = (new Date()).toISOString();
var user = {
id: username,
username: username,
@@ -203,17 +203,17 @@ function verifyWithEmail(email, password, callback) {
});
}
function removeUser(username, callback) {
assert.strictEqual(typeof username, 'string');
function removeUser(userId, callback) {
assert.strictEqual(typeof userId, 'string');
assert.strictEqual(typeof callback, 'function');
userdb.del(username, function (error) {
userdb.del(userId, function (error) {
if (error && error.reason === DatabaseError.NOT_FOUND) return callback(new UserError(UserError.NOT_FOUND));
if (error) return callback(new UserError(UserError.INTERNAL_ERROR, error));
callback(null);
mailer.userRemoved(username);
mailer.userRemoved(userId);
});
}
@@ -331,7 +331,7 @@ function setPassword(userId, newPassword, callback) {
crypto.pbkdf2(newPassword, saltBuffer, CRYPTO_ITERATIONS, CRYPTO_KEY_LENGTH, function (error, derivedKey) {
if (error) return callback(new UserError(UserError.INTERNAL_ERROR, error));
user.modifiedAt = (new Date()).toUTCString();
user.modifiedAt = (new Date()).toISOString();
user.password = new Buffer(derivedKey, 'binary').toString('hex');
user.resetToken = '';
+30 -26
View File
@@ -8,7 +8,9 @@
<link href="/api/v1/cloudron/avatar" rel="icon" type="image/png">
<!-- Theme CSS -->
<!-- CSS -->
<link rel="stylesheet" type="text/css" href="/3rdparty/slick.css"/>
<link rel="stylesheet" type="text/css" href="/3rdparty/angular-ui-notification.min.css"/>
<link href="theme.css" rel="stylesheet">
<!-- Custom Fonts -->
@@ -25,12 +27,8 @@
<script src="3rdparty/js/bootstrap.min.js"></script>
<!-- Slick carousel -->
<link rel="stylesheet" type="text/css" href="/3rdparty/slick.css"/>
<script type="text/javascript" src="3rdparty/js/slick.js"></script>
<!-- Additional stylesheets -->
<link rel="stylesheet" type="text/css" href="/3rdparty/angular-ui-notification.min.css"/>
<!-- Angularjs scripts -->
<script src="3rdparty/js/angular.min.js"></script>
<script src="3rdparty/js/angular-loader.min.js"></script>
@@ -75,27 +73,34 @@
<br/>
<br/>
</div>
<p>This update will install version <b>{{config.update.box.version}}</b> on your Cloudron.</p>
<p>Recent Changes:</p>
<ul>
<li ng-repeat="change in config.update.box.changelog">{{change}}</li>
</ul>
<br/>
<fieldset ng-show="installedApps | readyToUpdate">
<form name="update_form" role="form" ng-submit="doUpdate()" autocomplete="off">
<div class="form-group" ng-class="{ 'has-error': update_form.password.$dirty && update_form.password.$invalid }">
<label class="control-label" for="inputUpdatePassword">Give your password to verify that you are performing that action</label>
<div class="control-label" ng-show="(!update_form.password.$dirty && update.error.password) || (update_form.password.$dirty && update_form.password.$invalid)">
<small ng-show="update_form.password.$error.required && !update.error.password">A password is required</small>
<small ng-show="update_form.password.$error.minlength">The password is too short</small>
<small ng-show="update_form.password.$error.maxlength">The password is too long</small>
<small ng-show="update.error.password">Incorrect password</small>
<div ng-show="installedApps | readyToUpdate">
<b ng-show="config.update.box.upgrade" class="text-danger">
The update is a base system upgrade.<br/>
This will cause some application downtime!<br/>
<br/>
</b>
<p>New version: <b>{{config.update.box.version}}</b></p>
<p>Recent Changes:</p>
<ul>
<li ng-repeat="change in config.update.box.changelog">{{change}}</li>
</ul>
<br/>
<fieldset>
<form name="update_form" role="form" ng-submit="doUpdate()" autocomplete="off">
<div class="form-group" ng-class="{ 'has-error': update_form.password.$dirty && update_form.password.$invalid }">
<label class="control-label" for="inputUpdatePassword">Give your password to verify that you are performing that action</label>
<div class="control-label" ng-show="(!update_form.password.$dirty && update.error.password) || (update_form.password.$dirty && update_form.password.$invalid)">
<small ng-show="update_form.password.$error.required && !update.error.password">A password is required</small>
<small ng-show="update_form.password.$error.minlength">The password is too short</small>
<small ng-show="update_form.password.$error.maxlength">The password is too long</small>
<small ng-show="update.error.password">Incorrect password</small>
</div>
<input type="password" class="form-control" ng-model="update.password" id="inputUpdatePassword" name="password" placeholder="Password" ng-maxlength="512" ng-minlength="5" required autofocus>
</div>
<input type="password" class="form-control" ng-model="update.password" id="inputUpdatePassword" name="password" placeholder="Password" ng-maxlength="512" ng-minlength="5" required autofocus>
</div>
<input class="ng-hide" type="submit" ng-disabled="update_form.$invalid || update.busy"/>
</form>
</fieldset>
<input class="ng-hide" type="submit" ng-disabled="update_form.$invalid || update.busy"/>
</form>
</fieldset>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">Close</button>
@@ -144,7 +149,6 @@
<li><a href="#/account"><i class="fa fa-user fa-fw"></i> Account</a></li>
<li ng-show="user.admin && config.isDev"><a href="#/dns"><i class="fa fa-wrench fa-fw"></i> DNS Management</a></li>
<li ng-show="user.admin"><a href="#/graphs"><i class="fa fa-bar-chart fa-fw"></i> Graphs</a></li>
<li ng-show="user.admin"><a href="#/upgrade"><i class="fa fa-arrow-up fa-fw"></i> Upgrade</a></li>
<li><a href="#/support"><i class="fa fa-comment fa-fw"></i> Support</a></li>
<li ng-show="user.admin"><a href="#/settings"><i class="fa fa-wrench fa-fw"></i> Settings</a></li>
<li class="divider"></li>
+17 -2
View File
@@ -23,7 +23,7 @@ angular.module('Application').service('Client', ['$http', 'md5', 'Notification',
function defaultErrorHandler(callback) {
return function (data, status) {
if (status === 401) return client.logout();
if (status === 401) return client.login();
if (status === 503) {
// this could indicate a update/upgrade/restore/migration
client.progress(function (error, result) {
@@ -605,12 +605,27 @@ angular.module('Application').service('Client', ['$http', 'md5', 'Notification',
});
};
Client.prototype.login = function () {
this.setToken(null);
this._userInfo = {};
var callbackURL = window.location.protocol + '//' + window.location.host + '/login_callback.html';
var scope = 'root,profile,apps,roleAdmin';
// generate a state id to protect agains csrf
var state = Math.floor((1 + Math.random()) * 0x1000000000000).toString(16).substring(1);
window.localStorage.oauth2State = state;
window.location.href = this.apiOrigin + '/api/v1/oauth/dialog/authorize?response_type=token&client_id=' + this._clientId + '&redirect_uri=' + callbackURL + '&scope=' + scope + '&state=' + state;
};
Client.prototype.logout = function () {
this.setToken(null);
this._userInfo = {};
// logout from OAuth session
window.location.href = client.apiOrigin + '/api/v1/session/logout';
var origin = window.location.protocol + "//" + window.location.host;
window.location.href = this.apiOrigin + '/api/v1/session/logout?redirect=' + origin;
};
Client.prototype.exchangeCodeForToken = function (code, callback) {
+7 -10
View File
@@ -40,9 +40,6 @@ app.config(['$routeProvider', function ($routeProvider) {
}).when('/support', {
controller: 'SupportController',
templateUrl: 'views/support.html'
}).when('/upgrade', {
controller: 'UpgradeController',
templateUrl: 'views/upgrade.html'
}).otherwise({ redirectTo: '/'});
}]);
@@ -96,13 +93,13 @@ app.filter('installationStateLabel', function() {
var waiting = app.progress === 0 ? ' (Waiting)' : '';
switch (app.installationState) {
case ISTATES.PENDING_INSTALL: return 'Installing...' + waiting;
case ISTATES.PENDING_CONFIGURE: return 'Configuring...' + waiting;
case ISTATES.PENDING_UNINSTALL: return 'Uninstalling...' + waiting;
case ISTATES.PENDING_RESTORE: return 'Restoring...' + waiting;
case ISTATES.PENDING_UPDATE: return 'Updating...' + waiting;
case ISTATES.PENDING_FORCE_UPDATE: return 'Updating...' + waiting;
case ISTATES.PENDING_BACKUP: return 'Backing up...' + waiting;
case ISTATES.PENDING_INSTALL: return 'Installing' + waiting;
case ISTATES.PENDING_CONFIGURE: return 'Configuring' + waiting;
case ISTATES.PENDING_UNINSTALL: return 'Uninstalling' + waiting;
case ISTATES.PENDING_RESTORE: return 'Restoring' + waiting;
case ISTATES.PENDING_UPDATE: return 'Updating' + waiting;
case ISTATES.PENDING_FORCE_UPDATE: return 'Updating' + waiting;
case ISTATES.PENDING_BACKUP: return 'Backing up' + waiting;
case ISTATES.ERROR: return 'Error';
case ISTATES.INSTALLED: {
if (app.runState === 'running') {
+29 -40
View File
@@ -23,17 +23,6 @@ angular.module('Application').controller('MainController', ['$scope', '$route',
Client.logout();
};
$scope.login = function () {
var callbackURL = window.location.protocol + '//' + window.location.host + '/login_callback.html';
var scope = 'root,profile,apps,roleAdmin';
// generate a state id to protect agains csrf
var state = Math.floor((1 + Math.random()) * 0x1000000000000).toString(16).substring(1);
window.localStorage.oauth2State = state;
window.location.href = Client.apiOrigin + '/api/v1/oauth/dialog/authorize?response_type=token&client_id=' + Client._clientId + '&redirect_uri=' + callbackURL + '&scope=' + scope + '&state=' + state;
};
$scope.setup = function () {
window.location.href = '/error.html?errorCode=1';
};
@@ -78,43 +67,43 @@ angular.module('Application').controller('MainController', ['$scope', '$route',
if (error) return $scope.error(error);
if (isFirstTime) return $scope.setup();
// we use the config request as an indicator if the token is still valid
// TODO we should probably attach such a handler for each request, as the token can get invalid
// at any time!
if (localStorage.token) {
Client.refreshConfig(function (error) {
if (error && error.statusCode === 401) return $scope.login();
Client.refreshConfig(function (error) {
if (error) return $scope.error(error);
// check version and force reload if needed
if (!localStorage.version) {
localStorage.version = Client.getConfig().version;
} else if (localStorage.version !== Client.getConfig().version) {
localStorage.version = Client.getConfig().version;
window.location.reload(true);
}
Client.refreshUserInfo(function (error, result) {
if (error) return $scope.error(error);
Client.refreshUserInfo(function (error, result) {
Client.refreshInstalledApps(function (error) {
if (error) return $scope.error(error);
Client.refreshInstalledApps(function (error) {
if (error) return $scope.error(error);
// kick off installed apps and config polling
var refreshAppsTimer = $interval(Client.refreshInstalledApps.bind(Client), 2000);
var refreshConfigTimer = $interval(Client.refreshConfig.bind(Client), 5000);
var refreshUserInfoTimer = $interval(Client.refreshUserInfo.bind(Client), 5000);
// kick off installed apps and config polling
var refreshAppsTimer = $interval(Client.refreshInstalledApps.bind(Client), 2000);
var refreshConfigTimer = $interval(Client.refreshConfig.bind(Client), 5000);
var refreshUserInfoTimer = $interval(Client.refreshUserInfo.bind(Client), 5000);
$scope.$on('$destroy', function () {
$interval.cancel(refreshAppsTimer);
$interval.cancel(refreshConfigTimer);
$interval.cancel(refreshUserInfoTimer);
});
// now mark the Client to be ready
Client.setReady();
$scope.config = Client.getConfig();
$scope.initialized = true;
$scope.$on('$destroy', function () {
$interval.cancel(refreshAppsTimer);
$interval.cancel(refreshConfigTimer);
$interval.cancel(refreshUserInfoTimer);
});
// now mark the Client to be ready
Client.setReady();
$scope.config = Client.getConfig();
$scope.initialized = true;
});
});
} else {
$scope.login();
}
});
});
// wait till the view has loaded until showing a modal dialog
+6 -18
View File
@@ -120,7 +120,6 @@ html {
.grid-item {
padding: 10px;
min-width: 200px;
overflow: hidden;
}
.grid-item:hover .grid-item-bottom {
@@ -175,6 +174,12 @@ html {
}
}
.app-update-badge {
position: absolute;
right: 0;
top: 0;
}
// ----------------------------
// Appstore view
// ----------------------------
@@ -354,23 +359,6 @@ html {
max-width: 800px;
}
.app-update-badge {
font-size: $font-size-h4;
position: absolute;
left: 2px;
top: 2px;
width: $font-size-h4 + 6px;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
background-color: transparent;
}
.app-update-badge:hover {
width: inherit;
background-color: #5CB85C;
}
.text-success {
color: #5CB85C;
}
+9 -12
View File
@@ -55,10 +55,6 @@
</fieldset>
</div>
<div class="modal-footer ">
<button type="button" class="btn btn-default" style="float: left;" ng-click="startApp(appConfigure.app)" ng-show="appConfigure.app.runState === 'stopped' && !appConfigure.runStateBusy && !(appConfigure.app | installationActive)"><i class="fa fa-play"></i> Start</button>
<button type="button" class="btn btn-default" style="float: left;" ng-show="appConfigure.app.runState !== 'stopped' && appConfigure.app.runState !== 'running' || appConfigure.runStateBusy && !(appConfigure.app | installationActive)" disabled ><i class="fa fa-refresh fa-spin"></i></button>
<button type="button" class="btn btn-default" style="float: left;" ng-click="stopApp(appConfigure.app)" ng-show="appConfigure.app.runState === 'running' && !appConfigure.runStateBusy && !(appConfigure.app | installationActive)"><i class="fa fa-pause"></i> Stop</button>
<button type="button" class="btn btn-default" data-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-success" ng-click="doConfigure()" ng-disabled="appConfigureForm.$invalid || appConfigure.busy"><i class="fa fa-spinner fa-pulse" ng-show="appConfigure.busy"></i> Save</button>
</div>
@@ -244,26 +240,27 @@
</div>
<div class="grid-item-bottom" ng-show="user.admin">
<br/>
<br/>
<div>
<a href="" ng-click="showUninstall(app)"><i class="fa fa-remove scale"></i></a>
<a href="" ng-click="showUninstall(app)" title="Uninstall App"><i class="fa fa-remove scale"></i></a>
</div>
<div ng-show="(app | installError) === true">
<a href="" ng-click="showRestore(app)"><i class="fa fa-undo scale"></i></a>
<a href="" ng-click="showRestore(app)" title="Restore App"><i class="fa fa-undo scale"></i></a>
</div>
<div ng-show="(app | installSuccess) == true">
<a href="" ng-click="showConfigure(app)"><i class="fa fa-wrench scale"></i></a>
</div>
<!-- we check the version here because the box updater does not know when an app gets updated -->
<div ng-show="config.update.apps[app.id].manifest.version && config.update.apps[app.id].manifest.version !== app.manifest.version && (app | installSuccess)">
<a href="" ng-click="showUpdate(app)"><i class="fa fa-arrow-up text-success scale"></i></a>
<a href="" ng-click="showConfigure(app)" title="Configure App"><i class="fa fa-wrench scale"></i></a>
</div>
<br/>
</div>
<!-- we check the version here because the box updater does not know when an app gets updated -->
<div class="app-update-badge" ng-show="config.update.apps[app.id].manifest.version && config.update.apps[app.id].manifest.version !== app.manifest.version && (app | installSuccess)">
<a href="" ng-click="showUpdate(app)" title="Update Available"><i class="fa fa-asterisk fa-2x text-success scale"></i></a>
</div>
</a>
</div>
</div>
-16
View File
@@ -313,22 +313,6 @@ angular.module('Application').controller('AppsController', ['$scope', '$location
});
};
$scope.startApp = function (app) {
$scope.appConfigure.runStateBusy = true;
app.runState = 'pending_start'; // we assume we will end up there
Client.startApp(app.id, function () {
$scope.appConfigure.runStateBusy = false;
});
};
$scope.stopApp = function (app) {
$scope.appConfigure.runStateBusy = true;
app.runState = 'pending_stop'; // we assume we will end up there
Client.stopApp(app.id, function () {
$scope.appConfigure.runStateBusy = false;
});
};
$scope.cancel = function () {
window.history.back();
};
+1 -1
View File
@@ -92,7 +92,7 @@ angular.module('Application').controller('GraphsController', ['$scope', '$locati
var options = {
scaleOverride: true,
scaleSteps: 10,
scaleStepWidth: $scope.activeApp === 'system' ? 200 : 60,
scaleStepWidth: $scope.activeApp === 'system' ? 200 : ($scope.activeApp.manifest.memoryLimit ? parseInt($scope.activeApp.manifest.memoryLimit/10000000,10) : 20),
scaleStartValue: 0
};
-135
View File
@@ -1,135 +0,0 @@
<!-- Modal upgrade -->
<div class="modal fade" id="upgradeModal" tabindex="-1" role="dialog">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title">Upgrade your Cloudron</h4>
</div>
<div class="modal-body">
Do you really want to upgrade your Cloudron?<br/>
<br/>
<span class="text-danger">Your Cloudron will have a downtime of around 10 minutes, where none of you apps will be reachable.</span>
<br/>
<br/>
<form name="upgradeForm" class="form-signin" role="form" novalidate ng-submit="upgrade()" autocomplete="off">
<fieldset>
<div class="form-group" ng-class="{ 'has-error': (upgradeForm.password.$dirty && upgradeForm.password.$invalid) || (!upgradeForm.password.$dirty && upgrade.error.password) }">
<label class="control-label" for="upgradePasswordInput">Provide your password to confirm this action</label>
<input type="password" class="form-control" ng-model="upgrade.password" id="upgradePasswordInput" name="password" ng-maxlength="512" ng-minlength="5" required autofocus autocomplete="off">
</div>
<input class="ng-hide" type="submit" ng-disabled="upgradeForm.$invalid || busy"/>
</fieldset>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-success" ng-click="upgrade()" ng-disabled="upgradeForm.$invalid || busy"><i class="fa fa-spinner fa-pulse" ng-show="busy"></i> Upgrade</button>
</div>
</div>
</div>
</div>
<!-- Modal relocate -->
<div class="modal fade" id="relocationModal" tabindex="-1" role="dialog">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title">Relocate your Cloudron</h4>
</div>
<div class="modal-body">
Do you really want to relocate your Cloudron from <b>{{currentRegionSlug}}</b> to <b>{{relocation.region.slug}}</b><br/>
<br/>
<span class="text-danger">Your Cloudron will have a downtime of around 10 minutes, where none of you apps will be reachable.</span>
<br/>
<br/>
<form name="relocateForm" class="form-signin" role="form" novalidate ng-submit="relocate()" autocomplete="off">
<fieldset>
<div class="form-group" ng-class="{ 'has-error': (relocateForm.password.$dirty && relocateForm.password.$invalid) || (!relocateForm.password.$dirty && relocation.error.password) }">
<label class="control-label" for="relocationPasswordInput">Provide your password to confirm this action</label>
<input type="password" class="form-control" ng-model="relocation.password" id="relocationPasswordInput" name="password" ng-maxlength="512" ng-minlength="5" required autofocus autocomplete="off">
</div>
<input class="ng-hide" type="submit" ng-disabled="relocateForm.$invalid || busy"/>
</fieldset>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-success" ng-click="relocate()" ng-disabled="relocateForm.$invalid || busy"><i class="fa fa-spinner fa-pulse" ng-show="busy"></i> Relocate</button>
</div>
</div>
</div>
</div>
<br/>
<br/>
<div style="margin-bottom: 15px; text-align: center" ng-show="user.admin">
<div class="row" ng-show="availableSizes.length > 1">
<div class="col-md-12">
<h3>Choose a Model to upgrade to</h3>
</div>
</div>
<br/>
<div class="row" ng-show="availableSizes.length > 1">
<div class="col-md-6 col-md-offset-3">
<div class="cloudron-model-list">
<div class="cloudron-model-item" ng-repeat="size in availableSizes">
<div class="cloudron-model-item-content shadow" ng-class="{ 'selected': size.slug === currentSize.slug }" style="height: {{ 120 + $index * 30 }}px">
<!-- <img src="img/box.png" style="transform: scale({{ size.price/50.0 }});"/><br/> -->
<h3>{{ size.name }}</h3>
<h5>${{ (size.price/100).toFixed() }}/mo</h5>
<button class="btn btn-success" ng-disabled="busy" ng-hide="size.slug === currentSize.slug" ng-click="showUpgradeConfirm(size)">Upgrade</button>
<button class="btn btn-success" ng-show="size.slug === currentSize.slug" data-toggle="tooltip" data-placement="top" title="Your Current Model"><i class="fa fa-check"></i></button>
</div>
</div>
</div>
</div>
</div>
<div class="row" ng-show="availableSizes.length > 1">
<div class="col-md-12">
<p>
Larger Cloudrons allow to run more apps and bring better performance to your installed services.
</p>
</div>
</div>
<div class="row" ng-show="availableSizes.length <= 1">
<div class="col-md-12">
<h4>You are already using the largest available Cloudron model.</h4>
</div>
</div>
<br/>
<br/>
<br/>
<br/>
<br/>
<div class="row">
<div class="form-group col-md-12">
<h3>Choose your Region, in case you want to relocate your Cloudron</h3>
<p>
The closer you are to your Cloudron, the faster the access speed will be.
</p>
</div>
</div>
<br/>
<div class="row">
<div class="form-group col-md-12">
<div class="region-select-map">
<div class="region-select" ng-click="setRegion('sfo1')" ng-class="{ 'region-selected': relocation.region.slug === 'sfo1' }" style="width: 270px; background-image: url('img/world_left.png');">
<div class="region-pin region-pin-left" ng-class="{ 'region-pin-selected-left': relocation.region.slug === 'sfo1' }">
<img src="img/pin.png" height="32px" width="16px"/> San Francisco
</div>
</div>
<div class="region-select" ng-click="setRegion('ams3')" ng-class="{ 'region-selected': relocation.region.slug === 'ams3' }" style="width: 330px; background-image: url('img/world_right.png');">
<div class="region-pin region-pin-right" ng-class="{ 'region-pin-selected-right': relocation.region.slug === 'ams3' }">
<img src="img/pin.png" height="32px" width="16px"/> Amsterdam
</div>
</div>
</div>
</div>
</div>
<div class="row">
<div class="form-group col-md-12">
<button class="btn btn-success" ng-click="showRelocationConfirm()" ng-disabled="(relocation.region.slug === currentRegionSlug) || busy">Relocate</button>
</div>
</div>
</div>
-114
View File
@@ -1,114 +0,0 @@
'use strict';
angular.module('Application').controller('UpgradeController', ['$scope', '$location', 'Client', 'AppStore', function ($scope, $location, Client, AppStore) {
Client.onReady(function () { if (!Client.getUserInfo().admin) $location.path('/'); });
$scope.user = Client.getUserInfo();
$scope.config = Client.getConfig();
$scope.busy = false;
$scope.availableRegions = [];
$scope.availableSizes = [];
$scope.currentSize = null;
$scope.currentRegionSlug = null;
$scope.upgrade = {
size: null,
error: {},
password: null
};
$scope.relocation = {
region: null,
error: {},
password: null
};
$scope.showUpgradeConfirm = function (size) {
$scope.upgrade.size = size;
$('#upgradeModal').modal('show');
};
$scope.upgrade = function () {
$scope.busy = true;
Client.migrate($scope.upgrade.size.slug, $scope.currentRegionSlug, $scope.upgrade.password, function (error) {
$scope.busy = false;
if (error && error.statusCode === 403) {
$scope.upgrade.error.password = true;
$scope.upgrade.password = '';
$('#upgradePasswordInput').focus();
return;
} else if (error) {
return console.error(error);
}
$('#upgradeModal').modal('hide');
});
};
$scope.showRelocationConfirm = function () {
$('#relocationModal').modal('show');
};
$scope.relocate = function () {
$scope.busy = true;
Client.migrate($scope.currentSize.slug, $scope.relocation.region.slug, $scope.relocation.password, function (error) {
$scope.busy = false;
if (error && error.statusCode === 403) {
$scope.relocation.error.password = true;
$scope.relocation.password = '';
$('#relocationPasswordInput').focus();
return;
} else if (error) {
return console.error(error);
}
$('#relocationModal').modal('hide');
});
};
$scope.setRegion = function (regionSlug) {
$scope.availableRegions.forEach(function (region) {
if (region.slug.indexOf(regionSlug) === 0) $scope.relocation.region = region;
});
};
Client.onReady(function () {
AppStore.getSizes(function (error, result) {
if (error) return console.error(error);
// result array is ordered by size
var found = false;
result = result.filter(function (size) {
if (size.slug === $scope.config.size) {
$scope.currentSize = size;
found = true;
return true;
} else {
return found;
}
});
angular.copy(result, $scope.availableSizes);
AppStore.getRegions(function (error, result) {
if (error) return console.error(error);
angular.copy(result, $scope.availableRegions);
$scope.currentRegionSlug = $scope.config.region;
$scope.setRegion($scope.config.region);
});
});
});
// setup all the dialog focus handling
['upgradeModal', 'relocationModal'].forEach(function (id) {
$('#' + id).on('shown.bs.modal', function () {
$(this).find("[autofocus]:first").focus();
});
});
}]);