node-opcua-client 2.175.6 → 2.176.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/client_base.d.ts +14 -0
- package/dist/client_base.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/private/client_base_impl.d.ts +8 -0
- package/dist/private/client_base_impl.js +95 -2
- package/dist/private/client_base_impl.js.map +1 -1
- package/dist/reverse/client_reverse_connect.d.ts +37 -0
- package/dist/reverse/client_reverse_connect.js +303 -0
- package/dist/reverse/client_reverse_connect.js.map +1 -0
- package/package.json +40 -40
- package/source/client_base.ts +15 -0
- package/source/index.ts +1 -0
- package/source/private/client_base_impl.ts +114 -3
- package/source/reverse/client_reverse_connect.ts +375 -0
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module node-opcua-client
|
|
3
|
+
*
|
|
4
|
+
* Client-side listener for OPC UA Reverse Connect (OPC UA Part 6 §7.1.3).
|
|
5
|
+
*
|
|
6
|
+
* A `ClientReverseConnect` owns a single TCP listener (`net.Server`). Servers dial into it and
|
|
7
|
+
* send a ReverseHello ("RHE"). The listener reads and validates the RHE, then hands the accepted
|
|
8
|
+
* socket to a waiting `OPCUAClient` (registered through {@link waitForConnection}), matched by
|
|
9
|
+
* `ServerUri` / `EndpointUrl`. This mirrors the OPC Foundation UA-.NETStandard
|
|
10
|
+
* `ReverseConnectManager`: one listener, many clients, demultiplexed by server identity.
|
|
11
|
+
*
|
|
12
|
+
* Security (Part 2 §6.14): reverse connect lets a Server open a socket to the Client, which is a
|
|
13
|
+
* denial-of-service surface the normal (client-initiated) connection does not have. The listener
|
|
14
|
+
* therefore (a) validates every inbound RHE against the caller's {@link ReverseConnectExpectation},
|
|
15
|
+
* (b) times out sockets that do not send a valid RHE quickly, and (c) caps the number of
|
|
16
|
+
* inbound sockets it is holding on to. Full OPC UA security (message security mode, certificate
|
|
17
|
+
* trust) is still enforced afterwards on the SecureChannel.
|
|
18
|
+
*
|
|
19
|
+
* Every accepted socket keeps a durable `error`/`close` guard for as long as the listener owns it,
|
|
20
|
+
* so a peer reset while a socket is being read or held can never surface as an unhandled `'error'`
|
|
21
|
+
* event (which Node would turn into a process-wide uncaught exception).
|
|
22
|
+
*/
|
|
23
|
+
import { createServer, type Server, type Socket } from "node:net";
|
|
24
|
+
|
|
25
|
+
import { checkDebugFlag, make_debugLog, make_warningLog } from "node-opcua-debug";
|
|
26
|
+
import {
|
|
27
|
+
decodeReverseHello,
|
|
28
|
+
type IAcceptedReverseConnection,
|
|
29
|
+
MAXIMUM_REVERSE_HELLO_FIELD_LENGTH,
|
|
30
|
+
packTcpMessage,
|
|
31
|
+
parseEndpointUrl,
|
|
32
|
+
readRawMessageHeader,
|
|
33
|
+
StatusCodes2,
|
|
34
|
+
TCPErrorMessage
|
|
35
|
+
} from "node-opcua-transport";
|
|
36
|
+
import type { StatusCode } from "node-opcua-status-code";
|
|
37
|
+
|
|
38
|
+
const doDebug = checkDebugFlag("ReverseConnect");
|
|
39
|
+
const debugLog = make_debugLog("ReverseConnect");
|
|
40
|
+
const warningLog = make_warningLog("ReverseConnect");
|
|
41
|
+
|
|
42
|
+
// header (8) + 2 × (UInt32 length prefix (4) + up to 4096 bytes). Generous fixed cap for the RHE frame.
|
|
43
|
+
const MAX_RHE_FRAME_LENGTH = 8 + 2 * (4 + MAXIMUM_REVERSE_HELLO_FIELD_LENGTH);
|
|
44
|
+
|
|
45
|
+
const DEFAULT_ACCEPT_TIMEOUT = 120000; // spec: an application shall time out sockets that do not send RHE (≤ 2 min)
|
|
46
|
+
const DEFAULT_MAX_PENDING_SOCKETS = 20;
|
|
47
|
+
const DEFAULT_MATCH_TIMEOUT = 30000; // how long to hold a validated connection waiting for a matching client
|
|
48
|
+
|
|
49
|
+
/** Criteria an inbound ReverseHello must satisfy to be handed to a waiting client. */
|
|
50
|
+
export interface ReverseConnectExpectation {
|
|
51
|
+
/** require the RHE ServerUri to equal this value */
|
|
52
|
+
serverUri?: string;
|
|
53
|
+
/** require the RHE EndpointUrl to equal this value */
|
|
54
|
+
endpointUrl?: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface ClientReverseConnectOptions {
|
|
58
|
+
/** the listen address, e.g. "opc.tcp://0.0.0.0:5555" */
|
|
59
|
+
connectionUrl: string;
|
|
60
|
+
/** ms to wait for a valid RHE after a socket connects. @default 120000 */
|
|
61
|
+
acceptTimeout?: number;
|
|
62
|
+
/** maximum number of inbound sockets the listener will hold on to at once (DoS guard). @default 20 */
|
|
63
|
+
maxPendingSockets?: number;
|
|
64
|
+
/** ms to hold a validated connection waiting for a matching client to register. @default 30000 */
|
|
65
|
+
matchTimeout?: number;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** A cancelable {@link ClientReverseConnect.waitForConnection} registration. */
|
|
69
|
+
export interface ICancelableRegistration {
|
|
70
|
+
cancel(): void;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
type AcceptedCallback = (err: Error | null, accepted?: IAcceptedReverseConnection) => void;
|
|
74
|
+
|
|
75
|
+
interface Waiter {
|
|
76
|
+
expectation?: ReverseConnectExpectation;
|
|
77
|
+
callback: AcceptedCallback;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
interface HeldConnection {
|
|
81
|
+
accepted: IAcceptedReverseConnection;
|
|
82
|
+
/** hand the socket to a waiter: marks it handed-off and stops the listener from touching it further */
|
|
83
|
+
handOff: () => void;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function matches(expectation: ReverseConnectExpectation | undefined, accepted: IAcceptedReverseConnection): boolean {
|
|
87
|
+
if (!expectation) {
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
if (expectation.serverUri && expectation.serverUri !== accepted.serverUri) {
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
if (expectation.endpointUrl && expectation.endpointUrl !== accepted.endpointUrl) {
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
return true;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export class ClientReverseConnect {
|
|
100
|
+
#connectionUrl: string;
|
|
101
|
+
#acceptTimeout: number;
|
|
102
|
+
#maxPendingSockets: number;
|
|
103
|
+
#matchTimeout: number;
|
|
104
|
+
|
|
105
|
+
#server?: Server;
|
|
106
|
+
#listeningPort = 0;
|
|
107
|
+
/** every socket the listener currently owns (being read, or held) — NOT sockets already handed to a transport */
|
|
108
|
+
#sockets = new Set<Socket>();
|
|
109
|
+
#waiters: Waiter[] = [];
|
|
110
|
+
#held: HeldConnection[] = [];
|
|
111
|
+
|
|
112
|
+
constructor(options: ClientReverseConnectOptions | string) {
|
|
113
|
+
const opts: ClientReverseConnectOptions = typeof options === "string" ? { connectionUrl: options } : options;
|
|
114
|
+
this.#connectionUrl = opts.connectionUrl;
|
|
115
|
+
this.#acceptTimeout = opts.acceptTimeout ?? DEFAULT_ACCEPT_TIMEOUT;
|
|
116
|
+
this.#maxPendingSockets = opts.maxPendingSockets ?? DEFAULT_MAX_PENDING_SOCKETS;
|
|
117
|
+
this.#matchTimeout = opts.matchTimeout ?? DEFAULT_MATCH_TIMEOUT;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
public get connectionUrl(): string {
|
|
121
|
+
return this.#connectionUrl;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
public get listeningPort(): number {
|
|
125
|
+
return this.#listeningPort;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
public start(): Promise<void> {
|
|
129
|
+
if (this.#server) {
|
|
130
|
+
return Promise.resolve();
|
|
131
|
+
}
|
|
132
|
+
const ep = parseEndpointUrl(this.#connectionUrl);
|
|
133
|
+
const port = parseInt(ep.port || "4840", 10);
|
|
134
|
+
const host = ep.hostname;
|
|
135
|
+
|
|
136
|
+
const server = createServer((socket: Socket) => this.#handleIncomingSocket(socket));
|
|
137
|
+
this.#server = server;
|
|
138
|
+
|
|
139
|
+
// Permanent server-level error handler: net.Server can emit "error" AFTER a successful
|
|
140
|
+
// listen() (e.g. EMFILE/ENFILE on accept). Without a listener Node throws an uncaught
|
|
141
|
+
// exception and crashes the process, so keep one attached for the server's whole life.
|
|
142
|
+
const onServerError = (err: Error) => {
|
|
143
|
+
// c8 ignore next
|
|
144
|
+
warningLog(`ClientReverseConnect server error: ${err.message}`);
|
|
145
|
+
};
|
|
146
|
+
server.on("error", onServerError);
|
|
147
|
+
|
|
148
|
+
return new Promise<void>((resolve, reject) => {
|
|
149
|
+
const onListenError = (err: Error) => {
|
|
150
|
+
server.removeListener("error", onServerError);
|
|
151
|
+
this.#server = undefined;
|
|
152
|
+
reject(err);
|
|
153
|
+
};
|
|
154
|
+
server.once("error", onListenError);
|
|
155
|
+
server.listen(port, host, () => {
|
|
156
|
+
server.removeListener("error", onListenError);
|
|
157
|
+
const addr = server.address();
|
|
158
|
+
this.#listeningPort = addr && typeof addr === "object" ? addr.port : port;
|
|
159
|
+
// c8 ignore next
|
|
160
|
+
doDebug && debugLog(`ClientReverseConnect listening on ${host}:${this.#listeningPort}`);
|
|
161
|
+
resolve();
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
public stop(): Promise<void> {
|
|
167
|
+
// reject any pending waiters
|
|
168
|
+
const waiters = this.#waiters;
|
|
169
|
+
this.#waiters = [];
|
|
170
|
+
for (const w of waiters) {
|
|
171
|
+
w.callback(new Error("ClientReverseConnect has been stopped"));
|
|
172
|
+
}
|
|
173
|
+
this.#held = [];
|
|
174
|
+
// destroy every socket the listener still owns (being read or held). Their durable
|
|
175
|
+
// error/close guards call forget(), which is a no-op once we clear the set below, so no
|
|
176
|
+
// dangling entries remain. This also unblocks server.close(), which otherwise waits for
|
|
177
|
+
// all live connections to end.
|
|
178
|
+
const sockets = [...this.#sockets];
|
|
179
|
+
this.#sockets.clear();
|
|
180
|
+
for (const socket of sockets) {
|
|
181
|
+
socket.destroy();
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const server = this.#server;
|
|
185
|
+
this.#server = undefined;
|
|
186
|
+
if (!server) {
|
|
187
|
+
return Promise.resolve();
|
|
188
|
+
}
|
|
189
|
+
return new Promise<void>((resolve) => server.close(() => resolve()));
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Register interest in the next reverse connection matching `expectation`.
|
|
194
|
+
* The callback fires once with an accepted, RHE-validated connection (or an error).
|
|
195
|
+
*/
|
|
196
|
+
public waitForConnection(expectation: ReverseConnectExpectation | undefined, callback: AcceptedCallback): ICancelableRegistration {
|
|
197
|
+
// a validated connection may already be waiting
|
|
198
|
+
for (let i = 0; i < this.#held.length; i++) {
|
|
199
|
+
const h = this.#held[i];
|
|
200
|
+
if (matches(expectation, h.accepted)) {
|
|
201
|
+
this.#held.splice(i, 1);
|
|
202
|
+
h.handOff();
|
|
203
|
+
callback(null, h.accepted);
|
|
204
|
+
return { cancel: () => undefined };
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
const waiter: Waiter = { expectation, callback };
|
|
208
|
+
this.#waiters.push(waiter);
|
|
209
|
+
return {
|
|
210
|
+
cancel: () => {
|
|
211
|
+
const idx = this.#waiters.indexOf(waiter);
|
|
212
|
+
if (idx >= 0) {
|
|
213
|
+
this.#waiters.splice(idx, 1);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
#handleIncomingSocket(socket: Socket): void {
|
|
220
|
+
// Track the socket and attach durable error/close guards FIRST, before any rejection path,
|
|
221
|
+
// so the socket is never without an "error" listener (an unhandled "error" would crash the
|
|
222
|
+
// whole process).
|
|
223
|
+
this.#sockets.add(socket);
|
|
224
|
+
|
|
225
|
+
let handedOff = false;
|
|
226
|
+
let removed = false;
|
|
227
|
+
let timer: NodeJS.Timeout | undefined;
|
|
228
|
+
let buffer: Buffer = Buffer.alloc(0);
|
|
229
|
+
|
|
230
|
+
const forget = () => {
|
|
231
|
+
if (removed) {
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
removed = true;
|
|
235
|
+
if (timer) {
|
|
236
|
+
clearTimeout(timer);
|
|
237
|
+
timer = undefined;
|
|
238
|
+
}
|
|
239
|
+
this.#sockets.delete(socket);
|
|
240
|
+
const hi = this.#held.findIndex((h) => h.accepted.socket === socket);
|
|
241
|
+
if (hi >= 0) {
|
|
242
|
+
this.#held.splice(hi, 1);
|
|
243
|
+
}
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
// durable guards: kept for the socket's whole life in the listener. After hand-off they
|
|
247
|
+
// become no-ops (the adopting transport installs its own handlers), but stay attached so
|
|
248
|
+
// there is never a window with zero "error" listeners.
|
|
249
|
+
const onError = () => {
|
|
250
|
+
if (handedOff || removed) {
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
forget();
|
|
254
|
+
socket.destroy();
|
|
255
|
+
};
|
|
256
|
+
const onClose = () => {
|
|
257
|
+
if (handedOff) {
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
forget();
|
|
261
|
+
};
|
|
262
|
+
socket.on("error", onError);
|
|
263
|
+
socket.on("close", onClose);
|
|
264
|
+
|
|
265
|
+
// DoS guard: bound the number of sockets the listener holds (being read + held).
|
|
266
|
+
if (this.#sockets.size > this.#maxPendingSockets) {
|
|
267
|
+
warningLog("ClientReverseConnect: too many pending reverse connections, rejecting");
|
|
268
|
+
this.#rejectSocket(socket, forget, StatusCodes2.BadTcpServerTooBusy, "too many pending reverse connections");
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
socket.setNoDelay(true);
|
|
273
|
+
|
|
274
|
+
timer = setTimeout(() => {
|
|
275
|
+
this.#rejectSocket(socket, forget, StatusCodes2.BadTimeout, "no ReverseHello received in time");
|
|
276
|
+
}, this.#acceptTimeout);
|
|
277
|
+
timer.unref?.();
|
|
278
|
+
|
|
279
|
+
const onData = (chunk: Buffer) => {
|
|
280
|
+
if (removed || handedOff) {
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
buffer = buffer.length === 0 ? chunk : Buffer.concat([buffer, chunk]);
|
|
284
|
+
if (buffer.length < 8) {
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
const declaredLength = readRawMessageHeader(buffer).length;
|
|
288
|
+
if (declaredLength > MAX_RHE_FRAME_LENGTH) {
|
|
289
|
+
socket.removeListener("data", onData);
|
|
290
|
+
this.#rejectSocket(socket, forget, StatusCodes2.BadTcpMessageTooLarge, "ReverseHello message too large");
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
if (buffer.length < declaredLength) {
|
|
294
|
+
return; // wait for the rest of the frame
|
|
295
|
+
}
|
|
296
|
+
const frame = buffer.subarray(0, declaredLength);
|
|
297
|
+
const leftover = buffer.subarray(declaredLength);
|
|
298
|
+
|
|
299
|
+
// stop reading the RHE and the accept timer; keep the error/close guards attached.
|
|
300
|
+
socket.removeListener("data", onData);
|
|
301
|
+
if (timer) {
|
|
302
|
+
clearTimeout(timer);
|
|
303
|
+
timer = undefined;
|
|
304
|
+
}
|
|
305
|
+
// pause before handing the socket over, then push back any bytes read past the RHE frame
|
|
306
|
+
socket.pause();
|
|
307
|
+
if (leftover.length > 0) {
|
|
308
|
+
socket.unshift(leftover);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
let serverUri = "";
|
|
312
|
+
let endpointUrl = "";
|
|
313
|
+
try {
|
|
314
|
+
const rhe = decodeReverseHello(frame);
|
|
315
|
+
serverUri = rhe.serverUri || "";
|
|
316
|
+
endpointUrl = rhe.endpointUrl || "";
|
|
317
|
+
} catch (err) {
|
|
318
|
+
const statusCode = (err as { statusCode?: StatusCode }).statusCode || StatusCodes2.BadTcpEndpointUrlInvalid;
|
|
319
|
+
this.#rejectSocket(socket, forget, statusCode, err instanceof Error ? err.message : "invalid ReverseHello");
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const accepted: IAcceptedReverseConnection = { socket, serverUri, endpointUrl };
|
|
324
|
+
const handOff = () => {
|
|
325
|
+
handedOff = true;
|
|
326
|
+
forget(); // release listener ownership; durable guards remain as no-ops
|
|
327
|
+
};
|
|
328
|
+
|
|
329
|
+
// immediate match?
|
|
330
|
+
for (let i = 0; i < this.#waiters.length; i++) {
|
|
331
|
+
if (matches(this.#waiters[i].expectation, accepted)) {
|
|
332
|
+
const [waiter] = this.#waiters.splice(i, 1);
|
|
333
|
+
handOff();
|
|
334
|
+
waiter.callback(null, accepted);
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// no waiter yet: hold the validated connection briefly in case a client registers imminently.
|
|
340
|
+
// c8 ignore next
|
|
341
|
+
doDebug && debugLog(`reverse connection from ${serverUri} held (no matching client yet)`);
|
|
342
|
+
timer = setTimeout(() => {
|
|
343
|
+
timer = undefined;
|
|
344
|
+
this.#rejectSocket(socket, forget, StatusCodes2.BadTcpServerTooBusy, "no client is waiting for this reverse connection");
|
|
345
|
+
}, this.#matchTimeout);
|
|
346
|
+
timer.unref?.();
|
|
347
|
+
this.#held.push({ accepted, handOff });
|
|
348
|
+
};
|
|
349
|
+
|
|
350
|
+
socket.on("data", onData);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
#rejectSocket(socket: Socket, forget: () => void, statusCode: StatusCode, reason: string): void {
|
|
354
|
+
forget();
|
|
355
|
+
// destroy only AFTER the ERR has been flushed to the kernel, otherwise an immediate
|
|
356
|
+
// destroy() would discard the buffered write and the peer would never see the reason.
|
|
357
|
+
// The socket's durable "error" guard is still attached, so a failed write cannot crash.
|
|
358
|
+
let destroyed = false;
|
|
359
|
+
const destroyOnce = () => {
|
|
360
|
+
if (destroyed) {
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
destroyed = true;
|
|
364
|
+
socket.destroy();
|
|
365
|
+
};
|
|
366
|
+
try {
|
|
367
|
+
const errorMessage = new TCPErrorMessage({ statusCode, reason });
|
|
368
|
+
socket.write(packTcpMessage("ERR", errorMessage), () => destroyOnce());
|
|
369
|
+
// safety net in case the write callback never fires (e.g. socket already broken)
|
|
370
|
+
setTimeout(destroyOnce, 1000).unref?.();
|
|
371
|
+
} catch {
|
|
372
|
+
destroyOnce();
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
}
|