From f368985a60663af9a93c0b7955f2231352e754ad Mon Sep 17 00:00:00 2001 From: Teagan glenn Date: Sat, 29 Jun 2024 21:03:19 +0000 Subject: [PATCH 1/8] feat(streams): Add multiple upstreams for basic load balancing --- backend/models/stream.js | 98 ++++++++++++---------- backend/schema/endpoints/streams.json | 34 ++++++-- backend/templates/stream.conf | 23 ++++- frontend/js/app/nginx/stream/form.ejs | 6 +- frontend/js/app/nginx/stream/form.js | 26 +++++- frontend/js/app/nginx/stream/list/item.ejs | 13 ++- frontend/js/app/nginx/stream/list/main.ejs | 1 + frontend/js/i18n/messages.json | 4 +- frontend/js/models/stream.js | 2 +- 9 files changed, 141 insertions(+), 66 deletions(-) diff --git a/backend/models/stream.js b/backend/models/stream.js index 7d84d2c36..3ad4ea9fe 100644 --- a/backend/models/stream.js +++ b/backend/models/stream.js @@ -1,55 +1,65 @@ // Objection Docs: // http://vincit.github.io/objection.js/ -const db = require('../db'); +const db = require('../db'); const Model = require('objection').Model; -const User = require('./user'); -const now = require('./now_helper'); +const User = require('./user'); +const now = require('./now_helper'); Model.knex(db); class Stream extends Model { - $beforeInsert () { - this.created_on = now(); - this.modified_on = now(); - - // Default for meta - if (typeof this.meta === 'undefined') { - this.meta = {}; - } - } - - $beforeUpdate () { - this.modified_on = now(); - } - - static get name () { - return 'Stream'; - } - - static get tableName () { - return 'stream'; - } - - static get jsonAttributes () { - return ['meta']; - } - - static get relationMappings () { - return { - owner: { - relation: Model.HasOneRelation, - modelClass: User, - join: { - from: 'stream.owner_user_id', - to: 'user.id' - }, - modify: function (qb) { - qb.where('user.is_deleted', 0); - } - } - }; - } + $beforeInsert() { + this.created_on = now(); + this.modified_on = now(); + + // Default for forwarding_hosts + if (typeof this.forwarding_hosts === 'undefined') { + this.forwarding_hosts = []; + } + + // Default for meta + if (typeof this.meta === 'undefined') { + this.meta = {}; + } + } + + $beforeUpdate() { + this.modified_on = now(); + + // Sort domain_names + if (typeof this.forwarding_hosts !== 'undefined') { + this.forwarding_hosts.sort(); + } + } + + static get name() { + return 'Stream'; + } + + static get tableName() { + return 'stream'; + } + + static get jsonAttributes() { + return ['forwarding_hosts', 'meta']; + } + + static get relationMappings() { + return { + owner: { + relation: Model.HasOneRelation, + modelClass: User, + join: { + from: 'stream.owner_user_id', + to: 'user.id' + }, + modify: function (qb) { + qb.where('user.is_deleted', 0); + } + } + }; + } } module.exports = Stream; diff --git a/backend/schema/endpoints/streams.json b/backend/schema/endpoints/streams.json index 159c8036e..da3de83ed 100644 --- a/backend/schema/endpoints/streams.json +++ b/backend/schema/endpoints/streams.json @@ -20,7 +20,7 @@ "minimum": 1, "maximum": 65535 }, - "forwarding_host": { + "host": { "anyOf": [ { "$ref": "../definitions.json#/definitions/domain_name" @@ -35,6 +35,22 @@ } ] }, + "forwarding_hosts": { + "anyOf": [ + { + "$ref": "#/definitions/host" + }, + { + "type": "array", + "minItems": 1, + "maxItems": 15, + "uniqueItems": true, + "items": { + "$ref": "#/definitions/host" + } + } + ] + }, "forwarding_port": { "type": "integer", "minimum": 1, @@ -66,8 +82,8 @@ "incoming_port": { "$ref": "#/definitions/incoming_port" }, - "forwarding_host": { - "$ref": "#/definitions/forwarding_host" + "forwarding_hosts": { + "$ref": "#/definitions/forwarding_hosts" }, "forwarding_port": { "$ref": "#/definitions/forwarding_port" @@ -118,15 +134,15 @@ "additionalProperties": false, "required": [ "incoming_port", - "forwarding_host", + "forwarding_hosts", "forwarding_port" ], "properties": { "incoming_port": { "$ref": "#/definitions/incoming_port" }, - "forwarding_host": { - "$ref": "#/definitions/forwarding_host" + "forwarding_hosts": { + "$ref": "#/definitions/forwarding_hosts" }, "forwarding_port": { "$ref": "#/definitions/forwarding_port" @@ -165,8 +181,8 @@ "incoming_port": { "$ref": "#/definitions/incoming_port" }, - "forwarding_host": { - "$ref": "#/definitions/forwarding_host" + "forwarding_hosts": { + "$ref": "#/definitions/forwarding_hosts" }, "forwarding_port": { "$ref": "#/definitions/forwarding_port" @@ -231,4 +247,4 @@ } } ] -} +} \ No newline at end of file diff --git a/backend/templates/stream.conf b/backend/templates/stream.conf index 76159a646..c7848223c 100644 --- a/backend/templates/stream.conf +++ b/backend/templates/stream.conf @@ -3,6 +3,13 @@ # ------------------------------------------------------------ {% if enabled %} + +upstream stream_{{ incoming_port }}_tcp { + {% for forwarding_host in forwarding_hosts %} + server {{ forwarding_host }}:{{ forwarding_port }}; + {%- endfor %} +} + {% if tcp_forwarding == 1 or tcp_forwarding == true -%} server { listen {{ incoming_port }}; @@ -12,7 +19,7 @@ server { #listen [::]:{{ incoming_port }}; {% endif %} - proxy_pass {{ forwarding_host }}:{{ forwarding_port }}; + proxy_pass stream_{{ incoming_port }}_tcp; # Custom include /data/nginx/custom/server_stream[.]conf; @@ -20,18 +27,26 @@ server { } {% endif %} {% if udp_forwarding == 1 or udp_forwarding == true %} + +upstream stream_{{ incoming_port }}_udp { + {% for forwarding_host in forwarding_hosts %} + server {{ forwarding_host }}:{{ forwarding_port }}; + {%- endfor %} +} + server { listen {{ incoming_port }} udp; {% if ipv6 -%} listen [::]:{{ incoming_port }} udp; {% else -%} - #listen [::]:{{ incoming_port }} udp; + #listen [::]:{{ incoming_port }} udp; {% endif %} - proxy_pass {{ forwarding_host }}:{{ forwarding_port }}; + + proxy_pass stream_{{ incoming_port }}_udp; # Custom include /data/nginx/custom/server_stream[.]conf; include /data/nginx/custom/server_stream_udp[.]conf; } {% endif %} -{% endif %} \ No newline at end of file +{% endif %} diff --git a/frontend/js/app/nginx/stream/form.ejs b/frontend/js/app/nginx/stream/form.ejs index 1fc4f1342..7db7ac8d5 100644 --- a/frontend/js/app/nginx/stream/form.ejs +++ b/frontend/js/app/nginx/stream/form.ejs @@ -2,7 +2,7 @@ + -<% } %> \ No newline at end of file +<% } %> diff --git a/frontend/js/app/nginx/stream/list/main.ejs b/frontend/js/app/nginx/stream/list/main.ejs index 5304f6145..d778f5caa 100644 --- a/frontend/js/app/nginx/stream/list/main.ejs +++ b/frontend/js/app/nginx/stream/list/main.ejs @@ -3,6 +3,7 @@ <%- i18n('streams', 'incoming-port') %> <%- i18n('str', 'destination') %> <%- i18n('streams', 'protocol') %> + <%- i18n('streams', 'forwarding-port') %> <%- i18n('str', 'status') %> <% if (canManage) { %>   diff --git a/frontend/js/i18n/messages.json b/frontend/js/i18n/messages.json index 0bbde4541..43eccf7a3 100644 --- a/frontend/js/i18n/messages.json +++ b/frontend/js/i18n/messages.json @@ -167,7 +167,7 @@ "add": "Add Stream", "form-title": "{id, select, undefined{New} other{Edit}} Stream", "incoming-port": "Incoming Port", - "forwarding-host": "Forward Host", + "forwarding-hosts": "Forward Hoss", "forwarding-port": "Forward Port", "tcp-forwarding": "TCP Forwarding", "udp-forwarding": "UDP Forwarding", @@ -293,4 +293,4 @@ "default-site-redirect": "Redirect" } } -} +} \ No newline at end of file diff --git a/frontend/js/models/stream.js b/frontend/js/models/stream.js index ba035429a..9c6159690 100644 --- a/frontend/js/models/stream.js +++ b/frontend/js/models/stream.js @@ -9,7 +9,7 @@ const model = Backbone.Model.extend({ created_on: null, modified_on: null, incoming_port: null, - forwarding_host: null, + forwarding_hosts: [], forwarding_port: null, tcp_forwarding: true, udp_forwarding: false, From ffeb190b3fc2221cb4f3acd2f9508676cf528894 Mon Sep 17 00:00:00 2001 From: Teagan glenn Date: Sat, 29 Jun 2024 21:34:00 +0000 Subject: [PATCH 2/8] feat(streams): Regex for host or host with port --- backend/schema/endpoints/streams.json | 34 ++++++++++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/backend/schema/endpoints/streams.json b/backend/schema/endpoints/streams.json index da3de83ed..b07735250 100644 --- a/backend/schema/endpoints/streams.json +++ b/backend/schema/endpoints/streams.json @@ -20,6 +20,18 @@ "minimum": 1, "maximum": 65535 }, + "domain_name_port": { + "type": "string", + "pattern": "^(?:[^.*:]+\\.?)+[^.]:(?:(?:6553[0-5])|(?:655[0-2][0-9])|(?:65[0-4][0-9]{2})|(?:6[0-4][0-9]{3})|(?:[1-5][0-9]{4})|(?:[0-9]{1,4}))$" + }, + "ipv4_port": { + "type": "string", + "pattern": "^(?:\\b25[0-5]|\\b2[0-4][0-9]|\\b[01]?[0-9][0-9]?)(?:\\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}:(?:(?:6553[0-5])|(?:655[0-2][0-9])|(?:65[0-4][0-9]{2})|(?:6[0-4][0-9]{3})|(?:[1-5][0-9]{4})|(?:[0-9]{1,4}))$" + }, + "ipv6_port": { + "type": "string", + "pattern": "^(?:(?:[0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|(?:[0-9a-fA-F]{1,4}:){1,7}:|(?:[0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|(?:[0-9a-fA-F]{1,4}:){1,5}(?::[0-9a-fA-F]{1,4}){1,2}|(?:[0-9a-fA-F]{1,4}:){1,4}(?::[0-9a-fA-F]{1,4}){1,3}|(?:[0-9a-fA-F]{1,4}:){1,3}(?::[0-9a-fA-F]{1,4}){1,4}|(?:[0-9a-fA-F]{1,4}:){1,2}(?::[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:(?:(?::[0-9a-fA-F]{1,4}){1,6})|:(?:(?::[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(?::[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(?:ffff(:0{1,4}){0,1}:){0,1}(?:(?:25[0-5]|(?:2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\.){3,3}(?:25[0-5]|(?:2[0-4]|1{0,1}[0-9]){0,1}[0-9])|(?:[0-9a-fA-F]{1,4}:){1,4}:(?:(?:25[0-5]|(?:2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\.){3,3}(?:25[0-5]|(?:2[0-4]|1{0,1}[0-9]){0,1}[0-9])):(?:(?:6553[0-5])|(?:655[0-2][0-9])|(?:65[0-4][0-9]{2})|(?:6[0-4][0-9]{3})|(?:[1-5][0-9]{4})|(?:[0-9]{1,4}))$" + }, "host": { "anyOf": [ { @@ -35,6 +47,19 @@ } ] }, + "host_port": { + "anyOf": [ + { + "$ref": "#/definitions/domain_name_port" + }, + { + "$ref": "#/definitions/ipv4_port" + }, + { + "$ref": "#/definitions/ipv6_port" + } + ] + }, "forwarding_hosts": { "anyOf": [ { @@ -46,7 +71,14 @@ "maxItems": 15, "uniqueItems": true, "items": { - "$ref": "#/definitions/host" + "anyOf": [ + { + "$ref": "#/definitions/host" + }, + { + "$ref": "#/definitions/host_port" + } + ] } } ] From f65f15af0172c94a03507920134ede18610c9613 Mon Sep 17 00:00:00 2001 From: Teagan glenn Date: Sat, 29 Jun 2024 22:46:21 +0000 Subject: [PATCH 3/8] feat(streams): Remove schema for host,ipv4,ipv6 with port --- backend/schema/endpoints/streams.json | 34 +-------------------------- 1 file changed, 1 insertion(+), 33 deletions(-) diff --git a/backend/schema/endpoints/streams.json b/backend/schema/endpoints/streams.json index b07735250..da3de83ed 100644 --- a/backend/schema/endpoints/streams.json +++ b/backend/schema/endpoints/streams.json @@ -20,18 +20,6 @@ "minimum": 1, "maximum": 65535 }, - "domain_name_port": { - "type": "string", - "pattern": "^(?:[^.*:]+\\.?)+[^.]:(?:(?:6553[0-5])|(?:655[0-2][0-9])|(?:65[0-4][0-9]{2})|(?:6[0-4][0-9]{3})|(?:[1-5][0-9]{4})|(?:[0-9]{1,4}))$" - }, - "ipv4_port": { - "type": "string", - "pattern": "^(?:\\b25[0-5]|\\b2[0-4][0-9]|\\b[01]?[0-9][0-9]?)(?:\\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}:(?:(?:6553[0-5])|(?:655[0-2][0-9])|(?:65[0-4][0-9]{2})|(?:6[0-4][0-9]{3})|(?:[1-5][0-9]{4})|(?:[0-9]{1,4}))$" - }, - "ipv6_port": { - "type": "string", - "pattern": "^(?:(?:[0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|(?:[0-9a-fA-F]{1,4}:){1,7}:|(?:[0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|(?:[0-9a-fA-F]{1,4}:){1,5}(?::[0-9a-fA-F]{1,4}){1,2}|(?:[0-9a-fA-F]{1,4}:){1,4}(?::[0-9a-fA-F]{1,4}){1,3}|(?:[0-9a-fA-F]{1,4}:){1,3}(?::[0-9a-fA-F]{1,4}){1,4}|(?:[0-9a-fA-F]{1,4}:){1,2}(?::[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:(?:(?::[0-9a-fA-F]{1,4}){1,6})|:(?:(?::[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(?::[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(?:ffff(:0{1,4}){0,1}:){0,1}(?:(?:25[0-5]|(?:2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\.){3,3}(?:25[0-5]|(?:2[0-4]|1{0,1}[0-9]){0,1}[0-9])|(?:[0-9a-fA-F]{1,4}:){1,4}:(?:(?:25[0-5]|(?:2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\.){3,3}(?:25[0-5]|(?:2[0-4]|1{0,1}[0-9]){0,1}[0-9])):(?:(?:6553[0-5])|(?:655[0-2][0-9])|(?:65[0-4][0-9]{2})|(?:6[0-4][0-9]{3})|(?:[1-5][0-9]{4})|(?:[0-9]{1,4}))$" - }, "host": { "anyOf": [ { @@ -47,19 +35,6 @@ } ] }, - "host_port": { - "anyOf": [ - { - "$ref": "#/definitions/domain_name_port" - }, - { - "$ref": "#/definitions/ipv4_port" - }, - { - "$ref": "#/definitions/ipv6_port" - } - ] - }, "forwarding_hosts": { "anyOf": [ { @@ -71,14 +46,7 @@ "maxItems": 15, "uniqueItems": true, "items": { - "anyOf": [ - { - "$ref": "#/definitions/host" - }, - { - "$ref": "#/definitions/host_port" - } - ] + "$ref": "#/definitions/host" } } ] From 85e7ebac0c45ce3a2da4c8f8e4bc2747dfa32b26 Mon Sep 17 00:00:00 2001 From: Teagan glenn Date: Sat, 29 Jun 2024 23:09:37 +0000 Subject: [PATCH 4/8] feat(streams): Add migration for chaning `forwarding_host' to 'forwarding_hosts' --- .../20240629165112_stream_load_balance.js | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 backend/migrations/20240629165112_stream_load_balance.js diff --git a/backend/migrations/20240629165112_stream_load_balance.js b/backend/migrations/20240629165112_stream_load_balance.js new file mode 100644 index 000000000..f1d1e4879 --- /dev/null +++ b/backend/migrations/20240629165112_stream_load_balance.js @@ -0,0 +1,42 @@ +const migrate_name = 'stream_load_balance'; +const logger = require('../logger').migrate; + +/** + * Migrate + * + * @see http://knexjs.org/#Schema + * + * @param {Object} knex + * @param {Promise} Promise + * @returns {Promise} + */ +exports.up = function (knex) { + logger.info('[' + migrate_name + '] Migrating Up...'); + + return knex.schema + .table('stream', (table) => { + table.renameColumn('forwarding_host', 'forwarding_hosts'); + }) + .then(function () { + logger.info('[' + migrate_name + '] stream Table altered'); + }); +}; + +/** + * Undo Migrate + * + * @param {Object} knex + * @param {Promise} Promise + * @returns {Promise} + */ +exports.down = function (knex) { + logger.info('[' + migrate_name + '] Migrating Down...'); + + return knex.schema + .table('stream', (table) => { + table.renameColumn('forwarding_hosts', 'forwarding_host'); + }) + .then(function () { + logger.info('[' + migrate_name + '] stream Table altered'); + }); +}; From 499933e4240d267fbd257ecffde878bf4c9429dd Mon Sep 17 00:00:00 2001 From: Teagan glenn Date: Sat, 29 Jun 2024 23:10:33 +0000 Subject: [PATCH 5/8] chore(streams): Lint fixes --- .../20240629165112_stream_load_balance.js | 2 +- backend/models/stream.js | 92 +++++++++---------- 2 files changed, 47 insertions(+), 47 deletions(-) diff --git a/backend/migrations/20240629165112_stream_load_balance.js b/backend/migrations/20240629165112_stream_load_balance.js index f1d1e4879..da5db6683 100644 --- a/backend/migrations/20240629165112_stream_load_balance.js +++ b/backend/migrations/20240629165112_stream_load_balance.js @@ -1,5 +1,5 @@ const migrate_name = 'stream_load_balance'; -const logger = require('../logger').migrate; +const logger = require('../logger').migrate; /** * Migrate diff --git a/backend/models/stream.js b/backend/models/stream.js index 3ad4ea9fe..00f2a67e1 100644 --- a/backend/models/stream.js +++ b/backend/models/stream.js @@ -1,65 +1,65 @@ // Objection Docs: // http://vincit.github.io/objection.js/ -const db = require('../db'); +const db = require('../db'); const Model = require('objection').Model; -const User = require('./user'); -const now = require('./now_helper'); +const User = require('./user'); +const now = require('./now_helper'); Model.knex(db); class Stream extends Model { - $beforeInsert() { - this.created_on = now(); - this.modified_on = now(); + $beforeInsert() { + this.created_on = now(); + this.modified_on = now(); - // Default for forwarding_hosts - if (typeof this.forwarding_hosts === 'undefined') { - this.forwarding_hosts = []; - } + // Default for forwarding_hosts + if (typeof this.forwarding_hosts === 'undefined') { + this.forwarding_hosts = []; + } - // Default for meta - if (typeof this.meta === 'undefined') { - this.meta = {}; - } - } + // Default for meta + if (typeof this.meta === 'undefined') { + this.meta = {}; + } + } - $beforeUpdate() { - this.modified_on = now(); + $beforeUpdate() { + this.modified_on = now(); - // Sort domain_names - if (typeof this.forwarding_hosts !== 'undefined') { - this.forwarding_hosts.sort(); - } - } + // Sort domain_names + if (typeof this.forwarding_hosts !== 'undefined') { + this.forwarding_hosts.sort(); + } + } - static get name() { - return 'Stream'; - } + static get name() { + return 'Stream'; + } - static get tableName() { - return 'stream'; - } + static get tableName() { + return 'stream'; + } - static get jsonAttributes() { - return ['forwarding_hosts', 'meta']; - } + static get jsonAttributes() { + return ['forwarding_hosts', 'meta']; + } - static get relationMappings() { - return { - owner: { - relation: Model.HasOneRelation, - modelClass: User, - join: { - from: 'stream.owner_user_id', - to: 'user.id' - }, - modify: function (qb) { - qb.where('user.is_deleted', 0); - } - } - }; - } + static get relationMappings() { + return { + owner: { + relation: Model.HasOneRelation, + modelClass: User, + join: { + from: 'stream.owner_user_id', + to: 'user.id' + }, + modify: function (qb) { + qb.where('user.is_deleted', 0); + } + } + }; + } } module.exports = Stream; From 32fb98fb7ccae32b18a00b48e631029676f3146b Mon Sep 17 00:00:00 2001 From: Teagan glenn Date: Sat, 29 Jun 2024 18:43:14 -0600 Subject: [PATCH 6/8] Fix template --- frontend/js/app/nginx/stream/list/item.ejs | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/frontend/js/app/nginx/stream/list/item.ejs b/frontend/js/app/nginx/stream/list/item.ejs index e925ddadc..9a65a3007 100644 --- a/frontend/js/app/nginx/stream/list/item.ejs +++ b/frontend/js/app/nginx/stream/list/item.ejs @@ -12,10 +12,10 @@ -
- <% forwarding_hosts.map(function(host) { %> - <%- host %> - <% }); %> +
+ <% forwarding_hosts.map(function(host) { + %><%- host %>:<%- forwarding_port %><% + }); %>
@@ -28,11 +28,6 @@ <% } %>
- -
- <%- forwarding_port %> -
- <% var o = isOnline(); @@ -59,4 +54,4 @@ -<% } %> +<% } %> \ No newline at end of file From d007b5c8a4c7287753cc640e2ccfd4f1d68ad1fd Mon Sep 17 00:00:00 2001 From: Teagan Glenn Date: Sat, 29 Jun 2024 20:45:05 -0600 Subject: [PATCH 7/8] feat(streams): Add least connection and max_fails if hosts > 1 --- backend/templates/stream.conf | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/backend/templates/stream.conf b/backend/templates/stream.conf index c7848223c..6c328c140 100644 --- a/backend/templates/stream.conf +++ b/backend/templates/stream.conf @@ -5,8 +5,16 @@ {% if enabled %} upstream stream_{{ incoming_port }}_tcp { + {% if forwarding_hosts.length > 1 -%} + least_conn; + {%- endif -%} + {% for forwarding_host in forwarding_hosts %} + {% if forloop.first == true and forloop.last == true -%} server {{ forwarding_host }}:{{ forwarding_port }}; + {%- else -%} + server {{ forwarding_host}}:{{ forwarding_port}} max_fails=3; + {%- endif %} {%- endfor %} } From fb5e40aa29d1b993a84ff9d27c3209781638da60 Mon Sep 17 00:00:00 2001 From: Teagan Glenn Date: Mon, 25 Nov 2024 21:01:21 -0700 Subject: [PATCH 8/8] Update frontend/js/i18n/messages.json Co-authored-by: Devon Thyne <42761453+devon-thyne@users.noreply.github.com> --- frontend/js/i18n/messages.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/js/i18n/messages.json b/frontend/js/i18n/messages.json index 43eccf7a3..31bf15e01 100644 --- a/frontend/js/i18n/messages.json +++ b/frontend/js/i18n/messages.json @@ -167,7 +167,7 @@ "add": "Add Stream", "form-title": "{id, select, undefined{New} other{Edit}} Stream", "incoming-port": "Incoming Port", - "forwarding-hosts": "Forward Hoss", + "forwarding-hosts": "Forward Hosts", "forwarding-port": "Forward Port", "tcp-forwarding": "TCP Forwarding", "udp-forwarding": "UDP Forwarding",