Compare commits
138 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
06cbea11ac | ||
|
|
7df1399f17 | ||
|
|
ce8f6c4c6b | ||
|
|
0832ebf052 | ||
|
|
7be176a3b5 | ||
|
|
4d9612889b | ||
|
|
29faa722ac | ||
|
|
2442abf10b | ||
|
|
dcbec79b77 | ||
|
|
f874acbeb9 | ||
|
|
3e01faeca3 | ||
|
|
f7d7e36f10 | ||
|
|
972f453535 | ||
|
|
d441b9d926 | ||
|
|
e0c996840d | ||
|
|
3c5987cdad | ||
|
|
e0673d78b9 | ||
|
|
08136a5347 | ||
|
|
98b5c77177 | ||
|
|
ea441d0b4b | ||
|
|
d8b5b49ffd | ||
|
|
13fd595e8b | ||
|
|
5f11e430bd | ||
|
|
cfa9f901c1 | ||
|
|
ce2c9d9ac5 | ||
|
|
8d5039da35 | ||
|
|
c264ff32c2 | ||
|
|
5e30bea155 | ||
|
|
2c63a89199 | ||
|
|
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 |
42
CHANGES
42
CHANGES
@@ -2549,3 +2549,45 @@
|
||||
* postgresql: fix issue when restoring large dumps
|
||||
* graphs: add cpu/disk/network usage
|
||||
* graphs: new disk usage UI
|
||||
* relay: add office 365
|
||||
|
||||
[7.3.3]
|
||||
* Fix oom detection in tasks
|
||||
* ldap: memberof is a DN and not just group name
|
||||
* mail relay: office365 provider
|
||||
* If we can't fetch applink upstreamUri, just stop icon and title detection
|
||||
* manifest: add runtimeDirs
|
||||
* remove external df module
|
||||
* Show remaining disk space in usage graph
|
||||
* Make users and groups available for the new app link dialog
|
||||
* Show swaps in disk graphs
|
||||
* disk usage: run once a day
|
||||
* mail: fix 100% cpu use with unreachable servers
|
||||
* security: do not password reset mail to cloudron owned mail domain
|
||||
* logrotate: only keep 14 days of logs
|
||||
* mail: fix dnsbl count when all servers are removed
|
||||
* applink: make users and groups available for the new app link dialog
|
||||
* Show app disk usage in storage tab
|
||||
* Make volume read-only checkbox a dropdown
|
||||
|
||||
[7.3.4]
|
||||
* Display platform update status in the UI
|
||||
* Fix image pruning
|
||||
* cloudflare: fix issue where incorrect URL configuration is accepted
|
||||
|
||||
[7.3.5]
|
||||
* du: fix crash when filesystem is cifs/nfs/sshfs
|
||||
* Start with a default to not fail if no swap is present
|
||||
* Fix bug in cert cleanup logic causing it to repeatedly cleanup
|
||||
* Fix crash in RBL check
|
||||
* unbound: disable controller interface explicitly
|
||||
* Fix issue where cert renewal logs where not displayed
|
||||
* Fix loading of mailboxes
|
||||
|
||||
[7.3.6]
|
||||
* aws: add melbourne region
|
||||
* Fix display of box backups
|
||||
* mail usage: fix issue caused by deleted mailboxes
|
||||
* reverseproxy: fix issue where renewed certs are not written to disk
|
||||
* support: fix crash when opening tickets with 0 length files
|
||||
|
||||
|
||||
6
box.js
6
box.js
@@ -49,6 +49,12 @@ async function main() {
|
||||
// require this here so that logging handler is already setup
|
||||
const debug = require('debug')('box:box');
|
||||
|
||||
process.on('SIGHUP', async function () {
|
||||
debug('Received SIGHUP. Re-reading configs.');
|
||||
const conf = await settings.getDirectoryServerConfig();
|
||||
if (conf.enabled) await directoryServer.checkCertificate();
|
||||
});
|
||||
|
||||
process.on('SIGINT', async function () {
|
||||
debug('Received SIGINT. Shutting down.');
|
||||
|
||||
|
||||
7356
package-lock.json
generated
7356
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
55
package.json
55
package.json
@@ -12,13 +12,12 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@google-cloud/dns": "^2.2.4",
|
||||
"@google-cloud/storage": "^5.19.2",
|
||||
"@sindresorhus/df": "git+https://github.com/cloudron-io/df.git#type",
|
||||
"async": "^3.2.3",
|
||||
"aws-sdk": "^2.1115.0",
|
||||
"@google-cloud/storage": "^5.20.5",
|
||||
"async": "^3.2.4",
|
||||
"aws-sdk": "^2.1248.0",
|
||||
"basic-auth": "^2.0.1",
|
||||
"body-parser": "^1.20.0",
|
||||
"cloudron-manifestformat": "^5.18.0",
|
||||
"body-parser": "^1.20.1",
|
||||
"cloudron-manifestformat": "^5.19.1",
|
||||
"connect": "^3.7.0",
|
||||
"connect-lastmile": "^2.1.1",
|
||||
"connect-timeout": "^1.9.0",
|
||||
@@ -28,39 +27,33 @@
|
||||
"db-migrate": "^0.11.13",
|
||||
"db-migrate-mysql": "^2.2.0",
|
||||
"debug": "^4.3.4",
|
||||
"dockerode": "^3.3.1",
|
||||
"ejs": "^3.1.6",
|
||||
"ejs-cli": "^2.2.3",
|
||||
"express": "^4.17.3",
|
||||
"dockerode": "^3.3.4",
|
||||
"ejs": "^3.1.8",
|
||||
"express": "^4.18.2",
|
||||
"ipaddr.js": "^2.0.1",
|
||||
"js-yaml": "^4.1.0",
|
||||
"jsdom": "^20.0.0",
|
||||
"json": "^11.0.0",
|
||||
"jsdom": "^20.0.2",
|
||||
"jsonwebtoken": "^8.5.1",
|
||||
"ldapjs": "^2.3.2",
|
||||
"ldapjs": "^2.3.3",
|
||||
"lodash": "^4.17.21",
|
||||
"moment": "^2.29.2",
|
||||
"moment-timezone": "^0.5.34",
|
||||
"moment": "^2.29.4",
|
||||
"moment-timezone": "^0.5.38",
|
||||
"morgan": "^1.10.0",
|
||||
"multiparty": "^4.2.3",
|
||||
"mysql": "^2.18.1",
|
||||
"nodemailer": "^6.7.3",
|
||||
"progress-stream": "^2.0.0",
|
||||
"qrcode": "^1.5.0",
|
||||
"nodemailer": "^6.8.0",
|
||||
"qrcode": "^1.5.1",
|
||||
"readdirp": "^3.6.0",
|
||||
"safetydance": "^2.2.0",
|
||||
"semver": "^7.3.7",
|
||||
"semver": "^7.3.8",
|
||||
"speakeasy": "^2.0.0",
|
||||
"split": "^1.0.1",
|
||||
"superagent": "^7.1.1",
|
||||
"superagent": "^7.1.5",
|
||||
"tar-fs": "github:cloudron-io/tar-fs#ignore_stat_error",
|
||||
"tar-stream": "^2.2.0",
|
||||
"tldjs": "^2.3.1",
|
||||
"ua-parser-js": "^1.0.2",
|
||||
"underscore": "^1.13.2",
|
||||
"ua-parser-js": "^1.0.32",
|
||||
"underscore": "^1.13.6",
|
||||
"uuid": "^8.3.2",
|
||||
"validator": "^13.7.0",
|
||||
"ws": "^8.5.0",
|
||||
"ws": "^8.10.0",
|
||||
"xml2js": "^0.4.23"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -69,15 +62,9 @@
|
||||
"js2xmlparser": "^4.0.2",
|
||||
"mocha": "^9.2.2",
|
||||
"mock-aws-s3": "git+https://github.com/cloudron-io/mock-aws-s3.git",
|
||||
"nock": "^13.2.4",
|
||||
"node-sass": "^7.0.1",
|
||||
"nyc": "^15.1.0"
|
||||
"nock": "^13.2.9"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "./run-tests",
|
||||
"postmerge": "/bin/true",
|
||||
"precommit": "/bin/true",
|
||||
"prepush": "npm test",
|
||||
"dashboard": "node_modules/.bin/gulp"
|
||||
"test": "./run-tests"
|
||||
}
|
||||
}
|
||||
|
||||
11
run-tests
11
run-tests
@@ -23,7 +23,7 @@ mkdir -p ${DATA_DIR}
|
||||
cd ${DATA_DIR}
|
||||
mkdir -p appsdata
|
||||
mkdir -p boxdata/box boxdata/mail boxdata/certs boxdata/mail/dkim/localhost boxdata/mail/dkim/foobar.com
|
||||
mkdir -p platformdata/addons/mail/banner platformdata/nginx/cert platformdata/nginx/applications platformdata/collectd/collectd.conf.d platformdata/addons platformdata/logrotate.d platformdata/backup platformdata/logs/tasks platformdata/sftp/ssh platformdata/firewall platformdata/update
|
||||
mkdir -p platformdata/addons/mail/banner platformdata/nginx/cert platformdata/nginx/applications/dashboard platformdata/collectd/collectd.conf.d platformdata/addons platformdata/logrotate.d platformdata/backup platformdata/logs/tasks platformdata/sftp/ssh platformdata/firewall platformdata/update
|
||||
sudo mkdir -p /mnt/cloudron-test-music /media/cloudron-test-music # volume test
|
||||
|
||||
# translations
|
||||
@@ -84,10 +84,5 @@ if [[ $# -gt 0 ]]; then
|
||||
TESTS="$*"
|
||||
fi
|
||||
|
||||
if [[ -z ${COVERAGE+x} ]]; then
|
||||
echo "=> Run tests with mocha"
|
||||
BOX_ENV=test ./node_modules/.bin/mocha --bail --no-timeouts --exit -R spec ${TESTS}
|
||||
else
|
||||
echo "=> Run tests with mocha and coverage"
|
||||
BOX_ENV=test ./node_modules/.bin/nyc --reporter=html ./node_modules/.bin/mocha --no-timeouts --exit -R spec ${TESTS}
|
||||
fi
|
||||
echo "=> Run tests with mocha"
|
||||
BOX_ENV=test ./node_modules/.bin/mocha --bail --no-timeouts --exit -R spec ${TESTS}
|
||||
|
||||
@@ -236,8 +236,8 @@ while true; do
|
||||
sleep 10
|
||||
done
|
||||
|
||||
ip4=$(curl -s --fail --connect-timeout 2 --max-time 2 https://ipv4.api.cloudron.io/api/v1/helper/public_ip | sed -n -e 's/.*"ip": "\(.*\)"/\1/p' || true)
|
||||
ip6=$(curl -s --fail --connect-timeout 2 --max-time 2 https://ipv6.api.cloudron.io/api/v1/helper/public_ip | sed -n -e 's/.*"ip": "\(.*\)"/\1/p' || true)
|
||||
ip4=$(curl -s --fail --connect-timeout 10 --max-time 10 https://ipv4.api.cloudron.io/api/v1/helper/public_ip | sed -n -e 's/.*"ip": "\(.*\)"/\1/p' || true)
|
||||
ip6=$(curl -s --fail --connect-timeout 10 --max-time 10 https://ipv6.api.cloudron.io/api/v1/helper/public_ip | sed -n -e 's/.*"ip": "\(.*\)"/\1/p' || true)
|
||||
|
||||
url4=""
|
||||
url6=""
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -179,8 +179,8 @@ systemctl disable systemd-resolved || true
|
||||
# on vultr, ufw is enabled by default. we have our own firewall
|
||||
ufw disable || true
|
||||
|
||||
# we need unbound to work as this is required for installer.sh to do any DNS requests
|
||||
echo -e "server:\n\tinterface: 127.0.0.1\n" > /etc/unbound/unbound.conf.d/cloudron-network.conf
|
||||
# we need unbound to work as this is required for installer.sh to do any DNS requests. control-enable is for https://github.com/NLnetLabs/unbound/issues/806
|
||||
echo -e "server:\n\tinterface: 127.0.0.1\n\nremote-control:\n\tcontrol-enable: no\n" > /etc/unbound/unbound.conf.d/cloudron-network.conf
|
||||
systemctl restart unbound
|
||||
|
||||
# Ubuntu 22 has private home directories by default (https://discourse.ubuntu.com/t/private-home-directories-for-ubuntu-21-04-onwards/)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -20,7 +20,6 @@ readonly BOX_DATA_DIR="${HOME_DIR}/boxdata/box"
|
||||
readonly MAIL_DATA_DIR="${HOME_DIR}/boxdata/mail"
|
||||
|
||||
readonly script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
readonly json="$(realpath ${script_dir}/../node_modules/.bin/json)"
|
||||
readonly ubuntu_version=$(lsb_release -rs)
|
||||
|
||||
cp -f "${script_dir}/../scripts/cloudron-support" /usr/bin/cloudron-support
|
||||
@@ -57,6 +56,7 @@ mkdir -p "${PLATFORM_DATA_DIR}/mysql"
|
||||
mkdir -p "${PLATFORM_DATA_DIR}/postgresql"
|
||||
mkdir -p "${PLATFORM_DATA_DIR}/mongodb"
|
||||
mkdir -p "${PLATFORM_DATA_DIR}/redis"
|
||||
mkdir -p "${PLATFORM_DATA_DIR}/tls"
|
||||
mkdir -p "${PLATFORM_DATA_DIR}/addons/mail/banner" \
|
||||
"${PLATFORM_DATA_DIR}/addons/mail/dkim"
|
||||
mkdir -p "${PLATFORM_DATA_DIR}/collectd"
|
||||
@@ -107,8 +107,6 @@ unbound-anchor -a /var/lib/unbound/root.key
|
||||
|
||||
log "Adding systemd services"
|
||||
cp -r "${script_dir}/start/systemd/." /etc/systemd/system/
|
||||
[[ "${ubuntu_version}" == "16.04" ]] && sed -e 's/MemoryMax/MemoryLimit/g' -i /etc/systemd/system/box.service
|
||||
[[ "${ubuntu_version}" == "16.04" ]] && sed -e 's/Type=notify/Type=simple/g' -i /etc/systemd/system/unbound.service
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now cloudron-syslog
|
||||
systemctl enable unbound
|
||||
@@ -163,7 +161,7 @@ log "Configuring nginx"
|
||||
# link nginx config to system config
|
||||
unlink /etc/nginx 2>/dev/null || rm -rf /etc/nginx
|
||||
ln -s "${PLATFORM_DATA_DIR}/nginx" /etc/nginx
|
||||
mkdir -p "${PLATFORM_DATA_DIR}/nginx/applications"
|
||||
mkdir -p "${PLATFORM_DATA_DIR}/nginx/applications/dashboard"
|
||||
mkdir -p "${PLATFORM_DATA_DIR}/nginx/cert"
|
||||
cp "${script_dir}/start/nginx/nginx.conf" "${PLATFORM_DATA_DIR}/nginx/nginx.conf"
|
||||
cp "${script_dir}/start/nginx/mime.types" "${PLATFORM_DATA_DIR}/nginx/mime.types"
|
||||
@@ -227,11 +225,14 @@ fi
|
||||
|
||||
rm -f /etc/cloudron/cloudron.conf
|
||||
|
||||
# 7.3 branch only: we had a bug in 7.3 that renewed certs were not written to disk. this will rebuild nginx/certs in the cron job
|
||||
touch "${PLATFORM_DATA_DIR}/nginx/rebuild-needed"
|
||||
|
||||
log "Changing ownership"
|
||||
# note, change ownership after db migrate. this allow db migrate to move files around as root and then we can fix it up here
|
||||
# be careful of what is chown'ed here. subdirs like mysql,redis etc are owned by the containers and will stop working if perms change
|
||||
chown -R "${USER}" /etc/cloudron
|
||||
chown "${USER}:${USER}" -R "${PLATFORM_DATA_DIR}/nginx" "${PLATFORM_DATA_DIR}/collectd" "${PLATFORM_DATA_DIR}/addons" "${PLATFORM_DATA_DIR}/acme" "${PLATFORM_DATA_DIR}/backup" "${PLATFORM_DATA_DIR}/logs" "${PLATFORM_DATA_DIR}/update" "${PLATFORM_DATA_DIR}/sftp" "${PLATFORM_DATA_DIR}/firewall" "${PLATFORM_DATA_DIR}/sshfs" "${PLATFORM_DATA_DIR}/cifs"
|
||||
chown "${USER}:${USER}" -R "${PLATFORM_DATA_DIR}/nginx" "${PLATFORM_DATA_DIR}/collectd" "${PLATFORM_DATA_DIR}/addons" "${PLATFORM_DATA_DIR}/acme" "${PLATFORM_DATA_DIR}/backup" "${PLATFORM_DATA_DIR}/logs" "${PLATFORM_DATA_DIR}/update" "${PLATFORM_DATA_DIR}/sftp" "${PLATFORM_DATA_DIR}/firewall" "${PLATFORM_DATA_DIR}/sshfs" "${PLATFORM_DATA_DIR}/cifs" "${PLATFORM_DATA_DIR}/tls"
|
||||
chown "${USER}:${USER}" "${PLATFORM_DATA_DIR}/INFRA_VERSION" 2>/dev/null || true
|
||||
chown "${USER}:${USER}" "${PLATFORM_DATA_DIR}"
|
||||
chown "${USER}:${USER}" "${APPS_DATA_DIR}"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -14,3 +14,8 @@ server:
|
||||
# enable below for logging to journalctl -u unbound
|
||||
# verbosity: 5
|
||||
# log-queries: yes
|
||||
|
||||
# https://github.com/NLnetLabs/unbound/issues/806
|
||||
remote-control:
|
||||
control-enable: no
|
||||
|
||||
|
||||
269
src/acme2.js
269
src/acme2.js
@@ -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();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -172,10 +172,10 @@ async function processApp(options) {
|
||||
|
||||
await Promise.allSettled(healthChecks); // wait for all promises to finish
|
||||
|
||||
const alive = allApps
|
||||
.filter(function (a) { return a.installationState === apps.ISTATE_INSTALLED && a.runState === apps.RSTATE_RUNNING && a.health === apps.HEALTH_HEALTHY; });
|
||||
const stopped = allApps.filter(app => app.runState === apps.RSTATE_STOPPED);
|
||||
const running = allApps.filter(function (a) { return a.installationState === apps.ISTATE_INSTALLED && a.runState === apps.RSTATE_RUNNING && a.health === apps.HEALTH_HEALTHY; });
|
||||
|
||||
debug(`app health: ${alive.length} alive / ${allApps.length - alive.length} dead.`);
|
||||
debug(`app health: ${running.length} running / ${stopped.length} stopped / ${allApps.length - running.length - stopped.length} unresponsive`);
|
||||
}
|
||||
|
||||
async function run(intervalSecs) {
|
||||
|
||||
@@ -74,7 +74,10 @@ async function detectMetaInfo(applink) {
|
||||
assert.strictEqual(typeof applink, 'object');
|
||||
|
||||
const [error, response] = await safe(superagent.get(applink.upstreamUri).set('User-Agent', 'Mozilla'));
|
||||
if (error || !response.text) throw new BoxError(BoxError.BAD_FIELD, 'cannot fetch upstream uri for favicon and label');
|
||||
if (error || !response.text) {
|
||||
debug('detectMetaInfo: Unable to fetch upstreamUri to detect icon and title', error.statusCode);
|
||||
return;
|
||||
}
|
||||
|
||||
if (applink.favicon && applink.label) return;
|
||||
|
||||
|
||||
186
src/apps.js
186
src/apps.js
@@ -56,6 +56,7 @@ exports = module.exports = {
|
||||
backup,
|
||||
listBackups,
|
||||
updateBackup,
|
||||
getBackupDownloadStream,
|
||||
|
||||
getTask,
|
||||
getLogPaths,
|
||||
@@ -63,8 +64,6 @@ exports = module.exports = {
|
||||
|
||||
appendLogLine,
|
||||
|
||||
getCertificate,
|
||||
|
||||
start,
|
||||
stop,
|
||||
restart,
|
||||
@@ -137,9 +136,19 @@ exports = module.exports = {
|
||||
LOCATION_TYPE_REDIRECT: 'redirect',
|
||||
LOCATION_TYPE_ALIAS: 'alias',
|
||||
|
||||
// should probably be in table as well
|
||||
LOCATION_TYPE_DASHBOARD: 'dashboard',
|
||||
LOCATION_TYPE_MAIL: 'mail',
|
||||
LOCATION_TYPE_DIRECTORY_SERVER: 'directoryserver',
|
||||
|
||||
// respositories, match with appstore
|
||||
REPOSITORY_CORE: 'core',
|
||||
REPOSITORY_COMMUNITY: 'community',
|
||||
|
||||
// exported for testing
|
||||
_validatePortBindings: validatePortBindings,
|
||||
_validateAccessRestriction: validateAccessRestriction,
|
||||
_validateUpstreamUri: validateUpstreamUri,
|
||||
_translatePortBindings: translatePortBindings,
|
||||
_parseCrontab: parseCrontab,
|
||||
_clear: clear
|
||||
@@ -159,6 +168,7 @@ const appstore = require('./appstore.js'),
|
||||
domains = require('./domains.js'),
|
||||
eventlog = require('./eventlog.js'),
|
||||
fs = require('fs'),
|
||||
LogStream = require('./log-stream.js'),
|
||||
mail = require('./mail.js'),
|
||||
manifestFormat = require('cloudron-manifestformat'),
|
||||
mounts = require('./mounts.js'),
|
||||
@@ -174,10 +184,11 @@ const appstore = require('./appstore.js'),
|
||||
settings = require('./settings.js'),
|
||||
shell = require('./shell.js'),
|
||||
spawn = require('child_process').spawn,
|
||||
split = require('split'),
|
||||
storage = require('./storage.js'),
|
||||
superagent = require('superagent'),
|
||||
system = require('./system.js'),
|
||||
tasks = require('./tasks.js'),
|
||||
tgz = require('./backupformat/tgz.js'),
|
||||
TransformStream = require('stream').Transform,
|
||||
users = require('./users.js'),
|
||||
util = require('util'),
|
||||
@@ -195,6 +206,7 @@ const APPS_FIELDS_PREFIXED = [ 'apps.id', 'apps.appStoreId', 'apps.installationS
|
||||
'apps.storageVolumeId', 'apps.storageVolumePrefix', 'apps.ts', 'apps.healthTime', '(apps.icon IS NOT NULL) AS hasIcon', '(apps.appStoreIcon IS NOT NULL) AS hasAppStoreIcon' ].join(',');
|
||||
|
||||
// const PORT_BINDINGS_FIELDS = [ 'hostPort', 'type', 'environmentVariable', 'appId' ].join(',');
|
||||
const LOCATION_FIELDS = [ 'appId', 'subdomain', 'domain', 'type', 'certificateJson' ];
|
||||
|
||||
const CHECKVOLUME_CMD = path.join(__dirname, 'scripts/checkvolume.sh');
|
||||
|
||||
@@ -459,7 +471,7 @@ function validateBackupFormat(format) {
|
||||
function validateUpstreamUri(upstreamUri) {
|
||||
assert.strictEqual(typeof upstreamUri, 'string');
|
||||
|
||||
if (!upstreamUri) return null;
|
||||
if (!upstreamUri) return new BoxError(BoxError.BAD_FIELD, 'upstreamUri cannot be empty');
|
||||
|
||||
const uri = safe(() => new URL(upstreamUri));
|
||||
if (!uri) return new BoxError(BoxError.BAD_FIELD, `upstreamUri is invalid: ${safe.error.message}`);
|
||||
@@ -527,10 +539,9 @@ async function checkStorage(app, volumeId, prefix) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function getDuplicateErrorDetails(errorMessage, locations, domainObjectMap, portBindings) {
|
||||
function getDuplicateErrorDetails(errorMessage, locations, portBindings) {
|
||||
assert.strictEqual(typeof errorMessage, 'string');
|
||||
assert(Array.isArray(locations));
|
||||
assert.strictEqual(typeof domainObjectMap, 'object');
|
||||
assert.strictEqual(typeof portBindings, 'object');
|
||||
|
||||
const match = errorMessage.match(/ER_DUP_ENTRY: Duplicate entry '(.*)' for key '(.*)'/);
|
||||
@@ -545,7 +556,7 @@ function getDuplicateErrorDetails(errorMessage, locations, domainObjectMap, port
|
||||
const { subdomain, domain, type } = locations[i];
|
||||
if (match[1] !== `${subdomain}-${domain}`) continue;
|
||||
|
||||
return new BoxError(BoxError.ALREADY_EXISTS, `${type} location '${dns.fqdn(subdomain, domainObjectMap[domain])}' is in use`);
|
||||
return new BoxError(BoxError.ALREADY_EXISTS, `${type} location '${dns.fqdn(subdomain, domain)}' is in use`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -584,7 +595,7 @@ function removeInternalFields(app) {
|
||||
'accessRestriction', 'manifest', 'portBindings', 'iconUrl', 'memoryLimit', 'cpuShares', 'operators',
|
||||
'sso', 'debugMode', 'reverseProxyConfig', 'enableBackup', 'creationTime', 'updateTime', 'ts', 'tags',
|
||||
'label', 'secondaryDomains', 'redirectDomains', 'aliasDomains', 'env', 'enableAutomaticUpdate',
|
||||
'storageVolumeId', 'storageVolumePrefix', 'mounts',
|
||||
'storageVolumeId', 'storageVolumePrefix', 'mounts', 'repository',
|
||||
'enableMailbox', 'mailboxDisplayName', 'mailboxName', 'mailboxDomain', 'enableInbox', 'inboxName', 'inboxDomain');
|
||||
|
||||
removeCertificateKeys(result);
|
||||
@@ -594,7 +605,7 @@ function removeInternalFields(app) {
|
||||
// non-admins can only see these
|
||||
function removeRestrictedFields(app) {
|
||||
const result = _.pick(app,
|
||||
'id', 'appStoreId', 'installationState', 'error', 'runState', 'health', 'taskId', 'accessRestriction',
|
||||
'id', 'appStoreId', 'installationState', 'error', 'runState', 'health', 'taskId', 'accessRestriction', 'repository',
|
||||
'secondaryDomains', 'redirectDomains', 'aliasDomains', 'sso', 'subdomain', 'domain', 'fqdn', 'certificate',
|
||||
'manifest', 'portBindings', 'iconUrl', 'creationTime', 'ts', 'tags', 'label', 'enableBackup', 'upstreamUri');
|
||||
|
||||
@@ -744,6 +755,11 @@ function postProcess(result) {
|
||||
delete result.errorJson;
|
||||
|
||||
result.taskId = result.taskId ? String(result.taskId) : null;
|
||||
|
||||
// package repository is currently determined by dockerImage
|
||||
if (!result.manifest.dockerImage) result.repository = '';
|
||||
else if (result.manifest.dockerImage.startsWith('cloudron/')) result.repository = exports.REPOSITORY_CORE;
|
||||
else result.repository = exports.REPOSITORY_COMMUNITY;
|
||||
}
|
||||
|
||||
function attachProperties(app, domainObjectMap) {
|
||||
@@ -756,10 +772,10 @@ function attachProperties(app, domainObjectMap) {
|
||||
}
|
||||
app.portBindings = result;
|
||||
app.iconUrl = app.hasIcon || app.hasAppStoreIcon ? `/api/v1/apps/${app.id}/icon` : null;
|
||||
app.fqdn = dns.fqdn(app.subdomain, domainObjectMap[app.domain]);
|
||||
app.secondaryDomains.forEach(function (ad) { ad.fqdn = dns.fqdn(ad.subdomain, domainObjectMap[ad.domain]); });
|
||||
app.redirectDomains.forEach(function (ad) { ad.fqdn = dns.fqdn(ad.subdomain, domainObjectMap[ad.domain]); });
|
||||
app.aliasDomains.forEach(function (ad) { ad.fqdn = dns.fqdn(ad.subdomain, domainObjectMap[ad.domain]); });
|
||||
app.fqdn = dns.fqdn(app.subdomain, app.domain);
|
||||
app.secondaryDomains.forEach(function (ad) { ad.fqdn = dns.fqdn(ad.subdomain, ad.domain); });
|
||||
app.redirectDomains.forEach(function (ad) { ad.fqdn = dns.fqdn(ad.subdomain, ad.domain); });
|
||||
app.aliasDomains.forEach(function (ad) { ad.fqdn = dns.fqdn(ad.subdomain, ad.domain); });
|
||||
}
|
||||
|
||||
function isAdmin(user) {
|
||||
@@ -1055,13 +1071,6 @@ async function clear() {
|
||||
await database.query('DELETE FROM apps');
|
||||
}
|
||||
|
||||
async function getDomainObjectMap() {
|
||||
const domainObjects = await domains.list();
|
||||
let domainObjectMap = {};
|
||||
for (let d of domainObjects) { domainObjectMap[d.domain] = d; }
|
||||
return domainObjectMap;
|
||||
}
|
||||
|
||||
// each query simply join apps table with another table by id. we then join the full result together
|
||||
const PB_QUERY = 'SELECT id, GROUP_CONCAT(CAST(appPortBindings.hostPort AS CHAR(6))) AS hostPorts, GROUP_CONCAT(appPortBindings.environmentVariable) AS environmentVariables, GROUP_CONCAT(appPortBindings.type) AS portTypes FROM apps LEFT JOIN appPortBindings ON apps.id = appPortBindings.appId GROUP BY apps.id';
|
||||
const ENV_QUERY = 'SELECT id, JSON_ARRAYAGG(appEnvVars.name) AS envNames, JSON_ARRAYAGG(appEnvVars.value) AS envValues FROM apps LEFT JOIN appEnvVars ON apps.id = appEnvVars.appId GROUP BY apps.id';
|
||||
@@ -1076,7 +1085,7 @@ const APPS_QUERY = `SELECT ${APPS_FIELDS_PREFIXED}, hostPorts, environmentVariab
|
||||
async function get(id) {
|
||||
assert.strictEqual(typeof id, 'string');
|
||||
|
||||
const domainObjectMap = await getDomainObjectMap();
|
||||
const domainObjectMap = await domains.getDomainObjectMap();
|
||||
|
||||
const result = await database.query(`${APPS_QUERY} WHERE apps.id = ?`, [ id ]);
|
||||
if (result.length === 0) return null;
|
||||
@@ -1091,7 +1100,7 @@ async function get(id) {
|
||||
async function getByIpAddress(ip) {
|
||||
assert.strictEqual(typeof ip, 'string');
|
||||
|
||||
const domainObjectMap = await getDomainObjectMap();
|
||||
const domainObjectMap = await domains.getDomainObjectMap();
|
||||
|
||||
const result = await database.query(`${APPS_QUERY} WHERE apps.containerIp = ?`, [ ip ]);
|
||||
if (result.length === 0) return null;
|
||||
@@ -1102,7 +1111,7 @@ async function getByIpAddress(ip) {
|
||||
}
|
||||
|
||||
async function list() {
|
||||
const domainObjectMap = await getDomainObjectMap();
|
||||
const domainObjectMap = await domains.getDomainObjectMap();
|
||||
|
||||
const results = await database.query(`${APPS_QUERY} ORDER BY apps.id`, [ ]);
|
||||
results.forEach(postProcess);
|
||||
@@ -1278,10 +1287,10 @@ function checkAppState(app, state) {
|
||||
async function validateLocations(locations) {
|
||||
assert(Array.isArray(locations));
|
||||
|
||||
const domainObjectMap = await getDomainObjectMap();
|
||||
const domainObjectMap = await domains.getDomainObjectMap();
|
||||
|
||||
for (let location of locations) {
|
||||
if (!(location.domain in domainObjectMap)) throw new BoxError(BoxError.BAD_FIELD, `No such domain in ${location.type} location`);
|
||||
for (const location of locations) {
|
||||
if (!(location.domain in domainObjectMap)) return new BoxError(BoxError.BAD_FIELD, `No such domain in ${location.type} location`);
|
||||
|
||||
let subdomain = location.subdomain;
|
||||
if (location.type === exports.LOCATION_TYPE_ALIAS && subdomain.startsWith('*')) {
|
||||
@@ -1289,11 +1298,11 @@ async function validateLocations(locations) {
|
||||
subdomain = subdomain.replace(/^\*\./, ''); // remove *.
|
||||
}
|
||||
|
||||
const error = dns.validateHostname(subdomain, domainObjectMap[location.domain]);
|
||||
if (error) throw new BoxError(BoxError.BAD_FIELD, `Bad ${location.type} location: ${error.message}`);
|
||||
const error = dns.validateHostname(subdomain, location.domain);
|
||||
if (error) return new BoxError(BoxError.BAD_FIELD, `Bad ${location.type} location: ${error.message}`);
|
||||
}
|
||||
|
||||
return domainObjectMap;
|
||||
return null;
|
||||
}
|
||||
|
||||
async function getCount() {
|
||||
@@ -1347,7 +1356,7 @@ async function install(data, auditSource) {
|
||||
error = validateLabel(label);
|
||||
if (error) throw error;
|
||||
|
||||
error = validateUpstreamUri(upstreamUri);
|
||||
if ('upstreamUri' in data) error = validateUpstreamUri(upstreamUri);
|
||||
if (error) throw error;
|
||||
|
||||
error = validateTags(tags);
|
||||
@@ -1378,12 +1387,13 @@ async function install(data, auditSource) {
|
||||
icon = Buffer.from(icon, 'base64');
|
||||
}
|
||||
|
||||
const locations = [{ subdomain: subdomain, domain, type: exports.LOCATION_TYPE_PRIMARY }]
|
||||
const locations = [{ subdomain, domain, type: exports.LOCATION_TYPE_PRIMARY }]
|
||||
.concat(secondaryDomains.map(ad => _.extend(ad, { type: exports.LOCATION_TYPE_SECONDARY })))
|
||||
.concat(redirectDomains.map(ad => _.extend(ad, { type: exports.LOCATION_TYPE_REDIRECT })))
|
||||
.concat(aliasDomains.map(ad => _.extend(ad, { type: exports.LOCATION_TYPE_ALIAS })));
|
||||
|
||||
const domainObjectMap = await validateLocations(locations);
|
||||
error = await validateLocations(locations);
|
||||
if (error) throw error;
|
||||
|
||||
if (settings.isDemo() && (await getCount() >= constants.DEMO_APP_LIMIT)) throw new BoxError(BoxError.BAD_STATE, 'Too many installed apps, please uninstall a few and try again');
|
||||
|
||||
@@ -1413,7 +1423,7 @@ async function install(data, auditSource) {
|
||||
};
|
||||
|
||||
const [addError] = await safe(add(appId, appStoreId, manifest, subdomain, domain, translatePortBindings(portBindings, manifest), app));
|
||||
if (addError && addError.reason === BoxError.ALREADY_EXISTS) throw getDuplicateErrorDetails(addError.message, locations, domainObjectMap, portBindings);
|
||||
if (addError && addError.reason === BoxError.ALREADY_EXISTS) throw getDuplicateErrorDetails(addError.message, locations, portBindings);
|
||||
if (addError) throw addError;
|
||||
|
||||
await purchaseApp({ appId, appstoreId: appStoreId, manifestId: manifest.id || 'customapp' });
|
||||
@@ -1427,10 +1437,10 @@ async function install(data, auditSource) {
|
||||
const taskId = await addTask(appId, app.installationState, task, auditSource);
|
||||
|
||||
const newApp = _.extend({}, _.omit(app, 'icon'), { appStoreId, manifest, subdomain, domain, portBindings });
|
||||
newApp.fqdn = dns.fqdn(newApp.subdomain, domainObjectMap[newApp.domain]);
|
||||
newApp.secondaryDomains.forEach(function (ad) { ad.fqdn = dns.fqdn(ad.subdomain, domainObjectMap[ad.domain]); });
|
||||
newApp.redirectDomains.forEach(function (ad) { ad.fqdn = dns.fqdn(ad.subdomain, domainObjectMap[ad.domain]); });
|
||||
newApp.aliasDomains.forEach(function (ad) { ad.fqdn = dns.fqdn(ad.subdomain, domainObjectMap[ad.domain]); });
|
||||
newApp.fqdn = dns.fqdn(newApp.subdomain, newApp.domain);
|
||||
newApp.secondaryDomains.forEach(function (ad) { ad.fqdn = dns.fqdn(ad.subdomain, ad.domain); });
|
||||
newApp.redirectDomains.forEach(function (ad) { ad.fqdn = dns.fqdn(ad.subdomain, ad.domain); });
|
||||
newApp.aliasDomains.forEach(function (ad) { ad.fqdn = dns.fqdn(ad.subdomain, ad.domain); });
|
||||
|
||||
await eventlog.add(eventlog.ACTION_APP_INSTALL, auditSource, { appId, app: newApp, taskId });
|
||||
|
||||
@@ -1782,7 +1792,7 @@ async function setCertificate(app, data, auditSource) {
|
||||
if (domainObject === null) throw new BoxError(BoxError.NOT_FOUND, 'Domain not found');
|
||||
|
||||
if (cert && key) {
|
||||
const error = reverseProxy.validateCertificate(subdomain, domainObject, { cert, key });
|
||||
const error = reverseProxy.validateCertificate(subdomain, domain, { cert, key });
|
||||
if (error) throw error;
|
||||
}
|
||||
|
||||
@@ -1790,11 +1800,23 @@ async function setCertificate(app, data, auditSource) {
|
||||
const result = await database.query('UPDATE locations SET certificateJson=? WHERE location=? AND domain=?', [ certificate ? JSON.stringify(certificate) : null, subdomain, domain ]);
|
||||
if (result.affectedRows === 0) throw new BoxError(BoxError.NOT_FOUND, 'Location not found');
|
||||
|
||||
app = await get(app.id); // refresh app object
|
||||
await reverseProxy.setUserCertificate(app, dns.fqdn(subdomain, domainObject), certificate);
|
||||
const location = await getLocation(subdomain, domain); // fresh location object
|
||||
await reverseProxy.setUserCertificate(app, location);
|
||||
await eventlog.add(eventlog.ACTION_APP_CONFIGURE, auditSource, { appId: app.id, app, subdomain, domain, cert });
|
||||
}
|
||||
|
||||
async function getLocation(subdomain, domain) {
|
||||
assert.strictEqual(typeof subdomain, 'string');
|
||||
assert.strictEqual(typeof domain, 'string');
|
||||
|
||||
const result = await database.query(`SELECT ${LOCATION_FIELDS} FROM locations WHERE subdomain=? AND domain=?`, [ subdomain, domain ]);
|
||||
if (result.length === 0) return null;
|
||||
|
||||
result[0].certificate = safe.JSON.parse(result[0].certificateJson);
|
||||
result[0].fqdn = dns.fqdn(subdomain, domain);
|
||||
return result[0];
|
||||
}
|
||||
|
||||
async function setLocation(app, data, auditSource) {
|
||||
assert.strictEqual(typeof app, 'object');
|
||||
assert.strictEqual(typeof data, 'object');
|
||||
@@ -1844,7 +1866,8 @@ async function setLocation(app, data, auditSource) {
|
||||
.concat(values.redirectDomains.map(ad => _.extend(ad, { type: exports.LOCATION_TYPE_REDIRECT })))
|
||||
.concat(values.aliasDomains.map(ad => _.extend(ad, { type: exports.LOCATION_TYPE_ALIAS })));
|
||||
|
||||
const domainObjectMap = await validateLocations(locations);
|
||||
error = await validateLocations(locations);
|
||||
if (error) throw error;
|
||||
|
||||
const task = {
|
||||
args: {
|
||||
@@ -1855,13 +1878,13 @@ async function setLocation(app, data, auditSource) {
|
||||
values
|
||||
};
|
||||
let [taskError, taskId] = await safe(addTask(appId, exports.ISTATE_PENDING_LOCATION_CHANGE, task, auditSource));
|
||||
if (taskError && taskError.reason === BoxError.ALREADY_EXISTS) taskError = getDuplicateErrorDetails(taskError.message, locations, domainObjectMap, data.portBindings);
|
||||
if (taskError && taskError.reason === BoxError.ALREADY_EXISTS) taskError = getDuplicateErrorDetails(taskError.message, locations, data.portBindings);
|
||||
if (taskError) throw taskError;
|
||||
|
||||
values.fqdn = dns.fqdn(values.subdomain, domainObjectMap[values.domain]);
|
||||
values.secondaryDomains.forEach(function (ad) { ad.fqdn = dns.fqdn(ad.subdomain, domainObjectMap[ad.domain]); });
|
||||
values.redirectDomains.forEach(function (ad) { ad.fqdn = dns.fqdn(ad.subdomain, domainObjectMap[ad.domain]); });
|
||||
values.aliasDomains.forEach(function (ad) { ad.fqdn = dns.fqdn(ad.subdomain, domainObjectMap[ad.domain]); });
|
||||
values.fqdn = dns.fqdn(values.subdomain, values.domain);
|
||||
values.secondaryDomains.forEach(function (ad) { ad.fqdn = dns.fqdn(ad.subdomain, ad.domain); });
|
||||
values.redirectDomains.forEach(function (ad) { ad.fqdn = dns.fqdn(ad.subdomain, ad.domain); });
|
||||
values.aliasDomains.forEach(function (ad) { ad.fqdn = dns.fqdn(ad.subdomain, ad.domain); });
|
||||
|
||||
await eventlog.add(eventlog.ACTION_APP_CONFIGURE, auditSource, _.extend({ appId, app, taskId }, values));
|
||||
|
||||
@@ -2023,29 +2046,12 @@ async function getLogs(app, options) {
|
||||
const logPaths = await getLogPaths(app);
|
||||
const cp = spawn('/usr/bin/tail', args.concat(logPaths));
|
||||
|
||||
const transformStream = split(function mapper(line) {
|
||||
if (format !== 'json') return line + '\n';
|
||||
const logStream = new LogStream({ format, source: appId });
|
||||
logStream.close = cp.kill.bind(cp, 'SIGKILL'); // hook for caller. closing stream kills the child process
|
||||
|
||||
const data = line.split(' '); // logs are <ISOtimestamp> <msg>
|
||||
let timestamp = (new Date(data[0])).getTime();
|
||||
if (isNaN(timestamp)) timestamp = 0;
|
||||
const message = line.slice(data[0].length+1);
|
||||
cp.stdout.pipe(logStream);
|
||||
|
||||
// ignore faulty empty logs
|
||||
if (!timestamp && !message) return;
|
||||
|
||||
return JSON.stringify({
|
||||
realtimeTimestamp: timestamp * 1000,
|
||||
message: message,
|
||||
source: appId
|
||||
}) + '\n';
|
||||
});
|
||||
|
||||
transformStream.close = cp.kill.bind(cp, 'SIGKILL'); // closing stream kills the child process
|
||||
|
||||
cp.stdout.pipe(transformStream);
|
||||
|
||||
return transformStream;
|
||||
return logStream;
|
||||
}
|
||||
|
||||
// never fails just prints error
|
||||
@@ -2058,15 +2064,6 @@ async function appendLogLine(app, line) {
|
||||
if (!safe.fs.appendFileSync(logFilePath, line)) console.error(`Could not append log line for app ${app.id} at ${logFilePath}: ${safe.error.message}`);
|
||||
}
|
||||
|
||||
async function getCertificate(subdomain, domain) {
|
||||
assert.strictEqual(typeof subdomain, 'string');
|
||||
assert.strictEqual(typeof domain, 'string');
|
||||
|
||||
const result = await database.query('SELECT certificateJson FROM locations WHERE subdomain=? AND domain=?', [ subdomain, domain ]);
|
||||
if (result.length === 0) return null;
|
||||
return safe.JSON.parse(result[0].certificateJson);
|
||||
}
|
||||
|
||||
// does a re-configure when called from most states. for install/clone errors, it re-installs with an optional manifest
|
||||
// re-configure can take a dockerImage but not a manifest because re-configure does not clean up addons
|
||||
async function repair(app, data, auditSource) {
|
||||
@@ -2289,10 +2286,11 @@ async function clone(app, data, user, auditSource) {
|
||||
if (error) throw error;
|
||||
const secondaryDomains = translateSecondaryDomains(data.secondaryDomains || {});
|
||||
|
||||
const locations = [{ subdomain: subdomain, domain, type: exports.LOCATION_TYPE_PRIMARY }]
|
||||
const locations = [{ subdomain, domain, type: exports.LOCATION_TYPE_PRIMARY }]
|
||||
.concat(secondaryDomains.map(ad => _.extend(ad, { type: exports.LOCATION_TYPE_SECONDARY })));
|
||||
|
||||
const domainObjectMap = await validateLocations(locations);
|
||||
error = await validateLocations(locations);
|
||||
if (error) throw error;
|
||||
|
||||
// re-validate because this new box version may not accept old configs
|
||||
error = checkManifestConstraints(manifest);
|
||||
@@ -2334,7 +2332,7 @@ async function clone(app, data, user, auditSource) {
|
||||
};
|
||||
|
||||
const [addError] = await safe(add(newAppId, appStoreId, manifest, subdomain, domain, translatePortBindings(portBindings, manifest), obj));
|
||||
if (addError && addError.reason === BoxError.ALREADY_EXISTS) throw getDuplicateErrorDetails(addError.message, locations, domainObjectMap, portBindings);
|
||||
if (addError && addError.reason === BoxError.ALREADY_EXISTS) throw getDuplicateErrorDetails(addError.message, locations, portBindings);
|
||||
if (addError) throw addError;
|
||||
|
||||
await purchaseApp({ appId: newAppId, appstoreId: app.appStoreId, manifestId: manifest.id || 'customapp' });
|
||||
@@ -2348,10 +2346,10 @@ async function clone(app, data, user, auditSource) {
|
||||
const taskId = await addTask(newAppId, exports.ISTATE_PENDING_CLONE, task, auditSource);
|
||||
|
||||
const newApp = _.extend({}, _.omit(obj, 'icon'), { appStoreId, manifest, subdomain, domain, portBindings });
|
||||
newApp.fqdn = dns.fqdn(newApp.subdomain, domainObjectMap[newApp.domain]);
|
||||
newApp.secondaryDomains.forEach(function (ad) { ad.fqdn = dns.fqdn(ad.subdomain, domainObjectMap[ad.domain]); });
|
||||
newApp.redirectDomains.forEach(function (ad) { ad.fqdn = dns.fqdn(ad.subdomain, domainObjectMap[ad.domain]); });
|
||||
newApp.aliasDomains.forEach(function (ad) { ad.fqdn = dns.fqdn(ad.subdomain, domainObjectMap[ad.domain]); });
|
||||
newApp.fqdn = dns.fqdn(newApp.subdomain, newApp.domain);
|
||||
newApp.secondaryDomains.forEach(function (ad) { ad.fqdn = dns.fqdn(ad.subdomain, ad.domain); });
|
||||
newApp.redirectDomains.forEach(function (ad) { ad.fqdn = dns.fqdn(ad.subdomain, ad.domain); });
|
||||
newApp.aliasDomains.forEach(function (ad) { ad.fqdn = dns.fqdn(ad.subdomain, ad.domain); });
|
||||
|
||||
await eventlog.add(eventlog.ACTION_APP_CLONE, auditSource, { appId: newAppId, oldAppId: appId, backupId, remotePath: backupInfo.remotePath, oldApp: app, newApp, taskId });
|
||||
|
||||
@@ -2475,6 +2473,9 @@ async function createExec(app, options) {
|
||||
Cmd: cmd
|
||||
};
|
||||
|
||||
// currently the webterminal and cli sets C.UTF-8
|
||||
if (options.lang) createOptions.Env = [ 'LANG=' + options.lang ];
|
||||
|
||||
return await docker.createExec(app.containerId, createOptions);
|
||||
}
|
||||
|
||||
@@ -2617,6 +2618,25 @@ async function updateBackup(app, backupId, data) {
|
||||
await backups.update(backupId, data);
|
||||
}
|
||||
|
||||
async function getBackupDownloadStream(app, backupId) {
|
||||
assert.strictEqual(typeof app, 'object');
|
||||
assert.strictEqual(typeof backupId, 'string');
|
||||
|
||||
const backup = await backups.get(backupId);
|
||||
if (!backup) throw new BoxError(BoxError.NOT_FOUND, 'Backup not found');
|
||||
if (backup.identifier !== app.id) throw new BoxError(BoxError.NOT_FOUND, 'Backup not found'); // some other app's backup
|
||||
if (backup.format !== 'tgz') throw new BoxError(BoxError.BAD_STATE, 'only tgz backups can be downloaded');
|
||||
|
||||
const backupConfig = await settings.getBackupConfig();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
storage.api(backupConfig.provider).download(backupConfig, tgz.getBackupFilePath(backupConfig, backup.remotePath), function (error, sourceStream) {
|
||||
if (error) return reject(error);
|
||||
resolve(sourceStream);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function restoreInstalledApps(options, auditSource) {
|
||||
assert.strictEqual(typeof options, 'object');
|
||||
assert.strictEqual(typeof auditSource, 'object');
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ const apps = require('./apps.js'),
|
||||
BoxError = require('./boxerror.js'),
|
||||
constants = require('./constants.js'),
|
||||
debug = require('debug')('box:apptask'),
|
||||
df = require('@sindresorhus/df'),
|
||||
df = require('./df.js'),
|
||||
dns = require('./dns.js'),
|
||||
docker = require('./docker.js'),
|
||||
ejs = require('ejs'),
|
||||
|
||||
@@ -242,11 +242,11 @@ async function cleanupSnapshots(backupConfig) {
|
||||
const info = safe.JSON.parse(contents);
|
||||
if (!info) return;
|
||||
|
||||
delete info.box;
|
||||
|
||||
const progressCallback = (progress) => { debug(`cleanupSnapshots: ${progress.message}`); };
|
||||
|
||||
for (const appId of Object.keys(info)) {
|
||||
if (appId === 'box' || appId === 'mail') continue;
|
||||
|
||||
const app = await apps.get(appId);
|
||||
if (app) continue; // app is still installed
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ const assert = require('assert'),
|
||||
{ DecryptStream, EncryptStream } = require('../hush.js'),
|
||||
once = require('../once.js'),
|
||||
path = require('path'),
|
||||
progressStream = require('progress-stream'),
|
||||
ProgressStream = require('../progress-stream.js'),
|
||||
storage = require('../storage.js'),
|
||||
tar = require('tar-fs'),
|
||||
zlib = require('zlib');
|
||||
@@ -51,7 +51,7 @@ function tarPack(dataLayout, encryption) {
|
||||
});
|
||||
|
||||
const gzip = zlib.createGzip({});
|
||||
const ps = progressStream({ time: 10000 }); // emit 'progress' every 10 seconds
|
||||
const ps = new ProgressStream({ interval: 10000 }); // emit 'progress' every 10 seconds
|
||||
|
||||
pack.on('error', function (error) {
|
||||
debug('tarPack: tar stream error.', error);
|
||||
@@ -84,7 +84,7 @@ function tarExtract(inStream, dataLayout, encryption) {
|
||||
assert.strictEqual(typeof encryption, 'object');
|
||||
|
||||
const gunzip = zlib.createGunzip({});
|
||||
const ps = progressStream({ time: 10000 }); // display a progress every 10 seconds
|
||||
const ps = new ProgressStream({ interval: 10000 }); // display a progress every 10 seconds
|
||||
const extract = tar.extract('/', {
|
||||
map: function (header) {
|
||||
header.name = dataLayout.toLocalPath(header.name);
|
||||
@@ -173,6 +173,8 @@ async function upload(backupConfig, remotePath, dataLayout, progressCallback) {
|
||||
assert.strictEqual(typeof dataLayout, 'object');
|
||||
assert.strictEqual(typeof progressCallback, 'function');
|
||||
|
||||
debug(`upload: Uploading ${dataLayout.toString()} to ${remotePath}`);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
async.retry({ times: 5, interval: 20000 }, function (retryCallback) {
|
||||
retryCallback = once(retryCallback); // protect again upload() erroring much later after tar stream error
|
||||
|
||||
@@ -64,7 +64,7 @@ async function checkPreconditions(backupConfig, dataLayout) {
|
||||
// check mount status before uploading
|
||||
const status = await storage.api(backupConfig.provider).getBackupProviderStatus(backupConfig);
|
||||
debug(`upload: mount point status is ${JSON.stringify(status)}`);
|
||||
if (status.state !== 'active') throw new BoxError(BoxError.MOUNT_ERROR, `Backup endpoint is not mounted: ${status.message}`);
|
||||
if (status.state !== 'active') throw new BoxError(BoxError.MOUNT_ERROR, `Backup endpoint is not active: ${status.message}`);
|
||||
|
||||
// check availabe size. this requires root for df to work
|
||||
const df = await storage.api(backupConfig.provider).getAvailableSize(backupConfig);
|
||||
@@ -76,7 +76,7 @@ async function checkPreconditions(backupConfig, dataLayout) {
|
||||
used += parseInt(result, 10);
|
||||
}
|
||||
|
||||
debug(`checkPreconditions: ${used} bytes`);
|
||||
debug(`checkPreconditions: total required =${used} available=${df.available}`);
|
||||
|
||||
const needed = 0.6 * used + (1024 * 1024 * 1024); // check if there is atleast 1GB left afterwards. aim for 60% because rsync/tgz won't need full 100%
|
||||
if (df.available <= needed) throw new BoxError(BoxError.FS_ERROR, `Not enough disk space for backup. Needed: ${prettyBytes(needed)} Available: ${prettyBytes(df.available)}`);
|
||||
@@ -229,7 +229,11 @@ async function copy(backupConfig, srcRemotePath, destRemotePath, progressCallbac
|
||||
const newFilePath = backupFormat.api(format).getBackupFilePath(backupConfig, destRemotePath);
|
||||
|
||||
const startTime = new Date();
|
||||
await safe(storage.api(provider).copy(backupConfig, oldFilePath, newFilePath, progressCallback));
|
||||
const [copyError] = await safe(storage.api(provider).copy(backupConfig, oldFilePath, newFilePath, progressCallback));
|
||||
if (copyError) {
|
||||
debug(`copy: copied to ${destRemotePath} errored. error: ${copyError.message}`);
|
||||
throw copyError;
|
||||
}
|
||||
debug(`copy: copied successfully to ${destRemotePath}. Took ${(new Date() - startTime)/1000} seconds`);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -36,9 +36,9 @@ const apps = require('./apps.js'),
|
||||
delay = require('./delay.js'),
|
||||
dns = require('./dns.js'),
|
||||
dockerProxy = require('./dockerproxy.js'),
|
||||
domains = require('./domains.js'),
|
||||
eventlog = require('./eventlog.js'),
|
||||
fs = require('fs'),
|
||||
LogStream = require('./log-stream.js'),
|
||||
mail = require('./mail.js'),
|
||||
notifications = require('./notifications.js'),
|
||||
path = require('path'),
|
||||
@@ -50,7 +50,6 @@ const apps = require('./apps.js'),
|
||||
settings = require('./settings.js'),
|
||||
shell = require('./shell.js'),
|
||||
spawn = require('child_process').spawn,
|
||||
split = require('split'),
|
||||
sysinfo = require('./sysinfo.js'),
|
||||
tasks = require('./tasks.js'),
|
||||
users = require('./users.js');
|
||||
@@ -113,8 +112,7 @@ async function runStartupTasks() {
|
||||
tasks.push(async function () {
|
||||
if (!settings.dashboardDomain()) return;
|
||||
|
||||
const domainObject = await domains.get(settings.dashboardDomain());
|
||||
await reverseProxy.writeDashboardConfig(domainObject);
|
||||
await reverseProxy.writeDashboardConfig(settings.dashboardDomain());
|
||||
});
|
||||
|
||||
tasks.push(async function () {
|
||||
@@ -161,7 +159,7 @@ async function getConfig() {
|
||||
footer: branding.renderFooter(allSettings[settings.FOOTER_KEY] || constants.FOOTER),
|
||||
features: appstore.getFeatures(),
|
||||
profileLocked: allSettings[settings.PROFILE_CONFIG_KEY].lockUserProfiles,
|
||||
mandatory2FA: allSettings[settings.PROFILE_CONFIG_KEY].mandatory2FA
|
||||
mandatory2FA: allSettings[settings.PROFILE_CONFIG_KEY].mandatory2FA,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -230,25 +228,12 @@ async function getLogs(unit, options) {
|
||||
|
||||
const cp = spawn('/usr/bin/tail', args);
|
||||
|
||||
const transformStream = split(function mapper(line) {
|
||||
if (format !== 'json') return line + '\n';
|
||||
const logStream = new LogStream({ format, source: unit });
|
||||
logStream.close = cp.kill.bind(cp, 'SIGKILL'); // hook for caller. closing stream kills the child process
|
||||
|
||||
const data = line.split(' '); // logs are <ISOtimestamp> <msg>
|
||||
let timestamp = (new Date(data[0])).getTime();
|
||||
if (isNaN(timestamp)) timestamp = 0;
|
||||
cp.stdout.pipe(logStream);
|
||||
|
||||
return JSON.stringify({
|
||||
realtimeTimestamp: timestamp * 1000,
|
||||
message: line.slice(data[0].length+1),
|
||||
source: unit
|
||||
}) + '\n';
|
||||
});
|
||||
|
||||
transformStream.close = cp.kill.bind(cp, 'SIGKILL'); // closing stream kills the child process
|
||||
|
||||
cp.stdout.pipe(transformStream);
|
||||
|
||||
return transformStream;
|
||||
return logStream;
|
||||
}
|
||||
|
||||
async function prepareDashboardDomain(domain, auditSource) {
|
||||
@@ -259,10 +244,7 @@ async function prepareDashboardDomain(domain, auditSource) {
|
||||
|
||||
if (settings.isDemo()) throw new BoxError(BoxError.CONFLICT, 'Not allowed in demo mode');
|
||||
|
||||
const domainObject = await domains.get(domain);
|
||||
if (!domain) throw new BoxError(BoxError.NOT_FOUND, 'No such domain');
|
||||
|
||||
const fqdn = dns.fqdn(constants.DASHBOARD_SUBDOMAIN, domainObject);
|
||||
const fqdn = dns.fqdn(constants.DASHBOARD_SUBDOMAIN, domain);
|
||||
|
||||
const result = await apps.list();
|
||||
if (result.some(app => app.fqdn === fqdn)) throw new BoxError(BoxError.BAD_STATE, 'Dashboard location conflicts with an existing app');
|
||||
@@ -281,11 +263,8 @@ async function setDashboardDomain(domain, auditSource) {
|
||||
|
||||
debug(`setDashboardDomain: ${domain}`);
|
||||
|
||||
const domainObject = await domains.get(domain);
|
||||
if (!domain) throw new BoxError(BoxError.NOT_FOUND, 'No such domain');
|
||||
|
||||
await reverseProxy.writeDashboardConfig(domainObject);
|
||||
const fqdn = dns.fqdn(constants.DASHBOARD_SUBDOMAIN, domainObject);
|
||||
await reverseProxy.writeDashboardConfig(domain);
|
||||
const fqdn = dns.fqdn(constants.DASHBOARD_SUBDOMAIN, domain);
|
||||
|
||||
await settings.setDashboardLocation(domain, fqdn);
|
||||
|
||||
@@ -323,8 +302,7 @@ async function setupDnsAndCert(subdomain, domain, auditSource, progressCallback)
|
||||
assert.strictEqual(typeof auditSource, 'object');
|
||||
assert.strictEqual(typeof progressCallback, 'function');
|
||||
|
||||
const domainObject = await domains.get(domain);
|
||||
const dashboardFqdn = dns.fqdn(subdomain, domainObject);
|
||||
const dashboardFqdn = dns.fqdn(subdomain, domain);
|
||||
|
||||
const ipv4 = await sysinfo.getServerIPv4();
|
||||
const ipv6 = await sysinfo.getServerIPv6();
|
||||
@@ -336,7 +314,8 @@ async function setupDnsAndCert(subdomain, domain, auditSource, progressCallback)
|
||||
await dns.waitForDnsRecord(subdomain, domain, 'A', ipv4, { interval: 30000, times: 50000 });
|
||||
if (ipv6) await dns.waitForDnsRecord(subdomain, domain, 'AAAA', ipv6, { interval: 30000, times: 50000 });
|
||||
progressCallback({ percent: 60, message: `Getting certificate of ${dashboardFqdn}` });
|
||||
await reverseProxy.ensureCertificate(dns.fqdn(subdomain, domainObject), domain, auditSource);
|
||||
const location = { subdomain, domain, fqdn: dashboardFqdn, type: apps.LOCATION_TYPE_DASHBOARD, certificate: null };
|
||||
await reverseProxy.ensureCertificate(location, {}, auditSource);
|
||||
}
|
||||
|
||||
async function syncDnsRecords(options) {
|
||||
|
||||
@@ -40,6 +40,7 @@ exports = module.exports = {
|
||||
|
||||
DEMO_USERNAME: 'cloudron',
|
||||
DEMO_BLACKLISTED_APPS: [
|
||||
'org.jupyter.cloudronapp',
|
||||
'com.github.cloudtorrent',
|
||||
'net.alltubedownload.cloudronapp',
|
||||
'com.adguard.home.cloudronapp',
|
||||
@@ -74,6 +75,6 @@ exports = module.exports = {
|
||||
|
||||
FOOTER: '© %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'
|
||||
};
|
||||
|
||||
|
||||
@@ -50,7 +50,8 @@ const gJobs = {
|
||||
dockerVolumeCleaner: null,
|
||||
dynamicDns: null,
|
||||
schedulerSync: null,
|
||||
appHealthMonitor: null
|
||||
appHealthMonitor: null,
|
||||
diskUsage: null
|
||||
};
|
||||
|
||||
// cron format
|
||||
@@ -95,6 +96,12 @@ async function startJobs() {
|
||||
start: true
|
||||
});
|
||||
|
||||
gJobs.diskUsage = new CronJob({
|
||||
cronTime: `00 ${minute} 3 * * *`, // once a day
|
||||
onTick: async () => await safe(cloudron.updateDiskUsage(), { debug }),
|
||||
start: true
|
||||
});
|
||||
|
||||
gJobs.diskSpaceChecker = new CronJob({
|
||||
cronTime: '00 30 * * * *', // every 30 minutes. if you change this interval, change the notification messages with correct duration
|
||||
onTick: async () => await safe(system.checkDiskSpace(), { debug }),
|
||||
|
||||
46
src/df.js
Normal file
46
src/df.js
Normal file
@@ -0,0 +1,46 @@
|
||||
'use strict';
|
||||
|
||||
exports = module.exports = {
|
||||
disks,
|
||||
file
|
||||
};
|
||||
|
||||
const assert = require('assert'),
|
||||
BoxError = require('./boxerror.js'),
|
||||
safe = require('safetydance');
|
||||
|
||||
function parseLine(line) {
|
||||
const parts = line.split(/\s+/, 7); // this way the mountpoint can have spaces in it
|
||||
|
||||
return {
|
||||
filesystem: parts[0],
|
||||
type: parts[1],
|
||||
size: Number.parseInt(parts[2], 10),
|
||||
used: Number.parseInt(parts[3], 10),
|
||||
available: Number.parseInt(parts[4], 10),
|
||||
capacity: Number.parseInt(parts[5], 10) / 100, // note: this has a trailing %
|
||||
mountpoint: parts[6]
|
||||
};
|
||||
}
|
||||
|
||||
async function disks() {
|
||||
const output = safe.child_process.execSync('df -B1 --output=source,fstype,size,used,avail,pcent,target', { encoding: 'utf8' });
|
||||
if (!output) throw new BoxError(BoxError.FS_ERROR, `Error running df: ${safe.error.message}`);
|
||||
|
||||
const lines = output.trim().split('\n').slice(1); // discard header
|
||||
const result = [];
|
||||
for (const line of lines) {
|
||||
result.push(parseLine(line));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function file(filename) {
|
||||
assert.strictEqual(typeof filename, 'string');
|
||||
|
||||
const output = safe.child_process.execSync(`df -B1 --output=source,fstype,size,used,avail,pcent,target ${filename}`, { encoding: 'utf8' });
|
||||
if (!output) throw new BoxError(BoxError.FS_ERROR, `Error running df: ${safe.error.message}`);
|
||||
|
||||
const lines = output.trim().split('\n').slice(1); // discard header
|
||||
return parseLine(lines[0]);
|
||||
}
|
||||
@@ -4,6 +4,8 @@ exports = module.exports = {
|
||||
start,
|
||||
stop,
|
||||
|
||||
checkCertificate,
|
||||
|
||||
validateConfig,
|
||||
applyConfig
|
||||
};
|
||||
@@ -12,15 +14,12 @@ const assert = require('assert'),
|
||||
BoxError = require('./boxerror.js'),
|
||||
constants = require('./constants.js'),
|
||||
debug = require('debug')('box:directoryserver'),
|
||||
dns = require('./dns.js'),
|
||||
domains = require('./domains.js'),
|
||||
eventlog = require('./eventlog.js'),
|
||||
fs = require('fs'),
|
||||
groups = require('./groups.js'),
|
||||
ldap = require('ldapjs'),
|
||||
path = require('path'),
|
||||
paths = require('./paths.js'),
|
||||
reverseproxy = require('./reverseproxy.js'),
|
||||
reverseProxy = require('./reverseproxy.js'),
|
||||
safe = require('safetydance'),
|
||||
settings = require('./settings.js'),
|
||||
speakeasy = require('speakeasy'),
|
||||
@@ -29,7 +28,7 @@ const assert = require('assert'),
|
||||
util = require('util'),
|
||||
validator = require('validator');
|
||||
|
||||
let gServer = null;
|
||||
let gServer = null, gCertificate = null;
|
||||
|
||||
const NOOP = function () {};
|
||||
|
||||
@@ -200,7 +199,7 @@ async function userSearch(req, res, next) {
|
||||
givenName: firstName,
|
||||
username: user.username,
|
||||
samaccountname: user.username, // to support ActiveDirectory clients
|
||||
memberof: allGroups.filter(function (g) { return g.userIds.indexOf(user.id) !== -1; }).map(function (g) { return g.name; })
|
||||
memberof: allGroups.filter(function (g) { return g.userIds.indexOf(user.id) !== -1; }).map(function (g) { return `cn=${g.name},ou=groups,dc=cloudron`; })
|
||||
}
|
||||
};
|
||||
|
||||
@@ -296,7 +295,6 @@ async function userAuth(req, res, next) {
|
||||
next();
|
||||
}
|
||||
|
||||
// FIXME this needs to be restarted if settings changes or dashboard cert got renewed
|
||||
async function start() {
|
||||
if (gServer) return; // already running
|
||||
|
||||
@@ -309,13 +307,11 @@ async function start() {
|
||||
fatal: debug
|
||||
};
|
||||
|
||||
const domainObject = await domains.get(settings.dashboardDomain());
|
||||
const dashboardFqdn = dns.fqdn(constants.DASHBOARD_SUBDOMAIN, domainObject);
|
||||
const certificatePath = await reverseproxy.getCertificatePath(dashboardFqdn, domainObject.domain);
|
||||
gCertificate = await reverseProxy.getDirectoryServerCertificate();
|
||||
|
||||
gServer = ldap.createServer({
|
||||
certificate: fs.readFileSync(certificatePath.certFilePath, 'utf8'),
|
||||
key: fs.readFileSync(certificatePath.keyFilePath, 'utf8'),
|
||||
certificate: gCertificate.cert,
|
||||
key: gCertificate.key,
|
||||
log: logger
|
||||
});
|
||||
|
||||
@@ -369,3 +365,15 @@ async function stop() {
|
||||
gServer.close();
|
||||
gServer = null;
|
||||
}
|
||||
|
||||
async function checkCertificate() {
|
||||
const certificate = await reverseProxy.getDirectoryServerCertificate();
|
||||
if (certificate.cert === gCertificate.cert) {
|
||||
debug('checkCertificate: certificate has not changed');
|
||||
return;
|
||||
}
|
||||
|
||||
debug('checkCertificate: certificate changed. restarting');
|
||||
await stop();
|
||||
await start();
|
||||
}
|
||||
|
||||
12
src/dns.js
12
src/dns.js
@@ -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,13 +200,14 @@ async function wait(domainObject, subdomain, type, value, options) {
|
||||
assert.strictEqual(typeof value, 'string');
|
||||
assert(options && typeof options === 'object'); // { interval: 5000, times: 50000 }
|
||||
|
||||
const fqdn = dns.fqdn(subdomain, domainObject);
|
||||
const fqdn = dns.fqdn(subdomain, domainObject.domain);
|
||||
|
||||
await waitForDns(fqdn, domainObject.zoneName, type, value, options);
|
||||
}
|
||||
|
||||
// https://stackoverflow.com/questions/14313183/javascript-regex-how-do-i-check-if-the-string-is-ascii-only
|
||||
function isASCII(str) {
|
||||
// eslint-disable-next-line no-control-regex
|
||||
return /^[\x00-\x7F]*$/.test(str);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,8 @@ const assert = require('assert'),
|
||||
debug = require('debug')('box:dns/waitfordns'),
|
||||
dig = require('../dig.js'),
|
||||
promiseRetry = require('../promise-retry.js'),
|
||||
safe = require('safetydance');
|
||||
safe = require('safetydance'),
|
||||
_ = require('underscore');
|
||||
|
||||
async function resolveIp(hostname, type, options) {
|
||||
assert.strictEqual(typeof hostname, 'string');
|
||||
@@ -20,13 +21,13 @@ async function resolveIp(hostname, type, options) {
|
||||
if (!error && results.length !== 0) return results;
|
||||
|
||||
// try CNAME record at authoritative server
|
||||
debug(`resolveIp: Checking if ${hostname} has CNAME record at ${options.server}`);
|
||||
debug(`resolveIp: No A record. Checking if ${hostname} has CNAME record at ${options.server}`);
|
||||
const cnameResults = await dig.resolve(hostname, 'CNAME', options);
|
||||
if (cnameResults.length === 0) return cnameResults;
|
||||
|
||||
// recurse lookup the CNAME record
|
||||
debug(`resolveIp: Resolving ${hostname}'s CNAME record ${cnameResults[0]}`);
|
||||
return await dig.resolve(cnameResults[0], type, options);
|
||||
debug(`resolveIp: CNAME record found. Resolving ${hostname}'s CNAME record ${cnameResults[0]} using unbound`);
|
||||
return await dig.resolve(cnameResults[0], type, _.omit(options, 'server'));
|
||||
}
|
||||
|
||||
async function isChangeSynced(hostname, type, value, nameserver) {
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -39,7 +39,7 @@ const apps = require('./apps.js'),
|
||||
debug = require('debug')('box:docker'),
|
||||
delay = require('./delay.js'),
|
||||
Docker = require('dockerode'),
|
||||
reverseProxy = require('./reverseproxy.js'),
|
||||
paths = require('./paths.js'),
|
||||
services = require('./services.js'),
|
||||
settings = require('./settings.js'),
|
||||
shell = require('./shell.js'),
|
||||
@@ -205,18 +205,11 @@ async function getAddonMounts(app) {
|
||||
break;
|
||||
}
|
||||
case 'tls': {
|
||||
const certificatePath = await reverseProxy.getCertificatePath(app.fqdn, app.domain);
|
||||
const certificateDir = `${paths.PLATFORM_DATA_DIR}/tls/${app.id}`;
|
||||
|
||||
mounts.push({
|
||||
Target: '/etc/certs/tls_cert.pem',
|
||||
Source: certificatePath.certFilePath,
|
||||
Type: 'bind',
|
||||
ReadOnly: true
|
||||
});
|
||||
|
||||
mounts.push({
|
||||
Target: '/etc/certs/tls_key.pem',
|
||||
Source: certificatePath.keyFilePath,
|
||||
Target: '/etc/certs',
|
||||
Source: certificateDir,
|
||||
Type: 'bind',
|
||||
ReadOnly: true
|
||||
});
|
||||
@@ -315,6 +308,15 @@ async function createSubcontainer(app, name, cmd, options) {
|
||||
const mounts = await getMounts(app);
|
||||
|
||||
const addonEnv = await services.getEnvironment(app);
|
||||
const runtimeVolumes = {
|
||||
'/tmp': {},
|
||||
'/run': {},
|
||||
'/home/cloudron/.cache': {},
|
||||
'/root/.cache': {}
|
||||
};
|
||||
if (app.manifest.runtimeDirs) {
|
||||
app.manifest.runtimeDirs.forEach(dir => runtimeVolumes[dir] = {});
|
||||
}
|
||||
|
||||
let containerOptions = {
|
||||
name: name, // for referencing containers
|
||||
@@ -323,10 +325,7 @@ async function createSubcontainer(app, name, cmd, options) {
|
||||
Cmd: (isAppContainer && app.debugMode && app.debugMode.cmd) ? app.debugMode.cmd : cmd,
|
||||
Env: stdEnv.concat(addonEnv).concat(portEnv).concat(appEnv).concat(secondaryDomainsEnv),
|
||||
ExposedPorts: isAppContainer ? exposedPorts : { },
|
||||
Volumes: { // see also ReadonlyRootfs
|
||||
'/tmp': {},
|
||||
'/run': {}
|
||||
},
|
||||
Volumes: runtimeVolumes,
|
||||
Labels: {
|
||||
'fqdn': app.fqdn,
|
||||
'appId': app.id,
|
||||
@@ -343,7 +342,7 @@ async function createSubcontainer(app, name, cmd, options) {
|
||||
'syslog-format': 'rfc5424'
|
||||
}
|
||||
},
|
||||
Memory: system.getMemoryAllocation(memoryLimit),
|
||||
Memory: await system.getMemoryAllocation(memoryLimit),
|
||||
MemorySwap: memoryLimit, // Memory + Swap
|
||||
PortBindings: isAppContainer ? dockerPortBindings : { },
|
||||
PublishAllPorts: false,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -35,6 +35,7 @@ async function getContainerStats(name, fromMinutes, noNullPoints) {
|
||||
const timeBucketSize = fromMinutes > (24 * 60) ? (6*60) : 5;
|
||||
const graphiteUrl = await getGraphiteUrl();
|
||||
|
||||
// https://collectd.org/wiki/index.php/Data_source . the gauge is point in time value. counter is the change of value
|
||||
const targets = [
|
||||
`summarize(collectd.localhost.docker-stats-${name}.gauge-cpu-perc, "${timeBucketSize}min", "avg")`,
|
||||
`summarize(collectd.localhost.docker-stats-${name}.gauge-mem-used, "${timeBucketSize}min", "avg")`,
|
||||
|
||||
@@ -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.2.0',
|
||||
'version': '49.4.0',
|
||||
|
||||
'baseImages': [
|
||||
{ repo: 'cloudron/base', tag: 'cloudron/base:3.2.0@sha256:ba1d566164a67c266782545ea9809dc611c4152e27686fd14060332dd88263ea' }
|
||||
@@ -17,11 +17,11 @@ exports = module.exports = {
|
||||
'images': {
|
||||
'turn': { repo: 'cloudron/turn', tag: 'cloudron/turn:1.4.0@sha256:45817f1631992391d585f171498d257487d872480fd5646723a2b956cc4ef15d' },
|
||||
'mysql': { repo: 'cloudron/mysql', tag: 'cloudron/mysql:3.2.2@sha256:8648ca5a16fcdec72799b919c5f62419fd19e922e3d98d02896b921ae6127ef4' },
|
||||
'postgresql': { repo: 'cloudron/postgresql', tag: 'cloudron/postgresql:4.3.4@sha256:84effb12e93d4e6467fedf3a426989980927ef90be61e73bde43476eebadf2a8' },
|
||||
'postgresql': { repo: 'cloudron/postgresql', tag: 'cloudron/postgresql:4.3.5@sha256:bc8cb91cbd48ee9a2f5a609b6131cd21a0210c15aaf127ee77963d90a125530a' },
|
||||
'mongodb': { repo: 'cloudron/mongodb', tag: 'cloudron/mongodb:4.2.2@sha256:df928d7dce1ac6454fc584787fa863f6d5e7ee0abb775dde5916a555fc94c3c7' },
|
||||
'redis': { repo: 'cloudron/redis', tag: 'cloudron/redis:3.3.1@sha256:383e11a5c7a54d17eb6bbceb0ffa92f486167be6ea9978ec745c8c8e9b7dfb19' },
|
||||
'mail': { repo: 'cloudron/mail', tag: 'cloudron/mail:3.7.0@sha256:44df70c8030cb9da452568c32fae7cae447e3b98cf48fdbc7b27a2466e706473' },
|
||||
'graphite': { repo: 'cloudron/graphite', tag: 'cloudron/graphite:3.1.0@sha256:182e5cae69fbddc703cb9f91be909452065c7ae159e9836cc88317c7a00f0e62' },
|
||||
'mail': { repo: 'cloudron/mail', tag: 'cloudron/mail:3.7.4@sha256:8ddbf13ee3fd479e18923c7bf1370d9d8aa5f12a94cbbda5afac8b5a4af72a28' },
|
||||
'graphite': { repo: 'cloudron/graphite', tag: 'cloudron/graphite:3.2.0@sha256:182e5cae69fbddc703cb9f91be909452065c7ae159e9836cc88317c7a00f0e62' },
|
||||
'sftp': { repo: 'cloudron/sftp', tag: 'cloudron/sftp:3.6.1@sha256:ba4b9a1fe274c0ef0a900e5d0deeb8f3da08e118798d1d90fbf995cc0cf6e3a3' }
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -178,7 +178,7 @@ async function userSearch(req, res, next) {
|
||||
givenName: firstName,
|
||||
username: user.username,
|
||||
samaccountname: user.username, // to support ActiveDirectory clients
|
||||
memberof: allGroups.filter(function (g) { return g.userIds.indexOf(user.id) !== -1; }).map(function (g) { return g.name; })
|
||||
memberof: allGroups.filter(function (g) { return g.userIds.indexOf(user.id) !== -1; }).map(function (g) { return `cn=${g.name},ou=groups,dc=cloudron`; })
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
52
src/log-stream.js
Normal file
52
src/log-stream.js
Normal file
@@ -0,0 +1,52 @@
|
||||
'use strict';
|
||||
|
||||
const stream = require('stream'),
|
||||
{ StringDecoder } = require('string_decoder'),
|
||||
TransformStream = stream.Transform;
|
||||
|
||||
class LogStream extends TransformStream {
|
||||
constructor(options) {
|
||||
super();
|
||||
this._options = Object.assign({ source: 'unknown', format: 'json' }, options);
|
||||
this._decoder = new StringDecoder();
|
||||
this._soFar = '';
|
||||
}
|
||||
|
||||
_format(line) {
|
||||
if (this._options.format !== 'json') return line + '\n';
|
||||
|
||||
const data = line.split(' '); // logs are <ISOtimestamp> <msg>
|
||||
let timestamp = (new Date(data[0])).getTime();
|
||||
if (isNaN(timestamp)) timestamp = 0;
|
||||
const message = line.slice(data[0].length+1);
|
||||
|
||||
// ignore faulty empty logs
|
||||
if (!timestamp && !message) return;
|
||||
|
||||
return JSON.stringify({
|
||||
realtimeTimestamp: timestamp * 1000,
|
||||
message: message,
|
||||
source: this._options.source
|
||||
}) + '\n';
|
||||
}
|
||||
|
||||
_transform(chunk, encoding, callback) {
|
||||
const data = this._soFar + this._decoder.write(chunk);
|
||||
let start = this._soFar.length, end = -1;
|
||||
while ((end = data.indexOf('\n', start)) !== -1) {
|
||||
const line = data.slice(start, end); // does not include end
|
||||
this.push(this._format(line));
|
||||
start = end + 1;
|
||||
}
|
||||
this._soFar = data.slice(start);
|
||||
callback(null);
|
||||
}
|
||||
|
||||
_flush(callback) {
|
||||
const line = this._soFar + this._decoder.end();
|
||||
this.push(this._format(line));
|
||||
callback(null);
|
||||
}
|
||||
}
|
||||
|
||||
exports = module.exports = LogStream;
|
||||
@@ -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
|
||||
|
||||
41
src/mail.js
41
src/mail.js
@@ -32,7 +32,7 @@ exports = module.exports = {
|
||||
|
||||
startMail,
|
||||
restartMail,
|
||||
handleCertChanged,
|
||||
checkCertificate,
|
||||
getMailAuth,
|
||||
|
||||
sendTestMail,
|
||||
@@ -185,7 +185,9 @@ function validateDisplayName(name) {
|
||||
|
||||
if (name.length < 1) return new BoxError(BoxError.BAD_FIELD, 'mailbox display name must be atleast 1 char');
|
||||
if (name.length >= 100) return new BoxError(BoxError.BAD_FIELD, 'mailbox display name too long');
|
||||
if (/["<>@]/.test(name)) return new BoxError(BoxError.BAD_FIELD, 'mailbox display name is not valid');
|
||||
// technically only ":" is disallowed it seems (https://www.rfc-editor.org/rfc/rfc5322#section-2.2)
|
||||
// in https://www.rfc-editor.org/rfc/rfc2822.html, display-name is a "phrase"
|
||||
if (/["<>)(,;\\@:]/.test(name)) return new BoxError(BoxError.BAD_FIELD, 'mailbox display name is not valid');
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -537,7 +539,7 @@ async function checkRblStatus(domain) {
|
||||
const [error2, txtRecords] = await safe(dig.resolve(flippedIp + '.' + rblServer.dns, 'TXT', DNS_OPTIONS));
|
||||
result.txtRecords = error2 || !txtRecords ? 'No txt record' : txtRecords.map(x => x.join(''));
|
||||
|
||||
debug(`checkRblStatus: ${domain} (error: ${error2.message}) (txtRecords: ${JSON.stringify(txtRecords)})`);
|
||||
debug(`checkRblStatus: ${domain} (error: ${error2?.message || null}) (txtRecords: ${JSON.stringify(txtRecords)})`);
|
||||
|
||||
blacklistedServers.push(result);
|
||||
}
|
||||
@@ -588,7 +590,7 @@ async function getStatus(domain) {
|
||||
for (let i = 0; i < checks.length; i++) {
|
||||
const response = responses[i], check = checks[i];
|
||||
if (response.status !== 'fulfilled') {
|
||||
debug(`check ${check.what} was rejected. This is not expected`);
|
||||
debug(`check ${check.what} was rejected. This is not expected. reason: ${response.reason}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -682,7 +684,8 @@ async function createMailConfig(mailFqdn) {
|
||||
const enableRelay = relay.provider !== 'cloudron-smtp' && relay.provider !== 'noop',
|
||||
host = relay.host || '',
|
||||
port = relay.port || 25,
|
||||
authType = relay.username ? 'plain' : '',
|
||||
// office365 removed plain auth (https://support.microsoft.com/en-us/office/outlook-com-no-longer-supports-auth-plain-authentication-07f7d5e9-1697-465f-84d2-4513d4ff0145)
|
||||
authType = relay.username ? (relay.provider === 'office365-legacy-smtp' ? 'login' : 'plain') : '',
|
||||
username = relay.username || '',
|
||||
password = relay.password || '',
|
||||
forceFromAddress = relay.forceFromAddress ? 'true' : 'false';
|
||||
@@ -711,18 +714,18 @@ async function configureMail(mailFqdn, mailDomain, serviceConfig) {
|
||||
|
||||
const tag = infra.images.mail.tag;
|
||||
const memoryLimit = serviceConfig.memoryLimit || exports.DEFAULT_MEMORY_LIMIT;
|
||||
const memory = system.getMemoryAllocation(memoryLimit);
|
||||
const memory = await system.getMemoryAllocation(memoryLimit);
|
||||
const cloudronToken = hat(8 * 128), relayToken = hat(8 * 128);
|
||||
|
||||
const certificatePath = await reverseProxy.getCertificatePath(mailFqdn, mailDomain);
|
||||
const certificate = await reverseProxy.getMailCertificate();
|
||||
|
||||
const dhparamsFilePath = `${paths.MAIL_CONFIG_DIR}/dhparams.pem`;
|
||||
const mailCertFilePath = `${paths.MAIL_CONFIG_DIR}/tls_cert.pem`;
|
||||
const mailKeyFilePath = `${paths.MAIL_CONFIG_DIR}/tls_key.pem`;
|
||||
|
||||
if (!safe.child_process.execSync(`cp ${paths.DHPARAMS_FILE} ${dhparamsFilePath}`)) throw new BoxError(BoxError.FS_ERROR, 'Could not copy dhparams:' + safe.error.message);
|
||||
if (!safe.child_process.execSync(`cp ${certificatePath.certFilePath} ${mailCertFilePath}`)) throw new BoxError(BoxError.FS_ERROR, 'Could not create cert file:' + safe.error.message);
|
||||
if (!safe.child_process.execSync(`cp ${certificatePath.keyFilePath} ${mailKeyFilePath}`)) throw new BoxError(BoxError.FS_ERROR, 'Could not create key file:' + safe.error.message);
|
||||
if (!safe.child_process.execSync(`cp ${paths.DHPARAMS_FILE} ${dhparamsFilePath}`)) throw new BoxError(BoxError.FS_ERROR, `Could not copy dhparams: ${safe.error.message}`);
|
||||
if (!safe.fs.writeFileSync(mailCertFilePath, certificate.cert)) throw new BoxError(BoxError.FS_ERROR, `Could not create cert file: ${safe.error.message}`);
|
||||
if (!safe.fs.writeFileSync(mailKeyFilePath, certificate.key)) throw new BoxError(BoxError.FS_ERROR, `Could not create key file: ${safe.error.message}`);
|
||||
|
||||
// if the 'yellowtent' user of OS and the 'cloudron' user of mail container don't match, the keys become inaccessible by mail code
|
||||
if (!safe.fs.chmodSync(mailKeyFilePath, 0o644)) throw new BoxError(BoxError.FS_ERROR, `Could not chmod key file: ${safe.error.message}`);
|
||||
@@ -793,6 +796,7 @@ async function restartMail() {
|
||||
async function startMail(existingInfra) {
|
||||
assert.strictEqual(typeof existingInfra, 'object');
|
||||
|
||||
debug('startMail: starting');
|
||||
await restartMail();
|
||||
}
|
||||
|
||||
@@ -804,11 +808,19 @@ async function restartMailIfActivated() {
|
||||
return; // not provisioned yet, do not restart container after dns setup
|
||||
}
|
||||
|
||||
debug('restartMailIfActivated: restarting on activated');
|
||||
await restartMail();
|
||||
}
|
||||
|
||||
async function handleCertChanged() {
|
||||
debug('handleCertChanged: will restart if activated');
|
||||
async function checkCertificate() {
|
||||
const certificate = await reverseProxy.getMailCertificate();
|
||||
const cert = safe.fs.readFileSync(`${paths.MAIL_CONFIG_DIR}/tls_cert.pem`, { encoding: 'utf8' });
|
||||
if (cert === certificate.cert) {
|
||||
debug('checkCertificate: certificate has not changed');
|
||||
return;
|
||||
}
|
||||
debug('checkCertificate: certificate has changed');
|
||||
|
||||
await restartMailIfActivated();
|
||||
}
|
||||
|
||||
@@ -982,8 +994,7 @@ async function setLocation(subdomain, domain, auditSource) {
|
||||
assert.strictEqual(typeof domain, 'string');
|
||||
assert.strictEqual(typeof auditSource, 'object');
|
||||
|
||||
const domainObject = await domains.get(domain);
|
||||
const fqdn = dns.fqdn(subdomain, domainObject);
|
||||
const fqdn = dns.fqdn(subdomain, domain);
|
||||
|
||||
await settings.setMailLocation(domain, fqdn);
|
||||
|
||||
@@ -999,6 +1010,7 @@ async function onDomainAdded(domain) {
|
||||
|
||||
if (!settings.mailFqdn()) return; // mail domain is not set yet (when provisioning)
|
||||
|
||||
debug(`onDomainAdded: configuring mail for added domain ${domain}`);
|
||||
await upsertDnsRecords(domain, settings.mailFqdn());
|
||||
await restartMailIfActivated();
|
||||
}
|
||||
@@ -1006,6 +1018,7 @@ async function onDomainAdded(domain) {
|
||||
async function onDomainRemoved(domain) {
|
||||
assert.strictEqual(typeof domain, 'string');
|
||||
|
||||
debug(`onDomainRemoved: configuring mail for removed domain ${domain}`);
|
||||
await restartMail();
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -4,8 +4,7 @@ exports = module.exports = {
|
||||
start,
|
||||
stopAllTasks,
|
||||
|
||||
// exported for testing
|
||||
_isReady: false
|
||||
getStatus
|
||||
};
|
||||
|
||||
const apps = require('./apps.js'),
|
||||
@@ -26,10 +25,16 @@ const apps = require('./apps.js'),
|
||||
volumes = require('./volumes.js'),
|
||||
_ = require('underscore');
|
||||
|
||||
let gStatusMessage = 'Initializing';
|
||||
|
||||
function getStatus() {
|
||||
return { message: gStatusMessage };
|
||||
}
|
||||
|
||||
async function start(options) {
|
||||
if (process.env.BOX_ENV === 'test' && !process.env.TEST_CREATE_INFRA) return;
|
||||
|
||||
debug('initializing addon infrastructure');
|
||||
debug('initializing platform');
|
||||
|
||||
let existingInfra = { version: 'none' };
|
||||
if (fs.existsSync(paths.INFRA_VERSION_FILE)) {
|
||||
@@ -52,11 +57,13 @@ async function start(options) {
|
||||
for (let attempt = 0; attempt < 5; attempt++) {
|
||||
try {
|
||||
if (existingInfra.version !== infra.version) {
|
||||
gStatusMessage = 'Removing containers for upgrade';
|
||||
await removeAllContainers();
|
||||
await createDockerNetwork();
|
||||
}
|
||||
if (existingInfra.version === 'none') await volumes.mountAll(); // when restoring, mount all volumes
|
||||
await markApps(existingInfra, options); // mark app state before we start addons. this gives the db import logic a chance to mark an app as errored
|
||||
gStatusMessage = 'Starting services, this can take a while';
|
||||
await services.startServices(existingInfra);
|
||||
await fs.promises.writeFile(paths.INFRA_VERSION_FILE, JSON.stringify(infra, null, 4));
|
||||
break;
|
||||
@@ -81,7 +88,7 @@ async function stopAllTasks() {
|
||||
|
||||
async function onPlatformReady(infraChanged) {
|
||||
debug(`onPlatformReady: platform is ready. infra changed: ${infraChanged}`);
|
||||
exports._isReady = true;
|
||||
gStatusMessage = 'Ready';
|
||||
|
||||
if (infraChanged) await safe(pruneInfraImages(), { debug }); // ignore error
|
||||
|
||||
@@ -95,20 +102,22 @@ async function pruneInfraImages() {
|
||||
const images = infra.baseImages.concat(Object.keys(infra.images).map(function (addon) { return infra.images[addon]; }));
|
||||
|
||||
for (const image of images) {
|
||||
let output = safe.child_process.execSync(`docker images --digests ${image.repo} --format "{{.ID}} {{.Repository}}:{{.Tag}}@{{.Digest}}"`, { encoding: 'utf8' });
|
||||
const output = safe.child_process.execSync(`docker images --digests ${image.repo} --format "{{.ID}} {{.Repository}}:{{.Tag}}@{{.Digest}}"`, { encoding: 'utf8' });
|
||||
if (output === null) {
|
||||
debug(`Failed to list images of ${image}`, safe.error);
|
||||
throw safe.error;
|
||||
}
|
||||
|
||||
let lines = output.trim().split('\n');
|
||||
for (let line of lines) {
|
||||
const lines = output.trim().split('\n');
|
||||
for (const line of lines) {
|
||||
if (!line) continue;
|
||||
let parts = line.split(' '); // [ ID, Repo:Tag@Digest ]
|
||||
if (image.tag === parts[1]) continue; // keep
|
||||
const parts = line.split(' '); // [ ID, Repo:Tag@Digest ]
|
||||
const normalizedTag = parts[1].replace('registry.ipv6.docker.com/', '').replace('registry-1.docker.io/', '');
|
||||
|
||||
if (image.tag === normalizedTag) continue; // keep
|
||||
debug(`pruneInfraImages: removing unused image of ${image.repo}: tag: ${parts[1]} id: ${parts[0]}`);
|
||||
|
||||
let result = safe.child_process.execSync(`docker rmi ${parts[0]}`, { encoding: 'utf8' });
|
||||
let result = safe.child_process.execSync(`docker rmi ${parts[1].replace(':<none>', '')}`, { encoding: 'utf8' }); // the none tag has to be removed
|
||||
if (result === null) debug(`Error removing image ${parts[0]}: ${safe.error.mesage}`);
|
||||
}
|
||||
}
|
||||
|
||||
44
src/progress-stream.js
Normal file
44
src/progress-stream.js
Normal file
@@ -0,0 +1,44 @@
|
||||
'use strict';
|
||||
|
||||
const stream = require('stream'),
|
||||
TransformStream = stream.Transform;
|
||||
|
||||
class ProgressStream extends TransformStream {
|
||||
constructor(options) {
|
||||
super();
|
||||
this._options = Object.assign({ interval: 10 * 1000 }, options);
|
||||
this._transferred = 0;
|
||||
this._delta = 0;
|
||||
this._started = false;
|
||||
this._startTime = null;
|
||||
this._interval = null;
|
||||
}
|
||||
|
||||
_start() {
|
||||
this._startTime = Date.now();
|
||||
this._started = true;
|
||||
this._interval = setInterval(() => {
|
||||
const speed = this._delta * 1000 / this._options.interval;
|
||||
this._delta = 0;
|
||||
this.emit('progress', { speed, transferred: this._transferred });
|
||||
}, this._options.interval);
|
||||
}
|
||||
|
||||
_stop() {
|
||||
clearInterval(this._interval);
|
||||
}
|
||||
|
||||
_transform(chunk, encoding, callback) {
|
||||
if (!this._started) this._start();
|
||||
this._transferred += chunk.length;
|
||||
this._delta += chunk.length;
|
||||
callback(null, chunk);
|
||||
}
|
||||
|
||||
_flush(callback) {
|
||||
this._stop();
|
||||
callback(null);
|
||||
}
|
||||
}
|
||||
|
||||
exports = module.exports = ProgressStream;
|
||||
@@ -8,7 +8,9 @@ exports = module.exports = {
|
||||
|
||||
validateCertificate,
|
||||
|
||||
getCertificatePath, // resolved cert path
|
||||
getMailCertificate,
|
||||
getDirectoryServerCertificate,
|
||||
|
||||
ensureCertificate,
|
||||
|
||||
checkCerts,
|
||||
@@ -25,8 +27,7 @@ exports = module.exports = {
|
||||
removeAppConfigs,
|
||||
restoreFallbackCertificates,
|
||||
|
||||
// exported for testing
|
||||
_getAcmeApi: getAcmeApi
|
||||
handleCertificateProviderChanged
|
||||
};
|
||||
|
||||
const acme2 = require('./acme2.js'),
|
||||
@@ -38,6 +39,7 @@ const acme2 = require('./acme2.js'),
|
||||
crypto = require('crypto'),
|
||||
debug = require('debug')('box:reverseproxy'),
|
||||
dns = require('./dns.js'),
|
||||
docker = require('./docker.js'),
|
||||
domains = require('./domains.js'),
|
||||
ejs = require('ejs'),
|
||||
eventlog = require('./eventlog.js'),
|
||||
@@ -50,7 +52,6 @@ const acme2 = require('./acme2.js'),
|
||||
settings = require('./settings.js'),
|
||||
shell = require('./shell.js'),
|
||||
sysinfo = require('./sysinfo.js'),
|
||||
users = require('./users.js'),
|
||||
util = require('util');
|
||||
|
||||
const NGINX_APPCONFIG_EJS = fs.readFileSync(__dirname + '/nginxconfig.ejs', { encoding: 'utf8' });
|
||||
@@ -63,39 +64,23 @@ function nginxLocation(s) {
|
||||
return `~ ^(?!(${re.slice(1)}))`; // negative regex assertion - https://stackoverflow.com/questions/16302897/nginx-location-not-equal-to-regex
|
||||
}
|
||||
|
||||
async function getAcmeApi(domainObject) {
|
||||
assert.strictEqual(typeof domainObject, 'object');
|
||||
function getCertificateDatesSync(cert) {
|
||||
assert.strictEqual(typeof cert, 'string');
|
||||
|
||||
const apiOptions = { prod: false, performHttpAuthorization: false, wildcard: false, email: '' };
|
||||
apiOptions.prod = domainObject.tlsConfig.provider.match(/.*-prod/) !== null; // matches 'le-prod' or 'letsencrypt-prod'
|
||||
apiOptions.performHttpAuthorization = domainObject.provider.match(/noop|manual|wildcard/) !== null;
|
||||
apiOptions.wildcard = !!domainObject.tlsConfig.wildcard;
|
||||
const result = safe.child_process.spawnSync('/usr/bin/openssl', [ 'x509', '-startdate', '-enddate', '-subject', '-noout' ], { input: cert, encoding: 'utf8' });
|
||||
if (!result) return { startDate: null, endDate: null } ; // some error
|
||||
|
||||
// registering user with an email requires A or MX record (https://github.com/letsencrypt/boulder/issues/1197)
|
||||
// we cannot use admin@fqdn because the user might not have set it up.
|
||||
// we simply update the account with the latest email we have each time when getting letsencrypt certs
|
||||
// https://github.com/ietf-wg-acme/acme/issues/30
|
||||
const [error, owner] = await safe(users.getOwner());
|
||||
apiOptions.email = (error || !owner) ? 'webmaster@cloudron.io' : owner.email; // can error if not activated yet
|
||||
const lines = result.stdout.trim().split('\n');
|
||||
const notBefore = lines[0].split('=')[1];
|
||||
const notBeforeDate = new Date(notBefore);
|
||||
|
||||
return { acme2, apiOptions };
|
||||
}
|
||||
|
||||
function getExpiryDate(certFilePath) {
|
||||
assert.strictEqual(typeof certFilePath, 'string');
|
||||
|
||||
if (!fs.existsSync(certFilePath)) return null; // not found
|
||||
|
||||
const result = safe.child_process.spawnSync('/usr/bin/openssl', [ 'x509', '-enddate', '-noout', '-in', certFilePath ]);
|
||||
if (!result) return null; // some error
|
||||
|
||||
const notAfter = result.stdout.toString('utf8').trim().split('=')[1];
|
||||
const notAfter = lines[1].split('=')[1];
|
||||
const notAfterDate = new Date(notAfter);
|
||||
|
||||
const daysLeft = (notAfterDate - new Date())/(24 * 60 * 60 * 1000);
|
||||
debug(`expiryDate: ${certFilePath} notAfter=${notAfter} daysLeft=${daysLeft}`);
|
||||
debug(`expiryDate: ${lines[2]} notBefore=${notBefore} notAfter=${notAfter} daysLeft=${daysLeft}`);
|
||||
|
||||
return notAfterDate;
|
||||
return { startDate: notBeforeDate, endDate: notAfterDate };
|
||||
}
|
||||
|
||||
async function isOcspEnabled(certFilePath) {
|
||||
@@ -110,14 +95,11 @@ async function isOcspEnabled(certFilePath) {
|
||||
}
|
||||
|
||||
// checks if the certificate matches the options provided by user (like wildcard, le-staging etc)
|
||||
function providerMatchesSync(domainObject, certFilePath, apiOptions) {
|
||||
function providerMatchesSync(domainObject, cert) {
|
||||
assert.strictEqual(typeof domainObject, 'object');
|
||||
assert.strictEqual(typeof certFilePath, 'string');
|
||||
assert.strictEqual(typeof apiOptions, 'object');
|
||||
assert.strictEqual(typeof cert, 'string');
|
||||
|
||||
if (!fs.existsSync(certFilePath)) return false; // not found
|
||||
|
||||
const subjectAndIssuer = safe.child_process.execSync(`/usr/bin/openssl x509 -noout -subject -issuer -in "${certFilePath}"`, { encoding: 'utf8' });
|
||||
const subjectAndIssuer = safe.child_process.execSync('/usr/bin/openssl x509 -noout -subject -issuer', { encoding: 'utf8', input: cert });
|
||||
if (!subjectAndIssuer) return false; // something bad happenned
|
||||
|
||||
const subject = subjectAndIssuer.match(/^subject=(.*)$/m)[1];
|
||||
@@ -126,14 +108,17 @@ function providerMatchesSync(domainObject, certFilePath, apiOptions) {
|
||||
const isWildcardCert = domain.includes('*');
|
||||
const isLetsEncryptProd = issuer.includes('Let\'s Encrypt') && !issuer.includes('STAGING');
|
||||
|
||||
const issuerMismatch = (apiOptions.prod && !isLetsEncryptProd) || (!apiOptions.prod && isLetsEncryptProd);
|
||||
const prod = domainObject.tlsConfig.provider.match(/.*-prod/) !== null; // matches 'le-prod' or 'letsencrypt-prod'
|
||||
const wildcard = !!domainObject.tlsConfig.wildcard;
|
||||
|
||||
const issuerMismatch = (prod && !isLetsEncryptProd) || (!prod && isLetsEncryptProd);
|
||||
// bare domain is not part of wildcard SAN
|
||||
const wildcardMismatch = (domain !== domainObject.domain) && (apiOptions.wildcard && !isWildcardCert) || (!apiOptions.wildcard && isWildcardCert);
|
||||
const wildcardMismatch = (domain !== domainObject.domain) && (wildcard && !isWildcardCert) || (!wildcard && isWildcardCert);
|
||||
|
||||
const mismatch = issuerMismatch || wildcardMismatch;
|
||||
|
||||
debug(`providerMatchesSync: ${certFilePath} subject=${subject} domain=${domain} issuer=${issuer} `
|
||||
+ `wildcard=${isWildcardCert}/${apiOptions.wildcard} prod=${isLetsEncryptProd}/${apiOptions.prod} `
|
||||
debug(`providerMatchesSync: subject=${subject} domain=${domain} issuer=${issuer} `
|
||||
+ `wildcard=${isWildcardCert}/${wildcard} prod=${isLetsEncryptProd}/${prod} `
|
||||
+ `issuerMismatch=${issuerMismatch} wildcardMismatch=${wildcardMismatch} match=${!mismatch}`);
|
||||
|
||||
return !mismatch;
|
||||
@@ -141,9 +126,9 @@ function providerMatchesSync(domainObject, certFilePath, apiOptions) {
|
||||
|
||||
// note: https://tools.ietf.org/html/rfc4346#section-7.4.2 (certificate_list) requires that the
|
||||
// servers certificate appears first (and not the intermediate cert)
|
||||
function validateCertificate(subdomain, domainObject, certificate) {
|
||||
function validateCertificate(subdomain, domain, certificate) {
|
||||
assert.strictEqual(typeof subdomain, 'string');
|
||||
assert.strictEqual(typeof domainObject, 'object');
|
||||
assert.strictEqual(typeof domain, 'string');
|
||||
assert(certificate && typeof certificate, 'object');
|
||||
|
||||
const { cert, key } = certificate;
|
||||
@@ -153,7 +138,7 @@ function validateCertificate(subdomain, domainObject, certificate) {
|
||||
if (cert && !key) return new BoxError(BoxError.BAD_FIELD, 'missing key');
|
||||
|
||||
// -checkhost checks for SAN or CN exclusively. SAN takes precedence and if present, ignores the CN.
|
||||
const fqdn = dns.fqdn(subdomain, domainObject);
|
||||
const fqdn = dns.fqdn(subdomain, domain);
|
||||
|
||||
let result = safe.child_process.execSync(`openssl x509 -noout -checkhost "${fqdn}"`, { encoding: 'utf8', input: cert });
|
||||
if (result === null) return new BoxError(BoxError.BAD_FIELD, 'Unable to get certificate subject:' + safe.error.message);
|
||||
@@ -176,6 +161,15 @@ function validateCertificate(subdomain, domainObject, certificate) {
|
||||
return null;
|
||||
}
|
||||
|
||||
async function notifyCertChange() {
|
||||
await mail.checkCertificate();
|
||||
await shell.promises.sudo('notifyCertChange', [ RESTART_SERVICE_CMD, 'box' ], {}); // directory server
|
||||
const allApps = (await apps.list()).filter(app => app.runState !== apps.RSTATE_STOPPED);
|
||||
for (const app of allApps) {
|
||||
if (app.manifest.addons?.tls) await setupTlsAddon(app);
|
||||
}
|
||||
}
|
||||
|
||||
async function reload() {
|
||||
if (constants.TEST) return;
|
||||
|
||||
@@ -224,8 +218,8 @@ async function setFallbackCertificate(domain, certificate) {
|
||||
if (!safe.fs.writeFileSync(path.join(paths.NGINX_CERT_DIR, `${domain}.host.cert`), certificate.cert)) throw new BoxError(BoxError.FS_ERROR, safe.error.message);
|
||||
if (!safe.fs.writeFileSync(path.join(paths.NGINX_CERT_DIR, `${domain}.host.key`), certificate.key)) throw new BoxError(BoxError.FS_ERROR, safe.error.message);
|
||||
|
||||
// TODO: maybe the cert is being used by the mail container
|
||||
await reload();
|
||||
await notifyCertChange(); // if domain uses fallback certs, propagate immediately
|
||||
}
|
||||
|
||||
async function restoreFallbackCertificates() {
|
||||
@@ -237,182 +231,212 @@ async function restoreFallbackCertificates() {
|
||||
}
|
||||
}
|
||||
|
||||
function getFallbackCertificatePathSync(domain) {
|
||||
assert.strictEqual(typeof domain, 'string');
|
||||
function getAppLocationsSync(app) {
|
||||
assert.strictEqual(typeof app, 'object');
|
||||
|
||||
const certFilePath = path.join(paths.NGINX_CERT_DIR, `${domain}.host.cert`);
|
||||
const keyFilePath = path.join(paths.NGINX_CERT_DIR, `${domain}.host.key`);
|
||||
|
||||
return { certFilePath, keyFilePath };
|
||||
return [{ domain: app.domain, fqdn: app.fqdn, certificate: app.certificate, type: apps.LOCATION_TYPE_PRIMARY }]
|
||||
.concat(app.secondaryDomains.map(sd => { return { domain: sd.domain, certificate: sd.certificate, fqdn: sd.fqdn, type: apps.LOCATION_TYPE_SECONDARY }; }))
|
||||
.concat(app.redirectDomains.map(rd => { return { domain: rd.domain, certificate: rd.certificate, fqdn: rd.fqdn, type: apps.LOCATION_TYPE_REDIRECT }; }))
|
||||
.concat(app.aliasDomains.map(ad => { return { domain: ad.domain, certificate: ad.certificate, fqdn: ad.fqdn, type: apps.LOCATION_TYPE_ALIAS }; }));
|
||||
}
|
||||
|
||||
function getUserCertificatePathSync(fqdn) {
|
||||
assert.strictEqual(typeof fqdn, 'string');
|
||||
|
||||
const certFilePath = path.join(paths.NGINX_CERT_DIR, `${fqdn}.user.cert`);
|
||||
const keyFilePath = path.join(paths.NGINX_CERT_DIR, `${fqdn}.user.key`);
|
||||
|
||||
return { certFilePath, keyFilePath };
|
||||
}
|
||||
|
||||
function getAcmeCertificatePathSync(fqdn, domainObject) {
|
||||
function getAcmeCertificateNameSync(fqdn, domainObject) {
|
||||
assert.strictEqual(typeof fqdn, 'string'); // this can contain wildcard domain (for alias domains)
|
||||
assert.strictEqual(typeof domainObject, 'object');
|
||||
|
||||
let certName, certFilePath, keyFilePath, csrFilePath, acmeChallengesDir = paths.ACME_CHALLENGES_DIR;
|
||||
|
||||
if (fqdn !== domainObject.domain && domainObject.tlsConfig.wildcard) { // bare domain is not part of wildcard SAN
|
||||
certName = dns.makeWildcard(fqdn).replace('*.', '_.');
|
||||
certFilePath = path.join(paths.NGINX_CERT_DIR, `${certName}.cert`);
|
||||
keyFilePath = path.join(paths.NGINX_CERT_DIR, `${certName}.key`);
|
||||
csrFilePath = path.join(paths.NGINX_CERT_DIR, `${certName}.csr`);
|
||||
return dns.makeWildcard(fqdn).replace('*.', '_.');
|
||||
} else if (fqdn.includes('*')) { // alias domain with non-wildcard cert
|
||||
return fqdn.replace('*.', '_.');
|
||||
} else {
|
||||
certName = fqdn;
|
||||
certFilePath = path.join(paths.NGINX_CERT_DIR, `${fqdn}.cert`);
|
||||
keyFilePath = path.join(paths.NGINX_CERT_DIR, `${fqdn}.key`);
|
||||
csrFilePath = path.join(paths.NGINX_CERT_DIR, `${fqdn}.csr`);
|
||||
return fqdn;
|
||||
}
|
||||
}
|
||||
|
||||
function needsRenewalSync(cert, options) {
|
||||
assert.strictEqual(typeof cert, 'string');
|
||||
assert.strictEqual(typeof options, 'object');
|
||||
|
||||
const { startDate, endDate } = getCertificateDatesSync(cert);
|
||||
const now = new Date();
|
||||
|
||||
let isExpiring;
|
||||
if (options.forceRenewal) {
|
||||
isExpiring = (now - startDate) > (65 * 60 * 1000); // was renewed 5 minutes ago. LE backdates issue date by 1 hour for clock skew
|
||||
} else {
|
||||
isExpiring = (endDate - now) <= (30 * 24 * 60 * 60 * 1000); // expiring in a month
|
||||
}
|
||||
|
||||
return { certName, certFilePath, keyFilePath, csrFilePath, acmeChallengesDir };
|
||||
debug(`needsRenewal: ${isExpiring}. force: ${!!options.forceRenewal}`);
|
||||
return isExpiring;
|
||||
}
|
||||
|
||||
async function getCertificatePath(fqdn, domain) {
|
||||
assert.strictEqual(typeof fqdn, 'string');
|
||||
assert.strictEqual(typeof domain, 'string');
|
||||
|
||||
// 1. user cert always wins
|
||||
// 2. if using fallback provider, return that cert
|
||||
// 3. look for LE certs
|
||||
async function getCertificate(location) {
|
||||
assert.strictEqual(typeof location, 'object');
|
||||
|
||||
const { domain, fqdn } = location;
|
||||
const domainObject = await domains.get(domain);
|
||||
if (!domainObject) throw new BoxError(BoxError.NOT_FOUND, `${domain} not found`);
|
||||
|
||||
const userPath = getUserCertificatePathSync(fqdn); // user cert always wins
|
||||
if (fs.existsSync(userPath.certFilePath) && fs.existsSync(userPath.keyFilePath)) return userPath;
|
||||
if (location.certificate) return location.certificate;
|
||||
if (domainObject.tlsConfig.provider === 'fallback') return domainObject.fallbackCertificate;
|
||||
|
||||
if (domainObject.tlsConfig.provider === 'fallback') return getFallbackCertificatePathSync(domain);
|
||||
const certName = getAcmeCertificateNameSync(fqdn, domainObject);
|
||||
const cert = await blobs.getString(`${blobs.CERT_PREFIX}-${certName}.cert`);
|
||||
const key = await blobs.getString(`${blobs.CERT_PREFIX}-${certName}.key`);
|
||||
if (!key || !cert) return domainObject.fallbackCertificate;
|
||||
|
||||
const acmePath = getAcmeCertificatePathSync(fqdn, domainObject);
|
||||
if (fs.existsSync(acmePath.certFilePath) && fs.existsSync(acmePath.keyFilePath)) return acmePath;
|
||||
|
||||
return getFallbackCertificatePathSync(domain);
|
||||
return { key, cert };
|
||||
}
|
||||
|
||||
async function syncUserCertificate(fqdn, domainObject) {
|
||||
assert.strictEqual(typeof fqdn, 'string'); // this can contain wildcard domain (for alias domains)
|
||||
assert.strictEqual(typeof domainObject, 'object');
|
||||
|
||||
const subdomain = fqdn.substr(0, fqdn.length - domainObject.domain.length - 1);
|
||||
const userCertificate = await apps.getCertificate(subdomain, domainObject.domain);
|
||||
if (!userCertificate) return null;
|
||||
|
||||
const { certFilePath, keyFilePath } = getUserCertificatePathSync(fqdn);
|
||||
|
||||
if (!safe.fs.writeFileSync(certFilePath, userCertificate.cert)) throw new BoxError(BoxError.FS_ERROR, `Failed to write certificate: ${safe.error.message}`);
|
||||
if (!safe.fs.writeFileSync(keyFilePath, userCertificate.key)) throw new BoxError(BoxError.FS_ERROR, `Failed to write key: ${safe.error.message}`);
|
||||
|
||||
return { certFilePath, keyFilePath };
|
||||
async function getMailCertificate() {
|
||||
return await getCertificate({ domain: settings.mailDomain(), fqdn: settings.mailFqdn(), certificate: null, type: apps.LOCATION_TYPE_MAIL });
|
||||
}
|
||||
|
||||
async function syncAcmeCertificate(fqdn, domainObject) {
|
||||
assert.strictEqual(typeof fqdn, 'string'); // this can contain wildcard domain (for alias domains)
|
||||
assert.strictEqual(typeof domainObject, 'object');
|
||||
|
||||
const { certName, certFilePath, keyFilePath, csrFilePath } = getAcmeCertificatePathSync(fqdn, domainObject);
|
||||
|
||||
const privateKey = await blobs.get(`${blobs.CERT_PREFIX}-${certName}.key`);
|
||||
const cert = await blobs.get(`${blobs.CERT_PREFIX}-${certName}.cert`);
|
||||
const csr = await blobs.get(`${blobs.CERT_PREFIX}-${certName}.csr`);
|
||||
|
||||
if (!privateKey || !cert) return null;
|
||||
|
||||
if (!safe.fs.writeFileSync(keyFilePath, privateKey)) throw new BoxError(BoxError.FS_ERROR, `Failed to write private key: ${safe.error.message}`);
|
||||
if (!safe.fs.writeFileSync(certFilePath, cert)) throw new BoxError(BoxError.FS_ERROR, `Failed to write certificate: ${safe.error.message}`);
|
||||
|
||||
if (csr) safe.fs.writeFileSync(csrFilePath, csr);
|
||||
|
||||
return { certFilePath, keyFilePath };
|
||||
async function getDirectoryServerCertificate() {
|
||||
return await getCertificate({ domain: settings.dashboardDomain(), fqdn: settings.dashboardFqdn(), certificate: null, type: apps.LOCATION_TYPE_DIRECTORY_SERVER });
|
||||
}
|
||||
|
||||
async function updateCertBlobs(fqdn, domainObject) {
|
||||
assert.strictEqual(typeof fqdn, 'string'); // this can contain wildcard domain (for alias domains)
|
||||
assert.strictEqual(typeof domainObject, 'object');
|
||||
// write if contents mismatch (thus preserving mtime)
|
||||
function writeFileSync(filePath, data) {
|
||||
assert.strictEqual(typeof filePath, 'string');
|
||||
assert.strictEqual(typeof data, 'string');
|
||||
|
||||
const { certName, certFilePath, keyFilePath, csrFilePath } = getAcmeCertificatePathSync(fqdn, domainObject);
|
||||
|
||||
const privateKey = safe.fs.readFileSync(keyFilePath);
|
||||
if (!privateKey) throw new BoxError(BoxError.FS_ERROR, `Failed to read private key: ${safe.error.message}`);
|
||||
|
||||
const cert = safe.fs.readFileSync(certFilePath);
|
||||
if (!cert) throw new BoxError(BoxError.FS_ERROR, `Failed to read cert: ${safe.error.message}`);
|
||||
|
||||
const csr = safe.fs.readFileSync(csrFilePath);
|
||||
if (!csr) throw new BoxError(BoxError.FS_ERROR, `Failed to read csr: ${safe.error.message}`);
|
||||
|
||||
await blobs.set(`${blobs.CERT_PREFIX}-${certName}.key`, privateKey);
|
||||
await blobs.set(`${blobs.CERT_PREFIX}-${certName}.cert`, cert);
|
||||
await blobs.set(`${blobs.CERT_PREFIX}-${certName}.csr`, csr);
|
||||
const curData = safe.fs.readFileSync(filePath, { encoding: 'utf8' });
|
||||
if (curData === data) return false;
|
||||
if (!safe.fs.writeFileSync(filePath, data)) throw new BoxError(BoxError.FS_ERROR, safe.error.message);
|
||||
return true;
|
||||
}
|
||||
|
||||
async function ensureCertificate(subdomain, domain, auditSource) {
|
||||
assert.strictEqual(typeof subdomain, 'string');
|
||||
assert.strictEqual(typeof domain, 'string');
|
||||
assert.strictEqual(typeof auditSource, 'object');
|
||||
async function setupTlsAddon(app) {
|
||||
assert.strictEqual(typeof app, 'object');
|
||||
|
||||
const certificateDir = `${paths.PLATFORM_DATA_DIR}/tls/${app.id}`;
|
||||
const contents = [];
|
||||
for (const location of getAppLocationsSync(app)) {
|
||||
const certificate = await getCertificate(location);
|
||||
contents.push({ filename: `${location.fqdn}.cert`, data: certificate.cert });
|
||||
contents.push({ filename: `${location.fqdn}.key`, data: certificate.key });
|
||||
|
||||
if (location.type === apps.LOCATION_TYPE_PRIMARY) { // backward compat
|
||||
contents.push({ filename: 'tls_cert.pem', data: certificate.cert });
|
||||
contents.push({ filename: 'tls_key.pem', data: certificate.key });
|
||||
}
|
||||
}
|
||||
|
||||
let changed = 0;
|
||||
for (const content of contents) {
|
||||
if (writeFileSync(`${certificateDir}/${content.filename}`, content.data)) ++changed;
|
||||
}
|
||||
debug(`setupTlsAddon: ${changed} files changed`);
|
||||
|
||||
// clean up any certs of old locations
|
||||
const filenamesInUse = new Set(contents.map(c => c.filename));
|
||||
const filenames = safe.fs.readdirSync(certificateDir) || [];
|
||||
let removed = 0;
|
||||
for (const filename of filenames) {
|
||||
if (filenamesInUse.has(filename)) continue;
|
||||
safe.fs.unlinkSync(path.join(certificateDir, filename));
|
||||
++removed;
|
||||
}
|
||||
debug(`setupTlsAddon: ${removed} files removed`);
|
||||
|
||||
if (changed || removed) await docker.restartContainer(app.id);
|
||||
}
|
||||
|
||||
// writes latest certificate to disk and returns the path
|
||||
async function writeCertificate(location) {
|
||||
assert.strictEqual(typeof location, 'object');
|
||||
|
||||
const { domain, fqdn } = location;
|
||||
const domainObject = await domains.get(domain);
|
||||
if (!domainObject) throw new BoxError(BoxError.NOT_FOUND, `${domain} not found`);
|
||||
|
||||
const userCertificatePath = await syncUserCertificate(subdomain, domainObject);
|
||||
if (userCertificatePath) return { certificatePath: userCertificatePath, renewed: false };
|
||||
if (location.certificate) {
|
||||
const certFilePath = path.join(paths.NGINX_CERT_DIR, `${fqdn}.user.cert`);
|
||||
const keyFilePath = path.join(paths.NGINX_CERT_DIR, `${fqdn}.user.key`);
|
||||
|
||||
writeFileSync(certFilePath, location.certificate.cert);
|
||||
writeFileSync(keyFilePath, location.certificate.key);
|
||||
|
||||
return { certFilePath, keyFilePath };
|
||||
}
|
||||
|
||||
if (domainObject.tlsConfig.provider === 'fallback') {
|
||||
debug(`ensureCertificate: ${subdomain} will use fallback certs`);
|
||||
const certFilePath = path.join(paths.NGINX_CERT_DIR, `${domain}.host.cert`);
|
||||
const keyFilePath = path.join(paths.NGINX_CERT_DIR, `${domain}.host.key`);
|
||||
|
||||
return { certificatePath: getFallbackCertificatePathSync(domain), renewed: false };
|
||||
debug(`writeCertificate: ${fqdn} will use fallback certs`);
|
||||
writeFileSync(certFilePath, domainObject.fallbackCertificate.cert);
|
||||
writeFileSync(keyFilePath, domainObject.fallbackCertificate.key);
|
||||
|
||||
return { certFilePath, keyFilePath };
|
||||
}
|
||||
|
||||
const { acme2, apiOptions } = await getAcmeApi(domainObject);
|
||||
let notAfter = null;
|
||||
const certName = getAcmeCertificateNameSync(fqdn, domainObject);
|
||||
let cert = await blobs.getString(`${blobs.CERT_PREFIX}-${certName}.cert`);
|
||||
let key = await blobs.getString(`${blobs.CERT_PREFIX}-${certName}.key`);
|
||||
|
||||
const [, acmeCertificatePath] = await safe(syncAcmeCertificate(subdomain, domainObject));
|
||||
if (acmeCertificatePath) {
|
||||
debug(`ensureCertificate: ${subdomain} certificate already exists at ${acmeCertificatePath.keyFilePath}`);
|
||||
notAfter = getExpiryDate(acmeCertificatePath.certFilePath);
|
||||
const isExpiring = (notAfter - new Date()) <= (30 * 24 * 60 * 60 * 1000); // expiring in a month
|
||||
if (!isExpiring && providerMatchesSync(domainObject, acmeCertificatePath.certFilePath, apiOptions)) return { certificatePath: acmeCertificatePath, renewed: false };
|
||||
debug(`ensureCertificate: ${subdomain} cert requires renewal`);
|
||||
} else {
|
||||
debug(`ensureCertificate: ${subdomain} cert does not exist`);
|
||||
if (!key || !cert) { // use fallback certs if we didn't manage to get acme certs
|
||||
debug(`writeCertificate: ${fqdn} will use fallback certs because acme is missing`);
|
||||
cert = domainObject.fallbackCertificate.cert;
|
||||
key = domainObject.fallbackCertificate.key;
|
||||
}
|
||||
const certFilePath = path.join(paths.NGINX_CERT_DIR, `${certName}.cert`);
|
||||
const keyFilePath = path.join(paths.NGINX_CERT_DIR, `${certName}.key`);
|
||||
|
||||
debug(`ensureCertificate: getting certificate for ${subdomain} with options ${JSON.stringify(apiOptions)}`);
|
||||
writeFileSync(certFilePath, cert);
|
||||
writeFileSync(keyFilePath, key);
|
||||
|
||||
const acmePaths = getAcmeCertificatePathSync(subdomain, domainObject);
|
||||
const [error] = await safe(acme2.getCertificate(subdomain, domain, acmePaths, apiOptions));
|
||||
debug(`ensureCertificate: error: ${error ? error.message : 'null'} cert: ${acmePaths.certFilePath || 'null'}`);
|
||||
|
||||
await safe(eventlog.add(acmeCertificatePath ? eventlog.ACTION_CERTIFICATE_RENEWAL : eventlog.ACTION_CERTIFICATE_NEW, auditSource, { domain: subdomain, errorMessage: error ? error.message : '', notAfter }));
|
||||
|
||||
if (error && acmeCertificatePath && (notAfter - new Date() > 0)) { // still some life left in this certificate
|
||||
debug('ensureCertificate: continue using existing certificate since renewal failed');
|
||||
return { certificatePath: acmeCertificatePath, renewed: false };
|
||||
}
|
||||
|
||||
if (!error) {
|
||||
const [updateCertError] = await safe(updateCertBlobs(subdomain, domainObject));
|
||||
if (!updateCertError) return { certificatePath: { certFilePath: acmePaths.certFilePath, keyFilePath: acmePaths.keyFilePath }, renewed: true };
|
||||
}
|
||||
|
||||
debug(`ensureCertificate: renewal of ${subdomain} failed. using fallback certificates for ${domain}`);
|
||||
|
||||
return { certificatePath: getFallbackCertificatePathSync(domain), renewed: false };
|
||||
return { certFilePath, keyFilePath };
|
||||
}
|
||||
|
||||
async function writeDashboardNginxConfig(fqdn, certificatePath) {
|
||||
assert.strictEqual(typeof fqdn, 'string');
|
||||
async function ensureCertificate(location, options, auditSource) {
|
||||
assert.strictEqual(typeof location, 'object');
|
||||
assert.strictEqual(typeof options, 'object');
|
||||
assert.strictEqual(typeof auditSource, 'object');
|
||||
|
||||
const domainObject = await domains.get(location.domain);
|
||||
if (!domainObject) throw new BoxError(BoxError.NOT_FOUND, `${location.domain} not found`);
|
||||
|
||||
const fqdn = location.fqdn;
|
||||
|
||||
if (location.certificate) { // user certificate
|
||||
debug(`ensureCertificate: ${fqdn} will use user certs`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (domainObject.tlsConfig.provider === 'fallback') {
|
||||
debug(`ensureCertificate: ${fqdn} will use fallback certs`);
|
||||
return;
|
||||
}
|
||||
|
||||
const certName = getAcmeCertificateNameSync(fqdn, domainObject);
|
||||
const key = await blobs.getString(`${blobs.CERT_PREFIX}-${certName}.key`);
|
||||
const cert = await blobs.getString(`${blobs.CERT_PREFIX}-${certName}.cert`);
|
||||
|
||||
if (key && cert) {
|
||||
if (providerMatchesSync(domainObject, cert) && !needsRenewalSync(cert, options)) {
|
||||
debug(`ensureCertificate: ${fqdn} acme cert exists and is up to date`);
|
||||
return;
|
||||
}
|
||||
debug(`ensureCertificate: ${fqdn} acme cert exists but provider mismatch or needs renewal`);
|
||||
}
|
||||
|
||||
debug(`ensureCertificate: ${fqdn} needs acme cert`);
|
||||
const [error] = await safe(acme2.getCertificate(fqdn, domainObject));
|
||||
debug(`ensureCertificate: error: ${error ? error.message : 'null'}`);
|
||||
|
||||
await safe(eventlog.add(eventlog.ACTION_CERTIFICATE_NEW, auditSource, { domain: fqdn, errorMessage: error?.message || '' }));
|
||||
}
|
||||
|
||||
async function writeDashboardNginxConfig(vhost, certificatePath) {
|
||||
assert.strictEqual(typeof vhost, 'string');
|
||||
assert.strictEqual(typeof certificatePath, 'object');
|
||||
|
||||
const data = {
|
||||
sourceDir: path.resolve(__dirname, '..'),
|
||||
vhost: fqdn,
|
||||
vhost,
|
||||
hasIPv6: sysinfo.hasIPv6(),
|
||||
endpoint: 'dashboard',
|
||||
certFilePath: certificatePath.certFilePath,
|
||||
@@ -422,51 +446,34 @@ async function writeDashboardNginxConfig(fqdn, certificatePath) {
|
||||
ocsp: await isOcspEnabled(certificatePath.certFilePath)
|
||||
};
|
||||
const nginxConf = ejs.render(NGINX_APPCONFIG_EJS, data);
|
||||
const nginxConfigFilename = path.join(paths.NGINX_APPCONFIG_DIR, `${fqdn}.conf`);
|
||||
const nginxConfigFilename = path.join(paths.NGINX_APPCONFIG_DIR, `dashboard/${vhost}.conf`);
|
||||
|
||||
if (!safe.fs.writeFileSync(nginxConfigFilename, nginxConf)) throw new BoxError(BoxError.FS_ERROR, safe.error);
|
||||
writeFileSync(nginxConfigFilename, nginxConf);
|
||||
}
|
||||
|
||||
// also syncs the certs to disk
|
||||
async function writeDashboardConfig(domain) {
|
||||
assert.strictEqual(typeof domain, 'string');
|
||||
|
||||
debug(`writeDashboardConfig: writing admin config for ${domain}`);
|
||||
|
||||
const dashboardFqdn = dns.fqdn(constants.DASHBOARD_SUBDOMAIN, domain);
|
||||
const location = { domain, fqdn: dashboardFqdn, certificate: null };
|
||||
const certificatePath = await writeCertificate(location);
|
||||
await writeDashboardNginxConfig(dashboardFqdn, certificatePath);
|
||||
await reload();
|
||||
}
|
||||
|
||||
async function writeDashboardConfig(domainObject) {
|
||||
assert.strictEqual(typeof domainObject, 'object');
|
||||
|
||||
debug(`writeDashboardConfig: writing admin config for ${domainObject.domain}`);
|
||||
|
||||
const dashboardFqdn = dns.fqdn(constants.DASHBOARD_SUBDOMAIN, domainObject);
|
||||
const certificatePath = await getCertificatePath(dashboardFqdn, domainObject.domain);
|
||||
|
||||
await writeDashboardNginxConfig(dashboardFqdn, certificatePath);
|
||||
}
|
||||
|
||||
function getNginxConfigFilename(app, fqdn, type) {
|
||||
async function writeAppLocationNginxConfig(app, location, certificatePath) {
|
||||
assert.strictEqual(typeof app, 'object');
|
||||
assert.strictEqual(typeof fqdn, 'string');
|
||||
assert.strictEqual(typeof type, 'string');
|
||||
|
||||
let nginxConfigFilenameSuffix = '';
|
||||
|
||||
if (type === apps.LOCATION_TYPE_ALIAS) {
|
||||
nginxConfigFilenameSuffix = `-alias-${fqdn.replace('*', '_')}`;
|
||||
} else if (type === apps.LOCATION_TYPE_SECONDARY) {
|
||||
nginxConfigFilenameSuffix = `-secondary-${fqdn}`;
|
||||
} else if (type === apps.LOCATION_TYPE_REDIRECT) {
|
||||
nginxConfigFilenameSuffix = `-redirect-${fqdn}`;
|
||||
}
|
||||
|
||||
return path.join(paths.NGINX_APPCONFIG_DIR, `${app.id}${nginxConfigFilenameSuffix}.conf`);
|
||||
}
|
||||
|
||||
async function writeAppNginxConfig(app, fqdn, type, certificatePath) {
|
||||
assert.strictEqual(typeof app, 'object');
|
||||
assert.strictEqual(typeof fqdn, 'string');
|
||||
assert.strictEqual(typeof type, 'string');
|
||||
assert.strictEqual(typeof location, 'object');
|
||||
assert.strictEqual(typeof certificatePath, 'object');
|
||||
|
||||
const type = location.type, vhost = location.fqdn;
|
||||
|
||||
const data = {
|
||||
sourceDir: path.resolve(__dirname, '..'),
|
||||
vhost: fqdn,
|
||||
vhost,
|
||||
hasIPv6: sysinfo.hasIPv6(),
|
||||
ip: null,
|
||||
port: null,
|
||||
@@ -487,10 +494,6 @@ async function writeAppNginxConfig(app, fqdn, type, certificatePath) {
|
||||
|
||||
if (app.manifest.id === constants.PROXY_APP_APPSTORE_ID) {
|
||||
data.endpoint = 'external';
|
||||
|
||||
// prevent generating invalid nginx configs
|
||||
if (!app.upstreamUri) throw new BoxError(BoxError.BAD_FIELD, 'upstreamUri cannot be empty');
|
||||
|
||||
data.upstreamUri = app.upstreamUri;
|
||||
}
|
||||
|
||||
@@ -512,7 +515,7 @@ async function writeAppNginxConfig(app, fqdn, type, certificatePath) {
|
||||
data.port = app.manifest.httpPort;
|
||||
} else if (type === apps.LOCATION_TYPE_SECONDARY) {
|
||||
data.ip = app.containerIp;
|
||||
const secondaryDomain = app.secondaryDomains.find(sd => sd.fqdn === fqdn);
|
||||
const secondaryDomain = app.secondaryDomains.find(sd => sd.fqdn === vhost);
|
||||
data.port = app.manifest.httpPorts[secondaryDomain.environmentVariable].containerPort;
|
||||
}
|
||||
} else if (type === apps.LOCATION_TYPE_REDIRECT) {
|
||||
@@ -522,178 +525,98 @@ async function writeAppNginxConfig(app, fqdn, type, certificatePath) {
|
||||
}
|
||||
|
||||
const nginxConf = ejs.render(NGINX_APPCONFIG_EJS, data);
|
||||
const filename = getNginxConfigFilename(app, fqdn, type);
|
||||
debug(`writeAppNginxConfig: writing config for "${fqdn}" to ${filename} with options ${JSON.stringify(data)}`);
|
||||
if (!safe.fs.writeFileSync(filename, nginxConf)) {
|
||||
debug(`Error creating nginx config for "${app.fqdn}" : ${safe.error.message}`);
|
||||
throw new BoxError(BoxError.FS_ERROR, safe.error);
|
||||
}
|
||||
|
||||
await reload();
|
||||
const filename = path.join(paths.NGINX_APPCONFIG_DIR, app.id, `${vhost.replace('*', '_')}.conf`);
|
||||
debug(`writeAppLocationNginxConfig: writing config for "${vhost}" to ${filename} with options ${JSON.stringify(data)}`);
|
||||
writeFileSync(filename, nginxConf);
|
||||
}
|
||||
|
||||
async function writeAppConfigs(app) {
|
||||
assert.strictEqual(typeof app, 'object');
|
||||
|
||||
const appDomains = [{ domain: app.domain, fqdn: app.fqdn, certificate: app.certificate, type: apps.LOCATION_TYPE_PRIMARY }]
|
||||
.concat(app.secondaryDomains.map(sd => { return { domain: sd.domain, certificate: sd.certificate, fqdn: sd.fqdn, type: apps.LOCATION_TYPE_SECONDARY }; }))
|
||||
.concat(app.redirectDomains.map(rd => { return { domain: rd.domain, certificate: rd.certificate, fqdn: rd.fqdn, type: apps.LOCATION_TYPE_REDIRECT }; }))
|
||||
.concat(app.aliasDomains.map(ad => { return { domain: ad.domain, certificate: ad.certificate, fqdn: ad.fqdn, type: apps.LOCATION_TYPE_ALIAS }; }));
|
||||
const locations = getAppLocationsSync(app);
|
||||
|
||||
for (const appDomain of appDomains) {
|
||||
const certificatePath = await getCertificatePath(appDomain.fqdn, appDomain.domain);
|
||||
await writeAppNginxConfig(app, appDomain.fqdn, appDomain.type, certificatePath);
|
||||
if (!safe.fs.mkdirSync(path.join(paths.NGINX_APPCONFIG_DIR, app.id), { recursive: true })) throw new BoxError(BoxError.FS_ERROR, `Could not create nginx config directory: ${safe.error.message}`);
|
||||
|
||||
for (const location of locations) {
|
||||
const certificatePath = await writeCertificate(location);
|
||||
await writeAppLocationNginxConfig(app, location, certificatePath);
|
||||
}
|
||||
|
||||
await reload();
|
||||
}
|
||||
|
||||
async function setUserCertificate(app, fqdn, certificate) {
|
||||
const { certFilePath, keyFilePath } = getUserCertificatePathSync(fqdn);
|
||||
async function setUserCertificate(app, location) {
|
||||
assert.strictEqual(typeof app, 'object');
|
||||
assert.strictEqual(typeof location, 'object');
|
||||
|
||||
if (certificate !== null) {
|
||||
if (!safe.fs.writeFileSync(certFilePath, certificate.cert)) throw safe.error;
|
||||
if (!safe.fs.writeFileSync(keyFilePath, certificate.key)) throw safe.error;
|
||||
} else { // remove existing cert/key
|
||||
if (!safe.fs.unlinkSync(certFilePath)) debug(`Error removing cert: ${safe.error.message}`);
|
||||
if (!safe.fs.unlinkSync(keyFilePath)) debug(`Error removing key: ${safe.error.message}`);
|
||||
}
|
||||
|
||||
await writeAppConfigs(app);
|
||||
const certificatePath = await writeCertificate(location);
|
||||
await writeAppLocationNginxConfig(app, location, certificatePath);
|
||||
await reload();
|
||||
}
|
||||
|
||||
async function configureApp(app, auditSource) {
|
||||
assert.strictEqual(typeof app, 'object');
|
||||
assert.strictEqual(typeof auditSource, 'object');
|
||||
|
||||
const appDomains = [{ domain: app.domain, fqdn: app.fqdn }]
|
||||
.concat(app.secondaryDomains.map(sd => { return { domain: sd.domain, fqdn: sd.fqdn }; }))
|
||||
.concat(app.redirectDomains.map(rd => { return { domain: rd.domain, fqdn: rd.fqdn }; }))
|
||||
.concat(app.aliasDomains.map(ad => { return { domain: ad.domain, fqdn: ad.fqdn }; }));
|
||||
const locations = getAppLocationsSync(app);
|
||||
|
||||
for (const appDomain of appDomains) {
|
||||
await ensureCertificate(appDomain.fqdn, appDomain.domain, auditSource);
|
||||
for (const location of locations) {
|
||||
await ensureCertificate(location, {}, auditSource);
|
||||
}
|
||||
|
||||
await writeAppConfigs(app);
|
||||
|
||||
if (app.manifest.addons?.tls) await setupTlsAddon(app);
|
||||
}
|
||||
|
||||
async function unconfigureApp(app) {
|
||||
assert.strictEqual(typeof app, 'object');
|
||||
|
||||
const configFilenames = safe.fs.readdirSync(paths.NGINX_APPCONFIG_DIR);
|
||||
if (!configFilenames) throw new BoxError(BoxError.FS_ERROR, `Error loading nginx config files: ${safe.error.message}`);
|
||||
|
||||
for (const filename of configFilenames) {
|
||||
if (!filename.startsWith(app.id)) continue;
|
||||
|
||||
safe.fs.unlinkSync(path.join(paths.NGINX_APPCONFIG_DIR, filename));
|
||||
}
|
||||
|
||||
if (!safe.fs.rmSync(path.join(paths.NGINX_APPCONFIG_DIR, app.id), { recursive: true, force: true })) throw new BoxError(BoxError.FS_ERROR, `Could not remove nginx config directory: ${safe.error.message}`);
|
||||
await reload();
|
||||
}
|
||||
|
||||
async function renewCerts(options, auditSource, progressCallback) {
|
||||
assert.strictEqual(typeof options, 'object');
|
||||
async function cleanupCerts(locations, auditSource, progressCallback) {
|
||||
assert(Array.isArray(locations));
|
||||
assert.strictEqual(typeof auditSource, 'object');
|
||||
assert.strictEqual(typeof progressCallback, 'function');
|
||||
|
||||
const allApps = await apps.list();
|
||||
|
||||
let appDomains = [];
|
||||
|
||||
// add webadmin and mail domain
|
||||
if (settings.mailFqdn() === settings.dashboardFqdn()) {
|
||||
appDomains.push({ domain: settings.dashboardDomain(), fqdn: settings.dashboardFqdn(), type: 'webadmin+mail', nginxConfigFilename: path.join(paths.NGINX_APPCONFIG_DIR, `${settings.dashboardFqdn()}.conf`) });
|
||||
} else {
|
||||
appDomains.push({ domain: settings.dashboardDomain(), fqdn: settings.dashboardFqdn(), type: 'webadmin', nginxConfigFilename: path.join(paths.NGINX_APPCONFIG_DIR, `${settings.dashboardFqdn()}.conf`) });
|
||||
appDomains.push({ domain: settings.mailDomain(), fqdn: settings.mailFqdn(), type: 'mail' });
|
||||
}
|
||||
|
||||
for (const app of allApps) {
|
||||
if (app.runState === apps.RSTATE_STOPPED) continue; // do not renew certs of stopped apps
|
||||
|
||||
appDomains = appDomains.concat([{ app, domain: app.domain, fqdn: app.fqdn, type: apps.LOCATION_TYPE_PRIMARY, nginxConfigFilename: getNginxConfigFilename(app, app.fqdn, apps.LOCATION_TYPE_PRIMARY) }])
|
||||
.concat(app.secondaryDomains.map(sd => { return { app, domain: sd.domain, fqdn: sd.fqdn, type: apps.LOCATION_TYPE_SECONDARY, nginxConfigFilename: getNginxConfigFilename(app, sd.fqdn, apps.LOCATION_TYPE_SECONDARY) }; }))
|
||||
.concat(app.redirectDomains.map(rd => { return { app, domain: rd.domain, fqdn: rd.fqdn, type: apps.LOCATION_TYPE_REDIRECT, nginxConfigFilename: getNginxConfigFilename(app, rd.fqdn, apps.LOCATION_TYPE_REDIRECT) }; }))
|
||||
.concat(app.aliasDomains.map(ad => { return { app, domain: ad.domain, fqdn: ad.fqdn, type: apps.LOCATION_TYPE_ALIAS, nginxConfigFilename: getNginxConfigFilename(app, ad.fqdn, apps.LOCATION_TYPE_ALIAS) }; }));
|
||||
}
|
||||
|
||||
if (options.domain) appDomains = appDomains.filter(function (appDomain) { return appDomain.domain === options.domain; });
|
||||
|
||||
let progress = 1, renewedCerts = [];
|
||||
|
||||
for (const appDomain of appDomains) {
|
||||
progressCallback({ percent: progress, message: `Ensuring certs of ${appDomain.fqdn}` });
|
||||
progress += Math.round(100/appDomains.length);
|
||||
|
||||
const { certificatePath, renewed } = await ensureCertificate(appDomain.fqdn, appDomain.domain, auditSource);
|
||||
|
||||
if (renewed) renewedCerts.push(appDomain.fqdn);
|
||||
|
||||
if (appDomain.type === 'mail') continue; // mail has no nginx config to check current cert
|
||||
|
||||
// hack to check if the app's cert changed or not. this doesn't handle prod/staging le change since they use same file name
|
||||
let currentNginxConfig = safe.fs.readFileSync(appDomain.nginxConfigFilename, 'utf8') || '';
|
||||
if (currentNginxConfig.includes(certificatePath.certFilePath)) continue;
|
||||
|
||||
debug(`renewCerts: creating new nginx config since ${appDomain.nginxConfigFilename} does not have ${certificatePath.certFilePath}`);
|
||||
|
||||
// reconfigure since the cert changed
|
||||
if (appDomain.type === 'webadmin' || appDomain.type === 'webadmin+mail') {
|
||||
await writeDashboardNginxConfig(settings.dashboardFqdn(), certificatePath);
|
||||
} else {
|
||||
await writeAppNginxConfig(appDomain.app, appDomain.fqdn, appDomain.type, certificatePath);
|
||||
}
|
||||
}
|
||||
|
||||
debug(`renewCerts: Renewed certs of ${JSON.stringify(renewedCerts)}`);
|
||||
if (renewedCerts.length === 0) return;
|
||||
|
||||
if (renewedCerts.includes(settings.mailFqdn())) await mail.handleCertChanged();
|
||||
|
||||
await reload(); // reload nginx if any certs were updated but the config was not rewritten
|
||||
|
||||
// restart tls apps on cert change
|
||||
const tlsApps = allApps.filter(app => app.manifest.addons && app.manifest.addons.tls && renewedCerts.includes(app.fqdn));
|
||||
for (const app of tlsApps) {
|
||||
await apps.restart(app, auditSource);
|
||||
}
|
||||
}
|
||||
|
||||
async function cleanupCerts(auditSource, progressCallback) {
|
||||
assert.strictEqual(typeof auditSource, 'object');
|
||||
assert.strictEqual(typeof progressCallback, 'function');
|
||||
|
||||
const filenames = await fs.promises.readdir(paths.NGINX_CERT_DIR);
|
||||
const certFilenames = filenames.filter(f => f.endsWith('.cert'));
|
||||
const now = new Date();
|
||||
|
||||
progressCallback({ message: 'Checking expired certs for removal' });
|
||||
|
||||
const fqdns = [];
|
||||
const domainObjectMap = await domains.getDomainObjectMap();
|
||||
const certNamesInUse = new Set();
|
||||
for (const location of locations) {
|
||||
certNamesInUse.add(await getAcmeCertificateNameSync(location.fqdn, domainObjectMap[location.domain]));
|
||||
}
|
||||
|
||||
for (const certFilename of certFilenames) {
|
||||
const certFilePath = path.join(paths.NGINX_CERT_DIR, certFilename);
|
||||
const notAfter = getExpiryDate(certFilePath);
|
||||
if (!notAfter) continue; // some error
|
||||
const now = new Date();
|
||||
const certIds = await blobs.listCertIds();
|
||||
const removedCertNames = [];
|
||||
for (const certId of certIds) {
|
||||
const certName = certId.match(new RegExp(`${blobs.CERT_PREFIX}-(.*).cert`))[1];
|
||||
if (certNamesInUse.has(certName)) continue;
|
||||
|
||||
if (now - notAfter >= (60 * 60 * 24 * 30 * 6 * 1000)) { // expired 6 months ago
|
||||
const fqdn = certFilename.replace(/\.cert$/, '');
|
||||
progressCallback({ message: `deleting certs of ${fqdn}` });
|
||||
const cert = await blobs.getString(certId);
|
||||
const { endDate } = getCertificateDatesSync(cert);
|
||||
if (!endDate) continue; // some error
|
||||
|
||||
if (now - endDate >= (60 * 60 * 24 * 30 * 6 * 1000)) { // expired 6 months ago and not in use
|
||||
progressCallback({ message: `deleting certs of ${certName}` });
|
||||
|
||||
// it is safe to delete the certs of stopped apps because their nginx configs are removed
|
||||
safe.fs.unlinkSync(certFilePath);
|
||||
safe.fs.unlinkSync(path.join(paths.NGINX_CERT_DIR, `${fqdn}.key`));
|
||||
safe.fs.unlinkSync(path.join(paths.NGINX_CERT_DIR, `${fqdn}.csr`));
|
||||
safe.fs.unlinkSync(path.join(paths.NGINX_CERT_DIR, `${certName}.cert`));
|
||||
safe.fs.unlinkSync(path.join(paths.NGINX_CERT_DIR, `${certName}.key`));
|
||||
safe.fs.unlinkSync(path.join(paths.NGINX_CERT_DIR, `${certName}.csr`));
|
||||
|
||||
await blobs.del(`${blobs.CERT_PREFIX}-${fqdn}.key`);
|
||||
await blobs.del(`${blobs.CERT_PREFIX}-${fqdn}.cert`);
|
||||
await blobs.del(`${blobs.CERT_PREFIX}-${fqdn}.csr`);
|
||||
await blobs.del(`${blobs.CERT_PREFIX}-${certName}.key`);
|
||||
await blobs.del(`${blobs.CERT_PREFIX}-${certName}.cert`);
|
||||
await blobs.del(`${blobs.CERT_PREFIX}-${certName}.csr`);
|
||||
|
||||
fqdns.push(fqdn);
|
||||
removedCertNames.push(certName);
|
||||
}
|
||||
}
|
||||
|
||||
if (fqdns.length) await safe(eventlog.add(eventlog.ACTION_CERTIFICATE_CLEANUP, auditSource, { domains: fqdns }));
|
||||
if (removedCertNames.length) await safe(eventlog.add(eventlog.ACTION_CERTIFICATE_CLEANUP, auditSource, { domains: removedCertNames }));
|
||||
|
||||
debug('cleanupCerts: done');
|
||||
}
|
||||
@@ -703,19 +626,56 @@ async function checkCerts(options, auditSource, progressCallback) {
|
||||
assert.strictEqual(typeof auditSource, 'object');
|
||||
assert.strictEqual(typeof progressCallback, 'function');
|
||||
|
||||
await renewCerts(options, auditSource, progressCallback);
|
||||
await cleanupCerts(auditSource, progressCallback);
|
||||
let locations = [];
|
||||
if (settings.dashboardFqdn() !== settings.mailFqdn()) locations.push({ domain: settings.mailDomain(), fqdn: settings.mailFqdn(), certificate: null, type: apps.LOCATION_TYPE_MAIL });
|
||||
locations.push({ domain: settings.dashboardDomain(), fqdn: settings.dashboardFqdn(), certificate: null, type: apps.LOCATION_TYPE_DASHBOARD });
|
||||
|
||||
const allApps = (await apps.list()).filter(app => app.runState !== apps.RSTATE_STOPPED);
|
||||
for (const app of allApps) {
|
||||
locations = locations.concat(getAppLocationsSync(app));
|
||||
}
|
||||
|
||||
let percent = 1;
|
||||
for (const location of locations) {
|
||||
percent += Math.round(100/locations.length);
|
||||
progressCallback({ percent, message: `Ensuring certs of ${location.fqdn}` });
|
||||
await ensureCertificate(location, options, auditSource);
|
||||
}
|
||||
|
||||
if (options.rebuild || fs.existsSync(paths.REVERSE_PROXY_REBUILD_FILE)) {
|
||||
progressCallback( { message: 'Rebuilding app configs' });
|
||||
for (const app of allApps) {
|
||||
await writeAppConfigs(app);
|
||||
}
|
||||
await writeDashboardConfig(settings.dashboardDomain());
|
||||
await notifyCertChange(); // this allows user to "rebuild" using UI just in case we crashed and went out of sync
|
||||
safe.fs.unlinkSync(paths.REVERSE_PROXY_REBUILD_FILE);
|
||||
} else {
|
||||
// sync all locations and not just the ones that changed. this helps with 0 length certs when disk is full and also
|
||||
// if renewal task crashed midway.
|
||||
for (const location of locations) {
|
||||
await writeCertificate(location);
|
||||
}
|
||||
await reload();
|
||||
await notifyCertChange(); // propagate any cert changes to services
|
||||
}
|
||||
|
||||
await cleanupCerts(locations, auditSource, progressCallback);
|
||||
}
|
||||
|
||||
function removeAppConfigs() {
|
||||
const dashboardConfigFilename = `${settings.dashboardFqdn()}.conf`;
|
||||
|
||||
debug('removeAppConfigs: reomving nginx configs of apps');
|
||||
debug('removeAppConfigs: removing app nginx configs');
|
||||
|
||||
// remove all configs which are not the default or current dashboard
|
||||
for (const appConfigFile of fs.readdirSync(paths.NGINX_APPCONFIG_DIR)) {
|
||||
if (appConfigFile !== constants.NGINX_DEFAULT_CONFIG_FILE_NAME && appConfigFile !== dashboardConfigFilename) {
|
||||
fs.unlinkSync(path.join(paths.NGINX_APPCONFIG_DIR, appConfigFile));
|
||||
for (const entry of fs.readdirSync(paths.NGINX_APPCONFIG_DIR, { withFileTypes: true })) {
|
||||
if (entry.isDirectory() && entry.name === 'dashboard') continue;
|
||||
if (entry.isFile() && entry.name === constants.NGINX_DEFAULT_CONFIG_FILE_NAME) continue;
|
||||
|
||||
const fullPath = path.join(paths.NGINX_APPCONFIG_DIR, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
fs.rmSync(fullPath, { recursive: true, force: true });
|
||||
} else if (entry.isFile()) {
|
||||
fs.unlinkSync(fullPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -757,3 +717,9 @@ async function writeDefaultConfig(options) {
|
||||
|
||||
await reload();
|
||||
}
|
||||
|
||||
async function handleCertificateProviderChanged(domain) {
|
||||
assert.strictEqual(typeof domain, 'string');
|
||||
|
||||
safe.fs.appendFileSync(paths.REVERSE_PROXY_REBUILD_FILE, `${domain}\n`, 'utf8');
|
||||
}
|
||||
|
||||
@@ -56,6 +56,7 @@ exports = module.exports = {
|
||||
downloadFile,
|
||||
|
||||
updateBackup,
|
||||
downloadBackup,
|
||||
|
||||
getLimits,
|
||||
getGraphs,
|
||||
@@ -175,12 +176,10 @@ async function install(req, res, next) {
|
||||
if ('skipDnsSetup' in data && typeof data.skipDnsSetup !== 'boolean') return next(new HttpError(400, 'skipDnsSetup must be boolean'));
|
||||
if ('enableMailbox' in data && typeof data.enableMailbox !== 'boolean') return next(new HttpError(400, 'enableMailbox must be boolean'));
|
||||
|
||||
if ('upstreamUri' in data && (typeof data.upstreamUri !== 'string' || !data.upstreamUri)) return next(new HttpError(400, 'upstreamUri must be a non emptry string'));
|
||||
|
||||
let [error, result] = await safe(apps.downloadManifest(data.appStoreId, data.manifest));
|
||||
if (error) return next(BoxError.toHttpError(error));
|
||||
|
||||
if (result.manifest.appStoreId === constants.PROXY_APP_APPSTORE_ID && (typeof data.upstreamUri !== 'string' || !data.upstreamUri)) return next(new HttpError(400, 'upstreamUri must be a non empty string'));
|
||||
if (result.appStoreId === constants.PROXY_APP_APPSTORE_ID && typeof data.upstreamUri !== 'string') return next(new HttpError(400, 'upstreamUri must be a non empty string'));
|
||||
|
||||
if (safe.query(result.manifest, 'addons.docker') && req.user.role !== users.ROLE_OWNER) return next(new HttpError(403, '"owner" role is required to install app with docker addon'));
|
||||
|
||||
@@ -727,9 +726,11 @@ async function createExec(req, res, next) {
|
||||
if ('tty' in req.body && typeof req.body.tty !== 'boolean') return next(new HttpError(400, 'tty must be boolean'));
|
||||
const tty = !!req.body.tty;
|
||||
|
||||
if ('lang' in req.body && typeof req.body.lang !== 'string') return next(new HttpError(400, 'lang must be a string'));
|
||||
|
||||
if (safe.query(req.app, 'manifest.addons.docker') && req.user.role !== users.ROLE_OWNER) return next(new HttpError(403, '"owner" role is requied to exec app with docker addon'));
|
||||
|
||||
const [error, id] = await safe(apps.createExec(req.app, { cmd, tty }));
|
||||
const [error, id] = await safe(apps.createExec(req.app, { cmd, tty, lang: req.body.lang }));
|
||||
if (error) return next(BoxError.toHttpError(error));
|
||||
|
||||
next(new HttpSuccess(200, { id }));
|
||||
@@ -852,6 +853,17 @@ async function updateBackup(req, res, next) {
|
||||
next(new HttpSuccess(200, {}));
|
||||
}
|
||||
|
||||
async function downloadBackup(req, res, next) {
|
||||
assert.strictEqual(typeof req.app, 'object');
|
||||
assert.strictEqual(typeof req.params.backupId, 'string');
|
||||
|
||||
const [error, result] = await safe(apps.getBackupDownloadStream(req.app, req.params.backupId));
|
||||
if (error) return next(BoxError.toHttpError(error));
|
||||
|
||||
res.attachment(`${req.params.backupId}.tgz`);
|
||||
result.pipe(res);
|
||||
}
|
||||
|
||||
async function uploadFile(req, res, next) {
|
||||
assert.strictEqual(typeof req.app, 'object');
|
||||
|
||||
@@ -910,6 +922,7 @@ async function setUpstreamUri(req, res, next) {
|
||||
assert.strictEqual(typeof req.body, 'object');
|
||||
assert.strictEqual(typeof req.app, 'object');
|
||||
|
||||
if (req.app.appStoreId !== constants.PROXY_APP_APPSTORE_ID) return next(new HttpError(400, 'upstreamUri can only be set for proxy app'));
|
||||
if (typeof req.body.upstreamUri !== 'string') return next(new HttpError(400, 'upstreamUri must be a string'));
|
||||
|
||||
const [error] = await safe(apps.setUpstreamUri(req.app, req.body.upstreamUri, AuditSource.fromRequest(req)));
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
@@ -26,7 +26,8 @@ exports = module.exports = {
|
||||
getLanguages,
|
||||
syncExternalLdap,
|
||||
syncDnsRecords,
|
||||
getSystemGraphs
|
||||
getSystemGraphs,
|
||||
getPlatformStatus
|
||||
};
|
||||
|
||||
const assert = require('assert'),
|
||||
@@ -40,6 +41,7 @@ const assert = require('assert'),
|
||||
graphs = require('../graphs.js'),
|
||||
HttpError = require('connect-lastmile').HttpError,
|
||||
HttpSuccess = require('connect-lastmile').HttpSuccess,
|
||||
platform = require('../platform.js'),
|
||||
safe = require('safetydance'),
|
||||
speakeasy = require('speakeasy'),
|
||||
sysinfo = require('../sysinfo.js'),
|
||||
@@ -87,7 +89,7 @@ async function passwordResetRequest(req, res, next) {
|
||||
if (!req.body.identifier || typeof req.body.identifier !== 'string') return next(new HttpError(401, 'A identifier must be non-empty string'));
|
||||
|
||||
const [error] = await safe(users.sendPasswordResetByIdentifier(req.body.identifier, AuditSource.fromRequest(req)));
|
||||
if (error && error.reason !== BoxError.NOT_FOUND) return next(BoxError.toHttpError(error));
|
||||
if (error && !(error.reason === BoxError.NOT_FOUND || error.reason === BoxError.CONFLICT)) return next(BoxError.toHttpError(error));
|
||||
|
||||
next(new HttpSuccess(202, {}));
|
||||
}
|
||||
@@ -167,10 +169,13 @@ async function getConfig(req, res, next) {
|
||||
}
|
||||
|
||||
async function getDisks(req, res, next) {
|
||||
const [error, result] = await safe(system.getDisks());
|
||||
if (error) return next(BoxError.toHttpError(error));
|
||||
const [getDisksError, disks] = await safe(system.getDisks());
|
||||
if (getDisksError) return next(BoxError.toHttpError(getDisksError));
|
||||
|
||||
next(new HttpSuccess(200, { disks: result }));
|
||||
let [getSwapsError, swaps] = await safe(system.getSwaps());
|
||||
if (getSwapsError) return next(BoxError.toHttpError(getSwapsError));
|
||||
|
||||
next(new HttpSuccess(200, { disks, swaps }));
|
||||
}
|
||||
|
||||
async function getDiskUsage(req, res, next) {
|
||||
@@ -272,7 +277,7 @@ async function getLogStream(req, res, next) {
|
||||
res.on('close', logStream.close);
|
||||
logStream.on('data', function (data) {
|
||||
const obj = JSON.parse(data);
|
||||
res.write(sse(obj.monotonicTimestamp, JSON.stringify(obj))); // send timestamp as id
|
||||
res.write(sse(obj.realtimeTimestamp, JSON.stringify(obj))); // send timestamp as id
|
||||
});
|
||||
logStream.on('end', res.end.bind(res));
|
||||
logStream.on('error', res.end.bind(res, null));
|
||||
@@ -297,9 +302,9 @@ async function prepareDashboardDomain(req, res, next) {
|
||||
}
|
||||
|
||||
async function renewCerts(req, res, next) {
|
||||
if ('domain' in req.body && typeof req.body.domain !== 'string') return next(new HttpError(400, 'domain must be a string'));
|
||||
if ('rebuild' in req.body && typeof req.body.rebuild !== 'boolean') return next(new HttpError(400, 'rebuild must be a boolean'));
|
||||
|
||||
const [error, taskId] = await safe(cloudron.renewCerts({ domain: req.body.domain || null }, AuditSource.fromRequest(req)));
|
||||
const [error, taskId] = await safe(cloudron.renewCerts(req.body, AuditSource.fromRequest(req)));
|
||||
if (error) return next(BoxError.toHttpError(error));
|
||||
|
||||
next(new HttpSuccess(202, { taskId }));
|
||||
@@ -356,3 +361,7 @@ async function getSystemGraphs(req, res, next) {
|
||||
|
||||
next(new HttpSuccess(200, result));
|
||||
}
|
||||
|
||||
async function getPlatformStatus(req, res, next) {
|
||||
next(new HttpSuccess(200, platform.getStatus()));
|
||||
}
|
||||
|
||||
@@ -110,7 +110,7 @@ async function getLogStream(req, res, next) {
|
||||
res.on('close', logStream.close);
|
||||
logStream.on('data', function (data) {
|
||||
const obj = JSON.parse(data);
|
||||
res.write(sse(obj.monotonicTimestamp, JSON.stringify(obj))); // send timestamp as id
|
||||
res.write(sse(obj.realtimeTimestamp, JSON.stringify(obj))); // send timestamp as id
|
||||
});
|
||||
logStream.on('end', res.end.bind(res));
|
||||
logStream.on('error', res.end.bind(res, null));
|
||||
|
||||
@@ -48,7 +48,7 @@ async function createTicket(req, res, next) {
|
||||
if (supportConfig.email !== constants.SUPPORT_EMAIL) return next(new HttpError(503, 'Sending to non-cloudron email not implemented yet'));
|
||||
|
||||
const [ticketError, result] = await safe(appstore.createTicket(_.extend({ }, req.body, { email: req.user.email, displayName: req.user.displayName }), AuditSource.fromRequest(req)));
|
||||
if (ticketError) return next(new HttpError(503, `Error contacting cloudron.io: ${error.message}. Please email ${constants.SUPPORT_EMAIL}`));
|
||||
if (ticketError) return next(new HttpError(503, `Error contacting cloudron.io: ${ticketError.message}. Please email ${constants.SUPPORT_EMAIL}`));
|
||||
|
||||
next(new HttpSuccess(201, result));
|
||||
}
|
||||
|
||||
@@ -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,6 +8,7 @@
|
||||
const constants = require('../../constants.js'),
|
||||
common = require('./common.js'),
|
||||
expect = require('expect.js'),
|
||||
fs = require('fs'),
|
||||
http = require('http'),
|
||||
os = require('os'),
|
||||
paths = require('../../paths.js'),
|
||||
@@ -300,6 +301,11 @@ describe('Cloudron API', function () {
|
||||
});
|
||||
|
||||
describe('logs', function () {
|
||||
before(function () {
|
||||
console.log(paths.BOX_LOG_FILE);
|
||||
fs.writeFileSync(paths.BOX_LOG_FILE, '2022-11-06T15:06:20.009Z box:apphealthmonitor app health: 0 alive / 0 dead.\n', 'utf8');
|
||||
});
|
||||
|
||||
it('logStream - requires event-stream accept header', async function () {
|
||||
const response = await superagent.get(`${serverUrl}/api/v1/cloudron/logstream/box`)
|
||||
.query({ access_token: owner.token, fromLine: 0 })
|
||||
@@ -335,6 +341,7 @@ describe('Cloudron API', function () {
|
||||
|
||||
expect(dataMessageFound).to.be.ok();
|
||||
|
||||
res.destroy();
|
||||
req.destroy();
|
||||
done();
|
||||
}, 1000);
|
||||
|
||||
@@ -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)}`
|
||||
+ ` v8 heap: used=${h(hs.used_heap_size)} total=${h(hs.total_heap_size)} max=${h(hs.heap_size_limit)}`);
|
||||
}
|
||||
|
||||
(async function main() {
|
||||
await database.initialize();
|
||||
await settings.initCache();
|
||||
|
||||
dumpMemoryInfo();
|
||||
const timerId = setInterval(dumpMemoryInfo, 180 * 1000);
|
||||
|
||||
const [uploadError] = await safe(backuptask.upload(remotePath, format, dataLayoutString, throttledProgressCallback(5000)));
|
||||
debug('upload completed. error: ', uploadError);
|
||||
|
||||
process.send({ result: uploadError ? uploadError.message : '' });
|
||||
clearInterval(timerId);
|
||||
|
||||
// https://nodejs.org/api/process.html are exit codes used by node. apps.js uses the value below
|
||||
// to check apptask crashes
|
||||
|
||||
@@ -25,11 +25,23 @@ if [[ "${service}" == "unbound" ]]; then
|
||||
unbound-anchor -a /var/lib/unbound/root.key
|
||||
systemctl restart --no-block unbound
|
||||
elif [[ "${service}" == "nginx" ]]; then
|
||||
nginx -s reload
|
||||
if systemctl -q is-active nginx; then
|
||||
nginx -s reload
|
||||
else
|
||||
systemctl restart --no-block nginx
|
||||
fi
|
||||
elif [[ "${service}" == "docker" ]]; then
|
||||
systemctl restart --no-block docker
|
||||
elif [[ "${service}" == "collectd" ]]; then
|
||||
systemctl restart --no-block collectd
|
||||
elif [[ "${service}" == "box" ]]; then
|
||||
readonly ubuntu_version=$(lsb_release -rs)
|
||||
if [[ "${ubuntu_version}" == "18.04" ]]; then
|
||||
pid=$(systemctl show box -p MainPID | sed 's/MainPID=//g')
|
||||
kill -HUP $pid
|
||||
else
|
||||
systemctl reload --no-block box
|
||||
fi
|
||||
else
|
||||
echo "Unknown service ${service}"
|
||||
exit 1
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -150,6 +150,7 @@ function initializeExpressSync() {
|
||||
|
||||
// config route (for dashboard). can return some private configuration unlike status
|
||||
router.get ('/api/v1/config', token, authorizeUser, routes.cloudron.getConfig);
|
||||
router.get ('/api/v1/platform_status', token, authorizeUser, routes.cloudron.getPlatformStatus);
|
||||
|
||||
// working off the user behind the provided token
|
||||
router.get ('/api/v1/profile', token, authorizeUser, routes.profile.get);
|
||||
@@ -242,6 +243,7 @@ function initializeExpressSync() {
|
||||
router.post('/api/v1/apps/:id/backup', json, token, routes.apps.load, authorizeOperator, routes.apps.backup);
|
||||
router.get ('/api/v1/apps/:id/backups', token, routes.apps.load, authorizeOperator, routes.apps.listBackups);
|
||||
router.post('/api/v1/apps/:id/backups/:backupId', json, token, routes.apps.load, authorizeOperator, routes.apps.updateBackup);
|
||||
router.get ('/api/v1/apps/:id/backups/:backupId/download', token, routes.apps.load, authorizeOperator, routes.apps.downloadBackup);
|
||||
router.post('/api/v1/apps/:id/start', json, token, routes.apps.load, authorizeOperator, routes.apps.start);
|
||||
router.post('/api/v1/apps/:id/stop', json, token, routes.apps.load, authorizeOperator, routes.apps.stop);
|
||||
router.post('/api/v1/apps/:id/restart', json, token, routes.apps.load, authorizeOperator, routes.apps.restart);
|
||||
|
||||
@@ -44,6 +44,7 @@ const addonConfigs = require('./addonconfigs.js'),
|
||||
hat = require('./hat.js'),
|
||||
http = require('http'),
|
||||
infra = require('./infra_version.js'),
|
||||
LogStream = require('./log-stream.js'),
|
||||
mail = require('./mail.js'),
|
||||
os = require('os'),
|
||||
path = require('path'),
|
||||
@@ -56,7 +57,6 @@ const addonConfigs = require('./addonconfigs.js'),
|
||||
sftp = require('./sftp.js'),
|
||||
shell = require('./shell.js'),
|
||||
spawn = require('child_process').spawn,
|
||||
split = require('split'),
|
||||
superagent = require('superagent'),
|
||||
system = require('./system.js');
|
||||
|
||||
@@ -161,8 +161,8 @@ const ADDONS = {
|
||||
clear: NOOP,
|
||||
},
|
||||
tls: {
|
||||
setup: NOOP,
|
||||
teardown: NOOP,
|
||||
setup: setupTls,
|
||||
teardown: teardownTls,
|
||||
backup: NOOP,
|
||||
restore: NOOP,
|
||||
clear: NOOP,
|
||||
@@ -300,11 +300,12 @@ async function containerStatus(containerName, tokenEnvName) {
|
||||
if (response.status !== 200 || !response.body.status) return { status: exports.SERVICE_STATUS_STARTING, error: `Error waiting for ${containerName}. Status code: ${response.statusCode} message: ${response.body.message}` };
|
||||
|
||||
const result = await docker.memoryUsage(containerName);
|
||||
const stats = result.memory_stats || { usage: 0, limit: 1 };
|
||||
|
||||
return {
|
||||
status: addonDetails.state.Running ? exports.SERVICE_STATUS_ACTIVE : exports.SERVICE_STATUS_STOPPED,
|
||||
memoryUsed: result.memory_stats.usage,
|
||||
memoryPercent: parseInt(100 * result.memory_stats.usage / result.memory_stats.limit),
|
||||
memoryUsed: stats.usage,
|
||||
memoryPercent: parseInt(100 * stats.usage / stats.limit),
|
||||
healthcheck: response.body
|
||||
};
|
||||
}
|
||||
@@ -471,29 +472,12 @@ async function getServiceLogs(id, options) {
|
||||
|
||||
const cp = spawn(cmd, args);
|
||||
|
||||
const transformStream = split(function mapper(line) {
|
||||
if (format !== 'json') return line + '\n';
|
||||
const logStream = new LogStream({ format, source: name });
|
||||
logStream.close = cp.kill.bind(cp, 'SIGKILL'); // closing stream kills the child process
|
||||
|
||||
const data = line.split(' '); // logs are <ISOtimestamp> <msg>
|
||||
let timestamp = (new Date(data[0])).getTime();
|
||||
if (isNaN(timestamp)) timestamp = 0;
|
||||
const message = line.slice(data[0].length+1);
|
||||
cp.stdout.pipe(logStream);
|
||||
|
||||
// ignore faulty empty logs
|
||||
if (!timestamp && !message) return;
|
||||
|
||||
return JSON.stringify({
|
||||
realtimeTimestamp: timestamp * 1000,
|
||||
message: message,
|
||||
source: name
|
||||
}) + '\n';
|
||||
});
|
||||
|
||||
transformStream.close = cp.kill.bind(cp, 'SIGKILL'); // closing stream kills the child process
|
||||
|
||||
cp.stdout.pipe(transformStream);
|
||||
|
||||
return transformStream;
|
||||
return logStream;
|
||||
}
|
||||
|
||||
async function rebuildService(id, auditSource) {
|
||||
@@ -781,7 +765,7 @@ async function applyMemoryLimit(id) {
|
||||
|
||||
debug(`applyMemoryLimit: ${containerName} ${JSON.stringify(serviceConfig)}`);
|
||||
|
||||
const memory = system.getMemoryAllocation(memoryLimit);
|
||||
const memory = await system.getMemoryAllocation(memoryLimit);
|
||||
await docker.update(containerName, memory, memoryLimit);
|
||||
}
|
||||
|
||||
@@ -921,7 +905,7 @@ async function startTurn(existingInfra) {
|
||||
const serviceConfig = await getServiceConfig('turn');
|
||||
const tag = infra.images.turn.tag;
|
||||
const memoryLimit = serviceConfig.memoryLimit || SERVICES['turn'].defaultMemoryLimit;
|
||||
const memory = system.getMemoryAllocation(memoryLimit);
|
||||
const memory = await system.getMemoryAllocation(memoryLimit);
|
||||
const realm = settings.dashboardFqdn();
|
||||
|
||||
let turnSecret = await blobs.getString(blobs.ADDON_TURN_SECRET);
|
||||
@@ -1637,7 +1621,7 @@ async function startGraphite(existingInfra) {
|
||||
const serviceConfig = await getServiceConfig('graphite');
|
||||
const tag = infra.images.graphite.tag;
|
||||
const memoryLimit = serviceConfig.memoryLimit || 256 * 1024 * 1024;
|
||||
const memory = system.getMemoryAllocation(memoryLimit);
|
||||
const memory = await system.getMemoryAllocation(memoryLimit);
|
||||
|
||||
const upgrading = existingInfra.version !== 'none' && requiresUpgrade(existingInfra.images.graphite.tag, tag);
|
||||
|
||||
@@ -1731,7 +1715,7 @@ async function setupRedis(app, options) {
|
||||
|
||||
// Compute redis memory limit based on app's memory limit (this is arbitrary)
|
||||
const memoryLimit = app.servicesConfig['redis']?.memoryLimit || APP_SERVICES['redis'].defaultMemoryLimit;
|
||||
const memory = system.getMemoryAllocation(memoryLimit);
|
||||
const memory = await system.getMemoryAllocation(memoryLimit);
|
||||
|
||||
const recoveryMode = app.servicesConfig['redis']?.recoveryMode || false;
|
||||
const readOnly = !recoveryMode ? '--read-only' : '';
|
||||
@@ -1829,6 +1813,23 @@ async function restoreRedis(app, options) {
|
||||
await pipeFileToRequest(dumpPath('redis', app.id), `http://${result.ip}:3000/restore?access_token=${result.token}`);
|
||||
}
|
||||
|
||||
async function setupTls(app, options) {
|
||||
assert.strictEqual(typeof app, 'object');
|
||||
assert.strictEqual(typeof options, 'object');
|
||||
|
||||
if (!safe.fs.mkdirSync(`${paths.PLATFORM_DATA_DIR}/tls/${app.id}`, { recursive: true })) {
|
||||
debug('Error creating tls directory');
|
||||
throw new BoxError(BoxError.FS_ERROR, safe.error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function teardownTls(app, options) {
|
||||
assert.strictEqual(typeof app, 'object');
|
||||
assert.strictEqual(typeof options, 'object');
|
||||
|
||||
safe.fs.rmSync(`${paths.PLATFORM_DATA_DIR}/tls/${app.id}`, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
async function statusTurn() {
|
||||
const [error, container] = await safe(docker.inspect('turn'));
|
||||
if (error && error.reason === BoxError.NOT_FOUND) return { status: exports.SERVICE_STATUS_STOPPED };
|
||||
@@ -1839,11 +1840,12 @@ async function statusTurn() {
|
||||
const status = container.State.Running
|
||||
? (container.HostConfig.ReadonlyRootfs ? exports.SERVICE_STATUS_ACTIVE : exports.SERVICE_STATUS_STARTING)
|
||||
: exports.SERVICE_STATUS_STOPPED;
|
||||
const stats = result.memory_stats || { usage: 0, limit: 1 };
|
||||
|
||||
return {
|
||||
status,
|
||||
memoryUsed: result.memory_stats.usage,
|
||||
memoryPercent: parseInt(100 * result.memory_stats.usage / result.memory_stats.limit)
|
||||
memoryUsed: stats.usage,
|
||||
memoryPercent: parseInt(100 * stats.usage / stats.limit)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1893,11 +1895,12 @@ async function statusGraphite() {
|
||||
if (response.status !== 200) return { status: exports.SERVICE_STATUS_STARTING, error: `Error waiting for graphite. Status code: ${response.status} message: ${response.body.message}` };
|
||||
|
||||
const result = await docker.memoryUsage('graphite');
|
||||
const stats = result.memory_stats || { usage: 0, limit: 1 };
|
||||
|
||||
return {
|
||||
status: container.State.Running ? exports.SERVICE_STATUS_ACTIVE : exports.SERVICE_STATUS_STOPPED,
|
||||
memoryUsed: result.memory_stats.usage,
|
||||
memoryPercent: parseInt(100 * result.memory_stats.usage / result.memory_stats.limit)
|
||||
memoryUsed: stats.usage,
|
||||
memoryPercent: parseInt(100 * stats.usage / stats.limit)
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ const assert = require('assert'),
|
||||
BoxError = require('../boxerror.js'),
|
||||
constants = require('../constants.js'),
|
||||
debug = require('debug')('box:storage/filesystem'),
|
||||
df = require('@sindresorhus/df'),
|
||||
df = require('../df.js'),
|
||||
fs = require('fs'),
|
||||
mounts = require('../mounts.js'),
|
||||
path = require('path'),
|
||||
@@ -66,12 +66,10 @@ async function getBackupProviderStatus(apiConfig) {
|
||||
assert.strictEqual(typeof apiConfig, 'object');
|
||||
|
||||
// Check filesystem is mounted so we don't write into the actual folder on disk
|
||||
if (mounts.isManagedProvider(apiConfig.provider) || apiConfig.provider === 'mountpoint') {
|
||||
const hostPath = mounts.isManagedProvider(apiConfig.provider) ? paths.MANAGED_BACKUP_MOUNT_DIR : apiConfig.mountPoint;
|
||||
return await mounts.getStatus(apiConfig.provider, hostPath); // { state, message }
|
||||
}
|
||||
if (!mounts.isManagedProvider(apiConfig.provider) && apiConfig.provider !== 'mountpoint') return await mounts.getStatus(apiConfig.provider, apiConfig.backupFolder);
|
||||
|
||||
return await mounts.getStatus(apiConfig.provider, apiConfig.backupFolder);
|
||||
const hostPath = mounts.isManagedProvider(apiConfig.provider) ? paths.MANAGED_BACKUP_MOUNT_DIR : apiConfig.mountPoint;
|
||||
return await mounts.getStatus(apiConfig.provider, hostPath); // { state, message }
|
||||
}
|
||||
|
||||
// the du call in the function below requires root
|
||||
|
||||
@@ -454,6 +454,7 @@ async function copy(apiConfig, oldFilePath, newFilePath, progressCallback) {
|
||||
}));
|
||||
|
||||
progressCallback({ message: `Copied ${total} files with error: ${copyError}` });
|
||||
if (copyError) throw copyError;
|
||||
}
|
||||
|
||||
async function remove(apiConfig, filename) {
|
||||
@@ -560,7 +561,7 @@ async function testConfig(apiConfig) {
|
||||
|
||||
const putParams = {
|
||||
Bucket: apiConfig.bucket,
|
||||
Key: path.join(apiConfig.prefix, 'cloudron-testfile'),
|
||||
Key: path.join(apiConfig.prefix, 'snapshot/cloudron-testfile'),
|
||||
Body: 'testcontent'
|
||||
};
|
||||
|
||||
@@ -568,9 +569,18 @@ async function testConfig(apiConfig) {
|
||||
const [putError] = await safe(s3.putObject(putParams).promise());
|
||||
if (putError) throw new BoxError(BoxError.EXTERNAL_ERROR, `Error put object cloudron-testfile. Message: ${putError.message} HTTP Code: ${putError.code}`);
|
||||
|
||||
const listParams = {
|
||||
Bucket: apiConfig.bucket,
|
||||
Prefix: path.join(apiConfig.prefix, 'snapshot'),
|
||||
MaxKeys: 1
|
||||
};
|
||||
|
||||
const [listError] = await safe(s3.listObjects(listParams).promise());
|
||||
if (listError) throw new BoxError(BoxError.EXTERNAL_ERROR, `Error listing objects. Message: ${listError.message} HTTP Code: ${listError.code}`);
|
||||
|
||||
const delParams = {
|
||||
Bucket: apiConfig.bucket,
|
||||
Key: path.join(apiConfig.prefix, 'cloudron-testfile')
|
||||
Key: path.join(apiConfig.prefix, 'snapshot/cloudron-testfile')
|
||||
};
|
||||
|
||||
const [delError] = await safe(s3.deleteObject(delParams).promise());
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
exports = module.exports = {
|
||||
getDisks,
|
||||
getSwaps,
|
||||
checkDiskSpace,
|
||||
getMemory,
|
||||
getMemoryAllocation,
|
||||
@@ -13,7 +14,7 @@ const apps = require('./apps.js'),
|
||||
assert = require('assert'),
|
||||
BoxError = require('./boxerror.js'),
|
||||
debug = require('debug')('box:disks'),
|
||||
df = require('@sindresorhus/df'),
|
||||
df = require('./df.js'),
|
||||
docker = require('./docker.js'),
|
||||
notifications = require('./notifications.js'),
|
||||
os = require('os'),
|
||||
@@ -35,15 +36,36 @@ async function du(file) {
|
||||
return parseInt(stdoutResult.trim(), 10);
|
||||
}
|
||||
|
||||
async function getSwaps() {
|
||||
const stdout = safe.child_process.execSync('swapon --noheadings --raw --bytes --show=type,size,used,name', { encoding: 'utf8' });
|
||||
if (!stdout) return {};
|
||||
|
||||
const swaps = {};
|
||||
for (const line of stdout.trim().split('\n')) {
|
||||
const parts = line.split(' ', 4);
|
||||
const name = parts[3];
|
||||
swaps[name] = {
|
||||
name: parts[3],
|
||||
type: parts[0], // partition or file
|
||||
size: parseInt(parts[1]),
|
||||
used: parseInt(parts[2]),
|
||||
};
|
||||
}
|
||||
|
||||
return swaps;
|
||||
}
|
||||
|
||||
async function getDisks() {
|
||||
let [dfError, dfEntries] = await safe(df());
|
||||
let [dfError, dfEntries] = await safe(df.disks());
|
||||
if (dfError) throw new BoxError(BoxError.FS_ERROR, `Error running df: ${dfError.message}`);
|
||||
|
||||
const disks = {}; // by file system
|
||||
let rootDisk;
|
||||
|
||||
const DISK_TYPES = [ 'ext4', 'xfs', 'cifs', 'nfs', 'fuse.sshfs' ]; // we don't show size of contents in untracked disk types
|
||||
|
||||
for (const disk of dfEntries) {
|
||||
if (disk.type !== 'ext4' && disk.type !== 'xfs') continue;
|
||||
if (!DISK_TYPES.includes(disk.type)) continue;
|
||||
if (disk.mountpoint === '/') rootDisk = disk;
|
||||
disks[disk.filesystem] = {
|
||||
filesystem: disk.filesystem,
|
||||
@@ -72,18 +94,21 @@ async function getDisks() {
|
||||
const backupConfig = await settings.getBackupConfig();
|
||||
if (backupConfig.provider === 'filesystem') {
|
||||
const [, dfResult] = await safe(df.file(backupConfig.backupFolder));
|
||||
disks[dfResult?.filesystem || rootDisk.filesystem].contents.push({ type: 'standard', id: 'cloudron-backup', path: backupConfig.backupFolder });
|
||||
const filesystem = dfResult?.filesystem || rootDisk.filesystem;
|
||||
if (disks[filesystem]) disks[filesystem].contents.push({ type: 'standard', id: 'cloudron-backup', path: backupConfig.backupFolder });
|
||||
}
|
||||
|
||||
const [dockerError, dockerInfo] = await safe(docker.info());
|
||||
if (!dockerError) {
|
||||
const [, dfResult] = await safe(df.file(dockerInfo.DockerRootDir));
|
||||
disks[dfResult?.filesystem || rootDisk.filesystem].contents.push({ type: 'standard', id: 'docker', path: dockerInfo.DockerRootDir });
|
||||
const filesystem = dfResult?.filesystem || rootDisk.filesystem;
|
||||
if (disks[filesystem]) disks[filesystem].contents.push({ type: 'standard', id: 'docker', path: dockerInfo.DockerRootDir });
|
||||
}
|
||||
|
||||
for (const volume of await volumes.list()) {
|
||||
const [, dfResult] = await safe(df(volume.hostPath));
|
||||
disks[dfResult?.filesystem || rootDisk.filesystem].contents.push({ type: 'volume', id: volume.id, path: volume.hostPath });
|
||||
const [, dfResult] = await safe(df.file(volume.hostPath));
|
||||
const filesystem = dfResult?.filesystem || rootDisk.filesystem;
|
||||
if (disks[filesystem]) disks[filesystem].contents.push({ type: 'volume', id: volume.id, path: volume.hostPath });
|
||||
}
|
||||
|
||||
for (const app of await apps.list()) {
|
||||
@@ -91,7 +116,17 @@ async function getDisks() {
|
||||
|
||||
const dataDir = await apps.getStorageDir(app);
|
||||
const [, dfResult] = await safe(df.file(dataDir));
|
||||
disks[dfResult?.filesystem || rootDisk.filesystem].contents.push({ type: 'app', id: app.id, path: dataDir });
|
||||
const filesystem = dfResult?.filesystem || rootDisk.filesystem;
|
||||
if (disks[filesystem]) disks[filesystem].contents.push({ type: 'app', id: app.id, path: dataDir });
|
||||
}
|
||||
|
||||
const swaps = await getSwaps();
|
||||
for (const k in swaps) {
|
||||
const swap = swaps[k];
|
||||
if (swap.type !== 'file') continue;
|
||||
|
||||
const [, dfResult] = await safe(df.file(swap.name));
|
||||
disks[dfResult?.filesystem || rootDisk.filesystem].contents.push({ type: 'swap', id: swap.name, path: swap.name });
|
||||
}
|
||||
|
||||
return disks;
|
||||
@@ -120,25 +155,23 @@ async function checkDiskSpace() {
|
||||
await notifications.alert(notifications.ALERT_DISK_SPACE, 'Server is running out of disk space', markdownMessage);
|
||||
}
|
||||
|
||||
function getSwapSize() {
|
||||
const stdout = safe.child_process.execSync('swapon --noheadings --raw --bytes --show=SIZE', { encoding: 'utf8' });
|
||||
const swap = !stdout ? 0 : stdout.trim().split('\n').map(x => parseInt(x, 10) || 0).reduce((acc, cur) => acc + cur);
|
||||
|
||||
return swap;
|
||||
async function getSwapSize() {
|
||||
const swaps = await getSwaps();
|
||||
return Object.keys(swaps).map(n => swaps[n].size).reduce((acc, cur) => acc + cur, 0);
|
||||
}
|
||||
|
||||
async function getMemory() {
|
||||
return {
|
||||
memory: os.totalmem(),
|
||||
swap: getSwapSize()
|
||||
swap: await getSwapSize()
|
||||
};
|
||||
}
|
||||
|
||||
function getMemoryAllocation(limit) {
|
||||
async function getMemoryAllocation(limit) {
|
||||
let ratio = parseFloat(safe.fs.readFileSync(paths.SWAP_RATIO_FILE, 'utf8'), 10);
|
||||
|
||||
if (!ratio) {
|
||||
const pc = os.totalmem() / (os.totalmem() + getSwapSize());
|
||||
const pc = os.totalmem() / (os.totalmem() + await getSwapSize());
|
||||
ratio = Math.round(pc * 10) / 10; // a simple ratio
|
||||
}
|
||||
|
||||
|
||||
36
src/tasks.js
36
src/tasks.js
@@ -46,12 +46,12 @@ const assert = require('assert'),
|
||||
BoxError = require('./boxerror.js'),
|
||||
database = require('./database.js'),
|
||||
debug = require('debug')('box:tasks'),
|
||||
LogStream = require('./log-stream.js'),
|
||||
path = require('path'),
|
||||
paths = require('./paths.js'),
|
||||
safe = require('safetydance'),
|
||||
shell = require('./shell.js'),
|
||||
spawn = require('child_process').spawn,
|
||||
split = require('split'),
|
||||
_ = require('underscore');
|
||||
|
||||
let gTasks = {}; // indexed by task id
|
||||
@@ -92,7 +92,7 @@ function updateStatus(result) {
|
||||
|
||||
// the error in db will be empty if we didn't get a chance to handle task exit
|
||||
if (!result.active && result.percent !== 100 && !result.error) {
|
||||
result.error = { message: 'Cloudron crashed/stopped', code: exports.ECRASHED };
|
||||
result.error = { message: 'Task was stopped because the server was restarted or crashed', code: exports.ECRASHED };
|
||||
}
|
||||
|
||||
return result;
|
||||
@@ -171,9 +171,8 @@ function startTask(id, options, callback) {
|
||||
if (!gTasks[id]) return; // ignore task exit since we are shutting down. see stopAllTasks
|
||||
|
||||
const code = sudoError ? sudoError.code : 0;
|
||||
const signal = sudoError ? sudoError.signal : 0;
|
||||
|
||||
debug(`startTask: ${id} completed with code ${code} and signal ${signal}`);
|
||||
debug(`startTask: ${id} completed with code ${code}`);
|
||||
|
||||
if (options.timeout) clearTimeout(killTimerId);
|
||||
|
||||
@@ -186,9 +185,9 @@ function startTask(id, options, callback) {
|
||||
message: `Task ${id} ${timedOut ? 'timed out' : 'stopped'}` ,
|
||||
code: timedOut ? exports.ETIMEOUT : exports.ESTOPPED
|
||||
};
|
||||
} else { // task crashed
|
||||
} else { // task crashed. for code, maybe we can check systemctl show box-task-1707 -p ExecMainStatus
|
||||
taskError = {
|
||||
message: signal === 9 ? `Task ${id} crashed as it ran out of memory` : `Task ${id} crashed with code ${code} and signal ${signal}`,
|
||||
message: code === 2 ? `Task ${id} crashed as it ran out of memory` : `Task ${id} crashed with code ${code}`,
|
||||
code: exports.ECRASHED
|
||||
};
|
||||
}
|
||||
@@ -282,29 +281,12 @@ function getLogs(taskId, options) {
|
||||
|
||||
const cp = spawn(cmd, args);
|
||||
|
||||
const transformStream = split(function mapper(line) {
|
||||
if (format !== 'json') return line + '\n';
|
||||
const logStream = new LogStream({ format, source: taskId });
|
||||
logStream.close = cp.kill.bind(cp, 'SIGKILL'); // hook for caller. closing stream kills the child process
|
||||
|
||||
const data = line.split(' '); // logs are <ISOtimestamp> <msg>
|
||||
let timestamp = (new Date(data[0])).getTime();
|
||||
if (isNaN(timestamp)) timestamp = 0;
|
||||
const message = line.slice(data[0].length+1);
|
||||
cp.stdout.pipe(logStream);
|
||||
|
||||
// ignore faulty empty logs
|
||||
if (!timestamp && !message) return;
|
||||
|
||||
return JSON.stringify({
|
||||
realtimeTimestamp: timestamp * 1000,
|
||||
message: message,
|
||||
source: taskId
|
||||
}) + '\n';
|
||||
});
|
||||
|
||||
transformStream.close = cp.kill.bind(cp, 'SIGKILL'); // closing stream kills the child process
|
||||
|
||||
cp.stdout.pipe(transformStream);
|
||||
|
||||
return transformStream;
|
||||
return logStream;
|
||||
}
|
||||
|
||||
// removes all fields that are strictly private and should never be returned by API calls
|
||||
|
||||
@@ -9,6 +9,7 @@ const apps = require('../apps.js'),
|
||||
AuditSource = require('../auditsource.js'),
|
||||
BoxError = require('../boxerror.js'),
|
||||
common = require('./common.js'),
|
||||
constants = require('../constants.js'),
|
||||
expect = require('expect.js'),
|
||||
safe = require('safetydance');
|
||||
|
||||
@@ -72,6 +73,40 @@ describe('Apps', function () {
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateUpstreamUri', function () {
|
||||
it('does not allow empty URI', function () {
|
||||
expect(apps._validateUpstreamUri('')).to.be.an(Error);
|
||||
});
|
||||
|
||||
it('does not allow invalid URI scheme', function () {
|
||||
expect(apps._validateUpstreamUri('bla:blub')).to.be.an(Error);
|
||||
});
|
||||
|
||||
it('does not allow unsupported scheme', function () {
|
||||
expect(apps._validateUpstreamUri('ftp://foobar.com')).to.be.an(Error);
|
||||
});
|
||||
|
||||
it('does not allow trailing URI paths ', function () {
|
||||
expect(apps._validateUpstreamUri('https://foobar.com/extra/path')).to.be.an(Error);
|
||||
});
|
||||
|
||||
it('allows IP', function () {
|
||||
expect(apps._validateUpstreamUri('http://1.2.3.4')).to.eql(null);
|
||||
});
|
||||
|
||||
it('allows IP with port', function () {
|
||||
expect(apps._validateUpstreamUri('http://1.2.3.4:80')).to.eql(null);
|
||||
});
|
||||
|
||||
it('allows domain', function () {
|
||||
expect(apps._validateUpstreamUri('https://www.cloudron.io')).to.eql(null);
|
||||
});
|
||||
|
||||
it('allows domain with port', function () {
|
||||
expect(apps._validateUpstreamUri('https://www.cloudron.io:443')).to.eql(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('canAccess', function () {
|
||||
const someuser = { id: 'someuser', groupIds: [], role: 'user' };
|
||||
const adminuser = { id: 'adminuser', groupIds: [ 'groupie' ], role: 'admin' };
|
||||
@@ -269,6 +304,27 @@ describe('Apps', function () {
|
||||
});
|
||||
});
|
||||
|
||||
describe('proxy app', function () {
|
||||
const app = require('./common.js').proxyApp;
|
||||
const newUpstreamUri = 'https://foobar.com:443';
|
||||
|
||||
before(async function () {
|
||||
await apps.add(app.id, app.appStoreId, app.manifest, app.subdomain, app.domain, app.portBindings, app);
|
||||
});
|
||||
|
||||
it('cannot set invalid upstream uri', async function () {
|
||||
const [error] = await safe(apps.setUpstreamUri(app, 'foo:bar:80', AuditSource.PLATFORM));
|
||||
expect(error.reason).to.be(BoxError.BAD_FIELD);
|
||||
});
|
||||
|
||||
it('can set upstream uri', async function () {
|
||||
await apps.setUpstreamUri(app, newUpstreamUri, AuditSource.PLATFORM);
|
||||
const result = await apps.get(app.id);
|
||||
|
||||
expect(result.upstreamUri).to.equal(newUpstreamUri);
|
||||
});
|
||||
});
|
||||
|
||||
describe('configureInstalledApps', function () {
|
||||
const app1 = Object.assign({}, app, { id: 'id1', installationState: apps.ISTATE_ERROR, subdomain: 'loc1' });
|
||||
const app2 = Object.assign({}, app, { id: 'id2', installationState: apps.ISTATE_INSTALLED, subdomain: 'loc2' });
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
'use strict';
|
||||
|
||||
const apps = require('../apps.js'),
|
||||
async = require('async'),
|
||||
constants = require('../constants.js'),
|
||||
database = require('../database.js'),
|
||||
delay = require('../delay.js'),
|
||||
@@ -11,7 +10,7 @@ const apps = require('../apps.js'),
|
||||
mailer = require('../mailer.js'),
|
||||
nock = require('nock'),
|
||||
path = require('path'),
|
||||
rimraf = require('rimraf'),
|
||||
paths = require('../paths.js'),
|
||||
settings = require('../settings.js'),
|
||||
tasks = require('../tasks.js'),
|
||||
users = require('../users.js');
|
||||
@@ -44,6 +43,34 @@ const manifest = {
|
||||
}
|
||||
};
|
||||
|
||||
// copied from the proxy app CloudronManifest.json
|
||||
const proxyAppManifest = {
|
||||
"id": "io.cloudron.builtin.appproxy",
|
||||
"title": "App Proxy",
|
||||
"author": "Cloudron Team",
|
||||
"version": "1.0.0",
|
||||
"upstreamVersion": "1.0.0",
|
||||
"description": "file://DESCRIPTION.md",
|
||||
"tagline": "Proxy an app through Cloudron",
|
||||
"tags": [ "proxy", "external" ],
|
||||
"healthCheckPath": "/",
|
||||
"httpPort": 3000,
|
||||
"minBoxVersion": "7.3.0",
|
||||
"dockerImage": "istobeignored",
|
||||
"manifestVersion": 2,
|
||||
"multiDomain": true,
|
||||
"website": "https://cloudron.io",
|
||||
"documentationUrl": "https://docs.cloudron.io/dashboard/#app-proxy",
|
||||
"forumUrl": "https://forum.cloudron.io",
|
||||
"contactEmail": "support@cloudron.io",
|
||||
"icon": "file://logo.png",
|
||||
"addons": {},
|
||||
"mediaLinks": [
|
||||
"https://screenshots.cloudron.io/io.cloudron.builtin.appproxy/diagram.png"
|
||||
],
|
||||
"changelog": "file://CHANGELOG.md"
|
||||
};
|
||||
|
||||
const domain = {
|
||||
domain: 'example.com',
|
||||
zoneName: 'example.com',
|
||||
@@ -111,6 +138,27 @@ const app = {
|
||||
};
|
||||
Object.freeze(app);
|
||||
|
||||
const proxyApp = {
|
||||
id: 'proxyapptestid',
|
||||
appStoreId: proxyAppManifest.id,
|
||||
installationState: apps.ISTATE_PENDING_INSTALL,
|
||||
runState: 'running',
|
||||
subdomain: 'proxylocation',
|
||||
upstreamUri: 'http://1.2.3.4:80',
|
||||
domain: domain.domain,
|
||||
fqdn: domain.domain + '.' + 'proxylocation',
|
||||
manifest,
|
||||
containerId: '',
|
||||
portBindings: null,
|
||||
accessRestriction: null,
|
||||
memoryLimit: 0,
|
||||
mailboxDomain: domain.domain,
|
||||
secondaryDomains: [],
|
||||
redirectDomains: [],
|
||||
aliasDomains: []
|
||||
};
|
||||
Object.freeze(proxyApp);
|
||||
|
||||
exports = module.exports = {
|
||||
createTree,
|
||||
domainSetup,
|
||||
@@ -125,6 +173,7 @@ exports = module.exports = {
|
||||
dashboardFqdn: `my.${domain.domain}`,
|
||||
|
||||
app,
|
||||
proxyApp,
|
||||
admin,
|
||||
auditSource,
|
||||
domain, // the domain object
|
||||
@@ -136,7 +185,7 @@ exports = module.exports = {
|
||||
};
|
||||
|
||||
function createTree(root, obj) {
|
||||
rimraf.sync(root);
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
fs.mkdirSync(root, { recursive: true });
|
||||
|
||||
function createSubTree(tree, curpath) {
|
||||
@@ -174,22 +223,16 @@ async function domainSetup() {
|
||||
await domains.add(domain.domain, domain, auditSource);
|
||||
}
|
||||
|
||||
function setup(done) {
|
||||
async.series([
|
||||
domainSetup,
|
||||
async function createOwner() {
|
||||
const result = await users.createOwner(admin.email, admin.username, admin.password, admin.displayName, auditSource);
|
||||
admin.id = result;
|
||||
},
|
||||
apps.add.bind(null, app.id, app.appStoreId, app.manifest, app.subdomain, app.domain, app.portBindings, app),
|
||||
settings._set.bind(null, settings.APPSTORE_API_TOKEN_KEY, exports.appstoreToken), // appstore token
|
||||
async function createUser() {
|
||||
const result = await users.add(user.email, user, auditSource);
|
||||
user.id = result;
|
||||
},
|
||||
|
||||
tasks.stopAllTasks,
|
||||
], done);
|
||||
async function setup() {
|
||||
await fs.promises.rm(paths.DISK_USAGE_FILE, { force: true });
|
||||
await domainSetup();
|
||||
const ownerId = await users.createOwner(admin.email, admin.username, admin.password, admin.displayName, auditSource);
|
||||
admin.id = ownerId;
|
||||
await apps.add(app.id, app.appStoreId, app.manifest, app.subdomain, app.domain, app.portBindings, app);
|
||||
await settings._set(settings.APPSTORE_API_TOKEN_KEY, exports.appstoreToken); // appstore token
|
||||
const userId = await users.add(user.email, user, auditSource);
|
||||
user.id = userId;
|
||||
await tasks.stopAllTasks();
|
||||
}
|
||||
|
||||
async function cleanup() {
|
||||
|
||||
36
src/test/df-test.js
Normal file
36
src/test/df-test.js
Normal file
@@ -0,0 +1,36 @@
|
||||
/* jslint node:true */
|
||||
/* global it:false */
|
||||
/* global describe:false */
|
||||
/* global before:false */
|
||||
/* global after:false */
|
||||
|
||||
'use strict';
|
||||
|
||||
const common = require('./common.js'),
|
||||
df = require('../df.js'),
|
||||
expect = require('expect.js');
|
||||
|
||||
describe('System', function () {
|
||||
const { setup, cleanup } = common;
|
||||
|
||||
before(setup);
|
||||
after(cleanup);
|
||||
|
||||
it('can get disks', async function () {
|
||||
// does not work on archlinux 8!
|
||||
if (require('child_process').execSync('uname -a').toString().indexOf('-arch') !== -1) return;
|
||||
|
||||
const disks = await df.disks();
|
||||
expect(disks).to.be.ok();
|
||||
expect(disks.some(d => d.mountpoint === '/')).to.be.ok();
|
||||
});
|
||||
|
||||
it('can get file', async function () {
|
||||
// does not work on archlinux 8!
|
||||
if (require('child_process').execSync('uname -a').toString().indexOf('-arch') !== -1) return;
|
||||
|
||||
const disks = await df.file(__dirname);
|
||||
expect(disks).to.be.ok();
|
||||
expect(disks.mountpoint).to.be('/home');
|
||||
});
|
||||
});
|
||||
@@ -10,7 +10,9 @@ const common = require('./common.js'),
|
||||
expect = require('expect.js');
|
||||
|
||||
describe('DNS', function () {
|
||||
const { setup, cleanup, app, domain } = common;
|
||||
const { setup, cleanup, app, domain:domainObject } = common;
|
||||
|
||||
const domain = domainObject.domain;
|
||||
|
||||
before(setup);
|
||||
after(cleanup);
|
||||
@@ -23,8 +25,7 @@ describe('DNS', function () {
|
||||
it('cannot have >63 length subdomains', function () {
|
||||
const s = Array(64).fill('s').join('');
|
||||
expect(dns.validateHostname(s, domain)).to.be.an(Error);
|
||||
const domainCopy = Object.assign({}, domain, { zoneName: `dev.${s}.example.com` });
|
||||
expect(dns.validateHostname(`dev.${s}`, domainCopy)).to.be.an(Error);
|
||||
expect(dns.validateHostname(`dev.${s}`, domain)).to.be.an(Error);
|
||||
});
|
||||
|
||||
it('allows only alphanumerics and hypen', function () {
|
||||
|
||||
29
src/test/log-stream-test.js
Normal file
29
src/test/log-stream-test.js
Normal file
@@ -0,0 +1,29 @@
|
||||
/* global it:false */
|
||||
/* global describe:false */
|
||||
|
||||
'use strict';
|
||||
|
||||
const expect = require('expect.js'),
|
||||
fs = require('fs'),
|
||||
LogStream = require('../log-stream.js'),
|
||||
stream = require('stream');
|
||||
|
||||
describe('log stream', function () {
|
||||
it('can create stream', function (done) {
|
||||
fs.writeFileSync('/tmp/test-input.log', '2022-10-09T15:19:48.740Z message', 'utf8');
|
||||
const input = fs.createReadStream('/tmp/test-input.log');
|
||||
const log = new LogStream({ format: 'json', source: 'test' });
|
||||
const output = fs.createWriteStream('/tmp/test-output.log');
|
||||
|
||||
stream.pipeline(input, log, output, function (error) {
|
||||
expect(error).to.not.be.ok();
|
||||
|
||||
const out = fs.readFileSync('/tmp/test-output.log', 'utf8');
|
||||
const firstLine = JSON.parse(out.split('\n')[0]);
|
||||
expect(firstLine.realtimeTimestamp).to.be.a('number');
|
||||
expect(firstLine.message).to.be('message');
|
||||
expect(firstLine.source).to.be('test');
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
25
src/test/progress-stream-test.js
Normal file
25
src/test/progress-stream-test.js
Normal file
@@ -0,0 +1,25 @@
|
||||
/* global it:false */
|
||||
/* global describe:false */
|
||||
|
||||
'use strict';
|
||||
|
||||
const expect = require('expect.js'),
|
||||
fs = require('fs'),
|
||||
ProgressStream = require('../progress-stream.js'),
|
||||
stream = require('stream');
|
||||
|
||||
describe('progress stream', function () {
|
||||
it('can create stream', function (done) {
|
||||
const input = fs.createReadStream(`${__dirname}/progress-stream-test.js`);
|
||||
const progress = new ProgressStream({ interval: 1000 });
|
||||
const output = fs.createWriteStream('/dev/null');
|
||||
|
||||
stream.pipeline(input, progress, output, function (error) {
|
||||
expect(error).to.not.be.ok();
|
||||
const size = fs.statSync(`${__dirname}/progress-stream-test.js`).size;
|
||||
expect(progress._transferred).to.be(size);
|
||||
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -20,15 +20,8 @@ describe('Reverse Proxy', function () {
|
||||
after(cleanup);
|
||||
|
||||
describe('validateCertificate', function () {
|
||||
let foobarDomain = {
|
||||
domain: 'foobar.com',
|
||||
config: {}
|
||||
};
|
||||
|
||||
let amazingDomain = {
|
||||
domain: 'amazing.com',
|
||||
config: {}
|
||||
};
|
||||
let foobarDomain = 'foobar.com';
|
||||
let amazingDomain = 'amazing.com';
|
||||
/*
|
||||
Generate these with:
|
||||
openssl genrsa -out server.key 512
|
||||
@@ -82,7 +75,7 @@ describe('Reverse Proxy', function () {
|
||||
});
|
||||
|
||||
it('does not allow cert without matching domain', function () {
|
||||
expect(reverseProxy.validateCertificate('', { domain: 'cloudron.io' }, { cert: validCert0, key: validKey0 })).to.be.an(Error);
|
||||
expect(reverseProxy.validateCertificate('', 'cloudron.io', { cert: validCert0, key: validKey0 })).to.be.an(Error);
|
||||
expect(reverseProxy.validateCertificate('cloudron.io', foobarDomain, { cert: validCert0, key: validKey0 })).to.be.an(Error);
|
||||
});
|
||||
|
||||
@@ -122,48 +115,17 @@ describe('Reverse Proxy', function () {
|
||||
});
|
||||
|
||||
describe('generateFallbackCertificate', function () {
|
||||
let domainObject = {
|
||||
domain: 'cool.com',
|
||||
config: {}
|
||||
};
|
||||
const domain = 'cool.com';
|
||||
let result;
|
||||
|
||||
it('can generate fallback certs', async function () {
|
||||
result = await reverseProxy.generateFallbackCertificate(domainObject.domain);
|
||||
result = await reverseProxy.generateFallbackCertificate(domain);
|
||||
expect(result).to.be.ok();
|
||||
});
|
||||
|
||||
it('can validate the certs', function () {
|
||||
expect(reverseProxy.validateCertificate('foo', domainObject, result)).to.be(null);
|
||||
expect(reverseProxy.validateCertificate('', domainObject, result)).to.be(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getApi - letsencrypt-prod', function () {
|
||||
before(async function () {
|
||||
domainCopy.tlsConfig = { provider: 'letsencrypt-prod' };
|
||||
|
||||
await domains.setConfig(domainCopy.domain, domainCopy, auditSource);
|
||||
});
|
||||
|
||||
it('returns prod acme in prod cloudron', async function () {
|
||||
const { acme2, apiOptions } = await reverseProxy._getAcmeApi(domainCopy);
|
||||
expect(acme2._name).to.be('acme');
|
||||
expect(apiOptions.prod).to.be(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getApi - letsencrypt-staging', function () {
|
||||
before(async function () {
|
||||
domainCopy.tlsConfig = { provider: 'letsencrypt-staging' };
|
||||
|
||||
await domains.setConfig(domainCopy.domain, domainCopy, auditSource);
|
||||
});
|
||||
|
||||
it('returns staging acme in prod cloudron', async function () {
|
||||
const { acme2, apiOptions } = await reverseProxy._getAcmeApi(domainCopy);
|
||||
expect(acme2._name).to.be('acme');
|
||||
expect(apiOptions.prod).to.be(false);
|
||||
expect(reverseProxy.validateCertificate('foo', domain, result)).to.be(null);
|
||||
expect(reverseProxy.validateCertificate('', domain, result)).to.be(null);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -18,7 +18,6 @@ const BoxError = require('../boxerror.js'),
|
||||
os = require('os'),
|
||||
path = require('path'),
|
||||
readdirp = require('readdirp'),
|
||||
rimraf = require('rimraf'),
|
||||
s3 = require('../storage/s3.js'),
|
||||
safe = require('safetydance'),
|
||||
settings = require('../settings.js');
|
||||
@@ -52,7 +51,7 @@ describe('Storage', function () {
|
||||
});
|
||||
|
||||
after(function (done) {
|
||||
rimraf.sync(gTmpFolder);
|
||||
fs.rmSync(gTmpFolder, { recursive: true, force: true });
|
||||
done();
|
||||
});
|
||||
|
||||
@@ -217,7 +216,7 @@ describe('Storage', function () {
|
||||
|
||||
before(function () {
|
||||
MockS3.config.basePath = path.join(os.tmpdir(), 's3-backup-test-buckets/');
|
||||
rimraf.sync(MockS3.config.basePath);
|
||||
fs.rmSync(MockS3.config.basePath, { recursive: true, force: true });
|
||||
gS3Folder = path.join(MockS3.config.basePath, gBackupConfig.bucket);
|
||||
|
||||
s3._mockInject(MockS3);
|
||||
@@ -225,7 +224,7 @@ describe('Storage', function () {
|
||||
|
||||
after(function () {
|
||||
s3._mockRestore();
|
||||
rimraf.sync(MockS3.config.basePath);
|
||||
fs.rmSync(MockS3.config.basePath, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('can upload', function (done) {
|
||||
@@ -367,7 +366,7 @@ describe('Storage', function () {
|
||||
|
||||
after(function (done) {
|
||||
gcs._mockRestore();
|
||||
rimraf.sync(GCSMockBasePath);
|
||||
fs.rmSync(GCSMockBasePath, { recursive: true, force: true });
|
||||
done();
|
||||
});
|
||||
|
||||
|
||||
@@ -22,6 +22,16 @@ describe('System', function () {
|
||||
|
||||
const disks = await system.getDisks();
|
||||
expect(disks).to.be.ok();
|
||||
expect(Object.keys(disks).some(fs => disks[fs].mountpoint === '/')).to.be.ok();
|
||||
});
|
||||
|
||||
it('can get swaps', async function () {
|
||||
// does not work on archlinux 8!
|
||||
if (require('child_process').execSync('uname -a').toString().indexOf('-arch') !== -1) return;
|
||||
|
||||
const swaps = await system.getSwaps();
|
||||
expect(swaps).to.be.ok();
|
||||
expect(Object.keys(swaps).some(n => swaps[n].type === 'partition')).to.be.ok();
|
||||
});
|
||||
|
||||
it('can check for disk space', async function () {
|
||||
|
||||
@@ -12,7 +12,7 @@ const apps = require('./apps.js'),
|
||||
constants = require('./constants.js'),
|
||||
crypto = require('crypto'),
|
||||
debug = require('debug')('box:updater'),
|
||||
df = require('@sindresorhus/df'),
|
||||
df = require('./df.js'),
|
||||
eventlog = require('./eventlog.js'),
|
||||
fs = require('fs'),
|
||||
locker = require('./locker.js'),
|
||||
|
||||
40
src/users.js
40
src/users.js
@@ -80,6 +80,7 @@ const appPasswords = require('./apppasswords.js'),
|
||||
eventlog = require('./eventlog.js'),
|
||||
externalLdap = require('./externalldap.js'),
|
||||
hat = require('./hat.js'),
|
||||
mail = require('./mail.js'),
|
||||
mailer = require('./mailer.js'),
|
||||
mysql = require('mysql'),
|
||||
qrcode = require('qrcode'),
|
||||
@@ -630,24 +631,6 @@ async function getSuperadmins() {
|
||||
return await getByRole(exports.ROLE_OWNER);
|
||||
}
|
||||
|
||||
async function sendPasswordResetByIdentifier(identifier, auditSource) {
|
||||
assert.strictEqual(typeof identifier, 'string');
|
||||
assert.strictEqual(typeof auditSource, 'object');
|
||||
|
||||
const user = identifier.indexOf('@') === -1 ? await getByUsername(identifier.toLowerCase()) : await getByEmail(identifier.toLowerCase());
|
||||
if (!user) throw new BoxError(BoxError.NOT_FOUND, 'User not found');
|
||||
|
||||
const resetToken = hat(256);
|
||||
const resetTokenCreationTime = new Date();
|
||||
|
||||
user.resetToken = resetToken;
|
||||
user.resetTokenCreationTime = resetTokenCreationTime;
|
||||
await update(user, { resetToken,resetTokenCreationTime }, auditSource);
|
||||
|
||||
const resetLink = `${settings.dashboardOrigin()}/login.html?resetToken=${user.resetToken}`;
|
||||
await mailer.passwordReset(user, user.fallbackEmail || user.email, resetLink);
|
||||
}
|
||||
|
||||
async function getPasswordResetLink(user, auditSource) {
|
||||
assert.strictEqual(typeof user, 'object');
|
||||
assert.strictEqual(typeof auditSource, 'object');
|
||||
@@ -667,6 +650,23 @@ async function getPasswordResetLink(user, auditSource) {
|
||||
return resetLink;
|
||||
}
|
||||
|
||||
async function sendPasswordResetByIdentifier(identifier, auditSource) {
|
||||
assert.strictEqual(typeof identifier, 'string');
|
||||
assert.strictEqual(typeof auditSource, 'object');
|
||||
|
||||
const user = identifier.indexOf('@') === -1 ? await getByUsername(identifier.toLowerCase()) : await getByEmail(identifier.toLowerCase());
|
||||
if (!user) throw new BoxError(BoxError.NOT_FOUND, 'User not found');
|
||||
|
||||
const email = user.fallbackEmail || user.email;
|
||||
|
||||
// security measure to prevent a mail manager or admin resetting the superadmin's password
|
||||
const mailDomains = await mail.listDomains();
|
||||
if (mailDomains.some(d => d.enabled && email.endsWith(`@${d.domain}`))) throw new BoxError(BoxError.CONFLICT, 'Password reset email cannot be sent to email addresses hosted on the same Cloudron');
|
||||
|
||||
const resetLink = await getPasswordResetLink(user, auditSource);
|
||||
await mailer.passwordReset(user, email, resetLink);
|
||||
}
|
||||
|
||||
async function sendPasswordResetEmail(user, email, auditSource) {
|
||||
assert.strictEqual(typeof user, 'object');
|
||||
assert.strictEqual(typeof email, 'string');
|
||||
@@ -675,6 +675,10 @@ async function sendPasswordResetEmail(user, email, auditSource) {
|
||||
const error = validateEmail(email);
|
||||
if (error) throw error;
|
||||
|
||||
// security measure to prevent a mail manager or admin resetting the superadmin's password
|
||||
const mailDomains = await mail.listDomains();
|
||||
if (mailDomains.some(d => d.enabled && email.endsWith(`@${d.domain}`))) throw new BoxError(BoxError.CONFLICT, 'Password reset email cannot be sent to email addresses hosted on the same Cloudron');
|
||||
|
||||
const resetLink = await getPasswordResetLink(user, auditSource);
|
||||
await mailer.passwordReset(user, email, resetLink);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user