Bladeren bron

Merge pull request #360 from Indemnity83/ip-access-control

Client Access Lists
jc21 5 jaren geleden
bovenliggende
commit
a9f068daa8

+ 70 - 20
backend/internal/access-list.js

@@ -1,14 +1,15 @@
-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');
+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 accessListClientModel = require('../models/access_list_client');
+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,14 +30,16 @@ const internalAccessList = {
 					.omit(omissions())
 					.insertAndFetch({
 						name:          data.name,
+						satify_any:    data.satify_any,
 						owner_user_id: access.token.getUserId(1)
 					});
 			})
 			.then((row) => {
 				data.id = row.id;
 
-				// Now add the items
 				let promises = [];
+
+				// Now add the items
 				data.items.map((item) => {
 					promises.push(accessListAuthModel
 						.query()
@@ -48,13 +51,27 @@ const internalAccessList = {
 					);
 				});
 
+				// Now add the clients
+				if (typeof data.clients !== 'undefined' && data.clients) {
+					data.clients.map((client) => {
+						promises.push(accessListClientModel
+							.query()
+							.insert({
+								access_list_id: row.id,
+								address:        client.address,
+								directive:      client.directive
+							})
+						);
+					});
+				}
+
 				return Promise.all(promises);
 			})
 			.then(() => {
 				// re-fetch with expansions
 				return internalAccessList.get(access, {
 					id:     data.id,
-					expand: ['owner', 'items']
+					expand: ['owner', 'items', 'clients', 'proxy_hosts.access_list.clients']
 				}, true /* <- skip masking */);
 			})
 			.then((row) => {
@@ -64,7 +81,7 @@ const internalAccessList = {
 				return internalAccessList.build(row)
 					.then(() => {
 						if (row.proxy_host_count) {
-							return internalNginx.reload();
+							return internalNginx.bulkGenerateConfigs('proxy_host', row.proxy_hosts);
 						}
 					})
 					.then(() => {
@@ -109,7 +126,8 @@ const internalAccessList = {
 						.query()
 						.where({id: data.id})
 						.patch({
-							name: data.name
+							name:       data.name,
+							satify_any: data.satify_any,
 						});
 				}
 			})
@@ -153,6 +171,38 @@ const internalAccessList = {
 						});
 				}
 			})
