@tier0/opcua-client 0.4.1 → 0.6.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/BENCHMARK.md CHANGED
@@ -132,3 +132,30 @@ The same deployed Flow was tested with a separate native client generating the f
132
132
  Write CSV parsing was then changed from multiple whole-payload arrays and object copies to a single line scan that constructs compact final write descriptors. Two mixed Node-RED runs, each producing CSV and executing all 100,000 writes inside the Flow, recorded stable peaks of 226.73 MB and 226.06 MB. The immediately preceding fresh-process repeat peaked at 362.29 MB, so the repeatable peak reduction was approximately 37.6%. The optimized repeat recorded Write P50/P95 of 1,410/1,769 ms, delivered 120,000/120,000 native notifications with zero drops, and converged 20,000/20,000 final values. These are process-RSS engineering measurements; allocator and garbage-collector behavior still require production baselines on the target Node-RED image.
133
133
 
134
134
  The 20,000-point outage acceptance stopped and restarted the real OPC UA fixture after the initial four Subscriptions were created. The Subscribe node rebuilt exactly four Subscriptions with 20,000/20,000 MonitoredItems and zero failures. Two subsequent complete write rounds plus the rebuilt initial values delivered 60,000/60,000 native notifications with zero drops and final convergence for all 20,000 points. Recovery-phase RSS peaked at 175.04 MB and the complete run at 184.5 MB. The recovery status explicitly reported `rebuilt=true` and `dataGapPossible=true` because a server-side subscription rebuild cannot guarantee changes that occurred during the outage.
135
+
136
+ ## 0.5.0 knee-point benchmark
137
+
138
+ Recorded on 2026-08-24 with Node-RED 4.1.10, Node.js 22.22.2, open62541 1.4.16, and package 0.5.0. Every tier used a fresh Node-RED process constrained to 2 vCPU and 2 GB. The server and clients used security mode `None` over container loopback. Service batches contained 500 nodes, each server Subscription contained at most 5,000 MonitoredItems, publishing and sampling intervals were 100 ms, and an independent native client changed every monitored value so the Node-RED Subscribe path could be measured without CSV construction in the Write node.
139
+
140
+ | Points | Read P50 / P95 | External write P50 / P95 | Subscribe setup | Native notifications | Max callback lag | Node-RED peak RSS |
141
+ | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
142
+ | 5,000 | 195 / 206 ms | 195 / 196 ms | 232 ms | 30,000, 0 dropped | 0.21 ms | 132.4 MB |
143
+ | 10,000 | 411 / 467 ms | 392 / 396 ms | 475 ms | 60,000, 0 dropped | 1.00 ms | 153.4 MB |
144
+ | 20,000 | 817 / 852 ms | 788 / 792 ms | 915 ms | 120,000, 0 dropped | 2.42 ms | 182.1 MB |
145
+ | 30,000 | 1,263 / 1,274 ms | 1,208 / 1,219 ms | 1,523 ms | 180,000, 0 dropped | 2.18 ms | 194.4 MB |
146
+ | 40,000 | 1,673 / 1,701 ms | 1,753 / 1,795 ms | 1,889 ms | 240,000, 0 dropped | 2.27 ms | 218.8 MB |
147
+ | 60,000 | 2,521 / 2,556 ms | 2,590 / 2,641 ms | 2,860 ms | 360,000, 0 dropped | 5.07 ms | 261.3 MB |
148
+ | 100,000 | 4,215 / 4,263 ms | 4,758 / 4,884 ms | 5,174 ms | 600,000, 0 dropped | 1.64 ms | 391.4 MB |
149
+ | 150,000 | 6,353 / 6,470 ms | 8,045 / 8,137 ms | 7,582 ms | 600,000, 0 dropped | 27.53 ms | 463.2 MB |
150
+ | 175,000 | 7,488 ms | 11,864 ms | 9,290 ms | 525,000, 0 dropped | 1.91 ms | 526.7 MB |
151
+ | 200,000 | 8,485 / 8,663 ms | 42,410 / 43,425 ms | 10,968 ms | 800,000, 0 dropped | 1.73 ms | 564.0 MB |
152
+
153
+ The 5,000 through 100,000 Read results remain close to 23,000-25,000 values per second. A one-second full-poll target therefore crosses its practical boundary between 20,000 and 30,000 points even though larger batches remain correct. Subscription creation stays near 18,000-22,000 MonitoredItems per second through 200,000 points.
154
+
155
+ The coupled server/client system begins a soft knee around 150,000 monitored points. At that tier callback lag repeatedly reached about 27 ms and the default 100,000-entry NodeId cache started heavy eviction. Increasing the cache to 200,000 removed all evictions and improved Read P50 from 6,524 ms to 6,353 ms, but raised peak RSS from 425 MB to 463 MB and did not remove the callback-lag spike. Cache size is therefore a secondary tuning control, not the main scale limit.
156
+
157
+ The sharp knee occurred between 175,000 and 200,000 monitored points. Increasing the point set by 14% increased one full write from about 11.9 seconds to 42.4 seconds. Node-RED still delivered all 800,000 native notifications with zero drops at 200,000 points, so the collapse occurred first in the open62541 server's processing of value changes under a very large monitored set, rather than in the Node-RED callback queue. A Siemens WinCC server can reach its own knee much earlier or later depending on licensed limits, Session and Subscription quotas, security, hardware, and project load.
158
+
159
+ The editor Browse API intentionally caps one tree request at 100,000 total nodes. A namespace containing approximately 100,000 variables plus their Area objects therefore cannot be rendered as one complete selectable tree. Browse a narrower Area/root or import the point list instead of raising this UI protection.
160
+
161
+ For production, keep a one-second full Read at or below roughly 20,000 points per Connection on equivalent hardware. For Subscribe, 50,000-60,000 monitored points per Connection is a conservative local-loopback engineering envelope with substantial margin below the observed system knee. Do not automatically add Sessions to exceed it: first verify the target server's Session, Subscription, MonitoredItem, publish, and sampling limits. Above 100,000 points, raise `nodeIdCacheSize` only with a corresponding memory budget and test the real server under the expected change rate and security policy.
package/README.md CHANGED
@@ -39,6 +39,8 @@ Browse output can be wired directly into Read or Subscribe. In the Read and Writ
39
39
 
