diff --git a/README.md b/README.md index 9ac6a6c85..e9c81b15b 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,11 @@ Immediately after logging in with this default user you will be asked to modify All are welcome to create pull requests for this project, against the `develop` branch. Official releases are created from the `master` branch. +Run the following commands to test locally: + +- `cd backend && npm i && node_modules/eslint/bin/eslint.js .` +- `cd test && npm i && npm run cypress` + CI is used in this project. All PR's must pass before being considered. After passing, docker builds for PR's are available on dockerhub for manual verifications. diff --git a/backend/internal/nginx.js b/backend/internal/nginx.js index 5f802c004..b18d4b83b 100644 --- a/backend/internal/nginx.js +++ b/backend/internal/nginx.js @@ -155,6 +155,7 @@ const internalNginx = { let locationCopy = Object.assign({}, {access_list_id: host.access_list_id}, {certificate_id: host.certificate_id}, {ssl_forced: host.ssl_forced}, {caching_enabled: host.caching_enabled}, {block_exploits: host.block_exploits}, {allow_websocket_upgrade: host.allow_websocket_upgrade}, {http2_support: host.http2_support}, + {enable_proxy_protocol: host.enable_proxy_protocol}, {load_balancer_ip: host.load_balancer_ip}, {hsts_enabled: host.hsts_enabled}, {hsts_subdomains: host.hsts_subdomains}, {access_list: host.access_list}, {certificate: host.certificate}, host.locations[i]); diff --git a/backend/migrations/20240310085523_proxy_protocol.js b/backend/migrations/20240310085523_proxy_protocol.js new file mode 100644 index 000000000..a520d9247 --- /dev/null +++ b/backend/migrations/20240310085523_proxy_protocol.js @@ -0,0 +1,41 @@ +const migrate_name = 'proxy_protocol'; +const logger = require('../logger').migrate; + +/** + * Migrate + * + * @see http://knexjs.org/#Schema + * + * @param {Object} knex + * @param {Promise} Promise + * @returns {Promise} + */ +exports.up = function (knex/*, Promise*/) { + logger.info('[' + migrate_name + '] Migrating Up...'); + + return knex.schema.table('proxy_host', function (proxy_host) { + proxy_host.integer('enable_proxy_protocol').notNull().unsigned().defaultTo(0); + proxy_host.string('load_balancer_ip').notNull().defaultTo(''); + }) + .then(() => { + logger.info('[' + migrate_name + '] proxy_host Table altered'); + }); + +}; + +/** + * Undo Migrate + * + * @param {Object} knex + * @param {Promise} Promise + * @returns {Promise} + */ +exports.down = function (knex/*, Promise*/) { + return knex.schema.table('proxy_host', function (proxy_host) { + proxy_host.dropColumn('enable_proxy_protocol'); + proxy_host.dropColumn('load_balancer_ip'); + }) + .then(function () { + logger.info('[' + migrate_name + '] proxy_host Table altered'); + }); +}; \ No newline at end of file diff --git a/backend/migrations/20240310100432_proxy_protocol_streams.js b/backend/migrations/20240310100432_proxy_protocol_streams.js new file mode 100644 index 000000000..a2a9a51fd --- /dev/null +++ b/backend/migrations/20240310100432_proxy_protocol_streams.js @@ -0,0 +1,41 @@ +const migrate_name = 'proxy_protocol_streams'; +const logger = require('../logger').migrate; + +/** + * Migrate + * + * @see http://knexjs.org/#Schema + * + * @param {Object} knex + * @param {Promise} Promise + * @returns {Promise} + */ +exports.up = function (knex/*, Promise*/) { + logger.info('[' + migrate_name + '] Migrating Up...'); + + return knex.schema.table('stream', function (stream) { + stream.integer('enable_proxy_protocol').notNull().unsigned().defaultTo(0); + stream.string('load_balancer_ip').notNull().defaultTo(''); + }) + .then(() => { + logger.info('[' + migrate_name + '] stream Table altered'); + }); + +}; + +/** + * Undo Migrate + * + * @param {Object} knex + * @param {Promise} Promise + * @returns {Promise} + */ +exports.down = function (knex/*, Promise*/) { + return knex.schema.table('stream', function (stream) { + stream.dropColumn('enable_proxy_protocol'); + stream.dropColumn('load_balancer_ip'); + }) + .then(function () { + logger.info('[' + migrate_name + '] stream Table altered'); + }); +}; \ No newline at end of file diff --git a/backend/templates/_listen.conf b/backend/templates/_listen.conf index ad1c96ba0..6a4bd92a9 100644 --- a/backend/templates/_listen.conf +++ b/backend/templates/_listen.conf @@ -1,15 +1,24 @@ - listen 80; -{% if ipv6 -%} - listen [::]:80; +{% if enable_proxy_protocol == 1 or enable_proxy_protocol == true -%} +{% assign port_number_http = "88" -%} +{% assign port_number_https = "444" -%} +{% assign listen_extra_args = "proxy_protocol" -%} {% else -%} - #listen [::]:80; -{% endif %} +{% assign port_number_http = "80" -%} +{% assign port_number_https = "443" -%} +{% assign listen_extra_args = "" -%} +{% endif -%} + + listen {{ port_number_http }} {{ listen_extra_args }}; +{% if ipv6 -%} + listen [::]:{{ port_number_http }} {{ listen_extra_args }}; +{% endif -%} + {% if certificate -%} - listen 443 ssl{% if http2_support == 1 or http2_support == true %} http2{% endif %}; + {% capture listen_extra_args_https %}ssl{% if http2_support %} http2{% endif %} {{ listen_extra_args }}{% endcapture -%} + listen {{ port_number_https }} {{ listen_extra_args_https }}; {% if ipv6 -%} - listen [::]:443 ssl{% if http2_support == 1 or http2_support == true %} http2{% endif %}; -{% else -%} - #listen [::]:443; -{% endif %} -{% endif %} + listen [::]:{{ port_number_https }} {{ listen_extra_args_https }}; +{% endif -%} +{% endif -%} + server_name {{ domain_names | join: " " }}; diff --git a/backend/templates/_proxy_protocol.conf b/backend/templates/_proxy_protocol.conf new file mode 100644 index 000000000..cba0424fb --- /dev/null +++ b/backend/templates/_proxy_protocol.conf @@ -0,0 +1,6 @@ +{% if enable_proxy_protocol == 1 or enable_proxy_protocol == true %} +{% if load_balancer_ip != '' %} + set_real_ip_from {{ load_balancer_ip }}; + real_ip_header proxy_protocol; +{% endif %} +{% endif %} diff --git a/backend/templates/proxy_host.conf b/backend/templates/proxy_host.conf index d23ca46fa..e753b6dde 100644 --- a/backend/templates/proxy_host.conf +++ b/backend/templates/proxy_host.conf @@ -15,6 +15,7 @@ server { {% include "_exploits.conf" %} {% include "_hsts.conf" %} {% include "_forced_ssl.conf" %} +{% include "_proxy_protocol.conf" %} {% if allow_websocket_upgrade == 1 or allow_websocket_upgrade == true %} proxy_set_header Upgrade $http_upgrade; diff --git a/backend/templates/stream.conf b/backend/templates/stream.conf index 76159a646..2694e6f5a 100644 --- a/backend/templates/stream.conf +++ b/backend/templates/stream.conf @@ -1,31 +1,38 @@ # ------------------------------------------------------------ # {{ incoming_port }} TCP: {{ tcp_forwarding }} UDP: {{ udp_forwarding }} # ------------------------------------------------------------ +{% if enable_proxy_protocol == 1 or enable_proxy_protocol == true -%} +{% capture listen_extra_args %}proxy_protocol{% endcapture -%} +{% endif -%} {% if enabled %} {% if tcp_forwarding == 1 or tcp_forwarding == true -%} server { - listen {{ incoming_port }}; + listen {{ incoming_port }} {{ listen_extra_args }}; {% if ipv6 -%} - listen [::]:{{ incoming_port }}; + listen [::]:{{ incoming_port }} {{ listen_extra_args }}; {% else -%} - #listen [::]:{{ incoming_port }}; + #listen [::]:{{ incoming_port }} {{ listen_extra_args }}; {% endif %} proxy_pass {{ forwarding_host }}:{{ forwarding_port }}; +{% include '_proxy_protocol.conf' %} + # Custom include /data/nginx/custom/server_stream[.]conf; include /data/nginx/custom/server_stream_tcp[.]conf; } {% endif %} {% if udp_forwarding == 1 or udp_forwarding == true %} +{% # Proxy Protocol is not supported for UDP %} +{% assign listen_extra_args = "" %} server { - listen {{ incoming_port }} udp; + listen {{ incoming_port }} udp {{ listen_extra_args }}; {% if ipv6 -%} - listen [::]:{{ incoming_port }} udp; + listen [::]:{{ incoming_port }} udp {{ listen_extra_args }}; {% else -%} - #listen [::]:{{ incoming_port }} udp; + #listen [::]:{{ incoming_port }} udp {{ listen_extra_args }}; {% endif %} proxy_pass {{ forwarding_host }}:{{ forwarding_port }}; diff --git a/docker/Dockerfile b/docker/Dockerfile index 0603e2ded..0f582c37e 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -35,7 +35,7 @@ RUN echo "fs.file-max = 65535" > /etc/sysctl.conf \ COPY docker/scripts/install-s6 /tmp/install-s6 RUN /tmp/install-s6 "${TARGETPLATFORM}" && rm -f /tmp/install-s6 -EXPOSE 80 81 443 +EXPOSE 80 81 88 443 444 COPY backend /app COPY frontend/dist /app/frontend diff --git a/docker/dev/Dockerfile b/docker/dev/Dockerfile index bb4ac6d44..c2ca82e47 100644 --- a/docker/dev/Dockerfile +++ b/docker/dev/Dockerfile @@ -35,5 +35,5 @@ RUN rm -f /etc/nginx/conf.d/production.conf \ COPY --from=pebbleca /test/certs/pebble.minica.pem /etc/ssl/certs/pebble.minica.pem COPY --from=testca /home/step/certs/root_ca.crt /etc/ssl/certs/NginxProxyManager.crt -EXPOSE 80 81 443 +EXPOSE 80 81 88 443 444 ENTRYPOINT [ "/init" ] diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml index 2bfa2b798..d7a8b5638 100644 --- a/docker/docker-compose.dev.yml +++ b/docker/docker-compose.dev.yml @@ -10,7 +10,9 @@ services: ports: - 3080:80 - 3081:81 + - 3088:88 - 3443:443 + - 3444:444 networks: nginx_proxy_manager: aliases: diff --git a/docs/src/advanced-config/index.md b/docs/src/advanced-config/index.md index efeaefec3..26c34493f 100644 --- a/docs/src/advanced-config/index.md +++ b/docs/src/advanced-config/index.md @@ -214,6 +214,29 @@ You can customise the logrotate configuration through a mount (if your custom co For reference, the default configuration can be found [here](https://github.com/NginxProxyManager/nginx-proxy-manager/blob/develop/docker/rootfs/etc/logrotate.d/nginx-proxy-manager). +## Enabling PROXY protocol for Proxy Hosts + +When running NPM behind a load balancer, you might want to use the [PROXY procotol](https://www.haproxy.org/download/1.8/doc/proxy-protocol.txt) to receive client information such as the source IP address (useful for banning IPs). + +When configuring the PROXY protocol for proxy hosts, NPM uses the ports 88 for http and 444 for https traffic to allow you to decide on a per host basis whether to use the PROSY protocol. + +To enable the PROXY protocol for your hosts you need to perform the following steps: + +1. Expose the ports `88` (and `444` is applicable) by adjusting your `docker-compose.yml` +2. Edit your proxy hosts to enable the PROXY protocol +3. Edit your upstream load balancer to redirect traffic to the port `88`/`444` and enable the PROXY protocol + +## Enabling PROXY protocol for Streams + +When running NPM behind a load balancer, you might want to use the [PROXY procotol](https://www.haproxy.org/download/1.8/doc/proxy-protocol.txt) to receive client information such as the source IP address (useful for banning IPs). + +Keep in mind that the PROXY procotol cannot be enabled for udp endpoints. + +To enable the PROXY protocol for streams: + +1. Expose the desired port by adjusting you `docker-compose.yml` +2. Edit the Stream to enable the PROXY protocol +3. Edit your upstream load balancer to enable the PROXY protocol ## Enabling the geoip2 module To enable the geoip2 module, you can create the custom configuration file `/data/nginx/custom/root_top.conf` and include the following snippet: diff --git a/docs/src/setup/index.md b/docs/src/setup/index.md index 9b1505bed..6ab2ebffc 100644 --- a/docs/src/setup/index.md +++ b/docs/src/setup/index.md @@ -65,6 +65,8 @@ services: - '80:80' # Public HTTP Port - '443:443' # Public HTTPS Port - '81:81' # Admin Web Port + # - '88:88' # Public HTTP Port with proxy_protocol enabled + # - '444:444' # Public HTTPS Port with proxy_protocol enabled # Add any other Stream port you want to expose # - '21:21' # FTP environment: diff --git a/frontend/js/app/nginx/proxy/form.ejs b/frontend/js/app/nginx/proxy/form.ejs index 8e7a2a2df..b45ef7493 100644 --- a/frontend/js/app/nginx/proxy/form.ejs +++ b/frontend/js/app/nginx/proxy/form.ejs @@ -72,7 +72,7 @@ -
+
+
+
+ +
+
+ +
+
+ + > +
+
diff --git a/frontend/js/app/nginx/proxy/form.js b/frontend/js/app/nginx/proxy/form.js index 4437a6ddd..d5bdca79e 100644 --- a/frontend/js/app/nginx/proxy/form.js +++ b/frontend/js/app/nginx/proxy/form.js @@ -43,6 +43,8 @@ module.exports = Mn.View.extend({ dns_provider_credentials: 'textarea[name="meta[dns_provider_credentials]"]', propagation_seconds: 'input[name="meta[propagation_seconds]"]', forward_scheme: 'select[name="forward_scheme"]', + enable_proxy_protocol: 'input[name="enable_proxy_protocol"]', + load_balancer_ip: 'input[name="load_balancer_ip"]', letsencrypt: '.letsencrypt' }, @@ -51,6 +53,13 @@ module.exports = Mn.View.extend({ }, events: { + 'change @ui.enable_proxy_protocol': function () { + let checked = this.ui.enable_proxy_protocol.prop('checked'); + this.ui.load_balancer_ip + .prop('disabled', !checked) + .parents('.form-group') + .css('opacity', checked ? 1 : 0.5); + }, 'change @ui.certificate_select': function () { let id = this.ui.certificate_select.val(); if (id === 'new') { @@ -163,6 +172,7 @@ module.exports = Mn.View.extend({ data.block_exploits = !!data.block_exploits; data.caching_enabled = !!data.caching_enabled; data.allow_websocket_upgrade = !!data.allow_websocket_upgrade; + data.enable_proxy_protocol = !!data.enable_proxy_protocol; data.http2_support = !!data.http2_support; data.hsts_enabled = !!data.hsts_enabled; data.hsts_subdomains = !!data.hsts_subdomains; @@ -264,6 +274,7 @@ module.exports = Mn.View.extend({ onRender: function () { let view = this; + this.ui.enable_proxy_protocol.trigger('change'); this.ui.ssl_forced.trigger('change'); this.ui.hsts_enabled.trigger('change'); diff --git a/frontend/js/app/nginx/stream/form.ejs b/frontend/js/app/nginx/stream/form.ejs index 1fc4f1342..e4e5d0c35 100644 --- a/frontend/js/app/nginx/stream/form.ejs +++ b/frontend/js/app/nginx/stream/form.ejs @@ -42,6 +42,22 @@
+
+
+ +
+
+ +
+
+ + > +
+
<%- i18n('streams', 'forward-type-error') %>
diff --git a/frontend/js/app/nginx/stream/form.js b/frontend/js/app/nginx/stream/form.js index be8fc8bc2..a645a1bf3 100644 --- a/frontend/js/app/nginx/stream/form.js +++ b/frontend/js/app/nginx/stream/form.js @@ -14,6 +14,8 @@ module.exports = Mn.View.extend({ ui: { form: 'form', forwarding_host: 'input[name="forwarding_host"]', + enable_proxy_protocol: 'input[name="enable_proxy_protocol"]', + load_balancer_ip: 'input[name="load_balancer_ip"]', type_error: '.forward-type-error', buttons: '.modal-footer button', switches: '.custom-switch-input', @@ -22,6 +24,13 @@ module.exports = Mn.View.extend({ }, events: { + 'change @ui.enable_proxy_protocol': function () { + let checked = this.ui.enable_proxy_protocol.prop('checked'); + this.ui.load_balancer_ip + .prop('disabled', !checked) + .parents('.form-group') + .css('opacity', checked ? 1 : 0.5); + }, 'change @ui.switches': function () { this.ui.type_error.hide(); }, @@ -47,6 +56,7 @@ module.exports = Mn.View.extend({ data.forwarding_port = parseInt(data.forwarding_port, 10); data.tcp_forwarding = !!data.tcp_forwarding; data.udp_forwarding = !!data.udp_forwarding; + data.enable_proxy_protocol = !!data.enable_proxy_protocol; let method = App.Api.Nginx.Streams.create; let is_new = true; @@ -76,6 +86,10 @@ module.exports = Mn.View.extend({ } }, + onRender: function () { + this.ui.enable_proxy_protocol.trigger('change'); + }, + initialize: function (options) { if (typeof options.model === 'undefined' || !options.model) { this.model = new StreamModel.Model(); diff --git a/frontend/js/i18n/messages.json b/frontend/js/i18n/messages.json index 0bbde4541..05fe88a69 100644 --- a/frontend/js/i18n/messages.json +++ b/frontend/js/i18n/messages.json @@ -131,6 +131,8 @@ "help-content": "A Proxy Host is the incoming endpoint for a web service that you want to forward.\nIt provides optional SSL termination for your service that might not have SSL support built in.\nProxy Hosts are the most common use for the Nginx Proxy Manager.", "access-list": "Access List", "allow-websocket-upgrade": "Websockets Support", + "enable-proxy-protocol": "Enable Proxy Protocol", + "load-balancer-ip": "Load balancer or TCP proxy IP / CIDR range", "ignore-invalid-upstream-ssl": "Ignore Invalid SSL", "custom-forward-host-help": "Add a path for sub-folder forwarding.\nExample: 203.0.113.25/path/", "search": "Search Host…" @@ -175,6 +177,8 @@ "protocol": "Protocol", "tcp": "TCP", "udp": "UDP", + "enable-proxy-protocol": "Enable Proxy Protocol", + "load-balancer-ip": "Load balancer or TCP proxy IP / CIDR range", "delete": "Delete Stream", "delete-confirm": "Are you sure you want to delete this Stream?", "help-title": "What is a Stream?", diff --git a/frontend/js/models/proxy-host.js b/frontend/js/models/proxy-host.js index b82d09fef..b1a80f541 100644 --- a/frontend/js/models/proxy-host.js +++ b/frontend/js/models/proxy-host.js @@ -19,6 +19,8 @@ const model = Backbone.Model.extend({ hsts_subdomains: false, caching_enabled: false, allow_websocket_upgrade: false, + enable_proxy_protocol: false, + load_balancer_ip: '', block_exploits: false, http2_support: false, advanced_config: '', diff --git a/frontend/js/models/stream.js b/frontend/js/models/stream.js index ba035429a..b4ee8f976 100644 --- a/frontend/js/models/stream.js +++ b/frontend/js/models/stream.js @@ -5,18 +5,20 @@ const model = Backbone.Model.extend({ defaults: function () { return { - id: undefined, - created_on: null, - modified_on: null, - incoming_port: null, - forwarding_host: null, - forwarding_port: null, - tcp_forwarding: true, - udp_forwarding: false, - enabled: true, - meta: {}, + id: undefined, + created_on: null, + modified_on: null, + incoming_port: null, + forwarding_host: null, + forwarding_port: null, + tcp_forwarding: true, + udp_forwarding: false, + enable_proxy_protocol: false, + load_balancer_ip: "", + enabled: true, + meta: {}, // The following are expansions: - owner: null + owner: null }; } }); diff --git a/test/cypress/e2e/api/ProxyHosts.cy.js b/test/cypress/e2e/api/ProxyHosts.cy.js index 5bc645800..6f8dafaf2 100644 --- a/test/cypress/e2e/api/ProxyHosts.cy.js +++ b/test/cypress/e2e/api/ProxyHosts.cy.js @@ -29,6 +29,8 @@ describe('Proxy Hosts endpoints', () => { block_exploits: false, caching_enabled: false, allow_websocket_upgrade: false, + enable_proxy_protocol: false, + load_balancer_ip: '', http2_support: false, hsts_enabled: false, hsts_subdomains: false,