diff --git a/.gitignore b/.gitignore index 0306177e..184fbc3c 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,5 @@ data/* yarn-error.log yarn.lock tmp +certbot.log + diff --git a/README.md b/README.md index 8521b099..5b3797d0 100644 --- a/README.md +++ b/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 diff --git a/TODO.md b/TODO.md index eda69623..eb3e1d0b 100644 --- a/TODO.md +++ b/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 diff --git a/doc/INSTALL.md b/doc/INSTALL.md index bed26b3e..16c7b1df 100644 --- a/doc/INSTALL.md +++ b/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: admin@example.com +Password: changeme +``` + +Immediately after logging in with this default user you will be asked to modify your details and change your password. diff --git a/package.json b/package.json index d87588fe..de2052b4 100644 --- a/package.json +++ b/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", diff --git a/rootfs/etc/services.d/manager/run b/rootfs/etc/services.d/manager/run index 13e14e8d..f70905d0 100755 --- a/rootfs/etc/services.d/manager/run +++ b/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 - diff --git a/rootfs/etc/services.d/nginx/run b/rootfs/etc/services.d/nginx/run index c85e1a23..8eb883b5 100755 --- a/rootfs/etc/services.d/nginx/run +++ b/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 diff --git a/src/backend/importer.js b/src/backend/importer.js new file mode 100644 index 00000000..80525f14 --- /dev/null +++ b/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(); + } + }); +}; diff --git a/src/backend/index.js b/src/backend/index.js index df3d63bd..d646df07 100644 --- a/src/backend/index.js +++ b/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; }) diff --git a/src/backend/internal/access-list.js b/src/backend/internal/access-list.js index 32d0f064..5a0b4b22 100644 --- a/src/backend/internal/access-list.js +++ b/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(() => { - return row; + if (row.proxy_host_count) { + return internalNginx.reload(); + } + }) + .then(() => { + // 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); + }); + }); + } + }); } }; diff --git a/src/backend/internal/proxy-host.js b/src/backend/internal/proxy-host.js index dc26ab2a..e3c07f74 100644 --- a/src/backend/internal/proxy-host.js +++ b/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 diff --git a/src/backend/logger.js b/src/backend/logger.js index 584287bf..2c618fdc 100644 --- a/src/backend/logger.js +++ b/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'}), }; diff --git a/src/backend/models/access_list.js b/src/backend/models/access_list.js index ca8f21ae..5bb0e355 100644 --- a/src/backend/models/access_list.js +++ b/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']); } } }; diff --git a/src/backend/routes/api/nginx/access_lists.js b/src/backend/routes/api/nginx/access_lists.js index f9560269..79bce0ef 100644 --- a/src/backend/routes/api/nginx/access_lists.js +++ b/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)}) diff --git a/src/backend/schema/endpoints/access-lists.json b/src/backend/schema/endpoints/access-lists.json index 8a69f3b6..da90a050 100644 --- a/src/backend/schema/endpoints/access-lists.json +++ b/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", diff --git a/src/backend/templates/proxy_host.conf b/src/backend/templates/proxy_host.conf index 300f1fce..76655c17 100644 --- a/src/backend/templates/proxy_host.conf +++ b/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" %} diff --git a/src/frontend/js/app/audit-log/list/item.ejs b/src/frontend/js/app/audit-log/list/item.ejs index 84743c8d..817680d4 100644 --- a/src/frontend/js/app/audit-log/list/item.ejs +++ b/src/frontend/js/app/audit-log/list/item.ejs @@ -39,7 +39,7 @@ items = meta.domain_names; break; case 'access-list': - %> <% + %> <% items.push(meta.name); break; case 'user': @@ -47,7 +47,7 @@ items.push(meta.name); break; case 'certificate': - %> <% + %> <% if (meta.provider === 'letsencrypt') { items = meta.domain_names; } else { diff --git a/src/frontend/js/app/controller.js b/src/frontend/js/app/controller.js index d1ced789..3f894748 100644 --- a/src/frontend/js/app/controller.js +++ b/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 */ diff --git a/src/frontend/js/app/nginx/access/delete.ejs b/src/frontend/js/app/nginx/access/delete.ejs index ba1b89eb..3833549a 100644 --- a/src/frontend/js/app/nginx/access/delete.ejs +++ b/src/frontend/js/app/nginx/access/delete.ejs @@ -8,6 +8,10 @@