Skip to content

Adds buttons to test availability of server from public internet #1538

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 6 commits into from
Nov 9, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 90 additions & 0 deletions backend/internal/certificate.js
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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'];
Expand Down Expand Up @@ -1119,6 +1121,94 @@ 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}` === '200') {
// Server exists but has responded with wrong data
logger.info(`HTTP challenge test failed for ___domain ${___domain} because of invalid returned data:`, result.htmlresponse);
return 'wrong-data';
} else if (`${result.responsecode}` === '404') {
// Server exists but responded with a 404
logger.info(`HTTP challenge test failed for ___domain ${___domain} because code 404 was returned`);
return '404';
} else if (`${result.responsecode}` === '0' || (typeof result.reason === 'string' && result.reason.toLowerCase() === 'host unavailable')) {
// Server does not exist at ___domain
logger.info(`HTTP challenge test failed for ___domain ${___domain} the host was not found`);
return 'no-host';
} else {
// Other errors
logger.info(`HTTP challenge test failed for ___domain ${___domain} because code ${result.responsecode} was returned`);
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;
}
};

Expand Down
50 changes: 50 additions & 0 deletions backend/migrations/20211108145214_regenerate_default_host.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
const migrate_name = 'stream_domain';
const logger = require('../logger').migrate;
const internalNginx = require('../internal/nginx');

async function regenerateDefaultHost(knex) {
const row = await knex('setting').select('*').where('id', 'default-site').first();

if (!row) {
return Promise.resolve();
}

return internalNginx.deleteConfig('default')
.then(() => {
return internalNginx.generateConfig('default', row);
})
.then(() => {
return internalNginx.test();
})
.then(() => {
return internalNginx.reload();
});
}

/**
* Migrate
*
* @see http://knexjs.org/#Schema
*
* @param {Object} knex
* @param {Promise} Promise
* @returns {Promise}
*/
exports.up = function (knex) {
logger.info('[' + migrate_name + '] Migrating Up...');

return regenerateDefaultHost(knex);
};

/**
* Undo Migrate
*
* @param {Object} knex
* @param {Promise} Promise
* @returns {Promise}
*/
exports.down = function (knex) {
logger.info('[' + migrate_name + '] Migrating Down...');

return regenerateDefaultHost(knex);
};
27 changes: 26 additions & 1 deletion backend/routes/api/nginx/certificates.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
*
Expand Down Expand Up @@ -209,7 +235,6 @@ router
.catch(next);
});


/**
* Download LE Certs
*
Expand Down
11 changes: 11 additions & 0 deletions backend/schema/endpoints/certificates.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
]
}
10 changes: 10 additions & 0 deletions frontend/js/app/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
13 changes: 13 additions & 0 deletions frontend/js/app/controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down
8 changes: 8 additions & 0 deletions frontend/js/app/nginx/certificates/form.ejs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,14 @@
<input type="text" name="domain_names" class="form-control" id="input-domains" value="<%- domain_names.join(',') %>" required>
<div class="text-blue"><i class="fe fe-alert-triangle"></i> <%- i18n('ssl', 'hosts-warning') %></div>
</div>
<div class="mb-3 test-domains-container">
<button type="button" class="btn btn-secondary test-domains col-sm-12"><%- i18n('certificates', 'test-reachability') %></button>
<div class="text-secondary small">
<i class="fe fe-info"></i>
<%- i18n('certificates', 'reachability-info') %>
</div>
</div>
</div>
<div class="col-sm-12 col-md-12">
<div class="form-group">
Expand Down
27 changes: 26 additions & 1 deletion frontend/js/app/nginx/certificates/form.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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();
}
},

Expand Down Expand Up @@ -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)
},
Expand Down Expand Up @@ -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) {
Expand Down
3 changes: 3 additions & 0 deletions frontend/js/app/nginx/certificates/list/item.ejs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@
<% if (provider === 'letsencrypt') { %>
<a href="#" class="renew dropdown-item"><i class="dropdown-icon fe fe-refresh-cw"></i> <%- i18n('certificates', 'force-renew') %></a>
<a href="#" class="download dropdown-item"><i class="dropdown-icon fe fe-download"></i> <%- i18n('certificates', 'download') %></a>
<% if (meta.dns_challenge === false) { %>
<a href="#" class="test dropdown-item"><i class="dropdown-icon fe fe-globe"></i> <%- i18n('certificates', 'test-reachability') %></a>
<% } %>
<div class="dropdown-divider"></div>
<% } %>
<a href="#" class="delete dropdown-item"><i class="dropdown-icon fe fe-trash-2"></i> <%- i18n('str', 'delete') %></a>
Expand Down
16 changes: 11 additions & 5 deletions frontend/js/app/nginx/certificates/list/item.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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: {
Expand All @@ -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: {
Expand Down
15 changes: 15 additions & 0 deletions frontend/js/app/nginx/certificates/test.ejs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><%- i18n('certificates', 'reachability-title') %></h5>
</div>
<div class="modal-body">
<div class="waiting text-center">
<%= i18n('str', 'please-wait') %>
</div>
<div class="alert alert-danger error" role="alert"></div>
<div class="alert alert-success success" role="alert"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary cancel" disabled><%- i18n('str', 'close') %></button>
</div>
</div>
Loading