Skip to content

Commit d45f39a

Browse files
authored
Merge pull request NginxProxyManager#1538 from jc21/adds-http-challenge-test
Adds buttons to test availability of server from public internet
2 parents cb09104 + ddd5389 commit d45f39a

File tree

13 files changed

+348
-7
lines changed

13 files changed

+348
-7
lines changed

backend/internal/certificate.js

+90
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
const _ = require('lodash');
22
const fs = require('fs');
3+
const https = require('https');
34
const tempWrite = require('temp-write');
45
const moment = require('moment');
56
const logger = require('../logger').ssl;
@@ -15,6 +16,7 @@ const letsencryptConfig = '/etc/letsencrypt.ini';
1516
const certbotCommand = 'certbot';
1617
const archiver = require('archiver');
1718
const path = require('path');
19+
const { isArray } = require('lodash');
1820

1921
function omissions() {
2022
return ['is_deleted'];
@@ -1124,6 +1126,94 @@ const internalCertificate = {
11241126
} else {
11251127
return Promise.resolve();
11261128
}
1129+
},
1130+
1131+
testHttpsChallenge: async (access, domains) => {
1132+
await access.can('certificates:list');
1133+
1134+
if (!isArray(domains)) {
1135+
throw new error.InternalValidationError('Domains must be an array of strings');
1136+
}
1137+
if (domains.length === 0) {
1138+
throw new error.InternalValidationError('No domains provided');
1139+
}
1140+
1141+
// Create a test challenge file
1142+
const testChallengeDir = '/data/letsencrypt-acme-challenge/.well-known/acme-challenge';
1143+
const testChallengeFile = testChallengeDir + '/test-challenge';
1144+
fs.mkdirSync(testChallengeDir, {recursive: true});
1145+
fs.writeFileSync(testChallengeFile, 'Success', {encoding: 'utf8'});
1146+
1147+
async function performTestForDomain (domain) {
1148+
logger.info('Testing http challenge for ' + domain);
1149+
const url = `http://${domain}/.well-known/acme-challenge/test-challenge`;
1150+
const formBody = `method=G&url=${encodeURI(url)}&bodytype=T&requestbody=&headername=User-Agent&headervalue=None&locationid=1&ch=false&cc=false`;
1151+
const options = {
1152+
method: 'POST',
1153+
headers: {
1154+
'Content-Type': 'application/x-www-form-urlencoded',
1155+
'Content-Length': Buffer.byteLength(formBody)
1156+
}
1157+
};
1158+
1159+
const result = await new Promise((resolve) => {
1160+
1161+
const req = https.request('https://www.site24x7.com/tools/restapi-tester', options, function (res) {
1162+
let responseBody = '';
1163+
1164+
res.on('data', (chunk) => responseBody = responseBody + chunk);
1165+
res.on('end', function () {
1166+
const parsedBody = JSON.parse(responseBody + '');
1167+
if (res.statusCode !== 200) {
1168+
logger.warn(`Failed to test HTTP challenge for domain ${domain}`, res);
1169+
resolve(undefined);
1170+
}
1171+
resolve(parsedBody);
1172+
});
1173+
});
1174+
1175+
// Make sure to write the request body.
1176+
req.write(formBody);
1177+
req.end();
1178+
req.on('error', function (e) { logger.warn(`Failed to test HTTP challenge for domain ${domain}`, e);
1179+
resolve(undefined); });
1180+
});
1181+
1182+
if (!result) {
1183+
// Some error occurred while trying to get the data
1184+
return 'failed';
1185+
} else if (`${result.responsecode}` === '200' && result.htmlresponse === 'Success') {
1186+
// Server exists and has responded with the correct data
1187+
return 'ok';
1188+
} else if (`${result.responsecode}` === '200') {
1189+
// Server exists but has responded with wrong data
1190+
logger.info(`HTTP challenge test failed for domain ${domain} because of invalid returned data:`, result.htmlresponse);
1191+
return 'wrong-data';
1192+
} else if (`${result.responsecode}` === '404') {
1193+
// Server exists but responded with a 404
1194+
logger.info(`HTTP challenge test failed for domain ${domain} because code 404 was returned`);
1195+
return '404';
1196+
} else if (`${result.responsecode}` === '0' || (typeof result.reason === 'string' && result.reason.toLowerCase() === 'host unavailable')) {
1197+
// Server does not exist at domain
1198+
logger.info(`HTTP challenge test failed for domain ${domain} the host was not found`);
1199+
return 'no-host';
1200+
} else {
1201+
// Other errors
1202+
logger.info(`HTTP challenge test failed for domain ${domain} because code ${result.responsecode} was returned`);
1203+
return `other:${result.responsecode}`;
1204+
}
1205+
}
1206+
1207+
const results = {};
1208+
1209+
for (const domain of domains){
1210+
results[domain] = await performTestForDomain(domain);
1211+
}
1212+
1213+
// Remove the test challenge file
1214+
fs.unlinkSync(testChallengeFile);
1215+
1216+
return results;
11271217
}
11281218
};
11291219

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
const migrate_name = 'stream_domain';
2+
const logger = require('../logger').migrate;
3+
const internalNginx = require('../internal/nginx');
4+
5+
async function regenerateDefaultHost(knex) {
6+
const row = await knex('setting').select('*').where('id', 'default-site').first();
7+
8+
if (!row) {
9+
return Promise.resolve();
10+
}
11+
12+
return internalNginx.deleteConfig('default')
13+
.then(() => {
14+
return internalNginx.generateConfig('default', row);
15+
})
16+
.then(() => {
17+
return internalNginx.test();
18+
})
19+
.then(() => {
20+
return internalNginx.reload();
21+
});
22+
}
23+
24+
/**
25+
* Migrate
26+
*
27+
* @see http://knexjs.org/#Schema
28+
*
29+
* @param {Object} knex
30+
* @param {Promise} Promise
31+
* @returns {Promise}
32+
*/
33+
exports.up = function (knex) {
34+
logger.info('[' + migrate_name + '] Migrating Up...');
35+
36+
return regenerateDefaultHost(knex);
37+
};
38+
39+
/**
40+
* Undo Migrate
41+
*
42+
* @param {Object} knex
43+
* @param {Promise} Promise
44+
* @returns {Promise}
45+
*/
46+
exports.down = function (knex) {
47+
logger.info('[' + migrate_name + '] Migrating Down...');
48+
49+
return regenerateDefaultHost(knex);
50+
};

