diff --git a/backend/lib/public-ports.js b/backend/lib/public-ports.js new file mode 100644 index 0000000000..347e3eb7d5 --- /dev/null +++ b/backend/lib/public-ports.js @@ -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;"]), + "", + ].join("\n"); +} diff --git a/backend/lib/public-ports.test.js b/backend/lib/public-ports.test.js new file mode 100644 index 0000000000..7fd5b65578 --- /dev/null +++ b/backend/lib/public-ports.test.js @@ -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`, + }); + } + } +}); diff --git a/backend/routes/main.js b/backend/routes/main.js index 2d719bb486..c3f8622c07 100644 --- a/backend/routes/main.js +++ b/backend/routes/main.js @@ -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"; @@ -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), diff --git a/backend/scripts/configure-public-ports.mjs b/backend/scripts/configure-public-ports.mjs new file mode 100644 index 0000000000..e9dbf51c41 --- /dev/null +++ b/backend/scripts/configure-public-ports.mjs @@ -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 }); diff --git a/docker/rootfs/etc/nginx/conf.d/include/force-ssl.conf b/docker/rootfs/etc/nginx/conf.d/include/force-ssl.conf index 8e58c64a38..ce6c147f4c 100644 --- a/docker/rootfs/etc/nginx/conf.d/include/force-ssl.conf +++ b/docker/rootfs/etc/nginx/conf.d/include/force-ssl.conf @@ -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; } diff --git a/docker/rootfs/etc/s6-overlay/s6-rc.d/prepare/00-all.sh b/docker/rootfs/etc/s6-overlay/s6-rc.d/prepare/00-all.sh index 172290eae6..2d17c08c97 100755 --- a/docker/rootfs/etc/s6-overlay/s6-rc.d/prepare/00-all.sh +++ b/docker/rootfs/etc/s6-overlay/s6-rc.d/prepare/00-all.sh @@ -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 diff --git a/docker/rootfs/etc/s6-overlay/s6-rc.d/prepare/70-public-ports.sh b/docker/rootfs/etc/s6-overlay/s6-rc.d/prepare/70-public-ports.sh new file mode 100755 index 0000000000..3c60d8018c --- /dev/null +++ b/docker/rootfs/etc/s6-overlay/s6-rc.d/prepare/70-public-ports.sh @@ -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 diff --git a/docs/src/advanced-config/index.md b/docs/src/advanced-config/index.md index a93a1a4d09..69d354f536 100644 --- a/docs/src/advanced-config/index.md +++ b/docs/src/advanced-config/index.md @@ -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. diff --git a/frontend/src/api/backend/responseTypes.ts b/frontend/src/api/backend/responseTypes.ts index 2f88ede547..9233d794ac 100644 --- a/frontend/src/api/backend/responseTypes.ts +++ b/frontend/src/api/backend/responseTypes.ts @@ -4,6 +4,7 @@ export interface HealthResponse { status: string; version: AppVersion; setup: boolean; + publicPorts?: { http: number; https: number }; } export interface TokenResponse { diff --git a/frontend/src/components/Table/Formatter/DomainsFormatter.test.tsx b/frontend/src/components/Table/Formatter/DomainsFormatter.test.tsx new file mode 100644 index 0000000000..6c267698cb --- /dev/null +++ b/frontend/src/components/Table/Formatter/DomainsFormatter.test.tsx @@ -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(); + 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(); + 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(); + expect(fireEvent.click(screen.getByRole("link", { name: "*.example.com:233" }))).toBe(false); + }); +}); diff --git a/frontend/src/components/Table/Formatter/DomainsFormatter.tsx b/frontend/src/components/Table/Formatter/DomainsFormatter.tsx index 3ccc4d804a..75f458ed0d 100644 --- a/frontend/src/components/Table/Formatter/DomainsFormatter.tsx +++ b/frontend/src/components/Table/Formatter/DomainsFormatter.tsx @@ -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 { @@ -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. @@ -24,13 +36,14 @@ const DomainLink = ({ domain, color }: { domain?: string; color?: string }) => { return ( {domain} + {suffix} ); } catch { @@ -38,8 +51,13 @@ const DomainLink = ({ domain, color }: { domain?: string; color?: string }) => { } }; -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) { @@ -58,7 +76,9 @@ export function DomainsFormatter({ domains, createdOn, niceName, provider, color } if (domains) { - domains.map((domain: string) => elms.push()); + domains.map((domain: string) => + elms.push(), + ); } return ( diff --git a/frontend/src/pages/Nginx/DeadHosts/Table.tsx b/frontend/src/pages/Nginx/DeadHosts/Table.tsx index e5c1b184f0..e9716e7ddb 100644 --- a/frontend/src/pages/Nginx/DeadHosts/Table.tsx +++ b/frontend/src/pages/Nginx/DeadHosts/Table.tsx @@ -49,7 +49,13 @@ export default function Table({ data, isFetching, onEdit, onDelete, onDisableTog }, cell: (info: any) => { const value = info.getValue(); - return ; + return ( + 0 ? "https" : "http"} + /> + ); }, }), columnHelper.accessor((row: any) => row.certificate, { diff --git a/frontend/src/pages/Nginx/ProxyHosts/Table.tsx b/frontend/src/pages/Nginx/ProxyHosts/Table.tsx index 7a9b4cd6b2..2e1666c0e0 100644 --- a/frontend/src/pages/Nginx/ProxyHosts/Table.tsx +++ b/frontend/src/pages/Nginx/ProxyHosts/Table.tsx @@ -60,7 +60,13 @@ export default function Table({ }, cell: (info: any) => { const value = info.getValue(); - return ; + return ( + 0 ? "https" : "http"} + /> + ); }, }), columnHelper.accessor((row: any) => row, { diff --git a/frontend/src/pages/Nginx/RedirectionHosts/Table.tsx b/frontend/src/pages/Nginx/RedirectionHosts/Table.tsx index 2457a47380..19367f36ad 100644 --- a/frontend/src/pages/Nginx/RedirectionHosts/Table.tsx +++ b/frontend/src/pages/Nginx/RedirectionHosts/Table.tsx @@ -49,7 +49,13 @@ export default function Table({ data, isFetching, onEdit, onDelete, onDisableTog }, cell: (info: any) => { const value = info.getValue(); - return ; + return ( + 0 ? "https" : "http"} + /> + ); }, }), columnHelper.accessor((row: any) => row.forwardHttpCode, { diff --git a/scripts/ci/test-and-build b/scripts/ci/test-and-build index a0de34140a..06bc90ee4f 100755 --- a/scripts/ci/test-and-build +++ b/scripts/ci/test-and-build @@ -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' echo -e "${BLUE}❯ ${GREEN}Testing Complete${RESET}" # Build diff --git a/test/public-ports-smoke.py b/test/public-ports-smoke.py new file mode 100644 index 0000000000..b7b282b097 --- /dev/null +++ b/test/public-ports-smoke.py @@ -0,0 +1,121 @@ +#!/usr/bin/env python3 +"""Verify the built image using disposable containers and synthetic hosts.""" + +import http.client +import json +import subprocess +import sys +import tempfile +import time +from pathlib import Path +import uuid + + +def docker(*args, **kwargs): + return subprocess.check_output(["docker", *args], text=True, **kwargs).strip() + + +def request(port, path, method="GET", headers=None): + connection = http.client.HTTPConnection("127.0.0.1", port, timeout=3) + try: + connection.request(method, path, headers=headers or {}) + response = connection.getresponse() + return response.status, dict(response.getheaders()), response.read() + finally: + connection.close() + + +def check_image(image, public_http=None, public_https=None, from_files=False): + name = f"npm-public-ports-smoke-{uuid.uuid4().hex[:10]}" + args = ["run", "-d", "--platform", "linux/amd64", "--name", name, + "-e", "IP_RANGES_FETCH_ENABLED=false", + "-p", "127.0.0.1::80", "-p", "127.0.0.1::443", "-p", "127.0.0.1::81", + "--mount", "type=volume,destination=/etc/letsencrypt"] + port_files = tempfile.TemporaryDirectory(prefix="npm-public-ports-") if from_files else None + if from_files: + for protocol, port in (("HTTP", public_http), ("HTTPS", public_https)): + Path(port_files.name, protocol).write_text(str(port)) + args.extend(["-e", f"PUBLIC_{protocol}_PORT__FILE=/run/public-ports/{protocol}"]) + args.extend(["--mount", f"type=bind,source={port_files.name},destination=/run/public-ports,readonly"]) + else: + if public_http is not None: + args.extend(["-e", f"PUBLIC_HTTP_PORT={public_http}"]) + if public_https is not None: + args.extend(["-e", f"PUBLIC_HTTPS_PORT={public_https}"]) + args.append(image) + expected_ports = {"http": public_http or 80, "https": public_https or 443} + try: + docker(*args) + ports = {port: int(docker("port", name, f"{port}/tcp").rsplit(":", 1)[1]) + for port in (80, 443, 81)} + deadline = time.monotonic() + 90 + while True: + try: + status, _, body = request(ports[81], "/api/") + health = json.loads(body) + if status == 200 and health.get("status") == "OK": + break + except (OSError, ValueError, http.client.HTTPException): + pass + if time.monotonic() >= deadline: + raise AssertionError("Manager health endpoint did not become ready") + time.sleep(2) + assert health["public_ports"] == expected_ports, health + docker("exec", name, "openssl", "req", "-x509", "-newkey", "rsa:2048", "-nodes", + "-keyout", "/tmp/public-ports.key", "-out", "/tmp/public-ports.crt", + "-days", "1", "-subj", "/CN=audit.example.test", stderr=subprocess.DEVNULL) + config = """server { + listen 80; + listen 443 ssl; + server_name audit.example.test; + ssl_certificate /tmp/public-ports.crt; + ssl_certificate_key /tmp/public-ports.key; + set $trust_forwarded_proto "F"; + include conf.d/include/force-ssl.conf; + location / { return 200 "public ports smoke"; } +} +server { + listen 80; + server_name trusted.example.test; + set $trust_forwarded_proto "T"; + include conf.d/include/force-ssl.conf; + location / { return 200 "trusted HTTPS"; } +} +""" + subprocess.run(["docker", "exec", "-i", name, "sh", "-c", + "cat > /data/nginx/proxy_host/999.conf"], input=config, text=True, check=True) + docker("exec", name, "nginx", "-t") + docker("exec", name, "nginx", "-s", "reload") + time.sleep(1) + suffix = "" if expected_ports["https"] == 443 else f':{expected_ports["https"]}' + path = "/nested/path?check=1&two=2" + location = f"https://audit.example.test{suffix}{path}" + for method in ("GET", "POST"): + status, headers, _ = request(ports[80], path, method, + {"Host": f'audit.example.test:{expected_ports["http"]}'}) + assert status == 301, (method, status, headers) + assert headers.get("Location") == location, headers + status, _, _ = request(ports[80], "/.well-known/acme-challenge/test-challenge", + headers={"Host": "audit.example.test"}) + assert status == 200, ("ACME exception", status) + status, _, _ = request(ports[80], "/", headers={"Host": "trusted.example.test", + "X-Forwarded-Proto": "https"}) + assert status == 200, ("trusted forwarded HTTPS", status) + if expected_ports["https"] != 443: + status, headers, _ = request(ports[443], path, headers={"Host": "audit.example.test"}) + assert status == 307 and headers.get("Location") == location, (status, headers) + print(json.dumps({"ports": expected_ports, "health": "passed", "force_ssl": "passed", + "acme_exception": "passed", "trusted_forwarded_https": "passed", + "http_on_ssl": "passed" if expected_ports["https"] != 443 else "upstream behavior"}), flush=True) + except Exception: + subprocess.run(["docker", "logs", "--tail", "60", name], check=False) + raise + finally: + subprocess.run(["docker", "rm", "-fv", name], stdout=subprocess.DEVNULL, check=False) + if port_files is not None: + port_files.cleanup() + + +check_image(sys.argv[1], 232, 233) +check_image(sys.argv[1]) +check_image(sys.argv[1], 232, 233, from_files=True)