diff --git a/backend/internal/stream.js b/backend/internal/stream.js index 9f76a1d..4d49bc3 100644 --- a/backend/internal/stream.js +++ b/backend/internal/stream.js @@ -1,13 +1,15 @@ -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']; + return ['is_deleted', 'owner.is_deleted', 'certificate.is_deleted']; } const internalStream = { @@ -18,6 +20,12 @@ const internalStream = { * @returns {Promise} */ create: (access, data) => { + const 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) => { + const 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 0000000..5f47b18 --- /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 b96ca5a..dbec2dc 100644 --- a/backend/models/stream.js +++ b/backend/models/stream.js @@ -1,15 +1,14 @@ -// 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 Model = require('objection').Model; +const db = require('../db'); +const helpers = require('../lib/helpers'); +const User = require('./user'); +const Certificate = require('./certificate'); +const now = require('./now_helper'); Model.knex(db); const boolFields = [ + 'enabled', 'is_deleted', 'tcp_forwarding', 'udp_forwarding', @@ -64,6 +63,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 e177499..848c30e 100644 --- a/backend/schema/components/stream-object.json +++ b/backend/schema/components/stream-object.json @@ -19,9 +19,7 @@ "incoming_port": { "type": "integer", "minimum": 1, - "maximum": 65535, - "if": {"properties": {"tcp_forwarding": {"const": true}}}, - "then": {"not": {"oneOf": [{"const": 80}, {"const": 443}]}} + "maximum": 65535 }, "forwarding_host": { "anyOf": [ @@ -55,8 +53,24 @@ "enabled": { "$ref": "../common.json#/properties/enabled" }, + "certificate_id": { + "$ref": "../common.json#/properties/certificate_id" + }, "meta": { "type": "object" + }, + "owner": { + "$ref": "./user-object.json" + }, + "certificate": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "./certificate-object.json" + } + ] } } } diff --git a/backend/schema/paths/nginx/streams/get.json b/backend/schema/paths/nginx/streams/get.json index 596afc6..17969ee 100644 --- a/backend/schema/paths/nginx/streams/get.json +++ b/backend/schema/paths/nginx/streams/get.json @@ -14,7 +14,7 @@ "description": "Expansions", "schema": { "type": "string", - "enum": ["access_list", "owner", "certificate"] + "enum": ["owner", "certificate"] } } ], @@ -40,7 +40,8 @@ "nginx_online": true, "nginx_err": null }, - "enabled": true + "enabled": true, + "certificate_id": 0 } ] } diff --git a/backend/schema/paths/nginx/streams/post.json b/backend/schema/paths/nginx/streams/post.json index 9f3514e..d26996b 100644 --- a/backend/schema/paths/nginx/streams/post.json +++ b/backend/schema/paths/nginx/streams/post.json @@ -32,6 +32,9 @@ "udp_forwarding": { "$ref": "../../../components/stream-object.json#/properties/udp_forwarding" }, + "certificate_id": { + "$ref": "../../../components/stream-object.json#/properties/certificate_id" + }, "meta": { "$ref": "../../../components/stream-object.json#/properties/meta" } @@ -73,7 +76,8 @@ "nickname": "Admin", "avatar": "", "roles": ["admin"] - } + }, + "certificate_id": 0 } } }, diff --git a/backend/schema/paths/nginx/streams/streamID/get.json b/backend/schema/paths/nginx/streams/streamID/get.json index 6547656..801af13 100644 --- a/backend/schema/paths/nginx/streams/streamID/get.json +++ b/backend/schema/paths/nginx/streams/streamID/get.json @@ -40,7 +40,8 @@ "nginx_online": true, "nginx_err": null }, - "enabled": true + "enabled": true, + "certificate_id": 0 } } }, diff --git a/backend/schema/paths/nginx/streams/streamID/put.json b/backend/schema/paths/nginx/streams/streamID/put.json index fbfdc90..14adb16 100644 --- a/backend/schema/paths/nginx/streams/streamID/put.json +++ b/backend/schema/paths/nginx/streams/streamID/put.json @@ -29,56 +29,26 @@ "additionalProperties": false, "minProperties": 1, "properties": { - "domain_names": { - "$ref": "../../../../components/proxy-host-object.json#/properties/domain_names" + "incoming_port": { + "$ref": "../../../../components/stream-object.json#/properties/incoming_port" }, - "forward_scheme": { - "$ref": "../../../../components/proxy-host-object.json#/properties/forward_scheme" + "forwarding_host": { + "$ref": "../../../../components/stream-object.json#/properties/forwarding_host" }, - "forward_host": { - "$ref": "../../../../components/proxy-host-object.json#/properties/forward_host" + "forwarding_port": { + "$ref": "../../../../components/stream-object.json#/properties/forwarding_port" }, - "forward_port": { - "$ref": "../../../../components/proxy-host-object.json#/properties/forward_port" + "tcp_forwarding": { + "$ref": "../../../../components/stream-object.json#/properties/tcp_forwarding" + }, + "udp_forwarding": { + "$ref": "../../../../components/stream-object.json#/properties/udp_forwarding" }, "certificate_id": { - "$ref": "../../../../components/proxy-host-object.json#/properties/certificate_id" - }, - "ssl_forced": { - "$ref": "../../../../components/proxy-host-object.json#/properties/ssl_forced" - }, - "hsts_enabled": { - "$ref": "../../../../components/proxy-host-object.json#/properties/hsts_enabled" - }, - "hsts_subdomains": { - "$ref": "../../../../components/proxy-host-object.json#/properties/hsts_subdomains" - }, - "http2_support": { - "$ref": "../../../../components/proxy-host-object.json#/properties/http2_support" - }, - "block_exploits": { - "$ref": "../../../../components/proxy-host-object.json#/properties/block_exploits" - }, - "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" - }, - "enabled": { - "$ref": "../../../../components/proxy-host-object.json#/properties/enabled" + "$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" } } } @@ -94,42 +64,32 @@ "default": { "value": { "id": 1, - "created_on": "2024-10-08T23:23:03.000Z", - "modified_on": "2024-10-08T23:26:37.000Z", + "created_on": "2024-10-09T02:33:45.000Z", + "modified_on": "2024-10-09T02:33:45.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", - "modified_on": "2024-10-08T12:52:54.000Z", + "created_on": "2024-10-09T02:33:16.000Z", + "modified_on": "2024-10-09T02:33:16.000Z", "is_deleted": false, "is_disabled": false, "email": "admin@example.com", "name": "Administrator", - "nickname": "some guy", - "avatar": "//www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?default=mm", + "nickname": "Admin", + "avatar": "", "roles": ["admin"] }, - "certificate": null, - "access_list": null + "certificate_id": 0 } } }, diff --git a/backend/templates/_certificates.conf b/backend/templates/_certificates.conf index 06ca7bb..efcca5c 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 0000000..ba7812f --- /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 76159a6..7333aae 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/docker-compose.ci.yml b/docker/docker-compose.ci.yml index 022f281..280a054 100644 --- a/docker/docker-compose.ci.yml +++ b/docker/docker-compose.ci.yml @@ -22,6 +22,10 @@ services: test: ["CMD", "/usr/bin/check-health"] interval: 10s timeout: 3s + expose: + - '80-81/tcp' + - '443/tcp' + - '1500-1503/tcp' networks: fulltest: aliases: @@ -97,7 +101,7 @@ services: HTTP_PROXY: 'squid:3128' HTTPS_PROXY: 'squid:3128' volumes: - - 'cypress_logs:/results' + - 'cypress_logs:/test/results' - './dev/resolv.conf:/etc/resolv.conf:ro' - '/etc/localtime:/etc/localtime:ro' command: cypress run --browser chrome --config-file=cypress/config/ci.js 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 0000000..433555d --- /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 0000000..aa7ba2c --- /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 233abb6..b5dacfb 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/docker/scripts/install-s6 b/docker/scripts/install-s6 index 2922735..5f3b73e 100755 --- a/docker/scripts/install-s6 +++ b/docker/scripts/install-s6 @@ -8,7 +8,7 @@ BLUE='\E[1;34m' GREEN='\E[1;32m' RESET='\E[0m' -S6_OVERLAY_VERSION=3.1.5.0 +S6_OVERLAY_VERSION=3.2.0.2 TARGETPLATFORM=${1:-linux/amd64} # Determine the correct binary file for the architecture given diff --git a/frontend/js/app/nginx/stream/form.ejs b/frontend/js/app/nginx/stream/form.ejs index 1fc4f13..800945f 100644 --- a/frontend/js/app/nginx/stream/form.ejs +++ b/frontend/js/app/nginx/stream/form.ejs @@ -3,48 +3,187 @@