backend/routes/api/nginx/certificates.js

+26-1
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,32 @@ router
6868
.catch(next);
6969
});
7070

71+
/**
72+
* Test HTTP challenge for domains
73+
*
74+
* /api/nginx/certificates/test-http
75+
*/
76+
router
77+
.route('/test-http')
78+
.options((req, res) => {
79+
res.sendStatus(204);
80+
})
81+
.all(jwtdecode())
82+
83+
/**
84+
* GET /api/nginx/certificates/test-http
85+
*
86+
* Test HTTP challenge for domains
87+
*/
88+
.get((req, res, next) => {
89+
internalCertificate.testHttpsChallenge(res.locals.access, JSON.parse(req.query.domains))
90+
.then((result) => {
91+
res.status(200)
92+
.send(result);
93+
})
94+
.catch(next);
95+
});
96+
7197
/**
7298
* Specific certificate
7399
*
@@ -209,7 +235,6 @@ router
209235
.catch(next);
210236
});
211237

212-
213238
/**
214239
* Download LE Certs
215240
*

backend/schema/endpoints/certificates.json

+11
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,17 @@
157157
"targetSchema": {
158158
"type": "boolean"
159159
}
160+
},
161+
{
162+
"title": "Test HTTP Challenge",
163+
"description": "Tests whether the HTTP challenge should work",
164+
"href": "/nginx/certificates/{definitions.identity.example}/test-http",
165+
"access": "private",
166+
"method": "GET",
167+
"rel": "info",
168+
"http_header": {
169+
"$ref": "../examples.json#/definitions/auth_header"
170+
}
160171
}
161172
]
162173
}

frontend/js/app/api.js

+10
Original file line numberDiff line numberDiff line change
@@ -685,6 +685,16 @@ module.exports = {
685685
return fetch('post', 'nginx/certificates/' + id + '/renew', undefined, {timeout});
686686
},
687687

688+
/**
689+
* @param {Number} id
690+
* @returns {Promise}
691+
*/
692+
testHttpChallenge: function (domains) {
693+
return fetch('get', 'nginx/certificates/test-http?' + new URLSearchParams({
694+
domains: JSON.stringify(domains),
695+
}));
696+
},
697+
688698
/**
689699
* @param {Number} id
690700
* @returns {Promise}

frontend/js/app/controller.js

+13
Original file line numberDiff line numberDiff line change
@@ -366,6 +366,19 @@ module.exports = {
366366
}
367367
},
368368

369+
/**
370+
* Certificate Test Reachability
371+
*
372+
* @param model
373+
*/
374+
showNginxCertificateTestReachability: function (model) {
375+
if (Cache.User.isAdmin() || Cache.User.canManage('certificates')) {
376+
require(['./main', './nginx/certificates/test'], function (App, View) {
377+
App.UI.showModalDialog(new View({model: model}));
378+
});
379+
}
380+
},
381+
369382
/**
370383
* Audit Log
371384
*/

