@yousolution/node-red-contrib-you-tunnel-websocket 1.1.1 → 1.2.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/AGENTS.md +45 -0
- package/CHANGELOG.md +109 -0
- package/README.md +83 -4
- package/STABILITY_CONTRACT.md +299 -0
- package/nodes/wsTunnel.html +126 -4
- package/nodes/wsTunnel.js +143 -68
- package/package.json +4 -12
- package/test/ki-001-host-path-passthrough.test.js +187 -0
- package/test/ki-002-tunnel-id-header-default.test.js +60 -0
- package/test/ki-003-debug-log-level.test.js +180 -0
- package/test/ki-004-console-error-usage.test.js +144 -0
- package/test/m-3-concurrent-deployment.test.js +227 -0
- package/test/output-events.test.js +241 -0
- package/test/rwt-001-port-exclusivity.test.js +171 -0
- package/test/rwt-001-port-release.test.js +58 -0
- package/test/rwt-002-shutdown-completion.test.js +226 -0
- package/test/rwt-003-runtime-editor-compatibility.test.js +113 -0
- package/test/rwt-004-persisted-config-compatibility.test.js +140 -0
- package/test/start-failure-path.test.js +212 -0
- package/test/wstunnel-command.test.js +203 -0
- package/test/wstunnel-metrics-input.test.js +234 -0
package/nodes/wsTunnel.html
CHANGED
|
@@ -74,7 +74,7 @@
|
|
|
74
74
|
host: { value: '' },
|
|
75
75
|
port: { value: '' },
|
|
76
76
|
path: { value: '' },
|
|
77
|
-
tunnelIdHeaderName: { value: '' },
|
|
77
|
+
tunnelIdHeaderName: { value: 'x-tunnel-id' },
|
|
78
78
|
logLevel: { value: 'info' },
|
|
79
79
|
},
|
|
80
80
|
label: function () {
|
|
@@ -261,12 +261,57 @@
|
|
|
261
261
|
The log level configured in the <code>wstunnel</code> config node is applied when the server starts, controlling the verbosity of logging output from the
|
|
262
262
|
tunnel library.
|
|
263
263
|
</p>
|
|
264
|
+
<p>
|
|
265
|
+
Send any message to the input to request a server metrics snapshot on the output. The snapshot includes aggregate counters and per-tunnel detail from the
|
|
266
|
+
tunnel library.
|
|
267
|
+
</p>
|
|
268
|
+
|
|
269
|
+
<h3>Output</h3>
|
|
270
|
+
<p>The single output emits messages when tunnels connect, disconnect, or when metrics are requested.</p>
|
|
271
|
+
<dl class="message-properties">
|
|
272
|
+
<dt>event <span class="property-type">string</span></dt>
|
|
273
|
+
<dd><code>"connect"</code> when a tunnel is registered, <code>"disconnect"</code> when it closes, <code>"metrics"</code> when a metrics snapshot is returned.</dd>
|
|
274
|
+
|
|
275
|
+
<dt>tunnelId <span class="property-type">string</span></dt>
|
|
276
|
+
<dd>The unique tunnel identifier (UUID). Present on connect and disconnect events.</dd>
|
|
277
|
+
|
|
278
|
+
<dt>remoteAddress <span class="property-type">string</span></dt>
|
|
279
|
+
<dd>The client IP address from the WebSocket upgrade request.</dd>
|
|
280
|
+
|
|
281
|
+
<dt>connectedAt <span class="property-type">number</span></dt>
|
|
282
|
+
<dd><code>Date.now()</code> when the tunnel was registered. Present on connect events.</dd>
|
|
283
|
+
|
|
284
|
+
<dt>serverPort <span class="property-type">number</span></dt>
|
|
285
|
+
<dd>The port the WebSocket server is listening on.</dd>
|
|
286
|
+
|
|
287
|
+
<dt>payload <span class="property-type">object</span></dt>
|
|
288
|
+
<dd>On metrics events, contains the full snapshot object with aggregate and per-tunnel metrics.</dd>
|
|
289
|
+
</dl>
|
|
290
|
+
|
|
291
|
+
<h3>Example</h3>
|
|
292
|
+
<p>Connect the output to a <code>switch</code> node to route events separately:</p>
|
|
293
|
+
<pre>[wstunnel server] → [switch: event == "connect"] → [notification flow]
|
|
294
|
+
→ [switch: event == "disconnect"] → [cleanup flow]
|
|
295
|
+
→ [switch: event == "metrics"] → [dashboard flow]</pre>
|
|
296
|
+
|
|
297
|
+
<h3>Metrics</h3>
|
|
298
|
+
<p>Send any message to the input to request a metrics snapshot. The output emits a message with <code>event: "metrics"</code> and <code>payload</code> containing:</p>
|
|
299
|
+
<ul>
|
|
300
|
+
<li><code>active_tunnels</code> — number of currently active tunnels</li>
|
|
301
|
+
<li><code>active_streams</code> — number of active TCP streams</li>
|
|
302
|
+
<li><code>bytes_in_total / bytes_out_total</code> — aggregate byte counters</li>
|
|
303
|
+
<li><code>event_loop_lag_ms</code> — <code>{ p50, p99 }</code> event loop lag</li>
|
|
304
|
+
<li><code>tunnels_detail</code> — per-tunnel: <code>remoteAddress</code>, <code>streamCount</code>, <code>bytesIn</code>, <code>bytesOut</code>, <code>agentVersion</code></li>
|
|
305
|
+
</ul>
|
|
264
306
|
|
|
265
307
|
<h3>Runtime Behavior</h3>
|
|
266
308
|
<ul>
|
|
267
309
|
<li>Status will show <code>listening on port [port]</code> when active.</li>
|
|
268
310
|
<li>Each client connection updates the status with the current number of connected clients.</li>
|
|
269
311
|
<li>All client connections are properly closed when the node is stopped or removed.</li>
|
|
312
|
+
<li>The output emits a message for each tunnel that successfully registers (after CONFIG handshake).</li>
|
|
313
|
+
<li>If a WebSocket connects but does not complete the CONFIG handshake, no events are emitted.</li>
|
|
314
|
+
<li>Any message sent to the input triggers a metrics snapshot on the output with <code>event: "metrics"</code>.</li>
|
|
270
315
|
</ul>
|
|
271
316
|
|
|
272
317
|
<h3>Logging</h3>
|
|
@@ -287,7 +332,9 @@
|
|
|
287
332
|
</li>
|
|
288
333
|
<li>Redeploying the flow will gracefully shut down existing WebSocket servers and restart them with updated configuration.</li>
|
|
289
334
|
<li>Log level changes require redeployment to take effect.</li>
|
|
290
|
-
<li>
|
|
335
|
+
<li>The output is optional — if not wired, the node behaves exactly as before (status-only monitoring).</li>
|
|
336
|
+
<li>The input accepts any message to trigger a metrics snapshot. Connect an <code>inject</code> node for periodic metrics.</li>
|
|
337
|
+
<li>Events are only emitted for tunnels that complete the CONFIG handshake. Raw WebSocket connections that are rejected or close before registration do not emit events.</li>
|
|
291
338
|
</ul>
|
|
292
339
|
</script>
|
|
293
340
|
|
|
@@ -299,8 +346,8 @@
|
|
|
299
346
|
wstunnel: { type: 'wstunnel', required: true },
|
|
300
347
|
name: { value: '' },
|
|
301
348
|
},
|
|
302
|
-
inputs:
|
|
303
|
-
outputs:
|
|
349
|
+
inputs: 1,
|
|
350
|
+
outputs: 1,
|
|
304
351
|
icon: 'font-awesome/fa-lock',
|
|
305
352
|
label: function () {
|
|
306
353
|
return this.name || 'tunnel Websocket server';
|
|
@@ -310,3 +357,78 @@
|
|
|
310
357
|
},
|
|
311
358
|
});
|
|
312
359
|
</script>
|
|
360
|
+
|
|
361
|
+
<script type="text/x-red" data-template-name="wstunnel command">
|
|
362
|
+
<div class="form-row">
|
|
363
|
+
<label for="node-input-wstunnel"><i class="fa fa-gears"></i> Tunnel Config</label>
|
|
364
|
+
<input type="text" id="node-input-wstunnel">
|
|
365
|
+
</div>
|
|
366
|
+
<div class="form-row">
|
|
367
|
+
<label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
|
|
368
|
+
<input type="text" id="node-input-name" placeholder="Name">
|
|
369
|
+
</div>
|
|
370
|
+
</script>
|
|
371
|
+
|
|
372
|
+
<script type="text/html" data-help-name="wstunnel command">
|
|
373
|
+
<p>Sends commands to connected tunnel clients via WebSocket.</p>
|
|
374
|
+
|
|
375
|
+
<h3>Inputs</h3>
|
|
376
|
+
<dl class="message-properties">
|
|
377
|
+
<dt>payload <span class="property-type">object</span></dt>
|
|
378
|
+
<dd>The command payload. May contain <code>tunnelId</code>, <code>command</code>, and <code>args</code>.</dd>
|
|
379
|
+
|
|
380
|
+
<dt>tunnelId <span class="property-type">string</span></dt>
|
|
381
|
+
<dd>Alternative to <code>payload.tunnelId</code>. The UUID of the target tunnel.</dd>
|
|
382
|
+
|
|
383
|
+
<dt>command <span class="property-type">string</span></dt>
|
|
384
|
+
<dd>Alternative to <code>payload.command</code>. The command name to send (e.g. <code>"kill"</code>).</dd>
|
|
385
|
+
|
|
386
|
+
<dt>args <span class="property-type">object</span></dt>
|
|
387
|
+
<dd>Alternative to <code>payload.args</code>. Optional arguments for the command.</dd>
|
|
388
|
+
</dl>
|
|
389
|
+
|
|
390
|
+
<h3>Outputs</h3>
|
|
391
|
+
<dl class="message-properties">
|
|
392
|
+
<dt class="optional">output 0 — success</dt>
|
|
393
|
+
<dd>Sent when the command was delivered. <code>msg.payload</code> is <code>true</code>.</dd>
|
|
394
|
+
|
|
395
|
+
<dt class="optional">output 1 — failure</dt>
|
|
396
|
+
<dd>Sent when the tunnel was not found or not connected. <code>msg.payload</code> is <code>false</code>.</dd>
|
|
397
|
+
</dl>
|
|
398
|
+
|
|
399
|
+
<h3>Details</h3>
|
|
400
|
+
<p>
|
|
401
|
+
The node sends a command to a connected tunnel client. The target tunnel is identified by its <code>tunnelId</code>.
|
|
402
|
+
If the tunnel exists and its WebSocket is open, the command is delivered and output 0 fires.
|
|
403
|
+
If the tunnel does not exist or is disconnected, output 1 fires with <code>msg.error</code>.
|
|
404
|
+
</p>
|
|
405
|
+
<p>Missing <code>tunnelId</code> or <code>command</code> sends the message to output 1 with an error.</p>
|
|
406
|
+
|
|
407
|
+
<h3>Example</h3>
|
|
408
|
+
<pre>
|
|
409
|
+
[inject: {tunnelId: "uuid", command: "kill"}]
|
|
410
|
+
→ [wstunnel command]
|
|
411
|
+
→ output 0 → [debug: success]
|
|
412
|
+
→ output 1 → [debug: failure]
|
|
413
|
+
</pre>
|
|
414
|
+
</script>
|
|
415
|
+
|
|
416
|
+
<script type="text/javascript">
|
|
417
|
+
RED.nodes.registerType('wstunnel command', {
|
|
418
|
+
category: 'network',
|
|
419
|
+
color: '#a7c957',
|
|
420
|
+
defaults: {
|
|
421
|
+
wstunnel: { type: 'wstunnel', required: true },
|
|
422
|
+
name: { value: '' },
|
|
423
|
+
},
|
|
424
|
+
inputs: 1,
|
|
425
|
+
outputs: 2,
|
|
426
|
+
icon: 'font-awesome/fa-terminal',
|
|
427
|
+
label: function () {
|
|
428
|
+
return this.name || 'tunnel command';
|
|
429
|
+
},
|
|
430
|
+
labelStyle: function () {
|
|
431
|
+
return this.name ? 'node_label_italic' : '';
|
|
432
|
+
},
|
|
433
|
+
});
|
|
434
|
+
</script>
|
package/nodes/wsTunnel.js
CHANGED
|
@@ -13,9 +13,8 @@
|
|
|
13
13
|
* See the License for the specific language governing permissions and
|
|
14
14
|
* limitations under the License.
|
|
15
15
|
**/
|
|
16
|
-
|
|
17
|
-
const {
|
|
18
|
-
const { setLogLevel } = require('@remotelinker/reverse-ws-tunnel/utils');
|
|
16
|
+
const { startWebSocketServer, stopWebSocketServer, sendCommand } = require('@remotelinker/reverse-ws-tunnel/server');
|
|
17
|
+
const { logger, setLogContext, setLogLevel, getMetrics } = require('@remotelinker/reverse-ws-tunnel/utils');
|
|
19
18
|
|
|
20
19
|
const instances = {};
|
|
21
20
|
|
|
@@ -45,112 +44,188 @@ module.exports = function (RED) {
|
|
|
45
44
|
|
|
46
45
|
// Set the log level dynamically based on configuration
|
|
47
46
|
const logLevel = node.tunnelConfig.options.logLevel || 'info';
|
|
47
|
+
setLogContext('TunnelNode-' + node.tunnelConfig.options.name);
|
|
48
48
|
setLogLevel(logLevel);
|
|
49
49
|
|
|
50
50
|
node.status({ fill: 'red', shape: 'ring', text: 'disconnected' });
|
|
51
|
-
|
|
52
51
|
const port = node.tunnelConfig.options.port;
|
|
53
|
-
const host = node.tunnelConfig.options.host
|
|
54
|
-
const path = node.tunnelConfig.options.path
|
|
52
|
+
const host = node.tunnelConfig.options.host || undefined;
|
|
53
|
+
const path = node.tunnelConfig.options.path || undefined;
|
|
55
54
|
const tunnelIdHeaderName = node.tunnelConfig.options.tunnelIdHeaderName;
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
55
|
+
instances[id] = instances[id] || {};
|
|
56
|
+
const pendingConnections = new Map();
|
|
57
|
+
const confirmedConnections = new Map();
|
|
58
|
+
// Cleanup any existing server on this port before starting
|
|
59
|
+
// This handles cases where previous deployment didn't cleanup properly
|
|
60
|
+
stopWebSocketServer(port)
|
|
61
|
+
.then(() => {
|
|
62
|
+
logger.info(`Cleaned up any existing server on port ${port} before starting`);
|
|
63
|
+
return startWebSocketServer({
|
|
64
|
+
port,
|
|
65
|
+
host,
|
|
66
|
+
path,
|
|
67
|
+
tunnelIdHeaderName,
|
|
68
|
+
});
|
|
69
|
+
})
|
|
70
|
+
.then((state) => {
|
|
71
|
+
instances[id].state = state;
|
|
72
|
+
logger.info(`Started WebSocketServer for node ${id} on port ${port}`);
|
|
73
|
+
if (instances[id].state[port].webSocketServer) {
|
|
74
|
+
onListening();
|
|
75
|
+
}
|
|
76
|
+
})
|
|
77
|
+
.catch((err) => {
|
|
78
|
+
logger.error(`Error starting WebSocketServer on port ${port}:`, err);
|
|
79
|
+
node.status({
|
|
80
|
+
fill: 'red',
|
|
81
|
+
shape: 'dot',
|
|
82
|
+
text: `error: ${err.message}`,
|
|
83
|
+
});
|
|
65
84
|
});
|
|
66
|
-
return;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
instances[id].state = startWebSocketServer({
|
|
70
|
-
port,
|
|
71
|
-
host,
|
|
72
|
-
path,
|
|
73
|
-
tunnelIdHeaderName,
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
console.log(`Started WebSocketServer for node ${id} on port ${port}`);
|
|
77
|
-
|
|
78
|
-
if (instances[id].state[port].webSocketServer) {
|
|
79
|
-
onListening();
|
|
80
|
-
}
|
|
81
|
-
|
|
82
85
|
function onListening() {
|
|
83
86
|
const server = instances[id].state[port].webSocketServer;
|
|
84
|
-
|
|
85
87
|
server.on('listening', () => {
|
|
86
|
-
|
|
88
|
+
logger.info('Listening ' + id);
|
|
87
89
|
node.status({
|
|
88
90
|
fill: 'green',
|
|
89
91
|
shape: 'dot',
|
|
90
92
|
text: `listening on port ${port}`,
|
|
91
93
|
});
|
|
92
94
|
});
|
|
93
|
-
|
|
94
95
|
server.on('connection', (ws) => {
|
|
95
96
|
const clientAddress = ws._socket.remoteAddress;
|
|
96
97
|
const clientPort = ws._socket.remotePort;
|
|
97
98
|
const totalClients = server.clients.size;
|
|
98
|
-
|
|
99
99
|
node.status({
|
|
100
100
|
fill: 'green',
|
|
101
101
|
shape: 'dot',
|
|
102
102
|
text: `clients ${totalClients}`,
|
|
103
103
|
});
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
104
|
+
logger.info(`WebSocket connection from ${clientAddress}:${clientPort}`);
|
|
105
|
+
logger.info(`Total connected clients: ${totalClients}`);
|
|
106
|
+
|
|
107
|
+
const connectedAt = Date.now();
|
|
108
|
+
pendingConnections.set(ws, { remoteAddress: clientAddress, connectedAt });
|
|
109
|
+
|
|
110
|
+
const onFirstMessage = () => {
|
|
111
|
+
ws.removeListener('message', onFirstMessage);
|
|
112
|
+
setImmediate(() => {
|
|
113
|
+
const tunnels =
|
|
114
|
+
instances[id]?.state?.[port]?.websocketTunnels || {};
|
|
115
|
+
for (const [tunnelId, tunnel] of Object.entries(tunnels)) {
|
|
116
|
+
if (tunnel.ws === ws) {
|
|
117
|
+
const pending = pendingConnections.get(ws);
|
|
118
|
+
if (pending) {
|
|
119
|
+
pendingConnections.delete(ws);
|
|
120
|
+
confirmedConnections.set(ws, {
|
|
121
|
+
tunnelId,
|
|
122
|
+
remoteAddress: pending.remoteAddress,
|
|
123
|
+
connectedAt: pending.connectedAt,
|
|
124
|
+
});
|
|
125
|
+
node.send({
|
|
126
|
+
event: 'connect',
|
|
127
|
+
tunnelId,
|
|
128
|
+
remoteAddress: pending.remoteAddress,
|
|
129
|
+
connectedAt: pending.connectedAt,
|
|
130
|
+
serverPort: port,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
};
|
|
138
|
+
ws.on('message', onFirstMessage);
|
|
107
139
|
|
|
108
140
|
ws.on('close', () => {
|
|
141
|
+
const confirmed = confirmedConnections.get(ws);
|
|
142
|
+
confirmedConnections.delete(ws);
|
|
143
|
+
pendingConnections.delete(ws);
|
|
144
|
+
|
|
109
145
|
const remainingClients = server.clients.size;
|
|
110
146
|
node.status({
|
|
111
147
|
fill: 'green',
|
|
112
148
|
shape: 'dot',
|
|
113
149
|
text: `clients ${remainingClients}`,
|
|
114
150
|
});
|
|
115
|
-
|
|
116
|
-
|
|
151
|
+
logger.info(`Client disconnected: ${clientAddress}:${clientPort}`);
|
|
152
|
+
logger.info(`Remaining clients: ${remainingClients}`);
|
|
153
|
+
|
|
154
|
+
if (confirmed) {
|
|
155
|
+
node.send({
|
|
156
|
+
event: 'disconnect',
|
|
157
|
+
tunnelId: confirmed.tunnelId,
|
|
158
|
+
remoteAddress: confirmed.remoteAddress,
|
|
159
|
+
serverPort: port,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
117
162
|
});
|
|
118
163
|
});
|
|
119
164
|
}
|
|
120
165
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
client.removeAllListeners();
|
|
129
|
-
client.close();
|
|
130
|
-
} catch (clientErr) {
|
|
131
|
-
console.error(`Error terminating client on port ${port}:`, clientErr);
|
|
132
|
-
}
|
|
133
|
-
}
|
|
166
|
+
node.on('input', function (msg) {
|
|
167
|
+
const snapshot = getMetrics().snapshot();
|
|
168
|
+
msg.payload = snapshot;
|
|
169
|
+
msg.event = 'metrics';
|
|
170
|
+
msg.serverPort = port;
|
|
171
|
+
node.send(msg);
|
|
172
|
+
});
|
|
134
173
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
console.error(`Error closing WebSocketServer on port ${port}:`, err);
|
|
140
|
-
}
|
|
141
|
-
}
|
|
174
|
+
this.on('close', function (removed, done) {
|
|
175
|
+
logger.debug(`Close called for node ${id}, removed=${removed}`);
|
|
176
|
+
pendingConnections.clear();
|
|
177
|
+
confirmedConnections.clear();
|
|
142
178
|
|
|
143
|
-
|
|
179
|
+
if (instances[id] && instances[id].state && instances[id].state[port]) {
|
|
180
|
+
stopWebSocketServer(port)
|
|
181
|
+
.then(() => {
|
|
182
|
+
logger.info(`Cleaned up WebSocketServer on port ${port} for node ${id}`);
|
|
183
|
+
delete instances[id];
|
|
184
|
+
done();
|
|
185
|
+
})
|
|
186
|
+
.catch((err) => {
|
|
187
|
+
logger.error(`Error stopping server on port ${port}:`, err);
|
|
188
|
+
delete instances[id];
|
|
189
|
+
done();
|
|
190
|
+
});
|
|
191
|
+
} else {
|
|
192
|
+
logger.info(`No server state found for node ${id} on port ${port}`);
|
|
193
|
+
done();
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
RED.nodes.registerType('wstunnel server', WSTunnelServer);
|
|
144
198
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
199
|
+
function WSTunnelCommand(n) {
|
|
200
|
+
RED.nodes.createNode(this, n);
|
|
201
|
+
const node = this;
|
|
202
|
+
node.tunnelConfig = RED.nodes.getNode(n.wstunnel);
|
|
203
|
+
|
|
204
|
+
node.on('input', function (msg) {
|
|
205
|
+
const port = node.tunnelConfig.options.port;
|
|
206
|
+
const tunnelId = msg.tunnelId || (msg.payload && msg.payload.tunnelId);
|
|
207
|
+
const command = msg.command || (msg.payload && msg.payload.command);
|
|
208
|
+
const args = msg.args || (msg.payload && msg.payload.args) || {};
|
|
209
|
+
|
|
210
|
+
if (!tunnelId || !command) {
|
|
211
|
+
node.error('Missing tunnelId or command');
|
|
212
|
+
msg.payload = false;
|
|
213
|
+
msg.sent = false;
|
|
214
|
+
msg.error = 'Missing tunnelId or command';
|
|
215
|
+
node.send([null, msg]);
|
|
216
|
+
return;
|
|
149
217
|
}
|
|
150
218
|
|
|
151
|
-
|
|
219
|
+
const sent = sendCommand(port, tunnelId, command, args);
|
|
220
|
+
msg.payload = sent;
|
|
221
|
+
msg.sent = sent;
|
|
222
|
+
msg.tunnelId = tunnelId;
|
|
223
|
+
msg.command = command;
|
|
224
|
+
if (!sent) {
|
|
225
|
+
msg.error = 'Tunnel not found or not connected';
|
|
226
|
+
}
|
|
227
|
+
node.send([sent ? msg : null, sent ? null : msg]);
|
|
152
228
|
});
|
|
153
229
|
}
|
|
154
|
-
|
|
155
|
-
RED.nodes.registerType('wstunnel server', WSTunnelServer);
|
|
230
|
+
RED.nodes.registerType('wstunnel command', WSTunnelCommand);
|
|
156
231
|
};
|
package/package.json
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yousolution/node-red-contrib-you-tunnel-websocket",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "Module to create websockeet tunnel for NODE-RED",
|
|
5
|
-
"license": "
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
6
|
"scripts": {
|
|
7
7
|
"update": "PACKAGE_NAME=$(npm pack) && mv $PACKAGE_NAME ./data && cd data && rm -rf node_modules && npm i $PACKAGE_NAME && docker-compose restart",
|
|
8
|
-
"test": "
|
|
8
|
+
"test": "jest --verbose",
|
|
9
9
|
"coverage": "nyc npm run test"
|
|
10
10
|
},
|
|
11
11
|
"keywords": [
|
|
@@ -23,15 +23,7 @@
|
|
|
23
23
|
}
|
|
24
24
|
},
|
|
25
25
|
"dependencies": {
|
|
26
|
-
"@remotelinker/reverse-ws-tunnel": "^1.0
|
|
27
|
-
"cookie": "^1.0.2",
|
|
28
|
-
"cross-conf-env": "^1.3.0",
|
|
29
|
-
"dotenv": "^17.2.1",
|
|
30
|
-
"express": "^4.21.2",
|
|
31
|
-
"http-parser-js": "^0.5.10",
|
|
32
|
-
"http-proxy": "^1.18.1",
|
|
33
|
-
"uuid": "^11.1.0",
|
|
34
|
-
"ws": "^8.18.0"
|
|
26
|
+
"@remotelinker/reverse-ws-tunnel": "^1.2.0"
|
|
35
27
|
},
|
|
36
28
|
"repository": {
|
|
37
29
|
"type": "git",
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* KI-001 — Inverted host/path Default Logic (Regression Test)
|
|
3
|
+
*
|
|
4
|
+
* This test proves that configured host and path values are discarded
|
|
5
|
+
* by the current ternary logic in WSTunnelServer.
|
|
6
|
+
*
|
|
7
|
+
* Expected behavior after fix:
|
|
8
|
+
* - host: '0.0.0.0' is passed through to startWebSocketServer
|
|
9
|
+
* - path: '/api/tunnel' is passed through to startWebSocketServer
|
|
10
|
+
*
|
|
11
|
+
* Current (defective) behavior:
|
|
12
|
+
* - host: '0.0.0.0' becomes ''
|
|
13
|
+
* - path: '/api/tunnel' becomes ''
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const path = require('path');
|
|
17
|
+
|
|
18
|
+
const WS_TUNNEL_PATH = path.join(__dirname, '..', 'nodes', 'wsTunnel.js');
|
|
19
|
+
|
|
20
|
+
// Mock stopWebSocketServer — resolves synchronously to avoid timing issues
|
|
21
|
+
const mockStopWebSocketServer = jest.fn(() => Promise.resolve());
|
|
22
|
+
|
|
23
|
+
// Mock startWebSocketServer — captures the options passed to it
|
|
24
|
+
const mockStartWebSocketServer = jest.fn((opts) =>
|
|
25
|
+
Promise.resolve({
|
|
26
|
+
[opts.port]: {
|
|
27
|
+
webSocketServer: {
|
|
28
|
+
on: jest.fn(),
|
|
29
|
+
clients: new Set(),
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
})
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
// Mock logger
|
|
36
|
+
const mockLogger = {
|
|
37
|
+
info: jest.fn(),
|
|
38
|
+
warn: jest.fn(),
|
|
39
|
+
error: jest.fn(),
|
|
40
|
+
debug: jest.fn(),
|
|
41
|
+
trace: jest.fn(),
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
// Mock setLogContext and setLogLevel
|
|
45
|
+
const mockSetLogContext = jest.fn();
|
|
46
|
+
const mockSetLogLevel = jest.fn();
|
|
47
|
+
|
|
48
|
+
// Creates a mock RED object with configurable getNode return value
|
|
49
|
+
function createMockRED(configOverrides = {}) {
|
|
50
|
+
const registeredTypes = {};
|
|
51
|
+
const defaultConfig = {
|
|
52
|
+
name: 'test-tunnel',
|
|
53
|
+
host: 'localhost',
|
|
54
|
+
port: 1880,
|
|
55
|
+
path: '',
|
|
56
|
+
tunnelIdHeaderName: 'x-tunnel-id',
|
|
57
|
+
logLevel: 'info',
|
|
58
|
+
};
|
|
59
|
+
return {
|
|
60
|
+
nodes: {
|
|
61
|
+
createNode: jest.fn((node, config) => {
|
|
62
|
+
node.status = jest.fn();
|
|
63
|
+
node.on = jest.fn((event, handler) => {
|
|
64
|
+
node._handlers = node._handlers || {};
|
|
65
|
+
node._handlers[event] = handler;
|
|
66
|
+
});
|
|
67
|
+
}),
|
|
68
|
+
registerType: jest.fn((type, constructor) => {
|
|
69
|
+
registeredTypes[type] = constructor;
|
|
70
|
+
}),
|
|
71
|
+
getNode: jest.fn(() => ({
|
|
72
|
+
options: { ...defaultConfig, ...configOverrides },
|
|
73
|
+
})),
|
|
74
|
+
},
|
|
75
|
+
_registeredTypes: registeredTypes,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Flush the promise chain: stopWebSocketServer().then(startWebSocketServer(...))
|
|
80
|
+
async function flushPromises() {
|
|
81
|
+
// Advance past setImmediate and all microtasks
|
|
82
|
+
await new Promise((resolve) => {
|
|
83
|
+
setImmediate(resolve);
|
|
84
|
+
});
|
|
85
|
+
// One more tick to handle the chained .then()
|
|
86
|
+
await new Promise((resolve) => {
|
|
87
|
+
setImmediate(resolve);
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
describe('KI-001 — host/path value passthrough (regression)', () => {
|
|
92
|
+
beforeAll(() => {
|
|
93
|
+
const serverModule = require('@remotelinker/reverse-ws-tunnel/server');
|
|
94
|
+
serverModule.stopWebSocketServer = mockStopWebSocketServer;
|
|
95
|
+
serverModule.startWebSocketServer = mockStartWebSocketServer;
|
|
96
|
+
|
|
97
|
+
const utilsModule = require('@remotelinker/reverse-ws-tunnel/utils');
|
|
98
|
+
utilsModule.logger = mockLogger;
|
|
99
|
+
utilsModule.setLogContext = mockSetLogContext;
|
|
100
|
+
utilsModule.setLogLevel = mockSetLogLevel;
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
beforeEach(() => {
|
|
104
|
+
mockStopWebSocketServer.mockClear();
|
|
105
|
+
mockStartWebSocketServer.mockClear();
|
|
106
|
+
mockLogger.info.mockClear();
|
|
107
|
+
mockSetLogContext.mockClear();
|
|
108
|
+
mockSetLogLevel.mockClear();
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test('configured host "0.0.0.0" is passed through to startWebSocketServer', async () => {
|
|
112
|
+
const RED = createMockRED({ host: '0.0.0.0' });
|
|
113
|
+
const tunnelModule = require(WS_TUNNEL_PATH);
|
|
114
|
+
tunnelModule(RED);
|
|
115
|
+
|
|
116
|
+
const WSTunnelServer = RED._registeredTypes['wstunnel server'];
|
|
117
|
+
const node = {};
|
|
118
|
+
WSTunnelServer.call(node, { wstunnel: 'test-config-id', name: 'test-server' });
|
|
119
|
+
|
|
120
|
+
await flushPromises();
|
|
121
|
+
|
|
122
|
+
expect(mockStartWebSocketServer).toHaveBeenCalledTimes(1);
|
|
123
|
+
const passedOptions = mockStartWebSocketServer.mock.calls[0][0];
|
|
124
|
+
|
|
125
|
+
// ASSERTION: host must be '0.0.0.0', not ''
|
|
126
|
+
// This FAILS with current code because the ternary converts truthy → ''
|
|
127
|
+
expect(passedOptions.host).toBe('0.0.0.0');
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test('configured path "/api/tunnel" is passed through to startWebSocketServer', async () => {
|
|
131
|
+
const RED = createMockRED({ path: '/api/tunnel' });
|
|
132
|
+
const tunnelModule = require(WS_TUNNEL_PATH);
|
|
133
|
+
tunnelModule(RED);
|
|
134
|
+
|
|
135
|
+
const WSTunnelServer = RED._registeredTypes['wstunnel server'];
|
|
136
|
+
const node = {};
|
|
137
|
+
WSTunnelServer.call(node, { wstunnel: 'test-config-id', name: 'test-server' });
|
|
138
|
+
|
|
139
|
+
await flushPromises();
|
|
140
|
+
|
|
141
|
+
expect(mockStartWebSocketServer).toHaveBeenCalledTimes(1);
|
|
142
|
+
const passedOptions = mockStartWebSocketServer.mock.calls[0][0];
|
|
143
|
+
|
|
144
|
+
// ASSERTION: path must be '/api/tunnel', not ''
|
|
145
|
+
// This FAILS with current code because the ternary converts truthy → ''
|
|
146
|
+
expect(passedOptions.path).toBe('/api/tunnel');
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test('empty path becomes undefined (semantically equivalent to unset)', async () => {
|
|
150
|
+
const RED = createMockRED({ path: '' });
|
|
151
|
+
const tunnelModule = require(WS_TUNNEL_PATH);
|
|
152
|
+
tunnelModule(RED);
|
|
153
|
+
|
|
154
|
+
const WSTunnelServer = RED._registeredTypes['wstunnel server'];
|
|
155
|
+
const node = {};
|
|
156
|
+
WSTunnelServer.call(node, { wstunnel: 'test-config-id', name: 'test-server' });
|
|
157
|
+
|
|
158
|
+
await flushPromises();
|
|
159
|
+
|
|
160
|
+
expect(mockStartWebSocketServer).toHaveBeenCalledTimes(1);
|
|
161
|
+
const passedOptions = mockStartWebSocketServer.mock.calls[0][0];
|
|
162
|
+
|
|
163
|
+
// ASSERTION: empty path should become undefined (ws library default = accept all)
|
|
164
|
+
// The ws library treats undefined/null the same as falsy for path filtering
|
|
165
|
+
expect(passedOptions.path).toBeUndefined();
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test('both host and path are passed through simultaneously', async () => {
|
|
169
|
+
const RED = createMockRED({ host: '0.0.0.0', path: '/api/tunnel' });
|
|
170
|
+
const tunnelModule = require(WS_TUNNEL_PATH);
|
|
171
|
+
tunnelModule(RED);
|
|
172
|
+
|
|
173
|
+
const WSTunnelServer = RED._registeredTypes['wstunnel server'];
|
|
174
|
+
const node = {};
|
|
175
|
+
WSTunnelServer.call(node, { wstunnel: 'test-config-id', name: 'test-server' });
|
|
176
|
+
|
|
177
|
+
await flushPromises();
|
|
178
|
+
|
|
179
|
+
expect(mockStartWebSocketServer).toHaveBeenCalledTimes(1);
|
|
180
|
+
const passedOptions = mockStartWebSocketServer.mock.calls[0][0];
|
|
181
|
+
|
|
182
|
+
// ASSERTION: both values must be preserved
|
|
183
|
+
// This FAILS with current code — both become ''
|
|
184
|
+
expect(passedOptions.host).toBe('0.0.0.0');
|
|
185
|
+
expect(passedOptions.path).toBe('/api/tunnel');
|
|
186
|
+
});
|
|
187
|
+
});
|