Browse Source

Merge pull request #654 from jc21/develop

2.6.0 Release
jc21 5 years ago
parent
commit
e3399e1035
38 changed files with 1214 additions and 232 deletions
  1. 1 1
      .version
  2. 1 0
      Jenkinsfile
  3. 21 1
      README.md
  4. 1 1
      backend/app.js
  5. 1 1
      backend/config/sqlite-test-db.json
  6. 3 1
      backend/internal/access-list.js
  7. 91 30
      backend/internal/certificate.js
  8. 41 0
      backend/migrations/20201014143841_pass_auth.js
  9. 4 0
      backend/models/access_list.js
  10. 2 0
      backend/routes/api/nginx/certificates.js
  11. 9 0
      backend/schema/endpoints/access-lists.json
  12. 14 2
      backend/schema/endpoints/certificates.json
  13. 2 0
      backend/templates/proxy_host.conf
  14. 3 2
      docker/Dockerfile
  15. 2 2
      docker/dev/Dockerfile
  16. 12 1
      docker/docker-compose.dev.yml
  17. 3 0
      docker/rootfs/etc/nginx/conf.d/dev.conf
  18. 3 0
      docker/rootfs/etc/nginx/conf.d/production.conf
  19. 1 1
      docker/rootfs/etc/services.d/manager/run
  20. 1 1
      docs/package.json
  21. 4 4
      docs/yarn.lock
  22. 5 4
      frontend/js/app/api.js
  23. 10 0
      frontend/js/app/nginx/access/form.ejs
  24. 1 0
      frontend/js/app/nginx/access/form.js
  25. 94 11
      frontend/js/app/nginx/certificates/form.ejs
  26. 80 29
      frontend/js/app/nginx/certificates/form.js
  27. 1 1
      frontend/js/app/nginx/certificates/list/item.ejs
  28. 7 5
      frontend/js/app/nginx/certificates/list/item.js
  29. 84 8
      frontend/js/app/nginx/dead/form.ejs
  30. 91 34
      frontend/js/app/nginx/dead/form.js
  31. 84 8
      frontend/js/app/nginx/proxy/form.ejs
  32. 92 35
      frontend/js/app/nginx/proxy/form.js
  33. 84 8
      frontend/js/app/nginx/redirection/form.ejs
  34. 95 37
      frontend/js/app/nginx/redirection/form.js
  35. 13 2
      frontend/js/i18n/messages.json
  36. 251 0
      global/certbot-dns-plugins.js
  37. 1 1
      scripts/frontend-build
  38. 1 1
      scripts/test-dev

+ 1 - 1
.version

@@ -1 +1 @@
-2.5.0
+2.6.0

+ 1 - 0
Jenkinsfile

