diff --git a/rootfs/etc/nginx/conf.d/default.conf b/rootfs/etc/nginx/conf.d/default.conf index 490e2868..2530ec2e 100644 --- a/rootfs/etc/nginx/conf.d/default.conf +++ b/rootfs/etc/nginx/conf.d/default.conf @@ -22,10 +22,10 @@ server { } } -# Default 80 Host, which shows a "You are not configured" page +# "You are not configured" page, which is the default if another default doesn't exist server { - listen 80 default; - server_name localhost; + listen 80; + server_name localhost-nginx-proxy-manager; access_log /data/logs/default.log proxy; @@ -38,9 +38,9 @@ server { } } -# Default 443 Host +# First 443 Host, which is the default if another default doesn't exist server { - listen 443 ssl default; + listen 443 ssl; server_name localhost; access_log /data/logs/default.log proxy; diff --git a/rootfs/etc/nginx/nginx.conf b/rootfs/etc/nginx/nginx.conf index ad51c873..7d068736 100644 --- a/rootfs/etc/nginx/nginx.conf +++ b/rootfs/etc/nginx/nginx.conf @@ -70,6 +70,7 @@ http { # Files generated by NPM include /etc/nginx/conf.d/*.conf; + include /data/nginx/default_host/*.conf; include /data/nginx/proxy_host/*.conf; include /data/nginx/redirection_host/*.conf; include /data/nginx/dead_host/*.conf; diff --git a/rootfs/etc/services.d/nginx/run b/rootfs/etc/services.d/nginx/run index c7b6181e..f6b59fd6 100755 --- a/rootfs/etc/services.d/nginx/run +++ b/rootfs/etc/services.d/nginx/run @@ -7,6 +7,8 @@ mkdir -p /tmp/nginx/body \ /data/custom_ssl \ /data/logs \ /data/access \ + /data/nginx/default_host \ + /data/nginx/default_www \ /data/nginx/proxy_host \ /data/nginx/redirection_host \ /data/nginx/stream \ diff --git a/src/backend/internal/nginx.js b/src/backend/internal/nginx.js index fd8fe165..1e992992 100644 --- a/src/backend/internal/nginx.js +++ b/src/backend/internal/nginx.js @@ -17,9 +17,9 @@ const internalNginx = { * - IF BAD: update the meta with offline status and remove the config entirely * - then reload nginx * - * @param {Object} model - * @param {String} host_type - * @param {Object} host + * @param {Object|String} model + * @param {String} host_type + * @param {Object} host * @returns {Promise} */ configure: (model, host_type, host) => { @@ -122,6 +122,11 @@ const internalNginx = { */ getConfigName: (host_type, host_id) => { host_type = host_type.replace(new RegExp('-', 'g'), '_'); + + if (host_type === 'default') { + return '/data/nginx/default_host/site.conf'; + } + return '/data/nginx/' + host_type + '/' + host_id + '.conf'; }, @@ -153,9 +158,11 @@ const internalNginx = { } // Manipulate the data a bit before sending it to the template - host.use_default_location = true; - if (typeof host.advanced_config !== 'undefined' && host.advanced_config) { - host.use_default_location = !internalNginx.advancedConfigHasDefaultLocation(host.advanced_config); + if (host_type !== 'default') { + host.use_default_location = true; + if (typeof host.advanced_config !== 'undefined' && host.advanced_config) { + host.use_default_location = !internalNginx.advancedConfigHasDefaultLocation(host.advanced_config); + } } renderEngine @@ -260,7 +267,7 @@ const internalNginx = { /** * @param {String} host_type - * @param {Object} host + * @param {Object} [host] * @param {Boolean} [throw_errors] * @returns {Promise} */ @@ -269,7 +276,7 @@ const internalNginx = { return new Promise((resolve, reject) => { try { - let config_file = internalNginx.getConfigName(host_type, host.id); + let config_file = internalNginx.getConfigName(host_type, typeof host === 'undefined' ? 0 : host.id); if (debug_mode) { logger.warn('Deleting nginx config: ' + config_file); diff --git a/src/backend/internal/proxy-host.js b/src/backend/internal/proxy-host.js index 882d6ddd..9f1d9be8 100644 --- a/src/backend/internal/proxy-host.js +++ b/src/backend/internal/proxy-host.js @@ -108,7 +108,7 @@ const internalProxyHost = { */ update: (access, data) => { let create_certificate = data.certificate_id === 'new'; -console.log('PH UPDATE:', data); + if (create_certificate) { delete data.certificate_id; } diff --git a/src/backend/internal/setting.js b/src/backend/internal/setting.js new file mode 100644 index 00000000..eedb7d3b --- /dev/null +++ b/src/backend/internal/setting.js @@ -0,0 +1,133 @@ +const fs = require('fs'); +const error = require('../lib/error'); +const settingModel = require('../models/setting'); +const internalNginx = require('./nginx'); + +const internalSetting = { + + /** + * @param {Access} access + * @param {Object} data + * @param {String} data.id + * @return {Promise} + */ + update: (access, data) => { + return access.can('settings:update', data.id) + .then(access_data => { + return internalSetting.get(access, {id: data.id}); + }) + .then(row => { + if (row.id !== data.id) { + // Sanity check that something crazy hasn't happened + throw new error.InternalValidationError('Setting could not be updated, IDs do not match: ' + row.id + ' !== ' + data.id); + } + + return settingModel + .query() + .where({id: data.id}) + .patch(data); + }) + .then(() => { + return internalSetting.get(access, { + id: data.id + }); + }) + .then(row => { + if (row.id === 'default-site') { + // write the html if we need to + if (row.value === 'html') { + fs.writeFileSync('/data/nginx/default_www/index.html', row.meta.html, {encoding: 'utf8'}); + } + + // Configure nginx + return internalNginx.deleteConfig('default') + .then(() => { + return internalNginx.generateConfig('default', row); + }) + .then(() => { + return internalNginx.test(); + }) + .then(() => { + return internalNginx.reload(); + }) + .then(() => { + return row; + }) + .catch((err) => { + internalNginx.deleteConfig('default') + .then(() => { + return internalNginx.test(); + }) + .then(() => { + return internalNginx.reload(); + }) + .then(() => { + // I'm being slack here I know.. + throw new error.ValidationError('Could not reconfigure Nginx. Please check logs.'); + }) + }); + } else { + return row; + } + }); + }, + + /** + * @param {Access} access + * @param {Object} data + * @param {String} data.id + * @return {Promise} + */ + get: (access, data) => { + return access.can('settings:get', data.id) + .then(() => { + return settingModel + .query() + .where('id', data.id) + .first(); + }) + .then(row => { + if (row) { + return row; + } else { + throw new error.ItemNotFoundError(data.id); + } + }); + }, + + /** + * This will only count the settings + * + * @param {Access} access + * @returns {*} + */ + getCount: (access) => { + return access.can('settings:list') + .then(() => { + return settingModel + .query() + .count('id as count') + .first(); + }) + .then(row => { + return parseInt(row.count, 10); + }); + }, + + /** + * All settings + * + * @param {Access} access + * @returns {Promise} + */ + getAll: (access) => { + return access.can('settings:list') + .then(() => { + return settingModel + .query() + .orderBy('description', 'ASC'); + }); + } +}; + +module.exports = internalSetting; diff --git a/src/backend/lib/access/settings-get.json b/src/backend/lib/access/settings-get.json new file mode 100644 index 00000000..d2709fd8 --- /dev/null +++ b/src/backend/lib/access/settings-get.json @@ -0,0 +1,7 @@ +{ + "anyOf": [ + { + "$ref": "roles#/definitions/admin" + } + ] +} diff --git a/src/backend/lib/access/settings-list.json b/src/backend/lib/access/settings-list.json new file mode 100644 index 00000000..d2709fd8 --- /dev/null +++ b/src/backend/lib/access/settings-list.json @@ -0,0 +1,7 @@ +{ + "anyOf": [ + { + "$ref": "roles#/definitions/admin" + } + ] +} diff --git a/src/backend/lib/access/settings-update.json b/src/backend/lib/access/settings-update.json new file mode 100644 index 00000000..d2709fd8 --- /dev/null +++ b/src/backend/lib/access/settings-update.json @@ -0,0 +1,7 @@ +{ + "anyOf": [ + { + "$ref": "roles#/definitions/admin" + } + ] +} diff --git a/src/backend/migrations/20190227065017_settings.js b/src/backend/migrations/20190227065017_settings.js new file mode 100644 index 00000000..6ba3653f --- /dev/null +++ b/src/backend/migrations/20190227065017_settings.js @@ -0,0 +1,54 @@ +const migrate_name = 'settings'; +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('setting', table => { + table.string('id').notNull().primary(); + table.string('name', 100).notNull(); + table.string('description', 255).notNull(); + table.string('value', 255).notNull(); + table.json('meta').notNull(); + }) + .then(() => { + logger.info('[' + migrate_name + '] setting Table created'); + + // TODO: add settings + let settingModel = require('../models/setting'); + + return settingModel + .query() + .insert({ + id: 'default-site', + name: 'Default Site', + description: 'What to show when Nginx is hit with an unknown Host', + value: 'congratulations', + meta: {} + }); + }) + .then(() => { + logger.info('[' + migrate_name + '] Default settings added'); + }); +}; + +/** + * Undo Migrate + * + * @param {Object} knex + * @param {Promise} Promise + * @returns {Promise} + */ +exports.down = function (knex, Promise) { + logger.warn('[' + migrate_name + '] You can\'t migrate down the initial data.'); + return Promise.resolve(true); +}; diff --git a/src/backend/models/setting.js b/src/backend/models/setting.js new file mode 100644 index 00000000..2c3e57ee --- /dev/null +++ b/src/backend/models/setting.js @@ -0,0 +1,30 @@ +// Objection Docs: +// http://vincit.github.io/objection.js/ + +const db = require('../db'); +const Model = require('objection').Model; + +Model.knex(db); + +class Setting extends Model { + $beforeInsert () { + // Default for meta + if (typeof this.meta === 'undefined') { + this.meta = {}; + } + } + + static get name () { + return 'Setting'; + } + + static get tableName () { + return 'setting'; + } + + static get jsonAttributes () { + return ['meta']; + } +} + +module.exports = Setting; diff --git a/src/backend/routes/api/main.js b/src/backend/routes/api/main.js index cbc352ed..a9c885c4 100644 --- a/src/backend/routes/api/main.js +++ b/src/backend/routes/api/main.js @@ -31,6 +31,7 @@ router.use('/tokens', require('./tokens')); router.use('/users', require('./users')); router.use('/audit-log', require('./audit-log')); router.use('/reports', require('./reports')); +router.use('/settings', require('./settings')); router.use('/nginx/proxy-hosts', require('./nginx/proxy_hosts')); router.use('/nginx/redirection-hosts', require('./nginx/redirection_hosts')); router.use('/nginx/dead-hosts', require('./nginx/dead_hosts')); diff --git a/src/backend/routes/api/settings.js b/src/backend/routes/api/settings.js new file mode 100644 index 00000000..cc56db8f --- /dev/null +++ b/src/backend/routes/api/settings.js @@ -0,0 +1,96 @@ +const express = require('express'); +const validator = require('../../lib/validator'); +const jwtdecode = require('../../lib/express/jwt-decode'); +const internalSetting = require('../../internal/setting'); +const apiValidator = require('../../lib/validator/api'); + +let router = express.Router({ + caseSensitive: true, + strict: true, + mergeParams: true +}); + +/** + * /api/settings + */ +router + .route('/') + .options((req, res) => { + res.sendStatus(204); + }) + .all(jwtdecode()) + + /** + * GET /api/settings + * + * Retrieve all settings + */ + .get((req, res, next) => { + internalSetting.getAll(res.locals.access) + .then(rows => { + res.status(200) + .send(rows); + }) + .catch(next); + }); + +/** + * Specific setting + * + * /api/settings/something + */ +router + .route('/:setting_id') + .options((req, res) => { + res.sendStatus(204); + }) + .all(jwtdecode()) + + /** + * GET /settings/something + * + * Retrieve a specific setting + */ + .get((req, res, next) => { + validator({ + required: ['setting_id'], + additionalProperties: false, + properties: { + setting_id: { + $ref: 'definitions#/definitions/setting_id' + } + } + }, { + setting_id: req.params.setting_id + }) + .then(data => { + return internalSetting.get(res.locals.access, { + id: data.setting_id + }); + }) + .then(row => { + res.status(200) + .send(row); + }) + .catch(next); + }) + + /** + * PUT /api/settings/something + * + * Update and existing setting + */ + .put((req, res, next) => { + apiValidator({$ref: 'endpoints/settings#/links/1/schema'}, req.body) + .then(payload => { + payload.id = req.params.setting_id; + return internalSetting.update(res.locals.access, payload); + }) + .then(result => { + res.status(200) + .send(result); + }) + .catch(next); + }); + +module.exports = router; diff --git a/src/backend/schema/definitions.json b/src/backend/schema/definitions.json index eaf55958..2aa538b2 100644 --- a/src/backend/schema/definitions.json +++ b/src/backend/schema/definitions.json @@ -9,6 +9,13 @@ "type": "integer", "minimum": 1 }, + "setting_id": { + "description": "Unique identifier for a Setting", + "example": "default-site", + "readOnly": true, + "type": "string", + "minLength": 2 + }, "token": { "type": "string", "minLength": 10 diff --git a/src/backend/schema/endpoints/settings.json b/src/backend/schema/endpoints/settings.json new file mode 100644 index 00000000..29e2865a --- /dev/null +++ b/src/backend/schema/endpoints/settings.json @@ -0,0 +1,99 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "endpoints/settings", + "title": "Settings", + "description": "Endpoints relating to Settings", + "stability": "stable", + "type": "object", + "definitions": { + "id": { + "$ref": "../definitions.json#/definitions/setting_id" + }, + "name": { + "description": "Name", + "example": "Default Site", + "type": "string", + "minLength": 2, + "maxLength": 100 + }, + "description": { + "description": "Description", + "example": "Default Site", + "type": "string", + "minLength": 2, + "maxLength": 255 + }, + "value": { + "description": "Value", + "example": "404", + "type": "string", + "maxLength": 255 + }, + "meta": { + "type": "object" + } + }, + "links": [ + { + "title": "List", + "description": "Returns a list of Settings", + "href": "/settings", + "access": "private", + "method": "GET", + "rel": "self", + "http_header": { + "$ref": "../examples.json#/definitions/auth_header" + }, + "targetSchema": { + "type": "array", + "items": { + "$ref": "#/properties" + } + } + }, + { + "title": "Update", + "description": "Updates a existing Setting", + "href": "/settings/{definitions.identity.example}", + "access": "private", + "method": "PUT", + "rel": "update", + "http_header": { + "$ref": "../examples.json#/definitions/auth_header" + }, + "schema": { + "type": "object", + "properties": { + "value": { + "$ref": "#/definitions/value" + }, + "meta": { + "$ref": "#/definitions/meta" + } + } + }, + "targetSchema": { + "properties": { + "$ref": "#/properties" + } + } + } + ], + "properties": { + "id": { + "$ref": "#/definitions/id" + }, + "name": { + "$ref": "#/definitions/description" + }, + "description": { + "$ref": "#/definitions/description" + }, + "value": { + "$ref": "#/definitions/value" + }, + "meta": { + "$ref": "#/definitions/meta" + } + } +} diff --git a/src/backend/schema/index.json b/src/backend/schema/index.json index b61509bd..6e7d1c8a 100644 --- a/src/backend/schema/index.json +++ b/src/backend/schema/index.json @@ -34,6 +34,9 @@ }, "access-lists": { "$ref": "endpoints/access-lists.json" + }, + "settings": { + "$ref": "endpoints/settings.json" } } } diff --git a/src/backend/templates/default.conf b/src/backend/templates/default.conf new file mode 100644 index 00000000..8660e5bc --- /dev/null +++ b/src/backend/templates/default.conf @@ -0,0 +1,32 @@ +# ------------------------------------------------------------ +# Default Site +# ------------------------------------------------------------ +{% if value == "congratulations" %} +# Skipping output, congratulations page configration is baked in. +{%- else %} +server { + listen 80 default; + server_name default-host.localhost; + access_log /data/logs/default_host.log combined; +{% include "_exploits.conf" %} + +{%- if value == "404" %} + location / { + return 404; + } +{% endif %} + +{%- if value == "redirect" %} + location / { + return 301 {{ meta.redirect }}; + } +{%- endif %} + +{%- if value == "html" %} + root /data/nginx/default_www; + location / { + try_files $uri /index.html ={{ meta.http_code }}; + } +{%- endif %} +} +{% endif %} diff --git a/src/frontend/js/app/api.js b/src/frontend/js/app/api.js index cc3b5ce6..c8d57193 100644 --- a/src/frontend/js/app/api.js +++ b/src/frontend/js/app/api.js @@ -662,5 +662,34 @@ module.exports = { getHostStats: function () { return fetch('get', 'reports/hosts'); } + }, + + Settings: { + + /** + * @param {String} setting_id + * @returns {Promise} + */ + getById: function (setting_id) { + return fetch('get', 'settings/' + setting_id); + }, + + /** + * @returns {Promise} + */ + getAll: function () { + return getAllObjects('settings'); + }, + + /** + * @param {Object} data + * @param {Number} data.id + * @returns {Promise} + */ + update: function (data) { + let id = data.id; + delete data.id; + return fetch('put', 'settings/' + id, data); + } } }; diff --git a/src/frontend/js/app/controller.js b/src/frontend/js/app/controller.js index 3f894748..7e516434 100644 --- a/src/frontend/js/app/controller.js +++ b/src/frontend/js/app/controller.js @@ -383,6 +383,36 @@ module.exports = { } }, + /** + * Settings + */ + showSettings: function () { + let controller = this; + if (Cache.User.isAdmin()) { + require(['./main', './settings/main'], (App, View) => { + controller.navigate('/settings'); + App.UI.showAppContent(new View()); + }); + } else { + this.showDashboard(); + } + }, + + /** + * Settings Item Form + * + * @param model + */ + showSettingForm: function (model) { + if (Cache.User.isAdmin()) { + if (model.get('id') === 'default-site') { + require(['./main', './settings/default-site/main'], function (App, View) { + App.UI.showModalDialog(new View({model: model})); + }); + } + } + }, + /** * Logout */ diff --git a/src/frontend/js/app/router.js b/src/frontend/js/app/router.js index f6b686f2..790ef817 100644 --- a/src/frontend/js/app/router.js +++ b/src/frontend/js/app/router.js @@ -15,6 +15,7 @@ module.exports = AppRouter.default.extend({ 'nginx/access': 'showNginxAccess', 'nginx/certificates': 'showNginxCertificates', 'audit-log': 'showAuditLog', + 'settings': 'showSettings', '*default': 'showDashboard' } }); diff --git a/src/frontend/js/app/settings/default-site/main.ejs b/src/frontend/js/app/settings/default-site/main.ejs new file mode 100644 index 00000000..d434c905 --- /dev/null +++ b/src/frontend/js/app/settings/default-site/main.ejs @@ -0,0 +1,77 @@ +
diff --git a/src/frontend/js/app/settings/default-site/main.js b/src/frontend/js/app/settings/default-site/main.js new file mode 100644 index 00000000..4bd14e5c --- /dev/null +++ b/src/frontend/js/app/settings/default-site/main.js @@ -0,0 +1,71 @@ +'use strict'; + +const Mn = require('backbone.marionette'); +const App = require('../../main'); +const template = require('./main.ejs'); + +require('jquery-serializejson'); +require('selectize'); + +module.exports = Mn.View.extend({ + template: template, + className: 'modal-dialog', + + ui: { + form: 'form', + buttons: '.modal-footer button', + cancel: 'button.cancel', + save: 'button.save', + options: '.option-item', + value: 'input[name="value"]', + redirect: '.redirect-input', + html: '.html-content' + }, + + events: { + 'change @ui.value': function (e) { + let val = this.ui.value.filter(':checked').val(); + this.ui.options.hide(); + this.ui.options.filter('.option-' + val).show(); + }, + + 'click @ui.save': function (e) { + e.preventDefault(); + + let val = this.ui.value.filter(':checked').val(); + + // Clear redirect field before validation + if (val !== 'redirect') { + this.ui.redirect.val('').attr('required', false); + } else { + this.ui.redirect.attr('required', true); + } + + this.ui.html.attr('required', val === 'html'); + + if (!this.ui.form[0].checkValidity()) { + $('').hide().appendTo(this.ui.form).click().remove(); + return; + } + + let view = this; + let data = this.ui.form.serializeJSON(); + data.id = this.model.get('id'); + + this.ui.buttons.prop('disabled', true).addClass('btn-disabled'); + App.Api.Settings.update(data) + .then(result => { + view.model.set(result); + App.UI.closeModal(); + }) + .catch(err => { + alert(err.message); + this.ui.buttons.prop('disabled', false).removeClass('btn-disabled'); + }); + } + }, + + onRender: function () { + this.ui.value.trigger('change'); + } +}); diff --git a/src/frontend/js/app/settings/list/item.ejs b/src/frontend/js/app/settings/list/item.ejs new file mode 100644 index 00000000..4f81b450 --- /dev/null +++ b/src/frontend/js/app/settings/list/item.ejs @@ -0,0 +1,21 @@ +