mirror of
https://github.com/NginxProxyManager/nginx-proxy-manager.git
synced 2026-09-24 19:40:23 +01:00
The stream table's forward_ip column was renamed to forwarding_host back in migration 20210423103500_stream_domain.js, so the stream log source labels were always showing "undefined" for the forwarding target. Spotted by @jc21 in review.
230 lines
7.1 KiB
JavaScript
230 lines
7.1 KiB
JavaScript
import fs from "node:fs";
|
|
import errs from "../lib/error.js";
|
|
import internalDeadHost from "./dead-host.js";
|
|
import internalProxyHost from "./proxy-host.js";
|
|
import internalRedirectionHost from "./redirection-host.js";
|
|
import internalStream from "./stream.js";
|
|
|
|
const SYSTEM_LOG_FILE = "/data/logs/backend.log";
|
|
const LETSENCRYPT_LOG_FILE = "/data/logs/letsencrypt.log";
|
|
|
|
// Matches the access_log/error_log paths written by the nginx templates
|
|
// (see backend/templates/{proxy_host,redirection_host,dead_host,stream}.conf).
|
|
// This is a fixed, server-side lookup table - a host log path is always derived
|
|
// from a validated `host_type` enum + numeric `host_id`, never from a client-supplied
|
|
// path or filename, so there is no path-traversal surface here.
|
|
const HOST_FILE_PREFIX = {
|
|
proxy: "proxy-host",
|
|
redirection: "redirection-host",
|
|
dead: "dead-host",
|
|
stream: "stream",
|
|
};
|
|
|
|
const DEFAULT_LINES = 200;
|
|
const MAX_LINES = 1000;
|
|
const CHUNK_SIZE = 64 * 1024;
|
|
// Never scan further back than this, regardless of how many lines were requested,
|
|
// so a huge or pathological log file can't turn a single request into unbounded I/O.
|
|
const MAX_SCAN_BYTES = 5 * 1024 * 1024;
|
|
|
|
/**
|
|
* Reads at most `maxLines` lines from the end of a file, without loading the
|
|
* whole file into memory. Reads backwards in fixed-size chunks until enough
|
|
* newlines have been seen, the start of the file is reached, or the hard
|
|
* MAX_SCAN_BYTES ceiling is hit.
|
|
*
|
|
* @param {String} filePath
|
|
* @param {Number} maxLines
|
|
* @returns {Promise<{lines: String[], size: Number, truncated: Boolean}>}
|
|
*/
|
|
const readLastLines = async (filePath, maxLines) => {
|
|
let handle;
|
|
try {
|
|
handle = await fs.promises.open(filePath, "r");
|
|
const stat = await handle.stat();
|
|
const { size } = stat;
|
|
|
|
if (size === 0) {
|
|
return { lines: [], size: 0, truncated: false };
|
|
}
|
|
|
|
let position = size;
|
|
let scanned = 0;
|
|
let newlineCount = 0;
|
|
const chunks = [];
|
|
|
|
while (position > 0 && newlineCount <= maxLines && scanned < MAX_SCAN_BYTES) {
|
|
const readSize = Math.min(CHUNK_SIZE, position);
|
|
position -= readSize;
|
|
const buffer = Buffer.alloc(readSize);
|
|
await handle.read(buffer, 0, readSize, position);
|
|
scanned += readSize;
|
|
for (let i = buffer.length - 1; i >= 0; i--) {
|
|
if (buffer[i] === 0x0a) newlineCount++;
|
|
}
|
|
chunks.unshift(buffer);
|
|
}
|
|
|
|
const truncated = position > 0 && scanned >= MAX_SCAN_BYTES;
|
|
|
|
// If we didn't start reading from byte 0, the first line in our buffer is only
|
|
// partial *unless* it happens that `position` landed exactly on a line boundary
|
|
// (the byte right before it is a newline) - check that one byte to avoid
|
|
// silently dropping a perfectly valid line.
|
|
let startsOnLineBoundary = position === 0;
|
|
if (position > 0) {
|
|
const boundaryByte = Buffer.alloc(1);
|
|
await handle.read(boundaryByte, 0, 1, position - 1);
|
|
startsOnLineBoundary = boundaryByte[0] === 0x0a;
|
|
}
|
|
|
|
const text = Buffer.concat(chunks).toString("utf8");
|
|
const allLines = text.split("\n");
|
|
|
|
if (!startsOnLineBoundary && allLines.length > 0) {
|
|
allLines.shift();
|
|
}
|
|
// Drop the trailing empty element caused by a final trailing newline.
|
|
if (allLines.length > 0 && allLines[allLines.length - 1] === "") {
|
|
allLines.pop();
|
|
}
|
|
|
|
return { lines: allLines.slice(-maxLines), size, truncated };
|
|
} finally {
|
|
if (handle) {
|
|
await handle.close();
|
|
}
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Resolves a validated {type, host_type, host_id, channel} selection to the
|
|
* fixed, absolute path of the log file on disk. Throws if the combination
|
|
* isn't a recognised source.
|
|
*
|
|
* `channel` ("access" | "error") picks which of the two log files nginx writes
|
|
* per host - it's deliberately not named "stream" to avoid confusion with the
|
|
* "stream" host_type (TCP/UDP stream hosts).
|
|
*
|
|
* @param {Object} data
|
|
* @returns {String}
|
|
*/
|
|
const resolveFilePath = (data) => {
|
|
switch (data.type) {
|
|
case "system":
|
|
return SYSTEM_LOG_FILE;
|
|
case "letsencrypt":
|
|
return LETSENCRYPT_LOG_FILE;
|
|
case "host": {
|
|
const prefix = HOST_FILE_PREFIX[data.host_type];
|
|
if (!prefix || !data.host_id || !["access", "error"].includes(data.channel)) {
|
|
throw new errs.ValidationError("Invalid host log source");
|
|
}
|
|
return `/data/logs/${prefix}-${data.host_id}_${data.channel}.log`;
|
|
}
|
|
default:
|
|
throw new errs.ItemNotFoundError(data.type);
|
|
}
|
|
};
|
|
|
|
/**
|
|
* @param {Array} rows
|
|
* @returns {Array}
|
|
*/
|
|
const toHostOptions = (rows) =>
|
|
rows.map((row) => ({
|
|
id: row.id,
|
|
label: Array.isArray(row.domain_names) ? row.domain_names.join(", ") : `Host #${row.id}`,
|
|
}));
|
|
|
|
const internalLogViewer = {
|
|
/**
|
|
* Lists the log sources available for the log viewer: the system (backend)
|
|
* log, the Let's Encrypt (certbot) log, and one entry per host the caller
|
|
* can see, for each host type.
|
|
*
|
|
* @param {Access} access
|
|
* @returns {Promise}
|
|
*/
|
|
listSources: async (access) => {
|
|
await access.can("logs:list");
|
|
|
|
const [proxyHosts, redirectionHosts, deadHosts, streams] = await Promise.all([
|
|
internalProxyHost.getAll(access),
|
|
internalRedirectionHost.getAll(access),
|
|
internalDeadHost.getAll(access),
|
|
internalStream.getAll(access),
|
|
]);
|
|
|
|
return {
|
|
system: { label: "System" },
|
|
letsencrypt: { label: "Let's Encrypt" },
|
|
hosts: {
|
|
proxy: toHostOptions(proxyHosts),
|
|
redirection: toHostOptions(redirectionHosts),
|
|
dead: toHostOptions(deadHosts),
|
|
stream: streams.map((row) => ({
|
|
id: row.id,
|
|
label: `Port ${row.incoming_port} → ${row.forwarding_host}:${row.forwarding_port}`,
|
|
})),
|
|
},
|
|
};
|
|
},
|
|
|
|
/**
|
|
* Returns the last N lines of the requested log source, optionally
|
|
* filtered by level and/or a plain-text search term.
|
|
*
|
|
* @param {Access} access
|
|
* @param {Object} data
|
|
* @param {String} data.type "system" | "letsencrypt" | "host"
|
|
* @param {String} [data.host_type] "proxy" | "redirection" | "dead" | "stream"
|
|
* @param {Number} [data.host_id]
|
|
* @param {String} [data.channel] "access" | "error"
|
|
* @param {Number} [data.lines]
|
|
* @param {String} [data.level]
|
|
* @param {String} [data.search]
|
|
* @returns {Promise}
|
|
*/
|
|
tail: async (access, data) => {
|
|
await access.can("logs:list");
|
|
|
|
const filePath = resolveFilePath(data);
|
|
const lines = Math.min(Math.max(data.lines || DEFAULT_LINES, 1), MAX_LINES);
|
|
|
|
let result;
|
|
try {
|
|
result = await readLastLines(filePath, lines);
|
|
} catch (err) {
|
|
if (err.code === "ENOENT") {
|
|
return { lines: [], size: 0, truncated: false, exists: false };
|
|
}
|
|
throw err;
|
|
}
|
|
|
|
let outputLines = result.lines;
|
|
|
|
// Only the system log is written in our own "LEVEL [scope]" format - level
|
|
// filtering on nginx/certbot lines would just match nothing and look like an
|
|
// empty log, so the filter is a no-op for any other source.
|
|
if (data.type === "system" && data.level) {
|
|
const needle = ` ${data.level.toUpperCase().padEnd(7)} `;
|
|
outputLines = outputLines.filter((line) => line.includes(needle));
|
|
}
|
|
|
|
if (data.search) {
|
|
const needle = data.search.toLowerCase();
|
|
outputLines = outputLines.filter((line) => line.toLowerCase().includes(needle));
|
|
}
|
|
|
|
return {
|
|
lines: outputLines,
|
|
size: result.size,
|
|
truncated: result.truncated,
|
|
exists: true,
|
|
};
|
|
},
|
|
};
|
|
|
|
export default internalLogViewer;
|