Переглянути джерело

Merge pull request #4344 from NginxProxyManager/stream-ssl

SSL for Streams - 2025
jc21 8 місяців тому
батько
коміт
c56c95a59a

+ 98 - 21
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;
 			});
 	},
 

+ 38 - 0
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');
+		});
+};

+ 18 - 8
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);
+				}
 			}
 		};
 	}

+ 17 - 3
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"
+				}
+			]
 		}
 	}
 }

+ 3 - 2
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
 								}
 							]
 						}

+ 5 - 1
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
 							}
 						}
 					},

+ 2 - 1
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
 							}
 						}
 					},

+ 25 - 65
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"
 						},
-						"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"
+						"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"
-						},
-						"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"
 						}
 					}
 				}
@@ -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": "[email protected]",
 									"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
 							}
 						}
 					},

+ 1 - 0
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;

+ 13 - 0
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 -%}

+ 8 - 12
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

+ 5 - 1
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

+ 2 - 0
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;

+ 2 - 0
docker/rootfs/etc/nginx/conf.d/include/ssl-cache.conf

@@ -0,0 +1,2 @@
+ssl_session_timeout 5m;
+ssl_session_cache shared:SSL:50m;

+ 0 - 3
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';

+ 1 - 1
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

+ 176 - 37
frontend/js/app/nginx/stream/form.ejs

@@ -3,48 +3,187 @@
         <h5 class="modal-title"><%- i18n('streams', 'form-title', {id: id}) %></h5>
         <button type="button" class="close cancel" aria-label="Close" data-dismiss="modal">&nbsp;</button>
     </div>
-    <div class="modal-body">
+    <div class="modal-body has-tabs">
+        <div class="alert alert-danger mb-0 rounded-0" id="le-error-info" role="alert"></div>
         <form>
-            <div class="row">
-                <div class="col-sm-12 col-md-12">
-                    <div class="form-group">
-                        <label class="form-label"><%- i18n('streams', 'incoming-port') %> <span class="form-required">*</span></label>
-                        <input name="incoming_port" type="number" class="form-control text-monospace" placeholder="eg: 8080" min="1" max="65535" value="<%- incoming_port %>" required>
+            <ul class="nav nav-tabs" role="tablist">
+                <li role="presentation" class="nav-item"><a href="#details" aria-controls="tab1" role="tab" data-toggle="tab" class="nav-link active"><i class="fe fe-zap"></i> <%- i18n('all-hosts', 'details') %></a></li>
+                <li role="presentation" class="nav-item"><a href="#ssl-options" aria-controls="tab2" role="tab" data-toggle="tab" class="nav-link"><i class="fe fe-shield"></i> <%- i18n('str', 'ssl') %></a></li>
+            </ul>
+            <div class="tab-content">
+                <!-- Details -->
+                <div role="tabpanel" class="tab-pane active" id="details">
+                    <div class="row">
+                        <div class="col-sm-12 col-md-12">
+                            <div class="form-group">
+                                <label class="form-label"><%- i18n('streams', 'incoming-port') %> <span class="form-required">*</span></label>
+                                <input name="incoming_port" type="number" class="form-control text-monospace" placeholder="eg: 8080" min="1" max="65535" value="<%- incoming_port %>" required>
+                            </div>
+                        </div>
+                        <div class="col-sm-8 col-md-8">
+                            <div class="form-group">
+                                <label class="form-label"><%- i18n('streams', 'forwarding-host') %><span class="form-required">*</span></label>
+                                <input type="text" name="forwarding_host" class="form-control text-monospace" placeholder="example.com or 10.0.0.1 or 2001:db8:3333:4444:5555:6666:7777:8888" value="<%- forwarding_host %>" autocomplete="off" maxlength="255" required>
+                            </div>
+                        </div>
+                        <div class="col-sm-4 col-md-4">
+                            <div class="form-group">
+                                <label class="form-label"><%- i18n('streams', 'forwarding-port') %> <span class="form-required">*</span></label>
+                                <input name="forwarding_port" type="number" class="form-control text-monospace" placeholder="eg: 80" min="1" max="65535" value="<%- forwarding_port %>" required>
+                            </div>
+                        </div>
+                        <div class="col-sm-6 col-md-6">
+                            <div class="form-group">
+                                <label class="custom-switch">
+                                    <input type="checkbox" class="custom-switch-input" name="tcp_forwarding" value="1"<%- tcp_forwarding ? ' checked' : '' %>>
+                                    <span class="custom-switch-indicator"></span>
+                                    <span class="custom-switch-description"><%- i18n('streams', 'tcp-forwarding') %></span>
+                                </label>
+                            </div>
+                        </div>
+                        <div class="col-sm-6 col-md-6">
+                            <div class="form-group">
+                                <label class="custom-switch">
+                                    <input type="checkbox" class="custom-switch-input" name="udp_forwarding" value="1"<%- udp_forwarding ? ' checked' : '' %>>
+                                    <span class="custom-switch-indicator"></span>
+                                    <span class="custom-switch-description"><%- i18n('streams', 'udp-forwarding') %></span>
+                                </label>
+                            </div>
+                        </div>
+                        <div class="col-sm-12 col-md-12">
+                            <div class="forward-type-error invalid-feedback"><%- i18n('streams', 'forward-type-error') %></div>
+                        </div>
                     </div>
                 </div>
