Adds integration tests for per path access lists, fixes ipv6 jsv,

and enforces host-wide access list when location access list is not set
This commit is contained in:
Jamie Curnow
2026-09-24 12:21:49 +10:00
parent c41ec008bb
commit 2cfd3395cf
21 changed files with 518 additions and 103 deletions
+29 -59
View File
@@ -42,9 +42,11 @@ const getProxyHostsUsingAccessListInLocations = async (accessListId) => {
[`%"access_list_id":${accessListId}%`],
);
}
// knex raw() returns [rows, metadata] for MySQL
const rows = Array.isArray(result) && Array.isArray(result[0]) ? result[0] : result;
return rows || [];
// knex raw() returns [rows, metadata] for MySQL, { rows } for Postgres and rows for SQLite
if (!Array.isArray(result)) {
return result?.rows || [];
}
return (Array.isArray(result[0]) ? result[0] : result) || [];
};
const internalAccessList = {
@@ -233,26 +235,6 @@ const internalAccessList = {
.whereIn("id", locationHostIds)
.allowGraph(proxyHostModel.defaultAllowGraph)
.withGraphFetched("[owner, certificate, access_list.[clients,items]]");
for (const host of locationHosts) {
// Fetch access lists for locations
if (host.locations?.length) {
for (let i = 0; i < host.locations.length; i++) {
const loc = host.locations[i];
if (loc.access_list_id && loc.access_list_id > 0) {
const locAccessList = await accessListModel
.query()
.allowGraph("[clients,items]")
.where("is_deleted", 0)
.andWhere("id", loc.access_list_id)
.withGraphFetched("[clients,items]")
.first();
if (locAccessList) {
host.locations[i].access_list = locAccessList;
}
}
}
}
}
await internalNginx.bulkGenerateConfigs("proxy_host", locationHosts);
}
}
@@ -338,50 +320,38 @@ const internalAccessList = {
});
// 2. update any proxy hosts that were using it (ignoring permissions)
if (row.proxy_hosts) {
const affectedHostIds = new Set((row.proxy_hosts || []).map((h) => h.id));
if (affectedHostIds.size) {
await proxyHostModel.query().where("access_list_id", "=", row.id).patch({ access_list_id: 0 });
// 3. reconfigure those hosts, then reload nginx
// set the access_list_id to zero for these items
row.proxy_hosts.map((_val, idx) => {
row.proxy_hosts[idx].access_list_id = 0;
return true;
});
await internalNginx.bulkGenerateConfigs("proxy_host", row.proxy_hosts);
}
// Also handle proxy hosts that reference this access list in their locations JSON
// Also clear it from any proxy host locations using it, these will then inherit the host's access list
const locationHostRows = await getProxyHostsUsingAccessListInLocations(row.id);
if (locationHostRows?.length) {
const locationHostIds = locationHostRows.map((r) => r.id).filter((id) => {
return !row.proxy_hosts?.find((h) => h.id === id);
});
if (locationHostIds.length) {
// Clear the access_list_id in locations JSON for these hosts
for (const hostId of locationHostIds) {
const host = await proxyHostModel.query().where("id", hostId).first();
if (host?.locations) {
const updatedLocations = host.locations.map((loc) => {
if (loc.access_list_id === row.id) {
return { ...loc, access_list_id: 0 };
}
return loc;
});
await proxyHostModel.query().where("id", hostId).patch({ locations: updatedLocations });
for (const { id: hostId } of locationHostRows) {
const host = await proxyHostModel.query().where("id", hostId).first();
if (host?.locations?.some((loc) => loc.access_list_id === row.id)) {
const updatedLocations = host.locations.map((loc) => {
if (loc.access_list_id === row.id) {
return { ...loc, access_list_id: 0 };
}
}
// Re-fetch and regenerate configs
const locationHosts = await proxyHostModel.query()
.where("is_deleted", 0)
.whereIn("id", locationHostIds)
.allowGraph(proxyHostModel.defaultExpand)
.withGraphFetched("[owner, certificate, access_list.[clients,items]]");
await internalNginx.bulkGenerateConfigs("proxy_host", locationHosts);
return loc;
});
await proxyHostModel.query().where("id", hostId).patch({ locations: updatedLocations });
affectedHostIds.add(hostId);
}
}
// 3. reconfigure those hosts from fresh rows, then reload nginx
if (affectedHostIds.size) {
const affectedHosts = await proxyHostModel
.query()
.where("is_deleted", 0)
.whereIn("id", [...affectedHostIds])
.allowGraph(proxyHostModel.defaultAllowGraph)
.withGraphFetched("[owner, certificate, access_list.[clients,items]]");
await internalNginx.bulkGenerateConfigs("proxy_host", affectedHosts);
}
await internalNginx.reload();
// delete the htpasswd file
+25 -1
View File
@@ -6,6 +6,7 @@ import _ from "lodash";
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";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
@@ -168,6 +169,24 @@ const internalNginx = {
host.locations[i],
);
// A location with its own access list overrides the host's,
// otherwise it inherits the host's access list
let locationAccessList = null;
if (locationCopy.access_list_id > 0 && locationCopy.access_list_id !== host.access_list?.id) {
locationAccessList = await accessListModel
.query()
.where("is_deleted", 0)
.andWhere("id", locationCopy.access_list_id)
.withGraphFetched("[clients,items]")
.first();
}
if (locationAccessList) {
locationCopy.access_list = locationAccessList;
} else {
locationCopy.access_list_id = host.access_list_id;
locationCopy.access_list = host.access_list;
}
if (locationCopy.forward_host.indexOf("/") > -1) {
const splitted = locationCopy.forward_host.split("/");
@@ -179,7 +198,9 @@ const internalNginx = {
}
};
locationRendering().then(() => resolve(renderedLocations));
locationRendering()
.then(() => resolve(renderedLocations))
.catch(reject);
});
},
@@ -271,6 +292,9 @@ const internalNginx = {
debug(logger, `Could not write ${filename}:`, err.message);
reject(new errs.ConfigurationError(err.message));
});
}).catch((err) => {
debug(logger, `Could not render locations for ${filename}:`, err.message);
reject(new errs.ConfigurationError(err.message));
});
});
},
-32
View File
@@ -2,7 +2,6 @@ import _ from "lodash";
import errs from "../lib/error.js";
import { castJsonIfNeed } from "../lib/helpers.js";
import utils from "../lib/utils.js";
import accessListModel from "../models/access_list.js";
import proxyHostModel from "../models/proxy_host.js";
import internalAuditLog from "./audit-log.js";
import internalCertificate from "./certificate.js";
@@ -13,33 +12,6 @@ const omissions = () => {
return ["is_deleted", "owner.is_deleted"];
};
/**
* Fetches access lists for each location that has its own access_list_id.
* Attaches the expanded access_list object (with clients and items) to each location.
*
* @param {Object} host
* @returns {Promise}
*/
const fetchLocationAccessLists = async (host) => {
if (!host.locations?.length) {
return;
}
for (let i = 0; i < host.locations.length; i++) {
const loc = host.locations[i];
if (loc.access_list_id && loc.access_list_id > 0) {
const accessList = await accessListModel
.query()
.where("is_deleted", 0)
.andWhere("id", loc.access_list_id)
.withGraphFetched("[clients,items]")
.first();
if (accessList) {
host.locations[i].access_list = accessList;
}
}
}
};
const internalProxyHost = {
/**
* @param {Access} access
@@ -112,7 +84,6 @@ const internalProxyHost = {
});
})
.then(async (row) => {
await fetchLocationAccessLists(row);
// Configure nginx
return internalNginx.configure(proxyHostModel, "proxy_host", row).then(() => {
return row;
@@ -242,7 +213,6 @@ const internalProxyHost = {
// No need to add nginx config if host is disabled
return row;
}
await fetchLocationAccessLists(row);
// Configure nginx
return internalNginx.configure(proxyHostModel, "proxy_host", row).then((new_meta) => {
row.meta = new_meta;
@@ -373,8 +343,6 @@ const internalProxyHost = {
enabled: 1,
});
await fetchLocationAccessLists(row);
// Configure nginx
await internalNginx.configure(proxyHostModel, "proxy_host", row);
+3
View File
@@ -1,3 +1,4 @@
import net from "node:net";
import Ajv from "ajv/dist/2020.js";
import errs from "../error.js";
@@ -9,6 +10,8 @@ const ajv = new Ajv({
coerceTypes: true,
});
ajv.addFormat("ipv6", { type: "string", validate: (value) => net.isIPv6(value) });
/**
* @param {Object} schema
* @param {Object} payload
+3
View File
@@ -1,3 +1,4 @@
import net from "node:net";
import Ajv from "ajv/dist/2020.js";
import _ from "lodash";
import commonDefinitions from "../../schema/common.json" with { type: "json" };
@@ -14,6 +15,8 @@ const ajv = new Ajv({
schemas: [commonDefinitions],
});
ajv.addFormat("ipv6", { type: "string", validate: (value) => net.isIPv6(value) });
/**
*
* @param {Object} schema
+4
View File
@@ -31,6 +31,10 @@ class AccessList extends Model {
$parseDatabaseJson(json) {
const thisJson = super.$parseDatabaseJson(json);
// Postgres returns COUNT() as a string
if (typeof thisJson.proxy_host_count === "string") {
thisJson.proxy_host_count = Number.parseInt(thisJson.proxy_host_count, 10);
}
return convertIntFieldsToBool(thisJson, boolFields);
}
+1 -1
View File
@@ -44,7 +44,7 @@
},
{
"type": "string",
"format": "^[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}$"
"pattern": "^[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}$"
},
{
"type": "string",