Compare commits

..

164 Commits

Author SHA1 Message Date
Girish Ramakrishnan 42c138e134 doc: appstore_config API 2017-04-15 07:30:58 -07:00
Girish Ramakrishnan b1b389dd7d Use the POST routes to login to appstore 2017-04-15 07:13:42 -07:00
Girish Ramakrishnan 8911081f85 When dns config changes, only fixup the cloudron records 2017-04-14 19:52:44 -07:00
Girish Ramakrishnan 9605fe3842 Only change dns records of already installed apps 2017-04-14 19:48:58 -07:00
Girish Ramakrishnan bb91faf23c mysql: Use utf8mb4 character set 2017-04-14 13:29:01 -07:00
Johannes Zellner ba56f7d15d Special case versions lower than '0.11*' to not pass new --data-dir 2017-04-14 17:24:28 +02:00
Johannes Zellner 6e73761983 We still require the versionsUrl in the setup script to be able to restore to older versions 2017-04-14 15:05:09 +02:00
Girish Ramakrishnan 588812a13a Disable secure cookies for tests (since they use http) 2017-04-14 01:45:00 -07:00
Girish Ramakrishnan b6d8721aed reset config in backups test 2017-04-14 01:35:04 -07:00
Girish Ramakrishnan da835afde1 More test fixing 2017-04-14 01:28:29 -07:00
Girish Ramakrishnan 5e22caa6e7 Fix updatechecker tests 2017-04-14 00:36:22 -07:00
Girish Ramakrishnan 937931e885 Pass the token when querying for updates
Part of #293
2017-04-13 19:40:59 -07:00
Girish Ramakrishnan c2b140208e doc: add note on subdomains vs paths 2017-04-13 16:11:12 -07:00
Girish Ramakrishnan f9a4d00b3c Query tarball url via appstore
Part of #293
2017-04-13 15:41:08 -07:00
Girish Ramakrishnan eb2ef47df1 remove boxVersionsUrl
update checker now uses the appstore routes
2017-04-13 11:38:42 -07:00
Girish Ramakrishnan cdb5dc2c53 Remove isDev flag
We can pretty much test everything here on self-hosted cloudrons now
2017-04-13 11:34:03 -07:00
Girish Ramakrishnan f6a2406091 Use hat for secret instead of uuid 2017-04-13 11:05:46 -07:00
Johannes Zellner c7134d2da3 Use a dynamic cookie secret
Fixes #300
2017-04-13 15:13:07 +02:00
Johannes Zellner 1692842bf0 Enable secure cookies
Fixes #299
2017-04-13 14:04:30 +02:00
Johannes Zellner 8d78f06a34 Add more 0.110.0 changes 2017-04-13 13:23:40 +02:00
Johannes Zellner 1694a1536c Use our own tar for app backups
This is only temporarily as moving away from btrfs snapshots,
we introduced a regression for app backups.
gnu tar fails to create tarballs if the files change during packing.
2017-04-13 13:06:12 +02:00
Girish Ramakrishnan e0b9dc3623 doc: fix links 2017-04-13 01:34:10 -07:00
Girish Ramakrishnan 644bc54a0d Get box updates via appstore API
Part of #293
2017-04-13 01:31:25 -07:00
Girish Ramakrishnan b2d062bdf8 Get app updates via appstore API
Part of #293
2017-04-13 01:23:18 -07:00
Girish Ramakrishnan 894d7a6e72 Handle AppstoreError correctly 2017-04-13 01:11:20 -07:00
Girish Ramakrishnan fee513594f Move sendAliveStatus to appstore.js 2017-04-13 01:07:07 -07:00
Girish Ramakrishnan 456c183622 Move sendAliveStatus into appstore.js 2017-04-13 00:52:02 -07:00
Girish Ramakrishnan 0488aada9f refactor appstore logic to separate file 2017-04-13 00:42:44 -07:00
Girish Ramakrishnan 54f7cf5f64 Fix copy/paste errors 2017-04-13 00:19:11 -07:00
Girish Ramakrishnan e1740a0d4b Use new alive route for sending status 2017-04-12 21:29:42 -07:00
Girish Ramakrishnan bac7d3ad84 Check for app and box updates once an hour
Part of #293
2017-04-12 18:48:14 -07:00
Girish Ramakrishnan 6402b0c221 doc: update check api 2017-04-12 18:38:33 -07:00
Girish Ramakrishnan 1f55bb52fc Add a button to check updates instantly
Part of #293
2017-04-12 18:34:20 -07:00
Girish Ramakrishnan 1029402d1e Make mailer.js work in test mode 2017-04-12 15:35:52 -07:00
Girish Ramakrishnan abb371d81e Add 0.110.0 changes 2017-04-12 13:49:27 -07:00
Girish Ramakrishnan 779c9d79b3 Add UI to choose from backups to restore from
Fixes #296
2017-04-12 13:41:26 -07:00
Girish Ramakrishnan 832c11d785 Always show the restore button
Part of #296
2017-04-12 13:17:53 -07:00
Girish Ramakrishnan 85fb63298e Overwrite existing DNS record on restore 2017-04-12 13:17:50 -07:00
Girish Ramakrishnan 25b9d5a746 Fix comment 2017-04-12 00:17:20 -07:00
Girish Ramakrishnan 6dc900bbd8 Bump mysql to fix the encoding issue in dumps 2017-04-12 00:16:33 -07:00
Girish Ramakrishnan e32b313cf2 refactor restore/clone/install into single function 2017-04-11 15:16:42 -07:00
Girish Ramakrishnan a01dea3932 Allow installing from a backup 2017-04-11 13:06:39 -07:00
Johannes Zellner aa0e820605 Bump infra version to rebuild nginx configs 2017-04-10 22:49:35 +02:00
Johannes Zellner 13db61a0e2 Merge branch 'master' into 'master'
Adding proxy_max_temp_file_size 0 to nginx config

Closes #294

See merge request !5
2017-04-10 20:48:50 +00:00
mehdi fce2cdce7f Adding proxy_max_temp_file_size 0 to nginx config.
Explanation:
When proxying an HTTP request, nginx first fills up the memory buffers (set by proxy_buffer_size and proxy_buffers).
When these are full, it then writes them to a temporary file in batches of proxy_temp_file_write_size until it reaches proxy_max_temp_file_size.
When proxy_max_temp_file_size is not set, and a very large file is being served, it reaches the maximum of 1GB, and nginx begins to behave weirdly.
2017-04-10 22:47:19 +02:00
Johannes Zellner 89bb690152 Only log failing REST requests 2017-04-10 14:17:26 +02:00
Girish Ramakrishnan 5c203dc759 Handle case where restore config is not found 2017-04-07 15:29:42 -07:00
Girish Ramakrishnan 4d737d535a doc: disks api 2017-04-07 12:52:57 -07:00
Girish Ramakrishnan 558acf27a3 Add 0.109.1 changes 2017-04-07 12:38:48 -07:00
Girish Ramakrishnan 3da503ab8e doc: improve the data-dir script 2017-04-07 12:34:59 -07:00
Girish Ramakrishnan 299e8aceeb Check if --data-dir exists 2017-04-07 12:34:59 -07:00
Johannes Zellner b422a27be8 Use the new disks api to explicitly get the apps data disk stats
We can add the other graphs if the disks deviate later

Also this is still pending the check if symlinked folders are reported
correctly.

Fixes #290
2017-04-07 18:46:11 +02:00
Johannes Zellner f2312a6768 Add rest api to list disks where cloudron data is stored 2017-04-07 18:45:36 +02:00
Johannes Zellner 178ffe20a8 use df instead of node-df 2017-04-07 18:45:14 +02:00
Johannes Zellner 3b8edd4896 Do not make the DNS notfication persistent 2017-04-07 17:08:52 +02:00
Girish Ramakrishnan f16aab7f80 Add --data-dir to cloudron-setup
This allows the data-dir to be re-located

Part of #148
2017-04-06 23:56:57 -07:00
Girish Ramakrishnan 09118d6b06 doc: how to configure data location 2017-04-06 15:31:43 -07:00
Girish Ramakrishnan bd57ee9461 doc: more security notes 2017-04-06 13:53:35 -07:00
Girish Ramakrishnan 1fbbe036ce doc: appstore command 2017-04-06 13:46:40 -07:00
Johannes Zellner 94d7bc5328 Ensure the app install dialog closes and releases the backdrop when moving to other views
Fixes #201
2017-04-06 17:39:45 +02:00
Johannes Zellner d709a5cfe4 Attempt to give better digitalocean DNS error messages 2017-04-05 16:50:31 +02:00
Johannes Zellner 188f000507 Dump whole errors for app installation progress
Sometimes on error we get random strings for the installationProgresss,
as those contain the upstream errors :-/
We now at least attempt to show that so the user may give us the real
error not some wrongly parsed bits from that.
In the long run we have to make that a real structure to give sane error
messages
2017-04-05 16:42:13 +02:00
Johannes Zellner 51d5b96fa1 use "mountpoint" to check if we have the user data mounted 2017-04-05 14:34:18 +02:00
Girish Ramakrishnan 11d12c591e Add 0.109.0 changes 2017-04-05 00:36:24 -07:00
Girish Ramakrishnan 245d17ad25 Fix test image version 2017-04-04 19:13:03 -07:00
Girish Ramakrishnan e05e9c3ead Use latest test-app 2017-04-04 14:47:54 -07:00
Girish Ramakrishnan 8102d431e8 use debug instead 2017-04-04 14:07:28 -07:00
Girish Ramakrishnan 0f76cbbb95 remove temporary authorized_keys file 2017-04-04 14:00:41 -07:00
Girish Ramakrishnan 2a45a9bbd4 test: rate limit is now in nginx 2017-04-04 13:12:50 -07:00
Girish Ramakrishnan e68d627f72 tests: data -> platformdata 2017-04-04 13:11:44 -07:00
Girish Ramakrishnan 1a3e3638ff iptables-restore is not used anymore 2017-04-04 13:00:48 -07:00
Girish Ramakrishnan 8f912d8a1b add note on how to view graphite browser 2017-04-04 12:35:29 -07:00
Girish Ramakrishnan d891058f8c Restore apps if the existingInfra version is not semver (as in, old infra) 2017-04-04 12:15:29 -07:00
Girish Ramakrishnan 71fe094be1 Make platform version a semver 2017-04-04 12:07:53 -07:00
Johannes Zellner da857f520b Only stop apps and addons on data migration 2017-04-04 14:30:45 +02:00
Johannes Zellner 39ff21bdf4 Bump infra version now with a explicit minor version 2017-04-04 12:34:55 +02:00
Johannes Zellner 72dd7c74d5 Introduce major/minor infra versions
The strategy now is that major infra version changes make apps restore,
whereas minor infra version changes only reconfigure and thus restart
them
2017-04-04 12:34:55 +02:00
Johannes Zellner 7c7ef15e1c Do not collect data for btrfs file systems 2017-04-04 12:34:55 +02:00
Johannes Zellner b320e15ea7 No need to install btrfs-tools in the base image 2017-04-04 12:34:55 +02:00
Johannes Zellner aa22ab8847 Cleanup the btrfs mounts and the user data file 2017-04-04 12:34:55 +02:00
Johannes Zellner 3e23c3efce Do not move the whole mail folder but only its content 2017-04-04 12:34:55 +02:00
Johannes Zellner c4f96bbd6b Some directory creation fixes 2017-04-04 12:34:55 +02:00
Johannes Zellner 649092ecb0 Fix typo PLATFORM_CONFIG_DIR -> PLATFORM_DATA_DIR 2017-04-04 12:34:55 +02:00
Johannes Zellner 128a3b03c9 Do not use btrfs snapshots for mail on box backup 2017-04-04 12:34:55 +02:00
Johannes Zellner 847ef6626f Also use appsdir in rmappdir.sh 2017-04-04 12:34:55 +02:00
Johannes Zellner 4643daeeec Use appsdata in createappdir.sh 2017-04-04 12:34:55 +02:00
Johannes Zellner 38178afd31 Do not use btrfs snapshots for app backups 2017-04-04 12:34:55 +02:00
Johannes Zellner 9c6324631d Use APPS_DATA_DIR in app backup and restore scripts 2017-04-04 12:34:55 +02:00
Johannes Zellner 3a17bf9a0f Ensure apps and platform data dirs exist 2017-04-04 12:34:55 +02:00
Johannes Zellner 602f8bcd04 Split platform and app data folders and get rid of btrfs volumes 2017-04-04 12:34:55 +02:00
Girish Ramakrishnan 785ae765a4 better error text 2017-04-03 16:54:06 -07:00
Girish Ramakrishnan c85120834c refactor ngTld so it can be used with plain strings 2017-04-03 16:20:32 -07:00
Johannes Zellner 89d36b8ad4 Reset the dns error states on resubmission 2017-04-03 22:36:02 +02:00
Johannes Zellner b9711d7b47 Move AMI instanceId verification to DNS setup 2017-04-03 22:19:01 +02:00
Johannes Zellner 4f9273819a Ensure autofocus on setup.html 2017-04-03 16:45:04 +02:00
Johannes Zellner e0d7850135 Add a tooltip to email field during setup 2017-04-03 16:33:07 +02:00
Girish Ramakrishnan 2c871705c7 Add a referrer policy 2017-03-31 16:11:54 -07:00
Girish Ramakrishnan 2bb99db2c7 Add another blacklist tester 2017-03-30 19:42:14 -07:00
Girish Ramakrishnan 3fc5757e97 doc: Add note on OS updates 2017-03-30 08:35:18 -07:00
Girish Ramakrishnan 92ff19ffce Add 0.108.0 changes 2017-03-29 22:20:13 -07:00
Girish Ramakrishnan e9456f70f9 use connlimit module to rate limit
hitcount cannot be more than 255 in recent module
2017-03-29 21:51:24 -07:00
Girish Ramakrishnan ffbda22145 Fine tune rate limits a bit more 2017-03-29 16:03:08 -07:00
Girish Ramakrishnan b92ae44578 Generate 128 byte passwords 2017-03-29 15:38:15 -07:00
Girish Ramakrishnan b6ffc966cd Bump mysql (for increasing multidb password len) 2017-03-29 15:21:49 -07:00
Girish Ramakrishnan b42bc52093 doc: improve rate limit wording 2017-03-29 10:40:02 -07:00
Girish Ramakrishnan 806b458ff1 Move it to the selfhosting guide instead 2017-03-29 10:33:51 -07:00
Girish Ramakrishnan d5d4e237bd doc: add security section 2017-03-29 10:23:08 -07:00
Girish Ramakrishnan 956fe86250 Add firewall service
Docker really insists on adding itself to the top of the FORWARD
chain. Making our firewall side-steps this docker design.
2017-03-29 02:31:53 -07:00
Girish Ramakrishnan 4d000e377f Enable iptables based ratelimit for cloudron auth services
The goal here is to simply add a rate limit to prevent brute
force password attacks.

Covered services includes:
    (public) http, https, ssh, smtp, msa, imap, sieve
    (private) postgres, redis, mysql, ldap, mongodb. msa

The private limits are higher because some apps will create
a db connection for each page request.  Some apps like mailtrain
will send out lots of emails etc.

Note that apps that use SSO are ratelimited by the ldap limit.

Part of #187
2017-03-29 00:02:05 -07:00
Johannes Zellner 39e827be04 Add rosehosting to the help output if no provider is specified 2017-03-28 10:38:00 +02:00
Girish Ramakrishnan e50b4cb7ec doc: fixup the best practices docs
Fixes #232
2017-03-27 15:29:07 -07:00
Johannes Zellner 1938ec635b Remove bestpractices.md as this was already incorporated into the main packaging guide 2017-03-27 16:05:03 +02:00
Johannes Zellner 03a3d367a4 Incorporate best practices into app package guide
Part of #232
2017-03-27 16:03:19 +02:00
Johannes Zellner 38c2f75b5e Also patch the cloudron-setup to match the resize script
Part of #278
2017-03-27 13:51:37 +02:00
Johannes Zellner 9d98b55881 Merge branch 'tobru/fix_278' into 'master'
get disk_size_bytes by directly querying df /. fixes #278

Closes #278

See merge request !4
2017-03-27 11:46:49 +00:00
Girish Ramakrishnan 18e59c4754 Rate limit nginx routes that verify the password
Also remove rate-limit middleware

Test using something like:

    ab -v 1 -n 1000 -c 10 -s 5 -m POST https://my.<doamain>/api/v1/developer/login

Part of #187
2017-03-27 00:06:42 -07:00
Girish Ramakrishnan 64cb951206 Fix failing dns test 2017-03-26 22:07:28 -07:00
Girish Ramakrishnan 77df520b07 addons is optional in manifest 2017-03-26 21:55:31 -07:00
Girish Ramakrishnan 32f94a03ce Fix failing test 2017-03-26 21:53:45 -07:00
Girish Ramakrishnan fc6ce4945f add sendmail/recvmail ldap tests 2017-03-26 20:42:46 -07:00
Girish Ramakrishnan 17b7d89db9 Generate password for mailboxes
Fixes #109
2017-03-26 20:07:59 -07:00
Girish Ramakrishnan 6ea741e92f Verify password for sendmail/recvmail addon
Part of #109
2017-03-26 20:07:55 -07:00
Girish Ramakrishnan 790ad4e74d Add getAddonConfigByName 2017-03-26 19:06:36 -07:00
Girish Ramakrishnan f92297cc99 Store env vars as name, value pairs
Part of #109
2017-03-26 12:22:19 -07:00
Tobias Brunner 0c6c835a39 get disk_size_bytes by directly querying df /. fixes #278
This simplifies the logic to get the available space the root
mountpoint has available and makes it more robust.
2017-03-26 18:03:10 +02:00
Girish Ramakrishnan 514341172c Add name to appAddonConfigs
Part of #109
2017-03-25 18:06:56 -07:00
Girish Ramakrishnan e535ffa778 Disable bind9 as it conflicts with unbound
part of #194
2017-03-25 17:36:10 -07:00
Girish Ramakrishnan b86cfabd17 Do not allocate more than 4GB swap
Also resize existing swap file, if necessary. Note that if the user
allocates more than what we expect, we don't do anything.

Fixes #277
2017-03-24 16:03:30 -07:00
Girish Ramakrishnan b44f0b78a1 remove spurious console.log 2017-03-24 14:55:22 -07:00
Johannes Zellner 76d234d0bf Also allow data: uri to be loaded for images 2017-03-24 17:23:20 +01:00
Johannes Zellner a694acba44 Redirect to /setupdns.html if cloudron is activated but no domain is set
This happens in the restore case where no domain is provided to
cloudron-setup

Fixes #273
2017-03-23 15:40:18 +01:00
Johannes Zellner 046120befc Move email toggle button above checks to make it more likely people read the text 2017-03-23 11:41:26 +01:00
Girish Ramakrishnan b65fee4b73 Pass ENABLE_MDA flag to mail addon 2017-03-22 20:42:28 -07:00
Girish Ramakrishnan 153dcc1826 Fix bug in example text 2017-03-22 18:23:24 -07:00
Girish Ramakrishnan fa4725176c Group help text together 2017-03-22 16:44:18 -07:00
Girish Ramakrishnan e42607fec6 Always show the password input 2017-03-22 16:13:18 -07:00
Girish Ramakrishnan 297c1ff266 Show error message only if the domain changed 2017-03-22 16:06:47 -07:00
Girish Ramakrishnan 5afe75f137 Bump mail container (for mx bypass fix) 2017-03-22 14:39:30 -07:00
Girish Ramakrishnan 4cfc85f6d3 Do not validate password length 2017-03-22 13:50:20 -07:00
Girish Ramakrishnan b03f901bbf More 0.107.0 changes 2017-03-22 12:01:04 -07:00
Johannes Zellner b9dfac94ed Revert "Add ldapjs-rate-limit module"
This reverts commit 3d60a04b36.
2017-03-22 19:35:06 +01:00
Johannes Zellner c905adde1e Revert "Limit ldap queries per client to 60 per minute"
This reverts commit 466dfdf81f.
2017-03-22 19:35:06 +01:00
Girish Ramakrishnan 0e7efa77a5 Bump the mail container 2017-03-22 09:55:04 -07:00
Johannes Zellner 875ca0307f Fix the node tutorial to export the node PATH and use latest node release 2017-03-22 16:20:48 +01:00
Johannes Zellner 543c9843ba Use df instead of fdisk
some disk types do not contain proper partition tables like on time4vps
the type is simfs. On those fdisk fails to access the partition table,
thus being unable to determine the size of the volume.
df does only return the real usable disk space by the user, thus we
lower the 20GB threshold to 18

Fixes #275
2017-03-22 14:23:59 +01:00
Johannes Zellner 83254a16f9 Do not restrict CSP img-src as 3rd party apps might use other origins for medialinks 2017-03-21 20:20:16 +01:00
Johannes Zellner 466dfdf81f Limit ldap queries per client to 60 per minute
Part of #187
2017-03-21 16:43:22 +01:00
Johannes Zellner 3d60a04b36 Add ldapjs-rate-limit module 2017-03-21 16:43:02 +01:00
Johannes Zellner 103cb10cad Ignore upstream headers for security headers we set in nginx
Apps like nextcloud set their own security headers ending up with having
them set twice. I am not 100% sure if our headers should win or if we
should not inject headers with nginx if the upstream app sets them already.
This looks like the more permissive case where we simply enforce our
values, regardless what the apps sets.

