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 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 layer |
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)
@@ -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 (protoVer === 1) {
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 META_REQUEST packets carry a 0-byte payload with no CRC
408
- // — accept as-is. ACK / NAK / HELLO must include a 2-byte CRC16 trailer;
409
- // reject undersized payloads so forged control frames cannot slip
410
- // through unverified.
411
- if (type !== PacketType.HEARTBEAT && type !== PacketType.META_REQUEST) {
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;