Compare commits

..

573 Commits

Author SHA1 Message Date
Girish Ramakrishnan ff550e897a caas: remove box plan change routes 2018-12-13 09:41:52 -08:00
Johannes Zellner 10034fcbba Function is called tasks.removePrivateFields 2018-12-13 13:50:53 +01:00
Girish Ramakrishnan 36f8ce453f No need to reserve .app suffix 2018-12-12 15:07:49 -08:00
Girish Ramakrishnan c2e40acb2c Fixup configuration and validation of mailboxName 2018-12-12 14:40:26 -08:00
Girish Ramakrishnan 82b1bb668d Fix tests 2018-12-11 16:26:19 -08:00
Girish Ramakrishnan 935a8258a6 task API: remove arg from response
it can contain sensitive information
2018-12-11 16:22:53 -08:00
Girish Ramakrishnan fa483e5806 rename to listByTypePaged 2018-12-11 16:14:07 -08:00
Girish Ramakrishnan e0c9658cb9 setup -> provision 2018-12-11 15:29:47 -08:00
Girish Ramakrishnan 0266a46b32 Ensure that location and domain are provided together
in our db, {location,domain} is unique. If we replace them one
by one in the database, it will cause conflicts.
2018-12-11 12:10:22 -08:00
Girish Ramakrishnan e7294f2950 Make handleCertChanged take a callback 2018-12-11 11:02:32 -08:00
Girish Ramakrishnan c9f325e75d renewCerts does not call callback 2018-12-11 10:49:04 -08:00
Girish Ramakrishnan 0fa353c2e2 return taskId for renewCerts 2018-12-11 10:16:38 -08:00
Girish Ramakrishnan c7da090882 tasks: remove auditSource from start/stop 2018-12-11 09:22:13 -08:00
Johannes Zellner ee609c8ef0 renew certs task also needs an auditSource 2018-12-11 13:10:32 +01:00
Johannes Zellner 6891ce2bc8 Fix typos 2018-12-11 12:33:35 +01:00
Johannes Zellner 94f5adba04 Renew certs is a POST route so lets use the body 2018-12-11 11:55:59 +01:00
Johannes Zellner b8f843993a Fix typo 2018-12-11 11:26:45 +01:00
Girish Ramakrishnan f9add21899 Add task route tests 2018-12-10 21:42:03 -08:00
Girish Ramakrishnan 1277da8bfe Add tasks test 2018-12-10 21:17:30 -08:00
Girish Ramakrishnan 55650fb734 Fix crash when db got cleared 2018-12-10 20:56:38 -08:00
Girish Ramakrishnan d2f4b68c9f Make certificate renewal a task 2018-12-10 20:48:10 -08:00
Girish Ramakrishnan a76731a991 remove old cloudron.conf and version field 2018-12-10 15:36:00 -08:00
Girish Ramakrishnan 536b8166ce typo 2018-12-09 12:04:51 -08:00
Girish Ramakrishnan d43106b0af Add taskworker that runs funcs out of process 2018-12-09 10:06:22 -08:00
Girish Ramakrishnan 3688371ce8 Make tasks log to their own log file 2018-12-08 21:59:20 -08:00
Girish Ramakrishnan 6d66eb7759 Various fixes 2018-12-08 21:12:49 -08:00
Girish Ramakrishnan 8502bf4bfa tasks: add route to list tasks 2018-12-08 20:18:20 -08:00
Girish Ramakrishnan d8225ad653 Make tasks indexed by id instead of type
The caas migrate logic is broken at this point until it uses new
task framework
2018-12-08 19:41:53 -08:00
Girish Ramakrishnan cfb68a0511 setAdmin -> setDashboardDomain 2018-12-08 18:19:49 -08:00
Girish Ramakrishnan 76677e0aea Fix response 2018-12-07 16:39:22 -08:00
Girish Ramakrishnan 515ee891d3 refactor code to allow admin domain to be set 2018-12-07 16:15:21 -08:00
Girish Ramakrishnan 3aea1f3c9d Fix error message 2018-12-07 15:41:02 -08:00
Girish Ramakrishnan 8d944f9a4a Allow setup to be run again as long as not-activated
this is useful if the activation fails somewhere mid-way and we don't
need to re-setup the cloudron all over
2018-12-07 15:11:25 -08:00
Girish Ramakrishnan 331c8ae247 Add a new line 2018-12-07 11:28:23 -08:00
Girish Ramakrishnan c71a429f61 Ask user before reboot
this is useful when installing on bare metal, don't want to reboot
server since the instructions get lost
2018-12-07 11:12:45 -08:00
Girish Ramakrishnan 3bad9e523c Add option to toggle app automatic updates 2018-12-07 10:02:11 -08:00
Girish Ramakrishnan dfa61f1b2d rework how app mailboxes are allocated
Our current setup had a mailbox allocated for an app during app
install (into the mailboxes table). This has many issues:

* When set to a custom mailbox location, there was no way to access
  this mailbox even via IMAP. Even when using app credentials, we
  cannot use IMAP since the ldap logic was testing on the addon type
  (most of our apps only use sendmail addon and thus cannot recvmail).

* The mailboxes table was being used to add hidden 'app' type entries.
  This made it very hard for the user to understand why a mailbox conflicts.
  For example, if you set an app to use custom mailbox 'blog', this is
  hidden from all views.

The solution is to let an app send email as whatever mailbox name is
allocated to it (which we now track in the apps table. the default is in the
db already so that REST response contains it). When not using
Cloudron email, it will just send mail as that mailbox and the auth
checks the "app password" in the addons table. Any replies to that
mailbox will end up in the domain's mail server (not our problem).

When using cloudron email, the app can send mail like above. Any responses
will not end anywhere and bounce since there is no 'mailbox'. This is the
expected behavior. If user wants to access this mailbox name, he can
create a concrete mailbox and set himself as owner OR set this as
an alias.

For apps using the recvmail addon, the workflow is to actually create
a mailbox at some point. Currently, we have no UI for this 'flow'.
It's fine because we have only meemo using it.

Intuitive much!
2018-12-06 22:13:32 -08:00
Girish Ramakrishnan 6331fa5ced Update mail addon (does not use ownerType) 2018-12-06 20:34:18 -08:00
Girish Ramakrishnan 707b03b8c8 mailbox: ownerType is now purely internal 2018-12-06 20:25:24 -08:00
Girish Ramakrishnan f2f93ed141 ldap: nobody binds to mailboxes 2018-12-06 19:34:25 -08:00
Girish Ramakrishnan 37e16c7a4c Force owner type to be user 2018-12-06 13:00:57 -08:00
Girish Ramakrishnan 41b0c3242e Make function name clearer 2018-12-06 12:47:50 -08:00
Girish Ramakrishnan 48ed051edf Use list verb 2018-12-06 10:23:10 -08:00
Girish Ramakrishnan 502642fd25 Add note on why we need universe 2018-12-06 09:32:02 -08:00
Johannes Zellner 4abe6a7a00 Ensure we have universe apt sources enabled 2018-12-06 13:23:30 +01:00
Girish Ramakrishnan 3f8fa64b98 wildcard must be outside the quotes 2018-12-05 16:39:29 -08:00
Johannes Zellner 527ff1b1fb Support docker and unbound service logs through journalctl 2018-12-05 16:19:10 +01:00
Girish Ramakrishnan 804467dce2 StandardOutput=file does not work in ubuntu 16
ubuntu 16 has systemd 229 which does not support this. ubuntu 18
has systemd 239 which works ok
2018-12-04 15:02:48 -08:00
Girish Ramakrishnan 4d7f308821 translate TaskError to UpdateError 2018-12-04 14:04:43 -08:00
Girish Ramakrishnan a5b8418845 Remove spurious success return 2018-12-04 09:34:00 -08:00
Johannes Zellner 93d428b8c5 Update cron node module 2018-12-04 14:53:08 +01:00
Johannes Zellner 7c424ad60c Add netcup as provider 2018-12-04 09:51:40 +01:00
Girish Ramakrishnan 5b29a8680d Add missing callback 2018-12-03 15:23:26 -08:00
Girish Ramakrishnan 8f57c44837 provision: force http1.1
for unknown reasons, we get a http2 framing error from curl at times.
2018-12-03 13:29:43 -08:00
Johannes Zellner b23939127b Ensure we always set the correct default value for TIMESTAMP types
So far it we rely on 0 default values in some case like the eventlog
This will not work in mysql strict mode with https://dev.mysql.com/doc/refman/5.7/en/sql-mode.html#sqlmode_no_zero_date
2018-12-03 14:09:57 +01:00
Johannes Zellner 3196322063 Add migration script to rename groups table to userGroups 2018-12-03 12:24:04 +01:00
Johannes Zellner 54c96d98d1 Remove all individual test timeouts and ignore timeouts toplevel 2018-12-03 12:24:04 +01:00
Johannes Zellner f5f92fbb03 Rename groups table to userGroups 2018-12-03 12:24:04 +01:00
Girish Ramakrishnan be0876603c ADDON_STATUS_* -> SERVICE_STATUS_* 2018-12-02 19:40:27 -08:00
Girish Ramakrishnan 7c1ef143f9 Add unbound service 2018-12-02 19:38:34 -08:00
Girish Ramakrishnan 6d128595e7 all container related functionality is 'service' 2018-12-02 19:12:43 -08:00
Girish Ramakrishnan 2f55abfc60 Fixup logs routes as well 2018-12-02 19:07:12 -08:00
Girish Ramakrishnan f93044ac3b move routes to services.js 2018-12-02 18:46:34 -08:00
Girish Ramakrishnan 7ed422a3c1 Split out service related functions 2018-12-02 18:45:39 -08:00
Girish Ramakrishnan 823b3b8aa8 Rename addon route to service
service are the containers and other things like unbound, nginx.
addons are app development hooks.
2018-12-02 17:56:11 -08:00
Girish Ramakrishnan 9a701560f4 Fix email status 2018-12-01 21:50:28 -08:00
Girish Ramakrishnan 9800154d01 Use latest mail container (healthcheck route) 2018-12-01 21:50:24 -08:00
Girish Ramakrishnan 4b3f18ccdb Fix messages in update.sh 2018-11-30 21:18:36 -08:00
Girish Ramakrishnan 840d78b2f4 run update as a task, so it is cancelable 2018-11-30 21:04:24 -08:00
Girish Ramakrishnan b409fd775d Revert "Fix crash"
This reverts commit 12a5965740.

Really fix crash
2018-11-30 19:10:44 -08:00
Girish Ramakrishnan dbcfb20fab Remove shebang hack, this does not apply anymore 2018-11-30 17:10:28 -08:00
Girish Ramakrishnan 12a5965740 Fix crash 2018-11-30 17:10:28 -08:00
Girish Ramakrishnan 006ab75433 More changes 2018-11-30 17:10:28 -08:00
Girish Ramakrishnan c72ea91743 make task args an object and put it in eventlog 2018-11-30 14:57:24 -08:00
Girish Ramakrishnan f39ce20580 better name for task functions 2018-11-30 14:41:11 -08:00
Girish Ramakrishnan b5c59e6b7d Add args to tasks table 2018-11-30 14:12:24 -08:00
Girish Ramakrishnan b0ecdcc8b6 Fix another crash 2018-11-30 10:03:16 -08:00
Girish Ramakrishnan 8e1560f412 Fix crash in apptask 2018-11-30 09:48:26 -08:00
Girish Ramakrishnan df927eae74 move backupupload.js to scripts
it is just a sudo helper
2018-11-29 23:30:56 -08:00
Girish Ramakrishnan 30aea047e3 startTask now takes args 2018-11-29 23:28:26 -08:00
Girish Ramakrishnan cbcadaa449 Return an active field to indicate if task is actually running 2018-11-29 23:12:03 -08:00
Girish Ramakrishnan 9f4226093b Handle bad state if task is already stopped 2018-11-29 23:10:15 -08:00
Girish Ramakrishnan fca0e897b2 Add tasks.startTask 2018-11-29 16:21:04 -08:00
Girish Ramakrishnan 2f729b56fa Use result in db instead of ipc 2018-11-29 15:34:05 -08:00
Girish Ramakrishnan d9f3f64c76 Store result and error message separately 2018-11-29 14:45:28 -08:00
Girish Ramakrishnan e8fa909c2f Bump graphite since the container port changed 2018-11-29 09:33:46 -08:00
Johannes Zellner 44f6636653 Add more changes 2018-11-29 16:44:44 +01:00
Johannes Zellner 148a0d0fc6 Add memory usage for addon status api 2018-11-28 12:33:24 +01:00
Girish Ramakrishnan 632ba69663 Make restore/download logic have progress callbacks 2018-11-27 12:13:16 -08:00
Girish Ramakrishnan b2465dd2ee Set the backup task progress in the task only 2018-11-27 12:12:27 -08:00
Girish Ramakrishnan e56b87766b backupApp should not set backup task progress 2018-11-27 11:06:03 -08:00
Girish Ramakrishnan f7ca2e416a Add progress callback for snapshotting 2018-11-27 11:03:58 -08:00
Girish Ramakrishnan 002f68b0a1 Add progress info during backup rotation 2018-11-27 10:51:35 -08:00
Girish Ramakrishnan aa31be5c5a backup: make upload progress available with callback+ipc 2018-11-27 10:42:34 -08:00
Johannes Zellner 6c0b7017bd Add some 3.4 changes 2018-11-27 14:41:03 +01:00
Girish Ramakrishnan 581774e001 Use ipc to pass back the result 2018-11-26 20:39:52 -08:00
Girish Ramakrishnan 3847a6616e No need to track backup time since it is killable now 2018-11-26 20:39:20 -08:00
Girish Ramakrishnan 48fbe28355 Make upload task abort when parent dies 2018-11-26 19:11:30 -08:00
Girish Ramakrishnan e3ee5bc1d5 lint 2018-11-26 15:55:00 -08:00
Girish Ramakrishnan a2da9bea58 backup: use ipc for communicating with upload process 2018-11-26 15:21:48 -08:00
Johannes Zellner e4512e12c5 Add --help for cloudron-support and make ssh key enabling optional 2018-11-26 15:28:11 +01:00
Johannes Zellner 114f48fb17 Remove leftover console.log() 2018-11-26 14:50:10 +01:00
Johannes Zellner 289e018160 Fix comment text 2018-11-26 08:19:24 +01:00
Johannes Zellner cb6699eeed Add api to check if the server needs a reboot 2018-11-26 08:19:24 +01:00
Girish Ramakrishnan 802011bb7e Fix args to shell.sudo 2018-11-25 14:57:51 -08:00
Girish Ramakrishnan 6cd8e769be remove all uses of sudoSync 2018-11-25 14:43:29 -08:00
Girish Ramakrishnan 9f6f67d331 ssh: remove use of shell.sudoSync 2018-11-25 14:27:06 -08:00
Johannes Zellner 161a8fe2bf Make docker restartable 2018-11-24 22:12:28 +01:00
Girish Ramakrishnan b9c9839bb7 apparmor is always enabled on all ubuntu 2018-11-23 13:11:31 -08:00
Girish Ramakrishnan 76edbee48c Better error message 2018-11-23 13:11:15 -08:00
Girish Ramakrishnan 4142d7a050 Fix error handling of all the execSync usage 2018-11-23 13:11:15 -08:00
Girish Ramakrishnan a0306c69e1 remove unused acme1.js
it got merged to acme2.js
2018-11-23 13:11:15 -08:00
Girish Ramakrishnan 31823f6282 Check if result is not null 2018-11-23 13:11:15 -08:00
Girish Ramakrishnan 9b4fffde29 Use shell.exec instead of shell.execSync 2018-11-23 11:18:45 -08:00
Girish Ramakrishnan cce03e250d these can just be debugs 2018-11-22 18:05:41 -08:00
Girish Ramakrishnan 9b32cad946 typoe 2018-11-22 17:26:06 -08:00
Girish Ramakrishnan 2877a1057e Add CLOUDRON_PROXY_IP 2018-11-22 16:50:02 -08:00
Johannes Zellner e2debe3c39 Add starting addon state when we wait for healthcheck 2018-11-22 21:54:31 +01:00
Johannes Zellner f54ab11f18 Handle mail related addon logs 2018-11-22 12:38:50 +01:00
Johannes Zellner b560e281d0 share container addon status code 2018-11-22 12:38:50 +01:00
Girish Ramakrishnan 3bb4ef5727 Some servers can be very slow starting databases 2018-11-21 09:49:08 -08:00
Johannes Zellner 900c008d20 Only allow addon restarts, no start+stop 2018-11-21 16:06:58 +01:00
Johannes Zellner c1183a09a8 Add addon configure route 2018-11-21 15:47:41 +01:00
Girish Ramakrishnan e04b7b55b0 Remove upgrade flag
This is not used since ages since we changed our update methodology
2018-11-20 10:29:54 -08:00
Johannes Zellner 329cc80933 Send memory settings on addon status 2018-11-20 16:53:27 +01:00
Johannes Zellner a13f0706b4 Report addon state detail for further use 2018-11-20 13:11:11 +01:00
Johannes Zellner 55811de4b8 Check for docker ping result 2018-11-20 13:07:41 +01:00
Johannes Zellner ab456f179e Check for healthcheck in addon status code 2018-11-20 12:26:33 +01:00
Johannes Zellner f9d5bcd352 Make getAddonDetails() return AddonsError 2018-11-20 12:12:25 +01:00
Johannes Zellner 6a337884b5 Add start/stop addon code 2018-11-20 11:09:09 +01:00
Johannes Zellner f953d115da Fix missing require 2018-11-20 11:09:09 +01:00
Johannes Zellner 88e8fc840f Fetch basic addon status from docker 2018-11-20 11:09:09 +01:00
Johannes Zellner d1818e31b0 Add addon status hooks 2018-11-20 11:09:09 +01:00
Johannes Zellner 3f4bf647e8 Allow console.* in the linter config 2018-11-20 11:09:09 +01:00
Johannes Zellner 725a7e6dec Add missing requires 2018-11-20 11:09:09 +01:00
Johannes Zellner e08b210001 Add initial addons rest apis 2018-11-20 11:09:09 +01:00
Girish Ramakrishnan ec08ccb996 move tasks to separate directory
these are just wrappers that we use to launch as processes
2018-11-19 21:17:04 -08:00
Girish Ramakrishnan b47a146c2b unlock on log failure 2018-11-19 21:12:58 -08:00
Girish Ramakrishnan 14dff27d45 boxUpdateInfo cannot be null here 2018-11-19 21:09:33 -08:00
Girish Ramakrishnan 305a3c94d0 Rework the progress API to use the task progress 2018-11-19 21:06:44 -08:00
Girish Ramakrishnan 218739a6b5 Add tasks table and API
progress will be tracked with this table instead of being in-process
like progress.js
2018-11-19 17:37:42 -08:00
Girish Ramakrishnan 390e69c01c Fix backup API routes 2018-11-19 14:41:16 -08:00
Johannes Zellner 4ef274acf0 Box logs are now in a log file 2018-11-19 19:47:19 +01:00
Girish Ramakrishnan 8267279779 backup can now be stopped
track the backup process and killable
2018-11-17 20:31:07 -08:00
Girish Ramakrishnan 6d971b9235 backupupload also logs to stdout now 2018-11-17 19:38:02 -08:00
Girish Ramakrishnan 98dc160886 remove the progress and watchdog timers
not needed since we will track these processes now and can be killed
by the user.
2018-11-17 19:38:02 -08:00
Girish Ramakrishnan a869c88b43 Remove setBackupProgress
the upload task can just output to stdout. for progress status, we
can just use a tailstream on the ui side
2018-11-17 19:38:02 -08:00
Girish Ramakrishnan 0b86070fe9 run backuptask as separate process 2018-11-17 19:38:02 -08:00
Girish Ramakrishnan 5c9b6736f0 Remove pruneInfraImages out of platform startup
If it fails, it's really OK. Maybe we will have a button to remove
images.

This goes further into step of webadmin always staying up and not
crashing/erroring for cosmetic issues.
2018-11-17 19:35:39 -08:00
Girish Ramakrishnan fd4057df94 shell.exec -> shell.spawn 2018-11-17 19:26:19 -08:00
Girish Ramakrishnan 1b1945e1f5 Move out graphite from port 8000
Port 8000 is used by esxi management service (!)
2018-11-17 19:14:21 -08:00
Girish Ramakrishnan ebb053b900 add back timestamps to debug output 2018-11-16 14:16:11 -08:00
Girish Ramakrishnan 3381d9b595 Add format to the default backup config 2018-11-16 14:15:20 -08:00
Girish Ramakrishnan d7a11ef394 runBackupTask -> runBackupUpload 2018-11-16 09:21:07 -08:00
Johannes Zellner 9d40cffabe Add logrotate config for box logs 2018-11-16 13:14:59 +01:00
Johannes Zellner de44c63557 box now also logs to a a file instead of journald 2018-11-16 13:05:11 +01:00
Johannes Zellner ac25477cd7 Ensure we prefix all output with a timestamp
note that debug() already does this now on its own in the same format
it does not use console.log but process.stderr
2018-11-16 13:02:37 +01:00
Johannes Zellner 59b86aa090 Stop logging box to journald 2018-11-16 12:15:38 +01:00
Girish Ramakrishnan 6abd48d480 rename backuptask to backupupload 2018-11-15 12:00:51 -08:00
Girish Ramakrishnan 72fc6b8c5a Fix tests 2018-11-15 12:00:51 -08:00
Girish Ramakrishnan fcce4a6853 Add note on StandardError 2018-11-15 10:52:31 -08:00
Girish Ramakrishnan a3b1a2c781 Get the domain correctly from subject 2018-11-15 10:47:20 -08:00
Johannes Zellner a838a1706f Fix indentation 2018-11-15 19:26:50 +01:00
Johannes Zellner a24c9fbafb Put cloudron-updater (installer.sh) logs persistently to /var/log/ 2018-11-15 14:54:12 +01:00
Johannes Zellner ab255e78c5 Make the cert subject match 2018-11-15 14:18:34 +01:00
Girish Ramakrishnan 2628678d82 3.3.4 changes 2018-11-14 22:11:46 -08:00
Girish Ramakrishnan eaf9b7f049 Add certificate.new event 2018-11-14 20:47:18 -08:00
Girish Ramakrishnan b8df4d0b79 bare domains are getting continuously renewed
the code is not handling the case where bare domain is not part
of the wildcard SAN.
2018-11-14 20:47:15 -08:00
Girish Ramakrishnan eb315f34dc Pass around domainObject 2018-11-14 20:32:14 -08:00
Girish Ramakrishnan af535757a8 Fix issue where request module was buffering all data
when request is give a callback, it will save all the data in memory
to give the data in the response callback
2018-11-14 19:03:27 -08:00
Girish Ramakrishnan 600e030c6d add a note what the script is about 2018-11-14 10:25:43 -08:00
Girish Ramakrishnan e86b813551 cloudron-support: use timeout command for docker ps output 2018-11-14 10:22:10 -08:00
Girish Ramakrishnan af6653dfeb do not timeout when checking for updates 2018-11-13 10:38:18 -08:00
Johannes Zellner f93e0c868c Warn the user if / or /tmp is full 2018-11-13 13:31:49 +01:00
Johannes Zellner e53aaddc9c Add 3.3.3 changes 2018-11-13 12:54:40 +01:00
Johannes Zellner d3ebb99131 Fix apptask concurrency
This broke due to async behavior introduced with 1dc649b7a2
2018-11-13 12:54:40 +01:00
Girish Ramakrishnan 82037b70e4 retry apply of platform config
it seems that scaling down addons can fail at times. this can happen
if the kernel is busy swapping things etc. so, we do not block the
platform startup for this operation.

Nov 13 05:20:00 localhost dockerd[28831]: time="2018-11-13T05:20:00.365056059Z" level=error msg="Handler for POST /v1.37/containers/mongodb/update returned error: Cannot update container 6532d4a923ce9f10303f2e2aa7f03c35383864f44f3db6abd5c58da3c1a7702f: docker-runc did not terminate sucessfully: failed to write 419430400 to memory.memsw.limit_in_bytes: write /sys/fs/cgroup/memory/docker/6532d4a923ce9f10303f2e2aa7f03c35383864f44f3db6abd5c58da3c1a7702f/memory.memsw.limit_in_bytes: device or resource busy\n: unknown"
Nov 13 05:20:00 localhost node[5486]: box:shell updatemongodb (stderr): Error response from daemon: Cannot update container 6532d4a923ce9f10303f2e2aa7f03c35383864f44f3db6abd5c58da3c1a7702f: docker-runc did not terminate sucessfully: failed to write 419430400 to memory.memsw.limit_in_bytes: write /sys/fs/cgroup/memory/docker/6532d4a923ce9f10303f2e2aa7f03c35383864f44f3db6abd5c58da3c1a7702f/memory.memsw.limit_in_bytes: device or resource busy
2018-11-12 21:58:57 -08:00
Girish Ramakrishnan 323dfb1853 More typos 2018-11-12 10:27:56 -08:00
Girish Ramakrishnan 59b6d2ef66 mail: clear timeout for dns route 2018-11-12 10:25:15 -08:00
Girish Ramakrishnan 7b99167d30 typo 2018-11-12 09:32:02 -08:00
Girish Ramakrishnan c829f190df 3.3.2 changes 2018-11-11 22:18:46 -08:00
Girish Ramakrishnan f306f334c0 Update mongodb for invalid index fix 2018-11-11 22:15:51 -08:00
Girish Ramakrishnan 3fb8243264 Add route to trigger app import 2018-11-11 21:58:20 -08:00
Girish Ramakrishnan b8861b9d78 Use debug instead 2018-11-11 21:57:45 -08:00
Girish Ramakrishnan 61d98ca4ca give addons more memory when starting up / importing
we scale it back after the import is done
2018-11-11 21:45:02 -08:00
Girish Ramakrishnan a3a22604ba Mark import errors as app errors 2018-11-11 10:36:11 -08:00
Girish Ramakrishnan 1766c3f6d6 lint 2018-11-11 10:12:51 -08:00
Girish Ramakrishnan aae259479f move configure webadmin and status out of setup logic 2018-11-11 09:56:01 -08:00
Girish Ramakrishnan b640a053e3 Always generate webadmin config on startup 2018-11-11 08:19:24 -08:00
Girish Ramakrishnan a3cc17705d Do not remove default.conf and admin.conf when re-configuring apps 2018-11-10 22:02:42 -08:00
Girish Ramakrishnan 78ac1d2a12 Add isCloudronManaged label to containers managed by cloudron 2018-11-10 19:00:03 -08:00
Girish Ramakrishnan 6ec36c9605 Do not crash if platform.start fails
With this change, the box code always starts up even if nginx fails,
docker fails etc.
2018-11-10 18:34:37 -08:00
Girish Ramakrishnan bd2b03876b Add users.isActivated
it's easier to see where we do activation checks
2018-11-10 18:09:00 -08:00
Girish Ramakrishnan 0c5cb9c98f Remove settings.initialize/uninitialize
this simplifies the startup sequence
2018-11-10 14:55:23 -08:00
Girish Ramakrishnan eddcc6fe27 Fix another test 2018-11-10 01:35:49 -08:00
Girish Ramakrishnan 953b55439c eventlog: add domain events 2018-11-10 01:25:34 -08:00
Girish Ramakrishnan e526890e3e Add a separate disabled event 2018-11-10 00:35:56 -08:00
Girish Ramakrishnan 71e1e2468b fix test 2018-11-10 00:22:38 -08:00
Girish Ramakrishnan fcb7c3e82a fix casing 2018-11-10 00:18:56 -08:00
Girish Ramakrishnan 0d65635bc4 eventlog: add email enabled/disabled events 2018-11-10 00:17:38 -08:00
Girish Ramakrishnan 4aa76a859f eventlog: mail list add,remove events 2018-11-10 00:17:25 -08:00
Girish Ramakrishnan 003789265c eventlog: mailbox add,remove events 2018-11-10 00:06:02 -08:00
Girish Ramakrishnan 1bb3b5e51b addons: refactor code into dumpPath 2018-11-09 12:02:38 -08:00
Girish Ramakrishnan 032ac3ed97 cloudron-provision args
zone -> domain
appstore configs -> license
+backup_config
cloudflare* -> dns_config
2018-11-08 21:44:56 -08:00
Girish Ramakrishnan edc2c25bda Add libpython
On ubuntu 18.04, on hetzner and vultr, collectd does not start without
this.
2018-11-08 16:11:01 -08:00
Girish Ramakrishnan 2fa44879e9 cloudron-support: Add more services 2018-11-08 16:05:37 -08:00
Girish Ramakrishnan 7e27413b29 18.04: Add sudo
This was hit on scaleway
2018-11-08 14:47:49 -08:00
Johannes Zellner 77508d180e Add changes for 3.3.1 2018-11-08 21:51:07 +01:00
Johannes Zellner 111f5a7c99 Update postgres addon to 2.0.2 2018-11-08 20:51:14 +01:00
Johannes Zellner d2182559e8 change the ssh user detection to rely on SUDO_USER 2018-11-08 19:39:25 +01:00
Johannes Zellner 9fe91cf9cb Log if we receive SIGINT or SIGTERM 2018-11-08 14:35:22 +01:00
Girish Ramakrishnan de000648dc do not chown redis during updates 2018-11-07 15:21:12 -08:00
Girish Ramakrishnan 0958a57c45 Fix path to conf file 2018-11-07 10:37:00 -08:00
Johannes Zellner 9aae0d9d4c Add initial version of the cloudron-support tool 2018-11-07 17:37:16 +01:00
Girish Ramakrishnan ccfd385beb Fix waitForDns 2018-11-05 23:00:55 -08:00
Girish Ramakrishnan ee6cca5cdf Make cert an object 2018-11-05 22:39:06 -08:00
Girish Ramakrishnan 0093e840c6 Fix various typos 2018-11-05 21:26:53 -08:00
Girish Ramakrishnan 7c6e5ac32b newline replace certs for json databag 2018-11-05 21:23:22 -08:00
Girish Ramakrishnan 15039bf293 Fix validation of fallback certs 2018-11-05 21:13:34 -08:00
Girish Ramakrishnan 89b6cd9d1f cloudron-provision: set tls cert and key 2018-11-05 20:59:29 -08:00
Girish Ramakrishnan 60992405d5 generate cert with correct CN 2018-11-05 20:48:28 -08:00
Girish Ramakrishnan d96b1cc864 generate fallback cert correctly for hyphenated domains 2018-11-05 19:53:50 -08:00
Girish Ramakrishnan 5165cd8f40 Use debug 2018-11-05 17:36:23 -08:00
Girish Ramakrishnan 9f8b47daa9 domains.fqdn only takes 2 args 2018-11-05 17:35:50 -08:00
Girish Ramakrishnan 9372afad9a Fix debug 2018-11-05 17:12:04 -08:00
Girish Ramakrishnan eef6056174 bump timeout of setup route 2018-11-05 16:55:46 -08:00
Girish Ramakrishnan a1dfc758c6 Fix debug 2018-11-02 17:10:16 -07:00
Girish Ramakrishnan 8caf5cc741 typo 2018-11-02 14:50:45 -07:00
Girish Ramakrishnan 7739f8f174 Set container hostname to something constant
currently, it defaults to the container name. apps might use this hostname
for communication with child containers. but this breaks if app gets updated
and gets a new hostname
2018-11-02 14:47:00 -07:00
Girish Ramakrishnan 0618431be7 print the cloudronId on registration 2018-11-01 23:10:26 -07:00
Girish Ramakrishnan 609c4388f0 cloudron-provision: take appstore user id and token 2018-11-01 23:08:26 -07:00
Girish Ramakrishnan 28243956db Add cert and appstore info to provision script 2018-11-01 22:38:21 -07:00
Girish Ramakrishnan ff3a4f65dd autoprovision: register cloudron when provided appstore config 2018-11-01 22:28:51 -07:00
Girish Ramakrishnan 44da148fd1 remove collectd directory again 2018-11-01 20:33:40 -07:00
Girish Ramakrishnan 0b37479838 Add provision script 2018-11-01 20:17:15 -07:00
Girish Ramakrishnan c09aa2a498 Make LE work with hyphenated domains 2018-11-01 19:08:05 -07:00
Girish Ramakrishnan 00d032616f More changes 2018-11-01 13:55:27 -07:00
Johannes Zellner 041285b187 Add dynamic dns settings routes 2018-10-31 16:02:51 +01:00
Johannes Zellner fa9aa50fdf support --output again in createReleaseTarball script for hotfix to work
This avoids having to calculate the output filename with revisions and
exact format in the cli tool.
2018-10-31 08:54:24 +01:00
Girish Ramakrishnan e0b1ebba92 verifyOperator -> isUnmanaged 2018-10-30 21:17:34 -07:00
Girish Ramakrishnan 581bbafa06 registry_config route does not require operator check 2018-10-30 20:54:35 -07:00
Girish Ramakrishnan ce93518c0a allow hyphenated subdomains on all editions 2018-10-30 20:35:35 -07:00
Girish Ramakrishnan 0ba0b009c7 make the domain locking code more clear 2018-10-30 20:35:32 -07:00
Girish Ramakrishnan eed8f109bc operator check is now directly based on edition type 2018-10-30 20:26:22 -07:00
Girish Ramakrishnan 63946509b3 Get autoconf data as separate object
This was done because restore API already takes a backupConfig.
So, it's best to split up the primary parameters and the autoconf
2018-10-30 19:27:14 -07:00
Girish Ramakrishnan 668ff99450 Add support for restricted certs as part of dns setup 2018-10-30 18:15:26 -07:00
Girish Ramakrishnan 03984a811f dnsSetup -> setup
It now takes a dnsConfig object. It will end up taking other config
objects for provisioning.
2018-10-30 14:01:02 -07:00
Girish Ramakrishnan 7c733ae150 Fix error handling 2018-10-29 20:19:48 -07:00
Girish Ramakrishnan 9fe02d3818 lint 2018-10-29 20:19:42 -07:00
Girish Ramakrishnan f10b80d90d Make apps test great again 2018-10-29 12:49:41 -07:00
Girish Ramakrishnan caf1d18250 Add flag for tests to check if platform is ready 2018-10-29 12:46:02 -07:00
Girish Ramakrishnan c700635656 certs: make renew switch to fallback certs 2018-10-28 19:03:57 -07:00
Girish Ramakrishnan d6d2ee7d19 Add version to release filename
This is required because we have to patch the VERSION file inside
the tarball for 'rerelease'. Which then requires release tarballs
to have a unique name.

The version is at the end because there is code  in release script
which relies on the box sha at the front
2018-10-28 16:16:51 -07:00
Girish Ramakrishnan f5a5da6731 3.0 changes 2018-10-28 14:43:20 -07:00
Girish Ramakrishnan 8f2ce5f4f8 cloudron-setup: Fixup script to support pre-3.2 versions
This is required for e2e and restore of old cloudrons
2018-10-28 14:21:51 -07:00
Girish Ramakrishnan 62619d21c0 Handle 409 in container exec 2018-10-27 14:15:52 -07:00
Girish Ramakrishnan bf7707b70b cleanup old base image as well 2018-10-27 13:10:02 -07:00
Girish Ramakrishnan 698f3c833b cloudflare: Fix crash
{ success: false,
  errors:
   [ { code: 9103, message: 'Unknown X-Auth-Key or X-Auth-Email' } ],
  messages: [],
  result: null }
/home/yellowtent/box/src/dns/cloudflare.js:36
        } else if (error.error_chain[0] && error.error_chain[0].message) {
                                    ^
TypeError: Cannot read property '0' of undefined
    at translateRequestError (/home/yellowtent/box/src/dns/cloudflare.js:36:37)
    at /home/yellowtent/box/src/dns/cloudflare.js:57:83
2018-10-27 12:51:24 -07:00
Girish Ramakrishnan 5996a107ed Return a bogus version to fix the tests 2018-10-27 12:26:39 -07:00
Girish Ramakrishnan 0307dc5145 Carefully prune addon images
With the docker image prune we ended up removing the redis image
which was ununsed...

This also prevents us from removing images of some app using the
docker addon
2018-10-27 12:01:22 -07:00
Girish Ramakrishnan f9aa09f717 typo 2018-10-27 11:28:30 -07:00
Girish Ramakrishnan 2688a57d46 Move version out of config.js
We now store this in a file called VERSION inside the tarball
itself.
2018-10-27 11:13:36 -07:00
Girish Ramakrishnan 7ad069fd94 Make config.setVersion hidden 2018-10-27 10:44:21 -07:00
Girish Ramakrishnan 06d043dac4 Make baseImage an array of objects 2018-10-26 16:58:18 -07:00
Girish Ramakrishnan 94be6a9e3c Pull images in installer.sh
This way if the network is not working, then the update will just
fail and use the old version

This also tags the addon images with the semver properly
2018-10-26 16:35:18 -07:00
Girish Ramakrishnan 97567b7d2a Do not re-create cloudron.conf
All this argument passing going around is just needless

* boxdata/version file is not used
* dashboard config.json is also unused
2018-10-26 15:29:14 -07:00
Girish Ramakrishnan 95d691154d scripts/cloudron-setup: --data-dir is obsolete 2018-10-26 10:59:06 -07:00
Girish Ramakrishnan 9ac9b49522 Move ntp setup to base image init script 2018-10-26 10:57:19 -07:00
Girish Ramakrishnan 6a887c2bba SSH port configuration will be done in appstore 2018-10-26 10:13:35 -07:00
Girish Ramakrishnan 0250508a89 Add comment for installer.sh and start.sh 2018-10-26 10:13:35 -07:00
Girish Ramakrishnan f97973626c start.sh: curl is not used
we will move network stuff to installer.sh anyways
2018-10-26 09:55:13 -07:00
Girish Ramakrishnan 5cdf9d1c6f Move host configuration to setup script since Cloudron does not rely on it
Also hostname cannot be set here since admin_fqdn is not available on
initial setup
2018-10-25 18:49:05 -07:00
Girish Ramakrishnan 009e888686 remove dead migration of nginx certs 2018-10-25 18:43:24 -07:00
Girish Ramakrishnan e3478c9d13 Use docker cli to cleanup old images 2018-10-25 16:33:12 -07:00
Girish Ramakrishnan 2e3ddba7e5 cloudron-setup: Log arguments later 2018-10-25 11:31:39 -07:00
Girish Ramakrishnan 81ac44b7da Fix failing test 2018-10-25 00:01:32 -07:00
Girish Ramakrishnan ffe50ff977 detect change in provider type and renew accordingly 2018-10-24 20:40:05 -07:00
Girish Ramakrishnan 73faba3c28 certs: Fix the config filename of admin domain 2018-10-24 20:32:03 -07:00
Girish Ramakrishnan c1db52927e Add a debug 2018-10-24 20:32:03 -07:00
Girish Ramakrishnan e7120bd086 Move renewal logic to domain model code 2018-10-24 20:32:03 -07:00
Girish Ramakrishnan 91ad94f978 No more prereleases 2018-10-24 15:28:41 -07:00
Girish Ramakrishnan ee517da4f4 Add route to renew certs of a domain 2018-10-24 13:09:41 -07:00
Girish Ramakrishnan 0d04213199 Ensure app can be uninstalled even if image format is invalid 2018-10-24 13:09:41 -07:00
Johannes Zellner 114f6c596d Use new mongodb addon with fixed return paths 2018-10-24 10:45:30 +02:00
Johannes Zellner 5dadd083be Use new mysql addon with fixed db import 2018-10-23 15:59:40 +02:00
Johannes Zellner 28d61a4d70 Use postgres addon with fixed restore 2018-10-23 13:05:39 +02:00
Girish Ramakrishnan a49969f2be Move apphealthmonitor into a cron job
This makes sure that it only runs post activation

See also a9c1af50f7
2018-10-22 20:08:49 -07:00
Girish Ramakrishnan 65eaf0792e Remove any appdata dir symlinks correctly 2018-10-18 19:58:37 -07:00
Girish Ramakrishnan 6153f45fd9 Remove legacy redis directory 2018-10-18 19:30:10 -07:00
Girish Ramakrishnan d5ffb8b118 Fix appstore casing
The API on cloudron.io wants appstore and not appStore
2018-10-18 14:21:09 -07:00
Girish Ramakrishnan bc283f1485 Validate env vars 2018-10-18 13:40:26 -07:00
Girish Ramakrishnan 2d427a86f0 Send appstoreId as part of purchase/unpurchase 2018-10-18 11:01:32 -07:00
Girish Ramakrishnan 2a6edd53b6 Update postgresql (clone/restore fix) 2018-10-17 18:49:35 -07:00
Girish Ramakrishnan cf8bb3da9e Update mysql (prefix backup and restore) 2018-10-17 18:40:48 -07:00
Girish Ramakrishnan 7c1325cb34 Update mail addon (gnutls-bin) 2018-10-17 14:18:54 -07:00
Girish Ramakrishnan f4ad912cf3 redis: do not import if not upgrading
There will be no backup to import from
2018-10-17 10:55:11 -07:00
Girish Ramakrishnan 78936a35c5 redis: Check the new dump location first
we do not cleanup the old dumps (yet). this then means that we will
constantly be importing the older dump.
2018-10-17 10:33:41 -07:00
Johannes Zellner 4ab2f8c153 Fix typo 2018-10-17 18:20:39 +02:00
Girish Ramakrishnan 858f03e02d Update redis addon container
Fixes #223
2018-10-16 15:40:40 -07:00
Girish Ramakrishnan 045cfeeb0d Move the addon startup logic to addons.js
Moved the graphite logic to new graphs.js

The settings code now does change notification itself. Over time,
it makes sense to just having settings code do this for everything
and not have this change listener logic. This lets us:
* Maybe the settings can only return based on final handler result
* All dependant modules otherwise have to "init"ed to listen on startup
* Easier to test those handlers without having to actually change the
  setting (since they will now be in "exports" naturally)

Also, maybe someday with this abstraction we can allow apps to have their
own isolated databases etc
2018-10-16 14:40:29 -07:00
Girish Ramakrishnan bbc121399e Stop containers by name before killing them outright 2018-10-16 13:24:11 -07:00
Girish Ramakrishnan 03d513a3b1 Remove infra upgrade logic
We now upgrade using the dumps of the last backups which are in the
filesystem itself instead of downloading the entire app from the
backup all over.
2018-10-16 11:06:01 -07:00
Girish Ramakrishnan 539447409e Fix mongodb addon digest 2018-10-16 10:06:04 -07:00
Johannes Zellner 4525c6f39e Revert base image change 2018-10-16 15:10:11 +02:00
Girish Ramakrishnan a6618c5813 Update mongodb (healthcheck) 2018-10-16 00:23:18 -07:00
Girish Ramakrishnan 5c1a0c1305 Check the status field for healthcheck 2018-10-16 00:21:48 -07:00
Girish Ramakrishnan 62c9fc90f9 Fix crash when oldConfig is null 2018-10-15 16:29:30 -07:00
Girish Ramakrishnan 2c60614d4b remove special rm script for redis 2018-10-15 16:14:16 -07:00
Girish Ramakrishnan 816fa94555 Upgrade the db addons using dumps when major version changes 2018-10-15 16:14:13 -07:00
Girish Ramakrishnan bbdafc6a2f pass existing infra information to start funcs 2018-10-15 14:09:13 -07:00
Girish Ramakrishnan 5333db5239 Add method to import database from dumps 2018-10-15 10:15:47 -07:00
Girish Ramakrishnan 6254fe196a Install resolvconf
When we disable systemd-resolved, the system goes into a state
where no DNS queries work anymore. Even if unbound is installed
right after. Installing 'resolvconf' fixes this.

There is competition even for this tiny package! (openresolv)
2018-10-15 08:58:37 -07:00
Girish Ramakrishnan 79d3713a4b All apps are being updated already to 1.0.0 2018-10-14 16:31:49 -07:00
Girish Ramakrishnan f1da537c80 wait for addons to start using heathcheck routes 2018-10-14 13:26:01 -07:00
Girish Ramakrishnan b19fc23cb2 Fix the tests 2018-10-12 17:05:31 -07:00
Girish Ramakrishnan 5366524dc0 Add route to set/unset the registry config 2018-10-12 17:04:04 -07:00
Girish Ramakrishnan f86d4f0755 Update manifestformat 2018-10-12 16:13:28 -07:00
Girish Ramakrishnan 3ad495528f whitespace 2018-10-11 16:18:38 -07:00
Girish Ramakrishnan 5bfb253869 Custom env vars for apps
Add a table and the install/configure routes. Initially, I thought
we can just keep the env vars in docker container but that doesn't
work since we create the container only later in apptask. And if the
container gets deleted we lose this information.
2018-10-11 16:17:18 -07:00
Girish Ramakrishnan 630fbb373c healthCheckPath is optional for non-appstore apps 2018-10-11 13:20:31 -07:00
Girish Ramakrishnan 8d09ec5ca6 Disable systemd-resolved on 18.04 2018-10-08 20:09:45 -07:00
Girish Ramakrishnan 23af20ddc9 Update graphite addon 2018-10-08 14:18:13 -07:00
Girish Ramakrishnan 86441bfeb6 Update mail container with latest base image 2018-10-08 10:08:05 -07:00
Johannes Zellner be51daabf0 Use restified addons based on new base image as v2.0.0 2018-10-08 13:41:39 +02:00
Girish Ramakrishnan 86e8db435a Add new 18.04 base image 2018-10-05 14:11:31 -07:00
Girish Ramakrishnan d8401f9ef9 Add 18.04 to changelog 2018-10-05 10:47:17 -07:00
Girish Ramakrishnan 64f98aca5a 18.04 support 2018-10-05 10:28:02 -07:00
Johannes Zellner f660947594 Do not silently retry on dns upsert in apptask 2018-10-05 17:07:34 +02:00
Girish Ramakrishnan 576e22e1a0 Update hash of mail container 2018-10-05 08:00:27 -07:00
Johannes Zellner e004a00073 Add some 3.3.0 changes 2018-10-05 16:24:32 +02:00
Johannes Zellner c0fdac5b34 The additional fetch wont hurt but makes it more readable 2018-10-05 16:21:06 +02:00
Johannes Zellner a2a035235e Use postgres addon 2.0.2 2018-10-05 16:07:22 +02:00
Girish Ramakrishnan 85ac2bbe52 Update 3.2.2 changelog
(cherry picked from commit e67fc64e65)
2018-10-04 21:21:04 -07:00
Girish Ramakrishnan c26e9bbef7 Fix debugs to match function name 2018-10-04 10:29:40 -07:00
Girish Ramakrishnan 3e85029ea1 Add 3.2.1 changes (updated dashboard)
(cherry picked from commit 0189539b27)
2018-09-30 15:03:43 -07:00
Girish Ramakrishnan 8dd3c55ecf Use async unlink 2018-09-28 17:05:53 -07:00
Girish Ramakrishnan 1ee902a541 typoe 2018-09-28 17:01:56 -07:00
Girish Ramakrishnan 55cbe46c7c godaddy: empty the text record for del 2018-09-28 14:35:57 -07:00
Girish Ramakrishnan 5a8a4e7907 acme2: Display any errors when cleaning up challenge 2018-09-28 14:33:08 -07:00
Girish Ramakrishnan 3b5be641f0 acme2: fix challenge subdomain calculation in cleanup 2018-09-28 13:24:34 -07:00
Girish Ramakrishnan a34fe120fb TXT values must be quoted 2018-09-27 20:17:39 -07:00
Girish Ramakrishnan b9918cb6fb Fix changelog ordering 2018-09-26 22:34:14 -07:00
Girish Ramakrishnan 21a86175b4 Use the local branch in hotfix
(cherry picked from commit ffb02a3ba8)
2018-09-26 22:30:23 -07:00
Girish Ramakrishnan 84150f53e7 Revert "Do not require dashboard remote branch with the same name for hotfix"
This reverts commit b2469de9b0.

This affects the release scripts and always creates dashboard tarball from master
2018-09-26 22:03:38 -07:00
Girish Ramakrishnan 42c1f8bb04 start most cron jobs only after activation
Importing the db might take some time. If a cron runs in the middle,
it crashes.

TypeError: Cannot read property 'domain' of undefined
    at Object.fqdn (/home/yellowtent/box/src/domains.js:126:111)
    at /home/yellowtent/box/src/apps.js:460:36
    at /home/yellowtent/box/node_modules/async/dist/async.js:3110:16
    at replenish (/home/yellowtent/box/node_modules/async/dist/async.js:1011:17)
    at /home/yellowtent/box/node_modules/async/dist/async.js:1016:9
    at eachLimit$1 (/home/yellowtent/box/node_modules/async/dist/async.js:3196:24)
    at Object.<anonymous> (/home/yellowtent/box/node_modules/async/dist/async.js:1046:16)
    at /home/yellowtent/box/src/apps.js:458:19
    at /home/yellowtent/box/src/appdb.js:232:13
    at Query.args.(anonymous function) [as _callback] (/home/yellowtent/box/src/database.js
2018-09-26 14:56:45 -07:00
Girish Ramakrishnan a504759b95 more debugs and comments 2018-09-26 12:39:33 -07:00
Girish Ramakrishnan af763eadd4 Fix perms issue when restoring
Fixes #536
2018-09-26 11:59:19 -07:00
Girish Ramakrishnan 818735e2c8 Fix import of older redis backups 2018-09-26 09:48:18 -07:00
Girish Ramakrishnan aefa8ed0d6 Add .eslintrc
Looks like only this supports async/await nicely
2018-09-25 18:31:15 -07:00
Girish Ramakrishnan 78d3aafd7a Fix case where mailbox already exists 2018-09-25 17:04:17 -07:00
Girish Ramakrishnan 1fed7335cf Update the db addons 2018-09-25 14:24:32 -07:00
Girish Ramakrishnan 34626abdcf reset mailboxname to .app when empty
fixes #587
2018-09-25 11:26:38 -07:00
Girish Ramakrishnan 477f8a3ca1 mysql: Add message to error string 2018-09-24 16:45:08 -07:00
Girish Ramakrishnan bd7bd2adae 3.1.4 changes 2018-09-24 09:29:58 -07:00
Girish Ramakrishnan d47fd34f66 Use alternateDomain fqdn for ensuring certificate
this makes it work for hyphenated domains as well
2018-09-22 16:26:28 -07:00
Girish Ramakrishnan f6ceee7f50 list domains only once 2018-09-22 16:19:12 -07:00
Girish Ramakrishnan a08f05fb44 Attach fqdn to all the alternateDomains 2018-09-22 15:11:01 -07:00
Girish Ramakrishnan 6214ba7b31 allow hyphenated subdomains in caas 2018-09-22 12:37:38 -07:00
Girish Ramakrishnan e69004548b waitForDnsRecord: use subdomain as argument
this allows to hyphenate the subdomain correctly in all places

the original issue was that altDomain in caas was not working
because waitForDnsRecord was not hyphenating.
2018-09-22 11:26:33 -07:00
Girish Ramakrishnan c8216d84ac register alt domains in install route 2018-09-22 10:09:46 -07:00
Girish Ramakrishnan 5cfc3b22fa Add redis healthcheck 2018-09-20 12:46:08 -07:00
Girish Ramakrishnan a755aecfc5 typo 2018-09-19 17:25:40 -07:00
Girish Ramakrishnan 64e34e13be display error messages 2018-09-19 15:55:42 -07:00
Girish Ramakrishnan e455ea987a postgresql: fix various issues during backup/restore/clear 2018-09-19 15:35:47 -07:00
Girish Ramakrishnan bf87d3fc8b Update redis sha256 2018-09-19 15:11:55 -07:00
Girish Ramakrishnan cfdb939bff redis: various fixes 2018-09-19 14:29:27 -07:00
Girish Ramakrishnan 16fab63442 Update mysql for quoting fix 2018-09-19 12:26:18 -07:00
Johannes Zellner fe14bcf155 Use updated mysql addon 2018-09-19 12:48:44 +02:00
Girish Ramakrishnan 9732b899b0 redis: backup returns 200 now 2018-09-18 14:11:42 -07:00
Girish Ramakrishnan 23b9854c57 Use REST API instead of volumes for redis 2018-09-18 13:09:24 -07:00
Girish Ramakrishnan 0b3f65c70e postgres: restore need not call setup anymore 2018-09-18 12:29:40 -07:00
Girish Ramakrishnan 83d56f79c6 lint 2018-09-18 12:22:19 -07:00
Johannes Zellner 8632939c6e Use restified mysql addon 2018-09-18 20:25:07 +02:00
Johannes Zellner 93cdba8137 Use post instead of delete for postgres clear 2018-09-18 20:23:19 +02:00
Johannes Zellner 2686a1b9e3 Fixup copy and paste errors and linter warnings 2018-09-18 20:20:27 +02:00
Johannes Zellner eec1fe1272 Start mysql addon with env vars instead of vars.sh file 2018-09-18 20:18:39 +02:00
Johannes Zellner f7f26fdf78 Fix logging in getAddonDetails() 2018-09-18 20:18:39 +02:00
Johannes Zellner d980d44833 Talk to the mysql addon via rest apis 2018-09-18 20:18:39 +02:00
Girish Ramakrishnan af4b2b075e redis: fix clear return code 2018-09-17 20:44:13 -07:00
Girish Ramakrishnan 3fcf6aa339 Add a timeout to wait for redis to start 2018-09-17 20:40:07 -07:00
Girish Ramakrishnan 1b205ac107 postgresql: fix clear route to have username and remove extra } 2018-09-17 19:53:12 -07:00
Girish Ramakrishnan bcee0aa2ad Update postgresql (reset root password fix) 2018-09-17 19:22:27 -07:00
Girish Ramakrishnan e2bf52b69d postgresql: Add missing db prefix in routes 2018-09-17 18:11:38 -07:00
Girish Ramakrishnan 3c6dffbbc7 Remove old docker images correctly
Old images had no sha256, so it prints "<none>". These images
have to be accessed by tag which uses the ":tag" format whereas
the sha images use the "@sha" format
2018-09-17 16:39:31 -07:00
Girish Ramakrishnan 691b876d61 Update postgresql image (re-setup fix) 2018-09-17 16:25:17 -07:00
Girish Ramakrishnan ed14115ff1 Fix new account return value
https://tools.ietf.org/html/draft-ietf-acme-acme-07#section-7.3
2018-09-17 15:30:16 -07:00
Girish Ramakrishnan 6d9c6ffba3 acme2: register new account returns 201 2018-09-17 15:19:19 -07:00
Johannes Zellner b8dd01d502 Bump postgres addon image again with latest fixes 2018-09-17 15:56:02 +02:00
Johannes Zellner 7e16b96abe Use updated postgres addon 2018-09-17 12:54:05 +02:00
Johannes Zellner 705d0ba7f9 Use restified postgresql addon 2018-09-16 22:56:26 +02:00
Girish Ramakrishnan 306c80dd93 Fix addons.js with the new env var names 2018-09-15 20:38:18 -07:00
Girish Ramakrishnan c8ed1d950b Update mongodb addon (restore does not clear anymore) 2018-09-15 20:29:04 -07:00
Girish Ramakrishnan 705bf3db98 Use only CLOUDRON_* prefixed vars (we reserve these) 2018-09-15 20:15:58 -07:00
Girish Ramakrishnan e2b388f721 Update redis addon that has clear route 2018-09-15 20:14:16 -07:00
Girish Ramakrishnan 3f34734933 withFileTypes is only available on node 10 2018-09-15 18:47:34 -07:00
Girish Ramakrishnan 391ee00db8 Fix cleanup of old images 2018-09-15 18:41:06 -07:00
Girish Ramakrishnan 64a7b80395 Fix issue deleting app dir during restore
During restore, only the superfluous addons are teardown. Rest of
the addons are kept as-is. This is done to preserve the addon
configuration across restores (thus preserving db passwords).

We then rely on the restoreApp logic to call restore in each addon.
Each restore currently setup, clear and them imports the backup.

With the volume changes, we have moved volume create/delete to the
localstorage addon. Currently, the localstorage addon has no concept of restore
because the backup download extracts it directly into the volume.

Because of the above, during the restore code path, we don't teardown
the localstorage addon and thus files are left behind in appid/data/. This
prevents deleteAppDir() from removing the appid/ directory.

The fix is to add a new 'clear' command to addons. Before restore, we
clear the addons.
2018-09-15 17:35:47 -07:00
Girish Ramakrishnan 46a00c839b rename create/deleteVolume to avoid confusion 2018-09-15 15:30:26 -07:00
Girish Ramakrishnan 9f6621434f Use sha256 to accidentally prevent overwritten tags 2018-09-14 20:20:46 -07:00
Johannes Zellner 3963eb687f Use the sudo script for app volume 2018-09-14 14:37:20 +02:00
Johannes Zellner 56cd97147a Unify the code to get addon container ip and token 2018-09-14 13:26:56 +02:00
Johannes Zellner 614b3ed5d1 Remove unused variable 2018-09-14 13:19:47 +02:00
Johannes Zellner 9f622c5e65 Do not store the cloudron redis service token in the addon env blob 2018-09-14 13:19:33 +02:00
Girish Ramakrishnan 4f6a467181 bump minor version to make apps reconfigure themselves
this is because the container configuration has changed
(we create volume)
2018-09-13 15:47:08 -07:00
Girish Ramakrishnan a46e208c63 Use docker volumes instead of bind mount for app data 2018-09-13 15:35:41 -07:00
Johannes Zellner a0fd60408b Use https with selfsigned certs for addon service calls 2018-09-13 21:37:55 +02:00
Johannes Zellner ffbbba938a Replace superagent with request for talking to addons 2018-09-13 12:54:33 +02:00
Johannes Zellner a222b3ed58 Use new restified mongodb and redis addons 2018-09-13 12:54:33 +02:00
Girish Ramakrishnan 6ba574432a calculate subdomain correctly for non-wildcard domains 2018-09-12 15:55:20 -07:00
Girish Ramakrishnan 96075c7c20 Fix double callback 2018-09-12 14:43:15 -07:00
Girish Ramakrishnan 64665542bc select app's cert based on domain's wildcard flag
this also removes the confusing type field in the bundle. we instead
check the current nginx config to see what cert is in use.
2018-09-12 14:22:54 -07:00
Girish Ramakrishnan 54d2a4f17b rework args to ensureCertificate 2018-09-12 12:50:04 -07:00
Girish Ramakrishnan 1d829c4af2 Allow wildcard only with programmable DNS backend 2018-09-12 12:36:13 -07:00
Girish Ramakrishnan dc4dc05628 Fixup changes file 2018-09-12 10:00:16 -07:00
Johannes Zellner b2469de9b0 Do not require dashboard remote branch with the same name for hotfix 2018-09-12 12:56:24 +02:00
Johannes Zellner 812d3576a9 Use mongodb rest api for backup and restore 2018-09-12 12:56:24 +02:00
Johannes Zellner 6012eb7898 docker inspect and the rest api have a different output format 2018-09-12 12:56:24 +02:00
Johannes Zellner a591b5910e Restify mongodb addon 2018-09-12 12:56:24 +02:00
Johannes Zellner 1e084e98d1 Change mongodb addon from env vars file to passing directly 2018-09-12 12:56:24 +02:00
Johannes Zellner b4224a7f8d Call the rest route on redis backup
The addons are an array of key/value
2018-09-12 12:56:24 +02:00
Johannes Zellner ab8a010b94 Redis does not use the vars.sh file anymore 2018-09-12 12:56:24 +02:00
Johannes Zellner 4c164c17cf Support internal only addon configs with redis persistent token 2018-09-12 12:56:24 +02:00
Johannes Zellner 650f181a07 Provision redis addon with new set of required env vars 2018-09-12 12:56:24 +02:00
Girish Ramakrishnan 97ab521038 make ensureCertificate check any wildcard cert 2018-09-11 23:47:23 -07:00
Girish Ramakrishnan c138c4bb5f acme2: implement wildcard certs 2018-09-11 23:15:50 -07:00
Girish Ramakrishnan 1067ff882a Move type validation to routes logic 2018-09-11 22:17:40 -07:00
Girish Ramakrishnan b6ad6e121b refactor to validateTlsConfig 2018-09-11 21:53:18 -07:00
Girish Ramakrishnan a756345138 consolidate hyphenatedSubdomains handling 2018-09-11 21:41:50 -07:00
Girish Ramakrishnan 35f69cfea9 acme2: wait for dns 2018-09-11 19:41:41 -07:00
Girish Ramakrishnan 3f0bc6165b Enhance waitForDns to support TXT records 2018-09-11 19:41:38 -07:00
Girish Ramakrishnan d0dde04695 acme2: dns authorization 2018-09-10 21:46:53 -07:00
Girish Ramakrishnan 2f38a4018c pass domain arg to getCertificate API 2018-09-10 20:48:47 -07:00
Girish Ramakrishnan 2c76716bc7 rename func 2018-09-10 20:35:48 -07:00
Girish Ramakrishnan f38b87c660 lint 2018-09-10 20:30:38 -07:00
Girish Ramakrishnan 9bac2acc37 Fix callback use 2018-09-10 17:39:13 -07:00
Girish Ramakrishnan 68536b6d7d acme2 implementation 2018-09-10 16:26:24 -07:00
Girish Ramakrishnan 1d0a52404a Fix tests 2018-09-10 13:45:05 -07:00
Girish Ramakrishnan 017460b497 acme -> acme1 2018-09-10 10:57:48 -07:00
Girish Ramakrishnan 8efd496579 storage: add access denied function (unused) 2018-09-10 10:41:08 -07:00
Girish Ramakrishnan 229fe0f66f Add 3.1.4 changes 2018-09-10 09:28:08 -07:00
Girish Ramakrishnan efa36ab161 cloudflare: Add the chain message 2018-09-07 11:46:10 -07:00
Girish Ramakrishnan 191c85d01f cloudflare: priority is now an integer 2018-09-07 11:44:31 -07:00
Girish Ramakrishnan 88330ab415 Return 424 for external errors 2018-09-07 11:27:19 -07:00
Girish Ramakrishnan 76f5b22c07 dns: implement wildcard dns validation 2018-09-06 20:45:10 -07:00
Girish Ramakrishnan 0639ca1594 Make wildcard a separate provider
this is required because the config object is not returned for
locked domains and the UI display for the provider field is then
wrong.
2018-09-06 20:09:08 -07:00
Girish Ramakrishnan e620a26c04 typo 2018-09-06 20:05:08 -07:00
Girish Ramakrishnan 867c3595b2 more changes 2018-09-06 13:18:26 -07:00
Girish Ramakrishnan df66d77a68 cloudflare: Fix crash when there is an external error updating dns records 2018-09-06 12:26:11 -07:00
Girish Ramakrishnan 5e919b90f5 Better fix for grub 2018-09-06 11:56:50 -07:00
Girish Ramakrishnan 428269f503 3.1.3 changes 2018-09-06 00:41:45 -07:00
Girish Ramakrishnan b03e26a510 Fix typo 2018-09-06 00:41:37 -07:00
Girish Ramakrishnan 1e15b63a4a prevent deletion of mail domain as well 2018-09-06 00:16:12 -07:00
Girish Ramakrishnan 8d5e70f6aa lock the admin domain based on the edition 2018-09-06 00:15:45 -07:00
Girish Ramakrishnan 91a1bc7a01 move verifyOperator to users routes 2018-09-06 00:10:09 -07:00
Girish Ramakrishnan 0e3f9c9569 Move verifyAppOwnership to app route 2018-09-06 00:09:42 -07:00
Girish Ramakrishnan 2ad0a57fc1 Typo 2018-09-05 23:59:40 -07:00
Girish Ramakrishnan def3521ee1 Do not allow admin domain to be deleted 2018-09-05 17:12:02 -07:00
Girish Ramakrishnan 3d004b3dcc Disable various server/operator routes based on edition
The initial idea was to put an owner flag but this means that the
owner will be visible inside apps.
2018-09-05 15:31:58 -07:00
Girish Ramakrishnan 0439bd8869 move addSpacesSuffix to model logic 2018-09-04 16:37:08 -07:00
Girish Ramakrishnan 10b4043358 Add alternateDomains to app install route 2018-09-04 16:27:35 -07:00
Girish Ramakrishnan ac3b0f0082 Add spaces suffix for alternate domains 2018-09-04 14:12:50 -07:00
Girish Ramakrishnan d49a1dea7a Fix usage of domains.fqdn 2018-09-04 11:35:01 -07:00
Girish Ramakrishnan ec9c96da6f Fix comments 2018-09-04 10:48:54 -07:00
Johannes Zellner 2de630e491 Put the app owner also into ldap groups
Fixes #585
2018-09-03 17:14:11 +02:00
Johannes Zellner 3af358b9bc List app owner as admins in ldap search 2018-09-03 16:08:05 +02:00
Johannes Zellner b61478edc9 Attach req.app for further use in ldap routes 2018-09-03 15:38:50 +02:00
Johannes Zellner b23afdd32d Fix tests to match the adjusted purchase logic 2018-09-01 11:53:05 +02:00
Girish Ramakrishnan 43055da614 Add route to let admin set user password 2018-08-31 14:35:01 -07:00
Girish Ramakrishnan 2c3f1ab720 Fix the error messages 2018-08-31 14:06:06 -07:00
Johannes Zellner 35a31922a5 Always run all tests 2018-08-31 22:25:06 +02:00
Girish Ramakrishnan bf432dc26f Revert "Disable memory accounting setup"
This reverts commit ad22df6f71.

This actually serves the purpose of enabling swap.

https://raw.githubusercontent.com/moby/moby/master/contrib/check-config.sh
2018-08-31 13:21:33 -07:00
Johannes Zellner 18cc93799e Fully rely on the appstore during 'purchase' call 2018-08-31 10:53:35 +02:00
Girish Ramakrishnan fc3bc48f47 Fix crash when location is not provided 2018-08-30 22:08:48 -07:00
Girish Ramakrishnan fc96f59ecc Add edition checks 2018-08-30 21:20:49 -07:00
Girish Ramakrishnan 534a00b3af Disallow dot in location in hyphenatedSubdomains 2018-08-30 21:03:54 -07:00
Girish Ramakrishnan 619d1e44e5 Move validateHostname to domains code 2018-08-30 20:54:15 -07:00
Girish Ramakrishnan 068113bd5d remove redundant domain arg to domains.fqdn 2018-08-30 19:57:12 -07:00
Girish Ramakrishnan ca16072d90 hypenatedSubdomains: do not hyphenate txt dns records 2018-08-30 16:17:56 -07:00
Girish Ramakrishnan 6fac59cf9d cloudron-setup: print before we shift all the args 2018-08-30 14:30:49 -07:00
Girish Ramakrishnan f953cfc4d5 cloudron-setup: print the args to the log file 2018-08-30 14:06:25 -07:00
Johannes Zellner 7a1723d173 Fix app tests 2018-08-30 00:32:38 +02:00
Johannes Zellner b6643518f6 Adjust apps test to how we check subscriptions 2018-08-29 23:57:59 +02:00
Johannes Zellner 91470156c9 Only remove all app container on uninstall
For update and configure, we do not want to purge potential docker addon
created ones. They are managed by the app itself.
2018-08-29 21:28:58 +02:00
Johannes Zellner 40c6ab5615 Remove unused require 2018-08-29 21:25:02 +02:00
Girish Ramakrishnan 6cc4e44f22 pass edition to update.sh 2018-08-28 22:14:27 -07:00
Girish Ramakrishnan 976cf1740e Put edition in status route 2018-08-28 21:43:25 -07:00
Girish Ramakrishnan 22cdd3f55e spaces: add username suffix to location 2018-08-28 20:27:17 -07:00
Girish Ramakrishnan e0cd7999eb Make spaces an edition instead of setting 2018-08-28 18:31:48 -07:00
Johannes Zellner 4f7242fa6a add --edition option to cloudron-setup 2018-08-28 18:06:25 -07:00
Johannes Zellner 964da5ee52 Send the edition in the config route 2018-08-28 18:05:45 -07:00
Johannes Zellner baa99d1a44 add edition property to cloudron.conf 2018-08-28 18:05:15 -07:00
Girish Ramakrishnan 6d1cb1bb14 Do not update grub packages
It looks on some VPS, the grub then asks the user to re-install
since the disk name has changed/moved (presumably because the
image was created on a server with a different disk name)
2018-08-28 14:33:07 -07:00
Girish Ramakrishnan f7e6c5cd40 Add galaxygate as a provider 2018-08-28 12:25:16 -07:00
Girish Ramakrishnan ad22df6f71 Disable memory accounting setup
Overall, this is outside the scope of Cloudron
2018-08-28 12:18:31 -07:00
Girish Ramakrishnan 8e572a7c23 Fix changelog 2018-08-27 16:05:01 -07:00
Girish Ramakrishnan e49b57294d Give optional name for tokens 2018-08-27 14:59:52 -07:00
Girish Ramakrishnan badb6e4672 add note on clients.appId 2018-08-27 14:30:39 -07:00
Girish Ramakrishnan d09ff985af remove all user tokens when user is deleted 2018-08-27 14:05:22 -07:00
Girish Ramakrishnan a3130c8aab inviteLink is not available during user create anymore 2018-08-27 10:40:58 -07:00
Girish Ramakrishnan 0843d51c98 Check for existing port bindings in udpPorts 2018-08-26 17:50:17 -07:00
Girish Ramakrishnan 9a1b5dd5cc Add 3.1.1 changelog 2018-08-25 18:32:02 -07:00
Girish Ramakrishnan 6f398144cb validate hyphenatedSubdomain 2018-08-25 18:29:55 -07:00
Girish Ramakrishnan d91df50b9f caas: add hyphenateSubdomain property 2018-08-25 17:49:31 -07:00
Johannes Zellner 99ead48fd5 Add more changes 2018-08-25 11:14:14 +02:00
Johannes Zellner 922b6d2b18 Also renew alternate domain certificates
Part of #583
2018-08-25 11:04:53 +02:00
Girish Ramakrishnan 6a3b45223a Better name for internal functions 2018-08-24 15:38:44 -07:00
Girish Ramakrishnan 93b0f39545 ensureCertificate: make it take appDomain object 2018-08-24 15:07:13 -07:00
Girish Ramakrishnan dbe86af31e Make getApi takes a string domain 2018-08-24 15:00:19 -07:00
Girish Ramakrishnan 3ede50a141 remove unused function 2018-08-24 14:11:02 -07:00
Girish Ramakrishnan e607fe9a41 Specify subdomain fields explicitly 2018-08-24 10:39:59 -07:00
Johannes Zellner 43d125b216 Send hyphenatedSubdomains as non restricted property 2018-08-22 17:19:18 +02:00
Johannes Zellner 9467a2922a Return hyphenated subdomain name in getName() 2018-08-22 14:13:48 +02:00
Johannes Zellner b35c81e546 Handle hyphenatedSubdomains in the backend verifyDnsConfig() 2018-08-22 12:16:19 +02:00
Johannes Zellner 59700e455e Use the full domain record for domains.fqdn() 2018-08-22 12:15:46 +02:00
Johannes Zellner e8fcfc4594 Allow to specify hyphenatedSubdomains for a domain 2018-08-22 11:59:42 +02:00
Johannes Zellner 9bac7e8124 Move the comment where it belongs 2018-08-22 11:02:06 +02:00
Girish Ramakrishnan 210c453508 More changes 2018-08-21 21:25:07 -07:00
Girish Ramakrishnan 442d4e5c6f Fix failing tests 2018-08-21 18:57:11 -07:00
Girish Ramakrishnan af63cb936d More changes 2018-08-21 18:47:12 -07:00
Girish Ramakrishnan b4c9f64721 Issue token on password reset and setup 2018-08-21 18:42:18 -07:00
Girish Ramakrishnan c64a29e6fc More 3.1.0 changes 2018-08-21 17:06:51 -07:00
Johannes Zellner f05df7cfef Allow set admin flag on user creation 2018-08-21 17:12:46 +02:00
Johannes Zellner f4a76a26af Remove left-over docker proxy start call in platform.js 2018-08-21 15:31:53 +02:00
Girish Ramakrishnan f338e015d5 Revert "Also allow docker in containers spawned by an authorized app"
This reverts commit 4f336a05fc.

This is not required by an app yet. Besides, it breaks tests
2018-08-20 20:10:16 -07:00
Girish Ramakrishnan 89cf8167e6 Make tests work 2018-08-20 20:10:14 -07:00
Johannes Zellner d5194cfdc9 Remove nativeLogging docker addon support
Was only required for eclipse che
2018-08-20 15:22:10 +02:00
Johannes Zellner eb07d3d543 Drop all custom network configs in docker proxy 2018-08-20 15:19:08 +02:00
Girish Ramakrishnan 6a1a697820 Split the invite route into two 2018-08-17 16:27:29 -07:00
Girish Ramakrishnan e5cc81d8fa Fix test name 2018-08-17 13:45:10 -07:00
Johannes Zellner 3640b0bd0e Allow to override the logging backend for app like che 2018-08-17 15:30:37 +02:00
Johannes Zellner 4d4ce9b86e Bare bones support of docker exec through the proxy 2018-08-17 15:30:23 +02:00
Johannes Zellner db385c6770 Ensure the docker proxy tests cleanup correctly 2018-08-17 13:44:12 +02:00
Johannes Zellner 2925e98d54 Make PUT requests through the docker proxy work 2018-08-17 12:33:46 +02:00
Girish Ramakrishnan 75ee40865e 3.0.2 changes 2018-08-16 23:30:20 -07:00
Girish Ramakrishnan af58e56732 Fix issue where normal users are shown all apps 2018-08-16 20:04:03 -07:00
Johannes Zellner dc3e3f5f4d Ensure we pipe the parsed body again upstream to docker 2018-08-16 14:28:51 +02:00
Johannes Zellner 83304ff66c Verify that docker logs is correctly reporting another logging driver 2018-08-16 12:07:15 +02:00
Johannes Zellner 575e0cea33 Use syslog for containers created in apps 2018-08-16 11:37:08 +02:00
Girish Ramakrishnan 0bf3b45ddc Fix bind mapping logic 2018-08-15 16:52:30 -07:00
Johannes Zellner 826a0e7708 Add test case for docker logs through the proxy 2018-08-15 18:16:03 +02:00
Johannes Zellner 0522d1e3c4 Simply prefix all docker volume mounts with the app data dir 2018-08-15 18:00:51 +02:00
Johannes Zellner 12970bf50a Add some debugs for volume rewriting 2018-08-15 16:51:10 +02:00
Johannes Zellner 4a739213bf When creating a container the Labels are toplevel 2018-08-15 12:51:52 +02:00
Johannes Zellner 4f336a05fc Also allow docker in containers spawned by an authorized app 2018-08-15 12:35:34 +02:00
Girish Ramakrishnan c3dacba894 dockerproxy: rewrite labels and binds 2018-08-14 20:49:41 -07:00
Girish Ramakrishnan f88c01eea6 dockerproxy: Add app authorization 2018-08-14 20:04:15 -07:00
Girish Ramakrishnan 15b0dfcb60 rename variable 2018-08-14 19:03:59 -07:00
Girish Ramakrishnan ebd27b444d dockerproxy: use express 2018-08-14 18:59:41 -07:00
Johannes Zellner ee1c587922 Overwrite the docker container network in the proxy 2018-08-14 22:54:02 +02:00
Johannes Zellner 4da91ec90d Make the docker proxy work 2018-08-14 22:54:02 +02:00
Girish Ramakrishnan 3cf3c36e86 Fix tests 2018-08-13 22:54:56 -07:00
Girish Ramakrishnan 8bd6c9933f Add interval secs
Part of #568
2018-08-13 22:35:38 -07:00
Girish Ramakrishnan 2e0a7dcd47 Fix migration filename 2018-08-13 22:31:58 -07:00
Johannes Zellner 714c205538 Set the correct debug label 2018-08-13 22:06:28 +02:00
Johannes Zellner 00041add55 No need to pull in underscore to build an object 2018-08-13 22:01:51 +02:00
Johannes Zellner 7f5fe12712 Use DOCKER_HOST to make the cli work out of the box 2018-08-13 21:59:02 +02:00
Johannes Zellner 441fdb81f8 Move docker proxy into its own file 2018-08-13 21:14:17 +02:00
Johannes Zellner fb02e8768c Remove unused require 2018-08-13 21:05:07 +02:00
Johannes Zellner 14f0f954b7 Use docker proxy over DOCKER_URL env var when docker addon is used 2018-08-13 20:47:15 +02:00
Johannes Zellner 10f0d48b2a Use docker proxy port from config 2018-08-13 20:41:02 +02:00
Johannes Zellner 6933184c2e Revert "Expose the host /app/data folder when localstorage and docker addons are used"
We will use the docker proxy for that

This reverts commit b3aa59de19.
2018-08-13 20:39:23 +02:00
Johannes Zellner a1b983de23 Initial code for docker addon proxy 2018-08-13 20:38:39 +02:00
Johannes Zellner b3aa59de19 Expose the host /app/data folder when localstorage and docker addons are used 2018-08-13 19:40:41 +02:00
Girish Ramakrishnan 796ced999f Add some 3.1 changes 2018-08-13 09:18:44 -07:00
Girish Ramakrishnan 353b5e07bf Save correct type of port in db
Part of #504
2018-08-13 08:33:17 -07:00
Girish Ramakrishnan c29eef4c14 Set the udp ports in docker configuration
Part of #504
2018-08-12 22:47:59 -07:00
Girish Ramakrishnan 8bc7dc9724 Pass the manifest to validatePortBindings 2018-08-12 22:37:36 -07:00
Girish Ramakrishnan 60984d18dd Add type field to port bindings table
Part of #504
2018-08-12 22:32:36 -07:00
Girish Ramakrishnan df1dc80fc1 Change the internal portBindings representation
Part of #504
2018-08-12 22:32:31 -07:00
Girish Ramakrishnan 8e2f0cdf73 Update cloudron-manifestformat (udpPorts) 2018-08-12 19:00:28 -07:00
Ian Fijolek bf1e19f8e6 Add more detailed checking of DMARC
Fixes #570
2018-08-12 13:47:24 -07:00
Girish Ramakrishnan 9a7214ea07 Update manifestformat for docker addon 2018-08-10 12:31:46 -07:00
Johannes Zellner 4499f08357 Revert "Remove _docker addon"
This reverts commit d6f49eb54f.
2018-08-09 11:54:46 +02:00
177 changed files with 9587 additions and 5924 deletions
+29
View File
@@ -0,0 +1,29 @@
{
"env": {
"es6": true,
"node": true
},
"extends": "eslint:recommended",
"parserOptions": {
"ecmaVersion": 2017
},
"rules": {
"indent": [
"error",
4
],
"linebreak-style": [
"error",
"unix"
],
"quotes": [
"error",
"single"
],
"semi": [
"error",
"always"
],
"no-console": "off"
}
}
-1
View File
@@ -6,4 +6,3 @@ installer/src/certs/server.key
# vim swap files
*.swp
-9
View File
@@ -1,9 +0,0 @@
{
"node": true,
"browser": true,
"unused": true,
"multistr": true,
"globalstrict": true,
"predef": [ "angular", "$" ],
"esnext": true
}
+122 -1
View File
@@ -1351,5 +1351,126 @@
[3.0.2]
* Fix issue where normal users are shown apps they don't have access to
* Re-configure mail apps when mail is enabled/disabled
* Re-configure email apps when email is enabled/disabled
[3.1.0]
* Add UDP support
* Clicking invite button does not send an invite immediately
* Implement docker addon
* Automatically login after password reset and account setup
* Make backup interval configurable
* Fix alternate domain certificate renewal
[3.1.1]
* Fix caas domain migration
[3.1.2]
* Add UDP support
* Clicking invite button does not send an invite immediately
* Implement docker addon
* Automatically login after password reset and account setup
* Make backup interval configurable
* Fix alternate domain certificate renewal
* API token can now have a name
[3.1.3]
* Prevent dashboard domain from being deleted
* Add alternateDomains to app install route
* cloudflare: Fix crash when access denied
[3.1.4]
* Fix issue where support tab was redirecting
[3.2.0]
* Add DO Spaces SFO2 region
* Wildcard DNS now validates the config
* Add ACMEv2 support
* Add Wildcard Let's Encrypt provider
[3.2.1]
* Add acme2 support. This provides DNS based validation removing inbound port 80 requirement
* Add support for wildcard certificates
* Allow mailbox name to be reset to the buit-in '.app' name
* Fix permission issue when restoring a Cloudron
* Fix a crash when restoring Cloudron
* Allow alternate domains to be set in app installation REST API
* Add SFO2 region for DigitalOcean Spaces
* Show the title in port bindings instead of the long description
[3.2.2]
* Update Haraka to 2.8.20
* (mail) Fix issue where LDAP connections where not cleaned up
[3.3.0]
* Use new addons with REST APIs
* Ubuntu 18.04 LTS support
* Custom env vars can be set per application
* Add a button to renew certs
* Add better support for private builds
* cloudflare: Fix crash when using bad email
* cloudflare: HTTP proxying works now
* add new exoscale-sos regions
* Add UI to toggle dynamic DNS
* Add support for hyphenated subdomains
[3.3.1]
* Use new addons with REST APIs
* Ubuntu 18.04 LTS support
* Custom env vars can be set per application
* Add a button to renew certs
* Add better support for private builds
* cloudflare: Fix crash when using bad email
* cloudflare: HTTP proxying works now
* add new exoscale-sos regions
* Add UI to toggle dynamic DNS
* Add support for hyphenated subdomains
[3.3.2]
* Use new addons with REST APIs
* Ubuntu 18.04 LTS support
* Custom env vars can be set per application
* Add a button to renew certs
* Add better support for private builds
* cloudflare: Fix crash when using bad email
* cloudflare: HTTP proxying works now
* add new exoscale-sos regions
* Add UI to toggle dynamic DNS
* Add support for hyphenated subdomains
* Add domain, mail events to eventlog
[3.3.3]
* Use new addons with REST APIs
* Ubuntu 18.04 LTS support
* Custom env vars can be set per application
* Add a button to renew certs
* Add better support for private builds
* cloudflare: Fix crash when using bad email
* cloudflare: HTTP proxying works now
* add new exoscale-sos regions
* Add UI to toggle dynamic DNS
* Add support for hyphenated subdomains
* Add domain, mail events to eventlog
[3.3.4]
* Use new addons with REST APIs
* Ubuntu 18.04 LTS support
* Custom env vars can be set per application
* Add a button to renew certs
* Add better support for private builds
* cloudflare: Fix crash when using bad email
* cloudflare: HTTP proxying works now
* add new exoscale-sos regions
* Add UI to toggle dynamic DNS
* Add support for hyphenated subdomains
* Add domain, mail events to eventlog
[3.4.0]
* Improve error page
* Add system view to manage addons and view their status
* Fix iconset regression for account and Cloudron name edits
* Add server reboot button and warn if reboot is required for security updates
* Backup and update tasks are now cancelable
* Move graphite away from port 3000 (reserved by ESXi)
* Flexible mailbox management
* Automatic updates can be toggled per app
+1
View File
@@ -0,0 +1 @@
# release version. do not edit manually
+21 -4
View File
@@ -14,8 +14,11 @@ function die {
export DEBIAN_FRONTEND=noninteractive
# hold grub since updating it breaks on some VPS providers. also, dist-upgrade will trigger it
apt-mark hold grub* >/dev/null
apt-get -o Dpkg::Options::="--force-confdef" update -y
apt-get -o Dpkg::Options::="--force-confdef" dist-upgrade -y
apt-get -o Dpkg::Options::="--force-confdef" upgrade -y
apt-mark unhold grub* >/dev/null
echo "==> Installing required packages"
@@ -23,6 +26,7 @@ debconf-set-selections <<< 'mysql-server mysql-server/root_password password pas
debconf-set-selections <<< 'mysql-server mysql-server/root_password_again password password'
# this enables automatic security upgrades (https://help.ubuntu.com/community/AutomaticSecurityUpdates)
# resolvconf is needed for unbound to work property after disabling systemd-resolved in 18.04
apt-get -y install \
acl \
awscli \
@@ -31,12 +35,14 @@ apt-get -y install \
curl \
dmsetup \
iptables \
libpython2.7 \
logrotate \
mysql-server-5.7 \
nginx-full \
openssh-server \
pwgen \
rcconf \
resolvconf \
sudo \
swaks \
unattended-upgrades \
unbound \
@@ -72,8 +78,9 @@ if [[ "${storage_driver}" != "overlay2" ]]; then
exit 1
fi
# do not upgrade grub because it might prompt user and break this script
echo "==> Enable memory accounting"
apt-get -y install grub2
apt-get -y --no-upgrade install grub2-common
sed -e 's/^GRUB_CMDLINE_LINUX="\(.*\)"$/GRUB_CMDLINE_LINUX="\1 cgroup_enable=memory swapaccount=1 panic_on_oops=1 panic=5"/' -i /etc/default/grub
update-grub
@@ -83,11 +90,12 @@ if [ ! -f "${arg_infraversionpath}/infra_version.js" ]; then
exit 1
fi
images=$(node -e "var i = require('${arg_infraversionpath}/infra_version.js'); console.log(i.baseImages.join(' '), Object.keys(i.images).map(function (x) { return i.images[x].tag; }).join(' '));")
images=$(node -e "var i = require('${arg_infraversionpath}/infra_version.js'); console.log(i.baseImages.map(function (x) { return x.tag; }).join(' '), Object.keys(i.images).map(function (x) { return i.images[x].tag; }).join(' '));")
echo -e "\tPulling docker images: ${images}"
for image in ${images}; do
docker pull "${image}"
docker pull "${image%@sha256:*}" # this will tag the image for readability
done
echo "==> Install collectd"
@@ -97,6 +105,11 @@ if ! apt-get install -y collectd collectd-utils; then
sed -e 's/^FQDNLookup true/FQDNLookup false/' -i /etc/collectd/collectd.conf
fi
echo "==> Configuring host"
sed -e 's/^#NTP=/NTP=0.ubuntu.pool.ntp.org 1.ubuntu.pool.ntp.org 2.ubuntu.pool.ntp.org 3.ubuntu.pool.ntp.org/' -i /etc/systemd/timesyncd.conf
timedatectl set-ntp 1
timedatectl set-timezone UTC
# Disable bind for good measure (on online.net, kimsufi servers these are pre-installed and conflicts with unbound)
systemctl stop bind9 || true
systemctl disable bind9 || true
@@ -109,3 +122,7 @@ systemctl disable dnsmasq || true
systemctl stop postfix || true
systemctl disable postfix || true
# on ubuntu 18.04, this is the default. this requires resolvconf for DNS to work further after the disable
systemctl stop systemd-resolved || true
systemctl disable systemd-resolved || true
+21 -8
View File
@@ -2,17 +2,21 @@
'use strict';
// prefix all output with a timestamp
// debug() already prefixes and uses process.stderr NOT console.*
['log', 'info', 'warn', 'debug', 'error'].forEach(function (log) {
var orig = console[log];
console[log] = function () {
orig.apply(console, [new Date().toISOString()].concat(Array.prototype.slice.call(arguments)));
};
});
require('supererror')({ splatchError: true });
// remove timestamp from debug() based output
require('debug').formatArgs = function formatArgs(args) {
args[0] = this.namespace + ' ' + args[0];
};
var appHealthMonitor = require('./src/apphealthmonitor.js'),
async = require('async'),
let async = require('async'),
config = require('./src/config.js'),
ldap = require('./src/ldap.js'),
dockerProxy = require('./src/dockerproxy.js'),
server = require('./src/server.js');
console.log();
@@ -25,6 +29,9 @@ console.log(' Version: ', config.version());
console.log(' Admin Origin: ', config.adminOrigin());
console.log(' Appstore API server origin: ', config.apiServerOrigin());
console.log(' Appstore Web server origin: ', config.webServerOrigin());
console.log(' SysAdmin Port: ', config.get('sysadminPort'));
console.log(' LDAP Server Port: ', config.get('ldapPort'));
console.log(' Docker Proxy Port: ', config.get('dockerProxyPort'));
console.log();
console.log('==========================================');
console.log();
@@ -32,7 +39,7 @@ console.log();
async.series([
server.start,
ldap.start,
appHealthMonitor.start,
dockerProxy.start
], function (error) {
if (error) {
console.error('Error starting server', error);
@@ -44,13 +51,19 @@ async.series([
var NOOP_CALLBACK = function () { };
process.on('SIGINT', function () {
console.log('Received SIGINT. Shutting down.');
server.stop(NOOP_CALLBACK);
ldap.stop(NOOP_CALLBACK);
dockerProxy.stop(NOOP_CALLBACK);
setTimeout(process.exit.bind(process), 3000);
});
process.on('SIGTERM', function () {
console.log('Received SIGTERM. Shutting down.');
server.stop(NOOP_CALLBACK);
ldap.stop(NOOP_CALLBACK);
dockerProxy.stop(NOOP_CALLBACK);
setTimeout(process.exit.bind(process), 3000);
});
@@ -1,7 +1,7 @@
'use strict';
exports.up = function(db, callback) {
var cmd = "CREATE TABLE groups(" +
var cmd = "CREATE TABLE userGroups(" +
"id VARCHAR(128) NOT NULL UNIQUE," +
"name VARCHAR(128) NOT NULL UNIQUE," +
"PRIMARY KEY(id))";
@@ -13,7 +13,7 @@ exports.up = function(db, callback) {
};
exports.down = function(db, callback) {
db.runSql('DROP TABLE groups', function (error) {
db.runSql('DROP TABLE userGroups', function (error) {
if (error) console.error(error);
callback(error);
});
@@ -4,7 +4,7 @@ exports.up = function(db, callback) {
var cmd = "CREATE TABLE IF NOT EXISTS groupMembers(" +
"groupId VARCHAR(128) NOT NULL," +
"userId VARCHAR(128) NOT NULL," +
"FOREIGN KEY(groupId) REFERENCES groups(id)," +
"FOREIGN KEY(groupId) REFERENCES userGroups(id)," +
"FOREIGN KEY(userId) REFERENCES users(id));";
db.runSql(cmd, function (error) {
@@ -7,7 +7,7 @@ var ADMIN_GROUP_ID = 'admin'; // see constants.js
exports.up = function(db, callback) {
async.series([
db.runSql.bind(db, 'START TRANSACTION;'),
db.runSql.bind(db, 'INSERT INTO groups (id, name) VALUES (?, ?)', [ ADMIN_GROUP_ID, 'admin' ]),
db.runSql.bind(db, 'INSERT INTO userGroups (id, name) VALUES (?, ?)', [ ADMIN_GROUP_ID, 'admin' ]),
function migrateAdminFlag(done) {
db.all('SELECT * FROM users WHERE admin=1', function (error, results) {
if (error) return done(error);
@@ -10,7 +10,7 @@ exports.up = function(db, callback) {
function addGroupMailboxes(done) {
console.log('Importing group mailboxes');
db.all('SELECT id, name FROM groups', function (error, results) {
db.all('SELECT id, name FROM userGroups', function (error, results) {
if (error) return done(error);
async.eachSeries(results, function (g, next) {
@@ -16,7 +16,7 @@ exports.up = function(db, callback) {
db.runSql.bind(db, 'ALTER TABLE clients CONVERT TO CHARACTER SET utf8 COLLATE utf8_bin'),
db.runSql.bind(db, 'ALTER TABLE eventlog CONVERT TO CHARACTER SET utf8 COLLATE utf8_bin'),
db.runSql.bind(db, 'ALTER TABLE groupMembers CONVERT TO CHARACTER SET utf8 COLLATE utf8_bin'),
db.runSql.bind(db, 'ALTER TABLE groups CONVERT TO CHARACTER SET utf8 COLLATE utf8_bin'),
db.runSql.bind(db, 'ALTER TABLE userGroups CONVERT TO CHARACTER SET utf8 COLLATE utf8_bin'),
db.runSql.bind(db, 'ALTER TABLE mailboxes CONVERT TO CHARACTER SET utf8 COLLATE utf8_bin'),
db.runSql.bind(db, 'ALTER TABLE migrations CONVERT TO CHARACTER SET utf8 COLLATE utf8_bin'),
db.runSql.bind(db, 'ALTER TABLE settings CONVERT TO CHARACTER SET utf8 COLLATE utf8_bin'),
+1 -1
View File
@@ -29,7 +29,7 @@ exports.up = function(db, callback) {
// this will be finally created once we have a domain when we create the owner in user.js
const ADMIN_GROUP_ID = 'admin'; // see constants.js
db.runSql('DELETE FROM groups WHERE id = ?', [ ADMIN_GROUP_ID ], function (error) {
db.runSql('DELETE FROM userGroups WHERE id = ?', [ ADMIN_GROUP_ID ], function (error) {
if (error) return done(error);
db.runSql('DELETE FROM mailboxes WHERE ownerId = ?', [ ADMIN_GROUP_ID ], done);
@@ -19,8 +19,8 @@ exports.up = function(db, callback) {
},
function getGroups(done) {
db.all('SELECT id, name, GROUP_CONCAT(groupMembers.userId) AS userIds ' +
' FROM groups LEFT OUTER JOIN groupMembers ON groups.id = groupMembers.groupId ' +
' GROUP BY groups.id', [ ], function (error, results) {
' FROM userGroups LEFT OUTER JOIN groupMembers ON userGroups.id = groupMembers.groupId ' +
' GROUP BY userGroups.id', [ ], function (error, results) {
if (error) return done(error);
results.forEach(function (result) {
@@ -4,7 +4,7 @@ var async = require('async');
exports.up = function(db, callback) {
db.all('SELECT * from apps', [ ], function (error, results) {
if (error) return done(error);
if (error) return callback(error);
var queries = [
db.runSql.bind(db, 'START TRANSACTION;')
+1 -1
View File
@@ -18,7 +18,7 @@ exports.up = function(db, callback) {
async.series([
db.runSql.bind(db, 'DELETE FROM groupMembers WHERE groupId=?', [ 'admin' ]),
db.runSql.bind(db, 'DELETE FROM groups WHERE id=?', [ 'admin' ])
db.runSql.bind(db, 'DELETE FROM userGroups WHERE id=?', [ 'admin' ])
], callback);
});
});
@@ -0,0 +1,18 @@
'use strict';
var async = require('async');
exports.up = function(db, callback) {
async.series([
db.runSql.bind(db, 'ALTER TABLE appPortBindings ADD COLUMN type VARCHAR(8) NOT NULL DEFAULT "tcp"'),
db.runSql.bind(db, 'ALTER TABLE appPortBindings DROP INDEX hostPort'), // this drops the unique constraint
db.runSql.bind(db, 'ALTER TABLE appPortBindings DROP PRIMARY KEY, ADD PRIMARY KEY(hostPort, type)')
], callback);
};
exports.down = function(db, callback) {
async.series([
db.runSql.bind(db, 'ALTER TABLE appPortBindings DROP PRIMARY KEY, ADD PRIMARY KEY(hostPort)'),
db.runSql.bind(db, 'ALTER TABLE appPortBindings DROP COLUMN type')
], callback);
};
@@ -0,0 +1,16 @@
'use strict';
exports.up = function(db, callback) {
db.all('SELECT value FROM settings WHERE name="backup_config"', function (error, results) {
if (error || results.length === 0) return callback(error);
var backupConfig = JSON.parse(results[0].value);
backupConfig.intervalSecs = 24 * 60 * 60;
db.runSql('UPDATE settings SET value=? WHERE name="backup_config"', [ JSON.stringify(backupConfig) ], callback);
});
};
exports.down = function(db, callback) {
callback();
};
@@ -0,0 +1,23 @@
'use strict';
var async = require('async');
exports.up = function(db, callback) {
// first check precondtion of domain entry in settings
db.all('SELECT * FROM domains', [ ], function (error, domains) {
if (error) return callback(error);
let caasDomains = domains.filter(function (d) { return d.provider === 'caas'; });
async.eachSeries(caasDomains, function (domain, iteratorCallback) {
let config = JSON.parse(domain.configJson);
config.hyphenatedSubdomains = true;
db.runSql('UPDATE domains SET configJson = ? WHERE domain = ?', [ JSON.stringify(config), domain.domain ], iteratorCallback);
}, callback);
});
};
exports.down = function(db, callback) {
callback();
};
@@ -0,0 +1,12 @@
'use strict';
exports.up = function(db, callback) {
db.runSql('ALTER TABLE tokens ADD COLUMN name VARCHAR(64) DEFAULT ""', [], callback);
};
exports.down = function(db, callback) {
db.runSql('ALTER TABLE tokens DROP COLUMN name', function (error) {
if (error) console.error(error);
callback(error);
});
};
@@ -0,0 +1,21 @@
'use strict';
var async = require('async');
exports.up = function(db, callback) {
db.all('SELECT * from domains WHERE provider=?', [ 'manual' ], function (error, results) {
if (error) return callback(error);
async.eachSeries(results, function (result, iteratorDone) {
var config = JSON.parse(result.configJson || '{}');
if (!config.wildcard) return iteratorDone();
delete config.wildcard;
db.runSql('UPDATE domains SET provider=?, configJson=? WHERE domain=?', [ 'wildcard', JSON.stringify(config), result.domain ], iteratorDone);
}, callback);
});
};
exports.down = function(db, callback) {
callback();
};
@@ -0,0 +1,21 @@
'use strict';
exports.up = function(db, callback) {
var cmd = 'CREATE TABLE IF NOT EXISTS appEnvVars(' +
'appId VARCHAR(128) NOT NULL,' +
'name TEXT NOT NULL,' +
'value TEXT NOT NULL,' +
'FOREIGN KEY(appId) REFERENCES apps(id)) CHARACTER SET utf8 COLLATE utf8_bin';
db.runSql(cmd, function (error) {
if (error) console.error(error);
callback(error);
});
};
exports.down = function(db, callback) {
db.runSql('DROP TABLE appEnvVars', function (error) {
if (error) console.error(error);
callback(error);
});
};
@@ -0,0 +1,27 @@
'use strict';
exports.up = function(db, callback) {
var cmd = 'CREATE TABLE tasks(' +
'id int NOT NULL AUTO_INCREMENT,' +
'type VARCHAR(32) NOT NULL,' +
'argsJson TEXT,' +
'percent INTEGER DEFAULT 0,' +
'message TEXT,' +
'errorMessage TEXT,' +
'result TEXT,' +
'creationTime TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,' +
'ts TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,' +
'PRIMARY KEY (id))';
db.runSql(cmd, function (error) {
if (error) console.error(error);
callback(error);
});
};
exports.down = function(db, callback) {
db.runSql('DROP TABLE tasks', function (error) {
if (error) console.error(error);
callback(error);
});
};
@@ -0,0 +1,17 @@
'use strict';
exports.up = function(db, callback) {
db.runSql('SELECT 1 FROM groups LIMIT 1', function (error) {
if (error) return callback(); // groups table does not exist
db.runSql('RENAME TABLE groups TO userGroups', function (error) {
if (error) console.error(error);
callback(error);
});
});
};
exports.down = function(db, callback) {
// this is a one way renaming since the previous migration steps have been already updated to match the new name
callback();
};
@@ -0,0 +1,17 @@
'use strict';
var async = require('async');
exports.up = function(db, callback) {
async.series([
db.runSql.bind(db, 'ALTER TABLE apps MODIFY creationTime TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP'),
db.runSql.bind(db, 'ALTER TABLE apps MODIFY updateTime TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP'),
db.runSql.bind(db, 'ALTER TABLE eventlog MODIFY creationTime TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP'),
db.runSql.bind(db, 'ALTER TABLE backups MODIFY creationTime TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP'),
db.runSql.bind(db, 'ALTER TABLE mailboxes MODIFY creationTime TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP'),
], callback);
};
exports.down = function(db, callback) {
callback();
};
@@ -0,0 +1,28 @@
'use strict';
var async = require('async');
exports.up = function(db, callback) {
async.series([
db.runSql.bind(db, 'ALTER TABLE apps ADD COLUMN mailboxName VARCHAR(128)'),
db.runSql.bind(db, 'START TRANSACTION;'),
function migrateMailboxNames(done) {
db.all('SELECT * FROM mailboxes', function (error, mailboxes) {
if (error) return done(error);
async.eachSeries(mailboxes, function (mailbox, iteratorDone) {
if (mailbox.ownerType !== 'app') return iteratorDone();
db.runSql('UPDATE apps SET mailboxName = ? WHERE id = ?', [ mailbox.name, mailbox.ownerId ], iteratorDone);
}, done);
});
},
db.runSql.bind(db, 'COMMIT')
], callback);
};
exports.down = function(db, callback) {
callback();
};
@@ -0,0 +1,28 @@
'use strict';
var async = require('async');
exports.up = function(db, callback) {
async.series([
db.runSql.bind(db, 'START TRANSACTION;'),
function migrateMailboxNames(done) {
db.all('SELECT * FROM mailboxes', function (error, mailboxes) {
if (error) return done(error);
async.eachSeries(mailboxes, function (mailbox, iteratorDone) {
if (mailbox.ownerType !== 'app') return iteratorDone();
db.runSql('DELETE FROM mailboxes WHERE name = ?', [ mailbox.name ], iteratorDone);
}, done);
});
},
db.runSql.bind(db, 'COMMIT'),
db.runSql.bind(db, 'ALTER TABLE mailboxes DROP COLUMN ownerType')
], callback);
};
exports.down = function(db, callback) {
callback();
};
@@ -0,0 +1,16 @@
'use strict';
exports.up = function(db, callback) {
db.runSql('ALTER TABLE apps ADD COLUMN enableAutomaticUpdate BOOLEAN DEFAULT 1', function (error) {
if (error) console.error(error);
callback(error);
});
};
exports.down = function(db, callback) {
db.runSql('ALTER TABLE apps DROP COLUMN enableAutomaticUpdate', function (error) {
if (error) console.error(error);
callback(error);
});
};
+35 -11
View File
@@ -29,7 +29,7 @@ CREATE TABLE IF NOT EXISTS users(
PRIMARY KEY(id));
CREATE TABLE IF NOT EXISTS groups(
CREATE TABLE IF NOT EXISTS userGroups(
id VARCHAR(128) NOT NULL UNIQUE,
name VARCHAR(254) NOT NULL UNIQUE,
PRIMARY KEY(id));
@@ -37,10 +37,11 @@ CREATE TABLE IF NOT EXISTS groups(
CREATE TABLE IF NOT EXISTS groupMembers(
groupId VARCHAR(128) NOT NULL,
userId VARCHAR(128) NOT NULL,
FOREIGN KEY(groupId) REFERENCES groups(id),
FOREIGN KEY(groupId) REFERENCES userGroups(id),
FOREIGN KEY(userId) REFERENCES users(id));
CREATE TABLE IF NOT EXISTS tokens(
name VARCHAR(64) DEFAULT "", // description
accessToken VARCHAR(128) NOT NULL UNIQUE,
identifier VARCHAR(128) NOT NULL,
clientId VARCHAR(128),
@@ -50,7 +51,7 @@ CREATE TABLE IF NOT EXISTS tokens(
CREATE TABLE IF NOT EXISTS clients(
id VARCHAR(128) NOT NULL UNIQUE, // prefixed with cid- to identify token easily in auth routes
appId VARCHAR(128) NOT NULL,
appId VARCHAR(128) NOT NULL, // name of the client (for external apps) or id of app (for built-in apps)
type VARCHAR(16) NOT NULL,
clientSecret VARCHAR(512) NOT NULL,
redirectURI VARCHAR(512) NOT NULL,
@@ -70,8 +71,8 @@ CREATE TABLE IF NOT EXISTS apps(
location VARCHAR(128) NOT NULL,
domain VARCHAR(128) NOT NULL,
accessRestrictionJson TEXT, // { users: [ ], groups: [ ] }
creationTime TIMESTAMP(2) NOT NULL DEFAULT CURRENT_TIMESTAMP, // when the app was installed
updateTime TIMESTAMP(2) NOT NULL DEFAULT CURRENT_TIMESTAMP, // when the last app update was done
creationTime TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, // when the app was installed
updateTime TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, // when the last app update was done
ts TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, // when this db record was updated (useful for UI caching)
memoryLimit BIGINT DEFAULT 0,
xFrameOptions VARCHAR(512),
@@ -79,6 +80,8 @@ CREATE TABLE IF NOT EXISTS apps(
debugModeJson TEXT, // options for development mode
robotsTxt TEXT,
enableBackup BOOLEAN DEFAULT 1, // misnomer: controls automatic daily backups
enableAutomaticUpdate BOOLEAN DEFAULT 1,
mailboxName VARCHAR(128), // mailbox of this app
// the following fields do not belong here, they can be removed when we use a queue for apptask
restoreConfigJson VARCHAR(256), // used to pass backupId to restore from to apptask
@@ -92,6 +95,7 @@ CREATE TABLE IF NOT EXISTS apps(
CREATE TABLE IF NOT EXISTS appPortBindings(
hostPort INTEGER NOT NULL UNIQUE,
type VARCHAR(8) NOT NULL DEFAULT "tcp",
environmentVariable VARCHAR(128) NOT NULL,
appId VARCHAR(128) NOT NULL,
FOREIGN KEY(appId) REFERENCES apps(id),
@@ -116,9 +120,15 @@ CREATE TABLE IF NOT EXISTS appAddonConfigs(
value VARCHAR(512) NOT NULL,
FOREIGN KEY(appId) REFERENCES apps(id));
CREATE TABLE IF NOT EXISTS appEnvVars(
appId VARCHAR(128) NOT NULL,
name TEXT NOT NULL,
value TEXT NOT NULL,
FOREIGN KEY(appId) REFERENCES apps(id));
CREATE TABLE IF NOT EXISTS backups(
id VARCHAR(128) NOT NULL,
creationTime TIMESTAMP,
creationTime TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
version VARCHAR(128) NOT NULL, /* app version or box version */
type VARCHAR(16) NOT NULL, /* 'box' or 'app' */
dependsOn TEXT, /* comma separate list of objects this backup depends on */
@@ -133,7 +143,7 @@ CREATE TABLE IF NOT EXISTS eventlog(
action VARCHAR(128) NOT NULL,
source TEXT, /* { userId, username, ip }. userId can be null for cron,sysadmin */
data TEXT, /* free flowing json based on action */
createdAt TIMESTAMP(2) NOT NULL,
creationTime TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id));
@@ -165,15 +175,17 @@ CREATE TABLE IF NOT EXISTS mail(
/* Future fields:
* accessRestriction - to determine who can access it. So this has foreign keys
* quota - per mailbox quota
NOTE: this table exists only real mailboxes. And has unique constraint to handle
conflict with aliases and mailbox names
*/
CREATE TABLE IF NOT EXISTS mailboxes(
name VARCHAR(128) NOT NULL,
type VARCHAR(16) NOT NULL, /* 'mailbox', 'alias', 'list' */
ownerId VARCHAR(128) NOT NULL, /* app id or user id or group id */
ownerType VARCHAR(16) NOT NULL, /* 'app' or 'user' or 'group' */
ownerId VARCHAR(128) NOT NULL, /* user id */
aliasTarget VARCHAR(128), /* the target name type is an alias */
membersJson TEXT, /* members of a group */
creationTime TIMESTAMP,
creationTime TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
domain VARCHAR(128),
FOREIGN KEY(domain) REFERENCES mail(domain),
@@ -187,6 +199,18 @@ CREATE TABLE IF NOT EXISTS subdomains(
FOREIGN KEY(domain) REFERENCES domains(domain),
FOREIGN KEY(appId) REFERENCES apps(id),
UNIQUE (subdomain, domain))
UNIQUE (subdomain, domain));
CREATE TABLE IF NOT EXISTS tasks(
id int NOT NULL AUTO_INCREMENT,
type VARCHAR(32) NOT NULL,
percent INTEGER DEFAULT 0,
message TEXT,
errorMessage TEXT,
result TEXT,
creationTime TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
ts TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id));
CHARACTER SET utf8 COLLATE utf8_bin;
+1769 -1482
View File
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -20,13 +20,14 @@
"async": "^2.6.1",
"aws-sdk": "^2.253.1",
"body-parser": "^1.18.3",
"cloudron-manifestformat": "^2.11.0",
"cloudron-manifestformat": "^2.14.2",
"connect": "^3.6.6",
"connect-ensure-login": "^0.1.1",
"connect-lastmile": "^1.0.2",
"connect-timeout": "^1.9.0",
"cookie-parser": "^1.3.5",
"cookie-session": "^1.3.2",
"cron": "^1.3.0",
"cron": "^1.5.1",
"csurf": "^1.6.6",
"db-migrate": "^0.11.1",
"db-migrate-mysql": "^1.1.10",
@@ -91,8 +92,7 @@
"scripts": {
"migrate_local": "DATABASE_URL=mysql://root:@localhost/box node_modules/.bin/db-migrate up",
"migrate_test": "BOX_ENV=test DATABASE_URL=mysql://root:@localhost/boxtest node_modules/.bin/db-migrate up",
"test": "npm run migrate_test && src/test/setupTest && BOX_ENV=test ./node_modules/istanbul/lib/cli.js test $1 ./node_modules/mocha/bin/_mocha -- --exit -R spec ./src/test ./src/routes/test/[^a]*",
"test_all": "npm run migrate_test && src/test/setupTest && BOX_ENV=test ./node_modules/istanbul/lib/cli.js test $1 ./node_modules/mocha/bin/_mocha -- --exit -R spec ./src/test ./src/routes/test",
"test": "npm run migrate_test && src/test/setupTest && BOX_ENV=test ./node_modules/istanbul/lib/cli.js test $1 ./node_modules/mocha/bin/_mocha -- --no-timeouts --exit -R spec ./src/test ./src/routes/test",
"postmerge": "/bin/true",
"precommit": "/bin/true",
"prepush": "npm test",
-122
View File
@@ -1,122 +0,0 @@
#!/bin/bash
set -eu -o pipefail
readonly curl="curl --fail --connect-timeout 20 --retry 10 --retry-delay 2 --max-time 2400"
function get_status() {
key="$1"
if status=$($curl -q -f "http://localhost:3000/api/v1/cloudron/status" 2>/dev/null); then
currentValue=$(echo "${status}" | python3 -c 'import sys, json; print(json.dumps(json.load(sys.stdin)[sys.argv[1]]))' "${key}")
echo "${currentValue}"
return 0
fi
return 1
}
function wait_for_status() {
key="$1"
expectedValue="$2"
echo "wait_for_status: $key to be $expectedValue"
while true; do
if currentValue=$(get_status "${key}"); then
echo "wait_for_status: $key is current: $currentValue expecting: $expectedValue"
if [[ "${currentValue}" == $expectedValue ]]; then
break
fi
fi
sleep 3
done
}
domain=""
domainProvider=""
domainConfigJson="{}"
domainTlsProvider="letsencrypt-prod"
adminUsername="superadmin"
adminPassword="Secret123#"
adminEmail="admin@server.local"
appstoreUserId=""
appstoreToken=""
backupDir="/var/backups"
args=$(getopt -o "" -l "domain:,domain-provider:,domain-tls-provider:,admin-username:,admin-password:,admin-email:,appstore-user:,appstore-token:,backup-dir:" -n "$0" -- "$@")
eval set -- "${args}"
while true; do
case "$1" in
--domain) domain="$2"; shift 2;;
--domain-provider) domainProvider="$2"; shift 2;;
--domain-tls-provider) domainTlsProvider="$2"; shift 2;;
--admin-username) adminUsername="$2"; shift 2;;
--admin-password) adminPassword="$2"; shift 2;;
--admin-email) adminEmail="$2"; shift 2;;
--appstore-user) appstoreUser="$2"; shift 2;;
--appstore-token) appstoreToken="$2"; shift 2;;
--backup-dir) backupDir="$2"; shift 2;;
--) break;;
*) echo "Unknown option $1"; exit 1;;
esac
done
echo "=> Waiting for cloudron to be ready"
wait_for_status "version" '*'
if [[ $(get_status "webadminStatus") != *'"tls": true'* ]]; then
echo "=> Domain setup"
dnsSetupData=$(printf '{ "domain": "%s", "adminFqdn": "%s", "provider": "%s", "config": %s, "tlsConfig": { "provider": "%s" } }' "${domain}" "my.${domain}" "${domainProvider}" "$domainConfigJson" "${domainTlsProvider}")
if ! $curl -X POST -H "Content-Type: application/json" -d "${dnsSetupData}" http://localhost:3000/api/v1/cloudron/dns_setup; then
echo "DNS Setup Failed"
exit 1
fi
wait_for_status "webadminStatus" '*"tls": true*'
else
echo "=> Skipping Domain setup"
fi
activationData=$(printf '{"username": "%s", "password":"%s", "email": "%s" }' "${adminUsername}" "${adminPassword}" "${adminEmail}")
if [[ $(get_status "activated") == "false" ]]; then
echo "=> Activating"
if ! activationResult=$($curl -X POST -H "Content-Type: application/json" -d "${activationData}" http://localhost:3000/api/v1/cloudron/activate); then
echo "Failed to activate with ${activationData}: ${activationResult}"
exit 1
fi
wait_for_status "activated" "true"
else
echo "=> Skipping Activation"
fi
echo "=> Getting token"
if ! activationResult=$($curl -X POST -H "Content-Type: application/json" -d "${activationData}" http://localhost:3000/api/v1/developer/login); then
echo "Failed to login with ${activationData}: ${activationResult}"
exit 1
fi
accessToken=$(echo "${activationResult}" | python3 -c 'import sys, json; print(json.load(sys.stdin)[sys.argv[1]])' "accessToken")
echo "=> Setting up App Store account with accessToken ${accessToken}"
appstoreData=$(printf '{"userId":"%s", "token":"%s" }' "${appstoreUser}" "${appstoreToken}")
if ! appstoreResult=$($curl -X POST -H "Content-Type: application/json" -d "${appstoreData}" "http://localhost:3000/api/v1/settings/appstore_config?access_token=${accessToken}"); then
echo "Failed to setup Appstore account with ${appstoreData}: ${appstoreResult}"
exit 1
fi
echo "=> Setting up Backup Directory with accessToken ${accessToken}"
backupData=$(printf '{"provider":"filesystem", "key":"", "backupFolder":"%s", "retentionSecs": 864000, "format": "tgz", "externalDisk": true}' "${backupDir}")
chown -R yellowtent:yellowtent "${backupDir}"
if ! backupResult=$($curl -X POST -H "Content-Type: application/json" -d "${backupData}" "http://localhost:3000/api/v1/settings/backup_config?access_token=${accessToken}"); then
echo "Failed to setup backup configuration with ${backupDir}: ${backupResult}"
exit 1
fi
echo "=> Done!"
+106
View File
@@ -0,0 +1,106 @@
#!/bin/bash
set -eu -o pipefail
readonly curl="curl --fail --connect-timeout 20 --retry 10 --retry-delay 2 --max-time 2400 --http1.1"
ip=""
dns_config=""
tls_cert_file=""
tls_key_file=""
license_file=""
backup_config=""
args=$(getopt -o "" -l "ip:,backup-config:,license:,dns-config:,tls-cert:,tls-key:" -n "$0" -- "$@")
eval set -- "${args}"
while true; do
case "$1" in
--ip) ip="$2"; shift 2;;
--dns-config) dns_config="$2"; shift 2;;
--tls-cert) tls_cert_file="$2"; shift 2;;
--tls-key) tls_key_file="$2"; shift 2;;
--license) license_file="$2"; shift 2;;
--backup-config) backup_config="$2"; shift 2;;
--) break;;
*) echo "Unknown option $1"; exit 1;;
esac
done
# validate arguments in the absence of data
if [[ -z "${ip}" ]]; then
echo "--ip is required"
exit 1
fi
if [[ -z "${dns_config}" ]]; then
echo "--dns-config is required"
exit 1
fi
if [[ ! -f "${license_file}" ]]; then
echo "--license must be a valid license file"
exit 1
fi
function get_status() {
key="$1"
if status=$($curl -q -f -k "https://${ip}/api/v1/cloudron/status" 2>/dev/null); then
currentValue=$(echo "${status}" | python3 -c 'import sys, json; print(json.dumps(json.load(sys.stdin)[sys.argv[1]]))' "${key}")
echo "${currentValue}"
return 0
fi
return 1
}
function wait_for_status() {
key="$1"
expectedValue="$2"
echo "wait_for_status: $key to be $expectedValue"
while true; do
if currentValue=$(get_status "${key}"); then
echo "wait_for_status: $key is current: $currentValue expecting: $expectedValue"
if [[ "${currentValue}" == $expectedValue ]]; then
break
fi
fi
sleep 3
done
}
echo "=> Waiting for cloudron to be ready"
wait_for_status "version" '*'
domain=$(echo "${dns_config}" | python3 -c 'import json,sys;obj=json.load(sys.stdin);print(obj["domain"])')
echo "Provisioning Cloudron ${domain}"
if [[ -n "${tls_cert_file}" && -n "${tls_key_file}" ]]; then
tls_cert=$(cat "${tls_cert_file}" | awk '{printf "%s\\n", $0}')
tls_key=$(cat "${tls_key_file}" | awk '{printf "%s\\n", $0}')
fallback_cert=$(printf '{ "cert": "%s", "key": "%s", "provider": "fallback", "restricted": true }' "${tls_cert}" "${tls_key}")
else
fallback_cert=None
fi
tls_config='{ "provider": "fallback" }'
dns_config=$(echo "${dns_config}" | python3 -c "import json,sys;obj=json.load(sys.stdin);obj.update(tlsConfig=${tls_config});obj.update(fallbackCertficate=${fallback_cert});print(json.dumps(obj))")
license=$(cat "${license_file}")
if [[ -z "${backup_config:-}" ]]; then
backup_config='{ "provider": "filesystem", "backupFolder": "/var/backups", "format": "tgz" }'
fi
setupData=$(printf '{ "dnsConfig": %s, "autoconf": { "appstoreConfig": %s, "backupConfig": %s } }' "${dns_config}" "${license}" "${backup_config}")
if ! setupResult=$($curl -kq -X POST -H "Content-Type: application/json" -d "${setupData}" https://${ip}/api/v1/cloudron/setup); then
echo "Failed to setup with ${setupData} ${setupResult}"
exit 1
fi
wait_for_status "webadminStatus" '*"tls": true*'
echo "Cloudron is ready at https://my-${domain}"
+68 -35
View File
@@ -4,7 +4,6 @@ set -eu -o pipefail
# change this to a hash when we make a upgrade release
readonly LOG_FILE="/var/log/cloudron-setup.log"
readonly DATA_FILE="/root/cloudron-install-data.json"
readonly MINIMUM_DISK_SIZE_GB="18" # this is the size of "/" and required to fit in docker images 18 is a safe bet for different reporting on 20GB min
readonly MINIMUM_MEMORY="974" # this is mostly reported for 1GB main memory (DO 992, EC2 990, Linode 989, Serverdiscounter.com 974)
@@ -44,37 +43,34 @@ fi
initBaseImage="true"
# provisioning data
provider=""
edition=""
requestedVersion=""
apiServerOrigin="https://api.cloudron.io"
webServerOrigin="https://cloudron.io"
prerelease="false"
sourceTarballUrl=""
rebootServer="true"
baseDataDir=""
args=$(getopt -o "" -l "help,skip-baseimage-init,data-dir:,provider:,version:,env:,prerelease,skip-reboot" -n "$0" -- "$@")
args=$(getopt -o "" -l "help,skip-baseimage-init,provider:,version:,env:,edition:,skip-reboot" -n "$0" -- "$@")
eval set -- "${args}"
while true; do
case "$1" in
--help) echo "See https://cloudron.io/documentation/installation/ on how to install Cloudron"; exit 0;;
--provider) provider="$2"; shift 2;;
--edition) edition="$2"; shift 2;;
--version) requestedVersion="$2"; shift 2;;
--env)
if [[ "$2" == "dev" ]]; then
apiServerOrigin="https://api.dev.cloudron.io"
webServerOrigin="https://dev.cloudron.io"
prerelease="true"
elif [[ "$2" == "staging" ]]; then
apiServerOrigin="https://api.staging.cloudron.io"
webServerOrigin="https://staging.cloudron.io"
prerelease="true"
fi
shift 2;;
--skip-baseimage-init) initBaseImage="false"; shift;;
--skip-reboot) rebootServer="false"; shift;;
--prerelease) prerelease="true"; shift;;
--data-dir) baseDataDir=$(realpath "$2"); shift 2;;
--) break;;
*) echo "Unknown option $1"; exit 1;;
esac
@@ -87,14 +83,18 @@ if [[ ${EUID} -ne 0 ]]; then
fi
# Only --help works with mismatched ubuntu
if [[ $(lsb_release -rs) != "16.04" ]]; then
echo "Cloudron requires Ubuntu 16.04" > /dev/stderr
ubuntu_version=$(lsb_release -rs)
if [[ "${ubuntu_version}" != "16.04" && "${ubuntu_version}" != "18.04" ]]; then
echo "Cloudron requires Ubuntu 16.04 or 18.04" > /dev/stderr
exit 1
fi
# Can only write after we have confirmed script has root access
echo "Running cloudron-setup with args : $@" > "${LOG_FILE}"
# validate arguments in the absence of data
if [[ -z "${provider}" ]]; then
echo "--provider is required (azure, cloudscale, digitalocean, ec2, exoscale, hetzner, lightsail, linode, ovh, rosehosting, scaleway, vultr or generic)"
echo "--provider is required (azure, digitalocean, ec2, exoscale, gce, hetzner, lightsail, linode, netcup, ovh, rosehosting, scaleway, vultr or generic)"
exit 1
elif [[ \
"${provider}" != "ami" && \
@@ -104,17 +104,20 @@ elif [[ \
"${provider}" != "digitalocean" && \
"${provider}" != "ec2" && \
"${provider}" != "exoscale" && \
"${provider}" != "galaxygate" && \
"${provider}" != "digitalocean" && \
"${provider}" != "gce" && \
"${provider}" != "hetzner" && \
"${provider}" != "lightsail" && \
"${provider}" != "linode" && \
"${provider}" != "netcup" && \
"${provider}" != "ovh" && \
"${provider}" != "rosehosting" && \
"${provider}" != "scaleway" && \
"${provider}" != "vultr" && \
"${provider}" != "generic" \
]]; then
echo "--provider must be one of: azure, cloudscale.ch, digitalocean, ec2, exoscale, gce, hetzner, lightsail, linode, ovh, rosehosting, scaleway, vultr or generic"
echo "--provider must be one of: azure, cloudscale.ch, digitalocean, ec2, exoscale, galaxygate, gce, hetzner, lightsail, linode, netcup, ovh, rosehosting, scaleway, vultr or generic"
exit 1
fi
@@ -135,20 +138,26 @@ echo " Join us at https://forum.cloudron.io for any questions."
echo ""
if [[ "${initBaseImage}" == "true" ]]; then
echo "=> Ensure required apt sources"
if ! add-apt-repository universe &>> "${LOG_FILE}"; then
echo "Could not add required apt sources (for nginx-full). See ${LOG_FILE}"
exit 1
fi
echo "=> Updating apt and installing script dependencies"
if ! apt-get update &>> "${LOG_FILE}"; then
echo "Could not update package repositories"
echo "Could not update package repositories. See ${LOG_FILE}"
exit 1
fi
if ! apt-get install curl python3 ubuntu-standard -y &>> "${LOG_FILE}"; then
echo "Could not install setup dependencies (curl)"
echo "Could not install setup dependencies (curl). See ${LOG_FILE}"
exit 1
fi
fi
echo "=> Checking version"
if ! releaseJson=$($curl -s "${apiServerOrigin}/api/v1/releases?prerelease=${prerelease}&boxVersion=${requestedVersion}"); then
if ! releaseJson=$($curl -s "${apiServerOrigin}/api/v1/releases?boxVersion=${requestedVersion}"); then
echo "Failed to get release information"
exit 1
fi
@@ -164,18 +173,6 @@ if ! sourceTarballUrl=$(echo "${releaseJson}" | python3 -c 'import json,sys;obj=
exit 1
fi
# Build data
# from 1.9, we use autoprovision.json
data=$(cat <<EOF
{
"provider": "${provider}",
"apiServerOrigin": "${apiServerOrigin}",
"webServerOrigin": "${webServerOrigin}",
"version": "${version}"
}
EOF
)
echo "=> Downloading version ${version} ..."
box_src_tmp_dir=$(mktemp -dt box-src-XXXXXX)
@@ -193,13 +190,44 @@ if [[ "${initBaseImage}" == "true" ]]; then
echo ""
fi
# NOTE: this install script only supports 3.x and above
echo "=> Installing version ${version} (this takes some time) ..."
echo "${data}" > "${DATA_FILE}"
if ! /bin/bash "${box_src_tmp_dir}/scripts/installer.sh" --data-file "${DATA_FILE}" --data-dir "${baseDataDir}" &>> "${LOG_FILE}"; then
echo "Failed to install cloudron. See ${LOG_FILE} for details"
exit 1
if [[ "${version}" =~ 3\.[0-2]+\.[0-9]+ ]]; then
readonly DATA_FILE="/root/cloudron-install-data.json"
data=$(cat <<EOF
{
"provider": "${provider}",
"edition": "${edition}",
"apiServerOrigin": "${apiServerOrigin}",
"webServerOrigin": "${webServerOrigin}",
"version": "${version}"
}
EOF
)
echo "${data}" > "${DATA_FILE}"
if ! /bin/bash "${box_src_tmp_dir}/scripts/installer.sh" --data-file "${DATA_FILE}" &>> "${LOG_FILE}"; then
echo "Failed to install cloudron. See ${LOG_FILE} for details"
exit 1
fi
rm "${DATA_FILE}"
else
mkdir -p /etc/cloudron
cat > "/etc/cloudron/cloudron.conf" <<CONF_END
{
"apiServerOrigin": "${apiServerOrigin}",
"webServerOrigin": "${webServerOrigin}",
"provider": "${provider}",
"edition": "${edition}"
}
CONF_END
if ! /bin/bash "${box_src_tmp_dir}/scripts/installer.sh" &>> "${LOG_FILE}"; then
echo "Failed to install cloudron. See ${LOG_FILE} for details"
exit 1
fi
fi
rm "${DATA_FILE}"
echo -n "=> Waiting for cloudron to be ready (this takes some time) ..."
while true; do
@@ -210,10 +238,15 @@ while true; do
sleep 10
done
echo -e "\n\n${GREEN}Visit https://<IP> and accept the self-signed certificate to finish setup.${DONE}"
echo -e "\n\n${GREEN}Visit https://<IP> and accept the self-signed certificate to finish setup.${DONE}\n"
if [[ "${rebootServer}" == "true" ]]; then
echo -e "\n${RED}Rebooting this server now to let changes take effect.${DONE}\n"
systemctl stop mysql # sometimes mysql ends up having corrupt privilege tables
systemctl reboot
systemctl stop box mysql # sometimes mysql ends up having corrupt privilege tables
read -p "This server has to rebooted to apply all the settings. Reboot now ? [Y/n] " yn
yn=${yn:-y}
case $yn in
[Yy]* ) systemctl reboot;;
* ) exit;;
esac
fi
+115
View File
@@ -0,0 +1,115 @@
#!/bin/bash
# This script collects diagnostic information to help debug server related issues
# It also enables SSH access for the cloudron support team
PASTEBIN="https://paste.cloudron.io"
OUT="/tmp/cloudron-support.log"
LINE="\n========================================================\n"
CLOUDRON_SUPPORT_PUBLIC_KEY="ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDQVilclYAIu+ioDp/sgzzFz6YU0hPcRYY7ze/LiF/lC7uQqK062O54BFXTvQ3ehtFZCx3bNckjlT2e6gB8Qq07OM66De4/S/g+HJW4TReY2ppSPMVNag0TNGxDzVH8pPHOysAm33LqT2b6L/wEXwC6zWFXhOhHjcMqXvi8Ejaj20H1HVVcf/j8qs5Thkp9nAaFTgQTPu8pgwD8wDeYX1hc9d0PYGesTADvo6HF4hLEoEnefLw7PaStEbzk2fD3j7/g5r5HcgQQXBe74xYZ/1gWOX2pFNuRYOBSEIrNfJEjFJsqk3NR1+ZoMGK7j+AZBR4k0xbrmncQLcQzl6MMDzkp support@cloudron.io"
HELP_MESSAGE="
This script collects diagnostic information to help debug server related issues
Options:
--enable-ssh Enable SSH access for the Cloudron support team
--help Show this message
"
# We require root
if [[ ${EUID} -ne 0 ]]; then
echo "This script should be run as root." > /dev/stderr
exit 1
fi
enableSSH="false"
args=$(getopt -o "" -l "help,enable-ssh" -n "$0" -- "$@")
eval set -- "${args}"
while true; do
case "$1" in
--help) echo -e "${HELP_MESSAGE}"; exit 0;;
--enable-ssh) enableSSH="true"; shift;;
--) break;;
*) echo "Unknown option $1"; exit 1;;
esac
done
# check if at least 10mb root partition space is available
if [[ "`df --output="avail" / | sed -n 2p`" -lt "10240" ]]; then
echo "No more space left on /"
echo "This is likely the root case of the issue. Free up some space and also check other partitions below:"
echo ""
df -h
echo ""
echo "To recover from a full disk, follow the guide at https://cloudron.io/documentation/server/#recovery-after-disk-full"
exit 1
fi
# check for at least 5mb free /tmp space for the log file
if [[ "`df --output="avail" /tmp | sed -n 2p`" -lt "5120" ]]; then
echo "Not enough space left on /tmp"
echo "Free up some space first by deleting files from /tmp"
exit 1
fi
echo -n "Generating Cloudron Support stats..."
# clear file
rm -rf $OUT
ssh_port=$(cat /etc/ssh/sshd_config | grep "Port " | sed -e "s/.*Port //")
if [[ $SUDO_USER == "" ]]; then
ssh_user="root"
ssh_folder="/root/.ssh/"
authorized_key_file="${ssh_folder}/authorized_keys"
else
ssh_user="$SUDO_USER"
ssh_folder="/home/$SUDO_USER/.ssh/"
authorized_key_file="${ssh_folder}/authorized_keys"
fi
echo -e $LINE"SSH"$LINE >> $OUT
echo "Username: ${ssh_user}" >> $OUT
echo "Port: ${ssh_port}" >> $OUT
echo -e $LINE"cloudron.conf"$LINE >> $OUT
cat /etc/cloudron/cloudron.conf &>> $OUT
echo -e $LINE"Docker container"$LINE >> $OUT
if ! timeout --kill-after 10s 15s docker ps -a &>> $OUT 2>&1; then
echo -e "Docker is not responding" >> $OUT
fi
echo -e $LINE"Filesystem stats"$LINE >> $OUT
df -h &>> $OUT
echo -e $LINE"System daemon status"$LINE >> $OUT
systemctl status --lines=100 cloudron.target box mysql unbound cloudron-syslog nginx collectd docker &>> $OUT
echo -e $LINE"Box logs"$LINE >> $OUT
tail -n 100 /home/yellowtent/platformdata/logs/box.log &>> $OUT
echo -e $LINE"Firewall chains"$LINE >> $OUT
iptables -L &>> $OUT
echo "Done"
echo -n "Uploading information..."
# for some reason not using $(cat $OUT) will not contain newlines!?
paste_key=$(curl -X POST ${PASTEBIN}/documents --silent -d "$(cat $OUT)" | python3 -c "import sys, json; print(json.load(sys.stdin)['key'])")
echo "Done"
if [[ "${enableSSH}" == "true" ]]; then
echo -n "Enabling ssh access for the Cloudron support team..."
mkdir -p "${ssh_folder}"
echo "${CLOUDRON_SUPPORT_PUBLIC_KEY}" >> ${authorized_key_file}
chown -R ${ssh_user} "${ssh_folder}"
chmod 600 "${authorized_key_file}"
echo "Done"
fi
echo ""
echo "Please email the following link to support@cloudron.io"
echo ""
echo "${PASTEBIN}/${paste_key}"
+11 -7
View File
@@ -7,21 +7,28 @@ set -eu
[[ $(uname -s) == "Darwin" ]] && GNU_GETOPT="/usr/local/opt/gnu-getopt/bin/getopt" || GNU_GETOPT="getopt"
readonly GNU_GETOPT
args=$(${GNU_GETOPT} -o "" -l "output:" -n "$0" -- "$@")
args=$(${GNU_GETOPT} -o "" -l "output:,version:" -n "$0" -- "$@")
eval set -- "${args}"
readonly SOURCE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
bundle_file=""
version=""
while true; do
case "$1" in
--output) bundle_file="$2"; shift 2;;
--version) version="$2"; shift 2;;
--) break;;
*) echo "Unknown option $1"; exit 1;;
esac
done
if [[ -z "${version}" ]]; then
echo "--version is required"
exit 1
fi
readonly TMPDIR=${TMPDIR:-/tmp} # why is this not set on mint?
if ! $(cd "${SOURCE_DIR}" && git diff --exit-code >/dev/null); then
@@ -41,19 +48,16 @@ fi
box_version=$(cd "${SOURCE_DIR}" && git rev-parse "HEAD")
branch=$(git rev-parse --abbrev-ref HEAD)
if [[ "${branch}" == "master" ]]; then
dashboard_version=$(cd "${SOURCE_DIR}/../dashboard" && git rev-parse "${branch}")
else
dashboard_version=$(cd "${SOURCE_DIR}/../dashboard" && git fetch && git rev-parse "origin/${branch}")
fi
dashboard_version=$(cd "${SOURCE_DIR}/../dashboard" && git fetch && git rev-parse "${branch}")
bundle_dir=$(mktemp -d -t box 2>/dev/null || mktemp -d box-XXXXXXXXXX --tmpdir=$TMPDIR)
[[ -z "$bundle_file" ]] && bundle_file="${TMPDIR}/box-${box_version:0:10}-${dashboard_version:0:10}.tar.gz"
[[ -z "$bundle_file" ]] && bundle_file="${TMPDIR}/box-${box_version:0:10}-${dashboard_version:0:10}-${version}.tar.gz"
chmod "o+rx,g+rx" "${bundle_dir}" # otherwise extracted tarball director won't be readable by others/group
echo "==> Checking out code box version [${box_version}] and dashboard version [${dashboard_version}] into ${bundle_dir}"
(cd "${SOURCE_DIR}" && git archive --format=tar ${box_version} | (cd "${bundle_dir}" && tar xf -))
(cd "${SOURCE_DIR}/../dashboard" && git archive --format=tar ${dashboard_version} | (mkdir -p "${bundle_dir}/dashboard.build" && cd "${bundle_dir}/dashboard.build" && tar xf -))
(cp "${SOURCE_DIR}/../dashboard/LICENSE" "${bundle_dir}")
echo "${version}" > "${bundle_dir}/VERSION"
echo "==> Installing modules for dashboard asset generation"
(cd "${bundle_dir}/dashboard.build" && npm install --production)
+21 -28
View File
@@ -1,5 +1,9 @@
#!/bin/bash
# This script is run before the box code is switched. This means that we can
# put network related/curl downloads here. If the script fails, the old code
# will continue to run
set -eu -o pipefail
if [[ ${EUID} -ne 0 ]]; then
@@ -10,29 +14,12 @@ fi
readonly USER=yellowtent
readonly BOX_SRC_DIR=/home/${USER}/box
readonly BASE_DATA_DIR=/home/${USER}
readonly CLOUDRON_CONF=/home/yellowtent/configs/cloudron.conf
readonly curl="curl --fail --connect-timeout 20 --retry 10 --retry-delay 2 --max-time 2400"
readonly script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
readonly box_src_tmp_dir="$(realpath ${script_dir}/..)"
readonly is_update=$([[ -f "${CLOUDRON_CONF}" ]] && echo "yes" || echo "no")
arg_data=""
arg_data_dir=""
args=$(getopt -o "" -l "data:,data-file:,data-dir:" -n "$0" -- "$@")
eval set -- "${args}"
while true; do
case "$1" in
--data) arg_data="$2"; shift 2;;
--data-file) arg_data=$(cat $2); shift 2;;
--data-dir) arg_data_dir="$2"; shift 2;;
--) break;;
*) echo "Unknown option $1"; exit 1;;
esac
done
readonly is_update=$(systemctl is-active box && echo "yes" || echo "no")
echo "==> installer: updating docker"
if [[ $(docker version --format {{.Client.Version}}) != "18.03.1-ce" ]]; then
@@ -94,6 +81,21 @@ if [[ ${try} -eq 10 ]]; then
exit 4
fi
echo "==> installer: downloading new addon images"
images=$(node -e "var i = require('${box_src_tmp_dir}/src/infra_version.js'); console.log(i.baseImages.map(function (x) { return x.tag; }).join(' '), Object.keys(i.images).map(function (x) { return i.images[x].tag; }).join(' '));")
echo -e "\tPulling docker images: ${images}"
for image in ${images}; do
if ! docker pull "${image}"; then # this pulls the image using the sha256
echo "==> installer: Could not pull ${image}"
exit 5
fi
if ! docker pull "${image%@sha256:*}"; then # this will tag the image for readability
echo "==> installer: Could not pull ${image%@sha256:*}"
exit 6
fi
done
echo "==> installer: update cloudron-syslog"
CLOUDRON_SYSLOG_DIR=/usr/local/cloudron-syslog
CLOUDRON_SYSLOG="${CLOUDRON_SYSLOG_DIR}/bin/cloudron-syslog"
@@ -115,15 +117,6 @@ if [[ "${is_update}" == "yes" ]]; then
${BOX_SRC_DIR}/setup/stop.sh
fi
# setup links to data directory
if [[ -n "${arg_data_dir}" ]]; then
echo "==> installer: setting up links to data directory"
mkdir "${arg_data_dir}/appsdata"
ln -s "${arg_data_dir}/appsdata" "${BASE_DATA_DIR}/appsdata"
mkdir "${arg_data_dir}/platformdata"
ln -s "${arg_data_dir}/platformdata" "${BASE_DATA_DIR}/platformdata"
fi
# ensure we are not inside the source directory, which we will remove now
cd /root
@@ -133,4 +126,4 @@ mv "${box_src_tmp_dir}" "${BOX_SRC_DIR}"
chown -R "${USER}:${USER}" "${BOX_SRC_DIR}"
echo "==> installer: calling box setup script"
"${BOX_SRC_DIR}/setup/start.sh" --data "${arg_data}"
"${BOX_SRC_DIR}/setup/start.sh"
-71
View File
@@ -1,71 +0,0 @@
#!/bin/bash
source_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
json="${source_dir}/../node_modules/.bin/json"
arg_api_server_origin=""
arg_fqdn="" # remove after 1.10
arg_admin_domain=""
arg_admin_location=""
arg_admin_fqdn=""
arg_retire_reason=""
arg_retire_info=""
arg_version=""
arg_web_server_origin=""
arg_provider=""
arg_is_demo="false"
args=$(getopt -o "" -l "data:,retire-reason:,retire-info:" -n "$0" -- "$@")
eval set -- "${args}"
while true; do
case "$1" in
--retire-reason)
arg_retire_reason="$2"
shift 2
;;
--retire-info)
arg_retire_info="$2"
shift 2
;;
--data)
# these params must be valid in all cases
arg_fqdn=$(echo "$2" | $json fqdn)
arg_admin_fqdn=$(echo "$2" | $json adminFqdn)
arg_admin_location=$(echo "$2" | $json adminLocation)
[[ "${arg_admin_location}" == "" ]] && arg_admin_location="my"
arg_admin_domain=$(echo "$2" | $json adminDomain)
[[ "${arg_admin_domain}" == "" ]] && arg_admin_domain="${arg_fqdn}"
# only update/restore have this valid (but not migrate)
arg_api_server_origin=$(echo "$2" | $json apiServerOrigin)
[[ "${arg_api_server_origin}" == "" ]] && arg_api_server_origin="https://api.cloudron.io"
arg_web_server_origin=$(echo "$2" | $json webServerOrigin)
[[ "${arg_web_server_origin}" == "" ]] && arg_web_server_origin="https://cloudron.io"
# TODO check if and where this is used
arg_version=$(echo "$2" | $json version)
# read possibly empty parameters here
arg_is_demo=$(echo "$2" | $json isDemo)
[[ "${arg_is_demo}" == "" ]] && arg_is_demo="false"
arg_provider=$(echo "$2" | $json provider)
[[ "${arg_provider}" == "" ]] && arg_provider="generic"
shift 2
;;
--) break;;
*) echo "Unknown option $1"; exit 1;;
esac
done
echo "Parsed arguments:"
echo "api server: ${arg_api_server_origin}"
echo "admin fqdn: ${arg_admin_fqdn}"
echo "fqdn: ${arg_fqdn}"
echo "version: ${arg_version}"
echo "web server: ${arg_web_server_origin}"
echo "provider: ${arg_provider}"
+15 -53
View File
@@ -2,6 +2,9 @@
set -eu -o pipefail
# This script is run after the box code is switched. This means that this script
# should pretty much always succeed. No network logic/download code here.
echo "==> Cloudron Start"
readonly USER="yellowtent"
@@ -12,17 +15,8 @@ readonly APPS_DATA_DIR="${HOME_DIR}/appsdata" # app data
readonly BOX_DATA_DIR="${HOME_DIR}/boxdata" # box data
readonly CONFIG_DIR="${HOME_DIR}/configs"
readonly curl="curl --fail --connect-timeout 20 --retry 10 --retry-delay 2 --max-time 2400"
readonly script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "${script_dir}/argparser.sh" "$@" # this injects the arg_* variables used below
echo "==> Configuring host"
sed -e 's/^#NTP=/NTP=0.ubuntu.pool.ntp.org 1.ubuntu.pool.ntp.org 2.ubuntu.pool.ntp.org 3.ubuntu.pool.ntp.org/' -i /etc/systemd/timesyncd.conf
timedatectl set-ntp 1
timedatectl set-timezone UTC
hostnamectl set-hostname "${arg_admin_fqdn}"
readonly json="$(realpath ${script_dir}/../node_modules/.bin/json)"
echo "==> Configuring docker"
cp "${script_dir}/start/docker-cloudron-app.apparmor" /etc/apparmor.d/docker-cloudron-app
@@ -48,19 +42,6 @@ if [[ ! -f /etc/systemd/system/docker.service.d/cloudron.conf ]] || ! diff -q /e
fi
docker network create --subnet=172.18.0.0/16 cloudron || true
# caas has ssh on port 202 and we disable password login
if [[ "${arg_provider}" == "caas" ]]; then
# https://stackoverflow.com/questions/4348166/using-with-sed on why ? must be escaped
sed -e 's/^#\?PermitRootLogin .*/PermitRootLogin without-password/g' \
-e 's/^#\?PermitEmptyPasswords .*/PermitEmptyPasswords no/g' \
-e 's/^#\?PasswordAuthentication .*/PasswordAuthentication no/g' \
-e 's/^#\?Port .*/Port 202/g' \
-i /etc/ssh/sshd_config
# required so we can connect to this machine since port 22 is blocked by iptables by now
systemctl reload sshd
fi
mkdir -p "${BOX_DATA_DIR}"
mkdir -p "${APPS_DATA_DIR}"
@@ -71,12 +52,13 @@ mkdir -p "${PLATFORM_DATA_DIR}/graphite"
mkdir -p "${PLATFORM_DATA_DIR}/mysql"
mkdir -p "${PLATFORM_DATA_DIR}/postgresql"
mkdir -p "${PLATFORM_DATA_DIR}/mongodb"
mkdir -p "${PLATFORM_DATA_DIR}/redis"
mkdir -p "${PLATFORM_DATA_DIR}/addons/mail"
mkdir -p "${PLATFORM_DATA_DIR}/collectd/collectd.conf.d"
mkdir -p "${PLATFORM_DATA_DIR}/logrotate.d"
mkdir -p "${PLATFORM_DATA_DIR}/acme"
mkdir -p "${PLATFORM_DATA_DIR}/backup"
mkdir -p "${PLATFORM_DATA_DIR}/logs/backup"
mkdir -p "${PLATFORM_DATA_DIR}/logs/backup" "${PLATFORM_DATA_DIR}/logs/updater" "${PLATFORM_DATA_DIR}/logs/tasks"
mkdir -p "${PLATFORM_DATA_DIR}/update"
mkdir -p "${BOX_DATA_DIR}/appicons"
@@ -109,6 +91,11 @@ setfacl -n -m u:${USER}:r /var/log/journal/*/system.journal
echo "==> Creating config directory"
mkdir -p "${CONFIG_DIR}"
# remove old cloudron.conf. Can be removed after 3.4
rm -f "${CONFIG_DIR}/cloudron.conf"
$json -f /etc/cloudron/cloudron.conf -I -e "delete this.version" # remove the version field
chown -R "${USER}" /etc/cloudron
echo "==> Setting up unbound"
# DO uses Google nameservers by default. This causes RBL queries to fail (host 2.0.0.127.zen.spamhaus.org)
# We do not use dnsmasq because it is not a recursive resolver and defaults to the value in the interfaces file (which is Google DNS!)
@@ -152,8 +139,8 @@ echo "==> Configuring logrotate"
if ! grep -q "^include ${PLATFORM_DATA_DIR}/logrotate.d" /etc/logrotate.conf; then
echo -e "\ninclude ${PLATFORM_DATA_DIR}/logrotate.d\n" >> /etc/logrotate.conf
fi
cp "${script_dir}/start/app-logrotate" "${PLATFORM_DATA_DIR}/logrotate.d/app-logrotate"
chown root:root "${PLATFORM_DATA_DIR}/logrotate.d/app-logrotate"
cp "${script_dir}/start/box-logrotate" "${script_dir}/start/app-logrotate" "${PLATFORM_DATA_DIR}/logrotate.d/"
chown root:root "${PLATFORM_DATA_DIR}/logrotate.d/box-logrotate" "${PLATFORM_DATA_DIR}/logrotate.d/app-logrotate"
echo "==> Adding motd message for admins"
cp "${script_dir}/start/cloudron-motd" /etc/update-motd.d/92-cloudron
@@ -171,14 +158,8 @@ if ! grep -q "^Restart=" /etc/systemd/system/multi-user.target.wants/nginx.servi
echo -e "\n[Service]\nRestart=always\n" >> /etc/systemd/system/multi-user.target.wants/nginx.service
systemctl daemon-reload
fi
# remove this migration after 1.10
[[ -f /etc/nginx/cert/host.cert ]] && cp /etc/nginx/cert/host.cert "/etc/nginx/cert/${arg_admin_domain}.host.cert"
[[ -f /etc/nginx/cert/host.key ]] && cp /etc/nginx/cert/host.key "/etc/nginx/cert/${arg_admin_domain}.host.key"
systemctl start nginx
# bookkeep the version as part of data
echo "{ \"version\": \"${arg_version}\", \"apiServerOrigin\": \"${arg_api_server_origin}\" }" > "${BOX_DATA_DIR}/version"
# restart mysql to make sure it has latest config
if [[ ! -f /etc/mysql/mysql.cnf ]] || ! diff -q "${script_dir}/start/mysql.cnf" /etc/mysql/mysql.cnf >/dev/null; then
# wait for all running mysql jobs
@@ -208,27 +189,6 @@ cd "${BOX_SRC_DIR}"
BOX_ENV=cloudron DATABASE_URL=mysql://root:${mysql_root_password}@127.0.0.1/box "${BOX_SRC_DIR}/node_modules/.bin/db-migrate" up
EOF
echo "==> Creating cloudron.conf"
cat > "${CONFIG_DIR}/cloudron.conf" <<CONF_END
{
"version": "${arg_version}",
"apiServerOrigin": "${arg_api_server_origin}",
"webServerOrigin": "${arg_web_server_origin}",
"adminDomain": "${arg_admin_domain}",
"adminFqdn": "${arg_admin_fqdn}",
"adminLocation": "${arg_admin_location}",
"provider": "${arg_provider}",
"isDemo": ${arg_is_demo}
}
CONF_END
echo "==> Creating config.json for dashboard"
cat > "${BOX_SRC_DIR}/dashboard/dist/config.json" <<CONF_END
{
"webServerOrigin": "${arg_web_server_origin}"
}
CONF_END
if [[ ! -f "${BOX_DATA_DIR}/dhparams.pem" ]]; then
echo "==> Generating dhparams (takes forever)"
openssl dhparam -out "${BOX_DATA_DIR}/dhparams.pem" 2048
@@ -239,9 +199,11 @@ fi
echo "==> Changing ownership"
chown "${USER}:${USER}" -R "${CONFIG_DIR}"
# be careful of what is chown'ed here. subdirs like mysql,redis etc are owned by the containers and will stop working if perms change
chown "${USER}:${USER}" -R "${PLATFORM_DATA_DIR}/nginx" "${PLATFORM_DATA_DIR}/collectd" "${PLATFORM_DATA_DIR}/addons" "${PLATFORM_DATA_DIR}/acme" "${PLATFORM_DATA_DIR}/backup" "${PLATFORM_DATA_DIR}/logs" "${PLATFORM_DATA_DIR}/update"
chown "${USER}:${USER}" "${PLATFORM_DATA_DIR}/INFRA_VERSION" 2>/dev/null || true
chown "${USER}:${USER}" "${PLATFORM_DATA_DIR}"
chown "${USER}:${USER}" "${APPS_DATA_DIR}"
# logrotate files have to be owned by root, this is here to fixup existing installations where we were resetting the owner to yellowtent
chown root:root -R "${PLATFORM_DATA_DIR}/logrotate.d"
+9
View File
@@ -0,0 +1,9 @@
# logrotate config for box logs
/home/yellowtent/platformdata/logs/box.log {
rotate 10
size 10M
# we never compress so we can simply tail the files
nocompress
copytruncate
}
+2 -2
View File
@@ -90,8 +90,8 @@ server {
add_header Referrer-Policy "no-referrer-when-downgrade";
proxy_hide_header Referrer-Policy;
# CSP headers for the admin/dashboard resources
<% if ( endpoint === 'admin' ) { -%>
# CSP headers for the admin/dashboard resources
add_header Content-Security-Policy "default-src 'none'; connect-src wss: https: 'self' *.cloudron.io; script-src https: 'self' 'unsafe-inline' 'unsafe-eval'; img-src * data:; style-src https: 'unsafe-inline'; object-src 'none'; font-src https: 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self';";
<% } -%>
@@ -162,7 +162,7 @@ server {
# graphite paths (uncomment block below and visit /graphite/index.html)
# remember to comment out the CSP policy as well to access the graphite dashboard
# location ~ ^/(graphite|content|metrics|dashboard|render|browser|composer)/ {
# proxy_pass http://127.0.0.1:8000;
# proxy_pass http://127.0.0.1:8417;
# client_max_body_size 1m;
# }
+12 -6
View File
@@ -1,11 +1,11 @@
# sudo logging breaks journalctl output with very long urls (systemd bug)
Defaults !syslog
Defaults!/home/yellowtent/box/src/scripts/createappdir.sh env_keep="HOME BOX_ENV"
yellowtent ALL=(root) NOPASSWD: /home/yellowtent/box/src/scripts/createappdir.sh
Defaults!/home/yellowtent/box/src/scripts/rmvolume.sh env_keep="HOME BOX_ENV"
yellowtent ALL=(root) NOPASSWD: /home/yellowtent/box/src/scripts/rmvolume.sh
Defaults!/home/yellowtent/box/src/scripts/rmappdir.sh env_keep="HOME BOX_ENV"
yellowtent ALL=(root) NOPASSWD: /home/yellowtent/box/src/scripts/rmappdir.sh
Defaults!/home/yellowtent/box/src/scripts/rmaddondir.sh env_keep="HOME BOX_ENV"
yellowtent ALL=(root) NOPASSWD: /home/yellowtent/box/src/scripts/rmaddondir.sh
Defaults!/home/yellowtent/box/src/scripts/reloadnginx.sh env_keep="HOME BOX_ENV"
yellowtent ALL=(root) NOPASSWD: /home/yellowtent/box/src/scripts/reloadnginx.sh
@@ -31,9 +31,15 @@ yellowtent ALL=(root) NOPASSWD: /home/yellowtent/box/src/scripts/authorized_keys
Defaults!/home/yellowtent/box/src/scripts/configurelogrotate.sh env_keep="HOME BOX_ENV"
yellowtent ALL=(root) NOPASSWD: /home/yellowtent/box/src/scripts/configurelogrotate.sh
Defaults!/home/yellowtent/box/src/backuptask.js env_keep="HOME BOX_ENV"
yellowtent ALL=(root) NOPASSWD:SETENV: /home/yellowtent/box/src/backuptask.js
Defaults!/home/yellowtent/box/src/scripts/backupupload.js env_keep="HOME BOX_ENV"
Defaults!/home/yellowtent/box/src/scripts/backupupload.js closefrom_override
yellowtent ALL=(root) NOPASSWD:SETENV: /home/yellowtent/box/src/scripts/backupupload.js
Defaults!/home/yellowtent/box/src/scripts/restart.sh env_keep="HOME BOX_ENV"
yellowtent ALL=(root) NOPASSWD: /home/yellowtent/box/src/scripts/restart.sh
Defaults!/home/yellowtent/box/src/scripts/restartdocker.sh env_keep="HOME BOX_ENV"
yellowtent ALL=(root) NOPASSWD: /home/yellowtent/box/src/scripts/restartdocker.sh
Defaults!/home/yellowtent/box/src/scripts/restartunbound.sh env_keep="HOME BOX_ENV"
yellowtent ALL=(root) NOPASSWD: /home/yellowtent/box/src/scripts/restartunbound.sh
+2 -1
View File
@@ -12,7 +12,8 @@ Wants=cloudron-resize-fs.service
Type=idle
WorkingDirectory=/home/yellowtent/box
Restart=always
ExecStart=/usr/bin/node --max_old_space_size=150 /home/yellowtent/box/box.js
; Systemd does not append logs when logging to files, we spawn a shell first and exec to replace it after setting up the pipes
ExecStart=/bin/sh -c 'echo "Logging to /home/yellowtent/platformdata/logs/box.log"; exec /usr/bin/node --max_old_space_size=150 /home/yellowtent/box/box.js >> /home/yellowtent/platformdata/logs/box.log 2>&1'
Environment="HOME=/home/yellowtent" "USER=yellowtent" "DEBUG=box*,connect-lastmile" "BOX_ENV=cloudron" "NODE_ENV=production"
; kill apptask processes as well
KillMode=control-group
+2 -6
View File
@@ -26,9 +26,9 @@ exports = module.exports = {
};
var assert = require('assert'),
config = require('./config.js'),
DatabaseError = require('./databaseerror.js'),
debug = require('debug')('box:accesscontrol'),
settings = require('./settings.js'),
tokendb = require('./tokendb.js'),
users = require('./users.js'),
UsersError = users.UsersError,
@@ -114,11 +114,7 @@ function scopesForUser(user, callback) {
if (user.admin) return callback(null, exports.VALID_SCOPES);
settings.getSpacesConfig(function (error, spaces) {
if (error) return callback(error);
callback(null, spaces.enabled ? [ 'profile', 'apps', 'domains:read', 'users:read' ] : [ 'profile', 'apps:read' ]);
});
callback(null, config.isSpacesEnabled() ? [ 'profile', 'apps', 'domains:read', 'users:read' ] : [ 'profile', 'apps:read' ]);
}
function validateToken(accessToken, callback) {
+1097 -236
View File
File diff suppressed because it is too large Load Diff
+88 -42
View File
@@ -18,6 +18,7 @@ exports = module.exports = {
getAddonConfigByName: getAddonConfigByName,
unsetAddonConfig: unsetAddonConfig,
unsetAddonConfigByAppId: unsetAddonConfigByAppId,
getAppIdByAddonConfigValue: getAppIdByAddonConfigValue,
setHealth: setHealth,
setInstallationCommand: setInstallationCommand,
@@ -61,7 +62,6 @@ var assert = require('assert'),
async = require('async'),
database = require('./database.js'),
DatabaseError = require('./databaseerror'),
mailboxdb = require('./mailboxdb.js'),
safe = require('safetydance'),
util = require('util');
@@ -69,9 +69,11 @@ var APPS_FIELDS_PREFIXED = [ 'apps.id', 'apps.appStoreId', 'apps.installationSta
'apps.health', 'apps.containerId', 'apps.manifestJson', 'apps.httpPort', 'subdomains.subdomain AS location', 'subdomains.domain',
'apps.accessRestrictionJson', 'apps.restoreConfigJson', 'apps.oldConfigJson', 'apps.updateConfigJson', 'apps.memoryLimit',
'apps.xFrameOptions', 'apps.sso', 'apps.debugModeJson', 'apps.robotsTxt', 'apps.enableBackup',
'apps.creationTime', 'apps.updateTime', 'apps.ownerId', 'apps.ts' ].join(',');
'apps.creationTime', 'apps.updateTime', 'apps.ownerId', 'apps.mailboxName', 'apps.enableAutomaticUpdate', 'apps.ts' ].join(',');
var PORT_BINDINGS_FIELDS = [ 'hostPort', 'environmentVariable', 'appId' ].join(',');
var PORT_BINDINGS_FIELDS = [ 'hostPort', 'type', 'environmentVariable', 'appId' ].join(',');
const SUBDOMAIN_FIELDS = [ 'appId', 'domain', 'subdomain', 'type' ].join(',');
function postProcess(result) {
assert.strictEqual(typeof result, 'object');
@@ -96,14 +98,16 @@ function postProcess(result) {
assert(result.environmentVariables === null || typeof result.environmentVariables === 'string');
result.portBindings = { };
var hostPorts = result.hostPorts === null ? [ ] : result.hostPorts.split(',');
var environmentVariables = result.environmentVariables === null ? [ ] : result.environmentVariables.split(',');
let hostPorts = result.hostPorts === null ? [ ] : result.hostPorts.split(',');
let environmentVariables = result.environmentVariables === null ? [ ] : result.environmentVariables.split(',');
let portTypes = result.portTypes === null ? [ ] : result.portTypes.split(',');
delete result.hostPorts;
delete result.environmentVariables;
delete result.portTypes;
for (var i = 0; i < environmentVariables.length; i++) {
result.portBindings[environmentVariables[i]] = parseInt(hostPorts[i], 10);
for (let i = 0; i < environmentVariables.length; i++) {
result.portBindings[environmentVariables[i]] = { hostPort: parseInt(hostPorts[i], 10), type: portTypes[i] };
}
assert(result.accessRestrictionJson === null || typeof result.accessRestrictionJson === 'string');
@@ -116,6 +120,7 @@ function postProcess(result) {
result.sso = !!result.sso; // make it bool
result.enableBackup = !!result.enableBackup; // make it bool
result.enableAutomaticUpdate = !!result.enableAutomaticUpdate; // make it bool
assert(result.debugModeJson === null || typeof result.debugModeJson === 'string');
result.debugMode = safe.JSON.parse(result.debugModeJson);
@@ -126,6 +131,14 @@ function postProcess(result) {
delete d.appId;
delete d.type;
});
let envNames = JSON.parse(result.envNames), envValues = JSON.parse(result.envValues);
delete result.envNames;
delete result.envValues;
result.env = {};
for (let i = 0; i < envNames.length; i++) { // NOTE: envNames is [ null ] when env of an app is empty
if (envNames[i]) result.env[envNames[i]] = envValues[i];
}
}
function get(id, callback) {
@@ -133,15 +146,17 @@ function get(id, callback) {
assert.strictEqual(typeof callback, 'function');
database.query('SELECT ' + APPS_FIELDS_PREFIXED + ','
+ 'GROUP_CONCAT(CAST(appPortBindings.hostPort AS CHAR(6))) AS hostPorts, GROUP_CONCAT(appPortBindings.environmentVariable) AS environmentVariables'
+ 'GROUP_CONCAT(CAST(appPortBindings.hostPort AS CHAR(6))) AS hostPorts, GROUP_CONCAT(appPortBindings.environmentVariable) AS environmentVariables, GROUP_CONCAT(appPortBindings.type) AS portTypes, '
+ 'JSON_ARRAYAGG(appEnvVars.name) AS envNames, JSON_ARRAYAGG(appEnvVars.value) AS envValues'
+ ' FROM apps'
+ ' LEFT OUTER JOIN appPortBindings ON apps.id = appPortBindings.appId'
+ ' LEFT OUTER JOIN subdomains ON apps.id = subdomains.appId AND type = ?'
+ ' LEFT OUTER JOIN appEnvVars ON apps.id = appEnvVars.appId'
+ ' LEFT OUTER JOIN subdomains ON apps.id = subdomains.appId AND subdomains.type = ?'
+ ' WHERE apps.id = ? GROUP BY apps.id', [ exports.SUBDOMAIN_TYPE_PRIMARY, id ], function (error, result) {
if (error) return callback(new DatabaseError(DatabaseError.INTERNAL_ERROR, error));
if (result.length === 0) return callback(new DatabaseError(DatabaseError.NOT_FOUND));
database.query('SELECT * FROM subdomains WHERE appId = ? AND type = ?', [ id, exports.SUBDOMAIN_TYPE_REDIRECT ], function (error, alternateDomains) {
database.query('SELECT ' + SUBDOMAIN_FIELDS + ' FROM subdomains WHERE appId = ? AND type = ?', [ id, exports.SUBDOMAIN_TYPE_REDIRECT ], function (error, alternateDomains) {
if (error) return callback(new DatabaseError(DatabaseError.INTERNAL_ERROR, error));
result[0].alternateDomains = alternateDomains;
@@ -149,7 +164,7 @@ function get(id, callback) {
postProcess(result[0]);
callback(null, result[0]);
})
});
});
}
@@ -158,15 +173,17 @@ function getByHttpPort(httpPort, callback) {
assert.strictEqual(typeof callback, 'function');
database.query('SELECT ' + APPS_FIELDS_PREFIXED + ','
+ 'GROUP_CONCAT(CAST(appPortBindings.hostPort AS CHAR(6))) AS hostPorts, GROUP_CONCAT(appPortBindings.environmentVariable) AS environmentVariables'
+ 'GROUP_CONCAT(CAST(appPortBindings.hostPort AS CHAR(6))) AS hostPorts, GROUP_CONCAT(appPortBindings.environmentVariable) AS environmentVariables, GROUP_CONCAT(appPortBindings.type) AS portTypes,'
+ 'JSON_ARRAYAGG(appEnvVars.name) AS envNames, JSON_ARRAYAGG(appEnvVars.value) AS envValues'
+ ' FROM apps'
+ ' LEFT OUTER JOIN appPortBindings ON apps.id = appPortBindings.appId'
+ ' LEFT OUTER JOIN subdomains ON apps.id = subdomains.appId AND type = ?'
+ ' LEFT OUTER JOIN appEnvVars ON apps.id = appEnvVars.appId'
+ ' LEFT OUTER JOIN subdomains ON apps.id = subdomains.appId AND subdomains.type = ?'
+ ' WHERE httpPort = ? GROUP BY apps.id', [ exports.SUBDOMAIN_TYPE_PRIMARY, httpPort ], function (error, result) {
if (error) return callback(new DatabaseError(DatabaseError.INTERNAL_ERROR, error));
if (result.length === 0) return callback(new DatabaseError(DatabaseError.NOT_FOUND));
database.query('SELECT * FROM subdomains WHERE appId = ? AND type = ?', [ result[0].id, exports.SUBDOMAIN_TYPE_REDIRECT ], function (error, alternateDomains) {
database.query('SELECT ' + SUBDOMAIN_FIELDS + ' FROM subdomains WHERE appId = ? AND type = ?', [ result[0].id, exports.SUBDOMAIN_TYPE_REDIRECT ], function (error, alternateDomains) {
if (error) return callback(new DatabaseError(DatabaseError.INTERNAL_ERROR, error));
result[0].alternateDomains = alternateDomains;
@@ -182,15 +199,17 @@ function getByContainerId(containerId, callback) {
assert.strictEqual(typeof callback, 'function');
database.query('SELECT ' + APPS_FIELDS_PREFIXED + ','
+ 'GROUP_CONCAT(CAST(appPortBindings.hostPort AS CHAR(6))) AS hostPorts, GROUP_CONCAT(appPortBindings.environmentVariable) AS environmentVariables'
+ 'GROUP_CONCAT(CAST(appPortBindings.hostPort AS CHAR(6))) AS hostPorts, GROUP_CONCAT(appPortBindings.environmentVariable) AS environmentVariables, GROUP_CONCAT(appPortBindings.type) AS portTypes,'
+ 'JSON_ARRAYAGG(appEnvVars.name) AS envNames, JSON_ARRAYAGG(appEnvVars.value) AS envValues'
+ ' FROM apps'
+ ' LEFT OUTER JOIN appPortBindings ON apps.id = appPortBindings.appId'
+ ' LEFT OUTER JOIN subdomains ON apps.id = subdomains.appId AND type = ?'
+ ' LEFT OUTER JOIN appEnvVars ON apps.id = appEnvVars.appId'
+ ' LEFT OUTER JOIN subdomains ON apps.id = subdomains.appId AND subdomains.type = ?'
+ ' WHERE containerId = ? GROUP BY apps.id', [ exports.SUBDOMAIN_TYPE_PRIMARY, 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));
database.query('SELECT * FROM subdomains WHERE appId = ? AND type = ?', [ result[0].id, exports.SUBDOMAIN_TYPE_REDIRECT ], function (error, alternateDomains) {
database.query('SELECT ' + SUBDOMAIN_FIELDS + ' FROM subdomains WHERE appId = ? AND type = ?', [ result[0].id, exports.SUBDOMAIN_TYPE_REDIRECT ], function (error, alternateDomains) {
if (error) return callback(new DatabaseError(DatabaseError.INTERNAL_ERROR, error));
result[0].alternateDomains = alternateDomains;
@@ -205,14 +224,16 @@ function getAll(callback) {
assert.strictEqual(typeof callback, 'function');
database.query('SELECT ' + APPS_FIELDS_PREFIXED + ','
+ 'GROUP_CONCAT(CAST(appPortBindings.hostPort AS CHAR(6))) AS hostPorts, GROUP_CONCAT(appPortBindings.environmentVariable) AS environmentVariables'
+ 'GROUP_CONCAT(CAST(appPortBindings.hostPort AS CHAR(6))) AS hostPorts, GROUP_CONCAT(appPortBindings.environmentVariable) AS environmentVariables, GROUP_CONCAT(appPortBindings.type) AS portTypes,'
+ 'JSON_ARRAYAGG(appEnvVars.name) AS envNames, JSON_ARRAYAGG(appEnvVars.value) AS envValues'
+ ' FROM apps'
+ ' LEFT OUTER JOIN appPortBindings ON apps.id = appPortBindings.appId'
+ ' LEFT OUTER JOIN subdomains ON apps.id = subdomains.appId AND type = ?'
+ ' LEFT OUTER JOIN appEnvVars ON apps.id = appEnvVars.appId'
+ ' LEFT OUTER JOIN subdomains ON apps.id = subdomains.appId AND subdomains.type = ?'
+ ' GROUP BY apps.id ORDER BY apps.id', [ exports.SUBDOMAIN_TYPE_PRIMARY ], function (error, results) {
if (error) return callback(new DatabaseError(DatabaseError.INTERNAL_ERROR, error));
database.query('SELECT * FROM subdomains WHERE type = ?', [ exports.SUBDOMAIN_TYPE_REDIRECT ], function (error, alternateDomains) {
database.query('SELECT ' + SUBDOMAIN_FIELDS + ' FROM subdomains WHERE type = ?', [ exports.SUBDOMAIN_TYPE_REDIRECT ], function (error, alternateDomains) {
if (error) return callback(new DatabaseError(DatabaseError.INTERNAL_ERROR, error));
alternateDomains.forEach(function (d) {
@@ -255,13 +276,15 @@ function add(id, appStoreId, manifest, location, domain, ownerId, portBindings,
var sso = 'sso' in data ? data.sso : null;
var robotsTxt = 'robotsTxt' in data ? data.robotsTxt : null;
var debugModeJson = data.debugMode ? JSON.stringify(data.debugMode) : null;
var env = data.env || {};
const mailboxName = data.mailboxName || null;
var queries = [];
queries.push({
query: 'INSERT INTO apps (id, appStoreId, manifestJson, installationState, accessRestrictionJson, memoryLimit, xFrameOptions, restoreConfigJson, sso, debugModeJson, robotsTxt, ownerId) ' +
' VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
args: [ id, appStoreId, manifestJson, installationState, accessRestrictionJson, memoryLimit, xFrameOptions, restoreConfigJson, sso, debugModeJson, robotsTxt, ownerId ]
query: 'INSERT INTO apps (id, appStoreId, manifestJson, installationState, accessRestrictionJson, memoryLimit, xFrameOptions, restoreConfigJson, sso, debugModeJson, robotsTxt, ownerId, mailboxName) ' +
' VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
args: [ id, appStoreId, manifestJson, installationState, accessRestrictionJson, memoryLimit, xFrameOptions, restoreConfigJson, sso, debugModeJson, robotsTxt, ownerId, mailboxName ]
});
queries.push({
@@ -271,18 +294,17 @@ function add(id, appStoreId, manifest, location, domain, ownerId, portBindings,
Object.keys(portBindings).forEach(function (env) {
queries.push({
query: 'INSERT INTO appPortBindings (environmentVariable, hostPort, appId) VALUES (?, ?, ?)',
args: [ env, portBindings[env], id ]
query: 'INSERT INTO appPortBindings (environmentVariable, hostPort, type, appId) VALUES (?, ?, ?, ?)',
args: [ env, portBindings[env].hostPort, portBindings[env].type, id ]
});
});
// only allocate a mailbox if mailboxName is set
if (data.mailboxName) {
Object.keys(env).forEach(function (name) {
queries.push({
query: 'INSERT INTO mailboxes (name, type, domain, ownerId, ownerType) VALUES (?, ?, ?, ?, ?)',
args: [ data.mailboxName, mailboxdb.TYPE_MAILBOX, domain, id, mailboxdb.OWNER_TYPE_APP ]
query: 'INSERT INTO appEnvVars (appId, name, value) VALUES (?, ?, ?)',
args: [ id, name, env[name] ]
});
}
});
if (data.alternateDomains) {
data.alternateDomains.forEach(function (d) {
@@ -322,18 +344,19 @@ function getPortBindings(id, callback) {
var portBindings = { };
for (var i = 0; i < results.length; i++) {
portBindings[results[i].environmentVariable] = results[i].hostPort;
portBindings[results[i].environmentVariable] = { hostPort: results[i].hostPort, type: results[i].type };
}
callback(null, portBindings);
});
}
function delPortBinding(hostPort, callback) {
function delPortBinding(hostPort, type, callback) {
assert.strictEqual(typeof hostPort, 'number');
assert.strictEqual(typeof type, 'string');
assert.strictEqual(typeof callback, 'function');
database.query('DELETE FROM appPortBindings WHERE hostPort=?', [ hostPort ], function (error, result) {
database.query('DELETE FROM appPortBindings WHERE hostPort=? AND type=?', [ hostPort, type ], function (error, result) {
if (error) return callback(new DatabaseError(DatabaseError.INTERNAL_ERROR, error));
if (result.affectedRows !== 1) return callback(new DatabaseError(DatabaseError.NOT_FOUND));
@@ -347,8 +370,8 @@ function del(id, callback) {
var queries = [
{ query: 'DELETE FROM subdomains WHERE appId = ?', args: [ id ] },
{ query: 'DELETE FROM mailboxes WHERE ownerId=?', args: [ id ] },
{ query: 'DELETE FROM appPortBindings WHERE appId = ?', args: [ id ] },
{ query: 'DELETE FROM appEnvVars WHERE appId = ?', args: [ id ] },
{ query: 'DELETE FROM apps WHERE id = ?', args: [ id ] }
];
@@ -367,6 +390,7 @@ function clear(callback) {
database.query.bind(null, 'DELETE FROM subdomains'),
database.query.bind(null, 'DELETE FROM appPortBindings'),
database.query.bind(null, 'DELETE FROM appAddonConfigs'),
database.query.bind(null, 'DELETE FROM appEnvVars'),
database.query.bind(null, 'DELETE FROM apps')
], function (error) {
if (error) return callback(new DatabaseError(DatabaseError.INTERNAL_ERROR, error));
@@ -386,6 +410,7 @@ function updateWithConstraints(id, app, constraints, callback) {
assert(!('portBindings' in app) || typeof app.portBindings === 'object');
assert(!('accessRestriction' in app) || typeof app.accessRestriction === 'object' || app.accessRestriction === '');
assert(!('alternateDomains' in app) || Array.isArray(app.alternateDomains));
assert(!('env' in app) || typeof app.env === 'object');
var queries = [ ];
@@ -394,17 +419,24 @@ function updateWithConstraints(id, app, constraints, callback) {
// replace entries by app id
queries.push({ query: 'DELETE FROM appPortBindings WHERE appId = ?', args: [ id ] });
Object.keys(portBindings).forEach(function (env) {
var values = [ portBindings[env], env, id ];
queries.push({ query: 'INSERT INTO appPortBindings (hostPort, environmentVariable, appId) VALUES(?, ?, ?)', args: values });
var values = [ portBindings[env].hostPort, portBindings[env].type, env, id ];
queries.push({ query: 'INSERT INTO appPortBindings (hostPort, type, environmentVariable, appId) VALUES(?, ?, ?, ?)', args: values });
});
}
if ('location' in app) {
queries.push({ query: 'UPDATE subdomains SET subdomain = ? WHERE appId = ? AND type = ?', args: [ app.location, id, exports.SUBDOMAIN_TYPE_PRIMARY ]});
if ('env' in app) {
queries.push({ query: 'DELETE FROM appEnvVars WHERE appId = ?', args: [ id ] });
Object.keys(app.env).forEach(function (name) {
queries.push({
query: 'INSERT INTO appEnvVars (appId, name, value) VALUES (?, ?, ?)',
args: [ id, name, app.env[name] ]
});
});
}
if ('domain' in app) {
queries.push({ query: 'UPDATE subdomains SET domain = ? WHERE appId = ? AND type = ?', args: [ app.domain, id, exports.SUBDOMAIN_TYPE_PRIMARY ]});
if ('location' in app && 'domain' in app) { // must be updated together as they are unique together
queries.push({ query: 'UPDATE subdomains SET subdomain = ?, domain = ? WHERE appId = ? AND type = ?', args: [ app.location, app.domain, id, exports.SUBDOMAIN_TYPE_PRIMARY ]});
}
if ('alternateDomains' in app) {
@@ -419,7 +451,7 @@ function updateWithConstraints(id, app, constraints, callback) {
if (p === 'manifest' || p === 'oldConfig' || p === 'updateConfig' || p === 'restoreConfig' || p === 'accessRestriction' || p === 'debugMode') {
fields.push(`${p}Json = ?`);
values.push(JSON.stringify(app[p]));
} else if (p !== 'portBindings' && p !== 'location' && p !== 'domain' && p !== 'alternateDomains') {
} else if (p !== 'portBindings' && p !== 'location' && p !== 'domain' && p !== 'alternateDomains' && p !== 'env') {
fields.push(p + ' = ?');
values.push(app[p]);
}
@@ -578,6 +610,20 @@ function getAddonConfigByAppId(appId, callback) {
});
}
function getAppIdByAddonConfigValue(addonId, name, value, callback) {
assert.strictEqual(typeof addonId, 'string');
assert.strictEqual(typeof name, 'string');
assert.strictEqual(typeof value, 'string');
assert.strictEqual(typeof callback, 'function');
database.query('SELECT appId FROM appAddonConfigs WHERE addonId = ? AND name = ? AND value = ?', [ addonId, name, value ], function (error, results) {
if (error) return callback(new DatabaseError(DatabaseError.INTERNAL_ERROR, error));
if (results.length === 0) return callback(new DatabaseError(DatabaseError.NOT_FOUND));
callback(null, results[0].appId);
});
}
function getAddonConfigByName(appId, addonId, name, callback) {
assert.strictEqual(typeof appId, 'string');
assert.strictEqual(typeof addonId, 'string');
@@ -612,7 +658,7 @@ function transferOwnership(oldOwnerId, newOwnerId, callback) {
assert.strictEqual(typeof newOwnerId, 'string');
assert.strictEqual(typeof callback, 'function');
database.query('UPDATE apps SET ownerId=? WHERE ownerId=?', [ newOwnerId, oldOwnerId ], function (error, results) {
database.query('UPDATE apps SET ownerId=? WHERE ownerId=?', [ newOwnerId, oldOwnerId ], function (error) {
if (error && error.code === 'ER_NO_REFERENCED_ROW_2') return callback(new DatabaseError(DatabaseError.NOT_FOUND, 'No such user'));
if (error) return callback(new DatabaseError(DatabaseError.INTERNAL_ERROR, error));
+50 -59
View File
@@ -12,15 +12,14 @@ var appdb = require('./appdb.js'),
util = require('util');
exports = module.exports = {
start: start,
stop: stop
run: run
};
var HEALTHCHECK_INTERVAL = 10 * 1000; // every 10 seconds. this needs to be small since the UI makes only healthy apps clickable
var UNHEALTHY_THRESHOLD = 10 * 60 * 1000; // 10 minutes
var gHealthInfo = { }; // { time, emailSent }
var gRunTimeout = null;
var gDockerEventStream = null;
const NOOP_CALLBACK = function (error) { if (error) console.error(error); };
function debugApp(app) {
assert(typeof app === 'object');
@@ -88,6 +87,9 @@ function checkAppHealth(app, callback) {
return setHealth(app, appdb.HEALTH_DEAD, callback);
}
// non-appstore apps may not have healthCheckPath
if (!manifest.healthCheckPath) return setHealth(app, appdb.HEALTH_HEALTHY, callback);
// poll through docker network instead of nginx to bypass any potential oauth proxy
var healthCheckUrl = 'http://127.0.0.1:' + app.httpPort + manifest.healthCheckPath;
superagent
@@ -110,48 +112,23 @@ function checkAppHealth(app, callback) {
});
}
function processApps(callback) {
apps.getAll(function (error, result) {
if (error) return callback(error);
async.each(result, checkAppHealth, function (error) {
if (error) console.error(error);
var alive = result
.filter(function (a) { return a.installationState === appdb.ISTATE_INSTALLED && a.runState === appdb.RSTATE_RUNNING && a.health === appdb.HEALTH_HEALTHY; })
.map(function (a) { return (a.location || 'naked_domain') + '|' + a.manifest.id; }).join(', ');
debug('apps alive: [%s]', alive);
callback(null);
});
});
}
function run() {
processApps(function (error) {
if (error) console.error(error);
gRunTimeout = setTimeout(run, HEALTHCHECK_INTERVAL);
});
}
/*
OOM can be tested using stress tool like so:
docker run -ti -m 100M cloudron/base:0.10.0 /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');
function processDockerEvents(interval, callback) {
assert.strictEqual(typeof interval, 'number');
assert.strictEqual(typeof callback, 'function');
const OOM_MAIL_LIMIT = 60 * 60 * 1000; // 60 minutes
var lastOomMailTime = new Date(new Date() - OOM_MAIL_LIMIT);
let lastOomMailTime = new Date(new Date() - OOM_MAIL_LIMIT);
const since = ((new Date().getTime() / 1000) - interval).toFixed(0);
const until = ((new Date().getTime() / 1000) - 1).toFixed(0);
docker.getEvents({ filters: JSON.stringify({ event: [ 'oom' ] }) }, function (error, stream) {
if (error) return console.error(error);
gDockerEventStream = stream;
docker.getEvents({ since: since, until: until, filters: JSON.stringify({ event: [ 'oom' ] }) }, function (error, stream) {
if (error) return callback(error);
stream.setEncoding('utf8');
stream.on('data', function (data) {
@@ -173,34 +150,48 @@ function processDockerEvents() {
});
stream.on('error', function (error) {
console.error('Error reading docker events', error);
gDockerEventStream = null; // TODO: reconnect?
debug('Error reading docker events', error);
callback();
});
stream.on('end', function () {
console.error('Docker event stream ended');
gDockerEventStream = null; // TODO: reconnect?
stream.on('end', callback);
// safety hatch if 'until' doesn't work (there are cases where docker is working with a different time)
setTimeout(stream.destroy.bind(stream), 3000); // https://github.com/apocas/dockerode/issues/179
});
}
function processApp(callback) {
assert.strictEqual(typeof callback, 'function');
apps.getAll(function (error, result) {
if (error) return callback(error);
async.each(result, checkAppHealth, function (error) {
if (error) console.error(error);
var alive = result
.filter(function (a) { return a.installationState === appdb.ISTATE_INSTALLED && a.runState === appdb.RSTATE_RUNNING && a.health === appdb.HEALTH_HEALTHY; })
.map(function (a) { return (a.location || 'naked_domain') + '|' + a.manifest.id; }).join(', ');
debug('apps alive: [%s]', alive);
callback(null);
});
});
}
function start(callback) {
assert.strictEqual(typeof callback, 'function');
function run(interval, callback) {
assert.strictEqual(typeof interval, 'number');
debug('Starting apphealthmonitor');
callback = callback || NOOP_CALLBACK;
processDockerEvents();
async.series([
processDockerEvents.bind(null, interval),
processApp
], function (error) {
if (error) debug(error);
run();
callback();
}
function stop(callback) {
assert.strictEqual(typeof callback, 'function');
clearTimeout(gRunTimeout);
if (gDockerEventStream) gDockerEventStream.end();
callback();
callback();
});
}
+221 -160
View File
@@ -45,10 +45,13 @@ exports = module.exports = {
setOwner: setOwner,
transferOwnership: transferOwnership,
PORT_TYPE_TCP: 'tcp',
PORT_TYPE_UDP: 'udp',
// exported for testing
_validateHostname: validateHostname,
_validatePortBindings: validatePortBindings,
_validateAccessRestriction: validateAccessRestriction
_validateAccessRestriction: validateAccessRestriction,
_translatePortBindings: translatePortBindings
};
var appdb = require('./appdb.js'),
@@ -69,7 +72,6 @@ var appdb = require('./appdb.js'),
eventlog = require('./eventlog.js'),
fs = require('fs'),
mail = require('./mail.js'),
mailboxdb = require('./mailboxdb.js'),
manifestFormat = require('cloudron-manifestformat'),
os = require('os'),
path = require('path'),
@@ -81,7 +83,6 @@ var appdb = require('./appdb.js'),
split = require('split'),
superagent = require('superagent'),
taskmanager = require('./taskmanager.js'),
tld = require('tldjs'),
TransformStream = require('stream').Transform,
updateChecker = require('./updatechecker.js'),
url = require('url'),
@@ -123,43 +124,10 @@ AppsError.BILLING_REQUIRED = 'Billing Required';
AppsError.ACCESS_DENIED = 'Access denied';
AppsError.BAD_CERTIFICATE = 'Invalid certificate';
// Hostname validation comes from RFC 1123 (section 2.1)
// Domain name validation comes from RFC 2181 (Name syntax)
// https://en.wikipedia.org/wiki/Hostname#Restrictions_on_valid_host_names
// We are validating the validity of the location-fqdn as host name (and not dns name)
function validateHostname(location, domain, hostname) {
assert.strictEqual(typeof location, 'string');
assert.strictEqual(typeof domain, 'string');
assert.strictEqual(typeof hostname, 'string');
const RESERVED_LOCATIONS = [
constants.API_LOCATION,
constants.SMTP_LOCATION,
constants.IMAP_LOCATION
];
if (RESERVED_LOCATIONS.indexOf(location) !== -1) return new AppsError(AppsError.BAD_FIELD, location + ' is reserved');
if (hostname === config.adminFqdn()) return new AppsError(AppsError.BAD_FIELD, location + ' is reserved');
// workaround https://github.com/oncletom/tld.js/issues/73
var tmp = hostname.replace('_', '-');
if (!tld.isValid(tmp)) return new AppsError(AppsError.BAD_FIELD, 'Hostname is not a valid domain name');
if (hostname.length > 253) return new AppsError(AppsError.BAD_FIELD, 'Hostname length exceeds 253 characters');
if (location) {
// label validation
if (location.split('.').some(function (p) { return p.length > 63 || p.length < 1; })) return new AppsError(AppsError.BAD_FIELD, 'Invalid subdomain length');
if (location.match(/^[A-Za-z0-9-.]+$/) === null) return new AppsError(AppsError.BAD_FIELD, 'Subdomain can only contain alphanumeric, hyphen and dot');
if (/^[-.]/.test(location)) return new AppsError(AppsError.BAD_FIELD, 'Subdomain cannot start or end with hyphen or dot');
}
return null;
}
// validate the port bindings
function validatePortBindings(portBindings, tcpPorts) {
function validatePortBindings(portBindings, manifest) {
assert.strictEqual(typeof portBindings, 'object');
assert.strictEqual(typeof manifest, 'object');
// keep the public ports in sync with firewall rules in setup/start/cloudron-firewall.sh
// these ports are reserved even if we listen only on 127.0.0.1 because we setup HostIp to be 127.0.0.1
@@ -185,31 +153,64 @@ function validatePortBindings(portBindings, tcpPorts) {
config.get('ldapPort'), /* ldap server (lo) */
3306, /* mysql (lo) */
4190, /* managesieve */
8000, /* graphite (lo) */
8000, /* ESXi monitoring */
8417, /* graphite (lo) */
];
if (!portBindings) return null;
var env;
for (env in portBindings) {
if (!/^[a-zA-Z0-9_]+$/.test(env)) return new AppsError(AppsError.BAD_FIELD, env + ' is not valid environment variable');
for (let portName in portBindings) {
if (!/^[a-zA-Z0-9_]+$/.test(portName)) return new AppsError(AppsError.BAD_FIELD, `${portName} is not a valid environment variable`);
if (!Number.isInteger(portBindings[env])) return new AppsError(AppsError.BAD_FIELD, portBindings[env] + ' is not an integer');
if (RESERVED_PORTS.indexOf(portBindings[env]) !== -1) return new AppsError(AppsError.PORT_RESERVED, String(portBindings[env]));
if (portBindings[env] <= 1023 || portBindings[env] > 65535) return new AppsError(AppsError.BAD_FIELD, portBindings[env] + ' is not in permitted range');
const hostPort = portBindings[portName];
if (!Number.isInteger(hostPort)) return new AppsError(AppsError.BAD_FIELD, `${hostPort} is not an integer`);
if (RESERVED_PORTS.indexOf(hostPort) !== -1) return new AppsError(AppsError.PORT_RESERVED, String(hostPort));
if (hostPort <= 1023 || hostPort > 65535) return new AppsError(AppsError.BAD_FIELD, `${hostPort} is not in permitted range`);
}
// it is OK if there is no 1-1 mapping between values in manifest.tcpPorts and portBindings. missing values implies
// that the user wants the service disabled
tcpPorts = tcpPorts || { };
for (env in portBindings) {
if (!(env in tcpPorts)) return new AppsError(AppsError.BAD_FIELD, 'Invalid portBindings ' + env);
const tcpPorts = manifest.tcpPorts || { };
const udpPorts = manifest.udpPorts || { };
for (let portName in portBindings) {
if (!(portName in tcpPorts) && !(portName in udpPorts)) return new AppsError(AppsError.BAD_FIELD, `Invalid portBindings ${portName}`);
}
return null;
}
function translatePortBindings(portBindings, manifest) {
assert.strictEqual(typeof portBindings, 'object');
assert.strictEqual(typeof manifest, 'object');
if (!portBindings) return null;
let result = {};
const tcpPorts = manifest.tcpPorts || { };
for (let portName in portBindings) {
const portType = portName in tcpPorts ? exports.PORT_TYPE_TCP : exports.PORT_TYPE_UDP;
result[portName] = { hostPort: portBindings[portName], type: portType };
}
return result;
}
function postProcess(app) {
let result = {};
for (let portName in app.portBindings) {
result[portName] = app.portBindings[portName].hostPort;
}
app.portBindings = result;
}
function addSpacesSuffix(location, user) {
if (user.admin || !config.isSpacesEnabled()) return location;
const spacesSuffix = user.username.replace(/\./g, '-');
return location === '' ? spacesSuffix : `${location}-${spacesSuffix}`;
}
function validateAccessRestriction(accessRestriction) {
assert.strictEqual(typeof accessRestriction, 'object');
@@ -290,6 +291,16 @@ function validateBackupFormat(format) {
return new AppsError(AppsError.BAD_FIELD, 'Invalid backup format');
}
function validateEnv(env) {
for (let key in env) {
if (key.length > 512) return new AppsError(AppsError.BAD_FIELD, 'Max env var key length is 512');
// http://pubs.opengroup.org/onlinepubs/000095399/basedefs/xbd_chap08.html
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(key)) return new AppsError(AppsError.BAD_FIELD, `"${key}" is not a valid environment variable`);
}
return null;
}
function getDuplicateErrorDetails(location, portBindings, error) {
assert.strictEqual(typeof location, 'string');
assert.strictEqual(typeof portBindings, 'object');
@@ -305,8 +316,8 @@ function getDuplicateErrorDetails(location, portBindings, error) {
if (match[1] === location) return new AppsError(AppsError.ALREADY_EXISTS);
// check if any of the port bindings conflict
for (var env in portBindings) {
if (portBindings[env] === parseInt(match[1])) return new AppsError(AppsError.PORT_CONFLICT, match[1]);
for (let portName in portBindings) {
if (portBindings[portName] === parseInt(match[1])) return new AppsError(AppsError.PORT_CONFLICT, match[1]);
}
return new AppsError(AppsError.ALREADY_EXISTS);
@@ -324,7 +335,8 @@ function getAppConfig(app) {
xFrameOptions: app.xFrameOptions || 'SAMEORIGIN',
robotsTxt: app.robotsTxt,
sso: app.sso,
alternateDomains: app.alternateDomains || []
alternateDomains: app.alternateDomains || [],
env: app.env
};
}
@@ -334,7 +346,7 @@ function removeInternalFields(app) {
'location', 'domain', 'fqdn', 'mailboxName',
'accessRestriction', 'manifest', 'portBindings', 'iconUrl', 'memoryLimit', 'xFrameOptions',
'sso', 'debugMode', 'robotsTxt', 'enableBackup', 'creationTime', 'updateTime', 'ts',
'alternateDomains', 'ownerId');
'alternateDomains', 'ownerId', 'env', 'enableAutomaticUpdate');
}
function removeRestrictedFields(app) {
@@ -371,23 +383,23 @@ function get(appId, callback) {
assert.strictEqual(typeof appId, 'string');
assert.strictEqual(typeof callback, 'function');
appdb.get(appId, function (error, app) {
if (error && error.reason === DatabaseError.NOT_FOUND) return callback(new AppsError(AppsError.NOT_FOUND, 'No such app'));
domaindb.getAll(function (error, domainObjects) {
if (error) return callback(new AppsError(AppsError.INTERNAL_ERROR, error));
domaindb.get(app.domain, function (error, result) {
appdb.get(appId, function (error, app) {
if (error && error.reason === DatabaseError.NOT_FOUND) return callback(new AppsError(AppsError.NOT_FOUND, 'No such app'));
if (error) return callback(new AppsError(AppsError.INTERNAL_ERROR, error));
postProcess(app);
let domainObjectMap = {};
for (let d of domainObjects) { domainObjectMap[d.domain] = d; }
app.iconUrl = getIconUrlSync(app);
app.fqdn = domains.fqdn(app.location, app.domain, result.provider);
app.fqdn = domains.fqdn(app.location, domainObjectMap[app.domain]);
app.alternateDomains.forEach(function (ad) { ad.fqdn = domains.fqdn(ad.subdomain, domainObjectMap[ad.domain]); });
mailboxdb.getByOwnerId(app.id, function (error, mailboxes) {
if (error && error.reason !== DatabaseError.NOT_FOUND) return callback(new AppsError(AppsError.INTERNAL_ERROR, error));
if (!error) app.mailboxName = mailboxes[0].name;
callback(null, app);
});
callback(null, app);
});
});
}
@@ -396,23 +408,30 @@ function getByIpAddress(ip, callback) {
assert.strictEqual(typeof ip, 'string');
assert.strictEqual(typeof callback, 'function');
docker.getContainerIdByIp(ip, function (error, containerId) {
domaindb.getAll(function (error, domainObjects) {
if (error) return callback(new AppsError(AppsError.INTERNAL_ERROR, error));
appdb.getByContainerId(containerId, function (error, app) {
if (error && error.reason === DatabaseError.NOT_FOUND) return callback(new AppsError(AppsError.NOT_FOUND, 'No such app'));
let domainObjectMap = {};
for (let d of domainObjects) { domainObjectMap[d.domain] = d; }
docker.getContainerIdByIp(ip, function (error, containerId) {
if (error) return callback(new AppsError(AppsError.INTERNAL_ERROR, error));
domaindb.get(app.domain, function (error, result) {
appdb.getByContainerId(containerId, function (error, app) {
if (error && error.reason === DatabaseError.NOT_FOUND) return callback(new AppsError(AppsError.NOT_FOUND, 'No such app'));
if (error) return callback(new AppsError(AppsError.INTERNAL_ERROR, error));
app.iconUrl = getIconUrlSync(app);
app.fqdn = domains.fqdn(app.location, app.domain, result.provider);
postProcess(app);
mailboxdb.getByOwnerId(app.id, function (error, mailboxes) {
if (error && error.reason !== DatabaseError.NOT_FOUND) return callback(new AppsError(AppsError.INTERNAL_ERROR, error));
domaindb.getAll(function (error, domainObjects) {
if (error) return callback(new AppsError(AppsError.INTERNAL_ERROR, error));
if (!error) app.mailboxName = mailboxes[0].name;
let domainObjectMap = {};
for (let d of domainObjects) { domainObjectMap[d.domain] = d; }
app.iconUrl = getIconUrlSync(app);
app.fqdn = domains.fqdn(app.location, domainObjectMap[app.domain]);
app.alternateDomains.forEach(function (ad) { ad.fqdn = domains.fqdn(ad.subdomain, domainObjectMap[ad.domain]); });
callback(null, app);
});
@@ -424,28 +443,28 @@ function getByIpAddress(ip, callback) {
function getAll(callback) {
assert.strictEqual(typeof callback, 'function');
appdb.getAll(function (error, apps) {
domaindb.getAll(function (error, domainObjects) {
if (error) return callback(new AppsError(AppsError.INTERNAL_ERROR, error));
async.eachSeries(apps, function (app, iteratorDone) {
domaindb.get(app.domain, function (error, result) {
if (error) return iteratorDone(new AppsError(AppsError.INTERNAL_ERROR, error));
let domainObjectMap = {};
for (let d of domainObjects) { domainObjectMap[d.domain] = d; }
appdb.getAll(function (error, apps) {
if (error) return callback(new AppsError(AppsError.INTERNAL_ERROR, error));
apps.forEach(postProcess);
async.eachSeries(apps, function (app, iteratorDone) {
app.iconUrl = getIconUrlSync(app);
app.fqdn = domains.fqdn(app.location, app.domain, result.provider);
app.fqdn = domains.fqdn(app.location, domainObjectMap[app.domain]);
app.alternateDomains.forEach(function (ad) { ad.fqdn = domains.fqdn(ad.subdomain, domainObjectMap[ad.domain]); });
mailboxdb.getByOwnerId(app.id, function (error, mailboxes) {
if (error && error.reason !== DatabaseError.NOT_FOUND) return callback(new AppsError(AppsError.INTERNAL_ERROR, error));
iteratorDone(null, app);
}, function (error) {
if (error) return callback(error);
if (!error) app.mailboxName = mailboxes[0].name;
iteratorDone(null, app);
});
callback(null, apps);
});
}, function (error) {
if (error) return callback(error);
callback(null, apps);
});
});
}
@@ -487,8 +506,9 @@ function mailboxNameForLocation(location, manifest) {
return (location ? location : manifest.title.toLowerCase().replace(/[^a-zA-Z0-9]/g, '')) + '.app';
}
function install(data, auditSource, callback) {
function install(data, user, auditSource, callback) {
assert(data && typeof data === 'object');
assert(user && typeof user === 'object');
assert.strictEqual(typeof auditSource, 'object');
assert.strictEqual(typeof callback, 'function');
@@ -505,9 +525,13 @@ function install(data, auditSource, callback) {
debugMode = data.debugMode || null,
robotsTxt = data.robotsTxt || null,
enableBackup = 'enableBackup' in data ? data.enableBackup : true,
enableAutomaticUpdate = 'enableAutomaticUpdate' in data ? data.enableAutomaticUpdate : true,
backupId = data.backupId || null,
backupFormat = data.backupFormat || 'tgz',
ownerId = data.ownerId;
ownerId = data.ownerId,
alternateDomains = data.alternateDomains || [],
env = data.env || {},
mailboxName = data.mailboxName || '';
assert(data.appStoreId || data.manifest); // atleast one of them is required
@@ -520,7 +544,7 @@ function install(data, auditSource, callback) {
error = checkManifestConstraints(manifest);
if (error) return callback(error);
error = validatePortBindings(portBindings, manifest.tcpPorts);
error = validatePortBindings(portBindings, manifest);
if (error) return callback(error);
error = validateAccessRestriction(accessRestriction);
@@ -545,6 +569,16 @@ function install(data, auditSource, callback) {
// if sso was unspecified, enable it by default if possible
if (sso === null) sso = !!manifest.addons['ldap'] || !!manifest.addons['oauth'];
error = validateEnv(env);
if (error) return callback(error);
if (mailboxName) {
error = mail.validateName(mailboxName);
if (error) return callback(error);
} else {
mailboxName = mailboxNameForLocation(location, manifest);
}
var appId = uuid.v4();
if (icon) {
@@ -559,13 +593,14 @@ function install(data, auditSource, callback) {
if (error && error.reason === DomainsError.NOT_FOUND) return callback(new AppsError(AppsError.NOT_FOUND, 'No such domain'));
if (error) return callback(new AppsError(AppsError.INTERNAL_ERROR, 'Could not get domain info:' + error.message));
var fqdn = domains.fqdn(location, domain, domainObject.provider);
location = addSpacesSuffix(location, user);
alternateDomains.forEach(function (ad) { ad.subdomain = addSpacesSuffix(ad.subdomain, user); }); // TODO: validate these
error = validateHostname(location, domain, fqdn);
if (error) return callback(error);
error = domains.validateHostname(location, domainObject);
if (error) return callback(new AppsError(AppsError.BAD_FIELD, 'Bad location: ' + error.message));
if (cert && key) {
error = reverseProxy.validateCertificate(fqdn, cert, key);
error = reverseProxy.validateCertificate(location, domainObject, { cert, key });
if (error) return callback(new AppsError(AppsError.BAD_CERTIFICATE, error.message));
}
@@ -577,22 +612,25 @@ function install(data, auditSource, callback) {
xFrameOptions: xFrameOptions,
sso: sso,
debugMode: debugMode,
mailboxName: mailboxNameForLocation(location, manifest),
mailboxName: mailboxName,
restoreConfig: backupId ? { backupId: backupId, backupFormat: backupFormat } : null,
enableBackup: enableBackup,
robotsTxt: robotsTxt
enableAutomaticUpdate: enableAutomaticUpdate,
robotsTxt: robotsTxt,
alternateDomains: alternateDomains,
env: env
};
appdb.add(appId, appStoreId, manifest, location, domain, ownerId, portBindings, data, function (error) {
appdb.add(appId, appStoreId, manifest, location, domain, ownerId, translatePortBindings(portBindings, manifest), data, function (error) {
if (error && error.reason === DatabaseError.ALREADY_EXISTS) return callback(getDuplicateErrorDetails(location, portBindings, error));
if (error && error.reason === DatabaseError.NOT_FOUND) return callback(new AppsError(AppsError.NOT_FOUND, error.message));
if (error) return callback(new AppsError(AppsError.INTERNAL_ERROR, error));
appstore.purchase(appId, appStoreId, function (appstoreError) {
appstore.purchase(appId, { appstoreId: appStoreId, manifestId: manifest.id }, function (appstoreError) {
// if purchase failed, rollback the appdb record
if (appstoreError) {
appdb.del(appId, function (error) {
if (error) console.error('Failed to rollback app installation.', error);
if (error) debug('install: Failed to rollback app installation.', error);
if (appstoreError.reason === AppstoreError.NOT_FOUND) return callback(new AppsError(AppsError.NOT_FOUND, appstoreError.message));
if (appstoreError && appstoreError.reason === AppstoreError.BILLING_REQUIRED) return callback(new AppsError(AppsError.BILLING_REQUIRED, appstoreError.message));
@@ -606,8 +644,8 @@ function install(data, auditSource, callback) {
// save cert to boxdata/certs
if (cert && key) {
if (!safe.fs.writeFileSync(path.join(paths.APP_CERTS_DIR, fqdn + '.user.cert'), cert)) return callback(new AppsError(AppsError.INTERNAL_ERROR, 'Error saving cert: ' + safe.error.message));
if (!safe.fs.writeFileSync(path.join(paths.APP_CERTS_DIR, fqdn + '.user.key'), key)) return callback(new AppsError(AppsError.INTERNAL_ERROR, 'Error saving key: ' + safe.error.message));
let error = reverseProxy.setAppCertificateSync(location, domainObject, { cert, key });
if (error) return callback(new AppsError(AppsError.INTERNAL_ERROR, 'Error setting cert: ' + error.message));
}
taskmanager.restartAppTask(appId);
@@ -626,21 +664,24 @@ function install(data, auditSource, callback) {
});
}
function configure(appId, data, auditSource, callback) {
function configure(appId, data, user, auditSource, callback) {
assert.strictEqual(typeof appId, 'string');
assert(data && typeof data === 'object');
assert(user && typeof user === 'object');
assert.strictEqual(typeof auditSource, 'object');
assert.strictEqual(typeof callback, 'function');
get(appId, function (error, app) {
if (error) return callback(error);
var domain, location, portBindings, values = { };
if ('location' in data) location = values.location = data.location.toLowerCase();
else location = app.location;
if ('domain' in data) domain = values.domain = data.domain.toLowerCase();
else domain = app.domain;
let domain, location, portBindings, values = { };
if ('location' in data && 'domain' in data) {
location = values.location = data.location.toLowerCase();
domain = values.domain = data.domain.toLowerCase();
} else {
location = app.location;
domain = app.domain;
}
if ('accessRestriction' in data) {
values.accessRestriction = data.accessRestriction;
@@ -649,9 +690,10 @@ function configure(appId, data, auditSource, callback) {
}
if ('portBindings' in data) {
portBindings = values.portBindings = data.portBindings;
error = validatePortBindings(values.portBindings, app.manifest.tcpPorts);
error = validatePortBindings(data.portBindings, app.manifest);
if (error) return callback(error);
values.portBindings = translatePortBindings(data.portBindings, app.manifest);
portBindings = data.portBindings;
} else {
portBindings = app.portBindings;
}
@@ -681,69 +723,70 @@ function configure(appId, data, auditSource, callback) {
}
if ('mailboxName' in data) {
error = mail.validateName(data.mailboxName);
if (error) return callback(error);
if (data.mailboxName) {
error = mail.validateName(data.mailboxName);
if (error) return callback(error);
values.mailboxName = data.mailboxName;
} else {
values.mailboxName = mailboxNameForLocation(location, app.manifest);
}
} else { // keep existing name or follow the new location
values.mailboxName = app.mailboxName.endsWith('.app') ? mailboxNameForLocation(location, app.manifest) : app.mailboxName;
}
if ('alternateDomains' in data) {
// TODO validate all subdomains [{ domain: '', subdomain: ''}]
values.alternateDomains = data.alternateDomains;
values.alternateDomains.forEach(function (ad) { ad.subdomain = addSpacesSuffix(ad.subdomain, user); }); // TODO: validate these
}
if ('env' in data) {
values.env = data.env;
error = validateEnv(data.env);
if (error) return callback(error);
}
domains.get(domain, function (error, domainObject) {
if (error && error.reason === DomainsError.NOT_FOUND) return callback(new AppsError(AppsError.NOT_FOUND, 'No such domain'));
if (error) return callback(new AppsError(AppsError.INTERNAL_ERROR, 'Could not get domain info:' + error.message));
var fqdn = domains.fqdn(location, domain, domainObject.provider);
location = addSpacesSuffix(location, user);
error = validateHostname(location, domain, fqdn);
if (error) return callback(error);
error = domains.validateHostname(location, domainObject);
if (error) return callback(new AppsError(AppsError.BAD_FIELD, 'Bad location: ' + error.message));
// save cert to boxdata/certs. TODO: move this to apptask when we have a real task queue
if ('cert' in data && 'key' in data) {
if (data.cert && data.key) {
error = reverseProxy.validateCertificate(fqdn, data.cert, data.key);
error = reverseProxy.validateCertificate(location, domainObject, { cert: data.cert, key: data.key });
if (error) return callback(new AppsError(AppsError.BAD_CERTIFICATE, error.message));
if (!safe.fs.writeFileSync(path.join(paths.APP_CERTS_DIR, `${fqdn}.user.cert`), data.cert)) return callback(new AppsError(AppsError.INTERNAL_ERROR, 'Error saving cert: ' + safe.error.message));
if (!safe.fs.writeFileSync(path.join(paths.APP_CERTS_DIR, `${fqdn}.user.key`), data.key)) return callback(new AppsError(AppsError.INTERNAL_ERROR, 'Error saving key: ' + safe.error.message));
} else { // remove existing cert/key
if (!safe.fs.unlinkSync(path.join(paths.APP_CERTS_DIR, `${fqdn}.user.cert`))) debug('Error removing cert: ' + safe.error.message);
if (!safe.fs.unlinkSync(path.join(paths.APP_CERTS_DIR, `${fqdn}.user.key`))) debug('Error removing key: ' + safe.error.message);
}
error = reverseProxy.setAppCertificateSync(location, domainObject, { cert: data.cert, key: data.key });
if (error) return callback(new AppsError(AppsError.INTERNAL_ERROR, 'Error setting cert: ' + error.message));
}
if ('enableBackup' in data) values.enableBackup = data.enableBackup;
if ('enableAutomaticUpdate' in data) values.enableAutomaticUpdate = data.enableAutomaticUpdate;
values.oldConfig = getAppConfig(app);
debug('Will configure app with id:%s values:%j', appId, values);
// make the mailbox name follow the apps new location, if the user did not set it explicitly
var oldName = app.mailboxName;
var newName = data.mailboxName || (app.mailboxName.endsWith('.app') ? mailboxNameForLocation(location, app.manifest) : app.mailboxName);
mailboxdb.updateName(oldName, values.oldConfig.domain, newName, domain, function (error) {
if (newName.endsWith('.app')) error = null; // ignore internal mailbox conflict errors since we want to show location conflict errors in the UI
if (error && error.reason === DatabaseError.ALREADY_EXISTS) return callback(new AppsError(AppsError.ALREADY_EXISTS, 'This mailbox is already taken'));
appdb.setInstallationCommand(appId, appdb.ISTATE_PENDING_CONFIGURE, values, function (error) {
if (error && error.reason === DatabaseError.ALREADY_EXISTS) return callback(getDuplicateErrorDetails(location, portBindings, error));
if (error && error.reason === DatabaseError.NOT_FOUND) return callback(new AppsError(AppsError.BAD_STATE));
if (error) return callback(new AppsError(AppsError.INTERNAL_ERROR, error));
appdb.setInstallationCommand(appId, appdb.ISTATE_PENDING_CONFIGURE, values, function (error) {
if (error && error.reason === DatabaseError.ALREADY_EXISTS) return callback(getDuplicateErrorDetails(location, portBindings, error));
if (error && error.reason === DatabaseError.NOT_FOUND) return callback(new AppsError(AppsError.BAD_STATE));
taskmanager.restartAppTask(appId);
// fetch fresh app object for eventlog
get(appId, function (error, result) {
if (error) return callback(new AppsError(AppsError.INTERNAL_ERROR, error));
taskmanager.restartAppTask(appId);
eventlog.add(eventlog.ACTION_APP_CONFIGURE, auditSource, { appId: appId, app: result });
// fetch fresh app object for eventlog
get(appId, function (error, result) {
if (error) return callback(new AppsError(AppsError.INTERNAL_ERROR, error));
eventlog.add(eventlog.ACTION_APP_CONFIGURE, auditSource, { appId: appId, app: result });
callback(null);
});
callback(null);
});
});
});
@@ -827,7 +870,7 @@ function getLogs(appId, options, callback) {
debug('Getting logs for %s', appId);
get(appId, function (error /*, app */) {
get(appId, function (error, app) {
if (error) return callback(error);
var lines = options.lines || 100,
@@ -841,6 +884,7 @@ function getLogs(appId, options, callback) {
if (follow) args.push('--follow', '--retry', '--quiet'); // same as -F. to make it work if file doesn't exist, --quiet to not output file headers, which are no logs
args.push(path.join(paths.LOG_DIR, appId, 'apptask.log'));
args.push(path.join(paths.LOG_DIR, appId, 'app.log'));
if (app.manifest.addons && app.manifest.addons.redis) args.push(path.join(paths.LOG_DIR, `redis-${appId}/app.log`));
var cp = spawn('/usr/bin/tail', args);
@@ -916,9 +960,10 @@ function restore(appId, data, auditSource, callback) {
});
}
function clone(appId, data, auditSource, callback) {
function clone(appId, data, user, auditSource, callback) {
assert.strictEqual(typeof appId, 'string');
assert.strictEqual(typeof data, 'object');
assert(user && typeof user === 'object');
assert.strictEqual(typeof auditSource, 'object');
assert.strictEqual(typeof callback, 'function');
@@ -928,7 +973,8 @@ function clone(appId, data, auditSource, callback) {
domain = data.domain.toLowerCase(),
portBindings = data.portBindings || null,
backupId = data.backupId,
ownerId = data.ownerId;
ownerId = data.ownerId,
mailboxName = data.mailboxName || '';
assert.strictEqual(typeof backupId, 'string');
assert.strictEqual(typeof location, 'string');
@@ -946,21 +992,31 @@ function clone(appId, data, auditSource, callback) {
if (!backupInfo.manifest) callback(new AppsError(AppsError.EXTERNAL_ERROR, 'Could not get restore config'));
const manifest = backupInfo.manifest;
// re-validate because this new box version may not accept old configs
error = checkManifestConstraints(backupInfo.manifest);
error = checkManifestConstraints(manifest);
if (error) return callback(error);
error = validatePortBindings(portBindings, backupInfo.manifest.tcpPorts);
error = validatePortBindings(portBindings, manifest);
if (error) return callback(error);
if (mailboxName) {
error = mail.validateName(mailboxName);
if (error) return callback(error);
} else {
mailboxName = mailboxNameForLocation(location, manifest);
}
domains.get(domain, function (error, domainObject) {
if (error && error.reason === DomainsError.NOT_FOUND) return callback(new AppsError(AppsError.EXTERNAL_ERROR, 'No such domain'));
if (error) return callback(new AppsError(AppsError.INTERNAL_ERROR, 'Could not get domain info:' + error.message));
error = validateHostname(location, domain, domains.fqdn(location, domain, domainObject.provider));
if (error) return callback(error);
location = addSpacesSuffix(location, user);
error = domains.validateHostname(location, domainObject);
if (error) return callback(new AppsError(AppsError.BAD_FIELD, 'Bad location: ' + error.message));
var newAppId = uuid.v4(), manifest = backupInfo.manifest;
var newAppId = uuid.v4();
var data = {
installationState: appdb.ISTATE_PENDING_CLONE,
@@ -969,20 +1025,21 @@ function clone(appId, data, auditSource, callback) {
xFrameOptions: app.xFrameOptions,
restoreConfig: { backupId: backupId, backupFormat: backupInfo.format },
sso: !!app.sso,
mailboxName: (location ? location : manifest.title.toLowerCase().replace(/[^a-zA-Z0-9]/g, '')) + '.app',
mailboxName: mailboxName,
enableBackup: app.enableBackup,
robotsTxt: app.robotsTxt
robotsTxt: app.robotsTxt,
env: app.env
};
appdb.add(newAppId, app.appStoreId, manifest, location, domain, ownerId, portBindings, data, function (error) {
appdb.add(newAppId, app.appStoreId, manifest, location, domain, ownerId, translatePortBindings(portBindings, manifest), data, function (error) {
if (error && error.reason === DatabaseError.ALREADY_EXISTS) return callback(getDuplicateErrorDetails(location, portBindings, error));
if (error) return callback(new AppsError(AppsError.INTERNAL_ERROR, error));
appstore.purchase(newAppId, app.appStoreId, function (appstoreError) {
appstore.purchase(newAppId, { appstoreId: app.appStoreId, manifestId: manifest.id }, function (appstoreError) {
// if purchase failed, rollback the appdb record
if (appstoreError) {
appdb.del(newAppId, function (error) {
if (error) console.error('Failed to rollback app installation.', error);
if (error) debug('install: Failed to rollback app installation.', error);
if (appstoreError.reason === AppstoreError.NOT_FOUND) return callback(new AppsError(AppsError.NOT_FOUND, appstoreError.message));
if (appstoreError && appstoreError.reason === AppstoreError.BILLING_REQUIRED) return callback(new AppsError(AppsError.BILLING_REQUIRED, appstoreError.message));
@@ -1021,7 +1078,7 @@ function uninstall(appId, auditSource, callback) {
get(appId, function (error, app) {
if (error) return callback(error);
appstore.unpurchase(appId, app.appStoreId, function (error) {
appstore.unpurchase(appId, { appstoreId: app.appStoreId, manifestId: app.manifest.id }, function (error) {
if (error && error.reason === AppstoreError.NOT_FOUND) return callback(new AppsError(AppsError.NOT_FOUND));
if (error && error.reason === AppstoreError.BILLING_REQUIRED) return callback(new AppsError(AppsError.BILLING_REQUIRED, error.message));
if (error && error.reason === AppstoreError.EXTERNAL_ERROR) return callback(new AppsError(AppsError.EXTERNAL_ERROR, error.message));
@@ -1119,6 +1176,8 @@ function exec(appId, options, callback) {
};
container.exec(execOptions, function (error, exec) {
if (error && error.statusCode === 409) return callback(new AppsError(AppsError.BAD_STATE, error.message)); // container restarting/not running
if (error) return callback(new AppsError(AppsError.INTERNAL_ERROR, error));
var startOptions = {
Detach: false,
@@ -1157,13 +1216,15 @@ function autoupdateApps(updateInfo, auditSource, callback) { // updateInfo is {
assert.strictEqual(typeof callback, 'function');
function canAutoupdateApp(app, newManifest) {
if (!app.enableAutomaticUpdate) return new Error('Automatic update disabled');
if ((semver.major(app.manifest.version) !== 0) && (semver.major(app.manifest.version) !== semver.major(newManifest.version))) return new Error('Major version change'); // major changes are blocking
var newTcpPorts = newManifest.tcpPorts || { };
var portBindings = app.portBindings; // this is never null
const newTcpPorts = newManifest.tcpPorts || { };
const newUdpPorts = newManifest.udpPorts || { };
const portBindings = app.portBindings; // this is never null
for (var env in portBindings) {
if (!(env in newTcpPorts)) return new Error(env + ' was in use but new update removes it');
for (let portName in portBindings) {
if (!(portName in newTcpPorts) && !(portName in newUdpPorts)) return new Error(`${portName} was in use but new update removes it`);
}
// it's fine if one or more (unused) keys got removed
+18 -42
View File
@@ -19,8 +19,7 @@ exports = module.exports = {
AppstoreError: AppstoreError
};
var appdb = require('./appdb.js'),
apps = require('./apps.js'),
var apps = require('./apps.js'),
assert = require('assert'),
async = require('async'),
config = require('./config.js'),
@@ -95,59 +94,36 @@ function isFreePlan(subscription) {
return !subscription || subscription.plan.id === 'free';
}
function purchase(appId, appstoreId, callback) {
// See app.js install it will create a db record first but remove it again if appstore purchase fails
function purchase(appId, data, callback) {
assert.strictEqual(typeof appId, 'string');
assert.strictEqual(typeof appstoreId, 'string');
assert.strictEqual(typeof data, 'object');
assert(data.appstoreId || data.manifestId);
assert.strictEqual(typeof callback, 'function');
if (appstoreId === '') return callback(null);
function doThePurchase() {
getAppstoreConfig(function (error, appstoreConfig) {
if (error) return callback(error);
var url = config.apiServerOrigin() + '/api/v1/users/' + appstoreConfig.userId + '/cloudrons/' + appstoreConfig.cloudronId + '/apps/' + appId;
var data = { appstoreId: appstoreId };
superagent.post(url).send(data).query({ accessToken: appstoreConfig.token }).timeout(30 * 1000).end(function (error, result) {
if (error && !error.response) return callback(new AppstoreError(AppstoreError.EXTERNAL_ERROR, error.message));
if (result.statusCode === 404) return callback(new AppstoreError(AppstoreError.NOT_FOUND));
if (result.statusCode === 403 || result.statusCode === 401) return callback(new AppstoreError(AppstoreError.BILLING_REQUIRED));
if (result.statusCode === 402) return callback(new AppstoreError(AppstoreError.BILLING_REQUIRED, result.body.message));
if (result.statusCode !== 201 && result.statusCode !== 200) return callback(new AppstoreError(AppstoreError.EXTERNAL_ERROR, util.format('App purchase failed. %s %j', result.status, result.body)));
callback(null);
});
});
}
getSubscription(function (error, result) {
getAppstoreConfig(function (error, appstoreConfig) {
if (error) return callback(error);
// only check for app install count if on the free plan
if (result.id !== 'free') return doThePurchase();
var url = config.apiServerOrigin() + '/api/v1/users/' + appstoreConfig.userId + '/cloudrons/' + appstoreConfig.cloudronId + '/apps/' + appId;
appdb.getAppStoreIds(function (error, result) {
if (error) return callback(new AppstoreError(AppstoreError.INTERNAL_ERROR, error));
superagent.post(url).send(data).query({ accessToken: appstoreConfig.token }).timeout(30 * 1000).end(function (error, result) {
if (error && !error.response) return callback(new AppstoreError(AppstoreError.EXTERNAL_ERROR, error.message));
if (result.statusCode === 404) return callback(new AppstoreError(AppstoreError.NOT_FOUND));
if (result.statusCode === 403 || result.statusCode === 401) return callback(new AppstoreError(AppstoreError.BILLING_REQUIRED));
if (result.statusCode === 402) return callback(new AppstoreError(AppstoreError.BILLING_REQUIRED, result.body.message));
if (result.statusCode !== 201 && result.statusCode !== 200) return callback(new AppstoreError(AppstoreError.EXTERNAL_ERROR, util.format('App purchase failed. %s %j', result.status, result.body)));
var count = result.filter(function (a) { return !!a.appStoreId; }).length;
// we only allow max of 2 app installations without a subscription
// WARNING install and clone in apps.js will first add the db record and then call purchase() so we test for more than 2 here
if (count > 2) return callback(new AppstoreError(AppstoreError.BILLING_REQUIRED, 'Too many apps installed'));
doThePurchase();
callback(null);
});
});
}
function unpurchase(appId, appstoreId, callback) {
function unpurchase(appId, data, callback) {
assert.strictEqual(typeof appId, 'string');
assert.strictEqual(typeof appstoreId, 'string');
assert.strictEqual(typeof data, 'object');
assert(data.appstoreId || data.manifestId);
assert.strictEqual(typeof callback, 'function');
if (appstoreId === '') return callback(null);
getAppstoreConfig(function (error, appstoreConfig) {
if (error) return callback(error);
@@ -159,7 +135,7 @@ function unpurchase(appId, appstoreId, callback) {
if (result.statusCode === 404) return callback(null); // was never purchased
if (result.statusCode !== 201 && result.statusCode !== 200) return callback(new AppstoreError(AppstoreError.EXTERNAL_ERROR, util.format('App unpurchase failed. %s %j', result.status, result.body)));
superagent.del(url).query({ accessToken: appstoreConfig.token }).timeout(30 * 1000).end(function (error, result) {
superagent.del(url).send(data).query({ accessToken: appstoreConfig.token }).timeout(30 * 1000).end(function (error, result) {
if (error && !error.response) return callback(new AppstoreError(AppstoreError.EXTERNAL_ERROR, error));
if (result.statusCode === 403 || result.statusCode === 401) return callback(new AppstoreError(AppstoreError.BILLING_REQUIRED));
if (result.statusCode !== 204) return callback(new AppstoreError(AppstoreError.EXTERNAL_ERROR, util.format('App unpurchase failed. %s %j', result.status, result.body)));
+98 -42
View File
@@ -10,8 +10,8 @@ exports = module.exports = {
_reserveHttpPort: reserveHttpPort,
_configureReverseProxy: configureReverseProxy,
_unconfigureReverseProxy: unconfigureReverseProxy,
_createVolume: createVolume,
_deleteVolume: deleteVolume,
_createAppDir: createAppDir,
_deleteAppDir: deleteAppDir,
_verifyManifest: verifyManifest,
_registerSubdomain: registerSubdomain,
_unregisterSubdomain: unregisterSubdomain,
@@ -36,6 +36,7 @@ var addons = require('./addons.js'),
ejs = require('ejs'),
fs = require('fs'),
manifestFormat = require('cloudron-manifestformat'),
mkdirp = require('mkdirp'),
net = require('net'),
os = require('os'),
path = require('path'),
@@ -46,16 +47,15 @@ var addons = require('./addons.js'),
shell = require('./shell.js'),
superagent = require('superagent'),
sysinfo = require('./sysinfo.js'),
tld = require('tldjs'),
util = require('util'),
_ = require('underscore');
var COLLECTD_CONFIG_EJS = fs.readFileSync(__dirname + '/collectd.config.ejs', { encoding: 'utf8' }),
CONFIGURE_COLLECTD_CMD = path.join(__dirname, 'scripts/configurecollectd.sh'),
LOGROTATE_CONFIG_EJS = fs.readFileSync(__dirname + '/logrotate.ejs', { encoding: 'utf8' }),
CONFIGURE_LOGROTATE_CMD = path.join(__dirname, 'scripts/configurelogrotate.sh'),
RMAPPDIR_CMD = path.join(__dirname, 'scripts/rmappdir.sh'),
CREATEAPPDIR_CMD = path.join(__dirname, 'scripts/createappdir.sh');
CONFIGURE_LOGROTATE_CMD = path.join(__dirname, 'scripts/configurelogrotate.sh');
var NOOP_CALLBACK = function (error) { if (error) debug(error); };
function initialize(callback) {
assert.strictEqual(typeof callback, 'function');
@@ -135,6 +135,20 @@ function createContainer(app, callback) {
});
}
// Only delete the main container of the app, not destroy any docker addon created ones
function deleteMainContainer(app, callback) {
assert.strictEqual(typeof app, 'object');
assert.strictEqual(typeof callback, 'function');
debugApp(app, 'deleting main app container');
docker.deleteContainer(app.containerId, function (error) {
if (error) return callback(new Error('Error deleting container: ' + error));
updateApp(app, { containerId: null }, callback);
});
}
function deleteContainers(app, callback) {
assert.strictEqual(typeof app, 'object');
assert.strictEqual(typeof callback, 'function');
@@ -148,19 +162,47 @@ function deleteContainers(app, callback) {
});
}
function createVolume(app, callback) {
function createAppDir(app, callback) {
assert.strictEqual(typeof app, 'object');
assert.strictEqual(typeof callback, 'function');
shell.sudo('createVolume', [ CREATEAPPDIR_CMD, app.id ], callback);
mkdirp(path.join(paths.APPS_DATA_DIR, app.id), callback);
}
function deleteVolume(app, options, callback) {
function deleteAppDir(app, options, callback) {
assert.strictEqual(typeof app, 'object');
assert.strictEqual(typeof options, 'object');
assert.strictEqual(typeof callback, 'function');
shell.sudo('deleteVolume', [ RMAPPDIR_CMD, app.id, !!options.removeDirectory ], callback);
const appDataDir = path.join(paths.APPS_DATA_DIR, app.id);
// resolve any symlinked data dir
const stat = safe.fs.lstatSync(appDataDir);
if (!stat) return callback(null);
const resolvedAppDataDir = stat.isSymbolicLink() ? safe.fs.readlinkSync(appDataDir) : appDataDir;
if (safe.fs.existsSync(resolvedAppDataDir)) {
const entries = safe.fs.readdirSync(resolvedAppDataDir);
if (!entries) return callback(`Error listing ${resolvedAppDataDir}: ${safe.error.message}`);
// remove only files. directories inside app dir are currently volumes managed by the addons
entries.forEach(function (entry) {
let stat = safe.fs.statSync(path.join(resolvedAppDataDir, entry));
if (stat && !stat.isDirectory()) safe.fs.unlinkSync(path.join(resolvedAppDataDir, entry));
});
}
// if this fails, it's probably because the localstorage/redis addons have not cleaned up properly
if (options.removeDirectory) {
if (stat.isSymbolicLink()) {
if (!safe.fs.unlinkSync(appDataDir)) return callback(safe.error.code === 'ENOENT' ? null : safe.error);
} else {
if (!safe.fs.rmdirSync(appDataDir)) return callback(safe.error.code === 'ENOENT' ? null : safe.error);
}
}
callback(null);
}
function addCollectdProfile(app, callback) {
@@ -170,7 +212,7 @@ function addCollectdProfile(app, callback) {
var collectdConf = ejs.render(COLLECTD_CONFIG_EJS, { appId: app.id, containerId: app.containerId });
fs.writeFile(path.join(paths.COLLECTD_APPCONFIG_DIR, app.id + '.conf'), collectdConf, function (error) {
if (error) return callback(error);
shell.sudo('addCollectdProfile', [ CONFIGURE_COLLECTD_CMD, 'add', app.id ], callback);
shell.sudo('addCollectdProfile', [ CONFIGURE_COLLECTD_CMD, 'add', app.id ], {}, callback);
});
}
@@ -180,7 +222,7 @@ function removeCollectdProfile(app, callback) {
fs.unlink(path.join(paths.COLLECTD_APPCONFIG_DIR, app.id + '.conf'), function (error) {
if (error && error.code !== 'ENOENT') debugApp(app, 'Error removing collectd profile', error);
shell.sudo('removeCollectdProfile', [ CONFIGURE_COLLECTD_CMD, 'remove', app.id ], callback);
shell.sudo('removeCollectdProfile', [ CONFIGURE_COLLECTD_CMD, 'remove', app.id ], {}, callback);
});
}
@@ -199,7 +241,7 @@ function addLogrotateConfig(app, callback) {
var tmpFilePath = path.join(os.tmpdir(), app.id + '.logrotate');
fs.writeFile(tmpFilePath, logrotateConf, function (error) {
if (error) return callback(error);
shell.sudo('addLogrotateConfig', [ CONFIGURE_LOGROTATE_CMD, 'add', app.id, tmpFilePath ], callback);
shell.sudo('addLogrotateConfig', [ CONFIGURE_LOGROTATE_CMD, 'add', app.id, tmpFilePath ], {}, callback);
});
});
}
@@ -208,7 +250,7 @@ function removeLogrotateConfig(app, callback) {
assert.strictEqual(typeof app, 'object');
assert.strictEqual(typeof callback, 'function');
shell.sudo('removeLogrotateConfig', [ CONFIGURE_LOGROTATE_CMD, 'remove', app.id ], callback);
shell.sudo('removeLogrotateConfig', [ CONFIGURE_LOGROTATE_CMD, 'remove', app.id ], {}, callback);
}
function verifyManifest(manifest, callback) {
@@ -270,7 +312,10 @@ function registerSubdomain(app, overwrite, callback) {
if (values.length !== 0 && !overwrite) return retryCallback(null, new Error('DNS Record already exists'));
domains.upsertDnsRecords(app.location, app.domain, 'A', [ ip ], function (error) {
if (error && (error.reason === DomainsError.STILL_BUSY || error.reason === DomainsError.EXTERNAL_ERROR)) return retryCallback(error); // try again
if (error && (error.reason === DomainsError.STILL_BUSY || error.reason === DomainsError.EXTERNAL_ERROR)) {
debug('Upsert error. Will retry.', error.message);
return retryCallback(error); // try again
}
retryCallback(null, error);
});
@@ -327,8 +372,10 @@ function registerAlternateDomains(app, overwrite, callback) {
if (values.length !== 0 && !overwrite) return retryCallback(null, new Error('DNS Record already exists'));
domains.upsertDnsRecords(domain.subdomain, domain.domain, 'A', [ ip ], function (error) {
if (error && (error.reason === DomainsError.STILL_BUSY || error.reason === DomainsError.EXTERNAL_ERROR)) return retryCallback(error); // try again
if (error && (error.reason === DomainsError.STILL_BUSY || error.reason === DomainsError.EXTERNAL_ERROR)) {
debug('Upsert error. Will retry.', error.message);
return retryCallback(error); // try again
}
retryCallback(null, error);
});
});
@@ -345,9 +392,14 @@ function unregisterAlternateDomains(app, all, callback) {
assert.strictEqual(typeof all, 'boolean');
assert.strictEqual(typeof callback, 'function');
var obsoleteDomains
if (all) obsoleteDomains = app.alternateDomains;
else obsoleteDomains = app.oldConfig.alternateDomains.filter(function (o) { return !app.alternateDomains.some(function (n) { return n.subdomain === o.subdomain && n.domain === o.domain; }); });
let obsoleteDomains = [];
if (all) {
obsoleteDomains = app.alternateDomains;
} else if (app.oldConfig) { // oldConfig can be null during an infra update
obsoleteDomains = app.oldConfig.alternateDomains.filter(function (o) {
return !app.alternateDomains.some(function (n) { return n.subdomain === o.subdomain && n.domain === o.domain; });
});
}
if (obsoleteDomains.length === 0) return callback();
@@ -386,8 +438,10 @@ function cleanupLogs(app, callback) {
assert.strictEqual(typeof app, 'object');
assert.strictEqual(typeof callback, 'function');
// note that redis container logs are cleaned up by the addon
rimraf(path.join(paths.LOG_DIR, app.id), function (error) {
if (error) debugApp(app, 'cannot cleanup logs: %s', error);
callback(null);
});
}
@@ -404,13 +458,12 @@ function waitForDnsPropagation(app, callback) {
sysinfo.getPublicIp(function (error, ip) {
if (error) return callback(error);
domains.waitForDnsRecord(app.fqdn, app.domain, ip, { interval: 5000, times: 240 }, function (error) {
domains.waitForDnsRecord(app.location, app.domain, 'A', ip, { interval: 5000, times: 240 }, function (error) {
if (error) return callback(error);
// now wait for alternateDomains, if any
async.eachSeries(app.alternateDomains, function (domain, callback) {
var fqdn = (domain.subdomain ? (domain.subdomain + '.') : '') + domain.domain;
domains.waitForDnsRecord(fqdn, domain.domain, ip, { interval: 5000, times: 240 }, callback);
async.eachSeries(app.alternateDomains, function (domain, iteratorCallback) {
domains.waitForDnsRecord(domain.subdomain, domain.domain, 'A', ip, { interval: 5000, times: 240 }, iteratorCallback);
}, callback);
});
});
@@ -444,16 +497,14 @@ function install(app, callback) {
removeCollectdProfile.bind(null, app),
removeLogrotateConfig.bind(null, app),
stopApp.bind(null, app),
deleteContainers.bind(null, app),
deleteMainContainer.bind(null, app),
function teardownAddons(next) {
// when restoring, app does not require these addons anymore. remove carefully to preserve the db passwords
var addonsToRemove = !isRestoring
? app.manifest.addons
: _.omit(app.oldConfig.manifest.addons, Object.keys(app.manifest.addons));
var addonsToRemove = !isRestoring ? app.manifest.addons : _.omit(app.oldConfig.manifest.addons, Object.keys(app.manifest.addons));
addons.teardownAddons(app, addonsToRemove, next);
},
deleteVolume.bind(null, app, { removeDirectory: false }), // do not remove any symlinked volume
deleteAppDir.bind(null, app, { removeDirectory: false }), // do not remove any symlinked volume
// for restore case
function deleteImageIfChanged(done) {
@@ -470,11 +521,14 @@ function install(app, callback) {
updateApp.bind(null, app, { installationProgress: '30, Registering subdomain' }),
registerSubdomain.bind(null, app, isRestoring /* overwrite */),
updateApp.bind(null, app, { installationProgress: '35, Registering alternate domains'}),
registerAlternateDomains.bind(null, app, isRestoring /* overwrite */),
updateApp.bind(null, app, { installationProgress: '40, Downloading image' }),
docker.downloadImage.bind(null, app.manifest),
updateApp.bind(null, app, { installationProgress: '50, Creating volume' }),
createVolume.bind(null, app),
createAppDir.bind(null, app),
function restoreFromBackup(next) {
if (!restoreConfig) {
@@ -485,7 +539,9 @@ function install(app, callback) {
} else {
async.series([
updateApp.bind(null, app, { installationProgress: '60, Download backup and restoring addons' }),
backups.restoreApp.bind(null, app, app.manifest.addons, restoreConfig),
addons.setupAddons.bind(null, app, app.manifest.addons),
addons.clearAddons.bind(null, app, app.manifest.addons),
backups.restoreApp.bind(null, app, app.manifest.addons, restoreConfig, (progress) => updateApp(app, { installationProgress: progress.message }, NOOP_CALLBACK))
], next);
}
},
@@ -527,7 +583,7 @@ function backup(app, callback) {
async.series([
updateApp.bind(null, app, { installationProgress: '10, Backing up' }),
backups.backupApp.bind(null, app),
backups.backupApp.bind(null, app, (progress) => updateApp(app, { installationProgress: progress.message }, NOOP_CALLBACK)),
// done!
function (callback) {
@@ -557,7 +613,7 @@ function configure(app, callback) {
removeCollectdProfile.bind(null, app),
removeLogrotateConfig.bind(null, app),
stopApp.bind(null, app),
deleteContainers.bind(null, app),
deleteMainContainer.bind(null, app),
unregisterAlternateDomains.bind(null, app, false /* all */),
function (next) {
if (!locationChanged) return next();
@@ -581,7 +637,7 @@ function configure(app, callback) {
docker.downloadImage.bind(null, app.manifest),
updateApp.bind(null, app, { installationProgress: '45, Ensuring volume' }),
createVolume.bind(null, app),
createAppDir.bind(null, app),
// re-setup addons since they rely on the app's fqdn (e.g oauth)
updateApp.bind(null, app, { installationProgress: '50, Setting up addons' }),
@@ -640,7 +696,7 @@ function update(app, callback) {
async.series([
updateApp.bind(null, app, { installationProgress: '15, Backing up app' }),
backups.backupApp.bind(null, app)
backups.backupApp.bind(null, app, (progress) => updateApp(app, { installationProgress: progress.message }, NOOP_CALLBACK))
], function (error) {
if (error) error.backupError = true;
next(error);
@@ -658,7 +714,7 @@ function update(app, callback) {
removeCollectdProfile.bind(null, app),
removeLogrotateConfig.bind(null, app),
stopApp.bind(null, app),
deleteContainers.bind(null, app),
deleteMainContainer.bind(null, app),
function deleteImageIfChanged(done) {
if (app.manifest.dockerImage === app.updateConfig.manifest.dockerImage) return done();
@@ -670,14 +726,14 @@ function update(app, callback) {
// free unused ports
function (next) {
// make sure we always have objects
var currentPorts = app.portBindings || {};
var newPorts = app.updateConfig.manifest.tcpPorts || {};
const currentPorts = app.portBindings || {};
const newTcpPorts = app.updateConfig.manifest.tcpPorts || {};
const newUdpPorts = app.updateConfig.manifest.udpPorts || {};
async.each(Object.keys(currentPorts), function (portName, callback) {
if (newPorts[portName]) return callback(); // port still in use
if (newTcpPorts[portName] || newUdpPorts[portName]) return callback(); // port still in use
appdb.delPortBinding(currentPorts[portName], function (error) {
appdb.delPortBinding(currentPorts[portName], apps.PORT_TYPE_TCP, function (error) {
if (error && error.reason === DatabaseError.NOT_FOUND) console.error('Portbinding does not exist in database.');
else if (error) return next(error);
@@ -750,7 +806,7 @@ function uninstall(app, callback) {
addons.teardownAddons.bind(null, app, app.manifest.addons),
updateApp.bind(null, app, { installationProgress: '40, Deleting volume' }),
deleteVolume.bind(null, app, { removeDirectory: true }),
deleteAppDir.bind(null, app, { removeDirectory: true }),
updateApp.bind(null, app, { installationProgress: '50, Deleting image' }),
docker.deleteImage.bind(null, app.manifest),
+134 -162
View File
@@ -10,9 +10,9 @@ exports = module.exports = {
get: get,
startBackupTask: startBackupTask,
ensureBackup: ensureBackup,
backup: backup,
restore: restore,
backupApp: backupApp,
@@ -53,7 +53,6 @@ var addons = require('./addons.js'),
once = require('once'),
path = require('path'),
paths = require('./paths.js'),
progress = require('./progress.js'),
progressStream = require('progress-stream'),
safe = require('safetydance'),
shell = require('./shell.js'),
@@ -61,12 +60,12 @@ var addons = require('./addons.js'),
superagent = require('superagent'),
syncer = require('./syncer.js'),
tar = require('tar-fs'),
tasks = require('./tasks.js'),
util = require('util'),
zlib = require('zlib');
var NOOP_CALLBACK = function (error) { if (error) debug(error); };
var BACKUPTASK_CMD = path.join(__dirname, 'backuptask.js');
const NOOP_CALLBACK = function (error) { if (error) debug(error); };
const BACKUP_UPLOAD_CMD = path.join(__dirname, 'scripts/backupupload.js');
function debugApp(app) {
assert(typeof app === 'object');
@@ -124,6 +123,9 @@ function testConfig(backupConfig, callback) {
if (backupConfig.format !== 'tgz' && backupConfig.format !== 'rsync') return callback(new BackupsError(BackupsError.BAD_FIELD, 'unknown format'));
// remember to adjust the cron ensureBackup task interval accordingly
if (backupConfig.intervalSecs < 6 * 60 * 60) return callback(new BackupsError(BackupsError.BAD_FIELD, 'Interval must be atleast 6 hours'));
api(backupConfig.provider).testConfig(backupConfig, callback);
}
@@ -178,11 +180,6 @@ function getBackupFilePath(backupConfig, backupId, format) {
}
}
function log(detail) {
safe.fs.appendFileSync(paths.BACKUP_LOG_FILE, detail + '\n', 'utf8');
progress.setDetail(progress.BACKUP, detail);
}
function encryptFilePath(filePath, key) {
assert.strictEqual(typeof filePath, 'string');
assert.strictEqual(typeof key, 'string');
@@ -235,10 +232,6 @@ function createReadStream(sourceFile, key) {
ps.emit('error', new BackupsError(BackupsError.EXTERNAL_ERROR, error.message));
});
ps.on('progress', function(progress) {
debug('createReadStream: %s@%s (%s)', Math.round(progress.transferred/1024/1024) + 'M', Math.round(progress.speed/1024/1024) + 'Mbps', sourceFile);
});
if (key !== null) {
var encrypt = crypto.createCipher('aes-256-cbc', key);
encrypt.on('error', function (error) {
@@ -284,7 +277,7 @@ function createTarPackStream(sourceDir, key) {
});
var gzip = zlib.createGzip({});
var ps = progressStream({ time: 10000 }); // display a progress every 10 seconds
var ps = progressStream({ time: 10000 }); // emit 'pgoress' every 10 seconds
pack.on('error', function (error) {
debug('createTarPackStream: tar stream error.', error);
@@ -296,10 +289,6 @@ function createTarPackStream(sourceDir, key) {
ps.emit('error', new BackupsError(BackupsError.EXTERNAL_ERROR, error.message));
});
ps.on('progress', function(progress) {
debug('createTarPackStream: %s@%s', Math.round(progress.transferred/1024/1024) + 'M', Math.round(progress.speed/1024/1024) + 'Mbps');
});
if (key !== null) {
var encrypt = crypto.createCipher('aes-256-cbc', key);
encrypt.on('error', function (error) {
@@ -312,17 +301,13 @@ function createTarPackStream(sourceDir, key) {
}
}
function sync(backupConfig, backupId, dataDir, callback) {
function sync(backupConfig, backupId, dataDir, progressCallback, callback) {
assert.strictEqual(typeof backupConfig, 'object');
assert.strictEqual(typeof backupId, 'string');
assert.strictEqual(typeof dataDir, 'string');
assert.strictEqual(typeof progressCallback, 'function');
assert.strictEqual(typeof callback, 'function');
function setBackupProgress(message) {
debug('%s', message);
safe.fs.writeFileSync(paths.BACKUP_RESULT_FILE, message);
}
syncer.sync(dataDir, function processTask(task, iteratorCallback) {
debug('sync: processing task: %j', task);
// the empty task.path is special to signify the directory
@@ -330,12 +315,12 @@ function sync(backupConfig, backupId, dataDir, callback) {
const backupFilePath = path.join(getBackupFilePath(backupConfig, backupId, backupConfig.format), destPath);
if (task.operation === 'removedir') {
setBackupProgress(`Removing directory ${backupFilePath}`);
debug(`Removing directory ${backupFilePath}`);
return api(backupConfig.provider).removeDir(backupConfig, backupFilePath)
.on('progress', setBackupProgress)
.on('progress', (message) => progressCallback({ message }))
.on('done', iteratorCallback);
} else if (task.operation === 'remove') {
setBackupProgress(`Removing ${backupFilePath}`);
debug(`Removing ${backupFilePath}`);
return api(backupConfig.provider).remove(backupConfig, backupFilePath, iteratorCallback);
}
@@ -344,16 +329,19 @@ function sync(backupConfig, backupId, dataDir, callback) {
retryCallback = once(retryCallback); // protect again upload() erroring much later after read stream error
++retryCount;
debug(`${task.operation} ${task.path} try ${retryCount}`);
progressCallback({ message: `${task.operation} ${task.path} try ${retryCount}` });
if (task.operation === 'add') {
setBackupProgress(`Adding ${task.path} position ${task.position} try ${retryCount}`);
debug(`Adding ${task.path} position ${task.position} try ${retryCount}`);
var stream = createReadStream(path.join(dataDir, task.path), backupConfig.key || null);
stream.on('error', function (error) {
setBackupProgress(`read stream error for ${task.path}: ${error.message}`);
debug(`read stream error for ${task.path}: ${error.message}`);
retryCallback();
}); // ignore error if file disappears
stream.on('progress', function(progress) {
progressCallback({ message: `Uploading ${task.path}: ${Math.round(progress.transferred/1024/1024)}M@${Math.round(progress.speed/1024/1024)}` });
});
api(backupConfig.provider).upload(backupConfig, backupFilePath, stream, function (error) {
setBackupProgress(error ? `Error uploading ${task.path} try ${retryCount}: ${error.message}` : `Uploaded ${task.path}`);
debug(error ? `Error uploading ${task.path} try ${retryCount}: ${error.message}` : `Uploaded ${task.path}`);
retryCallback(error);
});
}
@@ -385,14 +373,15 @@ function saveFsMetadata(appDataDir, callback) {
callback();
}
// this function is called via backuptask (since it needs root to traverse app's directory)
function upload(backupId, format, dataDir, callback) {
// this function is called via backupupload (since it needs root to traverse app's directory)
function upload(backupId, format, dataDir, progressCallback, callback) {
assert.strictEqual(typeof backupId, 'string');
assert.strictEqual(typeof format, 'string');
assert.strictEqual(typeof dataDir, 'string');
assert.strictEqual(typeof progressCallback, 'function');
assert.strictEqual(typeof callback, 'function');
debug('upload: id %s format %s dataDir %s', backupId, format, dataDir);
debug(`upload: id ${backupId} format ${format} dataDir ${dataDir}`);
settings.getBackupConfig(function (error, backupConfig) {
if (error) return callback(new BackupsError(BackupsError.INTERNAL_ERROR, error));
@@ -402,6 +391,9 @@ function upload(backupId, format, dataDir, callback) {
retryCallback = once(retryCallback); // protect again upload() erroring much later after tar stream error
var tarStream = createTarPackStream(dataDir, backupConfig.key || null);
tarStream.on('progress', function(progress) {
progressCallback({ message: `Uploading ${Math.round(progress.transferred/1024/1024)}M@${Math.round(progress.speed/1024/1024)}Mbps` });
});
tarStream.on('error', retryCallback); // already returns BackupsError
api(backupConfig.provider).upload(backupConfig, getBackupFilePath(backupConfig, backupId, format), tarStream, retryCallback);
@@ -409,7 +401,7 @@ function upload(backupId, format, dataDir, callback) {
} else {
async.series([
saveFsMetadata.bind(null, dataDir),
sync.bind(null, backupConfig, backupId, dataDir)
sync.bind(null, backupConfig, backupId, dataDir, progressCallback)
], callback);
}
});
@@ -432,10 +424,6 @@ function tarExtract(inStream, destination, key, callback) {
callback(new BackupsError(BackupsError.EXTERNAL_ERROR, error.message));
});
ps.on('progress', function(progress) {
debug('tarExtract: %s@%s', Math.round(progress.transferred/1024/1024) + 'M', Math.round(progress.speed/1024/1024) + 'Mbps');
});
gunzip.on('error', function (error) {
debug('tarExtract: gunzip stream error.', error);
callback(new BackupsError(BackupsError.EXTERNAL_ERROR, error.message));
@@ -461,13 +449,15 @@ function tarExtract(inStream, destination, key, callback) {
} else {
inStream.pipe(ps).pipe(gunzip).pipe(extract);
}
return ps;
}
function restoreFsMetadata(appDataDir, callback) {
assert.strictEqual(typeof appDataDir, 'string');
assert.strictEqual(typeof callback, 'function');
log('Recreating empty directories');
debug(`Recreating empty directories in ${appDataDir}`);
var metadataJson = safe.fs.readFileSync(path.join(appDataDir, 'fsmetadata.json'), 'utf8');
if (metadataJson === null) return callback(new BackupsError(BackupsError.EXTERNAL_ERROR, 'Error loading fsmetadata.txt:' + safe.error.message));
@@ -489,10 +479,11 @@ function restoreFsMetadata(appDataDir, callback) {
});
}
function downloadDir(backupConfig, backupFilePath, destDir, callback) {
function downloadDir(backupConfig, backupFilePath, destDir, progressCallback, callback) {
assert.strictEqual(typeof backupConfig, 'object');
assert.strictEqual(typeof backupFilePath, 'string');
assert.strictEqual(typeof destDir, 'string');
assert.strictEqual(typeof progressCallback, 'function');
assert.strictEqual(typeof callback, 'function');
debug(`downloadDir: ${backupFilePath} to ${destDir}`);
@@ -516,7 +507,7 @@ function downloadDir(backupConfig, backupFilePath, destDir, callback) {
let destStream = createWriteStream(destFilePath, backupConfig.key || null);
destStream.on('error', callback);
debug(`downloadDir: Copying ${entry.fullPath} to ${destFilePath}`);
progressCallback({ message: `Downloading ${entry.fullPath} to ${destFilePath}` });
sourceStream.pipe(destStream, { end: true }).on('finish', callback);
});
@@ -528,25 +519,27 @@ function downloadDir(backupConfig, backupFilePath, destDir, callback) {
}, callback);
}
function download(backupConfig, backupId, format, dataDir, callback) {
function download(backupConfig, backupId, format, dataDir, progressCallback, callback) {
assert.strictEqual(typeof backupConfig, 'object');
assert.strictEqual(typeof backupId, 'string');
assert.strictEqual(typeof format, 'string');
assert.strictEqual(typeof dataDir, 'string');
assert.strictEqual(typeof progressCallback, 'function');
assert.strictEqual(typeof callback, 'function');
safe.fs.unlinkSync(paths.BACKUP_LOG_FILE); // start fresh log file
log(`Downloading ${backupId} of format ${format} to ${dataDir}`);
debug(`download - Downloading ${backupId} of format ${format} to ${dataDir}`);
if (format === 'tgz') {
api(backupConfig.provider).download(backupConfig, getBackupFilePath(backupConfig, backupId, format), function (error, sourceStream) {
if (error) return callback(error);
tarExtract(sourceStream, dataDir, backupConfig.key || null, callback);
let ps = tarExtract(sourceStream, dataDir, backupConfig.key || null, callback);
ps.on('progress', function (progress) {
progressCallback({ message: `Downloading ${Math.round(progress.transferred/1024/1024)}M@${Math.round(progress.speed/1024/1024)}Mbps` });
});
});
} else {
downloadDir(backupConfig, getBackupFilePath(backupConfig, backupId, format), dataDir, function (error) {
downloadDir(backupConfig, getBackupFilePath(backupConfig, backupId, format), dataDir, progressCallback, function (error) {
if (error) return callback(error);
restoreFsMetadata(dataDir, callback);
@@ -554,26 +547,32 @@ function download(backupConfig, backupId, format, dataDir, callback) {
}
}
function restore(backupConfig, backupId, callback) {
function restore(backupConfig, backupId, progressCallback, callback) {
assert.strictEqual(typeof backupConfig, 'object');
assert.strictEqual(typeof backupId, 'string');
assert.strictEqual(typeof progressCallback, 'function');
assert.strictEqual(typeof callback, 'function');
download(backupConfig, backupId, backupConfig.format, paths.BOX_DATA_DIR, function (error) {
download(backupConfig, backupId, backupConfig.format, paths.BOX_DATA_DIR, progressCallback, function (error) {
if (error) return callback(error);
debug('restore: download completed, importing database');
database.importFromFile(`${paths.BOX_DATA_DIR}/box.mysqldump`, function (error) {
if (error) return callback(new BackupsError(BackupsError.INTERNAL_ERROR, error));
debug('restore: database imported');
callback();
});
});
}
function restoreApp(app, addonsToRestore, restoreConfig, callback) {
function restoreApp(app, addonsToRestore, restoreConfig, progressCallback, callback) {
assert.strictEqual(typeof app, 'object');
assert.strictEqual(typeof addonsToRestore, 'object');
assert.strictEqual(typeof restoreConfig, 'object');
assert.strictEqual(typeof progressCallback, 'function');
assert.strictEqual(typeof callback, 'function');
var appDataDir = safe.fs.realpathSync(path.join(paths.APPS_DATA_DIR, app.id));
@@ -584,7 +583,7 @@ function restoreApp(app, addonsToRestore, restoreConfig, callback) {
if (error) return callback(new BackupsError(BackupsError.INTERNAL_ERROR, error));
async.series([
download.bind(null, backupConfig, restoreConfig.backupId, restoreConfig.backupFormat, appDataDir),
download.bind(null, backupConfig, restoreConfig.backupId, restoreConfig.backupFormat, appDataDir, progressCallback),
addons.restoreAddons.bind(null, app, addonsToRestore)
], function (error) {
debug('restoreApp: time: %s', (new Date() - startTime)/1000);
@@ -594,44 +593,27 @@ function restoreApp(app, addonsToRestore, restoreConfig, callback) {
});
}
function runBackupTask(backupId, format, dataDir, callback) {
function runBackupUpload(backupId, format, dataDir, progressCallback, callback) {
assert.strictEqual(typeof backupId, 'string');
assert.strictEqual(typeof format, 'string');
assert.strictEqual(typeof dataDir, 'string');
assert.strictEqual(typeof progressCallback, 'function');
assert.strictEqual(typeof callback, 'function');
var killTimerId = null, progressTimerId = null;
var logStream = fs.createWriteStream(paths.BACKUP_LOG_FILE, { flags: 'a' });
var cp = shell.sudo(`backup-${backupId}`, [ BACKUPTASK_CMD, backupId, format, dataDir ], { env: process.env, logStream: logStream }, function (error) {
clearTimeout(killTimerId);
clearInterval(progressTimerId);
cp = null;
let result = '';
shell.sudo(`backup-${backupId}`, [ BACKUP_UPLOAD_CMD, backupId, format, dataDir ], { preserveEnv: true, ipc: true }, function (error) {
if (error && (error.code === null /* signal */ || (error.code !== 0 && error.code !== 50))) { // backuptask crashed
return callback(new BackupsError(BackupsError.INTERNAL_ERROR, 'Backuptask crashed'));
} else if (error && error.code === 50) { // exited with error
var result = safe.fs.readFileSync(paths.BACKUP_RESULT_FILE, 'utf8') || safe.error.message;
return callback(new BackupsError(BackupsError.EXTERNAL_ERROR, result));
}
callback();
});
progressTimerId = setInterval(function () {
var result = safe.fs.readFileSync(paths.BACKUP_RESULT_FILE, 'utf8');
if (result) progress.setDetail(progress.BACKUP, result);
}, 1000); // every second
killTimerId = setTimeout(function () {
debug('runBackupTask: backup task taking too long. killing');
cp.kill();
}, 4 * 60 * 60 * 1000); // 4 hours
logStream.on('error', function (error) {
debug('runBackupTask: error in logging stream', error);
cp.kill();
}).on('message', function (message) {
if (!message.result) return progressCallback(message);
debug(`runBackupUpload: result - ${message}`);
result = message.result;
});
}
@@ -657,10 +639,11 @@ function setSnapshotInfo(id, info, callback) {
callback();
}
function snapshotBox(callback) {
function snapshotBox(progressCallback, callback) {
assert.strictEqual(typeof progressCallback, 'function');
assert.strictEqual(typeof callback, 'function');
log('Snapshotting box');
progressCallback({ message: 'Snapshotting box' });
database.exportToFile(`${paths.BOX_DATA_DIR}/box.mysqldump`, function (error) {
if (error) return callback(new BackupsError(BackupsError.INTERNAL_ERROR, error));
@@ -669,16 +652,17 @@ function snapshotBox(callback) {
});
}
function uploadBoxSnapshot(backupConfig, callback) {
function uploadBoxSnapshot(backupConfig, progressCallback, callback) {
assert.strictEqual(typeof backupConfig, 'object');
assert.strictEqual(typeof progressCallback, 'function');
assert.strictEqual(typeof callback, 'function');
var startTime = new Date();
snapshotBox(function (error) {
snapshotBox(progressCallback, function (error) {
if (error) return callback(error);
runBackupTask('snapshot/box', backupConfig.format, paths.BOX_DATA_DIR, function (error) {
runBackupUpload('snapshot/box', backupConfig.format, paths.BOX_DATA_DIR, progressCallback, function (error) {
if (error) return callback(error);
debug('uploadBoxSnapshot: time: %s secs', (new Date() - startTime)/1000);
@@ -716,10 +700,11 @@ function backupDone(apiConfig, backupId, appBackupIds, callback) {
});
}
function rotateBoxBackup(backupConfig, timestamp, appBackupIds, callback) {
function rotateBoxBackup(backupConfig, timestamp, appBackupIds, progressCallback, callback) {
assert.strictEqual(typeof backupConfig, 'object');
assert.strictEqual(typeof timestamp, 'string');
assert(Array.isArray(appBackupIds));
assert.strictEqual(typeof progressCallback, 'function');
assert.strictEqual(typeof callback, 'function');
var snapshotInfo = getSnapshotInfo('box');
@@ -729,13 +714,13 @@ function rotateBoxBackup(backupConfig, timestamp, appBackupIds, callback) {
var backupId = util.format('%s/box_%s_v%s', timestamp, snapshotTime, config.version());
const format = backupConfig.format;
log(`Rotating box backup to id ${backupId}`);
debug(`Rotating box backup to id ${backupId}`);
backupdb.add({ id: backupId, version: config.version(), type: backupdb.BACKUP_TYPE_BOX, dependsOn: appBackupIds, manifest: null, format: format }, function (error) {
if (error) return callback(new BackupsError(BackupsError.INTERNAL_ERROR, error));
var copy = api(backupConfig.provider).copy(backupConfig, getBackupFilePath(backupConfig, 'snapshot/box', format), getBackupFilePath(backupConfig, backupId, format));
copy.on('progress', log);
copy.on('progress', (message) => progressCallback({ message }));
copy.on('done', function (copyBackupError) {
const state = copyBackupError ? backupdb.BACKUP_STATE_ERROR : backupdb.BACKUP_STATE_NORMAL;
@@ -743,7 +728,7 @@ function rotateBoxBackup(backupConfig, timestamp, appBackupIds, callback) {
if (copyBackupError) return callback(copyBackupError);
if (error) return callback(new BackupsError(BackupsError.INTERNAL_ERROR, error));
log(`Rotated box backup successfully as id ${backupId}`);
debug(`Rotated box backup successfully as id ${backupId}`);
backupDone(backupConfig, backupId, appBackupIds, function (error) {
if (error) return callback(error);
@@ -755,18 +740,19 @@ function rotateBoxBackup(backupConfig, timestamp, appBackupIds, callback) {
});
}
function backupBoxWithAppBackupIds(appBackupIds, timestamp, callback) {
function backupBoxWithAppBackupIds(appBackupIds, timestamp, progressCallback, callback) {
assert(Array.isArray(appBackupIds));
assert.strictEqual(typeof timestamp, 'string');
assert.strictEqual(typeof progressCallback, 'function');
assert.strictEqual(typeof callback, 'function');
settings.getBackupConfig(function (error, backupConfig) {
if (error) return callback(new BackupsError(BackupsError.INTERNAL_ERROR, error));
uploadBoxSnapshot(backupConfig, function (error) {
uploadBoxSnapshot(backupConfig, progressCallback, function (error) {
if (error) return callback(error);
rotateBoxBackup(backupConfig, timestamp, appBackupIds, callback);
rotateBoxBackup(backupConfig, timestamp, appBackupIds, progressCallback, callback);
});
});
}
@@ -780,11 +766,12 @@ function canBackupApp(app) {
app.installationState === appdb.ISTATE_PENDING_UPDATE; // called from apptask
}
function snapshotApp(app, callback) {
function snapshotApp(app, progressCallback, callback) {
assert.strictEqual(typeof app, 'object');
assert.strictEqual(typeof progressCallback, 'function');
assert.strictEqual(typeof callback, 'function');
log(`Snapshotting app ${app.id}`);
progressCallback({ message: `Snapshotting app ${app.id}` });
if (!safe.fs.writeFileSync(path.join(paths.APPS_DATA_DIR, app.id + '/config.json'), JSON.stringify(apps.getAppConfig(app)))) {
return callback(new BackupsError(BackupsError.EXTERNAL_ERROR, 'Error creating config.json: ' + safe.error.message));
@@ -797,10 +784,11 @@ function snapshotApp(app, callback) {
});
}
function rotateAppBackup(backupConfig, app, timestamp, callback) {
function rotateAppBackup(backupConfig, app, timestamp, progressCallback, callback) {
assert.strictEqual(typeof backupConfig, 'object');
assert.strictEqual(typeof app, 'object');
assert.strictEqual(typeof timestamp, 'string');
assert.strictEqual(typeof progressCallback, 'function');
assert.strictEqual(typeof callback, 'function');
var snapshotInfo = getSnapshotInfo(app.id);
@@ -811,13 +799,13 @@ function rotateAppBackup(backupConfig, app, timestamp, callback) {
var backupId = util.format('%s/app_%s_%s_v%s', timestamp, app.id, snapshotTime, manifest.version);
const format = backupConfig.format;
log(`Rotating app backup of ${app.id} to id ${backupId}`);
debug(`Rotating app backup of ${app.id} to id ${backupId}`);
backupdb.add({ id: backupId, version: manifest.version, type: backupdb.BACKUP_TYPE_APP, dependsOn: [ ], manifest: manifest, format: format }, function (error) {
if (error) return callback(new BackupsError(BackupsError.INTERNAL_ERROR, error));
var copy = api(backupConfig.provider).copy(backupConfig, getBackupFilePath(backupConfig, `snapshot/app_${app.id}`, format), getBackupFilePath(backupConfig, backupId, format));
copy.on('progress', log);
copy.on('progress', (message) => progressCallback({ message }));
copy.on('done', function (copyBackupError) {
const state = copyBackupError ? backupdb.BACKUP_STATE_ERROR : backupdb.BACKUP_STATE_NORMAL;
@@ -825,7 +813,7 @@ function rotateAppBackup(backupConfig, app, timestamp, callback) {
if (copyBackupError) return callback(copyBackupError);
if (error) return callback(new BackupsError(BackupsError.INTERNAL_ERROR, error));
log(`Rotated app backup of ${app.id} successfully to id ${backupId}`);
debug(`Rotated app backup of ${app.id} successfully to id ${backupId}`);
callback(null, backupId);
});
@@ -833,21 +821,22 @@ function rotateAppBackup(backupConfig, app, timestamp, callback) {
});
}
function uploadAppSnapshot(backupConfig, app, callback) {
function uploadAppSnapshot(backupConfig, app, progressCallback, callback) {
assert.strictEqual(typeof backupConfig, 'object');
assert.strictEqual(typeof app, 'object');
assert.strictEqual(typeof progressCallback, 'function');
assert.strictEqual(typeof callback, 'function');
if (!canBackupApp(app)) return callback(); // nothing to do
var startTime = new Date();
snapshotApp(app, function (error) {
snapshotApp(app, progressCallback, function (error) {
if (error) return callback(error);
var backupId = util.format('snapshot/app_%s', app.id);
var appDataDir = safe.fs.realpathSync(path.join(paths.APPS_DATA_DIR, app.id));
runBackupTask(backupId, backupConfig.format, appDataDir, function (error) {
runBackupUpload(backupId, backupConfig.format, appDataDir, progressCallback, function (error) {
if (error) return callback(error);
debugApp(app, 'uploadAppSnapshot: %s done time: %s secs', backupId, (new Date() - startTime)/1000);
@@ -857,9 +846,10 @@ function uploadAppSnapshot(backupConfig, app, callback) {
});
}
function backupAppWithTimestamp(app, timestamp, callback) {
function backupAppWithTimestamp(app, timestamp, progressCallback, callback) {
assert.strictEqual(typeof app, 'object');
assert.strictEqual(typeof timestamp, 'string');
assert.strictEqual(typeof progressCallback, 'function');
assert.strictEqual(typeof callback, 'function');
if (!canBackupApp(app)) return callback(); // nothing to do
@@ -867,110 +857,88 @@ function backupAppWithTimestamp(app, timestamp, callback) {
settings.getBackupConfig(function (error, backupConfig) {
if (error) return callback(new BackupsError(BackupsError.INTERNAL_ERROR, error));
uploadAppSnapshot(backupConfig, app, function (error) {
uploadAppSnapshot(backupConfig, app, progressCallback, function (error) {
if (error) return callback(error);
rotateAppBackup(backupConfig, app, timestamp, callback);
rotateAppBackup(backupConfig, app, timestamp, progressCallback, callback);
});
});
}
function backupApp(app, callback) {
function backupApp(app, progressCallback, callback) {
assert.strictEqual(typeof app, 'object');
assert.strictEqual(typeof progressCallback, 'function');
assert.strictEqual(typeof callback, 'function');
const timestamp = (new Date()).toISOString().replace(/[T.]/g, '-').replace(/[:Z]/g,'');
safe.fs.unlinkSync(paths.BACKUP_LOG_FILE); // start fresh log file
progress.set(progress.BACKUP, 10, 'Backing up ' + app.fqdn);
debug(`backupApp - Backing up ${app.fqdn} with timestamp ${timestamp}`);
backupAppWithTimestamp(app, timestamp, function (error) {
progress.set(progress.BACKUP, 100, error ? error.message : '');
callback(error);
});
backupAppWithTimestamp(app, timestamp, progressCallback, callback);
}
// this function expects you to have a lock
function backupBoxAndApps(auditSource, callback) {
assert.strictEqual(typeof auditSource, 'object');
callback = callback || NOOP_CALLBACK;
// this function expects you to have a lock. Unlike other progressCallback this also has a progress field
function backupBoxAndApps(progressCallback, callback) {
assert.strictEqual(typeof progressCallback, 'function');
assert.strictEqual(typeof callback, 'function');
var timestamp = (new Date()).toISOString().replace(/[T.]/g, '-').replace(/[:Z]/g,'');
safe.fs.unlinkSync(paths.BACKUP_LOG_FILE); // start fresh log file
eventlog.add(eventlog.ACTION_BACKUP_START, auditSource, { });
apps.getAll(function (error, allApps) {
if (error) return callback(new BackupsError(BackupsError.INTERNAL_ERROR, error));
var processed = 1;
var step = 100/(allApps.length+2);
let percent = 1;
let step = 100/(allApps.length+2);
async.mapSeries(allApps, function iterator(app, iteratorCallback) {
progress.set(progress.BACKUP, step * processed, 'Backing up ' + app.fqdn);
++processed;
progressCallback({ percent: percent, message: `Backing up ${app.fqdn}` });
percent += step;
if (!app.enableBackup) {
progress.set(progress.BACKUP, step * processed, 'Skipped backup ' + app.fqdn);
debug(`Skipped backup ${app.fqdn}`);
return iteratorCallback(null, null); // nothing to backup
}
backupAppWithTimestamp(app, timestamp, function (error, backupId) {
backupAppWithTimestamp(app, timestamp, (progress) => progressCallback({ percent: percent, message: progress.message }), function (error, backupId) {
if (error && error.reason !== BackupsError.BAD_STATE) {
debugApp(app, 'Unable to backup', error);
return iteratorCallback(error);
}
progress.set(progress.BACKUP, step * processed, 'Backed up ' + app.fqdn);
debugApp(app, 'Backed up');
iteratorCallback(null, backupId || null); // clear backupId if is in BAD_STATE and never backed up
});
}, function appsBackedUp(error, backupIds) {
if (error) {
progress.set(progress.BACKUP, 100, error.message);
return callback(error);
}
if (error) return callback(error);
backupIds = backupIds.filter(function (id) { return id !== null; }); // remove apps in bad state that were never backed up
progress.set(progress.BACKUP, step * processed, 'Backing up system data');
progressCallback({ percent: percent, message: 'Backing up system data' });
percent += step;
backupBoxWithAppBackupIds(backupIds, timestamp, function (error, backupId) {
progress.set(progress.BACKUP, 100, error ? error.message : '');
eventlog.add(eventlog.ACTION_BACKUP_FINISH, auditSource, { errorMessage: error ? error.message : null, backupId: backupId, timestamp: timestamp });
callback(error, backupId);
});
backupBoxWithAppBackupIds(backupIds, timestamp, (progress) => progressCallback({ percent: percent, message: progress.message }), callback);
});
});
}
function backup(auditSource, callback) {
assert.strictEqual(typeof auditSource, 'object');
assert.strictEqual(typeof callback, 'function');
var error = locker.lock(locker.OP_FULL_BACKUP);
if (error) return callback(new BackupsError(BackupsError.BAD_STATE, error.message));
var startTime = new Date();
progress.set(progress.BACKUP, 0, 'Starting'); // ensure tools can 'wait' on progress
backupBoxAndApps(auditSource, function (error) { // start the backup operation in the background
if (error) {
debug('backup failed.', error);
mailer.backupFailed(error);
}
function startBackupTask(auditSource, callback) {
let error = locker.lock(locker.OP_FULL_BACKUP);
if (error) return callback(error);
let task = tasks.startTask(tasks.TASK_BACKUP, [], auditSource);
task.on('error', (error) => callback(new BackupsError(BackupsError.INTERNAL_ERROR, error)));
task.on('start', (taskId) => {
eventlog.add(eventlog.ACTION_BACKUP_START, auditSource, { taskId });
callback(null, taskId);
});
task.on('finish', (error, result) => {
locker.unlock(locker.OP_FULL_BACKUP);
debug('backup took %s seconds', (new Date() - startTime)/1000);
});
if (error) mailer.backupFailed(error);
callback(null);
eventlog.add(eventlog.ACTION_BACKUP_FINISH, auditSource, { errorMessage: error ? error.message : null, backupId: result });
});
}
function ensureBackup(auditSource, callback) {
@@ -981,15 +949,19 @@ function ensureBackup(auditSource, callback) {
getByStatePaged(backupdb.BACKUP_STATE_NORMAL, 1, 1, function (error, backups) {
if (error) {
debug('Unable to list backups', error);
return callback(error); // no point trying to backup if appstore is down
return callback(error);
}
if (backups.length !== 0 && (new Date() - new Date(backups[0].creationTime) < 23 * 60 * 60 * 1000)) { // ~1 day ago
debug('Previous backup was %j, no need to backup now', backups[0]);
return callback(null);
}
settings.getBackupConfig(function (error, backupConfig) {
if (error) return callback(error);
backup(auditSource, callback);
if (backups.length !== 0 && (new Date() - new Date(backups[0].creationTime) < (backupConfig.intervalSecs - 3600) * 1000)) { // adjust 1 hour
debug('Previous backup was %j, no need to backup now', backups[0]);
return callback(null);
}
startBackupTask(auditSource, callback);
});
});
}
+1 -137
View File
@@ -4,29 +4,18 @@ exports = module.exports = {
verifySetupToken: verifySetupToken,
setupDone: setupDone,
changePlan: changePlan,
upgrade: upgrade,
sendHeartbeat: sendHeartbeat,
getBoxAndUserDetails: getBoxAndUserDetails,
setPtrRecord: setPtrRecord,
CaasError: CaasError
};
var assert = require('assert'),
backups = require('./backups.js'),
config = require('./config.js'),
debug = require('debug')('box:caas'),
locker = require('./locker.js'),
path = require('path'),
progress = require('./progress.js'),
settings = require('./settings.js'),
shell = require('./shell.js'),
superagent = require('superagent'),
util = require('util'),
_ = require('underscore');
const RETIRE_CMD = path.join(__dirname, 'scripts/retire.sh');
util = require('util');
function CaasError(reason, errorOrMessage) {
assert.strictEqual(typeof reason, 'string');
@@ -53,20 +42,6 @@ CaasError.INVALID_TOKEN = 'Invalid Token';
CaasError.INTERNAL_ERROR = 'Internal Error';
CaasError.EXTERNAL_ERROR = 'External Error';
var NOOP_CALLBACK = function (error) { if (error) debug(error); };
function retire(reason, info, callback) {
assert(reason === 'migrate' || reason === 'upgrade');
info = info || { };
callback = callback || NOOP_CALLBACK;
var data = {
apiServerOrigin: config.apiServerOrigin(),
adminFqdn: config.adminFqdn()
};
shell.sudo('retire', [ RETIRE_CMD, reason, JSON.stringify(info), JSON.stringify(data) ], callback);
}
function getCaasConfig(callback) {
assert.strictEqual(typeof callback, 'function');
@@ -117,96 +92,6 @@ function setupDone(setupToken, callback) {
});
});
}
function doMigrate(options, caasConfig, callback) {
assert.strictEqual(typeof options, 'object');
assert.strictEqual(typeof caasConfig, 'object');
assert.strictEqual(typeof callback, 'function');
var error = locker.lock(locker.OP_MIGRATE);
if (error) return callback(new CaasError(CaasError.BAD_STATE, error.message));
function unlock(error) {
debug('Failed to migrate', error);
locker.unlock(locker.OP_MIGRATE);
progress.set(progress.MIGRATE, -1, 'Backup failed: ' + error.message);
}
progress.set(progress.MIGRATE, 10, 'Backing up for migration');
// initiate the migration in the background
backups.backupBoxAndApps({ userId: null, username: 'migrator' }, function (error) {
if (error) return unlock(error);
debug('migrate: domain: %s size %s region %s', options.domain, options.size, options.region);
superagent
.post(config.apiServerOrigin() + '/api/v1/boxes/' + caasConfig.boxId + '/migrate')
.query({ token: caasConfig.token })
.send(options)
.timeout(30 * 1000)
.end(function (error, result) {
if (error && !error.response) return unlock(error); // network error
if (result.statusCode === 409) return unlock(new CaasError(CaasError.BAD_STATE));
if (result.statusCode === 404) return unlock(new CaasError(CaasError.NOT_FOUND));
if (result.statusCode !== 202) return unlock(new CaasError(CaasError.EXTERNAL_ERROR, util.format('%s %j', result.status, result.body)));
progress.set(progress.MIGRATE, 10, 'Migrating');
retire('migrate', _.pick(options, 'domain', 'size', 'region'));
});
});
callback(null);
}
function changePlan(options, callback) {
assert.strictEqual(typeof options, 'object');
assert.strictEqual(typeof callback, 'function');
if (config.isDemo()) return callback(new CaasError(CaasError.BAD_FIELD, 'Not allowed in demo mode'));
getCaasConfig(function (error, result) {
if (error) return callback(error);
doMigrate(options, result, callback);
});
}
// this function expects a lock
function upgrade(boxUpdateInfo, callback) {
assert(boxUpdateInfo !== null && typeof boxUpdateInfo === 'object');
function upgradeError(e) {
progress.set(progress.UPDATE, -1, e.message);
callback(e);
}
progress.set(progress.UPDATE, 5, 'Backing up for upgrade');
backups.backupBoxAndApps({ userId: null, username: 'upgrader' }, function (error) {
if (error) return upgradeError(error);
getCaasConfig(function (error, result) {
if (error) return upgradeError(error);
superagent.post(config.apiServerOrigin() + '/api/v1/boxes/' + result.boxId + '/upgrade')
.query({ token: result.token })
.send({ version: boxUpdateInfo.version })
.timeout(30 * 1000)
.end(function (error, result) {
if (error && !error.response) return upgradeError(new Error('Network error making upgrade request: ' + error));
if (result.statusCode !== 202) return upgradeError(new Error(util.format('Server not ready to upgrade. statusCode: %s body: %j', result.status, result.body)));
progress.set(progress.UPDATE, 10, 'Updating base system');
// no need to unlock since this is the last thing we ever do on this box
callback();
retire('upgrade');
});
});
});
}
function sendHeartbeat() {
assert(config.provider() === 'caas', 'Heartbeat is only sent for managed cloudrons');
@@ -223,27 +108,6 @@ function sendHeartbeat() {
});
}
function getBoxAndUserDetails(callback) {
assert.strictEqual(typeof callback, 'function');
if (config.provider() !== 'caas') return callback(null, {});
getCaasConfig(function (error, caasConfig) {
if (error) return callback(error);
superagent
.get(config.apiServerOrigin() + '/api/v1/boxes/' + caasConfig.boxId)
.query({ token: caasConfig.token })
.timeout(30 * 1000)
.end(function (error, result) {
if (error && !error.response) return callback(new CaasError(CaasError.EXTERNAL_ERROR, 'Cannot reach appstore'));
if (result.statusCode !== 200) return callback(new CaasError(CaasError.EXTERNAL_ERROR, util.format('%s %j', result.statusCode, result.body)));
return callback(null, result.body);
});
});
}
function setPtrRecord(domain, callback) {
assert.strictEqual(typeof domain, 'string');
assert.strictEqual(typeof callback, 'function');
-479
View File
@@ -1,479 +0,0 @@
'use strict';
var assert = require('assert'),
async = require('async'),
crypto = require('crypto'),
debug = require('debug')('box:cert/acme'),
execSync = require('safetydance').child_process.execSync,
fs = require('fs'),
parseLinks = require('parse-links'),
path = require('path'),
paths = require('../paths.js'),
safe = require('safetydance'),
superagent = require('superagent'),
util = require('util'),
_ = require('underscore');
var CA_PROD = 'https://acme-v01.api.letsencrypt.org',
CA_STAGING = 'https://acme-staging.api.letsencrypt.org',
LE_AGREEMENT = 'https://letsencrypt.org/documents/LE-SA-v1.2-November-15-2017.pdf';
exports = module.exports = {
getCertificate: getCertificate,
// testing
_name: 'acme'
};
function AcmeError(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(AcmeError, Error);
AcmeError.INTERNAL_ERROR = 'Internal Error';
AcmeError.EXTERNAL_ERROR = 'External Error';
AcmeError.ALREADY_EXISTS = 'Already Exists';
AcmeError.NOT_COMPLETED = 'Not Completed';
AcmeError.FORBIDDEN = 'Forbidden';
// http://jose.readthedocs.org/en/latest/
// https://www.ietf.org/proceedings/92/slides/slides-92-acme-1.pdf
// https://community.letsencrypt.org/t/list-of-client-implementations/2103
function Acme(options) {
assert.strictEqual(typeof options, 'object');
this.caOrigin = options.prod ? CA_PROD : CA_STAGING;
this.accountKeyPem = null; // Buffer
this.email = options.email;
}
Acme.prototype.getNonce = function (callback) {
superagent.get(this.caOrigin + '/directory').timeout(30 * 1000).end(function (error, response) {
if (error && !error.response) return callback(error);
if (response.statusCode !== 200) return callback(new Error('Invalid response code when fetching nonce : ' + response.statusCode));
return callback(null, response.headers['Replay-Nonce'.toLowerCase()]);
});
};
// urlsafe base64 encoding (jose)
function urlBase64Encode(string) {
return string.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
}
function b64(str) {
var buf = util.isBuffer(str) ? str : new Buffer(str);
return urlBase64Encode(buf.toString('base64'));
}
function getModulus(pem) {
assert(util.isBuffer(pem));
var stdout = execSync('openssl rsa -modulus -noout', { input: pem, encoding: 'utf8' });
if (!stdout) return null;
var match = stdout.match(/Modulus=([0-9a-fA-F]+)$/m);
if (!match) return null;
return Buffer.from(match[1], 'hex');
}
Acme.prototype.sendSignedRequest = function (url, payload, callback) {
assert.strictEqual(typeof url, 'string');
assert.strictEqual(typeof payload, 'string');
assert.strictEqual(typeof callback, 'function');
assert(util.isBuffer(this.accountKeyPem));
var that = this;
var header = {
alg: 'RS256',
jwk: {
e: b64(Buffer.from([0x01, 0x00, 0x01])), // exponent - 65537
kty: 'RSA',
n: b64(getModulus(this.accountKeyPem))
}
};
var payload64 = b64(payload);
this.getNonce(function (error, nonce) {
if (error) return callback(error);
debug('sendSignedRequest: using nonce %s for url %s', nonce, url);
var protected64 = b64(JSON.stringify(_.extend({ }, header, { nonce: nonce })));
var signer = crypto.createSign('RSA-SHA256');
signer.update(protected64 + '.' + payload64, 'utf8');
var signature64 = urlBase64Encode(signer.sign(that.accountKeyPem, 'base64'));
var data = {
header: header,
protected: protected64,
payload: payload64,
signature: signature64
};
superagent.post(url).set('Content-Type', 'application/x-www-form-urlencoded').send(JSON.stringify(data)).timeout(30 * 1000).end(function (error, res) {
if (error && !error.response) return callback(error); // network errors
callback(null, res);
});
});
};
Acme.prototype.updateContact = function (registrationUri, callback) {
assert.strictEqual(typeof registrationUri, 'string');
assert.strictEqual(typeof callback, 'function');
debug('updateContact: %s %s', registrationUri, this.email);
// https://github.com/ietf-wg-acme/acme/issues/30
var payload = {
resource: 'reg',
contact: [ 'mailto:' + this.email ],
agreement: LE_AGREEMENT
};
var that = this;
this.sendSignedRequest(registrationUri, JSON.stringify(payload), function (error, result) {
if (error) return callback(new AcmeError(AcmeError.EXTERNAL_ERROR, 'Network error when registering user: ' + error.message));
if (result.statusCode !== 202) return callback(new AcmeError(AcmeError.EXTERNAL_ERROR, util.format('Failed to update contact. Expecting 202, got %s %s', result.statusCode, result.text)));
debug('updateContact: contact of user updated to %s', that.email);
callback();
});
};
Acme.prototype.registerUser = function (callback) {
assert.strictEqual(typeof callback, 'function');
var payload = {
resource: 'new-reg',
contact: [ 'mailto:' + this.email ],
agreement: LE_AGREEMENT
};
debug('registerUser: %s', this.email);
var that = this;
this.sendSignedRequest(this.caOrigin + '/acme/new-reg', JSON.stringify(payload), function (error, result) {
if (error) return callback(new AcmeError(AcmeError.EXTERNAL_ERROR, 'Network error when registering user: ' + error.message));
if (result.statusCode === 409) return that.updateContact(result.headers.location, callback); // already exists
if (result.statusCode !== 201) return callback(new AcmeError(AcmeError.EXTERNAL_ERROR, util.format('Failed to register user. Expecting 201, got %s %s', result.statusCode, result.text)));
debug('registerUser: registered user %s', that.email);
callback(null);
});
};
Acme.prototype.registerDomain = function (domain, callback) {
assert.strictEqual(typeof domain, 'string');
assert.strictEqual(typeof callback, 'function');
var payload = {
resource: 'new-authz',
identifier: {
type: 'dns',
value: domain
}
};
debug('registerDomain: %s', domain);
this.sendSignedRequest(this.caOrigin + '/acme/new-authz', JSON.stringify(payload), function (error, result) {
if (error) return callback(new AcmeError(AcmeError.EXTERNAL_ERROR, 'Network error when registering domain: ' + error.message));
if (result.statusCode === 403) return callback(new AcmeError(AcmeError.FORBIDDEN, result.body.detail));
if (result.statusCode !== 201) return callback(new AcmeError(AcmeError.EXTERNAL_ERROR, util.format('Failed to register user. Expecting 201, got %s %s', result.statusCode, result.text)));
debug('registerDomain: registered %s', domain);
callback(null, result.body);
});
};
Acme.prototype.prepareHttpChallenge = function (challenge, callback) {
assert.strictEqual(typeof challenge, 'object');
assert.strictEqual(typeof callback, 'function');
debug('prepareHttpChallenge: preparing for challenge %j', challenge);
var token = challenge.token;
assert(util.isBuffer(this.accountKeyPem));
var jwk = {
e: b64(Buffer.from([0x01, 0x00, 0x01])), // Exponent - 65537
kty: 'RSA',
n: b64(getModulus(this.accountKeyPem))
};
var shasum = crypto.createHash('sha256');
shasum.update(JSON.stringify(jwk));
var thumbprint = urlBase64Encode(shasum.digest('base64'));
var keyAuthorization = token + '.' + thumbprint;
debug('prepareHttpChallenge: writing %s to %s', keyAuthorization, path.join(paths.ACME_CHALLENGES_DIR, token));
fs.writeFile(path.join(paths.ACME_CHALLENGES_DIR, token), token + '.' + thumbprint, function (error) {
if (error) return callback(new AcmeError(AcmeError.INTERNAL_ERROR, error));
callback();
});
};
Acme.prototype.notifyChallengeReady = function (challenge, callback) {
assert.strictEqual(typeof challenge, 'object');
assert.strictEqual(typeof callback, 'function');
debug('notifyChallengeReady: %s was met', challenge.uri);
var keyAuthorization = fs.readFileSync(path.join(paths.ACME_CHALLENGES_DIR, challenge.token), 'utf8');
var payload = {
resource: 'challenge',
keyAuthorization: keyAuthorization
};
this.sendSignedRequest(challenge.uri, JSON.stringify(payload), function (error, result) {
if (error) return callback(new AcmeError(AcmeError.EXTERNAL_ERROR, 'Network error when notifying challenge: ' + error.message));
if (result.statusCode !== 202) return callback(new AcmeError(AcmeError.EXTERNAL_ERROR, util.format('Failed to notify challenge. Expecting 202, got %s %s', result.statusCode, result.text)));
callback();
});
};
Acme.prototype.waitForChallenge = function (challenge, callback) {
assert.strictEqual(typeof challenge, 'object');
assert.strictEqual(typeof callback, 'function');
debug('waitingForChallenge: %j', challenge);
async.retry({ times: 10, interval: 5000 }, function (retryCallback) {
debug('waitingForChallenge: getting status');
superagent.get(challenge.uri).timeout(30 * 1000).end(function (error, result) {
if (error && !error.response) {
debug('waitForChallenge: network error getting uri %s', challenge.uri);
return retryCallback(new AcmeError(AcmeError.EXTERNAL_ERROR, error.message)); // network error
}
if (result.statusCode !== 202) {
debug('waitForChallenge: invalid response code getting uri %s', result.statusCode);
return retryCallback(new AcmeError(AcmeError.EXTERNAL_ERROR, 'Bad response code:' + result.statusCode));
}
debug('waitForChallenge: status is "%s %j', result.body.status, result.body);
if (result.body.status === 'pending') return retryCallback(new AcmeError(AcmeError.NOT_COMPLETED));
else if (result.body.status === 'valid') return retryCallback();
else return retryCallback(new AcmeError(AcmeError.EXTERNAL_ERROR, 'Unexpected status: ' + result.body.status));
});
}, function retryFinished(error) {
// async.retry will pass 'undefined' as second arg making it unusable with async.waterfall()
callback(error);
});
};
// https://community.letsencrypt.org/t/public-beta-rate-limits/4772 for rate limits
Acme.prototype.signCertificate = function (domain, csrDer, callback) {
assert.strictEqual(typeof domain, 'string');
assert(util.isBuffer(csrDer));
assert.strictEqual(typeof callback, 'function');
var outdir = paths.APP_CERTS_DIR;
var payload = {
resource: 'new-cert',
csr: b64(csrDer)
};
debug('signCertificate: sending new-cert request');
this.sendSignedRequest(this.caOrigin + '/acme/new-cert', JSON.stringify(payload), function (error, result) {
if (error) return callback(new AcmeError(AcmeError.EXTERNAL_ERROR, 'Network error when signing certificate: ' + error.message));
// 429 means we reached the cert limit for this domain
if (result.statusCode !== 201) return callback(new AcmeError(AcmeError.EXTERNAL_ERROR, util.format('Failed to sign certificate. Expecting 201, got %s %s', result.statusCode, result.text)));
var certUrl = result.headers.location;
if (!certUrl) return callback(new AcmeError(AcmeError.EXTERNAL_ERROR, 'Missing location in downloadCertificate'));
safe.fs.writeFileSync(path.join(outdir, domain + '.url'), certUrl, 'utf8'); // maybe use for renewal
return callback(null, result.headers.location);
});
};
Acme.prototype.createKeyAndCsr = function (domain, callback) {
assert.strictEqual(typeof domain, 'string');
assert.strictEqual(typeof callback, 'function');
var outdir = paths.APP_CERTS_DIR;
var csrFile = path.join(outdir, domain + '.csr');
var privateKeyFile = path.join(outdir, domain + '.key');
if (safe.fs.existsSync(privateKeyFile)) {
// in some old releases, csr file was corrupt. so always regenerate it
debug('createKeyAndCsr: reuse the key for renewal at %s', privateKeyFile);
} else {
var key = execSync('openssl genrsa 4096');
if (!key) return callback(new AcmeError(AcmeError.INTERNAL_ERROR, safe.error));
if (!safe.fs.writeFileSync(privateKeyFile, key)) return callback(new AcmeError(AcmeError.INTERNAL_ERROR, safe.error));
debug('createKeyAndCsr: key file saved at %s', privateKeyFile);
}
var csrDer = execSync(util.format('openssl req -new -key %s -outform DER -subj /CN=%s', privateKeyFile, domain));
if (!csrDer) return callback(new AcmeError(AcmeError.INTERNAL_ERROR, safe.error));
if (!safe.fs.writeFileSync(csrFile, csrDer)) return callback(new AcmeError(AcmeError.INTERNAL_ERROR, safe.error)); // bookkeeping
debug('createKeyAndCsr: csr file (DER) saved at %s', csrFile);
callback(null, csrDer);
};
// TODO: download the chain in a loop following 'up' header
Acme.prototype.downloadChain = function (linkHeader, callback) {
if (!linkHeader) return new AcmeError(AcmeError.EXTERNAL_ERROR, 'Empty link header when downloading certificate chain');
debug('downloadChain: linkHeader %s', linkHeader);
var linkInfo = parseLinks(linkHeader);
if (!linkInfo || !linkInfo.up) return new AcmeError(AcmeError.EXTERNAL_ERROR, 'Failed to parse link header when downloading certificate chain');
var intermediateCertUrl = linkInfo.up.startsWith('https://') ? linkInfo.up : (this.caOrigin + linkInfo.up);
debug('downloadChain: downloading from %s', intermediateCertUrl);
superagent.get(intermediateCertUrl).buffer().parse(function (res, done) {
var data = [ ];
res.on('data', function(chunk) { data.push(chunk); });
res.on('end', function () { res.text = Buffer.concat(data); done(); });
}).timeout(30 * 1000).end(function (error, result) {
if (error && !error.response) return callback(new AcmeError(AcmeError.EXTERNAL_ERROR, 'Network error when downloading certificate'));
if (result.statusCode !== 200) return callback(new AcmeError(AcmeError.EXTERNAL_ERROR, util.format('Failed to get cert. Expecting 200, got %s %s', result.statusCode, result.text)));
var chainDer = result.text;
var chainPem = execSync('openssl x509 -inform DER -outform PEM', { input: chainDer }); // this is really just base64 encoding with header
if (!chainPem) return callback(new AcmeError(AcmeError.INTERNAL_ERROR, safe.error));
callback(null, chainPem);
});
};
Acme.prototype.downloadCertificate = function (domain, certUrl, callback) {
assert.strictEqual(typeof domain, 'string');
assert.strictEqual(typeof certUrl, 'string');
assert.strictEqual(typeof callback, 'function');
var outdir = paths.APP_CERTS_DIR;
var that = this;
superagent.get(certUrl).buffer().parse(function (res, done) {
var data = [ ];
res.on('data', function(chunk) { data.push(chunk); });
res.on('end', function () { res.text = Buffer.concat(data); done(); });
}).timeout(30 * 1000).end(function (error, result) {
if (error && !error.response) return callback(new AcmeError(AcmeError.EXTERNAL_ERROR, 'Network error when downloading certificate'));
if (result.statusCode === 202) return callback(new AcmeError(AcmeError.INTERNAL_ERROR, 'Retry not implemented yet'));
if (result.statusCode !== 200) return callback(new AcmeError(AcmeError.EXTERNAL_ERROR, util.format('Failed to get cert. Expecting 200, got %s %s', result.statusCode, result.text)));
var certificateDer = result.text;
safe.fs.writeFileSync(path.join(outdir, domain + '.der'), certificateDer);
debug('downloadCertificate: cert der file for %s saved', domain);
var certificatePem = execSync('openssl x509 -inform DER -outform PEM', { input: certificateDer }); // this is really just base64 encoding with header
if (!certificatePem) return callback(new AcmeError(AcmeError.INTERNAL_ERROR, safe.error));
that.downloadChain(result.header['link'], function (error, chainPem) {
if (error) return callback(error);
var certificateFile = path.join(outdir, domain + '.cert');
var fullChainPem = Buffer.concat([certificatePem, chainPem]);
if (!safe.fs.writeFileSync(certificateFile, fullChainPem)) return callback(new AcmeError(AcmeError.INTERNAL_ERROR, safe.error));
debug('downloadCertificate: cert file for %s saved at %s', domain, certificateFile);
callback();
});
});
};
Acme.prototype.acmeFlow = function (domain, callback) {
assert.strictEqual(typeof domain, 'string');
assert.strictEqual(typeof callback, 'function');
if (!fs.existsSync(paths.ACME_ACCOUNT_KEY_FILE)) {
debug('getCertificate: generating acme account key on first run');
this.accountKeyPem = safe.child_process.execSync('openssl genrsa 4096');
if (!this.accountKeyPem) return callback(new AcmeError(AcmeError.INTERNAL_ERROR, safe.error));
safe.fs.writeFileSync(paths.ACME_ACCOUNT_KEY_FILE, this.accountKeyPem);
} else {
debug('getCertificate: using existing acme account key');
this.accountKeyPem = fs.readFileSync(paths.ACME_ACCOUNT_KEY_FILE);
}
var that = this;
this.registerUser(function (error) {
if (error) return callback(error);
that.registerDomain(domain, function (error, result) {
if (error) return callback(error);
debug('acmeFlow: challenges: %j', result);
var httpChallenges = result.challenges.filter(function(x) { return x.type === 'http-01'; });
if (httpChallenges.length === 0) return callback(new AcmeError(AcmeError.EXTERNAL_ERROR, 'no http challenges'));
var challenge = httpChallenges[0];
async.waterfall([
that.prepareHttpChallenge.bind(that, challenge),
that.notifyChallengeReady.bind(that, challenge),
that.waitForChallenge.bind(that, challenge),
that.createKeyAndCsr.bind(that, domain),
that.signCertificate.bind(that, domain),
that.downloadCertificate.bind(that, domain)
], callback);
});
});
};
Acme.prototype.getCertificate = function (domain, callback) {
assert.strictEqual(typeof domain, 'string');
assert.strictEqual(typeof callback, 'function');
debug('getCertificate: start acme flow for %s from %s', domain, this.caOrigin);
this.acmeFlow(domain, function (error) {
if (error) return callback(error);
var outdir = paths.APP_CERTS_DIR;
callback(null, path.join(outdir, domain + '.cert'), path.join(outdir, domain + '.key'));
});
};
function getCertificate(domain, options, callback) {
assert.strictEqual(typeof domain, 'string');
assert.strictEqual(typeof options, 'object');
assert.strictEqual(typeof callback, 'function');
var acme = new Acme(options || { });
acme.getCertificate(domain, callback);
}
+636
View File
@@ -0,0 +1,636 @@
'use strict';
var assert = require('assert'),
async = require('async'),
crypto = require('crypto'),
debug = require('debug')('box:cert/acme2'),
domains = require('../domains.js'),
fs = require('fs'),
path = require('path'),
paths = require('../paths.js'),
safe = require('safetydance'),
superagent = require('superagent'),
util = require('util'),
_ = require('underscore');
const CA_PROD_DIRECTORY_URL = 'https://acme-v02.api.letsencrypt.org/directory',
CA_STAGING_DIRECTORY_URL = 'https://acme-staging-v02.api.letsencrypt.org/directory';
exports = module.exports = {
getCertificate: getCertificate,
// testing
_name: 'acme',
_getChallengeSubdomain: getChallengeSubdomain
};
function Acme2Error(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(Acme2Error, Error);
Acme2Error.INTERNAL_ERROR = 'Internal Error';
Acme2Error.EXTERNAL_ERROR = 'External Error';
Acme2Error.ALREADY_EXISTS = 'Already Exists';
Acme2Error.NOT_COMPLETED = 'Not Completed';
Acme2Error.FORBIDDEN = 'Forbidden';
// http://jose.readthedocs.org/en/latest/
// https://www.ietf.org/proceedings/92/slides/slides-92-acme-1.pdf
// https://community.letsencrypt.org/t/list-of-client-implementations/2103
function Acme2(options) {
assert.strictEqual(typeof options, 'object');
this.accountKeyPem = null; // Buffer
this.email = options.email;
this.keyId = null;
this.caDirectory = options.prod ? CA_PROD_DIRECTORY_URL : CA_STAGING_DIRECTORY_URL;
this.directory = {};
this.performHttpAuthorization = !!options.performHttpAuthorization;
this.wildcard = !!options.wildcard;
}
Acme2.prototype.getNonce = function (callback) {
superagent.get(this.directory.newNonce).timeout(30 * 1000).end(function (error, response) {
if (error && !error.response) return callback(error);
if (response.statusCode !== 204) return callback(new Error('Invalid response code when fetching nonce : ' + response.statusCode));
return callback(null, response.headers['Replay-Nonce'.toLowerCase()]);
});
};
// urlsafe base64 encoding (jose)
function urlBase64Encode(string) {
return string.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
}
function b64(str) {
var buf = util.isBuffer(str) ? str : new Buffer(str);
return urlBase64Encode(buf.toString('base64'));
}
function getModulus(pem) {
assert(util.isBuffer(pem));
var stdout = safe.child_process.execSync('openssl rsa -modulus -noout', { input: pem, encoding: 'utf8' });
if (!stdout) return null;
var match = stdout.match(/Modulus=([0-9a-fA-F]+)$/m);
if (!match) return null;
return Buffer.from(match[1], 'hex');
}
Acme2.prototype.sendSignedRequest = function (url, payload, callback) {
assert.strictEqual(typeof url, 'string');
assert.strictEqual(typeof payload, 'string');
assert.strictEqual(typeof callback, 'function');
assert(util.isBuffer(this.accountKeyPem));
const that = this;
let header = {
url: url,
alg: 'RS256'
};
// keyId is null when registering account
if (this.keyId) {
header.kid = this.keyId;
} else {
header.jwk = {
e: b64(Buffer.from([0x01, 0x00, 0x01])), // exponent - 65537
kty: 'RSA',
n: b64(getModulus(this.accountKeyPem))
};
}
var payload64 = b64(payload);
this.getNonce(function (error, nonce) {
if (error) return callback(error);
debug('sendSignedRequest: using nonce %s for url %s', nonce, url);
var protected64 = b64(JSON.stringify(_.extend({ }, header, { nonce: nonce })));
var signer = crypto.createSign('RSA-SHA256');
signer.update(protected64 + '.' + payload64, 'utf8');
var signature64 = urlBase64Encode(signer.sign(that.accountKeyPem, 'base64'));
var data = {
protected: protected64,
payload: payload64,
signature: signature64
};
superagent.post(url).set('Content-Type', 'application/jose+json').set('User-Agent', 'acme-cloudron').send(JSON.stringify(data)).timeout(30 * 1000).end(function (error, res) {
if (error && !error.response) return callback(error); // network errors
callback(null, res);
});
});
};
Acme2.prototype.updateContact = function (registrationUri, callback) {
assert.strictEqual(typeof registrationUri, 'string');
assert.strictEqual(typeof callback, 'function');
debug(`updateContact: registrationUri: ${registrationUri} email: ${this.email}`);
// https://github.com/ietf-wg-acme/acme/issues/30
const payload = {
contact: [ 'mailto:' + this.email ]
};
const that = this;
this.sendSignedRequest(registrationUri, JSON.stringify(payload), function (error, result) {
if (error) return callback(new Acme2Error(Acme2Error.EXTERNAL_ERROR, 'Network error when registering user: ' + error.message));
if (result.statusCode !== 200) return callback(new Acme2Error(Acme2Error.EXTERNAL_ERROR, util.format('Failed to update contact. Expecting 200, got %s %s', result.statusCode, result.text)));
debug(`updateContact: contact of user updated to ${that.email}`);
callback();
});
};
Acme2.prototype.registerUser = function (callback) {
assert.strictEqual(typeof callback, 'function');
var payload = {
termsOfServiceAgreed: true
};
debug('registerUser: registering user');
var that = this;
this.sendSignedRequest(this.directory.newAccount, JSON.stringify(payload), function (error, result) {
if (error) return callback(new Acme2Error(Acme2Error.EXTERNAL_ERROR, 'Network error when registering new account: ' + error.message));
// 200 if already exists. 201 for new accounts
if (result.statusCode !== 200 && result.statusCode !== 201) return callback(new Acme2Error(Acme2Error.EXTERNAL_ERROR, util.format('Failed to register new account. Expecting 200 or 201, got %s %s', result.statusCode, result.text)));
debug(`registerUser: user registered keyid: ${result.headers.location}`);
that.keyId = result.headers.location;
that.updateContact(result.headers.location, callback);
});
};
Acme2.prototype.newOrder = function (domain, callback) {
assert.strictEqual(typeof domain, 'string');
assert.strictEqual(typeof callback, 'function');
var payload = {
identifiers: [{
type: 'dns',
value: domain
}]
};
debug('newOrder: %s', domain);
this.sendSignedRequest(this.directory.newOrder, JSON.stringify(payload), function (error, result) {
if (error) return callback(new Acme2Error(Acme2Error.EXTERNAL_ERROR, 'Network error when registering domain: ' + error.message));
if (result.statusCode === 403) return callback(new Acme2Error(Acme2Error.FORBIDDEN, result.body.detail));
if (result.statusCode !== 201) return callback(new Acme2Error(Acme2Error.EXTERNAL_ERROR, util.format('Failed to register user. Expecting 201, got %s %s', result.statusCode, result.text)));
debug('newOrder: created order %s %j', domain, result.body);
const order = result.body, orderUrl = result.headers.location;
if (!Array.isArray(order.authorizations)) return callback(new Acme2Error(Acme2Error.EXTERNAL_ERROR, 'invalid authorizations in order'));
if (typeof order.finalize !== 'string') return callback(new Acme2Error(Acme2Error.EXTERNAL_ERROR, 'invalid finalize in order'));
if (typeof orderUrl !== 'string') return callback(new Acme2Error(Acme2Error.EXTERNAL_ERROR, 'invalid order location in order header'));
callback(null, order, orderUrl);
});
};
Acme2.prototype.waitForOrder = function (orderUrl, callback) {
assert.strictEqual(typeof orderUrl, 'string');
assert.strictEqual(typeof callback, 'function');
debug(`waitForOrder: ${orderUrl}`);
async.retry({ times: 10, interval: 5000 }, function (retryCallback) {
debug('waitForOrder: getting status');
superagent.get(orderUrl).timeout(30 * 1000).end(function (error, result) {
if (error && !error.response) {
debug('waitForOrder: network error getting uri %s', orderUrl);
return retryCallback(new Acme2Error(Acme2Error.EXTERNAL_ERROR, error.message)); // network error
}
if (result.statusCode !== 200) {
debug('waitForOrder: invalid response code getting uri %s', result.statusCode);
return retryCallback(new Acme2Error(Acme2Error.EXTERNAL_ERROR, 'Bad response code:' + result.statusCode));
}
debug('waitForOrder: status is "%s %j', result.body.status, result.body);
if (result.body.status === 'pending' || result.body.status === 'processing') return retryCallback(new Acme2Error(Acme2Error.NOT_COMPLETED));
else if (result.body.status === 'valid' && result.body.certificate) return retryCallback(null, result.body.certificate);
else return retryCallback(new Acme2Error(Acme2Error.EXTERNAL_ERROR, 'Unexpected status or invalid response: ' + result.body));
});
}, callback);
};
Acme2.prototype.getKeyAuthorization = function (token) {
assert(util.isBuffer(this.accountKeyPem));
let jwk = {
e: b64(Buffer.from([0x01, 0x00, 0x01])), // Exponent - 65537
kty: 'RSA',
n: b64(getModulus(this.accountKeyPem))
};
let shasum = crypto.createHash('sha256');
shasum.update(JSON.stringify(jwk));
let thumbprint = urlBase64Encode(shasum.digest('base64'));
return token + '.' + thumbprint;
};
Acme2.prototype.notifyChallengeReady = function (challenge, callback) {
assert.strictEqual(typeof challenge, 'object'); // { type, status, url, token }
assert.strictEqual(typeof callback, 'function');
debug('notifyChallengeReady: %s was met', challenge.url);
const keyAuthorization = this.getKeyAuthorization(challenge.token);
var payload = {
resource: 'challenge',
keyAuthorization: keyAuthorization
};
this.sendSignedRequest(challenge.url, JSON.stringify(payload), function (error, result) {
if (error) return callback(new Acme2Error(Acme2Error.EXTERNAL_ERROR, 'Network error when notifying challenge: ' + error.message));
if (result.statusCode !== 200) return callback(new Acme2Error(Acme2Error.EXTERNAL_ERROR, util.format('Failed to notify challenge. Expecting 200, got %s %s', result.statusCode, result.text)));
callback();
});
};
Acme2.prototype.waitForChallenge = function (challenge, callback) {
assert.strictEqual(typeof challenge, 'object');
assert.strictEqual(typeof callback, 'function');
debug('waitingForChallenge: %j', challenge);
async.retry({ times: 10, interval: 5000 }, function (retryCallback) {
debug('waitingForChallenge: getting status');
superagent.get(challenge.url).timeout(30 * 1000).end(function (error, result) {
if (error && !error.response) {
debug('waitForChallenge: network error getting uri %s', challenge.url);
return retryCallback(new Acme2Error(Acme2Error.EXTERNAL_ERROR, error.message)); // network error
}
if (result.statusCode !== 200) {
debug('waitForChallenge: invalid response code getting uri %s', result.statusCode);
return retryCallback(new Acme2Error(Acme2Error.EXTERNAL_ERROR, 'Bad response code:' + result.statusCode));
}
debug('waitForChallenge: status is "%s %j', result.body.status, result.body);
if (result.body.status === 'pending') return retryCallback(new Acme2Error(Acme2Error.NOT_COMPLETED));
else if (result.body.status === 'valid') return retryCallback();
else return retryCallback(new Acme2Error(Acme2Error.EXTERNAL_ERROR, 'Unexpected status: ' + result.body.status));
});
}, function retryFinished(error) {
// async.retry will pass 'undefined' as second arg making it unusable with async.waterfall()
callback(error);
});
};
// https://community.letsencrypt.org/t/public-beta-rate-limits/4772 for rate limits
Acme2.prototype.signCertificate = function (domain, finalizationUrl, csrDer, callback) {
assert.strictEqual(typeof domain, 'string');
assert.strictEqual(typeof finalizationUrl, 'string');
assert(util.isBuffer(csrDer));
assert.strictEqual(typeof callback, 'function');
const payload = {
csr: b64(csrDer)
};
debug('signCertificate: sending sign request');
this.sendSignedRequest(finalizationUrl, JSON.stringify(payload), function (error, result) {
if (error) return callback(new Acme2Error(Acme2Error.EXTERNAL_ERROR, 'Network error when signing certificate: ' + error.message));
// 429 means we reached the cert limit for this domain
if (result.statusCode !== 200) return callback(new Acme2Error(Acme2Error.EXTERNAL_ERROR, util.format('Failed to sign certificate. Expecting 200, got %s %s', result.statusCode, result.text)));
return callback(null);
});
};
Acme2.prototype.createKeyAndCsr = function (hostname, callback) {
assert.strictEqual(typeof hostname, 'string');
assert.strictEqual(typeof callback, 'function');
var outdir = paths.APP_CERTS_DIR;
const certName = hostname.replace('*.', '_.');
var csrFile = path.join(outdir, `${certName}.csr`);
var privateKeyFile = path.join(outdir, `${certName}.key`);
if (safe.fs.existsSync(privateKeyFile)) {
// in some old releases, csr file was corrupt. so always regenerate it
debug('createKeyAndCsr: reuse the key for renewal at %s', privateKeyFile);
} else {
var key = safe.child_process.execSync('openssl genrsa 4096');
if (!key) return callback(new Acme2Error(Acme2Error.INTERNAL_ERROR, safe.error));
if (!safe.fs.writeFileSync(privateKeyFile, key)) return callback(new Acme2Error(Acme2Error.INTERNAL_ERROR, safe.error));
debug('createKeyAndCsr: key file saved at %s', privateKeyFile);
}
var csrDer = safe.child_process.execSync(`openssl req -new -key ${privateKeyFile} -outform DER -subj /CN=${hostname}`);
if (!csrDer) return callback(new Acme2Error(Acme2Error.INTERNAL_ERROR, safe.error));
if (!safe.fs.writeFileSync(csrFile, csrDer)) return callback(new Acme2Error(Acme2Error.INTERNAL_ERROR, safe.error)); // bookkeeping
debug('createKeyAndCsr: csr file (DER) saved at %s', csrFile);
callback(null, csrDer);
};
Acme2.prototype.downloadCertificate = function (hostname, certUrl, callback) {
assert.strictEqual(typeof hostname, 'string');
assert.strictEqual(typeof certUrl, 'string');
assert.strictEqual(typeof callback, 'function');
var outdir = paths.APP_CERTS_DIR;
superagent.get(certUrl).buffer().parse(function (res, done) {
var data = [ ];
res.on('data', function(chunk) { data.push(chunk); });
res.on('end', function () { res.text = Buffer.concat(data); done(); });
}).timeout(30 * 1000).end(function (error, result) {
if (error && !error.response) return callback(new Acme2Error(Acme2Error.EXTERNAL_ERROR, 'Network error when downloading certificate'));
if (result.statusCode === 202) return callback(new Acme2Error(Acme2Error.INTERNAL_ERROR, 'Retry not implemented yet'));
if (result.statusCode !== 200) return callback(new Acme2Error(Acme2Error.EXTERNAL_ERROR, util.format('Failed to get cert. Expecting 200, got %s %s', result.statusCode, result.text)));
const fullChainPem = result.text;
const certName = hostname.replace('*.', '_.');
var certificateFile = path.join(outdir, `${certName}.cert`);
if (!safe.fs.writeFileSync(certificateFile, fullChainPem)) return callback(new Acme2Error(Acme2Error.INTERNAL_ERROR, safe.error));
debug('downloadCertificate: cert file for %s saved at %s', hostname, certificateFile);
callback();
});
};
Acme2.prototype.prepareHttpChallenge = function (hostname, domain, authorization, callback) {
assert.strictEqual(typeof hostname, 'string');
assert.strictEqual(typeof domain, 'string');
assert.strictEqual(typeof authorization, 'object');
assert.strictEqual(typeof callback, 'function');
debug('acmeFlow: challenges: %j', authorization);
let httpChallenges = authorization.challenges.filter(function(x) { return x.type === 'http-01'; });
if (httpChallenges.length === 0) return callback(new Acme2Error(Acme2Error.EXTERNAL_ERROR, 'no http challenges'));
let challenge = httpChallenges[0];
debug('prepareHttpChallenge: preparing for challenge %j', challenge);
let keyAuthorization = this.getKeyAuthorization(challenge.token);
debug('prepareHttpChallenge: writing %s to %s', keyAuthorization, path.join(paths.ACME_CHALLENGES_DIR, challenge.token));
fs.writeFile(path.join(paths.ACME_CHALLENGES_DIR, challenge.token), keyAuthorization, function (error) {
if (error) return callback(new Acme2Error(Acme2Error.INTERNAL_ERROR, error));
callback(null, challenge);
});
};
Acme2.prototype.cleanupHttpChallenge = function (hostname, domain, challenge, callback) {
assert.strictEqual(typeof hostname, 'string');
assert.strictEqual(typeof domain, 'string');
assert.strictEqual(typeof challenge, 'object');
assert.strictEqual(typeof callback, 'function');
debug('cleanupHttpChallenge: unlinking %s', path.join(paths.ACME_CHALLENGES_DIR, challenge.token));
fs.unlink(path.join(paths.ACME_CHALLENGES_DIR, challenge.token), callback);
};
function getChallengeSubdomain(hostname, domain) {
let challengeSubdomain;
if (hostname === domain) {
challengeSubdomain = '_acme-challenge';
} else if (hostname.includes('*')) { // wildcard
let subdomain = hostname.slice(0, -domain.length - 1);
challengeSubdomain = subdomain ? subdomain.replace('*', '_acme-challenge') : '_acme-challenge';
} else {
challengeSubdomain = '_acme-challenge.' + hostname.slice(0, -domain.length - 1);
}
debug(`getChallengeSubdomain: challenge subdomain for hostname ${hostname} at domain ${domain} is ${challengeSubdomain}`);
return challengeSubdomain;
}
Acme2.prototype.prepareDnsChallenge = function (hostname, domain, authorization, callback) {
assert.strictEqual(typeof hostname, 'string');
assert.strictEqual(typeof domain, 'string');
assert.strictEqual(typeof authorization, 'object');
assert.strictEqual(typeof callback, 'function');
debug('acmeFlow: challenges: %j', authorization);
let dnsChallenges = authorization.challenges.filter(function(x) { return x.type === 'dns-01'; });
if (dnsChallenges.length === 0) return callback(new Acme2Error(Acme2Error.EXTERNAL_ERROR, 'no dns challenges'));
let challenge = dnsChallenges[0];
const keyAuthorization = this.getKeyAuthorization(challenge.token);
let shasum = crypto.createHash('sha256');
shasum.update(keyAuthorization);
const txtValue = urlBase64Encode(shasum.digest('base64'));
let challengeSubdomain = getChallengeSubdomain(hostname, domain);
debug(`prepareDnsChallenge: update ${challengeSubdomain} with ${txtValue}`);
domains.upsertDnsRecords(challengeSubdomain, domain, 'TXT', [ `"${txtValue}"` ], function (error) {
if (error) return callback(new Acme2Error(Acme2Error.EXTERNAL_ERROR, error.message));
domains.waitForDnsRecord(challengeSubdomain, domain, 'TXT', txtValue, { interval: 5000, times: 200 }, function (error) {
if (error) return callback(new Acme2Error(Acme2Error.EXTERNAL_ERROR, error.message));
callback(null, challenge);
});
});
};
Acme2.prototype.cleanupDnsChallenge = function (hostname, domain, challenge, callback) {
assert.strictEqual(typeof hostname, 'string');
assert.strictEqual(typeof domain, 'string');
assert.strictEqual(typeof challenge, 'object');
assert.strictEqual(typeof callback, 'function');
const keyAuthorization = this.getKeyAuthorization(challenge.token);
let shasum = crypto.createHash('sha256');
shasum.update(keyAuthorization);
const txtValue = urlBase64Encode(shasum.digest('base64'));
let challengeSubdomain = getChallengeSubdomain(hostname, domain);
debug(`cleanupDnsChallenge: remove ${challengeSubdomain} with ${txtValue}`);
domains.removeDnsRecords(challengeSubdomain, domain, 'TXT', [ `"${txtValue}"` ], function (error) {
if (error) return callback(new Acme2Error(Acme2Error.EXTERNAL_ERROR, error));
callback(null);
});
};
Acme2.prototype.prepareChallenge = function (hostname, domain, authorizationUrl, callback) {
assert.strictEqual(typeof hostname, 'string');
assert.strictEqual(typeof domain, 'string');
assert.strictEqual(typeof authorizationUrl, 'string');
assert.strictEqual(typeof callback, 'function');
const that = this;
superagent.get(authorizationUrl).timeout(30 * 1000).end(function (error, response) {
if (error && !error.response) return callback(error);
if (response.statusCode !== 200) return callback(new Error('Invalid response code getting authorization : ' + response.statusCode));
const authorization = response.body;
if (that.performHttpAuthorization) {
that.prepareHttpChallenge(hostname, domain, authorization, callback);
} else {
that.prepareDnsChallenge(hostname, domain, authorization, callback);
}
});
};
Acme2.prototype.cleanupChallenge = function (hostname, domain, challenge, callback) {
assert.strictEqual(typeof hostname, 'string');
assert.strictEqual(typeof domain, 'string');
assert.strictEqual(typeof challenge, 'object');
assert.strictEqual(typeof callback, 'function');
if (this.performHttpAuthorization) {
this.cleanupHttpChallenge(hostname, domain, challenge, callback);
} else {
this.cleanupDnsChallenge(hostname, domain, challenge, callback);
}
};
Acme2.prototype.acmeFlow = function (hostname, domain, callback) {
assert.strictEqual(typeof hostname, 'string');
assert.strictEqual(typeof domain, 'string');
assert.strictEqual(typeof callback, 'function');
if (!fs.existsSync(paths.ACME_ACCOUNT_KEY_FILE)) {
debug('getCertificate: generating acme account key on first run');
this.accountKeyPem = safe.child_process.execSync('openssl genrsa 4096');
if (!this.accountKeyPem) return callback(new Acme2Error(Acme2Error.INTERNAL_ERROR, safe.error));
safe.fs.writeFileSync(paths.ACME_ACCOUNT_KEY_FILE, this.accountKeyPem);
} else {
debug('getCertificate: using existing acme account key');
this.accountKeyPem = fs.readFileSync(paths.ACME_ACCOUNT_KEY_FILE);
}
var that = this;
this.registerUser(function (error) {
if (error) return callback(error);
that.newOrder(hostname, function (error, order, orderUrl) {
if (error) return callback(error);
async.eachSeries(order.authorizations, function (authorizationUrl, iteratorCallback) {
debug(`acmeFlow: authorizing ${authorizationUrl}`);
that.prepareChallenge(hostname, domain, authorizationUrl, function (error, challenge) {
if (error) return iteratorCallback(error);
async.waterfall([
that.notifyChallengeReady.bind(that, challenge),
that.waitForChallenge.bind(that, challenge),
that.createKeyAndCsr.bind(that, hostname),
that.signCertificate.bind(that, hostname, order.finalize),
that.waitForOrder.bind(that, orderUrl),
that.downloadCertificate.bind(that, hostname)
], function (error) {
that.cleanupChallenge(hostname, domain, challenge, function (cleanupError) {
if (cleanupError) debug('acmeFlow: ignoring error when cleaning up challenge:', cleanupError);
iteratorCallback(error);
});
});
});
}, callback);
});
});
};
Acme2.prototype.getDirectory = function (callback) {
const that = this;
superagent.get(this.caDirectory).timeout(30 * 1000).end(function (error, response) {
if (error && !error.response) return callback(error);
if (response.statusCode !== 200) return callback(new Error('Invalid response code when fetching directory : ' + response.statusCode));
if (typeof response.body.newNonce !== 'string' ||
typeof response.body.newOrder !== 'string' ||
typeof response.body.newAccount !== 'string') return callback(new Error(`Invalid response body : ${response.body}`));
that.directory = response.body;
callback(null);
});
};
Acme2.prototype.getCertificate = function (hostname, domain, callback) {
assert.strictEqual(typeof hostname, 'string');
assert.strictEqual(typeof domain, 'string');
assert.strictEqual(typeof callback, 'function');
debug(`getCertificate: start acme flow for ${hostname} from ${this.caDirectory}`);
if (hostname !== domain && this.wildcard) { // bare domain is not part of wildcard SAN
hostname = domains.makeWildcard(hostname);
debug(`getCertificate: will get wildcard cert for ${hostname}`);
}
const that = this;
this.getDirectory(function (error) {
if (error) return callback(error);
that.acmeFlow(hostname, domain, function (error) {
if (error) return callback(error);
var outdir = paths.APP_CERTS_DIR;
const certName = hostname.replace('*.', '_.');
callback(null, path.join(outdir, `${certName}.cert`), path.join(outdir, `${certName}.key`));
});
});
};
function getCertificate(hostname, domain, options, callback) {
assert.strictEqual(typeof hostname, 'string');
assert.strictEqual(typeof domain, 'string');
assert.strictEqual(typeof options, 'object');
assert.strictEqual(typeof callback, 'function');
var acme = new Acme2(options || { });
acme.getCertificate(hostname, domain, callback);
}
+4 -3
View File
@@ -10,12 +10,13 @@ exports = module.exports = {
var assert = require('assert'),
debug = require('debug')('box:cert/caas.js');
function getCertificate(vhost, options, callback) {
assert.strictEqual(typeof vhost, 'string');
function getCertificate(hostname, domain, options, callback) {
assert.strictEqual(typeof hostname, 'string');
assert.strictEqual(typeof domain, 'string');
assert.strictEqual(typeof options, 'object');
assert.strictEqual(typeof callback, 'function');
debug('getCertificate: using fallback certificate', vhost);
debug('getCertificate: using fallback certificate', hostname);
return callback(null, '', '');
}
+4 -3
View File
@@ -10,12 +10,13 @@ exports = module.exports = {
var assert = require('assert'),
debug = require('debug')('box:cert/fallback.js');
function getCertificate(vhost, options, callback) {
assert.strictEqual(typeof vhost, 'string');
function getCertificate(hostname, domain, options, callback) {
assert.strictEqual(typeof hostname, 'string');
assert.strictEqual(typeof domain, 'string');
assert.strictEqual(typeof options, 'object');
assert.strictEqual(typeof callback, 'function');
debug('getCertificate: using fallback certificate', vhost);
debug('getCertificate: using fallback certificate', hostname);
return callback(null, '', '');
}
+2 -1
View File
@@ -12,7 +12,8 @@ exports = module.exports = {
var assert = require('assert');
function getCertificate(domain, options, callback) {
function getCertificate(hostname, domain, options, callback) {
assert.strictEqual(typeof hostname, 'string');
assert.strictEqual(typeof domain, 'string');
assert.strictEqual(typeof options, 'object');
assert.strictEqual(typeof callback, 'function');
+21 -8
View File
@@ -68,7 +68,7 @@ ClientsError.NOT_FOUND = 'Not found';
ClientsError.INTERNAL_ERROR = 'Internal Error';
ClientsError.NOT_ALLOWED = 'Not allowed to remove this client';
function validateName(name) {
function validateClientName(name) {
assert.strictEqual(typeof name, 'string');
if (name.length < 1) return new ClientsError(ClientsError.BAD_FIELD, 'Name must be atleast 1 character');
@@ -79,6 +79,14 @@ function validateName(name) {
return null;
}
function validateTokenName(name) {
assert.strictEqual(typeof name, 'string');
if (name.length > 64) return new ClientsError(ClientsError.BAD_FIELD, 'Name too long');
return null;
}
function add(appId, type, redirectURI, scope, callback) {
assert.strictEqual(typeof appId, 'string');
assert.strictEqual(typeof type, 'string');
@@ -89,7 +97,7 @@ function add(appId, type, redirectURI, scope, callback) {
var error = accesscontrol.validateScopeString(scope);
if (error) return callback(new ClientsError(ClientsError.INVALID_SCOPE, error.message));
error = validateName(appId);
error = validateClientName(appId);
if (error) return callback(error);
var id = 'cid-' + uuid.v4();
@@ -244,12 +252,17 @@ function delByAppIdAndType(appId, type, callback) {
});
}
function addTokenByUserId(clientId, userId, expiresAt, callback) {
function addTokenByUserId(clientId, userId, expiresAt, options, callback) {
assert.strictEqual(typeof clientId, 'string');
assert.strictEqual(typeof userId, 'string');
assert.strictEqual(typeof expiresAt, 'number');
assert.strictEqual(typeof options, 'object');
assert.strictEqual(typeof callback, 'function');
const name = options.name || '';
let error = validateTokenName(name);
if (error) return callback(error);
get(clientId, function (error, result) {
if (error) return callback(error);
@@ -265,7 +278,7 @@ function addTokenByUserId(clientId, userId, expiresAt, callback) {
var token = tokendb.generateToken();
tokendb.add(token, userId, result.id, expiresAt, authorizedScopes.join(','), function (error) {
tokendb.add(token, userId, result.id, expiresAt, authorizedScopes.join(','), name, function (error) {
if (error) return callback(new ClientsError(ClientsError.INTERNAL_ERROR, error));
callback(null, {
@@ -282,17 +295,17 @@ function addTokenByUserId(clientId, userId, expiresAt, callback) {
}
// this issues a cid-cli token that does not require a password in various routes
function issueDeveloperToken(userObject, ip, callback) {
function issueDeveloperToken(userObject, auditSource, callback) {
assert.strictEqual(typeof userObject, 'object');
assert.strictEqual(typeof ip, 'string');
assert.strictEqual(typeof auditSource, 'object');
assert.strictEqual(typeof callback, 'function');
const expiresAt = Date.now() + constants.DEFAULT_TOKEN_EXPIRATION;
addTokenByUserId('cid-cli', userObject.id, expiresAt, function (error, result) {
addTokenByUserId('cid-cli', userObject.id, expiresAt, {}, function (error, result) {
if (error) return callback(error);
eventlog.add(eventlog.ACTION_USER_LOGIN, { authType: 'cli', ip: ip }, { userId: userObject.id, user: users.removePrivateFields(userObject) });
eventlog.add(eventlog.ACTION_USER_LOGIN, auditSource, { userId: userObject.id, user: users.removePrivateFields(userObject) });
callback(null, result);
});
+182 -61
View File
@@ -8,32 +8,44 @@ exports = module.exports = {
getConfig: getConfig,
getDisks: getDisks,
getLogs: getLogs,
getStatus: getStatus,
reboot: reboot,
isRebootRequired: isRebootRequired,
onActivated: onActivated,
checkDiskSpace: checkDiskSpace
setDashboardDomain: setDashboardDomain,
renewCerts: renewCerts,
checkDiskSpace: checkDiskSpace,
configureWebadmin: configureWebadmin,
getWebadminStatus: getWebadminStatus
};
var assert = require('assert'),
async = require('async'),
clients = require('./clients.js'),
config = require('./config.js'),
cron = require('./cron.js'),
debug = require('debug')('box:cloudron'),
domains = require('./domains.js'),
DomainsError = require('./domains.js').DomainsError,
df = require('@sindresorhus/df'),
fs = require('fs'),
mailer = require('./mailer.js'),
os = require('os'),
path = require('path'),
paths = require('./paths.js'),
platform = require('./platform.js'),
progress = require('./progress.js'),
reverseProxy = require('./reverseproxy.js'),
safe = require('safetydance'),
settings = require('./settings.js'),
shell = require('./shell.js'),
spawn = require('child_process').spawn,
split = require('split'),
sysinfo = require('./sysinfo.js'),
tasks = require('./tasks.js'),
users = require('./users.js'),
util = require('util');
@@ -41,6 +53,16 @@ var REBOOT_CMD = path.join(__dirname, 'scripts/reboot.sh');
var NOOP_CALLBACK = function (error) { if (error) debug(error); };
let gWebadminStatus = {
dns: false,
tls: false,
configuring: false,
restore: {
active: false,
error: null
}
};
function CloudronError(reason, errorOrMessage) {
assert.strictEqual(typeof reason, 'string');
assert(errorOrMessage instanceof Error || typeof errorOrMessage === 'string' || typeof errorOrMessage === 'undefined');
@@ -65,41 +87,50 @@ CloudronError.INTERNAL_ERROR = 'Internal Error';
CloudronError.EXTERNAL_ERROR = 'External Error';
CloudronError.BAD_STATE = 'Bad state';
CloudronError.ALREADY_UPTODATE = 'No Update Available';
CloudronError.NOT_FOUND = 'Not found';
CloudronError.SELF_UPGRADE_NOT_SUPPORTED = 'Self upgrade not supported';
function initialize(callback) {
assert.strictEqual(typeof callback, 'function');
async.series([
settings.initialize,
reverseProxy.configureDefaultServer,
cron.initialize, // required for caas heartbeat before activation
onActivated
], callback);
cron.startPreActivationJobs(callback);
runStartupTasks();
}
function uninitialize(callback) {
assert.strictEqual(typeof callback, 'function');
async.series([
cron.uninitialize,
platform.stop,
settings.uninitialize
cron.stopJobs,
platform.stop
], callback);
}
function onActivated(callback) {
callback = callback || NOOP_CALLBACK;
assert.strictEqual(typeof callback, 'function');
// Starting the platform after a user is available means:
// 1. mail bounces can now be sent to the cloudron owner
// 2. the restore code path can run without sudo (since mail/ is non-root)
users.count(function (error, count) {
if (error) return callback(new CloudronError(CloudronError.INTERNAL_ERROR, error));
if (!count) return callback(); // not activated
async.series([
platform.start,
cron.startPostActivationJobs
], callback);
}
platform.start(callback);
// each of these tasks can fail. we will add some routes to fix/re-run them
function runStartupTasks() {
// configure nginx to be reachable by IP
reverseProxy.configureDefaultServer(NOOP_CALLBACK);
// always generate webadmin config since we have no versioning mechanism for the ejs
configureWebadmin(NOOP_CALLBACK);
// check activation state and start the platform
users.isActivated(function (error, activated) {
if (error) return debug(error);
if (!activated) return debug('initialize: not activated yet'); // not activated
onActivated(NOOP_CALLBACK);
});
}
@@ -143,18 +174,24 @@ function getConfig(callback) {
adminFqdn: config.adminFqdn(),
mailFqdn: config.mailFqdn(),
version: config.version(),
progress: progress.getAll(),
isDemo: config.isDemo(),
edition: config.edition(),
memory: os.totalmem(),
provider: config.provider(),
cloudronName: allSettings[settings.CLOUDRON_NAME_KEY],
spaces: allSettings[settings.SPACES_CONFIG_KEY] // here because settings route cannot be accessed by spaces users
cloudronName: allSettings[settings.CLOUDRON_NAME_KEY]
});
});
}
function reboot(callback) {
shell.sudo('reboot', [ REBOOT_CMD ], callback);
shell.sudo('reboot', [ REBOOT_CMD ], {}, callback);
}
function isRebootRequired(callback) {
assert.strictEqual(typeof callback, 'function');
// https://serverfault.com/questions/92932/how-does-ubuntu-keep-track-of-the-system-restart-required-flag-in-motd
callback(null, fs.existsSync('/var/run/reboot-required'));
}
function checkDiskSpace(callback) {
@@ -216,49 +253,28 @@ function getLogs(unit, options, callback) {
debug('Getting logs for %s as %s', unit, format);
var cp, transformStream;
if (unit === 'box') {
let args = [ '--no-pager', `--lines=${lines}` ];
if (format === 'short') args.push('--output=short', '-a'); else args.push('--output=json');
if (follow) args.push('--follow');
args.push('--unit=box');
args.push('--unit=cloudron-updater');
cp = spawn('/bin/journalctl', args);
let args = [ '--lines=' + lines ];
if (follow) args.push('--follow');
transformStream = split(function mapper(line) {
if (format !== 'json') return line + '\n';
// need to handle box.log without subdir
if (unit === 'box') args.push(path.join(paths.LOG_DIR, 'box.log'));
else args.push(path.join(paths.LOG_DIR, unit, 'app.log'));
var obj = safe.JSON.parse(line);
if (!obj) return undefined;
var cp = spawn('/usr/bin/tail', args);
return JSON.stringify({
realtimeTimestamp: obj.__REALTIME_TIMESTAMP,
monotonicTimestamp: obj.__MONOTONIC_TIMESTAMP,
message: obj.MESSAGE,
source: obj.SYSLOG_IDENTIFIER || ''
}) + '\n';
});
} else { // mail, mongodb, mysql, postgresql, backup
let args = [ '--lines=' + lines ];
if (follow) args.push('--follow');
args.push(path.join(paths.LOG_DIR, unit, 'app.log'));
var transformStream = split(function mapper(line) {
if (format !== 'json') return line + '\n';
cp = spawn('/usr/bin/tail', args);
var data = line.split(' '); // logs are <ISOtimestamp> <msg>
var timestamp = (new Date(data[0])).getTime();
if (isNaN(timestamp)) timestamp = 0;
transformStream = split(function mapper(line) {
if (format !== 'json') return line + '\n';
var data = line.split(' '); // logs are <ISOtimestamp> <msg>
var timestamp = (new Date(data[0])).getTime();
if (isNaN(timestamp)) timestamp = 0;
return JSON.stringify({
realtimeTimestamp: timestamp * 1000,
message: line.slice(data[0].length+1),
source: unit
}) + '\n';
});
}
return JSON.stringify({
realtimeTimestamp: timestamp * 1000,
message: line.slice(data[0].length+1),
source: unit
}) + '\n';
});
transformStream.close = cp.kill.bind(cp, 'SIGKILL'); // closing stream kills the child process
@@ -266,3 +282,108 @@ function getLogs(unit, options, callback) {
return callback(null, transformStream);
}
function configureWebadmin(callback) {
assert.strictEqual(typeof callback, 'function');
debug('configureWebadmin: adminDomain:%s status:%j', config.adminDomain(), gWebadminStatus);
if (process.env.BOX_ENV === 'test' || !config.adminDomain() || gWebadminStatus.configuring) return callback();
gWebadminStatus.configuring = true; // re-entracy guard
function configureReverseProxy(error) {
debug('configureReverseProxy: error %j', error || null);
reverseProxy.configureAdmin({ userId: null, username: 'setup' }, function (error) {
debug('configureWebadmin: done error: %j', error || {});
gWebadminStatus.configuring = false;
if (error) return callback(error);
gWebadminStatus.tls = true;
callback();
});
}
// update the DNS. configure nginx regardless of whether it succeeded so that
// box is accessible even if dns creds are invalid
sysinfo.getPublicIp(function (error, ip) {
if (error) return configureReverseProxy(error);
domains.upsertDnsRecords(config.adminLocation(), config.adminDomain(), 'A', [ ip ], function (error) {
debug('addWebadminDnsRecord: updated records with error:', error);
if (error) return configureReverseProxy(error);
domains.waitForDnsRecord(config.adminLocation(), config.adminDomain(), 'A', ip, { interval: 30000, times: 50000 }, function (error) {
if (error) return configureReverseProxy(error);
gWebadminStatus.dns = true;
configureReverseProxy();
});
});
});
}
function getWebadminStatus() {
return gWebadminStatus;
}
function getStatus(callback) {
assert.strictEqual(typeof callback, 'function');
users.isActivated(function (error, activated) {
if (error) return callback(new CloudronError(CloudronError.INTERNAL_ERROR, error));
settings.getCloudronName(function (error, cloudronName) {
if (error) return callback(new CloudronError(CloudronError.INTERNAL_ERROR, error));
callback(null, {
version: config.version(),
apiServerOrigin: config.apiServerOrigin(), // used by CaaS tool
provider: config.provider(),
cloudronName: cloudronName,
adminFqdn: config.adminDomain() ? config.adminFqdn() : null,
activated: activated,
edition: config.edition(),
webadminStatus: gWebadminStatus // only valid when !activated
});
});
});
}
function setDashboardDomain(domain, callback) {
assert.strictEqual(typeof domain, 'string');
assert.strictEqual(typeof callback, 'function');
debug(`setDashboardDomain: ${domain}`);
domains.get(domain, function (error, result) {
if (error && error.reason === DomainsError.NOT_FOUND) return callback(new CloudronError(CloudronError.BAD_FIELD, 'No such domain'));
if (error) return callback(new CloudronError(CloudronError.INTERNAL_ERROR, error));
config.setAdminDomain(result.domain);
config.setAdminLocation('my');
config.setAdminFqdn('my' + (result.config.hyphenatedSubdomains ? '-' : '.') + result.domain);
clients.addDefaultClients(config.adminOrigin(), function (error) {
if (error) return callback(new CloudronError(CloudronError.INTERNAL_ERROR, error));
callback(null);
configureWebadmin(NOOP_CALLBACK); // ## trigger as task
});
});
}
function renewCerts(options, auditSource, callback) {
assert.strictEqual(typeof options, 'object');
assert.strictEqual(typeof auditSource, 'object');
assert.strictEqual(typeof callback, 'function');
let task = tasks.startTask(tasks.TASK_RENEW_CERTS, [ options, auditSource ]);
task.on('error', (error) => callback(new CloudronError(CloudronError.INTERNAL_ERROR, error)));
task.on('start', (taskId) => callback(null, taskId));
}
+25 -16
View File
@@ -22,8 +22,8 @@ exports = module.exports = {
setAdminFqdn: setAdminFqdn,
setAdminLocation: setAdminLocation,
version: version,
setVersion: setVersion,
database: database,
edition: edition,
// these values are derived
adminOrigin: adminOrigin,
@@ -36,8 +36,12 @@ exports = module.exports = {
hasIPv6: hasIPv6,
dkimSelector: dkimSelector,
isManaged: isManaged,
isDemo: isDemo,
// feature flags based on editions (these have a separate license from standard edition)
isSpacesEnabled: isSpacesEnabled,
// for testing resets to defaults
_reset: _reset
};
@@ -52,31 +56,28 @@ var assert = require('assert'),
// assert on unknown environment can't proceed
assert(exports.CLOUDRON || exports.TEST, 'Unknown environment. This should not happen!');
var homeDir = process.env.HOME || process.env.HOMEPATH || process.env.USERPROFILE;
var data = { };
function baseDir() {
const homeDir = process.env.HOME || process.env.HOMEPATH || process.env.USERPROFILE;
if (exports.CLOUDRON) return homeDir;
if (exports.TEST) return path.join(homeDir, '.cloudron_test');
// cannot reach
}
var cloudronConfigFileName = path.join(baseDir(), 'configs/cloudron.conf');
// only tests can run without a config file on disk, they use the defaults with runtime overrides
if (exports.CLOUDRON) assert(fs.existsSync(cloudronConfigFileName), 'No cloudron.conf found, cannot proceed');
const cloudronConfigFileName = exports.CLOUDRON ? '/etc/cloudron/cloudron.conf' : path.join(baseDir(), 'cloudron.conf');
function saveSync() {
// only save values we want to have in the cloudron.conf, see start.sh
var conf = {
version: data.version,
apiServerOrigin: data.apiServerOrigin,
webServerOrigin: data.webServerOrigin,
adminDomain: data.adminDomain,
adminFqdn: data.adminFqdn,
adminLocation: data.adminLocation,
provider: data.provider,
isDemo: data.isDemo
isDemo: data.isDemo,
edition: data.edition
};
fs.writeFileSync(cloudronConfigFileName, JSON.stringify(conf, null, 4)); // functions are ignored by JSON.stringify
@@ -96,13 +97,14 @@ function initConfig() {
data.adminDomain = '';
data.adminLocation = 'my';
data.port = 3000;
data.version = null;
data.apiServerOrigin = null;
data.webServerOrigin = null;
data.provider = 'generic';
data.smtpPort = 2525; // this value comes from mail container
data.sysadminPort = 3001;
data.ldapPort = 3002;
data.dockerProxyPort = 3003;
data.edition = '';
// keep in sync with start.sh
data.database = {
@@ -115,7 +117,6 @@ function initConfig() {
// overrides for local testings
if (exports.TEST) {
data.version = '1.1.1-test';
data.port = 5454;
data.apiServerOrigin = 'http://localhost:6060'; // hock doesn't support https
data.database.password = '';
@@ -204,11 +205,8 @@ function sysadminOrigin() {
}
function version() {
return get('version');
}
function setVersion(version) {
set('version', version);
if (exports.TEST) return '3.0.0-test';
return fs.readFileSync(path.join(__dirname, '../VERSION'), 'utf8').trim();
}
function database() {
@@ -219,10 +217,18 @@ function isDemo() {
return get('isDemo') === true;
}
function isSpacesEnabled() {
return get('edition') === 'education';
}
function provider() {
return get('provider');
}
function isManaged() {
return edition() === 'hostingprovider';
}
function hasIPv6() {
const IPV6_PROC_FILE = '/proc/net/if_inet6';
return fs.existsSync(IPV6_PROC_FILE);
@@ -235,3 +241,6 @@ function dkimSelector() {
return loc === 'my' ? 'cloudron' : `cloudron-${loc.replace(/\./g, '')}`;
}
function edition() {
return get('edition');
}
+1 -2
View File
@@ -19,9 +19,8 @@ exports = module.exports = {
ADMIN_NAME: 'Settings',
ADMIN_CLIENT_ID: 'webadmin', // oauth client id
NGINX_ADMIN_CONFIG_FILE_NAME: 'admin.conf',
NGINX_DEFAULT_CONFIG_FILE_NAME: 'default.conf',
GHOST_USER_FILE: '/tmp/cloudron_ghost.json',
+38 -22
View File
@@ -1,11 +1,14 @@
'use strict';
exports = module.exports = {
initialize: initialize,
uninitialize: uninitialize
startPostActivationJobs: startPostActivationJobs,
startPreActivationJobs: startPreActivationJobs,
stopJobs: stopJobs
};
var apps = require('./apps.js'),
var appHealthMonitor = require('./apphealthmonitor.js'),
apps = require('./apps.js'),
appstore = require('./appstore.js'),
assert = require('assert'),
backups = require('./backups.js'),
@@ -40,8 +43,9 @@ var gJobs = {
cleanupTokens: null,
digestEmail: null,
dockerVolumeCleaner: null,
dynamicDNS: null,
schedulerSync: null
dynamicDns: null,
schedulerSync: null,
appHealthMonitor: null
};
var NOOP_CALLBACK = function (error) { if (error) console.error(error); };
@@ -55,9 +59,7 @@ var AUDIT_SOURCE = { userId: null, username: 'cron' };
// Months: 0-11
// Day of Week: 0-6
function initialize(callback) {
assert.strictEqual(typeof callback, 'function');
function startPreActivationJobs(callback) {
if (config.provider() === 'caas') {
// hack: send the first heartbeat only after we are running for 60 seconds
// required as we end up sending a heartbeat and then cloudron-setup reboots the server
@@ -71,6 +73,12 @@ function initialize(callback) {
});
}
callback();
}
function startPostActivationJobs(callback) {
assert.strictEqual(typeof callback, 'function');
var randomHourMinute = Math.floor(60*Math.random());
gJobs.alive = new CronJob({
cronTime: '00 ' + randomHourMinute + ' * * * *', // every hour on a random minute
@@ -78,10 +86,10 @@ function initialize(callback) {
start: true
});
settings.events.on(settings.TIME_ZONE_KEY, recreateJobs);
settings.events.on(settings.APP_AUTOUPDATE_PATTERN_KEY, appAutoupdatePatternChanged);
settings.events.on(settings.BOX_AUTOUPDATE_PATTERN_KEY, boxAutoupdatePatternChanged);
settings.events.on(settings.DYNAMIC_DNS_KEY, dynamicDnsChanged);
settings.on(settings.TIME_ZONE_KEY, recreateJobs);
settings.on(settings.APP_AUTOUPDATE_PATTERN_KEY, appAutoupdatePatternChanged);
settings.on(settings.BOX_AUTOUPDATE_PATTERN_KEY, boxAutoupdatePatternChanged);
settings.on(settings.DYNAMIC_DNS_KEY, dynamicDnsChanged);
settings.getAll(function (error, allSettings) {
if (error) return callback(error);
@@ -102,7 +110,7 @@ function recreateJobs(tz) {
if (gJobs.backup) gJobs.backup.stop();
gJobs.backup = new CronJob({
cronTime: '00 00 */6 * * *', // every 6 hours. backups.ensureBackup() will only trigger a backup once per day
cronTime: '00 00 */6 * * *', // check every 6 hours
onTick: backups.ensureBackup.bind(null, AUDIT_SOURCE, NOOP_CALLBACK),
start: true,
timeZone: tz
@@ -178,7 +186,7 @@ function recreateJobs(tz) {
if (gJobs.certificateRenew) gJobs.certificateRenew.stop();
gJobs.certificateRenew = new CronJob({
cronTime: '00 00 */12 * * *', // every 12 hours
onTick: reverseProxy.renewAll.bind(null, AUDIT_SOURCE, NOOP_CALLBACK),
onTick: cloudron.renewCerts.bind(null, {}, AUDIT_SOURCE, NOOP_CALLBACK),
start: true,
timeZone: tz
});
@@ -190,6 +198,14 @@ function recreateJobs(tz) {
start: true,
timeZone: tz
});
if (gJobs.appHealthMonitor) gJobs.appHealthMonitor.stop();
gJobs.appHealthMonitor = new CronJob({
cronTime: '*/10 * * * * *', // every 10 seconds
onTick: appHealthMonitor.run.bind(null, 10),
start: true,
timeZone: tz
});
}
function boxAutoupdatePatternChanged(pattern) {
@@ -251,25 +267,25 @@ function dynamicDnsChanged(enabled) {
debug('Dynamic DNS setting changed to %s', enabled);
if (enabled) {
gJobs.dynamicDNS = new CronJob({
gJobs.dynamicDns = new CronJob({
cronTime: '00 */10 * * * *',
onTick: dyndns.sync,
start: true,
timeZone: gJobs.boxUpdateCheckerJob.cronTime.zone // hack
});
} else {
if (gJobs.dynamicDNS) gJobs.dynamicDNS.stop();
gJobs.dynamicDNS = null;
if (gJobs.dynamicDns) gJobs.dynamicDns.stop();
gJobs.dynamicDns = null;
}
}
function uninitialize(callback) {
function stopJobs(callback) {
assert.strictEqual(typeof callback, 'function');
settings.events.removeListener(settings.TIME_ZONE_KEY, recreateJobs);
settings.events.removeListener(settings.APP_AUTOUPDATE_PATTERN_KEY, appAutoupdatePatternChanged);
settings.events.removeListener(settings.BOX_AUTOUPDATE_PATTERN_KEY, boxAutoupdatePatternChanged);
settings.events.removeListener(settings.DYNAMIC_DNS_KEY, dynamicDnsChanged);
settings.removeListener(settings.TIME_ZONE_KEY, recreateJobs);
settings.removeListener(settings.APP_AUTOUPDATE_PATTERN_KEY, appAutoupdatePatternChanged);
settings.removeListener(settings.BOX_AUTOUPDATE_PATTERN_KEY, boxAutoupdatePatternChanged);
settings.removeListener(settings.DYNAMIC_DNS_KEY, dynamicDnsChanged);
for (var job in gJobs) {
if (!gJobs[job]) continue;
+2 -1
View File
@@ -130,7 +130,8 @@ function verifyDnsConfig(dnsConfig, domain, zoneName, ip, callback) {
var credentials = {
token: dnsConfig.token,
fqdn: domain
fqdn: domain,
hyphenatedSubdomains: true // this will ensure we always use them, regardless of passed-in configs
};
const testSubdomain = 'cloudrontestdns';
+2 -7
View File
@@ -28,12 +28,7 @@ function translateRequestError(result, callback) {
if (result.statusCode === 422) return callback(new DomainsError(DomainsError.BAD_FIELD, result.body.message));
if ((result.statusCode === 400 || result.statusCode === 401 || result.statusCode === 403) && result.body.errors.length > 0) {
let error = result.body.errors[0];
let message = error.message;
if (error.code === 6003) {
if (error.error_chain[0] && error.error_chain[0].code === 6103) message = 'Invalid API Key';
else message = 'Invalid credentials';
}
let message = `message: ${error.message} statusCode: ${result.statusCode} code:${error.code}`;
return callback(new DomainsError(DomainsError.ACCESS_DENIED, message));
}
@@ -112,7 +107,7 @@ function upsert(dnsConfig, zoneName, subdomain, type, values, callback) {
var priority = null;
if (type === 'MX') {
priority = value.split(' ')[0];
priority = parseInt(value.split(' ')[0], 10);
value = value.split(' ')[1];
}
+3 -2
View File
@@ -21,6 +21,7 @@ const GODADDY_API = 'https://api.godaddy.com/v1/domains';
// this is a workaround for godaddy not having a delete API
// https://stackoverflow.com/questions/39347464/delete-record-libcloud-godaddy-api
const GODADDY_INVALID_IP = '0.0.0.0';
const GODADDY_INVALID_TXT = '""';
function formatError(response) {
return util.format(`GoDaddy DNS error [${response.statusCode}] ${response.body.message}`);
@@ -109,7 +110,7 @@ function del(dnsConfig, zoneName, subdomain, type, values, callback) {
debug(`get: ${subdomain} in zone ${zoneName} of type ${type} with values ${JSON.stringify(values)}`);
if (type !== 'A') return callback(new DomainsError(DomainsError.EXTERNAL_ERROR, new Error('Not supported by GoDaddy API'))); // can never happen
if (type !== 'A' && type !== 'TXT') return callback(new DomainsError(DomainsError.EXTERNAL_ERROR, new Error('Record deletion is not supported by GoDaddy API')));
// check if the record exists at all so that we don't insert the "Dead" record for no reason
get(dnsConfig, zoneName, subdomain, type, function (error, values) {
@@ -119,7 +120,7 @@ function del(dnsConfig, zoneName, subdomain, type, values, callback) {
// godaddy does not have a delete API. so fill it up with an invalid IP that we can ignore in future get()
var records = [{
ttl: 600,
data: GODADDY_INVALID_IP
data: type === 'A' ? GODADDY_INVALID_IP : GODADDY_INVALID_TXT
}];
superagent.put(`${GODADDY_API}/${zoneName}/records/${type}/${subdomain}`)
+1 -1
View File
@@ -60,6 +60,6 @@ function verifyDnsConfig(dnsConfig, domain, zoneName, ip, callback) {
if (error && error.code === 'ENOTFOUND') return callback(new DomainsError(DomainsError.BAD_FIELD, 'Unable to resolve nameservers for this domain'));
if (error || !nameservers) return callback(new DomainsError(DomainsError.BAD_FIELD, error ? error.message : 'Unable to get nameservers'));
callback(null, { wildcard: !!dnsConfig.wildcard });
callback(null, {});
});
}
+3
View File
@@ -208,6 +208,9 @@ function verifyDnsConfig(dnsConfig, fqdn, zoneName, ip, callback) {
assert.strictEqual(typeof ip, 'string');
assert.strictEqual(typeof callback, 'function');
if (typeof dnsConfig.username !== 'string') return callback(new DomainsError(DomainsError.BAD_FIELD, 'username must be a string'));
if (typeof dnsConfig.token !== 'string') return callback(new DomainsError(DomainsError.BAD_FIELD, 'token must be a string'));
var credentials = {
username: dnsConfig.username,
token: dnsConfig.token
+2 -1
View File
@@ -46,9 +46,10 @@ function del(dnsConfig, zoneName, subdomain, type, values, callback) {
return callback();
}
function waitForDns(domain, zoneName, value, options, callback) {
function waitForDns(domain, zoneName, type, value, options, callback) {
assert.strictEqual(typeof domain, 'string');
assert.strictEqual(typeof zoneName, 'string');
assert.strictEqual(typeof type, 'string');
assert.strictEqual(typeof value, 'string');
assert(options && typeof options === 'object'); // { interval: 5000, times: 50000 }
assert.strictEqual(typeof callback, 'function');
+2 -2
View File
@@ -114,7 +114,7 @@ function add(dnsConfig, zoneName, subdomain, type, values, callback) {
};
var route53 = new AWS.Route53(getDnsCredentials(dnsConfig));
route53.changeResourceRecordSets(params, function(error, result) {
route53.changeResourceRecordSets(params, function(error) {
if (error && error.code === 'AccessDenied') return callback(new DomainsError(DomainsError.ACCESS_DENIED, error.message));
if (error && error.code === 'InvalidClientTokenId') return callback(new DomainsError(DomainsError.ACCESS_DENIED, error.message));
if (error && error.code === 'PriorRequestNotComplete') return callback(new DomainsError(DomainsError.STILL_BUSY, error.message));
@@ -241,7 +241,7 @@ function verifyDnsConfig(dnsConfig, fqdn, zoneName, ip, callback) {
secretAccessKey: dnsConfig.secretAccessKey,
region: dnsConfig.region || 'us-east-1',
endpoint: dnsConfig.endpoint || null,
listHostedZonesByName: true // new/updated creds require this perm
listHostedZonesByName: true, // new/updated creds require this perm
};
if (process.env.BOX_ENV === 'test') return callback(null, credentials); // this shouldn't be here
+20 -8
View File
@@ -30,8 +30,9 @@ function resolveIp(hostname, options, callback) {
});
}
function isChangeSynced(domain, value, nameserver, callback) {
function isChangeSynced(domain, type, value, nameserver, callback) {
assert.strictEqual(typeof domain, 'string');
assert.strictEqual(typeof type, 'string');
assert.strictEqual(typeof value, 'string');
assert.strictEqual(typeof nameserver, 'string');
assert.strictEqual(typeof callback, 'function');
@@ -44,20 +45,30 @@ function isChangeSynced(domain, value, nameserver, callback) {
}
async.every(nsIps, function (nsIp, iteratorCallback) {
resolveIp(domain, { server: nsIp, timeout: 5000 }, function (error, answer) {
const resolveOptions = { server: nsIp, timeout: 5000 };
const resolver = type === 'A' ? resolveIp.bind(null, domain) : dns.resolve.bind(null, domain, 'TXT');
resolver(resolveOptions, function (error, answer) {
if (error && error.code === 'TIMEOUT') {
debug(`isChangeSynced: NS ${nameserver} (${nsIp}) timed out when resolving ${domain}`);
debug(`isChangeSynced: NS ${nameserver} (${nsIp}) timed out when resolving ${domain} (${type})`);
return iteratorCallback(null, true); // should be ok if dns server is down
}
if (error) {
debug(`isChangeSynced: NS ${nameserver} (${nsIp}) errored when resolve ${domain}: ${error}`);
debug(`isChangeSynced: NS ${nameserver} (${nsIp}) errored when resolve ${domain} (${type}): ${error}`);
return iteratorCallback(null, false);
}
debug(`isChangeSynced: ${domain} was resolved to ${answer} at NS ${nameserver} (${nsIp}). Expecting ${value}`);
let match;
if (type === 'A') {
match = answer.length === 1 && answer[0] === value;
} else if (type === 'TXT') { // answer is a 2d array of strings
match = answer.some(function (a) { return value === a.join(''); });
}
iteratorCallback(null, answer.length === 1 && answer[0] === value);
debug(`isChangeSynced: ${domain} (${type}) was resolved to ${answer} at NS ${nameserver} (${nsIp}). Expecting ${value}. Match ${match}`);
iteratorCallback(null, match);
});
}, callback);
@@ -65,9 +76,10 @@ function isChangeSynced(domain, value, nameserver, callback) {
}
// check if IP change has propagated to every nameserver
function waitForDns(domain, zoneName, value, options, callback) {
function waitForDns(domain, zoneName, type, value, options, callback) {
assert.strictEqual(typeof domain, 'string');
assert.strictEqual(typeof zoneName, 'string');
assert(type === 'A' || type === 'TXT');
assert.strictEqual(typeof value, 'string');
assert(options && typeof options === 'object'); // { interval: 5000, times: 50000 }
assert.strictEqual(typeof callback, 'function');
@@ -82,7 +94,7 @@ function waitForDns(domain, zoneName, value, options, callback) {
dns.resolve(zoneName, 'NS', { timeout: 5000 }, function (error, nameservers) {
if (error || !nameservers) return retryCallback(error || new DomainsError(DomainsError.EXTERNAL_ERROR, 'Unable to get nameservers'));
async.every(nameservers, isChangeSynced.bind(null, domain, value), function (error, synced) {
async.every(nameservers, isChangeSynced.bind(null, domain, type, value), function (error, synced) {
debug('waitForDns: %s %s ns: %j', domain, synced ? 'done' : 'not done', nameservers);
retryCallback(synced ? null : new DomainsError(DomainsError.EXTERNAL_ERROR, 'ETRYAGAIN'));
+79
View File
@@ -0,0 +1,79 @@
'use strict';
exports = module.exports = {
upsert: upsert,
get: get,
del: del,
waitForDns: require('./waitfordns.js'),
verifyDnsConfig: verifyDnsConfig
};
var assert = require('assert'),
debug = require('debug')('box:dns/manual'),
dns = require('../native-dns.js'),
DomainsError = require('../domains.js').DomainsError,
sysinfo = require('../sysinfo.js'),
util = require('util');
function upsert(dnsConfig, zoneName, subdomain, type, values, callback) {
assert.strictEqual(typeof dnsConfig, 'object');
assert.strictEqual(typeof zoneName, 'string');
assert.strictEqual(typeof subdomain, 'string');
assert.strictEqual(typeof type, 'string');
assert(util.isArray(values));
assert.strictEqual(typeof callback, 'function');
debug('upsert: %s for zone %s of type %s with values %j', subdomain, zoneName, type, values);
return callback(null);
}
function get(dnsConfig, zoneName, subdomain, type, callback) {
assert.strictEqual(typeof dnsConfig, 'object');
assert.strictEqual(typeof zoneName, 'string');
assert.strictEqual(typeof subdomain, 'string');
assert.strictEqual(typeof type, 'string');
assert.strictEqual(typeof callback, 'function');
callback(null, [ ]); // returning ip confuses apptask into thinking the entry already exists
}
function del(dnsConfig, zoneName, subdomain, type, values, callback) {
assert.strictEqual(typeof dnsConfig, 'object');
assert.strictEqual(typeof zoneName, 'string');
assert.strictEqual(typeof subdomain, 'string');
assert.strictEqual(typeof type, 'string');
assert(util.isArray(values));
assert.strictEqual(typeof callback, 'function');
return callback();
}
function verifyDnsConfig(dnsConfig, domain, zoneName, ip, callback) {
assert.strictEqual(typeof dnsConfig, 'object');
assert.strictEqual(typeof domain, 'string');
assert.strictEqual(typeof zoneName, 'string');
assert.strictEqual(typeof ip, 'string');
assert.strictEqual(typeof callback, 'function');
// Very basic check if the nameservers can be fetched
dns.resolve(zoneName, 'NS', { timeout: 5000 }, function (error, nameservers) {
if (error && error.code === 'ENOTFOUND') return callback(new DomainsError(DomainsError.BAD_FIELD, 'Unable to resolve nameservers for this domain'));
if (error || !nameservers) return callback(new DomainsError(DomainsError.BAD_FIELD, error ? error.message : 'Unable to get nameservers'));
const separator = dnsConfig.hyphenatedSubdomains ? '-' : '.';
const fqdn = `cloudrontest${separator}${domain}`;
dns.resolve(fqdn, 'A', { server: '127.0.0.1', timeout: 5000 }, function (error, result) {
if (error && error.code === 'ENOTFOUND') return callback(new DomainsError(DomainsError.BAD_FIELD, `Unable to resolve ${fqdn}`));
if (error || !result) return callback(new DomainsError(DomainsError.BAD_FIELD, error ? error.message : `Unable to resolve ${fqdn}`));
sysinfo.getPublicIp(function (error, ip) {
if (error) return callback(new DomainsError(DomainsError.EXTERNAL_ERROR, `Failed to detect IP of this server: ${error.message}`));
if (result.length !== 1 || ip !== result[0]) return callback(new DomainsError(DomainsError.EXTERNAL_ERROR, `Domain resolves to ${JSON.stringify(result)} instead of ${ip}`));
callback(null, {});
});
});
});
}
+176 -20
View File
@@ -1,7 +1,13 @@
'use strict';
exports = module.exports = {
DockerError: DockerError,
connection: connectionInstance(),
setRegistryConfig: setRegistryConfig,
ping: ping,
downloadImage: downloadImage,
createContainer: createContainer,
startContainer: startContainer,
@@ -16,21 +22,26 @@ exports = module.exports = {
getContainerIdByIp: getContainerIdByIp,
inspect: inspect,
inspectByName: inspect,
execContainer: execContainer
memoryUsage: memoryUsage,
execContainer: execContainer,
createVolume: createVolume,
removeVolume: removeVolume,
clearVolume: clearVolume
};
function connectionInstance() {
// timeout is optional
function connectionInstance(timeout) {
var Docker = require('dockerode');
var docker;
if (process.env.BOX_ENV === 'test') {
// test code runs a docker proxy on this port
docker = new Docker({ host: 'http://localhost', port: 5687 });
docker = new Docker({ host: 'http://localhost', port: 5687, timeout: timeout });
// proxy code uses this to route to the real docker
docker.options = { socketPath: '/var/run/docker.sock' };
} else {
docker = new Docker({ socketPath: '/var/run/docker.sock' });
docker = new Docker({ socketPath: '/var/run/docker.sock', timeout: timeout });
}
return docker;
@@ -43,25 +54,83 @@ var addons = require('./addons.js'),
config = require('./config.js'),
constants = require('./constants.js'),
debug = require('debug')('box:docker.js'),
mkdirp = require('mkdirp'),
once = require('once'),
path = require('path'),
paths = require('./paths.js'),
safe = require('safetydance'),
shell = require('./shell.js'),
spawn = child_process.spawn,
util = require('util'),
_ = require('underscore');
const RMVOLUME_CMD = path.join(__dirname, 'scripts/rmvolume.sh');
function DockerError(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(DockerError, Error);
DockerError.INTERNAL_ERROR = 'Internal Error';
DockerError.NOT_FOUND = 'Not found';
DockerError.BAD_FIELD = 'Bad field';
function debugApp(app, args) {
assert(typeof app === 'object');
debug(app.fqdn + ' ' + util.format.apply(util, Array.prototype.slice.call(arguments, 1)));
}
function setRegistryConfig(auth, callback) {
assert.strictEqual(typeof auth, 'object');
assert.strictEqual(typeof callback, 'function');
const isLogin = !!auth.password;
// currently, auth info is not stashed in the db but maybe it should for restore to work?
const cmd = isLogin ? `docker login ${auth.serveraddress} --username ${auth.username} --password ${auth.password}` : `docker logout ${auth.serveraddress}`;
child_process.exec(cmd, { }, function (error, stdout, stderr) {
if (error) return callback(new DockerError(DockerError.BAD_FIELD, stderr));
callback();
});
}
function ping(callback) {
assert.strictEqual(typeof callback, 'function');
// do not let the request linger
var docker = connectionInstance(1000);
docker.ping(function (error, result) {
if (error) return callback(new DockerError(DockerError.INTERNAL_ERROR, error));
if (result !== 'OK') return callback(new DockerError(DockerError.INTERNAL_ERROR, 'Unable to ping the docker daemon'));
callback(null);
});
}
function pullImage(manifest, callback) {
var docker = exports.connection;
// Use docker CLI here to support downloading of private repos. for dockerode, we have to use
// https://github.com/apocas/dockerode#pull-from-private-repos
shell.exec('pullImage', '/usr/bin/docker', [ 'pull', manifest.dockerImage ], { }, function (error) {
shell.spawn('pullImage', '/usr/bin/docker', [ 'pull', manifest.dockerImage ], {}, function (error) {
if (error) {
debug(`pullImage: Error pulling image ${manifest.dockerImage} of ${manifest.id}: ${error.message}`);
return callback(new Error('Failed to pull image'));
@@ -113,8 +182,10 @@ function createSubcontainer(app, name, cmd, options, callback) {
var manifest = app.manifest;
var exposedPorts = {}, dockerPortBindings = { };
var domain = app.fqdn;
// TODO: these should all have the CLOUDRON_ prefix
var stdEnv = [
'CLOUDRON=1',
'CLOUDRON_PROXY_IP=172.18.0.1',
'WEBADMIN_ORIGIN=' + config.adminOrigin(),
'API_ORIGIN=' + config.adminOrigin(),
'APP_ORIGIN=https://' + domain,
@@ -127,16 +198,22 @@ function createSubcontainer(app, name, cmd, options, callback) {
dockerPortBindings[manifest.httpPort + '/tcp'] = [ { HostIp: '127.0.0.1', HostPort: app.httpPort + '' } ];
var portEnv = [];
for (var e in app.portBindings) {
var hostPort = app.portBindings[e];
var containerPort = manifest.tcpPorts[e].containerPort || hostPort;
for (let portName in app.portBindings) {
const hostPort = app.portBindings[portName];
const portType = portName in manifest.tcpPorts ? 'tcp' : 'udp';
const ports = portType == 'tcp' ? manifest.tcpPorts : manifest.udpPorts;
exposedPorts[containerPort + '/tcp'] = {};
portEnv.push(e + '=' + hostPort);
var containerPort = ports[portName].containerPort || hostPort;
dockerPortBindings[containerPort + '/tcp'] = [ { HostIp: '0.0.0.0', HostPort: hostPort + '' } ];
exposedPorts[`${containerPort}/${portType}`] = {};
portEnv.push(`${portName}=${hostPort}`);
dockerPortBindings[`${containerPort}/${portType}`] = [ { HostIp: '0.0.0.0', HostPort: hostPort + '' } ];
}
let appEnv = [];
Object.keys(app.env).forEach(function (name) { appEnv.push(`${name}=${app.env[name]}`); });
// first check db record, then manifest
var memoryLimit = app.memoryLimit || manifest.memoryLimit || 0;
@@ -150,9 +227,6 @@ function createSubcontainer(app, name, cmd, options, callback) {
// if required, we can make this a manifest and runtime argument later
if (!isAppContainer) memoryLimit *= 2;
// apparmor is disabled on few servers
var enableSecurityOpt = config.CLOUDRON && safe(function () { return child_process.spawnSync('aa-enabled').status === 0; }, false);
addons.getEnvironment(app, function (error, addonEnv) {
if (error) return callback(new Error('Error getting addon environment : ' + error));
@@ -164,9 +238,10 @@ function createSubcontainer(app, name, cmd, options, callback) {
var containerOptions = {
name: name, // used for filtering logs
Tty: isAppContainer,
Hostname: app.id, // set to something 'constant' so app containers can use this to communicate (across app updates)
Image: app.manifest.dockerImage,
Cmd: (isAppContainer && app.debugMode && app.debugMode.cmd) ? app.debugMode.cmd : cmd,
Env: stdEnv.concat(addonEnv).concat(portEnv),
Env: stdEnv.concat(addonEnv).concat(portEnv).concat(appEnv),
ExposedPorts: isAppContainer ? exposedPorts : { },
Volumes: { // see also ReadonlyRootfs
'/tmp': {},
@@ -175,10 +250,11 @@ function createSubcontainer(app, name, cmd, options, callback) {
Labels: {
'fqdn': app.fqdn,
'appId': app.id,
'isSubcontainer': String(!isAppContainer)
'isSubcontainer': String(!isAppContainer),
'isCloudronManaged': String(true)
},
HostConfig: {
Binds: addons.getBindsSync(app, app.manifest.addons),
Mounts: addons.getMountsSync(app, app.manifest.addons),
LogConfig: {
Type: 'syslog',
Config: {
@@ -201,7 +277,7 @@ function createSubcontainer(app, name, cmd, options, callback) {
NetworkMode: 'cloudron',
Dns: ['172.18.0.1'], // use internal dns
DnsSearch: ['.'], // use internal dns
SecurityOpt: enableSecurityOpt ? [ 'apparmor=docker-cloudron-app' ] : null // profile available only on cloudron
SecurityOpt: [ 'apparmor=docker-cloudron-app' ]
}
};
@@ -349,7 +425,8 @@ function deleteImage(manifest, callback) {
// just removes the tag). we used to remove the image by id. this is not required anymore because aliases are
// not created anymore after https://github.com/docker/docker/pull/10571
docker.getImage(dockerImage).remove(removeOptions, function (error) {
if (error && error.statusCode === 404) return callback(null);
if (error && error.statusCode === 400) return callback(null); // invalid image format. this can happen if user installed with a bad --docker-image
if (error && error.statusCode === 404) return callback(null); // not found
if (error && error.statusCode === 409) return callback(null); // another container using the image
if (error) debug('Error removing image %s : %j', dockerImage, error);
@@ -388,7 +465,23 @@ function inspect(containerId, callback) {
var container = exports.connection.getContainer(containerId);
container.inspect(function (error, result) {
if (error) return callback(error);
if (error && error.statusCode === 404) return callback(new DockerError(DockerError.NOT_FOUND));
if (error) return callback(new DockerError(DockerError.INTERNAL_ERROR, error));
callback(null, result);
});
}
function memoryUsage(containerId, callback) {
assert.strictEqual(typeof containerId, 'string');
assert.strictEqual(typeof callback, 'function');
var container = exports.connection.getContainer(containerId);
container.stats({ stream: false }, function (error, result) {
if (error && error.statusCode === 404) return callback(new DockerError(DockerError.NOT_FOUND));
if (error) return callback(new DockerError(DockerError.INTERNAL_ERROR, error));
callback(null, result);
});
}
@@ -423,3 +516,66 @@ function execContainer(containerId, cmd, options, callback) {
if (options.stdin) options.stdin.pipe(cp.stdin).on('error', callback);
}
function createVolume(app, name, subdir, callback) {
assert.strictEqual(typeof app, 'object');
assert.strictEqual(typeof name, 'string');
assert.strictEqual(typeof subdir, 'string');
assert.strictEqual(typeof callback, 'function');
let docker = exports.connection;
const volumeDataDir = path.join(paths.APPS_DATA_DIR, app.id, subdir);
const volumeOptions = {
Name: name,
Driver: 'local',
DriverOpts: { // https://github.com/moby/moby/issues/19990#issuecomment-248955005
type: 'none',
device: volumeDataDir,
o: 'bind'
},
Labels: {
'fqdn': app.fqdn,
'appId': app.id
},
};
mkdirp(volumeDataDir, function (error) {
if (error) return callback(new Error(`Error creating app data dir: ${error.message}`));
docker.createVolume(volumeOptions, function (error) {
if (error) return callback(error);
callback();
});
});
}
function clearVolume(app, name, subdir, callback) {
assert.strictEqual(typeof app, 'object');
assert.strictEqual(typeof name, 'string');
assert.strictEqual(typeof subdir, 'string');
assert.strictEqual(typeof callback, 'function');
shell.sudo('removeVolume', [ RMVOLUME_CMD, app.id, subdir ], {}, callback);
}
function removeVolume(app, name, subdir, callback) {
assert.strictEqual(typeof app, 'object');
assert.strictEqual(typeof name, 'string');
assert.strictEqual(typeof subdir, 'string');
assert.strictEqual(typeof callback, 'function');
let docker = exports.connection;
let volume = docker.getVolume(name);
volume.remove(function (error) {
if (error && error.statusCode !== 404) {
debug(`removeVolume: Error removing volume of ${app.id} ${error}`);
callback(error);
}
shell.sudo('removeVolume', [ RMVOLUME_CMD, app.id, subdir ], {}, callback);
});
}
+178
View File
@@ -0,0 +1,178 @@
'use strict';
exports = module.exports = {
start: start,
stop: stop
};
var apps = require('./apps.js'),
AppsError = apps.AppsError,
assert = require('assert'),
config = require('./config.js'),
express = require('express'),
debug = require('debug')('box:dockerproxy'),
http = require('http'),
HttpError = require('connect-lastmile').HttpError,
middleware = require('./middleware'),
net = require('net'),
path = require('path'),
paths = require('./paths.js'),
safe = require('safetydance'),
_ = require('underscore');
var gHttpServer = null;
function authorizeApp(req, res, next) {
// TODO add here some authorization
// - block apps not using the docker addon
// - block calls regarding platform containers
// - only allow managing and inspection of containers belonging to the app
// make the tests pass for now
if (config.TEST) {
req.app = { id: 'testappid' };
return next();
}
apps.getByIpAddress(req.connection.remoteAddress, function (error, app) {
if (error && error.reason === AppsError.NOT_FOUND) return next(new HttpError(401, 'Unauthorized'));
if (error) return next(new HttpError(500, error));
if (!('docker' in app.manifest.addons)) return next(new HttpError(401, 'Unauthorized'));
req.app = app;
next();
});
}
function attachDockerRequest(req, res, next) {
var options = {
socketPath: '/var/run/docker.sock',
method: req.method,
path: req.url,
headers: req.headers
};
req.dockerRequest = http.request(options, function (dockerResponse) {
res.writeHead(dockerResponse.statusCode, dockerResponse.headers);
// Force node to send out the headers, this is required for the /container/wait api to make the docker cli proceed
res.write(' ');
dockerResponse.on('error', function (error) { console.error('dockerResponse error:', error); });
dockerResponse.pipe(res, { end: true });
});
next();
}
function containersCreate(req, res, next) {
safe.set(req.body, 'HostConfig.NetworkMode', 'cloudron'); // overwrite the network the container lives in
safe.set(req.body, 'NetworkingConfig', {}); // drop any custom network configs
safe.set(req.body, 'Labels', _.extend({ }, safe.query(req.body, 'Labels'), { appId: req.app.id })); // overwrite the app id to track containers of an app
safe.set(req.body, 'HostConfig.LogConfig', { Type: 'syslog', Config: { 'tag': req.app.id, 'syslog-address': 'udp://127.0.0.1:2514', 'syslog-format': 'rfc5424' }});
const appDataDir = path.join(paths.APPS_DATA_DIR, req.app.id, 'data'),
dockerDataDir = path.join(paths.APPS_DATA_DIR, req.app.id, 'docker');
debug('Original volume binds:', req.body.HostConfig.Binds);
let binds = [];
for (let bind of (req.body.HostConfig.Binds || [])) {
if (bind.startsWith(appDataDir)) binds.push(bind); // eclipse will inspect docker to find out the host folders and pass that to child containers
else if (bind.startsWith('/app/data')) binds.push(bind.replace(new RegExp('^/app/data'), appDataDir));
else binds.push(`${dockerDataDir}/${bind}`);
}
// cleanup the paths from potential double slashes
binds = binds.map(function (bind) { return bind.replace(/\/+/g, '/'); });
debug('Rewritten volume binds:', binds);
safe.set(req.body, 'HostConfig.Binds', binds);
let plainBody = JSON.stringify(req.body);
req.dockerRequest.setHeader('Content-Length', Buffer.byteLength(plainBody));
req.dockerRequest.end(plainBody);
}
function process(req, res, next) {
// we have to rebuild the body since we consumed in in the parser
if (Object.keys(req.body).length !== 0) {
let plainBody = JSON.stringify(req.body);
req.dockerRequest.setHeader('Content-Length', Buffer.byteLength(plainBody));
req.dockerRequest.end(plainBody);
} else if (!req.readable) {
req.dockerRequest.end();
} else {
req.pipe(req.dockerRequest, { end: true });
}
}
function start(callback) {
assert.strictEqual(typeof callback, 'function');
assert(gHttpServer === null, 'Already started');
let json = middleware.json({ strict: true });
let router = new express.Router();
router.post('/:version/containers/create', containersCreate);
let proxyServer = express();
if (config.TEST) {
proxyServer.use(function (req, res, next) {
console.log('Proxying: ' + req.method, req.url);
next();
});
}
proxyServer.use(authorizeApp)
.use(attachDockerRequest)
.use(json)
.use(router)
.use(process)
.use(middleware.lastMile());
gHttpServer = http.createServer(proxyServer);
gHttpServer.listen(config.get('dockerProxyPort'), '0.0.0.0', callback);
debug(`startDockerProxy: started proxy on port ${config.get('dockerProxyPort')}`);
gHttpServer.on('upgrade', function (req, client, head) {
// Create a new tcp connection to the TCP server
var remote = net.connect('/var/run/docker.sock', function () {
var upgradeMessage = req.method + ' ' + req.url + ' HTTP/1.1\r\n' +
`Host: ${req.headers.host}\r\n` +
'Connection: Upgrade\r\n' +
'Upgrade: tcp\r\n';
if (req.headers['content-type'] === 'application/json') {
// TODO we have to parse the immediate upgrade request body, but I don't know how
let plainBody = '{"Detach":false,"Tty":false}\r\n';
upgradeMessage += `Content-Type: application/json\r\n`;
upgradeMessage += `Content-Length: ${Buffer.byteLength(plainBody)}\r\n`;
upgradeMessage += '\r\n';
upgradeMessage += plainBody;
}
upgradeMessage += '\r\n';
// resend the upgrade event to the docker daemon, so it responds with the proper message through the pipes
remote.write(upgradeMessage);
// two-way pipes between client and docker daemon
client.pipe(remote).pipe(client);
});
});
}
function stop(callback) {
assert.strictEqual(typeof callback, 'function');
if (gHttpServer) gHttpServer.close();
gHttpServer = null;
callback();
}
+1 -2
View File
@@ -8,8 +8,7 @@ exports = module.exports = {
getAll: getAll,
update: update,
del: del,
_clear: clear
clear: clear
};
var assert = require('assert'),
+206 -97
View File
@@ -6,9 +6,10 @@ module.exports = exports = {
getAll: getAll,
update: update,
del: del,
clear: clear,
isLocked: isLocked,
fqdn: fqdn,
setAdmin: setAdmin,
getDnsRecords: getDnsRecords,
upsertDnsRecords: upsertDnsRecords,
@@ -19,28 +20,33 @@ module.exports = exports = {
removePrivateFields: removePrivateFields,
removeRestrictedFields: removeRestrictedFields,
DomainsError: DomainsError
validateHostname: validateHostname,
makeWildcard: makeWildcard,
parentDomain: parentDomain,
DomainsError: DomainsError,
// exported for testing
_getName: getName
};
var assert = require('assert'),
caas = require('./caas.js'),
config = require('./config.js'),
constants = require('./constants.js'),
DatabaseError = require('./databaseerror.js'),
debug = require('debug')('box:domains'),
domaindb = require('./domaindb.js'),
path = require('path'),
eventlog = require('./eventlog.js'),
reverseProxy = require('./reverseproxy.js'),
ReverseProxyError = reverseProxy.ReverseProxyError,
safe = require('safetydance'),
shell = require('./shell.js'),
sysinfo = require('./sysinfo.js'),
tld = require('tldjs'),
util = require('util'),
_ = require('underscore');
var RESTART_CMD = path.join(__dirname, 'scripts/restart.sh');
var NOOP_CALLBACK = function (error) { if (error) debug(error); };
function DomainsError(reason, errorOrMessage) {
assert.strictEqual(typeof reason, 'string');
assert(errorOrMessage instanceof Error || typeof errorOrMessage === 'string' || typeof errorOrMessage === 'undefined');
@@ -69,7 +75,7 @@ DomainsError.STILL_BUSY = 'Still busy';
DomainsError.IN_USE = 'In Use';
DomainsError.INTERNAL_ERROR = 'Internal error';
DomainsError.ACCESS_DENIED = 'Access denied';
DomainsError.INVALID_PROVIDER = 'provider must be route53, gcdns, digitalocean, gandi, cloudflare, namecom, noop, manual or caas';
DomainsError.INVALID_PROVIDER = 'provider must be route53, gcdns, digitalocean, gandi, cloudflare, namecom, noop, wildcard, manual or caas';
// choose which subdomain backend we use for test purpose we use route53
function api(provider) {
@@ -86,12 +92,18 @@ function api(provider) {
case 'namecom': return require('./dns/namecom.js');
case 'noop': return require('./dns/noop.js');
case 'manual': return require('./dns/manual.js');
case 'wildcard': return require('./dns/wildcard.js');
default: return null;
}
}
function verifyDnsConfig(config, domain, zoneName, provider, ip, callback) {
assert(config && typeof config === 'object'); // the dns config to test with
function parentDomain(domain) {
assert.strictEqual(typeof domain, 'string');
return domain.replace(/^\S+?\./, ''); // +? means non-greedy
}
function verifyDnsConfig(dnsConfig, domain, zoneName, provider, ip, callback) {
assert(dnsConfig && typeof dnsConfig === 'object'); // the dns config to test with
assert.strictEqual(typeof domain, 'string');
assert.strictEqual(typeof zoneName, 'string');
assert.strictEqual(typeof provider, 'string');
@@ -99,21 +111,98 @@ function verifyDnsConfig(config, domain, zoneName, provider, ip, callback) {
assert.strictEqual(typeof callback, 'function');
var backend = api(provider);
if (!backend) return callback(new DomainsError(DomainsError.INVALID_PROVIDER));
if (!backend) return callback(new DomainsError(DomainsError.BAD_FIELD, 'Invalid provider'));
api(provider).verifyDnsConfig(config, domain, zoneName, ip, callback);
api(provider).verifyDnsConfig(dnsConfig, domain, zoneName, ip, function (error, result) {
if (error && error.reason === DomainsError.ACCESS_DENIED) return callback(new DomainsError(DomainsError.BAD_FIELD, 'Incorrect configuration. Access denied'));
if (error && error.reason === DomainsError.NOT_FOUND) return callback(new DomainsError(DomainsError.BAD_FIELD, 'Zone not found'));
if (error && error.reason === DomainsError.EXTERNAL_ERROR) return callback(new DomainsError(DomainsError.BAD_FIELD, 'Configuration error: ' + error.message));
if (error && error.reason === DomainsError.BAD_FIELD) return callback(new DomainsError(DomainsError.BAD_FIELD, error.message));
if (error && error.reason === DomainsError.INVALID_PROVIDER) return callback(new DomainsError(DomainsError.BAD_FIELD, error.message));
if (error) return callback(new DomainsError(DomainsError.INTERNAL_ERROR, error));
result.hyphenatedSubdomains = !!dnsConfig.hyphenatedSubdomains;
callback(null, result);
});
}
function fqdn(location, domainObject) {
return location + (location ? (domainObject.config.hyphenatedSubdomains ? '-' : '.') : '') + domainObject.domain;
}
function add(domain, zoneName, provider, config, fallbackCertificate, tlsConfig, callback) {
assert.strictEqual(typeof domain, 'string');
assert.strictEqual(typeof zoneName, 'string');
assert.strictEqual(typeof provider, 'string');
assert.strictEqual(typeof config, 'object');
assert.strictEqual(typeof fallbackCertificate, 'object');
// Hostname validation comes from RFC 1123 (section 2.1)
// Domain name validation comes from RFC 2181 (Name syntax)
// https://en.wikipedia.org/wiki/Hostname#Restrictions_on_valid_host_names
// We are validating the validity of the location-fqdn as host name (and not dns name)
function validateHostname(location, domainObject) {
assert.strictEqual(typeof location, 'string');
assert.strictEqual(typeof domainObject, 'object');
const hostname = fqdn(location, domainObject);
const RESERVED_LOCATIONS = [
constants.API_LOCATION,
constants.SMTP_LOCATION,
constants.IMAP_LOCATION
];
if (RESERVED_LOCATIONS.indexOf(location) !== -1) return new DomainsError(DomainsError.BAD_FIELD, location + ' is reserved');
if (hostname === config.adminFqdn()) return new DomainsError(DomainsError.BAD_FIELD, location + ' is reserved');
// workaround https://github.com/oncletom/tld.js/issues/73
var tmp = hostname.replace('_', '-');
if (!tld.isValid(tmp)) return new DomainsError(DomainsError.BAD_FIELD, 'Hostname is not a valid domain name');
if (hostname.length > 253) return new DomainsError(DomainsError.BAD_FIELD, 'Hostname length exceeds 253 characters');
if (location) {
// label validation
if (location.split('.').some(function (p) { return p.length > 63 || p.length < 1; })) return new DomainsError(DomainsError.BAD_FIELD, 'Invalid subdomain length');
if (location.match(/^[A-Za-z0-9-.]+$/) === null) return new DomainsError(DomainsError.BAD_FIELD, 'Subdomain can only contain alphanumeric, hyphen and dot');
if (/^[-.]/.test(location)) return new DomainsError(DomainsError.BAD_FIELD, 'Subdomain cannot start or end with hyphen or dot');
}
if (domainObject.config.hyphenatedSubdomains) {
if (location.indexOf('.') !== -1) return new DomainsError(DomainsError.BAD_FIELD, 'Subdomain cannot contain a dot');
}
return null;
}
function validateTlsConfig(tlsConfig, dnsProvider) {
assert.strictEqual(typeof tlsConfig, 'object');
assert.strictEqual(typeof dnsProvider, 'string');
switch (tlsConfig.provider) {
case 'letsencrypt-prod':
case 'letsencrypt-staging':
case 'fallback':
case 'caas':
break;
default:
return new DomainsError(DomainsError.BAD_FIELD, 'tlsConfig.provider must be caas, fallback, letsencrypt-prod/staging');
}
if (tlsConfig.wildcard) {
if (!tlsConfig.provider.startsWith('letsencrypt')) return new DomainsError(DomainsError.BAD_FIELD, 'wildcard can only be set with letsencrypt');
if (dnsProvider === 'manual' || dnsProvider === 'noop' || dnsProvider === 'wildcard') return new DomainsError(DomainsError.BAD_FIELD, 'wildcard cert requires a programmable DNS backend');
}
return null;
}
function add(domain, data, auditSource, callback) {
assert.strictEqual(typeof domain, 'string');
assert.strictEqual(typeof data.zoneName, 'string');
assert.strictEqual(typeof data.provider, 'string');
assert.strictEqual(typeof data.config, 'object');
assert.strictEqual(typeof data.fallbackCertificate, 'object');
assert.strictEqual(typeof data.tlsConfig, 'object');
assert.strictEqual(typeof callback, 'function');
let { zoneName, provider, config, fallbackCertificate, tlsConfig } = data;
if (!tld.isValid(domain)) return callback(new DomainsError(DomainsError.BAD_FIELD, 'Invalid domain'));
if (domain.endsWith('.')) return callback(new DomainsError(DomainsError.BAD_FIELD, 'Invalid domain'));
@@ -125,32 +214,31 @@ function add(domain, zoneName, provider, config, fallbackCertificate, tlsConfig,
}
if (fallbackCertificate) {
let error = reverseProxy.validateCertificate(`test.${domain}`, fallbackCertificate.cert, fallbackCertificate.key);
let error = reverseProxy.validateCertificate('test', { domain, config }, fallbackCertificate);
if (error) return callback(new DomainsError(DomainsError.BAD_FIELD, error.message));
} else {
fallbackCertificate = reverseProxy.generateFallbackCertificateSync({ domain, config });
if (fallbackCertificate.error) return callback(new DomainsError(DomainsError.INTERNAL_ERROR, fallbackCertificate.error));
}
if (tlsConfig.provider !== 'fallback' && tlsConfig.provider !== 'caas' && tlsConfig.provider.indexOf('letsencrypt-') !== 0) {
return callback(new DomainsError(DomainsError.BAD_FIELD, 'tlsConfig.provider must be caas, fallback or le-*'));
}
let error = validateTlsConfig(tlsConfig, provider);
if (error) return callback(error);
sysinfo.getPublicIp(function (error, ip) {
if (error) return callback(new DomainsError(DomainsError.INTERNAL_ERROR, 'Error getting IP:' + error.message));
verifyDnsConfig(config, domain, zoneName, provider, ip, function (error, result) {
if (error && error.reason === DomainsError.ACCESS_DENIED) return callback(new DomainsError(DomainsError.BAD_FIELD, 'Error adding A record. Access denied'));
if (error && error.reason === DomainsError.NOT_FOUND) return callback(new DomainsError(DomainsError.BAD_FIELD, 'Zone not found'));
if (error && error.reason === DomainsError.EXTERNAL_ERROR) return callback(new DomainsError(DomainsError.BAD_FIELD, 'Error adding A record: ' + error.message));
if (error && error.reason === DomainsError.BAD_FIELD) return callback(new DomainsError(DomainsError.BAD_FIELD, error.message));
if (error && error.reason === DomainsError.INVALID_PROVIDER) return callback(new DomainsError(DomainsError.BAD_FIELD, error.message));
if (error) return callback(new DomainsError(DomainsError.INTERNAL_ERROR, error));
verifyDnsConfig(config, domain, zoneName, provider, ip, function (error, sanitizedConfig) {
if (error) return callback(error);
domaindb.add(domain, { zoneName: zoneName, provider: provider, config: result, tlsConfig: tlsConfig }, function (error) {
domaindb.add(domain, { zoneName: zoneName, provider: provider, config: sanitizedConfig, tlsConfig: tlsConfig }, function (error) {
if (error && error.reason === DatabaseError.ALREADY_EXISTS) return callback(new DomainsError(DomainsError.ALREADY_EXISTS));
if (error) return callback(new DomainsError(DomainsError.INTERNAL_ERROR, error));
reverseProxy.setFallbackCertificate(domain, fallbackCertificate, function (error) {
if (error) return callback(new DomainsError(DomainsError.INTERNAL_ERROR, error));
eventlog.add(eventlog.ACTION_DOMAIN_ADD, auditSource, { domain, zoneName, provider });
callback();
});
});
@@ -158,6 +246,10 @@ function add(domain, zoneName, provider, config, fallbackCertificate, tlsConfig,
});
}
function isLocked(domain) {
return domain === config.adminDomain() && config.edition() === 'hostingprovider';
}
function get(domain, callback) {
assert.strictEqual(typeof domain, 'string');
assert.strictEqual(typeof callback, 'function');
@@ -167,6 +259,8 @@ function get(domain, callback) {
if (error && error.reason === DatabaseError.NOT_FOUND) return callback(new DomainsError(DomainsError.NOT_FOUND));
if (error) return callback(new DomainsError(DomainsError.INTERNAL_ERROR, error));
result.locked = isLocked(domain);
reverseProxy.getFallbackCertificate(domain, function (error, bundle) {
if (error && error.reason !== ReverseProxyError.NOT_FOUND) return callback(new DomainsError(DomainsError.INTERNAL_ERROR, error));
@@ -188,50 +282,49 @@ function getAll(callback) {
domaindb.getAll(function (error, result) {
if (error) return callback(new DomainsError(DomainsError.INTERNAL_ERROR, error));
result.forEach(function (r) { r.locked = isLocked(r.domain); });
return callback(null, result);
});
}
function update(domain, zoneName, provider, config, fallbackCertificate, tlsConfig, callback) {
function update(domain, data, auditSource, callback) {
assert.strictEqual(typeof domain, 'string');
assert.strictEqual(typeof zoneName, 'string');
assert.strictEqual(typeof provider, 'string');
assert.strictEqual(typeof config, 'object');
assert.strictEqual(typeof fallbackCertificate, 'object');
assert.strictEqual(typeof tlsConfig, 'object');
assert.strictEqual(typeof data.zoneName, 'string');
assert.strictEqual(typeof data.provider, 'string');
assert.strictEqual(typeof data.config, 'object');
assert.strictEqual(typeof data.fallbackCertificate, 'object');
assert.strictEqual(typeof data.tlsConfig, 'object');
assert.strictEqual(typeof auditSource, 'object');
assert.strictEqual(typeof callback, 'function');
domaindb.get(domain, function (error, result) {
let { zoneName, provider, config, fallbackCertificate, tlsConfig } = data;
domaindb.get(domain, function (error, domainObject) {
if (error && error.reason === DatabaseError.NOT_FOUND) return callback(new DomainsError(DomainsError.NOT_FOUND));
if (error) return callback(new DomainsError(DomainsError.INTERNAL_ERROR, error));
if (zoneName) {
if (!tld.isValid(zoneName)) return callback(new DomainsError(DomainsError.BAD_FIELD, 'Invalid zoneName'));
} else {
zoneName = result.zoneName;
zoneName = domainObject.zoneName;
}
if (fallbackCertificate) {
let error = reverseProxy.validateCertificate(`test.${domain}`, fallbackCertificate.cert, fallbackCertificate.key);
let error = reverseProxy.validateCertificate('test', domainObject, fallbackCertificate);
if (error) return callback(new DomainsError(DomainsError.BAD_FIELD, error.message));
}
if (tlsConfig.provider !== 'fallback' && tlsConfig.provider !== 'caas' && tlsConfig.provider.indexOf('letsencrypt-') !== 0) {
return callback(new DomainsError(DomainsError.BAD_FIELD, 'tlsConfig.provider must be caas, fallback or letsencrypt-*'));
}
error = validateTlsConfig(tlsConfig, provider);
if (error) return callback(error);
sysinfo.getPublicIp(function (error, ip) {
if (error) return callback(new DomainsError(DomainsError.INTERNAL_ERROR, 'Error getting IP:' + error.message));
verifyDnsConfig(config, domain, zoneName, provider, ip, function (error, result) {
if (error && error.reason === DomainsError.ACCESS_DENIED) return callback(new DomainsError(DomainsError.BAD_FIELD, 'Error adding A record. Access denied'));
if (error && error.reason === DomainsError.NOT_FOUND) return callback(new DomainsError(DomainsError.BAD_FIELD, 'Zone not found'));
if (error && error.reason === DomainsError.EXTERNAL_ERROR) return callback(new DomainsError(DomainsError.BAD_FIELD, 'Error adding A record:' + error.message));
if (error && error.reason === DomainsError.BAD_FIELD) return callback(new DomainsError(DomainsError.BAD_FIELD, error.message));
if (error && error.reason === DomainsError.INVALID_PROVIDER) return callback(new DomainsError(DomainsError.BAD_FIELD, error.message));
if (error) return callback(new DomainsError(DomainsError.INTERNAL_ERROR, error));
verifyDnsConfig(config, domain, zoneName, provider, ip, function (error, sanitizedConfig) {
if (error) return callback(error);
domaindb.update(domain, { zoneName: zoneName, provider: provider, config: result, tlsConfig: tlsConfig }, function (error) {
domaindb.update(domain, { zoneName: zoneName, provider: provider, config: sanitizedConfig, tlsConfig: tlsConfig }, function (error) {
if (error && error.reason === DatabaseError.NOT_FOUND) return callback(new DomainsError(DomainsError.NOT_FOUND));
if (error) return callback(new DomainsError(DomainsError.INTERNAL_ERROR, error));
@@ -240,6 +333,8 @@ function update(domain, zoneName, provider, config, fallbackCertificate, tlsConf
reverseProxy.setFallbackCertificate(domain, fallbackCertificate, function (error) {
if (error) return callback(new DomainsError(DomainsError.INTERNAL_ERROR, error));
eventlog.add(eventlog.ACTION_DOMAIN_UPDATE, auditSource, { domain, zoneName, provider });
callback();
});
});
@@ -248,28 +343,56 @@ function update(domain, zoneName, provider, config, fallbackCertificate, tlsConf
});
}
function del(domain, callback) {
function del(domain, auditSource, callback) {
assert.strictEqual(typeof domain, 'string');
assert.strictEqual(typeof auditSource, 'object');
assert.strictEqual(typeof callback, 'function');
if (domain === config.adminDomain()) return callback(new DomainsError(DomainsError.IN_USE));
domaindb.del(domain, function (error) {
if (error && error.reason === DatabaseError.NOT_FOUND) return callback(new DomainsError(DomainsError.NOT_FOUND));
if (error && error.reason === DatabaseError.IN_USE) return callback(new DomainsError(DomainsError.IN_USE));
if (error) return callback(new DomainsError(DomainsError.INTERNAL_ERROR, error));
eventlog.add(eventlog.ACTION_DOMAIN_REMOVE, auditSource, { domain });
return callback(null);
});
}
function getName(domain, subdomain) {
// support special caas domains
function clear(callback) {
assert.strictEqual(typeof callback, 'function');
domaindb.clear(function (error) {
if (error) return callback(new DomainsError(DomainsError.INTERNAL_ERROR, error));
return callback(null);
});
}
// returns the 'name' that needs to be inserted into zone
function getName(domain, subdomain, type) {
// hack for supporting special caas domains. if we want to remove this, we have to fix the appstore domain API first
if (domain.provider === 'caas') return subdomain;
if (domain.domain === domain.zoneName) return subdomain;
const part = domain.domain.slice(0, -domain.zoneName.length - 1);
var part = domain.domain.slice(0, -domain.zoneName.length - 1);
if (subdomain === '') return part;
return subdomain === '' ? part : subdomain + '.' + part;
if (!domain.config.hyphenatedSubdomains) return part ? `${subdomain}.${part}` : subdomain;
// hyphenatedSubdomains
if (type !== 'TXT') return `${subdomain}-${part}`;
if (subdomain.startsWith('_acme-challenge.')) {
return `${subdomain}-${part}`;
} else if (subdomain === '_acme-challenge') {
const up = part.replace(/^[^.]*\.?/, ''); // this gets the domain one level up
return up ? `${subdomain}.${up}` : subdomain;
} else {
return `${subdomain}.${part}`;
}
}
function getDnsRecords(subdomain, domain, type, callback) {
@@ -281,7 +404,7 @@ function getDnsRecords(subdomain, domain, type, callback) {
get(domain, function (error, result) {
if (error) return callback(new DomainsError(DomainsError.INTERNAL_ERROR, error));
api(result.provider).get(result.config, result.zoneName, getName(result, subdomain), type, function (error, values) {
api(result.provider).get(result.config, result.zoneName, getName(result, subdomain, type), type, function (error, values) {
if (error) return callback(error);
callback(null, values);
@@ -289,6 +412,7 @@ function getDnsRecords(subdomain, domain, type, callback) {
});
}
// note: for TXT records the values must be quoted
function upsertDnsRecords(subdomain, domain, type, values, callback) {
assert.strictEqual(typeof subdomain, 'string');
assert.strictEqual(typeof domain, 'string');
@@ -301,7 +425,7 @@ function upsertDnsRecords(subdomain, domain, type, values, callback) {
get(domain, function (error, result) {
if (error) return callback(new DomainsError(DomainsError.INTERNAL_ERROR, error));
api(result.provider).upsert(result.config, result.zoneName, getName(result, subdomain), type, values, function (error) {
api(result.provider).upsert(result.config, result.zoneName, getName(result, subdomain, type), type, values, function (error) {
if (error) return callback(error);
callback(null);
@@ -321,7 +445,7 @@ function removeDnsRecords(subdomain, domain, type, values, callback) {
get(domain, function (error, result) {
if (error) return callback(error);
api(result.provider).del(result.config, result.zoneName, getName(result, subdomain), type, values, function (error) {
api(result.provider).del(result.config, result.zoneName, getName(result, subdomain, type), type, values, function (error) {
if (error && error.reason !== DomainsError.NOT_FOUND) return callback(error);
callback(null);
@@ -329,59 +453,44 @@ function removeDnsRecords(subdomain, domain, type, values, callback) {
});
}
// only wait for A record
function waitForDnsRecord(fqdn, domain, value, options, callback) {
assert.strictEqual(typeof fqdn, 'string');
function waitForDnsRecord(subdomain, domain, type, value, options, callback) {
assert.strictEqual(typeof subdomain, 'string');
assert.strictEqual(typeof domain, 'string');
assert(type === 'A' || type === 'TXT');
assert.strictEqual(typeof value, 'string');
assert(options && typeof options === 'object'); // { interval: 5000, times: 50000 }
assert.strictEqual(typeof callback, 'function');
get(domain, function (error, result) {
get(domain, function (error, domainObject) {
if (error) return callback(error);
api(result.provider).waitForDns(fqdn, result ? result.zoneName : domain, value, options, callback);
const hostname = fqdn(subdomain, domainObject);
api(domainObject.provider).waitForDns(hostname, domainObject.zoneName, type, value, options, callback);
});
}
function setAdmin(domain, callback) {
assert.strictEqual(typeof domain, 'string');
assert.strictEqual(typeof callback, 'function');
debug('setAdmin domain:%s', domain);
get(domain, function (error, result) {
if (error) return callback(error);
var setPtrRecord = config.provider() === 'caas' ? caas.setPtrRecord : function (d, next) { next(); };
setPtrRecord(domain, function (error) {
if (error) return callback(new DomainsError(DomainsError.EXTERNAL_ERROR, 'Error setting PTR record:' + error.message));
config.setAdminDomain(result.domain);
config.setAdminLocation('my');
config.setAdminFqdn('my' + (result.provider === 'caas' ? '-' : '.') + result.domain);
callback();
shell.sudo('restart', [ RESTART_CMD ], NOOP_CALLBACK);
});
});
}
function fqdn(location, domain, provider) {
return location + (location ? (provider !== 'caas' ? '.' : '-') : '') + domain;
}
// removes all fields that are strictly private and should never be returned by API calls
function removePrivateFields(domain) {
var result = _.pick(domain, 'domain', 'zoneName', 'provider', 'config', 'tlsConfig', 'fallbackCertificate');
var result = _.pick(domain, 'domain', 'zoneName', 'provider', 'config', 'tlsConfig', 'fallbackCertificate', 'locked');
if (result.fallbackCertificate) delete result.fallbackCertificate.key; // do not return the 'key'. in caas, this is private
return result;
}
// removes all fields that are not accessible by a normal user
function removeRestrictedFields(domain) {
var result = _.pick(domain, 'domain', 'zoneName', 'provider');
var result = _.pick(domain, 'domain', 'zoneName', 'provider', 'locked');
// always ensure config object
result.config = { hyphenatedSubdomains: !!domain.config.hyphenatedSubdomains };
return result;
}
}
function makeWildcard(hostname) {
assert.strictEqual(typeof hostname, 'string');
let parts = hostname.split('.');
parts[0] = '*';
return parts.join('.');
}
+18
View File
@@ -18,12 +18,30 @@ exports = module.exports = {
ACTION_APP_UNINSTALL: 'app.uninstall',
ACTION_APP_UPDATE: 'app.update',
ACTION_APP_LOGIN: 'app.login',
ACTION_BACKUP_FINISH: 'backup.finish',
ACTION_BACKUP_START: 'backup.start',
ACTION_BACKUP_CLEANUP: 'backup.cleanup',
ACTION_CERTIFICATE_NEW: 'certificate.new',
ACTION_CERTIFICATE_RENEWAL: 'certificate.renew',
ACTION_DOMAIN_ADD: 'domain.add',
ACTION_DOMAIN_UPDATE: 'domain.update',
ACTION_DOMAIN_REMOVE: 'domain.remove',
ACTION_MAIL_ENABLED: 'mail.enabled',
ACTION_MAIL_DISABLED: 'mail.disabled',
ACTION_MAIL_MAILBOX_ADD: 'mail.box.add',
ACTION_MAIL_MAILBOX_REMOVE: 'mail.box.remove',
ACTION_MAIL_LIST_ADD: 'mail.list.add',
ACTION_MAIL_LIST_REMOVE: 'mail.list.remove',
ACTION_PROVISION: 'cloudron.provision',
ACTION_RESTORE: 'cloudron.restore', // unused
ACTION_START: 'cloudron.start',
ACTION_UPDATE: 'cloudron.update',
ACTION_USER_ADD: 'user.add',
ACTION_USER_LOGIN: 'user.login',
ACTION_USER_REMOVE: 'user.remove',
+40
View File
@@ -0,0 +1,40 @@
'use strict';
exports = module.exports = {
startGraphite: startGraphite
};
var assert = require('assert'),
infra = require('./infra_version.js'),
paths = require('./paths.js'),
shell = require('./shell.js');
function startGraphite(existingInfra, callback) {
assert.strictEqual(typeof existingInfra, 'object');
assert.strictEqual(typeof callback, 'function');
const tag = infra.images.graphite.tag;
const dataDir = paths.PLATFORM_DATA_DIR;
if (existingInfra.version === infra.version && infra.images.graphite.tag === existingInfra.images.graphite.tag) return callback();
const cmd = `docker run --restart=always -d --name="graphite" \
--net cloudron \
--net-alias graphite \
--log-driver syslog \
--log-opt syslog-address=udp://127.0.0.1:2514 \
--log-opt syslog-format=rfc5424 \
--log-opt tag=graphite \
-m 75m \
--memory-swap 150m \
--dns 172.18.0.1 \
--dns-search=. \
-p 127.0.0.1:2003:2003 \
-p 127.0.0.1:2004:2004 \
-p 127.0.0.1:8417:8000 \
-v "${dataDir}/graphite:/var/lib/graphite" \
--label isCloudronManaged=true \
--read-only -v /tmp -v /run "${tag}"`;
shell.exec('startGraphite', cmd, callback);
}
+14 -14
View File
@@ -34,7 +34,7 @@ function get(groupId, callback) {
assert.strictEqual(typeof groupId, 'string');
assert.strictEqual(typeof callback, 'function');
database.query('SELECT ' + GROUPS_FIELDS + ' FROM groups WHERE id = ? ORDER BY name', [ groupId ], function (error, result) {
database.query('SELECT ' + GROUPS_FIELDS + ' FROM userGroups WHERE id = ? ORDER BY name', [ groupId ], function (error, result) {
if (error) return callback(new DatabaseError(DatabaseError.INTERNAL_ERROR, error));
if (result.length === 0) return callback(new DatabaseError(DatabaseError.NOT_FOUND));
@@ -47,9 +47,9 @@ function getWithMembers(groupId, callback) {
assert.strictEqual(typeof callback, 'function');
database.query('SELECT ' + GROUPS_FIELDS + ',GROUP_CONCAT(groupMembers.userId) AS userIds ' +
' FROM groups LEFT OUTER JOIN groupMembers ON groups.id = groupMembers.groupId ' +
' WHERE groups.id = ? ' +
' GROUP BY groups.id', [ groupId ], function (error, results) {
' FROM userGroups LEFT OUTER JOIN groupMembers ON userGroups.id = groupMembers.groupId ' +
' WHERE userGroups.id = ? ' +
' GROUP BY userGroups.id', [ groupId ], function (error, results) {
if (error) return callback(new DatabaseError(DatabaseError.INTERNAL_ERROR, error));
if (results.length === 0) return callback(new DatabaseError(DatabaseError.NOT_FOUND));
@@ -63,7 +63,7 @@ function getWithMembers(groupId, callback) {
function getAll(callback) {
assert.strictEqual(typeof callback, 'function');
database.query('SELECT ' + GROUPS_FIELDS + ' FROM groups', function (error, results) {
database.query('SELECT ' + GROUPS_FIELDS + ' FROM userGroups', function (error, results) {
if (error) return callback(new DatabaseError(DatabaseError.INTERNAL_ERROR, error));
callback(null, results);
@@ -72,8 +72,8 @@ function getAll(callback) {
function getAllWithMembers(callback) {
database.query('SELECT ' + GROUPS_FIELDS + ',GROUP_CONCAT(groupMembers.userId) AS userIds ' +
' FROM groups LEFT OUTER JOIN groupMembers ON groups.id = groupMembers.groupId ' +
' GROUP BY groups.id', function (error, results) {
' FROM userGroups LEFT OUTER JOIN groupMembers ON userGroups.id = groupMembers.groupId ' +
' GROUP BY userGroups.id', function (error, results) {
if (error) return callback(new DatabaseError(DatabaseError.INTERNAL_ERROR, error));
if (results.length === 0) return callback(new DatabaseError(DatabaseError.NOT_FOUND));
@@ -88,7 +88,7 @@ function add(id, name, callback) {
assert.strictEqual(typeof name, 'string');
assert.strictEqual(typeof callback, 'function');
database.query('INSERT INTO groups (id, name) VALUES (?, ?)', [ id, name ], function (error, result) {
database.query('INSERT INTO userGroups (id, name) VALUES (?, ?)', [ id, name ], function (error, result) {
if (error && error.code === 'ER_DUP_ENTRY') return callback(new DatabaseError(DatabaseError.ALREADY_EXISTS, error));
if (error || result.affectedRows !== 1) return callback(new DatabaseError(DatabaseError.INTERNAL_ERROR, error));
@@ -112,8 +112,8 @@ function update(id, data, callback) {
}
args.push(id);
database.query('UPDATE groups SET ' + fields.join(', ') + ' WHERE id = ?', args, function (error, result) {
if (error && error.code === 'ER_DUP_ENTRY' && error.sqlMessage.indexOf('groups_name') !== -1) return callback(new DatabaseError(DatabaseError.ALREADY_EXISTS, 'name already exists'));
database.query('UPDATE userGroups SET ' + fields.join(', ') + ' WHERE id = ?', args, function (error, result) {
if (error && error.code === 'ER_DUP_ENTRY' && error.sqlMessage.indexOf('userGroups_name') !== -1) return callback(new DatabaseError(DatabaseError.ALREADY_EXISTS, 'name already exists'));
if (error) return callback(new DatabaseError(DatabaseError.INTERNAL_ERROR, error));
if (result.affectedRows !== 1) return callback(new DatabaseError(DatabaseError.NOT_FOUND));
@@ -128,7 +128,7 @@ function del(id, callback) {
// also cleanup the groupMembers table
var queries = [];
queries.push({ query: 'DELETE FROM groupMembers WHERE groupId = ?', args: [ id ] });
queries.push({ query: 'DELETE FROM groups WHERE id = ?', args: [ id ] });
queries.push({ query: 'DELETE FROM userGroups WHERE id = ?', args: [ id ] });
database.transaction(queries, function (error, result) {
if (error) return callback(new DatabaseError(DatabaseError.INTERNAL_ERROR, error));
@@ -141,7 +141,7 @@ function del(id, callback) {
function count(callback) {
assert.strictEqual(typeof callback, 'function');
database.query('SELECT COUNT(*) AS total FROM groups', function (error, result) {
database.query('SELECT COUNT(*) AS total FROM userGroups', function (error, result) {
if (error) return callback(new DatabaseError(DatabaseError.INTERNAL_ERROR, error));
return callback(null, result[0].total);
@@ -152,7 +152,7 @@ function clear(callback) {
database.query('DELETE FROM groupMembers', function (error) {
if (error) return callback(new DatabaseError(DatabaseError.INTERNAL_ERROR, error));
database.query('DELETE FROM groups', function (error) {
database.query('DELETE FROM userGroups', function (error) {
if (error) return callback(new DatabaseError(DatabaseError.INTERNAL_ERROR, error));
callback(error);
@@ -266,7 +266,7 @@ function getGroups(userId, callback) {
assert.strictEqual(typeof callback, 'function');
database.query('SELECT ' + GROUPS_FIELDS + ' ' +
' FROM groups INNER JOIN groupMembers ON groups.id = groupMembers.groupId AND groupMembers.userId = ?', [ userId ], function (error, results) {
' FROM userGroups INNER JOIN groupMembers ON userGroups.id = groupMembers.groupId AND groupMembers.userId = ?', [ userId ], function (error, results) {
if (error) return callback(new DatabaseError(DatabaseError.INTERNAL_ERROR, error));
callback(null, results);
+13 -12
View File
@@ -5,20 +5,21 @@
// Do not require anything here!
exports = module.exports = {
// a major version makes all apps restore from backup. #451 must be fixed before we do this.
// a minor version makes all apps re-configure themselves
'version': '48.11.0',
// a version change recreates all containers with latest docker config
'version': '48.12.1',
'baseImages': [ 'cloudron/base:0.10.0' ],
'baseImages': [
{ repo: 'cloudron/base', tag: 'cloudron/base:1.0.0@sha256:147a648a068a2e746644746bbfb42eb7a50d682437cead3c67c933c546357617' }
],
// Note that if any of the databases include an upgrade, bump the infra version above
// This is because we upgrade using dumps instead of mysql_upgrade, pg_upgrade etc
// a major version bump in the db containers will trigger the restore logic that uses the db dumps
// docker inspect --format='{{index .RepoDigests 0}}' $IMAGE to get the sha256
'images': {
'mysql': { repo: 'cloudron/mysql', tag: 'cloudron/mysql:1.1.0' },
'postgresql': { repo: 'cloudron/postgresql', tag: 'cloudron/postgresql:1.1.0' },
'mongodb': { repo: 'cloudron/mongodb', tag: 'cloudron/mongodb:1.1.0' },
'redis': { repo: 'cloudron/redis', tag: 'cloudron/redis:1.0.0' },
'mail': { repo: 'cloudron/mail', tag: 'cloudron/mail:1.4.0' },
'graphite': { repo: 'cloudron/graphite', tag: 'cloudron/graphite:1.0.0' }
'mysql': { repo: 'cloudron/mysql', tag: 'cloudron/mysql:2.0.1@sha256:5a13360da4a2085c7d474bea6b1090c5eb24732d4f73459942af7612d4993d7f' },
'postgresql': { repo: 'cloudron/postgresql', tag: 'cloudron/postgresql:2.0.2@sha256:6dcee0731dfb9b013ed94d56205eee219040ee806c7e251db3b3886eaa4947ff' },
'mongodb': { repo: 'cloudron/mongodb', tag: 'cloudron/mongodb:2.0.2@sha256:95e006390ddce7db637e1672eb6f3c257d3c2652747424f529b1dee3cbe6728c' },
'redis': { repo: 'cloudron/redis', tag: 'cloudron/redis:2.0.0@sha256:8a88dd334b62b578530a014ca1a2425a54cb9df1e475f5d3a36806e5cfa22121' },
'mail': { repo: 'cloudron/mail', tag: 'cloudron/mail:2.0.1@sha256:deee3739011670d45abd8997a8a0b8d3c4cd577a93f235417614dea58338e0f9' },
'graphite': { repo: 'cloudron/graphite', tag: 'cloudron/graphite:2.0.2@sha256:454f035d60b768153d4f31210380271b5ba1c09367c9d95c7fa37f9e39d2f59c' }
}
};
+53 -69
View File
@@ -10,7 +10,6 @@ var assert = require('assert'),
apps = require('./apps.js'),
async = require('async'),
config = require('./config.js'),
constants = require('./constants.js'),
DatabaseError = require('./databaseerror.js'),
debug = require('debug')('box:ldap'),
eventlog = require('./eventlog.js'),
@@ -29,37 +28,32 @@ var NOOP = function () {};
var GROUP_USERS_DN = 'cn=users,ou=groups,dc=cloudron';
var GROUP_ADMINS_DN = 'cn=admins,ou=groups,dc=cloudron';
function getAppByRequest(req, callback) {
assert.strictEqual(typeof req, 'object');
assert.strictEqual(typeof callback, 'function');
// Will attach req.app if successful
function authenticateApp(req, res, next) {
var sourceIp = req.connection.ldap.id.split(':')[0];
if (sourceIp.split('.').length !== 4) return callback(new ldap.InsufficientAccessRightsError('Missing source identifier'));
if (sourceIp.split('.').length !== 4) return next(new ldap.InsufficientAccessRightsError('Missing source identifier'));
apps.getByIpAddress(sourceIp, function (error, app) {
if (error) return callback(new ldap.OperationsError(error.message));
if (error) return next(new ldap.OperationsError(error.message));
if (!app) return next(new ldap.OperationsError('Could not detect app source'));
if (!app) return callback(new ldap.OperationsError('Could not detect app source'));
req.app = app;
callback(null, app);
next();
});
}
function getUsersWithAccessToApp(req, callback) {
assert.strictEqual(typeof req, 'object');
assert.strictEqual(typeof req.app, 'object');
assert.strictEqual(typeof callback, 'function');
getAppByRequest(req, function (error, app) {
if (error) return callback(error);
users.list(function (error, result) {
if (error) return callback(new ldap.OperationsError(error.toString()));
users.list(function (error, result) {
async.filter(result, apps.hasAccessTo.bind(null, req.app), function (error, allowedUsers) {
if (error) return callback(new ldap.OperationsError(error.toString()));
async.filter(result, apps.hasAccessTo.bind(null, app), function (error, allowedUsers) {
if (error) return callback(new ldap.OperationsError(error.toString()));
callback(null, allowedUsers);
});
callback(null, allowedUsers);
});
});
}
@@ -140,7 +134,7 @@ function userSearch(req, res, next) {
var dn = ldap.parseDN('cn=' + entry.id + ',ou=users,dc=cloudron');
var groups = [ GROUP_USERS_DN ];
if (entry.admin) groups.push(GROUP_ADMINS_DN);
if (entry.admin || req.app.ownerId === entry.id) groups.push(GROUP_ADMINS_DN);
var displayName = entry.displayName || entry.username || ''; // displayName can be empty and username can be null
var nameParts = displayName.split(' ');
@@ -160,7 +154,7 @@ function userSearch(req, res, next) {
givenName: firstName,
username: entry.username,
samaccountname: entry.username, // to support ActiveDirectory clients
isadmin: entry.admin ? 1 : 0,
isadmin: (entry.admin || req.app.ownerId === entry.id) ? 1 : 0,
memberof: groups
}
};
@@ -200,7 +194,7 @@ function groupSearch(req, res, next) {
groups.forEach(function (group) {
var dn = ldap.parseDN('cn=' + group.name + ',ou=groups,dc=cloudron');
var members = group.admin ? result.filter(function (entry) { return entry.admin; }) : result;
var members = group.admin ? result.filter(function (entry) { return entry.admin || req.app.ownerId === entry.id; }) : result;
var obj = {
dn: dn.toString(),
@@ -249,7 +243,7 @@ function groupAdminsCompare(req, res, next) {
// we only support memberuid here, if we add new group attributes later add them here
if (req.attribute === 'memberuid') {
var found = result.find(function (u) { return u.id === req.value; });
if (found && found.admin) return res.end(true);
if (found && (found.admin || req.app.ownerId == found.id)) return res.end(true);
}
res.end(false);
@@ -277,7 +271,6 @@ function mailboxSearch(req, res, next) {
cn: `${mailbox.name}@${mailbox.domain}`,
uid: `${mailbox.name}@${mailbox.domain}`,
mail: `${mailbox.name}@${mailbox.domain}`,
ownerType: mailbox.ownerType,
displayname: 'Max Mustermann',
givenName: 'Max',
username: 'mmustermann',
@@ -303,9 +296,6 @@ function mailboxSearch(req, res, next) {
var results = [];
// only send user mailboxes
result = result.filter(function (m) { return m.ownerType === mailboxdb.OWNER_TYPE_USER; });
// send mailbox objects
result.forEach(function (mailbox) {
var dn = ldap.parseDN(`cn=${mailbox.name}@${domain},domain=${domain},ou=mailboxes,dc=cloudron`);
@@ -317,8 +307,7 @@ function mailboxSearch(req, res, next) {
objectcategory: 'mailbox',
cn: `${mailbox.name}@${domain}`,
uid: `${mailbox.name}@${domain}`,
mail: `${mailbox.name}@${domain}`,
ownerType: mailbox.ownerType
mail: `${mailbox.name}@${domain}`
}
};
@@ -414,6 +403,7 @@ function mailingListSearch(req, res, next) {
});
}
// Will attach req.user if successful
function authenticateUser(req, res, next) {
debug('user bind: %s (from %s)', req.dn.toString(), req.connection.ldap.id);
@@ -445,21 +435,18 @@ function authenticateUser(req, res, next) {
}
function authorizeUserForApp(req, res, next) {
assert(req.user);
assert.strictEqual(typeof req.user, 'object');
assert.strictEqual(typeof req.app, 'object');
getAppByRequest(req, function (error, app) {
if (error) return next(error);
apps.hasAccessTo(req.app, req.user, function (error, result) {
if (error) return next(new ldap.OperationsError(error.toString()));
apps.hasAccessTo(app, req.user, function (error, result) {
if (error) return next(new ldap.OperationsError(error.toString()));
// we return no such object, to avoid leakage of a users existence
if (!result) return next(new ldap.NoSuchObjectError(req.dn.toString()));
// we return no such object, to avoid leakage of a users existence
if (!result) return next(new ldap.NoSuchObjectError(req.dn.toString()));
eventlog.add(eventlog.ACTION_USER_LOGIN, { authType: 'ldap', appId: req.app.id, app: req.app }, { userId: req.user.id, user: users.removePrivateFields(req.user) });
eventlog.add(eventlog.ACTION_USER_LOGIN, { authType: 'ldap', appId: app.id, app: app }, { userId: req.user.id, user: users.removePrivateFields(req.user) });
res.end();
});
res.end();
});
}
@@ -472,30 +459,30 @@ function authenticateMailbox(req, res, next) {
var parts = email.split('@');
if (parts.length !== 2) return next(new ldap.NoSuchObjectError(req.dn.toString()));
mailboxdb.getMailbox(parts[0], parts[1], function (error, mailbox) {
if (error && error.reason === DatabaseError.NOT_FOUND) return next(new ldap.NoSuchObjectError(req.dn.toString()));
const addonId = req.dn.rdns[1].attrs.ou.value.toLowerCase(); // 'sendmail' or 'recvmail'
mail.getDomain(parts[1], function (error, domain) {
if (error && error.reason === MailError.NOT_FOUND) return next(new ldap.NoSuchObjectError(req.dn.toString()));
if (error) return next(new ldap.OperationsError(error.message));
mail.getDomain(parts[1], function (error, domain) {
if (error && error.reason === MailError.NOT_FOUND) return next(new ldap.NoSuchObjectError(req.dn.toString()));
if (error) return next(new ldap.OperationsError(error.message));
if (addonId === 'recvmail' && !domain.enabled) return next(new ldap.NoSuchObjectError(req.dn.toString()));
if (mailbox.ownerType === mailboxdb.OWNER_TYPE_APP) {
var addonId = req.dn.rdns[1].attrs.ou.value.toLowerCase(); // 'sendmail' or 'recvmail'
var name;
if (addonId === 'sendmail') name = 'MAIL_SMTP_PASSWORD';
else if (addonId === 'recvmail') name = 'MAIL_IMAP_PASSWORD';
else return next(new ldap.OperationsError('Invalid DN'));
let name;
if (addonId === 'sendmail') name = 'MAIL_SMTP_PASSWORD';
else if (addonId === 'recvmail') name = 'MAIL_IMAP_PASSWORD';
else return next(new ldap.OperationsError('Invalid DN'));
appdb.getAddonConfigByName(mailbox.ownerId, addonId, name, function (error, value) {
if (error) return next(new ldap.OperationsError(error.message));
if (req.credentials !== value) return next(new ldap.InvalidCredentialsError(req.dn.toString()));
// note: with sendmail addon, apps can send mail without a mailbox (unlike users)
appdb.getAppIdByAddonConfigValue(addonId, name, req.credentials || '', function (error, appId) {
if (error && error.reason !== DatabaseError.NOT_FOUND) return next(new ldap.OperationsError(error.message));
if (appId) { // matched app password
eventlog.add(eventlog.ACTION_APP_LOGIN, { authType: 'ldap', mailboxId: email }, { appId: appId, addonId: addonId });
return res.end();
}
eventlog.add(eventlog.ACTION_APP_LOGIN, { authType: 'ldap', mailboxId: name }, { appId: mailbox.ownerId, addonId: addonId });
return res.end();
});
} else if (mailbox.ownerType === mailboxdb.OWNER_TYPE_USER) {
if (!domain.enabled) return next(new ldap.NoSuchObjectError(req.dn.toString()));
mailboxdb.getMailbox(parts[0], parts[1], function (error, mailbox) {
if (error && error.reason === DatabaseError.NOT_FOUND) return next(new ldap.NoSuchObjectError(req.dn.toString()));
if (error) return next(new ldap.OperationsError(error.message));
users.verify(mailbox.ownerId, req.credentials || '', function (error, result) {
if (error && error.reason === UsersError.NOT_FOUND) return next(new ldap.NoSuchObjectError(req.dn.toString()));
@@ -505,9 +492,7 @@ function authenticateMailbox(req, res, next) {
eventlog.add(eventlog.ACTION_USER_LOGIN, { authType: 'ldap', mailboxId: email }, { userId: result.id, user: users.removePrivateFields(result) });
res.end();
});
} else {
return next(new ldap.OperationsError('Unknown ownerType for mailbox'));
}
});
});
});
}
@@ -526,21 +511,20 @@ function start(callback) {
gServer = ldap.createServer({ log: logger });
gServer.search('ou=users,dc=cloudron', userSearch);
gServer.search('ou=groups,dc=cloudron', groupSearch);
gServer.bind('ou=users,dc=cloudron', authenticateUser, authorizeUserForApp);
gServer.search('ou=users,dc=cloudron', authenticateApp, userSearch);
gServer.search('ou=groups,dc=cloudron', authenticateApp, groupSearch);
gServer.bind('ou=users,dc=cloudron', authenticateApp, authenticateUser, authorizeUserForApp);
// http://www.ietf.org/proceedings/43/I-D/draft-srivastava-ldap-mail-00.txt
gServer.search('ou=mailboxes,dc=cloudron', mailboxSearch);
gServer.search('ou=mailaliases,dc=cloudron', mailAliasSearch);
gServer.search('ou=mailinglists,dc=cloudron', mailingListSearch);
gServer.bind('ou=mailboxes,dc=cloudron', authenticateMailbox);
gServer.bind('ou=recvmail,dc=cloudron', authenticateMailbox);
gServer.bind('ou=sendmail,dc=cloudron', authenticateMailbox);
gServer.bind('ou=recvmail,dc=cloudron', authenticateMailbox); // dovecot
gServer.bind('ou=sendmail,dc=cloudron', authenticateMailbox); // haraka
gServer.compare('cn=users,ou=groups,dc=cloudron', groupUsersCompare);
gServer.compare('cn=admins,ou=groups,dc=cloudron', groupAdminsCompare);
gServer.compare('cn=users,ou=groups,dc=cloudron', authenticateApp, groupUsersCompare);
gServer.compare('cn=admins,ou=groups,dc=cloudron', authenticateApp, groupAdminsCompare);
// this is the bind for addons (after bind, they might search and authenticate)
gServer.bind('ou=addons,dc=cloudron', function(req, res /*, next */) {
-1
View File
@@ -18,7 +18,6 @@ Locker.prototype.OP_BOX_UPDATE = 'box_update';
Locker.prototype.OP_PLATFORM_START = 'platform_start';
Locker.prototype.OP_FULL_BACKUP = 'full_backup';
Locker.prototype.OP_APPTASK = 'apptask';
Locker.prototype.OP_MIGRATE = 'migrate';
Locker.prototype.lock = function (operation) {
assert.strictEqual(typeof operation, 'string');
+2
View File
@@ -22,6 +22,8 @@ function collectLogs(unitName, callback) {
assert.strictEqual(typeof callback, 'function');
var logs = safe.child_process.execSync('sudo ' + COLLECT_LOGS_CMD + ' ' + unitName, { encoding: 'utf8' });
if (!logs) return callback(safe.error);
logs = logs + '\n\n=====================================\n\n';
callback(null, logs);
+107 -52
View File
@@ -8,6 +8,7 @@ exports = module.exports = {
getDomain: getDomain,
addDomain: addDomain,
removeDomain: removeDomain,
clearDomains: clearDomains,
setDnsRecords: setDnsRecords,
@@ -22,11 +23,11 @@ exports = module.exports = {
sendTestMail: sendTestMail,
getMailboxes: getMailboxes,
listMailboxes: listMailboxes,
removeMailboxes: removeMailboxes,
getMailbox: getMailbox,
addMailbox: addMailbox,
updateMailbox: updateMailbox,
updateMailboxOwner: updateMailboxOwner,
removeMailbox: removeMailbox,
listAliases: listAliases,
@@ -51,13 +52,14 @@ var assert = require('assert'),
debug = require('debug')('box:mail'),
dns = require('./native-dns.js'),
domains = require('./domains.js'),
eventlog = require('./eventlog.js'),
hat = require('./hat.js'),
infra = require('./infra_version.js'),
mailboxdb = require('./mailboxdb.js'),
maildb = require('./maildb.js'),
mailer = require('./mailer.js'),
net = require('net'),
nodemailer = require('nodemailer'),
os = require('os'),
path = require('path'),
paths = require('./paths.js'),
reverseProxy = require('./reverseproxy.js'),
@@ -108,9 +110,6 @@ function validateName(name) {
// also need to consider valid LDAP characters here (e.g '+' is reserved)
if (/[^a-zA-Z0-9.-]/.test(name)) return new MailError(MailError.BAD_FIELD, 'mailbox name can only contain alphanumerals and dot');
// app emails are sent using the .app suffix
if (name.indexOf('.app') !== -1) return new MailError(MailError.BAD_FIELD, 'mailbox name pattern is reserved for apps');
return null;
}
@@ -278,6 +277,15 @@ function checkMx(domain, callback) {
});
}
function txtToDict(txt) {
var dict = {};
txt.split(';').forEach(function(v) {
var p = v.trim().split('=');
dict[p[0]]=p[1];
});
return dict;
}
function checkDmarc(domain, callback) {
var dmarc = {
domain: '_dmarc.' + domain,
@@ -293,7 +301,9 @@ function checkDmarc(domain, callback) {
if (txtRecords.length !== 0) {
dmarc.value = txtRecords[0].join('');
dmarc.status = (dmarc.value === dmarc.expected);
// allow extra fields in dmarc like rua
const actual = txtToDict(dmarc.value), expected = txtToDict(dmarc.expected);
dmarc.status = Object.keys(expected).every(k => expected[k] === actual[k]);
}
callback(null, dmarc);
@@ -542,7 +552,8 @@ function restartMail(callback) {
if (process.env.BOX_ENV === 'test' && !process.env.TEST_CREATE_INFRA) return callback();
const tag = infra.images.mail.tag;
const memoryLimit = Math.max((1 + Math.round(os.totalmem()/(1024*1024*1024)/4)) * 128, 256);
const memoryLimit = 4 * 256;
const cloudronToken = hat(8 * 128);
// admin and mail share the same certificate
reverseProxy.getCertificate({ fqdn: config.adminFqdn(), domain: config.adminDomain() }, function (error, bundle) {
@@ -555,37 +566,53 @@ function restartMail(callback) {
if (!safe.child_process.execSync(`cp ${bundle.certFilePath} ${mailCertFilePath}`)) return callback(new Error('Could not create cert file:' + safe.error.message));
if (!safe.child_process.execSync(`cp ${bundle.keyFilePath} ${mailKeyFilePath}`)) return callback(new Error('Could not create key file:' + safe.error.message));
shell.execSync('startMail', 'docker rm -f mail || true');
createMailConfig(function (error, allowInbound) {
shell.exec('startMail', 'docker rm -f mail || true', function (error) {
if (error) return callback(error);
var ports = allowInbound ? '-p 587:2525 -p 993:9993 -p 4190:4190 -p 25:2525' : '';
createMailConfig(function (error, allowInbound) {
if (error) return callback(error);
const cmd = `docker run --restart=always -d --name="mail" \
--net cloudron \
--net-alias mail \
--log-driver syslog \
--log-opt syslog-address=udp://127.0.0.1:2514 \
--log-opt syslog-format=rfc5424 \
--log-opt tag=mail \
-m ${memoryLimit}m \
--memory-swap ${memoryLimit * 2}m \
--dns 172.18.0.1 \
--dns-search=. \
-v "${paths.MAIL_DATA_DIR}:/app/data" \
-v "${paths.PLATFORM_DATA_DIR}/addons/mail:/etc/mail" \
${ports} \
-p 127.0.0.1:2020:2020 \
--read-only -v /run -v /tmp ${tag}`;
var ports = allowInbound ? '-p 587:2525 -p 993:9993 -p 4190:4190 -p 25:2525' : '';
shell.execSync('startMail', cmd);
const cmd = `docker run --restart=always -d --name="mail" \
--net cloudron \
--net-alias mail \
--log-driver syslog \
--log-opt syslog-address=udp://127.0.0.1:2514 \
--log-opt syslog-format=rfc5424 \
--log-opt tag=mail \
-m ${memoryLimit}m \
--memory-swap ${memoryLimit * 2}m \
--dns 172.18.0.1 \
--dns-search=. \
-e CLOUDRON_MAIL_TOKEN="${cloudronToken}" \
-v "${paths.MAIL_DATA_DIR}:/app/data" \
-v "${paths.PLATFORM_DATA_DIR}/addons/mail:/etc/mail" \
${ports} \
-p 127.0.0.1:2020:2020 \
--label isCloudronManaged=true \
--read-only -v /run -v /tmp ${tag}`;
callback();
shell.exec('startMail', cmd, callback);
});
});
});
}
function restartMailIfActivated(callback) {
assert.strictEqual(typeof callback, 'function');
users.isActivated(function (error, activated) {
if (error) return callback(new MailError(MailError.INTERNAL_ERROR, error));
if (!activated) {
debug('restartMailIfActivated: skipping restart of mail container since Cloudron is not activated yet');
return callback(); // not provisioned yet, do not restart container after dns setup
}
restartMail(callback);
});
}
function getDomain(domain, callback) {
assert.strictEqual(typeof domain, 'string');
assert.strictEqual(typeof callback, 'function');
@@ -601,7 +628,7 @@ function getDomain(domain, callback) {
function getDomains(callback) {
assert.strictEqual(typeof callback, 'function');
maildb.getAll(function (error, results) {
maildb.list(function (error, results) {
if (error) return callback(new MailError(MailError.INTERNAL_ERROR, error));
return callback(null, results);
@@ -614,7 +641,7 @@ function txtRecordsWithSpf(domain, callback) {
assert.strictEqual(typeof callback, 'function');
domains.getDnsRecords('', domain, 'TXT', function (error, txtRecords) {
if (error) return callback(error);
if (error) return new MailError(MailError.EXTERNAL_ERROR, error.message);
debug('txtRecordsWithSpf: current txt records - %j', txtRecords);
@@ -718,22 +745,26 @@ function setDnsRecords(domain, callback) {
records.push({ subdomain: '', domain: domain, type: 'MX', values: [ '10 ' + config.mailFqdn() + '.' ] });
}
debug('addDnsRecords: %j', records);
debug('setDnsRecords: %j', records);
txtRecordsWithSpf(domain, function (error, txtRecords) {
if (error) return callback(error);
if (txtRecords) records.push({ subdomain: '', domain: domain, type: 'TXT', values: txtRecords });
debug('addDnsRecords: will update %j', records);
debug('setDnsRecords: will update %j', records);
async.mapSeries(records, function (record, iteratorCallback) {
domains.upsertDnsRecords(record.subdomain, record.domain, record.type, record.values, iteratorCallback);
}, function (error, changeIds) {
if (error) debug('addDnsRecords: failed to update : %s. will retry', error);
else debug('addDnsRecords: records %j added with changeIds %j', records, changeIds);
if (error) {
debug(`setDnsRecords: failed to update: ${error}`);
return callback(new MailError(MailError.EXTERNAL_ERROR, error.message));
}
callback(error);
debug('setDnsRecords: records %j added with changeIds %j', records, changeIds);
callback(null);
});
});
});
@@ -750,7 +781,7 @@ function addDomain(domain, callback) {
async.series([
setDnsRecords.bind(null, domain), // do this first to ensure DKIM keys
restartMail
restartMailIfActivated
], NOOP_CALLBACK); // do these asynchronously
callback();
@@ -761,6 +792,8 @@ function removeDomain(domain, callback) {
assert.strictEqual(typeof domain, 'string');
assert.strictEqual(typeof callback, 'function');
if (domain === config.adminDomain()) return callback(new MailError(MailError.IN_USE));
maildb.del(domain, function (error) {
if (error && error.reason === DatabaseError.IN_USE) return callback(new MailError(MailError.IN_USE));
if (error && error.reason === DatabaseError.NOT_FOUND) return callback(new MailError(MailError.NOT_FOUND, error.message));
@@ -772,6 +805,16 @@ function removeDomain(domain, callback) {
});
}
function clearDomains(callback) {
assert.strictEqual(typeof callback, 'function');
maildb.clear(function (error) {
if (error) return callback(new MailError(MailError.INTERNAL_ERROR, error));
callback();
});
}
function setMailFromValidation(domain, enabled, callback) {
assert.strictEqual(typeof domain, 'string');
assert.strictEqual(typeof enabled, 'boolean');
@@ -821,9 +864,10 @@ function setMailRelay(domain, relay, callback) {
});
}
function setMailEnabled(domain, enabled, callback) {
function setMailEnabled(domain, enabled, auditSource, callback) {
assert.strictEqual(typeof domain, 'string');
assert.strictEqual(typeof enabled, 'boolean');
assert.strictEqual(typeof auditSource, 'object');
assert.strictEqual(typeof callback, 'function');
maildb.update(domain, { enabled: enabled }, function (error) {
@@ -832,6 +876,8 @@ function setMailEnabled(domain, enabled, callback) {
restartMail(NOOP_CALLBACK);
eventlog.add(enabled ? eventlog.ACTION_MAIL_ENABLED : eventlog.ACTION_MAIL_DISABLED, auditSource, { domain });
callback(null);
});
}
@@ -850,7 +896,7 @@ function sendTestMail(domain, to, callback) {
});
}
function getMailboxes(domain, callback) {
function listMailboxes(domain, callback) {
assert.strictEqual(typeof domain, 'string');
assert.strictEqual(typeof callback, 'function');
@@ -885,10 +931,11 @@ function getMailbox(name, domain, callback) {
});
}
function addMailbox(name, domain, userId, callback) {
function addMailbox(name, domain, userId, auditSource, callback) {
assert.strictEqual(typeof name, 'string');
assert.strictEqual(typeof domain, 'string');
assert.strictEqual(typeof userId, 'string');
assert.strictEqual(typeof auditSource, 'object');
assert.strictEqual(typeof callback, 'function');
name = name.toLowerCase();
@@ -896,15 +943,17 @@ function addMailbox(name, domain, userId, callback) {
var error = validateName(name);
if (error) return callback(error);
mailboxdb.addMailbox(name, domain, userId, mailboxdb.OWNER_TYPE_USER, function (error) {
mailboxdb.addMailbox(name, domain, userId, function (error) {
if (error && error.reason === DatabaseError.ALREADY_EXISTS) return callback(new MailError(MailError.ALREADY_EXISTS, `mailbox ${name} already exists`));
if (error) return callback(new MailError(MailError.INTERNAL_ERROR, error));
eventlog.add(eventlog.ACTION_MAIL_MAILBOX_ADD, auditSource, { name, domain, userId });
callback(null);
});
}
function updateMailbox(name, domain, userId, callback) {
function updateMailboxOwner(name, domain, userId, callback) {
assert.strictEqual(typeof name, 'string');
assert.strictEqual(typeof domain, 'string');
assert.strictEqual(typeof userId, 'string');
@@ -912,10 +961,7 @@ function updateMailbox(name, domain, userId, callback) {
name = name.toLowerCase();
var error = validateName(name);
if (error) return callback(error);
mailboxdb.updateMailbox(name, domain, userId, mailboxdb.OWNER_TYPE_USER, function (error) {
mailboxdb.updateMailboxOwner(name, domain, userId, function (error) {
if (error && error.reason === DatabaseError.NOT_FOUND) return callback(new MailError(MailError.NOT_FOUND, 'no such mailbox'));
if (error) return callback(new MailError(MailError.INTERNAL_ERROR, error));
@@ -923,15 +969,18 @@ function updateMailbox(name, domain, userId, callback) {
});
}
function removeMailbox(name, domain, callback) {
function removeMailbox(name, domain, auditSource, callback) {
assert.strictEqual(typeof domain, 'string');
assert.strictEqual(typeof name, 'string');
assert.strictEqual(typeof auditSource, 'object');
assert.strictEqual(typeof callback, 'function');
mailboxdb.del(name, domain, function (error) {
if (error && error.reason === DatabaseError.NOT_FOUND) return callback(new MailError(MailError.NOT_FOUND, 'no such mailbox'));
if (error) return callback(new MailError(MailError.INTERNAL_ERROR, error));
eventlog.add(eventlog.ACTION_MAIL_MAILBOX_REMOVE, auditSource, { name, domain });
callback(null);
});
}
@@ -1016,10 +1065,11 @@ function getList(domain, listName, callback) {
});
}
function addList(name, domain, members, callback) {
function addList(name, domain, members, auditSource, callback) {
assert.strictEqual(typeof domain, 'string');
assert.strictEqual(typeof name, 'string');
assert(Array.isArray(members));
assert.strictEqual(typeof auditSource, 'object');
assert.strictEqual(typeof callback, 'function');
name = name.toLowerCase();
@@ -1038,6 +1088,8 @@ function addList(name, domain, members, callback) {
if (error && error.reason === DatabaseError.ALREADY_EXISTS) return callback(new MailError(MailError.ALREADY_EXISTS, 'list already exits'));
if (error) return callback(new MailError(MailError.INTERNAL_ERROR, error));
eventlog.add(eventlog.ACTION_MAIL_LIST_ADD, auditSource, { name, domain });
callback();
});
}
@@ -1068,15 +1120,18 @@ function updateList(name, domain, members, callback) {
});
}
function removeList(domain, listName, callback) {
function removeList(name, domain, auditSource, callback) {
assert.strictEqual(typeof name, 'string');
assert.strictEqual(typeof domain, 'string');
assert.strictEqual(typeof listName, 'string');
assert.strictEqual(typeof auditSource, 'object');
assert.strictEqual(typeof callback, 'function');
mailboxdb.del(listName, domain, function (error) {
mailboxdb.del(name, domain, function (error) {
if (error && error.reason === DatabaseError.NOT_FOUND) return callback(new MailError(MailError.NOT_FOUND, 'no such list'));
if (error) return callback(new MailError(MailError.INTERNAL_ERROR, error));
eventlog.add(eventlog.ACTION_MAIL_LIST_ADD, auditSource, { name, domain });
callback();
});
}
-17
View File
@@ -4,15 +4,6 @@ Dear <%= cloudronName %> Admin,
A new user with email <%= user.email %> was added to <%= cloudronName %>.
<% if (inviteLink) { %>
As requested, this user has not been sent an invitation email.
To set a password and perform any configuration on behalf of the user, please use this link:
<%= inviteLink %>
<% } %>
Powered by https://cloudron.io
<% } else { %>
@@ -27,14 +18,6 @@ Powered by https://cloudron.io
A new user with email <%= user.email %> was added to <%= cloudronName %>.
</p>
<% if (inviteLink) { %>
<p>
As requested, this user has not been sent an invitation email.<br/>
<br/>
<a href="<%= inviteLink %>">Set a password and perform any configuration on behalf of the user</a>
</p>
<% } %>
<br/>
<br/>
+11 -17
View File
@@ -4,7 +4,7 @@ exports = module.exports = {
addMailbox: addMailbox,
addGroup: addGroup,
updateMailbox: updateMailbox,
updateMailboxOwner: updateMailboxOwner,
updateList: updateList,
del: del,
@@ -29,11 +29,7 @@ exports = module.exports = {
TYPE_MAILBOX: 'mailbox',
TYPE_LIST: 'list',
TYPE_ALIAS: 'alias',
OWNER_TYPE_USER: 'user',
OWNER_TYPE_APP: 'app',
OWNER_TYPE_GROUP: 'group' // obsolete
TYPE_ALIAS: 'alias'
};
var assert = require('assert'),
@@ -42,7 +38,7 @@ var assert = require('assert'),
safe = require('safetydance'),
util = require('util');
var MAILBOX_FIELDS = [ 'name', 'type', 'ownerId', 'ownerType', 'aliasTarget', 'creationTime', 'membersJson', 'domain' ].join(',');
var MAILBOX_FIELDS = [ 'name', 'type', 'ownerId', 'aliasTarget', 'creationTime', 'membersJson', 'domain' ].join(',');
function postProcess(data) {
data.members = safe.JSON.parse(data.membersJson) || [ ];
@@ -51,14 +47,13 @@ function postProcess(data) {
return data;
}
function addMailbox(name, domain, ownerId, ownerType, callback) {
function addMailbox(name, domain, ownerId, callback) {
assert.strictEqual(typeof name, 'string');
assert.strictEqual(typeof domain, 'string');
assert.strictEqual(typeof ownerId, 'string');
assert.strictEqual(typeof ownerType, 'string');
assert.strictEqual(typeof callback, 'function');
database.query('INSERT INTO mailboxes (name, type, domain, ownerId, ownerType) VALUES (?, ?, ?, ?, ?)', [ name, exports.TYPE_MAILBOX, domain, ownerId, ownerType ], function (error) {
database.query('INSERT INTO mailboxes (name, type, domain, ownerId) VALUES (?, ?, ?, ?)', [ name, exports.TYPE_MAILBOX, domain, ownerId ], function (error) {
if (error && error.code === 'ER_DUP_ENTRY') return callback(new DatabaseError(DatabaseError.ALREADY_EXISTS, 'mailbox already exists'));
if (error) return callback(new DatabaseError(DatabaseError.INTERNAL_ERROR, error));
@@ -66,14 +61,13 @@ function addMailbox(name, domain, ownerId, ownerType, callback) {
});
}
function updateMailbox(name, domain, ownerId, ownerType, callback) {
function updateMailboxOwner(name, domain, ownerId, callback) {
assert.strictEqual(typeof name, 'string');
assert.strictEqual(typeof domain, 'string');
assert.strictEqual(typeof ownerId, 'string');
assert.strictEqual(typeof ownerType, 'string');
assert.strictEqual(typeof callback, 'function');
database.query('UPDATE mailboxes SET ownerId = ? WHERE name = ? AND domain = ? AND ownerType = ?', [ ownerId, name, domain, ownerType ], function (error, result) {
database.query('UPDATE mailboxes SET ownerId = ? WHERE name = ? AND domain = ?', [ ownerId, name, domain ], function (error, result) {
if (error) return callback(new DatabaseError(DatabaseError.INTERNAL_ERROR, error));
if (result.affectedRows === 0) return callback(new DatabaseError(DatabaseError.NOT_FOUND));
@@ -87,8 +81,8 @@ function addGroup(name, domain, members, callback) {
assert(Array.isArray(members));
assert.strictEqual(typeof callback, 'function');
database.query('INSERT INTO mailboxes (name, type, domain, ownerId, ownerType, membersJson) VALUES (?, ?, ?, ?, ?, ?)',
[ name, exports.TYPE_LIST, domain, 'admin', exports.OWNER_TYPE_GROUP, JSON.stringify(members) ], function (error) {
database.query('INSERT INTO mailboxes (name, type, domain, ownerId, membersJson) VALUES (?, ?, ?, ?, ?)',
[ name, exports.TYPE_LIST, domain, 'admin', JSON.stringify(members) ], function (error) {
if (error && error.code === 'ER_DUP_ENTRY') return callback(new DatabaseError(DatabaseError.ALREADY_EXISTS, 'mailbox already exists'));
if (error) return callback(new DatabaseError(DatabaseError.INTERNAL_ERROR, error));
@@ -259,8 +253,8 @@ function setAliasesForName(name, domain, aliases, callback) {
// clear existing aliases
queries.push({ query: 'DELETE FROM mailboxes WHERE aliasTarget = ? AND domain = ? AND type = ?', args: [ name, domain, exports.TYPE_ALIAS ] });
aliases.forEach(function (alias) {
queries.push({ query: 'INSERT INTO mailboxes (name, type, domain, aliasTarget, ownerId, ownerType) VALUES (?, ?, ?, ?, ?, ?)',
args: [ alias, exports.TYPE_ALIAS, domain, name, results[0].ownerId, results[0].ownerType ] });
queries.push({ query: 'INSERT INTO mailboxes (name, type, domain, aliasTarget, ownerId) VALUES (?, ?, ?, ?, ?)',
args: [ alias, exports.TYPE_ALIAS, domain, name, results[0].ownerId ] });
});
database.transaction(queries, function (error) {
+5 -4
View File
@@ -4,10 +4,10 @@ exports = module.exports = {
add: add,
del: del,
get: get,
getAll: getAll,
list: list,
update: update,
_clear: clear,
clear: clear,
TYPE_USER: 'user',
TYPE_APP: 'app',
@@ -49,7 +49,8 @@ function add(domain, callback) {
function clear(callback) {
assert.strictEqual(typeof callback, 'function');
database.query('TRUNCATE TABLE mail', [], function (error) {
// using TRUNCATE makes it fail foreign key check
database.query('DELETE FROM mail', [], function (error) {
if (error) return callback(new DatabaseError(DatabaseError.INTERNAL_ERROR, error));
callback(null);
});
@@ -81,7 +82,7 @@ function get(domain, callback) {
});
}
function getAll(callback) {
function list(callback) {
assert.strictEqual(typeof callback, 'function');
database.query('SELECT ' + MAILDB_FIELDS + ' FROM mail ORDER BY domain', function (error, results) {
+2 -4
View File
@@ -226,11 +226,10 @@ function sendInvite(user, invitor) {
});
}
function userAdded(user, inviteSent) {
function userAdded(user) {
assert.strictEqual(typeof user, 'object');
assert.strictEqual(typeof inviteSent, 'boolean');
debug('Sending mail for userAdded %s including invite link', inviteSent ? 'not' : '');
debug('Sending mail for userAdded');
getMailConfig(function (error, mailConfig) {
if (error) return debug('Error getting mail details:', error);
@@ -239,7 +238,6 @@ function userAdded(user, inviteSent) {
var templateData = {
user: user,
inviteLink: inviteSent ? null : `${config.adminOrigin()}/api/v1/session/account/setup.html?reset_token=${user.resetToken}&email=${encodeURIComponent(user.email)}`,
cloudronName: mailConfig.cloudronName,
cloudronAvatarUrl: config.adminOrigin() + '/api/v1/cloudron/avatar'
};
+1
View File
@@ -30,6 +30,7 @@ function resolve(hostname, rrtype, options, callback) {
// result is an empty array if there was no error but there is no record. when you query a random
// domain, it errors with ENOTFOUND. But if you query an existing domain (A record) but with different
// type (CNAME) it is not an error and empty array
// for TXT records, result is 2d array of strings
callback(error, result);
});
}
+3 -3
View File
@@ -7,7 +7,6 @@ var config = require('./config.js'),
exports = module.exports = {
CLOUDRON_DEFAULT_AVATAR_FILE: path.join(__dirname + '/../assets/avatar.png'),
INFRA_VERSION_FILE: path.join(config.baseDir(), 'platformdata/INFRA_VERSION'),
BACKUP_RESULT_FILE: path.join(config.baseDir(), 'platformdata/backup/result.txt'),
OLD_DATA_DIR: path.join(config.baseDir(), 'data'),
PLATFORM_DATA_DIR: path.join(config.baseDir(), 'platformdata'),
@@ -33,9 +32,10 @@ exports = module.exports = {
CLOUDRON_AVATAR_FILE: path.join(config.baseDir(), 'boxdata/avatar.png'),
UPDATE_CHECKER_FILE: path.join(config.baseDir(), 'boxdata/updatechecker.json'),
AUTO_PROVISION_FILE: path.join(config.baseDir(), 'configs/autoprovision.json'),
LOG_DIR: path.join(config.baseDir(), 'platformdata/logs'),
TASKS_LOG_DIR: path.join(config.baseDir(), 'platformdata/logs/tasks'),
// this pattern is for the cloudron logs API route to work
BACKUP_LOG_FILE: path.join(config.baseDir(), 'platformdata/logs/backup/app.log'),
UPDATER_LOG_FILE: path.join(config.baseDir(), 'platformdata/logs/updater/app.log')
};
+72 -215
View File
@@ -4,32 +4,31 @@ exports = module.exports = {
start: start,
stop: stop,
handleCertChanged: handleCertChanged
handleCertChanged: handleCertChanged,
// exported for testing
_isReady: false
};
var apps = require('./apps.js'),
var addons = require('./addons.js'),
apps = require('./apps.js'),
assert = require('assert'),
async = require('async'),
config = require('./config.js'),
debug = require('debug')('box:platform'),
fs = require('fs'),
hat = require('./hat.js'),
graphs = require('./graphs.js'),
infra = require('./infra_version.js'),
locker = require('./locker.js'),
mail = require('./mail.js'),
os = require('os'),
paths = require('./paths.js'),
reverseProxy = require('./reverseproxy.js'),
safe = require('safetydance'),
semver = require('semver'),
settings = require('./settings.js'),
shell = require('./shell.js'),
taskmanager = require('./taskmanager.js'),
util = require('util'),
_ = require('underscore');
var gPlatformReadyTimer = null;
var NOOP_CALLBACK = function (error) { if (error) debug(error); };
function start(callback) {
@@ -45,13 +44,11 @@ function start(callback) {
if (!existingInfra) existingInfra = { version: 'corrupt' };
}
settings.events.on(settings.PLATFORM_CONFIG_KEY, updateAddons);
// short-circuit for the restart case
if (_.isEqual(infra, existingInfra)) {
debug('platform is uptodate at version %s', infra.version);
emitPlatformReady();
onPlatformReady();
return callback();
}
@@ -63,261 +60,121 @@ function start(callback) {
async.series([
stopContainers.bind(null, existingInfra),
startAddons.bind(null, existingInfra),
removeOldImages,
// mark app state before we start addons. this gives the db import logic a chance to mark an app as errored
startApps.bind(null, existingInfra),
graphs.startGraphite.bind(null, existingInfra),
addons.startServices.bind(null, existingInfra),
fs.writeFile.bind(fs, paths.INFRA_VERSION_FILE, JSON.stringify(infra, null, 4))
], function (error) {
if (error) return callback(error);
locker.unlock(locker.OP_PLATFORM_START);
emitPlatformReady();
onPlatformReady();
callback();
});
}
function stop(callback) {
clearTimeout(gPlatformReadyTimer);
gPlatformReadyTimer = null;
exports.events = null;
taskmanager.pauseTasks(callback);
}
function updateAddons(platformConfig, callback) {
callback = callback || NOOP_CALLBACK;
function onPlatformReady() {
debug('onPlatformReady: platform is ready');
exports._isReady = true;
taskmanager.resumeTasks();
// TODO: this should possibly also rollback memory to default
async.eachSeries([ 'mysql', 'postgresql', 'mail', 'mongodb' ], function iterator(containerName, iteratorCallback) {
const containerConfig = platformConfig[containerName];
if (!containerConfig) return iteratorCallback();
applyPlatformConfig(NOOP_CALLBACK);
pruneInfraImages(NOOP_CALLBACK);
}
if (!containerConfig.memory || !containerConfig.memorySwap) return iteratorCallback();
function applyPlatformConfig(callback) {
// scale back db containers, if possible. this is retried because updating memory constraints can fail
// with failed to write to memory.memsw.limit_in_bytes: write /sys/fs/cgroup/memory/docker/xx/memory.memsw.limit_in_bytes: device or resource busy
const args = `update --memory ${containerConfig.memory} --memory-swap ${containerConfig.memorySwap} ${containerName}`.split(' ');
shell.exec(`update${containerName}`, '/usr/bin/docker', args, { }, iteratorCallback);
async.retry({ times: 10, interval: 5 * 60 * 1000 }, function (retryCallback) {
settings.getPlatformConfig(function (error, platformConfig) {
if (error) return retryCallback(error);
addons.updateServiceConfig(platformConfig, function (error) {
if (error) debug('Error updating services. Will rety in 5 minutes', platformConfig, error);
retryCallback(error);
});
});
}, callback);
}
function emitPlatformReady() {
// give some time for the platform to "settle". For example, mysql might still be initing the
// database dir and we cannot call service scripts until that's done.
// TODO: make this smarter to not wait for 15secs for the crash-restart case
gPlatformReadyTimer = setTimeout(function () {
debug('emitting platform ready');
gPlatformReadyTimer = null;
taskmanager.resumeTasks();
}, 15000);
}
function pruneInfraImages(callback) {
debug('pruneInfraImages: checking existing images');
function removeOldImages(callback) {
debug('removing old addon images');
// cannot blindly remove all unused images since redis image may not be used
const images = infra.baseImages.concat(Object.keys(infra.images).map(function (addon) { return infra.images[addon]; }));
for (var imageName in infra.images) {
if (imageName === 'redis') continue; // see #223
var image = infra.images[imageName];
debug('cleaning up images of %j', image);
var cmd = 'docker images "%s" | tail -n +2 | awk \'{ print $1 ":" $2 }\' | grep -v "%s" | xargs --no-run-if-empty docker rmi';
shell.execSync('removeOldImagesSync', util.format(cmd, image.repo, image.tag));
}
async.eachSeries(images, function (image, iteratorCallback) {
let output = safe.child_process.execSync(`docker images --digests ${image.repo} --format "{{.ID}} {{.Repository}}:{{.Tag}}@{{.Digest}}"`, { encoding: 'utf8' });
if (output === null) return iteratorCallback(safe.error);
callback();
let lines = output.trim().split('\n');
for (let line of lines) {
if (!line) continue;
let parts = line.split(' '); // [ ID, Repo:Tag@Digest ]
if (image.tag === parts[1]) continue; // keep
debug(`pruneInfraImages: removing unused image of ${image.repo}: ${line}`);
shell.exec('pruneInfraImages', `docker rmi ${parts[0]}`, iteratorCallback);
}
}, callback);
}
function stopContainers(existingInfra, callback) {
// TODO: be nice and stop addons cleanly (example, shutdown commands)
// always stop addons to restart them on any infra change, regardless of minor or major update
if (existingInfra.version !== infra.version) {
// TODO: only nuke containers with isCloudronManaged=true
debug('stopping all containers for infra upgrade');
shell.execSync('stopContainers', 'docker ps -qa | xargs --no-run-if-empty docker rm -f');
async.series([
shell.exec.bind(null, 'stopContainers', 'docker ps -qa | xargs --no-run-if-empty docker stop'),
shell.exec.bind(null, 'stopContainers', 'docker ps -qa | xargs --no-run-if-empty docker rm -f')
], callback);
} else {
assert(typeof infra.images, 'object');
var changedAddons = [ ];
for (var imageName in infra.images) {
if (imageName === 'redis') continue; // see #223
if (infra.images[imageName].tag !== existingInfra.images[imageName].tag) changedAddons.push(imageName);
}
debug('stopping addons for incremental infra update: %j', changedAddons);
debug('stopContainer: stopping addons for incremental infra update: %j', changedAddons);
let filterArg = changedAddons.map(function (c) { return `--filter 'name=${c}'`; }).join(' '); // name=c matches *c*. required for redis-{appid}
// ignore error if container not found (and fail later) so that this code works across restarts
shell.execSync('stopContainers', 'docker rm -f ' + changedAddons.join(' ') + ' || true');
async.series([
shell.exec.bind(null, 'stopContainers', `docker ps -qa ${filterArg} | xargs --no-run-if-empty docker stop || true`),
shell.exec.bind(null, 'stopContainers', `docker ps -qa ${filterArg} | xargs --no-run-if-empty docker rm -f || true`)
], callback);
}
callback();
}
function startGraphite(callback) {
const tag = infra.images.graphite.tag;
const dataDir = paths.PLATFORM_DATA_DIR;
const cmd = `docker run --restart=always -d --name="graphite" \
--net cloudron \
--net-alias graphite \
--log-driver syslog \
--log-opt syslog-address=udp://127.0.0.1:2514 \
--log-opt syslog-format=rfc5424 \
--log-opt tag=graphite \
-m 75m \
--memory-swap 150m \
--dns 172.18.0.1 \
--dns-search=. \
-p 127.0.0.1:2003:2003 \
-p 127.0.0.1:2004:2004 \
-p 127.0.0.1:8000:8000 \
-v "${dataDir}/graphite:/app/data" \
--read-only -v /tmp -v /run "${tag}"`;
shell.execSync('startGraphite', cmd);
callback();
}
function startMysql(callback) {
const tag = infra.images.mysql.tag;
const dataDir = paths.PLATFORM_DATA_DIR;
const rootPassword = hat(8 * 128);
const memoryLimit = (1 + Math.round(os.totalmem()/(1024*1024*1024)/4)) * 256;
if (!safe.fs.writeFileSync(paths.ADDON_CONFIG_DIR + '/mysql_vars.sh',
'MYSQL_ROOT_PASSWORD=' + rootPassword +'\nMYSQL_ROOT_HOST=172.18.0.1', 'utf8')) {
return callback(new Error('Could not create mysql var file:' + safe.error.message));
}
const cmd = `docker run --restart=always -d --name="mysql" \
--net cloudron \
--net-alias mysql \
--log-driver syslog \
--log-opt syslog-address=udp://127.0.0.1:2514 \
--log-opt syslog-format=rfc5424 \
--log-opt tag=mysql \
-m ${memoryLimit}m \
--memory-swap ${memoryLimit * 2}m \
--dns 172.18.0.1 \
--dns-search=. \
-v "${dataDir}/mysql:/var/lib/mysql" \
-v "${dataDir}/addons/mysql_vars.sh:/etc/mysql/mysql_vars.sh:ro" \
--read-only -v /tmp -v /run "${tag}"`;
shell.execSync('startMysql', cmd);
setTimeout(callback, 5000);
}
function startPostgresql(callback) {
const tag = infra.images.postgresql.tag;
const dataDir = paths.PLATFORM_DATA_DIR;
const rootPassword = hat(8 * 128);
const memoryLimit = (1 + Math.round(os.totalmem()/(1024*1024*1024)/4)) * 256;
if (!safe.fs.writeFileSync(paths.ADDON_CONFIG_DIR + '/postgresql_vars.sh', 'POSTGRESQL_ROOT_PASSWORD=' + rootPassword, 'utf8')) {
return callback(new Error('Could not create postgresql var file:' + safe.error.message));
}
const cmd = `docker run --restart=always -d --name="postgresql" \
--net cloudron \
--net-alias postgresql \
--log-driver syslog \
--log-opt syslog-address=udp://127.0.0.1:2514 \
--log-opt syslog-format=rfc5424 \
--log-opt tag=postgresql \
-m ${memoryLimit}m \
--memory-swap ${memoryLimit * 2}m \
--dns 172.18.0.1 \
--dns-search=. \
-v "${dataDir}/postgresql:/var/lib/postgresql" \
-v "${dataDir}/addons/postgresql_vars.sh:/etc/postgresql/postgresql_vars.sh:ro" \
--read-only -v /tmp -v /run "${tag}"`;
shell.execSync('startPostgresql', cmd);
setTimeout(callback, 5000);
}
function startMongodb(callback) {
const tag = infra.images.mongodb.tag;
const dataDir = paths.PLATFORM_DATA_DIR;
const rootPassword = hat(8 * 128);
const memoryLimit = (1 + Math.round(os.totalmem()/(1024*1024*1024)/4)) * 200;
if (!safe.fs.writeFileSync(paths.ADDON_CONFIG_DIR + '/mongodb_vars.sh', 'MONGODB_ROOT_PASSWORD=' + rootPassword, 'utf8')) {
return callback(new Error('Could not create mongodb var file:' + safe.error.message));
}
const cmd = `docker run --restart=always -d --name="mongodb" \
--net cloudron \
--net-alias mongodb \
--log-driver syslog \
--log-opt syslog-address=udp://127.0.0.1:2514 \
--log-opt syslog-format=rfc5424 \
--log-opt tag=mongodb \
-m ${memoryLimit}m \
--memory-swap ${memoryLimit * 2}m \
--dns 172.18.0.1 \
--dns-search=. \
-v "${dataDir}/mongodb:/var/lib/mongodb" \
-v "${dataDir}/addons/mongodb_vars.sh:/etc/mongodb_vars.sh:ro" \
--read-only -v /tmp -v /run "${tag}"`;
shell.execSync('startMongodb', cmd);
setTimeout(callback, 5000);
}
function startAddons(existingInfra, callback) {
var startFuncs = [ ];
// always start addons on any infra change, regardless of minor or major update
if (existingInfra.version !== infra.version) {
debug('startAddons: no existing infra or infra upgrade. starting all addons');
startFuncs.push(startGraphite, startMysql, startPostgresql, startMongodb, mail.startMail);
} else {
assert.strictEqual(typeof existingInfra.images, 'object');
if (infra.images.graphite.tag !== existingInfra.images.graphite.tag) startFuncs.push(startGraphite);
if (infra.images.mysql.tag !== existingInfra.images.mysql.tag) startFuncs.push(startMysql);
if (infra.images.postgresql.tag !== existingInfra.images.postgresql.tag) startFuncs.push(startPostgresql);
if (infra.images.mongodb.tag !== existingInfra.images.mongodb.tag) startFuncs.push(startMongodb);
if (infra.images.mail.tag !== existingInfra.images.mail.tag) startFuncs.push(mail.startMail);
debug('startAddons: existing infra. incremental addon create %j', startFuncs.map(function (f) { return f.name; }));
}
async.series(startFuncs, function (error) {
if (error) return callback(error);
settings.getPlatformConfig(function (error, platformConfig) {
if (error) return callback(error);
updateAddons(platformConfig, callback);
});
});
}
function startApps(existingInfra, callback) {
// Infra version change strategy:
// * no existing version - restore apps
// * major versions - restore apps
// * minor versions - reconfigure apps
if (existingInfra.version === infra.version) {
debug('startApp: apps are already uptodate');
callback();
} else if (existingInfra.version === 'none' || !semver.valid(existingInfra.version) || semver.major(existingInfra.version) !== semver.major(infra.version)) {
if (existingInfra.version === 'none') { // cloudron is being restored from backup
debug('startApps: restoring installed apps');
apps.restoreInstalledApps(callback);
} else {
} else if (existingInfra.version !== infra.version) {
debug('startApps: reconfiguring installed apps');
reverseProxy.removeAppConfigs(); // should we change the cert location, nginx will not start
apps.configureInstalledApps(callback);
} else {
debug('startApps: apps are already uptodate');
callback();
}
}
function handleCertChanged(cn) {
function handleCertChanged(cn, callback) {
assert.strictEqual(typeof cn, 'string');
assert.strictEqual(typeof callback, 'function');
if (cn === '*.' + config.adminDomain() || cn === config.adminFqdn()) {
mail.startMail(NOOP_CALLBACK);
}
debug('handleCertChanged', cn);
if (cn === '*.' + config.adminDomain() || cn === config.adminFqdn()) return mail.startMail(callback);
callback();
}
-59
View File
@@ -1,59 +0,0 @@
'use strict';
exports = module.exports = {
set: set,
setDetail: setDetail,
clear: clear,
getAll: getAll,
UPDATE: 'update',
BACKUP: 'backup',
MIGRATE: 'migrate'
};
var assert = require('assert'),
debug = require('debug')('box:progress');
// if progress.update or progress.backup are object, they will contain 'percent' and 'message' properties
// otherwise no such operation is currently ongoing
var progress = {
update: null,
backup: null,
migrate: null
};
// We use -1 for percentage to indicate errors
function set(tag, percent, message) {
assert.strictEqual(typeof tag, 'string');
assert.strictEqual(typeof percent, 'number');
assert.strictEqual(typeof message, 'string');
progress[tag] = {
percent: percent,
message: message,
detail: ''
};
debug('%s: %s %s', tag, percent, message);
}
function setDetail(tag, detail) {
assert.strictEqual(typeof tag, 'string');
assert.strictEqual(typeof detail, 'string');
if (!progress[tag]) return debug('[%s] %s', tag, detail);
progress[tag].detail = detail;
}
function clear(tag) {
assert.strictEqual(typeof tag, 'string');
progress[tag] = null;
debug('clearing %s', tag);
}
function getAll() {
return progress;
}
+268
View File
@@ -0,0 +1,268 @@
'use strict';
exports = module.exports = {
setup: setup,
restore: restore,
activate: activate,
ProvisionError: ProvisionError
};
var assert = require('assert'),
async = require('async'),
backups = require('./backups.js'),
BackupsError = require('./backups.js').BackupsError,
config = require('./config.js'),
constants = require('./constants.js'),
clients = require('./clients.js'),
cloudron = require('./cloudron.js'),
debug = require('debug')('box:provision'),
domains = require('./domains.js'),
DomainsError = domains.DomainsError,
eventlog = require('./eventlog.js'),
mail = require('./mail.js'),
path = require('path'),
safe = require('safetydance'),
semver = require('semver'),
settingsdb = require('./settingsdb.js'),
settings = require('./settings.js'),
shell = require('./shell.js'),
superagent = require('superagent'),
users = require('./users.js'),
UsersError = users.UsersError,
tld = require('tldjs'),
util = require('util');
var RESTART_CMD = path.join(__dirname, 'scripts/restart.sh');
var NOOP_CALLBACK = function (error) { if (error) debug(error); };
function ProvisionError(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(ProvisionError, Error);
ProvisionError.BAD_FIELD = 'Field error';
ProvisionError.BAD_STATE = 'Bad State';
ProvisionError.ALREADY_SETUP = 'Already Setup';
ProvisionError.INTERNAL_ERROR = 'Internal Error';
ProvisionError.EXTERNAL_ERROR = 'External Error';
ProvisionError.ALREADY_PROVISIONED = 'Already Provisioned';
function autoprovision(autoconf, callback) {
assert.strictEqual(typeof autoconf, 'object');
assert.strictEqual(typeof callback, 'function');
async.eachSeries(Object.keys(autoconf), function (key, iteratorDone) {
debug(`autoprovision: ${key}`);
switch (key) {
case 'appstoreConfig':
if (config.provider() === 'caas') { // skip registration
settingsdb.set(settings.APPSTORE_CONFIG_KEY, JSON.stringify(autoconf[key]), iteratorDone);
} else { // register cloudron
settings.setAppstoreConfig(autoconf[key], iteratorDone);
}
break;
case 'caasConfig':
settingsdb.set(settings.CAAS_CONFIG_KEY, JSON.stringify(autoconf[key]), iteratorDone);
break;
case 'backupConfig':
settings.setBackupConfig(autoconf[key], iteratorDone);
break;
default:
debug(`autoprovision: ${key} ignored`);
return iteratorDone();
}
}, function (error) {
if (error) return callback(new ProvisionError(ProvisionError.INTERNAL_ERROR, error));
callback(null);
});
}
function unprovision(callback) {
assert.strictEqual(typeof callback, 'function');
debug('unprovision');
config.setAdminDomain('');
config.setAdminFqdn('');
config.setAdminLocation('my');
// TODO: also cancel any existing configureWebadmin task
async.series([
mail.clearDomains,
domains.clear
], callback);
}
function setup(dnsConfig, autoconf, auditSource, callback) {
assert.strictEqual(typeof dnsConfig, 'object');
assert.strictEqual(typeof autoconf, 'object');
assert.strictEqual(typeof auditSource, 'object');
assert.strictEqual(typeof callback, 'function');
users.isActivated(function (error, activated) {
if (error) return callback(new ProvisionError(ProvisionError.INTERNAL_ERROR, error));
if (activated) return callback(new ProvisionError(ProvisionError.ALREADY_SETUP));
unprovision(function (error) {
if (error) return callback(new ProvisionError(ProvisionError.INTERNAL_ERROR, error));
let webadminStatus = cloudron.getWebadminStatus();
if (webadminStatus.configuring || webadminStatus.restore.active) return callback(new ProvisionError(ProvisionError.BAD_STATE, 'Already restoring or configuring'));
const domain = dnsConfig.domain.toLowerCase();
const zoneName = dnsConfig.zoneName ? dnsConfig.zoneName : (tld.getDomain(domain) || domain);
const adminFqdn = 'my' + (dnsConfig.config.hyphenatedSubdomains ? '-' : '.') + domain;
debug(`provision: Setting up Cloudron with domain ${domain} and zone ${zoneName} using admin fqdn ${adminFqdn}`);
let data = {
zoneName: zoneName,
provider: dnsConfig.provider,
config: dnsConfig.config,
fallbackCertificate: dnsConfig.fallbackCertificate || null,
tlsConfig: dnsConfig.tlsConfig || { provider: 'letsencrypt-prod' }
};
domains.add(domain, data, auditSource, function (error) {
if (error && error.reason === DomainsError.BAD_FIELD) return callback(new ProvisionError(ProvisionError.BAD_FIELD, error.message));
if (error && error.reason === DomainsError.ALREADY_EXISTS) return callback(new ProvisionError(ProvisionError.BAD_FIELD, error.message));
if (error) return callback(new ProvisionError(ProvisionError.INTERNAL_ERROR, error));
async.series([
mail.addDomain.bind(null, domain),
cloudron.setDashboardDomain.bind(null, domain), // triggers task to setup my. dns/cert/reverseproxy
autoprovision.bind(null, autoconf),
eventlog.add.bind(null, eventlog.ACTION_PROVISION, auditSource, { })
], callback);
});
});
});
}
function setTimeZone(ip, callback) {
assert.strictEqual(typeof ip, 'string');
assert.strictEqual(typeof callback, 'function');
debug('setTimeZone ip:%s', ip);
superagent.get('https://geolocation.cloudron.io/json').query({ ip: ip }).timeout(10 * 1000).end(function (error, result) {
if ((error && !error.response) || result.statusCode !== 200) {
debug('Failed to get geo location: %s', error.message);
return callback(null);
}
var timezone = safe.query(result.body, 'location.time_zone');
if (!timezone || typeof timezone !== 'string') {
debug('No timezone in geoip response : %j', result.body);
return callback(null);
}
debug('Setting timezone to ', timezone);
settings.setTimeZone(timezone, callback);
});
}
function activate(username, password, email, displayName, ip, auditSource, callback) {
assert.strictEqual(typeof username, 'string');
assert.strictEqual(typeof password, 'string');
assert.strictEqual(typeof email, 'string');
assert.strictEqual(typeof displayName, 'string');
assert.strictEqual(typeof ip, 'string');
assert.strictEqual(typeof auditSource, 'object');
assert.strictEqual(typeof callback, 'function');
debug('activating user:%s email:%s', username, email);
setTimeZone(ip, function () { }); // TODO: get this from user. note that timezone is detected based on the browser location and not the cloudron region
users.createOwner(username, password, email, displayName, auditSource, function (error, userObject) {
if (error && error.reason === UsersError.ALREADY_EXISTS) return callback(new ProvisionError(ProvisionError.ALREADY_PROVISIONED, 'Already activated'));
if (error && error.reason === UsersError.BAD_FIELD) return callback(new ProvisionError(ProvisionError.BAD_FIELD, error.message));
if (error) return callback(new ProvisionError(ProvisionError.INTERNAL_ERROR, error));
clients.addTokenByUserId('cid-webadmin', userObject.id, Date.now() + constants.DEFAULT_TOKEN_EXPIRATION, {}, function (error, result) {
if (error) return callback(new ProvisionError(ProvisionError.INTERNAL_ERROR, error));
eventlog.add(eventlog.ACTION_ACTIVATE, auditSource, { });
callback(null, {
userId: userObject.id,
token: result.accessToken,
expires: result.expires
});
setImmediate(cloudron.onActivated.bind(null, NOOP_CALLBACK)); // hack for now to not block the above http response
});
});
}
function restore(backupConfig, backupId, version, autoconf, auditSource, callback) {
assert.strictEqual(typeof backupConfig, 'object');
assert.strictEqual(typeof backupId, 'string');
assert.strictEqual(typeof version, 'string');
assert.strictEqual(typeof autoconf, 'object');
assert.strictEqual(typeof auditSource, 'object');
assert.strictEqual(typeof callback, 'function');
if (!semver.valid(version)) return callback(new ProvisionError(ProvisionError.BAD_STATE, 'version is not a valid semver'));
if (semver.major(config.version()) !== semver.major(version) || semver.minor(config.version()) !== semver.minor(version)) return callback(new ProvisionError(ProvisionError.BAD_STATE, `Run cloudron-setup with --version ${version} to restore from this backup`));
let webadminStatus = cloudron.getWebadminStatus();
if (webadminStatus.configuring || webadminStatus.restore.active) return callback(new ProvisionError(ProvisionError.BAD_STATE, 'Already restoring or configuring'));
users.isActivated(function (error, activated) {
if (error) return callback(new ProvisionError(ProvisionError.INTERNAL_ERROR, error));
if (activated) return callback(new ProvisionError(ProvisionError.ALREADY_PROVISIONED, 'Already activated'));
backups.testConfig(backupConfig, function (error) {
if (error && error.reason === BackupsError.BAD_FIELD) return callback(new ProvisionError(ProvisionError.BAD_FIELD, error.message));
if (error && error.reason === BackupsError.EXTERNAL_ERROR) return callback(new ProvisionError(ProvisionError.EXTERNAL_ERROR, error.message));
if (error) return callback(new ProvisionError(ProvisionError.INTERNAL_ERROR, error));
debug(`restore: restoring from ${backupId} from provider ${backupConfig.provider} with format ${backupConfig.format}`);
webadminStatus.restore.active = true;
webadminStatus.restore.error = null;
callback(null); // do no block
async.series([
backups.restore.bind(null, backupConfig, backupId, (progress) => debug(`restore: ${progress}`)),
eventlog.add.bind(null, eventlog.ACTION_RESTORE, auditSource, { backupId }),
autoprovision.bind(null, autoconf),
// currently, our suggested restore flow is after a dnsSetup. The dnSetup creates DKIM keys and updates the DNS
// for this reason, we have to re-setup DNS after a restore so it has DKIm from the backup
// Once we have a 100% IP based restore, we can skip this
mail.setDnsRecords.bind(null, config.adminDomain()),
shell.sudo.bind(null, 'restart', [ RESTART_CMD ], {})
], function (error) {
debug('restore:', error);
if (error) webadminStatus.restore.error = error.message;
webadminStatus.restore.active = false;
});
});
});
}
+289 -162
View File
@@ -6,11 +6,15 @@ exports = module.exports = {
setFallbackCertificate: setFallbackCertificate,
getFallbackCertificate: getFallbackCertificate,
generateFallbackCertificateSync: generateFallbackCertificateSync,
setAppCertificateSync: setAppCertificateSync,
validateCertificate: validateCertificate,
getCertificate: getCertificate,
renewAll: renewAll,
renewCerts: renewCerts,
configureDefaultServer: configureDefaultServer,
@@ -22,10 +26,10 @@ exports = module.exports = {
removeAppConfigs: removeAppConfigs,
// exported for testing
_getApi: getApi
_getCertApi: getCertApi
};
var acme = require('./cert/acme.js'),
var acme2 = require('./cert/acme2.js'),
apps = require('./apps.js'),
assert = require('assert'),
async = require('async'),
@@ -33,7 +37,7 @@ var acme = require('./cert/acme.js'),
config = require('./config.js'),
constants = require('./constants.js'),
crypto = require('crypto'),
debug = require('debug')('box:certificates'),
debug = require('debug')('box:reverseproxy'),
domains = require('./domains.js'),
ejs = require('ejs'),
eventlog = require('./eventlog.js'),
@@ -51,8 +55,7 @@ var acme = require('./cert/acme.js'),
util = require('util');
var NGINX_APPCONFIG_EJS = fs.readFileSync(__dirname + '/../setup/start/nginx/appconfig.ejs', { encoding: 'utf8' }),
RELOAD_NGINX_CMD = path.join(__dirname, 'scripts/reloadnginx.sh'),
NOOP_CALLBACK = function (error) { if (error) debug(error); };
RELOAD_NGINX_CMD = path.join(__dirname, 'scripts/reloadnginx.sh');
function ReverseProxyError(reason, errorOrMessage) {
assert.strictEqual(typeof reason, 'string');
@@ -77,33 +80,29 @@ ReverseProxyError.INTERNAL_ERROR = 'Internal Error';
ReverseProxyError.INVALID_CERT = 'Invalid certificate';
ReverseProxyError.NOT_FOUND = 'Not Found';
function getApi(app, callback) {
assert.strictEqual(typeof app, 'object');
function getCertApi(domainObject, callback) {
assert.strictEqual(typeof domainObject, 'object');
assert.strictEqual(typeof callback, 'function');
domains.get(app.domain, function (error, domain) {
if (error) return callback(error);
if (domainObject.tlsConfig.provider === 'fallback') return callback(null, fallback, { fallback: true });
if (domain.tlsConfig.provider === 'fallback') return callback(null, fallback, {});
var api = domainObject.tlsConfig.provider === 'caas' ? caas : acme2;
var api = domain.tlsConfig.provider === 'caas' ? caas : acme;
var options = { prod: false, performHttpAuthorization: false, wildcard: false, email: '' };
if (domainObject.tlsConfig.provider !== 'caas') { // matches 'le-prod' or 'letsencrypt-prod'
options.prod = domainObject.tlsConfig.provider.match(/.*-prod/) !== null;
options.performHttpAuthorization = domainObject.provider.match(/noop|manual|wildcard/) !== null;
options.wildcard = !!domainObject.tlsConfig.wildcard;
}
var options = { };
if (domain.tlsConfig.provider === 'caas') {
options.prod = true;
} else { // acme
options.prod = domain.tlsConfig.provider.match(/.*-prod/) !== null; // matches 'le-prod' or 'letsencrypt-prod'
}
// registering user with an email requires A or MX record (https://github.com/letsencrypt/boulder/issues/1197)
// we cannot use admin@fqdn because the user might not have set it up.
// we simply update the account with the latest email we have each time when getting letsencrypt certs
// https://github.com/ietf-wg-acme/acme/issues/30
users.getOwner(function (error, owner) {
options.email = error ? 'support@cloudron.io' : (owner.fallbackEmail || owner.email); // can error if not activated yet
// registering user with an email requires A or MX record (https://github.com/letsencrypt/boulder/issues/1197)
// we cannot use admin@fqdn because the user might not have set it up.
// we simply update the account with the latest email we have each time when getting letsencrypt certs
// https://github.com/ietf-wg-acme/acme/issues/30
users.getOwner(function (error, owner) {
options.email = error ? 'support@cloudron.io' : (owner.fallbackEmail || owner.email); // can error if not activated yet
callback(null, api, options);
});
callback(null, api, options);
});
}
@@ -115,39 +114,71 @@ function isExpiringSync(certFilePath, hours) {
var result = safe.child_process.spawnSync('/usr/bin/openssl', [ 'x509', '-checkend', String(60 * 60 * hours), '-in', certFilePath ]);
if (!result) return 3; // some error
debug('isExpiringSync: %s %s %s', certFilePath, result.stdout.toString('utf8').trim(), result.status);
return result.status === 1; // 1 - expired 0 - not expired
}
// checks if the certificate matches the options provided by user (like wildcard, le-staging etc)
function providerMatchesSync(domainObject, certFilePath, apiOptions) {
assert.strictEqual(typeof domainObject, 'object');
assert.strictEqual(typeof certFilePath, 'string');
assert.strictEqual(typeof apiOptions, 'object');
if (!fs.existsSync(certFilePath)) return false; // not found
if (apiOptions.fallback) return certFilePath.includes('.host.cert');
const subjectAndIssuer = safe.child_process.execSync(`/usr/bin/openssl x509 -noout -subject -issuer -in "${certFilePath}"`, { encoding: 'utf8' });
if (!subjectAndIssuer) return false; // something bad happenned
const subject = subjectAndIssuer.match(/^subject=(.*)$/m)[1];
const domain = subject.substr(subject.indexOf('=') + 1).trim(); // subject can be /CN=, CN=, CN = and other forms
const issuer = subjectAndIssuer.match(/^issuer=(.*)$/m)[1];
const isWildcardCert = domain.includes('*');
const isLetsEncryptProd = issuer.includes('Let\'s Encrypt Authority');
const issuerMismatch = (apiOptions.prod && !isLetsEncryptProd) || (!apiOptions.prod && isLetsEncryptProd);
// bare domain is not part of wildcard SAN
const wildcardMismatch = (domain !== domainObject.domain) && (apiOptions.wildcard && !isWildcardCert) || (!apiOptions.wildcard && isWildcardCert);
const mismatch = issuerMismatch || wildcardMismatch;
debug(`providerMatchesSync: ${certFilePath} subject=${subject} domain=${domain} issuer=${issuer} wildcard=${isWildcardCert}/${apiOptions.wildcard} prod=${isLetsEncryptProd}/${apiOptions.prod} match=${!mismatch}`);
return !mismatch;
}
// note: https://tools.ietf.org/html/rfc4346#section-7.4.2 (certificate_list) requires that the
// servers certificate appears first (and not the intermediate cert)
function validateCertificate(domain, cert, key) {
assert.strictEqual(typeof domain, 'string');
assert.strictEqual(typeof cert, 'string');
assert.strictEqual(typeof key, 'string');
function validateCertificate(location, domainObject, certificate) {
assert.strictEqual(typeof location, 'string');
assert.strictEqual(typeof domainObject, 'object');
assert(certificate && typeof certificate, 'object');
function matchesDomain(candidate) {
if (typeof candidate !== 'string') return false;
if (candidate === domain) return true;
if (candidate.indexOf('*') === 0 && candidate.slice(2) === domain.slice(domain.indexOf('.') + 1)) return true;
return false;
}
const cert = certificate.cert, key = certificate.key;
// check for empty cert and key strings
if (!cert && key) return new ReverseProxyError(ReverseProxyError.INVALID_CERT, 'missing cert');
if (cert && !key) return new ReverseProxyError(ReverseProxyError.INVALID_CERT, 'missing key');
// -checkhost checks for SAN or CN exclusively. SAN takes precedence and if present, ignores the CN.
var result = safe.child_process.execSync(`openssl x509 -noout -checkhost "${domain}"`, { encoding: 'utf8', input: cert });
if (!result) return new ReverseProxyError(ReverseProxyError.INVALID_CERT, 'Unable to get certificate subject.');
const fqdn = domains.fqdn(location, domainObject);
if (result.indexOf('does match certificate') === -1) return new ReverseProxyError(ReverseProxyError.INVALID_CERT, `Certificate is not valid for this domain. Expecting ${domain}`);
var result = safe.child_process.execSync(`openssl x509 -noout -checkhost "${fqdn}"`, { encoding: 'utf8', input: cert });
if (result === null) return new ReverseProxyError(ReverseProxyError.INVALID_CERT, 'Unable to get certificate subject:' + safe.error.message);
if (result.indexOf('does match certificate') === -1) return new ReverseProxyError(ReverseProxyError.INVALID_CERT, `Certificate is not valid for this domain. Expecting ${fqdn}`);
// http://httpd.apache.org/docs/2.0/ssl/ssl_faq.html#verify
var certModulus = safe.child_process.execSync('openssl x509 -noout -modulus', { encoding: 'utf8', input: cert });
if (certModulus === null) return new ReverseProxyError(ReverseProxyError.INVALID_CERT, `Unable to get cert modulus: ${safe.error.message}`);
var keyModulus = safe.child_process.execSync('openssl rsa -noout -modulus', { encoding: 'utf8', input: key });
if (keyModulus === null) return new ReverseProxyError(ReverseProxyError.INVALID_CERT, `Unable to get key modulus: ${safe.error.message}`);
if (certModulus !== keyModulus) return new ReverseProxyError(ReverseProxyError.INVALID_CERT, 'Key does not match the certificate.');
// check expiration
@@ -160,38 +191,65 @@ function validateCertificate(domain, cert, key) {
function reload(callback) {
if (process.env.BOX_ENV === 'test') return callback();
shell.sudo('reload', [ RELOAD_NGINX_CMD ], callback);
shell.sudo('reload', [ RELOAD_NGINX_CMD ], {}, callback);
}
function generateFallbackCertificateSync(domainObject) {
assert.strictEqual(typeof domainObject, 'object');
const domain = domainObject.domain;
const certFilePath = path.join(os.tmpdir(), `${domain}-${crypto.randomBytes(4).readUInt32LE(0)}.cert`);
const keyFilePath = path.join(os.tmpdir(), `${domain}-${crypto.randomBytes(4).readUInt32LE(0)}.key`);
let opensslConf = safe.fs.readFileSync('/etc/ssl/openssl.cnf', 'utf8');
// SAN must contain all the domains since CN check is based on implementation if SAN is found. -checkhost also checks only SAN if present!
let opensslConfWithSan;
let cn = domainObject.config.hyphenatedSubdomains ? domains.parentDomain(domain) : domain;
debug(`generateFallbackCertificateSync: domain=${domainObject.domain} cn=${cn} hyphenated=${domainObject.config.hyphenatedSubdomains}`);
opensslConfWithSan = `${opensslConf}\n[SAN]\nsubjectAltName=DNS:${domain},DNS:*.${cn}\n`;
let configFile = path.join(os.tmpdir(), 'openssl-' + crypto.randomBytes(4).readUInt32LE(0) + '.conf');
safe.fs.writeFileSync(configFile, opensslConfWithSan, 'utf8');
let certCommand = util.format(`openssl req -x509 -newkey rsa:2048 -keyout ${keyFilePath} -out ${certFilePath} -days 3650 -subj /CN=*.${cn} -extensions SAN -config ${configFile} -nodes`);
if (!safe.child_process.execSync(certCommand)) return { error: new ReverseProxyError(ReverseProxyError.INTERNAL_ERROR, safe.error.message) };
safe.fs.unlinkSync(configFile);
const cert = safe.fs.readFileSync(certFilePath, 'utf8');
if (!cert) return { error: safe.error };
safe.fs.unlinkSync(certFilePath);
const key = safe.fs.readFileSync(keyFilePath, 'utf8');
if (!key) return { error: safe.error };
safe.fs.unlinkSync(keyFilePath);
return { cert: cert, key: key, error: null };
}
function setFallbackCertificate(domain, fallback, callback) {
assert.strictEqual(typeof domain, 'string');
assert(fallback && typeof fallback === 'object');
assert.strictEqual(typeof fallback, 'object');
assert.strictEqual(typeof callback, 'function');
const certFilePath = path.join(paths.APP_CERTS_DIR, `${domain}.host.cert`);
const keyFilePath = path.join(paths.APP_CERTS_DIR, `${domain}.host.key`);
if (fallback) {
// backup the cert
if (fallback.restricted) { // restricted certs are not backed up
debug(`setFallbackCertificate: setting restricted certs for domain ${domain}`);
if (!safe.fs.writeFileSync(path.join(paths.NGINX_CERT_DIR, `${domain}.host.cert`), fallback.cert)) return callback(new ReverseProxyError(ReverseProxyError.INTERNAL_ERROR, safe.error.message));
if (!safe.fs.writeFileSync(path.join(paths.NGINX_CERT_DIR, `${domain}.host.key`), fallback.key)) return callback(new ReverseProxyError(ReverseProxyError.INTERNAL_ERROR, safe.error.message));
} else {
debug(`setFallbackCertificate: setting certs for domain ${domain}`);
if (!safe.fs.writeFileSync(path.join(paths.APP_CERTS_DIR, `${domain}.host.cert`), fallback.cert)) return callback(new ReverseProxyError(ReverseProxyError.INTERNAL_ERROR, safe.error.message));
if (!safe.fs.writeFileSync(path.join(paths.APP_CERTS_DIR, `${domain}.host.key`), fallback.key)) return callback(new ReverseProxyError(ReverseProxyError.INTERNAL_ERROR, safe.error.message));
} else if (!fs.existsSync(certFilePath) || !fs.existsSync(keyFilePath)) { // generate it
let opensslConf = safe.fs.readFileSync('/etc/ssl/openssl.cnf', 'utf8');
// SAN must contain all the domains since CN check is based on implementation if SAN is found. -checkhost also checks only SAN if present!
let opensslConfWithSan = `${opensslConf}\n[SAN]\nsubjectAltName=DNS:${domain},DNS:*.${domain}\n`;
let configFile = path.join(os.tmpdir(), 'openssl-' + crypto.randomBytes(4).readUInt32LE(0) + '.conf');
safe.fs.writeFileSync(configFile, opensslConfWithSan, 'utf8');
let certCommand = util.format(`openssl req -x509 -newkey rsa:2048 -keyout ${keyFilePath} -out ${certFilePath} -days 3650 -subj /CN=*.${domain} -extensions SAN -config ${configFile} -nodes`);
if (!safe.child_process.execSync(certCommand)) return callback(new ReverseProxyError(ReverseProxyError.INTERNAL_ERROR, safe.error.message));
safe.fs.unlinkSync(configFile);
}
platform.handleCertChanged('*.' + domain);
reload(function (error) {
platform.handleCertChanged('*.' + domain, function (error) {
if (error) return callback(new ReverseProxyError(ReverseProxyError.INTERNAL_ERROR, error));
return callback(null);
reload(function (error) {
if (error) return callback(new ReverseProxyError(ReverseProxyError.INTERNAL_ERROR, error));
return callback(null);
});
});
}
@@ -203,83 +261,119 @@ function getFallbackCertificate(domain, callback) {
var certFilePath = path.join(paths.NGINX_CERT_DIR, `${domain}.host.cert`);
var keyFilePath = path.join(paths.NGINX_CERT_DIR, `${domain}.host.key`);
if (fs.existsSync(certFilePath) && fs.existsSync(keyFilePath)) return callback(null, { certFilePath, keyFilePath });
if (fs.existsSync(certFilePath) && fs.existsSync(keyFilePath)) return callback(null, { certFilePath, keyFilePath, type: 'provisioned' });
// check for auto-generated or user set fallback certs
certFilePath = path.join(paths.APP_CERTS_DIR, `${domain}.host.cert`);
keyFilePath = path.join(paths.APP_CERTS_DIR, `${domain}.host.key`);
callback(null, { certFilePath, keyFilePath });
callback(null, { certFilePath, keyFilePath, type: 'fallback' });
}
function setAppCertificateSync(location, domainObject, certificate) {
assert.strictEqual(typeof location, 'string');
assert.strictEqual(typeof domainObject, 'object');
assert.strictEqual(typeof certificate, 'object');
let fqdn = domains.fqdn(location, domainObject);
if (certificate.cert && certificate.key) {
if (!safe.fs.writeFileSync(path.join(paths.APP_CERTS_DIR, `${fqdn}.user.cert`), certificate.cert)) return safe.error;
if (!safe.fs.writeFileSync(path.join(paths.APP_CERTS_DIR, `${fqdn}.user.key`), certificate.key)) return safe.error;
} else { // remove existing cert/key
if (!safe.fs.unlinkSync(path.join(paths.APP_CERTS_DIR, `${fqdn}.user.cert`))) debug('Error removing cert: ' + safe.error.message);
if (!safe.fs.unlinkSync(path.join(paths.APP_CERTS_DIR, `${fqdn}.user.key`))) debug('Error removing key: ' + safe.error.message);
}
return null;
}
function getCertificateByHostname(hostname, domainObject, callback) {
assert.strictEqual(typeof hostname, 'string');
assert.strictEqual(typeof domainObject, 'object');
assert.strictEqual(typeof callback, 'function');
let certFilePath = path.join(paths.APP_CERTS_DIR, `${hostname}.user.cert`);
let keyFilePath = path.join(paths.APP_CERTS_DIR, `${hostname}.user.key`);
if (fs.existsSync(certFilePath) && fs.existsSync(keyFilePath)) return callback(null, { certFilePath, keyFilePath });
if (hostname !== domainObject.domain && domainObject.tlsConfig.wildcard) { // bare domain is not part of wildcard SAN
let certName = domains.makeWildcard(hostname).replace('*.', '_.');
certFilePath = path.join(paths.APP_CERTS_DIR, `${certName}.cert`);
keyFilePath = path.join(paths.APP_CERTS_DIR, `${certName}.key`);
if (fs.existsSync(certFilePath) && fs.existsSync(keyFilePath)) return callback(null, { certFilePath, keyFilePath });
} else {
certFilePath = path.join(paths.APP_CERTS_DIR, `${hostname}.cert`);
keyFilePath = path.join(paths.APP_CERTS_DIR, `${hostname}.key`);
if (fs.existsSync(certFilePath) && fs.existsSync(keyFilePath)) return callback(null, { certFilePath, keyFilePath });
}
callback(null);
}
function getCertificate(app, callback) {
assert.strictEqual(typeof app, 'object');
assert.strictEqual(typeof callback, 'function');
var certFilePath = path.join(paths.APP_CERTS_DIR, `${app.fqdn}.user.cert`);
var keyFilePath = path.join(paths.APP_CERTS_DIR, `${app.fqdn}.user.key`);
if (fs.existsSync(certFilePath) && fs.existsSync(keyFilePath)) return callback(null, { certFilePath, keyFilePath });
certFilePath = path.join(paths.APP_CERTS_DIR, `${app.fqdn}.cert`);
keyFilePath = path.join(paths.APP_CERTS_DIR, `${app.fqdn}.key`);
if (fs.existsSync(certFilePath) && fs.existsSync(keyFilePath)) return callback(null, { certFilePath, keyFilePath });
return getFallbackCertificate(app.domain, callback);
}
function ensureCertificate(app, auditSource, callback) {
assert.strictEqual(typeof app, 'object');
assert.strictEqual(typeof auditSource, 'object');
assert.strictEqual(typeof callback, 'function');
const vhost = app.fqdn;
var certFilePath = path.join(paths.APP_CERTS_DIR, `${vhost}.user.cert`);
var keyFilePath = path.join(paths.APP_CERTS_DIR, `${vhost}.user.key`);
if (fs.existsSync(certFilePath) && fs.existsSync(keyFilePath)) {
debug('ensureCertificate: %s. user certificate already exists at %s', vhost, keyFilePath);
return callback(null, { certFilePath, keyFilePath, reason: 'user' });
}
certFilePath = path.join(paths.APP_CERTS_DIR, `${vhost}.cert`);
keyFilePath = path.join(paths.APP_CERTS_DIR, `${vhost}.key`);
if (fs.existsSync(certFilePath) && fs.existsSync(keyFilePath)) {
debug('ensureCertificate: %s. certificate already exists at %s', vhost, keyFilePath);
if (!isExpiringSync(certFilePath, 24 * 30)) return callback(null, { certFilePath, keyFilePath, reason: 'existing-le' });
debug('ensureCertificate: %s cert require renewal', vhost);
} else {
debug('ensureCertificate: %s cert does not exist', vhost);
}
getApi(app, function (error, api, apiOptions) {
domains.get(app.domain, function (error, domainObject) {
if (error) return callback(error);
debug('ensureCertificate: getting certificate for %s with options %j', vhost, apiOptions);
getCertificateByHostname(app.fqdn, domainObject, function (error, result) {
if (error || result) return callback(error, result);
api.getCertificate(vhost, apiOptions, function (error, certFilePath, keyFilePath) {
var errorMessage = error ? error.message : '';
if (error) {
debug('ensureCertificate: could not get certificate. using fallback certs', error);
mailer.certificateRenewalError(vhost, errorMessage);
}
eventlog.add(eventlog.ACTION_CERTIFICATE_RENEWAL, auditSource, { domain: vhost, errorMessage: errorMessage });
// if no cert was returned use fallback. the fallback/caas provider will not provide any for example
if (!certFilePath || !keyFilePath) return getFallbackCertificate(app.domain, callback);
callback(null, { certFilePath, keyFilePath, reason: 'new-le' });
return getFallbackCertificate(app.domain, callback);
});
});
}
function configureAdminInternal(bundle, configFileName, vhost, callback) {
function ensureCertificate(vhost, domain, auditSource, callback) {
assert.strictEqual(typeof vhost, 'string');
assert.strictEqual(typeof domain, 'string');
assert.strictEqual(typeof auditSource, 'object');
assert.strictEqual(typeof callback, 'function');
domains.get(domain, function (error, domainObject) {
if (error) return callback(error);
getCertApi(domainObject, function (error, api, apiOptions) {
if (error) return callback(error);
getCertificateByHostname(vhost, domainObject, function (error, currentBundle) {
if (currentBundle) {
debug(`ensureCertificate: ${vhost} certificate already exists at ${currentBundle.keyFilePath}`);
if (currentBundle.certFilePath.endsWith('.user.cert')) return callback(null, currentBundle); // user certs cannot be renewed
if (!isExpiringSync(currentBundle.certFilePath, 24 * 30) && providerMatchesSync(domainObject, currentBundle.certFilePath, apiOptions)) return callback(null, currentBundle);
debug(`ensureCertificate: ${vhost} cert require renewal`);
} else {
debug(`ensureCertificate: ${vhost} cert does not exist`);
}
debug('ensureCertificate: getting certificate for %s with options %j', vhost, apiOptions);
api.getCertificate(vhost, domain, apiOptions, function (error, certFilePath, keyFilePath) {
var errorMessage = error ? error.message : '';
if (error) {
debug('ensureCertificate: could not get certificate. using fallback certs', error);
mailer.certificateRenewalError(vhost, errorMessage);
}
eventlog.add(currentBundle ? eventlog.ACTION_CERTIFICATE_RENEWAL : eventlog.ACTION_CERTIFICATE_NEW, auditSource, { domain: vhost, errorMessage: errorMessage });
// if no cert was returned use fallback. the fallback/caas provider will not provide any for example
if (!certFilePath || !keyFilePath) return getFallbackCertificate(domain, callback);
callback(null, { certFilePath, keyFilePath, type: 'new-le' });
});
});
});
});
}
function writeAdminConfig(bundle, configFileName, vhost, callback) {
assert.strictEqual(typeof bundle, 'object');
assert.strictEqual(typeof configFileName, 'string');
assert.strictEqual(typeof vhost, 'string');
@@ -308,15 +402,14 @@ function configureAdmin(auditSource, callback) {
assert.strictEqual(typeof auditSource, 'object');
assert.strictEqual(typeof callback, 'function');
var adminApp = { domain: config.adminDomain(), fqdn: config.adminFqdn() };
ensureCertificate(adminApp, auditSource, function (error, bundle) {
ensureCertificate(config.adminFqdn(), config.adminDomain(), auditSource, function (error, bundle) {
if (error) return callback(error);
configureAdminInternal(bundle, constants.NGINX_ADMIN_CONFIG_FILE_NAME, config.adminFqdn(), callback);
writeAdminConfig(bundle, constants.NGINX_ADMIN_CONFIG_FILE_NAME, config.adminFqdn(), callback);
});
}
function configureAppInternal(app, bundle, callback) {
function writeAppConfig(app, bundle, callback) {
assert.strictEqual(typeof app, 'object');
assert.strictEqual(typeof bundle, 'object');
assert.strictEqual(typeof callback, 'function');
@@ -349,7 +442,7 @@ function configureAppInternal(app, bundle, callback) {
reload(callback);
}
function configureAppRedirect(app, fqdn, bundle, callback) {
function writeAppRedirectConfig(app, fqdn, bundle, callback) {
assert.strictEqual(typeof app, 'object');
assert.strictEqual(typeof fqdn, 'string');
assert.strictEqual(typeof bundle, 'object');
@@ -385,20 +478,17 @@ function configureApp(app, auditSource, callback) {
assert.strictEqual(typeof auditSource, 'object');
assert.strictEqual(typeof callback, 'function');
ensureCertificate({ fqdn: app.fqdn, domain: app.domain }, auditSource, function (error, bundle) {
ensureCertificate(app.fqdn, app.domain, auditSource, function (error, bundle) {
if (error) return callback(error);
configureAppInternal(app, bundle, function (error) {
writeAppConfig(app, bundle, function (error) {
if (error) return callback(error);
// now setup alternateDomain redirects if any
async.eachSeries(app.alternateDomains, function (domain, callback) {
var fqdn = (domain.subdomain ? (domain.subdomain + '.') : '') + domain.domain;
ensureCertificate({ fqdn: fqdn, domain: domain.domain }, auditSource, function (error, bundle) {
async.eachSeries(app.alternateDomains, function (alternateDomain, callback) {
ensureCertificate(alternateDomain.fqdn, alternateDomain.domain, auditSource, function (error, bundle) {
if (error) return callback(error);
configureAppRedirect(app, fqdn, bundle, callback);
writeAppRedirectConfig(app, alternateDomain.fqdn, bundle, callback);
});
}, callback);
});
@@ -417,47 +507,82 @@ function unconfigureApp(app, callback) {
});
}
function renewCerts(options, auditSource, progressCallback, callback) {
assert.strictEqual(typeof options, 'object');
assert.strictEqual(typeof auditSource, 'object');
assert.strictEqual(typeof progressCallback, 'function');
assert.strictEqual(typeof callback, 'function');
apps.getAll(function (error, allApps) {
if (error) return callback(error);
var appDomains = [];
// add webadmin domain
appDomains.push({ domain: config.adminDomain(), fqdn: config.adminFqdn(), type: 'webadmin', nginxConfigFilename: path.join(paths.NGINX_APPCONFIG_DIR, constants.NGINX_ADMIN_CONFIG_FILE_NAME) });
// add app main
allApps.forEach(function (app) {
appDomains.push({ domain: app.domain, fqdn: app.fqdn, type: 'main', app: app, nginxConfigFilename: path.join(paths.NGINX_APPCONFIG_DIR, app.id + '.conf') });
app.alternateDomains.forEach(function (alternateDomain) {
let nginxConfigFilename = path.join(paths.NGINX_APPCONFIG_DIR, `${app.id}-redirect-${alternateDomain.fqdn}.conf`);
appDomains.push({ domain: alternateDomain.domain, fqdn: alternateDomain.fqdn, type: 'alternate', app: app, nginxConfigFilename: nginxConfigFilename });
});
});
if (options.domain) appDomains = appDomains.filter(function (appDomain) { return appDomain.domain === options.domain; });
let progress = 1;
async.eachSeries(appDomains, function (appDomain, iteratorCallback) {
progressCallback({ percent: progress, message: `Renewing certs of ${appDomain.fqdn}` });
progress += Math.round(100/appDomains.length);
ensureCertificate(appDomain.fqdn, appDomain.domain, auditSource, function (error, bundle) {
if (error) return iteratorCallback(error); // this can happen if cloudron is not setup yet
// hack to check if the app's cert changed or not. this doesn't handle prod/staging le change since they use same file name
let currentNginxConfig = safe.fs.readFileSync(appDomain.nginxConfigFilename, 'utf8') || '';
if (currentNginxConfig.includes(bundle.certFilePath)) return iteratorCallback();
debug(`renewCerts: creating new nginx config since ${appDomain.nginxConfigFilename} does not have ${bundle.certFilePath}`);
// reconfigure since the cert changed
var configureFunc;
if (appDomain.type === 'webadmin') configureFunc = writeAdminConfig.bind(null, bundle, constants.NGINX_ADMIN_CONFIG_FILE_NAME, config.adminFqdn());
else if (appDomain.type === 'main') configureFunc = writeAppConfig.bind(null, appDomain.app, bundle);
else if (appDomain.type === 'alternate') configureFunc = writeAppRedirectConfig.bind(null, appDomain.app, appDomain.fqdn, bundle);
else return iteratorCallback(new Error(`Unknown domain type for ${appDomain.fqdn}. This should never happen`));
configureFunc(function (ignoredError) {
if (ignoredError) debug('renewAll: error reconfiguring app', ignoredError);
platform.handleCertChanged(appDomain.fqdn, iteratorCallback);
});
});
}, callback);
});
}
function renewAll(auditSource, callback) {
assert.strictEqual(typeof auditSource, 'object');
assert.strictEqual(typeof callback, 'function');
debug('renewAll: Checking certificates for renewal');
apps.getAll(function (error, allApps) {
if (error) return callback(error);
allApps.push({ domain: config.adminDomain(), fqdn: config.adminFqdn() }); // inject fake webadmin app
async.eachSeries(allApps, function (app, iteratorCallback) {
ensureCertificate(app, auditSource, function (error, bundle) {
if (error) return iteratorCallback(error); // this can happen if cloudron is not setup yet
if (bundle.reason !== 'new-le' && bundle.reason !== 'fallback') return iteratorCallback();
// reconfigure for the case where we got a renewed cert after fallback
var configureFunc = app.fqdn === config.adminFqdn() ?
configureAdminInternal.bind(null, bundle, constants.NGINX_ADMIN_CONFIG_FILE_NAME, config.adminFqdn())
: configureAppInternal.bind(null, app, bundle);
configureFunc(function (ignoredError) {
if (ignoredError) debug('fallbackExpiredCertificates: error reconfiguring app', ignoredError);
platform.handleCertChanged(app.fqdn);
iteratorCallback(); // move to next app
});
});
});
});
renewCerts({}, auditSource, callback);
}
function removeAppConfigs() {
for (var appConfigFile of fs.readdirSync(paths.NGINX_APPCONFIG_DIR)) {
fs.unlinkSync(path.join(paths.NGINX_APPCONFIG_DIR, appConfigFile));
for (let appConfigFile of fs.readdirSync(paths.NGINX_APPCONFIG_DIR)) {
if (appConfigFile !== constants.NGINX_DEFAULT_CONFIG_FILE_NAME && appConfigFile !== constants.NGINX_ADMIN_CONFIG_FILE_NAME) {
fs.unlinkSync(path.join(paths.NGINX_APPCONFIG_DIR, appConfigFile));
}
}
}
function configureDefaultServer(callback) {
callback = callback || NOOP_CALLBACK;
assert.strictEqual(typeof callback, 'function');
var certFilePath = path.join(paths.NGINX_CERT_DIR, 'default.cert');
var keyFilePath = path.join(paths.NGINX_CERT_DIR, 'default.key');
@@ -466,11 +591,13 @@ function configureDefaultServer(callback) {
debug('configureDefaultServer: create new cert');
var cn = 'cloudron-' + (new Date()).toISOString(); // randomize date a bit to keep firefox happy
var certCommand = util.format('openssl req -x509 -newkey rsa:2048 -keyout %s -out %s -days 3650 -subj /CN=%s -nodes', keyFilePath, certFilePath, cn);
safe.child_process.execSync(certCommand);
if (!safe.child_process.execSync(`openssl req -x509 -newkey rsa:2048 -keyout ${keyFilePath} -out ${certFilePath} -days 3650 -subj /CN=${cn} -nodes`)) {
debug(`configureDefaultServer: could not generate certificate: ${safe.error.message}`);
return callback(safe.error);
}
}
configureAdminInternal({ certFilePath, keyFilePath }, 'default.conf', '', function (error) {
writeAdminConfig({ certFilePath, keyFilePath }, constants.NGINX_DEFAULT_CONFIG_FILE_NAME, '', function (error) {
if (error) return callback(error);
debug('configureDefaultServer: done');
+7 -24
View File
@@ -4,24 +4,23 @@ exports = module.exports = {
initialize: initialize,
uninitialize: uninitialize,
isUnmanaged: isUnmanaged,
scope: scope,
websocketAuth: websocketAuth,
verifyAppOwnership: verifyAppOwnership
websocketAuth: websocketAuth
};
var accesscontrol = require('../accesscontrol.js'),
apps = require('../apps.js'),
AppsError = apps.AppsError,
assert = require('assert'),
BasicStrategy = require('passport-http').BasicStrategy,
BearerStrategy = require('passport-http-bearer').Strategy,
clients = require('../clients.js'),
ClientPasswordStrategy = require('passport-oauth2-client-password').Strategy,
ClientsError = clients.ClientsError,
config = require('../config.js'),
HttpError = require('connect-lastmile').HttpError,
LocalStrategy = require('passport-local').Strategy,
passport = require('passport'),
settings = require('../settings.js'),
users = require('../users.js'),
UsersError = users.UsersError;
@@ -143,24 +142,8 @@ function websocketAuth(requiredScopes, req, res, next) {
});
}
function verifyAppOwnership(req, res, next) {
if (req.user.admin) return next();
function isUnmanaged(req, res, next) {
if (!config.isManaged()) return next();
const appCreate = !('id' in req.params);
settings.getSpacesConfig(function (error, spaces) {
if (error) return next(new HttpError(500, error));
if (!spaces.enabled) return next();
if (appCreate) return next(); // ok to install app
apps.get(req.params.id, function (error, app) {
if (error && error.reason === AppsError.NOT_FOUND) return next(new HttpError(404, 'No such app'));
if (error) return next(new HttpError(500, error));
if (app.ownerId !== req.user.id) return next(new HttpError(401, 'Unauthorized'));
next();
});
});
next(new HttpError(401, 'Managed instance does not permit this operation'));
}
+51 -8
View File
@@ -1,6 +1,8 @@
'use strict';
exports = module.exports = {
verifyOwnership: verifyOwnership,
getApp: getApp,
getApps: getApps,
getAppIcon: getAppIcon,
@@ -30,6 +32,7 @@ exports = module.exports = {
var apps = require('../apps.js'),
AppsError = apps.AppsError,
assert = require('assert'),
config = require('../config.js'),
debug = require('debug')('box:routes/apps'),
fs = require('fs'),
HttpError = require('connect-lastmile').HttpError,
@@ -44,6 +47,25 @@ function auditSource(req) {
return { ip: ip, username: req.user ? req.user.username : null, userId: req.user ? req.user.id : null };
}
function verifyOwnership(req, res, next) {
if (req.user.admin) return next();
if (!config.isSpacesEnabled()) return next();
const appCreate = !('id' in req.params);
if (appCreate) return next(); // ok to install app
apps.get(req.params.id, function (error, app) {
if (error && error.reason === AppsError.NOT_FOUND) return next(new HttpError(404, 'No such app'));
if (error) return next(new HttpError(500, error));
if (app.ownerId !== req.user.id) return next(new HttpError(401, 'Unauthorized'));
next();
});
}
function getApp(req, res, next) {
assert.strictEqual(typeof req.params.id, 'string');
@@ -112,14 +134,25 @@ function installApp(req, res, next) {
if ('sso' in data && typeof data.sso !== 'boolean') return next(new HttpError(400, 'sso must be a boolean'));
if ('enableBackup' in data && typeof data.enableBackup !== 'boolean') return next(new HttpError(400, 'enableBackup must be a boolean'));
if ('enableAutomaticUpdate' in data && typeof data.enableAutomaticUpdate !== 'boolean') return next(new HttpError(400, 'enableAutomaticUpdate must be a boolean'));
if (('debugMode' in data) && typeof data.debugMode !== 'object') return next(new HttpError(400, 'debugMode must be an object'));
if (data.robotsTxt && typeof data.robotsTxt !== 'string') return next(new HttpError(400, 'robotsTxt must be a string'));
if ('alternateDomains' in data) {
if (!Array.isArray(data.alternateDomains)) return next(new HttpError(400, 'alternateDomains must be an array'));
if (data.alternateDomains.some(function (d) { return (typeof d.domain !== 'string' || typeof d.subdomain !== 'string'); })) return next(new HttpError(400, 'alternateDomains array must contain objects with domain and subdomain strings'));
}
if ('env' in data) {
if (!data.env || typeof data.env !== 'object') return next(new HttpError(400, 'env must be an object'));
if (Object.keys(data.env).some(function (key) { return typeof data.env[key] !== 'string'; })) return next(new HttpError(400, 'env must contain values as strings'));
}
debug('Installing app :%j', data);
apps.install(data, auditSource(req), function (error, app) {
apps.install(data, req.user, auditSource(req), function (error, app) {
if (error && error.reason === AppsError.NOT_FOUND) return next(new HttpError(404, error.message));
if (error && error.reason === AppsError.ALREADY_EXISTS) return next(new HttpError(409, error.message));
if (error && error.reason === AppsError.PORT_RESERVED) return next(new HttpError(409, 'Port ' + error.message + ' is reserved.'));
@@ -127,7 +160,7 @@ function installApp(req, res, next) {
if (error && error.reason === AppsError.BAD_FIELD) return next(new HttpError(400, error.message));
if (error && error.reason === AppsError.BILLING_REQUIRED) return next(new HttpError(402, error.message));
if (error && error.reason === AppsError.BAD_CERTIFICATE) return next(new HttpError(400, error.message));
if (error && error.reason === AppsError.EXTERNAL_ERROR) return next(new HttpError(503, error.message));
if (error && error.reason === AppsError.EXTERNAL_ERROR) return next(new HttpError(424, error.message));
if (error) return next(new HttpError(500, error));
next(new HttpSuccess(202, app));
@@ -142,6 +175,10 @@ function configureApp(req, res, next) {
if ('location' in data && typeof data.location !== 'string') return next(new HttpError(400, 'location must be string'));
if ('domain' in data && typeof data.domain !== 'string') return next(new HttpError(400, 'domain must be string'));
// domain, location must both be provided since they are unique together
if ('location' in data && !('domain' in data)) return next(new HttpError(400, 'domain must be provided'));
if (!('location' in data) && 'domain' in data) return next(new HttpError(400, 'location must be provided'));
if ('portBindings' in data && typeof data.portBindings !== 'object') return next(new HttpError(400, 'portBindings must be an object'));
if ('accessRestriction' in data && typeof data.accessRestriction !== 'object') return next(new HttpError(400, 'accessRestriction must be an object'));
@@ -155,6 +192,7 @@ function configureApp(req, res, next) {
if (data.xFrameOptions && typeof data.xFrameOptions !== 'string') return next(new HttpError(400, 'xFrameOptions must be a string'));
if ('enableBackup' in data && typeof data.enableBackup !== 'boolean') return next(new HttpError(400, 'enableBackup must be a boolean'));
if ('enableAutomaticUpdate' in data && typeof data.enableAutomaticUpdate !== 'boolean') return next(new HttpError(400, 'enableAutomaticUpdate must be a boolean'));
if (('debugMode' in data) && typeof data.debugMode !== 'object') return next(new HttpError(400, 'debugMode must be an object'));
@@ -167,9 +205,14 @@ function configureApp(req, res, next) {
if (data.alternateDomains.some(function (d) { return (typeof d.domain !== 'string' || typeof d.subdomain !== 'string'); })) return next(new HttpError(400, 'alternateDomains array must contain objects with domain and subdomain strings'));
}
if ('env' in data) {
if (!data.env || typeof data.env !== 'object') return next(new HttpError(400, 'env must be an object'));
if (Object.keys(data.env).some(function (key) { return typeof data.env[key] !== 'string'; })) return next(new HttpError(400, 'env must contain values as strings'));
}
debug('Configuring app id:%s data:%j', req.params.id, data);
apps.configure(req.params.id, data, auditSource(req), function (error) {
apps.configure(req.params.id, data, req.user, auditSource(req), function (error) {
if (error && error.reason === AppsError.ALREADY_EXISTS) return next(new HttpError(409, error.message));
if (error && error.reason === AppsError.PORT_RESERVED) return next(new HttpError(409, 'Port ' + error.message + ' is reserved.'));
if (error && error.reason === AppsError.PORT_CONFLICT) return next(new HttpError(409, 'Port ' + error.message + ' is already in use.'));
@@ -219,7 +262,7 @@ function cloneApp(req, res, next) {
if (typeof data.domain !== 'string') return next(new HttpError(400, 'domain is required'));
if (('portBindings' in data) && typeof data.portBindings !== 'object') return next(new HttpError(400, 'portBindings must be an object'));
apps.clone(req.params.id, data, auditSource(req), function (error, result) {
apps.clone(req.params.id, data, req.user, auditSource(req), function (error, result) {
if (error && error.reason === AppsError.NOT_FOUND) return next(new HttpError(404, 'No such app'));
if (error && error.reason === AppsError.PORT_RESERVED) return next(new HttpError(409, 'Port ' + error.message + ' is reserved.'));
if (error && error.reason === AppsError.PORT_CONFLICT) return next(new HttpError(409, 'Port ' + error.message + ' is already in use.'));
@@ -243,7 +286,7 @@ function backupApp(req, res, next) {
apps.backup(req.params.id, function (error) {
if (error && error.reason === AppsError.NOT_FOUND) return next(new HttpError(404, 'No such app'));
if (error && error.reason === AppsError.BAD_STATE) return next(new HttpError(409, error.message));
if (error && error.reason === AppsError.EXTERNAL_ERROR) return next(new HttpError(503, error));
if (error && error.reason === AppsError.EXTERNAL_ERROR) return next(new HttpError(424, error));
if (error) return next(new HttpError(500, error));
next(new HttpSuccess(202, { }));
@@ -476,7 +519,7 @@ function execWebSocket(req, res, next) {
if (error && error.reason === AppsError.BAD_STATE) return next(new HttpError(409, error.message));
if (error) return next(new HttpError(500, error));
console.log('Connected to terminal');
debug('Connected to terminal');
req.clearTimeout();
@@ -484,7 +527,7 @@ function execWebSocket(req, res, next) {
duplexStream.on('end', function () { ws.close(); });
duplexStream.on('close', function () { ws.close(); });
duplexStream.on('error', function (error) {
console.error('duplexStream error:', error);
debug('duplexStream error:', error);
});
duplexStream.on('data', function (data) {
if (ws.readyState !== WebSocket.OPEN) return;
@@ -492,7 +535,7 @@ function execWebSocket(req, res, next) {
});
ws.on('error', function (error) {
console.error('websocket error:', error);
debug('websocket error:', error);
});
ws.on('message', function (msg) {
duplexStream.write(msg);
+7 -9
View File
@@ -1,8 +1,8 @@
'use strict';
exports = module.exports = {
get: get,
create: create
list: list,
startBackup: startBackup
};
var backupdb = require('../backupdb.js'),
@@ -16,7 +16,7 @@ function auditSource(req) {
return { ip: ip, username: req.user ? req.user.username : null, userId: req.user ? req.user.id : null };
}
function get(req, res, next) {
function list(req, res, next) {
var page = typeof req.query.page !== 'undefined' ? parseInt(req.query.page) : 1;
if (!page || page < 0) return next(new HttpError(400, 'page query param has to be a postive number'));
@@ -24,20 +24,18 @@ function get(req, res, next) {
if (!perPage || perPage < 0) return next(new HttpError(400, 'per_page query param has to be a postive number'));
backups.getByStatePaged(backupdb.BACKUP_STATE_NORMAL, page, perPage, function (error, result) {
if (error && error.reason === BackupsError.EXTERNAL_ERROR) return next(new HttpError(503, error.message));
if (error && error.reason === BackupsError.EXTERNAL_ERROR) return next(new HttpError(424, error.message));
if (error) return next(new HttpError(500, error));
next(new HttpSuccess(200, { backups: result }));
});
}
function create(req, res, next) {
// note that cloudron.backup only waits for backup initiation and not for backup to complete
// backup progress can be checked up ny polling the progress api call
backups.backup(auditSource(req), function (error) {
function startBackup(req, res, next) {
backups.startBackupTask(auditSource(req), function (error, taskId) {
if (error && error.reason === BackupsError.BAD_STATE) return next(new HttpError(409, error.message));
if (error) return next(new HttpError(500, error));
next(new HttpSuccess(202, {}));
next(new HttpSuccess(202, { taskId }));
});
}
-60
View File
@@ -1,60 +0,0 @@
'use strict';
exports = module.exports = {
getConfig: getConfig,
changePlan: changePlan
};
var caas = require('../caas.js'),
CaasError = require('../caas.js').CaasError,
config = require('../config.js'),
debug = require('debug')('box:routes/cloudron'),
HttpError = require('connect-lastmile').HttpError,
HttpSuccess = require('connect-lastmile').HttpSuccess,
_ = require('underscore');
function getConfig(req, res, next) {
if (config.provider() !== 'caas') return next(new HttpError(422, 'Cannot use this API with this provider'));
caas.getBoxAndUserDetails(function (error, result) {
if (error) return next(new HttpError(500, error));
// the result is { box: { region, size, plan }, user: { billing, currency } }
next(new HttpSuccess(200, {
region: result.box.region,
size: result.box.size,
billing: !!result.user.billing,
plan: result.box.plan,
currency: result.user.currency
}));
});
}
function changePlan(req, res, next) {
if (config.provider() !== 'caas') return next(new HttpError(422, 'Cannot use this API with this provider'));
if ('size' in req.body && typeof req.body.size !== 'string') return next(new HttpError(400, 'size must be string'));
if ('region' in req.body && typeof req.body.region !== 'string') return next(new HttpError(400, 'region must be string'));
if ('domain' in req.body) {
if (typeof req.body.domain !== 'string') return next(new HttpError(400, 'domain must be string'));
if (typeof req.body.provider !== 'string') return next(new HttpError(400, 'provider must be string'));
}
if ('zoneName' in req.body && typeof req.body.zoneName !== 'string') return next(new HttpError(400, 'zoneName must be string'));
debug('Migration requested domain:%s size:%s region:%s', req.body.domain, req.body.size, req.body.region);
var options = _.pick(req.body, 'domain', 'size', 'region');
if (Object.keys(options).length === 0) return next(new HttpError(400, 'no migrate option provided'));
if (options.domain) options.domain = options.domain.toLowerCase();
caas.changePlan(req.body, function (error) { // pass req.body because 'domain' can have arbitrary options
if (error && error.reason === CaasError.BAD_STATE) return next(new HttpError(409, error.message));
if (error && error.reason === CaasError.BAD_FIELD) return next(new HttpError(400, error.message));
if (error) return next(new HttpError(500, error));
next(new HttpSuccess(202, {}));
});
}
+2 -1
View File
@@ -80,8 +80,9 @@ function addToken(req, res, next) {
var data = req.body;
var expiresAt = data.expiresAt ? parseInt(data.expiresAt, 10) : Date.now() + constants.DEFAULT_TOKEN_EXPIRATION;
if (isNaN(expiresAt) || expiresAt <= Date.now()) return next(new HttpError(400, 'expiresAt must be a timestamp in the future'));
if ('name' in req.body && typeof req.body.name !== 'string') return next(new HttpError(400, 'name must be a string'));
clients.addTokenByUserId(req.params.clientId, req.user.id, expiresAt, function (error, result) {
clients.addTokenByUserId(req.params.clientId, req.user.id, expiresAt, { name: req.body.name || '' }, function (error, result) {
if (error && error.reason === ClientsError.NOT_FOUND) return next(new HttpError(404, error.message));
if (error) return next(new HttpError(500, error));
next(new HttpSuccess(201, { token: result }));
+48 -12
View File
@@ -2,7 +2,7 @@
exports = module.exports = {
reboot: reboot,
getProgress: getProgress,
isRebootRequired: isRebootRequired,
getConfig: getConfig,
getDisks: getDisks,
getUpdateInfo: getUpdateInfo,
@@ -10,7 +10,10 @@ exports = module.exports = {
feedback: feedback,
checkForUpdates: checkForUpdates,
getLogs: getLogs,
getLogStream: getLogStream
getLogStream: getLogStream,
getStatus: getStatus,
setDashboardDomain: setDashboardDomain,
renewCerts: renewCerts
};
var appstore = require('../appstore.js'),
@@ -21,7 +24,6 @@ var appstore = require('../appstore.js'),
CloudronError = cloudron.CloudronError,
HttpError = require('connect-lastmile').HttpError,
HttpSuccess = require('connect-lastmile').HttpSuccess,
progress = require('../progress.js'),
updater = require('../updater.js'),
updateChecker = require('../updatechecker.js'),
UpdaterError = require('../updater.js').UpdaterError,
@@ -32,15 +34,19 @@ function auditSource(req) {
return { ip: ip, username: req.user ? req.user.username : null, userId: req.user ? req.user.id : null };
}
function getProgress(req, res, next) {
return next(new HttpSuccess(200, progress.getAll()));
}
function reboot(req, res, next) {
// Finish the request, to let the appstore know we triggered the restore it
// Finish the request, to let the appstore know we triggered the reboot
next(new HttpSuccess(202, {}));
cloudron.reboot(function () { });
cloudron.reboot(function () {});
}
function isRebootRequired(req, res, next) {
cloudron.isRebootRequired(function (error, result) {
if (error) return next(new HttpError(500, error));
next(new HttpSuccess(200, { rebootRequired: result }));
});
}
function getConfig(req, res, next) {
@@ -60,13 +66,12 @@ function getDisks(req, res, next) {
function update(req, res, next) {
// this only initiates the update, progress can be checked via the progress route
updater.updateToLatest(auditSource(req), function (error) {
updater.updateToLatest(auditSource(req), function (error, taskId) {
if (error && error.reason === UpdaterError.ALREADY_UPTODATE) return next(new HttpError(422, error.message));
if (error && error.reason === UpdaterError.BAD_STATE) return next(new HttpError(409, error.message));
if (error && error.reason === UpdaterError.SELF_UPGRADE_NOT_SUPPORTED) return next(new HttpError(412, error.message));
if (error) return next(new HttpError(500, error));
next(new HttpSuccess(202, {}));
next(new HttpSuccess(202, { taskId }));
});
}
@@ -75,6 +80,9 @@ function getUpdateInfo(req, res, next) {
}
function checkForUpdates(req, res, next) {
// it can take a while sometimes to get all the app updates one by one
req.clearTimeout();
async.series([
updateChecker.checkAppUpdates,
updateChecker.checkBoxUpdates
@@ -166,3 +174,31 @@ function getLogStream(req, res, next) {
logStream.on('error', res.end.bind(res, null));
});
}
function setDashboardDomain(req, res, next) {
if (!req.body.domain || typeof req.body.domain !== 'string') return next(new HttpError(400, 'domain must be a string'));
cloudron.setDashboardDomain(req.body.domain, function (error) {
if (error && error.reason === CloudronError.BAD_FIELD) return next(new HttpError(404, error.message));
if (error) return next(new HttpError(500, error));
next(new HttpSuccess(204, {}));
});
}
function getStatus(req, res, next) {
cloudron.getStatus(function (error, status) {
if (error) return next(new HttpError(500, error));
next(new HttpSuccess(200, status));
});
}
function renewCerts(req, res, next) {
cloudron.renewCerts({ domain: req.body.domain || null }, auditSource(req), function (error, taskId) {
if (error && error.reason === CloudronError.NOT_FOUND) return next(new HttpError(404, error.message));
if (error) return next(new HttpError(500, error));
next(new HttpSuccess(202, { taskId }));
});
}
+2 -1
View File
@@ -24,7 +24,8 @@ function login(req, res, next) {
if (!verified) return next(new HttpError(401, 'Invalid totpToken'));
}
clients.issueDeveloperToken(user, ip, function (error, result) {
const auditSource = { authType: 'cli', ip: ip };
clients.issueDeveloperToken(user, auditSource, function (error, result) {
if (error) return next(new HttpError(500, error));
next(new HttpSuccess(200, result));

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