@jsgorana/node-red-opcua 0.1.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/CHANGELOG.md +19 -0
- package/CONTRIBUTING.md +36 -0
- package/LICENSE +21 -0
- package/README.md +506 -0
- package/docs/assets/client-quickstart-flow.png +0 -0
- package/docs/assets/endpoint-security-config.png +0 -0
- package/docs/assets/node-red-import-examples-menu.png +0 -0
- package/docs/assets/server-client-demo-flow.png +0 -0
- package/docs/assets/server-quickstart-flow.png +0 -0
- package/docs/assets/server-security-config.png +0 -0
- package/docs/assets/subscribe-deadband-config.png +0 -0
- package/examples/client-quickstart.json +146 -0
- package/examples/server-and-client-demo.json +197 -0
- package/examples/server-quickstart.json +83 -0
- package/nodes/lib/connection-manager.js +489 -0
- package/nodes/lib/server-manager.js +343 -0
- package/nodes/opcua-browse.html +46 -0
- package/nodes/opcua-browse.js +59 -0
- package/nodes/opcua-endpoint.html +122 -0
- package/nodes/opcua-endpoint.js +85 -0
- package/nodes/opcua-read.html +47 -0
- package/nodes/opcua-read.js +86 -0
- package/nodes/opcua-server.html +191 -0
- package/nodes/opcua-server.js +105 -0
- package/nodes/opcua-subscribe.html +84 -0
- package/nodes/opcua-subscribe.js +124 -0
- package/nodes/opcua-write.html +63 -0
- package/nodes/opcua-write.js +110 -0
- package/package.json +63 -0
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const os = require("os");
|
|
4
|
+
const path = require("path");
|
|
5
|
+
const {
|
|
6
|
+
OPCUAServer,
|
|
7
|
+
OPCUACertificateManager,
|
|
8
|
+
MessageSecurityMode,
|
|
9
|
+
SecurityPolicy,
|
|
10
|
+
Variant,
|
|
11
|
+
DataType,
|
|
12
|
+
StatusCodes,
|
|
13
|
+
nodesets
|
|
14
|
+
} = require("node-opcua");
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Wraps a node-opcua OPCUAServer and exposes a simple variable model so a
|
|
18
|
+
* Node-RED node can:
|
|
19
|
+
* - publish values outward (setValue) for clients to read / subscribe, and
|
|
20
|
+
* - react to client writes via the onClientWrite callback.
|
|
21
|
+
*
|
|
22
|
+
* Each variable is backed by an entry in `this.vars`:
|
|
23
|
+
* { dataType: DataType, value: any, uaVariable: UAVariable }
|
|
24
|
+
* The UA variable uses get/set closures over that entry, so node-opcua's
|
|
25
|
+
* sampling picks up Node-RED-driven changes for subscriptions automatically.
|
|
26
|
+
*
|
|
27
|
+
* Standalone (no Node-RED dependency) so it can be unit-tested in isolation.
|
|
28
|
+
*/
|
|
29
|
+
class ServerManager {
|
|
30
|
+
/**
|
|
31
|
+
* @param {object} config
|
|
32
|
+
* @param {number} [config.port]
|
|
33
|
+
* @param {string} [config.resourcePath]
|
|
34
|
+
* @param {string} [config.serverName]
|
|
35
|
+
* @param {boolean} [config.allowAnonymous]
|
|
36
|
+
* @param {string} [config.username] enables username/password auth when set
|
|
37
|
+
* @param {string} [config.password]
|
|
38
|
+
* @param {string[]} [config.securityModes] subset of "None"|"Sign"|"SignAndEncrypt"
|
|
39
|
+
* @param {string} [config.pkiFolder] where the server certificate / trust lists live
|
|
40
|
+
* @param {boolean} [config.acceptUntrusted] dev-only: trust unknown client certs
|
|
41
|
+
* @param {Array<{name:string,dataType:string,value:any}>} [config.variables]
|
|
42
|
+
* @param {string} [config.folderName] address-space object that holds the vars
|
|
43
|
+
* @param {(name:string, value:any, nodeId:string) => void} [config.onClientWrite]
|
|
44
|
+
*/
|
|
45
|
+
constructor(config) {
|
|
46
|
+
this.config = config || {};
|
|
47
|
+
this.port = this.config.port || 4840;
|
|
48
|
+
this.resourcePath = this.config.resourcePath || "/UA/NodeRED";
|
|
49
|
+
this.serverName = this.config.serverName || "Node-RED OPC-UA Server";
|
|
50
|
+
this.folderName = this.config.folderName || "NodeRED";
|
|
51
|
+
this.allowAnonymous = this.config.allowAnonymous !== false;
|
|
52
|
+
this.username = this.config.username || undefined;
|
|
53
|
+
this.password = this.config.password;
|
|
54
|
+
this.securityModeNames = (Array.isArray(this.config.securityModes) && this.config.securityModes.length)
|
|
55
|
+
? this.config.securityModes
|
|
56
|
+
: ["None"];
|
|
57
|
+
this.pkiFolder = this.config.pkiFolder || path.join(os.tmpdir(), "node-red-opcua-pki");
|
|
58
|
+
this.acceptUntrusted = this.config.acceptUntrusted === true;
|
|
59
|
+
this.onClientWrite = this.config.onClientWrite || function () {};
|
|
60
|
+
|
|
61
|
+
this.server = null;
|
|
62
|
+
this.namespace = null;
|
|
63
|
+
this.folder = null;
|
|
64
|
+
this.state = "stopped";
|
|
65
|
+
this.lastError = null;
|
|
66
|
+
this.endpointUrl = null;
|
|
67
|
+
|
|
68
|
+
/** @type {Object<string,{dataType:number,value:any,uaVariable:any}>} */
|
|
69
|
+
this.vars = {};
|
|
70
|
+
this.pendingValues = {};
|
|
71
|
+
|
|
72
|
+
this.listeners = [];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** @returns {number} DataType enum value (defaults to Double) */
|
|
76
|
+
mapDataType(name) {
|
|
77
|
+
const dt = DataType[name];
|
|
78
|
+
return dt === undefined ? DataType.Double : dt;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** @returns {boolean} whether any secure (Sign/SignAndEncrypt) mode is offered */
|
|
82
|
+
hasSecureMode() {
|
|
83
|
+
return this.securityModeNames.includes("Sign") ||
|
|
84
|
+
this.securityModeNames.includes("SignAndEncrypt");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Translate the configured mode names into node-opcua security arrays.
|
|
89
|
+
* @private
|
|
90
|
+
* @returns {{securityModes:number[], securityPolicies:string[]}}
|
|
91
|
+
*/
|
|
92
|
+
_buildSecurity() {
|
|
93
|
+
const modes = [];
|
|
94
|
+
const policies = new Set();
|
|
95
|
+
if (this.securityModeNames.includes("None")) {
|
|
96
|
+
modes.push(MessageSecurityMode.None);
|
|
97
|
+
policies.add(SecurityPolicy.None);
|
|
98
|
+
}
|
|
99
|
+
if (this.securityModeNames.includes("Sign")) {
|
|
100
|
+
modes.push(MessageSecurityMode.Sign);
|
|
101
|
+
policies.add(SecurityPolicy.Basic256Sha256);
|
|
102
|
+
}
|
|
103
|
+
if (this.securityModeNames.includes("SignAndEncrypt")) {
|
|
104
|
+
modes.push(MessageSecurityMode.SignAndEncrypt);
|
|
105
|
+
policies.add(SecurityPolicy.Basic256Sha256);
|
|
106
|
+
}
|
|
107
|
+
if (modes.length === 0) {
|
|
108
|
+
modes.push(MessageSecurityMode.None);
|
|
109
|
+
policies.add(SecurityPolicy.None);
|
|
110
|
+
}
|
|
111
|
+
// Username/password tokens must be encrypted with the server certificate.
|
|
112
|
+
// Offer Basic256Sha256 as a user-token policy so credentials are protected
|
|
113
|
+
// even when the channel itself is None.
|
|
114
|
+
if (this.username) {
|
|
115
|
+
policies.add(SecurityPolicy.Basic256Sha256);
|
|
116
|
+
}
|
|
117
|
+
return { securityModes: modes, securityPolicies: [...policies] };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Best-practice advisories about the current server security configuration.
|
|
122
|
+
* @returns {string[]}
|
|
123
|
+
*/
|
|
124
|
+
serverSecurityWarnings() {
|
|
125
|
+
const warnings = [];
|
|
126
|
+
const secure = this.hasSecureMode();
|
|
127
|
+
if (this.securityModeNames.includes("None")) {
|
|
128
|
+
warnings.push("Server exposes an unsecured 'None' endpoint. For production, " +
|
|
129
|
+
"disable None and require Sign/SignAndEncrypt.");
|
|
130
|
+
}
|
|
131
|
+
if (this.allowAnonymous && secure) {
|
|
132
|
+
warnings.push("Server allows anonymous access. Require username/password for production.");
|
|
133
|
+
}
|
|
134
|
+
if (this.username && !secure) {
|
|
135
|
+
warnings.push("Server accepts credentials over an unencrypted channel (None). " +
|
|
136
|
+
"Offer SignAndEncrypt so credentials are protected.");
|
|
137
|
+
}
|
|
138
|
+
if (secure && this.acceptUntrusted) {
|
|
139
|
+
warnings.push("Server is accepting untrusted client certificates (development only). " +
|
|
140
|
+
"Disable it and trust client certificates explicitly for production.");
|
|
141
|
+
}
|
|
142
|
+
if (!this.allowAnonymous && !this.username) {
|
|
143
|
+
warnings.push("Server has anonymous access disabled and no username configured — " +
|
|
144
|
+
"no client will be able to authenticate.");
|
|
145
|
+
}
|
|
146
|
+
return warnings;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Start the server and create the configured variables.
|
|
151
|
+
* @returns {Promise<void>}
|
|
152
|
+
*/
|
|
153
|
+
async start() {
|
|
154
|
+
if (this.state === "running" || this.state === "starting") return;
|
|
155
|
+
this._setState("starting");
|
|
156
|
+
try {
|
|
157
|
+
const { securityModes, securityPolicies } = this._buildSecurity();
|
|
158
|
+
const options = {
|
|
159
|
+
port: this.port,
|
|
160
|
+
resourcePath: this.resourcePath,
|
|
161
|
+
nodeset_filename: [nodesets.standard],
|
|
162
|
+
allowAnonymous: this.allowAnonymous,
|
|
163
|
+
securityModes,
|
|
164
|
+
securityPolicies,
|
|
165
|
+
buildInfo: {
|
|
166
|
+
productName: this.serverName,
|
|
167
|
+
buildNumber: "1",
|
|
168
|
+
buildDate: new Date()
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
// Username/password authentication (single user).
|
|
173
|
+
if (this.username) {
|
|
174
|
+
const expectedUser = this.username;
|
|
175
|
+
const expectedPass = this.password;
|
|
176
|
+
options.userManager = {
|
|
177
|
+
isValidUser: (userName, password) =>
|
|
178
|
+
userName === expectedUser && password === expectedPass
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Secure endpoints — and encrypted username tokens — need a server
|
|
183
|
+
// certificate + a client-cert trust store.
|
|
184
|
+
if (this.hasSecureMode() || this.username) {
|
|
185
|
+
const scm = new OPCUACertificateManager({
|
|
186
|
+
rootFolder: path.join(this.pkiFolder, "server"),
|
|
187
|
+
automaticallyAcceptUnknownCertificate: this.acceptUntrusted
|
|
188
|
+
});
|
|
189
|
+
await scm.initialize();
|
|
190
|
+
options.serverCertificateManager = scm;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
this.server = new OPCUAServer(options);
|
|
194
|
+
|
|
195
|
+
await this.server.initialize();
|
|
196
|
+
|
|
197
|
+
const addressSpace = this.server.engine.addressSpace;
|
|
198
|
+
this.namespace = addressSpace.getOwnNamespace();
|
|
199
|
+
this.folder = this.namespace.addObject({
|
|
200
|
+
organizedBy: addressSpace.rootFolder.objects,
|
|
201
|
+
browseName: this.folderName
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
for (const v of (this.config.variables || [])) {
|
|
205
|
+
this._createVariable(v.name, v.dataType, this._coerce(v.dataType, v.value));
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
for (const [name, pending] of Object.entries(this.pendingValues)) {
|
|
209
|
+
this._createVariable(name, pending.dataTypeName || "Double", pending.value);
|
|
210
|
+
}
|
|
211
|
+
this.pendingValues = {};
|
|
212
|
+
|
|
213
|
+
await this.server.start();
|
|
214
|
+
this.endpointUrl = this.server.getEndpointUrl();
|
|
215
|
+
this._setState("running");
|
|
216
|
+
} catch (err) {
|
|
217
|
+
this.lastError = err;
|
|
218
|
+
this._setState("error", err);
|
|
219
|
+
throw err;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Create (or no-op if exists) a served variable.
|
|
225
|
+
* @private
|
|
226
|
+
*/
|
|
227
|
+
_createVariable(name, dataTypeName, initialValue) {
|
|
228
|
+
if (this.vars[name]) return this.vars[name];
|
|
229
|
+
|
|
230
|
+
const dataType = this.mapDataType(dataTypeName);
|
|
231
|
+
const entry = { dataType, value: initialValue, uaVariable: null };
|
|
232
|
+
this.vars[name] = entry;
|
|
233
|
+
|
|
234
|
+
entry.uaVariable = this.namespace.addVariable({
|
|
235
|
+
componentOf: this.folder,
|
|
236
|
+
nodeId: "ns=1;s=" + name,
|
|
237
|
+
browseName: name,
|
|
238
|
+
dataType: dataTypeName || "Double",
|
|
239
|
+
minimumSamplingInterval: 100,
|
|
240
|
+
value: {
|
|
241
|
+
get: () => new Variant({ dataType: entry.dataType, value: entry.value }),
|
|
242
|
+
set: (variant) => {
|
|
243
|
+
entry.value = variant.value;
|
|
244
|
+
try {
|
|
245
|
+
this.onClientWrite(name, variant.value, "ns=1;s=" + name);
|
|
246
|
+
} catch (_e) { /* never break the write path */ }
|
|
247
|
+
return StatusCodes.Good;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
});
|
|
251
|
+
return entry;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** @private best-effort coercion of a configured string value to its datatype */
|
|
255
|
+
_coerce(dataTypeName, value) {
|
|
256
|
+
const numeric = ["SByte", "Byte", "Int16", "UInt16", "Int32", "UInt32", "Float", "Double"];
|
|
257
|
+
if (numeric.includes(dataTypeName)) {
|
|
258
|
+
const n = parseFloat(value);
|
|
259
|
+
return isNaN(n) ? 0 : n;
|
|
260
|
+
}
|
|
261
|
+
if (dataTypeName === "Boolean") {
|
|
262
|
+
return value === true || value === "true" || value === 1 || value === "1";
|
|
263
|
+
}
|
|
264
|
+
return value === undefined || value === null ? "" : value;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Publish a value outward (Node-RED -> served). Creates the variable on the
|
|
269
|
+
* fly (default Double) if it does not exist yet.
|
|
270
|
+
* @param {string} name
|
|
271
|
+
* @param {any} value
|
|
272
|
+
* @param {string} [dataTypeName] used only when creating a new variable
|
|
273
|
+
*/
|
|
274
|
+
setValue(name, value, dataTypeName) {
|
|
275
|
+
const entry = this.vars[name];
|
|
276
|
+
if (!entry) {
|
|
277
|
+
if (!this.namespace || !this.folder) {
|
|
278
|
+
this.pendingValues[name] = { value, dataTypeName: dataTypeName || "Double" };
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
this._createVariable(name, dataTypeName || "Double", value);
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
entry.value = value;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** @returns {any} current value or undefined */
|
|
288
|
+
getValue(name) {
|
|
289
|
+
return this.vars[name] ? this.vars[name].value : undefined;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** @returns {string[]} variable names currently served */
|
|
293
|
+
listVariables() {
|
|
294
|
+
return Object.keys({ ...this.pendingValues, ...this.vars });
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** @returns {object} lightweight runtime diagnostics */
|
|
298
|
+
diagnostics() {
|
|
299
|
+
return {
|
|
300
|
+
state: this.state,
|
|
301
|
+
port: this.port,
|
|
302
|
+
endpointUrl: this.endpointUrl,
|
|
303
|
+
lastError: this.lastError ? this.lastError.message : null,
|
|
304
|
+
variableCount: Object.keys(this.vars).length,
|
|
305
|
+
pendingValueCount: Object.keys(this.pendingValues).length
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Stop the server and release the port.
|
|
311
|
+
* @returns {Promise<void>}
|
|
312
|
+
*/
|
|
313
|
+
async stop() {
|
|
314
|
+
const server = this.server;
|
|
315
|
+
this.server = null;
|
|
316
|
+
this.namespace = null;
|
|
317
|
+
this.folder = null;
|
|
318
|
+
this.vars = {};
|
|
319
|
+
this.pendingValues = {};
|
|
320
|
+
try {
|
|
321
|
+
if (server) await server.shutdown();
|
|
322
|
+
this._setState("stopped");
|
|
323
|
+
} catch (err) {
|
|
324
|
+
this.lastError = err;
|
|
325
|
+
this._setState("error", err);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** Register a state listener: fn(state, info). */
|
|
330
|
+
onState(listener) {
|
|
331
|
+
this.listeners.push(listener);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** @private */
|
|
335
|
+
_setState(state, info) {
|
|
336
|
+
this.state = state;
|
|
337
|
+
for (const listener of this.listeners) {
|
|
338
|
+
try { listener(state, info); } catch (_e) { /* ignore */ }
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
module.exports = { ServerManager };
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
<script type="text/javascript">
|
|
2
|
+
RED.nodes.registerType("opcua-browse", {
|
|
3
|
+
category: "OPC-UA",
|
|
4
|
+
color: "#4a90d9",
|
|
5
|
+
defaults: {
|
|
6
|
+
name: { value: "" },
|
|
7
|
+
endpoint: { value: "", type: "opcua-endpoint", required: true },
|
|
8
|
+
nodeId: { value: "RootFolder" }
|
|
9
|
+
},
|
|
10
|
+
inputs: 1,
|
|
11
|
+
outputs: 1,
|
|
12
|
+
icon: "font-awesome/fa-sitemap",
|
|
13
|
+
label: function () {
|
|
14
|
+
return this.name || (this.nodeId ? "browse " + this.nodeId : "opcua-browse");
|
|
15
|
+
}
|
|
16
|
+
});
|
|
17
|
+
</script>
|
|
18
|
+
|
|
19
|
+
<script type="text/html" data-template-name="opcua-browse">
|
|
20
|
+
<div class="form-row">
|
|
21
|
+
<label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
|
|
22
|
+
<input type="text" id="node-input-name" placeholder="Name">
|
|
23
|
+
</div>
|
|
24
|
+
<div class="form-row">
|
|
25
|
+
<label for="node-input-endpoint"><i class="fa fa-server"></i> Endpoint</label>
|
|
26
|
+
<input type="text" id="node-input-endpoint">
|
|
27
|
+
</div>
|
|
28
|
+
<div class="form-row">
|
|
29
|
+
<label for="node-input-nodeId"><i class="fa fa-hashtag"></i> Node ID</label>
|
|
30
|
+
<input type="text" id="node-input-nodeId" placeholder="RootFolder">
|
|
31
|
+
</div>
|
|
32
|
+
</script>
|
|
33
|
+
|
|
34
|
+
<script type="text/html" data-help-name="opcua-browse">
|
|
35
|
+
<p>Browses the children of an OPC-UA node and returns the references.</p>
|
|
36
|
+
<h3>Inputs</h3>
|
|
37
|
+
<dl class="message-properties">
|
|
38
|
+
<dt class="optional">nodeId <span class="property-type">string</span></dt>
|
|
39
|
+
<dd>Overrides the configured starting Node ID (default <code>RootFolder</code>).</dd>
|
|
40
|
+
</dl>
|
|
41
|
+
<h3>Outputs</h3>
|
|
42
|
+
<dl class="message-properties">
|
|
43
|
+
<dt>payload <span class="property-type">array</span></dt>
|
|
44
|
+
<dd>Array of references: <code>{ nodeId, browseName, displayName, nodeClass, typeDefinition }</code>.</dd>
|
|
45
|
+
</dl>
|
|
46
|
+
</script>
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const STATUS_MAP = {
|
|
4
|
+
connecting: { fill: "yellow", shape: "ring", text: "connecting" },
|
|
5
|
+
connected: { fill: "green", shape: "dot", text: "connected" },
|
|
6
|
+
reconnecting: { fill: "yellow", shape: "ring", text: "reconnecting" },
|
|
7
|
+
error: { fill: "red", shape: "ring", text: "error" },
|
|
8
|
+
closed: { fill: "grey", shape: "ring", text: "closed" }
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
module.exports = function (RED) {
|
|
12
|
+
function OpcuaBrowseNode(config) {
|
|
13
|
+
RED.nodes.createNode(this, config);
|
|
14
|
+
const node = this;
|
|
15
|
+
node.endpoint = RED.nodes.getNode(config.endpoint);
|
|
16
|
+
node.nodeId = config.nodeId || "RootFolder";
|
|
17
|
+
|
|
18
|
+
if (!node.endpoint) {
|
|
19
|
+
node.status({ fill: "red", shape: "ring", text: "no endpoint" });
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
const manager = node.endpoint.register();
|
|
23
|
+
const unsubscribeState = manager.onState((state) => node.status(STATUS_MAP[state] || {}));
|
|
24
|
+
|
|
25
|
+
node.on("input", async function (msg, send, done) {
|
|
26
|
+
try {
|
|
27
|
+
const nodeId = msg.nodeId || node.nodeId;
|
|
28
|
+
const browseResult = await manager.runWithTimeout(async () => {
|
|
29
|
+
const session = await manager.getSession();
|
|
30
|
+
return session.browse(nodeId);
|
|
31
|
+
}, "browse");
|
|
32
|
+
|
|
33
|
+
const references = (browseResult.references || []).map((ref) => ({
|
|
34
|
+
nodeId: ref.nodeId.toString(),
|
|
35
|
+
browseName: ref.browseName.toString(),
|
|
36
|
+
displayName: ref.displayName ? ref.displayName.text : undefined,
|
|
37
|
+
nodeClass: ref.nodeClass,
|
|
38
|
+
typeDefinition: ref.typeDefinition ? ref.typeDefinition.toString() : undefined
|
|
39
|
+
}));
|
|
40
|
+
|
|
41
|
+
msg.payload = references;
|
|
42
|
+
msg.statusCode = browseResult.statusCode ? browseResult.statusCode.toString() : undefined;
|
|
43
|
+
msg.nodeId = nodeId;
|
|
44
|
+
send(msg);
|
|
45
|
+
done();
|
|
46
|
+
} catch (err) {
|
|
47
|
+
node.status({ fill: "red", shape: "ring", text: "browse error" });
|
|
48
|
+
done(err);
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
node.on("close", async function (done) {
|
|
53
|
+
unsubscribeState();
|
|
54
|
+
await node.endpoint.deregister();
|
|
55
|
+
done();
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
RED.nodes.registerType("opcua-browse", OpcuaBrowseNode);
|
|
59
|
+
};
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
<script type="text/javascript">
|
|
2
|
+
RED.nodes.registerType("opcua-endpoint", {
|
|
3
|
+
category: "config",
|
|
4
|
+
defaults: {
|
|
5
|
+
name: { value: "" },
|
|
6
|
+
endpointUrl: { value: "opc.tcp://localhost:4840", required: true },
|
|
7
|
+
securityMode: { value: "None", required: true },
|
|
8
|
+
securityPolicy: { value: "None", required: true },
|
|
9
|
+
authType: { value: "anonymous", required: true },
|
|
10
|
+
timeout: { value: 10000, validate: RED.validators.number() },
|
|
11
|
+
acceptUntrusted: { value: false }
|
|
12
|
+
},
|
|
13
|
+
credentials: {
|
|
14
|
+
username: { type: "text" },
|
|
15
|
+
password: { type: "password" }
|
|
16
|
+
},
|
|
17
|
+
label: function () {
|
|
18
|
+
return this.name || this.endpointUrl || "opcua-endpoint";
|
|
19
|
+
},
|
|
20
|
+
oneditprepare: function () {
|
|
21
|
+
const toggleAuth = function () {
|
|
22
|
+
const t = $("#node-config-input-authType").val();
|
|
23
|
+
$(".opcua-auth-credentials").toggle(t === "username");
|
|
24
|
+
};
|
|
25
|
+
$("#node-config-input-authType").on("change", toggleAuth);
|
|
26
|
+
toggleAuth();
|
|
27
|
+
|
|
28
|
+
const toggleSecure = function () {
|
|
29
|
+
const secure = $("#node-config-input-securityMode").val() !== "None";
|
|
30
|
+
$(".opcua-secure-only").toggle(secure);
|
|
31
|
+
};
|
|
32
|
+
$("#node-config-input-securityMode").on("change", toggleSecure);
|
|
33
|
+
toggleSecure();
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
</script>
|
|
37
|
+
|
|
38
|
+
<script type="text/html" data-template-name="opcua-endpoint">
|
|
39
|
+
<div class="form-row">
|
|
40
|
+
<label for="node-config-input-name"><i class="fa fa-tag"></i> Name</label>
|
|
41
|
+
<input type="text" id="node-config-input-name" placeholder="Name">
|
|
42
|
+
</div>
|
|
43
|
+
<div class="form-row">
|
|
44
|
+
<label for="node-config-input-endpointUrl"><i class="fa fa-globe"></i> Endpoint</label>
|
|
45
|
+
<input type="text" id="node-config-input-endpointUrl" placeholder="opc.tcp://localhost:4840">
|
|
46
|
+
</div>
|
|
47
|
+
<div class="form-row">
|
|
48
|
+
<label for="node-config-input-securityMode"><i class="fa fa-lock"></i> Security Mode</label>
|
|
49
|
+
<select id="node-config-input-securityMode">
|
|
50
|
+
<option value="None">None</option>
|
|
51
|
+
<option value="Sign">Sign</option>
|
|
52
|
+
<option value="SignAndEncrypt">SignAndEncrypt</option>
|
|
53
|
+
</select>
|
|
54
|
+
</div>
|
|
55
|
+
<div class="form-row">
|
|
56
|
+
<label for="node-config-input-securityPolicy"><i class="fa fa-shield"></i> Security Policy</label>
|
|
57
|
+
<select id="node-config-input-securityPolicy">
|
|
58
|
+
<option value="None">None</option>
|
|
59
|
+
<option value="Basic256Sha256">Basic256Sha256</option>
|
|
60
|
+
<option value="Aes128_Sha256_RsaOaep">Aes128_Sha256_RsaOaep</option>
|
|
61
|
+
<option value="Aes256_Sha256_RsaPss">Aes256_Sha256_RsaPss</option>
|
|
62
|
+
<option value="Basic256">Basic256 (legacy)</option>
|
|
63
|
+
<option value="Basic128Rsa15">Basic128Rsa15 (legacy)</option>
|
|
64
|
+
</select>
|
|
65
|
+
</div>
|
|
66
|
+
<div class="form-row">
|
|
67
|
+
<label for="node-config-input-authType"><i class="fa fa-user"></i> Authentication</label>
|
|
68
|
+
<select id="node-config-input-authType">
|
|
69
|
+
<option value="anonymous">Anonymous</option>
|
|
70
|
+
<option value="username">Username / Password</option>
|
|
71
|
+
</select>
|
|
72
|
+
</div>
|
|
73
|
+
<div class="form-row opcua-auth-credentials">
|
|
74
|
+
<label for="node-config-input-username"><i class="fa fa-user"></i> Username</label>
|
|
75
|
+
<input type="text" id="node-config-input-username">
|
|
76
|
+
</div>
|
|
77
|
+
<div class="form-row opcua-auth-credentials">
|
|
78
|
+
<label for="node-config-input-password"><i class="fa fa-key"></i> Password</label>
|
|
79
|
+
<input type="password" id="node-config-input-password">
|
|
80
|
+
</div>
|
|
81
|
+
<div class="form-row">
|
|
82
|
+
<label for="node-config-input-timeout"><i class="fa fa-clock-o"></i> Op timeout</label>
|
|
83
|
+
<input type="number" id="node-config-input-timeout" placeholder="10000" style="width:120px"> ms
|
|
84
|
+
</div>
|
|
85
|
+
<div class="form-row opcua-secure-only">
|
|
86
|
+
<label for="node-config-input-acceptUntrusted" style="width:auto"><i class="fa fa-exclamation-triangle"></i> Accept untrusted server cert</label>
|
|
87
|
+
<input type="checkbox" id="node-config-input-acceptUntrusted" style="display:inline-block;width:auto;vertical-align:top">
|
|
88
|
+
<span style="color:#a00"> dev only — disables server-certificate validation</span>
|
|
89
|
+
</div>
|
|
90
|
+
</script>
|
|
91
|
+
|
|
92
|
+
<script type="text/html" data-help-name="opcua-endpoint">
|
|
93
|
+
<p>Defines an OPC-UA server endpoint and the shared connection used by the
|
|
94
|
+
OPC-UA read, write, browse and subscribe nodes.</p>
|
|
95
|
+
<h3>Settings</h3>
|
|
96
|
+
<dl class="message-properties">
|
|
97
|
+
<dt>Endpoint</dt><dd>The server URL, e.g. <code>opc.tcp://localhost:4840</code>.</dd>
|
|
98
|
+
<dt>Security Mode</dt><dd>None, Sign, or SignAndEncrypt.</dd>
|
|
99
|
+
<dt>Security Policy</dt><dd>The cryptographic policy to negotiate.</dd>
|
|
100
|
+
<dt>Authentication</dt><dd>Anonymous, or username/password.</dd>
|
|
101
|
+
<dt>Op timeout</dt><dd>Max time (ms) a read/write/browse waits before failing
|
|
102
|
+
fast when the server is unreachable. 0 disables. Subscriptions are not
|
|
103
|
+
affected — they keep retrying in the background. Default 10000.</dd>
|
|
104
|
+
</dl>
|
|
105
|
+
|
|
106
|
+
<h3>Security & certificates</h3>
|
|
107
|
+
<p>For <b>Sign</b> or <b>SignAndEncrypt</b>, the client uses an application
|
|
108
|
+
certificate stored under <code><userDir>/opcua-pki</code>. On first connect
|
|
109
|
+
the server certificate is validated against that trust store:</p>
|
|
110
|
+
<ul>
|
|
111
|
+
<li><b>Trusted by default:</b> an unknown server certificate is rejected and
|
|
112
|
+
placed in <code>opcua-pki/rejected</code>. Move it to
|
|
113
|
+
<code>opcua-pki/trusted/certs</code> to trust it.</li>
|
|
114
|
+
<li><b>Accept untrusted server cert</b> disables this validation. It is for
|
|
115
|
+
development only and must be off in production.</li>
|
|
116
|
+
<li>The client's own certificate (<code>opcua-pki/own/certs</code>) usually
|
|
117
|
+
needs to be trusted on the server too.</li>
|
|
118
|
+
</ul>
|
|
119
|
+
<p>Prefer <b>Basic256Sha256</b> or the <b>Aes*</b> policies. <b>Basic128Rsa15</b>
|
|
120
|
+
and <b>Basic256</b> are deprecated. Use <b>SignAndEncrypt</b> when sending
|
|
121
|
+
credentials.</p>
|
|
122
|
+
</script>
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const path = require("path");
|
|
4
|
+
const { ConnectionManager } = require("./lib/connection-manager.js");
|
|
5
|
+
|
|
6
|
+
module.exports = function (RED) {
|
|
7
|
+
/**
|
|
8
|
+
* Configuration node describing a single OPC-UA server endpoint and owning
|
|
9
|
+
* the shared ConnectionManager that operation nodes attach to.
|
|
10
|
+
*/
|
|
11
|
+
function OpcuaEndpointNode(config) {
|
|
12
|
+
RED.nodes.createNode(this, config);
|
|
13
|
+
const node = this;
|
|
14
|
+
|
|
15
|
+
node.endpointUrl = config.endpointUrl;
|
|
16
|
+
node.securityMode = config.securityMode || "None";
|
|
17
|
+
node.securityPolicy = config.securityPolicy || "None";
|
|
18
|
+
node.authType = config.authType || "anonymous";
|
|
19
|
+
node.timeout = config.timeout;
|
|
20
|
+
node.acceptUntrusted = config.acceptUntrusted === true;
|
|
21
|
+
|
|
22
|
+
// Persist the PKI (client app certificate + trusted/rejected server certs)
|
|
23
|
+
// under the Node-RED user directory so it survives restarts.
|
|
24
|
+
const userDir = (RED.settings && RED.settings.userDir) || process.cwd();
|
|
25
|
+
node.pkiFolder = path.join(userDir, "opcua-pki");
|
|
26
|
+
|
|
27
|
+
// Credentials (username/password) are stored securely by Node-RED.
|
|
28
|
+
const username = node.credentials ? node.credentials.username : undefined;
|
|
29
|
+
const password = node.credentials ? node.credentials.password : undefined;
|
|
30
|
+
|
|
31
|
+
node.manager = new ConnectionManager({
|
|
32
|
+
endpointUrl: node.endpointUrl,
|
|
33
|
+
securityMode: node.securityMode,
|
|
34
|
+
securityPolicy: node.securityPolicy,
|
|
35
|
+
authType: node.authType,
|
|
36
|
+
username,
|
|
37
|
+
password,
|
|
38
|
+
timeout: node.timeout,
|
|
39
|
+
pkiFolder: node.pkiFolder,
|
|
40
|
+
acceptUntrusted: node.acceptUntrusted,
|
|
41
|
+
applicationName: "node-red-opcua"
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// Surface best-practice advisories in the runtime log.
|
|
45
|
+
node.manager.securityWarnings().forEach((w) => node.warn(w));
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Operation nodes call this to obtain the shared manager and register
|
|
49
|
+
* themselves as a consumer (ref-counted). They must call
|
|
50
|
+
* `deregister()` on close.
|
|
51
|
+
* @returns {ConnectionManager}
|
|
52
|
+
*/
|
|
53
|
+
node.register = function () {
|
|
54
|
+
node.manager.acquire();
|
|
55
|
+
return node.manager;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/** Operation node teardown hook. */
|
|
59
|
+
node.deregister = async function () {
|
|
60
|
+
try {
|
|
61
|
+
await node.manager.release();
|
|
62
|
+
} catch (err) {
|
|
63
|
+
node.error("OPC-UA release error: " + err.message);
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
node.on("close", async function (done) {
|
|
68
|
+
try {
|
|
69
|
+
// Force a full teardown regardless of ref count on config redeploy.
|
|
70
|
+
node.manager.refCount = 0;
|
|
71
|
+
await node.manager.disconnect();
|
|
72
|
+
} catch (err) {
|
|
73
|
+
node.error("OPC-UA endpoint close error: " + err.message);
|
|
74
|
+
}
|
|
75
|
+
done();
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
RED.nodes.registerType("opcua-endpoint", OpcuaEndpointNode, {
|
|
80
|
+
credentials: {
|
|
81
|
+
username: { type: "text" },
|
|
82
|
+
password: { type: "password" }
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
};
|