Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions backend/lib/public-ports.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
function parsePort(env, name, fallback) {
const value = env[name];
if (value === undefined) return fallback;
const port = Number(value);
if (!/^\d+$/.test(value) || !Number.isInteger(port) || port < 1 || port > 65535) {
throw new Error(`${name} must be an integer between 1 and 65535`);
}
return port;
}

export function getPublicPorts(env = process.env) {
return {
http: parsePort(env, "PUBLIC_HTTP_PORT", 80),
https: parsePort(env, "PUBLIC_HTTPS_PORT", 443),
};
}

export function renderPublicPortsConfig(ports) {
const suffix = ports.https === 443 ? "" : `:${ports.https}`;
return [
"# Generated from PUBLIC_HTTP_PORT / PUBLIC_HTTPS_PORT at container startup.",
"map $host $npm_public_https_port_suffix {",
`\tdefault "${suffix}";`,
"}",
...(ports.https === 443 ? [] : ["error_page 497 =307 https://$host$npm_public_https_port_suffix$request_uri;"]),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 SSL listener redirect can disappear

If a host’s advanced configuration defines a server-level error_page, such as one for 404 responses, Nginx does not inherit this HTTP-level 497 handler. With a nonstandard public HTTPS port, plain HTTP sent to that host’s SSL listener returns the default error instead of the intended 307 redirect.

"",
].join("\n");
}
36 changes: 36 additions & 0 deletions backend/lib/public-ports.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { getPublicPorts, renderPublicPortsConfig } from "./public-ports.js";

test("uses standard ports when deployment variables are absent", () => {
assert.deepEqual(getPublicPorts({}), { http: 80, https: 443 });
assert.match(renderPublicPortsConfig(getPublicPorts({})), /default "";/);
assert.doesNotMatch(renderPublicPortsConfig(getPublicPorts({})), /error_page/);
});

test("shares custom ports with the API and produces the HTTPS redirect suffix", () => {
const ports = getPublicPorts({ PUBLIC_HTTP_PORT: "232", PUBLIC_HTTPS_PORT: "233" });
assert.deepEqual(ports, { http: 232, https: 233 });
assert.match(renderPublicPortsConfig(ports), /default ":233";/);
assert.match(
renderPublicPortsConfig(ports),
/error_page 497 =307 https:\/\/\$host\$npm_public_https_port_suffix\$request_uri;/,
);
});

test("accepts the full valid port range independently for each protocol", () => {
assert.deepEqual(getPublicPorts({ PUBLIC_HTTP_PORT: "1", PUBLIC_HTTPS_PORT: "65535" }), {
http: 1,
https: 65535,
});
});

