Skip to content

Commit c55476b

Browse files
committed
Adds buttons to test availability of server from public internet
1 parent d0bfa08 commit c55476b

File tree

12 files changed

+288
-7
lines changed

12 files changed

+288
-7
lines changed

backend/internal/certificate.js

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

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-danger 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)