-                <div class="col-sm-8 col-md-8">
-                    <div class="form-group">
-                        <label class="form-label"><%- i18n('streams', 'forwarding-host') %><span class="form-required">*</span></label>
-                        <input type="text" name="forwarding_host" class="form-control text-monospace" placeholder="example.com or 10.0.0.1 or 2001:db8:3333:4444:5555:6666:7777:8888" value="<%- forwarding_host %>" autocomplete="off" maxlength="255" required>
+
+                <!-- SSL -->
+                <div role="tabpanel" class="tab-pane" id="ssl-options">
+                    <div class="row">
+                        <div class="col-sm-12 col-md-12">
+                            <div class="form-group">
+                                <label class="form-label"><%- i18n('streams', 'ssl-certificate') %></label>
+                                <select name="certificate_id" class="form-control custom-select" placeholder="<%- i18n('all-hosts', 'none') %>">
+                                    <option selected value="0" data-data="{&quot;id&quot;:0}" <%- certificate_id ? '' : 'selected' %>><%- i18n('all-hosts', 'none') %></option>
+                                    <option selected value="new" data-data="{&quot;id&quot;:&quot;new&quot;}"><%- i18n('all-hosts', 'new-cert') %></option>
+                                </select>
+                            </div>
+                        </div>
+
+                        <!-- DNS challenge -->
+                        <div class="col-sm-12 col-md-12 letsencrypt">
+                            <div class="form-group">
+                                <label class="form-label"><%- i18n('all-hosts', 'domain-names') %> <span class="form-required">*</span></label>
+                                <input type="text" name="domain_names" class="form-control" id="input-domains" value="<%- domain_names.join(',') %>">
+                            </div>
+                            <div class="form-group">
+                                <label class="custom-switch">
+                                    <input
+                                            type="checkbox"
+                                            class="custom-switch-input"
+                                            name="meta[dns_challenge]"
+                                            value="1"
+                                            checked
+                                            disabled
+                                    >
+                                    <span class="custom-switch-indicator"></span>
+                                    <span class="custom-switch-description"><%= i18n('ssl', 'dns-challenge') %></span>
+                                </label>
+                            </div>
+                        </div>
+                        <div class="col-sm-12 col-md-12 letsencrypt">
+                            <fieldset class="form-fieldset dns-challenge">
+                                <div class="text-red mb-4"><i class="fe fe-alert-triangle"></i> <%= i18n('ssl', 'certbot-warning') %></div>
+
+                                <!-- Certbot DNS plugin selection -->
+                                <div class="row">
+                                    <div class="col-sm-12 col-md-12">
+                                        <div class="form-group">
+                                            <label class="form-label"><%- i18n('ssl', 'dns-provider') %> <span class="form-required">*</span></label>
+                                            <select
+                                                    name="meta[dns_provider]"
+                                                    id="dns_provider"
+                                                    class="form-control custom-select"
+                                            >
+                                                <option
+                                                        value=""
+                                                        disabled
+                                                        hidden
+                                                        <%- getDnsProvider() === null ? 'selected' : '' %>
+                                                >Please Choose...</option>
+                                                <% _.each(dns_plugins, function(plugin_info, plugin_name){ %>
+                                                    <option
+                                                            value="<%- plugin_name %>"
+                                                            <%- getDnsProvider() === plugin_name ? 'selected' : '' %>
+                                                    ><%- plugin_info.name %></option>
+                                                <% }); %>
+                                            </select>
+                                        </div>
+                                    </div>
+                                </div>
+
+                                <!-- Certbot credentials file content -->
+                                <div class="row credentials-file-content">
+                                    <div class="col-sm-12 col-md-12">
+                                        <div class="form-group">
+                                            <label class="form-label"><%- i18n('ssl', 'credentials-file-content') %> <span class="form-required">*</span></label>
+                                            <textarea
+                                                    name="meta[dns_provider_credentials]"
+                                                    class="form-control text-monospace"
+                                                    id="dns_provider_credentials"
+                                            ><%- getDnsProviderCredentials() %></textarea>
+                                            <div class="text-secondary small">
+                                                <i class="fe fe-info"></i>
+                                                <%= i18n('ssl', 'credentials-file-content-info') %>
+                                            </div>
+                                            <div class="text-red small">
+                                                <i class="fe fe-alert-triangle"></i>
+                                                <%= i18n('ssl', 'stored-as-plaintext-info') %>
+                                            </div>
+                                        </div>
+                                    </div>
+                                </div>
+
+                                <!-- DNS propagation delay -->
+                                <div class="row">
+                                    <div class="col-sm-12 col-md-12">
+                                        <div class="form-group mb-0">
+                                            <label class="form-label"><%- i18n('ssl', 'propagation-seconds') %></label>
+                                            <input
+                                                    type="number"
+                                                    min="0"
+                                                    name="meta[propagation_seconds]"
+                                                    class="form-control"
+                                                    id="propagation_seconds"
+                                                    value="<%- getPropagationSeconds() %>"
+                                            >
+                                            <div class="text-secondary small">
+                                                <i class="fe fe-info"></i>
+                                                <%= i18n('ssl', 'propagation-seconds-info') %>
+                                            </div>
+                                        </div>
+                                    </div>
+                                </div>
+                            </fieldset>
+                        </div>
+
+                        <!-- Lets encrypt -->
+                        <div class="col-sm-12 col-md-12 letsencrypt">
+                            <div class="form-group">
+                                <label class="form-label"><%- i18n('ssl', 'letsencrypt-email') %> <span class="form-required">*</span></label>
+                                <input name="meta[letsencrypt_email]" type="email" class="form-control" placeholder="" value="<%- getLetsencryptEmail() %>" required disabled>
+                            </div>
+                        </div>
+                        <div class="col-sm-12 col-md-12 letsencrypt">
+                            <div class="form-group">
+                                <label class="custom-switch">
+                                    <input type="checkbox" class="custom-switch-input" name="meta[letsencrypt_agree]" value="1" required disabled>
+                                    <span class="custom-switch-indicator"></span>
+                                    <span class="custom-switch-description"><%= i18n('ssl', 'letsencrypt-agree', {url: 'https://letsencrypt.org/repository/'}) %> <span class="form-required">*</span></span>
+                                </label>
+                            </div>
+                        </div>
                     </div>
                 </div>
