node-red-modbus-dynamic-server 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.
@@ -0,0 +1,82 @@
1
+ <script type="text/html" data-template-name="modbus-server-exception">
2
+ <div class="form-row">
3
+ <label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
4
+ <input type="text" id="node-input-name" placeholder="Name">
5
+ </div>
6
+
7
+ <div class="form-row">
8
+ <label for="node-input-exceptionCode"><i class="fa fa-ban"></i> Default Exception</label>
9
+ <select id="node-input-exceptionCode" style="width: calc(100% - 110px);">
10
+ <option value="1">1 - Illegal Function</option>
11
+ <option value="2">2 - Illegal Data Address</option>
12
+ <option value="3">3 - Illegal Data Value</option>
13
+ <option value="4">4 - Server Device Failure</option>
14
+ <option value="5">5 - Acknowledge</option>
15
+ <option value="6">6 - Server Device Busy</option>
16
+ <option value="8">8 - Memory Parity Error</option>
17
+ <option value="10">10 - Gateway Path Unavailable</option>
18
+ <option value="11">11 - Gateway Target Device Failed to Respond</option>
19
+ </select>
20
+ </div>
21
+ </script>
22
+
23
+ <script type="text/html" data-help-name="modbus-server-exception">
24
+ <p>Sends a Modbus exception response for a pending intercepted server request.</p>
25
+
26
+ <p>This is a convenience node for custom flows that decide a request must be
27
+ rejected, such as unsupported address ranges, blocked unit/function combinations,
28
+ policy violations, or fallback reject paths.</p>
29
+
30
+ <h3>Inputs</h3>
31
+ <dl class="message-properties">
32
+ <dt>_modbus.requestId <span class="property-type">string</span></dt>
33
+ <dd>Required internal request identifier for the pending Modbus transaction.</dd>
34
+
35
+ <dt>_modbus.configNodeId <span class="property-type">string</span></dt>
36
+ <dd>Required internal config-node identifier used to submit the exception response.</dd>
37
+
38
+ <dt class="optional">modbusExceptionCode <span class="property-type">number</span></dt>
39
+ <dd>Optional per-message exception code override. If valid, this overrides the
40
+ configured default exception code.</dd>
41
+ </dl>
42
+
43
+ <h3>Outputs</h3>
44
+ <p>None. This node terminates the request by responding with an exception.</p>
45
+
46
+ <h3>Details</h3>
47
+ <p>Exception code source precedence:</p>
48
+ <ol>
49
+ <li><code>msg.modbusExceptionCode</code> (if valid)</li>
50
+ <li>Configured <em>Default Exception</em></li>
51
+ </ol>
52
+ <p>Flows must preserve <code>msg._modbus</code>:</p>
53
+ <pre><code>// CORRECT
54
+ msg.payload = newValue;
55
+ return msg;
56
+
57
+ // INCORRECT: request context is lost
58
+ msg = { payload: newValue };
59
+ return msg;</code></pre>
60
+ <p>Use <code>modbus-source-router</code> or <code>modbus-fc-filter</code> for common filtering
61
+ cases, and use this node when custom logic in a <code>switch</code> or <code>function</code>
62
+ node decides the request must be rejected.</p>
63
+ </script>
64
+
65
+ <script type="text/javascript">
66
+ RED.nodes.registerType('modbus-server-exception', {
67
+ category: 'modbus',
68
+ color: '#E9967A',
69
+ defaults: {
70
+ name: { value: '' },
71
+ exceptionCode: { value: 4, required: true, validate: RED.validators.number() }
72
+ },
73
+ inputs: 1,
74
+ outputs: 0,
75
+ icon: 'arrow-out.svg',
76
+ label: function () {
77
+ return this.name || 'modbus-server-exception'
78
+ }
79
+ })
80
+ </script>
81
+
82
+
@@ -0,0 +1,44 @@
1
+ const { respondWithPayload } = require('./modbus-response-context')
2
+
3
+ module.exports = function (RED) {
4
+ 'use strict'
5
+
6
+ function toValidExceptionCode (value) {
7
+ const n = Number(value)
8
+ if (!Number.isInteger(n) || n < 1 || n > 255) return null
9
+ return n
10
+ }
11
+
12
+ function ModbusServerExceptionNode (config) {
13
+ RED.nodes.createNode(this, config)
14
+ const node = this
15
+
16
+ node.exceptionCode = toValidExceptionCode(config.exceptionCode) || 4
17
+
18
+ node.status({ fill: 'grey', shape: 'ring', text: 'ready' })
19
+
20
+ node.on('input', function (msg, send, done) {
21
+ const override = toValidExceptionCode(msg ? msg.modbusExceptionCode : undefined)
22
+ const exceptionCode = override !== null ? override : node.exceptionCode
23
+
24
+ const responseResult = respondWithPayload(RED, node, msg, { exception: exceptionCode })
25
+ if (!responseResult.context) {
26
+ node.status({ fill: 'yellow', shape: 'ring', text: 'missing context' })
27
+ done()
28
+ return
29
+ }
30
+
31
+ if (!responseResult.ok) {
32
+ node.status({ fill: 'red', shape: 'ring', text: 'unknown requestId' })
33
+ done()
34
+ return
35
+ }
36
+
37
+ node.status({ fill: 'yellow', shape: 'ring', text: `exception ${exceptionCode}` })
38
+ done()
39
+ })
40
+ }
41
+
42
+ RED.nodes.registerType('modbus-server-exception', ModbusServerExceptionNode)
43
+ }
44
+
@@ -0,0 +1,296 @@
1
+ <!-- ─── Edit dialog template ──────────────────────────────────────────────── -->
2
+ <script type="text/html" data-template-name="modbus-source-router">
3
+
4
+ <div class="form-row">
5
+ <label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
6
+ <input type="text" id="node-input-name" placeholder="Name">
7
+ </div>
8
+
9
+ <!-- Rule list --------------------------------------------------------- -->
10
+ <div class="form-row">
11
+ <label style="vertical-align:top; padding-top:6px;"><i class="fa fa-filter"></i> Rules</label>
12
+ <div style="display:inline-block; width:calc(100% - 110px); vertical-align:top;">
13
+ <!-- Column headings -->
14
+ <div id="node-input-rule-header"
15
+ style="display:flex; align-items:center; gap:4px;
16
+ font-size:0.82em; color:#888; padding:0 2px 4px 2px;">
17
+ <span style="width:20px;"></span>
18
+ <span style="flex:1; min-width:70px;">Label (optional)</span>
19
+ <span style="flex:2; min-width:110px;">CIDR (e.g. 192.168.1.0/24)</span>
20
+ <span style="width:28px;"></span>
21
+ </div>
22
+ <ol id="node-input-rule-container"
23
+ style="list-style:none; margin:0; padding:0;"></ol>
24
+ </div>
25
+ </div>
26
+
27
+ <div class="form-row">
28
+ <label>&nbsp;</label>
29
+ <button type="button" id="node-input-add-rule"
30
+ style="background:#f0f0f0; border:1px solid #ccc; border-radius:3px;
31
+ padding:4px 10px; cursor:pointer;">
32
+ <i class="fa fa-plus"></i> Add Rule
33
+ </button>
34
+ </div>
35
+
36
+ <!-- No-match exception code ------------------------------------------- -->
37
+ <div class="form-row">
38
+ <label for="node-input-noMatchException">
39
+ <i class="fa fa-ban"></i> No-match Exception
40
+ </label>
41
+ <select id="node-input-noMatchException" style="width:calc(100% - 110px);">
42
+ <option value="1">1 — Illegal Function</option>
43
+ <option value="2">2 — Illegal Data Address</option>
44
+ <option value="3">3 — Illegal Data Value</option>
45
+ <option value="4">4 — Server Device Failure</option>
46
+ <option value="10">10 — Gateway Path Unavailable</option>
47
+ <option value="11">11 — Gateway Target Device Failed to Respond</option>
48
+ </select>
49
+ </div>
50
+
51
+ </script>
52
+
53
+
54
+ <!-- ─── Help text ────────────────────────────────────────────────────────── -->
55
+ <script type="text/html" data-help-name="modbus-source-router">
56
+ <p>Routes an incoming Modbus request emitted by a <code>modbus-dynamic-server</code> node to
57
+ one of several outputs based on the client's source IP address.</p>
58
+
59
+ <p>Rules are evaluated in the order they appear in the editor. The <strong>first matching
60
+ rule wins</strong> and the message is forwarded to the corresponding output only. A message
61
+ is never sent to more than one output. If no rule matches, the node sends a Modbus exception
62
+ response using the configured exception code and emits nothing on any output.</p>
63
+
64
+ <h3>Inputs</h3>
65
+ <dl class="message-properties">
66
+ <dt>_modbus.requestId <span class="property-type">string</span></dt>
67
+ <dd>Required internal request context field emitted by
68
+ <code>modbus-dynamic-server</code>.</dd>
69
+
70
+ <dt>_modbus.configNodeId <span class="property-type">string</span></dt>
71
+ <dd>Required internal context field used to send a no-match exception response.</dd>
72
+
73
+ <dt>modbus.connection.source.address <span class="property-type">string</span></dt>
74
+ <dd>Required. IPv4 address of the Modbus client. IPv6-mapped IPv4 addresses
75
+ (<code>::ffff:x.x.x.x</code>) are automatically normalised.</dd>
76
+ </dl>
77
+
78
+ <h3>Outputs</h3>
79
+ <p>One output per configured rule. Output <em>N</em> corresponds to rule <em>N</em> in
80
+ the ordered rule list. The total number of outputs is equal to the number of rules.</p>
81
+ <dl class="message-properties">
82
+ <dt>payload <span class="property-type">object</span></dt>
83
+ <dd>The original request message, forwarded unchanged to the matching output.</dd>
84
+ </dl>
85
+
86
+ <h3>Configuration</h3>
87
+ <dl class="message-properties">
88
+ <dt>Rules <span class="property-type">list</span></dt>
89
+ <dd>An ordered list of source-address match rules. Each rule has:
90
+ <ul>
91
+ <li><b>Label</b> — optional description shown on the output port.</li>
92
+ <li><b>CIDR</b> — an IPv4 CIDR block such as <code>192.168.1.0/24</code>
93
+ or a single host as <code>192.168.1.42/32</code>.</li>
94
+ </ul>
95
+ Rules are evaluated top-to-bottom. Drag rows to reorder them.
96
+ </dd>
97
+
98
+ <dt>No-match Exception <span class="property-type">select</span></dt>
99
+ <dd>The Modbus exception code sent to the client when no rule matches the
100
+ source address. Common values:
101
+ <ul>
102
+ <li><b>1</b> — Illegal Function</li>
103
+ <li><b>2</b> — Illegal Data Address</li>
104
+ <li><b>3</b> — Illegal Data Value</li>
105
+ <li><b>4</b> — Server Device Failure</li>
106
+ <li><b>10</b> — Gateway Path Unavailable</li>
107
+ <li><b>11</b> — Gateway Target Device Failed to Respond</li>
108
+ </ul>
109
+ </dd>
110
+ </dl>
111
+
112
+ <h3>Details</h3>
113
+ <p>This node is intended to sit immediately after a <code>modbus-dynamic-server</code> node
114
+ and filter/route requests by client IP before they reach any processing logic.</p>
115
+ <p>A catch-all rule (<code>0.0.0.0/0</code>) can be added as the last rule to handle any
116
+ address not matched by earlier, more-specific rules. It is never added automatically.</p>
117
+ <p>IPv4 CIDR matching examples:</p>
118
+ <ul>
119
+ <li><code>192.168.1.42</code> matches <code>192.168.1.42/32</code></li>
120
+ <li><code>192.168.1.42</code> matches <code>192.168.1.0/24</code></li>
121
+ <li><code>192.168.1.42</code> does <em>not</em> match <code>192.168.2.0/24</code></li>
122
+ <li><code>10.5.6.7</code> matches <code>10.0.0.0/8</code></li>
123
+ </ul>
124
+ <p>IPv6 is not supported in this version. IPv6-mapped IPv4 addresses
125
+ (e.g. <code>::ffff:192.168.1.1</code>) are automatically converted to plain IPv4 before
126
+ evaluation.</p>
127
+ <p>Flows must preserve <code>msg._modbus</code> so no-match exceptions can be sent:</p>
128
+ <pre><code>// CORRECT
129
+ msg.payload = newValue;
130
+ return msg;
131
+
132
+ // INCORRECT: request context is lost
133
+ msg = { payload: newValue };
134
+ return msg;</code></pre>
135
+ </script>
136
+
137
+
138
+ <!-- ─── Node registration ─────────────────────────────────────────────── -->
139
+ <script type="text/javascript">
140
+ ;(function () {
141
+ /**
142
+ * Validate an IPv4 CIDR string.
143
+ * Accepts /0 through /32, rejects any other form.
144
+ */
145
+ function isValidIpv4Cidr (cidr) {
146
+ if (typeof cidr !== 'string') return false
147
+ const parts = cidr.trim().split('/')
148
+ if (parts.length !== 2) return false
149
+ const ip = parts[0]
150
+ const prefixStr = parts[1]
151
+ // Prefix must be a non-negative integer with no leading zeros (except "0" itself).
152
+ if (!/^\d+$/.test(prefixStr)) return false
153
+ const prefix = parseInt(prefixStr, 10)
154
+ if (prefix < 0 || prefix > 32) return false
155
+ const octets = ip.split('.')
156
+ if (octets.length !== 4) return false
157
+ for (let i = 0; i < 4; i++) {
158
+ if (!/^\d+$/.test(octets[i])) return false
159
+ const n = parseInt(octets[i], 10)
160
+ if (n < 0 || n > 255) return false
161
+ }
162
+ return true
163
+ }
164
+
165
+ RED.nodes.registerType('modbus-source-router', {
166
+ category: 'modbus',
167
+ color: '#E9967A',
168
+ defaults: {
169
+ name: { value: '' },
170
+ rules: {
171
+ value: [],
172
+ validate: function (v) {
173
+ if (!Array.isArray(v) || v.length === 0) return true
174
+ return v.every(function (rule) {
175
+ return rule && typeof rule.cidr === 'string' && isValidIpv4Cidr(rule.cidr)
176
+ })
177
+ }
178
+ },
179
+ noMatchException: { value: 1, required: true },
180
+ outputs: { value: 0 }
181
+ },
182
+ inputs: 1,
183
+ outputs: 0,
184
+ icon: 'switch.svg',
185
+
186
+ label: function () {
187
+ return this.name || 'Modbus Source Router'
188
+ },
189
+
190
+ outputLabels: function (index) {
191
+ const rule = (this.rules || [])[index]
192
+ if (!rule) return 'Rule ' + (index + 1)
193
+ return rule.label || rule.cidr || ('Rule ' + (index + 1))
194
+ },
195
+
196
+ oneditprepare: function () {
197
+ const node = this
198
+ const container = $('#node-input-rule-container')
199
+
200
+ // ── Sortable ──────────────────────────────────────────────────────
201
+ container.sortable({
202
+ handle: '.rule-drag-handle',
203
+ axis: 'y',
204
+ cursor: 'move',
205
+ revert: true,
206
+ placeholder: 'ui-sortable-placeholder',
207
+ start: function (event, ui) {
208
+ ui.placeholder.height(ui.item.outerHeight())
209
+ }
210
+ })
211
+
212
+ // ── Row factory ───────────────────────────────────────────────────
213
+ function addRuleRow (rule) {
214
+ const li = $('<li></li>').css({
215
+ display: 'flex',
216
+ alignItems: 'center',
217
+ gap: '4px',
218
+ marginBottom: '4px',
219
+ padding: '4px 6px',
220
+ background: '#f8f8f8',
221
+ border: '1px solid #ddd',
222
+ borderRadius: '3px'
223
+ })
224
+
225
+ // Drag handle
226
+ const handle = $(
227
+ '<i class="fa fa-bars rule-drag-handle" title="Drag to reorder"' +
228
+ ' style="cursor:move; color:#bbb; padding:0 2px; flex-shrink:0;"></i>'
229
+ )
230
+
231
+ // Label input
232
+ const labelInput = $('<input type="text" class="rule-label" placeholder="Label (optional)">')
233
+ .css({ flex: '1', minWidth: '60px' })
234
+ .val((rule && rule.label) || '')
235
+
236
+ // CIDR input with live validation
237
+ const cidrInput = $('<input type="text" class="rule-cidr" placeholder="e.g. 192.168.1.0/24">')
238
+ .css({ flex: '2', minWidth: '100px' })
239
+ .val((rule && rule.cidr) || '')
240
+
241
+ function validateCidr () {
242
+ const val = cidrInput.val().trim()
243
+ if (val && !isValidIpv4Cidr(val)) {
244
+ cidrInput.css({ outline: '2px solid #c00', outlineOffset: '1px' })
245
+ } else {
246
+ cidrInput.css({ outline: '' })
247
+ }
248
+ }
249
+
250
+ cidrInput.on('input change', validateCidr)
251
+ validateCidr()
252
+
253
+ // Delete button
254
+ const deleteBtn = $(
255
+ '<button type="button" title="Remove rule"' +
256
+ ' style="background:none; border:1px solid #ccc; border-radius:3px;' +
257
+ ' padding:2px 7px; cursor:pointer; color:#666; flex-shrink:0;">' +
258
+ '<i class="fa fa-remove"></i></button>'
259
+ )
260
+ deleteBtn.on('click', function () {
261
+ li.remove()
262
+ })
263
+
264
+ li.append(handle, labelInput, cidrInput, deleteBtn)
265
+ container.append(li)
266
+ }
267
+
268
+ // Populate with saved rules
269
+ ;(node.rules || []).forEach(function (rule) {
270
+ addRuleRow(rule)
271
+ })
272
+
273
+ // Add Rule button
274
+ $('#node-input-add-rule').on('click', function () {
275
+ addRuleRow({ cidr: '', label: '' })
276
+ })
277
+ },
278
+
279
+ oneditsave: function () {
280
+ const rules = []
281
+ $('#node-input-rule-container').children('li').each(function () {
282
+ const cidr = $(this).find('.rule-cidr').val().trim()
283
+ const label = $(this).find('.rule-label').val().trim()
284
+ rules.push({ cidr: cidr, label: label })
285
+ })
286
+ this.rules = rules
287
+ this.outputs = rules.length
288
+ },
289
+
290
+ oneditcancel: function () {
291
+ // No cleanup needed.
292
+ }
293
+ })
294
+ })()
295
+ </script>
296
+
@@ -0,0 +1,120 @@
1
+ const { respondWithPayload } = require('./modbus-response-context')
2
+
3
+ module.exports = function (RED) {
4
+ 'use strict'
5
+
6
+ /**
7
+ * Convert a dotted-decimal IPv4 string to a 32-bit unsigned integer.
8
+ * Returns NaN if the string is not a valid IPv4 address.
9
+ */
10
+ function ipToUint32 (ip) {
11
+ const parts = ip.split('.')
12
+ if (parts.length !== 4) return NaN
13
+ let num = 0
14
+ for (let i = 0; i < 4; i++) {
15
+ const octet = parseInt(parts[i], 10)
16
+ if (!Number.isFinite(octet) || octet < 0 || octet > 255 || String(octet) !== parts[i]) return NaN
17
+ num = ((num << 8) | octet) >>> 0
18
+ }
19
+ return num
20
+ }
21
+
22
+ /**
23
+ * Return true if the given IPv4 address string falls within the given CIDR block.
24
+ * Both ip and cidr must be valid IPv4 / IPv4-CIDR strings.
25
+ */
26
+ function matchesCidr (ip, cidr) {
27
+ if (typeof ip !== 'string' || typeof cidr !== 'string') return false
28
+ const slashIdx = cidr.indexOf('/')
29
+ if (slashIdx === -1) return false
30
+ const networkStr = cidr.slice(0, slashIdx)
31
+ const prefixStr = cidr.slice(slashIdx + 1)
32
+ const prefix = parseInt(prefixStr, 10)
33
+ if (!Number.isFinite(prefix) || prefix < 0 || prefix > 32 || String(prefix) !== prefixStr) return false
34
+ // A /0 mask covers every address.
35
+ const mask = prefix === 0 ? 0 : (0xFFFFFFFF << (32 - prefix)) >>> 0
36
+ const networkInt = ipToUint32(networkStr)
37
+ const ipInt = ipToUint32(ip)
38
+ if (isNaN(networkInt) || isNaN(ipInt)) return false
39
+ return (ipInt & mask) === (networkInt & mask)
40
+ }
41
+
42
+ /**
43
+ * Normalise an IP address string.
44
+ * Node.js TCP sockets can return IPv6-mapped IPv4 addresses such as
45
+ * "::ffff:192.168.1.1". Strip the prefix so CIDR matching works correctly.
46
+ */
47
+ function normalizeIp (ip) {
48
+ if (typeof ip !== 'string') return ip
49
+ if (ip.startsWith('::ffff:')) return ip.slice(7)
50
+ // Handle full-form IPv6-mapped notation, e.g. ::ffff:c0a8:0101
51
+ return ip
52
+ }
53
+
54
+ function ModbusSourceRouterNode (config) {
55
+ RED.nodes.createNode(this, config)
56
+ const node = this
57
+
58
+ node.rules = Array.isArray(config.rules) ? config.rules : []
59
+ node.noMatchException = Number(config.noMatchException) || 1
60
+
61
+ if (node.rules.length === 0) {
62
+ node.status({ fill: 'yellow', shape: 'ring', text: 'no rules configured' })
63
+ } else {
64
+ node.status({ fill: 'grey', shape: 'ring', text: 'ready' })
65
+ }
66
+
67
+ node.on('input', function (msg, send, done) {
68
+ // Extract the source IP address from the message.
69
+ const rawAddress =
70
+ msg.modbus &&
71
+ msg.modbus.connection &&
72
+ msg.modbus.connection.source
73
+ ? msg.modbus.connection.source.address
74
+ : null
75
+
76
+ if (!rawAddress) {
77
+ node.status({ fill: 'yellow', shape: 'ring', text: 'no source address' })
78
+ done(new Error('modbus-source-router: msg.modbus.connection.source.address is missing'))
79
+ return
80
+ }
81
+
82
+ const sourceAddress = normalizeIp(rawAddress)
83
+
84
+ // Evaluate rules from top to bottom; the first matching rule wins.
85
+ for (let i = 0; i < node.rules.length; i++) {
86
+ const rule = node.rules[i]
87
+ if (!rule || !rule.cidr) continue
88
+
89
+ if (matchesCidr(sourceAddress, rule.cidr)) {
90
+ const label = rule.label || rule.cidr
91
+ node.status({ fill: 'green', shape: 'dot', text: `rule ${i + 1}: ${label}` })
92
+
93
+ // Build an output array with the matching slot filled and all others null.
94
+ const outputs = new Array(node.rules.length).fill(null)
95
+ outputs[i] = msg
96
+ send(outputs)
97
+ done()
98
+ return
99
+ }
100
+ }
101
+
102
+ // No rule matched — send a Modbus exception response and emit nothing.
103
+ node.status({ fill: 'yellow', shape: 'ring', text: `no match → exception ${node.noMatchException}` })
104
+
105
+ const responseResult = respondWithPayload(RED, node, msg, { exception: node.noMatchException })
106
+ if (!responseResult.context) {
107
+ node.status({ fill: 'red', shape: 'ring', text: 'missing context' })
108
+ }
109
+
110
+ done()
111
+ })
112
+
113
+ node.on('close', function () {
114
+ // Pending requests are owned and cleaned up by the config node.
115
+ })
116
+ }
117
+
118
+ RED.nodes.registerType('modbus-source-router', ModbusSourceRouterNode)
119
+ }
120
+