feat: per-path access lists, host logs modal, PostgreSQL support

- Per-path access lists: assign different access lists to individual
  locations on the same proxy host
- Host logs modal: view access/error logs from proxy host dropdown
- PostgreSQL JSON containment query (@>) for location regeneration
- Locale keys: action.logs, column.error
This commit is contained in:
Dimas R. Wisnu
2026-08-07 16:40:32 +07:00
parent a62c2a6dc3
commit 4a0f212289
16 changed files with 434 additions and 78 deletions
+101
View File
@@ -2,7 +2,9 @@ import fs from "node:fs";
import batchflow from "batchflow";
import _ from "lodash";
import errs from "../lib/error.js";
import { isMysql, isPostgres } from "../lib/config.js";
import utils from "../lib/utils.js";
import db from "../db.js";
import { access as logger } from "../logger.js";
import accessListModel from "../models/access_list.js";
import accessListAuthModel from "../models/access_list_auth.js";
@@ -15,6 +17,36 @@ const omissions = () => {
return ["is_deleted"];
};
/**
* Find proxy hosts that reference an access list in their locations JSON.
*
* @param {Integer} accessListId
* @returns {Promise<Array>}
*/
const getProxyHostsUsingAccessListInLocations = async (accessListId) => {
let result;
if (isMysql()) {
const searchObj = JSON.stringify([{ access_list_id: accessListId }]);
result = await db().raw(
`SELECT id FROM proxy_host WHERE is_deleted = 0 AND JSON_CONTAINS(locations, ?, ?)`,
[searchObj, "$"],
);
} else if (isPostgres()) {
result = await db().raw(
`SELECT id FROM proxy_host WHERE is_deleted = 0 AND locations::jsonb @> ?::jsonb`,
[JSON.stringify([{ access_list_id: accessListId }])],
);
} else {
result = await db().raw(
`SELECT id FROM proxy_host WHERE is_deleted = 0 AND locations LIKE ?`,
[`%"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 || [];
};
const internalAccessList = {
/**
* @param {Access} access
@@ -187,6 +219,44 @@ const internalAccessList = {
if (Number.parseInt(freshRow.proxy_host_count, 10)) {
await internalNginx.bulkGenerateConfigs("proxy_host", freshRow.proxy_hosts);
}
// Also regenerate configs for proxy hosts that reference this access list in their locations
const locationHostRows = await getProxyHostsUsingAccessListInLocations(data.id);
if (locationHostRows && locationHostRows.length) {
const locationHostIds = locationHostRows.map((r) => r.id).filter((id) => {
// Exclude hosts already regenerated above
return !freshRow.proxy_hosts || !freshRow.proxy_hosts.find((h) => h.id === id);
});
if (locationHostIds.length) {
const locationHosts = await proxyHostModel.query()
.where("is_deleted", 0)
.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 && 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);
}
}
await internalNginx.reload();
return internalAccessList.maskItems(freshRow);
},
@@ -291,6 +361,37 @@ const internalAccessList = {
await internalNginx.bulkGenerateConfigs("proxy_host", row.proxy_hosts);
}
// Also handle proxy hosts that reference this access list in their locations JSON
const locationHostRows = await getProxyHostsUsingAccessListInLocations(row.id);
if (locationHostRows && locationHostRows.length) {
const locationHostIds = locationHostRows.map((r) => r.id).filter((id) => {
return !row.proxy_hosts || !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 && 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 });
}
}
// 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);
}
}
await internalNginx.reload();
// delete the htpasswd file
+96 -67
View File
@@ -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,33 @@ 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 || !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
@@ -83,28 +111,29 @@ const internalProxyHost = {
expand: ["certificate", "owner", "access_list.[clients,items]"],
});
})
.then((row) => {
// Configure nginx
return internalNginx.configure(proxyHostModel, "proxy_host", row).then(() => {
.then(async (row) => {
await fetchLocationAccessLists(row);
// Configure nginx
return internalNginx.configure(proxyHostModel, "proxy_host", row).then(() => {
return row;
});
})
.then((row) => {
// Audit log
thisData.meta = _.assign({}, thisData.meta || {}, row.meta);
// Add to audit log
return internalAuditLog
.add(access, {
action: "created",
object_type: "proxy-host",
object_id: row.id,
meta: thisData,
})
.then(() => {
return row;
});
})
.then((row) => {
// Audit log
thisData.meta = _.assign({}, thisData.meta || {}, row.meta);
// Add to audit log
return internalAuditLog
.add(access, {
action: "created",
object_type: "proxy-host",
object_id: row.id,
meta: thisData,
})
.then(() => {
return row;
});
});
});
},
/**
@@ -202,24 +231,25 @@ const internalProxyHost = {
});
});
})
.then(() => {
return internalProxyHost
.get(access, {
id: thisData.id,
expand: ["owner", "certificate", "access_list.[clients,items]"],
})
.then((row) => {
if (!row.enabled) {
// No need to add nginx config if host is disabled
return row;
}
// Configure nginx
return internalNginx.configure(proxyHostModel, "proxy_host", row).then((new_meta) => {
row.meta = new_meta;
return _.omit(internalHost.cleanRowCertificateMeta(row), omissions());
});
.then(() => {
return internalProxyHost
.get(access, {
id: thisData.id,
expand: ["owner", "certificate", "access_list.[clients,items]"],
})
.then(async (row) => {
if (!row.enabled) {
// 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;
return _.omit(internalHost.cleanRowCertificateMeta(row), omissions());
});
});
});
});
},
/**
@@ -326,39 +356,38 @@ const internalProxyHost = {
expand: ["certificate", "owner", "access_list"],
});
})
.then((row) => {
if (!row?.id) {
throw new errs.ItemNotFoundError(data.id);
}
if (row.enabled) {
throw new errs.ValidationError("Host is already enabled");
}
.then(async (row) => {
if (!row?.id) {
throw new errs.ItemNotFoundError(data.id);
}
if (row.enabled) {
throw new errs.ValidationError("Host is already enabled");
}
row.enabled = 1;
row.enabled = 1;
return proxyHostModel
.query()
.where("id", row.id)
.patch({
enabled: 1,
})
.then(() => {
// Configure nginx
return internalNginx.configure(proxyHostModel, "proxy_host", row);
})
.then(() => {
// Add to audit log
return internalAuditLog.add(access, {
action: "enabled",
object_type: "proxy-host",
object_id: row.id,
meta: _.omit(row, omissions()),
});
});
})
.then(() => {
return true;
await proxyHostModel
.query()
.where("id", row.id)
.patch({
enabled: 1,
});
await fetchLocationAccessLists(row);
// Configure nginx
await internalNginx.configure(proxyHostModel, "proxy_host", row);
// Add to audit log
await internalAuditLog.add(access, {
action: "enabled",
object_type: "proxy-host",
object_id: row.id,
meta: _.omit(row, omissions()),
});
return true;
});
},
/**
+67
View File
@@ -1,4 +1,5 @@
import express from "express";
import fs from "node:fs";
import internalProxyHost from "../../internal/proxy-host.js";
import jwtdecode from "../../lib/express/jwt-decode.js";
import apiValidator from "../../lib/validator/api.js";
@@ -206,4 +207,70 @@ router
}
});
/**
* Proxy-host logs
*
* /api/nginx/proxy-hosts/123/logs
*/
router
.route("/:host_id/logs")
.options((_, res) => {
res.sendStatus(204);
})
.all(jwtdecode())
/**
* GET /api/nginx/proxy-hosts/123/logs
*
* Retrieve logs for a specific proxy-host
*/
.get(async (req, res, next) => {
try {
const data = await validator(
{
required: ["host_id"],
additionalProperties: false,
properties: {
host_id: {
$ref: "common#/properties/id",
},
type: {
type: "string",
enum: ["access", "error"],
},
},
},
{
host_id: req.params.host_id,
type: req.query.type || "access",
},
);
const hostId = Number.parseInt(data.host_id, 10);
const logType = data.type === "error" ? "error" : "access";
const logFile = `/data/logs/proxy-host-${hostId}_${logType}.log`;
// Check access permission
await res.locals.access.can("proxy_hosts:get", hostId);
let logs = "";
if (fs.existsSync(logFile)) {
const content = fs.readFileSync(logFile, { encoding: "utf8" });
const lines = content.split("\n");
// Return last 1000 lines to avoid huge payloads
const maxLines = 1000;
if (lines.length > maxLines) {
logs = lines.slice(-maxLines).join("\n");
} else {
logs = content;
}
}
res.status(200).send({ logs });
} catch (err) {
debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`);
next(err);
}
});
export default router;
@@ -124,6 +124,9 @@
},
"advanced_config": {
"type": "string"
},
"access_list_id": {
"$ref": "../common.json#/properties/access_list_id"
}
}
},
@@ -132,7 +135,8 @@
"path": "/app",
"forward_scheme": "http",
"forward_host": "example.com",
"forward_port": 80
"forward_port": 80,
"access_list_id": 0
}
]
},