signalk-edge-link 2.5.1 → 2.6.1
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/README.md +31 -8
- package/lib/connection-config.js +1 -4
- package/lib/instance.js +22 -48
- package/lib/packet.js +20 -7
- package/lib/pipeline-v2-client.js +22 -0
- package/lib/pipeline-v2-server.js +37 -0
- package/lib/pipeline.js +1 -215
- package/lib/routes/config.js +2 -1
- package/lib/routes/connections.js +6 -8
- package/lib/shared/connection-schema.js +9 -8
- package/package.json +1 -1
- package/public/982.fb1b6560eada159d88ee.js +2 -0
- package/public/982.fb1b6560eada159d88ee.js.map +1 -0
- package/public/remoteEntry.js +1 -1
- package/public/remoteEntry.js.map +1 -1
- package/public/982.8b63f75cddc5341d56cb.js +0 -2
- package/public/982.8b63f75cddc5341d56cb.js.map +0 -1
package/README.md
CHANGED
|
@@ -22,6 +22,9 @@ It is designed for links where latency, packet loss, and bandwidth usage matter
|
|
|
22
22
|
- congestion control
|
|
23
23
|
- optional primary/backup bonding
|
|
24
24
|
- monitoring and alerting endpoints
|
|
25
|
+
- values snapshot replay on subscribe, retry, and socket recovery
|
|
26
|
+
- optional server-triggered full-state request on restart (`requestFullStatusOnRestart`)
|
|
27
|
+
- Signal K path metadata transport (units, descriptions, zones)
|
|
25
28
|
- **Multi-connection support** on one Signal K instance
|
|
26
29
|
|
|
27
30
|
## How data flows
|
|
@@ -110,11 +113,11 @@ Check that:
|
|
|
110
113
|
|
|
111
114
|
## Protocol version guidance
|
|
112
115
|
|
|
113
|
-
| Version | Use when | Notes
|
|
114
|
-
| ------- | --------------------------------------------------------- |
|
|
115
|
-
| v1 | stable local links, simplest setup | lower overhead, no ACK/NAK reliability
|
|
116
|
-
| v2 | packet loss, variable latency, WAN links | adds retransmission, congestion control, bonding, richer monitoring
|
|
117
|
-
| v3 | same use cases as v2 when both peers can upgrade together | keeps v2 features and authenticates ACK/NAK/HEARTBEAT/HELLO control packets
|
|
116
|
+
| Version | Use when | Notes |
|
|
117
|
+
| ------- | --------------------------------------------------------- | ----------------------------------------------------------------------------- |
|
|
118
|
+
| v1 | stable local links, simplest setup | lower overhead, no ACK/NAK reliability, no metadata transport |
|
|
119
|
+
| v2 | packet loss, variable latency, WAN links | adds retransmission, congestion control, bonding, metadata, richer monitoring |
|
|
120
|
+
| v3 | same use cases as v2 when both peers can upgrade together | keeps v2 features and authenticates ACK/NAK/HEARTBEAT/HELLO control packets |
|
|
118
121
|
|
|
119
122
|
For unstable links, start with **v3** when both peers support it; fall back to **v2** only when you need compatibility with an already deployed v2 peer.
|
|
120
123
|
|
|
@@ -137,7 +140,7 @@ Most used endpoints:
|
|
|
137
140
|
- `GET /bonding`
|
|
138
141
|
- `POST /bonding`
|
|
139
142
|
|
|
140
|
-
For full endpoint details, use `docs/api-reference.md
|
|
143
|
+
For full endpoint details, use `docs/api-reference.md`
|
|
141
144
|
|
|
142
145
|
## Configuration model (summary)
|
|
143
146
|
|
|
@@ -151,7 +154,8 @@ Configuration is an array of independent connections:
|
|
|
151
154
|
"serverType": "server",
|
|
152
155
|
"udpPort": 4446,
|
|
153
156
|
"secretKey": "<32-byte key>",
|
|
154
|
-
"protocolVersion": 3
|
|
157
|
+
"protocolVersion": 3,
|
|
158
|
+
"requestFullStatusOnRestart": false
|
|
155
159
|
},
|
|
156
160
|
{
|
|
157
161
|
"name": "sat-client",
|
|
@@ -168,6 +172,7 @@ Configuration is an array of independent connections:
|
|
|
168
172
|
- Each connection runs independently.
|
|
169
173
|
- Legacy single-object config is auto-normalized to one connection.
|
|
170
174
|
- Client runtime JSON files (`delta_timer.json`, `subscription.json`, `sentence_filter.json`) are stored per connection and can be edited via API.
|
|
175
|
+
- `requestFullStatusOnRestart` (server mode, v2/v3, default `false`): when enabled, the server sends a `FULL_STATUS_REQUEST` to each client on first contact after a (re)start; the client immediately replays its complete values snapshot so the server rebuilds state without waiting for incremental deltas. Client-side rate-limited to 10 s to prevent replay floods across rapid restarts.
|
|
171
176
|
|
|
172
177
|
For complete setting definitions and ranges, use `docs/configuration-reference.md`.
|
|
173
178
|
|
|
@@ -199,6 +204,24 @@ Common checks:
|
|
|
199
204
|
- Confirm server UDP port is reachable and not already in use.
|
|
200
205
|
- If link quality is poor, switch to `protocolVersion: 3` when both peers can upgrade together, or `2` if you must stay compatible with an existing v2 peer.
|
|
201
206
|
|
|
207
|
+
**`testAddress is only supported on v1 clients` after upgrading to v2/v3**
|
|
208
|
+
|
|
209
|
+
The fields `testAddress`, `testPort`, and `pingIntervalTime` belong to the v1 ping monitor and are not used by v2/v3 clients (which derive RTT from HEARTBEAT exchanges instead). If these fields are present in a connection with `protocolVersion: 2` or `3` the validator will reject the config.
|
|
210
|
+
|
|
211
|
+
Remove them from the affected connection:
|
|
212
|
+
|
|
213
|
+
```json
|
|
214
|
+
{
|
|
215
|
+
"name": "my-client",
|
|
216
|
+
"serverType": "client",
|
|
217
|
+
"protocolVersion": 3,
|
|
218
|
+
"udpAddress": "...",
|
|
219
|
+
"heartbeatInterval": 25000
|
|
220
|
+
}
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
The plugin strips these fields automatically on startup, but if you see the error when saving via the SignalK admin UI you need to remove them from the stored config JSON manually once.
|
|
224
|
+
|
|
202
225
|
For issue-oriented diagnostics, use `docs/troubleshooting.md`.
|
|
203
226
|
|
|
204
227
|
## Developer commands
|
|
@@ -264,7 +287,7 @@ window.__EDGE_LINK_AUTH__ = {
|
|
|
264
287
|
- `docs/README.md` (documentation index)
|
|
265
288
|
- `docs/architecture-overview.md` (system architecture and lifecycle)
|
|
266
289
|
- `docs/configuration-reference.md` (settings and defaults)
|
|
267
|
-
- `docs/api-reference.md
|
|
290
|
+
- `docs/api-reference.md`
|
|
268
291
|
- `docs/protocol-v2.md` (reliable protocol operational overview)
|
|
269
292
|
- `docs/protocol-v3-spec.md` (authenticated control-plane details)
|
|
270
293
|
- `docs/bonding.md` (bonding concepts and API usage)
|
package/lib/connection-config.js
CHANGED
|
@@ -14,7 +14,6 @@ exports.VALID_CONNECTION_KEYS = [
|
|
|
14
14
|
"name",
|
|
15
15
|
"serverType",
|
|
16
16
|
"udpPort",
|
|
17
|
-
"udpMetaPort",
|
|
18
17
|
"secretKey",
|
|
19
18
|
"stretchAsciiKey",
|
|
20
19
|
"useMsgpack",
|
|
@@ -28,6 +27,7 @@ exports.VALID_CONNECTION_KEYS = [
|
|
|
28
27
|
"testAddress",
|
|
29
28
|
"testPort",
|
|
30
29
|
"pingIntervalTime",
|
|
30
|
+
"requestFullStatusOnRestart",
|
|
31
31
|
"reliability",
|
|
32
32
|
"congestionControl",
|
|
33
33
|
"bonding",
|
|
@@ -85,9 +85,6 @@ function validateConnectionConfig(connection, prefix = "") {
|
|
|
85
85
|
if (!isValidPort(conn.udpPort, 1024)) {
|
|
86
86
|
return `${p}udpPort must be an integer between 1024 and 65535`;
|
|
87
87
|
}
|
|
88
|
-
if (conn.udpMetaPort !== undefined && !isValidPort(conn.udpMetaPort, 1024)) {
|
|
89
|
-
return `${p}udpMetaPort must be an integer between 1024 and 65535`;
|
|
90
|
-
}
|
|
91
88
|
try {
|
|
92
89
|
(0, crypto_1.validateSecretKey)(conn.secretKey);
|
|
93
90
|
}
|
package/lib/instance.js
CHANGED
|
@@ -69,7 +69,6 @@ function createInstance(app, options, instanceId, pluginId, onStatusChange) {
|
|
|
69
69
|
isHealthy: false,
|
|
70
70
|
options,
|
|
71
71
|
socketUdp: null,
|
|
72
|
-
metaSocketUdp: null,
|
|
73
72
|
readyToSend: false,
|
|
74
73
|
stopped: false,
|
|
75
74
|
isServerMode: false,
|
|
@@ -111,6 +110,7 @@ function createInstance(app, options, instanceId, pluginId, onStatusChange) {
|
|
|
111
110
|
metaDiffFlushTimer: null,
|
|
112
111
|
metaSnapshotTimers: [],
|
|
113
112
|
lastMetaRequestAt: 0,
|
|
113
|
+
lastFullStatusRequestAt: 0,
|
|
114
114
|
sourceRegistry: (0, source_replication_1.createSourceRegistry)(app)
|
|
115
115
|
};
|
|
116
116
|
const metricsApi = (0, metrics_1.default)();
|
|
@@ -251,16 +251,8 @@ function createInstance(app, options, instanceId, pluginId, onStatusChange) {
|
|
|
251
251
|
if (entries.length === 0) {
|
|
252
252
|
return false;
|
|
253
253
|
}
|
|
254
|
-
const protoVer = options.protocolVersion ?? 2;
|
|
255
254
|
try {
|
|
256
|
-
if (
|
|
257
|
-
if (!options.udpMetaPort || options.udpMetaPort <= 0) {
|
|
258
|
-
app.debug(`[${instanceId}] Meta skipped: v1 pipeline requires 'udpMetaPort' in connection config`);
|
|
259
|
-
return false;
|
|
260
|
-
}
|
|
261
|
-
await getV1Pipeline().packCryptMeta(entries, kind, options.secretKey, options.udpAddress, options.udpMetaPort);
|
|
262
|
-
}
|
|
263
|
-
else if (state.pipeline && typeof state.pipeline.sendMetadata === "function") {
|
|
255
|
+
if (state.pipeline && typeof state.pipeline.sendMetadata === "function") {
|
|
264
256
|
await state.pipeline.sendMetadata(entries, kind, options.secretKey, options.udpAddress, options.udpPort);
|
|
265
257
|
}
|
|
266
258
|
else {
|
|
@@ -384,6 +376,22 @@ function createInstance(app, options, instanceId, pluginId, onStatusChange) {
|
|
|
384
376
|
app.debug(`[${instanceId}] META_REQUEST snapshot failed: ${msg}`);
|
|
385
377
|
});
|
|
386
378
|
}
|
|
379
|
+
/** Minimum gap between server-initiated full-status replays. Prevents a
|
|
380
|
+
* restarting or misconfigured server from flooding the link. */
|
|
381
|
+
const FULL_STATUS_REQUEST_RATE_LIMIT_MS = 10000;
|
|
382
|
+
/** Server asked for a full values snapshot (FULL_STATUS_REQUEST control
|
|
383
|
+
* packet). Replays the entire current Signal K tree to the server.
|
|
384
|
+
* Rate-limited to prevent replay floods across rapid server restarts. */
|
|
385
|
+
function handleFullStatusRequest() {
|
|
386
|
+
const now = Date.now();
|
|
387
|
+
if (now - state.lastFullStatusRequestAt < FULL_STATUS_REQUEST_RATE_LIMIT_MS) {
|
|
388
|
+
app.debug(`[${instanceId}] FULL_STATUS_REQUEST rate-limited, skipping`);
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
state.lastFullStatusRequestAt = now;
|
|
392
|
+
app.debug(`[${instanceId}] FULL_STATUS_REQUEST received — replaying values snapshot`);
|
|
393
|
+
replayValuesSnapshot("full-status-request");
|
|
394
|
+
}
|
|
387
395
|
async function sendSourceSnapshot() {
|
|
388
396
|
if (state.stopped ||
|
|
389
397
|
!state.readyToSend ||
|
|
@@ -857,34 +865,6 @@ function createInstance(app, options, instanceId, pluginId, onStatusChange) {
|
|
|
857
865
|
getV1Pipeline().unpackDecrypt(delta, options.secretKey);
|
|
858
866
|
});
|
|
859
867
|
app.debug(`[${instanceId}] [v1] Server pipeline initialized`);
|
|
860
|
-
// v1 has no packet-type byte, so meta is streamed on a separate UDP
|
|
861
|
-
// port by the client. Bind that port here when the operator has opted
|
|
862
|
-
// in. If `udpMetaPort` is unset we simply don't listen — keeping the
|
|
863
|
-
// receive side idle is the correct default for existing v1 peers that
|
|
864
|
-
// don't know about meta.
|
|
865
|
-
if (typeof options.udpMetaPort === "number" && options.udpMetaPort > 0) {
|
|
866
|
-
if (options.udpMetaPort === options.udpPort) {
|
|
867
|
-
app.error(`[${instanceId}] [v1] udpMetaPort (${options.udpMetaPort}) must differ from udpPort; meta disabled`);
|
|
868
|
-
}
|
|
869
|
-
else {
|
|
870
|
-
const metaSocket = dgram_1.default.createSocket({ type: "udp4", reuseAddr: true });
|
|
871
|
-
state.metaSocketUdp = metaSocket;
|
|
872
|
-
metaSocket.on("message", (msg) => {
|
|
873
|
-
getV1Pipeline()
|
|
874
|
-
.unpackDecryptMeta(msg, options.secretKey)
|
|
875
|
-
.catch((err) => {
|
|
876
|
-
const m = err instanceof Error ? err.message : String(err);
|
|
877
|
-
app.debug(`[${instanceId}] [v1] meta decrypt failed: ${m}`);
|
|
878
|
-
});
|
|
879
|
-
});
|
|
880
|
-
metaSocket.on("error", (err) => {
|
|
881
|
-
app.error(`[${instanceId}] [v1] meta socket error: ${err.message} (${err.code})`);
|
|
882
|
-
recordError("udpSend", `v1 meta socket error: ${err.message} (${err.code})`);
|
|
883
|
-
});
|
|
884
|
-
metaSocket.bind(options.udpMetaPort);
|
|
885
|
-
app.debug(`[${instanceId}] [v1] Meta listener bound to UDP :${options.udpMetaPort}`);
|
|
886
|
-
}
|
|
887
|
-
}
|
|
888
868
|
}
|
|
889
869
|
const startupSocket = state.socketUdp;
|
|
890
870
|
await new Promise((resolve, reject) => {
|
|
@@ -1128,6 +1108,9 @@ function createInstance(app, options, instanceId, pluginId, onStatusChange) {
|
|
|
1128
1108
|
if (typeof v2Pipeline.setMetaRequestHandler === "function") {
|
|
1129
1109
|
v2Pipeline.setMetaRequestHandler(handleMetaRequest);
|
|
1130
1110
|
}
|
|
1111
|
+
if (typeof v2Pipeline.setFullStatusRequestHandler === "function") {
|
|
1112
|
+
v2Pipeline.setFullStatusRequestHandler(handleFullStatusRequest);
|
|
1113
|
+
}
|
|
1131
1114
|
v2Pipeline.startMetricsPublishing();
|
|
1132
1115
|
if (options.congestionControl && options.congestionControl.enabled) {
|
|
1133
1116
|
v2Pipeline.startCongestionControl();
|
|
@@ -1210,6 +1193,7 @@ function createInstance(app, options, instanceId, pluginId, onStatusChange) {
|
|
|
1210
1193
|
Object.keys(state.configContentHashes).forEach((k) => delete state.configContentHashes[k]);
|
|
1211
1194
|
state.excludedSentences = ["GSV"];
|
|
1212
1195
|
state.lastPacketTime = 0;
|
|
1196
|
+
state.lastFullStatusRequestAt = 0;
|
|
1213
1197
|
// Reset metrics
|
|
1214
1198
|
resetMetrics();
|
|
1215
1199
|
// Clear timers
|
|
@@ -1291,16 +1275,6 @@ function createInstance(app, options, instanceId, pluginId, onStatusChange) {
|
|
|
1291
1275
|
state.socketUdp = null;
|
|
1292
1276
|
app.debug(`[${instanceId}] Stopped`);
|
|
1293
1277
|
}
|
|
1294
|
-
if (state.metaSocketUdp) {
|
|
1295
|
-
try {
|
|
1296
|
-
state.metaSocketUdp.close();
|
|
1297
|
-
}
|
|
1298
|
-
catch (err) {
|
|
1299
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
1300
|
-
app.debug(`[${instanceId}] Meta socket close failed: ${msg}`);
|
|
1301
|
-
}
|
|
1302
|
-
state.metaSocketUdp = null;
|
|
1303
|
-
}
|
|
1304
1278
|
_setStatus("Stopped", false);
|
|
1305
1279
|
}
|
|
1306
1280
|
// ── Public API ────────────────────────────────────────────────────────────
|
package/lib/packet.js
CHANGED
|
@@ -55,7 +55,9 @@ const PacketType = Object.freeze({
|
|
|
55
55
|
HEARTBEAT: 0x04,
|
|
56
56
|
HELLO: 0x05,
|
|
57
57
|
METADATA: 0x06,
|
|
58
|
-
META_REQUEST: 0x07
|
|
58
|
+
META_REQUEST: 0x07,
|
|
59
|
+
/** Server → client: request a full values snapshot replay. */
|
|
60
|
+
FULL_STATUS_REQUEST: 0x08
|
|
59
61
|
});
|
|
60
62
|
exports.PacketType = PacketType;
|
|
61
63
|
/**
|
|
@@ -176,6 +178,14 @@ class PacketBuilder {
|
|
|
176
178
|
buildMetaRequestPacket(options = {}) {
|
|
177
179
|
return this._buildPacket(PacketType.META_REQUEST, Buffer.alloc(0), {}, options);
|
|
178
180
|
}
|
|
181
|
+
/**
|
|
182
|
+
* Build a FULL_STATUS_REQUEST control packet (server → client).
|
|
183
|
+
* Payload is empty. Instructs the client to replay its full values snapshot
|
|
184
|
+
* so the server can rebuild state after a restart.
|
|
185
|
+
*/
|
|
186
|
+
buildFullStatusRequestPacket(options = {}) {
|
|
187
|
+
return this._buildPacket(PacketType.FULL_STATUS_REQUEST, Buffer.alloc(0), {}, options);
|
|
188
|
+
}
|
|
179
189
|
/**
|
|
180
190
|
* Build an ACK packet
|
|
181
191
|
* @param {number} ackedSequence - Sequence number being acknowledged
|
|
@@ -404,11 +414,13 @@ class PacketParser {
|
|
|
404
414
|
payload = payloadData;
|
|
405
415
|
}
|
|
406
416
|
else {
|
|
407
|
-
// HEARTBEAT and
|
|
408
|
-
// — accept as-is. ACK / NAK / HELLO must include
|
|
409
|
-
// reject undersized payloads so forged control
|
|
410
|
-
// through unverified.
|
|
411
|
-
if (type !== PacketType.HEARTBEAT &&
|
|
417
|
+
// HEARTBEAT, META_REQUEST, and FULL_STATUS_REQUEST carry a 0-byte
|
|
418
|
+
// payload with no CRC — accept as-is. ACK / NAK / HELLO must include
|
|
419
|
+
// a 2-byte CRC16 trailer; reject undersized payloads so forged control
|
|
420
|
+
// frames cannot slip through unverified.
|
|
421
|
+
if (type !== PacketType.HEARTBEAT &&
|
|
422
|
+
type !== PacketType.META_REQUEST &&
|
|
423
|
+
type !== PacketType.FULL_STATUS_REQUEST) {
|
|
412
424
|
if (payload.length < 2) {
|
|
413
425
|
throw new Error(`Control packet payload too short for CRC: ${payload.length} byte(s)`);
|
|
414
426
|
}
|
|
@@ -504,7 +516,8 @@ function getTypeName(type) {
|
|
|
504
516
|
[PacketType.HEARTBEAT]: "HEARTBEAT",
|
|
505
517
|
[PacketType.HELLO]: "HELLO",
|
|
506
518
|
[PacketType.METADATA]: "METADATA",
|
|
507
|
-
[PacketType.META_REQUEST]: "META_REQUEST"
|
|
519
|
+
[PacketType.META_REQUEST]: "META_REQUEST",
|
|
520
|
+
[PacketType.FULL_STATUS_REQUEST]: "FULL_STATUS_REQUEST"
|
|
508
521
|
};
|
|
509
522
|
return names[type] || "UNKNOWN";
|
|
510
523
|
}
|
|
@@ -106,6 +106,9 @@ function createPipelineV2Client(app, state, metricsApi) {
|
|
|
106
106
|
// (META_REQUEST control packet). Wired up by instance.ts, which is the only
|
|
107
107
|
// layer that knows how to build a snapshot from `app.signalk.retrieve()`.
|
|
108
108
|
let metaRequestHandler = null;
|
|
109
|
+
// Callback fired when the server sends FULL_STATUS_REQUEST, asking the client
|
|
110
|
+
// to replay its complete current values snapshot.
|
|
111
|
+
let fullStatusRequestHandler = null;
|
|
109
112
|
let metaEnvelopeSeq = 0;
|
|
110
113
|
let sourceEnvelopeSeq = 0;
|
|
111
114
|
// Seed all four meta bandwidth counters so downstream consumers (metrics
|
|
@@ -632,6 +635,9 @@ function createPipelineV2Client(app, state, metricsApi) {
|
|
|
632
635
|
function setMetaRequestHandler(handler) {
|
|
633
636
|
metaRequestHandler = handler;
|
|
634
637
|
}
|
|
638
|
+
function setFullStatusRequestHandler(handler) {
|
|
639
|
+
fullStatusRequestHandler = handler;
|
|
640
|
+
}
|
|
635
641
|
/**
|
|
636
642
|
* Handle incoming ACK packet from server.
|
|
637
643
|
* Removes acknowledged packets from the retransmit queue.
|
|
@@ -772,6 +778,21 @@ function createPipelineV2Client(app, state, metricsApi) {
|
|
|
772
778
|
}
|
|
773
779
|
}
|
|
774
780
|
}
|
|
781
|
+
else if (parsed.type === packet_1.PacketType.FULL_STATUS_REQUEST) {
|
|
782
|
+
// Server asks us to replay our full values snapshot (e.g. after a
|
|
783
|
+
// server restart). Rate-limited in instance.ts to prevent abuse.
|
|
784
|
+
if (fullStatusRequestHandler) {
|
|
785
|
+
try {
|
|
786
|
+
Promise.resolve(fullStatusRequestHandler()).catch((err) => {
|
|
787
|
+
app.debug(`FULL_STATUS_REQUEST handler rejected: ${err instanceof Error ? err.message : String(err)}`);
|
|
788
|
+
});
|
|
789
|
+
}
|
|
790
|
+
catch (err) {
|
|
791
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
792
|
+
app.debug(`FULL_STATUS_REQUEST handler error: ${errMsg}`);
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
}
|
|
775
796
|
// Ignore other packet types on client side
|
|
776
797
|
}
|
|
777
798
|
catch (err) {
|
|
@@ -1110,6 +1131,7 @@ function createPipelineV2Client(app, state, metricsApi) {
|
|
|
1110
1131
|
sendMetadata,
|
|
1111
1132
|
sendSourceSnapshot,
|
|
1112
1133
|
setMetaRequestHandler,
|
|
1134
|
+
setFullStatusRequestHandler,
|
|
1113
1135
|
getPacketBuilder,
|
|
1114
1136
|
getRetransmitQueue,
|
|
1115
1137
|
getMetricsPublisher,
|
|
@@ -178,6 +178,7 @@ function createPipelineV2Server(app, state, metricsApi) {
|
|
|
178
178
|
rateLimitCount: 0,
|
|
179
179
|
rateLimitWindowStart: Date.now(),
|
|
180
180
|
metaRequested: false,
|
|
181
|
+
statusRequested: false,
|
|
181
182
|
lastMetaEnvSeq: null,
|
|
182
183
|
seenMetaChunkIdx: new Set(),
|
|
183
184
|
lastSourceEnvSeq: null,
|
|
@@ -212,6 +213,8 @@ function createPipelineV2Server(app, state, metricsApi) {
|
|
|
212
213
|
rateLimitWindowStart: Date.now(),
|
|
213
214
|
// META_REQUEST bookkeeping
|
|
214
215
|
metaRequested: false,
|
|
216
|
+
// FULL_STATUS_REQUEST bookkeeping
|
|
217
|
+
statusRequested: false,
|
|
215
218
|
// Stale-envelope rejection for METADATA packets
|
|
216
219
|
lastMetaEnvSeq: null,
|
|
217
220
|
seenMetaChunkIdx: new Set(),
|
|
@@ -491,6 +494,7 @@ function createPipelineV2Server(app, state, metricsApi) {
|
|
|
491
494
|
seenChunkIdx.clear();
|
|
492
495
|
if (!isSource) {
|
|
493
496
|
session.metaRequested = false;
|
|
497
|
+
session.statusRequested = false;
|
|
494
498
|
}
|
|
495
499
|
}
|
|
496
500
|
if (lastEnvSeq !== null) {
|
|
@@ -610,6 +614,7 @@ function createPipelineV2Server(app, state, metricsApi) {
|
|
|
610
614
|
session.lastMetaEnvSeq = null;
|
|
611
615
|
session.seenMetaChunkIdx.clear();
|
|
612
616
|
session.metaRequested = false;
|
|
617
|
+
session.statusRequested = false;
|
|
613
618
|
}
|
|
614
619
|
if (session.lastMetaEnvSeq !== null) {
|
|
615
620
|
const distance = (envSeq - session.lastMetaEnvSeq) >>> 0;
|
|
@@ -697,6 +702,21 @@ function createPipelineV2Server(app, state, metricsApi) {
|
|
|
697
702
|
recordError("general", `v2 META decode error: ${msg}`);
|
|
698
703
|
}
|
|
699
704
|
}
|
|
705
|
+
/**
|
|
706
|
+
* Build and send a FULL_STATUS_REQUEST (0x08) control packet to a client.
|
|
707
|
+
* Instructs the client to replay its complete current values snapshot so the
|
|
708
|
+
* server can rebuild state immediately after a restart.
|
|
709
|
+
*/
|
|
710
|
+
async function _sendFullStatusRequest(session, secretKey) {
|
|
711
|
+
try {
|
|
712
|
+
const packet = packetBuilder.buildFullStatusRequestPacket({ secretKey });
|
|
713
|
+
await _sendUDP(packet, { address: session.address, port: session.port });
|
|
714
|
+
app.debug(`[v2-server] FULL_STATUS_REQUEST sent to ${session.key}`);
|
|
715
|
+
}
|
|
716
|
+
catch (err) {
|
|
717
|
+
throw err;
|
|
718
|
+
}
|
|
719
|
+
}
|
|
700
720
|
/**
|
|
701
721
|
* Build and send a META_REQUEST (0x07) control packet to a client.
|
|
702
722
|
* Instructs the client to emit a fresh metadata snapshot — used on first
|
|
@@ -819,6 +839,15 @@ function createPipelineV2Server(app, state, metricsApi) {
|
|
|
819
839
|
app.debug(`[v2-server] META_REQUEST send failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
820
840
|
});
|
|
821
841
|
}
|
|
842
|
+
// If the operator enabled full-status-on-restart, also request a values
|
|
843
|
+
// snapshot. Capped at one per session so rapid HELLOs (e.g. NAT churn)
|
|
844
|
+
// don't create repeated replay bursts.
|
|
845
|
+
if (session && !session.statusRequested && state.options?.requestFullStatusOnRestart) {
|
|
846
|
+
session.statusRequested = true;
|
|
847
|
+
_sendFullStatusRequest(session, secretKey).catch((err) => {
|
|
848
|
+
app.debug(`[v2-server] FULL_STATUS_REQUEST send failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
849
|
+
});
|
|
850
|
+
}
|
|
822
851
|
return;
|
|
823
852
|
}
|
|
824
853
|
if (parsed.type === packet_1.PacketType.METADATA) {
|
|
@@ -892,6 +921,14 @@ function createPipelineV2Server(app, state, metricsApi) {
|
|
|
892
921
|
if (session) {
|
|
893
922
|
session.hasReceivedData = true;
|
|
894
923
|
}
|
|
924
|
+
// On first DATA from a new session, request full-status replay if enabled.
|
|
925
|
+
// This covers the case where the client sends data before its next HELLO.
|
|
926
|
+
if (session && !session.statusRequested && state.options?.requestFullStatusOnRestart) {
|
|
927
|
+
session.statusRequested = true;
|
|
928
|
+
_sendFullStatusRequest(session, secretKey).catch((err) => {
|
|
929
|
+
app.debug(`[v2-server] FULL_STATUS_REQUEST (data trigger) send failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
930
|
+
});
|
|
931
|
+
}
|
|
895
932
|
const dataSeq = parsed.sequence >>> 0;
|
|
896
933
|
if (session) {
|
|
897
934
|
if (seqResult.resynced || session.lossBaseSeq === null) {
|
package/lib/pipeline.js
CHANGED
|
@@ -39,15 +39,6 @@ const delta_sanitizer_1 = require("./delta-sanitizer");
|
|
|
39
39
|
const source_dispatch_1 = require("./source-dispatch");
|
|
40
40
|
const pipeline_utils_1 = require("./pipeline-utils");
|
|
41
41
|
const constants_1 = require("./constants");
|
|
42
|
-
const metadata_1 = require("./metadata");
|
|
43
|
-
/** Leading magic that distinguishes v1 meta payloads from v1 deltas, placed
|
|
44
|
-
* inside the encrypted plaintext so existing v1 receivers (which do not
|
|
45
|
-
* recognise it) simply reject the packet rather than misinterpreting it. */
|
|
46
|
-
const V1_META_MAGIC = Buffer.from("SKM1", "ascii");
|
|
47
|
-
/** Threshold for v1 sender-restart detection — see the v2 server's
|
|
48
|
-
* META_RESTART_THRESHOLD comment. envSeq=0 is treated as a restart only when
|
|
49
|
-
* the last accepted seq has moved beyond this small reorder window. */
|
|
50
|
-
const META_RESTART_THRESHOLD_V1 = 8;
|
|
51
42
|
/**
|
|
52
43
|
* Creates the data processing pipeline (compress, encrypt, send / receive, decrypt, decompress).
|
|
53
44
|
* @param app - SignalK app object (for logging)
|
|
@@ -58,12 +49,6 @@ const META_RESTART_THRESHOLD_V1 = 8;
|
|
|
58
49
|
function createPipeline(app, state, metricsApi) {
|
|
59
50
|
const { metrics, recordError, trackPathStats } = metricsApi;
|
|
60
51
|
const setStatus = app.setPluginStatus || app.setProviderStatus || (() => { });
|
|
61
|
-
let metaEnvelopeSeqV1 = 0;
|
|
62
|
-
// Last accepted inner-envelope seq on the receive side. v1 has no
|
|
63
|
-
// per-session concept (one socket per pipeline instance), so a single
|
|
64
|
-
// closure variable is sufficient. Used to drop stale/duplicate envelopes
|
|
65
|
-
// that UDP reorders or replays.
|
|
66
|
-
let lastIngestedMetaEnvSeqV1 = null;
|
|
67
52
|
/**
|
|
68
53
|
* Compresses, encrypts, and sends delta data via UDP.
|
|
69
54
|
* Pipeline: Serialize -> Compress -> Encrypt (AES-256-GCM) -> Send
|
|
@@ -148,73 +133,6 @@ function createPipeline(app, state, metricsApi) {
|
|
|
148
133
|
}
|
|
149
134
|
}
|
|
150
135
|
}
|
|
151
|
-
/**
|
|
152
|
-
* Sends Signal K path metadata to the receiver using the v1 wire format on a
|
|
153
|
-
* separate UDP port.
|
|
154
|
-
*
|
|
155
|
-
* v1 has no packet-type byte so we cannot multiplex meta with deltas on the
|
|
156
|
-
* existing port without breaking every deployed v1 receiver. To keep the
|
|
157
|
-
* change backward-compatible, meta is sent on `udpMetaPort` with a 4-byte
|
|
158
|
-
* `SKM1` magic prefix inside the encrypted plaintext — a v1 receiver that
|
|
159
|
-
* has not been upgraded will fail to JSON-parse the payload and simply drop
|
|
160
|
-
* it without side effects.
|
|
161
|
-
*/
|
|
162
|
-
async function packCryptMeta(entries, kind, secretKey, udpAddress, udpMetaPort) {
|
|
163
|
-
try {
|
|
164
|
-
if (!state.options) {
|
|
165
|
-
app.debug("packCryptMeta called but plugin is stopped, ignoring");
|
|
166
|
-
return;
|
|
167
|
-
}
|
|
168
|
-
if (!udpMetaPort || udpMetaPort <= 0) {
|
|
169
|
-
app.debug("packCryptMeta: no udpMetaPort configured, meta disabled on v1");
|
|
170
|
-
return;
|
|
171
|
-
}
|
|
172
|
-
if (entries.length === 0) {
|
|
173
|
-
return;
|
|
174
|
-
}
|
|
175
|
-
const usePathDict = !!state.options.usePathDictionary;
|
|
176
|
-
const useMsgpack = !!state.options.useMsgpack;
|
|
177
|
-
const maxPerPacket = state.metaConfig?.maxPathsPerPacket ?? 500;
|
|
178
|
-
const chunks = (0, metadata_1.splitIntoPackets)(entries, maxPerPacket);
|
|
179
|
-
const envelopeSeq = metaEnvelopeSeqV1++ >>> 0;
|
|
180
|
-
for (let i = 0; i < chunks.length; i++) {
|
|
181
|
-
const chunk = chunks[i];
|
|
182
|
-
const processed = usePathDict ? chunk.map(pathDictionary_1.encodeMetaEntry) : chunk;
|
|
183
|
-
const envelope = (0, metadata_1.buildMetaEnvelope)(processed, kind, envelopeSeq, i, chunks.length);
|
|
184
|
-
const serialized = (0, pipeline_utils_1.deltaBuffer)(envelope, useMsgpack);
|
|
185
|
-
const withMagic = Buffer.concat([V1_META_MAGIC, serialized]);
|
|
186
|
-
const compressed = await (0, pipeline_utils_1.compressPayload)(withMagic, useMsgpack);
|
|
187
|
-
const packet = (0, crypto_1.encryptBinary)(compressed, secretKey, {
|
|
188
|
-
stretchAsciiKey: !!state.options.stretchAsciiKey
|
|
189
|
-
});
|
|
190
|
-
if (packet.length > constants_1.MAX_SAFE_UDP_PAYLOAD) {
|
|
191
|
-
app.debug(`Warning: v1 meta packet size ${packet.length} bytes exceeds safe MTU (${constants_1.MAX_SAFE_UDP_PAYLOAD})`);
|
|
192
|
-
}
|
|
193
|
-
await udpSendAsync(packet, udpAddress, udpMetaPort);
|
|
194
|
-
metrics.bandwidth.metaBytesOut = (metrics.bandwidth.metaBytesOut || 0) + packet.length;
|
|
195
|
-
metrics.bandwidth.metaPacketsOut = (metrics.bandwidth.metaPacketsOut || 0) + 1;
|
|
196
|
-
metrics.bandwidth.bytesOut += packet.length;
|
|
197
|
-
metrics.bandwidth.packetsOut++;
|
|
198
|
-
}
|
|
199
|
-
if (kind === "snapshot") {
|
|
200
|
-
metrics.bandwidth.metaSnapshotsSent = (metrics.bandwidth.metaSnapshotsSent || 0) + 1;
|
|
201
|
-
}
|
|
202
|
-
else {
|
|
203
|
-
metrics.bandwidth.metaDiffsSent = (metrics.bandwidth.metaDiffsSent || 0) + 1;
|
|
204
|
-
}
|
|
205
|
-
app.debug(`v1 meta sent: kind=${kind}, entries=${entries.length}, chunks=${chunks.length}, envSeq=${envelopeSeq}`);
|
|
206
|
-
}
|
|
207
|
-
catch (error) {
|
|
208
|
-
const msg = error instanceof Error ? error.message : String(error);
|
|
209
|
-
app.error(`packCryptMeta error: ${msg}`);
|
|
210
|
-
recordError("general", `packCryptMeta error: ${msg}`);
|
|
211
|
-
// Re-throw so the caller (sendMetaEntries) can tell the send failed
|
|
212
|
-
// and refrain from committing the MetaCache. Without this, a broken
|
|
213
|
-
// socket/encryption/compression would silently suppress every future
|
|
214
|
-
// diff for the affected paths.
|
|
215
|
-
throw error instanceof Error ? error : new Error(msg);
|
|
216
|
-
}
|
|
217
|
-
}
|
|
218
136
|
/**
|
|
219
137
|
* Decompresses, decrypts, and processes received UDP data.
|
|
220
138
|
* Pipeline: Receive -> Decrypt (AES-256-GCM) -> Decompress -> Parse -> Process
|
|
@@ -357,138 +275,6 @@ function createPipeline(app, state, metricsApi) {
|
|
|
357
275
|
}
|
|
358
276
|
});
|
|
359
277
|
}
|
|
360
|
-
|
|
361
|
-
* Receive-side counterpart to `packCryptMeta` for v1. Decrypts a packet
|
|
362
|
-
* arrived on `udpMetaPort`, verifies the 4-byte `SKM1` magic inside the
|
|
363
|
-
* plaintext (packets without the magic are dropped — v1 has no packet-type
|
|
364
|
-
* byte, so the magic is the only signal that this is a meta payload and not
|
|
365
|
-
* a corrupted delta), and dispatches each entry as a minimal Signal K delta
|
|
366
|
-
* with `updates[].meta[]` via `app.handleMessage`.
|
|
367
|
-
*/
|
|
368
|
-
async function unpackDecryptMeta(packet, secretKey) {
|
|
369
|
-
try {
|
|
370
|
-
if (!state.options) {
|
|
371
|
-
app.debug("unpackDecryptMeta called but plugin is stopped, ignoring");
|
|
372
|
-
return;
|
|
373
|
-
}
|
|
374
|
-
// Bump bytesIn/packetsIn AND the meta-scoped counters at the same
|
|
375
|
-
// gate — any packet that reached this code is a meta packet (the
|
|
376
|
-
// separate udpMetaPort ensures that), so bytesIn should always equal
|
|
377
|
-
// metaBytesIn for this pipeline path. Keeping them in lockstep lets
|
|
378
|
-
// consumers cross-check: bytesIn === dataBytesIn + metaBytesIn.
|
|
379
|
-
metrics.bandwidth.bytesIn += packet.length;
|
|
380
|
-
metrics.bandwidth.packetsIn++;
|
|
381
|
-
metrics.bandwidth.metaBytesIn = (metrics.bandwidth.metaBytesIn || 0) + packet.length;
|
|
382
|
-
metrics.bandwidth.metaPacketsIn = (metrics.bandwidth.metaPacketsIn || 0) + 1;
|
|
383
|
-
const decrypted = (0, crypto_1.decryptBinary)(packet, secretKey, {
|
|
384
|
-
stretchAsciiKey: !!state.options.stretchAsciiKey
|
|
385
|
-
});
|
|
386
|
-
const decompressed = (await (0, pipeline_utils_1.brotliDecompressAsync)(decrypted, {
|
|
387
|
-
maxOutputLength: constants_1.MAX_DECOMPRESSED_SIZE
|
|
388
|
-
}));
|
|
389
|
-
if (decompressed.length < V1_META_MAGIC.length) {
|
|
390
|
-
app.debug("v1 meta: decompressed payload too short, ignoring");
|
|
391
|
-
return;
|
|
392
|
-
}
|
|
393
|
-
// Reject anything that isn't prefixed with the SKM1 magic so a stray
|
|
394
|
-
// non-meta packet on the meta port (misconfiguration, replay, attacker)
|
|
395
|
-
// cannot be misinterpreted. The magic lives INSIDE the encrypted
|
|
396
|
-
// plaintext, so this check is authenticated.
|
|
397
|
-
if (decompressed.subarray(0, V1_META_MAGIC.length).compare(V1_META_MAGIC) !== 0) {
|
|
398
|
-
app.debug("v1 meta: missing SKM1 magic, dropping");
|
|
399
|
-
return;
|
|
400
|
-
}
|
|
401
|
-
const body = decompressed.subarray(V1_META_MAGIC.length);
|
|
402
|
-
if (body.length > constants_1.MAX_PARSE_PAYLOAD_SIZE) {
|
|
403
|
-
app.error(`v1 meta: payload too large to parse: ${body.length} bytes`);
|
|
404
|
-
return;
|
|
405
|
-
}
|
|
406
|
-
let content;
|
|
407
|
-
if (state.options.useMsgpack) {
|
|
408
|
-
try {
|
|
409
|
-
content = msgpack.decode(body);
|
|
410
|
-
}
|
|
411
|
-
catch {
|
|
412
|
-
content = JSON.parse(body.toString());
|
|
413
|
-
}
|
|
414
|
-
}
|
|
415
|
-
else {
|
|
416
|
-
content = JSON.parse(body.toString());
|
|
417
|
-
}
|
|
418
|
-
if (!content || typeof content !== "object" || Array.isArray(content)) {
|
|
419
|
-
app.debug("v1 meta: envelope was not an object, dropping");
|
|
420
|
-
return;
|
|
421
|
-
}
|
|
422
|
-
const env = content;
|
|
423
|
-
if (!Array.isArray(env.entries) || env.entries.length === 0) {
|
|
424
|
-
return;
|
|
425
|
-
}
|
|
426
|
-
// Drop stale/duplicate envelopes. The inner envelope `seq` is shared
|
|
427
|
-
// across all chunks of the same batch, so equal-seq chunks are still
|
|
428
|
-
// accepted; only earlier batches are rejected. Uint32-wrap aware so a
|
|
429
|
-
// long-running sender's wrap doesn't trigger mass-rejection.
|
|
430
|
-
//
|
|
431
|
-
// Sender-restart detection: the v1 client's meta envelope counter
|
|
432
|
-
// initialises to 0 at process start. Treat envSeq=0 as a peer restart
|
|
433
|
-
// only once lastIngestedMetaEnvSeqV1 has advanced beyond a small
|
|
434
|
-
// reorder window — below the threshold, envSeq=0 is ambiguous with
|
|
435
|
-
// first-packet replay and falls through to normal dedup.
|
|
436
|
-
if (typeof env.seq === "number" && Number.isFinite(env.seq)) {
|
|
437
|
-
const envSeq = env.seq >>> 0;
|
|
438
|
-
if (lastIngestedMetaEnvSeqV1 !== null &&
|
|
439
|
-
envSeq === 0 &&
|
|
440
|
-
lastIngestedMetaEnvSeqV1 >= META_RESTART_THRESHOLD_V1) {
|
|
441
|
-
app.debug(`v1 meta: sender restart detected (last seq was ${lastIngestedMetaEnvSeqV1}); resetting`);
|
|
442
|
-
lastIngestedMetaEnvSeqV1 = null;
|
|
443
|
-
}
|
|
444
|
-
if (lastIngestedMetaEnvSeqV1 !== null) {
|
|
445
|
-
const distance = (envSeq - lastIngestedMetaEnvSeqV1) >>> 0;
|
|
446
|
-
if (distance !== 0 && distance >= 0x80000000) {
|
|
447
|
-
app.debug(`v1 meta: stale envelope seq=${envSeq} (last=${lastIngestedMetaEnvSeqV1}), dropping`);
|
|
448
|
-
return;
|
|
449
|
-
}
|
|
450
|
-
if (distance !== 0) {
|
|
451
|
-
lastIngestedMetaEnvSeqV1 = envSeq;
|
|
452
|
-
}
|
|
453
|
-
}
|
|
454
|
-
else {
|
|
455
|
-
lastIngestedMetaEnvSeqV1 = envSeq;
|
|
456
|
-
}
|
|
457
|
-
}
|
|
458
|
-
const nowIso = new Date().toISOString();
|
|
459
|
-
const usePathDict = !!state.options.usePathDictionary;
|
|
460
|
-
for (const rawEntry of env.entries) {
|
|
461
|
-
if (!rawEntry || typeof rawEntry.meta !== "object" || !rawEntry.meta) {
|
|
462
|
-
continue;
|
|
463
|
-
}
|
|
464
|
-
const entry = usePathDict
|
|
465
|
-
? (0, pathDictionary_1.decodeMetaEntry)(rawEntry)
|
|
466
|
-
: rawEntry;
|
|
467
|
-
const path = typeof entry.path === "string" ? entry.path : String(entry.path ?? "");
|
|
468
|
-
if (!path) {
|
|
469
|
-
continue;
|
|
470
|
-
}
|
|
471
|
-
const context = typeof rawEntry.context === "string" ? rawEntry.context : "vessels.self";
|
|
472
|
-
const delta = {
|
|
473
|
-
context,
|
|
474
|
-
updates: [
|
|
475
|
-
{
|
|
476
|
-
timestamp: nowIso,
|
|
477
|
-
values: [],
|
|
478
|
-
meta: [{ path, value: entry.meta }]
|
|
479
|
-
}
|
|
480
|
-
]
|
|
481
|
-
};
|
|
482
|
-
app.handleMessage("", delta);
|
|
483
|
-
}
|
|
484
|
-
app.debug(`v1 meta received: kind=${env.kind ?? "?"}, entries=${env.entries.length}`);
|
|
485
|
-
}
|
|
486
|
-
catch (error) {
|
|
487
|
-
const msg = error instanceof Error ? error.message : String(error);
|
|
488
|
-
app.error(`unpackDecryptMeta error: ${msg}`);
|
|
489
|
-
recordError("general", `unpackDecryptMeta error: ${msg}`);
|
|
490
|
-
}
|
|
491
|
-
}
|
|
492
|
-
return { packCrypt, packCryptMeta, unpackDecrypt, unpackDecryptMeta };
|
|
278
|
+
return { packCrypt, unpackDecrypt };
|
|
493
279
|
}
|
|
494
280
|
module.exports = createPipeline;
|