From 8f65271f02bbc725421b92abbe2bb032714317f9 Mon Sep 17 00:00:00 2001 From: hactazia Date: Tue, 14 Apr 2026 23:08:15 +0200 Subject: [PATCH 1/2] 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/2] 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