signalk-edge-link 2.7.0 → 2.8.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/config-watcher.js +62 -30
- package/lib/constants.js +32 -2
- package/lib/crypto.js +12 -1
- package/lib/index.js +17 -0
- package/lib/instance.js +241 -42
- package/lib/metadata.js +12 -0
- package/lib/metrics.js +21 -1
- package/lib/packet.js +21 -3
- package/lib/pipeline-v2-client.js +29 -0
- package/lib/pipeline-v2-server.js +53 -4
- package/lib/routes/monitoring.js +38 -4
- package/lib/shared/connection-schema.js +5 -1
- package/lib/source-replication.js +72 -2
- package/lib/source-snapshot.js +87 -2
- package/package.json +165 -165
- package/public/982.a8caac8a5aef99f5a6bf.js +2 -0
- package/public/982.a8caac8a5aef99f5a6bf.js.map +1 -0
- package/public/remoteEntry.js +1 -1
- package/public/remoteEntry.js.map +1 -1
- package/public/982.fb1b6560eada159d88ee.js +0 -2
- package/public/982.fb1b6560eada159d88ee.js.map +0 -1
package/lib/config-watcher.js
CHANGED
|
@@ -53,7 +53,14 @@ const constants_1 = require("./constants");
|
|
|
53
53
|
const { readFile, writeFile, mkdir } = fs_1.promises;
|
|
54
54
|
function createDebouncedConfigHandler(opts) {
|
|
55
55
|
const { name, getFilePath, processConfig, state, instanceId, app, readFallback } = opts;
|
|
56
|
-
|
|
56
|
+
// Serialize concurrent runLoad calls: while one is in flight, a follow-up
|
|
57
|
+
// call awaits its completion and only then evaluates its own work. The
|
|
58
|
+
// previous hash-claim trick had a window between "claim hash" and
|
|
59
|
+
// "processConfig await" in which a second runLoad could observe the new
|
|
60
|
+
// hash, skip, and silently drop a legitimate event if the first one then
|
|
61
|
+
// threw. A simple promise-chain serialization is strictly more correct.
|
|
62
|
+
let runInFlight = null;
|
|
63
|
+
async function runLoadInner() {
|
|
57
64
|
if (state.stopped)
|
|
58
65
|
return;
|
|
59
66
|
let content;
|
|
@@ -72,48 +79,73 @@ function createDebouncedConfigHandler(opts) {
|
|
|
72
79
|
app.debug(`[${instanceId}] ${name} file unchanged, skipping`);
|
|
73
80
|
return;
|
|
74
81
|
}
|
|
75
|
-
// Claim the hash BEFORE awaiting processConfig so a concurrent runLoad
|
|
76
|
-
// (e.g. the initial flush() racing an fs.watch event triggered during
|
|
77
|
-
// startup) sees the hash and skips instead of running processConfig
|
|
78
|
-
// twice. Without this, two processConfigs can both observe the same
|
|
79
|
-
// pre-claim hash and both call subscribe(), leaving leaked listeners
|
|
80
|
-
// for any path whose bus is created between the new subscribe() and
|
|
81
|
-
// the old previousUnsubscribes.forEach() in the second call.
|
|
82
|
-
const previousHash = state.configContentHashes[name];
|
|
83
|
-
function revertHashClaim() {
|
|
84
|
-
if (state.configContentHashes[name] !== contentHash)
|
|
85
|
-
return;
|
|
86
|
-
if (previousHash === undefined) {
|
|
87
|
-
delete state.configContentHashes[name];
|
|
88
|
-
}
|
|
89
|
-
else {
|
|
90
|
-
state.configContentHashes[name] = previousHash;
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
state.configContentHashes[name] = contentHash;
|
|
94
82
|
let parsed;
|
|
95
83
|
try {
|
|
96
84
|
parsed = content ? JSON.parse(content) : readFallback;
|
|
97
85
|
}
|
|
98
86
|
catch (parseErr) {
|
|
99
|
-
// Parse failure
|
|
100
|
-
//
|
|
101
|
-
// silently skipped on the unchanged-hash check.
|
|
102
|
-
revertHashClaim();
|
|
87
|
+
// Parse failure: do not advance the hash so a subsequent file event
|
|
88
|
+
// (presumably with corrected content) is not silently skipped.
|
|
103
89
|
throw parseErr;
|
|
104
90
|
}
|
|
105
91
|
try {
|
|
106
92
|
await processConfig(parsed);
|
|
107
93
|
}
|
|
108
94
|
catch (err) {
|
|
109
|
-
|
|
95
|
+
// Processing failed: leave the previous hash intact so a retry can
|
|
96
|
+
// re-detect the same content as still-pending.
|
|
110
97
|
throw err;
|
|
111
98
|
}
|
|
112
|
-
//
|
|
113
|
-
//
|
|
114
|
-
//
|
|
115
|
-
|
|
116
|
-
|
|
99
|
+
// Only after processConfig completes successfully do we mark this
|
|
100
|
+
// content as the last-known-good. Holding the hash update to the
|
|
101
|
+
// success path means a failed apply does not silently swallow the next
|
|
102
|
+
// identical event.
|
|
103
|
+
if (!state.stopped) {
|
|
104
|
+
state.configContentHashes[name] = contentHash;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
let rerunRequested = false;
|
|
108
|
+
async function runLoad() {
|
|
109
|
+
// Single-producer serialization: only the first caller spawns the
|
|
110
|
+
// runLoadInner loop; later callers set rerunRequested and return,
|
|
111
|
+
// so the producer drains them before clearing runInFlight. A naive
|
|
112
|
+
// "attach + recreate" pattern would let two callers both spawn a
|
|
113
|
+
// fresh runLoadInner and reintroduce overlap.
|
|
114
|
+
if (runInFlight) {
|
|
115
|
+
rerunRequested = true;
|
|
116
|
+
await runInFlight.catch(() => {
|
|
117
|
+
/* errors surface through the caller's .catch */
|
|
118
|
+
});
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
runInFlight = (async () => {
|
|
122
|
+
// Drain queued reruns even if an earlier pass threw — a parse or
|
|
123
|
+
// apply failure must not strand the queued follow-up that
|
|
124
|
+
// arrived between the failure and now. The latest error is
|
|
125
|
+
// reported once draining is complete.
|
|
126
|
+
let lastError;
|
|
127
|
+
while (!state.stopped) {
|
|
128
|
+
rerunRequested = false;
|
|
129
|
+
try {
|
|
130
|
+
await runLoadInner();
|
|
131
|
+
lastError = undefined;
|
|
132
|
+
}
|
|
133
|
+
catch (err) {
|
|
134
|
+
lastError = err;
|
|
135
|
+
}
|
|
136
|
+
if (!rerunRequested) {
|
|
137
|
+
break;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
if (lastError !== undefined) {
|
|
141
|
+
throw lastError;
|
|
142
|
+
}
|
|
143
|
+
})();
|
|
144
|
+
try {
|
|
145
|
+
await runInFlight;
|
|
146
|
+
}
|
|
147
|
+
finally {
|
|
148
|
+
runInFlight = null;
|
|
117
149
|
}
|
|
118
150
|
}
|
|
119
151
|
const handleChange = function () {
|
package/lib/constants.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.
|
|
4
|
-
exports.PACKET_INSPECTOR_MAX_CLIENTS = exports.PACKET_CAPTURE_MAX_PACKETS = exports.MONITORING_ALERT_COOLDOWN = exports.MONITORING_PATH_LATENCY_WINDOW = exports.MONITORING_RETRANSMIT_HISTORY_SIZE = exports.MONITORING_HEATMAP_BUCKET_DURATION = void 0;
|
|
3
|
+
exports.OUTBOUND_DUPLICATE_SUPPRESS_MS = exports.PATH_STATS_MAX_SIZE = exports.BANDWIDTH_HISTORY_MAX = exports.METRICS_PUBLISH_INTERVAL = exports.MAX_PARSE_PAYLOAD_SIZE = exports.MAX_DECOMPRESSED_SIZE = exports.UDP_RATE_LIMIT_MAX_PACKETS = exports.UDP_RATE_LIMIT_WINDOW = exports.MAX_CLIENT_SESSIONS = exports.BONDING_RTT_EMA_ALPHA = exports.BONDING_HEALTH_WINDOW_SIZE = exports.BONDING_FAILBACK_LOSS_HYSTERESIS = exports.BONDING_FAILBACK_RTT_HYSTERESIS = exports.BONDING_HEARTBEAT_TIMEOUT = exports.BONDING_FAILBACK_DELAY = exports.BONDING_LOSS_THRESHOLD = exports.BONDING_RTT_THRESHOLD = exports.BONDING_HEALTH_CHECK_INTERVAL = exports.CONGESTION_DECREASE_FACTOR = exports.CONGESTION_INCREASE_FACTOR = exports.CONGESTION_RTT_MULTIPLIER_HIGH = exports.CONGESTION_LOSS_THRESHOLD_HIGH = exports.CONGESTION_LOSS_THRESHOLD_LOW = exports.CONGESTION_SMOOTHING_FACTOR = exports.CONGESTION_MAX_ADJUSTMENT = exports.CONGESTION_ADJUST_INTERVAL = exports.CONGESTION_TARGET_RTT = exports.CONGESTION_MAX_DELTA_TIMER = exports.CONGESTION_MIN_DELTA_TIMER = exports.RATE_LIMIT_MAX_REQUESTS = exports.RATE_LIMIT_WINDOW = exports.SMART_BATCH_MAX_DELTAS = exports.SMART_BATCH_MIN_DELTAS = exports.SMART_BATCH_INITIAL_ESTIMATE = exports.SMART_BATCH_SMOOTHING = exports.SMART_BATCH_SAFETY_MARGIN = exports.UDP_SEND_TIMEOUT_MS = exports.UDP_RETRY_DELAY = exports.UDP_RETRY_MAX = exports.BROTLI_QUALITY_HIGH = exports.MAX_SAFE_UDP_PAYLOAD = exports.WATCHER_RECOVERY_DELAY = exports.CONTENT_HASH_ALGORITHM = exports.FILE_WATCH_DEBOUNCE_DELAY = exports.MAX_DELTAS_PER_PACKET = exports.DELTA_BUFFER_DROP_RATIO = exports.MAX_DELTAS_BUFFER_SIZE = exports.MILLISECONDS_PER_MINUTE = exports.PING_TIMEOUT_BUFFER = exports.DEFAULT_DELTA_TIMER = void 0;
|
|
4
|
+
exports.PACKET_INSPECTOR_MAX_CLIENTS = exports.PACKET_CAPTURE_MAX_PACKETS = exports.MONITORING_ALERT_COOLDOWN = exports.MONITORING_PATH_LATENCY_WINDOW = exports.MONITORING_RETRANSMIT_HISTORY_SIZE = exports.MONITORING_HEATMAP_BUCKET_DURATION = exports.MONITORING_HEATMAP_BUCKETS = exports.HELLO_PAYLOAD_MAX_BYTES = exports.SNAPSHOT_REPLAY_CHUNK_SIZE = exports.DELTA_SEND_RETRY_BACKOFF_MS = exports.DELTA_SEND_MAX_RETRIES = exports.SOURCE_SNAPSHOT_MAX_OBJECT_KEYS = exports.SOURCE_SNAPSHOT_MAX_ARRAY_LENGTH = exports.SOURCE_SNAPSHOT_MAX_DEPTH = exports.SOURCE_SNAPSHOT_MAX_STRING_LENGTH = exports.SOURCE_SNAPSHOT_MAX_KEY_LENGTH = exports.SOURCE_SNAPSHOT_MAX_PROVIDERS = exports.SOURCE_SNAPSHOT_INTERVAL_MS = exports.SOURCE_REGISTRY_TTL_MS = exports.SOURCE_REGISTRY_MAX_RECORDS = exports.OUTBOUND_DEDUPE_MAX_ENTRIES = exports.OUTBOUND_DEDUPE_CLEANUP_INTERVAL_MS = exports.SUPPRESSED_DUPLICATE_STATS_MAX_SIZE = void 0;
|
|
5
5
|
exports.calculateMaxDeltasPerBatch = calculateMaxDeltasPerBatch;
|
|
6
6
|
// Delta and timing
|
|
7
7
|
exports.DEFAULT_DELTA_TIMER = 1000; // milliseconds
|
|
@@ -66,6 +66,36 @@ exports.MAX_PARSE_PAYLOAD_SIZE = 512 * 1024; // 512 KB
|
|
|
66
66
|
exports.METRICS_PUBLISH_INTERVAL = 1000; // Interval (ms) for publishing metrics to Signal K
|
|
67
67
|
exports.BANDWIDTH_HISTORY_MAX = 60; // Keep 60 data points (5 minutes at 5s intervals)
|
|
68
68
|
exports.PATH_STATS_MAX_SIZE = 500; // Max tracked paths in pathStats Map (prevent unbounded growth)
|
|
69
|
+
// Outbound delta deduplication. signalk-server's subscriptionmanager can
|
|
70
|
+
// deliver the same cached/live pair about one fixed-policy window apart;
|
|
71
|
+
// 1500 ms catches that without suppressing legitimate periodic resends.
|
|
72
|
+
exports.OUTBOUND_DUPLICATE_SUPPRESS_MS = 1500;
|
|
73
|
+
exports.SUPPRESSED_DUPLICATE_STATS_MAX_SIZE = 50;
|
|
74
|
+
exports.OUTBOUND_DEDUPE_CLEANUP_INTERVAL_MS = 1000;
|
|
75
|
+
exports.OUTBOUND_DEDUPE_MAX_ENTRIES = 5000;
|
|
76
|
+
// Source replication registry. 7-day TTL chosen to retain records across a
|
|
77
|
+
// typical maintenance gap; 5000 records covers the busiest production
|
|
78
|
+
// boats observed with N2K source-address rotation churn.
|
|
79
|
+
exports.SOURCE_REGISTRY_MAX_RECORDS = 5000;
|
|
80
|
+
exports.SOURCE_REGISTRY_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
81
|
+
// Source snapshot replication (per-peer source tree)
|
|
82
|
+
exports.SOURCE_SNAPSHOT_INTERVAL_MS = 60000;
|
|
83
|
+
exports.SOURCE_SNAPSHOT_MAX_PROVIDERS = 256;
|
|
84
|
+
exports.SOURCE_SNAPSHOT_MAX_KEY_LENGTH = 128;
|
|
85
|
+
exports.SOURCE_SNAPSHOT_MAX_STRING_LENGTH = 1024;
|
|
86
|
+
exports.SOURCE_SNAPSHOT_MAX_DEPTH = 8;
|
|
87
|
+
// Per-container limits inside a provider — without these, an authorised
|
|
88
|
+
// peer could stay within depth/string rules and still force large
|
|
89
|
+
// allocations by sending one extremely wide array or object.
|
|
90
|
+
exports.SOURCE_SNAPSHOT_MAX_ARRAY_LENGTH = 256;
|
|
91
|
+
exports.SOURCE_SNAPSHOT_MAX_OBJECT_KEYS = 256;
|
|
92
|
+
// Delta send retry behaviour
|
|
93
|
+
exports.DELTA_SEND_MAX_RETRIES = 1;
|
|
94
|
+
exports.DELTA_SEND_RETRY_BACKOFF_MS = 100;
|
|
95
|
+
// Full-status snapshot replay backpressure
|
|
96
|
+
exports.SNAPSHOT_REPLAY_CHUNK_SIZE = 50; // Yield to event loop every N deltas
|
|
97
|
+
// HELLO payload bound (UDP MTU is already a soft cap; reject anything larger)
|
|
98
|
+
exports.HELLO_PAYLOAD_MAX_BYTES = 4096;
|
|
69
99
|
// Enhanced monitoring
|
|
70
100
|
exports.MONITORING_HEATMAP_BUCKETS = 60; // Number of time buckets for packet loss heatmap
|
|
71
101
|
exports.MONITORING_HEATMAP_BUCKET_DURATION = 5000; // Duration of each bucket (5 seconds)
|
package/lib/crypto.js
CHANGED
|
@@ -128,7 +128,9 @@ function normalizeKey(secretKey, options = {}) {
|
|
|
128
128
|
// the raw ASCII key (hex-encoded for Map use) so the plaintext key is not
|
|
129
129
|
// retained in process memory beyond the live derivation. The cache is
|
|
130
130
|
// effectively bounded by the number of distinct configured keys (typically
|
|
131
|
-
// one or two per Signal K instance)
|
|
131
|
+
// one or two per Signal K instance); the LRU cap is a defensive measure
|
|
132
|
+
// against a future caller that passes externally-influenced strings here.
|
|
133
|
+
const ASCII_KEY_CACHE_MAX = 32;
|
|
132
134
|
const asciiKeyCache = new Map();
|
|
133
135
|
function asciiKeyCacheKey(asciiKey) {
|
|
134
136
|
return crypto.createHash("sha256").update(asciiKey, "utf8").digest("hex");
|
|
@@ -137,9 +139,18 @@ function getOrDeriveAsciiKey(asciiKey) {
|
|
|
137
139
|
const cacheKey = asciiKeyCacheKey(asciiKey);
|
|
138
140
|
const cached = asciiKeyCache.get(cacheKey);
|
|
139
141
|
if (cached) {
|
|
142
|
+
// LRU refresh: move to tail by delete-then-set on insertion order Map.
|
|
143
|
+
asciiKeyCache.delete(cacheKey);
|
|
144
|
+
asciiKeyCache.set(cacheKey, cached);
|
|
140
145
|
return cached;
|
|
141
146
|
}
|
|
142
147
|
const derived = deriveKeyFromPassphrase(asciiKey);
|
|
148
|
+
if (asciiKeyCache.size >= ASCII_KEY_CACHE_MAX) {
|
|
149
|
+
const oldest = asciiKeyCache.keys().next();
|
|
150
|
+
if (!oldest.done) {
|
|
151
|
+
asciiKeyCache.delete(oldest.value);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
143
154
|
asciiKeyCache.set(cacheKey, derived);
|
|
144
155
|
return derived;
|
|
145
156
|
}
|
package/lib/index.js
CHANGED
|
@@ -165,6 +165,23 @@ module.exports = function createPlugin(app) {
|
|
|
165
165
|
// ── Start rate limiting ───────────────────────────────────────────────
|
|
166
166
|
routes.startRateLimitCleanup();
|
|
167
167
|
// ── Create and start instances ────────────────────────────────────────
|
|
168
|
+
// Emit a security warning specifically for v2 connections so operators
|
|
169
|
+
// running on untrusted networks notice the gap and migrate to v3. v2
|
|
170
|
+
// control packets (HEARTBEAT/HELLO/META_REQUEST/FULL_STATUS_REQUEST/
|
|
171
|
+
// ACK/NAK) are CRC-only and forgeable; v3 adds HMAC authentication.
|
|
172
|
+
// v1 is logged at debug level — it is the documented legacy default and
|
|
173
|
+
// has a wholly different threat model (no reliable transport at all).
|
|
174
|
+
for (const cfg of connectionList) {
|
|
175
|
+
const proto = (cfg.protocolVersion ?? 1);
|
|
176
|
+
if (proto === 2) {
|
|
177
|
+
app.error(`[security] Connection "${cfg.name}" uses protocol v2; control frames are NOT authenticated (CRC-only) ` +
|
|
178
|
+
`and can be forged by any host that reaches the UDP port. Migrate to protocolVersion: 3 for any link exposed ` +
|
|
179
|
+
`to untrusted networks. See docs/protocol-v2-spec.md §5.`);
|
|
180
|
+
}
|
|
181
|
+
else if (proto < 2) {
|
|
182
|
+
app.debug(`[security] Connection "${cfg.name}" uses legacy protocol v${proto}; see docs/protocol-v2-spec.md for v3 migration.`);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
168
185
|
const usedIds = new Set();
|
|
169
186
|
for (const cfg of connectionList) {
|
|
170
187
|
const instanceId = generateInstanceId(cfg.name, usedIds);
|