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 @@ + +
+ +
+ + +
+ <%- domain_name %> +
+
+ <%- i18n('str', 'created-on', {date: formatDbDate(created_on, 'Do MMMM YYYY')}) %> +
+ + +
<%- forwarding_host %>:<%- forwarding_port %>
+ + + <% + var o = isOnline(); + if (!enabled) { %> + <%- i18n('str', 'disabled') %> + <% } else if (o === true) { %> + <%- i18n('str', 'online') %> + <% } else if (o === false) { %> + <%- i18n('str', 'offline') %> + <% } else { %> + <%- i18n('str', 'unknown') %> + <% } %> + +<% if (canManage) { %> + + + +<% } %> \ No newline at end of file diff --git a/frontend/js/app/nginx/ssl-passthrough/list/item.js b/frontend/js/app/nginx/ssl-passthrough/list/item.js new file mode 100644 index 00000000..b04500b7 --- /dev/null +++ b/frontend/js/app/nginx/ssl-passthrough/list/item.js @@ -0,0 +1,54 @@ +const Mn = require('backbone.marionette'); +const App = require('../../../main'); +const template = require('./item.ejs'); + +module.exports = Mn.View.extend({ + template: template, + tagName: 'tr', + + ui: { + able: 'a.able', + edit: 'a.edit', + delete: 'a.delete' + }, + + events: { + 'click @ui.able': function (e) { + e.preventDefault(); + let id = this.model.get('id'); + App.Api.Nginx.SslPassthroughHosts[this.model.get('enabled') ? 'disable' : 'enable'](id) + .then(() => { + return App.Api.Nginx.SslPassthroughHosts.get(id) + .then(row => { + this.model.set(row); + }); + }); + }, + + 'click @ui.edit': function (e) { + e.preventDefault(); + App.Controller.showNginxSslPassthroughForm(this.model); + }, + + 'click @ui.delete': function (e) { + e.preventDefault(); + App.Controller.showNginxSslPassthroughDeleteConfirm(this.model); + } + }, + + templateContext: { + canManage: App.Cache.User.canManage('ssl_passthrough_hosts'), + + isOnline: function () { + return typeof this.meta.nginx_online === 'undefined' ? null : this.meta.nginx_online; + }, + + getOfflineError: function () { + return this.meta.nginx_err || ''; + } + }, + + initialize: function () { + this.listenTo(this.model, 'change', this.render); + } +}); diff --git a/frontend/js/app/nginx/ssl-passthrough/list/main.ejs b/frontend/js/app/nginx/ssl-passthrough/list/main.ejs new file mode 100644 index 00000000..79268f06 --- /dev/null +++ b/frontend/js/app/nginx/ssl-passthrough/list/main.ejs @@ -0,0 +1,12 @@ + +   + <%- i18n('ssl-passthrough-hosts', 'domain-name') %> + <%- i18n('str', 'destination') %> + <%- i18n('str', 'status') %> + <% if (canManage) { %> +   + <% } %> + + + + diff --git a/frontend/js/app/nginx/ssl-passthrough/list/main.js b/frontend/js/app/nginx/ssl-passthrough/list/main.js new file mode 100644 index 00000000..0c82d9f6 --- /dev/null +++ b/frontend/js/app/nginx/ssl-passthrough/list/main.js @@ -0,0 +1,32 @@ +const Mn = require('backbone.marionette'); +const App = require('../../../main'); +const ItemView = require('./item'); +const template = require('./main.ejs'); + +const TableBody = Mn.CollectionView.extend({ + tagName: 'tbody', + childView: ItemView +}); + +module.exports = Mn.View.extend({ + tagName: 'table', + className: 'table table-hover table-outline table-vcenter card-table', + template: template, + + regions: { + body: { + el: 'tbody', + replaceElement: true + } + }, + + templateContext: { + canManage: App.Cache.User.canManage('ssl_passthrough_hosts') + }, + + onRender: function () { + this.showChildView('body', new TableBody({ + collection: this.collection + })); + } +}); diff --git a/frontend/js/app/nginx/ssl-passthrough/main.ejs b/frontend/js/app/nginx/ssl-passthrough/main.ejs new file mode 100644 index 00000000..cf29c2d7 --- /dev/null +++ b/frontend/js/app/nginx/ssl-passthrough/main.ejs @@ -0,0 +1,20 @@ +
+
+
+

<%- i18n('ssl-passthrough-hosts', 'title') %>

