From a8d63d0df1283b907c7057bfb78d5b438a35025f Mon Sep 17 00:00:00 2001 From: Jamie Curnow Date: Mon, 23 Jul 2018 15:12:24 +1000 Subject: [PATCH] SSL certificate upload support --- src/backend/app.js | 2 + src/backend/internal/host.js | 17 ++ src/backend/internal/proxy-host.js | 35 ++++ src/backend/lib/access.js | 14 +- src/backend/routes/api/nginx/proxy_hosts.js | 34 ++++ src/frontend/js/app/api.js | 175 +++++++++++++++++++- src/frontend/js/app/nginx/proxy/form.ejs | 4 +- src/frontend/js/app/nginx/proxy/form.js | 79 +++++++-- src/frontend/js/models/proxy-host.js | 11 +- 9 files changed, 347 insertions(+), 24 deletions(-) diff --git a/src/backend/app.js b/src/backend/app.js index 59f00e6b..e433013a 100644 --- a/src/backend/app.js +++ b/src/backend/app.js @@ -3,6 +3,7 @@ const path = require('path'); const express = require('express'); const bodyParser = require('body-parser'); +const fileUpload = require('express-fileupload'); const compression = require('compression'); const log = require('./logger').express; @@ -10,6 +11,7 @@ const log = require('./logger').express; * App */ const app = express(); +app.use(fileUpload()); app.use(bodyParser.json()); app.use(bodyParser.urlencoded({extended: true})); diff --git a/src/backend/internal/host.js b/src/backend/internal/host.js index 791954b5..3ba159b8 100644 --- a/src/backend/internal/host.js +++ b/src/backend/internal/host.js @@ -8,6 +8,8 @@ const deadHostModel = require('../models/dead_host'); const internalHost = { + allowed_ssl_files: ['other_certificate', 'other_certificate_key'], + /** * Internal use only, checks to see if the domain is already taken by any other record * @@ -64,6 +66,21 @@ 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 * diff --git a/src/backend/internal/proxy-host.js b/src/backend/internal/proxy-host.js index 39ba50b0..dbcb4d54 100644 --- a/src/backend/internal/proxy-host.js +++ b/src/backend/internal/proxy-host.js @@ -96,6 +96,7 @@ const internalProxyHost = { .omit(omissions()) .patchAndFetchById(row.id, data) .then(saved_row => { + saved_row.meta = internalHost.cleanMeta(saved_row.meta); return _.omit(saved_row, omissions()); }); }); @@ -144,6 +145,7 @@ const internalProxyHost = { }) .then(row => { if (row) { + row.meta = internalHost.cleanMeta(row.meta); return _.omit(row, omissions()); } else { throw new error.ItemNotFoundError(data.id); @@ -180,6 +182,32 @@ const internalProxyHost = { }); }, + /** + * @param {Access} access + * @param {Object} data + * @param {Integer} data.id + * @param {Object} data.files + * @returns {Promise} + */ + setCerts: (access, data) => { + return internalProxyHost.get(access, {id: data.id}) + .then(row => { + _.map(data.files, (file, name) => { + if (internalHost.allowed_ssl_files.indexOf(name) !== -1) { + row.meta[name] = file.data.toString(); + } + }); + + return internalProxyHost.update(access, { + id: data.id, + meta: row.meta + }); + }) + .then(row => { + return _.pick(row.meta, internalHost.allowed_ssl_files); + }); + }, + /** * All Hosts * @@ -215,6 +243,13 @@ const internalProxyHost = { } return query; + }) + .then(rows => { + rows.map(row => { + row.meta = internalHost.cleanMeta(row.meta); + }); + + return rows; }); }, diff --git a/src/backend/lib/access.js b/src/backend/lib/access.js index 4b403592..81dc768d 100644 --- a/src/backend/lib/access.js +++ b/src/backend/lib/access.js @@ -234,6 +234,8 @@ module.exports = function (token_string) { }); }, + reloadObjects: this.loadObjects, + /** * * @param {String} permission @@ -248,7 +250,6 @@ module.exports = function (token_string) { return this.init() .then(() => { // Initialised, token decoded ok - return this.getObjectSchema(permission) .then(objectSchema => { let data_schema = { @@ -275,9 +276,9 @@ module.exports = function (token_string) { permissionSchema.properties[permission] = require('./access/' + permission.replace(/:/gim, '-') + '.json'); - //logger.debug('objectSchema:', JSON.stringify(objectSchema, null, 2)); - //logger.debug('permissionSchema:', JSON.stringify(permissionSchema, null, 2)); - //logger.debug('data_schema:', JSON.stringify(data_schema, null, 2)); + // logger.info('objectSchema', JSON.stringify(objectSchema, null, 2)); + // logger.info('permissionSchema', JSON.stringify(permissionSchema, null, 2)); + // logger.info('data_schema', JSON.stringify(data_schema, null, 2)); let ajv = validator({ verbose: true, @@ -301,8 +302,9 @@ module.exports = function (token_string) { }); }) .catch(err => { - logger.error(err.message); - logger.error(err.errors); + err.permission = permission; + err.permission_data = data; + logger.error(permission, data, err.message); throw new error.PermissionError('Permission Denied', err); }); diff --git a/src/backend/routes/api/nginx/proxy_hosts.js b/src/backend/routes/api/nginx/proxy_hosts.js index ad69f003..da1dedef 100644 --- a/src/backend/routes/api/nginx/proxy_hosts.js +++ b/src/backend/routes/api/nginx/proxy_hosts.js @@ -147,4 +147,38 @@ router .catch(next); }); +/** + * Specific proxy-host Certificates + * + * /api/nginx/proxy-hosts/123/certificates + */ +router + .route('/:host_id/certificates') + .options((req, res) => { + res.sendStatus(204); + }) + .all(jwtdecode()) // preferred so it doesn't apply to nonexistent routes + + /** + * POST /api/nginx/proxy-hosts/123/certificates + * + * Upload certifications + */ + .post((req, res, next) => { + if (!req.files) { + res.status(400) + .send({error: 'No files were uploaded'}); + } else { + internalProxyHost.setCerts(res.locals.access, { + id: parseInt(req.params.host_id, 10), + files: req.files + }) + .then(result => { + res.status(200) + .send(result); + }) + .catch(next); + } + }); + module.exports = router; diff --git a/src/frontend/js/app/api.js b/src/frontend/js/app/api.js index 0cb7529a..0a80b292 100644 --- a/src/frontend/js/app/api.js +++ b/src/frontend/js/app/api.js @@ -43,14 +43,19 @@ function fetch (verb, path, data, options) { let url = api_url + path; let token = Tokens.getTopToken(); + if ((typeof options.contentType === 'undefined' || options.contentType.match(/json/im)) && typeof data === 'object') { + data = JSON.stringify(data); + } + $.ajax({ url: url, data: typeof data === 'object' ? JSON.stringify(data) : data, type: verb, dataType: 'json', - contentType: 'application/json; charset=UTF-8', + contentType: options.contentType || 'application/json; charset=UTF-8', + processData: options.processData || true, crossDomain: true, - timeout: (options.timeout ? options.timeout : 15000), + timeout: options.timeout ? options.timeout : 15000, xhrFields: { withCredentials: true }, @@ -123,6 +128,41 @@ function getAllObjects (path, expand, query) { return fetch('get', path + (params.length ? '?' + params.join('&') : '')); } +/** + * @param {String} path + * @param {FormData} form_data + * @returns {Promise} + */ +function upload (path, form_data) { + console.log('UPLOAD:', path, form_data); + return fetch('post', path, form_data, { + contentType: 'multipart/form-data', + processData: false + }); +} + +function FileUpload (path, fd) { + return new Promise((resolve, reject) => { + let xhr = new XMLHttpRequest(); + let token = Tokens.getTopToken(); + + xhr.open('POST', '/api/' + path); + xhr.overrideMimeType('text/plain'); + xhr.setRequestHeader('Authorization', 'Bearer ' + (token ? token.t : null)); + xhr.send(fd); + + xhr.onreadystatechange = function () { + if (this.readyState === XMLHttpRequest.DONE) { + if (xhr.status !== 200 && xhr.status !== 201) { + reject(new Error('Upload failed: ' + xhr.status)); + } else { + resolve(xhr.responseText); + } + } + }; + }); +} + module.exports = { status: function () { return fetch('get', ''); @@ -283,6 +323,15 @@ module.exports = { */ delete: function (id) { return fetch('delete', 'nginx/proxy-hosts/' + id); + }, + + /** + * @param {Integer} id + * @param {FormData} form_data + * @params {Promise} + */ + setCerts: function (id, form_data) { + return FileUpload('nginx/proxy-hosts/' + id + '/certificates', form_data); } }, @@ -294,6 +343,41 @@ module.exports = { */ getAll: function (expand, query) { return getAllObjects('nginx/redirection-hosts', expand, query); + }, + + /** + * @param {Object} data + */ + create: function (data) { + return fetch('post', 'nginx/redirection-hosts', data); + }, + + /** + * @param {Object} data + * @param {Integer} data.id + * @returns {Promise} + */ + update: function (data) { + let id = data.id; + delete data.id; + return fetch('put', 'nginx/redirection-hosts/' + id, data); + }, + + /** + * @param {Integer} id + * @returns {Promise} + */ + delete: function (id) { + return fetch('delete', 'nginx/redirection-hosts/' + id); + }, + + /** + * @param {Integer} id + * @param {FormData} form_data + * @params {Promise} + */ + setCerts: function (id, form_data) { + return upload('nginx/redirection-hosts/' + id + '/certificates', form_data); } }, @@ -305,6 +389,32 @@ module.exports = { */ getAll: function (expand, query) { return getAllObjects('nginx/streams', expand, query); + }, + + /** + * @param {Object} data + */ + create: function (data) { + return fetch('post', 'nginx/streams', data); + }, + + /** + * @param {Object} data + * @param {Integer} data.id + * @returns {Promise} + */ + update: function (data) { + let id = data.id; + delete data.id; + return fetch('put', 'nginx/streams/' + id, data); + }, + + /** + * @param {Integer} id + * @returns {Promise} + */ + delete: function (id) { + return fetch('delete', 'nginx/streams/' + id); } }, @@ -316,6 +426,41 @@ module.exports = { */ getAll: function (expand, query) { return getAllObjects('nginx/dead-hosts', expand, query); + }, + + /** + * @param {Object} data + */ + create: function (data) { + return fetch('post', 'nginx/dead-hosts', data); + }, + + /** + * @param {Object} data + * @param {Integer} data.id + * @returns {Promise} + */ + update: function (data) { + let id = data.id; + delete data.id; + return fetch('put', 'nginx/dead-hosts/' + id, data); + }, + + /** + * @param {Integer} id + * @returns {Promise} + */ + delete: function (id) { + return fetch('delete', 'nginx/dead-hosts/' + id); + }, + + /** + * @param {Integer} id + * @param {FormData} form_data + * @params {Promise} + */ + setCerts: function (id, form_data) { + return upload('nginx/dead-hosts/' + id + '/certificates', form_data); } } }, @@ -328,6 +473,32 @@ module.exports = { */ getAll: function (expand, query) { return getAllObjects('access-lists', expand, query); + }, + + /** + * @param {Object} data + */ + create: function (data) { + return fetch('post', 'access-lists', data); + }, + + /** + * @param {Object} data + * @param {Integer} data.id + * @returns {Promise} + */ + update: function (data) { + let id = data.id; + delete data.id; + return fetch('put', 'access-lists/' + id, data); + }, + + /** + * @param {Integer} id + * @returns {Promise} + */ + delete: function (id) { + return fetch('delete', 'access-lists/' + id); } }, diff --git a/src/frontend/js/app/nginx/proxy/form.ejs b/src/frontend/js/app/nginx/proxy/form.ejs index 3674a4fe..ebfeafef 100644 --- a/src/frontend/js/app/nginx/proxy/form.ejs +++ b/src/frontend/js/app/nginx/proxy/form.ejs @@ -94,7 +94,7 @@
<%- i18n('all-hosts', 'other-certificate') %>
- +
@@ -103,7 +103,7 @@
<%- i18n('all-hosts', 'other-certificate-key') %>
- +
diff --git a/src/frontend/js/app/nginx/proxy/form.js b/src/frontend/js/app/nginx/proxy/form.js index 494caf9e..3c2ca76b 100644 --- a/src/frontend/js/app/nginx/proxy/form.js +++ b/src/frontend/js/app/nginx/proxy/form.js @@ -13,17 +13,20 @@ require('selectize'); module.exports = Mn.View.extend({ template: template, className: 'modal-dialog', + max_file_size: 5120, ui: { - form: 'form', - domain_names: 'input[name="domain_names"]', - forward_ip: 'input[name="forward_ip"]', - buttons: '.modal-footer button', - cancel: 'button.cancel', - save: 'button.save', - ssl_enabled: 'input[name="ssl_enabled"]', - ssl_options: '#ssl-options input', - ssl_provider: 'input[name="ssl_provider"]', + form: 'form', + domain_names: 'input[name="domain_names"]', + forward_ip: 'input[name="forward_ip"]', + buttons: '.modal-footer button', + cancel: 'button.cancel', + save: 'button.save', + ssl_enabled: 'input[name="ssl_enabled"]', + ssl_options: '#ssl-options input', + ssl_provider: 'input[name="ssl_provider"]', + other_ssl_certificate: '#other_ssl_certificate', + other_ssl_certificate_key: '#other_ssl_certificate_key', // SSL hiding and showing all_ssl: '.letsencrypt-ssl, .other-ssl', @@ -75,21 +78,71 @@ module.exports = Mn.View.extend({ data.domain_names = data.domain_names.split(','); } - // Process - this.ui.buttons.prop('disabled', true).addClass('btn-disabled'); - let method = App.Api.Nginx.ProxyHosts.create; + let require_ssl_files = typeof data.ssl_enabled !== 'undefined' && data.ssl_enabled && typeof data.ssl_provider !== 'undefined' && data.ssl_provider === 'other'; + let ssl_files = []; + let method = App.Api.Nginx.ProxyHosts.create; + let is_new = true; + + let must_require_ssl_files = require_ssl_files && !view.model.hasSslFiles('other'); if (this.model.get('id')) { // edit + is_new = false; method = App.Api.Nginx.ProxyHosts.update; 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'); method(data) .then(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 () { - if (method === App.Api.Nginx.ProxyHosts.create) { + if (is_new) { App.Controller.showNginxProxy(); } }); diff --git a/src/frontend/js/models/proxy-host.js b/src/frontend/js/models/proxy-host.js index 86c99a0b..1127208a 100644 --- a/src/frontend/js/models/proxy-host.js +++ b/src/frontend/js/models/proxy-host.js @@ -19,11 +19,20 @@ const model = Backbone.Model.extend({ ssl_forced: false, caching_enabled: false, block_exploits: false, - meta: [], + meta: {}, // The following are expansions: owner: null, access_list: null }; + }, + + /** + * @param {String} type 'letsencrypt' or 'other' + * @returns {Boolean} + */ + hasSslFiles: function (type) { + let meta = this.get('meta'); + return typeof meta[type + '_certificate'] !== 'undefined' && meta[type + '_certificate'] && typeof meta[type + '_certificate_key'] !== 'undefined' && meta[type + '_certificate_key']; } });