Compare commits
122 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fa842034ed | ||
|
|
672b472359 | ||
|
|
37ed87f9c1 | ||
|
|
25ba312636 | ||
|
|
340ea3fe9b | ||
|
|
d264f8b05c | ||
|
|
54672d9fce | ||
|
|
5ac9a7f1ef | ||
|
|
b906b0f7f2 | ||
|
|
758e1965f1 | ||
|
|
8ff437c4d2 | ||
|
|
4374124985 | ||
|
|
8b5afaa12c | ||
|
|
a54c6d3c32 | ||
|
|
93af9379bd | ||
|
|
39deb41e2e | ||
|
|
d7c0a947fb | ||
|
|
09b438850e | ||
|
|
cbefd4195f | ||
|
|
849c8bf6ac | ||
|
|
00268b1da9 | ||
|
|
5f5e6084d7 | ||
|
|
852c4d1300 | ||
|
|
81fe6f884b | ||
|
|
9780e4184e | ||
|
|
1af1660312 | ||
|
|
1206f5dc88 | ||
|
|
793c4ac017 | ||
|
|
620e3af525 | ||
|
|
c7b2e15d16 | ||
|
|
48f0c75c57 | ||
|
|
93d3b24300 | ||
|
|
21f830eb8c | ||
|
|
c195cb00c0 | ||
|
|
f7a53e1b15 | ||
|
|
759f3f29f0 | ||
|
|
be35926fd1 | ||
|
|
45fd046b9b | ||
|
|
2b8d0f60e7 | ||
|
|
0e0199fc94 | ||
|
|
7a730c445b | ||
|
|
4d29592450 | ||
|
|
44be454a1e | ||
|
|
cbf1b47332 | ||
|
|
eb64bd296a | ||
|
|
72083f59cd | ||
|
|
8a20b603f5 | ||
|
|
d45c433bc7 | ||
|
|
470417fcbe | ||
|
|
8e28d2a5aa | ||
|
|
344578006c | ||
|
|
e19fd5cf17 | ||
|
|
943325baa3 | ||
|
|
702de2557e | ||
|
|
159f3419a5 | ||
|
|
b1fb3bccd8 | ||
|
|
8927634636 | ||
|
|
b9e584752b | ||
|
|
5857c05e01 | ||
|
|
81eb4bdebb | ||
|
|
da18427125 | ||
|
|
df0b4ace5e | ||
|
|
5971d3bf77 | ||
|
|
cca3138f05 | ||
|
|
242c091add | ||
|
|
6f0788c9e4 | ||
|
|
15132a30da | ||
|
|
3245370280 | ||
|
|
740c0fe318 | ||
|
|
8d20ca2053 | ||
|
|
cdd8e34cfc | ||
|
|
a056bcfdfe | ||
|
|
b5065a381f | ||
|
|
56324e3e8e | ||
|
|
e64182d791 | ||
|
|
573eaee287 | ||
|
|
771bfd0244 | ||
|
|
2db96a5242 | ||
|
|
8459d231c2 | ||
|
|
efd42b7293 | ||
|
|
fe1c483b78 | ||
|
|
bf381aff7f | ||
|
|
1a43c05d48 | ||
|
|
804a3f8adb | ||
|
|
1122137d12 | ||
|
|
b88afbac4e | ||
|
|
8e468788a9 | ||
|
|
7f9e5303be | ||
|
|
08c48df862 | ||
|
|
1bc3875519 | ||
|
|
c69cf4731a | ||
|
|
4ad5bd71f1 | ||
|
|
1ddc1cec20 | ||
|
|
934c701be2 | ||
|
|
fadd4165df | ||
|
|
538454b11b | ||
|
|
e4464afd56 | ||
|
|
eb1f3d8b55 | ||
|
|
e7208278fc | ||
|
|
e87370354b | ||
|
|
fc3bd3a0fe | ||
|
|
2270f5789a | ||
|
|
7ef20c273e | ||
|
|
39942dc5b0 | ||
|
|
37a6e60e90 | ||
|
|
1f8c55f536 | ||
|
|
36c4772b17 | ||
|
|
47d7536e24 | ||
|
|
9d9a407c3d | ||
|
|
7d731d7600 | ||
|
|
dd9db22e9c | ||
|
|
6830c4fc67 | ||
|
|
2f3fba346f | ||
|
|
5bae308cae | ||
|
|
ed71f9ac68 | ||
|
|
5e7bc78d35 | ||
|
|
41319bc817 | ||
|
|
ceb908bee7 | ||
|
|
0e195679bf | ||
|
|
9c78b2df9a | ||
|
|
4844f6d927 | ||
|
|
64381e2a04 |
26
CHANGES
26
CHANGES
@@ -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
5
box.js
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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'">
|
||||
|
||||
@@ -1088,6 +1088,10 @@ multiselect {
|
||||
max-width: 970px;
|
||||
}
|
||||
|
||||
.card-expand {
|
||||
max-width: initial;
|
||||
}
|
||||
|
||||
.text-success {
|
||||
color: #5CB85C;
|
||||
}
|
||||
|
||||
@@ -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 }}?",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Английский",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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();
|
||||
}]);
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 = '';
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
// });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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">& {{ 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 }};"> </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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
}]);
|
||||
|
||||
684
frontend/package-lock.json
generated
684
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
})
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -19,7 +19,6 @@ a {
|
||||
|
||||
a:hover, a:focus {
|
||||
color: #0a6ebd;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.shadow {
|
||||
|
||||
@@ -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
555
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
43
package.json
43
package.json
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)}`);
|
||||
|
||||
@@ -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,
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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
263
src/dns/dnsimple.js
Normal 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;
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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
234
src/dns/ovh.js
Normal 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;
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 + '' }; });
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
};
|
||||
|
||||
@@ -635,6 +635,8 @@ async function maybeRootDSE(req, res, next) {
|
||||
}
|
||||
|
||||
async function start() {
|
||||
assert(gServer === null, 'Already started');
|
||||
|
||||
const logger = {
|
||||
trace: NOOP,
|
||||
debug: NOOP,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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, {}));
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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 }));
|
||||
}
|
||||
|
||||
@@ -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 () {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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`)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 () {
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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}"
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user