Compare commits

...

138 Commits
v7.3.2 ... 7.3

Author SHA1 Message Date
Girish Ramakrishnan
06cbea11ac cname fix again
e4d9dbb558 left out this line by mistake

(cherry picked from commit 2b260c873f)
2023-02-28 11:07:42 +01:00
Girish Ramakrishnan
7df1399f17 typo 2023-02-01 21:52:27 +01:00
Girish Ramakrishnan
ce8f6c4c6b ubuntu 18: systemd kill ends up killing the script itself
This is because KillMode=control-group by default

(cherry picked from commit c07c8b5bb8)
2023-02-01 18:51:16 +01:00
Girish Ramakrishnan
0832ebf052 ubuntu 18: ExecReload does not work
(cherry picked from commit 7bbc7c2306)
2023-02-01 17:28:27 +01:00
Girish Ramakrishnan
7be176a3b5 reverseproxy: LE backdates certs by an hour
https://community.letsencrypt.org/t/valid-from-date-on-cert-off-by-1-hour/103239
(cherry picked from commit 54add73d2a)
2023-02-01 13:07:40 +01:00
Girish Ramakrishnan
4d9612889b print subject and fix notBefore parsing
(cherry picked from commit 3f70edf3ec)
2023-02-01 12:39:22 +01:00
Girish Ramakrishnan
29faa722ac typo
(cherry picked from commit c63e0036cb)
2023-02-01 12:39:16 +01:00
Girish Ramakrishnan
2442abf10b reverseproxy: force renewal only renews if not issued in last 5 mins
otherwise, this leads to repeated renewals in checkCerts

(cherry picked from commit 3b9486596d)
2023-02-01 12:05:11 +01:00
Girish Ramakrishnan
dcbec79b77 reverseproxy: get dates
(cherry picked from commit eddfd20f24)
2023-02-01 12:05:05 +01:00
Girish Ramakrishnan
f874acbeb9 reverseproxy: add option to force renewal for e2e
(cherry picked from commit 690df0e5c4)
2023-02-01 12:04:58 +01:00
Girish Ramakrishnan
3e01faeca3 7.3.6 changes 2023-01-31 18:06:25 +01:00
Girish Ramakrishnan
f7d7e36f10 reverseproxy: rebuild in 7.3 only 2023-01-31 18:02:13 +01:00
Girish Ramakrishnan
972f453535 reverseproxy: fix issue where renewed certs are not written to disk
(cherry picked from commit ce9e78d23b)
2023-01-31 17:59:30 +01:00
Girish Ramakrishnan
d441b9d926 backup cleaner: do not delete mail snapshot
(cherry picked from commit 02b6aa93cb)
2023-01-31 11:51:32 +01:00
Johannes Zellner
e0c996840d Use correct error object to avoid crash
(cherry picked from commit 6f84fd3f71)
2023-01-31 11:33:30 +01:00
Girish Ramakrishnan
3c5987cdad more 7.3.5 changes 2023-01-25 10:22:37 +01:00
Girish Ramakrishnan
e0673d78b9 dns: resolve cname records using unbound
cname record can be external and the original NS may not respond to
recursive queries

(cherry picked from commit e4d9dbb558)
2023-01-25 09:58:24 +01:00
Girish Ramakrishnan
08136a5347 More 7.3.4 changes 2023-01-17 11:18:31 +01:00
Girish Ramakrishnan
98b5c77177 s3: add listing check
This is needed for situations like in cloudflare where the endpoint can
be mistakenly configured with the bucket name like https://xx.r2.cloudflarestorage.com/cloudron-backups .
The upload and del calls work but list and copy does not.

(cherry picked from commit 093fc98ae5)
2023-01-17 11:18:05 +01:00
Girish Ramakrishnan
ea441d0b4b s3: throw any copy errors
(cherry picked from commit 40bcfdba76)
2023-01-17 11:18:00 +01:00
Girish Ramakrishnan
d8b5b49ffd Update manifest format for runtimeDirs change 2023-01-16 12:27:58 +01:00
Girish Ramakrishnan
13fd595e8b contabo: network can be real slow
(cherry picked from commit 68f4f1ba85)
2022-12-24 16:34:18 +01:00
Girish Ramakrishnan
5f11e430bd unbound: disable controller interface explicitly
https://github.com/NLnetLabs/unbound/issues/806
(cherry picked from commit ae30fe25d7)
2022-12-24 16:34:09 +01:00
Girish Ramakrishnan
cfa9f901c1 Fix crash in RBL check
(cherry picked from commit d5793bc7c0)
2022-12-09 00:00:46 +01:00
Girish Ramakrishnan
ce2c9d9ac5 reverseproxy: fix typo in regexp matching
(cherry picked from commit d7d43c73fe)
2022-12-08 10:06:48 +01:00
Girish Ramakrishnan
8d5039da35 7.3.5 changes
(cherry picked from commit a198d1ea8d)
2022-12-08 10:06:43 +01:00
Girish Ramakrishnan
c264ff32c2 du: fix crash when filesystem is cifs/nfs/sshfs
(cherry picked from commit 67cde5a62c)
2022-12-08 08:54:41 +01:00
Johannes Zellner
5e30bea155 Start with a default to not fail if no swap is present
(cherry picked from commit d126f056fc)
2022-12-08 08:54:33 +01:00
Johannes Zellner
2c63a89199 Disallow jupyter hub on demo
(cherry picked from commit db5e0b8fdf)
2022-12-08 08:54:27 +01:00
Girish Ramakrishnan
d547bad17a 7.3.4 changes 2022-11-30 21:19:03 +01:00
Girish Ramakrishnan
36ddb8c7c2 prune: normalize the tag 2022-11-30 21:12:00 +01:00
Girish Ramakrishnan
6c9aa1a77f Revert "prune all images instead of parsing output"
This reverts commit d42c524a46.

This caused a bug that all app images are getting removed since we remove
all containers on infra update!
2022-11-30 20:00:51 +01:00
Girish Ramakrishnan
27dec3f61e bump test version 2022-11-30 19:56:51 +01:00
Girish Ramakrishnan
79cb8ef251 add route to get platform status 2022-11-30 19:54:32 +01:00
Girish Ramakrishnan
f27847950c reverseproxy: notify cert change only in cron job
notifying this in ensureCertificate does not work if provider changed in the middle anyway.
might as well get them to be in sync in the cronjob.

this change also resulted in tls addon getting restarted non-stop if you change from wildcard
to non-wildcard since ensureCertificate notifies the change.
2022-11-30 15:55:32 +01:00
Girish Ramakrishnan
69b46d82ab Fix typo 2022-11-30 14:56:40 +01:00
Girish Ramakrishnan
2a660fa59d change terminology to running and unresponsive 2022-11-30 14:41:48 +01:00
Girish Ramakrishnan
e942b8fe7e better debugs 2022-11-30 13:08:05 +01:00
Girish Ramakrishnan
1c3ef36a47 typo in graphite version 2022-11-30 10:37:28 +01:00
Girish Ramakrishnan
d42c524a46 prune all images instead of parsing output
nothing is really lost since these are just unused images
2022-11-30 10:01:50 +01:00
Girish Ramakrishnan
15cc624fa5 do string compare in certs 2022-11-30 09:59:19 +01:00
Girish Ramakrishnan
7e1c56161d reverseproxy: notify services immediately
there are 2 cases where certs change (in db):
* LE cert is new or renewed
* fallback cert changes with fallback provider

if something is off i.e we crashed midway of above, then user can click the
rebuild button.
2022-11-29 18:27:08 +01:00
Girish Ramakrishnan
77a5f01585 reverseproxy: rebuild only when needed
re-creating nginx configs is only needed in 3 cases:
* provider changes. we create a rebuild file for this
* nginx config is somehow corrupt by external changes. user can click ui button

on startup, dashboard also always creates the nginx configs. so it's always up to provide the button
2022-11-29 18:17:53 +01:00
Girish Ramakrishnan
3aa3cb6e39 tls: remove any old location certs 2022-11-29 17:58:51 +01:00
Girish Ramakrishnan
302f975d5c handle type mismatch 2022-11-29 17:13:58 +01:00
Girish Ramakrishnan
d23c65a7e7 reverseproxy: cert/key/csr are all pem
just use strings instead of binary/string confusion
2022-11-29 14:33:52 +01:00
Girish Ramakrishnan
1cf613dca6 Fix name of wildcard alias domain cert and configs 2022-11-29 13:35:17 +01:00
Girish Ramakrishnan
89127e1df7 reverseproxy: rework cert logic
9c8f78a059 already fixed many of the cert issues.

However, some issues were caught in the CI:

* The TLS addon has to be rebuilt and not just restarted. For this reason, we now
  move things to a directory instead of mounting files. This way the container is just restarted.

* Cleanups must be driven by the database and not the filesystem . Deleting files on disk or after a restore,
  the certs are left dangling forever in the db.

* Separate the db cert logic and disk cert logic. This way we can sync as many times as we want and whenever we want.
2022-11-29 11:07:23 +01:00
Girish Ramakrishnan
c844be5be1 make validateLocations return error 2022-11-28 22:16:22 +01:00
Girish Ramakrishnan
e15c6324e4 getDuplicateErrorDetails does not need domain map 2022-11-28 22:14:10 +01:00
Girish Ramakrishnan
b70572a6e9 dns: fqdn only needs domain string
This is from the caas days, when we had hyphenated subdomains flag
2022-11-28 21:56:25 +01:00
Girish Ramakrishnan
cab7409d85 mail: update haraka 2022-11-24 18:27:33 +01:00
Girish Ramakrishnan
ce00165e41 Update containterd
this possible fixes stuck containers - https://github.com/containerd/containerd/issues/6772
2022-11-24 14:49:12 +01:00
Girish Ramakrishnan
38312b810a add note 2022-11-24 01:21:32 +01:00
Girish Ramakrishnan
9477e0bbb5 Fix crash when accessing memory_stats 2022-11-24 00:40:40 +01:00
Girish Ramakrishnan
4c6f7de10a more debug messages 2022-11-23 22:03:18 +01:00
Girish Ramakrishnan
28f3b697a1 tokens: add test for readonly token 2022-11-23 18:16:03 +01:00
Girish Ramakrishnan
f728971479 add test that only owner can open tickets 2022-11-23 17:56:24 +01:00
Girish Ramakrishnan
30fb1aa351 proxy: do not set Host header when proxying
The default when proxying is $proxy_host.

Proxied apps must used X-Forwarded-Host header to determine the intended
target. I think we overwrote the Host header back in the day because apps
had varied support for this. Ideally, it can be removed across all our configurations.
2022-11-23 16:50:38 +01:00
Johannes Zellner
a5d244b593 Add tests for proxy app upstreamUri 2022-11-23 14:36:57 +01:00
Girish Ramakrishnan
817e950d47 Fix upstreamUri verification 2022-11-23 12:58:17 +01:00
Girish Ramakrishnan
258eea4318 Fix appstore-test 2022-11-22 22:14:59 +01:00
Girish Ramakrishnan
1b0c33fc73 Fix system-test 2022-11-22 21:38:22 +01:00
Girish Ramakrishnan
1d56bcb2e0 Update node to 16.18.1 2022-11-22 19:29:54 +01:00
Johannes Zellner
35ea3b1575 Also include potential swap files in the disk usage stats 2022-11-22 12:15:17 +01:00
Girish Ramakrishnan
c639559a6d Update docker 20.10.21
many users reporting hangs in docker, maybe this solves it
2022-11-21 13:20:49 +01:00
Girish Ramakrishnan
b437466f8c mail: send quota value as raw bytes 2022-11-21 09:45:17 +01:00
Girish Ramakrishnan
3b8221190d Better error mesasge 2022-11-20 18:16:16 +01:00
Girish Ramakrishnan
250d54f157 postgresql: fix issue with pg_ctl timing out 2022-11-20 18:05:37 +01:00
Girish Ramakrishnan
5d0309f1ca reverseproxy: check renewal against cert instead of the files 2022-11-17 16:40:14 +01:00
Girish Ramakrishnan
00771d8197 reverseproxy: move dashboard config to subdir as well 2022-11-17 15:50:34 +01:00
Girish Ramakrishnan
641752a222 reverseproxy: remove getAcmeApiOptions 2022-11-17 12:39:23 +01:00
Girish Ramakrishnan
e3b0d3960a reverseproxy: create configs in subdirectories for easy management 2022-11-17 12:16:11 +01:00
Girish Ramakrishnan
cd90864bc3 typos 2022-11-17 11:46:29 +01:00
Girish Ramakrishnan
23cc0d6f0e acme2: do not pass around paths 2022-11-17 11:44:36 +01:00
Girish Ramakrishnan
51f43597bc Make location have subdomain just like in the database 2022-11-17 10:22:46 +01:00
Girish Ramakrishnan
28b5457e9c Fix validateLocations return value 2022-11-17 10:22:46 +01:00
Girish Ramakrishnan
35076b0e93 use vhost naming for nginx config terminology 2022-11-17 10:22:46 +01:00
Girish Ramakrishnan
293b8a0d34 remove location type from nginx filename
this will keep it consistent with upcoming cert filenames
2022-11-17 10:22:46 +01:00
Girish Ramakrishnan
0c8b8346f4 Move getLocationsSync into apps.js 2022-11-17 10:22:43 +01:00
Girish Ramakrishnan
8c2a1906ba Add to changes 2022-11-17 08:00:44 +01:00
Girish Ramakrishnan
720bafaf02 logrotate: only keep 14 days of logs
https://unix.stackexchange.com/questions/261696/logrotation-rotate-and-maxage-command
https://blog.gsterling.de/2017/10/03/logrotate-misconceptions-about-maxsize-and-size/
2022-11-17 00:47:39 +01:00
Johannes Zellner
0b6bbf4cc2 Set exec LANG via rest API only 2022-11-16 16:14:54 +01:00
Girish Ramakrishnan
013e15e361 reverseproxy: do deep compare in tlsConfig
wildcard field might change
2022-11-16 16:04:26 +01:00
Johannes Zellner
9da4f55754 Set default LANG in exec container to make umlauts and other special characters work 2022-11-16 15:49:06 +01:00
Girish Ramakrishnan
e3642f4278 reverse proxy: rebuild configs on provider change 2022-11-16 12:42:06 +01:00
Girish Ramakrishnan
19b0d47988 remove obsolete fixme 2022-11-16 11:46:31 +01:00
Girish Ramakrishnan
f82f533f36 Add SIGHUP handler to reload certs
we have to reload directory server certs out of process
2022-11-16 08:24:42 +01:00
Girish Ramakrishnan
15d5dfd406 reverseproxy: move the reload out of the write functions 2022-11-16 07:55:26 +01:00
Girish Ramakrishnan
af870d0eac mail: fix dnsbl count
empty string was parsed as [''] leading the UI to think there is one zone
2022-11-14 22:06:33 +01:00
Girish Ramakrishnan
7b7e5d24de domains: update event not generated 2022-11-14 10:58:47 +01:00
Girish Ramakrishnan
0843baad8b reverseproxy: remove options from renewCerts 2022-11-14 08:13:47 +01:00
Girish Ramakrishnan
5e2a55ecad add debug 2022-11-13 22:10:01 +01:00
Girish Ramakrishnan
c597d9fbaa add fixme 2022-11-13 21:55:13 +01:00
Girish Ramakrishnan
8b43d43e35 reverseproxy: compare the cert path on cert renewal
fqdn will not match for wildcard certs
2022-11-13 18:06:34 +01:00
Girish Ramakrishnan
5447181e41 cert: add some asserts 2022-11-13 17:27:05 +01:00
Girish Ramakrishnan
3caf77cee6 cert: add message for fallback cert 2022-11-13 16:59:22 +01:00
Girish Ramakrishnan
2515a0f18f cert: do not autoclean default cert 2022-11-13 16:56:51 +01:00
Girish Ramakrishnan
9c8f78a059 reverseproxy: simplify certificate renewal
An issue was that mail container was not getting refreshed with the up to
date certs. The root cause is that it is refreshed only in the renewCerts()
cron job. If cert renewal was caused by an app task, then the cron job will
skip the restart (since cert is fresh).

The other issue is that we keep hitting 0 length certs when we run out of disk
space. The root cause is that when out of disk space, a cert renewal will
cause cert to be written but since it has no space it is 0 length. Then, when
the user tries to restart the server, the box code does not write the cert again.

This change fixes the above two including:
* To simplify, we use the fallback cert only if we failed to get a LE cert. Expired LE certs
  will continue to be used. nginx is fine with this.

* restart directory as well on renewal
2022-11-13 11:55:12 +01:00
Girish Ramakrishnan
f917eb8f13 rename variable 2022-11-11 16:21:28 +01:00
Johannes Zellner
d19c7ac3e3 Return repository info in app rest api 2022-11-10 20:00:55 +01:00
Johannes Zellner
f61131babf Amend app.repository depending on presence and value of dockerImage 2022-11-10 18:12:13 +01:00
Girish Ramakrishnan
e9eeab074a Clarify error message further 2022-11-10 13:50:28 +01:00
Girish Ramakrishnan
3477cf474f security: do not password reset mail to cloudron owned mail domain
https://forum.cloudron.io/topic/7951/privilege-escalation-through-mail-manager-role
2022-11-10 12:59:03 +01:00
Girish Ramakrishnan
d49c171c79 mail: fix 100% cpu use with unreachable servers 2022-11-09 23:04:05 +01:00
Johannes Zellner
0035247618 add app repository support 2022-11-09 15:46:00 +01:00
Girish Ramakrishnan
3d6cdf8ff3 run disk usage task once a day 2022-11-09 15:21:53 +01:00
Girish Ramakrishnan
925b08c7a1 Fix log test 2022-11-06 16:17:55 +01:00
Girish Ramakrishnan
440504a6e9 add tests for both the stream 2022-11-06 15:44:04 +01:00
Girish Ramakrishnan
ca44f47af3 replace split with our own LogStream
split module is archived
2022-11-06 13:44:47 +01:00
Girish Ramakrishnan
9dac5e3406 typo 2022-11-06 11:57:45 +01:00
Girish Ramakrishnan
d0b7097706 rimraf is gone 2022-11-06 11:48:56 +01:00
Girish Ramakrishnan
fac0a9ca5d classes are not hoisted 2022-11-06 11:44:43 +01:00
Girish Ramakrishnan
b6f707955c Update packages 2022-11-06 10:27:10 +01:00
Girish Ramakrishnan
962d7030bb replace progress-stream with our implementation
upstream is mostly unmaintained
2022-11-06 10:17:14 +01:00
Girish Ramakrishnan
5af1bbfb3c once: add debug 2022-11-05 15:36:07 +01:00
Girish Ramakrishnan
f2d25ff2fd remove many of the scripts 2022-11-05 15:26:56 +01:00
Girish Ramakrishnan
94327e397a remove ununsed ejs-cli 2022-11-05 15:22:27 +01:00
Girish Ramakrishnan
9f54ec47b6 remove nyc and node-sass modules 2022-11-05 15:20:39 +01:00
Girish Ramakrishnan
cb85336595 remove js-yaml (unused) 2022-11-05 15:19:00 +01:00
Girish Ramakrishnan
b28d559d1a remove unused tar-stream 2022-11-05 15:17:26 +01:00
Girish Ramakrishnan
4918d2099f remove json module (not used) 2022-11-05 15:15:53 +01:00
Girish Ramakrishnan
8a5d4e2fb0 better debugs 2022-11-05 08:43:02 +01:00
Girish Ramakrishnan
aae52ec795 backups: remove periodic dumping of heap info
this has not been as useful as I expected
2022-11-05 08:32:38 +01:00
Girish Ramakrishnan
549cb92ce7 return swap listing in the disk route 2022-11-04 15:25:12 +01:00
Johannes Zellner
c4c90cfaf9 Add route to download app backups 2022-11-04 10:24:12 +01:00
Girish Ramakrishnan
ad3e593f01 mail: disallow more characters in display name 2022-11-04 08:50:47 +01:00
Girish Ramakrishnan
1c4205b714 mount: ignore filesystem type 2022-11-03 23:28:02 +01:00
Girish Ramakrishnan
7a8559ca9e 7.3.3 changes 2022-11-02 22:41:24 +01:00
Girish Ramakrishnan
8bc3b832e7 detect oom in tasks correctly 2022-11-02 22:39:25 +01:00
Girish Ramakrishnan
80a3ca0f46 remove 16.04 related task logic 2022-11-02 21:22:42 +01:00
Girish Ramakrishnan
0f0a98f7ac Add TimeoutStopSec=10s for systemctl kill to work faster 2022-11-02 18:46:20 +01:00
Girish Ramakrishnan
59783eb11b ldap: memberof is a DN and not just group name
https://ldapwiki.com/wiki/MemberOf
https://access.redhat.com/documentation/en-us/red_hat_jboss_operations_network/3.1/html/admin_initial_setup_inventory_groups_and_users/ex-ldap-authz
2022-10-30 15:07:26 +01:00
Girish Ramakrishnan
a2bf9180af relay: office365 wants login AUTH
https://support.microsoft.com/en-us/office/outlook-com-no-longer-supports-auth-plain-authentication-07f7d5e9-1697-465f-84d2-4513d4ff0145
2022-10-27 23:18:43 +02:00
Johannes Zellner
e662cd7c80 If we can't fetch applink upstreamUri, just stop icon and title detection
This may happen for Cloudflare protected domains
2022-10-27 15:41:51 +02:00
Girish Ramakrishnan
2f946de775 make cache folders always writable 2022-10-24 23:58:20 +02:00
Girish Ramakrishnan
d8eb8d23bb manifest: add runtimeDirs 2022-10-24 22:34:06 +02:00
Girish Ramakrishnan
17c7cc5ec7 Remove external df module
It has some parsing issues with locale
2022-10-18 19:56:18 +02:00
100 changed files with 2535 additions and 7589 deletions

42
CHANGES
View File

@@ -2549,3 +2549,45 @@
* postgresql: fix issue when restoring large dumps
* graphs: add cpu/disk/network usage
* graphs: new disk usage UI
* relay: add office 365
[7.3.3]
* Fix oom detection in tasks
* ldap: memberof is a DN and not just group name
* mail relay: office365 provider
* If we can't fetch applink upstreamUri, just stop icon and title detection
* manifest: add runtimeDirs
* remove external df module
* Show remaining disk space in usage graph
* Make users and groups available for the new app link dialog
* Show swaps in disk graphs
* disk usage: run once a day
* mail: fix 100% cpu use with unreachable servers
* security: do not password reset mail to cloudron owned mail domain
* logrotate: only keep 14 days of logs
* mail: fix dnsbl count when all servers are removed
* applink: make users and groups available for the new app link dialog
* Show app disk usage in storage tab
* Make volume read-only checkbox a dropdown
[7.3.4]
* Display platform update status in the UI
* Fix image pruning
* cloudflare: fix issue where incorrect URL configuration is accepted
[7.3.5]
* du: fix crash when filesystem is cifs/nfs/sshfs
* Start with a default to not fail if no swap is present
* Fix bug in cert cleanup logic causing it to repeatedly cleanup
* Fix crash in RBL check
* unbound: disable controller interface explicitly
* Fix issue where cert renewal logs where not displayed
* Fix loading of mailboxes
[7.3.6]
* aws: add melbourne region
* Fix display of box backups
* mail usage: fix issue caused by deleted mailboxes
* reverseproxy: fix issue where renewed certs are not written to disk
* support: fix crash when opening tickets with 0 length files

6
box.js
View File

