Ver Fonte

Access polish, import v1 stsarted

Jamie Curnow há 7 anos atrás
pai
commit
8d925deeb0

+ 2 - 0
.gitignore

@@ -10,3 +10,5 @@ data/*
 yarn-error.log
 yarn.lock
 tmp
+certbot.log
+

+ 7 - 7
README.md

@@ -6,11 +6,15 @@
 ![Stars](https://img.shields.io/docker/stars/jc21/nginx-proxy-manager.svg?style=for-the-badge)
 ![Pulls](https://img.shields.io/docker/pulls/jc21/nginx-proxy-manager.svg?style=for-the-badge)
 
-**NOTE: Version 2 is a work in progress. Not all of the areas are complete and is definitely not ready for production use.**
-
 This project comes as a pre-built docker image that enables you to easily forward to your websites
 running at home or otherwise, including free SSL, without having to know too much about Nginx or Letsencrypt.
 
+----------
+
+**WARNING: Version 2 a complete rewrite!** If you are using the `latest` docker tag and update to version 2
+without preparation, horrible things might happen. Refer to the [Migrating Documentation](doc/MIGRATING.md). 
+
+----------
  
 ## Features
 
@@ -18,13 +22,9 @@ running at home or otherwise, including free SSL, without having to know too muc
 - Easily create forwarding domains, redirections, streams and 404 hosts without knowing anything about Nginx
 - Free SSL using Let's Encrypt or provide your own custom SSL certificates 
 - Access Lists and basic HTTP Authentication for your hosts
-- Advanced Nginx configuration available for super users
+- -Advanced Nginx configuration available for super users- TODO
 - User management, permissions and audit log
 
-#### Future Features
-
-- Live log tail
-
 
 ## Screenshots
 

+ 4 - 4
TODO.md

@@ -2,16 +2,16 @@
 
 In order of importance, somewhat.. 
 
-- Manual certificate writing to disk and usage in nginx configs - MIGRATING.md
-- Access Lists UI and Nginx usage
-- Make modal dialogs unclosable in overlay
+- Custom ssl certificate saving to disk and usage in nginx configs
 - Dashboard stats are caching instead of querying
 - Create a nice way of importing from v1 let's encrypt certs and config data
 - UI Log tail
+- Custom Nginx Config Editor
 
-Testing
+Testing:
 
 - Access Levels
+  - Adding a proxy host without access to read certs or access lists 
 - Visibility
 - Forwarding
 - Cert renewals

+ 23 - 19
doc/INSTALL.md

@@ -3,7 +3,6 @@
 There's a few ways to configure this app depending on:
 
 - Whether you use `docker-compose` or vanilla docker
-- Which Database you want to use (mysql or postgres)
 - Which architecture you're running it on (raspberry pi also supported)
 
 ### Configuration File
@@ -12,9 +11,9 @@ There's a few ways to configure this app depending on:
 
 Don't worry, this is easy to do.
 
-The app requires a configuration file to let it know what database you're using and where it is.
+The app requires a configuration file to let it know what database you're using.
 
-Here's an example configuration for `mysql`:
+Here's an example configuration for `mysql` (or mariadb):
 
 ```json
 {
@@ -29,22 +28,6 @@ Here's an example configuration for `mysql`:
 }
 ```
 
-and here's one for `postgres`:
-
-```json
-{
-  "database": {
-    "engine": "pg",
-    "version": "7.2",
-    "host": "127.0.0.1",
-    "name": "nginxproxymanager",
-    "user": "nginxproxymanager",
-    "password": "password123",
-    "port": 5432
-  }
-}
-```
-
 Once you've created your configuration file it's easy to mount it in the docker container, examples below.
 
 **Note:** After the first run of the application, the config file will be altered to include generated encryption keys unique to your installation. These keys
@@ -138,3 +121,24 @@ docker run -d \
     -v /path/to/letsencrypt:/etc/letsencrypt \
     jc21/nginx-proxy-manager:2-armhf
 ```
+
+
+### Initial Run
+
+After the app is running for the first time, the following will happen:
+
+- The database will initialize with table structures
+- GPG keys will be generated and saved in the configuration file
+- A default admin user will be created
+
+This process can take a couple of minutes depending on your machine.
+
+
+### Default Administrator User
+
+```
+Email:    [email protected]
+Password: changeme
+```
+
+Immediately after logging in with this default user you will be asked to modify your details and change your password.

+ 1 - 1
package.json

@@ -41,6 +41,7 @@
     "body-parser": "^1.18.3",
     "compression": "^1.7.2",
     "config": "^2.0.1",
+    "diskdb": "^0.1.17",
     "ejs": "^2.6.1",
     "express": "^4.16.3",
     "express-fileupload": "^0.4.0",
@@ -56,7 +57,6 @@
     "node-rsa": "^1.0.0",
     "objection": "^1.1.10",
     "path": "^0.12.7",
-    "pg": "^7.4.3",
     "restler": "^3.4.0",
     "signale": "^1.2.1",
     "temp-write": "^3.4.0",

+ 0 - 1
rootfs/etc/services.d/manager/run

@@ -4,4 +4,3 @@ mkdir -p /data/letsencrypt-acme-challenge
 
 cd /app
 node --abort_on_uncaught_exception --max_old_space_size=250 /app/src/backend/index.js
-

+ 10 - 3
rootfs/etc/services.d/nginx/run

@@ -2,9 +2,16 @@
 
 mkdir -p /tmp/nginx/body \
   /var/log/nginx \
-  /data/{nginx,logs,access} \
-  /data/nginx/{proxy_host,redirection_host,stream,dead_host,temp} \
-  /var/lib/nginx/cache/{public,private}
+  /data/nginx \
+  /data/logs \
+  /data/access \
+  /data/nginx/proxy_host \
+  /data/nginx/redirection_host \
+  /data/nginx/stream \
+  /data/nginx/dead_host \
+  /data/nginx/temp \
+  /var/lib/nginx/cache/public \
+  /var/lib/nginx/cache/private
 
 touch /var/log/nginx/error.log && chmod 777 /var/log/nginx/error.log
 chown root /tmp/nginx

+ 68 - 0
src/backend/importer.js

@@ -0,0 +1,68 @@
+'use strict';
+
+const fs     = require('fs');
+const logger = require('./logger').import;
+const utils  = require('./lib/utils');
+
+module.exports = function () {
+    return new Promise((resolve, reject) => {
+        if (fs.existsSync('/config') && !fs.existsSync('/config/v2-imported')) {
+
+            logger.info('Beginning import from V1 ...');
+
+            // Setup
+            const batchflow = require('batchflow');
+            const db        = require('diskdb');
+            module.exports  = db.connect('/config', ['hosts', 'access']);
+
+            // Create a fake access object
+            const Access = require('./lib/access');
+            let access   = new Access(null);
+            resolve(access.load(true)
+                .then(access => {
+
+
+
+                    // Import access lists first
+                    let lists = db.access.find();
+                    lists.map(list => {
+                        logger.warn('List:', list);
+
+                    });
+
+                })
+            );
+
+            /*
+                        let hosts = db.hosts.find();
+                        hosts.map(host => {
+                            logger.warn('Host:', host);
+                        });
+            */
+
+            // Looks like we need to import from version 1
+            // There are numerous parts to this import:
+            //
+            // 1. The letsencrypt certificates, the need to be added to the database and files renamed
+            // 2. The access lists from the previous datastore
+            // 3. The Hosts from the previous datastore
+
+            // get all hosts:
+            // resolve(db.hosts.find());
+
+            // get specific host:
+            // existing_host = db.hosts.findOne({incoming_port: payload.incoming_port});
+
+            // remove host:
+            // db.hosts.remove({hostname: payload.hostname});
+
+            // get all access:
+            // resolve(db.access.find());
+
+            resolve();
+
+        } else {
+            resolve();
+        }
+    });
+};

