Эх сурвалжийг харах

SSL certificate upload support

Jamie Curnow 7 жил өмнө
parent
commit
a8d63d0df1

+ 2 - 0
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}));
 

+ 17 - 0
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
      *

+ 35 - 0
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;
             });
     },
 

+ 8 - 6
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);
                     });

+ 34 - 0
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;

+ 173 - 2
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);
         }
     },
 

+ 2 - 2
src/frontend/js/app/nginx/proxy/form.ejs

@@ -94,7 +94,7 @@
                             <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]">
+                                    <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>
@@ -103,7 +103,7 @@
                             <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]">
+                                    <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>

+ 66 - 13
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();
                         }
                     });

+ 10 - 1
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'];
     }
 });