UNPKG

node-opcua-transport

Version:

pure nodejs OPCUA SDK - module transport

606 lines (524 loc) 22.2 kB
/* eslint-disable @typescript-eslint/ban-types */ /** * @module node-opcua-transport */ import { EventEmitter } from "events"; import chalk from "chalk"; import { assert } from "node-opcua-assert"; import { make_debugLog, checkDebugFlag, make_errorLog, hexDump, make_warningLog } from "node-opcua-debug"; import { ObjectRegistry } from "node-opcua-object-registry"; import { PacketAssembler, PacketAssemblerErrorCode } from "node-opcua-packet-assembler"; import { ErrorCallback, CallbackWithData, StatusCode } from "node-opcua-status-code"; import { StatusCodes2 } from "./status_codes"; import { readRawMessageHeader } from "./message_builder_base"; import { doTraceIncomingChunk } from "./utils"; import { TCPErrorMessage } from "./TCPErrorMessage"; import { packTcpMessage } from "./tools"; const debugLog = make_debugLog("TRANSPORT"); const doDebug = checkDebugFlag("TRANSPORT"); const errorLog = make_errorLog("TRANSPORT"); const warningLog = make_warningLog("TRANSPORT"); const doDebugFlow = false; export interface ISocketLike extends EventEmitter { remoteAddress?: string; remotePort?: number; write(data: string | Buffer, callback?: (err?: Error) => void | undefined): void; end(): void; setKeepAlive(enable?: boolean, initialDelay?: number): this; setNoDelay(noDelay?: boolean): this; setTimeout(timeout: number, callback?: () => void): this; destroy(err?: Error): void; destroyed: boolean; on(event: "close", listener: (hadError: boolean) => void): this; on(event: "connect", listener: () => void): this; on(event: "data", listener: (data: Buffer) => void): this; on(event: "end", listener: () => void): this; on(event: "error", listener: (err: Error) => void): this; on(event: "timeout", listener: () => void): this; once(event: "close", listener: (hadError: boolean) => void): this; once(event: "connect", listener: () => void): this; once(event: "data", listener: (data: Buffer) => void): this; once(event: "end", listener: () => void): this; once(event: "error", listener: (err: Error) => void): this; once(event: "timeout", listener: () => void): this; prependListener(event: "close", listener: (hadError: boolean) => void): this; prependListener(event: "connect", listener: () => void): this; prependListener(event: "data", listener: (data: Buffer) => void): this; prependListener(event: "end", listener: () => void): this; prependListener(event: "error", listener: (err: Error) => void): this; prependListener(event: "timeout", listener: () => void): this; } export interface IMockSocket extends ISocketLike { invalid?: boolean; // [key: string]: any; write(data: string | Buffer, callback?: (err?: Error) => void | undefined): void; destroy(): void; end(): void; setKeepAlive(enable?: boolean, initialDelay?: number): this; setNoDelay(noDelay?: boolean): this; setTimeout(timeout: number, callback?: () => void): this; } const defaultFakeSocket = { invalid: true, destroyed: false, destroy(err?: Error) { this.destroyed = true; errorLog("MockSocket.destroy"); }, end() { errorLog("MockSocket.end"); }, write(data: string | Buffer, callback?: (err?: Error) => void | undefined): void { /** */ if (callback) { callback(); } }, setKeepAlive(enable?: boolean, initialDelay?: number) { return this; }, setNoDelay(noDelay?: boolean) { return this; }, setTimeout(timeout: number, callback?: () => void) { return this; } }; let fakeSocket: IMockSocket = defaultFakeSocket as IMockSocket; export function setFakeTransport(mockSocket: IMockSocket): void { fakeSocket = mockSocket; } export function getFakeTransport(): ISocketLike { if (fakeSocket.invalid) { throw new Error("getFakeTransport: setFakeTransport must be called first - BadProtocolVersionUnsupported"); } process.nextTick(() => fakeSocket.emit("connect")); return fakeSocket; } let counter = 0; export interface TCP_transport { /** * notify the observers that a message chunk has been received * @event chunk * @param message_chunk the message chunk */ on(eventName: "chunk", eventHandler: (messageChunk: Buffer) => void): this; /** * notify the observers that the transport layer has been disconnected. * @event close */ on(eventName: "close", eventHandler: (err: Error | null) => void): this; once(eventName: "chunk", eventHandler: (messageChunk: Buffer) => void): this; once(eventName: "close", eventHandler: (err: Error | null) => void): this; emit(eventName: "close", err?: Error | null): boolean; emit(eventName: "chunk", messageChunk: Buffer): boolean; } // tslint:disable:class-name export class TCP_transport extends EventEmitter { private static registry = new ObjectRegistry(); /** * the size of the header in bytes * @default 8 */ public static readonly headerSize = 8; /** * indicates the version number of the OPCUA protocol used * @default 0 */ public protocolVersion: number; public maxMessageSize: number; public maxChunkCount: number; public sendBufferSize: number; public receiveBufferSize: number; public bytesWritten: number; public bytesRead: number; public chunkWrittenCount: number; public chunkReadCount: number; public name: string; public _socket: ISocketLike | null; #_closedEmitted: Error | string | undefined = undefined; #_timerId: NodeJS.Timeout | null; #_theCallback?: (err?: Error | null, data?: Buffer) => void; #_on_error_during_one_time_message_receiver: ((hadError: boolean) => void) | undefined; #packetAssembler?: PacketAssembler; #_timeout: number; #_isDisconnecting = false; protected _theCloseError: Error | null = null; constructor() { super(); this.name = this.constructor.name + counter; counter += 1; this._socket = null; this.#_timerId = null; this.#_timeout = 5000; // 5 seconds timeout this.#_theCallback = undefined; this.maxMessageSize = 0; this.maxChunkCount = 0; this.receiveBufferSize = 0; this.sendBufferSize = 0; this.protocolVersion = 0; this.bytesWritten = 0; this.bytesRead = 0; this.chunkWrittenCount = 0; this.chunkReadCount = 0; TCP_transport.registry.register(this); } public toString() { let str = "\n"; str += " name.............. = " + this.name + "\n"; str += " protocolVersion... = " + this.protocolVersion + "\n"; str += " maxMessageSize.... = " + this.maxMessageSize + "\n"; str += " maxChunkCount..... = " + this.maxChunkCount + "\n"; str += " receiveBufferSize. = " + this.receiveBufferSize + "\n"; str += " sendBufferSize.... = " + this.sendBufferSize + "\n"; str += " bytesRead......... = " + this.bytesRead + "\n"; str += " bytesWritten...... = " + this.bytesWritten + "\n"; str += " chunkWrittenCount. = " + this.chunkWrittenCount + "\n"; str += " chunkReadCount.... = " + this.chunkReadCount + "\n"; str += " closeEmitted ? ....= " + this.#_closedEmitted + "\n"; return str; } public setLimits({ receiveBufferSize, sendBufferSize, maxMessageSize, maxChunkCount }: { receiveBufferSize: number; sendBufferSize: number; maxMessageSize: number; maxChunkCount: number; }) { this.receiveBufferSize = receiveBufferSize; this.sendBufferSize = sendBufferSize; this.maxMessageSize = maxMessageSize; this.maxChunkCount = maxChunkCount; if (maxChunkCount !== 0) { if (maxMessageSize / sendBufferSize > maxChunkCount) { const expectedMaxChunkCount = Math.ceil(maxMessageSize / sendBufferSize); warningLog( `Warning: maxChunkCount is not big enough : maxMessageSize / sendBufferSize ${expectedMaxChunkCount} > maxChunkCount ${maxChunkCount}` ); } if (maxMessageSize / receiveBufferSize > maxChunkCount) { const expectedMaxChunkCount = Math.ceil(maxMessageSize / receiveBufferSize); warningLog( `Warning: maxChunkCount is not big enough :maxMessageSize / sendBufferSize ${expectedMaxChunkCount} > maxChunkCount ${maxChunkCount}` ); } } // reinstall packetAssembler with correct limits this.#_install_packetAssembler(); } public get timeout(): number { return this.#_timeout; } public set timeout(value: number) { assert(!this.#_timerId); debugLog("Setting socket " + this.name + " timeout = ", value); this.#_timeout = value; } public dispose(): void { this._cleanup_timers(); assert(!this.#_timerId); if (this._socket) { const gracefully = true; if (gracefully) { // close the connection gracefully this._socket.end(); } else { // close the connection forcefully this._socket.destroy(); } // this._socket.removeAllListeners(); this._socket = null; } TCP_transport.registry.unregister(this); } /** * write the message_chunk on the socket. * @param messageChunk */ public write(messageChunk: Buffer, callback?: (err?: Error) => void | undefined): void { const header = readRawMessageHeader(messageChunk); assert(header.length === messageChunk.length); const c = header.messageHeader.isFinal; assert(c === "F" || c === "C" || c === "A"); this._write_chunk(messageChunk, (err) => { callback && callback(err); }); } public isDisconnecting(): boolean { return !this._socket || this.#_isDisconnecting; } /** * disconnect the TCP layer and close the underlying socket. * The ```"close"``` event will be emitted to the observers with err=null. * */ public disconnect(callback: ErrorCallback): void { assert(typeof callback === "function", "expecting a callback function, but got " + callback); if (!this._socket || this.#_isDisconnecting) { if (!this.#_isDisconnecting) { this.dispose(); } callback(); return; } this.#_isDisconnecting = true; this._cleanup_timers(); this._socket.prependOnceListener("close", () => { this._emitClose(null); setImmediate(() => { callback(); }); }); this._socket.end(); this._socket && this._socket.destroy(); this._socket = null; } public isValid(): boolean { return this._socket !== null && !this._socket.destroyed; } protected _write_chunk(messageChunk: Buffer, callback?: (err?: Error) => void | undefined): void { if (this._socket !== null) { this.bytesWritten += messageChunk.length; this.chunkWrittenCount++; this._socket.write(messageChunk, callback); } else { if (callback) { callback(); } } } #_install_packetAssembler() { if (this.#packetAssembler) { this.#packetAssembler.removeAllListeners(); this.#packetAssembler = undefined; } // install packet assembler ... this.#packetAssembler = new PacketAssembler({ readChunkFunc: readRawMessageHeader, minimumSizeInBytes: TCP_transport.headerSize, maxChunkSize: this.receiveBufferSize //Math.max(this.receiveBufferSize, this.sendBufferSize) }); this.#packetAssembler.on("chunk", (chunk: Buffer) => this._on_message_chunk_received(chunk)); this.#packetAssembler.on("error", (err, code) => { let statusCode = StatusCodes2.BadTcpMessageTooLarge; switch (code) { case PacketAssemblerErrorCode.ChunkSizeExceeded: statusCode = StatusCodes2.BadTcpMessageTooLarge; break; default: statusCode = StatusCodes2.BadTcpInternalError; } this.sendErrorMessage(statusCode, err.message); this.prematureTerminate(new Error("Packet Assembler : " + err.message), statusCode); }); } protected _install_socket(socket: ISocketLike): void { // note: it is possible that a transport may be recycled and re-used again after a connection break assert(socket); assert(!this._socket, "already have a socket"); this._socket = socket; this.#_closedEmitted = undefined; this._theCloseError = null; assert(this.#_closedEmitted === undefined, "TCP Transport has already been closed !"); this._socket.setKeepAlive(true); // Setting true for noDelay will immediately fire off data each time socket.write() is called. this._socket.setNoDelay(true); // set socket timeout debugLog(" TCP_transport#install => setting " + this.name + " _socket.setTimeout to ", this.timeout); // let use a large timeout here to make sure that we not conflict with our internal timeout this._socket.setTimeout(this.timeout); // istanbul ignore next doDebug && debugLog(" TCP_transport#_install_socket ", this.name); this.#_install_packetAssembler(); this._socket .on("data", (data: Buffer) => this._on_socket_data(data)) .on("close", (hadError) => this._on_socket_close(hadError)) .on("end", () => this._on_socket_end()) .on("error", (err: Error) => this._on_socket_error(err)) .on("timeout", () => this._on_socket_timeout()); } public sendErrorMessage(statusCode: StatusCode, extraErrorDescription: string | null): void { // When the Client receives an Error Message it reports the error to the application and closes the TransportConnection gracefully. // If a Client encounters a fatal error, it shall report the error to the application and send a CloseSecureChannel Message. /* istanbul ignore next*/ if (doDebug) { debugLog(chalk.red(" sendErrorMessage ") + chalk.cyan(statusCode.toString())); debugLog(chalk.red(" extraErrorDescription ") + chalk.cyan(extraErrorDescription)); } const reason = `${statusCode.toString()}:${extraErrorDescription || ""}`; const errorResponse = new TCPErrorMessage({ statusCode, reason }); const messageChunk = packTcpMessage("ERR", errorResponse); this.write(messageChunk); } public prematureTerminate(err: Error, statusCode: StatusCode): void { // https://reference.opcfoundation.org/v104/Core/docs/Part6/6.7.3/ debugLog("prematureTerminate", err ? err.message : "", statusCode.toString(), "has socket = ", !!this._socket); doDebugFlow && errorLog("prematureTerminate from", "has socket = ", !!this._socket, new Error().stack); if (this._socket) { err.message = "premature socket termination " + err.message; // we consider this as an error const _s = this._socket; this._socket = null; _s.destroy(err); this.dispose(); } } /** * * install a one time message receiver callback * * Rules: * * TCP_transport will not emit the ```message``` event, while the "one time message receiver" is in operation. * * the TCP_transport will wait for the next complete message chunk and call the provided callback func * ```callback(null,messageChunk);``` * * if a messageChunk is not received within ```TCP_transport.timeout``` or if the underlying socket reports * an error, the callback function will be called with an Error. * */ protected _install_one_time_message_receiver(callback: CallbackWithData): void { assert(!this.#_theCallback, "callback already set"); assert(typeof callback === "function"); this._start_one_time_message_receiver(callback); } private _fulfill_pending_promises(err: Error | null, data?: Buffer): boolean { if (!this.#_theCallback) return false; doDebugFlow && errorLog("_fulfill_pending_promises from", new Error().stack); const callback = this.#_theCallback; this.#_theCallback = undefined; callback(err, data); return true; } private _on_message_chunk_received(messageChunk: Buffer) { if (doTraceIncomingChunk) { warningLog("Transport", this.name); warningLog(hexDump(messageChunk)); } const hadCallback = this._fulfill_pending_promises(null, messageChunk); this.chunkReadCount++; if (!hadCallback) { this.emit("chunk", messageChunk); } } private _cleanup_timers() { if (this.#_timerId) { clearTimeout(this.#_timerId); this.#_timerId = null; } } private _start_one_time_message_receiver(callback: CallbackWithData) { assert(!this.#_timerId && !this.#_on_error_during_one_time_message_receiver, "timer already started"); const _cleanUp = () => { this._cleanup_timers(); if (this.#_on_error_during_one_time_message_receiver) { this._socket?.removeListener("close", this.#_on_error_during_one_time_message_receiver); this.#_on_error_during_one_time_message_receiver = undefined; } }; const onTimeout = () => { _cleanUp(); this._fulfill_pending_promises( new Error(`Timeout(A) in waiting for data on socket ( timeout was = ${this.timeout} ms)`) ); this.dispose(); }; // Setup timeout detection timer .... this.#_timerId = setTimeout(() => { this.#_timerId = null; onTimeout(); }, this.timeout); // also monitored if (this._socket) { // to do = intercept socket error as well this.#_on_error_during_one_time_message_receiver = (hadError: boolean) => { const err = new Error( `ERROR in waiting for data on socket ( timeout was = ${this.timeout} ms) hadError` + hadError ); this._emitClose(err); this._fulfill_pending_promises(err); }; this._socket.prependOnceListener("close", this.#_on_error_during_one_time_message_receiver); } const _callback = callback; this.#_theCallback = (err?: Error | null, data?: Buffer) => { _cleanUp(); this.#_theCallback = undefined; _callback(err!, data); }; } private _on_socket_data(data: Buffer): void { // istanbul ignore next if (!this.#packetAssembler) { throw new Error("internal Error"); } this.bytesRead += data.length; if (data.length > 0) { this.#packetAssembler.feed(data); } } private _on_socket_close(hadError: boolean) { // istanbul ignore next if (doDebug) { debugLog(chalk.red(` SOCKET CLOSE ${this.name}: `), chalk.yellow("had_error ="), chalk.cyan(hadError.toString())); } this.dispose(); if (this.#_theCallback) return; // if (hadError) { // if (this._socket) { // this._socket.destroy(); // } // } this._emitClose(); } protected _emitClose(err?: Error | null) { err = err || this._theCloseError; doDebugFlow && warningLog("_emitClose ", err?.message || "", "from", new Error().stack); if (!this.#_closedEmitted) { this.#_closedEmitted = err || "noError"; this.emit("close", err || null); // if (this._theCallback) { // const callback = this._theCallback; // this._theCallback = undefined; // callback(err || null); // } } else { debugLog("Already emitted close event", (this.#_closedEmitted as any).message); debugLog("err = ", err?.message); debugLog(""); debugLog("Already emitted close event", this.#_closedEmitted); debugLog("err = ", err?.message, err); } } private _on_socket_end() { // istanbul ignore next doDebug && debugLog(chalk.red(` SOCKET END : ${this.name}`), "is disconnecting ", this.isDisconnecting()); if (this.isDisconnecting()) { return; } // debugLog(chalk.red(" Transport Connection ended") + " " + this.name); const err = new Error(this.name + ": socket has been disconnected by third party"); debugLog(" bytesRead = ", this.bytesRead); debugLog(" bytesWritten = ", this.bytesWritten); this._theCloseError = err; this._fulfill_pending_promises(new Error("Connection aborted - ended by server : " + (err ? err.message : ""))); } private _on_socket_error(err: Error) { // istanbul ignore next debugLog(chalk.red(` _on_socket_error: ${this.name}`), chalk.yellow(err.message)); // node The "close" event will be called directly following this event. // this._emitClose(err); } private _on_socket_timeout() { const msg = `socket timeout : timeout=${this.timeout} ${this.name}`; doDebug && debugLog(msg); this.prematureTerminate(new Error(msg), StatusCodes2.BadTimeout); } }