@@ -49,6 +49,12 @@ async function main() {
// require this here so that logging handler is already setup
const debug = require('debug')('box:box');
process.on('SIGHUP', async function () {
debug('Received SIGHUP. Re-reading configs.');
const conf = await settings.getDirectoryServerConfig();
if (conf.enabled) await directoryServer.checkCertificate();
});
process.on('SIGINT', async function () {
debug('Received SIGINT. Shutting down.');

7356
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -12,13 +12,12 @@
},
"dependencies": {
"@google-cloud/dns": "^2.2.4",
"@google-cloud/storage": "^5.19.2",
"@sindresorhus/df": "git+https://github.com/cloudron-io/df.git#type",
"async": "^3.2.3",
"aws-sdk": "^2.1115.0",
"@google-cloud/storage": "^5.20.5",
"async": "^3.2.4",
"aws-sdk": "^2.1248.0",
"basic-auth": "^2.0.1",
"body-parser": "^1.20.0",
"cloudron-manifestformat": "^5.18.0",
"body-parser": "^1.20.1",
"cloudron-manifestformat": "^5.19.1",
"connect": "^3.7.0",
"connect-lastmile": "^2.1.1",
"connect-timeout": "^1.9.0",
@@ -28,39 +27,33 @@
"db-migrate": "^0.11.13",
"db-migrate-mysql": "^2.2.0",
"debug": "^4.3.4",
"dockerode": "^3.3.1",
"ejs": "^3.1.6",
"ejs-cli": "^2.2.3",
"express": "^4.17.3",
"dockerode": "^3.3.4",
"ejs": "^3.1.8",
"express": "^4.18.2",
"ipaddr.js": "^2.0.1",
"js-yaml": "^4.1.0",
"jsdom": "^20.0.0",
"json": "^11.0.0",
"jsdom": "^20.0.2",
"jsonwebtoken": "^8.5.1",
"ldapjs": "^2.3.2",
"ldapjs": "^2.3.3",
"lodash": "^4.17.21",
"moment": "^2.29.2",
"moment-timezone": "^0.5.34",
"moment": "^2.29.4",
"moment-timezone": "^0.5.38",
"morgan": "^1.10.0",
"multiparty": "^4.2.3",
"mysql": "^2.18.1",
"nodemailer": "^6.7.3",
"progress-stream": "^2.0.0",
"qrcode": "^1.5.0",
"nodemailer": "^6.8.0",
"qrcode": "^1.5.1",
"readdirp": "^3.6.0",
"safetydance": "^2.2.0",
"semver": "^7.3.7",
"semver": "^7.3.8",
"speakeasy": "^2.0.0",
"split": "^1.0.1",
"superagent": "^7.1.1",
"superagent": "^7.1.5",
"tar-fs": "github:cloudron-io/tar-fs#ignore_stat_error",
"tar-stream": "^2.2.0",
"tldjs": "^2.3.1",
"ua-parser-js": "^1.0.2",
"underscore": "^1.13.2",
"ua-parser-js": "^1.0.32",
"underscore": "^1.13.6",
"uuid": "^8.3.2",
"validator": "^13.7.0",
"ws": "^8.5.0",
"ws": "^8.10.0",
"xml2js": "^0.4.23"
},
"devDependencies": {
@@ -69,15 +62,9 @@
"js2xmlparser": "^4.0.2",
"mocha": "^9.2.2",
"mock-aws-s3": "git+https://github.com/cloudron-io/mock-aws-s3.git",
"nock": "^13.2.4",
"node-sass": "^7.0.1",
"nyc": "^15.1.0"
"nock": "^13.2.9"
},
"scripts": {
"test": "./run-tests",
"postmerge": "/bin/true",
"precommit": "/bin/true",
"prepush": "npm test",
"dashboard": "node_modules/.bin/gulp"
"test": "./run-tests"
}
}

View File

@@ -23,7 +23,7 @@ mkdir -p ${DATA_DIR}
cd ${DATA_DIR}
mkdir -p appsdata
mkdir -p boxdata/box boxdata/mail boxdata/certs boxdata/mail/dkim/localhost boxdata/mail/dkim/foobar.com
mkdir -p platformdata/addons/mail/banner platformdata/nginx/cert platformdata/nginx/applications platformdata/collectd/collectd.conf.d platformdata/addons platformdata/logrotate.d platformdata/backup platformdata/logs/tasks platformdata/sftp/ssh platformdata/firewall platformdata/update
mkdir -p platformdata/addons/mail/banner platformdata/nginx/cert platformdata/nginx/applications/dashboard platformdata/collectd/collectd.conf.d platformdata/addons platformdata/logrotate.d platformdata/backup platformdata/logs/tasks platformdata/sftp/ssh platformdata/firewall platformdata/update
sudo mkdir -p /mnt/cloudron-test-music /media/cloudron-test-music # volume test
# translations
@@ -84,10 +84,5 @@ if [[ $# -gt 0 ]]; then
TESTS="$*"
fi
if [[ -z ${COVERAGE+x} ]]; then
echo "=> Run tests with mocha"
BOX_ENV=test ./node_modules/.bin/mocha --bail --no-timeouts --exit -R spec ${TESTS}
else
echo "=> Run tests with mocha and coverage"
BOX_ENV=test ./node_modules/.bin/nyc --reporter=html ./node_modules/.bin/mocha --no-timeouts --exit -R spec ${TESTS}
fi
echo "=> Run tests with mocha"
BOX_ENV=test ./node_modules/.bin/mocha --bail --no-timeouts --exit -R spec ${TESTS}

View File

@@ -236,8 +236,8 @@ while true; do
sleep 10
done
ip4=$(curl -s --fail --connect-timeout 2 --max-time 2 https://ipv4.api.cloudron.io/api/v1/helper/public_ip | sed -n -e 's/.*"ip": "\(.*\)"/\1/p' || true)
ip6=$(curl -s --fail --connect-timeout 2 --max-time 2 https://ipv6.api.cloudron.io/api/v1/helper/public_ip | sed -n -e 's/.*"ip": "\(.*\)"/\1/p' || true)
ip4=$(curl -s --fail --connect-timeout 10 --max-time 10 https://ipv4.api.cloudron.io/api/v1/helper/public_ip | sed -n -e 's/.*"ip": "\(.*\)"/\1/p' || true)
ip6=$(curl -s --fail --connect-timeout 10 --max-time 10 https://ipv6.api.cloudron.io/api/v1/helper/public_ip | sed -n -e 's/.*"ip": "\(.*\)"/\1/p' || true)
url4=""
url6=""

View File

@@ -41,8 +41,8 @@ if ! $(cd "${SOURCE_DIR}/../dashboard" && git diff --exit-code >/dev/null); then
exit 1
fi
if [[ "$(node --version)" != "v16.13.1" ]]; then
echo "This script requires node 16.13.1"
if [[ "$(node --version)" != "v16.18.1" ]]; then
echo "This script requires node 16.18.1"
exit 1
fi

View File

@@ -179,8 +179,8 @@ systemctl disable systemd-resolved || true
# on vultr, ufw is enabled by default. we have our own firewall
ufw disable || true
# we need unbound to work as this is required for installer.sh to do any DNS requests
echo -e "server:\n\tinterface: 127.0.0.1\n" > /etc/unbound/unbound.conf.d/cloudron-network.conf
# we need unbound to work as this is required for installer.sh to do any DNS requests. control-enable is for https://github.com/NLnetLabs/unbound/issues/806
echo -e "server:\n\tinterface: 127.0.0.1\n\nremote-control:\n\tcontrol-enable: no\n" > /etc/unbound/unbound.conf.d/cloudron-network.conf
systemctl restart unbound
# Ubuntu 22 has private home directories by default (https://discourse.ubuntu.com/t/private-home-directories-for-ubuntu-21-04-onwards/)

View File

@@ -72,7 +72,8 @@ readonly is_update=$(systemctl is-active -q box && echo "yes" || echo "no")
log "Updating from $(cat $box_src_dir/VERSION 2>/dev/null) to $(cat $box_src_tmp_dir/VERSION 2>/dev/null)"
# https://docs.docker.com/engine/installation/linux/ubuntulinux/
readonly docker_version=20.10.14
readonly docker_version=20.10.21
readonly containerd_version=1.6.10-1
if ! which docker 2>/dev/null || [[ $(docker version --format {{.Client.Version}}) != "${docker_version}" ]]; then
log "installing/updating docker"
@@ -80,8 +81,8 @@ if ! which docker 2>/dev/null || [[ $(docker version --format {{.Client.Version}
mkdir -p /etc/systemd/system/docker.service.d
echo -e "[Service]\nExecStart=\nExecStart=/usr/bin/dockerd -H fd:// --log-driver=journald --exec-opt native.cgroupdriver=cgroupfs --storage-driver=overlay2 --experimental --ip6tables" > /etc/systemd/system/docker.service.d/cloudron.conf
# there are 3 packages for docker - containerd, CLI and the daemon
$curl -sL "https://download.docker.com/linux/ubuntu/dists/${ubuntu_codename}/pool/stable/amd64/containerd.io_1.5.11-1_amd64.deb" -o /tmp/containerd.deb
# there are 3 packages for docker - containerd, CLI and the daemon (https://download.docker.com/linux/ubuntu/dists/jammy/pool/stable/amd64/)
$curl -sL "https://download.docker.com/linux/ubuntu/dists/${ubuntu_codename}/pool/stable/amd64/containerd.io_${containerd_version}_amd64.deb" -o /tmp/containerd.deb
$curl -sL "https://download.docker.com/linux/ubuntu/dists/${ubuntu_codename}/pool/stable/amd64/docker-ce-cli_${docker_version}~3-0~ubuntu-${ubuntu_codename}_amd64.deb" -o /tmp/docker-ce-cli.deb
$curl -sL "https://download.docker.com/linux/ubuntu/dists/${ubuntu_codename}/pool/stable/amd64/docker-ce_${docker_version}~3-0~ubuntu-${ubuntu_codename}_amd64.deb" -o /tmp/docker.deb
@@ -114,14 +115,14 @@ elif [[ "${ubuntu_version}" == "18.04" ]]; then
fi
fi
readonly node_version=16.14.2
readonly node_version=16.18.1
if ! which node 2>/dev/null || [[ "$(node --version)" != "v${node_version}" ]]; then
log "installing/updating node ${node_version}"
mkdir -p /usr/local/node-${node_version}
$curl -sL https://nodejs.org/dist/v${node_version}/node-v${node_version}-linux-x64.tar.gz | tar zxvf - --strip-components=1 -C /usr/local/node-${node_version}
ln -sf /usr/local/node-${node_version}/bin/node /usr/bin/node
ln -sf /usr/local/node-${node_version}/bin/npm /usr/bin/npm
rm -rf /usr/local/node-16.13.1
rm -rf /usr/local/node-16.14.2
fi
# note that rebuild requires the above node

View File

@@ -20,7 +20,6 @@ readonly BOX_DATA_DIR="${HOME_DIR}/boxdata/box"
readonly MAIL_DATA_DIR="${HOME_DIR}/boxdata/mail"
readonly script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
readonly json="$(realpath ${script_dir}/../node_modules/.bin/json)"
readonly ubuntu_version=$(lsb_release -rs)
cp -f "${script_dir}/../scripts/cloudron-support" /usr/bin/cloudron-support
@@ -57,6 +56,7 @@ mkdir -p "${PLATFORM_DATA_DIR}/mysql"
mkdir -p "${PLATFORM_DATA_DIR}/postgresql"
mkdir -p "${PLATFORM_DATA_DIR}/mongodb"
mkdir -p "${PLATFORM_DATA_DIR}/redis"
mkdir -p "${PLATFORM_DATA_DIR}/tls"
mkdir -p "${PLATFORM_DATA_DIR}/addons/mail/banner" \
"${PLATFORM_DATA_DIR}/addons/mail/dkim"
mkdir -p "${PLATFORM_DATA_DIR}/collectd"
@@ -107,8 +107,6 @@ unbound-anchor -a /var/lib/unbound/root.key
log "Adding systemd services"
cp -r "${script_dir}/start/systemd/." /etc/systemd/system/
[[ "${ubuntu_version}" == "16.04" ]] && sed -e 's/MemoryMax/MemoryLimit/g' -i /etc/systemd/system/box.service
[[ "${ubuntu_version}" == "16.04" ]] && sed -e 's/Type=notify/Type=simple/g' -i /etc/systemd/system/unbound.service
systemctl daemon-reload
systemctl enable --now cloudron-syslog
systemctl enable unbound
@@ -163,7 +161,7 @@ log "Configuring nginx"
# link nginx config to system config
unlink /etc/nginx 2>/dev/null || rm -rf /etc/nginx
ln -s "${PLATFORM_DATA_DIR}/nginx" /etc/nginx
mkdir -p "${PLATFORM_DATA_DIR}/nginx/applications"
mkdir -p "${PLATFORM_DATA_DIR}/nginx/applications/dashboard"
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"
@@ -227,11 +225,14 @@ fi
rm -f /etc/cloudron/cloudron.conf
# 7.3 branch only: we had a bug in 7.3 that renewed certs were not written to disk. this will rebuild nginx/certs in the cron job
touch "${PLATFORM_DATA_DIR}/nginx/rebuild-needed"
log "Changing ownership"
# note, change ownership after db migrate. this allow db migrate to move files around as root and then we can fix it up here
# be careful of what is chown'ed here. subdirs like mysql,redis etc are owned by the containers and will stop working if perms change
chown -R "${USER}" /etc/cloudron
chown "${USER}:${USER}" -R "${PLATFORM_DATA_DIR}/nginx" "${PLATFORM_DATA_DIR}/collectd" "${PLATFORM_DATA_DIR}/addons" "${PLATFORM_DATA_DIR}/acme" "${PLATFORM_DATA_DIR}/backup" "${PLATFORM_DATA_DIR}/logs" "${PLATFORM_DATA_DIR}/update" "${PLATFORM_DATA_DIR}/sftp" "${PLATFORM_DATA_DIR}/firewall" "${PLATFORM_DATA_DIR}/sshfs" "${PLATFORM_DATA_DIR}/cifs"
chown "${USER}:${USER}" -R "${PLATFORM_DATA_DIR}/nginx" "${PLATFORM_DATA_DIR}/collectd" "${PLATFORM_DATA_DIR}/addons" "${PLATFORM_DATA_DIR}/acme" "${PLATFORM_DATA_DIR}/backup" "${PLATFORM_DATA_DIR}/logs" "${PLATFORM_DATA_DIR}/update" "${PLATFORM_DATA_DIR}/sftp" "${PLATFORM_DATA_DIR}/firewall" "${PLATFORM_DATA_DIR}/sshfs" "${PLATFORM_DATA_DIR}/cifs" "${PLATFORM_DATA_DIR}/tls"
chown "${USER}:${USER}" "${PLATFORM_DATA_DIR}/INFRA_VERSION" 2>/dev/null || true
chown "${USER}:${USER}" "${PLATFORM_DATA_DIR}"
chown "${USER}:${USER}" "${APPS_DATA_DIR}"

View File

@@ -1,9 +1,11 @@
# logrotate config for box logs
# keep upto 5 logs of size 10M each
# we rotate weekly, unless 10M was hit. Keep only up to 5 rotated files. Also, delete if > 14 days old
/home/yellowtent/platformdata/logs/box.log {
rotate 5
size 10M
weekly
maxage 14
maxsize 10M
# we never compress so we can simply tail the files
nocompress
copytruncate

View File

@@ -14,7 +14,9 @@
/home/yellowtent/platformdata/logs/updater/*.log {
# only keep one rotated file, we currently do not send that over the api
rotate 1
size 10M
weekly
maxage 14
maxsize 10M
missingok
# we never compress so we can simply tail the files
nocompress
@@ -23,7 +25,7 @@
}
# keep task logs for a week. the 'nocreate' option ensures empty log files are not
# created post rotation
# created post rotation. task logs are kept for 7 days
/home/yellowtent/platformdata/logs/tasks/*.log {
minage 7
daily

View File

@@ -39,4 +39,5 @@ http {
limit_req_zone $binary_remote_addr zone=admin_login:10m rate=10r/s; # 10 request a second
include applications/*.conf;
include applications/*/*.conf;
}

View File

@@ -13,6 +13,7 @@ Type=idle
WorkingDirectory=/home/yellowtent/box
Restart=always
ExecStart=/home/yellowtent/box/box.js
ExecReload=/usr/bin/kill -HUP $MAINPID
; we run commands like df which will parse properly only with correct locale
Environment="HOME=/home/yellowtent" "USER=yellowtent" "DEBUG=box:*,connect-lastmile,-box:ldap" "BOX_ENV=cloudron" "NODE_ENV=production" "LC_ALL=C"
; kill apptask processes as well

View File

@@ -14,3 +14,8 @@ server:
# enable below for logging to journalctl -u unbound
# verbosity: 5
# log-queries: yes
# https://github.com/NLnetLabs/unbound/issues/806
remote-control:
control-enable: no

View File

@@ -17,9 +17,11 @@ const assert = require('assert'),
fs = require('fs'),
os = require('os'),
path = require('path'),
paths = require('./paths.js'),
promiseRetry = require('./promise-retry.js'),
superagent = require('superagent'),
safe = require('safetydance'),
users = require('./users.js'),
_ = require('underscore');
const CA_PROD_DIRECTORY_URL = 'https://acme-v02.api.letsencrypt.org/directory',
@@ -29,16 +31,26 @@ const CA_PROD_DIRECTORY_URL = 'https://acme-v02.api.letsencrypt.org/directory',
// https://www.ietf.org/proceedings/92/slides/slides-92-acme-1.pdf
// https://community.letsencrypt.org/t/list-of-client-implementations/2103
function Acme2(options) {
assert.strictEqual(typeof options, 'object');
function Acme2(fqdn, domainObject, email) {
assert.strictEqual(typeof fqdn, 'string');
assert.strictEqual(typeof domainObject, 'object');
assert.strictEqual(typeof email, 'string');
this.accountKeyPem = null; // Buffer .
this.email = options.email;
this.fqdn = fqdn;
this.accountKey = null;
this.email = email;
this.keyId = null;
this.caDirectory = options.prod ? CA_PROD_DIRECTORY_URL : CA_STAGING_DIRECTORY_URL;
const prod = domainObject.tlsConfig.provider.match(/.*-prod/) !== null; // matches 'le-prod' or 'letsencrypt-prod'
this.caDirectory = prod ? CA_PROD_DIRECTORY_URL : CA_STAGING_DIRECTORY_URL;
this.directory = {};
this.performHttpAuthorization = !!options.performHttpAuthorization;
this.wildcard = !!options.wildcard;
this.performHttpAuthorization = domainObject.provider.match(/noop|manual|wildcard/) !== null;
this.wildcard = !!domainObject.tlsConfig.wildcard;
this.domain = domainObject.domain;
this.cn = fqdn !== this.domain && this.wildcard ? dns.makeWildcard(fqdn) : fqdn; // bare domain is not part of wildcard SAN
this.certName = this.cn.replace('*.', '_.');
debug(`Acme2: will get cert for fqdn: ${this.fqdn} cn: ${this.cn} certName: ${this.certName} wildcard: ${this.wildcard} http: ${this.performHttpAuthorization}`);
}
// urlsafe base64 encoding (jose)
@@ -52,7 +64,7 @@ function b64(str) {
}
function getModulus(pem) {
assert(Buffer.isBuffer(pem));
assert.strictEqual(typeof pem, 'string');
const stdout = safe.child_process.execSync('openssl rsa -modulus -noout', { input: pem, encoding: 'utf8' });
if (!stdout) return null;
@@ -64,8 +76,7 @@ function getModulus(pem) {
Acme2.prototype.sendSignedRequest = async function (url, payload) {
assert.strictEqual(typeof url, 'string');
assert.strictEqual(typeof payload, 'string');
assert(Buffer.isBuffer(this.accountKeyPem));
assert.strictEqual(typeof this.accountKey, 'string');
const that = this;
let header = {
@@ -80,7 +91,7 @@ Acme2.prototype.sendSignedRequest = async function (url, payload) {
header.jwk = {
e: b64(Buffer.from([0x01, 0x00, 0x01])), // exponent - 65537
kty: 'RSA',
n: b64(getModulus(this.accountKeyPem))
n: b64(getModulus(this.accountKey))
};
}
@@ -99,7 +110,7 @@ Acme2.prototype.sendSignedRequest = async function (url, payload) {
const signer = crypto.createSign('RSA-SHA256');
signer.update(protected64 + '.' + payload64, 'utf8');
const signature64 = urlBase64Encode(signer.sign(that.accountKeyPem, 'base64'));
const signature64 = urlBase64Encode(signer.sign(that.accountKey, 'base64'));
const data = {
protected: protected64,
@@ -135,7 +146,7 @@ Acme2.prototype.updateContact = async function (registrationUri) {
};
async function generateAccountKey() {
const acmeAccountKey = safe.child_process.execSync('openssl genrsa 4096');
const acmeAccountKey = safe.child_process.execSync('openssl genrsa 4096', { encoding: 'utf8' });
if (!acmeAccountKey) throw new BoxError(BoxError.OPENSSL_ERROR, `Could not generate acme account key: ${safe.error.message}`);
return acmeAccountKey;
}
@@ -147,18 +158,18 @@ Acme2.prototype.ensureAccount = async function () {
debug('ensureAccount: registering user');
this.accountKeyPem = await blobs.get(blobs.ACME_ACCOUNT_KEY);
if (!this.accountKeyPem) {
this.accountKey = await blobs.getString(blobs.ACME_ACCOUNT_KEY);
if (!this.accountKey) {
debug('ensureAccount: generating new account keys');
this.accountKeyPem = await generateAccountKey();
await blobs.set(blobs.ACME_ACCOUNT_KEY, this.accountKeyPem);
this.accountKey = await generateAccountKey();
await blobs.setString(blobs.ACME_ACCOUNT_KEY, this.accountKey);
}
let result = await this.sendSignedRequest(this.directory.newAccount, JSON.stringify(payload));
if (result.status === 403 && result.body.type === 'urn:ietf:params:acme:error:unauthorized') {
debug(`ensureAccount: key was revoked. ${result.status} ${JSON.stringify(result.body)}. generating new account key`);
this.accountKeyPem = await generateAccountKey();
await blobs.set(blobs.ACME_ACCOUNT_KEY, this.accountKeyPem);
this.accountKey = await generateAccountKey();
await blobs.setString(blobs.ACME_ACCOUNT_KEY, this.accountKey);
result = await this.sendSignedRequest(this.directory.newAccount, JSON.stringify(payload));
}
@@ -172,23 +183,21 @@ Acme2.prototype.ensureAccount = async function () {
await this.updateContact(result.headers.location);
};
Acme2.prototype.newOrder = async function (domain) {
assert.strictEqual(typeof domain, 'string');
Acme2.prototype.newOrder = async function () {
const payload = {
identifiers: [{
type: 'dns',
value: domain
value: this.cn
}]
};
debug(`newOrder: ${domain}`);
debug(`newOrder: ${this.cn}`);
const result = await this.sendSignedRequest(this.directory.newOrder, JSON.stringify(payload));
if (result.status === 403) throw new BoxError(BoxError.ACCESS_DENIED, `Forbidden sending new order: ${result.body.detail}`);
if (result.status !== 201) throw new BoxError(BoxError.ACME_ERROR, `Failed to send new order. Expecting 201, got ${result.statusCode} ${JSON.stringify(result.body)}`);
debug('newOrder: created order %s %j', domain, result.body);
debug(`newOrder: created order ${this.cn} %j`, result.body);
const order = result.body, orderUrl = result.headers.location;
@@ -222,12 +231,12 @@ Acme2.prototype.waitForOrder = async function (orderUrl) {
};
Acme2.prototype.getKeyAuthorization = function (token) {
assert(Buffer.isBuffer(this.accountKeyPem));
assert(typeof this.accountKey, 'string');
let jwk = {
e: b64(Buffer.from([0x01, 0x00, 0x01])), // Exponent - 65537
kty: 'RSA',
n: b64(getModulus(this.accountKeyPem))
n: b64(getModulus(this.accountKey))
};
let shasum = crypto.createHash('sha256');
@@ -275,10 +284,12 @@ Acme2.prototype.waitForChallenge = async function (challenge) {
};
// https://community.letsencrypt.org/t/public-beta-rate-limits/4772 for rate limits
Acme2.prototype.signCertificate = async function (domain, finalizationUrl, csrDer) {
assert.strictEqual(typeof domain, 'string');
Acme2.prototype.signCertificate = async function (finalizationUrl, csrPem) {
assert.strictEqual(typeof finalizationUrl, 'string');
assert(Buffer.isBuffer(csrDer));
assert.strictEqual(typeof csrPem, 'string');
const csrDer = safe.child_process.execSync('openssl req -inform pem -outform der', { input: csrPem });
if (!csrDer) throw new BoxError(BoxError.OPENSSL_ERROR, safe.error);
const payload = {
csr: b64(csrDer)
@@ -291,22 +302,28 @@ Acme2.prototype.signCertificate = async function (domain, finalizationUrl, csrDe
if (result.status !== 200) throw new BoxError(BoxError.ACME_ERROR, `Failed to sign certificate. Expecting 200, got ${result.status} ${JSON.stringify(result.body)}`);
};
Acme2.prototype.createKeyAndCsr = async function (hostname, keyFilePath, csrFilePath) {
assert.strictEqual(typeof hostname, 'string');
if (safe.fs.existsSync(keyFilePath)) {
debug('createKeyAndCsr: reuse the key for renewal at %s', keyFilePath);
} else {
let key = safe.child_process.execSync('openssl ecparam -genkey -name secp384r1'); // openssl ecparam -list_curves
if (!key) throw new BoxError(BoxError.OPENSSL_ERROR, safe.error);
if (!safe.fs.writeFileSync(keyFilePath, key)) throw new BoxError(BoxError.FS_ERROR, safe.error);
debug('createKeyAndCsr: key file saved at %s', keyFilePath);
Acme2.prototype.ensureKey = async function () {
const key = await blobs.getString(`${blobs.CERT_PREFIX}-${this.certName}.key`);
if (key) {
debug(`ensureKey: reuse existing key for ${this.cn}`);
return key;
}
debug(`ensureKey: generating new key for ${this.cn}`);
const newKey = safe.child_process.execSync('openssl ecparam -genkey -name secp384r1', { encoding: 'utf8' }); // openssl ecparam -list_curves
if (!newKey) throw new BoxError(BoxError.OPENSSL_ERROR, safe.error);
return newKey;
};
Acme2.prototype.createCsr = async function (key) {
assert.strictEqual(typeof key, 'string');
const [error, tmpdir] = await safe(fs.promises.mkdtemp(path.join(os.tmpdir(), 'acme-')));
if (error) throw new BoxError(BoxError.FS_ERROR, `Error creating temporary directory for openssl config: ${error.message}`);
const keyFilePath = path.join(tmpdir, 'key');
if (!safe.fs.writeFileSync(keyFilePath, key)) throw new BoxError(BoxError.FS_ERROR, `Failed to write key file: ${safe.error.message}`);
// OCSP must-staple is currently disabled because nginx does not provide staple on the first request (https://forum.cloudron.io/topic/4917/ocsp-stapling-for-tls-ssl/)
// ' -addext "tlsfeature = status_request"'; // this adds OCSP must-staple
// we used to use -addext to the CLI to add these but that arg doesn't work on Ubuntu 16.04
@@ -314,47 +331,37 @@ Acme2.prototype.createKeyAndCsr = async function (hostname, keyFilePath, csrFile
const conf = '[req]\nreq_extensions = v3_req\ndistinguished_name = req_distinguished_name\n'
+ '[req_distinguished_name]\n\n'
+ '[v3_req]\nbasicConstraints = CA:FALSE\nkeyUsage = nonRepudiation, digitalSignature, keyEncipherment\nsubjectAltName = @alt_names\n'
+ `[alt_names]\nDNS.1 = ${hostname}\n`;
+ `[alt_names]\nDNS.1 = ${this.cn}\n`;
const opensslConfigFile = path.join(tmpdir, 'openssl.conf');
if (!safe.fs.writeFileSync(opensslConfigFile, conf)) throw new BoxError(BoxError.FS_ERROR, `Failed to write openssl config: ${safe.error.message}`);
// while we pass the CN anyways, subjectAltName takes precedence
const csrDer = safe.child_process.execSync(`openssl req -new -key ${keyFilePath} -outform DER -subj /CN=${hostname} -config ${opensslConfigFile}`);
if (!csrDer) throw new BoxError(BoxError.OPENSSL_ERROR, safe.error);
if (!safe.fs.writeFileSync(csrFilePath, csrDer)) throw new BoxError(BoxError.FS_ERROR, safe.error); // bookkeeping. inspect with openssl req -text -noout -in hostname.csr -inform der
const csrPem = safe.child_process.execSync(`openssl req -new -key ${keyFilePath} -outform PEM -subj /CN=${this.cn} -config ${opensslConfigFile}`, { encoding: 'utf8' });
if (!csrPem) throw new BoxError(BoxError.OPENSSL_ERROR, safe.error);
await safe(fs.promises.rm(tmpdir, { recursive: true, force: true }));
debug('createKeyAndCsr: csr file (DER) saved at %s', csrFilePath);
return csrDer;
debug(`createCsr: csr file created for ${this.cn}`);
return csrPem; // inspect with openssl req -text -noout -in hostname.csr -inform pem
};
Acme2.prototype.downloadCertificate = async function (hostname, certUrl, certFilePath) {
assert.strictEqual(typeof hostname, 'string');
Acme2.prototype.downloadCertificate = async function (certUrl) {
assert.strictEqual(typeof certUrl, 'string');
await promiseRetry({ times: 5, interval: 20000, debug }, async () => {
debug(`downloadCertificate: downloading certificate of ${hostname}`);
return await promiseRetry({ times: 5, interval: 20000, debug }, async () => {
debug(`downloadCertificate: downloading certificate of ${this.cn}`);
const result = await this.postAsGet(certUrl);
if (result.statusCode === 202) throw new BoxError(BoxError.ACME_ERROR, 'Retry downloading certificate');
if (result.statusCode !== 200) throw new BoxError(BoxError.ACME_ERROR, `Failed to get cert. Expecting 200, got ${result.statusCode} ${JSON.stringify(result.body)}`);
const fullChainPem = result.body; // buffer
if (!safe.fs.writeFileSync(certFilePath, fullChainPem)) throw new BoxError(BoxError.FS_ERROR, safe.error);
debug(`downloadCertificate: cert file for ${hostname} saved at ${certFilePath}`);
const fullChainPem = result.body.toString('utf8'); // buffer
return fullChainPem;
});
};
Acme2.prototype.prepareHttpChallenge = async function (hostname, domain, authorization, acmeChallengesDir) {
assert.strictEqual(typeof hostname, 'string');
assert.strictEqual(typeof domain, 'string');
Acme2.prototype.prepareHttpChallenge = async function (authorization) {
assert.strictEqual(typeof authorization, 'object');
assert.strictEqual(typeof acmeChallengesDir, 'string');
debug('prepareHttpChallenge: challenges: %j', authorization);
let httpChallenges = authorization.challenges.filter(function(x) { return x.type === 'http-01'; });
@@ -365,44 +372,39 @@ Acme2.prototype.prepareHttpChallenge = async function (hostname, domain, authori
let keyAuthorization = this.getKeyAuthorization(challenge.token);
debug('prepareHttpChallenge: writing %s to %s', keyAuthorization, path.join(acmeChallengesDir, challenge.token));
debug('prepareHttpChallenge: writing %s to %s', keyAuthorization, path.join(paths.ACME_CHALLENGES_DIR, challenge.token));
if (!safe.fs.writeFileSync(path.join(acmeChallengesDir, challenge.token), keyAuthorization)) throw new BoxError(BoxError.FS_ERROR, `Error writing challenge: ${safe.error.message}`);
if (!safe.fs.writeFileSync(path.join(paths.ACME_CHALLENGES_DIR, challenge.token), keyAuthorization)) throw new BoxError(BoxError.FS_ERROR, `Error writing challenge: ${safe.error.message}`);
return challenge;
};
Acme2.prototype.cleanupHttpChallenge = async function (hostname, domain, challenge, acmeChallengesDir) {
assert.strictEqual(typeof hostname, 'string');
assert.strictEqual(typeof domain, 'string');
Acme2.prototype.cleanupHttpChallenge = async function (challenge) {
assert.strictEqual(typeof challenge, 'object');
assert.strictEqual(typeof acmeChallengesDir, 'string');
debug('cleanupHttpChallenge: unlinking %s', path.join(acmeChallengesDir, challenge.token));
debug('cleanupHttpChallenge: unlinking %s', path.join(paths.ACME_CHALLENGES_DIR, challenge.token));
if (!safe.fs.unlinkSync(path.join(acmeChallengesDir, challenge.token))) throw new BoxError(BoxError.FS_ERROR, `Error unlinking challenge: ${safe.error.message}`);
if (!safe.fs.unlinkSync(path.join(paths.ACME_CHALLENGES_DIR, challenge.token))) throw new BoxError(BoxError.FS_ERROR, `Error unlinking challenge: ${safe.error.message}`);
};
function getChallengeSubdomain(hostname, domain) {
function getChallengeSubdomain(cn, domain) {
let challengeSubdomain;
if (hostname === domain) {
if (cn === domain) {
challengeSubdomain = '_acme-challenge';
} else if (hostname.includes('*')) { // wildcard
let subdomain = hostname.slice(0, -domain.length - 1);
} else if (cn.includes('*')) { // wildcard
let subdomain = cn.slice(0, -domain.length - 1);
challengeSubdomain = subdomain ? subdomain.replace('*', '_acme-challenge') : '_acme-challenge';
} else {
challengeSubdomain = '_acme-challenge.' + hostname.slice(0, -domain.length - 1);
challengeSubdomain = '_acme-challenge.' + cn.slice(0, -domain.length - 1);
}
debug(`getChallengeSubdomain: challenge subdomain for hostname ${hostname} at domain ${domain} is ${challengeSubdomain}`);
debug(`getChallengeSubdomain: challenge subdomain for cn ${cn} at domain ${domain} is ${challengeSubdomain}`);
return challengeSubdomain;
}
Acme2.prototype.prepareDnsChallenge = async function (hostname, domain, authorization) {
assert.strictEqual(typeof hostname, 'string');
assert.strictEqual(typeof domain, 'string');
Acme2.prototype.prepareDnsChallenge = async function (authorization) {
assert.strictEqual(typeof authorization, 'object');
debug('prepareDnsChallenge: challenges: %j', authorization);
@@ -415,39 +417,34 @@ Acme2.prototype.prepareDnsChallenge = async function (hostname, domain, authoriz
shasum.update(keyAuthorization);
const txtValue = urlBase64Encode(shasum.digest('base64'));
const challengeSubdomain = getChallengeSubdomain(hostname, domain);
const challengeSubdomain = getChallengeSubdomain(this.cn, this.domain);
debug(`prepareDnsChallenge: update ${challengeSubdomain} with ${txtValue}`);
await dns.upsertDnsRecords(challengeSubdomain, domain, 'TXT', [ `"${txtValue}"` ]);
await dns.upsertDnsRecords(challengeSubdomain, this.domain, 'TXT', [ `"${txtValue}"` ]);
await dns.waitForDnsRecord(challengeSubdomain, domain, 'TXT', txtValue, { times: 200 });
await dns.waitForDnsRecord(challengeSubdomain, this.domain, 'TXT', txtValue, { times: 200 });
return challenge;
};
Acme2.prototype.cleanupDnsChallenge = async function (hostname, domain, challenge) {
assert.strictEqual(typeof hostname, 'string');
assert.strictEqual(typeof domain, 'string');
Acme2.prototype.cleanupDnsChallenge = async function (challenge) {
assert.strictEqual(typeof challenge, 'object');
const keyAuthorization = this.getKeyAuthorization(challenge.token);
let shasum = crypto.createHash('sha256');
const shasum = crypto.createHash('sha256');
shasum.update(keyAuthorization);
const txtValue = urlBase64Encode(shasum.digest('base64'));
let challengeSubdomain = getChallengeSubdomain(hostname, domain);
const challengeSubdomain = getChallengeSubdomain(this.cn, this.domain);
debug(`cleanupDnsChallenge: remove ${challengeSubdomain} with ${txtValue}`);
await dns.removeDnsRecords(challengeSubdomain, domain, 'TXT', [ `"${txtValue}"` ]);
await dns.removeDnsRecords(challengeSubdomain, this.domain, 'TXT', [ `"${txtValue}"` ]);
};
Acme2.prototype.prepareChallenge = async function (hostname, domain, authorizationUrl, acmeChallengesDir) {
assert.strictEqual(typeof hostname, 'string');
assert.strictEqual(typeof domain, 'string');
Acme2.prototype.prepareChallenge = async function (authorizationUrl) {
assert.strictEqual(typeof authorizationUrl, 'string');
assert.strictEqual(typeof acmeChallengesDir, 'string');
debug(`prepareChallenge: http: ${this.performHttpAuthorization}`);
@@ -457,55 +454,49 @@ Acme2.prototype.prepareChallenge = async function (hostname, domain, authorizati
const authorization = response.body;
if (this.performHttpAuthorization) {
return await this.prepareHttpChallenge(hostname, domain, authorization, acmeChallengesDir);
return await this.prepareHttpChallenge(authorization);
} else {
return await this.prepareDnsChallenge(hostname, domain, authorization);
return await this.prepareDnsChallenge(authorization);
}
};
Acme2.prototype.cleanupChallenge = async function (hostname, domain, challenge, acmeChallengesDir) {
assert.strictEqual(typeof hostname, 'string');
assert.strictEqual(typeof domain, 'string');
Acme2.prototype.cleanupChallenge = async function (challenge) {
assert.strictEqual(typeof challenge, 'object');
assert.strictEqual(typeof acmeChallengesDir, 'string');
debug(`cleanupChallenge: http: ${this.performHttpAuthorization}`);
if (this.performHttpAuthorization) {
await this.cleanupHttpChallenge(hostname, domain, challenge, acmeChallengesDir);
await this.cleanupHttpChallenge(challenge);
} else {
await this.cleanupDnsChallenge(hostname, domain, challenge);
await this.cleanupDnsChallenge(challenge);
}
};
Acme2.prototype.acmeFlow = async function (hostname, domain, paths) {
assert.strictEqual(typeof hostname, 'string');
assert.strictEqual(typeof domain, 'string');
assert.strictEqual(typeof paths, 'object');
const { certFilePath, keyFilePath, csrFilePath, acmeChallengesDir } = paths;
Acme2.prototype.acmeFlow = async function () {
await this.ensureAccount();
const { order, orderUrl } = await this.newOrder(hostname);
const { order, orderUrl } = await this.newOrder();
const certificates = [];
for (let i = 0; i < order.authorizations.length; i++) {
const authorizationUrl = order.authorizations[i];
debug(`acmeFlow: authorizing ${authorizationUrl}`);
const challenge = await this.prepareChallenge(hostname, domain, authorizationUrl, acmeChallengesDir);
const challenge = await this.prepareChallenge(authorizationUrl);
await this.notifyChallengeReady(challenge);
await this.waitForChallenge(challenge);
const csrDer = await this.createKeyAndCsr(hostname, keyFilePath, csrFilePath);
await this.signCertificate(hostname, order.finalize, csrDer);
const key = await this.ensureKey();
const csr = await this.createCsr(key);
await this.signCertificate(order.finalize, csr);
const certUrl = await this.waitForOrder(orderUrl);
await this.downloadCertificate(hostname, certUrl, certFilePath);
const cert = await this.downloadCertificate(certUrl);
try {
await this.cleanupChallenge(hostname, domain, challenge, acmeChallengesDir);
} catch (cleanupError) {
debug('acmeFlow: ignoring error when cleaning up challenge:', cleanupError);
}
await safe(this.cleanupChallenge(challenge), { debug });
certificates.push({ cert, key, csr });
}
return certificates;
};
Acme2.prototype.loadDirectory = async function () {
@@ -522,32 +513,36 @@ Acme2.prototype.loadDirectory = async function () {
});
};
Acme2.prototype.getCertificate = async function (fqdn, domain, paths) {
assert.strictEqual(typeof fqdn, 'string');
assert.strictEqual(typeof domain, 'string');
assert.strictEqual(typeof paths, 'object');
debug(`getCertificate: start acme flow for ${fqdn} from ${this.caDirectory}`);
if (fqdn !== domain && this.wildcard) { // bare domain is not part of wildcard SAN
fqdn = dns.makeWildcard(fqdn);
debug(`getCertificate: will get wildcard cert for ${fqdn}`);
}
Acme2.prototype.getCertificate = async function () {
debug(`getCertificate: start acme flow for ${this.cn} from ${this.caDirectory}`);
await this.loadDirectory();
await this.acmeFlow(fqdn, domain, paths);
const result = await this.acmeFlow();
debug(`getCertificate: acme flow completed for ${this.cn}. result: ${result.length}`);
await blobs.setString(`${blobs.CERT_PREFIX}-${this.certName}.key`, result[0].key);
await blobs.setString(`${blobs.CERT_PREFIX}-${this.certName}.cert`, result[0].cert);
await blobs.setString(`${blobs.CERT_PREFIX}-${this.certName}.csr`, result[0].csr);
return result[0];
};
async function getCertificate(fqdn, domain, paths, options) {
async function getCertificate(fqdn, domainObject) {
assert.strictEqual(typeof fqdn, 'string'); // this can also be a wildcard domain (for alias domains)
assert.strictEqual(typeof domain, 'string');
assert.strictEqual(typeof paths, 'object');
assert.strictEqual(typeof options, 'object');
assert.strictEqual(typeof domainObject, 'object');
await promiseRetry({ times: 3, interval: 0, debug }, async function () {
debug(`getCertificate: for fqdn ${fqdn} and domain ${domain}`);
// registering user with an email requires A or MX record (https://github.com/letsencrypt/boulder/issues/1197)
// we cannot use admin@fqdn because the user might not have set it up.
// we simply update the account with the latest email we have each time when getting letsencrypt certs
// https://github.com/ietf-wg-acme/acme/issues/30
const owner = await users.getOwner();
const email = owner?.email || 'webmaster@cloudron.io'; // can error if not activated yet
const acme = new Acme2(options || { });
return await acme.getCertificate(fqdn, domain, paths);
return await promiseRetry({ times: 3, interval: 0, debug }, async function () {
debug(`getCertificate: for fqdn ${fqdn} and domain ${domainObject.domain}`);
const acme = new Acme2(fqdn, domainObject, email);
return await acme.getCertificate();
});
}

View File

@@ -172,10 +172,10 @@ async function processApp(options) {
await Promise.allSettled(healthChecks); // wait for all promises to finish
const alive = allApps
.filter(function (a) { return a.installationState === apps.ISTATE_INSTALLED && a.runState === apps.RSTATE_RUNNING && a.health === apps.HEALTH_HEALTHY; });
const stopped = allApps.filter(app => app.runState === apps.RSTATE_STOPPED);
const running = allApps.filter(function (a) { return a.installationState === apps.ISTATE_INSTALLED && a.runState === apps.RSTATE_RUNNING && a.health === apps.HEALTH_HEALTHY; });
debug(`app health: ${alive.length} alive / ${allApps.length - alive.length} dead.`);
debug(`app health: ${running.length} running / ${stopped.length} stopped / ${allApps.length - running.length - stopped.length} unresponsive`);
}
async function run(intervalSecs) {

View File

@@ -74,7 +74,10 @@ async function detectMetaInfo(applink) {
assert.strictEqual(typeof applink, 'object');
const [error, response] = await safe(superagent.get(applink.upstreamUri).set('User-Agent', 'Mozilla'));
if (error || !response.text) throw new BoxError(BoxError.BAD_FIELD, 'cannot fetch upstream uri for favicon and label');
if (error || !response.text) {
debug('detectMetaInfo: Unable to fetch upstreamUri to detect icon and title', error.statusCode);
return;
}
if (applink.favicon && applink.label) return;

View File

@@ -56,6 +56,7 @@ exports = module.exports = {
backup,
listBackups,
updateBackup,
getBackupDownloadStream,
getTask,
getLogPaths,
@@ -63,8 +64,6 @@ exports = module.exports = {
appendLogLine,
getCertificate,
start,
stop,
restart,
@@ -137,9 +136,19 @@ exports = module.exports = {
LOCATION_TYPE_REDIRECT: 'redirect',
LOCATION_TYPE_ALIAS: 'alias',
// should probably be in table as well
LOCATION_TYPE_DASHBOARD: 'dashboard',
LOCATION_TYPE_MAIL: 'mail',
LOCATION_TYPE_DIRECTORY_SERVER: 'directoryserver',
// respositories, match with appstore
REPOSITORY_CORE: 'core',
REPOSITORY_COMMUNITY: 'community',
// exported for testing
_validatePortBindings: validatePortBindings,
_validateAccessRestriction: validateAccessRestriction,
_validateUpstreamUri: validateUpstreamUri,
_translatePortBindings: translatePortBindings,
_parseCrontab: parseCrontab,
_clear: clear
@@ -159,6 +168,7 @@ const appstore = require('./appstore.js'),
domains = require('./domains.js'),
eventlog = require('./eventlog.js'),
fs = require('fs'),
LogStream = require('./log-stream.js'),
mail = require('./mail.js'),
manifestFormat = require('cloudron-manifestformat'),
mounts = require('./mounts.js'),
@@ -174,10 +184,11 @@ const appstore = require('./appstore.js'),
settings = require('./settings.js'),
shell = require('./shell.js'),
spawn = require('child_process').spawn,
split = require('split'),
storage = require('./storage.js'),
superagent = require('superagent'),
system = require('./system.js'),
tasks = require('./tasks.js'),
tgz = require('./backupformat/tgz.js'),
TransformStream = require('stream').Transform,
users = require('./users.js'),
util = require('util'),
@@ -195,6 +206,7 @@ const APPS_FIELDS_PREFIXED = [ 'apps.id', 'apps.appStoreId', 'apps.installationS
'apps.storageVolumeId', 'apps.storageVolumePrefix', 'apps.ts', 'apps.healthTime', '(apps.icon IS NOT NULL) AS hasIcon', '(apps.appStoreIcon IS NOT NULL) AS hasAppStoreIcon' ].join(',');
// const PORT_BINDINGS_FIELDS = [ 'hostPort', 'type', 'environmentVariable', 'appId' ].join(',');
const LOCATION_FIELDS = [ 'appId', 'subdomain', 'domain', 'type', 'certificateJson' ];
const CHECKVOLUME_CMD = path.join(__dirname, 'scripts/checkvolume.sh');
@@ -459,7 +471,7 @@ function validateBackupFormat(format) {
function validateUpstreamUri(upstreamUri) {
assert.strictEqual(typeof upstreamUri, 'string');
if (!upstreamUri) return null;
if (!upstreamUri) return new BoxError(BoxError.BAD_FIELD, 'upstreamUri cannot be empty');
const uri = safe(() => new URL(upstreamUri));
if (!uri) return new BoxError(BoxError.BAD_FIELD, `upstreamUri is invalid: ${safe.error.message}`);
@@ -527,10 +539,9 @@ async function checkStorage(app, volumeId, prefix) {
return null;
}
function getDuplicateErrorDetails(errorMessage, locations, domainObjectMap, portBindings) {
function getDuplicateErrorDetails(errorMessage, locations, portBindings) {
assert.strictEqual(typeof errorMessage, 'string');
assert(Array.isArray(locations));
assert.strictEqual(typeof domainObjectMap, 'object');
assert.strictEqual(typeof portBindings, 'object');
const match = errorMessage.match(/ER_DUP_ENTRY: Duplicate entry '(.*)' for key '(.*)'/);
@@ -545,7 +556,7 @@ function getDuplicateErrorDetails(errorMessage, locations, domainObjectMap, port
const { subdomain, domain, type } = locations[i];
if (match[1] !== `${subdomain}-${domain}`) continue;
return new BoxError(BoxError.ALREADY_EXISTS, `${type} location '${dns.fqdn(subdomain, domainObjectMap[domain])}' is in use`);
return new BoxError(BoxError.ALREADY_EXISTS, `${type} location '${dns.fqdn(subdomain, domain)}' is in use`);
}
}
@@ -584,7 +595,7 @@ function removeInternalFields(app) {
'accessRestriction', 'manifest', 'portBindings', 'iconUrl', 'memoryLimit', 'cpuShares', 'operators',
'sso', 'debugMode', 'reverseProxyConfig', 'enableBackup', 'creationTime', 'updateTime', 'ts', 'tags',
'label', 'secondaryDomains', 'redirectDomains', 'aliasDomains', 'env', 'enableAutomaticUpdate',
'storageVolumeId', 'storageVolumePrefix', 'mounts',
'storageVolumeId', 'storageVolumePrefix', 'mounts', 'repository',
'enableMailbox', 'mailboxDisplayName', 'mailboxName', 'mailboxDomain', 'enableInbox', 'inboxName', 'inboxDomain');
removeCertificateKeys(result);
@@ -594,7 +605,7 @@ function removeInternalFields(app) {
// non-admins can only see these
function removeRestrictedFields(app) {
const result = _.pick(app,
'id', 'appStoreId', 'installationState', 'error', 'runState', 'health', 'taskId', 'accessRestriction',
'id', 'appStoreId', 'installationState', 'error', 'runState', 'health', 'taskId', 'accessRestriction', 'repository',
'secondaryDomains', 'redirectDomains', 'aliasDomains', 'sso', 'subdomain', 'domain', 'fqdn', 'certificate',
'manifest', 'portBindings', 'iconUrl', 'creationTime', 'ts', 'tags', 'label', 'enableBackup', 'upstreamUri');
@@ -744,6 +755,11 @@ function postProcess(result) {
delete result.errorJson;
result.taskId = result.taskId ? String(result.taskId) : null;
// package repository is currently determined by dockerImage
if (!result.manifest.dockerImage) result.repository = '';
else if (result.manifest.dockerImage.startsWith('cloudron/')) result.repository = exports.REPOSITORY_CORE;
else result.repository = exports.REPOSITORY_COMMUNITY;
}
function attachProperties(app, domainObjectMap) {
@@ -756,10 +772,10 @@ function attachProperties(app, domainObjectMap) {
}
app.portBindings = result;
app.iconUrl = app.hasIcon || app.hasAppStoreIcon ? `/api/v1/apps/${app.id}/icon` : null;
app.fqdn = dns.fqdn(app.subdomain, domainObjectMap[app.domain]);
app.secondaryDomains.forEach(function (ad) { ad.fqdn = dns.fqdn(ad.subdomain, domainObjectMap[ad.domain]); });
app.redirectDomains.forEach(function (ad) { ad.fqdn = dns.fqdn(ad.subdomain, domainObjectMap[ad.domain]); });
app.aliasDomains.forEach(function (ad) { ad.fqdn = dns.fqdn(ad.subdomain, domainObjectMap[ad.domain]); });
app.fqdn = dns.fqdn(app.subdomain, app.domain);
app.secondaryDomains.forEach(function (ad) { ad.fqdn = dns.fqdn(ad.subdomain, ad.domain); });
app.redirectDomains.forEach(function (ad) { ad.fqdn = dns.fqdn(ad.subdomain, ad.domain); });
app.aliasDomains.forEach(function (ad) { ad.fqdn = dns.fqdn(ad.subdomain, ad.domain); });
}
function isAdmin(user) {
@@ -1055,13 +1071,6 @@ async function clear() {
await database.query('DELETE FROM apps');
}
async function getDomainObjectMap() {
const domainObjects = await domains.list();
let domainObjectMap = {};
for (let d of domainObjects) { domainObjectMap[d.domain] = d; }
return domainObjectMap;
}
// each query simply join apps table with another table by id. we then join the full result together
const PB_QUERY = 'SELECT id, GROUP_CONCAT(CAST(appPortBindings.hostPort AS CHAR(6))) AS hostPorts, GROUP_CONCAT(appPortBindings.environmentVariable) AS environmentVariables, GROUP_CONCAT(appPortBindings.type) AS portTypes FROM apps LEFT JOIN appPortBindings ON apps.id = appPortBindings.appId GROUP BY apps.id';
const ENV_QUERY = 'SELECT id, JSON_ARRAYAGG(appEnvVars.name) AS envNames, JSON_ARRAYAGG(appEnvVars.value) AS envValues FROM apps LEFT JOIN appEnvVars ON apps.id = appEnvVars.appId GROUP BY apps.id';
@@ -1076,7 +1085,7 @@ const APPS_QUERY = `SELECT ${APPS_FIELDS_PREFIXED}, hostPorts, environmentVariab
async function get(id) {
assert.strictEqual(typeof id, 'string');
const domainObjectMap = await getDomainObjectMap();
const domainObjectMap = await domains.getDomainObjectMap();
const result = await database.query(`${APPS_QUERY} WHERE apps.id = ?`, [ id ]);
if (result.length === 0) return null;
@@ -1091,7 +1100,7 @@ async function get(id) {
async function getByIpAddress(ip) {
assert.strictEqual(typeof ip, 'string');
const domainObjectMap = await getDomainObjectMap();
const domainObjectMap = await domains.getDomainObjectMap();
const result = await database.query(`${APPS_QUERY} WHERE apps.containerIp = ?`, [ ip ]);
if (result.length === 0) return null;
@@ -1102,7 +1111,7 @@ async function getByIpAddress(ip) {
}
async function list() {
const domainObjectMap = await getDomainObjectMap();
const domainObjectMap = await domains.getDomainObjectMap();
const results = await database.query(`${APPS_QUERY} ORDER BY apps.id`, [ ]);
results.forEach(postProcess);
@@ -1278,10 +1287,10 @@ function checkAppState(app, state) {
async function validateLocations(locations) {
assert(Array.isArray(locations));
const domainObjectMap = await getDomainObjectMap();
const domainObjectMap = await domains.getDomainObjectMap();
for (let location of locations) {
if (!(location.domain in domainObjectMap)) throw new BoxError(BoxError.BAD_FIELD, `No such domain in ${location.type} location`);
for (const location of locations) {
if (!(location.domain in domainObjectMap)) return new BoxError(BoxError.BAD_FIELD, `No such domain in ${location.type} location`);
let subdomain = location.subdomain;
if (location.type === exports.LOCATION_TYPE_ALIAS && subdomain.startsWith('*')) {
@@ -1289,11 +1298,11 @@ async function validateLocations(locations) {
subdomain = subdomain.replace(/^\*\./, ''); // remove *.
}
const error = dns.validateHostname(subdomain, domainObjectMap[location.domain]);
if (error) throw new BoxError(BoxError.BAD_FIELD, `Bad ${location.type} location: ${error.message}`);
const error = dns.validateHostname(subdomain, location.domain);
if (error) return new BoxError(BoxError.BAD_FIELD, `Bad ${location.type} location: ${error.message}`);
}
return domainObjectMap;
return null;
}
async function getCount() {
@@ -1347,7 +1356,7 @@ async function install(data, auditSource) {
error = validateLabel(label);
if (error) throw error;
error = validateUpstreamUri(upstreamUri);
if ('upstreamUri' in data) error = validateUpstreamUri(upstreamUri);
if (error) throw error;
error = validateTags(tags);
@@ -1378,12 +1387,13 @@ async function install(data, auditSource) {
icon = Buffer.from(icon, 'base64');
}
const locations = [{ subdomain: subdomain, domain, type: exports.LOCATION_TYPE_PRIMARY }]
const locations = [{ subdomain, domain, type: exports.LOCATION_TYPE_PRIMARY }]
.concat(secondaryDomains.map(ad => _.extend(ad, { type: exports.LOCATION_TYPE_SECONDARY })))
.concat(redirectDomains.map(ad => _.extend(ad, { type: exports.LOCATION_TYPE_REDIRECT })))
.concat(aliasDomains.map(ad => _.extend(ad, { type: exports.LOCATION_TYPE_ALIAS })));
const domainObjectMap = await validateLocations(locations);
error = await validateLocations(locations);
if (error) throw error;
if (settings.isDemo() && (await getCount() >= constants.DEMO_APP_LIMIT)) throw new BoxError(BoxError.BAD_STATE, 'Too many installed apps, please uninstall a few and try again');
@@ -1413,7 +1423,7 @@ async function install(data, auditSource) {
};
const [addError] = await safe(add(appId, appStoreId, manifest, subdomain, domain, translatePortBindings(portBindings, manifest), app));
if (addError && addError.reason === BoxError.ALREADY_EXISTS) throw getDuplicateErrorDetails(addError.message, locations, domainObjectMap, portBindings);
if (addError && addError.reason === BoxError.ALREADY_EXISTS) throw getDuplicateErrorDetails(addError.message, locations, portBindings);
if (addError) throw addError;
await purchaseApp({ appId, appstoreId: appStoreId, manifestId: manifest.id || 'customapp' });
@@ -1427,10 +1437,10 @@ async function install(data, auditSource) {
const taskId = await addTask(appId, app.installationState, task, auditSource);
const newApp = _.extend({}, _.omit(app, 'icon'), { appStoreId, manifest, subdomain, domain, portBindings });
newApp.fqdn = dns.fqdn(newApp.subdomain, domainObjectMap[newApp.domain]);
newApp.secondaryDomains.forEach(function (ad) { ad.fqdn = dns.fqdn(ad.subdomain, domainObjectMap[ad.domain]); });
newApp.redirectDomains.forEach(function (ad) { ad.fqdn = dns.fqdn(ad.subdomain, domainObjectMap[ad.domain]); });
newApp.aliasDomains.forEach(function (ad) { ad.fqdn = dns.fqdn(ad.subdomain, domainObjectMap[ad.domain]); });
newApp.fqdn = dns.fqdn(newApp.subdomain, newApp.domain);
newApp.secondaryDomains.forEach(function (ad) { ad.fqdn = dns.fqdn(ad.subdomain, ad.domain); });
newApp.redirectDomains.forEach(function (ad) { ad.fqdn = dns.fqdn(ad.subdomain, ad.domain); });
newApp.aliasDomains.forEach(function (ad) { ad.fqdn = dns.fqdn(ad.subdomain, ad.domain); });
await eventlog.add(eventlog.ACTION_APP_INSTALL, auditSource, { appId, app: newApp, taskId });
@@ -1782,7 +1792,7 @@ async function setCertificate(app, data, auditSource) {
if (domainObject === null) throw new BoxError(BoxError.NOT_FOUND, 'Domain not found');
if (cert && key) {
const error = reverseProxy.validateCertificate(subdomain, domainObject, { cert, key });
const error = reverseProxy.validateCertificate(subdomain, domain, { cert, key });
if (error) throw error;
}
@@ -1790,11 +1800,23 @@ async function setCertificate(app, data, auditSource) {
const result = await database.query('UPDATE locations SET certificateJson=? WHERE location=? AND domain=?', [ certificate ? JSON.stringify(certificate) : null, subdomain, domain ]);
if (result.affectedRows === 0) throw new BoxError(BoxError.NOT_FOUND, 'Location not found');
app = await get(app.id); // refresh app object
await reverseProxy.setUserCertificate(app, dns.fqdn(subdomain, domainObject), certificate);
const location = await getLocation(subdomain, domain); // fresh location object
await reverseProxy.setUserCertificate(app, location);
await eventlog.add(eventlog.ACTION_APP_CONFIGURE, auditSource, { appId: app.id, app, subdomain, domain, cert });
}
async function getLocation(subdomain, domain) {
assert.strictEqual(typeof subdomain, 'string');
assert.strictEqual(typeof domain, 'string');
const result = await database.query(`SELECT ${LOCATION_FIELDS} FROM locations WHERE subdomain=? AND domain=?`, [ subdomain, domain ]);
if (result.length === 0) return null;
result[0].certificate = safe.JSON.parse(result[0].certificateJson);
result[0].fqdn = dns.fqdn(subdomain, domain);
return result[0];
}
async function setLocation(app, data, auditSource) {
assert.strictEqual(typeof app, 'object');
assert.strictEqual(typeof data, 'object');
@@ -1844,7 +1866,8 @@ async function setLocation(app, data, auditSource) {
.concat(values.redirectDomains.map(ad => _.extend(ad, { type: exports.LOCATION_TYPE_REDIRECT })))
.concat(values.aliasDomains.map(ad => _.extend(ad, { type: exports.LOCATION_TYPE_ALIAS })));
const domainObjectMap = await validateLocations(locations);
error = await validateLocations(locations);
if (error) throw error;
const task = {
args: {
@@ -1855,13 +1878,13 @@ async function setLocation(app, data, auditSource) {
values
};
let [taskError, taskId] = await safe(addTask(appId, exports.ISTATE_PENDING_LOCATION_CHANGE, task, auditSource));
if (taskError && taskError.reason === BoxError.ALREADY_EXISTS) taskError = getDuplicateErrorDetails(taskError.message, locations, domainObjectMap, data.portBindings);
if (taskError && taskError.reason === BoxError.ALREADY_EXISTS) taskError = getDuplicateErrorDetails(taskError.message, locations, data.portBindings);
if (taskError) throw taskError;
values.fqdn = dns.fqdn(values.subdomain, domainObjectMap[values.domain]);
values.secondaryDomains.forEach(function (ad) { ad.fqdn = dns.fqdn(ad.subdomain, domainObjectMap[ad.domain]); });
values.redirectDomains.forEach(function (ad) { ad.fqdn = dns.fqdn(ad.subdomain, domainObjectMap[ad.domain]); });
values.aliasDomains.forEach(function (ad) { ad.fqdn = dns.fqdn(ad.subdomain, domainObjectMap[ad.domain]); });
values.fqdn = dns.fqdn(values.subdomain, values.domain);
values.secondaryDomains.forEach(function (ad) { ad.fqdn = dns.fqdn(ad.subdomain, ad.domain); });
values.redirectDomains.forEach(function (ad) { ad.fqdn = dns.fqdn(ad.subdomain, ad.domain); });
values.aliasDomains.forEach(function (ad) { ad.fqdn = dns.fqdn(ad.subdomain, ad.domain); });
await eventlog.add(eventlog.ACTION_APP_CONFIGURE, auditSource, _.extend({ appId, app, taskId }, values));
@@ -2023,29 +2046,12 @@ async function getLogs(app, options) {
const logPaths = await getLogPaths(app);
const cp = spawn('/usr/bin/tail', args.concat(logPaths));
const transformStream = split(function mapper(line) {
if (format !== 'json') return line + '\n';
const logStream = new LogStream({ format, source: appId });
logStream.close = cp.kill.bind(cp, 'SIGKILL'); // hook for caller. closing stream kills the child process
const data = line.split(' '); // logs are <ISOtimestamp> <msg>
let timestamp = (new Date(data[0])).getTime();
if (isNaN(timestamp)) timestamp = 0;
const message = line.slice(data[0].length+1);
cp.stdout.pipe(logStream);
// ignore faulty empty logs
if (!timestamp && !message) return;
return JSON.stringify({
realtimeTimestamp: timestamp * 1000,
message: message,
source: appId
}) + '\n';
});
transformStream.close = cp.kill.bind(cp, 'SIGKILL'); // closing stream kills the child process
cp.stdout.pipe(transformStream);
return transformStream;
return logStream;
}
// never fails just prints error
@@ -2058,15 +2064,6 @@ async function appendLogLine(app, line) {
if (!safe.fs.appendFileSync(logFilePath, line)) console.error(`Could not append log line for app ${app.id} at ${logFilePath}: ${safe.error.message}`);
}
async function getCertificate(subdomain, domain) {
assert.strictEqual(typeof subdomain, 'string');
assert.strictEqual(typeof domain, 'string');
const result = await database.query('SELECT certificateJson FROM locations WHERE subdomain=? AND domain=?', [ subdomain, domain ]);
if (result.length === 0) return null;
return safe.JSON.parse(result[0].certificateJson);
}
// does a re-configure when called from most states. for install/clone errors, it re-installs with an optional manifest
// re-configure can take a dockerImage but not a manifest because re-configure does not clean up addons
async function repair(app, data, auditSource) {
@@ -2289,10 +2286,11 @@ async function clone(app, data, user, auditSource) {
if (error) throw error;
const secondaryDomains = translateSecondaryDomains(data.secondaryDomains || {});
const locations = [{ subdomain: subdomain, domain, type: exports.LOCATION_TYPE_PRIMARY }]
const locations = [{ subdomain, domain, type: exports.LOCATION_TYPE_PRIMARY }]
.concat(secondaryDomains.map(ad => _.extend(ad, { type: exports.LOCATION_TYPE_SECONDARY })));
const domainObjectMap = await validateLocations(locations);
error = await validateLocations(locations);
if (error) throw error;
// re-validate because this new box version may not accept old configs
error = checkManifestConstraints(manifest);
@@ -2334,7 +2332,7 @@ async function clone(app, data, user, auditSource) {
};
const [addError] = await safe(add(newAppId, appStoreId, manifest, subdomain, domain, translatePortBindings(portBindings, manifest), obj));
if (addError && addError.reason === BoxError.ALREADY_EXISTS) throw getDuplicateErrorDetails(addError.message, locations, domainObjectMap, portBindings);
if (addError && addError.reason === BoxError.ALREADY_EXISTS) throw getDuplicateErrorDetails(addError.message, locations, portBindings);
if (addError) throw addError;
await purchaseApp({ appId: newAppId, appstoreId: app.appStoreId, manifestId: manifest.id || 'customapp' });
@@ -2348,10 +2346,10 @@ async function clone(app, data, user, auditSource) {
const taskId = await addTask(newAppId, exports.ISTATE_PENDING_CLONE, task, auditSource);
const newApp = _.extend({}, _.omit(obj, 'icon'), { appStoreId, manifest, subdomain, domain, portBindings });
newApp.fqdn = dns.fqdn(newApp.subdomain, domainObjectMap[newApp.domain]);
newApp.secondaryDomains.forEach(function (ad) { ad.fqdn = dns.fqdn(ad.subdomain, domainObjectMap[ad.domain]); });
newApp.redirectDomains.forEach(function (ad) { ad.fqdn = dns.fqdn(ad.subdomain, domainObjectMap[ad.domain]); });
newApp.aliasDomains.forEach(function (ad) { ad.fqdn = dns.fqdn(ad.subdomain, domainObjectMap[ad.domain]); });
newApp.fqdn = dns.fqdn(newApp.subdomain, newApp.domain);
newApp.secondaryDomains.forEach(function (ad) { ad.fqdn = dns.fqdn(ad.subdomain, ad.domain); });
newApp.redirectDomains.forEach(function (ad) { ad.fqdn = dns.fqdn(ad.subdomain, ad.domain); });
newApp.aliasDomains.forEach(function (ad) { ad.fqdn = dns.fqdn(ad.subdomain, ad.domain); });
await eventlog.add(eventlog.ACTION_APP_CLONE, auditSource, { appId: newAppId, oldAppId: appId, backupId, remotePath: backupInfo.remotePath, oldApp: app, newApp, taskId });
@@ -2475,6 +2473,9 @@ async function createExec(app, options) {
Cmd: cmd
};
// currently the webterminal and cli sets C.UTF-8
if (options.lang) createOptions.Env = [ 'LANG=' + options.lang ];
return await docker.createExec(app.containerId, createOptions);
}
@@ -2617,6 +2618,25 @@ async function updateBackup(app, backupId, data) {
await backups.update(backupId, data);
}
async function getBackupDownloadStream(app, backupId) {
assert.strictEqual(typeof app, 'object');
assert.strictEqual(typeof backupId, 'string');
const backup = await backups.get(backupId);
if (!backup) throw new BoxError(BoxError.NOT_FOUND, 'Backup not found');
if (backup.identifier !== app.id) throw new BoxError(BoxError.NOT_FOUND, 'Backup not found'); // some other app's backup
if (backup.format !== 'tgz') throw new BoxError(BoxError.BAD_STATE, 'only tgz backups can be downloaded');
const backupConfig = await settings.getBackupConfig();
return new Promise((resolve, reject) => {
storage.api(backupConfig.provider).download(backupConfig, tgz.getBackupFilePath(backupConfig, backup.remotePath), function (error, sourceStream) {
if (error) return reject(error);
resolve(sourceStream);
});
});
}
async function restoreInstalledApps(options, auditSource) {
assert.strictEqual(typeof options, 'object');
assert.strictEqual(typeof auditSource, 'object');

View File

@@ -394,14 +394,14 @@ async function createTicket(info, auditSource) {
return { message: `An email was sent to ${constants.SUPPORT_EMAIL}. We will get back shortly!` };
}
async function getApps() {
async function getApps(repository = 'core') {
const token = await settings.getAppstoreApiToken();
if (!token) throw new BoxError(BoxError.LICENSE_ERROR, 'Missing token');
const unstable = await settings.getUnstableAppsConfig();
const [error, response] = await safe(superagent.get(`${settings.apiServerOrigin()}/api/v1/apps`)
.query({ accessToken: token, boxVersion: constants.VERSION, unstable: unstable })
.query({ accessToken: token, boxVersion: constants.VERSION, unstable, repository })
.timeout(30 * 1000)
.ok(() => true));

View File

@@ -19,7 +19,7 @@ const apps = require('./apps.js'),
BoxError = require('./boxerror.js'),
constants = require('./constants.js'),
debug = require('debug')('box:apptask'),
df = require('@sindresorhus/df'),
df = require('./df.js'),
dns = require('./dns.js'),
docker = require('./docker.js'),
ejs = require('ejs'),

View File

@@ -242,11 +242,11 @@ async function cleanupSnapshots(backupConfig) {
const info = safe.JSON.parse(contents);
if (!info) return;
delete info.box;
const progressCallback = (progress) => { debug(`cleanupSnapshots: ${progress.message}`); };
for (const appId of Object.keys(info)) {
if (appId === 'box' || appId === 'mail') continue;
const app = await apps.get(appId);
if (app) continue; // app is still installed

View File

@@ -14,7 +14,7 @@ const assert = require('assert'),
{ DecryptStream, EncryptStream } = require('../hush.js'),
once = require('../once.js'),
path = require('path'),
progressStream = require('progress-stream'),
ProgressStream = require('../progress-stream.js'),
storage = require('../storage.js'),
tar = require('tar-fs'),
zlib = require('zlib');
@@ -51,7 +51,7 @@ function tarPack(dataLayout, encryption) {
});
const gzip = zlib.createGzip({});
const ps = progressStream({ time: 10000 }); // emit 'progress' every 10 seconds
const ps = new ProgressStream({ interval: 10000 }); // emit 'progress' every 10 seconds
pack.on('error', function (error) {
debug('tarPack: tar stream error.', error);
@@ -84,7 +84,7 @@ function tarExtract(inStream, dataLayout, encryption) {
assert.strictEqual(typeof encryption, 'object');
const gunzip = zlib.createGunzip({});
const ps = progressStream({ time: 10000 }); // display a progress every 10 seconds
const ps = new ProgressStream({ interval: 10000 }); // display a progress every 10 seconds
const extract = tar.extract('/', {
map: function (header) {
header.name = dataLayout.toLocalPath(header.name);
@@ -173,6 +173,8 @@ async function upload(backupConfig, remotePath, dataLayout, progressCallback) {
assert.strictEqual(typeof dataLayout, 'object');
assert.strictEqual(typeof progressCallback, 'function');
debug(`upload: Uploading ${dataLayout.toString()} to ${remotePath}`);
return new Promise((resolve, reject) => {
async.retry({ times: 5, interval: 20000 }, function (retryCallback) {
retryCallback = once(retryCallback); // protect again upload() erroring much later after tar stream error

View File

@@ -64,7 +64,7 @@ async function checkPreconditions(backupConfig, dataLayout) {
// check mount status before uploading
const status = await storage.api(backupConfig.provider).getBackupProviderStatus(backupConfig);
debug(`upload: mount point status is ${JSON.stringify(status)}`);
if (status.state !== 'active') throw new BoxError(BoxError.MOUNT_ERROR, `Backup endpoint is not mounted: ${status.message}`);
if (status.state !== 'active') throw new BoxError(BoxError.MOUNT_ERROR, `Backup endpoint is not active: ${status.message}`);
// check availabe size. this requires root for df to work
const df = await storage.api(backupConfig.provider).getAvailableSize(backupConfig);
@@ -76,7 +76,7 @@ async function checkPreconditions(backupConfig, dataLayout) {
used += parseInt(result, 10);
}
debug(`checkPreconditions: ${used} bytes`);
debug(`checkPreconditions: total required =${used} available=${df.available}`);
const needed = 0.6 * used + (1024 * 1024 * 1024); // check if there is atleast 1GB left afterwards. aim for 60% because rsync/tgz won't need full 100%
if (df.available <= needed) throw new BoxError(BoxError.FS_ERROR, `Not enough disk space for backup. Needed: ${prettyBytes(needed)} Available: ${prettyBytes(df.available)}`);
@@ -229,7 +229,11 @@ async function copy(backupConfig, srcRemotePath, destRemotePath, progressCallbac
const newFilePath = backupFormat.api(format).getBackupFilePath(backupConfig, destRemotePath);
const startTime = new Date();
await safe(storage.api(provider).copy(backupConfig, oldFilePath, newFilePath, progressCallback));
const [copyError] = await safe(storage.api(provider).copy(backupConfig, oldFilePath, newFilePath, progressCallback));
if (copyError) {
debug(`copy: copied to ${destRemotePath} errored. error: ${copyError.message}`);
throw copyError;
}
debug(`copy: copied successfully to ${destRemotePath}. Took ${(new Date() - startTime)/1000} seconds`);
}

View File

@@ -9,6 +9,8 @@ exports = module.exports = {
setString,
del,
listCertIds,
ACME_ACCOUNT_KEY: 'acme_account_key',
ADDON_TURN_SECRET: 'addon_turn_secret',
SFTP_PUBLIC_KEY: 'sftp_public_key',
@@ -16,6 +18,7 @@ exports = module.exports = {
PROXY_AUTH_TOKEN_SECRET: 'proxy_auth_token_secret',
CERT_PREFIX: 'cert',
CERT_SUFFIX: 'cert',
_clear: clear
};
@@ -62,3 +65,8 @@ async function del(id) {
async function clear() {
await database.query('DELETE FROM blobs');
}
async function listCertIds() {
const result = await database.query('SELECT id FROM blobs WHERE id LIKE ?', [ `${exports.CERT_PREFIX}-%.${exports.CERT_SUFFIX}` ]);
return result.map(r => r.id);
}

View File

@@ -36,9 +36,9 @@ const apps = require('./apps.js'),
delay = require('./delay.js'),
dns = require('./dns.js'),
dockerProxy = require('./dockerproxy.js'),
domains = require('./domains.js'),
eventlog = require('./eventlog.js'),
fs = require('fs'),
LogStream = require('./log-stream.js'),
mail = require('./mail.js'),
notifications = require('./notifications.js'),
path = require('path'),
@@ -50,7 +50,6 @@ const apps = require('./apps.js'),
settings = require('./settings.js'),
shell = require('./shell.js'),
spawn = require('child_process').spawn,
split = require('split'),
sysinfo = require('./sysinfo.js'),
tasks = require('./tasks.js'),
users = require('./users.js');
@@ -113,8 +112,7 @@ async function runStartupTasks() {
tasks.push(async function () {
if (!settings.dashboardDomain()) return;
const domainObject = await domains.get(settings.dashboardDomain());
await reverseProxy.writeDashboardConfig(domainObject);
await reverseProxy.writeDashboardConfig(settings.dashboardDomain());
});
tasks.push(async function () {
@@ -161,7 +159,7 @@ async function getConfig() {
footer: branding.renderFooter(allSettings[settings.FOOTER_KEY] || constants.FOOTER),
features: appstore.getFeatures(),
profileLocked: allSettings[settings.PROFILE_CONFIG_KEY].lockUserProfiles,
mandatory2FA: allSettings[settings.PROFILE_CONFIG_KEY].mandatory2FA
mandatory2FA: allSettings[settings.PROFILE_CONFIG_KEY].mandatory2FA,
};
}
@@ -230,25 +228,12 @@ async function getLogs(unit, options) {
const cp = spawn('/usr/bin/tail', args);
const transformStream = split(function mapper(line) {
if (format !== 'json') return line + '\n';
const logStream = new LogStream({ format, source: unit });
logStream.close = cp.kill.bind(cp, 'SIGKILL'); // hook for caller. closing stream kills the child process
const data = line.split(' '); // logs are <ISOtimestamp> <msg>
let timestamp = (new Date(data[0])).getTime();
if (isNaN(timestamp)) timestamp = 0;
cp.stdout.pipe(logStream);
return JSON.stringify({
realtimeTimestamp: timestamp * 1000,
message: line.slice(data[0].length+1),
source: unit
}) + '\n';
});
transformStream.close = cp.kill.bind(cp, 'SIGKILL'); // closing stream kills the child process
cp.stdout.pipe(transformStream);
return transformStream;
return logStream;
}
async function prepareDashboardDomain(domain, auditSource) {
@@ -259,10 +244,7 @@ async function prepareDashboardDomain(domain, auditSource) {
if (settings.isDemo()) throw new BoxError(BoxError.CONFLICT, 'Not allowed in demo mode');
const domainObject = await domains.get(domain);
if (!domain) throw new BoxError(BoxError.NOT_FOUND, 'No such domain');
const fqdn = dns.fqdn(constants.DASHBOARD_SUBDOMAIN, domainObject);
const fqdn = dns.fqdn(constants.DASHBOARD_SUBDOMAIN, domain);
const result = await apps.list();
if (result.some(app => app.fqdn === fqdn)) throw new BoxError(BoxError.BAD_STATE, 'Dashboard location conflicts with an existing app');
@@ -281,11 +263,8 @@ async function setDashboardDomain(domain, auditSource) {
debug(`setDashboardDomain: ${domain}`);
const domainObject = await domains.get(domain);
if (!domain) throw new BoxError(BoxError.NOT_FOUND, 'No such domain');
await reverseProxy.writeDashboardConfig(domainObject);
const fqdn = dns.fqdn(constants.DASHBOARD_SUBDOMAIN, domainObject);
await reverseProxy.writeDashboardConfig(domain);
const fqdn = dns.fqdn(constants.DASHBOARD_SUBDOMAIN, domain);
await settings.setDashboardLocation(domain, fqdn);
@@ -323,8 +302,7 @@ async function setupDnsAndCert(subdomain, domain, auditSource, progressCallback)
assert.strictEqual(typeof auditSource, 'object');
assert.strictEqual(typeof progressCallback, 'function');
const domainObject = await domains.get(domain);
const dashboardFqdn = dns.fqdn(subdomain, domainObject);
const dashboardFqdn = dns.fqdn(subdomain, domain);
const ipv4 = await sysinfo.getServerIPv4();
const ipv6 = await sysinfo.getServerIPv6();
@@ -336,7 +314,8 @@ async function setupDnsAndCert(subdomain, domain, auditSource, progressCallback)
await dns.waitForDnsRecord(subdomain, domain, 'A', ipv4, { interval: 30000, times: 50000 });
if (ipv6) await dns.waitForDnsRecord(subdomain, domain, 'AAAA', ipv6, { interval: 30000, times: 50000 });
progressCallback({ percent: 60, message: `Getting certificate of ${dashboardFqdn}` });
await reverseProxy.ensureCertificate(dns.fqdn(subdomain, domainObject), domain, auditSource);
const location = { subdomain, domain, fqdn: dashboardFqdn, type: apps.LOCATION_TYPE_DASHBOARD, certificate: null };
await reverseProxy.ensureCertificate(location, {}, auditSource);
}
async function syncDnsRecords(options) {

View File

@@ -40,6 +40,7 @@ exports = module.exports = {
DEMO_USERNAME: 'cloudron',
DEMO_BLACKLISTED_APPS: [
'org.jupyter.cloudronapp',
'com.github.cloudtorrent',
'net.alltubedownload.cloudronapp',
'com.adguard.home.cloudronapp',
@@ -74,6 +75,6 @@ exports = module.exports = {
FOOTER: '&copy; %YEAR% &nbsp; [Cloudron](https://cloudron.io) &nbsp; &nbsp; &nbsp; [Forum <i class="fa fa-comments"></i>](https://forum.cloudron.io)',
VERSION: process.env.BOX_ENV === 'cloudron' ? fs.readFileSync(path.join(__dirname, '../VERSION'), 'utf8').trim() : '7.2.0-test'
VERSION: process.env.BOX_ENV === 'cloudron' ? fs.readFileSync(path.join(__dirname, '../VERSION'), 'utf8').trim() : '7.3.0-test'
};

View File

@@ -50,7 +50,8 @@ const gJobs = {
dockerVolumeCleaner: null,
dynamicDns: null,
schedulerSync: null,
appHealthMonitor: null
appHealthMonitor: null,
diskUsage: null
};
// cron format
@@ -95,6 +96,12 @@ async function startJobs() {
start: true
});
gJobs.diskUsage = new CronJob({
cronTime: `00 ${minute} 3 * * *`, // once a day
onTick: async () => await safe(cloudron.updateDiskUsage(), { debug }),
start: true
});
gJobs.diskSpaceChecker = new CronJob({
cronTime: '00 30 * * * *', // every 30 minutes. if you change this interval, change the notification messages with correct duration
onTick: async () => await safe(system.checkDiskSpace(), { debug }),

46
src/df.js Normal file
View File

@@ -0,0 +1,46 @@
'use strict';
exports = module.exports = {
disks,
file
};
const assert = require('assert'),
BoxError = require('./boxerror.js'),
safe = require('safetydance');
function parseLine(line) {
const parts = line.split(/\s+/, 7); // this way the mountpoint can have spaces in it
return {
filesystem: parts[0],
type: parts[1],
size: Number.parseInt(parts[2], 10),
used: Number.parseInt(parts[3], 10),
available: Number.parseInt(parts[4], 10),
capacity: Number.parseInt(parts[5], 10) / 100, // note: this has a trailing %
mountpoint: parts[6]
};
}
async function disks() {
const output = safe.child_process.execSync('df -B1 --output=source,fstype,size,used,avail,pcent,target', { encoding: 'utf8' });
if (!output) throw new BoxError(BoxError.FS_ERROR, `Error running df: ${safe.error.message}`);
const lines = output.trim().split('\n').slice(1); // discard header
const result = [];
for (const line of lines) {
result.push(parseLine(line));
}
return result;
}
async function file(filename) {
assert.strictEqual(typeof filename, 'string');
const output = safe.child_process.execSync(`df -B1 --output=source,fstype,size,used,avail,pcent,target ${filename}`, { encoding: 'utf8' });
if (!output) throw new BoxError(BoxError.FS_ERROR, `Error running df: ${safe.error.message}`);
const lines = output.trim().split('\n').slice(1); // discard header
return parseLine(lines[0]);
}

View File

@@ -4,6 +4,8 @@ exports = module.exports = {
start,
stop,
checkCertificate,
validateConfig,
applyConfig
};
@@ -12,15 +14,12 @@ const assert = require('assert'),
BoxError = require('./boxerror.js'),
constants = require('./constants.js'),
debug = require('debug')('box:directoryserver'),
dns = require('./dns.js'),
domains = require('./domains.js'),
eventlog = require('./eventlog.js'),
fs = require('fs'),
groups = require('./groups.js'),
ldap = require('ldapjs'),
path = require('path'),
paths = require('./paths.js'),
reverseproxy = require('./reverseproxy.js'),
reverseProxy = require('./reverseproxy.js'),
safe = require('safetydance'),
settings = require('./settings.js'),
speakeasy = require('speakeasy'),
@@ -29,7 +28,7 @@ const assert = require('assert'),
util = require('util'),
validator = require('validator');
let gServer = null;
let gServer = null, gCertificate = null;
const NOOP = function () {};
@@ -200,7 +199,7 @@ async function userSearch(req, res, next) {
givenName: firstName,
username: user.username,
samaccountname: user.username, // to support ActiveDirectory clients
memberof: allGroups.filter(function (g) { return g.userIds.indexOf(user.id) !== -1; }).map(function (g) { return g.name; })
memberof: allGroups.filter(function (g) { return g.userIds.indexOf(user.id) !== -1; }).map(function (g) { return `cn=${g.name},ou=groups,dc=cloudron`; })
}
};
@@ -296,7 +295,6 @@ async function userAuth(req, res, next) {
next();
}
// FIXME this needs to be restarted if settings changes or dashboard cert got renewed
async function start() {
if (gServer) return; // already running
@@ -309,13 +307,11 @@ async function start() {
fatal: debug
};
const domainObject = await domains.get(settings.dashboardDomain());
const dashboardFqdn = dns.fqdn(constants.DASHBOARD_SUBDOMAIN, domainObject);
const certificatePath = await reverseproxy.getCertificatePath(dashboardFqdn, domainObject.domain);
gCertificate = await reverseProxy.getDirectoryServerCertificate();
gServer = ldap.createServer({
certificate: fs.readFileSync(certificatePath.certFilePath, 'utf8'),
key: fs.readFileSync(certificatePath.keyFilePath, 'utf8'),
certificate: gCertificate.cert,
key: gCertificate.key,
log: logger
});
@@ -369,3 +365,15 @@ async function stop() {
gServer.close();
gServer = null;
}
async function checkCertificate() {
const certificate = await reverseProxy.getDirectoryServerCertificate();
if (certificate.cert === gCertificate.cert) {
debug('checkCertificate: certificate has not changed');
return;
}
debug('checkCertificate: certificate changed. restarting');
await stop();
await start();
}

View File

@@ -59,22 +59,22 @@ function api(provider) {
}
}
function fqdn(subdomain, domainObject) {
function fqdn(subdomain, domain) {
assert.strictEqual(typeof subdomain, 'string');
assert.strictEqual(typeof domainObject, 'object');
assert.strictEqual(typeof domain, 'string');
return subdomain + (subdomain ? '.' : '') + domainObject.domain;
return subdomain + (subdomain ? '.' : '') + domain;
}
// Hostname validation comes from RFC 1123 (section 2.1)
// Domain name validation comes from RFC 2181 (Name syntax)
// https://en.wikipedia.org/wiki/Hostname#Restrictions_on_valid_host_names
// We are validating the validity of the location-fqdn as host name (and not dns name)
function validateHostname(subdomain, domainObject) {
function validateHostname(subdomain, domain) {
assert.strictEqual(typeof subdomain, 'string');
assert.strictEqual(typeof domainObject, 'object');
assert.strictEqual(typeof domain, 'string');
const hostname = fqdn(subdomain, domainObject);
const hostname = fqdn(subdomain, domain);
const RESERVED_SUBDOMAINS = [
constants.SMTP_SUBDOMAIN,

View File

@@ -105,7 +105,7 @@ async function upsert(domainObject, location, type, values) {
const domainConfig = domainObject.config,
zoneName = domainObject.zoneName,
fqdn = dns.fqdn(location, domainObject);
fqdn = dns.fqdn(location, domainObject.domain);
debug('upsert: %s for zone %s of type %s with values %j', fqdn, zoneName, type, values);
@@ -166,7 +166,7 @@ async function get(domainObject, location, type) {
const domainConfig = domainObject.config,
zoneName = domainObject.zoneName,
fqdn = dns.fqdn(location, domainObject);
fqdn = dns.fqdn(location, domainObject.domain);
const zone = await getZoneByName(domainConfig, zoneName);
const result = await getDnsRecords(domainConfig, zone.id, fqdn, type);
@@ -182,7 +182,7 @@ async function del(domainObject, location, type, values) {
const domainConfig = domainObject.config,
zoneName = domainObject.zoneName,
fqdn = dns.fqdn(location, domainObject);
fqdn = dns.fqdn(location, domainObject.domain);
const zone = await getZoneByName(domainConfig, zoneName);
@@ -212,7 +212,7 @@ async function wait(domainObject, subdomain, type, value, options) {
const domainConfig = domainObject.config,
zoneName = domainObject.zoneName,
fqdn = dns.fqdn(subdomain, domainObject);
fqdn = dns.fqdn(subdomain, domainObject.domain);
debug('wait: %s for zone %s of type %s', fqdn, zoneName, type);

View File

@@ -200,13 +200,14 @@ async function wait(domainObject, subdomain, type, value, options) {
assert.strictEqual(typeof value, 'string');
assert(options && typeof options === 'object'); // { interval: 5000, times: 50000 }
const fqdn = dns.fqdn(subdomain, domainObject);
const fqdn = dns.fqdn(subdomain, domainObject.domain);
await waitForDns(fqdn, domainObject.zoneName, type, value, options);
}
// https://stackoverflow.com/questions/14313183/javascript-regex-how-do-i-check-if-the-string-is-ascii-only
function isASCII(str) {
// eslint-disable-next-line no-control-regex
return /^[\x00-\x7F]*$/.test(str);
}

View File

@@ -119,7 +119,7 @@ async function wait(domainObject, subdomain, type, value, options) {
assert.strictEqual(typeof value, 'string');
assert(options && typeof options === 'object'); // { interval: 5000, times: 50000 }
const fqdn = dns.fqdn(subdomain, domainObject);
const fqdn = dns.fqdn(subdomain, domainObject.domain);
await waitForDns(fqdn, domainObject.zoneName, type, value, options);
}

View File

@@ -76,7 +76,7 @@ async function upsert(domainObject, location, type, values) {
const domainConfig = domainObject.config,
zoneName = domainObject.zoneName,
fqdn = dns.fqdn(location, domainObject);
fqdn = dns.fqdn(location, domainObject.domain);
debug('add: %s for zone %s of type %s with values %j', fqdn, zoneName, type, values);
@@ -105,7 +105,7 @@ async function get(domainObject, location, type) {
const domainConfig = domainObject.config,
zoneName = domainObject.zoneName,
fqdn = dns.fqdn(location, domainObject);
fqdn = dns.fqdn(location, domainObject.domain);
const zone = await getZoneByName(getDnsCredentials(domainConfig), zoneName);
@@ -130,7 +130,7 @@ async function del(domainObject, location, type, values) {
const domainConfig = domainObject.config,
zoneName = domainObject.zoneName,
fqdn = dns.fqdn(location, domainObject);
fqdn = dns.fqdn(location, domainObject.domain);
const zone = await getZoneByName(getDnsCredentials(domainConfig), zoneName);
@@ -151,7 +151,7 @@ async function wait(domainObject, subdomain, type, value, options) {
assert.strictEqual(typeof value, 'string');
assert(options && typeof options === 'object'); // { interval: 5000, times: 50000 }
const fqdn = dns.fqdn(subdomain, domainObject);
const fqdn = dns.fqdn(subdomain, domainObject.domain);
await waitForDns(fqdn, domainObject.zoneName, type, value, options);
}

View File

@@ -151,7 +151,7 @@ async function wait(domainObject, subdomain, type, value, options) {
assert.strictEqual(typeof value, 'string');
assert(options && typeof options === 'object'); // { interval: 5000, times: 50000 }
const fqdn = dns.fqdn(subdomain, domainObject);
const fqdn = dns.fqdn(subdomain, domainObject.domain);
await waitForDns(fqdn, domainObject.zoneName, type, value, options);
}

View File

@@ -216,7 +216,7 @@ async function wait(domainObject, subdomain, type, value, options) {
assert.strictEqual(typeof value, 'string');
assert(options && typeof options === 'object'); // { interval: 5000, times: 50000 }
const fqdn = dns.fqdn(subdomain, domainObject);
const fqdn = dns.fqdn(subdomain, domainObject.domain);
await waitForDns(fqdn, domainObject.zoneName, type, value, options);
}

View File

@@ -225,7 +225,7 @@ async function wait(domainObject, subdomain, type, value, options) {
assert.strictEqual(typeof value, 'string');
assert(options && typeof options === 'object'); // { interval: 5000, times: 50000 }
const fqdn = dns.fqdn(subdomain, domainObject);
const fqdn = dns.fqdn(subdomain, domainObject.domain);
await waitForDns(fqdn, domainObject.zoneName, type, value, options);
}

View File

@@ -62,7 +62,7 @@ async function wait(domainObject, subdomain, type, value, options) {
assert.strictEqual(typeof value, 'string');
assert(options && typeof options === 'object'); // { interval: 5000, times: 50000 }
const fqdn = dns.fqdn(subdomain, domainObject);
const fqdn = dns.fqdn(subdomain, domainObject.domain);
await waitForDns(fqdn, domainObject.zoneName, type, value, options);
}

View File

@@ -237,7 +237,7 @@ async function wait(domainObject, subdomain, type, value, options) {
assert.strictEqual(typeof value, 'string');
assert(options && typeof options === 'object'); // { interval: 5000, times: 50000 }
const fqdn = dns.fqdn(subdomain, domainObject);
const fqdn = dns.fqdn(subdomain, domainObject.domain);
await waitForDns(fqdn, domainObject.zoneName, type, value, options);
}

View File

@@ -206,7 +206,7 @@ async function wait(domainObject, subdomain, type, value, options) {
assert.strictEqual(typeof value, 'string');
assert(options && typeof options === 'object'); // { interval: 5000, times: 50000 }
const fqdn = dns.fqdn(subdomain, domainObject);
const fqdn = dns.fqdn(subdomain, domainObject.domain);
await waitForDns(fqdn, domainObject.zoneName, type, value, options);
}

View File

@@ -217,7 +217,7 @@ async function wait(domainObject, subdomain, type, value, options) {
assert.strictEqual(typeof value, 'string');
assert(options && typeof options === 'object'); // { interval: 5000, times: 50000 }
const fqdn = dns.fqdn(subdomain, domainObject);
const fqdn = dns.fqdn(subdomain, domainObject.domain);
await waitForDns(fqdn, domainObject.zoneName, type, value, options);
}

View File

@@ -95,7 +95,7 @@ async function upsert(domainObject, location, type, values) {
const domainConfig = domainObject.config,
zoneName = domainObject.zoneName,
fqdn = dns.fqdn(location, domainObject);
fqdn = dns.fqdn(location, domainObject.domain);
debug('add: %s for zone %s of type %s with values %j', fqdn, zoneName, type, values);
@@ -134,7 +134,7 @@ async function get(domainObject, location, type) {
const domainConfig = domainObject.config,
zoneName = domainObject.zoneName,
fqdn = dns.fqdn(location, domainObject);
fqdn = dns.fqdn(location, domainObject.domain);
const zone = await getZoneByName(domainConfig, zoneName);
@@ -165,7 +165,7 @@ async function del(domainObject, location, type, values) {
const domainConfig = domainObject.config,
zoneName = domainObject.zoneName,
fqdn = dns.fqdn(location, domainObject);
fqdn = dns.fqdn(location, domainObject.domain);
const zone = await getZoneByName(domainConfig, zoneName);
@@ -212,7 +212,7 @@ async function wait(domainObject, subdomain, type, value, options) {
assert.strictEqual(typeof value, 'string');
assert(options && typeof options === 'object'); // { interval: 5000, times: 50000 }
const fqdn = dns.fqdn(subdomain, domainObject);
const fqdn = dns.fqdn(subdomain, domainObject.domain);
await waitForDns(fqdn, domainObject.zoneName, type, value, options);
}

View File

@@ -195,7 +195,7 @@ async function wait(domainObject, subdomain, type, value, options) {
assert.strictEqual(typeof value, 'string');
assert(options && typeof options === 'object'); // { interval: 5000, times: 50000 }
const fqdn = dns.fqdn(subdomain, domainObject);
const fqdn = dns.fqdn(subdomain, domainObject.domain);
await waitForDns(fqdn, domainObject.zoneName, type, value, options);
}

View File

@@ -7,7 +7,8 @@ const assert = require('assert'),
debug = require('debug')('box:dns/waitfordns'),
dig = require('../dig.js'),
promiseRetry = require('../promise-retry.js'),
safe = require('safetydance');
safe = require('safetydance'),
_ = require('underscore');
async function resolveIp(hostname, type, options) {
assert.strictEqual(typeof hostname, 'string');
@@ -20,13 +21,13 @@ async function resolveIp(hostname, type, options) {
if (!error && results.length !== 0) return results;
// try CNAME record at authoritative server
debug(`resolveIp: Checking if ${hostname} has CNAME record at ${options.server}`);
debug(`resolveIp: No A record. Checking if ${hostname} has CNAME record at ${options.server}`);
const cnameResults = await dig.resolve(hostname, 'CNAME', options);
if (cnameResults.length === 0) return cnameResults;
// recurse lookup the CNAME record
debug(`resolveIp: Resolving ${hostname}'s CNAME record ${cnameResults[0]}`);
return await dig.resolve(cnameResults[0], type, options);
debug(`resolveIp: CNAME record found. Resolving ${hostname}'s CNAME record ${cnameResults[0]} using unbound`);
return await dig.resolve(cnameResults[0], type, _.omit(options, 'server'));
}
async function isChangeSynced(hostname, type, value, nameserver) {

View File

@@ -62,7 +62,7 @@ async function wait(domainObject, subdomain, type, value, options) {
assert.strictEqual(typeof value, 'string');
assert(options && typeof options === 'object'); // { interval: 5000, times: 50000 }
const fqdn = dns.fqdn(subdomain, domainObject);
const fqdn = dns.fqdn(subdomain, domainObject.domain);
await waitForDns(fqdn, domainObject.zoneName, type, value, options);
}
@@ -77,7 +77,7 @@ async function verifyDomainConfig(domainObject) {
if (error || !nameservers) throw new BoxError(BoxError.BAD_FIELD, error ? error.message : 'Unable to get nameservers');
const location = 'cloudrontestdns';
const fqdn = dns.fqdn(location, domainObject);
const fqdn = dns.fqdn(location, domainObject.domain);
const [ipv4Error, ipv4Result] = await safe(dig.resolve(fqdn, 'A', { server: '127.0.0.1', timeout: 5000 }));
if (ipv4Error && (ipv4Error.code === 'ENOTFOUND' || ipv4Error.code === 'ENODATA')) throw new BoxError(BoxError.BAD_FIELD, `Unable to resolve IPv4 of ${fqdn}. Please check if you have set up *.${domainObject.domain} to point to this server's IP`);

View File

@@ -39,7 +39,7 @@ const apps = require('./apps.js'),
debug = require('debug')('box:docker'),
delay = require('./delay.js'),
Docker = require('dockerode'),
reverseProxy = require('./reverseproxy.js'),
paths = require('./paths.js'),
services = require('./services.js'),
settings = require('./settings.js'),
shell = require('./shell.js'),
@@ -205,18 +205,11 @@ async function getAddonMounts(app) {
break;
}
case 'tls': {
const certificatePath = await reverseProxy.getCertificatePath(app.fqdn, app.domain);
const certificateDir = `${paths.PLATFORM_DATA_DIR}/tls/${app.id}`;
mounts.push({
Target: '/etc/certs/tls_cert.pem',
Source: certificatePath.certFilePath,
Type: 'bind',
ReadOnly: true
});
mounts.push({
Target: '/etc/certs/tls_key.pem',
Source: certificatePath.keyFilePath,
Target: '/etc/certs',
Source: certificateDir,
Type: 'bind',
ReadOnly: true
});
@@ -315,6 +308,15 @@ async function createSubcontainer(app, name, cmd, options) {
const mounts = await getMounts(app);
const addonEnv = await services.getEnvironment(app);
const runtimeVolumes = {
'/tmp': {},
'/run': {},
'/home/cloudron/.cache': {},
'/root/.cache': {}
};
if (app.manifest.runtimeDirs) {
app.manifest.runtimeDirs.forEach(dir => runtimeVolumes[dir] = {});
}
let containerOptions = {
name: name, // for referencing containers
@@ -323,10 +325,7 @@ async function createSubcontainer(app, name, cmd, options) {
Cmd: (isAppContainer && app.debugMode && app.debugMode.cmd) ? app.debugMode.cmd : cmd,
Env: stdEnv.concat(addonEnv).concat(portEnv).concat(appEnv).concat(secondaryDomainsEnv),
ExposedPorts: isAppContainer ? exposedPorts : { },
Volumes: { // see also ReadonlyRootfs
'/tmp': {},
'/run': {}
},
Volumes: runtimeVolumes,
Labels: {
'fqdn': app.fqdn,
'appId': app.id,
@@ -343,7 +342,7 @@ async function createSubcontainer(app, name, cmd, options) {
'syslog-format': 'rfc5424'
}
},
Memory: system.getMemoryAllocation(memoryLimit),
Memory: await system.getMemoryAllocation(memoryLimit),
MemorySwap: memoryLimit, // Memory + Swap
PortBindings: isAppContainer ? dockerPortBindings : { },
PublishAllPorts: false,

View File

@@ -9,6 +9,8 @@ module.exports = exports = {
del,
clear,
getDomainObjectMap,
removePrivateFields,
removeRestrictedFields,
};
@@ -17,6 +19,7 @@ const assert = require('assert'),
BoxError = require('./boxerror.js'),
crypto = require('crypto'),
database = require('./database.js'),
debug = require('debug')('box:domains'),
eventlog = require('./eventlog.js'),
mail = require('./mail.js'),
reverseProxy = require('./reverseproxy.js'),
@@ -134,7 +137,7 @@ async function add(domain, data, auditSource) {
}
if (fallbackCertificate) {
let error = reverseProxy.validateCertificate('test', { domain, config }, fallbackCertificate);
let error = reverseProxy.validateCertificate('test', domain, fallbackCertificate);
if (error) throw error;
} else {
fallbackCertificate = await reverseProxy.generateFallbackCertificate(domain);
@@ -167,7 +170,7 @@ async function add(domain, data, auditSource) {
await eventlog.add(eventlog.ACTION_DOMAIN_ADD, auditSource, { domain, zoneName, provider });
safe(mail.onDomainAdded(domain)); // background
safe(mail.onDomainAdded(domain), { debug }); // background
}
async function get(domain) {
@@ -205,7 +208,7 @@ async function setConfig(domain, data, auditSource) {
}
if (fallbackCertificate) {
let error = reverseProxy.validateCertificate('test', domainObject, fallbackCertificate);
let error = reverseProxy.validateCertificate('test', domain, fallbackCertificate);
if (error) throw error;
}
@@ -240,9 +243,8 @@ async function setConfig(domain, data, auditSource) {
const result = await database.query('UPDATE domains SET ' + fields.join(', ') + ' WHERE domain=?', args);
if (result.affectedRows === 0) throw new BoxError(BoxError.NOT_FOUND, 'Domain not found');
if (!fallbackCertificate) return;
await reverseProxy.setFallbackCertificate(domain, fallbackCertificate);
if (fallbackCertificate) await reverseProxy.setFallbackCertificate(domain, fallbackCertificate);
if (!_.isEqual(domainObject.tlsConfig, tlsConfig.provider)) await reverseProxy.handleCertificateProviderChanged(domain);
await eventlog.add(eventlog.ACTION_DOMAIN_UPDATE, auditSource, { domain, zoneName, provider });
}
@@ -306,3 +308,10 @@ function removeRestrictedFields(domain) {
return result;
}
async function getDomainObjectMap() {
const domainObjects = await list();
let domainObjectMap = {};
for (let d of domainObjects) { domainObjectMap[d.domain] = d; }
return domainObjectMap;
}

View File

@@ -35,7 +35,7 @@ exports = module.exports = {
ACTION_BACKUP_CLEANUP_FINISH: 'backup.cleanup.finish',
ACTION_CERTIFICATE_NEW: 'certificate.new',
ACTION_CERTIFICATE_RENEWAL: 'certificate.renew',
ACTION_CERTIFICATE_RENEWAL: 'certificate.renew', // obsolete
ACTION_CERTIFICATE_CLEANUP: 'certificate.cleanup',
ACTION_DASHBOARD_DOMAIN_UPDATE: 'dashboard.domain.update',

View File

@@ -35,6 +35,7 @@ async function getContainerStats(name, fromMinutes, noNullPoints) {
const timeBucketSize = fromMinutes > (24 * 60) ? (6*60) : 5;
const graphiteUrl = await getGraphiteUrl();
// https://collectd.org/wiki/index.php/Data_source . the gauge is point in time value. counter is the change of value
const targets = [
`summarize(collectd.localhost.docker-stats-${name}.gauge-cpu-perc, "${timeBucketSize}min", "avg")`,
`summarize(collectd.localhost.docker-stats-${name}.gauge-mem-used, "${timeBucketSize}min", "avg")`,

View File

@@ -5,7 +5,7 @@ const assert = require('assert'),
crypto = require('crypto'),
debug = require('debug')('box:hush'),
fs = require('fs'),
progressStream = require('progress-stream'),
ProgressStream = require('./progress-stream.js'),
TransformStream = require('stream').Transform;
class EncryptStream extends TransformStream {
@@ -157,7 +157,7 @@ function createReadStream(sourceFile, encryption) {
assert.strictEqual(typeof encryption, 'object');
const stream = fs.createReadStream(sourceFile);
const ps = progressStream({ time: 10000 }); // display a progress every 10 seconds
const ps = new ProgressStream({ interval: 10000 }); // display a progress every 10 seconds
stream.on('error', function (error) {
debug(`createReadStream: read stream error at ${sourceFile}`, error);
@@ -185,7 +185,7 @@ function createWriteStream(destFile, encryption) {
assert.strictEqual(typeof encryption, 'object');
const stream = fs.createWriteStream(destFile);
const ps = progressStream({ time: 10000 }); // display a progress every 10 seconds
const ps = new ProgressStream({ interval: 10000 }); // display a progress every 10 seconds
stream.on('error', function (error) {
debug(`createWriteStream: write stream error ${destFile}`, error);

View File

@@ -6,7 +6,7 @@
exports = module.exports = {
// a version change recreates all containers with latest docker config
'version': '49.2.0',
'version': '49.4.0',
'baseImages': [
{ repo: 'cloudron/base', tag: 'cloudron/base:3.2.0@sha256:ba1d566164a67c266782545ea9809dc611c4152e27686fd14060332dd88263ea' }
@@ -17,11 +17,11 @@ exports = module.exports = {
'images': {
'turn': { repo: 'cloudron/turn', tag: 'cloudron/turn:1.4.0@sha256:45817f1631992391d585f171498d257487d872480fd5646723a2b956cc4ef15d' },
'mysql': { repo: 'cloudron/mysql', tag: 'cloudron/mysql:3.2.2@sha256:8648ca5a16fcdec72799b919c5f62419fd19e922e3d98d02896b921ae6127ef4' },
'postgresql': { repo: 'cloudron/postgresql', tag: 'cloudron/postgresql:4.3.4@sha256:84effb12e93d4e6467fedf3a426989980927ef90be61e73bde43476eebadf2a8' },
'postgresql': { repo: 'cloudron/postgresql', tag: 'cloudron/postgresql:4.3.5@sha256:bc8cb91cbd48ee9a2f5a609b6131cd21a0210c15aaf127ee77963d90a125530a' },
'mongodb': { repo: 'cloudron/mongodb', tag: 'cloudron/mongodb:4.2.2@sha256:df928d7dce1ac6454fc584787fa863f6d5e7ee0abb775dde5916a555fc94c3c7' },
'redis': { repo: 'cloudron/redis', tag: 'cloudron/redis:3.3.1@sha256:383e11a5c7a54d17eb6bbceb0ffa92f486167be6ea9978ec745c8c8e9b7dfb19' },
'mail': { repo: 'cloudron/mail', tag: 'cloudron/mail:3.7.0@sha256:44df70c8030cb9da452568c32fae7cae447e3b98cf48fdbc7b27a2466e706473' },
'graphite': { repo: 'cloudron/graphite', tag: 'cloudron/graphite:3.1.0@sha256:182e5cae69fbddc703cb9f91be909452065c7ae159e9836cc88317c7a00f0e62' },
'mail': { repo: 'cloudron/mail', tag: 'cloudron/mail:3.7.4@sha256:8ddbf13ee3fd479e18923c7bf1370d9d8aa5f12a94cbbda5afac8b5a4af72a28' },
'graphite': { repo: 'cloudron/graphite', tag: 'cloudron/graphite:3.2.0@sha256:182e5cae69fbddc703cb9f91be909452065c7ae159e9836cc88317c7a00f0e62' },
'sftp': { repo: 'cloudron/sftp', tag: 'cloudron/sftp:3.6.1@sha256:ba4b9a1fe274c0ef0a900e5d0deeb8f3da08e118798d1d90fbf995cc0cf6e3a3' }
}
};

View File

@@ -28,7 +28,7 @@ async function cleanupTmpVolume(containerInfo) {
const cmd = 'find /tmp -type f -mtime +10 -exec rm -rf {} +'.split(' '); // 10 day old files
debug('cleanupTmpVolume %j', containerInfo.Names);
debug(`cleanupTmpVolume ${JSON.stringify(containerInfo.Names)}`);
const [error, execContainer] = await safe(gConnection.getContainer(containerInfo.Id).exec({ Cmd: cmd, AttachStdout: true, AttachStderr: true, Tty: false }));
if (error) throw new BoxError(BoxError.DOCKER_ERROR, `Failed to exec container: ${error.message}`);
@@ -53,4 +53,6 @@ async function cleanupDockerVolumes() {
for (const container of containers) {
await safe(cleanupTmpVolume(container), { debug }); // intentionally ignore error
}
debug('Cleaned up docker volumes');
}

View File

@@ -178,7 +178,7 @@ async function userSearch(req, res, next) {
givenName: firstName,
username: user.username,
samaccountname: user.username, // to support ActiveDirectory clients
memberof: allGroups.filter(function (g) { return g.userIds.indexOf(user.id) !== -1; }).map(function (g) { return g.name; })
memberof: allGroups.filter(function (g) { return g.userIds.indexOf(user.id) !== -1; }).map(function (g) { return `cn=${g.name},ou=groups,dc=cloudron`; })
}
};

52
src/log-stream.js Normal file
View File

@@ -0,0 +1,52 @@
'use strict';
const stream = require('stream'),
{ StringDecoder } = require('string_decoder'),
TransformStream = stream.Transform;
class LogStream extends TransformStream {
constructor(options) {
super();
this._options = Object.assign({ source: 'unknown', format: 'json' }, options);
this._decoder = new StringDecoder();
this._soFar = '';
}
_format(line) {
if (this._options.format !== 'json') return line + '\n';
const data = line.split(' '); // logs are <ISOtimestamp> <msg>
let timestamp = (new Date(data[0])).getTime();
if (isNaN(timestamp)) timestamp = 0;
const message = line.slice(data[0].length+1);
// ignore faulty empty logs
if (!timestamp && !message) return;
return JSON.stringify({
realtimeTimestamp: timestamp * 1000,
message: message,
source: this._options.source
}) + '\n';
}
_transform(chunk, encoding, callback) {
const data = this._soFar + this._decoder.write(chunk);
let start = this._soFar.length, end = -1;
while ((end = data.indexOf('\n', start)) !== -1) {
const line = data.slice(start, end); // does not include end
this.push(this._format(line));
start = end + 1;
}
this._soFar = data.slice(start);
callback(null);
}
_flush(callback) {
const line = this._soFar + this._decoder.end();
this.push(this._format(line));
callback(null);
}
}
exports = module.exports = LogStream;

View File

@@ -1,11 +1,12 @@
# Generated by apptask
# keep upto 7 rotated logs. rotation triggered daily or ahead of time if size is > 1M
# keep upto 5 rotated logs. rotation triggered weekly or ahead of time if size is > 10M
<%= volumePath %>/*.log <%= volumePath %>/*/*.log <%= volumePath %>/*/*/*.log {
rotate 7
daily
rotate 5
weekly
maxage 14
compress
maxsize 1M
maxsize 10M
missingok
delaycompress
# this truncates the original log file and not the rotated one
@@ -15,7 +16,9 @@
/home/yellowtent/platformdata/logs/<%= appId %>/*.log {
# only keep one rotated file, we currently do not send that over the api
rotate 1
size 10M
weekly
maxage 14
maxsize 10M
missingok
# we never compress so we can simply tail the files
nocompress

View File

@@ -32,7 +32,7 @@ exports = module.exports = {
startMail,
restartMail,
handleCertChanged,
checkCertificate,
getMailAuth,
sendTestMail,
@@ -185,7 +185,9 @@ function validateDisplayName(name) {
if (name.length < 1) return new BoxError(BoxError.BAD_FIELD, 'mailbox display name must be atleast 1 char');
if (name.length >= 100) return new BoxError(BoxError.BAD_FIELD, 'mailbox display name too long');
if (/["<>@]/.test(name)) return new BoxError(BoxError.BAD_FIELD, 'mailbox display name is not valid');
// technically only ":" is disallowed it seems (https://www.rfc-editor.org/rfc/rfc5322#section-2.2)
// in https://www.rfc-editor.org/rfc/rfc2822.html, display-name is a "phrase"
if (/["<>)(,;\\@:]/.test(name)) return new BoxError(BoxError.BAD_FIELD, 'mailbox display name is not valid');
return null;
}
@@ -537,7 +539,7 @@ async function checkRblStatus(domain) {
const [error2, txtRecords] = await safe(dig.resolve(flippedIp + '.' + rblServer.dns, 'TXT', DNS_OPTIONS));
result.txtRecords = error2 || !txtRecords ? 'No txt record' : txtRecords.map(x => x.join(''));
debug(`checkRblStatus: ${domain} (error: ${error2.message}) (txtRecords: ${JSON.stringify(txtRecords)})`);
debug(`checkRblStatus: ${domain} (error: ${error2?.message || null}) (txtRecords: ${JSON.stringify(txtRecords)})`);
blacklistedServers.push(result);
}
@@ -588,7 +590,7 @@ async function getStatus(domain) {
for (let i = 0; i < checks.length; i++) {
const response = responses[i], check = checks[i];
if (response.status !== 'fulfilled') {
debug(`check ${check.what} was rejected. This is not expected`);
debug(`check ${check.what} was rejected. This is not expected. reason: ${response.reason}`);
continue;
}
@@ -682,7 +684,8 @@ async function createMailConfig(mailFqdn) {
const enableRelay = relay.provider !== 'cloudron-smtp' && relay.provider !== 'noop',
host = relay.host || '',
port = relay.port || 25,
authType = relay.username ? 'plain' : '',
// office365 removed plain auth (https://support.microsoft.com/en-us/office/outlook-com-no-longer-supports-auth-plain-authentication-07f7d5e9-1697-465f-84d2-4513d4ff0145)
authType = relay.username ? (relay.provider === 'office365-legacy-smtp' ? 'login' : 'plain') : '',
username = relay.username || '',
password = relay.password || '',
forceFromAddress = relay.forceFromAddress ? 'true' : 'false';
@@ -711,18 +714,18 @@ async function configureMail(mailFqdn, mailDomain, serviceConfig) {
const tag = infra.images.mail.tag;
const memoryLimit = serviceConfig.memoryLimit || exports.DEFAULT_MEMORY_LIMIT;
const memory = system.getMemoryAllocation(memoryLimit);
const memory = await system.getMemoryAllocation(memoryLimit);
const cloudronToken = hat(8 * 128), relayToken = hat(8 * 128);
const certificatePath = await reverseProxy.getCertificatePath(mailFqdn, mailDomain);
const certificate = await reverseProxy.getMailCertificate();
const dhparamsFilePath = `${paths.MAIL_CONFIG_DIR}/dhparams.pem`;
const mailCertFilePath = `${paths.MAIL_CONFIG_DIR}/tls_cert.pem`;
const mailKeyFilePath = `${paths.MAIL_CONFIG_DIR}/tls_key.pem`;
if (!safe.child_process.execSync(`cp ${paths.DHPARAMS_FILE} ${dhparamsFilePath}`)) throw new BoxError(BoxError.FS_ERROR, 'Could not copy dhparams:' + safe.error.message);
if (!safe.child_process.execSync(`cp ${certificatePath.certFilePath} ${mailCertFilePath}`)) throw new BoxError(BoxError.FS_ERROR, 'Could not create cert file:' + safe.error.message);
if (!safe.child_process.execSync(`cp ${certificatePath.keyFilePath} ${mailKeyFilePath}`)) throw new BoxError(BoxError.FS_ERROR, 'Could not create key file:' + safe.error.message);
if (!safe.child_process.execSync(`cp ${paths.DHPARAMS_FILE} ${dhparamsFilePath}`)) throw new BoxError(BoxError.FS_ERROR, `Could not copy dhparams: ${safe.error.message}`);
if (!safe.fs.writeFileSync(mailCertFilePath, certificate.cert)) throw new BoxError(BoxError.FS_ERROR, `Could not create cert file: ${safe.error.message}`);
if (!safe.fs.writeFileSync(mailKeyFilePath, certificate.key)) throw new BoxError(BoxError.FS_ERROR, `Could not create key file: ${safe.error.message}`);
// if the 'yellowtent' user of OS and the 'cloudron' user of mail container don't match, the keys become inaccessible by mail code
if (!safe.fs.chmodSync(mailKeyFilePath, 0o644)) throw new BoxError(BoxError.FS_ERROR, `Could not chmod key file: ${safe.error.message}`);
@@ -793,6 +796,7 @@ async function restartMail() {
async function startMail(existingInfra) {
assert.strictEqual(typeof existingInfra, 'object');
debug('startMail: starting');
await restartMail();
}
@@ -804,11 +808,19 @@ async function restartMailIfActivated() {
return; // not provisioned yet, do not restart container after dns setup
}
debug('restartMailIfActivated: restarting on activated');
await restartMail();
}
async function handleCertChanged() {
debug('handleCertChanged: will restart if activated');
async function checkCertificate() {
const certificate = await reverseProxy.getMailCertificate();
const cert = safe.fs.readFileSync(`${paths.MAIL_CONFIG_DIR}/tls_cert.pem`, { encoding: 'utf8' });
if (cert === certificate.cert) {
debug('checkCertificate: certificate has not changed');
return;
}
debug('checkCertificate: certificate has changed');
await restartMailIfActivated();
}
@@ -982,8 +994,7 @@ async function setLocation(subdomain, domain, auditSource) {
assert.strictEqual(typeof domain, 'string');
assert.strictEqual(typeof auditSource, 'object');
const domainObject = await domains.get(domain);
const fqdn = dns.fqdn(subdomain, domainObject);
const fqdn = dns.fqdn(subdomain, domain);
await settings.setMailLocation(domain, fqdn);
@@ -999,6 +1010,7 @@ async function onDomainAdded(domain) {
if (!settings.mailFqdn()) return; // mail domain is not set yet (when provisioning)
debug(`onDomainAdded: configuring mail for added domain ${domain}`);
await upsertDnsRecords(domain, settings.mailFqdn());
await restartMailIfActivated();
}
@@ -1006,6 +1018,7 @@ async function onDomainAdded(domain) {
async function onDomainRemoved(domain) {
assert.strictEqual(typeof domain, 'string');
debug(`onDomainRemoved: configuring mail for removed domain ${domain}`);
await restartMail();
}

View File

@@ -196,7 +196,7 @@ async function tryAddMount(mount, options) {
assert.strictEqual(typeof mount, 'object'); // { name, hostPath, mountType, mountOptions }
assert.strictEqual(typeof options, 'object'); // { timeout, skipCleanup }
if (mount.mountType === 'mountpoint') return;
if (mount.mountType === 'mountpoint' || mount.mountType === 'filesystem') return;
if (constants.TEST) return;
@@ -215,7 +215,7 @@ async function tryAddMount(mount, options) {
async function remount(mount) {
assert.strictEqual(typeof mount, 'object'); // { name, hostPath, mountType, mountOptions }
if (mount.mountType === 'mountpoint') return;
if (mount.mountType === 'mountpoint' || mount.mountType === 'filesystem') return;
if (constants.TEST) return;

View File

@@ -147,7 +147,9 @@ server {
proxy_read_timeout 3500;
proxy_connect_timeout 3250;
<% if ( endpoint !== 'external' ) { %>
proxy_set_header Host $host;
<% } %>
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port $server_port;

View File

@@ -2,10 +2,15 @@
exports = module.exports = once;
const debug = require('debug')('box:once');
// https://github.com/isaacs/once/blob/main/LICENSE (ISC)
function once (fn) {
const f = function () {
if (f.called) return f.value;
if (f.called) {
debug(`${f.name} was already called, returning previous return value`);
return f.value;
}
f.called = true;
return f.value = fn.apply(this, arguments);
};

View File

@@ -51,6 +51,7 @@ exports = module.exports = {
SFTP_PRIVATE_KEY_FILE: path.join(baseDir(), 'platformdata/sftp/ssh/ssh_host_rsa_key'),
FIREWALL_BLOCKLIST_FILE: path.join(baseDir(), 'platformdata/firewall/blocklist.txt'),
LDAP_ALLOWLIST_FILE: path.join(baseDir(), 'platformdata/firewall/ldap_allowlist.txt'),
REVERSE_PROXY_REBUILD_FILE: path.join(baseDir(), 'platformdata/nginx/rebuild-needed'),
BOX_DATA_DIR: path.join(baseDir(), 'boxdata/box'),
MAIL_DATA_DIR: path.join(baseDir(), 'boxdata/mail'),

View File

@@ -4,8 +4,7 @@ exports = module.exports = {
start,
stopAllTasks,
// exported for testing
_isReady: false
getStatus
};
const apps = require('./apps.js'),
@@ -26,10 +25,16 @@ const apps = require('./apps.js'),
volumes = require('./volumes.js'),
_ = require('underscore');
let gStatusMessage = 'Initializing';
function getStatus() {
return { message: gStatusMessage };
}
async function start(options) {
if (process.env.BOX_ENV === 'test' && !process.env.TEST_CREATE_INFRA) return;
debug('initializing addon infrastructure');
debug('initializing platform');
let existingInfra = { version: 'none' };
if (fs.existsSync(paths.INFRA_VERSION_FILE)) {
@@ -52,11 +57,13 @@ async function start(options) {
for (let attempt = 0; attempt < 5; attempt++) {
try {
if (existingInfra.version !== infra.version) {
gStatusMessage = 'Removing containers for upgrade';
await removeAllContainers();
await createDockerNetwork();
}
if (existingInfra.version === 'none') await volumes.mountAll(); // when restoring, mount all volumes
await markApps(existingInfra, options); // mark app state before we start addons. this gives the db import logic a chance to mark an app as errored
gStatusMessage = 'Starting services, this can take a while';
await services.startServices(existingInfra);
await fs.promises.writeFile(paths.INFRA_VERSION_FILE, JSON.stringify(infra, null, 4));
break;
@@ -81,7 +88,7 @@ async function stopAllTasks() {
async function onPlatformReady(infraChanged) {
debug(`onPlatformReady: platform is ready. infra changed: ${infraChanged}`);
exports._isReady = true;
gStatusMessage = 'Ready';
if (infraChanged) await safe(pruneInfraImages(), { debug }); // ignore error
@@ -95,20 +102,22 @@ async function pruneInfraImages() {
const images = infra.baseImages.concat(Object.keys(infra.images).map(function (addon) { return infra.images[addon]; }));
for (const image of images) {
let output = safe.child_process.execSync(`docker images --digests ${image.repo} --format "{{.ID}} {{.Repository}}:{{.Tag}}@{{.Digest}}"`, { encoding: 'utf8' });
const output = safe.child_process.execSync(`docker images --digests ${image.repo} --format "{{.ID}} {{.Repository}}:{{.Tag}}@{{.Digest}}"`, { encoding: 'utf8' });
if (output === null) {
debug(`Failed to list images of ${image}`, safe.error);
throw safe.error;
}
let lines = output.trim().split('\n');
for (let line of lines) {
const lines = output.trim().split('\n');
for (const line of lines) {
if (!line) continue;
let parts = line.split(' '); // [ ID, Repo:Tag@Digest ]
if (image.tag === parts[1]) continue; // keep
const parts = line.split(' '); // [ ID, Repo:Tag@Digest ]
const normalizedTag = parts[1].replace('registry.ipv6.docker.com/', '').replace('registry-1.docker.io/', '');
if (image.tag === normalizedTag) continue; // keep
debug(`pruneInfraImages: removing unused image of ${image.repo}: tag: ${parts[1]} id: ${parts[0]}`);
let result = safe.child_process.execSync(`docker rmi ${parts[0]}`, { encoding: 'utf8' });
let result = safe.child_process.execSync(`docker rmi ${parts[1].replace(':<none>', '')}`, { encoding: 'utf8' }); // the none tag has to be removed
if (result === null) debug(`Error removing image ${parts[0]}: ${safe.error.mesage}`);
}
}

44
src/progress-stream.js Normal file
View File

@@ -0,0 +1,44 @@
'use strict';
const stream = require('stream'),
TransformStream = stream.Transform;
class ProgressStream extends TransformStream {
constructor(options) {
super();
this._options = Object.assign({ interval: 10 * 1000 }, options);
this._transferred = 0;
this._delta = 0;
this._started = false;
this._startTime = null;
this._interval = null;
}
_start() {
this._startTime = Date.now();
this._started = true;
this._interval = setInterval(() => {
const speed = this._delta * 1000 / this._options.interval;
this._delta = 0;
this.emit('progress', { speed, transferred: this._transferred });
}, this._options.interval);
}
_stop() {
clearInterval(this._interval);
}
_transform(chunk, encoding, callback) {
if (!this._started) this._start();
this._transferred += chunk.length;
this._delta += chunk.length;
callback(null, chunk);
}
_flush(callback) {
this._stop();
callback(null);
}
}
exports = module.exports = ProgressStream;

View File

@@ -8,7 +8,9 @@ exports = module.exports = {
validateCertificate,
getCertificatePath, // resolved cert path
getMailCertificate,
getDirectoryServerCertificate,
ensureCertificate,
checkCerts,
@@ -25,8 +27,7 @@ exports = module.exports = {
removeAppConfigs,
restoreFallbackCertificates,
// exported for testing
_getAcmeApi: getAcmeApi
handleCertificateProviderChanged
};
const acme2 = require('./acme2.js'),
@@ -38,6 +39,7 @@ const acme2 = require('./acme2.js'),
crypto = require('crypto'),
debug = require('debug')('box:reverseproxy'),
dns = require('./dns.js'),
docker = require('./docker.js'),
domains = require('./domains.js'),
ejs = require('ejs'),
eventlog = require('./eventlog.js'),
@@ -50,7 +52,6 @@ const acme2 = require('./acme2.js'),
settings = require('./settings.js'),
shell = require('./shell.js'),
sysinfo = require('./sysinfo.js'),
users = require('./users.js'),
util = require('util');
const NGINX_APPCONFIG_EJS = fs.readFileSync(__dirname + '/nginxconfig.ejs', { encoding: 'utf8' });
@@ -63,39 +64,23 @@ function nginxLocation(s) {
return `~ ^(?!(${re.slice(1)}))`; // negative regex assertion - https://stackoverflow.com/questions/16302897/nginx-location-not-equal-to-regex
}
async function getAcmeApi(domainObject) {
assert.strictEqual(typeof domainObject, 'object');
function getCertificateDatesSync(cert) {
assert.strictEqual(typeof cert, 'string');
const apiOptions = { prod: false, performHttpAuthorization: false, wildcard: false, email: '' };
apiOptions.prod = domainObject.tlsConfig.provider.match(/.*-prod/) !== null; // matches 'le-prod' or 'letsencrypt-prod'
apiOptions.performHttpAuthorization = domainObject.provider.match(/noop|manual|wildcard/) !== null;
apiOptions.wildcard = !!domainObject.tlsConfig.wildcard;
const result = safe.child_process.spawnSync('/usr/bin/openssl', [ 'x509', '-startdate', '-enddate', '-subject', '-noout' ], { input: cert, encoding: 'utf8' });
if (!result) return { startDate: null, endDate: null } ; // some error
// registering user with an email requires A or MX record (https://github.com/letsencrypt/boulder/issues/1197)
// we cannot use admin@fqdn because the user might not have set it up.
// we simply update the account with the latest email we have each time when getting letsencrypt certs
// https://github.com/ietf-wg-acme/acme/issues/30
const [error, owner] = await safe(users.getOwner());
apiOptions.email = (error || !owner) ? 'webmaster@cloudron.io' : owner.email; // can error if not activated yet
const lines = result.stdout.trim().split('\n');
const notBefore = lines[0].split('=')[1];
const notBeforeDate = new Date(notBefore);
return { acme2, apiOptions };
}
function getExpiryDate(certFilePath) {
assert.strictEqual(typeof certFilePath, 'string');
if (!fs.existsSync(certFilePath)) return null; // not found
const result = safe.child_process.spawnSync('/usr/bin/openssl', [ 'x509', '-enddate', '-noout', '-in', certFilePath ]);
if (!result) return null; // some error
const notAfter = result.stdout.toString('utf8').trim().split('=')[1];
const notAfter = lines[1].split('=')[1];
const notAfterDate = new Date(notAfter);
const daysLeft = (notAfterDate - new Date())/(24 * 60 * 60 * 1000);
debug(`expiryDate: ${certFilePath} notAfter=${notAfter} daysLeft=${daysLeft}`);
debug(`expiryDate: ${lines[2]} notBefore=${notBefore} notAfter=${notAfter} daysLeft=${daysLeft}`);
return notAfterDate;
return { startDate: notBeforeDate, endDate: notAfterDate };
}
async function isOcspEnabled(certFilePath) {
@@ -110,14 +95,11 @@ async function isOcspEnabled(certFilePath) {
}
// checks if the certificate matches the options provided by user (like wildcard, le-staging etc)
function providerMatchesSync(domainObject, certFilePath, apiOptions) {
function providerMatchesSync(domainObject, cert) {
assert.strictEqual(typeof domainObject, 'object');
assert.strictEqual(typeof certFilePath, 'string');
assert.strictEqual(typeof apiOptions, 'object');
assert.strictEqual(typeof cert, 'string');
if (!fs.existsSync(certFilePath)) return false; // not found
const subjectAndIssuer = safe.child_process.execSync(`/usr/bin/openssl x509 -noout -subject -issuer -in "${certFilePath}"`, { encoding: 'utf8' });
const subjectAndIssuer = safe.child_process.execSync('/usr/bin/openssl x509 -noout -subject -issuer', { encoding: 'utf8', input: cert });
if (!subjectAndIssuer) return false; // something bad happenned
const subject = subjectAndIssuer.match(/^subject=(.*)$/m)[1];
@@ -126,14 +108,17 @@ function providerMatchesSync(domainObject, certFilePath, apiOptions) {
const isWildcardCert = domain.includes('*');
const isLetsEncryptProd = issuer.includes('Let\'s Encrypt') && !issuer.includes('STAGING');
const issuerMismatch = (apiOptions.prod && !isLetsEncryptProd) || (!apiOptions.prod && isLetsEncryptProd);
const prod = domainObject.tlsConfig.provider.match(/.*-prod/) !== null; // matches 'le-prod' or 'letsencrypt-prod'
const wildcard = !!domainObject.tlsConfig.wildcard;
const issuerMismatch = (prod && !isLetsEncryptProd) || (!prod && isLetsEncryptProd);
// bare domain is not part of wildcard SAN
const wildcardMismatch = (domain !== domainObject.domain) && (apiOptions.wildcard && !isWildcardCert) || (!apiOptions.wildcard && isWildcardCert);
const wildcardMismatch = (domain !== domainObject.domain) && (wildcard && !isWildcardCert) || (!wildcard && isWildcardCert);
const mismatch = issuerMismatch || wildcardMismatch;
debug(`providerMatchesSync: ${certFilePath} subject=${subject} domain=${domain} issuer=${issuer} `
+ `wildcard=${isWildcardCert}/${apiOptions.wildcard} prod=${isLetsEncryptProd}/${apiOptions.prod} `
debug(`providerMatchesSync: subject=${subject} domain=${domain} issuer=${issuer} `
+ `wildcard=${isWildcardCert}/${wildcard} prod=${isLetsEncryptProd}/${prod} `
+ `issuerMismatch=${issuerMismatch} wildcardMismatch=${wildcardMismatch} match=${!mismatch}`);
return !mismatch;
@@ -141,9 +126,9 @@ function providerMatchesSync(domainObject, certFilePath, apiOptions) {
// note: https://tools.ietf.org/html/rfc4346#section-7.4.2 (certificate_list) requires that the
// servers certificate appears first (and not the intermediate cert)
function validateCertificate(subdomain, domainObject, certificate) {
function validateCertificate(subdomain, domain, certificate) {
assert.strictEqual(typeof subdomain, 'string');
assert.strictEqual(typeof domainObject, 'object');
assert.strictEqual(typeof domain, 'string');
assert(certificate && typeof certificate, 'object');
const { cert, key } = certificate;
@@ -153,7 +138,7 @@ function validateCertificate(subdomain, domainObject, certificate) {
if (cert && !key) return new BoxError(BoxError.BAD_FIELD, 'missing key');
// -checkhost checks for SAN or CN exclusively. SAN takes precedence and if present, ignores the CN.
const fqdn = dns.fqdn(subdomain, domainObject);
const fqdn = dns.fqdn(subdomain, domain);
let result = safe.child_process.execSync(`openssl x509 -noout -checkhost "${fqdn}"`, { encoding: 'utf8', input: cert });
if (result === null) return new BoxError(BoxError.BAD_FIELD, 'Unable to get certificate subject:' + safe.error.message);
@@ -176,6 +161,15 @@ function validateCertificate(subdomain, domainObject, certificate) {
return null;
}
async function notifyCertChange() {
await mail.checkCertificate();
await shell.promises.sudo('notifyCertChange', [ RESTART_SERVICE_CMD, 'box' ], {}); // directory server
const allApps = (await apps.list()).filter(app => app.runState !== apps.RSTATE_STOPPED);
for (const app of allApps) {
if (app.manifest.addons?.tls) await setupTlsAddon(app);
}
}
async function reload() {
if (constants.TEST) return;
@@ -224,8 +218,8 @@ async function setFallbackCertificate(domain, certificate) {
if (!safe.fs.writeFileSync(path.join(paths.NGINX_CERT_DIR, `${domain}.host.cert`), certificate.cert)) throw new BoxError(BoxError.FS_ERROR, safe.error.message);
if (!safe.fs.writeFileSync(path.join(paths.NGINX_CERT_DIR, `${domain}.host.key`), certificate.key)) throw new BoxError(BoxError.FS_ERROR, safe.error.message);
// TODO: maybe the cert is being used by the mail container
await reload();
await notifyCertChange(); // if domain uses fallback certs, propagate immediately
}
async function restoreFallbackCertificates() {
@@ -237,182 +231,212 @@ async function restoreFallbackCertificates() {
}
}
function getFallbackCertificatePathSync(domain) {
assert.strictEqual(typeof domain, 'string');
function getAppLocationsSync(app) {
assert.strictEqual(typeof app, 'object');
const certFilePath = path.join(paths.NGINX_CERT_DIR, `${domain}.host.cert`);
const keyFilePath = path.join(paths.NGINX_CERT_DIR, `${domain}.host.key`);
return { certFilePath, keyFilePath };
return [{ domain: app.domain, fqdn: app.fqdn, certificate: app.certificate, type: apps.LOCATION_TYPE_PRIMARY }]
.concat(app.secondaryDomains.map(sd => { return { domain: sd.domain, certificate: sd.certificate, fqdn: sd.fqdn, type: apps.LOCATION_TYPE_SECONDARY }; }))
.concat(app.redirectDomains.map(rd => { return { domain: rd.domain, certificate: rd.certificate, fqdn: rd.fqdn, type: apps.LOCATION_TYPE_REDIRECT }; }))
.concat(app.aliasDomains.map(ad => { return { domain: ad.domain, certificate: ad.certificate, fqdn: ad.fqdn, type: apps.LOCATION_TYPE_ALIAS }; }));
}
function getUserCertificatePathSync(fqdn) {
assert.strictEqual(typeof fqdn, 'string');
const certFilePath = path.join(paths.NGINX_CERT_DIR, `${fqdn}.user.cert`);
const keyFilePath = path.join(paths.NGINX_CERT_DIR, `${fqdn}.user.key`);
return { certFilePath, keyFilePath };
}
function getAcmeCertificatePathSync(fqdn, domainObject) {
function getAcmeCertificateNameSync(fqdn, domainObject) {
assert.strictEqual(typeof fqdn, 'string'); // this can contain wildcard domain (for alias domains)
assert.strictEqual(typeof domainObject, 'object');
let certName, certFilePath, keyFilePath, csrFilePath, acmeChallengesDir = paths.ACME_CHALLENGES_DIR;
if (fqdn !== domainObject.domain && domainObject.tlsConfig.wildcard) { // bare domain is not part of wildcard SAN
certName = dns.makeWildcard(fqdn).replace('*.', '_.');
certFilePath = path.join(paths.NGINX_CERT_DIR, `${certName}.cert`);
keyFilePath = path.join(paths.NGINX_CERT_DIR, `${certName}.key`);
csrFilePath = path.join(paths.NGINX_CERT_DIR, `${certName}.csr`);
return dns.makeWildcard(fqdn).replace('*.', '_.');
} else if (fqdn.includes('*')) { // alias domain with non-wildcard cert
return fqdn.replace('*.', '_.');
} else {
certName = fqdn;
certFilePath = path.join(paths.NGINX_CERT_DIR, `${fqdn}.cert`);
keyFilePath = path.join(paths.NGINX_CERT_DIR, `${fqdn}.key`);
csrFilePath = path.join(paths.NGINX_CERT_DIR, `${fqdn}.csr`);
return fqdn;
}
}
function needsRenewalSync(cert, options) {
assert.strictEqual(typeof cert, 'string');
assert.strictEqual(typeof options, 'object');
const { startDate, endDate } = getCertificateDatesSync(cert);
const now = new Date();
let isExpiring;
if (options.forceRenewal) {
isExpiring = (now - startDate) > (65 * 60 * 1000); // was renewed 5 minutes ago. LE backdates issue date by 1 hour for clock skew
} else {
isExpiring = (endDate - now) <= (30 * 24 * 60 * 60 * 1000); // expiring in a month
}
return { certName, certFilePath, keyFilePath, csrFilePath, acmeChallengesDir };
debug(`needsRenewal: ${isExpiring}. force: ${!!options.forceRenewal}`);
return isExpiring;
}
async function getCertificatePath(fqdn, domain) {
assert.strictEqual(typeof fqdn, 'string');
assert.strictEqual(typeof domain, 'string');
// 1. user cert always wins
// 2. if using fallback provider, return that cert
// 3. look for LE certs
async function getCertificate(location) {
assert.strictEqual(typeof location, 'object');
const { domain, fqdn } = location;
const domainObject = await domains.get(domain);
if (!domainObject) throw new BoxError(BoxError.NOT_FOUND, `${domain} not found`);
const userPath = getUserCertificatePathSync(fqdn); // user cert always wins
if (fs.existsSync(userPath.certFilePath) && fs.existsSync(userPath.keyFilePath)) return userPath;
if (location.certificate) return location.certificate;
if (domainObject.tlsConfig.provider === 'fallback') return domainObject.fallbackCertificate;
if (domainObject.tlsConfig.provider === 'fallback') return getFallbackCertificatePathSync(domain);
const certName = getAcmeCertificateNameSync(fqdn, domainObject);
const cert = await blobs.getString(`${blobs.CERT_PREFIX}-${certName}.cert`);
const key = await blobs.getString(`${blobs.CERT_PREFIX}-${certName}.key`);
if (!key || !cert) return domainObject.fallbackCertificate;
const acmePath = getAcmeCertificatePathSync(fqdn, domainObject);
if (fs.existsSync(acmePath.certFilePath) && fs.existsSync(acmePath.keyFilePath)) return acmePath;
return getFallbackCertificatePathSync(domain);
return { key, cert };
}
async function syncUserCertificate(fqdn, domainObject) {
assert.strictEqual(typeof fqdn, 'string'); // this can contain wildcard domain (for alias domains)
assert.strictEqual(typeof domainObject, 'object');
const subdomain = fqdn.substr(0, fqdn.length - domainObject.domain.length - 1);
const userCertificate = await apps.getCertificate(subdomain, domainObject.domain);
if (!userCertificate) return null;
const { certFilePath, keyFilePath } = getUserCertificatePathSync(fqdn);
if (!safe.fs.writeFileSync(certFilePath, userCertificate.cert)) throw new BoxError(BoxError.FS_ERROR, `Failed to write certificate: ${safe.error.message}`);
if (!safe.fs.writeFileSync(keyFilePath, userCertificate.key)) throw new BoxError(BoxError.FS_ERROR, `Failed to write key: ${safe.error.message}`);
return { certFilePath, keyFilePath };
async function getMailCertificate() {
return await getCertificate({ domain: settings.mailDomain(), fqdn: settings.mailFqdn(), certificate: null, type: apps.LOCATION_TYPE_MAIL });
}
async function syncAcmeCertificate(fqdn, domainObject) {
assert.strictEqual(typeof fqdn, 'string'); // this can contain wildcard domain (for alias domains)
assert.strictEqual(typeof domainObject, 'object');
const { certName, certFilePath, keyFilePath, csrFilePath } = getAcmeCertificatePathSync(fqdn, domainObject);
const privateKey = await blobs.get(`${blobs.CERT_PREFIX}-${certName}.key`);
const cert = await blobs.get(`${blobs.CERT_PREFIX}-${certName}.cert`);
const csr = await blobs.get(`${blobs.CERT_PREFIX}-${certName}.csr`);
if (!privateKey || !cert) return null;
if (!safe.fs.writeFileSync(keyFilePath, privateKey)) throw new BoxError(BoxError.FS_ERROR, `Failed to write private key: ${safe.error.message}`);
if (!safe.fs.writeFileSync(certFilePath, cert)) throw new BoxError(BoxError.FS_ERROR, `Failed to write certificate: ${safe.error.message}`);
if (csr) safe.fs.writeFileSync(csrFilePath, csr);
return { certFilePath, keyFilePath };
async function getDirectoryServerCertificate() {
return await getCertificate({ domain: settings.dashboardDomain(), fqdn: settings.dashboardFqdn(), certificate: null, type: apps.LOCATION_TYPE_DIRECTORY_SERVER });
}
async function updateCertBlobs(fqdn, domainObject) {
assert.strictEqual(typeof fqdn, 'string'); // this can contain wildcard domain (for alias domains)
assert.strictEqual(typeof domainObject, 'object');
// write if contents mismatch (thus preserving mtime)
function writeFileSync(filePath, data) {
assert.strictEqual(typeof filePath, 'string');
assert.strictEqual(typeof data, 'string');
const { certName, certFilePath, keyFilePath, csrFilePath } = getAcmeCertificatePathSync(fqdn, domainObject);
const privateKey = safe.fs.readFileSync(keyFilePath);
if (!privateKey) throw new BoxError(BoxError.FS_ERROR, `Failed to read private key: ${safe.error.message}`);
const cert = safe.fs.readFileSync(certFilePath);
if (!cert) throw new BoxError(BoxError.FS_ERROR, `Failed to read cert: ${safe.error.message}`);
const csr = safe.fs.readFileSync(csrFilePath);
if (!csr) throw new BoxError(BoxError.FS_ERROR, `Failed to read csr: ${safe.error.message}`);
await blobs.set(`${blobs.CERT_PREFIX}-${certName}.key`, privateKey);
await blobs.set(`${blobs.CERT_PREFIX}-${certName}.cert`, cert);
await blobs.set(`${blobs.CERT_PREFIX}-${certName}.csr`, csr);
const curData = safe.fs.readFileSync(filePath, { encoding: 'utf8' });
if (curData === data) return false;
if (!safe.fs.writeFileSync(filePath, data)) throw new BoxError(BoxError.FS_ERROR, safe.error.message);
return true;
}
async function ensureCertificate(subdomain, domain, auditSource) {
assert.strictEqual(typeof subdomain, 'string');
assert.strictEqual(typeof domain, 'string');
assert.strictEqual(typeof auditSource, 'object');
async function setupTlsAddon(app) {
assert.strictEqual(typeof app, 'object');
const certificateDir = `${paths.PLATFORM_DATA_DIR}/tls/${app.id}`;
const contents = [];
for (const location of getAppLocationsSync(app)) {
const certificate = await getCertificate(location);
contents.push({ filename: `${location.fqdn}.cert`, data: certificate.cert });
contents.push({ filename: `${location.fqdn}.key`, data: certificate.key });
if (location.type === apps.LOCATION_TYPE_PRIMARY) { // backward compat
contents.push({ filename: 'tls_cert.pem', data: certificate.cert });
contents.push({ filename: 'tls_key.pem', data: certificate.key });
}
}
let changed = 0;
for (const content of contents) {
if (writeFileSync(`${certificateDir}/${content.filename}`, content.data)) ++changed;
}
debug(`setupTlsAddon: ${changed} files changed`);
// clean up any certs of old locations
const filenamesInUse = new Set(contents.map(c => c.filename));
const filenames = safe.fs.readdirSync(certificateDir) || [];
let removed = 0;
for (const filename of filenames) {
if (filenamesInUse.has(filename)) continue;
safe.fs.unlinkSync(path.join(certificateDir, filename));
++removed;
}
debug(`setupTlsAddon: ${removed} files removed`);
if (changed || removed) await docker.restartContainer(app.id);
}
// writes latest certificate to disk and returns the path
async function writeCertificate(location) {
assert.strictEqual(typeof location, 'object');
const { domain, fqdn } = location;
const domainObject = await domains.get(domain);
if (!domainObject) throw new BoxError(BoxError.NOT_FOUND, `${domain} not found`);
const userCertificatePath = await syncUserCertificate(subdomain, domainObject);
if (userCertificatePath) return { certificatePath: userCertificatePath, renewed: false };
if (location.certificate) {
const certFilePath = path.join(paths.NGINX_CERT_DIR, `${fqdn}.user.cert`);
const keyFilePath = path.join(paths.NGINX_CERT_DIR, `${fqdn}.user.key`);
writeFileSync(certFilePath, location.certificate.cert);
writeFileSync(keyFilePath, location.certificate.key);
return { certFilePath, keyFilePath };
}
if (domainObject.tlsConfig.provider === 'fallback') {
debug(`ensureCertificate: ${subdomain} will use fallback certs`);
const certFilePath = path.join(paths.NGINX_CERT_DIR, `${domain}.host.cert`);
const keyFilePath = path.join(paths.NGINX_CERT_DIR, `${domain}.host.key`);
return { certificatePath: getFallbackCertificatePathSync(domain), renewed: false };
debug(`writeCertificate: ${fqdn} will use fallback certs`);
writeFileSync(certFilePath, domainObject.fallbackCertificate.cert);
writeFileSync(keyFilePath, domainObject.fallbackCertificate.key);
return { certFilePath, keyFilePath };
}
const { acme2, apiOptions } = await getAcmeApi(domainObject);
let notAfter = null;
const certName = getAcmeCertificateNameSync(fqdn, domainObject);
let cert = await blobs.getString(`${blobs.CERT_PREFIX}-${certName}.cert`);
let key = await blobs.getString(`${blobs.CERT_PREFIX}-${certName}.key`);
const [, acmeCertificatePath] = await safe(syncAcmeCertificate(subdomain, domainObject));
if (acmeCertificatePath) {
debug(`ensureCertificate: ${subdomain} certificate already exists at ${acmeCertificatePath.keyFilePath}`);
notAfter = getExpiryDate(acmeCertificatePath.certFilePath);
const isExpiring = (notAfter - new Date()) <= (30 * 24 * 60 * 60 * 1000); // expiring in a month
if (!isExpiring && providerMatchesSync(domainObject, acmeCertificatePath.certFilePath, apiOptions)) return { certificatePath: acmeCertificatePath, renewed: false };
debug(`ensureCertificate: ${subdomain} cert requires renewal`);
} else {
debug(`ensureCertificate: ${subdomain} cert does not exist`);
if (!key || !cert) { // use fallback certs if we didn't manage to get acme certs
debug(`writeCertificate: ${fqdn} will use fallback certs because acme is missing`);
cert = domainObject.fallbackCertificate.cert;
key = domainObject.fallbackCertificate.key;
}
const certFilePath = path.join(paths.NGINX_CERT_DIR, `${certName}.cert`);
const keyFilePath = path.join(paths.NGINX_CERT_DIR, `${certName}.key`);
debug(`ensureCertificate: getting certificate for ${subdomain} with options ${JSON.stringify(apiOptions)}`);
writeFileSync(certFilePath, cert);
writeFileSync(keyFilePath, key);
const acmePaths = getAcmeCertificatePathSync(subdomain, domainObject);
const [error] = await safe(acme2.getCertificate(subdomain, domain, acmePaths, apiOptions));
debug(`ensureCertificate: error: ${error ? error.message : 'null'} cert: ${acmePaths.certFilePath || 'null'}`);
await safe(eventlog.add(acmeCertificatePath ? eventlog.ACTION_CERTIFICATE_RENEWAL : eventlog.ACTION_CERTIFICATE_NEW, auditSource, { domain: subdomain, errorMessage: error ? error.message : '', notAfter }));
if (error && acmeCertificatePath && (notAfter - new Date() > 0)) { // still some life left in this certificate
debug('ensureCertificate: continue using existing certificate since renewal failed');
return { certificatePath: acmeCertificatePath, renewed: false };
}
if (!error) {
const [updateCertError] = await safe(updateCertBlobs(subdomain, domainObject));
if (!updateCertError) return { certificatePath: { certFilePath: acmePaths.certFilePath, keyFilePath: acmePaths.keyFilePath }, renewed: true };
}
debug(`ensureCertificate: renewal of ${subdomain} failed. using fallback certificates for ${domain}`);
return { certificatePath: getFallbackCertificatePathSync(domain), renewed: false };
return { certFilePath, keyFilePath };
}
async function writeDashboardNginxConfig(fqdn, certificatePath) {
assert.strictEqual(typeof fqdn, 'string');
async function ensureCertificate(location, options, auditSource) {
assert.strictEqual(typeof location, 'object');
assert.strictEqual(typeof options, 'object');
assert.strictEqual(typeof auditSource, 'object');
const domainObject = await domains.get(location.domain);
if (!domainObject) throw new BoxError(BoxError.NOT_FOUND, `${location.domain} not found`);
const fqdn = location.fqdn;
if (location.certificate) { // user certificate
debug(`ensureCertificate: ${fqdn} will use user certs`);
return;
}
if (domainObject.tlsConfig.provider === 'fallback') {
debug(`ensureCertificate: ${fqdn} will use fallback certs`);
return;
}
const certName = getAcmeCertificateNameSync(fqdn, domainObject);
const key = await blobs.getString(`${blobs.CERT_PREFIX}-${certName}.key`);
const cert = await blobs.getString(`${blobs.CERT_PREFIX}-${certName}.cert`);
if (key && cert) {
if (providerMatchesSync(domainObject, cert) && !needsRenewalSync(cert, options)) {
debug(`ensureCertificate: ${fqdn} acme cert exists and is up to date`);
return;
}
debug(`ensureCertificate: ${fqdn} acme cert exists but provider mismatch or needs renewal`);
}
debug(`ensureCertificate: ${fqdn} needs acme cert`);
const [error] = await safe(acme2.getCertificate(fqdn, domainObject));
debug(`ensureCertificate: error: ${error ? error.message : 'null'}`);
await safe(eventlog.add(eventlog.ACTION_CERTIFICATE_NEW, auditSource, { domain: fqdn, errorMessage: error?.message || '' }));
}
async function writeDashboardNginxConfig(vhost, certificatePath) {
assert.strictEqual(typeof vhost, 'string');
assert.strictEqual(typeof certificatePath, 'object');
const data = {
sourceDir: path.resolve(__dirname, '..'),
vhost: fqdn,
vhost,
hasIPv6: sysinfo.hasIPv6(),
endpoint: 'dashboard',
certFilePath: certificatePath.certFilePath,
@@ -422,51 +446,34 @@ async function writeDashboardNginxConfig(fqdn, certificatePath) {
ocsp: await isOcspEnabled(certificatePath.certFilePath)
};
const nginxConf = ejs.render(NGINX_APPCONFIG_EJS, data);
const nginxConfigFilename = path.join(paths.NGINX_APPCONFIG_DIR, `${fqdn}.conf`);
const nginxConfigFilename = path.join(paths.NGINX_APPCONFIG_DIR, `dashboard/${vhost}.conf`);
if (!safe.fs.writeFileSync(nginxConfigFilename, nginxConf)) throw new BoxError(BoxError.FS_ERROR, safe.error);
writeFileSync(nginxConfigFilename, nginxConf);
}
// also syncs the certs to disk
async function writeDashboardConfig(domain) {
assert.strictEqual(typeof domain, 'string');
debug(`writeDashboardConfig: writing admin config for ${domain}`);
const dashboardFqdn = dns.fqdn(constants.DASHBOARD_SUBDOMAIN, domain);
const location = { domain, fqdn: dashboardFqdn, certificate: null };
const certificatePath = await writeCertificate(location);
await writeDashboardNginxConfig(dashboardFqdn, certificatePath);
await reload();
}
async function writeDashboardConfig(domainObject) {
assert.strictEqual(typeof domainObject, 'object');
debug(`writeDashboardConfig: writing admin config for ${domainObject.domain}`);
const dashboardFqdn = dns.fqdn(constants.DASHBOARD_SUBDOMAIN, domainObject);
const certificatePath = await getCertificatePath(dashboardFqdn, domainObject.domain);
await writeDashboardNginxConfig(dashboardFqdn, certificatePath);
}
function getNginxConfigFilename(app, fqdn, type) {
async function writeAppLocationNginxConfig(app, location, certificatePath) {
assert.strictEqual(typeof app, 'object');
assert.strictEqual(typeof fqdn, 'string');
assert.strictEqual(typeof type, 'string');
let nginxConfigFilenameSuffix = '';
if (type === apps.LOCATION_TYPE_ALIAS) {
nginxConfigFilenameSuffix = `-alias-${fqdn.replace('*', '_')}`;
} else if (type === apps.LOCATION_TYPE_SECONDARY) {
nginxConfigFilenameSuffix = `-secondary-${fqdn}`;
} else if (type === apps.LOCATION_TYPE_REDIRECT) {
nginxConfigFilenameSuffix = `-redirect-${fqdn}`;
}
return path.join(paths.NGINX_APPCONFIG_DIR, `${app.id}${nginxConfigFilenameSuffix}.conf`);
}
async function writeAppNginxConfig(app, fqdn, type, certificatePath) {
assert.strictEqual(typeof app, 'object');
assert.strictEqual(typeof fqdn, 'string');
assert.strictEqual(typeof type, 'string');
assert.strictEqual(typeof location, 'object');
assert.strictEqual(typeof certificatePath, 'object');
const type = location.type, vhost = location.fqdn;
const data = {
sourceDir: path.resolve(__dirname, '..'),
vhost: fqdn,
vhost,
hasIPv6: sysinfo.hasIPv6(),
ip: null,
port: null,
@@ -487,10 +494,6 @@ async function writeAppNginxConfig(app, fqdn, type, certificatePath) {
if (app.manifest.id === constants.PROXY_APP_APPSTORE_ID) {
data.endpoint = 'external';
// prevent generating invalid nginx configs
if (!app.upstreamUri) throw new BoxError(BoxError.BAD_FIELD, 'upstreamUri cannot be empty');
data.upstreamUri = app.upstreamUri;
}
@@ -512,7 +515,7 @@ async function writeAppNginxConfig(app, fqdn, type, certificatePath) {
data.port = app.manifest.httpPort;
} else if (type === apps.LOCATION_TYPE_SECONDARY) {
data.ip = app.containerIp;
const secondaryDomain = app.secondaryDomains.find(sd => sd.fqdn === fqdn);
const secondaryDomain = app.secondaryDomains.find(sd => sd.fqdn === vhost);
data.port = app.manifest.httpPorts[secondaryDomain.environmentVariable].containerPort;
}
} else if (type === apps.LOCATION_TYPE_REDIRECT) {
@@ -522,178 +525,98 @@ async function writeAppNginxConfig(app, fqdn, type, certificatePath) {
}
const nginxConf = ejs.render(NGINX_APPCONFIG_EJS, data);
const filename = getNginxConfigFilename(app, fqdn, type);
debug(`writeAppNginxConfig: writing config for "${fqdn}" to ${filename} with options ${JSON.stringify(data)}`);
if (!safe.fs.writeFileSync(filename, nginxConf)) {
debug(`Error creating nginx config for "${app.fqdn}" : ${safe.error.message}`);
throw new BoxError(BoxError.FS_ERROR, safe.error);
}
await reload();
const filename = path.join(paths.NGINX_APPCONFIG_DIR, app.id, `${vhost.replace('*', '_')}.conf`);
debug(`writeAppLocationNginxConfig: writing config for "${vhost}" to ${filename} with options ${JSON.stringify(data)}`);
writeFileSync(filename, nginxConf);
}
async function writeAppConfigs(app) {
assert.strictEqual(typeof app, 'object');
const appDomains = [{ domain: app.domain, fqdn: app.fqdn, certificate: app.certificate, type: apps.LOCATION_TYPE_PRIMARY }]
.concat(app.secondaryDomains.map(sd => { return { domain: sd.domain, certificate: sd.certificate, fqdn: sd.fqdn, type: apps.LOCATION_TYPE_SECONDARY }; }))
.concat(app.redirectDomains.map(rd => { return { domain: rd.domain, certificate: rd.certificate, fqdn: rd.fqdn, type: apps.LOCATION_TYPE_REDIRECT }; }))
.concat(app.aliasDomains.map(ad => { return { domain: ad.domain, certificate: ad.certificate, fqdn: ad.fqdn, type: apps.LOCATION_TYPE_ALIAS }; }));
const locations = getAppLocationsSync(app);
for (const appDomain of appDomains) {
const certificatePath = await getCertificatePath(appDomain.fqdn, appDomain.domain);
await writeAppNginxConfig(app, appDomain.fqdn, appDomain.type, certificatePath);
if (!safe.fs.mkdirSync(path.join(paths.NGINX_APPCONFIG_DIR, app.id), { recursive: true })) throw new BoxError(BoxError.FS_ERROR, `Could not create nginx config directory: ${safe.error.message}`);
for (const location of locations) {
const certificatePath = await writeCertificate(location);
await writeAppLocationNginxConfig(app, location, certificatePath);
}
await reload();
}
async function setUserCertificate(app, fqdn, certificate) {
const { certFilePath, keyFilePath } = getUserCertificatePathSync(fqdn);
async function setUserCertificate(app, location) {
assert.strictEqual(typeof app, 'object');
assert.strictEqual(typeof location, 'object');
if (certificate !== null) {
if (!safe.fs.writeFileSync(certFilePath, certificate.cert)) throw safe.error;
if (!safe.fs.writeFileSync(keyFilePath, certificate.key)) throw safe.error;
} else { // remove existing cert/key
if (!safe.fs.unlinkSync(certFilePath)) debug(`Error removing cert: ${safe.error.message}`);
if (!safe.fs.unlinkSync(keyFilePath)) debug(`Error removing key: ${safe.error.message}`);
}
await writeAppConfigs(app);
const certificatePath = await writeCertificate(location);
await writeAppLocationNginxConfig(app, location, certificatePath);
await reload();
}
async function configureApp(app, auditSource) {
assert.strictEqual(typeof app, 'object');
assert.strictEqual(typeof auditSource, 'object');
const appDomains = [{ domain: app.domain, fqdn: app.fqdn }]
.concat(app.secondaryDomains.map(sd => { return { domain: sd.domain, fqdn: sd.fqdn }; }))
.concat(app.redirectDomains.map(rd => { return { domain: rd.domain, fqdn: rd.fqdn }; }))
.concat(app.aliasDomains.map(ad => { return { domain: ad.domain, fqdn: ad.fqdn }; }));
const locations = getAppLocationsSync(app);
for (const appDomain of appDomains) {
await ensureCertificate(appDomain.fqdn, appDomain.domain, auditSource);
for (const location of locations) {
await ensureCertificate(location, {}, auditSource);
}
await writeAppConfigs(app);
if (app.manifest.addons?.tls) await setupTlsAddon(app);
}
async function unconfigureApp(app) {
assert.strictEqual(typeof app, 'object');
const configFilenames = safe.fs.readdirSync(paths.NGINX_APPCONFIG_DIR);
if (!configFilenames) throw new BoxError(BoxError.FS_ERROR, `Error loading nginx config files: ${safe.error.message}`);
for (const filename of configFilenames) {
if (!filename.startsWith(app.id)) continue;
safe.fs.unlinkSync(path.join(paths.NGINX_APPCONFIG_DIR, filename));
}
if (!safe.fs.rmSync(path.join(paths.NGINX_APPCONFIG_DIR, app.id), { recursive: true, force: true })) throw new BoxError(BoxError.FS_ERROR, `Could not remove nginx config directory: ${safe.error.message}`);
await reload();
}
async function renewCerts(options, auditSource, progressCallback) {
assert.strictEqual(typeof options, 'object');
async function cleanupCerts(locations, auditSource, progressCallback) {
assert(Array.isArray(locations));
assert.strictEqual(typeof auditSource, 'object');
assert.strictEqual(typeof progressCallback, 'function');
const allApps = await apps.list();
let appDomains = [];
// add webadmin and mail domain
if (settings.mailFqdn() === settings.dashboardFqdn()) {
appDomains.push({ domain: settings.dashboardDomain(), fqdn: settings.dashboardFqdn(), type: 'webadmin+mail', nginxConfigFilename: path.join(paths.NGINX_APPCONFIG_DIR, `${settings.dashboardFqdn()}.conf`) });
} else {
appDomains.push({ domain: settings.dashboardDomain(), fqdn: settings.dashboardFqdn(), type: 'webadmin', nginxConfigFilename: path.join(paths.NGINX_APPCONFIG_DIR, `${settings.dashboardFqdn()}.conf`) });
appDomains.push({ domain: settings.mailDomain(), fqdn: settings.mailFqdn(), type: 'mail' });
}
for (const app of allApps) {
if (app.runState === apps.RSTATE_STOPPED) continue; // do not renew certs of stopped apps
appDomains = appDomains.concat([{ app, domain: app.domain, fqdn: app.fqdn, type: apps.LOCATION_TYPE_PRIMARY, nginxConfigFilename: getNginxConfigFilename(app, app.fqdn, apps.LOCATION_TYPE_PRIMARY) }])
.concat(app.secondaryDomains.map(sd => { return { app, domain: sd.domain, fqdn: sd.fqdn, type: apps.LOCATION_TYPE_SECONDARY, nginxConfigFilename: getNginxConfigFilename(app, sd.fqdn, apps.LOCATION_TYPE_SECONDARY) }; }))
.concat(app.redirectDomains.map(rd => { return { app, domain: rd.domain, fqdn: rd.fqdn, type: apps.LOCATION_TYPE_REDIRECT, nginxConfigFilename: getNginxConfigFilename(app, rd.fqdn, apps.LOCATION_TYPE_REDIRECT) }; }))
.concat(app.aliasDomains.map(ad => { return { app, domain: ad.domain, fqdn: ad.fqdn, type: apps.LOCATION_TYPE_ALIAS, nginxConfigFilename: getNginxConfigFilename(app, ad.fqdn, apps.LOCATION_TYPE_ALIAS) }; }));
}
if (options.domain) appDomains = appDomains.filter(function (appDomain) { return appDomain.domain === options.domain; });
let progress = 1, renewedCerts = [];
for (const appDomain of appDomains) {
progressCallback({ percent: progress, message: `Ensuring certs of ${appDomain.fqdn}` });
progress += Math.round(100/appDomains.length);
const { certificatePath, renewed } = await ensureCertificate(appDomain.fqdn, appDomain.domain, auditSource);
if (renewed) renewedCerts.push(appDomain.fqdn);
if (appDomain.type === 'mail') continue; // mail has no nginx config to check current cert
// hack to check if the app's cert changed or not. this doesn't handle prod/staging le change since they use same file name
let currentNginxConfig = safe.fs.readFileSync(appDomain.nginxConfigFilename, 'utf8') || '';
if (currentNginxConfig.includes(certificatePath.certFilePath)) continue;
debug(`renewCerts: creating new nginx config since ${appDomain.nginxConfigFilename} does not have ${certificatePath.certFilePath}`);
// reconfigure since the cert changed
if (appDomain.type === 'webadmin' || appDomain.type === 'webadmin+mail') {
await writeDashboardNginxConfig(settings.dashboardFqdn(), certificatePath);
} else {
await writeAppNginxConfig(appDomain.app, appDomain.fqdn, appDomain.type, certificatePath);
}
}
debug(`renewCerts: Renewed certs of ${JSON.stringify(renewedCerts)}`);
if (renewedCerts.length === 0) return;
if (renewedCerts.includes(settings.mailFqdn())) await mail.handleCertChanged();
await reload(); // reload nginx if any certs were updated but the config was not rewritten
// restart tls apps on cert change
const tlsApps = allApps.filter(app => app.manifest.addons && app.manifest.addons.tls && renewedCerts.includes(app.fqdn));
for (const app of tlsApps) {
await apps.restart(app, auditSource);
}
}
async function cleanupCerts(auditSource, progressCallback) {
assert.strictEqual(typeof auditSource, 'object');
assert.strictEqual(typeof progressCallback, 'function');
const filenames = await fs.promises.readdir(paths.NGINX_CERT_DIR);
const certFilenames = filenames.filter(f => f.endsWith('.cert'));
const now = new Date();
progressCallback({ message: 'Checking expired certs for removal' });
const fqdns = [];
const domainObjectMap = await domains.getDomainObjectMap();
const certNamesInUse = new Set();
for (const location of locations) {
certNamesInUse.add(await getAcmeCertificateNameSync(location.fqdn, domainObjectMap[location.domain]));
}
for (const certFilename of certFilenames) {
const certFilePath = path.join(paths.NGINX_CERT_DIR, certFilename);
const notAfter = getExpiryDate(certFilePath);
if (!notAfter) continue; // some error
const now = new Date();
const certIds = await blobs.listCertIds();
const removedCertNames = [];
for (const certId of certIds) {
const certName = certId.match(new RegExp(`${blobs.CERT_PREFIX}-(.*).cert`))[1];
if (certNamesInUse.has(certName)) continue;
if (now - notAfter >= (60 * 60 * 24 * 30 * 6 * 1000)) { // expired 6 months ago
const fqdn = certFilename.replace(/\.cert$/, '');
progressCallback({ message: `deleting certs of ${fqdn}` });
const cert = await blobs.getString(certId);
const { endDate } = getCertificateDatesSync(cert);
if (!endDate) continue; // some error
if (now - endDate >= (60 * 60 * 24 * 30 * 6 * 1000)) { // expired 6 months ago and not in use
progressCallback({ message: `deleting certs of ${certName}` });
// it is safe to delete the certs of stopped apps because their nginx configs are removed
safe.fs.unlinkSync(certFilePath);
safe.fs.unlinkSync(path.join(paths.NGINX_CERT_DIR, `${fqdn}.key`));
safe.fs.unlinkSync(path.join(paths.NGINX_CERT_DIR, `${fqdn}.csr`));
safe.fs.unlinkSync(path.join(paths.NGINX_CERT_DIR, `${certName}.cert`));
safe.fs.unlinkSync(path.join(paths.NGINX_CERT_DIR, `${certName}.key`));
safe.fs.unlinkSync(path.join(paths.NGINX_CERT_DIR, `${certName}.csr`));
await blobs.del(`${blobs.CERT_PREFIX}-${fqdn}.key`);
await blobs.del(`${blobs.CERT_PREFIX}-${fqdn}.cert`);
await blobs.del(`${blobs.CERT_PREFIX}-${fqdn}.csr`);
await blobs.del(`${blobs.CERT_PREFIX}-${certName}.key`);
await blobs.del(`${blobs.CERT_PREFIX}-${certName}.cert`);
await blobs.del(`${blobs.CERT_PREFIX}-${certName}.csr`);
fqdns.push(fqdn);
removedCertNames.push(certName);
}
}
if (fqdns.length) await safe(eventlog.add(eventlog.ACTION_CERTIFICATE_CLEANUP, auditSource, { domains: fqdns }));
if (removedCertNames.length) await safe(eventlog.add(eventlog.ACTION_CERTIFICATE_CLEANUP, auditSource, { domains: removedCertNames }));
debug('cleanupCerts: done');
}
@@ -703,19 +626,56 @@ async function checkCerts(options, auditSource, progressCallback) {
assert.strictEqual(typeof auditSource, 'object');
assert.strictEqual(typeof progressCallback, 'function');
await renewCerts(options, auditSource, progressCallback);
await cleanupCerts(auditSource, progressCallback);
let locations = [];
if (settings.dashboardFqdn() !== settings.mailFqdn()) locations.push({ domain: settings.mailDomain(), fqdn: settings.mailFqdn(), certificate: null, type: apps.LOCATION_TYPE_MAIL });
locations.push({ domain: settings.dashboardDomain(), fqdn: settings.dashboardFqdn(), certificate: null, type: apps.LOCATION_TYPE_DASHBOARD });
const allApps = (await apps.list()).filter(app => app.runState !== apps.RSTATE_STOPPED);
for (const app of allApps) {
locations = locations.concat(getAppLocationsSync(app));
}
let percent = 1;
for (const location of locations) {
percent += Math.round(100/locations.length);
progressCallback({ percent, message: `Ensuring certs of ${location.fqdn}` });
await ensureCertificate(location, options, auditSource);
}
if (options.rebuild || fs.existsSync(paths.REVERSE_PROXY_REBUILD_FILE)) {
progressCallback( { message: 'Rebuilding app configs' });
for (const app of allApps) {
await writeAppConfigs(app);
}
await writeDashboardConfig(settings.dashboardDomain());
await notifyCertChange(); // this allows user to "rebuild" using UI just in case we crashed and went out of sync
safe.fs.unlinkSync(paths.REVERSE_PROXY_REBUILD_FILE);
} else {
// sync all locations and not just the ones that changed. this helps with 0 length certs when disk is full and also
// if renewal task crashed midway.
for (const location of locations) {
await writeCertificate(location);
}
await reload();
await notifyCertChange(); // propagate any cert changes to services
}
await cleanupCerts(locations, auditSource, progressCallback);
}
function removeAppConfigs() {
const dashboardConfigFilename = `${settings.dashboardFqdn()}.conf`;
debug('removeAppConfigs: reomving nginx configs of apps');
debug('removeAppConfigs: removing app nginx configs');
// remove all configs which are not the default or current dashboard
for (const appConfigFile of fs.readdirSync(paths.NGINX_APPCONFIG_DIR)) {
if (appConfigFile !== constants.NGINX_DEFAULT_CONFIG_FILE_NAME && appConfigFile !== dashboardConfigFilename) {
fs.unlinkSync(path.join(paths.NGINX_APPCONFIG_DIR, appConfigFile));
for (const entry of fs.readdirSync(paths.NGINX_APPCONFIG_DIR, { withFileTypes: true })) {
if (entry.isDirectory() && entry.name === 'dashboard') continue;
if (entry.isFile() && entry.name === constants.NGINX_DEFAULT_CONFIG_FILE_NAME) continue;
const fullPath = path.join(paths.NGINX_APPCONFIG_DIR, entry.name);
if (entry.isDirectory()) {
fs.rmSync(fullPath, { recursive: true, force: true });
} else if (entry.isFile()) {
fs.unlinkSync(fullPath);
}
}
}
@@ -757,3 +717,9 @@ async function writeDefaultConfig(options) {
await reload();
}
async function handleCertificateProviderChanged(domain) {
assert.strictEqual(typeof domain, 'string');
safe.fs.appendFileSync(paths.REVERSE_PROXY_REBUILD_FILE, `${domain}\n`, 'utf8');
}

View File

@@ -56,6 +56,7 @@ exports = module.exports = {
downloadFile,
updateBackup,
downloadBackup,
getLimits,
getGraphs,
@@ -175,12 +176,10 @@ async function install(req, res, next) {
if ('skipDnsSetup' in data && typeof data.skipDnsSetup !== 'boolean') return next(new HttpError(400, 'skipDnsSetup must be boolean'));
if ('enableMailbox' in data && typeof data.enableMailbox !== 'boolean') return next(new HttpError(400, 'enableMailbox must be boolean'));
if ('upstreamUri' in data && (typeof data.upstreamUri !== 'string' || !data.upstreamUri)) return next(new HttpError(400, 'upstreamUri must be a non emptry string'));
let [error, result] = await safe(apps.downloadManifest(data.appStoreId, data.manifest));
if (error) return next(BoxError.toHttpError(error));
if (result.manifest.appStoreId === constants.PROXY_APP_APPSTORE_ID && (typeof data.upstreamUri !== 'string' || !data.upstreamUri)) return next(new HttpError(400, 'upstreamUri must be a non empty string'));
if (result.appStoreId === constants.PROXY_APP_APPSTORE_ID && typeof data.upstreamUri !== 'string') return next(new HttpError(400, 'upstreamUri must be a non empty string'));
if (safe.query(result.manifest, 'addons.docker') && req.user.role !== users.ROLE_OWNER) return next(new HttpError(403, '"owner" role is required to install app with docker addon'));
@@ -727,9 +726,11 @@ async function createExec(req, res, next) {
if ('tty' in req.body && typeof req.body.tty !== 'boolean') return next(new HttpError(400, 'tty must be boolean'));
const tty = !!req.body.tty;
if ('lang' in req.body && typeof req.body.lang !== 'string') return next(new HttpError(400, 'lang must be a string'));
if (safe.query(req.app, 'manifest.addons.docker') && req.user.role !== users.ROLE_OWNER) return next(new HttpError(403, '"owner" role is requied to exec app with docker addon'));
const [error, id] = await safe(apps.createExec(req.app, { cmd, tty }));
const [error, id] = await safe(apps.createExec(req.app, { cmd, tty, lang: req.body.lang }));
if (error) return next(BoxError.toHttpError(error));
next(new HttpSuccess(200, { id }));
@@ -852,6 +853,17 @@ async function updateBackup(req, res, next) {
next(new HttpSuccess(200, {}));
}
async function downloadBackup(req, res, next) {
assert.strictEqual(typeof req.app, 'object');
assert.strictEqual(typeof req.params.backupId, 'string');
const [error, result] = await safe(apps.getBackupDownloadStream(req.app, req.params.backupId));
if (error) return next(BoxError.toHttpError(error));
res.attachment(`${req.params.backupId}.tgz`);
result.pipe(res);
}
async function uploadFile(req, res, next) {
assert.strictEqual(typeof req.app, 'object');
@@ -910,6 +922,7 @@ async function setUpstreamUri(req, res, next) {
assert.strictEqual(typeof req.body, 'object');
assert.strictEqual(typeof req.app, 'object');
if (req.app.appStoreId !== constants.PROXY_APP_APPSTORE_ID) return next(new HttpError(400, 'upstreamUri can only be set for proxy app'));
if (typeof req.body.upstreamUri !== 'string') return next(new HttpError(400, 'upstreamUri must be a string'));
const [error] = await safe(apps.setUpstreamUri(req.app, req.body.upstreamUri, AuditSource.fromRequest(req)));

View File

@@ -20,7 +20,9 @@ const appstore = require('../appstore.js'),
_ = require('underscore');
async function getApps(req, res, next) {
const [error, apps] = await safe(appstore.getApps());
const repository = req.query.repository || 'core';
const [error, apps] = await safe(appstore.getApps(repository));
if (error) return next(BoxError.toHttpError(error));
next(new HttpSuccess(200, { apps }));

View File

@@ -26,7 +26,8 @@ exports = module.exports = {
getLanguages,
syncExternalLdap,
syncDnsRecords,
getSystemGraphs
getSystemGraphs,
getPlatformStatus
};
const assert = require('assert'),
@@ -40,6 +41,7 @@ const assert = require('assert'),
graphs = require('../graphs.js'),
HttpError = require('connect-lastmile').HttpError,
HttpSuccess = require('connect-lastmile').HttpSuccess,
platform = require('../platform.js'),
safe = require('safetydance'),
speakeasy = require('speakeasy'),
sysinfo = require('../sysinfo.js'),
@@ -87,7 +89,7 @@ async function passwordResetRequest(req, res, next) {
if (!req.body.identifier || typeof req.body.identifier !== 'string') return next(new HttpError(401, 'A identifier must be non-empty string'));
const [error] = await safe(users.sendPasswordResetByIdentifier(req.body.identifier, AuditSource.fromRequest(req)));
if (error && error.reason !== BoxError.NOT_FOUND) return next(BoxError.toHttpError(error));
if (error && !(error.reason === BoxError.NOT_FOUND || error.reason === BoxError.CONFLICT)) return next(BoxError.toHttpError(error));
next(new HttpSuccess(202, {}));
}
@@ -167,10 +169,13 @@ async function getConfig(req, res, next) {
}
async function getDisks(req, res, next) {
const [error, result] = await safe(system.getDisks());
if (error) return next(BoxError.toHttpError(error));
const [getDisksError, disks] = await safe(system.getDisks());
if (getDisksError) return next(BoxError.toHttpError(getDisksError));
next(new HttpSuccess(200, { disks: result }));
let [getSwapsError, swaps] = await safe(system.getSwaps());
if (getSwapsError) return next(BoxError.toHttpError(getSwapsError));
next(new HttpSuccess(200, { disks, swaps }));
}
async function getDiskUsage(req, res, next) {
@@ -272,7 +277,7 @@ async function getLogStream(req, res, next) {
res.on('close', logStream.close);
logStream.on('data', function (data) {
const obj = JSON.parse(data);
res.write(sse(obj.monotonicTimestamp, JSON.stringify(obj))); // send timestamp as id
res.write(sse(obj.realtimeTimestamp, JSON.stringify(obj))); // send timestamp as id
});
logStream.on('end', res.end.bind(res));
logStream.on('error', res.end.bind(res, null));
@@ -297,9 +302,9 @@ async function prepareDashboardDomain(req, res, next) {
}
async function renewCerts(req, res, next) {
if ('domain' in req.body && typeof req.body.domain !== 'string') return next(new HttpError(400, 'domain must be a string'));
if ('rebuild' in req.body && typeof req.body.rebuild !== 'boolean') return next(new HttpError(400, 'rebuild must be a boolean'));
const [error, taskId] = await safe(cloudron.renewCerts({ domain: req.body.domain || null }, AuditSource.fromRequest(req)));
const [error, taskId] = await safe(cloudron.renewCerts(req.body, AuditSource.fromRequest(req)));
if (error) return next(BoxError.toHttpError(error));
next(new HttpSuccess(202, { taskId }));
@@ -356,3 +361,7 @@ async function getSystemGraphs(req, res, next) {
next(new HttpSuccess(200, result));
}
async function getPlatformStatus(req, res, next) {
next(new HttpSuccess(200, platform.getStatus()));
}

View File

@@ -110,7 +110,7 @@ async function getLogStream(req, res, next) {
res.on('close', logStream.close);
logStream.on('data', function (data) {
const obj = JSON.parse(data);
res.write(sse(obj.monotonicTimestamp, JSON.stringify(obj))); // send timestamp as id
res.write(sse(obj.realtimeTimestamp, JSON.stringify(obj))); // send timestamp as id
});
logStream.on('end', res.end.bind(res));
logStream.on('error', res.end.bind(res, null));

View File

@@ -48,7 +48,7 @@ async function createTicket(req, res, next) {
if (supportConfig.email !== constants.SUPPORT_EMAIL) return next(new HttpError(503, 'Sending to non-cloudron email not implemented yet'));
const [ticketError, result] = await safe(appstore.createTicket(_.extend({ }, req.body, { email: req.user.email, displayName: req.user.displayName }), AuditSource.fromRequest(req)));
if (ticketError) return next(new HttpError(503, `Error contacting cloudron.io: ${error.message}. Please email ${constants.SUPPORT_EMAIL}`));
if (ticketError) return next(new HttpError(503, `Error contacting cloudron.io: ${ticketError.message}. Please email ${constants.SUPPORT_EMAIL}`));
next(new HttpSuccess(201, result));
}

View File

@@ -105,7 +105,7 @@ async function getLogStream(req, res, next) {
res.on('close', logStream.close);
logStream.on('data', function (data) {
const obj = JSON.parse(data);
res.write(sse(obj.monotonicTimestamp, JSON.stringify(obj))); // send timestamp as id
res.write(sse(obj.realtimeTimestamp, JSON.stringify(obj))); // send timestamp as id
});
logStream.on('end', res.end.bind(res));
logStream.on('error', res.end.bind(res, null));

View File

@@ -199,7 +199,7 @@ function startBox(done) {
function (callback) {
process.stdout.write('Waiting for platform to be ready...');
async.retry({ times: 500, interval: 1000 }, function (retryCallback) {
if (platform._isReady) return retryCallback();
if (platform.getStatus().message === '') return retryCallback();
process.stdout.write('.');
retryCallback('Platform not ready yet');
}, function (error) {
@@ -854,6 +854,37 @@ xdescribe('App API', function () {
});
});
////////////// upstreamUri
it('cannot set empty upstreamUri', function (done) {
superagent.post(SERVER_URL + '/api/v1/apps/' + APP_ID + '/configure/upstream_uri')
.query({ access_token: token })
.send({ upstreamUri: '' })
.end(function (err, res) {
expect(res.statusCode).to.equal(400);
done();
});
});
it('cannot set bad upstreamUri', function (done) {
superagent.post(SERVER_URL + '/api/v1/apps/' + APP_ID + '/configure/upstream_uri')
.query({ access_token: token })
.send({ upstreamUri: 'foobar:com' })
.end(function (err, res) {
expect(res.statusCode).to.equal(400);
done();
});
});
it('can set upstreamUri', function (done) {
superagent.post(SERVER_URL + '/api/v1/apps/' + APP_ID + '/configure/upstream_uri')
.query({ access_token: token })
.send({ upstreamUri: 'https://1.2.3.4:443' })
.end(function (err, res) {
expect(res.statusCode).to.equal(200);
done();
});
});
/////////////// cert
it('cannot set only the cert, no key', function (done) {
superagent.post(SERVER_URL + '/api/v1/apps/' + APP_ID + '/configure/cert')

View File

@@ -41,7 +41,7 @@ describe('Appstore Apps API', function () {
it('can list apps', async function () {
const scope1 = nock(settings.apiServerOrigin())
.get(`/api/v1/apps?accessToken=${appstoreToken}&boxVersion=${constants.VERSION}&unstable=true`, () => true)
.get(`/api/v1/apps?accessToken=${appstoreToken}&boxVersion=${constants.VERSION}&unstable=true&repository=core`, () => true)
.reply(200, { apps: [] });
const response = await superagent.get(`${serverUrl}/api/v1/appstore/apps`)
@@ -98,7 +98,8 @@ describe('Appstore Cloudron Registration API - existing user', function () {
const response = await superagent.post(`${serverUrl}/api/v1/appstore/register_cloudron`)
.send({ email: 'test@cloudron.io', password: 'secret', signup: false })
.query({ access_token: owner.token });
.query({ access_token: owner.token })
.ok(() => true);
expect(response.statusCode).to.equal(201);
expect(scope1.isDone()).to.not.be.ok(); // should not have called register_user since signup is false

View File

@@ -8,6 +8,7 @@
const constants = require('../../constants.js'),
common = require('./common.js'),
expect = require('expect.js'),
fs = require('fs'),
http = require('http'),
os = require('os'),
paths = require('../../paths.js'),
@@ -300,6 +301,11 @@ describe('Cloudron API', function () {
});
describe('logs', function () {
before(function () {
console.log(paths.BOX_LOG_FILE);
fs.writeFileSync(paths.BOX_LOG_FILE, '2022-11-06T15:06:20.009Z box:apphealthmonitor app health: 0 alive / 0 dead.\n', 'utf8');
});
it('logStream - requires event-stream accept header', async function () {
const response = await superagent.get(`${serverUrl}/api/v1/cloudron/logstream/box`)
.query({ access_token: owner.token, fromLine: 0 })
@@ -335,6 +341,7 @@ describe('Cloudron API', function () {
expect(dataMessageFound).to.be.ok();
res.destroy();
req.destroy();
done();
}, 1000);

View File

@@ -31,6 +31,14 @@ exports = module.exports = {
token: null
},
admin: {
id: null,
username: 'administrator',
password: 'Foobar?1339',
email: 'admin@cloudron.local',
token: null
},
user: {
id: null,
username: 'user',
@@ -54,7 +62,7 @@ async function setupServer() {
}
async function setup() {
const owner = exports.owner, serverUrl = exports.serverUrl, user = exports.user;
const owner = exports.owner, serverUrl = exports.serverUrl, user = exports.user, admin = exports.admin;
await setupServer();
await safe(fs.promises.unlink(support._sshInfo().filePath));
@@ -74,6 +82,16 @@ async function setup() {
owner.token = response.body.token;
owner.id = response.body.userId;
// create an admin
response = await superagent.post(`${serverUrl}/api/v1/users`)
.query({ access_token: owner.token })
.send({ username: admin.username, email: admin.email, password: admin.password });
expect(response.status).to.equal(201);
admin.id = response.body.id;
// HACK to get a token for second user (passwords are generated and the user should have gotten a password setup link...)
const token1 = await tokens.add({ identifier: admin.id, clientId: 'test-client-id', expires: Date.now() + (60 * 60 * 1000), name: 'fromtest' });
admin.token = token1.accessToken;
// create user
response = await superagent.post(`${serverUrl}/api/v1/users`)
.query({ access_token: owner.token })
@@ -81,8 +99,8 @@ async function setup() {
expect(response.status).to.equal(201);
user.id = response.body.id;
// HACK to get a token for second user (passwords are generated and the user should have gotten a password setup link...)
const token = await tokens.add({ identifier: user.id, clientId: 'test-client-id', expires: Date.now() + (60 * 60 * 1000), name: 'fromtest' });
user.token = token.accessToken;
const token2 = await tokens.add({ identifier: user.id, clientId: 'test-client-id', expires: Date.now() + (60 * 60 * 1000), name: 'fromtest' });
user.token = token2.accessToken;
await settings._set(settings.APPSTORE_API_TOKEN_KEY, exports.appstoreToken); // appstore token
}

View File

@@ -101,7 +101,7 @@ describe('Eventlog API', function () {
.query({ access_token: owner.token, page: 1, per_page: 10, actions: 'cloudron.activate, user.add' });
expect(response.statusCode).to.equal(200);
expect(response.body.eventlogs.length).to.equal(3);
expect(response.body.eventlogs.length).to.equal(4);
});
it('succeeds with search', async function () {

View File

@@ -13,7 +13,7 @@ const common = require('./common.js'),
superagent = require('superagent');
describe('Support API', function () {
const { setup, cleanup, serverUrl, owner, mockApiServerOrigin, appstoreToken } = common;
const { setup, cleanup, serverUrl, owner, mockApiServerOrigin, appstoreToken, user, admin } = common;
before(setup);
after(cleanup);
@@ -169,7 +169,25 @@ describe('Support API', function () {
expect(scope2.isDone()).to.be.ok();
});
it('succeeds with app type', async function () {
it('normal user cannot open tickets', async function () {
const response = await superagent.post(`${serverUrl}/api/v1/support/ticket`)
.send({ type: 'app_missing', subject: 'some subject', description: 'some description' })
.query({ access_token: user.token })
.ok(() => true);
expect(response.statusCode).to.equal(403);
});
it('admin also cannot open tickets', async function () {
const response = await superagent.post(`${serverUrl}/api/v1/support/ticket`)
.send({ type: 'app_missing', subject: 'some subject', description: 'some description' })
.query({ access_token: admin.token })
.ok(() => true);
expect(response.statusCode).to.equal(403);
});
it('owner can open tickets', async function () {
const scope2 = nock(mockApiServerOrigin)
.filteringRequestBody(function (/* unusedBody */) { return ''; }) // strip out body
.post(`/api/v1/ticket?accessToken=${appstoreToken}`)

View File

@@ -17,79 +17,111 @@ describe('Tokens API', function () {
let token, readOnlyToken;
it('cannot create token with bad name', async function () {
const response = await superagent.post(`${serverUrl}/api/v1/tokens`)
.query({ access_token: owner.token })
.send({ name: new Array(128).fill('s').join('') })
.ok(() => true);
expect(response.statusCode).to.equal(400);
describe('CRUD', function () {
it('cannot create token with bad name', async function () {
const response = await superagent.post(`${serverUrl}/api/v1/tokens`)
.query({ access_token: owner.token })
.send({ name: new Array(128).fill('s').join('') })
.ok(() => true);
expect(response.statusCode).to.equal(400);
});
it('can create token', async function () {
const response = await superagent.post(`${serverUrl}/api/v1/tokens`)
.query({ access_token: owner.token })
.send({ name: 'mytoken1' });
expect(response.status).to.equal(201);
expect(response.body).to.be.a('object');
token = response.body;
});
it('can create read-only token', async function () {
const response = await superagent.post(`${serverUrl}/api/v1/tokens`)
.query({ access_token: owner.token })
.send({ name: 'mytoken1', scope: { '*': 'r' }});
expect(response.status).to.equal(201);
expect(response.body).to.be.a('object');
readOnlyToken = response.body;
});
it('cannot create read-only token with invalid scope', async function () {
const response = await superagent.post(`${serverUrl}/api/v1/tokens`)
.query({ access_token: owner.token })
.send({ name: 'mytoken1', scope: { 'foobar': 'rw' }})
.ok(() => true);
expect(response.status).to.equal(400);
});
it('can list tokens', async function () {
const response = await superagent.get(`${serverUrl}/api/v1/tokens`)
.query({ access_token: owner.token });
expect(response.statusCode).to.equal(200);
expect(response.body.tokens.length).to.be(3); // one is owner token on activation
const tokenIds = response.body.tokens.map(t => t.id);
expect(tokenIds).to.contain(token.id);
expect(tokenIds).to.contain(readOnlyToken.id);
});
it('can get token', async function () {
const response = await superagent.get(`${serverUrl}/api/v1/tokens/${token.id}`)
.query({ access_token: owner.token });
expect(response.statusCode).to.equal(200);
expect(response.body.id).to.be(token.id);
});
it('can delete token', async function () {
const response = await superagent.del(`${serverUrl}/api/v1/tokens/${token.id}`)
.query({ access_token: owner.token });
expect(response.statusCode).to.equal(204);
});
});
it('can create token', async function () {
const response = await superagent.post(`${serverUrl}/api/v1/tokens`)
.query({ access_token: owner.token })
.send({ name: 'mytoken1' });
describe('readonly token', function () {
it('cannot create token with read only token', async function () {
const response = await superagent.post(`${serverUrl}/api/v1/tokens`)
.query({ access_token: readOnlyToken.accessToken })
.send({ name: 'somename' })
.ok(() => true);
expect(response.status).to.equal(201);
expect(response.body).to.be.a('object');
token = response.body;
});
expect(response.status).to.equal(403);
});
it('can create read-only token', async function () {
const response = await superagent.post(`${serverUrl}/api/v1/tokens`)
.query({ access_token: owner.token })
.send({ name: 'mytoken1', scope: { '*': 'r' }});
it('can use read only token to list domains', async function () {
const response = await superagent.get(`${serverUrl}/api/v1/domains`)
.query({ access_token: readOnlyToken.accessToken })
.ok(() => true);
expect(response.status).to.equal(201);
expect(response.body).to.be.a('object');
readOnlyToken = response.body;
});
expect(response.status).to.equal(200);
expect(response.body.domains.length).to.be(1);
});
it('cannot create read-only token with invalid scope', async function () {
const response = await superagent.post(`${serverUrl}/api/v1/tokens`)
.query({ access_token: owner.token })
.send({ name: 'mytoken1', scope: { 'foobar': 'rw' }})
.ok(() => true);
it('cannot use read only token for creating a domain', async function () {
const DOMAIN_0 = {
domain: 'domain0.com',
zoneName: 'domain0.com',
provider: 'noop',
config: { },
tlsConfig: {
provider: 'fallback'
}
};
expect(response.status).to.equal(400);
});
const response = await superagent.post(`${serverUrl}/api/v1/domains`)
.query({ access_token: readOnlyToken.accessToken })
.send(DOMAIN_0)
.ok(() => true);
it('can list tokens', async function () {
const response = await superagent.get(`${serverUrl}/api/v1/tokens`)
.query({ access_token: owner.token });
expect(response.statusCode).to.equal(200);
expect(response.body.tokens.length).to.be(3); // one is owner token on activation
const tokenIds = response.body.tokens.map(t => t.id);
expect(tokenIds).to.contain(token.id);
expect(tokenIds).to.contain(readOnlyToken.id);
});
expect(response.statusCode).to.equal(403);
});
it('cannot create token with read only token', async function () {
const response = await superagent.post(`${serverUrl}/api/v1/tokens`)
.query({ access_token: readOnlyToken.accessToken })
.send({ name: 'somename' })
.ok(() => true);
expect(response.status).to.equal(403);
});
it('cannot get non-existent token', async function () {
const response = await superagent.get(`${serverUrl}/api/v1/tokens/foobar`)
.query({ access_token: owner.token })
.ok(() => true);
expect(response.statusCode).to.equal(404);
});
it('can get token', async function () {
const response = await superagent.get(`${serverUrl}/api/v1/tokens/${token.id}`)
.query({ access_token: owner.token });
expect(response.statusCode).to.equal(200);
expect(response.body.id).to.be(token.id);
});
it('can delete token', async function () {
const response = await superagent.del(`${serverUrl}/api/v1/tokens/${token.id}`)
.query({ access_token: owner.token });
expect(response.statusCode).to.equal(204);
it('cannot get non-existent token', async function () {
const response = await superagent.get(`${serverUrl}/api/v1/tokens/foobar`)
.query({ access_token: owner.token })
.ok(() => true);
expect(response.statusCode).to.equal(404);
});
});
});

View File

@@ -11,8 +11,7 @@ const backuptask = require('../backuptask.js'),
database = require('../database.js'),
debug = require('debug')('box:backupupload'),
safe = require('safetydance'),
settings = require('../settings.js'),
v8 = require('v8');
settings = require('../settings.js');
// Main process starts here
const remotePath = process.argv[2];
@@ -43,35 +42,14 @@ function throttledProgressCallback(msecs) {
};
}
// https://github.com/josefzamrzla/gc-heap-stats#readme
// https://stackoverflow.com/questions/41541843/nodejs-v8-getheapstatistics-method
function dumpMemoryInfo() {
const mu = process.memoryUsage();
const hs = v8.getHeapStatistics();
function h(bytes) { // human readable
const i = Math.floor(Math.log(bytes) / Math.log(1024)),
sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
return (bytes / Math.pow(1024, i)).toFixed(2) * 1 + '' + sizes[i];
}
debug(`process: rss=${h(mu.rss)} heapUsed=${h(mu.heapUsed)} heapTotal=${h(mu.heapTotal)} external=${h(mu.external)}`
+ ` v8 heap: used=${h(hs.used_heap_size)} total=${h(hs.total_heap_size)} max=${h(hs.heap_size_limit)}`);
}
(async function main() {
await database.initialize();
await settings.initCache();
dumpMemoryInfo();
const timerId = setInterval(dumpMemoryInfo, 180 * 1000);
const [uploadError] = await safe(backuptask.upload(remotePath, format, dataLayoutString, throttledProgressCallback(5000)));
debug('upload completed. error: ', uploadError);
process.send({ result: uploadError ? uploadError.message : '' });
clearInterval(timerId);
// https://nodejs.org/api/process.html are exit codes used by node. apps.js uses the value below
// to check apptask crashes

View File

@@ -25,11 +25,23 @@ if [[ "${service}" == "unbound" ]]; then
unbound-anchor -a /var/lib/unbound/root.key
systemctl restart --no-block unbound
elif [[ "${service}" == "nginx" ]]; then
nginx -s reload
if systemctl -q is-active nginx; then
nginx -s reload
else
systemctl restart --no-block nginx
fi
elif [[ "${service}" == "docker" ]]; then
systemctl restart --no-block docker
elif [[ "${service}" == "collectd" ]]; then
systemctl restart --no-block collectd
elif [[ "${service}" == "box" ]]; then
readonly ubuntu_version=$(lsb_release -rs)
if [[ "${ubuntu_version}" == "18.04" ]]; then
pid=$(systemctl show box -p MainPID | sed 's/MainPID=//g')
kill -HUP $pid
else
systemctl reload --no-block box
fi
else
echo "Unknown service ${service}"
exit 1

View File

@@ -31,16 +31,11 @@ systemctl reset-failed "${service_name}" 2>/dev/null || true
readonly id=$(id -u $SUDO_USER)
readonly ubuntu_version=$(lsb_release -rs)
if [[ "${ubuntu_version}" == "16.04" ]]; then
options="-p MemoryLimit=${memory_limit_mb}M --remain-after-exit"
else
options="-p MemoryMax=${memory_limit_mb}M --pipe --wait"
options="-p TimeoutStopSec=10s -p MemoryMax=${memory_limit_mb}M --pipe --wait"
# Note: BindsTo will kill this task when the box is stopped. but will not kill this task when restarted!
# For this reason, we have code to kill the tasks both on shutdown and startup.
# BindsTo does not work on ubuntu 16, this means that even if box is stopped, the tasks keep running
[[ "$BOX_ENV" == "cloudron" ]] && options="${options} -p BindsTo=box.service"
fi
# Note: BindsTo will kill this task when the box is stopped. but will not kill this task when restarted!
# For this reason, we have code to kill the tasks both on shutdown and startup.
[[ "$BOX_ENV" == "cloudron" ]] && options="${options} -p BindsTo=box.service"
# systemd 237 on ubuntu 18.04 does not apply --nice
if [[ "${ubuntu_version}" == "18.04" ]]; then
@@ -49,23 +44,12 @@ fi
# DEBUG has to be hardcoded because it is not set in the tests. --setenv is required for ubuntu 16 (-E does not work)
# NODE_OPTIONS is used because env -S does not work in ubuntu 16/18.
systemd-run --unit "${service_name}" --nice "${nice}" --uid=${id} --gid=${id} ${options} \
--setenv HOME=${HOME} --setenv USER=${SUDO_USER} --setenv DEBUG=box:* --setenv BOX_ENV=${BOX_ENV} --setenv NODE_ENV=production --setenv NODE_OPTIONS=--unhandled-rejections=strict \
"${task_worker}" "${task_id}" "${logfile}"
exit_code=$?
if [[ "${ubuntu_version}" == "16.04" ]]; then
sleep 3
# we cannot use systemctl is-active because unit is always active until stopped with RemainAfterExit
while [[ "$(systemctl show -p SubState ${service_name})" == *"running"* ]]; do
echo "Waiting for service ${service_name} to finish"
sleep 3
done
exit_code=$(systemctl show "${service_name}" -p ExecMainStatus | sed 's/ExecMainStatus=//g')
systemctl stop "${service_name}" || true # because of remain-after-exit we have to deactivate the service
# it seems systemd-run does not return the exit status of the process despite --wait
if ! systemd-run --unit "${service_name}" --nice "${nice}" --uid=${id} --gid=${id} ${options} --setenv HOME=${HOME} --setenv USER=${SUDO_USER} --setenv DEBUG=box:* --setenv BOX_ENV=${BOX_ENV} --setenv NODE_ENV=production --setenv NODE_OPTIONS=--unhandled-rejections=strict "${task_worker}" "${task_id}" "${logfile}"; then
echo "Service ${service_name} failed to run" # this only happens if the path to task worker itself is wrong
fi
[[ "${ubuntu_version}" == "18.04" ]] && wait # for the renice subshell we started
exit_code=$(systemctl show "${service_name}" -p ExecMainCode | sed 's/ExecMainCode=//g')
echo "Service ${service_name} finished with exit code ${exit_code}"
exit "${exit_code}"

View File

@@ -24,8 +24,10 @@ if [[ "${task_id}" == "all" ]]; then
systemctl kill --signal=SIGTERM box-task-* || true
systemctl reset-failed box-task-* 2>/dev/null || true
systemctl stop box-task-* || true # because of remain-after-exit in Ubuntu 16 we have to deactivate the service
echo "All tasks stopped"
else
readonly service_name="box-task-${task_id}"
systemctl kill --signal=SIGTERM "${service_name}" || true
systemctl stop "${service_name}" || true # because of remain-after-exit in Ubuntu 16 we have to deactivate the service
echo "${service_name} stopped"
fi

View File

@@ -30,18 +30,12 @@ systemctl reset-failed "${UPDATER_SERVICE}" 2>/dev/null || true
# StandardError will follow StandardOutput in default inherit mode. https://www.freedesktop.org/software/systemd/man/systemd.exec.html
echo "=> Run installer.sh as ${UPDATER_SERVICE}."
if [[ "$(systemd --version | head -n1)" != "systemd 22"* ]]; then
readonly DATETIME=`date '+%Y-%m-%d_%H-%M-%S'`
readonly LOG_FILE="/home/yellowtent/platformdata/logs/updater/cloudron-updater-${DATETIME}.log"
readonly DATETIME=`date '+%Y-%m-%d_%H-%M-%S'`
readonly LOG_FILE="/home/yellowtent/platformdata/logs/updater/cloudron-updater-${DATETIME}.log"
update_service_options="-p StandardOutput=file:${LOG_FILE}"
echo "=> starting service (ubuntu 18.04) ${UPDATER_SERVICE}. see logs at ${LOG_FILE}"
else
update_service_options=""
echo "=> starting service (ubuntu 16.04) ${UPDATER_SERVICE}. see logs using journalctl -u ${UPDATER_SERVICE}"
fi
echo "=> starting service ${UPDATER_SERVICE}. see logs at ${LOG_FILE}"
if ! systemd-run --property=OOMScoreAdjust=-1000 --unit "${UPDATER_SERVICE}" $update_service_options ${installer_path}; then
if ! systemd-run --property=OOMScoreAdjust=-1000 --unit "${UPDATER_SERVICE}" -p StandardOutput=file:${LOG_FILE} ${installer_path}; then
echo "Failed to install cloudron. See log for details"
exit 1
fi

View File

@@ -150,6 +150,7 @@ function initializeExpressSync() {
// config route (for dashboard). can return some private configuration unlike status
router.get ('/api/v1/config', token, authorizeUser, routes.cloudron.getConfig);
router.get ('/api/v1/platform_status', token, authorizeUser, routes.cloudron.getPlatformStatus);
// working off the user behind the provided token
router.get ('/api/v1/profile', token, authorizeUser, routes.profile.get);
@@ -242,6 +243,7 @@ function initializeExpressSync() {
router.post('/api/v1/apps/:id/backup', json, token, routes.apps.load, authorizeOperator, routes.apps.backup);
router.get ('/api/v1/apps/:id/backups', token, routes.apps.load, authorizeOperator, routes.apps.listBackups);
router.post('/api/v1/apps/:id/backups/:backupId', json, token, routes.apps.load, authorizeOperator, routes.apps.updateBackup);
router.get ('/api/v1/apps/:id/backups/:backupId/download', token, routes.apps.load, authorizeOperator, routes.apps.downloadBackup);
router.post('/api/v1/apps/:id/start', json, token, routes.apps.load, authorizeOperator, routes.apps.start);
router.post('/api/v1/apps/:id/stop', json, token, routes.apps.load, authorizeOperator, routes.apps.stop);
router.post('/api/v1/apps/:id/restart', json, token, routes.apps.load, authorizeOperator, routes.apps.restart);

View File

@@ -44,6 +44,7 @@ const addonConfigs = require('./addonconfigs.js'),
hat = require('./hat.js'),
http = require('http'),
infra = require('./infra_version.js'),
LogStream = require('./log-stream.js'),
mail = require('./mail.js'),
os = require('os'),
path = require('path'),
@@ -56,7 +57,6 @@ const addonConfigs = require('./addonconfigs.js'),
sftp = require('./sftp.js'),
shell = require('./shell.js'),
spawn = require('child_process').spawn,
split = require('split'),
superagent = require('superagent'),
system = require('./system.js');
@@ -161,8 +161,8 @@ const ADDONS = {
clear: NOOP,
},
tls: {
setup: NOOP,
teardown: NOOP,
setup: setupTls,
teardown: teardownTls,
backup: NOOP,
restore: NOOP,
clear: NOOP,
@@ -300,11 +300,12 @@ async function containerStatus(containerName, tokenEnvName) {
if (response.status !== 200 || !response.body.status) return { status: exports.SERVICE_STATUS_STARTING, error: `Error waiting for ${containerName}. Status code: ${response.statusCode} message: ${response.body.message}` };
const result = await docker.memoryUsage(containerName);
const stats = result.memory_stats || { usage: 0, limit: 1 };
return {
status: addonDetails.state.Running ? exports.SERVICE_STATUS_ACTIVE : exports.SERVICE_STATUS_STOPPED,
memoryUsed: result.memory_stats.usage,
memoryPercent: parseInt(100 * result.memory_stats.usage / result.memory_stats.limit),
memoryUsed: stats.usage,
memoryPercent: parseInt(100 * stats.usage / stats.limit),
healthcheck: response.body
};
}
@@ -471,29 +472,12 @@ async function getServiceLogs(id, options) {
const cp = spawn(cmd, args);
const transformStream = split(function mapper(line) {
if (format !== 'json') return line + '\n';
const logStream = new LogStream({ format, source: name });
logStream.close = cp.kill.bind(cp, 'SIGKILL'); // closing stream kills the child process
const data = line.split(' '); // logs are <ISOtimestamp> <msg>
let timestamp = (new Date(data[0])).getTime();
if (isNaN(timestamp)) timestamp = 0;
const message = line.slice(data[0].length+1);
cp.stdout.pipe(logStream);
// ignore faulty empty logs
if (!timestamp && !message) return;
return JSON.stringify({
realtimeTimestamp: timestamp * 1000,
message: message,
source: name
}) + '\n';
});
transformStream.close = cp.kill.bind(cp, 'SIGKILL'); // closing stream kills the child process
cp.stdout.pipe(transformStream);
return transformStream;
return logStream;
}
async function rebuildService(id, auditSource) {
@@ -781,7 +765,7 @@ async function applyMemoryLimit(id) {
debug(`applyMemoryLimit: ${containerName} ${JSON.stringify(serviceConfig)}`);
const memory = system.getMemoryAllocation(memoryLimit);
const memory = await system.getMemoryAllocation(memoryLimit);
await docker.update(containerName, memory, memoryLimit);
}
@@ -921,7 +905,7 @@ async function startTurn(existingInfra) {
const serviceConfig = await getServiceConfig('turn');
const tag = infra.images.turn.tag;
const memoryLimit = serviceConfig.memoryLimit || SERVICES['turn'].defaultMemoryLimit;
const memory = system.getMemoryAllocation(memoryLimit);
const memory = await system.getMemoryAllocation(memoryLimit);
const realm = settings.dashboardFqdn();
let turnSecret = await blobs.getString(blobs.ADDON_TURN_SECRET);
@@ -1637,7 +1621,7 @@ async function startGraphite(existingInfra) {
const serviceConfig = await getServiceConfig('graphite');
const tag = infra.images.graphite.tag;
const memoryLimit = serviceConfig.memoryLimit || 256 * 1024 * 1024;
const memory = system.getMemoryAllocation(memoryLimit);
const memory = await system.getMemoryAllocation(memoryLimit);
const upgrading = existingInfra.version !== 'none' && requiresUpgrade(existingInfra.images.graphite.tag, tag);
@@ -1731,7 +1715,7 @@ async function setupRedis(app, options) {
// Compute redis memory limit based on app's memory limit (this is arbitrary)
const memoryLimit = app.servicesConfig['redis']?.memoryLimit || APP_SERVICES['redis'].defaultMemoryLimit;
const memory = system.getMemoryAllocation(memoryLimit);
const memory = await system.getMemoryAllocation(memoryLimit);
const recoveryMode = app.servicesConfig['redis']?.recoveryMode || false;
const readOnly = !recoveryMode ? '--read-only' : '';
@@ -1829,6 +1813,23 @@ async function restoreRedis(app, options) {
await pipeFileToRequest(dumpPath('redis', app.id), `http://${result.ip}:3000/restore?access_token=${result.token}`);
}
async function setupTls(app, options) {
assert.strictEqual(typeof app, 'object');
assert.strictEqual(typeof options, 'object');
if (!safe.fs.mkdirSync(`${paths.PLATFORM_DATA_DIR}/tls/${app.id}`, { recursive: true })) {
debug('Error creating tls directory');
throw new BoxError(BoxError.FS_ERROR, safe.error.message);
}
}
async function teardownTls(app, options) {
assert.strictEqual(typeof app, 'object');
assert.strictEqual(typeof options, 'object');
safe.fs.rmSync(`${paths.PLATFORM_DATA_DIR}/tls/${app.id}`, { recursive: true, force: true });
}
async function statusTurn() {
const [error, container] = await safe(docker.inspect('turn'));
if (error && error.reason === BoxError.NOT_FOUND) return { status: exports.SERVICE_STATUS_STOPPED };
@@ -1839,11 +1840,12 @@ async function statusTurn() {
const status = container.State.Running
? (container.HostConfig.ReadonlyRootfs ? exports.SERVICE_STATUS_ACTIVE : exports.SERVICE_STATUS_STARTING)
: exports.SERVICE_STATUS_STOPPED;
const stats = result.memory_stats || { usage: 0, limit: 1 };
return {
status,
memoryUsed: result.memory_stats.usage,
memoryPercent: parseInt(100 * result.memory_stats.usage / result.memory_stats.limit)
memoryUsed: stats.usage,
memoryPercent: parseInt(100 * stats.usage / stats.limit)
};
}
@@ -1893,11 +1895,12 @@ async function statusGraphite() {
if (response.status !== 200) return { status: exports.SERVICE_STATUS_STARTING, error: `Error waiting for graphite. Status code: ${response.status} message: ${response.body.message}` };
const result = await docker.memoryUsage('graphite');
const stats = result.memory_stats || { usage: 0, limit: 1 };
return {
status: container.State.Running ? exports.SERVICE_STATUS_ACTIVE : exports.SERVICE_STATUS_STOPPED,
memoryUsed: result.memory_stats.usage,
memoryPercent: parseInt(100 * result.memory_stats.usage / result.memory_stats.limit)
memoryUsed: stats.usage,
memoryPercent: parseInt(100 * stats.usage / stats.limit)
};
}

View File

@@ -49,7 +49,7 @@ async function start(existingInfra) {
const serviceConfig = servicesConfig['sftp'] || {};
const tag = infra.images.sftp.tag;
const memoryLimit = serviceConfig.memoryLimit || exports.DEFAULT_MEMORY_LIMIT;
const memory = system.getMemoryAllocation(memoryLimit);
const memory = await system.getMemoryAllocation(memoryLimit);
const cloudronToken = hat(8 * 128);
await ensureKeys();
@@ -133,9 +133,11 @@ async function status() {
? (container.HostConfig.ReadonlyRootfs ? services.SERVICE_STATUS_ACTIVE : services.SERVICE_STATUS_STARTING)
: services.SERVICE_STATUS_STOPPED;
const stats = result.memory_stats || { usage: 0, limit: 1 };
return {
status,
memoryUsed: result.memory_stats.usage,
memoryPercent: parseInt(100 * result.memory_stats.usage / result.memory_stats.limit)
memoryUsed: stats.usage,
memoryPercent: parseInt(100 * stats.usage / stats.limit)
};
}

View File

@@ -35,7 +35,7 @@ const assert = require('assert'),
BoxError = require('../boxerror.js'),
constants = require('../constants.js'),
debug = require('debug')('box:storage/filesystem'),
df = require('@sindresorhus/df'),
df = require('../df.js'),
fs = require('fs'),
mounts = require('../mounts.js'),
path = require('path'),
@@ -66,12 +66,10 @@ async function getBackupProviderStatus(apiConfig) {
assert.strictEqual(typeof apiConfig, 'object');
// Check filesystem is mounted so we don't write into the actual folder on disk
if (mounts.isManagedProvider(apiConfig.provider) || apiConfig.provider === 'mountpoint') {
const hostPath = mounts.isManagedProvider(apiConfig.provider) ? paths.MANAGED_BACKUP_MOUNT_DIR : apiConfig.mountPoint;
return await mounts.getStatus(apiConfig.provider, hostPath); // { state, message }
}
if (!mounts.isManagedProvider(apiConfig.provider) && apiConfig.provider !== 'mountpoint') return await mounts.getStatus(apiConfig.provider, apiConfig.backupFolder);
return await mounts.getStatus(apiConfig.provider, apiConfig.backupFolder);
const hostPath = mounts.isManagedProvider(apiConfig.provider) ? paths.MANAGED_BACKUP_MOUNT_DIR : apiConfig.mountPoint;
return await mounts.getStatus(apiConfig.provider, hostPath); // { state, message }
}
// the du call in the function below requires root

View File

@@ -454,6 +454,7 @@ async function copy(apiConfig, oldFilePath, newFilePath, progressCallback) {
}));
progressCallback({ message: `Copied ${total} files with error: ${copyError}` });
if (copyError) throw copyError;
}
async function remove(apiConfig, filename) {
@@ -560,7 +561,7 @@ async function testConfig(apiConfig) {
const putParams = {
Bucket: apiConfig.bucket,
Key: path.join(apiConfig.prefix, 'cloudron-testfile'),
Key: path.join(apiConfig.prefix, 'snapshot/cloudron-testfile'),
Body: 'testcontent'
};
@@ -568,9 +569,18 @@ async function testConfig(apiConfig) {
const [putError] = await safe(s3.putObject(putParams).promise());
if (putError) throw new BoxError(BoxError.EXTERNAL_ERROR, `Error put object cloudron-testfile. Message: ${putError.message} HTTP Code: ${putError.code}`);
const listParams = {
Bucket: apiConfig.bucket,
Prefix: path.join(apiConfig.prefix, 'snapshot'),
MaxKeys: 1
};
const [listError] = await safe(s3.listObjects(listParams).promise());
if (listError) throw new BoxError(BoxError.EXTERNAL_ERROR, `Error listing objects. Message: ${listError.message} HTTP Code: ${listError.code}`);
const delParams = {
Bucket: apiConfig.bucket,
Key: path.join(apiConfig.prefix, 'cloudron-testfile')
Key: path.join(apiConfig.prefix, 'snapshot/cloudron-testfile')
};
const [delError] = await safe(s3.deleteObject(delParams).promise());

View File

@@ -2,6 +2,7 @@
exports = module.exports = {
getDisks,
getSwaps,
checkDiskSpace,
getMemory,
getMemoryAllocation,
@@ -13,7 +14,7 @@ const apps = require('./apps.js'),
assert = require('assert'),
BoxError = require('./boxerror.js'),
debug = require('debug')('box:disks'),
df = require('@sindresorhus/df'),
df = require('./df.js'),
docker = require('./docker.js'),
notifications = require('./notifications.js'),
os = require('os'),
@@ -35,15 +36,36 @@ async function du(file) {
return parseInt(stdoutResult.trim(), 10);
}
async function getSwaps() {
const stdout = safe.child_process.execSync('swapon --noheadings --raw --bytes --show=type,size,used,name', { encoding: 'utf8' });
if (!stdout) return {};
const swaps = {};
for (const line of stdout.trim().split('\n')) {
const parts = line.split(' ', 4);
const name = parts[3];
swaps[name] = {
name: parts[3],
type: parts[0], // partition or file
size: parseInt(parts[1]),
used: parseInt(parts[2]),
};
}
return swaps;
}
async function getDisks() {
let [dfError, dfEntries] = await safe(df());
let [dfError, dfEntries] = await safe(df.disks());
if (dfError) throw new BoxError(BoxError.FS_ERROR, `Error running df: ${dfError.message}`);
const disks = {}; // by file system
let rootDisk;
const DISK_TYPES = [ 'ext4', 'xfs', 'cifs', 'nfs', 'fuse.sshfs' ]; // we don't show size of contents in untracked disk types
for (const disk of dfEntries) {
if (disk.type !== 'ext4' && disk.type !== 'xfs') continue;
if (!DISK_TYPES.includes(disk.type)) continue;
if (disk.mountpoint === '/') rootDisk = disk;
disks[disk.filesystem] = {
filesystem: disk.filesystem,
@@ -72,18 +94,21 @@ async function getDisks() {
const backupConfig = await settings.getBackupConfig();
if (backupConfig.provider === 'filesystem') {
const [, dfResult] = await safe(df.file(backupConfig.backupFolder));
disks[dfResult?.filesystem || rootDisk.filesystem].contents.push({ type: 'standard', id: 'cloudron-backup', path: backupConfig.backupFolder });
const filesystem = dfResult?.filesystem || rootDisk.filesystem;
if (disks[filesystem]) disks[filesystem].contents.push({ type: 'standard', id: 'cloudron-backup', path: backupConfig.backupFolder });
}
const [dockerError, dockerInfo] = await safe(docker.info());
if (!dockerError) {
const [, dfResult] = await safe(df.file(dockerInfo.DockerRootDir));
disks[dfResult?.filesystem || rootDisk.filesystem].contents.push({ type: 'standard', id: 'docker', path: dockerInfo.DockerRootDir });
const filesystem = dfResult?.filesystem || rootDisk.filesystem;
if (disks[filesystem]) disks[filesystem].contents.push({ type: 'standard', id: 'docker', path: dockerInfo.DockerRootDir });
}
for (const volume of await volumes.list()) {
const [, dfResult] = await safe(df(volume.hostPath));
disks[dfResult?.filesystem || rootDisk.filesystem].contents.push({ type: 'volume', id: volume.id, path: volume.hostPath });
const [, dfResult] = await safe(df.file(volume.hostPath));
const filesystem = dfResult?.filesystem || rootDisk.filesystem;
if (disks[filesystem]) disks[filesystem].contents.push({ type: 'volume', id: volume.id, path: volume.hostPath });
}
for (const app of await apps.list()) {
@@ -91,7 +116,17 @@ async function getDisks() {
const dataDir = await apps.getStorageDir(app);
const [, dfResult] = await safe(df.file(dataDir));
disks[dfResult?.filesystem || rootDisk.filesystem].contents.push({ type: 'app', id: app.id, path: dataDir });
const filesystem = dfResult?.filesystem || rootDisk.filesystem;
if (disks[filesystem]) disks[filesystem].contents.push({ type: 'app', id: app.id, path: dataDir });
}
const swaps = await getSwaps();
for (const k in swaps) {
const swap = swaps[k];
if (swap.type !== 'file') continue;
const [, dfResult] = await safe(df.file(swap.name));
disks[dfResult?.filesystem || rootDisk.filesystem].contents.push({ type: 'swap', id: swap.name, path: swap.name });
}
return disks;
@@ -120,25 +155,23 @@ async function checkDiskSpace() {
await notifications.alert(notifications.ALERT_DISK_SPACE, 'Server is running out of disk space', markdownMessage);
}
function getSwapSize() {
const stdout = safe.child_process.execSync('swapon --noheadings --raw --bytes --show=SIZE', { encoding: 'utf8' });
const swap = !stdout ? 0 : stdout.trim().split('\n').map(x => parseInt(x, 10) || 0).reduce((acc, cur) => acc + cur);
return swap;
async function getSwapSize() {
const swaps = await getSwaps();
return Object.keys(swaps).map(n => swaps[n].size).reduce((acc, cur) => acc + cur, 0);
}
async function getMemory() {
return {
memory: os.totalmem(),
swap: getSwapSize()
swap: await getSwapSize()
};
}
function getMemoryAllocation(limit) {
async function getMemoryAllocation(limit) {
let ratio = parseFloat(safe.fs.readFileSync(paths.SWAP_RATIO_FILE, 'utf8'), 10);
if (!ratio) {
const pc = os.totalmem() / (os.totalmem() + getSwapSize());
const pc = os.totalmem() / (os.totalmem() + await getSwapSize());
ratio = Math.round(pc * 10) / 10; // a simple ratio
}

View File

@@ -46,12 +46,12 @@ const assert = require('assert'),
BoxError = require('./boxerror.js'),
database = require('./database.js'),
debug = require('debug')('box:tasks'),
LogStream = require('./log-stream.js'),
path = require('path'),
paths = require('./paths.js'),
safe = require('safetydance'),
shell = require('./shell.js'),
spawn = require('child_process').spawn,
split = require('split'),
_ = require('underscore');
let gTasks = {}; // indexed by task id
@@ -92,7 +92,7 @@ function updateStatus(result) {
// the error in db will be empty if we didn't get a chance to handle task exit
if (!result.active && result.percent !== 100 && !result.error) {
result.error = { message: 'Cloudron crashed/stopped', code: exports.ECRASHED };
result.error = { message: 'Task was stopped because the server was restarted or crashed', code: exports.ECRASHED };
}
return result;
@@ -171,9 +171,8 @@ function startTask(id, options, callback) {
if (!gTasks[id]) return; // ignore task exit since we are shutting down. see stopAllTasks
const code = sudoError ? sudoError.code : 0;
const signal = sudoError ? sudoError.signal : 0;
debug(`startTask: ${id} completed with code ${code} and signal ${signal}`);
debug(`startTask: ${id} completed with code ${code}`);
if (options.timeout) clearTimeout(killTimerId);
@@ -186,9 +185,9 @@ function startTask(id, options, callback) {
message: `Task ${id} ${timedOut ? 'timed out' : 'stopped'}` ,
code: timedOut ? exports.ETIMEOUT : exports.ESTOPPED
};
} else { // task crashed
} else { // task crashed. for code, maybe we can check systemctl show box-task-1707 -p ExecMainStatus
taskError = {
message: signal === 9 ? `Task ${id} crashed as it ran out of memory` : `Task ${id} crashed with code ${code} and signal ${signal}`,
message: code === 2 ? `Task ${id} crashed as it ran out of memory` : `Task ${id} crashed with code ${code}`,
code: exports.ECRASHED
};
}
@@ -282,29 +281,12 @@ function getLogs(taskId, options) {
const cp = spawn(cmd, args);
const transformStream = split(function mapper(line) {
if (format !== 'json') return line + '\n';
const logStream = new LogStream({ format, source: taskId });
logStream.close = cp.kill.bind(cp, 'SIGKILL'); // hook for caller. closing stream kills the child process
const data = line.split(' '); // logs are <ISOtimestamp> <msg>
let timestamp = (new Date(data[0])).getTime();
if (isNaN(timestamp)) timestamp = 0;
const message = line.slice(data[0].length+1);
cp.stdout.pipe(logStream);
// ignore faulty empty logs
if (!timestamp && !message) return;
return JSON.stringify({
realtimeTimestamp: timestamp * 1000,
message: message,
source: taskId
}) + '\n';
});
transformStream.close = cp.kill.bind(cp, 'SIGKILL'); // closing stream kills the child process
cp.stdout.pipe(transformStream);
return transformStream;
return logStream;
}
// removes all fields that are strictly private and should never be returned by API calls

View File

@@ -9,6 +9,7 @@ const apps = require('../apps.js'),
AuditSource = require('../auditsource.js'),
BoxError = require('../boxerror.js'),
common = require('./common.js'),
constants = require('../constants.js'),
expect = require('expect.js'),
safe = require('safetydance');
@@ -72,6 +73,40 @@ describe('Apps', function () {
});
});
describe('validateUpstreamUri', function () {
it('does not allow empty URI', function () {
expect(apps._validateUpstreamUri('')).to.be.an(Error);
});
it('does not allow invalid URI scheme', function () {
expect(apps._validateUpstreamUri('bla:blub')).to.be.an(Error);
});
it('does not allow unsupported scheme', function () {
expect(apps._validateUpstreamUri('ftp://foobar.com')).to.be.an(Error);
});
it('does not allow trailing URI paths ', function () {
expect(apps._validateUpstreamUri('https://foobar.com/extra/path')).to.be.an(Error);
});
it('allows IP', function () {
expect(apps._validateUpstreamUri('http://1.2.3.4')).to.eql(null);
});
it('allows IP with port', function () {
expect(apps._validateUpstreamUri('http://1.2.3.4:80')).to.eql(null);
});
it('allows domain', function () {
expect(apps._validateUpstreamUri('https://www.cloudron.io')).to.eql(null);
});
it('allows domain with port', function () {
expect(apps._validateUpstreamUri('https://www.cloudron.io:443')).to.eql(null);
});
});
describe('canAccess', function () {
const someuser = { id: 'someuser', groupIds: [], role: 'user' };
const adminuser = { id: 'adminuser', groupIds: [ 'groupie' ], role: 'admin' };
@@ -269,6 +304,27 @@ describe('Apps', function () {
});
});
describe('proxy app', function () {
const app = require('./common.js').proxyApp;
const newUpstreamUri = 'https://foobar.com:443';
before(async function () {
await apps.add(app.id, app.appStoreId, app.manifest, app.subdomain, app.domain, app.portBindings, app);
});
it('cannot set invalid upstream uri', async function () {
const [error] = await safe(apps.setUpstreamUri(app, 'foo:bar:80', AuditSource.PLATFORM));
expect(error.reason).to.be(BoxError.BAD_FIELD);
});
it('can set upstream uri', async function () {
await apps.setUpstreamUri(app, newUpstreamUri, AuditSource.PLATFORM);
const result = await apps.get(app.id);
expect(result.upstreamUri).to.equal(newUpstreamUri);
});
});
describe('configureInstalledApps', function () {
const app1 = Object.assign({}, app, { id: 'id1', installationState: apps.ISTATE_ERROR, subdomain: 'loc1' });
const app2 = Object.assign({}, app, { id: 'id2', installationState: apps.ISTATE_INSTALLED, subdomain: 'loc2' });

View File

@@ -1,7 +1,6 @@
'use strict';
const apps = require('../apps.js'),
async = require('async'),
constants = require('../constants.js'),
database = require('../database.js'),
delay = require('../delay.js'),
@@ -11,7 +10,7 @@ const apps = require('../apps.js'),
mailer = require('../mailer.js'),
nock = require('nock'),
path = require('path'),
rimraf = require('rimraf'),
paths = require('../paths.js'),
settings = require('../settings.js'),
tasks = require('../tasks.js'),
users = require('../users.js');
@@ -44,6 +43,34 @@ const manifest = {
}
};
// copied from the proxy app CloudronManifest.json
const proxyAppManifest = {
"id": "io.cloudron.builtin.appproxy",
"title": "App Proxy",
"author": "Cloudron Team",
"version": "1.0.0",
"upstreamVersion": "1.0.0",
"description": "file://DESCRIPTION.md",
"tagline": "Proxy an app through Cloudron",
"tags": [ "proxy", "external" ],
"healthCheckPath": "/",
"httpPort": 3000,
"minBoxVersion": "7.3.0",
"dockerImage": "istobeignored",
"manifestVersion": 2,
"multiDomain": true,
"website": "https://cloudron.io",
"documentationUrl": "https://docs.cloudron.io/dashboard/#app-proxy",
"forumUrl": "https://forum.cloudron.io",
"contactEmail": "support@cloudron.io",
"icon": "file://logo.png",
"addons": {},
"mediaLinks": [
"https://screenshots.cloudron.io/io.cloudron.builtin.appproxy/diagram.png"
],
"changelog": "file://CHANGELOG.md"
};
const domain = {
domain: 'example.com',
zoneName: 'example.com',
@@ -111,6 +138,27 @@ const app = {
};
Object.freeze(app);
const proxyApp = {
id: 'proxyapptestid',
appStoreId: proxyAppManifest.id,
installationState: apps.ISTATE_PENDING_INSTALL,
runState: 'running',
subdomain: 'proxylocation',
upstreamUri: 'http://1.2.3.4:80',
domain: domain.domain,
fqdn: domain.domain + '.' + 'proxylocation',
manifest,
containerId: '',
portBindings: null,
accessRestriction: null,
memoryLimit: 0,
mailboxDomain: domain.domain,
secondaryDomains: [],
redirectDomains: [],
aliasDomains: []
};
Object.freeze(proxyApp);
exports = module.exports = {
createTree,
domainSetup,
@@ -125,6 +173,7 @@ exports = module.exports = {
dashboardFqdn: `my.${domain.domain}`,
app,
proxyApp,
admin,
auditSource,
domain, // the domain object
@@ -136,7 +185,7 @@ exports = module.exports = {
};
function createTree(root, obj) {
rimraf.sync(root);
fs.rmSync(root, { recursive: true, force: true });
fs.mkdirSync(root, { recursive: true });
function createSubTree(tree, curpath) {
@@ -174,22 +223,16 @@ async function domainSetup() {
await domains.add(domain.domain, domain, auditSource);
}
function setup(done) {
async.series([
domainSetup,
async function createOwner() {
const result = await users.createOwner(admin.email, admin.username, admin.password, admin.displayName, auditSource);
admin.id = result;
},
apps.add.bind(null, app.id, app.appStoreId, app.manifest, app.subdomain, app.domain, app.portBindings, app),
settings._set.bind(null, settings.APPSTORE_API_TOKEN_KEY, exports.appstoreToken), // appstore token
async function createUser() {
const result = await users.add(user.email, user, auditSource);
user.id = result;
},
tasks.stopAllTasks,
], done);
async function setup() {
await fs.promises.rm(paths.DISK_USAGE_FILE, { force: true });
await domainSetup();
const ownerId = await users.createOwner(admin.email, admin.username, admin.password, admin.displayName, auditSource);
admin.id = ownerId;
await apps.add(app.id, app.appStoreId, app.manifest, app.subdomain, app.domain, app.portBindings, app);
await settings._set(settings.APPSTORE_API_TOKEN_KEY, exports.appstoreToken); // appstore token
const userId = await users.add(user.email, user, auditSource);
user.id = userId;
await tasks.stopAllTasks();
}
async function cleanup() {

36
src/test/df-test.js Normal file
View File

@@ -0,0 +1,36 @@
/* jslint node:true */
/* global it:false */
/* global describe:false */
/* global before:false */
/* global after:false */
'use strict';
const common = require('./common.js'),
df = require('../df.js'),
expect = require('expect.js');
describe('System', function () {
const { setup, cleanup } = common;
before(setup);
after(cleanup);
it('can get disks', async function () {
// does not work on archlinux 8!
if (require('child_process').execSync('uname -a').toString().indexOf('-arch') !== -1) return;
const disks = await df.disks();
expect(disks).to.be.ok();
expect(disks.some(d => d.mountpoint === '/')).to.be.ok();
});
it('can get file', async function () {
// does not work on archlinux 8!
if (require('child_process').execSync('uname -a').toString().indexOf('-arch') !== -1) return;
const disks = await df.file(__dirname);
expect(disks).to.be.ok();
expect(disks.mountpoint).to.be('/home');
});
});

View File

@@ -10,7 +10,9 @@ const common = require('./common.js'),
expect = require('expect.js');
describe('DNS', function () {
const { setup, cleanup, app, domain } = common;
const { setup, cleanup, app, domain:domainObject } = common;
const domain = domainObject.domain;
before(setup);
after(cleanup);
@@ -23,8 +25,7 @@ describe('DNS', function () {
it('cannot have >63 length subdomains', function () {
const s = Array(64).fill('s').join('');
expect(dns.validateHostname(s, domain)).to.be.an(Error);
const domainCopy = Object.assign({}, domain, { zoneName: `dev.${s}.example.com` });
expect(dns.validateHostname(`dev.${s}`, domainCopy)).to.be.an(Error);
expect(dns.validateHostname(`dev.${s}`, domain)).to.be.an(Error);
});
it('allows only alphanumerics and hypen', function () {

View File

@@ -0,0 +1,29 @@
/* global it:false */
/* global describe:false */
'use strict';
const expect = require('expect.js'),
fs = require('fs'),
LogStream = require('../log-stream.js'),
stream = require('stream');
describe('log stream', function () {
it('can create stream', function (done) {
fs.writeFileSync('/tmp/test-input.log', '2022-10-09T15:19:48.740Z message', 'utf8');
const input = fs.createReadStream('/tmp/test-input.log');
const log = new LogStream({ format: 'json', source: 'test' });
const output = fs.createWriteStream('/tmp/test-output.log');
stream.pipeline(input, log, output, function (error) {
expect(error).to.not.be.ok();
const out = fs.readFileSync('/tmp/test-output.log', 'utf8');
const firstLine = JSON.parse(out.split('\n')[0]);
expect(firstLine.realtimeTimestamp).to.be.a('number');
expect(firstLine.message).to.be('message');
expect(firstLine.source).to.be('test');
done();
});
});
});

View File

@@ -0,0 +1,25 @@
/* global it:false */
/* global describe:false */
'use strict';
const expect = require('expect.js'),
fs = require('fs'),
ProgressStream = require('../progress-stream.js'),
stream = require('stream');
describe('progress stream', function () {
it('can create stream', function (done) {
const input = fs.createReadStream(`${__dirname}/progress-stream-test.js`);
const progress = new ProgressStream({ interval: 1000 });
const output = fs.createWriteStream('/dev/null');
stream.pipeline(input, progress, output, function (error) {
expect(error).to.not.be.ok();
const size = fs.statSync(`${__dirname}/progress-stream-test.js`).size;
expect(progress._transferred).to.be(size);
done();
});
});
});

View File

@@ -20,15 +20,8 @@ describe('Reverse Proxy', function () {
after(cleanup);
describe('validateCertificate', function () {
let foobarDomain = {
domain: 'foobar.com',
config: {}
};
let amazingDomain = {
domain: 'amazing.com',
config: {}
};
let foobarDomain = 'foobar.com';
let amazingDomain = 'amazing.com';
/*
Generate these with:
openssl genrsa -out server.key 512
@@ -82,7 +75,7 @@ describe('Reverse Proxy', function () {
});
it('does not allow cert without matching domain', function () {
expect(reverseProxy.validateCertificate('', { domain: 'cloudron.io' }, { cert: validCert0, key: validKey0 })).to.be.an(Error);
expect(reverseProxy.validateCertificate('', 'cloudron.io', { cert: validCert0, key: validKey0 })).to.be.an(Error);
expect(reverseProxy.validateCertificate('cloudron.io', foobarDomain, { cert: validCert0, key: validKey0 })).to.be.an(Error);
});
@@ -122,48 +115,17 @@ describe('Reverse Proxy', function () {
});
describe('generateFallbackCertificate', function () {
let domainObject = {
domain: 'cool.com',
config: {}
};
const domain = 'cool.com';
let result;
it('can generate fallback certs', async function () {
result = await reverseProxy.generateFallbackCertificate(domainObject.domain);
result = await reverseProxy.generateFallbackCertificate(domain);
expect(result).to.be.ok();
});
it('can validate the certs', function () {
expect(reverseProxy.validateCertificate('foo', domainObject, result)).to.be(null);
expect(reverseProxy.validateCertificate('', domainObject, result)).to.be(null);
});
});
describe('getApi - letsencrypt-prod', function () {
before(async function () {
domainCopy.tlsConfig = { provider: 'letsencrypt-prod' };
await domains.setConfig(domainCopy.domain, domainCopy, auditSource);
});
it('returns prod acme in prod cloudron', async function () {
const { acme2, apiOptions } = await reverseProxy._getAcmeApi(domainCopy);
expect(acme2._name).to.be('acme');
expect(apiOptions.prod).to.be(true);
});
});
describe('getApi - letsencrypt-staging', function () {
before(async function () {
domainCopy.tlsConfig = { provider: 'letsencrypt-staging' };
await domains.setConfig(domainCopy.domain, domainCopy, auditSource);
});
it('returns staging acme in prod cloudron', async function () {
const { acme2, apiOptions } = await reverseProxy._getAcmeApi(domainCopy);
expect(acme2._name).to.be('acme');
expect(apiOptions.prod).to.be(false);
expect(reverseProxy.validateCertificate('foo', domain, result)).to.be(null);
expect(reverseProxy.validateCertificate('', domain, result)).to.be(null);
});
});

View File

@@ -18,7 +18,6 @@ const BoxError = require('../boxerror.js'),
os = require('os'),
path = require('path'),
readdirp = require('readdirp'),
rimraf = require('rimraf'),
s3 = require('../storage/s3.js'),
safe = require('safetydance'),
settings = require('../settings.js');
@@ -52,7 +51,7 @@ describe('Storage', function () {
});
after(function (done) {
rimraf.sync(gTmpFolder);
fs.rmSync(gTmpFolder, { recursive: true, force: true });
done();
});
@@ -217,7 +216,7 @@ describe('Storage', function () {
before(function () {
MockS3.config.basePath = path.join(os.tmpdir(), 's3-backup-test-buckets/');
rimraf.sync(MockS3.config.basePath);
fs.rmSync(MockS3.config.basePath, { recursive: true, force: true });
gS3Folder = path.join(MockS3.config.basePath, gBackupConfig.bucket);
s3._mockInject(MockS3);
@@ -225,7 +224,7 @@ describe('Storage', function () {
after(function () {
s3._mockRestore();
rimraf.sync(MockS3.config.basePath);
fs.rmSync(MockS3.config.basePath, { recursive: true, force: true });
});
it('can upload', function (done) {
@@ -367,7 +366,7 @@ describe('Storage', function () {
after(function (done) {
gcs._mockRestore();
rimraf.sync(GCSMockBasePath);
fs.rmSync(GCSMockBasePath, { recursive: true, force: true });
done();
});

View File

@@ -22,6 +22,16 @@ describe('System', function () {
const disks = await system.getDisks();
expect(disks).to.be.ok();
expect(Object.keys(disks).some(fs => disks[fs].mountpoint === '/')).to.be.ok();
});
it('can get swaps', async function () {
// does not work on archlinux 8!
if (require('child_process').execSync('uname -a').toString().indexOf('-arch') !== -1) return;
const swaps = await system.getSwaps();
expect(swaps).to.be.ok();
expect(Object.keys(swaps).some(n => swaps[n].type === 'partition')).to.be.ok();
});
it('can check for disk space', async function () {

View File

@@ -12,7 +12,7 @@ const apps = require('./apps.js'),
constants = require('./constants.js'),
crypto = require('crypto'),
debug = require('debug')('box:updater'),
df = require('@sindresorhus/df'),
df = require('./df.js'),
eventlog = require('./eventlog.js'),
fs = require('fs'),
locker = require('./locker.js'),

View File

@@ -80,6 +80,7 @@ const appPasswords = require('./apppasswords.js'),
eventlog = require('./eventlog.js'),
externalLdap = require('./externalldap.js'),
hat = require('./hat.js'),
mail = require('./mail.js'),
mailer = require('./mailer.js'),
mysql = require('mysql'),
qrcode = require('qrcode'),
@@ -630,24 +631,6 @@ async function getSuperadmins() {
return await getByRole(exports.ROLE_OWNER);
}
async function sendPasswordResetByIdentifier(identifier, auditSource) {
assert.strictEqual(typeof identifier, 'string');
assert.strictEqual(typeof auditSource, 'object');
const user = identifier.indexOf('@') === -1 ? await getByUsername(identifier.toLowerCase()) : await getByEmail(identifier.toLowerCase());
if (!user) throw new BoxError(BoxError.NOT_FOUND, 'User not found');
const resetToken = hat(256);
const resetTokenCreationTime = new Date();
user.resetToken = resetToken;
user.resetTokenCreationTime = resetTokenCreationTime;
await update(user, { resetToken,resetTokenCreationTime }, auditSource);
const resetLink = `${settings.dashboardOrigin()}/login.html?resetToken=${user.resetToken}`;
await mailer.passwordReset(user, user.fallbackEmail || user.email, resetLink);
}
async function getPasswordResetLink(user, auditSource) {
assert.strictEqual(typeof user, 'object');
assert.strictEqual(typeof auditSource, 'object');
@@ -667,6 +650,23 @@ async function getPasswordResetLink(user, auditSource) {
return resetLink;
}
async function sendPasswordResetByIdentifier(identifier, auditSource) {
assert.strictEqual(typeof identifier, 'string');
assert.strictEqual(typeof auditSource, 'object');
const user = identifier.indexOf('@') === -1 ? await getByUsername(identifier.toLowerCase()) : await getByEmail(identifier.toLowerCase());
if (!user) throw new BoxError(BoxError.NOT_FOUND, 'User not found');
const email = user.fallbackEmail || user.email;
// security measure to prevent a mail manager or admin resetting the superadmin's password
const mailDomains = await mail.listDomains();
if (mailDomains.some(d => d.enabled && email.endsWith(`@${d.domain}`))) throw new BoxError(BoxError.CONFLICT, 'Password reset email cannot be sent to email addresses hosted on the same Cloudron');
const resetLink = await getPasswordResetLink(user, auditSource);
await mailer.passwordReset(user, email, resetLink);
}
async function sendPasswordResetEmail(user, email, auditSource) {
assert.strictEqual(typeof user, 'object');
assert.strictEqual(typeof email, 'string');
@@ -675,6 +675,10 @@ async function sendPasswordResetEmail(user, email, auditSource) {
const error = validateEmail(email);
if (error) throw error;
// security measure to prevent a mail manager or admin resetting the superadmin's password
const mailDomains = await mail.listDomains();
if (mailDomains.some(d => d.enabled && email.endsWith(`@${d.domain}`))) throw new BoxError(BoxError.CONFLICT, 'Password reset email cannot be sent to email addresses hosted on the same Cloudron');
const resetLink = await getPasswordResetLink(user, auditSource);
await mailer.passwordReset(user, email, resetLink);
}