+			.then(() => {
+				// Check for clients and add/update/remove them
+				if (typeof data.clients !== 'undefined' && data.clients) {
+					let promises = [];
+
+					data.clients.map(function (client) {
+						if (client.address) {
+							promises.push(accessListClientModel
+								.query()
+								.insert({
+									access_list_id: data.id,
+									address:        client.address,
+									directive:      client.directive
+								})
+							);
+						}
+					});
+
+					let query = accessListClientModel
+						.query()
+						.delete()
+						.where('access_list_id', data.id);
+
+					return query
+						.then(() => {
+							// Add new items
+							if (promises.length) {
+								return Promise.all(promises);
+							}
+						});
+				}
+			})
 			.then(() => {
 				// Add to audit log
 				return internalAuditLog.add(access, {
@@ -166,14 +216,14 @@ const internalAccessList = {
 				// re-fetch with expansions
 				return internalAccessList.get(access, {
 					id:     data.id,
-					expand: ['owner', 'items']
+					expand: ['owner', 'items', 'clients', 'proxy_hosts.access_list.clients']
 				}, true /* <- skip masking */);
 			})
 			.then((row) => {
 				return internalAccessList.build(row)
 					.then(() => {
 						if (row.proxy_host_count) {
-							return internalNginx.reload();
+							return internalNginx.bulkGenerateConfigs('proxy_host', row.proxy_hosts);
 						}
 					})
 					.then(() => {
@@ -204,7 +254,7 @@ const internalAccessList = {
 					.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]')
+					.allowEager('[owner,items,clients,proxy_hosts,proxy_hosts.access_list.clients]')
 					.omit(['access_list.is_deleted'])
 					.first();
 
@@ -246,7 +296,7 @@ const internalAccessList = {
 	delete: (access, data) => {
 		return access.can('access_lists:delete', data.id)
 			.then(() => {
-				return internalAccessList.get(access, {id: data.id, expand: ['proxy_hosts', 'items']});
+				return internalAccessList.get(access, {id: data.id, expand: ['proxy_hosts', 'items', 'clients']});
 			})
 			.then((row) => {
 				if (!row) {
@@ -330,7 +380,7 @@ const internalAccessList = {
 					.where('access_list.is_deleted', 0)
 					.groupBy('access_list.id')
 					.omit(['access_list.is_deleted'])
-					.allowEager('[owner,items]')
+					.allowEager('[owner,items,clients]')
 					.orderBy('access_list.name', 'ASC');
 
 				if (access_data.permission_visibility !== 'all') {

+ 3 - 3
backend/internal/proxy-host.js

@@ -73,7 +73,7 @@ const internalProxyHost = {
 				// re-fetch with cert
 				return internalProxyHost.get(access, {
 					id:     row.id,
-					expand: ['certificate', 'owner', 'access_list']
+					expand: ['certificate', 'owner', 'access_list.clients']
 				});
 			})
 			.then((row) => {
@@ -186,7 +186,7 @@ const internalProxyHost = {
 			.then(() => {
 				return internalProxyHost.get(access, {
 					id:     data.id,
-					expand: ['owner', 'certificate', 'access_list']
+					expand: ['owner', 'certificate', 'access_list.clients']
 				})
 					.then((row) => {
 						// Configure nginx
@@ -219,7 +219,7 @@ const internalProxyHost = {
 					.query()
 					.where('is_deleted', 0)
 					.andWhere('id', data.id)
-					.allowEager('[owner,access_list,certificate]')
+					.allowEager('[owner,access_list,access_list.clients,certificate]')
 					.first();
 
 				if (access_data.permission_visibility !== 'all') {

+ 53 - 0
backend/migrations/20200410143839_access_list_client.js

@@ -0,0 +1,53 @@
+const migrate_name = 'access_list_client';
+const logger       = require('../logger').migrate;
+
+/**
+ * Migrate
+ *
+ * @see http://knexjs.org/#Schema
+ *
+ * @param   {Object}  knex
+ * @param   {Promise} Promise
+ * @returns {Promise}
+ */
+exports.up = function (knex/*, Promise*/) {
+
+	logger.info('[' + migrate_name + '] Migrating Up...');
+
+	return knex.schema.createTable('access_list_client', (table) => {
+		table.increments().primary();
+		table.dateTime('created_on').notNull();
+		table.dateTime('modified_on').notNull();
+		table.integer('access_list_id').notNull().unsigned();
+		table.string('address').notNull();
+		table.string('directive').notNull();
+		table.json('meta').notNull();
+
+	})
+		.then(function () {
+			logger.info('[' + migrate_name + '] access_list_client Table created');
+
+			return knex.schema.table('access_list', function (access_list) {
+				access_list.integer('satify_any').notNull().defaultTo(0);
+			});
+		})
+		.then(() => {
+			logger.info('[' + migrate_name + '] access_list Table altered');
+		});
+};
+
+/**
+ * Undo Migrate
+ *
+ * @param {Object} knex
+ * @param {Promise} Promise
+ * @returns {Promise}
+ */
+exports.down = function (knex/*, Promise*/) {
+	logger.info('[' + migrate_name + '] Migrating Down...');
+
+	return knex.schema.dropTable('access_list_client')
+		.then(() => {
+			logger.info('[' + migrate_name + '] access_list_client Table dropped');
+		});
+};

+ 20 - 4
backend/models/access_list.js

@@ -1,10 +1,11 @@
 // Objection Docs:
 // http://vincit.github.io/objection.js/
 
-const db             = require('../db');
-const Model          = require('objection').Model;
-const User           = require('./user');
-const AccessListAuth = require('./access_list_auth');
+const db               = require('../db');
+const Model            = require('objection').Model;
+const User             = require('./user');
+const AccessListAuth   = require('./access_list_auth');
+const AccessListClient = require('./access_list_client');
 
 Model.knex(db);
 
@@ -62,6 +63,17 @@ class AccessList extends Model {
 					qb.omit(['id', 'created_on', 'modified_on', 'access_list_id', 'meta']);
 				}
 			},
+			clients: {
+				relation:   Model.HasManyRelation,
+				modelClass: AccessListClient,
+				join:       {
+					from: 'access_list.id',
+					to:   'access_list_client.access_list_id'
+				},
+				modify: function (qb) {
+					qb.omit(['id', 'created_on', 'modified_on', 'access_list_id', 'meta']);
+				}
+			},
 			proxy_hosts: {
 				relation:   Model.HasManyRelation,
 				modelClass: ProxyHost,
@@ -76,6 +88,10 @@ class AccessList extends Model {
 			}
 		};
 	}
+
+	get satisfy() {
+		return this.satify_any ? 'satisfy any' : 'satisfy all';
+	}
 }
 
 module.exports = AccessList;

+ 58 - 0
backend/models/access_list_client.js

@@ -0,0 +1,58 @@
+// Objection Docs:
+// http://vincit.github.io/objection.js/
+
+const db    = require('../db');
+const Model = require('objection').Model;
+
+Model.knex(db);
+
+class AccessListClient extends Model {
+	$beforeInsert () {
+		this.created_on  = Model.raw('NOW()');
+		this.modified_on = Model.raw('NOW()');
+
+		// Default for meta
+		if (typeof this.meta === 'undefined') {
+			this.meta = {};
+		}
+	}
+
+	$beforeUpdate () {
+		this.modified_on = Model.raw('NOW()');
+	}
+
+	static get name () {
+		return 'AccessListClient';
+	}
+
+	static get tableName () {
+		return 'access_list_client';
+	}
+
+	static get jsonAttributes () {
+		return ['meta'];
+	}
+
+	static get relationMappings () {
+		return {
+			access_list: {
+				relation:   Model.HasOneRelation,
+				modelClass: require('./access_list'),
+				join:       {
+					from: 'access_list_client.access_list_id',
+					to:   'access_list.id'
+				},
+				modify: function (qb) {
+					qb.where('access_list.is_deleted', 0);
+					qb.omit(['created_on', 'modified_on', 'is_deleted', 'access_list_id']);
+				}
+			}
+		};
+	}
+
+	get rule() {
+		return `${this.directive} ${this.address}`;
+	}
+}
+
+module.exports = AccessListClient;

+ 63 - 2
backend/schema/endpoints/access-lists.json

@@ -19,6 +19,29 @@
       "type": "string",
       "description": "Name of the Access List"
     },
+    "directive": {
+      "type": "string",
+      "enum": ["allow", "deny"]
+    },
+    "address": {
+      "oneOf": [
+        {
+          "type": "string",
+          "pattern": "^([0-9]{1,3}\\.){3}[0-9]{1,3}(\/([0-9]|[1-2][0-9]|3[0-2]))?$"
+        },
+        {
+          "type": "string",
+          "pattern": "^s*((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:)))(%.+)?s*(\/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8]))?$"
+        },
+        {
+          "type": "string",
+          "pattern": "^any$"
+        }
+      ]
+    },
+    "satify_any": {
+      "type": "boolean"
+    },
     "meta": {
       "type": "object"
     }
@@ -78,9 +101,12 @@
           "name": {
             "$ref": "#/definitions/name"
           },
+          "satify_any": {
+            "$ref": "#/definitions/satify_any"
+          },
           "items": {
             "type": "array",
-            "minItems": 1,
+            "minItems": 0,
             "items": {
               "type": "object",
               "additionalProperties": false,
@@ -96,6 +122,22 @@
               }
             }
           },
+          "clients": {
+            "type": "array",
+            "minItems": 0,
+            "items": {
+              "type": "object",
+              "additionalProperties": false,
+              "properties": {
+                "address": {
+                  "$ref": "#/definitions/address"
+                },
+                "directive": {
+                  "$ref": "#/definitions/directive"
+                }
+              }
+            }
+          },
           "meta": {
             "$ref": "#/definitions/meta"
           }
@@ -124,9 +166,12 @@
           "name": {
             "$ref": "#/definitions/name"
           },
+          "satify_any": {
+            "$ref": "#/definitions/satify_any"
+          },
           "items": {
             "type": "array",
-            "minItems": 1,
+            "minItems": 0,
             "items": {
               "type": "object",
               "additionalProperties": false,
@@ -141,6 +186,22 @@
                 }
               }
             }
+          },
+          "clients": {
+            "type": "array",
+            "minItems": 0,
+            "items": {
+              "type": "object",
+              "additionalProperties": false,
+              "properties": {
+                "address": {
+                  "$ref": "#/definitions/address"
+                },
+                "directive": {
+                  "$ref": "#/definitions/directive"
+                }
+              }
+            }
           }
         }
       },

+ 13 - 3
backend/templates/proxy_host.conf

@@ -21,11 +21,21 @@ server {
 {% if use_default_location %}
 
   location / {
-    {%- if access_list_id > 0 -%}
-    # Access List
+
+    {% if access_list_id > 0 %}
+    # Authorization
     auth_basic            "Authorization required";
     auth_basic_user_file  /data/access/{{ access_list_id }};
-    {%- endif %}
+
+    # Access Rules
+    {% for client in access_list.clients %}
+    {{- client.rule -}};
+    {% endfor %}deny all;
+
+    # Access checks must...
+    {{ access_list.satisfy }};
+
+    {% endif %}
 
 {% include "_forced_ssl.conf" %}
 {% include "_hsts.conf" %}

+ 60 - 14
frontend/js/app/nginx/access/form.ejs

@@ -3,28 +3,74 @@
         <h5 class="modal-title"><%- i18n('access-lists', 'form-title', {id: id}) %></h5>
         <button type="button" class="close cancel" aria-label="Close" data-dismiss="modal">&nbsp;</button>
     </div>
-    <div class="modal-body">
+    <div class="modal-body has-tabs">
         <form>
-            <div class="row">
-                <div class="col-sm-12 col-md-12">
-                    <div class="form-group">
-                        <label class="form-label"><%- i18n('str', 'name') %> <span class="form-required">*</span></label>
-                        <input type="text" name="name" class="form-control" value="<%- name %>" required>
+            <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 show" aria-selected="true"><i class="fe fe-zap"></i> <%- i18n('access-lists', 'details') %></a></li>
+                <li role="presentation" class="nav-item"><a href="#auth" aria-controls="tab4" role="tab" data-toggle="tab" class="nav-link" aria-selected="false"><i class="fe fe-users"></i> <%- i18n('access-lists', 'authorization') %></a></li>
+                <li role="presentation" class="nav-item"><a href="#access" aria-controls="tab2" role="tab" data-toggle="tab" class="nav-link" aria-selected="false"><i class="fe fe-radio"></i> <%- i18n('access-lists', 'access') %></a></li>
+            </ul>
+
+            <div class="tab-content">
+                <!-- Details -->
+                <div role="tabpanel" class="tab-pane active show" id="details">
+                    <div class="row">
+                        <div class="col-sm-12 col-md-12">
+                            <div class="form-group">
+                                <label class="form-label"><%- i18n('str', 'name') %> <span class="form-required">*</span></label>
+                                <input type="text" name="name" class="form-control" value="<%- name %>" required>
+                            </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="satify_any" value="1"<%- typeof satify_any !== 'undefined' && satify_any ? ' checked' : '' %>>
+                                    <span class="custom-switch-indicator"></span>
+                                    <span class="custom-switch-description"><%- i18n('access-lists', 'satisfy-any') %></span>
+                                </label>
+                            </div>
+                        </div>
                     </div>
                 </div>
-                <div class="col-sm-6 col-md-6">
-                    <div class="form-group">
-                        <label class="form-label"><%- i18n('str', 'username') %></label>
+
+                <!-- Authorization -->
+                <div class="tab-pane" id="auth">
+                    <div class="row">
+                        <div class="col-sm-6 col-md-6">
+                            <div class="form-group">
+                                <label class="form-label"><%- i18n('str', 'username') %></label>
+                            </div>
+                        </div>
+                        <div class="col-sm-6 col-md-6">
+                            <div class="form-group">
+                                <label class="form-label"><%- i18n('str', 'password') %></label>
+                            </div>
+                        </div>
                     </div>
+        
+                    <div class="items"><!-- items --></div>
                 </div>
-                <div class="col-sm-6 col-md-6">
-                    <div class="form-group">
-                        <label class="form-label"><%- i18n('str', 'password') %></label>
+
+                <!-- Access -->
+                <div class="tab-pane" id="access">
+                    <div class="clients"><!-- clients --></div>
+                    <div class="row">
+                        <div class="col-sm-3 col-md-3">
+                            <div class="form-group">
+                                <input type="text" class="form-control disabled" value="deny" disabled>
+                            </div>
+                        </div>
+                        <div class="col-sm-9 col-md-9">
+                            <div class="form-group">
+                                <input type="text" class="form-control disabled" value="all" disabled>
+                            </div>
+                        </div>
                     </div>
+                    <div class="text-muted">Note that the <code>allow</code> and <code>deny</code> directives will be applied in the order they are defined.</div>
                 </div>
-            </div>
 
-            <div class="items"><!-- items --></div>
+            </div>
         </form>
     </div>
     <div class="modal-footer">

+ 46 - 13
frontend/js/app/nginx/access/form.js

@@ -3,6 +3,7 @@ const App             = require('../../main');
 const AccessListModel = require('../../../models/access-list');
 const template        = require('./form.ejs');
 const ItemView        = require('./form/item');
+const ClientView      = require('./form/client');
 
 require('jquery-serializejson');
 
@@ -10,20 +11,26 @@ const ItemsView = Mn.CollectionView.extend({
     childView: ItemView
 });
 
+const ClientsView = Mn.CollectionView.extend({
+    childView: ClientView
+});
+
 module.exports = Mn.View.extend({
     template:  template,
     className: 'modal-dialog',
 
     ui: {
-        items_region: '.items',
-        form:         'form',
-        buttons:      '.modal-footer button',
-        cancel:       'button.cancel',
-        save:         'button.save'
+        items_region:   '.items',
+        clients_region: '.clients',
+        form:           'form',
+        buttons:        '.modal-footer button',
+        cancel:         'button.cancel',
+        save:           'button.save'
     },
 
     regions: {
-        items_region: '@ui.items_region'
+        items_region:   '@ui.items_region',
+        clients_region: '@ui.clients_region'
     },
 
     events: {
@@ -35,9 +42,10 @@ module.exports = Mn.View.extend({
                 return;
             }
 
-            let view       = this;
-            let form_data  = this.ui.form.serializeJSON();
-            let items_data = [];
+            let view         = this;
+            let form_data    = this.ui.form.serializeJSON();
+            let items_data   = [];
+            let clients_data = [];
 
             form_data.username.map(function (val, idx) {
                 if (val.trim().length) {
@@ -48,16 +56,29 @@ module.exports = Mn.View.extend({
                 }
             });
 
-            if (!items_data.length) {
-                alert('You must specify at least 1 Username and Password combination');
+            form_data.address.map(function (val, idx) {
+                if (val.trim().length) {
+                    clients_data.push({
+                        address: val.trim(),
+                        directive: form_data.directive[idx]
+                    })
+                }
+            });
+
+            if (!items_data.length && !clients_data.length) {
+                alert('You must specify at least 1 Authorization or Access rule');
                 return;
             }
 
             let data = {
-                name:  form_data.name,
-                items: items_data
+                name:       form_data.name,
+                satify_any: !!form_data.satify_any,
+                items:      items_data,
+                clients:    clients_data
             };
 
+            console.log(data);
+
             let method = App.Api.Nginx.AccessLists.create;
             let is_new = true;
 
@@ -88,6 +109,7 @@ module.exports = Mn.View.extend({
 
     onRender: function () {
         let items = this.model.get('items');
+        let clients = this.model.get('clients');
 
         // Add empty items to the end of the list. This is cheating but hey I don't have the time to do it right
         let items_to_add = 5 - items.length;
@@ -97,9 +119,20 @@ module.exports = Mn.View.extend({
             }
         }
 
+        let clients_to_add = 4 - clients.length;
+        if (clients_to_add) {
+            for (let i = 0; i < clients_to_add; i++) {
+                clients.push({});
+            }
+        }
+
         this.showChildView('items_region', new ItemsView({
             collection: new Backbone.Collection(items)
         }));
+
+        this.showChildView('clients_region', new ClientsView({
+            collection: new Backbone.Collection(clients)
+        }));
     },
 
     initialize: function (options) {

+ 13 - 0
frontend/js/app/nginx/access/form/client.ejs

@@ -0,0 +1,13 @@
+<div class="col-sm-3 col-md-3">
+    <div class="form-group">
+        <select name="directive[]" class="form-control custom-select" placeholder="http">
+            <option value="allow" <%- typeof directive == 'undefined' || directive === 'allow' ? 'selected' : '' %>>allow</option>
+            <option value="deny" <%- typeof directive !== 'undefined' && directive === 'deny' ? 'selected' : '' %>>deny</option>
+        </select>
+    </div>
+</div>
+<div class="col-sm-9 col-md-9">
+    <div class="form-group">
+        <input type="text" name="address[]" class="form-control" value="<%- typeof address !== 'undefined' ? address : '' %>" value="">
+    </div>
+</div>

+ 7 - 0
frontend/js/app/nginx/access/form/client.js

@@ -0,0 +1,7 @@
+const Mn       = require('backbone.marionette');
+const template = require('./client.ejs');
+
+module.exports = Mn.View.extend({
+    template:  template,
+    className: 'row'
+});

+ 10 - 0
frontend/js/app/nginx/access/list/item.ejs

@@ -14,6 +14,16 @@
 <td>
     <%- i18n('access-lists', 'item-count', {count: items.length || 0}) %>
 </td>
+<td>
+    <%- i18n('access-lists', 'client-count', {count: clients.length || 0}) %>
+</td>
+<td>
+    <% if (satify_any) { %>
+    <%- i18n('str', 'any') %>
+    <%} else { %>
+    <%- i18n('str', 'all') %>
+    <% } %>
+</td>
 <td>
     <%- i18n('access-lists', 'proxy-host-count', {count: proxy_host_count}) %>
 </td>

+ 3 - 1
frontend/js/app/nginx/access/list/main.ejs

@@ -1,7 +1,9 @@
 <thead>
     <th width="30">&nbsp;</th>
     <th><%- i18n('str', 'name') %></th>
-    <th><%- i18n('users', 'title') %></th>
+    <th><%- i18n('access-lists', 'authorization') %></th>
+    <th><%- i18n('access-lists', 'access') %></th>
+    <th><%- i18n('access-lists', 'satisfy') %></th>
     <th><%- i18n('proxy-hosts', 'title') %></th>
     <% if (canManage) { %>
     <th>&nbsp;</th>

+ 1 - 1
frontend/js/app/nginx/access/main.js

@@ -40,7 +40,7 @@ module.exports = Mn.View.extend({
     onRender: function () {
         let view = this;
 
-        App.Api.Nginx.AccessLists.getAll(['owner', 'items'])
+        App.Api.Nginx.AccessLists.getAll(['owner', 'items', 'clients'])
             .then(response => {
                 if (!view.isDestroyed()) {
                     if (response && response.length) {

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

@@ -3,7 +3,7 @@
         <div class="title">
             <i class="fe fe-lock 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>
+        <span class="description"><%- i18n('access-lists', 'item-count', {count: items.length || 0}) %>, <%- i18n('access-lists', 'client-count', {count: clients.length || 0}) %> &ndash; Created: <%- formatDbDate(created_on, 'Do MMMM YYYY, h:mm a') %></span>
     <% } else { %>
         <div class="title">
             <i class="fe fe-unlock text-yellow"></i> <%- i18n('access-lists', 'public') %>

+ 1 - 1
frontend/js/app/nginx/proxy/form.js

@@ -222,7 +222,7 @@ module.exports = Mn.View.extend({
                 }
             },
             load:             function (query, callback) {
-                App.Api.Nginx.AccessLists.getAll(['items'])
+                App.Api.Nginx.AccessLists.getAll(['items', 'clients'])
                     .then(rows => {
                         callback(rows);
                     })

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

@@ -33,7 +33,9 @@
       "unknown": "Unknown",
       "expires": "Expires",
       "value": "Value",
-      "please-wait": "Please wait..."
+      "please-wait": "Please wait...",
+      "all": "All",
+      "any": "Any"
     },
     "login": {
       "title": "Login to your account"
@@ -184,10 +186,16 @@
       "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.",
+      "help-content": "Access Lists provide a blacklist or whitelist of specific client IP addresses along with authentication for the Proxy Hosts via Basic HTTP Authentication.\nYou can configure multiple client rules, 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 or that you want to protect from access by unknown clients.",
       "item-count": "{count} {count, select, 1{User} other{Users}}",
+      "client-count": "{count} {count, select, 1{Rule} other{Rules}}",
       "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."
+      "delete-has-hosts": "This Access List is associated with {count} Proxy Hosts. They will become publicly available upon deletion.",
+      "details": "Details",
+      "authorization": "Authorization",
+      "access": "Access",
+      "satisfy": "Satisfy",
+      "satisfy-any": "Satify Any"
     },
     "users": {
       "title": "Users",

+ 1 - 0
frontend/js/models/access-list.js

@@ -10,6 +10,7 @@ const model = Backbone.Model.extend({
             modified_on:     null,
             name:            '',
             items:           [],
+            clients:         [],
             // The following are expansions:
             owner:           null
         };