diff --git a/backend/internal/host.js b/backend/internal/host.js index 58e1d09a..f37b943d 100644 --- a/backend/internal/host.js +++ b/backend/internal/host.js @@ -1,7 +1,8 @@ -const _ = require('lodash'); -const proxyHostModel = require('../models/proxy_host'); -const redirectionHostModel = require('../models/redirection_host'); -const deadHostModel = require('../models/dead_host'); +const _ = require('lodash'); +const proxyHostModel = require('../models/proxy_host'); +const redirectionHostModel = require('../models/redirection_host'); +const deadHostModel = require('../models/dead_host'); +const sslPassthroughHostModel = require('../models/ssl_passthrough_host'); const internalHost = { @@ -81,6 +82,9 @@ const internalHost = { .query() .where('is_deleted', 0), deadHostModel + .query() + .where('is_deleted', 0), + sslPassthroughHostModel .query() .where('is_deleted', 0) ]; @@ -112,6 +116,12 @@ const internalHost = { response_object.total_count += response_object.dead_hosts.length; } + if (promises_results[3]) { + // SSL Passthrough Hosts + response_object.ssl_passthrough_hosts = internalHost._getHostsWithDomains(promises_results[3], domain_names); + response_object.total_count += response_object.ssl_passthrough_hosts.length; + } + return response_object; }); }, @@ -137,7 +147,11 @@ const internalHost = { deadHostModel .query() .where('is_deleted', 0) - .andWhere('domain_names', 'like', '%' + hostname + '%') + .andWhere('domain_names', 'like', '%' + hostname + '%'), + sslPassthroughHostModel + .query() + .where('is_deleted', 0) + .andWhere('domain_name', '=', hostname), ]; return Promise.all(promises) @@ -165,6 +179,13 @@ const internalHost = { } } + if (promises_results[3]) { + // SSL Passthrough Hosts + if (internalHost._checkHostnameRecordsTaken(hostname, promises_results[3], ignore_type === 'ssl_passthrough' && ignore_id ? ignore_id : 0)) { + is_taken = true; + } + } + return { hostname: hostname, is_taken: is_taken diff --git a/backend/internal/nginx.js b/backend/internal/nginx.js index 52bdd66d..9215df9a 100644 --- a/backend/internal/nginx.js +++ b/backend/internal/nginx.js @@ -1,10 +1,11 @@ -const _ = require('lodash'); -const fs = require('fs'); -const logger = require('../logger').nginx; -const utils = require('../lib/utils'); -const error = require('../lib/error'); -const { Liquid } = require('liquidjs'); -const debug_mode = process.env.NODE_ENV !== 'production' || !!process.env.DEBUG; +const _ = require('lodash'); +const fs = require('fs'); +const logger = require('../logger').nginx; +const utils = require('../lib/utils'); +const error = require('../lib/error'); +const { Liquid } = require('liquidjs'); +const passthroughHostModel = require('../models/ssl_passthrough_host'); +const debug_mode = process.env.NODE_ENV !== 'production' || !!process.env.DEBUG; const internalNginx = { @@ -44,12 +45,21 @@ const internalNginx = { nginx_err: null }); + if(host_type === 'ssl_passthrough_host'){ + return passthroughHostModel + .query() + .patch({ + meta: combined_meta + }); + } + return model .query() .where('id', host.id) .patch({ meta: combined_meta }); + }) .catch((err) => { // Remove the error_log line because it's a docker-ism false positive that doesn't need to be reported. @@ -125,6 +135,8 @@ const internalNginx = { if (host_type === 'default') { return '/data/nginx/default_host/site.conf'; + } else if (host_type === 'ssl_passthrough_host') { + return '/data/nginx/ssl_passthrough_host/hosts.conf'; } return '/data/nginx/' + host_type + '/' + host_id + '.conf'; @@ -199,7 +211,7 @@ const internalNginx = { root: __dirname + '/../templates/' }); - return new Promise((resolve, reject) => { + return new Promise(async (resolve, reject) => { let template = null; let filename = internalNginx.getConfigName(host_type, host.id); @@ -214,7 +226,25 @@ const internalNginx = { let origLocations; // Manipulate the data a bit before sending it to the template - if (host_type !== 'default') { + if (host_type === 'ssl_passthrough_host') { + if(internalNginx.sslPassthroughEnabled()){ + const allHosts = await passthroughHostModel + .query() + .where('is_deleted', 0) + .groupBy('id') + .omit(['is_deleted']); + host = { + all_passthrough_hosts: allHosts.map((host) => { + // Replace dots in domain + host.escaped_name = host.domain_name.replace(/\./, '_'); + host.forwarding_host = internalNginx.addIpv6Brackets(host.forwarding_host); + }), + } + } else { + internalNginx.deleteConfig(host_type, host) + } + + } else 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); @@ -429,6 +459,33 @@ const internalNginx = { } return true; + }, + + /** + * @returns {boolean} + */ + sslPassthroughEnabled: function () { + if (typeof process.env.ENABLE_SSL_PASSTHROUGH !== 'undefined') { + const enabled = process.env.ENABLE_SSL_PASSTHROUGH.toLowerCase(); + return (enabled === 'on' || enabled === 'true' || enabled === '1' || enabled === 'yes'); + } + + return true; + }, + + /** + * Helper function to add brackets to an IP if it is IPv6 + * @returns {string} + */ + addIpv6Brackets: function (ip) { + // Only run check if ipv6 is enabled + if (internalNginx.ipv6Enabled()) { + const ipv6Regex = /^(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))$/gi; + if(ipv6Regex.test(ip)){ + return `[${ip}]` + } + } + return ip; } }; diff --git a/backend/internal/ssl-passthrough-host.js b/backend/internal/ssl-passthrough-host.js new file mode 100644 index 00000000..a4f0d57d --- /dev/null +++ b/backend/internal/ssl-passthrough-host.js @@ -0,0 +1,381 @@ +const _ = require('lodash'); +const error = require('../lib/error'); +const passthroughHostModel = require('../models/ssl_passthrough_host'); +const internalHost = require('./host'); +const internalNginx = require('./nginx'); +const internalAuditLog = require('./audit-log'); + +function omissions () { + return ['is_deleted']; +} + +const internalPassthroughHost = { + + /** + * @param {Access} access + * @param {Object} data + * @returns {Promise} + */ + create: (access, data) => { + return access.can('ssl_passthrough_hosts:create', data) + .then(() => { + // Get a list of the domain names and check each of them against existing records + let domain_name_check_promises = []; + + data.domain_names.map(function (domain_name) { + domain_name_check_promises.push(internalHost.isHostnameTaken(domain_name)); + }); + + return Promise.all(domain_name_check_promises) + .then((check_results) => { + check_results.map(function (result) { + if (result.is_taken) { + throw new error.ValidationError(result.hostname + ' is already in use'); + } + }); + }); + }).then((/*access_data*/) => { + data.owner_user_id = access.token.getUserId(1); + + if (typeof data.meta === 'undefined') { + data.meta = {}; + } + + return passthroughHostModel + .query() + .omit(omissions()) + .insertAndFetch(data); + }) + .then((row) => { + // Configure nginx + return internalNginx.configure(passthroughHostModel, 'ssl_passthrough_host', {}) + .then(() => { + return internalPassthroughHost.get(access, {id: row.id, expand: ['owner']}); + }); + }) + .then((row) => { + // Add to audit log + return internalAuditLog.add(access, { + action: 'created', + object_type: 'ssl_passthrough_host', + object_id: row.id, + meta: data + }) + .then(() => { + return row; + }); + }); + }, + + /** + * @param {Access} access + * @param {Object} data + * @param {Number} data.id + * @return {Promise} + */ + update: (access, data) => { + return access.can('ssl_passthrough_hosts:update', data.id) + .then((/*access_data*/) => { + // Get a list of the domain names and check each of them against existing records + let domain_name_check_promises = []; + + if (typeof data.domain_names !== 'undefined') { + data.domain_names.map(function (domain_name) { + domain_name_check_promises.push(internalHost.isHostnameTaken(domain_name, 'ssl_passthrough', data.id)); + }); + + return Promise.all(domain_name_check_promises) + .then((check_results) => { + check_results.map(function (result) { + if (result.is_taken) { + throw new error.ValidationError(result.hostname + ' is already in use'); + } + }); + }); + } + }).then((/*access_data*/) => { + return internalPassthroughHost.get(access, {id: data.id}); + }) + .then((row) => { + if (row.id !== data.id) { + // Sanity check that something crazy hasn't happened + throw new error.InternalValidationError('SSL Passthrough Host could not be updated, IDs do not match: ' + row.id + ' !== ' + data.id); + } + + return passthroughHostModel + .query() + .omit(omissions()) + .patchAndFetchById(row.id, data) + .then(() => { + return internalNginx.configure(passthroughHostModel, 'ssl_passthrough_host', {}) + .then(() => { + return internalPassthroughHost.get(access, {id: row.id, expand: ['owner']}); + }); + }) + .then((saved_row) => { + // Add to audit log + return internalAuditLog.add(access, { + action: 'updated', + object_type: 'ssl_passthrough_host', + object_id: row.id, + meta: data + }) + .then(() => { + return _.omit(saved_row, omissions()); + }); + }); + }); + }, + + /** + * @param {Access} access + * @param {Object} data + * @param {Number} data.id + * @param {Array} [data.expand] + * @param {Array} [data.omit] + * @return {Promise} + */ + get: (access, data) => { + if (typeof data === 'undefined') { + data = {}; + } + + return access.can('ssl_passthrough_hosts:get', data.id) + .then((access_data) => { + let query = passthroughHostModel + .query() + .where('is_deleted', 0) + .andWhere('id', data.id) + .allowEager('[owner]') + .first(); + + if (access_data.permission_visibility !== 'all') { + query.andWhere('owner_user_id', access.token.getUserId(1)); + } + + // Custom omissions + if (typeof data.omit !== 'undefined' && data.omit !== null) { + query.omit(data.omit); + } + + if (typeof data.expand !== 'undefined' && data.expand !== null) { + query.eager('[' + data.expand.join(', ') + ']'); + } + + return query; + }) + .then((row) => { + if (row) { + return _.omit(row, omissions()); + } else { + throw new error.ItemNotFoundError(data.id); + } + }); + }, + + /** + * @param {Access} access + * @param {Object} data + * @param {Number} data.id + * @param {String} [data.reason] + * @returns {Promise} + */ + delete: (access, data) => { + return access.can('ssl_passthrough_hosts:delete', data.id) + .then(() => { + return internalPassthroughHost.get(access, {id: data.id}); + }) + .then((row) => { + if (!row) { + throw new error.ItemNotFoundError(data.id); + } + + return passthroughHostModel + .query() + .where('id', row.id) + .patch({ + is_deleted: 1 + }) + .then(() => { + // Update Nginx Config + return internalNginx.configure(passthroughHostModel, 'ssl_passthrough_host', {}) + .then(() => { + return internalNginx.reload(); + }); + }) + .then(() => { + // Add to audit log + return internalAuditLog.add(access, { + action: 'deleted', + object_type: 'ssl_passthrough_host', + object_id: row.id, + meta: _.omit(row, omissions()) + }); + }); + }) + .then(() => { + return true; + }); + }, + + /** + * @param {Access} access + * @param {Object} data + * @param {Number} data.id + * @param {String} [data.reason] + * @returns {Promise} + */ + enable: (access, data) => { + return access.can('ssl_passthrough_hosts:update', data.id) + .then(() => { + return internalPassthroughHost.get(access, { + id: data.id, + expand: ['owner'] + }); + }) + .then((row) => { + if (!row) { + throw new error.ItemNotFoundError(data.id); + } else if (row.enabled) { + throw new error.ValidationError('Host is already enabled'); + } + + row.enabled = 1; + + return passthroughHostModel + .query() + .where('id', row.id) + .patch({ + enabled: 1 + }) + .then(() => { + // Configure nginx + return internalNginx.configure(passthroughHostModel, 'ssl_passthrough_host', {}); + }) + .then(() => { + // Add to audit log + return internalAuditLog.add(access, { + action: 'enabled', + object_type: 'ssl_passthrough_host', + object_id: row.id, + meta: _.omit(row, omissions()) + }); + }); + }) + .then(() => { + return true; + }); + }, + + /** + * @param {Access} access + * @param {Object} data + * @param {Number} data.id + * @param {String} [data.reason] + * @returns {Promise} + */ + disable: (access, data) => { + return access.can('ssl_passthrough_hosts:update', data.id) + .then(() => { + return internalPassthroughHost.get(access, {id: data.id}); + }) + .then((row) => { + if (!row) { + throw new error.ItemNotFoundError(data.id); + } else if (!row.enabled) { + throw new error.ValidationError('Host is already disabled'); + } + + row.enabled = 0; + + return passthroughHostModel + .query() + .where('id', row.id) + .patch({ + enabled: 0 + }) + .then(() => { + // Update Nginx Config + return internalNginx.configure(passthroughHostModel, 'ssl_passthrough_host', {}) + .then(() => { + return internalNginx.reload(); + }); + }) + .then(() => { + // Add to audit log + return internalAuditLog.add(access, { + action: 'disabled', + object_type: 'ssl_passthrough_host', + object_id: row.id, + meta: _.omit(row, omissions()) + }); + }); + }) + .then(() => { + return true; + }); + }, + + /** + * All SSL Passthrough Hosts + * + * @param {Access} access + * @param {Array} [expand] + * @param {String} [search_query] + * @returns {Promise} + */ + getAll: (access, expand, search_query) => { + return access.can('ssl_passthrough_hosts:list') + .then((access_data) => { + let query = passthroughHostModel + .query() + .where('is_deleted', 0) + .groupBy('id') + .omit(['is_deleted']) + .allowEager('[owner]') + .orderBy('domain_name', 'ASC'); + + if (access_data.permission_visibility !== 'all') { + query.andWhere('owner_user_id', access.token.getUserId(1)); + } + + // Query is used for searching + if (typeof search_query === 'string') { + query.where(function () { + this.where('domain_name', 'like', '%' + search_query + '%'); + }); + } + + if (typeof expand !== 'undefined' && expand !== null) { + query.eager('[' + expand.join(', ') + ']'); + } + + return query; + }); + }, + + /** + * Report use + * + * @param {Number} user_id + * @param {String} visibility + * @returns {Promise} + */ + getCount: (user_id, visibility) => { + let query = passthroughHostModel + .query() + .count('id as count') + .where('is_deleted', 0); + + if (visibility !== 'all') { + query.andWhere('owner_user_id', user_id); + } + + return query.first() + .then((row) => { + return parseInt(row.count, 10); + }); + } +}; + +module.exports = internalPassthroughHost; diff --git a/backend/internal/user.js b/backend/internal/user.js index 2e2d8abf..7ad4c4ea 100644 --- a/backend/internal/user.js +++ b/backend/internal/user.js @@ -62,14 +62,15 @@ const internalUser = { return userPermissionModel .query() .insert({ - user_id: user.id, - visibility: is_admin ? 'all' : 'user', - proxy_hosts: 'manage', - redirection_hosts: 'manage', - dead_hosts: 'manage', - streams: 'manage', - access_lists: 'manage', - certificates: 'manage' + user_id: user.id, + visibility: is_admin ? 'all' : 'user', + proxy_hosts: 'manage', + redirection_hosts: 'manage', + dead_hosts: 'manage', + ssl_passthrough_hosts: 'manage', + streams: 'manage', + access_lists: 'manage', + certificates: 'manage' }) .then(() => { return internalUser.get(access, {id: user.id, expand: ['permissions']}); diff --git a/backend/lib/access/ssl_passthrough_hosts-create.json b/backend/lib/access/ssl_passthrough_hosts-create.json new file mode 100644 index 00000000..63f278b8 --- /dev/null +++ b/backend/lib/access/ssl_passthrough_hosts-create.json @@ -0,0 +1,23 @@ +{ + "anyOf": [ + { + "$ref": "roles#/definitions/admin" + }, + { + "type": "object", + "required": ["permission_ssl_passthrough_hosts", "roles"], + "properties": { + "permission_ssl_passthrough_hosts": { + "$ref": "perms#/definitions/manage" + }, + "roles": { + "type": "array", + "items": { + "type": "string", + "enum": ["user"] + } + } + } + } + ] +} diff --git a/backend/lib/access/ssl_passthrough_hosts-delete.json b/backend/lib/access/ssl_passthrough_hosts-delete.json new file mode 100644 index 00000000..63f278b8 --- /dev/null +++ b/backend/lib/access/ssl_passthrough_hosts-delete.json @@ -0,0 +1,23 @@ +{ + "anyOf": [ + { + "$ref": "roles#/definitions/admin" + }, + { + "type": "object", + "required": ["permission_ssl_passthrough_hosts", "roles"], + "properties": { + "permission_ssl_passthrough_hosts": { + "$ref": "perms#/definitions/manage" + }, + "roles": { + "type": "array", + "items": { + "type": "string", + "enum": ["user"] + } + } + } + } + ] +} diff --git a/backend/lib/access/ssl_passthrough_hosts-get.json b/backend/lib/access/ssl_passthrough_hosts-get.json new file mode 100644 index 00000000..ab0ec15e --- /dev/null +++ b/backend/lib/access/ssl_passthrough_hosts-get.json @@ -0,0 +1,23 @@ +{ + "anyOf": [ + { + "$ref": "roles#/definitions/admin" + }, + { + "type": "object", + "required": ["permission_ssl_passthrough_hosts", "roles"], + "properties": { + "permission_ssl_passthrough_hosts": { + "$ref": "perms#/definitions/view" + }, + "roles": { + "type": "array", + "items": { + "type": "string", + "enum": ["user"] + } + } + } + } + ] +} diff --git a/backend/lib/access/ssl_passthrough_hosts-list.json b/backend/lib/access/ssl_passthrough_hosts-list.json new file mode 100644 index 00000000..ab0ec15e --- /dev/null +++ b/backend/lib/access/ssl_passthrough_hosts-list.json @@ -0,0 +1,23 @@ +{ + "anyOf": [ + { + "$ref": "roles#/definitions/admin" + }, + { + "type": "object", + "required": ["permission_ssl_passthrough_hosts", "roles"], + "properties": { + "permission_ssl_passthrough_hosts": { + "$ref": "perms#/definitions/view" + }, + "roles": { + "type": "array", + "items": { + "type": "string", + "enum": ["user"] + } + } + } + } + ] +} diff --git a/backend/lib/access/ssl_passthrough_hosts-update.json b/backend/lib/access/ssl_passthrough_hosts-update.json new file mode 100644 index 00000000..63f278b8 --- /dev/null +++ b/backend/lib/access/ssl_passthrough_hosts-update.json @@ -0,0 +1,23 @@ +{ + "anyOf": [ + { + "$ref": "roles#/definitions/admin" + }, + { + "type": "object", + "required": ["permission_ssl_passthrough_hosts", "roles"], + "properties": { + "permission_ssl_passthrough_hosts": { + "$ref": "perms#/definitions/manage" + }, + "roles": { + "type": "array", + "items": { + "type": "string", + "enum": ["user"] + } + } + } + } + ] +} diff --git a/backend/migrations/20211010141200_ssl_passthrough_host.js b/backend/migrations/20211010141200_ssl_passthrough_host.js new file mode 100644 index 00000000..9a92442b --- /dev/null +++ b/backend/migrations/20211010141200_ssl_passthrough_host.js @@ -0,0 +1,46 @@ +const migrate_name = 'ssl_passthrough_host'; +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('ssl_passthrough_host', (table) => { + table.increments().primary(); + table.dateTime('created_on').notNull(); + table.dateTime('modified_on').notNull(); + table.integer('owner_user_id').notNull().unsigned(); + table.integer('is_deleted').notNull().unsigned().defaultTo(0); + table.string('domain_name').notNull(); + table.string('forward_ip').notNull(); + table.integer('forwarding_port').notNull().unsigned(); + table.json('meta').notNull(); + }) + .then(() => { + logger.info('[' + migrate_name + '] Table created'); + }); +}; + +/** + * 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('stream') + .then(function () { + logger.info('[' + migrate_name + '] Table altered'); + }); +}; diff --git a/backend/models/ssl_passthrough_host.js b/backend/models/ssl_passthrough_host.js new file mode 100644 index 00000000..65875460 --- /dev/null +++ b/backend/models/ssl_passthrough_host.js @@ -0,0 +1,56 @@ +// Objection Docs: +// http://vincit.github.io/objection.js/ + +const db = require('../db'); +const Model = require('objection').Model; +const User = require('./user'); +const now = require('./now_helper'); + +Model.knex(db); + +class SslPassthrougHost extends Model { + $beforeInsert () { + this.created_on = now(); + this.modified_on = now(); + + // Default for meta + if (typeof this.meta === 'undefined') { + this.meta = {}; + } + } + + $beforeUpdate () { + this.modified_on = now(); + } + + static get name () { + return 'SslPassthrougHost'; + } + + static get tableName () { + return 'ssl_passthrough_host'; + } + + static get jsonAttributes () { + return ['meta']; + } + + static get relationMappings () { + return { + owner: { + relation: Model.HasOneRelation, + modelClass: User, + join: { + from: 'ssl_passthrough_host.owner_user_id', + to: 'user.id' + }, + modify: function (qb) { + qb.where('user.is_deleted', 0); + qb.omit(['id', 'created_on', 'modified_on', 'is_deleted', 'email', 'roles']); + } + } + }; + } +} + +module.exports = SslPassthrougHost; diff --git a/backend/routes/api/main.js b/backend/routes/api/main.js index 33cbbc21..48b095a8 100644 --- a/backend/routes/api/main.js +++ b/backend/routes/api/main.js @@ -1,6 +1,7 @@ -const express = require('express'); -const pjson = require('../../package.json'); -const error = require('../../lib/error'); +const express = require('express'); +const pjson = require('../../package.json'); +const error = require('../../lib/error'); +const internalNginx = require('../../internal/nginx'); let router = express.Router({ caseSensitive: true, @@ -34,10 +35,18 @@ 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')); +router.use('/nginx/ssl-passthrough-hosts', require('./nginx/ssl_passthrough_hosts')); router.use('/nginx/streams', require('./nginx/streams')); router.use('/nginx/access-lists', require('./nginx/access_lists')); router.use('/nginx/certificates', require('./nginx/certificates')); +router.get('/ssl-passthrough-enabled', (req, res/*, next*/) => { + res.status(200).send({ + status: 'OK', + ssl_passthrough_enabled: internalNginx.sslPassthroughEnabled() + }); +}); + /** * API 404 for all other routes * diff --git a/backend/routes/api/nginx/ssl_passthrough_hosts.js b/backend/routes/api/nginx/ssl_passthrough_hosts.js new file mode 100644 index 00000000..5eb75f71 --- /dev/null +++ b/backend/routes/api/nginx/ssl_passthrough_hosts.js @@ -0,0 +1,196 @@ +const express = require('express'); +const validator = require('../../../lib/validator'); +const jwtdecode = require('../../../lib/express/jwt-decode'); +const internalSslPassthrough = require('../../../internal/ssl-passthrough-host'); +const apiValidator = require('../../../lib/validator/api'); + +let router = express.Router({ + caseSensitive: true, + strict: true, + mergeParams: true +}); + +/** + * /api/nginx/ssl-passthrough-hosts + */ +router + .route('/') + .options((req, res) => { + res.sendStatus(204); + }) + .all(jwtdecode()) // preferred so it doesn't apply to nonexistent routes + + /** + * GET /api/nginx/ssl-passthrough-hosts + * + * Retrieve all ssl passthrough hosts + */ + .get((req, res, next) => { + validator({ + additionalProperties: false, + properties: { + expand: { + $ref: 'definitions#/definitions/expand' + }, + query: { + $ref: 'definitions#/definitions/query' + } + } + }, { + expand: (typeof req.query.expand === 'string' ? req.query.expand.split(',') : null), + query: (typeof req.query.query === 'string' ? req.query.query : null) + }) + .then((data) => { + return internalSslPassthrough.getAll(res.locals.access, data.expand, data.query); + }) + .then((rows) => { + res.status(200) + .send(rows); + }) + .catch(next); + }) + + /** + * POST /api/nginx/ssl-passthrough-hosts + * + * Create a new ssl passthrough host + */ + .post((req, res, next) => { + apiValidator({$ref: 'endpoints/ssl-passthrough-hosts#/links/1/schema'}, req.body) + .then((payload) => { + return internalSslPassthrough.create(res.locals.access, payload); + }) + .then((result) => { + res.status(201) + .send(result); + }) + .catch(next); + }); + +/** + * Specific ssl passthrough host + * + * /api/nginx/ssl-passthrough-hosts/123 + */ +router + .route('/:ssl_passthrough_host_id') + .options((req, res) => { + res.sendStatus(204); + }) + .all(jwtdecode()) // preferred so it doesn't apply to nonexistent routes + + /** + * GET /api/nginx/ssl-passthrough-hosts/123 + * + * Retrieve a specific ssl passthrough host + */ + .get((req, res, next) => { + validator({ + required: ['ssl_passthrough_host_id'], + additionalProperties: false, + properties: { + host_id: { + $ref: 'definitions#/definitions/id' + }, + expand: { + $ref: 'definitions#/definitions/expand' + } + } + }, { + host_id: req.params.host_id, + expand: (typeof req.query.expand === 'string' ? req.query.expand.split(',') : null) + }) + .then((data) => { + return internalSslPassthrough.get(res.locals.access, { + id: parseInt(data.host_id, 10), + expand: data.expand + }); + }) + .then((row) => { + res.status(200) + .send(row); + }) + .catch(next); + }) + + /** + * PUT /api/nginx/ssl-passthrough-hosts/123 + * + * Update an existing ssl passthrough host + */ + .put((req, res, next) => { + apiValidator({$ref: 'endpoints/ssl-passthrough-hosts#/links/2/schema'}, req.body) + .then((payload) => { + payload.id = parseInt(req.params.host_id, 10); + return internalSslPassthrough.update(res.locals.access, payload); + }) + .then((result) => { + res.status(200) + .send(result); + }) + .catch(next); + }) + + /** + * DELETE /api/nginx/ssl-passthrough-hosts/123 + * + * Delete an ssl passthrough host + */ + .delete((req, res, next) => { + internalSslPassthrough.delete(res.locals.access, {id: parseInt(req.params.host_id, 10)}) + .then((result) => { + res.status(200) + .send(result); + }) + .catch(next); + }); + +/** + * Enable ssl passthrough host + * + * /api/nginx/ssl-passthrough-hosts/123/enable + */ +router + .route('/:host_id/enable') + .options((req, res) => { + res.sendStatus(204); + }) + .all(jwtdecode()) + + /** + * POST /api/nginx/ssl-passthrough-hosts/123/enable + */ + .post((req, res, next) => { + internalSslPassthrough.enable(res.locals.access, {id: parseInt(req.params.host_id, 10)}) + .then((result) => { + res.status(200) + .send(result); + }) + .catch(next); + }); + +/** + * Disable ssl passthrough host + * + * /api/nginx/ssl-passthrough-hosts/123/disable + */ +router + .route('/:host_id/disable') + .options((req, res) => { + res.sendStatus(204); + }) + .all(jwtdecode()) + + /** + * POST /api/nginx/ssl-passthrough-hosts/123/disable + */ + .post((req, res, next) => { + internalSslPassthrough.disable(res.locals.access, {id: parseInt(req.params.host_id, 10)}) + .then((result) => { + res.status(200) + .send(result); + }) + .catch(next); + }); + +module.exports = router; diff --git a/backend/schema/endpoints/ssl-passthrough-hosts.json b/backend/schema/endpoints/ssl-passthrough-hosts.json new file mode 100644 index 00000000..12306d08 --- /dev/null +++ b/backend/schema/endpoints/ssl-passthrough-hosts.json @@ -0,0 +1,208 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "endpoints/ssl-passthough-hosts", + "title": "SSL Passthrough Hosts", + "description": "Endpoints relating to SSL Passthrough Hosts", + "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" + }, + "domain_name": { + "$ref": "../definitions.json#/definitions/domain_name" + }, + "forwarding_host": { + "anyOf": [ + { + "$ref": "../definitions.json#/definitions/domain_name" + }, + { + "type": "string", + "format": "ipv4" + }, + { + "type": "string", + "format": "ipv6" + } + ] + }, + "forwarding_port": { + "type": "integer", + "minimum": 1, + "maximum": 65535 + }, + "enabled": { + "$ref": "../definitions.json#/definitions/enabled" + }, + "meta": { + "type": "object" + } + }, + "properties": { + "id": { + "$ref": "#/definitions/id" + }, + "created_on": { + "$ref": "#/definitions/created_on" + }, + "modified_on": { + "$ref": "#/definitions/modified_on" + }, + "domain_name": { + "$ref": "#/definitions/domain_name" + }, + "forwarding_host": { + "$ref": "#/definitions/forwarding_host" + }, + "forwarding_port": { + "$ref": "#/definitions/forwarding_port" + }, + "enabled": { + "$ref": "#/definitions/enabled" + }, + "meta": { + "$ref": "#/definitions/meta" + } + }, + "links": [ + { + "title": "List", + "description": "Returns a list of SSL Passthrough Hosts", + "href": "/nginx/ssl-passthrough-hosts", + "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 SSL Passthrough Host", + "href": "/nginx/ssl-passthrough-hosts", + "access": "private", + "method": "POST", + "rel": "create", + "http_header": { + "$ref": "../examples.json#/definitions/auth_header" + }, + "schema": { + "type": "object", + "additionalProperties": false, + "required": [ + "domain_name", + "forwarding_host", + "forwarding_port" + ], + "properties": { + "domain_name": { + "$ref": "#/definitions/domain_name" + }, + "forwarding_host": { + "$ref": "#/definitions/forwarding_host" + }, + "forwarding_port": { + "$ref": "#/definitions/forwarding_port" + }, + "meta": { + "$ref": "#/definitions/meta" + } + } + }, + "targetSchema": { + "properties": { + "$ref": "#/properties" + } + } + }, + { + "title": "Update", + "description": "Updates a existing SSL Passthrough Host", + "href": "/nginx/ssl-passthrough-hosts/{definitions.identity.example}", + "access": "private", + "method": "PUT", + "rel": "update", + "http_header": { + "$ref": "../examples.json#/definitions/auth_header" + }, + "schema": { + "type": "object", + "additionalProperties": false, + "properties": { + "domain_name": { + "$ref": "#/definitions/domain_name" + }, + "forwarding_host": { + "$ref": "#/definitions/forwarding_host" + }, + "forwarding_port": { + "$ref": "#/definitions/forwarding_port" + }, + "meta": { + "$ref": "#/definitions/meta" + } + } + }, + "targetSchema": { + "properties": { + "$ref": "#/properties" + } + } + }, + { + "title": "Delete", + "description": "Deletes a existing SSL Passthrough Host", + "href": "/nginx/ssl-passthrough-hosts/{definitions.identity.example}", + "access": "private", + "method": "DELETE", + "rel": "delete", + "http_header": { + "$ref": "../examples.json#/definitions/auth_header" + }, + "targetSchema": { + "type": "boolean" + } + }, + { + "title": "Enable", + "description": "Enables a existing SSL Passthrough Host", + "href": "/nginx/ssl-passthrough-hosts/{definitions.identity.example}/enable", + "access": "private", + "method": "POST", + "rel": "update", + "http_header": { + "$ref": "../examples.json#/definitions/auth_header" + }, + "targetSchema": { + "type": "boolean" + } + }, + { + "title": "Disable", + "description": "Disables a existing SSL Passthrough Host", + "href": "/nginx/ssl-passthrough-hosts/{definitions.identity.example}/disable", + "access": "private", + "method": "POST", + "rel": "update", + "http_header": { + "$ref": "../examples.json#/definitions/auth_header" + }, + "targetSchema": { + "type": "boolean" + } + } + ] +} diff --git a/backend/schema/index.json b/backend/schema/index.json index 6e7d1c8a..58eb9139 100644 --- a/backend/schema/index.json +++ b/backend/schema/index.json @@ -26,6 +26,9 @@ "dead-hosts": { "$ref": "endpoints/dead-hosts.json" }, + "ssl-passthrough-hosts": { + "$ref": "endpoints/ssl-passthrough-hosts.json" + }, "streams": { "$ref": "endpoints/streams.json" }, diff --git a/backend/setup.js b/backend/setup.js index 4d614baf..4a2f9489 100644 --- a/backend/setup.js +++ b/backend/setup.js @@ -1,15 +1,17 @@ -const fs = require('fs'); -const NodeRSA = require('node-rsa'); -const config = require('config'); -const logger = require('./logger').setup; -const certificateModel = require('./models/certificate'); -const userModel = require('./models/user'); -const userPermissionModel = require('./models/user_permission'); -const utils = require('./lib/utils'); -const authModel = require('./models/auth'); -const settingModel = require('./models/setting'); -const dns_plugins = require('./global/certbot-dns-plugins'); -const debug_mode = process.env.NODE_ENV !== 'production' || !!process.env.DEBUG; +const fs = require('fs'); +const NodeRSA = require('node-rsa'); +const config = require('config'); +const logger = require('./logger').setup; +const certificateModel = require('./models/certificate'); +const userModel = require('./models/user'); +const userPermissionModel = require('./models/user_permission'); +const utils = require('./lib/utils'); +const authModel = require('./models/auth'); +const settingModel = require('./models/setting'); +const passthroughHostModel = require('./models/ssl_passthrough_host'); +const dns_plugins = require('./global/certbot-dns-plugins'); +const internalNginx = require('./internal/nginx'); +const debug_mode = process.env.NODE_ENV !== 'production' || !!process.env.DEBUG; /** * Creates a new JWT RSA Keypair if not alread set on the config @@ -222,10 +224,19 @@ const setupLogrotation = () => { return runLogrotate(); }; +/** + * Makes sure the ssl passthrough option is reflected in the nginx config + * @returns {Promise} + */ +const setupSslPassthrough = () => { + return internalNginx.configure(passthroughHostModel, 'ssl_passthrough_host', {}); +}; + module.exports = function () { return setupJwt() .then(setupDefaultUser) .then(setupDefaultSettings) .then(setupCertbotPlugins) - .then(setupLogrotation); + .then(setupLogrotation) + .then(setupSslPassthrough); }; diff --git a/backend/templates/ssl_passthrough_host.conf b/backend/templates/ssl_passthrough_host.conf new file mode 100644 index 00000000..9ee872d4 --- /dev/null +++ b/backend/templates/ssl_passthrough_host.conf @@ -0,0 +1,39 @@ +# ------------------------------------------------------------ +# SSL Passthrough hosts +# ------------------------------------------------------------ + +map $ssl_preread_server_name $name { +{% for host in all_passthrough_hosts %} +{% if enabled %} + {{ host.domain_name }} ssl_passthrough_{{ host.escaped_name }} +{% endif %} +{% endfor %} + default https_default_backend; +} + +{% for host in all_passthrough_hosts %} +{% if enabled %} +upstream ssl_passthrough_{{ host.escaped_name }} { + server {{host.forwarding_host}}:{{host.forwarding_port}}; +} +{% endif %} +{% endfor %} + +upstream https_default_backend { + server 127.0.0.1:443; +} + +server { + listen 444; +{% if ipv6 -%} + listen [::]:444; +{% else -%} + #listen [::]:444; +{% endif %} + + proxy_pass $name; + ssl_preread on; + + # Custom + include /data/nginx/custom/server_ssl_passthrough[.]conf; +} \ No newline at end of file diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml index 99f262f1..4914cd10 100644 --- a/docker/docker-compose.dev.yml +++ b/docker/docker-compose.dev.yml @@ -11,6 +11,7 @@ services: - 3080:80 - 3081:81 - 3443:443 + - 3444:444 networks: - nginx_proxy_manager environment: @@ -22,6 +23,7 @@ services: DB_MYSQL_USER: "npm" DB_MYSQL_PASSWORD: "npm" DB_MYSQL_NAME: "npm" + ENABLE_SSL_PASSTHROUGH: "true" # DB_SQLITE_FILE: "/data/database.sqlite" # DISABLE_IPV6: "true" volumes: diff --git a/docker/rootfs/etc/nginx/nginx.conf b/docker/rootfs/etc/nginx/nginx.conf index 4d5ee901..4b9fef5e 100644 --- a/docker/rootfs/etc/nginx/nginx.conf +++ b/docker/rootfs/etc/nginx/nginx.conf @@ -85,6 +85,7 @@ http { stream { # Files generated by NPM + include /data/nginx/ssl_passthrough_host/hosts.conf; include /data/nginx/stream/*.conf; # Custom diff --git a/docker/rootfs/etc/services.d/nginx/run b/docker/rootfs/etc/services.d/nginx/run index fe6ea44b..bc5d23f1 100755 --- a/docker/rootfs/etc/services.d/nginx/run +++ b/docker/rootfs/etc/services.d/nginx/run @@ -12,6 +12,7 @@ mkdir -p /tmp/nginx/body \ /data/nginx/default_www \ /data/nginx/proxy_host \ /data/nginx/redirection_host \ + /data/nginx/ssl_passthrough_host \ /data/nginx/stream \ /data/nginx/dead_host \ /data/nginx/temp \ diff --git a/frontend/js/app/api.js b/frontend/js/app/api.js index 2511a789..af47a133 100644 --- a/frontend/js/app/api.js +++ b/frontend/js/app/api.js @@ -515,6 +515,67 @@ module.exports = { } }, + SslPassthroughHosts: { + /** + * @param {Array} [expand] + * @param {String} [query] + * @returns {Promise} + */ + getAll: function (expand, query) { + return getAllObjects('nginx/ssl-passthrough-hosts', expand, query); + }, + + /** + * @param {Object} data + */ + create: function (data) { + return fetch('post', 'nginx/ssl-passthrough-hosts', data); + }, + + /** + * @param {Object} data + * @param {Number} data.id + * @returns {Promise} + */ + update: function (data) { + let id = data.id; + delete data.id; + return fetch('put', 'nginx/ssl-passthrough-hosts/' + id, data); + }, + + /** + * @param {Number} id + * @returns {Promise} + */ + delete: function (id) { + return fetch('delete', 'nginx/ssl-passthrough-hosts/' + id); + }, + + /** + * @param {Number} id + * @returns {Promise} + */ + get: function (id) { + return fetch('get', 'nginx/ssl-passthrough-hosts/' + id); + }, + + /** + * @param {Number} id + * @returns {Promise} + */ + enable: function (id) { + return fetch('post', 'nginx/ssl-passthrough-hosts/' + id + '/enable'); + }, + + /** + * @param {Number} id + * @returns {Promise} + */ + disable: function (id) { + return fetch('post', 'nginx/ssl-passthrough-hosts/' + id + '/disable'); + } + }, + DeadHosts: { /** * @param {Array} [expand] diff --git a/frontend/js/app/controller.js b/frontend/js/app/controller.js index 902659be..abac53ad 100644 --- a/frontend/js/app/controller.js +++ b/frontend/js/app/controller.js @@ -221,6 +221,46 @@ module.exports = { } }, + /** + * Nginx SSL Passthrough Hosts + */ + showNginxSslPassthrough: function () { + if (Cache.User.isAdmin() || Cache.User.canView('ssl_passthrough_hosts')) { + let controller = this; + + require(['./main', './nginx/ssl-passthrough/main'], (App, View) => { + controller.navigate('/nginx/ssl-passthrough'); + App.UI.showAppContent(new View()); + }); + } + }, + + /** + * SSL Passthrough Hosts Form + * + * @param [model] + */ + showNginxSslPassthroughForm: function (model) { + if (Cache.User.isAdmin() || Cache.User.canManage('ssl_passthrough_hosts')) { + require(['./main', './nginx/ssl-passthrough/form'], function (App, View) { + App.UI.showModalDialog(new View({model: model})); + }); + } + }, + + /** + * SSL Passthrough Hosts Delete Confirm + * + * @param model + */ + showNginxSslPassthroughConfirm: function (model) { + if (Cache.User.isAdmin() || Cache.User.canManage('ssl_passthrough_hosts')) { + require(['./main', './nginx/ssl-passthrough/delete'], function (App, View) { + App.UI.showModalDialog(new View({model: model})); + }); + } + }, + /** * Nginx Dead Hosts */ diff --git a/frontend/js/app/nginx/ssl-passthrough/delete.ejs b/frontend/js/app/nginx/ssl-passthrough/delete.ejs new file mode 100644 index 00000000..d4ffdd07 --- /dev/null +++ b/frontend/js/app/nginx/ssl-passthrough/delete.ejs @@ -0,0 +1,19 @@ +
diff --git a/frontend/js/app/nginx/ssl-passthrough/delete.js b/frontend/js/app/nginx/ssl-passthrough/delete.js new file mode 100644 index 00000000..26cf1920 --- /dev/null +++ b/frontend/js/app/nginx/ssl-passthrough/delete.js @@ -0,0 +1,32 @@ +const Mn = require('backbone.marionette'); +const App = require('../../main'); +const template = require('./delete.ejs'); + +module.exports = Mn.View.extend({ + template: template, + className: 'modal-dialog', + + ui: { + form: 'form', + buttons: '.modal-footer button', + cancel: 'button.cancel', + save: 'button.save' + }, + + events: { + + 'click @ui.save': function (e) { + e.preventDefault(); + + App.Api.Nginx.SslPassthroughHosts.delete(this.model.get('id')) + .then(() => { + App.Controller.showNginxSslPassthrough(); + App.UI.closeModal(); + }) + .catch(err => { + alert(err.message); + this.ui.buttons.prop('disabled', false).removeClass('btn-disabled'); + }); + } + } +}); diff --git a/frontend/js/app/nginx/ssl-passthrough/form.ejs b/frontend/js/app/nginx/ssl-passthrough/form.ejs new file mode 100644 index 00000000..31200028 --- /dev/null +++ b/frontend/js/app/nginx/ssl-passthrough/form.ejs @@ -0,0 +1,34 @@ + diff --git a/frontend/js/app/nginx/ssl-passthrough/form.js b/frontend/js/app/nginx/ssl-passthrough/form.js new file mode 100644 index 00000000..ffaf2755 --- /dev/null +++ b/frontend/js/app/nginx/ssl-passthrough/form.js @@ -0,0 +1,77 @@ +const Mn = require('backbone.marionette'); +const App = require('../../main'); +const SslPassthroughModel = require('../../../models/ssl-passthrough-host'); +const template = require('./form.ejs'); + +require('jquery-serializejson'); +require('jquery-mask-plugin'); +require('selectize'); + +module.exports = Mn.View.extend({ + template: template, + className: 'modal-dialog', + + ui: { + form: 'form', + forwarding_host: 'input[name="forwarding_host"]', + type_error: '.forward-type-error', + buttons: '.modal-footer button', + switches: '.custom-switch-input', + cancel: 'button.cancel', + save: 'button.save' + }, + + events: { + 'change @ui.switches': function () { + this.ui.type_error.hide(); + }, + + 'click @ui.save': function (e) { + e.preventDefault(); + + if (!this.ui.form[0].checkValidity()) { + $('').hide().appendTo(this.ui.form).click().remove(); + return; + } + + let view = this; + let data = this.ui.form.serializeJSON(); + + // Manipulate + data.incoming_port = parseInt(data.incoming_port, 10); + data.forwarding_port = parseInt(data.forwarding_port, 10); + + let method = App.Api.Nginx.SslPassthroughHosts.create; + let is_new = true; + + if (this.model.get('id')) { + // edit + is_new = false; + method = App.Api.Nginx.SslPassthroughHosts.update; + data.id = this.model.get('id'); + } + + this.ui.buttons.prop('disabled', true).addClass('btn-disabled'); + method(data) + .then(result => { + view.model.set(result); + + App.UI.closeModal(function () { + if (is_new) { + App.Controller.showNginxSslPassthrough(); + } + }); + }) + .catch(err => { + alert(err.message); + this.ui.buttons.prop('disabled', false).removeClass('btn-disabled'); + }); + } + }, + + initialize: function (options) { + if (typeof options.model === 'undefined' || !options.model) { + this.model = new SslPassthroughModel.Model(); + } + } +}); diff --git a/frontend/js/app/nginx/ssl-passthrough/list/item.ejs b/frontend/js/app/nginx/ssl-passthrough/list/item.ejs new file mode 100644 index 00000000..12c9c374 --- /dev/null +++ b/frontend/js/app/nginx/ssl-passthrough/list/item.ejs @@ -0,0 +1,43 @@ +