Browse Source

Certificates ui section and permissions

Jamie Curnow 7 years ago
parent
commit
1c57ccdc87
65 changed files with 1697 additions and 109 deletions
  1. 2 0
      docker-compose.yml
  2. 96 0
      rootfs/etc/nginx/mime.types
  3. 4 2
      rootfs/etc/services.d/nginx/run
  4. 4 0
      src/backend/index.js
  5. 183 0
      src/backend/internal/certificate.js
  6. 16 1
      src/backend/internal/dead-host.js
  7. 113 11
      src/backend/internal/nginx.js
  8. 18 1
      src/backend/internal/proxy-host.js
  9. 16 1
      src/backend/internal/redirection-host.js
  10. 2 1
      src/backend/internal/ssl.js
  11. 16 1
      src/backend/internal/stream.js
  12. 2 1
      src/backend/internal/user.js
  13. 2 1
      src/backend/lib/access.js
  14. 23 0
      src/backend/lib/access/certificates-create.json
  15. 23 0
      src/backend/lib/access/certificates-delete.json
  16. 23 0
      src/backend/lib/access/certificates-get.json
  17. 23 0
      src/backend/lib/access/certificates-list.json
  18. 23 0
      src/backend/lib/access/certificates-update.json
  19. 15 0
      src/backend/migrations/20180618015850_initial.js
  20. 52 0
      src/backend/models/certificate.js
  21. 1 0
      src/backend/routes/api/main.js
  22. 150 0
      src/backend/routes/api/nginx/certificates.js
  23. 4 0
      src/backend/schema/endpoints/users.json
  24. 2 1
      src/backend/setup.js
  25. 15 11
      src/backend/templates/dead_host.conf
  26. 4 5
      src/backend/templates/letsencrypt.conf
  27. 37 19
      src/backend/templates/proxy_host.conf
  28. 26 14
      src/backend/templates/redirection_host.conf
  29. 12 9
      src/backend/templates/stream.conf
  30. 37 0
      src/frontend/js/app/api.js
  31. 14 0
      src/frontend/js/app/controller.js
  32. 2 2
      src/frontend/js/app/dashboard/main.js
  33. 1 3
      src/frontend/js/app/nginx/access/delete.js
  34. 19 0
      src/frontend/js/app/nginx/certificates/delete.ejs
  35. 34 0
      src/frontend/js/app/nginx/certificates/delete.js
  36. 122 0
      src/frontend/js/app/nginx/certificates/form.ejs
  37. 195 0
      src/frontend/js/app/nginx/certificates/form.js
  38. 40 0
      src/frontend/js/app/nginx/certificates/list/item.ejs
  39. 35 0
      src/frontend/js/app/nginx/certificates/list/item.js
  40. 13 0
      src/frontend/js/app/nginx/certificates/list/main.ejs
  41. 34 0
      src/frontend/js/app/nginx/certificates/list/main.js
  42. 20 0
      src/frontend/js/app/nginx/certificates/main.ejs
  43. 83 0
      src/frontend/js/app/nginx/certificates/main.js
  44. 0 2
      src/frontend/js/app/nginx/dead/delete.js
  45. 11 0
      src/frontend/js/app/nginx/dead/list/item.ejs
  46. 9 1
      src/frontend/js/app/nginx/dead/list/item.js
  47. 1 0
      src/frontend/js/app/nginx/dead/list/main.ejs
  48. 0 2
      src/frontend/js/app/nginx/proxy/delete.js
  49. 11 0
      src/frontend/js/app/nginx/proxy/list/item.ejs
  50. 9 1
      src/frontend/js/app/nginx/proxy/list/item.js
  51. 1 0
      src/frontend/js/app/nginx/proxy/list/main.ejs
  52. 0 2
      src/frontend/js/app/nginx/redirection/delete.js
  53. 11 0
      src/frontend/js/app/nginx/redirection/list/item.ejs
  54. 9 1
      src/frontend/js/app/nginx/redirection/list/item.js
  55. 1 0
      src/frontend/js/app/nginx/redirection/list/main.ejs
  56. 0 2
      src/frontend/js/app/nginx/stream/delete.js
  57. 11 0
      src/frontend/js/app/nginx/stream/list/item.ejs
  58. 9 1
      src/frontend/js/app/nginx/stream/list/item.js
  59. 1 0
      src/frontend/js/app/nginx/stream/list/main.ejs
  60. 10 9
      src/frontend/js/app/router.js
  61. 6 1
      src/frontend/js/app/ui/menu/main.ejs
  62. 1 1
      src/frontend/js/app/user/permissions.ejs
  63. 2 1
      src/frontend/js/app/user/permissions.js
  64. 14 1
      src/frontend/js/i18n/messages.json
  65. 24 0
      src/frontend/js/models/certificate.js

+ 2 - 0
docker-compose.yml

@@ -1,3 +1,4 @@
+# WARNING: This is a DEVELOPMENT docker-compose file, it should not be used for production.
 version: "2"
 services:
   app:
@@ -12,6 +13,7 @@ services:
     volumes:
       - ./data/letsencrypt:/etc/letsencrypt
       - .:/app
+      - ./rootfs/etc/nginx:/etc/nginx
     working_dir: /app
     depends_on:
       - db

+ 96 - 0
rootfs/etc/nginx/mime.types

@@ -0,0 +1,96 @@
+types {
+    text/html                                        html htm shtml;
+    text/css                                         css;
+    text/xml                                         xml;
+    image/gif                                        gif;
+    image/jpeg                                       jpeg jpg;
+    application/javascript                           js;
+    application/atom+xml                             atom;
+    application/rss+xml                              rss;
+
+    text/mathml                                      mml;
+    text/plain                                       txt;
+    text/vnd.sun.j2me.app-descriptor                 jad;
+    text/vnd.wap.wml                                 wml;
+    text/x-component                                 htc;
+
+    image/png                                        png;
+    image/svg+xml                                    svg svgz;
+    image/tiff                                       tif tiff;
+    image/vnd.wap.wbmp                               wbmp;
+    image/webp                                       webp;
+    image/x-icon                                     ico;
+    image/x-jng                                      jng;
+    image/x-ms-bmp                                   bmp;
+
+    font/woff                                        woff;
+    font/woff2                                       woff2;
+
+    application/java-archive                         jar war ear;
+    application/json                                 json;
+    application/mac-binhex40                         hqx;
+    application/msword                               doc;
+    application/pdf                                  pdf;
+    application/postscript                           ps eps ai;
+    application/rtf                                  rtf;
+    application/vnd.apple.mpegurl                    m3u8;
+    application/vnd.google-earth.kml+xml             kml;
+    application/vnd.google-earth.kmz                 kmz;
+    application/vnd.ms-excel                         xls;
+    application/vnd.ms-fontobject                    eot;
+    application/vnd.ms-powerpoint                    ppt;
+    application/vnd.oasis.opendocument.graphics      odg;
+    application/vnd.oasis.opendocument.presentation  odp;
+    application/vnd.oasis.opendocument.spreadsheet   ods;
+    application/vnd.oasis.opendocument.text          odt;
+    application/vnd.openxmlformats-officedocument.presentationml.presentation
+                                                     pptx;
+    application/vnd.openxmlformats-officedocument.spreadsheetml.sheet
+                                                     xlsx;
+    application/vnd.openxmlformats-officedocument.wordprocessingml.document
+                                                     docx;
+    application/vnd.wap.wmlc                         wmlc;
+    application/x-7z-compressed                      7z;
+    application/x-cocoa                              cco;
+    application/x-java-archive-diff                  jardiff;
+    application/x-java-jnlp-file                     jnlp;
+    application/x-makeself                           run;
+    application/x-perl                               pl pm;
+    application/x-pilot                              prc pdb;
+    application/x-rar-compressed                     rar;
+    application/x-redhat-package-manager             rpm;
+    application/x-sea                                sea;
+    application/x-shockwave-flash                    swf;
+    application/x-stuffit                            sit;
+    application/x-tcl                                tcl tk;
+    application/x-x509-ca-cert                       der pem crt;
+    application/x-xpinstall                          xpi;
+    application/xhtml+xml                            xhtml;
+    application/xspf+xml                             xspf;
+    application/zip                                  zip;
+
+    application/octet-stream                         bin exe dll;
+    application/octet-stream                         deb;
+    application/octet-stream                         dmg;
+    application/octet-stream                         iso img;
+    application/octet-stream                         msi msp msm;
+
+    audio/midi                                       mid midi kar;
+    audio/mpeg                                       mp3;
+    audio/ogg                                        ogg;
+    audio/x-m4a                                      m4a;
+    audio/x-realaudio                                ra;
+
+    video/3gpp                                       3gpp 3gp;
+    video/mp2t                                       ts;
+    video/mp4                                        mp4;
+    video/mpeg                                       mpeg mpg;
+    video/quicktime                                  mov;
+    video/webm                                       webm;
+    video/x-flv                                      flv;
+    video/x-m4v                                      m4v;
+    video/x-mng                                      mng;
+    video/x-ms-asf                                   asx asf;
+    video/x-ms-wmv                                   wmv;
+    video/x-msvideo                                  avi;
+}

+ 4 - 2
rootfs/etc/services.d/nginx/run

@@ -1,10 +1,12 @@
 #!/usr/bin/with-contenv bash
 
-mkdir -p /tmp/nginx \
+mkdir -p /tmp/nginx/body \
+  /var/log/nginx \
   /data/{nginx,logs,access} \
   /data/nginx/{proxy_host,redirection_host,stream,dead_host} \
   /var/lib/nginx/cache/{public,private}
 
+touch /var/log/nginx/error.log && chmod 777 /var/log/nginx/error.log
 chown root /tmp/nginx
-exec nginx
 
+exec nginx

+ 4 - 0
src/backend/index.js