-                <div class="col-sm-4 col-md-4">
-                    <div class="form-group">
-                        <label class="form-label"><%- i18n('streams', 'forwarding-port') %> <span class="form-required">*</span></label>
-                        <input name="forwarding_port" type="number" class="form-control text-monospace" placeholder="eg: 80" min="1" max="65535" value="<%- forwarding_port %>" required>
-                    </div>
-                </div>
-                <div class="col-sm-6 col-md-6">
-                    <div class="form-group">
-                        <label class="custom-switch">
-                            <input type="checkbox" class="custom-switch-input" name="tcp_forwarding" value="1"<%- tcp_forwarding ? ' checked' : '' %>>
-                            <span class="custom-switch-indicator"></span>
-                            <span class="custom-switch-description"><%- i18n('streams', 'tcp-forwarding') %></span>
-                        </label>
-                    </div>
-                </div>
-                <div class="col-sm-6 col-md-6">
-                    <div class="form-group">
-                        <label class="custom-switch">
-                            <input type="checkbox" class="custom-switch-input" name="udp_forwarding" value="1"<%- udp_forwarding ? ' checked' : '' %>>
-                            <span class="custom-switch-indicator"></span>
-                            <span class="custom-switch-description"><%- i18n('streams', 'udp-forwarding') %></span>
-                        </label>
-                    </div>
-                </div>
-                <div class="col-sm-12 col-md-12">
-                    <div class="forward-type-error invalid-feedback"><%- i18n('streams', 'forward-type-error') %></div>
-                </div>
             </div>
         </form>
     </div>

