diff --git a/backend/internal/nginx.js b/backend/internal/nginx.js index 378b5359..5c5f0b97 100644 --- a/backend/internal/nginx.js +++ b/backend/internal/nginx.js @@ -162,6 +162,8 @@ const internalNginx = { { caching_enabled: host.caching_enabled }, { block_exploits: host.block_exploits }, { allow_websocket_upgrade: host.allow_websocket_upgrade }, + { enable_proxy_protocol: host.enable_proxy_protocol }, + { load_balancer_ip: host.load_balancer_ip }, { http2_support: host.http2_support }, { hsts_enabled: host.hsts_enabled }, { hsts_subdomains: host.hsts_subdomains }, diff --git a/backend/migrations/20260425014441_proxy_protocol.js b/backend/migrations/20260425014441_proxy_protocol.js new file mode 100644 index 00000000..e6d7e466 --- /dev/null +++ b/backend/migrations/20260425014441_proxy_protocol.js @@ -0,0 +1,41 @@ +const migrate_name = "proxy_protocol"; +import { migrate as logger } from "../logger.js"; + +/** + * Migrate + * + * @see http://knexjs.org/#Schema + * + * @param {Object} knex + * @returns {Promise} + */ +const up = (knex) => { + logger.info(`[${migrate_name}] Migrating Up...`); + + return knex.schema.table("proxy_host", (table) => { + table.integer("enable_proxy_protocol").notNull().unsigned().defaultTo(0); + table.string("load_balancer_ip").notNull().defaultTo(""); + }).then(() => { + logger.info(`[${migrate_name}] proxy_host Table altered`); + }); +}; + +/** + * Undo Migrate + * + * @param {Object} knex + * @returns {Promise} + */ +const down = (knex) => { + logger.info(`[${migrate_name}] Migrating Down...`); + + return knex.schema.table("proxy_host", (table) => { + table.dropColumn("enable_proxy_protocol"); + table.dropColumn("load_balancer_ip"); + }) + .then(() => { + logger.info(`[${migrate_name}] proxy_host Table altered`); + }); +}; + +export { up, down }; diff --git a/backend/models/proxy_host.js b/backend/models/proxy_host.js index acb8da93..575342d3 100644 --- a/backend/models/proxy_host.js +++ b/backend/models/proxy_host.js @@ -22,6 +22,7 @@ const boolFields = [ "hsts_enabled", "hsts_subdomains", "trust_forwarded_proto", + "enable_proxy_protocol", ]; class ProxyHost extends Model { diff --git a/backend/schema/components/proxy-host-object.json b/backend/schema/components/proxy-host-object.json index 90e6e23b..c3986880 100644 --- a/backend/schema/components/proxy-host-object.json +++ b/backend/schema/components/proxy-host-object.json @@ -17,6 +17,8 @@ "advanced_config", "meta", "allow_websocket_upgrade", + "enable_proxy_protocol", + "load_balancer_ip", "http2_support", "forward_scheme", "enabled", @@ -84,6 +86,16 @@ "type": "boolean", "example": true }, + "enable_proxy_protocol": { + "description": "Enable PROXY Protocol support", + "example": true, + "type": "boolean" + }, + "load_balancer_ip": { + "type": "string", + "minLength": 0, + "maxLength": 255 + }, "http2_support": { "$ref": "../common.json#/properties/http2_support" }, diff --git a/backend/schema/paths/nginx/proxy-hosts/get.json b/backend/schema/paths/nginx/proxy-hosts/get.json index 301e28bf..66ec45cf 100644 --- a/backend/schema/paths/nginx/proxy-hosts/get.json +++ b/backend/schema/paths/nginx/proxy-hosts/get.json @@ -53,6 +53,8 @@ "nginx_err": null }, "allow_websocket_upgrade": false, + "enable_proxy_protocol": false, + "load_balancer_ip": "", "http2_support": false, "forward_scheme": "http", "enabled": true, diff --git a/backend/schema/paths/nginx/proxy-hosts/hostID/get.json b/backend/schema/paths/nginx/proxy-hosts/hostID/get.json index 2e677fed..bb1d04a9 100644 --- a/backend/schema/paths/nginx/proxy-hosts/hostID/get.json +++ b/backend/schema/paths/nginx/proxy-hosts/hostID/get.json @@ -50,6 +50,8 @@ "nginx_err": null }, "allow_websocket_upgrade": false, + "enable_proxy_protocol": false, + "load_balancer_ip": "", "http2_support": false, "forward_scheme": "http", "enabled": true, diff --git a/backend/schema/paths/nginx/proxy-hosts/hostID/put.json b/backend/schema/paths/nginx/proxy-hosts/hostID/put.json index fc319845..2d051644 100644 --- a/backend/schema/paths/nginx/proxy-hosts/hostID/put.json +++ b/backend/schema/paths/nginx/proxy-hosts/hostID/put.json @@ -71,6 +71,12 @@ "allow_websocket_upgrade": { "$ref": "../../../../components/proxy-host-object.json#/properties/allow_websocket_upgrade" }, + "enable_proxy_protocol": { + "$ref": "../../../../components/proxy-host-object.json#/properties/enable_proxy_protocol" + }, + "load_balancer_ip": { + "$ref": "../../../../components/proxy-host-object.json#/properties/load_balancer_ip" + }, "access_list_id": { "$ref": "../../../../components/proxy-host-object.json#/properties/access_list_id" }, @@ -119,6 +125,8 @@ "nginx_err": null }, "allow_websocket_upgrade": false, + "enable_proxy_protocol": false, + "load_balancer_ip": "", "http2_support": false, "forward_scheme": "http", "enabled": true, diff --git a/backend/schema/paths/nginx/proxy-hosts/post.json b/backend/schema/paths/nginx/proxy-hosts/post.json index 28ddad8f..b9124c95 100644 --- a/backend/schema/paths/nginx/proxy-hosts/post.json +++ b/backend/schema/paths/nginx/proxy-hosts/post.json @@ -63,6 +63,12 @@ "allow_websocket_upgrade": { "$ref": "../../../components/proxy-host-object.json#/properties/allow_websocket_upgrade" }, + "enable_proxy_protocol": { + "$ref": "../../../components/proxy-host-object.json#/properties/enable_proxy_protocol" + }, + "load_balancer_ip": { + "$ref": "../../../components/proxy-host-object.json#/properties/load_balancer_ip" + }, "access_list_id": { "$ref": "../../../components/proxy-host-object.json#/properties/access_list_id" }, @@ -116,6 +122,8 @@ "advanced_config": "", "meta": {}, "allow_websocket_upgrade": false, + "enable_proxy_protocol": false, + "load_balancer_ip": "", "http2_support": false, "forward_scheme": "http", "enabled": true, diff --git a/backend/templates/_listen.conf b/backend/templates/_listen.conf index 34a808e6..d75bb34d 100644 --- a/backend/templates/_listen.conf +++ b/backend/templates/_listen.conf @@ -1,16 +1,32 @@ +{% if enable_proxy_protocol == 1 or enable_proxy_protocol == true%} + listen 88 proxy_protocol; + listen 80; +{% if ipv6 -%} + listen [::]:88 proxy_protocol; + listen [::]:80; +{% endif %} +{% else -%} listen 80; {% if ipv6 -%} listen [::]:80; -{% else -%} - #listen [::]:80; +{% endif %} {% endif %} {% if certificate -%} +{% if enable_proxy_protocol == 1 or enable_proxy_protocol == true%} + listen 444 ssl proxy_protocol; + listen 443 ssl; +{% if ipv6 -%} + listen [::]:444 ssl proxy_protocol; + listen [::]:443 ssl; +{% endif %} +{% else -%} listen 443 ssl; {% if ipv6 -%} listen [::]:443 ssl; -{% else -%} - #listen [::]:443; {% endif %} +{% endif %} +{% else %} + #listen [::]:443; {% endif %} server_name {{ domain_names | join: " " }}; {% if http2_support == 1 or http2_support == true %} diff --git a/backend/templates/_proxy_protocol.conf b/backend/templates/_proxy_protocol.conf new file mode 100644 index 00000000..cba0424f --- /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 d23ca46f..e753b6dd 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/docs/src/advanced-config/index.md b/docs/src/advanced-config/index.md index a93a1a4d..f490724c 100644 --- a/docs/src/advanced-config/index.md +++ b/docs/src/advanced-config/index.md @@ -237,6 +237,40 @@ Setting these environment variables will create the default user on startup, ski INITIAL_ADMIN_PASSWORD: mypassword1 ``` +## PROXY Protocol Support + +Proxy hosts can opt-in to the PROXY protocol so the client's real IP is preserved when +NPM sits behind an upstream load balancer (AWS NLB, HAProxy, another nginx, etc.) that +speaks PROXY protocol. See nginx's [`ngx_http_realip_module`](https://nginx.org/en/docs/http/ngx_http_realip_module.html) +for how `set_real_ip_from` and `real_ip_header proxy_protocol` interact. + +When enabled on a proxy host, NPM listens for PROXY protocol traffic on additional ports: + +| Port | Purpose | +|------|-------------------------------| +| 88 | HTTP with PROXY protocol | +| 444 | HTTPS (TLS) with PROXY protocol | + +These ports are **not** exposed by default. Publish them in your `docker-compose.yml` +so your upstream load balancer can reach them: + +```yml +services: + app: + image: 'jc21/nginx-proxy-manager:{{VERSION}}' + ports: + - '80:80' + - '443:443' + - '81:81' + - '88:88' # PROXY protocol (HTTP) + - '444:444' # PROXY protocol (HTTPS) + # ... +``` + +When editing a proxy host, enable **PROXY Protocol** and supply the **Load Balancer IP / CIDR** +of the upstream that will be sending PROXY protocol headers. This value becomes the +`set_real_ip_from` directive — without it, nginx will not trust the forwarded client IP. + ## Disable Nginx Resolver On startup, we generate a resolvers directive for Nginx unless this is defined: diff --git a/frontend/src/api/backend/models.ts b/frontend/src/api/backend/models.ts index 96c1b8a4..2b5324a9 100644 --- a/frontend/src/api/backend/models.ts +++ b/frontend/src/api/backend/models.ts @@ -154,6 +154,8 @@ export interface ProxyHost { hstsEnabled: boolean; hstsSubdomains: boolean; trustForwardedProto: boolean; + enableProxyProtocol: boolean; + loadBalancerIp: string; // Expansions: owner?: User; accessList?: AccessList; diff --git a/frontend/src/hooks/useProxyHost.ts b/frontend/src/hooks/useProxyHost.ts index 24e7f4fa..226f17b4 100644 --- a/frontend/src/hooks/useProxyHost.ts +++ b/frontend/src/hooks/useProxyHost.ts @@ -25,6 +25,8 @@ const fetchProxyHost = (id: number | "new") => { hstsEnabled: false, hstsSubdomains: false, trustForwardedProto: false, + enableProxyProtocol: false, + loadBalancerIp: "", } as ProxyHost); } return getProxyHost(id, ["owner"]); diff --git a/frontend/src/locale/src/en.json b/frontend/src/locale/src/en.json index 11d09ea7..e35ed3c6 100644 --- a/frontend/src/locale/src/en.json +++ b/frontend/src/locale/src/en.json @@ -410,6 +410,9 @@ "error.invalid-email": { "defaultMessage": "Invalid email address" }, + "error.invalid-ip-or-cidr": { + "defaultMessage": "Must be a valid IPv4/IPv6 address or CIDR range" + }, "error.max-character-length": { "defaultMessage": "Maximum length is {max} character{max, plural, one {} other {s}}" }, @@ -449,9 +452,15 @@ "host.flags.protocols": { "defaultMessage": "Protocols" }, + "host.flags.proxy-protocol": { + "defaultMessage": "Enable PROXY Protocol" + }, "host.flags.websockets-upgrade": { "defaultMessage": "Websockets Support" }, + "host.load-balancer-ip": { + "defaultMessage": "Load Balancer IP / CIDR (set_real_ip_from)" + }, "host.forward-port": { "defaultMessage": "Forward Port" }, diff --git a/frontend/src/modals/ProxyHostModal.tsx b/frontend/src/modals/ProxyHostModal.tsx index 3227be51..3d16604e 100644 --- a/frontend/src/modals/ProxyHostModal.tsx +++ b/frontend/src/modals/ProxyHostModal.tsx @@ -17,11 +17,20 @@ import { SSLOptionsFields, } from "src/components"; import { useProxyHost, useSetProxyHost, useUser } from "src/hooks"; -import { T } from "src/locale"; +import { intl, T } from "src/locale"; import { MANAGE, PROXY_HOSTS } from "src/modules/Permissions"; import { validateNumber, validateString } from "src/modules/Validations"; import { showObjectSuccess } from "src/notifications"; +const IP_OR_CIDR_RE = + /^(?:(?:25[0-5]|2[0-4]\d|[01]?\d?\d)\.){3}(?:25[0-5]|2[0-4]\d|[01]?\d?\d)(?:\/(?:3[0-2]|[12]?\d))?$|^[0-9a-fA-F:]+(?:\/(?:12[0-8]|1[01]\d|\d?\d))?$/; + +// Only invoked when enableProxyProtocol is true (Field is conditionally rendered). +const validateLoadBalancerIp = (value: string): string | undefined => { + if (!value || !value.length) return intl.formatMessage({ id: "error.required" }); + if (!IP_OR_CIDR_RE.test(value)) return intl.formatMessage({ id: "error.invalid-ip-or-cidr" }); +}; + const showProxyHostModal = (id: number | "new") => { EasyModal.show(ProxyHostModal, { id }); }; @@ -80,6 +89,8 @@ const ProxyHostModal = EasyModal.create(({ id, visible, remove }: Props) => { cachingEnabled: data?.cachingEnabled || false, blockExploits: data?.blockExploits || false, allowWebsocketUpgrade: data?.allowWebsocketUpgrade || false, + enableProxyProtocol: data?.enableProxyProtocol || false, + loadBalancerIp: data?.loadBalancerIp || "", // Locations tab locations: data?.locations || [], // SSL tab @@ -328,7 +339,60 @@ const ProxyHostModal = EasyModal.create(({ id, visible, remove }: Props) => { +
+ +
+ + {({ field: enableField }: any) => + enableField.value ? ( + + {({ field, form }: any) => ( +
+ + + {form.errors.loadBalancerIp && form.touched.loadBalancerIp ? ( +
+ {form.errors.loadBalancerIp} +
+ ) : null} +
+ )} +
+ ) : null + } +