40
40
  Subscription commands are `replace`, `add`, `remove`, and `clear`. Read and Write split large arrays into configurable service batches; they do not loop over scalar OPC UA requests.
41
41
 
42
+ Subscribe nodes can save or import their own point list and establish it automatically after deploy. Repeating an unchanged `replace` command preserves existing server Subscriptions and MonitoredItems. Only added, removed, or monitoring-parameter-changed points are reconciled. A rejected point is reported in `failures` without stopping valid points, and requested versus server-revised publishing, sampling, lifetime, keepalive, and queue settings are included in subscription status output.
43
+
42
44
  New Read nodes default to compact results: `nodeId`, optional `alias`, `value`,
43
45
  `statusCode`, and `sourceTimestamp`. Browse metadata and subscription-only point
44
46
  settings are not repeated on every Read. **Value only** emits an array in point-
@@ -107,6 +109,13 @@ Session is intentional. Read/Write diagnostics and subscription status outputs
107
109
  include `connectionGovernance`, with the equivalent and active configuration
108
110
  counts.
109
111
 
112
+ `connectionUsage` reports the current Read/Write/Browse/Subscribe consumer count
113
+ and types, each consumer's last use, aggregate operation counts, and connection
114
+ idle time. It is diagnostic only: `automaticIdleDisconnect` is false. A deployed
115
+ Connection therefore remains stable for subscriptions until its configuration
116
+ node is closed; deleting the last referencing flow is visible as a zero consumer
117
+ count but does not race an idle timer.
118
+
110
119
  ## Connection and subscription recovery
111
120
 
112
121
  The native client reports secure-channel, session, connectivity-check, and subscription-inactivity transitions to a JavaScript connection supervisor. Recoverable channel interruptions keep the native client alive. Terminal failures replace the native client and retry with exponential backoff, 20% jitter, and the configured maximum interval. Operations fail fast with `OPCUA_RECONNECTING` while the connection is unavailable instead of building an unbounded request queue.
@@ -137,6 +146,12 @@ message or Write node identifies one target NodeId. A configured single
137
146
  `useInput` point behaves the same way. Set `msg.opcua.pointList = true` only when
138
147
  an array payload must be forced to mean a dynamic list of point descriptors.
139
148
 