+ 154 - 13
frontend/js/app/nginx/stream/form.js

@@ -1,24 +1,38 @@
-const Mn          = require('backbone.marionette');
-const App         = require('../../main');
-const StreamModel = require('../../../models/stream');
-const template    = require('./form.ejs');
+const Mn            = require('backbone.marionette');
+const App           = require('../../main');
+const StreamModel   = require('../../../models/stream');
+const template      = require('./form.ejs');
+const dns_providers = require('../../../../../global/certbot-dns-plugins');
 
 require('jquery-serializejson');
 require('jquery-mask-plugin');
 require('selectize');
+const Helpers = require("../../../lib/helpers");
+const certListItemTemplate = require("../certificates-list-item.ejs");
+const i18n = require("../../i18n");
 
 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'
+        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',
+        le_error_info:            '#le-error-info',
+        certificate_select:       'select[name="certificate_id"]',
+        domain_names:             'input[name="domain_names"]',
+        dns_challenge_switch:     'input[name="meta[dns_challenge]"]',
+        dns_challenge_content:    '.dns-challenge',
+        dns_provider:             'select[name="meta[dns_provider]"]',
+        credentials_file_content: '.credentials-file-content',
+        dns_provider_credentials: 'textarea[name="meta[dns_provider_credentials]"]',
+        propagation_seconds:      'input[name="meta[propagation_seconds]"]',
+        letsencrypt:              '.letsencrypt'
     },
 
     events: {
@@ -48,6 +62,35 @@ module.exports = Mn.View.extend({
             data.tcp_forwarding  = !!data.tcp_forwarding;
             data.udp_forwarding  = !!data.udp_forwarding;
 
+            if (typeof data.meta === 'undefined') data.meta = {};
+            data.meta.letsencrypt_agree = data.meta.letsencrypt_agree == 1;
+            data.meta.dns_challenge = true;
+
+            if (data.meta.propagation_seconds === '') data.meta.propagation_seconds = undefined;
+
+            if (typeof data.domain_names === 'string' && data.domain_names) {
+                data.domain_names = data.domain_names.split(',');
+            }
+
+            // Check for any domain names containing wildcards, which are not allowed with letsencrypt
+            if (data.certificate_id === 'new') {
+                let domain_err = false;
+                if (!data.meta.dns_challenge) {
+                    data.domain_names.map(function (name) {
+                        if (name.match(/\*/im)) {
+                            domain_err = true;
+                        }
+                    });
+                }
+
+                if (domain_err) {
+                    alert(i18n('ssl', 'no-wildcard-without-dns'));
+                    return;
+                }
+            } else {
+                data.certificate_id = parseInt(data.certificate_id, 10);
+            }
+
             let method = App.Api.Nginx.Streams.create;
             let is_new = true;
 
@@ -70,10 +113,108 @@ module.exports = Mn.View.extend({
                     });
                 })
                 .catch(err => {
-                    alert(err.message);
+                    let more_info = '';
+                    if (err.code === 500 && err.debug) {
+                        try {
+                            more_info = JSON.parse(err.debug).debug.stack.join("\n");
+                        } catch (e) {
+                        }
+                    }
+                    this.ui.le_error_info[0].innerHTML = `${err.message}${more_info !== '' ? `<pre class="mt-3">${more_info}</pre>` : ''}`;
+                    this.ui.le_error_info.show();
+                    this.ui.le_error_info[0].scrollIntoView();
                     this.ui.buttons.prop('disabled', false).removeClass('btn-disabled');
+                    this.ui.save.removeClass('btn-loading');
                 });
-        }
+        },
+
+        'change @ui.certificate_select': function () {
+            let id = this.ui.certificate_select.val();
+            if (id === 'new') {
+                this.ui.letsencrypt.show().find('input').prop('disabled', false);
+                this.ui.domain_names.prop('required', 'required');
+
+                this.ui.dns_challenge_switch
+                    .prop('disabled', true)
+                    .parents('.form-group')
+                    .css('opacity', 0.5);
+
+                this.ui.dns_provider.prop('required', 'required');
+                const selected_provider = this.ui.dns_provider[0].options[this.ui.dns_provider[0].selectedIndex].value;
+                if (selected_provider != '' && dns_providers[selected_provider].credentials !== false) {
+                    this.ui.dns_provider_credentials.prop('required', 'required');
+                }
+                this.ui.dns_challenge_content.show();
+            } else {
+                this.ui.letsencrypt.hide().find('input').prop('disabled', true);
+            }
+        },
+
+        'change @ui.dns_provider': function () {
+            const selected_provider = this.ui.dns_provider[0].options[this.ui.dns_provider[0].selectedIndex].value;
+            if (selected_provider != '' && dns_providers[selected_provider].credentials !== false) {
+                this.ui.dns_provider_credentials.prop('required', 'required');
+                this.ui.dns_provider_credentials[0].value = dns_providers[selected_provider].credentials;
+                this.ui.credentials_file_content.show();
+            } else {
+                this.ui.dns_provider_credentials.prop('required', false);
+                this.ui.credentials_file_content.hide();
+            }
+        },
+    },
+
+    templateContext: {
+        getLetsencryptEmail: function () {
+            return App.Cache.User.get('email');
+        },
+        getDnsProvider: function () {
+            return typeof this.meta.dns_provider !== 'undefined' && this.meta.dns_provider != '' ? this.meta.dns_provider : null;
+        },
+        getDnsProviderCredentials: function () {
+            return typeof this.meta.dns_provider_credentials !== 'undefined' ? this.meta.dns_provider_credentials : '';
+        },
+        getPropagationSeconds: function () {
+            return typeof this.meta.propagation_seconds !== 'undefined' ? this.meta.propagation_seconds : '';
+        },
+        dns_plugins: dns_providers,
+    },
+
+    onRender: function () {
+        let view = this;
+
+        // Certificates
+        this.ui.le_error_info.hide();
+        this.ui.dns_challenge_content.hide();
+        this.ui.credentials_file_content.hide();
+        this.ui.letsencrypt.hide();
+        this.ui.certificate_select.selectize({
+            valueField:       'id',
+            labelField:       'nice_name',
+            searchField:      ['nice_name', 'domain_names'],
+            create:           false,
+            preload:          true,
+            allowEmptyOption: true,
+            render:           {
+                option: function (item) {
+                    item.i18n         = App.i18n;
+                    item.formatDbDate = Helpers.formatDbDate;
+                    return certListItemTemplate(item);
+                }
+            },
+            load:             function (query, callback) {
+                App.Api.Nginx.Certificates.getAll()
+                    .then(rows => {
+                        callback(rows);
+                    })
+                    .catch(err => {
+                        console.error(err);
+                        callback();
+                    });
+            },
+            onLoad:           function () {
+                view.ui.certificate_select[0].selectize.setValue(view.model.get('certificate_id'));
+            }
+        });
     },
 
     initialize: function (options) {

+ 7 - 1
frontend/js/app/nginx/stream/list/item.ejs

@@ -16,7 +16,10 @@
 </td>
 <td>
     <div>
-        <% if (tcp_forwarding) { %>
+        <% if (certificate) { %>
+            <span class="tag"><%- i18n('streams', 'tcp+ssl') %></span>
+        <% }
+        else if (tcp_forwarding) { %>
             <span class="tag"><%- i18n('streams', 'tcp') %></span>
         <% }
         if (udp_forwarding) { %>
@@ -24,6 +27,9 @@
         <% } %>
     </div>
 </td>
+<td>
+    <div><%- certificate && certificate_id ? i18n('ssl', certificate.provider) : i18n('all-hosts', 'none') %></div>
+</td>
 <td>
     <%
     var o = isOnline();

+ 1 - 0
frontend/js/app/nginx/stream/list/main.ejs

@@ -3,6 +3,7 @@
     <th><%- i18n('streams', 'incoming-port') %></th>
     <th><%- i18n('str', 'destination') %></th>
     <th><%- i18n('streams', 'protocol') %></th>
+    <th><%- i18n('str', 'ssl') %></th>
     <th><%- i18n('str', 'status') %></th>
     <% if (canManage) { %>
     <th>&nbsp;</th>

+ 1 - 1
frontend/js/app/nginx/stream/main.js

@@ -88,7 +88,7 @@ module.exports = Mn.View.extend({
     onRender: function () {
         let view = this;
 
-        view.fetch(['owner'])
+        view.fetch(['owner', 'certificate'])
             .then(response => {
                 if (!view.isDestroyed()) {
                     if (response && response.length) {

+ 3 - 1
frontend/js/i18n/messages.json

@@ -179,7 +179,9 @@
       "delete-confirm": "Are you sure you want to delete this Stream?",
       "help-title": "What is a Stream?",
       "help-content": "A relatively new feature for Nginx, a Stream will serve to forward TCP/UDP traffic directly to another computer on the network.\nIf you're running game servers, FTP or SSH servers this can come in handy.",
-      "search": "Search Incoming Port…"
+      "search": "Search Incoming Port…",
+      "ssl-certificate": "SSL Certificate for TCP Forwarding",
+      "tcp+ssl": "TCP+SSL"
     },
     "certificates": {
       "title": "SSL Certificates",

+ 4 - 1
frontend/js/models/stream.js

@@ -15,8 +15,11 @@ const model = Backbone.Model.extend({
             udp_forwarding:  false,
             enabled:         true,
             meta:            {},
+            certificate_id:  0,
+            domain_names:    [],
             // The following are expansions:
-            owner:           null
+            owner:           null,
+            certificate:     null
         };
     }
 });

+ 14 - 3
test/cypress/Dockerfile

@@ -1,11 +1,22 @@
-FROM cypress/included:13.9.0
-
-COPY --chown=1000 ./test /test
+FROM cypress/included:14.0.1
 
 # Disable Cypress CLI colors
 ENV FORCE_COLOR=0
 ENV NO_COLOR=1
 
+# testssl.sh and mkcert
+RUN wget "https://github.com/testssl/testssl.sh/archive/refs/tags/v3.2rc4.tar.gz" -O /tmp/testssl.tgz -q \
+	&& tar -xzf /tmp/testssl.tgz -C /tmp \
+	&& mv /tmp/testssl.sh-3.2rc4 /testssl \
+	&& rm /tmp/testssl.tgz \
+	&& apt-get update \
+	&& apt-get install -y bsdmainutils curl dnsutils \
+	&& apt-get clean \
+	&& rm -rf /var/lib/apt/lists/* \
+	&& wget "https://github.com/FiloSottile/mkcert/releases/download/v1.4.4/mkcert-v1.4.4-linux-amd64" -O /bin/mkcert \
+	&& chmod +x /bin/mkcert
+
+COPY --chown=1000 ./test /test
 WORKDIR /test
 RUN yarn install && yarn cache clean
 ENTRYPOINT []

+ 213 - 0
test/cypress/e2e/api/Streams.cy.js

@@ -0,0 +1,213 @@
+/// <reference types="cypress" />
+
+describe('Streams', () => {
+	let token;
+
+	before(() => {
+		cy.getToken().then((tok) => {
+			token = tok;
+			// Set default site content
+			cy.task('backendApiPut', {
+				token: token,
+				path:  '/api/settings/default-site',
+				data: {
+					value: 'html',
+					meta: {
+						html: '<p>yay it works</p>'
+					},
+				},
+			}).then((data) => {
+				cy.validateSwaggerSchema('put', 200, '/settings/{settingID}', data);
+			});
+		});
+
+		// Create a custom cert pair
+		cy.exec('mkcert -cert-file=/test/cypress/fixtures/website1.pem -key-file=/test/cypress/fixtures/website1.key.pem website1.example.com').then((result) => {
+			expect(result.code).to.eq(0);
+			// Install CA
+			cy.exec('mkcert -install').then((result) => {
+				expect(result.code).to.eq(0);
+			});
+		});
+
+		cy.exec('rm -f /test/results/testssl.json');
+	});
+
+	it('Should be able to create TCP Stream', function() {
+		cy.task('backendApiPost', {
+			token: token,
+			path:  '/api/nginx/streams',
+			data:  {
+				incoming_port: 1500,
+				forwarding_host: '127.0.0.1',
+				forwarding_port: 80,
+				certificate_id: 0,
+				meta: {
+					dns_provider_credentials: "",
+					letsencrypt_agree: false,
+					dns_challenge: true
+				},
+				tcp_forwarding: true,
+				udp_forwarding: false
+			}
+		}).then((data) => {
+			cy.validateSwaggerSchema('post', 201, '/nginx/streams', data);
+			expect(data).to.have.property('id');
+			expect(data.id).to.be.greaterThan(0);
+			expect(data).to.have.property('enabled', true);
+			expect(data).to.have.property('tcp_forwarding', true);
+			expect(data).to.have.property('udp_forwarding', false);
+
+			cy.exec('curl --noproxy -- http://website1.example.com:1500').then((result) => {
+				expect(result.code).to.eq(0);
+				expect(result.stdout).to.contain('yay it works');
+			});
+		});
+	});
+
+	it('Should be able to create UDP Stream', function() {
+		cy.task('backendApiPost', {
+			token: token,
+			path:  '/api/nginx/streams',
+			data:  {
+				incoming_port: 1501,
+				forwarding_host: '127.0.0.1',
+				forwarding_port: 80,
+				certificate_id: 0,
+				meta: {
+					dns_provider_credentials: "",
+					letsencrypt_agree: false,
+					dns_challenge: true
+				},
+				tcp_forwarding: false,
+				udp_forwarding: true
+			}
+		}).then((data) => {
+			cy.validateSwaggerSchema('post', 201, '/nginx/streams', data);
+			expect(data).to.have.property('id');
+			expect(data.id).to.be.greaterThan(0);
+			expect(data).to.have.property('enabled', true);
+			expect(data).to.have.property('tcp_forwarding', false);
+			expect(data).to.have.property('udp_forwarding', true);
+		});
+	});
+
+	it('Should be able to create TCP/UDP Stream', function() {
+		cy.task('backendApiPost', {
+			token: token,
+			path:  '/api/nginx/streams',
+			data:  {
+				incoming_port: 1502,
+				forwarding_host: '127.0.0.1',
+				forwarding_port: 80,
+				certificate_id: 0,
+				meta: {
+					dns_provider_credentials: "",
+					letsencrypt_agree: false,
+					dns_challenge: true
+				},
+				tcp_forwarding: true,
+				udp_forwarding: true
+			}
+		}).then((data) => {
+			cy.validateSwaggerSchema('post', 201, '/nginx/streams', data);
+			expect(data).to.have.property('id');
+			expect(data.id).to.be.greaterThan(0);
+			expect(data).to.have.property('enabled', true);
+			expect(data).to.have.property('tcp_forwarding', true);
+			expect(data).to.have.property('udp_forwarding', true);
+
+			cy.exec('curl --noproxy -- http://website1.example.com:1502').then((result) => {
+				expect(result.code).to.eq(0);
+				expect(result.stdout).to.contain('yay it works');
+			});
+		});
+	});
+
+	it('Should be able to create SSL TCP Stream', function() {
+		let certID = 0;
+
+		// Create custom cert
+		cy.task('backendApiPost', {
+			token: token,
+			path:  '/api/nginx/certificates',
+			data:  {
+				provider: "other",
+				nice_name: "Custom Certificate for SSL Stream",
+			},
+		}).then((data) => {
+			cy.validateSwaggerSchema('post', 201, '/nginx/certificates', data);
+			expect(data).to.have.property('id');
+			certID = data.id;
+
+			// Upload files
+			cy.task('backendApiPostFiles', {
+				token: token,
+				path:  `/api/nginx/certificates/${certID}/upload`,
+				files:  {
+					certificate: 'website1.pem',
+					certificate_key: 'website1.key.pem',
+				},
+			}).then((data) => {
+				cy.validateSwaggerSchema('post', 200, '/nginx/certificates/{certID}/upload', data);
+				expect(data).to.have.property('certificate');
+				expect(data).to.have.property('certificate_key');
+
+				// Create the stream
+				cy.task('backendApiPost', {
+					token: token,
+					path:  '/api/nginx/streams',
+					data:  {
+						incoming_port: 1503,
+						forwarding_host: '127.0.0.1',
+						forwarding_port: 80,
+						certificate_id: certID,
+						meta: {
+							dns_provider_credentials: "",
+							letsencrypt_agree: false,
+							dns_challenge: true
+						},
+						tcp_forwarding: true,
+						udp_forwarding: false
+					}
+				}).then((data) => {
+					cy.validateSwaggerSchema('post', 201, '/nginx/streams', data);
+					expect(data).to.have.property('id');
+					expect(data.id).to.be.greaterThan(0);
+					expect(data).to.have.property("enabled", true);
+					expect(data).to.have.property('tcp_forwarding', true);
+					expect(data).to.have.property('udp_forwarding', false);
+					expect(data).to.have.property('certificate_id', certID);
+
+					// Check the ssl termination
+					cy.task('log', '[testssl.sh] Running ...');
+					cy.exec('/testssl/testssl.sh --quiet --add-ca="$(/bin/mkcert -CAROOT)/rootCA.pem" --jsonfile=/test/results/testssl.json website1.example.com:1503', {
+						timeout: 120000, // 2 minutes
+					}).then((result) => {
+						cy.task('log', '[testssl.sh] ' + result.stdout);
+
+						const allowedSeverities = ["INFO", "OK", "LOW", "MEDIUM"];
+						const ignoredIDs = [
+							'cert_chain_of_trust',
+							'cert_extlifeSpan',
+							'cert_revocation',
+							'overall_grade',
+						];
+
+						cy.readFile('/test/results/testssl.json').then((data) => {
+							// Parse each array item
+							for (let i = 0; i < data.length; i++) {
+								const item = data[i];
+								if (ignoredIDs.includes(item.id)) {
+									continue;
+								}
+								expect(item.severity).to.be.oneOf(allowedSeverities);
+							}
+						});
+					});
+				});
+			});
+		});
+	});
+
+});

+ 7 - 7
test/package.json

@@ -4,18 +4,18 @@
 	"description": "",
 	"main": "index.js",
 	"dependencies": {
-		"@jc21/cypress-swagger-validation": "^0.3.1",
-		"axios": "^1.7.7",
-		"cypress": "^13.15.0",
-		"cypress-multi-reporters": "^1.6.4",
+		"@jc21/cypress-swagger-validation": "^0.3.2",
+		"axios": "^1.7.9",
+		"cypress": "^14.0.1",
+		"cypress-multi-reporters": "^2.0.5",
 		"cypress-wait-until": "^3.0.2",
-		"eslint": "^9.12.0",
+		"eslint": "^9.19.0",
 		"eslint-plugin-align-assignments": "^1.1.2",
 		"eslint-plugin-chai-friendly": "^1.0.1",
-		"eslint-plugin-cypress": "^3.5.0",
+		"eslint-plugin-cypress": "^4.1.0",
 		"form-data": "^4.0.1",
 		"lodash": "^4.17.21",
-		"mocha": "^10.7.3",
+		"mocha": "^11.1.0",
 		"mocha-junit-reporter": "^2.2.1"
 	},
 	"scripts": {