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.
- package/CHANGELOG.md +41 -0
- package/LICENSE +19 -0
- package/README.md +180 -0
- package/package.json +75 -0
- package/src/modbus-dynamic-proxy.html +108 -0
- package/src/modbus-dynamic-proxy.js +400 -0
- package/src/modbus-dynamic-server-config.html +85 -0
- package/src/modbus-dynamic-server-config.js +391 -0
- package/src/modbus-dynamic-server-response.html +88 -0
- package/src/modbus-dynamic-server-response.js +160 -0
- package/src/modbus-dynamic-server.html +90 -0
- package/src/modbus-dynamic-server.js +45 -0
- package/src/modbus-fc-filter.html +205 -0
- package/src/modbus-fc-filter.js +85 -0
- package/src/modbus-proxy-target.html +175 -0
- package/src/modbus-proxy-target.js +302 -0
- package/src/modbus-registers-config.html +109 -0
- package/src/modbus-registers-config.js +216 -0
- package/src/modbus-registers-read.html +133 -0
- package/src/modbus-registers-read.js +204 -0
- package/src/modbus-registers-respond.html +116 -0
- package/src/modbus-registers-respond.js +286 -0
- package/src/modbus-registers-write.html +157 -0
- package/src/modbus-registers-write.js +156 -0
- package/src/modbus-response-context.js +52 -0
- package/src/modbus-server-exception.html +82 -0
- package/src/modbus-server-exception.js +44 -0
- package/src/modbus-source-router.html +296 -0
- package/src/modbus-source-router.js +120 -0
|
@@ -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> </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
|
+
|