149
+ OPC UA Int64 and UInt64 values inside the JavaScript safe-integer range are
150
+ returned as Numbers. Larger values are returned as exact decimal strings so
151
+ totals and counters never silently lose precision. Use decimal strings for
152
+ 64-bit writes; unsafe Numbers, fractional integers, and values outside the OPC UA
153
+ type range are rejected before a service request.
154
+
140
155
  ```json
141
156
  {
142
157
  "topic": "ns=2;s=Recipe.Steps",
@@ -154,21 +169,43 @@ The Browse editor loads the address space through the deployed Connection node.
154
169
 
155
170
  The selected Variable descriptors are saved with the Browse node. Each input message outputs the full saved selection so it can be wired directly into Read or Subscribe. Send `msg.opcua.liveBrowse = true` when a flow needs to ignore the saved selection and perform a new live Browse.
156
171
 
172
+ Recursive Browse requests batch multiple parent nodes and honor the server's
173
+ `MaxNodesPerBrowse` OperationLimit. `Browse batch` and `References / page` bound
174
+ each request. If `Maximum depth` or `Maximum nodes` truncates a live Browse,
175
+ `msg.opcua.browse.truncated` and `truncationReason` make the partial result
176
+ explicit. Unused server ContinuationPoints are released before the operation
177
+ finishes.
178
+
157
179
  Node editors show only the standard settings by default. Connection security/timeouts/cache, Browse limits/paging, Read/Write batching/output, and Subscribe queue/deadband tuning remain available under a closed **Advanced settings** section. Existing flows retain their stored advanced values.
158
180
 
181
+ ## Server compatibility profiles
182
+
183
+ Connection nodes default to the standard OPC UA profile. Select **Siemens
184
+ WinCC** for classic WinCC or Unified servers that do not advertise usable
185
+ OperationLimits: Read, Write, and MonitoredItem service calls then use a
186
+ conservative fallback of 200 items per request, while advertised non-zero
187
+ server limits still take precedence. Subscribe nodes use at most 1000
188
+ MonitoredItems per Subscription and remove Deadband filters because WinCC does
189
+ not support `ModifyMonitoredItems`/Deadband behavior consistently. The selected
190
+ profile and effective subscription behavior are included in status diagnostics.
191
+
159
192
  ## Secure connections
160
193
 
161
194
  Connection nodes support `None/None` and `Sign` or `SignAndEncrypt` with
162
- `Basic256Sha256`. Secure connections require a client certificate, its private
163
- key, and at least one trusted Server or CA certificate. Trust is strict: an
195
+ `Basic256Sha256`, `Aes128_Sha256_RsaOaep`, or `Aes256_Sha256_RsaPss`. Secure
196
+ connections require a client certificate, its private key, and at least one
197
+ trusted Server or CA certificate. Trust is strict: an
164
198
  empty trust list is rejected instead of silently accepting every Server
165
199
  certificate. Optional CRL files can be supplied as a newline-separated list.
166
200
  Password-protected PEM private keys use the separate Node-RED credential field,
167
201
  so the password is not stored in the normal Flow configuration.
168
202
 
169
- The client Application URI can be configured when the URI embedded in the
170
- client certificate differs from the open62541 default. Username authentication
171
- uses the selected secure endpoint and its advertised UserTokenPolicy.
203
+ Before opening the native client, the package checks certificate validity,
204
+ certificate/private-key matching, RSA key type and minimum 2048-bit strength,
205
+ trusted-certificate validity, and Application URI consistency. When the client
206
+ certificate contains exactly one URI, leaving Application URI empty selects it
207
+ automatically. Username authentication uses the selected secure endpoint and
208
+ its advertised UserTokenPolicy.
172
209
 
173
210
  The Connection editor can call `GetEndpoints`, filters the response to the
174
211
  implemented policies, and selects an endpoint compatible with Anonymous or
@@ -190,7 +227,7 @@ Node-RED Alpine image, the pinned open62541 v1.4.16 source archive, and its
190
227
  not contact npm or GitHub and does not invoke a compiler:
191
228
 
192
229
  ```bash
193
- npm install ./tier0-opcua-client-0.4.1.tgz --offline
230
+ npm install ./tier0-opcua-client-0.6.0.tgz --offline
194
231
  ```
195
232
 
196
233
  The loader uses the bundled native addon directly. Set
@@ -207,6 +244,11 @@ npm run build:offline-prebuild -- --nodedir=/usr/local
207
244
  npm pack
208
245
  ```
209
246
 
247
+ Before publishing, `npm run test:release` runs the JavaScript, native, offline
248
+ runtime, and deployed Node-RED suites when their test Server and Node-RED runtime
249
+ are available. `npm pack` also reruns the JavaScript suite and rejects an
250
+ incomplete offline package.
251
+
210
252
  See `THIRD_PARTY_NOTICES.md` for the bundled open62541 and node-addon-api
211
253
  licenses.
212
254
 
package/lib/client.js CHANGED
@@ -3,7 +3,8 @@
3
3
  const { EventEmitter } = require('events');
4
4
  const { chunks } = require('./points');
5
5
  const { loadAddon } = require('./native');
6
- const { normalizeConnectionOptions } = require('./security');
6
+ const { preflightConnectionSecurity } = require('./security');
7
+ const { SIEMENS_WINCC_DEFAULTS, isSiemensWinCC } = require('./compatibility');
7
8
 
