From c55476b196242e6e75dc1ec0c708761b3c6404d6 Mon Sep 17 00:00:00 2001 From: Julian Reinhardt Date: Sun, 31 Oct 2021 00:19:18 +0200 Subject: [PATCH] Adds buttons to test availability of server from public internet --- backend/internal/certificate.js | 83 +++++++++++++++++++ backend/routes/api/nginx/certificates.js | 27 +++++- backend/schema/endpoints/certificates.json | 11 +++ frontend/js/app/api.js | 10 +++ frontend/js/app/controller.js | 13 +++ frontend/js/app/nginx/certificates/form.ejs | 8 ++ frontend/js/app/nginx/certificates/form.js | 27 +++++- .../js/app/nginx/certificates/list/item.ejs | 3 + .../js/app/nginx/certificates/list/item.js | 16 ++-- frontend/js/app/nginx/certificates/test.ejs | 15 ++++ frontend/js/app/nginx/certificates/test.js | 73 ++++++++++++++++ frontend/js/i18n/messages.json | 9 ++ 12 files changed, 288 insertions(+), 7 deletions(-) create mode 100644 frontend/js/app/nginx/certificates/test.ejs create mode 100644 frontend/js/app/nginx/certificates/test.js diff --git a/backend/internal/certificate.js b/backend/internal/certificate.js index 401c5604..d8330b49 100644 --- a/backend/internal/certificate.js +++ b/backend/internal/certificate.js @@ -1,5 +1,6 @@ const _ = require('lodash'); const fs = require('fs'); +const https = require('https'); const tempWrite = require('temp-write'); const moment = require('moment'); const logger = require('../logger').ssl; @@ -15,6 +16,7 @@ const letsencryptConfig = '/etc/letsencrypt.ini'; const certbotCommand = 'certbot'; const archiver = require('archiver'); const path = require('path'); +const { isArray } = require('lodash'); function omissions() { return ['is_deleted']; @@ -1119,6 +1121,87 @@ const internalCertificate = { } else { return Promise.resolve(); } + }, + + testHttpsChallenge: async (access, domains) => { + await access.can('certificates:list'); + + if (!isArray(domains)) { + throw new error.InternalValidationError('Domains must be an array of strings'); + } + if (domains.length === 0) { + throw new error.InternalValidationError('No domains provided'); + } + + // Create a test challenge file + const testChallengeDir = '/data/letsencrypt-acme-challenge/.well-known/acme-challenge'; + const testChallengeFile = testChallengeDir + '/test-challenge'; + fs.mkdirSync(testChallengeDir, {recursive: true}); + fs.writeFileSync(testChallengeFile, 'Success', {encoding: 'utf8'}); + + async function performTestForDomain (domain) { + logger.info('Testing http challenge for ' + domain); + const url = `http://${domain}/.well-known/acme-challenge/test-challenge`; + const formBody = `method=G&url=${encodeURI(url)}&bodytype=T&requestbody=&headername=User-Agent&headervalue=None&locationid=1&ch=false&cc=false`; + const options = { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Content-Length': Buffer.byteLength(formBody) + } + }; + + const result = await new Promise((resolve) => { + + const req = https.request('https://www.site24x7.com/tools/restapi-tester', options, function (res) { + let responseBody = ''; + + res.on('data', (chunk) => responseBody = responseBody + chunk); + res.on('end', function () { + const parsedBody = JSON.parse(responseBody + ''); + if (res.statusCode !== 200) { + logger.warn(`Failed to test HTTP challenge for domain ${domain}`, res); + resolve(undefined); + } + resolve(parsedBody); + }); + }); + + // Make sure to write the request body. + req.write(formBody); + req.end(); + req.on('error', function (e) { logger.warn(`Failed to test HTTP challenge for domain ${domain}`, e); + resolve(undefined); }); + }); + + if (!result) { + // Some error occurred while trying to get the data + return 'failed'; + } else if (`${result.responsecode}` === '200' && result.htmlresponse === 'Success') { + // Server exists and has responded with the correct data + return 'ok'; + } else if (`${result.responsecode}` === '404') { + // Server exists but responded with a 404 + return '404'; + } else if (`${result.responsecode}` === '0' || result.reason.toLowerCase() === 'host unavailable') { + // Server does not exist at domain + return 'no-host'; + } else { + // Other errors + return `other:${result.responsecode}`; + } + } + + const results = {}; + + for (const domain of domains){ + results[domain] = await performTestForDomain(domain); + } + + // Remove the test challenge file + fs.unlinkSync(testChallengeFile); + + return results; } }; diff --git a/backend/routes/api/nginx/certificates.js b/backend/routes/api/nginx/certificates.js index 32995c53..ad5b5633 100644 --- a/backend/routes/api/nginx/certificates.js +++ b/backend/routes/api/nginx/certificates.js @@ -68,6 +68,32 @@ router .catch(next); }); +/** + * Test HTTP challenge for domains + * + * /api/nginx/certificates/test-http + */ + router + .route('/test-http') + .options((req, res) => { + res.sendStatus(204); + }) + .all(jwtdecode()) + +/** + * GET /api/nginx/certificates/test-http + * + * Test HTTP challenge for domains + */ + .get((req, res, next) => { + internalCertificate.testHttpsChallenge(res.locals.access, JSON.parse(req.query.domains)) + .then((result) => { + res.status(200) + .send(result); + }) + .catch(next); + }); + /** * Specific certificate * @@ -209,7 +235,6 @@ router .catch(next); }); - /** * Download LE Certs * diff --git a/backend/schema/endpoints/certificates.json b/backend/schema/endpoints/certificates.json index 49fd6a7d..955ca75c 100644 --- a/backend/schema/endpoints/certificates.json +++ b/backend/schema/endpoints/certificates.json @@ -157,6 +157,17 @@ "targetSchema": { "type": "boolean" } + }, + { + "title": "Test HTTP Challenge", + "description": "Tests whether the HTTP challenge should work", + "href": "/nginx/certificates/{definitions.identity.example}/test-http", + "access": "private", + "method": "GET", + "rel": "info", + "http_header": { + "$ref": "../examples.json#/definitions/auth_header" + } } ] } diff --git a/frontend/js/app/api.js b/frontend/js/app/api.js index 2511a789..6e33a6dc 100644 --- a/frontend/js/app/api.js +++ b/frontend/js/app/api.js @@ -685,6 +685,16 @@ module.exports = { return fetch('post', 'nginx/certificates/' + id + '/renew', undefined, {timeout}); }, + /** + * @param {Number} id + * @returns {Promise} + */ + testHttpChallenge: function (domains) { + return fetch('get', 'nginx/certificates/test-http?' + new URLSearchParams({ + domains: JSON.stringify(domains), + })); + }, + /** * @param {Number} id * @returns {Promise} diff --git a/frontend/js/app/controller.js b/frontend/js/app/controller.js index 902659be..ccb2978a 100644 --- a/frontend/js/app/controller.js +++ b/frontend/js/app/controller.js @@ -366,6 +366,19 @@ module.exports = { } }, + /** + * Certificate Test Reachability + * + * @param model + */ + showNginxCertificateTestReachability: function (model) { + if (Cache.User.isAdmin() || Cache.User.canManage('certificates')) { + require(['./main', './nginx/certificates/test'], function (App, View) { + App.UI.showModalDialog(new View({model: model})); + }); + } + }, + /** * Audit Log */ diff --git a/frontend/js/app/nginx/certificates/form.ejs b/frontend/js/app/nginx/certificates/form.ejs index c8b1369f..7fc12785 100644 --- a/frontend/js/app/nginx/certificates/form.ejs +++ b/frontend/js/app/nginx/certificates/form.ejs @@ -18,6 +18,14 @@
<%- i18n('ssl', 'hosts-warning') %>
+ +
+ +
+ + <%- i18n('certificates', 'reachability-info') %> +
+
diff --git a/frontend/js/app/nginx/certificates/form.js b/frontend/js/app/nginx/certificates/form.js index 65a54b69..56b31cf2 100644 --- a/frontend/js/app/nginx/certificates/form.js +++ b/frontend/js/app/nginx/certificates/form.js @@ -29,6 +29,8 @@ module.exports = Mn.View.extend({ non_loader_content: '.non-loader-content', le_error_info: '#le-error-info', domain_names: 'input[name="domain_names"]', + test_domains_container: '.test-domains-container', + test_domains_button: '.test-domains', buttons: '.modal-footer button', cancel: 'button.cancel', save: 'button.save', @@ -56,10 +58,12 @@ module.exports = Mn.View.extend({ this.ui.dns_provider_credentials.prop('required', 'required'); } this.ui.dns_challenge_content.show(); + this.ui.test_domains_container.hide(); } else { this.ui.dns_provider.prop('required', false); this.ui.dns_provider_credentials.prop('required', false); - this.ui.dns_challenge_content.hide(); + this.ui.dns_challenge_content.hide(); + this.ui.test_domains_container.show(); } }, @@ -205,6 +209,23 @@ module.exports = Mn.View.extend({ this.ui.non_loader_content.show(); }); }, + 'click @ui.test_domains_button': function (e) { + e.preventDefault(); + const domainNames = this.ui.domain_names[0].value.split(','); + if (domainNames && domainNames.length > 0) { + this.model.set('domain_names', domainNames); + this.model.set('back_to_add', true); + App.Controller.showNginxCertificateTestReachability(this.model); + } + }, + 'change @ui.domain_names': function(e){ + const domainNames = e.target.value.split(','); + if (domainNames && domainNames.length > 0) { + this.ui.test_domains_button.prop('disabled', false); + } else { + this.ui.test_domains_button.prop('disabled', true); + } + }, 'change @ui.other_certificate_key': function(e){ this.setFileName("other_certificate_key_label", e) }, @@ -257,6 +278,10 @@ module.exports = Mn.View.extend({ this.ui.credentials_file_content.hide(); this.ui.loader_content.hide(); this.ui.le_error_info.hide(); + const domainNames = this.ui.domain_names[0].value.split(','); + if (!domainNames || domainNames.length === 0 || (domainNames.length === 1 && domainNames[0] === "")) { + this.ui.test_domains_button.prop('disabled', true); + } }, initialize: function (options) { diff --git a/frontend/js/app/nginx/certificates/list/item.ejs b/frontend/js/app/nginx/certificates/list/item.ejs index 1a73605b..bdeeecad 100644 --- a/frontend/js/app/nginx/certificates/list/item.ejs +++ b/frontend/js/app/nginx/certificates/list/item.ejs @@ -42,6 +42,9 @@ <% if (provider === 'letsencrypt') { %> <%- i18n('certificates', 'force-renew') %> <%- i18n('certificates', 'download') %> + <% if (meta.dns_challenge === false) { %> + <%- i18n('certificates', 'test-reachability') %> + <% } %> <% } %> <%- i18n('str', 'delete') %> diff --git a/frontend/js/app/nginx/certificates/list/item.js b/frontend/js/app/nginx/certificates/list/item.js index ca167fae..7fa1c681 100644 --- a/frontend/js/app/nginx/certificates/list/item.js +++ b/frontend/js/app/nginx/certificates/list/item.js @@ -2,7 +2,7 @@ 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') +const dns_providers = require('../../../../../../global/certbot-dns-plugins'); module.exports = Mn.View.extend({ template: template, @@ -12,7 +12,8 @@ module.exports = Mn.View.extend({ host_link: '.host-link', renew: 'a.renew', delete: 'a.delete', - download: 'a.download' + download: 'a.download', + test: 'a.test' }, events: { @@ -31,11 +32,16 @@ module.exports = Mn.View.extend({ let win = window.open($(e.currentTarget).attr('rel'), '_blank'); win.focus(); }, - + 'click @ui.download': function (e) { e.preventDefault(); - App.Api.Nginx.Certificates.download(this.model.get('id')) - } + App.Api.Nginx.Certificates.download(this.model.get('id')); + }, + + 'click @ui.test': function (e) { + e.preventDefault(); + App.Controller.showNginxCertificateTestReachability(this.model); + }, }, templateContext: { diff --git a/frontend/js/app/nginx/certificates/test.ejs b/frontend/js/app/nginx/certificates/test.ejs new file mode 100644 index 00000000..c941a779 --- /dev/null +++ b/frontend/js/app/nginx/certificates/test.ejs @@ -0,0 +1,15 @@ + diff --git a/frontend/js/app/nginx/certificates/test.js b/frontend/js/app/nginx/certificates/test.js new file mode 100644 index 00000000..b29fd5eb --- /dev/null +++ b/frontend/js/app/nginx/certificates/test.js @@ -0,0 +1,73 @@ +const Mn = require('backbone.marionette'); +const App = require('../../main'); +const template = require('./test.ejs'); + +module.exports = Mn.View.extend({ + template: template, + className: 'modal-dialog', + + ui: { + waiting: '.waiting', + error: '.error', + success: '.success', + close: 'button.cancel' + }, + + events: { + 'click @ui.close': function (e) { + e.preventDefault(); + if (this.model.get('back_to_add')) { + App.Controller.showNginxCertificateForm(this.model); + } else { + App.UI.closeModal(); + } + }, + }, + + onRender: function () { + this.ui.error.hide(); + this.ui.success.hide(); + + App.Api.Nginx.Certificates.testHttpChallenge(this.model.get('domain_names')) + .then((result) => { + let allOk = true; + let text = ''; + + for (const domain in result) { + const status = result[domain]; + if (status === 'ok') { + text += `

${domain}: ${App.i18n('certificates', 'reachability-ok')}

`; + } else { + allOk = false; + if (status === 'no-host') { + text += `

${domain}: ${App.i18n('certificates', 'reachability-not-resolved')}

`; + } else if (status === 'failed') { + text += `

${domain}: ${App.i18n('certificates', 'reachability-failed-to-check')}

`; + } else if (status === '404') { + text += `

${domain}: ${App.i18n('certificates', 'reachability-404')}

`; + } else if (status.startsWith('other:')) { + const code = status.substring(6); + text += `

${domain}: ${App.i18n('certificates', 'reachability-other', {code})}

`; + } else { + // This should never happen + text += `

${domain}: ?

`; + } + } + } + + this.ui.waiting.hide(); + if (allOk) { + this.ui.success.html(text).show(); + } else { + this.ui.error.html(text).show(); + } + this.ui.close.prop('disabled', false); + }) + .catch((e) => { + console.error(e); + this.ui.waiting.hide(); + this.ui.error.text(App.i18n('certificates', 'reachability-failed-to-reach-api')).show(); + this.ui.close.prop('disabled', false); + }); + } +}); diff --git a/frontend/js/i18n/messages.json b/frontend/js/i18n/messages.json index 1e00ef7c..f231de52 100644 --- a/frontend/js/i18n/messages.json +++ b/frontend/js/i18n/messages.json @@ -188,6 +188,15 @@ "other-certificate-key": "Certificate Key", "other-intermediate-certificate": "Intermediate Certificate", "force-renew": "Renew Now", + "test-reachability": "Test Server Reachability", + "reachability-title": "Test Server Reachability", + "reachability-info": "Test whether the domains are reachable from the public internet using Site24x7. This is not necessary when using the DNS Challenge.", + "reachability-failed-to-reach-api": "Communication with the API failed, is NPM running correctly?", + "reachability-failed-to-check": "Failed to check the reachability due to a communication error with site24x7.com.", + "reachability-ok": "Your server is reachable and creating certificates should be possible.", + "reachability-404": "There is a server found at this domain but it does not seem to be Nginx Proxy Manager. Please make sure your domain points to the IP where your NPM instance is running.", + "reachability-not-resolved": "There is no server available at this domain. Please make sure your domain exists and points to the IP where your NPM instance is running and if necessary port 80 is forwarded in your router.", + "reachability-other": "There is a server found at this domain but it returned an unexpected status code {code}. Is it the NPM server? Please make sure your domain points to the IP where your NPM instance is running.", "download": "Download", "renew-title": "Renew Let'sEncrypt Certificate" },