signalk-edge-link 2.2.1 → 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 +164 -164
- 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/main.f1780db6593b0c07a48c.js +0 -2
- package/public/main.f1780db6593b0c07a48c.js.map +0 -1
package/lib/pipeline.js
CHANGED
|
@@ -35,8 +35,18 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
35
35
|
const msgpack = __importStar(require("@msgpack/msgpack"));
|
|
36
36
|
const crypto_1 = require("./crypto");
|
|
37
37
|
const pathDictionary_1 = require("./pathDictionary");
|
|
38
|
+
const delta_sanitizer_1 = require("./delta-sanitizer");
|
|
38
39
|
const pipeline_utils_1 = require("./pipeline-utils");
|
|
39
40
|
const constants_1 = require("./constants");
|
|
41
|
+
const metadata_1 = require("./metadata");
|
|
42
|
+
/** Leading magic that distinguishes v1 meta payloads from v1 deltas, placed
|
|
43
|
+
* inside the encrypted plaintext so existing v1 receivers (which do not
|
|
44
|
+
* recognise it) simply reject the packet rather than misinterpreting it. */
|
|
45
|
+
const V1_META_MAGIC = Buffer.from("SKM1", "ascii");
|
|
46
|
+
/** Threshold for v1 sender-restart detection — see the v2 server's
|
|
47
|
+
* META_RESTART_THRESHOLD comment. envSeq=0 is treated as a restart only when
|
|
48
|
+
* the last accepted seq has moved beyond this small reorder window. */
|
|
49
|
+
const META_RESTART_THRESHOLD_V1 = 8;
|
|
40
50
|
/**
|
|
41
51
|
* Creates the data processing pipeline (compress, encrypt, send / receive, decrypt, decompress).
|
|
42
52
|
* @param app - SignalK app object (for logging)
|
|
@@ -47,6 +57,12 @@ const constants_1 = require("./constants");
|
|
|
47
57
|
function createPipeline(app, state, metricsApi) {
|
|
48
58
|
const { metrics, recordError, trackPathStats } = metricsApi;
|
|
49
59
|
const setStatus = app.setPluginStatus || app.setProviderStatus || (() => { });
|
|
60
|
+
let metaEnvelopeSeqV1 = 0;
|
|
61
|
+
// Last accepted inner-envelope seq on the receive side. v1 has no
|
|
62
|
+
// per-session concept (one socket per pipeline instance), so a single
|
|
63
|
+
// closure variable is sufficient. Used to drop stale/duplicate envelopes
|
|
64
|
+
// that UDP reorders or replays.
|
|
65
|
+
let lastIngestedMetaEnvSeqV1 = null;
|
|
50
66
|
/**
|
|
51
67
|
* Compresses, encrypts, and sends delta data via UDP.
|
|
52
68
|
* Pipeline: Serialize -> Compress -> Encrypt (AES-256-GCM) -> Send
|
|
@@ -131,6 +147,73 @@ function createPipeline(app, state, metricsApi) {
|
|
|
131
147
|
}
|
|
132
148
|
}
|
|
133
149
|
}
|
|
150
|
+
/**
|
|
151
|
+
* Sends Signal K path metadata to the receiver using the v1 wire format on a
|
|
152
|
+
* separate UDP port.
|
|
153
|
+
*
|
|
154
|
+
* v1 has no packet-type byte so we cannot multiplex meta with deltas on the
|
|
155
|
+
* existing port without breaking every deployed v1 receiver. To keep the
|
|
156
|
+
* change backward-compatible, meta is sent on `udpMetaPort` with a 4-byte
|
|
157
|
+
* `SKM1` magic prefix inside the encrypted plaintext — a v1 receiver that
|
|
158
|
+
* has not been upgraded will fail to JSON-parse the payload and simply drop
|
|
159
|
+
* it without side effects.
|
|
160
|
+
*/
|
|
161
|
+
async function packCryptMeta(entries, kind, secretKey, udpAddress, udpMetaPort) {
|
|
162
|
+
try {
|
|
163
|
+
if (!state.options) {
|
|
164
|
+
app.debug("packCryptMeta called but plugin is stopped, ignoring");
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
if (!udpMetaPort || udpMetaPort <= 0) {
|
|
168
|
+
app.debug("packCryptMeta: no udpMetaPort configured, meta disabled on v1");
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
if (entries.length === 0) {
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
const usePathDict = !!state.options.usePathDictionary;
|
|
175
|
+
const useMsgpack = !!state.options.useMsgpack;
|
|
176
|
+
const maxPerPacket = state.metaConfig?.maxPathsPerPacket ?? 500;
|
|
177
|
+
const chunks = (0, metadata_1.splitIntoPackets)(entries, maxPerPacket);
|
|
178
|
+
const envelopeSeq = metaEnvelopeSeqV1++ >>> 0;
|
|
179
|
+
for (let i = 0; i < chunks.length; i++) {
|
|
180
|
+
const chunk = chunks[i];
|
|
181
|
+
const processed = usePathDict ? chunk.map(pathDictionary_1.encodeMetaEntry) : chunk;
|
|
182
|
+
const envelope = (0, metadata_1.buildMetaEnvelope)(processed, kind, envelopeSeq, i, chunks.length);
|
|
183
|
+
const serialized = (0, pipeline_utils_1.deltaBuffer)(envelope, useMsgpack);
|
|
184
|
+
const withMagic = Buffer.concat([V1_META_MAGIC, serialized]);
|
|
185
|
+
const compressed = await (0, pipeline_utils_1.compressPayload)(withMagic, useMsgpack);
|
|
186
|
+
const packet = (0, crypto_1.encryptBinary)(compressed, secretKey, {
|
|
187
|
+
stretchAsciiKey: !!state.options.stretchAsciiKey
|
|
188
|
+
});
|
|
189
|
+
if (packet.length > constants_1.MAX_SAFE_UDP_PAYLOAD) {
|
|
190
|
+
app.debug(`Warning: v1 meta packet size ${packet.length} bytes exceeds safe MTU (${constants_1.MAX_SAFE_UDP_PAYLOAD})`);
|
|
191
|
+
}
|
|
192
|
+
await udpSendAsync(packet, udpAddress, udpMetaPort);
|
|
193
|
+
metrics.bandwidth.metaBytesOut = (metrics.bandwidth.metaBytesOut || 0) + packet.length;
|
|
194
|
+
metrics.bandwidth.metaPacketsOut = (metrics.bandwidth.metaPacketsOut || 0) + 1;
|
|
195
|
+
metrics.bandwidth.bytesOut += packet.length;
|
|
196
|
+
metrics.bandwidth.packetsOut++;
|
|
197
|
+
}
|
|
198
|
+
if (kind === "snapshot") {
|
|
199
|
+
metrics.bandwidth.metaSnapshotsSent = (metrics.bandwidth.metaSnapshotsSent || 0) + 1;
|
|
200
|
+
}
|
|
201
|
+
else {
|
|
202
|
+
metrics.bandwidth.metaDiffsSent = (metrics.bandwidth.metaDiffsSent || 0) + 1;
|
|
203
|
+
}
|
|
204
|
+
app.debug(`v1 meta sent: kind=${kind}, entries=${entries.length}, chunks=${chunks.length}, envSeq=${envelopeSeq}`);
|
|
205
|
+
}
|
|
206
|
+
catch (error) {
|
|
207
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
208
|
+
app.error(`packCryptMeta error: ${msg}`);
|
|
209
|
+
recordError("general", `packCryptMeta error: ${msg}`);
|
|
210
|
+
// Re-throw so the caller (sendMetaEntries) can tell the send failed
|
|
211
|
+
// and refrain from committing the MetaCache. Without this, a broken
|
|
212
|
+
// socket/encryption/compression would silently suppress every future
|
|
213
|
+
// diff for the affected paths.
|
|
214
|
+
throw error instanceof Error ? error : new Error(msg);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
134
217
|
/**
|
|
135
218
|
* Decompresses, decrypts, and processes received UDP data.
|
|
136
219
|
* Pipeline: Receive -> Decrypt (AES-256-GCM) -> Decompress -> Parse -> Process
|
|
@@ -206,6 +289,11 @@ function createPipeline(app, state, metricsApi) {
|
|
|
206
289
|
app.debug(`Skipping null delta message after decoding at index ${i}`);
|
|
207
290
|
continue;
|
|
208
291
|
}
|
|
292
|
+
deltaMessage = (0, delta_sanitizer_1.sanitizeDeltaForSignalK)(deltaMessage);
|
|
293
|
+
if (deltaMessage === null) {
|
|
294
|
+
app.debug(`Skipping delta with no valid Signal K values at index ${i}`);
|
|
295
|
+
continue;
|
|
296
|
+
}
|
|
209
297
|
// Track path stats for server-side analytics
|
|
210
298
|
trackPathStats(deltaMessage, decompressed.length / deltas.length);
|
|
211
299
|
app.handleMessage("", deltaMessage);
|
|
@@ -267,6 +355,138 @@ function createPipeline(app, state, metricsApi) {
|
|
|
267
355
|
}
|
|
268
356
|
});
|
|
269
357
|
}
|
|
270
|
-
|
|
358
|
+
/**
|
|
359
|
+
* Receive-side counterpart to `packCryptMeta` for v1. Decrypts a packet
|
|
360
|
+
* arrived on `udpMetaPort`, verifies the 4-byte `SKM1` magic inside the
|
|
361
|
+
* plaintext (packets without the magic are dropped — v1 has no packet-type
|
|
362
|
+
* byte, so the magic is the only signal that this is a meta payload and not
|
|
363
|
+
* a corrupted delta), and dispatches each entry as a minimal Signal K delta
|
|
364
|
+
* with `updates[].meta[]` via `app.handleMessage`.
|
|
365
|
+
*/
|
|
366
|
+
async function unpackDecryptMeta(packet, secretKey) {
|
|
367
|
+
try {
|
|
368
|
+
if (!state.options) {
|
|
369
|
+
app.debug("unpackDecryptMeta called but plugin is stopped, ignoring");
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
// Bump bytesIn/packetsIn AND the meta-scoped counters at the same
|
|
373
|
+
// gate — any packet that reached this code is a meta packet (the
|
|
374
|
+
// separate udpMetaPort ensures that), so bytesIn should always equal
|
|
375
|
+
// metaBytesIn for this pipeline path. Keeping them in lockstep lets
|
|
376
|
+
// consumers cross-check: bytesIn === dataBytesIn + metaBytesIn.
|
|
377
|
+
metrics.bandwidth.bytesIn += packet.length;
|
|
378
|
+
metrics.bandwidth.packetsIn++;
|
|
379
|
+
metrics.bandwidth.metaBytesIn = (metrics.bandwidth.metaBytesIn || 0) + packet.length;
|
|
380
|
+
metrics.bandwidth.metaPacketsIn = (metrics.bandwidth.metaPacketsIn || 0) + 1;
|
|
381
|
+
const decrypted = (0, crypto_1.decryptBinary)(packet, secretKey, {
|
|
382
|
+
stretchAsciiKey: !!state.options.stretchAsciiKey
|
|
383
|
+
});
|
|
384
|
+
const decompressed = (await (0, pipeline_utils_1.brotliDecompressAsync)(decrypted, {
|
|
385
|
+
maxOutputLength: constants_1.MAX_DECOMPRESSED_SIZE
|
|
386
|
+
}));
|
|
387
|
+
if (decompressed.length < V1_META_MAGIC.length) {
|
|
388
|
+
app.debug("v1 meta: decompressed payload too short, ignoring");
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
// Reject anything that isn't prefixed with the SKM1 magic so a stray
|
|
392
|
+
// non-meta packet on the meta port (misconfiguration, replay, attacker)
|
|
393
|
+
// cannot be misinterpreted. The magic lives INSIDE the encrypted
|
|
394
|
+
// plaintext, so this check is authenticated.
|
|
395
|
+
if (decompressed.subarray(0, V1_META_MAGIC.length).compare(V1_META_MAGIC) !== 0) {
|
|
396
|
+
app.debug("v1 meta: missing SKM1 magic, dropping");
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
const body = decompressed.subarray(V1_META_MAGIC.length);
|
|
400
|
+
if (body.length > constants_1.MAX_PARSE_PAYLOAD_SIZE) {
|
|
401
|
+
app.error(`v1 meta: payload too large to parse: ${body.length} bytes`);
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
let content;
|
|
405
|
+
if (state.options.useMsgpack) {
|
|
406
|
+
try {
|
|
407
|
+
content = msgpack.decode(body);
|
|
408
|
+
}
|
|
409
|
+
catch {
|
|
410
|
+
content = JSON.parse(body.toString());
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
else {
|
|
414
|
+
content = JSON.parse(body.toString());
|
|
415
|
+
}
|
|
416
|
+
if (!content || typeof content !== "object" || Array.isArray(content)) {
|
|
417
|
+
app.debug("v1 meta: envelope was not an object, dropping");
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
const env = content;
|
|
421
|
+
if (!Array.isArray(env.entries) || env.entries.length === 0) {
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
// Drop stale/duplicate envelopes. The inner envelope `seq` is shared
|
|
425
|
+
// across all chunks of the same batch, so equal-seq chunks are still
|
|
426
|
+
// accepted; only earlier batches are rejected. Uint32-wrap aware so a
|
|
427
|
+
// long-running sender's wrap doesn't trigger mass-rejection.
|
|
428
|
+
//
|
|
429
|
+
// Sender-restart detection: the v1 client's meta envelope counter
|
|
430
|
+
// initialises to 0 at process start. Treat envSeq=0 as a peer restart
|
|
431
|
+
// only once lastIngestedMetaEnvSeqV1 has advanced beyond a small
|
|
432
|
+
// reorder window — below the threshold, envSeq=0 is ambiguous with
|
|
433
|
+
// first-packet replay and falls through to normal dedup.
|
|
434
|
+
if (typeof env.seq === "number" && Number.isFinite(env.seq)) {
|
|
435
|
+
const envSeq = env.seq >>> 0;
|
|
436
|
+
if (lastIngestedMetaEnvSeqV1 !== null &&
|
|
437
|
+
envSeq === 0 &&
|
|
438
|
+
lastIngestedMetaEnvSeqV1 >= META_RESTART_THRESHOLD_V1) {
|
|
439
|
+
app.debug(`v1 meta: sender restart detected (last seq was ${lastIngestedMetaEnvSeqV1}); resetting`);
|
|
440
|
+
lastIngestedMetaEnvSeqV1 = null;
|
|
441
|
+
}
|
|
442
|
+
if (lastIngestedMetaEnvSeqV1 !== null) {
|
|
443
|
+
const distance = (envSeq - lastIngestedMetaEnvSeqV1) >>> 0;
|
|
444
|
+
if (distance !== 0 && distance >= 0x80000000) {
|
|
445
|
+
app.debug(`v1 meta: stale envelope seq=${envSeq} (last=${lastIngestedMetaEnvSeqV1}), dropping`);
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
if (distance !== 0) {
|
|
449
|
+
lastIngestedMetaEnvSeqV1 = envSeq;
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
else {
|
|
453
|
+
lastIngestedMetaEnvSeqV1 = envSeq;
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
const nowIso = new Date().toISOString();
|
|
457
|
+
const usePathDict = !!state.options.usePathDictionary;
|
|
458
|
+
for (const rawEntry of env.entries) {
|
|
459
|
+
if (!rawEntry || typeof rawEntry.meta !== "object" || !rawEntry.meta) {
|
|
460
|
+
continue;
|
|
461
|
+
}
|
|
462
|
+
const entry = usePathDict
|
|
463
|
+
? (0, pathDictionary_1.decodeMetaEntry)(rawEntry)
|
|
464
|
+
: rawEntry;
|
|
465
|
+
const path = typeof entry.path === "string" ? entry.path : String(entry.path ?? "");
|
|
466
|
+
if (!path) {
|
|
467
|
+
continue;
|
|
468
|
+
}
|
|
469
|
+
const context = typeof rawEntry.context === "string" ? rawEntry.context : "vessels.self";
|
|
470
|
+
const delta = {
|
|
471
|
+
context,
|
|
472
|
+
updates: [
|
|
473
|
+
{
|
|
474
|
+
timestamp: nowIso,
|
|
475
|
+
values: [],
|
|
476
|
+
meta: [{ path, value: entry.meta }]
|
|
477
|
+
}
|
|
478
|
+
]
|
|
479
|
+
};
|
|
480
|
+
app.handleMessage("", delta);
|
|
481
|
+
}
|
|
482
|
+
app.debug(`v1 meta received: kind=${env.kind ?? "?"}, entries=${env.entries.length}`);
|
|
483
|
+
}
|
|
484
|
+
catch (error) {
|
|
485
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
486
|
+
app.error(`unpackDecryptMeta error: ${msg}`);
|
|
487
|
+
recordError("general", `unpackDecryptMeta error: ${msg}`);
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
return { packCrypt, packCryptMeta, unpackDecrypt, unpackDecryptMeta };
|
|
271
491
|
}
|
|
272
492
|
module.exports = createPipeline;
|
package/lib/prometheus.js
CHANGED
|
@@ -42,6 +42,10 @@ function formatPrometheusMetrics(metrics, state, extra = {}, opts = {}) {
|
|
|
42
42
|
// Delta counters
|
|
43
43
|
counter("deltas_sent_total", "Total deltas sent", metrics.deltasSent);
|
|
44
44
|
counter("deltas_received_total", "Total deltas received", metrics.deltasReceived);
|
|
45
|
+
counter("data_packets_received_total", "Total v2 data packets received", metrics.dataPacketsReceived || 0);
|
|
46
|
+
counter("rate_limited_packets_total", "Total packets dropped by rate limiting", metrics.rateLimitedPackets || 0);
|
|
47
|
+
counter("dropped_delta_batches_total", "Total delta batches dropped before send", metrics.droppedDeltaBatches || 0);
|
|
48
|
+
counter("dropped_deltas_total", "Total deltas dropped before send", metrics.droppedDeltaCount || 0);
|
|
45
49
|
// Error counters
|
|
46
50
|
counter("udp_send_errors_total", "Total UDP send errors", metrics.udpSendErrors);
|
|
47
51
|
counter("udp_retries_total", "Total UDP send retries", metrics.udpRetries);
|
|
@@ -63,6 +67,13 @@ function formatPrometheusMetrics(metrics, state, extra = {}, opts = {}) {
|
|
|
63
67
|
counter("bytes_in_raw_total", "Total bytes received (raw/uncompressed)", metrics.bandwidth.bytesInRaw);
|
|
64
68
|
counter("packets_out_total", "Total packets sent", metrics.bandwidth.packetsOut);
|
|
65
69
|
counter("packets_in_total", "Total packets received", metrics.bandwidth.packetsIn);
|
|
70
|
+
counter("metadata_bytes_out_total", "Total bytes sent as metadata packets", metrics.bandwidth.metaBytesOut || 0);
|
|
71
|
+
counter("metadata_bytes_in_total", "Total bytes received as metadata packets", metrics.bandwidth.metaBytesIn || 0);
|
|
72
|
+
counter("metadata_packets_out_total", "Total metadata packets sent", metrics.bandwidth.metaPacketsOut || 0);
|
|
73
|
+
counter("metadata_packets_in_total", "Total metadata packets received", metrics.bandwidth.metaPacketsIn || 0);
|
|
74
|
+
counter("metadata_snapshots_sent_total", "Total metadata snapshot envelopes sent", metrics.bandwidth.metaSnapshotsSent || 0);
|
|
75
|
+
counter("metadata_diffs_sent_total", "Total metadata diff envelopes sent", metrics.bandwidth.metaDiffsSent || 0);
|
|
76
|
+
counter("metadata_rate_limited_packets_total", "Total metadata packets dropped by rate limiting", metrics.bandwidth.metaRateLimitedPackets || 0);
|
|
66
77
|
gauge("bandwidth_rate_out_bytes", "Current outbound bandwidth (bytes/s)", metrics.bandwidth.rateOut);
|
|
67
78
|
gauge("bandwidth_rate_in_bytes", "Current inbound bandwidth (bytes/s)", metrics.bandwidth.rateIn);
|
|
68
79
|
gauge("compression_ratio_percent", "Current compression ratio percentage", metrics.bandwidth.compressionRatio);
|
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.validateRuntimeConfigBody = validateRuntimeConfigBody;
|
|
4
|
+
const metadata_1 = require("../metadata");
|
|
5
|
+
/** Upper bound on meta.includePathsMatching length. Same constant as the
|
|
6
|
+
* metadata runtime — mirrored here so the validator rejects patterns at
|
|
7
|
+
* save time rather than letting the runtime silently fall back to
|
|
8
|
+
* allow-all. */
|
|
9
|
+
const META_FILTER_MAX_LENGTH = 256;
|
|
4
10
|
function validateRuntimeConfigBody(filename, body) {
|
|
5
11
|
if (!body || typeof body !== "object" || Array.isArray(body)) {
|
|
6
12
|
return "Request body must be a JSON object";
|
|
@@ -23,6 +29,49 @@ function validateRuntimeConfigBody(filename, body) {
|
|
|
23
29
|
}
|
|
24
30
|
}
|
|
25
31
|
}
|
|
32
|
+
if (body.meta !== undefined) {
|
|
33
|
+
if (body.meta === null || typeof body.meta !== "object" || Array.isArray(body.meta)) {
|
|
34
|
+
return "meta must be an object";
|
|
35
|
+
}
|
|
36
|
+
const m = body.meta;
|
|
37
|
+
if (m.enabled !== undefined && typeof m.enabled !== "boolean") {
|
|
38
|
+
return "meta.enabled must be a boolean";
|
|
39
|
+
}
|
|
40
|
+
if (m.intervalSec !== undefined &&
|
|
41
|
+
(typeof m.intervalSec !== "number" ||
|
|
42
|
+
!Number.isFinite(m.intervalSec) ||
|
|
43
|
+
m.intervalSec < 30 ||
|
|
44
|
+
m.intervalSec > 86400)) {
|
|
45
|
+
return "meta.intervalSec must be a number between 30 and 86400";
|
|
46
|
+
}
|
|
47
|
+
if (m.includePathsMatching !== undefined && m.includePathsMatching !== null) {
|
|
48
|
+
if (typeof m.includePathsMatching !== "string") {
|
|
49
|
+
return "meta.includePathsMatching must be a string or null";
|
|
50
|
+
}
|
|
51
|
+
// Same three checks the runtime applies, hoisted to save-time so
|
|
52
|
+
// persisted subscription.json cannot contain patterns the runtime
|
|
53
|
+
// would silently ignore. Keeps the API and the runtime in agreement.
|
|
54
|
+
if (m.includePathsMatching.length > META_FILTER_MAX_LENGTH) {
|
|
55
|
+
return `meta.includePathsMatching must be at most ${META_FILTER_MAX_LENGTH} characters`;
|
|
56
|
+
}
|
|
57
|
+
if ((0, metadata_1.isLikelyUnsafePathFilter)(m.includePathsMatching)) {
|
|
58
|
+
return "meta.includePathsMatching contains a nested unbounded quantifier (ReDoS shape); refused";
|
|
59
|
+
}
|
|
60
|
+
try {
|
|
61
|
+
new RegExp(m.includePathsMatching);
|
|
62
|
+
}
|
|
63
|
+
catch (err) {
|
|
64
|
+
return `meta.includePathsMatching failed to compile: ${err instanceof Error ? err.message : String(err)}`;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
if (m.maxPathsPerPacket !== undefined &&
|
|
68
|
+
(typeof m.maxPathsPerPacket !== "number" ||
|
|
69
|
+
!Number.isFinite(m.maxPathsPerPacket) ||
|
|
70
|
+
m.maxPathsPerPacket < 10 ||
|
|
71
|
+
m.maxPathsPerPacket > 5000)) {
|
|
72
|
+
return "meta.maxPathsPerPacket must be a number between 10 and 5000";
|
|
73
|
+
}
|
|
74
|
+
}
|
|
26
75
|
}
|
|
27
76
|
else if (filename === "sentence_filter.json") {
|
|
28
77
|
if (body.excludedSentences !== undefined && !Array.isArray(body.excludedSentences)) {
|
package/lib/routes/metrics.js
CHANGED
|
@@ -37,6 +37,7 @@ function register(router, ctx) {
|
|
|
37
37
|
packetLoss: effectiveNetwork.packetLoss,
|
|
38
38
|
retransmissions: effectiveNetwork.retransmissions,
|
|
39
39
|
queueDepth: effectiveNetwork.queueDepth,
|
|
40
|
+
retransmitRate: effectiveNetwork.retransmitRate,
|
|
40
41
|
acksSent: metrics.acksSent || 0,
|
|
41
42
|
naksSent: metrics.naksSent || 0,
|
|
42
43
|
activeLink: effectiveNetwork.activeLink,
|
package/lib/routes.js
CHANGED
|
@@ -289,6 +289,12 @@ function createRoutes(app, instanceRegistry, pluginRef) {
|
|
|
289
289
|
}
|
|
290
290
|
return localVal ?? 0;
|
|
291
291
|
}
|
|
292
|
+
const bondingManager = state.pipeline && state.pipeline.getBondingManager
|
|
293
|
+
? state.pipeline.getBondingManager()
|
|
294
|
+
: null;
|
|
295
|
+
const localActiveLink = bondingManager
|
|
296
|
+
? bondingManager.getActiveLinkName() || "primary"
|
|
297
|
+
: "primary";
|
|
292
298
|
return {
|
|
293
299
|
rtt: selectMetric(remote.rtt, metrics.rtt),
|
|
294
300
|
jitter: selectMetric(remote.jitter, metrics.jitter),
|
|
@@ -296,7 +302,7 @@ function createRoutes(app, instanceRegistry, pluginRef) {
|
|
|
296
302
|
retransmissions: selectMetric(remote.retransmissions, metrics.retransmissions),
|
|
297
303
|
queueDepth: selectMetric(remote.queueDepth, metrics.queueDepth),
|
|
298
304
|
retransmitRate: selectMetric(remote.retransmitRate, clientRetransmitRate),
|
|
299
|
-
activeLink: hasFreshRemote ? (remote.activeLink ?? "primary") :
|
|
305
|
+
activeLink: hasFreshRemote ? (remote.activeLink ?? "primary") : localActiveLink,
|
|
300
306
|
dataSource: hasFreshRemote ? "remote-client" : "local",
|
|
301
307
|
lastUpdate: hasFreshRemote ? remote.lastUpdate : 0
|
|
302
308
|
};
|
|
@@ -336,6 +342,10 @@ function createRoutes(app, instanceRegistry, pluginRef) {
|
|
|
336
342
|
subscriptionErrors: metrics.subscriptionErrors,
|
|
337
343
|
duplicatePackets: metrics.duplicatePackets || 0,
|
|
338
344
|
malformedPackets: metrics.malformedPackets || 0,
|
|
345
|
+
dataPacketsReceived: metrics.dataPacketsReceived || 0,
|
|
346
|
+
rateLimitedPackets: metrics.rateLimitedPackets || 0,
|
|
347
|
+
droppedDeltaBatches: metrics.droppedDeltaBatches || 0,
|
|
348
|
+
droppedDeltaCount: metrics.droppedDeltaCount || 0,
|
|
339
349
|
errorCounts: { ...(metrics.errorCounts || {}) }
|
|
340
350
|
},
|
|
341
351
|
status: {
|
|
@@ -348,6 +358,8 @@ function createRoutes(app, instanceRegistry, pluginRef) {
|
|
|
348
358
|
: metrics.bandwidth.packetsOut;
|
|
349
359
|
const bytes = state.isServerMode ? metrics.bandwidth.bytesIn : metrics.bandwidth.bytesOut;
|
|
350
360
|
const avgPacketSize = packets > 0 ? Math.round(bytes / packets) : 0;
|
|
361
|
+
const metaBytesOut = metrics.bandwidth.metaBytesOut || 0;
|
|
362
|
+
const metaBytesIn = metrics.bandwidth.metaBytesIn || 0;
|
|
351
363
|
return {
|
|
352
364
|
bytesOut: metrics.bandwidth.bytesOut,
|
|
353
365
|
bytesIn: metrics.bandwidth.bytesIn,
|
|
@@ -356,6 +368,7 @@ function createRoutes(app, instanceRegistry, pluginRef) {
|
|
|
356
368
|
bytesOutFormatted: formatBytes(metrics.bandwidth.bytesOut),
|
|
357
369
|
bytesInFormatted: formatBytes(metrics.bandwidth.bytesIn),
|
|
358
370
|
bytesOutRawFormatted: formatBytes(metrics.bandwidth.bytesOutRaw),
|
|
371
|
+
bytesInRawFormatted: formatBytes(metrics.bandwidth.bytesInRaw),
|
|
359
372
|
packetsOut: metrics.bandwidth.packetsOut,
|
|
360
373
|
packetsIn: metrics.bandwidth.packetsIn,
|
|
361
374
|
rateOut: metrics.bandwidth.rateOut,
|
|
@@ -365,6 +378,15 @@ function createRoutes(app, instanceRegistry, pluginRef) {
|
|
|
365
378
|
compressionRatio: metrics.bandwidth.compressionRatio,
|
|
366
379
|
avgPacketSize,
|
|
367
380
|
avgPacketSizeFormatted: avgPacketSize > 0 ? formatBytes(avgPacketSize) : "0 B",
|
|
381
|
+
metaBytesOut,
|
|
382
|
+
metaBytesIn,
|
|
383
|
+
metaBytesOutFormatted: formatBytes(metaBytesOut),
|
|
384
|
+
metaBytesInFormatted: formatBytes(metaBytesIn),
|
|
385
|
+
metaPacketsOut: metrics.bandwidth.metaPacketsOut || 0,
|
|
386
|
+
metaPacketsIn: metrics.bandwidth.metaPacketsIn || 0,
|
|
387
|
+
metaSnapshotsSent: metrics.bandwidth.metaSnapshotsSent || 0,
|
|
388
|
+
metaDiffsSent: metrics.bandwidth.metaDiffsSent || 0,
|
|
389
|
+
metaRateLimitedPackets: metrics.bandwidth.metaRateLimitedPackets || 0,
|
|
368
390
|
history: metrics.bandwidth.history.toArray().slice(-30)
|
|
369
391
|
};
|
|
370
392
|
})(),
|
|
@@ -387,16 +409,15 @@ function createRoutes(app, instanceRegistry, pluginRef) {
|
|
|
387
409
|
packetLoss: effectiveNetwork.packetLoss,
|
|
388
410
|
retransmissions: effectiveNetwork.retransmissions,
|
|
389
411
|
queueDepth: effectiveNetwork.queueDepth,
|
|
412
|
+
retransmitRate: effectiveNetwork.retransmitRate,
|
|
390
413
|
acksSent: metrics.acksSent || 0,
|
|
391
414
|
naksSent: metrics.naksSent || 0,
|
|
415
|
+
activeLink: effectiveNetwork.activeLink,
|
|
392
416
|
dataSource: effectiveNetwork.dataSource
|
|
393
417
|
};
|
|
394
418
|
if (state.isServerMode && effectiveNetwork.lastUpdate > 0) {
|
|
395
419
|
networkData.lastRemoteUpdate = effectiveNetwork.lastUpdate;
|
|
396
420
|
}
|
|
397
|
-
if (state.isServerMode) {
|
|
398
|
-
networkData.activeLink = effectiveNetwork.activeLink;
|
|
399
|
-
}
|
|
400
421
|
const publisher = getActiveMetricsPublisher(state);
|
|
401
422
|
if (publisher) {
|
|
402
423
|
networkData.linkQuality = publisher.calculateLinkQuality({
|