Merge pull request #3526 from Zhen-Bo/feature/access-log-middleware Add Separate Access Logging Middleware with Configuration Option
Signed| @@ -71,8 +71,6 @@ autheliaAuth: false | |||
| 71 | # the username and passwords for basic auth are the same as those | 71 | # the username and passwords for basic auth are the same as those |
| 72 | # for the individual accounts | 72 | # for the individual accounts |
| 73 | perUserBasicAuth: false | 73 | perUserBasicAuth: false |
| 74 | # Minimum log level to display in the terminal (DEBUG = 0, INFO = 1, WARN = 2, ERROR = 3) | ||
| 75 | minLogLevel: 0 | ||
| 76 | 74 | ||
| 77 | # User session timeout *in seconds* (defaults to 24 hours). | 75 | # User session timeout *in seconds* (defaults to 24 hours). |
| 78 | ## Set to a positive number to expire session after a certain time of inactivity | 76 | ## Set to a positive number to expire session after a certain time of inactivity |
| @@ -85,6 +83,13 @@ cookieSecret: '' | |||
| 85 | disableCsrfProtection: false | 83 | disableCsrfProtection: false |
| 86 | # Disable startup security checks - NOT RECOMMENDED | 84 | # Disable startup security checks - NOT RECOMMENDED |
| 87 | securityOverride: false | 85 | securityOverride: false |
| 86 | # -- LOGGING CONFIGURATION -- | ||
| 87 | logging: | ||
| 88 | # Enable access logging to access.log file | ||
| 89 | # Records new connections with timestamp, IP address and user agent | ||
| 90 | enableAccessLog: true | ||
| 91 | # Minimum log level to display in the terminal (DEBUG = 0, INFO = 1, WARN = 2, ERROR = 3) | ||
| 92 | minLogLevel: 0 | ||
| 88 | # -- RATE LIMITING CONFIGURATION -- | 93 | # -- RATE LIMITING CONFIGURATION -- |
| 89 | rateLimiting: | 94 | rateLimiting: |
| 90 | # Use X-Real-IP header instead of socket IP for rate limiting | 95 | # Use X-Real-IP header instead of socket IP for rate limiting |
| @@ -104,6 +104,11 @@ const keyMigrationMap = [ | |||
| 104 | newKey: 'extensions.models.textToSpeech', | 104 | newKey: 'extensions.models.textToSpeech', |
| 105 | migrate: (value) => value, | 105 | migrate: (value) => value, |
| 106 | }, | 106 | }, |
| 107 | { | ||
| 108 | oldKey: 'minLogLevel', | ||
| 109 | newKey: 'logging.minLogLevel', | ||
| 110 | migrate: (value) => value, | ||
| 111 | }, | ||
| 107 | ]; | 112 | ]; |
| 108 | 113 | ||
| 109 | /** | 114 | /** |
| @@ -57,7 +57,8 @@ import { | |||
| 57 | 57 | ||
| 58 | import getWebpackServeMiddleware from './src/middleware/webpack-serve.js'; | 58 | import getWebpackServeMiddleware from './src/middleware/webpack-serve.js'; |
| 59 | import basicAuthMiddleware from './src/middleware/basicAuth.js'; | 59 | import basicAuthMiddleware from './src/middleware/basicAuth.js'; |
| 60 | import whitelistMiddleware, { getAccessLogPath, migrateAccessLog } from './src/middleware/whitelist.js'; | 60 | import whitelistMiddleware from './src/middleware/whitelist.js'; |
| 61 | import accessLoggerMiddleware, { getAccessLogPath, migrateAccessLog } from './src/middleware/accessLogWriter.js'; | ||
| 61 | import multerMonkeyPatch from './src/middleware/multerMonkeyPatch.js'; | 62 | import multerMonkeyPatch from './src/middleware/multerMonkeyPatch.js'; |
| 62 | import initRequestProxy from './src/request-proxy.js'; | 63 | import initRequestProxy from './src/request-proxy.js'; |
| 63 | import getCacheBusterMiddleware from './src/middleware/cacheBuster.js'; | 64 | import getCacheBusterMiddleware from './src/middleware/cacheBuster.js'; |
| @@ -339,9 +340,17 @@ const CORS = cors({ | |||
| 339 | 340 | ||
| 340 | app.use(CORS); | 341 | app.use(CORS); |
| 341 | 342 | ||
| 342 | if (listen && basicAuthMode) app.use(basicAuthMiddleware); | 343 | if (listen && basicAuthMode) { |
| 344 | app.use(basicAuthMiddleware); | ||
| 345 | } | ||
| 346 | |||
| 347 | if (enableWhitelist) { | ||
| 348 | app.use(whitelistMiddleware()); | ||
| 349 | } | ||
| 343 | 350 | ||
| 344 | app.use(whitelistMiddleware(enableWhitelist, listen)); | 351 | if (listen) { |
| 352 | app.use(accessLoggerMiddleware()); | ||
| 353 | } | ||
| 345 | 354 | ||
| 346 | if (enableCorsProxy) { | 355 | if (enableCorsProxy) { |
| 347 | app.use(bodyParser.json({ | 356 | app.use(bodyParser.json({ |
| @@ -0,0 +1,59 @@ | |||
| 1 | import path from 'node:path'; | ||
| 2 | import fs from 'node:fs'; | ||
| 3 | import { getRealIpFromHeader } from '../express-common.js'; | ||
| 4 | import { color, getConfigValue } from '../util.js'; | ||
| 5 | |||
| 6 | const enableAccessLog = getConfigValue('logging.enableAccessLog', true); | ||
| 7 | |||
| 8 | const knownIPs = new Set(); | ||
| 9 | |||
| 10 | export const getAccessLogPath = () => path.join(globalThis.DATA_ROOT, 'access.log'); | ||
| 11 | |||
| 12 | export function migrateAccessLog() { | ||
| 13 | try { | ||
| 14 | if (!fs.existsSync('access.log')) { | ||
| 15 | return; | ||
| 16 | } | ||
| 17 | const logPath = getAccessLogPath(); | ||
| 18 | if (fs.existsSync(logPath)) { | ||
| 19 | return; | ||
| 20 | } | ||
| 21 | fs.renameSync('access.log', logPath); | ||
| 22 | console.log(color.yellow('Migrated access.log to new location:'), logPath); | ||
| 23 | } catch (e) { | ||
| 24 | console.error('Failed to migrate access log:', e); | ||
| 25 | console.info('Please move access.log to the data directory manually.'); | ||
| 26 | } | ||
| 27 | } | ||
| 28 | |||
| 29 | /** | ||
| 30 | * Creates middleware for logging access and new connections | ||
| 31 | * @returns {import('express').RequestHandler} | ||
| 32 | */ | ||
| 33 | export default function accessLoggerMiddleware() { | ||
| 34 | return function (req, res, next) { | ||
| 35 | const clientIp = getRealIpFromHeader(req); | ||
| 36 | const userAgent = req.headers['user-agent']; | ||
| 37 | |||
| 38 | if (!knownIPs.has(clientIp)) { | ||
| 39 | // Log new connection | ||
| 40 | console.info(color.yellow(`New connection from ${clientIp}; User Agent: ${userAgent}\n`)); | ||
| 41 | knownIPs.add(clientIp); | ||
| 42 | |||
| 43 | // Write to access log if enabled | ||
| 44 | if (enableAccessLog) { | ||
| 45 | const logPath = getAccessLogPath(); | ||
| 46 | const timestamp = new Date().toISOString(); | ||
| 47 | const log = `${timestamp} ${clientIp} ${userAgent}\n`; | ||
| 48 | |||
| 49 | fs.appendFile(logPath, log, (err) => { | ||
| 50 | if (err) { | ||
| 51 | console.error('Failed to write access log:', err); | ||
| 52 | } | ||
| 53 | }); | ||
| 54 | } | ||
| 55 | } | ||
| 56 | |||
| 57 | next(); | ||
| 58 | }; | ||
| 59 | } | ||
| @@ -10,9 +10,6 @@ import { color, getConfigValue, safeReadFileSync } from '../util.js'; | |||
| 10 | const whitelistPath = path.join(process.cwd(), './whitelist.txt'); | 10 | const whitelistPath = path.join(process.cwd(), './whitelist.txt'); |
| 11 | const enableForwardedWhitelist = getConfigValue('enableForwardedWhitelist', false); | 11 | const enableForwardedWhitelist = getConfigValue('enableForwardedWhitelist', false); |
| 12 | let whitelist = getConfigValue('whitelist', []); | 12 | let whitelist = getConfigValue('whitelist', []); |
| 13 | let knownIPs = new Set(); | ||
| 14 | |||
| 15 | export const getAccessLogPath = () => path.join(globalThis.DATA_ROOT, 'access.log'); | ||
| 16 | 13 | ||
| 17 | if (fs.existsSync(whitelistPath)) { | 14 | if (fs.existsSync(whitelistPath)) { |
| 18 | try { | 15 | try { |
| @@ -48,67 +45,41 @@ function getForwardedIp(req) { | |||
| 48 | return undefined; | 45 | return undefined; |
| 49 | } | 46 | } |
| 50 | 47 | ||
| 51 | export function migrateAccessLog() { | ||
| 52 | try { | ||
| 53 | if (!fs.existsSync('access.log')) { | ||
| 54 | return; | ||
| 55 | } | ||
| 56 | const logPath = getAccessLogPath(); | ||
| 57 | if (fs.existsSync(logPath)) { | ||
| 58 | return; | ||
| 59 | } | ||
| 60 | fs.renameSync('access.log', logPath); | ||
| 61 | console.log(color.yellow('Migrated access.log to new location:'), logPath); | ||
| 62 | } catch (e) { | ||
| 63 | console.error('Failed to migrate access log:', e); | ||
| 64 | console.info('Please move access.log to the data directory manually.'); | ||
| 65 | } | ||
| 66 | } | ||
| 67 | |||
| 68 | /** | 48 | /** |
| 69 | * Returns a middleware function that checks if the client IP is in the whitelist. | 49 | * Returns a middleware function that checks if the client IP is in the whitelist. |
| 70 | * @param {boolean} whitelistMode If whitelist mode is enabled via config or command line | ||
| 71 | * @param {boolean} listen If listen mode is enabled via config or command line | ||
| 72 | * @returns {import('express').RequestHandler} The middleware function | 50 | * @returns {import('express').RequestHandler} The middleware function |
| 73 | */ | 51 | */ |
| 74 | export default function whitelistMiddleware(whitelistMode, listen) { | 52 | export default function whitelistMiddleware() { |
| 75 | const forbiddenWebpage = Handlebars.compile( | 53 | const forbiddenWebpage = Handlebars.compile( |
| 76 | safeReadFileSync('./public/error/forbidden-by-whitelist.html') ?? '', | 54 | safeReadFileSync('./public/error/forbidden-by-whitelist.html') ?? '', |
| 77 | ); | 55 | ); |
| 78 | 56 | ||
| 57 | const noLogPaths = [ | ||
| 58 | '/favicon.ico', | ||
| 59 | ]; | ||
| 60 | |||
| 79 | return function (req, res, next) { | 61 | return function (req, res, next) { |
| 80 | const clientIp = getIpFromRequest(req); | 62 | const clientIp = getIpFromRequest(req); |
| 81 | const forwardedIp = getForwardedIp(req); | 63 | const forwardedIp = getForwardedIp(req); |
| 82 | const userAgent = req.headers['user-agent']; | 64 | const userAgent = req.headers['user-agent']; |
| 83 | 65 | ||
| 84 | if (listen && !knownIPs.has(clientIp)) { | ||
| 85 | console.info(color.yellow(`New connection from ${clientIp}; User Agent: ${userAgent}\n`)); | ||
| 86 | knownIPs.add(clientIp); | ||
| 87 | |||
| 88 | // Write access log | ||
| 89 | const logPath = getAccessLogPath(); | ||
| 90 | const timestamp = new Date().toISOString(); | ||
| 91 | const log = `${timestamp} ${clientIp} ${userAgent}\n`; | ||
| 92 | fs.appendFile(logPath, log, (err) => { | ||
| 93 | if (err) { | ||
| 94 | console.error('Failed to write access log:', err); | ||
| 95 | } | ||
| 96 | }); | ||
| 97 | } | ||
| 98 | |||
| 99 | //clientIp = req.connection.remoteAddress.split(':').pop(); | 66 | //clientIp = req.connection.remoteAddress.split(':').pop(); |
| 100 | if (whitelistMode === true && !whitelist.some(x => ipMatching.matches(clientIp, ipMatching.getMatch(x))) | 67 | if (!whitelist.some(x => ipMatching.matches(clientIp, ipMatching.getMatch(x))) |
| 101 | || forwardedIp && whitelistMode === true && !whitelist.some(x => ipMatching.matches(forwardedIp, ipMatching.getMatch(x))) | 68 | || forwardedIp && !whitelist.some(x => ipMatching.matches(forwardedIp, ipMatching.getMatch(x))) |
| 102 | ) { | 69 | ) { |
| 103 | // Log the connection attempt with real IP address | 70 | // Log the connection attempt with real IP address |
| 104 | const ipDetails = forwardedIp | 71 | const ipDetails = forwardedIp |
| 105 | ? `${clientIp} (forwarded from ${forwardedIp})` | 72 | ? `${clientIp} (forwarded from ${forwardedIp})` |
| 106 | : clientIp; | 73 | : clientIp; |
| 107 | console.warn( | 74 | |
| 108 | color.red( | 75 | if (!noLogPaths.includes(req.path)) { |
| 109 | `Blocked connection from ${clientIp}; User Agent: ${userAgent}\n\tTo allow this connection, add its IP address to the whitelist or disable whitelist mode by editing config.yaml in the root directory of your SillyTavern installation.\n`, | 76 | console.warn( |
| 110 | ), | 77 | color.red( |
| 111 | ); | 78 | `Blocked connection from ${clientIp}; User Agent: ${userAgent}\n\tTo allow this connection, add its IP address to the whitelist or disable whitelist mode by editing config.yaml in the root directory of your SillyTavern installation.\n`, |
| 79 | ), | ||
| 80 | ); | ||
| 81 | } | ||
| 82 | |||
| 112 | return res.status(403).send(forbiddenWebpage({ ipDetails })); | 83 | return res.status(403).send(forbiddenWebpage({ ipDetails })); |
| 113 | } | 84 | } |
| 114 | next(); | 85 | next(); |
| @@ -763,7 +763,7 @@ export function stringToBool(str) { | |||
| 763 | * Setup the minimum log level | 763 | * Setup the minimum log level |
| 764 | */ | 764 | */ |
| 765 | export function setupLogLevel() { | 765 | export function setupLogLevel() { |
| 766 | const logLevel = getConfigValue('minLogLevel', LOG_LEVELS.DEBUG); | 766 | const logLevel = getConfigValue('logging.minLogLevel', LOG_LEVELS.DEBUG); |
| 767 | 767 | ||
| 768 | globalThis.console.debug = logLevel <= LOG_LEVELS.DEBUG ? console.debug : () => {}; | 768 | globalThis.console.debug = logLevel <= LOG_LEVELS.DEBUG ? console.debug : () => {}; |
| 769 | globalThis.console.info = logLevel <= LOG_LEVELS.INFO ? console.info : () => {}; | 769 | globalThis.console.info = logLevel <= LOG_LEVELS.INFO ? console.info : () => {}; |