first commit
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled

This commit is contained in:
2026-03-17 16:22:57 +03:30
commit 3d5eaf9445
15349 changed files with 2847338 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
This client is based upon the great work done in this project:
https://github.com/paulgrove/node-syslog-client/tree/master
It was partially built with Claude Code. However, many of the tests were verified and / or added manually.
@@ -0,0 +1,22 @@
import { defineConfig } from 'eslint/config';
import { nodeConfig } from '@n8n/eslint-config/node';
export default defineConfig(
nodeConfig,
{
rules: {
'unicorn/filename-case': ['error', { case: 'kebabCase' }],
// TODO: Remove this
'@typescript-eslint/naming-convention': 'warn',
'@typescript-eslint/no-unsafe-call': 'warn',
'@typescript-eslint/no-unsafe-function-type': 'warn',
},
},
{
files: ['**/*.config.ts'],
rules: {
'n8n-local-rules/no-untyped-config-class-field': 'error',
},
},
);
@@ -0,0 +1,2 @@
/** @type {import('jest').Config} */
module.exports = require('../../../jest.config');
+31
View File
@@ -0,0 +1,31 @@
{
"name": "@n8n/syslog-client",
"version": "1.2.0",
"scripts": {
"clean": "rimraf dist .turbo",
"dev": "pnpm watch",
"typecheck": "tsc --noEmit",
"build": "tsc -p tsconfig.build.json",
"format": "biome format --write src test",
"format:check": "biome ci src test",
"lint": "eslint . --quiet",
"lint:fix": "eslint . --fix",
"watch": "tsc -p tsconfig.build.json --watch",
"test": "jest",
"test:unit": "jest",
"test:dev": "jest --watch"
},
"main": "dist/index.js",
"module": "src/index.ts",
"types": "dist/index.d.ts",
"files": [
"dist/**/*"
],
"dependencies": {
"zod": "catalog:"
},
"devDependencies": {
"@n8n/typescript-config": "workspace:*",
"get-port": "^7.1.0"
}
}
+504
View File
@@ -0,0 +1,504 @@
import * as dgram from 'dgram';
import { EventEmitter } from 'events';
import * as net from 'net';
import * as os from 'os';
import * as tls from 'tls';
import { Facility, Severity, Transport } from './constants';
import {
ConnectionError,
SyslogClientError,
TimeoutError,
TransportError,
ValidationError,
} from './errors';
import { clientOptionsSchema, logOptionsSchema } from './schemas';
import type {
ClientOptions,
DateFormatter,
LogOptions,
ResolvedLogOptions,
SyslogCallback,
TransportConnection,
} from './types';
import { buildFormattedMessage, defaultDateFormatter, isIPv6 } from './utils';
/**
* Syslog client supporting UDP, TCP, TLS, and Unix socket transports.
* Supports both RFC 3164 and RFC 5424 formats.
*
* @example
* ```typescript
* const client = new SyslogClient('192.168.1.1', {
* transport: Transport.Tcp,
* facility: Facility.Local0,
* });
*
* // Callback API
* client.log('Test message', (error) => {
* if (error) console.error(error);
* });
*
* // Promise API (when no callback provided)
* await client.log('Test message');
* await client.log('Test message', { severity: Severity.Error });
*
* client.close();
* ```
*/
export class SyslogClient extends EventEmitter {
// Public configuration properties
readonly target: string;
readonly syslogHostname: string;
readonly port: number;
readonly tcpTimeout: number;
readonly facility: Facility;
readonly severity: Severity;
readonly rfc3164: boolean;
readonly appName: string;
readonly dateFormatter: DateFormatter;
readonly udpBindAddress?: string;
readonly transport: Transport;
readonly tlsCA?: string | string[] | Buffer | Buffer[];
// Private state
private transport_?: TransportConnection;
private connecting = false;
private getTransportRequests: Array<
(error: Error | null, transport?: TransportConnection) => void
> = [];
/**
* Create a new syslog client.
*
* @param target - Target host/path (IP address, hostname, or Unix socket path)
* @param options - Client configuration options
* @throws {ValidationError} If options validation fails
*/
constructor(target?: string, options?: ClientOptions) {
super();
this.target = target ?? '127.0.0.1';
const validationResult = clientOptionsSchema.safeParse(options ?? {});
if (!validationResult.success) {
throw ValidationError.fromZod('Invalid client options', validationResult.error.errors);
}
const opts = validationResult.data;
// Initialize properties with defaults
this.syslogHostname = opts.syslogHostname ?? os.hostname();
this.port = opts.port ?? 514;
this.tcpTimeout = opts.tcpTimeout ?? 10000;
// BUG FIX: Original code has incorrect logic (typeof !== "number" || default)
// Should be: typeof === "number" ? value : default
this.facility = typeof opts.facility === 'number' ? opts.facility : Facility.Local0;
this.severity = typeof opts.severity === 'number' ? opts.severity : Severity.Informational;
this.rfc3164 = opts.rfc3164 ?? true;
this.appName = opts.appName ?? process.title.substring(process.title.lastIndexOf('/') + 1, 48);
this.dateFormatter = opts.dateFormatter ?? defaultDateFormatter;
this.udpBindAddress = opts.udpBindAddress;
this.transport = opts.transport ?? Transport.Udp;
this.tlsCA = opts.tlsCA;
}
/**
* Log a message to syslog.
* Supports both callback and promise-based API.
*
* @param message - Message to log
* @param options - Optional log options or callback
* @param errorCb - Optional callback
* @returns Promise<void> if no callback provided, otherwise void
*
* @example
* ```typescript
* // Callback API
* client.log('Test message', (error) => {
* if (error) console.error(error);
* });
*
* // Promise API
* await client.log('Test message');
* await client.log('Test message', { severity: Severity.Error });
* ```
*/
log(
message: string,
options?: LogOptions | SyslogCallback,
errorCb?: SyslogCallback,
): Promise<void> | void {
// Parse arguments
let opts: LogOptions = {};
let logCallback: SyslogCallback | undefined;
if (typeof options === 'function') {
logCallback = options;
} else if (typeof options === 'object') {
opts = options;
logCallback = errorCb;
}
// Promise mode: no callback provided
if (!logCallback) {
return new Promise<void>((resolve, reject) => {
this.logInternal(message, opts, (error) => {
if (error) reject(error);
else resolve();
});
});
}
// Callback mode
this.logInternal(message, opts, logCallback);
}
/**
* Internal log implementation using callbacks.
*/
private logInternal(message: string, options: LogOptions, errorCb: SyslogCallback): void {
// Validate options
const validationResult = logOptionsSchema.safeParse(options);
if (!validationResult.success) {
errorCb(ValidationError.fromZod('Invalid log options', validationResult.error.errors));
return;
}
// Resolve options with defaults
const resolvedOptions: ResolvedLogOptions = {
facility: options.facility ?? this.facility,
severity: options.severity ?? this.severity,
rfc3164: options.rfc3164 ?? this.rfc3164,
appName: options.appName ?? this.appName,
syslogHostname: options.syslogHostname ?? this.syslogHostname,
timestamp: options.timestamp,
msgid: options.msgid,
};
// Build formatted message
const formattedMessage = buildFormattedMessage(message, resolvedOptions, this.dateFormatter);
// Get transport and send
this.getTransport((error, transport) => {
if (error || !transport) {
errorCb(error ?? new ConnectionError('Failed to get transport'));
return;
}
this.sendMessage(transport, formattedMessage, errorCb);
});
}
/**
* Send message via transport.
*/
private sendMessage(
transport: TransportConnection,
message: Buffer,
completionCb: SyslogCallback,
): void {
try {
if (this.isStreamSocket(transport)) {
// TCP/TLS/Unix: use write
transport.write(message, (error) => {
if (error) {
completionCb(new TransportError('Write failed', this.getTransportName(), error));
} else {
completionCb();
}
});
} else if (this.isUdpSocket(transport)) {
// UDP: use send
transport.send(message, 0, message.length, this.port, this.target, (error) => {
if (error) {
completionCb(new TransportError('Send failed', 'UDP', error));
} else {
completionCb();
}
});
} else {
completionCb(new SyslogClientError(`Unknown transport: ${this.transport}`));
}
} catch (error) {
this.onError(this.normalizeError(error));
completionCb(this.normalizeError(error));
}
}
/**
* Get or create transport connection.
*/
private getTransport(
completionCb: (error: Error | null, transport?: TransportConnection) => void,
): void {
// Return existing transport
if (this.transport_) {
completionCb(null, this.transport_);
return;
}
// Queue request
this.getTransportRequests.push(completionCb);
// Already connecting, wait for result
if (this.connecting) {
return;
}
this.connecting = true;
// Create transport and notify all waiting requests
const notifyAllWaitingRequests = (error: Error | null, transport?: TransportConnection) => {
// Drain queue: notify all waiting callbacks
while (this.getTransportRequests.length > 0) {
const listenerCb = this.getTransportRequests.shift();
if (listenerCb) listenerCb(error, transport);
}
this.connecting = false;
};
// Create appropriate transport
if (this.transport === Transport.Udp) {
this.createUdpTransport(notifyAllWaitingRequests);
} else if (this.transport === Transport.Tcp || this.transport === Transport.Unix) {
this.createTcpTransport(notifyAllWaitingRequests);
} else if (this.transport === Transport.Tls) {
this.createTlsTransport(notifyAllWaitingRequests);
} else {
notifyAllWaitingRequests(
new SyslogClientError(`Unknown transport: ${this.getTransportName()}`),
);
}
}
/**
* Create TCP or Unix socket transport.
*/
private createTcpTransport(
completionCb: (error: Error | null, transport?: TransportConnection) => void,
): void {
const options =
this.transport === Transport.Unix
? { path: this.target }
: {
host: this.target,
port: this.port,
family: isIPv6(this.target) ? 6 : 4,
};
let transport: net.Socket;
try {
transport = net.createConnection(options, () =>
this.onSocketConnected(transport, completionCb),
);
} catch (error) {
completionCb(
new ConnectionError('Failed to create TCP connection', this.normalizeError(error)),
);
this.onError(this.normalizeError(error));
return;
}
this.setupSocketHandlers(transport, completionCb);
}
/**
* Create TLS transport.
*/
private createTlsTransport(
completionCb: (error: Error | null, transport?: TransportConnection) => void,
): void {
const options: tls.ConnectionOptions = {
host: this.target,
port: this.port,
ca: this.tlsCA,
minVersion: 'TLSv1.2',
};
let transport: tls.TLSSocket;
try {
transport = tls.connect(options, () => this.onSocketConnected(transport, completionCb));
} catch (error) {
completionCb(
new ConnectionError('Failed to create TLS connection', this.normalizeError(error)),
);
this.onError(this.normalizeError(error));
return;
}
this.setupSocketHandlers(transport, completionCb);
}
/**
* Setup event handlers for stream-based transports (TCP/TLS/Unix).
*/
private setupSocketHandlers(
socket: net.Socket | tls.TLSSocket,
completionCb: (error: Error | null, transport?: TransportConnection) => void,
): void {
// Timeout handler
socket.setTimeout(this.tcpTimeout, () => {
const error = new TimeoutError();
socket.destroy();
this.emit('error', error);
completionCb(error);
});
// Error handler
socket.on('error', (socketError: Error) => {
socket.destroy();
const error = new ConnectionError('Transport error', socketError);
this.onError(socketError);
completionCb(error);
});
// Close handler
socket.on('close', this.onClose.bind(this));
socket.unref();
}
/**
* Handle successful socket connection.
*/
private onSocketConnected(
socket: net.Socket | tls.TLSSocket,
completionCb: (error: Error | null, transport?: TransportConnection) => void,
): void {
this.transport_ = socket;
socket.setTimeout(0); // Clear connection timeout
completionCb(null, this.transport_);
}
/**
* Create UDP transport.
*/
private createUdpTransport(
completionCb: (error: Error | null, transport?: TransportConnection) => void,
): void {
try {
const family = isIPv6(this.target) ? 6 : 4;
this.transport_ = dgram.createSocket(`udp${family}` as dgram.SocketType);
// Bind to specific address if specified
if (this.udpBindAddress) {
this.transport_.bind({ address: this.udpBindAddress });
}
// Setup event handlers
this.transport_.on('close', this.onClose.bind(this));
this.transport_.on('error', (transportError) => {
const error = new ConnectionError('UDP socket error', transportError);
this.onError(error);
completionCb(error);
});
// Unref to not block process exit
this.transport_.unref();
completionCb(null, this.transport_);
} catch (transportError) {
if (this.transport_ && this.isUdpSocket(this.transport_)) {
try {
this.transport_.close();
} catch {
// Ignore cleanup error
}
}
const error = this.normalizeError(transportError);
this.onError(error);
completionCb(new ConnectionError('Failed to create UDP socket', error));
}
}
/**
* Close the client and destroy the transport.
*
* @returns this for chaining
*/
close(): this {
if (this.transport_) {
if (this.isStreamSocket(this.transport_)) {
this.transport_.destroy();
} else if (this.isUdpSocket(this.transport_)) {
this.transport_.close();
}
this.transport_ = undefined;
} else {
this.onClose();
}
return this;
}
/**
* Handle close event.
*/
private onClose(): this {
if (this.transport_) {
if ('destroy' in this.transport_) {
this.transport_.destroy();
}
this.transport_ = undefined;
}
this.emit('close');
return this;
}
/**
* Handle error event.
*/
private onError(error: Error): this {
if (this.transport_) {
if ('destroy' in this.transport_) {
this.transport_.destroy();
}
this.transport_ = undefined;
}
this.emit('error', error);
return this;
}
/**
* Type guard to check if transport is a stream socket (TCP/TLS/Unix).
*/
private isStreamSocket(transport: TransportConnection): transport is net.Socket | tls.TLSSocket {
return 'write' in transport && typeof transport.write === 'function';
}
/**
* Type guard to check if transport is a UDP socket.
*/
private isUdpSocket(transport: TransportConnection): transport is dgram.Socket {
return 'send' in transport && typeof transport.send === 'function';
}
/**
* Get transport name as string.
* Required because const enums don't have reverse mapping at runtime.
*/
private getTransportName(): string {
switch (this.transport) {
case Transport.Tcp:
return 'TCP';
case Transport.Udp:
return 'UDP';
case Transport.Tls:
return 'TLS';
case Transport.Unix:
return 'Unix';
default:
return 'Unknown';
}
}
/**
* Normalize any error to an Error instance.
*/
private normalizeError(error: unknown): Error {
return error instanceof Error ? error : new Error(String(error));
}
}
@@ -0,0 +1,56 @@
/* eslint-disable no-restricted-syntax */
/* We want the runtime overhead here */
/**
* Transport protocols supported by the syslog client.
*/
export enum Transport {
Tcp = 1,
Udp = 2,
Tls = 3,
Unix = 4,
}
/**
* Syslog facility codes as defined in RFC 5424.
*/
export enum Facility {
Kernel = 0,
User = 1,
Mail = 2,
System = 3,
// eslint-disable-next-line @typescript-eslint/no-duplicate-enum-values
Daemon = 3,
Auth = 4,
Syslog = 5,
Lpr = 6,
News = 7,
Uucp = 8,
Cron = 9,
Authpriv = 10,
Ftp = 11,
Audit = 13,
Alert = 14,
Local0 = 16,
Local1 = 17,
Local2 = 18,
Local3 = 19,
Local4 = 20,
Local5 = 21,
Local6 = 22,
Local7 = 23,
}
/**
* Syslog severity levels as defined in RFC 5424.
*/
export enum Severity {
Emergency = 0,
Alert = 1,
Critical = 2,
Error = 3,
Warning = 4,
Notice = 5,
Informational = 6,
Debug = 7,
}
+84
View File
@@ -0,0 +1,84 @@
import type { ZodIssue } from 'zod';
/**
* Base error class for all syslog client errors.
* Extends native Error with additional context.
*/
export class SyslogClientError extends Error {
constructor(
message: string,
readonly code?: string,
readonly cause?: Error,
) {
super(message);
this.name = 'SyslogClientError';
// Maintain proper prototype chain for instanceof checks
Object.setPrototypeOf(this, SyslogClientError.prototype);
// Capture stack trace, excluding constructor call from it
if (Error.captureStackTrace) {
Error.captureStackTrace(this, this.constructor);
}
}
}
/**
* Error thrown when client options validation fails.
*/
export class ValidationError extends SyslogClientError {
constructor(
message: string,
readonly validationErrors: Array<{ path: string; message: string }>,
) {
super(message, 'VALIDATION_ERROR');
this.name = 'ValidationError';
Object.setPrototypeOf(this, ValidationError.prototype);
}
static fromZod(message: string, zodErrors: ZodIssue[]) {
const errors = zodErrors.map((zodError) => ({
path: zodError.path.join('.'),
message: zodError.message,
}));
return new ValidationError(message, errors);
}
}
/**
* Error thrown when transport connection fails.
*/
export class ConnectionError extends SyslogClientError {
constructor(message: string, cause?: Error) {
super(message, 'CONNECTION_ERROR', cause);
this.name = 'ConnectionError';
Object.setPrototypeOf(this, ConnectionError.prototype);
}
}
/**
* Error thrown when transport operations fail (send/write).
*/
export class TransportError extends SyslogClientError {
constructor(
message: string,
readonly transportType: string,
cause?: Error,
) {
super(message, 'TRANSPORT_ERROR', cause);
this.name = 'TransportError';
Object.setPrototypeOf(this, TransportError.prototype);
}
}
/**
* Error thrown when timeout occurs.
*/
export class TimeoutError extends SyslogClientError {
constructor(message: string = 'Connection timed out') {
super(message, 'TIMEOUT_ERROR');
this.name = 'TimeoutError';
Object.setPrototypeOf(this, TimeoutError.prototype);
}
}
@@ -0,0 +1,27 @@
import { SyslogClient } from './client';
import type { ClientOptions } from './types';
/**
* Factory function to create a syslog client.
* Provided for backward compatibility with original API.
*
* @param target - Target host/path (IP address, hostname, or Unix socket path)
* @param options - Client configuration options
* @returns New SyslogClient instance
*
* @example
* ```typescript
* import { createClient, Transport } from '@n8n/syslog-client';
*
* const client = createClient('192.168.1.1', {
* transport: Transport.Tcp,
* port: 514,
* });
*
* await client.log('Test message');
* client.close();
* ```
*/
export function createClient(target?: string, options?: ClientOptions): SyslogClient {
return new SyslogClient(target, options);
}
+21
View File
@@ -0,0 +1,21 @@
/**
* @n8n/syslog-client
*
* A syslog client for Node.js supporting UDP, TCP, TLS, and Unix socket transports.
* Supports both RFC 3164 and RFC 5424 syslog message formats.
*
* Based upon the great work done in:
* https://github.com/paulgrove/node-syslog-client
*/
export { SyslogClient } from './client';
export { createClient } from './factory';
export { Facility, Severity, Transport } from './constants';
export type { ClientOptions, DateFormatter, LogOptions, SyslogCallback } from './types';
export {
ConnectionError,
SyslogClientError,
TimeoutError,
TransportError,
ValidationError,
} from './errors';
@@ -0,0 +1,56 @@
import { z } from 'zod';
import { Facility, Severity, Transport } from './constants';
/**
* Helper to dynamically create Zod schema for enum values.
* Filters out string keys that regular enums create at runtime.
*/
const createEnumSchema = (enumObject: object, name: string) => {
const values = Object.values(enumObject).filter((val) => typeof val === 'number');
return z.number().refine((val) => values.includes(val), {
message: `Invalid ${name} value. Must be one of: ${values.join(', ')}`,
});
};
/**
* Zod schema for validating ClientOptions.
*/
export const clientOptionsSchema = z.object({
syslogHostname: z.string().optional(),
port: z.number().int().positive().max(65535).optional(),
tcpTimeout: z.number().int().positive().optional(),
facility: createEnumSchema(Facility, 'facility').optional(),
severity: createEnumSchema(Severity, 'severity').optional(),
rfc3164: z.boolean().optional(),
appName: z.string().max(48).optional(), // RFC 5424 limit
dateFormatter: z.function().args(z.date()).returns(z.string()).optional(),
udpBindAddress: z.string().ip().optional(),
transport: createEnumSchema(Transport, 'transport').optional(),
tlsCA: z
.union([z.string(), z.array(z.string()), z.instanceof(Buffer), z.array(z.instanceof(Buffer))])
.optional(),
});
/**
* Zod schema for validating LogOptions.
*/
export const logOptionsSchema = z.object({
facility: createEnumSchema(Facility, 'facility').optional(),
severity: createEnumSchema(Severity, 'severity').optional(),
rfc3164: z.boolean().optional(),
appName: z.string().max(48).optional(),
syslogHostname: z.string().optional(),
timestamp: z.instanceof(Date).optional(),
msgid: z.string().max(32).optional(), // RFC 5424 limit
});
/**
* Inferred type from clientOptionsSchema for consistency.
*/
export type ValidatedClientOptions = z.infer<typeof clientOptionsSchema>;
/**
* Inferred type from logOptionsSchema for consistency.
*/
export type ValidatedLogOptions = z.infer<typeof logOptionsSchema>;
+147
View File
@@ -0,0 +1,147 @@
import type * as dgram from 'dgram';
import type * as net from 'net';
import type * as tls from 'tls';
import type { Facility, Severity, Transport } from './constants';
/**
* Callback type for legacy API support.
*/
export type SyslogCallback = (error?: Error) => void;
/**
* Date formatter function type.
* Takes a Date object and returns a formatted string.
*/
export type DateFormatter = (date: Date) => string;
/**
* Options for creating a syslog client.
*/
export interface ClientOptions {
/**
* Hostname to use in syslog messages.
* @default os.hostname()
*/
syslogHostname?: string;
/**
* Port number for TCP/TLS/UDP connections.
* @default 514
*/
port?: number;
/**
* TCP connection timeout in milliseconds.
* @default 10000
*/
tcpTimeout?: number;
/**
* Default facility for log messages.
* @default Facility.Local0
*/
facility?: Facility;
/**
* Default severity for log messages.
* @default Severity.Informational
*/
severity?: Severity;
/**
* Use RFC 3164 format (true) or RFC 5424 format (false).
* @default true
*/
rfc3164?: boolean;
/**
* Application name for RFC 5424 format.
* @default process.title
*/
appName?: string;
/**
* Custom date formatter function.
* @default Date.prototype.toISOString
*/
dateFormatter?: DateFormatter;
/**
* UDP bind address for outgoing datagrams.
* If not specified, node will bind to 0.0.0.0.
*/
udpBindAddress?: string;
/**
* Transport protocol to use.
* @default Transport.Udp
*/
transport?: Transport;
/**
* TLS CA certificate(s). Only used when transport is Transport.Tls.
*/
tlsCA?: string | string[] | Buffer | Buffer[];
}
/**
* Options for individual log messages.
* These override the client defaults for a single message.
*/
export interface LogOptions {
/**
* Override facility for this message.
*/
facility?: Facility;
/**
* Override severity for this message.
*/
severity?: Severity;
/**
* Override RFC format for this message.
*/
rfc3164?: boolean;
/**
* Override app name for this message.
*/
appName?: string;
/**
* Override syslog hostname for this message.
*/
syslogHostname?: string;
/**
* Custom timestamp for the message.
* Useful for back-dating messages based on external timestamps.
*/
timestamp?: Date;
/**
* Message ID for RFC 5424 format.
* @default "-"
*/
msgid?: string;
}
/**
* Internal type for resolved log options with all defaults applied.
*/
export interface ResolvedLogOptions {
facility: Facility;
severity: Severity;
rfc3164: boolean;
appName: string;
syslogHostname: string;
timestamp?: Date;
msgid?: string;
}
/**
* Union type for all possible transport implementations.
*/
export type TransportConnection = dgram.Socket | net.Socket | tls.TLSSocket;
+69
View File
@@ -0,0 +1,69 @@
import type { DateFormatter, ResolvedLogOptions } from './types';
/**
* Default date formatter for RFC 5424 format.
* Returns ISO 8601 timestamp.
*/
export const defaultDateFormatter: DateFormatter = (date) => date.toISOString();
/**
* Format RFC 3164 timestamp.
* Example: "Jan 15 08:30:00"
*
* Note: BSD syslog requires leading 0's in day to be a space.
*/
export const formatRfc3164Timestamp = (date: Date): string => {
const elements = date.toString().split(/\s+/);
const month = elements[1];
let day = elements[2];
const time = elements[4];
// BSD syslog requires leading 0's to be a space
if (day[0] === '0') {
day = ' ' + day.substring(1);
}
return `${month} ${day} ${time}`;
};
/**
* Build formatted syslog message according to RFC 3164 or RFC 5424.
*
* @param message - The message to format
* @param options - Resolved log options with all defaults applied
* @param dateFormatter - Date formatter function
* @returns Buffer containing the formatted syslog message
*/
export const buildFormattedMessage = (
message: string,
options: ResolvedLogOptions,
dateFormatter: DateFormatter,
): Buffer => {
const date = options.timestamp ?? new Date();
const pri = options.facility * 8 + options.severity;
const newline = message.endsWith('\n') ? '' : '\n';
let formattedMessage: string;
if (options.rfc3164) {
// RFC 3164 format: <PRI>TIMESTAMP HOSTNAME MESSAGE
const timestamp = formatRfc3164Timestamp(date);
formattedMessage = `<${pri}>${timestamp} ${options.syslogHostname} ${message}${newline}`;
} else {
// RFC 5424 format: <PRI>VERSION TIMESTAMP HOSTNAME APP-NAME PROCID MSGID SD MESSAGE
const timestamp = dateFormatter(date);
const msgid = options.msgid ?? '-';
formattedMessage = `<${pri}>1 ${timestamp} ${options.syslogHostname} ${options.appName} ${process.pid} ${msgid} - ${message}${newline}`;
}
return Buffer.from(formattedMessage);
};
/**
* Check if an address is IPv6.
* Simple check based on presence of colons.
*
* @param address - IP address to check
* @returns true if IPv6, false otherwise
*/
export const isIPv6 = (address: string): boolean => address.includes(':');
@@ -0,0 +1,84 @@
import * as os from 'os';
import { Facility, Severity, SyslogClient, Transport } from '../src';
import { TLS_CERTIFICATE } from './setup';
describe('SyslogClient - Core', () => {
describe('Constructor and defaults', () => {
it('should set default options correctly', () => {
const client = new SyslogClient();
expect(client.target).toBe('127.0.0.1');
expect(client.port).toBe(514);
expect(client.syslogHostname).toBe(os.hostname());
expect(client.tcpTimeout).toBe(10000);
expect(client.transport).toBe(Transport.Udp);
client.close();
});
it('should accept target parameter', () => {
const client = new SyslogClient('127.0.0.2');
expect(client.target).toBe('127.0.0.2');
expect(client.port).toBe(514);
expect(client.syslogHostname).toBe(os.hostname());
client.close();
});
it('should accept custom hostname', () => {
const client = new SyslogClient('127.0.0.2', {
syslogHostname: 'test',
});
expect(client.target).toBe('127.0.0.2');
expect(client.syslogHostname).toBe('test');
client.close();
});
it('should accept custom port and timeout', () => {
const client = new SyslogClient('127.0.0.2', {
syslogHostname: 'test',
port: 5555,
tcpTimeout: 50,
});
expect(client.port).toBe(5555);
expect(client.tcpTimeout).toBe(50);
client.close();
});
it('should accept TCP transport option', () => {
const client = new SyslogClient('127.0.0.2', {
port: 5555,
transport: Transport.Tcp,
});
expect(client.transport).toBe(Transport.Tcp);
client.close();
});
it('should accept TLS transport with certificate', () => {
const client = new SyslogClient('127.0.0.2', {
port: 6514,
transport: Transport.Tls,
tlsCA: TLS_CERTIFICATE,
});
expect(client.transport).toBe(Transport.Tls);
expect(client.tlsCA).toBe(TLS_CERTIFICATE);
client.close();
});
it('should accept facility and severity options', () => {
const client = new SyslogClient('127.0.0.1', {
facility: Facility.Mail,
severity: Severity.Critical,
});
expect(client.facility).toBe(Facility.Mail);
expect(client.severity).toBe(Severity.Critical);
client.close();
});
it('should accept RFC format option', () => {
const client = new SyslogClient('127.0.0.1', {
rfc3164: false,
});
expect(client.rfc3164).toBe(false);
client.close();
});
});
});
+32
View File
@@ -0,0 +1,32 @@
# Test Fixtures
This directory contains test certificates for TLS testing.
## Required Files
These files can be regenerated using the following commands:
- `key.pem` - Private key for test TLS server
- `certificate.pem` - Valid certificate for test TLS server
- `wrong.pem` - Invalid certificate for testing certificate validation
## Generate Test Certificates
You can generate self-signed test certificates using OpenSSL:
```bash
# Generate private key
openssl genrsa -out key.pem 2048
# Generate valid certificate
openssl req -new -x509 -key key.pem -out certificate.pem -days 10000 \
-subj "/C=US/ST=Test/L=Test/O=Test/CN=localhost"
# Generate wrong certificate (different key)
openssl genrsa -out wrong-key.pem 2048
openssl req -new -x509 -key wrong-key.pem -out wrong.pem -days 10000 \
-subj "/C=US/ST=Wrong/L=Wrong/O=Wrong/CN=wrong"
rm wrong-key.pem
```
These certificates are only for testing and should never be used in production.
@@ -0,0 +1,21 @@
-----BEGIN CERTIFICATE-----
MIIDfzCCAmegAwIBAgIUEo4pU1hgsKHlV01QIN3hhDaQ6ggwDQYJKoZIhvcNAQEL
BQAwTjELMAkGA1UEBhMCVVMxDTALBgNVBAgMBFRlc3QxDTALBgNVBAcMBFRlc3Qx
DTALBgNVBAoMBFRlc3QxEjAQBgNVBAMMCWxvY2FsaG9zdDAgFw0yNjAxMDUyMDQ1
NDhaGA8yMDUzMDUyMzIwNDU0OFowTjELMAkGA1UEBhMCVVMxDTALBgNVBAgMBFRl
c3QxDTALBgNVBAcMBFRlc3QxDTALBgNVBAoMBFRlc3QxEjAQBgNVBAMMCWxvY2Fs
aG9zdDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAMzoP33jcgWYJ/mS
A8NQm9gABgeOVHlc4lEqHsMo9gZy3GfmanI/XFpTr3Nxeb2AMqJ1B9EgXOtJLBDP
J+Z+k3jN9Sv2ZRSClf6ZZUUPFKcjQ6TNYXG/Jgmjoku7k0I9cJluXE8VwlC9mzty
iHbZGo/tRtU4LkbaedmL2eTctcBukX4g99uVMJVKot5l8PWxZ1seVZ6T4IiRcaRo
NcQqL67xspm3KmlrVx/WzlM41BfGj2k+L4Bgenm/bIg1RPQ+MoETViVFGeKgKjkp
kTOjCsZqeK2STCIj1+XI4tLMCxV0B95fw7bcFfEtbnL9XyEjyn+3A33625EJMoiq
OX6bb9MCAwEAAaNTMFEwHQYDVR0OBBYEFEB7f6J6nW90lXS8i4gOR/6rya7qMB8G
A1UdIwQYMBaAFEB7f6J6nW90lXS8i4gOR/6rya7qMA8GA1UdEwEB/wQFMAMBAf8w
DQYJKoZIhvcNAQELBQADggEBAIXoFrV9saNREtlDkMp5O66IO+UFhr1NcLYmXvhs
n3H+OFpg/Uo+/p++vHgf5UKNTdx24pO/88FyPujIcq1mfemrAIgcd8t++aclzSlf
G49bZvhYyxsE+qoyl7XWtmYLPydCpYKkL7krLwUVzSeC/f6f/GWEn0vvwWPht5ky
dR/Uze7pg/Ux12gztSLPV77n9gBua6lj5HKhXYGoIFuZSw9n1tWqndT28OQ3VwI7
ZSpFFJmDyevspC2F18NaTE5VmXv9gckeUmqx8nr0zOmWWREVnFt+d4NNvrNLEChj
IJFfkzynUHYTe+1oQkv8Evr0GHU5ocHsqOQ4NqW5gx0XXRA=
-----END CERTIFICATE-----
+28
View File
@@ -0,0 +1,28 @@
-----BEGIN PRIVATE KEY-----
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDM6D9943IFmCf5
kgPDUJvYAAYHjlR5XOJRKh7DKPYGctxn5mpyP1xaU69zcXm9gDKidQfRIFzrSSwQ
zyfmfpN4zfUr9mUUgpX+mWVFDxSnI0OkzWFxvyYJo6JLu5NCPXCZblxPFcJQvZs7
coh22RqP7UbVOC5G2nnZi9nk3LXAbpF+IPfblTCVSqLeZfD1sWdbHlWek+CIkXGk
aDXEKi+u8bKZtyppa1cf1s5TONQXxo9pPi+AYHp5v2yINUT0PjKBE1YlRRnioCo5
KZEzowrGanitkkwiI9flyOLSzAsVdAfeX8O23BXxLW5y/V8hI8p/twN9+tuRCTKI
qjl+m2/TAgMBAAECggEARqkwe/EbOOKnpmC7+3aox2v6qFBkwRVO1j54LUTK0p55
czr6Kju/VUbWkjDnknnK/ErvTyah5GFvWLyXPd0YbehYS7jEUrZVk17ClXYF4T0/
7m0E1XzdWSWcqhEdTxJw6fgszPjr8XvxNCbi+FkV19wzOQQOsVBWBLc9hLa31ous
CCMmpmJD+KaaN1D7VKDyLOtMQDPM7xft9VcWTU0qATN86HzvTxRF3+wWi1Z8ChV+
miMfu4yb4Ip/tFSZfCJQqWupxdXYxw+p6HbcYRxSFa8ux4oZoQwvSVIi3pGKFXij
/0oBhx2N1Y0T5LnuUZvne6CjwSyRSCmN68FnUKiogQKBgQD5UuHXcMZiDyZSskpj
6rj//Hc3I9eRvB2CIvxVbDecRoCat3r9SZmZJ+DFxyH9Voy0ClrQnjSjPsXR5T89
Ya+dFELl0zg7scHMw9VVSBsIjPrBhCnqPII1tfZj5ZzKI0oM1P8DwsoBOMotZIjl
eHsUsd3Qw5sogbrwJ5d0cuDcaQKBgQDSZOPLwuEtAc7qyu1lSbifCMYwM4ISac3F
2f116BV43/9L8eQv41XmVl1ELmwebahQdIbTtP5bNGDRHLRyIfbsSWxzBamQkKX9
INzsiaeCENMTEHo4oSXX89DoZkr50iT62y9DKF5TkzNkA+CMvUuKfkh8YZAnLizA
dhrM0NOS2wKBgQD3nJ/ctgzgEEmwMY994fxvhw3i/j62ZswrlZFjSQGFu+M3ROfp
q3HXUGqEIbuY/Z7po6sDq0t9oPcX+QQwctbVOu0hkP81Edn2AbvaYa0vdcwH6rkx
/3wV2axlFAH+IsQFMHhABPFX+02XfVQUCe6649b24X0z4nuEzN028mxtmQKBgC7q
XHmVbUzx+EgXFDTf6ZFdDYS2F60vdrlF0OU36YHYwT8YI4AiO4dvfsCzcVyfq0a1
lVMkKwv/dA9tTTeeJ0etX8eOXa9k8f6uE1WTpIy2X7sCk56JIL01G0KXfOSUXbaq
pbFeERql2nHVA3+evneVjWhfARwbidMpAvTlJCi9AoGAQxFKi/dKzjZQFkikv9xU
QpvtrfjZvYHU56HMDkKA4yIjBpONPWc6TAir5eOroKIKxAF0K+5s/bcQqZagShfZ
WhZSdUid4J+gPCYL2v5F8symu9dxP54xRw1886uJUuqyb7tHhdFCOHRclD5wlLg3
OrfEUwhYRn7d21UZoGDAvYU=
-----END PRIVATE KEY-----
+11
View File
@@ -0,0 +1,11 @@
#!/usr/bin/env bash
# Generate valid certificate
openssl req -new -x509 -key key.pem -out certificate.pem -days 10000 \
-subj "/C=US/ST=Test/L=Test/O=Test/CN=localhost"
# Generate wrong certificate (different key)
openssl genrsa -out wrong-key.pem 2048
openssl req -new -x509 -key wrong-key.pem -out wrong.pem -days 10000 \
-subj "/C=US/ST=Wrong/L=Wrong/O=Wrong/CN=wrong"
rm wrong-key.pem
+21
View File
@@ -0,0 +1,21 @@
-----BEGIN CERTIFICATE-----
MIIDfTCCAmWgAwIBAgIUfC52wMYMzcoL9c/GY3Ks0beSH2kwDQYJKoZIhvcNAQEL
BQAwTTELMAkGA1UEBhMCVVMxDjAMBgNVBAgMBVdyb25nMQ4wDAYDVQQHDAVXcm9u
ZzEOMAwGA1UECgwFV3JvbmcxDjAMBgNVBAMMBXdyb25nMCAXDTI2MDEwNTIwNDU0
OFoYDzIwNTMwNTIzMjA0NTQ4WjBNMQswCQYDVQQGEwJVUzEOMAwGA1UECAwFV3Jv
bmcxDjAMBgNVBAcMBVdyb25nMQ4wDAYDVQQKDAVXcm9uZzEOMAwGA1UEAwwFd3Jv
bmcwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCj1725lupt3pTvMf2O
o931s2l4ObA5qTC6ihlWJX+A7R4RkHrQTlwahEwIgMNSzNURtXOwu3ePyqZE3Luk
pVWGMFdgedNuU+4zNUXXamIyIJfsMuEmw6xrWLpdWqawo0G50Y2RxB4MN6/127PL
EqogXC+0PHdgBUWtso/sJcGRrdQvGpmH9ZXavVNhER4G3q6f/Tcp1eu0xry1UwGV
WN/DghkE5R/9mLm830Lr7gLpEQ6JKUUTnPKaZQW9m9G8gjr6oEXR9QaTcTariHgx
HT0wkGRKO54CC3RdBxqwucdkvgUI7OkaGl8tS46QMI4DwQHcUFLkqx29wXHY/Zhu
mHxDAgMBAAGjUzBRMB0GA1UdDgQWBBT8E+hqfJTcai2AlqT27R4KL/nGNTAfBgNV
HSMEGDAWgBT8E+hqfJTcai2AlqT27R4KL/nGNTAPBgNVHRMBAf8EBTADAQH/MA0G
CSqGSIb3DQEBCwUAA4IBAQCf38uZYe6+cW7/juw2wCoLObSBoomRuJicwpRbDJpo
kzKzlBm4KbAwrz0+2kugZCrqrY1pLZPozQiqqf7UOImNcBi/wCbNk6iksdD9WRpv
tooKb2UHY9Z+1VJ7FqBhEekUJnxAoOlmOnSwPW3qDQNaP+niWZEZjgK+m+3/ZI5d
oW8lU1UGYJs/pZrZDwPUGHRnPj7Sa6q0S5OCVFx4BN+YMKN9ynvVG0z0pkO3QpA3
wOYu/WxINCuFb6Iu469Ov/FoSfnqZRcqZ+PlMDWHr0NCj0DNBP/eGq8VWy6VRabQ
SyRRjByPFe8fK1PAbrwIh8w6QIWz28OQZjAz0TsHnK7D
-----END CERTIFICATE-----
@@ -0,0 +1,285 @@
import { SyslogClient, Transport } from '../src';
import {
awaitUdpMsg,
constructRfc5424Regex,
constructSyslogRegex,
startTestServers,
stopTestServers,
SYSLOG_UDP_PORT,
} from './setup';
beforeAll(async () => {
await startTestServers();
});
afterAll(() => {
stopTestServers();
});
describe('SyslogClient - Message Formats', () => {
describe('RFC 3164 (BSD syslog)', () => {
it('should send RFC 3164 format by default', async () => {
const hostname = 'testhostname';
const client = new SyslogClient('127.0.0.1', {
port: SYSLOG_UDP_PORT,
syslogHostname: hostname,
transport: Transport.Udp,
});
await client.log('RFC 3164 test');
const msg = await awaitUdpMsg();
expect(msg).toMatch(constructSyslogRegex(134, hostname, 'RFC 3164 test'));
client.close();
});
it('should send RFC 3164 format when explicitly enabled', async () => {
const hostname = 'testhostname';
const client = new SyslogClient('127.0.0.1', {
port: SYSLOG_UDP_PORT,
syslogHostname: hostname,
transport: Transport.Udp,
rfc3164: true,
});
await client.log('RFC 3164 explicit');
const msg = await awaitUdpMsg();
expect(msg).toMatch(constructSyslogRegex(134, hostname, 'RFC 3164 explicit'));
client.close();
});
it('should send back-dated RFC 3164 messages', async () => {
const hostname = 'testhostname';
const client = new SyslogClient('127.0.0.1', {
port: SYSLOG_UDP_PORT,
syslogHostname: hostname,
transport: Transport.Udp,
});
const backdate = new Date(2017, 2, 1);
await client.log('Back-dated test', { timestamp: backdate });
const msg = await awaitUdpMsg();
expect(msg).toMatch(constructSyslogRegex(134, hostname, 'Back-dated test', backdate));
client.close();
});
it('should handle messages with custom options', async () => {
const hostname = 'testhostname';
const client = new SyslogClient('127.0.0.1', {
port: SYSLOG_UDP_PORT,
syslogHostname: hostname,
transport: Transport.Udp,
});
await client.log('Custom options', {
rfc3164: true,
msgid: '12345', // msgid is ignored in RFC 3164
});
const msg = await awaitUdpMsg();
expect(msg).toMatch(constructSyslogRegex(134, hostname, 'Custom options'));
client.close();
});
});
describe('RFC 5424 (modern syslog)', () => {
it('should send RFC 5424 format when enabled', async () => {
const hostname = 'testhostname';
const client = new SyslogClient('127.0.0.1', {
port: SYSLOG_UDP_PORT,
syslogHostname: hostname,
transport: Transport.Udp,
rfc3164: false,
});
await client.log('RFC 5424 test');
const msg = await awaitUdpMsg();
expect(msg).toMatch(constructRfc5424Regex(134, hostname, 'RFC 5424 test', '-'));
client.close();
});
it('should use default msgid when not provided', async () => {
const hostname = 'testhostname';
const client = new SyslogClient('127.0.0.1', {
port: SYSLOG_UDP_PORT,
syslogHostname: hostname,
transport: Transport.Udp,
});
await client.log('Default msgid', { rfc3164: false });
const msg = await awaitUdpMsg();
expect(msg).toMatch(constructRfc5424Regex(134, hostname, 'Default msgid', '-'));
client.close();
});
it('should use custom msgid when provided', async () => {
const hostname = 'testhostname';
const client = new SyslogClient('127.0.0.1', {
port: SYSLOG_UDP_PORT,
syslogHostname: hostname,
transport: Transport.Udp,
});
await client.log('Custom msgid', {
rfc3164: false,
msgid: '98765',
});
const msg = await awaitUdpMsg();
expect(msg).toMatch(constructRfc5424Regex(134, hostname, 'Custom msgid', '98765'));
client.close();
});
it('should accept msgid up to 32 characters (RFC 5424 limit)', async () => {
const hostname = 'testhostname';
const client = new SyslogClient('127.0.0.1', {
port: SYSLOG_UDP_PORT,
syslogHostname: hostname,
transport: Transport.Udp,
});
const maxLengthMsgid = '550e8400e29b41d4a716446655440000'; // 32 chars (UUID without hyphens)
await client.log('Max length msgid', {
rfc3164: false,
msgid: maxLengthMsgid,
});
const msg = await awaitUdpMsg();
expect(msg).toMatch(constructRfc5424Regex(134, hostname, 'Max length msgid', maxLengthMsgid));
client.close();
});
it('should reject msgid longer than 32 characters (RFC 5424 limit)', async () => {
const hostname = 'testhostname';
const client = new SyslogClient('127.0.0.1', {
port: SYSLOG_UDP_PORT,
syslogHostname: hostname,
transport: Transport.Udp,
});
const tooLongMsgid = '550e8400-e29b-41d4-a716-446655440000'; // 36 chars (UUID with hyphens)
await expect(
client.log('Too long msgid', {
rfc3164: false,
msgid: tooLongMsgid,
}),
).rejects.toThrow();
client.close();
});
it('should send back-dated RFC 5424 messages', async () => {
const hostname = 'testhostname';
const client = new SyslogClient('127.0.0.1', {
port: SYSLOG_UDP_PORT,
syslogHostname: hostname,
transport: Transport.Udp,
});
const backdate = new Date(2017, 2, 1);
await client.log('Back-dated RFC 5424', {
rfc3164: false,
msgid: '98765',
timestamp: backdate,
});
const msg = await awaitUdpMsg();
expect(msg).toMatch(
constructRfc5424Regex(134, hostname, 'Back-dated RFC 5424', '98765', backdate),
);
client.close();
});
it('should include process ID in RFC 5424 messages', async () => {
const hostname = 'testhostname';
const client = new SyslogClient('127.0.0.1', {
port: SYSLOG_UDP_PORT,
syslogHostname: hostname,
transport: Transport.Udp,
rfc3164: false,
});
await client.log('Process ID test');
const msg = await awaitUdpMsg();
// Check that message contains a process ID (numeric)
expect(msg).toMatch(/\d+ - - Process ID test/);
client.close();
});
});
describe('Format switching', () => {
it('should allow switching format per message', async () => {
const hostname = 'testhostname';
const client = new SyslogClient('127.0.0.1', {
port: SYSLOG_UDP_PORT,
syslogHostname: hostname,
transport: Transport.Udp,
rfc3164: true, // Default to RFC 3164
});
// Send RFC 3164 message (default)
await client.log('Message 1');
const msg1 = await awaitUdpMsg();
expect(msg1).toMatch(constructSyslogRegex(134, hostname, 'Message 1'));
// Send RFC 5424 message (override)
await client.log('Message 2', { rfc3164: false });
const msg2 = await awaitUdpMsg();
expect(msg2).toMatch(constructRfc5424Regex(134, hostname, 'Message 2', '-'));
// Send RFC 3164 message again (back to default)
await client.log('Message 3');
const msg3 = await awaitUdpMsg();
expect(msg3).toMatch(constructSyslogRegex(134, hostname, 'Message 3'));
client.close();
});
});
describe('Message content', () => {
it('should preserve newlines in message', async () => {
const hostname = 'testhostname';
const client = new SyslogClient('127.0.0.1', {
port: SYSLOG_UDP_PORT,
syslogHostname: hostname,
transport: Transport.Udp,
});
const messageWithNewline = 'Message with\nnewline';
await client.log(messageWithNewline);
const msg = await awaitUdpMsg();
expect(msg).toContain(messageWithNewline);
client.close();
});
it('should handle empty messages', async () => {
const hostname = 'testhostname';
const client = new SyslogClient('127.0.0.1', {
port: SYSLOG_UDP_PORT,
syslogHostname: hostname,
transport: Transport.Udp,
});
await client.log('');
const msg = await awaitUdpMsg();
expect(msg).toMatch(constructSyslogRegex(134, hostname, ''));
client.close();
});
});
});
+221
View File
@@ -0,0 +1,221 @@
import * as dgram from 'dgram';
import * as fs from 'fs';
import * as net from 'net';
import * as path from 'path';
import * as readline from 'readline';
import * as tls from 'tls';
// Test ports - dynamically assigned by getAvailablePort
export let SYSLOG_UDP_PORT = 0;
export let SYSLOG_TCP_PORT = 0;
export let SYSLOG_TLS_PORT = 0;
/**
* Get an available port by creating and immediately closing a server.
*/
const getAvailablePort = async (): Promise<number> =>
await new Promise((resolve, reject) => {
const server = net.createServer();
server.unref();
server.on('error', reject);
server.listen(0, () => {
const address = server.address();
if (!address || typeof address === 'string') {
server.close();
return reject(new Error('Unable to get port'));
}
const { port } = address;
server.close(() => resolve(port));
});
});
// Test certificates
export const TLS_PRIVATE_KEY = fs.readFileSync(path.join(__dirname, 'fixtures', 'key.pem'), 'utf8');
export const TLS_CERTIFICATE = fs.readFileSync(
path.join(__dirname, 'fixtures', 'certificate.pem'),
'utf8',
);
export const WRONG_CERTIFICATE = fs.readFileSync(
path.join(__dirname, 'fixtures', 'wrong.pem'),
'utf8',
);
// Message queues
const queuedUdpMessages: string[] = [];
const pendingUdpResolvers: Array<(msg: string) => void> = [];
const queuedTcpMessages: string[] = [];
const pendingTcpResolvers: Array<(msg: string) => void> = [];
const queuedTlsMessages: string[] = [];
const pendingTlsResolvers: Array<(msg: string) => void> = [];
// Test servers
let udpServer: dgram.Socket | null = null;
let tcpServer: net.Server | null = null;
let tlsServer: tls.Server | null = null;
/**
* Helper to await syslog messages from UDP server.
*/
export const awaitUdpMsg = async (): Promise<string> =>
await new Promise((resolve) => {
const queued = queuedUdpMessages.shift();
if (queued) return resolve(queued);
pendingUdpResolvers.push(resolve);
});
/**
* Helper to await syslog messages from TCP server.
*/
export const awaitTcpMsg = async (): Promise<string> =>
await new Promise((resolve) => {
const queued = queuedTcpMessages.shift();
if (queued) return resolve(queued);
pendingTcpResolvers.push(resolve);
});
/**
* Helper to await syslog messages from TLS server.
*/
export const awaitTlsMsg = async (): Promise<string> =>
await new Promise((resolve) => {
const queued = queuedTlsMessages.shift();
if (queued) return resolve(queued);
pendingTlsResolvers.push(resolve);
});
/**
* Escape special regex characters.
*/
const escapeRegExp = (text: string): string => text.replace(/[\\^$.*+?()[\]{}|]/g, '\\$&');
/**
* Construct regex to match RFC 3164 syslog message.
*/
export const constructSyslogRegex = (
pri: number,
hostname: string,
msg: string,
timestamp?: Date,
): RegExp => {
let patDate: string;
if (!timestamp) {
// RFC-3164 date format: "MMM dd HH:mm:ss"
// Month is 3 letters, day is space-padded, time is in 24-hour format
patDate = '[A-Z][a-z]{2}\\s{1,2}\\d{1,2}\\s\\d{2}:\\d{2}:\\d{2}';
} else {
const elements = timestamp.toString().split(/\s+/);
const month = elements[1];
let day = elements[2];
const time = elements[4];
// Ensure day is space-padded for single digits as per RFC-3164
if (day.length === 2 && day[0] === '0') {
day = ' ' + day.substring(1);
}
patDate = escapeRegExp(`${month} ${day} ${time}`);
}
// RFC-3164 format: <PRI>TIMESTAMP HOSTNAME MSG
// - PRI is enclosed in angle brackets
// - Single space between timestamp and hostname
// - Single space between hostname and message
// - Optional newline at the end
return new RegExp(
`^<${escapeRegExp(String(pri))}>${patDate}\\s${escapeRegExp(hostname)}\\s${escapeRegExp(msg)}\\n?$`,
);
};
/**
* Construct regex to match RFC 5424 syslog message.
*/
export const constructRfc5424Regex = (
pri: number,
hostname: string,
msg: string,
msgid: string | number,
timestamp?: Date,
): RegExp => {
const patDate = !timestamp
? '\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(\\.\\d+)?Z'
: escapeRegExp(timestamp.toISOString());
return new RegExp(
`^<${escapeRegExp(String(pri))}>\\d+ ${patDate} ${escapeRegExp(hostname)} \\S{1,48} \\d+ ${escapeRegExp(String(msgid))} - ${escapeRegExp(msg)}\\n?$`,
);
};
/**
* Start test syslog servers (UDP, TCP, TLS).
*/
export const startTestServers = async (): Promise<void> => {
// Get available ports
SYSLOG_UDP_PORT = await getAvailablePort();
SYSLOG_TCP_PORT = await getAvailablePort();
SYSLOG_TLS_PORT = await getAvailablePort();
// Setup UDP server
await new Promise<void>((resolve) => {
udpServer = dgram.createSocket('udp4');
udpServer.on('message', (msg) => {
const resolver = pendingUdpResolvers.shift();
if (resolver) return resolver(msg.toString());
queuedUdpMessages.push(msg.toString());
});
udpServer.on('listening', () => {
console.log(`Started UDP syslog server on port ${SYSLOG_UDP_PORT}`);
resolve();
});
udpServer.bind(SYSLOG_UDP_PORT);
});
// Setup TCP server
await new Promise<void>((resolve) => {
tcpServer = net.createServer((socket) => {
const lines = readline.createInterface({ input: socket, output: socket });
lines.on('line', (line) => {
const resolver = pendingTcpResolvers.shift();
if (resolver) return resolver(line);
queuedTcpMessages.push(line);
});
});
tcpServer.listen(SYSLOG_TCP_PORT, () => {
console.log(`Started TCP syslog server on port ${SYSLOG_TCP_PORT}`);
resolve();
});
});
// Setup TLS server
await new Promise<void>((resolve) => {
tlsServer = tls.createServer(
{
key: TLS_PRIVATE_KEY,
cert: TLS_CERTIFICATE,
secureProtocol: 'TLSv1_2_method',
},
(socket) => {
const lines = readline.createInterface({ input: socket, output: socket });
lines.on('line', (line) => {
const resolver = pendingTlsResolvers.shift();
if (resolver) return resolver(line);
queuedTlsMessages.push(line);
});
},
);
tlsServer.listen(SYSLOG_TLS_PORT, () => {
console.log(`Started TLS syslog server on port ${SYSLOG_TLS_PORT}`);
resolve();
});
});
};
/**
* Stop test syslog servers.
*/
export const stopTestServers = (): void => {
udpServer?.close();
tcpServer?.close();
tlsServer?.close();
udpServer = null;
tcpServer = null;
tlsServer = null;
};
@@ -0,0 +1,219 @@
import { Facility, Severity, SyslogClient, Transport } from '../src';
import {
awaitTcpMsg,
constructSyslogRegex,
startTestServers,
stopTestServers,
SYSLOG_TCP_PORT,
} from './setup';
beforeAll(async () => {
await startTestServers();
});
afterAll(() => {
stopTestServers();
});
describe('SyslogClient - TCP Transport', () => {
describe('Basic functionality', () => {
it('should connect and send log messages', async () => {
const hostname = 'testhostname';
const client = new SyslogClient('127.0.0.1', {
port: SYSLOG_TCP_PORT,
syslogHostname: hostname,
transport: Transport.Tcp,
});
await client.log('This is a test');
const msg1 = await awaitTcpMsg();
expect(msg1).toMatch(constructSyslogRegex(134, hostname, 'This is a test'));
await client.log('This is a second test');
const msg2 = await awaitTcpMsg();
expect(msg2).toMatch(constructSyslogRegex(134, hostname, 'This is a second test'));
client.close();
});
it('should work with promise API', async () => {
const hostname = 'testhostname';
const client = new SyslogClient('127.0.0.1', {
port: SYSLOG_TCP_PORT,
syslogHostname: hostname,
transport: Transport.Tcp,
});
await client.log('Promise test');
const msg = await awaitTcpMsg();
expect(msg).toMatch(constructSyslogRegex(134, hostname, 'Promise test'));
client.close();
});
it('should work with callback API', async () => {
const hostname = 'testhostname';
const client = new SyslogClient('127.0.0.1', {
port: SYSLOG_TCP_PORT,
syslogHostname: hostname,
transport: Transport.Tcp,
});
const callbackPromise = new Promise<void>((resolve) => {
void client.log('Callback test', (error) => {
expect(error).toBeUndefined();
resolve();
});
});
const msg = await awaitTcpMsg();
expect(msg).toMatch(constructSyslogRegex(134, hostname, 'Callback test'));
await callbackPromise;
client.close();
});
});
describe('Transport reuse', () => {
it('should reuse TCP transport for multiple messages', async () => {
const hostname = 'testhostname';
const client = new SyslogClient('127.0.0.1', {
port: SYSLOG_TCP_PORT,
syslogHostname: hostname,
transport: Transport.Tcp,
});
await client.log('Transport reuse test');
await awaitTcpMsg();
const transport1 = client['transport_'];
expect(transport1).toBeDefined();
await client.log('Transport reuse test 2');
await awaitTcpMsg();
const transport2 = client['transport_'];
expect(transport2).toBe(transport1);
client.close();
});
});
describe('Close and reconnect', () => {
it('should emit close event', async () => {
const client = new SyslogClient('127.0.0.1', {
port: SYSLOG_TCP_PORT,
transport: Transport.Tcp,
});
const closePromise = new Promise<void>((resolve) => {
client.once('close', resolve);
});
await client.log('Test');
await awaitTcpMsg();
client.close();
await closePromise;
expect(client['transport_']).toBeUndefined();
});
it('should reconnect after close', async () => {
const hostname = 'testhostname';
const client = new SyslogClient('127.0.0.1', {
port: SYSLOG_TCP_PORT,
syslogHostname: hostname,
transport: Transport.Tcp,
});
await client.log('Before close');
await awaitTcpMsg();
const closePromise = new Promise<void>((resolve) => {
client.once('close', resolve);
});
client.close();
await closePromise;
await client.log('After reconnect');
const msg = await awaitTcpMsg();
expect(msg).toMatch(constructSyslogRegex(134, hostname, 'After reconnect'));
client.close();
});
});
describe('Log options', () => {
it('should accept options with callback', async () => {
const hostname = 'testhostname';
const client = new SyslogClient('127.0.0.1', {
port: SYSLOG_TCP_PORT,
syslogHostname: hostname,
transport: Transport.Tcp,
});
const callbackPromise = new Promise<void>((resolve) => {
void client.log(
'With options',
{
facility: Facility.System,
severity: Severity.Notice,
},
(error) => {
expect(error).toBeUndefined();
resolve();
},
);
});
const msg = await awaitTcpMsg();
expect(msg).toMatch(constructSyslogRegex(29, hostname, 'With options'));
await callbackPromise;
client.close();
});
it('should calculate correct PRI value', async () => {
const hostname = 'testhostname';
const client = new SyslogClient('127.0.0.1', {
port: SYSLOG_TCP_PORT,
syslogHostname: hostname,
transport: Transport.Tcp,
});
await client.log('Test', {
facility: Facility.Local0,
severity: Severity.Emergency,
});
const msg = await awaitTcpMsg();
expect(msg).toMatch(constructSyslogRegex(128, hostname, 'Test'));
client.close();
});
});
describe('Error handling', () => {
it('should handle connection timeout', async () => {
const client = new SyslogClient('203.0.113.1', {
port: SYSLOG_TCP_PORT,
tcpTimeout: 500,
transport: Transport.Tcp,
});
const errorPromise = new Promise<Error>((resolve) => {
client.on('error', resolve);
});
await expect(client.log("Shouldn't work")).rejects.toThrow();
const error = await errorPromise;
expect(error).toBeInstanceOf(Error);
});
it('should handle invalid port', () => {
expect(() => {
new SyslogClient('127.0.0.1', {
port: 502342323,
tcpTimeout: 2000,
transport: Transport.Tcp,
});
}).toThrow();
});
});
});
@@ -0,0 +1,187 @@
import { ConnectionError, SyslogClient, Transport } from '../src';
import {
awaitTlsMsg,
constructSyslogRegex,
startTestServers,
stopTestServers,
SYSLOG_TLS_PORT,
TLS_CERTIFICATE,
WRONG_CERTIFICATE,
} from './setup';
beforeAll(async () => {
await startTestServers();
});
afterAll(() => {
stopTestServers();
});
describe('SyslogClient - TLS Transport', () => {
describe('Basic functionality', () => {
it('should connect and send log messages', async () => {
const hostname = 'testhostname';
const client = new SyslogClient('localhost', {
port: SYSLOG_TLS_PORT,
syslogHostname: hostname,
transport: Transport.Tls,
tlsCA: TLS_CERTIFICATE,
});
await client.log('This is a test');
const msg1 = await awaitTlsMsg();
expect(msg1).toMatch(constructSyslogRegex(134, hostname, 'This is a test'));
await client.log('This is a second test');
const msg2 = await awaitTlsMsg();
expect(msg2).toMatch(constructSyslogRegex(134, hostname, 'This is a second test'));
client.close();
});
it('should work with promise API', async () => {
const hostname = 'testhostname';
const client = new SyslogClient('localhost', {
port: SYSLOG_TLS_PORT,
syslogHostname: hostname,
transport: Transport.Tls,
tlsCA: TLS_CERTIFICATE,
});
await client.log('Promise test');
const msg = await awaitTlsMsg();
expect(msg).toMatch(constructSyslogRegex(134, hostname, 'Promise test'));
client.close();
});
it('should work with callback API', async () => {
const hostname = 'testhostname';
const client = new SyslogClient('localhost', {
port: SYSLOG_TLS_PORT,
syslogHostname: hostname,
transport: Transport.Tls,
tlsCA: TLS_CERTIFICATE,
});
const callbackPromise = new Promise<void>((resolve) => {
void client.log('Callback test', (error) => {
expect(error).toBeUndefined();
resolve();
});
});
const msg = await awaitTlsMsg();
expect(msg).toMatch(constructSyslogRegex(134, hostname, 'Callback test'));
await callbackPromise;
client.close();
});
});
describe('Transport reuse', () => {
it('should reuse TLS transport for multiple messages', async () => {
const hostname = 'testhostname';
const client = new SyslogClient('localhost', {
port: SYSLOG_TLS_PORT,
syslogHostname: hostname,
transport: Transport.Tls,
tlsCA: TLS_CERTIFICATE,
});
await client.log('Transport reuse test');
await awaitTlsMsg();
const transport1 = client['transport_'];
expect(transport1).toBeDefined();
await client.log('Transport reuse test 2');
await awaitTlsMsg();
const transport2 = client['transport_'];
expect(transport2).toBe(transport1);
client.close();
});
});
describe('Close and reconnect', () => {
it('should emit close event', async () => {
const client = new SyslogClient('localhost', {
port: SYSLOG_TLS_PORT,
transport: Transport.Tls,
tlsCA: TLS_CERTIFICATE,
});
const closePromise = new Promise<void>((resolve) => {
client.once('close', resolve);
});
await client.log('Test');
await awaitTlsMsg();
client.close();
await closePromise;
expect(client['transport_']).toBeUndefined();
});
it('should reconnect after close', async () => {
const hostname = 'testhostname';
const client = new SyslogClient('localhost', {
port: SYSLOG_TLS_PORT,
syslogHostname: hostname,
transport: Transport.Tls,
tlsCA: TLS_CERTIFICATE,
});
await client.log('Before close');
await awaitTlsMsg();
const closePromise = new Promise<void>((resolve) => {
client.once('close', resolve);
});
client.close();
await closePromise;
await client.log('After reconnect');
const msg = await awaitTlsMsg();
expect(msg).toMatch(constructSyslogRegex(134, hostname, 'After reconnect'));
client.close();
});
});
describe('Certificate validation', () => {
it('should reject invalid certificate', async () => {
const client = new SyslogClient('localhost', {
port: SYSLOG_TLS_PORT,
transport: Transport.Tls,
tlsCA: WRONG_CERTIFICATE,
});
const errorPromise = new Promise<Error>((resolve) => {
client.on('error', resolve);
});
await expect(client.log('Should error')).rejects.toBeInstanceOf(ConnectionError);
const error: NodeJS.ErrnoException = await errorPromise;
expect(error.code).toBe('DEPTH_ZERO_SELF_SIGNED_CERT');
});
it('should handle connection timeout', async () => {
const client = new SyslogClient('203.0.113.1', {
port: SYSLOG_TLS_PORT,
tcpTimeout: 500,
transport: Transport.Tls,
tlsCA: TLS_CERTIFICATE,
});
const errorPromise = new Promise<Error>((resolve) => {
client.on('error', resolve);
});
await expect(client.log("Shouldn't work")).rejects.toThrow();
const error = await errorPromise;
expect(error).toBeInstanceOf(Error);
});
});
});
@@ -0,0 +1,182 @@
import { SyslogClient, Transport } from '../src';
import {
awaitUdpMsg,
constructSyslogRegex,
startTestServers,
stopTestServers,
SYSLOG_UDP_PORT,
} from './setup';
beforeAll(async () => {
await startTestServers();
});
afterAll(() => {
stopTestServers();
});
describe('SyslogClient - UDP Transport', () => {
describe('Basic functionality', () => {
it('should connect and send log messages', async () => {
const hostname = 'testhostname';
const client = new SyslogClient('127.0.0.1', {
port: SYSLOG_UDP_PORT,
syslogHostname: hostname,
transport: Transport.Udp,
});
await client.log('This is a test');
const msg1 = await awaitUdpMsg();
expect(msg1).toMatch(constructSyslogRegex(134, hostname, 'This is a test'));
await client.log('This is a second test');
const msg2 = await awaitUdpMsg();
expect(msg2).toMatch(constructSyslogRegex(134, hostname, 'This is a second test'));
client.close();
});
it('should work with promise API', async () => {
const hostname = 'testhostname';
const client = new SyslogClient('127.0.0.1', {
port: SYSLOG_UDP_PORT,
syslogHostname: hostname,
transport: Transport.Udp,
});
await client.log('Promise test');
const msg = await awaitUdpMsg();
expect(msg).toMatch(constructSyslogRegex(134, hostname, 'Promise test'));
client.close();
});
it('should work with callback API', async () => {
const hostname = 'testhostname';
const client = new SyslogClient('127.0.0.1', {
port: SYSLOG_UDP_PORT,
syslogHostname: hostname,
transport: Transport.Udp,
});
const callbackPromise = new Promise<void>((resolve) => {
void client.log('Callback test', (error) => {
expect(error).toBeUndefined();
resolve();
});
});
const msg = await awaitUdpMsg();
expect(msg).toMatch(constructSyslogRegex(134, hostname, 'Callback test'));
await callbackPromise;
client.close();
});
});
describe('Transport reuse', () => {
it('should reuse UDP transport for multiple messages', async () => {
const hostname = 'testhostname';
const client = new SyslogClient('127.0.0.1', {
port: SYSLOG_UDP_PORT,
syslogHostname: hostname,
transport: Transport.Udp,
});
await client.log('Transport reuse test');
await awaitUdpMsg();
const transport1 = client['transport_'];
expect(transport1).toBeDefined();
await client.log('Transport reuse test 2');
await awaitUdpMsg();
const transport2 = client['transport_'];
expect(transport2).toBe(transport1);
client.close();
});
});
describe('UDP bind address', () => {
it('should bind to specific network address', async () => {
const hostname = 'testhostname';
const client = new SyslogClient('127.0.0.1', {
port: SYSLOG_UDP_PORT,
syslogHostname: hostname,
transport: Transport.Udp,
udpBindAddress: '127.0.0.1',
});
await client.log('Bind test');
const msg = await awaitUdpMsg();
expect(msg).toMatch(constructSyslogRegex(134, hostname, 'Bind test'));
client.close();
});
it('should handle invalid bind address', () => {
expect(() => {
new SyslogClient('127.0.0.1', {
port: SYSLOG_UDP_PORT,
transport: Transport.Udp,
udpBindAddress: '500.500.500.500',
});
}).toThrow();
});
});
describe('Close and reconnect', () => {
it('should emit close event', async () => {
const client = new SyslogClient('127.0.0.1', {
port: SYSLOG_UDP_PORT,
transport: Transport.Udp,
});
const closePromise = new Promise<void>((resolve) => {
client.once('close', resolve);
});
await client.log('Test');
await awaitUdpMsg();
client.close();
await closePromise;
expect(client['transport_']).toBeUndefined();
});
it('should reconnect after close', async () => {
const hostname = 'testhostname';
const client = new SyslogClient('127.0.0.1', {
port: SYSLOG_UDP_PORT,
syslogHostname: hostname,
transport: Transport.Udp,
});
await client.log('Before close');
await awaitUdpMsg();
const closePromise = new Promise<void>((resolve) => {
client.once('close', resolve);
});
client.close();
await closePromise;
await client.log('After reconnect');
const msg = await awaitUdpMsg();
expect(msg).toMatch(constructSyslogRegex(134, hostname, 'After reconnect'));
client.close();
});
});
describe('Error handling', () => {
it('should handle invalid port', () => {
expect(() => {
new SyslogClient('127.0.0.1', {
port: 12378726362,
transport: Transport.Udp,
});
}).toThrow();
});
});
});
@@ -0,0 +1,11 @@
{
"extends": ["./tsconfig.json", "@n8n/typescript-config/tsconfig.build.json"],
"compilerOptions": {
"composite": true,
"rootDir": "src",
"outDir": "dist",
"tsBuildInfoFile": "dist/build.tsbuildinfo"
},
"include": ["src/**/*.ts"],
"exclude": ["test/**", "src/**/__tests__/**"]
}
+14
View File
@@ -0,0 +1,14 @@
{
"extends": "@n8n/typescript-config/tsconfig.common.json",
"compilerOptions": {
"rootDir": ".",
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"strictPropertyInitialization": false,
"types": ["node", "jest"],
"baseUrl": "src",
"tsBuildInfoFile": "dist/typecheck.tsbuildinfo"
},
"include": ["src/**/*.ts", "test/**/*.ts"],
"references": [{ "path": "../di/tsconfig.build.json" }]
}