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
}
]
},
@@ -0,0 +1,8 @@
import * as api from "./base";
export async function getProxyHostLogs(id: number, type: "access" | "error" = "access"): Promise<{ logs: string }> {
return await api.get({
url: `/nginx/proxy-hosts/${id}/logs`,
params: { type },
});
}
+1
View File
@@ -27,6 +27,7 @@ export * from "./getDeadHosts";
export * from "./getHealth";
export * from "./getHostsReport";
export * from "./getProxyHost";
export * from "./getProxyHostLogs";
export * from "./getProxyHosts";
export * from "./getRedirectionHost";
export * from "./getRedirectionHosts";
+2
View File
@@ -103,6 +103,8 @@ export interface ProxyLocation {
forwardScheme: string;
forwardHost: string;
forwardPort: number;
accessListId?: number;
accessList?: AccessList;
}
export interface ProxyHost {
+5 -1
View File
@@ -31,14 +31,18 @@ interface Props {
id?: string;
name?: string;
label?: string;
onFormChange?: (value: number) => void;
}
export function AccessField({ name = "accessListId", label = "access-list", id = "accessListId" }: Props) {
export function AccessField({ name = "accessListId", label = "access-list", id = "accessListId", onFormChange }: Props) {
const { locale } = useLocaleState();
const { isLoading, isError, error, data } = useAccessLists(["owner", "items", "clients"]);
const { setFieldValue } = useFormikContext();
const handleChange = (newValue: any, _actionMeta: ActionMeta<AccessOption>) => {
setFieldValue(name, newValue?.value);
if (onFormChange) {
onFormChange(newValue?.value ?? 0);
}
};
const options: AccessOption[] =
@@ -5,6 +5,7 @@ import { useFormikContext } from "formik";
import { useState } from "react";
import type { ProxyLocation } from "src/api/backend";
import { intl, T } from "src/locale";
import { AccessField } from "./AccessField";
import styles from "./LocationsFields.module.css";
interface Props {
@@ -22,6 +23,7 @@ export function LocationsFields({ initialValues, name = "locations" }: Props) {
forwardScheme: "http",
forwardHost: "",
forwardPort: 80,
accessListId: 0,
};
const toggleAdvVisible = (idx: number) => {
@@ -44,6 +46,12 @@ export function LocationsFields({ initialValues, name = "locations" }: Props) {
setFormField(newValues);
};
const handleAccessListChange = (idx: number, accessListId: number) => {
const newValues = values.map((v: ProxyLocation, i: number) => (i === idx ? { ...v, accessListId } : v));
setValues(newValues);
setFormField(newValues);
};
const setFormField = (newValues: ProxyLocation[]) => {
const filtered = newValues.filter((v: ProxyLocation) => v?.path?.trim() !== "");
setFieldValue(name, filtered);
@@ -141,6 +149,12 @@ export function LocationsFields({ initialValues, name = "locations" }: Props) {
</div>
</div>
</div>
<AccessField
name={`locations[${idx}].accessListId`}
label="access-list"
id={`locations-access-list-${idx}`}
onFormChange={(value) => handleAccessListChange(idx, value)}
/>
{advVisible.includes(idx) && (
<div className="">
<CodeEditor
+1
View File
@@ -11,6 +11,7 @@ export * from "./useDnsProviders";
export * from "./useHealth";
export * from "./useHostReport";
export * from "./useProxyHost";
export * from "./useProxyHostLogs";
export * from "./useProxyHosts";
export * from "./useRedirectionHost";
export * from "./useRedirectionHosts";
+12
View File
@@ -0,0 +1,12 @@
import { useQuery } from "@tanstack/react-query";
import { getProxyHostLogs } from "src/api/backend";
const useProxyHostLogs = (id: number, type: "access" | "error" = "access") => {
return useQuery<{ logs: string }, Error>({
queryKey: ["proxy-host-logs", id, type],
queryFn: () => getProxyHostLogs(id, type),
staleTime: 10_000,
});
};
export { useProxyHostLogs };
+6
View File
@@ -122,6 +122,9 @@
"action.enable": {
"defaultMessage": "Enable"
},
"action.logs": {
"defaultMessage": "Logs"
},
"action.permissions": {
"defaultMessage": "Permissions"
},
@@ -248,6 +251,9 @@
"column.access": {
"defaultMessage": "Access"
},
"column.error": {
"defaultMessage": "Error"
},
"column.authorization": {
"defaultMessage": "Authorization"
},
+93
View File
@@ -0,0 +1,93 @@
import CodeEditor from "@uiw/react-textarea-code-editor";
import EasyModal, { type InnerModalProps } from "ez-modal-react";
import { useState } from "react";
import { Alert } from "react-bootstrap";
import Modal from "react-bootstrap/Modal";
import { Button, Loading } from "src/components";
import { useProxyHostLogs } from "src/hooks";
import { T } from "src/locale";
const showHostLogsModal = (id: number) => {
EasyModal.show(HostLogsModal, { id });
};
interface Props extends InnerModalProps {
id: number;
}
const HostLogsModal = EasyModal.create(({ id, visible, remove }: Props) => {
const [logType, setLogType] = useState<"access" | "error">("access");
const { data, isLoading, error } = useProxyHostLogs(id, logType);
return (
<Modal show={visible} onHide={remove} size="lg" scrollable>
{!isLoading && error && (
<Alert variant="danger" className="m-3">
{error?.message || "Unknown error"}
</Alert>
)}
<Modal.Header closeButton>
<Modal.Title>
<T id="action.logs" />
</Modal.Title>
</Modal.Header>
<Modal.Body>
<div className="mb-3">
<ul className="nav nav-tabs">
<li className="nav-item">
<a
href="#"
className={`nav-link ${logType === "access" ? "active" : ""}`}
onClick={(e) => {
e.preventDefault();
setLogType("access");
}}
>
<T id="column.access" />
</a>
</li>
<li className="nav-item">
<a
href="#"
className={`nav-link ${logType === "error" ? "active" : ""}`}
onClick={(e) => {
e.preventDefault();
setLogType("error");
}}
>
<T id="column.error" />
</a>
</li>
</ul>
</div>
{isLoading ? (
<Loading noLogo />
) : (
<div style={{ maxHeight: "60vh", overflow: "auto" }}>
<CodeEditor
language="text"
padding={15}
data-color-mode="dark"
indentWidth={2}
style={{
fontFamily:
"ui-monospace,SFMono-Regular,SF Mono,Consolas,Liberation Mono,Menlo,monospace",
borderRadius: "0.3rem",
backgroundColor: "var(--tblr-bg-surface-dark)",
fontSize: "12px",
}}
readOnly
value={data?.logs || ""}
/>
</div>
)}
</Modal.Body>
<Modal.Footer>
<Button data-bs-dismiss="modal" onClick={remove}>
<T id="action.close" />
</Button>
</Modal.Footer>
</Modal>
);
});
export { showHostLogsModal };
+1
View File
@@ -6,6 +6,7 @@ export * from "./DeleteConfirmModal";
export * from "./DNSCertificateModal";
export * from "./EventDetailsModal";
export * from "./HelpModal";
export * from "./HostLogsModal";
export * from "./HTTPCertificateModal";
export * from "./PermissionsModal";
export * from "./ProxyHostModal";
+20 -8
View File
@@ -1,4 +1,4 @@
import { IconDotsVertical, IconEdit, IconPower, IconTrash } from "@tabler/icons-react";
import { IconDotsVertical, IconEdit, IconFileText, IconPower, IconTrash } from "@tabler/icons-react";
import {
createColumnHelper,
getCoreRowModel,
@@ -28,9 +28,10 @@ interface Props {
onEdit?: (id: number) => void;
onDelete?: (id: number) => void;
onDisableToggle?: (id: number, enabled: boolean) => void;
onLogs?: (id: number) => void;
onNew?: () => void;
}
export default function Table({ data, isFetching, onEdit, onDelete, onDisableToggle, onNew, isFiltered }: Props) {
export default function Table({ data, isFetching, onEdit, onDelete, onDisableToggle, onLogs, onNew, isFiltered }: Props) {
const columnHelper = createColumnHelper<ProxyHost>();
const columns = useMemo(
() => [
@@ -115,19 +116,30 @@ export default function Table({ data, isFetching, onEdit, onDelete, onDisableTog
data={{ id: info.row.original.id }}
/>
</span>
<a
className="dropdown-item"
href="#"
onClick={(e) => {
e.preventDefault();
onEdit?.(info.row.original.id);
}}
>
<IconEdit size={16} />
<T id="action.edit" />
</a>
<HasPermission section={PROXY_HOSTS} permission={MANAGE} hideError>
<a
className="dropdown-item"
href="#"
onClick={(e) => {
e.preventDefault();
onEdit?.(info.row.original.id);
onLogs?.(info.row.original.id);
}}
>
<IconEdit size={16} />
<T id="action.edit" />
<IconFileText size={16} />
<T id="action.logs" />
</a>
<HasPermission section={PROXY_HOSTS} permission={MANAGE} hideError>
<a
<a
className="dropdown-item"
href="#"
onClick={(e) => {
@@ -160,7 +172,7 @@ export default function Table({ data, isFetching, onEdit, onDelete, onDisableTog
},
}),
],
[columnHelper, onEdit, onDisableToggle, onDelete],
[columnHelper, onEdit, onDisableToggle, onDelete, onLogs],
);
const [sorting, setSorting] = useState<SortingState>([]);
@@ -6,7 +6,7 @@ import { deleteProxyHost, toggleProxyHost } from "src/api/backend";
import { Button, HasPermission, LoadingPage } from "src/components";
import { useProxyHosts } from "src/hooks";
import { T } from "src/locale";
import { showDeleteConfirmModal, showHelpModal, showProxyHostModal } from "src/modals";
import { showDeleteConfirmModal, showHelpModal, showHostLogsModal, showProxyHostModal } from "src/modals";
import { MANAGE, PROXY_HOSTS } from "src/modules/Permissions";
import { showObjectSuccess } from "src/notifications";
import Table from "./Table";
@@ -99,6 +99,7 @@ export default function TableWrapper() {
isFiltered={!!search}
isFetching={isFetching}
onEdit={(id: number) => showProxyHostModal(id)}
onLogs={(id: number) => showHostLogsModal(id)}
onDelete={(id: number) => {
const host = data?.find((h) => h.id === id);
showDeleteConfirmModal({