mirror of
https://github.com/NginxProxyManager/nginx-proxy-manager.git
synced 2026-09-24 11:20:19 +01:00
Nginx Proxy Manager had no way to inspect application or nginx logs from the web UI - admins had to shell into the container or read `docker logs`. This adds an admin-only Logs page that can tail: - the backend application log, now also mirrored to /data/logs/backend.log (in addition to stdout) and rotated by the logrotate timer that already runs every 2 days - the Let's Encrypt/certbot log, which certbot already writes to /data/logs/letsencrypt.log via its existing --logs-dir flag - per-host nginx access/error logs (proxy, redirection, 404 and stream hosts), with the file path always resolved server-side from a validated host_type enum + numeric host_id, never from client input Reads use a reverse chunked scan (64KB chunks, capped at 5MB scanned per request) instead of loading whole files into memory, and the frontend polls every 5s only while the tab is focused and "Live" is on, so this stays cheap on both CPU and memory. No new runtime dependencies were added on either side. Purely additive: two new admin-only endpoints (GET /api/logs/sources, GET /api/logs/tail), no existing behaviour changed.
127 lines
3.7 KiB
JavaScript
127 lines
3.7 KiB
JavaScript
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import signale from "signale";
|
|
import { isDebugMode } from "./lib/config.js";
|
|
|
|
const opts = {
|
|
logLevel: "info",
|
|
};
|
|
|
|
// Methods that are actually used across the codebase (see grep of `.info(`, `.warn(`, etc).
|
|
// Only these are mirrored to the log file - decorative signale methods (star, note, watch, ...)
|
|
// are left console-only since they carry no diagnostic value worth persisting.
|
|
const PERSISTED_METHODS = ["info", "warn", "error", "debug", "success", "fatal", "complete"];
|
|
|
|
const LOG_FILE = "/data/logs/backend.log";
|
|
// biome-ignore lint/suspicious/noControlCharactersInRegex: stripping ANSI colour codes before writing to disk
|
|
const ANSI_PATTERN = /\x1b\[[0-9;]*m/g;
|
|
|
|
let fileStream = null;
|
|
let lastOpenAttempt = 0;
|
|
// If the file can't be opened (eg. running outside the standard Docker image, or in
|
|
// CI without /data), don't retry on every single log call - but do retry periodically
|
|
// so a transient issue (disk full, permissions fixed later) recovers without a restart.
|
|
const REOPEN_COOLDOWN_MS = 30 * 1000;
|
|
|
|
/**
|
|
* Lazily opens the backend log file for appending. If the directory isn't writable,
|
|
* file logging is silently disabled and console logging continues unaffected.
|
|
*
|
|
* @returns {import('node:fs').WriteStream|null}
|
|
*/
|
|
const getFileStream = () => {
|
|
if (fileStream) {
|
|
return fileStream;
|
|
}
|
|
|
|
const now = Date.now();
|
|
if (now - lastOpenAttempt < REOPEN_COOLDOWN_MS) {
|
|
return null;
|
|
}
|
|
lastOpenAttempt = now;
|
|
|
|
try {
|
|
fs.mkdirSync(path.dirname(LOG_FILE), { recursive: true });
|
|
const stream = fs.createWriteStream(LOG_FILE, { flags: "a" });
|
|
stream.on("error", () => {
|
|
fileStream = null;
|
|
});
|
|
fileStream = stream;
|
|
} catch (_err) {
|
|
fileStream = null;
|
|
}
|
|
return fileStream;
|
|
};
|
|
|
|
/**
|
|
* Formats and appends a single log line to the backend log file.
|
|
* Never throws - a failure here must never take down the application.
|
|
*
|
|
* @param {String} level
|
|
* @param {String} scope
|
|
* @param {Array} args
|
|
*/
|
|
const writeToFile = (level, scope, args) => {
|
|
const stream = getFileStream();
|
|
if (!stream) {
|
|
return;
|
|
}
|
|
|
|
const message = args
|
|
.map((arg) => {
|
|
if (typeof arg === "string") return arg;
|
|
if (arg instanceof Error) return arg.stack || arg.message;
|
|
try {
|
|
return JSON.stringify(arg);
|
|
} catch (_err) {
|
|
return String(arg);
|
|
}
|
|
})
|
|
.join(" ")
|
|
.replace(ANSI_PATTERN, "");
|
|
|
|
const line = `${new Date().toISOString()} ${level.toUpperCase().padEnd(7)} [${scope.trim()}] ${message}\n`;
|
|
stream.write(line);
|
|
};
|
|
|
|
/**
|
|
* Wraps a Signale instance so that every call to one of PERSISTED_METHODS is also
|
|
* appended to the backend log file, in addition to its normal console output.
|
|
*
|
|
* @param {Signale} instance
|
|
* @param {String} scope
|
|
* @returns {Signale}
|
|
*/
|
|
const withFileSink = (instance, scope) => {
|
|
for (const method of PERSISTED_METHODS) {
|
|
const original = instance[method].bind(instance);
|
|
instance[method] = (...args) => {
|
|
writeToFile(method, scope, args);
|
|
return original(...args);
|
|
};
|
|
}
|
|
return instance;
|
|
};
|
|
|
|
const createLogger = (scope) => withFileSink(new signale.Signale({ scope, ...opts }), scope);
|
|
|
|
const global = createLogger("Global ");
|
|
const migrate = createLogger("Migrate ");
|
|
const express = createLogger("Express ");
|
|
const access = createLogger("Access ");
|
|
const nginx = createLogger("Nginx ");
|
|
const ssl = createLogger("SSL ");
|
|
const certbot = createLogger("Certbot ");
|
|
const importer = createLogger("Importer ");
|
|
const setup = createLogger("Setup ");
|
|
const ipRanges = createLogger("IP Ranges");
|
|
const remoteVersion = createLogger("Remote Version");
|
|
|
|
const debug = (logger, ...args) => {
|
|
if (isDebugMode()) {
|
|
logger.debug(...args);
|
|
}
|
|
};
|
|
|
|
export { debug, global, migrate, express, access, nginx, ssl, certbot, importer, setup, ipRanges, remoteVersion };
|