Compare commits

..

175 Commits

Author SHA1 Message Date
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
Johannes Zellner 8b295fbfdb total stats are reported directly as single value 2022-10-14 12:00:24 +02:00
Johannes Zellner 4e47a1ad3b Clean stats api response to have specific response types 2022-10-14 11:25:43 +02:00
Johannes Zellner 8f91991e1e Also collect total I/O stats for the containers 2022-10-14 11:15:52 +02:00
Girish Ramakrishnan ae66692eda Ensure collectd directory 2022-10-14 10:43:30 +02:00
Girish Ramakrishnan 7cb326cfff no camel case in filenames 2022-10-14 08:22:04 +02:00
Girish Ramakrishnan eb5c90a2e7 du: do not crash when app dir is missing
this can happen when the app is installing/uninstalling
2022-10-13 23:35:01 +02:00
Girish Ramakrishnan 91d1d0b74b add to changes 2022-10-13 23:12:20 +02:00
Girish Ramakrishnan 351292ce1a graph: return sum cpu value 2022-10-13 23:03:31 +02:00
Girish Ramakrishnan ca4e1e207c return cpuCount from app/service graphs as well 2022-10-13 22:38:44 +02:00
Girish Ramakrishnan 1872cea763 graphs: do not average cpu use
Show like htop/top: cpu core count * 100
2022-10-13 22:36:20 +02:00
Girish Ramakrishnan 4015afc69c graphs: send service graphs 2022-10-13 20:52:22 +02:00
Johannes Zellner 6d8c3febac Also add rootDSE to the directory server 2022-10-12 22:13:54 +02:00
Girish Ramakrishnan b5da4143c9 graphs: add app response in system graphs 2022-10-12 22:08:10 +02:00
Girish Ramakrishnan 4fe0402735 box data is separate from mail data already 2022-10-12 11:59:28 +02:00
Girish Ramakrishnan 4a3d85a269 add docker disk usage tests 2022-10-12 10:57:22 +02:00
Girish Ramakrishnan fa7c0a6e1b add disks tests 2022-10-12 10:45:29 +02:00
Girish Ramakrishnan 62d68e2733 graphs: remove the disk info 2022-10-12 10:30:02 +02:00
Girish Ramakrishnan edb6ed91fe add disk usage task 2022-10-12 10:26:21 +02:00
Girish Ramakrishnan a3f7ce15ab system: rework disks api to return by filesystem 2022-10-12 09:42:14 +02:00
Girish Ramakrishnan 4348556dc7 Fix applinks test 2022-10-12 09:37:25 +02:00
Girish Ramakrishnan deb6d78e4d bump addon timeouts 2022-10-11 23:33:35 +02:00
Girish Ramakrishnan 3c963329e9 du: exclude option 2022-10-11 23:14:50 +02:00
Girish Ramakrishnan 656f3fcc13 add system.du 2022-10-11 23:06:54 +02:00
Girish Ramakrishnan 760301ce02 Add docker.df 2022-10-11 23:06:51 +02:00
Girish Ramakrishnan 6f61145b01 configurecollectd.sh is no more 2022-10-11 21:04:25 +02:00
Johannes Zellner cbaf86b8c7 Use counter values for docker stats in collectd and grafana queries 2022-10-11 19:06:40 +02:00
Girish Ramakrishnan 9d35756db5 graphs: just query graphite IP instead of localhost mapping 2022-10-11 12:44:37 +02:00
Girish Ramakrishnan 22790fd9b7 system: include only ram info for graphs
app graphs only contain ram info since that is what docker stats provides
2022-10-11 11:54:06 +02:00
Johannes Zellner ad29f51833 Fixup typo guage -> gauge in docker-stats.py 2022-10-11 10:54:53 +02:00
Girish Ramakrishnan 3caffdb4e1 Rework app stats
Previously, the du plugin was collecting data every 20 seconds but
carbon was configured to only keep data every 12 hours causing much
confusion.

In the process of reworking this, it was determined:

* No need to collect disk usage info over time. Not sure how that is useful
* Instead, collect CPU/Network/Block info over time. We get this now from docker stats
* We also collect info about the services (addon containers)
* No need to reconfigure collectd for each app change anymore since there is no per
app collectd configuration anymore.
2022-10-10 21:13:26 +02:00
Girish Ramakrishnan 2133eab341 postgresql: fix issue when restoring large dumps 2022-10-10 12:30:26 +02:00
Johannes Zellner 25379f1d21 Prevent code from crashing when DO access token contains non-ascii characters 2022-10-07 11:25:17 +02:00
Johannes Zellner cb8d90699b Better feedback if no applink schema is provided 2022-10-06 19:49:33 +02:00
Johannes Zellner 6e4e8bf74d Add applink upstreamUri validation 2022-10-06 19:35:07 +02:00
Johannes Zellner 87a00b9209 Fixup app link icon fetching and do not overwrite upstreamUri with redirect 2022-10-06 19:23:15 +02:00
Girish Ramakrishnan d51b022721 applinks: make get return null
this style matches rest of the code base
2022-10-06 11:32:42 +02:00
Girish Ramakrishnan cb9b9272cd lint 2022-10-06 11:27:12 +02:00
Girish Ramakrishnan 7dbb677af4 postgresql: move config to runtime for debuggability 2022-10-06 10:13:49 +02:00
Girish Ramakrishnan 071202fb00 mail: log error 2022-10-04 10:57:42 +02:00
Girish Ramakrishnan fc7414cce6 support: require superadmin 2022-10-04 10:25:11 +02:00
Girish Ramakrishnan acb92c8865 mail queue: fix search + pagination 2022-10-03 10:51:35 +02:00
Girish Ramakrishnan c3793da5bb split checkPrecondition so it can be used in cleaner as well 2022-10-02 17:41:21 +02:00
Girish Ramakrishnan 4f4a0ec289 use mount code to check mount status 2022-10-02 16:51:03 +02:00
Girish Ramakrishnan a4a9b52966 Clarify error message 2022-10-02 16:38:12 +02:00
Girish Ramakrishnan 56b981a52b backups: when checking mount status, ignore the prefix 2022-10-02 16:33:04 +02:00
Girish Ramakrishnan 074e9cfd93 rename getRootPath to getBackupRootPath 2022-10-02 16:26:27 +02:00
Girish Ramakrishnan 9d17c6606b rename to checkBackupPreconditions
since this is called only by the backup logic
2022-10-02 16:20:14 +02:00
Girish Ramakrishnan b32288050e backups: check mount status before checking available size 2022-10-02 16:16:30 +02:00
Girish Ramakrishnan 4aab03bb07 import: cleanup app import logic 2022-10-02 10:08:50 +02:00
Girish Ramakrishnan 9f788c2c57 backup: reduce memory logs 2022-10-01 20:16:08 +02:00
Girish Ramakrishnan 84ba333aa1 app proxy: disable TLS check in app health monitor 2022-10-01 11:47:52 +02:00
Girish Ramakrishnan c07fe4195f eventlog: preserve last 2 months 2022-10-01 11:01:41 +02:00
Girish Ramakrishnan 92112986a7 7.3.1 changes 2022-10-01 08:46:13 +02:00
Girish Ramakrishnan 54af286fcd app proxy: workaround for nginx not starting if upstream is down
https://sandro-keil.de/blog/let-nginx-start-if-upstream-host-is-unavailable-or-down/