@@ -65,6 +65,7 @@ pipeline {
 				// See: https://github.com/yarnpkg/yarn/issues/3254
 				sh '''docker run --rm \\
 					-v "$(pwd)/backend:/app" \\
+					-v "$(pwd)/global:/app/global" \\
 					-w /app \\
 					node:latest \\
 					sh -c "yarn install && yarn eslint . && rm -rf node_modules"

+ 21 - 1
README.md

@@ -1,7 +1,7 @@
 <p align="center">
 	<img src="https://nginxproxymanager.com/github.png">
 	<br><br>
-	<img src="https://img.shields.io/badge/version-2.5.0-green.svg?style=for-the-badge">
+	<img src="https://img.shields.io/badge/version-2.6.0-green.svg?style=for-the-badge">
 	<a href="https://hub.docker.com/repository/docker/jc21/nginx-proxy-manager">
 		<img src="https://img.shields.io/docker/stars/jc21/nginx-proxy-manager.svg?style=for-the-badge">
 	</a>
@@ -185,6 +185,26 @@ Special thanks to the following contributors:
 				<br /><sub><b>Jaap-Jan de Wit</b></sub>
 			</a>
 		</td>
+		<td align="center">
+			<a href="https://github.com/jmwebslave">
+				<img src="https://avatars2.githubusercontent.com/u/6118262?s=460&u=7db409c47135b1e141c366bbb03ed9fae6ac2638&v=4" width="80px;" alt=""/>
+				<br /><sub><b>James Morgan</b></sub>
+			</a>
+		</td>
+	</tr>
+	<tr>
+		<td align="center">
+			<a href="https://github.com/chaptergy">
+				<img src="https://avatars2.githubusercontent.com/u/26956711?s=460&u=7d9adebabb6b4e7af7cb05d98d751087a372304b&v=4" width="80px;" alt=""/>
+				<br /><sub><b>chaptergy</b></sub>
+			</a>
+		</td>
+		<td align="center">
+			<a href="https://github.com/Philip-Mooney">
+				<img src="https://avatars0.githubusercontent.com/u/48624631?s=460&v=4" width="80px;" alt=""/>
+				<br /><sub><b>Philip Mooney</b></sub>
+			</a>
+		</td>
 	</tr>
 </table>
 <!-- markdownlint-enable -->

+ 1 - 1
backend/app.js

@@ -66,7 +66,7 @@ app.use(function (err, req, res, next) {
 		}
 	};
 
-	if (process.env.NODE_ENV === 'development') {
+	if (process.env.NODE_ENV === 'development' || (req.baseUrl + req.path).includes('nginx/certificates')) {
 		payload.debug = {
 			stack:    typeof err.stack !== 'undefined' && err.stack ? err.stack.split('\n') : null,
 			previous: err.previous

+ 1 - 1
backend/config/sqlite-test-db.json

@@ -4,7 +4,7 @@
       "knex": {
         "client": "sqlite3",
         "connection": {
-          "filename": "/app/backend/config/mydb.sqlite"
+          "filename": "/app/config/mydb.sqlite"
         },
         "pool": {
           "min": 0,

+ 3 - 1
backend/internal/access-list.js

@@ -31,6 +31,7 @@ const internalAccessList = {
 					.insertAndFetch({
 						name:          data.name,
 						satisfy_any:   data.satisfy_any,
+						pass_auth:     data.pass_auth,
 						owner_user_id: access.token.getUserId(1)
 					});
 			})
@@ -128,6 +129,7 @@ const internalAccessList = {
 						.patch({
 							name:        data.name,
 							satisfy_any: data.satisfy_any,
+							pass_auth:   data.pass_auth,
 						});
 				}
 			})
@@ -384,7 +386,7 @@ const internalAccessList = {
 					.orderBy('access_list.name', 'ASC');
 
 				if (access_data.permission_visibility !== 'all') {
-					query.andWhere('owner_user_id', access.token.getUserId(1));
+					query.andWhere('access_list.owner_user_id', access.token.getUserId(1));
 				}
 
 				// Query is used for searching

+ 91 - 30
backend/internal/certificate.js

@@ -13,6 +13,7 @@ const internalNginx    = require('./nginx');
 const internalHost     = require('./host');
 const certbot_command  = '/usr/bin/certbot';
 const le_config        = '/etc/letsencrypt.ini';
+const dns_plugins      = require('../global/certbot-dns-plugins');
 
 function omissions() {
 	return ['is_deleted'];
@@ -141,11 +142,11 @@ const internalCertificate = {
 								});
 						})
 						.then((in_use_result) => {
-							// Is CloudFlare, no config needed, so skip 3 and 5.
-							if (data.meta.cloudflare_use) {
+							// With DNS challenge no config is needed, so skip 3 and 5.
+							if (certificate.meta.dns_challenge) {
 								return internalNginx.reload().then(() => {
 									// 4. Request cert
-									return internalCertificate.requestLetsEncryptCloudFlareDnsSsl(certificate, data.meta.cloudflare_token);
+									return internalCertificate.requestLetsEncryptSslWithDnsChallenge(certificate);
 								})
 									.then(internalNginx.reload)
 									.then(() => {
@@ -772,35 +773,70 @@ const internalCertificate = {
 	},
 
 	/**
-	 * @param   {Object}  certificate   the certificate row
-	 * @param	{String} apiToken		the cloudflare api token
+	 * @param   {Object}  certificate   			the certificate row
+	 * @param		{String} 	dns_provider				the dns provider name (key used in `certbot-dns-plugins.js`)
+	 * @param		{String | null} 	credentials	the content of this providers credentials file
+	 * @param		{String} 	propagation_seconds	the cloudflare api token
 	 * @returns {Promise}
 	 */
-	requestLetsEncryptCloudFlareDnsSsl: (certificate, apiToken) => {
-		logger.info('Requesting Let\'sEncrypt certificates via Cloudflare DNS for Cert #' + certificate.id + ': ' + certificate.domain_names.join(', '));
+	requestLetsEncryptSslWithDnsChallenge: (certificate) => {
+		const dns_plugin = dns_plugins[certificate.meta.dns_provider];
+
+		if (!dns_plugin) {
+			throw Error(`Unknown DNS provider '${certificate.meta.dns_provider}'`);
+		}
+
+		logger.info(`Requesting Let'sEncrypt certificates via ${dns_plugin.display_name} for Cert #${certificate.id}: ${certificate.domain_names.join(', ')}`);
+
+		const credentials_loc = '/etc/letsencrypt/credentials-' + certificate.id;
+		const credentials_cmd = 'echo \'' + certificate.meta.dns_provider_credentials.replace('\'', '\\\'') + '\' > \'' + credentials_loc + '\' && chmod 600 \'' + credentials_loc + '\'';
+		const prepare_cmd     = 'pip3 install ' + dns_plugin.package_name + '==' + dns_plugin.package_version;
 
-		let tokenLoc = '~/cloudflare-token';
-		let storeKey = 'echo "dns_cloudflare_api_token = ' + apiToken + '" > ' + tokenLoc;	
+		// Whether the plugin has a --<name>-credentials argument
+		const has_config_arg = certificate.meta.dns_provider !== 'route53';
 
-		let cmd = 
-			storeKey + ' && ' +
+		let main_cmd = 
 			certbot_command + ' certonly --non-interactive ' +
 			'--cert-name "npm-' + certificate.id + '" ' +
 			'--agree-tos ' +
 			'--email "' + certificate.meta.letsencrypt_email + '" ' +			
 			'--domains "' + certificate.domain_names.join(',') + '" ' +
-			'--dns-cloudflare --dns-cloudflare-credentials ' + tokenLoc +
-			(le_staging ? ' --staging' : '')
-			+ ' && rm ' + tokenLoc;
+			'--authenticator ' + dns_plugin.full_plugin_name + ' ' +
+			(
+				has_config_arg 
+					? '--' + dns_plugin.full_plugin_name + '-credentials "' + credentials_loc + '"' 
+					: ''
+			) +
+			(
+				certificate.meta.propagation_seconds !== undefined 
+					? ' --' + dns_plugin.full_plugin_name + '-propagation-seconds ' + certificate.meta.propagation_seconds 
+					: ''
+			) +
+			(le_staging ? ' --staging' : '');
+
+		// Prepend the path to the credentials file as an environment variable
+		if (certificate.meta.dns_provider === 'route53') {
+			main_cmd = 'AWS_CONFIG_FILE=\'' + credentials_loc + '\' ' + main_cmd;
+		}
+		
+		const teardown_cmd = `rm '${credentials_loc}'`;
 
 		if (debug_mode) {
-			logger.info('Command:', cmd);
+			logger.info('Command:', `${credentials_cmd} && ${prepare_cmd} && ${main_cmd} && ${teardown_cmd}`);
 		}
 
-		return utils.exec(cmd).then((result) => {
-			logger.info(result);
-			return result;
-		});
+		return utils.exec(credentials_cmd)
+			.then(() => {
+				return utils.exec(prepare_cmd)
+					.then(() => {
+						return utils.exec(main_cmd)
+							.then(async (result) => {
+								await utils.exec(teardown_cmd);
+								logger.info(result);
+								return result;
+							});
+					});
+			});
 	},
 
 
@@ -817,7 +853,7 @@ const internalCertificate = {
 			})
 			.then((certificate) => {
 				if (certificate.provider === 'letsencrypt') {
-					let renewMethod = certificate.meta.cloudflare_use ? internalCertificate.renewLetsEncryptCloudFlareSsl : internalCertificate.renewLetsEncryptSsl;		
+					let renewMethod = certificate.meta.dns_challenge ? internalCertificate.renewLetsEncryptSslWithDnsChallenge : internalCertificate.renewLetsEncryptSsl;		
 
 					return renewMethod(certificate)
 						.then(() => {
@@ -877,22 +913,47 @@ const internalCertificate = {
 	 * @param   {Object}  certificate   the certificate row
 	 * @returns {Promise}
 	 */
-	renewLetsEncryptCloudFlareSsl: (certificate) => {
-		logger.info('Renewing Let\'sEncrypt certificates for Cert #' + certificate.id + ': ' + certificate.domain_names.join(', '));
+	renewLetsEncryptSslWithDnsChallenge: (certificate) => {
+		const dns_plugin = dns_plugins[certificate.meta.dns_provider];
 
-		let cmd = certbot_command + ' renew --non-interactive ' +
+		if (!dns_plugin) {
+			throw Error(`Unknown DNS provider '${certificate.meta.dns_provider}'`);
+		}
+
+		logger.info(`Renewing Let'sEncrypt certificates via ${dns_plugin.display_name} for Cert #${certificate.id}: ${certificate.domain_names.join(', ')}`);
+
+		const credentials_loc = '/etc/letsencrypt/credentials-' + certificate.id;
+		const credentials_cmd = 'echo \'' + certificate.meta.dns_provider_credentials.replace('\'', '\\\'') + '\' > \'' + credentials_loc + '\' && chmod 600 \'' + credentials_loc + '\'';
+		const prepare_cmd     = 'pip3 install ' + dns_plugin.package_name + '==' + dns_plugin.package_version;
+
+		let main_cmd = 
+			certbot_command + ' renew --non-interactive ' +
 			'--cert-name "npm-' + certificate.id + '" ' +
-			'--disable-hook-validation ' +
-			(le_staging ? '--staging' : '');
+			'--disable-hook-validation' +
+			(le_staging ? ' --staging' : '');
+
+		// Prepend the path to the credentials file as an environment variable
+		if (certificate.meta.dns_provider === 'route53') {
+			main_cmd = 'AWS_CONFIG_FILE=\'' + credentials_loc + '\' ' + main_cmd;
+		}
+
+		const teardown_cmd = `rm '${credentials_loc}'`;
 
 		if (debug_mode) {
-			logger.info('Command:', cmd);
+			logger.info('Command:', `${credentials_cmd} && ${prepare_cmd} && ${main_cmd} && ${teardown_cmd}`);
 		}
 
-		return utils.exec(cmd)
-			.then((result) => {
-				logger.info(result);
-				return result;
+		return utils.exec(credentials_cmd)
+			.then(() => {
+				return utils.exec(prepare_cmd)
+					.then(() => {
+						return utils.exec(main_cmd)
+							.then(async (result) => {
+								await utils.exec(teardown_cmd);
+								logger.info(result);
+								return result;
+							});
+					});
 			});
 	},
 

+ 41 - 0
backend/migrations/20201014143841_pass_auth.js

@@ -0,0 +1,41 @@
+const migrate_name = 'pass_auth';
+const logger       = require('../logger').migrate;
+
+/**
+ * Migrate
+ *
+ * @see http://knexjs.org/#Schema
+ *
+ * @param   {Object}  knex
+ * @param   {Promise} Promise
+ * @returns {Promise}
+ */
+exports.up = function (knex/*, Promise*/) {
+
+	logger.info('[' + migrate_name + '] Migrating Up...');
+
+	return knex.schema.table('access_list', function (access_list) {
+		access_list.integer('pass_auth').notNull().defaultTo(1);
+	})
+		.then(() => {
+			logger.info('[' + migrate_name + '] access_list Table altered');
+		});
+};
+
+/**
+ * Undo Migrate
+ *
+ * @param {Object} knex
+ * @param {Promise} Promise
+ * @returns {Promise}
+ */
+exports.down = function (knex/*, Promise*/) {
+	logger.info('[' + migrate_name + '] Migrating Down...');
+
+	return knex.schema.table('access_list', function (access_list) {
+		access_list.dropColumn('pass_auth');
+	})
+		.then(() => {
+			logger.info('[' + migrate_name + '] access_list pass_auth Column dropped');
+		});
+};

+ 4 - 0
backend/models/access_list.js

@@ -93,6 +93,10 @@ class AccessList extends Model {
 	get satisfy() {
 		return this.satisfy_any ? 'satisfy any' : 'satisfy all';
 	}
+
+	get passauth() {
+		return this.pass_auth ? '' : 'proxy_set_header Authorization "";';
+	}
 }
 
 module.exports = AccessList;

+ 2 - 0
backend/routes/api/nginx/certificates.js

@@ -58,6 +58,7 @@ router
 	.post((req, res, next) => {
 		apiValidator({$ref: 'endpoints/certificates#/links/1/schema'}, req.body)
 			.then((payload) => {
+				req.setTimeout(900000); // 15 minutes timeout
 				return internalCertificate.create(res.locals.access, payload);
 			})
 			.then((result) => {
@@ -197,6 +198,7 @@ router
 	 * Renew certificate
 	 */
 	.post((req, res, next) => {
+		req.setTimeout(900000); // 15 minutes timeout
 		internalCertificate.renew(res.locals.access, {
 			id: parseInt(req.params.certificate_id, 10)
 		})

+ 9 - 0
backend/schema/endpoints/access-lists.json

@@ -42,6 +42,9 @@
 		"satisfy_any": {
 			"type": "boolean"
 		},
+		"pass_auth": {
+			"type": "boolean"
+		},
 		"meta": {
 			"type": "object"
 		}
@@ -102,6 +105,9 @@
 					"satisfy_any": {
 						"$ref": "#/definitions/satisfy_any"
 					},
+					"pass_auth": {
+						"$ref": "#/definitions/pass_auth"
+					},
 					"items": {
 						"type": "array",
 						"minItems": 0,
@@ -167,6 +173,9 @@
 					"satisfy_any": {
 						"$ref": "#/definitions/satisfy_any"
 					},
+					"pass_auth": {
+						"$ref": "#/definitions/pass_auth"
+					},
 					"items": {
 						"type": "array",
 						"minItems": 0,

+ 14 - 2
backend/schema/endpoints/certificates.json

@@ -42,11 +42,23 @@
         "letsencrypt_agree": {
           "type": "boolean"
         },
-        "cloudflare_use": {
+        "dns_challenge": {
           "type": "boolean"
         },
-        "cloudflare_token": {
+        "dns_provider": {
           "type": "string"
+        },
+        "dns_provider_credentials": {
+          "type": "string"
+        },
+        "propagation_seconds": {
+          "anyOf": [
+            { 
+              "type": "integer",
+              "minimum": 0 
+            }
+          ]
+          
         }
       }
     }

+ 2 - 0
backend/templates/proxy_host.conf

@@ -27,6 +27,8 @@ server {
     # Authorization
     auth_basic            "Authorization required";
     auth_basic_user_file  /data/access/{{ access_list_id }};
+
+    {{ access_list.passauth }}
     {% endif %}
 
     # Access Rules

+ 3 - 2
docker/Dockerfile

@@ -17,8 +17,8 @@ ENV NODE_ENV=production
 
 RUN echo "fs.file-max = 65535" > /etc/sysctl.conf \
 	&& apk update \
-	&& apk add python2 py-pip certbot jq \
-	&& pip install certbot-dns-cloudflare \
+	&& apk add python3 certbot jq \
+	&& python3 -m ensurepip \
 	&& rm -rf /var/cache/apk/*
 
 ENV NPM_BUILD_VERSION="${BUILD_VERSION}" NPM_BUILD_COMMIT="${BUILD_COMMIT}" NPM_BUILD_DATE="${BUILD_DATE}"
@@ -34,6 +34,7 @@ EXPOSE 443
 COPY docker/rootfs      /
 ADD backend             /app
 ADD frontend/dist       /app/frontend
+COPY global							/app/global
 
 WORKDIR /app
 RUN yarn install

+ 2 - 2
docker/dev/Dockerfile

@@ -7,8 +7,8 @@ ENV S6_FIX_ATTRS_HIDDEN=1
 
 RUN echo "fs.file-max = 65535" > /etc/sysctl.conf \
 	&& apk update \
-	&& apk add python2 py-pip certbot jq \
-	&& pip install certbot-dns-cloudflare \
+	&& apk add python3 certbot jq \
+	&& python3 -m ensurepip \
 	&& rm -rf /var/cache/apk/*
 
 # Task

+ 12 - 1
docker/docker-compose.dev.yml

@@ -11,6 +11,8 @@ services:
       - 3080:80
       - 3081:81
       - 3443:443
+    networks:
+      - nginx_proxy_manager
     environment:
       - NODE_ENV=development
       - FORCE_COLOR=1
@@ -19,13 +21,17 @@ services:
     volumes:
       - npm_data:/data
       - le_data:/etc/letsencrypt
-      - ..:/app
+      - ../backend:/app
+      - ../frontend:/app/frontend
+      - ../global:/app/global
     depends_on:
       - db
     working_dir: /app
 
   db:
     image: jc21/mariadb-aria
+    networks:
+      - nginx_proxy_manager
     environment:
       MYSQL_ROOT_PASSWORD: "npm"
       MYSQL_DATABASE: "npm"
@@ -38,6 +44,8 @@ services:
     image: 'swaggerapi/swagger-ui:latest'
     ports:
       - 3001:80
+    networks:
+      - nginx_proxy_manager
     environment:
       URL: "http://127.0.0.1:3081/api/schema"
       PORT: '80'
@@ -48,3 +56,6 @@ volumes:
   npm_data:
   le_data:
   db_data:
+
+networks:
+  nginx_proxy_manager:

+ 3 - 0
docker/rootfs/etc/nginx/conf.d/dev.conf

@@ -17,6 +17,9 @@ server {
 		proxy_set_header      X-Forwarded-Proto  $scheme;
 		proxy_set_header      X-Forwarded-For    $remote_addr;
 		proxy_pass            http://127.0.0.1:3000/;
+
+		proxy_read_timeout 15m;
+		proxy_send_timeout 15m;
 	}
 
 	location / {

+ 3 - 0
docker/rootfs/etc/nginx/conf.d/production.conf

@@ -18,6 +18,9 @@ server {
 		proxy_set_header      X-Forwarded-Proto  $scheme;
 		proxy_set_header      X-Forwarded-For    $remote_addr;
 		proxy_pass            http://127.0.0.1:3000/;
+
+		proxy_read_timeout 15m;
+		proxy_send_timeout 15m;
 	}
 
 	location / {

+ 1 - 1
docker/rootfs/etc/services.d/manager/run

@@ -5,7 +5,7 @@ mkdir -p /data/letsencrypt-acme-challenge
 cd /app || echo
 
 if [ "$DEVELOPMENT" == "true" ]; then
-	cd /app/backend || exit 1
+	cd /app || exit 1
 	yarn install
 	node --max_old_space_size=250 --abort_on_uncaught_exception node_modules/nodemon/bin/nodemon.js
 else

+ 1 - 1
docs/package.json

@@ -434,7 +434,7 @@
     "neo-async": "^2.6.2",
     "nice-try": "^2.0.1",
     "no-case": "^3.0.3",
-    "node-forge": "^0.9.1",
+    "node-forge": "^0.10.0",
     "node-libs-browser": "^2.2.1",
     "node-releases": "^1.1.60",
     "nopt": "^4.0.3",

+ 4 - 4
docs/yarn.lock

@@ -6584,10 +6584,10 @@ [email protected]:
   resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-0.9.0.tgz#d624050edbb44874adca12bb9a52ec63cb782579"
   integrity sha512-7ASaDa3pD+lJ3WvXFsxekJQelBKRpne+GOVbLbtHYdd7pFspyeuJHnWfLplGf3SwKGbfs/aYl5V/JCIaHVUKKQ==
 
-node-forge@^0.9.1:
-  version "0.9.1"
-  resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-0.9.1.tgz#775368e6846558ab6676858a4d8c6e8d16c677b5"
-  integrity sha512-G6RlQt5Sb4GMBzXvhfkeFmbqR6MzhtnT7VTHuLadjkii3rdYHNdw0m8zA4BTxVIh68FicCQ2NSUANpsqkr9jvQ==
+node-forge@^0.10.0:
+  version "0.10.0"
+  resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-0.10.0.tgz#32dea2afb3e9926f02ee5ce8794902691a676bf3"
+  integrity sha512-PPmu8eEeG9saEUvI97fm4OYxXVB6bFvyNTyiUOBichBpFG8A1Ljw3bY62+5oOjDEMHRnd0Y7HQ+x7uzxOzC6JA==
 
 node-libs-browser@^2.2.1:
   version "2.2.1"

+ 5 - 4
frontend/js/app/api.js

@@ -53,7 +53,7 @@ function fetch(verb, path, data, options) {
             contentType: options.contentType || 'application/json; charset=UTF-8',
             processData: options.processData || true,
             crossDomain: true,
-            timeout:     options.timeout ? options.timeout : 30000,
+            timeout:     options.timeout ? options.timeout : 180000,
             xhrFields:   {
                 withCredentials: true
             },
@@ -587,7 +587,8 @@ module.exports = {
              * @param {Object}  data
              */
             create: function (data) {
-                return fetch('post', 'nginx/certificates', data);
+                const timeout = 180000 + (data.meta.propagation_seconds ? Number(data.meta.propagation_seconds) * 1000 : 0);
+                return fetch('post', 'nginx/certificates', data, {timeout});
             },
 
             /**
@@ -630,8 +631,8 @@ module.exports = {
              * @param   {Number}  id
              * @returns {Promise}
              */
-            renew: function (id) {
-                return fetch('post', 'nginx/certificates/' + id + '/renew');
+            renew: function (id, timeout = 180000) {
+                return fetch('post', 'nginx/certificates/' + id + '/renew', undefined, {timeout});
             }
         }
     },

+ 10 - 0
frontend/js/app/nginx/access/form.ejs

@@ -31,6 +31,16 @@
                                 </label>
                             </div>
                         </div>
+
+                        <div class="col-sm-6 col-md-6">
+                            <div class="form-group">
+                                <label class="custom-switch">
+                                    <input type="checkbox" class="custom-switch-input" name="pass_auth" value="1"<%- typeof pass_auth !== 'undefined' && pass_auth ? ' checked' : '' %>>
+                                    <span class="custom-switch-indicator"></span>
+                                    <span class="custom-switch-description"><%- i18n('access-lists', 'pass-auth') %></span>
+                                </label>
+                            </div>
+                        </div>
                     </div>
                 </div>
 

+ 1 - 0
frontend/js/app/nginx/access/form.js

@@ -73,6 +73,7 @@ module.exports = Mn.View.extend({
             let data = {
                 name:       form_data.name,
                 satisfy_any: !!form_data.satisfy_any,
+                pass_auth: !!form_data.pass_auth,
                 items:      items_data,
                 clients:    clients_data
             };

+ 94 - 11
frontend/js/app/nginx/certificates/form.ejs

@@ -1,12 +1,20 @@
 <div class="modal-content">
     <div class="modal-header">
         <h5 class="modal-title"><%- i18n('certificates', 'form-title', {provider: provider}) %></h5>
-        <button type="button" class="close cancel" aria-label="Close" data-dismiss="modal">&nbsp;</button>
+        <button type="button" class="close cancel non-loader-content" aria-label="Close" data-dismiss="modal">&nbsp;</button>
     </div>
     <div class="modal-body">
-        <form>
+        <div class="text-center loader-content">
+            <div class="loader mx-auto my-6"></div>
+            <p><%- i18n('ssl', 'obtaining-certificate-info') %></p>
+        </div>
+        <form class="non-loader-content">
             <div class="row">
                 <% if (provider === 'letsencrypt') { %>
+                    <div class="col-sm-12 col-md-12">
+                        <div class="alert alert-danger" id="le-error-info" role="alert"></div>
+                    </div>
+
                     <div class="col-sm-12 col-md-12">
                         <div class="form-group">
                             <label class="form-label"><%- i18n('all-hosts', 'domain-names') %> <span class="form-required">*</span></label>
@@ -21,21 +29,96 @@
                         </div>
                     </div>
 
-                    <!-- CloudFlare -->
+                    <!-- DNS challenge -->
                     <div class="col-sm-12 col-md-12">
                         <div class="form-group">
                             <label class="custom-switch">
-                                <input type="checkbox" class="custom-switch-input" name="meta[cloudflare_use]" value="1">
+                                <input 
+                                    type="checkbox" 
+                                    class="custom-switch-input" 
+                                    name="meta[dns_challenge]" 
+                                    value="1" 
+                                    <%- getUseDnsChallenge() ? 'checked' : '' %>
+                                >
                                 <span class="custom-switch-indicator"></span>
-                                <span class="custom-switch-description"><%= i18n('ssl', 'use-cloudflare') %></span>
+                                <span class="custom-switch-description"><%= i18n('ssl', 'dns-challenge') %></span>
                             </label>
                         </div>
                     </div>
-                    <div class="col-sm-12 col-md-12 cloudflare">
-                        <div class="form-group">
-                            <label class="form-label">CloudFlare DNS API Token  <span class="form-required">*</span></label>
-                            <input type="text" name="meta[cloudflare_token]" class="form-control" id="cloudflare_token">
-                        </div>
+                    <div class="col-sm-12 col-md-12">
+                        <fieldset class="form-fieldset dns-challenge">
+                            <div class="text-red mb-4"><i class="fe fe-alert-triangle"></i> <%= i18n('ssl', 'certbot-warning') %></div>
+
+                            <!-- Certbot DNS plugin selection -->
+                            <div class="row">
+                                <div class="col-sm-12 col-md-12">
+                                    <div class="form-group">
+                                        <label class="form-label"><%- i18n('ssl', 'dns-provider') %> <span class="form-required">*</span></label>
+                                        <select 
+                                            name="meta[dns_provider]" 
+                                            id="dns_provider"
+                                            class="form-control custom-select"
+                                        >
+                                            <option 
+                                                value="" 
+                                                disabled 
+                                                hidden
+                                                <%- getDnsProvider() === null ? 'selected' : '' %>
+                                            >Please Choose...</option>
+                                            <% _.each(dns_plugins, function(plugin_info, plugin_name){ %>
+                                            <option 
+                                                value="<%- plugin_name %>"
+                                                <%- getDnsProvider() === plugin_name ? 'selected' : '' %>
+                                            ><%- plugin_info.display_name %></option>
+                                            <% }); %>
+                                        </select>
+                                    </div>
+                                </div>
+                            </div>
+
+                            <!-- Certbot credentials file content -->
+                            <div class="row credentials-file-content">
+                                <div class="col-sm-12 col-md-12">
+                                    <div class="form-group">
+                                        <label class="form-label"><%- i18n('ssl', 'credentials-file-content') %> <span class="form-required">*</span></label>
+                                        <textarea 
+                                            name="meta[dns_provider_credentials]" 
+                                            class="form-control text-monospace" 
+                                            id="dns_provider_credentials" 
+                                        ><%- getDnsProviderCredentials() %></textarea>
+                                        <div class="text-secondary small">
+                                            <i class="fe fe-info"></i> 
+                                            <%= i18n('ssl', 'credentials-file-content-info') %>
+                                        </div>
+                                        <div class="text-red small">
+                                            <i class="fe fe-alert-triangle"></i> 
+                                            <%= i18n('ssl', 'stored-as-plaintext-info') %>
+                                        </div>
+                                    </div>
+                                </div>
+                            </div>
+
+                            <!-- DNS propagation delay -->
+                            <div class="row">
+                                <div class="col-sm-12 col-md-12">
+                                    <div class="form-group mb-0">
+                                        <label class="form-label"><%- i18n('ssl', 'propagation-seconds') %></label>
+                                        <input 
+                                            type="number"
+                                            min="0"
+                                            name="meta[propagation_seconds]" 
+                                            class="form-control" 
+                                            id="propagation_seconds" 
+                                            value="<%- getPropagationSeconds() %>"
+                                        >
+                                        <div class="text-secondary small">
+                                            <i class="fe fe-info"></i> 
+                                            <%= i18n('ssl', 'propagation-seconds-info') %>
+                                        </div>
+                                    </div>
+                                </div>
+                            </div>
+                        </fieldset>
                     </div>
 
                     <div class="col-sm-12 col-md-12">
@@ -87,7 +170,7 @@
             </div>
         </form>
     </div>
-    <div class="modal-footer">
+    <div class="modal-footer non-loader-content">
         <button type="button" class="btn btn-secondary cancel" data-dismiss="modal"><%- i18n('str', 'cancel') %></button>
         <button type="button" class="btn btn-teal save"><%- i18n('str', 'save') %></button>
     </div>

+ 80 - 29
frontend/js/app/nginx/certificates/form.js

@@ -3,6 +3,8 @@ const Mn               = require('backbone.marionette');
 const App              = require('../../main');
 const CertificateModel = require('../../../models/certificate');
 const template         = require('./form.ejs');
+const i18n             = require('../../i18n');
+const dns_providers    = require('../../../../../global/certbot-dns-plugins');
 
 require('jquery-serializejson');
 require('selectize');
@@ -14,6 +16,9 @@ module.exports = Mn.View.extend({
 
     ui: {
         form:                                 'form',
+        loader_content:                       '.loader-content',
+        non_loader_content:                   '.non-loader-content',
+        le_error_info:                        '#le-error-info',
         domain_names:                         'input[name="domain_names"]',
         buttons:                              '.modal-footer button',
         cancel:                               'button.cancel',
@@ -21,27 +26,49 @@ module.exports = Mn.View.extend({
         other_certificate:                    '#other_certificate',
         other_certificate_label:              '#other_certificate_label',
         other_certificate_key:                '#other_certificate_key',
-        cloudflare_switch:                    'input[name="meta[cloudflare_use]"]',
-        cloudflare_token:                     'input[name="meta[cloudflare_token]"',
-        cloudflare:                           '.cloudflare',
+        dns_challenge_switch:                 'input[name="meta[dns_challenge]"]',
+        dns_challenge_content:                '.dns-challenge',
+        dns_provider:                         'select[name="meta[dns_provider]"]',
+        credentials_file_content:             '.credentials-file-content',
+        dns_provider_credentials:             'textarea[name="meta[dns_provider_credentials]"]',
+        propagation_seconds:                  'input[name="meta[propagation_seconds]"]',
         other_certificate_key_label:          '#other_certificate_key_label',
         other_intermediate_certificate:       '#other_intermediate_certificate',
         other_intermediate_certificate_label: '#other_intermediate_certificate_label'
     },
     
     events: {
-        'change @ui.cloudflare_switch': function() {
-            let checked = this.ui.cloudflare_switch.prop('checked');
-            if (checked) {                
-                this.ui.cloudflare_token.prop('required', 'required');
-                this.ui.cloudflare.show();
-            } else {                
-                this.ui.cloudflare_token.prop('required', false);
-                this.ui.cloudflare.hide();                
+        'change @ui.dns_challenge_switch': function () {
+            const checked = this.ui.dns_challenge_switch.prop('checked');
+            if (checked) {
+                this.ui.dns_provider.prop('required', 'required');
+                const selected_provider = this.ui.dns_provider[0].options[this.ui.dns_provider[0].selectedIndex].value;
+                if(selected_provider != '' && dns_providers[selected_provider].credentials !== false){
+                    this.ui.dns_provider_credentials.prop('required', 'required');
+                }
+                this.ui.dns_challenge_content.show();
+            } else {
+                this.ui.dns_provider.prop('required', false);
+                this.ui.dns_provider_credentials.prop('required', false);
+                this.ui.dns_challenge_content.hide();                
+            }
+        },
+
+        'change @ui.dns_provider': function () {
+            const selected_provider = this.ui.dns_provider[0].options[this.ui.dns_provider[0].selectedIndex].value;
+            if (selected_provider != '' && dns_providers[selected_provider].credentials !== false) {
+                this.ui.dns_provider_credentials.prop('required', 'required');
+                this.ui.dns_provider_credentials[0].value = dns_providers[selected_provider].credentials;
+                this.ui.credentials_file_content.show();
+            } else {
+                this.ui.dns_provider_credentials.prop('required', false);
+                this.ui.credentials_file_content.hide();                
             }
         },
+        
         'click @ui.save': function (e) {
             e.preventDefault();
+            this.ui.le_error_info.hide();
 
             if (!this.ui.form[0].checkValidity()) {
                 $('<input type="submit">').hide().appendTo(this.ui.form).click().remove();
@@ -56,7 +83,7 @@ module.exports = Mn.View.extend({
 
 
             let domain_err = false;
-            if (!data.meta.cloudflare_use) {                
+            if (!data.meta.dns_challenge) {                
                 data.domain_names.split(',').map(function (name) {
                     if (name.match(/\*/im)) {
                         domain_err = true;
@@ -65,16 +92,21 @@ module.exports = Mn.View.extend({
             }
 
             if (domain_err) {
-                alert('Cannot request Let\'s Encrypt Certificate for wildcard domains when not using CloudFlare DNS');
+                alert(i18n('ssl', 'no-wildcard-without-dns'));
                 return;
             }
 
             // Manipulate
-            if (typeof data.meta !== 'undefined' && typeof data.meta.letsencrypt_agree !== 'undefined') {
-                data.meta.letsencrypt_agree = !!data.meta.letsencrypt_agree;
-            }
-            if (typeof data.meta !== 'undefined' && typeof data.meta.cloudflare_use !== 'undefined') {
-                data.meta.cloudflare_use = !!data.meta.cloudflare_use;
+            if (typeof data.meta === 'undefined') data.meta = {};
+            data.meta.letsencrypt_agree = data.meta.letsencrypt_agree == 1;
+            data.meta.dns_challenge = data.meta.dns_challenge == 1;
+
+            if(!data.meta.dns_challenge){
+                data.meta.dns_provider = undefined;
+                data.meta.dns_provider_credentials = undefined;
+                data.meta.propagation_seconds = undefined;
+            } else {
+                if(data.meta.propagation_seconds === '') data.meta.propagation_seconds = undefined; 
             }
 
             if (typeof data.domain_names === 'string' && data.domain_names) {
@@ -116,8 +148,8 @@ module.exports = Mn.View.extend({
                 }
             }
 
-            this.ui.buttons.prop('disabled', true).addClass('btn-disabled');
-            this.ui.save.addClass('btn-loading');
+            this.ui.loader_content.show();
+            this.ui.non_loader_content.hide();
 
             // compile file data
             let form_data = new FormData();
@@ -154,9 +186,17 @@ module.exports = Mn.View.extend({
                     });
                 })
                 .catch(err => {
-                    alert(err.message);
-                    this.ui.buttons.prop('disabled', false).removeClass('btn-disabled');
-                    this.ui.save.removeClass('btn-loading');
+                    let more_info = '';
+                    if(err.code === 500 && err.debug){
+                        try{
+                            more_info = JSON.parse(err.debug).debug.stack.join("\n");
+                        } catch(e) {}
+                    }
+                    this.ui.le_error_info[0].innerHTML = `${err.message}${more_info !== '' ? `<pre class="mt-3">${more_info}</pre>`:''}`;
+                    this.ui.le_error_info.show();
+                    this.ui.le_error_info[0].scrollIntoView();
+                    this.ui.loader_content.hide();
+                    this.ui.non_loader_content.show();
                 });
         },
         'change @ui.other_certificate_key': function(e){
@@ -176,14 +216,22 @@ module.exports = Mn.View.extend({
         getLetsencryptEmail: function () {
             return typeof this.meta.letsencrypt_email !== 'undefined' ? this.meta.letsencrypt_email : App.Cache.User.get('email');
         },
-
         getLetsencryptAgree: function () {
             return typeof this.meta.letsencrypt_agree !== 'undefined' ? this.meta.letsencrypt_agree : false;
         },
-
-        getCloudflareUse: function () {
-            return typeof this.meta.cloudflare_use !== 'undefined' ? this.meta.cloudflare_use : false;
-        }
+        getUseDnsChallenge: function () {
+            return typeof this.meta.dns_challenge !== 'undefined' ? this.meta.dns_challenge : false;
+        },
+        getDnsProvider: function () {
+            return typeof this.meta.dns_provider !== 'undefined' && this.meta.dns_provider != '' ? this.meta.dns_provider : null;
+        },
+        getDnsProviderCredentials: function () {
+            return typeof this.meta.dns_provider_credentials !== 'undefined' ? this.meta.dns_provider_credentials : '';
+        },
+        getPropagationSeconds: function () {
+            return typeof this.meta.propagation_seconds !== 'undefined' ? this.meta.propagation_seconds : '';
+        },
+        dns_plugins: dns_providers,
     },
 
     onRender: function () {
@@ -199,7 +247,10 @@ module.exports = Mn.View.extend({
             },
             createFilter: /^(?:[^.]+\.?)+[^.]$/
         });
-        this.ui.cloudflare.hide();
+        this.ui.dns_challenge_content.hide();
+        this.ui.credentials_file_content.hide(); 
+        this.ui.loader_content.hide();
+        this.ui.le_error_info.hide();
     },
 
     initialize: function (options) {

+ 1 - 1
frontend/js/app/nginx/certificates/list/item.ejs

@@ -28,7 +28,7 @@
     </div>
 </td>
 <td>
-    <%- i18n('ssl', provider) %><% if (meta.cloudflare_use) { %> - CloudFlare DNS<% } %>
+    <%- i18n('ssl', provider) %><% if (meta.dns_provider) { %> - <%- dns_providers[meta.dns_provider].display_name %><% } %>
 </td>
 <td class="<%- isExpired() ? 'text-danger' : '' %>">
     <%- formatDbDate(expires_on, 'Do MMMM YYYY, h:mm a') %>

+ 7 - 5
frontend/js/app/nginx/certificates/list/item.js

@@ -1,7 +1,8 @@
-const Mn       = require('backbone.marionette');
-const moment   = require('moment');
-const App      = require('../../../main');
-const template = require('./item.ejs');
+const Mn            = require('backbone.marionette');
+const moment        = require('moment');
+const App           = require('../../../main');
+const template      = require('./item.ejs');
+const dns_providers = require('../../../../../../global/certbot-dns-plugins')
 
 module.exports = Mn.View.extend({
     template: template,
@@ -35,7 +36,8 @@ module.exports = Mn.View.extend({
         canManage: App.Cache.User.canManage('certificates'),
         isExpired: function () {
             return moment(this.expires_on).isBefore(moment());
-        }
+        },
+        dns_providers: dns_providers
     },
 
     initialize: function () {

+ 84 - 8
frontend/js/app/nginx/dead/form.ejs

@@ -4,6 +4,7 @@
         <button type="button" class="close cancel" aria-label="Close" data-dismiss="modal">&nbsp;</button>
     </div>
     <div class="modal-body has-tabs">
+        <div class="alert alert-danger mb-0 rounded-0" id="le-error-info" role="alert"></div>
         <form>
             <ul class="nav nav-tabs" role="tablist">
                 <li role="presentation" class="nav-item"><a href="#details" aria-controls="tab1" role="tab" data-toggle="tab" class="nav-link active"><i class="fe fe-zap"></i> <%- i18n('all-hosts', 'details') %></a></li>
@@ -73,21 +74,96 @@
                             </div>
                         </div>
 
-                        <!-- CloudFlare -->
+                        <!-- DNS challenge -->
                         <div class="col-sm-12 col-md-12 letsencrypt">
                             <div class="form-group">
                                 <label class="custom-switch">
-                                    <input type="checkbox" class="custom-switch-input" name="meta[cloudflare_use]" value="1">
+                                    <input 
+                                        type="checkbox" 
+                                        class="custom-switch-input" 
+                                        name="meta[dns_challenge]" 
+                                        value="1" 
+                                        <%- getUseDnsChallenge() ? 'checked' : '' %>
+                                    >
                                     <span class="custom-switch-indicator"></span>
-                                    <span class="custom-switch-description"><%= i18n('ssl', 'use-cloudflare') %></span>
+                                    <span class="custom-switch-description"><%= i18n('ssl', 'dns-challenge') %></span>
                                 </label>
                             </div>
                         </div>
-                        <div class="col-sm-12 col-md-12 cloudflare letsencrypt">
-                            <div class="form-group">
-                                <label class="form-label">CloudFlare DNS API Token  <span class="form-required">*</span></label>
-                                <input type="text" name="meta[cloudflare_token]" class="form-control" id="cloudflare_token">
-                            </div>
+                        <div class="col-sm-12 col-md-12 letsencrypt">
+                            <fieldset class="form-fieldset dns-challenge">
+                                <div class="text-red mb-4"><i class="fe fe-alert-triangle"></i> <%= i18n('ssl', 'certbot-warning') %></div>
+
+                                <!-- Certbot DNS plugin selection -->
+                                <div class="row">
+                                    <div class="col-sm-12 col-md-12">
+                                        <div class="form-group">
+                                            <label class="form-label"><%- i18n('ssl', 'dns-provider') %> <span class="form-required">*</span></label>
+                                            <select 
+                                                name="meta[dns_provider]" 
+                                                id="dns_provider"
+                                                class="form-control custom-select"
+                                            >
+                                                <option 
+                                                    value="" 
+                                                    disabled 
+                                                    hidden
+                                                    <%- getDnsProvider() === null ? 'selected' : '' %>
+                                                >Please Choose...</option>
+                                                <% _.each(dns_plugins, function(plugin_info, plugin_name){ %>
+                                                <option 
+                                                    value="<%- plugin_name %>"
+                                                    <%- getDnsProvider() === plugin_name ? 'selected' : '' %>
+                                                ><%- plugin_info.display_name %></option>
+                                                <% }); %>
+                                            </select>
+                                        </div>
+                                    </div>
+                                </div>
+
+                                <!-- Certbot credentials file content -->
+                                <div class="row credentials-file-content">
+                                    <div class="col-sm-12 col-md-12">
+                                        <div class="form-group">
+                                            <label class="form-label"><%- i18n('ssl', 'credentials-file-content') %> <span class="form-required">*</span></label>
+                                            <textarea 
+                                                name="meta[dns_provider_credentials]" 
+                                                class="form-control text-monospace" 
+                                                id="dns_provider_credentials" 
+                                            ><%- getDnsProviderCredentials() %></textarea>
+                                            <div class="text-secondary small">
+                                                <i class="fe fe-info"></i> 
+                                                <%= i18n('ssl', 'credentials-file-content-info') %>
+                                            </div>
+                                            <div class="text-red small">
+                                                <i class="fe fe-alert-triangle"></i> 
+                                                <%= i18n('ssl', 'stored-as-plaintext-info') %>
+                                            </div>
+                                        </div>
+                                    </div>
+                                </div>
+
+                                <!-- DNS propagation delay -->
+                                <div class="row">
+                                    <div class="col-sm-12 col-md-12">
+                                        <div class="form-group mb-0">
+                                            <label class="form-label"><%- i18n('ssl', 'propagation-seconds') %></label>
+                                            <input 
+                                                type="number"
+                                                min="0"
+                                                name="meta[propagation_seconds]" 
+                                                class="form-control" 
+                                                id="propagation_seconds" 
+                                                value="<%- getPropagationSeconds() %>"
+                                            >
+                                            <div class="text-secondary small">
+                                                <i class="fe fe-info"></i> 
+                                                <%= i18n('ssl', 'propagation-seconds-info') %>
+                                            </div>
+                                        </div>
+                                    </div>
+                                </div>
+                            </fieldset>
                         </div>
 
                         <!-- Lets encrypt -->

+ 91 - 34
frontend/js/app/nginx/dead/form.js

@@ -4,6 +4,8 @@ const DeadHostModel        = require('../../../models/dead-host');
 const template             = require('./form.ejs');
 const certListItemTemplate = require('../certificates-list-item.ejs');
 const Helpers              = require('../../../lib/helpers');
+const i18n                 = require('../../i18n');
+const dns_providers        = require('../../../../../global/certbot-dns-plugins');
 
 require('jquery-serializejson');
 require('selectize');
@@ -13,20 +15,24 @@ module.exports = Mn.View.extend({
     className: 'modal-dialog',
 
     ui: {
-        form:               'form',
-        domain_names:       'input[name="domain_names"]',
-        buttons:            '.modal-footer button',
-        cancel:             'button.cancel',
-        save:               'button.save',
-        certificate_select: 'select[name="certificate_id"]',
-        ssl_forced:         'input[name="ssl_forced"]',
-        hsts_enabled:       'input[name="hsts_enabled"]',
-        hsts_subdomains:    'input[name="hsts_subdomains"]',
-        http2_support:      'input[name="http2_support"]',
-        cloudflare_switch:  'input[name="meta[cloudflare_use]"]',
-        cloudflare_token:   'input[name="meta[cloudflare_token]"',
-        cloudflare:         '.cloudflare',
-        letsencrypt:        '.letsencrypt'
+        form:                     'form',
+        domain_names:             'input[name="domain_names"]',
+        buttons:                  '.modal-footer button',
+        cancel:                   'button.cancel',
+        save:                     'button.save',
+        le_error_info:            '#le-error-info',
+        certificate_select:       'select[name="certificate_id"]',
+        ssl_forced:               'input[name="ssl_forced"]',
+        hsts_enabled:             'input[name="hsts_enabled"]',
+        hsts_subdomains:          'input[name="hsts_subdomains"]',
+        http2_support:            'input[name="http2_support"]',
+        dns_challenge_switch:     'input[name="meta[dns_challenge]"]',
+        dns_challenge_content:    '.dns-challenge',
+        dns_provider:             'select[name="meta[dns_provider]"]',
+        credentials_file_content: '.credentials-file-content',
+        dns_provider_credentials: 'textarea[name="meta[dns_provider_credentials]"]',
+        propagation_seconds:      'input[name="meta[propagation_seconds]"]',
+        letsencrypt:              '.letsencrypt'
     },
 
     events: {
@@ -34,7 +40,7 @@ module.exports = Mn.View.extend({
             let id = this.ui.certificate_select.val();
             if (id === 'new') {
                 this.ui.letsencrypt.show().find('input').prop('disabled', false);
-                this.ui.cloudflare.hide();
+                this.ui.dns_challenge_content.hide();
             } else {
                 this.ui.letsencrypt.hide().find('input').prop('disabled', true);
             }
@@ -81,19 +87,37 @@ module.exports = Mn.View.extend({
             }
         },
 
-        'change @ui.cloudflare_switch': function() {
-            let checked = this.ui.cloudflare_switch.prop('checked');
-            if (checked) {                
-                this.ui.cloudflare_token.prop('required', 'required');
-                this.ui.cloudflare.show();
-            } else {                
-                this.ui.cloudflare_token.prop('required', false);
-                this.ui.cloudflare.hide();                
+        'change @ui.dns_challenge_switch': function () {
+            const checked = this.ui.dns_challenge_switch.prop('checked');
+            if (checked) {
+                this.ui.dns_provider.prop('required', 'required');
+                const selected_provider = this.ui.dns_provider[0].options[this.ui.dns_provider[0].selectedIndex].value;
+                if(selected_provider != '' && dns_providers[selected_provider].credentials !== false){
+                    this.ui.dns_provider_credentials.prop('required', 'required');
+                }
+                this.ui.dns_challenge_content.show();
+            } else {
+                this.ui.dns_provider.prop('required', false);
+                this.ui.dns_provider_credentials.prop('required', false);
+                this.ui.dns_challenge_content.hide();                
+            }
+        },
+
+        'change @ui.dns_provider': function () {
+            const selected_provider = this.ui.dns_provider[0].options[this.ui.dns_provider[0].selectedIndex].value;
+            if (selected_provider != '' && dns_providers[selected_provider].credentials !== false) {
+                this.ui.dns_provider_credentials.prop('required', 'required');
+                this.ui.dns_provider_credentials[0].value = dns_providers[selected_provider].credentials;
+                this.ui.credentials_file_content.show();
+            } else {
+                this.ui.dns_provider_credentials.prop('required', false);
+                this.ui.credentials_file_content.hide();                
             }
         },
 
         'click @ui.save': function (e) {
             e.preventDefault();
+            this.ui.le_error_info.hide();
 
             if (!this.ui.form[0].checkValidity()) {
                 $('<input type="submit">').hide().appendTo(this.ui.form).click().remove();
@@ -104,10 +128,22 @@ module.exports = Mn.View.extend({
             let data = this.ui.form.serializeJSON();
 
             // Manipulate
-            data.hsts_enabled    = !!data.hsts_enabled;
-            data.hsts_subdomains = !!data.hsts_subdomains;
-            data.http2_support   = !!data.http2_support;
-            data.ssl_forced      = !!data.ssl_forced;
+            data.hsts_enabled       = !!data.hsts_enabled;
+            data.hsts_subdomains    = !!data.hsts_subdomains;
+            data.http2_support      = !!data.http2_support;
+            data.ssl_forced         = !!data.ssl_forced;
+
+            if (typeof data.meta === 'undefined') data.meta = {};
+            data.meta.letsencrypt_agree = data.meta.letsencrypt_agree == 1;
+            data.meta.dns_challenge = data.meta.dns_challenge == 1;
+            
+            if(!data.meta.dns_challenge){
+                data.meta.dns_provider = undefined;
+                data.meta.dns_provider_credentials = undefined;
+                data.meta.propagation_seconds = undefined;
+            } else {
+                if(data.meta.propagation_seconds === '') data.meta.propagation_seconds = undefined; 
+            }
 
             if (typeof data.domain_names === 'string' && data.domain_names) {
                 data.domain_names = data.domain_names.split(',');
@@ -116,7 +152,7 @@ module.exports = Mn.View.extend({
             // Check for any domain names containing wildcards, which are not allowed with letsencrypt
             if (data.certificate_id === 'new') {                
                 let domain_err = false;
-                if (!data.meta.cloudflare_use) {
+                if (!data.meta.dns_challenge) {
                     data.domain_names.map(function (name) {
                         if (name.match(/\*/im)) {
                             domain_err = true;
@@ -125,12 +161,9 @@ module.exports = Mn.View.extend({
                 }
 
                 if (domain_err) {
-                    alert('Cannot request Let\'s Encrypt Certificate for wildcard domains without CloudFlare DNS.');
+                    alert(i18n('ssl', 'no-wildcard-without-dns'));
                     return;
                 }
-
-                data.meta.cloudflare_use = data.meta.cloudflare_use === '1';
-                data.meta.letsencrypt_agree = data.meta.letsencrypt_agree === '1';                
             } else {
                 data.certificate_id = parseInt(data.certificate_id, 10);
             }
@@ -159,7 +192,15 @@ module.exports = Mn.View.extend({
                     });
                 })
                 .catch(err => {
-                    alert(err.message);
+                    let more_info = '';
+                    if(err.code === 500 && err.debug){
+                        try{
+                            more_info = JSON.parse(err.debug).debug.stack.join("\n");
+                        } catch(e) {}
+                    }
+                    this.ui.le_error_info[0].innerHTML = `${err.message}${more_info !== '' ? `<pre class="mt-3">${more_info}</pre>`:''}`;
+                    this.ui.le_error_info.show();
+                    this.ui.le_error_info[0].scrollIntoView();
                     this.ui.buttons.prop('disabled', false).removeClass('btn-disabled');
                     this.ui.save.removeClass('btn-loading');
                 });
@@ -169,7 +210,20 @@ module.exports = Mn.View.extend({
     templateContext: {
         getLetsencryptEmail: function () {
             return App.Cache.User.get('email');
-        }
+        },
+        getUseDnsChallenge: function () {
+            return typeof this.meta.dns_challenge !== 'undefined' ? this.meta.dns_challenge : false;
+        },
+        getDnsProvider: function () {
+            return typeof this.meta.dns_provider !== 'undefined' && this.meta.dns_provider != '' ? this.meta.dns_provider : null;
+        },
+        getDnsProviderCredentials: function () {
+            return typeof this.meta.dns_provider_credentials !== 'undefined' ? this.meta.dns_provider_credentials : '';
+        },
+        getPropagationSeconds: function () {
+            return typeof this.meta.propagation_seconds !== 'undefined' ? this.meta.propagation_seconds : '';
+        },
+        dns_plugins: dns_providers,
     },
 
     onRender: function () {
@@ -190,6 +244,9 @@ module.exports = Mn.View.extend({
         });
 
         // Certificates
+        this.ui.le_error_info.hide();
+        this.ui.dns_challenge_content.hide();
+        this.ui.credentials_file_content.hide();
         this.ui.letsencrypt.hide();
         this.ui.certificate_select.selectize({
             valueField:       'id',

+ 84 - 8
frontend/js/app/nginx/proxy/form.ejs

@@ -4,6 +4,7 @@
         <button type="button" class="close cancel" aria-label="Close" data-dismiss="modal">&nbsp;</button>
     </div>
     <div class="modal-body has-tabs">
+        <div class="alert alert-danger mb-0 rounded-0" id="le-error-info" role="alert"></div>
         <form>
             <ul class="nav nav-tabs" role="tablist">
                 <li role="presentation" class="nav-item"><a href="#details" aria-controls="tab1" role="tab" data-toggle="tab" class="nav-link active"><i class="fe fe-zap"></i> <%- i18n('all-hosts', 'details') %></a></li>
@@ -141,21 +142,96 @@
                             </div>
                         </div>
 
-                        <!-- CloudFlare -->
+                        <!-- DNS challenge -->
                         <div class="col-sm-12 col-md-12 letsencrypt">
                             <div class="form-group">
                                 <label class="custom-switch">
-                                    <input type="checkbox" class="custom-switch-input" name="meta[cloudflare_use]" value="1">
+                                    <input 
+                                        type="checkbox" 
+                                        class="custom-switch-input" 
+                                        name="meta[dns_challenge]" 
+                                        value="1" 
+                                        <%- getUseDnsChallenge() ? 'checked' : '' %>
+                                    >
                                     <span class="custom-switch-indicator"></span>
-                                    <span class="custom-switch-description"><%= i18n('ssl', 'use-cloudflare') %></span>
+                                    <span class="custom-switch-description"><%= i18n('ssl', 'dns-challenge') %></span>
                                 </label>
                             </div>
                         </div>
-                        <div class="col-sm-12 col-md-12 cloudflare letsencrypt">
-                            <div class="form-group">
-                                <label class="form-label">CloudFlare DNS API Token  <span class="form-required">*</span></label>
-                                <input type="text" name="meta[cloudflare_token]" class="form-control" id="cloudflare_token">
-                            </div>
+                        <div class="col-sm-12 col-md-12 letsencrypt">
+                            <fieldset class="form-fieldset dns-challenge">
+                                <div class="text-red mb-4"><i class="fe fe-alert-triangle"></i> <%= i18n('ssl', 'certbot-warning') %></div>
+
+                                <!-- Certbot DNS plugin selection -->
+                                <div class="row">
+                                    <div class="col-sm-12 col-md-12">
+                                        <div class="form-group">
+                                            <label class="form-label"><%- i18n('ssl', 'dns-provider') %> <span class="form-required">*</span></label>
+                                            <select 
+                                                name="meta[dns_provider]" 
+                                                id="dns_provider"
+                                                class="form-control custom-select"
+                                            >
+                                                <option 
+                                                    value="" 
+                                                    disabled 
+                                                    hidden
+                                                    <%- getDnsProvider() === null ? 'selected' : '' %>
+                                                >Please Choose...</option>
+                                                <% _.each(dns_plugins, function(plugin_info, plugin_name){ %>
+                                                <option 
+                                                    value="<%- plugin_name %>"
+                                                    <%- getDnsProvider() === plugin_name ? 'selected' : '' %>
+                                                ><%- plugin_info.display_name %></option>
+                                                <% }); %>
+                                            </select>
+                                        </div>
+                                    </div>
+                                </div>
+
+                                <!-- Certbot credentials file content -->
+                                <div class="row credentials-file-content">
+                                    <div class="col-sm-12 col-md-12">
+                                        <div class="form-group">
+                                            <label class="form-label"><%- i18n('ssl', 'credentials-file-content') %> <span class="form-required">*</span></label>
+                                            <textarea 
+                                                name="meta[dns_provider_credentials]" 
+                                                class="form-control text-monospace" 
+                                                id="dns_provider_credentials" 
+                                            ><%- getDnsProviderCredentials() %></textarea>
+                                            <div class="text-secondary small">
+                                                <i class="fe fe-info"></i> 
+                                                <%= i18n('ssl', 'credentials-file-content-info') %>
+                                            </div>
+                                            <div class="text-red small">
+                                                <i class="fe fe-alert-triangle"></i> 
+                                                <%= i18n('ssl', 'stored-as-plaintext-info') %>
+                                            </div>
+                                        </div>
+                                    </div>
+                                </div>
+
+                                <!-- DNS propagation delay -->
+                                <div class="row">
+                                    <div class="col-sm-12 col-md-12">
+                                        <div class="form-group mb-0">
+                                            <label class="form-label"><%- i18n('ssl', 'propagation-seconds') %></label>
+                                            <input 
+                                                type="number"
+                                                min="0"
+                                                name="meta[propagation_seconds]" 
+                                                class="form-control" 
+                                                id="propagation_seconds" 
+                                                value="<%- getPropagationSeconds() %>"
+                                            >
+                                            <div class="text-secondary small">
+                                                <i class="fe fe-info"></i> 
+                                                <%= i18n('ssl', 'propagation-seconds-info') %>
+                                            </div>
+                                        </div>
+                                    </div>
+                                </div>
+                            </fieldset>
                         </div>
 
                         <!-- Lets encrypt -->

+ 92 - 35
frontend/js/app/nginx/proxy/form.js

@@ -7,6 +7,8 @@ const certListItemTemplate   = require('../certificates-list-item.ejs');
 const accessListItemTemplate = require('./access-list-item.ejs');
 const CustomLocation         = require('./location');
 const Helpers                = require('../../../lib/helpers');
+const i18n                   = require('../../i18n');
+const dns_providers          = require('../../../../../global/certbot-dns-plugins');
 
 
 require('jquery-serializejson');
@@ -19,25 +21,29 @@ module.exports = Mn.View.extend({
     locationsCollection: new ProxyLocationModel.Collection(),
 
     ui: {
-        form:               'form',
-        domain_names:       'input[name="domain_names"]',
-        forward_host:       'input[name="forward_host"]',
-        buttons:            '.modal-footer button',
-        cancel:             'button.cancel',
-        save:               'button.save',
-        add_location_btn:   'button.add_location',
-        locations_container:'.locations_container',
-        certificate_select: 'select[name="certificate_id"]',
-        access_list_select: 'select[name="access_list_id"]',
-        ssl_forced:         'input[name="ssl_forced"]',
-        hsts_enabled:       'input[name="hsts_enabled"]',
-        hsts_subdomains:    'input[name="hsts_subdomains"]',
-        http2_support:      'input[name="http2_support"]',
-        cloudflare_switch:  'input[name="meta[cloudflare_use]"]',
-        cloudflare_token:   'input[name="meta[cloudflare_token]"',
-        cloudflare:         '.cloudflare',
-        forward_scheme:     'select[name="forward_scheme"]',
-        letsencrypt:        '.letsencrypt'
+        form:                     'form',
+        domain_names:             'input[name="domain_names"]',
+        forward_host:             'input[name="forward_host"]',
+        buttons:                  '.modal-footer button',
+        cancel:                   'button.cancel',
+        save:                     'button.save',
+        add_location_btn:         'button.add_location',
+        locations_container:      '.locations_container',
+        le_error_info:            '#le-error-info',
+        certificate_select:       'select[name="certificate_id"]',
+        access_list_select:       'select[name="access_list_id"]',
+        ssl_forced:               'input[name="ssl_forced"]',
+        hsts_enabled:             'input[name="hsts_enabled"]',
+        hsts_subdomains:          'input[name="hsts_subdomains"]',
+        http2_support:            'input[name="http2_support"]',
+        dns_challenge_switch:     'input[name="meta[dns_challenge]"]',
+        dns_challenge_content:    '.dns-challenge',
+        dns_provider:             'select[name="meta[dns_provider]"]',
+        credentials_file_content: '.credentials-file-content',
+        dns_provider_credentials: 'textarea[name="meta[dns_provider_credentials]"]',
+        propagation_seconds:      'input[name="meta[propagation_seconds]"]',
+        forward_scheme:           'select[name="forward_scheme"]',
+        letsencrypt:              '.letsencrypt'
     },
 
     regions: {
@@ -49,7 +55,7 @@ module.exports = Mn.View.extend({
             let id = this.ui.certificate_select.val();
             if (id === 'new') {
                 this.ui.letsencrypt.show().find('input').prop('disabled', false);
-                this.ui.cloudflare.hide();
+                this.ui.dns_challenge_content.hide();
             } else {
                 this.ui.letsencrypt.hide().find('input').prop('disabled', true);
             }
@@ -95,14 +101,31 @@ module.exports = Mn.View.extend({
             }
         },
 
-        'change @ui.cloudflare_switch': function() {
-            let checked = this.ui.cloudflare_switch.prop('checked');
-            if (checked) {                
-                this.ui.cloudflare_token.prop('required', 'required');
-                this.ui.cloudflare.show();
-            } else {                
-                this.ui.cloudflare_token.prop('required', false);
-                this.ui.cloudflare.hide();                
+        'change @ui.dns_challenge_switch': function () {
+            const checked = this.ui.dns_challenge_switch.prop('checked');
+            if (checked) {
+                this.ui.dns_provider.prop('required', 'required');
+                const selected_provider = this.ui.dns_provider[0].options[this.ui.dns_provider[0].selectedIndex].value;
+                if(selected_provider != '' && dns_providers[selected_provider].credentials !== false){
+                    this.ui.dns_provider_credentials.prop('required', 'required');
+                }
+                this.ui.dns_challenge_content.show();
+            } else {
+                this.ui.dns_provider.prop('required', false);
+                this.ui.dns_provider_credentials.prop('required', false);
+                this.ui.dns_challenge_content.hide();                
+            }
+        },
+
+        'change @ui.dns_provider': function () {
+            const selected_provider = this.ui.dns_provider[0].options[this.ui.dns_provider[0].selectedIndex].value;
+            if (selected_provider != '' && dns_providers[selected_provider].credentials !== false) {
+                this.ui.dns_provider_credentials.prop('required', 'required');
+                this.ui.dns_provider_credentials[0].value = dns_providers[selected_provider].credentials;
+                this.ui.credentials_file_content.show();
+            } else {
+                this.ui.dns_provider_credentials.prop('required', false);
+                this.ui.credentials_file_content.hide();                
             }
         },
 
@@ -115,6 +138,7 @@ module.exports = Mn.View.extend({
 
         'click @ui.save': function (e) {
             e.preventDefault();
+            this.ui.le_error_info.hide();
 
             if (!this.ui.form[0].checkValidity()) {
                 $('<input type="submit">').hide().appendTo(this.ui.form).click().remove();
@@ -143,6 +167,18 @@ module.exports = Mn.View.extend({
             data.hsts_enabled            = !!data.hsts_enabled;
             data.hsts_subdomains         = !!data.hsts_subdomains;
             data.ssl_forced              = !!data.ssl_forced;
+            
+            if (typeof data.meta === 'undefined') data.meta = {};
+            data.meta.letsencrypt_agree = data.meta.letsencrypt_agree == 1;
+            data.meta.dns_challenge = data.meta.dns_challenge == 1;
+            
+            if(!data.meta.dns_challenge){
+                data.meta.dns_provider = undefined;
+                data.meta.dns_provider_credentials = undefined;
+                data.meta.propagation_seconds = undefined;
+            } else {
+                if(data.meta.propagation_seconds === '') data.meta.propagation_seconds = undefined; 
+            }
 
             if (typeof data.domain_names === 'string' && data.domain_names) {
                 data.domain_names = data.domain_names.split(',');
@@ -151,7 +187,7 @@ module.exports = Mn.View.extend({
             // Check for any domain names containing wildcards, which are not allowed with letsencrypt
             if (data.certificate_id === 'new') {                
                 let domain_err = false;
-                if (!data.meta.cloudflare_use) {
+                if (!data.meta.dns_challenge) {
                     data.domain_names.map(function (name) {
                         if (name.match(/\*/im)) {
                             domain_err = true;
@@ -160,12 +196,9 @@ module.exports = Mn.View.extend({
                 }
 
                 if (domain_err) {
-                    alert('Cannot request Let\'s Encrypt Certificate for wildcard domains without CloudFlare DNS.');
+                    alert(i18n('ssl', 'no-wildcard-without-dns'));
                     return;
                 }
-
-                data.meta.cloudflare_use = data.meta.cloudflare_use === '1';
-                data.meta.letsencrypt_agree = data.meta.letsencrypt_agree === '1';                
             } else {
                 data.certificate_id = parseInt(data.certificate_id, 10);
             }
@@ -194,7 +227,15 @@ module.exports = Mn.View.extend({
                     });
                 })
                 .catch(err => {
-                    alert(err.message);
+                    let more_info = '';
+                    if(err.code === 500 && err.debug){
+                        try{
+                            more_info = JSON.parse(err.debug).debug.stack.join("\n");
+                        } catch(e) {}
+                    }
+                    this.ui.le_error_info[0].innerHTML = `${err.message}${more_info !== '' ? `<pre class="mt-3">${more_info}</pre>`:''}`;
+                    this.ui.le_error_info.show();
+                    this.ui.le_error_info[0].scrollIntoView();
                     this.ui.buttons.prop('disabled', false).removeClass('btn-disabled');
                     this.ui.save.removeClass('btn-loading');
                 });
@@ -204,7 +245,20 @@ module.exports = Mn.View.extend({
     templateContext: {
         getLetsencryptEmail: function () {
             return App.Cache.User.get('email');
-        }
+        },
+        getUseDnsChallenge: function () {
+            return typeof this.meta.dns_challenge !== 'undefined' ? this.meta.dns_challenge : false;
+        },
+        getDnsProvider: function () {
+            return typeof this.meta.dns_provider !== 'undefined' && this.meta.dns_provider != '' ? this.meta.dns_provider : null;
+        },
+        getDnsProviderCredentials: function () {
+            return typeof this.meta.dns_provider_credentials !== 'undefined' ? this.meta.dns_provider_credentials : '';
+        },
+        getPropagationSeconds: function () {
+            return typeof this.meta.propagation_seconds !== 'undefined' ? this.meta.propagation_seconds : '';
+        },
+        dns_plugins: dns_providers,
     },
 
     onRender: function () {
@@ -258,6 +312,9 @@ module.exports = Mn.View.extend({
         });
 
         // Certificates
+        this.ui.le_error_info.hide();
+        this.ui.dns_challenge_content.hide();
+        this.ui.credentials_file_content.hide();
         this.ui.letsencrypt.hide();
         this.ui.certificate_select.selectize({
             valueField:       'id',

+ 84 - 8
frontend/js/app/nginx/redirection/form.ejs

@@ -4,6 +4,7 @@
         <button type="button" class="close cancel" aria-label="Close" data-dismiss="modal">&nbsp;</button>
     </div>
     <div class="modal-body has-tabs">
+        <div class="alert alert-danger mb-0 rounded-0" id="le-error-info" role="alert"></div>
         <form>
             <ul class="nav nav-tabs" role="tablist">
                 <li role="presentation" class="nav-item"><a href="#details" aria-controls="tab1" role="tab" data-toggle="tab" class="nav-link active"><i class="fe fe-zap"></i> <%- i18n('all-hosts', 'details') %></a></li>
@@ -97,21 +98,96 @@
                             </div>
                         </div>
 
-                        <!-- CloudFlare -->
+                        <!-- DNS challenge -->
                         <div class="col-sm-12 col-md-12 letsencrypt">
                             <div class="form-group">
                                 <label class="custom-switch">
-                                    <input type="checkbox" class="custom-switch-input" name="meta[cloudflare_use]" value="1">
+                                    <input 
+                                        type="checkbox" 
+                                        class="custom-switch-input" 
+                                        name="meta[dns_challenge]" 
+                                        value="1" 
+                                        <%- getUseDnsChallenge() ? 'checked' : '' %>
+                                    >
                                     <span class="custom-switch-indicator"></span>
-                                    <span class="custom-switch-description"><%= i18n('ssl', 'use-cloudflare') %></span>
+                                    <span class="custom-switch-description"><%= i18n('ssl', 'dns-challenge') %></span>
                                 </label>
                             </div>
                         </div>
-                        <div class="col-sm-12 col-md-12 cloudflare letsencrypt">
-                            <div class="form-group">
-                                <label class="form-label">CloudFlare DNS API Token  <span class="form-required">*</span></label>
-                                <input type="text" name="meta[cloudflare_token]" class="form-control" id="cloudflare_token">
-                            </div>
+                        <div class="col-sm-12 col-md-12 letsencrypt">
+                            <fieldset class="form-fieldset dns-challenge">
+                                <div class="text-red mb-4"><i class="fe fe-alert-triangle"></i> <%= i18n('ssl', 'certbot-warning') %></div>
+
+                                <!-- Certbot DNS plugin selection -->
+                                <div class="row">
+                                    <div class="col-sm-12 col-md-12">
+                                        <div class="form-group">
+                                            <label class="form-label"><%- i18n('ssl', 'dns-provider') %> <span class="form-required">*</span></label>
+                                            <select 
+                                                name="meta[dns_provider]" 
+                                                id="dns_provider"
+                                                class="form-control custom-select"
+                                            >
+                                                <option 
+                                                    value="" 
+                                                    disabled 
+                                                    hidden
+                                                    <%- getDnsProvider() === null ? 'selected' : '' %>
+                                                >Please Choose...</option>
+                                                <% _.each(dns_plugins, function(plugin_info, plugin_name){ %>
+                                                <option 
+                                                    value="<%- plugin_name %>"
+                                                    <%- getDnsProvider() === plugin_name ? 'selected' : '' %>
+                                                ><%- plugin_info.display_name %></option>
+                                                <% }); %>
+                                            </select>
+                                        </div>
+                                    </div>
+                                </div>
+
+                                <!-- Certbot credentials file content -->
+                                <div class="row credentials-file-content">
+                                    <div class="col-sm-12 col-md-12">
+                                        <div class="form-group">
+                                            <label class="form-label"><%- i18n('ssl', 'credentials-file-content') %> <span class="form-required">*</span></label>
+                                            <textarea 
+                                                name="meta[dns_provider_credentials]" 
+                                                class="form-control text-monospace" 
+                                                id="dns_provider_credentials" 
+                                            ><%- getDnsProviderCredentials() %></textarea>
+                                            <div class="text-secondary small">
+                                                <i class="fe fe-info"></i> 
+                                                <%= i18n('ssl', 'credentials-file-content-info') %>
+                                            </div>
+                                            <div class="text-red small">
+                                                <i class="fe fe-alert-triangle"></i> 
+                                                <%= i18n('ssl', 'stored-as-plaintext-info') %>
+                                            </div>
+                                        </div>
+                                    </div>
+                                </div>
+
+                                <!-- DNS propagation delay -->
+                                <div class="row">
+                                    <div class="col-sm-12 col-md-12">
+                                        <div class="form-group mb-0">
+                                            <label class="form-label"><%- i18n('ssl', 'propagation-seconds') %></label>
+                                            <input 
+                                                type="number"
+                                                min="0"
+                                                name="meta[propagation_seconds]" 
+                                                class="form-control" 
+                                                id="propagation_seconds" 
+                                                value="<%- getPropagationSeconds() %>"
+                                            >
+                                            <div class="text-secondary small">
+                                                <i class="fe fe-info"></i> 
+                                                <%= i18n('ssl', 'propagation-seconds-info') %>
+                                            </div>
+                                        </div>
+                                    </div>
+                                </div>
+                            </fieldset>
                         </div>
 
                         <!-- Lets encrypt -->

+ 95 - 37
frontend/js/app/nginx/redirection/form.js

@@ -4,6 +4,9 @@ const RedirectionHostModel = require('../../../models/redirection-host');
 const template             = require('./form.ejs');
 const certListItemTemplate = require('../certificates-list-item.ejs');
 const Helpers              = require('../../../lib/helpers');
+const i18n                 = require('../../i18n');
+const dns_providers        = require('../../../../../global/certbot-dns-plugins');
+
 
 require('jquery-serializejson');
 require('selectize');
@@ -13,20 +16,24 @@ module.exports = Mn.View.extend({
     className: 'modal-dialog',
 
     ui: {
-        form:               'form',
-        domain_names:       'input[name="domain_names"]',
-        buttons:            '.modal-footer button',
-        cancel:             'button.cancel',
-        save:               'button.save',
-        certificate_select: 'select[name="certificate_id"]',
-        ssl_forced:         'input[name="ssl_forced"]',
-        hsts_enabled:       'input[name="hsts_enabled"]',
-        hsts_subdomains:    'input[name="hsts_subdomains"]',
-        http2_support:      'input[name="http2_support"]',
-        cloudflare_switch:  'input[name="meta[cloudflare_use]"]',
-        cloudflare_token:   'input[name="meta[cloudflare_token]"',
-        cloudflare:         '.cloudflare',
-        letsencrypt:        '.letsencrypt'
+        form:                     'form',
+        domain_names:             'input[name="domain_names"]',
+        buttons:                  '.modal-footer button',
+        cancel:                   'button.cancel',
+        save:                     'button.save',
+        le_error_info:            '#le-error-info',
+        certificate_select:       'select[name="certificate_id"]',
+        ssl_forced:               'input[name="ssl_forced"]',
+        hsts_enabled:             'input[name="hsts_enabled"]',
+        hsts_subdomains:          'input[name="hsts_subdomains"]',
+        http2_support:            'input[name="http2_support"]',
+        dns_challenge_switch:     'input[name="meta[dns_challenge]"]',
+        dns_challenge_content:    '.dns-challenge',
+        dns_provider:             'select[name="meta[dns_provider]"]',
+        credentials_file_content: '.credentials-file-content',
+        dns_provider_credentials: 'textarea[name="meta[dns_provider_credentials]"]',
+        propagation_seconds:      'input[name="meta[propagation_seconds]"]',
+        letsencrypt:              '.letsencrypt'
     },
 
     events: {
@@ -34,7 +41,7 @@ module.exports = Mn.View.extend({
             let id = this.ui.certificate_select.val();
             if (id === 'new') {
                 this.ui.letsencrypt.show().find('input').prop('disabled', false);
-                this.ui.cloudflare.hide();
+                this.ui.dns_challenge_content.hide();
             } else {
                 this.ui.letsencrypt.hide().find('input').prop('disabled', true);
             }
@@ -80,19 +87,37 @@ module.exports = Mn.View.extend({
             }
         },
 
-        'change @ui.cloudflare_switch': function() {
-            let checked = this.ui.cloudflare_switch.prop('checked');
-            if (checked) {                
-                this.ui.cloudflare_token.prop('required', 'required');
-                this.ui.cloudflare.show();
-            } else {                
-                this.ui.cloudflare_token.prop('required', false);
-                this.ui.cloudflare.hide();                
+        'change @ui.dns_challenge_switch': function () {
+            const checked = this.ui.dns_challenge_switch.prop('checked');
+            if (checked) {
+                this.ui.dns_provider.prop('required', 'required');
+                const selected_provider = this.ui.dns_provider[0].options[this.ui.dns_provider[0].selectedIndex].value;
+                if(selected_provider != '' && dns_providers[selected_provider].credentials !== false){
+                    this.ui.dns_provider_credentials.prop('required', 'required');
+                }
+                this.ui.dns_challenge_content.show();
+            } else {
+                this.ui.dns_provider.prop('required', false);
+                this.ui.dns_provider_credentials.prop('required', false);
+                this.ui.dns_challenge_content.hide();                
+            }
+        },
+
+        'change @ui.dns_provider': function () {
+            const selected_provider = this.ui.dns_provider[0].options[this.ui.dns_provider[0].selectedIndex].value;
+            if (selected_provider != '' && dns_providers[selected_provider].credentials !== false) {
+                this.ui.dns_provider_credentials.prop('required', 'required');
+                this.ui.dns_provider_credentials[0].value = dns_providers[selected_provider].credentials;
+                this.ui.credentials_file_content.show();
+            } else {
+                this.ui.dns_provider_credentials.prop('required', false);
+                this.ui.credentials_file_content.hide();                
             }
         },
 
         'click @ui.save': function (e) {
             e.preventDefault();
+            this.ui.le_error_info.hide();
 
             if (!this.ui.form[0].checkValidity()) {
                 $('<input type="submit">').hide().appendTo(this.ui.form).click().remove();
@@ -103,12 +128,24 @@ module.exports = Mn.View.extend({
             let data = this.ui.form.serializeJSON();
 
             // Manipulate
-            data.block_exploits  = !!data.block_exploits;
-            data.preserve_path   = !!data.preserve_path;
-            data.http2_support   = !!data.http2_support;
-            data.hsts_enabled    = !!data.hsts_enabled;
-            data.hsts_subdomains = !!data.hsts_subdomains;
-            data.ssl_forced      = !!data.ssl_forced;
+            data.block_exploits     = !!data.block_exploits;
+            data.preserve_path      = !!data.preserve_path;
+            data.http2_support      = !!data.http2_support;
+            data.hsts_enabled       = !!data.hsts_enabled;
+            data.hsts_subdomains    = !!data.hsts_subdomains;
+            data.ssl_forced         = !!data.ssl_forced;
+            
+            if (typeof data.meta === 'undefined') data.meta = {};
+            data.meta.letsencrypt_agree = data.meta.letsencrypt_agree == 1;
+            data.meta.dns_challenge = data.meta.dns_challenge == 1;
+            
+            if(!data.meta.dns_challenge){
+                data.meta.dns_provider = undefined;
+                data.meta.dns_provider_credentials = undefined;
+                data.meta.propagation_seconds = undefined;
+            } else {
+                if(data.meta.propagation_seconds === '') data.meta.propagation_seconds = undefined; 
+            }
 
             if (typeof data.domain_names === 'string' && data.domain_names) {
                 data.domain_names = data.domain_names.split(',');
@@ -117,7 +154,7 @@ module.exports = Mn.View.extend({
             // Check for any domain names containing wildcards, which are not allowed with letsencrypt
             if (data.certificate_id === 'new') {                
                 let domain_err = false;
-                if (!data.meta.cloudflare_use) {
+                if (!data.meta.dns_challenge) {
                     data.domain_names.map(function (name) {
                         if (name.match(/\*/im)) {
                             domain_err = true;
@@ -126,12 +163,9 @@ module.exports = Mn.View.extend({
                 }
 
                 if (domain_err) {
-                    alert('Cannot request Let\'s Encrypt Certificate for wildcard domains without CloudFlare DNS.');
+                    alert(i18n('ssl', 'no-wildcard-without-dns'));
                     return;
-                }
-
-                data.meta.cloudflare_use = data.meta.cloudflare_use === '1';
-                data.meta.letsencrypt_agree = data.meta.letsencrypt_agree === '1';                
+                }             
             } else {
                 data.certificate_id = parseInt(data.certificate_id, 10);
             }
@@ -160,7 +194,15 @@ module.exports = Mn.View.extend({
                     });
                 })
                 .catch(err => {
-                    alert(err.message);
+                    let more_info = '';
+                    if(err.code === 500 && err.debug){
+                        try{
+                            more_info = JSON.parse(err.debug).debug.stack.join("\n");
+                        } catch(e) {}
+                    }
+                    this.ui.le_error_info[0].innerHTML = `${err.message}${more_info !== '' ? `<pre class="mt-3">${more_info}</pre>`:''}`;
+                    this.ui.le_error_info.show();
+                    this.ui.le_error_info[0].scrollIntoView();
                     this.ui.buttons.prop('disabled', false).removeClass('btn-disabled');
                     this.ui.save.removeClass('btn-loading');
                 });
@@ -170,7 +212,20 @@ module.exports = Mn.View.extend({
     templateContext: {
         getLetsencryptEmail: function () {
             return App.Cache.User.get('email');
-        }
+        },
+        getUseDnsChallenge: function () {
+            return typeof this.meta.dns_challenge !== 'undefined' ? this.meta.dns_challenge : false;
+        },
+        getDnsProvider: function () {
+            return typeof this.meta.dns_provider !== 'undefined' && this.meta.dns_provider != '' ? this.meta.dns_provider : null;
+        },
+        getDnsProviderCredentials: function () {
+            return typeof this.meta.dns_provider_credentials !== 'undefined' ? this.meta.dns_provider_credentials : '';
+        },
+        getPropagationSeconds: function () {
+            return typeof this.meta.propagation_seconds !== 'undefined' ? this.meta.propagation_seconds : '';
+        },
+        dns_plugins: dns_providers,
     },
 
     onRender: function () {
@@ -191,6 +246,9 @@ module.exports = Mn.View.extend({
         });
 
         // Certificates
+        this.ui.le_error_info.hide();
+        this.ui.dns_challenge_content.hide();
+        this.ui.credentials_file_content.hide();
         this.ui.letsencrypt.hide();
         this.ui.certificate_select.selectize({
             valueField:       'id',

+ 13 - 2
frontend/js/i18n/messages.json

@@ -102,7 +102,17 @@
       "letsencrypt-agree": "I Agree to the <a href=\"{url}\" target=\"_blank\">Let's Encrypt Terms of Service</a>",
       "delete-ssl": "The SSL certificates attached will NOT be removed, they will need to be removed manually.",
       "hosts-warning": "These domains must be already configured to point to this installation",
-      "use-cloudflare": "Use CloudFlare DNS verification"      
+      "no-wildcard-without-dns": "Cannot request Let's Encrypt Certificate for wildcard domains when not using DNS challenge",
+      "dns-challenge": "Use a DNS Challenge",
+      "certbot-warning": "This section requires some knowledge about Certbot and its DNS plugins. Please consult the respective plugins documentation.",
+      "dns-provider": "DNS Provider",
+      "please-choose": "Please Choose...",
+      "credentials-file-content": "Credentials File Content",
+      "credentials-file-content-info": "This plugin requires a configuration file containing an API token or other credentials to your provider",
+      "stored-as-plaintext-info": "This data will be stored as plaintext in the database!",
+      "propagation-seconds": "Propagation Seconds",
+      "propagation-seconds-info": "Leave empty to use the plugins default value. Number of seconds to wait for DNS propagation.",
+      "obtaining-certificate-info": "Obtaining certificate... This might take a few minutes."
     },
     "proxy-hosts": {
       "title": "Proxy Hosts",
@@ -196,7 +206,8 @@
       "authorization": "Authorization",
       "access": "Access",
       "satisfy": "Satisfy",
-      "satisfy-any": "Satisfy Any"
+      "satisfy-any": "Satisfy Any",
+      "pass-auth": "Pass Auth to Host"
     },
     "users": {
       "title": "Users",

+ 251 - 0
global/certbot-dns-plugins.js

@@ -0,0 +1,251 @@
+/**
+ * This file contains info about available Certbot DNS plugins.
+ * This only works for plugins which use the standard argument structure, so:
+ * --authenticator <plugin-name> --<plugin-name>-credentials <FILE> --<plugin-name>-propagation-seconds <number>
+ *
+ * File Structure:
+ *
+ *  {
+ *    cloudflare: {
+ *      display_name: "Name displayed to the user",
+ *      package_name: "Package name in PyPi repo",
+ *      package_version: "Package version in PyPi repo",
+ *      credentials: `Template of the credentials file`,
+ *      full_plugin_name: "The full plugin name as used in the commandline with certbot, including prefixes, e.g. 'certbot-dns-njalla:dns-njalla'",
+ *      credentials_file: Whether the plugin has a credentials file
+ *    },
+ *    ...
+ *  }
+ *
+ */
+
+module.exports = {
+	cloudflare: {
+		display_name:    'Cloudflare',
+		package_name:    'certbot-dns-cloudflare',
+		package_version: '1.8.0',
+		credentials:     `# Cloudflare API token
+dns_cloudflare_api_token = 0123456789abcdef0123456789abcdef01234567`,
+		full_plugin_name: 'dns-cloudflare',
+	},
+	//####################################################//
+	cloudxns: {
+		display_name:    'CloudXNS',
+		package_name:    'certbot-dns-cloudxns',
+		package_version: '1.8.0',
+		credentials:     `dns_cloudxns_api_key = 1234567890abcdef1234567890abcdef
+dns_cloudxns_secret_key = 1122334455667788`,
+		full_plugin_name: 'dns-cloudxns',
+	},
+	//####################################################//
+	corenetworks: {
+		display_name:    'Core Networks',
+		package_name:    'certbot-dns-corenetworks',
+		package_version: '0.1.4',
+		credentials:     `certbot_dns_corenetworks:dns_corenetworks_username = asaHB12r
+certbot_dns_corenetworks:dns_corenetworks_password = secure_password`,
+		full_plugin_name: 'certbot-dns-corenetworks:dns-corenetworks',
+	},
+	//####################################################//
+	cpanel: {
+		display_name:    'cPanel',
+		package_name:    'certbot-dns-cpanel',
+		package_version: '0.2.2',
+		credentials:     `certbot_dns_cpanel:cpanel_url = https://cpanel.example.com:2083
+certbot_dns_cpanel:cpanel_username = user
+certbot_dns_cpanel:cpanel_password = hunter2`,
+		full_plugin_name: 'certbot-dns-cpanel:cpanel',
+	},
+	//####################################################//
+	digitalocean: {
+		display_name:     'DigitalOcean',
+		package_name:     'certbot-dns-digitalocean',
+		package_version:  '1.8.0',
+		credentials:      'dns_digitalocean_token = 0000111122223333444455556666777788889999aaaabbbbccccddddeeeeffff',
+		full_plugin_name: 'dns-digitalocean',
+	},
+	//####################################################//
+	directadmin: {
+		display_name:    'DirectAdmin',
+		package_name:    'certbot-dns-directadmin',
+		package_version: '0.0.20',
+		credentials:     `directadmin_url = https://my.directadminserver.com:2222
+directadmin_username = username
+directadmin_password = aSuperStrongPassword`,
+		full_plugin_name: 'certbot-dns-directadmin:directadmin',
+	},
+	//####################################################//
+	dnsimple: {
+		display_name:     'DNSimple',
+		package_name:     'certbot-dns-dnsimple',
+		package_version:  '1.8.0',
+		credentials:      'dns_dnsimple_token = MDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAw',
+		full_plugin_name: 'dns-dnsimple',
+	},
+	//####################################################//
+	dnsmadeeasy: {
+		display_name:    'DNS Made Easy',
+		package_name:    'certbot-dns-dnsmadeeasy',
+		package_version: '1.8.0',
+		credentials:     `dns_dnsmadeeasy_api_key = 1c1a3c91-4770-4ce7-96f4-54c0eb0e457a
+dns_dnsmadeeasy_secret_key = c9b5625f-9834-4ff8-baba-4ed5f32cae55`,
+		full_plugin_name: 'dns-dnsmadeeasy',
+	},
+	//####################################################//
+	dnspod: {
+		display_name:    'DNSPod',
+		package_name:    'certbot-dns-dnspod',
+		package_version: '0.1.0',
+		credentials:     `certbot_dns_dnspod:dns_dnspod_email = "DNSPOD-API-REQUIRES-A-VALID-EMAIL"
+certbot_dns_dnspod:dns_dnspod_api_token = "DNSPOD-API-TOKEN"`,
+		full_plugin_name: 'certbot-dns-dnspod:dns-dnspod',
+	},
+	//####################################################//
+	google: {
+		display_name:    'Google',
+		package_name:    'certbot-dns-google',
+		package_version: '1.8.0',
+		credentials:     `{
+	"type": "service_account",
+	...
+}`,
+		full_plugin_name: 'dns-google',
+	},
+	//####################################################//
+	hetzner: {
+		display_name:     'Hetzner',
+		package_name:     'certbot-dns-hetzner',
+		package_version:  '1.0.4',
+		credentials:      'certbot_dns_hetzner:dns_hetzner_api_token = 0123456789abcdef0123456789abcdef',
+		full_plugin_name: 'certbot-dns-hetzner:dns-hetzner',
+	},
+	//####################################################//
+	inwx: {
+		display_name:    'INWX',
+		package_name:    'certbot-dns-inwx',
+		package_version: '2.1.2',
+		credentials:     `certbot_dns_inwx:dns_inwx_url = https://api.domrobot.com/xmlrpc/
+certbot_dns_inwx:dns_inwx_username = your_username
+certbot_dns_inwx:dns_inwx_password = your_password
+certbot_dns_inwx:dns_inwx_shared_secret = your_shared_secret optional`,
+		full_plugin_name: 'certbot-dns-inwx:dns-inwx',
+	},
+	//####################################################//
+	ispconfig: {
+		display_name:    'ISPConfig',
+		package_name:    'certbot-dns-ispconfig',
+		package_version: '0.2.0',
+		credentials:     `certbot_dns_ispconfig:dns_ispconfig_username = myremoteuser
+certbot_dns_ispconfig:dns_ispconfig_password = verysecureremoteuserpassword
+certbot_dns_ispconfig:dns_ispconfig_endpoint = https://localhost:8080`,
+		full_plugin_name: 'certbot-dns-ispconfig:dns-ispconfig',
+	},
+	//####################################################//
+	isset: {
+		display_name:    'Isset',
+		package_name:    'certbot-dns-isset',
+		package_version: '0.0.3',
+		credentials:     `certbot_dns_isset:dns_isset_endpoint="https://customer.isset.net/api"
+certbot_dns_isset:dns_isset_token="<token>"`,
+		full_plugin_name: 'certbot-dns-isset:dns-isset',
+	},
+	//####################################################//
+	linode: {
+		display_name:    'Linode',
+		package_name:    'certbot-dns-linode',
+		package_version: '1.8.0',
+		credentials:     `dns_linode_key = 0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ64
+dns_linode_version = [<blank>|3|4]`,
+		full_plugin_name: 'dns-linode',
+	},
+	//####################################################//
+	luadns: {
+		display_name:    'LuaDNS',
+		package_name:    'certbot-dns-luadns',
+		package_version: '1.8.0',
+		credentials:     `dns_luadns_email = [email protected]
+dns_luadns_token = 0123456789abcdef0123456789abcdef`,
+		full_plugin_name: 'dns-luadns',
+	},
+	//####################################################//
+	netcup: {
+		display_name:    'netcup',
+		package_name:    'certbot-dns-netcup',
+		package_version: '1.0.0',
+		credentials:     `dns_netcup_customer_id  = 123456
+dns_netcup_api_key      = 0123456789abcdef0123456789abcdef01234567
+dns_netcup_api_password = abcdef0123456789abcdef01234567abcdef0123`,
+		full_plugin_name: 'certbot-dns-netcup:dns-netcup',
+	},
+	//####################################################//
+	njalla: {
+		display_name:     'Njalla',
+		package_name:     'certbot-dns-njalla',
+		package_version:  '0.0.4',
+		credentials:      'certbot_dns_njalla:dns_njalla_token = 0123456789abcdef0123456789abcdef01234567',
+		full_plugin_name: 'certbot-dns-njalla:dns-njalla',
+	},
+	//####################################################//
+	nsone: {
+		display_name:     'NS1',
+		package_name:     'certbot-dns-nsone',
+		package_version:  '1.8.0',
+		credentials:      'dns_nsone_api_key = MDAwMDAwMDAwMDAwMDAw',
+		full_plugin_name: 'dns-nsone',
+	},
+	//####################################################//
+	ovh: {
+		display_name:    'OVH',
+		package_name:    'certbot-dns-ovh',
+		package_version: '1.8.0',
+		credentials:     `dns_ovh_endpoint = ovh-eu
+dns_ovh_application_key = MDAwMDAwMDAwMDAw
+dns_ovh_application_secret = MDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAw
+dns_ovh_consumer_key = MDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAw`,
+		full_plugin_name: 'dns-ovh',
+	},
+	//####################################################//
+	powerdns: {
+		display_name:    'PowerDNS',
+		package_name:    'certbot-dns-powerdns',
+		package_version: '0.2.0',
+		credentials:     `certbot_dns_powerdns:dns_powerdns_api_url = https://api.mypowerdns.example.org
+certbot_dns_powerdns:dns_powerdns_api_key = AbCbASsd!@34`,
+		full_plugin_name: 'certbot-dns-powerdns:dns-powerdns',
+	},
+	//####################################################//
+	rfc2136: {
+		display_name:    'RFC 2136',
+		package_name:    'certbot-dns-rfc2136',
+		package_version: '1.8.0',
+		credentials:     `# Target DNS server
+dns_rfc2136_server = 192.0.2.1
+# Target DNS port
+dns_rfc2136_port = 53
+# TSIG key name
+dns_rfc2136_name = keyname.
+# TSIG key secret
+dns_rfc2136_secret = 4q4wM/2I180UXoMyN4INVhJNi8V9BCV+jMw2mXgZw/CSuxUT8C7NKKFs AmKd7ak51vWKgSl12ib86oQRPkpDjg==
+# TSIG key algorithm
+dns_rfc2136_algorithm = HMAC-SHA512`,
+		full_plugin_name: 'dns-rfc2136',
+	},
+	//####################################################//
+	route53: {
+		display_name:    'Route 53 (Amazon)',
+		package_name:    'certbot-dns-route53',
+		package_version: '1.8.0',
+		credentials:     `[default]
+aws_access_key_id=AKIAIOSFODNN7EXAMPLE
+aws_secret_access_key=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY`,
+		full_plugin_name: 'dns-route53',
+	},
+	//####################################################//
+	vultr: {
+		display_name:     'Vultr',
+		package_name:     'certbot-dns-vultr',
+		package_version:  '1.0.3',
+		credentials:      'certbot_dns_vultr:dns_vultr_key = YOUR_VULTR_API_KEY',
+		full_plugin_name: 'certbot-dns-vultr:dns-vultr',
+	},
+};

+ 1 - 1
scripts/frontend-build

@@ -10,7 +10,7 @@ if hash docker 2>/dev/null; then
 	docker pull "${DOCKER_IMAGE}"
 	cd "${DIR}/.."
 	echo -e "${BLUE}❯ ${CYAN}Building Frontend ...${RESET}"
-	docker run --rm -e CI=true -v "$(pwd)/frontend:/app/frontend" -w /app/frontend "$DOCKER_IMAGE" sh -c "yarn install && yarn build && yarn build && chown -R $(id -u):$(id -g) /app/frontend"
+	docker run --rm -e CI=true -v "$(pwd)/frontend:/app/frontend" -v "$(pwd)/global:/app/global" -w /app/frontend "$DOCKER_IMAGE" sh -c "yarn install && yarn build && yarn build && chown -R $(id -u):$(id -g) /app/frontend"
 	echo -e "${BLUE}❯ ${GREEN}Building Frontend Complete${RESET}"
 else
 	echo -e "${RED}❯ docker command is not available${RESET}"

+ 1 - 1
scripts/test-dev

@@ -7,7 +7,7 @@ DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
 if hash docker-compose 2>/dev/null; then
 	cd "${DIR}/.."
 	echo -e "${BLUE}❯ ${CYAN}Testing Dev Stack ...${RESET}"
-	docker-compose exec -T npm bash -c "cd /app/backend && task test"
+	docker-compose exec -T npm bash -c "cd /app && task test"
 else
 	echo -e "${RED}❯ docker-compose command is not available${RESET}"
 fi