diff --git a/backend/internal/certificate.js b/backend/internal/certificate.js index 7c25741..2acd895 100644 --- a/backend/internal/certificate.js +++ b/backend/internal/certificate.js @@ -141,36 +141,60 @@ const internalCertificate = { }); }) .then((in_use_result) => { - // 3. Generate the LE config - return internalNginx.generateLetsEncryptRequestConfig(certificate) - .then(internalNginx.reload) - .then(() => { + // Is CloudFlare, no config needed, so skip 3 and 5. + if (data.meta.cloudflare_use) { + return internalNginx.reload().then(() => { // 4. Request cert - return internalCertificate.requestLetsEncryptSsl(certificate); + return internalCertificate.requestLetsEncryptCloudFlareDnsSsl(certificate, data.meta.cloudflare_token); }) - .then(() => { - // 5. Remove LE config - return internalNginx.deleteLetsEncryptRequestConfig(certificate); - }) - .then(internalNginx.reload) - .then(() => { - // 6. Re-instate previously disabled hosts - return internalCertificate.enableInUseHosts(in_use_result); - }) - .then(() => { - return certificate; - }) - .catch((err) => { - // In the event of failure, revert things and throw err back - return internalNginx.deleteLetsEncryptRequestConfig(certificate) - .then(() => { - return internalCertificate.enableInUseHosts(in_use_result); - }) - .then(internalNginx.reload) - .then(() => { - throw err; - }); - }); + .then(internalNginx.reload) + .then(() => { + // 6. Re-instate previously disabled hosts + return internalCertificate.enableInUseHosts(in_use_result); + }) + .then(() => { + return certificate; + }) + .catch((err) => { + // In the event of failure, revert things and throw err back + return internalCertificate.enableInUseHosts(in_use_result) + .then(internalNginx.reload) + .then(() => { + throw err; + }); + }); + } else { + // 3. Generate the LE config + return internalNginx.generateLetsEncryptRequestConfig(certificate) + .then(internalNginx.reload) + .then(() => { + // 4. Request cert + return internalCertificate.requestLetsEncryptSsl(certificate); + }) + .then(() => { + // 5. Remove LE config + return internalNginx.deleteLetsEncryptRequestConfig(certificate); + }) + .then(internalNginx.reload) + .then(() => { + // 6. Re-instate previously disabled hosts + return internalCertificate.enableInUseHosts(in_use_result); + }) + .then(() => { + return certificate; + }) + .catch((err) => { + // In the event of failure, revert things and throw err back + return internalNginx.deleteLetsEncryptRequestConfig(certificate) + .then(() => { + return internalCertificate.enableInUseHosts(in_use_result); + }) + .then(internalNginx.reload) + .then(() => { + throw err; + }); + }); + } }) .then(() => { // At this point, the letsencrypt cert should exist on disk. @@ -747,6 +771,39 @@ const internalCertificate = { }); }, + /** + * @param {Object} certificate the certificate row + * @param {String} apiToken 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(', ')); + + let tokenLoc = '~/cloudflare-token'; + let storeKey = 'echo "dns_cloudflare_api_token = ' + apiToken + '" > ' + tokenLoc; + + let cmd = + storeKey + ' && ' + + 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; + + if (debug_mode) { + logger.info('Command:', cmd); + } + + return utils.exec(cmd).then((result) => { + logger.info(result); + return result; + }); + }, + + /** * @param {Access} access * @param {Object} data @@ -760,7 +817,9 @@ const internalCertificate = { }) .then((certificate) => { if (certificate.provider === 'letsencrypt') { - return internalCertificate.renewLetsEncryptSsl(certificate) + let renewMethod = certificate.meta.cloudflare_use ? internalCertificate.renewLetsEncryptCloudFlareSsl : internalCertificate.renewLetsEncryptSsl; + + return renewMethod(certificate) .then(() => { return internalCertificate.getCertificateInfoFromFile('/etc/letsencrypt/live/npm-' + certificate.id + '/fullchain.pem'); }) @@ -814,6 +873,29 @@ 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(', ')); + + let cmd = certbot_command + ' renew --non-interactive ' + + '--cert-name "npm-' + certificate.id + '" ' + + '--disable-hook-validation ' + + (le_staging ? '--staging' : ''); + + if (debug_mode) { + logger.info('Command:', cmd); + } + + return utils.exec(cmd) + .then((result) => { + logger.info(result); + return result; + }); + }, + /** * @param {Object} certificate the certificate row * @param {Boolean} [throw_errors] @@ -823,7 +905,6 @@ const internalCertificate = { logger.info('Revoking Let\'sEncrypt certificates for Cert #' + certificate.id + ': ' + certificate.domain_names.join(', ')); let cmd = certbot_command + ' revoke --non-interactive ' + - '--config "' + le_config + '" ' + '--cert-path "/etc/letsencrypt/live/npm-' + certificate.id + '/fullchain.pem" ' + '--delete-after-revoke ' + (le_staging ? '--staging' : ''); diff --git a/backend/schema/endpoints/certificates.json b/backend/schema/endpoints/certificates.json index d3294f8..27ea2d2 100644 --- a/backend/schema/endpoints/certificates.json +++ b/backend/schema/endpoints/certificates.json @@ -41,6 +41,12 @@ }, "letsencrypt_agree": { "type": "boolean" + }, + "cloudflare_use": { + "type": "boolean" + }, + "cloudflare_token": { + "type": "string" } } } diff --git a/docker/Dockerfile b/docker/Dockerfile index e3eefb3..5224416 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -17,7 +17,8 @@ ENV NODE_ENV=production RUN echo "fs.file-max = 65535" > /etc/sysctl.conf \ && apk update \ - && apk add python2 certbot jq \ + && apk add python2 py-pip certbot jq \ + && pip install certbot-dns-cloudflare \ && rm -rf /var/cache/apk/* ENV NPM_BUILD_VERSION="${BUILD_VERSION}" NPM_BUILD_COMMIT="${BUILD_COMMIT}" NPM_BUILD_DATE="${BUILD_DATE}" diff --git a/docker/dev/Dockerfile b/docker/dev/Dockerfile index 35f5651..5b67981 100644 --- a/docker/dev/Dockerfile +++ b/docker/dev/Dockerfile @@ -7,7 +7,8 @@ ENV S6_FIX_ATTRS_HIDDEN=1 RUN echo "fs.file-max = 65535" > /etc/sysctl.conf \ && apk update \ - && apk add python2 certbot jq \ + && apk add python2 py-pip certbot jq \ + && pip install certbot-dns-cloudflare \ && rm -rf /var/cache/apk/* # Task diff --git a/frontend/js/app/nginx/certificates/form.ejs b/frontend/js/app/nginx/certificates/form.ejs index 32edb6b..19ea2c7 100644 --- a/frontend/js/app/nginx/certificates/form.ejs +++ b/frontend/js/app/nginx/certificates/form.ejs @@ -20,6 +20,24 @@ + + +
+
+ +
+
+
+
+ + +
+
+
- <%- i18n('ssl', provider) %> + <%- i18n('ssl', provider) %><% if (meta.cloudflare_use) { %> - CloudFlare DNS<% } %> <%- formatDbDate(expires_on, 'Do MMMM YYYY, h:mm a') %> diff --git a/frontend/js/app/nginx/dead/form.ejs b/frontend/js/app/nginx/dead/form.ejs index f94d2cc..d48820f 100644 --- a/frontend/js/app/nginx/dead/form.ejs +++ b/frontend/js/app/nginx/dead/form.ejs @@ -73,6 +73,23 @@
+ +
+
+ +
+
+
+
+ + +
+
+
diff --git a/frontend/js/app/nginx/dead/form.js b/frontend/js/app/nginx/dead/form.js index 4d7ef6b..aca367a 100644 --- a/frontend/js/app/nginx/dead/form.js +++ b/frontend/js/app/nginx/dead/form.js @@ -23,6 +23,9 @@ module.exports = Mn.View.extend({ 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' }, @@ -31,10 +34,12 @@ 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(); } else { this.ui.letsencrypt.hide().find('input').prop('disabled', true); } + let enabled = id === 'new' || parseInt(id, 10) > 0; let inputs = this.ui.ssl_forced.add(this.ui.http2_support); @@ -76,6 +81,17 @@ 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(); + } + }, + 'click @ui.save': function (e) { e.preventDefault(); @@ -98,20 +114,23 @@ module.exports = Mn.View.extend({ } // Check for any domain names containing wildcards, which are not allowed with letsencrypt - if (data.certificate_id === 'new') { + if (data.certificate_id === 'new') { let domain_err = false; - data.domain_names.map(function (name) { - if (name.match(/\*/im)) { - domain_err = true; - } - }); + if (!data.meta.cloudflare_use) { + data.domain_names.map(function (name) { + if (name.match(/\*/im)) { + domain_err = true; + } + }); + } if (domain_err) { - alert('Cannot request Let\'s Encrypt Certificate for wildcard domains'); + alert('Cannot request Let\'s Encrypt Certificate for wildcard domains without CloudFlare DNS.'); return; } - data.meta.letsencrypt_agree = data.meta.letsencrypt_agree === '1'; + 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); } @@ -127,6 +146,8 @@ module.exports = Mn.View.extend({ } this.ui.buttons.prop('disabled', true).addClass('btn-disabled'); + this.ui.save.addClass('btn-loading'); + method(data) .then(result => { view.model.set(result); @@ -140,6 +161,7 @@ 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'); }); } }, diff --git a/frontend/js/app/nginx/proxy/form.ejs b/frontend/js/app/nginx/proxy/form.ejs index 0cc0d54..e003597 100644 --- a/frontend/js/app/nginx/proxy/form.ejs +++ b/frontend/js/app/nginx/proxy/form.ejs @@ -141,6 +141,23 @@
+ +
+
+ +
+
+
+
+ + +
+
+
diff --git a/frontend/js/app/nginx/proxy/form.js b/frontend/js/app/nginx/proxy/form.js index eb93bc8..0f64281 100644 --- a/frontend/js/app/nginx/proxy/form.js +++ b/frontend/js/app/nginx/proxy/form.js @@ -33,6 +33,9 @@ module.exports = Mn.View.extend({ 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' }, @@ -46,6 +49,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(); } else { this.ui.letsencrypt.hide().find('input').prop('disabled', true); } @@ -91,6 +95,17 @@ 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(); + } + }, + 'click @ui.add_location_btn': function (e) { e.preventDefault(); @@ -134,20 +149,23 @@ module.exports = Mn.View.extend({ } // Check for any domain names containing wildcards, which are not allowed with letsencrypt - if (data.certificate_id === 'new') { + if (data.certificate_id === 'new') { let domain_err = false; - data.domain_names.map(function (name) { - if (name.match(/\*/im)) { - domain_err = true; - } - }); + if (!data.meta.cloudflare_use) { + data.domain_names.map(function (name) { + if (name.match(/\*/im)) { + domain_err = true; + } + }); + } if (domain_err) { - alert('Cannot request Let\'s Encrypt Certificate for wildcard domains'); + alert('Cannot request Let\'s Encrypt Certificate for wildcard domains without CloudFlare DNS.'); return; } - data.meta.letsencrypt_agree = data.meta.letsencrypt_agree === '1'; + 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); } @@ -163,6 +181,8 @@ module.exports = Mn.View.extend({ } this.ui.buttons.prop('disabled', true).addClass('btn-disabled'); + this.ui.save.addClass('btn-loading'); + method(data) .then(result => { view.model.set(result); @@ -176,6 +196,7 @@ 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'); }); } }, @@ -203,7 +224,7 @@ module.exports = Mn.View.extend({ text: input }; }, - createFilter: /^(?:\*\.)?(?:[^.*]+\.?)+[^.]$/ + createFilter: /^(?:\.)?(?:[^.*]+\.?)+[^.]$/ }); // Access Lists diff --git a/frontend/js/app/nginx/redirection/form.ejs b/frontend/js/app/nginx/redirection/form.ejs index 7cdb8a3..7d49769 100644 --- a/frontend/js/app/nginx/redirection/form.ejs +++ b/frontend/js/app/nginx/redirection/form.ejs @@ -97,6 +97,23 @@
+ +
+
+ +
+
+
+
+ + +
+
+
diff --git a/frontend/js/app/nginx/redirection/form.js b/frontend/js/app/nginx/redirection/form.js index 0cef1a3..4e5b168 100644 --- a/frontend/js/app/nginx/redirection/form.js +++ b/frontend/js/app/nginx/redirection/form.js @@ -23,6 +23,9 @@ module.exports = Mn.View.extend({ 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' }, @@ -31,6 +34,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(); } else { this.ui.letsencrypt.hide().find('input').prop('disabled', true); } @@ -76,6 +80,17 @@ 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(); + } + }, + 'click @ui.save': function (e) { e.preventDefault(); @@ -100,20 +115,23 @@ module.exports = Mn.View.extend({ } // Check for any domain names containing wildcards, which are not allowed with letsencrypt - if (data.certificate_id === 'new') { + if (data.certificate_id === 'new') { let domain_err = false; - data.domain_names.map(function (name) { - if (name.match(/\*/im)) { - domain_err = true; - } - }); + if (!data.meta.cloudflare_use) { + data.domain_names.map(function (name) { + if (name.match(/\*/im)) { + domain_err = true; + } + }); + } if (domain_err) { - alert('Cannot request Let\'s Encrypt Certificate for wildcard domains'); + alert('Cannot request Let\'s Encrypt Certificate for wildcard domains without CloudFlare DNS.'); return; } - data.meta.letsencrypt_agree = data.meta.letsencrypt_agree === '1'; + 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); } @@ -129,6 +147,8 @@ module.exports = Mn.View.extend({ } this.ui.buttons.prop('disabled', true).addClass('btn-disabled'); + this.ui.save.addClass('btn-loading'); + method(data) .then(result => { view.model.set(result); @@ -142,6 +162,7 @@ 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'); }); } }, diff --git a/frontend/js/i18n/messages.json b/frontend/js/i18n/messages.json index 7b5205a..d0c9d8e 100644 --- a/frontend/js/i18n/messages.json +++ b/frontend/js/i18n/messages.json @@ -101,7 +101,8 @@ "letsencrypt-email": "Email Address for Let's Encrypt", "letsencrypt-agree": "I Agree to the Let's Encrypt Terms of Service", "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" + "hosts-warning": "These domains must be already configured to point to this installation", + "use-cloudflare": "Use CloudFlare DNS verification" }, "proxy-hosts": { "title": "Proxy Hosts",