This commit is contained in:
Austin Drummond 2026-10-17 20:01:55 -07:00 • committed by GitHub
commit b0b4f6fdf4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 215 additions and 5 deletions

View file

@ -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 },

View file

@ -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 };

View file

@ -22,6 +22,7 @@ const boolFields = [
"hsts_enabled",
"hsts_subdomains",
"trust_forwarded_proto",
"enable_proxy_protocol",
];
class ProxyHost extends Model {

View file

@ -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"
},

View file

@ -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,

View file

@ -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,

View file

@ -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,

View file

@ -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,

View file

@ -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 %}

View file

@ -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 %}

View file

@ -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;

View file

@ -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:

View file

@ -154,6 +154,8 @@ export interface ProxyHost {
hstsEnabled: boolean;
hstsSubdomains: boolean;
trustForwardedProto: boolean;
enableProxyProtocol: boolean;
loadBalancerIp: string;
// Expansions:
owner?: User;
accessList?: AccessList;

View file

@ -25,6 +25,8 @@ const fetchProxyHost = (id: number | "new") => {
hstsEnabled: false,
hstsSubdomains: false,
trustForwardedProto: false,
enableProxyProtocol: false,
loadBalancerIp: "",
} as ProxyHost);
}
return getProxyHost(id, ["owner"]);

View file

@ -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"
},

View file

@ -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) => {
</span>
</label>
</div>
<div>
<label className="row" htmlFor="enableProxyProtocol">
<span className="col">
<T id="host.flags.proxy-protocol" />
</span>
<span className="col-auto">
<Field name="enableProxyProtocol" type="checkbox">
{({ field }: any) => (
<label className="form-check form-check-single form-switch">
<input
{...field}
id="enableProxyProtocol"
className={cn("form-check-input", {
"bg-lime": field.checked,
})}
type="checkbox"
/>
</label>
)}
</Field>
</span>
</label>
</div>
</div>
<Field name="enableProxyProtocol">
{({ field: enableField }: any) =>
enableField.value ? (
<Field name="loadBalancerIp" validate={validateLoadBalancerIp}>
{({ field, form }: any) => (
<div className="mb-3">
<label className="form-label" htmlFor="loadBalancerIp">
<T id="host.load-balancer-ip" />
</label>
<input
id="loadBalancerIp"
type="text"
className={`form-control text-monospace ${form.errors.loadBalancerIp && form.touched.loadBalancerIp ? "is-invalid" : ""}`}
placeholder=""
maxLength={255}
autoComplete="off"
required
{...field}
/>
{form.errors.loadBalancerIp && form.touched.loadBalancerIp ? (
<div className="invalid-feedback">
{form.errors.loadBalancerIp}
</div>
) : null}
</div>
)}
</Field>
) : null
}
</Field>
</div>
</div>
<div className="tab-pane" id="tab-locations" role="tabpanel">