Certificates polish

This commit is contained in:
Jamie Curnow 2018-08-13 19:50:28 +10:00
parent c8592503e3
commit 065727fba2
15 changed files with 563 additions and 256 deletions

View File

@ -5,9 +5,9 @@ const _ = require('lodash');
const error = require('../lib/error'); const error = require('../lib/error');
const certificateModel = require('../models/certificate'); const certificateModel = require('../models/certificate');
const internalAuditLog = require('./audit-log'); const internalAuditLog = require('./audit-log');
const internalHost = require('./host');
const tempWrite = require('temp-write'); const tempWrite = require('temp-write');
const utils = require('../lib/utils'); const utils = require('../lib/utils');
const moment = require('moment');
function omissions () { function omissions () {
return ['is_deleted']; return ['is_deleted'];
@ -15,6 +15,8 @@ function omissions () {
const internalCertificate = { const internalCertificate = {
allowed_ssl_files: ['certificate', 'certificate_key', 'intermediate_certificate'],
/** /**
* @param {Access} access * @param {Access} access
* @param {Object} data * @param {Object} data
@ -57,8 +59,39 @@ const internalCertificate = {
update: (access, data) => { update: (access, data) => {
return access.can('certificates:update', data.id) return access.can('certificates:update', data.id)
.then(access_data => { .then(access_data => {
// TODO return internalCertificate.get(access, {id: data.id});
return {}; })
.then(row => {
if (row.id !== data.id) {
// Sanity check that something crazy hasn't happened
throw new error.InternalValidationError('Certificate could not be updated, IDs do not match: ' + row.id + ' !== ' + data.id);
}
return certificateModel
.query()
.omit(omissions())
.patchAndFetchById(row.id, data)
.debug()
.then(saved_row => {
saved_row.meta = internalCertificate.cleanMeta(saved_row.meta);
data.meta = internalCertificate.cleanMeta(data.meta);
// Add row.nice_name for custom certs
if (saved_row.provider === 'other') {
data.nice_name = saved_row.nice_name;
}
// Add to audit log
return internalAuditLog.add(access, {
action: 'updated',
object_type: 'certificate',
object_id: row.id,
meta: _.omit(data, ['expires_on']) // this prevents json circular reference because expires_on might be raw
})
.then(() => {
return _.omit(saved_row, omissions());
});
});
}); });
}, },
@ -134,6 +167,17 @@ const internalCertificate = {
.where('id', row.id) .where('id', row.id)
.patch({ .patch({
is_deleted: 1 is_deleted: 1
})
.then(() => {
// Add to audit log
row.meta = internalCertificate.cleanMeta(row.meta);
return internalAuditLog.add(access, {
action: 'deleted',
object_type: 'certificate',
object_id: row.id,
meta: _.omit(row, omissions())
});
}); });
}) })
.then(() => { .then(() => {
@ -204,19 +248,18 @@ const internalCertificate = {
/** /**
* Validates that the certs provided are good. * Validates that the certs provided are good.
* This is probably a horrible way to do this. * No access required here, nothing is changed or stored.
* *
* @param {Access} access
* @param {Object} data * @param {Object} data
* @param {Object} data.files * @param {Object} data.files
* @returns {Promise} * @returns {Promise}
*/ */
validate: (access, data) => { validate: data => {
return new Promise(resolve => { return new Promise(resolve => {
// Put file contents into an object // Put file contents into an object
let files = {}; let files = {};
_.map(data.files, (file, name) => { _.map(data.files, (file, name) => {
if (internalHost.allowed_ssl_files.indexOf(name) !== -1) { if (internalCertificate.allowed_ssl_files.indexOf(name) !== -1) {
files[name] = file.data.toString(); files[name] = file.data.toString();
} }
}); });
@ -228,56 +271,26 @@ const internalCertificate = {
// Then test it depending on the file type // Then test it depending on the file type
let promises = []; let promises = [];
_.map(files, (content, type) => { _.map(files, (content, type) => {
promises.push(tempWrite(content, '/tmp') promises.push(new Promise((resolve, reject) => {
.then(filepath => {
if (type === 'certificate_key') { if (type === 'certificate_key') {
return utils.exec('openssl rsa -in ' + filepath + ' -check') resolve(internalCertificate.checkPrivateKey(content));
.then(result => {
return {tmp: filepath, result: result.split("\n").shift()};
}).catch(err => {
return {tmp: filepath, result: false, err: new error.ValidationError('Certificate Key is not valid')};
});
} else if (type === 'certificate') {
return utils.exec('openssl x509 -in ' + filepath + ' -text -noout')
.then(result => {
return {tmp: filepath, result: result};
}).catch(err => {
return {tmp: filepath, result: false, err: new error.ValidationError('Certificate is not valid')};
});
} else { } else {
return {tmp: filepath, result: false}; // this should handle `certificate` and intermediate certificate
resolve(internalCertificate.getCertificateInfo(content, true));
} }
}) }).then(res => {
.then(file_result => { return {[type]: res};
// Remove temp files }));
fs.unlinkSync(file_result.tmp);
delete file_result.tmp;
return {[type]: file_result};
})
);
}); });
// With the results, delete the temp files for security mainly.
// If there was an error with any of them, wait until we've done the deleting
// before throwing it.
return Promise.all(promises) return Promise.all(promises)
.then(files => { .then(files => {
let data = {}; let data = {};
let err = null;
_.each(files, file => { _.each(files, file => {
data = _.assign({}, data, file); data = _.assign({}, data, file);
if (typeof file.err !== 'undefined' && file.err) {
err = file.err;
}
}); });
if (err) {
throw err;
}
return data; return data;
}); });
}); });
@ -297,28 +310,159 @@ const internalCertificate = {
throw new error.ValidationError('Cannot upload certificates for this type of provider'); throw new error.ValidationError('Cannot upload certificates for this type of provider');
} }
return internalCertificate.validate(data)
.then(validations => {
if (typeof validations.certificate === 'undefined') {
throw new error.ValidationError('Certificate file was not provided');
}
_.map(data.files, (file, name) => { _.map(data.files, (file, name) => {
if (internalHost.allowed_ssl_files.indexOf(name) !== -1) { if (internalCertificate.allowed_ssl_files.indexOf(name) !== -1) {
row.meta[name] = file.data.toString(); row.meta[name] = file.data.toString();
} }
}); });
return internalCertificate.update(access, { return internalCertificate.update(access, {
id: data.id, id: data.id,
expires_on: certificateModel.raw('FROM_UNIXTIME(' + validations.certificate.dates.to + ')'),
domain_names: [validations.certificate.cn],
meta: row.meta meta: row.meta
}); });
}) })
.then(row => { .then(() => {
return internalAuditLog.add(access, { return _.pick(row.meta, internalCertificate.allowed_ssl_files);
action: 'updated', });
object_type: 'certificate', });
object_id: row.id, },
meta: data
/**
* Uses the openssl command to validate the private key.
* It will save the file to disk first, then run commands on it, then delete the file.
*
* @param {String} private_key This is the entire key contents as a string
*/
checkPrivateKey: private_key => {
return tempWrite(private_key, '/tmp')
.then(filepath => {
return utils.exec('openssl rsa -in ' + filepath + ' -check -noout')
.then(result => {
if (!result.toLowerCase().includes('key ok')) {
throw new error.ValidationError(result);
}
fs.unlinkSync(filepath);
return true;
}).catch(err => {
fs.unlinkSync(filepath);
throw new error.ValidationError('Certificate Key is not valid (' + err.message + ')', err);
});
});
},
/**
* Uses the openssl command to both validate and get info out of the certificate.
* It will save the file to disk first, then run commands on it, then delete the file.
*
* @param {String} certificate This is the entire cert contents as a string
* @param {Boolean} [throw_expired] Throw when the certificate is out of date
*/
getCertificateInfo: (certificate, throw_expired) => {
return tempWrite(certificate, '/tmp')
.then(filepath => {
let cert_data = {};
return utils.exec('openssl x509 -in ' + filepath + ' -subject -noout')
.then(result => {
// subject=CN = something.example.com
let regex = /(?:subject=)?[^=]+=\s+(\S+)/gim;
let match = regex.exec(result);
if (typeof match[1] === 'undefined') {
throw new error.ValidationError('Could not determine subject from certificate: ' + result);
}
cert_data['cn'] = match[1];
}) })
.then(() => { .then(() => {
return _.pick(row.meta, internalHost.allowed_ssl_files); return utils.exec('openssl x509 -in ' + filepath + ' -issuer -noout');
})
.then(result => {
// issuer=C = US, O = Let's Encrypt, CN = Let's Encrypt Authority X3
let regex = /^(?:issuer=)?(.*)$/gim;
let match = regex.exec(result);
if (typeof match[1] === 'undefined') {
throw new error.ValidationError('Could not determine issuer from certificate: ' + result);
}
cert_data['issuer'] = match[1];
})
.then(() => {
return utils.exec('openssl x509 -in ' + filepath + ' -dates -noout');
})
.then(result => {
// notBefore=Jul 14 04:04:29 2018 GMT
// notAfter=Oct 12 04:04:29 2018 GMT
let valid_from = null;
let valid_to = null;
let lines = result.split('\n');
lines.map(function (str) {
let regex = /^(\S+)=(.*)$/gim;
let match = regex.exec(str.trim());
if (match && typeof match[2] !== 'undefined') {
let date = parseInt(moment(match[2], 'MMM DD HH:mm:ss YYYY z').format('X'), 10);
if (match[1].toLowerCase() === 'notbefore') {
valid_from = date;
} else if (match[1].toLowerCase() === 'notafter') {
valid_to = date;
}
}
});
if (!valid_from || !valid_to) {
throw new error.ValidationError('Could not determine dates from certificate: ' + result);
}
if (throw_expired && valid_to < parseInt(moment().format('X'), 10)) {
throw new error.ValidationError('Certificate has expired');
}
cert_data['dates'] = {
from: valid_from,
to: valid_to
};
})
.then(() => {
fs.unlinkSync(filepath);
return cert_data;
}).catch(err => {
fs.unlinkSync(filepath);
throw new error.ValidationError('Certificate is not valid (' + err.message + ')', err);
}); });
}); });
},
/**
* Cleans the ssl keys from the meta object and sets them to "true"
*
* @param {Object} meta
* @param {Boolean} [remove]
* @returns {Object}
*/
cleanMeta: function (meta, remove) {
internalCertificate.allowed_ssl_files.map(key => {
if (typeof meta[key] !== 'undefined' && meta[key]) {
if (remove) {
delete meta[key];
} else {
meta[key] = true;
}
}
});
return meta;
} }
}; };

View File

@ -1,15 +1,11 @@
'use strict'; 'use strict';
const _ = require('lodash');
const error = require('../lib/error');
const proxyHostModel = require('../models/proxy_host'); const proxyHostModel = require('../models/proxy_host');
const redirectionHostModel = require('../models/redirection_host'); const redirectionHostModel = require('../models/redirection_host');
const deadHostModel = require('../models/dead_host'); const deadHostModel = require('../models/dead_host');
const internalHost = { const internalHost = {
allowed_ssl_files: ['certificate', 'certificate_key', 'intermediate_certificate'],
/** /**
* Internal use only, checks to see if the domain is already taken by any other record * Internal use only, checks to see if the domain is already taken by any other record
* *
@ -66,21 +62,6 @@ const internalHost = {
}); });
}, },
/**
* Cleans the ssl keys from the meta object and sets them to "true"
*
* @param {Object} meta
* @returns {*}
*/
cleanMeta: function (meta) {
internalHost.allowed_ssl_files.map(key => {
if (typeof meta[key] !== 'undefined' && meta[key]) {
meta[key] = true;
}
});
return meta;
},
/** /**
* Private call only * Private call only
* *

View File

@ -203,7 +203,7 @@ router
res.status(400) res.status(400)
.send({error: 'No files were uploaded'}); .send({error: 'No files were uploaded'});
} else { } else {
internalCertificate.validate(res.locals.access, { internalCertificate.validate({
files: req.files files: req.files
}) })
.then(result => { .then(result => {

View File

@ -47,7 +47,7 @@
items.push(meta.name); items.push(meta.name);
break; break;
case 'certificate': case 'certificate':
%> <span class="text-teal"><i class="fe fe-shield"></i></span> <% %> <span class="text-pink"><i class="fe fe-shield"></i></span> <%
if (meta.provider === 'letsencrypt') { if (meta.provider === 'letsencrypt') {
items = meta.domain_names; items = meta.domain_names;
} else { } else {

View File

@ -0,0 +1,18 @@
<div>
<% if (id === 'new') { %>
<div class="title">
<i class="fe fe-shield text-success"></i> Request a new SSL Certificate
</div>
<span class="description">with Let's Encrypt</span>
<% } else if (id > 0) { %>
<div class="title">
<i class="fe fe-shield text-pink"></i> <%- provider === 'other' ? nice_name : domain_names.join(', ') %>
</div>
<span class="description"><%- i18n('ssl', provider) %> &ndash; Expires: <%- formatDbDate(expires_on, 'Do MMMM YYYY, h:mm a') %></span>
<% } else { %>
<div class="title">
<i class="fe fe-shield-off text-danger"></i> None
</div>
<span class="description">This host will not use HTTPS</span>
<% } %>
</div>

View File

@ -1,12 +1,12 @@
<div class="card"> <div class="card">
<div class="card-status bg-teal"></div> <div class="card-status bg-pink"></div>
<div class="card-header"> <div class="card-header">
<h3 class="card-title"><%- i18n('certificates', 'title') %></h3> <h3 class="card-title"><%- i18n('certificates', 'title') %></h3>
<div class="card-options"> <div class="card-options">
<a href="#" class="btn btn-outline-secondary btn-sm ml-2 help"><i class="fe fe-help-circle"></i></a> <a href="#" class="btn btn-outline-secondary btn-sm ml-2 help"><i class="fe fe-help-circle"></i></a>
<% if (showAddButton) { %> <% if (showAddButton) { %>
<div class="dropdown"> <div class="dropdown">
<button type="button" class="btn btn-outline-teal btn-sm ml-2 dropdown-toggle" data-toggle="dropdown"> <button type="button" class="btn btn-outline-pink btn-sm ml-2 dropdown-toggle" data-toggle="dropdown">
<%- i18n('certificates', 'add') %> <%- i18n('certificates', 'add') %>
</button> </button>
<div class="dropdown-menu"> <div class="dropdown-menu">

View File

@ -57,7 +57,7 @@ module.exports = Mn.View.extend({
title: App.i18n('certificates', 'empty'), title: App.i18n('certificates', 'empty'),
subtitle: App.i18n('all-hosts', 'empty-subtitle', {manage: manage}), subtitle: App.i18n('all-hosts', 'empty-subtitle', {manage: manage}),
link: manage ? App.i18n('certificates', 'add') : null, link: manage ? App.i18n('certificates', 'add') : null,
btn_color: 'teal', btn_color: 'pink',
permission: 'certificates', permission: 'certificates',
action: function () { action: function () {
App.Controller.showNginxCertificateForm(); App.Controller.showNginxCertificateForm();

View File

@ -52,7 +52,7 @@
</div> </div>
<div class="col-sm-12 col-md-12"> <div class="col-sm-12 col-md-12">
<div class="form-group"> <div class="form-group">
<label class="form-label">Access List</label> <label class="form-label"><%- i18n('proxy-hosts', 'access-list') %></label>
<select name="access_list_id" class="form-control custom-select"> <select name="access_list_id" class="form-control custom-select">
<option value="0" selected="selected"><%- i18n('access-lists', 'public') %></option> <option value="0" selected="selected"><%- i18n('access-lists', 'public') %></option>
</select> </select>
@ -64,76 +64,41 @@
<!-- SSL --> <!-- SSL -->
<div role="tabpanel" class="tab-pane" id="ssl-options"> <div role="tabpanel" class="tab-pane" id="ssl-options">
<div class="row"> <div class="row">
<div class="col-sm-6 col-md-6"> <div class="col-sm-12 col-md-12">
<div class="form-group"> <div class="form-group">
<label class="custom-switch"> <label class="form-label">SSL Certificate</label>
<input type="checkbox" class="custom-switch-input" name="ssl_enabled" value="1"<%- ssl_enabled ? ' checked' : '' %>> <select name="certificate_id" class="form-control custom-select" placeholder="None">
<span class="custom-switch-indicator"></span> <option selected value="0" data-data="{&quot;id&quot;:0}" <%- certificate_id ? '' : 'selected' %>>None</option>
<span class="custom-switch-description"><%- i18n('all-hosts', 'enable-ssl') %></span> <option selected value="new" data-data="{&quot;id&quot;:&quot;new&quot;}">Request a new SSL Certificate</option>
</label> </select>
</div> </div>
</div> </div>
<div class="col-sm-6 col-md-6"> <div class="col-sm-12 col-md-12">
<div class="form-group"> <div class="form-group">
<label class="custom-switch"> <label class="custom-switch">
<input type="checkbox" class="custom-switch-input" name="ssl_forced" value="1"<%- ssl_forced ? ' checked' : '' %><%- ssl_enabled ? '' : ' disabled' %>> <input type="checkbox" class="custom-switch-input" name="ssl_forced" value="1"<%- ssl_forced ? ' checked' : '' %><%- certificate_id ? '' : ' disabled' %>>
<span class="custom-switch-indicator"></span> <span class="custom-switch-indicator"></span>
<span class="custom-switch-description"><%- i18n('all-hosts', 'force-ssl') %></span> <span class="custom-switch-description"><%- i18n('all-hosts', 'force-ssl') %></span>
</label> </label>
</div> </div>
</div> </div>
<div class="col-sm-12 col-md-12">
<div class="form-group">
<label class="form-label"><%- i18n('all-hosts', 'cert-provider') %></label>
<div class="selectgroup w-100">
<label class="selectgroup-item">
<input type="radio" name="ssl_provider" value="letsencrypt" class="selectgroup-input"<%- ssl_provider !== 'other' ? ' checked' : '' %>>
<span class="selectgroup-button"><%- i18n('ssl', 'letsencrypt') %></span>
</label>
<label class="selectgroup-item">
<input type="radio" name="ssl_provider" value="other" class="selectgroup-input"<%- ssl_provider === 'other' ? ' checked' : '' %>>
<span class="selectgroup-button"><%- i18n('ssl', 'other') %></span>
</label>
</div>
</div>
</div>
<!-- Lets encrypt --> <!-- Lets encrypt -->
<div class="col-sm-12 col-md-12 letsencrypt-ssl"> <div class="col-sm-12 col-md-12 letsencrypt">
<div class="form-group"> <div class="form-group">
<label class="form-label"><%- i18n('ssl', 'letsencrypt-email') %> <span class="form-required">*</span></label> <label class="form-label"><%- i18n('ssl', 'letsencrypt-email') %> <span class="form-required">*</span></label>
<input name="meta[letsencrypt_email]" type="email" class="form-control" placeholder="" value="<%- getLetsencryptEmail() %>" required> <input name="meta[letsencrypt_email]" type="email" class="form-control" placeholder="" value="<%- getLetsencryptEmail() %>" required>
</div> </div>
</div> </div>
<div class="col-sm-12 col-md-12 letsencrypt-ssl"> <div class="col-sm-12 col-md-12 letsencrypt">
<div class="form-group"> <div class="form-group">
<label class="custom-switch"> <label class="custom-switch">
<input type="checkbox" class="custom-switch-input" name="meta[letsencrypt_agree]" value="1" required<%- getLetsencryptAgree() ? ' checked' : '' %>> <input type="checkbox" class="custom-switch-input" name="meta[letsencrypt_agree]" value="1" required>
<span class="custom-switch-indicator"></span> <span class="custom-switch-indicator"></span>
<span class="custom-switch-description"><%= i18n('ssl', 'letsencrypt-agree', {url: 'https://letsencrypt.org/repository/'}) %> <span class="form-required">*</span></span> <span class="custom-switch-description"><%= i18n('ssl', 'letsencrypt-agree', {url: 'https://letsencrypt.org/repository/'}) %> <span class="form-required">*</span></span>
</label> </label>
</div> </div>
</div> </div>
<!-- Other -->
<div class="col-sm-12 col-md-12 other-ssl">
<div class="form-group">
<div class="form-label"><%- i18n('all-hosts', 'other-certificate') %></div>
<div class="custom-file">
<input type="file" class="custom-file-input" name="meta[other_ssl_certificate]" id="other_ssl_certificate">
<label class="custom-file-label"><%- i18n('str', 'choose-file') %></label>
</div>
</div>
</div>
<div class="col-sm-12 col-md-12 other-ssl">
<div class="form-group">
<div class="form-label"><%- i18n('all-hosts', 'other-certificate-key') %></div>
<div class="custom-file">
<input type="file" class="custom-file-input" name="meta[other_ssl_certificate_key]" id="other_ssl_certificate_key">
<label class="custom-file-label"><%- i18n('str', 'choose-file') %></label>
</div>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@ -1,10 +1,11 @@
'use strict'; 'use strict';
const _ = require('underscore');
const Mn = require('backbone.marionette'); const Mn = require('backbone.marionette');
const App = require('../../main'); const App = require('../../main');
const ProxyHostModel = require('../../../models/proxy-host'); const ProxyHostModel = require('../../../models/proxy-host');
const template = require('./form.ejs'); const template = require('./form.ejs');
const certListItemTemplate = require('../certificates-list-item.ejs');
const Helpers = require('../../../lib/helpers');
require('jquery-serializejson'); require('jquery-serializejson');
require('jquery-mask-plugin'); require('jquery-mask-plugin');
@ -22,30 +23,22 @@ module.exports = Mn.View.extend({
buttons: '.modal-footer button', buttons: '.modal-footer button',
cancel: 'button.cancel', cancel: 'button.cancel',
save: 'button.save', save: 'button.save',
ssl_enabled: 'input[name="ssl_enabled"]', certificate_select: 'select[name="certificate_id"]',
ssl_options: '#ssl-options input', ssl_options: '#ssl-options input',
ssl_provider: 'input[name="ssl_provider"]', letsencrypt: '.letsencrypt'
other_ssl_certificate: '#other_ssl_certificate',
other_ssl_certificate_key: '#other_ssl_certificate_key',
// SSL hiding and showing
all_ssl: '.letsencrypt-ssl, .other-ssl',
letsencrypt_ssl: '.letsencrypt-ssl',
other_ssl: '.other-ssl'
}, },
events: { events: {
'change @ui.ssl_enabled': function () { 'change @ui.certificate_select': function () {
let enabled = this.ui.ssl_enabled.prop('checked'); let id = this.ui.certificate_select.val();
this.ui.ssl_options.not(this.ui.ssl_enabled).prop('disabled', !enabled).parents('.form-group').css('opacity', enabled ? 1 : 0.5); if (id === 'new') {
this.ui.ssl_provider.trigger('change'); this.ui.letsencrypt.show().find('input').prop('disabled', false);
}, } else {
this.ui.letsencrypt.hide().find('input').prop('disabled', true);
}
'change @ui.ssl_provider': function () { let enabled = id === 'new' || parseInt(id, 10) > 0;
let enabled = this.ui.ssl_enabled.prop('checked'); this.ui.ssl_options.prop('disabled', !enabled).parents('.form-group').css('opacity', enabled ? 1 : 0.5);
let provider = this.ui.ssl_provider.filter(':checked').val();
this.ui.all_ssl.hide().find('input').prop('disabled', true);
this.ui[provider + '_ssl'].show().find('input').prop('disabled', !enabled);
}, },
'click @ui.save': function (e) { 'click @ui.save': function (e) {
@ -63,24 +56,31 @@ module.exports = Mn.View.extend({
data.forward_port = parseInt(data.forward_port, 10); data.forward_port = parseInt(data.forward_port, 10);
data.block_exploits = !!data.block_exploits; data.block_exploits = !!data.block_exploits;
data.caching_enabled = !!data.caching_enabled; data.caching_enabled = !!data.caching_enabled;
data.ssl_enabled = !!data.ssl_enabled;
data.ssl_forced = !!data.ssl_forced;
if (typeof data.meta !== 'undefined' && typeof data.meta.letsencrypt_agree !== 'undefined') {
data.meta.letsencrypt_agree = !!data.meta.letsencrypt_agree;
}
if (typeof data.domain_names === 'string' && data.domain_names) { if (typeof data.domain_names === 'string' && data.domain_names) {
data.domain_names = data.domain_names.split(','); data.domain_names = data.domain_names.split(',');
} }
let require_ssl_files = typeof data.ssl_enabled !== 'undefined' && data.ssl_enabled && typeof data.ssl_provider !== 'undefined' && data.ssl_provider === 'other'; // Check for any domain names containing wildcards, which are not allowed with letsencrypt
let ssl_files = []; if (data.certificate_id === 'new') {
let domain_err = false;
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');
return;
}
} else {
data.certificate_id = parseInt(data.certificate_id, 0);
}
let method = App.Api.Nginx.ProxyHosts.create; let method = App.Api.Nginx.ProxyHosts.create;
let is_new = true; let is_new = true;
let must_require_ssl_files = require_ssl_files && !view.model.hasSslFiles('other');
if (this.model.get('id')) { if (this.model.get('id')) {
// edit // edit
is_new = false; is_new = false;
@ -88,55 +88,11 @@ module.exports = Mn.View.extend({
data.id = this.model.get('id'); data.id = this.model.get('id');
} }
// check files are attached
if (require_ssl_files) {
if (!this.ui.other_ssl_certificate[0].files.length || !this.ui.other_ssl_certificate[0].files[0].size) {
if (must_require_ssl_files) {
alert('certificate file is not attached');
return;
}
} else {
if (this.ui.other_ssl_certificate[0].files[0].size > this.max_file_size) {
alert('certificate file is too large (> 5kb)');
return;
}
ssl_files.push({name: 'other_certificate', file: this.ui.other_ssl_certificate[0].files[0]});
}
if (!this.ui.other_ssl_certificate_key[0].files.length || !this.ui.other_ssl_certificate_key[0].files[0].size) {
if (must_require_ssl_files) {
alert('certificate key file is not attached');
return;
}
} else {
if (this.ui.other_ssl_certificate_key[0].files[0].size > this.max_file_size) {
alert('certificate key file is too large (> 5kb)');
return;
}
ssl_files.push({name: 'other_certificate_key', file: this.ui.other_ssl_certificate_key[0].files[0]});
}
}
this.ui.buttons.prop('disabled', true).addClass('btn-disabled'); this.ui.buttons.prop('disabled', true).addClass('btn-disabled');
method(data) method(data)
.then(result => { .then(result => {
view.model.set(result); view.model.set(result);
// Now upload the certs if we need to
if (ssl_files.length) {
let form_data = new FormData();
ssl_files.map(function (file) {
form_data.append(file.name, file.file);
});
return App.Api.Nginx.ProxyHosts.setCerts(view.model.get('id'), form_data)
.then(result => {
view.model.set('meta', _.assign({}, view.model.get('meta'), result));
});
}
})
.then(() => {
App.UI.closeModal(function () { App.UI.closeModal(function () {
if (is_new) { if (is_new) {
App.Controller.showNginxProxy(); App.Controller.showNginxProxy();
@ -152,23 +108,20 @@ module.exports = Mn.View.extend({
templateContext: { templateContext: {
getLetsencryptEmail: function () { getLetsencryptEmail: function () {
return typeof this.meta.letsencrypt_email !== 'undefined' ? this.meta.letsencrypt_email : App.Cache.User.get('email'); return App.Cache.User.get('email');
},
getLetsencryptAgree: function () {
return typeof this.meta.letsencrypt_agree !== 'undefined' ? this.meta.letsencrypt_agree : false;
} }
}, },
onRender: function () { onRender: function () {
let view = this;
// IP Address
this.ui.forward_ip.mask('099.099.099.099', { this.ui.forward_ip.mask('099.099.099.099', {
clearIfNotMatch: true, clearIfNotMatch: true,
placeholder: '000.000.000.000' placeholder: '000.000.000.000'
}); });
this.ui.ssl_enabled.trigger('change'); // Domain names
this.ui.ssl_provider.trigger('change');
this.ui.domain_names.selectize({ this.ui.domain_names.selectize({
delimiter: ',', delimiter: ',',
persist: false, persist: false,
@ -181,6 +134,37 @@ module.exports = Mn.View.extend({
}, },
createFilter: /^(?:\*\.)?(?:[^.*]+\.?)+[^.]$/ createFilter: /^(?:\*\.)?(?:[^.*]+\.?)+[^.]$/
}); });
// Certificates
this.ui.letsencrypt.hide();
this.ui.certificate_select.selectize({
valueField: 'id',
labelField: 'nice_name',
searchField: ['nice_name', 'domain_names'],
create: false,
preload: true,
allowEmptyOption: true,
render: {
option: function (item) {
item.i18n = App.i18n;
item.formatDbDate = Helpers.formatDbDate;
return certListItemTemplate(item);
}
},
load: function (query, callback) {
App.Api.Nginx.Certificates.getAll()
.then(rows => {
callback(rows);
})
.catch(err => {
console.error(err);
callback();
});
},
onLoad: function () {
view.ui.certificate_select[0].selectize.setValue(view.model.get('certificate_id'));
}
});
}, },
initialize: function (options) { initialize: function (options) {

View File

@ -88,7 +88,8 @@
"delete": "Delete Proxy Host", "delete": "Delete Proxy Host",
"delete-confirm": "Are you sure you want to delete the Proxy host for: <strong>{domains}</strong>?", "delete-confirm": "Are you sure you want to delete the Proxy host for: <strong>{domains}</strong>?",
"help-title": "What is a Proxy Host?", "help-title": "What is a Proxy Host?",
"help-content": "A Proxy Host is the incoming endpoint for a web service that you want to forward.\nIt provides optional SSL termination for your service that might not have SSL support built in.\nProxy Hosts are the most common use for the Nginx Proxy Manager." "help-content": "A Proxy Host is the incoming endpoint for a web service that you want to forward.\nIt provides optional SSL termination for your service that might not have SSL support built in.\nProxy Hosts are the most common use for the Nginx Proxy Manager.",
"access-list": "Access List"
}, },
"redirection-hosts": { "redirection-hosts": {
"title": "Redirection Hosts", "title": "Redirection Hosts",

View File

@ -1,6 +1,7 @@
'use strict'; 'use strict';
const numeral = require('numeral'); const numeral = require('numeral');
const moment = require('moment');
module.exports = { module.exports = {
@ -10,5 +11,18 @@ module.exports = {
*/ */
niceNumber: function (number) { niceNumber: function (number) {
return numeral(number).format('0,0'); return numeral(number).format('0,0');
},
/**
* @param {String|Number} date
* @param {String} format
* @returns {String}
*/
formatDbDate: function (date, format) {
if (typeof date === 'number') {
return moment.unix(date).format(format);
}
return moment(date).format(format);
} }
}; };

View File

@ -2,29 +2,15 @@
const _ = require('underscore'); const _ = require('underscore');
const Mn = require('backbone.marionette'); const Mn = require('backbone.marionette');
const moment = require('moment');
const i18n = require('../app/i18n'); const i18n = require('../app/i18n');
const Helpers = require('./helpers');
let render = Mn.Renderer.render; let render = Mn.Renderer.render;
Mn.Renderer.render = function (template, data, view) { Mn.Renderer.render = function (template, data, view) {
data = _.clone(data); data = _.clone(data);
data.i18n = i18n; data.i18n = i18n;
data.formatDbDate = Helpers.formatDbDate;
/**
* @param {String} date
* @param {String} format
* @returns {String}
*/
data.formatDbDate = function (date, format) {
if (typeof date === 'number') {
return moment.unix(date).format(format);
}
return moment(date).format(format);
};
return render.call(this, template, data, view); return render.call(this, template, data, view);
}; };

View File

@ -0,0 +1,192 @@
.selectize-dropdown-header {
position: relative;
padding: 5px 8px;
background: #f8f8f8;
border-bottom: 1px solid #d0d0d0;
-webkit-border-radius: 3px 3px 0 0;
-moz-border-radius: 3px 3px 0 0;
border-radius: 3px 3px 0 0;
}
.selectize-dropdown-header-close {
position: absolute;
top: 50%;
right: 8px;
margin-top: -12px;
font-size: 20px !important;
line-height: 20px;
color: #303030;
opacity: 0.4;
}
.selectize-dropdown-header-close:hover {
color: #000000;
}
.selectize-dropdown.plugin-optgroup_columns .optgroup {
float: left;
border-top: 0 none;
border-right: 1px solid #f2f2f2;
-webkit-box-sizing: border-box;
-moz-box-sizing: border-box;
box-sizing: border-box;
}
.selectize-dropdown.plugin-optgroup_columns .optgroup:last-child {
border-right: 0 none;
}
.selectize-dropdown.plugin-optgroup_columns .optgroup:before {
display: none;
}
.selectize-dropdown.plugin-optgroup_columns .optgroup-header {
border-top: 0 none;
}
.selectize-control.plugin-remove_button [data-value] {
position: relative;
padding-right: 24px !important;
}
.selectize-control.plugin-remove_button [data-value] .remove {
position: absolute;
top: 0;
right: 0;
bottom: 0;
display: inline-block;
width: 17px;
padding: 2px 0 0 0;
font-size: 12px;
font-weight: bold;
color: inherit;
text-align: center;
text-decoration: none;
vertical-align: middle;
border-left: 1px solid #0073bb;
-webkit-border-radius: 0 2px 2px 0;
-moz-border-radius: 0 2px 2px 0;
border-radius: 0 2px 2px 0;
-webkit-box-sizing: border-box;
-moz-box-sizing: border-box;
box-sizing: border-box;
}
.selectize-control.plugin-remove_button [data-value] .remove:hover {
background: rgba(0, 0, 0, 0.05);
}
.selectize-control.plugin-remove_button [data-value].active .remove {
border-left-color: #00578d;
}
.selectize-control.plugin-remove_button .disabled [data-value] .remove:hover {
background: none;
}
.selectize-control.plugin-remove_button .disabled [data-value] .remove {
border-left-color: #aaaaaa;
}
.selectize-control {
position: relative;
}
.selectize-dropdown {
font-family: inherit;
font-size: 13px;
-webkit-font-smoothing: inherit;
line-height: 18px;
color: #303030;
}
.selectize-control.single {
display: inline-block;
cursor: text;
background: #ffffff;
}
.selectize-dropdown {
position: absolute;
z-index: 10;
margin: -1px 0 0 0;
background: #ffffff;
border: 1px solid #d0d0d0;
border-top: 0 none;
-webkit-border-radius: 0 0 3px 3px;
-moz-border-radius: 0 0 3px 3px;
border-radius: 0 0 3px 3px;
-webkit-box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
-webkit-box-sizing: border-box;
-moz-box-sizing: border-box;
box-sizing: border-box;
}
.selectize-dropdown [data-selectable] {
overflow: hidden;
cursor: pointer;
}
.selectize-dropdown [data-selectable] .highlight {
background: rgba(125, 168, 208, 0.2);
-webkit-border-radius: 1px;
-moz-border-radius: 1px;
border-radius: 1px;
}
.selectize-dropdown [data-selectable],
.selectize-dropdown .optgroup-header {
padding: 5px 8px;
}
.selectize-dropdown .optgroup:first-child .optgroup-header {
border-top: 0 none;
}
.selectize-dropdown .optgroup-header {
color: #303030;
cursor: default;
background: #ffffff;
}
.selectize-dropdown .active {
color: #495c68;
background-color: #f5fafd;
}
.selectize-dropdown .active.create {
color: #495c68;
}
.selectize-dropdown .create {
color: rgba(48, 48, 48, 0.5);
}
.selectize-dropdown-content {
max-height: 200px;
overflow-x: hidden;
overflow-y: auto;
.title {
font-weight: bold;
}
.description {
padding-left: 16px;
}
}
.selectize-dropdown .optgroup-header {
padding-top: 7px;
font-size: 0.85em;
font-weight: bold;
}
.selectize-dropdown .optgroup {
border-top: 1px solid #f0f0f0;
}
.selectize-dropdown .optgroup:first-child {
border-top: 0 none;
}

View File

@ -1,5 +1,6 @@
@import "~tabler-ui/dist/assets/css/dashboard"; @import "~tabler-ui/dist/assets/css/dashboard";
@import "tabler-extra"; @import "tabler-extra";
@import "selectize";
@import "custom"; @import "custom";
/* Before any JS content is loaded */ /* Before any JS content is loaded */

View File

@ -1,6 +1,7 @@
$teal: #2bcbba; $teal: #2bcbba;
$yellow: #f1c40f; $yellow: #f1c40f;
$blue: #467fcf; $blue: #467fcf;
$pink: #f66d9b;
/* For Card bodies where I don't want padding */ /* For Card bodies where I don't want padding */
.card-body.no-padding { .card-body.no-padding {
@ -67,6 +68,26 @@ $blue: #467fcf;
border-color: $blue; border-color: $blue;
} }
/* Pink Outline Buttons */
.btn-outline-pink {
color: $pink;
background-color: transparent;
background-image: none;
border-color: $pink;
}
.btn-outline-pink:hover {
color: #fff;
background-color: $pink;
border-color: $pink;
}
.btn-outline-pink:not(:disabled):not(.disabled):active, .btn-outline-pink:not(:disabled):not(.disabled).active, .show > .btn-outline-pink.dropdown-toggle {
color: #fff;
background-color: $pink;
border-color: $pink;
}
/* dimmer */ /* dimmer */
.dimmer .loader { .dimmer .loader {