diff --git a/backend/internal/nginx.js b/backend/internal/nginx.js index fe84607f96..e05753186b 100644 --- a/backend/internal/nginx.js +++ b/backend/internal/nginx.js @@ -1,4 +1,6 @@ import fs from "node:fs"; +import { lookup } from "node:dns/promises"; +import { isIP } from "node:net"; import { dirname } from "node:path"; import { fileURLToPath } from "node:url"; import _ from "lodash"; @@ -8,6 +10,7 @@ import { debug, nginx as logger } from "../logger.js"; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); +const TRUE_ENV_VALUES = new Set(["1", "true", "yes", "on"]); const internalNginx = { /** @@ -172,6 +175,8 @@ const internalNginx = { locationCopy.forward_path = `/${splitted.join("/")}`; } + locationCopy.forward_host = await internalNginx.preResolveUpstreamHost(locationCopy.forward_host); + renderedLocations += await renderEngine.parseAndRender(template, locationCopy); } }; @@ -238,10 +243,14 @@ const internalNginx = { locationsPromise = Promise.resolve(); } + const forwardHostPromise = internalNginx.preResolveUpstreamHost(host.forward_host).then((resolvedHost) => { + host.forward_host = resolvedHost; + }); + // Set the IPv6 setting for the host host.ipv6 = internalNginx.ipv6Enabled(); - locationsPromise.then(() => { + Promise.all([locationsPromise, forwardHostPromise]).then(() => { renderEngine .parseAndRender(template, host) .then((config_text) => { @@ -258,8 +267,8 @@ const internalNginx = { reject(new errs.ConfigurationError(err.message)); }); }); - }); - }, + }); + }, /** * This generates a temporary nginx config listening on port 80 for the domain names listed @@ -421,6 +430,63 @@ const internalNginx = { */ advancedConfigHasDefaultLocation: (cfg) => !!cfg.match(/^(?:.*;)?\s*?location\s*?\/\s*?{/im), + /** + * @returns {boolean} + */ + preResolveUpstreamHostEnabled: () => { + if (typeof process.env.NPM_PRE_RESOLVE_UPSTREAM_HOSTS === "undefined") { + return false; + } + return TRUE_ENV_VALUES.has(String(process.env.NPM_PRE_RESOLVE_UPSTREAM_HOSTS).trim().toLowerCase()); + }, + + /** + * @param {String} host + * @returns {boolean} + */ + canPreResolveUpstreamHost: (host) => { + if (typeof host !== "string") { + return false; + } + const candidate = host.trim(); + if (candidate === "") { + return false; + } + if (candidate.includes("$") || candidate.includes("/") || candidate.includes(":")) { + return false; + } + return isIP(candidate) === 0; + }, + + /** + * @param {String} host + * @returns {Promise} + */ + preResolveUpstreamHost: async (host) => { + if (!internalNginx.preResolveUpstreamHostEnabled()) { + return host; + } + if (typeof host !== "string") { + return host; + } + const candidate = host.trim(); + if (!internalNginx.canPreResolveUpstreamHost(candidate)) { + return host; + } + + try { + const resolved = await lookup(candidate, { family: 0, all: false, verbatim: false }); + if (resolved?.address) { + debug(logger, `Pre-resolved upstream host "${candidate}" to "${resolved.address}"`); + return resolved.address; + } + } catch (err) { + debug(logger, `Could not pre-resolve upstream host "${candidate}": ${err.message}`); + } + + return host; + }, + /** * @returns {boolean} */ diff --git a/docs/src/advanced-config/index.md b/docs/src/advanced-config/index.md index d987e0b022..326a36361d 100644 --- a/docs/src/advanced-config/index.md +++ b/docs/src/advanced-config/index.md @@ -168,6 +168,30 @@ By default, NPM fetches IP ranges from CloudFront and Cloudflare during applicat IP_RANGES_FETCH_ENABLED: 'false' ``` +## Pre-resolving Upstream Hostnames + +By default, proxy upstream hostnames are resolved by Nginx's configured DNS resolver. +In some Docker setups, hostnames such as `host.docker.internal` may only be available +through the container's system resolver path (for example `extra_hosts`) and may not be +resolvable by Nginx runtime DNS resolution. + +To enable optional pre-resolution in NPM while generating proxy configs: + +```yml + environment: + NPM_PRE_RESOLVE_UPSTREAM_HOSTS: 'true' +``` + +When enabled, NPM attempts to resolve eligible upstream hostnames via the system resolver +before writing Nginx config and stores the resolved IP in generated upstream targets. +If resolution fails, NPM keeps the original hostname. + +Notes: + +- Default is disabled (`false`) to preserve existing behavior. +- Resolution happens when configs are generated/re-generated, not per request at runtime. +- If upstream IPs change, regenerate or reload the related proxy host config. + ## Custom Nginx Configurations If you are a more advanced user, you might be itching for extra Nginx customizability. diff --git a/frontend/src/modals/CustomCertificateModal.tsx b/frontend/src/modals/CustomCertificateModal.tsx index deab1c5231..1d9c45da45 100644 --- a/frontend/src/modals/CustomCertificateModal.tsx +++ b/frontend/src/modals/CustomCertificateModal.tsx @@ -15,6 +15,17 @@ const showCustomCertificateModal = () => { EasyModal.show(CustomCertificateModal); }; +const buildPemFile = (file: File | null, content: string, filename: string) => { + if (file) { + return file; + } + const pemContent = typeof content === "string" ? content.trim() : ""; + if (!pemContent) { + return null; + } + return new File([pemContent], filename, { type: "application/x-pem-file" }); +}; + const CustomCertificateModal = EasyModal.create(({ visible, remove }: InnerModalProps) => { const queryClient = useQueryClient(); const [errorMsg, setErrorMsg] = useState(null); @@ -26,13 +37,42 @@ const CustomCertificateModal = EasyModal.create(({ visible, remove }: InnerModal setErrorMsg(null); try { - const { niceName, provider, certificate, certificateKey, intermediateCertificate } = values; + const { + niceName, + provider, + certificate, + certificateText, + certificateKey, + certificateKeyText, + intermediateCertificate, + intermediateCertificateText, + } = values; const formData = new FormData(); - formData.append("certificate", certificate); - formData.append("certificate_key", certificateKey); - if (intermediateCertificate !== null) { - formData.append("intermediate_certificate", intermediateCertificate); + const certFile = buildPemFile(certificate, certificateText, "certificate.pem"); + const certKeyFile = buildPemFile(certificateKey, certificateKeyText, "certificate-key.pem"); + const intermediateFile = buildPemFile( + intermediateCertificate, + intermediateCertificateText, + "intermediate.pem", + ); + + if (!certFile || !certKeyFile) { + setErrorMsg( + <> + / :{" "} + + , + ); + setIsSubmitting(false); + setSubmitting(false); + return; + } + + formData.append("certificate", certFile); + formData.append("certificate_key", certKeyFile); + if (intermediateFile !== null) { + formData.append("intermediate_certificate", intermediateFile); } // Validate @@ -64,8 +104,11 @@ const CustomCertificateModal = EasyModal.create(({ visible, remove }: InnerModal niceName: "", provider: "other", certificate: null, + certificateText: "", certificateKey: null, + certificateKeyText: "", intermediateCertificate: null, + intermediateCertificateText: "", } as any } onSubmit={onSubmit} @@ -110,103 +153,140 @@ const CustomCertificateModal = EasyModal.create(({ visible, remove }: InnerModal ) : null} )} - - - {({ field, form }: any) => ( -
- - { - form.setFieldValue( - field.name, - event.currentTarget.files?.length - ? event.currentTarget.files[0] - : null, - ); - }} - /> - {form.errors.certificateKey ? ( -
+ + + {({ field, form }: any) => ( +
+ + { + form.setFieldValue( + field.name, + event.currentTarget.files?.length + ? event.currentTarget.files[0] + : null, + ); + }} + /> + {form.errors.certificateKey ? ( +
{form.errors.certificateKey && form.touched.certificateKey ? form.errors.certificateKey : null} -
- ) : null} -
- )} -
- - {({ field, form }: any) => ( -
- - { - form.setFieldValue( - field.name, - event.currentTarget.files?.length - ? event.currentTarget.files[0] - : null, - ); - }} - /> - {form.errors.certificate ? ( -
- {form.errors.certificate && form.touched.certificate - ? form.errors.certificate - : null} -
- ) : null} -
- )} -
- - {({ field, form }: any) => ( -
- - { - form.setFieldValue( - field.name, - event.currentTarget.files?.length - ? event.currentTarget.files[0] - : null, - ); - }} - /> - {form.errors.intermediateCertificate ? ( -
- {form.errors.intermediateCertificate && - form.touched.intermediateCertificate - ? form.errors.intermediateCertificate - : null} -
- ) : null} -
- )} -
+
+ ) : null} +
+ )} +
+ + {({ field }: any) => ( +
+