8
9
  function normalizeNotificationBatch(notification) {
9
10
  if (notification && Array.isArray(notification.changes)) {
@@ -16,13 +17,17 @@ function normalizeNotificationBatch(notification) {
16
17
  };
17
18
  }
18
19
 
19
- function effectiveBatchSize(requested, serverLimit) {
20
+ function effectiveBatchSize(requested, serverLimit, fallbackLimit) {
20
21
  const configured = Number.isFinite(Number(requested)) && Number(requested) > 0
21
22
  ? Math.floor(Number(requested))
22
23
  : 500;
23
24
  const limit = Number(serverLimit);
24
- return Number.isFinite(limit) && limit > 0
25
- ? Math.min(configured, Math.floor(limit))
25
+ if (Number.isFinite(limit) && limit > 0) {
26
+ return Math.min(configured, Math.floor(limit));
27
+ }
28
+ const fallback = Number(fallbackLimit);
29
+ return Number.isFinite(fallback) && fallback > 0
30
+ ? Math.min(configured, Math.floor(fallback))
26
31
  : configured;
27
32
  }
28
33
 
@@ -55,6 +60,7 @@ class OpcUaClient extends EventEmitter {
55
60
  operationLimits: {},
56
61
  nodeIdCache: {},
57
62
  connection: {},
63
+ compatibility: this.#compatibilityDiagnostics(),
58
64
  };
59
65
  }
60
66
 
@@ -85,7 +91,8 @@ class OpcUaClient extends EventEmitter {
85
91
  }
86
92
 
87
93
  async #connect() {
88
- const options = normalizeConnectionOptions(this.options);
94
+ const options = preflightConnectionSecurity(this.options);
95
+ this.diagnostics.securityPreflight = options.securityPreflight;
89
96
  const addon = loadAddon();
90
97
  const token = ++this.connectionToken;
91
98
  this.connectionState = 'connecting';
@@ -164,14 +171,36 @@ class OpcUaClient extends EventEmitter {
164
171
  }
165
172
 
166
173
  async browse(options) {
167
- return this.#invoke('browseAsync', options);
174
+ const items = await this.#invoke('browseAsync', options);
175
+ const browse = {
176
+ truncated: Boolean(items?.truncated),
177
+ truncationReason: items?.truncationReason || null,
178
+ serviceCalls: Number(items?.serviceCalls) || 0,
179
+ browseBatchSize: Number(items?.browseBatchSize) || 1,
180
+ releasedContinuationPoints: Number(items?.releasedContinuationPoints) || 0,
181
+ failures: Array.isArray(items?.browseFailures) ? items.browseFailures : [],
182
+ };
183
+ this.diagnostics.browse = browse;
184
+ if (Array.isArray(items)) {
185
+ for (const field of [
186
+ 'truncated', 'truncationReason', 'serviceCalls', 'browseBatchSize',
187
+ 'releasedContinuationPoints', 'browseFailures',
188
+ ]) delete items[field];
189
+ Object.defineProperty(items, 'diagnostics', {
190
+ configurable: true,
191
+ enumerable: false,
192
+ value: browse,
193
+ });
194
+ }
195
+ return items;
168
196
  }
169
197
 
170
198
  async readMany(points, batchSize = 500) {
171
199
  const results = [];
172
200
  await this.connect();
173
201
  const effective = effectiveBatchSize(
174
- batchSize, this.diagnostics.operationLimits?.maxNodesPerRead
202
+ batchSize, this.diagnostics.operationLimits?.maxNodesPerRead,
203
+ this.#compatibilityBatchFallback()
175
204
  );
176
205
  for (const batch of chunks(points, effective)) {
177
206
  results.push(...await this.#invoke('readManyAsync', batch));
@@ -183,7 +212,8 @@ class OpcUaClient extends EventEmitter {
183
212
  const results = [];
184
213
  await this.connect();
185
214
  const effective = effectiveBatchSize(
186
- batchSize, this.diagnostics.operationLimits?.maxNodesPerWrite
215
+ batchSize, this.diagnostics.operationLimits?.maxNodesPerWrite,
216
+ this.#compatibilityBatchFallback()
187
217
  );
188
218
  for (const batch of chunks(points, effective)) {
189
219
  results.push(...await this.#invoke('writeManyAsync', batch));
@@ -201,7 +231,8 @@ class OpcUaClient extends EventEmitter {
201
231
  const results = [];
202
232
  await this.connect();
203
233
  const effective = effectiveBatchSize(
204
- batchSize, this.diagnostics.operationLimits?.maxMonitoredItemsPerCall
234
+ batchSize, this.diagnostics.operationLimits?.maxMonitoredItemsPerCall,
235
+ this.#compatibilityBatchFallback()
205
236
  );
206
237
  for (const batch of chunks(points, effective)) {
207
238
  results.push(...await this.#invoke(
@@ -215,7 +246,8 @@ class OpcUaClient extends EventEmitter {
215
246
  const results = [];
216
247
  await this.connect();
217
248
  const effective = effectiveBatchSize(
218
- batchSize, this.diagnostics.operationLimits?.maxMonitoredItemsPerCall
249
+ batchSize, this.diagnostics.operationLimits?.maxMonitoredItemsPerCall,
250
+ this.#compatibilityBatchFallback()
219
251
  );
220
252
  for (const batch of chunks(monitoredItemIds, effective)) {
221
253
  results.push(...await this.#invoke('removeMonitoredItemsAsync', subscriptionId, batch));
@@ -240,6 +272,22 @@ class OpcUaClient extends EventEmitter {
240
272
  return this.diagnostics;
241
273
  }
242
274
 
275
+ #compatibilityBatchFallback() {
276
+ return isSiemensWinCC(this.options.compatibilityProfile)
277
+ ? SIEMENS_WINCC_DEFAULTS.requestBatchSize
278
+ : 0;
279
+ }
280
+
281
+ #compatibilityDiagnostics() {
282
+ const siemensWincc = isSiemensWinCC(this.options.compatibilityProfile);
283
+ return {
284
+ profile: siemensWincc ? 'siemens-wincc' : 'standard',
285
+ requestBatchFallback: siemensWincc
286
+ ? SIEMENS_WINCC_DEFAULTS.requestBatchSize
287
+ : null,
288
+ };
289
+ }
290
+
243
291
  #refreshDiagnostics() {
244
292
  if (this.handle == null) return;
245
293
  const addon = loadAddon();
@@ -248,6 +296,9 @@ class OpcUaClient extends EventEmitter {
248
296
  const nativeDiagnostics = addon.getClientDiagnostics(this.handle) || {};
249
297
  this.diagnostics = {
250
298
  ...nativeDiagnostics,
299
+ browse: this.diagnostics.browse,
300
+ securityPreflight: this.diagnostics.securityPreflight,
301
+ compatibility: this.#compatibilityDiagnostics(),
251
302
  connection: {
252
303
  ...(nativeDiagnostics.connection || {}),
253
304
  supervisor: { ...this.supervisorDiagnostics },
@@ -0,0 +1,28 @@
1
+ 'use strict';
2
+
3
+ const COMPATIBILITY_PROFILES = Object.freeze({
4
+ Standard: 'standard',
5
+ SiemensWinCC: 'siemens-wincc',
6
+ });
7
+
8
+ const SIEMENS_WINCC_DEFAULTS = Object.freeze({
9
+ requestBatchSize: 200,
10
+ monitoredItemsPerSubscription: 1000,
11
+ });
12
+
13
+ function normalizeCompatibilityProfile(value) {
14
+ return value === COMPATIBILITY_PROFILES.SiemensWinCC
15
+ ? COMPATIBILITY_PROFILES.SiemensWinCC
16
+ : COMPATIBILITY_PROFILES.Standard;
17
+ }
18
+
19
+ function isSiemensWinCC(value) {
20
+ return normalizeCompatibilityProfile(value) === COMPATIBILITY_PROFILES.SiemensWinCC;
21
+ }
22
+
23
+ module.exports = {
24
+ COMPATIBILITY_PROFILES,
25
+ SIEMENS_WINCC_DEFAULTS,
26
+ isSiemensWinCC,
27
+ normalizeCompatibilityProfile,
28
+ };
package/lib/points.js CHANGED
@@ -6,6 +6,17 @@ const DEFAULTS = Object.freeze({
6
6
  discardOldest: true,
7
7
  });
8
8
 
9
+ const INTEGER_RANGES = Object.freeze({
10
+ SByte: [-128n, 127n],
11
+ Byte: [0n, 255n],
12
+ Int16: [-32768n, 32767n],
13
+ UInt16: [0n, 65535n],
14
+ Int32: [-2147483648n, 2147483647n],
15
+ UInt32: [0n, 4294967295n],
16
+ Int64: [-9223372036854775808n, 9223372036854775807n],
17
+ UInt64: [0n, 18446744073709551615n],
18
+ });
19
+
9
20
  function positiveNumber(value, fallback) {
10
21
  const number = Number(value);
11
22
  return Number.isFinite(number) && number > 0 ? number : fallback;
@@ -245,15 +256,39 @@ function messagePoints(msg, staticNodeId, defaults = {}, options = {}) {
245
256
  }
246
257
 
247
258
  function coerceWriteValue(value, dataType) {
248
- if (value == null || typeof value !== 'string') return value;
249
259
  const type = String(dataType || 'String');
260
+ if (Array.isArray(value)) {
261
+ return value.map((item) => coerceWriteValue(item, type));
262
+ }
263
+ if (value == null) return value;
250
264
  if (type === 'Boolean') {
251
- if (/^(true|1)$/i.test(value)) return true;
252
- if (/^(false|0)$/i.test(value)) return false;
265
+ if (typeof value === 'boolean') return value;
266
+ if (/^(true|1)$/i.test(String(value))) return true;
267
+ if (/^(false|0)$/i.test(String(value))) return false;
253
268
  throw new Error(`Invalid Boolean value: ${value}`);
254
269
  }
255
- if (/^(S?Byte|U?Int(16|32|64)|Float|Double)$/.test(type)) {
256
- const number = Number(value);
270
+ if (Object.prototype.hasOwnProperty.call(INTEGER_RANGES, type)) {
271
+ let integer;
272
+ if (typeof value === 'bigint') integer = value;
273
+ else if (typeof value === 'number') {
274
+ if (!Number.isSafeInteger(value)) {
275
+ throw new Error(`Invalid ${type} value: ${value}; use a decimal string for 64-bit integers`);
276
+ }
277
+ integer = BigInt(value);
278
+ } else if (typeof value === 'string' && /^-?\d+$/.test(value.trim())) {
279
+ integer = BigInt(value.trim());
280
+ } else throw new Error(`Invalid ${type} value: ${value}`);
281
+ const [minimum, maximum] = INTEGER_RANGES[type];
282
+ if (integer < minimum || integer > maximum) {
283
+ throw new Error(`Invalid ${type} value: ${value}; outside OPC UA range`);
284
+ }
285
+ if (type === 'Int64' || type === 'UInt64') {
286
+ return typeof value === 'number' ? value : integer.toString();
287
+ }
288
+ return Number(integer);
289
+ }
290
+ if (type === 'Float' || type === 'Double') {
291
+ const number = typeof value === 'number' ? value : Number(value);
257
292
  if (!Number.isFinite(number)) throw new Error(`Invalid ${type} value: ${value}`);
258
293
  return number;
259
294
  }
package/lib/security.js CHANGED
@@ -1,18 +1,111 @@
1
1
  'use strict';
2
2
 
3
+ const fs = require('node:fs');
4
+ const crypto = require('node:crypto');
5
+
3
6
  const SECURITY_POLICY_URIS = Object.freeze({
4
7
  None: 'http://opcfoundation.org/UA/SecurityPolicy#None',
5
8
  Basic256Sha256: 'http://opcfoundation.org/UA/SecurityPolicy#Basic256Sha256',
9
+ Aes128_Sha256_RsaOaep: 'http://opcfoundation.org/UA/SecurityPolicy#Aes128_Sha256_RsaOaep',
10
+ Aes256_Sha256_RsaPss: 'http://opcfoundation.org/UA/SecurityPolicy#Aes256_Sha256_RsaPss',
6
11
  });
7
12
 
8
13
  const SECURITY_MODES = new Set(['None', 'Sign', 'SignAndEncrypt']);
9
14
 
10
- function configurationError(message) {
15
+ function configurationError(message, code = 'OPCUA_INVALID_SECURITY_CONFIGURATION', cause) {
11
16
  const error = new Error(message);
12
- error.code = 'OPCUA_INVALID_SECURITY_CONFIGURATION';
17
+ error.code = code;
18
+ if (cause) error.cause = cause;
13
19
  return error;
14
20
  }
15
21
 
22
+ function readSecurityFile(filePath, label, readFileSync = fs.readFileSync) {
23
+ try {
24
+ return readFileSync(filePath);
25
+ } catch (error) {
26
+ throw configurationError(
27
+ `Cannot read OPC UA ${label}: ${filePath}`,
28
+ 'OPCUA_SECURITY_FILE_UNREADABLE',
29
+ error
30
+ );
31
+ }
32
+ }
33
+
34
+ function parseCertificate(buffer, label, X509Certificate = crypto.X509Certificate) {
35
+ try {
36
+ return new X509Certificate(buffer);
37
+ } catch (error) {
38
+ throw configurationError(
39
+ `Invalid OPC UA ${label}`,
40
+ 'OPCUA_CERTIFICATE_INVALID',
41
+ error
42
+ );
43
+ }
44
+ }
45
+
46
+ function applicationUris(certificate) {
47
+ const output = [];
48
+ const expression = /(?:^|,\s*)URI:((?:"[^"]+")|[^,]+)/g;
49
+ for (const match of String(certificate.subjectAltName || '').matchAll(expression)) {
50
+ const value = match[1].replace(/^"|"$/g, '').trim();
51
+ if (value && !output.includes(value)) output.push(value);
52
+ }
53
+ return output;
54
+ }
55
+
56
+ function validateCertificateTime(certificate, label, now = Date.now()) {
57
+ const validFrom = Date.parse(certificate.validFrom);
58
+ const validTo = Date.parse(certificate.validTo);
59
+ if (Number.isFinite(validFrom) && now < validFrom) {
60
+ throw configurationError(
61
+ `OPC UA ${label} is not valid before ${certificate.validFrom}`,
62
+ 'OPCUA_CERTIFICATE_NOT_YET_VALID'
63
+ );
64
+ }
65
+ if (Number.isFinite(validTo) && now > validTo) {
66
+ throw configurationError(
67
+ `OPC UA ${label} expired at ${certificate.validTo}`,
68
+ 'OPCUA_CERTIFICATE_EXPIRED'
69
+ );
70
+ }
71
+ }
72
+
73
+ function loadPrivateKey(buffer, passphrase, createPrivateKey = crypto.createPrivateKey) {
74
+ const password = passphrase ? String(passphrase) : undefined;
75
+ const attempts = [
76
+ { key: buffer, passphrase: password },
77
+ { key: buffer, format: 'der', type: 'pkcs8', passphrase: password },
78
+ { key: buffer, format: 'der', type: 'pkcs1', passphrase: password },
79
+ ];
80
+ let lastError;
81
+ for (const options of attempts) {
82
+ try {
83
+ return createPrivateKey(options);
84
+ } catch (error) {
85
+ lastError = error;
86
+ }
87
+ }
88
+ throw configurationError(
89
+ 'Invalid OPC UA client private key or key password',
90
+ 'OPCUA_PRIVATE_KEY_INVALID',
91
+ lastError
92
+ );
93
+ }
94
+
95
+ function certificateSummary(certificate, uri) {
96
+ const publicKey = certificate.publicKey;
97
+ return {
98
+ subject: certificate.subject,
99
+ issuer: certificate.issuer,
100
+ validFrom: certificate.validFrom,
101
+ validTo: certificate.validTo,
102
+ fingerprint256: certificate.fingerprint256,
103
+ applicationUri: uri || null,
104
+ publicKeyType: publicKey?.asymmetricKeyType || null,
105
+ modulusLength: publicKey?.asymmetricKeyDetails?.modulusLength || null,
106
+ };
107
+ }
108
+
16
109
  function normalizePathList(value) {
17
110
  const source = Array.isArray(value)
18
111
  ? value
@@ -72,6 +165,92 @@ function normalizeConnectionOptions(options = {}) {
72
165
  return normalized;
73
166
  }
74
167
 
168
+ function preflightConnectionSecurity(options = {}, dependencies = {}) {
169
+ const normalized = normalizeConnectionOptions(options);
170
+ const runtime = {
171
+ readFileSync: dependencies.readFileSync || fs.readFileSync,
172
+ X509Certificate: dependencies.X509Certificate || crypto.X509Certificate,
173
+ createPrivateKey: dependencies.createPrivateKey || crypto.createPrivateKey,
174
+ now: dependencies.now == null ? Date.now() : Number(dependencies.now),
175
+ };
176
+ if (normalized.securityMode === 'None') {
177
+ return {
178
+ ...normalized,
179
+ securityPreflight: { enabled: false, policy: normalized.securityPolicy },
180
+ };
181
+ }
182
+
183
+ const certificate = parseCertificate(
184
+ readSecurityFile(normalized.certificate, 'client certificate', runtime.readFileSync),
185
+ 'client certificate',
186
+ runtime.X509Certificate
187
+ );
188
+ validateCertificateTime(certificate, 'client certificate', runtime.now);
189
+
190
+ const uris = applicationUris(certificate);
191
+ const applicationUri = normalized.applicationUri || (uris.length === 1 ? uris[0] : '');
192
+ if (!applicationUri) {
193
+ throw configurationError(
194
+ 'The OPC UA client certificate must contain one Application URI or Application URI must be configured',
195
+ 'OPCUA_CERTIFICATE_URI_MISSING'
196
+ );
197
+ }
198
+ if (!uris.includes(applicationUri)) {
199
+ throw configurationError(
200
+ `Configured Application URI does not match the client certificate: ${applicationUri}`,
201
+ 'OPCUA_CERTIFICATE_URI_MISMATCH'
202
+ );
203
+ }
204
+
205
+ const privateKey = loadPrivateKey(
206
+ readSecurityFile(normalized.privateKey, 'client private key', runtime.readFileSync),
207
+ normalized.privateKeyPassword,
208
+ runtime.createPrivateKey
209
+ );
210
+ if (!certificate.checkPrivateKey(privateKey)) {
211
+ throw configurationError(
212
+ 'The OPC UA client private key does not match the client certificate',
213
+ 'OPCUA_PRIVATE_KEY_MISMATCH'
214
+ );
215
+ }
216
+
217
+ const keyType = certificate.publicKey?.asymmetricKeyType || '';
218
+ const modulusLength = Number(certificate.publicKey?.asymmetricKeyDetails?.modulusLength) || 0;
219
+ if (!keyType.startsWith('rsa')) {
220
+ throw configurationError(
221
+ `OPC UA ${normalized.securityPolicy} requires an RSA client certificate`,
222
+ 'OPCUA_CERTIFICATE_KEY_TYPE_UNSUPPORTED'
223
+ );
224
+ }
225
+ if (modulusLength > 0 && modulusLength < 2048) {
226
+ throw configurationError(
227
+ `OPC UA client certificate RSA key is too short: ${modulusLength} bits; at least 2048 bits required`,
228
+ 'OPCUA_CERTIFICATE_KEY_TOO_SHORT'
229
+ );
230
+ }
231
+
232
+ const trust = normalized.trustedCertificates.map((filePath) => {
233
+ const trusted = parseCertificate(
234
+ readSecurityFile(filePath, 'trusted certificate', runtime.readFileSync),
235
+ `trusted certificate: ${filePath}`,
236
+ runtime.X509Certificate
237
+ );
238
+ validateCertificateTime(trusted, `trusted certificate: ${filePath}`, runtime.now);
239
+ return certificateSummary(trusted);
240
+ });
241
+
242
+ return {
243
+ ...normalized,
244
+ applicationUri,
245
+ securityPreflight: {
246
+ enabled: true,
247
+ policy: normalized.securityPolicy,
248
+ clientCertificate: certificateSummary(certificate, applicationUri),
249
+ trustedCertificates: trust,
250
+ },
251
+ };
252
+ }
253
+
75
254
  function endpointIdentityTypes(endpoint) {
76
255
  return [...new Set((endpoint?.userIdentityTokens || []).map((token) => token.tokenType))];
77
256
  }
@@ -92,5 +271,6 @@ module.exports = {
92
271
  matchingEndpoints,
93
272
  normalizeConnectionOptions,
94
273
  normalizePathList,
274
+ preflightConnectionSecurity,
95
275
  securityPolicyName,
96
276
  };
package/nodes/browse.html CHANGED
@@ -36,6 +36,8 @@
36
36
  <div class="form-row"><label for="node-input-variablesOnly">Variables only</label><input type="checkbox" id="node-input-variablesOnly" style="width:auto"></div>
37
37
  <div class="form-row"><label for="node-input-maxDepth">Maximum depth</label><input type="number" id="node-input-maxDepth"></div>
38
38
  <div class="form-row"><label for="node-input-maxNodes">Maximum nodes</label><input type="number" id="node-input-maxNodes"></div>
39
+ <div class="form-row"><label for="node-input-browseBatchSize">Browse batch</label><input type="number" id="node-input-browseBatchSize"></div>
40
+ <div class="form-row"><label for="node-input-referencesPerNode">References / page</label><input type="number" id="node-input-referencesPerNode"></div>
39
41
  <div class="form-row"><label for="node-input-attributeBatchSize">Attribute batch</label><input type="number" id="node-input-attributeBatchSize"></div>
40
42
  <div class="form-row"><label for="node-input-outputMode">Output</label><select id="node-input-outputMode"><option value="all">All selected points</option><option value="paged">Paged selected points</option></select></div>
41
43
  <div class="form-row"><label for="node-input-pageSize">Page size</label><input type="number" id="node-input-pageSize"></div>
@@ -54,7 +56,7 @@
54
56
  <li><code>msg.nodeId</code> 或 <code>msg.topic</code> 可覆盖根 NodeId;默认根节点为 ObjectsFolder。</li>
55
57
  </ul>
56
58
  <h4>输出</h4>
57
- <p><code>msg.payload</code> 是点位描述数组,包含 NodeId、名称、Browse 路径、数据类型和访问属性,可直接连接 Read 或 Subscribe。Paged 模式会分批输出,并通过 <code>msg.opcua.command</code> 标记 replace/add。</p>
59
+ <p><code>msg.payload</code> 是点位描述数组,包含 NodeId、名称、Browse 路径、数据类型和访问属性,可直接连接 Read 或 Subscribe。Paged 模式会分批输出,并通过 <code>msg.opcua.command</code> 标记 replace/add。浏览被最大深度或节点数截断时,<code>msg.opcua.browse.truncated</code> 为 true;节点会释放未使用的 ContinuationPoint。</p>
58
60
  </section>
59
61
  <hr>
60
62
  <section lang="en">
@@ -68,7 +70,7 @@
68
70
  <li><code>msg.nodeId</code> or <code>msg.topic</code> can override the root NodeId; the default root is ObjectsFolder.</li>
69
71
  </ul>
70
72
  <h4>Output</h4>
71
- <p><code>msg.payload</code> is an array of point descriptors containing NodeId, names, Browse path, data type, and access attributes. It can feed Read or Subscribe directly. Paged mode emits multiple messages with replace/add in <code>msg.opcua.command</code>.</p>
73
+ <p><code>msg.payload</code> is an array of point descriptors containing NodeId, names, Browse path, data type, and access attributes. It can feed Read or Subscribe directly. Paged mode emits multiple messages with replace/add in <code>msg.opcua.command</code>. When maximum depth or node count truncates a Browse, <code>msg.opcua.browse.truncated</code> is true; unused ContinuationPoints are released.</p>
72
74
  </section>
73
75
  </script>
74
76
  <script type="text/javascript">
@@ -78,6 +80,8 @@
78
80
  connection: { value: '', type: 'tier0-opcua-connection', required: true }, rootNodeId: { value: 'ns=0;i=85' },
79
81
  selectedNodes: { value: [] }, recursive: { value: true }, variablesOnly: { value: true },
80
82
  maxDepth: { value: 10, validate: RED.validators.number() }, maxNodes: { value: 10000, validate: RED.validators.number() },
83
+ browseBatchSize: { value: 100, validate: RED.validators.number() },
84
+ referencesPerNode: { value: 1000, validate: RED.validators.number() },
81
85
  attributeBatchSize: { value: 200, validate: RED.validators.number() }, outputMode: { value: 'all' },
82
86
  pageSize: { value: 500, validate: RED.validators.number() }, name: { value: '' }
83
87
  },
@@ -240,6 +244,8 @@
240
244
  rootNodeId: $('#node-input-rootNodeId').val() || 'ns=0;i=85',
241
245
  maxDepth: $('#node-input-maxDepth').val(),
242
246
  maxNodes: $('#node-input-maxNodes').val(),
247
+ browseBatchSize: $('#node-input-browseBatchSize').val(),
248
+ referencesPerNode: $('#node-input-referencesPerNode').val(),
243
249
  attributeBatchSize: $('#node-input-attributeBatchSize').val()
244
250
  })
245
251
  }).done((data) => {
@@ -248,6 +254,11 @@
248
254
  return;
249
255
  }
250
256
  renderTree(data.tree);
257
+ if (data.truncated) {
258
+ statusArea.text(`${data.total} node(s) loaded; truncated by ${data.truncationReason || 'server limit'}.`);
259
+ } else if (Array.isArray(data.failures) && data.failures.length) {
260
+ statusArea.text(`${data.total} node(s) loaded; ${data.failures.length} branch(es) failed.`);
261
+ }
251
262
  }).fail((request) => {
252
263
  const message = request.responseJSON && request.responseJSON.error || request.responseText || 'Browse failed';
253
264
  statusArea.text(message);