Browse Source

Access Lists

Jamie Curnow 7 years ago
parent
commit
13f08df46c

+ 18 - 0
TODO.md

@@ -0,0 +1,18 @@
+# TODO
+
+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
+- 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
+
+Testing
+
+- Access Levels
+- Visibility
+- Forwarding
+- Cert renewals
+- Custom certs

+ 2 - 0
config/README.md

@@ -0,0 +1,2 @@
+These files are use in development and are not deployed as part of the final product.
+ 

+ 2 - 3
config/my.cnf

@@ -1,8 +1,7 @@
 [mysqld]
 skip-innodb
-default-storage-engine=MyISAM
-default-tmp-storage-engine=MyISAM
+default-storage-engine=Aria
+default-tmp-storage-engine=Aria
 innodb=OFF
 symbolic-links=0
 log-output=file
-

+ 2 - 1
doc/example/docker-compose.yml

@@ -3,13 +3,14 @@ services:
   app:
     image: jc21/nginx-proxy-manager:2
     restart: always
-    network_mode: host
     volumes:
       - ./config.json:/app/config/production.json
       - ./data:/data
       - ./letsencrypt:/etc/letsencrypt
     depends_on:
       - db
+    links:
+      - db
   db:
     image: mariadb
     restart: always

+ 82 - 7
src/backend/internal/access-list.js

@@ -1,8 +1,10 @@
 'use strict';
 
-const _               = require('lodash');
-const error           = require('../lib/error');
-const accessListModel = require('../models/access_list');
+const _                   = require('lodash');
+const error               = require('../lib/error');
+const accessListModel     = require('../models/access_list');
+const accessListAuthModel = require('../models/access_list_auth');
+const internalAuditLog    = require('./audit-log');
 
 function omissions () {
     return ['is_deleted'];
@@ -18,8 +20,51 @@ const internalAccessList = {
     create: (access, data) => {
         return access.can('access_lists:create', data)
             .then(access_data => {
-                // TODO
-                return {};
+                return accessListModel
+                    .query()
+                    .omit(omissions())
+                    .insertAndFetch({
+                        name:          data.name,
+                        owner_user_id: access.token.get('attrs').id
+                    });
+            })
+            .then(row => {
+                // Now add the items
+                let promises = [];
+                data.items.map(function (item) {
+                    promises.push(accessListAuthModel
+                        .query()
+                        .insert({
+                            access_list_id: row.id,
+                            username:       item.username,
+                            password:       item.password
+                        })
+                    );
+                });
+
+                return Promise.all(promises);
+            })
+            .then(row => {
+                // re-fetch with cert
+                return internalAccessList.get(access, {
+                    id:     row.id,
+                    expand: ['owner', 'items']
+                });
+            })
+            .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
+                })
+                    .then(() => {
+                        return row;
+                    });
             });
     },
 
@@ -62,7 +107,7 @@ const internalAccessList = {
                     .query()
                     .where('is_deleted', 0)
                     .andWhere('id', data.id)
-                    .allowEager('[owner]')
+                    .allowEager('[owner,items]')
                     .first();
 
                 if (access_data.permission_visibility !== 'all') {
@@ -82,6 +127,10 @@ const internalAccessList = {
             })
             .then(row => {
                 if (row) {
+                    if (typeof row.items !== 'undefined' && row.items) {
+                        row.items = internalAccessList.maskItems(row.items);
+                    }
+
                     return _.omit(row, omissions());
                 } else {
                     throw new error.ItemNotFoundError(data.id);
@@ -134,7 +183,7 @@ const internalAccessList = {
                     .where('is_deleted', 0)
                     .groupBy('id')
                     .omit(['is_deleted'])
-                    .allowEager('[owner]')
+                    .allowEager('[owner,items]')
                     .orderBy('name', 'ASC');
 
                 if (access_data.permission_visibility !== 'all') {
@@ -153,6 +202,17 @@ const internalAccessList = {
                 }
 
                 return query;
+            })
+            .then(rows => {
+                if (rows) {
+                    rows.map(function (row, idx) {
+                        if (typeof row.items !== 'undefined' && row.items) {
+                            rows[idx].items = internalAccessList.maskItems(row.items);
+                        }
+                    });
+                }
+
+                return rows;
             });
     },
 
@@ -177,6 +237,21 @@ const internalAccessList = {
             .then(row => {
                 return parseInt(row.count, 10);
             });
+    },
+
+    /**
+     * @param   {Object}  list
+     * @returns {Object}
+     */
+    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);
+                list.items[idx].password = '';
+            });
+        }
+
+        return list;
     }
 };
 

