diff --git a/backend/internal/stream.js b/backend/internal/stream.js index 9f76a1def..36cb40554 100644 --- a/backend/internal/stream.js +++ b/backend/internal/stream.js @@ -1,10 +1,12 @@ -const _ = require('lodash'); -const error = require('../lib/error'); -const utils = require('../lib/utils'); -const streamModel = require('../models/stream'); -const internalNginx = require('./nginx'); -const internalAuditLog = require('./audit-log'); -const {castJsonIfNeed} = require('../lib/helpers'); +const _ = require('lodash'); +const error = require('../lib/error'); +const utils = require('../lib/utils'); +const streamModel = require('../models/stream'); +const internalNginx = require('./nginx'); +const internalAuditLog = require('./audit-log'); +const internalCertificate = require('./certificate'); +const internalHost = require('./host'); +const {castJsonIfNeed} = require('../lib/helpers'); function omissions () { return ['is_deleted']; @@ -18,6 +20,12 @@ const internalStream = { * @returns {Promise} */ create: (access, data) => { + let create_certificate = data.certificate_id === 'new'; + + if (create_certificate) { + delete data.certificate_id; + } + return access.can('streams:create', data) .then((/*access_data*/) => { // TODO: At this point the existing ports should have been checked @@ -27,16 +35,44 @@ const internalStream = { data.meta = {}; } + // streams aren't routed by domain name so don't store domain names in the DB + let data_no_domains = structuredClone(data); + delete data_no_domains.domain_names; + return streamModel .query() - .insertAndFetch(data) + .insertAndFetch(data_no_domains) .then(utils.omitRow(omissions())); }) + .then((row) => { + if (create_certificate) { + return internalCertificate.createQuickCertificate(access, data) + .then((cert) => { + // update host with cert id + return internalStream.update(access, { + id: row.id, + certificate_id: cert.id + }); + }) + .then(() => { + return row; + }); + } else { + return row; + } + }) + .then((row) => { + // re-fetch with cert + return internalStream.get(access, { + id: row.id, + expand: ['certificate', 'owner'] + }); + }) .then((row) => { // Configure nginx return internalNginx.configure(streamModel, 'stream', row) .then(() => { - return internalStream.get(access, {id: row.id, expand: ['owner']}); + return row; }); }) .then((row) => { @@ -60,6 +96,12 @@ const internalStream = { * @return {Promise} */ update: (access, data) => { + let create_certificate = data.certificate_id === 'new'; + + if (create_certificate) { + delete data.certificate_id; + } + return access.can('streams:update', data.id) .then((/*access_data*/) => { // TODO: at this point the existing streams should have been checked @@ -71,16 +113,32 @@ const internalStream = { throw new error.InternalValidationError('Stream could not be updated, IDs do not match: ' + row.id + ' !== ' + data.id); } + if (create_certificate) { + return internalCertificate.createQuickCertificate(access, { + domain_names: data.domain_names || row.domain_names, + meta: _.assign({}, row.meta, data.meta) + }) + .then((cert) => { + // update host with cert id + data.certificate_id = cert.id; + }) + .then(() => { + return row; + }); + } else { + return row; + } + }) + .then((row) => { + // Add domain_names to the data in case it isn't there, so that the audit log renders correctly. The order is important here. + data = _.assign({}, { + domain_names: row.domain_names + }, data); + return streamModel .query() .patchAndFetchById(row.id, data) .then(utils.omitRow(omissions())) - .then((saved_row) => { - return internalNginx.configure(streamModel, 'stream', saved_row) - .then(() => { - return internalStream.get(access, {id: row.id, expand: ['owner']}); - }); - }) .then((saved_row) => { // Add to audit log return internalAuditLog.add(access, { @@ -93,6 +151,17 @@ const internalStream = { return saved_row; }); }); + }) + .then(() => { + return internalStream.get(access, {id: data.id, expand: ['owner', 'certificate']}) + .then((row) => { + return internalNginx.configure(streamModel, 'stream', row) + .then((new_meta) => { + row.meta = new_meta; + row = internalHost.cleanRowCertificateMeta(row); + return _.omit(row, omissions()); + }); + }); }); }, @@ -115,7 +184,7 @@ const internalStream = { .query() .where('is_deleted', 0) .andWhere('id', data.id) - .allowGraph('[owner]') + .allowGraph('[owner,certificate]') .first(); if (access_data.permission_visibility !== 'all') { @@ -132,6 +201,7 @@ const internalStream = { if (!row || !row.id) { throw new error.ItemNotFoundError(data.id); } + row = internalHost.cleanRowCertificateMeta(row); // Custom omissions if (typeof data.omit !== 'undefined' && data.omit !== null) { row = _.omit(row, data.omit); @@ -197,14 +267,14 @@ const internalStream = { .then(() => { return internalStream.get(access, { id: data.id, - expand: ['owner'] + expand: ['certificate', 'owner'] }); }) .then((row) => { if (!row || !row.id) { throw new error.ItemNotFoundError(data.id); } else if (row.enabled) { - throw new error.ValidationError('Host is already enabled'); + throw new error.ValidationError('Stream is already enabled'); } row.enabled = 1; @@ -250,7 +320,7 @@ const internalStream = { if (!row || !row.id) { throw new error.ItemNotFoundError(data.id); } else if (!row.enabled) { - throw new error.ValidationError('Host is already disabled'); + throw new error.ValidationError('Stream is already disabled'); } row.enabled = 0; @@ -298,7 +368,7 @@ const internalStream = { .query() .where('is_deleted', 0) .groupBy('id') - .allowGraph('[owner]') + .allowGraph('[owner,certificate]') .orderByRaw('CAST(incoming_port AS INTEGER) ASC'); if (access_data.permission_visibility !== 'all') { @@ -317,6 +387,13 @@ const internalStream = { } return query.then(utils.omitRows(omissions())); + }) + .then((rows) => { + if (typeof expand !== 'undefined' && expand !== null && expand.indexOf('certificate') !== -1) { + return internalHost.cleanAllRowsCertificateMeta(rows); + } + + return rows; }); }, diff --git a/backend/migrations/20240427161436_stream_ssl.js b/backend/migrations/20240427161436_stream_ssl.js new file mode 100644 index 000000000..5f47b18ec --- /dev/null +++ b/backend/migrations/20240427161436_stream_ssl.js @@ -0,0 +1,38 @@ +const migrate_name = 'stream_ssl'; +const logger = require('../logger').migrate; + +/** + * Migrate + * + * @see http://knexjs.org/#Schema + * + * @param {Object} knex + * @returns {Promise} + */ +exports.up = function (knex) { + logger.info('[' + migrate_name + '] Migrating Up...'); + + return knex.schema.table('stream', (table) => { + table.integer('certificate_id').notNull().unsigned().defaultTo(0); + }) + .then(function () { + logger.info('[' + migrate_name + '] stream Table altered'); + }); +}; + +/** + * Undo Migrate + * + * @param {Object} knex + * @returns {Promise} + */ +exports.down = function (knex) { + logger.info('[' + migrate_name + '] Migrating Down...'); + + return knex.schema.table('stream', (table) => { + table.dropColumn('certificate_id'); + }) + .then(function () { + logger.info('[' + migrate_name + '] stream Table altered'); + }); +}; diff --git a/backend/models/stream.js b/backend/models/stream.js index b96ca5a17..c28b86cde 100644 --- a/backend/models/stream.js +++ b/backend/models/stream.js @@ -1,11 +1,12 @@ // Objection Docs: // http://vincit.github.io/objection.js/ -const db = require('../db'); -const helpers = require('../lib/helpers'); -const Model = require('objection').Model; -const User = require('./user'); -const now = require('./now_helper'); +const db = require('../db'); +const helpers = require('../lib/helpers'); +const Model = require('objection').Model; +const User = require('./user'); +const now = require('./now_helper'); +const Certificate = require('./certificate'); Model.knex(db); @@ -64,6 +65,17 @@ class Stream extends Model { modify: function (qb) { qb.where('user.is_deleted', 0); } + }, + certificate: { + relation: Model.HasOneRelation, + modelClass: Certificate, + join: { + from: 'stream.certificate_id', + to: 'certificate.id' + }, + modify: function (qb) { + qb.where('certificate.is_deleted', 0); + } } }; } diff --git a/backend/schema/components/stream-object.json b/backend/schema/components/stream-object.json index e17749940..856bdd104 100644 --- a/backend/schema/components/stream-object.json +++ b/backend/schema/components/stream-object.json @@ -52,6 +52,22 @@ "udp_forwarding": { "type": "boolean" }, + "domain_names": { + "$ref": "../common.json#/properties/domain_names" + }, + "certificate_id": { + "$ref": "../common.json#/properties/certificate_id" + }, + "certificate": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "./certificate-object.json" + } + ] + }, "enabled": { "$ref": "../common.json#/properties/enabled" }, diff --git a/backend/schema/paths/nginx/streams/post.json b/backend/schema/paths/nginx/streams/post.json index 9f3514e0f..3e3cd55e6 100644 --- a/backend/schema/paths/nginx/streams/post.json +++ b/backend/schema/paths/nginx/streams/post.json @@ -32,6 +32,12 @@ "udp_forwarding": { "$ref": "../../../components/stream-object.json#/properties/udp_forwarding" }, + "domain_names": { + "$ref": "../../../components/stream-object.json#/properties/domain_names" + }, + "certificate_id": { + "$ref": "../../../components/stream-object.json#/properties/certificate_id" + }, "meta": { "$ref": "../../../components/stream-object.json#/properties/meta" } @@ -73,7 +79,9 @@ "nickname": "Admin", "avatar": "", "roles": ["admin"] - } + }, + "certificate_id": 0, + "certificate": null } } }, diff --git a/backend/schema/paths/nginx/streams/streamID/put.json b/backend/schema/paths/nginx/streams/streamID/put.json index fbfdc901b..8fa64cdf6 100644 --- a/backend/schema/paths/nginx/streams/streamID/put.json +++ b/backend/schema/paths/nginx/streams/streamID/put.json @@ -29,56 +29,29 @@ "additionalProperties": false, "minProperties": 1, "properties": { - "domain_names": { - "$ref": "../../../../components/proxy-host-object.json#/properties/domain_names" - }, - "forward_scheme": { - "$ref": "../../../../components/proxy-host-object.json#/properties/forward_scheme" - }, - "forward_host": { - "$ref": "../../../../components/proxy-host-object.json#/properties/forward_host" - }, - "forward_port": { - "$ref": "../../../../components/proxy-host-object.json#/properties/forward_port" - }, - "certificate_id": { - "$ref": "../../../../components/proxy-host-object.json#/properties/certificate_id" - }, - "ssl_forced": { - "$ref": "../../../../components/proxy-host-object.json#/properties/ssl_forced" + "incoming_port": { + "$ref": "../../../../components/stream-object.json#/properties/incoming_port" }, - "hsts_enabled": { - "$ref": "../../../../components/proxy-host-object.json#/properties/hsts_enabled" + "forwarding_host": { + "$ref": "../../../../components/stream-object.json#/properties/forwarding_host" }, - "hsts_subdomains": { - "$ref": "../../../../components/proxy-host-object.json#/properties/hsts_subdomains" + "forwarding_port": { + "$ref": "../../../../components/stream-object.json#/properties/forwarding_port" }, - "http2_support": { - "$ref": "../../../../components/proxy-host-object.json#/properties/http2_support" + "tcp_forwarding": { + "$ref": "../../../../components/stream-object.json#/properties/tcp_forwarding" }, - "block_exploits": { - "$ref": "../../../../components/proxy-host-object.json#/properties/block_exploits" + "udp_forwarding": { + "$ref": "../../../../components/stream-object.json#/properties/udp_forwarding" }, - "caching_enabled": { - "$ref": "../../../../components/proxy-host-object.json#/properties/caching_enabled" - }, - "allow_websocket_upgrade": { - "$ref": "../../../../components/proxy-host-object.json#/properties/allow_websocket_upgrade" - }, - "access_list_id": { - "$ref": "../../../../components/proxy-host-object.json#/properties/access_list_id" - }, - "advanced_config": { - "$ref": "../../../../components/proxy-host-object.json#/properties/advanced_config" + "domain_names": { + "$ref": "../../../../components/stream-object.json#/properties/domain_names" }, - "enabled": { - "$ref": "../../../../components/proxy-host-object.json#/properties/enabled" + "certificate_id": { + "$ref": "../../../../components/stream-object.json#/properties/certificate_id" }, "meta": { - "$ref": "../../../../components/proxy-host-object.json#/properties/meta" - }, - "locations": { - "$ref": "../../../../components/proxy-host-object.json#/properties/locations" + "$ref": "../../../../components/stream-object.json#/properties/meta" } } } @@ -97,25 +70,16 @@ "created_on": "2024-10-08T23:23:03.000Z", "modified_on": "2024-10-08T23:26:37.000Z", "owner_user_id": 1, - "domain_names": ["test.example.com"], - "forward_host": "192.168.0.10", - "forward_port": 8989, - "access_list_id": 0, - "certificate_id": 0, - "ssl_forced": false, - "caching_enabled": false, - "block_exploits": false, - "advanced_config": "", + "incoming_port": 9090, + "forwarding_host": "router.internal", + "forwarding_port": 80, + "tcp_forwarding": true, + "udp_forwarding": false, "meta": { "nginx_online": true, "nginx_err": null }, - "allow_websocket_upgrade": false, - "http2_support": false, - "forward_scheme": "http", "enabled": true, - "hsts_enabled": false, - "hsts_subdomains": false, "owner": { "id": 1, "created_on": "2024-10-07T22:43:55.000Z", diff --git a/backend/templates/_certificates.conf b/backend/templates/_certificates.conf index 06ca7bb87..efcca5cd5 100644 --- a/backend/templates/_certificates.conf +++ b/backend/templates/_certificates.conf @@ -2,6 +2,7 @@ {% if certificate.provider == "letsencrypt" %} # Let's Encrypt SSL include conf.d/include/letsencrypt-acme-challenge.conf; + include conf.d/include/ssl-cache.conf; include conf.d/include/ssl-ciphers.conf; ssl_certificate /etc/letsencrypt/live/npm-{{ certificate_id }}/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/npm-{{ certificate_id }}/privkey.pem; diff --git a/backend/templates/_certificates_stream.conf b/backend/templates/_certificates_stream.conf new file mode 100644 index 000000000..ba7812fdd --- /dev/null +++ b/backend/templates/_certificates_stream.conf @@ -0,0 +1,13 @@ +{% if certificate and certificate_id > 0 %} +{% if certificate.provider == "letsencrypt" %} + # Let's Encrypt SSL + include conf.d/include/ssl-cache-stream.conf; + include conf.d/include/ssl-ciphers.conf; + ssl_certificate /etc/letsencrypt/live/npm-{{ certificate_id }}/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/npm-{{ certificate_id }}/privkey.pem; +{%- else %} + # Custom SSL + ssl_certificate /data/custom_ssl/npm-{{ certificate_id }}/fullchain.pem; + ssl_certificate_key /data/custom_ssl/npm-{{ certificate_id }}/privkey.pem; +{%- endif -%} +{%- endif -%} diff --git a/backend/templates/stream.conf b/backend/templates/stream.conf index 76159a646..7333aaee1 100644 --- a/backend/templates/stream.conf +++ b/backend/templates/stream.conf @@ -5,12 +5,10 @@ {% if enabled %} {% if tcp_forwarding == 1 or tcp_forwarding == true -%} server { - listen {{ incoming_port }}; -{% if ipv6 -%} - listen [::]:{{ incoming_port }}; -{% else -%} - #listen [::]:{{ incoming_port }}; -{% endif %} + listen {{ incoming_port }} {%- if certificate %} ssl {%- endif %}; + {% unless ipv6 -%} # {%- endunless -%} listen [::]:{{ incoming_port }} {%- if certificate %} ssl {%- endif %}; + + {%- include "_certificates_stream.conf" %} proxy_pass {{ forwarding_host }}:{{ forwarding_port }}; @@ -19,14 +17,12 @@ server { include /data/nginx/custom/server_stream_tcp[.]conf; } {% endif %} -{% if udp_forwarding == 1 or udp_forwarding == true %} + +{% if udp_forwarding == 1 or udp_forwarding == true -%} server { listen {{ incoming_port }} udp; -{% if ipv6 -%} - listen [::]:{{ incoming_port }} udp; -{% else -%} - #listen [::]:{{ incoming_port }} udp; -{% endif %} + {% unless ipv6 -%} # {%- endunless -%} listen [::]:{{ incoming_port }} udp; + proxy_pass {{ forwarding_host }}:{{ forwarding_port }}; # Custom diff --git a/docker/rootfs/etc/nginx/conf.d/include/ssl-cache-stream.conf b/docker/rootfs/etc/nginx/conf.d/include/ssl-cache-stream.conf new file mode 100644 index 000000000..433555dfa --- /dev/null +++ b/docker/rootfs/etc/nginx/conf.d/include/ssl-cache-stream.conf @@ -0,0 +1,2 @@ +ssl_session_timeout 5m; +ssl_session_cache shared:SSL_stream:50m; diff --git a/docker/rootfs/etc/nginx/conf.d/include/ssl-cache.conf b/docker/rootfs/etc/nginx/conf.d/include/ssl-cache.conf new file mode 100644 index 000000000..aa7ba2cb7 --- /dev/null +++ b/docker/rootfs/etc/nginx/conf.d/include/ssl-cache.conf @@ -0,0 +1,2 @@ +ssl_session_timeout 5m; +ssl_session_cache shared:SSL:50m; diff --git a/docker/rootfs/etc/nginx/conf.d/include/ssl-ciphers.conf b/docker/rootfs/etc/nginx/conf.d/include/ssl-ciphers.conf index 233abb6e9..b5dacfb57 100644 --- a/docker/rootfs/etc/nginx/conf.d/include/ssl-ciphers.conf +++ b/docker/rootfs/etc/nginx/conf.d/include/ssl-ciphers.conf @@ -1,6 +1,3 @@ -ssl_session_timeout 5m; -ssl_session_cache shared:SSL:50m; - # intermediate configuration. tweak to your needs. ssl_protocols TLSv1.2 TLSv1.3; ssl_ciphers 'ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384'; diff --git a/frontend/js/app/nginx/stream/form.ejs b/frontend/js/app/nginx/stream/form.ejs index 1fc4f1342..800945f36 100644 --- a/frontend/js/app/nginx/stream/form.ejs +++ b/frontend/js/app/nginx/stream/form.ejs @@ -3,48 +3,187 @@ -