Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 7 additions & 5 deletions backend/internal/nginx.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import errs from "../lib/error.js";
import utils from "../lib/utils.js";
import { debug, nginx as logger } from "../logger.js";
import accessListModel from "../models/access_list.js";
import { prepareLocationForward } from "./upstream_host.js";

const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
Expand Down Expand Up @@ -187,11 +188,12 @@ const internalNginx = {
locationCopy.access_list = host.access_list;
}

if (locationCopy.forward_host.indexOf("/") > -1) {
const splitted = locationCopy.forward_host.split("/");

locationCopy.forward_host = splitted.shift();
locationCopy.forward_path = `/${splitted.join("/")}`;
// A slash in forward_host is a path suffix. An IPv6 literal must then
// be bracketed or "host:port" is an invalid upstream.
const forward = prepareLocationForward(locationCopy.forward_host);
locationCopy.forward_host = forward.forward_host;
if (forward.forward_path !== undefined) {
locationCopy.forward_path = forward.forward_path;
}

renderedLocations += await renderEngine.parseAndRender(template, locationCopy);
Expand Down
37 changes: 37 additions & 0 deletions backend/internal/upstream_host.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import net from "node:net";

/**
* An IPv6 literal must be wrapped in square brackets before nginx appends
* ":<port>". Otherwise proxy_pass is rejected with "invalid port in upstream".
* Hostnames, IPv4 addresses, and values that are already bracketed are unchanged.
*
* @param {string} host
* @returns {string}
*/
export function formatUpstreamHost(host) {
if (typeof host === "string" && net.isIPv6(host)) {
return `[${host}]`;
}
return host;
}

/**
* Split an optional path suffix off a custom-location forward host, then
* bracket an IPv6 address. `forward_path` is omitted when the host has no slash.
*
* @param {string} forwardHost
* @returns {{forward_host: string, forward_path?: string}}
*/
export function prepareLocationForward(forwardHost) {
let host = forwardHost;
let forwardPath;
if (host.indexOf("/") > -1) {
const splitted = host.split("/");
host = splitted.shift();
forwardPath = `/${splitted.join("/")}`;
}
return {
forward_host: formatUpstreamHost(host),
forward_path: forwardPath,
};
}
38 changes: 38 additions & 0 deletions backend/internal/upstream_host.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import test from "node:test";
import { prepareLocationForward } from "./upstream_host.js";

const locationTemplate = fs.readFileSync(new URL("../templates/_location.conf", import.meta.url), "utf8");
const nginxSource = fs.readFileSync(new URL("./nginx.js", import.meta.url), "utf8");

function renderLocationProxyPass(forwardHost, forwardPort, forwardPath = "") {
const forward = prepareLocationForward(forwardHost);
const path = forward.forward_path !== undefined ? forward.forward_path : forwardPath;
return `proxy_pass http://${forward.forward_host}:${forwardPort}${path};`;
}

test("custom location template appends the port directly to forward_host", () => {
assert.match(
locationTemplate,
/proxy_pass\s+\{\{\s*forward_scheme\s*\}\}:\/\/\{\{\s*forward_host\s*\}\}:\{\{\s*forward_port\s*\}\}/,
);
assert.match(nginxSource, /prepareLocationForward\(locationCopy\.forward_host\)/);
});

test("ipv6 custom location upstreams are bracketed before the port", () => {
assert.equal(
renderLocationProxyPass("fe80::528:3c87:e7bb:ab08", 25),
"proxy_pass http://[fe80::528:3c87:e7bb:ab08]:25;",
);
assert.equal(renderLocationProxyPass("::1", 8080), "proxy_pass http://[::1]:8080;");
assert.equal(renderLocationProxyPass("::1/api", 8080), "proxy_pass http://[::1]:8080/api;");
assert.equal(renderLocationProxyPass("2001:db8::1", 443, "/health"), "proxy_pass http://[2001:db8::1]:443/health;");
});

test("ipv4, hostnames, and already bracketed addresses are left unchanged", () => {
assert.equal(renderLocationProxyPass("192.168.1.10", 8080), "proxy_pass http://192.168.1.10:8080;");
assert.equal(renderLocationProxyPass("backend.internal", 80), "proxy_pass http://backend.internal:80;");
assert.equal(renderLocationProxyPass("example.com/api", 80), "proxy_pass http://example.com:80/api;");
assert.equal(renderLocationProxyPass("[::1]", 8080), "proxy_pass http://[::1]:8080;");
});