From e1ce98458eeb60935d02a0aeb71e044f991cfccf Mon Sep 17 00:00:00 2001 From: Vitor Coelho <6161176+VitorCoelho@users.noreply.github.com> Date: Sun, 8 Nov 2026 10:39:43 -0300 Subject: [PATCH] fix(simulator): accept winston log levels in log.level and map syslog names (#2189) MIME-Version: 1.0 Content-Type: text/plain; charset=utf8 Content-Transfer-Encoding: 8bit * fix(simulator): accept winston log levels in log.level and map syslog names The logger is created with winston's default npm levels (error, warn, info, http, verbose, debug, silly), but the configuration schema only accepted the syslog names emerg/alert/crit/error/warning/notice/info/debug documented in the README: - 'warn', 'http', 'verbose' and 'silly' were rejected at startup, although they are valid winston levels (they were passed to winston unchecked before the configuration validation added in 4.8.0); - 'emerg', 'alert', 'crit', 'warning' and 'notice' were accepted, but winston does not know them, so the logger stopped writing anything, errors included. Accept the npm levels and keep accepting the syslog names for backward compatibility, mapped to the npm level of the same severity (emerg/alert/crit -> error, warning -> warn, notice -> info). Update the README accordingly. Signed-off-by: VitorCoelho * docs(simulator): clarify which log outputs an unknown winston level silences An unknown level silences the console and the main log file, but the error file transport sets its own 'error' level and still logs errors. Signed-off-by: VitorCoelho * test(simulator): pin deprecated logLevel syslog mapping and clarify JSDoc Address the review findings on #2189: - LogConfiguration JSDoc now references LOG_LEVELS and SYSLOG_LOG_LEVEL_ALIASES instead of re-enumerating the syslog->npm pairs, so the prose cannot drift when the alias map changes - pin the non-identity composition through the deprecated top-level logLevel key ("logLevel": "warning" -> log.level "warn") Closes #2188 Signed-off-by: Jérôme Benoit --------- Signed-off-by: VitorCoelho Signed-off-by: Jérôme Benoit Co-authored-by: Jérôme Benoit --- README.md | 2 +- src/utils/ConfigurationSchema.ts | 40 ++++++++++++-- tests/utils/ConfigurationSchema.test.ts | 69 +++++++++++++++++++++++-- 3 files changed, 102 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index bd894785..810adf7d 100644 --- a/README.md +++ b/README.md @@ -182,7 +182,7 @@ The `/configurations` subdirectory is created by the runtime when pe | $schemaVersion | 2 | 2 | integer | Configuration schema version. Set to 2. Files without this field are migrated from v0; deprecated keys are remapped on every load. | | supervisionUrls | | [] | string \| string[] | string or strings array containing global connection URIs to OCPP-J servers (`ws://`, `wss://`, `http://`, `https://` or `ws+unix://`; no `#` fragment or `?` query, since the charging station identity is appended to the URL; a `ws+unix://` URL separates the socket path from the request path with one `:`, e.g. `ws+unix:///path/to/socket.sock:/OCPP16`) | | supervisionUrlDistribution | round-robin/random/charging-station-affinity | charging-station-affinity | string | supervision urls distribution policy to simulated charging stations | -| log | | {
"enabled": true,
"file": "logs/combined.log",
"errorFile": "logs/error.log",
"statisticsInterval": 60,
"level": "info",
"console": false,
"format": "simple",
"rotate": true
} | {
enabled?: boolean;
file?: string;
errorFile?: string;
statisticsInterval?: number;
level?: string;
console?: boolean;
format?: string;
rotate?: boolean;
maxFiles?: string \| number;
maxSize?: string \| number;
} | Log configuration section:
- _enabled_: enable logging
- _file_: log file relative path
- _errorFile_: error log file relative path
- _statisticsInterval_: seconds between charging stations statistics output in the logs
- _level_: emerg/alert/crit/error/warning/notice/info/debug [winston](https://github.com/winstonjs/winston) logging level
- _console_: output logs on the console
- _format_: [winston](https://github.com/winstonjs/winston) log format
- _rotate_: enable daily log files rotation
- _maxFiles_: maximum number of log files: https://github.com/winstonjs/winston-daily-rotate-file#options
- _maxSize_: maximum size of log files in bytes, or units of kb, mb, and gb: https://github.com/winstonjs/winston-daily-rotate-file#options | +| log | | {
"enabled": true,
"file": "logs/combined.log",
"errorFile": "logs/error.log",
"statisticsInterval": 60,
"level": "info",
"console": false,
"format": "simple",
"rotate": true
} | {
enabled?: boolean;
file?: string;
errorFile?: string;
statisticsInterval?: number;
level?: string;
console?: boolean;
format?: string;
rotate?: boolean;
maxFiles?: string \| number;
maxSize?: string \| number;
} | Log configuration section:
- _enabled_: enable logging
- _file_: log file relative path
- _errorFile_: error log file relative path
- _statisticsInterval_: seconds between charging stations statistics output in the logs
- _level_: error/warn/info/http/verbose/debug/silly [winston](https://github.com/winstonjs/winston) logging level (the syslog names emerg/alert/crit/warning/notice are accepted and mapped to error/error/error/warn/info)
- _console_: output logs on the console
- _format_: [winston](https://github.com/winstonjs/winston) log format
- _rotate_: enable daily log files rotation
- _maxFiles_: maximum number of log files: https://github.com/winstonjs/winston-daily-rotate-file#options
- _maxSize_: maximum size of log files in bytes, or units of kb, mb, and gb: https://github.com/winstonjs/winston-daily-rotate-file#options | | worker | | {
"processType": "workerSet",
"startDelay": 500,
"elementAddDelay": 0,
"elementsPerWorker": 'auto',
"poolMinSize": 4,
"poolMaxSize": 16
} | {
processType?: WorkerProcessType;
startDelay?: number;
elementAddDelay?: number;
elementsPerWorker?: number \| 'auto' \| 'all';
poolMinSize?: number;
poolMaxSize?: number;
resourceLimits?: ResourceLimits;
} | Worker configuration section:
- _processType_: worker threads process type (`workerSet`/`fixedPool`/`dynamicPool`)
- _startDelay_: milliseconds to wait at worker threads startup (only for `workerSet` worker threads process type)
- _elementAddDelay_: milliseconds to wait between charging station add
- _elementsPerWorker_: number of charging stations per worker threads for the `workerSet` process type (`auto` means (number of stations) / (number of CPUs) \* 1.5 if (number of stations) > (number of CPUs), otherwise 1; `all` means a unique worker will run all charging stations)
- _poolMinSize_: worker threads pool minimum number of threads
- _poolMaxSize_: worker threads pool maximum number of threads
- _resourceLimits_: worker threads [resource limits](https://nodejs.org/api/worker_threads.html#new-workerfilename-options) object option | | uiServer | | {
"enabled": false,
"type": "ws",
"version": "1.1",
"accessPolicy": {
"requireTlsForNonLoopback": true,
"trustedProxies": [],
"allowLoopbackProxy": false,
"allowedHosts": [],
"allowedOrigins": []
},
"options": {
"host": "localhost",
"port": 8080
}
} | {
enabled?: boolean;
type?: ApplicationProtocol;
version?: ApplicationProtocolVersion;
accessPolicy?: {
requireTlsForNonLoopback?: boolean;
trustedProxies?: string[];
allowLoopbackProxy?: boolean;
allowedHosts?: string[];
allowedOrigins?: string[];
};
options?: ServerOptions;
authentication?: {
enabled: boolean;
type: AuthenticationType;
username?: string;
password?: string;
};
metrics?: {
enabled?: boolean;
softSampleCap?: number;
};
securityHeaders?: {
strictTransportSecurity?: string \| false;
};
} | UI server configuration section:
- _enabled_: enable UI server
- _type_: 'ws', 'mcp' or 'http' (deprecated)
- _version_: HTTP version '1.1' or '2.0' (ws and mcp transports only support '1.1')
- _accessPolicy_: gateway access policy. Loopback request sources are allowed in plaintext; non-loopback sources require TLS termination by a reverse proxy:
  - _requireTlsForNonLoopback_: reject non-loopback plaintext requests; the check honors `X-Forwarded-Proto` or `Forwarded: proto=` from a trusted proxy, non-loopback requests without forwarded protocol headers are denied as `tls-required`
  - _trustedProxies_: IPv4 or IPv6 literals of the immediate reverse proxies whose forwarded headers are honored (hostnames and CIDR ranges are not accepted; only single-hop forwarded chains are honored); a compromised entry can bypass per-client rate limiting by varying `X-Forwarded-For`
  - _allowLoopbackProxy_: accept forwarded headers when the immediate peer is loopback AND listed in _trustedProxies_ (e.g. `['127.0.0.1', '::1']`)
  - _allowedHosts_: explicit Host header allowlist; mitigates DNS rebinding when the UI server is exposed through a browser-facing host
  - _allowedOrigins_: explicit Origin header allowlist; when empty, the request Origin's URL hostname falls back to matching against _allowedHosts_
- _options_: node.js net module [listen options](https://nodejs.org/api/net.html#serverlistenoptions-callback)
- _authentication_: authentication type configuration section
- _metrics_: opt-in Prometheus `/metrics` endpoint (served on the configured `uiServer.type` listener — `http`, `ws` or `mcp`):
  - _enabled_: enable the `/metrics` endpoint
  - _softSampleCap_: soft cardinality cap above which a single warn is logged per scrape (default 5000)
- _securityHeaders_: opt-in security response headers emitted on every UI-server HTTP response path served over secure transport, across the `http`, `ws` and `mcp` transports (denials, WebSocket upgrade rejections, `http` success responses and the `/metrics` endpoint), except MCP JSON-RPC success bodies (written by the MCP SDK transport); absent by default so the default response behavior is unchanged:
  - _strictTransportSecurity_: `Strict-Transport-Security` (HSTS) header value, emitted verbatim (and unvalidated) when set to a non-empty string; `false` (default) or an empty string omits the header. Emission is gated on secure transport — direct TLS or a trusted-proxy-forwarded `https`/`wss` protocol — per [RFC 6797](https://www.rfc-editor.org/rfc/rfc6797) §7.2 (HSTS must not be sent over non-secure transport), so it is omitted over plaintext (e.g. loopback development). Recommended production value behind a TLS-terminating reverse proxy or native TLS: `"max-age=31536000; includeSubDomains"`. Caution: `includeSubDomains` extends the policy to every subdomain of the served host and `preload` (with submission to hstspreload.org) is effectively irreversible (removal propagates through browser preload lists over ~6–12 months); only use `includeSubDomains`/`preload` on a dedicated hostname you fully control, never on a shared host (including `localhost`) | | performanceStorage | | {
"enabled": true,
"type": "none",
} | {
enabled?: boolean;
type?: string;
uri?: string;
} | Performance storage configuration section:
- _enabled_: enable performance storage
- _type_: 'jsonfile', 'mongodb' or 'none'
- _uri_: storage URI | diff --git a/src/utils/ConfigurationSchema.ts b/src/utils/ConfigurationSchema.ts index 8c51186e..376ab184 100644 --- a/src/utils/ConfigurationSchema.ts +++ b/src/utils/ConfigurationSchema.ts @@ -21,8 +21,44 @@ import { has, isNotEmptyArray } from './Utils.js' // Sub-schemas // --------------------------------------------------------------- +/** + * Logging levels of the winston logger: the logger is created with winston's + * default `npm` levels, so only these names select a severity threshold. + */ +const LOG_LEVELS = ['error', 'warn', 'info', 'http', 'verbose', 'debug', 'silly'] as const +type LogLevel = (typeof LOG_LEVELS)[number] + +/** + * Syslog level names accepted for backward compatibility and mapped to the + * `npm` level of the same severity. winston has no such levels in its `npm` + * set: passed as is, they silence the console and the main log file, errors + * included (only the error file transport, which sets its own `error` level, + * still logs errors). + */ +const SYSLOG_LOG_LEVEL_ALIASES = { + alert: 'error', + crit: 'error', + emerg: 'error', + notice: 'info', + warning: 'warn', +} as const satisfies Record + +type SyslogLogLevelAlias = keyof typeof SYSLOG_LOG_LEVEL_ALIASES + +const isSyslogLogLevelAlias = (level: string): level is SyslogLogLevelAlias => + has(level, SYSLOG_LOG_LEVEL_ALIASES) + +const LogLevelSchema = z + .enum([...LOG_LEVELS, ...(Object.keys(SYSLOG_LOG_LEVEL_ALIASES) as SyslogLogLevelAlias[])]) + .transform((level): LogLevel => + isSyslogLogLevelAlias(level) ? SYSLOG_LOG_LEVEL_ALIASES[level] : level + ) + /** * LogConfiguration — winston logger configuration section. + * `level` is a winston `npm` logging level (`LOG_LEVELS`); the syslog + * aliases (`SYSLOG_LOG_LEVEL_ALIASES`) are mapped to the `npm` level of + * the same severity. * `maxFiles` and `maxSize` accept `number | string` (winston-daily-rotate-file * units like '14d', '20m'). */ @@ -33,9 +69,7 @@ export const LogConfigurationSchema = z errorFile: z.string().optional(), file: z.string().optional(), format: z.string().optional(), - level: z - .enum(['emerg', 'alert', 'crit', 'error', 'warning', 'notice', 'info', 'debug']) - .optional(), + level: LogLevelSchema.optional(), maxFiles: z.union([z.number(), z.string()]).optional(), maxSize: z.union([z.number(), z.string()]).optional(), rotate: z.boolean().optional(), diff --git a/tests/utils/ConfigurationSchema.test.ts b/tests/utils/ConfigurationSchema.test.ts index 9a1831cf..4f615b65 100644 --- a/tests/utils/ConfigurationSchema.test.ts +++ b/tests/utils/ConfigurationSchema.test.ts @@ -5,7 +5,9 @@ import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' +import { Writable } from 'node:stream' import { afterEach, describe, it } from 'node:test' +import { createLogger, transports, config as winstonConfig } from 'winston' import { WebSocket } from 'ws' import { @@ -14,8 +16,10 @@ import { } from '../../src/utils/index.js' import { ConfigurationSchema, + has, SupervisionUrlSchema, UI_SERVER_ACCESS_POLICY_DEFAULTS, + validateConfiguration, WorkerConfigurationSchema, } from '../../src/utils/index.js' import { standardCleanup } from '../helpers/TestLifecycleHelpers.js' @@ -23,6 +27,7 @@ import { BAD_FIXTURES, buildFullConfiguration, buildMinimalConfiguration, + buildV1WithDeprecatedKey, } from './helpers/ConfigurationFixtures.js' import { TEST_SUPERVISION_URL, TEST_SUPERVISION_URL_ALT_PORT } from './TestNetworkConstants.js' @@ -1065,27 +1070,81 @@ await describe('ConfigurationSchema', async () => { assert.ok(!result.success) }) - await it('should accept valid log.level values', () => { + await it('should accept the winston npm log.level values', () => { + for (const level of ['error', 'warn', 'info', 'http', 'verbose', 'debug', 'silly']) { + const result = ConfigurationSchema.safeParse(buildMinimalConfiguration({ log: { level } })) + assert.ok(result.success, `Expected log.level '${level}' to be valid`) + assert.strictEqual(result.data.log?.level, level) + } + }) + + await it('should map the syslog log.level names to the winston npm level of the same severity', () => { + for (const [level, expected] of [ + ['emerg', 'error'], + ['alert', 'error'], + ['crit', 'error'], + ['warning', 'warn'], + ['notice', 'info'], + ]) { + const result = ConfigurationSchema.safeParse(buildMinimalConfiguration({ log: { level } })) + assert.ok(result.success, `Expected log.level '${level}' to be valid`) + assert.strictEqual(result.data.log?.level, expected) + } + }) + + await it('should map a deprecated logLevel through the syslog aliases', () => { + const result = validateConfiguration( + buildV1WithDeprecatedKey('logLevel', 'warning'), + 'loglevel.json' + ) + assert.strictEqual(result.log?.level, 'warn') + }) + + await it('should only produce log.level values the winston logger knows', () => { for (const level of [ 'emerg', 'alert', 'crit', 'error', 'warning', + 'warn', 'notice', 'info', + 'http', + 'verbose', 'debug', + 'silly', ]) { const result = ConfigurationSchema.safeParse(buildMinimalConfiguration({ log: { level } })) assert.ok(result.success, `Expected log.level '${level}' to be valid`) + const parsedLevel = result.data.log?.level + assert.ok(parsedLevel != null && has(parsedLevel, winstonConfig.npm.levels)) + // a logger at any accepted level must still emit errors + const lines: string[] = [] + const logger = createLogger({ + level: parsedLevel, + transports: [ + new transports.Stream({ + stream: new Writable({ + write (chunk: Buffer, _encoding, callback) { + lines.push(chunk.toString()) + callback() + }, + }), + }), + ], + }) + logger.error('error message') + assert.strictEqual(lines.length, 1, `Expected log.level '${level}' to log errors`) + logger.close() } }) await it('should reject invalid log.level', () => { - const result = ConfigurationSchema.safeParse( - buildMinimalConfiguration({ log: { level: 'verbose' } }) - ) - assert.ok(!result.success) + for (const level of ['trace', 'WARN', '']) { + const result = ConfigurationSchema.safeParse(buildMinimalConfiguration({ log: { level } })) + assert.ok(!result.success, `Expected log.level '${level}' to be invalid`) + } }) }) -- 2.53.0