Compare commits

...

122 Commits

Author SHA1 Message Date
Girish Ramakrishnan
fa842034ed update: continue to update apps if box update never starts
https://forum.cloudron.io/topic/10699/no-automatic-app-updates-with-pending-box-update
2023-12-28 12:16:03 +01:00
Girish Ramakrishnan
672b472359 hetzner: typo in error message 2023-12-27 20:41:34 +01:00
Girish Ramakrishnan
37ed87f9c1 route53: retry on rate limit
route53 has a limit of 5 req/sec/region - https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/DNSLimitations.html#limits-api-requests

see https://forum.cloudron.io/topic/10656/improve-dns-updates-to-avoid-rate-limits/
2023-12-27 12:23:09 +01:00
Johannes Zellner
25ba312636 Use postgres addon with pgvecto_rs extension 2023-12-22 22:45:41 +01:00
Johannes Zellner
340ea3fe9b Fix variable usage bug for noop backup provider 2023-12-18 13:23:40 +01:00
Girish Ramakrishnan
d264f8b05c cloudron-support: box check 2023-12-15 15:45:29 +01:00
Girish Ramakrishnan
54672d9fce cloudron-support: fix variable name 2023-12-14 18:01:44 +01:00
Johannes Zellner
5ac9a7f1ef Do not bind to ipv6 for port 53 apps (adguard) 2023-12-14 18:00:03 +01:00
Girish Ramakrishnan
b906b0f7f2 cloudron-support: delete extra dashboard conf files 2023-12-14 17:40:03 +01:00
Johannes Zellner
758e1965f1 cloudron-support: improve troubleshooting 2023-12-14 17:28:30 +01:00
Johannes Zellner
8ff437c4d2 cloudron-support: Add colors 2023-12-14 17:22:03 +01:00
Girish Ramakrishnan
4374124985 cloudron-support: whois may not have expiry info 2023-12-14 17:12:07 +01:00
Girish Ramakrishnan
8b5afaa12c cloudron-support: check if whois installed 2023-12-14 17:07:51 +01:00
Girish Ramakrishnan
a54c6d3c32 install whois 2023-12-14 17:05:22 +01:00
Girish Ramakrishnan
93af9379bd cloudron-support: add option to disable dnssec 2023-12-14 17:04:05 +01:00
Girish Ramakrishnan
39deb41e2e cloudron-support: troubleshoot 2023-12-14 16:53:51 +01:00
Johannes Zellner
d7c0a947fb dashboard: open internal app configure screen from disk usage info 2023-12-14 13:04:03 +01:00
Johannes Zellner
09b438850e Show disk content label in usage bar tooltip 2023-12-14 13:01:59 +01:00
Johannes Zellner
cbefd4195f Add some 7.6.3 changes 2023-12-13 17:44:33 +01:00
Girish Ramakrishnan
849c8bf6ac cloudron-support: diag is too short 2023-12-13 16:59:00 +01:00
Johannes Zellner
00268b1da9 Use postgresql addon 5.1.5 which fixes the search_path issue 2023-12-13 16:47:40 +01:00
Girish Ramakrishnan
5f5e6084d7 cloudron-support: rework script into functions 2023-12-13 16:47:15 +01:00
Girish Ramakrishnan
852c4d1300 cloudron-support: remove --reset-appstore-account
the preferred way now is to delete it in cloudron.io instead
2023-12-13 16:21:47 +01:00
Girish Ramakrishnan
81fe6f884b cloudron-support: rename enable-ssh to enable-remote-access 2023-12-13 16:21:14 +01:00
Girish Ramakrishnan
9780e4184e cloudron-setup: typo 2023-12-13 09:32:38 +01:00
Girish Ramakrishnan
1af1660312 cloudron-setup: t2 has lesser memory now 2023-12-11 15:39:25 +01:00
Girish Ramakrishnan
1206f5dc88 Fix the support text 2023-12-10 13:11:22 +01:00
Girish Ramakrishnan
793c4ac017 add some debugs to the firewall script 2023-12-08 11:05:55 +01:00
Girish Ramakrishnan
620e3af525 add to changelog 2023-12-08 10:17:30 +01:00
Johannes Zellner
c7b2e15d16 Use new postgres addon image with vectors extension enabled 2023-12-08 09:15:45 +01:00
Girish Ramakrishnan
48f0c75c57 network: increase maxelem of the ipsets 2023-12-07 23:20:24 +01:00
Girish Ramakrishnan
93d3b24300 firewall: max 65536 elements 2023-12-07 21:52:51 +01:00
Girish Ramakrishnan
21f830eb8c network: disable save button when in progress 2023-12-07 21:29:23 +01:00
Girish Ramakrishnan
c195cb00c0 backup: redact nested password in configs 2023-12-07 13:38:38 +01:00
Girish Ramakrishnan
f7a53e1b15 also flush the ipv6 blocklist 2023-12-06 22:20:25 +01:00
Girish Ramakrishnan
759f3f29f0 hetzner: accomodate other nameservers 2023-12-05 18:13:34 +01:00
Girish Ramakrishnan
be35926fd1 ovh: accomodate anycast.me servers 2023-12-05 14:04:16 +01:00
Johannes Zellner
45fd046b9b Make systeminfo strings translatable 2023-12-04 16:59:24 +01:00
Girish Ramakrishnan
2b8d0f60e7 add to changes 2023-12-04 15:35:35 +01:00
Girish Ramakrishnan
0e0199fc94 typo 2023-12-04 09:09:43 +01:00
Johannes Zellner
7a730c445b dashboard: Show system stats 2023-12-04 01:51:33 +01:00
Johannes Zellner
4d29592450 Do not invalidate session sudo but only for the command we want to test 2023-12-04 01:42:46 +01:00
Girish Ramakrishnan
44be454a1e system: return activation time if we have it 2023-12-04 01:41:56 +01:00
Girish Ramakrishnan
cbf1b47332 system: merge info and dmi routes
also return uptimeSecs instead of abstract date
2023-12-04 01:11:26 +01:00
Girish Ramakrishnan
eb64bd296a system: return uptime and reboot required 2023-12-04 00:46:12 +01:00
Girish Ramakrishnan
72083f59cd system: dmi information 2023-12-04 00:31:18 +01:00
Girish Ramakrishnan
8a20b603f5 system: cpu route 2023-12-04 00:23:25 +01:00
Girish Ramakrishnan
d45c433bc7 fix dockerproxy test 2023-12-04 00:11:11 +01:00
Girish Ramakrishnan
470417fcbe more test fixing 2023-12-03 21:18:16 +01:00
Girish Ramakrishnan
8e28d2a5aa Fix support tests 2023-12-03 20:04:17 +01:00
Girish Ramakrishnan
344578006c make oidc test stable 2023-12-03 20:04:17 +01:00
Johannes Zellner
e19fd5cf17 Make support help items translatable 2023-12-03 18:03:25 +01:00
Girish Ramakrishnan
943325baa3 better sudoers configuration check 2023-12-03 17:50:50 +01:00
Johannes Zellner
702de2557e Update translations 2023-12-03 16:46:15 +01:00
Johannes Zellner
159f3419a5 Hide support ticket UI 2023-12-03 16:46:15 +01:00
Johannes Zellner
b1fb3bccd8 Add help section in support 2023-12-03 16:46:15 +01:00
Johannes Zellner
8927634636 Remove supportConfig route 2023-12-03 16:46:15 +01:00
Girish Ramakrishnan
b9e584752b Fix system test 2023-12-03 15:52:31 +01:00
Johannes Zellner
5857c05e01 Remove noisy debug for applinks 2023-12-03 15:11:16 +01:00
Johannes Zellner
81eb4bdebb Improve jsdom usage for applink icons 2023-12-03 14:24:45 +01:00
Johannes Zellner
da18427125 Better error feedback on appstore login 2023-12-02 18:20:13 +01:00
Johannes Zellner
df0b4ace5e Update translations 2023-12-02 18:20:13 +01:00
Johannes Zellner
5971d3bf77 Better error handling for setupToken 2023-12-02 18:20:13 +01:00
Johannes Zellner
cca3138f05 Remove appstore web token api 2023-12-02 18:20:13 +01:00
Johannes Zellner
242c091add Add ability to register a Cloudron with a setupToken only 2023-12-02 18:20:13 +01:00
Girish Ramakrishnan
6f0788c9e4 typo 2023-12-01 17:29:06 +01:00
Girish Ramakrishnan
15132a30da Fix linode object storage
36c4772b17 broke linode object storage
2023-12-01 17:27:10 +01:00
Johannes Zellner
3245370280 New postgres addon for newly required extensions 2023-11-30 13:00:53 +01:00
Girish Ramakrishnan
740c0fe318 dockerproxy: all volumes to be mounted in child containers
this will allow jupyterhub notebooks to access volumes
2023-11-27 23:06:11 +01:00
Johannes Zellner
8d20ca2053 frontend: update dependencies 2023-11-27 13:09:56 +01:00
Johannes Zellner
cdd8e34cfc Move owner/chown model into directoryModel 2023-11-27 13:09:42 +01:00
Girish Ramakrishnan
a056bcfdfe mailserver: fix sending of double header 2023-11-26 15:40:21 +01:00
Girish Ramakrishnan
b5065a381f update packages 2023-11-26 09:46:51 +01:00
Girish Ramakrishnan
56324e3e8e Fixup sshd comment 2023-11-24 15:46:24 +01:00
Girish Ramakrishnan
e64182d791 mail: make redis non-persistent
it keeps emitting warnings non-stop about bgsave not working
2023-11-23 14:27:58 +01:00
Johannes Zellner
573eaee287 frontend: unify owner models for apps and volumes 2023-11-21 14:51:24 +01:00
Johannes Zellner
771bfd0244 Do not underline a tags on hover 2023-11-21 12:57:37 +01:00
Johannes Zellner
2db96a5242 frontend: update dependencies 2023-11-21 12:51:57 +01:00
Girish Ramakrishnan
8459d231c2 setup/restore: fix error with static ip configuration 2023-11-18 17:53:53 +01:00
Girish Ramakrishnan
efd42b7293 ovh: fix nameserver matching
there's a whole bunch: ovh.ca, ovh.us, ovh.com, ovhcloud.com, ovh.co.uk

https://forum.cloudron.io/topic/10435/limitation-with-dns-using-ovh-in-validating-name-server-domains-domain-nameservers-are-not-set-to-ovh
2023-11-16 10:27:59 +01:00
Johannes Zellner
fe1c483b78 logviewer: preserve horizontal scroll position 2023-11-14 14:24:58 +01:00
Girish Ramakrishnan
bf381aff7f redis: use default instead of redisuser
suggested at https://github.com/redis/node-redis/issues/1591
2023-11-14 10:50:25 +01:00
Girish Ramakrishnan
1a43c05d48 sftp: fix crash when app has no addons 2023-11-13 21:58:44 +01:00
Girish Ramakrishnan
804a3f8adb Capitalize dnsimple properly 2023-11-13 18:30:24 +01:00
Girish Ramakrishnan
1122137d12 typo in dnsimple configuration 2023-11-11 12:57:10 +01:00
Girish Ramakrishnan
b88afbac4e dns: add ovh backend 2023-11-06 15:22:24 +01:00
Girish Ramakrishnan
8e468788a9 dockerproxy: fix typo 2023-11-04 13:28:02 +01:00
Girish Ramakrishnan
7f9e5303be add voip category 2023-11-03 10:24:38 +01:00
Girish Ramakrishnan
08c48df862 add qbittorrent to blacklist 2023-11-01 23:54:20 +01:00
Girish Ramakrishnan
1bc3875519 cloudron-support: check for active owner as well 2023-11-01 12:30:31 +01:00
Girish Ramakrishnan
c69cf4731a remove extra space 2023-10-31 21:51:46 +01:00
Johannes Zellner
4ad5bd71f1 Try to only use sensible icons for applinks 2023-10-31 14:55:24 +01:00
Girish Ramakrishnan
1ddc1cec20 Fix role definitions 2023-10-30 18:40:20 +01:00
Girish Ramakrishnan
934c701be2 vultr: fix copy of large objects
https://forum.cloudron.io/topic/10266/backups-are-failing
2023-10-26 09:51:07 +02:00
Johannes Zellner
fadd4165df Update pankow with item activation debouncing 2023-10-25 16:19:44 +02:00
Johannes Zellner
538454b11b Update dependencies 2023-10-25 16:03:48 +02:00
Johannes Zellner
e4464afd56 Use new graphite container for whisper cleanup 2023-10-24 01:06:37 +02:00
Girish Ramakrishnan
eb1f3d8b55 dns: add dnsimple 2023-10-24 00:26:10 +02:00
Johannes Zellner
e7208278fc Only collect stats for app main containers 2023-10-23 22:23:23 +02:00
Johannes Zellner
e87370354b Update dependencies 2023-10-23 16:16:20 +02:00
Johannes Zellner
fc3bd3a0fe Deletion confirmation dialog moved out of pankow 2023-10-23 16:16:00 +02:00
Johannes Zellner
2270f5789a frontend: Update pankow 2023-10-21 18:59:57 +02:00
Johannes Zellner
7ef20c273e Update sftp service for folder copy 2023-10-21 17:37:44 +02:00
Johannes Zellner
39942dc5b0 frontend: update dependencies 2023-10-21 17:13:31 +02:00
Johannes Zellner
37a6e60e90 Do not allow newlines in CSP rules 2023-10-18 13:53:21 +02:00
Johannes Zellner
1f8c55f536 Add docker-volume disk usage info 2023-10-17 16:51:57 +02:00
Johannes Zellner
36c4772b17 Add missing Linode S3 regions 2023-10-17 15:35:33 +02:00
Girish Ramakrishnan
47d7536e24 du: add dovecot index log to the exclude list 2023-10-17 10:00:27 +02:00
Johannes Zellner
9d9a407c3d Noop provider does not have a rootPath set 2023-10-16 16:36:31 +02:00
Johannes Zellner
7d731d7600 dashboard: paint backup failure notifications red 2023-10-16 14:18:18 +02:00
Girish Ramakrishnan
dd9db22e9c Fix transient du error
du error: Command failed: du -Dsb "/home/yellowtent/boxdata/mail" du: cannot access '/home/yellowtent/boxdata/mail/vmail/user@example.com/mail/dovecot-uidlist.lock': No such file or directory .
2023-10-13 15:52:36 +05:30
Girish Ramakrishnan
6830c4fc67 redis: fix issue when restoring optional redis 2023-10-11 14:53:25 +05:30
Girish Ramakrishnan
2f3fba346f volumes: throw error for unsupported update 2023-10-09 10:31:31 +05:30
Girish Ramakrishnan
5bae308cae docker: Fix crah when docker has no space 2023-10-09 07:38:57 +05:30
Johannes Zellner
ed71f9ac68 The oidc client signing algorithm is not really so important to show it toplevel 2023-10-06 15:32:40 +02:00
Johannes Zellner
5e7bc78d35 Set custom oidc client id and secret in the backend 2023-10-06 15:16:57 +02:00
Girish Ramakrishnan
41319bc817 ldap server close has no callback 2023-10-01 14:33:19 +05:30
Girish Ramakrishnan
ceb908bee7 Use constants.TEST 2023-10-01 13:52:19 +05:30
Girish Ramakrishnan
0e195679bf Make tests pass 2023-10-01 13:42:02 +05:30
Girish Ramakrishnan
9c78b2df9a dockerproxy: lint 2023-10-01 12:12:02 +05:30
Girish Ramakrishnan
4844f6d927 dashboard: remove old domain config on switch 2023-09-29 09:26:42 +05:30
Girish Ramakrishnan
64381e2a04 backups: remove validation mount point after testing it
this also moves out the attempt validation logic from mounts code
into volumes. mounts.tryAddMount is also used in backup code
2023-09-29 08:01:58 +05:30
111 changed files with 2905 additions and 1313 deletions

26
CHANGES
View File

@@ -2698,3 +2698,29 @@
* oidc: add oidc logo as login indicator for apps
* dyndns: update dns every 10 mins
[7.6.1]
* Cleanup backup validation mount point
* dashboard: remove nginx config of old domain when domain changed
* Show disk consumption of docker volumes for /run and /tmp of apps separately
* dns: add dnsimple automation
* roles: admin role can access branding and networking
* dns: add ovh backend
[7.6.2]
* mail: fix issue with redis emitting warnings non-stop
* mail: fix issue where doublle header was sent
* ovh: fix nameserver matching
* logviewer: preserve horizontal scroll position
* redis: use default instead of redisuser
* dockerproxy: allow child containers to access volumes
* dashboard: Show system information
* Fix linode object storage
* postgres: enable cube, vector and earthdistance extensions
* Add ability to register a Cloudron with a setupToken only
* support: replace ticket section with help section
* firewall: increase blocklist size to 262144
[7.6.3]
* postgres: do not clear search_path for restore
* route53: retry on rate limit errors
* update: continue with app update if box update does not start

5
box.js
View File

