diff --git a/backend/internal/nginx.js b/backend/internal/nginx.js index fe84607f96..d862b8a39a 100644 --- a/backend/internal/nginx.js +++ b/backend/internal/nginx.js @@ -9,6 +9,30 @@ import { debug, nginx as logger } from "../logger.js"; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); +/** + * Returns the DNS resolver address to use in nginx `resolver` directives. + * Priority: NGINX_RESOLVER env var → first nameserver in /etc/resolv.conf → 127.0.0.11 + * + * @returns {String} + */ +const getUpstreamResolver = () => { + if (process.env.NGINX_RESOLVER) { + return process.env.NGINX_RESOLVER; + } + + try { + const resolvConf = fs.readFileSync("/etc/resolv.conf", { encoding: "utf8" }); + const match = resolvConf.match(/^\s*nameserver\s+(\S+)/m); + if (match) { + return match[1]; + } + } catch (_err) { + // ignore — fall through to default + } + + return "127.0.0.11"; +}; + const internalNginx = { /** * This will: @@ -160,6 +184,8 @@ const internalNginx = { { http2_support: host.http2_support }, { hsts_enabled: host.hsts_enabled }, { hsts_subdomains: host.hsts_subdomains }, + { dynamic_upstream_resolve: host.dynamic_upstream_resolve }, + { upstream_resolver: host.upstream_resolver }, { access_list: host.access_list }, { certificate: host.certificate }, host.locations[i], @@ -241,6 +267,9 @@ const internalNginx = { // Set the IPv6 setting for the host host.ipv6 = internalNginx.ipv6Enabled(); + // Set the upstream resolver (used when dynamic_upstream_resolve is enabled) + host.upstream_resolver = getUpstreamResolver(); + locationsPromise.then(() => { renderEngine .parseAndRender(template, host) diff --git a/backend/migrations/20260131163528_trust_forwarded_proto.js b/backend/migrations/20260131163528_trust_forwarded_proto.js index c32c6fb697..546cbca674 100644 --- a/backend/migrations/20260131163528_trust_forwarded_proto.js +++ b/backend/migrations/20260131163528_trust_forwarded_proto.js @@ -10,7 +10,7 @@ const migrateName = "trust_forwarded_proto"; * @param {Object} knex * @returns {Promise} */ -const up = (knex) => { +const up = function (knex) { logger.info(`[${migrateName}] Migrating Up...`); return knex.schema @@ -28,7 +28,7 @@ const up = (knex) => { * @param {Object} knex * @returns {Promise} */ -const down = (knex) => { +const down = function (knex) { logger.info(`[${migrateName}] Migrating Down...`); return knex.schema diff --git a/backend/migrations/20260414000000_dynamic_upstream_resolve.js b/backend/migrations/20260414000000_dynamic_upstream_resolve.js new file mode 100644 index 0000000000..1847faad89 --- /dev/null +++ b/backend/migrations/20260414000000_dynamic_upstream_resolve.js @@ -0,0 +1,43 @@ +import { migrate as logger } from "../logger.js"; + +const migrateName = "dynamic_upstream_resolve"; + +/** + * Migrate + * + * @see http://knexjs.org/#Schema + * + * @param {Object} knex + * @returns {Promise} + */ +const up = (knex) => { + logger.info(`[${migrateName}] Migrating Up...`); + + return knex.schema + .alterTable('proxy_host', (table) => { + table.tinyint('dynamic_upstream_resolve').notNullable().defaultTo(0); + }) + .then(() => { + logger.info(`[${migrateName}] proxy_host Table altered`); + }); +}; + +/** + * Undo Migrate + * + * @param {Object} knex + * @returns {Promise} + */ +const down = (knex) => { + logger.info(`[${migrateName}] Migrating Down...`); + + return knex.schema + .alterTable('proxy_host', (table) => { + table.dropColumn('dynamic_upstream_resolve'); + }) + .then(() => { + logger.info(`[${migrateName}] proxy_host Table altered`); + }); +}; + +export { up, down }; diff --git a/backend/models/proxy_host.js b/backend/models/proxy_host.js index acb8da9358..6b3507f5fc 100644 --- a/backend/models/proxy_host.js +++ b/backend/models/proxy_host.js @@ -21,6 +21,7 @@ const boolFields = [ "enabled", "hsts_enabled", "hsts_subdomains", + "dynamic_upstream_resolve", "trust_forwarded_proto", ]; diff --git a/backend/schema/components/proxy-host-object.json b/backend/schema/components/proxy-host-object.json index 3ac6462136..10b06b24c7 100644 --- a/backend/schema/components/proxy-host-object.json +++ b/backend/schema/components/proxy-host-object.json @@ -22,6 +22,7 @@ "enabled", "locations", "hsts_enabled", + "dynamic_upstream_resolve", "hsts_subdomains", "trust_forwarded_proto" ], @@ -147,6 +148,11 @@ "description": "Trust the forwarded headers", "example": false }, + "dynamic_upstream_resolve": { + "type": "boolean", + "description": "Resolve upstream host dynamically using resolver directive", + "example": false + }, "certificate": { "oneOf": [ { diff --git a/backend/schema/paths/nginx/proxy-hosts/get.json b/backend/schema/paths/nginx/proxy-hosts/get.json index 301e28bfdf..f5dbf7f09b 100644 --- a/backend/schema/paths/nginx/proxy-hosts/get.json +++ b/backend/schema/paths/nginx/proxy-hosts/get.json @@ -53,6 +53,7 @@ "nginx_err": null }, "allow_websocket_upgrade": false, + "dynamic_upstream_resolve": false, "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 2e677fed32..8308ce4fe9 100644 --- a/backend/schema/paths/nginx/proxy-hosts/hostID/get.json +++ b/backend/schema/paths/nginx/proxy-hosts/hostID/get.json @@ -50,6 +50,7 @@ "nginx_err": null }, "allow_websocket_upgrade": false, + "dynamic_upstream_resolve": false, "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 fc3198456b..5ec64db70c 100644 --- a/backend/schema/paths/nginx/proxy-hosts/hostID/put.json +++ b/backend/schema/paths/nginx/proxy-hosts/hostID/put.json @@ -59,6 +59,9 @@ "trust_forwarded_proto": { "$ref": "../../../../components/proxy-host-object.json#/properties/trust_forwarded_proto" }, + "dynamic_upstream_resolve": { + "$ref": "../../../../components/proxy-host-object.json#/properties/dynamic_upstream_resolve" + }, "http2_support": { "$ref": "../../../../components/proxy-host-object.json#/properties/http2_support" }, @@ -119,6 +122,7 @@ "nginx_err": null }, "allow_websocket_upgrade": false, + "dynamic_upstream_resolve": false, "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 28ddad8fc2..abe5d1f6d3 100644 --- a/backend/schema/paths/nginx/proxy-hosts/post.json +++ b/backend/schema/paths/nginx/proxy-hosts/post.json @@ -51,6 +51,9 @@ "trust_forwarded_proto": { "$ref": "../../../components/proxy-host-object.json#/properties/trust_forwarded_proto" }, + "dynamic_upstream_resolve": { + "$ref": "../../../components/proxy-host-object.json#/properties/dynamic_upstream_resolve" + }, "http2_support": { "$ref": "../../../components/proxy-host-object.json#/properties/http2_support" }, @@ -116,6 +119,7 @@ "advanced_config": "", "meta": {}, "allow_websocket_upgrade": false, + "dynamic_upstream_resolve": false, "http2_support": false, "forward_scheme": "http", "enabled": true, diff --git a/backend/templates/_location.conf b/backend/templates/_location.conf index a2ecb166d6..60b3a2cfb2 100644 --- a/backend/templates/_location.conf +++ b/backend/templates/_location.conf @@ -7,7 +7,12 @@ proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header X-Real-IP $remote_addr; + {% if dynamic_upstream_resolve == 1 or dynamic_upstream_resolve == true %} + set $upstream_host "{{ forward_host }}"; + proxy_pass {{ forward_scheme }}://$upstream_host:{{ forward_port }}{{ forward_path }}; + {% else %} proxy_pass {{ forward_scheme }}://{{ forward_host }}:{{ forward_port }}{{ forward_path }}; + {% endif %} {% include "_access.conf" %} {% include "_assets.conf" %} diff --git a/backend/templates/proxy_host.conf b/backend/templates/proxy_host.conf index d23ca46fa2..a564f98a3a 100644 --- a/backend/templates/proxy_host.conf +++ b/backend/templates/proxy_host.conf @@ -9,6 +9,10 @@ server { set $server "{{ forward_host }}"; set $port {{ forward_port }}; + {% if dynamic_upstream_resolve == 1 or dynamic_upstream_resolve == true %} + resolver {{ upstream_resolver }} valid=10s; + {% endif %} + {% include "_listen.conf" %} {% include "_certificates.conf" %} {% include "_assets.conf" %} diff --git a/frontend/src/api/backend/models.ts b/frontend/src/api/backend/models.ts index 2ae0b08348..02e04eab5b 100644 --- a/frontend/src/api/backend/models.ts +++ b/frontend/src/api/backend/models.ts @@ -128,6 +128,7 @@ export interface ProxyHost { hstsEnabled: boolean; hstsSubdomains: boolean; trustForwardedProto: boolean; + dynamicUpstreamResolve: boolean; // Expansions: owner?: User; accessList?: AccessList; diff --git a/frontend/src/hooks/useProxyHost.ts b/frontend/src/hooks/useProxyHost.ts index 24e7f4fae2..2808d3abef 100644 --- a/frontend/src/hooks/useProxyHost.ts +++ b/frontend/src/hooks/useProxyHost.ts @@ -25,6 +25,7 @@ const fetchProxyHost = (id: number | "new") => { hstsEnabled: false, hstsSubdomains: false, trustForwardedProto: false, + dynamicUpstreamResolve: false, } as ProxyHost); } return getProxyHost(id, ["owner"]); diff --git a/frontend/src/locale/src/en.json b/frontend/src/locale/src/en.json index bb00ac3322..10f47d74a2 100644 --- a/frontend/src/locale/src/en.json +++ b/frontend/src/locale/src/en.json @@ -437,6 +437,12 @@ "host.flags.websockets-upgrade": { "defaultMessage": "Websockets Support" }, + "host.flags.dynamic-upstream-resolve": { + "defaultMessage": "Dynamic Upstream Resolve" + }, + "host.flags.dynamic-upstream-resolve-description": { + "defaultMessage": "Resolves upstream hostnames dynamically at request time. The DNS resolver is auto-detected from /etc/resolv.conf, or can be overridden with the NGINX_RESOLVER environment variable. Defaults to 127.0.0.11 (Docker bridge DNS) if no resolver is found." + }, "host.forward-port": { "defaultMessage": "Forward Port" }, diff --git a/frontend/src/locale/src/fr.json b/frontend/src/locale/src/fr.json index 04320266d3..e5cf8d7317 100644 --- a/frontend/src/locale/src/fr.json +++ b/frontend/src/locale/src/fr.json @@ -437,6 +437,12 @@ "host.flags.websockets-upgrade": { "defaultMessage": "Prise en charge de Websockets" }, + "host.flags.dynamic-upstream-resolve": { + "defaultMessage": "Résolution dynamique de l'upstream" + }, + "host.flags.dynamic-upstream-resolve-description": { + "defaultMessage": "Résout les noms d'hôtes upstream dynamiquement à chaque requête. Le résolveur DNS est détecté automatiquement depuis /etc/resolv.conf, ou peut être remplacé via la variable d'environnement NGINX_RESOLVER. Par défaut : 127.0.0.11 (DNS Docker bridge) si aucun résolveur n'est trouvé." + }, "host.forward-port": { "defaultMessage": "Port de redirection" }, diff --git a/frontend/src/modals/ProxyHostModal.tsx b/frontend/src/modals/ProxyHostModal.tsx index 3227be51bb..f49a996a8e 100644 --- a/frontend/src/modals/ProxyHostModal.tsx +++ b/frontend/src/modals/ProxyHostModal.tsx @@ -89,6 +89,7 @@ const ProxyHostModal = EasyModal.create(({ id, visible, remove }: Props) => { hstsEnabled: data?.hstsEnabled || false, hstsSubdomains: data?.hstsSubdomains || false, trustForwardedProto: data?.trustForwardedProto || false, + dynamicUpstreamResolve: data?.dynamicUpstreamResolve || false, // Advanced tab advancedConfig: data?.advancedConfig || "", meta: data?.meta || {}, @@ -328,6 +329,32 @@ const ProxyHostModal = EasyModal.create(({ id, visible, remove }: Props) => { +