@@ -9,6 +9,7 @@ function appStart () {
     const setup        = require('./setup');
     const app          = require('./app');
     const apiValidator = require('./lib/validator/api');
+    const internalSsl  = require('./internal/ssl');
 
     return migrate.latest()
         .then(() => {
@@ -18,6 +19,9 @@ function appStart () {
             return apiValidator.loadSchemas;
         })
         .then(() => {
+
+            internalSsl.initTimer();
+
             const server = app.listen(81, () => {
                 logger.info('PID ' + process.pid + ' listening on port 81 ...');
 

+ 183 - 0
src/backend/internal/certificate.js

@@ -0,0 +1,183 @@
+'use strict';
+
+const _                = require('lodash');
+const error            = require('../lib/error');
+const certificateModel = require('../models/certificate');
+
+function omissions () {
+    return ['is_deleted'];
+}
+
+const internalCertificate = {
+
+    /**
+     * @param   {Access}  access
+     * @param   {Object}  data
+     * @returns {Promise}
+     */
+    create: (access, data) => {
+        return access.can('certificates:create', data)
+            .then(access_data => {
+                // TODO
+                return {};
+            });
+    },
+
+    /**
+     * @param  {Access}  access
+     * @param  {Object}  data
+     * @param  {Integer} data.id
+     * @param  {String}  [data.email]
+     * @param  {String}  [data.name]
+     * @return {Promise}
+     */
+    update: (access, data) => {
+        return access.can('certificates:update', data.id)
+            .then(access_data => {
+                // TODO
+                return {};
+            });
+    },
+
+    /**
+     * @param  {Access}   access
+     * @param  {Object}   data
+     * @param  {Integer}  data.id
+     * @param  {Array}    [data.expand]
+     * @param  {Array}    [data.omit]
+     * @return {Promise}
+     */
+    get: (access, data) => {
+        if (typeof data === 'undefined') {
+            data = {};
+        }
+
+        if (typeof data.id === 'undefined' || !data.id) {
+            data.id = access.token.get('attrs').id;
+        }
+
+        return access.can('certificates:get', data.id)
+            .then(access_data => {
+                let query = certificateModel
+                    .query()
+                    .where('is_deleted', 0)
+                    .andWhere('id', data.id)
+                    .allowEager('[owner]')
+                    .first();
+
+                if (access_data.permission_visibility !== 'all') {
+                    query.andWhere('owner_user_id', access.token.get('attrs').id);
+                }
+
+                // Custom omissions
+                if (typeof data.omit !== 'undefined' && data.omit !== null) {
+                    query.omit(data.omit);
+                }
+
+                if (typeof data.expand !== 'undefined' && data.expand !== null) {
+                    query.eager('[' + data.expand.join(', ') + ']');
+                }
+
+                return query;
+            })
+            .then(row => {
+                if (row) {
+                    return _.omit(row, omissions());
+                } else {
+                    throw new error.ItemNotFoundError(data.id);
+                }
+            });
+    },
+
+    /**
+     * @param   {Access}  access
+     * @param   {Object}  data
+     * @param   {Integer} data.id
+     * @param   {String}  [data.reason]
+     * @returns {Promise}
+     */
+    delete: (access, data) => {
+        return access.can('certificates:delete', data.id)
+            .then(() => {
+                return internalCertificate.get(access, {id: data.id});
+            })
+            .then(row => {
+                if (!row) {
+                    throw new error.ItemNotFoundError(data.id);
+                }
+
+                return certificateModel
+                    .query()
+                    .where('id', row.id)
+                    .patch({
+                        is_deleted: 1
+                    });
+            })
+            .then(() => {
+                return true;
+            });
+    },
+
+    /**
+     * All Lists
+     *
+     * @param   {Access}  access
+     * @param   {Array}   [expand]
+     * @param   {String}  [search_query]
+     * @returns {Promise}
+     */
+    getAll: (access, expand, search_query) => {
+        return access.can('certificates:list')
+            .then(access_data => {
+                let query = certificateModel
+                    .query()
+                    .where('is_deleted', 0)
+                    .groupBy('id')
+                    .omit(['is_deleted'])
+                    .allowEager('[owner]')
+                    .orderBy('name', 'ASC');
+
+                if (access_data.permission_visibility !== 'all') {
+                    query.andWhere('owner_user_id', access.token.get('attrs').id);
+                }
+
+                // Query is used for searching
+                if (typeof search_query === 'string') {
+                    query.where(function () {
+                        this.where('name', 'like', '%' + search_query + '%');
+                    });
+                }
+
+                if (typeof expand !== 'undefined' && expand !== null) {
+                    query.eager('[' + expand.join(', ') + ']');
+                }
+
+                return query;
+            });
+    },
+
+    /**
+     * Report use
+     *
+     * @param   {Integer} user_id
+     * @param   {String}  visibility
+     * @returns {Promise}
+     */
+    getCount: (user_id, visibility) => {
+        let query = certificateModel
+            .query()
+            .count('id as count')
+            .where('is_deleted', 0);
+
+        if (visibility !== 'all') {
+            query.andWhere('owner_user_id', user_id);
+        }
+
+        return query.first()
+            .then(row => {
+                return parseInt(row.count, 10);
+            });
+    }
+};
+
+module.exports = internalCertificate;

+ 16 - 1
src/backend/internal/dead-host.js

@@ -4,6 +4,7 @@ const _                = require('lodash');
 const error            = require('../lib/error');
 const deadHostModel    = require('../models/dead_host');
 const internalHost     = require('./host');
+const internalNginx    = require('./nginx');
 const internalAuditLog = require('./audit-log');
 
 function omissions () {
@@ -49,6 +50,13 @@ const internalDeadHost = {
                     .omit(omissions())
                     .insertAndFetch(data);
             })
+            .then(row => {
+                // Configure nginx
+                return internalNginx.configure(deadHostModel, 'dead_host', row)
+                    .then(() => {
+                        return internalDeadHost.get(access, {id: row.id, expand: ['owner']});
+                    });
+            })
             .then(row => {
                 // Add to audit log
                 return internalAuditLog.add(access, {
@@ -58,7 +66,7 @@ const internalDeadHost = {
                     meta:        data
                 })
                     .then(() => {
-                        return _.omit(row, omissions());
+                        return row;
                     });
             });
     },
@@ -192,6 +200,13 @@ const internalDeadHost = {
                     .patch({
                         is_deleted: 1
                     })
+                    .then(() => {
+                        // Delete Nginx Config
+                        return internalNginx.deleteConfig('dead_host', row)
+                            .then(() => {
+                                return internalNginx.reload();
+                            });
+                    })
                     .then(() => {
                         // Add to audit log
                         row.meta = internalHost.cleanMeta(row.meta);

+ 113 - 11
src/backend/internal/nginx.js

@@ -1,18 +1,94 @@
 'use strict';
 
-const fs     = require('fs');
-const Liquid = require('liquidjs');
-const logger = require('../logger').nginx;
-const utils  = require('../lib/utils');
-const error  = require('../lib/error');
+const _           = require('lodash');
+const fs          = require('fs');
+const Liquid      = require('liquidjs');
+const logger      = require('../logger').nginx;
+const utils       = require('../lib/utils');
+const error       = require('../lib/error');
+const internalSsl = require('./ssl');
+const debug_mode  = process.env.NODE_ENV !== 'production';
 
 const internalNginx = {
 
+    /**
+     * This will:
+     * - test the nginx config first to make sure it's OK
+     * - create / recreate the config for the host
+     * - test again
+     * - IF OK:  update the meta with online status
+     * - IF BAD: update the meta with offline status and remove the config entirely
+     * - then reload nginx
+     *
+     * @param   {Object}  model
+     * @param   {String}  host_type
+     * @param   {Object}  host
+     * @returns {Promise}
+     */
+    configure: (model, host_type, host) => {
+        return internalNginx.test()
+            .then(() => {
+                // Nginx is OK
+                // We're deleting this config regardless.
+                return internalNginx.deleteConfig(host_type, host); // Don't throw errors, as the file may not exist at all
+            })
+            .then(() => {
+                if (host.ssl && !internalSsl.hasValidSslCerts(host_type, host)) {
+                    return internalSsl.configureSsl(host_type, host);
+                }
+            })
+            .then(() => {
+                return internalNginx.generateConfig(host_type, host);
+            })
+            .then(() => {
+                // Test nginx again and update meta with result
+                return internalNginx.test()
+                    .then(() => {
+                        // nginx is ok
+                        return model
+                            .query()
+                            .where('id', host.id)
+                            .patch({
+                                meta: _.assign({}, host.meta, {
+                                    nginx_online: true,
+                                    nginx_err:    null
+                                })
+                            });
+                    })
+                    .catch(err => {
+
+                        if (debug_mode) {
+                            logger.error('Nginx test failed:', err.message);
+                        }
+
+                        // config is bad, update meta and delete config
+                        return model
+                            .query()
+                            .where('id', host.id)
+                            .patch({
+                                meta: _.assign({}, host.meta, {
+                                    nginx_online: false,
+                                    nginx_err:    err.message
+                                })
+                            })
+                            .then(() => {
+                                return internalNginx.deleteConfig(host_type, host, true);
+                            });
+                    });
+            })
+            .then(() => {
+                return internalNginx.reload();
+            });
+    },
+
     /**
      * @returns {Promise}
      */
     test: () => {
-        logger.info('Testing Nginx configuration');
+        if (debug_mode) {
+            logger.info('Testing Nginx configuration');
+        }
+
         return utils.exec('/usr/sbin/nginx -t');
     },
 
@@ -43,8 +119,13 @@ const internalNginx = {
      * @returns {Promise}
      */
     generateConfig: (host_type, host) => {
+        host_type = host_type.replace(new RegExp('-', 'g'), '_');
+
+        if (debug_mode) {
+            logger.info('Generating ' + host_type + ' Config:', host);
+        }
+
         let renderEngine = Liquid();
-        host_type        = host_type.replace(new RegExp('-', 'g'), '_');
 
         return new Promise((resolve, reject) => {
             let template = null;
@@ -56,14 +137,23 @@ const internalNginx = {
                 return;
             }
 
-            return renderEngine
+            renderEngine
                 .parseAndRender(template, host)
                 .then(config_text => {
                     fs.writeFileSync(filename, config_text, {encoding: 'utf8'});
-                    return true;
+
+                    if (debug_mode) {
+                        logger.success('Wrote config:', filename, config_text);
+                    }
+
+                    resolve(true);
                 })
                 .catch(err => {
-                    throw new error.ConfigurationError(err.message);
+                    if (debug_mode) {
+                        logger.warn('Could not write ' + filename + ':', err.message);
+                    }
+
+                    reject(new error.ConfigurationError(err.message));
                 });
         });
     },
@@ -75,10 +165,22 @@ const internalNginx = {
      * @returns {Promise}
      */
     deleteConfig: (host_type, host, throw_errors) => {
+        host_type = host_type.replace(new RegExp('-', 'g'), '_');
+
         return new Promise((resolve, reject) => {
             try {
-                fs.unlinkSync(internalNginx.getConfigName(host_type, host.id));
+                let config_file = internalNginx.getConfigName(host_type, host.id);
+
+                if (debug_mode) {
+                    logger.warn('Deleting nginx config: ' + config_file);
+                }
+
+                fs.unlinkSync(config_file);
             } catch (err) {
+                if (debug_mode) {
+                    logger.warn('Could not delete config:', err.message);
+                }
+
                 if (throw_errors) {
                     reject(err);
                 }

+ 18 - 1
src/backend/internal/proxy-host.js

@@ -4,6 +4,7 @@ const _                = require('lodash');
 const error            = require('../lib/error');
 const proxyHostModel   = require('../models/proxy_host');
 const internalHost     = require('./host');
+const internalNginx    = require('./nginx');
 const internalAuditLog = require('./audit-log');
 
 function omissions () {
@@ -50,6 +51,15 @@ const internalProxyHost = {
                     .insertAndFetch(data);
             })
             .then(row => {
+                // Configure nginx
+                return internalNginx.configure(proxyHostModel, 'proxy_host', row)
+                    .then(() => {
+                        return internalProxyHost.get(access, {id: row.id, expand: ['owner']});
+                    });
+            })
+            .then(row => {
+                data.meta = _.assign({}, data.meta || {}, row.meta);
+
                 // Add to audit log
                 return internalAuditLog.add(access, {
                     action:      'created',
@@ -58,7 +68,7 @@ const internalProxyHost = {
                     meta:        data
                 })
                     .then(() => {
-                        return _.omit(row, omissions());
+                        return row;
                     });
             });
     },
@@ -192,6 +202,13 @@ const internalProxyHost = {
                     .patch({
                         is_deleted: 1
                     })
+                    .then(() => {
+                        // Delete Nginx Config
+                        return internalNginx.deleteConfig('proxy_host', row)
+                            .then(() => {
+                                return internalNginx.reload();
+                            });
+                    })
                     .then(() => {
                         // Add to audit log
                         row.meta = internalHost.cleanMeta(row.meta);

+ 16 - 1
src/backend/internal/redirection-host.js

@@ -4,6 +4,7 @@ const _                    = require('lodash');
 const error                = require('../lib/error');
 const redirectionHostModel = require('../models/redirection_host');
 const internalHost         = require('./host');
+const internalNginx        = require('./nginx');
 const internalAuditLog     = require('./audit-log');
 
 function omissions () {
@@ -49,6 +50,13 @@ const internalRedirectionHost = {
                     .omit(omissions())
                     .insertAndFetch(data);
             })
+            .then(row => {
+                // Configure nginx
+                return internalNginx.configure(redirectionHostModel, 'redirection_host', row)
+                    .then(() => {
+                        return internalRedirectionHost.get(access, {id: row.id, expand: ['owner']});
+                    });
+            })
             .then(row => {
                 // Add to audit log
                 return internalAuditLog.add(access, {
@@ -58,7 +66,7 @@ const internalRedirectionHost = {
                     meta:        data
                 })
                     .then(() => {
-                        return _.omit(row, omissions());
+                        return row;
                     });
             });
     },
@@ -192,6 +200,13 @@ const internalRedirectionHost = {
                     .patch({
                         is_deleted: 1
                     })
+                    .then(() => {
+                        // Delete Nginx Config
+                        return internalNginx.deleteConfig('redirection_host', row)
+                            .then(() => {
+                                return internalNginx.reload();
+                            });
+                    })
                     .then(() => {
                         // Add to audit log
                         row.meta = internalHost.cleanMeta(row.meta);

+ 2 - 1
src/backend/internal/ssl.js

@@ -17,6 +17,7 @@ const internalSsl = {
     interval_processing: false,
 
     initTimer: () => {
+        logger.info('Let\'s Encrypt Renewal Timer initialized');
         internalSsl.interval = setInterval(internalSsl.processExpiringHosts, internalSsl.interval_timeout);
     },
 
@@ -51,7 +52,7 @@ const internalSsl = {
      */
     hasValidSslCerts: (host_type, host) => {
         host_type   = host_type.replace(new RegExp('-', 'g'), '_');
-        let le_path = '/etc/letsencrypt/live/' + host_type + '_' + host.id;
+        let le_path = '/etc/letsencrypt/live/' + host_type + '-' + host.id;
 
         return fs.existsSync(le_path + '/fullchain.pem') && fs.existsSync(le_path + '/privkey.pem');
     },

+ 16 - 1
src/backend/internal/stream.js

@@ -3,6 +3,7 @@
 const _                = require('lodash');
 const error            = require('../lib/error');
 const streamModel      = require('../models/stream');
+const internalNginx    = require('./nginx');
 const internalAuditLog = require('./audit-log');
 
 function omissions () {
@@ -31,6 +32,13 @@ const internalStream = {
                     .omit(omissions())
                     .insertAndFetch(data);
             })
+            .then(row => {
+                // Configure nginx
+                return internalNginx.configure(streamModel, 'stream', row)
+                    .then(() => {
+                        return internalStream.get(access, {id: row.id, expand: ['owner']});
+                    });
+            })
             .then(row => {
                 // Add to audit log
                 return internalAuditLog.add(access, {
@@ -40,7 +48,7 @@ const internalStream = {
                     meta:        data
                 })
                     .then(() => {
-                        return _.omit(row, omissions());
+                        return row;
                     });
             });
     },
@@ -153,6 +161,13 @@ const internalStream = {
                     .patch({
                         is_deleted: 1
                     })
+                    .then(() => {
+                        // Delete Nginx Config
+                        return internalNginx.deleteConfig('stream', row)
+                            .then(() => {
+                                return internalNginx.reload();
+                            });
+                    })
                     .then(() => {
                         // Add to audit log
                         return internalAuditLog.add(access, {

+ 2 - 1
src/backend/internal/user.js

@@ -70,7 +70,8 @@ const internalUser = {
                         redirection_hosts: 'manage',
                         dead_hosts:        'manage',
                         streams:           'manage',
-                        access_lists:      'manage'
+                        access_lists:      'manage',
+                        certificates:      'manage'
                     })
                     .then(() => {
                         return internalUser.get(access, {id: user.id, expand: ['permissions']});

+ 2 - 1
src/backend/lib/access.js

@@ -262,7 +262,8 @@ module.exports = function (token_string) {
                                         permission_redirection_hosts: permissions.redirection_hosts,
                                         permission_dead_hosts:        permissions.dead_hosts,
                                         permission_streams:           permissions.streams,
-                                        permission_access_lists:      permissions.access_lists
+                                        permission_access_lists:      permissions.access_lists,
+                                        permission_certificates:      permissions.certificates
                                     }
                                 };
 

+ 23 - 0
src/backend/lib/access/certificates-create.json

@@ -0,0 +1,23 @@
+{
+  "anyOf": [
+    {
+      "$ref": "roles#/definitions/admin"
+    },
+    {
+      "type": "object",
+      "required": ["permission_certificates", "roles"],
+      "properties": {
+        "permission_certificates": {
+          "$ref": "perms#/definitions/manage"
+        },
+        "roles": {
+          "type": "array",
+          "items": {
+            "type": "string",
+            "enum": ["user"]
+          }
+        }
+      }
+    }
+  ]
+}

+ 23 - 0
src/backend/lib/access/certificates-delete.json

@@ -0,0 +1,23 @@
+{
+  "anyOf": [
+    {
+      "$ref": "roles#/definitions/admin"
+    },
+    {
+      "type": "object",
+      "required": ["permission_certificates", "roles"],
+      "properties": {
+        "permission_certificates": {
+          "$ref": "perms#/definitions/manage"
+        },
+        "roles": {
+          "type": "array",
+          "items": {
+            "type": "string",
+            "enum": ["user"]
+          }
+        }
+      }
+    }
+  ]
+}

+ 23 - 0
src/backend/lib/access/certificates-get.json

@@ -0,0 +1,23 @@
+{
+  "anyOf": [
+    {
+      "$ref": "roles#/definitions/admin"
+    },
+    {
+      "type": "object",
+      "required": ["permission_certificates", "roles"],
+      "properties": {
+        "permission_certificates": {
+          "$ref": "perms#/definitions/view"
+        },
+        "roles": {
+          "type": "array",
+          "items": {
+            "type": "string",
+            "enum": ["user"]
+          }
+        }
+      }
+    }
+  ]
+}

+ 23 - 0
src/backend/lib/access/certificates-list.json

@@ -0,0 +1,23 @@
+{
+  "anyOf": [
+    {
+      "$ref": "roles#/definitions/admin"
+    },
+    {
+      "type": "object",
+      "required": ["permission_certificates", "roles"],
+      "properties": {
+        "permission_certificates": {
+          "$ref": "perms#/definitions/view"
+        },
+        "roles": {
+          "type": "array",
+          "items": {
+            "type": "string",
+            "enum": ["user"]
+          }
+        }
+      }
+    }
+  ]
+}

+ 23 - 0
src/backend/lib/access/certificates-update.json

@@ -0,0 +1,23 @@
+{
+  "anyOf": [
+    {
+      "$ref": "roles#/definitions/admin"
+    },
+    {
+      "type": "object",
+      "required": ["permission_certificates", "roles"],
+      "properties": {
+        "permission_certificates": {
+          "$ref": "perms#/definitions/manage"
+        },
+        "roles": {
+          "type": "array",
+          "items": {
+            "type": "string",
+            "enum": ["user"]
+          }
+        }
+      }
+    }
+  ]
+}

+ 15 - 0
src/backend/migrations/20180618015850_initial.js

@@ -55,6 +55,7 @@ exports.up = function (knex/*, Promise*/) {
                 table.string('dead_hosts').notNull();
                 table.string('streams').notNull();
                 table.string('access_lists').notNull();
+                table.string('certificates').notNull();
                 table.unique('user_id');
             });
         })
@@ -147,6 +148,20 @@ exports.up = function (knex/*, Promise*/) {
         .then(() => {
             logger.info('[' + migrate_name + '] access_list Table created');
 
+            return knex.schema.createTable('certificate', table => {
+                table.increments().primary();
+                table.dateTime('created_on').notNull();
+                table.dateTime('modified_on').notNull();
+                table.integer('owner_user_id').notNull().unsigned();
+                table.integer('is_deleted').notNull().unsigned().defaultTo(0);
+                table.string('name').notNull();
+                // TODO
+                table.json('meta').notNull();
+            });
+        })
+        .then(() => {
+            logger.info('[' + migrate_name + '] certificate Table created');
+
             return knex.schema.createTable('access_list_auth', table => {
                 table.increments().primary();
                 table.dateTime('created_on').notNull();

+ 52 - 0
src/backend/models/certificate.js

@@ -0,0 +1,52 @@
+// Objection Docs:
+// http://vincit.github.io/objection.js/
+
+'use strict';
+
+const db    = require('../db');
+const Model = require('objection').Model;
+const User  = require('./user');
+
+Model.knex(db);
+
+class Certificate extends Model {
+    $beforeInsert () {
+        this.created_on  = Model.raw('NOW()');
+        this.modified_on = Model.raw('NOW()');
+    }
+
+    $beforeUpdate () {
+        this.modified_on = Model.raw('NOW()');
+    }
+
+    static get name () {
+        return 'Certificate';
+    }
+
+    static get tableName () {
+        return 'certificate';
+    }
+
+    static get jsonAttributes () {
+        return ['meta'];
+    }
+
+    static get relationMappings () {
+        return {
+            owner: {
+                relation:   Model.HasOneRelation,
+                modelClass: User,
+                join:       {
+                    from: 'certificate.owner_user_id',
+                    to:   'user.id'
+                },
+                modify:     function (qb) {
+                    qb.where('user.is_deleted', 0);
+                    qb.omit(['id', 'created_on', 'modified_on', 'is_deleted', 'email', 'roles']);
+                }
+            }
+        };
+    }
+}
+
+module.exports = Certificate;

+ 1 - 0
src/backend/routes/api/main.js

@@ -36,6 +36,7 @@ router.use('/nginx/redirection-hosts', require('./nginx/redirection_hosts'));
 router.use('/nginx/dead-hosts', require('./nginx/dead_hosts'));
 router.use('/nginx/streams', require('./nginx/streams'));
 router.use('/nginx/access-lists', require('./nginx/access_lists'));
+router.use('/nginx/certificates', require('./nginx/certificates'));
 
 /**
  * API 404 for all other routes

+ 150 - 0
src/backend/routes/api/nginx/certificates.js

@@ -0,0 +1,150 @@
+'use strict';
+
+const express             = require('express');
+const validator           = require('../../../lib/validator');
+const jwtdecode           = require('../../../lib/express/jwt-decode');
+const internalCertificate = require('../../../internal/certificate');
+const apiValidator        = require('../../../lib/validator/api');
+
+let router = express.Router({
+    caseSensitive: true,
+    strict:        true,
+    mergeParams:   true
+});
+
+/**
+ * /api/nginx/certificates
+ */
+router
+    .route('/')
+    .options((req, res) => {
+        res.sendStatus(204);
+    })
+    .all(jwtdecode()) // preferred so it doesn't apply to nonexistent routes
+
+    /**
+     * GET /api/nginx/certificates
+     *
+     * Retrieve all certificates
+     */
+    .get((req, res, next) => {
+        validator({
+            additionalProperties: false,
+            properties:           {
+                expand: {
+                    $ref: 'definitions#/definitions/expand'
+                },
+                query:  {
+                    $ref: 'definitions#/definitions/query'
+                }
+            }
+        }, {
+            expand: (typeof req.query.expand === 'string' ? req.query.expand.split(',') : null),
+            query:  (typeof req.query.query === 'string' ? req.query.query : null)
+        })
+            .then(data => {
+                return internalCertificate.getAll(res.locals.access, data.expand, data.query);
+            })
+            .then(rows => {
+                res.status(200)
+                    .send(rows);
+            })
+            .catch(next);
+    })
+
+    /**
+     * POST /api/nginx/certificates
+     *
+     * Create a new certificate
+     */
+    .post((req, res, next) => {
+        apiValidator({$ref: 'endpoints/certificates#/links/1/schema'}, req.body)
+            .then(payload => {
+                return internalCertificate.create(res.locals.access, payload);
+            })
+            .then(result => {
+                res.status(201)
+                    .send(result);
+            })
+            .catch(next);
+    });
+
+/**
+ * Specific certificate
+ *
+ * /api/nginx/certificates/123
+ */
+router
+    .route('/:host_id')
+    .options((req, res) => {
+        res.sendStatus(204);
+    })
+    .all(jwtdecode()) // preferred so it doesn't apply to nonexistent routes
+
+    /**
+     * GET /api/nginx/certificates/123
+     *
+     * Retrieve a specific certificate
+     */
+    .get((req, res, next) => {
+        validator({
+            required:             ['host_id'],
+            additionalProperties: false,
+            properties:           {
+                host_id: {
+                    $ref: 'definitions#/definitions/id'
+                },
+                expand:  {
+                    $ref: 'definitions#/definitions/expand'
+                }
+            }
+        }, {
+            host_id: req.params.host_id,
+            expand:  (typeof req.query.expand === 'string' ? req.query.expand.split(',') : null)
+        })
+            .then(data => {
+                return internalCertificate.get(res.locals.access, {
+                    id:     parseInt(data.host_id, 10),
+                    expand: data.expand
+                });
+            })
+            .then(row => {
+                res.status(200)
+                    .send(row);
+            })
+            .catch(next);
+    })
+
+    /**
+     * PUT /api/nginx/certificates/123
+     *
+     * Update and existing certificate
+     */
+    .put((req, res, next) => {
+        apiValidator({$ref: 'endpoints/certificates#/links/2/schema'}, req.body)
+            .then(payload => {
+                payload.id = parseInt(req.params.host_id, 10);
+                return internalCertificate.update(res.locals.access, payload);
+            })
+            .then(result => {
+                res.status(200)
+                    .send(result);
+            })
+            .catch(next);
+    })
+
+    /**
+     * DELETE /api/nginx/certificates/123
+     *
+     * Update and existing certificate
+     */
+    .delete((req, res, next) => {
+        internalCertificate.delete(res.locals.access, {id: parseInt(req.params.host_id, 10)})
+            .then(result => {
+                res.status(200)
+                    .send(result);
+            })
+            .catch(next);
+    });
+
+module.exports = router;

+ 4 - 0
src/backend/schema/endpoints/users.json

@@ -243,6 +243,10 @@
           "streams": {
             "type": "string",
             "pattern": "^(hidden|view|manage)$"
+          },
+          "certificates": {
+            "type": "string",
+            "pattern": "^(hidden|view|manage)$"
           }
         }
       },

+ 2 - 1
src/backend/setup.js

@@ -91,7 +91,8 @@ module.exports = function () {
                                                 redirection_hosts: 'manage',
                                                 dead_hosts:        'manage',
                                                 streams:           'manage',
-                                                access_lists:      'manage'
+                                                access_lists:      'manage',
+                                                certificates:      'manage'
                                             });
                                     });
                             });

+ 15 - 11
src/backend/templates/dead_host.conf

@@ -1,19 +1,23 @@
-# <%- hostname %>
+# {{ domain_names | join: ", " }}
 server {
   listen 80;
-  <%- typeof ssl !== 'undefined' && ssl ? 'listen 443 ssl;' : '' %>
+  {%- if ssl_enabled == 1 or ssl_enabled == true -%}
+  listen 443 ssl;
+  {%- endif %}
+  server_name {{ domain_names | join: " " }};
+  access_log /data/logs/proxy_host-{{ id }}.log proxy;
 
-  server_name <%- hostname %>;
-
-  access_log /config/logs/<%- hostname %>.log proxy;
-
-<% if (typeof ssl !== 'undefined' && ssl) { -%>
+  {%- if ssl_enabled == 1 or ssl_enabled == true -%}
+  {%- if ssl_provider == "letsencrypt" %}
+  # Let's Encrypt SSL
+  include conf.d/include/letsencrypt-acme-challenge.conf;
   include conf.d/include/ssl-ciphers.conf;
-  ssl_certificate /etc/letsencrypt/live/<%- hostname %>/fullchain.pem;
-  ssl_certificate_key /etc/letsencrypt/live/<%- hostname %>/privkey.pem;
-<% } -%>
+  ssl_certificate /etc/letsencrypt/live/proxy_host-{{ id }}/fullchain.pem;
+  ssl_certificate_key /etc/letsencrypt/live/proxy_host-{{ id }}/privkey.pem;
+  {%- endif -%}
+  {%- endif %}
 
-  <%- typeof advanced !== 'undefined' && advanced ? advanced : '' %>
+  # TODO: Advanced config options
 
   return 404;
 }

+ 4 - 5
src/backend/templates/letsencrypt.conf

@@ -1,11 +1,10 @@
-# Letsencrypt Verification Temporary Host: <%- hostname %>
+# Letsencrypt Verification Temporary Host: {{ domain_names | join: ", " }}
 server {
   listen 80;
-  server_name <%- hostname %>;
-
-  access_log /config/logs/letsencrypt.log proxy;
+  server_name {{ domain_names | join: " " }};
+  access_log /data/logs/letsencrypt.log proxy;
 
   location / {
-    root /config/letsencrypt-acme-challenge;
+    root /data/letsencrypt-acme-challenge;
   }
 }

+ 37 - 19
src/backend/templates/proxy_host.conf

@@ -1,33 +1,51 @@
-# <%- hostname %>
+# {{ domain_names | join: ", " }}
 server {
   listen 80;
-  <%- typeof ssl !== 'undefined' && ssl ? 'listen 443 ssl;' : '' %>
+  {%- if ssl_enabled == 1 or ssl_enabled == true -%}
+  listen 443 ssl;
+  {%- endif %}
+  server_name {{ domain_names | join: " " }};
+  access_log /data/logs/proxy_host-{{ id }}.log proxy;
 
-  server_name <%- hostname %>;
+  set $server {{ forward_ip }};
+  set $port   {{ forward_port }};
 
-  access_log /config/logs/<%- hostname %>.log proxy;
+  {% if caching_enabled == 1 or caching_enabled == true -%}
+  # Asset Caching
+  include conf.d/include/assets.conf;
+  {%- endif %}
+  {% if block_exploits == 1 or block_exploits == true -%}
+  # Block Exploits
+  include conf.d/include/block-exploits.conf;
+  {%- endif -%}
 
-  set $server <%- forward_server %>;
-  set $port   <%- forward_port %>;
-
-  <%- typeof asset_caching !== 'undefined' && asset_caching ? 'include conf.d/include/assets.conf;' : '' %>
-  <%- typeof block_exploits !== 'undefined' && block_exploits ? 'include conf.d/include/block-exploits.conf;' : '' %>
-
-<% if (typeof ssl !== 'undefined' && ssl) { -%>
+  {%- if ssl_enabled == 1 or ssl_enabled == true -%}
+  {%- if ssl_provider == "letsencrypt" %}
+  # Let's Encrypt SSL
   include conf.d/include/letsencrypt-acme-challenge.conf;
   include conf.d/include/ssl-ciphers.conf;
-  ssl_certificate /etc/letsencrypt/live/<%- hostname %>/fullchain.pem;
-  ssl_certificate_key /etc/letsencrypt/live/<%- hostname %>/privkey.pem;
-<% } -%>
+  ssl_certificate /etc/letsencrypt/live/proxy_host-{{ id }}/fullchain.pem;
+  ssl_certificate_key /etc/letsencrypt/live/proxy_host-{{ id }}/privkey.pem;
+  {%- endif -%}
+  {%- endif %}
 
-<%- typeof advanced !== 'undefined' && advanced ? advanced : '' %>
+  # TODO: Advanced config options
 
   location / {
-    <% if (typeof access_list_id !== 'undefined' && access_list_id) { -%>
+    {%- if access_list_id > 0 -%}
+    # Access List
     auth_basic            "Authorization required";
-    auth_basic_user_file  /config/access/<%- access_list_id %>;
-    <% } -%>
-    <%- typeof force_ssl !== 'undefined' && force_ssl ? 'include conf.d/include/force-ssl.conf;' : '' %>
+    auth_basic_user_file  /config/access/{{ access_list_id }};
+    {%- endif %}
+
+    {%- if ssl_enabled == 1 or ssl_enabled == true -%}
+    {%- if ssl_forced == 1 or ssl_forced == true -%}
+    # Force SSL
+    include conf.d/include/force-ssl.conf;
+    {%- endif -%}
+    {%- endif %}
+
+    # Proxy!
     include conf.d/include/proxy.conf;
   }
 }

+ 26 - 14
src/backend/templates/redirection_host.conf

@@ -1,22 +1,34 @@
-# <%- hostname %>
+# {{ domain_names | join: ", " }}
 server {
   listen 80;
-  <%- typeof ssl !== 'undefined' && ssl ? 'listen 443 ssl;' : '' %>
+  {%- if ssl_enabled == 1 or ssl_enabled == true -%}
+  listen 443 ssl;
+  {%- endif %}
+  server_name {{ domain_names | join: " " }};
+  access_log /data/logs/proxy_host-{{ id }}.log proxy;
 
-  server_name <%- hostname %>;
+  {%- if caching_enabled == 1 or caching_enabled == true %}
+  # Asset Caching
+  include conf.d/include/assets.conf;
+  {%- endif %}
+  {%- if block_exploits == 1 or block_exploits == true %}
+  # Block Exploits
+  include conf.d/include/block-exploits.conf;
+  {%- endif -%}
 
-  access_log /config/logs/<%- hostname %>.log proxy;
-
-  <%- typeof asset_caching !== 'undefined' && asset_caching ? 'include conf.d/include/assets.conf;' : '' %>
-  <%- typeof block_exploits !== 'undefined' && block_exploits ? 'include conf.d/include/block-exploits.conf;' : '' %>
-
-<% if (typeof ssl !== 'undefined' && ssl) { -%>
+  {%- if ssl_enabled == 1 or ssl_enabled == true -%}
+  {%- if ssl_provider == "letsencrypt" %}
+  # Let's Encrypt SSL
+  include conf.d/include/letsencrypt-acme-challenge.conf;
   include conf.d/include/ssl-ciphers.conf;
-  ssl_certificate /etc/letsencrypt/live/<%- hostname %>/fullchain.pem;
-  ssl_certificate_key /etc/letsencrypt/live/<%- hostname %>/privkey.pem;
-<% } -%>
+  ssl_certificate /etc/letsencrypt/live/proxy_host-{{ id }}/fullchain.pem;
+  ssl_certificate_key /etc/letsencrypt/live/proxy_host-{{ id }}/privkey.pem;
+  {%- endif -%}
+  {%- endif %}
+
+  # TODO: Advanced config options
 
-  <%- typeof advanced !== 'undefined' && advanced ? advanced : '' %>
+  # TODO: Preserve Path Option
 
-  return 301 $scheme://<%- forward_host %>$request_uri;
+  return 301 $scheme://{{ forward_domain_name }}$request_uri;
 }

+ 12 - 9
src/backend/templates/stream.conf

@@ -1,11 +1,14 @@
-# <%- incoming_port %> - <%- protocols.join(',').toUpperCase() %>
-<%
-protocols.forEach(function (protocol) {
-%>
+# {{ incoming_port }} TCP: {{ tcp_forwarding }} UDP: {{ udp_forwarding }}
+
+{% if tcp_forwarding == 1 or tcp_forwarding == true -%}
 server {
-    listen <%- incoming_port %> <%- protocol === 'tcp' ? '' : protocol %>;
-    proxy_pass <%- forward_server %>:<%- forward_port %>;
+    listen {{ incoming_port }};
+    proxy_pass {{ forward_ip }}:{{ forwarding_port }};
 }
-<%
-});
-%>
+{% endif %}
+{% if udp_forwarding == 1 or udp_forwarding == true %}
+server {
+    listen {{ incoming_port }} udp;
+    proxy_pass {{ forward_ip }}:{{ forwarding_port }};
+}
+{% endif %}

+ 37 - 0
src/frontend/js/app/api.js

@@ -500,6 +500,43 @@ module.exports = {
                 return fetch('delete', 'nginx/access-lists/' + id);
             }
         },
+
+        Certificates: {
+            /**
+             * @param   {Array}    [expand]
+             * @param   {String}   [query]
+             * @returns {Promise}
+             */
+            getAll: function (expand, query) {
+                return getAllObjects('nginx/certificates', expand, query);
+            },
+
+            /**
+             * @param {Object}  data
+             */
+            create: function (data) {
+                return fetch('post', 'nginx/certificates', data);
+            },
+
+            /**
+             * @param   {Object}   data
+             * @param   {Integer}  data.id
+             * @returns {Promise}
+             */
+            update: function (data) {
+                let id = data.id;
+                delete data.id;
+                return fetch('put', 'nginx/certificates/' + id, data);
+            },
+
+            /**
+             * @param   {Integer}  id
+             * @returns {Promise}
+             */
+            delete: function (id) {
+                return fetch('delete', 'nginx/certificates/' + id);
+            }
+        }
     },
 
     AuditLog: {

+ 14 - 0
src/frontend/js/app/controller.js

@@ -319,6 +319,20 @@ module.exports = {
         }
     },
 
+    /**
+     * Nginx Certificates
+     */
+    showNginxCertificates: function () {
+        if (Cache.User.isAdmin() || Cache.User.canView('certificates')) {
+            let controller = this;
+
+            require(['./main', './nginx/certificates/main'], (App, View) => {
+                controller.navigate('/nginx/certificates');
+                App.UI.showAppContent(new View());
+            });
+        }
+    },
+
     /**
      * Audit Log
      */

+ 2 - 2
src/frontend/js/app/dashboard/main.js

@@ -10,7 +10,7 @@ const template   = require('./main.ejs');
 module.exports = Mn.View.extend({
     template: template,
     id:       'dashboard',
-    columns: 0,
+    columns:  0,
 
     stats: {},
 
@@ -46,7 +46,7 @@ module.exports = Mn.View.extend({
             },
 
             columns: view.columns
-        }
+        };
     },
 
     onRender: function () {

+ 1 - 3
src/frontend/js/app/nginx/access/delete.js

@@ -4,8 +4,6 @@ const Mn       = require('backbone.marionette');
 const App      = require('../../main');
 const template = require('./delete.ejs');
 
-require('jquery-serializejson');
-
 module.exports = Mn.View.extend({
     template:  template,
     className: 'modal-dialog',
@@ -22,7 +20,7 @@ module.exports = Mn.View.extend({
         'click @ui.save': function (e) {
             e.preventDefault();
 
-            App.Api.Nginx.ProxyHosts.delete(this.model.get('id'))
+            App.Api.Nginx.AccessLists.delete(this.model.get('id'))
                 .then(() => {
                     App.Controller.showNginxAccess();
                     App.UI.closeModal();

+ 19 - 0
src/frontend/js/app/nginx/certificates/delete.ejs

@@ -0,0 +1,19 @@
+<div class="modal-content">
+    <div class="modal-header">
+        <h5 class="modal-title"><%- i18n('certificates', 'delete') %></h5>
+        <button type="button" class="close cancel" aria-label="Close" data-dismiss="modal">&nbsp;</button>
+    </div>
+    <div class="modal-body">
+        <form>
+            <div class="row">
+                <div class="col-sm-12 col-md-12">
+                    <%= i18n('certificates', 'delete-confirm') %>
+                </div>
+            </div>
+        </form>
+    </div>
+    <div class="modal-footer">
+        <button type="button" class="btn btn-secondary cancel" data-dismiss="modal"><%- i18n('str', 'cancel') %></button>
+        <button type="button" class="btn btn-danger save"><%- i18n('str', 'sure') %></button>
+    </div>
+</div>

+ 34 - 0
src/frontend/js/app/nginx/certificates/delete.js

@@ -0,0 +1,34 @@
+'use strict';
+
+const Mn       = require('backbone.marionette');
+const App      = require('../../main');
+const template = require('./delete.ejs');
+
+module.exports = Mn.View.extend({
+    template:  template,
+    className: 'modal-dialog',
+
+    ui: {
+        form:    'form',
+        buttons: '.modal-footer button',
+        cancel:  'button.cancel',
+        save:    'button.save'
+    },
+
+    events: {
+
+        'click @ui.save': function (e) {
+            e.preventDefault();
+
+            App.Api.Nginx.Certificates.delete(this.model.get('id'))
+                .then(() => {
+                    App.Controller.showNginxCertificates();
+                    App.UI.closeModal();
+                })
+                .catch(err => {
+                    alert(err.message);
+                    this.ui.buttons.prop('disabled', false).removeClass('btn-disabled');
+                });
+        }
+    }
+});

+ 122 - 0
src/frontend/js/app/nginx/certificates/form.ejs

@@ -0,0 +1,122 @@
+<div class="modal-content">
+    <div class="modal-header">
+        <h5 class="modal-title"><%- i18n('certificates', 'form-title', {id: id}) %></h5>
+        <button type="button" class="close cancel" aria-label="Close" data-dismiss="modal">&nbsp;</button>
+    </div>
+    <div class="modal-body has-tabs">
+        <form>
+            <ul class="nav nav-tabs" role="tablist">
+                <li role="presentation" class="nav-item"><a href="#details" aria-controls="tab1" role="tab" data-toggle="tab" class="nav-link active"><i class="fe fe-zap"></i> <%- i18n('all-hosts', 'details') %></a></li>
+                <li role="presentation" class="nav-item"><a href="#ssl-options" aria-controls="tab2" role="tab" data-toggle="tab" class="nav-link"><i class="fe fe-shield"></i> <%- i18n('str', 'ssl') %></a></li>
+            </ul>
+            <div class="tab-content">
+                <!-- Details -->
+                <div role="tabpanel" class="tab-pane active" id="details">
+                    <div class="row">
+
+                        <div class="col-sm-12 col-md-12">
+                            <div class="form-group">
+                                <label class="form-label"><%- i18n('all-hosts', 'domain-names') %> <span class="form-required">*</span></label>
+                                <input type="text" name="domain_names" class="form-control" id="input-domains" value="<%- domain_names.join(',') %>" required>
+                            </div>
+                        </div>
+                        <div class="col-sm-8 col-md-8">
+                            <div class="form-group">
+                                <label class="form-label"><%- i18n('proxy-hosts', 'forward-ip') %><span class="form-required">*</span></label>
+                                <input type="text" name="forward_ip" class="form-control text-monospace" placeholder="000.000.000.000" value="<%- forward_ip %>" autocomplete="off" maxlength="15" required>
+                            </div>
+                        </div>
+                        <div class="col-sm-4 col-md-4">
+                            <div class="form-group">
+                                <label class="form-label"><%- i18n('proxy-hosts', 'forward-port') %> <span class="form-required">*</span></label>
+                                <input name="forward_port" type="number" class="form-control text-monospace" placeholder="80" value="<%- forward_port %>" required>
+                            </div>
+                        </div>
+                    </div>
+                </div>
+
+                <!-- SSL -->
+                <div role="tabpanel" class="tab-pane" id="ssl-options">
+                    <div class="row">
+                        <div class="col-sm-6 col-md-6">
+                            <div class="form-group">
+                                <label class="custom-switch">
+                                    <input type="checkbox" class="custom-switch-input" name="ssl_enabled" value="1"<%- ssl_enabled ? ' checked' : '' %>>
+                                    <span class="custom-switch-indicator"></span>
+                                    <span class="custom-switch-description"><%- i18n('all-hosts', 'enable-ssl') %></span>
+                                </label>
+                            </div>
+                        </div>
+                        <div class="col-sm-6 col-md-6">
+                            <div class="form-group">
+                                <label class="custom-switch">
+                                    <input type="checkbox" class="custom-switch-input" name="ssl_forced" value="1"<%- ssl_forced ? ' checked' : '' %><%- ssl_enabled ? '' : ' disabled' %>>
+                                    <span class="custom-switch-indicator"></span>
+                                    <span class="custom-switch-description"><%- i18n('all-hosts', 'force-ssl') %></span>
+                                </label>
+                            </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 -->
+                        <div class="col-sm-12 col-md-12 letsencrypt-ssl">
+                            <div class="form-group">
+                                <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>
+                            </div>
+                        </div>
+                        <div class="col-sm-12 col-md-12 letsencrypt-ssl">
+                            <div class="form-group">
+                                <label class="custom-switch">
+                                    <input type="checkbox" class="custom-switch-input" name="meta[letsencrypt_agree]" value="1" required<%- getLetsencryptAgree() ? ' checked' : '' %>>
+                                    <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>
+                                </label>
+                            </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>
+
+
+        </form>
+    </div>
+    <div class="modal-footer">
+        <button type="button" class="btn btn-secondary cancel" data-dismiss="modal"><%- i18n('str', 'cancel') %></button>
+        <button type="button" class="btn btn-teal save"><%- i18n('str', 'save') %></button>
+    </div>
+</div>

+ 195 - 0
src/frontend/js/app/nginx/certificates/form.js

@@ -0,0 +1,195 @@
+'use strict';
+
+const _                = require('underscore');
+const Mn               = require('backbone.marionette');
+const App              = require('../../main');
+const CertificateModel = require('../../../models/certificate');
+const template         = require('./form.ejs');
+
+require('jquery-serializejson');
+require('jquery-mask-plugin');
+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"]',
+        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: {
+        'change @ui.ssl_enabled': function () {
+            let enabled = this.ui.ssl_enabled.prop('checked');
+            this.ui.ssl_options.not(this.ui.ssl_enabled).prop('disabled', !enabled).parents('.form-group').css('opacity', enabled ? 1 : 0.5);
+            this.ui.ssl_provider.trigger('change');
+        },
+
+        'change @ui.ssl_provider': function () {
+            let enabled  = this.ui.ssl_enabled.prop('checked');
+            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) {
+            e.preventDefault();
+
+            if (!this.ui.form[0].checkValidity()) {
+                $('<input type="submit">').hide().appendTo(this.ui.form).click().remove();
+                return;
+            }
+
+            let view = this;
+            let data = this.ui.form.serializeJSON();
+
+            // Manipulate
+            data.forward_port = parseInt(data.forward_port, 10);
+            _.map(data, function (item, idx) {
+                if (typeof item === 'string' && item === '1') {
+                    item = true;
+                } else if (typeof item === 'object' && item !== null) {
+                    _.map(item, function (item2, idx2) {
+                        if (typeof item2 === 'string' && item2 === '1') {
+                            item[idx2] = true;
+                        }
+                    });
+                }
+                data[idx] = item;
+            });
+
+            if (typeof data.domain_names === 'string' && data.domain_names) {
+                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';
+            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 (is_new) {
+                            App.Controller.showNginxProxy();
+                        }
+                    });
+                })
+                .catch(err => {
+                    alert(err.message);
+                    this.ui.buttons.prop('disabled', false).removeClass('btn-disabled');
+                });
+        }
+    },
+
+    templateContext: {
+        getLetsencryptEmail: function () {
+            return typeof this.meta.letsencrypt_email !== 'undefined' ? this.meta.letsencrypt_email : App.Cache.User.get('email');
+        },
+
+        getLetsencryptAgree: function () {
+            return typeof this.meta.letsencrypt_agree !== 'undefined' ? this.meta.letsencrypt_agree : false;
+        }
+    },
+
+    onRender: function () {
+        this.ui.forward_ip.mask('099.099.099.099', {
+            clearIfNotMatch: true,
+            placeholder:     '000.000.000.000'
+        });
+
+        this.ui.ssl_enabled.trigger('change');
+        this.ui.ssl_provider.trigger('change');
+
+        this.ui.domain_names.selectize({
+            delimiter:    ',',
+            persist:      false,
+            maxOptions:   15,
+            create:       function (input) {
+                return {
+                    value: input,
+                    text:  input
+                };
+            },
+            createFilter: /^(?:\*\.)?(?:[^.*]+\.?)+[^.]$/
+        });
+    },
+
+    initialize: function (options) {
+        if (typeof options.model === 'undefined' || !options.model) {
+            this.model = new CertificateModel.Model();
+        }
+    }
+});

+ 40 - 0
src/frontend/js/app/nginx/certificates/list/item.ejs

@@ -0,0 +1,40 @@
+<td class="text-center">
+    <div class="avatar d-block" style="background-image: url(<%- owner.avatar || '/images/default-avatar.jpg' %>)" title="Owned by <%- owner.name %>">
+        <span class="avatar-status <%- owner.is_disabled ? 'bg-red' : 'bg-green' %>"></span>
+    </div>
+</td>
+<td>
+    <div>
+        <% domain_names.map(function(host) {
+        %>
+        <span class="tag"><%- host %></span>
+        <%
+        });
+        %>
+    </div>
+    <div class="small text-muted">
+        <%- i18n('str', 'created-on', {date: formatDbDate(created_on, 'Do MMMM YYYY')}) %>
+    </div>
+</td>
+<td>
+    <div class="text-monospace"><%- forward_ip %>:<%- forward_port %></div>
+</td>
+<td>
+    <div><%- ssl_enabled && ssl_provider ? i18n('ssl', ssl_provider) : i18n('ssl', 'none') %></div>
+</td>
+<td>
+    <div><%- access_list_id ? access_list.name : i18n('str', 'public') %></div>
+</td>
+<% if (canManage) { %>
+<td class="text-center">
+    <div class="item-action dropdown">
+        <a href="#" data-toggle="dropdown" class="icon"><i class="fe fe-more-vertical"></i></a>
+        <div class="dropdown-menu dropdown-menu-right">
+            <a href="#" class="edit dropdown-item"><i class="dropdown-icon fe fe-edit"></i> <%- i18n('str', 'edit') %></a>
+            <a href="#" class="logs dropdown-item"><i class="dropdown-icon fe fe-book"></i> <%- i18n('str', 'logs') %></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>
+        </div>
+    </div>
+</td>
+<% } %>

+ 35 - 0
src/frontend/js/app/nginx/certificates/list/item.js

@@ -0,0 +1,35 @@
+'use strict';
+
+const Mn       = require('backbone.marionette');
+const App      = require('../../../main');
+const template = require('./item.ejs');
+
+module.exports = Mn.View.extend({
+    template: template,
+    tagName:  'tr',
+
+    ui: {
+        edit:   'a.edit',
+        delete: 'a.delete'
+    },
+
+    events: {
+        'click @ui.edit': function (e) {
+            e.preventDefault();
+            App.Controller.showNginxCertificateForm(this.model);
+        },
+
+        'click @ui.delete': function (e) {
+            e.preventDefault();
+            App.Controller.showNginxCertificateDeleteConfirm(this.model);
+        }
+    },
+
+    templateContext: {
+        canManage: App.Cache.User.canManage('certificates')
+    },
+
+    initialize: function () {
+        this.listenTo(this.model, 'change', this.render);
+    }
+});

+ 13 - 0
src/frontend/js/app/nginx/certificates/list/main.ejs

@@ -0,0 +1,13 @@
+<thead>
+    <th width="30">&nbsp;</th>
+    <th><%- i18n('str', 'source') %></th>
+    <th><%- i18n('str', 'destination') %></th>
+    <th><%- i18n('str', 'ssl') %></th>
+    <th><%- i18n('str', 'access') %></th>
+    <% if (canManage) { %>
+    <th>&nbsp;</th>
+    <% } %>
+</thead>
+<tbody>
+    <!-- items -->
+</tbody>

+ 34 - 0
src/frontend/js/app/nginx/certificates/list/main.js

@@ -0,0 +1,34 @@
+'use strict';
+
+const Mn       = require('backbone.marionette');
+const App      = require('../../../main');
+const ItemView = require('./item');
+const template = require('./main.ejs');
+
+const TableBody = Mn.CollectionView.extend({
+    tagName:   'tbody',
+    childView: ItemView
+});
+
+module.exports = Mn.View.extend({
+    tagName:   'table',
+    className: 'table table-hover table-outline table-vcenter text-nowrap card-table',
+    template:  template,
+
+    regions: {
+        body: {
+            el:             'tbody',
+            replaceElement: true
+        }
+    },
+
+    templateContext: {
+        canManage: App.Cache.User.canManage('certificates')
+    },
+
+    onRender: function () {
+        this.showChildView('body', new TableBody({
+            collection: this.collection
+        }));
+    }
+});

+ 20 - 0
src/frontend/js/app/nginx/certificates/main.ejs

@@ -0,0 +1,20 @@
+<div class="card">
+    <div class="card-status bg-teal"></div>
+    <div class="card-header">
+        <h3 class="card-title"><%- i18n('certificates', 'title') %></h3>
+        <div class="card-options">
+            <a href="#" class="btn btn-outline-secondary btn-sm ml-2 help"><i class="fe fe-help-circle"></i></a>
+            <% if (showAddButton) { %>
+            <a href="#" class="btn btn-outline-teal btn-sm ml-2 add-item"><%- i18n('certificates', 'add') %></a>
+            <% } %>
+        </div>
+    </div>
+    <div class="card-body no-padding min-100">
+        <div class="dimmer active">
+            <div class="loader"></div>
+            <div class="dimmer-content list-region">
+                <!-- List Region -->
+            </div>
+        </div>
+    </div>
+</div>

+ 83 - 0
src/frontend/js/app/nginx/certificates/main.js

@@ -0,0 +1,83 @@
+'use strict';
+
+const Mn               = require('backbone.marionette');
+const App              = require('../../main');
+const CertificateModel = require('../../../models/certificate');
+const ListView         = require('./list/main');
+const ErrorView        = require('../../error/main');
+const EmptyView        = require('../../empty/main');
+const template         = require('./main.ejs');
+
+module.exports = Mn.View.extend({
+    id:       'nginx-certificates',
+    template: template,
+
+    ui: {
+        list_region: '.list-region',
+        add:         '.add-item',
+        help:        '.help',
+        dimmer:      '.dimmer'
+    },
+
+    regions: {
+        list_region: '@ui.list_region'
+    },
+
+    events: {
+        'click @ui.add': function (e) {
+            e.preventDefault();
+            App.Controller.showNginxCertificateForm();
+        },
+
+        'click @ui.help': function (e) {
+            e.preventDefault();
+            App.Controller.showHelp(App.i18n('certificates', 'help-title'), App.i18n('certificates', 'help-content'));
+        }
+    },
+
+    templateContext: {
+        showAddButton: App.Cache.User.canManage('certificates')
+    },
+
+    onRender: function () {
+        let view = this;
+
+        App.Api.Nginx.Certificates.getAll(['owner'])
+            .then(response => {
+                if (!view.isDestroyed()) {
+                    if (response && response.length) {
+                        view.showChildView('list_region', new ListView({
+                            collection: new CertificateModel.Collection(response)
+                        }));
+                    } else {
+                        let manage = App.Cache.User.canManage('certificates');
+
+                        view.showChildView('list_region', new EmptyView({
+                            title:      App.i18n('certificates', 'empty'),
+                            subtitle:   App.i18n('all-hosts', 'empty-subtitle', {manage: manage}),
+                            link:       manage ? App.i18n('certificates', 'add') : null,
+                            btn_color:  'teal',
+                            permission: 'certificates',
+                            action:     function () {
+                                App.Controller.showNginxCertificateForm();
+                            }
+                        }));
+                    }
+                }
+            })
+            .catch(err => {
+                view.showChildView('list_region', new ErrorView({
+                    code:    err.code,
+                    message: err.message,
+                    retry:   function () {
+                        App.Controller.showNginxCertificates();
+                    }
+                }));
+
+                console.error(err);
+            })
+            .then(() => {
+                view.ui.dimmer.removeClass('active');
+            });
+    }
+});

+ 0 - 2
src/frontend/js/app/nginx/dead/delete.js

@@ -4,8 +4,6 @@ const Mn       = require('backbone.marionette');
 const App      = require('../../main');
 const template = require('./delete.ejs');
 
-require('jquery-serializejson');
-
 module.exports = Mn.View.extend({
     template:  template,
     className: 'modal-dialog',

+ 11 - 0
src/frontend/js/app/nginx/dead/list/item.ejs

@@ -19,6 +19,17 @@
 <td>
     <div><%- ssl_enabled && ssl_provider ? i18n('ssl', ssl_provider) : i18n('ssl', 'none') %></div>
 </td>
+<td>
+    <%
+    var o = isOnline();
+    if (o === true) { %>
+        <span class="status-icon bg-success"></span> <%- i18n('str', 'online') %>
+    <% } else if (o === false) { %>
+        <span title="<%- getOfflineError() %>"><span class="status-icon bg-danger"></span> <%- i18n('str', 'offline') %></span>
+    <% } else { %>
+        <span class="status-icon bg-warning"></span> <%- i18n('str', 'unknown') %>
+    <% } %>
+</td>
 <% if (canManage) { %>
 <td class="text-center">
     <div class="item-action dropdown">

+ 9 - 1
src/frontend/js/app/nginx/dead/list/item.js

@@ -26,7 +26,15 @@ module.exports = Mn.View.extend({
     },
 
     templateContext: {
-        canManage: App.Cache.User.canManage('dead_hosts')
+        canManage: App.Cache.User.canManage('dead_hosts'),
+
+        isOnline: function () {
+            return typeof this.meta.nginx_online === 'undefined' ? null : this.meta.nginx_online;
+        },
+
+        getOfflineError: function () {
+            return this.meta.nginx_err || '';
+        }
     },
 
     initialize: function () {

+ 1 - 0
src/frontend/js/app/nginx/dead/list/main.ejs

@@ -2,6 +2,7 @@
     <th width="30">&nbsp;</th>
     <th><%- i18n('str', 'source') %></th>
     <th><%- i18n('str', 'ssl') %></th>
+    <th><%- i18n('str', 'status') %></th>
     <% if (canManage) { %>
     <th>&nbsp;</th>
     <% } %>

+ 0 - 2
src/frontend/js/app/nginx/proxy/delete.js

@@ -4,8 +4,6 @@ const Mn       = require('backbone.marionette');
 const App      = require('../../main');
 const template = require('./delete.ejs');
 
-require('jquery-serializejson');
-
 module.exports = Mn.View.extend({
     template:  template,
     className: 'modal-dialog',

+ 11 - 0
src/frontend/js/app/nginx/proxy/list/item.ejs

@@ -25,6 +25,17 @@
 <td>
     <div><%- access_list_id ? access_list.name : i18n('str', 'public') %></div>
 </td>
+<td>
+    <%
+    var o = isOnline();
+    if (o === true) { %>
+        <span class="status-icon bg-success"></span> <%- i18n('str', 'online') %>
+    <% } else if (o === false) { %>
+        <span title="<%- getOfflineError() %>"><span class="status-icon bg-danger"></span> <%- i18n('str', 'offline') %></span>
+    <% } else { %>
+        <span class="status-icon bg-warning"></span> <%- i18n('str', 'unknown') %>
+    <% } %>
+</td>
 <% if (canManage) { %>
 <td class="text-center">
     <div class="item-action dropdown">

+ 9 - 1
src/frontend/js/app/nginx/proxy/list/item.js

@@ -26,7 +26,15 @@ module.exports = Mn.View.extend({
     },
 
     templateContext: {
-        canManage: App.Cache.User.canManage('proxy_hosts')
+        canManage: App.Cache.User.canManage('proxy_hosts'),
+
+        isOnline: function () {
+            return typeof this.meta.nginx_online === 'undefined' ? null : this.meta.nginx_online;
+        },
+
+        getOfflineError: function () {
+            return this.meta.nginx_err || '';
+        }
     },
 
     initialize: function () {

+ 1 - 0
src/frontend/js/app/nginx/proxy/list/main.ejs

@@ -4,6 +4,7 @@
     <th><%- i18n('str', 'destination') %></th>
     <th><%- i18n('str', 'ssl') %></th>
     <th><%- i18n('str', 'access') %></th>
+    <th><%- i18n('str', 'status') %></th>
     <% if (canManage) { %>
     <th>&nbsp;</th>
     <% } %>

+ 0 - 2
src/frontend/js/app/nginx/redirection/delete.js

@@ -4,8 +4,6 @@ const Mn       = require('backbone.marionette');
 const App      = require('../../main');
 const template = require('./delete.ejs');
 
-require('jquery-serializejson');
-
 module.exports = Mn.View.extend({
     template:  template,
     className: 'modal-dialog',

+ 11 - 0
src/frontend/js/app/nginx/redirection/list/item.ejs

@@ -22,6 +22,17 @@
 <td>
     <div><%- ssl_enabled && ssl_provider ? i18n('ssl', ssl_provider) : i18n('ssl', 'none') %></div>
 </td>
+<td>
+    <%
+    var o = isOnline();
+    if (o === true) { %>
+        <span class="status-icon bg-success"></span> <%- i18n('str', 'online') %>
+    <% } else if (o === false) { %>
+        <span title="<%- getOfflineError() %>"><span class="status-icon bg-danger"></span> <%- i18n('str', 'offline') %></span>
+    <% } else { %>
+        <span class="status-icon bg-warning"></span> <%- i18n('str', 'unknown') %>
+    <% } %>
+</td>
 <% if (canManage) { %>
 <td class="text-center">
     <div class="item-action dropdown">

+ 9 - 1
src/frontend/js/app/nginx/redirection/list/item.js

@@ -26,7 +26,15 @@ module.exports = Mn.View.extend({
     },
 
     templateContext: {
-        canManage: App.Cache.User.canManage('redirection_hosts')
+        canManage: App.Cache.User.canManage('redirection_hosts'),
+
+        isOnline: function () {
+            return typeof this.meta.nginx_online === 'undefined' ? null : this.meta.nginx_online;
+        },
+
+        getOfflineError: function () {
+            return this.meta.nginx_err || '';
+        }
     },
 
     initialize: function () {

+ 1 - 0
src/frontend/js/app/nginx/redirection/list/main.ejs

@@ -3,6 +3,7 @@
     <th><%- i18n('str', 'source') %></th>
     <th><%- i18n('str', 'destination') %></th>
     <th><%- i18n('str', 'ssl') %></th>
+    <th><%- i18n('str', 'status') %></th>
     <% if (canManage) { %>
         <th>&nbsp;</th>
     <% } %>

+ 0 - 2
src/frontend/js/app/nginx/stream/delete.js

@@ -4,8 +4,6 @@ const Mn       = require('backbone.marionette');
 const App      = require('../../main');
 const template = require('./delete.ejs');
 
-require('jquery-serializejson');
-
 module.exports = Mn.View.extend({
     template:  template,
     className: 'modal-dialog',

+ 11 - 0
src/frontend/js/app/nginx/stream/list/item.ejs

@@ -24,6 +24,17 @@
         <% } %>
     </div>
 </td>
+<td>
+    <%
+    var o = isOnline();
+    if (o === true) { %>
+        <span class="status-icon bg-success"></span> <%- i18n('str', 'online') %>
+    <% } else if (o === false) { %>
+        <span title="<%- getOfflineError() %>"><span class="status-icon bg-danger"></span> <%- i18n('str', 'offline') %></span>
+    <% } else { %>
+        <span class="status-icon bg-warning"></span> <%- i18n('str', 'unknown') %>
+    <% } %>
+</td>
 <% if (canManage) { %>
 <td class="text-center">
     <div class="item-action dropdown">

+ 9 - 1
src/frontend/js/app/nginx/stream/list/item.js

@@ -26,7 +26,15 @@ module.exports = Mn.View.extend({
     },
 
     templateContext: {
-        canManage: App.Cache.User.canManage('streams')
+        canManage: App.Cache.User.canManage('streams'),
+
+        isOnline: function () {
+            return typeof this.meta.nginx_online === 'undefined' ? null : this.meta.nginx_online;
+        },
+
+        getOfflineError: function () {
+            return this.meta.nginx_err || '';
+        }
     },
 
     initialize: function () {

+ 1 - 0
src/frontend/js/app/nginx/stream/list/main.ejs

@@ -3,6 +3,7 @@
     <th><%- i18n('streams', 'incoming-port') %></th>
     <th><%- i18n('str', 'destination') %></th>
     <th><%- i18n('streams', 'protocol') %></th>
+    <th><%- i18n('str', 'status') %></th>
     <% if (canManage) { %>
     <th>&nbsp;</th>
     <% } %>

+ 10 - 9
src/frontend/js/app/router.js

@@ -5,15 +5,16 @@ const Controller = require('./controller');
 
 module.exports = Mn.AppRouter.extend({
     appRoutes: {
-        users:               'showUsers',
-        logout:              'logout',
-        'nginx/proxy':       'showNginxProxy',
-        'nginx/redirection': 'showNginxRedirection',
-        'nginx/404':         'showNginxDead',
-        'nginx/stream':      'showNginxStream',
-        'nginx/access':      'showNginxAccess',
-        'audit-log':         'showAuditLog',
-        '*default':          'showDashboard'
+        users:                'showUsers',
+        logout:               'logout',
+        'nginx/proxy':        'showNginxProxy',
+        'nginx/redirection':  'showNginxRedirection',
+        'nginx/404':          'showNginxDead',
+        'nginx/stream':       'showNginxStream',
+        'nginx/access':       'showNginxAccess',
+        'nginx/certificates': 'showNginxCertificates',
+        'audit-log':          'showAuditLog',
+        '*default':           'showDashboard'
     },
 
     initialize: function () {

+ 6 - 1
src/frontend/js/app/ui/menu/main.ejs

@@ -27,7 +27,12 @@
                 </li>
                 <% if (canShow('access_lists')) { %>
                 <li class="nav-item">
-                    <a href="/nginx/access" class="nav-link"><i class="fe fe-lock"></i> <%- i18n('access-lists', 'title') %></a>
+                    <a href="/nginx/access" class="nav-link"><i class="fe fe-shield"></i> <%- i18n('access-lists', 'title') %></a>
+                </li>
+                <% } %>
+                <% if (canShow('certificates')) { %>
+                <li class="nav-item">
+                    <a href="/nginx/certificates" class="nav-link"><i class="fe fe-lock"></i> <%- i18n('certificates', 'title') %></a>
                 </li>
                 <% } %>
                 <% if (isAdmin()) { %>

+ 1 - 1
src/frontend/js/app/user/permissions.ejs

@@ -31,7 +31,7 @@
                 </div>
 
                 <%
-                var list = ['proxy-hosts', 'redirection-hosts', 'dead-hosts', 'streams', 'access-lists'];
+                var list = ['proxy-hosts', 'redirection-hosts', 'dead-hosts', 'streams', 'access-lists', 'certificates'];
                 list.map(function(item) {
                     var perm = item.replace('-', '_');
                     %>

+ 2 - 1
src/frontend/js/app/user/permissions.js

@@ -35,7 +35,8 @@ module.exports = Mn.View.extend({
                     dead_hosts:        'manage',
                     proxy_hosts:       'manage',
                     redirection_hosts: 'manage',
-                    streams:           'manage'
+                    streams:           'manage',
+                    certificates:      'manage'
                 });
             }
 

+ 14 - 1
src/frontend/js/i18n/messages.json

@@ -23,7 +23,11 @@
       "public": "Public",
       "edit": "Edit",
       "delete": "Delete",
-      "logs": "Logs"
+      "logs": "Logs",
+      "status": "Status",
+      "online": "Online",
+      "offline": "Offline",
+      "unknown": "Unknown"
     },
     "login": {
       "title": "Login to your account"
@@ -127,6 +131,15 @@
       "help-title": "What is a Stream?",
       "help-content": "A relatively new feature for Nginx, a Stream will serve to forward TCP/UDP traffic directly to another computer on the network.\nIf you're running game servers, FTP or SSH servers this can come in handy."
     },
+    "certificates": {
+      "title": "Certificates",
+      "empty": "There are no Certificates",
+      "add": "Add Certificate",
+      "delete": "Delete Certificate",
+      "delete-confirm": "Are you sure you want to delete this certificate? Any hosts using it will need to be updated later.",
+      "help-title": "SSL Certificates",
+      "help-content": "TODO"
+    },
     "access-lists": {
       "title": "Access Lists",
       "empty": "There are no Access Lists",

+ 24 - 0
src/frontend/js/models/certificate.js

@@ -0,0 +1,24 @@
+'use strict';
+
+const Backbone = require('backbone');
+
+const model = Backbone.Model.extend({
+    idAttribute: 'id',
+
+    defaults: function () {
+        return {
+            id:              0,
+            created_on:      null,
+            modified_on:     null,
+            // The following are expansions:
+            owner:           null
+        };
+    }
+});
+
+module.exports = {
+    Model:      model,
+    Collection: Backbone.Collection.extend({
+        model: model
+    })
+};