Skip to content
Open
29 changes: 29 additions & 0 deletions backend/internal/nginx.js
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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],
Expand Down Expand Up @@ -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)
Expand Down
4 changes: 2 additions & 2 deletions backend/migrations/20260131163528_trust_forwarded_proto.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
43 changes: 43 additions & 0 deletions backend/migrations/20260414000000_dynamic_upstream_resolve.js
Original file line number Diff line number Diff line change
@@ -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 };
1 change: 1 addition & 0 deletions backend/models/proxy_host.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ const boolFields = [
"enabled",
"hsts_enabled",
"hsts_subdomains",
"dynamic_upstream_resolve",
"trust_forwarded_proto",
];

Expand Down
6 changes: 6 additions & 0 deletions backend/schema/components/proxy-host-object.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
"enabled",
"locations",
"hsts_enabled",
"dynamic_upstream_resolve",
"hsts_subdomains",
"trust_forwarded_proto"
],
Expand Down Expand Up @@ -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": [
{
Expand Down
1 change: 1 addition & 0 deletions backend/schema/paths/nginx/proxy-hosts/get.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
"nginx_err": null
},
"allow_websocket_upgrade": false,
"dynamic_upstream_resolve": false,
"http2_support": false,
"forward_scheme": "http",
"enabled": true,
Expand Down
1 change: 1 addition & 0 deletions backend/schema/paths/nginx/proxy-hosts/hostID/get.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
"nginx_err": null
},
"allow_websocket_upgrade": false,
"dynamic_upstream_resolve": false,
"http2_support": false,
"forward_scheme": "http",
"enabled": true,
Expand Down
4 changes: 4 additions & 0 deletions backend/schema/paths/nginx/proxy-hosts/hostID/put.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down Expand Up @@ -119,6 +122,7 @@
"nginx_err": null
},
"allow_websocket_upgrade": false,
"dynamic_upstream_resolve": false,
"http2_support": false,
"forward_scheme": "http",
"enabled": true,
Expand Down
4 changes: 4 additions & 0 deletions backend/schema/paths/nginx/proxy-hosts/post.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down Expand Up @@ -116,6 +119,7 @@
"advanced_config": "",
"meta": {},
"allow_websocket_upgrade": false,
"dynamic_upstream_resolve": false,
"http2_support": false,
"forward_scheme": "http",
"enabled": true,
Expand Down
5 changes: 5 additions & 0 deletions backend/templates/_location.conf
Original file line number Diff line number Diff line change
Expand Up @@ -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" %}
Expand Down
4 changes: 4 additions & 0 deletions backend/templates/proxy_host.conf
Original file line number Diff line number Diff line change
Expand Up @@ -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" %}
Expand Down
1 change: 1 addition & 0 deletions frontend/src/api/backend/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ export interface ProxyHost {
hstsEnabled: boolean;
hstsSubdomains: boolean;
trustForwardedProto: boolean;
dynamicUpstreamResolve: boolean;
// Expansions:
owner?: User;
accessList?: AccessList;
Expand Down
1 change: 1 addition & 0 deletions frontend/src/hooks/useProxyHost.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ const fetchProxyHost = (id: number | "new") => {
hstsEnabled: false,
hstsSubdomains: false,
trustForwardedProto: false,
dynamicUpstreamResolve: false,
} as ProxyHost);
}
return getProxyHost(id, ["owner"]);
Expand Down
6 changes: 6 additions & 0 deletions frontend/src/locale/src/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
6 changes: 6 additions & 0 deletions frontend/src/locale/src/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
27 changes: 27 additions & 0 deletions frontend/src/modals/ProxyHostModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 || {},
Expand Down Expand Up @@ -328,6 +329,32 @@ const ProxyHostModal = EasyModal.create(({ id, visible, remove }: Props) => {
</span>
</label>
</div>
<div>
<label className="row" htmlFor="dynamicUpstreamResolve">
<span className="col">
<T id="host.flags.dynamic-upstream-resolve" />
<div className="small text-muted mt-1">
<T id="host.flags.dynamic-upstream-resolve-description" />
</div>
</span>
<span className="col-auto">
<Field name="dynamicUpstreamResolve" type="checkbox">
{({ field }: any) => (
<label className="form-check form-check-single form-switch">
<input
{...field}
id="dynamicUpstreamResolve"
className={cn("form-check-input", {
"bg-lime": field.checked,
})}
type="checkbox"
/>
</label>
)}
</Field>
</span>
</label>
</div>
</div>
</div>
</div>
Expand Down
52 changes: 43 additions & 9 deletions test/cypress/e2e/api/ProxyHosts.cy.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);
});
});

});