+
+ + <% if (showAddButton) { %> + <%- i18n('ssl-passthrough-hosts', 'add') %> + <% } %> +
+
+
+
+
+
+ +
+
+
+
diff --git a/frontend/js/app/nginx/ssl-passthrough/main.js b/frontend/js/app/nginx/ssl-passthrough/main.js new file mode 100644 index 00000000..c4419206 --- /dev/null +++ b/frontend/js/app/nginx/ssl-passthrough/main.js @@ -0,0 +1,81 @@ +const Mn = require('backbone.marionette'); +const App = require('../../main'); +const SslPassthroughModel = require('../../../models/ssl-passthrough-host'); +const ListView = require('./list/main'); +const ErrorView = require('../../error/main'); +const EmptyView = require('../../empty/main'); +const template = require('./main.ejs'); + +module.exports = Mn.View.extend({ + id: 'nginx-ssl-passthrough', + template: template, + + ui: { + list_region: '.list-region', + add: '.add-item', + help: '.help', + dimmer: '.dimmer' + }, + + regions: { + list_region: '@ui.list_region' + }, + + events: { + 'click @ui.add': function (e) { + e.preventDefault(); + App.Controller.showNginxSslPassthroughForm(); + }, + + 'click @ui.help': function (e) { + e.preventDefault(); + App.Controller.showHelp(App.i18n('ssl-passthrough-hosts', 'help-title'), App.i18n('ssl-passthrough-hosts', 'help-content')); + } + }, + + templateContext: { + showAddButton: App.Cache.User.canManage('ssl_passthrough_hosts') + }, + + onRender: function () { + let view = this; + + App.Api.Nginx.SslPassthroughHosts.getAll(['owner']) + .then(response => { + if (!view.isDestroyed()) { + if (response && response.length) { + view.showChildView('list_region', new ListView({ + collection: new SslPassthroughModel.Collection(response) + })); + } else { + let manage = App.Cache.User.canManage('ssl_passthrough_hosts'); + + view.showChildView('list_region', new EmptyView({ + title: App.i18n('ssl-passthrough-hosts', 'empty'), + subtitle: App.i18n('all-hosts', 'empty-subtitle', {manage: manage}), + link: manage ? App.i18n('ssl_passthrough_hosts', 'add') : null, + btn_color: 'blue', + permission: 'ssl-passthrough-hosts', + action: function () { + App.Controller.showNginxSslPassthroughForm(); + } + })); + } + } + }) + .catch(err => { + view.showChildView('list_region', new ErrorView({ + code: err.code, + message: err.message, + retry: function () { + App.Controller.showNginxSslPassthrough(); + } + })); + + console.error(err); + }) + .then(() => { + view.ui.dimmer.removeClass('active'); + }); + } +}); diff --git a/frontend/js/app/router.js b/frontend/js/app/router.js index a036bfc5..5407277b 100644 --- a/frontend/js/app/router.js +++ b/frontend/js/app/router.js @@ -4,16 +4,17 @@ const Controller = require('./controller'); module.exports = AppRouter.default.extend({ controller: Controller, appRoutes: { - users: 'showUsers', - logout: 'logout', - 'nginx/proxy': 'showNginxProxy', - 'nginx/redirection': 'showNginxRedirection', - 'nginx/404': 'showNginxDead', - 'nginx/stream': 'showNginxStream', - 'nginx/access': 'showNginxAccess', - 'nginx/certificates': 'showNginxCertificates', - 'audit-log': 'showAuditLog', - 'settings': 'showSettings', - '*default': 'showDashboard' + users: 'showUsers', + logout: 'logout', + 'nginx/proxy': 'showNginxProxy', + 'nginx/redirection': 'showNginxRedirection', + 'nginx/404': 'showNginxDead', + 'nginx/ssl-passthrough': 'showNginxSslPassthrough', + 'nginx/stream': 'showNginxStream', + 'nginx/access': 'showNginxAccess', + 'nginx/certificates': 'showNginxCertificates', + 'audit-log': 'showAuditLog', + 'settings': 'showSettings', + '*default': 'showDashboard' } }); diff --git a/frontend/js/i18n/messages.json b/frontend/js/i18n/messages.json index 1e00ef7c..94a7f280 100644 --- a/frontend/js/i18n/messages.json +++ b/frontend/js/i18n/messages.json @@ -115,6 +115,19 @@ "processing-info": "Processing... This might take a few minutes.", "passphrase-protection-support-info": "Key files protected with a passphrase are not supported." }, + "ssl-passthrough-hosts": { + "title": "SSL Passthrough Hosts", + "empty": "There are no SSL Passthrough Hosts", + "add": "Add SSL Passthrough Hosts", + "form-title": "{id, select, undefined{New} other{Edit}} SSL Passthrough Host", + "domain-name": "Domain Name", + "forwarding-host": "Forward Host", + "forwarding-port": "Forward Port", + "delete": "Delete SSL Passthrough Host", + "delete-confirm": "Are you sure you want to delete this SSL Passthrough Host?", + "help-title": "What is a SSL Passthrough Host?", + "help-content": "An SSL Passthrough Host will allow you to proxy a server without SSL termination. This means the SSL encryption of the server will be passed right through the proxy, retaining the upstream certificates.\nThough this also means the proxy does not know anything about the traffic, and it just relies on an SSL feature called Server Name Indication, to know where to send this packet. This also means, if the client does not provide this additional information, accessing the site through the proxy won't be possible. However most modern browsers include this information in HTTP requests.\n\nHowever using SSL Passthrough comes with a performance penalty, since all hosts (including normal proxy hosts) now have to pass through this additional step of checking the destination. If you do not need your service to be available on port 443, it is recommended to use a stream host instead." + }, "proxy-hosts": { "title": "Proxy Hosts", "empty": "There are no Proxy Hosts", @@ -248,6 +261,7 @@ "proxy-host": "Proxy Host", "redirection-host": "Redirection Host", "dead-host": "404 Host", + "ssl-passthrough-host": "SSL Passthrough Host", "stream": "Stream", "user": "User", "certificate": "Certificate", diff --git a/frontend/js/models/ssl-passthrough-host.js b/frontend/js/models/ssl-passthrough-host.js new file mode 100644 index 00000000..e41cb619 --- /dev/null +++ b/frontend/js/models/ssl-passthrough-host.js @@ -0,0 +1,27 @@ +const Backbone = require('backbone'); + +const model = Backbone.Model.extend({ + idAttribute: 'id', + + defaults: function () { + return { + id: undefined, + created_on: null, + modified_on: null, + domain_name: null, + forwarding_host: null, + forwarding_port: null, + enabled: true, + meta: {}, + // The following are expansions: + owner: null + }; + } +}); + +module.exports = { + Model: model, + Collection: Backbone.Collection.extend({ + model: model + }) +};