/** * @module node-opcua-client-private */ // tslint:disable:no-unused-expression import fs from "node:fs"; import path from "node:path"; import { withLock } from "@ster5/global-mutex"; import chalk from "chalk"; import { assert } from "node-opcua-assert"; import { getDefaultCertificateManager, makeSubject, type OPCUACertificateManager } from "node-opcua-certificate-manager"; import { type IOPCUASecureObjectOptions, makeApplicationUrn, OPCUASecureObject } from "node-opcua-common"; import { type Certificate, makeSHA1Thumbprint, split_der } from "node-opcua-crypto/web"; import { installPeriodicClockAdjustment, periodicClockAdjustment, uninstallPeriodicClockAdjustment } from "node-opcua-date-time"; import { checkDebugFlag, make_debugLog, make_errorLog, make_warningLog } from "node-opcua-debug"; import { getHostname } from "node-opcua-hostname"; import type { IBasicTransportSettings, ResponseCallback } from "node-opcua-pseudo-session"; import { ClientSecureChannelLayer, type ConnectionStrategy, type ConnectionStrategyOptions, coerceConnectionStrategy, coerceSecurityPolicy, type PerformTransactionCallback, type Request as Request1, type Response as Response1, type SecurityPolicy } from "node-opcua-secure-channel"; import { FindServersOnNetworkRequest, type FindServersOnNetworkRequestOptions, FindServersOnNetworkResponse, FindServersRequest, FindServersResponse, type ServerOnNetwork } from "node-opcua-service-discovery"; import { type ApplicationDescription, type EndpointDescription, GetEndpointsRequest, GetEndpointsResponse } from "node-opcua-service-endpoints"; import { type ChannelSecurityToken, coerceMessageSecurityMode, MessageSecurityMode } from "node-opcua-service-secure-channel"; import { CloseSessionRequest, type CloseSessionResponse } from "node-opcua-service-session"; import { type ErrorCallback, StatusCodes } from "node-opcua-status-code"; import { checkFileExistsAndIsNotEmpty, matchUri } from "node-opcua-utils"; import { type CreateSecureChannelCallbackFunc, type FindEndpointCallback, type FindEndpointOptions, type FindEndpointResult, type FindServersOnNetworkRequestLike, type FindServersRequestLike, type GetEndpointsOptions, OPCUAClientBase, type OPCUAClientBaseEvents, type OPCUAClientBaseOptions, type TransportSettings } from "../client_base"; import type { Request, Response } from "../common"; import type { UserIdentityInfo } from "../user_identity_info"; import { performCertificateSanityCheck } from "../verify"; import type { ClientSessionImpl } from "./client_session_impl"; import type { IClientBase } from "./i_private_client"; const debugLog = make_debugLog(__filename); const doDebug = checkDebugFlag(__filename); const errorLog = make_errorLog(__filename); const warningLog = make_warningLog(__filename); function makeCertificateThumbPrint(certificate: Certificate | Certificate[] | null | undefined): Buffer | null { if (!certificate) return null; return makeSHA1Thumbprint(Array.isArray(certificate) ? certificate[0] : certificate); } const traceInternalState = false; const defaultConnectionStrategy: ConnectionStrategyOptions = { initialDelay: 1000, maxDelay: 20 * 1000, // 20 seconds maxRetry: -1, // infinite randomisationFactor: 0.1 }; function __findEndpoint(this: ClientBaseImpl, endpointUrl: string, params: FindEndpointOptions, _callback: FindEndpointCallback) { if (this.isUnusable()) { return _callback(new Error("Client is not usable")); } const masterClient = this as ClientBaseImpl; doDebug && debugLog("findEndpoint : endpointUrl = ", endpointUrl); doDebug && debugLog(" params ", params); assert(!masterClient._tmpClient); const callback = (err: Error | null, result?: FindEndpointResult) => { masterClient._tmpClient = undefined; _callback(err, result); }; const securityMode = params.securityMode; const securityPolicy = params.securityPolicy; const _connectionStrategy = params.connectionStrategy; const options: OPCUAClientBaseOptions = { applicationName: params.applicationName, applicationUri: params.applicationUri, certificateFile: params.certificateFile, clientCertificateManager: params.clientCertificateManager, clientName: "EndpointFetcher", // use same connectionStrategy as parent connectionStrategy: params.connectionStrategy, // connectionStrategy: { // maxRetry: 0 /* no- retry */, // maxDelay: 2000 // }, privateKeyFile: params.privateKeyFile }; const client = new TmpClient(options); masterClient._tmpClient = client; let selectedEndpoint: EndpointDescription | undefined; const allEndpoints: EndpointDescription[] = []; const step1_connect = (): Promise => { return new Promise((resolve, reject) => { // rebind backoff handler masterClient.listeners("backoff").forEach((handler) => { client.on("backoff", handler as unknown as (retryCount: number, delay: number) => void); }); // c8 ignore next if (doDebug) { client.on("backoff", (retryCount: number, delay: number) => { debugLog( "finding Endpoint => reconnecting ", " retry count", retryCount, " next attempt in ", delay / 1000, "seconds" ); }); } client.connect(endpointUrl, (err?: Error) => { if (err) { err.message = "Fail to connect to server at " + endpointUrl + " to collect server's certificate (in findEndpoint) \n" + " (err =" + err.message + ")"; warningLog(err.message); return reject(err); } resolve(); }); }); }; const step2_getEndpoints = (): Promise => { return new Promise((resolve, reject) => { client.getEndpoints((err: Error | null, endpoints?: EndpointDescription[]) => { if (err) { err.message = `error in getEndpoints \n${err.message}`; return reject(err); } // c8 ignore next if (!endpoints) { return reject(new Error("Internal Error")); } for (const endpoint of endpoints) { if (endpoint.securityMode === securityMode && endpoint.securityPolicyUri === securityPolicy) { if (selectedEndpoint) { errorLog( "Warning more than one endpoint matching !", endpoint.endpointUrl, selectedEndpoint.endpointUrl ); } selectedEndpoint = endpoint; // found it } } resolve(); }); }); }; const step3_disconnect = (): Promise => { return new Promise((resolve) => { client.disconnect(() => resolve()); }); }; step1_connect() .then(() => step2_getEndpoints()) .then(() => step3_disconnect()) .then(() => { if (!selectedEndpoint) { return callback( new Error( "Cannot find an Endpoint matching " + " security mode: " + securityMode.toString() + " policy: " + securityPolicy.toString() ) ); } // c8 ignore next if (doDebug) { debugLog(chalk.bgWhite.red("xxxxxxxxxxxxxxxxxxxxx => selected EndPoint = "), selectedEndpoint.toString()); } callback(null, { endpoints: allEndpoints, selectedEndpoint }); }) .catch((err) => { client.disconnect(() => { callback(err); }); }); } /** * check if certificate is trusted or untrusted */ async function _verify_serverCertificate(certificateManager: OPCUACertificateManager, serverCertificate: Certificate) { const status = await certificateManager.checkCertificate(serverCertificate); if (!status.isGood()) { // c8 ignore next if (doDebug) { // do it again for debug purposes const status1 = await certificateManager.verifyCertificate(serverCertificate); debugLog(status1); } warningLog("serverCertificate = ", makeCertificateThumbPrint(serverCertificate)?.toString("hex") || "none"); warningLog("serverCertificate = ", serverCertificate.toString("base64")); throw new Error(`server Certificate verification failed with err ${status?.toString()}`); } } const forceEndpointDiscoveryOnConnect = !!parseInt(process.env.NODEOPCUA_CLIENT_FORCE_ENDPOINT_DISCOVERY || "0", 10); debugLog("forceEndpointDiscoveryOnConnect = ", forceEndpointDiscoveryOnConnect); class ClockAdjustment { constructor() { debugLog("installPeriodicClockAdjustment ", periodicClockAdjustment.timerInstallationCount); installPeriodicClockAdjustment(); } dispose() { uninstallPeriodicClockAdjustment(); debugLog("uninstallPeriodicClockAdjustment ", periodicClockAdjustment.timerInstallationCount); } } type InternalClientState = | "uninitialized" | "disconnected" | "connecting" | "connected" | "panic" | "reconnecting" | "reconnecting_newchannel_connected" | "disconnecting"; /* * "disconnected" ---[connect]----------------------> "connecting" * * "connecting" ---[(connection successful)]------> "connected" * * "connecting" ---[(connection failure)]---------> "disconnected" * * "connecting" ---[disconnect]-------------------> "disconnecting" --> "disconnected" * * "connecting" ---[lost of connection]-----------> "reconnecting" ->[reconnection] * * "reconnecting" ---[reconnection successful]------> "reconnecting_newchannel_connected" * * "reconnecting_newchannel_connected" --(session failure) -->"reconnecting" * * "reconnecting" ---[reconnection failure]---------> [reconnection] ---> "reconnecting" * * "reconnecting" ---[disconnect]-------------------> "disconnecting" --> "disconnected" */ let g_ClientCounter = 0; /** * @internal */ // tslint:disable-next-line: max-classes-per-file export class ClientBaseImpl extends OPCUASecureObject implements OPCUAClientBase, IClientBase { /** * total number of requests that been canceled due to timeout */ public get timedOutRequestCount(): number { return this._timedOutRequestCount + (this._secureChannel ? this._secureChannel.timedOutRequestCount : 0); } /** * total number of transactions performed by the client x */ public get transactionsPerformed(): number { return this._transactionsPerformed + (this._secureChannel ? this._secureChannel.transactionsPerformed : 0); } /** * is true when the client has already requested the server end points. */ get knowsServerEndpoint(): boolean { return this._serverEndpoints && this._serverEndpoints.length > 0; } /** * true if the client is trying to reconnect to the server after a connection break. */ get isReconnecting(): boolean { return ( !!this._secureChannel?.isConnecting || this._internalState === "reconnecting_newchannel_connected" || this._internalState === "reconnecting" ); } /** * true if the connection strategy is set to automatically try to reconnect in case of failure */ get reconnectOnFailure(): boolean { return this.connectionStrategy.maxRetry > 0 || this.connectionStrategy.maxRetry === -1; } /** * total number of bytes read by the client */ get bytesRead(): number { return this._byteRead + (this._secureChannel ? this._secureChannel.bytesRead : 0); } /** * total number of bytes written by the client */ public get bytesWritten(): number { return this._byteWritten + (this._secureChannel ? this._secureChannel.bytesWritten : 0); } public securityMode: MessageSecurityMode; public securityPolicy: SecurityPolicy; public serverCertificate?: Certificate | Certificate[]; public clientName: string; public protocolVersion: 0; public defaultSecureTokenLifetime: number; public tokenRenewalInterval: number; public connectionStrategy: ConnectionStrategy; public keepPendingSessionsOnDisconnect: boolean; public endpointUrl: string; public discoveryUrl: string; public readonly applicationName: string; private _applicationUri: string; public defaultTransactionTimeout?: number; /** * true if session shall periodically probe the server to keep the session alive and prevent timeout */ public keepSessionAlive: boolean; public readonly keepAliveInterval?: number; public _sessions: ClientSessionImpl[]; protected _serverEndpoints: EndpointDescription[]; public _secureChannel: ClientSecureChannelLayer | null; // statistics... private _byteRead: number; private _byteWritten: number; private _timedOutRequestCount: number; private _transactionsPerformed: number; private _reconnectionIsCanceled: boolean; private _clockAdjuster?: ClockAdjustment; protected _tmpClient?: OPCUAClientBase; private _instanceNumber: number; private _transportSettings: TransportSettings; private _transportTimeout?: number; public clientCertificateManager: OPCUACertificateManager; public isUnusable() { return ( this._internalState === "disconnected" || this._internalState === "disconnecting" || this._internalState === "panic" || this._internalState === "uninitialized" ); } protected _setInternalState(internalState: InternalClientState): void { const previousState = this._internalState; if (doDebug || traceInternalState) { (traceInternalState ? warningLog : debugLog)( chalk.cyan(` Client ${this._instanceNumber} ${this.clientName} : _internalState from `), chalk.yellow(previousState), "to", chalk.yellow(internalState) ); } if (this._internalState === "disconnecting" || this._internalState === "disconnected") { if (internalState === "reconnecting") { errorLog("Internal error, cannot switch to reconnecting when already disconnecting"); } // when disconnecting, we cannot accept any other state } this._internalState = internalState; } public emit(eventName: K, ...others: unknown[]): boolean { // c8 ignore next if (doDebug) { debugLog( chalk.cyan(` Client ${this._instanceNumber} ${this.clientName} emitting `), chalk.magentaBright(eventName as string) ); } // @ts-expect-error return super.emit(eventName, ...others); } constructor(options?: OPCUAClientBaseOptions) { options = options || {}; if (!options.clientCertificateManager) { options.clientCertificateManager = getDefaultCertificateManager("PKI"); } options.privateKeyFile = options.privateKeyFile || options.clientCertificateManager.privateKey; options.certificateFile = options.certificateFile || path.join(options.clientCertificateManager.rootDir, "own/certs/client_certificate.pem"); super(options as IOPCUASecureObjectOptions); this._setInternalState("uninitialized"); this._instanceNumber = g_ClientCounter++; this.applicationName = options.applicationName || "NodeOPCUA-Client"; assert(!this.applicationName.match(/^locale=/), "applicationName badly converted from LocalizedText"); assert(!this.applicationName.match(/urn:/), "applicationName should not be a URI"); // we need to delay _applicationUri initialization this._applicationUri = options.applicationUri || this._getBuiltApplicationUri(); this.clientCertificateManager = options.clientCertificateManager; this.clientCertificateManager.referenceCounter++; this._secureChannel = null; this._reconnectionIsCanceled = false; this.endpointUrl = ""; this.clientName = options.clientName || "ClientSession"; // must be ZERO with Spec 1.0.2 this.protocolVersion = 0; this._sessions = []; this._serverEndpoints = []; this.defaultSecureTokenLifetime = options.defaultSecureTokenLifetime || 600000; this.defaultTransactionTimeout = options.defaultTransactionTimeout; this.tokenRenewalInterval = options.tokenRenewalInterval || 0; assert(Number.isFinite(this.tokenRenewalInterval) && this.tokenRenewalInterval >= 0); this.securityMode = coerceMessageSecurityMode(options.securityMode); this.securityPolicy = coerceSecurityPolicy(options.securityPolicy); this.serverCertificate = options.serverCertificate; this.keepSessionAlive = typeof options.keepSessionAlive === "boolean" ? options.keepSessionAlive : false; this.keepAliveInterval = options.keepAliveInterval; // statistics... this._byteRead = 0; this._byteWritten = 0; this._transactionsPerformed = 0; this._timedOutRequestCount = 0; this.connectionStrategy = coerceConnectionStrategy(options.connectionStrategy || defaultConnectionStrategy); /*** * @property keepPendingSessionsOnDisconnect² * @type {boolean} */ this.keepPendingSessionsOnDisconnect = options.keepPendingSessionsOnDisconnect || false; this.discoveryUrl = options.discoveryUrl || ""; this._setInternalState("disconnected"); this._transportSettings = options.transportSettings || {}; this._transportTimeout = options.transportTimeout; } private _cancel_reconnection(callback: ErrorCallback) { // _cancel_reconnection is invoked during disconnection // when we detect that a reconnection is in progress... // c8 ignore next if (!this.isReconnecting) { warningLog("internal error: _cancel_reconnection should only be used when reconnecting is in progress"); } debugLog("canceling reconnection : ", this.clientName); this._reconnectionIsCanceled = true; // c8 ignore next if (!this._secureChannel) { debugLog("_cancel_reconnection: Nothing to do for !", this.clientName, " because secure channel doesn't exist"); return callback(); // nothing to do } this._secureChannel.abortConnection((/*err?: Error*/) => { this._secureChannel = null; callback(); }); } public _recreate_secure_channel(callback: ErrorCallback): void { debugLog("_recreate_secure_channel... while internalState is", this._internalState); if (!this.knowsServerEndpoint) { debugLog("Cannot reconnect , server endpoint is unknown"); callback(new Error("Cannot reconnect, server endpoint is unknown - this.knowsServerEndpoint = false")); return; } assert(this.knowsServerEndpoint); this._setInternalState("reconnecting"); this.emit("start_reconnection"); // send after callback const infiniteConnectionRetry: ConnectionStrategyOptions = { initialDelay: this.connectionStrategy.initialDelay, maxDelay: this.connectionStrategy.maxDelay, maxRetry: -1 }; const _when_internal_error = (err: Error, callback: ErrorCallback) => { errorLog("INTERNAL ERROR", err.message); callback(err); }; const _when_reconnectionIsCanceled = (callback: ErrorCallback) => { doDebug && debugLog("attempt to recreate a new secure channel : suspended because reconnection is canceled !"); this.emit("reconnection_canceled"); return callback(new Error(`Reconnection has been canceled - ${this.clientName}`)); }; const _failAndRetry = (err: Error, message: string, callback: ErrorCallback) => { debugLog("failAndRetry; ", message); if (this._reconnectionIsCanceled) { return _when_reconnectionIsCanceled(callback); } this._destroy_secure_channel(); warningLog("client = ", this.clientName, message, err.message); // else // let retry a little bit later this.emit("reconnection_attempt_has_failed", err, message); // send after callback setImmediate(_attempt_to_recreate_secure_channel, callback); }; const _when_connected = (callback: ErrorCallback) => { this.emit("after_reconnection", null); // send after callback assert(this._secureChannel, "expecting a secureChannel here "); // a new channel has be created and a new connection is established debugLog(chalk.bgWhite.red("ClientBaseImpl: RECONNECTED !!!")); this._setInternalState("reconnecting_newchannel_connected"); return callback(); }; const _attempt_to_recreate_secure_channel = (callback: ErrorCallback) => { debugLog("attempt to recreate a new secure channel"); if (this._reconnectionIsCanceled) { return _when_reconnectionIsCanceled(callback); } if (this._secureChannel) { doDebug && debugLog("attempt to recreate a new secure channel, while channel already exists"); // are we reentrant ? const err = new Error("_internal_create_secure_channel failed, this._secureChannel is supposed to be null"); return _when_internal_error(err, callback); } assert(!this._secureChannel, "_attempt_to_recreate_secure_channel, expecting this._secureChannel not to exist"); this._internal_create_secure_channel(infiniteConnectionRetry, (err?: Error | null) => { if (err) { // c8 ignore next if (this._secureChannel) { const err = new Error("_internal_create_secure_channel failed, expecting this._secureChannel not to exist"); return _when_internal_error(err, callback); } if (err.message.match(/ECONNREFUSED|ECONNABORTED|ETIMEDOUT/)) { return _failAndRetry( err, `create secure channel failed with ECONNREFUSED|ECONNABORTED|ETIMEDOUT\n${err.message}`, callback ); } if (err.message.match("Backoff aborted.")) { _failAndRetry(err, "cannot create secure channel (backoff aborted)", callback); return; } if ( err?.message.match("BadCertificateInvalid") || err?.message.match(/socket has been disconnected by third party/) ) { // it is possible also that hte server has shutdown innapropriately the connection warningLog( "the server certificate has changed, we need to retrieve server certificate again: ", err.message ); const oldServerCertificate = this.serverCertificate; warningLog( "old server certificate ", makeCertificateThumbPrint(oldServerCertificate)?.toString("hex") || "undefined" ); // the server may have shut down the channel because its certificate // has changed .... // let request the server certificate again .... return this.fetchServerCertificate(this.endpointUrl, (err1?: Error | null) => { if (err1) { return _failAndRetry(err1, "Failing to fetch new server certificate", callback); } const newServerCertificate = this.serverCertificate; warningLog( "new server certificate ", makeCertificateThumbPrint(newServerCertificate)?.toString("hex") || "none" ); const sha1Old = makeCertificateThumbPrint(oldServerCertificate)?.toString("hex") || null; const sha1New = makeCertificateThumbPrint(newServerCertificate)?.toString("hex") || null; if (sha1Old === sha1New) { warningLog("server certificate has not changed, but was expected to have changed"); return _failAndRetry( new Error("Server Certificate not changed"), "Failing to fetch new server certificate", callback ); } this._internal_create_secure_channel(infiniteConnectionRetry, (err3?: Error | null) => { if (err3) { return _failAndRetry(err3, "trying to create new channel with new certificate", callback); } return _when_connected(callback); }); }); } else { return _failAndRetry(err, "cannot create secure channel", callback); } } else { return _when_connected(callback); } }); }; // create a secure channel // a new secure channel must be established _attempt_to_recreate_secure_channel(callback); } public _internal_create_secure_channel( connectionStrategy: ConnectionStrategyOptions, callback: CreateSecureChannelCallbackFunc ): void { assert(this._secureChannel === null); assert(typeof this.endpointUrl === "string"); debugLog( "_internal_create_secure_channel creating new ClientSecureChannelLayer _internalState =", this._internalState, this.clientName ); const secureChannel = new ClientSecureChannelLayer({ connectionStrategy, defaultSecureTokenLifetime: this.defaultSecureTokenLifetime, parent: this, securityMode: this.securityMode, securityPolicy: this.securityPolicy, serverCertificate: this.serverCertificate, tokenRenewalInterval: this.tokenRenewalInterval, transportSettings: this._transportSettings, transportTimeout: this._transportTimeout, defaultTransactionTimeout: this.defaultTransactionTimeout }); secureChannel.on("backoff", (count: number, delay: number) => { this.emit("backoff", count, delay); }); secureChannel.on("abort", () => { this.emit("abort"); }); secureChannel.protocolVersion = this.protocolVersion; this._secureChannel = secureChannel; this.emit("secure_channel_created", secureChannel); const step2_openSecureChannel = (): Promise => { return new Promise((resolve, reject) => { debugLog("_internal_create_secure_channel before secureChannel.create"); secureChannel.create(this.endpointUrl, (err?: Error) => { debugLog("_internal_create_secure_channel after secureChannel.create"); if (!this._secureChannel) { debugLog("_secureChannel has been closed during the transaction !"); return reject(new Error("Secure Channel Closed")); } if (err) { return reject(err); } this._install_secure_channel_event_handlers(secureChannel); resolve(); }); }); }; const step3_getEndpoints = (): Promise => { return new Promise((resolve, reject) => { assert(this._secureChannel !== null); if (!this.knowsServerEndpoint) { this._setInternalState("connecting"); this.getEndpoints((err: Error | null /*, endpoints?: EndpointDescription[]*/) => { if (!this._secureChannel) { debugLog("_secureChannel has been closed during the transaction ! (while getEndpoints)"); return reject(new Error("Secure Channel Closed")); } if (err) { return reject(err); } resolve(); }); } else { // end points are already known resolve(); } }); }; step2_openSecureChannel() .then(() => step3_getEndpoints()) .then(() => { assert(this._secureChannel !== null); callback(null, secureChannel); }) .catch((err) => { doDebug && debugLog(this.clientName, " : Inner create secure channel has failed", err.message); if (this._secureChannel) { this._secureChannel.abortConnection(() => { this._destroy_secure_channel(); callback(err); }); } else { callback(err); } }); } static async createCertificate( clientCertificateManager: OPCUACertificateManager, certificateFile: string, applicationName: string, applicationUri: string ): Promise { if (!fs.existsSync(certificateFile)) { const hostname = getHostname(); // this.serverInfo.applicationUri!; await clientCertificateManager.createSelfSignedCertificate({ applicationUri, dns: [hostname], // ip: await getIpAddresses(), outputFile: certificateFile, subject: makeSubject(applicationName, hostname), startDate: new Date(), validity: 365 * 10 // 10 years }); } // c8 ignore next if (!fs.existsSync(certificateFile)) { throw new Error(` cannot locate certificate file ${certificateFile}`); } } public async createDefaultCertificate(): Promise { // c8 ignore next if ((this as unknown as { _inCreateDefaultCertificate: boolean })._inCreateDefaultCertificate) { errorLog("Internal error : re-entrancy in createDefaultCertificate!"); } (this as unknown as { _inCreateDefaultCertificate: boolean })._inCreateDefaultCertificate = true; if (!checkFileExistsAndIsNotEmpty(this.certificateFile)) { await withLock({ fileToLock: `${this.certificateFile}.mutex` }, async () => { if (checkFileExistsAndIsNotEmpty(this.certificateFile)) { // the file may have been created in between return; } warningLog("Creating default certificate ... please wait"); await ClientBaseImpl.createCertificate( this.clientCertificateManager, this.certificateFile, this.applicationName, this._getBuiltApplicationUri() ); debugLog("privateKey = ", this.privateKeyFile); debugLog(" = ", this.clientCertificateManager.privateKey); debugLog("certificateFile = ", this.certificateFile); const _certificate = this.getCertificate(); const _privateKey = this.getPrivateKey(); }); } (this as unknown as { _inCreateDefaultCertificate: boolean })._inCreateDefaultCertificate = false; } protected _getBuiltApplicationUri(): string { if (!this._applicationUri) { this._applicationUri = makeApplicationUrn(getHostname(), this.applicationName); } return this._applicationUri; } protected async initializeCM(): Promise { if (!this.clientCertificateManager) { // this usually happen when the client has been already disconnected, // disconnect errorLog( "[NODE-OPCUA-E08] initializeCM: clientCertificateManager is null\n" + " This happen when you disconnected the client, to free resources.\n" + " Please create a new OPCUAClient instance if you want to reconnect" ); return; } await this.clientCertificateManager.initialize(); await this.createDefaultCertificate(); // c8 ignore next if (!fs.existsSync(this.privateKeyFile)) { throw new Error(` cannot locate private key file ${this.privateKeyFile}`); } if (this.isUnusable()) return; // Note: do NOT wrap this in withLock2 — performCertificateSanityCheck // calls trustCertificate and verifyCertificate which each acquire // withLock2 internally, so an outer withLock2 would cause a deadlock // on the same file-based mutex. await performCertificateSanityCheck(this, "client", this.clientCertificateManager, this._getBuiltApplicationUri()); } protected _internalState: InternalClientState = "uninitialized"; protected _handleUnrecoverableConnectionFailure(err: Error, callback: ErrorCallback): void { debugLog(err.message); this.emit("connection_failed", err); this._setInternalState("disconnected"); callback(err); } private _handleDisconnectionWhileConnecting(err: Error, callback: ErrorCallback) { debugLog(err.message); this.emit("connection_failed", err); this._setInternalState("disconnected"); callback(err); } private _handleSuccessfulConnection(callback: ErrorCallback) { debugLog(" Connected successfully to ", this.endpointUrl); this.emit("connected"); this._setInternalState("connected"); callback(); } /** * connect the OPC-UA client to a server end point. */ public connect(endpointUrl: string): Promise; public connect(endpointUrl: string, callback: ErrorCallback): void; public connect(...args: unknown[]): Promise | void { const endpointUrl = args[0]; const callback = args[1] as ErrorCallback; assert(typeof callback === "function", "expecting a callback"); if (typeof endpointUrl !== "string" || endpointUrl.length <= 0) { errorLog(`[NODE-OPCUA-E03] OPCUAClient#connect expects a valid endpoint : ${endpointUrl}`); callback(new Error("Invalid endpoint")); return; } assert(typeof endpointUrl === "string" && endpointUrl.length > 0); // c8 ignore next if (this._internalState !== "disconnected") { callback(new Error(`client#connect failed, as invalid internal state = ${this._internalState}`)); return; } // prevent illegal call to connect if (this._secureChannel !== null) { setImmediate(() => callback(new Error("connect already called"))); return; } this._setInternalState("connecting"); this.initializeCM() .then(() => { debugLog("ClientBaseImpl#connect ", endpointUrl, this.clientName); if (this._internalState === "disconnecting" || this._internalState === "disconnected") { return this._handleDisconnectionWhileConnecting(new Error("premature disconnection 1"), callback); } if ( !this.serverCertificate && (forceEndpointDiscoveryOnConnect || this.securityMode !== MessageSecurityMode.None) ) { debugLog("Fetching certificates from endpoints"); this.fetchServerCertificate(endpointUrl, (err: Error | null, adjustedEndpointUrl?: string) => { if (err) { return this._handleUnrecoverableConnectionFailure(err, callback); } if (this.isUnusable()) { return this._handleDisconnectionWhileConnecting(new Error("premature disconnection 2"), callback); } if (forceEndpointDiscoveryOnConnect) { debugLog("connecting with adjusted endpoint : ", adjustedEndpointUrl, " was =", endpointUrl); this._connectStep2(adjustedEndpointUrl || "", callback); } else { debugLog("connecting with endpoint : ", endpointUrl); this._connectStep2(endpointUrl, callback); } }); } else { this._connectStep2(endpointUrl, callback); } }) .catch((err) => { return this._handleUnrecoverableConnectionFailure(err, callback); }); } /** * @private */ public _connectStep2(endpointUrl: string, callback: ErrorCallback): void { // prevent illegal call to connect assert(this._secureChannel === null); this.endpointUrl = endpointUrl; this._clockAdjuster = this._clockAdjuster || new ClockAdjustment(); OPCUAClientBase.registry.register(this); debugLog("__connectStep2 ", this._internalState); this._internal_create_secure_channel(this.connectionStrategy, (err: Error | null) => { if (!err) { this._handleSuccessfulConnection(callback); } else { OPCUAClientBase.registry.unregister(this); if (this._clockAdjuster) { this._clockAdjuster.dispose(); this._clockAdjuster = undefined; } debugLog(chalk.red("SecureChannel creation has failed with error :", err.message)); if (err.message.match(/ECONNABORTED/)) { debugLog(chalk.yellow(`- The client cannot to :${endpointUrl}. Connection has been aborted.`)); err = new Error("The connection has been aborted"); this._handleUnrecoverableConnectionFailure(err, callback); } else if (err.message.match(/ECONNREF/)) { debugLog(chalk.yellow(`- The client cannot to :${endpointUrl}. Server is not reachable.`)); err = new Error( `The connection cannot be established with server ${endpointUrl} .\n` + "Please check that the server is up and running or your network configuration.\n" + "Err = (" + err.message + ")" ); this._handleUnrecoverableConnectionFailure(err, callback); } else if (err.message.match(/disconnecting/)) { /* */ this._handleDisconnectionWhileConnecting(err, callback); } else { err = new Error(`The connection may have been rejected by server,\n Err = (${err.message})`); this._handleUnrecoverableConnectionFailure(err, callback); } } }); } public performMessageTransaction(request: Request, callback: ResponseCallback): void { if (!this._secureChannel) { // this may happen if the Server has closed the connection abruptly for some unknown reason // or if the tcp connection has been broken. callback( new Error("performMessageTransaction: No SecureChannel , connection may have been canceled abruptly by server") ); return; } if ( this._internalState !== "connected" && this._internalState !== "reconnecting_newchannel_connected" && this._internalState !== "connecting" && this._internalState !== "reconnecting" ) { callback( new Error( "performMessageTransaction: Invalid client state = " + this._internalState + " while performing a transaction " + request.schema.name ) ); return; } assert(this._secureChannel); assert(request); assert(request.requestHeader); assert(typeof callback === "function"); this._secureChannel.performMessageTransaction(request, callback as unknown as PerformTransactionCallback); } /** * * return the endpoint information matching security mode and security policy. */ public findEndpointForSecurity( securityMode: MessageSecurityMode, securityPolicy: SecurityPolicy ): EndpointDescription | undefined { securityMode = coerceMessageSecurityMode(securityMode); securityPolicy = coerceSecurityPolicy(securityPolicy); assert(this.knowsServerEndpoint, "Server end point are not known yet"); return this._serverEndpoints.find((endpoint) => { return endpoint.securityMode === securityMode && endpoint.securityPolicyUri === securityPolicy; }); } /** * * return the endpoint information matching the specified url , security mode and security policy. */ public findEndpoint( endpointUrl: string, securityMode: MessageSecurityMode, securityPolicy: SecurityPolicy ): EndpointDescription | undefined { assert(this.knowsServerEndpoint, "Server end point are not known yet"); if (!this._serverEndpoints || this._serverEndpoints.length === 0) { return undefined; } return this._serverEndpoints.find((endpoint: EndpointDescription) => { return ( matchUri(endpoint.endpointUrl, endpointUrl) && endpoint.securityMode === securityMode && endpoint.securityPolicyUri === securityPolicy ); }); } public async getEndpoints(options?: GetEndpointsOptions): Promise; public getEndpoints(options: GetEndpointsOptions, callback: ResponseCallback): void; public getEndpoints(callback: ResponseCallback): void; public getEndpoints(...args: unknown[]): Promise | undefined { if (args.length === 1) { this.getEndpoints({} as GetEndpointsOptions, args[0] as unknown as ResponseCallback); return; } const options = args[0] as GetEndpointsOptions; const callback = args[1] as ResponseCallback; assert(typeof callback === "function"); options.localeIds = options.localeIds || []; options.profileUris = options.profileUris || []; const request = new GetEndpointsRequest({ endpointUrl: options.endpointUrl || this.endpointUrl, localeIds: options.localeIds, profileUris: options.profileUris, requestHeader: { auditEntryId: null } }); this.performMessageTransaction(request, (err: Error | null, response?: Response) => { if (err) { callback(err); return; } this._serverEndpoints = []; // c8 ignore next if (!response || !(response instanceof GetEndpointsResponse)) { callback(new Error("Internal Error")); return; } if (response?.endpoints) { this._serverEndpoints = response.endpoints; } callback(null, this._serverEndpoints); }); return; } /** * @deprecated */ public getEndpointsRequest(options: GetEndpointsOptions, callback: ResponseCallback): void { warningLog("note: ClientBaseImpl#getEndpointsRequest is deprecated, use ClientBaseImpl#getEndpoints instead"); this.getEndpoints(options, callback); } /** */ public findServers(options?: FindServersRequestLike): Promise; public findServers(options: FindServersRequestLike, callback: ResponseCallback): void; public findServers(callback: ResponseCallback): void; public findServers(...args: unknown[]): Promise | undefined { if (args.length === 1) { if (typeof args[0] === "function") { this.findServers({} as FindServersRequestLike, args[0] as ResponseCallback); return; } throw new Error("Invalid arguments"); } const options = args[0] as FindServersRequestLike; const callback = args[1] as ResponseCallback; const request = new FindServersRequest({ endpointUrl: options.endpointUrl || this.endpointUrl, localeIds: options.localeIds || [], serverUris: options.serverUris || [] }); this.performMessageTransaction(request, (err: Error | null, response?: Response) => { if (err) { callback(err); return; } /* c8 ignore next */ if (!response || !(response instanceof FindServersResponse)) { callback(new Error("Internal Error")); return; } response.servers = response.servers || []; callback(null, response.servers); }); return undefined; } public findServersOnNetwork(options?: FindServersOnNetworkRequestLike): Promise; public findServersOnNetwork(callback: ResponseCallback): void; public findServersOnNetwork(options: FindServersOnNetworkRequestLike, callback: ResponseCallback): void; public findServersOnNetwork(...args: unknown[]): Promise | undefined { if (args.length === 1) { this.findServersOnNetwork({} as FindServersOnNetworkRequestOptions, args[0] as ResponseCallback); return undefined; } const options = args[0] as FindServersOnNetworkRequestOptions; const callback = args[1] as ResponseCallback; const request = new FindServersOnNetworkRequest(options); this.performMessageTransaction(request, (err: Error | null, response?: Response) => { if (err) { callback(err); return; } /* c8 ignore next */ if (!response || !(response instanceof FindServersOnNetworkResponse)) { callback(new Error("Internal Error")); return; } response.servers = response.servers || []; callback(null, response.servers); }); return undefined; } public _removeSession(session: ClientSessionImpl): void { const index = this._sessions.indexOf(session); if (index >= 0) { const _s = this._sessions.splice(index, 1)[0]; // assert(s === session); // assert(session._client === this); session._client = null; } assert(this._sessions.indexOf(session) === -1); } private _closeSession( session: ClientSessionImpl, deleteSubscriptions: boolean, callback: (err: Error | null, response?: CloseSessionResponse) => void ) { assert(typeof callback === "function"); assert(typeof deleteSubscriptions === "boolean"); // c8 ignore next if (!this._secureChannel) { return callback(null); // new Error("no channel")); } assert(this._secureChannel); if (!this._secureChannel.isValid()) { return callback(null); } debugLog(chalk.bgWhite.green("_closeSession ") + this._secureChannel.channelId); const request = new CloseSessionRequest({ deleteSubscriptions }); session.performMessageTransaction(request, (err: Error | null, response?: Response) => { if (err) { callback(err); } else { callback(err, response as CloseSessionResponse); } }); } public closeSession(session: ClientSessionImpl, deleteSubscriptions: boolean): Promise; public closeSession(session: ClientSessionImpl, deleteSubscriptions: boolean, callback: ErrorCallback): void; public closeSession(session: ClientSessionImpl, deleteSubscriptions: boolean, callback?: ErrorCallback): Promise | void { assert(typeof deleteSubscriptions === "boolean"); assert(session); assert(session._client === this, "session must be attached to this"); session._closed = true; // todo : send close session on secure channel this._closeSession(session, deleteSubscriptions, (err?: Error | null, _response?: CloseSessionResponse) => { session.emitCloseEvent(StatusCodes.Good); this._removeSession(session); session.dispose(); assert(this._sessions.indexOf(session) === -1); assert(session._closed, "session must indicate it is closed"); callback?.(err ? err : undefined); }); } public disconnect(): Promise; public disconnect(callback: ErrorCallback): void; // eslint-disable-next-line max-statements public disconnect(...args: unknown[]): undefined | Promise { const callback = args[0] as ErrorCallback; assert(typeof callback === "function", "expecting a callback function here"); this._reconnectionIsCanceled = true; if (this._tmpClient) { warningLog("disconnecting client while tmpClient exists", this._tmpClient.clientName); this._tmpClient.disconnect((_err) => { this._tmpClient = undefined; // retry disconnect on main client this.disconnect(callback); }); return undefined; } if (this._internalState === "disconnected" || this._internalState === "disconnecting") { if (this._internalState === "disconnecting") { warningLog( "[NODE-OPCUA-W26] OPCUAClient#disconnect called while already disconnecting. clientName=", this.clientName ); } if (!this._reconnectionIsCanceled && this.isReconnecting) { errorLog("Internal Error : _reconnectionIsCanceled should be true if isReconnecting is true"); } callback(); return undefined; } debugLog("disconnecting client! (will set reconnectionIsCanceled to true"); this._reconnectionIsCanceled = true; debugLog("ClientBaseImpl#disconnect", this.endpointUrl); if (this.isReconnecting && !this._reconnectionIsCanceled) { debugLog("ClientBaseImpl#disconnect called while reconnection is in progress"); // let's abort the reconnection process this._cancel_reconnection((err?: Error) => { debugLog("ClientBaseImpl#disconnect reconnection has been canceled", this.applicationName); assert(!err, " why would this fail ?"); // sessions cannot be cancelled properly and must be discarded. this.disconnect(callback); }); return undefined; } if (this._sessions.length && !this.keepPendingSessionsOnDisconnect) { debugLog("warning : disconnection : closing pending sessions"); // disconnect has been called whereas living session exists // we need to close them first .... (unless keepPendingSessionsOnDisconnect) this._close_pending_sessions((/*err*/) => { this.disconnect(callback); }); return undefined; } debugLog("Disconnecting !"); this._setInternalState("disconnecting"); if (this.clientCertificateManager) { const tmp = this.clientCertificateManager; // (this as any).clientCertificateManager = null; tmp.dispose().catch((err: Error) => { debugLog("Error disposing clientCertificateManager", err.message); }); } if (this._sessions.length) { // transfer active session to orphan and detach them from channel const tmp = [...this._sessions]; for (const session of tmp) { this._removeSession(session); } this._sessions = []; } assert(this._sessions.length === 0, " attempt to disconnect a client with live sessions "); OPCUAClientBase.registry.unregister(this); if (this._clockAdjuster) { this._clockAdjuster.dispose(); this._clockAdjuster = undefined; } if (this._secureChannel) { let tmpChannel: ClientSecureChannelLayer | null = this._secureChannel; this._secureChannel = null; debugLog("Closing channel"); tmpChannel.close(() => { this._secureChannel = tmpChannel; tmpChannel = null; this._destroy_secure_channel(); this._setInternalState("disconnected"); callback(); }); } else { this._setInternalState("disconnected"); // this.emit("close", null); callback(); } return undefined; } // override me ! public _on_connection_reestablished(callback: ErrorCallback): void { callback(); } public toString(): string { let str = "\n"; str += ` defaultSecureTokenLifetime.... ${this.defaultSecureTokenLifetime}\n`; str += ` securityMode.................. ${MessageSecurityMode[this.securityMode]}\n`; str += ` securityPolicy................ ${this.securityPolicy.toString()}\n`; str += ` certificate fingerprint....... ${makeCertificateThumbPrint(this.getCertificate())?.toString("hex") || "none"}\n`; str += ` server certificate fingerprint ${makeCertificateThumbPrint(this.serverCertificate)?.toString("hex") || "none"}\n`; // this.serverCertificate = options.serverCertificate || null + "\n"; str += ` keepSessionAlive.............. ${this.keepSessionAlive}\n`; str += ` bytesRead..................... ${this.bytesRead}\n`; str += ` bytesWritten.................. ${this.bytesWritten}\n`; str += ` transactionsPerformed......... ${this.transactionsPerformed}\n`; str += ` timedOutRequestCount.......... ${this.timedOutRequestCount}\n`; str += " connectionStrategy." + "\n"; str += ` .maxRetry............... ${this.connectionStrategy.maxRetry}\n`; str += ` .initialDelay........... ${this.connectionStrategy.initialDelay}\n`; str += ` .maxDelay............... ${this.connectionStrategy.maxDelay}\n`; str += ` .randomisationFactor.... ${this.connectionStrategy.randomisationFactor}\n`; str += ` keepSessionAlive.............. ${this.keepSessionAlive}\n`; str += ` applicationName............... ${this.applicationName}\n`; str += ` applicationUri................ ${this._getBuiltApplicationUri()}\n`; str += ` clientName.................... ${this.clientName}\n`; str += ` reconnectOnFailure............ ${this.reconnectOnFailure}\n`; str += ` isReconnecting................ ${this.isReconnecting}\n`; str += ` (internal state).............. ${this._internalState}\n`; str += ` sessions count................ ${this.getSessions().length}\n`; if (this._secureChannel) { str += `secureChannel:\n${this._secureChannel.toString()}`; } return str; } public getSessions(): ClientSessionImpl[] { return this._sessions; } public getTransportSettings(): IBasicTransportSettings { return this._secureChannel?.getTransportSettings() || ({} as IBasicTransportSettings); } protected _addSession(session: ClientSessionImpl): void { assert(!session._client || session._client === this); assert(this._sessions.indexOf(session) === -1, "session already added"); session._client = this; this._sessions.push(session); } private fetchServerCertificate(endpointUrl: string, callback: (err: Error | null, adjustedEndpointUrl?: string) => void): void { const discoveryUrl = this.discoveryUrl.length > 0 ? this.discoveryUrl : endpointUrl; debugLog("OPCUAClientImpl : getting serverCertificate"); // we have not been given the serverCertificate but this certificate // is required as the connection is to be secured. // // Let's explore the server endpoint that matches our security settings // This will give us the missing Certificate as well from the server. // todo : // Once we have the certificate, we cannot trust it straight away // we have to verify that the certificate is valid and not outdated and not revoked. // if the certificate is self-signed the certificate must appear in the trust certificate // list. // if the certificate has been certified by an Certificate Authority we have to // verify that the certificates in the chain are valid and not revoked. // const certificateFile = this.certificateFile; const privateKeyFile = this.privateKeyFile; const applicationName = this.applicationName; const applicationUri = this._applicationUri; const params = { connectionStrategy: this.connectionStrategy, endpointMustExist: false, // Node: May be the discovery endpoint only support security mode NONE // what should we do ? securityMode: this.securityMode, securityPolicy: this.securityPolicy, applicationName, applicationUri, certificateFile, privateKeyFile, clientCertificateManager: this.clientCertificateManager }; __findEndpoint.call(this, discoveryUrl, params, (err: Error | null, result?: FindEndpointResult) => { if (err) { callback(err); return; } // c8 ignore next if (!result) { const err1 = new Error("internal error"); callback(err1); return; } const endpoint = result.selectedEndpoint; if (!endpoint) { // no matching end point can be found ... const err1 = new Error( "cannot find endpoint for securityMode=" + MessageSecurityMode[this.securityMode] + " policy = " + this.securityPolicy ); callback(err1); return; } assert(endpoint); _verify_serverCertificate(this.clientCertificateManager, endpoint.serverCertificate) .then(() => { this.serverCertificate = endpoint.serverCertificate; callback(null, endpoint.endpointUrl || ""); }) .catch((err1: Error) => { warningLog("[NODE-OPCUA-W25] client's server certificate verification has failed ", err1.message); warningLog(" clientCertificateManager.rootDir = ", this.clientCertificateManager.rootDir); const chain = split_der(endpoint.serverCertificate); warningLog(` server certificate chain contains ${chain.length} element(s)`); for (let i = 0; i < chain.length; i++) { const c = chain[i]; const thumbprint = makeSHA1Thumbprint(c).toString("hex"); try { const { exploreCertificate } = require("node-opcua-crypto"); const info = exploreCertificate(c); const tbs = info.tbsCertificate; const cn = tbs.subject?.commonName ?? "unknown"; const issuerCN = tbs.issuer?.commonName ?? "unknown"; const isCA = tbs.extensions?.basicConstraints?.cA === true; const isSelfSigned = JSON.stringify(tbs.subject) === JSON.stringify(tbs.issuer); const certType = isCA ? (isSelfSigned ? "Root CA" : "Intermediate CA") : (isSelfSigned ? "Application (self-signed)" : "Application (CA-signed)"); const ski = tbs.extensions?.subjectKeyIdentifier ?? "-"; const aki = tbs.extensions?.authorityKeyIdentifier?.keyIdentifier ?? "-"; const notBefore = tbs.validity.notBefore.toISOString(); const notAfter = tbs.validity.notAfter.toISOString(); warningLog(` [${i + 1}/${chain.length}] "${cn}" (${certType})`); warningLog(` thumbprint: ${thumbprint}`); warningLog(` issuer: "${issuerCN}"`); warningLog(` SKI: ${ski}`); warningLog(` AKI: ${aki}`); warningLog(` validity: ${notBefore} → ${notAfter}`); } catch { warningLog(` [${i + 1}/${chain.length}] thumbprint: ${thumbprint} (details unavailable)`); } } if (chain.length > 1) { warningLog( " verify also that the issuer certificate is trusted and the issuer's certificate is present in the issuer.cert folder\n" + " of the client certificate manager located in ", this.clientCertificateManager.rootDir ); } else { warningLog( " verify that the server certificate is trusted or that server certificate issuer's certificate is present in the issuer folder" ); } callback(err1); }); }); } private _accumulate_statistics() { if (this._secureChannel) { // keep accumulated statistics this._byteWritten += this._secureChannel.bytesWritten; this._byteRead += this._secureChannel.bytesRead; this._transactionsPerformed += this._secureChannel.transactionsPerformed; this._timedOutRequestCount += this._secureChannel.timedOutRequestCount; // c8 ignore next if (doDebug) { const h = `Client ${this._instanceNumber} ${this.clientName}`; debugLog(chalk.cyan(`${h} byteWritten = `), this._byteWritten); debugLog(chalk.cyan(`${h} byteRead = `), this._byteRead); debugLog(chalk.cyan(`${h} transactions = `), this._transactionsPerformed); debugLog(chalk.cyan(`${h} timedOutRequestCount = `), this._timedOutRequestCount); } } } private _destroy_secure_channel() { if (this._secureChannel) { // c8 ignore next if (doDebug) { debugLog( " DESTROYING SECURE CHANNEL (isTransactionInProgress ?", this._secureChannel.isTransactionInProgress(), ")" ); } this._accumulate_statistics(); const secureChannel = this._secureChannel; this._secureChannel = null; secureChannel.dispose(); secureChannel.removeAllListeners(); } } private _close_pending_sessions(callback: ErrorCallback) { const sessions = [...this._sessions]; const closeAll = async (): Promise => { for (const session of sessions) { assert(session._client === this); await new Promise((resolve) => { let resolved = false; session.close((err?: Error) => { if (resolved) return; resolved = true; if (err) { const msg = session.authenticationToken ? session.authenticationToken.toString() : ""; debugLog(` failing to close session ${msg}`); } resolve(); }); }); } }; closeAll() .then(() => { // c8 ignore next if (this._sessions.length > 0) { debugLog( this._sessions .map((s: ClientSessionImpl) => (s.authenticationToken ? s.authenticationToken.toString() : "")) .join(" ") ); } assert(this._sessions.length === 0, " failed to disconnect exiting sessions "); callback(); }) .catch((err) => callback(err)); } private _install_secure_channel_event_handlers(secureChannel: ClientSecureChannelLayer) { assert(this instanceof ClientBaseImpl); secureChannel.on("send_chunk", (chunk: Buffer) => { /** * notify the observer that a message_chunk has been sent * @event send_chunk * @param message_chunk */ this.emit("send_chunk", chunk); }); secureChannel.on("receive_chunk", (chunk: Buffer) => { /** * notify the observer that a message_chunk has been received * @event receive_chunk * @param message_chunk */ this.emit("receive_chunk", chunk); }); secureChannel.on("send_request", (message: Request1) => { /** * notify the observer that a request has been sent to the server. * @event send_request * @param message */ this.emit("send_request", message as unknown as Request); }); secureChannel.on("receive_response", (message: Response1) => { /** * notify the observer that a response has been received from the server. * @event receive_response * @param message */ this.emit("receive_response", message as unknown as Response); }); secureChannel.on("lifetime_75", (token: ChannelSecurityToken) => { // secureChannel requests a new token debugLog( "SecureChannel Security Token ", token.tokenId, "live time was =", token.revisedLifetime, " is about to expired , it's time to request a new token" ); // forward message to upper level this.emit("lifetime_75", token); }); secureChannel.on("security_token_renewed", (token: ChannelSecurityToken) => { // forward message to upper level this.emit("security_token_renewed", secureChannel, token); }); secureChannel.on("close", (err?: Error | null) => { if (err) { this._setInternalState("panic"); } debugLog(chalk.yellow.bold(" ClientBaseImpl emitting close"), err?.message); this._destroy_secure_channel(); if (!err || !this.reconnectOnFailure) { if (err) { /** * @event connection_lost */ this.emit("connection_lost"); // instead of "close" } // this is a normal close operation initiated by us this.emit("close", err); // instead of "close" } else { if ( this.reconnectOnFailure && this._internalState !== "reconnecting" && this._internalState !== "reconnecting_newchannel_connected" ) { debugLog(" ClientBaseImpl emitting connection_lost"); this._setInternalState("reconnecting"); /** * @event connection_lost */ this.emit("connection_lost"); // instead of "close" this._repairConnection(); } } }); secureChannel.on("timed_out_request", (request: Request1) => { /** * send when a request has timed out without receiving a response * @event timed_out_request * @param request */ this.emit("timed_out_request", request as unknown as Request); }); } #insideRepairConnection = false; #shouldRepairAgain = false; /** * @internal * @private * * timeout to wait before client attempt to reconnect in case of failure * */ static retryDelay = 1000; private _repairConnection() { doDebug && debugLog("_repairConnection = ", this._internalState); if (this.isUnusable()) return; const duration = ClientBaseImpl.retryDelay; if (duration) { this.emit("startingDelayBeforeReconnection", duration); setTimeout(() => { if (this.isUnusable()) return; this.__innerRepairConnection(); }, duration); } else { this.__innerRepairConnection(); } } private __innerRepairConnection() { if (this.isUnusable()) return; debugLog("Entering _repairConnection ", this._internalState); if (this.#insideRepairConnection) { errorLog( "_repairConnection already in progress internal state = ", this._internalState, "clientName =", this.clientName ); this.#shouldRepairAgain = true; return; } this.emit("repairConnectionStarted"); this.#insideRepairConnection = true; debugLog("recreating new secure channel ", this._internalState); this._recreate_secure_channel((err1?: Error) => { debugLog("secureChannel#on(close) => _recreate_secure_channel returns ", err1 ? err1.message : "OK"); if (err1) { debugLog("_recreate_secure_channel has failed: err = ", err1.message); this.emit("close", err1); this.#insideRepairConnection = false; if (this.#shouldRepairAgain) { this.#shouldRepairAgain = false; this._repairConnection(); } else { this._setInternalState("disconnected"); } return; } else { if (this.isUnusable()) return; this._finalReconnectionStep((err2?: Error | null) => { if (err2) { // c8 ignore next if (doDebug) { debugLog("connection_reestablished has failed"); debugLog("err= ", err2.message); } // we still need to retry connecting here !!! debugLog("Disconnected following reconnection failure", err2.message); debugLog(`I will retry OPCUA client reconnection in ${OPCUAClientBase.retryDelay / 1000} seconds`); this.#insideRepairConnection = false; this.#shouldRepairAgain = false; this._destroy_secure_channel(); // this._setInternalState("reconnecting_failed"); setTimeout(() => this._repairConnection(), OPCUAClientBase.retryDelay); return; } else { /** * @event connection_reestablished * send when the connection is reestablished after a connection break */ this.#insideRepairConnection = false; this.#shouldRepairAgain = false; this._setInternalState("connected"); this.emit("connection_reestablished"); } }); } }); } private _finalReconnectionStep(callback: ErrorCallback) { // now delegate to upper class the if (this._on_connection_reestablished) { assert(typeof this._on_connection_reestablished === "function"); this._on_connection_reestablished((err2?: Error) => { callback(err2); }); } else { callback(); } } /** * * @internal * @private */ public __createSession_step2( _session: ClientSessionImpl, _callback: (err: Error | null, session?: ClientSessionImpl) => void ): void { throw new Error("Please override"); } public _activateSession( _session: ClientSessionImpl, _userIdentity: UserIdentityInfo, _callback: (err: Error | null, session?: ClientSessionImpl) => void ): void { throw new Error("Please override"); } } // tslint:disable-next-line: max-classes-per-file class TmpClient extends ClientBaseImpl { constructor(options: OPCUAClientBaseOptions) { options.clientName = `${options.clientName || ""}_TmpClient`; super(options); } async connect(endpoint: string): Promise; connect(endpoint: string, callback: ErrorCallback): void; connect(endpoint: string, callback?: ErrorCallback): Promise | void { debugLog("connecting to TmpClient"); // c8 ignore next if (this._internalState !== "disconnected") { callback?.(new Error(`TmpClient#connect: invalid internal state = ${this._internalState}`)); return; } this._setInternalState("connecting"); this._connectStep2(endpoint, (err?: Error) => { if (this.isUnusable()) { this._handleUnrecoverableConnectionFailure(new Error("premature disconnection 4"), callback || (() => { })); return; } callback?.(err); }); } } // tslint:disable:no-var-requires // tslint:disable:max-line-length import { withCallback } from "thenify-ex"; ClientBaseImpl.prototype.connect = withCallback(ClientBaseImpl.prototype.connect); ClientBaseImpl.prototype.disconnect = withCallback(ClientBaseImpl.prototype.disconnect); ClientBaseImpl.prototype.getEndpoints = withCallback(ClientBaseImpl.prototype.getEndpoints); ClientBaseImpl.prototype.findServers = withCallback(ClientBaseImpl.prototype.findServers); ClientBaseImpl.prototype.findServersOnNetwork = withCallback(ClientBaseImpl.prototype.findServersOnNetwork); OPCUAClientBase.create = (options: OPCUAClientBaseOptions): OPCUAClientBase => { return new ClientBaseImpl(options); };