test("rejects invalid input before emitting Nginx configuration", () => {
for (const key of ["PUBLIC_HTTP_PORT", "PUBLIC_HTTPS_PORT"]) {
for (const value of ["", "0", "65536", "-1", "233.5", "2e2", " 233", "233; return 200;"]) {
assert.throws(() => getPublicPorts({ [key]: value }), {
message: `${key} must be an integer between 1 and 65535`,
});
}
}
});
2 changes: 2 additions & 0 deletions backend/routes/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import express from "express";
import { isCI } from "../lib/config.js";
import errs from "../lib/error.js";
import logRequest from "../lib/express/log-request.js";
import { getPublicPorts } from "../lib/public-ports.js";
import pjson from "../package.json" with { type: "json" };
import { isSetup } from "../setup.js";
import auditLogRoutes from "./audit-log.js";
Expand Down Expand Up @@ -39,6 +40,7 @@ router.get("/", async (_, res /*, next*/) => {
res.status(200).send({
status: "OK",
setup,
public_ports: getPublicPorts(),
version: {
major: Number.parseInt(version.shift(), 10),
minor: Number.parseInt(version.shift(), 10),
Expand Down
5 changes: 5 additions & 0 deletions backend/scripts/configure-public-ports.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import fs from "node:fs";
import { getPublicPorts, renderPublicPortsConfig } from "../lib/public-ports.js";

const filename = process.argv[2] || "/etc/nginx/conf.d/public-ports.conf";
fs.writeFileSync(filename, renderPublicPortsConfig(getPublicPorts()), { mode: 0o644 });
2 changes: 1 addition & 1 deletion docker/rootfs/etc/nginx/conf.d/include/force-ssl.conf
Original file line number Diff line number Diff line change
Expand Up @@ -28,5 +28,5 @@ if ($test_ssl_handled = "TS") {
}

if ($test = H) {
return 301 https://$host$request_uri;
return 301 https://$host$npm_public_https_port_suffix$request_uri;
}
1 change: 1 addition & 0 deletions docker/rootfs/etc/s6-overlay/s6-rc.d/prepare/00-all.sh
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,5 @@ fi
. /etc/s6-overlay/s6-rc.d/prepare/45-admin-port.sh
. /etc/s6-overlay/s6-rc.d/prepare/50-ipv6.sh
. /etc/s6-overlay/s6-rc.d/prepare/60-secrets.sh
. /etc/s6-overlay/s6-rc.d/prepare/70-public-ports.sh
. /etc/s6-overlay/s6-rc.d/prepare/90-banner.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
#!/command/with-contenv bash
# shellcheck shell=bash

set -e

log_info "Configuring public ports ..."
/command/with-contenv node /app/scripts/configure-public-ports.mjs
40 changes: 40 additions & 0 deletions docs/src/advanced-config/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,46 @@ By default, NPM fetches IP ranges from CloudFront and Cloudflare during applicat
IP_RANGES_FETCH_ENABLED: 'false'
```

## Public HTTP and HTTPS ports

Some networks block incoming connections to ports 80 and 443. NPM can be published
on other ports using Docker port mappings or router forwarding, while its internal
listeners remain on 80 and 443. Configure the public ports so Force SSL redirects
and host links in the manager use the addresses clients can actually reach:

```yml
services:
app:
image: 'jc21/nginx-proxy-manager:{{VERSION}}'
ports:
- '232:80'
- '233:443'
- '81:81'
environment:
PUBLIC_HTTP_PORT: '232'
PUBLIC_HTTPS_PORT: '233'
# ...
```

`PUBLIC_HTTP_PORT` defaults to 80 and `PUBLIC_HTTPS_PORT` defaults to 443.
Both must be decimal integers between 1 and 65535. They describe the client-facing
ports; configure Docker or router mappings to match and recreate the container
after changing them. These settings do not change internal listeners or ACME
validation requirements.

Force SSL redirects preserve their 301 status, path, and query, including the
nonstandard HTTPS port. HTTP sent to an SSL listener redirects with 307 when the
public HTTPS port is nonstandard; default 443 behavior is unchanged.

Proxy, Redirection, and 404 Host links use HTTPS when a certificate is configured,
and HTTP otherwise. Both the visible domain and link include nonstandard ports;
standard 80/443 are omitted. Certificate-list links retain their existing behavior.
The existing health API exposes the configured ports as `public_ports`.

Explicit redirection destinations, upstream ports, and application-generated URLs
remain separately configured. Remove any custom `error_page` rule that previously
hardcoded a public port if it would override the new redirects.

## Custom Nginx Configurations

If you are a more advanced user, you might be itching for extra Nginx customizability.
Expand Down
1 change: 1 addition & 0 deletions frontend/src/api/backend/responseTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export interface HealthResponse {
status: string;
version: AppVersion;
setup: boolean;
publicPorts?: { http: number; https: number };
}

export interface TokenResponse {
Expand Down
36 changes: 36 additions & 0 deletions frontend/src/components/Table/Formatter/DomainsFormatter.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { DomainsFormatter } from "./DomainsFormatter";

const { health } = vi.hoisted(() => ({ health: { publicPorts: { http: 232, https: 233 } } }));
vi.mock("src/context", () => ({ useLocaleState: () => ({ locale: "en" }) }));
vi.mock("src/hooks/useHealth", () => ({ useHealth: () => ({ data: health }) }));
vi.mock("src/locale", () => ({ formatDateTime: () => "", T: () => null }));

afterEach(cleanup);

describe("public host links", () => {
it.each([
["https", 232, 233, "example.com:233", "https://example.com:233"],
["http", 232, 233, "example.com:232", "http://example.com:232"],
["https", 80, 443, "example.com", "https://example.com"],
["http", 80, 443, "example.com", "http://example.com"],
] as const)("formats %s with HTTP %s / HTTPS %s", (scheme, http, https, text, href) => {
health.publicPorts = { http, https };
render(<DomainsFormatter domains={["example.com"]} linkScheme={scheme} />);
const link = screen.getByRole("link", { name: text });
expect(link.getAttribute("href")).toBe(href);
});

it("preserves certificate-list links that have no host scheme", () => {
health.publicPorts = { http: 232, https: 233 };
render(<DomainsFormatter domains={["example.com"]} />);
expect(screen.getByRole("link", { name: "example.com" }).getAttribute("href")).toBe("http://example.com");
});

it("keeps wildcard links non-navigable", () => {
health.publicPorts = { http: 232, https: 233 };
render(<DomainsFormatter domains={["*.example.com"]} linkScheme="https" />);
expect(fireEvent.click(screen.getByRole("link", { name: "*.example.com:233" }))).toBe(false);
});
});
28 changes: 24 additions & 4 deletions frontend/src/components/Table/Formatter/DomainsFormatter.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import cn from "classnames";
import type { ReactNode } from "react";
import { useLocaleState } from "src/context";
import { useHealth } from "src/hooks/useHealth";
import { formatDateTime, T } from "src/locale";

interface Props {
Expand All @@ -9,9 +10,20 @@ interface Props {
niceName?: string;
provider?: string;
color?: string;
linkScheme?: "http" | "https";
}

const DomainLink = ({ domain, color }: { domain?: string; color?: string }) => {
const DomainLink = ({
domain,
color,
scheme,
suffix,
}: {
domain?: string;
color?: string;
scheme: "http" | "https";
suffix: string;
}) => {
// when domain contains a wildcard, make the link go nowhere.
// Apparently the domain can be null or undefined sometimes.
// This try is just a safeguard to prevent the whole formatter from breaking.
Expand All @@ -24,22 +36,28 @@ const DomainLink = ({ domain, color }: { domain?: string; color?: string }) => {
return (
<a
key={domain}
href={`http://${domain}`}
href={`${scheme}://${domain}${suffix}`}
target="_blank"
rel="noopener"
onClick={onClick}
className={cn("badge", color ? `bg-${color}-lt` : null, "domain-name", "me-2")}
>
{domain}
{suffix}
</a>
);
} catch {
return null;
}
};

export function DomainsFormatter({ domains, createdOn, niceName, provider, color }: Props) {
export function DomainsFormatter({ domains, createdOn, niceName, provider, color, linkScheme }: Props) {
const { locale } = useLocaleState();
const health = useHealth();
const scheme = linkScheme ?? "http";
const defaultPort = scheme === "https" ? 443 : 80;
const port = linkScheme ? (health.data?.publicPorts?.[scheme] ?? defaultPort) : defaultPort;
const suffix = port === defaultPort ? "" : `:${port}`;
const elms: ReactNode[] = [];

if ((!domains || domains.length === 0) && !niceName) {
Expand All @@ -58,7 +76,9 @@ export function DomainsFormatter({ domains, createdOn, niceName, provider, color
}

if (domains) {
domains.map((domain: string) => elms.push(<DomainLink key={domain} domain={domain} color={color} />));
domains.map((domain: string) =>
elms.push(<DomainLink key={domain} domain={domain} color={color} scheme={scheme} suffix={suffix} />),
);
}

return (
Expand Down
8 changes: 7 additions & 1 deletion frontend/src/pages/Nginx/DeadHosts/Table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,13 @@ export default function Table({ data, isFetching, onEdit, onDelete, onDisableTog
},
cell: (info: any) => {
const value = info.getValue();
return <DomainsFormatter domains={value.domainNames} createdOn={value.createdOn} />;
return (
<DomainsFormatter
domains={value.domainNames}
createdOn={value.createdOn}
linkScheme={value.certificateId > 0 ? "https" : "http"}
/>
);
},
}),
columnHelper.accessor((row: any) => row.certificate, {
Expand Down
8 changes: 7 additions & 1 deletion frontend/src/pages/Nginx/ProxyHosts/Table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,13 @@ export default function Table({
},
cell: (info: any) => {
const value = info.getValue();
return <DomainsFormatter domains={value.domainNames} createdOn={value.createdOn} />;
return (
<DomainsFormatter
domains={value.domainNames}
createdOn={value.createdOn}
linkScheme={value.certificateId > 0 ? "https" : "http"}
/>
);
},
}),
columnHelper.accessor((row: any) => row, {
Expand Down
8 changes: 7 additions & 1 deletion frontend/src/pages/Nginx/RedirectionHosts/Table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,13 @@ export default function Table({ data, isFetching, onEdit, onDelete, onDisableTog
},
cell: (info: any) => {
const value = info.getValue();
return <DomainsFormatter domains={value.domainNames} createdOn={value.createdOn} />;
return (
<DomainsFormatter
domains={value.domainNames}
createdOn={value.createdOn}
linkScheme={value.certificateId > 0 ? "https" : "http"}
/>
);
},
}),
columnHelper.accessor((row: any) => row.forwardHttpCode, {
Expand Down
2 changes: 1 addition & 1 deletion scripts/ci/test-and-build
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ docker run --rm \
-v "$(pwd)/backend:/app" \
-w /app \
"${TESTING_IMAGE}" \
sh -c 'yarn install && yarn lint . && rm -rf node_modules'
sh -c 'yarn install && yarn lint . && node --test lib/public-ports.test.js && rm -rf node_modules'

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Built-image test is omitted

This CI command runs the new backend unit test, but it never runs the disposable-container smoke test against the image it builds. CI can therefore pass without checking that the image starts with the generated Nginx configuration or serves the new redirects. Adding the smoke test to this workflow would cover those integration failures.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

echo -e "${BLUE}❯ ${GREEN}Testing Complete${RESET}"

# Build
Expand Down
Loading