+ 3 - 3
src/backend/index.js

@@ -7,14 +7,14 @@ const logger = require('./logger').global;
 function appStart () {
     const migrate             = require('./migrate');
     const setup               = require('./setup');
+    const importer            = require('./importer');
     const app                 = require('./app');
     const apiValidator        = require('./lib/validator/api');
     const internalCertificate = require('./internal/certificate');
 
     return migrate.latest()
-        .then(() => {
-            return setup();
-        })
+        .then(setup)
+        .then(importer)
         .then(() => {
             return apiValidator.loadSchemas;
         })

+ 253 - 26
src/backend/internal/access-list.js

@@ -1,10 +1,16 @@
 'use strict';
 
 const _                   = require('lodash');
+const fs                  = require('fs');
+const batchflow           = require('batchflow');
+const logger              = require('../logger').access;
 const error               = require('../lib/error');
 const accessListModel     = require('../models/access_list');
 const accessListAuthModel = require('../models/access_list_auth');
+const proxyHostModel      = require('../models/proxy_host');
 const internalAuditLog    = require('./audit-log');
+const internalNginx       = require('./nginx');
+const utils               = require('../lib/utils');
 
 function omissions () {
     return ['is_deleted'];
@@ -29,6 +35,8 @@ const internalAccessList = {
                     });
             })
             .then(row => {
+                data.id = row.id;
+
                 // Now add the items
                 let promises = [];
                 data.items.map(function (item) {
@@ -44,26 +52,34 @@ const internalAccessList = {
 
                 return Promise.all(promises);
             })
-            .then(row => {
-                // re-fetch with cert
+            .then(() => {
+                // re-fetch with expansions
                 return internalAccessList.get(access, {
-                    id:     row.id,
+                    id:     data.id,
                     expand: ['owner', 'items']
-                });
+                }, true /* <- skip masking */);
             })
             .then(row => {
                 // Audit log
                 data.meta = _.assign({}, data.meta || {}, row.meta);
 
-                // Add to audit log
-                return internalAuditLog.add(access, {
-                    action:      'created',
-                    object_type: 'access-list',
-                    object_id:   row.id,
-                    meta:        data
-                })
+                return internalAccessList.build(row)
+                    .then(() => {
+                        if (row.proxy_host_count) {
+                            return internalNginx.reload();
+                        }
+                    })
                     .then(() => {
-                        return row;
+                        // Add to audit log
+                        return internalAuditLog.add(access, {
+                            action:      'created',
+                            object_type: 'access-list',
+                            object_id:   row.id,
+                            meta:        internalAccessList.maskItems(data)
+                        });
+                    })
+                    .then(() => {
+                        return internalAccessList.maskItems(row);
                     });
             });
     },
@@ -72,15 +88,99 @@ const internalAccessList = {
      * @param  {Access}  access
      * @param  {Object}  data
      * @param  {Integer} data.id
-     * @param  {String}  [data.email]
      * @param  {String}  [data.name]
+     * @param  {String}  [data.items]
      * @return {Promise}
      */
     update: (access, data) => {
         return access.can('access_lists:update', data.id)
             .then(access_data => {
-                // TODO
-                return {};
+                return internalAccessList.get(access, {id: data.id});
+            })
+            .then(row => {
+                if (row.id !== data.id) {
+                    // Sanity check that something crazy hasn't happened
+                    throw new error.InternalValidationError('Access List could not be updated, IDs do not match: ' + row.id + ' !== ' + data.id);
+                }
+
+            })
+            .then(() => {
+                // patch name if specified
+                if (typeof data.name !== 'undefined' && data.name) {
+                    return accessListModel
+                        .query()
+                        .where({id: data.id})
+                        .patch({
+                            name: data.name
+                        });
+                }
+            })
+            .then(() => {
+                // Check for items and add/update/remove them
+                if (typeof data.items !== 'undefined' && data.items) {
+                    let promises      = [];
+                    let items_to_keep = [];
+
+                    data.items.map(function (item) {
+                        if (item.password) {
+                            promises.push(accessListAuthModel
+                                .query()
+                                .insert({
+                                    access_list_id: data.id,
+                                    username:       item.username,
+                                    password:       item.password
+                                })
+                            );
+                        } else {
+                            // This was supplied with an empty password, which means keep it but don't change the password
+                            items_to_keep.push(item.username);
+                        }
+                    });
+
+                    let query = accessListAuthModel
+                        .query()
+                        .delete()
+                        .where('access_list_id', data.id);
+
+                    if (items_to_keep.length) {
+                        query.andWhere('username', 'NOT IN', items_to_keep);
+                    }
+
+                    return query
+                        .then(() => {
+                            // Add new items
+                            if (promises.length) {
+                                return Promise.all(promises);
+                            }
+                        });
+                }
+            })
+            .then(() => {
+                // Add to audit log
+                return internalAuditLog.add(access, {
+                    action:      'updated',
+                    object_type: 'access-list',
+                    object_id:   data.id,
+                    meta:        internalAccessList.maskItems(data)
+                });
+            })
+            .then(() => {
+                // re-fetch with expansions
+                return internalAccessList.get(access, {
+                    id:     data.id,
+                    expand: ['owner', 'items']
+                }, true /* <- skip masking */);
+            })
+            .then(row => {
+                return internalAccessList.build(row)
+                    .then(() => {
+                        if (row.proxy_host_count) {
+                            return internalNginx.reload();
+                        }
+                    })
+                    .then(() => {
+                        return internalAccessList.maskItems(row);
+                    });
             });
     },
 
@@ -90,9 +190,10 @@ const internalAccessList = {
      * @param  {Integer}  data.id
      * @param  {Array}    [data.expand]
      * @param  {Array}    [data.omit]
+     * @param  {Boolean}  [skip_masking]
      * @return {Promise}
      */
-    get: (access, data) => {
+    get: (access, data, skip_masking) => {
         if (typeof data === 'undefined') {
             data = {};
         }
@@ -105,9 +206,12 @@ const internalAccessList = {
             .then(access_data => {
                 let query = accessListModel
                     .query()
-                    .where('is_deleted', 0)
-                    .andWhere('id', data.id)
-                    .allowEager('[owner,items]')
+                    .select('access_list.*', accessListModel.raw('COUNT(proxy_host.id) as proxy_host_count'))
+                    .joinRaw('LEFT JOIN `proxy_host` ON `proxy_host`.`access_list_id` = `access_list`.`id` AND `proxy_host`.`is_deleted` = 0')
+                    .where('access_list.is_deleted', 0)
+                    .andWhere('access_list.id', data.id)
+                    .allowEager('[owner,items,proxy_hosts]')
+                    .omit(['access_list.is_deleted'])
                     .first();
 
                 if (access_data.permission_visibility !== 'all') {
@@ -127,7 +231,7 @@ const internalAccessList = {
             })
             .then(row => {
                 if (row) {
-                    if (typeof row.items !== 'undefined' && row.items) {
+                    if (!skip_masking && typeof row.items !== 'undefined' && row.items) {
                         row = internalAccessList.maskItems(row);
                     }
 
@@ -148,19 +252,66 @@ const internalAccessList = {
     delete: (access, data) => {
         return access.can('access_lists:delete', data.id)
             .then(() => {
-                return internalAccessList.get(access, {id: data.id});
+                return internalAccessList.get(access, {id: data.id, expand: ['proxy_hosts', 'items']});
             })
             .then(row => {
                 if (!row) {
                     throw new error.ItemNotFoundError(data.id);
                 }
 
+                // 1. update row to be deleted
+                // 2. update any proxy hosts that were using it (ignoring permissions)
+                // 3. reconfigure those hosts
+                // 4. audit log
+
+                // 1. update row to be deleted
                 return accessListModel
                     .query()
                     .where('id', row.id)
                     .patch({
                         is_deleted: 1
-                    });
+                    })
+                    .then(() => {
+                        // 2. update any proxy hosts that were using it (ignoring permissions)
+                        if (row.proxy_hosts) {
+                            return proxyHostModel
+                                .query()
+                                .where('access_list_id', '=', row.id)
+                                .patch({access_list_id: 0})
+                                .then(() => {
+                                    // 3. reconfigure those hosts, then reload nginx
+
+                                    // set the access_list_id to zero for these items
+                                    row.proxy_hosts.map(function (val, idx) {
+                                        row.proxy_hosts[idx].access_list_id = 0;
+                                    });
+
+                                    return internalNginx.bulkGenerateConfigs('proxy_host', row.proxy_hosts);
+                                })
+                                .then(() => {
+                                    return internalNginx.reload();
+                                });
+                        }
+                    })
+                    .then(() => {
+                        // delete the htpasswd file
+                        let htpasswd_file = internalAccessList.getFilename(row);
+
+                        try {
+                            fs.unlinkSync(htpasswd_file);
+                        } catch (err) {
+                            // do nothing
+                        }
+                    })
+                    .then(() => {
+                        // 4. audit log
+                        return internalAuditLog.add(access, {
+                            action:      'deleted',
+                            object_type: 'access-list',
+                            object_id:   row.id,
+                            meta:        _.omit(internalAccessList.maskItems(row), ['is_deleted', 'proxy_hosts'])
+                        });
+                    })
             })
             .then(() => {
                 return true;
@@ -180,9 +331,8 @@ const internalAccessList = {
             .then(access_data => {
                 let query = accessListModel
                     .query()
-                    .select('access_list.*', accessListModel.raw('COUNT(proxy_hosts.id) as proxy_host_count'), accessListModel.raw('COUNT(items.id) as item_count'))
-                    .leftJoinRelation('proxy_hosts')
-                    .leftJoinRelation('items')
+                    .select('access_list.*', accessListModel.raw('COUNT(proxy_host.id) as proxy_host_count'))
+                    .joinRaw('LEFT JOIN `proxy_host` ON `proxy_host`.`access_list_id` = `access_list`.`id` AND `proxy_host`.`is_deleted` = 0')
                     .where('access_list.is_deleted', 0)
                     .groupBy('access_list.id')
                     .omit(['access_list.is_deleted'])
@@ -249,12 +399,89 @@ const internalAccessList = {
     maskItems: list => {
         if (list && typeof list.items !== 'undefined') {
             list.items.map(function (val, idx) {
-                list.items[idx].hint     = val.password.charAt(0) + ('*').repeat(val.password.length - 1);
+                let repeat_for = 8;
+                let first_char = '*';
+
+                if (typeof val.password !== 'undefined' && val.password) {
+                    repeat_for = val.password.length - 1;
+                    first_char = val.password.charAt(0);
+                }
+
+                list.items[idx].hint     = first_char + ('*').repeat(repeat_for);
                 list.items[idx].password = '';
             });
         }
 
         return list;
+    },
+
+    /**
+     * @param   {Object}  list
+     * @param   {Integer} list.id
+     * @returns {String}
+     */
+    getFilename: list => {
+        return '/data/access/' + list.id;
+    },
+
+    /**
+     * @param   {Object}  list
+     * @param   {Integer} list.id
+     * @param   {String}  list.name
+     * @param   {Array}   list.items
+     * @returns {Promise}
+     */
+    build: list => {
+        logger.info('Building Access file #' + list.id + ' for: ' + list.name);
+
+        return new Promise((resolve, reject) => {
+            let htpasswd_file = internalAccessList.getFilename(list);
+
+            // 1. remove any existing access file
+            try {
+                fs.unlinkSync(htpasswd_file);
+            } catch (err) {
+                // do nothing
+            }
+
+            // 2. create empty access file
+            try {
+                fs.writeFileSync(htpasswd_file, '', {encoding: 'utf8'});
+                resolve(htpasswd_file);
+            } catch (err) {
+                reject(err);
+            }
+        })
+            .then(htpasswd_file => {
+                // 3. generate password for each user
+                if (list.items.length) {
+                    return new Promise((resolve, reject) => {
+                        batchflow(list.items).sequential()
+                            .each((i, item, next) => {
+                                if (typeof item.password !== 'undefined' && item.password.length) {
+                                    logger.info('Adding: ' + item.username);
+
+                                    utils.exec('/usr/bin/htpasswd -b "' + htpasswd_file + '" "' + item.username + '" "' + item.password + '"')
+                                        .then((/*result*/) => {
+                                            next();
+                                        })
+                                        .catch(err => {
+                                            logger.error(err);
+                                            next(err);
+                                        });
+                                }
+                            })
+                            .error(err => {
+                                logger.error(err);
+                                reject(err);
+                            })
+                            .end(results => {
+                                logger.success('Built Access file #' + list.id + ' for: ' + list.name);
+                                resolve(results);
+                            });
+                    });
+                }
+            });
     }
 };
 

+ 2 - 2
src/backend/internal/proxy-host.js

@@ -74,7 +74,7 @@ const internalProxyHost = {
                 // re-fetch with cert
                 return internalProxyHost.get(access, {
                     id:     row.id,
-                    expand: ['certificate', 'owner']
+                    expand: ['certificate', 'owner', 'access_list']
                 });
             })
             .then(row => {
@@ -185,7 +185,7 @@ const internalProxyHost = {
             .then(() => {
                 return internalProxyHost.get(access, {
                     id:     data.id,
-                    expand: ['owner', 'certificate']
+                    expand: ['owner', 'certificate', 'access_list']
                 })
                     .then(row => {
                         // Configure nginx

+ 2 - 1
src/backend/logger.js

@@ -6,5 +6,6 @@ module.exports = {
     express: new Signale({scope: 'Express '}),
     access:  new Signale({scope: 'Access  '}),
     nginx:   new Signale({scope: 'Nginx   '}),
-    ssl:     new Signale({scope: 'SSL     '})
+    ssl:     new Signale({scope: 'SSL     '}),
+    import:  new Signale({scope: 'Importer'}),
 };

+ 2 - 2
src/backend/models/access_list.js

@@ -56,7 +56,7 @@ class AccessList extends Model {
                     to:   'access_list_auth.access_list_id'
                 },
                 modify:     function (qb) {
-                    qb.omit(['id', 'created_on', 'modified_on']);
+                    qb.omit(['id', 'created_on', 'modified_on', 'access_list_id', 'meta']);
                 }
             },
             proxy_hosts: {
@@ -68,7 +68,7 @@ class AccessList extends Model {
                 },
                 modify:     function (qb) {
                     qb.where('proxy_host.is_deleted', 0);
-                    qb.omit(['id', 'created_on', 'modified_on', 'is_deleted', 'meta']);
+                    qb.omit(['is_deleted', 'meta']);
                 }
             }
         };

+ 1 - 1
src/backend/routes/api/nginx/access_lists.js

@@ -136,7 +136,7 @@ router
     /**
      * DELETE /api/nginx/access-lists/123
      *
-     * Update and existing access-list
+     * Delete and existing access-list
      */
     .delete((req, res, next) => {
         internalAccessList.delete(res.locals.access, {id: parseInt(req.params.list_id, 10)})

+ 43 - 0
src/backend/schema/endpoints/access-lists.json

@@ -107,6 +107,49 @@
         }
       }
     },
+    {
+      "title": "Update",
+      "description": "Updates a existing Access List",
+      "href": "/nginx/access-list/{definitions.identity.example}",
+      "access": "private",
+      "method": "PUT",
+      "rel": "update",
+      "http_header": {
+        "$ref": "../examples.json#/definitions/auth_header"
+      },
+      "schema": {
+        "type": "object",
+        "additionalProperties": false,
+        "properties": {
+          "name": {
+            "$ref": "#/definitions/name"
+          },
+          "items": {
+            "type": "array",
+            "minItems": 1,
+            "items": {
+              "type": "object",
+              "additionalProperties": false,
+              "properties": {
+                "username": {
+                  "type": "string",
+                  "minLength": 1
+                },
+                "password": {
+                  "type": "string",
+                  "minLength": 0
+                }
+              }
+            }
+          }
+        }
+      },
+      "targetSchema": {
+        "properties": {
+          "$ref": "#/properties"
+        }
+      }
+    },
     {
       "title": "Delete",
       "description": "Deletes a existing Access List",

+ 1 - 1
src/backend/templates/proxy_host.conf

@@ -17,7 +17,7 @@ server {
     {%- if access_list_id > 0 -%}
     # Access List
     auth_basic            "Authorization required";
-    auth_basic_user_file  /config/access/{{ access_list_id }};
+    auth_basic_user_file  /data/access/{{ access_list_id }};
     {%- endif %}
 
 {% include "_forced_ssl.conf" %}

+ 2 - 2
src/frontend/js/app/audit-log/list/item.ejs

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

+ 13 - 17
src/frontend/js/app/controller.js

@@ -91,23 +91,6 @@ module.exports = {
         }
     },
 
-    /**
-     * Error
-     *
-     * @param {Error}   err
-     * @param {String}  nice_msg
-     */
-    /*
-    showError: function (err, nice_msg) {
-        require(['./main', './error/main'], (App, View) => {
-            App.UI.showAppContent(new View({
-                err:      err,
-                nice_msg: nice_msg
-            }));
-        });
-    },
-    */
-
     /**
      * Dashboard
      */
@@ -319,6 +302,19 @@ module.exports = {
         }
     },
 
+    /**
+     * Access List Delete Confirm
+     *
+     * @param model
+     */
+    showNginxAccessListDeleteConfirm: function (model) {
+        if (Cache.User.isAdmin() || Cache.User.canManage('access_lists')) {
+            require(['./main', './nginx/access/delete'], function (App, View) {
+                App.UI.showModalDialog(new View({model: model}));
+            });
+        }
+    },
+
     /**
      * Nginx Certificates
      */

+ 4 - 0
src/frontend/js/app/nginx/access/delete.ejs

@@ -8,6 +8,10 @@
             <div class="row">
                 <div class="col-sm-12 col-md-12">
                     <%= i18n('access-lists', 'delete-confirm') %>
+                    <% if (proxy_host_count) { %>
+                        <br><br>
+                        <%- i18n('access-lists', 'delete-has-hosts', {count: proxy_host_count}) %>
+                    <% } %>
                 </div>
             </div>
         </form>

+ 1 - 1
src/frontend/js/app/nginx/access/list/item.ejs

@@ -12,7 +12,7 @@
     </div>
 </td>
 <td>
-    <%- i18n('access-lists', 'item-count', {count: item_count}) %>
+    <%- i18n('access-lists', 'item-count', {count: items.length || 0}) %>
 </td>
 <td>
     <%- i18n('access-lists', 'proxy-host-count', {count: proxy_host_count}) %>

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

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

+ 4 - 4
src/frontend/js/app/nginx/dead/form.ejs

@@ -28,10 +28,10 @@
                     <div class="row">
                         <div class="col-sm-12 col-md-12">
                             <div class="form-group">
-                                <label class="form-label">SSL Certificate</label>
-                                <select name="certificate_id" class="form-control custom-select" placeholder="None">
-                                    <option selected value="0" data-data="{&quot;id&quot;:0}" <%- certificate_id ? '' : 'selected' %>>None</option>
-                                    <option selected value="new" data-data="{&quot;id&quot;:&quot;new&quot;}">Request a new SSL Certificate</option>
+                                <label class="form-label"><%- i18n('all-hosts', 'ssl-certificate') %></label>
+                                <select name="certificate_id" class="form-control custom-select" placeholder="<%- i18n('all-hosts', 'none') %>">
+                                    <option selected value="0" data-data="{&quot;id&quot;:0}" <%- certificate_id ? '' : 'selected' %>><%- i18n('all-hosts', 'none') %></option>
+                                    <option selected value="new" data-data="{&quot;id&quot;:&quot;new&quot;}"><%- i18n('all-hosts', 'new-cert') %></option>
                                 </select>
                             </div>
                         </div>

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

@@ -0,0 +1,13 @@
+<div>
+    <% if (id > 0) { %>
+        <div class="title">
+            <i class="fe fe-shield text-teal"></i> <%- name %>
+        </div>
+        <span class="description"><%- i18n('access-lists', 'item-count', {count: items.length || 0}) %> &ndash; Created: <%- formatDbDate(created_on, 'Do MMMM YYYY, h:mm a') %></span>
+    <% } else { %>
+        <div class="title">
+            <i class="fe fe-shield-off text-yellow"></i> <%- i18n('access-lists', 'public') %>
+        </div>
+        <span class="description"><%- i18n('access-lists', 'public-sub') %></span>
+    <% } %>
+</div>

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

@@ -53,8 +53,8 @@
                         <div class="col-sm-12 col-md-12">
                             <div class="form-group">
                                 <label class="form-label"><%- i18n('proxy-hosts', 'access-list') %></label>
-                                <select name="access_list_id" class="form-control custom-select">
-                                    <option value="0" selected="selected"><%- i18n('access-lists', 'public') %></option>
+                                <select name="access_list_id" class="form-control custom-select" placeholder="<%- i18n('access-lists', 'public') %>">
+                                    <option selected value="0" data-data="{&quot;id&quot;:0}" <%- access_list_id ? '' : 'selected' %>><%- i18n('access-lists', 'public') %></option>
                                 </select>
                             </div>
                         </div>
@@ -66,10 +66,10 @@
                     <div class="row">
                         <div class="col-sm-12 col-md-12">
                             <div class="form-group">
-                                <label class="form-label">SSL Certificate</label>
-                                <select name="certificate_id" class="form-control custom-select" placeholder="None">
-                                    <option selected value="0" data-data="{&quot;id&quot;:0}" <%- certificate_id ? '' : 'selected' %>>None</option>
-                                    <option selected value="new" data-data="{&quot;id&quot;:&quot;new&quot;}">Request a new SSL Certificate</option>
+                                <label class="form-label"><%- i18n('all-hosts', 'ssl-certificate') %></label>
+                                <select name="certificate_id" class="form-control custom-select" placeholder="<%- i18n('all-hosts', 'none') %>">
+                                    <option selected value="0" data-data="{&quot;id&quot;:0}" <%- certificate_id ? '' : 'selected' %>><%- i18n('all-hosts', 'none') %></option>
+                                    <option selected value="new" data-data="{&quot;id&quot;:&quot;new&quot;}"><%- i18n('all-hosts', 'new-cert') %></option>
                                 </select>
                             </div>
                         </div>

+ 39 - 6
src/frontend/js/app/nginx/proxy/form.js

@@ -1,11 +1,12 @@
 'use strict';
 
-const Mn                   = require('backbone.marionette');
-const App                  = require('../../main');
-const ProxyHostModel       = require('../../../models/proxy-host');
-const template             = require('./form.ejs');
-const certListItemTemplate = require('../certificates-list-item.ejs');
-const Helpers              = require('../../../lib/helpers');
+const Mn                     = require('backbone.marionette');
+const App                    = require('../../main');
+const ProxyHostModel         = require('../../../models/proxy-host');
+const template               = require('./form.ejs');
+const certListItemTemplate   = require('../certificates-list-item.ejs');
+const accessListItemTemplate = require('./access-list-item.ejs');
+const Helpers                = require('../../../lib/helpers');
 
 require('jquery-serializejson');
 require('jquery-mask-plugin');
@@ -23,6 +24,7 @@ module.exports = Mn.View.extend({
         cancel:             'button.cancel',
         save:               'button.save',
         certificate_select: 'select[name="certificate_id"]',
+        access_list_select: 'select[name="access_list_id"]',
         ssl_forced:         'input[name="ssl_forced"]',
         letsencrypt:        '.letsencrypt'
     },
@@ -140,6 +142,37 @@ module.exports = Mn.View.extend({
             createFilter: /^(?:\*\.)?(?:[^.*]+\.?)+[^.]$/
         });
 
+        // Access Lists
+        this.ui.letsencrypt.hide();
+        this.ui.access_list_select.selectize({
+            valueField:       'id',
+            labelField:       'name',
+            searchField:      ['name'],
+            create:           false,
+            preload:          true,
+            allowEmptyOption: true,
+            render:           {
+                option: function (item) {
+                    item.i18n         = App.i18n;
+                    item.formatDbDate = Helpers.formatDbDate;
+                    return accessListItemTemplate(item);
+                }
+            },
+            load:             function (query, callback) {
+                App.Api.Nginx.AccessLists.getAll(['items'])
+                    .then(rows => {
+                        callback(rows);
+                    })
+                    .catch(err => {
+                        console.error(err);
+                        callback();
+                    });
+            },
+            onLoad:           function () {
+                view.ui.access_list_select[0].selectize.setValue(view.model.get('access_list_id'));
+            }
+        });
+
         // Certificates
         this.ui.letsencrypt.hide();
         this.ui.certificate_select.selectize({

+ 4 - 4
src/frontend/js/app/nginx/redirection/form.ejs

@@ -52,10 +52,10 @@
                     <div class="row">
                         <div class="col-sm-12 col-md-12">
                             <div class="form-group">
-                                <label class="form-label">SSL Certificate</label>
-                                <select name="certificate_id" class="form-control custom-select" placeholder="None">
-                                    <option selected value="0" data-data="{&quot;id&quot;:0}" <%- certificate_id ? '' : 'selected' %>>None</option>
-                                    <option selected value="new" data-data="{&quot;id&quot;:&quot;new&quot;}">Request a new SSL Certificate</option>
+                                <label class="form-label"><%- i18n('all-hosts', 'ssl-certificate') %></label>
+                                <select name="certificate_id" class="form-control custom-select" placeholder="<%- i18n('all-hosts', 'none') %>">
+                                    <option selected value="0" data-data="{&quot;id&quot;:0}" <%- certificate_id ? '' : 'selected' %>><%- i18n('all-hosts', 'none') %></option>
+                                    <option selected value="new" data-data="{&quot;id&quot;:&quot;new&quot;}"><%- i18n('all-hosts', 'new-cert') %></option>
                                 </select>
                             </div>
                         </div>

+ 11 - 3
src/frontend/js/i18n/messages.json

@@ -68,7 +68,12 @@
       "domain-names": "Domain Names",
       "cert-provider": "Certificate Provider",
       "block-exploits": "Block Common Exploits",
-      "caching-enabled": "Cache Assets"
+      "caching-enabled": "Cache Assets",
+      "ssl-certificate": "SSL Certificate",
+      "none": "None",
+      "new-cert": "Request a new SSL Certificate",
+      "with-le": "with Let's Encrypt",
+      "no-ssl": "This host will not use HTTPS"
     },
     "ssl": {
       "letsencrypt": "Let's Encrypt",
@@ -152,12 +157,14 @@
       "add": "Add Access List",
       "form-title": "{id, select, undefined{New} other{Edit}} Access List",
       "delete": "Delete Access List",
-      "delete-confirm": "Are you sure you want to delete this access list? Any hosts using it will need to be updated later.",
+      "delete-confirm": "Are you sure you want to delete this access list?",
       "public": "Publicly Accessible",
+      "public-sub": "No Access Restrictions",
       "help-title": "What is an Access List?",
       "help-content": "Access Lists provide authentication for the Proxy Hosts via Basic HTTP Authentication.\nYou can configure multiple usernames and passwords for a single Access List and then apply that to a Proxy Host.\nThis is most useful for forwarded web services that do not have authentication mechanisms built in.",
       "item-count": "{count} {count, select, 1{User} other{Users}}",
-      "proxy-host-count": "{count} {count, select, 1{Proxy Host} other{Proxy Hosts}}"
+      "proxy-host-count": "{count} {count, select, 1{Proxy Host} other{Proxy Hosts}}",
+      "delete-has-hosts": "This Access List is associated with {count} Proxy Hosts. They will become publicly available upon deletion."
     },
     "users": {
       "title": "Users",
@@ -195,6 +202,7 @@
       "stream": "Stream",
       "user": "User",
       "certificate": "Certificate",
+      "access-list": "Access List",
       "created": "Created {name}",
       "updated": "Updated {name}",
       "deleted": "Deleted {name}",