signalk-edge-link 2.6.0 → 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 +1 -48
- 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 +0 -7
- package/package.json +165 -165
- 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.078efbd502a09820e418.js +0 -2
- package/public/982.078efbd502a09820e418.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,
|
|
@@ -252,16 +251,8 @@ function createInstance(app, options, instanceId, pluginId, onStatusChange) {
|
|
|
252
251
|
if (entries.length === 0) {
|
|
253
252
|
return false;
|
|
254
253
|
}
|
|
255
|
-
const protoVer = options.protocolVersion ?? 2;
|
|
256
254
|
try {
|
|
257
|
-
if (
|
|
258
|
-
if (!options.udpMetaPort || options.udpMetaPort <= 0) {
|
|
259
|
-
app.debug(`[${instanceId}] Meta skipped: v1 pipeline requires 'udpMetaPort' in connection config`);
|
|
260
|
-
return false;
|
|
261
|
-
}
|
|
262
|
-
await getV1Pipeline().packCryptMeta(entries, kind, options.secretKey, options.udpAddress, options.udpMetaPort);
|
|
263
|
-
}
|
|
264
|
-
else if (state.pipeline && typeof state.pipeline.sendMetadata === "function") {
|
|
255
|
+
if (state.pipeline && typeof state.pipeline.sendMetadata === "function") {
|
|
265
256
|
await state.pipeline.sendMetadata(entries, kind, options.secretKey, options.udpAddress, options.udpPort);
|
|
266
257
|
}
|
|
267
258
|
else {
|
|
@@ -874,34 +865,6 @@ function createInstance(app, options, instanceId, pluginId, onStatusChange) {
|
|
|
874
865
|
getV1Pipeline().unpackDecrypt(delta, options.secretKey);
|
|
875
866
|
});
|
|
876
867
|
app.debug(`[${instanceId}] [v1] Server pipeline initialized`);
|
|
877
|
-
// v1 has no packet-type byte, so meta is streamed on a separate UDP
|
|
878
|
-
// port by the client. Bind that port here when the operator has opted
|
|
879
|
-
// in. If `udpMetaPort` is unset we simply don't listen — keeping the
|
|
880
|
-
// receive side idle is the correct default for existing v1 peers that
|
|
881
|
-
// don't know about meta.
|
|
882
|
-
if (typeof options.udpMetaPort === "number" && options.udpMetaPort > 0) {
|
|
883
|
-
if (options.udpMetaPort === options.udpPort) {
|
|
884
|
-
app.error(`[${instanceId}] [v1] udpMetaPort (${options.udpMetaPort}) must differ from udpPort; meta disabled`);
|
|
885
|
-
}
|
|
886
|
-
else {
|
|
887
|
-
const metaSocket = dgram_1.default.createSocket({ type: "udp4", reuseAddr: true });
|
|
888
|
-
state.metaSocketUdp = metaSocket;
|
|
889
|
-
metaSocket.on("message", (msg) => {
|
|
890
|
-
getV1Pipeline()
|
|
891
|
-
.unpackDecryptMeta(msg, options.secretKey)
|
|
892
|
-
.catch((err) => {
|
|
893
|
-
const m = err instanceof Error ? err.message : String(err);
|
|
894
|
-
app.debug(`[${instanceId}] [v1] meta decrypt failed: ${m}`);
|
|
895
|
-
});
|
|
896
|
-
});
|
|
897
|
-
metaSocket.on("error", (err) => {
|
|
898
|
-
app.error(`[${instanceId}] [v1] meta socket error: ${err.message} (${err.code})`);
|
|
899
|
-
recordError("udpSend", `v1 meta socket error: ${err.message} (${err.code})`);
|
|
900
|
-
});
|
|
901
|
-
metaSocket.bind(options.udpMetaPort);
|
|
902
|
-
app.debug(`[${instanceId}] [v1] Meta listener bound to UDP :${options.udpMetaPort}`);
|
|
903
|
-
}
|
|
904
|
-
}
|
|
905
868
|
}
|
|
906
869
|
const startupSocket = state.socketUdp;
|
|
907
870
|
await new Promise((resolve, reject) => {
|
|
@@ -1312,16 +1275,6 @@ function createInstance(app, options, instanceId, pluginId, onStatusChange) {
|
|
|
1312
1275
|
state.socketUdp = null;
|
|
1313
1276
|
app.debug(`[${instanceId}] Stopped`);
|
|
1314
1277
|
}
|
|
1315
|
-
if (state.metaSocketUdp) {
|
|
1316
|
-
try {
|
|
1317
|
-
state.metaSocketUdp.close();
|
|
1318
|
-
}
|
|
1319
|
-
catch (err) {
|
|
1320
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
1321
|
-
app.debug(`[${instanceId}] Meta socket close failed: ${msg}`);
|
|
1322
|
-
}
|
|
1323
|
-
state.metaSocketUdp = null;
|
|
1324
|
-
}
|
|
1325
1278
|
_setStatus("Stopped", false);
|
|
1326
1279
|
}
|
|
1327
1280
|
// ── Public API ────────────────────────────────────────────────────────────
|
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;
|
package/lib/routes/config.js
CHANGED
|
@@ -187,6 +187,7 @@ function register(router, ctx) {
|
|
|
187
187
|
error: error instanceof Error ? error.message : String(error)
|
|
188
188
|
});
|
|
189
189
|
}
|
|
190
|
+
connectionList = connectionList.map((connection) => (0, connection_config_1.sanitizeConnectionConfig)(connection));
|
|
190
191
|
for (let index = 0; index < connectionList.length; index++) {
|
|
191
192
|
const prefix = connectionList.length > 1 ? `connections[${index}].` : "";
|
|
192
193
|
const validationError = (0, connection_config_1.validateConnectionConfig)(connectionList[index], prefix);
|
|
@@ -213,7 +214,7 @@ function register(router, ctx) {
|
|
|
213
214
|
}
|
|
214
215
|
}
|
|
215
216
|
const finalConfig = {
|
|
216
|
-
connections: connectionList
|
|
217
|
+
connections: connectionList
|
|
217
218
|
};
|
|
218
219
|
if (resolvedManagementToken !== undefined) {
|
|
219
220
|
finalConfig.managementApiToken = resolvedManagementToken;
|
|
@@ -191,12 +191,13 @@ function register(router, ctx) {
|
|
|
191
191
|
if (!body.name) {
|
|
192
192
|
return res.status(400).json({ error: "Missing required field 'name'" });
|
|
193
193
|
}
|
|
194
|
-
const
|
|
194
|
+
const sanitized = (0, connection_config_1.sanitizeConnectionConfig)(body);
|
|
195
|
+
const validationError = (0, connection_config_1.validateConnectionConfig)(sanitized);
|
|
195
196
|
if (validationError) {
|
|
196
197
|
return res.status(400).json({ error: validationError });
|
|
197
198
|
}
|
|
198
199
|
const connections = getCurrentConnectionsConfig();
|
|
199
|
-
connections.push(
|
|
200
|
+
connections.push(sanitized);
|
|
200
201
|
const portError = (0, connection_config_1.validateUniqueServerPorts)(connections);
|
|
201
202
|
if (portError) {
|
|
202
203
|
return res.status(400).json({ error: portError });
|
|
@@ -237,15 +238,11 @@ function register(router, ctx) {
|
|
|
237
238
|
const mutableAllowed = new Set([
|
|
238
239
|
"name",
|
|
239
240
|
"protocolVersion",
|
|
240
|
-
"udpMetaPort",
|
|
241
241
|
"useMsgpack",
|
|
242
242
|
"usePathDictionary",
|
|
243
243
|
"enableNotifications",
|
|
244
244
|
"udpAddress",
|
|
245
245
|
"helloMessageSender",
|
|
246
|
-
"testAddress",
|
|
247
|
-
"testPort",
|
|
248
|
-
"pingIntervalTime",
|
|
249
246
|
"reliability",
|
|
250
247
|
"congestionControl",
|
|
251
248
|
"bonding",
|
|
@@ -264,11 +261,12 @@ function register(router, ctx) {
|
|
|
264
261
|
}
|
|
265
262
|
}
|
|
266
263
|
const mergedConnection = { ...connections[idx], ...patch };
|
|
267
|
-
const
|
|
264
|
+
const sanitizedConnection = (0, connection_config_1.sanitizeConnectionConfig)(mergedConnection);
|
|
265
|
+
const validationError = (0, connection_config_1.validateConnectionConfig)(sanitizedConnection);
|
|
268
266
|
if (validationError) {
|
|
269
267
|
return res.status(400).json({ error: validationError });
|
|
270
268
|
}
|
|
271
|
-
connections[idx] =
|
|
269
|
+
connections[idx] = sanitizedConnection;
|
|
272
270
|
const portError = (0, connection_config_1.validateUniqueServerPorts)(connections);
|
|
273
271
|
if (portError) {
|
|
274
272
|
return res.status(400).json({ error: portError });
|
|
@@ -46,13 +46,6 @@ exports.commonConnectionProperties = {
|
|
|
46
46
|
minimum: 1024,
|
|
47
47
|
maximum: 65535
|
|
48
48
|
},
|
|
49
|
-
udpMetaPort: {
|
|
50
|
-
type: "integer",
|
|
51
|
-
title: "v1 Metadata UDP Port",
|
|
52
|
-
description: "Optional separate UDP port for v1 metadata packets; ignored by v2/v3.",
|
|
53
|
-
minimum: 1024,
|
|
54
|
-
maximum: 65535
|
|
55
|
-
},
|
|
56
49
|
secretKey: {
|
|
57
50
|
type: "string",
|
|
58
51
|
title: "Encryption Key",
|