This also fixes the nextcloud/owncloud security checks which were
failing because the header values were duplicated, which results in
string concatenation of values from same headers.
2017-03-21 14:18:39 +01:00
Johannes Zellner 29ef079a83 Do not let the invite link overflow the dialog 2017-03-21 13:36:36 +01:00
Johannes Zellner a55645770e Add missing csp img-src policy for app icons 2017-03-21 13:25:29 +01:00
Johannes Zellner 132ddd2671 Add 0.107.0 changes 2017-03-21 11:15:51 +01:00
Johannes Zellner fa5891b149 Also put csp meta tag in oauth views 2017-03-21 11:12:04 +01:00
Johannes Zellner d01929debc Be more permissive with csp header values 2017-03-21 11:12:04 +01:00
Johannes Zellner 7c01ee58b5 Template the cloudron origin for csp to support local development 2017-03-21 11:12:04 +01:00
Johannes Zellner ec89f8719c Add CSP meta tag for webadmin 2017-03-21 11:12:04 +01:00
Girish Ramakrishnan 9145022a2c Put scope in the end since it is pre-filled 2017-03-20 20:06:24 -07:00
Girish Ramakrishnan 9ae8ce3296 Change default oauth client scope to profile 2017-03-20 20:05:22 -07:00
Girish Ramakrishnan eabf27f0c9 More OAuth wording changes 2017-03-20 19:55:27 -07:00
Girish Ramakrishnan 3102a15dff doc: add oauth note in user manual 2017-03-20 19:52:34 -07:00
Girish Ramakrishnan 7747c482d4 Fix oauth wording in the tokens UI 2017-03-20 19:12:32 -07:00
Girish Ramakrishnan 444ca1888b remove dead comment 2017-03-20 15:14:06 -07:00
Girish Ramakrishnan 86ccf5ea84 doc: add kimsufi note
Fixes #261
2017-03-20 14:32:35 -07:00
Girish Ramakrishnan ef088293b6 Do not show repair and configure together 2017-03-20 08:48:50 -07:00
102 changed files with 2062 additions and 4037 deletions
+26
View File
@@ -805,3 +805,29 @@
* (mail) Set maximum email size to 25MB
* Remove SimpleAuth addon
[0.107.0]
* Support CSP for webinterface and OAuth views
* (mail) Fix issue where Cloudron is only used to send emails
[0.108.0]
* Redirect to /setupdns.html when restoring
* Fix setting custom avatar
* Do not allocate more than 4GB swap
* Generate real passwords for sendmail/recvmail addons
* Rate limit all authentication routes to prevent password brute force
* Generate 128 byte password for MySQL multi-db addon
[0.109.0]
* Add Referrer-policy
* Add tooltip for admin email field explaining it is local & private
* Verify AMI instance id during DNS setup instead of admin account setup
* Split platform and app data folders and get rid of btrfs volumes
[0.110.0]
* Fix disk usage graphs
* Add --data-dir to cloudron-setup that allows customizing data location
* Add UI to restore from any app backup
* (mysql) Use utf8mb4 encoding for databases and backups
* Allow installing a new app from a backup
* Fix download of large files (> 1GB)
* Fix app backup regression
+3 -1
View File
@@ -26,7 +26,6 @@ debconf-set-selections <<< 'mysql-server mysql-server/root_password_again passwo
apt-get -y install \
acl \
awscli \
btrfs-tools \
build-essential \
cron \
curl \
@@ -124,3 +123,6 @@ if ! apt-get install -y collectd collectd-utils; then
sed -e 's/^FQDNLookup true/FQDNLookup false/' -i /etc/collectd/collectd.conf
fi
# 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
+1 -2
View File
@@ -184,7 +184,7 @@ TokenURL = ${API_ORIGIN}/api/v1/oauth/token
The token obtained via OAuth has a restricted scope wherein they can only access the [profile API](/references/api.html#profile). This restriction
is so that apps cannot make undesired changes to the user's Cloudron.
We currently provide OAuth2 integration for Ruby [omniauth](https://github.com/cloudron-io/omniauth-cloudron) and Node.js [passport](https://github.com/cloudron-io/passport-cloudron).
We currently provide OAuth2 integration for Ruby [omniauth](https://git.cloudron.io/cloudron/omniauth-cloudron) and Node.js [passport](https://git.cloudron.io/cloudron/passport-cloudron).
## postgresql
@@ -317,4 +317,3 @@ cloudron exec
> swaks --server "${MAIL_SMTP_SERVER}" -p "${MAIL_SMTP_PORT}" --from "${MAIL_SMTP_USERNAME}@${MAIL_DOMAIN}" --body "Test mail from cloudron app at $(hostname -f)" --auth-user "${MAIL_SMTP_USERNAME}" --auth-password "${MAIL_SMTP_PASSWORD}"
```
+64 -2
View File
@@ -117,6 +117,7 @@ Request:
cert: <string>, // pem encoded TLS cert
key: <string>, // pem encoded TLS key
memoryLimit: <number>, // memory constraint in bytes
backupId: <string>, // initialize the app from this backup
altDomain: <string>, // alternate domain from which this app can be reached
xFrameOptions: <string> // set X-Frame-Options header, to control which websites can embed this app
}
@@ -153,6 +154,8 @@ If `altDomain` is set, the app can be accessed from `https://<altDomain>`.
`memoryLimit` is the maximum memory this app can use (in bytes) including swap. If set to 0, the app uses the `memoryLimit` value set in the manifest. If set to -1, the app gets unlimited memory.
If `backupId` is provided the app will be initialized with the data from the backup.
Read more about the options at [MDN](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Frame-Options).
Response (200):
@@ -691,6 +694,23 @@ Curl example to activate the cloudron:
curl -X POST -H "Content-Type: application/json" -d '{"username": "girish", "password":"MySecret123#", "email": "girish@cloudron.io" }' https://my.cloudron.info/api/v1/cloudron/activate
```
### Check for updates
POST `/api/v1/check_for_updates` <scope>admin</scope>
Checks for any available updates for the Cloudron and the installed apps.
Response (200):
```
{
box: null|<object>, // object containing information about update
apps: { // update info (if any) for each app
<appid>: <object>,
...
}
}
```
### Update the Cloudron
POST `/api/v1/cloudron/update` <scope>admin</scope>
@@ -739,7 +759,6 @@ Response (200):
{
activated: <boolean>,
version: <semver>,
boxVersionsUrl: <url>, // Location of the Cloudron versions file to check for updates
apiServerOrigin: <url>, // Always https://api.cloudron.io
provider: <string>,
cloudronName: <string>
@@ -770,7 +789,6 @@ Response (200):
{
apiServerOrigin: <string>, // Always https://api.cloudron.io
webServerOrigin: <string>, // Always https://cloudron.io
isDev: <boolean>, // internal
fqdn: <fqdn>, // The FQDN
ip: <ip>, // The public IP
version: <semver>, // Current version
@@ -794,6 +812,20 @@ Response (200):
}
```
### Get disks
GET `/api/v1/cloudron/disks` <scope>admin</scope>
Gets information on the disks being used on the Cloudron server.
```
Response (200):
{
boxDataDisk: <string>, // Disk used for storing box data
platformDataDisk: <string>, // Disk used for addon databases and email
appsDataDisk: <string> // Disk used for apps' local storage
}
```
## Eventlog
@@ -1006,6 +1038,36 @@ Response (204):
## Settings
### Get Appstore Config
GET `/api/v1/settings/appstore_config` <scope>admin</scope>
Response (200):
```
{
userId: <string>, // the appstore userId
token: <string>, // appstore token
cloudronId: <string> // cloudron id
}
```
### Set Appstore Config
POST `/api/v1/settings/appstore_config` <scope>admin</scope>
Sets the credentials used for the Cloudron Store.
Request:
```
{
userId: <string>, // the appstore userId
token: <string> // token from appstore
}
```
You can get the `userId` and `token` by sending a `/api/v1/login` POST request to `api.cloudron.io`
with the `email` and `password` fields set in the request.
### Get auto update pattern
GET `/api/v1/settings/autoupdate_pattern` <scope>admin</scope>
-93
View File
@@ -1,93 +0,0 @@
# Best practices
## Overview
This document explains the spirit of what makes a Cloudron app.
## No Setup
Cloudron apps do not show a setup screen after installation and should choose reasonable
defaults.
Databases, email configuration should be automatically picked up using [addons](/references/addons.html).
Admin role for the application can be detected dynamically using one of the [authentication](/references/authentication.html)
strategies.
## Image
The Dockerfile contains a specification for building an application image.
* Install any required software packages in the Dockerfile.
* Create static configuration files in the Dockerfile.
* Create symlinks to dynamic configuration files under `/run` in the Dockerfile.
* Docker supports restarting processes natively. Should your application crash, it will
be restarted automatically. If your application is a single process, you do not require
any process manager.
* The main process must handle `SIGTERM` and forward it as required to child processes. `bash`
does not automatically forward signals to child processes. For this reason, when using a startup
shell script, remember to use `exec <app>` as the last line. Doing so will replace bash with your
program and allows your program to handle signals as required.
* Use `supervisor`, `pm2` or any of the other process managers if you application has more
then one component. This excludes web servers like apache, nginx which can already manage their
children by themselves. Be sure to pick a process manager that forwards signals to child processes.
* Disable auto updates for apps. Updates must be triggered through the Cloudron Store. This allows the admin
to manage updates and downtime in a central location (the Cloudron Webadmin).
## File system
The Cloudron runs the application image as read-only. The app can only write to the following directories:
* `/tmp` - use this for temporary files.
* `/run` - use this for runtime configration and any dynamic data.
* `/app/data` - When the `localstorage` addon is enabled, any data under this directory is automatically backed up.
## Logging
Cloudron applications stream their logs to stdout and stderr. In contrast to logging
to files, this approach has many advantages:
* App does not need to rotate logs and the Cloudron takes care of managing logs
* App does not need special mechanism to release log file handles (on a log rotate)
* Integrates better with tooling like `cloudron cli`
This document gives you some recipes for configuring popular libraries to log to stdout. See
[base image](/references/baseimage.html#configuring) on how to configure various libraries to log to stdout/stderr.
## Memory
By default, applications get 256MB RAM (including swap). This can be changed using the `memoryLimit` field in the manifest.
Design your application runtime for concurrent use by 10s of users. The Cloudron is not designed for concurrent access by
100s or 1000s of users.
## Startup
* Apps must not present a post-installation screen on first run. It should be already pre-configured for
a specific purpose.
* Do not run as `root`. Apps can use the `cloudron` user which is part of the [base image](/references/baseimage.html)
for this purpose or create their own.
* When using the `localstorage` addon, the application must change the ownership of files in `/app/data` as desired using `chown`. This
is necessary because file permissions may not be correctly preserved across backup, restore, application and base image
updates.
* Addon information (mail, database) is exposed as environment variables. An application must use these values directly
and not cache them across restarts. If the variables are stored in a configuration file, then the configuration file
must be regenerated on every application start. This is usually done using a configuration template that is patched
on every startup.
## Authentication
Apps should integrate with one of the [authentication strategies](/references/authentication.html).
This saves the user from having to manage separate set of users for different apps.
+104 -2
View File
@@ -53,6 +53,10 @@ Cloudron has a built-in firewall and ports are opened and closed dynamically, as
apps are installed, re-configured or removed. For this reason, be sure to open all TCP and
UDP traffic to the server and leave the traffic management to the Cloudron.
### Kimsufi
Be sure to check the "use the distribution kernel" checkbox in the personalized installation mode.
### Linode
Since Linode does not manage SSH keys, be sure to add the public key to
@@ -90,6 +94,9 @@ Specifying `fallback` will setup the Cloudron to use the fallback wildcard certi
Initially a self-signed one is provided, which can be overwritten later in the admin interface.
This may be useful for non-public installations.
* `--data-dir` is the path where Cloudron will store platform and application data.
Optional arguments used for update and restore:
* `--version` is the version of Cloudron to install. By default, the setup script installs
@@ -268,8 +275,8 @@ reputation should be easy to get back.
* Scaleway - Edit your security group to allow email and [reboot the server](https://community.online.net/t/security-group-not-working/2096) for the change to take effect. You can also set a PTR record on the interface with your `my.<domain>`.
* Check if your IP is listed in any DNSBL list [here](http://multirbl.valli.org/). In most cases,
you can apply for removal of your IP by filling out a form at the DNSBL manager site.
* Check if your IP is listed in any DNSBL list [here](http://multirbl.valli.org/) and [here](www.blk.mx).
In most cases, you can apply for removal of your IP by filling out a form at the DNSBL manager site.
* When using wildcard or manual DNS backends, you have to setup the DMARC, MX records manually.
@@ -374,6 +381,101 @@ To restore a Cloudron from a specific backup:
* Make the box backup private, once the upgrade is complete.
# Security
Security is a core feature of the Cloudron and we continue to push out updates to tighten the Cloudron's security policy. Our goal is that Cloudron users should be able to rely on Cloudron being secure out of the box without having to do manual configuration.
This section lists various security measures in place to protect the Cloudron.
## HTTP Security
* Cloudron admin has a CSP policy that prevents XSS attacks.
* Cloudron set various security related HTTP headers like `X-XSS-Protection`, `X-Download-Options`,
`X-Content-Type-Options`, `X-Permitted-Cross-Domain-Policies`, `X-Frame-Options` across all apps.
## SSL
* Cloudron enforces HTTPS across all apps. HTTP requests are automatically redirected to
HTTPS.
* The Cloudron automatically installs and renews certificates for your apps as needed. Should
installation of certificate fail for reasons beyond it's control, Cloudron admins will get a notification about it.
* Cloudron sets the `Strict-Transport-Security` header (HSTS) to protect apps against downgrade attacks
and cookie hijacking.
* Cloudron has A+ rating for SSL from [SSL Labs](https://cloudron.io/blog/2017-02-22-release-0.102.0.html).
## App isolation
* Apps are isolated completely from one another. One app cannot tamper with another apps' database or
local files. We achieve this using Linux Containers.
* Apps run with a read-only rootfs preventing attacks where the application code can be tampered with.
* Apps can only connect to addons like databases, LDAP, email relay using authentication.
* Apps are run with an AppArmor profile that disables many system calls and restricts access to `proc`
and `sys` filesystems.
* Most apps are run as non-root user. In the future, we intend to implement user namespaces.
* Each app is run in it's own subdomain as opposed to sub-paths. This ensures that XSS vulnerabilities
in one app doesn't [compromise](https://security.stackexchange.com/questions/24155/preventing-insecure-webapp-on-subdomain-compromise-security-of-main-webapp) other apps.
## Email
* Cloudron checks against the [Zen Spamhaus DNSBL](https://www.spamhaus.org/zen/) before accepting mail.
* Email can only be accessed with IMAP over TLS (IMAPS).
* Email can only be relayed (including same-domain emails) by authenticated users using SMTP/STARTTLS.
* Cloudron ensures that `MAIL FROM` is the same as the authenticated user. Users cannot spoof each other.
* All outbound mails from Cloudron are `DKIM` signed.
* Cloudron automatically sets up SPF, DMARC policies in the DNS for best email delivery.
* All incoming mail is scanned via `Spamassasin`.
## Firewall
* Cloudron blocks all incoming ports except 22 (ssh), 80 (http), 443 (https)
* When email is enabled, Cloudron allows 25 (SMTP), 587 (MSA), 993 (IMAPS) and 4190 (WebSieve)
## OS Updates
* Ubuntu [automatic security updates](https://help.ubuntu.com/community/AutomaticSecurityUpdates) are enabled
## Rate limits
The goal of rate limits is to prevent password brute force attacks.
* Cloudron password verification routes - 10 requests per second per IP.
* HTTP and HTTPS requests - 5000 requests per second per IP.
* SSH access - 5 connections per 10 seconds per IP.
* Email access (Port 25, 587, 993, 4190) - 50 connections per second per IP/App.
* Database addons access - 5000 connections per second per app (addons use 128 byte passwords).
* Email relay access - 500 connections per second per app.
* Email receive access - 50 connections per second per app.
* Auth addon access - 500 connections per second per app.
## Password restrictions
* Cloudron requires user passwords to have 1 uppercase, 1 number and 1 symbol.
* Minimum length for user passwords is 8
# Data directory
If you are installing a brand new Cloudron, you can configure the data directory
that Cloudron uses by passing the `--data-dir` option to `cloudron-setup`.
```
./cloudron-setup --provider digitalocean --data-dir /var/cloudrondata
```
If you have an existing Cloudron, we recommend moving the existing data directory
to a new location as follows (`DATA_DIR` is the location to move your data):
```
systemctl stop cloudron.target
systemctl stop docker
DATA_DIR="/var/data"
mv /home/yellowtent/appsdata "${DATA_DIR}"
ln -s "${DATA_DIR}/appsdata" /home/yellowtent/appsdata
mv /home/yellowtent/platformdata "${DATA_DIR}"
ln -s "${DATA_DIR}/platformdata" /home/yellowtent/platformdata
systemctl start docker
systemctl start cloudron.target
```
# Debug
You can SSH into your Cloudron and collect logs:
+15 -3
View File
@@ -330,16 +330,28 @@ the apps on your Cloudron and also tracks configuration changes.
<img src="/docs/img/activity.png" class="shadow">
# API Access
All the operations listed in this manual like installing app, configuring users and groups, are
completely programmable with a [REST API](/references/api.html).
# Domains and SSL Certificates
All apps on the Cloudron can only be reached by `https`. The Cloudron automatically installs and
renews certificates for your apps as needed. Should installation of certificate fail for reasons
beyond it's control, Cloudron admins will get a notification about it.
# API Access
# OAuth Provider
All the operations listed in this manual like installing app, configuring users and groups, are
completely programmable with a [REST API](/references/api.html).
Cloudron is an OAuth 2.0 provider. To integrate Cloudron login into an external application, create
an OAuth application under `API Access`.
You can use the following OAuth URLs to add Cloudron in the external app:
```
authorizationURL: https://my.<domain>/api/v1/oauth/dialog/authorize
tokenURL: https://my.<domain>/api/v1/oauth/token
```
# Moving to a larger Cloudron
+10 -8
View File
@@ -42,12 +42,12 @@ Creating an application for Cloudron can be summarized as follows:
1. Create a web application using any language/framework. This web application must run a HTTP server
and can optionally provide other services using custom protocols (like git, ssh, TCP etc).
2. Create a [Dockerfile](http://docs.docker.com/engine/reference/builder/) that specifies how to create
2. Create a [Dockerfile](http://docs.docker.com/engine/reference/builder/) that specifies how to create
an application ```image```. An ```image``` is essentially a bundle of the application source code
and it's dependencies.
3. Create a [CloudronManifest.json](/references/manifest.html) file that provides essential information
about the app. This includes information required for the Cloudron Store like title, version, icon and
about the app. This includes information required for the Cloudron Store like title, version, icon and
runtime requirements like `addons`.
## Simple Web application
@@ -79,7 +79,7 @@ FROM cloudron/base:0.10.0
ADD server.js /app/code/server.js
CMD [ "/usr/local/node-0.12.7/bin/node", "/app/code/server.js" ]
CMD [ "/usr/local/node-6.9.5/bin/node", "/app/code/server.js" ]
```
The `FROM` command specifies that we want to start off with Cloudron's [base image](/references/baseimage.html).
@@ -90,7 +90,7 @@ While this example only copies a single file, the ADD command can be used to cop
See the [Dockerfile](https://docs.docker.com/reference/builder/#add) documentation for more details.
The `CMD` command specifies how to run the server. There are multiple versions of node available under `/usr/local`. We
choose node v0.12.7 for our app.
choose node v6.9.5 for our app.
## CloudronManifest.json
@@ -176,7 +176,7 @@ Step 0 : FROM cloudron/base:0.10.0
Step 1 : ADD server.js /app/code
---> b09b97ecdfbc
Removing intermediate container 03c1e1f77acb
Step 2 : CMD /usr/local/node-0.12.7/bin/node /app/code/main.js
Step 2 : CMD /usr/local/node-6.9.5/bin/node /app/code/main.js
---> Running in 370f59d87ab2
---> 53b51eabcb89
Removing intermediate container 370f59d87ab2
@@ -335,13 +335,15 @@ File `tutorial/Dockerfile`
```dockerfile
FROM cloudron/base:0.10.0
ENV PATH /usr/local/node-6.9.5/bin:$PATH
ADD server.js /app/code/server.js
ADD package.json /app/code/package.json
WORKDIR /app/code
RUN npm install --production
CMD [ "/usr/local/node-0.12.7/bin/node", "/app/code/server.js" ]
CMD [ "node", "/app/code/server.js" ]
```
Notice the new `RUN` command which installs the node module dependencies in package.json using `npm install`.
@@ -588,7 +590,7 @@ Once your app is ready, you can upload it to the store for `beta testing` by
other Cloudron users. This can be done using:
```
cloudron upload
cloudron appstore upload
```
The app should now be visible in the Store view of your cloudron under
@@ -605,7 +607,7 @@ developer mode.
Once you are satisfied with the beta testing, you can submit it for review.
```
cloudron submit
cloudron appstore submit
```
The cloudron.io team will review the app and publish the app to the store.
+233 -23
View File
@@ -5,7 +5,7 @@ This tutorial outlines how to package an existing web application for the Cloudr
If you are aware of Docker and Heroku, you should feel at home packaging for the
Cloudron. Roughly, the steps involved are:
* Create a Dockerfile for your application. If your application already has a Dockerfile, it
* Create a Dockerfile for your application. If your application already has a Dockerfile, it
is a good starting point for packaging for the Cloudron. By virtue of Docker, the Cloudron
is able to run apps written in any language/framework.
@@ -348,12 +348,64 @@ show any setup screen after installation and should simply choose reasonable def
Databases, email configuration should be automatically picked up from the environment variables using
addons.
## Dockerfile
## Docker
The app is run as a read-only docker container. Because of this:
* Install any required packages in the Dockerfile.
* Create static configuration files in the Dockerfile.
* Create symlinks to dynamic configuration files under /run in the Dockerfile.
Cloudron uses Docker in the backend, so the package build script is a regular `Dockerfile`.
The app is run as a read-only docker container. Only `/run` (dynamic data), `/app/data` (backup data) and `/tmp` (temporary files) are writable at runtime. Because of this:
* Install any required packages in the Dockerfile.
* Create static configuration files in the Dockerfile.
* Create symlinks to dynamic configuration files under `/run` in the Dockerfile.
### Source directory
By convention, Cloudron apps install the source code in `/app/code`. Do not forget to create the directory for the code of the app:
```sh
RUN mkdir -p /app/code
WORKDIR /app/code
```
### Download archives
When packaging an app you often want to download and extract archives (e.g. from github).
This can be done in one line by combining `wget` and `tar` like this:
```docker
ENV VERSION 1.6.2
RUN wget "https://github.com/FreshRSS/FreshRSS/archive/${VERSION}.tar.gz" -O - \
| tar -xz -C /app/code --strip-components=1
```
The `--strip-components=1` causes the topmost directory in the archive to be skipped.
Always pin the download to a specific tag or commit instead of using `HEAD` or `master`
so that the builds are reasonably reproducible.
### Applying patches
To get the app working in Cloudron, sometimes it is necessary to patch the original sources. Patch is a safe way to modify sources, as it fails when the expected original sources changed too much.
First create a backup copy of the full sources (to be able to calculate the differences):
```sh
cp -a extensions extensions-orig
```
Then modify the sources in the original path and when finished, create a patch like this:
```sh
diff -Naru extensions-orig/ extensions/ > change-ttrss-file-path.patch
```
Add and apply this patch to the sources in the Dockerfile:
```docker
ADD change-ttrss-file-path.patch /app/code/change-ttrss-file-path.patch
RUN patch -p1 -d /app/code/extensions < /app/code/change-ttrss-file-path.patch
```
The `-p1` causes patch to ignore the topmost directory in the patch.
## Process manager
@@ -362,7 +414,7 @@ automatically. If your application is a single process, you do not require any p
Use supervisor, pm2 or any of the other process managers if you application has more then one component.
This **excludes** web servers like apache, nginx which can already manage their children by themselves.
Be sure to pick a process manager that forwards signals to child processes.
Be sure to pick a process manager that [forwards signals](#sigterm-handling) to child processes.
## Automatic updates
@@ -396,26 +448,184 @@ An app can determine it's memory limit by reading `/sys/fs/cgroup/memory/memory.
Apps should integrate with one of the [authentication strategies](/references/authentication.html).
This saves the user from having to manage separate set of credentials for each app.
## Startup Script
## Start script
Many apps do not launch the server directly, as we did in our basic example. Instead, they execute
a `start.sh` script (named so by convention) which launches the server. Before starting the server,
the `start.sh` script does the following:
a `start.sh` script (named so by convention) which is used as the app entry point.
* When using the `localstorage` addon, it changes the ownership of files in `/app/data` as desired using `chown`. This
is necessary because file permissions may not be correctly preserved across backup, restore, application and base image
updates.
At the end of the Dockerfile you should add your start script (`start.sh`) and set it as the default command.
Ensure that the `start.sh` is executable in the app package repo. This can be done with `chmod +x start.sh`.
```docker
ADD start.sh /app/code/start.sh
CMD [ "/app/code/start.sh" ]
```
* Addon information (mail, database) exposed as environment are subject to change across restarts and an application
must use these values directly (i.e not cache them across restarts). For this reason, it usually regenerates
any config files with the current database settings on each invocation.
### One-time init
* Finally, it starts the server as a non-root user.
One common pattern is to initialize the data directory with some commands once depending on the existence of a special `.initialized` file.
The app's main process must handle SIGTERM and forward it as required to child processes. bash does not
automatically forward signals to child processes. For this reason, when using a startup shell script,
remember to use exec <app> as the last line. Doing so will replace bash with your program and allows
your program to handle signals as required.
```sh
if ! [ -f /app/data/.initialized ]; then
echo "Fresh installation, setting up data directory..."
# Setup commands here
touch /app/data/.initialized
echo "Done."
fi
```
To copy over some files from the code directory you can use the following command:
```sh
rsync -a /app/code/config/ /app/data/config/
```
### chown data files
Since the app containers use other user ids than the host, it is sometimes necessary to change the permissions on the data directory:
```sh
chown -R cloudron.cloudron /app/data
```
For Apache+PHP apps you might need to change permissions to `www-data.www-data` instead.
### Persisting random values
Some apps need a random value that is initialized once and does not change afterwards (e.g. a salt for security purposes). This can be accomplished by creating a random value and storing it in a file in the data directory like this:
```sh
if ! [ -e /app/data/.salt ]; then
dd if=/dev/urandom bs=1 count=1024 2>/dev/null | sha1sum | awk '{ print $1 }' > /app/data/.salt
fi
SALT=$(cat /app/data/.salt)
```
### Generate config
Addon information (mail, database) exposed as environment are subject to change across restarts and an application must use these values directly (i.e not cache them across restarts). For this reason, it usually regenerates any config files with the current database settings on each invocation.
First create a config file template like this:
```sh
... snipped ...
'mysql' => array(
'driver' => 'mysql',
'host' => '##MYSQL_HOST',
'port' => '##MYSQL_PORT',
'database' => '##MYSQL_DATABASE',
'username' => '##MYSQL_USERNAME',
'password' => '##MYSQL_PASSWORD',
'charset' => 'utf8',
'collation' => 'utf8_general_ci',
'prefix' => '',
),
... snipped ...
```
Add the template file to the Dockerfile and create a symlink to the dynamic configuration file as follows:
```docker
ADD database.php.template /app/code/database.php.template
RUN ln -s /run/paperwork/database.php /app/code/database.php
```
Then in `start.sh`, generate the real config file under `/run` from the template like this:
```sh
sed -e "s/##MYSQL_HOST/${MYSQL_HOST}/" \
-e "s/##MYSQL_PORT/${MYSQL_PORT}/" \
-e "s/##MYSQL_DATABASE/${MYSQL_DATABASE}/" \
-e "s/##MYSQL_USERNAME/${MYSQL_USERNAME}/" \
-e "s/##MYSQL_PASSWORD/${MYSQL_PASSWORD}/" \
-e "s/##REDIS_HOST/${REDIS_HOST}/" \
-e "s/##REDIS_PORT/${REDIS_PORT}/" \
/app/code/database.php.template > /run/paperwork/database.php
```
### Non-root user
The cloudron runs the `start.sh` as root user. This is required for various commands like `chown` to
work as expected. However, to keep the app and cloudron secure, always run the app with the least
required permissions.
The `gosu` tool lets you run a binary with a specific user/group as follows:
```sh
/usr/local/bin/gosu cloudron:cloudron node /app/code/.build/bundle/main.js
```
### SIGTERM handling
bash, by default, does not automatically forward signals to child processes. This would mean that a SIGTERM sent to the parent processes does not reach the children. For this reason, be sure to `exec` as the
last line of the start.sh script. Programs like gosu, nginx, apache do proper SIGTERM handling.
For example, start apache using `exec` as below:
```sh
echo "Starting apache"
APACHE_CONFDIR="" source /etc/apache2/envvars
rm -f "${APACHE_PID_FILE}"
exec /usr/sbin/apache2 -DFOREGROUND
```
## Popular stacks
### Apache
Apache requires some configuration changes to work properly with Cloudron. The following commands configure Apache in the following way:
* Disable all default sites
* Print errors into the app's log and disable other logs
* Limit server processes to `5` (good default value)
* Change the port number to Cloudrons default `8000`
```docker
RUN rm /etc/apache2/sites-enabled/* \
&& sed -e 's,^ErrorLog.*,ErrorLog "/dev/stderr",' -i /etc/apache2/apache2.conf \
&& sed -e "s,MaxSpareServers[^:].*,MaxSpareServers 5," -i /etc/apache2/mods-available/mpm_prefork.conf \
&& a2disconf other-vhosts-access-log \
&& echo "Listen 8000" > /etc/apache2/ports.conf
```
Afterwards, add your site config to Apache:
```docker
ADD apache2.conf /etc/apache2/sites-available/app.conf
RUN a2ensite app
```
In `start.sh` Apache can be started using these commands:
```sh
echo "Starting apache..."
APACHE_CONFDIR="" source /etc/apache2/envvars
rm -f "${APACHE_PID_FILE}"
exec /usr/sbin/apache2 -DFOREGROUND
```
### PHP
PHP wants to store session data at `/var/lib/php/sessions` which is read-only in Cloudron. To fix this problem you can move this data to `/run/php/sessions` with these commands:
```docker
RUN rm -rf /var/lib/php/sessions && ln -s /run/php/sessions /var/lib/php/sessions
```
Don't forget to create this directory and it's ownership in the `start.sh`:
```sh
mkdir -p /run/php/sessions
chown www-data:www-data /run/php/sessions
```
### Java
Java scales its memory usage dynamically according to the available system memory. Due to how Docker works, Java sees the hosts total memory instead of the memory limit of the app. To restrict Java to the apps memory limit it is necessary to add a special parameter to Java calls.
```sh
LIMIT=$(($(cat /sys/fs/cgroup/memory/memory.memsw.limit_in_bytes)/2**20))
export JAVA_OPTS="-XX:MaxRAM=${LIMIT}M"
java ${JAVA_OPTS} -jar ...
```
# Beta Testing
@@ -432,7 +642,7 @@ Once your app is ready, you can upload it to the store for `beta testing` by
other Cloudron users. This can be done using:
```
cloudron upload
cloudron appstore upload
```
You should now be able to visit `/#/appstore/<appid>?version=<appversion>` on your
@@ -446,7 +656,7 @@ Other Cloudron users can install your app on their Cloudron's using
Once you are satisfied with the beta testing, you can submit it for review.
```
cloudron submit
cloudron appstore submit
```
The cloudron.io team will review the app and publish the app to the store.
+18 -15
View File
@@ -2,17 +2,18 @@
'use strict';
var ejs = require('gulp-ejs'),
gulp = require('gulp'),
del = require('del'),
concat = require('gulp-concat'),
uglify = require('gulp-uglify'),
serve = require('gulp-serve'),
sass = require('gulp-sass'),
sourcemaps = require('gulp-sourcemaps'),
cssnano = require('gulp-cssnano'),
var argv = require('yargs').argv,
autoprefixer = require('gulp-autoprefixer'),
argv = require('yargs').argv;
concat = require('gulp-concat'),
cssnano = require('gulp-cssnano'),
del = require('del'),
ejs = require('gulp-ejs'),
gulp = require('gulp'),
sass = require('gulp-sass'),
serve = require('gulp-serve'),
sourcemaps = require('gulp-sourcemaps'),
uglify = require('gulp-uglify'),
url = require('url');
gulp.task('3rdparty', function () {
gulp.src([
@@ -54,14 +55,16 @@ gulp.task('js', ['js-index', 'js-setup', 'js-setupdns', 'js-update'], function (
var oauth = {
clientId: argv.clientId || 'cid-webadmin',
clientSecret: argv.clientSecret || 'unused',
apiOrigin: argv.apiOrigin || ''
apiOrigin: argv.apiOrigin || '',
apiOriginHostname: argv.apiOrigin ? url.parse(argv.apiOrigin).hostname : ''
};
console.log();
console.log('Using OAuth credentials:');
console.log(' ClientId: %s', oauth.clientId);
console.log(' ClientSecret: %s', oauth.clientSecret);
console.log(' Cloudron API: %s', oauth.apiOrigin || 'default');
console.log(' ClientId: %s', oauth.clientId);
console.log(' ClientSecret: %s', oauth.clientSecret);
console.log(' Cloudron API: %s', oauth.apiOrigin || 'default');
console.log(' Cloudron Host: %s', oauth.apiOriginHostname);
console.log();
@@ -140,7 +143,7 @@ gulp.task('js-update', function () {
// --------------
gulp.task('html', ['html-views', 'html-update', 'html-templates'], function () {
return gulp.src('webadmin/src/*.html').pipe(gulp.dest('webadmin/dist'));
return gulp.src('webadmin/src/*.html').pipe(ejs({ apiOriginHostname: oauth.apiOriginHostname }, { ext: '.html' })).pipe(gulp.dest('webadmin/dist'));
});
gulp.task('html-update', function () {
Executable
+32
View File
@@ -0,0 +1,32 @@
#!/usr/bin/env node
'use strict';
var tar = require('tar-fs'),
fs = require('fs'),
path = require('path'),
zlib = require('zlib');
if (process.argv.length < 4) {
console.error('Usage: tarjs <cwd> <dir>');
process.exit(1);
}
var dir = process.argv[3];
var cwd = process.argv[2];
console.error('Packing directory "'+ dir +'" from within "' + cwd + '" and stream to stdout');
process.chdir(cwd);
var stat = fs.statSync(dir);
if (!stat.isDirectory()) throw(dir + ' is not a directory');
var gzipStream = zlib.createGzip({});
tar.pack(path.resolve(dir), {
ignore: function (name) {
if (name === '.') return true;
return false;
}
}).pipe(gzipStream).pipe(process.stdout);
@@ -0,0 +1,16 @@
'use strict';
exports.up = function(db, callback) {
db.runSql('ALTER TABLE appAddonConfigs ADD COLUMN name VARCHAR(128)', function (error) {
if (error) console.error(error);
callback(error);
});
};
exports.down = function(db, callback) {
db.runSql('ALTER TABLE appAddonConfigs DROP COLUMN name', function (error) {
if (error) console.error(error);
callback(error);
});
};
@@ -0,0 +1,14 @@
'use strict';
var url = require('url');
exports.up = function(db, callback) {
var dbName = url.parse(process.env.DATABASE_URL).path.substr(1); // remove slash
// by default, mysql collates case insensitively. 'utf8_general_cs' is not available
db.runSql('ALTER DATABASE ' + dbName + ' DEFAULT CHARACTER SET=utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci', callback);
};
exports.down = function(db, callback) {
callback();
};
+1
View File
@@ -97,6 +97,7 @@ CREATE TABLE IF NOT EXISTS settings(
CREATE TABLE IF NOT EXISTS appAddonConfigs(
appId VARCHAR(128) NOT NULL,
addonId VARCHAR(32) NOT NULL,
name VARCHAR(128) NOT NULL,
value VARCHAR(512) NOT NULL,
FOREIGN KEY(appId) REFERENCES apps(id));
+155 -2436
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -13,6 +13,7 @@
"node >=4.0.0 <=4.1.1"
],
"dependencies": {
"@sindresorhus/df": "^2.1.0",
"async": "^2.1.4",
"aws-sdk": "^2.1.46",
"body-parser": "^1.13.1",
@@ -32,7 +33,6 @@
"ejs": "^2.2.4",
"ejs-cli": "^1.2.0",
"express": "^4.12.4",
"express-rate-limit": "^2.6.0",
"express-session": "^1.11.3",
"gulp-sass": "^3.0.0",
"hat": "0.0.3",
@@ -44,7 +44,6 @@
"multiparty": "^4.1.2",
"mysql": "^2.7.0",
"native-dns": "^0.7.0",
"node-df": "^0.1.1",
"node-uuid": "^1.4.3",
"nodemailer": "^1.3.0",
"nodemailer-smtp-transport": "^1.0.3",
@@ -64,6 +63,7 @@
"split": "^1.0.0",
"superagent": "^1.8.3",
"supererror": "^0.7.1",
"tar-fs": "^1.15.2",
"tldjs": "^1.6.2",
"underscore": "^1.7.0",
"valid-url": "^1.0.9",
+42 -22
View File
@@ -15,16 +15,15 @@ fi
# 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="19" # this is the size of "/" and required to fit in docker images 19 is a safe bet for different reporting on 20GB min
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)
readonly curl="curl --fail --connect-timeout 20 --retry 10 --retry-delay 2 --max-time 2400"
# copied from cloudron-resize-fs.sh
readonly physical_memory=$(LC_ALL=C free -m | awk '/Mem:/ { print $2 }')
readonly disk_device="$(for d in $(find /dev -type b); do [ "$(mountpoint -d /)" = "$(mountpoint -x $d)" ] && echo $d && break; done)"
readonly disk_size_bytes=$(LC_ALL=C fdisk -l ${disk_device} | grep "Disk ${disk_device}" | awk '{ printf $5 }')
readonly disk_size_gb=$((${disk_size_bytes}/1024/1024/1024))
readonly disk_size_bytes=$(LC_ALL=C df --output=size / | tail -n1)
readonly disk_size_gb=$((${disk_size_bytes}/1024/1024))
# verify the system has minimum requirements met
if [[ "${physical_memory}" -lt "${MINIMUM_MEMORY}" ]]; then
@@ -33,7 +32,7 @@ if [[ "${physical_memory}" -lt "${MINIMUM_MEMORY}" ]]; then
fi
if [[ "${disk_size_gb}" -lt "${MINIMUM_DISK_SIZE_GB}" ]]; then
echo "Error: Cloudron requires atleast 20GB disk space (Disk space on ${disk_device} is ${disk_size_gb}GB)"
echo "Error: Cloudron requires atleast 20GB disk space (Disk space on / is ${disk_size_gb}GB)"
exit 1
fi
@@ -45,15 +44,18 @@ encryptionKey=""
restoreUrl=""
dnsProvider="manual"
tlsProvider="le-prod"
versionsUrl="https://s3.amazonaws.com/prod-cloudron-releases/versions.json"
requestedVersion="latest"
requestedVersion=""
apiServerOrigin="https://api.cloudron.io"
dataJson=""
prerelease="false"
sourceTarballUrl=""
rebootServer="true"
baseDataDir=""
args=$(getopt -o "" -l "domain:,help,skip-baseimage-init,data:,provider:,encryption-key:,restore-url:,tls-provider:,version:,versions-url:,api-server:,dns-provider:,env:,prerelease,skip-reboot,source-url:" -n "$0" -- "$@")
# TODO this is still there for the restore case, see other occasions below
versionsUrl="https://s3.amazonaws.com/prod-cloudron-releases/versions.json"
args=$(getopt -o "" -l "domain:,help,skip-baseimage-init,data:,data-dir:,provider:,encryption-key:,restore-url:,tls-provider:,version:,api-server:,dns-provider:,env:,prerelease,skip-reboot,source-url:" -n "$0" -- "$@")
eval set -- "${args}"
while true; do
@@ -68,24 +70,24 @@ while true; do
--version) requestedVersion="$2"; shift 2;;
--env)
if [[ "$2" == "dev" ]]; then
apiServerOrigin="https://api.dev.cloudron.io"
versionsUrl="https://s3.amazonaws.com/dev-cloudron-releases/versions.json"
apiServerOrigin="https://api.dev.cloudron.io"
tlsProvider="le-staging"
prerelease="true"
elif [[ "$2" == "staging" ]]; then
apiServerOrigin="https://api.staging.cloudron.io"
versionsUrl="https://s3.amazonaws.com/staging-cloudron-releases/versions.json"
apiServerOrigin="https://api.staging.cloudron.io"
tlsProvider="le-staging"
prerelease="true"
fi
shift 2;;
--versions-url) versionsUrl="$2"; shift 2;;
--api-server) apiServerOrigin="$2"; shift 2;;
--skip-baseimage-init) initBaseImage="false"; shift;;
--skip-reboot) rebootServer="false"; shift;;
--data) dataJson="$2"; shift 2;;
--prerelease) prerelease="true"; shift;;
--source-url) sourceTarballUrl="$2"; version="0.0.1+custom"; shift 2;;
--data-dir) baseDataDir=$(realpath "$2"); shift 2;;
--) break;;
*) echo "Unknown option $1"; exit 1;;
esac
@@ -94,7 +96,7 @@ done
# validate arguments in the absence of data
if [[ -z "${dataJson}" ]]; then
if [[ -z "${provider}" ]]; then
echo "--provider is required (azure, digitalocean, ec2, lightsail, linode, ovh, scaleway, vultr or generic)"
echo "--provider is required (azure, digitalocean, ec2, lightsail, linode, ovh, rosehosting, scaleway, vultr or generic)"
exit 1
elif [[ \
"${provider}" != "ami" && \
@@ -125,11 +127,16 @@ if [[ -z "${dataJson}" ]]; then
echo "--dns-provider must be one of : manual, noop"
exit 1
fi
if [[ -n "${baseDataDir}" && ! -d "${baseDataDir}" ]]; then
echo "${baseDataDir} does not exist"
exit 1
fi
fi
echo ""
echo "##############################################"
echo " Cloudron Setup (${requestedVersion}) "
echo " Cloudron Setup (${requestedVersion:-latest})"
echo "##############################################"
echo ""
echo " Follow setup logs in a second terminal with:"
@@ -153,20 +160,25 @@ fi
echo "=> Checking version"
if [[ "${sourceTarballUrl}" == "" ]]; then
releaseJson=$($curl -s "${versionsUrl}")
if [[ "$requestedVersion" == "latest" ]]; then
pre=$([[ "${prerelease}" == "true" ]] && echo "null" || echo "-pre")
version=$(echo "${releaseJson}" | python3 -c "import json,sys,collections;obj=json.load(sys.stdin, object_pairs_hook=collections.OrderedDict);latest=list(v for v in obj if '${pre}' not in v)[-1];print(latest)")
if ! releaseJson=$($curl -s "${apiServerOrigin}/api/v1/releases?prerelease=${prerelease}&boxVersion=${requestedVersion}"); then
echo "Failed to get release information"
exit 1
fi
if [[ "$requestedVersion" == "" ]]; then
version=$(echo "${releaseJson}" | python3 -c 'import json,sys;obj=json.load(sys.stdin);print(obj["version"])')
else
version="${requestedVersion}"
fi
if ! sourceTarballUrl=$(echo "${releaseJson}" | python3 -c 'import json,sys;obj=json.load(sys.stdin);print(obj[sys.argv[1]]["sourceTarballUrl"])' "${version}"); then
echo "No source code for version ${requestedVersion}"
if ! sourceTarballUrl=$(echo "${releaseJson}" | python3 -c 'import json,sys;obj=json.load(sys.stdin);print(obj["info"]["sourceTarballUrl"])'); then
echo "No source code for version '${requestedVersion:-latest}'"
exit 1
fi
fi
# Build data
# TODO versionsUrl is still there for the cloudron restore case
if [[ -z "${dataJson}" ]]; then
if [[ -z "${restoreUrl}" ]]; then
data=$(cat <<EOF
@@ -232,9 +244,17 @@ fi
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}" &>> "${LOG_FILE}"; then
echo "Failed to install cloudron. See ${LOG_FILE} for details"
exit 1
# poor mans semver
if [[ ${version} == "0.11"* ]]; then
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
fi
else
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
fi
rm "${DATA_FILE}"
+13 -1
View File
@@ -9,6 +9,7 @@ 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 script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
@@ -17,14 +18,16 @@ 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:" -n "$0" -- "$@")
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
@@ -56,6 +59,15 @@ if [[ "${is_update}" == "yes" ]]; then
${BOX_SRC_DIR}/setup/stop.sh # stop the old code
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
-4
View File
@@ -5,7 +5,6 @@ json="${source_dir}/../node_modules/.bin/json"
# IMPORTANT: Fix cloudron.js:doUpdate if you add/remove any arg. keep these sorted for readability
arg_api_server_origin=""
arg_box_versions_url=""
arg_fqdn=""
arg_is_custom_domain="false"
arg_restore_key=""
@@ -50,8 +49,6 @@ while true; do
[[ "${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"
arg_box_versions_url=$(echo "$2" | $json boxVersionsUrl)
[[ "${arg_box_versions_url}" == "" ]] && arg_box_versions_url="https://s3.amazonaws.com/prod-cloudron-releases/versions.json"
# TODO check if an where this is used
arg_version=$(echo "$2" | $json version)
@@ -97,7 +94,6 @@ done
echo "Parsed arguments:"
echo "api server: ${arg_api_server_origin}"
echo "box versions url: ${arg_box_versions_url}"
echo "fqdn: ${arg_fqdn}"
echo "custom domain: ${arg_is_custom_domain}"
echo "restore key: ${arg_restore_key}"
+6 -6
View File
@@ -6,12 +6,12 @@ readonly SETUP_WEBSITE_DIR="/home/yellowtent/setup/website"
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
readonly box_src_dir="$(realpath ${script_dir}/..)"
readonly DATA_DIR="/home/yellowtent/data"
readonly PLATFORM_DATA_DIR="/home/yellowtent/platformdata"
readonly ADMIN_LOCATION="my" # keep this in sync with constants.js
echo "Setting up nginx update page"
if [[ ! -f "${DATA_DIR}/nginx/applications/admin.conf" ]]; then
if [[ ! -f "${PLATFORM_DATA_DIR}/nginx/applications/admin.conf" ]]; then
echo "No admin.conf found. This Cloudron has no domain yet. Skip splash setup"
exit
fi
@@ -29,16 +29,16 @@ cp -r "${script_dir}/splash/website/"* "${SETUP_WEBSITE_DIR}"
# create nginx config
readonly current_infra=$(node -e "console.log(require('${script_dir}/../src/infra_version.js').version);")
existing_infra="none"
[[ -f "${DATA_DIR}/INFRA_VERSION" ]] && existing_infra=$(node -e "console.log(JSON.parse(require('fs').readFileSync('${DATA_DIR}/INFRA_VERSION', 'utf8')).version);")
[[ -f "${PLATFORM_DATA_DIR}/INFRA_VERSION" ]] && existing_infra=$(node -e "console.log(JSON.parse(require('fs').readFileSync('${PLATFORM_DATA_DIR}/INFRA_VERSION', 'utf8')).version);")
if [[ "${arg_retire_reason}" != "" || "${existing_infra}" != "${current_infra}" ]]; then
echo "Showing progress bar on all subdomains in retired mode or infra update. retire: ${arg_retire_reason} existing: ${existing_infra} current: ${current_infra}"
rm -f ${DATA_DIR}/nginx/applications/*
rm -f ${PLATFORM_DATA_DIR}/nginx/applications/*
${box_src_dir}/node_modules/.bin/ejs-cli -f "${script_dir}/start/nginx/appconfig.ejs" \
-O "{ \"vhost\": \"~^(.+)\$\", \"adminOrigin\": \"${admin_origin}\", \"endpoint\": \"splash\", \"sourceDir\": \"${SETUP_WEBSITE_DIR}\", \"certFilePath\": \"cert/host.cert\", \"keyFilePath\": \"cert/host.key\", \"xFrameOptions\": \"SAMEORIGIN\" }" > "${DATA_DIR}/nginx/applications/admin.conf"
-O "{ \"vhost\": \"~^(.+)\$\", \"adminOrigin\": \"${admin_origin}\", \"endpoint\": \"splash\", \"sourceDir\": \"${SETUP_WEBSITE_DIR}\", \"certFilePath\": \"cert/host.cert\", \"keyFilePath\": \"cert/host.key\", \"xFrameOptions\": \"SAMEORIGIN\" }" > "${PLATFORM_DATA_DIR}/nginx/applications/admin.conf"
else
echo "Show progress bar only on admin domain for normal update"
${box_src_dir}/node_modules/.bin/ejs-cli -f "${script_dir}/start/nginx/appconfig.ejs" \
-O "{ \"vhost\": \"${admin_fqdn}\", \"adminOrigin\": \"${admin_origin}\", \"endpoint\": \"splash\", \"sourceDir\": \"${SETUP_WEBSITE_DIR}\", \"certFilePath\": \"cert/host.cert\", \"keyFilePath\": \"cert/host.key\", \"xFrameOptions\": \"SAMEORIGIN\" }" > "${DATA_DIR}/nginx/applications/admin.conf"
-O "{ \"vhost\": \"${admin_fqdn}\", \"adminOrigin\": \"${admin_origin}\", \"endpoint\": \"splash\", \"sourceDir\": \"${SETUP_WEBSITE_DIR}\", \"certFilePath\": \"cert/host.cert\", \"keyFilePath\": \"cert/host.key\", \"xFrameOptions\": \"SAMEORIGIN\" }" > "${PLATFORM_DATA_DIR}/nginx/applications/admin.conf"
fi
if [[ "${arg_retire_reason}" == "migrate" ]]; then
+55 -82
View File
@@ -5,10 +5,11 @@ set -eu -o pipefail
echo "==> Cloudron Start"
readonly USER="yellowtent"
readonly DATA_FILE="/root/user_data.img"
readonly HOME_DIR="/home/${USER}"
readonly BOX_SRC_DIR="${HOME_DIR}/box"
readonly DATA_DIR="${HOME_DIR}/data" # app and platform data
readonly OLD_DATA_DIR="${HOME_DIR}/data";
readonly PLATFORM_DATA_DIR="${HOME_DIR}/platformdata" # platform data
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 SETUP_PROGRESS_JSON="${HOME_DIR}/setup/website/progress.json"
@@ -33,36 +34,6 @@ timedatectl set-ntp 1
timedatectl set-timezone UTC
hostnamectl set-hostname "${arg_fqdn}"
echo "==> Setting up firewall"
iptables -t filter -N CLOUDRON || true
iptables -t filter -F CLOUDRON # empty any existing rules
# NOTE: keep these in sync with src/apps.js validatePortBindings
# allow ssh, http, https, ping, dns
iptables -t filter -I CLOUDRON -m state --state RELATED,ESTABLISHED -j ACCEPT
# caas has ssh on port 202
if [[ "${arg_provider}" == "caas" ]]; then
iptables -A CLOUDRON -p tcp -m tcp -m multiport --dports 25,80,202,443,587,993,4190 -j ACCEPT
else
iptables -A CLOUDRON -p tcp -m tcp -m multiport --dports 25,80,22,443,587,993,4190 -j ACCEPT
fi
iptables -t filter -A CLOUDRON -p icmp --icmp-type echo-request -j ACCEPT
iptables -t filter -A CLOUDRON -p icmp --icmp-type echo-reply -j ACCEPT
iptables -t filter -A CLOUDRON -p udp --sport 53 -j ACCEPT
iptables -t filter -A CLOUDRON -s 172.18.0.0/16 -j ACCEPT # required to accept any connections from apps to our IP:<public port>
iptables -t filter -A CLOUDRON -i lo -j ACCEPT # required for localhost connections (mysql)
# log dropped incoming. keep this at the end of all the rules
iptables -t filter -A CLOUDRON -m limit --limit 2/min -j LOG --log-prefix "IPTables Packet Dropped: " --log-level 7
iptables -t filter -A CLOUDRON -j DROP
if ! iptables -t filter -C INPUT -j CLOUDRON 2>/dev/null; then
iptables -t filter -I INPUT -j CLOUDRON
fi
# so it gets restored across reboot
mkdir -p /etc/iptables && iptables-save > /etc/iptables/rules.v4
echo "==> Configuring docker"
cp "${script_dir}/start/docker-cloudron-app.apparmor" /etc/apparmor.d/docker-cloudron-app
systemctl enable apparmor
@@ -96,46 +67,51 @@ if [[ "${arg_provider}" == "caas" ]]; then
systemctl reload sshd
fi
echo "==> Setup btrfs data"
if [[ ! -d "${DATA_DIR}" ]]; then
echo "==> Mounting loopback btrfs"
truncate -s "8192m" "${DATA_FILE}" # 8gb start (this will get resized dynamically by cloudron-resize-fs.service)
mkfs.btrfs -L UserDataHome "${DATA_FILE}"
mkdir -p "${DATA_DIR}"
mount -t btrfs -o loop,nosuid "${DATA_FILE}" ${DATA_DIR}
fi
mkdir -p "${BOX_DATA_DIR}"
mkdir -p "${APPS_DATA_DIR}"
mkdir -p "${PLATFORM_DATA_DIR}"
# keep these in sync with paths.js
echo "==> Ensuring directories"
if ! btrfs subvolume show "${DATA_DIR}/mail" &> /dev/null; then
# Migrate mail data to new format
docker stop mail || true # otherwise the move below might fail if mail container writes in the middle
rm -rf "${DATA_DIR}/mail" # this used to be mail container's run directory
btrfs subvolume create "${DATA_DIR}/mail"
[[ -d "${DATA_DIR}/box/mail" ]] && mv "${DATA_DIR}/box/mail/"* "${DATA_DIR}/mail"
rm -rf "${DATA_DIR}/box/mail"
if [[ ! -d "${PLATFORM_DATA_DIR}/mail" ]]; then
if [[ -d "${OLD_DATA_DIR}/mail" ]]; then
echo "==> Migrate old mail data"
# Migrate mail data to new format
docker stop mail || true # otherwise the move below might fail if mail container writes in the middle
mkdir -p "${PLATFORM_DATA_DIR}/mail"
# we can't move the whole folder as it is a btrfs subvolume mount
mv -f "${OLD_DATA_DIR}/mail/"* "${PLATFORM_DATA_DIR}/mail/" # this used to be mail container's run directory
else
echo "==> Create new mail data dir"
mkdir -p "${PLATFORM_DATA_DIR}/mail"
fi
fi
mkdir -p "${DATA_DIR}/graphite"
mkdir -p "${DATA_DIR}/mail/dkim"
mkdir -p "${DATA_DIR}/mysql"
mkdir -p "${DATA_DIR}/postgresql"
mkdir -p "${DATA_DIR}/mongodb"
mkdir -p "${DATA_DIR}/snapshots"
mkdir -p "${DATA_DIR}/addons/mail"
mkdir -p "${DATA_DIR}/collectd/collectd.conf.d"
mkdir -p "${DATA_DIR}/acme"
mkdir -p "${PLATFORM_DATA_DIR}/graphite"
mkdir -p "${PLATFORM_DATA_DIR}/mail/dkim"
mkdir -p "${PLATFORM_DATA_DIR}/mysql"
mkdir -p "${PLATFORM_DATA_DIR}/postgresql"
mkdir -p "${PLATFORM_DATA_DIR}/mongodb"
mkdir -p "${PLATFORM_DATA_DIR}/snapshots"
mkdir -p "${PLATFORM_DATA_DIR}/addons/mail"
mkdir -p "${PLATFORM_DATA_DIR}/collectd/collectd.conf.d"
mkdir -p "${PLATFORM_DATA_DIR}/acme"
mkdir -p "${BOX_DATA_DIR}"
if btrfs subvolume show "${DATA_DIR}/box" &> /dev/null; then
# Migrate box data out of data volume
mv "${DATA_DIR}/box/"* "${BOX_DATA_DIR}"
btrfs subvolume delete "${DATA_DIR}/box"
fi
mkdir -p "${BOX_DATA_DIR}/appicons"
mkdir -p "${BOX_DATA_DIR}/certs"
mkdir -p "${BOX_DATA_DIR}/acme" # acme keys
echo "==> Check for old btrfs volumes"
if mountpoint -q "${OLD_DATA_DIR}"; then
echo "==> Cleanup btrfs volumes"
# First stop all container to be able to unmount
docker ps -q | xargs docker stop
umount "${OLD_DATA_DIR}"
rm -rf "/root/user_data.img"
else
echo "==> No btrfs volumes found";
fi
echo "==> Configuring journald"
sed -e "s/^#SystemMaxUse=.*$/SystemMaxUse=100M/" \
-e "s/^#ForwardToSyslog=.*$/ForwardToSyslog=no/" \
@@ -168,7 +144,10 @@ cp -r "${script_dir}/start/systemd/." /etc/systemd/system/
systemctl daemon-reload
systemctl enable unbound
systemctl enable cloudron.target
systemctl enable iptables-restore
systemctl enable cloudron-firewall
# update firewall rules
systemctl restart cloudron-firewall
# For logrotate
systemctl enable --now cron
@@ -182,18 +161,18 @@ cp "${script_dir}/start/sudoers" /etc/sudoers.d/${USER}
echo "==> Configuring collectd"
rm -rf /etc/collectd
ln -sfF "${DATA_DIR}/collectd" /etc/collectd
cp "${script_dir}/start/collectd.conf" "${DATA_DIR}/collectd/collectd.conf"
ln -sfF "${PLATFORM_DATA_DIR}/collectd" /etc/collectd
cp "${script_dir}/start/collectd.conf" "${PLATFORM_DATA_DIR}/collectd/collectd.conf"
systemctl restart collectd
echo "==> Configuring nginx"
# link nginx config to system config
unlink /etc/nginx 2>/dev/null || rm -rf /etc/nginx
ln -s "${DATA_DIR}/nginx" /etc/nginx
mkdir -p "${DATA_DIR}/nginx/applications"
mkdir -p "${DATA_DIR}/nginx/cert"
cp "${script_dir}/start/nginx/nginx.conf" "${DATA_DIR}/nginx/nginx.conf"
cp "${script_dir}/start/nginx/mime.types" "${DATA_DIR}/nginx/mime.types"
ln -s "${PLATFORM_DATA_DIR}/nginx" /etc/nginx
mkdir -p "${PLATFORM_DATA_DIR}/nginx/applications"
mkdir -p "${PLATFORM_DATA_DIR}/nginx/cert"
cp "${script_dir}/start/nginx/nginx.conf" "${PLATFORM_DATA_DIR}/nginx/nginx.conf"
cp "${script_dir}/start/nginx/mime.types" "${PLATFORM_DATA_DIR}/nginx/mime.types"
if ! grep -q "^Restart=" /etc/systemd/system/multi-user.target.wants/nginx.service; then
# default nginx service file does not restart on crash
echo -e "\n[Service]\nRestart=always\n" >> /etc/systemd/system/multi-user.target.wants/nginx.service
@@ -202,12 +181,7 @@ fi
systemctl start nginx
# bookkeep the version as part of data
echo "{ \"version\": \"${arg_version}\", \"boxVersionsUrl\": \"${arg_box_versions_url}\" }" > "${BOX_DATA_DIR}/version"
# remove old snapshots. if we do want to keep this around, we will have to fix the chown -R below
# which currently fails because these are readonly fs
echo "==> Cleaning up snapshots"
find "${DATA_DIR}/snapshots" -mindepth 1 -maxdepth 1 | xargs --no-run-if-empty btrfs subvolume delete
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
@@ -234,7 +208,7 @@ if [[ -n "${arg_restore_url}" ]]; then
while true; do
if $curl -L "${arg_restore_url}" | openssl aes-256-cbc -d -pass "pass:${arg_restore_key}" \
| tar -zxf - --overwrite --transform="s,^box/\?,boxdata/," --transform="s,^mail/\?,data/mail/," --show-transformed-names -C "${HOME_DIR}"; then break; fi
| tar -zxf - --overwrite --transform="s,^box/\?,boxdata/," --transform="s,^mail/\?,platformdata/mail/," --show-transformed-names -C "${HOME_DIR}"; then break; fi
echo "Failed to download data, trying again"
done
@@ -261,7 +235,6 @@ cat > "${CONFIG_DIR}/cloudron.conf" <<CONF_END
"webServerOrigin": "${arg_web_server_origin}",
"fqdn": "${arg_fqdn}",
"isCustomDomain": ${arg_is_custom_domain},
"boxVersionsUrl": "${arg_box_versions_url}",
"provider": "${arg_provider}",
"isDemo": ${arg_is_demo},
"database": {
@@ -289,11 +262,11 @@ CONF_END
echo "==> Changing ownership"
chown "${USER}:${USER}" -R "${CONFIG_DIR}"
chown "${USER}:${USER}" -R "${DATA_DIR}/nginx" "${DATA_DIR}/collectd" "${DATA_DIR}/addons" "${DATA_DIR}/acme"
chown "${USER}:${USER}" -R "${PLATFORM_DATA_DIR}/nginx" "${PLATFORM_DATA_DIR}/collectd" "${PLATFORM_DATA_DIR}/addons" "${PLATFORM_DATA_DIR}/acme"
chown "${USER}:${USER}" -R "${BOX_DATA_DIR}"
chown "${USER}:${USER}" -R "${DATA_DIR}/mail/dkim" # this is owned by box currently since it generates the keys
chown "${USER}:${USER}" "${DATA_DIR}/INFRA_VERSION" 2>/dev/null || true
chown "${USER}:${USER}" "${DATA_DIR}"
chown "${USER}:${USER}" -R "${PLATFORM_DATA_DIR}/mail/dkim" # this is owned by box currently since it generates the keys
chown "${USER}:${USER}" "${PLATFORM_DATA_DIR}/INFRA_VERSION" 2>/dev/null || true
chown "${USER}:${USER}" "${PLATFORM_DATA_DIR}"
echo "==> Adding automated configs"
if [[ ! -z "${arg_backup_config}" ]]; then
+75
View File
@@ -0,0 +1,75 @@
#!/bin/bash
set -eu -o pipefail
echo "==> Setting up firewall"
iptables -t filter -N CLOUDRON || true
iptables -t filter -F CLOUDRON # empty any existing rules
# NOTE: keep these in sync with src/apps.js validatePortBindings
# allow ssh, http, https, ping, dns
iptables -t filter -I CLOUDRON -m state --state RELATED,ESTABLISHED -j ACCEPT
# caas has ssh on port 202
iptables -A CLOUDRON -p tcp -m tcp -m multiport --dports 22,25,80,202,443,587,993,4190 -j ACCEPT
iptables -t filter -A CLOUDRON -p icmp --icmp-type echo-request -j ACCEPT
iptables -t filter -A CLOUDRON -p icmp --icmp-type echo-reply -j ACCEPT
iptables -t filter -A CLOUDRON -p udp --sport 53 -j ACCEPT
iptables -t filter -A CLOUDRON -s 172.18.0.0/16 -j ACCEPT # required to accept any connections from apps to our IP:<public port>
iptables -t filter -A CLOUDRON -i lo -j ACCEPT # required for localhost connections (mysql)
# log dropped incoming. keep this at the end of all the rules
iptables -t filter -A CLOUDRON -m limit --limit 2/min -j LOG --log-prefix "IPTables Packet Dropped: " --log-level 7
iptables -t filter -A CLOUDRON -j DROP
if ! iptables -t filter -C INPUT -j CLOUDRON 2>/dev/null; then
iptables -t filter -I INPUT -j CLOUDRON
fi
# Setup rate limit chain (the recent info is at /proc/net/xt_recent)
iptables -t filter -N CLOUDRON_RATELIMIT || true
iptables -t filter -F CLOUDRON_RATELIMIT # empty any existing rules
# log dropped incoming. keep this at the end of all the rules
iptables -t filter -N CLOUDRON_RATELIMIT_LOG || true
iptables -t filter -F CLOUDRON_RATELIMIT_LOG # empty any existing rules
iptables -t filter -A CLOUDRON_RATELIMIT_LOG -m limit --limit 2/min -j LOG --log-prefix "IPTables RateLimit: " --log-level 7
iptables -t filter -A CLOUDRON_RATELIMIT_LOG -j DROP
# http https
for port in 80 443; do
iptables -A CLOUDRON_RATELIMIT -p tcp --syn --dport ${port} -m connlimit --connlimit-above 5000 -j CLOUDRON_RATELIMIT_LOG
done
# ssh smtp ssh msa imap sieve
for port in 22 202; do
iptables -A CLOUDRON_RATELIMIT -p tcp --dport ${port} -m state --state NEW -m recent --set --name "public-${port}"
iptables -A CLOUDRON_RATELIMIT -p tcp --dport ${port} -m state --state NEW -m recent --update --name "public-${port}" --seconds 10 --hitcount 5 -j CLOUDRON_RATELIMIT_LOG
done
# TODO: move docker platform rules to platform.js so it can be specialized to rate limit only when destination is the mail container
# docker translates (dnat) 25, 587, 993, 4190 in the PREROUTING step
for port in 2525 4190 9993; do
iptables -A CLOUDRON_RATELIMIT -p tcp --syn ! -s 172.18.0.0/16 -d 172.18.0.0/16 --dport ${port} -m connlimit --connlimit-above 50 -j CLOUDRON_RATELIMIT_LOG
done
# msa, ldap, imap, sieve
for port in 2525 3002 4190 9993; do
iptables -A CLOUDRON_RATELIMIT -p tcp --syn -s 172.18.0.0/16 -d 172.18.0.0/16 --dport ${port} -m connlimit --connlimit-above 500 -j CLOUDRON_RATELIMIT_LOG
done
# cloudron docker network: mysql postgresql redis mongodb
for port in 3306 5432 6379 27017; do
iptables -A CLOUDRON_RATELIMIT -p tcp --syn -s 172.18.0.0/16 -d 172.18.0.0/16 --dport ${port} -m connlimit --connlimit-above 5000 -j CLOUDRON_RATELIMIT_LOG
done
# For ssh, http, https
if ! iptables -t filter -C INPUT -j CLOUDRON_RATELIMIT 2>/dev/null; then
iptables -t filter -I INPUT 1 -j CLOUDRON_RATELIMIT
fi
# For smtp, imap etc routed via docker/nat
# Workaroud issue where Docker insists on adding itself first in FORWARD table
iptables -D FORWARD -j CLOUDRON_RATELIMIT || true
iptables -I FORWARD 1 -j CLOUDRON_RATELIMIT
+21 -28
View File
@@ -2,49 +2,42 @@
set -eu -o pipefail
readonly USER_HOME="/home/yellowtent"
readonly APPS_SWAP_FILE="/apps.swap"
readonly USER_DATA_FILE="/root/user_data.img"
readonly USER_DATA_DIR="/home/yellowtent/data"
# detect device of rootfs (http://forums.fedoraforum.org/showthread.php?t=270316)
disk_device="$(for d in $(find /dev -type b); do [ "$(mountpoint -d /)" = "$(mountpoint -x $d)" ] && echo $d && break; done)"
existing_swap=$(cat /proc/meminfo | grep SwapTotal | awk '{ printf "%.0f", $2/1024 }')
# all sizes are in mb
readonly physical_memory=$(LC_ALL=C free -m | awk '/Mem:/ { print $2 }')
readonly swap_size=$((${physical_memory} - ${existing_swap})) # if you change this, fix enoughResourcesAvailable() in client.js
readonly swap_size=$((${physical_memory} > 4096 ? 4096 : ${physical_memory})) # min(RAM, 4GB) if you change this, fix enoughResourcesAvailable() in client.js
readonly app_count=$((${physical_memory} / 200)) # estimated app count
readonly disk_size_bytes=$(LC_ALL=C fdisk -l ${disk_device} | grep "Disk ${disk_device}" | awk '{ printf $5 }') # can't rely on fdisk human readable units, using bytes instead
readonly disk_size=$((${disk_size_bytes}/1024/1024))
readonly disk_size_bytes=$(LC_ALL=C df --output=size / | tail -n1)
readonly disk_size=$((${disk_size_bytes}/1024))
readonly system_size=10240 # 10 gigs for system libs, apps images, installer, box code, data and tmp
readonly ext4_reserved=$((disk_size * 5 / 100)) # this can be changes using tune2fs -m percent /dev/vda1
echo "Disk device: ${disk_device}"
echo "Physical memory: ${physical_memory}"
echo "Estimated app count: ${app_count}"
echo "Disk size: ${disk_size}M"
# Allocate swap for general app usage
if [[ ! -f "${APPS_SWAP_FILE}" && ${swap_size} -gt 0 ]]; then
echo "Creating Apps swap file of size ${swap_size}M"
fallocate -l "${swap_size}m" "${APPS_SWAP_FILE}"
readonly current_swap=$(swapon --show="name,size" --noheadings --bytes | awk 'BEGIN{s=0}{s+=$2}END{printf "%.0f", s/1024/1024}')
readonly needed_swap_size=$((swap_size - current_swap))
if [[ ${needed_swap_size} -gt 0 ]]; then
echo "Need more swap of ${needed_swap_size}M"
# compute size of apps.swap ignoring what is already set
without_apps_swap=$(swapon --show="name,size" --noheadings --bytes | awk 'BEGIN{s=0}{if ($1!="/apps.swap") s+=$2}END{printf "%.0f", s/1024/1024}')
apps_swap_size=$((swap_size - without_apps_swap))
echo "Creating Apps swap file of size ${apps_swap_size}M"
if [[ -f "${APPS_SWAP_FILE}" ]]; then
echo "Swapping off before resizing swap"
swapoff "${APPS_SWAP_FILE}" || true
fi
fallocate -l "${apps_swap_size}m" "${APPS_SWAP_FILE}"
chmod 600 "${APPS_SWAP_FILE}"
mkswap "${APPS_SWAP_FILE}"
swapon "${APPS_SWAP_FILE}"
echo "${APPS_SWAP_FILE} none swap sw 0 0" >> /etc/fstab
if ! grep -q "${APPS_SWAP_FILE}" /etc/fstab; then
echo "Adding swap to fstab"
echo "${APPS_SWAP_FILE} none swap sw 0 0" >> /etc/fstab
fi
else
echo "Apps Swap file already exists"
echo "Swap requirements already met"
fi
# see start.sh for the initial default size of 8gb. On small disks the calculation might be lower than 8gb resulting in a failure to resize here.
echo "Resizing data volume"
home_data_size=$((disk_size - system_size - swap_size - ext4_reserved))
echo "Resizing up btrfs user data to size ${home_data_size}M"
umount "${USER_DATA_DIR}" || true
# Do not preallocate (non-sparse). Doing so overallocates for data too much in advance and causes problems when using many apps with smaller data
# fallocate -l "${home_data_size}m" "${USER_DATA_FILE}" # does not overwrite existing data
truncate -s "${home_data_size}m" "${USER_DATA_FILE}" # this will shrink it if the file had existed. this is useful when running this script on a live system
mount -t btrfs -o loop,nosuid "${USER_DATA_FILE}" ${USER_DATA_DIR}
btrfs filesystem resize max "${USER_DATA_DIR}"
-2
View File
@@ -194,7 +194,6 @@ LoadPlugin write_graphite
<Plugin df>
FSType "ext4"
FSType "btrfs"
ReportByDevice true
IgnoreSelected false
@@ -260,4 +259,3 @@ LoadPlugin write_graphite
<Include "/etc/collectd/collectd.conf.d">
Filter "*.conf"
</Include>
+16
View File
@@ -8,3 +8,19 @@ max_connections=50
# on ec2, without this we get a sporadic connection drop when doing the initial migration
max_allowed_packet=32M
# https://mathiasbynens.be/notes/mysql-utf8mb4
character-set-server = utf8mb4
collation-server = utf8mb4_unicode_ci
[mysqldump]
quick
quote-names
max_allowed_packet = 16M
default-character-set = utf8mb4
[mysql]
default-character-set = utf8mb4
[client]
default-character-set = utf8mb4
+17 -3
View File
@@ -32,14 +32,21 @@ server {
# https://developer.mozilla.org/en-US/docs/Web/HTTP/X-Frame-Options
add_header X-Frame-Options "<%= xFrameOptions %>";
proxy_hide_header X-Frame-Options;
# https://github.com/twitter/secureheaders
# https://www.owasp.org/index.php/OWASP_Secure_Headers_Project#tab=Compatibility_Matrix
# https://wiki.mozilla.org/Security/Guidelines/Web_Security
add_header X-XSS-Protection "1; mode=block";
proxy_hide_header X-XSS-Protection;
add_header X-Download-Options "noopen";
proxy_hide_header X-Download-Options;
add_header X-Content-Type-Options "nosniff";
proxy_hide_header X-Content-Type-Options;
add_header X-Permitted-Cross-Domain-Policies "none";
proxy_hide_header X-Permitted-Cross-Domain-Policies;
add_header Referrer-Policy "no-referrer-when-downgrade";
proxy_hide_header Referrer-Policy;
proxy_http_version 1.1;
proxy_intercept_errors on;
@@ -69,6 +76,9 @@ server {
proxy_buffers 4 256k;
proxy_busy_buffers_size 256k;
# No buffering to temp files, it fails for large downloads
proxy_max_temp_file_size 0;
# Disable check to allow unlimited body sizes
client_max_body_size 0;
@@ -78,13 +88,19 @@ server {
client_max_body_size 1m;
}
location ~ ^/api/v1/(developer|session)/login$ {
proxy_pass http://127.0.0.1:3000;
client_max_body_size 1m;
limit_req zone=admin_login burst=5;
}
# the read timeout is between successive reads and not the whole connection
location ~ ^/api/v1/apps/.*/exec$ {
proxy_pass http://127.0.0.1:3000;
proxy_read_timeout 30m;
}
# graphite paths
# graphite paths (uncomment block below and visit /graphite/index.html)
# location ~ ^/(graphite|content|metrics|dashboard|render|browser|composer)/ {
# proxy_pass http://127.0.0.1:8000;
# client_max_body_size 1m;
@@ -94,7 +110,6 @@ server {
root <%= sourceDir %>/webadmin/dist;
index index.html index.htm;
}
<% } else if ( endpoint === 'app' ) { %>
proxy_pass http://127.0.0.1:<%= port %>;
<% } else if ( endpoint === 'splash' ) { %>
@@ -134,4 +149,3 @@ server {
<% } %>
}
}
+4 -2
View File
@@ -33,6 +33,9 @@ http {
# keep-alive connections timeout in 65s. this is because many browsers timeout in 60 seconds
keepalive_timeout 65s;
# zones for rate limiting
limit_req_zone $binary_remote_addr zone=admin_login:10m rate=10r/s; # 10 request a second
# HTTP server
server {
listen 80;
@@ -48,7 +51,7 @@ http {
# acme challenges
location /.well-known/acme-challenge/ {
default_type text/plain;
alias /home/yellowtent/data/acme/;
alias /home/yellowtent/platformdata/acme/;
}
location / {
@@ -59,4 +62,3 @@ http {
include applications/*.conf;
}
@@ -0,0 +1,12 @@
[Unit]
Description=Cloudron Firewall
After=docker.service
PartOf=docker.service
[Service]
Type=oneshot
ExecStart="/home/yellowtent/box/setup/start/cloudron-firewall.sh"
RemainAfterExit=yes
[Install]
WantedBy=multi-user.target
@@ -1,11 +0,0 @@
[Unit]
Description=IPTables Restore
Before=docker.service
[Service]
Type=oneshot
ExecStart=/sbin/iptables-restore /etc/iptables/rules.v4
RemainAfterExit=yes
[Install]
WantedBy=multi-user.target
+58 -46
View File
@@ -194,7 +194,11 @@ function getEnvironment(app, callback) {
assert.strictEqual(typeof app, 'object');
assert.strictEqual(typeof callback, 'function');
appdb.getAddonConfigByAppId(app.id, callback);
appdb.getAddonConfigByAppId(app.id, function (error, result) {
if (error) return callback(error);
return callback(null, result.map(function (e) { return e.name + '=' + e.value; }));
});
}
function getBindsSync(app, addons) {
@@ -207,7 +211,7 @@ function getBindsSync(app, addons) {
for (var addon in addons) {
switch (addon) {
case 'localstorage': binds.push(path.join(paths.DATA_DIR, app.id, 'data') + ':/app/data:rw'); break;
case 'localstorage': binds.push(path.join(paths.APPS_DATA_DIR, app.id, 'data') + ':/app/data:rw'); break;
default: break;
}
}
@@ -254,9 +258,9 @@ function setupOauth(app, options, callback) {
if (error) return callback(error);
var env = [
'OAUTH_CLIENT_ID=' + result.id,
'OAUTH_CLIENT_SECRET=' + result.clientSecret,
'OAUTH_ORIGIN=' + config.adminOrigin()
{ name: 'OAUTH_CLIENT_ID', value: result.id },
{ name: 'OAUTH_CLIENT_SECRET', value: result.clientSecret },
{ name: 'OAUTH_ORIGIN', value: config.adminOrigin() }
];
debugApp(app, 'Setting oauth addon config to %j', env);
@@ -287,13 +291,13 @@ function setupEmail(app, options, callback) {
// note that "external" access info can be derived from MAIL_DOMAIN (since it's part of user documentation)
var env = [
'MAIL_SMTP_SERVER=mail',
'MAIL_SMTP_PORT=2525',
'MAIL_IMAP_SERVER=mail',
'MAIL_IMAP_PORT=9993',
'MAIL_SIEVE_SERVER=mail',
'MAIL_SIEVE_PORT=4190',
'MAIL_DOMAIN=' + config.fqdn()
{ name: 'MAIL_SMTP_SERVER', value: 'mail' },
{ name: 'MAIL_SMTP_PORT', value: '2525' },
{ name: 'MAIL_IMAP_SERVER', value: 'mail' },
{ name: 'MAIL_IMAP_PORT', value: '9993' },
{ name: 'MAIL_SIEVE_SERVER', value: 'mail' },
{ name: 'MAIL_SIEVE_PORT', value: '4190' },
{ name: 'MAIL_DOMAIN', value: config.fqdn() }
];
debugApp(app, 'Setting up Email');
@@ -319,13 +323,13 @@ function setupLdap(app, options, callback) {
if (!app.sso) return callback(null);
var env = [
'LDAP_SERVER=172.18.0.1',
'LDAP_PORT=' + config.get('ldapPort'),
'LDAP_URL=ldap://172.18.0.1:' + config.get('ldapPort'),
'LDAP_USERS_BASE_DN=ou=users,dc=cloudron',
'LDAP_GROUPS_BASE_DN=ou=groups,dc=cloudron',
'LDAP_BIND_DN=cn='+ app.id + ',ou=apps,dc=cloudron',
'LDAP_BIND_PASSWORD=' + hat(4 * 128) // this is ignored
{ name: 'LDAP_SERVER', value: '172.18.0.1' },
{ name: 'LDAP_PORT', value: '' + config.get('ldapPort') },
{ name: 'LDAP_URL', value: 'ldap://172.18.0.1:' + config.get('ldapPort') },
{ name: 'LDAP_USERS_BASE_DN', value: 'ou=users,dc=cloudron' },
{ name: 'LDAP_GROUPS_BASE_DN', value: 'ou=groups,dc=cloudron' },
{ name: 'LDAP_BIND_DN', value: 'cn='+ app.id + ',ou=apps,dc=cloudron' },
{ name: 'LDAP_BIND_PASSWORD', value: hat(4 * 128) } // this is ignored
];
debugApp(app, 'Setting up LDAP');
@@ -354,14 +358,15 @@ function setupSendMail(app, options, callback) {
if (error) return callback(error);
var mailbox = results.filter(function (r) { return !r.aliasTarget; })[0];
var password = generatePassword(128, false /* memorable */, /[\w\d_]/);
var env = [
"MAIL_SMTP_SERVER=mail",
"MAIL_SMTP_PORT=2525",
"MAIL_SMTP_USERNAME=" + mailbox.name,
"MAIL_SMTP_PASSWORD=" + app.id,
"MAIL_FROM=" + mailbox.name + '@' + config.fqdn(),
"MAIL_DOMAIN=" + config.fqdn()
{ name: 'MAIL_SMTP_SERVER', value: 'mail' },
{ name: 'MAIL_SMTP_PORT', value: '2525' },
{ name: 'MAIL_SMTP_USERNAME', value: mailbox.name },
{ name: 'MAIL_SMTP_PASSWORD', value: password },
{ name: 'MAIL_FROM', value: mailbox.name + '@' + config.fqdn() },
{ name: 'MAIL_DOMAIN', value: config.fqdn() }
];
debugApp(app, 'Setting sendmail addon config to %j', env);
appdb.setAddonConfig(app.id, 'sendmail', env, callback);
@@ -389,14 +394,15 @@ function setupRecvMail(app, options, callback) {
if (error) return callback(error);
var mailbox = results.filter(function (r) { return !r.aliasTarget; })[0];
var password = generatePassword(128, false /* memorable */, /[\w\d_]/);
var env = [
"MAIL_IMAP_SERVER=mail",
"MAIL_IMAP_PORT=9993",
"MAIL_IMAP_USERNAME=" + mailbox.name,
"MAIL_IMAP_PASSWORD=" + app.id,
"MAIL_TO=" + mailbox.name + '@' + config.fqdn(),
"MAIL_DOMAIN=" + config.fqdn()
{ name: 'MAIL_IMAP_SERVER', value: 'mail' },
{ name: 'MAIL_IMAP_PORT', value: '9993' },
{ name: 'MAIL_IMAP_USERNAME', value: mailbox.name },
{ name: 'MAIL_IMAP_PASSWORD', value: password },
{ name: 'MAIL_TO', value: mailbox.name + '@' + config.fqdn() },
{ name: 'MAIL_DOMAIN', value: config.fqdn() }
];
debugApp(app, 'Setting sendmail addon config to %j', env);
@@ -426,7 +432,9 @@ function setupMySql(app, options, callback) {
docker.execContainer('mysql', cmd, { bufferStdout: true }, function (error, stdout) {
if (error) return callback(error);
var env = stdout.toString('utf8').split('\n').slice(0, -1); // remove trailing newline
var result = stdout.toString('utf8').split('\n').slice(0, -1); // remove trailing newline
var env = result.map(function (r) { var idx = r.indexOf('='); return { name: r.substr(0, idx), value: r.substr(idx + 1) }; });
debugApp(app, 'Setting mysql addon config to %j', env);
appdb.setAddonConfig(app.id, 'mysql', env, callback);
});
@@ -453,7 +461,7 @@ function backupMySql(app, options, callback) {
callback = once(callback); // ChildProcess exit may or may not be called after error
var output = fs.createWriteStream(path.join(paths.DATA_DIR, app.id, 'mysqldump'));
var output = fs.createWriteStream(path.join(paths.APPS_DATA_DIR, app.id, 'mysqldump'));
output.on('error', callback);
var cmd = [ '/addons/mysql/service.sh', options.multipleDatabases ? 'backup-prefix' : 'backup', app.id ];
@@ -469,7 +477,7 @@ function restoreMySql(app, options, callback) {
debugApp(app, 'restoreMySql');
var input = fs.createReadStream(path.join(paths.DATA_DIR, app.id, 'mysqldump'));
var input = fs.createReadStream(path.join(paths.APPS_DATA_DIR, app.id, 'mysqldump'));
input.on('error', callback);
var cmd = [ '/addons/mysql/service.sh', options.multipleDatabases ? 'restore-prefix' : 'restore', app.id ];
@@ -489,7 +497,9 @@ function setupPostgreSql(app, options, callback) {
docker.execContainer('postgresql', cmd, { bufferStdout: true }, function (error, stdout) {
if (error) return callback(error);
var env = stdout.toString('utf8').split('\n').slice(0, -1); // remove trailing newline
var result = stdout.toString('utf8').split('\n').slice(0, -1); // remove trailing newline
var env = result.map(function (r) { var idx = r.indexOf('='); return { name: r.substr(0, idx), value: r.substr(idx + 1) }; });
debugApp(app, 'Setting postgresql addon config to %j', env);
appdb.setAddonConfig(app.id, 'postgresql', env, callback);
});
@@ -516,7 +526,7 @@ function backupPostgreSql(app, options, callback) {
callback = once(callback); // ChildProcess exit may or may not be called after error
var output = fs.createWriteStream(path.join(paths.DATA_DIR, app.id, 'postgresqldump'));
var output = fs.createWriteStream(path.join(paths.APPS_DATA_DIR, app.id, 'postgresqldump'));
output.on('error', callback);
var cmd = [ '/addons/postgresql/service.sh', 'backup', app.id ];
@@ -532,7 +542,7 @@ function restorePostgreSql(app, options, callback) {
debugApp(app, 'restorePostgreSql');
var input = fs.createReadStream(path.join(paths.DATA_DIR, app.id, 'postgresqldump'));
var input = fs.createReadStream(path.join(paths.APPS_DATA_DIR, app.id, 'postgresqldump'));
input.on('error', callback);
var cmd = [ '/addons/postgresql/service.sh', 'restore', app.id ];
@@ -553,7 +563,9 @@ function setupMongoDb(app, options, callback) {
docker.execContainer('mongodb', cmd, { bufferStdout: true }, function (error, stdout) {
if (error) return callback(error);
var env = stdout.toString('utf8').split('\n').slice(0, -1); // remove trailing newline
var result = stdout.toString('utf8').split('\n').slice(0, -1); // remove trailing newline
var env = result.map(function (r) { var idx = r.indexOf('='); return { name: r.substr(0, idx), value: r.substr(idx + 1) }; });
debugApp(app, 'Setting mongodb addon config to %j', env);
appdb.setAddonConfig(app.id, 'mongodb', env, callback);
});
@@ -580,7 +592,7 @@ function backupMongoDb(app, options, callback) {
callback = once(callback); // ChildProcess exit may or may not be called after error
var output = fs.createWriteStream(path.join(paths.DATA_DIR, app.id, 'mongodbdump'));
var output = fs.createWriteStream(path.join(paths.APPS_DATA_DIR, app.id, 'mongodbdump'));
output.on('error', callback);
var cmd = [ '/addons/mongodb/service.sh', 'backup', app.id ];
@@ -596,7 +608,7 @@ function restoreMongoDb(app, options, callback) {
debugApp(app, 'restoreMongoDb');
var input = fs.createReadStream(path.join(paths.DATA_DIR, app.id, 'mongodbdump'));
var input = fs.createReadStream(path.join(paths.APPS_DATA_DIR, app.id, 'mongodbdump'));
input.on('error', callback);
var cmd = [ '/addons/mongodb/service.sh', 'restore', app.id ];
@@ -610,9 +622,9 @@ function setupRedis(app, options, callback) {
assert.strictEqual(typeof options, 'object');
assert.strictEqual(typeof callback, 'function');
var redisPassword = generatePassword(64, false /* memorable */, /[\w\d_]/); // ensure no / in password for being sed friendly (and be uri friendly)
var redisPassword = generatePassword(128, false /* memorable */, /[\w\d_]/); // ensure no / in password for being sed friendly (and be uri friendly)
var redisVarsFile = path.join(paths.ADDON_CONFIG_DIR, 'redis-' + app.id + '_vars.sh');
var redisDataDir = path.join(paths.DATA_DIR, app.id + '/redis');
var redisDataDir = path.join(paths.APPS_DATA_DIR, app.id + '/redis');
if (!safe.fs.writeFileSync(redisVarsFile, 'REDIS_PASSWORD=' + redisPassword)) {
return callback(new Error('Error writing redis config'));
@@ -631,10 +643,10 @@ function setupRedis(app, options, callback) {
--read-only -v /tmp -v /run ${tag}`;
var env = [
'REDIS_URL=redis://redisuser:' + redisPassword + '@redis-' + app.id,
'REDIS_PASSWORD=' + redisPassword,
'REDIS_HOST=' + redisName,
'REDIS_PORT=6379'
{ name: 'REDIS_URL', value: 'redis://redisuser:' + redisPassword + '@redis-' + app.id },
{ name: 'REDIS_PASSWORD', value: redisPassword },
{ name: 'REDIS_HOST', value: redisName },
{ name: 'REDIS_PORT', value: '6379' }
];
async.series([
+21 -13
View File
@@ -14,6 +14,7 @@ exports = module.exports = {
setAddonConfig: setAddonConfig,
getAddonConfig: getAddonConfig,
getAddonConfigByAppId: getAddonConfigByAppId,
getAddonConfigByName: getAddonConfigByName,
unsetAddonConfig: unsetAddonConfig,
unsetAddonConfigByAppId: unsetAddonConfigByAppId,
@@ -413,11 +414,11 @@ function setAddonConfig(appId, addonId, env, callback) {
if (env.length === 0) return callback(null);
var query = 'INSERT INTO appAddonConfigs(appId, addonId, value) VALUES ';
var query = 'INSERT INTO appAddonConfigs(appId, addonId, name, value) VALUES ';
var args = [ ], queryArgs = [ ];
for (var i = 0; i < env.length; i++) {
args.push(appId, addonId, env[i]);
queryArgs.push('(?, ?, ?)');
args.push(appId, addonId, env[i].name, env[i].value);
queryArgs.push('(?, ?, ?, ?)');
}
database.query(query + queryArgs.join(','), args, function (error) {
@@ -456,13 +457,10 @@ function getAddonConfig(appId, addonId, callback) {
assert.strictEqual(typeof addonId, 'string');
assert.strictEqual(typeof callback, 'function');
database.query('SELECT value FROM appAddonConfigs WHERE appId = ? AND addonId = ?', [ appId, addonId ], function (error, results) {
database.query('SELECT name, value FROM appAddonConfigs WHERE appId = ? AND addonId = ?', [ appId, addonId ], function (error, results) {
if (error) return callback(new DatabaseError(DatabaseError.INTERNAL_ERROR, error));
var config = [ ];
results.forEach(function (v) { config.push(v.value); });
callback(null, config);
callback(null, results);
});
}
@@ -470,13 +468,23 @@ function getAddonConfigByAppId(appId, callback) {
assert.strictEqual(typeof appId, 'string');
assert.strictEqual(typeof callback, 'function');
database.query('SELECT value FROM appAddonConfigs WHERE appId = ?', [ appId ], function (error, results) {
database.query('SELECT name, value FROM appAddonConfigs WHERE appId = ?', [ appId ], function (error, results) {
if (error) return callback(new DatabaseError(DatabaseError.INTERNAL_ERROR, error));
var config = [ ];
results.forEach(function (v) { config.push(v.value); });
callback(null, config);
callback(null, results);
});
}
function getAddonConfigByName(appId, addonId, name, callback) {
assert.strictEqual(typeof appId, 'string');
assert.strictEqual(typeof addonId, 'string');
assert.strictEqual(typeof name, 'string');
assert.strictEqual(typeof callback, 'function');
database.query('SELECT value FROM appAddonConfigs WHERE appId = ? AND addonId = ? AND name = ?', [ appId, addonId, name ], 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].value);
});
}
+25 -105
View File
@@ -45,6 +45,8 @@ exports = module.exports = {
var addons = require('./addons.js'),
appdb = require('./appdb.js'),
appstore = require('./appstore.js'),
AppstoreError = require('./appstore.js').AppstoreError,
assert = require('assert'),
async = require('async'),
backups = require('./backups.js'),
@@ -64,7 +66,6 @@ var addons = require('./addons.js'),
paths = require('./paths.js'),
safe = require('safetydance'),
semver = require('semver'),
settings = require('./settings.js'),
spawn = require('child_process').spawn,
split = require('split'),
superagent = require('superagent'),
@@ -366,99 +367,6 @@ function getAllByUser(user, callback) {
});
}
function purchase(appId, appstoreId, callback) {
assert.strictEqual(typeof appId, 'string');
assert.strictEqual(typeof appstoreId, 'string');
assert.strictEqual(typeof callback, 'function');
if (appstoreId === '') return callback(null);
function purchaseWithAppstoreConfig(appstoreConfig) {
assert.strictEqual(typeof appstoreConfig.userId, 'string');
assert.strictEqual(typeof appstoreConfig.cloudronId, 'string');
assert.strictEqual(typeof appstoreConfig.token, 'string');
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 AppsError(AppsError.EXTERNAL_ERROR, error));
if (result.statusCode === 404) return callback(new AppsError(AppsError.NOT_FOUND));
if (result.statusCode === 403 || result.statusCode === 401) return callback(new AppsError(AppsError.BILLING_REQUIRED));
if (result.statusCode !== 201 && result.statusCode !== 200) return callback(new AppsError(AppsError.EXTERNAL_ERROR, util.format('App purchase failed. %s %j', result.status, result.body)));
callback(null);
});
}
// Caas Cloudrons do not store appstore credentials in their local database
if (config.provider() === 'caas') {
var url = config.apiServerOrigin() + '/api/v1/exchangeBoxTokenWithUserToken';
superagent.post(url).query({ token: config.token() }).timeout(30 * 1000).end(function (error, result) {
if (error && !error.response) return callback(new AppsError(AppsError.EXTERNAL_ERROR, error));
if (result.statusCode !== 201) return callback(new AppsError(AppsError.EXTERNAL_ERROR, util.format('App purchase failed. %s %j', result.status, result.body)));
purchaseWithAppstoreConfig(result.body);
});
} else {
settings.getAppstoreConfig(function (error, result) {
if (error) return callback(new AppsError(AppsError.INTERNAL_ERROR, error));
if (!result.token) return callback(new AppsError(AppsError.BILLING_REQUIRED));
purchaseWithAppstoreConfig(result);
});
}
}
function unpurchase(appId, appstoreId, callback) {
assert.strictEqual(typeof appId, 'string');
assert.strictEqual(typeof appstoreId, 'string');
assert.strictEqual(typeof callback, 'function');
if (appstoreId === '') return callback(null);
function unpurchaseWithAppstoreConfig(appstoreConfig) {
assert.strictEqual(typeof appstoreConfig.userId, 'string');
assert.strictEqual(typeof appstoreConfig.cloudronId, 'string');
assert.strictEqual(typeof appstoreConfig.token, 'string');
var url = config.apiServerOrigin() + '/api/v1/users/' + appstoreConfig.userId + '/cloudrons/' + appstoreConfig.cloudronId + '/apps/' + appId;
superagent.get(url).query({ accessToken: appstoreConfig.token }).timeout(30 * 1000).end(function (error, result) {
if (error && !error.response) return callback(new AppsError(AppsError.EXTERNAL_ERROR, error));
if (result.statusCode === 403 || result.statusCode === 401) return callback(new AppsError(AppsError.BILLING_REQUIRED));
if (result.statusCode === 404) return callback(null); // was never purchased
if (result.statusCode !== 201 && result.statusCode !== 200) return callback(new AppsError(AppsError.EXTERNAL_ERROR, util.format('App purchase failed. %s %j', result.status, result.body)));
superagent.del(url).query({ accessToken: appstoreConfig.token }).timeout(30 * 1000).end(function (error, result) {
if (error && !error.response) return callback(new AppsError(AppsError.EXTERNAL_ERROR, error));
if (result.statusCode === 403 || result.statusCode === 401) return callback(new AppsError(AppsError.BILLING_REQUIRED));
if (result.statusCode !== 204) return callback(new AppsError(AppsError.EXTERNAL_ERROR, util.format('App unpurchase failed. %s %j', result.status, result.body)));
callback(null);
});
});
}
// Caas Cloudrons do not store appstore credentials in their local database
if (config.provider() === 'caas') {
var url = config.apiServerOrigin() + '/api/v1/exchangeBoxTokenWithUserToken';
superagent.post(url).query({ token: config.token() }).timeout(30 * 1000).end(function (error, result) {
if (error && !error.response) return callback(new AppsError(AppsError.EXTERNAL_ERROR, error));
if (result.statusCode !== 201) return callback(new AppsError(AppsError.EXTERNAL_ERROR, util.format('App purchase failed. %s %j', result.status, result.body)));
unpurchaseWithAppstoreConfig(result.body);
});
} else {
settings.getAppstoreConfig(function (error, result) {
if (error) return callback(new AppsError(AppsError.INTERNAL_ERROR, error));
if (!result.token) return callback(new AppsError(AppsError.BILLING_REQUIRED));
unpurchaseWithAppstoreConfig(result);
});
}
}
function downloadManifest(appStoreId, manifest, callback) {
if (!appStoreId && !manifest) return callback(new AppsError(AppsError.BAD_FIELD, 'Neither manifest nor appStoreId provided'));
@@ -494,7 +402,8 @@ function install(data, auditSource, callback) {
altDomain = data.altDomain || null,
xFrameOptions = data.xFrameOptions || 'SAMEORIGIN',
sso = 'sso' in data ? data.sso : null,
debugMode = data.debugMode || null;
debugMode = data.debugMode || null,
backupId = data.backupId || null;
assert(data.appStoreId || data.manifest); // atleast one of them is required
@@ -529,7 +438,7 @@ 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'];
if (altDomain !== null && !validator.isFQDN(altDomain)) return callback(new AppsError(AppsError.BAD_FIELD, 'Invalid alt domain'));
if (altDomain !== null && !validator.isFQDN(altDomain)) return callback(new AppsError(AppsError.BAD_FIELD, 'Invalid external domain'));
var appId = uuid.v4();
@@ -546,8 +455,11 @@ function install(data, auditSource, callback) {
debug('Will install app with id : ' + appId);
purchase(appId, appStoreId, function (error) {
if (error) return callback(error);
appstore.purchase(appId, appStoreId, 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));
if (error) return callback(new AppsError(AppsError.INTERNAL_ERROR, error));
var data = {
accessRestriction: accessRestriction,
@@ -556,7 +468,8 @@ function install(data, auditSource, callback) {
xFrameOptions: xFrameOptions,
sso: sso,
debugMode: debugMode,
mailboxName: (location ? location : manifest.title.toLowerCase().replace(/[^a-zA-Z0-9]/g, '')) + '.app'
mailboxName: (location ? location : manifest.title.toLowerCase().replace(/[^a-zA-Z0-9]/g, '')) + '.app',
lastBackupId: backupId
};
appdb.add(appId, appStoreId, manifest, location, portBindings, data, function (error) {
@@ -571,7 +484,7 @@ function install(data, auditSource, callback) {
taskmanager.restartAppTask(appId);
eventlog.add(eventlog.ACTION_APP_INSTALL, auditSource, { appId: appId, location: location, manifest: manifest });
eventlog.add(eventlog.ACTION_APP_INSTALL, auditSource, { appId: appId, location: location, manifest: manifest, backupId: backupId });
callback(null, { id : appId });
});
@@ -606,7 +519,7 @@ function configure(appId, data, auditSource, callback) {
if ('altDomain' in data) {
values.altDomain = data.altDomain;
if (values.altDomain !== null && !validator.isFQDN(values.altDomain)) return callback(new AppsError(AppsError.BAD_FIELD, 'Invalid alt domain'));
if (values.altDomain !== null && !validator.isFQDN(values.altDomain)) return callback(new AppsError(AppsError.BAD_FIELD, 'Invalid external domain'));
}
if ('portBindings' in data) {
@@ -869,6 +782,7 @@ function clone(appId, data, auditSource, callback) {
backups.getRestoreConfig(backupId, function (error, restoreConfig) {
if (error && error.reason === BackupsError.EXTERNAL_ERROR) return callback(new AppsError(AppsError.EXTERNAL_ERROR, error.message));
if (error && error.reason === BackupsError.NOT_FOUND) return callback(new AppsError(AppsError.EXTERNAL_ERROR, error.message));
if (error) return callback(new AppsError(AppsError.INTERNAL_ERROR, error));
if (!restoreConfig) callback(new AppsError(AppsError.EXTERNAL_ERROR, 'Could not get restore config'));
@@ -885,8 +799,11 @@ function clone(appId, data, auditSource, callback) {
var newAppId = uuid.v4(), appStoreId = app.appStoreId, manifest = restoreConfig.manifest;
purchase(newAppId, appStoreId, function (error) {
if (error) return callback(error);
appstore.purchase(newAppId, appStoreId, 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));
if (error) return callback(new AppsError(AppsError.INTERNAL_ERROR, error));
var data = {
installationState: appdb.ISTATE_PENDING_CLONE,
@@ -923,8 +840,11 @@ function uninstall(appId, auditSource, callback) {
get(appId, function (error, result) {
if (error) return callback(error);
unpurchase(appId, result.appStoreId, function (error) {
if (error) return callback(error);
appstore.unpurchase(appId, result.appStoreId, 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));
if (error) return callback(new AppsError(AppsError.INTERNAL_ERROR, error));
taskmanager.stopAppTask(appId, function () {
appdb.setInstallationCommand(appId, appdb.ISTATE_PENDING_UNINSTALL, function (error) {
+211
View File
@@ -0,0 +1,211 @@
'use strict';
exports = module.exports = {
purchase: purchase,
unpurchase: unpurchase,
sendAliveStatus: sendAliveStatus,
getAppUpdate: getAppUpdate,
getBoxUpdate: getBoxUpdate,
AppstoreError: AppstoreError
};
var assert = require('assert'),
config = require('./config.js'),
debug = require('debug')('box:appstore'),
os = require('os'),
settings = require('./settings.js'),
superagent = require('superagent'),
util = require('util');
function AppstoreError(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(AppstoreError, Error);
AppstoreError.INTERNAL_ERROR = 'Internal Error';
AppstoreError.EXTERNAL_ERROR = 'External Error';
AppstoreError.NOT_FOUND = 'Internal Error';
AppstoreError.BILLING_REQUIRED = 'Billing Required';
var NOOP_CALLBACK = function (error) { if (error) debug(error); };
function getAppstoreConfig(callback) {
assert.strictEqual(typeof callback, 'function');
// Caas Cloudrons do not store appstore credentials in their local database
if (config.provider() === 'caas') {
var url = config.apiServerOrigin() + '/api/v1/exchangeBoxTokenWithUserToken';
superagent.post(url).query({ token: config.token() }).timeout(30 * 1000).end(function (error, result) {
if (error && !error.response) return callback(new AppstoreError(AppstoreError.EXTERNAL_ERROR, error));
if (result.statusCode !== 201) return callback(new AppstoreError(AppstoreError.EXTERNAL_ERROR, util.format('App unpurchase failed. %s %j', result.status, result.body)));
callback(null, result.body);
});
} else {
settings.getAppstoreConfig(function (error, result) {
if (error) return callback(new AppstoreError(AppstoreError.INTERNAL_ERROR, error));
if (!result.token) return callback(new AppstoreError(AppstoreError.BILLING_REQUIRED));
callback(null, result);
});
}
}
function purchase(appId, appstoreId, callback) {
assert.strictEqual(typeof appId, 'string');
assert.strictEqual(typeof appstoreId, 'string');
assert.strictEqual(typeof callback, 'function');
if (appstoreId === '') return callback(null);
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));
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 !== 201 && result.statusCode !== 200) return callback(new AppstoreError(AppstoreError.EXTERNAL_ERROR, util.format('App purchase failed. %s %j', result.status, result.body)));
callback(null);
});
});
}
function unpurchase(appId, appstoreId, callback) {
assert.strictEqual(typeof appId, 'string');
assert.strictEqual(typeof appstoreId, 'string');
assert.strictEqual(typeof callback, 'function');
if (appstoreId === '') return callback(null);
getAppstoreConfig(function (error, appstoreConfig) {
if (error) return callback(error);
var url = config.apiServerOrigin() + '/api/v1/users/' + appstoreConfig.userId + '/cloudrons/' + appstoreConfig.cloudronId + '/apps/' + appId;
superagent.get(url).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 === 404) return callback(null); // was never purchased
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)));
superagent.del(url).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)));
callback(null);
});
});
});
}
function sendAliveStatus(data, callback) {
callback = callback || NOOP_CALLBACK;
settings.getAll(function (error, result) {
if (error) return callback(new AppstoreError(AppstoreError.INTERNAL_ERROR, error));
var backendSettings = {
dnsConfig: {
provider: result[settings.DNS_CONFIG_KEY].provider,
wildcard: result[settings.DNS_CONFIG_KEY].provider === 'manual' ? result[settings.DNS_CONFIG_KEY].wildcard : undefined
},
tlsConfig: {
provider: result[settings.TLS_CONFIG_KEY].provider
},
backupConfig: {
provider: result[settings.BACKUP_CONFIG_KEY].provider
},
mailConfig: {
enabled: result[settings.MAIL_CONFIG_KEY].enabled
},
autoupdatePattern: result[settings.AUTOUPDATE_PATTERN_KEY],
timeZone: result[settings.TIME_ZONE_KEY]
};
var data = {
domain: config.fqdn(),
version: config.version(),
provider: config.provider(),
backendSettings: backendSettings,
machine: {
cpus: os.cpus(),
totalmem: os.totalmem()
}
};
getAppstoreConfig(function (error, appstoreConfig) {
if (error) return callback(error);
var url = config.apiServerOrigin() + '/api/v1/users/' + appstoreConfig.userId + '/cloudrons/' + appstoreConfig.cloudronId + '/alive';
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));
if (result.statusCode === 404) return callback(new AppstoreError(AppstoreError.NOT_FOUND));
if (result.statusCode !== 201) return callback(new AppstoreError(AppstoreError.EXTERNAL_ERROR, util.format('Sending alive status failed. %s %j', result.status, result.body)));
callback(null);
});
});
});
}
function getBoxUpdate(callback) {
assert.strictEqual(typeof callback, 'function');
getAppstoreConfig(function (error, appstoreConfig) {
if (error) return callback(error);
var url = config.apiServerOrigin() + '/api/v1/users/' + appstoreConfig.userId + '/cloudrons/' + appstoreConfig.cloudronId + '/boxupdate';
superagent.get(url).query({ accessToken: appstoreConfig.token, boxVersion: config.version() }).timeout(10 * 1000).end(function (error, result) {
if (error && !error.response) return callback(new AppstoreError(AppstoreError.EXTERNAL_ERROR, error));
if (result.statusCode === 204) return callback(null); // no update
if (result.statusCode !== 200) return callback(new AppstoreError(AppstoreError.EXTERNAL_ERROR, util.format('Bad response: %s %s', result.statusCode, result.text)));
// { version, changelog, upgrade, sourceTarballUrl}
callback(null, result.body);
});
});
}
function getAppUpdate(app, callback) {
assert.strictEqual(typeof app, 'object');
assert.strictEqual(typeof callback, 'function');
getAppstoreConfig(function (error, appstoreConfig) {
if (error) return callback(error);
var url = config.apiServerOrigin() + '/api/v1/users/' + appstoreConfig.userId + '/cloudrons/' + appstoreConfig.cloudronId + '/appupdate';
superagent.get(url).query({ accessToken: appstoreConfig.token, boxVersion: config.version(), appId: app.appStoreId, appVersion: app.manifest.version }).timeout(10 * 1000).end(function (error, result) {
if (error && !error.response) return callback(new AppstoreError(AppstoreError.EXTERNAL_ERROR, error));
if (result.statusCode === 204) return callback(null); // no update
if (result.statusCode !== 200) return callback(new AppstoreError(AppstoreError.EXTERNAL_ERROR, util.format('Bad response: %s %s', result.statusCode, result.text)));
// { id, creationDate, manifest }
callback(null, result.body);
});
});
}
+30 -86
View File
@@ -222,7 +222,7 @@ function registerSubdomain(app, overwrite, callback) {
if (error) return callback(error);
async.retry({ times: 200, interval: 5000 }, function (retryCallback) {
debugApp(app, 'Registering subdomain location [%s]', app.location);
debugApp(app, 'Registering subdomain location [%s] overwrite: %s', app.location, overwrite);
// get the current record before updating it
subdomains.get(app.location, 'A', function (error, values) {
@@ -343,6 +343,8 @@ function install(app, callback) {
assert.strictEqual(typeof app, 'object');
assert.strictEqual(typeof callback, 'function');
const backupId = app.lastBackupId, isRestoring = app.installationState === appdb.ISTATE_PENDING_RESTORE;
async.series([
verifyManifest.bind(null, app),
@@ -352,9 +354,16 @@ function install(app, callback) {
removeCollectdProfile.bind(null, app),
stopApp.bind(null, app),
deleteContainers.bind(null, app),
addons.teardownAddons.bind(null, app, app.manifest.addons),
// oldConfig can be null during upgrades
addons.teardownAddons.bind(null, app, app.oldConfig ? app.oldConfig.manifest.addons : app.manifest.addons),
deleteVolume.bind(null, app),
unregisterSubdomain.bind(null, app, app.location),
// for restore case
function deleteImageIfChanged(done) {
if (!app.oldConfig || (app.oldConfig.manifest.dockerImage === app.manifest.dockerImage)) return done();
docker.deleteImage(app.oldConfig.manifest, done);
},
reserveHttpPort.bind(null, app),
@@ -362,7 +371,7 @@ function install(app, callback) {
downloadIcon.bind(null, app),
updateApp.bind(null, app, { installationProgress: '30, Registering subdomain' }),
registerSubdomain.bind(null, app, false /* overwrite */),
registerSubdomain.bind(null, app, isRestoring /* overwrite */),
updateApp.bind(null, app, { installationProgress: '40, Downloading image' }),
docker.downloadImage.bind(null, app.manifest),
@@ -373,6 +382,15 @@ function install(app, callback) {
updateApp.bind(null, app, { installationProgress: '60, Setting up addons' }),
addons.setupAddons.bind(null, app, app.manifest.addons),
function restoreFromBackup(next) {
if (!backupId) return next();
async.series([
updateApp.bind(null, app, { installationProgress: '65, Download backup and restore addons' }),
backups.restoreApp.bind(null, app, app.manifest.addons, backupId),
], next);
},
updateApp.bind(null, app, { installationProgress: '70, Creating container' }),
createContainer.bind(null, app),
@@ -426,84 +444,6 @@ function backup(app, callback) {
});
}
// restore is also called for upgrades and infra updates. note that in those cases it is possible there is no backup
function restore(app, callback) {
assert.strictEqual(typeof app, 'object');
assert.strictEqual(typeof callback, 'function');
// we don't have a backup, same as re-install. this allows us to install from install failures (update failures always
// have a backupId)
if (!app.lastBackupId) {
debugApp(app, 'No lastBackupId. reinstalling');
return install(app, callback);
}
var backupId = app.lastBackupId;
async.series([
updateApp.bind(null, app, { installationProgress: '10, Cleaning up old install' }),
unconfigureNginx.bind(null, app),
removeCollectdProfile.bind(null, app),
stopApp.bind(null, app),
deleteContainers.bind(null, app),
// oldConfig can be null during upgrades
addons.teardownAddons.bind(null, app, app.oldConfig ? app.oldConfig.manifest.addons : null),
deleteVolume.bind(null, app),
function deleteImageIfChanged(done) {
if (!app.oldConfig || (app.oldConfig.manifest.dockerImage === app.manifest.dockerImage)) return done();
docker.deleteImage(app.oldConfig.manifest, done);
},
reserveHttpPort.bind(null, app),
updateApp.bind(null, app, { installationProgress: '40, Downloading icon' }),
downloadIcon.bind(null, app),
updateApp.bind(null, app, { installationProgress: '55, Registering subdomain' }), // ip might change during upgrades
registerSubdomain.bind(null, app, true /* overwrite */),
updateApp.bind(null, app, { installationProgress: '60, Downloading image' }),
docker.downloadImage.bind(null, app.manifest),
updateApp.bind(null, app, { installationProgress: '65, Creating volume' }),
createVolume.bind(null, app),
updateApp.bind(null, app, { installationProgress: '70, Download backup and restore addons' }),
backups.restoreApp.bind(null, app, app.manifest.addons, backupId),
updateApp.bind(null, app, { installationProgress: '75, Creating container' }),
createContainer.bind(null, app),
updateApp.bind(null, app, { installationProgress: '80, Setting up collectd profile' }),
addCollectdProfile.bind(null, app),
runApp.bind(null, app),
updateApp.bind(null, app, { installationProgress: '85, Waiting for DNS propagation' }),
exports._waitForDnsPropagation.bind(null, app),
updateApp.bind(null, app, { installationProgress: '90, Waiting for External Domain CNAME setup' }),
exports._waitForAltDomainDnsPropagation.bind(null, app),
updateApp.bind(null, app, { installationProgress: '95, Configuring Nginx' }),
configureNginx.bind(null, app),
// done!
function (callback) {
debugApp(app, 'restored');
updateApp(app, { installationState: appdb.ISTATE_INSTALLED, installationProgress: '', health: null }, callback);
}
], function seriesDone(error) {
if (error) {
debugApp(app, 'Error installing app: %s', error);
return updateApp(app, { installationState: appdb.ISTATE_ERROR, installationProgress: error.message }, callback.bind(null, error));
}
callback(null);
});
}
// note that configure is called after an infra update as well
function configure(app, callback) {
assert.strictEqual(typeof app, 'object');
@@ -740,13 +680,17 @@ function startTask(appId, callback) {
switch (app.installationState) {
case appdb.ISTATE_PENDING_UNINSTALL: return uninstall(app, callback);
case appdb.ISTATE_PENDING_CONFIGURE: return configure(app, callback);
case appdb.ISTATE_PENDING_UPDATE: return update(app, callback);
case appdb.ISTATE_PENDING_RESTORE: return restore(app, callback);
case appdb.ISTATE_PENDING_FORCE_UPDATE: return update(app, callback);
case appdb.ISTATE_PENDING_INSTALL: return install(app, callback);
case appdb.ISTATE_PENDING_CLONE: return install(app, callback);
case appdb.ISTATE_PENDING_RESTORE: return install(app, callback);
case appdb.ISTATE_PENDING_BACKUP: return backup(app, callback);
case appdb.ISTATE_INSTALLED: return handleRunCommand(app, callback);
case appdb.ISTATE_PENDING_INSTALL: return install(app, callback);
case appdb.ISTATE_PENDING_CLONE: return restore(app, callback);
case appdb.ISTATE_PENDING_FORCE_UPDATE: return update(app, callback);
case appdb.ISTATE_ERROR:
debugApp(app, 'Internal error. apptask launched with error status.');
return callback(null);
+1 -1
View File
@@ -318,7 +318,7 @@ function backupApp(app, manifest, prefix, callback) {
appConfig.manifest = manifest;
backupFunction = createNewAppBackup.bind(null, app, manifest, prefix);
if (!safe.fs.writeFileSync(path.join(paths.DATA_DIR, app.id + '/config.json'), JSON.stringify(appConfig), 'utf8')) {
if (!safe.fs.writeFileSync(path.join(paths.APPS_DATA_DIR, app.id + '/config.json'), JSON.stringify(appConfig), 'utf8')) {
return callback(safe.error);
}
}
+1 -1
View File
@@ -96,7 +96,7 @@ function getApi(app, callback) {
var options = { };
if (tlsConfig.provider === 'caas') {
options.prod = !config.isDev(); // with altDomain, we will choose acme setting based on this
options.prod = true; // with altDomain, we will choose acme setting based on this
} else { // acme
options.prod = tlsConfig.provider.match(/.*-prod/) !== null;
}
+64 -99
View File
@@ -8,10 +8,10 @@ exports = module.exports = {
activate: activate,
getConfig: getConfig,
getStatus: getStatus,
getDisks: getDisks,
dnsSetup: dnsSetup,
sendHeartbeat: sendHeartbeat,
sendAliveStatus: sendAliveStatus,
updateToLatest: updateToLatest,
reboot: reboot,
@@ -30,7 +30,8 @@ exports = module.exports = {
EVENT_ACTIVATED: 'activated'
};
var apps = require('./apps.js'),
var appdb = require('./appdb.js'),
apps = require('./apps.js'),
assert = require('assert'),
async = require('async'),
backups = require('./backups.js'),
@@ -41,7 +42,7 @@ var apps = require('./apps.js'),
constants = require('./constants.js'),
cron = require('./cron.js'),
debug = require('debug')('box:cloudron'),
df = require('node-df'),
df = require('@sindresorhus/df'),
eventlog = require('./eventlog.js'),
fs = require('fs'),
locker = require('./locker.js'),
@@ -169,7 +170,7 @@ function onConfigured(callback) {
gConfigState.configured = true;
platform.events.on(platform.EVENT_READY, onPlatformReady);
settings.events.on(settings.DNS_CONFIG_KEY, function () { refreshDNS(); });
settings.events.on(settings.DNS_CONFIG_KEY, function () { addDnsRecords(); });
async.series([
clients.addDefaultClients,
@@ -366,7 +367,6 @@ function getStatus(callback) {
callback(null, {
activated: count !== 0,
version: config.version(),
boxVersionsUrl: config.get('boxVersionsUrl'),
apiServerOrigin: config.apiServerOrigin(), // used by CaaS tool
provider: config.provider(),
cloudronName: cloudronName,
@@ -377,6 +377,32 @@ function getStatus(callback) {
});
}
function getDisks(callback) {
assert.strictEqual(typeof callback, 'function');
var disks = {
boxDataDisk: null,
platformDataDisk: null,
appsDataDisk: null
};
df.file(paths.BOX_DATA_DIR).then(function (result) {
disks.boxDataDisk = result.filesystem;
return df.file(paths.PLATFORM_DATA_DIR);
}).then(function (result) {
disks.platformDataDisk = result.filesystem;
return df.file(paths.APPS_DATA_DIR);
}).then(function (result) {
disks.appsDataDisk = result.filesystem;
callback(null, disks);
}).catch(function (error) {
callback(new CloudronError(CloudronError.INTERNAL_ERROR, error));
});
}
function getBoxAndUserDetails(callback) {
assert.strictEqual(typeof callback, 'function');
@@ -416,7 +442,6 @@ function getConfig(callback) {
callback(null, {
apiServerOrigin: config.apiServerOrigin(),
webServerOrigin: config.webServerOrigin(),
isDev: config.isDev(),
fqdn: config.fqdn(),
version: config.version(),
update: updateChecker.getUpdateInfo(),
@@ -449,86 +474,6 @@ function sendHeartbeat() {
});
}
function sendAliveStatus(callback) {
if (typeof callback !== 'function') {
callback = function (error) {
if (error && error.reason !== CloudronError.INTERNAL_ERROR) debug(error);
else if (error) debug(error);
};
}
function sendAliveStatusWithAppstoreConfig(backendSettings, appstoreConfig) {
assert.strictEqual(typeof backendSettings, 'object');
assert.strictEqual(typeof appstoreConfig.userId, 'string');
assert.strictEqual(typeof appstoreConfig.cloudronId, 'string');
assert.strictEqual(typeof appstoreConfig.token, 'string');
var url = config.apiServerOrigin() + '/api/v1/users/' + appstoreConfig.userId + '/cloudrons/' + appstoreConfig.cloudronId;
var data = {
domain: config.fqdn(),
version: config.version(),
provider: config.provider(),
backendSettings: backendSettings,
machine: {
cpus: os.cpus(),
totalmem: os.totalmem()
}
};
superagent.post(url).send(data).query({ accessToken: appstoreConfig.token }).timeout(30 * 1000).end(function (error, result) {
if (error && !error.response) return callback(new CloudronError(CloudronError.EXTERNAL_ERROR, error));
if (result.statusCode === 404) return callback(new CloudronError(CloudronError.NOT_FOUND));
if (result.statusCode !== 201) return callback(new CloudronError(CloudronError.EXTERNAL_ERROR, util.format('Sending alive status failed. %s %j', result.status, result.body)));
callback(null);
});
}
settings.getAll(function (error, result) {
if (error) return callback(new CloudronError(CloudronError.INTERNAL_ERROR, error));
var backendSettings = {
dnsConfig: {
provider: result[settings.DNS_CONFIG_KEY].provider,
wildcard: result[settings.DNS_CONFIG_KEY].provider === 'manual' ? result[settings.DNS_CONFIG_KEY].wildcard : undefined
},
tlsConfig: {
provider: result[settings.TLS_CONFIG_KEY].provider
},
backupConfig: {
provider: result[settings.BACKUP_CONFIG_KEY].provider
},
mailConfig: {
enabled: result[settings.MAIL_CONFIG_KEY].enabled
},
autoupdatePattern: result[settings.AUTOUPDATE_PATTERN_KEY],
timeZone: result[settings.TIME_ZONE_KEY]
};
// Caas Cloudrons do not store appstore credentials in their local database
if (config.provider() === 'caas') {
var url = config.apiServerOrigin() + '/api/v1/exchangeBoxTokenWithUserToken';
superagent.post(url).query({ token: config.token() }).timeout(30 * 1000).end(function (error, result) {
if (error && !error.response) return callback(new CloudronError(CloudronError.EXTERNAL_ERROR, error));
if (result.statusCode !== 201) return callback(new CloudronError(CloudronError.EXTERNAL_ERROR, util.format('App purchase failed. %s %j', result.status, result.body)));
sendAliveStatusWithAppstoreConfig(backendSettings, result.body);
});
} else {
settings.getAppstoreConfig(function (error, result) {
if (error) return callback(new CloudronError(CloudronError.INTERNAL_ERROR, error));
if (!result.token) {
debug('sendAliveStatus: Cloudron not yet registered');
return callback(null);
}
sendAliveStatusWithAppstoreConfig(backendSettings, result);
});
}
});
}
function ensureDkimKey(callback) {
var dkimPath = path.join(paths.MAIL_DATA_DIR, 'dkim/' + config.fqdn());
var dkimPrivateKeyFile = path.join(dkimPath, 'private');
@@ -809,8 +754,7 @@ function doUpdate(boxUpdateInfo, callback) {
webServerOrigin: config.webServerOrigin()
},
version: boxUpdateInfo.version,
boxVersionsUrl: config.get('boxVersionsUrl')
version: boxUpdateInfo.version
};
debug('updating box %s %j', boxUpdateInfo.sourceTarballUrl, data);
@@ -860,23 +804,40 @@ function checkDiskSpace(callback) {
debug('Checking disk space');
df(function (error, entries) {
getDisks(function (error, disks) {
if (error) {
debug('df error %s', error.message);
mailer.outOfDiskSpace(error.message);
return callback();
}
var oos = entries.some(function (entry) {
return (entry.mount === paths.DATA_DIR && entry.capacity >= 0.90) ||
(entry.mount === '/' && entry.available <= (1.25 * 1024 * 1024)); // 1.5G
df().then(function (entries) {
/*
[{
filesystem: '/dev/disk1',
size: 499046809600,
used: 443222245376,
available: 55562420224,
capacity: 0.89,
mountpoint: '/'
}, ...]
*/
var oos = entries.some(function (entry) {
// ignore other filesystems but where box, app and platform data is
if (entry.filesystem !== disks.boxDataDisk && entry.filesystem !== disks.platformDataDisk && entry.filesystem !== disks.appsDataDisk) return false;
return (entry.available <= (1.25 * 1024 * 1024 * 1024)); // 1.5G
});
debug('Disk space checked. ok: %s', !oos);
if (oos) mailer.outOfDiskSpace(JSON.stringify(entries, null, 4));
callback();
}).catch(function (error) {
debug('df error %s', error.message);
mailer.outOfDiskSpace(error.message);
return callback();
});
debug('Disk space checked. ok: %s', !oos);
if (oos) mailer.outOfDiskSpace(JSON.stringify(entries, null, 4));
callback();
});
}
@@ -955,6 +916,7 @@ function migrate(options, callback) {
});
}
// called for dynamic dns setups where we have to update the IP
function refreshDNS(callback) {
callback = callback || NOOP_CALLBACK;
@@ -972,6 +934,9 @@ function refreshDNS(callback) {
if (error) return callback(error);
async.each(result, function (app, callback) {
// do not change state of installing apps since apptask will error if dns record already exists
if (app.installationState !== appdb.ISTATE_INSTALLED) return callback();
subdomains.upsert(app.location, 'A', [ ip ], callback);
}, function (error) {
if (error) return callback(error);
-6
View File
@@ -32,7 +32,6 @@ exports = module.exports = {
appFqdn: appFqdn,
zoneName: zoneName,
isDev: isDev,
isDemo: isDemo,
tlsCert: tlsCert,
@@ -77,7 +76,6 @@ function initConfig() {
data.fqdn = 'localhost';
data.token = null;
data.boxVersionsUrl = null;
data.version = null;
data.isCustomDomain = true;
data.webServerOrigin = null;
@@ -204,10 +202,6 @@ function database() {
return get('database');
}
function isDev() {
return /dev/i.test(get('boxVersionsUrl'));
}
function isDemo() {
return get('isDemo') === true;
}
+6 -6
View File
@@ -6,6 +6,7 @@ exports = module.exports = {
};
var apps = require('./apps.js'),
appstore = require('./appstore.js'),
assert = require('assert'),
backups = require('./backups.js'),
certificates = require('./certificates.js'),
@@ -65,7 +66,7 @@ function initialize(callback) {
var randomHourMinute = Math.floor(60*Math.random());
gAliveJob = new CronJob({
cronTime: '00 ' + randomHourMinute + ' * * * *', // every hour on a random minute
onTick: cloudron.sendAliveStatus,
onTick: appstore.sendAliveStatus,
start: true
});
@@ -105,13 +106,12 @@ function recreateJobs(tz) {
timeZone: tz
});
// randomized pattern per cloudron every 10 min
var randomMinute = Math.floor(10*Math.random());
var random10MinPattern = [0,1,2,3,4,5].map(function (n) { return n*10+randomMinute; }).join(',');
// randomized pattern per cloudron every hour
var randomMinute = Math.floor(60*Math.random());
if (gBoxUpdateCheckerJob) gBoxUpdateCheckerJob.stop();
gBoxUpdateCheckerJob = new CronJob({
cronTime: '00 ' + random10MinPattern + ' * * * *', // every 10 minutes
cronTime: '00 ' + randomMinute + ' * * * *', // once an hour
onTick: updateChecker.checkBoxUpdates,
start: true,
timeZone: tz
@@ -119,7 +119,7 @@ function recreateJobs(tz) {
if (gAppUpdateCheckerJob) gAppUpdateCheckerJob.stop();
gAppUpdateCheckerJob = new CronJob({
cronTime: '00 ' + random10MinPattern + ' * * * *', // every 10 minutes
cronTime: '00 ' + randomMinute + ' * * * *', // once an hour
onTick: updateChecker.checkAppUpdates,
start: true,
timeZone: tz
+13 -9
View File
@@ -18,6 +18,10 @@ var assert = require('assert'),
var DIGITALOCEAN_ENDPOINT = 'https://api.digitalocean.com';
function formatError(response) {
return util.format('DigitalOcean DNS error [%s] %j', response.statusCode, response.body);
}
function getInternal(dnsConfig, zoneName, subdomain, type, callback) {
assert.strictEqual(typeof dnsConfig, 'object');
assert.strictEqual(typeof zoneName, 'string');
@@ -30,9 +34,9 @@ function getInternal(dnsConfig, zoneName, subdomain, type, callback) {
.timeout(30 * 1000)
.end(function (error, result) {
if (error && !error.response) return callback(error);
if (result.statusCode === 404) return callback(new SubdomainError(SubdomainError.NOT_FOUND, util.format('%s %j', result.statusCode, result.body)));
if (result.statusCode === 403 || result.statusCode === 401) return callback(new SubdomainError(SubdomainError.ACCESS_DENIED, util.format('%s %j', result.statusCode, result.body)));
if (result.statusCode !== 200) return callback(new SubdomainError(SubdomainError.EXTERNAL_ERROR, util.format('%s %j', result.statusCode, result.body)));
if (result.statusCode === 404) return callback(new SubdomainError(SubdomainError.NOT_FOUND, formatError(result)));
if (result.statusCode === 403 || result.statusCode === 401) return callback(new SubdomainError(SubdomainError.ACCESS_DENIED, formatError(result)));
if (result.statusCode !== 200) return callback(new SubdomainError(SubdomainError.EXTERNAL_ERROR, formatError(result)));
var tmp = result.body.domain_records.filter(function (record) {
return (record.type === type && record.name === subdomain);
@@ -84,9 +88,9 @@ function upsert(dnsConfig, zoneName, subdomain, type, values, callback) {
.timeout(30 * 1000)
.end(function (error, result) {
if (error && !error.response) return callback(error);
if (result.statusCode === 403 || result.statusCode === 401) return callback(new SubdomainError(SubdomainError.ACCESS_DENIED, util.format('%s %j', result.statusCode, result.body)));
if (result.statusCode === 403 || result.statusCode === 401) return callback(new SubdomainError(SubdomainError.ACCESS_DENIED, formatError(result)));
if (result.statusCode === 422) return callback(new SubdomainError(SubdomainError.BAD_FIELD, result.body.message));
if (result.statusCode !== 201) return callback(new SubdomainError(SubdomainError.EXTERNAL_ERROR, util.format('%s %j', result.statusCode, result.body)));
if (result.statusCode !== 201) return callback(new SubdomainError(SubdomainError.EXTERNAL_ERROR, formatError(result)));
return callback(null);
});
@@ -100,9 +104,9 @@ function upsert(dnsConfig, zoneName, subdomain, type, values, callback) {
++i;
if (error && !error.response) return callback(error);
if (result.statusCode === 403 || result.statusCode === 401) return callback(new SubdomainError(SubdomainError.ACCESS_DENIED, util.format('%s %j', result.statusCode, result.body)));
if (result.statusCode === 403 || result.statusCode === 401) return callback(new SubdomainError(SubdomainError.ACCESS_DENIED, formatError(result)));
if (result.statusCode === 422) return callback(new SubdomainError(SubdomainError.BAD_FIELD, result.body.message));
if (result.statusCode !== 200) return callback(new SubdomainError(SubdomainError.EXTERNAL_ERROR, util.format('%s %j', result.statusCode, result.body)));
if (result.statusCode !== 200) return callback(new SubdomainError(SubdomainError.EXTERNAL_ERROR, formatError(result)));
return callback(null);
});
@@ -165,8 +169,8 @@ function del(dnsConfig, zoneName, subdomain, type, values, callback) {
.end(function (error, result) {
if (error && !error.response) return callback(error);
if (result.statusCode === 404) return callback(null);
if (result.statusCode === 403 || result.statusCode === 401) return callback(new SubdomainError(SubdomainError.ACCESS_DENIED, util.format('%s %j', result.statusCode, result.body)));
if (result.statusCode !== 204) return callback(new SubdomainError(SubdomainError.EXTERNAL_ERROR, util.format('%s %j', result.statusCode, result.body)));
if (result.statusCode === 403 || result.statusCode === 401) return callback(new SubdomainError(SubdomainError.ACCESS_DENIED, formatError(result)));
if (result.statusCode !== 204) return callback(new SubdomainError(SubdomainError.EXTERNAL_ERROR, formatError(result)));
debug('del: done');
+5 -5
View File
@@ -5,20 +5,20 @@
// Do not require anything here!
exports = module.exports = {
// a version bump means that all app containers are recreated
'version': 46,
// a major version makes all apps restore from backup
// a minor version makes all apps re-configure themselves
'version': '48.1.0',
'baseImages': [ 'cloudron/base:0.10.0' ],
// 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
'images': {
'mysql': { repo: 'cloudron/mysql', tag: 'cloudron/mysql:0.14.0' },
'mysql': { repo: 'cloudron/mysql', tag: 'cloudron/mysql:0.16.0' },
'postgresql': { repo: 'cloudron/postgresql', tag: 'cloudron/postgresql:0.16.0' },
'mongodb': { repo: 'cloudron/mongodb', tag: 'cloudron/mongodb:0.12.0' },
'redis': { repo: 'cloudron/redis', tag: 'cloudron/redis:0.11.0' },
'mail': { repo: 'cloudron/mail', tag: 'cloudron/mail:0.30.3' },
'mail': { repo: 'cloudron/mail', tag: 'cloudron/mail:0.31.0' },
'graphite': { repo: 'cloudron/graphite', tag: 'cloudron/graphite:0.11.0' }
}
};
+24 -12
View File
@@ -6,6 +6,7 @@ exports = module.exports = {
};
var assert = require('assert'),
appdb = require('./appdb.js'),
apps = require('./apps.js'),
async = require('async'),
config = require('./config.js'),
@@ -318,18 +319,28 @@ function authenticateMailbox(req, res, next) {
if (error) return next(new ldap.OperationsError(error.message));
if (mailbox.ownerType === mailboxdb.TYPE_APP) {
if (req.credentials !== mailbox.ownerId) return next(new ldap.NoSuchObjectError(req.dn.toString()));
eventlog.add(eventlog.ACTION_APP_LOGIN, { authType: 'ldap', mailboxId: name }, { appId: mailbox.ownerId });
return res.end();
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'));
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()));
eventlog.add(eventlog.ACTION_APP_LOGIN, { authType: 'ldap', mailboxId: name }, { appId: mailbox.ownerId, addonId: addonId });
return res.end();
});
} else if (mailbox.ownerType === mailboxdb.TYPE_USER) {
authenticateUser(req, res, function (error) {
if (error) return next(error);
eventlog.add(eventlog.ACTION_USER_LOGIN, { authType: 'ldap', mailboxId: name }, { userId: req.user.username });
res.end();
});
} else {
return next(new ldap.OperationsError('Unknown ownerType for mailbox'));
}
assert.strictEqual(mailbox.ownerType, mailboxdb.TYPE_USER);
authenticateUser(req, res, function (error) {
if (error) return next(error);
eventlog.add(eventlog.ACTION_USER_LOGIN, { authType: 'ldap', mailboxId: name }, { userId: req.user.username });
res.end();
});
});
}
@@ -356,7 +367,8 @@ function start(callback) {
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);
// 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
@@ -6,7 +6,6 @@ exports = module.exports = {
listAliases: listAliases,
listMailboxes: listMailboxes,
// listGroups: listGroups, // this is beyond my SQL skillz
getMailbox: getMailbox,
getGroup: getGroup,
+2
View File
@@ -95,6 +95,8 @@ function mailConfig() {
// keep this in sync with the cloudron.js dns changes
function checkDns() {
if (process.env.BOX_ENV === 'test') return;
subdomains.waitForDns(config.fqdn(), new RegExp('^v=spf1 .*a:' + config.adminFqdn().replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&') + '.*'), 'TXT', { interval: 60000, times: Infinity }, function (error) {
if (error) return debug(error); // can never happen
-48
View File
@@ -1,48 +0,0 @@
<% include header %>
<!-- tester -->
<script>
'use strict';
// very basic angular app
var app = angular.module('Application', []);
app.controller('Controller', ['$scope', function ($scope) {
$scope.success = <%= success %>;
$scope.error = '<%= error %>';
}]);
</script>
<div class="container" ng-app="Application" ng-controller="Controller" ng-cloak>
<div class="row">
<div class="col-md-12 text-center">
<br/>
<h4 ng-hide="success">Hello there, welcome to <%= cloudronName %>.</h4>
<h2 ng-hide="success">Sign up with your email address.</h2>
<h3 ng-show="success">You have received an email invitation to this Cloudron to finish the signup.</h3>
<br/><br/>
</div>
</div>
<div class="row">
<div class="col-md-6 col-md-offset-3" ng-show="!success">
<form action="/api/v1/session/account/create" method="post" name="createForm" autocomplete="off" role="form" novalidate>
<input type="password" style="display: none;">
<input type="hidden" name="_csrf" value="<%= csrf %>"/>
<div class="form-group" ng-class="{ 'has-error': (createForm.email.$dirty && createForm.email.$invalid) || (!createForm.email.$dirty && error) }">
<label class="control-label" for="inputEmail">Email</label>
<input type="email" class="form-control" id="inputEmail" ng-model="email" name="email" autofocus required>
<div class="control-label" ng-show="(createForm.email.$dirty && createForm.email.$invalid) || (!createForm.email.$dirty && error)">
<small ng-show="createForm.email.$dirty && createForm.email.$invalid">Must be a valid email address</small>
<small ng-show="!createForm.email.$dirty && error">{{ error }}</small>
</div>
</div>
<input class="btn btn-primary btn-outline pull-right" type="submit" value="Create" ng-disabled="createForm.$invalid"/>
</form>
</div>
</div>
</div>
<% include footer %>
+4 -4
View File
@@ -32,19 +32,19 @@ app.controller('Controller', ['$scope', function ($scope) {
<center><p class="has-error"><%= error %></p></center>
<% if (user && user.username) { %>
<div class="form-group">
<div class="form-group"">
<label class="control-label">Username</label>
<input type="text" class="form-control" ng-model="username" name="username" readonly required>
</div>
<% } else { %>
<div class="form-group" ng-class="{ 'has-error': (setupForm.username.$dirty && setupForm.username.$invalid) }">
<label class="control-label">Username</label>
<input type="text" class="form-control" ng-model="username" name="username" required autofocus>
<div class="control-label" ng-show="setupForm.username.$dirty && setupForm.username.$invalid">
<small ng-show="setupForm.username.$error.minlength">The username is too short</small>
<small ng-show="setupForm.username.$error.maxlength">The username is too long</small>
<small ng-show="setupForm.username.$dirty && setupForm.username.$invalid">Not a valid username</small>
</div>
<input type="text" class="form-control" ng-model="username" name="username" required autofocus>
</div>
<% } %>
@@ -55,18 +55,18 @@ app.controller('Controller', ['$scope', function ($scope) {
<div class="form-group" ng-class="{ 'has-error': (setupForm.password.$dirty && setupForm.password.$invalid) }">
<label class="control-label">New Password</label>
<input type="password" class="form-control" ng-model="password" name="password" ng-pattern="/^(?=.*\d)(?=.*[a-z])(?=.*[A-Z])(?=.*[^a-zA-Z0-9])(?!.*\s).{8,30}$/" required>
<div class="control-label" ng-show="setupForm.password.$dirty && setupForm.password.$invalid">
<small ng-show="setupForm.password.$dirty && setupForm.password.$invalid">Password must be 8-30 character with at least one uppercase, one numeric and one special character</small>
</div>
<input type="password" class="form-control" ng-model="password" name="password" ng-pattern="/^(?=.*\d)(?=.*[a-z])(?=.*[A-Z])(?=.*[^a-zA-Z0-9])(?!.*\s).{8,30}$/" required>
</div>
<div class="form-group" ng-class="{ 'has-error': (setupForm.passwordRepeat.$dirty && (password !== passwordRepeat)) }">
<label class="control-label">Repeat Password</label>
<input type="password" class="form-control" ng-model="passwordRepeat" name="passwordRepeat" required>
<div class="control-label" ng-show="setupForm.passwordRepeat.$dirty && (password !== passwordRepeat)">
<small ng-show="setupForm.passwordRepeat.$dirty && (password !== passwordRepeat)">Passwords don't match</small>
</div>
<input type="password" class="form-control" ng-model="passwordRepeat" name="passwordRepeat" required>
</div>
<input class="btn btn-primary btn-outline pull-right" type="submit" value="Create" ng-disabled="setupForm.$invalid || password !== passwordRepeat"/>
+1
View File
@@ -3,6 +3,7 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="user-scalable=no, initial-scale=1, maximum-scale=1, minimum-scale=1, width=device-width, height=device-height" />
<meta http-equiv="Content-Security-Policy" content="default-src 'unsafe-inline' 'unsafe-eval' 'self'; img-src 'self'" />
<title> <%= title %> </title>
+2 -2
View File
@@ -26,17 +26,17 @@ app.controller('Controller', [function () {}]);
<div class="form-group" ng-class="{ 'has-error': resetForm.password.$dirty && resetForm.password.$invalid }">
<label class="control-label" for="inputPassword">New Password</label>
<input type="password" class="form-control" id="inputPassword" ng-model="password" name="password" ng-pattern="/^(?=.*\d)(?=.*[a-z])(?=.*[A-Z])(?=.*[^a-zA-Z0-9])(?!.*\s).{8,30}$/" autofocus required>
<div class="control-label" ng-show="resetForm.password.$dirty && resetForm.password.$invalid">
<small ng-show="resetForm.password.$dirty && resetForm.password.$invalid">Password must be 8-30 character with at least one uppercase, one numeric and one special character</small>
</div>
<input type="password" class="form-control" id="inputPassword" ng-model="password" name="password" ng-pattern="/^(?=.*\d)(?=.*[a-z])(?=.*[A-Z])(?=.*[^a-zA-Z0-9])(?!.*\s).{8,30}$/" autofocus required>
</div>
<div class="form-group" ng-class="{ 'has-error': resetForm.passwordRepeat.$dirty && (password !== passwordRepeat) }">
<label class="control-label" for="inputPasswordRepeat">Repeat Password</label>
<input type="password" class="form-control" id="inputPasswordRepeat" ng-model="passwordRepeat" name="passwordRepeat" required>
<div class="control-label" ng-show="resetForm.passwordRepeat.$dirty && (password !== passwordRepeat)">
<small ng-show="resetForm.passwordRepeat.$dirty && (password !== passwordRepeat)">Passwords don't match</small>
</div>
<input type="password" class="form-control" id="inputPasswordRepeat" ng-model="passwordRepeat" name="passwordRepeat" required>
</div>
<input class="btn btn-primary btn-outline pull-right" type="submit" value="Create" ng-disabled="resetForm.$invalid || password !== passwordRepeat"/>
</form>
+11 -9
View File
@@ -6,18 +6,20 @@ var config = require('./config.js'),
// keep these values in sync with start.sh
exports = module.exports = {
CLOUDRON_DEFAULT_AVATAR_FILE: path.join(__dirname + '/../assets/avatar.png'),
INFRA_VERSION_FILE: path.join(config.baseDir(), 'data/INFRA_VERSION'),
INFRA_VERSION_FILE: path.join(config.baseDir(), 'platformdata/INFRA_VERSION'),
DATA_DIR: path.join(config.baseDir(), 'data'),
OLD_DATA_DIR: path.join(config.baseDir(), 'data'),
PLATFORM_DATA_DIR: path.join(config.baseDir(), 'platformdata'),
APPS_DATA_DIR: path.join(config.baseDir(), 'appsdata'),
BOX_DATA_DIR: path.join(config.baseDir(), 'boxdata'),
ACME_CHALLENGES_DIR: path.join(config.baseDir(), 'data/acme'),
ADDON_CONFIG_DIR: path.join(config.baseDir(), 'data/addons'),
COLLECTD_APPCONFIG_DIR: path.join(config.baseDir(), 'data/collectd/collectd.conf.d'),
MAIL_DATA_DIR: path.join(config.baseDir(), 'data/mail'),
NGINX_CONFIG_DIR: path.join(config.baseDir(), 'data/nginx'),
NGINX_APPCONFIG_DIR: path.join(config.baseDir(), 'data/nginx/applications'),
NGINX_CERT_DIR: path.join(config.baseDir(), 'data/nginx/cert'),
ACME_CHALLENGES_DIR: path.join(config.baseDir(), 'platformdata/acme'),
ADDON_CONFIG_DIR: path.join(config.baseDir(), 'platformdata/addons'),
COLLECTD_APPCONFIG_DIR: path.join(config.baseDir(), 'platformdata/collectd/collectd.conf.d'),
MAIL_DATA_DIR: path.join(config.baseDir(), 'platformdata/mail'),
NGINX_CONFIG_DIR: path.join(config.baseDir(), 'platformdata/nginx'),
NGINX_APPCONFIG_DIR: path.join(config.baseDir(), 'platformdata/nginx/applications'),
NGINX_CERT_DIR: path.join(config.baseDir(), 'platformdata/nginx/cert'),
// this is not part of appdata because an icon may be set before install
ACME_ACCOUNT_KEY_FILE: path.join(config.baseDir(), 'boxdata/acme/acme.key'),
+22 -13
View File
@@ -25,6 +25,7 @@ var apps = require('./apps.js'),
os = require('os'),
paths = require('./paths.js'),
safe = require('safetydance'),
semver = require('semver'),
settings = require('./settings.js'),
shell = require('./shell.js'),
subdomains = require('./subdomains.js'),
@@ -125,7 +126,8 @@ function removeOldImages(callback) {
function stopContainers(existingInfra, callback) {
// TODO: be nice and stop addons cleanly (example, shutdown commands)
if (existingInfra.version !== infra.version) { // infra upgrade
// always stop addons to restart them on any infra change, regardless of minor or major update
if (existingInfra.version !== infra.version) {
debug('stopping all containers for infra upgrade');
shell.execSync('stopContainers', 'docker ps -qa | xargs --no-run-if-empty docker rm -f');
} else {
@@ -146,7 +148,7 @@ function stopContainers(existingInfra, callback) {
function startGraphite(callback) {
const tag = infra.images.graphite.tag;
const dataDir = paths.DATA_DIR;
const dataDir = paths.PLATFORM_DATA_DIR;
const cmd = `docker run --restart=always -d --name="graphite" \
--net cloudron \
@@ -166,11 +168,11 @@ function startGraphite(callback) {
function startMysql(callback) {
const tag = infra.images.mysql.tag;
const dataDir = paths.DATA_DIR;
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.DATA_DIR + '/addons/mysql_vars.sh',
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));
}
@@ -191,11 +193,11 @@ function startMysql(callback) {
function startPostgresql(callback) {
const tag = infra.images.postgresql.tag;
const dataDir = paths.DATA_DIR;
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.DATA_DIR + '/addons/postgresql_vars.sh', 'POSTGRESQL_ROOT_PASSWORD=' + rootPassword, 'utf8')) {
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));
}
@@ -215,11 +217,11 @@ function startPostgresql(callback) {
function startMongodb(callback) {
const tag = infra.images.mongodb.tag;
const dataDir = paths.DATA_DIR;
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.DATA_DIR + '/addons/mongodb_vars.sh', 'MONGODB_ROOT_PASSWORD=' + rootPassword, 'utf8')) {
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));
}
@@ -248,7 +250,7 @@ function createMailConfig(callback) {
var alertsTo = config.provider() === 'caas' ? [ 'support@cloudron.io' ] : [ ];
alertsTo.concat(error ? [] : owner.email).join(',');
if (!safe.fs.writeFileSync(paths.DATA_DIR + '/addons/mail/mail.ini',
if (!safe.fs.writeFileSync(paths.ADDON_CONFIG_DIR + '/mail/mail.ini',
`mail_domain=${fqdn}\nmail_server_name=${mailFqdn}\nalerts_from=${alertsFrom}\nalerts_to=${alertsTo}`, 'utf8')) {
return callback(new Error('Could not create mail var file:' + safe.error.message));
}
@@ -264,15 +266,15 @@ function startMail(callback) {
// mail container uses /app/data for backed up data and /run for restart-able data
const tag = infra.images.mail.tag;
const dataDir = paths.DATA_DIR;
const dataDir = paths.PLATFORM_DATA_DIR;
const memoryLimit = Math.max((1 + Math.round(os.totalmem()/(1024*1024*1024)/4)) * 128, 256);
// admin and mail share the same certificate
certificates.getAdminCertificate(function (error, cert, key) {
if (error) return callback(error);
if (!safe.fs.writeFileSync(paths.DATA_DIR + '/addons/mail/tls_cert.pem', cert)) return callback(new Error('Could not create cert file:' + safe.error.message));
if (!safe.fs.writeFileSync(paths.DATA_DIR + '/addons/mail/tls_key.pem', key)) return callback(new Error('Could not create key file:' + safe.error.message));
if (!safe.fs.writeFileSync(paths.ADDON_CONFIG_DIR + '/mail/tls_cert.pem', cert)) return callback(new Error('Could not create cert file:' + safe.error.message));
if (!safe.fs.writeFileSync(paths.ADDON_CONFIG_DIR + '/mail/tls_key.pem', key)) return callback(new Error('Could not create key file:' + safe.error.message));
settings.getMailConfig(function (error, mailConfig) {
if (error) return callback(error);
@@ -289,6 +291,7 @@ function startMail(callback) {
--net-alias mail \
-m ${memoryLimit}m \
--memory-swap ${memoryLimit * 2}m \
--env ENABLE_MDA=${mailConfig.enabled} \
-v "${dataDir}/mail:/app/data" \
-v "${dataDir}/addons/mail:/etc/mail" \
${ports} \
@@ -316,6 +319,7 @@ function startMail(callback) {
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, startMail);
@@ -335,10 +339,15 @@ function startAddons(existingInfra, 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') {
} else if (existingInfra.version === 'none' || !semver.valid(existingInfra.version) || semver.major(existingInfra.version) !== semver.major(infra.version)) {
debug('startApps: restoring installed apps');
apps.restoreInstalledApps(callback);
} else {
+2
View File
@@ -113,6 +113,8 @@ function installApp(req, res, next) {
if (('portBindings' in data) && typeof data.portBindings !== 'object') return next(new HttpError(400, 'portBindings must be an object'));
if ('icon' in data && typeof data.icon !== 'string') return next(new HttpError(400, 'icon is not a string'));
if (data.backupId && typeof data.backupId !== 'string') return next(new HttpError(400, 'backupId must be string or null'));
// falsy values in cert and key unset the cert
if (data.key && typeof data.cert !== 'string') return next(new HttpError(400, 'cert must be a string'));
if (data.cert && typeof data.key !== 'string') return next(new HttpError(400, 'key must be a string'));
+20 -3
View File
@@ -4,11 +4,13 @@ exports = module.exports = {
activate: activate,
dnsSetup: dnsSetup,
setupTokenAuth: setupTokenAuth,
providerTokenAuth: providerTokenAuth,
getStatus: getStatus,
reboot: reboot,
migrate: migrate,
getProgress: getProgress,
getConfig: getConfig,
getDisks: getDisks,
update: update,
feedback: feedback,
checkForUpdates: checkForUpdates
@@ -102,14 +104,22 @@ function setupTokenAuth(req, res, next) {
next();
});
} else if (config.provider() === 'ami') {
if (typeof req.query.setupToken !== 'string' || !req.query.setupToken) return next(new HttpError(400, 'setupToken must be a non empty string'));
} else {
next();
}
}
function providerTokenAuth(req, res, next) {
assert.strictEqual(typeof req.body, 'object');
if (config.provider() === 'ami') {
if (typeof req.body.providerToken !== 'string' || !req.body.providerToken) return next(new HttpError(400, 'providerToken must be a non empty string'));
superagent.get('http://169.254.169.254/latest/meta-data/instance-id').timeout(30 * 1000).end(function (error, result) {
if (error && !error.response) return next(new HttpError(500, error));
if (result.statusCode !== 200) return next(new HttpError(500, 'Unable to get meta data'));
if (result.text !== req.query.setupToken) return next(new HttpError(403, 'Invalid token'));
if (result.text !== req.body.providerToken) return next(new HttpError(403, 'Invalid providerToken'));
next();
});
@@ -170,6 +180,13 @@ function getConfig(req, res, next) {
});
}
function getDisks(req, res, next) {
cloudron.getDisks(function (error, result) {
if (error) return next(new HttpError(500, error));
next(new HttpSuccess(200, result));
});
}
function update(req, res, next) {
// this only initiates the update, progress can be checked via the progress route
cloudron.updateToLatest(auditSource(req), function (error) {
-84
View File
@@ -11,7 +11,6 @@ var appdb = require('../appdb'),
DatabaseError = require('../databaseerror'),
debug = require('debug')('box:routes/oauth2'),
eventlog = require('../eventlog.js'),
generatePassword = require('../password.js').generate,
hat = require('hat'),
HttpError = require('connect-lastmile').HttpError,
middleware = require('../middleware/index.js'),
@@ -358,87 +357,6 @@ function accountSetup(req, res, next) {
});
}
// -> POST /api/v1/session/account/setup
function accountSetup(req, res, next) {
assert.strictEqual(typeof req.body, 'object');
if (typeof req.body.resetToken !== 'string') return next(new HttpError(400, 'Missing resetToken'));
if (typeof req.body.password !== 'string') return next(new HttpError(400, 'Missing password'));
if (typeof req.body.username !== 'string') return next(new HttpError(400, 'Missing username'));
if (typeof req.body.displayName !== 'string') return next(new HttpError(400, 'Missing displayName'));
debug('accountSetup: with token %s.', req.body.resetToken);
user.getByResetToken(req.body.resetToken, function (error, userObject) {
if (error) return sendError(req, res, 'Invalid Reset Token');
var data = _.pick(req.body, 'username', 'displayName');
user.update(userObject.id, data, auditSource(req), function (error) {
if (error && error.reason === UserError.ALREADY_EXISTS) return renderAccountSetupSite(res, req, userObject, 'Username already exists');
if (error && error.reason === UserError.BAD_FIELD) return renderAccountSetupSite(res, req, userObject, error.message);
if (error && error.reason === UserError.NOT_FOUND) return renderAccountSetupSite(res, req, userObject, 'No such user');
if (error) return next(new HttpError(500, error));
userObject.username = req.body.username;
userObject.displayName = req.body.displayName;
// setPassword clears the resetToken
user.setPassword(userObject.id, req.body.password, function (error, result) {
if (error && error.reason === UserError.BAD_FIELD) return renderAccountSetupSite(res, req, userObject, error.message);
if (error) return next(new HttpError(500, error));
res.redirect(util.format('%s?accessToken=%s&expiresAt=%s', config.adminOrigin(), result.token, result.expiresAt));
});
});
});
}
function renderAccountCreateSite(res, req, error, success) {
renderTemplate(res, 'account_create', {
error: error,
success: !!success,
csrf: req.csrfToken(),
title: 'Account Create'
});
}
// -> GET /api/v1/session/account/create.html
function accountCreateSite(req, res, next) {
settings.getOpenRegistration(function (error, enabled) {
if (error) return next(new HttpError(500, error));
if (!enabled) return sendError(req, res, 'User creation is not allowed on this Cloudron');
renderAccountCreateSite(res, req, '', '');
});
}
// -> POST /api/v1/session/account/create
function accountCreate(req, res, next) {
assert.strictEqual(typeof req.body, 'object');
if (typeof req.body.email !== 'string') return next(new HttpError(400, 'Missing email'));
debug('accountCreate: with email %s.', req.body.email);
settings.getOpenRegistration(function (error, enabled) {
if (error) return next(new HttpError(500, error));
if (!enabled) return sendError(req, res, 'User signup is not allowed on this Cloudron');
var ip = req.headers['x-forwarded-for'] || req.connection.remoteAddress || null;
var auditSource = { ip: ip, username: req.body.email, userId: null };
user.create(null, generatePassword(), req.body.email, '', auditSource, { sendInvite: true }, function (error, result) {
if (error && error.reason === UserError.ALREADY_EXISTS) return renderAccountCreateSite(res, req, 'User with this email address already exists');
if (error) return sendError(req, res, 'Internal Error');
debug('accountCreate: success for email %s now with id %s', req.body.remail, result.id);
renderAccountCreateSite(res, req, '', true);
});
});
}
// -> GET /api/v1/session/password/reset.html
function passwordResetSite(req, res, next) {
if (!req.query.reset_token) return next(new HttpError(400, 'Missing reset_token'));
@@ -637,8 +555,6 @@ exports = module.exports = {
passwordReset: passwordReset,
accountSetupSite: accountSetupSite,
accountSetup: accountSetup,
accountCreateSite: accountCreateSite,
accountCreate: accountCreate,
authorization: authorization,
token: token,
validateRequestedScopes: validateRequestedScopes,
-24
View File
@@ -27,9 +27,6 @@ exports = module.exports = {
getAppstoreConfig: getAppstoreConfig,
setAppstoreConfig: setAppstoreConfig,
getOpenRegistration: getOpenRegistration,
setOpenRegistration: setOpenRegistration,
setFallbackCertificate: setFallbackCertificate,
setAdminCertificate: setAdminCertificate
};
@@ -237,27 +234,6 @@ function setAppstoreConfig(req, res, next) {
});
}
function getOpenRegistration(req, res, next) {
settings.getOpenRegistration(function (error, enabled) {
if (error) return next(new HttpError(500, error));
next(new HttpSuccess(200, { enabled: enabled }));
});
}
function setOpenRegistration(req, res, next) {
assert.strictEqual(typeof req.body, 'object');
if (typeof req.body.enabled !== 'boolean') return next(new HttpError(400, 'enabled is required'));
settings.setOpenRegistration(req.body.enabled, function (error) {
if (error && error.reason === SettingsError.BAD_FIELD) return next(new HttpError(400, error.message));
if (error) return next(new HttpError(500, error));
next(new HttpSuccess(200));
});
}
// default fallback cert
function setFallbackCertificate(req, res, next) {
assert.strictEqual(typeof req.body, 'object');
+9 -6
View File
@@ -41,7 +41,7 @@ var SERVER_URL = 'http://localhost:' + config.get('port');
// Test image information
var TEST_IMAGE_REPO = 'cloudron/test';
var TEST_IMAGE_TAG = '20.0.0';
var TEST_IMAGE_TAG = '22.0.0';
var TEST_IMAGE = TEST_IMAGE_REPO + ':' + TEST_IMAGE_TAG;
// var TEST_IMAGE_ID = child_process.execSync('docker inspect --format={{.Id}} ' + TEST_IMAGE).toString('utf8').trim();
@@ -747,7 +747,7 @@ describe('App installation', function () {
});
it('installation - volume created', function (done) {
expect(fs.existsSync(paths.DATA_DIR + '/' + APP_ID));
expect(fs.existsSync(paths.APPS_DATA_DIR + '/' + APP_ID));
done();
});
@@ -758,7 +758,10 @@ describe('App installation', function () {
superagent.get('http://localhost:' + appEntry.httpPort + appResult.manifest.healthCheckPath)
.end(function (err, res) {
if (err || res.statusCode !== 200) {
if (--tryCount === 0) return done(new Error('Timedout'));
if (--tryCount === 0) {
console.log('Unable to curl http://localhost:' + appEntry.httpPort + appResult.manifest.healthCheckPath);
return done(new Error('Timedout'));
}
return setTimeout(healthCheck, 2000);
}
@@ -784,9 +787,9 @@ describe('App installation', function () {
// support newer docker versions
if (data.Volumes) {
expect(data.Volumes['/app/data']).to.eql(paths.DATA_DIR + '/' + APP_ID + '/data');
expect(data.Volumes['/app/data']).to.eql(paths.APPS_DATA_DIR + '/' + APP_ID + '/data');
} else {
expect(data.Mounts.filter(function (mount) { return mount.Destination === '/app/data'; })[0].Source).to.eql(paths.DATA_DIR + '/' + APP_ID + '/data');
expect(data.Mounts.filter(function (mount) { return mount.Destination === '/app/data'; })[0].Source).to.eql(paths.APPS_DATA_DIR + '/' + APP_ID + '/data');
}
done();
@@ -1140,7 +1143,7 @@ describe('App installation', function () {
});
it('uninstalled - volume destroyed', function (done) {
expect(!fs.existsSync(paths.DATA_DIR + '/' + APP_ID));
expect(!fs.existsSync(paths.APPS_DATA_DIR + '/' + APP_ID));
done();
});
+3
View File
@@ -22,7 +22,10 @@ var token = null;
var server;
function setup(done) {
nock.cleanAll();
config._reset();
config.setVersion('1.2.3');
config.set('fqdn', 'localhost');
async.series([
server.start.bind(server),
+100 -137
View File
@@ -533,6 +533,8 @@ describe('Settings API', function () {
var dnsAnswerQueue = [];
var dkimDomain, spfDomain, mxDomain, dmarcDomain;
this.timeout(10000);
before(function (done) {
var dns = require('native-dns');
@@ -564,7 +566,7 @@ describe('Settings API', function () {
});
it('does not fail when dns errors', function (done) {
superagent.get(SERVER_URL + '/api/v1/settings/email_dns_records')
superagent.get(SERVER_URL + '/api/v1/settings/email_status')
.query({ access_token: token })
.end(function (err, res) {
expect(res.statusCode).to.equal(200);
@@ -583,42 +585,42 @@ describe('Settings API', function () {
it('succeeds with dns errors', function (done) {
clearDnsAnswerQueue();
superagent.get(SERVER_URL + '/api/v1/settings/email_dns_records')
superagent.get(SERVER_URL + '/api/v1/settings/email_status')
.query({ access_token: token })
.end(function (err, res) {
expect(res.statusCode).to.equal(200);
expect(res.body.dkim).to.be.an('object');
expect(res.body.dkim.domain).to.eql(dkimDomain);
expect(res.body.dkim.type).to.eql('TXT');
expect(res.body.dkim.value).to.eql(null);
expect(res.body.dkim.expected).to.eql('v=DKIM1; t=s; p=' + cloudron.readDkimPublicKeySync());
expect(res.body.dkim.status).to.eql(false);
expect(res.body.dns.dkim).to.be.an('object');
expect(res.body.dns.dkim.domain).to.eql(dkimDomain);
expect(res.body.dns.dkim.type).to.eql('TXT');
expect(res.body.dns.dkim.value).to.eql(null);
expect(res.body.dns.dkim.expected).to.eql('v=DKIM1; t=s; p=' + cloudron.readDkimPublicKeySync());
expect(res.body.dns.dkim.status).to.eql(false);
expect(res.body.spf).to.be.an('object');
expect(res.body.spf.domain).to.eql(spfDomain);
expect(res.body.spf.type).to.eql('TXT');
expect(res.body.spf.value).to.eql(null);
expect(res.body.spf.expected).to.eql('v=spf1 a:' + config.adminFqdn() + ' ~all');
expect(res.body.spf.status).to.eql(false);
expect(res.body.dns.spf).to.be.an('object');
expect(res.body.dns.spf.domain).to.eql(spfDomain);
expect(res.body.dns.spf.type).to.eql('TXT');
expect(res.body.dns.spf.value).to.eql(null);
expect(res.body.dns.spf.expected).to.eql('v=spf1 a:' + config.adminFqdn() + ' ~all');
expect(res.body.dns.spf.status).to.eql(false);
expect(res.body.dmarc).to.be.an('object');
expect(res.body.dmarc.type).to.eql('TXT');
expect(res.body.dmarc.value).to.eql(null);
expect(res.body.dmarc.expected).to.eql('v=DMARC1; p=reject; pct=100');
expect(res.body.dmarc.status).to.eql(false);
expect(res.body.dns.dmarc).to.be.an('object');
expect(res.body.dns.dmarc.type).to.eql('TXT');
expect(res.body.dns.dmarc.value).to.eql(null);
expect(res.body.dns.dmarc.expected).to.eql('v=DMARC1; p=reject; pct=100');
expect(res.body.dns.dmarc.status).to.eql(false);
expect(res.body.mx).to.be.an('object');
expect(res.body.mx.type).to.eql('MX');
expect(res.body.mx.value).to.eql(null);
expect(res.body.mx.expected).to.eql('10 ' + config.mailFqdn());
expect(res.body.mx.status).to.eql(false);
expect(res.body.dns.mx).to.be.an('object');
expect(res.body.dns.mx.type).to.eql('MX');
expect(res.body.dns.mx.value).to.eql(null);
expect(res.body.dns.mx.expected).to.eql('10 ' + config.mailFqdn());
expect(res.body.dns.mx.status).to.eql(false);
expect(res.body.ptr).to.be.an('object');
expect(res.body.ptr.type).to.eql('PTR');
expect(res.body.dns.ptr).to.be.an('object');
expect(res.body.dns.ptr.type).to.eql('PTR');
// expect(res.body.ptr.value).to.eql(null); this will be anything random
expect(res.body.ptr.expected).to.eql(config.mailFqdn());
expect(res.body.ptr.status).to.eql(false);
expect(res.body.dns.ptr.expected).to.eql(config.mailFqdn());
expect(res.body.dns.ptr.status).to.eql(false);
done();
});
@@ -632,34 +634,34 @@ describe('Settings API', function () {
dnsAnswerQueue[mxDomain].MX = null;
dnsAnswerQueue[dmarcDomain].TXT = null;
superagent.get(SERVER_URL + '/api/v1/settings/email_dns_records')
superagent.get(SERVER_URL + '/api/v1/settings/email_status')
.query({ access_token: token })
.end(function (err, res) {
expect(res.statusCode).to.equal(200);
expect(res.body.spf).to.be.an('object');
expect(res.body.spf.expected).to.eql('v=spf1 a:' + config.adminFqdn() + ' ~all');
expect(res.body.spf.status).to.eql(false);
expect(res.body.spf.value).to.eql(null);
expect(res.body.dns.spf).to.be.an('object');
expect(res.body.dns.spf.expected).to.eql('v=spf1 a:' + config.adminFqdn() + ' ~all');
expect(res.body.dns.spf.status).to.eql(false);
expect(res.body.dns.spf.value).to.eql(null);
expect(res.body.dkim).to.be.an('object');
expect(res.body.dkim.expected).to.eql('v=DKIM1; t=s; p=' + cloudron.readDkimPublicKeySync());
expect(res.body.dkim.status).to.eql(false);
expect(res.body.dkim.value).to.eql(null);
expect(res.body.dns.dkim).to.be.an('object');
expect(res.body.dns.dkim.expected).to.eql('v=DKIM1; t=s; p=' + cloudron.readDkimPublicKeySync());
expect(res.body.dns.dkim.status).to.eql(false);
expect(res.body.dns.dkim.value).to.eql(null);
expect(res.body.dmarc).to.be.an('object');
expect(res.body.dmarc.expected).to.eql('v=DMARC1; p=reject; pct=100');
expect(res.body.dmarc.status).to.eql(false);
expect(res.body.dmarc.value).to.eql(null);
expect(res.body.dns.dmarc).to.be.an('object');
expect(res.body.dns.dmarc.expected).to.eql('v=DMARC1; p=reject; pct=100');
expect(res.body.dns.dmarc.status).to.eql(false);
expect(res.body.dns.dmarc.value).to.eql(null);
expect(res.body.mx).to.be.an('object');
expect(res.body.mx.status).to.eql(false);
expect(res.body.mx.expected).to.eql('10 ' + config.mailFqdn());
expect(res.body.mx.value).to.eql(null);
expect(res.body.dns.mx).to.be.an('object');
expect(res.body.dns.mx.status).to.eql(false);
expect(res.body.dns.mx.expected).to.eql('10 ' + config.mailFqdn());
expect(res.body.dns.mx.value).to.eql(null);
expect(res.body.ptr).to.be.an('object');
expect(res.body.ptr.expected).to.eql(config.mailFqdn());
expect(res.body.ptr.status).to.eql(false);
expect(res.body.dns.ptr).to.be.an('object');
expect(res.body.dns.ptr.expected).to.eql(config.mailFqdn());
expect(res.body.dns.ptr.status).to.eql(false);
// expect(res.body.ptr.value).to.eql(null); this will be anything random
done();
@@ -674,36 +676,38 @@ describe('Settings API', function () {
dnsAnswerQueue[dkimDomain].TXT = [['v=DKIM2; t=s; p=' + cloudron.readDkimPublicKeySync()]];
dnsAnswerQueue[spfDomain].TXT = [['v=spf1 a:random.com ~all']];
superagent.get(SERVER_URL + '/api/v1/settings/email_dns_records')
superagent.get(SERVER_URL + '/api/v1/settings/email_status')
.query({ access_token: token })
.end(function (err, res) {
expect(res.statusCode).to.equal(200);
expect(res.body.spf).to.be.an('object');
expect(res.body.spf.expected).to.eql('v=spf1 a:' + config.adminFqdn() + ' a:random.com ~all');
expect(res.body.spf.status).to.eql(false);
expect(res.body.spf.value).to.eql('v=spf1 a:random.com ~all');
expect(res.body.dns.spf).to.be.an('object');
expect(res.body.dns.spf.expected).to.eql('v=spf1 a:' + config.adminFqdn() + ' a:random.com ~all');
expect(res.body.dns.spf.status).to.eql(false);
expect(res.body.dns.spf.value).to.eql('v=spf1 a:random.com ~all');
expect(res.body.dkim).to.be.an('object');
expect(res.body.dkim.expected).to.eql('v=DKIM1; t=s; p=' + cloudron.readDkimPublicKeySync());
expect(res.body.dkim.status).to.eql(false);
expect(res.body.dkim.value).to.eql('v=DKIM2; t=s; p=' + cloudron.readDkimPublicKeySync());
expect(res.body.dns.dkim).to.be.an('object');
expect(res.body.dns.dkim.expected).to.eql('v=DKIM1; t=s; p=' + cloudron.readDkimPublicKeySync());
expect(res.body.dns.dkim.status).to.eql(false);
expect(res.body.dns.dkim.value).to.eql('v=DKIM2; t=s; p=' + cloudron.readDkimPublicKeySync());
expect(res.body.dmarc).to.be.an('object');
expect(res.body.dmarc.expected).to.eql('v=DMARC1; p=reject; pct=100');
expect(res.body.dmarc.status).to.eql(false);
expect(res.body.dmarc.value).to.eql('v=DMARC2; p=reject; pct=100');
expect(res.body.dns.dmarc).to.be.an('object');
expect(res.body.dns.dmarc.expected).to.eql('v=DMARC1; p=reject; pct=100');
expect(res.body.dns.dmarc.status).to.eql(false);
expect(res.body.dns.dmarc.value).to.eql('v=DMARC2; p=reject; pct=100');
expect(res.body.mx).to.be.an('object');
expect(res.body.mx.status).to.eql(false);
expect(res.body.mx.expected).to.eql('10 ' + config.mailFqdn());
expect(res.body.mx.value).to.eql('20 ' + config.mailFqdn() + ' 30 ' + config.mailFqdn());
expect(res.body.dns.mx).to.be.an('object');
expect(res.body.dns.mx.status).to.eql(false);
expect(res.body.dns.mx.expected).to.eql('10 ' + config.mailFqdn());
expect(res.body.dns.mx.value).to.eql('20 ' + config.mailFqdn() + ' 30 ' + config.mailFqdn());
expect(res.body.ptr).to.be.an('object');
expect(res.body.ptr.expected).to.eql(config.mailFqdn());
expect(res.body.ptr.status).to.eql(false);
expect(res.body.dns.ptr).to.be.an('object');
expect(res.body.dns.ptr.expected).to.eql(config.mailFqdn());
expect(res.body.dns.ptr.status).to.eql(false);
// expect(res.body.ptr.value).to.eql(null); this will be anything random
expect(res.body.outboundPort25).to.be.an('object');
done();
});
});
@@ -713,17 +717,17 @@ describe('Settings API', function () {
dnsAnswerQueue[spfDomain].TXT = [['v=spf1 a:example.com a:' + config.mailFqdn() + ' ~all']];
superagent.get(SERVER_URL + '/api/v1/settings/email_dns_records')
superagent.get(SERVER_URL + '/api/v1/settings/email_status')
.query({ access_token: token })
.end(function (err, res) {
expect(res.statusCode).to.equal(200);
expect(res.body.spf).to.be.an('object');
expect(res.body.spf.domain).to.eql(spfDomain);
expect(res.body.spf.type).to.eql('TXT');
expect(res.body.spf.value).to.eql('v=spf1 a:example.com a:' + config.mailFqdn() + ' ~all');
expect(res.body.spf.expected).to.eql('v=spf1 a:example.com a:' + config.mailFqdn() + ' ~all');
expect(res.body.spf.status).to.eql(true);
expect(res.body.dns.spf).to.be.an('object');
expect(res.body.dns.spf.domain).to.eql(spfDomain);
expect(res.body.dns.spf.type).to.eql('TXT');
expect(res.body.dns.spf.value).to.eql('v=spf1 a:example.com a:' + config.mailFqdn() + ' ~all');
expect(res.body.dns.spf.expected).to.eql('v=spf1 a:example.com a:' + config.mailFqdn() + ' ~all');
expect(res.body.dns.spf.status).to.eql(true);
done();
});
@@ -737,78 +741,37 @@ describe('Settings API', function () {
dnsAnswerQueue[dkimDomain].TXT = [['v=DKIM1;', 't=s;', 'p=' + cloudron.readDkimPublicKeySync()]];
dnsAnswerQueue[spfDomain].TXT = [['v=spf1', ' a:' + config.adminFqdn(), ' ~all']];
superagent.get(SERVER_URL + '/api/v1/settings/email_dns_records')
superagent.get(SERVER_URL + '/api/v1/settings/email_status')
.query({ access_token: token })
.end(function (err, res) {
expect(res.statusCode).to.equal(200);
expect(res.body.dkim).to.be.an('object');
expect(res.body.dkim.domain).to.eql(dkimDomain);
expect(res.body.dkim.type).to.eql('TXT');
expect(res.body.dkim.value).to.eql('v=DKIM1; t=s; p=' + cloudron.readDkimPublicKeySync());
expect(res.body.dkim.expected).to.eql('v=DKIM1; t=s; p=' + cloudron.readDkimPublicKeySync());
expect(res.body.dkim.status).to.eql(true);
expect(res.body.dns.dkim).to.be.an('object');
expect(res.body.dns.dkim.domain).to.eql(dkimDomain);
expect(res.body.dns.dkim.type).to.eql('TXT');
expect(res.body.dns.dkim.value).to.eql('v=DKIM1; t=s; p=' + cloudron.readDkimPublicKeySync());
expect(res.body.dns.dkim.expected).to.eql('v=DKIM1; t=s; p=' + cloudron.readDkimPublicKeySync());
expect(res.body.dns.dkim.status).to.eql(true);
expect(res.body.spf).to.be.an('object');
expect(res.body.spf.domain).to.eql(spfDomain);
expect(res.body.spf.type).to.eql('TXT');
expect(res.body.spf.value).to.eql('v=spf1 a:' + config.adminFqdn() + ' ~all');
expect(res.body.spf.expected).to.eql('v=spf1 a:' + config.adminFqdn() + ' ~all');
expect(res.body.spf.status).to.eql(true);
expect(res.body.dns.spf).to.be.an('object');
expect(res.body.dns.spf.domain).to.eql(spfDomain);
expect(res.body.dns.spf.type).to.eql('TXT');
expect(res.body.dns.spf.value).to.eql('v=spf1 a:' + config.adminFqdn() + ' ~all');
expect(res.body.dns.spf.expected).to.eql('v=spf1 a:' + config.adminFqdn() + ' ~all');
expect(res.body.dns.spf.status).to.eql(true);
expect(res.body.dmarc).to.be.an('object');
expect(res.body.dmarc.expected).to.eql('v=DMARC1; p=reject; pct=100');
expect(res.body.dmarc.status).to.eql(true);
expect(res.body.dmarc.value).to.eql('v=DMARC1; p=reject; pct=100');
expect(res.body.dns.dmarc).to.be.an('object');
expect(res.body.dns.dmarc.expected).to.eql('v=DMARC1; p=reject; pct=100');
expect(res.body.dns.dmarc.status).to.eql(true);
expect(res.body.dns.dmarc.value).to.eql('v=DMARC1; p=reject; pct=100');
expect(res.body.mx).to.be.an('object');
expect(res.body.mx.status).to.eql(true);
expect(res.body.mx.expected).to.eql('10 ' + config.mailFqdn());
expect(res.body.mx.value).to.eql('10 ' + config.mailFqdn());
expect(res.body.dns.mx).to.be.an('object');
expect(res.body.dns.mx.status).to.eql(true);
expect(res.body.dns.mx.expected).to.eql('10 ' + config.mailFqdn());
expect(res.body.dns.mx.value).to.eql('10 ' + config.mailFqdn());
done();
});
});
});
describe('open_registration', function () {
it('get open_registration succeeds without being set', function (done) {
superagent.get(SERVER_URL + '/api/v1/settings/open_registration')
.query({ access_token: token })
.end(function (err, res) {
expect(res.statusCode).to.equal(200);
expect(res.body.enabled).to.equal(false);
done();
});
});
it('cannot set without data', function (done) {
superagent.post(SERVER_URL + '/api/v1/settings/open_registration')
.query({ access_token: token })
.end(function (err, res) {
expect(res.statusCode).to.equal(400);
done();
});
});
it('set succeeds', function (done) {
superagent.post(SERVER_URL + '/api/v1/settings/open_registration')
.query({ access_token: token })
.send({ enabled: true })
.end(function (err, res) {
expect(res.statusCode).to.equal(200);
done();
});
});
it('get succeeds', function (done) {
superagent.get(SERVER_URL + '/api/v1/settings/open_registration')
.query({ access_token: token })
.end(function (err, res) {
expect(res.statusCode).to.equal(200);
expect(res.body.enabled).to.equal(true);
done();
});
});
});
});
+1 -1
View File
@@ -43,7 +43,7 @@ function sync(callback) {
debug('sync: checking apps %j', allAppIds);
async.eachSeries(allApps, function (app, iteratorDone) {
var appState = gState[app.id] || null;
var schedulerConfig = app.manifest.addons.scheduler || null;
var schedulerConfig = app.manifest.addons ? app.manifest.addons.scheduler : null;
if (!appState && !schedulerConfig) return iteratorDone(); // nothing changed
+7 -18
View File
@@ -12,7 +12,7 @@ if [[ $# == 1 && "$1" == "--check" ]]; then
exit 0
fi
readonly DATA_DIR="${HOME}/data"
readonly APPS_DATA_DIR="${HOME}/appsdata"
# verify argument count
if [[ "$1" == "s3" && $# -lt 9 ]]; then
@@ -50,11 +50,8 @@ elif [[ "$1" == "filesystem" ]]; then
fi
# perform backup
readonly now=$(date "+%Y-%m-%d-%H%M%S")
readonly app_data_dir="${DATA_DIR}/${app_id}"
readonly app_data_snapshot="${DATA_DIR}/snapshots/${app_id}-${now}"
btrfs subvolume snapshot -r "${app_data_dir}" "${app_data_snapshot}"
readonly app_data_dir="${APPS_DATA_DIR}/${app_id}"
readonly tar_bin="/home/yellowtent/box/helper/tarjs"
# will be checked at the end
try=0
@@ -73,24 +70,18 @@ if [[ "$1" == "s3" ]]; then
# use aws instead of curl because curl will always read entire stream memory to set Content-Length
# aws will do multipart upload
if cat "${app_data_snapshot}/config.json" \
if cat "${app_data_dir}/config.json" \
| aws ${optional_args} s3 cp - "${s3_config_url}" 2>"${error_log}"; then
break
fi
cat "${error_log}" && rm "${error_log}"
done
if [[ ${try} -eq 5 ]]; then
echo "Backup failed uploading config.json"
btrfs subvolume delete "${app_data_snapshot}"
exit 3
fi
for try in `seq 1 5`; do
echo "Uploading backup to ${s3_data_url} (try ${try})"
error_log=$(mktemp)
if tar -czf - -C "${app_data_snapshot}" . \
if ${tar_bin} "${app_data_dir}" . \
| openssl aes-256-cbc -e -pass "pass:${password}" \
| aws ${optional_args} s3 cp - "${s3_data_url}" 2>"${error_log}"; then
break
@@ -101,14 +92,12 @@ elif [[ "$1" == "filesystem" ]]; then
mkdir -p $(dirname "${backup_folder}/${backup_config_fileName}")
echo "Storing backup config to ${backup_folder}/${backup_config_fileName}"
cat "${app_data_snapshot}/config.json" > "${backup_folder}/${backup_config_fileName}"
cat "${app_data_dir}/config.json" > "${backup_folder}/${backup_config_fileName}"
echo "Storing backup data to ${backup_folder}/${backup_data_fileName}"
tar -czf - -C "${app_data_snapshot}" . | openssl aes-256-cbc -e -pass "pass:${password}" > "${backup_folder}/${backup_data_fileName}"
${tar_bin} "${app_data_dir}" . | openssl aes-256-cbc -e -pass "pass:${password}" > "${backup_folder}/${backup_data_fileName}"
fi
btrfs subvolume delete "${app_data_snapshot}"
if [[ ${try} -eq 5 ]]; then
echo "Backup failed uploading backup tarball"
exit 3
+2 -11
View File
@@ -45,16 +45,10 @@ fi
# perform backup
BOX_DATA_DIR="${HOME}/boxdata"
MAIL_DATA_DIR="${HOME}/data/mail"
mail_snapshot_dir="${HOME}/data/snapshots/mail"
echo "Creating MySQL dump"
mysqldump -u root -ppassword --single-transaction --routines --triggers box > "${BOX_DATA_DIR}/box.mysqldump"
echo "Snapshotting mail"
btrfs subvolume delete "${mail_snapshot_dir}" &> /dev/null || true
btrfs subvolume snapshot -r "${MAIL_DATA_DIR}" "${mail_snapshot_dir}"
# will be checked at the end
try=0
@@ -71,7 +65,7 @@ if [[ "$1" == "s3" ]]; then
# use aws instead of curl because curl will always read entire stream memory to set Content-Length
# aws will do multipart upload
if tar -czf - -C "${HOME}" --transform="s,^boxdata/\?,box/," --transform="s,^data/mail/\?,mail/," --show-transformed-names boxdata data/mail \
if tar -czf - -C "${HOME}" --transform="s,^boxdata/\?,box/," --transform="s,^platformdata/mail/\?,mail/," --show-transformed-names boxdata platformdata/mail \
| openssl aes-256-cbc -e -pass "pass:${password}" \
| aws ${optional_args} s3 cp - "${s3_url}" 2>"${error_log}"; then
break
@@ -83,13 +77,10 @@ elif [[ "$1" == "filesystem" ]]; then
mkdir -p $(dirname "${backup_folder}/${backup_fileName}")
tar -czf - -C "${HOME}" --transform="s,^boxdata/\?,box/," --transform="s,^data/mail/\?,mail/," --show-transformed-names boxdata data/mail \
tar -czf - -C "${HOME}" --transform="s,^boxdata/\?,box/," --transform="s,^platformdata/mail/\?,mail/," --show-transformed-names boxdata platformdata/mail \
| openssl aes-256-cbc -e -pass "pass:${password}" > "${backup_folder}/${backup_fileName}"
fi
echo "Deleting backup snapshot"
btrfs subvolume delete "${mail_snapshot_dir}"
if [[ ${try} -eq 5 ]]; then
echo "Backup failed"
exit 3
+2 -8
View File
@@ -18,20 +18,14 @@ if [[ "$1" == "--check" ]]; then
fi
if [[ "${BOX_ENV}" == "cloudron" ]]; then
readonly app_data_dir="${HOME}/data/$1"
# Only create subvolume if it does not exist
if [[ ! -d "${app_data_dir}" ]]; then
btrfs subvolume create "${app_data_dir}"
fi
readonly app_data_dir="${HOME}/appsdata/$1"
mkdir -p "${app_data_dir}/data"
# only the top level ownership is changed because containers own the subdirectores
# and will chown them as necessary
chown yellowtent:yellowtent "${app_data_dir}"
else
readonly app_data_dir="${HOME}/.cloudron_test/data/$1"
readonly app_data_dir="${HOME}/.cloudron_test/appsdata/$1"
mkdir -p "${app_data_dir}/data"
chown ${SUDO_USER}:${SUDO_USER} "${app_data_dir}"
fi
+3 -4
View File
@@ -17,7 +17,7 @@ if [ $# -lt 3 ]; then
exit 1
fi
readonly DATA_DIR="${HOME}/data"
readonly APPS_DATA_DIR="${HOME}/appsdata"
readonly curl="curl --fail --connect-timeout 20 --retry 10 --retry-delay 2 --max-time 2400"
app_id="$1"
@@ -33,8 +33,8 @@ for try in `seq 1 5`; do
if $curl -L "${restore_url}" \
| openssl aes-256-cbc -d -pass "pass:${restore_key}" \
| tar -zxf - -C "${DATA_DIR}/${app_id}" 2>"${error_log}"; then
chown -R yellowtent:yellowtent "${DATA_DIR}/${app_id}"
| tar -zxf - -C "${APPS_DATA_DIR}/${app_id}" 2>"${error_log}"; then
chown -R yellowtent:yellowtent "${APPS_DATA_DIR}/${app_id}"
break
fi
cat "${error_log}" && rm "${error_log}"
@@ -46,4 +46,3 @@ if [[ ${try} -eq 5 ]]; then
else
echo "restore successful"
fi
+3 -7
View File
@@ -18,13 +18,9 @@ if [[ "$1" == "--check" ]]; then
fi
if [[ "${BOX_ENV}" == "cloudron" ]]; then
readonly app_data_dir="${HOME}/data/$1"
if [[ -d "${app_data_dir}" ]]; then
find "${app_data_dir}" -mindepth 1 -delete
rm -rf "${app_data_dir}" || btrfs subvolume delete "${app_data_dir}"
fi
readonly app_data_dir="${HOME}/appsdata/$1"
rm -rf "${app_data_dir}"
else
readonly app_data_dir="${HOME}/.cloudron_test/data/$1"
readonly app_data_dir="${HOME}/.cloudron_test/appsdata/$1"
rm -rf "${app_data_dir}"
fi
+22 -15
View File
@@ -14,11 +14,11 @@ var assert = require('assert'),
database = require('./database.js'),
eventlog = require('./eventlog.js'),
express = require('express'),
hat = require('hat'),
http = require('http'),
middleware = require('./middleware'),
passport = require('passport'),
path = require('path'),
RateLimit = require('express-rate-limit'),
routes = require('./routes/index.js');
var gHttpServer = null;
@@ -44,25 +44,34 @@ function initializeExpressSync() {
// for rate limiting
app.enable('trust proxy');
var limiter = new RateLimit({
windowMs: 60*1000, // 1 minute
max: 200, // limit each IP to 200 requests per windowMs
delayMs: 0 // disable delaying - full speed until the max limit is reached
});
if (process.env.BOX_ENV !== 'test') app.use(middleware.morgan('Box :method :url :status :response-time ms - :res[content-length]', { immediate: false }));
if (process.env.BOX_ENV !== 'test') {
app.use(middleware.morgan('Box :method :url :status :response-time ms - :res[content-length]', {
immediate: false,
// only log failed requests by default
skip: function (req, res) { return res.statusCode < 400; }
}));
}
var router = new express.Router();
router.del = router.delete; // amend router.del for readability further on
app
.use(limiter)
.use(middleware.timeout(REQUEST_TIMEOUT))
.use(json)
.use(urlencoded)
.use(middleware.cookieParser())
.use(middleware.cors({ origins: [ '*' ], allowCredentials: false }))
.use(middleware.session({ secret: 'yellow is blue', resave: true, saveUninitialized: true, cookie: { path: '/', httpOnly: true, secure: false, maxAge: 600000 } }))
.use(middleware.session({
secret: hat(128), // we only use the session during oauth, and already have an in-memory session store, so we can safely change that during restarts
resave: true,
saveUninitialized: true,
cookie: {
path: '/',
httpOnly: true,
secure: process.env.BOX_ENV !== 'test',
maxAge: 600000
}
}))
.use(passport.initialize())
.use(passport.session())
.use(router)
@@ -87,7 +96,7 @@ function initializeExpressSync() {
// public routes
router.post('/api/v1/cloudron/activate', routes.cloudron.setupTokenAuth, routes.cloudron.activate);
router.post('/api/v1/cloudron/dns_setup', routes.cloudron.dnsSetup); // only available until no-domain
router.post('/api/v1/cloudron/dns_setup', routes.cloudron.providerTokenAuth, routes.cloudron.dnsSetup); // only available until no-domain
router.get ('/api/v1/cloudron/progress', routes.cloudron.getProgress);
router.get ('/api/v1/cloudron/status', routes.cloudron.getStatus);
router.get ('/api/v1/cloudron/avatar', routes.settings.getCloudronAvatar); // this is a public alias for /api/v1/settings/cloudron_avatar
@@ -105,6 +114,7 @@ function initializeExpressSync() {
router.post('/api/v1/cloudron/reboot', cloudronScope, routes.cloudron.reboot);
router.post('/api/v1/cloudron/migrate', cloudronScope, routes.user.requireAdmin, routes.user.verifyPassword, routes.cloudron.migrate);
router.get ('/api/v1/cloudron/graphs', cloudronScope, routes.graphs.getGraphs);
router.get ('/api/v1/cloudron/disks', cloudronScope, routes.cloudron.getDisks);
router.get ('/api/v1/cloudron/ssh/authorized_keys', cloudronScope, routes.user.requireAdmin, routes.ssh.getAuthorizedKeys);
router.put ('/api/v1/cloudron/ssh/authorized_keys', cloudronScope, routes.user.requireAdmin, routes.ssh.addAuthorizedKey);
router.get ('/api/v1/cloudron/ssh/authorized_keys/:identifier', cloudronScope, routes.user.requireAdmin, routes.ssh.getAuthorizedKey);
@@ -148,8 +158,6 @@ function initializeExpressSync() {
router.post('/api/v1/session/password/reset', csrf, routes.oauth2.passwordReset);
router.get ('/api/v1/session/account/setup.html', csrf, routes.oauth2.accountSetupSite);
router.post('/api/v1/session/account/setup', csrf, routes.oauth2.accountSetup);
router.get ('/api/v1/session/account/create.html', csrf, routes.oauth2.accountCreateSite);
router.post('/api/v1/session/account/create', csrf, routes.oauth2.accountCreate);
// oauth2 routes
router.get ('/api/v1/oauth/dialog/authorize', routes.oauth2.authorization);
@@ -196,6 +204,7 @@ function initializeExpressSync() {
router.get ('/api/v1/settings/backup_config', settingsScope, routes.user.requireAdmin, routes.settings.getBackupConfig);
router.post('/api/v1/settings/backup_config', settingsScope, routes.user.requireAdmin, routes.settings.setBackupConfig);
router.post('/api/v1/settings/certificate', settingsScope, routes.user.requireAdmin, routes.settings.setFallbackCertificate);
router.post('/api/v1/settings/admin_certificate', settingsScope, routes.user.requireAdmin, routes.settings.setAdminCertificate);
router.get ('/api/v1/settings/time_zone', settingsScope, routes.user.requireAdmin, routes.settings.getTimeZone);
router.post('/api/v1/settings/time_zone', settingsScope, routes.user.requireAdmin, routes.settings.setTimeZone);
@@ -203,8 +212,6 @@ function initializeExpressSync() {
router.post('/api/v1/settings/appstore_config', settingsScope, routes.user.requireAdmin, routes.settings.setAppstoreConfig);
router.get ('/api/v1/settings/mail_config', settingsScope, routes.user.requireAdmin, routes.settings.getMailConfig);
router.post('/api/v1/settings/mail_config', settingsScope, routes.user.requireAdmin, routes.settings.setMailConfig);
router.get ('/api/v1/settings/open_registration', settingsScope, routes.user.requireAdmin, routes.settings.getOpenRegistration);
router.post('/api/v1/settings/open_registration', settingsScope, routes.user.requireAdmin, routes.settings.setOpenRegistration);
// eventlog route
router.get('/api/v1/eventlog', settingsScope, routes.user.requireAdmin, routes.eventlog.get);
-30
View File
@@ -44,9 +44,6 @@ exports = module.exports = {
getMailConfig: getMailConfig,
setMailConfig: setMailConfig,
getOpenRegistration: getOpenRegistration,
setOpenRegistration: setOpenRegistration,
getDefaultSync: getDefaultSync,
getAll: getAll,
@@ -61,7 +58,6 @@ exports = module.exports = {
UPDATE_CONFIG_KEY: 'update_config',
APPSTORE_CONFIG_KEY: 'appstore_config',
MAIL_CONFIG_KEY: 'mail_config',
OPEN_REGISTRATION_KEY: 'open_registration',
events: null
};
@@ -106,7 +102,6 @@ var gDefaults = (function () {
result[exports.UPDATE_CONFIG_KEY] = { prerelease: false };
result[exports.APPSTORE_CONFIG_KEY] = {};
result[exports.MAIL_CONFIG_KEY] = { enabled: false };
result[exports.OPEN_REGISTRATION_KEY] = false;
return result;
})();
@@ -650,31 +645,6 @@ function setMailConfig(mailConfig, callback) {
});
}
function getOpenRegistration(callback) {
assert.strictEqual(typeof callback, 'function');
settingsdb.get(exports.OPEN_REGISTRATION_KEY, function (error, value) {
if (error && error.reason === DatabaseError.NOT_FOUND) return callback(null, gDefaults[exports.OPEN_REGISTRATION_KEY]);
if (error) return callback(new SettingsError(SettingsError.INTERNAL_ERROR, error));
// settingsdb holds string values only
callback(null, !!value);
});
}
function setOpenRegistration(enabled, callback) {
assert.strictEqual(typeof enabled, 'boolean');
assert.strictEqual(typeof callback, 'function');
settingsdb.set(exports.OPEN_REGISTRATION_KEY, enabled ? 'enabled' : '', function (error) {
if (error) return callback(new SettingsError(SettingsError.INTERNAL_ERROR, error));
exports.events.emit(exports.OPEN_REGISTRATION_KEY, enabled);
return callback(null);
});
}
function getAppstoreConfig(callback) {
assert.strictEqual(typeof callback, 'function');
+8 -6
View File
@@ -13,6 +13,7 @@ exports = module.exports = {
var assert = require('assert'),
config = require('./config.js'),
debug = require('debug')('box:ssh'),
fs = require('fs'),
path = require('path'),
safe = require('safetydance'),
@@ -50,17 +51,16 @@ SshError.INTERNAL_ERROR = 'Internal Error';
function clear(callback) {
assert.strictEqual(typeof callback, 'function');
fs.unlink(AUTHORIZED_KEYS_FILEPATH, function (error) {
if (error && error.code !== 'ENOENT') return callback(error);
callback();
});
safe.fs.unlinkSync(AUTHORIZED_KEYS_FILEPATH);
callback();
}
function saveKeys(keys) {
assert(Array.isArray(keys));
if (!safe.fs.writeFileSync(AUTHORIZED_KEYS_TMP_FILEPATH, keys.map(function (k) { return k.key; }).join('\n'))) {
console.error(safe.error);
debug('Error writing to temporary file', safe.error);
return false;
}
@@ -68,7 +68,7 @@ function saveKeys(keys) {
// 600 = rw-------
fs.chmodSync(AUTHORIZED_KEYS_TMP_FILEPATH, '600');
} catch (e) {
console.error('Failed to adjust permissions of %s', AUTHORIZED_KEYS_TMP_FILEPATH, e);
debug('Failed to adjust permissions of %s %j', AUTHORIZED_KEYS_TMP_FILEPATH, e);
return false;
}
@@ -89,6 +89,8 @@ function getKeys() {
.map(function (k) { return { identifier: k.split(' ')[2], key: k }; })
.filter(function (k) { return k.identifier && k.key; });
safe.fs.unlinkSync(AUTHORIZED_KEYS_TMP_FILEPATH);
return keys;
}
+3 -4
View File
@@ -69,6 +69,7 @@ describe('apptask', function () {
before(function (done) {
config.set('version', '0.5.0');
config.set('fqdn', 'foobar.com');
config.set('provider', 'caas');
awsHostedZones = {
HostedZones: [{
@@ -130,7 +131,7 @@ describe('apptask', function () {
it('create volume', function (done) {
apptask._createVolume(APP, function (error) {
expect(fs.existsSync(paths.DATA_DIR + '/' + APP.id + '/data')).to.be(true);
expect(fs.existsSync(paths.APPS_DATA_DIR + '/' + APP.id + '/data')).to.be(true);
expect(error).to.be(null);
done();
});
@@ -138,7 +139,7 @@ describe('apptask', function () {
it('delete volume', function (done) {
apptask._deleteVolume(APP, function (error) {
expect(!fs.existsSync(paths.DATA_DIR + '/' + APP.id + '/data')).to.be(true);
expect(!fs.existsSync(paths.APPS_DATA_DIR + '/' + APP.id + '/data')).to.be(true);
expect(error).to.be(null);
done();
});
@@ -241,5 +242,3 @@ describe('apptask', function () {
});
});
});
+4 -24
View File
@@ -115,8 +115,6 @@ describe('Certificates', function () {
after(cleanup);
it('returns prod caas for prod cloudron', function (done) {
config.set('boxVersionsUrl', 'http://prod/release.json');
certificates._getApi({ }, function (error, api, options) {
expect(error).to.be(null);
expect(api._name).to.be('caas');
@@ -125,20 +123,16 @@ describe('Certificates', function () {
});
});
it('returns non-prod caas for dev cloudron', function (done) {
config.set('boxVersionsUrl', 'http://dev/release.json');
it('returns prod caas for dev cloudron', function (done) {
certificates._getApi({ }, function (error, api, options) {
expect(error).to.be(null);
expect(api._name).to.be('caas');
expect(options.prod).to.be(false);
expect(options.prod).to.be(true);
done();
});
});
it('returns prod-acme with altDomain in prod cloudron', function (done) {
config.set('boxVersionsUrl', 'http://prod/release.json');
certificates._getApi({ altDomain: 'foo.something.com' }, function (error, api, options) {
expect(error).to.be(null);
expect(api._name).to.be('acme');
@@ -147,13 +141,11 @@ describe('Certificates', function () {
});
});
it('returns non-prod acme with altDomain in dev cloudron', function (done) {
config.set('boxVersionsUrl', 'http://dev/release.json');
it('returns prod acme with altDomain in dev cloudron', function (done) {
certificates._getApi({ altDomain: 'foo.something.com' }, function (error, api, options) {
expect(error).to.be(null);
expect(api._name).to.be('acme');
expect(options.prod).to.be(false);
expect(options.prod).to.be(true);
done();
});
});
@@ -170,8 +162,6 @@ describe('Certificates', function () {
after(cleanup);
it('returns prod acme in prod cloudron', function (done) {
config.set('boxVersionsUrl', 'http://prod/release.json');
certificates._getApi({ }, function (error, api, options) {
expect(error).to.be(null);
expect(api._name).to.be('acme');
@@ -181,8 +171,6 @@ describe('Certificates', function () {
});
it('returns prod acme with altDomain in prod cloudron', function (done) {
config.set('boxVersionsUrl', 'http://prod/release.json');
certificates._getApi({ altDomain: 'foo.bar.com' }, function (error, api, options) {
expect(error).to.be(null);
expect(api._name).to.be('acme');
@@ -192,8 +180,6 @@ describe('Certificates', function () {
});
it('returns prod acme in dev cloudron', function (done) {
config.set('boxVersionsUrl', 'http://dev/release.json');
certificates._getApi({ }, function (error, api, options) {
expect(error).to.be(null);
expect(api._name).to.be('acme');
@@ -214,8 +200,6 @@ describe('Certificates', function () {
after(cleanup);
it('returns staging acme in prod cloudron', function (done) {
config.set('boxVersionsUrl', 'http://prod/release.json');
certificates._getApi({ }, function (error, api, options) {
expect(error).to.be(null);
expect(api._name).to.be('acme');
@@ -225,8 +209,6 @@ describe('Certificates', function () {
});
it('returns staging acme in dev cloudron', function (done) {
config.set('boxVersionsUrl', 'http://dev/release.json');
certificates._getApi({ }, function (error, api, options) {
expect(error).to.be(null);
expect(api._name).to.be('acme');
@@ -236,8 +218,6 @@ describe('Certificates', function () {
});
it('returns staging acme with altDomain in prod cloudron', function (done) {
config.set('boxVersionsUrl', 'http://prod/release.json');
certificates._getApi({ altDomain: 'foo.bar.com' }, function (error, api, options) {
expect(error).to.be(null);
expect(api._name).to.be('acme');
+1 -1
View File
@@ -3,7 +3,7 @@
set -eu
readonly SOURCE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
readonly TEST_IMAGE="cloudron/test:18.0.0"
readonly TEST_IMAGE="cloudron/test:22.0.0"
# reset sudo timestamp to avoid wrong success
sudo -k || sudo --reset-timestamp
+20 -5
View File
@@ -789,14 +789,14 @@ describe('database', function () {
});
it('setAddonConfig succeeds', function (done) {
appdb.setAddonConfig(APP_1.id, 'addonid1', [ 'ENV1=env', 'ENV2=env' ], function (error) {
appdb.setAddonConfig(APP_1.id, 'addonid1', [ { name: 'ENV1', value: 'env' }, { name: 'ENV2', value: 'env2' } ], function (error) {
expect(error).to.be(null);
done();
});
});
it('setAddonConfig succeeds', function (done) {
appdb.setAddonConfig(APP_1.id, 'addonid2', [ 'ENV3=env' ], function (error) {
appdb.setAddonConfig(APP_1.id, 'addonid2', [ { name: 'ENV3', value: 'env' } ], function (error) {
expect(error).to.be(null);
done();
});
@@ -805,7 +805,7 @@ describe('database', function () {
it('getAddonConfig succeeds', function (done) {
appdb.getAddonConfig(APP_1.id, 'addonid1', function (error, results) {
expect(error).to.be(null);
expect(results).to.eql([ 'ENV1=env', 'ENV2=env' ]);
expect(results).to.eql([ { name: 'ENV1', value: 'env' }, { name: 'ENV2', value: 'env2' } ]);
done();
});
});
@@ -813,7 +813,22 @@ describe('database', function () {
it('getAddonConfigByAppId succeeds', function (done) {
appdb.getAddonConfigByAppId(APP_1.id, function (error, results) {
expect(error).to.be(null);
expect(results).to.eql([ 'ENV1=env', 'ENV2=env', 'ENV3=env' ]);
expect(results).to.eql([ { name: 'ENV1', value: 'env' }, { name: 'ENV2', value: 'env2' }, { name: 'ENV3', value: 'env' } ]);
done();
});
});
it('getAddonConfigByName succeeds', function (done) {
appdb.getAddonConfigByName(APP_1.id, 'addonid1', 'ENV2', function (error, value) {
expect(error).to.be(null);
expect(value).to.be('env2');
done();
});
});
it('getAddonConfigByName of unknown value succeeds', function (done) {
appdb.getAddonConfigByName(APP_1.id, 'addonid1', 'NOPE', function (error, value) {
expect(error.reason).to.be(DatabaseError.NOT_FOUND);
done();
});
});
@@ -828,7 +843,7 @@ describe('database', function () {
it('unsetAddonConfig did remove configs', function (done) {
appdb.getAddonConfigByAppId(APP_1.id, function (error, results) {
expect(error).to.be(null);
expect(results).to.eql([ 'ENV3=env' ]);
expect(results).to.eql([ { name: 'ENV3', value: 'env' }]);
done();
});
});
+100 -4
View File
@@ -16,6 +16,7 @@ var appdb = require('../appdb.js'),
groups = require('../groups.js'),
http = require('http'),
ldapServer = require('../ldap.js'),
mailboxdb = require('../mailboxdb.js'),
settings = require('../settings.js'),
settingsdb = require('../settingsdb.js'),
ldap = require('ldapjs'),
@@ -86,6 +87,10 @@ function setup(done) {
ldapServer.start.bind(null),
appdb.add.bind(null, APP_0.id, APP_0.appStoreId, APP_0.manifest, APP_0.location, APP_0.portBindings, APP_0),
appdb.update.bind(null, APP_0.id, { containerId: APP_0.containerId }),
appdb.setAddonConfig.bind(null, APP_0.id, 'sendmail', [{ name: 'MAIL_SMTP_PASSWORD', value : 'sendmailpassword' }]),
appdb.setAddonConfig.bind(null, APP_0.id, 'recvmail', [{ name: 'MAIL_IMAP_PASSWORD', value : 'recvmailpassword' }]),
mailboxdb.add.bind(null, APP_0.location + '.app', APP_0.id, mailboxdb.TYPE_APP),
function (callback) {
user.createOwner(USER_0.username, USER_0.password, USER_0.email, USER_0.displayName, AUDIT_SOURCE, function (error, result) {
if (error) return callback(error);
@@ -734,11 +739,11 @@ describe('Ldap', function () {
});
});
describe('bind mailbox', function () {
describe('user sendmail bind', function () {
it('does not allow with invalid password', function (done) {
var client = ldap.createClient({ url: 'ldap://127.0.0.1:' + config.get('ldapPort') });
client.bind('cn=' + USER_0.username + ',ou=mailboxes,dc=cloudron', USER_0.password + 'nope', function (error) {
client.bind('cn=' + USER_0.username + ',ou=sendmail,dc=cloudron', USER_0.password + 'nope', function (error) {
expect(error).to.be.a(ldap.InvalidCredentialsError);
done();
});
@@ -747,7 +752,7 @@ describe('Ldap', function () {
it('allows with valid password', function (done) {
var client = ldap.createClient({ url: 'ldap://127.0.0.1:' + config.get('ldapPort') });
client.bind('cn=' + USER_0.username + ',ou=mailboxes,dc=cloudron', USER_0.password, function (error) {
client.bind('cn=' + USER_0.username + ',ou=sendmail,dc=cloudron', USER_0.password, function (error) {
done(error);
});
});
@@ -759,7 +764,7 @@ describe('Ldap', function () {
var client = ldap.createClient({ url: 'ldap://127.0.0.1:' + config.get('ldapPort') });
client.bind('cn=' + USER_0.username + '@' + config.fqdn() + ',ou=mailboxes,dc=cloudron', USER_0.password, function (error) {
client.bind('cn=' + USER_0.username + '@' + config.fqdn() + ',ou=sendmail,dc=cloudron', USER_0.password, function (error) {
expect(error).not.to.be.ok();
settingsdb.set(settings.MAIL_CONFIG_KEY, JSON.stringify({ enabled: false }), done);
@@ -767,4 +772,95 @@ describe('Ldap', function () {
});
});
});
describe('app sendmail bind', function () {
it('does not allow with invalid app', function (done) {
var client = ldap.createClient({ url: 'ldap://127.0.0.1:' + config.get('ldapPort') });
client.bind('cn=hacker.app,ou=sendmail,dc=cloudron', 'nope', function (error) {
expect(error).to.be.a(ldap.NoSuchObjectError);
done();
});
});
it('does not allow with invalid password', function (done) {
var client = ldap.createClient({ url: 'ldap://127.0.0.1:' + config.get('ldapPort') });
client.bind('cn=' + APP_0.location + '.app,ou=sendmail,dc=cloudron', 'nope', function (error) {
expect(error).to.be.a(ldap.InvalidCredentialsError);
done();
});
});
it('allows with valid password', function (done) {
var client = ldap.createClient({ url: 'ldap://127.0.0.1:' + config.get('ldapPort') });
client.bind('cn=' + APP_0.location + '.app,ou=sendmail,dc=cloudron', 'sendmailpassword', function (error) {
done(error);
});
});
});
describe('user recvmail bind', function () {
it('does not allow with invalid password', function (done) {
var client = ldap.createClient({ url: 'ldap://127.0.0.1:' + config.get('ldapPort') });
client.bind('cn=' + USER_0.username + ',ou=recvmail,dc=cloudron', USER_0.password + 'nope', function (error) {
expect(error).to.be.a(ldap.InvalidCredentialsError);
done();
});
});
it('allows with valid password', function (done) {
var client = ldap.createClient({ url: 'ldap://127.0.0.1:' + config.get('ldapPort') });
client.bind('cn=' + USER_0.username + ',ou=recvmail,dc=cloudron', USER_0.password, function (error) {
done(error);
});
});
it('allows with valid email', function (done) {
// user settingsdb instead of settings, to not trigger further events
settingsdb.set(settings.MAIL_CONFIG_KEY, JSON.stringify({ enabled: true }), function (error) {
expect(error).not.to.be.ok();
var client = ldap.createClient({ url: 'ldap://127.0.0.1:' + config.get('ldapPort') });
client.bind('cn=' + USER_0.username + '@' + config.fqdn() + ',ou=recvmail,dc=cloudron', USER_0.password, function (error) {
expect(error).not.to.be.ok();
settingsdb.set(settings.MAIL_CONFIG_KEY, JSON.stringify({ enabled: false }), done);
});
});
});
});
describe('app recvmail bind', function () {
it('does not allow with invalid app', function (done) {
var client = ldap.createClient({ url: 'ldap://127.0.0.1:' + config.get('ldapPort') });
client.bind('cn=hacker.app,ou=recvmail,dc=cloudron', 'nope', function (error) {
expect(error).to.be.a(ldap.NoSuchObjectError);
done();
});
});
it('does not allow with invalid password', function (done) {
var client = ldap.createClient({ url: 'ldap://127.0.0.1:' + config.get('ldapPort') });
client.bind('cn=' + APP_0.location + '.app,ou=recvmail,dc=cloudron', 'nope', function (error) {
expect(error).to.be.a(ldap.InvalidCredentialsError);
done();
});
});
it('allows with valid password', function (done) {
var client = ldap.createClient({ url: 'ldap://127.0.0.1:' + config.get('ldapPort') });
client.bind('cn=' + APP_0.location + '.app,ou=recvmail,dc=cloudron', 'recvmailpassword', function (error) {
done(error);
});
});
});
});
-25
View File
@@ -215,31 +215,6 @@ describe('Server', function () {
});
});
describe('rate limit', function () {
before(function (done) {
server.start(done);
});
after(function (done) {
server.stop(done);
nock.cleanAll();
});
it('gets throttled after 200 requests', function (done) {
async.times(200, function (n, next) {
superagent.get(SERVER_URL + '/api/v1/cloudron/status', function (error, result) {
expect(result.statusCode).to.equal(200);
next();
});
}, function () {
superagent.get(SERVER_URL + '/api/v1/cloudron/status', function (error, result) {
expect(result.statusCode).to.equal(429);
done();
});
});
});
});
describe('cors', function () {
before(function (done) {
server.start(function (error) {
+2 -23
View File
@@ -12,6 +12,8 @@ var async = require('async'),
settings = require('../settings.js');
function setup(done) {
config.set('provider', 'caas');
async.series([
database.initialize,
settings.initialize,
@@ -188,28 +190,5 @@ describe('Settings', function () {
done();
});
});
it('can get open registration default value', function (done) {
settings.getOpenRegistration(function (error, enabled) {
expect(error).to.be(null);
expect(enabled).to.equal(false);
done();
});
});
it('can set open registration', function (done) {
settings.setOpenRegistration(true, function (error) {
expect(error).to.be(null);
done();
});
});
it('can get open registration', function (done) {
settings.getOpenRegistration(function (error, enabled) {
expect(error).to.be(null);
expect(enabled).to.equal(true);
done();
});
});
});
});
+2 -2
View File
@@ -10,10 +10,10 @@ readonly source_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")"/../.. && pwd)"
rm -rf $HOME/.cloudron_test 2>/dev/null || true # some of those docker container data requires sudo to be removed
mkdir -p $HOME/.cloudron_test
cd $HOME/.cloudron_test
mkdir -p data/appdata boxdata/appicons data/mail data/addons/mail data/nginx/cert data/nginx/applications data/collectd/collectd.conf.d data/addons configs boxdata/certs data/mail/dkim/localhost data/mail/dkim/foobar.com
mkdir -p appsdata boxdata/appicons platformdata/mail platformdata/addons/mail platformdata/nginx/cert platformdata/nginx/applications platformdata/collectd/collectd.conf.d platformdata/addons configs boxdata/certs platformdata/mail/dkim/localhost platformdata/mail/dkim/foobar.com
# put cert
openssl req -x509 -newkey rsa:2048 -keyout data/nginx/cert/host.key -out data/nginx/cert/host.cert -days 3650 -subj '/CN=localhost' -nodes
openssl req -x509 -newkey rsa:2048 -keyout platformdata/nginx/cert/host.key -out platformdata/nginx/cert/host.cert -days 3650 -subj '/CN=localhost' -nodes
# create docker network (while the infra code does this, most tests skip infra setup)
docker network create --subnet=172.18.0.0/16 cloudron || true
+53 -144
View File
@@ -10,55 +10,15 @@ var appdb = require('../appdb.js'),
config = require('../config.js'),
constants = require('../constants.js'),
database = require('../database.js'),
deepExtend = require('deep-extend'),
expect = require('expect.js'),
mailer = require('../mailer.js'),
nock = require('nock'),
paths = require('../paths.js'),
safe = require('safetydance'),
settings = require('../settings.js'),
settingsdb = require('../settingsdb.js'),
updatechecker = require('../updatechecker.js'),
user = require('../user.js'),
_ = require('underscore');
var RELEASE_1 = {
"sourceTarballUrl": "https://dev-cloudron-releases.s3.amazonaws.com/box-3314658ce81f328462508e14b6d388acf36ca81c.tar.gz",
"imageId": 100,
"imageName": "box-dev-2c7a52b-2016-01-22-150657",
"changelog": [ ],
"date": "2016-01-23T23:53:01.566Z",
"author": "Girish Ramakrishnan <girish@cloudron.io>",
"next": "2.0.0-pre0"
};
var RELEASE_2_PRERELEASE = {
"sourceTarballUrl": "https://dev-cloudron-releases.s3.amazonaws.com/box-3314658ce81f328462508e14b6d388acf36ca81c.tar.gz",
"imageId": 2001,
"imageName": "box-dev-2c7a52b-2016-01-22-150657",
"changelog": [ ],
"upgrade": false,
"date": "2016-01-23T23:53:01.566Z",
"author": "Girish Ramakrishnan <girish@cloudron.io>",
"next": "2.0.0"
};
var RELEASE_2 = {
"sourceTarballUrl": "https://dev-cloudron-releases.s3.amazonaws.com/box-3314658ce81f328462508e14b6d388acf36ca81c.tar.gz",
"imageId": 200,
"imageName": "box-dev-2c7a52b-2016-01-22-150657",
"changelog": [ ],
"upgrade": false,
"date": "2016-01-23T23:53:01.566Z",
"author": "Girish Ramakrishnan <girish@cloudron.io>",
"next": null
};
var RELEASES = {
"1.0.0": RELEASE_1,
"1.0.1": RELEASE_1,
"2.0.0-pre0": RELEASE_2_PRERELEASE,
"2.0.0": RELEASE_2
};
user = require('../user.js');
// owner
var USER_0 = {
@@ -90,17 +50,21 @@ function cleanup(done) {
], done);
}
describe('updatechecker - box - manual', function () {
describe('updatechecker - box - manual (mail)', function () {
before(function (done) {
config._reset();
config.set('version', '1.0.0');
config.set('boxVersionsUrl', 'http://localhost:4444/release.json');
config.set('apiServerOrigin', 'http://localhost:4444');
config.set('provider', 'notcaas');
safe.fs.unlinkSync(paths.UPDATE_CHECKER_FILE);
async.series([
database.initialize,
database._clear,
settings.initialize,
user.createOwner.bind(null, USER_0.username, USER_0.password, USER_0.email, USER_0.displayName, AUDIT_SOURCE),
settings.setAutoupdatePattern.bind(null, constants.AUTOUPDATE_PATTERN_NEVER),
settingsdb.set.bind(null, settings.APPSTORE_CONFIG_KEY, JSON.stringify({ userId: 'uid', cloudronId: 'cid', token: 'token' })),
mailer._clearMailQueue
], done);
});
@@ -110,12 +74,10 @@ describe('updatechecker - box - manual', function () {
it('no updates', function (done) {
nock.cleanAll();
var releaseCopy = deepExtend({}, RELEASES);
releaseCopy['1.0.0'].next = null;
var scope = nock('http://localhost:4444')
.get('/release.json')
.reply(200, releaseCopy);
.get('/api/v1/users/uid/cloudrons/cid/boxupdate')
.query({ boxVersion: config.version(), accessToken: 'token' })
.reply(204, { } );
updatechecker.checkBoxUpdates(function (error) {
expect(!error).to.be.ok();
@@ -129,50 +91,28 @@ describe('updatechecker - box - manual', function () {
it('new version', function (done) {
nock.cleanAll();
var releaseCopy = deepExtend({}, RELEASES);
delete releaseCopy['2.0.0-pre0'];
releaseCopy['1.0.0'].next = '2.0.0';
var scope = nock('http://localhost:4444')
.get('/release.json')
.reply(200, releaseCopy);
.get('/api/v1/users/uid/cloudrons/cid/boxupdate')
.query({ boxVersion: config.version(), accessToken: 'token' })
.reply(200, { version: '2.0.0', changelog: [''], sourceTarballUrl: '2.0.0.tar.gz' } );
updatechecker.checkBoxUpdates(function (error) {
expect(!error).to.be.ok();
expect(updatechecker.getUpdateInfo().box.version).to.be('2.0.0');
expect(updatechecker.getUpdateInfo().box.sourceTarballUrl).to.be('2.0.0.tar.gz');
expect(scope.isDone()).to.be.ok();
checkMails(1, done);
});
});
it('existing version missing offers latest version', function (done) {
nock.cleanAll();
var releaseCopy = deepExtend({}, RELEASES);
delete releaseCopy['1.0.0'];
var scope = nock('http://localhost:4444')
.get('/release.json')
.reply(200, releaseCopy);
updatechecker.checkBoxUpdates(function (error) {
expect(!error).to.be.ok();
expect(updatechecker.getUpdateInfo().box.version).to.be('2.0.0');
expect(scope.isDone()).to.be.ok();
checkMails(0, done); // already notified for 2.0.0
});
});
it('does not offer prerelease', function (done) {
nock.cleanAll();
var releaseCopy = deepExtend({}, RELEASES);
var scope = nock('http://localhost:4444')
.get('/release.json')
.reply(200, releaseCopy);
.get('/api/v1/users/uid/cloudrons/cid/boxupdate')
.query({ boxVersion: config.version(), accessToken: 'token' })
.reply(200, { version: '2.0.0-pre.0', changelog: [''], sourceTarballUrl: '2.0.0-pre.0.tar.gz' } );
updatechecker.checkBoxUpdates(function (error) {
expect(!error).to.be.ok();
@@ -189,15 +129,14 @@ describe('updatechecker - box - manual', function () {
settings.setUpdateConfig({ prerelease: true }, function (error) {
if (error) return done(error);
var releaseCopy = deepExtend({}, RELEASES);
var scope = nock('http://localhost:4444')
.get('/release.json')
.reply(200, releaseCopy);
.get('/api/v1/users/uid/cloudrons/cid/boxupdate')
.query({ boxVersion: config.version(), accessToken: 'token' })
.reply(200, { version: '2.0.0-pre.0', changelog: [''], sourceTarballUrl: '2.0.0-pre.0.tar.gz' } );
updatechecker.checkBoxUpdates(function (error) {
expect(!error).to.be.ok();
expect(updatechecker.getUpdateInfo().box.version).to.be('2.0.0-pre0');
expect(updatechecker.getUpdateInfo().box.version).to.be('2.0.0-pre.0');
expect(scope.isDone()).to.be.ok();
checkMails(1, done);
@@ -205,31 +144,13 @@ describe('updatechecker - box - manual', function () {
});
});
it('does not send mail for patch releases', function (done) {
var releaseCopy = deepExtend({}, RELEASES);
releaseCopy['1.0.0'].next = '1.0.1';
var scope = nock('http://localhost:4444')
.get('/release.json')
.reply(200, releaseCopy);
updatechecker.checkBoxUpdates(function (error) {
expect(!error).to.be.ok();
expect(updatechecker.getUpdateInfo().box.version).to.be('1.0.1'); // got the update
expect(scope.isDone()).to.be.ok();
checkMails(0, done); // but no email sent since patch release
});
});
it('bad response offers nothing', function (done) {
nock.cleanAll();
var releaseCopy = _.extend({}, RELEASES);
var scope = nock('http://localhost:4444')
.get('/release.json')
.reply(404, releaseCopy);
.get('/api/v1/users/uid/cloudrons/cid/boxupdate')
.query({ boxVersion: config.version(), accessToken: 'token' })
.reply(404, { version: '2.0.0-pre.0', changelog: [''], sourceTarballUrl: '2.0.0-pre.0.tar.gz' } );
updatechecker.checkBoxUpdates(function (error) {
expect(error).to.be.ok();
@@ -244,12 +165,14 @@ describe('updatechecker - box - manual', function () {
describe('updatechecker - box - automatic', function () {
before(function (done) {
config.set('version', '1.0.0');
config.set('boxVersionsUrl', 'http://localhost:4444/release.json');
config.set('apiServerOrigin', 'http://localhost:4444');
config.set('provider', 'notcaas');
async.series([
database.initialize,
settings.initialize,
mailer._clearMailQueue,
user.createOwner.bind(null, USER_0.username, USER_0.password, USER_0.email, USER_0.displayName, AUDIT_SOURCE)
user.createOwner.bind(null, USER_0.username, USER_0.password, USER_0.email, USER_0.displayName, AUDIT_SOURCE),
settingsdb.set.bind(null, settings.APPSTORE_CONFIG_KEY, JSON.stringify({ userId: 'uid', cloudronId: 'cid', token: 'token' }))
], done);
});
@@ -258,13 +181,10 @@ describe('updatechecker - box - automatic', function () {
it('new version', function (done) {
nock.cleanAll();
var releaseCopy = deepExtend({}, RELEASES);
delete releaseCopy['2.0.0-pre0'];
releaseCopy['1.0.0'].next = '2.0.0';
var scope = nock('http://localhost:4444')
.get('/release.json')
.reply(200, releaseCopy);
.get('/api/v1/users/uid/cloudrons/cid/boxupdate')
.query({ boxVersion: config.version(), accessToken: 'token' })
.reply(200, { version: '2.0.0', sourceTarballUrl: '2.0.0.tar.gz' } );
updatechecker.checkBoxUpdates(function (error) {
expect(!error).to.be.ok();
@@ -276,7 +196,7 @@ describe('updatechecker - box - automatic', function () {
});
});
describe('updatechecker - app - manual', function () {
describe('updatechecker - app - manual (mails)', function () {
var APP_0 = {
id: 'appid-0',
appStoreId: 'io.cloudron.app',
@@ -304,6 +224,8 @@ describe('updatechecker - app - manual', function () {
before(function (done) {
config.set('version', '1.0.0');
config.set('apiServerOrigin', 'http://localhost:4444');
config.set('provider', 'notcaas');
async.series([
database.initialize,
database._clear,
@@ -311,7 +233,8 @@ describe('updatechecker - app - manual', function () {
mailer._clearMailQueue,
appdb.add.bind(null, APP_0.id, APP_0.appStoreId, APP_0.manifest, APP_0.location, APP_0.portBindings, APP_0),
user.createOwner.bind(null, USER_0.username, USER_0.password, USER_0.email, USER_0.displayName, AUDIT_SOURCE),
settings.setAutoupdatePattern.bind(null, constants.AUTOUPDATE_PATTERN_NEVER)
settings.setAutoupdatePattern.bind(null, constants.AUTOUPDATE_PATTERN_NEVER),
settingsdb.set.bind(null, settings.APPSTORE_CONFIG_KEY, JSON.stringify({ userId: 'uid', cloudronId: 'cid', token: 'token' }))
], done);
});
@@ -321,9 +244,9 @@ describe('updatechecker - app - manual', function () {
nock.cleanAll();
var scope = nock('http://localhost:4444')
.get('/api/v1/apps/io.cloudron.app/versions/1.0.0/update')
.query({ boxVersion: config.version() })
.reply(200, { update: null });
.get('/api/v1/users/uid/cloudrons/cid/appupdate')
.query({ boxVersion: config.version(), accessToken: 'token', appId: APP_0.appStoreId, appVersion: APP_0.manifest.version })
.reply(204, { } );
updatechecker.checkAppUpdates(function (error) {
expect(!error).to.be.ok();
@@ -338,8 +261,8 @@ describe('updatechecker - app - manual', function () {
nock.cleanAll();
var scope = nock('http://localhost:4444')
.get('/api/v1/apps/io.cloudron.app/versions/1.0.0/update')
.query({ boxVersion: config.version() })
.get('/api/v1/users/uid/cloudrons/cid/appupdate')
.query({ boxVersion: config.version(), accessToken: 'token', appId: APP_0.appStoreId, appVersion: APP_0.manifest.version })
.reply(500, { update: { manifest: { version: '1.0.0' } } } );
updatechecker.checkAppUpdates(function (error) {
@@ -355,9 +278,9 @@ describe('updatechecker - app - manual', function () {
nock.cleanAll();
var scope = nock('http://localhost:4444')
.get('/api/v1/apps/io.cloudron.app/versions/1.0.0/update')
.query({ boxVersion: config.version() })
.reply(200, { update: { manifest: { version: '2.0.0' } } } );
.get('/api/v1/users/uid/cloudrons/cid/appupdate')
.query({ boxVersion: config.version(), accessToken: 'token', appId: APP_0.appStoreId, appVersion: APP_0.manifest.version })
.reply(200, { manifest: { version: '2.0.0' } } );
updatechecker.checkAppUpdates(function (error) {
expect(!error).to.be.ok();
@@ -368,23 +291,6 @@ describe('updatechecker - app - manual', function () {
});
});
it('does not send mail for patch releases', function (done) {
nock.cleanAll();
var scope = nock('http://localhost:4444')
.get('/api/v1/apps/io.cloudron.app/versions/1.0.0/update')
.query({ boxVersion: config.version() })
.reply(200, { update: { manifest: { version: '1.0.1' } } } );
updatechecker.checkAppUpdates(function (error) {
expect(!error).to.be.ok();
expect(updatechecker.getUpdateInfo().apps).to.eql({ 'appid-0': { manifest: { version: '1.0.1' } } }); // got the update
expect(scope.isDone()).to.be.ok();
checkMails(0, done); // but no email sent since patch release
});
});
it('does not offer old version', function (done) {
nock.cleanAll();
@@ -396,7 +302,7 @@ describe('updatechecker - app - manual', function () {
});
});
describe('updatechecker - app - automatic', function () {
describe('updatechecker - app - automatic (no emails)', function () {
var APP_0 = {
id: 'appid-0',
appStoreId: 'io.cloudron.app',
@@ -424,13 +330,16 @@ describe('updatechecker - app - automatic', function () {
before(function (done) {
config.set('version', '1.0.0');
config.set('apiServerOrigin', 'http://localhost:4444');
config.set('provider', 'notcaas');
async.series([
database.initialize,
database._clear,
settings.initialize,
mailer._clearMailQueue,
appdb.add.bind(null, APP_0.id, APP_0.appStoreId, APP_0.manifest, APP_0.location, APP_0.portBindings, APP_0),
user.createOwner.bind(null, USER_0.username, USER_0.password, USER_0.email, USER_0.displayName, AUDIT_SOURCE)
user.createOwner.bind(null, USER_0.username, USER_0.password, USER_0.email, USER_0.displayName, AUDIT_SOURCE),
settingsdb.set.bind(null, settings.APPSTORE_CONFIG_KEY, JSON.stringify({ userId: 'uid', cloudronId: 'cid', token: 'token' }))
], done);
});
@@ -440,9 +349,9 @@ describe('updatechecker - app - automatic', function () {
nock.cleanAll();
var scope = nock('http://localhost:4444')
.get('/api/v1/apps/io.cloudron.app/versions/1.0.0/update')
.query({ boxVersion: config.version() })
.reply(200, { update: { manifest: { version: '2.0.0' } } } );
.get('/api/v1/users/uid/cloudrons/cid/appupdate')
.query({ boxVersion: config.version(), accessToken: 'token', appId: APP_0.appStoreId, appVersion: APP_0.manifest.version })
.reply(200, { manifest: { version: '2.0.0' } } );
updatechecker.checkAppUpdates(function (error) {
expect(!error).to.be.ok();
+11 -83
View File
@@ -10,6 +10,7 @@ exports = module.exports = {
};
var apps = require('./apps.js'),
appstore = require('./appstore.js'),
async = require('async'),
config = require('./config.js'),
constants = require('./constants.js'),
@@ -18,9 +19,7 @@ var apps = require('./apps.js'),
paths = require('./paths.js'),
safe = require('safetydance'),
semver = require('semver'),
settings = require('./settings.js'),
superagent = require('superagent'),
util = require('util');
settings = require('./settings.js');
var gAppUpdateInfo = { }, // id -> update info { creationDate, manifest }
gBoxUpdateInfo = null; // { version, changelog, upgrade, sourceTarballUrl }
@@ -57,69 +56,6 @@ function resetAppUpdateInfo(appId) {
}
}
function getAppUpdate(app, callback) {
superagent
.get(config.apiServerOrigin() + '/api/v1/apps/' + app.appStoreId + '/versions/' + app.manifest.version + '/update')
.query({ boxVersion: config.version() })
.timeout(10 * 1000)
.end(function (error, result) {
if (error && !error.response) return callback(error);
if (result.statusCode !== 200 || !('update' in result.body)) return callback(new Error(util.format('Bad response: %s %s', result.statusCode, result.text)));
callback(null, result.body.update);
});
}
function getBoxUpdates(callback) {
var currentVersion = config.version();
// do not crash if boxVersionsUrl is not set
if (!config.get('boxVersionsUrl')) return callback(null, null);
superagent
.get(config.get('boxVersionsUrl'))
.timeout(10 * 1000)
.end(function (error, result) {
if (error && !error.response) return callback(error);
if (result.statusCode !== 200) return callback(new Error(util.format('Bad status: %s %s', result.statusCode, result.text)));
var versions = safe.JSON.parse(result.text);
if (!versions || typeof versions !== 'object') return callback(new Error('versions is not in valid format:' + safe.error));
var latestVersion = Object.keys(versions).sort(semver.compare).pop();
debug('checkBoxUpdates: Latest version is %s etag:%s', latestVersion, result.header['etag']);
if (!latestVersion) return callback(new Error('No version available'));
var nextVersion = null, nextVersionInfo = null;
var currentVersionInfo = versions[currentVersion];
if (!currentVersionInfo) {
debug('Cloudron runs on unknown version %s. Offering to update to latest version', currentVersion);
nextVersion = latestVersion;
nextVersionInfo = versions[latestVersion];
} else {
nextVersion = currentVersionInfo.next;
nextVersionInfo = nextVersion ? versions[nextVersion] : null;
}
if (nextVersionInfo && typeof nextVersionInfo === 'object') {
debug('new version %s available. imageId: %d code: %s', nextVersion, nextVersionInfo.imageId, nextVersionInfo.sourceTarballUrl);
callback(null, {
version: nextVersion,
changelog: nextVersionInfo.changelog,
upgrade: nextVersionInfo.upgrade,
sourceTarballUrl: nextVersionInfo.sourceTarballUrl
});
} else {
debug('no new version available.');
callback(null, null);
}
});
}
function checkAppUpdates(callback) {
callback = callback || NOOP_CALLBACK; // null when called from a timer task
@@ -135,7 +71,7 @@ function checkAppUpdates(callback) {
async.eachSeries(apps, function (app, iteratorDone) {
if (app.appStoreId === '') return iteratorDone(); // appStoreId can be '' for dev apps
getAppUpdate(app, function (error, updateInfo) {
appstore.getAppUpdate(app, function (error, updateInfo) {
if (error) {
debug('Error getting app update info for %s', app.id, error);
return iteratorDone(); // continue to next
@@ -160,8 +96,6 @@ function checkAppUpdates(callback) {
if (oldState[app.id] === newState[app.id]) {
debug('Skipping notification of app update %s since user was already notified', app.id);
} else if (semver.satisfies(newState[app.id], '~' + app.manifest.version)) {
debug('Skipping notification of app update as this is a patch release');
} else {
// only send notifications if update pattern is 'never'
settings.getAutoupdatePattern(function (error, result) {
@@ -191,7 +125,7 @@ function checkBoxUpdates(callback) {
gBoxUpdateInfo = null;
getBoxUpdates(function (error, updateInfo) {
appstore.getBoxUpdate(function (error, updateInfo) {
if (error || !updateInfo) return callback(error);
settings.getUpdateConfig(function (error, updateConfig) {
@@ -214,23 +148,17 @@ function checkBoxUpdates(callback) {
return callback();
}
if (semver.satisfies(gBoxUpdateInfo.version, '~' + config.version())) {
debug('Skipping notification of box update as this is a patch release');
} else {
// only send notifications if update pattern is 'never'
settings.getAutoupdatePattern(function (error, result) {
if (error) return debug(error);
if (result !== constants.AUTOUPDATE_PATTERN_NEVER) return;
mailer.boxUpdateAvailable(updateInfo.version, updateInfo.changelog);
});
}
state.box = updateInfo.version;
saveState(state);
callback();
// only send notifications if update pattern is 'never'
settings.getAutoupdatePattern(function (error, result) {
if (error) debug(error);
else if (result === constants.AUTOUPDATE_PATTERN_NEVER) mailer.boxUpdateAvailable(updateInfo.version, updateInfo.changelog);
callback();
});
});
});
}
+6 -6
View File
@@ -7,13 +7,13 @@ angular.module('ngTld', [])
.directive('checkTld', checkTld);
function ngTld() {
function tldExists($path) {
function tldExists(path) {
// https://github.com/oncletom/tld.js/issues/58
return ($path.$viewValue.slice(-1) !== '.') && $path.$viewValue === tld.getDomain($path.$viewValue);
return (path.slice(-1) !== '.') && path === tld.getDomain(path);
}
function isSubdomain($path) {
return ($path.$viewValue.slice(-1) !== '.') && !!tld.getDomain($path.$viewValue) && $path.$viewValue !== tld.getDomain($path.$viewValue);
function isSubdomain(path) {
return (path.slice(-1) !== '.') && !!tld.getDomain(path) && path !== tld.getDomain(path);
}
return {
@@ -28,11 +28,11 @@ function checkTld(ngTld) {
require: 'ngModel',
link: function(scope, element, attr, ngModel) {
ngModel.$validators.invalidTld = function(modelValue, viewValue) {
return ngTld.tldExists(ngModel);
return ngTld.tldExists(ngModel.$viewValue);
};
ngModel.$validators.invalidSubdomain = function(modelValue, viewValue) {
return !ngTld.isSubdomain(ngModel);
return !ngTld.isSubdomain(ngModel.$viewValue);
};
}
};
+1
View File
@@ -3,6 +3,7 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="user-scalable=no, initial-scale=1, maximum-scale=1, minimum-scale=1, width=device-width, height=device-height" />
<meta http-equiv="Content-Security-Policy" content="default-src 'unsafe-inline' 'self' <%= apiOriginHostname %>; img-src 'self' <%= apiOriginHostname %>;" />
<title> Cloudron App Error </title>
+1
View File
@@ -3,6 +3,7 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="user-scalable=no, initial-scale=1, maximum-scale=1, minimum-scale=1, width=device-width, height=device-height" />
<meta http-equiv="Content-Security-Policy" content="default-src 'unsafe-inline' 'unsafe-eval' 'self' <%= apiOriginHostname %>; img-src 'self' <%= apiOriginHostname %>;" />
<title> Cloudron Error </title>
+1
View File
@@ -3,6 +3,7 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="user-scalable=no, initial-scale=1, maximum-scale=1, minimum-scale=1, width=device-width, height=device-height" />
<meta http-equiv="Content-Security-Policy" content="default-src 'unsafe-inline' 'unsafe-eval' 'self' *.cloudron.io <%= apiOriginHostname %>; img-src * data:;" />
<!-- this gets changed once we get the config (because angular has not loaded yet, we see template string for a flash) -->
<title> Cloudron </title>
+6 -4
View File
@@ -133,11 +133,13 @@ angular.module('Application').service('AppStore', ['$http', '$base64', 'Client',
AppStore.prototype.login = function (email, password, callback) {
if (Client.getConfig().apiServerOrigin === null) return callback(new AppStoreError(420, 'Enhance Your Calm'));
var headers = {
authorization: 'Basic ' + $base64.encode(email + ':' + password)
var data = {
email: email,
password: password,
persistent: true
};
$http.get(Client.getConfig().apiServerOrigin + '/api/v1/login?persistent', { headers: headers }).success(function (data, status) {
$http.post(Client.getConfig().apiServerOrigin + '/api/v1/login', data).success(function (data, status) {
if (status !== 200) return callback(new AppStoreError(status, data));
return callback(null, data);
}).error(function (data, status) {
@@ -148,7 +150,7 @@ angular.module('Application').service('AppStore', ['$http', '$base64', 'Client',
AppStore.prototype.logout = function (email, password, callback) {
if (Client.getConfig().apiServerOrigin === null) return callback(new AppStoreError(420, 'Enhance Your Calm'));
$http.get(Client.getConfig().apiServerOrigin + '/api/v1/logout').success(function (data, status) {
$http.post(Client.getConfig().apiServerOrigin + '/api/v1/logout').success(function (data, status) {
if (status !== 200) return callback(new AppStoreError(status, data));
return callback(null);
}).error(function (data, status) {
+35 -18
View File
@@ -110,7 +110,6 @@ angular.module('Application').service('Client', ['$http', 'md5', 'Notification',
ip: null,
revision: null,
update: { box: null, apps: null },
isDev: false,
progress: {},
isCustomDomain: false,
region: null,
@@ -432,24 +431,17 @@ angular.module('Application').service('Client', ['$http', 'md5', 'Notification',
}).error(defaultErrorHandler(callback));
};
Client.prototype.getAutoupdatePattern = function (callback) {
get('/api/v1/settings/autoupdate_pattern').success(function(data, status) {
if (status !== 200) return callback(new ClientError(status, data));
callback(null, data);
}).error(defaultErrorHandler(callback));
};
Client.prototype.setOpenRegistration = function (enabled, callback) {
post('/api/v1/settings/open_registration', { enabled: enabled }).success(function(data, status) {
Client.prototype.checkForUpdates = function (callback) {
post('/api/v1/cloudron/check_for_updates', { }).success(function(data, status) {
if (status !== 200) return callback(new ClientError(status, data));
callback(null);
}).error(defaultErrorHandler(callback));
};
Client.prototype.getOpenRegistration = function (callback) {
get('/api/v1/settings/open_registration').success(function(data, status) {
Client.prototype.getAutoupdatePattern = function (callback) {
get('/api/v1/settings/autoupdate_pattern').success(function(data, status) {
if (status !== 200) return callback(new ClientError(status, data));
callback(null, data.enabled);
callback(null, data);
}).error(defaultErrorHandler(callback));
};
@@ -549,6 +541,20 @@ angular.module('Application').service('Client', ['$http', 'md5', 'Notification',
}).error(defaultErrorHandler(callback));
};
Client.prototype.getAppBackups = function (callback) {
get('/api/v1/backups').success(function (data, status) {
if (status !== 200 || typeof data !== 'object') return callback(new ClientError(status, data));
callback(null, data.backups);
}).error(defaultErrorHandler(callback));
};
Client.prototype.getAppBackups = function (appId, callback) {
get('/api/v1/apps/' + appId + '/backups').success(function (data, status) {
if (status !== 200 || typeof data !== 'object') return callback(new ClientError(status, data));
callback(null, data.backups);
}).error(defaultErrorHandler(callback));
};
Client.prototype.getUsers = function (callback) {
get('/api/v1/users').success(function (data, status) {
if (status !== 200 || typeof data !== 'object') return callback(new ClientError(status, data));
@@ -775,6 +781,13 @@ angular.module('Application').service('Client', ['$http', 'md5', 'Notification',
}).error(defaultErrorHandler(callback));
};
Client.prototype.disks = function (callback) {
get('/api/v1/cloudron/disks').success(function (data, status) {
if (status !== 200 || typeof data !== 'object') return callback(new ClientError(status, data));
callback(null, data);
}).error(defaultErrorHandler(callback));
};
Client.prototype.graphs = function (targets, from, callback) {
var config = {
params: {
@@ -916,12 +929,18 @@ angular.module('Application').service('Client', ['$http', 'md5', 'Notification',
app.iconUrl = icons.cloudron;
app.iconUrlStore = icons.store;
// FIXME have a real message structure, not some string to randomly parse
// extract progress percentage
var installationProgress = app.installationProgress || '';
var progress = parseInt(installationProgress.split(',')[0], 10);
if (isNaN(progress)) progress = 0;
app.progress = progress;
app.message = installationProgress.replace(/.*, /,'');
// Unfortunatelly some errors are not actual progress messages, but still have a number in fron like a http status code
if (isNaN(progress) || progress > 100) {
app.progress = 0;
app.message = installationProgress;
} else {
app.progress = progress;
app.message = installationProgress.replace(/.*, /,'');
}
return app;
};
@@ -1018,8 +1037,6 @@ angular.module('Application').service('Client', ['$http', 'md5', 'Notification',
var totalMemory = roundedMemory * 1.2; // cloudron-system-setup.sh creates equal amount of swap. 1.2 factor is arbitrary
var available = (totalMemory || 0) - used;
console.log(needed, used, roundedMemory, totalMemory, available);
return (available - needed) >= 0;
};
+9 -2
View File
@@ -83,7 +83,7 @@ angular.module('Application').controller('MainController', ['$scope', '$route',
var actionScope = $scope.$new(true);
actionScope.action = '/#/settings';
Client.notify('DNS Configuration', 'Please setup all required DNS records to guarantee correct mail delivery', true, 'info', actionScope);
Client.notify('DNS Configuration', 'Please setup all required DNS records to guarantee correct mail delivery', false, 'info', actionScope);
}
});
});
@@ -92,6 +92,13 @@ angular.module('Application').controller('MainController', ['$scope', '$route',
Client.getStatus(function (error, status) {
if (error) return $scope.error(error);
// WARNING if anything about the routing is changed here test these use-cases:
//
// 1. Caas
// 2. selfhosted with --domain argument
// 3. selfhosted restore
// 4. local development with gulp develop
if (!status.activated) {
console.log('You have on domain, redirecting', status.configState.configured);
window.location.href = status.configState.configured ? '/setup.html' : '/setupdns.html';
@@ -100,7 +107,7 @@ angular.module('Application').controller('MainController', ['$scope', '$route',
// support local development with localhost check
if (window.location.hostname !== status.adminFqdn && window.location.hostname !== 'localhost') {
window.location.href = 'https://' + status.adminFqdn + '/noapp.html';
window.location.href = '/setupdns.html';
return;
}
+7 -4
View File
@@ -1,7 +1,7 @@
'use strict';
// create main application module
var app = angular.module('Application', ['angular-md5', 'ui-notification']);
var app = angular.module('Application', ['angular-md5', 'ui-notification', 'ui.bootstrap']);
app.controller('SetupController', ['$scope', '$http', 'Client', function ($scope, $http, Client) {
// Stupid angular location provider either wants html5 location mode or not, do the query parsing on my own
@@ -20,12 +20,11 @@ app.controller('SetupController', ['$scope', '$http', 'Client', function ($scope
$scope.provider = '';
$scope.apiServerOrigin = '';
$scope.setupToken = '';
$scope.instanceId = '';
$scope.activateCloudron = function () {
$scope.busy = true;
Client.createAdmin($scope.account.username, $scope.account.password, $scope.account.email, $scope.account.displayName, $scope.setupToken || $scope.instanceId, function (error) {
Client.createAdmin($scope.account.username, $scope.account.password, $scope.account.email, $scope.account.displayName, $scope.setupToken, function (error) {
if (error && error.statusCode === 403) {
$scope.busy = false;
$scope.error = $scope.provider === 'ami' ? 'Wrong instance id' : 'Wrong setup token';
@@ -82,9 +81,13 @@ app.controller('SetupController', ['$scope', '$http', 'Client', function ($scope
$scope.account.displayName = search.displayName || $scope.account.displayName;
$scope.account.requireEmail = !search.email;
$scope.provider = status.provider;
$scope.instanceId = search.instanceId;
$scope.apiServerOrigin = status.apiServerOrigin;
$scope.initialized = true;
// Ensure we have a good autofocus
setTimeout(function () {
$(document).find("[autofocus]:first").focus();
}, 250);
});
}]);
+13 -2
View File
@@ -4,11 +4,14 @@
var app = angular.module('Application', ['angular-md5', 'ui-notification', 'ngTld']);
app.controller('SetupDNSController', ['$scope', '$http', 'Client', 'ngTld', function ($scope, $http, Client, ngTld) {
var search = decodeURIComponent(window.location.search).slice(1).split('&').map(function (item) { return item.split('='); }).reduce(function (o, k) { o[k[0]] = k[1]; return o; }, {});
$scope.initialized = false;
$scope.busy = false;
$scope.error = null;
$scope.provider = '';
$scope.showDNSSetup = false;
$scope.instanceId = '';
// keep in sync with certs.js
$scope.dnsProvider = [
@@ -30,13 +33,16 @@ app.controller('SetupDNSController', ['$scope', '$http', 'Client', 'ngTld', func
$scope.setDnsCredentials = function () {
$scope.dnsCredentials.busy = true;
$scope.dnsCredentials.error = null;
$scope.error = null;
var data = {
domain: $scope.dnsCredentials.domain,
provider: $scope.dnsCredentials.provider,
accessKeyId: $scope.dnsCredentials.accessKeyId,
secretAccessKey: $scope.dnsCredentials.secretAccessKey,
token: $scope.dnsCredentials.digitalOceanToken
token: $scope.dnsCredentials.digitalOceanToken,
providerToken: $scope.instanceId
};
// special case the wildcard provider
@@ -46,7 +52,11 @@ app.controller('SetupDNSController', ['$scope', '$http', 'Client', 'ngTld', func
}
Client.setupDnsConfig(data, function (error) {
if (error) {
if (error && error.statusCode === 403) {
$scope.dnsCredentials.busy = false;
$scope.error = 'Wrong instance id provided.';
return;
} else if (error) {
$scope.dnsCredentials.busy = false;
$scope.dnsCredentials.error = error.message;
return;
@@ -84,6 +94,7 @@ app.controller('SetupDNSController', ['$scope', '$http', 'Client', 'ngTld', func
$scope.dnsCredentials.provider = 'wildcard';
}
$scope.instanceId = search.instanceId;
$scope.provider = status.provider;
$scope.initialized = true;
});
+1
View File
@@ -1,6 +1,7 @@
<html>
<head>
<title> Cloudron OAuth Callback </title>
<meta http-equiv="Content-Security-Policy" content="default-src 'unsafe-inline' 'unsafe-eval' 'self' <%= apiOriginHostname %>; img-src 'self' <%= apiOriginHostname %>" />
<script>
+1
View File
@@ -3,6 +3,7 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="user-scalable=no, initial-scale=1, maximum-scale=1, minimum-scale=1, width=device-width, height=device-height" />
<meta http-equiv="Content-Security-Policy" content="default-src 'unsafe-inline' 'unsafe-eval' 'self' <%= apiOriginHostname %>; img-src 'self' <%= apiOriginHostname %>;" />
<title> Cloudron </title>
+5 -6
View File
@@ -3,6 +3,7 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="user-scalable=no, initial-scale=1, maximum-scale=1, minimum-scale=1, width=device-width, height=device-height" />
<meta http-equiv="Content-Security-Policy" content="default-src 'unsafe-inline' 'unsafe-eval' 'self' *.cloudron.io <%= apiOriginHostname %>; img-src 'self' <%= apiOriginHostname %>;" />
<title> Cloudron Admin Setup </title>
@@ -25,6 +26,9 @@
<script src="3rdparty/js/angular-ui-notification.min.js"></script>
<script src="3rdparty/js/autofill-event.js"></script>
<!-- Angular directives for bootstrap https://angular-ui.github.io/bootstrap/ -->
<script src="3rdparty/js/ui-bootstrap-tpls-1.3.3.min.js"></script>
<!-- Setup Application -->
<script src="js/setup.js"></script>
@@ -66,7 +70,7 @@
<input type="text" class="form-control" ng-model="account.displayName" id="inputDisplayName" name="displayName" placeholder="Display Name" required autocomplete="off" autofocus>
</div>
<div ng-show="account.requireEmail" class="form-group" ng-class="{ 'has-error': setupForm.email.$dirty && setupForm.email.$invalid }">
<input type="email" class="form-control" ng-model="account.email" id="inputEmail" name="email" placeholder="Email" required autocomplete="off">
<input type="email" class="form-control" ng-model="account.email" id="inputEmail" name="email" placeholder="Email" required autocomplete="off" tooltip-trigger="focus" uib-tooltip="This email address is local to your Cloudron and used for notifications and password reset.">
</div>
<div class="form-group" ng-class="{ 'has-error': setupForm.username.$dirty && setupForm.username.$invalid }">
<input type="text" class="form-control" ng-model="account.username" id="inputUsername" name="username" placeholder="Username" ng-maxlength="512" ng-minlength="3" required autocomplete="off">
@@ -77,11 +81,6 @@
<small ng-show="setupForm.password.$dirty && setupForm.password.$invalid">Password must be 8-30 character with at least one uppercase, one numeric and one special character</small>
</div>
</div>
<div class="form-group" ng-class="{ 'has-error': setupForm.instanceId.$dirty && (setupForm.instanceId.$invalid || error) }" ng-show="provider === 'ami'">
<p>Provide the EC2 instance id to verify you are the owner</p>
<input type="text" class="form-control" ng-model="instanceId" id="inputInstanceId" name="instanceId" placeholder="AWS EC2 instance id" ng-maxlength="20" ng-minlength="10" ng-required="provider === 'ami'" autocomplete="off">
<p ng-show="error" class="has-error">{{ error }}</p>
</div>
</div>
</div>
<div class="row">
+12
View File
@@ -3,6 +3,7 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="user-scalable=no, initial-scale=1, maximum-scale=1, minimum-scale=1, width=device-width, height=device-height" />
<meta http-equiv="Content-Security-Policy" content="default-src 'unsafe-inline' 'unsafe-eval' 'self' *.cloudron.io <%= apiOriginHostname %>; img-src 'self' <%= apiOriginHostname %>;" />
<title> Cloudron Setup </title>
@@ -101,6 +102,17 @@
</p>
</div>
</div>
<div class="row" ng-show="provider === 'ami'">
<div class="col-md-10 col-md-offset-1 text-center">
<br/>
<h3>Owner verification</h3>
<p>Provide the EC2 instance id to verify you have access to this server.</p>
<div class="form-group" ng-class="{ 'has-error': dnsCredentialsForm.instanceId.$dirty && (dnsCredentialsForm.instanceId.$invalid || error) }">
<input type="text" class="form-control" ng-model="instanceId" id="inputInstanceId" name="instanceId" placeholder="AWS EC2 instance id" ng-maxlength="20" ng-minlength="10" ng-required="provider === 'ami'" autocomplete="off">
</div>
<p>&nbsp;<span ng-show="error" class="text-danger">{{ error }}</span></p>
</div>
</div>
<div class="row">
<div class="col-md-12 text-center">
<button type="submit" class="btn btn-primary" ng-disabled="dnsCredentialsForm.$invalid"/><i class="fa fa-circle-o-notch fa-spin" ng-show="dnsCredentials.busy"></i> Next</button>
+1
View File
@@ -3,6 +3,7 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="user-scalable=no, initial-scale=1, maximum-scale=1, minimum-scale=1, width=device-width, height=device-height"/>
<meta http-equiv="Content-Security-Policy" content="default-src 'unsafe-inline' 'unsafe-eval' 'self' *.cloudron.io <%= apiOriginHostname %>; img-src 'self' *.cloudron.io <%= apiOriginHostname %>" />
<title> Cloudron </title>
+21 -8
View File
@@ -191,10 +191,23 @@
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title">Really restore {{ appRestore.app.fqdn }} ?</h4>
<h4 class="modal-title">Restore {{ appRestore.app.fqdn }}</h4>
</div>
<div class="modal-body">
<p>Restoring the app will lose all content generated since last backup of this app.</p>
<div class="modal-body" ng-show="appRestore.backups.length === 0">
<p class="text-danger">This app has no backups.</p>
</div>
<div class="modal-body" ng-show="appRestore.backups.length !== 0">
<p>Restoring the app will lose all content generated since the backup.</p>
<label class="control-label">Select backup</label>
<div class="dropdown">
<button type="button" class="btn btn-block btn-default" data-toggle="dropdown">{{ appRestore.selectedBackup.creationTime | prettyDate }} - v{{appRestore.selectedBackup.version}} <span class="caret"></span></button>
<ul class="dropdown-menu" role="menu">
<li ng-repeat="backup in appRestore.backups | orderBy:'-creationTime'">
<a href="" ng-click="appRestore.selectBackup(backup)">{{backup.creationTime}} {{ backup.creationTime | prettyDate }} - v{{backup.version}}</a>
</li>
</ul>
</div>
<br/>
<fieldset>
<form role="form" name="appRestoreForm" ng-submit="doRestore()" autocomplete="off">
<div class="form-group" ng-class="{ 'has-error': (appRestoreForm.password.$dirty && appRestoreForm.password.$invalid) || (!appRestoreForm.password.$dirty && appRestore.error.password) }">
@@ -212,7 +225,7 @@
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-success" ng-click="doRestore()" ng-disabled="appRestoreForm.$invalid || appRestore.busy"><i class="fa fa-circle-o-notch fa-spin" ng-show="appRestore.busy"></i> Restore</button>
<button type="button" class="btn btn-success" ng-click="doRestore()" ng-disabled="appRestoreForm.$invalid || appRestore.busy || !appRestore.selectedBackup"><i class="fa fa-circle-o-notch fa-spin" ng-show="appRestore.busy"></i> Restore</button>
</div>
</div>
</div>
@@ -318,7 +331,7 @@
</div>
<div class="form-group" ng-class="{ 'has-error': (!appUpdateForm.password.$dirty && appUpdate.error.password) || (appUpdateForm.password.$dirty && appUpdateForm.password.$invalid) }">
<label class="control-label" for="inputUpdatePassword">Provide your password to confirm this action</label>
<input type="password" class="form-control" ng-model="appUpdate.password" id="inputUpdatePassword" name="password" ng-maxlength="30" ng-minlength="8" required autofocus>
<input type="password" class="form-control" ng-model="appUpdate.password" id="inputUpdatePassword" name="password" required autofocus>
</div>
<input class="ng-hide" type="submit" ng-disabled="appUpdateForm.$invalid || busy"/>
</form>
@@ -386,7 +399,7 @@
<div class="grid-item-bottom-mobile" ng-show="user.admin">
<div class="row">
<div class="col-xs-4 text-left">
<a href="" ng-click="showRestore(app)" ng-show="app.lastBackupId != null">
<a href="" ng-click="showRestore(app)">
<i class="fa fa-undo scale"></i>
</a>
@@ -408,11 +421,11 @@
<a href="" ng-click="showUninstall(app)" title="Uninstall App"><i class="fa fa-remove scale"></i></a>
</div>
<div ng-show="app.lastBackupId !== null">
<div>
<a href="" ng-click="showRestore(app)" title="Restore App"><i class="fa fa-undo scale"></i></a>
</div>
<div ng-show="app.installationState === 'installed' || app.installationState === 'pending_configure'">
<div ng-show="(app.installationState === 'installed' || app.installationState === 'pending_configure') && !(app | installError)">
<a href="" ng-click="showConfigure(app)" title="Configure App"><i class="fa fa-pencil scale"></i></a>
</div>
+21 -2
View File
@@ -58,7 +58,13 @@ angular.module('Application').controller('AppsController', ['$scope', '$location
busy: false,
error: {},
app: {},
password: ''
password: '',
backups: [ ],
selectedBackup: null,
selectBackup: function (backup) {
$scope.appRestore.selectedBackup = backup;
}
};
$scope.appPostInstall = {
@@ -125,6 +131,8 @@ angular.module('Application').controller('AppsController', ['$scope', '$location
$scope.appRestore.error = {};
$scope.appRestore.app = {};
$scope.appRestore.password = '';
$scope.appRestore.selectedBackup = null;
$scope.appRestore.backups = [];
$scope.appRestoreForm.$setPristine();
$scope.appRestoreForm.$setUntouched();
@@ -308,9 +316,20 @@ angular.module('Application').controller('AppsController', ['$scope', '$location
$scope.reset();
$scope.appRestore.app = app;
$scope.appRestore.busy = true;
$('#appRestoreModal').modal('show');
Client.getAppBackups(app.id, function (error, backups) {
if (error) {
Client.error(error)
} else {
$scope.appRestore.backups = backups;
if (backups.length) $scope.appRestore.selectedBackup = backups[0]; // pre-select first backup
$scope.appRestore.busy = false;
}
});
return false; // prevent propagation and default
};
@@ -318,7 +337,7 @@ angular.module('Application').controller('AppsController', ['$scope', '$location
$scope.appRestore.busy = true;
$scope.appRestore.error.password = null;
Client.restoreApp($scope.appRestore.app.id, $scope.appRestore.app.lastBackupId, $scope.appRestore.password, function (error) {
Client.restoreApp($scope.appRestore.app.id, $scope.appRestore.selectedBackup.id, $scope.appRestore.password, function (error) {
if (error && error.statusCode === 403) {
$scope.appRestore.password = '';
$scope.appRestore.error.password = true;
+3 -3
View File
@@ -76,7 +76,7 @@
Only allow the following user groups <span class="label label-danger" ng-show="appInstall.accessRestrictionOption === 'groups' && !appInstall.isAccessRestrictionValid()">Select at least one group</span>
</label>
</div>
<div ng-show="groups.length <= 1" style="margin-left: 20px;">No groups available. <a href="#/users">Create groups</a></div>
<div ng-show="groups.length <= 1" style="margin-left: 20px;">No groups available. <a href="" ng-click="showView('/users')">Create groups</a></div>
<div>
<div style="margin-left: 20px;">
<span ng-repeat="group in groups | ignoreAdminGroup">
@@ -139,8 +139,8 @@
<div class="modal-footer">
<button type="button" class="btn btn-default" ng-show="appInstall.state !== 'postInstall'" data-dismiss="modal">Close</button>
<button type="button" class="btn btn-default" ng-show="appInstall.state === 'postInstall'" data-dismiss="modal" ng-click="appInstall.switchToAppsView()">Got it</button>
<button type="button" class="btn btn-success" ng-show="config.provider === 'caas' && user.admin && appInstall.state === 'resourceConstraint'" ng-click="showRequestUpgrade()">Upgrade Cloudron</button>
<button type="button" class="btn btn-danger" ng-show="(config.isDev || config.provider !== 'caas') && user.admin && appInstall.state === 'resourceConstraint'" ng-click="appInstall.showForm(true)">Install anyway</button>
<button type="button" class="btn btn-success" ng-show="config.provider === 'caas' && user.admin && appInstall.state === 'resourceConstraint'" ng-click="showView('/settings')">Upgrade Cloudron</button>
<button type="button" class="btn btn-danger" ng-show="config.provider !== 'caas' && user.admin && appInstall.state === 'resourceConstraint'" ng-click="appInstall.showForm(true)">Install anyway</button>
<button type="button" class="btn btn-success" ng-show="appInstall.state === 'appInfo' && user.admin" ng-click="appInstall.showForm()">Install</button>
<button type="button" class="btn btn-success" ng-show="appInstall.state === 'installForm' && user.admin" ng-click="appInstall.submit()" ng-disabled="appInstallForm.$invalid || appInstall.busy"><i class="fa fa-circle-o-notch fa-spin" ng-show="appInstall.busy"></i> Install</button>
</div>
+5 -5
View File
@@ -20,15 +20,15 @@ angular.module('Application').controller('AppStoreController', ['$scope', '$loca
$scope.appstoreConfig = null;
$scope.mailConfig = {};
$scope.showRequestUpgrade = function () {
$scope.showView = function (view) {
// wait for dialog to be fully closed to avoid modal behavior breakage when moving to a different view already
$('#appInstallModal').on('hidden.bs.modal', function () {
$('.modal').on('hidden.bs.modal', function () {
$scope.appInstall.reset();
$('#appInstallModal').off('hidden.bs.modal');
$location.path('/settings');
$('.modal').off('hidden.bs.modal');
$location.path(view);
});
$('#appInstallModal').modal('hide');
$('.modal').modal('hide');
};
$scope.appInstall = {
+13 -11
View File
@@ -12,7 +12,7 @@
<div class="form-group" ng-class="{ 'has-error': dnsCredentialsForm.customDomainId.$invalid }" uib-tooltip="{{ config.provider === 'caas' ? '' : 'Changing the domain is not yet supported' }}">
<label class="control-label" for="customDomainId">Domain name</label>
<input type="text" class="form-control" ng-model="dnsCredentials.customDomain" id="customDomainId" name="customDomainId" ng-disabled="dnsCredentials.busy || config.provider !== 'caas'" check-tld placeholder="example.com" required autofocus>
<p>&nbsp;<span ng-show="dnsCredentialsForm.customDomainId.$error.invalidSubdomain" class="text-danger">Subdomains are <a href="https://cloudron.io/references/selfhosting.html#domain-setup" target="_blank" title="Domain documentation">not supported</a></span></p>
<p>&nbsp;<span ng-show="dnsCredentialsForm.customDomainId.$error.invalidSubdomain && dnsCredentials.customDomain !== config.fqdn" class="text-danger">Subdomains are <a href="https://cloudron.io/references/selfhosting.html#domain-setup" target="_blank" title="Domain documentation">not supported</a></span></p>
</div>
<div class="form-group">
@@ -28,19 +28,15 @@
<div class="form-group" ng-class="{ 'has-error': false }" ng-show="dnsCredentials.provider === 'route53'">
<label class="control-label" for="dnsCredentialsSecretAccessKey">Secret access key</label>
<input type="text" class="form-control" ng-model="dnsCredentials.secretAccessKey" id="dnsCredentialsSecretAccessKey" name="secretAccessKey" ng-disabled="dnsCredentials.busy" ng-required="dnsCredentials.provider === 'route53'">
<br/>
<p>This domain must be hosted on <a href="https://aws.amazon.com/route53/?nc2=h_m1" target="_blank">AWS Route53</a>.</p>
</div>
<!-- DigitalOcean -->
<div class="form-group" ng-class="{ 'has-error': false }" ng-show="dnsCredentials.provider === 'digitalocean'">
<label class="control-label" for="dnsCredentialsDigitalOceanToken">DigitalOcean token</label>
<input type="text" class="form-control" ng-model="dnsCredentials.digitalOceanToken" id="dnsCredentialsDigitalOceanToken" name="digitalOceanToken" ng-disabled="dnsCredentials.busy" ng-required="dnsCredentials.provider === 'digitalocean'">
<br/>
<p>This domain must be hosted on <a href="https://www.digitalocean.com/community/tutorials/how-to-set-up-a-host-name-with-digitalocean#step-two%E2%80%94change-your-domain-server" target="_blank">DigitalOcean</a>.</p>
</div>
<div class="form-group" ng-class="{ 'has-error': false }" ng-if="config.fqdn !== dnsCredentials.customDomain && !dnsCredentialsForm.customDomainId.$invalid">
<div class="form-group" ng-class="{ 'has-error': false }">
<label class="control-label" for="dnsCredentialsPassword">Provide your password to confirm this action</label>
<input type="password" class="form-control" ng-model="dnsCredentials.password" id="dnsCredentialsPassword" name="password" ng-disabled="dnsCredentials.busy" required>
</div>
@@ -49,14 +45,20 @@
</fieldset>
</form>
<!-- Wildcard -->
<p ng-show="dnsCredentials.provider === 'wildcard'">
Setup <i>A</i> records for <b>*.{{ dnsCredentials.domain || 'example.com' }}</b> and <b>{{ dnsCredentials.domain || 'example.com' }}</b> to this server's IP.
<p ng-show="dnsCredentials.provider === 'route53'">
This domain must be hosted on <a href="https://aws.amazon.com/route53/?nc2=h_m1" target="_blank">AWS Route53</a>.
</p>
<p ng-show="dnsCredentials.provider === 'digitalocean'">
This domain must be hosted on <a href="https://www.digitalocean.com/community/tutorials/how-to-set-up-a-host-name-with-digitalocean#step-two%E2%80%94change-your-domain-server" target="_blank">DigitalOcean</a>.
</p>
<p ng-show="dnsCredentials.provider === 'wildcard'">
Setup <i>A</i> records for <b>*.{{ dnsCredentials.customDomain || 'example.com' }}</b> and <b>{{ dnsCredentials.customDomain || 'example.com' }}</b> to this server's IP.
</p>
<!-- Manual -->
<p ng-show="dnsCredentials.provider === 'manual'">
Setup an <i>A</i> record for <b>my.{{ dnsCredentials.domain || 'example.com' }}</b> to this server's IP. All DNS records have to be setup manually <i>before</i> each app installation.
Setup an <i>A</i> record for <b>my.{{ dnsCredentials.customDomain || 'example.com' }}</b> to this server's IP. All DNS records have to be setup manually <i>before</i> each app installation.
</p>
</div>
<div class="modal-footer ">
+2 -1
View File
@@ -184,7 +184,8 @@ angular.module('Application').controller('CertsController', ['$scope', '$locatio
$scope.showChangeDnsCredentials = function () {
dnsCredentialsReset();
$scope.dnsCredentials.customDomain = $scope.config.fqdn;
// clear the input box for non-custom domain
$scope.dnsCredentials.customDomain = $scope.config.isCustomDomain ? $scope.config.fqdn : '';
$scope.dnsCredentials.accessKeyId = $scope.dnsConfig.accessKeyId;
$scope.dnsCredentials.secretAccessKey = $scope.dnsConfig.secretAccessKey;
$scope.dnsCredentials.digitalOceanToken = $scope.dnsConfig.provider === 'digitalocean' ? $scope.dnsConfig.token : '';
+13 -6
View File
@@ -108,14 +108,21 @@ angular.module('Application').controller('GraphsController', ['$scope', '$locati
// https://graphite.readthedocs.io/en/latest/render_api.html#paths-and-wildcards
// on scaleway, for some reason docker devices are collected as part of collectd
// until we figure why just hardcode popular disk devices - https://www.mjmwired.net/kernel/Documentation/devices.txt
Client.graphs([
'averageSeries(collectd.localhost.df-{sd,hd,vd,md,ad,nb,vd,ub,xvd}*.df_complex-free)',
'averageSeries(collectd.localhost.df-{sd,hd,vd,md,ad,nb,vd,ub,xvd}*.df_complex-reserved)',
'averageSeries(collectd.localhost.df-{sd,hd,vd,md,ad,nb,vd,ub,xvd}*.df_complex-used)'
], '-1min', function (error, data) {
Client.disks(function (error, disks) {
if (error) return console.log(error);
renderDisk('system', data[0], data[1], data[2]);
// We have to see if this is sufficient for all server configurations
var appDataDiskName = disks.appsDataDisk.slice(disks.appsDataDisk.lastIndexOf('/') + 1)
Client.graphs([
'absolute(collectd.localhost.df-' + appDataDiskName + '.df_complex-free)',
'absolute(collectd.localhost.df-' + appDataDiskName + '.df_complex-reserved)',
'absolute(collectd.localhost.df-' + appDataDiskName + '.df_complex-used)'
], '-1min', function (error, data) {
if (error) return console.log(error);
renderDisk('system', data[0], data[1], data[2]);
});
});
};
+22 -38
View File
@@ -296,6 +296,15 @@
</div>
</div>
<br/>
<div class="row">
<div class="col-md-12" ng-show="dnsConfig.provider !== 'caas'">
<button ng-class="mailConfig.enabled ? 'btn btn-danger' : 'btn btn-primary'" ng-click="email.toggle()" ng-enabled="mailConfig">{{ mailConfig.enabled ? "Disable Email" : "Enable Email" }}</button>
</div>
<div class="col-md-12" ng-show="dnsConfig.provider === 'caas'">
<span class="text-danger text-bold">This feature requires the Cloudron to be on <a href="https://cloudron.io/references/usermanual.html#entire-cloudron-on-a-custom-domain" target="_blank">custom domain</a>.</span>
</div>
</div>
<br/>
<div class="row">
<div class="col-md-12" ng-show="(dnsConfig.provider !== 'caas')">
Set the following DNS records to guarantee email delivery.
@@ -336,15 +345,6 @@
</div>
</div>
</div>
<br/>
<div class="row">
<div class="col-md-12" ng-show="dnsConfig.provider !== 'caas'">
<button ng-class="mailConfig.enabled ? 'btn btn-danger pull-right' : 'btn btn-primary pull-right'" ng-click="email.toggle()" ng-enabled="mailConfig">{{ mailConfig.enabled ? "Disable Email" : "Enable Email" }}</button>
</div>
<div class="col-md-12" ng-show="dnsConfig.provider === 'caas'">
<span class="text-danger text-bold">This feature requires the Cloudron to be on <a href="https://cloudron.io/references/usermanual.html#entire-cloudron-on-a-custom-domain" target="_blank">custom domain</a>.</span>
</div>
</div>
</div>
<div class="section-header">
@@ -354,7 +354,7 @@
</div>
<div class="card" style="margin-bottom: 15px;">
<div class="row" ng-show="backupConfig.provider !== 'caas' || config.isDev">
<div class="row" ng-show="backupConfig.provider !== 'caas'">
<div class="col-xs-6">
<span class="text-muted">Provider</span>
</div>
@@ -362,7 +362,7 @@
<span>{{ backupConfig.provider === 'caas' ? 'cloudron.io' : backupConfig.provider }}</span>
</div>
</div>
<div class="row" ng-show="backupConfig.provider !== 'caas' || config.isDev">
<div class="row" ng-show="backupConfig.provider !== 'caas'">
<div class="col-xs-6">
<span class="text-muted">Location</span>
</div>
@@ -384,7 +384,7 @@
</div>
</div>
<div class="row" ng-show="backupConfig.provider !== 'caas' || config.isDev">
<div class="row" ng-show="backupConfig.provider !== 'caas'">
<br/>
<div class="col-md-12">
<div ng-show="createBackup.busy" class="progress progress-striped active animateMe">
@@ -393,7 +393,7 @@
<br/>
</div>
</div>
<div class="row" ng-show="backupConfig.provider !== 'caas' || config.isDev">
<div class="row" ng-show="backupConfig.provider !== 'caas'">
<div class="col-md-6">
<p ng-show="createBackup.busy">{{ createBackup.message }}</p>
</div>
@@ -423,19 +423,19 @@
<div class="col-md-12">
<div class="radio">
<label>
<input type="radio" name="scheduleRadio" ng-model="autoUpdate.pattern" value="00 00 1,3,5,23 * * *">
<input type="radio" name="scheduleRadio" ng-change="autoUpdate.success = false" ng-model="autoUpdate.pattern" value="00 00 1,3,5,23 * * *">
Every night
</label>
</div>
<div class="radio">
<label>
<input type="radio" name="scheduleRadio" ng-model="autoUpdate.pattern" value="00 00 1,3,5,23 * * 6">
<input type="radio" name="scheduleRadio" ng-change="autoUpdate.success = false" ng-model="autoUpdate.pattern" value="00 00 1,3,5,23 * * 6">
Saturday night
</label>
</div>
<div class="radio">
<label>
<input type="radio" name="scheduleRadio" ng-model="autoUpdate.pattern" value="never">
<input type="radio" name="scheduleRadio" ng-change="autoUpdate.success = false" ng-model="autoUpdate.pattern" value="never">
Update manually (Not recommended)
</label>
</div>
@@ -443,31 +443,15 @@
</div>
<div class="row">
<div class="col-md-12">
<p class="text-success pull-right text-bold" ng-show="autoUpdate.success && autoUpdate.pattern === autoUpdate.currentPattern">Saved</p>
<button class="btn btn-outline btn-primary pull-right" ng-hide="autoUpdate.success && autoUpdate.pattern === autoUpdate.currentPattern" ng-click="autoUpdate.submit()" ng-disabled="autoUpdate.busy || autoUpdate.pattern === autoUpdate.currentPattern"><i class="fa fa-circle-o-notch fa-spin" ng-show="autoUpdate.busy"></i> Save</button>
<div class="col-md-6">
<i class="fa fa-circle-o-notch fa-spin" ng-show="autoUpdate.busy"></i>
<span class="text-success text-bold" ng-show="autoUpdate.success && autoUpdate.pattern === autoUpdate.currentPattern">Saved</span>
</div>
</div>
</div>
<div class="section-header">
<div class="text-left">
<h3>User Registration</h3>
</div>
</div>
<div class="col-md-6 text-right">
<button class="btn btn-outline btn-primary pull-right" ng-click="autoUpdate.submit()" ng-disabled="autoUpdate.busy || autoUpdate.pattern === autoUpdate.currentPattern"> Save</button>
<div class="card" style="margin-bottom: 15px;">
<div class="row">
<div class="col-md-12">
<p>
By default the Cloudron only allows admins to invite other users.
You may enable user registration, allowing users to signup without such an invite.
</p>
<p ng-show="openRegistrationEnabled">
The user signup link is: <a ng-href="{{ signupLink }}" target="_blank">{{ signupLink }}</a>
</p>
<br/>
<button class="btn btn-primary pull-right" ng-click="toggleOpenRegistration()">{{ openRegistrationEnabled ? 'Disable user registration' : 'Enable user registration' }}</button>
<button class="btn btn-outline btn-primary" ng-click="autoUpdate.checkNow()" ng-disabled="autoUpdate.busy" style="margin-right: 10px">Check now</button>
</div>
</div>
</div>
+10 -20
View File
@@ -6,8 +6,6 @@ angular.module('Application').controller('SettingsController', ['$scope', '$loca
$scope.client = Client;
$scope.user = Client.getUserInfo();
$scope.config = Client.getConfig();
$scope.openRegistrationEnabled = false;
$scope.signupLink = '';
$scope.backupConfig = {};
$scope.dnsConfig = {};
$scope.outboundPort25 = {};
@@ -435,6 +433,16 @@ angular.module('Application').controller('SettingsController', ['$scope', '$loca
pattern: '',
currentPattern: '',
checkNow: function () {
$scope.autoUpdate.busy = true;
Client.checkForUpdates(function (error) {
if (error) $scope.autoUpdate.error = error.message;
$scope.autoUpdate.busy = false;
});
},
submit: function () {
if ($scope.autoUpdate.pattern === $scope.autoUpdate.currentPattern) return;
@@ -509,16 +517,6 @@ angular.module('Application').controller('SettingsController', ['$scope', '$loca
});
}
function getOpenRegistration() {
Client.getOpenRegistration(function (error, enabled) {
if (error) return console.error(error);
$scope.openRegistrationEnabled = enabled;
$scope.signupLink = window.location.origin + '/api/v1/session/account/create.html';
});
}
function showExpectedDnsRecords(callback) {
callback = callback || function (error) { if (error) console.error(error); };
@@ -625,20 +623,12 @@ angular.module('Application').controller('SettingsController', ['$scope', '$loca
}
};
$scope.toggleOpenRegistration = function () {
Client.setOpenRegistration(!$scope.openRegistrationEnabled, function (error) {
if (error) return console.error(error);
$scope.openRegistrationEnabled = !$scope.openRegistrationEnabled;
});
};
Client.onReady(function () {
fetchBackups();
getMailConfig();
getBackupConfig();
getDnsConfig();
getAutoupdatePattern();
getOpenRegistration();
if ($scope.config.provider === 'caas') {
getPlans();
+16 -15
View File
@@ -4,18 +4,25 @@
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title">Add API Client</h4>
<h4 class="modal-title">Add OAuth Client</h4>
</div>
<div class="modal-body">
<form name="clientAddForm" role="form" novalidate ng-submit="clientAdd.submit()" autocomplete="off">
<div class="form-group" ng-class="{ 'has-error': (clientAddForm.name.$dirty && clientAddForm.name.$invalid) || (!clientAddForm.name.$dirty && clientAdd.error.name) }">
<label class="control-label">Name</label>
<label class="control-label">Application name</label>
<div class="control-label" ng-show="(!clientAddForm.name.$dirty && clientAdd.error.name) || (clientAddForm.name.$dirty && clientAddForm.name.$invalid)">
<small ng-show="clientAddForm.name.$error.required">A name is required</small>
<small ng-show="!clientAddForm.name.$dirty && clientAdd.error.name">{{ clientAdd.error.name }}</small>
</div>
<input type="text" class="form-control" ng-model="clientAdd.name" name="name" id="clientAddName" required autofocus>
</div>
<div class="form-group" ng-class="{ 'has-error': (clientAddForm.redirectURI.$dirty && clientAddForm.redirectURI.$invalid) || (!clientAddForm.redirectURI.$dirty && clientAdd.error.redirectURI) }">
<label class="control-label">Authorization callback URL</label>
<div class="control-label" ng-show="!clientAddForm.redirectURI.$dirty && clientAdd.error.redirectURI">
<small ng-show="!clientAddForm.redirectURI.$dirty && clientAdd.error.redirectURI">{{ clientAdd.error.redirectURI }}</small>
</div>
<input type="text" class="form-control" ng-model="clientAdd.redirectURI" name="redirectURI" id="clientAddRedirectURI" required>
</div>
<div class="form-group" ng-class="{ 'has-error': (clientAddForm.scope.$dirty && clientAddForm.scope.$invalid) || (!clientAddForm.scope.$dirty && clientAdd.error.scope) }">
<label class="control-label">Scope</label>
<div class="control-label" ng-show="(!clientAddForm.scope.$dirty && clientAdd.error.scope) || (clientAddForm.scope.$dirty && clientAddForm.scope.$invalid)">
@@ -24,19 +31,12 @@
</div>
<input type="text" class="form-control" ng-model="clientAdd.scope" name="scope" id="clientAddScope" placeholder="Specify any number of scope separated by a comma ','" required>
</div>
<div class="form-group" ng-class="{ 'has-error': (clientAddForm.redirectURI.$dirty && clientAddForm.redirectURI.$invalid) || (!clientAddForm.redirectURI.$dirty && clientAdd.error.redirectURI) }">
<label class="control-label">Redirect URI</label>
<div class="control-label" ng-show="!clientAddForm.redirectURI.$dirty && clientAdd.error.redirectURI">
<small ng-show="!clientAddForm.redirectURI.$dirty && clientAdd.error.redirectURI">{{ clientAdd.error.redirectURI }}</small>
</div>
<input type="text" class="form-control" ng-model="clientAdd.redirectURI" name="redirectURI" id="clientAddRedirectURI" placeholder="Only required if OAuth logins are used">
</div>
<input class="hide" type="submit" ng-disabled="clientAddForm.$invalid || clientAdd.busy"/>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-success" ng-click="clientAdd.submit()" ng-disabled="clientAddForm.$invalid || clientAdd.busy"><i class="fa fa-circle-o-notch fa-spin" ng-show="clientAdd.busy"></i> Add API Client</button>
<button type="button" class="btn btn-success" ng-click="clientAdd.submit()" ng-disabled="clientAddForm.$invalid || clientAdd.busy"><i class="fa fa-circle-o-notch fa-spin" ng-show="clientAdd.busy"></i> Add OAuth Client</button>
</div>
</div>
</div>
@@ -47,7 +47,7 @@
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title">Remove API Client</h4>
<h4 class="modal-title">Remove OAuth Client</h4>
</div>
<div class="modal-body">
<p>
@@ -57,7 +57,7 @@
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-danger" ng-click="clientRemove.submit()" ng-disabled="clientRemove.busy"><i class="fa fa-circle-o-notch fa-spin" ng-show="clientRemove.busy"></i> Remove API Client</button>
<button type="button" class="btn btn-danger" ng-click="clientRemove.submit()" ng-disabled="clientRemove.busy"><i class="fa fa-circle-o-notch fa-spin" ng-show="clientRemove.busy"></i> Remove OAuth Client</button>
</div>
</div>
</div>
@@ -116,10 +116,11 @@
<div class="row">
<div class="col-xs-12">
<p>These tokens can be used to access the <a href="https://cloudron.io/references/api.html" target="_blank">Cloudron API</a>. They have the <b>admin</b> <a href="https://cloudron.io/references/api.html#scopes" target="_blank">scope</a> and do not expire.</p>
<br/>
<h4 class="text-muted">Active Tokens</h4>
<hr/>
<p ng-repeat="token in apiClient.activeTokens">
<b ng-click-select>{{ token.accessToken }}</b> <button class="btn btn-xs btn-danger pull-right" ng-click="removeToken(apiClient, token)" title="Revoke Token"><i class="fa fa-trash-o"></i></button>
<span ng-click-select>{{ token.accessToken }}</span> <button class="btn btn-xs btn-danger pull-right" ng-click="removeToken(apiClient, token)" title="Revoke Token"><i class="fa fa-trash-o"></i></button>
</p>
</div>
</div>
@@ -130,7 +131,7 @@
<div class="section-header">
<div class="text-left">
<h3>Apps<button class="btn btn-xs btn-primary btn-outline pull-right" ng-click="clientAdd.show()"><i class="fa fa-plus"></i> New API Client</button></h3>
<h3>OAuth Apps<button class="btn btn-xs btn-primary btn-outline pull-right" ng-click="clientAdd.show()"><i class="fa fa-plus"></i> New OAuth Client</button></h3>
</div>
</div>
@@ -155,7 +156,7 @@
<a href="" data-toggle="collapse" data-parent="#accordion" data-target="#collapse{{client.id}}">Advanced</a>
<div id="collapse{{client.id}}" class="panel-collapse collapse">
<div class="panel-body">
<h4 class="text-muted">Credentials <button class="btn btn-xs btn-danger pull-right" ng-click="clientRemove.show(client)" title="Remove API Client" ng-show="client.type === 'external'">Remove API Client</button></h4>
<h4 class="text-muted">Credentials <button class="btn btn-xs btn-danger pull-right" ng-click="clientRemove.show(client)" title="Remove OAuth Client" ng-show="client.type === 'external'">Remove OAuth Client</button></h4>
<hr/>
<p>Scope: <b ng-click-select>{{ client.scope }}</b></p>
<p>RedirectURI: <b ng-click-select>{{ client.redirectURI }}</b></p>

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