@@ -2,7 +2,8 @@
'use strict';
const fs = require('fs'),
const constants = require('./src/constants.js'),
fs = require('fs'),
ldap = require('./src/ldap.js'),
oidc = require('./src/oidc.js'),
paths = require('./src/paths.js'),
@@ -14,7 +15,7 @@ const fs = require('fs'),
let logFd;
async function setupLogging() {
if (process.env.BOX_ENV === 'test') return;
if (constants.TEST) return;
logFd = fs.openSync(paths.BOX_LOG_FILE, 'a');
// we used to write using a stream before but it caches internally and there is no way to flush it when things crash

View File

@@ -195,11 +195,25 @@ const REGIONS_SCALEWAY = [
{ name: 'Warsaw (PL-WAW)', value: 'https://s3.pl-waw.scw.cloud', region: 'nl-ams' }
];
// https://www.linode.com/docs/products/storage/object-storage/guides/urls/
const REGIONS_LINODE = [
{ name: 'Atlanta', value: 'us-southeast-1.linodeobjects.com', region: 'us-east-1' }, // default
{ name: 'Newark', value: 'us-east-1.linodeobjects.com', region: 'us-east-1' },
{ name: 'Frankfurt', value: 'eu-central-1.linodeobjects.com', region: 'us-east-1' },
{ name: 'Singapore', value: 'ap-south-1.linodeobjects.com', region: 'us-east-1' }
{ name: 'Amsterdam', value: 'https://nl-ams-1.linodeobjects.com', region: 'nl-ams-1' },
{ name: 'Atlanta', value: 'https://us-southeast-1.linodeobjects.com', region: 'us-southeast-1' },
{ name: 'Chennai', value: 'https://in-maa-1.linodeobjects.com', region: 'in-maa-1' },
{ name: 'Chicago', value: 'https://us-ord-1.linodeobjects.com', region: 'us-ord-1' },
{ name: 'Frankfurt', value: 'https://eu-central-1.linodeobjects.com', region: 'eu-central-1' },
{ name: 'Jakarta', value: 'https://id-cgk-1.linodeobjects.com', region: 'id-cgk-1' },
{ name: 'Los Angeles, CA (USA)', value: 'https://us-lax-1.linodeobjects.com', region: 'us-lax-1' },
{ name: 'Miami', value: 'https://us-mia-1.linodeobjects.com', region: 'us-mia-1' },
{ name: 'Milan', value: 'https://it-mil-1.linodeobjects.com', region: 'it-mil-1' },
{ name: 'Newark', value: 'https://us-east-1.linodeobjects.com', region: 'us-east-1' }, // default
{ name: 'Osaka', value: 'https://jp-osa-1.linodeobjects.com', region: 'jp-osa-1' },
{ name: 'Paris', value: 'https://fr-par-1.linodeobjects.com', region: 'fr-par-1' },
{ name: 'Sao Paulo', value: 'https://br-gru-1.linodeobjects.com', region: 'br-gru-1' },
{ name: 'Seattle', value: 'https://us-sea-1.linodeobjects.com', region: 'us-sea-1' },
{ name: 'Singapore', value: 'https://ap-south-1.linodeobjects.com', region: 'ap-south-1' },
{ name: 'Stockholm', value: 'https://se-sto-1.linodeobjects.com', region: 'se-sto-1' },
{ name: 'Washington', value: 'https://us-iad-1.linodeobjects.com', region: 'us-iad-1' },
];
// note: ovh also has a storage endpoint but that only supports path style access (https://docs.ovh.com/au/en/storage/object-storage/s3/location/)
@@ -213,6 +227,16 @@ const REGIONS_OVH = [
{ name: 'Warsaw (WAW)', value: 'https://s3.waw.cloud.ovh.net', region: 'waw' },
];
const ENDPOINTS_OVH = [
{ name: 'OVH Europe', value: 'ovh-eu' },
{ name: 'OVH US', value: 'ovh-us' },
{ name: 'OVH North-America', value: 'ovh-ca' },
{ name: 'SoYouStart Europe', value: 'soyoustart-eu' },
{ name: 'SoYouStart North-America', value: 'soyoustart-ca' },
{ name: 'Kimsufi Europe', value: 'kimsufi-eu' },
{ name: 'Kimsufi North-America', value: 'kimsufi-ca' },
];
// https://docs.ionos.com/cloud/managed-services/s3-object-storage/endpoints
const REGIONS_IONOS = [
{ name: 'Frankfurt (DE)', value: 'https://s3-de-central.profitbricks.com', region: 's3-de-central' }, // default
@@ -1064,6 +1088,7 @@ angular.module('Application').service('Client', ['$http', '$interval', '$timeout
callback(null, data);
});
};
Client.prototype.remountBackupStorage = function (callback) {
post('/api/v1/backups/remount', {}, null, function (error, data, status) {
if (error) return callback(error);
@@ -1073,15 +1098,6 @@ angular.module('Application').service('Client', ['$http', '$interval', '$timeout
});
};
Client.prototype.getSupportConfig = function (callback) {
get('/api/v1/support/config', null, function (error, data, status) {
if (error) return callback(error);
if (status !== 200) return callback(new ClientError(status, data));
callback(null, data);
});
};
Client.prototype.setExternalLdapConfig = function (config, callback) {
post('/api/v1/external_ldap/config', config, null, function (error, data, status) {
if (error) return callback(error);
@@ -1956,11 +1972,9 @@ angular.module('Application').service('Client', ['$http', '$interval', '$timeout
});
};
Client.prototype.addOidcClient = function (id, name, secret, loginRedirectUri, tokenSignatureAlgorithm, callback) {
Client.prototype.addOidcClient = function (name, loginRedirectUri, tokenSignatureAlgorithm, callback) {
var data = {
id: id,
name: name,
secret: secret,
loginRedirectUri: loginRedirectUri,
tokenSignatureAlgorithm: tokenSignatureAlgorithm
};
@@ -1973,9 +1987,8 @@ angular.module('Application').service('Client', ['$http', '$interval', '$timeout
});
};
Client.prototype.updateOidcClient = function (id, name, secret, loginRedirectUri, tokenSignatureAlgorithm, callback) {
Client.prototype.updateOidcClient = function (id, name, loginRedirectUri, tokenSignatureAlgorithm, callback) {
var data = {
secret: secret,
name: name,
loginRedirectUri: loginRedirectUri,
tokenSignatureAlgorithm, tokenSignatureAlgorithm
@@ -2044,7 +2057,7 @@ angular.module('Application').service('Client', ['$http', '$interval', '$timeout
};
Client.prototype.isRebootRequired = function (callback) {
get('/api/v1/system/reboot', null, function (error, data, status) {
get('/api/v1/system/info', null, function (error, data, status) {
if (error) return callback(error);
if (status !== 200) return callback(new ClientError(status, data));
@@ -2097,6 +2110,26 @@ angular.module('Application').service('Client', ['$http', '$interval', '$timeout
});
};
Client.prototype.systemInfo = function (callback) {
get('/api/v1/system/info', null, function (error, data, status) {
if (error) return callback(error);
if (status !== 200) return callback(new ClientError(status, data));
console.log(data)
callback(null, data.info);
});
};
Client.prototype.cpus = function (callback) {
get('/api/v1/system/cpus', null, function (error, data, status) {
if (error) return callback(error);
if (status !== 200) return callback(new ClientError(status, data));
callback(null, data.cpus);
});
};
Client.prototype.memory = function (callback) {
get('/api/v1/system/memory', null, function (error, data, status) {
if (error) return callback(error);
@@ -3474,11 +3507,24 @@ angular.module('Application').service('Client', ['$http', '$interval', '$timeout
});
};
Client.prototype.registerCloudronWithSetupToken = function (setupToken, callback) {
var data = {
setupToken: setupToken
};
post('/api/v1/appstore/register_cloudron_with_setup_token', data, null, function (error, data, status) {
if (error) return callback(error);
if (status !== 201) return callback(new ClientError(status, data));
callback(null);
});
};
Client.prototype.registerCloudron = function (email, password, totpToken, signup, callback) {
var data = {
email: email,
password: password,
signup: signup,
signup: signup
};
if (totpToken) data.totpToken = totpToken;

View File

@@ -127,6 +127,7 @@ app.filter('notificadtionTypeToColor', function () {
case NOTIFICATION_TYPES.ALERT_CERTIFICATE_RENEWAL_FAILED:
case NOTIFICATION_TYPES.ALERT_DISK_SPACE:
case NOTIFICATION_TYPES.ALERT_BACKUP_CONFIG:
case NOTIFICATION_TYPES.ALERT_BACKUP_FAILED:
return '#ff4c4c';
case NOTIFICATION_TYPES.ALERT_BOX_UPDATE:
case NOTIFICATION_TYPES.ALERT_MANUAL_APP_UPDATE:

View File

@@ -243,7 +243,7 @@ app.controller('RestoreController', ['$scope', 'Client', function ($scope, Clien
provider: $scope.sysinfo.provider
};
if ($scope.sysinfo.provider === 'fixed') {
sysinfoConfig.ipv4 = $scope.sysinfo.ipv4;
sysinfoConfig.ip = $scope.sysinfo.ipv4;
} else if ($scope.sysinfo.provider === 'network-interface') {
sysinfoConfig.ifname = $scope.sysinfo.ifname;
}

View File

@@ -1,6 +1,6 @@
'use strict';
/* global $, tld, angular, Clipboard */
/* global $, tld, angular, Clipboard, ENDPOINTS_OVH */
// create main application module
var app = angular.module('Application', ['pascalprecht.translate', 'ngCookies', 'angular-md5', 'ui-notification', 'ui.bootstrap']);
@@ -55,6 +55,8 @@ app.controller('SetupDNSController', ['$scope', '$http', '$timeout', 'Client', f
}
};
$scope.ovhEndpoints = ENDPOINTS_OVH;
$scope.needsPort80 = function (dnsProvider, tlsProvider) {
return ((dnsProvider === 'manual' || dnsProvider === 'noop' || dnsProvider === 'wildcard') &&
(tlsProvider === 'letsencrypt-prod' || tlsProvider === 'letsencrypt-staging'));
@@ -82,6 +84,7 @@ app.controller('SetupDNSController', ['$scope', '$http', '$timeout', 'Client', f
{ name: 'Bunny', value: 'bunny' },
{ name: 'Cloudflare', value: 'cloudflare' },
{ name: 'DigitalOcean', value: 'digitalocean' },
{ name: 'DNSimple', value: 'dnsimple' },
{ name: 'Gandi LiveDNS', value: 'gandi' },
{ name: 'GoDaddy', value: 'godaddy' },
{ name: 'Google Cloud DNS', value: 'gcdns' },
@@ -90,6 +93,7 @@ app.controller('SetupDNSController', ['$scope', '$http', '$timeout', 'Client', f
{ name: 'Name.com', value: 'namecom' },
{ name: 'Namecheap', value: 'namecheap' },
{ name: 'Netcup', value: 'netcup' },
{ name: 'OVH', value: 'ovh' },
{ name: 'Porkbun', value: 'porkbun' },
{ name: 'Vultr', value: 'vultr' },
{ name: 'Wildcard', value: 'wildcard' },
@@ -112,6 +116,7 @@ app.controller('SetupDNSController', ['$scope', '$http', '$timeout', 'Client', f
godaddyApiSecret: '',
linodeToken: '',
bunnyAccessKey: '',
dnsimpleAccessToken: '',
hetznerToken: '',
vultrToken: '',
nameComUsername: '',
@@ -121,6 +126,10 @@ app.controller('SetupDNSController', ['$scope', '$http', '$timeout', 'Client', f
netcupCustomerNumber: '',
netcupApiKey: '',
netcupApiPassword: '',
ovhEndpoint: 'ovh-eu',
ovhConsumerKey: '',
ovhAppKey: '',
ovhAppSecret: '',
porkbunSecretapikey: '',
porkbunApikey: '',
@@ -204,6 +213,8 @@ app.controller('SetupDNSController', ['$scope', '$http', '$timeout', 'Client', f
config.token = $scope.dnsCredentials.linodeToken;
} else if (provider === 'bunny') {
config.token = $scope.dnsCredentials.bunnyAccessKey;
} else if (provider === 'dnsimple') {
config.accessToken = $scope.dnsCredentials.dnsimpleAccessToken;
} else if (provider === 'hetzner') {
config.token = $scope.dnsCredentials.hetznerToken;
} else if (provider === 'vultr') {
@@ -218,6 +229,11 @@ app.controller('SetupDNSController', ['$scope', '$http', '$timeout', 'Client', f
config.customerNumber = $scope.dnsCredentials.netcupCustomerNumber;
config.apiKey = $scope.dnsCredentials.netcupApiKey;
config.apiPassword = $scope.dnsCredentials.netcupApiPassword;
} else if (provider === 'ovh') {
config.endpoint = $scope.dnsCredentials.ovhEndpoint;
config.consumerKey = $scope.dnsCredentials.ovhConsumerKey;
config.appKey = $scope.dnsCredentials.ovhAppKey;
config.appSecret = $scope.dnsCredentials.ovhAppSecret;
} else if (provider === 'porkbun') {
config.apikey = $scope.dnsCredentials.porkbunApikey;
config.secretapikey = $scope.dnsCredentials.porkbunSecretapikey;
@@ -236,7 +252,7 @@ app.controller('SetupDNSController', ['$scope', '$http', '$timeout', 'Client', f
provider: $scope.sysinfo.provider
};
if ($scope.sysinfo.provider === 'fixed') {
sysinfoConfig.ipv4 = $scope.sysinfo.ipv4;
sysinfoConfig.ip = $scope.sysinfo.ipv4;
} else if ($scope.sysinfo.provider === 'network-interface') {
sysinfoConfig.ifname = $scope.sysinfo.ifname;
}

View File

@@ -226,15 +226,39 @@
<input type="text" class="form-control" ng-model="dnsCredentials.bunnyAccessKey" name="bunnyAccessKey" ng-required="dnsCredentials.provider === 'bunny'" ng-disabled="dnsCredentials.busy">
</p>
<!-- dnsimple -->
<p class="form-group" ng-show="dnsCredentials.provider === 'dnsimple'">
<label class="control-label">Access Token</label>
<input type="text" class="form-control" ng-model="dnsCredentials.dnsimpleAccessToken" name="dnsimpleAccessToken" ng-required="dnsCredentials.provider === 'dnsimple'" ng-disabled="dnsCredentials.busy">
</p>
<!-- OVH -->
<p class="form-group" ng-show="dnsCredentials.provider === 'ovh'">
<label class="control-label" for="inputConfigureOvhEndpoint">Endpoint</label>
<select class="form-control" name="endpoint" id="inputConfigureOvhEndpoint" ng-model="dnsCredentials.ovhEndpoint" ng-options="a.value as a.name for a in ovhEndpoints" ng-disabled="dnsCredentials.busy" ng-required="dnsCredentials.provider === 'ovh'"></select>
</p>
<p class="form-group" ng-show="dnsCredentials.provider === 'ovh'">
<label class="control-label">Consumer Key</label>
<input type="text" class="form-control" ng-model="dnsCredentials.ovhConsumerKey" name="ovhConsumerKey" ng-disabled="dnsCredentials.busy" ng-required="dnsCredentials.provider === 'ovh'">
</p>
<p class="form-group" ng-show="dnsCredentials.provider === 'ovh'">
<label class="control-label">Application Key</label>
<input type="text" class="form-control" ng-model="dnsCredentials.ovhAppKey" name="ovhAppKey" ng-disabled="dnsCredentials.busy" ng-minlength="1" ng-required="dnsCredentials.provider === 'ovh'">
</p>
<p class="form-group" ng-show="dnsCredentials.provider === 'ovh'">
<label class="control-label">Application Secret</label>
<input type="text" class="form-control" ng-model="dnsCredentials.ovhAppSecret" name="ovhAppSecret" ng-disabled="dnsCredentials.busy" ng-required="dnsCredentials.provider === 'ovh'">
</p>
<!-- Porkbun -->
<div class="form-group" ng-class="{ 'has-error': dnsCredentialsForm.porkbunApikey.$dirty && dnsCredentialsForm.porkbunApikey.$invalid }" ng-show="dnsCredentials.provider === 'porkbun'">
<p class="form-group" ng-class="{ 'has-error': dnsCredentialsForm.porkbunApikey.$dirty && dnsCredentialsForm.porkbunApikey.$invalid }" ng-show="dnsCredentials.provider === 'porkbun'">
<label class="control-label">API Key</label>
<input type="text" class="form-control" ng-model="dnsCredentials.porkbunApikey" name="porkbunApikey" placeholder="API Key" ng-minlength="1" ng-required="dnsCredentials.provider === 'porkbun'" ng-disabled="dnsCredentials.busy">
</div>
<div class="form-group" ng-class="{ 'has-error': dnsCredentialsForm.porkbunSecretapikey.$dirty && dnsCredentialsForm.porkbunSecretapikey.$invalid }" ng-show="dnsCredentials.provider === 'porkbun'">
<label class="control-label">API Secret</label>
<input type="text" class="form-control" ng-model="dnsCredentials.porkbunSecretapikey" name="porkbunSecretapikey" placeholder="API Secret" ng-required="dnsCredentials.provider === 'porkbun'" ng-disabled="dnsCredentials.busy">
</div>
</p>
<p class="form-group" ng-class="{ 'has-error': dnsCredentialsForm.porkbunSecretapikey.$dirty && dnsCredentialsForm.porkbunSecretapikey.$invalid }" ng-show="dnsCredentials.provider === 'porkbun'">
<label class="control-label">API Secret</label>
<input type="text" class="form-control" ng-model="dnsCredentials.porkbunSecretapikey" name="porkbunSecretapikey" placeholder="API Secret" ng-required="dnsCredentials.provider === 'porkbun'" ng-disabled="dnsCredentials.busy">
</p>
<!-- Hetzner -->
<p class="form-group" ng-show="dnsCredentials.provider === 'hetzner'">

View File

@@ -1088,6 +1088,10 @@ multiselect {
max-width: 970px;
}
.card-expand {
max-width: initial;
}
.text-success {
color: #5CB85C;
}

View File

@@ -170,7 +170,10 @@
"loginAction": "Login",
"createAccountAction": "Create Account",
"switchToSignUpAction": "Don't have an account yet? Sign up",
"switchToLoginAction": "Already have an account? Log in"
"switchToLoginAction": "Already have an account? Log in",
"setupWithTokenAction": "Setup",
"setupToken": "Setup Token",
"titleToken": "Sign up with Setup Token"
},
"categoryLabel": "Category",
"ssofilter": {
@@ -949,6 +952,10 @@
"warning": "Do not enable this option unless requested by the Cloudron support team.",
"disableAction": "Disable SSH support access",
"enableAction": "Enable SSH support access"
},
"help": {
"title": "Help",
"description": "Please use the following resources for help and support:\n* [Cloudron Forum]({{ forumLink }}) - Please use the Support and App specific categories for questions.\n* [Cloudron Docs & Knowledge Base]({{ docsLink }})\n* [Custom App Packaging & API]({{ packagingLink }})\n"
}
},
"system": {
@@ -973,7 +980,19 @@
"graphTitle": "Percentage",
"graphSubtext": "Only apps using more than {{ threshold }} of cpu are shown"
},
"selectPeriodLabel": "Select Period"
"selectPeriodLabel": "Select Period",
"info": {
"platformVersion": "Platform Version",
"title": "Info",
"vendor": "Vendor",
"product": "Product",
"memory": "Memory",
"uptime": "Uptime",
"activationTime": "Cloudron Creation Time"
},
"graphs": {
"title": "Graphs"
}
},
"eventlog": {
"title": "Event Log",
@@ -1055,7 +1074,12 @@
"cloudflareDefaultProxyStatus": "Enable proxying for new DNS records",
"porkbunApikey": "API Key",
"porkbunSecretapikey": "Secret API Key",
"bunnyAccessKey": "Bunny Access Key"
"bunnyAccessKey": "Bunny Access Key",
"dnsimpleAccessToken": "Access Token",
"ovhEndpoint": "Endpoint",
"ovhConsumerKey": "Consumer Key",
"ovhAppKey": "Application Key",
"ovhAppSecret": "Application Secret"
},
"removeDialog": {
"title": "Really remove {{ domain }}?",

View File

@@ -62,7 +62,7 @@
"switchToLoginAction": "¿Ya tienes una cuenta? Inicia sesión",
"switchToSignUpAction": "¿No tienes una cuenta todavía? Regístrate",
"createAccountAction": "Crear Cuenta",
"loginAction": "Iniciar sesión",
"loginAction": "Iniciar Sesión",
"errorWrongPassword": "Contraseña errónea",
"licenseCheckbox": "Acepto la <a href=\"{{ licenseLink }}\" target=\"_blank\">licencia de Cloudron</a>",
"chooseAnOption": "Por favor escoge una opción…",
@@ -97,7 +97,8 @@
},
"action": {
"logs": "Registros",
"reboot": "Reiniciar"
"reboot": "Reiniciar",
"showLogs": "Mostrar registros"
},
"pagination": {
"perPageSelector": "Mostrar {{ n }} por página",
@@ -141,7 +142,8 @@
"statusEnabled": "Habilitado",
"statusDisabled": "Deshabilitado",
"loadingPlaceholder": "Cargando",
"settings": "Ajustes"
"settings": "Ajustes",
"saveAction": "Guardar"
},
"apps": {
"domainsFilterHeader": "Todos los Dominios",
@@ -166,7 +168,8 @@
"auth": {
"nosso": "Inicia sesión con una cuenta dedicada",
"sso": "Inicia sesión con las credenciales de Cloudron",
"email": "Inicia sesión con el correo electrónico"
"email": "Inicia sesión con el correo electrónico",
"openid": "Iniciar sesión con Cloudron OpenID"
},
"addAppAction": "Añadir Aplicación",
"addAppproxyAction": "Añadir Proxi de la Aplicación",
@@ -218,7 +221,7 @@
"subscriptionRequired": "Estas características solo están habilitadas para planes de pago.",
"require2FACheckbox": "Requerir que los usuarios configuren 2FA",
"allowProfileEditCheckbox": "Permitir a los usuarios editar su nombre y correo",
"title": "Ajustes",
"title": "Ajustes de usuario",
"require2FAWarning": "Configura primero 2FA para tu cuenta para evitar que la bloqueen."
},
"groups": {
@@ -521,7 +524,8 @@
"preserved": {
"description": "Copia de seguridad persistente independientemente de la política de retención",
"tooltip": "Esto también conservará el correo y las copias de seguridad de la aplicación {{ appsLength }}."
}
},
"remotePath": "Ruta remota"
}
},
"profile": {
@@ -611,7 +615,7 @@
"errorPasswordsDontMatch": "Las contraseñas no coinciden",
"errorPasswordRequired": "Se requiere una contraseña",
"newPasswordRepeat": "Repite nueva contraseña",
"newPassword": "Nueva contraseña",
"newPassword": "Nueva Contraseña",
"currentPassword": "Contraseña actual",
"title": "Cambia tu contraseña"
},
@@ -632,7 +636,8 @@
},
"changeBackgroundImage": {
"title": "Establecer imagen de fondo"
}
},
"enable2FANotAvailable": "No disponible para usuarios de una fuente de autentificación externa"
},
"emails": {
"eventlog": {
@@ -679,7 +684,8 @@
"info": "Esta configuración es global y se aplica a todos los dominios.",
"title": "Ajustes",
"acl": "Correo ACL",
"aclOverview": "{{ dnsblZonesCount }} zona(s) DNSBL"
"aclOverview": "{{ dnsblZonesCount }} zona(s) DNSBL",
"virtualAllMail": "Carpeta \"Todos los correos\""
},
"domains": {
"testEmailTooltip": "Enviar Email de prueba",
@@ -722,7 +728,7 @@
"manualInfo": "Agrega un registro A manualmente para el {{dominio}} a la IP pública de este Cloudron",
"locationPlaceholder": "Dejar vacío para usar el dominio desnudo",
"location": "Ubicación",
"description": "Cloudron realizará los cambios de DNS necesarios en todos los dominios y reiniciará el servidor de correo. Los clientes de correo electrónico de escritorio y móviles deben reconfigurarse para usar esta nueva ubicación como servidor IMAP y SMTP.",
"description": "Esto moverá el servidor IMAP y SMTP a la ubicación especificada.",
"title": "Cambiar ubicación del Servidor de Correo"
},
"aclDialog": {
@@ -750,6 +756,10 @@
},
"action": {
"queue": "Cola"
},
"changeVirtualAllMailDialog": {
"title": "Carpeta \"Todos los correos\"",
"description": "La carpeta \"Todos los correos\" es una carpeta única que contiene todos los correos electrónicos de su bandeja de entrada. La carpeta puede resultar útil en clientes de correo que no admiten la búsqueda recursiva de carpetas."
}
},
"branding": {
@@ -794,7 +804,8 @@
},
"dyndns": {
"description": "Habilite esta opción para mantener todos sus registros DNS sincronizados con una dirección IP cambiante. Esto es útil cuando Cloudron se ejecuta en una red con una dirección IP pública que cambia con frecuencia, como una conexión doméstica.",
"title": "DNS Dinámico"
"title": "DNS Dinámico",
"showLogsAction": "Mostrar registros"
},
"ipv4": {
"address": "Dirección IPv4"
@@ -806,7 +817,13 @@
},
"configureIpv6": {
"title": "Configurar Proveedor de IPv6"
}
},
"trustedIps": {
"summary": "{{ trustCount }} IPs confiables",
"description": "Se confiará en los encabezados HTTP de direcciones IP coincidentes",
"title": "Configurar IP confiables"
},
"trustedIpRanges": "Rangos e IPs confiables "
},
"services": {
"configure": {
@@ -826,7 +843,7 @@
"service": "Servicio",
"description": "Los servicios de Cloudron implementan funcionalidades como bases de datos, correo electrónico y autentificación.",
"title": "Servicios",
"refresh": "Actualizar"
"refresh": "Refrescar"
},
"settings": {
"appstoreAccount": {
@@ -905,7 +922,7 @@
"domains": {
"title": "Dominios y Certificados",
"changeDashboardDomain": {
"description": "Esto moverá el Panel y el Servidor de Correo al subdominio <code>my</code> del dominio seleccionado.",
"description": "Esto moverá el panel al subdominio <code>my</code> del dominio seleccionado.",
"showLogsAction": "Mostrar Registros",
"cancelAction": "Cancelar",
"changeAction": "Cambiar Dominio",
@@ -1047,7 +1064,8 @@
"dataDirPlaceholder": "Dejar vacío para usar la plataforma predeterminada",
"description": "Si el servidor se está quedando sin espacio en disco, usa esto para mover los datos de la aplicación a un <a href=\"/#/volumes\">volumen</a>. Cualquier dato aquí es parte de la copia de seguridad de la aplicación.",
"moveAction": "Mover datos",
"diskUsage": "Actualmente, la aplicación está usando {{ size }} de almacenamiento (hasta el {{ date }})."
"diskUsage": "Actualmente, la aplicación está usando {{ size }} de almacenamiento (hasta el {{ date }}).",
"mountTypeWarning": "El sistema de archivos de destino debe admitir permisos y propiedad de los archivos para que el traslado funcione"
}
},
"logsActionTooltip": "Registros",
@@ -1321,6 +1339,17 @@
"label": "Etiqueta",
"clearIconAction": "Borrar icono",
"clearIconDescription": "Esto intentará obtener el favicon de la aplicación al guardar."
},
"servicesTabTitle": "Servicios",
"turn": {
"title": "Configuración de TURN",
"enable": "Configura la aplicación para utilizar el servidor TURN integrado",
"disable": "No configures los ajustes de la aplicación TURN. Su configuración se deja como está. Puedes hacer los ajustes dentro de la aplicación."
},
"redis": {
"title": "Configuración de Redis",
"enable": "Configura la aplicación para usar Redis",
"disable": "Deshabilitar Redis"
}
},
"lang": {
@@ -1389,7 +1418,8 @@
"sshCheckbox": "Permitir que los ingenieros de soporte se conecten a este servidor a través de SSH",
"emailPlaceholder": "Si es necesario, proporciona una dirección de correo electrónico diferente de la anterior para contactarte",
"emailVerifyAction": "Verificar ahora",
"emailNotVerified": "El correo electrónico de su cuenta cloudron.io {{email}} no está verificado. Verifíquelo para abrir tickets de soporte."
"emailNotVerified": "El correo electrónico de su cuenta cloudron.io {{email}} no está verificado. Verifíquelo para abrir tickets de soporte.",
"typeBilling": "Problema de facturación"
},
"title": "Soporte"
},
@@ -1428,7 +1458,11 @@
"title": "Actualizar Volumen {{ volume }}"
},
"tooltipEdit": "Editar Volumen",
"remountActionTooltip": "Volver a montar Volumen"
"remountActionTooltip": "Volver a montar Volumen",
"editVolumeDialog": {
"title": "Editar volumen {{ name }}"
},
"editActionTooltip": "Editar Volumen"
},
"eventlog": {
"filterAllEvents": "Todos los Eventos",
@@ -1507,7 +1541,8 @@
"copy": "Copiar",
"paste": "Pegar",
"selectAll": "Seleccionar todo",
"download": "Descargar"
"download": "Descargar",
"open": "Abrir"
},
"mtime": "Modificado"
},
@@ -1522,12 +1557,26 @@
},
"extract": {
"error": "La extracción falló: {{ message }}"
}
},
"extractionInProgress": "Extracción en progreso",
"uploader": {
"exitWarning": "Subida en progreso... ¿quieres realmente cerrar esta página?",
"uploading": "Subiendo"
},
"textEditor": {
"undo": "Deshacer",
"redo": "Rehacer",
"save": "Guardar"
},
"pasteInProgress": "Pegado en progreso",
"deleteInProgress": "Borrado en progreso"
},
"logs": {
"download": "Descarga los Registros Completos",
"clear": "Borrar Vista",
"title": "Registros"
"title": "Registros",
"notFoundError": "No existe esa tarea o aplicación",
"logsGoneError": "Archivo(s) de registro no encontrados"
},
"email": {
"signature": {
@@ -1763,7 +1812,7 @@
"newPassword": {
"errorLength": "La contraseña debe tener al menos 8 y un máximo de 265 caracteres",
"title": "Establecer nueva contraseña",
"password": "Nueva contraseña",
"password": "Nueva Contraseña",
"passwordRepeat": "Repetir Contraseña",
"errorMismatch": "Las contraseñas no coinciden"
},
@@ -1823,7 +1872,7 @@
"username": "Nombre de usuario",
"password": "Contraseña",
"2faToken": "Token 2FA (si está habilitado)",
"signInAction": "Iniciar sesión",
"signInAction": "Iniciar Sesión",
"resetPasswordAction": "Resetear contraseña",
"errorIncorrect2FAToken": "El token 2FA es inválido",
"errorInternal": "Error interno, prueba de nuevo más tarde"
@@ -1879,5 +1928,6 @@
"newClient": "Nuevo cliente",
"empty": "No hay clientes aún"
}
}
},
"automation": "Automatización"
}

View File

@@ -22,7 +22,8 @@
"auth": {
"nosso": "Log in met specifiek account",
"sso": "Log in met Cloudron aanmeldgegevens",
"email": "Log in met e-mailadres"
"email": "Log in met e-mailadres",
"openid": "Log in met Cloudron OpenID"
},
"addAppAction": "App toevoegen",
"addAppproxyAction": "App Proxy toevoegen",
@@ -164,7 +165,10 @@
"loginAction": "Inloggen",
"createAccountAction": "Account aanmaken",
"switchToSignUpAction": "Nog geen account? Registreer",
"switchToLoginAction": "Al een account? Log in"
"switchToLoginAction": "Al een account? Log in",
"setupWithTokenAction": "Instellen",
"setupToken": "Instel Token",
"titleToken": "Inloggen met Instel Token"
},
"searchPlaceholder": "Zoek voor alternatieven zoals Github, Dropbox, Slack, Trello, …",
"appNotFoundDialog": {
@@ -823,7 +827,12 @@
"cloudflareDefaultProxyStatus": "Inschakelen proxy voor nieuwe DNS regels",
"porkbunApikey": "API sleutel",
"porkbunSecretapikey": "Geheime API sleutel",
"bunnyAccessKey": "Bunny toegangssleutel"
"bunnyAccessKey": "Bunny toegangssleutel",
"dnsimpleAccessToken": "Toegangstoken",
"ovhEndpoint": "Eindpunt",
"ovhConsumerKey": "Consumer sleutel",
"ovhAppKey": "Applicatie sleutel",
"ovhAppSecret": "Applicatie geheim"
},
"title": "Domeinen & Certificaten",
"addDomain": "Domein toevoegen",
@@ -1381,6 +1390,10 @@
"disableAction": "SSH ondersteuningstoegang uitschakelen",
"enableAction": "SSH ondersteuningstoegang inschakelen",
"description": "Met het inschakelen van deze optie geeft je ondersteuningsmedewerkers toegang tot deze server middels SSH."
},
"help": {
"title": "Hulp",
"description": "Om problemen op te lossen met Cloudron hebben we verschillende bronnen:\n* [Kennisbank & App Docs]({{ docsLink }})\n* [Eigen App Packaging & API]({{ packagingLink }})\n* [Forum]({{ forumLink }})"
}
},
"system": {
@@ -1405,7 +1418,19 @@
"graphTitle": "Percentage",
"graphSubtext": "Alleen apps die meer dan {{ threshold }} van de CPU gebruiken worden getoond"
},
"selectPeriodLabel": "Selecteer periode"
"selectPeriodLabel": "Selecteer periode",
"info": {
"title": "Info",
"vendor": "Leverancier",
"memory": "Geheugen",
"uptime": "Uptime",
"activationTime": "Cloudron installatie tijd",
"platformVersion": "Platform Versie",
"product": "Product"
},
"graphs": {
"title": "Grafieken"
}
},
"eventlog": {
"title": "Logboek",
@@ -1812,7 +1837,11 @@
"mountStatus": "Koppel status",
"localDirectory": "Lokale map",
"type": "Type",
"remountActionTooltip": "Her-koppel Volume"
"remountActionTooltip": "Her-koppel Volume",
"editVolumeDialog": {
"title": "Bewerk volume {{ name }}"
},
"editActionTooltip": "Bewerk Volume"
},
"lang": {
"it": "Italiaans",

View File

@@ -8,7 +8,8 @@
"auth": {
"sso": "Войдите, используя учётную запись Cloudron",
"email": "Войдите, используя email",
"nosso": "Войдите, используя Вашу учётную запись"
"nosso": "Войдите, используя Вашу учётную запись",
"openid": "Войти с помощью Cloudron OpenID"
},
"noAccess": {
"description": "После открытия доступа приложения отобразятся здесь.",
@@ -234,7 +235,7 @@
"groupBaseDn": "Групповой корневой элемент",
"groupFilter": "Фильтр группы",
"groupnameField": "Поле с именем группы",
"auth": "Войти",
"auth": "Авторизоваться",
"autocreateUsersOnLogin": "Автоматически создавать пользователей после их входа в Cloudron",
"showLogsAction": "Показать логи",
"syncAction": "Синхронизировать",
@@ -294,7 +295,7 @@
"description": "Ссылка для сброса пароля отправлена на электронную почту {{ email }}:",
"sendEmailLinkAction": "Отправить ссылку пользователю по электронной почте",
"emailSent": "Отправлено",
"newLinkAction": "Отправить ссылку для сброса пароля",
"newLinkAction": "Отправить ссылку для сброса",
"reset2FAAction": "Сбросить 2FA",
"sendAction": "Отправить письмо",
"descriptionLink": "Скопировать ссылку для сброса пароля",
@@ -409,7 +410,7 @@
"changePassword": {
"currentPassword": "Текущий пароль",
"errorPasswordInvalid": "Пароль должен быть не менее 8 и не более 265 символов",
"title": "Изменить пароль",
"title": "Изменить ваш пароль",
"newPassword": "Новый пароль",
"newPasswordRepeat": "Повторите новый пароль",
"errorPasswordRequired": "Требуется пароль",
@@ -976,7 +977,8 @@
"preserved": {
"description": "Хранить резервную копию, игнорируя политику хранения",
"tooltip": "Также будет сохранена почта и {{ appsLength } резервных копий."
}
},
"remotePath": "Удаленный путь"
}
},
"branding": {
@@ -1018,7 +1020,8 @@
"acl": "Почтовый ACL (Access Control List)",
"maxMailSize": "Максимальный размер письма",
"solrFts": "Полный поиск по тексту (Solr)",
"aclOverview": "{{ dnsblZonesCount }} DNSBL зон"
"aclOverview": "{{ dnsblZonesCount }} DNSBL зон",
"virtualAllMail": "Папка \"Вся почта\""
},
"eventlog": {
"title": "Журнал событий электронной почты",
@@ -1109,6 +1112,10 @@
},
"action": {
"queue": "Очередь"
},
"changeVirtualAllMailDialog": {
"title": "Папка \"Вся почта\"",
"description": "Папка \"Вся почта\" содержит все электронные письма из вашего почтового ящика. Данная папка может быть полезна в том случае, когда ваш почтовый клиент не поддерживает рекурсивный поиск по папкам."
}
},
"network": {
@@ -1389,7 +1396,12 @@
"cloudflareDefaultProxyStatus": "Активировать прокси для новых DNS записей",
"porkbunApikey": "API Ключ",
"porkbunSecretapikey": "Secret API Ключ",
"bunnyAccessKey": "Ключ доступа Bunny"
"bunnyAccessKey": "Ключ доступа Bunny",
"dnsimpleAccessToken": "Токен доступа",
"ovhEndpoint": "Конечная точка",
"ovhConsumerKey": "Ключ пользователя",
"ovhAppKey": "Ключ приложения",
"ovhAppSecret": "Секрет приложения"
},
"addDomain": "Добавить домен",
"removeDialog": {
@@ -1806,7 +1818,11 @@
"title": "Тома",
"hostPath": "Назначение",
"description": "Тома - локальные или удаленные файловые системы. Они могут быть использованы для хранения данных приложений или для создания общей директории для нескольких приложений.",
"localDirectory": "Локальный каталог"
"localDirectory": "Локальный каталог",
"editVolumeDialog": {
"title": "Редактирование тома {{ name }}"
},
"editActionTooltip": "Редактировать том"
},
"lang": {
"en": "Английский",

View File

@@ -18,12 +18,18 @@
"title": "Chưa có app cài đặt!",
"description": "Cài đặt một vài app nhé? Hãy xem trong <a href=\"{{ appStoreLink }}\">Cửa hàng App</a>"
},
"groupsFilterHeader": "Chọn nhóm",
"groupsFilterHeader": "Tất cả Nhóm",
"auth": {
"email": "Đăng nhập bằng email",
"sso": "Đăng nhập với tên & mật khẩu trên Cloudron",
"nosso": "Đăng nhập vào tài khoản riêng"
}
},
"addAppAction": "Thêm App",
"addApplinkAction": "Thêm đường link App",
"filter": {
"clearAll": "Xoá tất cả"
},
"addAppproxyAction": "Thêm proxy cho app"
},
"main": {
"logout": "Thoát",
@@ -32,7 +38,8 @@
"save": "Lưu",
"close": "Đóng",
"no": "Không",
"yes": "Có"
"yes": "Có",
"delete": "Xoá"
},
"username": "Tên đăng nhập",
"displayName": "Tên hiển thị",
@@ -42,7 +49,8 @@
"pagination": {
"prev": "trước",
"next": "tiếp",
"perPageSelector": "Hiển thị {{ n }} trên một trang"
"perPageSelector": "Hiển thị {{ n }} trên một trang",
"itemCount": "Đã tìm thấy {{ count }}"
},
"action": {
"reboot": "Khởi động lại",
@@ -79,7 +87,9 @@
"users": "Người dùng"
},
"enableAction": "Bật",
"disableAction": "Tắt"
"disableAction": "Tắt",
"loadingPlaceholder": "Đang tải",
"settings": "Cài đặt"
},
"appstore": {
"title": "Cửa hàng App",
@@ -134,7 +144,8 @@
"configuredForCloudronEmail": "App này đã được cấu hình sẵn để sử dụng với <a href=\"{{ emailDocsLink }}\" target=\"_blank\">Cloudron Email</a>.",
"doInstallAction": "Tải về {{ dnsOverwrite ? 'and overwrite DNS' : '' }}",
"cloudflarePortWarning": "Cần tắt proxy Cloudflare để tên miền app này có thể truy cập được vào cổng",
"titleAndVersion": "App này đóng gói phần mềm {{ title }} {{ version }}"
"titleAndVersion": "App này đóng gói phần mềm {{ title }} {{ version }}",
"portReadOnly": "chỉ-đọc"
},
"appNotFoundDialog": {
"title": "Không tìm thấy app",
@@ -256,7 +267,7 @@
"subscriptionRequired": "Chức năng này chỉ có trong gói trả phí.",
"require2FACheckbox": "Yêu cầu người dùng cài đặt Mã xác minh 2 bước",
"allowProfileEditCheckbox": "Cho phép người dùng chỉnh sửa tên và email",
"title": "Cài đặt",
"title": "Cài đặt Người dùng",
"require2FAWarning": "Hãy cài đặt Mã xác minh 2 Bước cho tài khoản của bạn trước đề phòng bị khoá ra khỏi TK."
},
"groups": {
@@ -328,8 +339,9 @@
"label": "Giới hạn quyền truy cập"
},
"secret": {
"label": "Mã bí mật",
"description": "Tất cả những yêu cầu LDAP cần phải được xác minh với mã bí mật này và tên người dùng user DN <i>{{ userDN }}</i>"
"label": "Mật khẩu bind",
"description": "Tất cả những yêu cầu LDAP cần phải được xác minh với mã bí mật này và tên người dùng user DN <i>{{ userDN }}</i>",
"url": "URL máy chủ"
}
},
"userImportDialog": {
@@ -435,7 +447,8 @@
"description": "Mã API mới:",
"copyNow": "Xin copy mã API này bây giờ. Nó sẽ không được hiển thị lại vì lý do an ninh.",
"generateToken": "Tạo mã API",
"name": "Tên cho mã API"
"name": "Tên cho mã API",
"access": "Truy cập API"
},
"enable2FAAction": "Bật xác minh hai bước",
"primaryEmail": "Email chính",
@@ -458,7 +471,10 @@
"name": "Tên",
"expiresAt": "Hết hiệu lực vào",
"lastUsed": "Lần dùng cuối",
"neverUsed": "chưa từng dùng"
"neverUsed": "chưa từng dùng",
"readonly": "Chỉ đọc",
"scope": "Mức độ bao phủ",
"readwrite": "Đọc và Ghi"
},
"loginTokens": {
"title": "Mã đăng nhập",
@@ -540,7 +556,7 @@
"mountPoint": "Điểm mount",
"noopNote": "Lựa chọn này sẽ làm hỏng tính năng sao lưu và khôi phục của Cloudron và chỉ nên dùng khi test hệ thống. Xin đảm bảo rằng server được sao lưu toàn bộ bằng những phương tiện khác.",
"format": "Định dạng lưu trữ",
"encryptedFilenames": "Mã hoá tên tập tin",
"encryptedFilenames": "Tên tập tin đã mã hoá",
"chown": "Hệ thống tập tin bên ngoài có hỗ trợ chown",
"username": "Tên đăng nhập",
"server": "IP hoặc hostname máy chủ",
@@ -552,7 +568,8 @@
"user": "Người dùng",
"privateKey": "Mật mã riêng",
"diskPath": "Đường dẫn đến ổ đĩa",
"cifsSealSupport": "Dùng mã hoá SEAL. Cần SMB thấp nhất là phiên bản v3"
"cifsSealSupport": "Dùng mã hoá SEAL. Cần SMB thấp nhất là phiên bản v3",
"encryptFilenames": "Mã hoá tên tập tin"
},
"cleanupBackups": {
"description": "Các bản sao lưu được dọn sạch tự động dựa trên thời gian lưu giữ. Thao tác này sẽ xoá ngay lập tức các bản sao lưu đang có.",
@@ -879,7 +896,13 @@
},
"configureIpv6": {
"title": "Cài đặt nhà cung cấp IPv6"
}
},
"trustedIps": {
"summary": "{{ trustCount }} địa chỉ IP được tin tưởng",
"description": "Những HTTP header từ những địa chỉ IP trùng khớp sẽ được chấp thuận cho qua",
"title": "Thiết lập những địa chỉ IP đáng tin cậy"
},
"trustedIpRanges": "Địa chỉ IP & Vùng được tin cậy "
},
"emails": {
"typeFilterHeader": "Tất cả sự kiện",
@@ -914,7 +937,7 @@
"locationPlaceholder": "Để trống để dùng tên miền gốc",
"location": "Vị trí",
"title": "Thay đổi vị trí đặt mail server",
"description": "Cloudron sẽ thay đổi những giá trị DNS cần thiết cho tất cả tên miền và khởi động lại mail server. Những client nhận mail trên máy tính hay điện thoại cần được cài đặt lại để sử dụng vị trí mới này làm IMAP và SMTP server."
"description": "Hành động này sẽ di chuyển server IMAP và SMTP đến vị trí được xác định."
},
"eventlog": {
"searchPlaceholder": "Tìm kiếm",
@@ -933,7 +956,10 @@
"queued": "Xếp hàng",
"outgoing": "Gửi mail ra",
"incoming": "Nhận mail vào",
"deferred": "Trì hoãn lại"
"deferred": "Trì hoãn lại",
"overQuotaInfo": "Hộp thư {{ mailbox }} đã đầy {{ quotaPercent }}%",
"underQuotaInfo": "Hộp thư {{ mailbox }} đã rơi xuống còn {{ quotaPercent }}% của hạn mức",
"quota": "Hạn mức hộp thư"
},
"empty": "Log sự kiện hiện đang trống.",
"details": "Chi tiết",
@@ -950,8 +976,8 @@
"solrEnabled": "Đã bật",
"solrDisabled": "Đã tắt",
"changeDomainProgress": "Thay đổi tên miền email:",
"spamFilterOverview": "{{ blacklistCount }} email có trong danh sách đen.",
"location": "Nơi đặt mail server",
"spamFilterOverview": "{{ blacklistCount }} email có trong danh sách bị chặn.",
"location": "Nơi đặt máy chủ mail",
"spamFilter": "Lọc spam",
"maxMailSize": "Kích cỡ mail tối đa",
"info": "Các cài đặt này áp dụng cho tất cả các tên miền.",
@@ -981,6 +1007,19 @@
"dnsblZonesInfo": "Địa chỉ IP đang muốn kết nối đến được dò tìm trong những danh sách IP bị chặn này",
"dnsblZonesPlaceholder": "Tên vùng (ghi xuống dòng)",
"title": "Đổi danh sách quản lý truy cập mail"
},
"queue": {
"empty": "Danh sách mail chờ đang trống",
"title": "Danh sách mail chờ gửi",
"rcptTo": "Gửi cho",
"mailFrom": "Đến từ",
"details": "Chi tiết",
"discardTooltip": "Bỏ qua",
"queueTime": "Thời gian chờ",
"resendTooltip": "Gửi lại ngay"
},
"action": {
"queue": "Cho vào hàng chờ gửi sau"
}
},
"branding": {
@@ -1009,10 +1048,11 @@
"selectPeriodLabel": "Chọn khoảng thời gian",
"cpuUsage": {
"graphTitle": "Phần trăm sử dụng",
"title": "Dung lượng CPU"
"title": "Dung lượng CPU",
"graphSubtext": "Chỉ những app sử dụng hơn {{ threshold }} cpu mới được hiển thị"
},
"systemMemory": {
"graphSubtext": "Các giá trị bộ nhớ riêng từng app không hiển thị chồng lên nhau",
"graphSubtext": "Chỉ những app sử dụng hơn {{ threshold }} bộ nhớ mới được hiển thị",
"title": "Bộ nhớ hệ thống"
},
"diskUsage": {
@@ -1020,7 +1060,11 @@
"diskContent": "Ổ đĩa {{ type }} này hiện chứa",
"usageInfo": "Còn {{ available | prettyDiskSize }}</b> trống trong tổng <b>{{ size | prettyDiskSize }}</b>",
"mountedAt": "{{ filesystem }} <small>được gắn ở</small> {{ mountpoint }}",
"title": "Dung lượng ổ đĩa"
"title": "Dung lượng ổ đĩa",
"usedInfo": "{{ used }} đã dùng trong tổng {{ size }}",
"volumeContent": "Ổ đĩa này thuộc volume <code>{{ name }}</code>",
"uninstalledApp": "App đã xoá",
"diskSpeed": "Tốc độ: {{ speed }} MB/s"
},
"title": "Hệ thống"
},
@@ -1265,7 +1309,9 @@
"logs": {
"download": "Tải xuống tất cả log",
"clear": "Làm sạch phần xem log",
"title": "Log"
"title": "Log",
"notFoundError": "Không có tác vụ hay app đó",
"logsGoneError": "Tập tin log không được tìm thấy"
},
"notifications": {
"clearAll": "Xoá hết",
@@ -1323,7 +1369,11 @@
"wellKnownDescription": "Những giá trị nhập vào này sẽ được dùng bởi Cloudron để phản hồi về những đường link <code>/.well-known/</code>. Lưu ý rằng một app cần được đang chạy cài đặt sẵn trên tên miền gốc <code>{{ domain }}</code> để tính năng này có thể hoạt động được. Xem phần <a href=\"{{docsLink}}\" target=\"_blank\">hướng dẫn sử dụng</a> để biết thêm thông tin.",
"vultrToken": "Mật mã Vultr",
"jitsiHostname": "Vị trí Jitsi",
"hetznerToken": "Mật mã Hetzner"
"hetznerToken": "Mật mã Hetzner",
"cloudflareDefaultProxyStatus": "Bật tính năng proxy cho những bản ghi DNS mới",
"porkbunSecretapikey": "Mã bí mật API",
"bunnyAccessKey": "Mã truy cập Bunny",
"porkbunApikey": "Key API"
},
"subscriptionRequired": {
"description": "Để thêm tên miền, hãy đăng ký gói trả phí.",
@@ -1358,7 +1408,8 @@
"domainWellKnown": {
"title": "Những vị trí Well-Known của {{ domain }}"
},
"tooltipWellKnown": "Cài đặt những vị trí Well-Known"
"tooltipWellKnown": "Cài đặt những vị trí Well-Known",
"count": "Tổng số tên miền: {{ count }}"
},
"app": {
"appInfo": {

View File

@@ -2107,7 +2107,15 @@ angular.module('Application').controller('AppController', ['$scope', '$location'
};
Object.keys($scope.backupConfig).forEach(function (k) {
if ($scope.backupConfig[k] !== SECRET_PLACEHOLDER) tmp[k] = $scope.backupConfig[k];
var v = $scope.backupConfig[k];
if (v && typeof v === 'object') { // to hide mountOptions.password and the likes
tmp[k] = {};
Object.keys(v).forEach(function (j) {
if (v[j] !== SECRET_PLACEHOLDER) tmp[k][j] = v[j];
});
} else {
if ($scope.backupConfig[k] !== SECRET_PLACEHOLDER) tmp[k] = v;
}
});
var filename = 'app-backup-config-' + (new Date()).toISOString().replace(/:|T/g,'-').replace(/\..*/,'') + ' (' + $scope.app.fqdn + ')' + '.json';

View File

@@ -281,8 +281,9 @@
<!-- appstore login -->
<div ng-show="ready && !validSubscription" class="container card card-small appstore-login ng-cloak">
<div class="col-md-12 text-center">
<h1 ng-show="appstoreLogin.register">{{ 'appstore.accountDialog.titleSignUp' | tr }}</h1>
<h1 ng-hide="appstoreLogin.register">{{ 'appstore.accountDialog.titleLogin' | tr }}</h1>
<h1 ng-show="appstoreLogin.setupType === 'signup'">{{ 'appstore.accountDialog.titleSignUp' | tr }}</h1>
<h1 ng-show="appstoreLogin.setupType === 'login'">{{ 'appstore.accountDialog.titleLogin' | tr }}</h1>
<h1 ng-show="appstoreLogin.setupType === 'setupToken'">{{ 'appstore.accountDialog.titleToken' | tr }}</h1>
</div>
<div class="col-md-12 text-center">
<p>{{ 'appstore.accountDialog.description' | tr }}</p>
@@ -293,54 +294,121 @@
<div class="col-md-12">
<br/>
<form name="appstoreLoginForm" role="form" novalidate ng-submit="appstoreLogin.submit()" autocomplete="off">
<input type="password" style="display: none;">
<div ng-show="appstoreLogin.setupType === 'signup'">
<form name="appstoreSignupForm" role="form" novalidate ng-submit="appstoreLogin.submit()" autocomplete="off">
<input type="password" style="display: none;">
<div class="form-group" ng-class="{ 'has-error': (appstoreLoginForm.email.$dirty && appstoreLoginForm.email.$invalid) || appstoreLogin.error.generic }">
<div class="form-group" ng-class="{ 'has-error': (appstoreSignupForm.email.$dirty && appstoreSignupForm.email.$invalid) || appstoreLogin.error.generic }">
<label class="control-label">{{ 'appstore.accountDialog.email' | tr }}</label>
<input type="email" class="form-control" ng-model="appstoreLogin.email" id="inputAppstoreLoginEmail" name="email" required autofocus>
<div class="control-label" ng-show="(!appstoreLoginForm.email.$dirty && appstoreLogin.error.email) || (appstoreLoginForm.email.$dirty && appstoreLoginForm.email.$invalid) || appstoreLogin.error.email">
<small class="text-danger" ng-show="appstoreLogin.error.email">{{ appstoreLogin.error.email }}</small>
<input type="email" class="form-control" ng-model="appstoreLogin.email" id="inputAppstoreSignupEmail" name="email" required autofocus>
<div class="control-label" ng-show="(!appstoreSignupForm.email.$dirty && appstoreLogin.error.email) || (appstoreSignupForm.email.$dirty && appstoreSignupForm.email.$invalid) || appstoreLogin.error.email">
<small class="text-danger" ng-show="appstoreLogin.error.email">{{ appstoreLogin.error.email }}</small>
</div>
</div>
</div>
<div class="form-group" ng-class="{ 'has-error': (!appstoreLoginForm.password.$dirty && appstoreLogin.error.password) || (appstoreLoginForm.password.$dirty && appstoreLoginForm.password.$invalid) || appstoreLogin.error.generic }">
<div class="form-group" ng-class="{ 'has-error': (!appstoreSignupForm.password.$dirty && appstoreLogin.error.signupPassword) || (appstoreSignupForm.password.$dirty && appstoreSignupForm.password.$invalid) || appstoreLogin.error.generic }">
<label class="control-label">{{ 'appstore.accountDialog.password' | tr }}</label>
<input type="password" class="form-control" ng-model="appstoreLogin.password" id="inputAppstoreSignupPassword" name="password" required password-reveal>
<div class="control-label" ng-show="(!appstoreSignupForm.password.$dirty && appstoreLogin.error.signupPassword) || (appstoreSignupForm.password.$dirty && appstoreSignupForm.password.$invalid)">
<small ng-show="!appstoreSignupForm.password.$dirty && appstoreLogin.error.signupPassword">{{ 'appstore.accountDialog.errorWrongPassword' | tr }}</small>
</div>
</div>
<div class="checkbox">
<label>
<input type="checkbox" ng-model="appstoreLogin.termsAccepted" ng-required="true"><span ng-bind-html="'appstore.accountDialog.licenseCheckbox' | tr:{ licenseLink: 'https://cloudron.io/legal/license.html' }"></span>
</label>
</div>
<br/>
<center>
<button type="submit" class="btn btn-lg btn-success" ng-disabled="appstoreSignupForm.$invalid || appstoreLogin.busy">
<i class="fa fa-circle-notch fa-spin" ng-show="appstoreLogin.busy"></i> {{ 'appstore.accountDialog.createAccountAction' | tr }}
</button>
</center>
</form>
</div>
<div ng-show="appstoreLogin.setupType === 'login'">
<form name="appstoreLoginForm" role="form" novalidate ng-submit="appstoreLogin.submit()" autocomplete="off">
<input type="password" style="display: none;">
<div class="form-group" ng-class="{ 'has-error': (appstoreLoginForm.email.$dirty && appstoreLoginForm.email.$invalid) || appstoreLogin.error.generic }">
<label class="control-label">{{ 'appstore.accountDialog.email' | tr }}</label>
<input type="email" class="form-control" ng-model="appstoreLogin.email" name="email" required autofocus>
<div class="control-label" ng-show="(!appstoreLoginForm.email.$dirty && appstoreLogin.error.email) || (appstoreLoginForm.email.$dirty && appstoreLoginForm.email.$invalid) || appstoreLogin.error.email">
<small class="text-danger" ng-show="appstoreLogin.error.email">{{ appstoreLogin.error.email }}</small>
</div>
</div>
<div class="form-group" ng-class="{ 'has-error': (!appstoreLoginForm.password.$dirty && appstoreLogin.error.loginPassword) || (appstoreLoginForm.password.$dirty && appstoreLoginForm.password.$invalid) || appstoreLogin.error.generic }">
<label class="control-label">{{ 'appstore.accountDialog.password' | tr }}</label>
<input type="password" class="form-control" ng-model="appstoreLogin.password" id="inputAppstoreLoginPassword" name="password" required password-reveal>
<div class="control-label" ng-show="(!appstoreLoginForm.password.$dirty && appstoreLogin.error.password) || (appstoreLoginForm.password.$dirty && appstoreLoginForm.password.$invalid)">
<small ng-show="!appstoreLoginForm.password.$dirty && appstoreLogin.error.password">{{ 'appstore.accountDialog.errorWrongPassword' | tr }}</small>
<div class="control-label" ng-show="(!appstoreLoginForm.password.$dirty && appstoreLogin.error.loginPassword) || (appstoreLoginForm.password.$dirty && appstoreLoginForm.password.$invalid)">
<small ng-show="!appstoreLoginForm.password.$dirty && appstoreLogin.error.loginPassword">{{ 'appstore.accountDialog.errorWrongPassword' | tr }}</small>
</div>
</div>
</div>
<div class="form-group" ng-hide="appstoreLogin.register" ng-class="{ 'has-error': appstoreLogin.error.totpToken }">
<div class="form-group" ng-class="{ 'has-error': appstoreLogin.error.totpToken }">
<label class="control-label">{{ 'appstore.accountDialog.2faToken' | tr }}</label>
<input type="text" class="form-control" ng-model="appstoreLogin.totpToken" id="inputAppstoreLoginTotpToken" name="totpToken">
<div class="control-label" ng-show="appstoreLogin.error.totpToken">
<small ng-show="appstoreLogin.error.totpToken">{{ appstoreLogin.error.totpToken }}</small>
<small ng-show="appstoreLogin.error.totpToken">{{ appstoreLogin.error.totpToken }}</small>
</div>
</div>
</div>
<div class="checkbox">
<label>
<input type="checkbox" ng-model="appstoreLogin.termsAccepted"><span ng-bind-html="'appstore.accountDialog.licenseCheckbox' | tr:{ licenseLink: 'https://cloudron.io/legal/license.html' }"></span>
</label>
</div>
<br/>
<center>
<button type="submit" class="btn btn-lg btn-success" ng-disabled="appstoreLoginForm.$invalid || appstoreLogin.busy || !appstoreLogin.termsAccepted">
<i class="fa fa-circle-notch fa-spin" ng-show="appstoreLogin.busy"></i> <span ng-hide="appstoreLogin.register">{{ 'appstore.accountDialog.loginAction' | tr }}</span><span ng-show="appstoreLogin.register">{{ 'appstore.accountDialog.createAccountAction' | tr }}</span>
</button>
<div class="checkbox">
<label>
<input type="checkbox" ng-model="appstoreLogin.termsAccepted" ng-required="true"><span ng-bind-html="'appstore.accountDialog.licenseCheckbox' | tr:{ licenseLink: 'https://cloudron.io/legal/license.html' }"></span>
</label>
</div>
<br/>
<center>
<button type="submit" class="btn btn-lg btn-success" ng-disabled="appstoreLoginForm.$invalid || appstoreLogin.busy">
<i class="fa fa-circle-notch fa-spin" ng-show="appstoreLogin.busy"></i> {{ 'appstore.accountDialog.loginAction' | tr }}
</button>
</center>
</form>
</div>
<div ng-show="appstoreLogin.setupType === 'setupToken'">
<form name="appstoreSetupTokenForm" role="form" novalidate ng-submit="appstoreLogin.submit()" autocomplete="off">
<input type="password" style="display: none;">
<div class="form-group" ng-class="{ 'has-error': appstoreLogin.error.setupToken }">
<label class="control-label">{{ 'appstore.accountDialog.setupToken' | tr }}</label>
<input type="text" class="form-control" ng-model="appstoreLogin.setupToken" id="inputAppstoreSetupToken" name="setupToken" ng-required="true">
<div class="control-label" ng-show="appstoreLogin.error.setupToken">
<small ng-show="appstoreLogin.error.setupToken">{{ appstoreLogin.error.setupToken }}</small>
</div>
</div>
<div class="checkbox">
<label>
<input type="checkbox" ng-model="appstoreLogin.termsAccepted" ng-required="true"><span ng-bind-html="'appstore.accountDialog.licenseCheckbox' | tr:{ licenseLink: 'https://cloudron.io/legal/license.html' }"></span>
</label>
</div>
<br/>
<a href="" ng-click="appstoreLogin.register = true" ng-hide="appstoreLogin.register">{{ 'appstore.accountDialog.switchToSignUpAction' | tr }}</a>
<a href="" ng-click="appstoreLogin.register = false" ng-show="appstoreLogin.register">{{ 'appstore.accountDialog.switchToLoginAction' | tr }}</a>
</center>
<center>
<button type="submit" class="btn btn-lg btn-success" ng-disabled="appstoreSetupTokenForm.$invalid || appstoreLogin.busy">
<i class="fa fa-circle-notch fa-spin" ng-show="appstoreLogin.busy"></i> {{ 'appstore.accountDialog.setupWithTokenAction' | tr }}
</button>
</center>
</form>
</div>
</form>
<br/>
<center>
<a href="" ng-click="appstoreLogin.setupType = 'signup'" ng-show="appstoreLogin.setupType === 'login'">{{ 'appstore.accountDialog.switchToSignUpAction' | tr }}</a>
<a href="" ng-click="appstoreLogin.setupType = 'login'" ng-show="appstoreLogin.setupType === 'signup' || appstoreLogin.setupType === 'setupToken'">{{ 'appstore.accountDialog.switchToLoginAction' | tr }}</a>
<span ng-show="appstoreLogin.setupType !== 'setupToken'"> or <a href="" ng-click="appstoreLogin.setupType = 'setupToken'">use a setup token</a></span>
</center>
</div>
</div>

View File

@@ -64,6 +64,7 @@ angular.module('Application').controller('AppStoreController', ['$scope', '$tran
{ id: 'notes', icon: 'fa fa-sticky-note', label: 'Notes'},
{ id: 'project', icon: 'fas fa-project-diagram', label: 'Project Management'},
{ id: 'sync', icon: 'fa fa-sync-alt', label: 'File Sync'},
{ id: 'voip', icon: 'fa fa-headset', label: 'VoIP'},
{ id: 'vpn', icon: 'fa fa-user-secret', label: 'VPN'},
{ id: 'wiki', icon: 'fab fa-wikipedia-w', label: 'Wiki'},
];
@@ -415,22 +416,24 @@ angular.module('Application').controller('AppStoreController', ['$scope', '$tran
email: '',
password: '',
totpToken: '',
register: true,
setupType: 'login',
termsAccepted: false,
setupToken: '',
submit: function () {
$scope.appstoreLogin.error = {};
$scope.appstoreLogin.busy = true;
Client.registerCloudron($scope.appstoreLogin.email, $scope.appstoreLogin.password, $scope.appstoreLogin.totpToken, $scope.appstoreLogin.register, function (error) {
var func = $scope.appstoreLogin.setupToken ? Client.registerCloudronWithSetupToken.bind(null, $scope.appstoreLogin.setupToken) : Client.registerCloudron.bind(null, $scope.appstoreLogin.email, $scope.appstoreLogin.password, $scope.appstoreLogin.totpToken, $scope.appstoreLogin.setupType === 'register');
func(function (error) {
if (error) {
$scope.appstoreLogin.busy = false;
if (error.statusCode === 409) {
$scope.appstoreLogin.error.email = 'An account with this email already exists';
$scope.appstoreLogin.password = '';
$scope.appstoreLoginForm.email.$setPristine();
$scope.appstoreLoginForm.password.$setPristine();
$scope.appstoreSignupForm.email.$setPristine();
$scope.appstoreSignupForm.password.$setPristine();
$('#inputAppstoreLoginEmail').focus();
} else if (error.statusCode === 412) {
if (error.message.indexOf('TOTP token missing') !== -1) {
@@ -441,7 +444,7 @@ angular.module('Application').controller('AppStoreController', ['$scope', '$tran
$scope.appstoreLogin.totpToken = '';
setTimeout(function () { $('#inputAppstoreLoginTotpToken').focus(); }, 0);
} else {
$scope.appstoreLogin.error.password = 'Wrong email or password';
$scope.appstoreLogin.error.loginPassword = 'Wrong email or password';
$scope.appstoreLogin.password = '';
$('#inputAppstoreLoginPassword').focus();
$scope.appstoreLoginForm.password.$setPristine();
@@ -453,11 +456,18 @@ angular.module('Application').controller('AppStoreController', ['$scope', '$tran
$scope.appstoreLogin.password = '';
$scope.appstoreLoginForm.email.$setPristine();
$scope.appstoreLoginForm.password.$setPristine();
$scope.appstoreSignupForm.email.$setPristine();
$scope.appstoreSignupForm.password.$setPristine();
$('#inputAppstoreLoginEmail').focus();
} else {
console.error(error);
$scope.appstoreLogin.error.generic = error.message;
}
} else if (error.statusCode === 402) {
$scope.appstoreLogin.error.setupToken = 'Invalid or expired setup token';
$scope.appstoreLogin.setupToken = '';
$scope.appstoreSetupTokenForm.setupToken.$setPristine();
$('#inputAppstoreSetupToken').focus();
} else {
console.error(error);
$scope.appstoreLogin.error.generic = error.message || 'Please retry later';
@@ -777,10 +787,12 @@ angular.module('Application').controller('AppStoreController', ['$scope', '$tran
getSubscription(function (error, validSubscription) {
if (error) console.error('Failed to get subscription.', error);
// autofocus login
if (!validSubscription) setTimeout(function () { $('[name=appstoreLoginForm]').find('[autofocus]:first').focus(); }, 1000);
$scope.validSubscription = validSubscription;
$scope.ready = true;
// refresh everything in background
Client.getAppstoreApps(function (error) { if (error) console.error('Failed to fetch apps.', error); });
Client.refreshConfig(); // refresh domain, user, group limit etc
@@ -827,10 +839,5 @@ angular.module('Application').controller('AppStoreController', ['$scope', '$tran
});
});
// autofocus if appstore login is shown
$scope.$watch('validSubscription', function (newValue/*, oldValue */) {
if (!newValue) setTimeout(function () { $('[name=appstoreLoginForm]').find('[autofocus]:first').focus(); }, 1000);
});
$('.modal-backdrop').remove();
}]);

View File

@@ -177,7 +177,7 @@
<div class="form-group" ng-class="{ 'has-error': configureBackup.error.mountPoint || (configureBackupForm.mountPoint.$dirty && !configureBackup.mountPoint) }" ng-show="configureBackup.provider === 'mountpoint'">
<label class="control-label" for="inputConfigureMountPoint">{{ 'backups.configureBackupStorage.mountPoint' | tr }}</label>
<input type="text" class="form-control" ng-model="configureBackup.mountPoint" id="inputConfigureMountPoint" name="mountPoint" ng-disabled="configureBackup.busy" placeholder="/mnt/backups" ng-required="configureBackup.provider === 'mountpoint'">
<p ng-show="configureBackup.provider === 'mointpoint'" ng-bind-html="'backups.configureBackupStorage.mountPointDescription' | tr:{ providerDocsLink: 'https://docs.cloudron.io/backups/#'+configureBackup.provider }"></p>
<p ng-show="configureBackup.provider === 'mountpoint'" ng-bind-html="'backups.configureBackupStorage.mountPointDescription' | tr:{ providerDocsLink: 'https://docs.cloudron.io/backups/#'+configureBackup.provider }"></p>
</div>
<!-- CIFS/NFS/SSHFS -->

View File

@@ -4,7 +4,7 @@
/* global $:false */
angular.module('Application').controller('BrandingController', ['$scope', '$location', 'Client', function ($scope, $location, Client) {
Client.onReady(function () { if (Client.getUserInfo().role !== 'owner') $location.path('/'); });
Client.onReady(function () { if (!Client.getUserInfo().isAtLeastAdmin) $location.path('/'); });
$scope.user = Client.getUserInfo();
$scope.config = Client.getConfig();

View File

@@ -98,6 +98,25 @@
<input type="text" class="form-control" ng-model="domainConfigure.netcupApiPassword" name="netcupApiPassword" ng-disabled="domainConfigure.busy" ng-required="domainConfigure.provider === 'netcup'">
</div>
<!-- OVH -->
<div class="form-group" ng-class="{ 'has-error': false }" ng-show="domainConfigure.provider === 'ovh'">
<label class="control-label" for="inputConfigureOvhEndpoint">{{ 'domains.domainDialog.ovhEndpoint' | tr }}</label>
<select class="form-control" name="endpoint" id="inputConfigureOvhEndpoint" ng-model="domainConfigure.ovhEndpoint" ng-options="a.value as a.name for a in ovhEndpoints" ng-disabled="domainConfigure.busy" ng-required="domainConfigure.provider === 'ovh'"></select>
</div>
<div class="form-group" ng-class="{ 'has-error': false }" ng-show="domainConfigure.provider === 'ovh'">
<label class="control-label">{{ 'domains.domainDialog.ovhConsumerKey' | tr }}</label>
<input type="text" class="form-control" ng-model="domainConfigure.ovhConsumerKey" name="ovhConsumerKey" ng-disabled="domainConfigure.busy" ng-required="domainConfigure.provider === 'ovh'">
</div>
<div class="form-group" ng-class="{ 'has-error': false }" ng-show="domainConfigure.provider === 'ovh'">
<label class="control-label">{{ 'domains.domainDialog.ovhAppKey' | tr }}</label>
<input type="text" class="form-control" ng-model="domainConfigure.ovhAppKey" name="ovhAppKey" ng-disabled="domainConfigure.busy" ng-minlength="1" ng-required="domainConfigure.provider === 'ovh'">
</div>
<div class="form-group" ng-class="{ 'has-error': false }" ng-show="domainConfigure.provider === 'ovh'">
<label class="control-label">{{ 'domains.domainDialog.ovhAppSecret' | tr }}</label>
<input type="text" class="form-control" ng-model="domainConfigure.ovhAppSecret" name="ovhAppSecret" ng-disabled="domainConfigure.busy" ng-required="domainConfigure.provider === 'ovh'">
</div>
<!-- Porkbun -->
<div class="form-group" ng-class="{ 'has-error': false }" ng-show="domainConfigure.provider === 'porkbun'">
<label class="control-label">{{ 'domains.domainDialog.porkbunApikey' | tr }}</label>
@@ -146,6 +165,12 @@
<input type="text" class="form-control" ng-model="domainConfigure.bunnyAccessKey" name="bunnyAccessKey" ng-disabled="domainConfigure.busy" ng-required="domainConfigure.provider === 'bunny'">
</div>
<!-- dnsimple -->
<div class="form-group" ng-class="{ 'has-error': false }" ng-show="domainConfigure.provider === 'dnsimple'">
<label class="control-label">{{ 'domains.domainDialog.dnsimpleAccessToken' | tr }}</label>
<input type="text" class="form-control" ng-model="domainConfigure.dnsimpleAccessToken" name="dnsimpleAccessToken" ng-disabled="domainConfigure.busy" ng-required="domainConfigure.provider === 'dnsimple'">
</div>
<!-- Hetzner -->
<div class="form-group" ng-class="{ 'has-error': false }" ng-show="domainConfigure.provider === 'hetzner'">
<label class="control-label">{{ 'domains.domainDialog.hetznerToken' | tr }}</label>

View File

@@ -2,7 +2,7 @@
/* global async */
/* global angular */
/* global $, TASK_TYPES */
/* global $, TASK_TYPES, ENDPOINTS_OVH */
angular.module('Application').controller('DomainsController', ['$scope', '$location', 'Client', function ($scope, $location, Client) {
Client.onReady(function () { if (!Client.getUserInfo().isAtLeastAdmin) $location.path('/'); });
@@ -47,6 +47,7 @@ angular.module('Application').controller('DomainsController', ['$scope', '$locat
{ name: 'Bunny', value: 'bunny' },
{ name: 'Cloudflare', value: 'cloudflare' },
{ name: 'DigitalOcean', value: 'digitalocean' },
{ name: 'DNSimple', value: 'dnsimple' },
{ name: 'Gandi LiveDNS', value: 'gandi' },
{ name: 'GoDaddy', value: 'godaddy' },
{ name: 'Google Cloud DNS', value: 'gcdns' },
@@ -55,6 +56,7 @@ angular.module('Application').controller('DomainsController', ['$scope', '$locat
{ name: 'Name.com', value: 'namecom' },
{ name: 'Namecheap', value: 'namecheap' },
{ name: 'Netcup', value: 'netcup' },
{ name: 'OVH', value: 'ovh' },
{ name: 'Porkbun', value: 'porkbun' },
{ name: 'Vultr', value: 'vultr' },
{ name: 'Wildcard', value: 'wildcard' },
@@ -68,12 +70,14 @@ angular.module('Application').controller('DomainsController', ['$scope', '$locat
case 'route53': return 'AWS Route53';
case 'cloudflare': return 'Cloudflare';
case 'digitalocean': return 'DigitalOcean';
case 'dnsimple': return 'dnsimple';
case 'gandi': return 'Gandi LiveDNS';
case 'hetzner': return 'Hetzner DNS';
case 'linode': return 'Linode';
case 'namecom': return 'Name.com';
case 'namecheap': return 'Namecheap';
case 'netcup': return 'Netcup';
case 'ovh': return 'OVH';
case 'gcdns': return 'Google Cloud';
case 'godaddy': return 'GoDaddy';
case 'vultr': return 'Vultr';
@@ -85,6 +89,8 @@ angular.module('Application').controller('DomainsController', ['$scope', '$locat
}
};
$scope.ovhEndpoints = ENDPOINTS_OVH;
$scope.needsPort80 = function (dnsProvider, tlsProvider) {
return ((dnsProvider === 'manual' || dnsProvider === 'noop' || dnsProvider === 'wildcard') &&
(tlsProvider === 'letsencrypt-prod' || tlsProvider === 'letsencrypt-staging'));
@@ -249,6 +255,7 @@ angular.module('Application').controller('DomainsController', ['$scope', '$locat
cloudflareTokenType: 'GlobalApiKey',
linodeToken: '',
bunnyAccessKey: '',
dnsimpleAccessToken: '',
hetznerToken: '',
vultrToken: '',
nameComToken: '',
@@ -258,6 +265,10 @@ angular.module('Application').controller('DomainsController', ['$scope', '$locat
netcupCustomerNumber: '',
netcupApiKey: '',
netcupApiPassword: '',
ovhEndpoint: 'ovh-eu',
ovhConsumerKey: '',
ovhAppKey: '',
ovhAppSecret: '',
porkbunSecretapikey: '',
porkbunApikey: '',
@@ -307,6 +318,7 @@ angular.module('Application').controller('DomainsController', ['$scope', '$locat
$scope.domainConfigure.digitalOceanToken = domain.provider === 'digitalocean' ? domain.config.token : '';
$scope.domainConfigure.linodeToken = domain.provider === 'linode' ? domain.config.token : '';
$scope.domainConfigure.bunnyAccessKey = domain.provider === 'bunny' ? domain.config.accessKey : '';
$scope.domainConfigure.dnsimpleAccessToken = domain.provider === 'dnsimple' ? domain.config.accessToken : '';
$scope.domainConfigure.hetznerToken = domain.provider === 'hetzner' ? domain.config.token : '';
$scope.domainConfigure.vultrToken = domain.provider === 'vultr' ? domain.config.token : '';
$scope.domainConfigure.gandiApiKey = domain.provider === 'gandi' ? domain.config.token : '';
@@ -328,6 +340,11 @@ angular.module('Application').controller('DomainsController', ['$scope', '$locat
$scope.domainConfigure.netcupApiKey = domain.provider === 'netcup' ? domain.config.apiKey : '';
$scope.domainConfigure.netcupApiPassword = domain.provider === 'netcup' ? domain.config.apiPassword : '';
$scope.domainConfigure.ovhEndpoint = domain.provider === 'ovh' ? domain.config.endpoint : '';
$scope.domainConfigure.ovhConsumerKey = domain.provider === 'ovh' ? domain.config.consumerKey : '';
$scope.domainConfigure.ovhAppKey = domain.provider === 'ovh' ? domain.config.appKey : '';
$scope.domainConfigure.ovhAppSecret = domain.provider === 'ovh' ? domain.config.appSecret : '';
$scope.domainConfigure.porkbunApikey = domain.provider === 'porkbun' ? domain.config.apikey : '';
$scope.domainConfigure.porkbunSecretapikey = domain.provider === 'porkbun' ? domain.config.secretapikey : '';
@@ -379,6 +396,8 @@ angular.module('Application').controller('DomainsController', ['$scope', '$locat
data.token = $scope.domainConfigure.linodeToken;
} else if (provider === 'bunny') {
data.accessKey = $scope.domainConfigure.bunnyAccessKey;
} else if (provider === 'dnsimple') {
data.accessToken = $scope.domainConfigure.dnsimpleAccessToken;
} else if (provider === 'hetzner') {
data.token = $scope.domainConfigure.hetznerToken;
} else if (provider === 'vultr') {
@@ -403,6 +422,11 @@ angular.module('Application').controller('DomainsController', ['$scope', '$locat
data.customerNumber = $scope.domainConfigure.netcupCustomerNumber;
data.apiKey = $scope.domainConfigure.netcupApiKey;
data.apiPassword = $scope.domainConfigure.netcupApiPassword;
} else if (provider === 'ovh') {
data.endpoint = $scope.domainConfigure.ovhEndpoint;
data.consumerKey = $scope.domainConfigure.ovhConsumerKey;
data.appKey = $scope.domainConfigure.ovhAppKey;
data.appSecret = $scope.domainConfigure.ovhAppSecret;
} else if (provider === 'porkbun') {
data.apikey = $scope.domainConfigure.porkbunApikey;
data.secretapikey = $scope.domainConfigure.porkbunSecretapikey;
@@ -472,6 +496,10 @@ angular.module('Application').controller('DomainsController', ['$scope', '$locat
$scope.domainConfigure.netcupCustomerNumber = '';
$scope.domainConfigure.netcupApiKey = '';
$scope.domainConfigure.netcupApiPassword = '';
$scope.domainConfigure.ovhEndpoint = '';
$scope.domainConfigure.ovhConsumerKey = '';
$scope.domainConfigure.ovhAppKey = '';
$scope.domainConfigure.ovhAppSecret = '';
$scope.domainConfigure.porkbunApikey = '';
$scope.domainConfigure.porkbunSecretapikey = '';
$scope.domainConfigure.vultrToken = '';

View File

@@ -4,7 +4,7 @@
/* global angular */
angular.module('Application').controller('EmailsEventlogController', ['$scope', '$location', '$translate', '$timeout', 'Client', function ($scope, $location, $translate, $timeout, Client) {
Client.onReady(function () { if (!Client.getUserInfo().isAtLeastOwner) $location.path('/'); });
Client.onReady(function () { if (!Client.getUserInfo().isAtLeastAdmin) $location.path('/'); });
$scope.ready = false;
$scope.config = Client.getConfig();

View File

@@ -4,7 +4,7 @@
/* global angular */
angular.module('Application').controller('EmailsQueueController', ['$scope', '$location', '$translate', '$timeout', 'Client', function ($scope, $location, $translate, $timeout, Client) {
Client.onReady(function () { if (!Client.getUserInfo().isAtLeastOwner) $location.path('/'); });
Client.onReady(function () { if (!Client.getUserInfo().isAtLeastAdmin) $location.path('/'); });
$scope.ready = false;
$scope.config = Client.getConfig();

View File

@@ -65,7 +65,7 @@
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">{{ 'main.dialog.cancel' | tr }}</button>
<button type="button" class="btn btn-success" ng-click="blocklist.submit()"><i class="fa fa-circle-notch fa-spin" ng-show="blocklist.busy"></i> {{ 'main.dialog.save' | tr }}</button>
<button type="button" class="btn btn-success" ng-disabled="blocklist.busy" ng-click="blocklist.submit()"><i class="fa fa-circle-notch fa-spin" ng-show="blocklist.busy"></i> {{ 'main.dialog.save' | tr }}</button>
</div>
</div>
</div>
@@ -91,7 +91,7 @@
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">{{ 'main.dialog.cancel' | tr }}</button>
<button type="button" class="btn btn-success" ng-click="trustedIps.submit()"><i class="fa fa-circle-notch fa-spin" ng-show="trustedIps.busy"></i> {{ 'main.dialog.save' | tr }}</button>
<button type="button" class="btn btn-success" ng-disabled="trustedIps.busy" ng-click="trustedIps.submit()"><i class="fa fa-circle-notch fa-spin" ng-show="trustedIps.busy"></i> {{ 'main.dialog.save' | tr }}</button>
</div>
</div>
</div>
@@ -251,11 +251,11 @@
</div>
<!-- Firewall -->
<div class="text-left section-header" ng-show="user.isAtLeastOwner">
<div class="text-left section-header">
<h3>{{ 'network.firewall.title' | tr }}</h3>
</div>
<div class="card" ng-show="user.isAtLeastOwner">
<div class="card">
<div class="row">
<div class="col-xs-6">
<span class="text-muted">{{ 'network.firewall.blockedIpRanges' | tr }}</span>

View File

@@ -5,6 +5,20 @@
</div>
<div class="text-left">
<h3>{{ 'support.help.title' | tr }}</h3>
</div>
<div class="card">
<div class="grid-item-top">
<div class="row">
<div class="col-lg-12">
<div ng-bind-html="'support.help.description' | tr:{ docsLink: 'https://docs.cloudron.io/?support_view', packagingLink: 'https://docs.cloudron.io/custom-apps/tutorial/?support_view', forumLink: 'https://forum.cloudron.io/' } | markdown2html"></div>
</div>
</div>
</div>
</div>
<!-- <div class="text-left">
<h3>{{ 'support.ticket.title' | tr }}</h3>
</div>
@@ -25,9 +39,14 @@
</p>
</div>
<div ng-bind-html="supportConfig.ticketFormBody | markdown2html"></div>
<p>Use this form to open support tickets. You can also write directly to <a href="mailto:support@cloudron.io">support@cloudron.io.</p>
<ul>
<li><a href="https://docs.cloudron.io/apps/?support_view" target="_blank">Knowledge Base & App Docs</a></li>
<li><a href="https://docs.cloudron.io/custom-apps/tutorial/?support_view" target="_blank">Custom App Packaging & API</li>
<li><a href="https://forum.cloudron.io/" target="_blank">Forum</a></li>
</ul>
<form ng-show="supportConfig.submitTickets" name="feedbackForm" ng-submit="submitFeedback()">
<form name="feedbackForm" ng-submit="submitFeedback()">
<div class="form-group">
<label>{{ 'support.ticket.type' | tr }}</label>
<select class="form-control" name="type" style="width: 50%;" ng-model="feedback.type" required ng-disabled="!subscription.emailVerified">
@@ -68,7 +87,7 @@
</div>
</div>
</div>
</div>
</div> -->
<div class="text-left section-header">
<h3>{{ 'support.remoteSupport.title' | tr }}</h3>

View File

@@ -9,70 +9,69 @@ angular.module('Application').controller('SupportController', ['$scope', '$locat
$scope.ready = false;
$scope.config = Client.getConfig();
$scope.user = Client.getUserInfo();
$scope.apps = Client.getInstalledApps();
$scope.appsById = {};
$scope.supportConfig = null;
// $scope.apps = Client.getInstalledApps();
// $scope.appsById = {};
$scope.feedback = {
error: null,
result: null,
busy: false,
enableSshSupport: false,
subject: '',
type: 'app_error',
description: '',
appId: '',
altEmail: ''
};
// $scope.feedback = {
// error: null,
// result: null,
// busy: false,
// enableSshSupport: false,
// subject: '',
// type: 'app_error',
// description: '',
// appId: '',
// altEmail: ''
// };
$scope.toggleSshSupportError = '';
$scope.sshSupportEnabled = false;
$scope.subscription = null;
// $scope.subscription = null;
function resetFeedback() {
$scope.feedback.enableSshSupport = false;
$scope.feedback.subject = '';
$scope.feedback.description = '';
$scope.feedback.type = 'app_error';
$scope.feedback.appId = '';
$scope.feedback.altEmail = '';
// function resetFeedback() {
// $scope.feedback.enableSshSupport = false;
// $scope.feedback.subject = '';
// $scope.feedback.description = '';
// $scope.feedback.type = 'app_error';
// $scope.feedback.appId = '';
// $scope.feedback.altEmail = '';
$scope.feedbackForm.$setUntouched();
$scope.feedbackForm.$setPristine();
}
// $scope.feedbackForm.$setUntouched();
// $scope.feedbackForm.$setPristine();
// }
$scope.submitFeedback = function () {
$scope.feedback.busy = true;
$scope.feedback.result = null;
$scope.feedback.error = null;
// $scope.submitFeedback = function () {
// $scope.feedback.busy = true;
// $scope.feedback.result = null;
// $scope.feedback.error = null;
var data = {
enableSshSupport: $scope.feedback.enableSshSupport,
subject: $scope.feedback.subject,
description: $scope.feedback.description,
type: $scope.feedback.type,
appId: $scope.feedback.appId,
altEmail: $scope.feedback.altEmail
};
// var data = {
// enableSshSupport: $scope.feedback.enableSshSupport,
// subject: $scope.feedback.subject,
// description: $scope.feedback.description,
// type: $scope.feedback.type,
// appId: $scope.feedback.appId,
// altEmail: $scope.feedback.altEmail
// };
Client.createTicket(data, function (error, result) {
if (error) {
$scope.feedback.error = error.message;
} else {
$scope.feedback.result = result;
resetFeedback();
}
// Client.createTicket(data, function (error, result) {
// if (error) {
// $scope.feedback.error = error.message;
// } else {
// $scope.feedback.result = result;
// resetFeedback();
// }
$scope.feedback.busy = false;
// $scope.feedback.busy = false;
// refresh state
Client.getRemoteSupport(function (error, enabled) {
if (error) return console.error(error);
// // refresh state
// Client.getRemoteSupport(function (error, enabled) {
// if (error) return console.error(error);
$scope.sshSupportEnabled = enabled;
});
});
};
// $scope.sshSupportEnabled = enabled;
// });
// });
// };
$scope.toggleSshSupport = function () {
$scope.toggleSshSupportError = '';
@@ -89,27 +88,22 @@ angular.module('Application').controller('SupportController', ['$scope', '$locat
};
Client.onReady(function () {
Client.getSubscription(function (error, result) {
if (error && error.statusCode === 402) return $scope.ready = true; // not yet registered
if (error && error.statusCode === 412) return $scope.ready = true; // invalid appstore token
Client.getRemoteSupport(function (error, enabled) {
if (error) return console.error(error);
$scope.subscription = result;
$scope.sshSupportEnabled = enabled;
Client.getSupportConfig(function (error, supportConfig) {
if (error) return console.error(error);
// Client.getSubscription(function (error, result) {
// if (error && error.statusCode === 402) return $scope.ready = true; // not yet registered
// if (error && error.statusCode === 412) return $scope.ready = true; // invalid appstore token
// if (error) return console.error(error);
$scope.supportConfig = supportConfig;
// $scope.subscription = result;
Client.getRemoteSupport(function (error, enabled) {
if (error) return console.error(error);
// Client.getInstalledApps().forEach(function (app) { $scope.appsById[app.id] = app; });
Client.getInstalledApps().forEach(function (app) { $scope.appsById[app.id] = app; });
$scope.sshSupportEnabled = enabled;
$scope.ready = true;
});
});
$scope.ready = true;
// });
});
});

View File

@@ -11,10 +11,47 @@
</div>
<div class="row">
<div class="col-md-6">
<div class="col-md-12">
<h3 class="graphs-toolbar">
Graphs
{{ 'system.info.title' | tr }}
</h3>
<div class="card card-expand">
<div class="row">
<div class="col-xs-4 text-muted">{{ 'system.info.platformVersion' | tr }}</div>
<div class="col-xs-8 text-right">v{{ config.version }} ({{ config.ubuntuVersion }})</div>
</div>
<div class="row">
<div class="col-xs-4 text-muted">{{ 'system.info.vendor' | tr }}</div>
<div class="col-xs-8 text-right">{{ info.sysVendor }}</div>
</div>
<div class="row">
<div class="col-xs-4 text-muted">{{ 'system.info.product' | tr }}</div>
<div class="col-xs-8 text-right">{{ info.productName }}</div>
</div>
<div class="row">
<div class="col-xs-4 text-muted">CPU</div>
<div class="col-xs-8 text-right">{{ cpus.length + ' Core "' + cpus[0].model + '"' }}</div>
</div>
<div class="row">
<div class="col-xs-4 text-muted">{{ 'system.info.memory' | tr }}</div>
<div class="col-xs-8 text-right">{{ memory.memory | prettyDiskSize }} RAM <span ng-show="memory.swap">&amp; {{ memory.swap | prettyDiskSize }} Swap</span></div>
</div>
<div class="row">
<div class="col-xs-4 text-muted">{{ 'system.info.uptime' | tr }}</div>
<div class="col-xs-8 text-right">{{ info.uptimeSecs }}</div>
</div>
<div class="row" ng-show="info.activationTime">
<div class="col-xs-4 text-muted">{{ 'system.info.activationTime' | tr }}</div>
<div class="col-xs-8 text-right">{{ info.activationTime | prettyDate }}</div>
</div>
</div>
</div>
<div class="col-md-6">
<h3 class="graphs-toolbar">
{{ 'system.graphs.title' | tr }}
<div class="graphs-toolbar-actions">
<button class="btn btn-sm btn-default" style="margin-right: 5px;" ng-click="graphs.refresh()" ng-disabled="graphs.busy"><i class="fas fa-sync-alt" ng-class="{ 'fa-spin': graphs.busy }"></i></button>
<div class="dropdown">
@@ -85,13 +122,13 @@
<div ng-repeat="content in disk.contents" class="disk-content">
<span class="color-indicator" style="background-color: {{ content.color }};">&nbsp;</span>
<span ng-show="content.type === 'cloudron-backup-default'">{{ content.path }} (Old Backups)</span>
<span ng-show="content.type === 'standard'">{{ content.label || content.id }}</span>
<span ng-show="content.type === 'swap'">{{ content.id }}</span>
<span ng-show="content.type === 'standard'">{{ content.label }}</span>
<span ng-show="content.type === 'swap'">{{ content.label }}</span>
<span ng-show="content.type === 'app'">
<a href="https://{{ content.app.fqdn }}" target="_blank" ng-hide="content.uninstalled">{{ content.app.label || content.app.fqdn }}</a>
<a href="/#/app/{{ content.app.id }}/storage" ng-hide="content.uninstalled">{{ content.label }}</a>
<span ng-show="content.uninstalled">{{ 'system.diskUsage.uninstalledApp' | tr }}</span>
</span>
<span ng-show="content.type === 'volume'"><a href="/#/volumes">{{ content.volume.name }}</a></span>
<span ng-show="content.type === 'volume'"><a href="/#/volumes">{{ content.label }}</a></span>
<small class="text-muted">{{ content.usage | prettyDiskSize }}</small>
</div>
</div>

View File

@@ -2,6 +2,7 @@
/* global angular */
/* global $ */
/* global TASK_TYPES */
/* global Chart */
angular.module('Application').controller('SystemController', ['$scope', '$location', '$timeout', 'Client', function ($scope, $location, $timeout, Client) {
@@ -9,6 +10,8 @@ angular.module('Application').controller('SystemController', ['$scope', '$locati
$scope.config = Client.getConfig();
$scope.memory = null;
$scope.cpus = null;
$scope.info = null;
$scope.volumesById = {};
// https://stackoverflow.com/questions/1484506/random-color-generator
@@ -93,11 +96,16 @@ angular.module('Application').controller('SystemController', ['$scope', '$locati
if (content.type === 'app') {
content.app = Client.getInstalledAppsByAppId()[content.id];
content.label = content.app.label || content.app.fqdn;
if (!content.app) content.uninstalled = true;
} else if (content.type === 'volume') {
content.volume = $scope.volumesById[content.id];
content.label = content.volume.name;
}
// ensure a label for ui
content.label = content.label || content.id;
usageOther -= content.usage;
});
@@ -321,6 +329,20 @@ angular.module('Application').controller('SystemController', ['$scope', '$locati
};
Client.onReady(function () {
Client.cpus(function (error, cpus) {
if (error) console.error(error);
$scope.cpus = cpus;
});
Client.systemInfo(function (error, info) {
if (error) console.error(error);
// prettify for UI
info.uptimeSecs = moment.duration(info.uptimeSecs, 'seconds').locale(navigator.language).humanize();
$scope.info = info;
});
Client.memory(function (error, memory) {
if (error) console.error(error);

View File

@@ -112,21 +112,11 @@
<br/>
<br/>
<form name="clientAddForm" role="form" novalidate ng-submit="clientAdd.submit()" autocomplete="off">
<p class="text-danger" ng-show="clientAdd.error">{{ clientAdd.error }}</p>
<div class="form-group">
<label class="control-label" for="clientName">{{ 'oidc.client.name' | tr }}</label>
<input type="text" id="clientName" class="form-control" name="clientName" ng-model="clientAdd.name" autofocus required/>
</div>
<div class="form-group" ng-class="{ 'has-error': clientAdd.error.id }">
<label class="control-label" for="clientId">{{ 'oidc.client.id' | tr }}</label>
<input type="text" id="clientId" class="form-control" name="clientId" ng-model="clientAdd.id" required/>
<div class="control-label" ng-show="clientAdd.error.id">
<small>{{ clientAdd.error.id }}</small>
</div>
</div>
<div class="form-group">
<label class="control-label" for="clientSecret">{{ 'oidc.client.secret' | tr }}</label>
<input type="text" id="clientSecret" class="form-control" name="clientSecret" ng-model="clientAdd.secret" required/>
</div>
<div class="form-group">
<label class="control-label" for="loginRedirectUri">{{ 'oidc.client.loginRedirectUri' | tr }}</label>
<input type="text" id="loginRedirectUri" class="form-control" name="loginRedirectUri" ng-model="clientAdd.loginRedirectUri" required/>
@@ -158,18 +148,37 @@
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title">{{ 'oidc.editClientDialog.title' | tr:{ client: clientEdit.id } }}</h4>
<h4 class="modal-title">{{ 'oidc.editClientDialog.title' | tr:{ client: clientEdit.name } }}</h4>
</div>
<div class="modal-body">
<form name="clientEditForm" role="form" novalidate ng-submit="clientEdit.submit()" autocomplete="off">
<p class="text-danger" ng-show="clientEdit.error">{{ clientEdit.error }}</p>
<div class="form-group">
<label class="control-label">{{ 'oidc.client.id' | tr }}</label>
<div class="input-group">
<input type="text" id="clientIdInput" class="form-control" ng-value="clientEdit.id" readonly/>
<span class="input-group-btn">
<button class="btn btn-primary" id="clientIdInputClipboardButton" type="button" data-clipboard-target="#clientIdInput"><i class="fa fa-clipboard"></i></button>
</span>
</div>
</div>
<div class="form-group">
<label class="control-label">{{ 'oidc.client.secret' | tr }}</label>
<div class="input-group">
<input type="text" id="clientSecretInput" class="form-control" ng-value="clientEdit.secret" readonly/>
<span class="input-group-btn">
<button class="btn btn-primary" id="clientSecretInputClipboardButton" type="button" data-clipboard-target="#clientSecretInput"><i class="fa fa-clipboard"></i></button>
</span>
</div>
</div>
<div class="form-group">
<label class="control-label" for="inputEditClientName">{{ 'oidc.client.name' | tr }}</label>
<input type="text" id="inputEditClientName" class="form-control" name="clientName" ng-model="clientEdit.name" autofocus required/>
</div>
<div class="form-group">
<label class="control-label" for="inputEditClientSecret">{{ 'oidc.client.secret' | tr }}</label>
<input type="text" id="inputEditClientSecret" class="form-control" name="clientSecret" ng-model="clientEdit.secret" required/>
</div>
<div class="form-group">
<label class="control-label" for="inputEditLoginRedirectUri">{{ 'oidc.client.loginRedirectUri' | tr }}</label>
<input type="text" id="inputEditLoginRedirectUri" class="form-control" name="loginRedirectUri" ng-model="clientEdit.loginRedirectUri" required/>
@@ -495,10 +504,8 @@
<table class="table table-hover">
<thead>
<tr>
<th style="width: 33%">{{ 'oidc.client.name' | tr }}</th>
<th style="width: 33%">{{ 'oidc.client.id' | tr }}</th>
<th style="width: 33%">{{ 'oidc.client.signingAlgorithm' | tr }}</th>
<th style="width: 10%" class="text-right">{{ 'main.actions' | tr }}</th>
<th style="width: 80%">{{ 'oidc.client.name' | tr }}</th>
<th style="width: 20%" class="text-right">{{ 'main.actions' | tr }}</th>
</tr>
</thead>
<tbody>
@@ -509,12 +516,6 @@
<td class="text-left elide-table-cell hand" ng-click="clientEdit.show(client)">
{{ client.name }}
</td>
<td class="text-left elide-table-cell hand" ng-click="clientEdit.show(client)">
{{ client.id }}
</td>
<td class="text-left elide-table-cell hand" ng-click="clientEdit.show(client)">
{{ client.tokenSignatureAlgorithm }}
</td>
<td class="text-right no-wrap" style="vertical-align: bottom">
<button class="btn btn-xs btn-danger" ng-click="deleteClient.show(client)" uib-tooltip="Delete"><i class="far fa-trash-alt"></i></button>
<button class="btn btn-xs btn-default" ng-click="clientEdit.show(client)" uib-tooltip="Edit"><i class="fa fa-pencil-alt"></i></button>

View File

@@ -298,16 +298,12 @@ angular.module('Application').controller('UserSettingsController', ['$scope', '$
$scope.clientAdd = {
busy: false,
error: {},
id: '',
error: null,
name: '',
secret: '',
loginRedirectUri: '',
tokenSignatureAlgorithm: '',
show: function () {
$scope.clientAdd.id = '';
$scope.clientAdd.secret = '';
$scope.clientAdd.name = '';
$scope.clientAdd.loginRedirectUri = '';
$scope.clientAdd.tokenSignatureAlgorithm = 'RS256';
@@ -320,19 +316,13 @@ angular.module('Application').controller('UserSettingsController', ['$scope', '$
submit: function () {
$scope.clientAdd.busy = true;
$scope.clientAdd.error = {};
$scope.clientAdd.error = null;
Client.addOidcClient($scope.clientAdd.id, $scope.clientAdd.name, $scope.clientAdd.secret, $scope.clientAdd.loginRedirectUri, $scope.clientAdd.tokenSignatureAlgorithm, function (error) {
Client.addOidcClient($scope.clientAdd.name, $scope.clientAdd.loginRedirectUri, $scope.clientAdd.tokenSignatureAlgorithm, function (error) {
if (error) {
if (error.statusCode === 409) {
$scope.clientAdd.error.id = 'Client ID already exists';
$('#clientId').focus();
} else {
console.error('Unable to add openid client.', error);
}
$scope.clientAdd.error = error.message;
console.error('Unable to add openid client.', error);
$scope.clientAdd.busy = false;
return;
}
@@ -346,17 +336,17 @@ angular.module('Application').controller('UserSettingsController', ['$scope', '$
$scope.clientEdit = {
busy: false,
error: {},
error: null,
id: '',
name: '',
secret: '',
name: '',
loginRedirectUri: '',
tokenSignatureAlgorithm: '',
show: function (client) {
$scope.clientEdit.id = client.id;
$scope.clientEdit.name = client.name;
$scope.clientEdit.secret = client.secret;
$scope.clientEdit.name = client.name;
$scope.clientEdit.loginRedirectUri = client.loginRedirectUri;
$scope.clientEdit.tokenSignatureAlgorithm = client.tokenSignatureAlgorithm;
$scope.clientEdit.busy = false;
@@ -368,14 +358,13 @@ angular.module('Application').controller('UserSettingsController', ['$scope', '$
submit: function () {
$scope.clientEdit.busy = true;
$scope.clientEdit.error = {};
$scope.clientEdit.error = null;
Client.updateOidcClient($scope.clientEdit.id, $scope.clientEdit.name, $scope.clientEdit.secret, $scope.clientEdit.loginRedirectUri, $scope.clientEdit.tokenSignatureAlgorithm, function (error) {
Client.updateOidcClient($scope.clientEdit.id, $scope.clientEdit.name, $scope.clientEdit.loginRedirectUri, $scope.clientEdit.tokenSignatureAlgorithm, function (error) {
if (error) {
$scope.clientEdit.error = error.message;
console.error('Unable to edit openid client.', error);
$scope.clientEdit.busy = false;
return;
}
@@ -444,5 +433,41 @@ angular.module('Application').controller('UserSettingsController', ['$scope', '$
$timeout(function () { $('#userDirectoryUrlClipboardButton').tooltip('hide'); }, 2000);
});
new Clipboard('#clientIdInputClipboardButton').on('success', function(e) {
$('#clientIdInputClipboardButton').tooltip({
title: 'Copied!',
trigger: 'manual'
}).tooltip('show');
$timeout(function () { $('#clientIdInputClipboardButton').tooltip('hide'); }, 2000);
e.clearSelection();
}).on('error', function(/*e*/) {
$('#clientIdInputClipboardButton').tooltip({
title: 'Press Ctrl+C to copy',
trigger: 'manual'
}).tooltip('show');
$timeout(function () { $('#clientIdInputClipboardButton').tooltip('hide'); }, 2000);
});
new Clipboard('#clientSecretInputClipboardButton').on('success', function(e) {
$('#clientSecretInputClipboardButton').tooltip({
title: 'Copied!',
trigger: 'manual'
}).tooltip('show');
$timeout(function () { $('#clientSecretInputClipboardButton').tooltip('hide'); }, 2000);
e.clearSelection();
}).on('error', function(/*e*/) {
$('#clientSecretInputClipboardButton').tooltip({
title: 'Press Ctrl+C to copy',
trigger: 'manual'
}).tooltip('show');
$timeout(function () { $('#clientSecretInputClipboardButton').tooltip('hide'); }, 2000);
});
$('.modal-backdrop').remove();
}]);

File diff suppressed because it is too large Load Diff

View File

@@ -9,25 +9,25 @@
"preview": "vite preview"
},
"dependencies": {
"@fontsource/noto-sans": "^5.0.12",
"@fontsource/noto-sans": "^5.0.17",
"anser": "^2.1.1",
"combokeys": "^3.0.1",
"filesize": "^10.0.12",
"marked": "^7.0.4",
"filesize": "^10.1.0",
"marked": "^10.0.0",
"moment": "^2.29.4",
"pankow": "^1.0.1",
"pankow": "^1.1.8",
"primeicons": "^6.0.1",
"primevue": "^3.34.1",
"primevue": "^3.41.1",
"superagent": "^8.1.2",
"vue": "^3.3.4",
"vue-i18n": "^9.4.1",
"vue-router": "^4.2.4",
"xterm": "^5.2.1",
"xterm-addon-attach": "^0.8.0",
"xterm-addon-fit": "^0.7.0"
"vue": "^3.3.9",
"vue-i18n": "^9.7.1",
"vue-router": "^4.2.5",
"xterm": "^5.3.0",
"xterm-addon-attach": "^0.9.0",
"xterm-addon-fit": "^0.8.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^4.3.4",
"vite": "^4.4.9"
"@vitejs/plugin-vue": "^4.5.0",
"vite": "^5.0.2"
}
}

View File

@@ -20,7 +20,7 @@
<div v-for="line in logLines" :key="line.id" class="log-line">
<span class="time">{{ line.time }}</span><span v-html="line.html"></span>
</div>
<div ref="scrollAnchor" class="bottom-spacer"></div>
<div class="bottom-spacer"></div>
</template>
</MainLayout>
</template>
@@ -144,7 +144,7 @@ export default {
if (!tmp) return;
const autoScroll = tmp.scrollTop > (tmp.scrollHeight - tmp.clientHeight - 34);
if (autoScroll) setTimeout(() => this.$refs.scrollAnchor.scrollIntoView(false), 1);
if (autoScroll) setTimeout(() => tmp.scrollTop = tmp.scrollHeight, 1);
}, function (error) {
console.error('Failed to start log stream:', error);
})

View File

@@ -5,9 +5,26 @@ import { sanitize } from 'pankow/utils';
const BASE_URL = import.meta.env.BASE_URL || '/';
export function createDirectoryModel(origin, accessToken, api) {
const ownersModel = [{
uid: 0,
label: 'root'
}, {
uid: 33,
label: 'www-data'
}, {
uid: 808,
label: 'yellowtent'
}, {
uid: 1000,
label: 'cloudron'
}, {
uid: 1001,
label: 'git'
}];
return {
name: 'DirectoryModel',
ownersModel,
buildFilePath(filePath, fileName) {
// remove leading and trailing slashes
while (filePath.startsWith('/')) filePath = filePath.slice(1);
@@ -45,10 +62,7 @@ export function createDirectoryModel(origin, accessToken, api) {
}
item.owner = item.uid;
if (item.uid === 0) item.owner = 'root';
if (item.uid === 33) item.owner = 'www-data';
if (item.uid === 1000) item.owner = 'cloudron';
if (item.uid === 1001) item.owner = 'git';
if (ownersModel.find((m) => m.uid === item.uid)) item.owner = ownersModel.find((m) => m.uid === item.uid).label;
});
return result.body.entries;

View File

@@ -19,7 +19,6 @@ a {
a:hover, a:focus {
color: #0a6ebd;
text-decoration: underline;
}
.shadow {

View File

@@ -198,19 +198,7 @@ export default {
busy: false,
name: ''
},
ownersModel: [{
uid: 0,
label: 'root'
}, {
uid: 33,
label: 'www-data'
}, {
uid: 1000,
label: 'cloudron'
}, {
uid: 1001,
label: 'git'
}],
ownersModel: [],
// contextMenuModel will have activeItem attached if any command() is called
createMenuModel: [{
label: () => this.$t('filemanager.toolbar.newFile'),
@@ -355,21 +343,39 @@ export default {
async deleteHandler(files) {
if (!files) return;
window.addEventListener('beforeunload', beforeUnloadListener, { capture: true });
this.deleteInProgress = true;
for (let i in files) {
try {
await this.directoryModel.remove(this.directoryModel.buildFilePath(this.cwd, files[i].name));
} catch (e) {
console.error(`Failed to remove file ${files[i].name}:`, e);
function start_and_end(str) {
if (str.length > 100) {
return str.substr(0, 45) + ' ... ' + str.substr(str.length-45, str.length);
}
return str;
}
await this.loadCwd();
this.$confirm.require({
header: this.$t('filemanager.removeDialog.reallyDelete'),
message: start_and_end(files.map((f) => f.name).join(', ')),
icon: '',
acceptClass: 'p-button-danger',
accept: async () => {
window.addEventListener('beforeunload', beforeUnloadListener, { capture: true });
this.deleteInProgress = true;
for (let i in files) {
try {
await this.directoryModel.remove(this.directoryModel.buildFilePath(this.cwd, files[i].name));
} catch (e) {
console.error(`Failed to remove file ${files[i].name}:`, e);
}
}
await this.loadCwd();
this.$confirm.close();
window.removeEventListener('beforeunload', beforeUnloadListener, { capture: true });
this.deleteInProgress = false;
}
});
window.removeEventListener('beforeunload', beforeUnloadListener, { capture: true });
this.deleteInProgress = false;
},
async renameHandler(file, newName) {
await this.directoryModel.rename(this.directoryModel.buildFilePath(this.cwd, file.name), sanitize(this.cwd + '/' + newName));
@@ -497,20 +503,6 @@ export default {
return;
}
this.ownersModel = [{
uid: 0,
label: 'root'
}, {
uid: 33,
label: 'www-data'
}, {
uid: 1000,
label: 'cloudron'
}, {
uid: 1001,
label: 'git'
}];
this.appLink = `https://${result.body.fqdn}`;
this.title = `${result.body.label || result.body.fqdn} (${result.body.manifest.title})`;
} else if (type === 'volume') {
@@ -527,20 +519,6 @@ export default {
return;
}
this.ownersModel = [{
uid: 0,
label: 'root'
}, {
uid: 33,
label: 'www-data'
}, {
uid: 808,
label: 'yellowtent'
}, {
uid: 1001,
label: 'git'
}];
this.title = result.body.name;
} else {
this.fatalError = `Unsupported type ${type}`;
@@ -561,6 +539,8 @@ export default {
this.resourceId = resourceId;
this.directoryModel = createDirectoryModel(this.apiOrigin, this.accessToken, type === 'volume' ? `volumes/${resourceId}` : `apps/${resourceId}`);
this.ownersModel = this.directoryModel.ownersModel;
this.loadCwd();
this.$watch(() => this.$route.params, (toParams, previousParams) => {

555
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -18,61 +18,62 @@
"dependencies": {
"@google-cloud/dns": "^3.0.2",
"@google-cloud/storage": "^6.12.0",
"async": "^3.2.4",
"aws-sdk": "^2.1426.0",
"async": "^3.2.5",
"aws-sdk": "^2.1502.0",
"basic-auth": "^2.0.1",
"body-parser": "^1.20.2",
"cloudron-manifestformat": "^5.21.0",
"connect": "^3.7.0",
"connect-lastmile": "^2.1.1",
"connect-lastmile": "^2.2.0",
"connect-timeout": "^1.9.0",
"cookie-parser": "^1.4.6",
"cookie-session": "^2.0.0",
"cron": "^2.4.0",
"db-migrate": "^0.11.13",
"db-migrate-mysql": "^2.2.0",
"cron": "^2.4.4",
"db-migrate": "^0.11.14",
"db-migrate-mysql": "^2.3.2",
"debug": "^4.3.4",
"dockerode": "^3.3.5",
"ejs": "^3.1.9",
"express": "^4.18.2",
"ipaddr.js": "^2.1.0",
"jose": "^4.14.4",
"jsdom": "^22.1.0",
"jsonwebtoken": "^9.0.1",
"jose": "^4.15.4",
"jsdom": "^23.0.1",
"jsonwebtoken": "^9.0.2",
"ldapjs": "^2.3.3",
"marked": "^7.0.2",
"marked": "^7.0.5",
"moment": "^2.29.4",
"moment-timezone": "^0.5.43",
"multiparty": "^4.2.3",
"mysql": "^2.18.1",
"nodemailer": "^6.9.4",
"nodemailer": "^6.9.7",
"nsyslog-parser": "^0.10.1",
"oidc-provider": "^8.2.2",
"oidc-provider": "^8.4.1",
"ovh": "^2.0.3",
"qrcode": "^1.5.3",
"readdirp": "^3.6.0",
"safetydance": "^2.2.0",
"safetydance": "^2.4.0",
"semver": "^7.5.4",
"speakeasy": "^2.0.0",
"superagent": "^8.0.9",
"superagent": "^8.1.2",
"tar-fs": "github:cloudron-io/tar-fs#ignore_stat_error",
"tldjs": "^2.3.1",
"ua-parser-js": "^1.0.35",
"ua-parser-js": "^1.0.37",
"underscore": "^1.13.6",
"uuid": "^9.0.0",
"validator": "^13.9.0",
"ws": "^8.13.0",
"uuid": "^9.0.1",
"validator": "^13.11.0",
"ws": "^8.14.2",
"xml2js": "^0.6.2"
},
"devDependencies": {
"commander": "^11.0.0",
"commander": "^11.1.0",
"easy-table": "^1.2.0",
"eslint": "^8.46.0",
"eslint": "^8.54.0",
"expect.js": "*",
"hock": "^1.4.1",
"js2xmlparser": "^5.0.0",
"mocha": "^10.2.0",
"mock-aws-s3": "git+https://github.com/cloudron-io/mock-aws-s3.git",
"nock": "^13.3.2",
"nock": "^13.3.8",
"ssh2": "^1.14.0",
"yesno": "^0.4.0"
},

View File

@@ -16,7 +16,7 @@ vergte() {
# change this to a hash when we make a upgrade release
readonly LOG_FILE="/var/log/cloudron-setup.log"
readonly MINIMUM_DISK_SIZE_GB="18" # this is the size of "/" and required to fit in docker images 18 is a safe bet for different reporting on 20GB min
readonly MINIMUM_MEMORY="950" # this is mostly reported for 1GB main memory (DO 957, EC2 967, Linode 989, Serverdiscounter.com 974)
readonly MINIMUM_MEMORY="949" # this is mostly reported for 1GB main memory (DO 957, EC2 949, Linode 989, Serverdiscounter.com 974)
readonly curl="curl --fail --connect-timeout 20 --retry 10 --retry-delay 2 --max-time 2400"
@@ -123,7 +123,7 @@ fi
if which nginx >/dev/null || which docker >/dev/null || which node > /dev/null; then
if [[ "${redo}" == "false" ]]; then
echo "Error: Some packages like nginx/docker/nodejs are already installed. Cloudron requires specific versions of these packages and will install them as part of it's installation. Please start with a fresh Ubuntu install and run this script again." > /dev/stderr
echo "Error: Some packages like nginx/docker/nodejs are already installed. Cloudron requires specific versions of these packages and will install them as part of its installation. Please start with a fresh Ubuntu install and run this script again." > /dev/stderr
exit 1
fi
fi

View File

@@ -2,156 +2,338 @@
set -eu -o pipefail
# This script collects diagnostic information to help debug server related issues
# It also enables SSH access for the cloudron support team
PASTEBIN="https://paste.cloudron.io"
OUT="/tmp/cloudron-support.log"
LINE="\n========================================================\n"
CLOUDRON_SUPPORT_PUBLIC_KEY="ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGWS+930b8QdzbchGljt3KSljH9wRhYvht8srrtQHdzg support@cloudron.io"
HELP_MESSAGE="
This script collects diagnostic information to help debug server related issues.
Options:
--owner-login Login as owner
--enable-ssh Enable SSH access for the Cloudron support team
--reset-appstore-account Reset associated cloudron.io account
--help Show this message
"
# We require root
# scripts requires root
if [[ ${EUID} -ne 0 ]]; then
echo "This script should be run as root. Run with sudo"
exit 1
fi
enableSSH="false"
readonly RED='\033[31m'
readonly GREEN='\033[32m'
readonly YELLOW='\033[33m'
readonly DONE='\033[m'
args=$(getopt -o "" -l "help,enable-ssh,admin-login,owner-login,reset-appstore-account" -n "$0" -- "$@")
eval set -- "${args}"
readonly PASTEBIN="https://paste.cloudron.io"
readonly LINE="\n========================================================\n"
readonly HELP_MESSAGE="
Cloudron Support and Diagnostics Tool
while true; do
case "$1" in
--help) echo -e "${HELP_MESSAGE}"; exit 0;;
--enable-ssh) enableSSH="true"; shift;;
--admin-login)
# fall through
;&
--owner-login)
admin_username=$(mysql -NB -uroot -ppassword -e "SELECT username FROM box.users WHERE role='owner' AND username IS NOT NULL ORDER BY creationTime LIMIT 1" 2>/dev/null)
admin_password=$(pwgen -1s 12)
dashboard_domain=$(mysql -NB -uroot -ppassword -e "SELECT value FROM box.settings WHERE name='dashboard_domain'" 2>/dev/null)
mysql -NB -uroot -ppassword -e "INSERT INTO box.settings (name, value) VALUES ('ghosts_config', '{\"${admin_username}\":\"${admin_password}\"}') ON DUPLICATE KEY UPDATE name='ghosts_config', value='{\"${admin_username}\":\"${admin_password}\"}'" 2>/dev/null
echo "Login at https://my.${dashboard_domain} as ${admin_username} / ${admin_password} . This password may only be used once."
exit 0
;;
--reset-appstore-account)
echo -e "This will reset the Cloudron.io account associated with this Cloudron. Once reset, you can re-login with a different account in the Cloudron Dashboard. See https://docs.cloudron.io/appstore/#change-account for more information.\n"
read -e -p "Reset the Cloudron.io account? [y/N] " choice
[[ "$choice" != [Yy]* ]] && exit 1
mysql -uroot -ppassword -e "DELETE FROM box.settings WHERE name='cloudron_token';" 2>/dev/null
dashboard_domain=$(mysql -NB -uroot -ppassword -e "SELECT value FROM box.settings WHERE name='dashboard_domain'" 2>/dev/null)
echo "Account reset. Please re-login at https://my.${dashboard_domain}/#/appstore"
exit 0
;;
--) break;;
*) echo "Unknown option $1"; exit 1;;
esac
done
Options:
--disable-dnssec Disable DNSSEC
--enable-remote-access Enable SSH Remote Access for the Cloudron support team
--send-diagnostics Collects server diagnostics and uploads it to ${PASTEBIN}
--troubleshoot Dashboard down? Run tests to identify the potential problem
--owner-login Login as owner
--use-external-dns Forwards all DNS requests to Google (8.8.8.8) and Cloudflare (1.1.1.1) DNS servers
--help Show this message
"
# check if at least 10mb root partition space is available
if [[ "`df --output="avail" / | sed -n 2p`" -lt "10240" ]]; then
echo "No more space left on /"
echo "This is likely the root case of the issue. Free up some space and also check other partitions below:"
echo ""
df -h
echo ""
echo "To recover from a full disk, follow the guide at https://docs.cloudron.io/troubleshooting/#recovery-after-disk-full"
exit 1
fi
function success() {
echo -e "[${GREEN}OK${DONE}]\t${1}"
}
# check for at least 5mb free /tmp space for the log file
if [[ "`df --output="avail" /tmp | sed -n 2p`" -lt "5120" ]]; then
echo "Not enough space left on /tmp"
echo "Free up some space first by deleting files from /tmp"
exit 1
fi
function info() {
echo -e "\t${1}"
}
if [[ "${enableSSH}" == "true" ]]; then
ssh_port=$(cat /etc/ssh/sshd_config | grep "Port " | sed -e "s/.*Port //")
function warn() {
echo -e "[${YELLOW}WARN${DONE}]\t${1}"
}
ssh_user="cloudron-support"
keys_file="/home/cloudron-support/.ssh/authorized_keys"
function fail() {
echo -e "[${RED}FAIL${DONE}]\t${1}"
}
echo -e $LINE"SSH"$LINE >> $OUT
echo "Username: ${ssh_user}" >> $OUT
echo "Port: ${ssh_port}" >> $OUT
echo "Key file: ${keys_file}" >> $OUT
function enable_remote_access() {
local -r cloudron_support_public_key="ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGWS+930b8QdzbchGljt3KSljH9wRhYvht8srrtQHdzg support@cloudron.io"
local -r ssh_user="cloudron-support"
local -r keys_file="/home/cloudron-support/.ssh/authorized_keys"
echo -n "Enabling ssh access for the Cloudron support team..."
echo -n "Enabling Remote Access for the Cloudron support team..."
mkdir -p $(dirname "${keys_file}") # .ssh does not exist sometimes
touch "${keys_file}" # required for concat to work
if ! grep -q "${CLOUDRON_SUPPORT_PUBLIC_KEY}" "${keys_file}"; then
echo -e "\n${CLOUDRON_SUPPORT_PUBLIC_KEY}" >> "${keys_file}"
if ! grep -q "${cloudron_support_public_key}" "${keys_file}"; then
echo -e "\n${cloudron_support_public_key}" >> "${keys_file}"
chmod 600 "${keys_file}"
chown "${ssh_user}" "${keys_file}"
fi
echo "Done"
}
exit 0
fi
function check_host_mysql() {
if ! systemctl is-active -q mysql; then
info "MySQL is down. Trying to restart MySQL ..."
echo -n "Generating Cloudron Support stats..."
systemctl restart mysql
# clear file
rm -rf $OUT
if ! systemctl is-active -q mysql; then
fail "MySQL is still down, please investigate the error by inspecting /var/log/mysql/error.log"
exit 1
fi
fi
echo -e $LINE"Linux"$LINE >> $OUT
uname -nar &>> $OUT
success "MySQL is running"
}
echo -e $LINE"Ubuntu"$LINE >> $OUT
lsb_release -a &>> $OUT
function check_box() {
if ! systemctl is-active -q box; then
info "box is down. re-running migration script and restarting it ..."
echo -e $LINE"Dashboard Domain"$LINE >> $OUT
mysql -NB -uroot -ppassword -e "SELECT value FROM box.settings WHERE name='dashboard_domain'" &>> $OUT 2>/dev/null || true
/home/yellowtent/box/setup/start.sh
systemctl stop box # a restart sometimes doesn't restart, no idea
systemctl start box
echo -e $LINE"Docker containers"$LINE >> $OUT
if ! timeout --kill-after 10s 15s docker ps -a &>> $OUT 2>&1; then
echo -e "Docker is not responding" >> $OUT
fi
if ! systemctl is-active -q box; then
fail "box is still down, please investigate the error by inspecting /home/yellowtent/platformdata/logs/box.log"
exit 1
fi
fi
echo -e $LINE"Filesystem stats"$LINE >> $OUT
df -h &>> $OUT
success "box is running"
}
echo -e $LINE"Appsdata stats"$LINE >> $OUT
du -hcsL /home/yellowtent/appsdata/* &>> $OUT || true
function owner_login() {
check_host_mysql
echo -e $LINE"Boxdata stats"$LINE >> $OUT
du -hcsL /home/yellowtent/boxdata/* &>> $OUT
local -r owner_username=$(mysql -NB -uroot -ppassword -e "SELECT username FROM box.users WHERE role='owner' AND username IS NOT NULL AND active=1 ORDER BY creationTime LIMIT 1" 2>/dev/null)
local -r owner_password=$(pwgen -1s 12)
local -r dashboard_domain=$(mysql -NB -uroot -ppassword -e "SELECT value FROM box.settings WHERE name='dashboard_domain'" 2>/dev/null)
mysql -NB -uroot -ppassword -e "INSERT INTO box.settings (name, value) VALUES ('ghosts_config', '{\"${owner_username}\":\"${owner_password}\"}') ON DUPLICATE KEY UPDATE name='ghosts_config', value='{\"${owner_username}\":\"${owner_password}\"}'" 2>/dev/null
echo "Login at https://my.${dashboard_domain} as ${owner_username} / ${owner_password} . This password may only be used once."
}
echo -e $LINE"Backup stats (possibly misleading)"$LINE >> $OUT
du -hcsL /var/backups/* &>> $OUT || true
function send_diagnostics() {
local -r log="/tmp/cloudron-support.log"
echo -e $LINE"System daemon status"$LINE >> $OUT
systemctl status --lines=100 box mysql unbound cloudron-syslog nginx collectd docker &>> $OUT
echo -n "Generating Cloudron Support stats..."
echo -e $LINE"Box logs"$LINE >> $OUT
tail -n 100 /home/yellowtent/platformdata/logs/box.log &>> $OUT
rm -rf $log
echo -e $LINE"Interface Info"$LINE >> $OUT
ip addr &>> $OUT
echo -e $LINE"Linux"$LINE >> $log
uname -nar &>> $log
echo -e $LINE"Firewall chains"$LINE >> $OUT
iptables -L &>> $OUT
has_ipv6=$(cat /proc/net/if_inet6 >/dev/null 2>&1 && echo "yes" || echo "no")
echo -e "IPv6: ${has_ipv6}" >> $OUT
[[ "${has_ipv6}" == "yes" ]] && ip6tables -L &>> $OUT
echo -e $LINE"Ubuntu"$LINE >> $log
lsb_release -a &>> $log
echo "Done"
echo -e $LINE"Dashboard Domain"$LINE >> $log
mysql -NB -uroot -ppassword -e "SELECT value FROM box.settings WHERE name='dashboard_domain'" &>> $log 2>/dev/null || true
echo -n "Uploading information..."
paste_key=$(curl -X POST ${PASTEBIN}/documents --silent --data-binary "@$OUT" | python3 -c "import sys, json; print(json.load(sys.stdin)['key'])")
echo "Done"
echo -e $LINE"Docker containers"$LINE >> $log
if ! timeout --kill-after 10s 15s docker ps -a &>> $log 2>&1; then
echo -e "Docker is not responding" >> $log
fi
echo -e "\nPlease email the following link to support@cloudron.io : ${PASTEBIN}/${paste_key}"
echo -e $LINE"Filesystem stats"$LINE >> $log
df -h &>> $log
echo -e $LINE"Appsdata stats"$LINE >> $log
du -hcsL /home/yellowtent/appsdata/* &>> $log || true
echo -e $LINE"Boxdata stats"$LINE >> $log
du -hcsL /home/yellowtent/boxdata/* &>> $log
echo -e $LINE"Backup stats (possibly misleading)"$LINE >> $log
du -hcsL /var/backups/* &>> $log || true
echo -e $LINE"System daemon status"$LINE >> $log
systemctl status --lines=100 box mysql unbound cloudron-syslog nginx collectd docker &>> $log
echo -e $LINE"Box logs"$LINE >> $log
tail -n 100 /home/yellowtent/platformdata/logs/box.log &>> $log
echo -e $LINE"Interface Info"$LINE >> $log
ip addr &>> $log
echo -e $LINE"Firewall chains"$LINE >> $log
iptables -L &>> $log
has_ipv6=$(cat /proc/net/if_inet6 >/dev/null 2>&1 && echo "yes" || echo "no")
echo -e "IPv6: ${has_ipv6}" >> $log
[[ "${has_ipv6}" == "yes" ]] && ip6tables -L &>> $log
echo "Done"
echo -n "Uploading information..."
paste_key=$(curl -X POST ${PASTEBIN}/documents --silent --data-binary "@$log" | python3 -c "import sys, json; print(json.load(sys.stdin)['key'])")
echo "Done"
echo -e "\nPlease email the following link to support@cloudron.io : ${PASTEBIN}/${paste_key}"
}
function check_unbound() {
if ! systemctl is-active -q unbound; then
info "unbound is down. updating root anchor to see if it fixes it"
unbound-anchor -a /var/lib/unbound/root.key
systemctl restart unbound
if ! systemctl is-active -q unbound; then
fail "unbound is still down, please investigate the error using 'journalctl -u unbound'"
exit 1
fi
fi
test_resolve=$(dig cloudron.io @127.0.0.1 +short)
if [[ -z "test_resolve" ]]; then
fail "DNS is not resolving, maybe try forwarding all DNS requests using the --use-external-dns option"
exit 1
fi
success "unbound is running"
}
function check_nginx() {
local -r dashboard_domain=$(mysql -NB -uroot -ppassword -e "SELECT value FROM box.settings WHERE name='dashboard_domain'" 2>/dev/null)
if ! systemctl is-active -q nginx; then
fail "nginx is down. Removing extraneous dashboard domain configs ..."
cd /home/yellowtent/platformdata/nginx/applications/dashboard/ && find . ! -name "my.${dashboard_domain}.conf" -type f -exec rm -f {} +
systemctl restart nginx
if ! systemctl is-active -q nginx; then
fail "nginx is still down, please investigate the error by inspecting /var/log/nginx/error.log"
exit 1
fi
fi
success "nginx is running"
}
function check_docker() {
if ! systemctl is-active -q docker; then
info "Docker is down. Trying to restart docker ..."
systemctl restart docker
if ! systemctl is-active -q docker; then
fail "Docker is still down, please investigate the error using 'journalctl -u docker'"
exit 1
fi
fi
success "docker is running"
}
function check_hairpin_nat() {
local -r dashboard_domain=$(mysql -NB -uroot -ppassword -e "SELECT value FROM box.settings WHERE name='dashboard_domain'" 2>/dev/null)
if ! curl --fail -s https://my.${dashboard_domain} >/dev/null; then
fail "Could not reach dashboard domain. Is Hairpin NAT functional?"
exit 1
fi
success "Hairpin NAT is good"
}
function check_expired_domain() {
local -r dashboard_domain=$(mysql -NB -uroot -ppassword -e "SELECT value FROM box.settings WHERE name='dashboard_domain'" 2>/dev/null)
if ! command -v whois &> /dev/null; then
info "Domain ${dashboard_domain} expiry check skipped because whois is not installed. Run 'apt install whois' to check"
exit 0
fi
local -r expdate=$(whois ${dashboard_domain} | egrep -i 'Expiration Date:|Expires on|Expiry Date:' | head -1 | awk '{print $NF}')
if [[ -z "${expdate}" ]]; then
warn "Domain ${dashboard_domain} expiry check skipped because whois does not have this information"
exit 0
fi
local -r expdate_secs=$(date -d"$expdate" +%s)
local -r curdate_secs="$(date +%s)"
if (( curdate_secs > expdate_secs )); then
fail "Domain ${dashboard_domain} appears to be expired"
exit 1
fi
success "Domain ${dashboard_domain} is valid and has not expired"
}
function use_external_dns() {
local -r conf_file="/etc/unbound/unbound.conf.d/forward-everything.conf"
info "To remove the forwarding, please delete $conf_file and 'systemctl restart unbound'"
cat > $conf_file <<EOF
forward-zone:
name: "."
forward-addr: 1.1.1.1
forward-addr: 8.8.8.8
EOF
systemctl restart unbound
success "Forwarded all DNS requests to Google (8.8.8.8) & Cloudflare DNS (1.1.1.1)"
}
function disable_dnssec() {
local -r conf_file="/etc/unbound/unbound.conf.d/disable-dnssec.conf"
warn "To reenable DNSSEC, please delete $conf_file and 'systemctl restart unbound'"
cat > $conf_file <<EOF
server:
val-permissive-mode: yes
EOF
systemctl restart unbound
success "DNSSEC Disabled"
}
function troubleshoot() {
# note: disk space test has already been run globally
check_nginx
check_docker
check_host_mysql
check_box
check_unbound
check_hairpin_nat # requires mysql to be checked
check_expired_domain
}
function check_disk_space() {
# check if at least 10mb root partition space is available
if [[ "`df --output="avail" / | sed -n 2p`" -lt "10240" ]]; then
echo "No more space left on /"
echo "This is likely the root case of the issue. Free up some space and also check other partitions below:"
echo ""
df -h
echo ""
echo "To recover from a full disk, follow the guide at https://docs.cloudron.io/troubleshooting/#recovery-after-disk-full"
exit 1
fi
# check for at least 5mb free /tmp space for the log file
if [[ "`df --output="avail" /tmp | sed -n 2p`" -lt "5120" ]]; then
echo "Not enough space left on /tmp"
echo "Free up some space first by deleting files from /tmp"
exit 1
fi
}
check_disk_space
args=$(getopt -o "" -l "admin-login,disable-dnssec,enable-ssh,enable-remote-access,help,owner-login,send-diagnostics,use-external-dns,troubleshoot" -n "$0" -- "$@")
eval set -- "${args}"
while true; do
case "$1" in
--enable-ssh)
# fall through
;&
--enable-remote-access) enable_remote_access; exit 0;;
--admin-login)
# fall through
;&
--owner-login) owner_login; exit 0;;
--send-diagnostics) send_diagnostics; exit 0;;
--troubleshoot) troubleshoot; exit 0;;
--disable-dnssec) disable_dnssec; exit 0;;
--use-external-dns) use_external_dns; exit 0;;
--help) break;;
--) break;;
*) echo "Unknown option $1"; exit 1;;
esac
done
echo -e "${HELP_MESSAGE}"

View File

@@ -92,6 +92,7 @@ apt-get -y install --no-install-recommends \
unattended-upgrades \
unbound \
unzip \
whois \
xfsprogs
# on some providers like scaleway the sudo file is changed and we want to keep the old one
@@ -145,7 +146,7 @@ timedatectl set-ntp 1
timedatectl set-timezone UTC
echo "==> Adding sshd configuration warning"
sed -e '/Port 22/ i # NOTE: Cloudron only supports moving SSH to port 202. See https://docs.cloudron.io/security/#securing-ssh-access' -i /etc/ssh/sshd_config
sed -e '/Port 22/ i # NOTE: Read https://docs.cloudron.io/security/#securing-ssh-access before changing this' -i /etc/ssh/sshd_config
# https://bugs.launchpad.net/ubuntu/+source/base-files/+bug/1701068
echo "==> Disabling motd news"

View File

@@ -18,28 +18,34 @@ function ipxtables() {
ipxtables -t filter -N CLOUDRON || true
ipxtables -t filter -F CLOUDRON # empty any existing rules
# first setup any user IP block lists
ipset create cloudron_blocklist hash:net || true
ipset create cloudron_blocklist6 hash:net family inet6 || true
# first setup any user IP block lists . remove all references in iptables before destroying them
echo "==> Creating ipset cloudron_blocklist"
$iptables -t filter -D DOCKER-USER -m set --match-set cloudron_blocklist src -j DROP || true
sleep 1 # without this there is a race that iptables is still referencing the ipset
ipset destroy cloudron_blocklist || true
ipset create cloudron_blocklist hash:net maxelem 262144 || true # if you change the size, change network.js size check
echo "==> Creating ipset cloudron_blocklist6"
$ip6tables -D FORWARD -m set --match-set cloudron_blocklist6 src -j DROP || true
sleep 1 # without this there is a race that iptables is still referencing the ipset
ipset destroy cloudron_blocklist6 || true
ipset create cloudron_blocklist6 hash:net family inet6 maxelem 262144 || true # if you change the size, change network.js size check
/home/yellowtent/box/src/scripts/setblocklist.sh
$iptables -t filter -A CLOUDRON -m set --match-set cloudron_blocklist src -j DROP
# the DOCKER-USER chain is not cleared on docker restart
if ! $iptables -t filter -C DOCKER-USER -m set --match-set cloudron_blocklist src -j DROP; then
$iptables -t filter -I DOCKER-USER 1 -m set --match-set cloudron_blocklist src -j DROP
fi
$iptables -t filter -I DOCKER-USER 1 -m set --match-set cloudron_blocklist src -j DROP # the DOCKER-USER chain is not cleared on docker restart
$ip6tables -t filter -A CLOUDRON -m set --match-set cloudron_blocklist6 src -j DROP
# there is no DOCKER-USER chain in ip6tables, bug?
$ip6tables -D FORWARD -m set --match-set cloudron_blocklist6 src -j DROP || true
$ip6tables -I FORWARD 1 -m set --match-set cloudron_blocklist6 src -j DROP
$ip6tables -I FORWARD 1 -m set --match-set cloudron_blocklist6 src -j DROP # there is no DOCKER-USER chain in ip6tables, bug?
# allow related and establisted connections
echo "==> Opening standard ports"
ipxtables -t filter -A CLOUDRON -m state --state RELATED,ESTABLISHED -j ACCEPT
ipxtables -t filter -A CLOUDRON -p tcp -m tcp -m multiport --dports 22,80,202,443 -j ACCEPT # 202 is the alternate ssh port
# whitelist any user ports. we used to use --dports but it has a 15 port limit (XT_MULTI_PORTS)
echo "==> Opening up user specified ports"
ports_json="/home/yellowtent/platformdata/firewall/ports.json"
if allowed_tcp_ports=$(node -e "console.log(JSON.parse(fs.readFileSync('${ports_json}', 'utf8')).allowed_tcp_ports.join(' '))" 2>/dev/null); then
for p in $allowed_tcp_ports; do
@@ -54,10 +60,17 @@ if allowed_udp_ports=$(node -e "console.log(JSON.parse(fs.readFileSync('${ports_
fi
# LDAP user directory allow list
ipset create cloudron_ldap_allowlist hash:net || true
echo "==> Configuring LDAP allow list"
if ! ipset list cloudron_ldap_allowlist >/dev/null 2>&1; then
echo "==> Creating the cloudron_ldap_allowlist ipset"
ipset create cloudron_ldap_allowlist hash:net
fi
ipset flush cloudron_ldap_allowlist
ipset create cloudron_ldap_allowlist6 hash:net family inet6 || true
if ! ipset list cloudron_ldap_allowlist6 >/dev/null 2>&1; then
echo "==> Creating the cloudron_ldap_allowlist6 ipset"
ipset create cloudron_ldap_allowlist6 hash:net family inet6
fi
ipset flush cloudron_ldap_allowlist6
ldap_allowlist_json="/home/yellowtent/platformdata/firewall/ldap_allowlist.txt"
@@ -85,11 +98,13 @@ if [[ -f "${ldap_allowlist_json}" ]]; then
fi
# turn and stun service
echo "==> Opening ports for TURN and STUN"
ipxtables -t filter -A CLOUDRON -p tcp -m multiport --dports 3478,5349 -j ACCEPT
ipxtables -t filter -A CLOUDRON -p udp -m multiport --dports 3478,5349 -j ACCEPT
ipxtables -t filter -A CLOUDRON -p udp -m multiport --dports 50000:51000 -j ACCEPT
# ICMPv6 is very fundamental to IPv6 connectivity unlike ICMPv4
echo "==> Allow ICMP"
$iptables -t filter -A CLOUDRON -p icmp --icmp-type echo-request -j ACCEPT
$iptables -t filter -A CLOUDRON -p icmp --icmp-type echo-reply -j ACCEPT
$ip6tables -t filter -A CLOUDRON -p ipv6-icmp -j ACCEPT
@@ -103,14 +118,17 @@ ipxtables -t filter -A CLOUDRON -m limit --limit 2/min -j LOG --log-prefix "Pack
ipxtables -t filter -A CLOUDRON -j DROP
# prepend our chain to the filter table
echo "==> Adding cloudron chain"
$iptables -t filter -C INPUT -j CLOUDRON 2>/dev/null || $iptables -t filter -I INPUT -j CLOUDRON
$ip6tables -t filter -C INPUT -j CLOUDRON 2>/dev/null || $ip6tables -t filter -I INPUT -j CLOUDRON
# Setup rate limit chain (the recent info is at /proc/net/xt_recent)
echo "==> Setup rate limit chain"
ipxtables -t filter -N CLOUDRON_RATELIMIT || true
ipxtables -t filter -F CLOUDRON_RATELIMIT # empty any existing rules
# log dropped incoming. keep this at the end of all the rules
echo "==> Setup logging"
ipxtables -t filter -N CLOUDRON_RATELIMIT_LOG || true
ipxtables -t filter -F CLOUDRON_RATELIMIT_LOG # empty any existing rules
ipxtables -t filter -A CLOUDRON_RATELIMIT_LOG -m limit --limit 2/min -j LOG --log-prefix "IPTables RateLimit: " --log-level 7

View File

@@ -28,6 +28,11 @@ def read():
for line in lines:
stat = json.loads(line)
containerName = stat["Name"] # same as app id
# currently we only collect data for apps main containers. Those have the app id as the Name which is 36 long
if len(containerName) != 36:
continue
networkData = stat["NetIO"].split("/")
networkRead = parseSiSize(networkData[0].strip())
networkWrite = parseSiSize(networkData[1].strip())

View File

@@ -84,8 +84,15 @@ async function detectMetaInfo(applink) {
// set redirected URI if any for favicon url
const redirectUri = (response.redirects && response.redirects.length) ? response.redirects[0] : null;
const dom = new jsdom.JSDOM(response.text);
if (!applink.icon) {
const virtualConsole = new jsdom.VirtualConsole();
virtualConsole.on('error', () => {
// No-op to skip console errors.
});
const [jsdomError, dom] = await safe(jsdom.JSDOM.fromURL(applink.upstreamUri, { virtualConsole }));
if (jsdomError) console.error('detectMetaInfo: jsdomError', jsdomError);
if (!applink.icon && dom) {
let favicon = '';
if (dom.window.document.querySelector('link[rel="apple-touch-icon"]')) favicon = dom.window.document.querySelector('link[rel="apple-touch-icon"]').href;
if (!favicon && dom.window.document.querySelector('meta[name="msapplication-TileImage"]')) favicon = dom.window.document.querySelector('meta[name="msapplication-TileImage"]').content;
@@ -113,7 +120,7 @@ async function detectMetaInfo(applink) {
const [error, response] = await safe(superagent.get(favicon));
if (error) debug(`Failed to fetch icon ${favicon}: `, error);
else if (response.ok) applink.icon = response.body;
else if (response.ok && response.headers['content-type'].indexOf('image') !== -1) applink.icon = response.body || response.text;
else debug(`Failed to fetch icon ${favicon}: statusCode=${response.status}`);
}
@@ -122,8 +129,8 @@ async function detectMetaInfo(applink) {
const [error, response] = await safe(superagent.get(applink.upstreamUri + '/favicon.ico'));
if (error) debug(`Failed to fetch icon ${favicon}: `, error);
else if (response.ok) applink.icon = response.body;
else debug(`Failed to fetch icon ${favicon}: statusCode=${response.status}`);
else if (response.ok && response.headers['content-type'].indexOf('image') !== -1) applink.icon = response.body || response.text;
else debug(`Failed to fetch icon ${favicon}: statusCode=${response.status} content type ${response.headers['content-type']}`);
}
}
@@ -138,7 +145,7 @@ async function add(applink) {
assert.strictEqual(typeof applink, 'object');
assert.strictEqual(typeof applink.upstreamUri, 'string');
debug(`add: ${applink.upstreamUri}`, applink);
debug(`add: ${applink.upstreamUri}`);
let error = validateUpstreamUri(applink.upstreamUri);
if (error) throw error;
@@ -184,7 +191,7 @@ async function update(applinkId, applink) {
assert.strictEqual(typeof applink, 'object');
assert.strictEqual(typeof applink.upstreamUri, 'string');
debug(`update: ${applink.upstreamUri}`, applink);
debug(`update: ${applink.upstreamUri}`);
let error = validateUpstreamUri(applink.upstreamUri);
if (error) throw error;

View File

@@ -441,8 +441,8 @@ function validateCsp(csp) {
if (csp === null) return null;
if (csp.length > 4096) return new BoxError(BoxError.BAD_FIELD, 'CSP must be less than 4096');
if (csp.includes('"')) return new BoxError(BoxError.BAD_FIELD, 'CSP cannot contains double quotes');
if (csp.includes('\n')) return new BoxError(BoxError.BAD_FIELD, 'CSP cannot contain newlines');
return null;
}

View File

@@ -13,13 +13,13 @@ exports = module.exports = {
getAppVersion,
downloadIcon,
registerWithLoginCredentials,
registerCloudronWithSetupToken,
registerCloudronWithLogin,
updateCloudron,
purchaseApp,
unpurchaseApp,
getWebToken,
getSubscription,
isFreePlan,
@@ -123,15 +123,6 @@ async function registerUser(email, password) {
if (response.status !== 201) throw new BoxError(BoxError.EXTERNAL_ERROR, `Registration error. invalid response: ${response.status}`);
}
async function getWebToken() {
if (constants.DEMO) throw new BoxError(BoxError.BAD_FIELD, 'Not allowed in demo mode');
const token = await settings.set(settings.APPSTORE_WEB_TOKEN_KEY);
if (!token) throw new BoxError(BoxError.NOT_FOUND); // user will have to re-login with password somehow
return token;
}
async function getSubscription() {
const token = await settings.get(settings.APPSTORE_API_TOKEN_KEY);
if (!token) throw new BoxError(BoxError.LICENSE_ERROR, 'Missing token');
@@ -295,14 +286,15 @@ async function getAppUpdate(app, options) {
async function registerCloudron(data) {
assert.strictEqual(typeof data, 'object');
const { domain, accessToken, version, existingApps } = data;
const { domain, setupToken, accessToken, version, existingApps } = data;
const [error, response] = await safe(superagent.post(`${await getApiServerOrigin()}/api/v1/register_cloudron`)
.send({ domain, accessToken, version, existingApps })
.send({ domain, setupToken, accessToken, version, existingApps })
.timeout(30 * 1000)
.ok(() => true));
if (error) throw new BoxError(BoxError.NETWORK_ERROR, error.message);
if (response.status === 401) throw new BoxError(BoxError.LICENSE_ERROR, 'Setup token invalid');
if (response.status !== 201) throw new BoxError(BoxError.EXTERNAL_ERROR, `Unable to register cloudron: ${response.statusCode} ${error.message}`);
// cloudronId, token
@@ -311,7 +303,6 @@ async function registerCloudron(data) {
await settings.set(settings.CLOUDRON_ID_KEY, response.body.cloudronId);
await settings.set(settings.APPSTORE_API_TOKEN_KEY, response.body.cloudronToken);
await settings.set(settings.APPSTORE_WEB_TOKEN_KEY, accessToken);
debug(`registerCloudron: Cloudron registered with id ${response.body.cloudronId}`);
}
@@ -341,13 +332,26 @@ async function updateCloudron(data) {
debug(`updateCloudron: Cloudron updated with data ${JSON.stringify(data)}`);
}
async function registerWithLoginCredentials(options) {
async function registerCloudronWithSetupToken(options) {
assert.strictEqual(typeof options, 'object');
const { domain } = await dashboard.getLocation();
await registerCloudron({ domain, setupToken: options.setupToken, version: constants.VERSION });
for (const app of await apps.list()) {
await purchaseApp({ appId: app.id, appstoreId: app.appStoreId, manifestId: app.manifest.id || 'customapp' });
}
}
async function registerCloudronWithLogin(options) {
assert.strictEqual(typeof options, 'object');
if (options.signup) await registerUser(options.email, options.password);
const result = await login(options.email, options.password, options.totpToken || '');
const { domain } = await dashboard.getLocation();
await registerCloudron({ domain, accessToken: result.accessToken, version: constants.VERSION });
for (const app of await apps.list()) {
@@ -358,7 +362,6 @@ async function registerWithLoginCredentials(options) {
async function unregister() {
await settings.set(settings.CLOUDRON_ID_KEY, '');
await settings.set(settings.APPSTORE_API_TOKEN_KEY, '');
await settings.set(settings.APPSTORE_WEB_TOKEN_KEY, '');
}
async function createTicket(info, auditSource) {

View File

@@ -31,6 +31,9 @@ function getBackupFilePath(backupConfig, remotePath) {
assert.strictEqual(typeof backupConfig, 'object');
assert.strictEqual(typeof remotePath, 'string');
// we don't have a rootPath for noop
if (backupConfig.provider === 'noop') return remotePath;
return path.join(backupConfig.rootPath, remotePath);
}

View File

@@ -26,6 +26,10 @@ function getBackupFilePath(backupConfig, remotePath) {
const rootPath = backupConfig.rootPath;
const fileType = backupConfig.encryption ? '.tar.gz.enc' : '.tar.gz';
// we don't have a rootPath for noop
if (backupConfig.provider === 'noop') return remotePath + fileType;
return path.join(rootPath, remotePath + fileType);
}

View File

@@ -349,7 +349,7 @@ async function validateEncryptionPassword(password) {
if (password.length < 8) return new BoxError(BoxError.BAD_FIELD, 'password must be atleast 8 characters');
}
function mountObjectFromBackupConfig(backupConfig) {
function managedBackupMountObject(backupConfig) {
assert(mounts.isManagedProvider(backupConfig.provider));
return {
@@ -366,7 +366,7 @@ async function remount(auditSource) {
const backupConfig = await getConfig();
if (mounts.isManagedProvider(backupConfig.provider)) {
await mounts.remount(mountObjectFromBackupConfig(backupConfig));
await mounts.remount(managedBackupMountObject(backupConfig));
}
}
@@ -449,48 +449,6 @@ function validateFormat(format) {
return new BoxError(BoxError.BAD_FIELD, 'Invalid backup format');
}
async function setStorage(storageConfig) {
assert.strictEqual(typeof storageConfig, 'object');
const oldConfig = await getConfig();
if (storageConfig.provider === oldConfig.provider) storage.api(storageConfig.provider).injectPrivateFields(storageConfig, oldConfig);
let error = validateFormat(storageConfig.format);
if (error) throw error;
debug('setStorage: validating new storage configuration');
await setupStorage(storageConfig, '/mnt/backup-storage-validation');
storageConfig.rootPath = getRootPath(storageConfig, '/mnt/backup-storage-validation');
error = await testStorage(storageConfig);
delete storageConfig.rootPath;
if (error) throw error;
debug('setStorage: removing old storage configuration');
if (mounts.isManagedProvider(oldConfig.provider)) await safe(mounts.removeMount(mountObjectFromBackupConfig(oldConfig)));
debug('setStorage: setting up new storage configuration');
await setupStorage(storageConfig, paths.MANAGED_BACKUP_MOUNT_DIR);
storageConfig.encryption = null;
if ('password' in storageConfig) { // user set password
if (storageConfig.password === constants.SECRET_PLACEHOLDER) {
storageConfig.encryption = oldConfig.encryption || null;
} else {
const error = await validateEncryptionPassword(storageConfig.password);
if (error) throw error;
storageConfig.encryption = generateEncryptionKeysSync(storageConfig.password);
}
delete storageConfig.password;
}
debug('setBackupConfig: clearing backup cache');
cleanupCacheFilesSync();
await settings.setJson(settings.BACKUP_STORAGE_KEY, storageConfig);
}
async function setupStorage(storageConfig, hostPath) {
assert.strictEqual(typeof storageConfig, 'object');
assert.strictEqual(typeof hostPath, 'string');
@@ -500,6 +458,8 @@ async function setupStorage(storageConfig, hostPath) {
const error = mounts.validateMountOptions(storageConfig.provider, storageConfig.mountOptions);
if (error) throw error;
debug(`setupStorage: setting up mount at ${hostPath} with ${storageConfig.provider}`);
const newMount = {
name: path.basename(hostPath),
hostPath: hostPath,
@@ -511,3 +471,46 @@ async function setupStorage(storageConfig, hostPath) {
return newMount;
}
async function setStorage(storageConfig) {
assert.strictEqual(typeof storageConfig, 'object');
const oldConfig = await getConfig();
if (storageConfig.provider === oldConfig.provider) storage.api(storageConfig.provider).injectPrivateFields(storageConfig, oldConfig);
const foratmError = validateFormat(storageConfig.format);
if (foratmError) throw foratmError;
debug('setStorage: validating new storage configuration');
const rootPath = getRootPath(storageConfig, '/mnt/backup-storage-validation');
const testStorageConfig = Object.assign({ rootPath }, storageConfig);
const testMountObject = await setupStorage(testStorageConfig, '/mnt/backup-storage-validation');
const testStorageError = await testStorage(testStorageConfig);
if (testMountObject) await mounts.removeMount(testMountObject);
if (testStorageError) throw testStorageError;
debug('setStorage: removing old storage configuration');
if (mounts.isManagedProvider(oldConfig.provider)) await safe(mounts.removeMount(managedBackupMountObject(oldConfig)));
debug('setStorage: setting up new storage configuration');
await setupStorage(storageConfig, paths.MANAGED_BACKUP_MOUNT_DIR);
storageConfig.encryption = null;
if ('password' in storageConfig) { // user set password
if (storageConfig.password === constants.SECRET_PLACEHOLDER) {
storageConfig.encryption = oldConfig.encryption || null;
} else {
const encryptionPasswordError = await validateEncryptionPassword(storageConfig.password);
if (encryptionPasswordError) throw encryptionPasswordError;
storageConfig.encryption = generateEncryptionKeysSync(storageConfig.password);
}
delete storageConfig.password;
}
debug('setBackupConfig: clearing backup cache');
cleanupCacheFilesSync();
await settings.setJson(settings.BACKUP_STORAGE_KEY, storageConfig);
}

View File

@@ -60,12 +60,12 @@ async function checkPreconditions(backupConfig, dataLayout) {
let used = 0;
for (const localPath of dataLayout.localPaths()) {
debug(`checkPreconditions: getting disk usage of ${localPath}`);
const result = safe.child_process.execSync(`du -Dsb "${localPath}"`, { encoding: 'utf8' });
const result = safe.child_process.execSync(`du -Dsb --exclude='*.lock' --exclude='dovecot.list.index.log.*' "${localPath}"`, { encoding: 'utf8' });
if (!result) throw new BoxError(BoxError.FS_ERROR, `du error: ${safe.error.message}`);
used += parseInt(result, 10);
}
debug(`checkPreconditions: total required =${used} available=${df.available}`);
debug(`checkPreconditions: total required=${used} available=${df.available}`);
const needed = 0.6 * used + (1024 * 1024 * 1024); // check if there is atleast 1GB left afterwards. aim for 60% because rsync/tgz won't need full 100%
if (df.available <= needed) throw new BoxError(BoxError.FS_ERROR, `Not enough disk space for backup. Needed: ${df.prettyBytes(needed)} Available: ${df.prettyBytes(df.available)}`);

View File

@@ -59,7 +59,8 @@ exports = module.exports = {
'com.adguard.home.cloudronapp',
'com.transmissionbt.cloudronapp',
'io.github.sickchill.cloudronapp',
'to.couchpota.cloudronapp'
'to.couchpota.cloudronapp',
'org.qbittorrent.cloudronapp'
],
DEMO_APP_LIMIT: 20,

View File

@@ -219,8 +219,9 @@ async function handleAutoupdatePatternChanged(pattern) {
if (updateInfo.box && !updateInfo.box.unstable) {
debug('Starting box autoupdate to %j', updateInfo.box);
const [error] = await safe(updater.updateToLatest({ skipBackup: false }, AuditSource.CRON));
if (error) debug(`Failed to box autoupdate: ${error.message}`);
return;
if (!error) return; // do not start app updates when a box update got scheduled
debug(`Failed to start box autoupdate task: ${error.message}`);
// fall through to update apps if box update never started (failed ubuntu or avx check)
}
const appUpdateInfo = _.omit(updateInfo, 'box');

View File

@@ -129,10 +129,13 @@ async function changeLocation(subdomain, domain, auditSource) {
assert.strictEqual(typeof domain, 'string');
assert.strictEqual(typeof auditSource, 'object');
const oldLocation = await getLocation();
await setupLocation(subdomain, domain, auditSource);
debug(`setupLocation: notifying appstore and platform of domain change to ${domain}`);
await eventlog.add(eventlog.ACTION_DASHBOARD_DOMAIN_UPDATE, auditSource, { subdomain, domain });
await safe(appstore.updateCloudron({ domain }), { debug });
await platform.onDashboardLocationChanged(auditSource);
await safe(reverseProxy.removeDashboardConfig(oldLocation.domain), { debug });
}

View File

@@ -78,7 +78,12 @@ async function applyConfig(config) {
const [error] = await safe(shell.promises.sudo('setLdapAllowlist', [ SET_LDAP_ALLOWLIST_CMD ], {}));
if (error) throw new BoxError(BoxError.IPTABLES_ERROR, `Error setting ldap allowlist: ${error.message}`);
if (config.enabled) await start(); else await stop();
if (!config.enabled) {
await stop();
return;
}
if (!gServer) await start();
}
async function setConfig(directoryServerConfig) {
@@ -320,7 +325,7 @@ async function userAuth(req, res, next) {
}
async function start() {
if (gServer) return; // already running
assert(gServer === null, 'Already running');
const logger = {
trace: NOOP,
@@ -386,11 +391,13 @@ async function stop() {
debug('stopping server');
gServer.close();
gServer.close(); // has no callback
gServer = null;
}
async function checkCertificate() {
assert(gServer !== null, 'Directory server is not running');
const certificate = await reverseProxy.getDirectoryServerCertificate();
if (certificate.cert === gCertificate.cert) {
debug('checkCertificate: certificate has not changed');

View File

@@ -46,6 +46,7 @@ function api(provider) {
switch (provider) {
case 'bunny': return require('./dns/bunny.js');
case 'cloudflare': return require('./dns/cloudflare.js');
case 'dnsimple': return require('./dns/dnsimple.js');
case 'route53': return require('./dns/route53.js');
case 'gcdns': return require('./dns/gcdns.js');
case 'digitalocean': return require('./dns/digitalocean.js');
@@ -59,6 +60,7 @@ function api(provider) {
case 'hetzner': return require('./dns/hetzner.js');
case 'noop': return require('./dns/noop.js');
case 'manual': return require('./dns/manual.js');
case 'ovh': return require('./dns/ovh.js');
case 'porkbun': return require('./dns/porkbun.js');
case 'wildcard': return require('./dns/wildcard.js');
default: return null;

View File

@@ -231,7 +231,7 @@ async function verifyDomainConfig(domainObject) {
accessKey: domainConfig.accessKey,
};
if (process.env.BOX_ENV === 'test') return credentials; // this shouldn't be here
if (constants.TEST) return credentials; // this shouldn't be here
const [error, nameservers] = await safe(dig.resolve(zoneName, 'NS', { timeout: 5000 }));
if (error && error.code === 'ENOTFOUND') throw new BoxError(BoxError.BAD_FIELD, 'Unable to resolve nameservers for this domain');

View File

@@ -261,7 +261,7 @@ async function verifyDomainConfig(domainObject) {
defaultProxyStatus: domainConfig.defaultProxyStatus
};
if (process.env.BOX_ENV === 'test') return sanitizedConfig; // this shouldn't be here
if (constants.TEST) return sanitizedConfig; // this shouldn't be here
const [error, nameservers] = await safe(dig.resolve(zoneName, 'NS', { timeout: 5000 }));
if (error && error.code === 'ENOTFOUND') throw new BoxError(BoxError.BAD_FIELD, 'Unable to resolve nameservers for this domain');

View File

@@ -226,7 +226,7 @@ async function verifyDomainConfig(domainObject) {
token: domainConfig.token
};
if (process.env.BOX_ENV === 'test') return credentials; // this shouldn't be here
if (constants.TEST) return credentials; // this shouldn't be here
const [error, nameservers] = await safe(dig.resolve(zoneName, 'NS', { timeout: 5000 }));
if (error && error.code === 'ENOTFOUND') throw new BoxError(BoxError.BAD_FIELD, 'Unable to resolve nameservers for this domain');

263
src/dns/dnsimple.js Normal file
View File

@@ -0,0 +1,263 @@
'use strict';
exports = module.exports = {
removePrivateFields,
injectPrivateFields,
upsert,
get,
del,
wait,
verifyDomainConfig
};
const assert = require('assert'),
BoxError = require('../boxerror.js'),
constants = require('../constants.js'),
debug = require('debug')('box:dns/dnsimple'),
dig = require('../dig.js'),
dns = require('../dns.js'),
safe = require('safetydance'),
superagent = require('superagent'),
waitForDns = require('./waitfordns.js');
const DNSIMPLE_API = 'https://api.dnsimple.com/v2';
function formatError(response) {
return `dnsimple DNS error ${response.statusCode} ${JSON.stringify(response.body)}`;
}
function removePrivateFields(domainObject) {
domainObject.config.accessToken = constants.SECRET_PLACEHOLDER;
return domainObject;
}
function injectPrivateFields(newConfig, currentConfig) {
if (newConfig.accessToken === constants.SECRET_PLACEHOLDER) newConfig.accessToken = currentConfig.accessToken;
}
async function getAccountId(domainConfig) {
assert.strictEqual(typeof domainConfig, 'object');
const [error, response] = await safe(superagent.get(`${DNSIMPLE_API}/accounts`)
.set('Authorization', `Bearer ${domainConfig.accessToken}`)
.retry(5)
.timeout(30 * 1000)
.ok(() => true));
if (error) throw new BoxError(BoxError.NETWORK_ERROR, error.message);
if (response.statusCode === 401) throw new BoxError(BoxError.ACCESS_DENIED, formatError(response));
if (response.statusCode !== 200) throw new BoxError(BoxError.EXTERNAL_ERROR, formatError(response));
const accountId = safe.query(response.body, 'data[0].id', null);
if (!accountId || typeof accountId !== 'number') throw new BoxError(BoxError.EXTERNAL_ERROR, `Could not determine account id: ${JSON.stringify(response.body)}`);
return String(accountId);
}
async function getZone(domainConfig, zoneName) {
assert.strictEqual(typeof domainConfig, 'object');
assert.strictEqual(typeof zoneName, 'string');
const accountId = await getAccountId(domainConfig);
const [error, response] = await safe(superagent.get(`${DNSIMPLE_API}/${accountId}/zones?name_like=${zoneName}`)
.set('Authorization', `Bearer ${domainConfig.accessToken}`)
.retry(5)
.timeout(30 * 1000)
.ok(() => true));
if (error) throw new BoxError(BoxError.NETWORK_ERROR, error.message);
if (response.statusCode === 401) throw new BoxError(BoxError.ACCESS_DENIED, formatError(response));
if (response.statusCode !== 200) throw new BoxError(BoxError.EXTERNAL_ERROR, formatError(response));
if (!Array.isArray(response.body.data)) throw new BoxError(BoxError.EXTERNAL_ERROR, `Invalid data in response: ${JSON.stringify(response.body)}`);
const item = response.body.data.filter(item => item.name === zoneName);
if (item.length === 0) throw new BoxError(BoxError.NOT_FOUND, 'Domain not found');
return { accountId, zoneId: item[0].id };
}
async function getDnsRecords(domainConfig, zoneName, name, type) {
assert.strictEqual(typeof domainConfig, 'object');
assert.strictEqual(typeof zoneName, 'string');
assert.strictEqual(typeof name, 'string');
assert.strictEqual(typeof type, 'string');
debug(`get: ${name} in zone ${zoneName} of type ${type}`);
const { accountId, zoneId } = await getZone(domainConfig, zoneName);
const [error, response] = await safe(superagent.get(`${DNSIMPLE_API}/${accountId}/zones/${zoneId}/records?name=${name}&type=${type}`)
.set('Authorization', `Bearer ${domainConfig.accessToken}`)
.retry(5)
.timeout(30 * 1000)
.ok(() => true));
if (error) throw new BoxError(BoxError.NETWORK_ERROR, error.message);
if (response.statusCode === 401) throw new BoxError(BoxError.ACCESS_DENIED, formatError(response));
if (response.statusCode !== 200) throw new BoxError(BoxError.EXTERNAL_ERROR, formatError(response));
if (!Array.isArray(response.body.data)) throw new BoxError(BoxError.EXTERNAL_ERROR, `Invalid data in response: ${JSON.stringify(response.body)}`);
return response.body.data;
}
async function upsert(domainObject, location, type, values) {
assert.strictEqual(typeof domainObject, 'object');
assert.strictEqual(typeof location, 'string');
assert.strictEqual(typeof type, 'string');
assert(Array.isArray(values));
const domainConfig = domainObject.config,
zoneName = domainObject.zoneName,
name = dns.getName(domainObject, location, type) || '';
debug(`upsert: ${name} in zone ${zoneName} of type ${type} with values ${JSON.stringify(values)}`);
const { accountId, zoneId } = await getZone(domainConfig, zoneName);
const records = await getDnsRecords(domainConfig, zoneName, name, type);
// used to track available records to update instead of create
let i = 0, recordIds = [];
for (let value of values) {
let priority = 0;
if (type === 'MX') {
priority = parseInt(value.split(' ')[0], 10);
value = value.split(' ')[1];
} else if (type === 'TXT') {
value = value.replace(/^"(.*)"$/, '$1'); // strip any double quotes
}
const data = {
type,
name,
content: value,
priority,
ttl: 60
};
if (i >= records.length) {
const [error, response] = await safe(superagent.post(`${DNSIMPLE_API}/${accountId}/zones/${zoneId}/records`)
.set('Authorization', `Bearer ${domainConfig.accessToken}`)
.send(data)
.retry(5)
.timeout(30 * 1000)
.ok(() => true));
if (error) throw new BoxError(BoxError.NETWORK_ERROR, error.message);
if (response.statusCode === 401) throw new BoxError(BoxError.ACCESS_DENIED, formatError(response));
if (response.statusCode !== 201) throw new BoxError(BoxError.EXTERNAL_ERROR, formatError(response));
recordIds.push(safe.query(response.body, 'data.id'));
} else {
const [error, response] = await safe(superagent.patch(`${DNSIMPLE_API}/${accountId}/zones/${zoneId}/records/${records[i].id}`)
.set('Authorization', `Bearer ${domainConfig.accessToken}`)
.send(data)
.retry(5)
.timeout(30 * 1000)
.ok(() => true));
++i;
if (error) throw new BoxError(BoxError.NETWORK_ERROR, error.message);
if (response.statusCode === 401) throw new BoxError(BoxError.ACCESS_DENIED, formatError(response));
if (response.statusCode !== 200) throw new BoxError(BoxError.EXTERNAL_ERROR, formatError(response));
recordIds.push(safe.query(response.body, 'data.id'));
}
}
for (let j = values.length + 1; j < records.length; j++) {
const [error] = await safe(superagent.del(`${DNSIMPLE_API}/${accountId}/zones/${zoneId}/records/${records[i].id}`)
.set('Authorization', `Bearer ${domainConfig.accessToken}`)
.retry(5)
.timeout(30 * 1000)
.ok(() => true));
if (error) debug(`upsert: error removing record ${records[j].id}: ${error.message}`);
}
debug('upsert: completed with recordIds:%j', recordIds);
}
async function get(domainObject, location, type) {
assert.strictEqual(typeof domainObject, 'object');
assert.strictEqual(typeof location, 'string');
assert.strictEqual(typeof type, 'string');
const domainConfig = domainObject.config,
zoneName = domainObject.zoneName,
name = dns.getName(domainObject, location, type) || '';
const records = await getDnsRecords(domainConfig, zoneName, name, type);
return records.map(r => r.content);
}
async function del(domainObject, location, type, values) {
assert.strictEqual(typeof domainObject, 'object');
assert.strictEqual(typeof location, 'string');
assert.strictEqual(typeof type, 'string');
assert(Array.isArray(values));
const domainConfig = domainObject.config,
zoneName = domainObject.zoneName,
name = dns.getName(domainObject, location, type) || '';
debug(`del: ${name} in zone ${zoneName} of type ${type} with values ${JSON.stringify(values)}`);
const { accountId, zoneId } = await getZone(domainConfig, zoneName);
const records = await getDnsRecords(domainConfig, zoneName, name, type);
const ids = records.map(r => r.id);
for (const id of ids) {
const [error, response] = await safe(superagent.del(`${DNSIMPLE_API}/${accountId}/zones/${zoneId}/records/${id}`)
.set('Authorization', `Bearer ${domainConfig.accessToken}`)
.retry(5)
.timeout(30 * 1000)
.ok(() => true));
if (error) throw new BoxError(BoxError.NETWORK_ERROR, error.message);
if (response.statusCode === 401) throw new BoxError(BoxError.ACCESS_DENIED, formatError(response));
if (response.statusCode === 404) continue;
if (response.statusCode !== 204) throw new BoxError(BoxError.EXTERNAL_ERROR, formatError(response));
}
}
async function wait(domainObject, subdomain, type, value, options) {
assert.strictEqual(typeof domainObject, 'object');
assert.strictEqual(typeof subdomain, 'string');
assert.strictEqual(typeof type, 'string');
assert.strictEqual(typeof value, 'string');
assert(options && typeof options === 'object'); // { interval: 5000, times: 50000 }
const fqdn = dns.fqdn(subdomain, domainObject.domain);
await waitForDns(fqdn, domainObject.zoneName, type, value, options);
}
async function verifyDomainConfig(domainObject) {
assert.strictEqual(typeof domainObject, 'object');
const domainConfig = domainObject.config,
zoneName = domainObject.zoneName;
if (!domainConfig.accessToken || typeof domainConfig.accessToken !== 'string') throw new BoxError(BoxError.BAD_FIELD, 'accessToken must be a non-empty string');
const ip = '127.0.0.1';
const credentials = {
accessToken: domainConfig.accessToken,
};
if (constants.TEST) return credentials; // this shouldn't be here
const [error, nameservers] = await safe(dig.resolve(zoneName, 'NS', { timeout: 5000 }));
if (error && error.code === 'ENOTFOUND') throw new BoxError(BoxError.BAD_FIELD, 'Unable to resolve nameservers for this domain');
if (error || !nameservers) throw new BoxError(BoxError.BAD_FIELD, error ? error.message : 'Unable to get nameservers');
if (!nameservers.every(function (n) { return n.toLowerCase().indexOf('dnsimple') !== -1; })) { // can be dnsimple.com or dnsimple-edge.org
debug('verifyDomainConfig: %j does not contain dnsimple NS', nameservers);
throw new BoxError(BoxError.BAD_FIELD, 'Domain nameservers are not set to dnsimple');
}
const location = 'cloudrontestdns';
await upsert(domainObject, location, 'A', [ ip ]);
debug('verifyDomainConfig: Test A record added');
await del(domainObject, location, 'A', [ ip ]);
debug('verifyDomainConfig: Test A record removed again');
return credentials;
}

View File

@@ -138,7 +138,7 @@ async function verifyDomainConfig(domainObject) {
const ip = '127.0.0.1';
if (process.env.BOX_ENV === 'test') return credentials; // this shouldn't be here
if (constants.TEST) return credentials; // this shouldn't be here
const [error, nameservers] = await safe(dig.resolve(zoneName, 'NS', { timeout: 5000 }));
if (error && error.code === 'ENOTFOUND') throw new BoxError(BoxError.BAD_FIELD, 'Unable to resolve nameservers for this domain');

View File

@@ -168,7 +168,7 @@ async function verifyDomainConfig(domainObject) {
const ip = '127.0.0.1';
if (process.env.BOX_ENV === 'test') return credentials; // this shouldn't be here
if (constants.TEST) return credentials; // this shouldn't be here
const [error, nameservers] = await safe(dig.resolve(zoneName, 'NS', { timeout: 5000 }));
if (error && error.code === 'ENOTFOUND') throw new BoxError(BoxError.BAD_FIELD, 'Unable to resolve nameservers for this domain');

View File

@@ -172,7 +172,7 @@ async function verifyDomainConfig(domainObject) {
apiSecret: domainConfig.apiSecret
};
if (process.env.BOX_ENV === 'test') return credentials; // this shouldn't be here
if (constants.TEST) return credentials; // this shouldn't be here
const [error, nameservers] = await safe(dig.resolve(zoneName, 'NS', { timeout: 5000 }));
if (error && error.code === 'ENOTFOUND') throw new BoxError(BoxError.BAD_FIELD, 'Unable to resolve nameservers for this domain');

View File

@@ -235,15 +235,14 @@ async function verifyDomainConfig(domainObject) {
token: domainConfig.token
};
if (process.env.BOX_ENV === 'test') return credentials; // this shouldn't be here
if (constants.TEST) return credentials; // this shouldn't be here
const [error, nameservers] = await safe(dig.resolve(zoneName, 'NS', { timeout: 5000 }));
if (error && error.code === 'ENOTFOUND') throw new BoxError(BoxError.BAD_FIELD, 'Unable to resolve nameservers for this domain');
if (error || !nameservers) throw new BoxError(BoxError.BAD_FIELD, error ? error.message : 'Unable to get nameservers');
// https://docs.hetzner.com/dns-console/dns/general/dns-overview#the-hetzner-online-name-servers-are
const nsMap = nameservers.map(function (n) { return n.toLowerCase(); });
if (!nsMap.includes('oxygen.ns.hetzner.com') && !nsMap.includes('ns1.your-server.de')) {
if (!nameservers.every(function (n) { return n.toLowerCase().search(/hetzner|your-server|second-ns/) !== -1; })) {
debug('verifyDomainConfig: %j does not contain Hetzner NS', nameservers);
throw new BoxError(BoxError.BAD_FIELD, 'Domain nameservers are not set to Hetzner');
}

View File

@@ -244,7 +244,7 @@ async function verifyDomainConfig(domainObject) {
token: domainConfig.token
};
if (process.env.BOX_ENV === 'test') return credentials; // this shouldn't be here
if (constants.TEST) return credentials; // this shouldn't be here
const [error, nameservers] = await safe(dig.resolve(zoneName, 'NS', { timeout: 5000 }));
if (error && error.code === 'ENOTFOUND') throw new BoxError(BoxError.BAD_FIELD, 'Unable to resolve nameservers for this domain');

View File

@@ -257,7 +257,7 @@ async function verifyDomainConfig(domainObject) {
token: domainConfig.token
};
if (process.env.BOX_ENV === 'test') return credentials; // this shouldn't be here
if (constants.TEST) return credentials; // this shouldn't be here
const [error, nameservers] = await safe(dig.resolve(zoneName, 'NS', { timeout: 5000 }));
if (error && error.code === 'ENOTFOUND') throw new BoxError(BoxError.BAD_FIELD, 'Unable to resolve nameservers for this domain');

View File

@@ -227,7 +227,7 @@ async function verifyDomainConfig(domainObject) {
const ip = '127.0.0.1';
if (process.env.BOX_ENV === 'test') return credentials; // this shouldn't be here
if (constants.TEST) return credentials; // this shouldn't be here
const [error, nameservers] = await safe(dig.resolve(zoneName, 'NS', { timeout: 5000 }));
if (error && error.code === 'ENOTFOUND') throw new BoxError(BoxError.BAD_FIELD, 'Unable to resolve nameservers for this domain');

View File

@@ -240,7 +240,7 @@ async function verifyDomainConfig(domainObject) {
apiPassword: domainConfig.apiPassword,
};
if (process.env.BOX_ENV === 'test') return credentials; // this shouldn't be here
if (constants.TEST) return credentials; // this shouldn't be here
const [error, nameservers] = await safe(dig.resolve(zoneName, 'NS', { timeout: 5000 }));
if (error && error.code === 'ENOTFOUND') throw new BoxError(BoxError.BAD_FIELD, 'Unable to resolve nameservers for this domain');

234
src/dns/ovh.js Normal file
View File

@@ -0,0 +1,234 @@
'use strict';
exports = module.exports = {
removePrivateFields,
injectPrivateFields,
upsert,
get,
del,
wait,
verifyDomainConfig
};
const assert = require('assert'),
BoxError = require('../boxerror.js'),
constants = require('../constants.js'),
debug = require('debug')('box:dns/ovh'),
dig = require('../dig.js'),
dns = require('../dns.js'),
ovhClient = require('ovh'),
safe = require('safetydance'),
waitForDns = require('./waitfordns.js');
function formatError(error) {
return `OVH DNS error ${error.error} ${error.message}`; // error.error is the statusCode
}
function removePrivateFields(domainObject) {
domainObject.config.appSecret = constants.SECRET_PLACEHOLDER;
return domainObject;
}
function injectPrivateFields(newConfig, currentConfig) {
if (newConfig.appSecret === constants.SECRET_PLACEHOLDER) newConfig.appSecret = currentConfig.appSecret;
}
function createClient(domainConfig) {
return ovhClient({
endpoint: domainConfig.endpoint,
appKey: domainConfig.appKey,
appSecret: domainConfig.appSecret,
consumerKey: domainConfig.consumerKey,
});
}
async function getDnsRecordIds(domainConfig, zoneName, name, type) {
assert.strictEqual(typeof domainConfig, 'object');
assert.strictEqual(typeof zoneName, 'string');
assert.strictEqual(typeof name, 'string');
assert.strictEqual(typeof type, 'string');
debug(`get: ${name} in zone ${zoneName} of type ${type}`);
const client = createClient(domainConfig);
const [error, data] = await safe(client.requestPromised('GET', `/domain/zone/${zoneName}/record`, { fieldType: type, subDomain: name }));
if (error) {
if (error.error === 401 || error.error === 403) throw new BoxError(BoxError.ACCESS_DENIED, formatError(error));
throw new BoxError(BoxError.EXTERNAL_ERROR, formatError(error));
}
return data || []; // array of numbers. data is undefined when no entries
}
async function refreshZone(domainConfig, zoneName) {
assert.strictEqual(typeof domainConfig, 'object');
assert.strictEqual(typeof zoneName, 'string');
debug(`refresh: zone ${zoneName}`);
const client = createClient(domainConfig);
const [error] = await safe(client.requestPromised('POST', `/domain/zone/${zoneName}/refresh`));
if (error) {
if (error.error === 401 || error.error === 403) throw new BoxError(BoxError.ACCESS_DENIED, formatError(error));
throw new BoxError(BoxError.EXTERNAL_ERROR, formatError(error));
}
}
async function upsert(domainObject, location, type, values) {
assert.strictEqual(typeof domainObject, 'object');
assert.strictEqual(typeof location, 'string');
assert.strictEqual(typeof type, 'string');
assert(Array.isArray(values));
const domainConfig = domainObject.config,
zoneName = domainObject.zoneName,
name = dns.getName(domainObject, location, type) || '';
debug(`upsert: ${name} in zone ${zoneName} of type ${type} with values ${JSON.stringify(values)}`);
const recordIds = await getDnsRecordIds(domainConfig, zoneName, name, type);
const client = createClient(domainConfig);
// used to track available records to update instead of create
let i = 0;
for (let value of values) {
const data = {
subDomain: name,
target: value,
ttl: 60
};
let error;
if (i >= recordIds.length) {
data.fieldType = type;
[error] = await safe(client.requestPromised('POST', `/domain/zone/${zoneName}/record`, data));
} else {
[error] = await safe(client.requestPromised('PUT', `/domain/zone/${zoneName}/record/${recordIds[i]}`, data));
++i;
}
if (error) {
if (error.error === 401 || error.error === 403) throw new BoxError(BoxError.ACCESS_DENIED, formatError(error));
throw new BoxError(BoxError.EXTERNAL_ERROR, formatError(error));
}
}
for (let j = values.length + 1; j < recordIds.length; j++) {
const [error] = await safe(client.requestPromised('DELETE', `/domain/zone/${zoneName}/record/${recordIds[j]}`));
if (error) {
if (error.error === 401) throw new BoxError(BoxError.ACCESS_DENIED, formatError(error));
if (error.error === 404) continue; // not found
throw new BoxError(BoxError.EXTERNAL_ERROR, formatError(error));
}
}
await refreshZone(domainConfig, zoneName);
debug('upsert: completed');
}
async function get(domainObject, location, type) {
assert.strictEqual(typeof domainObject, 'object');
assert.strictEqual(typeof location, 'string');
assert.strictEqual(typeof type, 'string');
const domainConfig = domainObject.config,
zoneName = domainObject.zoneName,
name = dns.getName(domainObject, location, type) || '';
const recordIds = await getDnsRecordIds(domainConfig, zoneName, name, type);
const client = createClient(domainConfig);
const result = [];
for (const id of recordIds) {
const [error, data] = await safe(client.requestPromised('GET', `/domain/zone/${zoneName}/record/${id}`));
if (error) {
if (error.error === 401) throw new BoxError(BoxError.ACCESS_DENIED, formatError(error));
throw new BoxError(BoxError.EXTERNAL_ERROR, formatError(error));
}
result.push(data.target);
}
return result;
}
async function del(domainObject, location, type, values) {
assert.strictEqual(typeof domainObject, 'object');
assert.strictEqual(typeof location, 'string');
assert.strictEqual(typeof type, 'string');
assert(Array.isArray(values));
const domainConfig = domainObject.config,
zoneName = domainObject.zoneName,
name = dns.getName(domainObject, location, type) || '';
debug(`del: ${name} in zone ${zoneName} of type ${type} with values ${JSON.stringify(values)}`);
const recordIds = await getDnsRecordIds(domainConfig, zoneName, name, type);
const client = createClient(domainConfig);
for (const id of recordIds) {
const [error] = await safe(client.requestPromised('DELETE', `/domain/zone/${zoneName}/record/${id}`));
if (error) {
if (error.error === 401) throw new BoxError(BoxError.ACCESS_DENIED, formatError(error));
if (error.error === 404) continue; // not found
throw new BoxError(BoxError.EXTERNAL_ERROR, formatError(error));
}
}
await refreshZone(domainConfig, zoneName);
}
async function wait(domainObject, subdomain, type, value, options) {
assert.strictEqual(typeof domainObject, 'object');
assert.strictEqual(typeof subdomain, 'string');
assert.strictEqual(typeof type, 'string');
assert.strictEqual(typeof value, 'string');
assert(options && typeof options === 'object'); // { interval: 5000, times: 50000 }
const fqdn = dns.fqdn(subdomain, domainObject.domain);
await waitForDns(fqdn, domainObject.zoneName, type, value, options);
}
async function verifyDomainConfig(domainObject) {
assert.strictEqual(typeof domainObject, 'object');
const domainConfig = domainObject.config,
zoneName = domainObject.zoneName;
if (!domainConfig.endpoint || typeof domainConfig.endpoint !== 'string') throw new BoxError(BoxError.BAD_FIELD, 'endpoint must be a non-empty string');
if (!domainConfig.appKey || typeof domainConfig.appKey !== 'string') throw new BoxError(BoxError.BAD_FIELD, 'appKey must be a non-empty string');
if (!domainConfig.appSecret || typeof domainConfig.appSecret !== 'string') throw new BoxError(BoxError.BAD_FIELD, 'appSecret must be a non-empty string');
if (!domainConfig.consumerKey || typeof domainConfig.consumerKey !== 'string') throw new BoxError(BoxError.BAD_FIELD, 'consumerKey must be a non-empty string');
const ip = '127.0.0.1';
const credentials = {
endpoint: domainConfig.endpoint, // https://github.com/ovh/node-ovh#2-authorize-your-application-to-access-to-a-customer-account
appKey: domainConfig.appKey,
appSecret: domainConfig.appSecret,
consumerKey: domainConfig.consumerKey,
};
if (constants.TEST) return credentials; // this shouldn't be here
const [error, nameservers] = await safe(dig.resolve(zoneName, 'NS', { timeout: 5000 }));
if (error && error.code === 'ENOTFOUND') throw new BoxError(BoxError.BAD_FIELD, 'Unable to resolve nameservers for this domain');
if (error || !nameservers) throw new BoxError(BoxError.BAD_FIELD, error ? error.message : 'Unable to get nameservers');
// ovh.net, ovh.ca or anycast.me
if (!nameservers.every(function (n) { return n.toLowerCase().search(/ovh|kimsufi|anycast/) !== -1; })) {
debug('verifyDomainConfig: %j does not contain OVH NS', nameservers);
throw new BoxError(BoxError.BAD_FIELD, 'Domain nameservers are not set to OVH');
}
const location = 'cloudrontestdns';
await upsert(domainObject, location, 'A', [ ip ]);
debug('verifyDomainConfig: Test A record added');
await del(domainObject, location, 'A', [ ip ]);
debug('verifyDomainConfig: Test A record removed again');
return credentials;
}

View File

@@ -217,7 +217,7 @@ async function verifyDomainConfig(domainObject) {
apikey: domainConfig.apikey
};
if (process.env.BOX_ENV === 'test') return credentials; // this shouldn't be here
if (constants.TEST) return credentials; // this shouldn't be here
const [error, nameservers] = await safe(dig.resolve(zoneName, 'NS', { timeout: 5000 }));
if (error && error.code === 'ENOTFOUND') throw new BoxError(BoxError.BAD_FIELD, 'Unable to resolve nameservers for this domain');

View File

@@ -39,7 +39,12 @@ function getDnsCredentials(domainConfig) {
const credentials = {
accessKeyId: domainConfig.accessKeyId,
secretAccessKey: domainConfig.secretAccessKey,
region: domainConfig.region
region: domainConfig.region,
maxRetries: 20,
// route53 has a limit of 5 req/sec/region - https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/DNSLimitations.html#limits-api-requests
retryDelayOptions: {
customBackoff: (/* retryCount, error */) => 3000 // constant backoff - https://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/Config.html#retryDelayOptions-property
},
};
if (domainConfig.endpoint) credentials.endpoint = new AWS.Endpoint(domainConfig.endpoint);
@@ -239,7 +244,7 @@ async function verifyDomainConfig(domainObject) {
const ip = '127.0.0.1';
if (process.env.BOX_ENV === 'test') return credentials; // this shouldn't be here
if (constants.TEST) return credentials; // this shouldn't be here
const [error, nameservers] = await safe(dig.resolve(zoneName, 'NS', { timeout: 5000 }));
if (error && error.code === 'ENOTFOUND') throw new BoxError(BoxError.BAD_FIELD, 'Unable to resolve nameservers for this domain');

View File

@@ -214,7 +214,7 @@ async function verifyDomainConfig(domainObject) {
token: domainConfig.token
};
if (process.env.BOX_ENV === 'test') credentials; // this shouldn't be here
if (constants.TEST) credentials; // this shouldn't be here
const [error, nameservers] = await safe(dig.resolve(zoneName, 'NS', { timeout: 5000 }));
if (error && error.code === 'ENOTFOUND') throw new BoxError(BoxError.BAD_FIELD, 'Unable to resolve nameservers for this domain');

View File

@@ -135,7 +135,7 @@ async function pullImage(manifest) {
if (!layerError) return resolve();
reject(new BoxError(layerError.includes('no space') ? BoxError.FS_ERROR : BoxError.DOCKER_ERROR, layerError.message));
reject(new BoxError(layerError.message.includes('no space') ? BoxError.FS_ERROR : BoxError.DOCKER_ERROR, layerError.message));
});
stream.on('error', function (error) { // this is only hit for stream error and not for some download error
@@ -235,7 +235,9 @@ async function getMounts(app) {
return volumeMounts.concat(addonMounts);
}
function getAddresses() {
// This only returns ipv4 addresses
// We dont bind to ipv6 interfaces, public prefix changes and container restarts wont work
function getAddressesForPort53() {
const deviceLinks = safe.fs.readdirSync('/sys/class/net'); // https://man7.org/linux/man-pages/man5/sysfs.5.html
if (!deviceLinks) return [];
@@ -249,11 +251,6 @@ function getAddresses() {
const address = safe.query(r, 'addr_info[0].local');
if (address) addresses.push(address);
}
const inet6 = safe.JSON.parse(safe.child_process.execSync(`ip -f inet6 -j addr show dev ${phy.name} scope global`, { encoding: 'utf8' }));
for (const r of inet6) {
const address = safe.query(r, 'addr_info[0].local');
if (address) addresses.push(address);
}
}
return addresses;
@@ -298,7 +295,7 @@ async function createSubcontainer(app, name, cmd, options) {
exposedPorts[`${containerPort}/${portType}`] = {};
portEnv.push(`${portName}=${hostPort}`);
const hostIps = hostPort === 53 ? getAddresses() : [ '0.0.0.0', '::0' ]; // port 53 is special because it is possibly taken by systemd-resolved
const hostIps = hostPort === 53 ? getAddressesForPort53() : [ '0.0.0.0', '::0' ]; // port 53 is special because it is possibly taken by systemd-resolved
dockerPortBindings[`${containerPort}/${portType}`] = hostIps.map(hip => { return { HostIp: hip, HostPort: hostPort + '' }; });
}

View File

@@ -17,7 +17,8 @@ const apps = require('./apps.js'),
path = require('path'),
paths = require('./paths.js'),
safe = require('safetydance'),
util = require('util');
util = require('util'),
volumes = require('./volumes.js');
let gHttpServer = null;
@@ -31,8 +32,7 @@ async function authorizeApp(req, res, next) {
const [error, app] = await safe(apps.getByIpAddress(req.connection.remoteAddress));
if (error) return next(new HttpError(500, error));
if (!app) return next(new HttpError(401, 'Unauthorized'));
if (!('docker' in app.manifest.addons)) return next(new HttpError(401, 'Unauthorized'));
if (!app.manifest.addons?.docker) return next(new HttpError(401, 'Unauthorized'));
req.app = app;
@@ -63,7 +63,7 @@ function attachDockerRequest(req, res, next) {
}
// eslint-disable-next-line no-unused-vars
function containersCreate(req, res, next) {
async function containersCreate(req, res, next) {
safe.set(req.body, 'HostConfig.NetworkMode', 'cloudron'); // overwrite the network the container lives in
safe.set(req.body, 'NetworkingConfig', {}); // drop any custom network configs
safe.set(req.body, 'Labels', Object.assign({}, safe.query(req.body, 'Labels'), { appId: req.app.id, isCloudronManaged: String(false) })); // overwrite the app id to track containers of an app
@@ -71,23 +71,33 @@ function containersCreate(req, res, next) {
const appDataDir = path.join(paths.APPS_DATA_DIR, req.app.id, 'data');
debug('Original bind mounts:', req.body.HostConfig.Binds);
debug('containersCreate: original bind mounts:', req.body.HostConfig.Binds);
let binds = [];
for (let bind of (req.body.HostConfig.Binds || [])) {
if (!bind.startsWith('/app/data/')) {
const [error, result] = await safe(volumes.list());
if (error) return next(new HttpError(500, `Error listing volumes: ${error.message}`));
const volumesByName = {};
result.forEach(r => volumesByName[r.name] = r);
const binds = [];
for (const bind of (req.body.HostConfig.Binds || [])) { // bind is of the host:container:rw format
if (bind.startsWith('/app/data')) {
binds.push(bind.replace(new RegExp('^/app/data/'), appDataDir + '/'));
} else if (bind.startsWith('/media/')) {
const volumeName = bind.match(new RegExp('/media/([^:/]+)/?'))[1];
const volume = volumesByName[volumeName];
if (volume) binds.push(bind.replace(new RegExp(`^/media/${volumeName}`), volume.hostPath));
else debug(`containersCreate: dropped unknown volume ${volumeName}`);
} else {
req.dockerRequest.abort();
return next(new HttpError(400, 'Binds must be under /app/data/'));
return next(new HttpError(400, 'Binds must be under /app/data/ or /media'));
}
binds.push(bind.replace(new RegExp('^/app/data/'), appDataDir + '/'));
}
debug('Rewritten bind mounts:', binds);
debug('containersCreate: rewritten bind mounts:', binds);
safe.set(req.body, 'HostConfig.Binds', binds);
let plainBody = JSON.stringify(req.body);
const plainBody = JSON.stringify(req.body);
req.dockerRequest.setHeader('Content-Length', Buffer.byteLength(plainBody));
req.dockerRequest.end(plainBody);
}
@@ -96,7 +106,7 @@ function containersCreate(req, res, next) {
function process(req, res, next) {
// we have to rebuild the body since we consumed in in the parser
if (Object.keys(req.body).length !== 0) {
let plainBody = JSON.stringify(req.body);
const plainBody = JSON.stringify(req.body);
req.dockerRequest.setHeader('Content-Length', Buffer.byteLength(plainBody));
req.dockerRequest.end(plainBody);
} else if (!req.readable) {
@@ -138,8 +148,6 @@ async function start() {
// Overwrite the default 2min request timeout. This is required for large builds for example
gHttpServer.setTimeout(60 * 60 * 1000);
debug(`startDockerProxy: started proxy on port ${constants.DOCKER_PROXY_PORT}`);
// eslint-disable-next-line no-unused-vars
gHttpServer.on('upgrade', function (req, client, head) {
// Create a new tcp connection to the TCP server
@@ -151,7 +159,7 @@ async function start() {
if (req.headers['content-type'] === 'application/json') {
// TODO we have to parse the immediate upgrade request body, but I don't know how
let plainBody = '{"Detach":false,"Tty":false}\r\n';
const plainBody = '{"Detach":false,"Tty":false}\r\n';
upgradeMessage += 'Content-Type: application/json\r\n';
upgradeMessage += `Content-Length: ${Buffer.byteLength(plainBody)}\r\n`;
upgradeMessage += '\r\n';
@@ -168,6 +176,7 @@ async function start() {
});
});
debug(`start: listening on 172.18.0.1:${constants.DOCKER_PROXY_PORT}`);
await util.promisify(gHttpServer.listen.bind(gHttpServer))(constants.DOCKER_PROXY_PORT, '172.18.0.1');
}

View File

@@ -54,6 +54,7 @@ function api(provider) {
switch (provider) {
case 'bunny': return require('./dns/bunny.js');
case 'cloudflare': return require('./dns/cloudflare.js');
case 'dnsimple': return require('./dns/dnsimple.js');
case 'route53': return require('./dns/route53.js');
case 'gcdns': return require('./dns/gcdns.js');
case 'digitalocean': return require('./dns/digitalocean.js');
@@ -66,6 +67,7 @@ function api(provider) {
case 'namecheap': return require('./dns/namecheap.js');
case 'netcup': return require('./dns/netcup.js');
case 'noop': return require('./dns/noop.js');
case 'ovh': return require('./dns/ovh.js');
case 'manual': return require('./dns/manual.js');
case 'porkbun': return require('./dns/porkbun.js');
case 'wildcard': return require('./dns/wildcard.js');

View File

@@ -4,6 +4,7 @@ exports = module.exports = {
add,
upsertLoginEvent,
get,
getActivationEvent,
listPaged,
cleanup,
_clear: clear,
@@ -145,7 +146,14 @@ async function upsertLoginEvent(action, source, data) {
async function get(id) {
assert.strictEqual(typeof id, 'string');
const result = await database.query('SELECT ' + EVENTLOG_FIELDS + ' FROM eventlog WHERE id = ?', [ id ]);
const result = await database.query(`SELECT ${EVENTLOG_FIELDS} FROM eventlog WHERE id = ?`, [ id ]);
if (result.length === 0) return null;
return postProcess(result[0]);
}
async function getActivationEvent() {
const result = await database.query(`SELECT ${EVENTLOG_FIELDS} FROM eventlog WHERE action = ? ORDER BY creationTime`, [ exports.ACTION_ACTIVATE ]);
if (result.length === 0) return null;
return postProcess(result[0]);

View File

@@ -12,13 +12,13 @@ exports = module.exports = {
// docker inspect --format='{{index .RepoDigests 0}}' $IMAGE to get the sha256
'images': {
'base': 'registry.docker.com/cloudron/base:4.2.0@sha256:46da2fffb36353ef714f97ae8e962bd2c212ca091108d768ba473078319a47f4',
'graphite': 'registry.docker.com/cloudron/graphite:3.4.2@sha256:bc30121baecfa887856de56278fca5a532543c8ff439b60dc178132d578c5e9f',
'mail': 'registry.docker.com/cloudron/mail:3.11.2@sha256:10ca751587055b1250171e53c7cd4a488d0075762f2fdba3fa32470a41d980da',
'graphite': 'registry.docker.com/cloudron/graphite:3.4.3@sha256:75df420ece34b31a7ce8d45b932246b7f524c123e1854f5e8f115a9e94e33f20',
'mail': 'registry.docker.com/cloudron/mail:3.11.3@sha256:0e8ec3ba14482e2256ab0fb75021da11f437e6f269cd9dc0232426aca1b9361a',
'mongodb': 'registry.docker.com/cloudron/mongodb:5.1.2@sha256:897bea3cae08c8c10f9f5adaff853be314ab94aa98d96a8d0caa502babd983aa',
'mysql': 'registry.docker.com/cloudron/mysql:3.4.2@sha256:379749708186a89f4ae09d6b23b58bc6d99a2005bac32e812b4b1dafa47071e4',
'postgresql': 'registry.docker.com/cloudron/postgresql:5.1.2@sha256:66a3046f784a94dce0205336ebe1a32fbf9e946e9b16352addb45f31eea34dd1',
'postgresql': 'registry.docker.com/cloudron/postgresql:5.1.6@sha256:a89231a7835955767893a83b2d993764f59da24e292385b06470c8e42a1ffa0e',
'redis': 'registry.docker.com/cloudron/redis:3.5.2@sha256:5c3d9a912d3ad723b195cfcbe9f44956a2aa88f9e29f7da3ef725162f8e2829a',
'sftp': 'registry.docker.com/cloudron/sftp:3.8.2@sha256:62e796ff97cd22266236cb1b53ac9ac9139cd08b0168ae35b5ee7e89c09a818b',
'sftp': 'registry.docker.com/cloudron/sftp:3.8.3@sha256:e00d8ef884b8657b57499d397d9db7f141f3d17253eec2752cdef5d15fff51da',
'turn': 'registry.docker.com/cloudron/turn:1.7.2@sha256:9ed8da613c1edc5cb8700657cf6e49f0f285b446222a8f459f80919945352f6d',
}
};

View File

@@ -635,6 +635,8 @@ async function maybeRootDSE(req, res, next) {
}
async function start() {
assert(gServer === null, 'Already started');
const logger = {
trace: NOOP,
debug: NOOP,

View File

@@ -689,7 +689,7 @@ async function upsertDnsRecords(domain, mailFqdn) {
const mailDomain = await getDomain(domain);
if (!mailDomain) throw new BoxError(BoxError.NOT_FOUND, 'mail domain not found');
if (process.env.BOX_ENV === 'test') return;
if (constants.TEST) return;
const publicKey = mailDomain.dkimKey.publicKey.split('\n').slice(1, -2).join(''); // remove header, footer and new lines

View File

@@ -18,6 +18,7 @@ exports = module.exports = {
const assert = require('assert'),
BoxError = require('./boxerror.js'),
branding = require('./branding.js'),
constants = require('./constants.js'),
dashboard = require('./dashboard.js'),
debug = require('debug')('box:mailer'),
ejs = require('ejs'),
@@ -25,7 +26,6 @@ const assert = require('assert'),
nodemailer = require('nodemailer'),
path = require('path'),
safe = require('safetydance'),
support = require('./support.js'),
translation = require('./translation.js'),
util = require('util');
@@ -34,20 +34,19 @@ const MAIL_TEMPLATES_DIR = path.join(__dirname, 'mail_templates');
// This will collect the most common details required for notification emails
async function getMailConfig() {
const cloudronName = await branding.getCloudronName();
const supportConfig = await support.getConfig();
const { domain:dashboardDomain } = await dashboard.getLocation();
return {
cloudronName,
notificationFrom: `"${cloudronName}" <no-reply@${dashboardDomain}>`,
supportEmail: supportConfig.email
supportEmail: 'support@cloudron.io'
};
}
async function sendMail(mailOptions) {
assert.strictEqual(typeof mailOptions, 'object');
if (process.env.BOX_ENV === 'test') {
if (constants.TEST) {
exports._mailQueue.push(mailOptions);
return;
}

View File

@@ -194,7 +194,7 @@ async function configureMail(mailFqdn, mailDomain, serviceConfig) {
}
async function restart() {
if (process.env.BOX_ENV === 'test' && !process.env.TEST_CREATE_INFRA) return;
if (constants.TEST && !process.env.TEST_CREATE_INFRA) return;
const mailConfig = await services.getServiceConfig('mail');
const { domain, fqdn } = await getLocation();

View File

@@ -195,20 +195,6 @@ async function tryAddMount(mount, options) {
if (constants.TEST) return;
// first try to mount at /mnt/volumes/<volumeId>-attempt
const originalHostPath = mount.hostPath;
mount.hostPath = originalHostPath + '-attempt';
const [attemptError] = await safe(shell.promises.sudo('addMount', [ ADD_MOUNT_CMD, renderMountFile(mount), options.timeout ], {}));
if (attemptError && attemptError.code === 2) throw new BoxError(BoxError.MOUNT_ERROR, 'Failed to unmount existing mount'); // at this point, the old mount config is still there
const attemptStatus = await getStatus(mount.mountType, mount.hostPath);
await removeMount(mount);
if (attemptStatus.state !== 'active') throw new BoxError(BoxError.MOUNT_ERROR, `Failed to mount (${attemptStatus.state}): ${attemptStatus.message}`);
// now create the real mount
mount.hostPath = originalHostPath;
const [error] = await safe(shell.promises.sudo('addMount', [ ADD_MOUNT_CMD, renderMountFile(mount), options.timeout ], {}));
if (error && error.code === 2) throw new BoxError(BoxError.MOUNT_ERROR, 'Failed to unmount existing mount'); // at this point, the old mount config is still there

View File

@@ -76,6 +76,7 @@ async function setBlocklist(blocklist, auditSource) {
const parsedIp = ipaddr.process(auditSource.ip);
let count = 0;
for (const line of blocklist.split('\n')) {
if (!line || line.startsWith('#')) continue;
const rangeOrIP = line.trim();
@@ -88,8 +89,10 @@ async function setBlocklist(blocklist, auditSource) {
const parsedRange = ipaddr.parseCIDR(rangeOrIP); // returns [addr, range]
if (parsedRange[0].kind() === parsedIp.kind() && parsedIp.match(parsedRange)) throw new BoxError(BoxError.BAD_FIELD, `${rangeOrIP} includes client IP. Cannot block yourself`);
}
++count;
}
if (count >= 262144) throw new BoxError(BoxError.CONFLICT, 'Blocklist is too large. Max 262144 entries are allowed'); // see the cloudron-firewall.sh
if (constants.DEMO) throw new BoxError(BoxError.CONFLICT, 'Not allowed in demo mode');
// store in blob since the value field is TEXT and has 16kb size limit

View File

@@ -9,6 +9,7 @@ exports = module.exports = {
const assert = require('assert'),
BoxError = require('../boxerror.js'),
constants = require('../constants.js'),
debug = require('debug')('box:network/generic'),
safe = require('safetydance'),
superagent = require('superagent');
@@ -18,7 +19,7 @@ const gCache = { ipv4: {}, ipv6: {} }; // each has { timestamp, value, request }
async function getIPv4(config) {
assert.strictEqual(typeof config, 'object');
if (process.env.BOX_ENV === 'test') return '127.0.0.1';
if (constants.TEST) return '127.0.0.1';
if (gCache.ipv4.value && (Date.now() - gCache.ipv4.timestamp <= 5 * 60 * 1000)) return gCache.ipv4.value;
@@ -52,7 +53,7 @@ async function getIPv4(config) {
async function getIPv6(config) {
assert.strictEqual(typeof config, 'object');
if (process.env.BOX_ENV === 'test') return '::1';
if (constants.TEST) return '::1';
if (gCache.ipv6.value && (Date.now() - gCache.ipv6.timestamp <= 5 * 60 * 1000)) return gCache.ipv6.value;

View File

@@ -110,13 +110,12 @@ async function clientsGet(id) {
async function clientsUpdate(id, data) {
assert.strictEqual(typeof id, 'string');
assert.strictEqual(typeof data.secret, 'string');
assert.strictEqual(typeof data.loginRedirectUri, 'string');
assert.strictEqual(typeof data.name, 'string');
assert.strictEqual(typeof data.appId, 'string');
assert(data.tokenSignatureAlgorithm === 'RS256' || data.tokenSignatureAlgorithm === 'EdDSA');
const result = await database.query(`UPDATE ${OIDC_CLIENTS_TABLE_NAME} SET secret=?, name=?, appId=?, loginRedirectUri=?, tokenSignatureAlgorithm=? WHERE id = ?`, [ data.secret, data.name, data.appId, data.loginRedirectUri, data.tokenSignatureAlgorithm, id]);
const result = await database.query(`UPDATE ${OIDC_CLIENTS_TABLE_NAME} SET name=?, appId=?, loginRedirectUri=?, tokenSignatureAlgorithm=? WHERE id = ?`, [ data.name, data.appId, data.loginRedirectUri, data.tokenSignatureAlgorithm, id]);
if (result.affectedRows !== 1) throw new BoxError(BoxError.NOT_FOUND, 'client not found');
}
@@ -128,7 +127,7 @@ async function clientsDel(id) {
}
async function clientsList() {
const results = await database.query(`SELECT * FROM ${OIDC_CLIENTS_TABLE_NAME} ORDER BY id ASC`, []);
const results = await database.query(`SELECT * FROM ${OIDC_CLIENTS_TABLE_NAME} ORDER BY name ASC`, []);
results.forEach(postProcess);
@@ -686,6 +685,8 @@ async function renderError(ctx, out, error) {
}
async function start() {
assert(gHttpServer === null, 'Already started');
const app = express();
gHttpServer = http.createServer(app);
@@ -826,6 +827,5 @@ async function stop() {
if (!gHttpServer) return;
await util.promisify(gHttpServer.close.bind(gHttpServer))();
gHttpServer = null;
}

View File

@@ -130,7 +130,7 @@ async function onInfraReady(infraChanged) {
async function startInfra(restoreOptions) {
assert.strictEqual(typeof restoreOptions, 'object');
if (process.env.BOX_ENV === 'test' && !process.env.TEST_CREATE_INFRA) return;
if (constants.TEST && !process.env.TEST_CREATE_INFRA) return;
debug('startInfra: checking infrastructure');
@@ -228,7 +228,7 @@ async function onActivated(restoreOptions) {
// disable responding to api calls via IP to not leak domain info. this is carefully placed as the last item, so it buys
// the UI some time to query the dashboard domain in the restore code path
await timers.setTimeout(30000);
if (!constants.TEST) await timers.setTimeout(30000);
await reverseProxy.writeDefaultConfig({ activated :true });
}

View File

@@ -25,6 +25,7 @@ exports = module.exports = {
writeDashboardConfig,
writeAppConfigs,
removeDashboardConfig,
removeAppConfigs,
restoreFallbackCertificates,
@@ -472,7 +473,7 @@ async function writeDashboardNginxConfig(vhost, certificatePath) {
async function writeDashboardConfig(domain) {
assert.strictEqual(typeof domain, 'string');
debug(`writeDashboardConfig: writing admin config for ${domain}`);
debug(`writeDashboardConfig: writing dashboard config for ${domain}`);
const dashboardFqdn = dns.fqdn(constants.DASHBOARD_SUBDOMAIN, domain);
const location = { domain, fqdn: dashboardFqdn, certificate: null };
@@ -481,6 +482,19 @@ async function writeDashboardConfig(domain) {
await reload();
}
async function removeDashboardConfig(domain) {
assert.strictEqual(typeof domain, 'string');
debug(`removeDashboardConfig: removing dashboard config of ${domain}`);
const vhost = dns.fqdn(constants.DASHBOARD_SUBDOMAIN, domain);
const nginxConfigFilename = path.join(paths.NGINX_APPCONFIG_DIR, `dashboard/${vhost}.conf`);
if (!safe.fs.unlinkSync(nginxConfigFilename)) throw new BoxError(BoxError.FS_ERROR, safe.error.message);
await reload();
}
async function writeAppLocationNginxConfig(app, location, certificatePath) {
assert.strictEqual(typeof app, 'object');
assert.strictEqual(typeof location, 'object');

View File

@@ -5,8 +5,8 @@ exports = module.exports = {
getApp,
getAppVersion,
getWebToken,
registerCloudron,
registerCloudronWithSetupToken,
registerCloudronWithLogin,
getSubscription
};
@@ -45,14 +45,18 @@ async function getAppVersion(req, res, next) {
next(new HttpSuccess(200, manifest));
}
async function getWebToken(req, res, next) {
const [error, accessToken] = await safe(appstore.getWebToken());
async function registerCloudronWithSetupToken(req, res, next) {
assert.strictEqual(typeof req.body, 'object');
if (typeof req.body.setupToken !== 'string') return next(new HttpError(400, 'setupToken must be a string'));
const [error] = await safe(appstore.registerCloudronWithSetupToken({ setupToken: req.body.setupToken }));
if (error) return next(BoxError.toHttpError(error));
next(new HttpSuccess(200, { accessToken }));
next(new HttpSuccess(201, {}));
}
async function registerCloudron(req, res, next) {
async function registerCloudronWithLogin(req, res, next) {
assert.strictEqual(typeof req.body, 'object');
if (typeof req.body.email !== 'string' || !req.body.email) return next(new HttpError(400, 'email must be string'));
@@ -60,7 +64,7 @@ async function registerCloudron(req, res, next) {
if ('totpToken' in req.body && typeof req.body.totpToken !== 'string') return next(new HttpError(400, 'totpToken must be string'));
if (typeof req.body.signup !== 'boolean') return next(new HttpError(400, 'signup must be a boolean'));
const [error] = await safe(appstore.registerWithLoginCredentials(req.body));
const [error] = await safe(appstore.registerCloudronWithLogin(req.body));
if (error) return next(BoxError.toHttpError(error));
next(new HttpSuccess(201, {}));

View File

@@ -46,7 +46,7 @@ async function proxyToMailContainer(port, pathname, req, res, next) {
req.clearTimeout(); // TODO: add timeout to mail server proxy logic instead of this
mailserverProxy(req, res, function (error) {
if (!error) return next();
if (!error) return; // response was already sent by proxy, do not proceed to connect-lastmile
if (error.code === 'ECONNREFUSED') return next(new HttpError(424, 'Unable to connect to mail server'));
if (error.code === 'ECONNRESET') return next(new HttpError(424, 'Unable to query mail server'));

View File

@@ -14,6 +14,7 @@ exports = module.exports = {
const assert = require('assert'),
BoxError = require('../boxerror.js'),
hat = require('../hat.js'),
oidc = require('../oidc.js'),
HttpError = require('connect-lastmile').HttpError,
HttpSuccess = require('connect-lastmile').HttpSuccess,
@@ -23,27 +24,28 @@ const assert = require('assert'),
async function add(req, res, next) {
assert.strictEqual(typeof req.body, 'object');
if (typeof req.body.id !== 'string' || !req.body.id) return next(new HttpError(400, 'id must be non-empty string'));
if (typeof req.body.name !== 'string' || !req.body.name) return next(new HttpError(400, 'name must be non-empty string'));
if (typeof req.body.secret !== 'string' || !req.body.secret) return next(new HttpError(400, 'secret must be non-empty string'));
if (typeof req.body.loginRedirectUri !== 'string') return next(new HttpError(400, 'loginRedirectUri must be non-empty string'));
if (req.body.tokenSignatureAlgorithm !== 'EdDSA' && req.body.tokenSignatureAlgorithm !== 'RS256') return next(new HttpError(400, 'tokenSignatureAlgorithm must be either EdDSA or RS256'));
// clients with appId are internal only
if (req.body.appId) return next(new HttpError(400, 'appId cannot be specified'));
const clientId = 'cid-' + hat(128);
const data = {
secret: req.body.secret,
secret: hat(256),
name: req.body.name,
appId: '', // always empty for custom clients
tokenSignatureAlgorithm: req.body.tokenSignatureAlgorithm,
loginRedirectUri: req.body.loginRedirectUri
};
const [error] = await safe(oidc.clients.add(req.body.id, data));
const [error] = await safe(oidc.clients.add(clientId, data));
if (error) return next(BoxError.toHttpError(error));
next(new HttpSuccess(201, {}));
data.id = clientId;
next(new HttpSuccess(201, data));
}
async function get(req, res, next) {
@@ -62,7 +64,6 @@ async function update(req, res, next) {
assert.strictEqual(typeof req.body, 'object');
if (typeof req.body.name !== 'string' || !req.body.name) return next(new HttpError(400, 'name must be non-empty string'));
if (typeof req.body.secret !== 'string' || !req.body.secret) return next(new HttpError(400, 'secret must be non-empty string'));
if (typeof req.body.loginRedirectUri !== 'string' || !req.body.loginRedirectUri) return next(new HttpError(400, 'loginRedirectUri must be non-empty string'));
if (req.body.tokenSignatureAlgorithm !== 'EdDSA' && req.body.tokenSignatureAlgorithm !== 'RS256') return next(new HttpError(400, 'tokenSignatureAlgorithm must be either EdDSA or RS256'));
@@ -72,7 +73,6 @@ async function update(req, res, next) {
if (client.appId) return next(new HttpError(422, 'OpenID connect client from an internal app'));
const data = {
secret: req.body.secret,
name: req.body.name,
appId: '', // always empty for custom clients
tokenSignatureAlgorithm: req.body.tokenSignatureAlgorithm,

View File

@@ -5,39 +5,17 @@ exports = module.exports = {
getRemoteSupport,
enableRemoteSupport,
getConfig,
canCreateTicket,
canEnableRemoteSupport
};
const appstore = require('../appstore.js'),
assert = require('assert'),
AuditSource = require('../auditsource.js'),
BoxError = require('../boxerror.js'),
constants = require('../constants.js'),
HttpError = require('connect-lastmile').HttpError,
HttpSuccess = require('connect-lastmile').HttpSuccess,
safe = require('safetydance'),
support = require('../support.js');
async function getConfig(req, res, next) {
const [error, supportConfig] = await safe(support.getConfig());
if (error) return next(BoxError.toHttpError(error));
next(new HttpSuccess(200, supportConfig));
}
async function canCreateTicket(req, res, next) {
const [error, supportConfig] = await safe(support.getConfig());
if (error) return next(new HttpError(503, error.message));
if (!supportConfig.submitTickets) return next(new HttpError(405, 'feature disabled by admin'));
next();
}
async function createTicket(req, res, next) {
assert.strictEqual(typeof req.user, 'object');
@@ -51,25 +29,12 @@ async function createTicket(req, res, next) {
if (req.body.altEmail && typeof req.body.altEmail !== 'string') return next(new HttpError(400, 'altEmail must be string'));
if (req.body.enableSshSupport && typeof req.body.enableSshSupport !== 'boolean') return next(new HttpError(400, 'enableSshSupport must be a boolean'));
const [error, supportConfig] = await safe(support.getConfig());
if (error) return next(new HttpError(503, `Error getting support config: ${error.message}`));
if (supportConfig.email !== constants.SUPPORT_EMAIL) return next(new HttpError(503, 'Sending to non-cloudron email not implemented yet'));
const [ticketError, result] = await safe(appstore.createTicket(Object.assign({ }, req.body, { email: req.user.email, displayName: req.user.displayName }), AuditSource.fromRequest(req)));
if (ticketError) return next(new HttpError(503, `Error contacting cloudron.io: ${ticketError.message}. Please email ${constants.SUPPORT_EMAIL}`));
next(new HttpSuccess(201, result));
}
async function canEnableRemoteSupport(req, res, next) {
const [error, supportConfig] = await safe(support.getConfig());
if (error) return next(new HttpError(503, error.message));
if (!supportConfig.remoteSupport) return next(new HttpError(405, 'feature disabled by admin'));
next();
}
async function enableRemoteSupport(req, res, next) {
assert.strictEqual(typeof req.body, 'object');

View File

@@ -2,7 +2,7 @@
exports = module.exports = {
reboot,
isRebootRequired,
getInfo,
getDisks,
getDiskUsage,
updateDiskUsage,
@@ -11,6 +11,7 @@ exports = module.exports = {
getLogStream,
getSystemGraphs,
getBlockDevices,
getCpus,
};
const assert = require('assert'),
@@ -28,11 +29,11 @@ async function reboot(req, res, next) {
await safe(system.reboot());
}
async function isRebootRequired(req, res, next) {
const [error, rebootRequired] = await safe(system.isRebootRequired());
async function getInfo(req, res, next) {
const [error, info] = await safe(system.getInfo());
if (error) return next(BoxError.toHttpError(error));
next(new HttpSuccess(200, { rebootRequired }));
next(new HttpSuccess(200, { info }));
}
async function getDisks(req, res, next) {
@@ -143,3 +144,10 @@ async function getBlockDevices(req, res, next) {
next(new HttpSuccess(200, { devices }));
}
async function getCpus(req, res, next) {
const [error, cpus] = await safe(system.getCpus());
if (error) return next(BoxError.toHttpError(error));
next(new HttpSuccess(200, { cpus }));
}

View File

@@ -106,7 +106,6 @@ describe('Appstore Cloudron Registration API - existing user', function () {
expect(scope2.isDone()).to.be.ok();
expect(scope3.isDone()).to.be.ok();
expect(await settings.get(settings.APPSTORE_API_TOKEN_KEY)).to.be('CLOUDRON_TOKEN');
expect(await settings.get(settings.APPSTORE_WEB_TOKEN_KEY)).to.be('SECRET_TOKEN');
nock.cleanAll();
});
@@ -154,7 +153,6 @@ describe('Appstore Cloudron Registration API - new user signup', function () {
expect(scope2.isDone()).to.be.ok();
expect(scope3.isDone()).to.be.ok();
expect(await settings.get(settings.APPSTORE_API_TOKEN_KEY)).to.be('CLOUDRON_TOKEN');
expect(await settings.get(settings.APPSTORE_WEB_TOKEN_KEY)).to.be('SECRET_TOKEN');
});
it('can get subscription', async function () {

View File

@@ -45,6 +45,7 @@ describe('Directory Server API', function () {
it('cannot set directory_server config without secret', async function () {
let tmp = JSON.parse(JSON.stringify(defaultConfig));
tmp.enabled = true;
delete tmp.secret;
const response = await superagent.post(`${serverUrl}/api/v1/directory_server/config`)
@@ -103,5 +104,18 @@ describe('Directory Server API', function () {
expect(response.statusCode).to.equal(200);
expect(response.body).to.eql({ enabled: true, secret: 'ldapsecret', allowlist: '1.2.3.4' });
});
// keep this last. this ensures directory server is stopped and the tests can exit
it('can disable directory_server config', async function () {
let tmp = JSON.parse(JSON.stringify(defaultConfig));
tmp.enabled = false;
tmp.secret = 'ldapsecret';
const response = await superagent.post(`${serverUrl}/api/v1/directory_server/config`)
.query({ access_token: owner.token })
.send(tmp);
expect(response.statusCode).to.equal(200);
});
});
});

View File

@@ -46,15 +46,8 @@ describe('OpenID connect clients API', function () {
.send(CLIENT_0);
expect(response.statusCode).to.equal(201);
});
it('create fails for already exists', async function () {
const response = await superagent.post(`${serverUrl}/api/v1/oidc/clients`)
.query({ access_token: owner.token })
.send(CLIENT_0)
.ok(() => true);
expect(response.statusCode).to.equal(409);
CLIENT_0.id = response.body.id;
CLIENT_0.secret = response.body.secret;
});
it('can create another client', async function () {
@@ -63,6 +56,8 @@ describe('OpenID connect clients API', function () {
.send(CLIENT_1);
expect(response.statusCode).to.equal(201);
CLIENT_1.id = response.body.id;
CLIENT_1.secret = response.body.secret;
});
it('cannot get non-existing client', async function () {
@@ -127,7 +122,7 @@ describe('OpenID connect clients API', function () {
expect(response.body.clients[1].id).to.eql(CLIENT_1.id);
});
it('cannot update client without secret', async function () {
it('cann update client', async function () {
const response = await superagent.post(`${serverUrl}/api/v1/oidc/clients/${CLIENT_0.id}`)
.query({ access_token: owner.token })
.send({ loginRedirectUri: CLIENT_0.loginRedirectUri })
@@ -139,7 +134,7 @@ describe('OpenID connect clients API', function () {
it('cannot update client without loginRedirectUri', async function () {
const response = await superagent.post(`${serverUrl}/api/v1/oidc/clients/${CLIENT_0.id}`)
.query({ access_token: owner.token })
.send({ secret: CLIENT_0.secret })
.send({})
.ok(() => true);
expect(response.statusCode).to.equal(400);

View File

@@ -83,34 +83,6 @@ describe('Support API', function () {
});
});
describe('config', function () {
it('normal user cannot get config', async function () {
const response = await superagent.get(`${serverUrl}/api/v1/support/config`)
.query({ access_token: user.token })
.ok(() => true);
expect(response.statusCode).to.equal(403);
});
it('admin also cannot get config', async function () {
const response = await superagent.get(`${serverUrl}/api/v1/support/config`)
.query({ access_token: admin.token })
.ok(() => true);
expect(response.statusCode).to.equal(403);
});
it('owner can get config', async function () {
const response = await superagent.get(`${serverUrl}/api/v1/support/config`)
.query({ access_token: owner.token });
expect(response.statusCode).to.equal(200);
expect(response.body.email).to.be('support@cloudron.io');
expect(response.body.remoteSupport).to.be(true);
expect(response.body.submitTickets).to.be(true);
});
});
describe('ticket', function () {
it('fails without token', async function () {
const response = await superagent.post(`${serverUrl}/api/v1/support/ticket`)

View File

@@ -21,6 +21,34 @@ describe('System', function () {
before(setup);
after(cleanup);
describe('cpus', function () {
it('succeeds', async function () {
const response = await superagent.get(`${serverUrl}/api/v1/system/cpus`)
.query({ access_token: owner.token });
expect(response.statusCode).to.equal(200);
expect(response.body.cpus).to.be.ok();
expect(response.body.cpus.every(c => typeof c.model === 'string')).to.be(true);
});
});
describe('info', function () {
it('succeeds', async function () {
const response = await superagent.get(`${serverUrl}/api/v1/system/info`)
.query({ access_token: owner.token });
expect(response.statusCode).to.equal(200);
expect(response.body.info).to.be.ok();
expect(response.body.info.sysVendor).to.be.a('string');
expect(response.body.info.productName).to.be.a('string');
expect(response.body.info.uptimeSecs).to.be.a('number');
expect(response.body.info.rebootRequired).to.be.a('boolean');
expect(response.body.info.activationTime).to.be.a('string');
});
});
describe('logs', function () {
before(function () {
console.log(paths.BOX_LOG_FILE);

View File

@@ -7,6 +7,7 @@
const common = require('./common.js'),
expect = require('expect.js'),
safe = require('safetydance'),
superagent = require('superagent');
describe('Volumes API', function () {
@@ -67,17 +68,13 @@ describe('Volumes API', function () {
expect(response.body.hostPath).to.be('/media/cloudron-test-music');
});
it('can update volume', async function () {
let response = await superagent.post(`${serverUrl}/api/v1/volumes/${volumeId}`)
it('cannot update volume', async function () {
let [error, response] = await safe(superagent.post(`${serverUrl}/api/v1/volumes/${volumeId}`)
.query({ access_token: owner.token })
.send({ mountOptions: { hostPath: '/media/cloudron-test-music-2' }});
expect(response.statusCode).to.equal(204);
.send({ mountOptions: { hostPath: '/media/cloudron-test-music-2' }})
.ok(() => true));
response = await superagent.get(`${serverUrl}/api/v1/volumes/${volumeId}`)
.query({ access_token: owner.token });
expect(response.statusCode).to.equal(200);
expect(response.body.id).to.be(volumeId);
expect(response.body.hostPath).to.be('/media/cloudron-test-music-2');
expect(response.statusCode).to.equal(400); // cannot update filesytem
});
it('can delete volume', async function () {

View File

@@ -28,7 +28,7 @@ mount_file="/etc/systemd/system/${mount_filename}"
# cleanup any previous mount of same name (after midway box crash?)
if systemctl -q is-active "${mount_filename}"; then
echo "Previous mount active, unmounting"
echo "Previous mount ${mount_filename} active, unmounting"
# unmounting can fail if a user is cd'ed into the directory. if we go ahead and mount anyway, systemd says "active" because it's referring to the previous mount config
if ! systemctl stop "${mount_filename}"; then
echo "Failed to unmount"
@@ -45,7 +45,7 @@ if ! timeout "${timeout}" systemctl enable --now "${mount_filename}"; then
exit 3
fi
echo "Mount succeeded"
echo "Mount ${mount_filename} succeeded"
# this has to be done post-mount because permissions come from the underlying mount file system and not the mount point
chmod 777 "${where}"

View File

@@ -2,6 +2,11 @@
set -eu
if [[ "${1:-}" == "--check" ]]; then
echo "OK"
exit 0
fi
args=$(getopt -o "" -l "follow,lines:" -n "$0" -- "$@")
eval set -- "${args}"

View File

@@ -15,6 +15,7 @@ fi
[[ "${BOX_ENV}" == "test" ]] && exit
ipset flush cloudron_blocklist
ipset flush cloudron_blocklist6 || true # because kernel may not have ipv6
user_firewall_json="/home/yellowtent/platformdata/firewall/blocklist.txt"

View File

@@ -110,10 +110,11 @@ async function initializeExpressSync() {
router.post('/api/v1/dashboard/location', json, token, authorizeAdmin, routes.dashboard.changeLocation);
// system (vm/server)
router.get ('/api/v1/system/reboot', token, authorizeAdmin, routes.system.isRebootRequired);
router.get ('/api/v1/system/info', token, authorizeAdmin, routes.system.getInfo);
router.post('/api/v1/system/reboot', json, token, authorizeAdmin, routes.system.reboot);
router.get ('/api/v1/system/graphs', token, authorizeAdmin, routes.system.getSystemGraphs);
router.get ('/api/v1/system/disks', token, authorizeAdmin, routes.system.getDisks);
router.get ('/api/v1/system/cpus', token, authorizeAdmin, routes.system.getCpus);
router.get ('/api/v1/system/disk_usage', token, authorizeAdmin, routes.system.getDiskUsage);
router.post('/api/v1/system/disk_usage', token, authorizeAdmin, routes.system.updateDiskUsage);
router.get ('/api/v1/system/block_devices', token, authorizeAdmin, routes.system.getBlockDevices);
@@ -219,8 +220,8 @@ async function initializeExpressSync() {
router.post('/api/v1/directory_server/config', json, token, authorizeAdmin, routes.directoryServer.setConfig);
// appstore and subscription routes
router.post('/api/v1/appstore/register_cloudron', json, token, authorizeOwner, routes.appstore.registerCloudron);
router.get ('/api/v1/appstore/web_token', json, token, authorizeOwner, routes.appstore.getWebToken);
router.post('/api/v1/appstore/register_cloudron', json, token, authorizeOwner, routes.appstore.registerCloudronWithLogin);
router.post('/api/v1/appstore/register_cloudron_with_setup_token', json, token, authorizeOwner, routes.appstore.registerCloudronWithSetupToken);
router.get ('/api/v1/appstore/subscription', token, authorizeUser, routes.appstore.getSubscription); // for all users
router.get ('/api/v1/appstore/apps', token, authorizeAdmin, routes.appstore.getApps);
router.get ('/api/v1/appstore/apps/:appstoreId', token, authorizeAdmin, routes.appstore.getApp);
@@ -293,12 +294,12 @@ async function initializeExpressSync() {
router.get ('/api/v1/applinks/:id/icon', token, authorizeUser, routes.applinks.getIcon);
// branding routes
router.get ('/api/v1/branding/cloudron_name', token, authorizeOwner, routes.branding.getCloudronName);
router.post('/api/v1/branding/cloudron_name', json, token, authorizeOwner, routes.branding.setCloudronName);
router.get ('/api/v1/branding/cloudron_avatar', token, authorizeOwner, routes.branding.getCloudronAvatar);
router.post('/api/v1/branding/cloudron_avatar', json, token, authorizeOwner, multipart, routes.branding.setCloudronAvatar);
router.get ('/api/v1/branding/footer', token, authorizeOwner, routes.branding.getFooter);
router.post('/api/v1/branding/footer', json, token, authorizeOwner, routes.branding.setFooter);
router.get ('/api/v1/branding/cloudron_name', token, authorizeAdmin, routes.branding.getCloudronName);
router.post('/api/v1/branding/cloudron_name', json, token, authorizeAdmin, routes.branding.setCloudronName);
router.get ('/api/v1/branding/cloudron_avatar', token, authorizeAdmin, routes.branding.getCloudronAvatar);
router.post('/api/v1/branding/cloudron_avatar', json, token, authorizeAdmin, multipart, routes.branding.setCloudronAvatar);
router.get ('/api/v1/branding/footer', token, authorizeAdmin, routes.branding.getFooter);
router.post('/api/v1/branding/footer', json, token, authorizeAdmin, routes.branding.setFooter);
// reverseproxy routes
router.post('/api/v1/reverseproxy/renew_certs', json, token, authorizeAdmin, routes.reverseProxy.renewCerts);
@@ -306,8 +307,8 @@ async function initializeExpressSync() {
router.post('/api/v1/reverseproxy/trusted_ips', json, token, authorizeAdmin, routes.reverseProxy.setTrustedIps);
// network routes
router.get ('/api/v1/network/blocklist', token, authorizeOwner, routes.network.getBlocklist);
router.post('/api/v1/network/blocklist', json, token, authorizeOwner, routes.network.setBlocklist);
router.get ('/api/v1/network/blocklist', token, authorizeAdmin, routes.network.getBlocklist);
router.post('/api/v1/network/blocklist', json, token, authorizeAdmin, routes.network.setBlocklist);
router.get ('/api/v1/network/dynamic_dns', token, authorizeAdmin, routes.network.getDynamicDns);
router.post('/api/v1/network/dynamic_dns', json, token, authorizeAdmin, routes.network.setDynamicDns);
router.get ('/api/v1/network/ipv4_config', token, authorizeAdmin, routes.network.getIPv4Config);
@@ -322,9 +323,9 @@ async function initializeExpressSync() {
router.post('/api/v1/docker/registry_config', json, token, authorizeAdmin, routes.docker.setRegistryConfig);
// email routes
router.get ('/api/v1/mailserver/eventlog', token, authorizeOwner, routes.mailserver.proxy);
router.post('/api/v1/mailserver/clear_eventlog', token, authorizeOwner, routes.mailserver.proxy);
router.use ('/api/v1/mailserver/files/*', token, authorizeOwner, routes.filemanager.proxy('mail'));
router.get ('/api/v1/mailserver/eventlog', token, authorizeAdmin, routes.mailserver.proxy);
router.post('/api/v1/mailserver/clear_eventlog', token, authorizeAdmin, routes.mailserver.proxy);
router.use ('/api/v1/mailserver/files/*', token, authorizeAdmin, routes.filemanager.proxy('mail'));
router.get ('/api/v1/mailserver/location', token, authorizeAdmin, routes.mailserver.getLocation);
router.post('/api/v1/mailserver/location', json, token, authorizeAdmin, routes.mailserver.setLocation);
router.get ('/api/v1/mailserver/max_email_size', token, authorizeAdmin, routes.mailserver.proxy);
@@ -368,10 +369,9 @@ async function initializeExpressSync() {
router.del ('/api/v1/mail/:domain/lists/:name', token, authorizeMailManager, routes.mail.delList);
// support routes
router.post('/api/v1/support/ticket', json, token, authorizeOwner, routes.support.canCreateTicket, routes.support.createTicket);
router.get ('/api/v1/support/config', token, authorizeOwner, routes.support.getConfig);
router.post('/api/v1/support/ticket', json, token, authorizeOwner, routes.support.createTicket);
router.get ('/api/v1/support/remote_support', token, authorizeOwner, routes.support.getRemoteSupport);
router.post('/api/v1/support/remote_support', json, token, authorizeOwner, routes.support.canEnableRemoteSupport, routes.support.enableRemoteSupport);
router.post('/api/v1/support/remote_support', json, token, authorizeOwner, routes.support.enableRemoteSupport);
// domain routes
router.post('/api/v1/domains', json, token, authorizeAdmin, routes.domains.add);

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