mirror of
https://github.com/NginxProxyManager/nginx-proxy-manager.git
synced 2026-09-24 13:40:19 +01:00
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:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user