diff --git a/backend/internal/access-list.js b/backend/internal/access-list.js index 60a7105dbe..14ab328131 100644 --- a/backend/internal/access-list.js +++ b/backend/internal/access-list.js @@ -15,6 +15,47 @@ const omissions = () => { return ["is_deleted"]; }; +// Build a map of ACL id -> usage count from locations only (host-level usage is counted by SQL) +const buildAccessListUsageMap = async () => { + const usage = {}; + const hosts = await proxyHostModel.query().select("locations").where("is_deleted", 0); + hosts.forEach((host) => { + if (Array.isArray(host.locations)) { + host.locations.forEach((loc) => { + if (loc && loc.use_parent_access_list === false && loc.access_list_id) { + usage[loc.access_list_id] = (usage[loc.access_list_id] || 0) + 1; + } + }); + } + }); + return usage; +}; + +// Find proxy hosts that reference an ACL either directly or via locations +const findProxyHostsUsingAcl = async (accessListId) => { + const hosts = await proxyHostModel.query().where("is_deleted", 0); + return hosts.filter((host) => { + if (host.access_list_id === accessListId) return true; + if (Array.isArray(host.locations)) { + return host.locations.some( + (loc) => loc && loc.use_parent_access_list === false && loc.access_list_id === accessListId, + ); + } + return false; + }); +}; + +// Fetch proxy hosts with full expansions needed for config generation +const fetchHostsForConfigGeneration = async (hostIds) => { + if (!hostIds.length) return []; + const hosts = await proxyHostModel + .query() + .whereIn("id", hostIds) + .where("is_deleted", 0) + .withGraphFetched("[certificate, access_list.[clients,items]]"); + return hosts; +}; + const internalAccessList = { /** * @param {Access} access @@ -194,9 +235,29 @@ const internalAccessList = { true // skip masking ); - await internalAccessList.build(freshRow) - if (Number.parseInt(freshRow.proxy_host_count, 10)) { - await internalNginx.bulkGenerateConfigs("proxy_host", freshRow.proxy_hosts); + // Find all hosts that use this ACL (directly or via locations) + const allAffectedHosts = await findProxyHostsUsingAcl(data.id); + const hostIds = allAffectedHosts.map((h) => h.id); + + // Re-fetch with proper expansions for config generation + const hostsForConfig = await fetchHostsForConfigGeneration(hostIds); + + // Enrich locations with the updated ACL data + for (const host of hostsForConfig) { + if (Array.isArray(host.locations)) { + host.locations.forEach((loc) => { + if (loc && loc.use_parent_access_list === false && loc.access_list_id === data.id) { + loc.access_list = freshRow; + } + }); + } + } + + freshRow.proxy_host_count = hostIds.length; + + await internalAccessList.build(freshRow); + if (hostsForConfig.length) { + await internalNginx.bulkGenerateConfigs("proxy_host", hostsForConfig); } await internalNginx.reload(); return internalAccessList.maskItems(freshRow); @@ -215,6 +276,7 @@ const internalAccessList = { const thisData = data || {}; const accessData = await access.can("access_lists:get", thisData.id) + const usageMap = await buildAccessListUsageMap(); const query = accessListModel .query() .select("access_list.*", accessListModel.raw("COUNT(proxy_host.id) as proxy_host_count")) @@ -240,6 +302,8 @@ const internalAccessList = { } let row = await query.then(utils.omitRow(omissions())); + row.proxy_host_count = Number.parseInt(row.proxy_host_count || 0, 10); + row.location_count = usageMap[row.id] || 0; if (!row || !row.id) { throw new errs.ItemNotFoundError(thisData.id); @@ -286,20 +350,36 @@ const internalAccessList = { }); // 2. update any proxy hosts that were using it (ignoring permissions) - if (row.proxy_hosts) { - 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; - }); + const affectedHosts = await findProxyHostsUsingAcl(row.id); + const hostIds = affectedHosts.map((h) => h.id); + + if (affectedHosts.length) { + // clear direct ACL assignment + await proxyHostModel.query().where("access_list_id", "=", row.id).patch({ access_list_id: 0 }); + + // clear location-level ACLs + await Promise.all( + affectedHosts.map(async (host) => { + if (!Array.isArray(host.locations)) return; + const updatedLocations = host.locations.map((loc) => { + if (loc && loc.use_parent_access_list === false && loc.access_list_id === row.id) { + return { + ...loc, + use_parent_access_list: true, + access_list_id: 0, + access_list: null, + }; + } + return loc; + }); + await proxyHostModel.query().where("id", host.id).patch({ locations: updatedLocations }); + return true; + }), + ); - await internalNginx.bulkGenerateConfigs("proxy_host", row.proxy_hosts); + // 3. re-fetch hosts with proper expansions for config generation + const hostsForConfig = await fetchHostsForConfigGeneration(hostIds); + await internalNginx.bulkGenerateConfigs("proxy_host", hostsForConfig); } await internalNginx.reload(); @@ -331,6 +411,7 @@ const internalAccessList = { */ getAll: async (access, expand, searchQuery) => { const accessData = await access.can("access_lists:list"); + const usageMap = await buildAccessListUsageMap(); const query = accessListModel .query() @@ -365,8 +446,10 @@ const internalAccessList = { const rows = await query.then(utils.omitRows(omissions())); if (rows) { rows.map((row, idx) => { + rows[idx].proxy_host_count = Number.parseInt(row.proxy_host_count || 0, 10); + rows[idx].location_count = usageMap[row.id] || 0; if (typeof row.items !== "undefined" && row.items) { - rows[idx] = internalAccessList.maskItems(row); + rows[idx] = internalAccessList.maskItems(rows[idx]); } return true; }); diff --git a/backend/internal/nginx.js b/backend/internal/nginx.js index fe84607f96..12da8b52a1 100644 --- a/backend/internal/nginx.js +++ b/backend/internal/nginx.js @@ -149,9 +149,21 @@ const internalNginx = { const locationRendering = async () => { for (let i = 0; i < host.locations.length; i++) { + const location = host.locations[i]; + + // Determine which access list to use + // If use_parent_access_list is false and access_list_id is set, use location-specific access list + // Otherwise, use parent proxy host access list (default behavior) + const useParentAccessList = location.use_parent_access_list !== false; // default to true + const effectiveAccessListId = useParentAccessList ? host.access_list_id : (location.access_list_id || 0); + const effectiveAccessList = useParentAccessList ? host.access_list : location.access_list; + + // Spread location first, then override with effective ACL and host-level settings const locationCopy = Object.assign( {}, - { access_list_id: host.access_list_id }, + location, + { access_list_id: effectiveAccessListId }, + { access_list: effectiveAccessList }, { certificate_id: host.certificate_id }, { ssl_forced: host.ssl_forced }, { caching_enabled: host.caching_enabled }, @@ -160,9 +172,7 @@ const internalNginx = { { http2_support: host.http2_support }, { hsts_enabled: host.hsts_enabled }, { hsts_subdomains: host.hsts_subdomains }, - { access_list: host.access_list }, { certificate: host.certificate }, - host.locations[i], ); if (locationCopy.forward_host.indexOf("/") > -1) { diff --git a/backend/internal/proxy-host.js b/backend/internal/proxy-host.js index 3299012a6b..60eeb74ef4 100644 --- a/backend/internal/proxy-host.js +++ b/backend/internal/proxy-host.js @@ -2,6 +2,7 @@ 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"; @@ -12,6 +13,23 @@ const omissions = () => { return ["is_deleted", "owner.is_deleted"]; }; +// Normalize location ACL flags for persistence +const normalizeLocations = (locations) => { + if (!Array.isArray(locations)) return locations; + return locations.map((loc) => { + if (!loc) return loc; + const normalized = { ...loc }; + if (normalized.use_parent_access_list !== false) { + normalized.use_parent_access_list = true; + normalized.access_list_id = normalized.access_list_id || 0; + delete normalized.access_list; // avoid stale embedded ACLs in DB JSON + } else { + normalized.access_list_id = normalized.access_list_id || 0; + } + return normalized; + }); +}; + const internalProxyHost = { /** * @param {Access} access @@ -20,6 +38,7 @@ const internalProxyHost = { */ create: (access, data) => { let thisData = data; + thisData.locations = normalizeLocations(thisData.locations); const createCertificate = thisData.certificate_id === "new"; if (createCertificate) { @@ -115,6 +134,7 @@ const internalProxyHost = { */ update: (access, data) => { let thisData = data; + thisData.locations = normalizeLocations(thisData.locations); const create_certificate = thisData.certificate_id === "new"; if (create_certificate) { @@ -258,11 +278,15 @@ const internalProxyHost = { throw new errs.ItemNotFoundError(thisData.id); } const thisRow = internalHost.cleanRowCertificateMeta(row); - // Custom omissions - if (typeof thisData.omit !== "undefined" && thisData.omit !== null) { - return _.omit(row, thisData.omit); - } - return thisRow; + + // Load access lists for locations if they have custom access lists + return internalProxyHost.enrichLocationsWithAccessLists(thisRow).then(() => { + // Custom omissions + if (typeof thisData.omit !== "undefined" && thisData.omit !== null) { + return _.omit(thisRow, thisData.omit); + } + return thisRow; + }); }); }, @@ -469,6 +493,85 @@ const internalProxyHost = { return Number.parseInt(row.count, 10); }); }, + + /** + * Enriches locations with their access lists if they have custom ones + * + * @param {Object} row - Proxy host row with locations + * @returns {Promise} + */ + enrichLocationsWithAccessLists: async (row) => { + if (!row.locations || !Array.isArray(row.locations) || row.locations.length === 0) { + return row; + } + + // Find all unique access list IDs that need to be loaded + const accessListIds = []; + row.locations.forEach((location) => { + if (location.use_parent_access_list === false && location.access_list_id && location.access_list_id > 0) { + if (!accessListIds.includes(location.access_list_id)) { + accessListIds.push(location.access_list_id); + } + } + }); + + // If no custom access lists, return as-is + if (accessListIds.length === 0) { + return row; + } + + // Load all needed access lists; include proxy_host_count to satisfy schema + const accessLists = await accessListModel + .query() + .select("access_list.*", accessListModel.raw("COUNT(proxy_host.id) as proxy_host_count")) + .leftJoin("proxy_host", function () { + this.on("proxy_host.access_list_id", "=", "access_list.id").andOn("proxy_host.is_deleted", "=", 0); + }) + .whereIn("access_list.id", accessListIds) + .where("access_list.is_deleted", 0) + .groupBy("access_list.id") + .withGraphFetched("[clients, items]"); + + // Create a map for easy lookup + const accessListMap = {}; + accessLists.forEach((al) => { + const withCount = { + ...al, + proxy_host_count: typeof al.proxy_host_count === "number" ? al.proxy_host_count : 0, + location_count: 0, // Will be calculated below + }; + accessListMap[al.id] = withCount; + }); + + // Count location usage + const locationUsage = {}; + row.locations.forEach((location) => { + if (location.use_parent_access_list === false && location.access_list_id) { + locationUsage[location.access_list_id] = (locationUsage[location.access_list_id] || 0) + 1; + } + }); + + // Attach access lists to their respective locations and update counts + row.locations.forEach((location) => { + if (location.use_parent_access_list === false && location.access_list_id) { + const found = accessListMap[location.access_list_id]; + if (found) { + const usageCount = locationUsage[location.access_list_id] || 0; + location.access_list = { + ...found, + location_count: usageCount, + }; + } else { + // Missing ACL (deleted) → fall back to parent/public + location.use_parent_access_list = true; + location.access_list_id = 0; + delete location.access_list; + } + } + }); + + return row; + }, }; export default internalProxyHost; diff --git a/backend/schema/components/access-list-object.json b/backend/schema/components/access-list-object.json index d80eb06d8f..f648b60f1f 100644 --- a/backend/schema/components/access-list-object.json +++ b/backend/schema/components/access-list-object.json @@ -1,7 +1,7 @@ { "type": "object", "description": "Access List object", - "required": ["id", "created_on", "modified_on", "owner_user_id", "name", "meta", "satisfy_any", "pass_auth", "proxy_host_count"], + "required": ["id", "created_on", "modified_on", "owner_user_id", "name", "meta", "satisfy_any", "pass_auth", "proxy_host_count", "location_count"], "properties": { "id": { "$ref": "../common.json#/properties/id" @@ -36,6 +36,11 @@ "type": "integer", "minimum": 0, "example": 3 + }, + "location_count": { + "type": "integer", + "minimum": 0, + "example": 1 } } } diff --git a/backend/schema/components/proxy-host-object.json b/backend/schema/components/proxy-host-object.json index 464b188e82..ff62d6693d 100644 --- a/backend/schema/components/proxy-host-object.json +++ b/backend/schema/components/proxy-host-object.json @@ -123,6 +123,26 @@ }, "advanced_config": { "type": "string" + }, + "use_parent_access_list": { + "type": "boolean", + "default": true, + "description": "Whether to use the parent proxy host's access list or a custom one" + }, + "access_list_id": { + "$ref": "../common.json#/properties/access_list_id" + }, + "access_list": { + "oneOf": [ + { + "type": "null", + "example": null + }, + { + "$ref": "./access-list-object.json" + } + ], + "example": null } } }, diff --git a/backend/schema/paths/nginx/access-lists/get.json b/backend/schema/paths/nginx/access-lists/get.json index ada40f5bf7..842aac597e 100644 --- a/backend/schema/paths/nginx/access-lists/get.json +++ b/backend/schema/paths/nginx/access-lists/get.json @@ -39,7 +39,8 @@ "meta": {}, "satisfy_any": true, "pass_auth": false, - "proxy_host_count": 0 + "proxy_host_count": 0, + "location_count": 0 }, "schema": { "$ref": "../../../components/access-list-object.json" diff --git a/backend/schema/paths/nginx/access-lists/listID/get.json b/backend/schema/paths/nginx/access-lists/listID/get.json index 9705826f20..584f1d1be7 100644 --- a/backend/schema/paths/nginx/access-lists/listID/get.json +++ b/backend/schema/paths/nginx/access-lists/listID/get.json @@ -40,7 +40,8 @@ "meta": {}, "satisfy_any": false, "pass_auth": false, - "proxy_host_count": 1 + "proxy_host_count": 1, + "location_count": 0 } } }, diff --git a/backend/schema/paths/nginx/access-lists/listID/put.json b/backend/schema/paths/nginx/access-lists/listID/put.json index 61e8044013..aa42b9de14 100644 --- a/backend/schema/paths/nginx/access-lists/listID/put.json +++ b/backend/schema/paths/nginx/access-lists/listID/put.json @@ -84,6 +84,7 @@ "satisfy_any": true, "pass_auth": false, "proxy_host_count": 0, + "location_count": 0, "owner": { "id": 1, "created_on": "2024-10-07T22:43:55.000Z", diff --git a/backend/schema/paths/nginx/access-lists/post.json b/backend/schema/paths/nginx/access-lists/post.json index 38b7003a1e..e070201793 100644 --- a/backend/schema/paths/nginx/access-lists/post.json +++ b/backend/schema/paths/nginx/access-lists/post.json @@ -75,6 +75,7 @@ "satisfy_any": true, "pass_auth": false, "proxy_host_count": 0, + "location_count": 0, "owner": { "id": 1, "created_on": "2024-10-07T22:43:55.000Z", diff --git a/frontend/src/api/backend/models.ts b/frontend/src/api/backend/models.ts index d63d47aecc..95e792e5f6 100644 --- a/frontend/src/api/backend/models.ts +++ b/frontend/src/api/backend/models.ts @@ -54,6 +54,7 @@ export interface AccessList { satisfyAny: boolean; passAuth: boolean; proxyHostCount?: number; + locationCount?: number; // Expansions: owner?: User; items?: AccessListItem[]; @@ -103,6 +104,10 @@ export interface ProxyLocation { forwardScheme: string; forwardHost: string; forwardPort: number; + useParentAccessList?: boolean; + accessListId?: number; + // Expansions: + accessList?: AccessList; } export interface ProxyHost { diff --git a/frontend/src/components/Form/LocationsFields.tsx b/frontend/src/components/Form/LocationsFields.tsx index 4240b1f986..4013bbcf02 100644 --- a/frontend/src/components/Form/LocationsFields.tsx +++ b/frontend/src/components/Form/LocationsFields.tsx @@ -4,6 +4,7 @@ import cn from "classnames"; import { useFormikContext } from "formik"; import { useState } from "react"; import type { ProxyLocation } from "src/api/backend"; +import { AccessField } from "src/components/Form/AccessField"; import { intl, T } from "src/locale"; import styles from "./LocationsFields.module.css"; @@ -22,6 +23,8 @@ export function LocationsFields({ initialValues, name = "locations" }: Props) { forwardScheme: "http", forwardHost: "", forwardPort: 80, + useParentAccessList: true, + accessListId: 0, }; const toggleAdvVisible = (idx: number) => { @@ -38,8 +41,16 @@ export function LocationsFields({ initialValues, name = "locations" }: Props) { setFormField(newValues); }; - const handleChange = (idx: number, field: string, fieldValue: string) => { - const newValues = values.map((v: ProxyLocation, i: number) => (i === idx ? { ...v, [field]: fieldValue } : v)); + const handleChange = (idx: number, field: string, fieldValue: string | number | boolean) => { + const newValues = values.map((v: ProxyLocation, i: number) => { + if (i !== idx) return v; + // When switching back to parent ACL, drop custom ACL references to keep state consistent + if (field === "useParentAccessList" && fieldValue === true) { + const { accessList, ...rest } = v as any; + return { ...rest, useParentAccessList: true, accessListId: undefined } as ProxyLocation; + } + return { ...v, [field]: fieldValue } as ProxyLocation; + }); setValues(newValues); setFormField(newValues); }; @@ -141,6 +152,36 @@ export function LocationsFields({ initialValues, name = "locations" }: Props) { +
+
+
+ + + + +
+
+
+ {item.useParentAccessList === false && ( +
+
+ +
+
+ )} {advVisible.includes(idx) && (
, }), + columnHelper.accessor((row: any) => row.locationCount, { + id: "locationCount", + header: intl.formatMessage({ id: "locations" }), + cell: (info: any) => , + }), columnHelper.display({ id: "id", cell: (info: any) => {