From 8f65271f02bbc725421b92abbe2bb032714317f9 Mon Sep 17 00:00:00 2001 From: hactazia Date: Tue, 14 Apr 2026 23:08:15 +0200 Subject: [PATCH 1/7] feat(proxy-host): add dynamic upstream resolve option When enabled, nginx resolves the upstream hostname at request time using Docker's internal DNS resolver (127.0.0.11) instead of only at startup. This prevents nginx from failing when an upstream container is not yet running or restarts with a new IP. --- backend/internal/nginx.js | 1 + ...20260414000000_dynamic_upstream_resolve.js | 43 +++++++++++++++ backend/models/proxy_host.js | 1 + .../schema/components/proxy-host-object.json | 6 +++ .../paths/nginx/proxy-hosts/hostID/put.json | 3 ++ .../schema/paths/nginx/proxy-hosts/post.json | 3 ++ backend/templates/_location.conf | 5 ++ backend/templates/proxy_host.conf | 4 ++ frontend/src/api/backend/models.ts | 1 + frontend/src/hooks/useProxyHost.ts | 1 + frontend/src/locale/src/en.json | 3 ++ frontend/src/locale/src/fr.json | 3 ++ frontend/src/modals/ProxyHostModal.tsx | 23 ++++++++ test/cypress/e2e/api/ProxyHosts.cy.js | 52 +++++++++++++++---- 14 files changed, 140 insertions(+), 9 deletions(-) create mode 100644 backend/migrations/20260414000000_dynamic_upstream_resolve.js diff --git a/backend/internal/nginx.js b/backend/internal/nginx.js index fe84607f96..86a0a77051 100644 --- a/backend/internal/nginx.js +++ b/backend/internal/nginx.js @@ -160,6 +160,7 @@ const internalNginx = { { http2_support: host.http2_support }, { hsts_enabled: host.hsts_enabled }, { hsts_subdomains: host.hsts_subdomains }, + { dynamic_upstream_resolve: host.dynamic_upstream_resolve }, { access_list: host.access_list }, { certificate: host.certificate }, host.locations[i], diff --git a/backend/migrations/20260414000000_dynamic_upstream_resolve.js b/backend/migrations/20260414000000_dynamic_upstream_resolve.js new file mode 100644 index 0000000000..570e5a5a9b --- /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 = function (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 = function (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/hostID/put.json b/backend/schema/paths/nginx/proxy-hosts/hostID/put.json index fc3198456b..163bdab1c0 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" }, diff --git a/backend/schema/paths/nginx/proxy-hosts/post.json b/backend/schema/paths/nginx/proxy-hosts/post.json index 28ddad8fc2..c3b2126389 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" }, 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..fa3e103674 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 127.0.0.11 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..2bd9a9f4b7 100644 --- a/frontend/src/locale/src/en.json +++ b/frontend/src/locale/src/en.json @@ -437,6 +437,9 @@ "host.flags.websockets-upgrade": { "defaultMessage": "Websockets Support" }, + "host.flags.dynamic-upstream-resolve": { + "defaultMessage": "Dynamic Upstream Resolve" + }, "host.forward-port": { "defaultMessage": "Forward Port" }, diff --git a/frontend/src/locale/src/fr.json b/frontend/src/locale/src/fr.json index c715c028a6..56a03a9008 100644 --- a/frontend/src/locale/src/fr.json +++ b/frontend/src/locale/src/fr.json @@ -347,6 +347,9 @@ "host.flags.websockets-upgrade": { "defaultMessage": "Prise en charge de Websockets" }, + "host.flags.dynamic-upstream-resolve": { + "defaultMessage": "Résolution dynamique de l'Upstream" + }, "host.forward-port": { "defaultMessage": "Port de redirection" }, diff --git a/frontend/src/modals/ProxyHostModal.tsx b/frontend/src/modals/ProxyHostModal.tsx index 3227be51bb..8b37c0626a 100644 --- a/frontend/src/modals/ProxyHostModal.tsx +++ b/frontend/src/modals/ProxyHostModal.tsx @@ -328,6 +328,29 @@ const ProxyHostModal = EasyModal.create(({ id, visible, remove }: Props) => { +
+ +
diff --git a/test/cypress/e2e/api/ProxyHosts.cy.js b/test/cypress/e2e/api/ProxyHosts.cy.js index 5f437cf950..dba29414ce 100644 --- a/test/cypress/e2e/api/ProxyHosts.cy.js +++ b/test/cypress/e2e/api/ProxyHosts.cy.js @@ -24,15 +24,16 @@ describe('Proxy Hosts endpoints', () => { meta: { dns_challenge: false }, - advanced_config: '', - locations: [], - block_exploits: false, - caching_enabled: false, - allow_websocket_upgrade: false, - http2_support: false, - hsts_enabled: false, - hsts_subdomains: false, - ssl_forced: false + advanced_config: '', + locations: [], + block_exploits: false, + caching_enabled: false, + allow_websocket_upgrade: false, + http2_support: false, + hsts_enabled: false, + hsts_subdomains: false, + ssl_forced: false, + dynamic_upstream_resolve: false, } }).then((data) => { cy.validateSwaggerSchema('post', 201, '/nginx/proxy-hosts', data); @@ -45,4 +46,37 @@ describe('Proxy Hosts endpoints', () => { }); }); + it('Should be able to create a proxy host with dynamic upstream resolve enabled', () => { + cy.task('backendApiPost', { + token: token, + path: '/api/nginx/proxy-hosts', + data: { + domain_names: ['dynamic-resolve.example.com'], + forward_scheme: 'http', + forward_host: 'my.node', + forward_port: 8080, + access_list_id: '0', + certificate_id: 0, + meta: { + dns_challenge: false + }, + advanced_config: '', + locations: [], + block_exploits: false, + caching_enabled: false, + allow_websocket_upgrade: false, + http2_support: false, + hsts_enabled: false, + hsts_subdomains: false, + ssl_forced: false, + dynamic_upstream_resolve: true, + } + }).then((data) => { + cy.validateSwaggerSchema('post', 201, '/nginx/proxy-hosts', data); + expect(data).to.have.property('id'); + expect(data.id).to.be.greaterThan(0); + expect(data).to.have.property('dynamic_upstream_resolve', true); + }); + }); + }); From 4f6ce60cbe47ecdd489de4a6271248b325655292 Mon Sep 17 00:00:00 2001 From: hactazia Date: Tue, 14 Apr 2026 23:20:48 +0200 Subject: [PATCH 2/7] refactor(migrations): convert function declarations to arrow functions --- backend/migrations/20260131163528_trust_forwarded_proto.js | 4 ++-- backend/migrations/20260414000000_dynamic_upstream_resolve.js | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/backend/migrations/20260131163528_trust_forwarded_proto.js b/backend/migrations/20260131163528_trust_forwarded_proto.js index 546cbca674..c32c6fb697 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 = function (knex) { +const up = (knex) => { logger.info(`[${migrateName}] Migrating Up...`); return knex.schema @@ -28,7 +28,7 @@ const up = function (knex) { * @param {Object} knex * @returns {Promise} */ -const down = function (knex) { +const down = (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 index 570e5a5a9b..1847faad89 100644 --- a/backend/migrations/20260414000000_dynamic_upstream_resolve.js +++ b/backend/migrations/20260414000000_dynamic_upstream_resolve.js @@ -10,7 +10,7 @@ const migrateName = "dynamic_upstream_resolve"; * @param {Object} knex * @returns {Promise} */ -const up = function (knex) { +const up = (knex) => { logger.info(`[${migrateName}] Migrating Up...`); return knex.schema @@ -28,7 +28,7 @@ const up = function (knex) { * @param {Object} knex * @returns {Promise} */ -const down = function (knex) { +const down = (knex) => { logger.info(`[${migrateName}] Migrating Down...`); return knex.schema From 63ffa40084b2279be2710ba4e69bd1e4992ce575 Mon Sep 17 00:00:00 2001 From: hactazia Date: Thu, 14 May 2026 05:58:15 +0200 Subject: [PATCH 3/7] revert(migrations): restore function declarations in trust_forwarded_proto Migration files must be treated as immutable once committed. Revert the unrelated style change (arrow functions) introduced in a previous commit. --- backend/migrations/20260131163528_trust_forwarded_proto.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 From 3de72d497533998af558b04c4aeefa2b2026174d Mon Sep 17 00:00:00 2001 From: hactazia Date: Thu, 14 May 2026 05:59:30 +0200 Subject: [PATCH 4/7] feat(nginx): configurable DNS resolver for dynamic upstream resolve Instead of hardcoding 127.0.0.11 (Docker bridge DNS only), the resolver address is now determined at startup using the following priority: 1. NGINX_RESOLVER environment variable (explicit override) 2. First nameserver entry in /etc/resolv.conf (system DNS) 3. Fallback to 127.0.0.11 (Docker bridge default) This makes the feature usable outside of Docker bridge networks (host-network, bare-metal, Kubernetes, etc.) without any code change. Also fix template indentation for the resolver directive to match the 2-space convention used by other directives in the server{} block. --- backend/internal/nginx.js | 28 ++++++++++++++++++++++++++++ backend/templates/proxy_host.conf | 6 +++--- 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/backend/internal/nginx.js b/backend/internal/nginx.js index 86a0a77051..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: @@ -161,6 +185,7 @@ const internalNginx = { { 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], @@ -242,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/templates/proxy_host.conf b/backend/templates/proxy_host.conf index fa3e103674..a564f98a3a 100644 --- a/backend/templates/proxy_host.conf +++ b/backend/templates/proxy_host.conf @@ -9,9 +9,9 @@ server { set $server "{{ forward_host }}"; set $port {{ forward_port }}; -{% if dynamic_upstream_resolve == 1 or dynamic_upstream_resolve == true %} - resolver 127.0.0.11 valid=10s; -{% endif %} + {% if dynamic_upstream_resolve == 1 or dynamic_upstream_resolve == true %} + resolver {{ upstream_resolver }} valid=10s; + {% endif %} {% include "_listen.conf" %} {% include "_certificates.conf" %} From b2e5d0d842ea9d2b4afb74911f515d2e690cbea2 Mon Sep 17 00:00:00 2001 From: hactazia Date: Thu, 14 May 2026 05:59:52 +0200 Subject: [PATCH 5/7] feat(ui): add resolver info to dynamic upstream resolve toggle Add a descriptive sub-line under the toggle explaining that the DNS resolver is auto-detected but can be overridden via NGINX_RESOLVER. Also add French translation for both the label and the description. --- frontend/src/locale/src/en.json | 3 +++ frontend/src/locale/src/fr.json | 5 ++++- frontend/src/modals/ProxyHostModal.tsx | 3 +++ 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/frontend/src/locale/src/en.json b/frontend/src/locale/src/en.json index 2bd9a9f4b7..10f47d74a2 100644 --- a/frontend/src/locale/src/en.json +++ b/frontend/src/locale/src/en.json @@ -440,6 +440,9 @@ "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 de17bfddaf..e5cf8d7317 100644 --- a/frontend/src/locale/src/fr.json +++ b/frontend/src/locale/src/fr.json @@ -438,7 +438,10 @@ "defaultMessage": "Prise en charge de Websockets" }, "host.flags.dynamic-upstream-resolve": { - "defaultMessage": "Résolution dynamique de l'Upstream" + "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 8b37c0626a..a495e9428c 100644 --- a/frontend/src/modals/ProxyHostModal.tsx +++ b/frontend/src/modals/ProxyHostModal.tsx @@ -332,6 +332,9 @@ const ProxyHostModal = EasyModal.create(({ id, visible, remove }: Props) => {