signalk-edge-link 2.2.0 → 2.3.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/lib/delta-sanitizer.js +86 -0
- package/lib/instance.js +284 -8
- package/lib/metadata.js +467 -0
- package/lib/metrics.js +22 -1
- package/lib/packet.js +51 -14
- package/lib/pathDictionary.js +20 -1
- package/lib/pipeline-v2-client.js +177 -12
- package/lib/pipeline-v2-server.js +236 -2
- package/lib/pipeline.js +221 -1
- package/lib/prometheus.js +11 -0
- package/lib/routes/config-validation.js +49 -0
- package/lib/routes/metrics.js +1 -0
- package/lib/routes.js +25 -4
- package/package.json +1 -1
- package/public/{982.b207a377ed6542e2fb4a.js → 982.63949a2b2f6c5854e034.js} +2 -2
- package/public/982.63949a2b2f6c5854e034.js.map +1 -0
- package/public/index.html +1 -1
- package/public/main.0b6f5e3267731da945f0.js +2 -0
- package/public/main.0b6f5e3267731da945f0.js.map +1 -0
- package/public/main.js +467 -0
- package/public/remoteEntry.js +1 -1
- package/public/remoteEntry.js.map +1 -1
- package/public/982.b207a377ed6542e2fb4a.js.map +0 -1
- package/public/main.f1780db6593b0c07a48c.js +0 -2
- package/public/main.f1780db6593b0c07a48c.js.map +0 -1
|
@@ -19,12 +19,14 @@ exports.createPipelineV2Client = createPipelineV2Client;
|
|
|
19
19
|
const CircularBuffer_1 = __importDefault(require("./CircularBuffer"));
|
|
20
20
|
const crypto_1 = require("./crypto");
|
|
21
21
|
const pathDictionary_1 = require("./pathDictionary");
|
|
22
|
+
const delta_sanitizer_1 = require("./delta-sanitizer");
|
|
22
23
|
const pipeline_utils_1 = require("./pipeline-utils");
|
|
23
24
|
const packet_1 = require("./packet");
|
|
24
25
|
const retransmit_queue_1 = require("./retransmit-queue");
|
|
25
26
|
const metrics_publisher_1 = require("./metrics-publisher");
|
|
26
27
|
const congestion_1 = require("./congestion");
|
|
27
28
|
const bonding_1 = require("./bonding");
|
|
29
|
+
const metadata_1 = require("./metadata");
|
|
28
30
|
const constants_1 = require("./constants");
|
|
29
31
|
/**
|
|
30
32
|
* Creates the v2 client pipeline
|
|
@@ -100,6 +102,35 @@ function createPipelineV2Client(app, state, metricsApi) {
|
|
|
100
102
|
let lastRetransmissions = 0;
|
|
101
103
|
// Enhanced monitoring hooks (set externally via setMonitoring)
|
|
102
104
|
let monitoringHooks = null;
|
|
105
|
+
// Callback fired when the receiver asks for a fresh metadata snapshot
|
|
106
|
+
// (META_REQUEST control packet). Wired up by instance.ts, which is the only
|
|
107
|
+
// layer that knows how to build a snapshot from `app.signalk.retrieve()`.
|
|
108
|
+
let metaRequestHandler = null;
|
|
109
|
+
let metaEnvelopeSeq = 0;
|
|
110
|
+
// Seed all four meta bandwidth counters so downstream consumers (metrics
|
|
111
|
+
// publishers, prometheus exporter, tests) always see numeric zeros rather
|
|
112
|
+
// than undefined on a fresh pipeline. Uses || 0 at write sites elsewhere as
|
|
113
|
+
// belt-and-braces, but consistent snapshots require consistent seeding.
|
|
114
|
+
if (metrics.bandwidth) {
|
|
115
|
+
if (metrics.bandwidth.metaBytesOut === undefined) {
|
|
116
|
+
metrics.bandwidth.metaBytesOut = 0;
|
|
117
|
+
}
|
|
118
|
+
if (metrics.bandwidth.metaPacketsOut === undefined) {
|
|
119
|
+
metrics.bandwidth.metaPacketsOut = 0;
|
|
120
|
+
}
|
|
121
|
+
if (metrics.bandwidth.metaBytesIn === undefined) {
|
|
122
|
+
metrics.bandwidth.metaBytesIn = 0;
|
|
123
|
+
}
|
|
124
|
+
if (metrics.bandwidth.metaPacketsIn === undefined) {
|
|
125
|
+
metrics.bandwidth.metaPacketsIn = 0;
|
|
126
|
+
}
|
|
127
|
+
if (metrics.bandwidth.metaSnapshotsSent === undefined) {
|
|
128
|
+
metrics.bandwidth.metaSnapshotsSent = 0;
|
|
129
|
+
}
|
|
130
|
+
if (metrics.bandwidth.metaDiffsSent === undefined) {
|
|
131
|
+
metrics.bandwidth.metaDiffsSent = 0;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
103
134
|
// RTT tracking for jitter calculation (CircularBuffer gives O(1) push with auto-eviction)
|
|
104
135
|
const rttSamples = new CircularBuffer_1.default(10);
|
|
105
136
|
let lastAckedSeq = null;
|
|
@@ -116,12 +147,33 @@ function createPipelineV2Client(app, state, metricsApi) {
|
|
|
116
147
|
const distance = (seq - reference) >>> 0;
|
|
117
148
|
return distance !== 0 && distance < 0x80000000;
|
|
118
149
|
}
|
|
150
|
+
function isSingleDeltaPayload(deltaPayload) {
|
|
151
|
+
return !Array.isArray(deltaPayload) && Array.isArray(deltaPayload.updates);
|
|
152
|
+
}
|
|
153
|
+
function deltaPayloadItems(deltaPayload) {
|
|
154
|
+
if (Array.isArray(deltaPayload)) {
|
|
155
|
+
return deltaPayload;
|
|
156
|
+
}
|
|
157
|
+
if (isSingleDeltaPayload(deltaPayload)) {
|
|
158
|
+
return [deltaPayload];
|
|
159
|
+
}
|
|
160
|
+
return Object.values(deltaPayload);
|
|
161
|
+
}
|
|
162
|
+
function encodeDeltaPayload(deltaPayload) {
|
|
163
|
+
if (Array.isArray(deltaPayload)) {
|
|
164
|
+
return deltaPayload.map(pathDictionary_1.encodeDelta);
|
|
165
|
+
}
|
|
166
|
+
if (isSingleDeltaPayload(deltaPayload)) {
|
|
167
|
+
return (0, pathDictionary_1.encodeDelta)(deltaPayload);
|
|
168
|
+
}
|
|
169
|
+
return Object.fromEntries(Object.entries(deltaPayload).map(([key, value]) => [key, (0, pathDictionary_1.encodeDelta)(value)]));
|
|
170
|
+
}
|
|
119
171
|
function recordPathLatencies(deltaPayload) {
|
|
120
172
|
if (!monitoringHooks || !monitoringHooks.pathLatencyTracker) {
|
|
121
173
|
return;
|
|
122
174
|
}
|
|
123
175
|
const now = Date.now();
|
|
124
|
-
const deltas =
|
|
176
|
+
const deltas = deltaPayloadItems(deltaPayload);
|
|
125
177
|
for (const delta of deltas) {
|
|
126
178
|
if (!delta || !Array.isArray(delta.updates)) {
|
|
127
179
|
continue;
|
|
@@ -253,22 +305,23 @@ function createPipelineV2Client(app, state, metricsApi) {
|
|
|
253
305
|
app.debug("sendDelta called but plugin is stopped, ignoring");
|
|
254
306
|
return;
|
|
255
307
|
}
|
|
308
|
+
const sanitizedDelta = (0, delta_sanitizer_1.sanitizeDeltaPayloadForSignalK)(delta);
|
|
309
|
+
if (sanitizedDelta === null) {
|
|
310
|
+
app.debug("sendDelta skipped: no valid Signal K values");
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
256
313
|
// Apply path dictionary encoding if enabled
|
|
257
314
|
const processedDelta = state.options.usePathDictionary
|
|
258
|
-
?
|
|
259
|
-
|
|
260
|
-
: (0, pathDictionary_1.encodeDelta)(delta)
|
|
261
|
-
: delta;
|
|
315
|
+
? encodeDeltaPayload(sanitizedDelta)
|
|
316
|
+
: sanitizedDelta;
|
|
262
317
|
// Serialize to buffer
|
|
263
318
|
const serialized = (0, pipeline_utils_1.deltaBuffer)(processedDelta, state.options.useMsgpack);
|
|
264
319
|
metrics.bandwidth.bytesOutRaw += serialized.length;
|
|
265
|
-
|
|
266
|
-
|
|
320
|
+
const sanitizedItems = deltaPayloadItems(sanitizedDelta);
|
|
321
|
+
for (const item of sanitizedItems) {
|
|
322
|
+
trackPathStats(item, serialized.length / sanitizedItems.length);
|
|
267
323
|
}
|
|
268
|
-
|
|
269
|
-
trackPathStats(delta, serialized.length);
|
|
270
|
-
}
|
|
271
|
-
recordPathLatencies(delta);
|
|
324
|
+
recordPathLatencies(sanitizedDelta);
|
|
272
325
|
// Compress
|
|
273
326
|
const compressed = await (0, pipeline_utils_1.compressPayload)(serialized, state.options?.useMsgpack ?? false);
|
|
274
327
|
// Encrypt
|
|
@@ -317,7 +370,7 @@ function createPipelineV2Client(app, state, metricsApi) {
|
|
|
317
370
|
lossWindow.push(false);
|
|
318
371
|
// Update smart batching model
|
|
319
372
|
// Guard against empty array: treat 0 as 1 to avoid Infinity in bytesPerDelta.
|
|
320
|
-
const deltaCount =
|
|
373
|
+
const deltaCount = Math.max(1, sanitizedItems.length);
|
|
321
374
|
const bytesPerDelta = packet.length / deltaCount;
|
|
322
375
|
state.avgBytesPerDelta =
|
|
323
376
|
(1 - constants_1.SMART_BATCH_SMOOTHING) * state.avgBytesPerDelta +
|
|
@@ -345,6 +398,92 @@ function createPipelineV2Client(app, state, metricsApi) {
|
|
|
345
398
|
throw error;
|
|
346
399
|
}
|
|
347
400
|
}
|
|
401
|
+
/**
|
|
402
|
+
* Send a batch of Signal K metadata entries to the receiver as one or more
|
|
403
|
+
* METADATA (0x06) packets. Mirrors the compress → encrypt → packet-build
|
|
404
|
+
* pipeline of `sendDelta` but uses a meta envelope so the receiver can
|
|
405
|
+
* reconstruct multi-chunk snapshots.
|
|
406
|
+
*
|
|
407
|
+
* Snapshots are NOT inserted into the retransmit queue — eventual
|
|
408
|
+
* consistency is provided by the periodic resend timer in instance.ts, and
|
|
409
|
+
* the receiver can always request a fresh snapshot via META_REQUEST.
|
|
410
|
+
*/
|
|
411
|
+
async function sendMetadata(entries, kind, secretKey, udpAddress, udpPort) {
|
|
412
|
+
try {
|
|
413
|
+
if (!state.options) {
|
|
414
|
+
app.debug("sendMetadata called but plugin is stopped, ignoring");
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
if (entries.length === 0) {
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
const maxPerPacket = state.metaConfig?.maxPathsPerPacket ?? 500;
|
|
421
|
+
const chunks = (0, metadata_1.splitIntoPackets)(entries, maxPerPacket);
|
|
422
|
+
const usePathDict = !!state.options.usePathDictionary;
|
|
423
|
+
const useMsgpack = !!state.options.useMsgpack;
|
|
424
|
+
// Assign one envelope seq per chunk group so the receiver can correlate
|
|
425
|
+
// `idx/total` inside a single snapshot/diff operation.
|
|
426
|
+
const envelopeSeq = metaEnvelopeSeq++ >>> 0;
|
|
427
|
+
for (let i = 0; i < chunks.length; i++) {
|
|
428
|
+
const chunk = chunks[i];
|
|
429
|
+
const processedEntries = usePathDict ? chunk.map(pathDictionary_1.encodeMetaEntry) : chunk;
|
|
430
|
+
const envelope = (0, metadata_1.buildMetaEnvelope)(processedEntries, kind, envelopeSeq, i, chunks.length);
|
|
431
|
+
const serialized = (0, pipeline_utils_1.deltaBuffer)(envelope, useMsgpack);
|
|
432
|
+
const compressed = await (0, pipeline_utils_1.compressPayload)(serialized, useMsgpack);
|
|
433
|
+
const encrypted = (0, crypto_1.encryptBinary)(compressed, secretKey, { stretchAsciiKey });
|
|
434
|
+
const packet = packetBuilder.buildMetadataPacket(encrypted, {
|
|
435
|
+
compressed: true,
|
|
436
|
+
encrypted: true,
|
|
437
|
+
messagepack: useMsgpack,
|
|
438
|
+
pathDictionary: usePathDict
|
|
439
|
+
});
|
|
440
|
+
// Mirror sendDelta's MTU guard + monitoring hooks so META traffic is
|
|
441
|
+
// visible to the same observability surfaces as DATA. Oversized META
|
|
442
|
+
// packets would otherwise fragment silently, and a user running a
|
|
443
|
+
// packet capture would see DATA but not META — confusing.
|
|
444
|
+
if (packet.length > constants_1.MAX_SAFE_UDP_PAYLOAD) {
|
|
445
|
+
app.debug(`Warning: v2 meta packet size ${packet.length} bytes exceeds safe MTU (${constants_1.MAX_SAFE_UDP_PAYLOAD}), may fragment.`);
|
|
446
|
+
metrics.smartBatching.oversizedPackets++;
|
|
447
|
+
}
|
|
448
|
+
await udpSendAsync(packet, udpAddress, udpPort);
|
|
449
|
+
if (monitoringHooks) {
|
|
450
|
+
const rinfo = { address: udpAddress, port: udpPort };
|
|
451
|
+
if (monitoringHooks.packetCapture) {
|
|
452
|
+
monitoringHooks.packetCapture.capture(packet, "send", rinfo);
|
|
453
|
+
}
|
|
454
|
+
if (monitoringHooks.packetInspector) {
|
|
455
|
+
monitoringHooks.packetInspector.inspect(packet, "send", rinfo);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
metrics.bandwidth.metaBytesOut = (metrics.bandwidth.metaBytesOut || 0) + packet.length;
|
|
459
|
+
metrics.bandwidth.metaPacketsOut = (metrics.bandwidth.metaPacketsOut || 0) + 1;
|
|
460
|
+
metrics.bandwidth.bytesOut += packet.length;
|
|
461
|
+
metrics.bandwidth.packetsOut++;
|
|
462
|
+
}
|
|
463
|
+
// Count one envelope per call (a multi-chunk envelope is logically one
|
|
464
|
+
// snapshot/diff, even though it shows up in metaPacketsOut as N).
|
|
465
|
+
if (kind === "snapshot") {
|
|
466
|
+
metrics.bandwidth.metaSnapshotsSent = (metrics.bandwidth.metaSnapshotsSent || 0) + 1;
|
|
467
|
+
}
|
|
468
|
+
else {
|
|
469
|
+
metrics.bandwidth.metaDiffsSent = (metrics.bandwidth.metaDiffsSent || 0) + 1;
|
|
470
|
+
}
|
|
471
|
+
app.debug(`v2 meta sent: kind=${kind}, entries=${entries.length}, chunks=${chunks.length}, envSeq=${envelopeSeq}`);
|
|
472
|
+
}
|
|
473
|
+
catch (error) {
|
|
474
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
475
|
+
app.error(`v2 sendMetadata error: ${msg}`);
|
|
476
|
+
recordError("general", `v2 sendMetadata error: ${msg}`);
|
|
477
|
+
// Re-throw so callers (e.g., sendMetaEntries in instance.ts) can
|
|
478
|
+
// distinguish a successful send from a swallowed failure. Without the
|
|
479
|
+
// rethrow the caller would commit the MetaCache despite nothing
|
|
480
|
+
// reaching the wire, silently suppressing the next diff.
|
|
481
|
+
throw error instanceof Error ? error : new Error(msg);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
function setMetaRequestHandler(handler) {
|
|
485
|
+
metaRequestHandler = handler;
|
|
486
|
+
}
|
|
348
487
|
/**
|
|
349
488
|
* Handle incoming ACK packet from server.
|
|
350
489
|
* Removes acknowledged packets from the retransmit queue.
|
|
@@ -461,6 +600,30 @@ function createPipelineV2Client(app, state, metricsApi) {
|
|
|
461
600
|
else if (parsed.type === packet_1.PacketType.NAK) {
|
|
462
601
|
await receiveNAK(parsed, rinfo.address, rinfo.port);
|
|
463
602
|
}
|
|
603
|
+
else if (parsed.type === packet_1.PacketType.META_REQUEST) {
|
|
604
|
+
// Receiver asks us to re-send the full meta snapshot. Rate-limited in
|
|
605
|
+
// the handler (instance.ts) to prevent a malformed receiver from
|
|
606
|
+
// pinning our CPU/bandwidth on snapshot generation. The handler
|
|
607
|
+
// itself is synchronous, but if a future implementation returns a
|
|
608
|
+
// Promise we swallow rejections here so they don't bubble up into
|
|
609
|
+
// the control-packet parse error path (which increments
|
|
610
|
+
// metrics.malformedPackets and would mis-classify the failure).
|
|
611
|
+
if (metaRequestHandler) {
|
|
612
|
+
try {
|
|
613
|
+
// Wrap in Promise.resolve so any thenable (PromiseLike) returned
|
|
614
|
+
// by the handler — not just real Promises with .catch — gets a
|
|
615
|
+
// .catch attached. This handles unusual user-supplied thenables
|
|
616
|
+
// and is simpler than feature-detecting .catch / .then directly.
|
|
617
|
+
Promise.resolve(metaRequestHandler()).catch((err) => {
|
|
618
|
+
app.debug(`META_REQUEST handler rejected: ${err instanceof Error ? err.message : String(err)}`);
|
|
619
|
+
});
|
|
620
|
+
}
|
|
621
|
+
catch (err) {
|
|
622
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
623
|
+
app.debug(`META_REQUEST handler error: ${errMsg}`);
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
}
|
|
464
627
|
// Ignore other packet types on client side
|
|
465
628
|
}
|
|
466
629
|
catch (err) {
|
|
@@ -792,6 +955,8 @@ function createPipelineV2Client(app, state, metricsApi) {
|
|
|
792
955
|
}
|
|
793
956
|
return {
|
|
794
957
|
sendDelta,
|
|
958
|
+
sendMetadata,
|
|
959
|
+
setMetaRequestHandler,
|
|
795
960
|
getPacketBuilder,
|
|
796
961
|
getRetransmitQueue,
|
|
797
962
|
getMetricsPublisher,
|
|
@@ -55,6 +55,7 @@ const node_zlib_1 = __importDefault(require("node:zlib"));
|
|
|
55
55
|
const msgpack = __importStar(require("@msgpack/msgpack"));
|
|
56
56
|
const crypto_1 = require("./crypto");
|
|
57
57
|
const pathDictionary_1 = require("./pathDictionary");
|
|
58
|
+
const delta_sanitizer_1 = require("./delta-sanitizer");
|
|
58
59
|
const packet_1 = require("./packet");
|
|
59
60
|
const sequence_1 = require("./sequence");
|
|
60
61
|
const metrics_publisher_1 = require("./metrics-publisher");
|
|
@@ -95,6 +96,12 @@ function createPipelineV2Server(app, state, metricsApi) {
|
|
|
95
96
|
// session table (MAX_CLIENT_SESSIONS) by spoofing many source ports from one
|
|
96
97
|
// IP, evicting all legitimate sessions (DoS).
|
|
97
98
|
const MAX_SESSIONS_PER_IP = 5;
|
|
99
|
+
// Threshold for sender-restart detection on the META envelope sequence.
|
|
100
|
+
// An incoming envSeq of 0 is treated as a peer restart only once
|
|
101
|
+
// lastMetaEnvSeq has advanced beyond this value — below it, envSeq=0 is
|
|
102
|
+
// ambiguous (could be a legitimate first-packet replay) and falls through
|
|
103
|
+
// to normal dedup. UDP reorders >8 packets backwards are exceedingly rare.
|
|
104
|
+
const META_RESTART_THRESHOLD = 8;
|
|
98
105
|
let ackTimer = null;
|
|
99
106
|
/**
|
|
100
107
|
* Per-client session map, keyed by "address:port".
|
|
@@ -165,7 +172,10 @@ function createPipelineV2Server(app, state, metricsApi) {
|
|
|
165
172
|
lastLossExpected: 0,
|
|
166
173
|
lastLossReceived: 0,
|
|
167
174
|
rateLimitCount: 0,
|
|
168
|
-
rateLimitWindowStart: Date.now()
|
|
175
|
+
rateLimitWindowStart: Date.now(),
|
|
176
|
+
metaRequested: false,
|
|
177
|
+
lastMetaEnvSeq: null,
|
|
178
|
+
seenMetaChunkIdx: new Set()
|
|
169
179
|
};
|
|
170
180
|
}
|
|
171
181
|
const session = {
|
|
@@ -191,7 +201,12 @@ function createPipelineV2Server(app, state, metricsApi) {
|
|
|
191
201
|
lastLossReceived: 0,
|
|
192
202
|
// per-session UDP rate limiting
|
|
193
203
|
rateLimitCount: 0,
|
|
194
|
-
rateLimitWindowStart: Date.now()
|
|
204
|
+
rateLimitWindowStart: Date.now(),
|
|
205
|
+
// META_REQUEST bookkeeping
|
|
206
|
+
metaRequested: false,
|
|
207
|
+
// Stale-envelope rejection for METADATA packets
|
|
208
|
+
lastMetaEnvSeq: null,
|
|
209
|
+
seenMetaChunkIdx: new Set()
|
|
195
210
|
};
|
|
196
211
|
clientSessions.set(key, session);
|
|
197
212
|
app.debug(`[v2-server] new client session: ${key}`);
|
|
@@ -435,6 +450,188 @@ function createPipelineV2Server(app, state, metricsApi) {
|
|
|
435
450
|
ackTimer = null;
|
|
436
451
|
}
|
|
437
452
|
}
|
|
453
|
+
/**
|
|
454
|
+
* Decrypt and dispatch a METADATA (0x06) packet.
|
|
455
|
+
*
|
|
456
|
+
* The payload envelope is `{ v, kind, seq, idx, total, entries }` where each
|
|
457
|
+
* entry is `{ context, path, meta }`. We convert every entry back into a
|
|
458
|
+
* minimal Signal K delta carrying `updates[].meta[]` so the local Signal K
|
|
459
|
+
* server picks it up through the normal `app.handleMessage` integration
|
|
460
|
+
* point — no special receiver API is needed.
|
|
461
|
+
*/
|
|
462
|
+
async function handleMetadataPacket(parsed, secretKey, session) {
|
|
463
|
+
try {
|
|
464
|
+
const decrypted = (0, crypto_1.decryptBinary)(parsed.payload, secretKey, { stretchAsciiKey });
|
|
465
|
+
const decompressed = (await brotliDecompressAsync(decrypted, {
|
|
466
|
+
maxOutputLength: constants_1.MAX_DECOMPRESSED_SIZE
|
|
467
|
+
}));
|
|
468
|
+
// Count a successful decrypt+decompress as "meta received on the wire"
|
|
469
|
+
// regardless of whether the envelope parses — this mirrors the DATA
|
|
470
|
+
// bandwidth accounting and keeps metaBytesIn useful even when a peer
|
|
471
|
+
// emits malformed envelopes.
|
|
472
|
+
metrics.bandwidth.metaBytesIn = (metrics.bandwidth.metaBytesIn || 0) + parsed.payload.length;
|
|
473
|
+
metrics.bandwidth.metaPacketsIn = (metrics.bandwidth.metaPacketsIn || 0) + 1;
|
|
474
|
+
if (decompressed.length > constants_1.MAX_PARSE_PAYLOAD_SIZE) {
|
|
475
|
+
metrics.malformedPackets = (metrics.malformedPackets || 0) + 1;
|
|
476
|
+
app.error(`[v2] META payload too large to parse: ${decompressed.length} bytes (limit ${constants_1.MAX_PARSE_PAYLOAD_SIZE})`);
|
|
477
|
+
recordError("general", `META payload too large: ${decompressed.length} bytes (limit ${constants_1.MAX_PARSE_PAYLOAD_SIZE})`);
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
let content;
|
|
481
|
+
if (parsed.flags.messagepack) {
|
|
482
|
+
try {
|
|
483
|
+
content = msgpack.decode(decompressed);
|
|
484
|
+
}
|
|
485
|
+
catch {
|
|
486
|
+
content = JSON.parse(decompressed.toString());
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
else {
|
|
490
|
+
content = JSON.parse(decompressed.toString());
|
|
491
|
+
}
|
|
492
|
+
if (!content || typeof content !== "object" || Array.isArray(content)) {
|
|
493
|
+
metrics.malformedPackets = (metrics.malformedPackets || 0) + 1;
|
|
494
|
+
app.debug("v2 META envelope was not an object, dropping");
|
|
495
|
+
recordError("general", "v2 META envelope was not an object");
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
const env = content;
|
|
499
|
+
if (!Array.isArray(env.entries) || env.entries.length === 0) {
|
|
500
|
+
metrics.malformedPackets = (metrics.malformedPackets || 0) + 1;
|
|
501
|
+
app.debug("v2 META envelope has no entries, dropping");
|
|
502
|
+
recordError("general", "v2 META envelope has no entries");
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
// Drop stale/duplicate envelopes that UDP reordered or replayed. The
|
|
506
|
+
// inner envelope `seq` identifies a batch (shared across all chunks of
|
|
507
|
+
// a multi-chunk snapshot/diff); the inner `idx` identifies a specific
|
|
508
|
+
// chunk within that batch. Two-level dedup:
|
|
509
|
+
// - reject envelopes whose seq is behind in uint32 space ("stale")
|
|
510
|
+
// - within the current batch, reject any (seq, idx) pair already
|
|
511
|
+
// processed ("exact replay"); other idx values for the same seq
|
|
512
|
+
// remain accepted so multi-chunk batches still apply in full.
|
|
513
|
+
if (session && typeof env.seq === "number" && Number.isFinite(env.seq)) {
|
|
514
|
+
const envSeq = env.seq >>> 0;
|
|
515
|
+
const envIdx = typeof env.idx === "number" && Number.isFinite(env.idx) ? env.idx >>> 0 : 0;
|
|
516
|
+
// Sender-restart detection: the client's meta sequence counter is
|
|
517
|
+
// initialised to 0 at process start, so an incoming envSeq of 0 with
|
|
518
|
+
// a sufficiently-advanced lastMetaEnvSeq is a strong signal that the
|
|
519
|
+
// peer restarted. The "sufficiently advanced" threshold guards
|
|
520
|
+
// against the first-packet-replay case (lastMetaEnvSeq=0, envSeq=0)
|
|
521
|
+
// which is indistinguishable from a true restart unless prior
|
|
522
|
+
// traffic has pushed the seq above a small reorder window. Backwards
|
|
523
|
+
// reorders of more than META_RESTART_THRESHOLD packets are
|
|
524
|
+
// exceedingly rare in UDP delivery; treating envSeq=0 as a restart
|
|
525
|
+
// only above that threshold keeps the dedup-vs-restart decision
|
|
526
|
+
// unambiguous in the common case.
|
|
527
|
+
if (session.lastMetaEnvSeq !== null &&
|
|
528
|
+
envSeq === 0 &&
|
|
529
|
+
session.lastMetaEnvSeq >= META_RESTART_THRESHOLD) {
|
|
530
|
+
app.debug(`[v2-server] META sender restart detected for ${session.key} ` +
|
|
531
|
+
`(last seq was ${session.lastMetaEnvSeq}); resetting meta state`);
|
|
532
|
+
session.lastMetaEnvSeq = null;
|
|
533
|
+
session.seenMetaChunkIdx.clear();
|
|
534
|
+
session.metaRequested = false;
|
|
535
|
+
}
|
|
536
|
+
if (session.lastMetaEnvSeq !== null) {
|
|
537
|
+
const distance = (envSeq - session.lastMetaEnvSeq) >>> 0;
|
|
538
|
+
if (distance !== 0 && distance >= 0x80000000) {
|
|
539
|
+
metrics.duplicatePackets = (metrics.duplicatePackets || 0) + 1;
|
|
540
|
+
app.debug(`[v2-server] stale META envelope seq=${envSeq} from ${session.key} (last=${session.lastMetaEnvSeq}), dropping`);
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
if (distance !== 0) {
|
|
544
|
+
// New batch: advance and reset the per-batch chunk set.
|
|
545
|
+
session.lastMetaEnvSeq = envSeq;
|
|
546
|
+
session.seenMetaChunkIdx.clear();
|
|
547
|
+
}
|
|
548
|
+
else if (session.seenMetaChunkIdx.has(envIdx)) {
|
|
549
|
+
// Same batch, exact duplicate chunk — drop.
|
|
550
|
+
metrics.duplicatePackets = (metrics.duplicatePackets || 0) + 1;
|
|
551
|
+
app.debug(`[v2-server] duplicate META chunk seq=${envSeq} idx=${envIdx} from ${session.key}, dropping`);
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
else {
|
|
556
|
+
session.lastMetaEnvSeq = envSeq;
|
|
557
|
+
session.seenMetaChunkIdx.clear();
|
|
558
|
+
}
|
|
559
|
+
session.seenMetaChunkIdx.add(envIdx);
|
|
560
|
+
}
|
|
561
|
+
// Group entries by context so the local Signal K server sees one delta
|
|
562
|
+
// per context rather than one per path. Reduces app.handleMessage
|
|
563
|
+
// overhead on big snapshots without changing semantics.
|
|
564
|
+
const nowIso = new Date().toISOString();
|
|
565
|
+
const byContext = new Map();
|
|
566
|
+
for (const rawEntry of env.entries) {
|
|
567
|
+
if (!rawEntry || typeof rawEntry.meta !== "object" || !rawEntry.meta) {
|
|
568
|
+
continue;
|
|
569
|
+
}
|
|
570
|
+
// Require a present path before attempting pathDictionary decode;
|
|
571
|
+
// decodeMetaEntry would otherwise coerce `undefined` into "undefined".
|
|
572
|
+
if (rawEntry.path === null || rawEntry.path === undefined) {
|
|
573
|
+
continue;
|
|
574
|
+
}
|
|
575
|
+
if (typeof rawEntry.path !== "string" && typeof rawEntry.path !== "number") {
|
|
576
|
+
continue;
|
|
577
|
+
}
|
|
578
|
+
const entry = parsed.flags.pathDictionary
|
|
579
|
+
? (0, pathDictionary_1.decodeMetaEntry)(rawEntry)
|
|
580
|
+
: rawEntry;
|
|
581
|
+
const path = typeof entry.path === "string" ? entry.path : String(entry.path ?? "");
|
|
582
|
+
if (!path) {
|
|
583
|
+
continue;
|
|
584
|
+
}
|
|
585
|
+
const context = typeof rawEntry.context === "string" ? rawEntry.context : "vessels.self";
|
|
586
|
+
const bucket = byContext.get(context);
|
|
587
|
+
const metaItem = { path, value: entry.meta };
|
|
588
|
+
if (bucket) {
|
|
589
|
+
bucket.push(metaItem);
|
|
590
|
+
}
|
|
591
|
+
else {
|
|
592
|
+
byContext.set(context, [metaItem]);
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
for (const [context, metaItems] of byContext) {
|
|
596
|
+
const deltaMessage = {
|
|
597
|
+
context,
|
|
598
|
+
updates: [
|
|
599
|
+
{
|
|
600
|
+
timestamp: nowIso,
|
|
601
|
+
values: [],
|
|
602
|
+
meta: metaItems
|
|
603
|
+
}
|
|
604
|
+
]
|
|
605
|
+
};
|
|
606
|
+
app.handleMessage("", deltaMessage);
|
|
607
|
+
}
|
|
608
|
+
app.debug(`v2 meta received: kind=${env.kind ?? "?"}, entries=${env.entries.length}, contexts=${byContext.size}, envSeq=${env.v ?? "?"}`);
|
|
609
|
+
}
|
|
610
|
+
catch (err) {
|
|
611
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
612
|
+
metrics.malformedPackets = (metrics.malformedPackets || 0) + 1;
|
|
613
|
+
app.error(`v2 handleMetadataPacket error: ${msg}`);
|
|
614
|
+
recordError("general", `v2 META decode error: ${msg}`);
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
/**
|
|
618
|
+
* Build and send a META_REQUEST (0x07) control packet to a client.
|
|
619
|
+
* Instructs the client to emit a fresh metadata snapshot — used on first
|
|
620
|
+
* contact from a new session so the receiver doesn't have to wait for the
|
|
621
|
+
* client's periodic resend cycle.
|
|
622
|
+
*/
|
|
623
|
+
async function _sendMetaRequest(session, secretKey) {
|
|
624
|
+
try {
|
|
625
|
+
const packet = packetBuilder.buildMetaRequestPacket({ secretKey });
|
|
626
|
+
await _sendUDP(packet, { address: session.address, port: session.port });
|
|
627
|
+
app.debug(`[v2-server] META_REQUEST sent to ${session.key}`);
|
|
628
|
+
}
|
|
629
|
+
catch (err) {
|
|
630
|
+
// Re-throw so the caller's .catch() records it once, rather than
|
|
631
|
+
// double-logging here.
|
|
632
|
+
throw err;
|
|
633
|
+
}
|
|
634
|
+
}
|
|
438
635
|
/**
|
|
439
636
|
* Send UDP packet to a destination
|
|
440
637
|
* @private
|
|
@@ -519,6 +716,37 @@ function createPipelineV2Server(app, state, metricsApi) {
|
|
|
519
716
|
catch (parseErr) {
|
|
520
717
|
app.error(`v2 failed to parse HELLO payload: ${parseErr instanceof Error ? parseErr.message : String(parseErr)}`);
|
|
521
718
|
}
|
|
719
|
+
// HELLO is the earliest reliable indication of a live peer, so use it
|
|
720
|
+
// as the trigger to demand a fresh metadata snapshot. The client
|
|
721
|
+
// self-rate-limits META_REQUEST responses (5 s window), and we only
|
|
722
|
+
// emit one per session, so this is safe even across rapid reconnects.
|
|
723
|
+
if (session && !session.metaRequested) {
|
|
724
|
+
session.metaRequested = true;
|
|
725
|
+
_sendMetaRequest(session, secretKey).catch((err) => {
|
|
726
|
+
app.debug(`[v2-server] META_REQUEST send failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
727
|
+
});
|
|
728
|
+
}
|
|
729
|
+
return;
|
|
730
|
+
}
|
|
731
|
+
if (parsed.type === packet_1.PacketType.METADATA) {
|
|
732
|
+
// Apply the same per-session rate limit used for DATA so a malformed
|
|
733
|
+
// or hostile peer can't overwhelm the meta decoder path.
|
|
734
|
+
if (session) {
|
|
735
|
+
const now = Date.now();
|
|
736
|
+
if (now - session.rateLimitWindowStart >= constants_1.UDP_RATE_LIMIT_WINDOW) {
|
|
737
|
+
session.rateLimitCount = 0;
|
|
738
|
+
session.rateLimitWindowStart = now;
|
|
739
|
+
}
|
|
740
|
+
session.rateLimitCount++;
|
|
741
|
+
if (session.rateLimitCount > constants_1.UDP_RATE_LIMIT_MAX_PACKETS) {
|
|
742
|
+
metrics.rateLimitedPackets = (metrics.rateLimitedPackets || 0) + 1;
|
|
743
|
+
metrics.bandwidth.metaRateLimitedPackets =
|
|
744
|
+
(metrics.bandwidth.metaRateLimitedPackets || 0) + 1;
|
|
745
|
+
app.debug(`[v2-server] rate limited META from ${session.key}`);
|
|
746
|
+
return;
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
await handleMetadataPacket(parsed, secretKey, session);
|
|
522
750
|
return;
|
|
523
751
|
}
|
|
524
752
|
if (parsed.type !== packet_1.PacketType.DATA) {
|
|
@@ -645,6 +873,12 @@ function createPipelineV2Server(app, state, metricsApi) {
|
|
|
645
873
|
app.debug(`v2 skipping null delta after decoding at index ${i}`);
|
|
646
874
|
continue;
|
|
647
875
|
}
|
|
876
|
+
const sanitizedDelta = (0, delta_sanitizer_1.sanitizeDeltaForSignalK)(deltaMessage);
|
|
877
|
+
if (sanitizedDelta === null) {
|
|
878
|
+
app.debug(`v2 skipping delta with no valid Signal K values at index ${i}`);
|
|
879
|
+
continue;
|
|
880
|
+
}
|
|
881
|
+
deltaMessage = sanitizedDelta;
|
|
648
882
|
_ingestRemoteTelemetry(deltaMessage);
|
|
649
883
|
if (!Array.isArray(deltaMessage.updates) || deltaMessage.updates.length === 0) {
|
|
650
884
|
continue;
|