without a resolver, dns names do not resolve
2022-09-30 10:36:44 +02:00
Girish Ramakrishnan 7b5df02a0e app proxy: validate uri 2022-09-29 18:56:10 +02:00
Girish Ramakrishnan 4f0e0706b2 backups: fix id
avoid box_box_ and mail_mail_ in backup ids
2022-09-29 18:01:19 +02:00
Girish Ramakrishnan 1f74febdb0 mail: do not clear eventlog on restart 2022-09-28 22:16:32 +02:00
Girish Ramakrishnan 49bf333355 merge these changelog entries 2022-09-28 18:22:00 +02:00
Girish Ramakrishnan c4af06dd66 remove duplicate changelog entry 2022-09-28 18:21:12 +02:00
Johannes Zellner f5f9a8e520 Send 404 if applink icon does not exist 2022-09-28 15:18:05 +02:00
Johannes Zellner ae376774e4 Ensure we don't put empty applink icon buffers in db 2022-09-28 15:10:17 +02:00
Johannes Zellner ff8c2184f6 Convert applink ts to timestamp 2022-09-28 14:59:30 +02:00
Johannes Zellner a7b056a84c Some tweaks for better app link detection logic 2022-09-28 14:23:45 +02:00
Girish Ramakrishnan 131d456329 Add cloudflare R2 2022-09-27 19:44:20 +02:00
Girish Ramakrishnan d4bba93dbf cloudron-setup: Fix display on newline 2022-09-27 11:25:11 +02:00
Johannes Zellner e332ad96e4 Remove duplicate changes for 7.3.0 2022-09-26 17:42:39 +02:00
120 changed files with 3199 additions and 8309 deletions
+36 -5
View File
@@ -2513,7 +2513,7 @@
* Applinks - app bookmarks in dashboard
* backups: optional encryption of backup file names
* eventlog: add event for impersonated user login
* ldap: remove virtual user and admin groups to ldap user records
* ldap & user directory: Remove virtual user and admin groups
* Randomize certificate generation cronjob to lighten load on Let's Encrypt servers
* mail: catch all address can be any domain
* mail: accept only STARTTLS servers for relay
@@ -2523,8 +2523,6 @@
* backups: allow space in label name
* mail: fix crash when solr is enabled on Ubuntu 22 (cgroup v2 detection fix)
* mail: fix issue where certificate renewal did not restart the mail container properly
* acme: Randomize certificate renewal check over a whole day
* ldap & user directory: Remove virtual user and admin groups
* notification: Fix crash when backupId is null
* IPv6: initial support for ipv6 only server
* User directory: Cloudron connector uses 2FA auth
@@ -2534,12 +2532,45 @@
* proxyAuth: add supportsBearerAuth flag
* backups: Fix precondition check which was not erroring if mount is missing
* mail: add queue management API and UI
* mail: catch all address can be any domain
* graphs: show app disk usage graphs
* UI: fix issue where mailbox display name was not init correctly
* wasabi: add singapore and sydney regions
* filemanager: add split view
* mail: fix issue where signature was appended to text attachments
* nginx: fix zero length certs when out of disk space
* read only API tokens
[7.3.1]
* Add cloudlare R2
* app proxy: fixes to https proxying
* app links: fix icons
[7.3.2]
* support: require owner permissions
* 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
+6
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.');
+891 -6465
View File
File diff suppressed because it is too large Load Diff
+21 -34
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.0",
"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": "./runTests",
"postmerge": "/bin/true",
"precommit": "/bin/true",
"prepush": "npm test",
"dashboard": "node_modules/.bin/gulp"
"test": "./run-tests"
}
}
+5 -10
View File
@@ -6,7 +6,7 @@ readonly source_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
readonly DATA_DIR="${HOME}/.cloudron_test"
readonly DEFAULT_TESTS="./src/test/*-test.js ./src/routes/test/*-test.js"
! "${source_dir}/src/test/checkInstall" && exit 1
! "${source_dir}/src/test/check-install" && exit 1
# cleanup old data dirs some of those docker container data requires sudo to be removed
echo "=> Provide root password to purge any leftover data in ${DATA_DIR} and load apparmor profile:"
@@ -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
@@ -39,7 +39,7 @@ if [[ -z ${FAST+x} ]]; then
echo "=> Delete all docker containers first"
docker ps -qa --filter "label=isCloudronManaged" | xargs --no-run-if-empty docker rm -f
docker rm -f mysql-server
echo "==> To skip this run with: FAST=1 ./runTests"
echo "==> To skip this run with: FAST=1 ./run-tests"
else
echo "==> WARNING!! Skipping docker container cleanup, the database might not be pristine!"
fi
@@ -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}
+2 -1
View File
@@ -259,7 +259,8 @@ echo -e "\n\n${GREEN}After reboot, visit one of the following URLs and accept th
if [[ "${rebootServer}" == "true" ]]; then
systemctl stop box mysql # sometimes mysql ends up having corrupt privilege tables
read -p "\nThe server has to be rebooted to apply all the settings. Reboot now ? [Y/n] " yn
# https://www.gnu.org/savannah-checkouts/gnu/bash/manual/bash.html#ANSI_002dC-Quoting
read -p $'\n'"The server has to be rebooted to apply all the settings. Reboot now ? [Y/n] " yn
yn=${yn:-y}
case $yn in
[Yy]* ) exitHandler; systemctl reboot;;
+2 -2
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
+6 -5
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
+5 -7
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,9 +56,10 @@ 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/collectd.conf.d"
mkdir -p "${PLATFORM_DATA_DIR}/collectd"
mkdir -p "${PLATFORM_DATA_DIR}/logrotate.d"
mkdir -p "${PLATFORM_DATA_DIR}/acme"
mkdir -p "${PLATFORM_DATA_DIR}/backup"
@@ -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
@@ -133,7 +131,7 @@ rm -f /etc/sudoers.d/${USER} /etc/sudoers.d/cloudron
cp "${script_dir}/start/sudoers" /etc/sudoers.d/cloudron
log "Configuring collectd"
rm -rf /etc/collectd /var/log/collectd.log
rm -rf /etc/collectd /var/log/collectd.log "${PLATFORM_DATA_DIR}/collectd/collectd.conf.d"
ln -sfF "${PLATFORM_DATA_DIR}/collectd" /etc/collectd
cp "${script_dir}/start/collectd/collectd.conf" "${PLATFORM_DATA_DIR}/collectd/collectd.conf"
systemctl restart collectd
@@ -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"
@@ -231,7 +229,7 @@ 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}"
+3 -22
View File
@@ -187,9 +187,9 @@ LoadPlugin swap
CalculateNum false
CalculateSum true
CalculateAverage true
CalculateAverage false
CalculateMinimum false
CalculateMaximum true
CalculateMaximum false
CalculateStddev false
</Aggregation>
</Plugin>
@@ -211,23 +211,7 @@ LoadPlugin swap
Interactive false
Import "df"
Import "du"
<Module du>
<Path>
Instance maildata
Dir "/home/yellowtent/boxdata/mail"
</Path>
<Path>
Instance boxdata
Dir "/home/yellowtent/boxdata"
Exclude "mail"
</Path>
<Path>
Instance platformdata
Dir "/home/yellowtent/platformdata"
</Path>
</Module>
Import "docker-stats"
</Plugin>
<Plugin write_graphite>
@@ -243,6 +227,3 @@ LoadPlugin swap
</Node>
</Plugin>
<Include "/etc/collectd/collectd.conf.d">
Filter "*.conf"
</Include>
+64
View File
@@ -0,0 +1,64 @@
import collectd,os,subprocess,json,re
# https://blog.dbrgn.ch/2017/3/10/write-a-collectd-python-plugin/
def parseSiSize(size):
units = {"B": 1, "KB": 10**3, "MB": 10**6, "GB": 10**9, "TB": 10**12}
number, unit, _ = re.split('([a-zA-Z]+)', size.upper())
return int(float(number)*units[unit])
def parseBinarySize(size):
units = {"B": 1, "KIB": 2**10, "MIB": 2**20, "GIB": 2**30, "TIB": 2**40}
number, unit, _ = re.split('([a-zA-Z]+)', size.upper())
return int(float(number)*units[unit])
def init():
collectd.info('custom docker-status plugin initialized')
def read():
try:
lines = subprocess.check_output('docker stats --format "{{ json . }}" --no-stream --no-trunc', shell=True).decode('utf-8').strip().split("\n")
except Exception as e:
collectd.info('\terror getting docker stats: %s' % (str(e)))
return 0
# Sample line
# {"BlockIO":"430kB / 676kB","CPUPerc":"0.00%","Container":"7eae5e6f4f11","ID":"7eae5e6f4f11","MemPerc":"59.15%","MemUsage":"45.55MiB / 77MiB","Name":"1062eef3-ec96-4d81-9f02-15b7dd81ccb9","NetIO":"1.5MB / 3.48MB","PIDs":"5"}
for line in lines:
stat = json.loads(line)
containerName = stat["Name"] # same as app id
networkData = stat["NetIO"].split("/")
networkRead = parseSiSize(networkData[0].strip())
networkWrite = parseSiSize(networkData[1].strip())
blockData = stat["BlockIO"].split("/")
blockRead = parseSiSize(blockData[0].strip())
blockWrite = parseSiSize(blockData[1].strip())
memUsageData = stat["MemUsage"].split("/")
memUsed = parseBinarySize(memUsageData[0].strip())
memMax = parseBinarySize(memUsageData[1].strip())
cpuPercData = stat["CPUPerc"].strip("%")
cpuPerc = float(cpuPercData)
# type comes from https://github.com/collectd/collectd/blob/master/src/types.db and https://collectd.org/wiki/index.php/Data_source
val = collectd.Values(type='gauge', plugin='docker-stats', plugin_instance=containerName)
val.dispatch(values=[networkRead], type_instance='network-read')
val.dispatch(values=[networkWrite], type_instance='network-write')
val.dispatch(values=[blockRead], type_instance='blockio-read')
val.dispatch(values=[blockWrite], type_instance='blockio-write')
val.dispatch(values=[memUsed], type_instance='mem-used')
val.dispatch(values=[memMax], type_instance='mem-max')
val.dispatch(values=[cpuPerc], type_instance='cpu-perc')
val = collectd.Values(type='counter', plugin='docker-stats', plugin_instance=containerName)
val.dispatch(values=[networkRead], type_instance='network-read')
val.dispatch(values=[networkWrite], type_instance='network-write')
val.dispatch(values=[blockRead], type_instance='blockio-read')
val.dispatch(values=[blockWrite], type_instance='blockio-write')
collectd.register_init(init)
# see Interval setting in collectd.conf for polling interval
collectd.register_read(read)
-105
View File
@@ -1,105 +0,0 @@
import collectd,os,subprocess,sys,re,time
# https://www.programcreek.com/python/example/106897/collectd.register_read
PATHS = [] # { name, dir, exclude }
# there is a pattern in carbon/storage-schemas.conf which stores values every 12h for a year
INTERVAL = 60 * 60 * 12 # twice a day. change values in docker-graphite if you change this
# we used to pass the INTERVAL as a parameter to register_read. however, collectd write_graphite
# takes a bit to load (tcp connection) and drops the du data. this then means that we have to wait
# for INTERVAL secs for du data. instead, we just cache the value for INTERVAL instead
CACHE = dict()
CACHE_TIME = 0
def du(pathinfo):
# -B1 makes du print block sizes and not apparent sizes (to match df which also uses block sizes)
dirname = pathinfo['dir']
cmd = 'timeout 1800 du -DsB1 "{}"'.format(dirname)
if pathinfo['exclude'] != '':
cmd += ' --exclude "{}"'.format(pathinfo['exclude'])
collectd.info('computing size with command: %s' % cmd);
try:
size = subprocess.check_output(cmd, shell=True).split()[0].decode('utf-8')
collectd.info('\tsize of %s is %s (time: %i)' % (dirname, size, int(time.time())))
return size
except Exception as e:
collectd.info('\terror getting the size of %s: %s' % (dirname, str(e)))
return 0
def parseSize(size):
units = {"B": 1, "KB": 10**3, "MB": 10**6, "GB": 10**9, "TB": 10**12}
number, unit, _ = re.split('([a-zA-Z]+)', size.upper())
return int(float(number)*units[unit])
def dockerSize():
# use --format '{{json .}}' to dump the string. '{{if eq .Type "Images"}}{{.Size}}{{end}}' still creates newlines
# https://godoc.org/github.com/docker/go-units#HumanSize is used. so it's 1000 (KB) and not 1024 (KiB)
cmd = 'timeout 1800 docker system df --format "{{.Size}}" | head -n1'
try:
size = subprocess.check_output(cmd, shell=True).strip().decode('utf-8')
collectd.info('size of docker images is %s (%s) (time: %i)' % (size, parseSize(size), int(time.time())))
return parseSize(size)
except Exception as e:
collectd.info('error getting docker images size : %s' % str(e))
return 0
# configure is called for each module block. this is called before init
def configure(config):
global PATHS
for child in config.children:
if child.key != 'Path':
collectd.info('du plugin: Unknown config key "%s"' % key)
continue
pathinfo = { 'name': '', 'dir': '', 'exclude': '' }
for node in child.children:
if node.key == 'Instance':
pathinfo['name'] = node.values[0]
elif node.key == 'Dir':
pathinfo['dir'] = node.values[0]
elif node.key == 'Exclude':
pathinfo['exclude'] = node.values[0]
PATHS.append(pathinfo);
collectd.info('du plugin: monitoring %s' % pathinfo['dir']);
def init():
global PATHS
collectd.info('custom du plugin initialized with %s %s' % (PATHS, sys.version))
def read():
global CACHE, CACHE_TIME
# read from cache if < 12 hours
read_cache = (time.time() - CACHE_TIME) < INTERVAL
if not read_cache:
CACHE_TIME = time.time()
for pathinfo in PATHS:
dirname = pathinfo['dir']
if read_cache and dirname in CACHE:
size = CACHE[dirname]
else:
size = du(pathinfo)
CACHE[dirname] = size
# type comes from https://github.com/collectd/collectd/blob/master/src/types.db
val = collectd.Values(type='capacity', plugin='du', plugin_instance=pathinfo['name'])
val.dispatch(values=[size], type_instance='usage')
if read_cache and 'docker' in CACHE:
size = CACHE['docker']
else:
size = dockerSize()
CACHE['docker'] = size
val = collectd.Values(type='capacity', plugin='du', plugin_instance='docker')
val.dispatch(values=[size], type_instance='usage')
collectd.register_init(init)
collectd.register_config(configure)
collectd.register_read(read)
+4 -2
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
+4 -2
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
+1
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;
}
+3 -4
View File
@@ -19,9 +19,6 @@ yellowtent ALL=(root) NOPASSWD: /home/yellowtent/box/src/scripts/rmaddondir.sh
Defaults!/home/yellowtent/box/src/scripts/reboot.sh env_keep="HOME BOX_ENV"
yellowtent ALL=(root) NOPASSWD: /home/yellowtent/box/src/scripts/reboot.sh
Defaults!/home/yellowtent/box/src/scripts/configurecollectd.sh env_keep="HOME BOX_ENV"
yellowtent ALL=(root) NOPASSWD: /home/yellowtent/box/src/scripts/configurecollectd.sh
Defaults!/home/yellowtent/box/src/scripts/collectlogs.sh env_keep="HOME BOX_ENV"
yellowtent ALL=(root) NOPASSWD: /home/yellowtent/box/src/scripts/collectlogs.sh
@@ -68,5 +65,7 @@ yellowtent ALL=(root) NOPASSWD: /home/yellowtent/box/src/scripts/rmmount.sh
Defaults!/home/yellowtent/box/src/scripts/remountmount.sh env_keep="HOME BOX_ENV"
yellowtent ALL=(root) NOPASSWD: /home/yellowtent/box/src/scripts/remountmount.sh
cloudron-support ALL=(ALL) NOPASSWD: ALL
Defaults!/home/yellowtent/box/src/scripts/du.sh env_keep="HOME BOX_ENV"
yellowtent ALL=(root) NOPASSWD: /home/yellowtent/box/src/scripts/du.sh
cloudron-support ALL=(ALL) NOPASSWD: ALL
+1
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
+132 -137
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();
});
}
+5 -4
View File
@@ -73,7 +73,7 @@ async function checkAppHealth(app, options) {
let healthCheckUrl, host;
if (app.manifest.id === constants.PROXY_APP_APPSTORE_ID) {
healthCheckUrl = app.upstreamUri;
host = '';
host = new URL(app.upstreamUri).host; // includes port
} else {
const [error, data] = await safe(docker.inspect(app.containerId));
if (error || !data || !data.State) return await setHealth(app, apps.HEALTH_ERROR);
@@ -88,6 +88,7 @@ async function checkAppHealth(app, options) {
const [healthCheckError, response] = await safe(superagent
.get(healthCheckUrl)
.disableTLSCerts() // for app proxy
.set('Host', host) // required for some apache configs with rewrite rules
.set('User-Agent', 'Mozilla (CloudronHealth)') // required for some apps (e.g. minio)
.redirects(0)
@@ -171,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) {
+70 -26
View File
@@ -12,14 +12,14 @@ exports = module.exports = {
const assert = require('assert'),
apps = require('./apps.js'),
database = require('./database.js'),
BoxError = require('./boxerror.js'),
uuid = require('uuid'),
database = require('./database.js'),
debug = require('debug')('box:applinks'),
jsdom = require('jsdom'),
safe = require('safetydance'),
superagent = require('superagent'),
validator = require('validator'),
jsdom = require('jsdom'),
debug = require('debug')('box:applinks');
uuid = require('uuid'),
validator = require('validator');
const APPLINKS_FIELDS= [ 'id', 'accessRestrictionJson', 'creationTime', 'updateTime', 'ts', 'label', 'tagsJson', 'icon', 'upstreamUri' ].join(',');
@@ -35,7 +35,24 @@ function postProcess(result) {
if (result.accessRestriction && !result.accessRestriction.users) result.accessRestriction.users = [];
delete result.accessRestrictionJson;
result.ts = new Date(result.ts).getTime();
result.icon = result.icon ? result.icon : null;
}
function validateUpstreamUri(upstreamUri) {
assert.strictEqual(typeof upstreamUri, 'string');
if (!upstreamUri) return new BoxError(BoxError.BAD_FIELD, 'upstreamUri cannot be empty');
if (!upstreamUri.includes('://')) return new BoxError(BoxError.BAD_FIELD, 'upstreamUri has no schema');
const uri = safe(() => new URL(upstreamUri));
if (!uri) return new BoxError(BoxError.BAD_FIELD, `upstreamUri is invalid: ${safe.error.message}`);
if (uri.protocol !== 'http:' && uri.protocol !== 'https:') return new BoxError(BoxError.BAD_FIELD, 'upstreamUri has an unsupported scheme');
return null;
}
async function list() {
@@ -53,29 +70,53 @@ async function listByUser(user) {
return result.filter((app) => apps.canAccess(app, user));
}
async function amendIconAndLabel(applink) {
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) {
debug('detectMetaInfo: Unable to fetch upstreamUri to detect icon and title', error.statusCode);
return;
}
if (applink.favicon && applink.label) return;
const [error, response] = await safe(superagent.get(applink.upstreamUri));
if (error || !response.text) throw new BoxError(BoxError.BAD_FIELD, 'cannot fetch upstream uri for favicon and label');
// set redirected URI if any for favicon url
const redirectUri = (response.redirects && response.redirects.length) ? response.redirects[0] : null;
const dom = new jsdom.JSDOM(response.text);
if (!applink.icon) {
let favicon = '';
if (dom.window.document.querySelector('link[rel="apple-touch-icon"]')) favicon = dom.window.document.querySelector('link[rel="apple-touch-icon"]').href ;
else if (!favicon.endsWith('.png') && dom.window.document.querySelector('meta[name="msapplication-TileImage"]')) favicon = dom.window.document.querySelector('meta[name="msapplication-TileImage"]').content ;
else if (!favicon.endsWith('.png') && dom.window.document.querySelector('link[rel="shortcut icon"]')) favicon = dom.window.document.querySelector('link[rel="shortcut icon"]').href ;
else if (!favicon.endsWith('.png') && dom.window.document.querySelector('link[rel="icon"]')) favicon = dom.window.document.querySelector('link[rel="icon"]').href ;
if (!favicon.endsWith('.png') && dom.window.document.querySelector('meta[name="msapplication-TileImage"]')) favicon = dom.window.document.querySelector('meta[name="msapplication-TileImage"]').content ;
if (!favicon.endsWith('.png') && dom.window.document.querySelector('link[rel="shortcut icon"]')) favicon = dom.window.document.querySelector('link[rel="shortcut icon"]').href ;
if (!favicon.endsWith('.png') && dom.window.document.querySelector('link[rel="icon"]')) {
let iconElements = dom.window.document.querySelectorAll('link[rel="icon"]');
if (iconElements.length) {
favicon = iconElements[0].href; // choose first one for a start
// check if we have sizes attributes and then choose the largest one
iconElements = Array.from(iconElements).filter(function (e) {
return e.attributes.getNamedItem('sizes') && e.attributes.getNamedItem('sizes').value;
}).sort(function (a, b) {
return parseInt(b.attributes.getNamedItem('sizes').value.split('x')[0]) - parseInt(a.attributes.getNamedItem('sizes').value.split('x')[0]);
});
if (iconElements.length) favicon = iconElements[0].href;
}
}
if (!favicon.endsWith('.png') && dom.window.document.querySelector('meta[itemprop="image"]')) favicon = dom.window.document.querySelector('meta[itemprop="image"]').content;
if (favicon) {
if (favicon.startsWith('/')) favicon = applink.upstreamUri + favicon;
if (favicon.startsWith('/')) favicon = (redirectUri || applink.upstreamUri) + favicon;
debug(`detectMetaInfo: found icon: ${favicon}`);
const [error, response] = await safe(superagent.get(favicon));
if (error) console.error(`Failed to fetch icon ${favicon}: `, error);
else if (response.ok && response.headers['content-type'] === 'image/png') applink.icon = response.body;
else console.error(`Failed to fetch icon ${favicon}: statusCode=${response.status}`);
} else {
console.error(`Unable to find a suitable icon for ${applink.upstreamUri}`);
}
}
@@ -92,12 +133,15 @@ async function add(applink) {
debug(`add: ${applink.upstreamUri}`, applink);
let error = validateUpstreamUri(applink.upstreamUri);
if (error) throw error;
if (applink.icon) {
if (!validator.isBase64(applink.icon)) throw new BoxError(BoxError.BAD_FIELD, 'icon is not base64');
applink.icon = Buffer.from(applink.icon, 'base64');
}
await amendIconAndLabel(applink);
await detectMetaInfo(applink);
const data = {
id: uuid.v4(),
@@ -111,7 +155,7 @@ async function add(applink) {
const query = 'INSERT INTO applinks (id, accessRestrictionJson, label, tagsJson, icon, upstreamUri) VALUES (?, ?, ?, ?, ?, ?)';
const args = [ data.id, data.accessRestrictionJson, data.label, data.tagsJson, data.icon, data.upstreamUri ];
const [error] = await safe(database.query(query, args));
[error] = await safe(database.query(query, args));
if (error) throw error;
return data.id;
@@ -121,7 +165,7 @@ async function get(applinkId) {
assert.strictEqual(typeof applinkId, 'string');
const result = await database.query(`SELECT ${APPLINKS_FIELDS} FROM applinks WHERE id = ?`, [ applinkId ]);
if (result.length === 0) throw new BoxError(BoxError.NOT_FOUND, 'Applink not found');
if (result.length === 0) return null;
postProcess(result[0]);
@@ -133,19 +177,20 @@ async function update(applinkId, applink) {
assert.strictEqual(typeof applink, 'object');
assert.strictEqual(typeof applink.upstreamUri, 'string');
debug(`update: ${applinkId} ${applink.upstreamUri}`, applink);
debug(`update: ${applink.upstreamUri}`, applink);
if ('icon' in applink) {
if (applink.icon) {
if (!validator.isBase64(applink.icon)) throw new BoxError(BoxError.BAD_FIELD, 'icon is not base64');
applink.icon = Buffer.from(applink.icon, 'base64');
}
let error = validateUpstreamUri(applink.upstreamUri);
if (error) throw error;
if (applink.icon) {
if (!validator.isBase64(applink.icon)) throw new BoxError(BoxError.BAD_FIELD, 'icon is not base64');
applink.icon = Buffer.from(applink.icon, 'base64');
}
await amendIconAndLabel(applink);
await detectMetaInfo(applink);
const query = 'UPDATE applinks SET label=?, icon=?, upstreamUri=?, tagsJson=?, accessRestrictionJson=? WHERE id = ?';
const args = [ applink.label, applink.icon, applink.upstreamUri, applink.tags ? JSON.stringify(applink.tags) : null, applink.accessRestriction ? JSON.stringify(applink.accessRestriction) : null, applinkId ];
const args = [ applink.label, applink.icon || null, applink.upstreamUri, applink.tags ? JSON.stringify(applink.tags) : null, applink.accessRestriction ? JSON.stringify(applink.accessRestriction) : null, applinkId ];
const result = await database.query(query, args);
if (result.affectedRows !== 1) throw new BoxError(BoxError.NOT_FOUND, 'Applink not found');
@@ -154,9 +199,7 @@ async function update(applinkId, applink) {
async function remove(applinkId) {
assert.strictEqual(typeof applinkId, 'string');
debug(`remove: ${applinkId}`);
const result = await database.query(`DELETE FROM applinks WHERE id = ?`, [ applinkId ]);
const result = await database.query('DELETE FROM applinks WHERE id = ?', [ applinkId ]);
if (result.affectedRows !== 1) throw new BoxError(BoxError.NOT_FOUND, 'Applink not found');
}
@@ -164,6 +207,7 @@ async function getIcon(applinkId) {
assert.strictEqual(typeof applinkId, 'string');
const applink = await get(applinkId);
if (!applink) throw new BoxError(BoxError.NOT_FOUND, 'Applink not found');
return applink.icon;
}
+125 -101
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');
@@ -218,7 +230,6 @@ function validatePortBindings(portBindings, manifest) {
993, /* imaps */
995, /* pop3s */
2003, /* graphite (lo) */
2004, /* graphite (lo) */
2514, /* cloudron-syslog (lo) */
constants.PORT, /* app server (lo) */
constants.AUTHWALL_PORT, /* protected sites */
@@ -229,7 +240,6 @@ function validatePortBindings(portBindings, manifest) {
4190, /* managesieve */
5349, /* turn,stun TLS */
8000, /* ESXi monitoring */
8417, /* graphite (lo) */
];
const RESERVED_PORT_RANGES = [
@@ -461,8 +471,16 @@ function validateBackupFormat(format) {
function validateUpstreamUri(upstreamUri) {
assert.strictEqual(typeof upstreamUri, 'string');
if (!upstreamUri) return null;
if (upstreamUri.length > 256) return new BoxError(BoxError.BAD_FIELD, 'upstreamUri must be less than 256');
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}`);
if (uri.protocol !== 'http:' && uri.protocol !== 'https:') return new BoxError(BoxError.BAD_FIELD, 'upstreamUri has an unsupported scheme');
if (uri.search || uri.hash) return new BoxError(BoxError.BAD_FIELD, 'upstreamUri cannot have search or hash');
if (uri.pathname !== '/') return new BoxError(BoxError.BAD_FIELD, 'upstreamUri cannot have a path');
// we use the uri in a named location @wellknown-upstream. nginx does not support having paths in it
if (upstreamUri.endsWith('/')) return new BoxError(BoxError.BAD_FIELD, 'upstreamUri cannot have a path');
return null;
}
@@ -521,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 '(.*)'/);
@@ -539,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`);
}
}
@@ -578,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);
@@ -588,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');
@@ -738,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) {
@@ -750,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) {
@@ -763,7 +785,7 @@ function isAdmin(user) {
}
function isOperator(app, user) {
assert.strictEqual(typeof app, 'object');
assert.strictEqual(typeof app, 'object'); // IMPORTANT: can also be applink
assert.strictEqual(typeof user, 'object');
if (!app.operators) return isAdmin(user);
@@ -776,7 +798,7 @@ function isOperator(app, user) {
}
function canAccess(app, user) {
assert.strictEqual(typeof app, 'object');
assert.strictEqual(typeof app, 'object'); // IMPORTANT: can also be applink
assert.strictEqual(typeof user, 'object');
if (app.accessRestriction === null) return true;
@@ -1049,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';
@@ -1070,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;
@@ -1085,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;
@@ -1096,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);
@@ -1272,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('*')) {
@@ -1283,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() {
@@ -1341,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);
@@ -1372,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');
@@ -1407,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' });
@@ -1421,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 });
@@ -1776,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;
}
@@ -1784,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');
@@ -1838,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: {
@@ -1849,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));
@@ -2017,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
@@ -2052,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) {
@@ -2166,20 +2169,18 @@ async function importApp(app, data, auditSource) {
const appId = app.id;
// all fields are optional
data.remotePath = data.remotePath || null;
data.backupFormat = data.backupFormat || null;
data.backupConfig = data.backupConfig || null;
const { remotePath, backupFormat, backupConfig } = data;
let error = backupFormat ? validateBackupFormat(backupFormat) : null;
let error = checkAppState(app, exports.ISTATE_PENDING_IMPORT);
if (error) throw error;
error = checkAppState(app, exports.ISTATE_PENDING_IMPORT);
if (error) throw error;
let restoreConfig;
// TODO: make this smarter to do a read-only test and check if the file exists in the storage backend
if (backupConfig) {
if (data.remotePath) { // if not provided, we import in-place
error = validateBackupFormat(backupFormat);
if (error) throw error;
// TODO: make this smarter to do a read-only test and check if the file exists in the storage backend
if (mounts.isManagedProvider(backupConfig.provider)) {
error = mounts.validateMountOptions(backupConfig.provider, backupConfig.mountOptions);
if (error) throw error;
@@ -2195,18 +2196,18 @@ async function importApp(app, data, auditSource) {
}
error = await backups.testProviderConfig(backupConfig);
if (error) throw error;
}
if (backupConfig) {
if ('password' in backupConfig) {
backupConfig.encryption = backups.generateEncryptionKeysSync(backupConfig.password);
delete backupConfig.password;
} else {
backupConfig.encryption = null;
}
}
const restoreConfig = { remotePath, backupFormat, backupConfig };
restoreConfig = { remotePath, backupFormat, backupConfig };
} else {
restoreConfig = { remotePath: null };
}
const task = {
args: {
@@ -2285,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);
@@ -2330,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' });
@@ -2344,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 });
@@ -2471,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);
}
@@ -2613,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');
+2 -2
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));
+1 -31
View File
@@ -17,10 +17,9 @@ const apps = require('./apps.js'),
AuditSource = require('./auditsource.js'),
backuptask = require('./backuptask.js'),
BoxError = require('./boxerror.js'),
collectd = require('./collectd.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'),
@@ -45,10 +44,6 @@ const MV_VOLUME_CMD = path.join(__dirname, 'scripts/mvvolume.sh'),
LOGROTATE_CONFIG_EJS = fs.readFileSync(__dirname + '/logrotate.ejs', { encoding: 'utf8' }),
CONFIGURE_LOGROTATE_CMD = path.join(__dirname, 'scripts/configurelogrotate.sh');
// https://rootlesscontaine.rs/getting-started/common/cgroup2/#checking-whether-cgroup-v2-is-already-enabled
const CGROUP_VERSION = fs.existsSync('/sys/fs/cgroup/cgroup.controllers') ? '2' : '1';
const COLLECTD_CONFIG_EJS = fs.readFileSync(`${__dirname}/collectd/app_cgroup_v${CGROUP_VERSION}.ejs`, { encoding: 'utf8' });
function makeTaskError(error, app) {
assert(error instanceof BoxError);
assert.strictEqual(typeof app, 'object');
@@ -98,7 +93,6 @@ async function createContainer(app) {
// re-generate configs that rely on container id
await addLogrotateConfig(app);
await addCollectdProfile(app);
}
async function deleteContainers(app, options) {
@@ -108,7 +102,6 @@ async function deleteContainers(app, options) {
debug('deleteContainer: deleting app containers (app, scheduler)');
// remove configs that rely on container id
await removeCollectdProfile(app);
await removeLogrotateConfig(app);
await docker.stopContainers(app.id);
await docker.deleteContainers(app.id, options);
@@ -161,20 +154,6 @@ async function deleteAppDir(app, options) {
}
}
async function addCollectdProfile(app) {
assert.strictEqual(typeof app, 'object');
const appDataDir = await apps.getStorageDir(app);
const collectdConf = ejs.render(COLLECTD_CONFIG_EJS, { appId: app.id, containerId: app.containerId, appDataDir });
await collectd.addProfile(app.id, collectdConf);
}
async function removeCollectdProfile(app) {
assert.strictEqual(typeof app, 'object');
await collectd.removeProfile(app.id);
}
async function addLogrotateConfig(app) {
assert.strictEqual(typeof app, 'object');
@@ -690,9 +669,6 @@ async function start(app, args, progressCallback) {
await docker.startContainer(app.id);
}
await progressCallback({ percent: 60, message: 'Adding collectd profile' });
await addCollectdProfile(app);
// stopped apps do not renew certs. currently, we don't do DNS to not overwrite existing user settings
await progressCallback({ percent: 80, message: 'Configuring reverse proxy' });
await reverseProxy.configureApp(app, AuditSource.APPTASK);
@@ -713,9 +689,6 @@ async function stop(app, args, progressCallback) {
await progressCallback({ percent: 50, message: 'Stopping app services' });
await services.stopAppServices(app);
await progressCallback({ percent: 80, message: 'Removing collectd profile' });
await removeCollectdProfile(app);
await progressCallback({ percent: 100, message: 'Done' });
await updateApp(app, { installationState: apps.ISTATE_INSTALLED, error: null, health: null });
}
@@ -733,9 +706,6 @@ async function restart(app, args, progressCallback) {
await docker.restartContainer(app.id);
}
await progressCallback({ percent: 60, message: 'Adding collectd profile' });
await addCollectdProfile(app);
// stopped apps do not renew certs. currently, we don't do DNS to not overwrite existing user settings
await progressCallback({ percent: 80, message: 'Configuring reverse proxy' });
await reverseProxy.configureApp(app, AuditSource.APPTASK);
+3 -7
View File
@@ -15,7 +15,6 @@ const apps = require('./apps.js'),
constants = require('./constants.js'),
debug = require('debug')('box:backupcleaner'),
moment = require('moment'),
mounts = require('./mounts.js'),
path = require('path'),
paths = require('./paths.js'),
safe = require('safetydance'),
@@ -272,12 +271,9 @@ async function run(progressCallback) {
const backupConfig = await settings.getBackupConfig();
if (mounts.isManagedProvider(backupConfig.provider) || backupConfig.provider === 'mountpoint') {
const hostPath = mounts.isManagedProvider(backupConfig.provider) ? paths.MANAGED_BACKUP_MOUNT_DIR : backupConfig.mountPoint;
const status = await mounts.getStatus(backupConfig.provider, hostPath); // { state, message }
debug(`clean: mount point status is ${JSON.stringify(status)}`);
if (status.state !== 'active') throw new BoxError(BoxError.MOUNT_ERROR, `Backup endpoint is not mounted: ${status.message}`);
}
const status = await storage.api(backupConfig.provider).getBackupProviderStatus(backupConfig);
debug(`clean: 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 (backupConfig.retentionPolicy.keepWithinSecs < 0) {
debug('cleanup: keeping all backups');
+1 -1
View File
@@ -27,7 +27,7 @@ function getBackupFilePath(backupConfig, remotePath) {
assert.strictEqual(typeof backupConfig, 'object');
assert.strictEqual(typeof remotePath, 'string');
const rootPath = storage.api(backupConfig.provider).getRootPath(backupConfig);
const rootPath = storage.api(backupConfig.provider).getBackupRootPath(backupConfig);
return path.join(rootPath, remotePath);
}
+6 -4
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');
@@ -23,7 +23,7 @@ function getBackupFilePath(backupConfig, remotePath) {
assert.strictEqual(typeof backupConfig, 'object');
assert.strictEqual(typeof remotePath, 'string');
const rootPath = storage.api(backupConfig.provider).getRootPath(backupConfig);
const rootPath = storage.api(backupConfig.provider).getBackupRootPath(backupConfig);
const fileType = backupConfig.encryption ? '.tar.gz.enc' : '.tar.gz';
return path.join(rootPath, remotePath + fileType);
@@ -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
+2 -19
View File
@@ -18,8 +18,6 @@ exports = module.exports = {
injectPrivateFields,
removePrivateFields,
configureCollectd,
generateEncryptionKeysSync,
getSnapshotInfo,
@@ -44,15 +42,12 @@ exports = module.exports = {
const assert = require('assert'),
BoxError = require('./boxerror.js'),
collectd = require('./collectd.js'),
constants = require('./constants.js'),
CronJob = require('cron').CronJob,
crypto = require('crypto'),
database = require('./database.js'),
debug = require('debug')('box:backups'),
ejs = require('ejs'),
eventlog = require('./eventlog.js'),
fs = require('fs'),
hat = require('./hat.js'),
locker = require('./locker.js'),
path = require('path'),
@@ -62,8 +57,6 @@ const assert = require('assert'),
storage = require('./storage.js'),
tasks = require('./tasks.js');
const COLLECTD_CONFIG_EJS = fs.readFileSync(__dirname + '/collectd/cloudron-backup.ejs', { encoding: 'utf8' });
const BACKUPS_FIELDS = [ 'id', 'remotePath', 'label', 'identifier', 'creationTime', 'packageVersion', 'type', 'dependsOnJson', 'state', 'manifestJson', 'format', 'preserveSecs', 'encryptionVersion' ];
function postProcess(result) {
@@ -127,7 +120,8 @@ async function add(data) {
const creationTime = data.creationTime || new Date(); // allow tests to set the time
const manifestJson = JSON.stringify(data.manifest);
const id = `${data.type}_${data.identifier}_v${data.packageVersion}_${hat(256)}`; // id is used by the UI to derive dependent packages. making this a UUID will require a lot of db querying
const prefixId = data.type === exports.BACKUP_TYPE_APP ? `${data.type}_${data.identifier}` : data.type; // type and identifier are same for other types
const id = `${prefixId}_v${data.packageVersion}_${hat(256)}`; // id is used by the UI to derive dependent packages. making this a UUID will require a lot of db querying
const [error] = await safe(database.query('INSERT INTO backups (id, remotePath, identifier, encryptionVersion, packageVersion, type, creationTime, state, dependsOnJson, manifestJson, format, preserveSecs) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
[ id, data.remotePath, data.identifier, data.encryptionVersion, data.packageVersion, data.type, creationTime, data.state, JSON.stringify(data.dependsOn), manifestJson, data.format, data.preserveSecs ]));
@@ -319,17 +313,6 @@ async function startCleanupTask(auditSource) {
return taskId;
}
async function configureCollectd(backupConfig) {
assert.strictEqual(typeof backupConfig, 'object');
if (backupConfig.provider === 'filesystem') {
const collectdConf = ejs.render(COLLECTD_CONFIG_EJS, { backupDir: backupConfig.backupFolder });
await collectd.addProfile('cloudron-backup', collectdConf);
} else {
await collectd.removeProfile('cloudron-backup');
}
}
async function testConfig(backupConfig) {
assert.strictEqual(typeof backupConfig, 'object');
+37 -1
View File
@@ -47,6 +47,41 @@ function canBackupApp(app) {
app.installationState === apps.ISTATE_PENDING_UPDATE; // called from apptask
}
// binary units (non SI) 1024 based
function prettyBytes(bytes) {
assert.strictEqual(typeof bytes, 'number');
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];
}
async function checkPreconditions(backupConfig, dataLayout) {
assert.strictEqual(typeof backupConfig, 'object');
assert(dataLayout instanceof DataLayout, 'dataLayout must be a 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 active: ${status.message}`);
// check availabe size. this requires root for df to work
const df = await storage.api(backupConfig.provider).getAvailableSize(backupConfig);
let used = 0;
for (const localPath of dataLayout.localPaths()) {
debug(`checkPreconditions: getting disk usage of ${localPath}`);
const result = safe.child_process.execSync(`du -Dsb ${localPath}`, { encoding: 'utf8' });
if (!result) throw new BoxError(BoxError.FS_ERROR, `du error: ${safe.error.message}`);
used += parseInt(result, 10);
}
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)}`);
}
// this function is called via backupupload (since it needs root to traverse app's directory)
async function upload(remotePath, format, dataLayoutString, progressCallback) {
assert.strictEqual(typeof remotePath, 'string');
@@ -58,7 +93,8 @@ async function upload(remotePath, format, dataLayoutString, progressCallback) {
const dataLayout = DataLayout.fromString(dataLayoutString);
const backupConfig = await settings.getBackupConfig();
await storage.api(backupConfig.provider).checkPreconditions(backupConfig, dataLayout);
await checkPreconditions(backupConfig, dataLayout);
await backupFormat.api(format).upload(backupConfig, remotePath, dataLayout, progressCallback);
}
+8
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);
}
+21 -41
View File
@@ -19,6 +19,8 @@ exports = module.exports = {
renewCerts,
syncDnsRecords,
updateDiskUsage,
runSystemChecks
};
@@ -26,7 +28,6 @@ const apps = require('./apps.js'),
appstore = require('./appstore.js'),
assert = require('assert'),
AuditSource = require('./auditsource.js'),
backups = require('./backups.js'),
BoxError = require('./boxerror.js'),
branding = require('./branding.js'),
constants = require('./constants.js'),
@@ -35,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'),
@@ -49,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');
@@ -108,18 +108,11 @@ async function runStartupTasks() {
// stop all the systemd tasks
tasks.push(platform.stopAllTasks);
// this configures collectd to collect backup storage metrics if filesystem is used. This is also triggerd when the settings change with the rest api
tasks.push(async function () {
const backupConfig = await settings.getBackupConfig();
await backups.configureCollectd(backupConfig);
});
// always generate webadmin config since we have no versioning mechanism for the ejs
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 () {
@@ -166,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,
};
}
@@ -235,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) {
@@ -264,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');
@@ -286,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);
@@ -328,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();
@@ -341,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) {
@@ -351,3 +325,9 @@ async function syncDnsRecords(options) {
tasks.startTask(taskId, {});
return taskId;
}
async function updateDiskUsage() {
const taskId = await tasks.add(tasks.TASK_UPDATE_DISK_USAGE, []);
tasks.startTask(taskId, {});
return taskId;
}
-43
View File
@@ -1,43 +0,0 @@
'use strict';
exports = module.exports = {
addProfile,
removeProfile
};
const assert = require('assert'),
BoxError = require('./boxerror.js'),
debug = require('debug')('collectd'),
path = require('path'),
paths = require('./paths.js'),
safe = require('safetydance'),
shell = require('./shell.js');
const CONFIGURE_COLLECTD_CMD = path.join(__dirname, 'scripts/configurecollectd.sh');
async function addProfile(name, profile) {
assert.strictEqual(typeof name, 'string');
assert.strictEqual(typeof profile, 'string');
const configFilePath = path.join(paths.COLLECTD_APPCONFIG_DIR, `${name}.conf`);
// skip restarting collectd if the profile already exists with the same contents
const currentProfile = safe.fs.readFileSync(configFilePath, 'utf8') || '';
if (currentProfile === profile) return;
if (!safe.fs.writeFileSync(configFilePath, profile)) throw new BoxError(BoxError.FS_ERROR, `Error writing collectd config: ${safe.error.message}`);
const [error] = await safe(shell.promises.sudo('addCollectdProfile', [ CONFIGURE_COLLECTD_CMD, 'add', name ], {}));
if (error) throw new BoxError(BoxError.COLLECTD_ERROR, 'Could not add collectd config');
}
async function removeProfile(name) {
assert.strictEqual(typeof name, 'string');
if (!safe.fs.unlinkSync(path.join(paths.COLLECTD_APPCONFIG_DIR, `${name}.conf`))) {
if (safe.error.code !== 'ENOENT') debug('Error removing collectd profile', safe.error);
}
const [error] = await safe(shell.promises.sudo('removeCollectdProfile', [ CONFIGURE_COLLECTD_CMD, 'remove', name ], {}));
if (error) throw new BoxError(BoxError.COLLECTD_ERROR, 'Could not remove collectd config');
}
-22
View File
@@ -1,22 +0,0 @@
LoadPlugin "table"
<Plugin table>
<Table "/sys/fs/cgroup/memory/docker/<%= containerId %>/memory.memsw.usage_in_bytes">
Instance "<%= appId %>-memory"
Separator "\\n"
<Result>
Type gauge
InstancePrefix "memsw_usage_in_bytes"
ValuesFrom 0
</Result>
</Table>
</Plugin>
<Plugin python>
<Module du>
<Path>
Instance "<%= appId %>"
Dir "<%= appDataDir %>"
</Path>
</Module>
</Plugin>
-32
View File
@@ -1,32 +0,0 @@
LoadPlugin "table"
<Plugin table>
<Table "/sys/fs/cgroup/docker/<%= containerId %>/memory.current">
Instance "<%= appId %>-memory"
Separator " \\n"
<Result>
Type gauge
InstancePrefix "memory_current"
ValuesFrom 0
</Result>
</Table>
<Table "/sys/fs/cgroup/docker/<%= containerId %>/memory.swap.current">
Instance "<%= appId %>-memory"
Separator "\\n"
<Result>
Type gauge
InstancePrefix "memory_swap_current"
ValuesFrom 0
</Result>
</Table>
</Plugin>
<Plugin python>
<Module du>
<Path>
Instance "<%= appId %>"
Dir "<%= appDataDir %>"
</Path>
</Module>
</Plugin>
+1 -1
View File
@@ -74,6 +74,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'
};
+9 -2
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 }),
@@ -122,7 +129,7 @@ async function startJobs() {
gJobs.cleanupEventlog = new CronJob({
cronTime: '00 */30 * * * *', // every 30 minutes
onTick: async () => await safe(eventlog.cleanup({ creationTime: new Date(Date.now() - 60 * 60 * 24 * 10 * 1000) }), { debug }), // 10 days ago
onTick: async () => await safe(eventlog.cleanup({ creationTime: new Date(Date.now() - 60 * 60 * 24 * 60 * 1000) }), { debug }), // 60 days ago
start: true
});
+46
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]);
}
+42 -12
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 () {};
@@ -143,6 +142,26 @@ async function authorize(req, res, next) {
return next();
}
// https://ldapwiki.com/wiki/RootDSE / RFC 4512 - ldapsearch -x -h "${CLOUDRON_LDAP_SERVER}" -p "${CLOUDRON_LDAP_PORT}" -b "" -s base
// ldapjs seems to call this handler for everything when search === ''
async function maybeRootDSE(req, res, next) {
debug(`maybeRootDSE: requested with scope:${req.scope} dn:${req.dn.toString()}`);
if (req.scope !== 'base') return next(new ldap.NoSuchObjectError()); // per the spec, rootDSE search require base scope
if (!req.dn || req.dn.toString() !== '') return next(new ldap.NoSuchObjectError());
res.send({
dn: '',
attributes: {
objectclass: [ 'RootDSE', 'top', 'OpenLDAProotDSE' ],
supportedLDAPVersion: '3',
vendorName: 'Cloudron LDAP',
vendorVersion: '1.0.0'
}
});
res.end();
}
async function userSearch(req, res, next) {
debug('user search: dn %s, scope %s, filter %s (from %s)', req.dn.toString(), req.scope, req.filter.toString(), req.connection.ldap.id);
@@ -180,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`; })
}
};
@@ -276,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
@@ -289,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
});
@@ -329,6 +345,8 @@ async function start() {
res.end();
});
gServer.search('', maybeRootDSE); // when '', it seems the callback is called for everything else
// just log that an attempt was made to unknown route, this helps a lot during app packaging
gServer.use(function(req, res, next) {
debug('not handled: dn %s, scope %s, filter %s (from %s)', req.dn ? req.dn.toString() : '-', req.scope, req.filter ? req.filter.toString() : '-', req.connection.ldap.id);
@@ -347,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();
}
+6 -6
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,
+4 -4
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);
+8 -1
View File
@@ -200,11 +200,17 @@ 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);
}
async function verifyDomainConfig(domainObject) {
assert.strictEqual(typeof domainObject, 'object');
@@ -212,6 +218,7 @@ async function verifyDomainConfig(domainObject) {
zoneName = domainObject.zoneName;
if (!domainConfig.token || typeof domainConfig.token !== 'string') throw new BoxError(BoxError.BAD_FIELD, 'token must be a non-empty string');
if (!isASCII(domainConfig.token)) throw new BoxError(BoxError.BAD_FIELD, 'token contains invalid characters');
const ip = '127.0.0.1';
+1 -1
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);
}
+4 -4
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);
}
+1 -1
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);
}
+1 -1
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);
}
+1 -1
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);
}
+1 -1
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);
}
+1 -1
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);
}
+1 -1
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);
}
+1 -1
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);
}
+4 -4
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);
}
+1 -1
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);
}
+2 -2
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`);
+22 -16
View File
@@ -8,6 +8,7 @@ exports = module.exports = {
ping,
info,
df,
downloadImage,
createContainer,
startContainer,
@@ -38,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'),
@@ -204,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
});
@@ -314,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
@@ -322,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,
@@ -342,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,
@@ -630,6 +630,12 @@ async function info() {
return result;
}
async function df() {
const [error, result] = await safe(gConnection.df());
if (error) throw new BoxError(BoxError.DOCKER_ERROR, 'Error connecting to docker');
return result;
}
async function update(name, memory, memorySwap) {
assert.strictEqual(typeof name, 'string');
assert.strictEqual(typeof memory, 'number');
+15 -6
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;
}
+1 -1
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',
+73 -132
View File
@@ -2,79 +2,86 @@
exports = module.exports = {
getSystem,
getByApp
getContainerStats
};
const apps = require('./apps.js'),
assert = require('assert'),
BoxError = require('./boxerror.js'),
fs = require('fs'),
docker = require('./docker.js'),
os = require('os'),
safe = require('safetydance'),
superagent = require('superagent'),
system = require('./system.js');
services = require('./services.js'),
superagent = require('superagent');
// for testing locally: curl 'http://127.0.0.1:8417/graphite-web/render?format=json&from=-1min&target=absolute(collectd.localhost.du-docker.capacity-usage)'
// the datapoint is (value, timestamp) https://buildmedia.readthedocs.org/media/pdf/graphite/0.9.16/graphite.pdf
const GRAPHITE_RENDER_URL = 'http://127.0.0.1:8417/graphite-web/render';
// for testing locally: curl 'http://${graphite-ip}:8000/graphite-web/render?format=json&from=-1min&target=absolute(collectd.localhost.du-docker.capacity-usage)'
// the datapoint is (value, timestamp) https://graphite.readthedocs.io/en/latest/
async function getGraphiteUrl() {
const [error, result] = await safe(docker.inspect('graphite'));
if (error && error.reason === BoxError.NOT_FOUND) return { status: exports.SERVICE_STATUS_STOPPED };
if (error) throw error;
// https://rootlesscontaine.rs/getting-started/common/cgroup2/#checking-whether-cgroup-v2-is-already-enabled
const CGROUP_VERSION = fs.existsSync('/sys/fs/cgroup/cgroup.controllers') ? '2' : '1';
const ip = safe.query(result, 'NetworkSettings.Networks.cloudron.IPAddress', null);
if (!ip) throw new BoxError(BoxError.INACTIVE, 'Error getting IP of graphite service');
async function getByApp(app, fromMinutes, noNullPoints) {
assert.strictEqual(typeof app, 'object');
return `http://${ip}:8000/graphite-web/render`;
}
async function getContainerStats(name, fromMinutes, noNullPoints) {
assert.strictEqual(typeof name, 'string');
assert.strictEqual(typeof fromMinutes, 'number');
assert.strictEqual(typeof noNullPoints, 'boolean');
const timeBucketSize = fromMinutes > (24 * 60) ? (6*60) : 5;
const graphiteUrl = await getGraphiteUrl();
const memoryQuery = {
target: null, // filled below
format: 'json',
from: `-${fromMinutes}min`,
until: 'now'
};
if (CGROUP_VERSION === '1') {
memoryQuery.target = `summarize(collectd.localhost.table-${app.id}-memory.gauge-memsw_usage_in_bytes, "${timeBucketSize}min", "avg")`;
} else {
memoryQuery.target = `summarize(sum(collectd.localhost.table-${app.id}-memory.gauge-memory_current, collectd.localhost.table-${app.id}-memory.gauge-memory_swap_current), "${timeBucketSize}min", "avg")`;
}
if (noNullPoints) memoryQuery.noNullPoints = true;
// 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")`,
// `summarize(collectd.localhost.docker-stats-${name}.gauge-mem-max, "${timeBucketSize}min", "avg")`,
`summarize(collectd.localhost.docker-stats-${name}.counter-blockio-read, "${timeBucketSize}min", "sum")`,
`summarize(collectd.localhost.docker-stats-${name}.counter-blockio-write, "${timeBucketSize}min", "sum")`,
`summarize(collectd.localhost.docker-stats-${name}.counter-network-read, "${timeBucketSize}min", "sum")`,
`summarize(collectd.localhost.docker-stats-${name}.counter-network-write, "${timeBucketSize}min", "sum")`,
`summarize(collectd.localhost.docker-stats-${name}.gauge-blockio-read, "${fromMinutes}min", "max")`,
`summarize(collectd.localhost.docker-stats-${name}.gauge-blockio-write, "${fromMinutes}min", "max")`,
`summarize(collectd.localhost.docker-stats-${name}.gauge-network-read, "${fromMinutes}min", "max")`,
`summarize(collectd.localhost.docker-stats-${name}.gauge-network-write, "${fromMinutes}min", "max")`,
];
const [memoryError, memoryResponse] = await safe(superagent.get(GRAPHITE_RENDER_URL)
.query(memoryQuery)
.timeout(30 * 1000)
.ok(() => true));
const results = [];
if (memoryError) throw new BoxError(BoxError.NETWORK_ERROR, memoryError.message);
if (memoryResponse.status !== 200) throw new BoxError(BoxError.EXTERNAL_ERROR, `Unknown error: ${memoryResponse.status} ${memoryResponse.text}`);
let diskDataPoints;
if (app.manifest.addons.localstorage) {
const diskQuery = {
target: `summarize(collectd.localhost.du-${app.id}.capacity-usage, "${timeBucketSize}min", "avg")`,
for (const target of targets) {
const query = {
target: target,
format: 'json',
from: `-${fromMinutes}min`,
until: 'now'
until: 'now',
noNullPoints: !!noNullPoints
};
if (noNullPoints) diskQuery.noNullPoints = true;
const [diskError, diskResponse] = await safe(superagent.get(GRAPHITE_RENDER_URL)
.query(diskQuery)
.timeout(30 * 1000)
.ok(() => true));
const [error, response] = await safe(superagent.get(graphiteUrl).query(query).timeout(30 * 1000).ok(() => true));
if (error) throw new BoxError(BoxError.NETWORK_ERROR, error.message);
if (response.status !== 200) throw new BoxError(BoxError.EXTERNAL_ERROR, `Unknown error with ${target}: ${response.status} ${response.text}`);
if (diskError) throw new BoxError(BoxError.NETWORK_ERROR, diskError.message);
if (diskResponse.status !== 200) throw new BoxError(BoxError.EXTERNAL_ERROR, `Unknown error: ${diskResponse.status} ${diskResponse.text}`);
// we may not have any datapoints
if (diskResponse.body.length === 0) diskDataPoints = [];
else diskDataPoints = diskResponse.body[0].datapoints;
} else {
diskDataPoints = [];
results.push(response.body[0] && response.body[0].datapoints ? response.body[0].datapoints : []);
}
// app proxy instances have no container and thus no datapoints
return { memory: memoryResponse.body[0] || { datapoints: [] }, disk: { datapoints: diskDataPoints } };
// results are datapoints[[value, ts], [value, ts], ...];
return {
cpu: results[0],
memory: results[1],
blockRead: results[2],
blockWrite: results[3],
networkRead: results[4],
networkWrite: results[5],
blockReadTotal: results[6][0] && results[6][0][0] ? results[6][0][0] : 0,
blockWriteTotal: results[7][0] && results[7][0][0] ? results[7][0][0] : 0,
networkReadTotal: results[8][0] && results[8][0][0] ? results[8][0][0] : 0,
networkWriteTotal: results[9][0] && results[9][0][0] ? results[9][0][0] : 0,
cpuCount: os.cpus().length
};
}
async function getSystem(fromMinutes, noNullPoints) {
@@ -82,9 +89,10 @@ async function getSystem(fromMinutes, noNullPoints) {
assert.strictEqual(typeof noNullPoints, 'boolean');
const timeBucketSize = fromMinutes > (24 * 60) ? (6*60) : 5;
const graphiteUrl = await getGraphiteUrl();
const cpuQuery = `summarize(sum(collectd.localhost.aggregation-cpu-average.cpu-system, collectd.localhost.aggregation-cpu-average.cpu-user), "${timeBucketSize}min", "avg")`;
const memoryQuery = `summarize(sum(collectd.localhost.memory.memory-used, collectd.localhost.swap.swap-used), "${timeBucketSize}min", "avg")`;
const cpuQuery = `summarize(sum(collectd.localhost.aggregation-cpu-sum.cpu-system, collectd.localhost.aggregation-cpu-sum.cpu-user), "${timeBucketSize}min", "avg")`;
const memoryQuery = `summarize(collectd.localhost.memory.memory-used, "${timeBucketSize}min", "avg")`;
const query = {
target: [ cpuQuery, memoryQuery ],
@@ -93,92 +101,25 @@ async function getSystem(fromMinutes, noNullPoints) {
until: 'now'
};
const [memCpuError, memCpuResponse] = await safe(superagent.get(GRAPHITE_RENDER_URL)
.query(query)
.timeout(30 * 1000)
.ok(() => true));
const [memCpuError, memCpuResponse] = await safe(superagent.get(graphiteUrl).query(query).timeout(30 * 1000).ok(() => true));
if (memCpuError) throw new BoxError(BoxError.NETWORK_ERROR, memCpuError.message);
if (memCpuResponse.status !== 200) throw new BoxError(BoxError.EXTERNAL_ERROR, `Unknown error: ${memCpuResponse.status} ${memCpuResponse.text}`);
const allApps = await apps.list();
const appResponses = {};
for (const app of allApps) {
appResponses[app.id] = await getByApp(app, fromMinutes, noNullPoints);
for (const app of await apps.list()) {
appResponses[app.id] = await getContainerStats(app.id, fromMinutes, noNullPoints);
}
const diskInfo = await system.getDisks();
// segregate locations into the correct disks based on 'filesystem'
diskInfo.disks.forEach(function (disk, index) {
disk.id = index;
disk.contains = [];
if (disk.filesystem === diskInfo.platformDataDisk) disk.contains.push({ type: 'standard', label: 'Platform data', id: 'platformdata', usage: 0 });
if (disk.filesystem === diskInfo.boxDataDisk) disk.contains.push({ type: 'standard', label: 'Box data', id: 'boxdata', usage: 0 });
if (disk.filesystem === diskInfo.dockerDataDisk) disk.contains.push({ type: 'standard', label: 'Docker images', id: 'docker', usage: 0 });
if (disk.filesystem === diskInfo.mailDataDisk) disk.contains.push({ type: 'standard', label: 'Email data', id: 'maildata', usage: 0 });
if (disk.filesystem === diskInfo.backupsDisk) disk.contains.push({ type: 'standard', label: 'Backup data', id: 'cloudron-backup', usage: 0 });
// attach appIds which reside on this disk
const apps = Object.keys(diskInfo.apps).filter(function (appId) { return diskInfo.apps[appId] === disk.filesystem; });
apps.forEach(function (appId) {
disk.contains.push({ type: 'app', id: appId, label: '', usage: 0 });
});
// attach volumeIds which reside on this disk
const volumes = Object.keys(diskInfo.volumes).filter(function (volumeId) { return diskInfo.volumes[volumeId] === disk.filesystem; });
volumes.forEach(function (volumeId) {
disk.contains.push({ type: 'volume', id: volumeId, label: '', usage: 0 });
});
});
for (const disk of diskInfo.disks) {
// /dev/sda1 -> sda1
// /dev/mapper/foo.com -> mapper_foo_com (see #348)
let diskName = disk.filesystem.slice(disk.filesystem.indexOf('/', 1) + 1);
diskName = diskName.replace(/\/|\./g, '_');
const target = [
`absolute(collectd.localhost.df-${diskName}.df_complex-free)`,
`absolute(collectd.localhost.df-${diskName}.df_complex-reserved)`, // reserved for root (default: 5%) tune2fs -l/m
`absolute(collectd.localhost.df-${diskName}.df_complex-used)`
];
const diskQuery = {
target: target,
format: 'json',
from: '-1day',
until: 'now'
};
const [diskError, diskResponse] = await safe(superagent.get(GRAPHITE_RENDER_URL).query(diskQuery).timeout(30 * 1000).ok(() => true));
if (diskError) throw new BoxError(BoxError.NETWORK_ERROR, diskError.message);
if (diskResponse.status !== 200) throw new BoxError(BoxError.EXTERNAL_ERROR, `Unknown error: ${diskResponse.status} ${diskResponse.text}`);
disk.size = diskResponse.body[2].datapoints[0][0] + diskResponse.body[1].datapoints[0][0] + diskResponse.body[0].datapoints[0][0];
disk.free = diskResponse.body[0].datapoints[0][0];
disk.occupied = diskResponse.body[2].datapoints[0][0];
for (const content of disk.contains) {
const query = {
target: `absolute(collectd.localhost.du-${content.id}.capacity-usage)`,
format: 'json',
from: '-1day',
until: 'now'
};
const [error, response] = await safe(superagent.get(GRAPHITE_RENDER_URL).query(query).timeout(30 * 1000).ok(() => true));
if (error) throw new BoxError(BoxError.NETWORK_ERROR, error.message);
if (response.status !== 200) throw new BoxError(BoxError.EXTERNAL_ERROR, `Unknown error: ${response.status} ${response.text}`);
// we may not have any datapoints
if (response.body.length === 0) content.usage = null;
else content.usage = response.body[0].datapoints[0][0];
console.log(content)
}
const serviceResponses = {};
for (const serviceId of await services.listServices()) {
serviceResponses[serviceId] = await getContainerStats(serviceId, fromMinutes, noNullPoints);
}
return { cpu: memCpuResponse.body[0], memory: memCpuResponse.body[1], apps: appResponses, disks: diskInfo.disks };
return {
cpu: memCpuResponse.body[0] && memCpuResponse.body[0].datapoints ? memCpuResponse.body[0].datapoints : [],
memory: memCpuResponse.body[1] && memCpuResponse.body[1].datapoints ? memCpuResponse.body[1].datapoints : [],
apps: appResponses,
services: serviceResponses,
cpuCount: os.cpus().length
};
}
+3 -3
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);
+7 -7
View File
@@ -6,7 +6,7 @@
exports = module.exports = {
// a version change recreates all containers with latest docker config
'version': '49.1.0',
'version': '49.4.0',
'baseImages': [
{ repo: 'cloudron/base', tag: 'cloudron/base:3.2.0@sha256:ba1d566164a67c266782545ea9809dc611c4152e27686fd14060332dd88263ea' }
@@ -16,12 +16,12 @@ exports = module.exports = {
// docker inspect --format='{{index .RepoDigests 0}}' $IMAGE to get the sha256
'images': {
'turn': { repo: 'cloudron/turn', tag: 'cloudron/turn:1.4.0@sha256:45817f1631992391d585f171498d257487d872480fd5646723a2b956cc4ef15d' },
'mysql': { repo: 'cloudron/mysql', tag: 'cloudron/mysql:3.2.1@sha256:75cef64ba4917ba9ec68bc0c9d9ba3a9eeae00a70173cd6d81cc6118038737d9' },
'postgresql': { repo: 'cloudron/postgresql', tag: 'cloudron/postgresql:4.3.1@sha256:b0c564d097b765d4a639330843e2e813d2c87fc8ed34b7df7550bf2c6df0012c' },
'mongodb': { repo: 'cloudron/mongodb', tag: 'cloudron/mongodb:4.2.1@sha256:f7f689beea07b1c6a9503a48f6fb38ef66e5b22f59fc585a92842a6578b33d46' },
'redis': { repo: 'cloudron/redis', tag: 'cloudron/redis:3.3.0@sha256:89c4e8083631b6d16b5d630d9b27f8ecf301c62f81219d77bd5948a1f4a4375c' },
'mail': { repo: 'cloudron/mail', tag: 'cloudron/mail:3.7.0@sha256:f9074d741bc1ecf8c798015d038933abb76fd2132f52702b19dfe5d34d644014' },
'graphite': { repo: 'cloudron/graphite', tag: 'cloudron/graphite:3.1.0@sha256:30ec3a01964a1e01396acf265183997c3e17fb07eac1a82b979292cc7719ff4b' },
'mysql': { repo: 'cloudron/mysql', tag: 'cloudron/mysql:3.2.2@sha256:8648ca5a16fcdec72799b919c5f62419fd19e922e3d98d02896b921ae6127ef4' },
'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.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' }
}
};
+3 -1
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');
}
+1 -1
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
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;
+8 -5
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
+30 -13
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;
}
@@ -516,7 +518,11 @@ const RBL_LIST = [
// this function currently only looks for black lists based on IP. TODO: also look up by domain
async function checkRblStatus(domain) {
const ip = await sysinfo.getServerIPv4();
const [error, ip] = await safe(sysinfo.getServerIPv4());
if (error) {
debug(`checkRblStatus: unable to determine server IPv4: ${error.message}`);
return { status: false, ip: null, servers: [] };
}
const flippedIp = ip.split('.').reverse().join('.');
@@ -678,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';
@@ -707,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}`);
@@ -789,6 +796,7 @@ async function restartMail() {
async function startMail(existingInfra) {
assert.strictEqual(typeof existingInfra, 'object');
debug('startMail: starting');
await restartMail();
}
@@ -800,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();
}
@@ -978,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);
@@ -995,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();
}
@@ -1002,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();
}
+3 -3
View File
@@ -184,7 +184,7 @@ async function getStatus(mountType, hostPath) {
if (end !== -1) message = lines.slice(start, end+1).map(line => line['MESSAGE']).join('\n');
}
if (!message) message = `Could not determine failure reason. ${safe.error ? safe.error.message : ''}`;
if (!message) message = `Could not determine mount failure reason. ${safe.error ? safe.error.message : ''}`;
} else {
message = 'Mounted';
}
@@ -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;
+14 -4
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;
@@ -176,7 +178,11 @@ server {
<% } else if ( endpoint === 'app' ) { %>
proxy_pass http://<%= ip %>:<%= port %>;
<% } else if ( endpoint === 'external' ) { %>
proxy_pass <%= upstreamUri %>;
# without a variable, nginx will not start if upstream is down or
resolver 127.0.0.1 valid=30s;
set $upstream <%= upstreamUri %>;
proxy_ssl_verify off;
proxy_pass $upstream;
<% } else if ( endpoint === 'redirect' ) { %>
return 302 https://<%= redirectTo %>$request_uri;
<% } %>
@@ -243,10 +249,10 @@ server {
client_max_body_size 0;
}
# graphite paths (uncomment block below and visit /graphite-web/dashboard)
# graphite paths (uncomment block below and visit /graphite-web/)
# remember to comment out the CSP policy as well to access the graphite dashboard
# location ~ ^/graphite-web/ {
# proxy_pass http://127.0.0.1:8417;
# proxy_pass http://172.18.0.6:8000;
# client_max_body_size 1m;
# }
@@ -330,7 +336,11 @@ server {
}
<% } else if ( endpoint === 'external' ) { %>
location / {
proxy_pass <%= upstreamUri %>;
# without a variable, nginx will not start if upstream is down or unavailable
resolver 127.0.0.1 valid=30s;
set $upstream <%= upstreamUri %>;
proxy_ssl_verify off;
proxy_pass $upstream;
}
<% } else if ( endpoint === 'ip' ) { %>
location /notfound.html {
+6 -1
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);
};
+2 -1
View File
@@ -31,7 +31,6 @@ exports = module.exports = {
ACME_CHALLENGES_DIR: path.join(baseDir(), 'platformdata/acme'),
ADDON_CONFIG_DIR: path.join(baseDir(), 'platformdata/addons'),
MAIL_CONFIG_DIR: path.join(baseDir(), 'platformdata/addons/mail'),
COLLECTD_APPCONFIG_DIR: path.join(baseDir(), 'platformdata/collectd/collectd.conf.d'),
LOGROTATE_CONFIG_DIR: path.join(baseDir(), 'platformdata/logrotate.d'),
NGINX_CONFIG_DIR: path.join(baseDir(), 'platformdata/nginx'),
NGINX_APPCONFIG_DIR: path.join(baseDir(), 'platformdata/nginx/applications'),
@@ -39,6 +38,7 @@ exports = module.exports = {
BACKUP_INFO_DIR: path.join(baseDir(), 'platformdata/backup'),
UPDATE_DIR: path.join(baseDir(), 'platformdata/update'),
UPDATE_CHECKER_FILE: path.join(baseDir(), 'platformdata/update/updatechecker.json'),
DISK_USAGE_FILE: path.join(baseDir(), 'platformdata/diskusage.json'),
SNAPSHOT_INFO_FILE: path.join(baseDir(), 'platformdata/backup/snapshot-info.json'),
DYNDNS_INFO_FILE: path.join(baseDir(), 'platformdata/dyndns-info.json'),
DHPARAMS_FILE: path.join(baseDir(), 'platformdata/dhparams.pem'),
@@ -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'),
+19 -10
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
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;
+311 -357
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,37 +64,17 @@ 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 getExpiryDateSync(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;
// 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
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 ]);
const result = safe.child_process.spawnSync('/usr/bin/openssl', [ 'x509', '-enddate', '-noout' ], { input: cert });
if (!result) return null; // some error
const notAfter = result.stdout.toString('utf8').trim().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: notAfter=${notAfter} daysLeft=${daysLeft}`);
return notAfterDate;
}
@@ -110,14 +91,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 +104,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 +122,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 +134,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 +157,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 +214,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 +227,202 @@ 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) {
assert.strictEqual(typeof cert, 'string');
const notAfter = getExpiryDateSync(cert);
const isExpiring = (notAfter - new Date()) <= (30 * 24 * 60 * 60 * 1000); // expiring in a month
debug(`needsRenewal: ${isExpiring}`);
return isExpiring;
}
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`);
if (location.certificate) return location.certificate;
if (domainObject.tlsConfig.provider === 'fallback') return domainObject.fallbackCertificate;
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;
return { key, cert };
}
async function getMailCertificate() {
return await getCertificate({ domain: settings.mailDomain(), fqdn: settings.mailFqdn(), certificate: null, type: apps.LOCATION_TYPE_MAIL });
}
async function getDirectoryServerCertificate() {
return await getCertificate({ domain: settings.dashboardDomain(), fqdn: settings.dashboardFqdn(), certificate: null, type: apps.LOCATION_TYPE_DIRECTORY_SERVER });
}
// write if contents mismatch (thus preserving mtime)
function writeFileSync(filePath, data) {
assert.strictEqual(typeof filePath, 'string');
assert.strictEqual(typeof data, 'string');
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 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 });
}
}
return { certName, certFilePath, keyFilePath, csrFilePath, acmeChallengesDir };
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);
}
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
// 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 userPath = getUserCertificatePathSync(fqdn); // user cert always wins
if (fs.existsSync(userPath.certFilePath) && fs.existsSync(userPath.keyFilePath)) return userPath;
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`);
if (domainObject.tlsConfig.provider === 'fallback') return getFallbackCertificatePathSync(domain);
writeFileSync(certFilePath, location.certificate.cert);
writeFileSync(keyFilePath, location.certificate.key);
const acmePath = getAcmeCertificatePathSync(fqdn, domainObject);
if (fs.existsSync(acmePath.certFilePath) && fs.existsSync(acmePath.keyFilePath)) return acmePath;
return getFallbackCertificatePathSync(domain);
}
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 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 updateCertBlobs(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 = 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);
}
async function ensureCertificate(subdomain, domain, auditSource) {
assert.strictEqual(typeof subdomain, 'string');
assert.strictEqual(typeof domain, 'string');
assert.strictEqual(typeof auditSource, 'object');
const domainObject = await domains.get(domain);
const userCertificatePath = await syncUserCertificate(subdomain, domainObject);
if (userCertificatePath) return { certificatePath: userCertificatePath, renewed: false };
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, auditSource) {
assert.strictEqual(typeof location, '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)) {
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 +432,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 +480,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 +501,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 +511,111 @@ 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 ensureCertificates(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);
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, auditSource);
}
}
async function cleanupCerts(auditSource, progressCallback) {
async function cleanupCerts(locations, auditSource, progressCallback) {
assert(Array.isArray(locations));
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);
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`))[0];
if (certNamesInUse.has(certName)) continue;
const cert = await blobs.getString(certId);
const notAfter = getExpiryDateSync(cert);
if (!notAfter) continue; // some error
if (now - notAfter >= (60 * 60 * 24 * 30 * 6 * 1000)) { // expired 6 months ago
const fqdn = certFilename.replace(/\.cert$/, '');
progressCallback({ message: `deleting certs of ${fqdn}` });
if (now - notAfter >= (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 +625,45 @@ 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));
}
await ensureCertificates(locations, auditSource, progressCallback);
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 {
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 +705,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');
}
+2
View File
@@ -47,6 +47,7 @@ async function get(req, res, next) {
const [error, result] = await safe(applinks.get(req.params.id));
if (error) return next(BoxError.toHttpError(error));
if (!result) return next(new HttpError(404, 'Applink not found'));
// we have a separate route for this
delete result.icon;
@@ -84,6 +85,7 @@ async function getIcon(req, res, next) {
const [error, icon] = await safe(applinks.getIcon(req.params.id, { original: req.query.original }));
if (error) return next(BoxError.toHttpError(error));
if (!icon) return next(new HttpError(404, 'no such icon'));
res.send(icon);
}
+34 -6
View File
@@ -56,8 +56,10 @@ exports = module.exports = {
downloadFile,
updateBackup,
downloadBackup,
getLimits,
getGraphs,
load
};
@@ -68,6 +70,7 @@ const apps = require('../apps.js'),
BoxError = require('../boxerror.js'),
constants = require('../constants.js'),
debug = require('debug')('box:routes/apps'),
graphs = require('../graphs.js'),
HttpError = require('connect-lastmile').HttpError,
HttpSuccess = require('connect-lastmile').HttpSuccess,
safe = require('safetydance'),
@@ -173,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'));
@@ -497,14 +498,14 @@ async function importApp(req, res, next) {
const data = req.body;
if ('remotePath' in data) { // if not provided, we import in-place
if (typeof data.remotePath !== 'string') return next(new HttpError(400, 'remotePath must be string'));
if (typeof data.remotePath !== 'string' || !data.remotePath) return next(new HttpError(400, 'remotePath must be non-empty string'));
if (typeof data.backupFormat !== 'string') return next(new HttpError(400, 'backupFormat must be string'));
if ('backupConfig' in data && typeof data.backupConfig !== 'object') return next(new HttpError(400, 'backupConfig must be an object'));
const backupConfig = req.body.backupConfig;
if (req.body.backupConfig) {
if (backupConfig) {
if (typeof backupConfig.provider !== 'string') return next(new HttpError(400, 'provider is required'));
if ('password' in backupConfig && typeof backupConfig.password !== 'string') return next(new HttpError(400, 'password must be a string'));
if ('encryptedFilenames' in backupConfig && typeof backupConfig.encryptedFilenames !== 'boolean') return next(new HttpError(400, 'encryptedFilenames must be a boolean'));
@@ -725,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 }));
@@ -850,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');
@@ -908,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)));
@@ -959,3 +974,16 @@ async function getLimits(req, res, next) {
next(new HttpSuccess(200, { limits }));
}
async function getGraphs(req, res, next) {
assert.strictEqual(typeof req.app, 'object');
if (!req.query.fromMinutes || !parseInt(req.query.fromMinutes)) return next(new HttpError(400, 'fromMinutes must be a number'));
const fromMinutes = parseInt(req.query.fromMinutes);
const noNullPoints = !!req.query.noNullPoints;
const [error, result] = await safe(graphs.getContainerStats(req.app.id, fromMinutes, noNullPoints));
if (error) return next(new HttpError(500, error));
next(new HttpSuccess(200, result));
}
+3 -1
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 }));
+45 -7
View File
@@ -10,6 +10,8 @@ exports = module.exports = {
isRebootRequired,
getConfig,
getDisks,
getDiskUsage,
updateDiskUsage,
getMemory,
getUpdateInfo,
update,
@@ -23,7 +25,9 @@ exports = module.exports = {
getServerIpv6,
getLanguages,
syncExternalLdap,
syncDnsRecords
syncDnsRecords,
getSystemGraphs,
getPlatformStatus
};
const assert = require('assert'),
@@ -34,8 +38,10 @@ const assert = require('assert'),
debug = require('debug')('box:routes/cloudron'),
eventlog = require('../eventlog.js'),
externalLdap = require('../externalldap.js'),
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'),
@@ -83,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, {}));
}
@@ -163,10 +169,27 @@ async function getConfig(req, res, next) {
}
async function getDisks(req, res, next) {
const [error, result] = await safe(system.getDisks());
const [getDisksError, disks] = await safe(system.getDisks());
if (getDisksError) return next(BoxError.toHttpError(getDisksError));
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) {
const [error, result] = await safe(system.getDiskUsage());
if (error) return next(BoxError.toHttpError(error));
next(new HttpSuccess(200, result));
next(new HttpSuccess(200, { usage: result }));
}
async function updateDiskUsage(req, res, next) {
const [error, taskId] = await safe(cloudron.updateDiskUsage());
if (error) return next(BoxError.toHttpError(error));
next(new HttpSuccess(201, { taskId }));
}
async function getMemory(req, res, next) {
@@ -254,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));
@@ -279,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 }));
@@ -327,3 +350,18 @@ async function syncDnsRecords(req, res, next) {
next(new HttpSuccess(201, { taskId }));
}
async function getSystemGraphs(req, res, next) {
if (!req.query.fromMinutes || !parseInt(req.query.fromMinutes)) return next(new HttpError(400, 'fromMinutes must be a number'));
const fromMinutes = parseInt(req.query.fromMinutes);
const noNullPoints = !!req.query.noNullPoints;
const [error, result] = await safe(graphs.getSystem(fromMinutes, noNullPoints));
if (error) return next(new HttpError(500, error));
next(new HttpSuccess(200, result));
}
async function getPlatformStatus(req, res, next) {
next(new HttpSuccess(200, platform.getStatus()));
}
-36
View File
@@ -1,36 +0,0 @@
'use strict';
exports = module.exports = {
getSystemGraphs,
getAppGraphs
};
const assert = require('assert'),
graphs = require('../graphs.js'),
HttpError = require('connect-lastmile').HttpError,
HttpSuccess = require('connect-lastmile').HttpSuccess,
safe = require('safetydance');
async function getSystemGraphs(req, res, next) {
if (!req.query.fromMinutes || !parseInt(req.query.fromMinutes)) return next(new HttpError(400, 'fromMinutes must be a number'));
const fromMinutes = parseInt(req.query.fromMinutes);
const noNullPoints = !!req.query.noNullPoints;
const [error, result] = await safe(graphs.getSystem(fromMinutes, noNullPoints));
if (error) return next(new HttpError(500, error));
next(new HttpSuccess(200, result));
}
async function getAppGraphs(req, res, next) {
assert.strictEqual(typeof req.app, 'object');
if (!req.query.fromMinutes || !parseInt(req.query.fromMinutes)) return next(new HttpError(400, 'fromMinutes must be a number'));
const fromMinutes = parseInt(req.query.fromMinutes);
const noNullPoints = !!req.query.noNullPoints;
const [error, result] = await safe(graphs.getByApp(req.app, fromMinutes, noNullPoints));
if (error) return next(new HttpError(500, error));
next(new HttpSuccess(200, result));
}
-1
View File
@@ -12,7 +12,6 @@ exports = module.exports = {
domains: require('./domains.js'),
eventlog: require('./eventlog.js'),
filemanager: require('./filemanager.js'),
graphs: require('./graphs.js'),
groups: require('./groups.js'),
mail: require('./mail.js'),
mailserver: require('./mailserver.js'),
+17 -2
View File
@@ -7,12 +7,14 @@ exports = module.exports = {
getLogs,
getLogStream,
restart,
rebuild
rebuild,
getGraphs
};
const assert = require('assert'),
AuditSource = require('../auditsource.js'),
BoxError = require('../boxerror.js'),
graphs = require('../graphs.js'),
HttpError = require('connect-lastmile').HttpError,
HttpSuccess = require('connect-lastmile').HttpSuccess,
safe = require('safetydance'),
@@ -108,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));
@@ -131,3 +133,16 @@ async function rebuild(req, res, next) {
next(new HttpSuccess(202, {}));
}
async function getGraphs(req, res, next) {
assert.strictEqual(typeof req.params.service, 'string');
if (!req.query.fromMinutes || !parseInt(req.query.fromMinutes)) return next(new HttpError(400, 'fromMinutes must be a number'));
const fromMinutes = parseInt(req.query.fromMinutes);
const noNullPoints = !!req.query.noNullPoints;
const [error, result] = await safe(graphs.getContainerStats(req.params.service, fromMinutes, noNullPoints));
if (error) return next(new HttpError(500, error));
next(new HttpSuccess(200, result));
}
+1 -1
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));
+32 -1
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')
+3 -2
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
+62 -1
View File
@@ -8,13 +8,16 @@
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'),
safe = require('safetydance'),
superagent = require('superagent'),
settings = require('../../settings.js');
describe('Cloudron API', function () {
const { setup, cleanup, serverUrl, owner, user } = common;
const { setup, cleanup, serverUrl, owner, user, waitForTask } = common;
before(setup);
after(cleanup);
@@ -298,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 })
@@ -333,6 +341,7 @@ describe('Cloudron API', function () {
expect(dataMessageFound).to.be.ok();
res.destroy();
req.destroy();
done();
}, 1000);
@@ -369,6 +378,58 @@ describe('Cloudron API', function () {
});
});
describe('disks', function () {
it('succeeds', async function () {
const response = await superagent.get(`${serverUrl}/api/v1/cloudron/disks`)
.query({ access_token: owner.token });
expect(response.statusCode).to.equal(200);
expect(response.body.disks).to.be.ok();
expect(Object.keys(response.body.disks).some(fs => response.body.disks[fs].mountpoint === '/')).to.be(true);
});
});
describe('disk usage', function () {
it('get succeeds with no cache', async function () {
safe.fs.unlinkSync(paths.DISK_USAGE_FILE);
const response = await superagent.get(`${serverUrl}/api/v1/cloudron/disk_usage`)
.query({ access_token: owner.token })
.send({});
expect(response.statusCode).to.equal(200);
expect(response.body).to.eql({ usage: null });
});
it('update the cache', async function () {
const response = await superagent.post(`${serverUrl}/api/v1/cloudron/disk_usage`)
.query({ access_token: owner.token });
expect(response.statusCode).to.equal(201);
expect(response.body.taskId).to.be.ok();
await waitForTask(response.body.taskId);
});
it('get succeeds with cache', async function () {
const response = await superagent.get(`${serverUrl}/api/v1/cloudron/disk_usage`)
.query({ access_token: owner.token })
.send({});
expect(response.statusCode).to.equal(200);
expect(response.body.usage.ts).to.be.a('number');
const filesystems = Object.keys(response.body.usage.disks);
let dockerUsage = null;
for (const fs of filesystems) {
for (const content of response.body.usage.disks[fs].contents) {
if (content.id === 'docker') dockerUsage = content;
}
}
expect(dockerUsage).to.be.ok();
expect(dockerUsage.usage).to.be.a('number');
});
});
describe('languages', function () {
it('succeeds', async function () {
const response = await superagent.get(`${serverUrl}/api/v1/cloudron/languages`);
+21 -3
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
}
+1 -1
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 () {
+20 -2
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}`)
+97 -65
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);
});
});
});
+1 -23
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)}`);
debug(`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, 30000);
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
-44
View File
@@ -1,44 +0,0 @@
#!/bin/bash
set -eu -o pipefail
if [[ ${EUID} -ne 0 ]]; then
echo "This script should be run as root." > /dev/stderr
exit 1
fi
if [[ $# == 1 && "$1" == "--check" ]]; then
echo "OK"
exit 0
fi
cmd="$1"
metric="$2" # note that this can also be 'cloudron-backup' or appid
if [[ "${BOX_ENV}" == "cloudron" ]]; then
# when restoring the cloudron with many apps, the apptasks rush in to restart
# collectd which makes systemd/collectd very unhappy and puts the collectd in
# inactive state
for i in {1..10}; do
echo "Restarting collectd"
if systemctl restart collectd; then
break
fi
echo "Failed to reload collectd. Maybe some other apptask is restarting it"
sleep $((RANDOM%30))
done
# delete old stats when uninstalling an app
if [[ "${cmd}" == "remove" ]]; then
echo "Removing collectd stats of ${metric}"
for i in {1..10}; do
if rm -rf ${HOME}/platformdata/graphite/whisper/collectd/localhost/*${metric}*; then
break
fi
echo "Failed to remove collectd directory. collectd possibly generated data in the middle of removal"
sleep 3
done
fi
fi
+23
View File
@@ -0,0 +1,23 @@
#!/bin/bash
set -eu -o pipefail
if [[ ${EUID} -ne 0 ]]; then
echo "This script should be run as root." > /dev/stderr
exit 1
fi
if [[ $# -eq 0 ]]; then
echo "No arguments supplied"
exit 1
fi
if [[ "$1" == "--check" ]]; then
echo "OK"
exit 0
fi
path="$1"
# -B1 makes du print block sizes and not apparent sizes (to match df which also uses block sizes)
du -DsB1 "${path}"
+7 -1
View File
@@ -25,11 +25,17 @@ 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
systemctl reload --no-block box
else
echo "Unknown service ${service}"
exit 1
+8 -24
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}"
+2
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
+4 -10
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
+10 -5
View File
@@ -116,8 +116,10 @@ function initializeExpressSync() {
router.post('/api/v1/cloudron/check_for_updates', json, token, authorizeAdmin, routes.cloudron.checkForUpdates);
router.get ('/api/v1/cloudron/reboot', token, authorizeAdmin, routes.cloudron.isRebootRequired);
router.post('/api/v1/cloudron/reboot', json, token, authorizeAdmin, routes.cloudron.reboot);
router.get ('/api/v1/cloudron/graphs', token, authorizeAdmin, routes.graphs.getSystemGraphs);
router.get ('/api/v1/cloudron/graphs', token, authorizeAdmin, routes.cloudron.getSystemGraphs);
router.get ('/api/v1/cloudron/disks', token, authorizeAdmin, routes.cloudron.getDisks);
router.get ('/api/v1/cloudron/disk_usage', token, authorizeAdmin, routes.cloudron.getDiskUsage);
router.post('/api/v1/cloudron/disk_usage', token, authorizeAdmin, routes.cloudron.updateDiskUsage);
router.get ('/api/v1/cloudron/memory', token, authorizeAdmin, routes.cloudron.getMemory);
router.get ('/api/v1/cloudron/logs/:unit', token, authorizeAdmin, routes.cloudron.getLogs);
router.get ('/api/v1/cloudron/logstream/:unit', token, authorizeAdmin, routes.cloudron.getLogStream);
@@ -148,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);
@@ -240,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);
@@ -248,7 +252,7 @@ function initializeExpressSync() {
router.get ('/api/v1/apps/:id/eventlog', token, routes.apps.load, authorizeOperator, routes.apps.listEventlog);
router.get ('/api/v1/apps/:id/limits', token, routes.apps.load, authorizeOperator, routes.apps.getLimits);
router.get ('/api/v1/apps/:id/task', token, routes.apps.load, authorizeOperator, routes.apps.getTask);
router.get ('/api/v1/apps/:id/graphs', token, routes.apps.load, authorizeOperator, routes.graphs.getAppGraphs);
router.get ('/api/v1/apps/:id/graphs', token, routes.apps.load, authorizeOperator, routes.apps.getGraphs);
router.post('/api/v1/apps/:id/clone', json, token, routes.apps.load, authorizeAdmin, routes.apps.clone);
router.get ('/api/v1/apps/:id/download', token, routes.apps.load, authorizeOperator, routes.apps.downloadFile);
router.post('/api/v1/apps/:id/upload', json, token, multipart, routes.apps.load, authorizeOperator, routes.apps.uploadFile);
@@ -328,9 +332,9 @@ function initializeExpressSync() {
router.del ('/api/v1/mail/:domain/lists/:name', token, authorizeMailManager, routes.mail.delList);
// support routes
router.post('/api/v1/support/ticket', json, token, authorizeAdmin, routes.support.canCreateTicket, routes.support.createTicket);
router.get ('/api/v1/support/remote_support', token, authorizeAdmin, routes.support.getRemoteSupport);
router.post('/api/v1/support/remote_support', json, token, authorizeAdmin, routes.support.canEnableRemoteSupport, routes.support.enableRemoteSupport);
router.post('/api/v1/support/ticket', json, token, authorizeOwner, routes.support.canCreateTicket, routes.support.createTicket);
router.get ('/api/v1/support/remote_support', token, authorizeOwner, routes.support.getRemoteSupport);
router.post('/api/v1/support/remote_support', json, token, authorizeOwner, routes.support.canEnableRemoteSupport, routes.support.enableRemoteSupport);
// domain routes
router.post('/api/v1/domains', json, token, authorizeAdmin, routes.domains.add);
@@ -354,6 +358,7 @@ function initializeExpressSync() {
router.get ('/api/v1/services', token, authorizeAdmin, routes.services.list);
router.get ('/api/v1/services/:service', token, authorizeAdmin, routes.services.get);
router.post('/api/v1/services/:service', json, token, authorizeAdmin, routes.services.configure);
router.get ('/api/v1/services/:service/graphs', token, authorizeAdmin, routes.services.getGraphs);
router.get ('/api/v1/services/:service/logs', token, authorizeAdmin, routes.services.getLogs);
router.get ('/api/v1/services/:service/logstream', token, authorizeAdmin, routes.services.getLogStream);
router.post('/api/v1/services/:service/restart', json, token, authorizeAdmin, routes.services.restart);
+43 -38
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,17 +300,18 @@ 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
};
}
async function listServices() {
let serviceIds = Object.keys(SERVICES);
const serviceIds = Object.keys(SERVICES);
const result = await apps.list();
for (let app of result) {
@@ -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);
@@ -1646,6 +1630,7 @@ async function startGraphite(existingInfra) {
const readOnly = !serviceConfig.recoveryMode ? '--read-only' : '';
const cmd = serviceConfig.recoveryMode ? '/bin/bash -c \'echo "Debug mode. Sleeping" && sleep infinity\'' : '';
// port 2003 is used by collectd
const runCmd = `docker run --restart=always -d --name="graphite" \
--hostname graphite \
--net cloudron \
@@ -1659,8 +1644,6 @@ async function startGraphite(existingInfra) {
--dns 172.18.0.1 \
--dns-search=. \
-p 127.0.0.1:2003:2003 \
-p 127.0.0.1:2004:2004 \
-p 127.0.0.1:8417:8000 \
-v "${paths.PLATFORM_DATA_DIR}/graphite:/var/lib/graphite" \
--label isCloudronManaged=true \
${readOnly} -v /tmp -v /run "${tag}" ${cmd}`;
@@ -1732,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' : '';
@@ -1830,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 };
@@ -1840,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)
};
}
@@ -1883,7 +1884,10 @@ async function statusGraphite() {
if (error && error.reason === BoxError.NOT_FOUND) return { status: exports.SERVICE_STATUS_STOPPED };
if (error) throw error;
const [networkError, response] = await safe(superagent.get('http://127.0.0.1:8417/graphite-web/dashboard')
const ip = safe.query(container, 'NetworkSettings.Networks.cloudron.IPAddress', null);
if (!ip) throw new BoxError(BoxError.INACTIVE, 'Error getting IP of graphite service');
const [networkError, response] = await safe(superagent.get(`http://${ip}:8000/graphite-web/dashboard`)
.timeout(20000)
.ok(() => true));
@@ -1891,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)
};
}
-4
View File
@@ -468,8 +468,6 @@ async function setBackupConfig(backupConfig) {
}
notifyChange(exports.BACKUP_CONFIG_KEY, backupConfig);
await backups.configureCollectd(backupConfig);
}
async function setBackupCredentials(credentials) {
@@ -487,8 +485,6 @@ async function setBackupCredentials(credentials) {
await set(exports.BACKUP_CONFIG_KEY, JSON.stringify(backupConfig));
notifyChange(exports.BACKUP_CONFIG_KEY, backupConfig);
await backups.configureCollectd(backupConfig);
}
async function getServicesConfig() {
+5 -3
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)
};
}
+1
View File
@@ -22,6 +22,7 @@ function api(provider) {
case 'wasabi': return require('./storage/s3.js');
case 'scaleway-objectstorage': return require('./storage/s3.js');
case 'backblaze-b2': return require('./storage/s3.js');
case 'cloudflare-r2': return require('./storage/s3.js');
case 'linode-objectstorage': return require('./storage/s3.js');
case 'ovh-objectstorage': return require('./storage/s3.js');
case 'ionos-objectstorage': return require('./storage/s3.js');
+15 -34
View File
@@ -1,8 +1,9 @@
'use strict';
exports = module.exports = {
getRootPath,
checkPreconditions,
getBackupRootPath,
getBackupProviderStatus,
getAvailableSize,
upload,
download,
@@ -33,9 +34,8 @@ const PROVIDER_EXT4 = 'ext4';
const assert = require('assert'),
BoxError = require('../boxerror.js'),
constants = require('../constants.js'),
DataLayout = require('../datalayout.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'),
@@ -45,7 +45,7 @@ const assert = require('assert'),
shell = require('../shell.js');
// storage api
function getRootPath(apiConfig) {
function getBackupRootPath(apiConfig) {
assert.strictEqual(typeof apiConfig, 'object');
switch (apiConfig.provider) {
@@ -62,43 +62,24 @@ function getRootPath(apiConfig) {
}
}
// binary units (non SI) 1024 based
function prettyBytes(bytes) {
assert.strictEqual(typeof bytes, 'number');
async function getBackupProviderStatus(apiConfig) {
assert.strictEqual(typeof apiConfig, 'object');
const i = Math.floor(Math.log(bytes) / Math.log(1024)),
sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
// Check filesystem is mounted so we don't write into the actual folder on disk
if (!mounts.isManagedProvider(apiConfig.provider) && apiConfig.provider !== 'mountpoint') return await mounts.getStatus(apiConfig.provider, apiConfig.backupFolder);
return (bytes / Math.pow(1024, i)).toFixed(2) * 1 + '' + sizes[i];
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
async function checkPreconditions(apiConfig, dataLayout) {
async function getAvailableSize(apiConfig) {
assert.strictEqual(typeof apiConfig, 'object');
assert(dataLayout instanceof DataLayout, 'dataLayout must be a DataLayout');
let used = 0;
for (let localPath of dataLayout.localPaths()) {
debug(`checkPreconditions: getting disk usage of ${localPath}`);
let result = safe.child_process.execSync(`du -Dsb ${localPath}`, { encoding: 'utf8' });
if (!result) throw new BoxError(BoxError.FS_ERROR, `du error: ${safe.error.message}`);
used += parseInt(result, 10);
}
debug(`checkPreconditions: ${used} bytes`);
const [error, result] = await safe(df.file(getRootPath(apiConfig)));
const [error, dfResult] = await safe(df.file(getBackupRootPath(apiConfig)));
if (error) throw new BoxError(BoxError.FS_ERROR, `Error when checking for disk space: ${error.message}`);
// Check filesystem is mounted so we don't write into the actual folder on disk
if (apiConfig.provider === PROVIDER_SSHFS || apiConfig.provider === PROVIDER_CIFS || apiConfig.provider === PROVIDER_NFS || apiConfig.provider === PROVIDER_EXT4 || apiConfig.provider === PROVIDER_XFS) {
if (result.mountpoint !== paths.MANAGED_BACKUP_MOUNT_DIR) throw new BoxError(BoxError.FS_ERROR, 'Backup target is not mounted');
} else if (apiConfig.provider === PROVIDER_MOUNTPOINT) {
if (result.mountpoint === '/') throw new BoxError(BoxError.FS_ERROR, `${apiConfig.backupFolder} is not mounted`);
}
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 (result.available <= needed) throw new BoxError(BoxError.FS_ERROR, `Not enough disk space for backup. Needed: ${prettyBytes(needed)} Available: ${prettyBytes(result.available)}`);
return dfResult.available;
}
function hasChownSupportSync(apiConfig) {
@@ -308,7 +289,7 @@ async function testConfig(apiConfig) {
if (!safe.child_process.execSync(`mountpoint -q -- ${apiConfig.mountPoint}`)) throw new BoxError(BoxError.BAD_FIELD, `${apiConfig.mountPoint} is not mounted`);
}
const basePath = getRootPath(apiConfig);
const basePath = getBackupRootPath(apiConfig);
const field = apiConfig.provider === PROVIDER_FILESYSTEM ? 'backupFolder' : 'mountPoint';
if (!safe.fs.mkdirSync(path.join(basePath, 'snapshot'), { recursive: true }) && safe.error.code !== 'EEXIST') {
+13 -6
View File
@@ -1,8 +1,9 @@
'use strict';
exports = module.exports = {
getRootPath,
checkPreconditions,
getBackupRootPath,
getBackupProviderStatus,
getAvailableSize,
upload,
exists,
@@ -29,7 +30,6 @@ const assert = require('assert'),
async = require('async'),
BoxError = require('../boxerror.js'),
constants = require('../constants.js'),
DataLayout = require('../datalayout.js'),
debug = require('debug')('box:storage/gcs'),
PassThrough = require('stream').PassThrough,
path = require('path'),
@@ -66,15 +66,22 @@ function getBucket(apiConfig) {
}
// storage api
function getRootPath(apiConfig) {
function getBackupRootPath(apiConfig) {
assert.strictEqual(typeof apiConfig, 'object');
return apiConfig.prefix;
}
async function checkPreconditions(apiConfig, dataLayout) {
async function getBackupProviderStatus(apiConfig) {
assert.strictEqual(typeof apiConfig, 'object');
assert(dataLayout instanceof DataLayout, 'dataLayout must be a DataLayout');
return { state: 'active' };
}
async function getAvailableSize(apiConfig) {
assert.strictEqual(typeof apiConfig, 'object');
return Number.POSITIVE_INFINITY;
}
function upload(apiConfig, backupFilePath, sourceStream, callback) {
+14 -7
View File
@@ -11,8 +11,9 @@
// for the other API calls we leave it to the backend to retry. this allows
// them to tune the concurrency based on failures/rate limits accordingly
exports = module.exports = {
getRootPath,
checkPreconditions,
getBackupRootPath,
getBackupProviderStatus,
getAvailableSize,
upload,
@@ -34,8 +35,7 @@ exports = module.exports = {
};
const assert = require('assert'),
BoxError = require('../boxerror.js'),
DataLayout = require('../datalayout.js');
BoxError = require('../boxerror.js');
function removePrivateFields(apiConfig) {
// in-place removal of tokens and api keys with constants.SECRET_PLACEHOLDER
@@ -47,16 +47,23 @@ function injectPrivateFields(newConfig, currentConfig) {
// in-place injection of tokens and api keys which came in with constants.SECRET_PLACEHOLDER
}
function getRootPath(apiConfig) {
function getBackupRootPath(apiConfig) {
assert.strictEqual(typeof apiConfig, 'object');
// Result: path at the backup storage
return '/';
}
async function checkPreconditions(apiConfig, dataLayout) {
async function getBackupProviderStatus(apiConfig) {
assert.strictEqual(typeof apiConfig, 'object');
assert(dataLayout instanceof DataLayout, 'dataLayout must be a DataLayout');
return { state: 'active' };
}
async function getAvailableSize(apiConfig) {
assert.strictEqual(typeof apiConfig, 'object');
return Number.POSITIVE_INFINITY;
}
function upload(apiConfig, backupFilePath, sourceStream, callback) {

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