+ 36 - 1
src/backend/internal/certificate.js

@@ -41,7 +41,6 @@ const internalCertificate = {
             return utils.exec(certbot_command + ' renew -q ' + (debug_mode ? '--staging' : ''))
                 .then(result => {
                     logger.info(result);
-                    internalCertificate.interval_processing = false;
 
                     return internalNginx.reload()
                         .then(() => {
@@ -49,6 +48,42 @@ const internalCertificate = {
                             return result;
                         });
                 })
+                .then(() => {
+                    // Now go and fetch all the letsencrypt certs from the db and query the files and update expiry times
+                    return certificateModel
+                        .query()
+                        .where('is_deleted', 0)
+                        .andWhere('provider', 'letsencrypt')
+                        .then(certificates => {
+                            if (certificates && certificates.length) {
+                                let promises = [];
+
+                                certificates.map(function (certificate) {
+                                    promises.push(
+                                        internalCertificate.getCertificateInfoFromFile('/etc/letsencrypt/live/npm-' + certificate.id + '/fullchain.pem')
+                                            .then(cert_info => {
+                                                return certificateModel
+                                                    .query()
+                                                    .where('id', certificate.id)
+                                                    .andWhere('provider', 'letsencrypt')
+                                                    .patch({
+                                                        expires_on: certificateModel.raw('FROM_UNIXTIME(' + cert_info.dates.to + ')')
+                                                    });
+                                            })
+                                            .catch(err => {
+                                                // Don't want to stop the train here, just log the error
+                                                logger.error(err.message);
+                                            })
+                                    );
+                                });
+
+                                return Promise.all(promises);
+                            }
+                        });
+                })
+                .then(() => {
+                    internalCertificate.interval_processing = false;
+                })
                 .catch(err => {
                     logger.error(err);
                     internalCertificate.interval_processing = false;

+ 15 - 3
src/backend/models/access_list.js

@@ -3,9 +3,10 @@
 
 'use strict';
 
-const db    = require('../db');
-const Model = require('objection').Model;
-const User  = require('./user');
+const db             = require('../db');
+const Model          = require('objection').Model;
+const User           = require('./user');
+const AccessListAuth = require('./access_list_auth');
 
 Model.knex(db);
 
@@ -44,6 +45,17 @@ class AccessList extends Model {
                     qb.where('user.is_deleted', 0);
                     qb.omit(['id', 'created_on', 'modified_on', 'is_deleted', 'email', 'roles']);
                 }
+            },
+            items: {
+                relation:   Model.HasManyRelation,
+                modelClass: AccessListAuth,
+                join:       {
+                    from: 'access_list.id',
+                    to:   'access_list_auth.access_list_id'
+                },
+                modify:     function (qb) {
+                    qb.omit(['id', 'created_on', 'modified_on']);
+                }
             }
         };
     }

+ 1 - 1
src/backend/models/access_list_auth.js

@@ -34,7 +34,7 @@ class AccessListAuth extends Model {
         return {
             access_list: {
                 relation:   Model.HasOneRelation,
-                modelClass: './access_list',
+                modelClass: require('./access_list'),
                 join:       {
                     from: 'access_list_auth.access_list_id',
                     to:   'access_list.id'

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

@@ -75,7 +75,7 @@ router
  * /api/nginx/access-lists/123
  */
 router
-    .route('/:host_id')
+    .route('/:list_id')
     .options((req, res) => {
         res.sendStatus(204);
     })
@@ -88,10 +88,10 @@ router
      */
     .get((req, res, next) => {
         validator({
-            required:             ['host_id'],
+            required:             ['list_id'],
             additionalProperties: false,
             properties:           {
-                host_id: {
+                list_id: {
                     $ref: 'definitions#/definitions/id'
                 },
                 expand:  {
@@ -99,12 +99,12 @@ router
                 }
             }
         }, {
-            host_id: req.params.host_id,
+            list_id: req.params.list_id,
             expand:  (typeof req.query.expand === 'string' ? req.query.expand.split(',') : null)
         })
             .then(data => {
                 return internalAccessList.get(res.locals.access, {
-                    id:     parseInt(data.host_id, 10),
+                    id:     parseInt(data.list_id, 10),
                     expand: data.expand
                 });
             })
@@ -123,7 +123,7 @@ router
     .put((req, res, next) => {
         apiValidator({$ref: 'endpoints/access-lists#/links/2/schema'}, req.body)
             .then(payload => {
-                payload.id = parseInt(req.params.host_id, 10);
+                payload.id = parseInt(req.params.list_id, 10);
                 return internalAccessList.update(res.locals.access, payload);
             })
             .then(result => {
@@ -139,7 +139,7 @@ router
      * Update and existing access-list
      */
     .delete((req, res, next) => {
-        internalAccessList.delete(res.locals.access, {id: parseInt(req.params.host_id, 10)})
+        internalAccessList.delete(res.locals.access, {id: parseInt(req.params.list_id, 10)})
             .then(result => {
                 res.status(200)
                     .send(result);

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

@@ -0,0 +1,125 @@
+{
+  "$schema": "http://json-schema.org/draft-07/schema#",
+  "$id": "endpoints/access-lists",
+  "title": "Access Lists",
+  "description": "Endpoints relating to Access Lists",
+  "stability": "stable",
+  "type": "object",
+  "definitions": {
+    "id": {
+      "$ref": "../definitions.json#/definitions/id"
+    },
+    "created_on": {
+      "$ref": "../definitions.json#/definitions/created_on"
+    },
+    "modified_on": {
+      "$ref": "../definitions.json#/definitions/modified_on"
+    },
+    "name": {
+      "type": "string",
+      "description": "Name of the Access List"
+    },
+    "meta": {
+      "type": "object"
+    }
+  },
+  "properties": {
+    "id": {
+      "$ref": "#/definitions/id"
+    },
+    "created_on": {
+      "$ref": "#/definitions/created_on"
+    },
+    "modified_on": {
+      "$ref": "#/definitions/modified_on"
+    },
+    "name": {
+      "$ref": "#/definitions/name"
+    },
+    "meta": {
+      "$ref": "#/definitions/meta"
+    }
+  },
+  "links": [
+    {
+      "title": "List",
+      "description": "Returns a list of Access Lists",
+      "href": "/nginx/access-lists",
+      "access": "private",
+      "method": "GET",
+      "rel": "self",
+      "http_header": {
+        "$ref": "../examples.json#/definitions/auth_header"
+      },
+      "targetSchema": {
+        "type": "array",
+        "items": {
+          "$ref": "#/properties"
+        }
+      }
+    },
+    {
+      "title": "Create",
+      "description": "Creates a new Access List",
+      "href": "/nginx/access-list",
+      "access": "private",
+      "method": "POST",
+      "rel": "create",
+      "http_header": {
+        "$ref": "../examples.json#/definitions/auth_header"
+      },
+      "schema": {
+        "type": "object",
+        "additionalProperties": false,
+        "required": [
+          "name"
+        ],
+        "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": 1
+                }
+              }
+            }
+          },
+          "meta": {
+            "$ref": "#/definitions/meta"
+          }
+        }
+      },
+      "targetSchema": {
+        "properties": {
+          "$ref": "#/properties"
+        }
+      }
+    },
+    {
+      "title": "Delete",
+      "description": "Deletes a existing Access List",
+      "href": "/nginx/access-list/{definitions.identity.example}",
+      "access": "private",
+      "method": "DELETE",
+      "rel": "delete",
+      "http_header": {
+        "$ref": "../examples.json#/definitions/auth_header"
+      },
+      "targetSchema": {
+        "type": "boolean"
+      }
+    }
+  ]
+}

+ 3 - 0
src/backend/schema/index.json

@@ -31,6 +31,9 @@
     },
     "certificates": {
       "$ref": "endpoints/certificates.json"
+    },
+    "access-lists": {
+      "$ref": "endpoints/access-lists.json"
     }
   }
 }

+ 12 - 1
src/frontend/js/app/nginx/access/form.ejs

@@ -12,8 +12,19 @@
                         <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="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>
         </form>
     </div>
     <div class="modal-footer">

+ 51 - 10
src/frontend/js/app/nginx/access/form.js

@@ -4,20 +4,28 @@ const Mn              = require('backbone.marionette');
 const App             = require('../../main');
 const AccessListModel = require('../../../models/access-list');
 const template        = require('./form.ejs');
+const ItemView        = require('./form/item');
 
 require('jquery-serializejson');
-require('jquery-mask-plugin');
-require('selectize');
+
+const ItemsView = Mn.CollectionView.extend({
+    childView: ItemView
+});
 
 module.exports = Mn.View.extend({
     template:  template,
     className: 'modal-dialog',
 
     ui: {
-        form:    'form',
-        buttons: '.modal-footer button',
-        cancel:  'button.cancel',
-        save:    'button.save'
+        items_region: '.items',
+        form:         'form',
+        buttons:      '.modal-footer button',
+        cancel:       'button.cancel',
+        save:         'button.save'
+    },
+
+    regions: {
+        items_region: '@ui.items_region'
     },
 
     events: {
@@ -29,11 +37,28 @@ module.exports = Mn.View.extend({
                 return;
             }
 
-            let view = this;
-            let data = this.ui.form.serializeJSON();
+            let view       = this;
+            let form_data  = this.ui.form.serializeJSON();
+            let items_data = [];
 
-            // Manipulate
-            // ...
+            form_data.username.map(function (val, idx) {
+                if (val.trim().length) {
+                    items_data.push({
+                        username: val.trim(),
+                        password: form_data.password[idx]
+                    });
+                }
+            });
+
+            if (!items_data.length) {
+                alert('You must specify at least 1 Username and Password combination');
+                return;
+            }
+
+            let data = {
+                name:  form_data.name,
+                items: items_data
+            };
 
             let method = App.Api.Nginx.AccessLists.create;
             let is_new = true;
@@ -63,6 +88,22 @@ module.exports = Mn.View.extend({
         }
     },
 
+    onRender: function () {
+        let items = this.model.get('items');
+
+        // 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;
+        if (items_to_add) {
+            for (let i = 0; i < items_to_add; i++) {
+                items.push({});
+            }
+        }
+
+        this.showChildView('items_region', new ItemsView({
+            collection: new Backbone.Collection(items)
+        }));
+    },
+
     initialize: function (options) {
         if (typeof options.model === 'undefined' || !options.model) {
             this.model = new AccessListModel.Model();

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

@@ -0,0 +1,10 @@
+<div class="col-sm-6 col-md-6">
+    <div class="form-group">
+        <input type="text" name="username[]" class="form-control" value="<%- typeof username !== 'undefined' ? username : '' %>">
+    </div>
+</div>
+<div class="col-sm-6 col-md-6">
+    <div class="form-group">
+        <input type="password" name="password[]" class="form-control" placeholder="<%- typeof hint !== 'undefined' ? hint : '' %>" value="">
+    </div>
+</div>

+ 9 - 0
src/frontend/js/app/nginx/access/form/item.js

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

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

@@ -2,6 +2,7 @@
   "en": {
     "str": {
       "email-address": "Email address",
+      "username": "Username",
       "password": "Password",
       "sign-in": "Sign in",
       "sign-out": "Sign out",

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

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