Compare commits
175 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d547bad17a | |||
| 36ddb8c7c2 | |||
| 6c9aa1a77f | |||
| 27dec3f61e | |||
| 79cb8ef251 | |||
| f27847950c | |||
| 69b46d82ab | |||
| 2a660fa59d | |||
| e942b8fe7e | |||
| 1c3ef36a47 | |||
| d42c524a46 | |||
| 15cc624fa5 | |||
| 7e1c56161d | |||
| 77a5f01585 | |||
| 3aa3cb6e39 | |||
| 302f975d5c | |||
| d23c65a7e7 | |||
| 1cf613dca6 | |||
| 89127e1df7 | |||
| c844be5be1 | |||
| e15c6324e4 | |||
| b70572a6e9 | |||
| cab7409d85 | |||
| ce00165e41 | |||
| 38312b810a | |||
| 9477e0bbb5 | |||
| 4c6f7de10a | |||
| 28f3b697a1 | |||
| f728971479 | |||
| 30fb1aa351 | |||
| a5d244b593 | |||
| 817e950d47 | |||
| 258eea4318 | |||
| 1b0c33fc73 | |||
| 1d56bcb2e0 | |||
| 35ea3b1575 | |||
| c639559a6d | |||
| b437466f8c | |||
| 3b8221190d | |||
| 250d54f157 | |||
| 5d0309f1ca | |||
| 00771d8197 | |||
| 641752a222 | |||
| e3b0d3960a | |||
| cd90864bc3 | |||
| 23cc0d6f0e | |||
| 51f43597bc | |||
| 28b5457e9c | |||
| 35076b0e93 | |||
| 293b8a0d34 | |||
| 0c8b8346f4 | |||
| 8c2a1906ba | |||
| 720bafaf02 | |||
| 0b6bbf4cc2 | |||
| 013e15e361 | |||
| 9da4f55754 | |||
| e3642f4278 | |||
| 19b0d47988 | |||
| f82f533f36 | |||
| 15d5dfd406 | |||
| af870d0eac | |||
| 7b7e5d24de | |||
| 0843baad8b | |||
| 5e2a55ecad | |||
| c597d9fbaa | |||
| 8b43d43e35 | |||
| 5447181e41 | |||
| 3caf77cee6 | |||
| 2515a0f18f | |||
| 9c8f78a059 | |||
| f917eb8f13 | |||
| d19c7ac3e3 | |||
| f61131babf | |||
| e9eeab074a | |||
| 3477cf474f | |||
| d49c171c79 | |||
| 0035247618 | |||
| 3d6cdf8ff3 | |||
| 925b08c7a1 | |||
| 440504a6e9 | |||
| ca44f47af3 | |||
| 9dac5e3406 | |||
| d0b7097706 | |||
| fac0a9ca5d | |||
| b6f707955c | |||
| 962d7030bb | |||
| 5af1bbfb3c | |||
| f2d25ff2fd | |||
| 94327e397a | |||
| 9f54ec47b6 | |||
| cb85336595 | |||
| b28d559d1a | |||
| 4918d2099f | |||
| 8a5d4e2fb0 | |||
| aae52ec795 | |||
| 549cb92ce7 | |||
| c4c90cfaf9 | |||
| ad3e593f01 | |||
| 1c4205b714 | |||
| 7a8559ca9e | |||
| 8bc3b832e7 | |||
| 80a3ca0f46 | |||
| 0f0a98f7ac | |||
| 59783eb11b | |||
| a2bf9180af | |||
| e662cd7c80 | |||
| 2f946de775 | |||
| d8eb8d23bb | |||
| 17c7cc5ec7 | |||
| 8b295fbfdb | |||
| 4e47a1ad3b | |||
| 8f91991e1e | |||
| ae66692eda | |||
| 7cb326cfff | |||
| eb5c90a2e7 | |||
| 91d1d0b74b | |||
| 351292ce1a | |||
| ca4e1e207c | |||
| 1872cea763 | |||
| 4015afc69c | |||
| 6d8c3febac | |||
| b5da4143c9 | |||
| 4fe0402735 | |||
| 4a3d85a269 | |||
| fa7c0a6e1b | |||
| 62d68e2733 | |||
| edb6ed91fe | |||
| a3f7ce15ab | |||
| 4348556dc7 | |||
| deb6d78e4d | |||
| 3c963329e9 | |||
| 656f3fcc13 | |||
| 760301ce02 | |||
| 6f61145b01 | |||
| cbaf86b8c7 | |||
| 9d35756db5 | |||
| 22790fd9b7 | |||
| ad29f51833 | |||
| 3caffdb4e1 | |||
| 2133eab341 | |||
| 25379f1d21 | |||
| cb8d90699b | |||
| 6e4e8bf74d | |||
| 87a00b9209 | |||
| d51b022721 | |||
| cb9b9272cd | |||
| 7dbb677af4 | |||
| 071202fb00 | |||
| fc7414cce6 | |||
| acb92c8865 | |||
| c3793da5bb | |||
| 4f4a0ec289 | |||
| a4a9b52966 | |||
| 56b981a52b | |||
| 074e9cfd93 | |||
| 9d17c6606b | |||
| b32288050e | |||
| 4aab03bb07 | |||
| 9f788c2c57 | |||
| 84ba333aa1 | |||
| c07fe4195f | |||
| 92112986a7 | |||
| 54af286fcd | |||
| 7b5df02a0e | |||
| 4f0e0706b2 | |||
| 1f74febdb0 | |||
| 49bf333355 | |||
| c4af06dd66 | |||
| f5f9a8e520 | |||
| ae376774e4 | |||
| ff8c2184f6 | |||
| a7b056a84c | |||
| 131d456329 | |||
| d4bba93dbf | |||
| e332ad96e4 |
@@ -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
|
||||
|
||||
|
||||
@@ -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.');
|
||||
|
||||
|
||||
Generated
+891
-6465
File diff suppressed because it is too large
Load Diff
+21
-34
@@ -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
@@ -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}
|
||||
@@ -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;;
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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}"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -74,6 +74,6 @@ exports = module.exports = {
|
||||
|
||||
FOOTER: '© %YEAR% [Cloudron](https://cloudron.io) [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
@@ -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
|
||||
});
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
@@ -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`; })
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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
@@ -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()));
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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));
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 () {
|
||||
|
||||
@@ -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}`)
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Executable
+23
@@ -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}"
|
||||
@@ -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
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user