Compare commits

...

260 Commits

Author SHA1 Message Date
Girish Ramakrishnan
dd44edde0a only clear backup cache if specific fields changed 2020-08-11 14:01:29 -07:00
Girish Ramakrishnan
885e90e810 add a todo 2020-08-11 12:57:37 -07:00
Girish Ramakrishnan
9cdf5dd0f3 backups: time the rotation and total as well 2020-08-11 10:28:11 -07:00
Girish Ramakrishnan
df6e3eb1e6 Add deleteConcurrency setting 2020-08-11 09:14:09 -07:00
Girish Ramakrishnan
05026771e1 add memoryLimit, copyConcurrency, downloadConcurrency to backup config 2020-08-10 22:12:01 -07:00
Girish Ramakrishnan
7039108438 pass memory limit as argument to starttask.sh 2020-08-10 21:53:07 -07:00
Girish Ramakrishnan
02ee13cfb2 return empty array when listing 2020-08-10 21:32:54 -07:00
Girish Ramakrishnan
096e244252 Fix typo that causes aliases in lists to bounce
https://forum.cloudron.io/topic/2890/bug-with-mailing-lists-that-point-to-aliases
2020-08-10 17:49:27 -07:00
Girish Ramakrishnan
bf5b7294a0 Add missing debugs 2020-08-10 14:54:37 -07:00
Girish Ramakrishnan
a5da266643 groups: when listing, return members as well 2020-08-10 13:50:18 -07:00
Girish Ramakrishnan
cf7bb49e15 More missing 5.5 changes 2020-08-10 10:16:09 -07:00
Girish Ramakrishnan
208b732bda yet more 5.5 changes 2020-08-10 10:07:50 -07:00
Girish Ramakrishnan
c73d93b8bd more 5.5 changes 2020-08-10 10:05:47 -07:00
Girish Ramakrishnan
98a96eae2b Update mongodb
part of #725
2020-08-10 09:36:56 -07:00
Girish Ramakrishnan
2f9fe30c9d sftp: only mount data dirs that exist
when restoring, the platform starts first and the sftp container
goes and creates app data dirs with root permission. this prevents
the app restore logic from downloading the backup since it expects
yellowtent perm
2020-08-09 12:10:20 -07:00
Girish Ramakrishnan
aeee8afc02 export database: fix async logic 2020-08-09 11:14:11 -07:00
Girish Ramakrishnan
e85f0a4f52 Rename to box-task
this way we can do systemctl stop box*
2020-08-09 11:14:11 -07:00
Johannes Zellner
da98649667 Ensure group listAllWitMembers also returns an ordered list 2020-08-09 11:34:53 +02:00
Girish Ramakrishnan
5ac08cc06b sftp: fix home directory path 2020-08-08 18:16:35 -07:00
Girish Ramakrishnan
da72597dd3 Fix start/stop task scripts for ubuntu 16 2020-08-08 11:10:02 -07:00
Girish Ramakrishnan
1f1c94de70 Fix certificate ordering logic
* app certs set by user are always preferred
* If fallback, choose fallback certs. ignore others
* If LE, try to pick LE certs. Otherwise, provider fallback.

Fixes #724
2020-08-07 23:02:24 -07:00
Girish Ramakrishnan
60b3fceea6 reset-failed state of tasks during startup 2020-08-07 22:41:09 -07:00
Girish Ramakrishnan
5073809486 More 5.5.0 changes 2020-08-07 22:20:20 -07:00
Girish Ramakrishnan
debd779cfd new public gpg key that doesn't expire
gpg --export admin@cloudron.io > releases.gpg
2020-08-07 22:17:30 -07:00
Girish Ramakrishnan
6b9454100e certs: remove caas backend 2020-08-07 17:58:27 -07:00
Girish Ramakrishnan
779ad24542 domains: remove caas backend, it is unused 2020-08-07 17:57:48 -07:00
Girish Ramakrishnan
b94dbf5fa3 remove restricted fallback cert
this feature was never used. iirc, it was for managed hosting
2020-08-07 17:57:25 -07:00
Girish Ramakrishnan
45c49c9757 route53: verifyDnsConfig lists zones using old API
It should be using the listHostedZonesByName API but it was using the old
API (which has a 100 zone limitation) because it was using old credentials.
2020-08-07 09:54:02 -07:00
Girish Ramakrishnan
91288c96b1 s3: set queue size to 3
fixes #691
2020-08-07 00:28:00 -07:00
Girish Ramakrishnan
f8e22a0730 Fix tests 2020-08-07 00:21:15 -07:00
Girish Ramakrishnan
114b45882a Set memory limit to 400M for tasks 2020-08-07 00:21:15 -07:00
Girish Ramakrishnan
b1b6f70118 Kill all tasks on shutdown and startup
BindsTo will kill all the tasks when systemctl stop box is executed.
But when restarted, it keeps the tasks running. Because of this behavior,
we kill the tasks on startup and stop of the box code.
2020-08-06 23:47:40 -07:00
Girish Ramakrishnan
648d42dfe4 Empty debug prints as undefined for some reason 2020-08-06 23:23:56 -07:00
Girish Ramakrishnan
99f989c384 run apptask and backup task with a nice
A child process inherits whatever nice value is held by the parent at the time that it is forked
2020-08-06 16:46:39 -07:00
Girish Ramakrishnan
2112c7d096 sudo: remove the nice support 2020-08-06 16:44:35 -07:00
Girish Ramakrishnan
ac63d00c93 run tasks as separate cgroup via systemd
this allows us to adjust the nice value and memory settings per task

part of #691
2020-08-06 16:43:14 -07:00
Girish Ramakrishnan
e04871f79f pass log file as argument to task worker
initially, i thought i can hardcode the log file into taskworker.js
depending on the task type but for apptask, it's not easy to get the
appId from the taskId unless we introspect task arguments as well.
it's easier for now to pass it as an argument.
2020-08-05 00:46:34 -07:00
Girish Ramakrishnan
182c162dc4 hardcode logging of box code to box.log 2020-08-04 13:30:18 -07:00
Johannes Zellner
822b38cc89 Fallback to NOOP callback if not supplied 2020-08-04 14:32:01 +02:00
Girish Ramakrishnan
d564003c87 backup cleaner: referenced backups must be counted as part of period
otherwise, we end up in a state where box backups keeps referencing
app backups and app backup cleanup is only performed on the remaining
app backups.
2020-08-03 21:22:27 -07:00
Girish Ramakrishnan
1b307632ab Use debug instead of console.* everywhere
No need to patch up console.* anymore

also removes supererror
2020-08-02 12:04:55 -07:00
Girish Ramakrishnan
aa747cea85 update postgresl for pg_stat_statements,plpgsql extensions (loomio) 2020-08-02 11:36:42 -07:00
Girish Ramakrishnan
f4a322478d cloudron.target is not needed 2020-08-01 20:00:20 -07:00
Girish Ramakrishnan
d2882433a5 run backup uploader with a nice of 15
the gzip takes a lot of cpu processing and hogs the CPU. With a nice
level, we give other things higher priority.

An alternate idea that was explored was to use cpulimit. This is to
send SIGSTOP and SIGCONT periodically but this will not make use of the
CPU if it's idle (unlike nice).

Another idea is to use cgroups, but it's not clear how to use it with
the dynamic setup we have.

part of #691
2020-07-31 18:23:36 -07:00
Girish Ramakrishnan
a94b175805 Add timing information for backups 2020-07-31 12:59:15 -07:00
Girish Ramakrishnan
37d81da806 do system checks once a day 2020-07-31 11:20:17 -07:00
Girish Ramakrishnan
d089444441 db upgrade: stop containers only after exporting
we cannot export if the containers were nuked in the platform logic.
for this reason, move the removal near the place where they get started.
2020-07-30 15:28:53 -07:00
Girish Ramakrishnan
b0d65a1bae rename startApps to markApps 2020-07-30 15:28:50 -07:00
Girish Ramakrishnan
16288cf277 better debug 2020-07-30 11:42:03 -07:00
Girish Ramakrishnan
7ddbabf781 Make the error message clearer 2020-07-30 11:29:43 -07:00
Girish Ramakrishnan
fe35f4497b Fix two typos 2020-07-30 10:58:24 -07:00
Girish Ramakrishnan
625463f6ab export the database before upgrade
it's possible that
a) backups are completely disabled
b) skip backup option is selected when upgrading

in the above cases, the dump file is not generated and thus any addon
upgrade will fail. to fix, we dump the db fresh for database upgrades.
2020-07-30 10:23:08 -07:00
Johannes Zellner
ff632b6816 Add more external ldap tests 2020-07-30 15:22:03 +02:00
Johannes Zellner
fbc666f178 Make externalldap sync more robust 2020-07-30 15:08:01 +02:00
Girish Ramakrishnan
d89bbdd50c Update to PostgreSQL 11 2020-07-29 21:54:05 -07:00
Girish Ramakrishnan
96f9aa39b2 add note on why we check for app updates separately 2020-07-29 20:27:06 -07:00
Girish Ramakrishnan
7330814d0f More 5.5 changes 2020-07-29 16:11:09 -07:00
Johannes Zellner
312efdcd94 Fix debug message 2020-07-29 20:38:46 +02:00
Girish Ramakrishnan
5db78ae359 Fix more usages of backup.intervalSecs 2020-07-29 11:25:59 -07:00
Girish Ramakrishnan
97967e60e8 remove yahoo from smtp test list 2020-07-29 11:25:59 -07:00
Johannes Zellner
9106b5d182 Avoid using extra /data dir for filemanager 2020-07-29 20:14:14 +02:00
Johannes Zellner
74bdb6cb9d Only mount app data volumes if localstorage is used 2020-07-29 19:58:41 +02:00
Johannes Zellner
0a44d426fa Explicitly mount all apps into the sftp container 2020-07-29 19:47:37 +02:00
Johannes Zellner
e1718c4e8d If app.dataDir is set, first unmount from sftp before deleting on uninstall 2020-07-29 17:54:32 +02:00
Girish Ramakrishnan
f511a610b5 backups: take a pattern instead of interval secs
part of #699
2020-07-28 21:54:56 -07:00
Girish Ramakrishnan
4d5715188d Increase invite link expiry to a week 2020-07-28 14:19:19 -07:00
Johannes Zellner
2ea21be5bd Add basic backup check route tests 2020-07-28 17:23:21 +02:00
Johannes Zellner
5bb0419699 Add backup check route
Part of #719
2020-07-28 17:18:50 +02:00
Johannes Zellner
a8131eed71 Run initial backup configuration check only after activation
Part of #719
2020-07-28 17:12:38 +02:00
Girish Ramakrishnan
ed09c06ba4 Add option to remove mailbox data
Fixes #720
2020-07-27 22:55:09 -07:00
Girish Ramakrishnan
3c59a0ff31 make it clear it is exported for testing 2020-07-27 22:07:25 -07:00
Girish Ramakrishnan
a6d24b3e48 postgresql: add btree_gist,postgres_fdw extensions for gitlab 2020-07-24 22:30:45 -07:00
Girish Ramakrishnan
060135eecb Next release is 5.5 2020-07-24 09:33:53 -07:00
Johannes Zellner
ef296c24fe Mount data custom app data location specifically into sftp addon
Fixes #722
2020-07-24 15:43:26 +02:00
Girish Ramakrishnan
707aaf25ec Add note on underscore in usernames 2020-07-23 16:29:54 -07:00
Girish Ramakrishnan
7edeb0c358 nginx displays version in stderr 2020-07-22 17:57:55 -07:00
Girish Ramakrishnan
e516af14b2 typo 2020-07-22 17:53:04 -07:00
Girish Ramakrishnan
4086f2671d Disable ldap/directory config/2fa in demo mode 2020-07-22 16:18:22 -07:00
Girish Ramakrishnan
23c4550430 Update postgresql addon to have citext extension for loomio 2020-07-22 08:29:44 -07:00
Johannes Zellner
31d25cd6be Add 5.4.1 changes 2020-07-19 21:11:05 +02:00
Johannes Zellner
07b3c7a245 Use sftp addon with fixed symlinks 2020-07-18 19:27:02 +02:00
Girish Ramakrishnan
a00b7281a7 Fixup changelog 2020-07-17 10:43:22 -07:00
Girish Ramakrishnan
ddeee0c970 Add note that links expire in 24 hours 2020-07-16 15:17:51 -07:00
Johannes Zellner
8aad71efd0 Add more feature flags 2020-07-16 18:14:25 +02:00
Johannes Zellner
2028f6b984 Do not reassign ubunt_codename in base image init 2020-07-16 16:42:15 +02:00
Girish Ramakrishnan
bff4999d27 mail: add mailbox count route 2020-07-15 15:48:30 -07:00
Johannes Zellner
d429015f83 Add more 3.4.0 changes 2020-07-15 14:57:06 +02:00
Johannes Zellner
e2628e2d43 Use latest filemanager addon
Fixes dot- and json-files
2020-07-14 17:16:41 +02:00
Girish Ramakrishnan
05dcbee7e3 backups: add b2 provider
part of #508
2020-07-13 14:52:35 -07:00
Johannes Zellner
a81919262e Use addon with chown functionality 2020-07-13 18:48:42 +02:00
Girish Ramakrishnan
b14b5f141b Hide nginx version 2020-07-13 09:27:57 -07:00
Girish Ramakrishnan
1259d11173 Add back provider field into getStatus 2020-07-13 08:46:05 -07:00
Johannes Zellner
0a7b132be8 Remove or increase timeouts for filemanager 2020-07-13 17:05:22 +02:00
Girish Ramakrishnan
ed9210eede Add mandatory 2FA flag
part of #716
2020-07-10 10:25:04 -07:00
Girish Ramakrishnan
9ee6aa54c6 avatar is not part of the profile lock
this is because avatar is not exposed via LDAP anyways. it's purely
a personal dashboard thing.
2020-07-10 09:43:42 -07:00
Girish Ramakrishnan
7cfc455cd3 make tests pass again
also disable column statistics on ubuntu 20
2020-07-10 09:33:35 -07:00
Johannes Zellner
a481ceac8c Allow larger file uploads for filemanager 2020-07-10 18:23:55 +02:00
Girish Ramakrishnan
8c7eff4e24 user: add routes to set/clear avatar 2020-07-10 07:23:38 -07:00
Girish Ramakrishnan
c6c584ff74 user: move avatar handling into model code 2020-07-10 07:01:15 -07:00
Johannes Zellner
ba50eb121d Use new sftp addon 2020-07-10 14:13:16 +02:00
Johannes Zellner
aa8ebbd7ea Add filemanager proxy routes 2020-07-10 14:10:52 +02:00
Girish Ramakrishnan
64bc9c6dbe disable profile view for all users to avoid confusion 2020-07-09 21:54:09 -07:00
Girish Ramakrishnan
bba9963b7c Add directoryConfig feature flag
Fixes #704
2020-07-09 21:51:22 -07:00
Girish Ramakrishnan
6ea2aa4a54 return profileLocked in config route
part of #704
2020-07-09 17:28:44 -07:00
Girish Ramakrishnan
3c3f81365b add route to get/set directory config
part of #704
2020-07-09 17:12:07 -07:00
Girish Ramakrishnan
3adeed381b setup account based on directory config
part of #704
2020-07-09 16:39:34 -07:00
Girish Ramakrishnan
0f5b7278b8 add directory config setting
part of #704
2020-07-09 16:02:58 -07:00
Girish Ramakrishnan
f94ff49fb9 users: replace modifiedAt with ts 2020-07-09 16:02:49 -07:00
Girish Ramakrishnan
d512a9c30d rename function 2020-07-09 16:02:43 -07:00
Girish Ramakrishnan
0c5113ed5b email is never used in account setup 2020-07-09 15:37:35 -07:00
Girish Ramakrishnan
2469f4cdff rename function to sendPasswordResetByIdentifier 2020-07-09 15:37:35 -07:00
Girish Ramakrishnan
9c53bfb7fb Do not show LDAP logs, it spams a lot 2020-07-07 11:16:47 -07:00
Girish Ramakrishnan
8b8144588d list must search members 2020-07-05 11:44:46 -07:00
Girish Ramakrishnan
77553da4c1 mail: add search param for mailbox and mailing list api 2020-07-05 11:23:53 -07:00
Girish Ramakrishnan
cbcf943691 mail: parameterize the query 2020-07-05 10:48:08 -07:00
Girish Ramakrishnan
725a19e5b5 mail: Add pagination to lists API
Fixes #712
2020-07-05 10:48:04 -07:00
Girish Ramakrishnan
f9115f902a Do not send alive status
we used to do this for managed hosting to track scaling but we don't
need this info anymore
2020-07-03 19:13:27 -07:00
Girish Ramakrishnan
e4faf26d74 5.3.4 changes
(cherry picked from commit 77785097c1)
2020-07-03 14:23:20 -07:00
Girish Ramakrishnan
1c96fbb533 Fixes for tests 2020-07-03 13:47:56 -07:00
Girish Ramakrishnan
3dc163c33d database: rework connection logic 2020-07-03 13:14:00 -07:00
Girish Ramakrishnan
edae94cf2e Bump max_connection for postgres addon to 200 2020-07-02 15:47:26 -07:00
Girish Ramakrishnan
d1ff8e9d6b Fix crash when mysql crashes 2020-07-02 15:10:05 -07:00
Girish Ramakrishnan
70743bd285 database: Fix event emitter warning
the connection object gets reused after release. this means that we keep
attaching the 'error' event and not unlistening.

--trace-warnings can be added to box.service to get the stack trace
2020-07-02 12:00:56 -07:00
Johannes Zellner
493f1505f0 Check also for mountpoint on filesystem with external disk 2020-07-02 19:08:27 +02:00
Girish Ramakrishnan
007e3b5eef Add changes 2020-07-01 14:29:40 -07:00
Johannes Zellner
d9bf6c0933 also support uniqueMember property next to member for ldap groups 2020-07-01 17:08:17 +02:00
Johannes Zellner
324344d118 Reusue the single correct ldap.createClient call also in auth 2020-07-01 14:59:26 +02:00
Johannes Zellner
5cb71e9443 No need to return externalLdapConfig in getClient() 2020-07-01 14:52:11 +02:00
Johannes Zellner
cca19f00c5 Fallback to mailPrimaryAddress in ldap sync 2020-07-01 14:34:41 +02:00
Girish Ramakrishnan
6648f41f3d nginx: [warn] the "ssl" directive is deprecated, use the "listen ... ssl" directive 2020-06-30 16:00:52 -07:00
Girish Ramakrishnan
c1e6b47fd6 Fix sogo aliases
Fixes cloudron/sogo#18
2020-06-30 14:29:50 -07:00
Girish Ramakrishnan
0f103ccce1 Add ping capability (for statping) 2020-06-30 07:40:17 -07:00
Girish Ramakrishnan
bc6e652293 5.3.3 changes 2020-06-29 19:52:08 -07:00
Girish Ramakrishnan
85b4f2dbdd print sudo command to check failures 2020-06-29 14:03:34 -07:00
Girish Ramakrishnan
d47b83a63b Package lock mystery 2020-06-29 14:03:15 -07:00
Girish Ramakrishnan
b2e9fa7e0d aschema: dd servicesConfigJson 2020-06-26 15:48:39 -07:00
Girish Ramakrishnan
a9fb444622 Use nginx 1.18 for security fixes 2020-06-26 14:57:53 -07:00
Girish Ramakrishnan
33ba22a021 Put this in 5.3.2 itself 2020-06-26 10:41:32 -07:00
Girish Ramakrishnan
57de0282cd remove provider from trackBeginSetup 2020-06-26 09:55:39 -07:00
Girish Ramakrishnan
8568fd26d8 Fix failing test 2020-06-26 09:48:10 -07:00
Girish Ramakrishnan
84f41e08cf Add mlock capability to manifest (for vault app) 2020-06-26 09:27:35 -07:00
Johannes Zellner
a96da20536 TODO is done for filesystem backend moutnpoint check 2020-06-26 17:57:26 +02:00
Johannes Zellner
5199a9342e Add missing ldap client error handling 2020-06-26 17:55:42 +02:00
Girish Ramakrishnan
893ecec0fa redis: Set maxmemory and maxmemory-policy 2020-06-26 08:54:47 -07:00
Girish Ramakrishnan
e3da6419f5 Add 5.3.2 changes 2020-06-26 08:48:01 -07:00
Girish Ramakrishnan
0750d2ba50 More changes 2020-06-25 16:48:11 -07:00
Girish Ramakrishnan
f1fcb65fbe Do not install sshfs. user will install it if they want
we don't use sshfs anywhere in our code ourselves
2020-06-25 12:21:49 -07:00
Girish Ramakrishnan
215aa65d5a Fix provider usage
* do not send to appstore anymore
* do not set in getStatus/getConfig
* provider is not needed when registering cloudron
2020-06-25 11:20:05 -07:00
Girish Ramakrishnan
85f67c13da remove unused registerWithLicense 2020-06-25 11:11:52 -07:00
Girish Ramakrishnan
6dcc478aeb add to changes 2020-06-25 09:20:37 -07:00
Johannes Zellner
3f2496db6f Support self-signed certs for external ldap/ad 2020-06-25 17:45:59 +02:00
Johannes Zellner
612f79f9e0 Copy over changes for 5.3.1 2020-06-25 14:22:44 +02:00
Johannes Zellner
90fb1cd735 We also need enableBackup property for app listing api 2020-06-25 12:31:00 +02:00
Girish Ramakrishnan
7c24d9c6c6 Give graphite more memory 2020-06-22 09:55:01 -07:00
Johannes Zellner
60f1b2356a Also make nfs storage provider same as cifs and sshfs 2020-06-22 15:51:05 +02:00
Johannes Zellner
0b8f21508f Add more changes 2020-06-22 12:04:34 +02:00
Johannes Zellner
ae128c0fa4 If no appstore account is setup restrict features to free plan 2020-06-22 12:02:10 +02:00
Girish Ramakrishnan
1b4ec9ecf9 Update changes 2020-06-18 10:25:45 -07:00
Girish Ramakrishnan
b0ce0b61d6 logging: fix crash when router errors 2020-06-18 09:27:09 -07:00
Girish Ramakrishnan
e1ffdaddfa Fix timeout issues in postgresql and mysql addon 2020-06-17 16:39:30 -07:00
Johannes Zellner
af8344f482 remove unused requires 2020-06-16 14:37:06 +02:00
Johannes Zellner
7dc2596b3b Ensure we support pre 5.3 Cloudron installation 2020-06-16 14:10:14 +02:00
Johannes Zellner
0109956fc2 do not rely on some argument passed through for infraversion base path 2020-06-16 14:09:55 +02:00
Johannes Zellner
945fe3f3ec Do not spam install logs with nodejs tarball contents 2020-06-16 13:58:23 +02:00
Johannes Zellner
9c868135f3 app sso flag is not restricted now 2020-06-16 13:09:06 +02:00
Girish Ramakrishnan
5be288023b update mail container to record separator and spam folder 2020-06-15 13:50:46 -07:00
Girish Ramakrishnan
a03f97186c Make mail auth case insensitive 2020-06-15 09:58:55 -07:00
Johannes Zellner
0aab891980 Support nginx logs 2020-06-15 17:30:16 +02:00
Johannes Zellner
5268d3f57d Fix test for systems without swap 2020-06-15 16:06:54 +02:00
Girish Ramakrishnan
129cbb5beb backups: fix cleanup
The various changes are:
* Latest backup is always kept for box and app backups
* If the latest backup is part of the policy, it is not counted twice
* Latest backup comes into action only when all backups are outside the retention policy
* For uninstalled apps, latest backup is not preserved
* This way the latest backup of apps that are not referenced in box backup is preserved.
  (for example, for stopped apps)

fixes #692
2020-06-14 22:06:00 -07:00
Girish Ramakrishnan
2601d2945d Fix backup tests 2020-06-14 14:01:01 -07:00
Girish Ramakrishnan
e3829eb24b typo 2020-06-14 14:00:29 -07:00
Girish Ramakrishnan
f6cb1a0863 backups: query using identifier instead of type
this allows us to move the enums into backups.js instead of backupdb.js
2020-06-14 12:27:41 -07:00
Girish Ramakrishnan
4f964101a0 add identifier to backups table 2020-06-14 11:39:44 -07:00
Girish Ramakrishnan
f6dcba025f auditSource is not used in the worker 2020-06-14 09:09:41 -07:00
Johannes Zellner
d6ec65d456 Do not remove alternateDomains to allow apps view filter to work 2020-06-14 13:39:15 +02:00
Girish Ramakrishnan
65d8074a07 Fix failing backup test 2020-06-12 12:58:11 -07:00
Girish Ramakrishnan
e3af61ca4a Fix failing test 2020-06-12 12:52:32 -07:00
Girish Ramakrishnan
a58f1268f0 mail: Add Auto-Submitted header to NDRs 2020-06-11 19:48:37 -07:00
Girish Ramakrishnan
41eacc4bc5 postgresql: Add unaccent extension 2020-06-11 09:53:53 -07:00
Girish Ramakrishnan
aabb9dee13 Fix transaction rollback logic 2020-06-11 09:50:49 -07:00
Girish Ramakrishnan
c855d75f35 remove mkdirp use
node 10.12 has { recursive: true }
2020-06-11 08:27:48 -07:00
Girish Ramakrishnan
8f5cdcf439 backups: some logs for debugging 2020-06-10 23:00:23 -07:00
Girish Ramakrishnan
984559427e update manifest format to 5.3.0 2020-06-09 11:35:54 -07:00
Johannes Zellner
89494ced41 Check for sshfs and cifs backup backends, if they are mounted 2020-06-08 17:46:52 +02:00
Johannes Zellner
ef764c2393 Merge sshfs.js into filesystem.js 2020-06-08 17:08:26 +02:00
Johannes Zellner
8624e2260d add storage api to make preflight checks
Currently there is only disk space checking but sshfs and cifs need
mount point checking as well
2020-06-08 16:25:05 +02:00
Johannes Zellner
aa011f4add add ldap group tests and fixes for the found issues 2020-06-07 13:49:01 +02:00
Girish Ramakrishnan
3df61c9ab8 do not automatically update unstable updates
part of #698
2020-06-05 16:26:23 -07:00
Girish Ramakrishnan
a4516776d6 make canAutoupdateApp take updateInfo object
part of #698
2020-06-05 16:06:37 -07:00
Girish Ramakrishnan
54d0ade997 curl uses -s and not -q 2020-06-05 13:50:40 -07:00
Johannes Zellner
3557fcd129 Add sshfs quirks to shared code in filesytstem.js 2020-06-05 13:45:25 +02:00
Johannes Zellner
330b4a613c Retrieve the backupPath from the storage provider itself 2020-06-05 13:27:18 +02:00
Johannes Zellner
7ba3412aae Add some sshfs config tests 2020-06-05 12:43:09 +02:00
Johannes Zellner
6f60495d4d Initial version of sshfs storage backend 2020-06-05 11:39:51 +02:00
Johannes Zellner
0b2eb8fb9e Sync users into groups
This does not yet remove users from groups

Part of #685
2020-06-05 11:28:57 +02:00
Johannes Zellner
48af17e052 Groups are lowercase on Cloudron 2020-06-05 10:13:19 +02:00
Johannes Zellner
b7b1055530 Avoid the pyramid 2020-06-05 09:26:52 +02:00
Johannes Zellner
e7029c0afd Remove unsused and poorly named groups.getGroups() API 2020-06-05 09:24:00 +02:00
Johannes Zellner
cba3674ac0 Stop ldap syncing if we hit some internal error 2020-06-05 09:03:30 +02:00
Girish Ramakrishnan
865a549885 say connected 2020-06-04 11:27:11 -07:00
Girish Ramakrishnan
50dcf827a5 remove console.error use in many places
the backtraces just flood the logs

apphealthtask: remove console.error
remove spurious console.dir
cleanup scheduler error logging
2020-06-04 11:21:56 -07:00
Girish Ramakrishnan
f5fb582f83 log status and message in morgan
connect lastmile does not forward final handler to express anymore.
otherwise, express logs using console.error()
https://github.com/expressjs/express/issues/2263
2020-06-04 09:17:58 -07:00
Girish Ramakrishnan
dbba502f83 remove message from debug 2020-06-04 09:17:58 -07:00
Girish Ramakrishnan
aae49f16a2 database: do no reconnect in query 2020-06-04 09:17:58 -07:00
Girish Ramakrishnan
45d5f8c74d make rollback return an error
fixes #690
2020-06-04 09:17:58 -07:00
Girish Ramakrishnan
6cfd64e536 database: do not crash if connection errors
Part of #690
2020-06-04 09:17:58 -07:00
Girish Ramakrishnan
c5cc404b3e do not retry here
Part of #690
2020-06-04 09:17:58 -07:00
Johannes Zellner
42cbcc6ce3 groups.create() now needs source argument 2020-06-04 14:20:05 +02:00
Johannes Zellner
812bdcd462 Fix groups test by ensuring we order by name 2020-06-04 14:03:06 +02:00
Johannes Zellner
f275409ee8 Fix cloudron api tests 2020-06-04 13:55:47 +02:00
Johannes Zellner
8994ac3727 Fix backup retention tests 2020-06-04 13:43:25 +02:00
Johannes Zellner
7c5ff5e4d5 Create user groups for ldap groups 2020-06-04 13:26:13 +02:00
Johannes Zellner
c5e84d5469 Add source property to userGroups 2020-06-04 13:25:55 +02:00
Johannes Zellner
c143450dc6 WIP 2020-06-04 12:59:27 +02:00
Johannes Zellner
07b95c2c4b Add groups.getByName() 2020-06-04 12:48:35 +02:00
Johannes Zellner
c30734f7f3 Show in the logs if group sync is disabled 2020-06-04 12:40:28 +02:00
Johannes Zellner
91f506c17b Explicitly enable/disable ldap group sync 2020-06-04 12:28:31 +02:00
Girish Ramakrishnan
7a17695ad5 Retry in 10 seconds to not make things worse
Part of #690
2020-06-03 16:05:48 -07:00
Girish Ramakrishnan
f5076c87d4 add to changes 2020-06-03 13:52:53 -07:00
Girish Ramakrishnan
a47d6e1f3a cloudron-setup: --provider is dead
Long live --provider

Part of #693
2020-06-03 13:47:30 -07:00
Girish Ramakrishnan
f6ff1abb00 cloudron-setup: remove --license arg. unused 2020-06-03 13:16:39 -07:00
Johannes Zellner
386aaf6470 Initial code to fetch LDAP groups during sync 2020-06-03 22:12:38 +02:00
Johannes Zellner
2b3c4cf0ff avatar blob now comes in only via branding api calls 2020-06-02 15:13:50 +02:00
Girish Ramakrishnan
b602e921d0 better error message if domains exists 2020-06-01 16:11:02 -07:00
Girish Ramakrishnan
2fc3cdc2a2 remove superfluous debug 2020-06-01 09:40:56 -07:00
Girish Ramakrishnan
e2cadbfc30 Fix uniqueness constraint in app passwords table
Fixes #688
2020-05-30 13:25:29 -07:00
Girish Ramakrishnan
3ffa935da7 Revert "part focal support"
This reverts commit 7d36533524.

not ready yet
2020-05-30 10:58:28 -07:00
Girish Ramakrishnan
5f539e331a 5.3.0 changes 2020-05-30 09:45:24 -07:00
Girish Ramakrishnan
356d0fabda Add note that pattern must match dashboard code 2020-05-30 09:44:33 -07:00
Girish Ramakrishnan
122ec75cb6 Fix links 2020-05-29 19:10:42 -07:00
Girish Ramakrishnan
a3a48e1a49 poll for updates a bit more often 2020-05-29 13:39:16 -07:00
Girish Ramakrishnan
4ede765e1f typo: memoryLimit -> memory 2020-05-29 13:29:01 -07:00
Girish Ramakrishnan
4fa181b346 re-use the latest backup id for non-backupable apps
for stopped apps, as an example
2020-05-28 14:16:38 -07:00
Johannes Zellner
4f76d91ae9 Add backup_config settings API tests 2020-05-28 21:42:25 +02:00
Girish Ramakrishnan
20d1759fa5 Run update checker on stopped apps, we just don't update them 2020-05-28 12:41:51 -07:00
Girish Ramakrishnan
433e783ede do not allow backup, import, update in stopped state 2020-05-28 12:41:51 -07:00
Johannes Zellner
47f47d916d Fixup tests 2020-05-28 21:05:21 +02:00
Johannes Zellner
b31ac7d1fd Revert backup policy fallback and check in rest api
Check is now in proper location at backups.testConfig()
2020-05-28 20:44:44 +02:00
Johannes Zellner
ea47fb7305 Properly check for backup policy in testConfig() 2020-05-28 20:44:44 +02:00
Girish Ramakrishnan
82170f8f1b Fix failing test 2020-05-28 11:04:39 -07:00
Girish Ramakrishnan
acb2655f58 rename variable (it ensures backup and may not actually backup) 2020-05-28 11:03:49 -07:00
Girish Ramakrishnan
b1464517e6 centralize all the cron patterns in one place 2020-05-28 11:01:46 -07:00
Girish Ramakrishnan
151e6351f6 add couple of 5.2 changes 2020-05-28 09:37:57 -07:00
Johannes Zellner
154f768281 Forgot .length 2020-05-28 16:44:45 +02:00
Johannes Zellner
90c857e8fc Further validate retentionPolicy api input 2020-05-28 16:27:07 +02:00
Johannes Zellner
7a3efa2631 Ensure we get a proper retention policy for backups 2020-05-28 16:26:21 +02:00
Girish Ramakrishnan
38cc767f27 move up the backup cron to not overlap auto-updates 2020-05-27 23:04:04 -07:00
Girish Ramakrishnan
e1a718c78f remove redundant call to canBackupApp 2020-05-27 22:48:48 -07:00
Girish Ramakrishnan
32a4450e5e 5.2.4 changes
(cherry picked from commit 2dc7342f09)
2020-05-27 22:35:30 -07:00
Girish Ramakrishnan
fca3f606d2 Do not backup stopped apps 2020-05-27 21:04:01 -07:00
Girish Ramakrishnan
4a0a934a76 start using vhost style for accessing s3 style storage
if bucket name has a '.', accept self-signed

fixes #680
2020-05-27 17:50:37 -07:00
Girish Ramakrishnan
f7c406bec9 s3: bucket name cannot contain _ or capitals or .
we can make it more elaborate, but not sure if it's needed

https://blogs.easydynamics.com/2016/10/24/aws-s3-bucket-name-validation-regex/
2020-05-27 17:01:42 -07:00
Girish Ramakrishnan
f4807a6354 update many node modules 2020-05-27 16:52:22 -07:00
Girish Ramakrishnan
0960008b7b 5.2.4 changes
(cherry picked from commit 4267f5ea0a)
2020-05-26 17:07:03 -07:00
Girish Ramakrishnan
04a1aa38b4 Add CIFS as storage provider
part of #686
2020-05-26 15:31:45 -07:00
Girish Ramakrishnan
f84622efa1 fs: add create/unlink tests 2020-05-26 15:31:41 -07:00
Girish Ramakrishnan
f6c4614275 Do not restart stopped apps
(cherry picked from commit 2e76b8bed9)
2020-05-26 07:54:35 -07:00
Girish Ramakrishnan
7d36533524 part focal support
part of #684
2020-05-25 19:49:15 -07:00
Girish Ramakrishnan
5cd3df4869 better nginx config for higher loads 2020-05-25 15:25:00 -07:00
120 changed files with 3672 additions and 2275 deletions

116
CHANGES
View File

@@ -1935,6 +1935,8 @@
* stopping an app will stop dependent services
* Add new wasabi s3 storage region us-east-2
* mail: Fix bug where SRS translation was done on the main domain instead of mailing list domain
* backups: add retention policy
* Drop `NET_RAW` caps from container preventing sniffing of network traffic
[5.2.1]
* Fix app disk graphs
@@ -1949,3 +1951,117 @@
* import: fix crash because encryption is unset
* create redis with the correct label
[5.2.3]
* Do not restart stopped apps
[5.2.4]
* mail: enable/disable incoming mail was showing an error
* Do not trigger backup of stopped apps. Instead, we will just retain it's existing backups
based on retention policy
* remove broken disk graphs
* fix OVH backups
[5.3.0]
* better nginx config for higher loads
* backups: add CIFS storage provider
* backups: add SSHFS storage provider
* backups: add NFS storage provider
* s3: use vhost style
* Fix crash when redis config was set
* Update schedule was unselected in the UI
* cloudron-setup: --provider is now optional
* show warning for unstable updates
* add forumUrl to app manifest
* postgresql: add unaccent extension for peertube
* mail: Add Auto-Submitted header to NDRs
* backups: ensure that the latest backup of installed apps is always preserved
* add nginx logs
* mail: make authentication case insensitive
* Fix timeout issues in postgresql and mysql addon
* Do not count stopped apps for memory use
* LDAP group synchronization
[5.3.1]
* better nginx config for higher loads
* backups: add CIFS storage provider
* backups: add SSHFS storage provider
* backups: add NFS storage provider
* s3: use vhost style
* Fix crash when redis config was set
* Update schedule was unselected in the UI
* cloudron-setup: --provider is now optional
* show warning for unstable updates
* add forumUrl to app manifest
* postgresql: add unaccent extension for peertube
* mail: Add Auto-Submitted header to NDRs
* backups: ensure that the latest backup of installed apps is always preserved
* add nginx logs
* mail: make authentication case insensitive
* Fix timeout issues in postgresql and mysql addon
* Do not count stopped apps for memory use
* LDAP group synchronization
[5.3.2]
* Do not install sshfs package
* 'provider' is not required anymore in various API calls
* redis: Set maxmemory and maxmemory-policy
* Add mlock capability to manifest (for vault app)
[5.3.3]
* Fix issue where some postinstall messages where causing angular to infinite loop
[5.3.4]
* Fix issue in database error handling
[5.4.0]
* Update nginx to 1.18 for various security fixes
* Add ping capability (for statping app)
* Fix bug where aliases were displayed incorrectly in SOGo
* Add univention as LDAP provider
* Bump max_connection for postgres addon to 200
* mail: Add pagination to mailing list API
* Allow admin to lock email and display name of users
* Allow admin to ensure all users have 2FA setup
* ami: fix regression where we didn't send provider as part of get status call
* nginx: hide version
* backups: add b2 provider
* Add filemanager webinterface
* Add darkmode
* Add note that password reset and invite links expire in 24 hours
[5.4.1]
* Update nginx to 1.18 for various security fixes
* Add ping capability (for statping app)
* Fix bug where aliases were displayed incorrectly in SOGo
* Add univention as LDAP provider
* Bump max_connection for postgres addon to 200
* mail: Add pagination to mailing list API
* Allow admin to lock email and display name of users
* Allow admin to ensure all users have 2FA setup
* ami: fix regression where we didn't send provider as part of get status call
* nginx: hide version
* backups: add b2 provider
* Add filemanager webinterface
* Add darkmode
* Add note that password reset and invite links expire in 24 hours
[5.5.0]
* postgresql: update to PostgreSQL 11
* postgresql: add citext extension to whitelist for loomio
* postgresql: add btree_gist,postgres_fdw,pg_stat_statements,plpgsql extensions for gitlab
* SFTP/Filebrowser: fix access of external data directories
* Fix contrast issues in dark mode
* Add option to delete mailbox data when mailbox is delete
* Allow days/hours of backups and updates to be configurable
* backup cleaner: fix issue where referenced backups where not counted against time periods
* route53: fix issue where verification failed if user had more than 100 zones
* rework task workers to run them in a separate cgroup
* backups: now much faster thanks to reworking of task worker
* When custom fallback cert is set, make sure it's used over LE certs
* mongodb: update to MongoDB 4.0.19
* List groups ordered by name
* Invite links are now valid for a week
* Update release GPG key
* Add pre-defined variables ($CLOUDRON_APPID) for better post install messages
* filemanager: show folder first

View File

@@ -4,8 +4,7 @@ set -euv -o pipefail
readonly SOURCE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
readonly arg_provider="${1:-generic}"
readonly arg_infraversionpath="${SOURCE_DIR}/${2:-}"
readonly arg_infraversionpath="${SOURCE_DIR}/../src"
function die {
echo $1
@@ -14,6 +13,9 @@ function die {
export DEBIAN_FRONTEND=noninteractive
readonly ubuntu_codename=$(lsb_release -cs)
readonly ubuntu_version=$(lsb_release -rs)
# hold grub since updating it breaks on some VPS providers. also, dist-upgrade will trigger it
apt-mark hold grub* >/dev/null
apt-get -o Dpkg::Options::="--force-confdef" update -y
@@ -27,8 +29,6 @@ debconf-set-selections <<< 'mysql-server mysql-server/root_password_again passwo
# this enables automatic security upgrades (https://help.ubuntu.com/community/AutomaticSecurityUpdates)
# resolvconf is needed for unbound to work property after disabling systemd-resolved in 18.04
ubuntu_version=$(lsb_release -rs)
ubuntu_codename=$(lsb_release -cs)
gpg_package=$([[ "${ubuntu_version}" == "16.04" ]] && echo "gnupg" || echo "gpg")
apt-get -y install \
acl \
@@ -53,16 +53,11 @@ apt-get -y install \
unbound \
xfsprogs
if [[ "${ubuntu_version}" == "16.04" ]]; then
echo "==> installing nginx for xenial for TLSv3 support"
curl -sL http://nginx.org/packages/ubuntu/pool/nginx/n/nginx/nginx_1.14.0-1~xenial_amd64.deb -o /tmp/nginx.deb
# apt install with install deps (as opposed to dpkg -i)
apt install -y /tmp/nginx.deb
rm /tmp/nginx.deb
else
apt install -y nginx-full
fi
echo "==> installing nginx for xenial for TLSv3 support"
curl -sL http://nginx.org/packages/ubuntu/pool/nginx/n/nginx/nginx_1.18.0-1~${ubuntu_codename}_amd64.deb -o /tmp/nginx.deb
# apt install with install deps (as opposed to dpkg -i)
apt install -y /tmp/nginx.deb
rm /tmp/nginx.deb
# on some providers like scaleway the sudo file is changed and we want to keep the old one
apt-get -o Dpkg::Options::="--force-confold" install -y sudo
@@ -73,7 +68,7 @@ cp /usr/share/unattended-upgrades/20auto-upgrades /etc/apt/apt.conf.d/20auto-upg
echo "==> Installing node.js"
mkdir -p /usr/local/node-10.18.1
curl -sL https://nodejs.org/dist/v10.18.1/node-v10.18.1-linux-x64.tar.gz | tar zxvf - --strip-components=1 -C /usr/local/node-10.18.1
curl -sL https://nodejs.org/dist/v10.18.1/node-v10.18.1-linux-x64.tar.gz | tar zxf - --strip-components=1 -C /usr/local/node-10.18.1
ln -sf /usr/local/node-10.18.1/bin/node /usr/bin/node
ln -sf /usr/local/node-10.18.1/bin/npm /usr/bin/npm
apt-get install -y python # Install python which is required for npm rebuild

83
box.js
View File

@@ -2,57 +2,60 @@
'use strict';
// prefix all output with a timestamp
// debug() already prefixes and uses process.stderr NOT console.*
['log', 'info', 'warn', 'debug', 'error'].forEach(function (log) {
var orig = console[log];
console[log] = function () {
orig.apply(console, [new Date().toISOString()].concat(Array.prototype.slice.call(arguments)));
};
});
require('supererror')({ splatchError: true });
let async = require('async'),
constants = require('./src/constants.js'),
dockerProxy = require('./src/dockerproxy.js'),
fs = require('fs'),
ldap = require('./src/ldap.js'),
server = require('./src/server.js');
paths = require('./src/paths.js'),
server = require('./src/server.js'),
util = require('util');
console.log();
console.log('==========================================');
console.log(` Cloudron ${constants.VERSION} `);
console.log('==========================================');
console.log();
const NOOP_CALLBACK = function () { };
function setupLogging(callback) {
if (process.env.BOX_ENV === 'test') return callback();
fs.open(paths.BOX_LOG_FILE, 'a', function (error, fd) {
if (error) return callback(error);
require('debug').log = function (...args) {
fs.appendFileSync(fd, util.format(...args) + '\n');
};
callback();
});
}
async.series([
setupLogging,
server.start,
ldap.start,
dockerProxy.start
], function (error) {
if (error) {
console.error('Error starting server', error);
console.log('Error starting server', error);
process.exit(1);
}
console.log('Cloudron is up and running');
});
var NOOP_CALLBACK = function () { };
process.on('SIGINT', function () {
console.log('Received SIGINT. Shutting down.');
server.stop(NOOP_CALLBACK);
ldap.stop(NOOP_CALLBACK);
dockerProxy.stop(NOOP_CALLBACK);
setTimeout(process.exit.bind(process), 3000);
});
process.on('SIGTERM', function () {
console.log('Received SIGTERM. Shutting down.');
server.stop(NOOP_CALLBACK);
ldap.stop(NOOP_CALLBACK);
dockerProxy.stop(NOOP_CALLBACK);
setTimeout(process.exit.bind(process), 3000);
const debug = require('debug')('box:box'); // require this here so that logging handler is already setup
process.on('SIGINT', function () {
debug('Received SIGINT. Shutting down.');
server.stop(NOOP_CALLBACK);
ldap.stop(NOOP_CALLBACK);
dockerProxy.stop(NOOP_CALLBACK);
setTimeout(process.exit.bind(process), 3000);
});
process.on('SIGTERM', function () {
debug('Received SIGTERM. Shutting down.');
server.stop(NOOP_CALLBACK);
ldap.stop(NOOP_CALLBACK);
dockerProxy.stop(NOOP_CALLBACK);
setTimeout(process.exit.bind(process), 3000);
});
console.log(`Cloudron is up and running. Logs are at ${paths.BOX_LOG_FILE}`); // this goes to journalctl
});

View File

@@ -12,8 +12,6 @@ exports.up = function(db, callback) {
db.all('SELECT * FROM users WHERE admin=1', function (error, results) {
if (error) return done(error);
console.dir(results);
async.eachSeries(results, function (r, next) {
db.runSql('INSERT INTO groupMembers (groupId, userId) VALUES (?, ?)', [ ADMIN_GROUP_ID, r.id ], next);
}, done);

View File

@@ -0,0 +1,18 @@
'use strict';
exports.up = function(db, callback) {
db.all('SELECT value FROM settings WHERE name="backup_config"', function (error, results) {
if (error || results.length === 0) return callback(error);
var backupConfig = JSON.parse(results[0].value);
if (backupConfig.provider !== 'minio' && backupConfig.provider !== 's3-v4-compat') return callback();
backupConfig.s3ForcePathStyle = true; // usually minio is self-hosted. s3 v4 compat, we don't know
db.runSql('UPDATE settings SET value=? WHERE name="backup_config"', [ JSON.stringify(backupConfig) ], callback);
});
};
exports.down = function(db, callback) {
callback();
};

View File

@@ -0,0 +1,17 @@
'use strict';
var async = require('async');
exports.up = function(db, callback) {
// http://stackoverflow.com/questions/386294/what-is-the-maximum-length-of-a-valid-email-address
async.series([
db.runSql.bind(db, 'ALTER TABLE appPasswords DROP INDEX name'),
db.runSql.bind(db, 'ALTER TABLE appPasswords ADD CONSTRAINT appPasswords_name_userId_identifier UNIQUE (name, userId, identifier)'),
], callback);
};
exports.down = function(db, callback) {
callback();
};

View File

@@ -0,0 +1,17 @@
'use strict';
exports.up = function(db, callback) {
db.runSql('ALTER TABLE userGroups ADD COLUMN source VARCHAR(128) DEFAULT ""', function (error) {
if (error) return callback(error);
callback();
});
};
exports.down = function(db, callback) {
db.runSql('ALTER TABLE userGroups DROP COLUMN source', function (error) {
if (error) console.error(error);
callback(error);
});
};

View File

@@ -0,0 +1,38 @@
'use strict';
const async = require('async');
exports.up = function(db, callback) {
db.runSql('ALTER TABLE backups ADD COLUMN identifier VARCHAR(128)', function (error) {
if (error) return callback(error);
db.all('SELECT * FROM backups', function (error, backups) {
if (error) return callback(error);
async.eachSeries(backups, function (backup, next) {
let identifier = 'unknown';
if (backup.type === 'box') {
identifier = 'box';
} else {
const match = backup.id.match(/app_(.+?)_.+/);
if (match) identifier = match[1];
}
db.runSql('UPDATE backups SET identifier=? WHERE id=?', [ identifier, backup.id ], next);
}, function (error) {
if (error) return callback(error);
db.runSql('ALTER TABLE backups MODIFY COLUMN identifier VARCHAR(128) NOT NULL', callback);
});
});
});
};
exports.down = function(db, callback) {
db.runSql('ALTER TABLE backups DROP COLUMN identifier', function (error) {
if (error) console.error(error);
callback(error);
});
};

View File

@@ -0,0 +1,16 @@
'use strict';
exports.up = function(db, callback) {
db.runSql('ALTER TABLE users ADD COLUMN ts TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP', function (error) {
if (error) console.error(error);
db.runSql('ALTER TABLE users DROP COLUMN modifiedAt', callback);
});
};
exports.down = function(db, callback) {
db.runSql('ALTER TABLE users DROP COLUMN ts', function (error) {
if (error) console.error(error);
callback(error);
});
};

View File

@@ -0,0 +1,29 @@
'use strict';
exports.up = function(db, callback) {
db.all('SELECT value FROM settings WHERE name="backup_config"', function (error, results) {
if (error || results.length === 0) return callback(error);
var backupConfig = JSON.parse(results[0].value);
if (backupConfig.intervalSecs === 6 * 60 * 60) { // every 6 hours
backupConfig.schedulePattern = '00 00 5,11,17,23 * * *';
} else if (backupConfig.intervalSecs === 12 * 60 * 60) { // every 12 hours
backupConfig.schedulePattern = '00 00 5,17 * * *';
} else if (backupConfig.intervalSecs === 24 * 60 * 60) { // every day
backupConfig.schedulePattern = '00 00 23 * * *';
} else if (backupConfig.intervalSecs === 3 * 24 * 60 * 60) { // every 3 days (based on day)
backupConfig.schedulePattern = '00 00 23 * * 1,3,5';
} else if (backupConfig.intervalSecs === 7 * 24 * 60 * 60) { // every week (saturday)
backupConfig.schedulePattern = '00 00 23 * * 6';
} else { // default to everyday
backupConfig.schedulePattern = '00 00 23 * * *';
}
delete backupConfig.intervalSecs;
db.runSql('UPDATE settings SET value=? WHERE name="backup_config"', [ JSON.stringify(backupConfig) ], callback);
});
};
exports.down = function(db, callback) {
callback();
};

View File

@@ -21,7 +21,7 @@ CREATE TABLE IF NOT EXISTS users(
password VARCHAR(1024) NOT NULL,
salt VARCHAR(512) NOT NULL,
createdAt VARCHAR(512) NOT NULL,
modifiedAt VARCHAR(512) NOT NULL,
ts TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
displayName VARCHAR(512) DEFAULT "",
fallbackEmail VARCHAR(512) DEFAULT "",
twoFactorAuthenticationSecret VARCHAR(128) DEFAULT "",
@@ -37,6 +37,7 @@ CREATE TABLE IF NOT EXISTS users(
CREATE TABLE IF NOT EXISTS userGroups(
id VARCHAR(128) NOT NULL UNIQUE,
name VARCHAR(254) NOT NULL UNIQUE,
source VARCHAR(128) DEFAULT "",
PRIMARY KEY(id));
CREATE TABLE IF NOT EXISTS groupMembers(
@@ -87,6 +88,7 @@ CREATE TABLE IF NOT EXISTS apps(
taskId INTEGER, // current task
errorJson TEXT,
bindsJson TEXT, // bind mounts
servicesConfigJson TEXT, // app services configuration
FOREIGN KEY(mailboxDomain) REFERENCES domains(domain),
FOREIGN KEY(taskId) REFERENCES tasks(id),
@@ -124,6 +126,7 @@ CREATE TABLE IF NOT EXISTS backups(
packageVersion VARCHAR(128) NOT NULL, /* app version or box version */
encryptionVersion INTEGER, /* when null, unencrypted backup */
type VARCHAR(16) NOT NULL, /* 'box' or 'app' */
identifier VARCHAR(128) NOT NULL, /* 'box' or the app id */
dependsOn TEXT, /* comma separate list of objects this backup depends on */
state VARCHAR(16) NOT NULL,
manifestJson TEXT, /* to validate if the app can be installed in this version of box */
@@ -219,7 +222,7 @@ CREATE TABLE IF NOT EXISTS notifications(
message TEXT,
acknowledged BOOLEAN DEFAULT false,
creationTime TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY appPasswords_name_appId_identifier (name, userId, identifier),
PRIMARY KEY (id)
);
@@ -231,7 +234,7 @@ CREATE TABLE IF NOT EXISTS appPasswords(
hashedPassword VARCHAR(1024) NOT NULL,
creationTime TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(userId) REFERENCES users(id),
UNIQUE (name, userId),
PRIMARY KEY (id)
);

772
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -18,33 +18,33 @@
"@google-cloud/storage": "^2.5.0",
"@sindresorhus/df": "git+https://github.com/cloudron-io/df.git#type",
"async": "^2.6.3",
"aws-sdk": "^2.610.0",
"aws-sdk": "^2.685.0",
"body-parser": "^1.19.0",
"cloudron-manifestformat": "^5.1.1",
"cloudron-manifestformat": "^5.5.0",
"connect": "^3.7.0",
"connect-lastmile": "^1.2.2",
"connect-lastmile": "^2.0.0",
"connect-timeout": "^1.9.0",
"cookie-session": "^1.4.0",
"cron": "^1.8.2",
"db-migrate": "^0.11.6",
"db-migrate": "^0.11.11",
"db-migrate-mysql": "^1.1.10",
"debug": "^4.1.1",
"dockerode": "^2.5.8",
"ejs": "^2.6.1",
"ejs-cli": "^2.1.1",
"ejs-cli": "^2.2.0",
"express": "^4.17.1",
"js-yaml": "^3.13.1",
"js-yaml": "^3.14.0",
"json": "^9.0.6",
"ldapjs": "^1.0.2",
"lodash": "^4.17.15",
"lodash.chunk": "^4.2.0",
"mime": "^2.4.4",
"moment": "^2.25.3",
"moment-timezone": "^0.5.27",
"morgan": "^1.9.1",
"mime": "^2.4.6",
"moment": "^2.26.0",
"moment-timezone": "^0.5.31",
"morgan": "^1.10.0",
"multiparty": "^4.2.1",
"mysql": "^2.18.1",
"nodemailer": "^6.4.2",
"nodemailer": "^6.4.6",
"nodemailer-smtp-transport": "^2.7.4",
"once": "^1.4.0",
"parse-links": "^0.1.0",
@@ -52,34 +52,33 @@
"progress-stream": "^2.0.0",
"proxy-middleware": "^0.15.0",
"qrcode": "^1.4.4",
"readdirp": "^3.3.0",
"request": "^2.88.0",
"readdirp": "^3.4.0",
"request": "^2.88.2",
"rimraf": "^2.6.3",
"s3-block-read-stream": "^0.5.0",
"safetydance": "^1.0.0",
"safetydance": "^1.1.1",
"semver": "^6.1.1",
"showdown": "^1.9.1",
"speakeasy": "^2.0.0",
"split": "^1.0.1",
"superagent": "^5.2.1",
"supererror": "^0.7.2",
"superagent": "^5.2.2",
"tar-fs": "github:cloudron-io/tar-fs#ignore_stat_error",
"tar-stream": "^2.1.0",
"tar-stream": "^2.1.2",
"tldjs": "^2.3.1",
"underscore": "^1.9.2",
"underscore": "^1.10.2",
"uuid": "^3.4.0",
"validator": "^11.0.0",
"ws": "^7.2.1",
"ws": "^7.3.0",
"xml2js": "^0.4.23"
},
"devDependencies": {
"expect.js": "*",
"hock": "^1.3.3",
"js2xmlparser": "^4.0.0",
"hock": "^1.4.1",
"js2xmlparser": "^4.0.1",
"mocha": "^6.1.4",
"mock-aws-s3": "git+https://github.com/cloudron-io/mock-aws-s3.git",
"nock": "^10.0.6",
"node-sass": "^4.12.0",
"node-sass": "^4.14.1",
"recursive-readdir": "^2.2.2"
},
"scripts": {

View File

@@ -41,16 +41,14 @@ if systemctl -q is-active box; then
fi
initBaseImage="true"
# provisioning data
provider=""
provider="generic"
requestedVersion=""
apiServerOrigin="https://api.cloudron.io"
webServerOrigin="https://cloudron.io"
sourceTarballUrl=""
rebootServer="true"
license=""
args=$(getopt -o "" -l "help,skip-baseimage-init,provider:,version:,env:,skip-reboot,license:" -n "$0" -- "$@")
args=$(getopt -o "" -l "help,skip-baseimage-init,provider:,version:,env:,skip-reboot" -n "$0" -- "$@")
eval set -- "${args}"
while true; do
@@ -67,7 +65,6 @@ while true; do
webServerOrigin="https://staging.cloudron.io"
fi
shift 2;;
--license) license="$2"; shift 2;;
--skip-baseimage-init) initBaseImage="false"; shift;;
--skip-reboot) rebootServer="false"; shift;;
--) break;;
@@ -91,48 +88,6 @@ fi
# Can only write after we have confirmed script has root access
echo "Running cloudron-setup with args : $@" > "${LOG_FILE}"
# validate arguments in the absence of data
readonly AVAILABLE_PROVIDERS="azure, caas, cloudscale, contabo, digitalocean, ec2, exoscale, gce, hetzner, interox, lightsail, linode, netcup, ovh, rosehosting, scaleway, skysilk, time4vps, upcloud, vultr or generic"
if [[ -z "${provider}" ]]; then
echo "--provider is required ($AVAILABLE_PROVIDERS)"
exit 1
elif [[ \
"${provider}" != "ami" && \
"${provider}" != "azure" && \
"${provider}" != "azure-image" && \
"${provider}" != "caas" && \
"${provider}" != "cloudscale" && \
"${provider}" != "contabo" && \
"${provider}" != "digitalocean" && \
"${provider}" != "digitalocean-mp" && \
"${provider}" != "ec2" && \
"${provider}" != "exoscale" && \
"${provider}" != "gce" && \
"${provider}" != "hetzner" && \
"${provider}" != "interox" && \
"${provider}" != "interox-image" && \
"${provider}" != "lightsail" && \
"${provider}" != "linode" && \
"${provider}" != "linode-oneclick" && \
"${provider}" != "linode-stackscript" && \
"${provider}" != "netcup" && \
"${provider}" != "netcup-image" && \
"${provider}" != "ovh" && \
"${provider}" != "rosehosting" && \
"${provider}" != "scaleway" && \
"${provider}" != "skysilk" && \
"${provider}" != "skysilk-image" && \
"${provider}" != "time4vps" && \
"${provider}" != "time4vps-image" && \
"${provider}" != "upcloud" && \
"${provider}" != "upcloud-image" && \
"${provider}" != "vultr" && \
"${provider}" != "generic" \
]]; then
echo "--provider must be one of: $AVAILABLE_PROVIDERS"
exit 1
fi
echo ""
echo "##############################################"
echo " Cloudron Setup (${requestedVersion:-latest})"
@@ -151,12 +106,6 @@ if [[ "${initBaseImage}" == "true" ]]; then
exit 1
fi
echo "=> Ensure required apt sources"
if ! add-apt-repository universe &>> "${LOG_FILE}"; then
echo "Could not add required apt sources (for nginx-full). See ${LOG_FILE}"
exit 1
fi
echo "=> Updating apt and installing script dependencies"
if ! apt-get update &>> "${LOG_FILE}"; then
echo "Could not update package repositories. See ${LOG_FILE}"
@@ -196,20 +145,19 @@ fi
if [[ "${initBaseImage}" == "true" ]]; then
echo -n "=> Installing base dependencies and downloading docker images (this takes some time) ..."
if ! /bin/bash "${box_src_tmp_dir}/baseimage/initializeBaseUbuntuImage.sh" "${provider}" "../src" &>> "${LOG_FILE}"; then
# initializeBaseUbuntuImage.sh args (provider, infraversion path) are only to support installation of pre 5.3 Cloudrons
if ! /bin/bash "${box_src_tmp_dir}/baseimage/initializeBaseUbuntuImage.sh" "generic" "../src" &>> "${LOG_FILE}"; then
echo "Init script failed. See ${LOG_FILE} for details"
exit 1
fi
echo ""
fi
# NOTE: this install script only supports 4.2 and above
# The provider flag is still used for marketplace images
echo "=> Installing version ${version} (this takes some time) ..."
mkdir -p /etc/cloudron
echo "${provider}" > /etc/cloudron/PROVIDER
[[ -n "${license}" ]] && echo -n "$license" > /etc/cloudron/LICENSE
if ! /bin/bash "${box_src_tmp_dir}/scripts/installer.sh" &>> "${LOG_FILE}"; then
echo "Failed to install cloudron. See ${LOG_FILE} for details"
exit 1
@@ -221,13 +169,13 @@ mysql -uroot -ppassword -e "REPLACE INTO box.settings (name, value) VALUES ('web
echo -n "=> Waiting for cloudron to be ready (this takes some time) ..."
while true; do
echo -n "."
if status=$($curl -q -f "http://localhost:3000/api/v1/cloudron/status" 2>/dev/null); then
if status=$($curl -s -f "http://localhost:3000/api/v1/cloudron/status" 2>/dev/null); then
break # we are up and running
fi
sleep 10
done
if ! ip=$(curl --fail --connect-timeout 2 --max-time 2 -q https://api.cloudron.io/api/v1/helper/public_ip | sed -n -e 's/.*"ip": "\(.*\)"/\1/p'); then
if ! ip=$(curl -s --fail --connect-timeout 2 --max-time 2 https://api.cloudron.io/api/v1/helper/public_ip | sed -n -e 's/.*"ip": "\(.*\)"/\1/p'); then
ip='<IP>'
fi
echo -e "\n\n${GREEN}Visit https://${ip} and accept the self-signed certificate to finish setup.${DONE}\n"

View File

@@ -94,7 +94,7 @@ echo -e $LINE"Backup stats (possibly misleading)"$LINE >> $OUT
du -hcsL /var/backups/* &>> $OUT || true
echo -e $LINE"System daemon status"$LINE >> $OUT
systemctl status --lines=100 cloudron.target box mysql unbound cloudron-syslog nginx collectd docker &>> $OUT
systemctl status --lines=100 box mysql unbound cloudron-syslog nginx collectd docker &>> $OUT
echo -e $LINE"Box logs"$LINE >> $OUT
tail -n 100 /home/yellowtent/platformdata/logs/box.log &>> $OUT
@@ -112,7 +112,7 @@ if [[ "${enableSSH}" == "true" ]]; then
permit_root_login=$(grep -q ^PermitRootLogin.*yes /etc/ssh/sshd_config && echo "yes" || echo "no")
# support.js uses similar logic
if $(grep -q "ec2\|lightsail\|ami" /etc/cloudron/PROVIDER); then
if [[ -d /home/ubuntu ]]; then
ssh_user="ubuntu"
keys_file="/home/ubuntu/.ssh/authorized_keys"
else

View File

@@ -57,10 +57,10 @@ if [[ $(docker version --format {{.Client.Version}}) != "18.09.2" ]]; then
rm /tmp/containerd.deb /tmp/docker-ce-cli.deb /tmp/docker.deb
fi
readonly nginx_version=$(nginx -v)
if [[ "${nginx_version}" != *"1.14."* && "${ubuntu_version}" == "16.04" ]]; then
echo "==> installer: installing nginx for xenial for TLSv3 support"
curl -sL http://nginx.org/packages/ubuntu/pool/nginx/n/nginx/nginx_1.14.0-1~xenial_amd64.deb -o /tmp/nginx.deb
readonly nginx_version=$(nginx -v 2>&1)
if [[ "${nginx_version}" != *"1.18."* ]]; then
echo "==> installer: installing nginx 1.18"
curl -sL http://nginx.org/packages/ubuntu/pool/nginx/n/nginx/nginx_1.18.0-1~${ubuntu_codename}_amd64.deb -o /tmp/nginx.deb
# apt install with install deps (as opposed to dpkg -i)
apt install -y -o Dpkg::Options::="--force-confdef" -o Dpkg::Options::="--force-confold" --force-yes /tmp/nginx.deb
rm /tmp/nginx.deb
@@ -124,7 +124,7 @@ if ! id "${user}" 2>/dev/null; then
fi
if [[ "${is_update}" == "yes" ]]; then
echo "==> installer: stop cloudron.target service for update"
echo "==> installer: stop box service for update"
${box_src_dir}/setup/stop.sh
fi

View File

@@ -85,6 +85,9 @@ systemctl daemon-reload
systemctl restart systemd-journald
setfacl -n -m u:${USER}:r /var/log/journal/*/system.journal
# Give user access to nginx logs (uses adm group)
usermod -a -G adm ${USER}
echo "==> Setting up unbound"
# DO uses Google nameservers by default. This causes RBL queries to fail (host 2.0.0.127.zen.spamhaus.org)
# We do not use dnsmasq because it is not a recursive resolver and defaults to the value in the interfaces file (which is Google DNS!)
@@ -97,11 +100,13 @@ unbound-anchor -a /var/lib/unbound/root.key
echo "==> Adding systemd services"
cp -r "${script_dir}/start/systemd/." /etc/systemd/system/
systemctl disable cloudron.target || true
rm -f /etc/systemd/system/cloudron.target
[[ "${ubuntu_version}" == "16.04" ]] && sed -e 's/MemoryMax/MemoryLimit/g' -i /etc/systemd/system/box.service
systemctl daemon-reload
systemctl enable unbound
systemctl enable cloudron-syslog
systemctl enable cloudron.target
systemctl enable box
systemctl enable cloudron-firewall
# update firewall rules
@@ -150,8 +155,15 @@ cp "${script_dir}/start/nginx/mime.types" "${PLATFORM_DATA_DIR}/nginx/mime.types
if ! grep -q "^Restart=" /etc/systemd/system/multi-user.target.wants/nginx.service; then
# default nginx service file does not restart on crash
echo -e "\n[Service]\nRestart=always\n" >> /etc/systemd/system/multi-user.target.wants/nginx.service
systemctl daemon-reload
fi
# worker_rlimit_nofile in nginx config can be max this number
mkdir -p /etc/systemd/system/nginx.service.d
if ! grep -q "^LimitNOFILE=" /etc/systemd/system/nginx.service.d/cloudron.conf; then
echo -e "[Service]\nLimitNOFILE=16384\n" > /etc/systemd/system/nginx.service.d/cloudron.conf
fi
systemctl daemon-reload
systemctl start nginx
# restart mysql to make sure it has latest config
@@ -217,7 +229,7 @@ chown "${USER}:${USER}" "${BOX_DATA_DIR}/mail"
chown "${USER}:${USER}" -R "${BOX_DATA_DIR}/mail/dkim" # this is owned by box currently since it generates the keys
echo "==> Starting Cloudron"
systemctl start cloudron.target
systemctl start box
sleep 2 # give systemd sometime to start the processes

View File

@@ -1,11 +1,18 @@
user www-data;
worker_processes 1;
# detect based on available CPU cores
worker_processes auto;
# this is 4096 by default. See /proc/<PID>/limits and /etc/security/limits.conf
# usually twice the worker_connections (one for uptsream, one for downstream)
# see also LimitNOFILE=16384 in systemd drop-in
worker_rlimit_nofile 8192;
pid /run/nginx.pid;
events {
worker_connections 1024;
# a single worker has these many simultaneous connections max
worker_connections 4096;
}
http {

View File

@@ -50,3 +50,12 @@ yellowtent ALL=(root) NOPASSWD: /home/yellowtent/box/src/scripts/restartdocker.s
Defaults!/home/yellowtent/box/src/scripts/restartunbound.sh env_keep="HOME BOX_ENV"
yellowtent ALL=(root) NOPASSWD: /home/yellowtent/box/src/scripts/restartunbound.sh
Defaults!/home/yellowtent/box/src/scripts/rmmailbox.sh env_keep="HOME BOX_ENV"
yellowtent ALL=(root) NOPASSWD: /home/yellowtent/box/src/scripts/rmmailbox.sh
Defaults!/home/yellowtent/box/src/scripts/starttask.sh env_keep="HOME BOX_ENV"
yellowtent ALL=(root) NOPASSWD:SETENV: /home/yellowtent/box/src/scripts/starttask.sh
Defaults!/home/yellowtent/box/src/scripts/stoptask.sh env_keep="HOME BOX_ENV"
yellowtent ALL=(root) NOPASSWD: /home/yellowtent/box/src/scripts/stoptask.sh

View File

@@ -1,20 +1,21 @@
[Unit]
Description=Cloudron Admin
OnFailure=crashnotifier@%n.service
StopWhenUnneeded=true
; journald crashes result in a EPIPE in node. Cannot ignore it as it results in loss of logs.
BindsTo=systemd-journald.service
After=mysql.service nginx.service
; As cloudron-resize-fs is a one-shot, the Wants= automatically ensures that the service *finishes*
Wants=cloudron-resize-fs.service
[Install]
WantedBy=multi-user.target
[Service]
Type=idle
WorkingDirectory=/home/yellowtent/box
Restart=always
; Systemd does not append logs when logging to files, we spawn a shell first and exec to replace it after setting up the pipes
ExecStart=/bin/sh -c 'echo "Logging to /home/yellowtent/platformdata/logs/box.log"; exec /usr/bin/node /home/yellowtent/box/box.js >> /home/yellowtent/platformdata/logs/box.log 2>&1'
Environment="HOME=/home/yellowtent" "USER=yellowtent" "DEBUG=box*,connect-lastmile" "BOX_ENV=cloudron" "NODE_ENV=production"
ExecStart=/home/yellowtent/box/box.js
Environment="HOME=/home/yellowtent" "USER=yellowtent" "DEBUG=box:*,connect-lastmile,-box:ldap" "BOX_ENV=cloudron" "NODE_ENV=production"
; kill apptask processes as well
KillMode=control-group
; Do not kill this process on OOM. Children inherit this score. Do not set it to -1000 so that MemoryMax can keep working

View File

@@ -1,10 +0,0 @@
[Unit]
Description=Cloudron Smartserver
Documentation=https://cloudron.io/documentation.html
StopWhenUnneeded=true
Requires=box.service
After=box.service
# AllowIsolate=yes
[Install]
WantedBy=multi-user.target

View File

@@ -4,4 +4,4 @@ set -eu -o pipefail
echo "Stopping cloudron"
systemctl stop cloudron.target
systemctl stop box

View File

@@ -277,7 +277,7 @@ function restartContainer(name, callback) {
docker.restartContainer(name, function (error) {
if (error && error.reason === BoxError.NOT_FOUND) {
callback(null); // callback early since rebuilding takes long
return rebuildService(name, function (error) { if (error) console.error(`Unable to rebuild service ${name}`, error); });
return rebuildService(name, function (error) { if (error) debug(`restartContainer: Unable to rebuild service ${name}`, error); });
}
if (error) return callback(error);
@@ -506,6 +506,13 @@ function getServiceLogs(id, options, callback) {
args.push('--output=short-iso');
if (follow) args.push('--follow');
} else if (name === 'nginx') {
cmd = '/usr/bin/tail';
args.push('--lines=' + (lines === -1 ? '+1' : lines));
if (follow) args.push('--follow', '--retry', '--quiet'); // same as -F. to make it work if file doesn't exist, --quiet to not output file headers, which are no logs
args.push('/var/log/nginx/access.log');
args.push('/var/log/nginx/error.log');
} else {
cmd = '/usr/bin/tail';
@@ -726,10 +733,10 @@ function importDatabase(addon, callback) {
debug(`importDatabase: Importing ${addon}`);
appdb.getAll(function (error, apps) {
appdb.getAll(function (error, allApps) {
if (error) return callback(error);
async.eachSeries(apps, function iterator (app, iteratorCallback) {
async.eachSeries(allApps, function iterator (app, iteratorCallback) {
if (!(addon in app.manifest.addons)) return iteratorCallback(); // app doesn't use the addon
debug(`importDatabase: Importing addon ${addon} of app ${app.id}`);
@@ -742,7 +749,51 @@ function importDatabase(addon, callback) {
// not clear, if repair workflow should be part of addon or per-app
appdb.update(app.id, { installationState: apps.ISTATE_ERROR, error: { message: error.message } }, iteratorCallback);
});
}, callback);
}, function (error) {
safe.fs.unlinkSync(path.join(paths.ADDON_CONFIG_DIR, `exported-${addon}`)); // clean up for future migrations
callback(error);
});
});
}
function exportDatabase(addon, callback) {
assert.strictEqual(typeof addon, 'string');
assert.strictEqual(typeof callback, 'function');
debug(`exportDatabase: Exporting ${addon}`);
if (fs.existsSync(path.join(paths.ADDON_CONFIG_DIR, `exported-${addon}`))) {
debug(`exportDatabase: Already exported addon ${addon} in previous run`);
return callback(null);
}
appdb.getAll(function (error, apps) {
if (error) return callback(error);
async.eachSeries(apps, function iterator (app, iteratorCallback) {
if (!app.manifest.addons || !(addon in app.manifest.addons)) return iteratorCallback(); // app doesn't use the addon
debug(`exportDatabase: Exporting addon ${addon} of app ${app.id}`);
ADDONS[addon].backup(app, app.manifest.addons[addon], function (error) {
if (error) {
debug(`exportDatabase: Error exporting ${addon} of app ${app.id}.`, error);
return iteratorCallback(error);
}
iteratorCallback();
});
}, function (error) {
if (error) return callback(error);
async.series([
(done) => fs.writeFile(path.join(paths.ADDON_CONFIG_DIR, `exported-${addon}`), '', 'utf8', done),
// note: after this point, we are restart safe. it's ok if the box code crashes at this point
(done) => shell.exec(`exportDatabase - remove${addon}`, `docker rm -f ${addon}`, done), // what if db writes something when quitting ...
(done) => shell.sudo(`exportDatabase - removeAddonDir${addon}`, [ RMADDONDIR_CMD, addon ], {}, done) // ready to start afresh
], callback);
});
});
}
@@ -763,7 +814,7 @@ function updateServiceConfig(platformConfig, callback) {
}
const args = `update --memory ${memory} --memory-swap ${memorySwap} ${serviceName}`.split(' ');
shell.spawn(`update${serviceName}`, '/usr/bin/docker', args, { }, iteratorCallback);
shell.spawn(`updateServiceConfig(${serviceName})`, '/usr/bin/docker', args, { }, iteratorCallback);
}, callback);
}
@@ -808,7 +859,7 @@ function startServices(existingInfra, callback) {
} else {
assert.strictEqual(typeof existingInfra.images, 'object');
if (!existingInfra.images.turn || infra.images.turn.tag !== existingInfra.images.turn.tag) startFuncs.push(startTurn.bind(null, existingInfra));
if (infra.images.turn.tag !== existingInfra.images.turn.tag) startFuncs.push(startTurn.bind(null, existingInfra));
if (infra.images.mysql.tag !== existingInfra.images.mysql.tag) startFuncs.push(startMysql.bind(null, existingInfra));
if (infra.images.postgresql.tag !== existingInfra.images.postgresql.tag) startFuncs.push(startPostgresql.bind(null, existingInfra));
if (infra.images.mongodb.tag !== existingInfra.images.mongodb.tag) startFuncs.push(startMongodb.bind(null, existingInfra));
@@ -925,7 +976,7 @@ function setupTurn(app, options, callback) {
assert.strictEqual(typeof callback, 'function');
var turnSecret = safe.fs.readFileSync(paths.ADDON_TURN_SECRET_FILE, 'utf8');
if (!turnSecret) console.error('No turn secret set. Will leave emtpy, but this is a problem!');
if (!turnSecret) debug('setupTurn: no turn secret set. Will leave emtpy, but this is a problem!');
const env = [
{ name: 'CLOUDRON_STUN_SERVER', value: settings.adminFqdn() },
@@ -1124,7 +1175,7 @@ function startMysql(existingInfra, callback) {
const upgrading = existingInfra.version !== 'none' && requiresUpgrade(existingInfra.images.mysql.tag, tag);
if (upgrading) debug('startMysql: mysql will be upgraded');
const upgradeFunc = upgrading ? shell.sudo.bind(null, 'startMysql', [ RMADDONDIR_CMD, 'mysql' ], {}) : (next) => next();
const upgradeFunc = upgrading ? exportDatabase.bind(null, 'mysql') : (next) => next();
upgradeFunc(function (error) {
if (error) return callback(error);
@@ -1148,7 +1199,11 @@ function startMysql(existingInfra, callback) {
--label isCloudronManaged=true \
--read-only -v /tmp -v /run "${tag}"`;
shell.exec('startMysql', cmd, function (error) {
async.series([
shell.exec.bind(null, 'stopMysql', 'docker stop mysql || true'),
shell.exec.bind(null, 'removeMysql', 'docker rm -f mysql || true'),
shell.exec.bind(null, 'startMysql', cmd)
], function (error) {
if (error) return callback(error);
waitForContainer('mysql', 'CLOUDRON_MYSQL_TOKEN', function (error) {
@@ -1341,7 +1396,7 @@ function startPostgresql(existingInfra, callback) {
const upgrading = existingInfra.version !== 'none' && requiresUpgrade(existingInfra.images.postgresql.tag, tag);
if (upgrading) debug('startPostgresql: postgresql will be upgraded');
const upgradeFunc = upgrading ? shell.sudo.bind(null, 'startPostgresql', [ RMADDONDIR_CMD, 'postgresql' ], {}) : (next) => next();
const upgradeFunc = upgrading ? exportDatabase.bind(null, 'postgresql') : (next) => next();
upgradeFunc(function (error) {
if (error) return callback(error);
@@ -1364,7 +1419,11 @@ function startPostgresql(existingInfra, callback) {
--label isCloudronManaged=true \
--read-only -v /tmp -v /run "${tag}"`;
shell.exec('startPostgresql', cmd, function (error) {
async.series([
shell.exec.bind(null, 'stopPostgresql', 'docker stop postgresql || true'),
shell.exec.bind(null, 'removePostgresql', 'docker rm -f postgresql || true'),
shell.exec.bind(null, 'startPostgresql', cmd)
], function (error) {
if (error) return callback(error);
waitForContainer('postgresql', 'CLOUDRON_POSTGRESQL_TOKEN', function (error) {
@@ -1520,8 +1579,6 @@ function startTurn(existingInfra, callback) {
const memoryLimit = 256;
const realm = settings.adminFqdn();
if (existingInfra.version === infra.version && existingInfra.images.turn && infra.images.turn.tag === existingInfra.images.turn.tag) return callback();
// this exports 3478/tcp, 5349/tls and 50000-51000/udp
const cmd = `docker run --restart=always -d --name="turn" \
--hostname turn \
@@ -1539,7 +1596,11 @@ function startTurn(existingInfra, callback) {
--label isCloudronManaged=true \
--read-only -v /tmp -v /run "${tag}"`;
shell.exec('startTurn', cmd, callback);
async.series([
shell.exec.bind(null, 'stopTurn', 'docker stop turn || true'),
shell.exec.bind(null, 'removeTurn', 'docker rm -f turn || true'),
shell.exec.bind(null, 'startTurn', cmd)
], callback);
}
function startMongodb(existingInfra, callback) {
@@ -1555,7 +1616,7 @@ function startMongodb(existingInfra, callback) {
const upgrading = existingInfra.version !== 'none' && requiresUpgrade(existingInfra.images.mongodb.tag, tag);
if (upgrading) debug('startMongodb: mongodb will be upgraded');
const upgradeFunc = upgrading ? shell.sudo.bind(null, 'startMongodb', [ RMADDONDIR_CMD, 'mongodb' ], {}) : (next) => next();
const upgradeFunc = upgrading ? exportDatabase.bind(null, 'mongodb') : (next) => next();
upgradeFunc(function (error) {
if (error) return callback(error);
@@ -1578,7 +1639,11 @@ function startMongodb(existingInfra, callback) {
--label isCloudronManaged=true \
--read-only -v /tmp -v /run "${tag}"`;
shell.exec('startMongodb', cmd, function (error) {
async.series([
shell.exec.bind(null, 'stopMongodb', 'docker stop mongodb || true'),
shell.exec.bind(null, 'removeMongodb', 'docker rm -f mongodb || true'),
shell.exec.bind(null, 'startMongodb', cmd)
], function (error) {
if (error) return callback(error);
waitForContainer('mongodb', 'CLOUDRON_MONGODB_TOKEN', function (error) {
@@ -1729,13 +1794,19 @@ function startRedis(existingInfra, callback) {
async.eachSeries(allApps, function iterator (app, iteratorCallback) {
if (!('redis' in app.manifest.addons)) return iteratorCallback(); // app doesn't use the addon
setupRedis(app, app.manifest.addons.redis, iteratorCallback);
const redisName = 'redis-' + app.id;
async.series([
shell.exec.bind(null, 'stopRedis', `docker stop ${redisName} || true`), // redis will backup as part of signal handling
shell.exec.bind(null, 'removeRedis', `docker rm -f ${redisName} || true`),
setupRedis.bind(null, app, app.manifest.addons.redis) // starts the container
], iteratorCallback);
}, function (error) {
if (error) return callback(error);
if (!upgrading) return callback();
importDatabase('redis', callback); // setupRedis currently starts the app container
importDatabase('redis', callback);
});
});
}
@@ -1755,7 +1826,7 @@ function setupRedis(app, options, callback) {
const redisServiceToken = hat(4 * 48);
// Compute redis memory limit based on app's memory limit (this is arbitrary)
const memoryLimit = app.servicesConfig['redis'] ? app.servicesConfig['redis'].memoryLimit : APP_SERVICES['redis'].defaultMemoryLimit;
const memoryLimit = app.servicesConfig['redis'] ? app.servicesConfig['redis'].memory : APP_SERVICES['redis'].defaultMemoryLimit;
const tag = infra.images.redis.tag;
const label = app.fqdn;

View File

@@ -181,12 +181,10 @@ function processApp(callback) {
if (error) return callback(error);
async.each(allApps, checkAppHealth, function (error) {
if (error) console.error(error);
const alive = 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: ${alive.length} alive / ${allApps.length - alive.length} dead.` + (error ? ` ${error.reason}` : ''));
callback(null);
});
@@ -201,7 +199,7 @@ function run(intervalSecs, callback) {
processApp, // this is first because docker.getEvents seems to get 'stuck' sometimes
processDockerEvents.bind(null, intervalSecs)
], function (error) {
if (error) debug(error);
if (error) debug(`run: could not check app health. ${error.message}`);
callback();
});

View File

@@ -426,8 +426,8 @@ function removeInternalFields(app) {
// non-admins can only see these
function removeRestrictedFields(app) {
return _.pick(app,
'id', 'appStoreId', 'installationState', 'error', 'runState', 'health', 'taskId',
'location', 'domain', 'fqdn', 'manifest', 'portBindings', 'iconUrl', 'creationTime', 'ts', 'tags', 'label');
'id', 'appStoreId', 'installationState', 'error', 'runState', 'health', 'taskId', 'alternateDomains', 'sso',
'location', 'domain', 'fqdn', 'manifest', 'portBindings', 'iconUrl', 'creationTime', 'ts', 'tags', 'label', 'enableBackup');
}
function getIconUrlSync(app) {
@@ -696,6 +696,11 @@ function checkAppState(app, state) {
if (state !== exports.ISTATE_PENDING_UNINSTALL && state !== exports.ISTATE_PENDING_RESTORE) return new BoxError(BoxError.BAD_STATE, 'Not allowed in error state');
}
if (app.runState === exports.RSTATE_STOPPED) {
// can't backup or restore since app addons are down. can't update because migration scripts won't run
if (state === exports.ISTATE_PENDING_UPDATE || state === exports.ISTATE_PENDING_BACKUP || state === exports.ISTATE_PENDING_RESTORE) return new BoxError(BoxError.BAD_STATE, 'Not allowed in stopped state');
}
return null;
}
@@ -1832,18 +1837,25 @@ function exec(app, options, callback) {
});
}
function canAutoupdateApp(app, newManifest) {
function canAutoupdateApp(app, updateInfo) {
assert.strictEqual(typeof app, 'object');
assert.strictEqual(typeof updateInfo, 'object');
const manifest = updateInfo.manifest;
if (!app.enableAutomaticUpdate) return false;
// for invalid subscriptions the appstore does not return a dockerImage
if (!newManifest.dockerImage) return false;
if (!manifest.dockerImage) return false;
if ((semver.major(app.manifest.version) !== 0) && (semver.major(app.manifest.version) !== semver.major(newManifest.version))) return false; // major changes are blocking
if (updateInfo.unstable) return false; // only manual update allowed for unstable updates
if ((semver.major(app.manifest.version) !== 0) && (semver.major(app.manifest.version) !== semver.major(manifest.version))) return false; // major changes are blocking
if (app.runState === exports.RSTATE_STOPPED) return false; // stopped apps won't run migration scripts and shouldn't be updated
const newTcpPorts = newManifest.tcpPorts || { };
const newUdpPorts = newManifest.udpPorts || { };
const newTcpPorts = manifest.tcpPorts || { };
const newUdpPorts = manifest.udpPorts || { };
const portBindings = app.portBindings; // this is never null
for (let portName in portBindings) {
@@ -1868,7 +1880,7 @@ function autoupdateApps(updateInfo, auditSource, callback) { // updateInfo is {
return iteratorDone();
}
if (!canAutoupdateApp(app, updateInfo[appId].manifest)) {
if (!canAutoupdateApp(app, updateInfo[appId])) {
debug(`app ${app.fqdn} requires manual update`);
return iteratorDone();
}
@@ -1913,7 +1925,7 @@ function listBackups(app, page, perPage, callback) {
assert(typeof perPage === 'number' && perPage > 0);
assert.strictEqual(typeof callback, 'function');
backups.getByAppIdPaged(page, perPage, app.id, function (error, results) {
backups.getByIdentifierAndStatePaged(app.id, backups.BACKUP_STATE_NORMAL, page, perPage, function (error, results) {
if (error) return callback(error);
callback(null, results);
@@ -1930,7 +1942,7 @@ function restoreInstalledApps(callback) {
apps = apps.filter(app => app.installationState !== exports.ISTATE_PENDING_RESTORE); // safeguard against tasks being created non-stop if we crash on startup
async.eachSeries(apps, function (app, iteratorDone) {
backups.getByAppIdPaged(1, 1, app.id, function (error, results) {
backups.getByIdentifierAndStatePaged(app.id, backups.BACKUP_STATE_NORMAL, 1, 1, function (error, results) {
let installationState, restoreConfig, oldManifest;
if (!error && results.length) {
installationState = exports.ISTATE_PENDING_RESTORE;
@@ -2001,6 +2013,7 @@ function restartAppsUsingAddons(changedAddons, callback) {
apps = apps.filter(app => app.manifest.addons && _.intersection(Object.keys(app.manifest.addons), changedAddons).length !== 0);
apps = apps.filter(app => app.installationState !== exports.ISTATE_ERROR); // remove errored apps. let them be 'repaired' by hand
apps = apps.filter(app => app.installationState !== exports.ISTATE_PENDING_RESTART); // safeguard against tasks being created non-stop restart if we crash on startup
apps = apps.filter(app => app.runState !== exports.RSTATE_STOPPED); // don't start stopped apps
async.eachSeries(apps, function (app, iteratorDone) {
debug(`restartAppsUsingAddons: marking ${app.fqdn} for restart`);

View File

@@ -11,7 +11,6 @@ exports = module.exports = {
trackFinishedSetup: trackFinishedSetup,
registerWithLoginCredentials: registerWithLoginCredentials,
registerWithLicense: registerWithLicense,
purchaseApp: purchaseApp,
unpurchaseApp: unpurchaseApp,
@@ -20,8 +19,6 @@ exports = module.exports = {
getSubscription: getSubscription,
isFreePlan: isFreePlan,
sendAliveStatus: sendAliveStatus,
getAppUpdate: getAppUpdate,
getBoxUpdate: getBoxUpdate,
@@ -34,32 +31,26 @@ var apps = require('./apps.js'),
BoxError = require('./boxerror.js'),
constants = require('./constants.js'),
debug = require('debug')('box:appstore'),
domains = require('./domains.js'),
eventlog = require('./eventlog.js'),
groups = require('./groups.js'),
mail = require('./mail.js'),
os = require('os'),
paths = require('./paths.js'),
safe = require('safetydance'),
semver = require('semver'),
settings = require('./settings.js'),
superagent = require('superagent'),
users = require('./users.js'),
util = require('util');
const NOOP_CALLBACK = function (error) { if (error) debug(error); };
// These are the default options and will be adjusted once a subscription state is obtained
// Keep in sync with appstore/routes/cloudrons.js
let gFeatures = {
userMaxCount: null,
externalLdap: true,
eventLog: true,
privateDockerRegistry: true,
branding: true,
userManager: true,
multiAdmin: true,
support: true
userMaxCount: 5,
domainMaxCount: 1,
externalLdap: false,
privateDockerRegistry: false,
branding: false,
support: false,
directoryConfig: false,
mailboxMaxCount: 5,
emailPremium: false
};
// attempt to load feature cache in case appstore would be down
@@ -235,111 +226,6 @@ function unpurchaseApp(appId, data, callback) {
});
}
function sendAliveStatus(callback) {
callback = callback || NOOP_CALLBACK;
let allSettings, allDomains, mailDomains, loginEvents, userCount, groupCount;
async.series([
function (callback) {
settings.getAll(function (error, result) {
if (error) return callback(error);
allSettings = result;
callback();
});
},
function (callback) {
domains.getAll(function (error, result) {
if (error) return callback(error);
allDomains = result;
callback();
});
},
function (callback) {
mail.getDomains(function (error, result) {
if (error) return callback(error);
mailDomains = result;
callback();
});
},
function (callback) {
eventlog.getAllPaged([ eventlog.ACTION_USER_LOGIN ], null, 1, 1, function (error, result) {
if (error) return callback(error);
loginEvents = result;
callback();
});
},
function (callback) {
users.count(function (error, result) {
if (error) return callback(error);
userCount = result;
callback();
});
},
function (callback) {
groups.count(function (error, result) {
if (error) return callback(error);
groupCount = result;
callback();
});
}
], function (error) {
if (error) return callback(error);
var backendSettings = {
backupConfig: {
provider: allSettings[settings.BACKUP_CONFIG_KEY].provider,
hardlinks: !allSettings[settings.BACKUP_CONFIG_KEY].noHardlinks
},
domainConfig: {
count: allDomains.length,
domains: Array.from(new Set(allDomains.map(function (d) { return { domain: d.domain, provider: d.provider }; })))
},
mailConfig: {
outboundCount: mailDomains.length,
inboundCount: mailDomains.filter(function (d) { return d.enabled; }).length,
catchAllCount: mailDomains.filter(function (d) { return d.catchAll.length !== 0; }).length,
relayProviders: Array.from(new Set(mailDomains.map(function (d) { return d.relay.provider; })))
},
userCount: userCount,
groupCount: groupCount,
appAutoupdatePattern: allSettings[settings.APP_AUTOUPDATE_PATTERN_KEY],
boxAutoupdatePattern: allSettings[settings.BOX_AUTOUPDATE_PATTERN_KEY],
timeZone: allSettings[settings.TIME_ZONE_KEY],
sysinfoProvider: allSettings[settings.SYSINFO_CONFIG_KEY].provider
};
var data = {
version: constants.VERSION,
adminFqdn: settings.adminFqdn(),
provider: settings.provider(),
backendSettings: backendSettings,
machine: {
cpus: os.cpus(),
totalmem: os.totalmem()
},
events: {
lastLogin: loginEvents[0] ? (new Date(loginEvents[0].creationTime).getTime()) : 0
}
};
getCloudronToken(function (error, token) {
if (error) return callback(error);
const url = `${settings.apiServerOrigin()}/api/v1/alive`;
superagent.post(url).send(data).query({ accessToken: token }).timeout(30 * 1000).end(function (error, result) {
if (error && !error.response) return callback(new BoxError(BoxError.NETWORK_ERROR, error));
if (result.statusCode === 404) return callback(new BoxError(BoxError.NOT_FOUND));
if (result.statusCode === 401) return callback(new BoxError(BoxError.INVALID_CREDENTIALS));
if (result.statusCode === 422) return callback(new BoxError(BoxError.LICENSE_ERROR, result.body.message));
if (result.statusCode !== 201) return callback(new BoxError(BoxError.EXTERNAL_ERROR, util.format('Sending alive status failed. %s %j', result.status, result.body)));
callback(null);
});
});
});
}
function getBoxUpdate(options, callback) {
assert.strictEqual(typeof options, 'object');
assert.strictEqual(typeof callback, 'function');
@@ -416,7 +302,9 @@ function getAppUpdate(app, options, callback) {
return callback(new BoxError(BoxError.EXTERNAL_ERROR, util.format('Malformed update: %s %s', result.statusCode, result.text)));
}
// { id, creationDate, manifest }
updateInfo.unstable = !!updateInfo.unstable;
// { id, creationDate, manifest, unstable }
callback(null, updateInfo);
});
});
@@ -453,18 +341,16 @@ function registerCloudron(data, callback) {
// This works without a Cloudron token as this Cloudron was not yet registered
let gBeginSetupAlreadyTracked = false;
function trackBeginSetup(provider) {
assert.strictEqual(typeof provider, 'string');
function trackBeginSetup() {
// avoid browser reload double tracking, not perfect since box might restart, but covers most cases and is simple
if (gBeginSetupAlreadyTracked) return;
gBeginSetupAlreadyTracked = true;
const url = `${settings.apiServerOrigin()}/api/v1/helper/setup_begin`;
superagent.post(url).send({ provider }).timeout(30 * 1000).end(function (error, result) {
if (error && !error.response) return console.error(error.message);
if (result.statusCode !== 200) return console.error(error.message);
superagent.post(url).send({}).timeout(30 * 1000).end(function (error, result) {
if (error && !error.response) return debug(`trackBeginSetup: ${error.message}`);
if (result.statusCode !== 200) return debug(`trackBeginSetup: ${result.statusCode} ${error.message}`);
});
}
@@ -475,23 +361,8 @@ function trackFinishedSetup(domain) {
const url = `${settings.apiServerOrigin()}/api/v1/helper/setup_finished`;
superagent.post(url).send({ domain }).timeout(30 * 1000).end(function (error, result) {
if (error && !error.response) return console.error(error.message);
if (result.statusCode !== 200) return console.error(error.message);
});
}
function registerWithLicense(license, domain, callback) {
assert.strictEqual(typeof license, 'string');
assert.strictEqual(typeof domain, 'string');
assert.strictEqual(typeof callback, 'function');
getCloudronToken(function (error, token) {
if (token) return callback(new BoxError(BoxError.CONFLICT, 'Cloudron is already registered'));
const provider = settings.provider();
const version = constants.VERSION;
registerCloudron({ license, domain, provider, version }, callback);
if (error && !error.response) return debug(`trackFinishedSetup: ${error.message}`);
if (result.statusCode !== 200) return debug(`trackFinishedSetup: ${result.statusCode} ${error.message}`);
});
}
@@ -514,7 +385,7 @@ function registerWithLoginCredentials(options, callback) {
login(options.email, options.password, options.totpToken || '', function (error, result) {
if (error) return callback(error);
registerCloudron({ domain: settings.adminDomain(), accessToken: result.accessToken, provider: settings.provider(), version: constants.VERSION, purpose: options.purpose || '' }, callback);
registerCloudron({ domain: settings.adminDomain(), accessToken: result.accessToken, version: constants.VERSION, purpose: options.purpose || '' }, callback);
});
});
});
@@ -539,7 +410,7 @@ function createTicket(info, auditSource, callback) {
if (error) return callback(error);
collectAppInfoIfNeeded(function (error, result) {
if (error) console.error('Unable to get app info', error);
if (error) return callback(error);
if (result) info.app = result;
let url = settings.apiServerOrigin() + '/api/v1/ticket';

View File

@@ -17,8 +17,6 @@ exports = module.exports = {
_waitForDnsPropagation: waitForDnsPropagation
};
require('supererror')({ splatchError: true });
var addons = require('./addons.js'),
appdb = require('./appdb.js'),
apps = require('./apps.js'),
@@ -37,7 +35,6 @@ var addons = require('./addons.js'),
eventlog = require('./eventlog.js'),
fs = require('fs'),
manifestFormat = require('cloudron-manifestformat'),
mkdirp = require('mkdirp'),
net = require('net'),
os = require('os'),
path = require('path'),
@@ -46,6 +43,7 @@ var addons = require('./addons.js'),
rimraf = require('rimraf'),
safe = require('safetydance'),
settings = require('./settings.js'),
sftp = require('./sftp.js'),
shell = require('./shell.js'),
superagent = require('superagent'),
sysinfo = require('./sysinfo.js'),
@@ -176,7 +174,7 @@ function createAppDir(app, callback) {
assert.strictEqual(typeof callback, 'function');
const appDir = path.join(paths.APPS_DATA_DIR, app.id);
mkdirp(appDir, function (error) {
fs.mkdir(appDir, { recursive: true }, function (error) {
if (error) return callback(new BoxError(BoxError.FS_ERROR, `Error creating directory: ${error.message}`, { appDir }));
callback(null);
@@ -580,6 +578,9 @@ function install(app, args, progressCallback, callback) {
startApp.bind(null, app),
progressCallback.bind(null, { percent: 80, message: 'Configuring file manager' }),
sftp.rebuild.bind(null, {}),
progressCallback.bind(null, { percent: 85, message: 'Waiting for DNS propagation' }),
exports._waitForDnsPropagation.bind(null, app),
@@ -741,7 +742,12 @@ function migrateDataDir(app, args, progressCallback, callback) {
debugApp(app, 'error migrating data dir : %s', error);
return updateApp(app, { installationState: apps.ISTATE_ERROR, error: makeTaskError(error, app) }, callback.bind(null, error));
}
callback(null);
// We do this after the app has the new data commited to the database
sftp.rebuild({}, function (error) {
if (error) debug('migrateDataDir: failed to rebuild sftp addon:', error);
callback();
});
});
}
@@ -776,6 +782,9 @@ function configure(app, args, progressCallback, callback) {
startApp.bind(null, app),
progressCallback.bind(null, { percent: 80, message: 'Configuring file manager' }),
sftp.rebuild.bind(null, {}),
progressCallback.bind(null, { percent: 90, message: 'Configuring reverse proxy' }),
configureReverseProxy.bind(null, app),
@@ -786,7 +795,8 @@ function configure(app, args, progressCallback, callback) {
debugApp(app, 'error reconfiguring : %s', error);
return updateApp(app, { installationState: apps.ISTATE_ERROR, error: makeTaskError(error, app) }, callback.bind(null, error));
}
callback(null);
callback();
});
}
@@ -853,7 +863,7 @@ function update(app, args, progressCallback, callback) {
if (newTcpPorts[portName] || newUdpPorts[portName]) return callback(null); // port still in use
appdb.delPortBinding(currentPorts[portName], apps.PORT_TYPE_TCP, function (error) {
if (error && error.reason === BoxError.NOT_FOUND) console.error('Portbinding does not exist in database.');
if (error && error.reason === BoxError.NOT_FOUND) debug('update: portbinding does not exist in database', error);
else if (error) return next(error);
// also delete from app object for further processing (the db is updated in the next step)
@@ -869,14 +879,17 @@ function update(app, args, progressCallback, callback) {
progressCallback.bind(null, { percent: 45, message: 'Downloading icon' }),
downloadIcon.bind(null, app),
progressCallback.bind(null, { percent: 70, message: 'Updating addons' }),
progressCallback.bind(null, { percent: 60, message: 'Updating addons' }),
addons.setupAddons.bind(null, app, updateConfig.manifest.addons),
progressCallback.bind(null, { percent: 80, message: 'Creating container' }),
progressCallback.bind(null, { percent: 70, message: 'Creating container' }),
createContainer.bind(null, app),
startApp.bind(null, app),
progressCallback.bind(null, { percent: 80, message: 'Configuring file manager' }),
sftp.rebuild.bind(null, {}),
progressCallback.bind(null, { percent: 100, message: 'Done' }),
updateApp.bind(null, app, { installationState: apps.ISTATE_INSTALLED, error: null, health: null, updateTime: new Date() })
], function seriesDone(error) {
@@ -979,16 +992,22 @@ function uninstall(app, args, progressCallback, callback) {
progressCallback.bind(null, { percent: 30, message: 'Teardown addons' }),
addons.teardownAddons.bind(null, app, app.manifest.addons),
progressCallback.bind(null, { percent: 40, message: 'Deleting app data directory' }),
progressCallback.bind(null, { percent: 40, message: 'Cleanup file manager' }),
function (callback) {
if (!app.dataDir) return callback();
sftp.rebuild({ ignoredApps: [ app.id ] }, callback);
},
progressCallback.bind(null, { percent: 50, message: 'Deleting app data directory' }),
deleteAppDir.bind(null, app, { removeDirectory: true }),
progressCallback.bind(null, { percent: 50, message: 'Deleting image' }),
progressCallback.bind(null, { percent: 60, message: 'Deleting image' }),
docker.deleteImage.bind(null, app.manifest),
progressCallback.bind(null, { percent: 60, message: 'Unregistering domains' }),
progressCallback.bind(null, { percent: 70, message: 'Unregistering domains' }),
unregisterSubdomains.bind(null, app, [ { subdomain: app.location, domain: app.domain } ].concat(app.alternateDomains)),
progressCallback.bind(null, { percent: 70, message: 'Cleanup icon' }),
progressCallback.bind(null, { percent: 80, message: 'Cleanup icon' }),
removeIcon.bind(null, app),
progressCallback.bind(null, { percent: 90, message: 'Cleanup logs' }),
@@ -1044,6 +1063,8 @@ function run(appId, args, progressCallback, callback) {
return stop(app, args, progressCallback, callback);
case apps.ISTATE_PENDING_RESTART:
return restart(app, args, progressCallback, callback);
case apps.ISTATE_INSTALLED: // can only happen when we have a bug in our code while testing/development
return updateApp(app, { installationState: apps.ISTATE_INSTALLED, error: null, health: null }, callback);
default:
debugApp(app, 'apptask launched with invalid command');
return callback(new BoxError(BoxError.INTERNAL_ERROR, 'Unknown install command in apptask:' + app.installationState));

View File

@@ -68,7 +68,8 @@ function scheduleTask(appId, taskId, callback) {
if (!fs.existsSync(path.dirname(logFile))) safe.fs.mkdirSync(path.dirname(logFile)); // ensure directory
tasks.startTask(taskId, { logFile, timeout: 20 * 60 * 60 * 1000 /* 20 hours */ }, function (error, result) {
// TODO: set memory limit for app backup task
tasks.startTask(taskId, { logFile, timeout: 20 * 60 * 60 * 1000 /* 20 hours */, nice: 15 }, function (error, result) {
callback(error, result);
delete gActiveTasks[appId];

View File

@@ -6,27 +6,20 @@ var assert = require('assert'),
safe = require('safetydance'),
util = require('util');
var BACKUPS_FIELDS = [ 'id', 'creationTime', 'packageVersion', 'type', 'dependsOn', 'state', 'manifestJson', 'format', 'preserveSecs', 'encryptionVersion' ];
var BACKUPS_FIELDS = [ 'id', 'identifier', 'creationTime', 'packageVersion', 'type', 'dependsOn', 'state', 'manifestJson', 'format', 'preserveSecs', 'encryptionVersion' ];
exports = module.exports = {
add: add,
add,
getByTypeAndStatePaged: getByTypeAndStatePaged,
getByTypePaged: getByTypePaged,
getByTypePaged,
getByIdentifierPaged,
getByIdentifierAndStatePaged,
get: get,
del: del,
update: update,
getByAppIdPaged: getByAppIdPaged,
get,
del,
update,
_clear: clear,
BACKUP_TYPE_APP: 'app',
BACKUP_TYPE_BOX: 'box',
BACKUP_STATE_NORMAL: 'normal', // should rename to created to avoid listing in UI?
BACKUP_STATE_CREATING: 'creating',
BACKUP_STATE_ERROR: 'error'
_clear: clear
};
function postProcess(result) {
@@ -38,15 +31,15 @@ function postProcess(result) {
delete result.manifestJson;
}
function getByTypeAndStatePaged(type, state, page, perPage, callback) {
assert(type === exports.BACKUP_TYPE_APP || type === exports.BACKUP_TYPE_BOX);
function getByIdentifierAndStatePaged(identifier, state, page, perPage, callback) {
assert.strictEqual(typeof identifier, 'string');
assert.strictEqual(typeof state, 'string');
assert(typeof page === 'number' && page > 0);
assert(typeof perPage === 'number' && perPage > 0);
assert.strictEqual(typeof callback, 'function');
database.query('SELECT ' + BACKUPS_FIELDS + ' FROM backups WHERE type = ? AND state = ? ORDER BY creationTime DESC LIMIT ?,?',
[ type, state, (page-1)*perPage, perPage ], function (error, results) {
database.query('SELECT ' + BACKUPS_FIELDS + ' FROM backups WHERE identifier = ? AND state = ? ORDER BY creationTime DESC LIMIT ?,?',
[ identifier, state, (page-1)*perPage, perPage ], function (error, results) {
if (error) return callback(new BoxError(BoxError.DATABASE_ERROR, error));
results.forEach(function (result) { postProcess(result); });
@@ -56,7 +49,7 @@ function getByTypeAndStatePaged(type, state, page, perPage, callback) {
}
function getByTypePaged(type, page, perPage, callback) {
assert(type === exports.BACKUP_TYPE_APP || type === exports.BACKUP_TYPE_BOX);
assert.strictEqual(typeof type, 'string');
assert(typeof page === 'number' && page > 0);
assert(typeof perPage === 'number' && perPage > 0);
assert.strictEqual(typeof callback, 'function');
@@ -71,15 +64,14 @@ function getByTypePaged(type, page, perPage, callback) {
});
}
function getByAppIdPaged(page, perPage, appId, callback) {
function getByIdentifierPaged(identifier, page, perPage, callback) {
assert.strictEqual(typeof identifier, 'string');
assert(typeof page === 'number' && page > 0);
assert(typeof perPage === 'number' && perPage > 0);
assert.strictEqual(typeof appId, 'string');
assert.strictEqual(typeof callback, 'function');
// box versions (0.93.x and below) used to use appbackup_ prefix
database.query('SELECT ' + BACKUPS_FIELDS + ' FROM backups WHERE type = ? AND state = ? AND id LIKE ? ORDER BY creationTime DESC LIMIT ?,?',
[ exports.BACKUP_TYPE_APP, exports.BACKUP_STATE_NORMAL, '%app%\\_' + appId + '\\_%', (page-1)*perPage, perPage ], function (error, results) {
database.query('SELECT ' + BACKUPS_FIELDS + ' FROM backups WHERE identifier = ? ORDER BY creationTime DESC LIMIT ?,?',
[ identifier, (page-1)*perPage, perPage ], function (error, results) {
if (error) return callback(new BoxError(BoxError.DATABASE_ERROR, error));
results.forEach(function (result) { postProcess(result); });
@@ -108,7 +100,9 @@ function add(id, data, callback) {
assert.strictEqual(typeof id, 'string');
assert(data.encryptionVersion === null || typeof data.encryptionVersion === 'number');
assert.strictEqual(typeof data.packageVersion, 'string');
assert(data.type === exports.BACKUP_TYPE_APP || data.type === exports.BACKUP_TYPE_BOX);
assert.strictEqual(typeof data.type, 'string');
assert.strictEqual(typeof data.identifier, 'string');
assert.strictEqual(typeof data.state, 'string');
assert(util.isArray(data.dependsOn));
assert.strictEqual(typeof data.manifest, 'object');
assert.strictEqual(typeof data.format, 'string');
@@ -117,8 +111,8 @@ function add(id, data, callback) {
var creationTime = data.creationTime || new Date(); // allow tests to set the time
var manifestJson = JSON.stringify(data.manifest);
database.query('INSERT INTO backups (id, encryptionVersion, packageVersion, type, creationTime, state, dependsOn, manifestJson, format) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
[ id, data.encryptionVersion, data.packageVersion, data.type, creationTime, exports.BACKUP_STATE_NORMAL, data.dependsOn.join(','), manifestJson, data.format ],
database.query('INSERT INTO backups (id, identifier, encryptionVersion, packageVersion, type, creationTime, state, dependsOn, manifestJson, format) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
[ id, data.identifier, data.encryptionVersion, data.packageVersion, data.type, creationTime, data.state, data.dependsOn.join(','), manifestJson, data.format ],
function (error) {
if (error && error.code === 'ER_DUP_ENTRY') return callback(new BoxError(BoxError.ALREADY_EXISTS));
if (error) return callback(new BoxError(BoxError.DATABASE_ERROR, error));

View File

@@ -4,13 +4,11 @@ exports = module.exports = {
testConfig: testConfig,
testProviderConfig: testProviderConfig,
getByStatePaged: getByStatePaged,
getByAppIdPaged: getByAppIdPaged,
getByIdentifierAndStatePaged,
get: get,
startBackupTask: startBackupTask,
ensureBackup: ensureBackup,
restore: restore,
@@ -34,6 +32,15 @@ exports = module.exports = {
generateEncryptionKeysSync: generateEncryptionKeysSync,
BACKUP_IDENTIFIER_BOX: 'box',
BACKUP_TYPE_APP: 'app',
BACKUP_TYPE_BOX: 'box',
BACKUP_STATE_NORMAL: 'normal', // should rename to created to avoid listing in UI?
BACKUP_STATE_CREATING: 'creating',
BACKUP_STATE_ERROR: 'error',
// for testing
_getBackupFilePath: getBackupFilePath,
_restoreFsMetadata: restoreFsMetadata,
@@ -49,22 +56,20 @@ var addons = require('./addons.js'),
BoxError = require('./boxerror.js'),
collectd = require('./collectd.js'),
constants = require('./constants.js'),
CronJob = require('cron').CronJob,
crypto = require('crypto'),
database = require('./database.js'),
DataLayout = require('./datalayout.js'),
debug = require('debug')('box:backups'),
df = require('@sindresorhus/df'),
ejs = require('ejs'),
eventlog = require('./eventlog.js'),
fs = require('fs'),
locker = require('./locker.js'),
moment = require('moment'),
mkdirp = require('mkdirp'),
once = require('once'),
path = require('path'),
paths = require('./paths.js'),
progressStream = require('progress-stream'),
prettyBytes = require('pretty-bytes'),
safe = require('safetydance'),
shell = require('./shell.js'),
settings = require('./settings.js'),
@@ -73,7 +78,8 @@ var addons = require('./addons.js'),
tasks = require('./tasks.js'),
TransformStream = require('stream').Transform,
util = require('util'),
zlib = require('zlib');
zlib = require('zlib'),
_ = require('underscore');
const BACKUP_UPLOAD_CMD = path.join(__dirname, 'scripts/backupupload.js');
const COLLECTD_CONFIG_EJS = fs.readFileSync(__dirname + '/collectd/cloudron-backup.ejs', { encoding: 'utf8' });
@@ -87,6 +93,9 @@ function debugApp(app) {
// choose which storage backend we use for test purpose we use s3
function api(provider) {
switch (provider) {
case 'nfs': return require('./storage/filesystem.js');
case 'cifs': return require('./storage/filesystem.js');
case 'sshfs': return require('./storage/filesystem.js');
case 's3': return require('./storage/s3.js');
case 'gcs': return require('./storage/gcs.js');
case 'filesystem': return require('./storage/filesystem.js');
@@ -96,6 +105,7 @@ function api(provider) {
case 'exoscale-sos': return require('./storage/s3.js');
case 'wasabi': return require('./storage/s3.js');
case 'scaleway-objectstorage': return require('./storage/s3.js');
case 'backblaze-b2': return require('./storage/s3.js');
case 'linode-objectstorage': return require('./storage/s3.js');
case 'ovh-objectstorage': return require('./storage/s3.js');
case 'noop': return require('./storage/noop.js');
@@ -133,8 +143,8 @@ function testConfig(backupConfig, callback) {
if (backupConfig.format !== 'tgz' && backupConfig.format !== 'rsync') return callback(new BoxError(BoxError.BAD_FIELD, 'unknown format', { field: 'format' }));
// remember to adjust the cron ensureBackup task interval accordingly
if (backupConfig.intervalSecs < 6 * 60 * 60) return callback(new BoxError(BoxError.BAD_FIELD, 'Interval must be atleast 6 hours', { field: 'intervalSecs' }));
const job = safe.safeCall(function () { return new CronJob(backupConfig.schedulePattern); });
if (!job) return callback(new BoxError(BoxError.BAD_FIELD, 'Invalid schedule pattern', { field: 'schedulePattern' }));
if ('password' in backupConfig) {
if (typeof backupConfig.password !== 'string') return callback(new BoxError(BoxError.BAD_FIELD, 'password must be a string', { field: 'password' }));
@@ -143,6 +153,7 @@ function testConfig(backupConfig, callback) {
const policy = backupConfig.retentionPolicy;
if (!policy) return callback(new BoxError(BoxError.BAD_FIELD, 'retentionPolicy is required', { field: 'retentionPolicy' }));
if (!['keepWithinSecs','keepDaily','keepWeekly','keepMonthly','keepYearly'].find(k => !!policy[k])) return callback(new BoxError(BoxError.BAD_FIELD, 'properties missing', { field: 'retentionPolicy' }));
if ('keepWithinSecs' in policy && typeof policy.keepWithinSecs !== 'number') return callback(new BoxError(BoxError.BAD_FIELD, 'keepWithinSecs must be a number', { field: 'retentionPolicy' }));
if ('keepDaily' in policy && typeof policy.keepDaily !== 'number') return callback(new BoxError(BoxError.BAD_FIELD, 'keepDaily must be a number', { field: 'retentionPolicy' }));
if ('keepWeekly' in policy && typeof policy.keepWeekly !== 'number') return callback(new BoxError(BoxError.BAD_FIELD, 'keepWeekly must be a number', { field: 'retentionPolicy' }));
@@ -175,26 +186,14 @@ function generateEncryptionKeysSync(password) {
};
}
function getByStatePaged(state, page, perPage, callback) {
function getByIdentifierAndStatePaged(identifier, state, page, perPage, callback) {
assert.strictEqual(typeof identifier, 'string');
assert.strictEqual(typeof state, 'string');
assert(typeof page === 'number' && page > 0);
assert(typeof perPage === 'number' && perPage > 0);
assert.strictEqual(typeof callback, 'function');
backupdb.getByTypeAndStatePaged(backupdb.BACKUP_TYPE_BOX, state, page, perPage, function (error, results) {
if (error) return callback(error);
callback(null, results);
});
}
function getByAppIdPaged(page, perPage, appId, callback) {
assert(typeof page === 'number' && page > 0);
assert(typeof perPage === 'number' && perPage > 0);
assert.strictEqual(typeof appId, 'string');
assert.strictEqual(typeof callback, 'function');
backupdb.getByAppIdPaged(page, perPage, appId, function (error, results) {
backupdb.getByIdentifierAndStatePaged(identifier, state, page, perPage, function (error, results) {
if (error) return callback(error);
callback(null, results);
@@ -212,16 +211,19 @@ function get(backupId, callback) {
});
}
// This is not part of the storage api, since we don't want to pull the "format" logistics into that
function getBackupFilePath(backupConfig, backupId, format) {
assert.strictEqual(typeof backupConfig, 'object');
assert.strictEqual(typeof backupId, 'string');
assert.strictEqual(typeof format, 'string');
const backupPath = api(backupConfig.provider).getBackupPath(backupConfig);
if (format === 'tgz') {
const fileType = backupConfig.encryption ? '.tar.gz.enc' : '.tar.gz';
return path.join(backupConfig.prefix || backupConfig.backupFolder || '', backupId+fileType);
return path.join(backupPath, backupId+fileType);
} else {
return path.join(backupConfig.prefix || backupConfig.backupFolder || '', backupId);
return path.join(backupPath, backupId);
}
}
@@ -563,34 +565,6 @@ function saveFsMetadata(dataLayout, metadataFile, callback) {
callback();
}
// the du call in the function below requires root
function checkFreeDiskSpace(backupConfig, dataLayout, callback) {
assert.strictEqual(typeof backupConfig, 'object');
assert(dataLayout instanceof DataLayout, 'dataLayout must be a DataLayout');
assert.strictEqual(typeof callback, 'function');
if (backupConfig.provider !== 'filesystem') return callback();
let used = 0;
for (let localPath of dataLayout.localPaths()) {
debug(`checkFreeDiskSpace: getting disk usage of ${localPath}`);
let result = safe.child_process.execSync(`du -Dsb ${localPath}`, { encoding: 'utf8' });
if (!result) return callback(new BoxError(BoxError.FS_ERROR, safe.error));
used += parseInt(result, 10);
}
debug(`checkFreeDiskSpace: ${used} bytes`);
df.file(backupConfig.backupFolder).then(function (diskUsage) {
const needed = used + (1024 * 1024 * 1024); // check if there is atleast 1GB left afterwards
if (diskUsage.available <= needed) return callback(new BoxError(BoxError.FS_ERROR, `Not enough disk space for backup. Needed: ${prettyBytes(needed)} Available: ${prettyBytes(diskUsage.available)}`));
callback(null);
}).catch(function (error) {
callback(new BoxError(BoxError.FS_ERROR, error));
});
}
// this function is called via backupupload (since it needs root to traverse app's directory)
function upload(backupId, format, dataLayoutString, progressCallback, callback) {
assert.strictEqual(typeof backupId, 'string');
@@ -606,7 +580,7 @@ function upload(backupId, format, dataLayoutString, progressCallback, callback)
settings.getBackupConfig(function (error, backupConfig) {
if (error) return callback(error);
checkFreeDiskSpace(backupConfig, dataLayout, function (error) {
api(backupConfig.provider).checkPreconditions(backupConfig, dataLayout, function (error) {
if (error) return callback(error);
if (format === 'tgz') {
@@ -705,7 +679,7 @@ function restoreFsMetadata(dataLayout, metadataFile, callback) {
if (metadata === null) return callback(new BoxError(BoxError.EXTERNAL_ERROR, 'Error parsing fsmetadata.json:' + safe.error.message));
async.eachSeries(metadata.emptyDirs, function createPath(emptyDir, iteratorDone) {
mkdirp(dataLayout.toLocalPath(emptyDir), iteratorDone);
fs.mkdir(dataLayout.toLocalPath(emptyDir), { recursive: true }, iteratorDone);
}, function (error) {
if (error) return callback(new BoxError(BoxError.EXTERNAL_ERROR, `unable to create path: ${error.message}`));
@@ -737,7 +711,7 @@ function downloadDir(backupConfig, backupFilePath, dataLayout, progressCallback,
}
const destFilePath = dataLayout.toLocalPath('./' + relativePath);
mkdirp(path.dirname(destFilePath), function (error) {
fs.mkdir(path.dirname(destFilePath), { recursive: true }, function (error) {
if (error) return done(new BoxError(BoxError.FS_ERROR, error.message));
async.retry({ times: 5, interval: 20000 }, function (retryCallback) {
@@ -927,9 +901,13 @@ function snapshotBox(progressCallback, callback) {
progressCallback({ message: 'Snapshotting box' });
const startTime = new Date();
database.exportToFile(`${paths.BOX_DATA_DIR}/box.mysqldump`, function (error) {
if (error) return callback(new BoxError(BoxError.DATABASE_ERROR, error));
debug(`snapshotBox: took ${(new Date() - startTime)/1000} seconds`);
return callback();
});
}
@@ -939,8 +917,6 @@ function uploadBoxSnapshot(backupConfig, progressCallback, callback) {
assert.strictEqual(typeof progressCallback, 'function');
assert.strictEqual(typeof callback, 'function');
var startTime = new Date();
snapshotBox(progressCallback, function (error) {
if (error) return callback(error);
@@ -954,10 +930,14 @@ function uploadBoxSnapshot(backupConfig, progressCallback, callback) {
progressTag: 'box'
};
progressCallback({ message: 'Uploading box snapshot' });
const startTime = new Date();
runBackupUpload(uploadConfig, progressCallback, function (error) {
if (error) return callback(error);
debug('uploadBoxSnapshot: time: %s secs', (new Date() - startTime)/1000);
debug(`uploadBoxSnapshot: took ${(new Date() - startTime)/1000} seconds`);
setSnapshotInfo('box', { timestamp: new Date().toISOString(), format: backupConfig.format }, callback);
});
@@ -982,7 +962,9 @@ function rotateBoxBackup(backupConfig, tag, appBackupIds, progressCallback, call
const data = {
encryptionVersion: backupConfig.encryption ? 2 : null,
packageVersion: constants.VERSION,
type: backupdb.BACKUP_TYPE_BOX,
type: exports.BACKUP_TYPE_BOX,
state: exports.BACKUP_STATE_CREATING,
identifier: 'box',
dependsOn: appBackupIds,
manifest: null,
format: format
@@ -993,9 +975,9 @@ function rotateBoxBackup(backupConfig, tag, appBackupIds, progressCallback, call
var copy = api(backupConfig.provider).copy(backupConfig, getBackupFilePath(backupConfig, 'snapshot/box', format), getBackupFilePath(backupConfig, backupId, format));
copy.on('progress', (message) => progressCallback({ message: `box: ${message}` }));
copy.on('done', function (copyBackupError) {
const state = copyBackupError ? backupdb.BACKUP_STATE_ERROR : backupdb.BACKUP_STATE_NORMAL;
const state = copyBackupError ? exports.BACKUP_STATE_ERROR : exports.BACKUP_STATE_NORMAL;
backupdb.update(backupId, { state: state }, function (error) {
backupdb.update(backupId, { state }, function (error) {
if (copyBackupError) return callback(copyBackupError);
if (error) return callback(error);
@@ -1026,6 +1008,10 @@ function backupBoxWithAppBackupIds(appBackupIds, tag, progressCallback, callback
function canBackupApp(app) {
// only backup apps that are installed or specific pending states
// stopped apps cannot be backed up because addons might be down (redis)
if (app.runState === apps.RSTATE_STOPPED) return false;
// we used to check the health here but that doesn't work for stopped apps. it's better to just fail
// and inform the user if the backup fails and the app addons have not been setup yet.
return app.installationState === apps.ISTATE_INSTALLED ||
@@ -1039,6 +1025,7 @@ function snapshotApp(app, progressCallback, callback) {
assert.strictEqual(typeof progressCallback, 'function');
assert.strictEqual(typeof callback, 'function');
const startTime = new Date();
progressCallback({ message: `Snapshotting app ${app.fqdn}` });
if (!safe.fs.writeFileSync(path.join(paths.APPS_DATA_DIR, app.id + '/config.json'), JSON.stringify(app))) {
@@ -1048,6 +1035,8 @@ function snapshotApp(app, progressCallback, callback) {
addons.backupAddons(app, app.manifest.addons, function (error) {
if (error) return callback(new BoxError(BoxError.EXTERNAL_ERROR, error.message));
debugApp(app, `snapshotApp: took ${(new Date() - startTime)/1000} seconds`);
return callback(null);
});
}
@@ -1060,6 +1049,8 @@ function rotateAppBackup(backupConfig, app, tag, options, progressCallback, call
assert.strictEqual(typeof progressCallback, 'function');
assert.strictEqual(typeof callback, 'function');
const startTime = new Date();
var snapshotInfo = getSnapshotInfo(app.id);
var manifest = snapshotInfo.restoreConfig ? snapshotInfo.restoreConfig.manifest : snapshotInfo.manifest; // compat
@@ -1072,7 +1063,9 @@ function rotateAppBackup(backupConfig, app, tag, options, progressCallback, call
const data = {
encryptionVersion: backupConfig.encryption ? 2 : null,
packageVersion: manifest.version,
type: backupdb.BACKUP_TYPE_APP,
type: exports.BACKUP_TYPE_APP,
state: exports.BACKUP_STATE_CREATING,
identifier: app.id,
dependsOn: [ ],
manifest,
format: format
@@ -1084,13 +1077,13 @@ function rotateAppBackup(backupConfig, app, tag, options, progressCallback, call
var copy = api(backupConfig.provider).copy(backupConfig, getBackupFilePath(backupConfig, `snapshot/app_${app.id}`, format), getBackupFilePath(backupConfig, backupId, format));
copy.on('progress', (message) => progressCallback({ message: `${message} (${app.fqdn})` }));
copy.on('done', function (copyBackupError) {
const state = copyBackupError ? backupdb.BACKUP_STATE_ERROR : backupdb.BACKUP_STATE_NORMAL;
const state = copyBackupError ? exports.BACKUP_STATE_ERROR : exports.BACKUP_STATE_NORMAL;
backupdb.update(backupId, { preserveSecs: options.preserveSecs || 0, state: state }, function (error) {
backupdb.update(backupId, { preserveSecs: options.preserveSecs || 0, state }, function (error) {
if (copyBackupError) return callback(copyBackupError);
if (error) return callback(error);
debug(`Rotated app backup of ${app.id} successfully to id ${backupId}`);
debug(`Rotated app backup of ${app.id} successfully to id ${backupId}. Took ${(new Date() - startTime)/1000} seconds`);
callback(null, backupId);
});
@@ -1104,10 +1097,6 @@ function uploadAppSnapshot(backupConfig, app, progressCallback, callback) {
assert.strictEqual(typeof progressCallback, 'function');
assert.strictEqual(typeof callback, 'function');
if (!canBackupApp(app)) return callback(); // nothing to do
var startTime = new Date();
snapshotApp(app, progressCallback, function (error) {
if (error) return callback(error);
@@ -1117,6 +1106,8 @@ function uploadAppSnapshot(backupConfig, app, progressCallback, callback) {
const dataLayout = new DataLayout(appDataDir, app.dataDir ? [{ localDir: app.dataDir, remoteDir: 'data' }] : []);
progressCallback({ message: `Uploading app snapshot ${app.fqdn}`});
const uploadConfig = {
backupId,
format: backupConfig.format,
@@ -1124,10 +1115,12 @@ function uploadAppSnapshot(backupConfig, app, progressCallback, callback) {
progressTag: app.fqdn
};
const startTime = new Date();
runBackupUpload(uploadConfig, progressCallback, function (error) {
if (error) return callback(error);
debugApp(app, 'uploadAppSnapshot: %s done time: %s secs', backupId, (new Date() - startTime)/1000);
debugApp(app, `uploadAppSnapshot: ${backupId} done. ${(new Date() - startTime)/1000} seconds`);
setSnapshotInfo(app.id, { timestamp: new Date().toISOString(), manifest: app.manifest, format: backupConfig.format }, callback);
});
@@ -1141,7 +1134,16 @@ function backupAppWithTag(app, tag, options, progressCallback, callback) {
assert.strictEqual(typeof progressCallback, 'function');
assert.strictEqual(typeof callback, 'function');
if (!canBackupApp(app)) return callback(); // nothing to do
if (!canBackupApp(app)) { // if we cannot backup, reuse it's most recent backup
getByIdentifierAndStatePaged(app.id, exports.BACKUP_STATE_NORMAL, 1, 1, function (error, results) {
if (error) return callback(error);
if (results.length === 0) return callback(null, null); // no backup to re-use
callback(null, results[0].id);
});
return;
}
settings.getBackupConfig(function (error, backupConfig) {
if (error) return callback(error);
@@ -1189,13 +1191,14 @@ function backupBoxAndApps(progressCallback, callback) {
return iteratorCallback(null, null); // nothing to backup
}
const startTime = new Date();
backupAppWithTag(app, tag, { /* options */ }, (progress) => progressCallback({ percent: percent, message: progress.message }), function (error, backupId) {
if (error && error.reason !== BoxError.BAD_STATE) {
if (error) {
debugApp(app, 'Unable to backup', error);
return iteratorCallback(error);
}
debugApp(app, 'Backed up');
debugApp(app, `Backed up. Took ${(new Date() - startTime)/1000} seconds`);
iteratorCallback(null, backupId || null); // clear backupId if is in BAD_STATE and never backed up
});
@@ -1216,59 +1219,46 @@ function startBackupTask(auditSource, callback) {
let error = locker.lock(locker.OP_FULL_BACKUP);
if (error) return callback(new BoxError(BoxError.BAD_STATE, `Cannot backup now: ${error.message}`));
tasks.add(tasks.TASK_BACKUP, [ ], function (error, taskId) {
settings.getBackupConfig(function (error, backupConfig) {
if (error) return callback(error);
eventlog.add(eventlog.ACTION_BACKUP_START, auditSource, { taskId });
const memoryLimit = 'memoryLimit' in backupConfig ? Math.max(backupConfig.memoryLimit/1024/1024, 400) : 400;
tasks.startTask(taskId, { timeout: 12 * 60 * 60 * 1000 /* 12 hours */ }, function (error, backupId) {
locker.unlock(locker.OP_FULL_BACKUP);
const errorMessage = error ? error.message : '';
const timedOut = error ? error.code === tasks.ETIMEOUT : false;
eventlog.add(eventlog.ACTION_BACKUP_FINISH, auditSource, { taskId, errorMessage, timedOut, backupId });
});
callback(null, taskId);
});
}
function ensureBackup(auditSource, callback) {
assert.strictEqual(typeof auditSource, 'object');
debug('ensureBackup: %j', auditSource);
getByStatePaged(backupdb.BACKUP_STATE_NORMAL, 1, 1, function (error, backups) {
if (error) {
debug('Unable to list backups', error);
return callback(error);
}
settings.getBackupConfig(function (error, backupConfig) {
tasks.add(tasks.TASK_BACKUP, [ ], function (error, taskId) {
if (error) return callback(error);
if (backups.length !== 0 && (new Date() - new Date(backups[0].creationTime) < (backupConfig.intervalSecs - 3600) * 1000)) { // adjust 1 hour
debug('Previous backup was %j, no need to backup now', backups[0]);
return callback(null);
}
eventlog.add(eventlog.ACTION_BACKUP_START, auditSource, { taskId });
startBackupTask(auditSource, callback);
tasks.startTask(taskId, { timeout: 12 * 60 * 60 * 1000 /* 12 hours */, nice: 15, memoryLimit }, function (error, backupId) {
locker.unlock(locker.OP_FULL_BACKUP);
const errorMessage = error ? error.message : '';
const timedOut = error ? error.code === tasks.ETIMEOUT : false;
eventlog.add(eventlog.ACTION_BACKUP_FINISH, auditSource, { taskId, errorMessage, timedOut, backupId });
});
callback(null, taskId);
});
});
}
// backups must be descending in creationTime
function applyBackupRetentionPolicy(backups, policy) {
function applyBackupRetentionPolicy(backups, policy, referencedBackupIds) {
assert(Array.isArray(backups));
assert.strictEqual(typeof policy, 'object');
assert(Array.isArray(referencedBackupIds));
const now = new Date();
for (const backup of backups) {
if (backup.keepReason) continue; // already kept for some other reason
if ((now - backup.creationTime) < (backup.preserveSecs * 1000)) {
if (backup.state === exports.BACKUP_STATE_ERROR) {
backup.discardReason = 'error';
} else if (backup.state === exports.BACKUP_STATE_CREATING) {
if ((now - backup.creationTime) < 48*60*60*1000) backup.keepReason = 'creating';
else backup.discardReason = 'creating-too-long';
} else if (referencedBackupIds.includes(backup.id)) {
backup.keepReason = 'reference';
} else if ((now - backup.creationTime) < (backup.preserveSecs * 1000)) {
backup.keepReason = 'preserveSecs';
} else if ((now - backup.creationTime < policy.keepWithinSecs * 1000) || policy.keepWithinSecs < 0) {
backup.keepReason = 'keepWithinSecs';
@@ -1290,18 +1280,24 @@ function applyBackupRetentionPolicy(backups, policy) {
let lastPeriod = null, keptSoFar = 0;
for (const backup of backups) {
if (backup.keepReason) continue; // already kept for some other reason
if (backup.discardReason) continue; // already discarded for some reason
if (backup.keepReason && backup.keepReason !== 'reference') continue; // kept for some other reason
const period = moment(backup.creationTime).format(KEEP_FORMATS[format]);
if (period === lastPeriod) continue; // already kept for this period
lastPeriod = period;
backup.keepReason = format;
backup.keepReason = backup.keepReason ? `${backup.keepReason}+${format}` : format;
if (++keptSoFar === n) break;
}
}
if (policy.keepLatest) {
let latestNormalBackup = backups.find(b => b.state === exports.BACKUP_STATE_NORMAL);
if (latestNormalBackup && !latestNormalBackup.keepReason) latestNormalBackup.keepReason = 'latest';
}
for (const backup of backups) {
if (backup.keepReason) debug(`applyBackupRetentionPolicy: ${backup.id} ${backup.type} ${backup.keepReason}`);
debug(`applyBackupRetentionPolicy: ${backup.id} ${backup.type} ${backup.keepReason || backup.discardReason || 'unprocessed'}`);
}
}
@@ -1350,60 +1346,52 @@ function cleanupAppBackups(backupConfig, referencedAppBackupIds, progressCallbac
let removedAppBackupIds = [];
// we clean app backups of any state because the ones to keep are determined by the box cleanup code
backupdb.getByTypePaged(backupdb.BACKUP_TYPE_APP, 1, 1000, function (error, appBackups) {
apps.getAll(function (error, allApps) {
if (error) return callback(error);
for (const appBackup of appBackups) { // set the reason so that policy filter can skip it
if (referencedAppBackupIds.includes(appBackup.id)) appBackup.keepReason = 'reference';
}
const allAppIds = allApps.map(a => a.id);
applyBackupRetentionPolicy(appBackups, backupConfig.retentionPolicy);
backupdb.getByTypePaged(exports.BACKUP_TYPE_APP, 1, 1000, function (error, appBackups) {
if (error) return callback(error);
async.eachSeries(appBackups, function iterator(appBackup, iteratorDone) {
if (appBackup.keepReason) return iteratorDone();
// collate the backups by app id. note that the app could already have been uninstalled
let appBackupsById = {};
for (const appBackup of appBackups) {
if (!appBackupsById[appBackup.identifier]) appBackupsById[appBackup.identifier] = [];
appBackupsById[appBackup.identifier].push(appBackup);
}
progressCallback({ message: `Removing app backup ${appBackup.id}`});
// apply backup policy per app. keep latest backup only for existing apps
let appBackupsToRemove = [];
for (const appId of Object.keys(appBackupsById)) {
applyBackupRetentionPolicy(appBackupsById[appId], _.extend({ keepLatest: allAppIds.includes(appId) }, backupConfig.retentionPolicy), referencedAppBackupIds);
appBackupsToRemove = appBackupsToRemove.concat(appBackupsById[appId].filter(b => !b.keepReason));
}
removedAppBackupIds.push(appBackup.id);
cleanupBackup(backupConfig, appBackup, progressCallback, iteratorDone);
}, function () {
debug('cleanupAppBackups: done');
async.eachSeries(appBackupsToRemove, function iterator(appBackup, iteratorDone) {
progressCallback({ message: `Removing app backup (${appBackup.identifier}): ${appBackup.id}`});
removedAppBackupIds.push(appBackup.id);
cleanupBackup(backupConfig, appBackup, progressCallback, iteratorDone);
}, function () {
debug('cleanupAppBackups: done');
callback(null, removedAppBackupIds);
callback(null, removedAppBackupIds);
});
});
});
}
function cleanupBoxBackups(backupConfig, progressCallback, auditSource, callback) {
function cleanupBoxBackups(backupConfig, progressCallback, callback) {
assert.strictEqual(typeof backupConfig, 'object');
assert.strictEqual(typeof progressCallback, 'function');
assert.strictEqual(typeof auditSource, 'object');
assert.strictEqual(typeof callback, 'function');
let referencedAppBackupIds = [], removedBoxBackupIds = [];
backupdb.getByTypePaged(backupdb.BACKUP_TYPE_BOX, 1, 1000, function (error, boxBackups) {
backupdb.getByTypePaged(exports.BACKUP_TYPE_BOX, 1, 1000, function (error, boxBackups) {
if (error) return callback(error);
if (boxBackups.length === 0) return callback(null, { removedBoxBackupIds, referencedAppBackupIds });
// search for the first valid backup
var i;
for (i = 0; i < boxBackups.length; i++) {
if (boxBackups[i].state === backupdb.BACKUP_STATE_NORMAL) break;
}
// keep the first valid backup
if (i !== boxBackups.length) {
debug('cleanupBoxBackups: preserving box backup %s (%j)', boxBackups[i].id, boxBackups[i].dependsOn);
referencedAppBackupIds = boxBackups[i].dependsOn;
boxBackups.splice(i, 1);
} else {
debug('cleanupBoxBackups: no box backup to preserve');
}
applyBackupRetentionPolicy(boxBackups, backupConfig.retentionPolicy);
applyBackupRetentionPolicy(boxBackups, _.extend({ keepLatest: true }, backupConfig.retentionPolicy), [] /* references */);
async.eachSeries(boxBackups, function iterator(boxBackup, iteratorNext) {
if (boxBackup.keepReason) {
@@ -1472,8 +1460,7 @@ function cleanupSnapshots(backupConfig, callback) {
});
}
function cleanup(auditSource, progressCallback, callback) {
assert.strictEqual(typeof auditSource, 'object');
function cleanup(progressCallback, callback) {
assert.strictEqual(typeof progressCallback, 'function');
assert.strictEqual(typeof callback, 'function');
@@ -1487,7 +1474,7 @@ function cleanup(auditSource, progressCallback, callback) {
progressCallback({ percent: 10, message: 'Cleaning box backups' });
cleanupBoxBackups(backupConfig, progressCallback, auditSource, function (error, { removedBoxBackupIds, referencedAppBackupIds }) {
cleanupBoxBackups(backupConfig, progressCallback, function (error, { removedBoxBackupIds, referencedAppBackupIds }) {
if (error) return callback(error);
progressCallback({ percent: 40, message: 'Cleaning app backups' });
@@ -1509,7 +1496,7 @@ function cleanup(auditSource, progressCallback, callback) {
function startCleanupTask(auditSource, callback) {
tasks.add(tasks.TASK_CLEAN_BACKUPS, [ auditSource ], function (error, taskId) {
tasks.add(tasks.TASK_CLEAN_BACKUPS, [], function (error, taskId) {
if (error) return callback(error);
tasks.startTask(taskId, {}, (error, result) => { // result is { removedBoxBackups, removedAppBackups }

View File

@@ -1,22 +0,0 @@
'use strict';
exports = module.exports = {
getCertificate: getCertificate,
// testing
_name: 'caas'
};
var assert = require('assert'),
debug = require('debug')('box:cert/caas.js');
function getCertificate(hostname, domain, options, callback) {
assert.strictEqual(typeof hostname, 'string');
assert.strictEqual(typeof domain, 'string');
assert.strictEqual(typeof options, 'object');
assert.strictEqual(typeof callback, 'function');
debug('getCertificate: using fallback certificate', hostname);
return callback(null, '', '');
}

View File

@@ -1,22 +0,0 @@
'use strict';
exports = module.exports = {
getCertificate: getCertificate,
// testing
_name: 'fallback'
};
var assert = require('assert'),
debug = require('debug')('box:cert/fallback.js');
function getCertificate(hostname, domain, options, callback) {
assert.strictEqual(typeof hostname, 'string');
assert.strictEqual(typeof domain, 'string');
assert.strictEqual(typeof options, 'object');
assert.strictEqual(typeof callback, 'function');
debug('getCertificate: using fallback certificate', hostname);
return callback(null, '', '');
}

View File

@@ -1,24 +0,0 @@
'use strict';
// -------------------------------------------
// This file just describes the interface
//
// New backends can start from here
// -------------------------------------------
exports = module.exports = {
getCertificate: getCertificate
};
var assert = require('assert'),
BoxError = require('../boxerror.js');
function getCertificate(hostname, domain, options, callback) {
assert.strictEqual(typeof hostname, 'string');
assert.strictEqual(typeof domain, 'string');
assert.strictEqual(typeof options, 'object');
assert.strictEqual(typeof callback, 'function');
return callback(new BoxError(BoxError.NOT_IMPLEMENTED, 'getCertificate is not implemented'));
}

View File

@@ -18,7 +18,7 @@ exports = module.exports = {
setupDashboard: setupDashboard,
runSystemChecks: runSystemChecks,
runSystemChecks: runSystemChecks
};
var addons = require('./addons.js'),
@@ -66,7 +66,7 @@ function uninitialize(callback) {
async.series([
cron.stopJobs,
platform.stop
platform.stopAllTasks
], callback);
}
@@ -78,7 +78,13 @@ function onActivated(callback) {
// 2. the restore code path can run without sudo (since mail/ is non-root)
async.series([
platform.start,
cron.startJobs
cron.startJobs,
function checkBackupConfiguration(callback) {
backups.checkConfiguration(function (error, message) {
if (error) return callback(error);
notifications.alert(notifications.ALERT_BACKUP_CONFIG, 'Backup configuration is unsafe', message, callback);
});
}
], callback);
}
@@ -103,12 +109,15 @@ function notifyUpdate(callback) {
// each of these tasks can fail. we will add some routes to fix/re-run them
function runStartupTasks() {
// stop all the systemd tasks
platform.stopAllTasks(NOOP_CALLBACK);
// configure nginx to be reachable by IP
reverseProxy.writeDefaultConfig(NOOP_CALLBACK);
// this configures collectd to collect backup storage metrics if filesystem is used. This is also triggerd when the settings change with the rest api
settings.getBackupConfig(function (error, backupConfig) {
if (error) return console.error('Failed to read backup config.', error);
if (error) return debug('runStartupTasks: failed to get backup config.', error);
backups.configureCollectd(backupConfig, NOOP_CALLBACK);
});
@@ -139,17 +148,18 @@ function getConfig(callback) {
mailFqdn: settings.mailFqdn(),
version: constants.VERSION,
isDemo: settings.isDemo(),
provider: settings.provider(),
cloudronName: allSettings[settings.CLOUDRON_NAME_KEY],
footer: allSettings[settings.FOOTER_KEY] || constants.FOOTER,
features: appstore.getFeatures()
features: appstore.getFeatures(),
profileLocked: allSettings[settings.DIRECTORY_CONFIG_KEY].lockUserProfiles,
mandatory2FA: allSettings[settings.DIRECTORY_CONFIG_KEY].mandatory2FA
});
});
}
function reboot(callback) {
notifications.alert(notifications.ALERT_REBOOT, 'Reboot Required', '', function (error) {
if (error) console.error('Failed to clear reboot notification.', error);
if (error) debug('reboot: failed to clear reboot notification.', error);
shell.sudo('reboot', [ REBOOT_CMD ], {}, callback);
});
@@ -167,24 +177,11 @@ function runSystemChecks(callback) {
assert.strictEqual(typeof callback, 'function');
async.parallel([
checkBackupConfiguration,
checkMailStatus,
checkRebootRequired
], callback);
}
function checkBackupConfiguration(callback) {
assert.strictEqual(typeof callback, 'function');
debug('checking backup configuration');
backups.checkConfiguration(function (error, message) {
if (error) return callback(error);
notifications.alert(notifications.ALERT_BACKUP_CONFIG, 'Backup configuration is unsafe', message, callback);
});
}
function checkMailStatus(callback) {
assert.strictEqual(typeof callback, 'function');

View File

@@ -1,16 +1,25 @@
'use strict';
// IMPORTANT: These patterns are together because they spin tasks which acquire a lock
// If the patterns overlap all the time, then the task may not ever get a chance to run!
// If you change this change dashboard patterns in settings.html
const DEFAULT_CLEANUP_BACKUPS_PATTERN = '00 30 1,3,5,23 * * *',
DEFAULT_BOX_AUTOUPDATE_PATTERN = '00 00 1,3,5,23 * * *',
DEFAULT_APP_AUTOUPDATE_PATTERN = '00 15 1,3,5,23 * * *';
exports = module.exports = {
startJobs: startJobs,
startJobs,
stopJobs: stopJobs,
stopJobs,
handleSettingsChanged: handleSettingsChanged
handleSettingsChanged,
DEFAULT_BOX_AUTOUPDATE_PATTERN,
DEFAULT_APP_AUTOUPDATE_PATTERN
};
var appHealthMonitor = require('./apphealthmonitor.js'),
apps = require('./apps.js'),
appstore = require('./appstore.js'),
assert = require('assert'),
async = require('async'),
auditSource = require('./auditsource.js'),
@@ -29,7 +38,6 @@ var appHealthMonitor = require('./apphealthmonitor.js'),
updateChecker = require('./updatechecker.js');
var gJobs = {
alive: null, // send periodic stats
appAutoUpdater: null,
boxAutoUpdater: null,
appUpdateChecker: null,
@@ -61,14 +69,8 @@ function startJobs(callback) {
assert.strictEqual(typeof callback, 'function');
const randomMinute = Math.floor(60*Math.random());
gJobs.alive = new CronJob({
cronTime: '00 ' + randomMinute + ' * * * *', // every hour on a random minute
onTick: appstore.sendAliveStatus,
start: true
});
gJobs.systemChecks = new CronJob({
cronTime: '00 30 * * * *', // every 30 minutes. if you change this interval, change the notification messages with correct duration
cronTime: '00 30 2 * * *', // once a day. if you change this interval, change the notification messages with correct duration
onTick: () => cloudron.runSystemChecks(NOOP_CALLBACK),
start: true
});
@@ -80,13 +82,14 @@ function startJobs(callback) {
});
gJobs.boxUpdateCheckerJob = new CronJob({
cronTime: '00 ' + randomMinute + ' 23 * * *', // once an day
cronTime: '00 ' + randomMinute + ' 1,3,5,21,23 * * *', // 5 times
onTick: () => updateChecker.checkBoxUpdates({ automatic: true }, NOOP_CALLBACK),
start: true
});
// this is run separately from the update itself so that the user can disable automatic updates but can still get a notification
gJobs.appUpdateChecker = new CronJob({
cronTime: '00 ' + randomMinute + ' 22 * * *', // once an day
cronTime: '00 ' + randomMinute + ' 2,4,6,20,22 * * *', // 5 times
onTick: () => updateChecker.checkAppUpdates({ automatic: true }, NOOP_CALLBACK),
start: true
});
@@ -98,7 +101,7 @@ function startJobs(callback) {
});
gJobs.cleanupBackups = new CronJob({
cronTime: '00 45 1,3,5,23 * * *', // every 6 hours. try not to overlap with ensureBackup job
cronTime: DEFAULT_CLEANUP_BACKUPS_PATTERN,
onTick: backups.startCleanupTask.bind(null, auditSource.CRON, NOOP_CALLBACK),
start: true
});
@@ -172,19 +175,13 @@ function backupConfigChanged(value, tz) {
assert.strictEqual(typeof value, 'object');
assert.strictEqual(typeof tz, 'string');
debug(`backupConfigChanged: interval ${value.intervalSecs} (${tz})`);
debug(`backupConfigChanged: schedule ${value.schedulePattern} (${tz})`);
if (gJobs.backup) gJobs.backup.stop();
let pattern;
if (value.intervalSecs <= 6 * 60 * 60) {
pattern = '00 00 1,7,13,19 * * *'; // no option but to backup in the middle of the day
} else {
pattern = '00 00 1,3,5,23 * * *'; // avoid middle of the day backups
}
gJobs.backup = new CronJob({
cronTime: pattern,
onTick: backups.ensureBackup.bind(null, auditSource.CRON, NOOP_CALLBACK),
cronTime: value.schedulePattern,
onTick: backups.startBackupTask.bind(null, auditSource.CRON, NOOP_CALLBACK),
start: true,
timeZone: tz
});

View File

@@ -17,12 +17,12 @@ var assert = require('assert'),
BoxError = require('./boxerror.js'),
child_process = require('child_process'),
constants = require('./constants.js'),
debug = require('debug')('box:database'),
mysql = require('mysql'),
once = require('once'),
util = require('util');
var gConnectionPool = null,
gDefaultConnection = null;
var gConnectionPool = null;
const gDatabase = {
hostname: '127.0.0.1',
@@ -42,59 +42,37 @@ function initialize(callback) {
gDatabase.hostname = require('child_process').execSync('docker inspect -f "{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}" mysql-server').toString().trim();
}
// https://github.com/mysqljs/mysql#pool-options
gConnectionPool = mysql.createPool({
connectionLimit: 5, // this has to be > 1 since we store one connection as 'default'. the rest for transactions
connectionLimit: 5,
host: gDatabase.hostname,
user: gDatabase.username,
password: gDatabase.password,
port: gDatabase.port,
database: gDatabase.name,
multipleStatements: false,
waitForConnections: true, // getConnection() will wait until a connection is avaiable
ssl: false,
timezone: 'Z' // mysql follows the SYSTEM timezone. on Cloudron, this is UTC
});
gConnectionPool.on('connection', function (connection) {
// connection objects are re-used. so we have to attach to the event here (once) to prevent crash
// note the pool also has an 'acquire' event but that is called whenever we do a getConnection()
connection.on('error', (error) => debug(`Connection ${connection.threadId} error: ${error.message} ${error.code}`));
connection.query('USE ' + gDatabase.name);
connection.query('SET SESSION sql_mode = \'strict_all_tables\'');
});
reconnect(callback);
callback(null);
}
function uninitialize(callback) {
if (gConnectionPool) {
gConnectionPool.end(callback);
gConnectionPool = null;
} else {
callback(null);
}
}
if (!gConnectionPool) return callback(null);
function reconnect(callback) {
callback = callback ? once(callback) : function () {};
gConnectionPool.getConnection(function (error, connection) {
if (error) {
console.error('Unable to reestablish connection to database. Try again in a bit.', error.message);
return setTimeout(reconnect.bind(null, callback), 1000);
}
connection.on('error', function (error) {
// by design, we catch all normal errors by providing callbacks.
// this function should be invoked only when we have no callbacks pending and we have a fatal error
assert(error.fatal, 'Non-fatal error on connection object');
console.error('Unhandled mysql connection error.', error);
// This is most likely an issue an can cause double callbacks from reconnect()
setTimeout(reconnect.bind(null, callback), 1000);
});
gDefaultConnection = connection;
callback(null);
});
gConnectionPool.end(callback);
gConnectionPool = null;
}
function clear(callback) {
@@ -107,80 +85,43 @@ function clear(callback) {
child_process.exec(cmd, callback);
}
function beginTransaction(callback) {
assert.strictEqual(typeof callback, 'function');
if (gConnectionPool === null) return callback(new BoxError(BoxError.DATABASE_ERROR, 'No database connection pool.'));
gConnectionPool.getConnection(function (error, connection) {
if (error) {
console.error('Unable to get connection to database. Try again in a bit.', error.message);
return setTimeout(beginTransaction.bind(null, callback), 1000);
}
connection.beginTransaction(function (error) {
if (error) return callback(error);
return callback(null, connection);
});
});
}
function rollback(connection, callback) {
assert.strictEqual(typeof callback, 'function');
connection.rollback(function (error) {
if (error) console.error(error); // can this happen?
connection.release();
callback(null);
});
}
// FIXME: if commit fails, is it supposed to return an error ?
function commit(connection, callback) {
assert.strictEqual(typeof callback, 'function');
connection.commit(function (error) {
if (error) return rollback(connection, callback);
connection.release();
return callback(null);
});
}
function query() {
var args = Array.prototype.slice.call(arguments);
var callback = args[args.length - 1];
const args = Array.prototype.slice.call(arguments);
const callback = args[args.length - 1];
assert.strictEqual(typeof callback, 'function');
if (gDefaultConnection === null) return callback(new BoxError(BoxError.DATABASE_ERROR, 'No connection to database'));
if (constants.TEST && !gConnectionPool) return callback(new BoxError(BoxError.DATABASE_ERROR, 'database.js not initialized'));
args[args.length -1 ] = function (error, result) {
if (error && error.fatal) {
gDefaultConnection = null;
setTimeout(reconnect, 1000);
}
callback(error, result);
};
gDefaultConnection.query.apply(gDefaultConnection, args);
gConnectionPool.query.apply(gConnectionPool, args); // this is same as getConnection/query/release
}
function transaction(queries, callback) {
assert(util.isArray(queries));
assert.strictEqual(typeof callback, 'function');
beginTransaction(function (error, conn) {
callback = once(callback);
gConnectionPool.getConnection(function (error, connection) {
if (error) return callback(error);
async.mapSeries(queries, function iterator(query, done) {
conn.query(query.query, query.args, done);
}, function seriesDone(error, results) {
if (error) return rollback(conn, callback.bind(null, error));
const releaseConnection = (error) => { connection.release(); callback(error); };
commit(conn, callback.bind(null, null, results));
connection.beginTransaction(function (error) {
if (error) return releaseConnection(error);
async.mapSeries(queries, function iterator(query, done) {
connection.query(query.query, query.args, done);
}, function seriesDone(error, results) {
if (error) return connection.rollback(() => releaseConnection(error));
connection.commit(function (error) {
if (error) return connection.rollback(() => releaseConnection(error));
connection.release();
callback(null, results);
});
});
});
});
}
@@ -203,7 +144,7 @@ function exportToFile(file, callback) {
// latest mysqldump enables column stats by default which is not present in MySQL 5.7 server
// this option must not be set in production cloudrons which still use the old mysqldump
const disableColStats = (constants.TEST && process.env.DESKTOP_SESSION !== 'ubuntu') ? '--column-statistics=0' : '';
const disableColStats = (constants.TEST && require('fs').readFileSync('/etc/lsb-release', 'utf-8').includes('20.04')) ? '--column-statistics=0' : '';
var cmd = `/usr/bin/mysqldump -h "${gDatabase.hostname}" -u root -p${gDatabase.password} ${disableColStats} --single-transaction --routines --triggers ${gDatabase.name} > "${file}"`;

View File

@@ -1,177 +0,0 @@
'use strict';
exports = module.exports = {
removePrivateFields: removePrivateFields,
injectPrivateFields: injectPrivateFields,
upsert: upsert,
get: get,
del: del,
wait: wait,
verifyDnsConfig: verifyDnsConfig
};
var assert = require('assert'),
BoxError = require('../boxerror.js'),
constants = require('../constants.js'),
debug = require('debug')('box:dns/caas'),
domains = require('../domains.js'),
settings = require('../settings.js'),
superagent = require('superagent'),
util = require('util'),
waitForDns = require('./waitfordns.js');
function formatError(response) {
return util.format('Caas DNS error [%s] %j', response.statusCode, response.body);
}
function getFqdn(location, domain) {
assert.strictEqual(typeof location, 'string');
assert.strictEqual(typeof domain, 'string');
return (location === '') ? domain : location + '-' + domain;
}
function removePrivateFields(domainObject) {
domainObject.config.token = constants.SECRET_PLACEHOLDER;
// do not return the 'key'. in caas, this is private
delete domainObject.fallbackCertificate.key;
return domainObject;
}
function injectPrivateFields(newConfig, currentConfig) {
if (newConfig.token === constants.SECRET_PLACEHOLDER) newConfig.token = currentConfig.token;
}
function upsert(domainObject, location, type, values, callback) {
assert.strictEqual(typeof domainObject, 'object');
assert.strictEqual(typeof location, 'string');
assert.strictEqual(typeof type, 'string');
assert(util.isArray(values));
assert.strictEqual(typeof callback, 'function');
const dnsConfig = domainObject.config;
let fqdn = location !== '' && type === 'TXT' ? location + '.' + domainObject.domain : getFqdn(location, domainObject.domain);
debug('add: %s for zone %s of type %s with values %j', location, domainObject.domain, type, values);
var data = {
type: type,
values: values
};
superagent
.post(settings.apiServerOrigin() + '/api/v1/caas/domains/' + fqdn)
.query({ token: dnsConfig.token })
.send(data)
.timeout(30 * 1000)
.end(function (error, result) {
if (error && !error.response) return callback(new BoxError(BoxError.NETWORK_ERROR, error.message));
if (result.statusCode === 400) return callback(new BoxError(BoxError.BAD_FIELD, result.body.message));
if (result.statusCode === 420) return callback(new BoxError(BoxError.BUSY));
if (result.statusCode !== 201) return callback(new BoxError(BoxError.EXTERNAL_ERROR, formatError(result)));
return callback(null);
});
}
function get(domainObject, location, type, callback) {
assert.strictEqual(typeof domainObject, 'object');
assert.strictEqual(typeof location, 'string');
assert.strictEqual(typeof type, 'string');
assert.strictEqual(typeof callback, 'function');
const dnsConfig = domainObject.config;
const fqdn = location !== '' && type === 'TXT' ? location + '.' + domainObject.domain : getFqdn(location, domainObject.domain);
debug('get: zoneName: %s subdomain: %s type: %s fqdn: %s', domainObject.domain, location, type, fqdn);
superagent
.get(settings.apiServerOrigin() + '/api/v1/caas/domains/' + fqdn)
.query({ token: dnsConfig.token, type: type })
.timeout(30 * 1000)
.end(function (error, result) {
if (error && !error.response) return callback(new BoxError(BoxError.NETWORK_ERROR, error.message));
if (result.statusCode !== 200) return callback(new BoxError(BoxError.EXTERNAL_ERROR, formatError(result)));
return callback(null, result.body.values);
});
}
function del(domainObject, location, type, values, callback) {
assert.strictEqual(typeof domainObject, 'object');
assert.strictEqual(typeof location, 'string');
assert.strictEqual(typeof type, 'string');
assert(util.isArray(values));
assert.strictEqual(typeof callback, 'function');
const dnsConfig = domainObject.config;
debug('del: %s for zone %s of type %s with values %j', location, domainObject.domain, type, values);
var data = {
type: type,
values: values
};
superagent
.del(settings.apiServerOrigin() + '/api/v1/caas/domains/' + getFqdn(location, domainObject.domain))
.query({ token: dnsConfig.token })
.send(data)
.timeout(30 * 1000)
.end(function (error, result) {
if (error && !error.response) return callback(new BoxError(BoxError.NETWORK_ERROR, error.message));
if (result.statusCode === 400) return callback(new BoxError(BoxError.BAD_FIELD, result.body.message));
if (result.statusCode === 420) return callback(new BoxError(BoxError.BUSY));
if (result.statusCode === 404) return callback(new BoxError(BoxError.NOT_FOUND));
if (result.statusCode !== 204) return callback(new BoxError(BoxError.EXTERNAL_ERROR, formatError(result)));
return callback(null);
});
}
function wait(domainObject, location, type, value, options, callback) {
assert.strictEqual(typeof domainObject, 'object');
assert.strictEqual(typeof location, 'string');
assert.strictEqual(typeof type, 'string');
assert.strictEqual(typeof value, 'string');
assert(options && typeof options === 'object'); // { interval: 5000, times: 50000 }
assert.strictEqual(typeof callback, 'function');
const fqdn = domains.fqdn(location, domainObject);
waitForDns(fqdn, domainObject.zoneName, type, value, options, callback);
}
function verifyDnsConfig(domainObject, callback) {
assert.strictEqual(typeof domainObject, 'object');
assert.strictEqual(typeof callback, 'function');
const dnsConfig = domainObject.config;
if (!dnsConfig.token || typeof dnsConfig.token !== 'string') return callback(new BoxError(BoxError.BAD_FIELD, 'token must be a non-empty string', { field: 'token' }));
const ip = '127.0.0.1';
var credentials = {
token: dnsConfig.token,
hyphenatedSubdomains: true // this will ensure we always use them, regardless of passed-in configs
};
const location = 'cloudrontestdns';
upsert(domainObject, location, 'A', [ ip ], function (error) {
if (error) return callback(error);
debug('verifyDnsConfig: Test A record added');
del(domainObject, location, 'A', [ ip ], function (error) {
if (error) return callback(error);
debug('verifyDnsConfig: Test A record removed again');
callback(null, credentials);
});
});
}

View File

@@ -281,13 +281,14 @@ function verifyDnsConfig(domainObject, callback) {
}
const location = 'cloudrontestdns';
const newDomainObject = Object.assign({ }, domainObject, { config: credentials });
upsert(domainObject, location, 'A', [ ip ], function (error) {
upsert(newDomainObject, location, 'A', [ ip ], function (error) {
if (error) return callback(error);
debug('verifyDnsConfig: Test A record added');
del(domainObject, location, 'A', [ ip ], function (error) {
del(newDomainObject, location, 'A', [ ip ], function (error) {
if (error) return callback(error);
debug('verifyDnsConfig: Test A record removed again');

View File

@@ -306,7 +306,8 @@ function createSubcontainer(app, name, cmd, options, callback) {
Dns: ['172.18.0.1'], // use internal dns
DnsSearch: ['.'], // use internal dns
SecurityOpt: [ 'apparmor=docker-cloudron-app' ],
CapDrop: [ 'NET_RAW' ] // https://docs-stage.docker.com/engine/reference/run/#runtime-privilege-and-linux-capabilities
CapAdd: [],
CapDrop: []
},
NetworkingConfig: {
EndpointsConfig: {
@@ -318,11 +319,11 @@ function createSubcontainer(app, name, cmd, options, callback) {
};
var capabilities = manifest.capabilities || [];
if (capabilities.includes('net_admin')) {
containerOptions.HostConfig.CapAdd = [
'NET_ADMIN', 'NET_RAW'
];
}
// https://docs-stage.docker.com/engine/reference/run/#runtime-privilege-and-linux-capabilities
if (capabilities.includes('net_admin')) containerOptions.HostConfig.CapAdd.push('NET_ADMIN', 'NET_RAW');
if (capabilities.includes('mlock')) containerOptions.HostConfig.CapAdd.push('IPC_LOCK'); // mlock prevents swapping
if (!capabilities.includes('ping')) containerOptions.HostConfig.CapDrop.push('NET_RAW'); // NET_RAW is included by default by Docker
containerOptions = _.extend(containerOptions, options);
@@ -506,7 +507,7 @@ function inspect(containerId, callback) {
var container = gConnection.getContainer(containerId);
container.inspect(function (error, result) {
if (error && error.statusCode === 404) return callback(new BoxError(BoxError.NOT_FOUND));
if (error && error.statusCode === 404) return callback(new BoxError(BoxError.NOT_FOUND, `Unable to find container ${containerId}`));
if (error) return callback(new BoxError(BoxError.DOCKER_ERROR, error));
callback(null, result);

View File

@@ -55,7 +55,7 @@ function attachDockerRequest(req, res, next) {
// Force node to send out the headers, this is required for the /container/wait api to make the docker cli proceed
res.write(' ');
dockerResponse.on('error', function (error) { console.error('dockerResponse error:', error); });
dockerResponse.on('error', function (error) { debug('dockerResponse error:', error); });
dockerResponse.pipe(res, { end: true });
});

View File

@@ -66,7 +66,7 @@ function add(name, data, callback) {
];
database.transaction(queries, function (error) {
if (error && error.code === 'ER_DUP_ENTRY') return callback(new BoxError(BoxError.ALREADY_EXISTS, error));
if (error && error.code === 'ER_DUP_ENTRY') return callback(new BoxError(BoxError.ALREADY_EXISTS, 'Domain already exists'));
if (error) return callback(new BoxError(BoxError.DATABASE_ERROR, error));
callback(null);

View File

@@ -54,7 +54,6 @@ function api(provider) {
assert.strictEqual(typeof provider, 'string');
switch (provider) {
case 'caas': return require('./dns/caas.js');
case 'cloudflare': return require('./dns/cloudflare.js');
case 'route53': return require('./dns/route53.js');
case 'gcdns': return require('./dns/gcdns.js');
@@ -149,10 +148,9 @@ function validateTlsConfig(tlsConfig, dnsProvider) {
case 'letsencrypt-prod':
case 'letsencrypt-staging':
case 'fallback':
case 'caas':
break;
default:
return new BoxError(BoxError.BAD_FIELD, 'tlsConfig.provider must be caas, fallback, letsencrypt-prod/staging', { field: 'tlsProvider' });
return new BoxError(BoxError.BAD_FIELD, 'tlsConfig.provider must be fallback, letsencrypt-prod/staging', { field: 'tlsProvider' });
}
if (tlsConfig.wildcard) {

View File

@@ -20,7 +20,9 @@ var assert = require('assert'),
BoxError = require('./boxerror.js'),
constants = require('./constants.js'),
debug = require('debug')('box:externalldap'),
groups = require('./groups.js'),
ldap = require('ldapjs'),
once = require('once'),
settings = require('./settings.js'),
tasks = require('./tasks.js'),
users = require('./users.js');
@@ -40,14 +42,14 @@ function translateUser(ldapConfig, ldapUser) {
return {
username: ldapUser[ldapConfig.usernameField],
email: ldapUser.mail,
email: ldapUser.mail || ldapUser.mailPrimaryAddress,
displayName: ldapUser.cn // user.giveName + ' ' + user.sn
};
}
function validUserRequirements(user) {
if (!user.username || !user.email || !user.displayName) {
debug(`[LDAP user empty username/email/displayName] username=${user.username} email=${user.email} displayName=${user.displayName}`);
debug(`[Invalid LDAP user] username=${user.username} email=${user.email} displayName=${user.displayName}`);
return false;
} else {
return true;
@@ -55,40 +57,95 @@ function validUserRequirements(user) {
}
// performs service bind if required
function getClient(externalLdapConfig, callback) {
function getClient(externalLdapConfig, doBindAuth, callback) {
assert.strictEqual(typeof externalLdapConfig, 'object');
assert.strictEqual(typeof doBindAuth, 'boolean');
assert.strictEqual(typeof callback, 'function');
// ensure we only callback once since we also have to listen to client.error events
callback = once(callback);
// basic validation to not crash
try { ldap.parseDN(externalLdapConfig.baseDn); } catch (e) { return callback(new BoxError(BoxError.BAD_FIELD, 'invalid baseDn')); }
try { ldap.parseFilter(externalLdapConfig.filter); } catch (e) { return callback(new BoxError(BoxError.BAD_FIELD, 'invalid filter')); }
var config = {
url: externalLdapConfig.url,
tlsOptions: {
rejectUnauthorized: externalLdapConfig.acceptSelfSignedCerts ? false : true
}
};
var client;
try {
client = ldap.createClient({ url: externalLdapConfig.url });
client = ldap.createClient(config);
} catch (e) {
if (e instanceof ldap.ProtocolError) return callback(new BoxError(BoxError.BAD_FIELD, 'url protocol is invalid'));
return callback(new BoxError(BoxError.INTERNAL_ERROR, e));
}
if (!externalLdapConfig.bindDn) return callback(null, client);
// ensure we don't just crash
client.on('error', function (error) {
callback(new BoxError(BoxError.EXTERNAL_ERROR, error));
});
// skip bind auth if none exist or if not wanted
if (!externalLdapConfig.bindDn || !doBindAuth) return callback(null, client);
client.bind(externalLdapConfig.bindDn, externalLdapConfig.bindPassword, function (error) {
if (error instanceof ldap.InvalidCredentialsError) return callback(new BoxError(BoxError.INVALID_CREDENTIALS));
if (error) return callback(new BoxError(BoxError.EXTERNAL_ERROR, error));
callback(null, client, externalLdapConfig);
callback(null, client);
});
}
function ldapGetByDN(externalLdapConfig, dn, callback) {
assert.strictEqual(typeof externalLdapConfig, 'object');
assert.strictEqual(typeof dn, 'string');
assert.strictEqual(typeof callback, 'function');
getClient(externalLdapConfig, true, function (error, client) {
if (error) return callback(error);
let searchOptions = {
paged: true,
scope: 'sub' // We may have to make this configurable
};
debug(`Get object at ${dn}`);
// basic validation to not crash
try { ldap.parseDN(dn); } catch (e) { return callback(new BoxError(BoxError.BAD_FIELD, 'invalid DN')); }
client.search(dn, searchOptions, function (error, result) {
if (error instanceof ldap.NoSuchObjectError) return callback(new BoxError(BoxError.NOT_FOUND));
if (error) return callback(new BoxError(BoxError.EXTERNAL_ERROR, error));
let ldapObjects = [];
result.on('searchEntry', entry => ldapObjects.push(entry.object));
result.on('error', error => callback(new BoxError(BoxError.EXTERNAL_ERROR, error)));
result.on('end', function (result) {
client.unbind();
if (result.status !== 0) return callback(new BoxError(BoxError.EXTERNAL_ERROR, 'Server returned status ' + result.status));
if (ldapObjects.length === 0) return callback(new BoxError(BoxError.NOT_FOUND));
callback(null, ldapObjects[0]);
});
});
});
}
// TODO support search by email
function ldapSearch(externalLdapConfig, options, callback) {
function ldapUserSearch(externalLdapConfig, options, callback) {
assert.strictEqual(typeof externalLdapConfig, 'object');
assert.strictEqual(typeof options, 'object');
assert.strictEqual(typeof callback, 'function');
getClient(externalLdapConfig, function (error, client) {
getClient(externalLdapConfig, true, function (error, client) {
if (error) return callback(error);
let searchOptions = {
@@ -124,6 +181,48 @@ function ldapSearch(externalLdapConfig, options, callback) {
});
}
function ldapGroupSearch(externalLdapConfig, options, callback) {
assert.strictEqual(typeof externalLdapConfig, 'object');
assert.strictEqual(typeof options, 'object');
assert.strictEqual(typeof callback, 'function');
getClient(externalLdapConfig, true, function (error, client) {
if (error) return callback(error);
let searchOptions = {
paged: true,
scope: 'sub' // We may have to make this configurable
};
if (externalLdapConfig.groupFilter) searchOptions.filter = ldap.parseFilter(externalLdapConfig.groupFilter);
if (options.filter) { // https://github.com/ldapjs/node-ldapjs/blob/master/docs/filters.md
let extraFilter = ldap.parseFilter(options.filter);
searchOptions.filter = new ldap.AndFilter({ filters: [ extraFilter, searchOptions.filter ] });
}
debug(`Listing groups at ${externalLdapConfig.groupBaseDn} with filter ${searchOptions.filter.toString()}`);
client.search(externalLdapConfig.groupBaseDn, searchOptions, function (error, result) {
if (error instanceof ldap.NoSuchObjectError) return callback(new BoxError(BoxError.NOT_FOUND));
if (error) return callback(new BoxError(BoxError.EXTERNAL_ERROR, error));
let ldapGroups = [];
result.on('searchEntry', entry => ldapGroups.push(entry.object));
result.on('error', error => callback(new BoxError(BoxError.EXTERNAL_ERROR, error)));
result.on('end', function (result) {
client.unbind();
if (result.status !== 0) return callback(new BoxError(BoxError.EXTERNAL_ERROR, 'Server returned status ' + result.status));
callback(null, ldapGroups);
});
});
});
}
function testConfig(config, callback) {
assert.strictEqual(typeof config, 'object');
assert.strictEqual(typeof callback, 'function');
@@ -141,7 +240,20 @@ function testConfig(config, callback) {
if (!config.filter) return callback(new BoxError(BoxError.BAD_FIELD, 'filter must not be empty'));
try { ldap.parseFilter(config.filter); } catch (e) { return callback(new BoxError(BoxError.BAD_FIELD, 'invalid filter')); }
getClient(config, function (error, client) {
if ('syncGroups' in config && typeof config.syncGroups !== 'boolean') return callback(new BoxError(BoxError.BAD_FIELD, 'syncGroups must be a boolean'));
if ('acceptSelfSignedCerts' in config && typeof config.acceptSelfSignedCerts !== 'boolean') return callback(new BoxError(BoxError.BAD_FIELD, 'acceptSelfSignedCerts must be a boolean'));
if (config.syncGroups) {
if (!config.groupBaseDn) return callback(new BoxError(BoxError.BAD_FIELD, 'groupBaseDn must not be empty'));
try { ldap.parseDN(config.groupBaseDn); } catch (e) { return callback(new BoxError(BoxError.BAD_FIELD, 'invalid groupBaseDn')); }
if (!config.groupFilter) return callback(new BoxError(BoxError.BAD_FIELD, 'groupFilter must not be empty'));
try { ldap.parseFilter(config.groupFilter); } catch (e) { return callback(new BoxError(BoxError.BAD_FIELD, 'invalid groupFilter')); }
if (!config.groupnameField || typeof config.groupnameField !== 'string') return callback(new BoxError(BoxError.BAD_FIELD, 'groupFilter must not be empty'));
}
getClient(config, true, function (error, client) {
if (error) return callback(error);
var opts = {
@@ -167,7 +279,7 @@ function search(identifier, callback) {
if (error) return callback(error);
if (externalLdapConfig.provider === 'noop') return callback(new BoxError(BoxError.BAD_STATE, 'not enabled'));
ldapSearch(externalLdapConfig, { filter: `${externalLdapConfig.usernameField}=${identifier}` }, function (error, ldapUsers) {
ldapUserSearch(externalLdapConfig, { filter: `${externalLdapConfig.usernameField}=${identifier}` }, function (error, ldapUsers) {
if (error) return callback(error);
// translate ldap properties to ours
@@ -188,7 +300,7 @@ function createAndVerifyUserIfNotExist(identifier, password, callback) {
if (externalLdapConfig.provider === 'noop') return callback(new BoxError(BoxError.BAD_STATE, 'not enabled'));
if (!externalLdapConfig.autoCreate) return callback(new BoxError(BoxError.BAD_STATE, 'auto create not enabled'));
ldapSearch(externalLdapConfig, { filter: `${externalLdapConfig.usernameField}=${identifier}` }, function (error, ldapUsers) {
ldapUserSearch(externalLdapConfig, { filter: `${externalLdapConfig.usernameField}=${identifier}` }, function (error, ldapUsers) {
if (error) return callback(error);
if (ldapUsers.length === 0) return callback(new BoxError(BoxError.NOT_FOUND));
if (ldapUsers.length > 1) return callback(new BoxError(BoxError.CONFLICT));
@@ -198,7 +310,7 @@ function createAndVerifyUserIfNotExist(identifier, password, callback) {
users.create(user.username, null /* password */, user.email, user.displayName, { source: 'ldap' }, auditSource.EXTERNAL_LDAP_AUTO_CREATE, function (error, user) {
if (error) {
console.error('Failed to auto create user', user.username, error);
debug(`createAndVerifyUserIfNotExist: Failed to auto create user ${user.username}`, error);
return callback(new BoxError(BoxError.INTERNAL_ERROR));
}
@@ -220,17 +332,20 @@ function verifyPassword(user, password, callback) {
if (error) return callback(error);
if (externalLdapConfig.provider === 'noop') return callback(new BoxError(BoxError.BAD_STATE, 'not enabled'));
ldapSearch(externalLdapConfig, { filter: `${externalLdapConfig.usernameField}=${user.username}` }, function (error, ldapUsers) {
ldapUserSearch(externalLdapConfig, { filter: `${externalLdapConfig.usernameField}=${user.username}` }, function (error, ldapUsers) {
if (error) return callback(error);
if (ldapUsers.length === 0) return callback(new BoxError(BoxError.NOT_FOUND));
if (ldapUsers.length > 1) return callback(new BoxError(BoxError.CONFLICT));
let client = ldap.createClient({ url: externalLdapConfig.url });
client.bind(ldapUsers[0].dn, password, function (error) {
if (error instanceof ldap.InvalidCredentialsError) return callback(new BoxError(BoxError.INVALID_CREDENTIALS));
if (error) return callback(new BoxError(BoxError.EXTERNAL_ERROR, error));
getClient(externalLdapConfig, false, function (error, client) {
if (error) return callback(error);
callback(null, translateUser(externalLdapConfig, ldapUsers[0]));
client.bind(ldapUsers[0].dn, password, function (error) {
if (error instanceof ldap.InvalidCredentialsError) return callback(new BoxError(BoxError.INVALID_CREDENTIALS));
if (error) return callback(new BoxError(BoxError.EXTERNAL_ERROR, error));
callback(null, translateUser(externalLdapConfig, ldapUsers[0]));
});
});
});
});
@@ -255,6 +370,197 @@ function startSyncer(callback) {
});
}
function syncUsers(externalLdapConfig, progressCallback, callback) {
assert.strictEqual(typeof externalLdapConfig, 'object');
assert.strictEqual(typeof progressCallback, 'function');
assert.strictEqual(typeof callback, 'function');
ldapUserSearch(externalLdapConfig, {}, function (error, ldapUsers) {
if (error) return callback(error);
debug(`Found ${ldapUsers.length} users`);
let percent = 10;
let step = 30/(ldapUsers.length+1); // ensure no divide by 0
// we ignore all errors here and just log them for now
async.eachSeries(ldapUsers, function (user, iteratorCallback) {
user = translateUser(externalLdapConfig, user);
if (!validUserRequirements(user)) return iteratorCallback();
percent += step;
progressCallback({ percent, message: `Syncing... ${user.username}` });
users.getByUsername(user.username, function (error, result) {
if (error && error.reason !== BoxError.NOT_FOUND) return iteratorCallback(error);
if (!result) {
debug(`[adding user] username=${user.username} email=${user.email} displayName=${user.displayName}`);
users.create(user.username, null /* password */, user.email, user.displayName, { source: 'ldap' }, auditSource.EXTERNAL_LDAP_TASK, function (error) {
if (error) debug('syncUsers: Failed to create user', user, error.message);
iteratorCallback();
});
} else if (result.source !== 'ldap') {
debug(`[conflicting user] username=${user.username} email=${user.email} displayName=${user.displayName}`);
iteratorCallback();
} else if (result.email !== user.email || result.displayName !== user.displayName) {
debug(`[updating user] username=${user.username} email=${user.email} displayName=${user.displayName}`);
users.update(result, { email: user.email, fallbackEmail: user.email, displayName: user.displayName }, auditSource.EXTERNAL_LDAP_TASK, function (error) {
if (error) debug('Failed to update user', user, error);
iteratorCallback();
});
} else {
// user known and up-to-date
debug(`[up-to-date user] username=${user.username} email=${user.email} displayName=${user.displayName}`);
iteratorCallback();
}
});
}, callback);
});
}
function syncGroups(externalLdapConfig, progressCallback, callback) {
assert.strictEqual(typeof externalLdapConfig, 'object');
assert.strictEqual(typeof progressCallback, 'function');
assert.strictEqual(typeof callback, 'function');
if (!externalLdapConfig.syncGroups) {
debug('Group sync is disabled');
progressCallback({ percent: 70, message: 'Skipping group sync...' });
return callback(null, []);
}
ldapGroupSearch(externalLdapConfig, {}, function (error, ldapGroups) {
if (error) return callback(error);
debug(`Found ${ldapGroups.length} groups`);
let percent = 40;
let step = 30/(ldapGroups.length+1); // ensure no divide by 0
// we ignore all non internal errors here and just log them for now
async.eachSeries(ldapGroups, function (ldapGroup, iteratorCallback) {
var groupName = ldapGroup[externalLdapConfig.groupnameField];
if (!groupName) return iteratorCallback();
// some servers return empty array for unknown properties :-/
if (typeof groupName !== 'string') return iteratorCallback();
// groups are lowercase
groupName = groupName.toLowerCase();
percent += step;
progressCallback({ percent, message: `Syncing... ${groupName}` });
groups.getByName(groupName, function (error, result) {
if (error && error.reason !== BoxError.NOT_FOUND) return iteratorCallback(error);
if (!result) {
debug(`[adding group] groupname=${groupName}`);
groups.create(groupName, 'ldap', function (error) {
if (error) debug('syncGroups: Failed to create group', groupName, error);
iteratorCallback();
});
} else {
debug(`[up-to-date group] groupname=${groupName}`);
iteratorCallback();
}
});
}, function (error) {
if (error) return callback(error);
debug('sync: ldap sync is done', error);
callback(error);
});
});
}
function syncGroupUsers(externalLdapConfig, progressCallback, callback) {
assert.strictEqual(typeof externalLdapConfig, 'object');
assert.strictEqual(typeof progressCallback, 'function');
assert.strictEqual(typeof callback, 'function');
if (!externalLdapConfig.syncGroups) {
debug('Group users sync is disabled');
progressCallback({ percent: 99, message: 'Skipping group users sync...' });
return callback(null, []);
}
groups.getAll(function (error, result) {
if (error) return callback(error);
var ldapGroups = result.filter(function (g) { return g.source === 'ldap'; });
debug(`Found ${ldapGroups.length} groups to sync users`);
async.eachSeries(ldapGroups, function (group, iteratorCallback) {
debug(`Sync users for group ${group.name}`);
ldapGroupSearch(externalLdapConfig, {}, function (error, result) {
if (error) return callback(error);
if (!result || result.length === 0) {
debug(`syncGroupUsers: Unable to find group ${group.name} ignoring for now.`);
return callback();
}
// since our group names are lowercase we cannot use potentially case matching ldap filters
let found = result.find(function (r) {
if (!r[externalLdapConfig.groupnameField]) return false;
return r[externalLdapConfig.groupnameField].toLowerCase() === group.name;
});
if (!found) {
debug(`syncGroupUsers: Unable to find group ${group.name} ignoring for now.`);
return callback();
}
var ldapGroupMembers = found.member || found.uniqueMember || [];
// if only one entry is in the group ldap returns a string, not an array!
if (typeof ldapGroupMembers === 'string') ldapGroupMembers = [ ldapGroupMembers ];
debug(`Group ${group.name} has ${ldapGroupMembers.length} members.`);
async.eachSeries(ldapGroupMembers, function (memberDn, iteratorCallback) {
ldapGetByDN(externalLdapConfig, memberDn, function (error, result) {
if (error) {
debug(`Failed to get ${memberDn}:`, error);
return iteratorCallback();
}
debug(`Found member object at ${memberDn} adding to group ${group.name}`);
const username = result[externalLdapConfig.usernameField];
if (!username) return iteratorCallback();
users.getByUsername(username, function (error, result) {
if (error) {
debug(`syncGroupUsers: Failed to get user by username ${username}`, error);
return iteratorCallback();
}
groups.addMember(group.id, result.id, function (error) {
if (error && error.reason !== BoxError.ALREADY_EXISTS) debug('syncGroupUsers: Failed to add member', error);
iteratorCallback();
});
});
});
}, function (error) {
if (error) debug('syncGroupUsers: ', error);
iteratorCallback();
});
});
}, callback);
});
}
function sync(progressCallback, callback) {
assert.strictEqual(typeof progressCallback, 'function');
assert.strictEqual(typeof callback, 'function');
@@ -265,58 +571,18 @@ function sync(progressCallback, callback) {
if (error) return callback(error);
if (externalLdapConfig.provider === 'noop') return callback(new BoxError(BoxError.BAD_STATE, 'not enabled'));
ldapSearch(externalLdapConfig, {}, function (error, ldapUsers) {
async.series([
syncUsers.bind(null, externalLdapConfig, progressCallback),
syncGroups.bind(null, externalLdapConfig, progressCallback),
syncGroupUsers.bind(null, externalLdapConfig, progressCallback)
], function (error) {
if (error) return callback(error);
debug(`Found ${ldapUsers.length} users`);
let percent = 10;
let step = 90/(ldapUsers.length+1); // ensure no divide by 0
progressCallback({ percent: 100, message: 'Done' });
// we ignore all errors here and just log them for now
async.eachSeries(ldapUsers, function (user, iteratorCallback) {
user = translateUser(externalLdapConfig, user);
debug('sync: ldap sync is done', error);
if (!validUserRequirements(user)) return iteratorCallback();
percent += step;
progressCallback({ percent, message: `Syncing... ${user.username}` });
users.getByUsername(user.username, function (error, result) {
if (error && error.reason !== BoxError.NOT_FOUND) {
debug(`Could not find user with username ${user.username}: ${error.message}`);
return iteratorCallback();
}
if (error) {
debug(`[adding user] username=${user.username} email=${user.email} displayName=${user.displayName}`);
users.create(user.username, null /* password */, user.email, user.displayName, { source: 'ldap' }, auditSource.EXTERNAL_LDAP_TASK, function (error) {
if (error) console.error('Failed to create user', user, error);
iteratorCallback();
});
} else if (result.source !== 'ldap') {
debug(`[conflicting user] username=${user.username} email=${user.email} displayName=${user.displayName}`);
iteratorCallback();
} else if (result.email !== user.email || result.displayName !== user.displayName) {
debug(`[updating user] username=${user.username} email=${user.email} displayName=${user.displayName}`);
users.update(result, { email: user.email, fallbackEmail: user.email, displayName: user.displayName }, auditSource.EXTERNAL_LDAP_TASK, function (error) {
if (error) debug('Failed to update user', user, error);
iteratorCallback();
});
} else {
// user known and up-to-date
debug(`[up-to-date user] username=${user.username} email=${user.email} displayName=${user.displayName}`);
iteratorCallback();
}
});
}, function (error) {
debug('sync: ldap sync is done', error);
callback(error);
});
callback(error);
});
});
}

View File

@@ -5,6 +5,7 @@ exports = module.exports = {
};
var assert = require('assert'),
async = require('async'),
infra = require('./infra_version.js'),
paths = require('./paths.js'),
shell = require('./shell.js');
@@ -26,7 +27,7 @@ function startGraphite(existingInfra, callback) {
--log-opt syslog-address=udp://127.0.0.1:2514 \
--log-opt syslog-format=rfc5424 \
--log-opt tag=graphite \
-m 75m \
-m 150m \
--memory-swap 150m \
--dns 172.18.0.1 \
--dns-search=. \
@@ -37,5 +38,9 @@ function startGraphite(existingInfra, callback) {
--label isCloudronManaged=true \
--read-only -v /tmp -v /run "${tag}"`;
shell.exec('startGraphite', cmd, callback);
async.series([
shell.exec.bind(null, 'stopGraphite', 'docker stop graphite || true'),
shell.exec.bind(null, 'removeGraphite', 'docker rm -f graphite || true'),
shell.exec.bind(null, 'startGraphite', cmd)
], callback);
}

View File

@@ -2,6 +2,7 @@
exports = module.exports = {
get: get,
getByName: getByName,
getWithMembers: getWithMembers,
getAll: getAll,
getAllWithMembers: getAllWithMembers,
@@ -19,8 +20,6 @@ exports = module.exports = {
getMembership: getMembership,
setMembership: setMembership,
getGroups: getGroups,
_clear: clear
};
@@ -28,7 +27,7 @@ var assert = require('assert'),
BoxError = require('./boxerror.js'),
database = require('./database.js');
var GROUPS_FIELDS = [ 'id', 'name' ].join(',');
var GROUPS_FIELDS = [ 'id', 'name', 'source' ].join(',');
function get(groupId, callback) {
assert.strictEqual(typeof groupId, 'string');
@@ -42,6 +41,18 @@ function get(groupId, callback) {
});
}
function getByName(name, callback) {
assert.strictEqual(typeof name, 'string');
assert.strictEqual(typeof callback, 'function');
database.query('SELECT ' + GROUPS_FIELDS + ' FROM userGroups WHERE name = ?', [ name ], function (error, result) {
if (error) return callback(new BoxError(BoxError.DATABASE_ERROR, error));
if (result.length === 0) return callback(new BoxError(BoxError.NOT_FOUND, 'Group not found'));
callback(null, result[0]);
});
}
function getWithMembers(groupId, callback) {
assert.strictEqual(typeof groupId, 'string');
assert.strictEqual(typeof callback, 'function');
@@ -63,7 +74,7 @@ function getWithMembers(groupId, callback) {
function getAll(callback) {
assert.strictEqual(typeof callback, 'function');
database.query('SELECT ' + GROUPS_FIELDS + ' FROM userGroups', function (error, results) {
database.query('SELECT ' + GROUPS_FIELDS + ' FROM userGroups ORDER BY name', function (error, results) {
if (error) return callback(new BoxError(BoxError.DATABASE_ERROR, error));
callback(null, results);
@@ -73,9 +84,8 @@ function getAll(callback) {
function getAllWithMembers(callback) {
database.query('SELECT ' + GROUPS_FIELDS + ',GROUP_CONCAT(groupMembers.userId) AS userIds ' +
' FROM userGroups LEFT OUTER JOIN groupMembers ON userGroups.id = groupMembers.groupId ' +
' GROUP BY userGroups.id', function (error, results) {
' GROUP BY userGroups.id ORDER BY name', function (error, results) {
if (error) return callback(new BoxError(BoxError.DATABASE_ERROR, error));
if (results.length === 0) return callback(new BoxError(BoxError.NOT_FOUND, 'Group not found'));
results.forEach(function (result) { result.userIds = result.userIds ? result.userIds.split(',') : [ ]; });
@@ -83,12 +93,13 @@ function getAllWithMembers(callback) {
});
}
function add(id, name, callback) {
function add(id, name, source, callback) {
assert.strictEqual(typeof id, 'string');
assert.strictEqual(typeof name, 'string');
assert.strictEqual(typeof source, 'string');
assert.strictEqual(typeof callback, 'function');
database.query('INSERT INTO userGroups (id, name) VALUES (?, ?)', [ id, name ], function (error, result) {
database.query('INSERT INTO userGroups (id, name, source) VALUES (?, ?, ?)', [ id, name, source ], function (error, result) {
if (error && error.code === 'ER_DUP_ENTRY') return callback(new BoxError(BoxError.ALREADY_EXISTS, error));
if (error || result.affectedRows !== 1) return callback(new BoxError(BoxError.DATABASE_ERROR, error));
@@ -260,15 +271,3 @@ function isMember(groupId, userId, callback) {
callback(null, result.length !== 0);
});
}
function getGroups(userId, callback) {
assert.strictEqual(typeof userId, 'string');
assert.strictEqual(typeof callback, 'function');
database.query('SELECT ' + GROUPS_FIELDS + ' ' +
' FROM userGroups INNER JOIN groupMembers ON userGroups.id = groupMembers.groupId AND groupMembers.userId = ?', [ userId ], function (error, results) {
if (error) return callback(new BoxError(BoxError.DATABASE_ERROR, error));
callback(null, results);
});
}

View File

@@ -4,6 +4,7 @@ exports = module.exports = {
create: create,
remove: remove,
get: get,
getByName: getByName,
update: update,
getWithMembers: getWithMembers,
getAll: getAll,
@@ -15,8 +16,6 @@ exports = module.exports = {
removeMember: removeMember,
isMember: isMember,
getGroups: getGroups,
setMembership: setMembership,
getMembership: getMembership,
@@ -45,8 +44,17 @@ function validateGroupname(name) {
return null;
}
function create(name, callback) {
function validateGroupSource(source) {
assert.strictEqual(typeof source, 'string');
if (source !== '' && source !== 'ldap') return new BoxError(BoxError.BAD_FIELD, 'source must be "" or "ldap"', { field: source });
return null;
}
function create(name, source, callback) {
assert.strictEqual(typeof name, 'string');
assert.strictEqual(typeof source, 'string');
assert.strictEqual(typeof callback, 'function');
// we store names in lowercase
@@ -55,8 +63,11 @@ function create(name, callback) {
var error = validateGroupname(name);
if (error) return callback(error);
error = validateGroupSource(source);
if (error) return callback(error);
var id = 'gid-' + uuid.v4();
groupdb.add(id, name, function (error) {
groupdb.add(id, name, source, function (error) {
if (error) return callback(error);
callback(null, { id: id, name: name });
@@ -85,6 +96,17 @@ function get(id, callback) {
});
}
function getByName(name, callback) {
assert.strictEqual(typeof name, 'string');
assert.strictEqual(typeof callback, 'function');
groupdb.getByName(name, function (error, result) {
if (error) return callback(error);
return callback(null, result);
});
}
function getWithMembers(id, callback) {
assert.strictEqual(typeof id, 'string');
assert.strictEqual(typeof callback, 'function');
@@ -217,17 +239,6 @@ function update(groupId, data, callback) {
});
}
function getGroups(userId, callback) {
assert.strictEqual(typeof userId, 'string');
assert.strictEqual(typeof callback, 'function');
groupdb.getGroups(userId, function (error, results) {
if (error) return callback(error);
callback(null, results);
});
}
function count(callback) {
assert.strictEqual(typeof callback, 'function');

View File

@@ -16,12 +16,12 @@ exports = module.exports = {
// docker inspect --format='{{index .RepoDigests 0}}' $IMAGE to get the sha256
'images': {
'turn': { repo: 'cloudron/turn', tag: 'cloudron/turn:1.1.0@sha256:e1dd22aa6eef5beb7339834b200a8bb787ffc2264ce11139857a054108fefb4f' },
'mysql': { repo: 'cloudron/mysql', tag: 'cloudron/mysql:2.3.0@sha256:0cc39004b80eb5ee570b9fd4f38859410a49692e93e0f0d8b104b0b524d7030a' },
'postgresql': { repo: 'cloudron/postgresql', tag: 'cloudron/postgresql:2.1.1@sha256:6ea1bcf9b655d628230acda01d46cf21883b112111d89583c94138646895c14c' },
'mongodb': { repo: 'cloudron/mongodb', tag: 'cloudron/mongodb:2.2.0@sha256:205486ff0f6bf6854610572df401cf3651bc62baf28fd26e9c5632497f10c2cb' },
'redis': { repo: 'cloudron/redis', tag: 'cloudron/redis:2.2.0@sha256:cfdcc1a54cf29818cac99eacedc2ecf04e44995be3d06beea11dcaa09d90ed8d' },
'mail': { repo: 'cloudron/mail', tag: 'cloudron/mail:2.9.3@sha256:2c062e8883cadae4b4c22553f9db141fc441d6c627aa85e2a5ad71e3fcbf2b42' },
'mysql': { repo: 'cloudron/mysql', tag: 'cloudron/mysql:2.3.1@sha256:c1145d43c8a912fe6f5a5629a4052454a4aa6f23391c1efbffeec9d12d72a256' },
'postgresql': { repo: 'cloudron/postgresql', tag: 'cloudron/postgresql:3.0.0@sha256:b00e5118a8f829c422234117bf113803be79a1d5102c51497c6d3005b041ce37' },
'mongodb': { repo: 'cloudron/mongodb', tag: 'cloudron/mongodb:3.0.0@sha256:59e50b1f55e433ffdf6d678f8c658812b4119f631db8325572a52ee40d3bc562' },
'redis': { repo: 'cloudron/redis', tag: 'cloudron/redis:2.3.0@sha256:0e31ec817e235b1814c04af97b1e7cf0053384aca2569570ce92bef0d95e94d2' },
'mail': { repo: 'cloudron/mail', tag: 'cloudron/mail:2.9.4@sha256:0e169b97a0584a76197d2bbc039d8698bf93f815588b3b43c251bd83dd545465' },
'graphite': { repo: 'cloudron/graphite', tag: 'cloudron/graphite:2.3.0@sha256:b7bc1ca4f4d0603a01369a689129aa273a938ce195fe43d00d42f4f2d5212f50' },
'sftp': { repo: 'cloudron/sftp', tag: 'cloudron/sftp:1.1.0@sha256:0c1fe4dd6121900624dcb383251ecb0084c3810e095064933de671409d8d6d7b' }
'sftp': { repo: 'cloudron/sftp', tag: 'cloudron/sftp:2.0.2@sha256:cbd604eaa970c99ba5c4c2e7984929668e05de824172f880e8c576b2fb7c976d' }
}
};

View File

@@ -16,21 +16,15 @@ const NOOP_CALLBACK = function () { };
const gConnection = new Docker({ socketPath: '/var/run/docker.sock' });
function ignoreError(func) {
return function (callback) {
func(function (error) {
if (error) console.error('Ignored error:', error);
function cleanupTokens(callback) {
assert(!callback || typeof callback === 'function'); // callback is null when called from cronjob
callback();
});
};
}
callback = callback || NOOP_CALLBACK;
function cleanupExpiredTokens(callback) {
assert.strictEqual(typeof callback, 'function');
debug('Cleaning up expired tokens');
tokendb.delExpired(function (error, result) {
if (error) return callback(error);
if (error) return debug('cleanupTokens: error removing expired tokens', error);
debug('Cleaned up %s expired tokens.', result);
@@ -38,16 +32,6 @@ function cleanupExpiredTokens(callback) {
});
}
function cleanupTokens(callback) {
assert(!callback || typeof callback === 'function'); // callback is null when called from cronjob
debug('Cleaning up expired tokens');
async.series([
ignoreError(cleanupExpiredTokens)
], callback);
}
function cleanupTmpVolume(containerInfo, callback) {
assert.strictEqual(typeof containerInfo, 'object');
assert.strictEqual(typeof callback, 'function');

View File

@@ -346,7 +346,7 @@ function mailboxSearch(req, res, next) {
if (error) return callback(error);
aliases.forEach(function (a, idx) {
obj.attributes['mail' + idx] = `${a}@${mailbox.domain}`;
obj.attributes['mail' + idx] = `${a.name}@${a.domain}`;
});
// ensure all filter values are also lowercase
@@ -577,7 +577,7 @@ function userSearchSftp(req, res, next) {
var obj = {
dn: ldap.parseDN(`cn=${username}@${appFqdn},ou=sftp,dc=cloudron`).toString(),
attributes: {
homeDirectory: path.join('/app/data', app.id, 'data'),
homeDirectory: path.join('/app/data', app.id),
objectclass: ['user'],
objectcategory: 'person',
cn: user.id,
@@ -645,14 +645,14 @@ function start(callback) {
debug: NOOP,
info: debug,
warn: debug,
error: console.error,
fatal: console.error
error: debug,
fatal: debug
};
gServer = ldap.createServer({ log: logger });
gServer.on('error', function (error) {
console.error('LDAP:', error);
debug('start: server error ', error);
});
gServer.search('ou=users,dc=cloudron', authenticateApp, userSearch);

View File

@@ -1,53 +1,54 @@
'use strict';
exports = module.exports = {
getStatus: getStatus,
checkConfiguration: checkConfiguration,
getStatus,
checkConfiguration,
getDomains: getDomains,
getDomains,
getDomain: getDomain,
clearDomains: clearDomains,
getDomain,
clearDomains,
onDomainAdded: onDomainAdded,
onDomainRemoved: onDomainRemoved,
onDomainAdded,
onDomainRemoved,
onMailFqdnChanged,
removePrivateFields: removePrivateFields,
removePrivateFields,
setDnsRecords: setDnsRecords,
onMailFqdnChanged: onMailFqdnChanged,
setDnsRecords,
validateName: validateName,
validateName,
setMailFromValidation: setMailFromValidation,
setCatchAllAddress: setCatchAllAddress,
setMailRelay: setMailRelay,
setMailEnabled: setMailEnabled,
setMailFromValidation,
setCatchAllAddress,
setMailRelay,
setMailEnabled,
startMail: restartMail,
restartMail: restartMail,
handleCertChanged: handleCertChanged,
getMailAuth: getMailAuth,
restartMail,
handleCertChanged,
getMailAuth,
sendTestMail: sendTestMail,
sendTestMail,
listMailboxes: listMailboxes,
removeMailboxes: removeMailboxes,
getMailbox: getMailbox,
addMailbox: addMailbox,
updateMailboxOwner: updateMailboxOwner,
removeMailbox: removeMailbox,
getMailboxCount,
listMailboxes,
getMailbox,
addMailbox,
updateMailboxOwner,
removeMailbox,
getAliases: getAliases,
setAliases: setAliases,
getAliases,
setAliases,
getLists: getLists,
getList: getList,
addList: addList,
updateList: updateList,
removeList: removeList,
resolveList: resolveList,
getLists,
getList,
addList,
updateList,
removeList,
resolveList,
_removeMailboxes: removeMailboxes,
_readDkimPublicKeySync: readDkimPublicKeySync
};
@@ -81,6 +82,7 @@ var assert = require('assert'),
const DNS_OPTIONS = { timeout: 5000 };
var NOOP_CALLBACK = function (error) { if (error) debug(error); };
const REMOVE_MAILBOX = path.join(__dirname, 'scripts/rmmailbox.sh');
function validateName(name) {
assert.strictEqual(typeof name, 'string');
@@ -100,7 +102,6 @@ function checkOutboundPort25(callback) {
var smtpServer = _.sample([
'smtp.gmail.com',
'smtp.live.com',
'smtp.mail.yahoo.com',
'smtp.1und1.de',
]);
@@ -629,7 +630,10 @@ function configureMail(mailFqdn, mailDomain, callback) {
if (!safe.child_process.execSync(`cp ${bundle.certFilePath} ${mailCertFilePath}`)) return callback(new BoxError(BoxError.FS_ERROR, 'Could not create cert file:' + safe.error.message));
if (!safe.child_process.execSync(`cp ${bundle.keyFilePath} ${mailKeyFilePath}`)) return callback(new BoxError(BoxError.FS_ERROR, 'Could not create key file:' + safe.error.message));
shell.exec('startMail', 'docker rm -f mail || true', function (error) {
async.series([
shell.exec.bind(null, 'stopMail', 'docker stop mail || true'),
shell.exec.bind(null, 'removeMail', 'docker rm -f mail || true'),
], function (error) {
if (error) return callback(error);
createMailConfig(mailFqdn, mailDomain, function (error, allowInbound) {
@@ -1032,13 +1036,25 @@ function sendTestMail(domain, to, callback) {
});
}
function listMailboxes(domain, page, perPage, callback) {
function listMailboxes(domain, search, page, perPage, callback) {
assert.strictEqual(typeof domain, 'string');
assert(typeof search === 'string' || search === null);
assert.strictEqual(typeof page, 'number');
assert.strictEqual(typeof perPage, 'number');
assert.strictEqual(typeof callback, 'function');
mailboxdb.listMailboxes(domain, page, perPage, function (error, result) {
mailboxdb.listMailboxes(domain, search, page, perPage, function (error, result) {
if (error) return callback(error);
callback(null, result);
});
}
function getMailboxCount(domain, callback) {
assert.strictEqual(typeof domain, 'string');
assert.strictEqual(typeof callback, 'function');
mailboxdb.getMailboxCount(domain, function (error, result) {
if (error) return callback(error);
callback(null, result);
@@ -1111,18 +1127,25 @@ function updateMailboxOwner(name, domain, userId, auditSource, callback) {
});
}
function removeMailbox(name, domain, auditSource, callback) {
function removeMailbox(name, domain, options, auditSource, callback) {
assert.strictEqual(typeof domain, 'string');
assert.strictEqual(typeof name, 'string');
assert.strictEqual(typeof options, 'object');
assert.strictEqual(typeof auditSource, 'object');
assert.strictEqual(typeof callback, 'function');
mailboxdb.del(name, domain, function (error) {
if (error) return callback(error);
const deleteMailFunc = options.deleteMails ? shell.sudo.bind(null, 'removeMailbox', [ REMOVE_MAILBOX, `${name}@${domain}` ], {}) : (next) => next();
eventlog.add(eventlog.ACTION_MAIL_MAILBOX_REMOVE, auditSource, { name, domain });
deleteMailFunc(function (error) {
if (error) return callback(new BoxError(BoxError.FS_ERROR, `Error removing mailbox: ${error.message}`));
callback(null);
mailboxdb.del(name, domain, function (error) {
if (error) return callback(error);
eventlog.add(eventlog.ACTION_MAIL_MAILBOX_REMOVE, auditSource, { name, domain });
callback();
});
});
}
@@ -1165,11 +1188,14 @@ function setAliases(name, domain, aliases, callback) {
});
}
function getLists(domain, callback) {
function getLists(domain, search, page, perPage, callback) {
assert.strictEqual(typeof domain, 'string');
assert(typeof search === 'string' || search === null);
assert.strictEqual(typeof page, 'number');
assert.strictEqual(typeof perPage, 'number');
assert.strictEqual(typeof callback, 'function');
mailboxdb.getLists(domain, function (error, result) {
mailboxdb.getLists(domain, search, page, perPage, function (error, result) {
if (error) return callback(error);
callback(null, result);
@@ -1296,7 +1322,7 @@ function resolveList(listName, listDomain, callback) {
if (entry.type === mailboxdb.TYPE_MAILBOX) { // concrete mailbox
result.push(member);
} else if (entry.type === mailboxdb.TYPE_ALIAS) { // resolve aliases
toResolve = toResolve.concat(`${entry.aliasName}@${entry.aliasTarget}`);
toResolve = toResolve.concat(`${entry.aliasName}@${entry.aliasDomain}`);
} else { // resolve list members
toResolve = toResolve.concat(entry.members);
}

View File

@@ -6,7 +6,7 @@ Dear <%= cloudronName %> Admin,
If this message appears repeatedly, give the app more memory.
* To increase an app's memory limit - https://cloudron.io/documentation/apps/#increasing-the-memory-limit-of-an-app
* To increase an app's memory limit - https://cloudron.io/documentation/apps/#memory-limit
* To increase a service's memory limit - https://cloudron.io/documentation/troubleshooting/#services
Out of memory event:

View File

@@ -8,7 +8,7 @@ be reset. If you did not request this reset, please ignore this message.
To reset your password, please visit the following page:
<%- resetLink %>
Please note that the password reset link will expire in 24 hours.
Powered by https://cloudron.io
@@ -29,6 +29,10 @@ Powered by https://cloudron.io
<a href="<%= resetLink %>">Click to reset your password</a>
</p>
<br/>
Please note that the password reset link will expire in 24 hours.
<br/>
<br/>

View File

@@ -11,6 +11,7 @@ Follow the link to get started.
You are receiving this email because you were invited by <%= invitor.email %>.
<% } %>
Please note that the invite link will expire in 7 days.
Powered by https://cloudron.io
@@ -36,6 +37,9 @@ Powered by https://cloudron.io
You are receiving this email because you were invited by <%= invitor.email %>.
<% } %>
<br/>
Please note that the invite link will expire in 7 days.
<br/>
Powered by <a href="https://cloudron.io">Cloudron</a>

View File

@@ -1,31 +1,32 @@
'use strict';
exports = module.exports = {
addMailbox: addMailbox,
addList: addList,
addMailbox,
addList,
updateMailboxOwner: updateMailboxOwner,
updateList: updateList,
del: del,
updateMailboxOwner,
updateList,
del,
listMailboxes: listMailboxes,
getLists: getLists,
getMailboxCount,
listMailboxes,
getLists,
listAllMailboxes: listAllMailboxes,
listAllMailboxes,
get: get,
getMailbox: getMailbox,
getList: getList,
getAlias: getAlias,
get,
getMailbox,
getList,
getAlias,
getAliasesForName: getAliasesForName,
setAliasesForName: setAliasesForName,
getAliasesForName,
setAliasesForName,
getByOwnerId: getByOwnerId,
delByOwnerId: delByOwnerId,
delByDomain: delByDomain,
getByOwnerId,
delByOwnerId,
delByDomain,
updateName: updateName,
updateName,
_clear: clear,
@@ -37,6 +38,7 @@ exports = module.exports = {
var assert = require('assert'),
BoxError = require('./boxerror.js'),
database = require('./database.js'),
mysql = require('mysql'),
safe = require('safetydance'),
util = require('util');
@@ -203,20 +205,35 @@ function getMailbox(name, domain, callback) {
});
}
function listMailboxes(domain, page, perPage, callback) {
function getMailboxCount(domain, callback) {
assert.strictEqual(typeof domain, 'string');
assert.strictEqual(typeof callback, 'function');
database.query('SELECT COUNT(*) AS total FROM mailboxes WHERE type = ? AND domain = ?', [ exports.TYPE_MAILBOX, domain ], function (error, results) {
if (error) return callback(new BoxError(BoxError.DATABASE_ERROR, error));
callback(null, results[0].total);
});
}
function listMailboxes(domain, search, page, perPage, callback) {
assert.strictEqual(typeof domain, 'string');
assert(typeof search === 'string' || search === null);
assert.strictEqual(typeof page, 'number');
assert.strictEqual(typeof perPage, 'number');
assert.strictEqual(typeof callback, 'function');
database.query(`SELECT ${MAILBOX_FIELDS} FROM mailboxes WHERE type = ? AND domain = ? ORDER BY name LIMIT ${(page-1)*perPage},${perPage}`,
[ exports.TYPE_MAILBOX, domain ], function (error, results) {
if (error) return callback(new BoxError(BoxError.DATABASE_ERROR, error));
let query = `SELECT ${MAILBOX_FIELDS} FROM mailboxes WHERE type = ? AND domain = ?`;
if (search) query += ' AND (name LIKE ' + mysql.escape('%' + search + '%') + ')';
query += 'ORDER BY name LIMIT ?,?';
results.forEach(function (result) { postProcess(result); });
database.query(query, [ exports.TYPE_MAILBOX, domain, (page-1)*perPage, perPage ], function (error, results) {
if (error) return callback(new BoxError(BoxError.DATABASE_ERROR, error));
callback(null, results);
});
results.forEach(function (result) { postProcess(result); });
callback(null, results);
});
}
function listAllMailboxes(page, perPage, callback) {
@@ -224,8 +241,8 @@ function listAllMailboxes(page, perPage, callback) {
assert.strictEqual(typeof perPage, 'number');
assert.strictEqual(typeof callback, 'function');
database.query(`SELECT ${MAILBOX_FIELDS} FROM mailboxes WHERE type = ? ORDER BY name LIMIT ${(page-1)*perPage},${perPage}`,
[ exports.TYPE_MAILBOX ], function (error, results) {
database.query(`SELECT ${MAILBOX_FIELDS} FROM mailboxes WHERE type = ? ORDER BY name LIMIT ?,?`,
[ exports.TYPE_MAILBOX, (page-1)*perPage, perPage ], function (error, results) {
if (error) return callback(new BoxError(BoxError.DATABASE_ERROR, error));
results.forEach(function (result) { postProcess(result); });
@@ -234,18 +251,25 @@ function listAllMailboxes(page, perPage, callback) {
});
}
function getLists(domain, callback) {
function getLists(domain, search, page, perPage, callback) {
assert.strictEqual(typeof domain, 'string');
assert(typeof search === 'string' || search === null);
assert.strictEqual(typeof page, 'number');
assert.strictEqual(typeof perPage, 'number');
assert.strictEqual(typeof callback, 'function');
database.query('SELECT ' + MAILBOX_FIELDS + ' FROM mailboxes WHERE type = ? AND domain = ?',
[ exports.TYPE_LIST, domain ], function (error, results) {
if (error) return callback(new BoxError(BoxError.DATABASE_ERROR, error));
let query = `SELECT ${MAILBOX_FIELDS} FROM mailboxes WHERE type = ? AND domain = ?`;
if (search) query += ' AND (name LIKE ' + mysql.escape('%' + search + '%') + ' OR membersJson LIKE ' + mysql.escape('%' + search + '%') + ')';
results.forEach(function (result) { postProcess(result); });
query += 'ORDER BY name LIMIT ?,?';
callback(null, results);
});
database.query(query, [ exports.TYPE_LIST, domain, (page-1)*perPage, perPage ], function (error, results) {
if (error) return callback(new BoxError(BoxError.DATABASE_ERROR, error));
results.forEach(function (result) { postProcess(result); });
callback(null, results);
});
}
function getList(name, domain, callback) {

View File

@@ -7,6 +7,7 @@ map $http_upgrade $connection_upgrade {
# http server
server {
listen 80;
server_tokens off; # hide version
<% if (hasIPv6) { -%>
listen [::]:80;
<% } -%>
@@ -42,18 +43,19 @@ server {
server {
<% if (vhost) { -%>
server_name <%= vhost %>;
listen 443 http2;
listen 443 ssl http2;
<% if (hasIPv6) { -%>
listen [::]:443 http2;
listen [::]:443 ssl http2;
<% } -%>
<% } else { -%>
listen 443 http2 default_server;
listen 443 ssl http2 default_server;
<% if (hasIPv6) { -%>
listen [::]:443 http2 default_server;
listen [::]:443 ssl http2 default_server;
<% } -%>
<% } -%>
ssl on;
server_tokens off; # hide version
# paths are relative to prefix and not to this file
ssl_certificate <%= certFilePath %>;
ssl_certificate_key <%= keyFilePath %>;
@@ -193,6 +195,11 @@ server {
client_max_body_size 0;
}
location ~ ^/api/v1/apps/.*/files/ {
proxy_pass http://127.0.0.1:3000;
client_max_body_size 0;
}
# graphite paths (uncomment block below and visit /graphite-web/dashboard)
# remember to comment out the CSP policy as well to access the graphite dashboard
# location ~ ^/graphite-web/ {

View File

@@ -156,7 +156,7 @@ function oomEvent(eventId, app, addon, containerId, event, callback) {
if (app) {
program = `App ${app.fqdn}`;
title = `The application ${app.fqdn} (${app.manifest.title}) ran out of memory.`;
message = 'The application has been restarted automatically. If you see this notification often, consider increasing the [memory limit](https://cloudron.io/documentation/apps/#increasing-the-memory-limit-of-an-app)';
message = 'The application has been restarted automatically. If you see this notification often, consider increasing the [memory limit](https://cloudron.io/documentation/apps/#memory-limit)';
} else if (addon) {
program = `${addon.name} service`;
title = `The ${addon.name} service ran out of memory`;
@@ -211,7 +211,7 @@ function appUpdated(eventId, app, callback) {
if (error) return callback(error);
mailer.appUpdated(admin.email, app, function (error) {
if (error) console.error('Failed to send app updated email', error); // non fatal
if (error) debug('appUpdated: Failed to send app updated email', error); // non fatal
done();
});
});
@@ -273,7 +273,7 @@ function alert(id, title, message, callback) {
assert.strictEqual(typeof message, 'string');
assert.strictEqual(typeof callback, 'function');
debug(`alert: id=${id} title=${title} message=${message}`);
debug(`alert: id=${id} title=${title}`);
const acknowledged = !message;
@@ -301,7 +301,7 @@ function alert(id, title, message, callback) {
});
});
}, function (error) {
if (error) console.error(error);
if (error) debug('alert: error notifying', error);
callback();
});

View File

@@ -17,7 +17,6 @@ exports = module.exports = {
CLOUDRON_DEFAULT_AVATAR_FILE: path.join(__dirname + '/../assets/avatar.png'),
INFRA_VERSION_FILE: path.join(baseDir(), 'platformdata/INFRA_VERSION'),
LICENSE_FILE: '/etc/cloudron/LICENSE',
PROVIDER_FILE: '/etc/cloudron/PROVIDER',
PLATFORM_DATA_DIR: path.join(baseDir(), 'platformdata'),
@@ -51,6 +50,7 @@ exports = module.exports = {
LOG_DIR: path.join(baseDir(), 'platformdata/logs'),
TASKS_LOG_DIR: path.join(baseDir(), 'platformdata/logs/tasks'),
CRASH_LOG_DIR: path.join(baseDir(), 'platformdata/logs/crash'),
BOX_LOG_FILE: path.join(baseDir(), 'platformdata/logs/box.log'),
GHOST_USER_FILE: path.join(baseDir(), 'platformdata/cloudron_ghost.json'),

View File

@@ -2,7 +2,7 @@
exports = module.exports = {
start: start,
stop: stop,
stopAllTasks: stopAllTasks,
// exported for testing
_isReady: false
@@ -56,9 +56,8 @@ function start(callback) {
if (error) return callback(error);
async.series([
stopContainers.bind(null, existingInfra),
// mark app state before we start addons. this gives the db import logic a chance to mark an app as errored
startApps.bind(null, existingInfra),
(next) => { if (existingInfra.version !== infra.version) removeAllContainers(existingInfra, next); else next(); },
markApps.bind(null, existingInfra), // mark app state before we start addons. this gives the db import logic a chance to mark an app as errored
graphs.startGraphite.bind(null, existingInfra),
sftp.startSftp.bind(null, existingInfra),
addons.startServices.bind(null, existingInfra),
@@ -74,7 +73,7 @@ function start(callback) {
});
}
function stop(callback) {
function stopAllTasks(callback) {
tasks.stopAllTasks(callback);
}
@@ -130,37 +129,21 @@ function pruneInfraImages(callback) {
}, callback);
}
function stopContainers(existingInfra, callback) {
// always stop addons to restart them on any infra change, regardless of minor or major update
if (existingInfra.version !== infra.version) {
debug('stopping all containers for infra upgrade');
async.series([
shell.exec.bind(null, 'stopContainers', 'docker ps -qa --filter \'label=isCloudronManaged\' | xargs --no-run-if-empty docker stop'),
shell.exec.bind(null, 'stopContainers', 'docker ps -qa --filter \'label=isCloudronManaged\' | xargs --no-run-if-empty docker rm -f')
], callback);
} else {
assert(typeof infra.images, 'object');
var changedAddons = [ ];
for (var imageName in existingInfra.images) { // do not use infra.images because we can only stop things which are existing
if (infra.images[imageName].tag !== existingInfra.images[imageName].tag) changedAddons.push(imageName);
}
function removeAllContainers(existingInfra, callback) {
debug('removeAllContainers: removing all containers for infra upgrade');
debug('stopContainer: stopping addons for incremental infra update: %j', changedAddons);
let filterArg = changedAddons.map(function (c) { return `--filter 'name=${c}'`; }).join(' '); // name=c matches *c*. required for redis-{appid}
// ignore error if container not found (and fail later) so that this code works across restarts
async.series([
shell.exec.bind(null, 'stopContainers', `docker ps -qa ${filterArg} --filter 'label=isCloudronManaged' | xargs --no-run-if-empty docker stop || true`),
shell.exec.bind(null, 'stopContainers', `docker ps -qa ${filterArg} --filter 'label=isCloudronManaged' | xargs --no-run-if-empty docker rm -f || true`)
], callback);
}
async.series([
shell.exec.bind(null, 'removeAllContainers', 'docker ps -qa --filter \'label=isCloudronManaged\' | xargs --no-run-if-empty docker stop'),
shell.exec.bind(null, 'removeAllContainers', 'docker ps -qa --filter \'label=isCloudronManaged\' | xargs --no-run-if-empty docker rm -f')
], callback);
}
function startApps(existingInfra, callback) {
function markApps(existingInfra, callback) {
if (existingInfra.version === 'none') { // cloudron is being restored from backup
debug('startApps: restoring installed apps');
debug('markApps: restoring installed apps');
apps.restoreInstalledApps(callback);
} else if (existingInfra.version !== infra.version) {
debug('startApps: reconfiguring installed apps');
debug('markApps: reconfiguring installed apps');
reverseProxy.removeAppConfigs(); // should we change the cert location, nginx will not start
apps.configureInstalledApps(callback);
} else {
@@ -172,10 +155,10 @@ function startApps(existingInfra, callback) {
if (changedAddons.length) {
// restart apps if docker image changes since the IP changes and any "persistent" connections fail
debug(`startApps: changedAddons: ${JSON.stringify(changedAddons)}`);
debug(`markApps: changedAddons: ${JSON.stringify(changedAddons)}`);
apps.restartAppsUsingAddons(changedAddons, callback);
} else {
debug('startApps: apps are already uptodate');
debug('markApps: apps are already uptodate');
callback();
}
}

View File

@@ -4,13 +4,10 @@ exports = module.exports = {
setup: setup,
restore: restore,
activate: activate,
getStatus: getStatus,
autoRegister: autoRegister
getStatus: getStatus
};
var appstore = require('./appstore.js'),
assert = require('assert'),
var assert = require('assert'),
async = require('async'),
backups = require('./backups.js'),
BoxError = require('./boxerror.js'),
@@ -19,10 +16,7 @@ var appstore = require('./appstore.js'),
debug = require('debug')('box:provision'),
domains = require('./domains.js'),
eventlog = require('./eventlog.js'),
fs = require('fs'),
mail = require('./mail.js'),
paths = require('./paths.js'),
safe = require('safetydance'),
semver = require('semver'),
settings = require('./settings.js'),
sysinfo = require('./sysinfo.js'),
@@ -53,27 +47,6 @@ function setProgress(task, message, callback) {
callback();
}
function autoRegister(domain, callback) {
assert.strictEqual(typeof domain, 'string');
assert.strictEqual(typeof callback, 'function');
if (!fs.existsSync(paths.LICENSE_FILE)) return callback();
const license = safe.fs.readFileSync(paths.LICENSE_FILE, 'utf8');
if (!license) return callback(new BoxError(BoxError.LICENSE_ERROR, 'Cannot read license'));
debug('Auto-registering cloudron');
appstore.registerWithLicense(license.trim(), domain, function (error) {
if (error && error.reason !== BoxError.CONFLICT) { // not already registered
debug('Failed to auto-register cloudron', error);
return callback(new BoxError(BoxError.LICENSE_ERROR, 'Failed to auto-register Cloudron with license. Please contact support@cloudron.io'));
}
callback();
});
}
function unprovision(callback) {
assert.strictEqual(typeof callback, 'function');
@@ -134,7 +107,6 @@ function setup(dnsConfig, sysinfoConfig, auditSource, callback) {
callback(); // now that args are validated run the task in the background
async.series([
autoRegister.bind(null, domain),
settings.setSysinfoConfig.bind(null, sysinfoConfig),
domains.prepareDashboardDomain.bind(null, domain, auditSource, (progress) => setProgress('setup', progress.message, NOOP_CALLBACK)),
cloudron.setDashboardDomain.bind(null, domain, auditSource),
@@ -254,11 +226,11 @@ function getStatus(callback) {
version: constants.VERSION,
apiServerOrigin: settings.apiServerOrigin(), // used by CaaS tool
webServerOrigin: settings.webServerOrigin(), // used by CaaS tool
provider: settings.provider(),
cloudronName: allSettings[settings.CLOUDRON_NAME_KEY],
footer: allSettings[settings.FOOTER_KEY] || constants.FOOTER,
adminFqdn: settings.adminDomain() ? settings.adminFqdn() : null,
activated: activated,
provider: settings.provider() // used by setup wizard of marketplace images
}, gProvisionStatus));
});
});

Binary file not shown.

View File

@@ -27,7 +27,7 @@ exports = module.exports = {
removeAppConfigs: removeAppConfigs,
// exported for testing
_getCertApi: getCertApi
_getAcmeApi: getAcmeApi
};
var acme2 = require('./cert/acme2.js'),
@@ -35,14 +35,12 @@ var acme2 = require('./cert/acme2.js'),
assert = require('assert'),
async = require('async'),
BoxError = require('./boxerror.js'),
caas = require('./cert/caas.js'),
constants = require('./constants.js'),
crypto = require('crypto'),
debug = require('debug')('box:reverseproxy'),
domains = require('./domains.js'),
ejs = require('ejs'),
eventlog = require('./eventlog.js'),
fallback = require('./cert/fallback.js'),
fs = require('fs'),
mail = require('./mail.js'),
os = require('os'),
@@ -59,20 +57,16 @@ var acme2 = require('./cert/acme2.js'),
var NGINX_APPCONFIG_EJS = fs.readFileSync(__dirname + '/nginxconfig.ejs', { encoding: 'utf8' }),
RELOAD_NGINX_CMD = path.join(__dirname, 'scripts/reloadnginx.sh');
function getCertApi(domainObject, callback) {
function getAcmeApi(domainObject, callback) {
assert.strictEqual(typeof domainObject, 'object');
assert.strictEqual(typeof callback, 'function');
if (domainObject.tlsConfig.provider === 'fallback') return callback(null, fallback, { fallback: true });
const api = acme2;
var api = domainObject.tlsConfig.provider === 'caas' ? caas : acme2;
var options = { prod: false, performHttpAuthorization: false, wildcard: false, email: '' };
if (domainObject.tlsConfig.provider !== 'caas') {
options.prod = domainObject.tlsConfig.provider.match(/.*-prod/) !== null; // matches 'le-prod' or 'letsencrypt-prod'
options.performHttpAuthorization = domainObject.provider.match(/noop|manual|wildcard/) !== null;
options.wildcard = !!domainObject.tlsConfig.wildcard;
}
let options = { prod: false, performHttpAuthorization: false, wildcard: false, email: '' };
options.prod = domainObject.tlsConfig.provider.match(/.*-prod/) !== null; // matches 'le-prod' or 'letsencrypt-prod'
options.performHttpAuthorization = domainObject.provider.match(/noop|manual|wildcard/) !== null;
options.wildcard = !!domainObject.tlsConfig.wildcard;
// registering user with an email requires A or MX record (https://github.com/letsencrypt/boulder/issues/1197)
// we cannot use admin@fqdn because the user might not have set it up.
@@ -108,8 +102,6 @@ function providerMatchesSync(domainObject, certFilePath, apiOptions) {
if (!fs.existsSync(certFilePath)) return false; // not found
if (apiOptions.fallback) return certFilePath.includes('.host.cert');
const subjectAndIssuer = safe.child_process.execSync(`/usr/bin/openssl x509 -noout -subject -issuer -in "${certFilePath}"`, { encoding: 'utf8' });
if (!subjectAndIssuer) return false; // something bad happenned
@@ -215,15 +207,9 @@ function setFallbackCertificate(domain, fallback, callback) {
assert.strictEqual(typeof fallback, 'object');
assert.strictEqual(typeof callback, 'function');
if (fallback.restricted) { // restricted certs are not backed up
debug(`setFallbackCertificate: setting restricted certs for domain ${domain}`);
if (!safe.fs.writeFileSync(path.join(paths.NGINX_CERT_DIR, `${domain}.host.cert`), fallback.cert)) return callback(new BoxError(BoxError.FS_ERROR, safe.error.message));
if (!safe.fs.writeFileSync(path.join(paths.NGINX_CERT_DIR, `${domain}.host.key`), fallback.key)) return callback(new BoxError(BoxError.FS_ERROR, safe.error.message));
} else {
debug(`setFallbackCertificate: setting certs for domain ${domain}`);
if (!safe.fs.writeFileSync(path.join(paths.APP_CERTS_DIR, `${domain}.host.cert`), fallback.cert)) return callback(new BoxError(BoxError.FS_ERROR, safe.error.message));
if (!safe.fs.writeFileSync(path.join(paths.APP_CERTS_DIR, `${domain}.host.key`), fallback.key)) return callback(new BoxError(BoxError.FS_ERROR, safe.error.message));
}
debug(`setFallbackCertificate: setting certs for domain ${domain}`);
if (!safe.fs.writeFileSync(path.join(paths.APP_CERTS_DIR, `${domain}.host.cert`), fallback.cert)) return callback(new BoxError(BoxError.FS_ERROR, safe.error.message));
if (!safe.fs.writeFileSync(path.join(paths.APP_CERTS_DIR, `${domain}.host.key`), fallback.key)) return callback(new BoxError(BoxError.FS_ERROR, safe.error.message));
// TODO: maybe the cert is being used by the mail container
reload(function (error) {
@@ -237,15 +223,8 @@ function getFallbackCertificate(domain, callback) {
assert.strictEqual(typeof domain, 'string');
assert.strictEqual(typeof callback, 'function');
// check for any pre-provisioned (caas) certs. they get first priority
var certFilePath = path.join(paths.NGINX_CERT_DIR, `${domain}.host.cert`);
var keyFilePath = path.join(paths.NGINX_CERT_DIR, `${domain}.host.key`);
if (fs.existsSync(certFilePath) && fs.existsSync(keyFilePath)) return callback(null, { certFilePath, keyFilePath });
// check for auto-generated or user set fallback certs
certFilePath = path.join(paths.APP_CERTS_DIR, `${domain}.host.cert`);
keyFilePath = path.join(paths.APP_CERTS_DIR, `${domain}.host.key`);
const certFilePath = path.join(paths.APP_CERTS_DIR, `${domain}.host.cert`);
const keyFilePath = path.join(paths.APP_CERTS_DIR, `${domain}.host.key`);
callback(null, { certFilePath, keyFilePath });
}
@@ -267,15 +246,12 @@ function setAppCertificateSync(location, domainObject, certificate) {
return null;
}
function getCertificateByHostname(hostname, domainObject, callback) {
function getAcmeCertificate(hostname, domainObject, callback) {
assert.strictEqual(typeof hostname, 'string');
assert.strictEqual(typeof domainObject, 'object');
assert.strictEqual(typeof callback, 'function');
let certFilePath = path.join(paths.APP_CERTS_DIR, `${hostname}.user.cert`);
let keyFilePath = path.join(paths.APP_CERTS_DIR, `${hostname}.user.key`);
if (fs.existsSync(certFilePath) && fs.existsSync(keyFilePath)) return callback(null, { certFilePath, keyFilePath });
let certFilePath, keyFilePath;
if (hostname !== domainObject.domain && domainObject.tlsConfig.wildcard) { // bare domain is not part of wildcard SAN
let certName = domains.makeWildcard(hostname).replace('*.', '_.');
@@ -298,10 +274,22 @@ function getCertificate(fqdn, domain, callback) {
assert.strictEqual(typeof domain, 'string');
assert.strictEqual(typeof callback, 'function');
// 1. user cert always wins
// 2. if using fallback provider, return that cert
// 3. look for LE certs
domains.get(domain, function (error, domainObject) {
if (error) return callback(error);
getCertificateByHostname(fqdn, domainObject, function (error, result) {
// user cert always wins
let certFilePath = path.join(paths.APP_CERTS_DIR, `${fqdn}.user.cert`);
let keyFilePath = path.join(paths.APP_CERTS_DIR, `${fqdn}.user.key`);
if (fs.existsSync(certFilePath) && fs.existsSync(keyFilePath)) return callback(null, { certFilePath, keyFilePath });
if (domainObject.tlsConfig.provider === 'fallback') return getFallbackCertificate(domain, callback);
getAcmeCertificate(fqdn, domainObject, function (error, result) {
if (error || result) return callback(error, result);
return getFallbackCertificate(domain, callback);
@@ -329,14 +317,32 @@ function ensureCertificate(vhost, domain, auditSource, callback) {
domains.get(domain, function (error, domainObject) {
if (error) return callback(error);
getCertApi(domainObject, function (error, api, apiOptions) {
// user cert always wins
let certFilePath = path.join(paths.APP_CERTS_DIR, `${vhost}.user.cert`);
let keyFilePath = path.join(paths.APP_CERTS_DIR, `${vhost}.user.key`);
if (fs.existsSync(certFilePath) && fs.existsSync(keyFilePath)) {
debug(`ensureCertificate: ${vhost} will use custom app certs`);
return callback(null, { certFilePath, keyFilePath }, { renewed: false });
}
if (domainObject.tlsConfig.provider === 'fallback') {
debug(`ensureCertificate: ${vhost} will use fallback certs`);
return getFallbackCertificate(domain, function (error, bundle) {
if (error) return callback(error);
callback(null, bundle, { renewed: false });
});
}
getAcmeApi(domainObject, function (error, acmeApi, apiOptions) {
if (error) return callback(error);
getCertificateByHostname(vhost, domainObject, function (_error, currentBundle) {
getAcmeCertificate(vhost, domainObject, function (_error, currentBundle) {
if (currentBundle) {
debug(`ensureCertificate: ${vhost} certificate already exists at ${currentBundle.keyFilePath}`);
if (currentBundle.certFilePath.endsWith('.user.cert')) return callback(null, currentBundle, { renewed: false }); // user certs cannot be renewed
if (!isExpiringSync(currentBundle.certFilePath, 24 * 30) && providerMatchesSync(domainObject, currentBundle.certFilePath, apiOptions)) return callback(null, currentBundle, { renewed: false });
debug(`ensureCertificate: ${vhost} cert require renewal`);
} else {
@@ -345,7 +351,7 @@ function ensureCertificate(vhost, domain, auditSource, callback) {
debug('ensureCertificate: getting certificate for %s with options %j', vhost, apiOptions);
api.getCertificate(vhost, domain, apiOptions, function (error, certFilePath, keyFilePath) {
acmeApi.getCertificate(vhost, domain, apiOptions, function (error, certFilePath, keyFilePath) {
debug(`ensureCertificate: error: ${error ? error.message : 'null'} cert: ${certFilePath || 'null'}`);
eventlog.add(currentBundle ? eventlog.ACTION_CERTIFICATE_RENEWAL : eventlog.ACTION_CERTIFICATE_NEW, auditSource, { domain: vhost, errorMessage: error ? error.message : '' });
@@ -362,7 +368,6 @@ function ensureCertificate(vhost, domain, auditSource, callback) {
debug(`ensureCertificate: renewal of ${vhost} failed. using fallback certificates for ${domain}`);
// if no cert was returned use fallback. the fallback/caas provider will not provide any for example
getFallbackCertificate(domain, function (error, bundle) {
if (error) return callback(error);

View File

@@ -3,11 +3,11 @@
exports = module.exports = {
list: list,
startBackup: startBackup,
cleanup: cleanup
cleanup: cleanup,
check: check
};
let auditSource = require('../auditsource.js'),
backupdb = require('../backupdb.js'),
backups = require('../backups.js'),
BoxError = require('../boxerror.js'),
HttpError = require('connect-lastmile').HttpError,
@@ -20,7 +20,7 @@ function list(req, res, next) {
var perPage = typeof req.query.per_page !== 'undefined'? parseInt(req.query.per_page) : 25;
if (!perPage || perPage < 0) return next(new HttpError(400, 'per_page query param has to be a postive number'));
backups.getByStatePaged(backupdb.BACKUP_STATE_NORMAL, page, perPage, function (error, result) {
backups.getByIdentifierAndStatePaged(backups.BACKUP_IDENTIFIER_BOX, backups.BACKUP_STATE_NORMAL, page, perPage, function (error, result) {
if (error) return next(BoxError.toHttpError(error));
next(new HttpSuccess(200, { backups: result }));
@@ -42,3 +42,11 @@ function cleanup(req, res, next) {
next(new HttpSuccess(202, { taskId }));
});
}
function check(req, res, next) {
backups.checkConfiguration(function (error, message) {
if (error) return next(BoxError.toHttpError(error));
next(new HttpSuccess(200, { ok: !message, message: message }));
});
}

View File

@@ -86,8 +86,8 @@ function logout(req, res) {
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'));
users.resetPasswordByIdentifier(req.body.identifier, function (error) {
if (error && error.reason !== BoxError.NOT_FOUND) console.error(error);
users.sendPasswordResetByIdentifier(req.body.identifier, function (error) {
if (error && error.reason !== BoxError.NOT_FOUND) return next(BoxError.toHttpError(error));
next(new HttpSuccess(202, {}));
});
@@ -102,16 +102,17 @@ function passwordReset(req, res, next) {
users.getByResetToken(req.body.resetToken, function (error, userObject) {
if (error) return next(new HttpError(401, 'Invalid resetToken'));
if (Date.now() - userObject.resetTokenCreationTime > 24 * 60 * 60 * 1000) return next(new HttpError(401, 'Token expired'));
// if you fix the duration here, the emails and UI have to be fixed as well
if (Date.now() - userObject.resetTokenCreationTime > 7 * 24 * 60 * 60 * 1000) return next(new HttpError(401, 'Token expired'));
if (!userObject.username) return next(new HttpError(409, 'No username set'));
// setPassword clears the resetToken
users.setPassword(userObject, req.body.password, function (error) {
if (error && error.reason === BoxError.BAD_FIELD) return next(new HttpError(400, error.message));
if (error) return next(new HttpError(500, error));
if (error) return next(BoxError.toHttpError(error));
tokens.add(tokens.ID_WEBADMIN, userObject.id, Date.now() + constants.DEFAULT_TOKEN_EXPIRATION, {}, function (error, result) {
if (error) return next(new HttpError(500, error));
if (error) return next(BoxError.toHttpError(error));
next(new HttpSuccess(202, { accessToken: result.accessToken }));
});
@@ -122,37 +123,23 @@ function passwordReset(req, res, next) {
function setupAccount(req, res, next) {
assert.strictEqual(typeof req.body, 'object');
if (!req.body.email || typeof req.body.email !== 'string') return next(new HttpError(400, 'email must be a non-empty string'));
if (!req.body.resetToken || typeof req.body.resetToken !== 'string') return next(new HttpError(400, 'resetToken must be a non-empty string'));
if (!req.body.password || typeof req.body.password !== 'string') return next(new HttpError(400, 'password must be a non-empty string'));
if (!req.body.username || typeof req.body.username !== 'string') return next(new HttpError(400, 'username must be a non-empty string'));
if (!req.body.displayName || typeof req.body.displayName !== 'string') return next(new HttpError(400, 'displayName must be a non-empty string'));
// only sent if profile is not locked
if ('username' in req.body && typeof req.body.username !== 'string') return next(new HttpError(400, 'username must be a non-empty string'));
if ('displayName' in req.body && typeof req.body.displayName !== 'string') return next(new HttpError(400, 'displayName must be a non-empty string'));
users.getByResetToken(req.body.resetToken, function (error, userObject) {
if (error) return next(new HttpError(401, 'Invalid Reset Token'));
// if you fix the duration here, the emails and UI have to be fixed as well
if (Date.now() - userObject.resetTokenCreationTime > 24 * 60 * 60 * 1000) return next(new HttpError(401, 'Token expired'));
users.update(userObject, { username: req.body.username, displayName: req.body.displayName }, auditSource.fromRequest(req), function (error) {
if (error && error.reason === BoxError.ALREADY_EXISTS) return next(new HttpError(409, 'Username already used'));
if (error && error.reason === BoxError.BAD_FIELD) return next(new HttpError(400, error.message));
if (error && error.reason === BoxError.NOT_FOUND) return next(new HttpError(404, 'No such user'));
if (error) return next(new HttpError(500, error));
users.setupAccount(userObject, req.body, auditSource.fromRequest(req), function (error, accessToken) {
if (error) return next(BoxError.toHttpError(error));
userObject.username = req.body.username;
userObject.displayName = req.body.displayName;
// setPassword clears the resetToken
users.setPassword(userObject, req.body.password, function (error) {
if (error && error.reason === BoxError.BAD_FIELD) return next(new HttpError(400, error.message));
if (error) return next(new HttpError(500, error));
tokens.add(tokens.ID_WEBADMIN, userObject.id, Date.now() + constants.DEFAULT_TOKEN_EXPIRATION, {}, function (error, result) {
if (error) return next(new HttpError(500, error));
next(new HttpSuccess(201, { accessToken: result.accessToken }));
});
});
next(new HttpSuccess(201, { accessToken }));
});
});
}

View File

@@ -33,7 +33,6 @@ function add(req, res, next) {
let fallbackCertificate = req.body.fallbackCertificate;
if (!fallbackCertificate.cert || typeof fallbackCertificate.cert !== 'string') return next(new HttpError(400, 'fallbackCertificate.cert must be a string'));
if (!fallbackCertificate.key || typeof fallbackCertificate.key !== 'string') return next(new HttpError(400, 'fallbackCertificate.key must be a string'));
if ('restricted' in fallbackCertificate && typeof fallbackCertificate.restricted !== 'boolean') return next(new HttpError(400, 'fallbackCertificate.restricted must be a boolean'));
}
if ('tlsConfig' in req.body) {
@@ -95,7 +94,6 @@ function update(req, res, next) {
let fallbackCertificate = req.body.fallbackCertificate;
if (!fallbackCertificate.cert || typeof fallbackCertificate.cert !== 'string') return next(new HttpError(400, 'fallbackCertificate.cert must be a string'));
if (!fallbackCertificate.key || typeof fallbackCertificate.key !== 'string') return next(new HttpError(400, 'fallbackCertificate.key must be a string'));
if ('restricted' in fallbackCertificate && typeof fallbackCertificate.restricted !== 'boolean') return next(new HttpError(400, 'fallbackCertificate.restricted must be a boolean'));
}
if ('tlsConfig' in req.body) {

43
src/routes/filemanager.js Normal file
View File

@@ -0,0 +1,43 @@
'use strict';
exports = module.exports = {
proxy
};
var assert = require('assert'),
BoxError = require('../boxerror.js'),
docker = require('../docker.js'),
middleware = require('../middleware/index.js'),
HttpError = require('connect-lastmile').HttpError,
safe = require('safetydance'),
url = require('url');
function proxy(req, res, next) {
assert.strictEqual(typeof req.params.id, 'string');
const appId = req.params.id;
req.clearTimeout();
docker.inspect('sftp', function (error, result) {
if (error)return next(BoxError.toHttpError(error));
const ip = safe.query(result, 'NetworkSettings.Networks.cloudron.IPAddress', null);
if (!ip) return next(new BoxError(BoxError.INACTIVE, 'Error getting IP of sftp service'));
req.url = req.originalUrl.replace(`/api/v1/apps/${appId}/files`, `/files/${appId}`);
const proxyOptions = url.parse(`https://${ip}:3000`);
proxyOptions.rejectUnauthorized = false;
const fileManagerProxy = middleware.proxy(proxyOptions);
fileManagerProxy(req, res, function (error) {
if (!error) return next();
if (error.code === 'ECONNREFUSED') return next(new HttpError(424, 'Unable to connect to filemanager server'));
if (error.code === 'ECONNRESET') return next(new HttpError(424, 'Unable to query filemanager server'));
next(new HttpError(500, error));
});
});
}

View File

@@ -20,7 +20,9 @@ function create(req, res, next) {
if (typeof req.body.name !== 'string') return next(new HttpError(400, 'name must be string'));
groups.create(req.body.name, function (error, group) {
var source = ''; // means local
groups.create(req.body.name, source, function (error, group) {
if (error) return next(BoxError.toHttpError(error));
var groupInfo = {
@@ -69,7 +71,7 @@ function updateMembers(req, res, next) {
}
function list(req, res, next) {
groups.getAll(function (error, result) {
groups.getAllWithMembers(function (error, result) {
if (error) return next(BoxError.toHttpError(error));
next(new HttpSuccess(200, { groups: result }));

View File

@@ -10,6 +10,7 @@ exports = module.exports = {
cloudron: require('./cloudron.js'),
domains: require('./domains.js'),
eventlog: require('./eventlog.js'),
filemanager: require('./filemanager.js'),
graphs: require('./graphs.js'),
groups: require('./groups.js'),
mail: require('./mail.js'),

View File

@@ -1,33 +1,35 @@
'use strict';
exports = module.exports = {
getDomain: getDomain,
getDomain,
setDnsRecords: setDnsRecords,
setDnsRecords,
getStatus: getStatus,
getStatus,
setMailFromValidation: setMailFromValidation,
setCatchAllAddress: setCatchAllAddress,
setMailRelay: setMailRelay,
setMailEnabled: setMailEnabled,
setMailFromValidation,
setCatchAllAddress,
setMailRelay,
setMailEnabled,
sendTestMail: sendTestMail,
sendTestMail,
listMailboxes: listMailboxes,
getMailbox: getMailbox,
addMailbox: addMailbox,
updateMailbox: updateMailbox,
removeMailbox: removeMailbox,
listMailboxes,
getMailbox,
addMailbox,
updateMailbox,
removeMailbox,
getAliases: getAliases,
setAliases: setAliases,
getAliases,
setAliases,
getLists: getLists,
getList: getList,
addList: addList,
updateList: updateList,
removeList: removeList,
getLists,
getList,
addList,
updateList,
removeList,
getMailboxCount
};
var assert = require('assert'),
@@ -159,13 +161,25 @@ function listMailboxes(req, res, next) {
var perPage = typeof req.query.per_page !== 'undefined'? parseInt(req.query.per_page) : 25;
if (!perPage || perPage < 0) return next(new HttpError(400, 'per_page query param has to be a positive number'));
mail.listMailboxes(req.params.domain, page, perPage, function (error, result) {
if (req.query.search && typeof req.query.search !== 'string') return next(new HttpError(400, 'search must be a string'));
mail.listMailboxes(req.params.domain, req.query.search || null, page, perPage, function (error, result) {
if (error) return next(BoxError.toHttpError(error));
next(new HttpSuccess(200, { mailboxes: result }));
});
}
function getMailboxCount(req, res, next) {
assert.strictEqual(typeof req.params.domain, 'string');
mail.getMailboxCount(req.params.domain, function (error, count) {
if (error) return next(BoxError.toHttpError(error));
next(new HttpSuccess(200, { count }));
});
}
function getMailbox(req, res, next) {
assert.strictEqual(typeof req.params.domain, 'string');
assert.strictEqual(typeof req.params.name, 'string');
@@ -207,7 +221,9 @@ function removeMailbox(req, res, next) {
assert.strictEqual(typeof req.params.domain, 'string');
assert.strictEqual(typeof req.params.name, 'string');
mail.removeMailbox(req.params.name, req.params.domain, auditSource.fromRequest(req), function (error) {
if (typeof req.body.deleteMails !== 'boolean') return next(new HttpError(400, 'deleteMails must be a boolean'));
mail.removeMailbox(req.params.name, req.params.domain, req.body, auditSource.fromRequest(req), function (error) {
if (error) return next(BoxError.toHttpError(error));
next(new HttpSuccess(201, {}));
@@ -248,7 +264,15 @@ function setAliases(req, res, next) {
function getLists(req, res, next) {
assert.strictEqual(typeof req.params.domain, 'string');
mail.getLists(req.params.domain, function (error, result) {
const page = typeof req.query.page !== 'undefined' ? parseInt(req.query.page) : 1;
if (!page || page < 0) return next(new HttpError(400, 'page query param has to be a positive number'));
const perPage = typeof req.query.per_page !== 'undefined'? parseInt(req.query.per_page) : 25;
if (!perPage || perPage < 0) return next(new HttpError(400, 'per_page query param has to be a positive number'));
if (req.query.search && typeof req.query.search !== 'string') return next(new HttpError(400, 'search must be a string'));
mail.getLists(req.params.domain, req.query.search || null, page, perPage, function (error, result) {
if (error) return next(BoxError.toHttpError(error));
next(new HttpSuccess(200, { lists: result }));

View File

@@ -1,34 +1,41 @@
'use strict';
exports = module.exports = {
get: get,
update: update,
getAvatar: getAvatar,
setAvatar: setAvatar,
clearAvatar: clearAvatar,
changePassword: changePassword,
setTwoFactorAuthenticationSecret: setTwoFactorAuthenticationSecret,
enableTwoFactorAuthentication: enableTwoFactorAuthentication,
disableTwoFactorAuthentication: disableTwoFactorAuthentication
authorize,
get,
update,
getAvatar,
setAvatar,
clearAvatar,
changePassword,
setTwoFactorAuthenticationSecret,
enableTwoFactorAuthentication,
disableTwoFactorAuthentication,
};
var assert = require('assert'),
auditSource = require('../auditsource.js'),
BoxError = require('../boxerror.js'),
fs = require('fs'),
HttpError = require('connect-lastmile').HttpError,
HttpSuccess = require('connect-lastmile').HttpSuccess,
path = require('path'),
paths = require('../paths.js'),
safe = require('safetydance'),
users = require('../users.js'),
settings = require('../settings.js'),
_ = require('underscore');
function get(req, res, next) {
function authorize(req, res, next) {
assert.strictEqual(typeof req.user, 'object');
const emailHash = require('crypto').createHash('md5').update(req.user.email).digest('hex');
settings.getDirectoryConfig(function (error, directoryConfig) {
if (error) return next(BoxError.toHttpError(error));
if (directoryConfig.lockUserProfiles) return next(new HttpError(403, 'admin has disallowed users from editing profiles'));
next();
});
}
function get(req, res, next) {
assert.strictEqual(typeof req.user, 'object');
next(new HttpSuccess(200, {
id: req.user.id,
@@ -39,7 +46,7 @@ function get(req, res, next) {
twoFactorAuthenticationEnabled: req.user.twoFactorAuthenticationEnabled,
role: req.user.role,
source: req.user.source,
avatarUrl: fs.existsSync(path.join(paths.PROFILE_ICONS_DIR, req.user.id)) ? `${settings.adminOrigin()}/api/v1/profile/avatar/${req.user.id}` : `https://www.gravatar.com/avatar/${emailHash}.jpg`
avatarUrl: users.getAvatarUrlSync(req.user)
}));
}
@@ -65,21 +72,27 @@ function setAvatar(req, res, next) {
if (!req.files.avatar) return next(new HttpError(400, 'avatar is missing'));
if (!safe.fs.renameSync(req.files.avatar.path, path.join(paths.PROFILE_ICONS_DIR, req.user.id))) return next(new HttpError(500, safe.error));
users.setAvatar(req.user.id, req.files.avatar.path, function (error) {
if (error) return next(BoxError.toHttpError(error));
next(new HttpSuccess(202, {}));
next(new HttpSuccess(202, {}));
});
}
function clearAvatar(req, res, next) {
assert.strictEqual(typeof req.user, 'object');
safe.fs.unlinkSync(path.join(paths.PROFILE_ICONS_DIR, req.user.id));
users.clearAvatar(req.user.id, function (error) {
if (error) return next(BoxError.toHttpError(error));
next(new HttpSuccess(202, {}));
next(new HttpSuccess(202, {}));
});
}
function getAvatar(req, res) {
res.sendFile(path.join(paths.PROFILE_ICONS_DIR, req.params.identifier));
assert.strictEqual(typeof req.params.identifier, 'string');
res.sendFile(users.getAvatarFileSync(req.params.identifier));
}
function changePassword(req, res, next) {

View File

@@ -122,7 +122,7 @@ function getStatus(req, res, next) {
// check if Cloudron is not in setup state nor activated and let appstore know of the attempt
if (!status.activated && !status.setup.active && !status.restore.active) {
appstore.trackBeginSetup(status.provider);
appstore.trackBeginSetup();
}
});
}

View File

@@ -97,12 +97,25 @@ function setBackupConfig(req, res, next) {
assert.strictEqual(typeof req.body, 'object');
if (typeof req.body.provider !== 'string') return next(new HttpError(400, 'provider is required'));
if (typeof req.body.intervalSecs !== 'number') return next(new HttpError(400, 'intervalSecs is required'));
if (typeof req.body.schedulePattern !== 'string') return next(new HttpError(400, 'schedulePattern is required'));
if ('password' in req.body && typeof req.body.password !== 'string') return next(new HttpError(400, 'password must be a string'));
if ('syncConcurrency' in req.body) {
if (typeof req.body.syncConcurrency !== 'number') return next(new HttpError(400, 'syncConcurrency must be a positive integer'));
if (req.body.syncConcurrency < 1) return next(new HttpError(400, 'syncConcurrency must be a positive integer'));
}
if ('copyConcurrency' in req.body) {
if (typeof req.body.copyConcurrency !== 'number') return next(new HttpError(400, 'copyConcurrency must be a positive integer'));
if (req.body.copyConcurrency < 1) return next(new HttpError(400, 'copyConcurrency must be a positive integer'));
}
if ('downloadConcurrency' in req.body) {
if (typeof req.body.downloadConcurrency !== 'number') return next(new HttpError(400, 'downloadConcurrency must be a positive integer'));
if (req.body.downloadConcurrency < 1) return next(new HttpError(400, 'downloadConcurrency must be a positive integer'));
}
if ('deleteConcurrency' in req.body) {
if (typeof req.body.deleteConcurrency !== 'number') return next(new HttpError(400, 'deleteConcurrency must be a positive integer'));
if (req.body.deleteConcurrency < 1) return next(new HttpError(400, 'deleteConcurrency must be a positive integer'));
}
if ('memoryLimit' in req.body && typeof req.body.memoryLimit !== 'number') return next(new HttpError(400, 'memoryLimit must be a positive integer'));
if (typeof req.body.format !== 'string') return next(new HttpError(400, 'format must be a string'));
if ('acceptSelfSignedCerts' in req.body && typeof req.body.acceptSelfSignedCerts !== 'boolean') return next(new HttpError(400, 'format must be a boolean'));
@@ -160,6 +173,7 @@ function setExternalLdapConfig(req, res, next) {
if ('baseDn' in req.body && typeof req.body.baseDn !== 'string') return next(new HttpError(400, 'baseDn must be a string'));
if ('usernameField' in req.body && typeof req.body.usernameField !== 'string') return next(new HttpError(400, 'usernameField must be a string'));
if ('filter' in req.body && typeof req.body.filter !== 'string') return next(new HttpError(400, 'filter must be a string'));
if ('groupBaseDn' in req.body && typeof req.body.groupBaseDn !== 'string') return next(new HttpError(400, 'groupBaseDn must be a string'));
if ('bindDn' in req.body && typeof req.body.bindDn !== 'string') return next(new HttpError(400, 'bindDn must be a non empty string'));
if ('bindPassword' in req.body && typeof req.body.bindPassword !== 'string') return next(new HttpError(400, 'bindPassword must be a string'));
@@ -233,6 +247,27 @@ function setRegistryConfig(req, res, next) {
});
}
function getDirectoryConfig(req, res, next) {
settings.getDirectoryConfig(function (error, directoryConfig) {
if (error) return next(BoxError.toHttpError(error));
next(new HttpSuccess(200, directoryConfig));
});
}
function setDirectoryConfig(req, res, next) {
assert.strictEqual(typeof req.body, 'object');
if (typeof req.body.lockUserProfiles !== 'boolean') return next(new HttpError(400, 'lockUserProfiles is required'));
if (typeof req.body.mandatory2FA !== 'boolean') return next(new HttpError(400, 'mandatory2FA is required'));
settings.setDirectoryConfig(req.body, function (error) {
if (error) return next(BoxError.toHttpError(error));
next(new HttpSuccess(200, {}));
});
}
function getSysinfoConfig(req, res, next) {
settings.getSysinfoConfig(function (error, sysinfoConfig) {
if (error) return next(BoxError.toHttpError(error));
@@ -269,6 +304,7 @@ function get(req, res, next) {
case settings.BOX_AUTOUPDATE_PATTERN_KEY: return getBoxAutoupdatePattern(req, res, next);
case settings.TIME_ZONE_KEY: return getTimeZone(req, res, next);
case settings.DIRECTORY_CONFIG_KEY: return getDirectoryConfig(req, res, next);
case settings.SUPPORT_CONFIG_KEY: return getSupportConfig(req, res, next);
default: return next(new HttpError(404, 'No such setting'));
@@ -290,6 +326,8 @@ function set(req, res, next) {
case settings.BOX_AUTOUPDATE_PATTERN_KEY: return setBoxAutoupdatePattern(req, res, next);
case settings.TIME_ZONE_KEY: return setTimeZone(req, res, next);
case settings.DIRECTORY_CONFIG_KEY: return setDirectoryConfig(req, res, next);
default: return next(new HttpError(404, 'No such setting'));
}
}

View File

@@ -62,7 +62,7 @@ function setup(done) {
},
function createSettings(callback) {
settings.setBackupConfig({ provider: 'filesystem', backupFolder: '/tmp', format: 'tgz', retentionPolicy: {} }, callback);
settings.setBackupConfig({ provider: 'filesystem', backupFolder: '/tmp', format: 'tgz', retentionPolicy: { keepWithinSecs: 2 * 24 * 60 * 60 }, schedulePattern: '00 00 23 * * *' }, callback);
}
], done);
}
@@ -108,4 +108,35 @@ describe('Backups API', function () {
});
});
});
describe('check', function () {
it('fails due to mising token', function (done) {
superagent.get(SERVER_URL + '/api/v1/backups/check')
.end(function (error, result) {
expect(result.statusCode).to.equal(401);
done();
});
});
it('fails due to wrong token', function (done) {
superagent.get(SERVER_URL + '/api/v1/backups/check')
.query({ access_token: token.toUpperCase() })
.end(function (error, result) {
expect(result.statusCode).to.equal(401);
done();
});
});
it('succeeds', function (done) {
superagent.get(SERVER_URL + '/api/v1/backups/check')
.query({ access_token: token })
.end(function (error, result) {
expect(result.statusCode).to.equal(200);
expect(result.body.ok).to.equal(false);
expect(result.body.message).to.not.be.empty();
done();
});
});
});
});

View File

@@ -32,7 +32,7 @@ function setup(done) {
server.start.bind(server),
database._clear,
settings._setApiServerOrigin.bind(null, 'http://localhost:6060'),
settings.setBackupConfig.bind(null, { provider: 'filesystem', backupFolder: '/tmp', format: 'tgz', retentionPolicy: {} })
settings.setBackupConfig.bind(null, { provider: 'filesystem', backupFolder: '/tmp', format: 'tgz', retentionPolicy: { keepWithinSecs: 10000 }, schedulePattern: '00 00 23 * * *' })
], done);
}
@@ -44,7 +44,7 @@ function cleanup(done) {
});
}
describe('Cloudron', function () {
describe('Cloudron API', function () {
describe('activate', function () {

View File

@@ -86,7 +86,7 @@ describe('Groups API', function () {
it('create fails due to mising token', function (done) {
superagent.post(SERVER_URL + '/api/v1/groups')
.send({ name: GROUP_NAME})
.send({ name: GROUP_NAME })
.end(function (error, result) {
expect(result.statusCode).to.equal(401);
done();
@@ -96,7 +96,7 @@ describe('Groups API', function () {
it('create succeeds', function (done) {
superagent.post(SERVER_URL + '/api/v1/groups')
.query({ access_token: token })
.send({ name: GROUP_NAME})
.send({ name: GROUP_NAME })
.end(function (error, result) {
expect(result.statusCode).to.equal(201);
groupObject = result.body;

View File

@@ -625,6 +625,7 @@ describe('Mail API', function () {
it('disable fails even if not exist', function (done) {
superagent.del(SERVER_URL + '/api/v1/mail/' + DOMAIN_0.domain + '/mailboxes/' + 'someuserdoesnotexist')
.send({ deleteMails: false })
.query({ access_token: token })
.end(function (err, res) {
expect(res.statusCode).to.equal(404);
@@ -634,6 +635,7 @@ describe('Mail API', function () {
it('disable succeeds', function (done) {
superagent.del(SERVER_URL + '/api/v1/mail/' + DOMAIN_0.domain + '/mailboxes/' + MAILBOX_NAME)
.send({ deleteMails: false })
.query({ access_token: token })
.end(function (err, res) {
expect(res.statusCode).to.equal(201);
@@ -649,7 +651,7 @@ describe('Mail API', function () {
describe('aliases', function () {
after(function (done) {
mail.removeMailboxes(DOMAIN_0.domain, function (error) {
mail._removeMailboxes(DOMAIN_0.domain, function (error) {
if (error) return done(error);
done();
});
@@ -726,7 +728,7 @@ describe('Mail API', function () {
describe('mailinglists', function () {
after(function (done) {
mail.removeMailboxes(DOMAIN_0.domain, function (error) {
mail._removeMailboxes(DOMAIN_0.domain, function (error) {
if (error) return done(error);
done();

View File

@@ -9,15 +9,20 @@ var async = require('async'),
constants = require('../../constants.js'),
database = require('../../database.js'),
expect = require('expect.js'),
fs = require('fs'),
rimraf = require('rimraf'),
server = require('../../server.js'),
superagent = require('superagent');
var SERVER_URL = 'http://localhost:' + constants.PORT;
var USERNAME = 'superadmin', PASSWORD = 'Foobar?1337', EMAIL ='silly@me.com';
var BACKUP_FOLDER = '/tmp/backup_test';
var token = null;
function setup(done) {
fs.mkdirSync(BACKUP_FOLDER, { recursive: true });
async.series([
server.start.bind(null),
database._clear.bind(null),
@@ -40,6 +45,8 @@ function setup(done) {
}
function cleanup(done) {
rimraf.sync(BACKUP_FOLDER);
database._clear(function (error) {
expect(!error).to.be.ok();
@@ -204,4 +211,246 @@ describe('Settings API', function () {
});
});
});
describe('backup_config', function () {
// keep in sync with defaults in settings.js
let defaultConfig = {
provider: 'filesystem',
backupFolder: '/var/backups',
format: 'tgz',
encryption: null,
retentionPolicy: { keepWithinSecs: 2 * 24 * 60 * 60 }, // 2 days
schedulePattern: '00 00 23 * * *' // every day at 11pm
};
it('can get backup_config (default)', function (done) {
superagent.get(SERVER_URL + '/api/v1/settings/backup_config')
.query({ access_token: token })
.end(function (err, res) {
expect(res.statusCode).to.equal(200);
expect(res.body).to.eql(defaultConfig);
done();
});
});
it('cannot set backup_config without provider', function (done) {
var tmp = JSON.parse(JSON.stringify(defaultConfig));
delete tmp.provider;
superagent.post(SERVER_URL + '/api/v1/settings/backup_config')
.query({ access_token: token })
.send(tmp)
.end(function (err, res) {
expect(res.statusCode).to.equal(400);
done();
});
});
it('cannot set backup_config with invalid provider', function (done) {
var tmp = JSON.parse(JSON.stringify(defaultConfig));
tmp.provider = 'invalid provider';
superagent.post(SERVER_URL + '/api/v1/settings/backup_config')
.query({ access_token: token })
.send(tmp)
.end(function (err, res) {
expect(res.statusCode).to.equal(400);
done();
});
});
it('cannot set backup_config without schedulePattern', function (done) {
var tmp = JSON.parse(JSON.stringify(defaultConfig));
delete tmp.schedulePattern;
superagent.post(SERVER_URL + '/api/v1/settings/backup_config')
.query({ access_token: token })
.send(tmp)
.end(function (err, res) {
expect(res.statusCode).to.equal(400);
done();
});
});
it('cannot set backup_config with invalid schedulePattern', function (done) {
var tmp = JSON.parse(JSON.stringify(defaultConfig));
tmp.schedulePattern = 'not a pattern';
superagent.post(SERVER_URL + '/api/v1/settings/backup_config')
.query({ access_token: token })
.send(tmp)
.end(function (err, res) {
expect(res.statusCode).to.equal(400);
done();
});
});
it('cannot set backup_config without format', function (done) {
var tmp = JSON.parse(JSON.stringify(defaultConfig));
delete tmp.format;
superagent.post(SERVER_URL + '/api/v1/settings/backup_config')
.query({ access_token: token })
.send(tmp)
.end(function (err, res) {
expect(res.statusCode).to.equal(400);
done();
});
});
it('cannot set backup_config with invalid format', function (done) {
var tmp = JSON.parse(JSON.stringify(defaultConfig));
tmp.format = 'invalid format';
superagent.post(SERVER_URL + '/api/v1/settings/backup_config')
.query({ access_token: token })
.send(tmp)
.end(function (err, res) {
expect(res.statusCode).to.equal(400);
done();
});
});
it('cannot set backup_config without retentionPolicy', function (done) {
var tmp = JSON.parse(JSON.stringify(defaultConfig));
delete tmp.retentionPolicy;
superagent.post(SERVER_URL + '/api/v1/settings/backup_config')
.query({ access_token: token })
.send(tmp)
.end(function (err, res) {
expect(res.statusCode).to.equal(400);
done();
});
});
it('cannot set backup_config with invalid retentionPolicy', function (done) {
var tmp = JSON.parse(JSON.stringify(defaultConfig));
tmp.retentionPolicy = 'not an object';
superagent.post(SERVER_URL + '/api/v1/settings/backup_config')
.query({ access_token: token })
.send(tmp)
.end(function (err, res) {
expect(res.statusCode).to.equal(400);
done();
});
});
it('cannot set backup_config with empty retentionPolicy', function (done) {
var tmp = JSON.parse(JSON.stringify(defaultConfig));
tmp.retentionPolicy = {};
superagent.post(SERVER_URL + '/api/v1/settings/backup_config')
.query({ access_token: token })
.send(tmp)
.end(function (err, res) {
expect(res.statusCode).to.equal(400);
done();
});
});
it('cannot set backup_config with retentionPolicy missing properties', function (done) {
var tmp = JSON.parse(JSON.stringify(defaultConfig));
tmp.retentionPolicy = { foo: 'bar' };
superagent.post(SERVER_URL + '/api/v1/settings/backup_config')
.query({ access_token: token })
.send(tmp)
.end(function (err, res) {
expect(res.statusCode).to.equal(400);
done();
});
});
it('cannot set backup_config with retentionPolicy with invalid keepWithinSecs', function (done) {
var tmp = JSON.parse(JSON.stringify(defaultConfig));
tmp.retentionPolicy = { keepWithinSecs: 'not a number' };
superagent.post(SERVER_URL + '/api/v1/settings/backup_config')
.query({ access_token: token })
.send(tmp)
.end(function (err, res) {
expect(res.statusCode).to.equal(400);
done();
});
});
it('cannot set backup_config with invalid password', function (done) {
var tmp = JSON.parse(JSON.stringify(defaultConfig));
tmp.password = 1234;
superagent.post(SERVER_URL + '/api/v1/settings/backup_config')
.query({ access_token: token })
.send(tmp)
.end(function (err, res) {
expect(res.statusCode).to.equal(400);
done();
});
});
it('cannot set backup_config with invalid syncConcurrency', function (done) {
var tmp = JSON.parse(JSON.stringify(defaultConfig));
tmp.syncConcurrency = 'not a number';
superagent.post(SERVER_URL + '/api/v1/settings/backup_config')
.query({ access_token: token })
.send(tmp)
.end(function (err, res) {
expect(res.statusCode).to.equal(400);
done();
});
});
it('cannot set backup_config with invalid syncConcurrency', function (done) {
var tmp = JSON.parse(JSON.stringify(defaultConfig));
tmp.syncConcurrency = 0;
superagent.post(SERVER_URL + '/api/v1/settings/backup_config')
.query({ access_token: token })
.send(tmp)
.end(function (err, res) {
expect(res.statusCode).to.equal(400);
done();
});
});
it('cannot set backup_config with invalid acceptSelfSignedCerts', function (done) {
var tmp = JSON.parse(JSON.stringify(defaultConfig));
tmp.acceptSelfSignedCerts = 'not a boolean';
superagent.post(SERVER_URL + '/api/v1/settings/backup_config')
.query({ access_token: token })
.send(tmp)
.end(function (err, res) {
expect(res.statusCode).to.equal(400);
done();
});
});
it('can set backup_config', function (done) {
var tmp = JSON.parse(JSON.stringify(defaultConfig));
tmp.format = 'rsync';
tmp.backupFolder = BACKUP_FOLDER;
superagent.post(SERVER_URL + '/api/v1/settings/backup_config')
.query({ access_token: token })
.send(tmp)
.end(function (err, res) {
expect(res.statusCode).to.equal(200);
done();
});
});
it('can get backup_config', function (done) {
superagent.get(SERVER_URL + '/api/v1/settings/backup_config')
.query({ access_token: token })
.end(function (err, res) {
expect(res.statusCode).to.equal(200);
expect(res.body.format).to.equal('rsync');
expect(res.body.backupFolder).to.equal(BACKUP_FOLDER);
done();
});
});
});
});

View File

@@ -117,7 +117,7 @@ describe('Tasks API', function () {
expect(res.body.active).to.be(false); // finished
expect(res.body.success).to.be(false);
expect(res.body.result).to.be(null);
expect(res.body.error.message).to.contain('signal SIGTERM');
expect(res.body.error.message).to.contain('stopped');
done();
});
});

View File

@@ -13,7 +13,6 @@ var async = require('async'),
expect = require('expect.js'),
hat = require('../../hat.js'),
groups = require('../../groups.js'),
mail = require('../../mail.js'),
mailer = require('../../mailer.js'),
superagent = require('superagent'),
server = require('../../server.js'),
@@ -50,7 +49,7 @@ function setup(done) {
], function (error) {
expect(error).to.not.be.ok();
groups.create('somegroupname', function (error, result) {
groups.create('somegroupname', '', function (error, result) {
expect(error).to.not.be.ok();
groupObject = result;

View File

@@ -1,18 +1,20 @@
'use strict';
exports = module.exports = {
get: get,
update: update,
list: list,
create: create,
remove: remove,
changePassword: changePassword,
verifyPassword: verifyPassword,
createInvite: createInvite,
sendInvite: sendInvite,
setGroups: setGroups,
get,
update,
list,
create,
remove,
changePassword,
verifyPassword,
createInvite,
sendInvite,
setGroups,
setAvatar,
clearAvatar,
load: load
load
};
var assert = require('assert'),
@@ -192,3 +194,25 @@ function changePassword(req, res, next) {
next(new HttpSuccess(204));
});
}
function setAvatar(req, res, next) {
assert.strictEqual(typeof req.resource, 'object');
if (!req.files.avatar) return next(new HttpError(400, 'avatar is missing'));
users.setAvatar(req.resource.id, req.files.avatar.path, function (error) {
if (error) return next(BoxError.toHttpError(error));
next(new HttpSuccess(202, {}));
});
}
function clearAvatar(req, res, next) {
assert.strictEqual(typeof req.resource, 'object');
users.clearAvatar(req.resource.id, function (error) {
if (error) return next(BoxError.toHttpError(error));
next(new HttpSuccess(202, {}));
});
}

View File

@@ -13,27 +13,21 @@ let apps = require('./apps.js'),
docker = require('./docker.js'),
_ = require('underscore');
var NOOP_CALLBACK = function (error) { if (error) debug('Unhandled error: ', error); };
// appId -> { schedulerConfig (manifest), cronjobs }
var gState = { };
function sync(callback) {
assert(!callback || typeof callback === 'function');
callback = callback || NOOP_CALLBACK;
function sync() {
apps.getAll(function (error, allApps) {
if (error) return callback(error);
if (error) return debug(`sync: error getting app list. ${error.message}`);
var allAppIds = allApps.map(function (app) { return app.id; });
var removedAppIds = _.difference(Object.keys(gState), allAppIds);
if (removedAppIds.length !== 0) debug('sync: stopping jobs of removed apps %j', removedAppIds);
if (removedAppIds.length !== 0) debug(`sync: stopping jobs of removed apps ${JSON.stringify(removedAppIds)}`);
async.eachSeries(removedAppIds, function (appId, iteratorDone) {
stopJobs(appId, gState[appId], iteratorDone);
}, function (error) {
if (error) debug('sync: error stopping jobs of removed apps', error);
if (error) debug(`sync: error stopping jobs of removed apps: ${error.message}`);
gState = _.omit(gState, removedAppIds);
@@ -48,7 +42,7 @@ function sync(callback) {
}
stopJobs(app.id, appState, function (error) {
if (error) debug(`sync: error stopping jobs of ${app.fqdn} : ${error.message}`);
if (error) debug(`sync: error stopping jobs of ${app.id} : ${error.message}`);
if (!schedulerConfig) {
delete gState[app.id];
@@ -75,7 +69,7 @@ function killContainer(containerName, callback) {
docker.stopContainerByName.bind(null, containerName),
docker.deleteContainerByName.bind(null, containerName)
], function (error) {
if (error) debug('Failed to kill task with name %s : %s', containerName, error.message);
if (error) debug(`killContainer: failed to kill task with name ${containerName} : ${error.message}`);
callback(error);
});
@@ -101,6 +95,7 @@ function createCronJobs(app, schedulerConfig) {
assert.strictEqual(typeof app, 'object');
assert(schedulerConfig && typeof schedulerConfig === 'object');
const appId = app.id;
var jobs = { };
Object.keys(schedulerConfig).forEach(function (taskName) {
@@ -112,7 +107,9 @@ function createCronJobs(app, schedulerConfig) {
var cronJob = new CronJob({
cronTime: cronTime, // at this point, the pattern has been validated
onTick: runTask.bind(null, app.id, taskName), // put the app id in closure, so we don't use the outdated app object by mistake
onTick: () => runTask(appId, taskName, (error) => { // put the app id in closure, so we don't use the outdated app object by mistake
if (error) debug(`could not run task ${taskName} : ${error.message}`);
}),
start: true
});
@@ -125,17 +122,14 @@ function createCronJobs(app, schedulerConfig) {
function runTask(appId, taskName, callback) {
assert.strictEqual(typeof appId, 'string');
assert.strictEqual(typeof taskName, 'string');
assert(!callback || typeof callback === 'function');
assert.strictEqual(typeof callback, 'function');
const JOB_MAX_TIME = 30 * 60 * 1000; // 30 minutes
callback = callback || NOOP_CALLBACK;
apps.get(appId, function (error, app) {
if (error) return callback(error);
if (app.installationState !== apps.ISTATE_INSTALLED || app.runState !== apps.RSTATE_RUNNING || app.health !== apps.HEALTH_HEALTHY) {
debug(`runTask: skipped task ${taskName} because app ${app.fqdn} has state ${app.installationState} / ${app.runState}`);
return callback();
}

View File

@@ -7,8 +7,6 @@
if (process.argv[2] === '--check') return console.log('OK');
require('supererror')({ splatchError: true });
var assert = require('assert'),
async = require('async'),
backups = require('../backups.js'),

View File

@@ -37,4 +37,4 @@ if [[ "${cmd}" == "clear" ]]; then
else
# this make not succeed if volume is a mount point
rmdir "${volume_dir}" || true
fi
fi

29
src/scripts/rmmailbox.sh Executable file
View File

@@ -0,0 +1,29 @@
#!/bin/bash
set -eu -o pipefail
if [[ ${EUID} -ne 0 ]]; then
echo "This script should be run as root." > /dev/stderr
exit 1
fi
if [[ $# -eq 0 ]]; then
echo "No arguments supplied"
exit 1
fi
if [[ "$1" == "--check" ]]; then
echo "OK"
exit 0
fi
mailbox="$1"
if [[ "${BOX_ENV}" == "cloudron" ]]; then
readonly mailbox_dir="${HOME}/boxdata/mail/vmail/$1"
else
readonly mailbox_dir="${HOME}/.cloudron_test/boxdata/mail/vmail/$1"
fi
rm -rf "${mailbox_dir}"

63
src/scripts/starttask.sh Executable file
View File

@@ -0,0 +1,63 @@
#!/bin/bash
set -eu -o pipefail
readonly script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
readonly task_worker="${script_dir}/../taskworker.js"
if [[ ${EUID} -ne 0 ]]; then
echo "This script should be run as root." > /dev/stderr
exit 1
fi
if [[ $# -eq 0 ]]; then
echo "No arguments supplied"
exit 1
fi
if [[ "$1" == "--check" ]]; then
echo "OK"
exit 0
fi
readonly task_id="$1"
readonly logfile="$2"
readonly nice="$3"
readonly memory_limit_mb="$4"
readonly service_name="box-task-${task_id}"
systemctl reset-failed "${service_name}" || 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"
# 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
# DEBUG has to be hardcoded because it is not set in the tests. --setenv is required for ubuntu 16 (-E does not work)
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 \
"${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
fi
echo "Service ${service_name} finished with exit code ${exit_code}"
exit "${exit_code}"

32
src/scripts/stoptask.sh Executable file
View File

@@ -0,0 +1,32 @@
#!/bin/bash
set -eu -o pipefail
if [[ ${EUID} -ne 0 ]]; then
echo "This script should be run as root." > /dev/stderr
exit 1
fi
if [[ $# -eq 0 ]]; then
echo "No arguments supplied"
exit 1
fi
if [[ "$1" == "--check" ]]; then
echo "OK"
exit 0
fi
task_id="$1"
if [[ "${task_id}" == "all" ]]; then
systemctl list-units --full --no-legend box-task-* # just to show who was running
systemctl kill --signal=SIGTERM box-task-* || true
systemctl reset-failed box-task-* || true
systemctl stop box-task-* || true # because of remain-after-exit in Ubuntu 16 we have to deactivate the service
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
fi

View File

@@ -10,6 +10,7 @@ let assert = require('assert'),
cloudron = require('./cloudron.js'),
constants = require('./constants.js'),
database = require('./database.js'),
debug = require('debug')('box:server'),
eventlog = require('./eventlog.js'),
express = require('express'),
http = require('http'),
@@ -47,6 +48,8 @@ function initializeExpressSync() {
tokens.method(req, res),
tokens.url(req, res).replace(/(access_token=)[^&]+/, '$1' + '<redacted>'),
tokens.status(req, res),
res.errorBody ? res.errorBody.status : '', // attached by connect-lastmile. can be missing when router errors like 404
res.errorBody ? res.errorBody.message : '', // attached by connect-lastmile. can be missing when router errors like 404
tokens['response-time'](req, res), 'ms', '-',
tokens.res(req, res, 'content-length')
].join(' ');
@@ -64,7 +67,6 @@ function initializeExpressSync() {
// the timeout middleware will respond with a 503. the request itself cannot be 'aborted' and will continue
// search for req.clearTimeout in route handlers to see places where this timeout is reset
.use(middleware.timeout(REQUEST_TIMEOUT, { respond: true }))
.use(json)
.use(urlencoded)
.use(middleware.cors({ origins: [ '*' ], allowCredentials: false }))
.use(router)
@@ -84,165 +86,165 @@ function initializeExpressSync() {
const authorizeUserManager = routes.accesscontrol.authorize(users.ROLE_USER_MANAGER);
// public routes
router.post('/api/v1/cloudron/setup', routes.provision.providerTokenAuth, routes.provision.setup); // only available until no-domain
router.post('/api/v1/cloudron/restore', routes.provision.restore); // only available until activated
router.post('/api/v1/cloudron/activate', routes.provision.activate);
router.get ('/api/v1/cloudron/status', routes.provision.getStatus);
router.get ('/api/v1/cloudron/avatar', routes.branding.getCloudronAvatar); // this is a public alias for /api/v1/branding/cloudron_avatar
router.post('/api/v1/cloudron/setup', json, routes.provision.providerTokenAuth, routes.provision.setup); // only available until no-domain
router.post('/api/v1/cloudron/restore', json, routes.provision.restore); // only available until activated
router.post('/api/v1/cloudron/activate', json, routes.provision.activate);
router.get ('/api/v1/cloudron/status', routes.provision.getStatus);
router.get ('/api/v1/cloudron/avatar', routes.branding.getCloudronAvatar); // this is a public alias for /api/v1/branding/cloudron_avatar
// login/logout routes
router.post('/api/v1/cloudron/login', password, routes.cloudron.login);
router.get ('/api/v1/cloudron/logout', routes.cloudron.logout); // this will invalidate the token if any and redirect to /login.html always
router.post('/api/v1/cloudron/password_reset_request', routes.cloudron.passwordResetRequest);
router.post('/api/v1/cloudron/password_reset', routes.cloudron.passwordReset);
router.post('/api/v1/cloudron/setup_account', routes.cloudron.setupAccount);
router.post('/api/v1/cloudron/login', json, password, routes.cloudron.login);
router.get ('/api/v1/cloudron/logout', routes.cloudron.logout); // this will invalidate the token if any and redirect to /login.html always
router.post('/api/v1/cloudron/password_reset_request', json, routes.cloudron.passwordResetRequest);
router.post('/api/v1/cloudron/password_reset', json, routes.cloudron.passwordReset);
router.post('/api/v1/cloudron/setup_account', json, routes.cloudron.setupAccount);
// developer routes
router.post('/api/v1/developer/login', password, routes.cloudron.login); // DEPRECATED we should use the regular /api/v1/cloudron/login
router.post('/api/v1/developer/login', json, password, routes.cloudron.login); // DEPRECATED we should use the regular /api/v1/cloudron/login
// cloudron routes
router.get ('/api/v1/cloudron/update', token, authorizeAdmin, routes.cloudron.getUpdateInfo);
router.post('/api/v1/cloudron/update', token, authorizeAdmin, routes.cloudron.update);
router.post('/api/v1/cloudron/prepare_dashboard_domain', token, authorizeAdmin, routes.cloudron.prepareDashboardDomain);
router.post('/api/v1/cloudron/set_dashboard_domain', token, authorizeAdmin, routes.cloudron.setDashboardAndMailDomain);
router.post('/api/v1/cloudron/renew_certs', token, authorizeAdmin, routes.cloudron.renewCerts);
router.post('/api/v1/cloudron/check_for_updates', token, authorizeAdmin, routes.cloudron.checkForUpdates);
router.get ('/api/v1/cloudron/reboot', token, authorizeAdmin, routes.cloudron.isRebootRequired);
router.post('/api/v1/cloudron/reboot', token, authorizeAdmin, routes.cloudron.reboot);
router.get ('/api/v1/cloudron/graphs', token, authorizeAdmin, routes.graphs.getGraphs);
router.get ('/api/v1/cloudron/disks', token, authorizeAdmin, routes.cloudron.getDisks);
router.get ('/api/v1/cloudron/memory', token, authorizeAdmin, routes.cloudron.getMemory);
router.get ('/api/v1/cloudron/logs/:unit', token, authorizeAdmin, routes.cloudron.getLogs);
router.get ('/api/v1/cloudron/logstream/:unit', token, authorizeAdmin, routes.cloudron.getLogStream);
router.get ('/api/v1/cloudron/eventlog', token, authorizeAdmin, routes.eventlog.list);
router.get ('/api/v1/cloudron/eventlog/:eventId', token, authorizeAdmin, routes.eventlog.get);
router.post('/api/v1/cloudron/sync_external_ldap', token, authorizeAdmin, routes.cloudron.syncExternalLdap);
router.get ('/api/v1/cloudron/server_ip', token, authorizeAdmin, routes.cloudron.getServerIp);
router.get ('/api/v1/cloudron/update', token, authorizeAdmin, routes.cloudron.getUpdateInfo);
router.post('/api/v1/cloudron/update', json, token, authorizeAdmin, routes.cloudron.update);
router.post('/api/v1/cloudron/prepare_dashboard_domain', json, token, authorizeAdmin, routes.cloudron.prepareDashboardDomain);
router.post('/api/v1/cloudron/set_dashboard_domain', json, token, authorizeAdmin, routes.cloudron.setDashboardAndMailDomain);
router.post('/api/v1/cloudron/renew_certs', json, token, authorizeAdmin, routes.cloudron.renewCerts);
router.post('/api/v1/cloudron/check_for_updates', json, token, authorizeAdmin, routes.cloudron.checkForUpdates);
router.get ('/api/v1/cloudron/reboot', token, authorizeAdmin, routes.cloudron.isRebootRequired);
router.post('/api/v1/cloudron/reboot', json, token, authorizeAdmin, routes.cloudron.reboot);
router.get ('/api/v1/cloudron/graphs', token, authorizeAdmin, routes.graphs.getGraphs);
router.get ('/api/v1/cloudron/disks', token, authorizeAdmin, routes.cloudron.getDisks);
router.get ('/api/v1/cloudron/memory', token, authorizeAdmin, routes.cloudron.getMemory);
router.get ('/api/v1/cloudron/logs/:unit', token, authorizeAdmin, routes.cloudron.getLogs);
router.get ('/api/v1/cloudron/logstream/:unit', token, authorizeAdmin, routes.cloudron.getLogStream);
router.get ('/api/v1/cloudron/eventlog', token, authorizeAdmin, routes.eventlog.list);
router.get ('/api/v1/cloudron/eventlog/:eventId', token, authorizeAdmin, routes.eventlog.get);
router.post('/api/v1/cloudron/sync_external_ldap', json, token, authorizeAdmin, routes.cloudron.syncExternalLdap);
router.get ('/api/v1/cloudron/server_ip', token, authorizeAdmin, routes.cloudron.getServerIp);
// tasks
router.get ('/api/v1/tasks', token, authorizeAdmin, routes.tasks.list);
router.get ('/api/v1/tasks/:taskId', token, authorizeAdmin, routes.tasks.get);
router.get ('/api/v1/tasks/:taskId/logs', token, authorizeAdmin, routes.tasks.getLogs);
router.get ('/api/v1/tasks/:taskId/logstream', token, authorizeAdmin, routes.tasks.getLogStream);
router.post('/api/v1/tasks/:taskId/stop', token, authorizeAdmin, routes.tasks.stopTask);
// task routes
router.get ('/api/v1/tasks', token, authorizeAdmin, routes.tasks.list);
router.get ('/api/v1/tasks/:taskId', token, authorizeAdmin, routes.tasks.get);
router.get ('/api/v1/tasks/:taskId/logs', token, authorizeAdmin, routes.tasks.getLogs);
router.get ('/api/v1/tasks/:taskId/logstream', token, authorizeAdmin, routes.tasks.getLogStream);
router.post('/api/v1/tasks/:taskId/stop', json, token, authorizeAdmin, routes.tasks.stopTask);
// notifications
router.get ('/api/v1/notifications', token, routes.notifications.verifyOwnership, routes.notifications.list);
router.get ('/api/v1/notifications/:notificationId', token, routes.notifications.verifyOwnership, routes.notifications.get);
router.post('/api/v1/notifications/:notificationId', token, routes.notifications.verifyOwnership, routes.notifications.ack);
// notification routes
router.get ('/api/v1/notifications', token, routes.notifications.verifyOwnership, routes.notifications.list);
router.get ('/api/v1/notifications/:notificationId', token, routes.notifications.verifyOwnership, routes.notifications.get);
router.post('/api/v1/notifications/:notificationId', json, token, routes.notifications.verifyOwnership, routes.notifications.ack);
// backups
router.get ('/api/v1/backups', token, authorizeAdmin, routes.backups.list);
router.post('/api/v1/backups/create', token, authorizeAdmin, routes.backups.startBackup);
router.post('/api/v1/backups/cleanup', token, authorizeAdmin, routes.backups.cleanup);
// backup routes
router.get ('/api/v1/backups', token, authorizeAdmin, routes.backups.list);
router.post('/api/v1/backups/create', token, authorizeAdmin, routes.backups.startBackup);
router.post('/api/v1/backups/cleanup', json, token, authorizeAdmin, routes.backups.cleanup);
router.get ('/api/v1/backups/check', token, authorizeAdmin, routes.backups.check);
// config route (for dashboard)
// config route (for dashboard). can return some private configuration unlike status
router.get ('/api/v1/config', token, routes.cloudron.getConfig);
// working off the user behind the provided token
router.get ('/api/v1/profile', token, routes.profile.get);
router.post('/api/v1/profile', token, routes.profile.update);
router.get ('/api/v1/profile/avatar/:identifier', routes.profile.getAvatar); // this is not scoped so it can used directly in img tag
router.post('/api/v1/profile/avatar', token, multipart, routes.profile.setAvatar);
router.del ('/api/v1/profile/avatar', token, routes.profile.clearAvatar);
router.post('/api/v1/profile/password', token, routes.users.verifyPassword, routes.profile.changePassword);
router.post('/api/v1/profile/twofactorauthentication', token, routes.profile.setTwoFactorAuthenticationSecret);
router.post('/api/v1/profile/twofactorauthentication/enable', token, routes.profile.enableTwoFactorAuthentication);
router.post('/api/v1/profile/twofactorauthentication/disable', token, routes.users.verifyPassword, routes.profile.disableTwoFactorAuthentication);
router.get ('/api/v1/profile', token, routes.profile.get);
router.post('/api/v1/profile', json, token, routes.profile.authorize, routes.profile.update);
router.get ('/api/v1/profile/avatar/:identifier', routes.profile.getAvatar); // this is not scoped so it can used directly in img tag
router.post('/api/v1/profile/avatar', json, token, multipart, routes.profile.setAvatar); // avatar is not exposed in LDAP. so it's personal and not locked
router.del ('/api/v1/profile/avatar', token, routes.profile.clearAvatar);
router.post('/api/v1/profile/password', json, token, routes.users.verifyPassword, routes.profile.changePassword);
router.post('/api/v1/profile/twofactorauthentication', json, token, routes.profile.setTwoFactorAuthenticationSecret);
router.post('/api/v1/profile/twofactorauthentication/enable', json, token, routes.profile.enableTwoFactorAuthentication);
router.post('/api/v1/profile/twofactorauthentication/disable', json, token, routes.users.verifyPassword, routes.profile.disableTwoFactorAuthentication);
router.get ('/api/v1/app_passwords', token, routes.appPasswords.list);
router.post('/api/v1/app_passwords', token, routes.appPasswords.add);
router.get ('/api/v1/app_passwords/:id', token, routes.appPasswords.get);
router.del ('/api/v1/app_passwords/:id', token, routes.appPasswords.del);
// app password routes
router.get ('/api/v1/app_passwords', token, routes.appPasswords.list);
router.post('/api/v1/app_passwords', json, token, routes.appPasswords.add);
router.get ('/api/v1/app_passwords/:id', token, routes.appPasswords.get);
router.del ('/api/v1/app_passwords/:id', token, routes.appPasswords.del);
// access tokens
router.get ('/api/v1/tokens', token, routes.tokens.getAll);
router.post('/api/v1/tokens', token, routes.tokens.add);
router.get ('/api/v1/tokens/:id', token, routes.tokens.verifyOwnership, routes.tokens.get);
router.del ('/api/v1/tokens/:id', token, routes.tokens.verifyOwnership, routes.tokens.del);
router.get ('/api/v1/tokens', token, routes.tokens.getAll);
router.post('/api/v1/tokens', json, token, routes.tokens.add);
router.get ('/api/v1/tokens/:id', token, routes.tokens.verifyOwnership, routes.tokens.get);
router.del ('/api/v1/tokens/:id', token, routes.tokens.verifyOwnership, routes.tokens.del);
// user routes
router.get ('/api/v1/users', token, authorizeUserManager, routes.users.list);
router.post('/api/v1/users', token, authorizeUserManager, routes.users.create);
router.get ('/api/v1/users/:userId', token, authorizeUserManager, routes.users.load, routes.users.get); // this is manage scope because it returns non-restricted fields
router.del ('/api/v1/users/:userId', token, authorizeUserManager, routes.users.load, routes.users.remove);
router.post('/api/v1/users/:userId', token, authorizeUserManager, routes.users.load, routes.users.update);
router.post('/api/v1/users/:userId/password', token, authorizeUserManager, routes.users.load, routes.users.changePassword);
router.put ('/api/v1/users/:userId/groups', token, authorizeUserManager, routes.users.load, routes.users.setGroups);
router.post('/api/v1/users/:userId/send_invite', token, authorizeUserManager, routes.users.load, routes.users.sendInvite);
router.post('/api/v1/users/:userId/create_invite', token, authorizeUserManager, routes.users.load, routes.users.createInvite);
router.get ('/api/v1/users', token, authorizeUserManager, routes.users.list);
router.post('/api/v1/users', json, token, authorizeUserManager, routes.users.create);
router.get ('/api/v1/users/:userId', token, authorizeUserManager, routes.users.load, routes.users.get); // this is manage scope because it returns non-restricted fields
router.del ('/api/v1/users/:userId', token, authorizeUserManager, routes.users.load, routes.users.remove);
router.post('/api/v1/users/:userId', json, token, authorizeUserManager, routes.users.load, routes.users.update);
router.post('/api/v1/users/:userId/password', json, token, authorizeUserManager, routes.users.load, routes.users.changePassword);
router.put ('/api/v1/users/:userId/groups', json, token, authorizeUserManager, routes.users.load, routes.users.setGroups);
router.post('/api/v1/users/:userId/send_invite', json, token, authorizeUserManager, routes.users.load, routes.users.sendInvite);
router.post('/api/v1/users/:userId/create_invite', json, token, authorizeUserManager, routes.users.load, routes.users.createInvite);
router.post('/api/v1/users/:userId/avatar', json, token, authorizeUserManager, routes.users.load, multipart, routes.users.setAvatar);
router.del ('/api/v1/users/:userId/avatar', token, authorizeUserManager, routes.users.load, routes.users.clearAvatar);
// Group management
router.get ('/api/v1/groups', token, authorizeUserManager, routes.groups.list);
router.post('/api/v1/groups', token, authorizeUserManager, routes.groups.create);
router.get ('/api/v1/groups/:groupId', token, authorizeUserManager, routes.groups.get);
router.put ('/api/v1/groups/:groupId/members', token, authorizeUserManager, routes.groups.updateMembers);
router.post('/api/v1/groups/:groupId', token, authorizeUserManager, routes.groups.update);
router.del ('/api/v1/groups/:groupId', token, authorizeUserManager, routes.groups.remove);
router.get ('/api/v1/groups', token, authorizeUserManager, routes.groups.list);
router.post('/api/v1/groups', json, token, authorizeUserManager, routes.groups.create);
router.get ('/api/v1/groups/:groupId', token, authorizeUserManager, routes.groups.get);
router.put ('/api/v1/groups/:groupId/members', json, token, authorizeUserManager, routes.groups.updateMembers);
router.post('/api/v1/groups/:groupId', json, token, authorizeUserManager, routes.groups.update);
router.del ('/api/v1/groups/:groupId', token, authorizeUserManager, routes.groups.remove);
// appstore and subscription routes
router.post('/api/v1/appstore/register_cloudron', token, authorizeAdmin, routes.appstore.registerCloudron);
router.post('/api/v1/appstore/user_token', token, authorizeAdmin, routes.appstore.createUserToken);
router.get ('/api/v1/appstore/subscription', token, authorizeAdmin, routes.appstore.getSubscription);
router.get ('/api/v1/appstore/apps', token, authorizeAdmin, routes.appstore.getApps);
router.get ('/api/v1/appstore/apps/:appstoreId', token, authorizeAdmin, routes.appstore.getApp);
router.get ('/api/v1/appstore/apps/:appstoreId/versions/:versionId', token, authorizeAdmin, routes.appstore.getAppVersion);
router.post('/api/v1/appstore/register_cloudron', json, token, authorizeAdmin, routes.appstore.registerCloudron);
router.post('/api/v1/appstore/user_token', json, token, authorizeAdmin, routes.appstore.createUserToken);
router.get ('/api/v1/appstore/subscription', token, authorizeAdmin, routes.appstore.getSubscription);
router.get ('/api/v1/appstore/apps', token, authorizeAdmin, routes.appstore.getApps);
router.get ('/api/v1/appstore/apps/:appstoreId', token, authorizeAdmin, routes.appstore.getApp);
router.get ('/api/v1/appstore/apps/:appstoreId/versions/:versionId', token, authorizeAdmin, routes.appstore.getAppVersion);
// app routes
router.get ('/api/v1/apps', token, routes.apps.getApps);
router.get ('/api/v1/apps/:id', token, authorizeAdmin, routes.apps.load, routes.apps.getApp);
router.get ('/api/v1/apps/:id/icon', token, routes.apps.load, routes.apps.getAppIcon);
router.post('/api/v1/apps/install', token, authorizeAdmin, routes.apps.install);
router.post('/api/v1/apps/:id/uninstall', token, authorizeAdmin, routes.apps.load, routes.apps.uninstall);
router.post('/api/v1/apps/:id/configure/access_restriction', token, authorizeAdmin, routes.apps.load, routes.apps.setAccessRestriction);
router.post('/api/v1/apps/:id/configure/label', token, authorizeAdmin, routes.apps.load, routes.apps.setLabel);
router.post('/api/v1/apps/:id/configure/tags', token, authorizeAdmin, routes.apps.load, routes.apps.setTags);
router.post('/api/v1/apps/:id/configure/icon', token, authorizeAdmin, routes.apps.load, routes.apps.setIcon);
router.post('/api/v1/apps/:id/configure/memory_limit', token, authorizeAdmin, routes.apps.load, routes.apps.setMemoryLimit);
router.post('/api/v1/apps/:id/configure/cpu_shares', token, authorizeAdmin, routes.apps.load, routes.apps.setCpuShares);
router.post('/api/v1/apps/:id/configure/automatic_backup', token, authorizeAdmin, routes.apps.load, routes.apps.setAutomaticBackup);
router.post('/api/v1/apps/:id/configure/automatic_update', token, authorizeAdmin, routes.apps.load, routes.apps.setAutomaticUpdate);
router.post('/api/v1/apps/:id/configure/reverse_proxy', token, authorizeAdmin, routes.apps.load, routes.apps.setReverseProxyConfig);
router.post('/api/v1/apps/:id/configure/cert', token, authorizeAdmin, routes.apps.load, routes.apps.setCertificate);
router.post('/api/v1/apps/:id/configure/debug_mode', token, authorizeAdmin, routes.apps.load, routes.apps.setDebugMode);
router.post('/api/v1/apps/:id/configure/mailbox', token, authorizeAdmin, routes.apps.load, routes.apps.setMailbox);
router.post('/api/v1/apps/:id/configure/env', token, authorizeAdmin, routes.apps.load, routes.apps.setEnvironment);
router.post('/api/v1/apps/:id/configure/data_dir', token, authorizeAdmin, routes.apps.load, routes.apps.setDataDir);
router.post('/api/v1/apps/:id/configure/location', token, authorizeAdmin, routes.apps.load, routes.apps.setLocation);
router.post('/api/v1/apps/:id/configure/binds', token, authorizeAdmin, routes.apps.load, routes.apps.setBinds);
router.post('/api/v1/apps/:id/repair', token, authorizeAdmin, routes.apps.load, routes.apps.repair);
router.post('/api/v1/apps/:id/update', token, authorizeAdmin, routes.apps.load, routes.apps.update);
router.post('/api/v1/apps/:id/restore', token, authorizeAdmin, routes.apps.load, routes.apps.restore);
router.post('/api/v1/apps/:id/import', token, authorizeAdmin, routes.apps.load, routes.apps.importApp);
router.post('/api/v1/apps/:id/backup', token, authorizeAdmin, routes.apps.load, routes.apps.backup);
router.get ('/api/v1/apps/:id/backups', token, authorizeAdmin, routes.apps.load, routes.apps.listBackups);
router.post('/api/v1/apps/:id/start', token, authorizeAdmin, routes.apps.load, routes.apps.start);
router.post('/api/v1/apps/:id/stop', token, authorizeAdmin, routes.apps.load, routes.apps.stop);
router.post('/api/v1/apps/:id/restart', token, authorizeAdmin, routes.apps.load, routes.apps.restart);
router.get ('/api/v1/apps/:id/logstream', token, authorizeAdmin, routes.apps.load, routes.apps.getLogStream);
router.get ('/api/v1/apps/:id/logs', token, authorizeAdmin, routes.apps.load, routes.apps.getLogs);
router.get ('/api/v1/apps/:id/exec', token, authorizeAdmin, routes.apps.load, routes.apps.exec);
router.post('/api/v1/apps/install', json, token, authorizeAdmin, routes.apps.install);
router.get ('/api/v1/apps', token, routes.apps.getApps);
router.get ('/api/v1/apps/:id', token, authorizeAdmin, routes.apps.load, routes.apps.getApp);
router.get ('/api/v1/apps/:id/icon', token, routes.apps.load, routes.apps.getAppIcon);
router.post('/api/v1/apps/:id/uninstall', json, token, authorizeAdmin, routes.apps.load, routes.apps.uninstall);
router.post('/api/v1/apps/:id/configure/access_restriction', json, token, authorizeAdmin, routes.apps.load, routes.apps.setAccessRestriction);
router.post('/api/v1/apps/:id/configure/label', json, token, authorizeAdmin, routes.apps.load, routes.apps.setLabel);
router.post('/api/v1/apps/:id/configure/tags', json, token, authorizeAdmin, routes.apps.load, routes.apps.setTags);
router.post('/api/v1/apps/:id/configure/icon', json, token, authorizeAdmin, routes.apps.load, routes.apps.setIcon);
router.post('/api/v1/apps/:id/configure/memory_limit', json, token, authorizeAdmin, routes.apps.load, routes.apps.setMemoryLimit);
router.post('/api/v1/apps/:id/configure/cpu_shares', json, token, authorizeAdmin, routes.apps.load, routes.apps.setCpuShares);
router.post('/api/v1/apps/:id/configure/automatic_backup', json, token, authorizeAdmin, routes.apps.load, routes.apps.setAutomaticBackup);
router.post('/api/v1/apps/:id/configure/automatic_update', json, token, authorizeAdmin, routes.apps.load, routes.apps.setAutomaticUpdate);
router.post('/api/v1/apps/:id/configure/reverse_proxy', json, token, authorizeAdmin, routes.apps.load, routes.apps.setReverseProxyConfig);
router.post('/api/v1/apps/:id/configure/cert', json, token, authorizeAdmin, routes.apps.load, routes.apps.setCertificate);
router.post('/api/v1/apps/:id/configure/debug_mode', json, token, authorizeAdmin, routes.apps.load, routes.apps.setDebugMode);
router.post('/api/v1/apps/:id/configure/mailbox', json, token, authorizeAdmin, routes.apps.load, routes.apps.setMailbox);
router.post('/api/v1/apps/:id/configure/env', json, token, authorizeAdmin, routes.apps.load, routes.apps.setEnvironment);
router.post('/api/v1/apps/:id/configure/data_dir', json, token, authorizeAdmin, routes.apps.load, routes.apps.setDataDir);
router.post('/api/v1/apps/:id/configure/location', json, token, authorizeAdmin, routes.apps.load, routes.apps.setLocation);
router.post('/api/v1/apps/:id/configure/binds', json, token, authorizeAdmin, routes.apps.load, routes.apps.setBinds);
router.post('/api/v1/apps/:id/repair', json, token, authorizeAdmin, routes.apps.load, routes.apps.repair);
router.post('/api/v1/apps/:id/update', json, token, authorizeAdmin, routes.apps.load, routes.apps.update);
router.post('/api/v1/apps/:id/restore', json, token, authorizeAdmin, routes.apps.load, routes.apps.restore);
router.post('/api/v1/apps/:id/import', json, token, authorizeAdmin, routes.apps.load, routes.apps.importApp);
router.post('/api/v1/apps/:id/backup', json, token, authorizeAdmin, routes.apps.load, routes.apps.backup);
router.get ('/api/v1/apps/:id/backups', token, authorizeAdmin, routes.apps.load, routes.apps.listBackups);
router.post('/api/v1/apps/:id/start', json, token, authorizeAdmin, routes.apps.load, routes.apps.start);
router.post('/api/v1/apps/:id/stop', json, token, authorizeAdmin, routes.apps.load, routes.apps.stop);
router.post('/api/v1/apps/:id/restart', json, token, authorizeAdmin, routes.apps.load, routes.apps.restart);
router.get ('/api/v1/apps/:id/logstream', token, authorizeAdmin, routes.apps.load, routes.apps.getLogStream);
router.get ('/api/v1/apps/:id/logs', token, authorizeAdmin, routes.apps.load, routes.apps.getLogs);
router.post('/api/v1/apps/:id/clone', json, token, authorizeAdmin, routes.apps.load, routes.apps.clone);
router.get ('/api/v1/apps/:id/download', token, authorizeAdmin, routes.apps.load, routes.apps.downloadFile);
router.post('/api/v1/apps/:id/upload', json, token, authorizeAdmin, multipart, routes.apps.load, routes.apps.uploadFile);
router.use ('/api/v1/apps/:id/files/*', token, authorizeAdmin, routes.filemanager.proxy);
router.get ('/api/v1/apps/:id/exec', token, authorizeAdmin, routes.apps.load, routes.apps.exec);
// websocket cannot do bearer authentication
router.get ('/api/v1/apps/:id/execws', routes.accesscontrol.websocketAuth.bind(null, users.ROLE_ADMIN), routes.apps.load, routes.apps.execWebSocket);
router.post('/api/v1/apps/:id/clone', token, authorizeAdmin, routes.apps.load, routes.apps.clone);
router.get ('/api/v1/apps/:id/download', token, authorizeAdmin, routes.apps.load, routes.apps.downloadFile);
router.post('/api/v1/apps/:id/upload', token, authorizeAdmin, multipart, routes.apps.load, routes.apps.uploadFile);
router.get ('/api/v1/branding/:setting', token, authorizeOwner, routes.branding.get);
router.post('/api/v1/branding/:setting', token, authorizeOwner, (req, res, next) => {
// branding routes
router.get ('/api/v1/branding/:setting', token, authorizeOwner, routes.branding.get);
router.post('/api/v1/branding/:setting', json, token, authorizeOwner, (req, res, next) => {
return req.params.setting === 'cloudron_avatar' ? multipart(req, res, next) : next();
}, routes.branding.set);
// settings routes (these are for the settings tab - avatar & name have public routes for normal users. see above)
router.get ('/api/v1/settings/:setting', token, authorizeAdmin, routes.settings.get);
router.post('/api/v1/settings/backup_config', token, authorizeOwner, routes.settings.setBackupConfig);
router.post('/api/v1/settings/:setting', token, authorizeAdmin, (req, res, next) => {
return req.params.setting === 'cloudron_avatar' ? multipart(req, res, next) : next();
}, routes.settings.set);
router.get ('/api/v1/settings/:setting', token, authorizeAdmin, routes.settings.get);
router.post('/api/v1/settings/backup_config', json, token, authorizeOwner, routes.settings.setBackupConfig);
router.post('/api/v1/settings/:setting', json, token, authorizeAdmin, routes.settings.set);
// email routes
router.get('/api/v1/mailserver/:pathname', token, (req, res, next) => {
@@ -253,48 +255,48 @@ function initializeExpressSync() {
authorizeAdmin(req, res, next);
}, routes.mailserver.proxy);
router.get ('/api/v1/mail/:domain', token, authorizeAdmin, routes.mail.getDomain);
router.get ('/api/v1/mail/:domain/status', token, authorizeAdmin, routes.mail.getStatus);
router.post('/api/v1/mail/:domain/mail_from_validation', token, authorizeAdmin, routes.mail.setMailFromValidation);
router.post('/api/v1/mail/:domain/catch_all', token, authorizeAdmin, routes.mail.setCatchAllAddress);
router.post('/api/v1/mail/:domain/relay', token, authorizeAdmin, routes.mail.setMailRelay);
router.post('/api/v1/mail/:domain/enable', token, authorizeAdmin, routes.mail.setMailEnabled);
router.post('/api/v1/mail/:domain/dns', token, authorizeAdmin, routes.mail.setDnsRecords);
router.post('/api/v1/mail/:domain/send_test_mail', token, authorizeAdmin, routes.mail.sendTestMail);
router.get ('/api/v1/mail/:domain/mailboxes', token, authorizeAdmin, routes.mail.listMailboxes);
router.get ('/api/v1/mail/:domain/mailboxes/:name', token, authorizeAdmin, routes.mail.getMailbox);
router.post('/api/v1/mail/:domain/mailboxes', token, authorizeAdmin, routes.mail.addMailbox);
router.post('/api/v1/mail/:domain/mailboxes/:name', token, authorizeAdmin, routes.mail.updateMailbox);
router.del ('/api/v1/mail/:domain/mailboxes/:name', token, authorizeAdmin, routes.mail.removeMailbox);
router.get ('/api/v1/mail/:domain/mailboxes/:name/aliases', token, authorizeAdmin, routes.mail.getAliases);
router.put ('/api/v1/mail/:domain/mailboxes/:name/aliases', token, authorizeAdmin, routes.mail.setAliases);
router.get ('/api/v1/mail/:domain', token, authorizeAdmin, routes.mail.getDomain);
router.get ('/api/v1/mail/:domain/status', token, authorizeAdmin, routes.mail.getStatus);
router.post('/api/v1/mail/:domain/mail_from_validation', json, token, authorizeAdmin, routes.mail.setMailFromValidation);
router.post('/api/v1/mail/:domain/catch_all', json, token, authorizeAdmin, routes.mail.setCatchAllAddress);
router.post('/api/v1/mail/:domain/relay', json, token, authorizeAdmin, routes.mail.setMailRelay);
router.post('/api/v1/mail/:domain/enable', json, token, authorizeAdmin, routes.mail.setMailEnabled);
router.post('/api/v1/mail/:domain/dns', json, token, authorizeAdmin, routes.mail.setDnsRecords);
router.post('/api/v1/mail/:domain/send_test_mail', json, token, authorizeAdmin, routes.mail.sendTestMail);
router.get ('/api/v1/mail/:domain/mailbox_count', token, authorizeAdmin, routes.mail.getMailboxCount);
router.get ('/api/v1/mail/:domain/mailboxes', token, authorizeAdmin, routes.mail.listMailboxes);
router.get ('/api/v1/mail/:domain/mailboxes/:name', token, authorizeAdmin, routes.mail.getMailbox);
router.post('/api/v1/mail/:domain/mailboxes', json, token, authorizeAdmin, routes.mail.addMailbox);
router.post('/api/v1/mail/:domain/mailboxes/:name', json, token, authorizeAdmin, routes.mail.updateMailbox);
router.del ('/api/v1/mail/:domain/mailboxes/:name', json, token, authorizeAdmin, routes.mail.removeMailbox);
router.get ('/api/v1/mail/:domain/mailboxes/:name/aliases', token, authorizeAdmin, routes.mail.getAliases);
router.put ('/api/v1/mail/:domain/mailboxes/:name/aliases', json, token, authorizeAdmin, routes.mail.setAliases);
router.get ('/api/v1/mail/:domain/lists', token, authorizeAdmin, routes.mail.getLists);
router.post('/api/v1/mail/:domain/lists', json, token, authorizeAdmin, routes.mail.addList);
router.get ('/api/v1/mail/:domain/lists/:name', token, authorizeAdmin, routes.mail.getList);
router.post('/api/v1/mail/:domain/lists/:name', json, token, authorizeAdmin, routes.mail.updateList);
router.del ('/api/v1/mail/:domain/lists/:name', token, authorizeAdmin, routes.mail.removeList);
router.get ('/api/v1/mail/:domain/lists', token, authorizeAdmin, routes.mail.getLists);
router.post('/api/v1/mail/:domain/lists', token, authorizeAdmin, routes.mail.addList);
router.get ('/api/v1/mail/:domain/lists/:name', token, authorizeAdmin, routes.mail.getList);
router.post('/api/v1/mail/:domain/lists/:name', token, authorizeAdmin, routes.mail.updateList);
router.del ('/api/v1/mail/:domain/lists/:name', token, authorizeAdmin, routes.mail.removeList);
// support
router.post('/api/v1/support/ticket', token, authorizeAdmin, routes.support.canCreateTicket, routes.support.createTicket);
router.get ('/api/v1/support/remote_support', token, authorizeAdmin, routes.support.getRemoteSupport);
router.post('/api/v1/support/remote_support', token, authorizeAdmin, routes.support.canEnableRemoteSupport, routes.support.enableRemoteSupport);
// support routes
router.post('/api/v1/support/ticket', json, token, authorizeAdmin, routes.support.canCreateTicket, routes.support.createTicket);
router.get ('/api/v1/support/remote_support', token, authorizeAdmin, routes.support.getRemoteSupport);
router.post('/api/v1/support/remote_support', json, token, authorizeAdmin, routes.support.canEnableRemoteSupport, routes.support.enableRemoteSupport);
// domain routes
router.post('/api/v1/domains', token, authorizeAdmin, routes.domains.add);
router.get ('/api/v1/domains', token, routes.domains.getAll);
router.get ('/api/v1/domains/:domain', token, authorizeAdmin, routes.domains.get); // this is manage scope because it returns non-restricted fields
router.put ('/api/v1/domains/:domain', token, authorizeAdmin, routes.domains.update);
router.del ('/api/v1/domains/:domain', token, authorizeAdmin, routes.domains.del);
router.get ('/api/v1/domains/:domain/dns_check', token, authorizeAdmin, routes.domains.checkDnsRecords);
router.post('/api/v1/domains', json, token, authorizeAdmin, routes.domains.add);
router.get ('/api/v1/domains', token, routes.domains.getAll);
router.get ('/api/v1/domains/:domain', token, authorizeAdmin, routes.domains.get); // this is manage scope because it returns non-restricted fields
router.put ('/api/v1/domains/:domain', json, token, authorizeAdmin, routes.domains.update);
router.del ('/api/v1/domains/:domain', token, authorizeAdmin, routes.domains.del);
router.get ('/api/v1/domains/:domain/dns_check', token, authorizeAdmin, routes.domains.checkDnsRecords);
// addon routes
router.get ('/api/v1/services', token, authorizeAdmin, routes.services.getAll);
router.get ('/api/v1/services/:service', token, authorizeAdmin, routes.services.get);
router.post('/api/v1/services/:service', token, authorizeAdmin, routes.services.configure);
router.get ('/api/v1/services/:service/logs', token, authorizeAdmin, routes.services.getLogs);
router.get ('/api/v1/services/:service/logstream', token, authorizeAdmin, routes.services.getLogStream);
router.post('/api/v1/services/:service/restart', token, authorizeAdmin, routes.services.restart);
router.get ('/api/v1/services', token, authorizeAdmin, routes.services.getAll);
router.get ('/api/v1/services/:service', token, authorizeAdmin, routes.services.get);
router.post('/api/v1/services/:service', json, token, authorizeAdmin, routes.services.configure);
router.get ('/api/v1/services/:service/logs', token, authorizeAdmin, routes.services.getLogs);
router.get ('/api/v1/services/:service/logstream', token, authorizeAdmin, routes.services.getLogStream);
router.post('/api/v1/services/:service/restart', json, token, authorizeAdmin, routes.services.restart);
// disable server socket "idle" timeout. we use the timeout middleware to handle timeouts on a route level
// we rely on nginx for timeouts on the TCP level (see client_header_timeout)
@@ -331,6 +333,10 @@ function start(callback) {
assert.strictEqual(typeof callback, 'function');
assert.strictEqual(gHttpServer, null, 'Server is already up and running.');
debug('==========================================');
debug(` Cloudron ${constants.VERSION} `);
debug('==========================================');
gHttpServer = initializeExpressSync();
async.series([

View File

@@ -50,6 +50,9 @@ exports = module.exports = {
getFooter: getFooter,
setFooter: setFooter,
getDirectoryConfig: getDirectoryConfig,
setDirectoryConfig: setDirectoryConfig,
getAppstoreListingConfig: getAppstoreListingConfig,
setAppstoreListingConfig: setAppstoreListingConfig,
@@ -86,6 +89,7 @@ exports = module.exports = {
SYSINFO_CONFIG_KEY: 'sysinfo_config',
APPSTORE_LISTING_CONFIG_KEY: 'appstore_listing_config',
SUPPORT_CONFIG_KEY: 'support_config',
DIRECTORY_CONFIG_KEY: 'directory_config',
// strings
APP_AUTOUPDATE_PATTERN_KEY: 'app_autoupdate_pattern',
@@ -131,8 +135,8 @@ var addons = require('./addons.js'),
let gDefaults = (function () {
var result = { };
result[exports.APP_AUTOUPDATE_PATTERN_KEY] = '00 30 1,3,5,23 * * *';
result[exports.BOX_AUTOUPDATE_PATTERN_KEY] = '00 00 1,3,5,23 * * *';
result[exports.APP_AUTOUPDATE_PATTERN_KEY] = cron.DEFAULT_APP_AUTOUPDATE_PATTERN;
result[exports.BOX_AUTOUPDATE_PATTERN_KEY] = cron.DEFAULT_BOX_AUTOUPDATE_PATTERN;
result[exports.TIME_ZONE_KEY] = 'America/Los_Angeles';
result[exports.CLOUDRON_NAME_KEY] = 'Cloudron';
result[exports.DYNAMIC_DNS_KEY] = false;
@@ -146,7 +150,7 @@ let gDefaults = (function () {
format: 'tgz',
encryption: null,
retentionPolicy: { keepWithinSecs: 2 * 24 * 60 * 60 }, // 2 days
intervalSecs: 24 * 60 * 60 // ~1 day
schedulePattern: '00 00 23 * * *' // every day at 11pm
};
result[exports.PLATFORM_CONFIG_KEY] = {};
result[exports.EXTERNAL_LDAP_KEY] = {
@@ -157,6 +161,11 @@ let gDefaults = (function () {
result[exports.SYSINFO_CONFIG_KEY] = {
provider: 'generic'
};
result[exports.DIRECTORY_CONFIG_KEY] = {
lockUserProfiles: false,
mandatory2FA: false
};
result[exports.ADMIN_DOMAIN_KEY] = '';
result[exports.ADMIN_FQDN_KEY] = '';
result[exports.API_SERVER_ORIGIN_KEY] = 'https://api.cloudron.io';
@@ -407,7 +416,11 @@ function setBackupConfig(backupConfig, callback) {
delete backupConfig.password;
}
backups.cleanupCacheFilesSync();
// if any of these changes, we have to clear the cache
if ([ 'format', 'provider', 'prefix', 'bucket', 'region', 'endpoint', 'backupFolder', 'mountPoint', 'encryption' ].some(p => backupConfig[p] !== currentConfig[p])) {
debug('setBackupConfig: clearing backup cache');
backups.cleanupCacheFilesSync();
}
settingsdb.set(exports.BACKUP_CONFIG_KEY, JSON.stringify(backupConfig), function (error) {
if (error) return callback(error);
@@ -428,7 +441,7 @@ function setBackupCredentials(credentials, callback) {
if (error) return callback(error);
// preserve these fields
const extra = _.pick(currentConfig, 'retentionPolicy', 'intervalSecs', 'copyConcurrency', 'syncConcurrency');
const extra = _.pick(currentConfig, 'retentionPolicy', 'schedulePattern', 'copyConcurrency', 'syncConcurrency', 'memoryLimit', 'downloadConcurrency', 'deleteConcurrency');
const backupConfig = _.extend({}, credentials, extra);
@@ -490,6 +503,8 @@ function setExternalLdapConfig(externalLdapConfig, callback) {
assert.strictEqual(typeof externalLdapConfig, 'object');
assert.strictEqual(typeof callback, 'function');
if (isDemo()) return callback(new BoxError(BoxError.BAD_FIELD, 'Not allowed in demo mode'));
getExternalLdapConfig(function (error, currentConfig) {
if (error) return callback(error);
@@ -573,6 +588,32 @@ function setSysinfoConfig(sysinfoConfig, callback) {
});
}
function getDirectoryConfig(callback) {
assert.strictEqual(typeof callback, 'function');
settingsdb.get(exports.DIRECTORY_CONFIG_KEY, function (error, value) {
if (error && error.reason === BoxError.NOT_FOUND) return callback(null, gDefaults[exports.DIRECTORY_CONFIG_KEY]);
if (error) return callback(error);
callback(null, JSON.parse(value));
});
}
function setDirectoryConfig(directoryConfig, callback) {
assert.strictEqual(typeof directoryConfig, 'object');
assert.strictEqual(typeof callback, 'function');
if (isDemo()) return callback(new BoxError(BoxError.BAD_FIELD, 'Not allowed in demo mode'));
settingsdb.set(exports.DIRECTORY_CONFIG_KEY, JSON.stringify(directoryConfig), function (error) {
if (error) return callback(error);
notifyChange(exports.DIRECTORY_CONFIG_KEY, directoryConfig);
callback(null);
});
}
function getAppstoreListingConfig(callback) {
assert.strictEqual(typeof callback, 'function');
@@ -696,7 +737,7 @@ function getAll(callback) {
result[exports.DEMO_KEY] = !!result[exports.DEMO_KEY];
// convert JSON objects
[exports.BACKUP_CONFIG_KEY, exports.PLATFORM_CONFIG_KEY, exports.EXTERNAL_LDAP_KEY, exports.REGISTRY_CONFIG_KEY, exports.SYSINFO_CONFIG_KEY ].forEach(function (key) {
[exports.BACKUP_CONFIG_KEY, exports.DIRECTORY_CONFIG_KEY, exports.PLATFORM_CONFIG_KEY, exports.EXTERNAL_LDAP_KEY, exports.REGISTRY_CONFIG_KEY, exports.SYSINFO_CONFIG_KEY ].forEach(function (key) {
result[key] = typeof result[key] === 'object' ? result[key] : safe.JSON.parse(result[key]);
});

View File

@@ -1,40 +1,88 @@
'use strict';
exports = module.exports = {
startSftp: startSftp
startSftp: startSftp,
rebuild: rebuild
};
var assert = require('assert'),
var apps = require('./apps.js'),
assert = require('assert'),
async = require('async'),
debug = require('debug')('box:sftp'),
infra = require('./infra_version.js'),
paths = require('./paths.js'),
safe = require('safetydance'),
shell = require('./shell.js');
function startSftp(existingInfra, callback) {
assert.strictEqual(typeof existingInfra, 'object');
assert.strictEqual(typeof callback, 'function');
if (existingInfra.version === infra.version && infra.images.sftp.tag === existingInfra.images.sftp.tag) return callback();
rebuild({}, callback);
}
// options only supports ignoredApps = [ appId ]
function rebuild(options, callback) {
assert.strictEqual(typeof options, 'object');
assert.strictEqual(typeof callback, 'function');
if (options.ignoredApps) assert(Array.isArray(options.ignoredApps), 'Expecting ignoredApps to be an array');
debug('rebuilding container');
const tag = infra.images.sftp.tag;
const memoryLimit = 256;
if (existingInfra.version === infra.version && infra.images.sftp.tag === existingInfra.images.sftp.tag) return callback();
apps.getAll(function (error, result) {
if (error) return callback(error);
const cmd = `docker run --restart=always -d --name="sftp" \
--hostname sftp \
--net cloudron \
--net-alias sftp \
--log-driver syslog \
--log-opt syslog-address=udp://127.0.0.1:2514 \
--log-opt syslog-format=rfc5424 \
--log-opt tag=sftp \
-m ${memoryLimit}m \
--memory-swap ${memoryLimit * 2}m \
--dns 172.18.0.1 \
--dns-search=. \
-p 222:22 \
-v "${paths.APPS_DATA_DIR}:/app/data" \
-v "/etc/ssh:/etc/ssh:ro" \
--label isCloudronManaged=true \
--read-only -v /tmp -v /run "${tag}"`;
let dataDirs = [];
result.forEach(function (app) {
if (!app.manifest.addons['localstorage']) return;
shell.exec('startSftp', cmd, callback);
if (options.ignoredApps && options.ignoredApps.indexOf(app.id) !== -1) {
debug(`Ignoring volume for ${app.id}`);
return;
}
const hostDir = apps.getDataDir(app, app.dataDir), mountDir = `/app/data/${app.id}`;
if (!safe.fs.existsSync(hostDir)) {
// do not create host path when cloudron is restoring. this will then create dir with root perms making restore logic fail
debug(`Ignoring volume for ${app.id} since it does not exist`);
return;
}
dataDirs.push({ hostDir, mountDir });
});
debug('app volume mounts', dataDirs);
const appDataVolumes = dataDirs.map(function (v) { return `-v "${v.hostDir}:${v.mountDir}"`; }).join(' ');
const cmd = `docker run --restart=always -d --name="sftp" \
--hostname sftp \
--net cloudron \
--net-alias sftp \
--log-driver syslog \
--log-opt syslog-address=udp://127.0.0.1:2514 \
--log-opt syslog-format=rfc5424 \
--log-opt tag=sftp \
-m ${memoryLimit}m \
--memory-swap ${memoryLimit * 2}m \
--dns 172.18.0.1 \
--dns-search=. \
-p 222:22 \
${appDataVolumes} \
-v "/etc/ssh:/etc/ssh:ro" \
--label isCloudronManaged=true \
--read-only -v /tmp -v /run "${tag}"`;
// ignore error if container not found (and fail later) so that this code works across restarts
async.series([
shell.exec.bind(null, 'stopSftp', 'docker stop sftp || true'),
shell.exec.bind(null, 'removeSftp', 'docker rm -f sftp || true'),
shell.exec.bind(null, 'startSftp', cmd)
], callback);
});
}

View File

@@ -13,7 +13,7 @@ var assert = require('assert'),
once = require('once'),
util = require('util');
var SUDO = '/usr/bin/sudo';
const SUDO = '/usr/bin/sudo';
function exec(tag, cmd, callback) {
assert.strictEqual(typeof tag, 'string');
@@ -39,11 +39,11 @@ function spawn(tag, file, args, options, callback) {
callback = once(callback); // exit may or may not be called after an 'error'
debug(tag + ' spawn: %s %s', file, args.join(' '));
if (options.ipc) options.stdio = ['pipe', 'pipe', 'pipe', 'ipc'];
var cp = child_process.spawn(file, args, options);
debug(tag + ' spawn: %s %s', file, args.join(' '));
const cp = child_process.spawn(file, args, options);
if (options.logStream) {
cp.stdout.pipe(options.logStream);
cp.stderr.pipe(options.logStream);

View File

@@ -1,6 +1,9 @@
'use strict';
exports = module.exports = {
getBackupPath: getBackupPath,
checkPreconditions: checkPreconditions,
upload: upload,
download: download,
@@ -16,25 +19,76 @@ exports = module.exports = {
injectPrivateFields: injectPrivateFields
};
const PROVIDER_FILESYSTEM = 'filesystem';
const PROVIDER_SSHFS = 'sshfs';
const PROVIDER_CIFS = 'cifs';
const PROVIDER_NFS = 'nfs';
var assert = require('assert'),
BoxError = require('../boxerror.js'),
DataLayout = require('../datalayout.js'),
debug = require('debug')('box:storage/filesystem'),
df = require('@sindresorhus/df'),
EventEmitter = require('events'),
fs = require('fs'),
mkdirp = require('mkdirp'),
path = require('path'),
prettyBytes = require('pretty-bytes'),
readdirp = require('readdirp'),
safe = require('safetydance'),
shell = require('../shell.js');
// storage api
function getBackupPath(apiConfig) {
assert.strictEqual(typeof apiConfig, 'object');
if (apiConfig.provider === PROVIDER_SSHFS) return path.join(apiConfig.mountPoint, apiConfig.prefix);
if (apiConfig.provider === PROVIDER_CIFS) return path.join(apiConfig.mountPoint, apiConfig.prefix);
if (apiConfig.provider === PROVIDER_NFS) return path.join(apiConfig.mountPoint, apiConfig.prefix);
return apiConfig.backupFolder;
}
// the du call in the function below requires root
function checkPreconditions(apiConfig, dataLayout, callback) {
assert.strictEqual(typeof apiConfig, 'object');
assert(dataLayout instanceof DataLayout, 'dataLayout must be a DataLayout');
assert.strictEqual(typeof callback, 'function');
let used = 0;
for (let localPath of dataLayout.localPaths()) {
debug(`checkPreconditions: getting disk usage of ${localPath}`);
let result = safe.child_process.execSync(`du -Dsb ${localPath}`, { encoding: 'utf8' });
if (!result) return callback(new BoxError(BoxError.FS_ERROR, safe.error));
used += parseInt(result, 10);
}
debug(`checkPreconditions: ${used} bytes`);
df.file(getBackupPath(apiConfig)).then(function (result) {
// Check filesystem is mounted so we don't write into the actual folder on disk
if (apiConfig.provider === PROVIDER_SSHFS || apiConfig.provider === PROVIDER_CIFS || apiConfig.provider === PROVIDER_NFS) {
if (result.mountpoint !== apiConfig.mountPoint) return callback(new BoxError(BoxError.FS_ERROR, `${apiConfig.mountPoint} is not mounted`));
} else if (apiConfig.provider === PROVIDER_FILESYSTEM && apiConfig.externalDisk) {
if (result.mountpoint === '/') return callback(new BoxError(BoxError.FS_ERROR, `${apiConfig.backupFolder} is not mounted`));
}
const needed = used + (1024 * 1024 * 1024); // check if there is atleast 1GB left afterwards
if (result.available <= needed) return callback(new BoxError(BoxError.FS_ERROR, `Not enough disk space for backup. Needed: ${prettyBytes(needed)} Available: ${prettyBytes(result.available)}`));
callback(null);
}).catch(function (error) {
callback(new BoxError(BoxError.FS_ERROR, error));
});
}
function upload(apiConfig, backupFilePath, sourceStream, callback) {
assert.strictEqual(typeof apiConfig, 'object');
assert.strictEqual(typeof backupFilePath, 'string');
assert.strictEqual(typeof sourceStream, 'object');
assert.strictEqual(typeof callback, 'function');
mkdirp(path.dirname(backupFilePath), function (error) {
fs.mkdir(path.dirname(backupFilePath), { recursive: true }, function (error) {
if (error) return callback(new BoxError(BoxError.EXTERNAL_ERROR, error.message));
safe.fs.unlinkSync(backupFilePath); // remove any hardlink
@@ -55,8 +109,9 @@ function upload(apiConfig, backupFilePath, sourceStream, callback) {
// in test, upload() may or may not be called via sudo script
const BACKUP_UID = parseInt(process.env.SUDO_UID, 10) || process.getuid();
if (!safe.fs.chownSync(backupFilePath, BACKUP_UID, BACKUP_UID)) return callback(new BoxError(BoxError.EXTERNAL_ERROR, 'Unable to chown:' + safe.error.message));
if (!safe.fs.chownSync(path.dirname(backupFilePath), BACKUP_UID, BACKUP_UID)) return callback(new BoxError(BoxError.EXTERNAL_ERROR, 'Unable to chown:' + safe.error.message));
// sshfs and cifs handle ownership through the mount args
if (apiConfig.provider === PROVIDER_FILESYSTEM && !safe.fs.chownSync(backupFilePath, BACKUP_UID, BACKUP_UID)) return callback(new BoxError(BoxError.EXTERNAL_ERROR, 'Unable to chown:' + safe.error.message));
if (apiConfig.provider === PROVIDER_FILESYSTEM && !safe.fs.chownSync(path.dirname(backupFilePath), BACKUP_UID, BACKUP_UID)) return callback(new BoxError(BoxError.EXTERNAL_ERROR, 'Unable to chown:' + safe.error.message));
debug('upload %s: done.', backupFilePath);
@@ -115,13 +170,17 @@ function copy(apiConfig, oldFilePath, newFilePath) {
var events = new EventEmitter();
mkdirp(path.dirname(newFilePath), function (error) {
fs.mkdir(path.dirname(newFilePath), { recursive: true }, function (error) {
if (error) return events.emit('done', new BoxError(BoxError.EXTERNAL_ERROR, error.message));
events.emit('progress', `Copying ${oldFilePath} to ${newFilePath}`);
// sshfs and cifs do not allow preserving attributes
var cpOptions = apiConfig.provider === PROVIDER_FILESYSTEM ? '-a' : '-dR';
// this will hardlink backups saving space
var cpOptions = apiConfig.noHardlinks ? '-a' : '-al';
cpOptions += apiConfig.noHardlinks ? '' : 'l';
shell.spawn('copy', '/bin/cp', [ cpOptions, oldFilePath, newFilePath ], { }, function (error) {
if (error) return events.emit('done', new BoxError(BoxError.EXTERNAL_ERROR, error.message));
@@ -170,25 +229,48 @@ function testConfig(apiConfig, callback) {
assert.strictEqual(typeof apiConfig, 'object');
assert.strictEqual(typeof callback, 'function');
if (typeof apiConfig.backupFolder !== 'string') return callback(new BoxError(BoxError.BAD_FIELD, 'backupFolder must be string', { field: 'backupFolder' }));
if (apiConfig.provider === PROVIDER_FILESYSTEM) {
if (!apiConfig.backupFolder || typeof apiConfig.backupFolder !== 'string') return callback(new BoxError(BoxError.BAD_FIELD, 'backupFolder must be non-empty string', { field: 'backupFolder' }));
if ('externalDisk' in apiConfig && typeof apiConfig.externalDisk !== 'boolean') return callback(new BoxError(BoxError.BAD_FIELD, 'externalDisk must be boolean', { field: 'externalDisk' }));
}
if (!apiConfig.backupFolder) return callback(new BoxError(BoxError.BAD_FIELD, 'backupFolder is required', { field: 'backupFolder' }));
if (apiConfig.provider === PROVIDER_SSHFS || apiConfig.provider === PROVIDER_CIFS || apiConfig.provider === PROVIDER_NFS) {
if (!apiConfig.mountPoint || typeof apiConfig.mountPoint !== 'string') return callback(new BoxError(BoxError.BAD_FIELD, 'mountPoint must be non-empty string', { field: 'mountPoint' }));
if (typeof apiConfig.prefix !== 'string') return callback(new BoxError(BoxError.BAD_FIELD, 'prefix must be a string', { field: 'prefix' }));
const mounts = safe.fs.readFileSync('/proc/mounts', 'utf8');
const mountInfo = mounts.split('\n').filter(function (l) { return l.indexOf(apiConfig.mountPoint) !== -1; })[0];
if (!mountInfo) return callback(new BoxError(BoxError.BAD_FIELD, `${apiConfig.mountPoint} is not mounted`, { field: 'mountPoint' }));
if (apiConfig.provider === PROVIDER_SSHFS && !mountInfo.split(' ').find(i => i === 'fuse.sshfs')) return callback(new BoxError(BoxError.BAD_FIELD, 'mountPoint must be a "fuse.sshfs" filesystem', { field: 'mountPoint' }));
if (apiConfig.provider === PROVIDER_CIFS && !mountInfo.split(' ').find(i => i === 'cifs')) return callback(new BoxError(BoxError.BAD_FIELD, 'mountPoint must be a "cifs" filesystem', { field: 'mountPoint' }));
if (apiConfig.provider === PROVIDER_NFS && !mountInfo.split(' ').find(i => i === 'nfs')) return callback(new BoxError(BoxError.BAD_FIELD, 'mountPoint must be a "nfs" filesystem', { field: 'mountPoint' }));
}
// common checks
const backupPath = getBackupPath(apiConfig);
const field = apiConfig.provider === PROVIDER_FILESYSTEM ? 'backupFolder' : 'prefix';
const stat = safe.fs.statSync(backupPath);
if (!stat) return callback(new BoxError(BoxError.BAD_FIELD, 'Directory does not exist or cannot be accessed: ' + safe.error.message), { field });
if (!stat.isDirectory()) return callback(new BoxError(BoxError.BAD_FIELD, 'Backup location is not a directory', { field }));
if (!safe.fs.mkdirSync(path.join(backupPath, 'snapshot')) && safe.error.code !== 'EEXIST') {
if (safe.error && safe.error.code === 'EACCES') return callback(new BoxError(BoxError.BAD_FIELD, `Access denied. Run "chown yellowtent:yellowtent ${backupPath}" on the server`, { field }));
return callback(new BoxError(BoxError.BAD_FIELD, safe.error.message, { field }));
}
if (!safe.fs.writeFileSync(path.join(backupPath, 'cloudron-testfile'), 'testcontent')) {
return callback(new BoxError(BoxError.BAD_FIELD, `Unable to create test file as 'yellowtent' user in ${backupPath}: ${safe.error.message}. Check dir/mount permissions`, { field }));
}
if (!safe.fs.unlinkSync(path.join(backupPath, 'cloudron-testfile'))) {
return callback(new BoxError(BoxError.BAD_FIELD, `Unable to remove test file as 'yellowtent' user in ${backupPath}: ${safe.error.message}. Check dir/mount permissions`, { field }));
}
if ('noHardlinks' in apiConfig && typeof apiConfig.noHardlinks !== 'boolean') return callback(new BoxError(BoxError.BAD_FIELD, 'noHardlinks must be boolean', { field: 'noHardLinks' }));
if ('externalDisk' in apiConfig && typeof apiConfig.externalDisk !== 'boolean') return callback(new BoxError(BoxError.BAD_FIELD, 'externalDisk must be boolean', { field: 'externalDisk' }));
fs.stat(apiConfig.backupFolder, function (error, result) {
if (error) return callback(new BoxError(BoxError.BAD_FIELD, 'Directory does not exist or cannot be accessed: ' + error.message), { field: 'backupFolder' });
if (!result.isDirectory()) return callback(new BoxError(BoxError.BAD_FIELD, 'Backup location is not a directory', { field: 'backupFolder' }));
mkdirp(path.join(apiConfig.backupFolder, 'snapshot'), function (error) {
if (error && error.code === 'EACCES') return callback(new BoxError(BoxError.BAD_FIELD, `Access denied. Run "chown yellowtent:yellowtent ${apiConfig.backupFolder}" on the server`, { field: 'backupFolder' }));
if (error) return callback(new BoxError(BoxError.BAD_FIELD, error.message, { field: 'backupFolder' }));
callback(null);
});
});
callback(null);
}
function removePrivateFields(apiConfig) {

View File

@@ -1,6 +1,9 @@
'use strict';
exports = module.exports = {
getBackupPath: getBackupPath,
checkPreconditions: checkPreconditions,
upload: upload,
download: download,
copy: copy,
@@ -23,6 +26,7 @@ var assert = require('assert'),
async = require('async'),
BoxError = require('../boxerror.js'),
constants = require('../constants.js'),
DataLayout = require('../datalayout.js'),
debug = require('debug')('box:storage/gcs'),
EventEmitter = require('events'),
GCS = require('@google-cloud/storage').Storage,
@@ -57,6 +61,20 @@ function getBucket(apiConfig) {
}
// storage api
function getBackupPath(apiConfig) {
assert.strictEqual(typeof apiConfig, 'object');
return apiConfig.prefix;
}
function checkPreconditions(apiConfig, dataLayout, callback) {
assert.strictEqual(typeof apiConfig, 'object');
assert(dataLayout instanceof DataLayout, 'dataLayout must be a DataLayout');
assert.strictEqual(typeof callback, 'function');
callback(null);
}
function upload(apiConfig, backupFilePath, sourceStream, callback) {
assert.strictEqual(typeof apiConfig, 'object');
assert.strictEqual(typeof backupFilePath, 'string');

View File

@@ -11,6 +11,9 @@
// for the other API calls we leave it to the backend to retry. this allows
// them to tune the concurrency based on failures/rate limits accordingly
exports = module.exports = {
getBackupPath: getBackupPath,
checkPreconditions: checkPreconditions,
upload: upload,
download: download,
@@ -29,6 +32,7 @@ exports = module.exports = {
var assert = require('assert'),
BoxError = require('../boxerror.js'),
DataLayout = require('../datalayout.js'),
EventEmitter = require('events');
function removePrivateFields(apiConfig) {
@@ -41,6 +45,21 @@ function injectPrivateFields(newConfig, currentConfig) {
// in-place injection of tokens and api keys which came in with constants.SECRET_PLACEHOLDER
}
function getBackupPath(apiConfig) {
assert.strictEqual(typeof apiConfig, 'object');
// Result: path at the backup storage
return '/';
}
function checkPreconditions(apiConfig, dataLayout, callback) {
assert.strictEqual(typeof apiConfig, 'object');
assert(dataLayout instanceof DataLayout, 'dataLayout must be a DataLayout');
assert.strictEqual(typeof callback, 'function');
callback(null);
}
function upload(apiConfig, backupFilePath, sourceStream, callback) {
assert.strictEqual(typeof apiConfig, 'object');
assert.strictEqual(typeof backupFilePath, 'string');

View File

@@ -1,6 +1,9 @@
'use strict';
exports = module.exports = {
getBackupPath: getBackupPath,
checkPreconditions: checkPreconditions,
upload: upload,
download: download,
downloadDir: downloadDir,
@@ -18,9 +21,23 @@ exports = module.exports = {
var assert = require('assert'),
BoxError = require('../boxerror.js'),
DataLayout = require('../datalayout.js'),
debug = require('debug')('box:storage/noop'),
EventEmitter = require('events');
function getBackupPath(apiConfig) {
assert.strictEqual(typeof apiConfig, 'object');
return '';
}
function checkPreconditions(apiConfig, dataLayout, callback) {
assert.strictEqual(typeof apiConfig, 'object');
assert(dataLayout instanceof DataLayout, 'dataLayout must be a DataLayout');
assert.strictEqual(typeof callback, 'function');
callback(null);
}
function upload(apiConfig, backupFilePath, sourceStream, callback) {
assert.strictEqual(typeof apiConfig, 'object');
assert.strictEqual(typeof backupFilePath, 'string');

View File

@@ -1,6 +1,9 @@
'use strict';
exports = module.exports = {
getBackupPath: getBackupPath,
checkPreconditions: checkPreconditions,
upload: upload,
download: download,
copy: copy,
@@ -25,6 +28,7 @@ var assert = require('assert'),
BoxError = require('../boxerror.js'),
chunk = require('lodash.chunk'),
constants = require('../constants.js'),
DataLayout = require('../datalayout.js'),
debug = require('debug')('box:storage/s3'),
EventEmitter = require('events'),
https = require('https'),
@@ -54,7 +58,7 @@ function getS3Config(apiConfig, callback) {
var credentials = {
signatureVersion: apiConfig.signatureVersion || 'v4',
s3ForcePathStyle: true, // Force use path-style url (http://endpoint/bucket/path) instead of host-style (http://bucket.endpoint/path)
s3ForcePathStyle: false, // Use vhost style instead of path style - https://forums.aws.amazon.com/ann.jspa?annID=6776
accessKeyId: apiConfig.accessKeyId,
secretAccessKey: apiConfig.secretAccessKey,
region: apiConfig.region || 'us-east-1',
@@ -70,13 +74,33 @@ function getS3Config(apiConfig, callback) {
if (apiConfig.endpoint) credentials.endpoint = apiConfig.endpoint;
if (apiConfig.acceptSelfSignedCerts === true && credentials.endpoint && credentials.endpoint.startsWith('https://')) {
credentials.httpOptions.agent = new https.Agent({ rejectUnauthorized: false });
if (apiConfig.s3ForcePathStyle === true) credentials.s3ForcePathStyle = true;
// s3 endpoint names come from the SDK
const isHttps = (credentials.endpoint && credentials.endpoint.startsWith('https://')) || apiConfig.provider === 's3';
if (isHttps) { // only set agent for https calls. otherwise, it crashes
if (apiConfig.acceptSelfSignedCerts || apiConfig.bucket.includes('.')) {
credentials.httpOptions.agent = new https.Agent({ rejectUnauthorized: false });
}
}
callback(null, credentials);
}
// storage api
function getBackupPath(apiConfig) {
assert.strictEqual(typeof apiConfig, 'object');
return apiConfig.prefix;
}
function checkPreconditions(apiConfig, dataLayout, callback) {
assert.strictEqual(typeof apiConfig, 'object');
assert(dataLayout instanceof DataLayout, 'dataLayout must be a DataLayout');
assert.strictEqual(typeof callback, 'function');
callback(null);
}
function upload(apiConfig, backupFilePath, sourceStream, callback) {
assert.strictEqual(typeof apiConfig, 'object');
assert.strictEqual(typeof backupFilePath, 'string');
@@ -94,12 +118,12 @@ function upload(apiConfig, backupFilePath, sourceStream, callback) {
var s3 = new AWS.S3(credentials);
// s3.upload automatically does a multi-part upload. we set queueSize to 1 to reduce memory usage
// s3.upload automatically does a multi-part upload. we set queueSize to 3 to reduce memory usage
// uploader will buffer at most queueSize * partSize bytes into memory at any given time.
// scaleway only supports 1000 parts per object (https://www.scaleway.com/en/docs/s3-multipart-upload/)
const partSize = apiConfig.provider === 'scaleway-objectstorage' ? 100 * 1024 * 1024 : 10 * 1024 * 1024;
s3.upload(params, { partSize, queueSize: 1 }, function (error, data) {
s3.upload(params, { partSize, queueSize: 3 }, function (error, data) {
if (error) {
debug('Error uploading [%s]: s3 upload error.', backupFilePath, error);
return callback(new BoxError(BoxError.EXTERNAL_ERROR, `Error uploading ${backupFilePath}. Message: ${error.message} HTTP Code: ${error.code}`));
@@ -372,7 +396,7 @@ function removeDir(apiConfig, pathPrefix) {
listDir(apiConfig, pathPrefix, 1000, function listDirIterator(entries, done) {
total += entries.length;
const chunkSize = apiConfig.provider !== 'digitalocean-spaces' ? 1000 : 100; // throttle objects in each request
const chunkSize = apiConfig.deleteConcurrency || (apiConfig.provider !== 'digitalocean-spaces' ? 1000 : 100); // throttle objects in each request
var chunks = chunk(entries, chunkSize);
async.eachSeries(chunks, function deleteFiles(objects, iteratorCallback) {
@@ -416,10 +440,16 @@ function testConfig(apiConfig, callback) {
// the node module seems to incorrectly accept bucket name with '/'
if (apiConfig.bucket.includes('/')) return callback(new BoxError(BoxError.BAD_FIELD, 'bucket name cannot contain "/"', { field: 'bucket' }));
// names must be lowercase and start with a letter or number. can contain dashes
if (apiConfig.bucket.includes('_') || apiConfig.bucket.match(/[A-Z]/)) return callback(new BoxError(BoxError.BAD_FIELD, 'bucket name cannot contain "_" or capitals', { field: 'bucket' }));
if (typeof apiConfig.prefix !== 'string') return callback(new BoxError(BoxError.BAD_FIELD, 'prefix must be a string', { field: 'prefix' }));
if ('signatureVersion' in apiConfig && typeof apiConfig.signatureVersion !== 'string') return callback(new BoxError(BoxError.BAD_FIELD, 'signatureVersion must be a string', { field: 'signatureVersion' }));
if ('endpoint' in apiConfig && typeof apiConfig.endpoint !== 'string') return callback(new BoxError(BoxError.BAD_FIELD, 'endpoint must be a string', { field: 'endpoint' }));
if ('acceptSelfSignedCerts' in apiConfig && typeof apiConfig.acceptSelfSignedCerts !== 'boolean') return callback(new BoxError(BoxError.BAD_FIELD, 'acceptSelfSignedCerts must be a boolean', { field: 'acceptSelfSignedCerts' }));
if ('s3ForcePathStyle' in apiConfig && typeof apiConfig.s3ForcePathStyle !== 'boolean') return callback(new BoxError(BoxError.BAD_FIELD, 's3ForcePathStyle must be a boolean', { field: 's3ForcePathStyle' }));
// attempt to upload and delete a file with new credentials
getS3Config(apiConfig, function (error, credentials) {
if (error) return callback(error);

View File

@@ -12,7 +12,7 @@ let assert = require('assert'),
once = require('once'),
path = require('path'),
paths = require('./paths.js'),
settings = require('./settings.js'),
safe = require('safetydance'),
shell = require('./shell.js');
// the logic here is also used in the cloudron-support tool
@@ -24,7 +24,7 @@ function sshInfo() {
if (constants.TEST) {
filePath = path.join(paths.baseDir(), 'authorized_keys');
user = process.getuid();
} else if (settings.provider() === 'ec2' || settings.provider() === 'lightsail' || settings.provider() === 'ami') {
} else if (safe.fs.existsSync('/home/ubuntu')) { // yellowtent user won't have access to anything deeper
filePath = '/home/ubuntu/.ssh/authorized_keys';
user = 'ubuntu';
} else {

View File

@@ -8,6 +8,7 @@ exports = module.exports = {
var assert = require('assert'),
async = require('async'),
BoxError = require('../boxerror.js'),
debug = require('debug')('box:sysinfo/generic'),
superagent = require('superagent');
function getServerIp(config, callback) {
@@ -19,11 +20,11 @@ function getServerIp(config, callback) {
async.retry({ times: 10, interval: 5000 }, function (callback) {
superagent.get('https://api.cloudron.io/api/v1/helper/public_ip').timeout(30 * 1000).end(function (error, result) {
if (error || result.statusCode !== 200) {
console.error('Error getting IP', error);
debug('Error getting IP', error);
return callback(new BoxError(BoxError.EXTERNAL_ERROR, 'Unable to detect IP. API server unreachable'));
}
if (!result.body && !result.body.ip) {
console.error('Unexpected answer. No "ip" found in response body.', result.body);
debug('Unexpected answer. No "ip" found in response body.', result.body);
return callback(new BoxError(BoxError.EXTERNAL_ERROR, 'Unable to detect IP. No IP found in response'));
}

View File

@@ -38,12 +38,11 @@ exports = module.exports = {
};
let assert = require('assert'),
async = require('async'),
BoxError = require('./boxerror.js'),
child_process = require('child_process'),
debug = require('debug')('box:tasks'),
path = require('path'),
paths = require('./paths.js'),
safe = require('safetydance'),
shell = require('./shell.js'),
spawn = require('child_process').spawn,
split = require('split'),
taskdb = require('./taskdb.js'),
@@ -52,6 +51,8 @@ let assert = require('assert'),
let gTasks = {}; // indexed by task id
const NOOP_CALLBACK = function (error) { if (error) debug(error); };
const START_TASK_CMD = path.join(__dirname, 'scripts/starttask.sh');
const STOP_TASK_CMD = path.join(__dirname, 'scripts/stoptask.sh');
function postProcess(result) {
assert.strictEqual(typeof result, 'object');
@@ -132,56 +133,54 @@ function add(type, args, callback) {
});
}
function startTask(taskId, options, callback) {
assert.strictEqual(typeof taskId, 'string');
function startTask(id, options, callback) {
assert.strictEqual(typeof id, 'string');
assert.strictEqual(typeof options, 'object');
assert.strictEqual(typeof callback, 'function');
const logFile = options.logFile || `${paths.TASKS_LOG_DIR}/${taskId}.log`;
let fd = safe.fs.openSync(logFile, 'a'); // will autoclose. append is for apptask logs
if (!fd) {
debug(`startTask: unable to get log filedescriptor ${safe.error.message}`);
return callback(new BoxError(BoxError.FS_ERROR, safe.error));
}
debug(`startTask - starting task ${taskId}. logs at ${logFile}`);
const logFile = options.logFile || `${paths.TASKS_LOG_DIR}/${id}.log`;
debug(`startTask - starting task ${id}. logs at ${logFile}`);
let killTimerId = null, timedOut = false;
gTasks[taskId] = child_process.fork(`${__dirname}/taskworker.js`, [ taskId ], { stdio: [ 'pipe', fd, fd, 'ipc' ]}); // fork requires ipc
gTasks[taskId].once('exit', function (code, signal) {
debug(`startTask: ${taskId} completed with code ${code} and signal ${signal}`);
gTasks[id] = shell.sudo('startTask', [ START_TASK_CMD, id, logFile, options.nice || 0, options.memoryLimit || 400 ], { preserveEnv: true }, function (error) {
if (!gTasks[id]) return; // ignore task exit since we are shutting down. see stopAllTasks
const code = error ? error.code : 0;
const signal = error ? error.signal : 0;
debug(`startTask: ${id} completed with code ${code} and signal ${signal}`);
if (options.timeout) clearTimeout(killTimerId);
get(taskId, function (error, task) {
get(id, function (getError, task) {
let taskError;
if (!error && task.percent !== 100) { // task crashed or was killed by us
if (!getError && task.percent !== 100) { // taskworker crashed or was killed by us
taskError = {
message: code === 0 ? `Task ${taskId} ${timedOut ? 'timed out' : 'stopped'}` : `Task ${taskId} crashed with code ${code} and signal ${signal}`,
message: code === 0 ? `Task ${id} ${timedOut ? 'timed out' : 'stopped'}` : `Task ${id} crashed with code ${code} and signal ${signal}`,
code: code === 0 ? (timedOut ? exports.ETIMEOUT : exports.ESTOPPED) : exports.ECRASHED
};
// note that despite the update() here, we should handle the case where the box code was restarted and never got taskworker exit
setCompleted(taskId, { error: taskError }, NOOP_CALLBACK);
} else if (!error && task.error) {
setCompleted(id, { error: taskError }, NOOP_CALLBACK);
} else if (!getError && task.error) {
taskError = task.error;
} else if (!task) { // db got cleared in tests
taskError = new BoxError(BoxError.NOT_FOUND, `No such task ${taskId}`);
taskError = new BoxError(BoxError.NOT_FOUND, `No such task ${id}`);
}
delete gTasks[taskId];
delete gTasks[id];
callback(taskError, task ? task.result : null);
debug(`startTask: ${taskId} done`);
debug(`startTask: ${id} done`);
});
});
if (options.timeout) {
killTimerId = setTimeout(function () {
debug(`startTask: task ${taskId} took too long. killing`);
debug(`startTask: task ${id} took too long. killing`);
timedOut = true;
stopTask(taskId, NOOP_CALLBACK);
stopTask(id, NOOP_CALLBACK);
}, options.timeout);
}
}
@@ -194,7 +193,7 @@ function stopTask(id, callback) {
debug(`stopTask: stopping task ${id}`);
gTasks[id].kill('SIGTERM'); // this will end up calling the 'exit' signal handler
shell.sudo('stopTask', [ STOP_TASK_CMD, id, ], {}, NOOP_CALLBACK);
callback(null);
}
@@ -202,9 +201,10 @@ function stopTask(id, callback) {
function stopAllTasks(callback) {
assert.strictEqual(typeof callback, 'function');
async.eachSeries(Object.keys(gTasks), function (id, iteratorDone) {
stopTask(id, () => iteratorDone()); // ignore any error
}, callback);
debug('stopTask: stopping all tasks');
gTasks = {}; // this signals startTask() to not set completion status as "crashed"
shell.sudo('stopTask', [ STOP_TASK_CMD, 'all' ], {}, callback);
}
function listByTypePaged(type, page, perPage, callback) {

View File

@@ -1,21 +1,19 @@
#!/usr/bin/env node
'use strict';
require('supererror')({ splatchError: true });
var apptask = require('./apptask.js'),
assert = require('assert'),
async = require('async'),
backups = require('./backups.js'),
database = require('./database.js'),
debug = require('debug')('box:taskworker'),
domains = require('./domains.js'),
externalLdap = require('./externalldap.js'),
fs = require('fs'),
reverseProxy = require('./reverseproxy.js'),
settings = require('./settings.js'),
tasks = require('./tasks.js'),
updater = require('./updater.js');
const NOOP_CALLBACK = function (error) { if (error) debug(error); };
updater = require('./updater.js'),
util = require('util');
const TASKS = { // indexed by task type
app: apptask.run,
@@ -28,30 +26,47 @@ const TASKS = { // indexed by task type
_identity: (arg, progressCallback, callback) => callback(null, arg),
_error: (arg, progressCallback, callback) => callback(new Error(`Failed for arg: ${arg}`)),
_crash: (arg) => { throw new Error(`Crashing for arg: ${arg}`); },
_crash: (arg) => { throw new Error(`Crashing for arg: ${arg}`); }, // the test looks for this debug string in the log file
_sleep: (arg) => setTimeout(process.exit, arg)
};
process.on('SIGTERM', function () {
process.exit(0);
});
if (process.argv.length !== 4) {
console.error('Pass the taskid and logfile as argument');
process.exit(1);
}
assert.strictEqual(process.argv.length, 3, 'Pass the taskid as argument');
const taskId = process.argv[2];
const logFile = process.argv[3];
function initialize(callback) {
async.series([
database.initialize,
settings.initCache
], callback);
function setupLogging(callback) {
fs.open(logFile, 'a', function (error, fd) {
if (error) return callback(error);
require('debug').log = function (...args) {
fs.appendFileSync(fd, util.format(...args) + '\n');
};
callback();
});
}
// Main process starts here
debug(`Starting task ${taskId}`);
const startTime = new Date();
initialize(function (error) {
async.series([
setupLogging,
database.initialize,
settings.initCache
], function (error) {
if (error) return process.exit(50);
const debug = require('debug')('box:taskworker'); // require this here so that logging handler is already setup
const NOOP_CALLBACK = function (error) { if (error) debug(error); };
process.on('SIGTERM', () => process.exit(0)); // sent as timeout notification
debug(`Starting task ${taskId}. Logs are at ${logFile}`);
tasks.get(taskId, function (error, task) {
if (error) return process.exit(50);
@@ -63,9 +78,16 @@ initialize(function (error) {
error: error ? JSON.parse(JSON.stringify(error, Object.getOwnPropertyNames(error))) : null
};
debug(`Task took ${(new Date() - startTime)/1000} seconds`);
tasks.setCompleted(taskId, progress, () => process.exit(error ? 50 : 0));
};
TASKS[task.type].apply(null, task.args.concat(progressCallback).concat(resultCallback));
try {
TASKS[task.type].apply(null, task.args.concat(progressCallback).concat(resultCallback));
} catch (error) {
debug('Uncaught exception in task', error);
process.exit(1); // do not call setCompleted() intentionally. the task code must be resilient enough to handle it
}
});
});

View File

@@ -29,7 +29,6 @@ describe('Apps', function () {
fallbackEmail: 'admin@me.com',
salt: 'morton',
createdAt: 'sometime back',
modifiedAt: 'now',
resetToken: hat(256),
displayName: '',
groupIds: [],
@@ -45,7 +44,6 @@ describe('Apps', function () {
fallbackEmail: 'safe@me.com',
salt: 'morton',
createdAt: 'sometime back',
modifiedAt: 'now',
resetToken: hat(256),
displayName: '',
groupIds: [],
@@ -61,7 +59,6 @@ describe('Apps', function () {
fallbackEmail: 'safe1@me.com',
salt: 'morton',
createdAt: 'sometime back',
modifiedAt: 'now',
resetToken: hat(256),
displayName: '',
groupIds: [ 'somegroup' ],
@@ -71,11 +68,13 @@ describe('Apps', function () {
var GROUP_0 = {
id: 'somegroup',
name: 'group0'
name: 'group0',
source: ''
};
var GROUP_1 = {
id: 'anothergroup',
name: 'group1'
name: 'group1',
source: 'ldap'
};
const DOMAIN_0 = {
@@ -180,8 +179,8 @@ describe('Apps', function () {
userdb.add.bind(null, ADMIN_0.id, ADMIN_0),
userdb.add.bind(null, USER_0.id, USER_0),
userdb.add.bind(null, USER_1.id, USER_1),
groupdb.add.bind(null, GROUP_0.id, GROUP_0.name),
groupdb.add.bind(null, GROUP_1.id, GROUP_1.name),
groupdb.add.bind(null, GROUP_0.id, GROUP_0.name, GROUP_0.source),
groupdb.add.bind(null, GROUP_1.id, GROUP_1.name, GROUP_1.source),
groups.addMember.bind(null, GROUP_0.id, USER_1.id),
appdb.add.bind(null, APP_0.id, APP_0.appStoreId, APP_0.manifest, APP_0.location, APP_0.domain, apps._translatePortBindings(APP_0.portBindings, APP_0.manifest), APP_0),
appdb.add.bind(null, APP_1.id, APP_1.appStoreId, APP_1.manifest, APP_1.location, APP_1.domain, apps._translatePortBindings(APP_1.portBindings, APP_1.manifest), APP_1),

View File

@@ -50,58 +50,10 @@ describe('Appstore', function () {
beforeEach(nock.cleanAll);
it('cannot send alive status without registering', function (done) {
appstore.sendAliveStatus(function (error) {
expect(error).to.be.ok();
expect(error.reason).to.equal(BoxError.LICENSE_ERROR); // missing token
done();
});
});
it('can set cloudron token', function (done) {
settingsdb.set(settings.CLOUDRON_TOKEN_KEY, APPSTORE_TOKEN, done);
});
it('can send alive status', function (done) {
var scope = nock(MOCK_API_SERVER_ORIGIN)
.post(`/api/v1/alive?accessToken=${APPSTORE_TOKEN}`, function (body) {
expect(body.version).to.be.a('string');
expect(body.adminFqdn).to.be.a('string');
expect(body.provider).to.be.a('string');
expect(body.backendSettings).to.be.an('object');
expect(body.backendSettings.backupConfig).to.be.an('object');
expect(body.backendSettings.backupConfig.provider).to.be.a('string');
expect(body.backendSettings.backupConfig.hardlinks).to.be.a('boolean');
expect(body.backendSettings.domainConfig).to.be.an('object');
expect(body.backendSettings.domainConfig.count).to.be.a('number');
expect(body.backendSettings.domainConfig.domains).to.be.an('array');
expect(body.backendSettings.mailConfig).to.be.an('object');
expect(body.backendSettings.mailConfig.outboundCount).to.be.a('number');
expect(body.backendSettings.mailConfig.inboundCount).to.be.a('number');
expect(body.backendSettings.mailConfig.catchAllCount).to.be.a('number');
expect(body.backendSettings.mailConfig.relayProviders).to.be.an('array');
expect(body.backendSettings.appAutoupdatePattern).to.be.a('string');
expect(body.backendSettings.boxAutoupdatePattern).to.be.a('string');
expect(body.backendSettings.sysinfoProvider).to.be.a('string');
expect(body.backendSettings.timeZone).to.be.a('string');
expect(body.machine).to.be.an('object');
expect(body.machine.cpus).to.be.an('array');
expect(body.machine.totalmem).to.be.an('number');
expect(body.events).to.be.an('object');
expect(body.events.lastLogin).to.be.an('number');
return true;
})
.reply(201, { cloudron: { id: CLOUDRON_ID }});
appstore.sendAliveStatus(function (error) {
expect(error).to.not.be.ok();
expect(scope.isDone()).to.be.ok();
done();
});
});
it('can purchase an app', function (done) {
var scope1 = nock(MOCK_API_SERVER_ORIGIN)
.post(`/api/v1/cloudronapps?accessToken=${APPSTORE_TOKEN}`, function () { return true; })

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