frontend/js/app/nginx/certificates/form.ejs

+8
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,14 @@
1818
<input type="text" name="domain_names" class="form-control" id="input-domains" value="<%- domain_names.join(',') %>" required>
1919
<div class="text-blue"><i class="fe fe-alert-triangle"></i> <%- i18n('ssl', 'hosts-warning') %></div>
2020
</div>
21+
22+
<div class="mb-3 test-domains-container">
23+
<button type="button" class="btn btn-secondary test-domains col-sm-12"><%- i18n('certificates', 'test-reachability') %></button>
24+
<div class="text-secondary small">
25+
<i class="fe fe-info"></i>
26+
<%- i18n('certificates', 'reachability-info') %>
27+
</div>
28+
</div>
2129
</div>
2230
<div class="col-sm-12 col-md-12">
2331
<div class="form-group">

frontend/js/app/nginx/certificates/form.js

+26-1
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ module.exports = Mn.View.extend({
2929
non_loader_content: '.non-loader-content',
3030
le_error_info: '#le-error-info',
3131
domain_names: 'input[name="domain_names"]',
32+
test_domains_container: '.test-domains-container',
33+
test_domains_button: '.test-domains',
3234
buttons: '.modal-footer button',
3335
cancel: 'button.cancel',
3436
save: 'button.save',
@@ -56,10 +58,12 @@ module.exports = Mn.View.extend({
5658
this.ui.dns_provider_credentials.prop('required', 'required');
5759
}
5860
this.ui.dns_challenge_content.show();
61+
this.ui.test_domains_container.hide();
5962
} else {
6063
this.ui.dns_provider.prop('required', false);
6164
this.ui.dns_provider_credentials.prop('required', false);
62-
this.ui.dns_challenge_content.hide();
65+
this.ui.dns_challenge_content.hide();
66+
this.ui.test_domains_container.show();
6367
}
6468
},
6569

@@ -205,6 +209,23 @@ module.exports = Mn.View.extend({
205209
this.ui.non_loader_content.show();
206210
});
207211
},
212+
'click @ui.test_domains_button': function (e) {
213+
e.preventDefault();
214+
const domainNames = this.ui.domain_names[0].value.split(',');
215+
if (domainNames && domainNames.length > 0) {
216+
this.model.set('domain_names', domainNames);
217+
this.model.set('back_to_add', true);
218+
App.Controller.showNginxCertificateTestReachability(this.model);
219+
}
220+
},
221+
'change @ui.domain_names': function(e){
222+
const domainNames = e.target.value.split(',');
223+
if (domainNames && domainNames.length > 0) {
224+
this.ui.test_domains_button.prop('disabled', false);
225+
} else {
226+
this.ui.test_domains_button.prop('disabled', true);
227+
}
228+
},
208229
'change @ui.other_certificate_key': function(e){
209230
this.setFileName("other_certificate_key_label", e)
210231
},
@@ -257,6 +278,10 @@ module.exports = Mn.View.extend({
257278
this.ui.credentials_file_content.hide();
258279
this.ui.loader_content.hide();
259280
this.ui.le_error_info.hide();
281+
const domainNames = this.ui.domain_names[0].value.split(',');
282+
if (!domainNames || domainNames.length === 0 || (domainNames.length === 1 && domainNames[0] === "")) {
283+
this.ui.test_domains_button.prop('disabled', true);
284+
}
260285
},
261286

262287
initialize: function (options) {

frontend/js/app/nginx/certificates/list/item.ejs

+3
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,9 @@
4242
<% if (provider === 'letsencrypt') { %>
4343
<a href="#" class="renew dropdown-item"><i class="dropdown-icon fe fe-refresh-cw"></i> <%- i18n('certificates', 'force-renew') %></a>
4444
<a href="#" class="download dropdown-item"><i class="dropdown-icon fe fe-download"></i> <%- i18n('certificates', 'download') %></a>
45+
<% if (meta.dns_challenge === false) { %>
46+
<a href="#" class="test dropdown-item"><i class="dropdown-icon fe fe-globe"></i> <%- i18n('certificates', 'test-reachability') %></a>
47+
<% } %>
4548
<div class="dropdown-divider"></div>
4649
<% } %>
4750
<a href="#" class="delete dropdown-item"><i class="dropdown-icon fe fe-trash-2"></i> <%- i18n('str', 'delete') %></a>

frontend/js/app/nginx/certificates/list/item.js

+11-5
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ const Mn = require('backbone.marionette');
22
const moment = require('moment');
33
const App = require('../../../main');
44
const template = require('./item.ejs');
5-
const dns_providers = require('../../../../../../global/certbot-dns-plugins')
5+
const dns_providers = require('../../../../../../global/certbot-dns-plugins');
66

77
module.exports = Mn.View.extend({
88
template: template,
@@ -12,7 +12,8 @@ module.exports = Mn.View.extend({
1212
host_link: '.host-link',
1313
renew: 'a.renew',
1414
delete: 'a.delete',
15-
download: 'a.download'
15+
download: 'a.download',
16+
test: 'a.test'
1617
},
1718

1819
events: {
@@ -31,11 +32,16 @@ module.exports = Mn.View.extend({
3132
let win = window.open($(e.currentTarget).attr('rel'), '_blank');
3233
win.focus();
3334
},
34-
35+
3536
'click @ui.download': function (e) {
3637
e.preventDefault();
37-
App.Api.Nginx.Certificates.download(this.model.get('id'))
38-
}
38+
App.Api.Nginx.Certificates.download(this.model.get('id'));
39+
},
40+
41+
'click @ui.test': function (e) {
42+
e.preventDefault();
43+
App.Controller.showNginxCertificateTestReachability(this.model);
44+
},
3945
},
4046

4147
templateContext: {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<div class="modal-content">
2+
<div class="modal-header">
3+
<h5 class="modal-title"><%- i18n('certificates', 'reachability-title') %></h5>
4+
</div>
5+
<div class="modal-body">
6+
<div class="waiting text-center">
7+
<%= i18n('str', 'please-wait') %>
8+
</div>
9+
<div class="alert alert-danger error" role="alert"></div>
10+
<div class="alert alert-success success" role="alert"></div>
11+
</div>
12+
<div class="modal-footer">
13+
<button type="button" class="btn btn-secondary cancel" disabled><%- i18n('str', 'close') %></button>
14+
</div>
15+
</div>

0 commit comments

Comments
 (0)