yakmesh 2.8.2 → 3.0.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/CHANGELOG.md +637 -0
- package/CONTRIBUTING.md +42 -0
- package/Caddyfile +77 -0
- package/README.md +119 -29
- package/adapters/adapter-mlv-bible/README.md +124 -0
- package/adapters/adapter-mlv-bible/index.js +400 -0
- package/adapters/chat-mod-adapter.js +532 -0
- package/adapters/content-adapter.js +273 -0
- package/content/api.js +50 -41
- package/content/index.js +2 -2
- package/content/store.js +355 -173
- package/dashboard/index.html +19 -3
- package/database/replication.js +117 -37
- package/docs/CRYPTO-AGILITY.md +204 -0
- package/docs/MTLS-RESEARCH.md +367 -0
- package/docs/NAMCHE-SPEC.md +681 -0
- package/docs/PEERQUANTA-YAKMESH-INTEGRATION.md +407 -0
- package/docs/PRECISION-DISCLOSURE.md +96 -0
- package/docs/README.md +76 -0
- package/docs/ROADMAP-2.4.0.md +447 -0
- package/docs/ROADMAP-2.5.0.md +244 -0
- package/docs/SECURITY-AUDIT-REPORT.md +306 -0
- package/docs/SST-INTEGRATION.md +712 -0
- package/docs/STEADYWATCH-IMPLEMENTATION.md +303 -0
- package/docs/TERNARY-AUDIT-REPORT.md +247 -0
- package/docs/TME-FAQ.md +221 -0
- package/docs/WHITEPAPER.md +623 -0
- package/docs/adapters.html +1001 -0
- package/docs/advanced-systems.html +1045 -0
- package/docs/annex.html +1046 -0
- package/docs/api.html +970 -0
- package/docs/business/response-templates.md +160 -0
- package/docs/c2c.html +1225 -0
- package/docs/cli.html +1332 -0
- package/docs/configuration.html +1248 -0
- package/docs/darshan.html +1085 -0
- package/docs/dharma.html +966 -0
- package/docs/docs-bundle.html +1075 -0
- package/docs/docs.css +3120 -0
- package/docs/docs.js +556 -0
- package/docs/doko.html +969 -0
- package/docs/geo-proof.html +858 -0
- package/docs/getting-started.html +840 -0
- package/docs/gumba-tutorial.html +1144 -0
- package/docs/gumba.html +1098 -0
- package/docs/index.html +914 -0
- package/docs/jhilke.html +1312 -0
- package/docs/karma.html +1100 -0
- package/docs/katha.html +1037 -0
- package/docs/lama.html +978 -0
- package/docs/mandala.html +1067 -0
- package/docs/mani.html +964 -0
- package/docs/mantra.html +967 -0
- package/docs/mesh.html +1409 -0
- package/docs/nakpak.html +869 -0
- package/docs/namche.html +928 -0
- package/docs/nav-order.json +53 -0
- package/docs/prahari.html +1043 -0
- package/docs/prism-bash.min.js +1 -0
- package/docs/prism-javascript.min.js +1 -0
- package/docs/prism-json.min.js +1 -0
- package/docs/prism-tomorrow.min.css +1 -0
- package/docs/prism.min.js +1 -0
- package/docs/privacy.html +699 -0
- package/docs/quick-reference.html +1181 -0
- package/docs/sakshi.html +1402 -0
- package/docs/sandboxing.md +386 -0
- package/docs/seva.html +911 -0
- package/docs/sherpa.html +871 -0
- package/docs/studio.html +860 -0
- package/docs/stupa.html +995 -0
- package/docs/tailwind.min.css +2 -0
- package/docs/tattva.html +1332 -0
- package/docs/terms.html +686 -0
- package/docs/time-server-deployment.md +166 -0
- package/docs/time-sources.html +1392 -0
- package/docs/tivra.html +1127 -0
- package/docs/trademark-policy.html +686 -0
- package/docs/tribhuj.html +1183 -0
- package/docs/trust-security.html +1029 -0
- package/docs/tutorials/backup-recovery.html +654 -0
- package/docs/tutorials/dashboard.html +604 -0
- package/docs/tutorials/domain-setup.html +605 -0
- package/docs/tutorials/host-website.html +456 -0
- package/docs/tutorials/mesh-network.html +505 -0
- package/docs/tutorials/mobile-access.html +445 -0
- package/docs/tutorials/privacy.html +467 -0
- package/docs/tutorials/raspberry-pi.html +600 -0
- package/docs/tutorials/security-basics.html +539 -0
- package/docs/tutorials/share-files.html +431 -0
- package/docs/tutorials/troubleshooting.html +637 -0
- package/docs/tutorials/trust-karma.html +419 -0
- package/docs/tutorials/yak-protocol.html +456 -0
- package/docs/tutorials.html +1034 -0
- package/docs/vani.html +1270 -0
- package/docs/webserver.html +809 -0
- package/docs/yak-protocol.html +940 -0
- package/docs/yak-timeserver-design.md +475 -0
- package/docs/yakapp.html +1015 -0
- package/docs/ypc27.html +1069 -0
- package/docs/yurt.html +1344 -0
- package/embedded-docs/bundle.js +334 -74
- package/gossip/protocol.js +247 -27
- package/identity/key-resolver.js +262 -0
- package/identity/machine-seed.js +632 -0
- package/identity/node-key.js +669 -368
- package/identity/tribhuj-ratchet.js +506 -0
- package/knowledge-base.js +37 -8
- package/launcher/yakmesh.bat +62 -0
- package/launcher/yakmesh.sh +70 -0
- package/mesh/annex.js +462 -108
- package/mesh/beacon-broadcast.js +113 -1
- package/mesh/darshan.js +1718 -0
- package/mesh/gumba.js +1567 -0
- package/mesh/jhilke.js +651 -0
- package/mesh/katha.js +1012 -0
- package/mesh/nakpak-routing.js +8 -5
- package/mesh/network.js +724 -34
- package/mesh/pulse-sync.js +4 -1
- package/mesh/rate-limiter.js +127 -15
- package/mesh/seva.js +526 -0
- package/mesh/sherpa-discovery.js +89 -8
- package/mesh/sybil-defense.js +19 -5
- package/mesh/temporal-encoder.js +4 -3
- package/mesh/vani.js +1364 -0
- package/mesh/yurt.js +1340 -0
- package/models/entropy-sentinel.onnx +0 -0
- package/models/karma-trust.onnx +0 -0
- package/models/manifest.json +43 -0
- package/models/sakshi-anomaly.onnx +0 -0
- package/oracle/code-proof-protocol.js +7 -6
- package/oracle/codebase-lock.js +257 -28
- package/oracle/index.js +74 -15
- package/oracle/ma902-snmp.js +678 -0
- package/oracle/module-sealer.js +5 -3
- package/oracle/network-identity.js +16 -0
- package/oracle/packet-checksum.js +201 -0
- package/oracle/sst.js +579 -0
- package/oracle/ternary-144t.js +714 -0
- package/oracle/ternary-ml.js +481 -0
- package/oracle/time-api.js +239 -0
- package/oracle/time-source.js +137 -47
- package/oracle/validation-oracle-hardened.js +1111 -1071
- package/oracle/validation-oracle.js +4 -2
- package/oracle/ypc27.js +211 -0
- package/package.json +20 -3
- package/protocol/yak-handler.js +35 -9
- package/protocol/yak-protocol.js +28 -13
- package/reference/cpp/yakmesh_mceliece_shard.cpp +168 -0
- package/reference/cpp/yakmesh_ypc27.cpp +179 -0
- package/sbom.json +87 -0
- package/scripts/security-audit.mjs +264 -0
- package/scripts/update-docs-nav.js +194 -0
- package/scripts/update-docs-sidebar.cjs +164 -0
- package/security/crypto-config.js +4 -3
- package/security/dharma-moderation.js +517 -0
- package/security/doko-identity.js +193 -143
- package/security/domain-consensus.js +86 -85
- package/security/fs-hardening.js +620 -0
- package/security/hardware-attestation.js +5 -3
- package/security/hybrid-trust.js +227 -87
- package/security/karma-rate-limiter.js +692 -0
- package/security/khata-protocol.js +22 -21
- package/security/khata-trust-integration.js +277 -150
- package/security/memory-safety.js +635 -0
- package/security/mesh-auth.js +11 -10
- package/security/mesh-revocation.js +373 -5
- package/security/namche-gateway.js +298 -69
- package/security/sakshi.js +460 -3
- package/security/sangha.js +770 -0
- package/security/secure-config.js +473 -0
- package/security/silicon-parity.js +13 -10
- package/security/steadywatch.js +1142 -0
- package/security/strike-system.js +32 -3
- package/security/temporal-signing.js +488 -0
- package/security/trit-commitment.js +464 -0
- package/server/crypto/annex.js +247 -0
- package/server/darshan-api.js +343 -0
- package/server/index.js +3259 -362
- package/server/komm-api.js +668 -0
- package/utils/accel.js +2273 -0
- package/utils/ternary-id.js +79 -0
- package/utils/verify-worker.js +57 -0
- package/webserver/index.js +95 -5
- package/assets/yakmesh-logo.png +0 -0
- package/assets/yakmesh-logo.svg +0 -80
- package/assets/yakmesh-logo2.png +0 -0
- package/assets/yakmesh-logo2sm.png +0 -0
- package/assets/ymsm.png +0 -0
- package/website/assets/silhouettes/adapters.svg +0 -107
- package/website/assets/silhouettes/api-endpoints.svg +0 -115
- package/website/assets/silhouettes/atomic-clock.svg +0 -83
- package/website/assets/silhouettes/base-camp.svg +0 -81
- package/website/assets/silhouettes/bridge.svg +0 -69
- package/website/assets/silhouettes/docs-bundle.svg +0 -113
- package/website/assets/silhouettes/doko-basket.svg +0 -70
- package/website/assets/silhouettes/fortress.svg +0 -93
- package/website/assets/silhouettes/gateway.svg +0 -54
- package/website/assets/silhouettes/gears.svg +0 -93
- package/website/assets/silhouettes/globe-satellite.svg +0 -67
- package/website/assets/silhouettes/karma-wheel.svg +0 -137
- package/website/assets/silhouettes/lama-council.svg +0 -141
- package/website/assets/silhouettes/mandala-network.svg +0 -169
- package/website/assets/silhouettes/mani-stones.svg +0 -149
- package/website/assets/silhouettes/mantra-wheel.svg +0 -116
- package/website/assets/silhouettes/mesh-nodes.svg +0 -113
- package/website/assets/silhouettes/nakpak.svg +0 -56
- package/website/assets/silhouettes/peak-lightning.svg +0 -73
- package/website/assets/silhouettes/sherpa.svg +0 -69
- package/website/assets/silhouettes/stupa-tower.svg +0 -119
- package/website/assets/silhouettes/tattva-eye.svg +0 -78
- package/website/assets/silhouettes/terminal.svg +0 -74
- package/website/assets/silhouettes/webserver.svg +0 -145
- package/website/assets/silhouettes/yak.svg +0 -78
- package/website/assets/yakmesh-logo.png +0 -0
- package/website/assets/yakmesh-logo.webp +0 -0
- package/website/assets/yakmesh-logo128x140.webp +0 -0
- package/website/assets/yakmesh-logo2.png +0 -0
- package/website/assets/yakmesh-logo2.svg +0 -51
- package/website/assets/yakmesh-logo40x44.webp +0 -0
- package/website/assets/yakmesh.gif +0 -0
- package/website/assets/yakmesh.ico +0 -0
- package/website/assets/yakmesh.jpg +0 -0
- package/website/assets/yakmesh.pdf +0 -0
- package/website/assets/yakmesh.png +0 -0
- package/website/assets/yakmesh.svg +0 -70
- package/website/assets/yakmesh128.webp +0 -0
- package/website/assets/yakmesh32.png +0 -0
- package/website/assets/yakmesh32.svg +0 -65
- package/website/assets/yakmesh32o.ico +0 -2
- package/website/assets/yakmesh32o.svg +0 -65
- package/website/assets/yakmesh32o.svgz +0 -0
package/mesh/network.js
CHANGED
|
@@ -16,16 +16,41 @@
|
|
|
16
16
|
* ║ PROTOCOL PHILOSOPHY: ║
|
|
17
17
|
* ║ "Sacred geometry binds us" - Structure creates resilience ║
|
|
18
18
|
* ║ ║
|
|
19
|
+
* ║ SECURITY POLICY (2026-02-11): ║
|
|
20
|
+
* ║ ALL peer-to-peer communications MUST use ANNEX encryption. ║
|
|
21
|
+
* ║ - ML-KEM-768 key exchange on connection ║
|
|
22
|
+
* ║ - AES-256-GCM for message encryption ║
|
|
23
|
+
* ║ - No plaintext on wire between nodes ║
|
|
24
|
+
* ║ ║
|
|
19
25
|
* ╚═══════════════════════════════════════════════════════════════════════════════╝
|
|
20
26
|
*
|
|
21
27
|
* MANDALA Mesh Protocol
|
|
22
28
|
* WebSocket-based peer-to-peer communication forming sacred network geometry
|
|
29
|
+
* Encrypted via ANNEX (Autonomous Network Negotiated Encrypted eXchange)
|
|
23
30
|
*/
|
|
24
31
|
|
|
25
32
|
import { WebSocketServer, WebSocket } from 'ws';
|
|
33
|
+
import { networkInterfaces } from 'os';
|
|
26
34
|
import { ConnectionRateLimiter } from './rate-limiter.js';
|
|
27
35
|
import { createLogger } from '../utils/logger.js';
|
|
28
36
|
|
|
37
|
+
// ANNEX - Autonomous Network Negotiated Encrypted eXchange
|
|
38
|
+
// PQ-encrypted point-to-point communication between mesh peers
|
|
39
|
+
import { Annex } from './annex.js';
|
|
40
|
+
|
|
41
|
+
// JHILKE — Just Hidden In-band Legitimate Key Exchange (झिल्के — cricket chirps)
|
|
42
|
+
// Deterministic bootstrap + steganographic rekey coordination
|
|
43
|
+
import { JhilkeCoordinator } from './jhilke.js';
|
|
44
|
+
|
|
45
|
+
// MessageValidator + SafeJsonParser — size limits, depth checks, proto pollution guard
|
|
46
|
+
import MessageValidator, { SafeJsonParser } from './message-validator.js';
|
|
47
|
+
|
|
48
|
+
// TRIBHUJ Key Ratchet — trinary rotating keypairs with gateway attestation
|
|
49
|
+
import { TribhujRatchet, GatewayAttestation } from '../identity/tribhuj-ratchet.js';
|
|
50
|
+
|
|
51
|
+
/** Extract unique peer suffix from nodeId (e.g. 'node-net-name-pq-kEEU' → 'kEEU') */
|
|
52
|
+
const peerTag = (id) => id?.split('-pq-').pop() || id?.slice?.(-8) || String(id);
|
|
53
|
+
|
|
29
54
|
const log = createLogger('mandala:network');
|
|
30
55
|
|
|
31
56
|
/**
|
|
@@ -63,9 +88,12 @@ export class MandalaNetwork {
|
|
|
63
88
|
this.identity = identity;
|
|
64
89
|
this.config = {
|
|
65
90
|
wsPort: config.wsPort || 9001,
|
|
66
|
-
maxPeers: config.maxPeers || 10,
|
|
67
91
|
pingInterval: config.pingInterval || 30000,
|
|
68
92
|
portRetries: config.portRetries || 10, // Try up to 10 sequential ports
|
|
93
|
+
// Max peers allowed in HELLO/WELCOME handshake simultaneously.
|
|
94
|
+
// Total connected peers is UNBOUNDED — the mesh scales freely.
|
|
95
|
+
// This only gates the handshake window to prevent Sybil flood attacks.
|
|
96
|
+
maxConcurrentHandshakes: config.maxConcurrentHandshakes || 50,
|
|
69
97
|
...config,
|
|
70
98
|
};
|
|
71
99
|
|
|
@@ -76,15 +104,55 @@ export class MandalaNetwork {
|
|
|
76
104
|
this.networkId = config.networkId || null;
|
|
77
105
|
this.networkFingerprint = config.networkFingerprint || null;
|
|
78
106
|
|
|
107
|
+
// Oracle code hash for JHILKE bootstrap key derivation
|
|
108
|
+
this.codeHash = config.codeHash || null;
|
|
109
|
+
|
|
79
110
|
this.server = null;
|
|
80
111
|
this.peers = new Map(); // nodeId -> { ws, identity, lastSeen }
|
|
81
112
|
this.knownNodes = new Map(); // nodeId -> { endpoint, identity }
|
|
82
113
|
this.messageHandlers = new Map();
|
|
83
114
|
this.seenMessages = new Set(); // For gossip deduplication
|
|
84
115
|
|
|
116
|
+
// ANNEX - PQ-encrypted point-to-point channels
|
|
117
|
+
// Initialized after start() when identity is available
|
|
118
|
+
this.annex = null;
|
|
119
|
+
|
|
120
|
+
// TRIBHUJ ratchet - trinary rotating keypairs for forward secrecy
|
|
121
|
+
this.ratchet = null;
|
|
122
|
+
this.gateway = null; // Gateway attestation for gossip verify-once
|
|
123
|
+
|
|
124
|
+
// Track peer ratchet states (their announced TRIBHUJ public keys)
|
|
125
|
+
this.peerRatchets = new Map(); // nodeId -> { currentPubKey, previousPubKey, epoch }
|
|
126
|
+
|
|
85
127
|
// Rate limiter for connection/message flood protection
|
|
86
128
|
this.rateLimiter = new ConnectionRateLimiter(config.rateLimiter || {});
|
|
87
129
|
|
|
130
|
+
// Concurrent handshake tracking — limits how many peers can be in the
|
|
131
|
+
// HELLO/WELCOME negotiation window at the same time. Legitimate nodes
|
|
132
|
+
// trickle in; a burst of 200 simultaneous connections is a Sybil tell.
|
|
133
|
+
// Total peer count is UNBOUNDED (mesh scales freely).
|
|
134
|
+
this._pendingHandshakeCount = 0;
|
|
135
|
+
this._pendingHandshakeWs = new Set(); // Track WSs in handshake state
|
|
136
|
+
|
|
137
|
+
// Connection burst detector — sliding window for GPS-timestamped alerts.
|
|
138
|
+
// A sudden spike from baseline to hundreds of connections per minute
|
|
139
|
+
// shows up as a "bright spot" with microsecond-precise timing evidence.
|
|
140
|
+
this._burstWindow = []; // [{ ts, ip }] — last 60s of connections
|
|
141
|
+
this._burstWindowMs = 60000; // 60-second sliding window
|
|
142
|
+
this._burstThreshold = 30; // connections/minute that trigger alert
|
|
143
|
+
this._burstAlerted = false; // debounce: one alert per burst episode
|
|
144
|
+
this._burstStats = {
|
|
145
|
+
totalBurstsDetected: 0,
|
|
146
|
+
lastBurstAt: null,
|
|
147
|
+
lastBurstRate: 0,
|
|
148
|
+
peakRate: 0,
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
// Message validation — size limits, depth limits, proto pollution guard
|
|
152
|
+
// This was implemented but never wired in. Now it gates ALL incoming WS messages.
|
|
153
|
+
this.messageValidator = new MessageValidator();
|
|
154
|
+
this.safeJsonParser = new SafeJsonParser();
|
|
155
|
+
|
|
88
156
|
this._setupDefaultHandlers();
|
|
89
157
|
}
|
|
90
158
|
|
|
@@ -104,6 +172,64 @@ export class MandalaNetwork {
|
|
|
104
172
|
log.warn('Port was in use, bound to alternate', { originalPort: basePort, boundPort: port });
|
|
105
173
|
}
|
|
106
174
|
log.info('Mesh server listening', { url: `ws://localhost:${port}` });
|
|
175
|
+
|
|
176
|
+
// Initialize ANNEX encryption layer
|
|
177
|
+
this.annex = new Annex({ identity: this.identity, mesh: this });
|
|
178
|
+
|
|
179
|
+
// CRITICAL: Route decrypted ANNEX payloads back to mesh handlers.
|
|
180
|
+
// Without this, messages encrypted by _send() via ANNEX are decrypted
|
|
181
|
+
// but never dispatched to GOSSIP/PING/PONG handlers — they vanish.
|
|
182
|
+
this.annex.onMessage(async (msg) => {
|
|
183
|
+
const payload = msg.payload;
|
|
184
|
+
if (!payload || typeof payload !== 'object') return;
|
|
185
|
+
|
|
186
|
+
const msgType = payload.type || 'gossip';
|
|
187
|
+
const handlers = this.messageHandlers.get(msgType) || [];
|
|
188
|
+
if (handlers.length === 0) return;
|
|
189
|
+
|
|
190
|
+
// Find the WS for this peer (needed by PING handler etc.)
|
|
191
|
+
const peer = this.peers.get(msg.from);
|
|
192
|
+
if (!peer) return; // No peer = stale ANNEX session, skip
|
|
193
|
+
|
|
194
|
+
for (const handler of handlers) {
|
|
195
|
+
try {
|
|
196
|
+
handler(payload, peer.ws, msg.from);
|
|
197
|
+
} catch (err) {
|
|
198
|
+
log.warn('ANNEX→mesh handler error', { type: msgType, error: err.message });
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
});
|
|
202
|
+
log.info('ANNEX encryption layer initialized');
|
|
203
|
+
|
|
204
|
+
// Initialize JHILKE coordinator (bootstrap + steganographic rekey)
|
|
205
|
+
if (this.codeHash) {
|
|
206
|
+
this.jhilke = new JhilkeCoordinator({
|
|
207
|
+
codeHash: this.codeHash,
|
|
208
|
+
nodeId: this.identity.identity.nodeId,
|
|
209
|
+
annex: this.annex,
|
|
210
|
+
mesh: this,
|
|
211
|
+
});
|
|
212
|
+
this.annex.jhilke = this.jhilke; // Cross-reference for rekey routing
|
|
213
|
+
this.jhilke.start(); // Start 1s cricket tick loop
|
|
214
|
+
log.info('JHILKE coordinator initialized (cricket chorus active)');
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Initialize TRIBHUJ key ratchet — trinary rotating keypairs
|
|
218
|
+
this.ratchet = new TribhujRatchet({
|
|
219
|
+
rotationInterval: this.config.tribhujRotation || 300000, // 5min default
|
|
220
|
+
gracePeriod: this.config.tribhujGrace || 60000, // 1min grace
|
|
221
|
+
});
|
|
222
|
+
await this.ratchet.initialize();
|
|
223
|
+
this.ratchet.startAutoRotation();
|
|
224
|
+
|
|
225
|
+
// Gateway attestation — verify gossip once, attest for downstream
|
|
226
|
+
this.gateway = new GatewayAttestation(
|
|
227
|
+
this.identity.identity.nodeId,
|
|
228
|
+
this.ratchet,
|
|
229
|
+
{ attestationTTL: 60000 }
|
|
230
|
+
);
|
|
231
|
+
log.info('TRIBHUJ ratchet + gateway attestation initialized');
|
|
232
|
+
|
|
107
233
|
this._startPingLoop();
|
|
108
234
|
return;
|
|
109
235
|
} catch (err) {
|
|
@@ -122,7 +248,7 @@ export class MandalaNetwork {
|
|
|
122
248
|
*/
|
|
123
249
|
_tryBindPort(port) {
|
|
124
250
|
return new Promise((resolve, reject) => {
|
|
125
|
-
const server = new WebSocketServer({ port });
|
|
251
|
+
const server = new WebSocketServer({ port, maxPayload: 1048576 }); // 1MB max message size
|
|
126
252
|
|
|
127
253
|
server.on('listening', () => {
|
|
128
254
|
this.server = server;
|
|
@@ -150,11 +276,14 @@ export class MandalaNetwork {
|
|
|
150
276
|
async connect(endpoint) {
|
|
151
277
|
return new Promise((resolve, reject) => {
|
|
152
278
|
log.debug('Connecting to peer', { endpoint });
|
|
279
|
+
let settled = false;
|
|
153
280
|
|
|
154
281
|
const ws = new WebSocket(endpoint);
|
|
282
|
+
ws._outboundEndpoint = endpoint; // Track origin for reconnect detection
|
|
155
283
|
|
|
156
284
|
ws.on('open', () => {
|
|
157
285
|
// Send HELLO with our identity AND network fingerprint for code proof verification
|
|
286
|
+
// Include our advertised endpoint so inbound peers know how to reach us
|
|
158
287
|
this._send(ws, {
|
|
159
288
|
type: MessageTypes.HELLO,
|
|
160
289
|
identity: {
|
|
@@ -162,6 +291,7 @@ export class MandalaNetwork {
|
|
|
162
291
|
networkId: this.networkId,
|
|
163
292
|
networkFingerprint: this.networkFingerprint,
|
|
164
293
|
},
|
|
294
|
+
advertisedEndpoint: this._getAdvertisedEndpoint(),
|
|
165
295
|
timestamp: Date.now(),
|
|
166
296
|
});
|
|
167
297
|
});
|
|
@@ -175,13 +305,19 @@ export class MandalaNetwork {
|
|
|
175
305
|
});
|
|
176
306
|
|
|
177
307
|
ws.on('error', (err) => {
|
|
178
|
-
|
|
179
|
-
|
|
308
|
+
if (!settled) {
|
|
309
|
+
settled = true;
|
|
310
|
+
log.debug(`Connection to ${endpoint} failed: ${err.message}`);
|
|
311
|
+
reject(err);
|
|
312
|
+
}
|
|
313
|
+
// If already settled (e.g. caller timed out), just silently close
|
|
314
|
+
try { ws.close(); } catch {}
|
|
180
315
|
});
|
|
181
316
|
|
|
182
317
|
// Resolve when we get WELCOME back
|
|
183
318
|
const welcomeHandler = (msg) => {
|
|
184
|
-
if (msg.type === MessageTypes.WELCOME) {
|
|
319
|
+
if (msg.type === MessageTypes.WELCOME && !settled) {
|
|
320
|
+
settled = true;
|
|
185
321
|
log.info('Connected to peer', { nodeId: msg.identity.nodeId });
|
|
186
322
|
resolve(msg.identity);
|
|
187
323
|
}
|
|
@@ -190,6 +326,31 @@ export class MandalaNetwork {
|
|
|
190
326
|
});
|
|
191
327
|
}
|
|
192
328
|
|
|
329
|
+
/**
|
|
330
|
+
* Send encrypted message to specific peer via ANNEX.
|
|
331
|
+
* HARD FAIL: If no ANNEX session exists, the message is NOT sent.
|
|
332
|
+
* Caller must handle the error and initiate ANNEX negotiation.
|
|
333
|
+
*/
|
|
334
|
+
async sendEncrypted(nodeId, payload) {
|
|
335
|
+
if (this.annex) {
|
|
336
|
+
const session = this.annex.sessions.get(nodeId);
|
|
337
|
+
if (session?.established && !session.isExpired()) {
|
|
338
|
+
return await this.annex.send(nodeId, payload);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
// HARD FAIL: No plaintext fallback. Encryption is mandatory.
|
|
342
|
+
const err = new Error(`No active ANNEX session for ${peerTag(nodeId)} — refusing plaintext send`);
|
|
343
|
+
log.error(err.message);
|
|
344
|
+
throw err;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Get ANNEX encryption stats
|
|
349
|
+
*/
|
|
350
|
+
getAnnexStats() {
|
|
351
|
+
return this.annex?.getStats() || { activeSessions: 0, note: 'ANNEX not initialized' };
|
|
352
|
+
}
|
|
353
|
+
|
|
193
354
|
/**
|
|
194
355
|
* Broadcast message to all peers (gossip)
|
|
195
356
|
*/
|
|
@@ -205,32 +366,38 @@ export class MandalaNetwork {
|
|
|
205
366
|
timestamp: Date.now(),
|
|
206
367
|
};
|
|
207
368
|
|
|
208
|
-
// Sign the message
|
|
209
|
-
const signed = this.
|
|
369
|
+
// Sign the message — prefer TRIBHUJ ratchet for forward secrecy, fall back to identity
|
|
370
|
+
const signed = this.ratchet
|
|
371
|
+
? this.ratchet.signObject(gossipMsg)
|
|
372
|
+
: this.identity.signObject(gossipMsg);
|
|
210
373
|
|
|
211
374
|
this.seenMessages.add(msgId);
|
|
212
375
|
|
|
213
|
-
// Send to all peers
|
|
376
|
+
// Send to all WS peers
|
|
214
377
|
for (const [nodeId, peer] of this.peers) {
|
|
215
378
|
this._send(peer.ws, signed);
|
|
216
379
|
}
|
|
380
|
+
|
|
381
|
+
// Emit for HTTP relay peers (server layer hooks this)
|
|
382
|
+
this.emit('outbound-gossip', signed, []);
|
|
217
383
|
}
|
|
218
384
|
|
|
219
385
|
/**
|
|
220
|
-
* Send message to specific peer
|
|
386
|
+
* Send message to specific peer (WS or relay fallback)
|
|
221
387
|
*/
|
|
222
388
|
sendTo(nodeId, message) {
|
|
389
|
+
const signed = this.ratchet
|
|
390
|
+
? this.ratchet.signObject({ ...message, timestamp: Date.now() })
|
|
391
|
+
: this.identity.signObject({ ...message, timestamp: Date.now() });
|
|
392
|
+
|
|
223
393
|
const peer = this.peers.get(nodeId);
|
|
224
|
-
if (
|
|
225
|
-
|
|
394
|
+
if (peer) {
|
|
395
|
+
this._send(peer.ws, signed);
|
|
396
|
+
return;
|
|
226
397
|
}
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
timestamp: Date.now(),
|
|
231
|
-
});
|
|
232
|
-
|
|
233
|
-
this._send(peer.ws, signed);
|
|
398
|
+
|
|
399
|
+
// Not a WS peer — try relay fallback (server layer hooks this)
|
|
400
|
+
this.emit('outbound-relay', nodeId, signed);
|
|
234
401
|
}
|
|
235
402
|
|
|
236
403
|
/**
|
|
@@ -299,10 +466,74 @@ export class MandalaNetwork {
|
|
|
299
466
|
}));
|
|
300
467
|
}
|
|
301
468
|
|
|
469
|
+
/**
|
|
470
|
+
* Get our advertised WebSocket endpoint for peer discovery.
|
|
471
|
+
* This tells inbound peers how to reconnect to us.
|
|
472
|
+
*/
|
|
473
|
+
_getAdvertisedEndpoint() {
|
|
474
|
+
if (!this.boundPort) return null;
|
|
475
|
+
|
|
476
|
+
// Use configured advertise address if set (for NAT/proxy scenarios)
|
|
477
|
+
if (this.config.advertiseAddress) {
|
|
478
|
+
return this.config.advertiseAddress;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// Otherwise construct from best-guess local IP + bound port
|
|
482
|
+
// Prefer non-localhost addresses for LAN/WAN connectivity
|
|
483
|
+
const ifaces = networkInterfaces();
|
|
484
|
+
let bestIp = '127.0.0.1';
|
|
485
|
+
|
|
486
|
+
for (const [name, addrs] of Object.entries(ifaces)) {
|
|
487
|
+
for (const addr of addrs) {
|
|
488
|
+
if (addr.family === 'IPv4' && !addr.internal) {
|
|
489
|
+
// Prefer 192.168.x.x or 10.x.x.x (private networks)
|
|
490
|
+
if (addr.address.startsWith('192.168.') || addr.address.startsWith('10.')) {
|
|
491
|
+
bestIp = addr.address;
|
|
492
|
+
break;
|
|
493
|
+
}
|
|
494
|
+
// Fallback to any non-internal IPv4
|
|
495
|
+
if (bestIp === '127.0.0.1') {
|
|
496
|
+
bestIp = addr.address;
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
return `ws://${bestIp}:${this.boundPort}`;
|
|
503
|
+
}
|
|
504
|
+
|
|
302
505
|
/**
|
|
303
506
|
* Stop the mesh server
|
|
304
507
|
*/
|
|
305
508
|
async stop() {
|
|
509
|
+
// Stop ping loop
|
|
510
|
+
if (this._pingInterval) {
|
|
511
|
+
clearInterval(this._pingInterval);
|
|
512
|
+
this._pingInterval = null;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// Close all ANNEX channels
|
|
516
|
+
if (this.annex) {
|
|
517
|
+
for (const nodeId of this.annex.sessions.keys()) {
|
|
518
|
+
try { await this.annex.closeChannel(nodeId); } catch {}
|
|
519
|
+
}
|
|
520
|
+
this.annex = null;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
// Stop JHILKE coordinator
|
|
524
|
+
if (this.jhilke) {
|
|
525
|
+
this.jhilke.stop();
|
|
526
|
+
this.jhilke = null;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// Destroy TRIBHUJ ratchet — zero all key material
|
|
530
|
+
if (this.ratchet) {
|
|
531
|
+
this.ratchet.destroy();
|
|
532
|
+
this.ratchet = null;
|
|
533
|
+
}
|
|
534
|
+
this.gateway = null;
|
|
535
|
+
this.peerRatchets.clear();
|
|
536
|
+
|
|
306
537
|
// Close all peer connections
|
|
307
538
|
for (const [nodeId, peer] of this.peers) {
|
|
308
539
|
peer.ws.close();
|
|
@@ -329,7 +560,7 @@ export class MandalaNetwork {
|
|
|
329
560
|
// Nodes with different codebases will have different fingerprints
|
|
330
561
|
if (this.networkFingerprint && msg.identity.networkFingerprint) {
|
|
331
562
|
if (msg.identity.networkFingerprint !== this.networkFingerprint) {
|
|
332
|
-
console.warn(`✗ Rejected peer ${nodeId
|
|
563
|
+
console.warn(`✗ Rejected peer ${peerTag(nodeId)} - incompatible codebase`);
|
|
333
564
|
console.warn(` Their network: ${msg.identity.networkId || 'unknown'}`);
|
|
334
565
|
console.warn(` Our network: ${this.networkId || 'unknown'}`);
|
|
335
566
|
|
|
@@ -345,14 +576,53 @@ export class MandalaNetwork {
|
|
|
345
576
|
}
|
|
346
577
|
}
|
|
347
578
|
|
|
348
|
-
//
|
|
579
|
+
// DUPLICATE / RECONNECT DETECTION: If this peer is already connected
|
|
580
|
+
// with a different WebSocket, decide which connection to keep.
|
|
581
|
+
const existingPeer = this.peers.get(nodeId);
|
|
582
|
+
if (existingPeer && existingPeer.ws !== ws) {
|
|
583
|
+
const oldAlive = existingPeer.ws.readyState === WebSocket.OPEN;
|
|
584
|
+
if (oldAlive) {
|
|
585
|
+
// Existing connection is still alive — this is a duplicate, not a
|
|
586
|
+
// reconnect. Close the NEW socket to avoid ping-pong overwrites.
|
|
587
|
+
log.info('Duplicate connection from peer — keeping existing WS', { peer: peerTag(nodeId) });
|
|
588
|
+
try { ws.close(1000, 'Duplicate connection'); } catch {}
|
|
589
|
+
return;
|
|
590
|
+
}
|
|
591
|
+
// Old WS is dead — genuine reconnect. Reset ANNEX state.
|
|
592
|
+
log.info('Peer reconnected (new WS) — resetting ANNEX/JHILKE state', { peer: peerTag(nodeId) });
|
|
593
|
+
if (this.annex) {
|
|
594
|
+
this.annex.sessions.delete(nodeId);
|
|
595
|
+
this.annex.pendingHandshakes.delete(nodeId);
|
|
596
|
+
}
|
|
597
|
+
if (this.jhilke) {
|
|
598
|
+
this.jhilke.removePeer(nodeId);
|
|
599
|
+
}
|
|
600
|
+
try { existingPeer.ws.close(1000, 'Replaced by reconnect'); } catch {}
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
// Store peer — no cap on total peers (mesh scales freely)
|
|
604
|
+
// For outbound connections, use our tracked endpoint.
|
|
605
|
+
// For inbound connections, use peer's advertised endpoint (so we can reconnect to them).
|
|
606
|
+
const peerEndpoint = ws._outboundEndpoint || msg.advertisedEndpoint || null;
|
|
349
607
|
this.peers.set(nodeId, {
|
|
350
608
|
ws,
|
|
351
609
|
identity: msg.identity,
|
|
610
|
+
endpoint: peerEndpoint,
|
|
352
611
|
lastSeen: Date.now(),
|
|
353
612
|
});
|
|
354
613
|
|
|
355
|
-
|
|
614
|
+
if (peerEndpoint && !ws._outboundEndpoint) {
|
|
615
|
+
log.debug('Learned peer endpoint from inbound connection', { peer: peerTag(nodeId), endpoint: peerEndpoint });
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
// Release handshake slot — peer is now fully registered.
|
|
619
|
+
// The slot was reserved in _handleIncomingConnection.
|
|
620
|
+
if (this._pendingHandshakeWs.has(ws)) {
|
|
621
|
+
this._pendingHandshakeCount = Math.max(0, this._pendingHandshakeCount - 1);
|
|
622
|
+
this._pendingHandshakeWs.delete(ws);
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
// Send WELCOME back with our network info + our advertised endpoint
|
|
356
626
|
this._send(ws, {
|
|
357
627
|
type: MessageTypes.WELCOME,
|
|
358
628
|
identity: {
|
|
@@ -360,10 +630,49 @@ export class MandalaNetwork {
|
|
|
360
630
|
networkId: this.networkId,
|
|
361
631
|
networkFingerprint: this.networkFingerprint,
|
|
362
632
|
},
|
|
633
|
+
advertisedEndpoint: this._getAdvertisedEndpoint(),
|
|
363
634
|
peers: this.getPeers().filter(p => p.nodeId !== nodeId),
|
|
364
635
|
});
|
|
365
636
|
|
|
366
|
-
log.info('Peer connected', { name: msg.identity.name,
|
|
637
|
+
log.info('Peer connected', { name: msg.identity.name, peer: peerTag(nodeId), totalPeers: this.peers.size });
|
|
638
|
+
|
|
639
|
+
// Signal that this peer's public key is now available — any deferred
|
|
640
|
+
// ANNEX messages waiting for this key will be replayed.
|
|
641
|
+
this.emit('peer-registered', nodeId);
|
|
642
|
+
|
|
643
|
+
// Deterministic initiator: lower nodeId always initiates ANNEX
|
|
644
|
+
// Prevents duplicate sessions when both sides try to openChannel simultaneously
|
|
645
|
+
// Guard: skip if the WELCOME handler already initiated (both fire when
|
|
646
|
+
// two nodes simultaneously connect to each other as bootstrap peers)
|
|
647
|
+
const ourNodeId = this.identity.identity.nodeId;
|
|
648
|
+
if (this.annex && ourNodeId < nodeId) {
|
|
649
|
+
const existingSession = this.annex.sessions.get(nodeId);
|
|
650
|
+
const pendingHandshake = this.annex.pendingHandshakes.get(nodeId);
|
|
651
|
+
if (!existingSession && !pendingHandshake) {
|
|
652
|
+
// JHILKE: Bootstrap session with deterministic key BEFORE KEM exchange
|
|
653
|
+
// Both nodes derive the same key from shared code hash + node IDs.
|
|
654
|
+
// This means traffic is encrypted from message #1 — no plaintext KEM.
|
|
655
|
+
if (this.jhilke) {
|
|
656
|
+
const bootstrapKey = this.jhilke.deriveBootstrapKey(nodeId);
|
|
657
|
+
this.annex.bootstrapSession(nodeId, bootstrapKey);
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
log.debug('ANNEX: we initiate (lower nodeId)', { us: peerTag(ourNodeId), them: peerTag(nodeId) });
|
|
661
|
+
this.annex.openChannel(nodeId).then(() => {
|
|
662
|
+
log.info('ANNEX channel established with peer', { peerId: peerTag(nodeId) });
|
|
663
|
+
}).catch(err => {
|
|
664
|
+
log.warn('ANNEX negotiation failed', { peerId: peerTag(nodeId), error: err.message });
|
|
665
|
+
});
|
|
666
|
+
}
|
|
667
|
+
} else if (this.annex) {
|
|
668
|
+
// JHILKE: Responder also derives bootstrap key (same deterministic key)
|
|
669
|
+
// so it can decrypt the incoming KEM exchange from the initiator
|
|
670
|
+
if (this.jhilke && !this.annex.sessions.get(nodeId)) {
|
|
671
|
+
const bootstrapKey = this.jhilke.deriveBootstrapKey(nodeId);
|
|
672
|
+
this.annex.bootstrapSession(nodeId, bootstrapKey);
|
|
673
|
+
}
|
|
674
|
+
log.debug('ANNEX: waiting for peer to initiate (they have lower nodeId)', { us: peerTag(ourNodeId), them: peerTag(nodeId) });
|
|
675
|
+
}
|
|
367
676
|
});
|
|
368
677
|
|
|
369
678
|
// Handle WELCOME
|
|
@@ -374,7 +683,7 @@ export class MandalaNetwork {
|
|
|
374
683
|
// This protects the INITIATOR - even if remote accepts us, we reject them if mismatched
|
|
375
684
|
if (this.networkFingerprint && msg.identity.networkFingerprint) {
|
|
376
685
|
if (msg.identity.networkFingerprint !== this.networkFingerprint) {
|
|
377
|
-
console.warn(`✗ Rejecting peer ${nodeId
|
|
686
|
+
console.warn(`✗ Rejecting peer ${peerTag(nodeId)} - incompatible codebase (on WELCOME)`);
|
|
378
687
|
console.warn(` Their network: ${msg.identity.networkId || 'unknown'}`);
|
|
379
688
|
console.warn(` Our network: ${this.networkId || 'unknown'}`);
|
|
380
689
|
ws.close(1008, 'Incompatible codebase');
|
|
@@ -388,7 +697,7 @@ export class MandalaNetwork {
|
|
|
388
697
|
}
|
|
389
698
|
} else if (this.networkFingerprint && !msg.identity.networkFingerprint) {
|
|
390
699
|
// Remote node didn't send fingerprint - they're running old code
|
|
391
|
-
console.warn(`✗ Rejecting peer ${nodeId
|
|
700
|
+
console.warn(`✗ Rejecting peer ${peerTag(nodeId)} - no fingerprint (old codebase)`);
|
|
392
701
|
ws.close(1008, 'Missing network fingerprint');
|
|
393
702
|
|
|
394
703
|
if (ws._pendingWelcome) {
|
|
@@ -398,17 +707,114 @@ export class MandalaNetwork {
|
|
|
398
707
|
return;
|
|
399
708
|
}
|
|
400
709
|
|
|
710
|
+
// DUPLICATE / RECONNECT DETECTION (WELCOME path): same logic as HELLO.
|
|
711
|
+
const existingPeerW = this.peers.get(nodeId);
|
|
712
|
+
if (existingPeerW && existingPeerW.ws !== ws) {
|
|
713
|
+
const oldAlive = existingPeerW.ws.readyState === WebSocket.OPEN;
|
|
714
|
+
if (oldAlive) {
|
|
715
|
+
// Existing connection is still alive — duplicate. Keep the old one.
|
|
716
|
+
// Tag the existing peer with this endpoint so bootstrap's
|
|
717
|
+
// connectedEndpoints check will match and stop retrying.
|
|
718
|
+
if (ws._outboundEndpoint && !existingPeerW.endpoint) {
|
|
719
|
+
log.info('Updating peer endpoint from duplicate outbound', {
|
|
720
|
+
peer: peerTag(nodeId),
|
|
721
|
+
newEndpoint: ws._outboundEndpoint
|
|
722
|
+
});
|
|
723
|
+
existingPeerW.endpoint = ws._outboundEndpoint;
|
|
724
|
+
}
|
|
725
|
+
log.info('Duplicate outbound to peer — keeping existing WS', { peer: peerTag(nodeId) });
|
|
726
|
+
try { ws.close(1000, 'Duplicate connection'); } catch {}
|
|
727
|
+
// Still resolve the pending promise so bootstrap doesn't retry
|
|
728
|
+
if (ws._pendingWelcome) {
|
|
729
|
+
ws._pendingWelcome(msg);
|
|
730
|
+
delete ws._pendingWelcome;
|
|
731
|
+
}
|
|
732
|
+
return;
|
|
733
|
+
}
|
|
734
|
+
// Old WS is dead — genuine reconnect. Reset ANNEX state.
|
|
735
|
+
log.info('Peer reconnected on WELCOME (new WS) — resetting ANNEX/JHILKE state', { peer: peerTag(nodeId) });
|
|
736
|
+
if (this.annex) {
|
|
737
|
+
this.annex.sessions.delete(nodeId);
|
|
738
|
+
this.annex.pendingHandshakes.delete(nodeId);
|
|
739
|
+
}
|
|
740
|
+
if (this.jhilke) {
|
|
741
|
+
this.jhilke.removePeer(nodeId);
|
|
742
|
+
}
|
|
743
|
+
try { existingPeerW.ws.close(1000, 'Replaced by reconnect'); } catch {}
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
// Store peer — for outbound we have _outboundEndpoint, for inbound use advertised
|
|
747
|
+
const peerEndpoint = ws._outboundEndpoint || msg.advertisedEndpoint || null;
|
|
401
748
|
this.peers.set(nodeId, {
|
|
402
749
|
ws,
|
|
403
750
|
identity: msg.identity,
|
|
751
|
+
endpoint: peerEndpoint,
|
|
404
752
|
lastSeen: Date.now(),
|
|
405
753
|
});
|
|
406
754
|
|
|
755
|
+
if (peerEndpoint && !ws._outboundEndpoint) {
|
|
756
|
+
log.debug('Learned peer endpoint from WELCOME', { peer: peerTag(nodeId), endpoint: peerEndpoint });
|
|
757
|
+
}
|
|
758
|
+
|
|
407
759
|
// Callback for pending connection
|
|
408
760
|
if (ws._pendingWelcome) {
|
|
409
761
|
ws._pendingWelcome(msg);
|
|
410
762
|
delete ws._pendingWelcome;
|
|
411
763
|
}
|
|
764
|
+
|
|
765
|
+
// Signal that this peer's public key is now available
|
|
766
|
+
this.emit('peer-registered', nodeId);
|
|
767
|
+
|
|
768
|
+
// Deterministic initiator: connector side also checks
|
|
769
|
+
// Lower nodeId always initiates ANNEX — mirrors the HELLO handler logic
|
|
770
|
+
// Guard: skip if the HELLO handler already initiated (openChannel returns
|
|
771
|
+
// the existing session when one is pending, but we avoid the extra call entirely)
|
|
772
|
+
const ourNodeId = this.identity.identity.nodeId;
|
|
773
|
+
if (this.annex && ourNodeId < nodeId) {
|
|
774
|
+
const existingSession = this.annex.sessions.get(nodeId);
|
|
775
|
+
const pendingHandshake = this.annex.pendingHandshakes.get(nodeId);
|
|
776
|
+
if (!existingSession && !pendingHandshake) {
|
|
777
|
+
// JHILKE: Bootstrap session with deterministic key
|
|
778
|
+
if (this.jhilke) {
|
|
779
|
+
const bootstrapKey = this.jhilke.deriveBootstrapKey(nodeId);
|
|
780
|
+
this.annex.bootstrapSession(nodeId, bootstrapKey);
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
log.debug('ANNEX: we initiate on WELCOME (lower nodeId)', { us: peerTag(ourNodeId), them: peerTag(nodeId) });
|
|
784
|
+
this.annex.openChannel(nodeId).then(() => {
|
|
785
|
+
log.info('ANNEX channel established with peer', { peerId: peerTag(nodeId) });
|
|
786
|
+
}).catch(err => {
|
|
787
|
+
log.warn('ANNEX negotiation failed', { peerId: peerTag(nodeId), error: err.message });
|
|
788
|
+
});
|
|
789
|
+
}
|
|
790
|
+
} else if (this.annex) {
|
|
791
|
+
// JHILKE: Responder derives bootstrap key on WELCOME too
|
|
792
|
+
if (this.jhilke && !this.annex.sessions.get(nodeId)) {
|
|
793
|
+
const bootstrapKey = this.jhilke.deriveBootstrapKey(nodeId);
|
|
794
|
+
this.annex.bootstrapSession(nodeId, bootstrapKey);
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
});
|
|
798
|
+
|
|
799
|
+
// Handle REJECT — peer rejected our connection (incompatible codebase, etc.)
|
|
800
|
+
this.on('REJECT', (msg, ws) => {
|
|
801
|
+
log.warn('Connection rejected by peer', {
|
|
802
|
+
reason: msg.reason || 'unknown',
|
|
803
|
+
theirNetwork: msg.ourNetworkId || 'unknown',
|
|
804
|
+
});
|
|
805
|
+
// Signal rejection to pending promise if this was an outbound connection
|
|
806
|
+
if (ws._pendingWelcome) {
|
|
807
|
+
ws._pendingWelcome({ rejected: true, reason: msg.reason });
|
|
808
|
+
delete ws._pendingWelcome;
|
|
809
|
+
}
|
|
810
|
+
try { ws.close(1000, 'Rejected'); } catch {}
|
|
811
|
+
});
|
|
812
|
+
|
|
813
|
+
// Handle mesh_entropy — JHILKE cricket signals hidden in entropy exchange
|
|
814
|
+
this.on('mesh_entropy', (msg, ws, senderNodeId) => {
|
|
815
|
+
if (this.jhilke && senderNodeId) {
|
|
816
|
+
this.jhilke.handleIncoming(senderNodeId, msg);
|
|
817
|
+
}
|
|
412
818
|
});
|
|
413
819
|
|
|
414
820
|
// Handle PING
|
|
@@ -427,24 +833,33 @@ export class MandalaNetwork {
|
|
|
427
833
|
// Handle GOSSIP
|
|
428
834
|
this.on(MessageTypes.GOSSIP, (msg, ws, nodeId) => {
|
|
429
835
|
// Deduplicate
|
|
430
|
-
if (this.seenMessages.has(msg.id))
|
|
836
|
+
if (this.seenMessages.has(msg.id)) {
|
|
837
|
+
log.debug('GOSSIP dedup — already seen', { id: msg.id?.slice(0, 12) });
|
|
838
|
+
return;
|
|
839
|
+
}
|
|
431
840
|
this.seenMessages.add(msg.id);
|
|
432
841
|
|
|
433
842
|
// TTL check
|
|
434
|
-
if (msg.ttl <= 0)
|
|
843
|
+
if (msg.ttl <= 0) {
|
|
844
|
+
log.debug('GOSSIP TTL expired', { id: msg.id?.slice(0, 12) });
|
|
845
|
+
return;
|
|
846
|
+
}
|
|
435
847
|
|
|
436
848
|
// Check for gossip protocol message
|
|
437
849
|
if (msg.payload && msg.payload.gossip) {
|
|
438
850
|
this.emit('gossip', msg.payload.gossip, nodeId);
|
|
439
851
|
}
|
|
440
852
|
|
|
441
|
-
// Forward to other peers
|
|
853
|
+
// Forward to other WS peers
|
|
442
854
|
const forwardMsg = { ...msg, ttl: msg.ttl - 1 };
|
|
443
855
|
for (const [peerId, peer] of this.peers) {
|
|
444
856
|
if (peerId !== nodeId && peerId !== msg.origin) {
|
|
445
857
|
this._send(peer.ws, forwardMsg);
|
|
446
858
|
}
|
|
447
859
|
}
|
|
860
|
+
|
|
861
|
+
// Also forward to HTTP relay peers (server layer hooks this)
|
|
862
|
+
this.emit('outbound-gossip', forwardMsg, [nodeId, msg.origin]);
|
|
448
863
|
});
|
|
449
864
|
}
|
|
450
865
|
|
|
@@ -452,7 +867,7 @@ export class MandalaNetwork {
|
|
|
452
867
|
const clientIp = req.socket.remoteAddress || 'unknown';
|
|
453
868
|
log.debug('Incoming connection', { clientIp });
|
|
454
869
|
|
|
455
|
-
// SECURITY: Rate limit check for connection flood protection
|
|
870
|
+
// SECURITY: Rate limit check for connection flood protection (per-IP)
|
|
456
871
|
const connectionCheck = this.rateLimiter.checkConnection(clientIp);
|
|
457
872
|
if (!connectionCheck.allowed) {
|
|
458
873
|
console.warn(`⚠️ Connection rejected (rate limit): ${clientIp} - ${connectionCheck.reason}`);
|
|
@@ -460,11 +875,38 @@ export class MandalaNetwork {
|
|
|
460
875
|
return;
|
|
461
876
|
}
|
|
462
877
|
|
|
878
|
+
// SECURITY: Concurrent handshake gate — limits how many peers can be
|
|
879
|
+
// negotiating HELLO/WELCOME simultaneously. Total peers is unbounded;
|
|
880
|
+
// only the handshake window is capped. A burst of connections from
|
|
881
|
+
// many IPs at once is a Sybil tell.
|
|
882
|
+
if (this._pendingHandshakeCount >= this.config.maxConcurrentHandshakes) {
|
|
883
|
+
log.warn('Connection rejected (handshake slots full)', {
|
|
884
|
+
clientIp,
|
|
885
|
+
pending: this._pendingHandshakeCount,
|
|
886
|
+
max: this.config.maxConcurrentHandshakes,
|
|
887
|
+
});
|
|
888
|
+
ws.close(1013, 'Try again later — handshake slots full');
|
|
889
|
+
return;
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
// Track this connection as pending handshake
|
|
893
|
+
this._pendingHandshakeCount++;
|
|
894
|
+
this._pendingHandshakeWs.add(ws);
|
|
895
|
+
|
|
896
|
+
// SECURITY: Burst detection — track connection rate in sliding window.
|
|
897
|
+
// GPS-timestamped evidence for Sybil forensics.
|
|
898
|
+
this._recordConnectionBurst(clientIp);
|
|
899
|
+
|
|
463
900
|
ws.on('message', (data) => {
|
|
464
901
|
this._handleMessage(ws, data, req);
|
|
465
902
|
});
|
|
466
903
|
|
|
467
904
|
ws.on('close', () => {
|
|
905
|
+
// Release handshake slot if peer disconnects before completing HELLO
|
|
906
|
+
if (this._pendingHandshakeWs.has(ws)) {
|
|
907
|
+
this._pendingHandshakeCount = Math.max(0, this._pendingHandshakeCount - 1);
|
|
908
|
+
this._pendingHandshakeWs.delete(ws);
|
|
909
|
+
}
|
|
468
910
|
this._handleDisconnect(ws);
|
|
469
911
|
});
|
|
470
912
|
|
|
@@ -475,23 +917,135 @@ export class MandalaNetwork {
|
|
|
475
917
|
|
|
476
918
|
_handleMessage(ws, data, req) {
|
|
477
919
|
try {
|
|
478
|
-
const
|
|
920
|
+
const rawStr = data.toString();
|
|
921
|
+
|
|
922
|
+
// STAGE 1: Raw size validation — reject before parsing
|
|
923
|
+
const rawCheck = this.messageValidator.validateRaw(rawStr);
|
|
924
|
+
if (!rawCheck.valid) {
|
|
925
|
+
log.warn('Rejected oversized WS message', { reason: rawCheck.reason, size: rawStr.length });
|
|
926
|
+
return;
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
// STAGE 2: Safe JSON parse — proto pollution guard + size check
|
|
930
|
+
const parseResult = this.safeJsonParser.parse(rawStr);
|
|
931
|
+
if (!parseResult.success) {
|
|
932
|
+
log.warn('Rejected malformed WS message', { error: parseResult.error });
|
|
933
|
+
return;
|
|
934
|
+
}
|
|
935
|
+
const msg = parseResult.data;
|
|
936
|
+
|
|
937
|
+
// STAGE 3: Structure validation — depth, array length, required fields
|
|
938
|
+
const msgType = msg.type || 'gossip';
|
|
939
|
+
const structCheck = this.messageValidator.validateStructure(msg, msgType);
|
|
940
|
+
if (!structCheck.valid) {
|
|
941
|
+
log.warn('Rejected invalid WS message structure', { reason: structCheck.reason, type: msgType });
|
|
942
|
+
return;
|
|
943
|
+
}
|
|
479
944
|
|
|
480
945
|
// Find nodeId for this connection
|
|
481
946
|
let senderNodeId = null;
|
|
947
|
+
let senderPublicKey = null;
|
|
482
948
|
for (const [nodeId, peer] of this.peers) {
|
|
483
949
|
if (peer.ws === ws) {
|
|
484
950
|
senderNodeId = nodeId;
|
|
951
|
+
senderPublicKey = peer.identity?.publicKey;
|
|
485
952
|
peer.lastSeen = Date.now();
|
|
486
953
|
break;
|
|
487
954
|
}
|
|
488
955
|
}
|
|
489
956
|
|
|
957
|
+
// SECURITY: Verify signatures on messages from known peers
|
|
958
|
+
// Priority: (1) gateway attestation (fast), (2) TRIBHUJ ratchet, (3) legacy identity
|
|
959
|
+
|
|
960
|
+
// Check for gateway attestation first — "verify once, trust the stamp"
|
|
961
|
+
if (msg._gwAttest && this.gateway) {
|
|
962
|
+
const attestResult = this.gateway.verifyAttestation(msg._gwAttest);
|
|
963
|
+
if (attestResult.valid) {
|
|
964
|
+
// Attestation valid — skip expensive ML-DSA-65 verify (~0.01ms vs ~2-5ms)
|
|
965
|
+
log.debug('Accepted via gateway attestation', {
|
|
966
|
+
type: msg.type,
|
|
967
|
+
gateway: peerTag(msg._gwAttest.gateway),
|
|
968
|
+
});
|
|
969
|
+
} else {
|
|
970
|
+
// Attestation invalid — still try full verification below
|
|
971
|
+
log.debug('Gateway attestation invalid, falling back to full verify', {
|
|
972
|
+
reason: attestResult.reason,
|
|
973
|
+
});
|
|
974
|
+
msg._gwAttest = null; // Clear bad attestation
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
// TRIBHUJ ratchet verification (rotating keys)
|
|
979
|
+
if (msg._tribhujSig && !msg._gwAttest?.hash) {
|
|
980
|
+
const payload = { ...msg };
|
|
981
|
+
delete payload._tribhujSig;
|
|
982
|
+
delete payload._tribhujEpoch;
|
|
983
|
+
delete payload._tribhujPubKey;
|
|
984
|
+
|
|
985
|
+
const result = this.ratchet
|
|
986
|
+
? this.ratchet.verifyObject(msg, msg._tribhujPubKey)
|
|
987
|
+
: { valid: false, keyState: 'no_ratchet' };
|
|
988
|
+
|
|
989
|
+
if (!result.valid) {
|
|
990
|
+
log.warn('Rejected message with invalid TRIBHUJ signature', {
|
|
991
|
+
type: msg.type,
|
|
992
|
+
epoch: msg._tribhujEpoch,
|
|
993
|
+
keyState: result.keyState,
|
|
994
|
+
sender: peerTag(senderNodeId),
|
|
995
|
+
});
|
|
996
|
+
return; // Drop forged message
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
// If we're also a gateway, attest this for downstream peers
|
|
1000
|
+
if (this.gateway && msg.type === MessageTypes.GOSSIP && msg.id) {
|
|
1001
|
+
msg._gwAttest = this.gateway.attest(msg.id, msg.origin || senderNodeId);
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
// Legacy identity verification (permanent key, no ratchet)
|
|
1005
|
+
else if (msg._signature && senderPublicKey && !msg._gwAttest?.hash) {
|
|
1006
|
+
const verified = this.identity.verifyObject(msg, senderPublicKey);
|
|
1007
|
+
if (!verified) {
|
|
1008
|
+
log.warn('Rejected message with invalid signature', {
|
|
1009
|
+
type: msg.type,
|
|
1010
|
+
signer: peerTag(msg._signer),
|
|
1011
|
+
sender: peerTag(senderNodeId),
|
|
1012
|
+
});
|
|
1013
|
+
return; // Drop forged message
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
// Attest for downstream if we have a gateway
|
|
1017
|
+
if (this.gateway && msg.type === MessageTypes.GOSSIP && msg.id) {
|
|
1018
|
+
msg._gwAttest = this.gateway.attest(msg.id, msg.origin || senderNodeId);
|
|
1019
|
+
}
|
|
1020
|
+
} else if (msg._signature && !senderPublicKey) {
|
|
1021
|
+
// Signed message from unknown peer — might be HELLO/WELCOME flow
|
|
1022
|
+
// Allow through since the handshake handler validates identity
|
|
1023
|
+
log.debug('Signed message from unregistered peer, passing through', { type: msg.type });
|
|
1024
|
+
} else if (!msg._gwAttest && !msg._tribhujSig && !msg._signature) {
|
|
1025
|
+
// UNSIGNED message — only allow handshake types (HELLO/WELCOME/REJECT)
|
|
1026
|
+
// All other message types from known peers MUST be signed
|
|
1027
|
+
const HANDSHAKE_TYPES = new Set([MessageTypes.HELLO, MessageTypes.WELCOME, 'REJECT']);
|
|
1028
|
+
if (!HANDSHAKE_TYPES.has(msg.type)) {
|
|
1029
|
+
log.warn('Rejected unsigned message from peer', {
|
|
1030
|
+
type: msg.type,
|
|
1031
|
+
sender: peerTag(senderNodeId) || 'unknown',
|
|
1032
|
+
});
|
|
1033
|
+
return; // Drop unsigned non-handshake message
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
|
|
490
1037
|
// Dispatch to handlers
|
|
491
1038
|
const handlers = this.messageHandlers.get(msg.type) || [];
|
|
492
1039
|
for (const handler of handlers) {
|
|
493
1040
|
handler(msg, ws, senderNodeId);
|
|
494
1041
|
}
|
|
1042
|
+
|
|
1043
|
+
// Route ANNEX messages — extract envelope and pass correctly
|
|
1044
|
+
if (msg.annex && this.annex) {
|
|
1045
|
+
this.annex._handleAnnexMessage(msg.annex, senderNodeId).catch(err => {
|
|
1046
|
+
log.warn('ANNEX message handling error', { error: err.message });
|
|
1047
|
+
});
|
|
1048
|
+
}
|
|
495
1049
|
} catch (e) {
|
|
496
1050
|
console.error('Failed to parse message:', e.message);
|
|
497
1051
|
}
|
|
@@ -501,20 +1055,59 @@ export class MandalaNetwork {
|
|
|
501
1055
|
for (const [nodeId, peer] of this.peers) {
|
|
502
1056
|
if (peer.ws === ws) {
|
|
503
1057
|
log.info('Peer disconnected', { name: peer.identity.name });
|
|
1058
|
+
// Close ANNEX channel for departing peer
|
|
1059
|
+
if (this.annex) {
|
|
1060
|
+
this.annex.closeChannel(nodeId).catch(() => {});
|
|
1061
|
+
}
|
|
1062
|
+
// Clean up JHILKE session for departing peer
|
|
1063
|
+
if (this.jhilke) {
|
|
1064
|
+
this.jhilke.removePeer(nodeId);
|
|
1065
|
+
}
|
|
504
1066
|
this.peers.delete(nodeId);
|
|
1067
|
+
// Signal so deferred ANNEX messages for this peer are cleaned up
|
|
1068
|
+
this.emit('peer-disconnected', nodeId);
|
|
505
1069
|
break;
|
|
506
1070
|
}
|
|
507
1071
|
}
|
|
508
1072
|
}
|
|
509
1073
|
|
|
510
1074
|
_send(ws, message) {
|
|
511
|
-
if (ws.readyState
|
|
512
|
-
|
|
1075
|
+
if (ws.readyState !== WebSocket.OPEN) return;
|
|
1076
|
+
|
|
1077
|
+
// Opportunistic ANNEX encryption: if we have an active session
|
|
1078
|
+
// for this peer, encrypt the message transparently.
|
|
1079
|
+
// This ensures gossip, broadcast, ping — ALL traffic — is encrypted on the wire.
|
|
1080
|
+
// SKIP for ANNEX control messages (type 'annex') to prevent infinite recursion:
|
|
1081
|
+
// _send → annex.send → _sendToMesh → mesh.sendTo → _send → ...
|
|
1082
|
+
if (this.annex && message.type !== 'annex') {
|
|
1083
|
+
// Reverse-lookup nodeId from ws
|
|
1084
|
+
for (const [nodeId, peer] of this.peers) {
|
|
1085
|
+
if (peer.ws === ws) {
|
|
1086
|
+
const session = this.annex.sessions.get(nodeId);
|
|
1087
|
+
if (session?.established && !session.isExpired()) {
|
|
1088
|
+
// Send via ANNEX (async, fire-and-forget for broadcast)
|
|
1089
|
+
this.annex.send(nodeId, message).catch(err => {
|
|
1090
|
+
// HARD FAIL: No plaintext fallback. Encryption is mandatory per Yakmesh ethos.
|
|
1091
|
+
// Peer must re-negotiate ANNEX session. Dropping message is safer than leaking it.
|
|
1092
|
+
log.error('ANNEX send failed — message dropped (no plaintext fallback)', {
|
|
1093
|
+
peer: peerTag(nodeId), error: err.message
|
|
1094
|
+
});
|
|
1095
|
+
});
|
|
1096
|
+
return;
|
|
1097
|
+
}
|
|
1098
|
+
break;
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
513
1101
|
}
|
|
1102
|
+
|
|
1103
|
+
// Plaintext only for ANNEX handshake messages (type 'annex') and initial
|
|
1104
|
+
// HELLO/WELCOME before ANNEX is established. Once ANNEX exists for a
|
|
1105
|
+
// peer, ALL traffic MUST go through it.
|
|
1106
|
+
ws.send(JSON.stringify(message));
|
|
514
1107
|
}
|
|
515
1108
|
|
|
516
1109
|
_startPingLoop() {
|
|
517
|
-
setInterval(() => {
|
|
1110
|
+
this._pingInterval = setInterval(() => {
|
|
518
1111
|
const now = Date.now();
|
|
519
1112
|
for (const [nodeId, peer] of this.peers) {
|
|
520
1113
|
// Check for stale connections
|
|
@@ -527,12 +1120,109 @@ export class MandalaNetwork {
|
|
|
527
1120
|
}
|
|
528
1121
|
}
|
|
529
1122
|
|
|
530
|
-
//
|
|
1123
|
+
// LRU eviction — keep newest half instead of clearing all (prevents dedup bypass window)
|
|
531
1124
|
if (this.seenMessages.size > 10000) {
|
|
532
|
-
this.seenMessages
|
|
1125
|
+
const entries = [...this.seenMessages];
|
|
1126
|
+
const keepCount = Math.floor(entries.length / 2);
|
|
1127
|
+
this.seenMessages = new Set(entries.slice(entries.length - keepCount));
|
|
533
1128
|
}
|
|
534
1129
|
}, this.config.pingInterval);
|
|
535
1130
|
}
|
|
1131
|
+
|
|
1132
|
+
/**
|
|
1133
|
+
* Record a connection in the burst detection sliding window.
|
|
1134
|
+
* When connections/minute exceeds _burstThreshold, emits a GPS-timestamped
|
|
1135
|
+
* alert — the "bright spot on the map" that makes Sybil floods visible.
|
|
1136
|
+
* @param {string} ip - Client IP address
|
|
1137
|
+
*/
|
|
1138
|
+
_recordConnectionBurst(ip) {
|
|
1139
|
+
const now = Date.now();
|
|
1140
|
+
|
|
1141
|
+
// Add to sliding window
|
|
1142
|
+
this._burstWindow.push({ ts: now, ip });
|
|
1143
|
+
|
|
1144
|
+
// Evict entries older than window
|
|
1145
|
+
const cutoff = now - this._burstWindowMs;
|
|
1146
|
+
while (this._burstWindow.length > 0 && this._burstWindow[0].ts < cutoff) {
|
|
1147
|
+
this._burstWindow.shift();
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
const rate = this._burstWindow.length; // connections in last 60s
|
|
1151
|
+
|
|
1152
|
+
// Track peak
|
|
1153
|
+
if (rate > this._burstStats.peakRate) {
|
|
1154
|
+
this._burstStats.peakRate = rate;
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
if (rate >= this._burstThreshold && !this._burstAlerted) {
|
|
1158
|
+
// Count unique IPs in burst
|
|
1159
|
+
const uniqueIps = new Set(this._burstWindow.map(e => e.ip)).size;
|
|
1160
|
+
|
|
1161
|
+
this._burstAlerted = true;
|
|
1162
|
+
this._burstStats.totalBurstsDetected++;
|
|
1163
|
+
this._burstStats.lastBurstAt = new Date().toISOString();
|
|
1164
|
+
this._burstStats.lastBurstRate = rate;
|
|
1165
|
+
|
|
1166
|
+
console.warn(`🛰️ BURST DETECTED: ${rate} connections/min (threshold: ${this._burstThreshold}) from ${uniqueIps} unique IPs`);
|
|
1167
|
+
log.warn('Connection burst detected — possible Sybil flood', {
|
|
1168
|
+
connectionsPerMinute: rate,
|
|
1169
|
+
threshold: this._burstThreshold,
|
|
1170
|
+
uniqueIps,
|
|
1171
|
+
pendingHandshakes: this._pendingHandshakeCount,
|
|
1172
|
+
totalPeers: this.peers.size,
|
|
1173
|
+
// GPS-precision timestamp for forensic evidence
|
|
1174
|
+
gpsTimestamp: new Date().toISOString(),
|
|
1175
|
+
// IP frequency distribution (top 5 offenders)
|
|
1176
|
+
topIps: this._getTopBurstIps(5),
|
|
1177
|
+
});
|
|
1178
|
+
|
|
1179
|
+
// Emit event for external consumers (health endpoint, SAKSHI anomaly detection)
|
|
1180
|
+
this.emit('connection-burst', {
|
|
1181
|
+
rate,
|
|
1182
|
+
uniqueIps,
|
|
1183
|
+
topIps: this._getTopBurstIps(5),
|
|
1184
|
+
timestamp: new Date().toISOString(),
|
|
1185
|
+
});
|
|
1186
|
+
|
|
1187
|
+
// Reset alert after 30s (allow re-triggering if burst continues)
|
|
1188
|
+
setTimeout(() => { this._burstAlerted = false; }, 30000);
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
/**
|
|
1193
|
+
* Get the top N most frequent IPs in the current burst window.
|
|
1194
|
+
* @param {number} n - Number of top IPs to return
|
|
1195
|
+
* @returns {Array<{ip: string, count: number}>}
|
|
1196
|
+
*/
|
|
1197
|
+
_getTopBurstIps(n = 5) {
|
|
1198
|
+
const counts = new Map();
|
|
1199
|
+
for (const entry of this._burstWindow) {
|
|
1200
|
+
counts.set(entry.ip, (counts.get(entry.ip) || 0) + 1);
|
|
1201
|
+
}
|
|
1202
|
+
return [...counts.entries()]
|
|
1203
|
+
.sort((a, b) => b[1] - a[1])
|
|
1204
|
+
.slice(0, n)
|
|
1205
|
+
.map(([ip, count]) => ({ ip, count }));
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
/**
|
|
1209
|
+
* Security stats for /health endpoint exposure.
|
|
1210
|
+
* Provides visibility into handshake pressure and burst detection.
|
|
1211
|
+
*/
|
|
1212
|
+
getSecurityStats() {
|
|
1213
|
+
return {
|
|
1214
|
+
pendingHandshakes: this._pendingHandshakeCount,
|
|
1215
|
+
maxConcurrentHandshakes: this.config.maxConcurrentHandshakes,
|
|
1216
|
+
totalConnectedPeers: this.peers.size,
|
|
1217
|
+
burstDetection: {
|
|
1218
|
+
currentRate: this._burstWindow.length,
|
|
1219
|
+
threshold: this._burstThreshold,
|
|
1220
|
+
windowMs: this._burstWindowMs,
|
|
1221
|
+
inBurst: this._burstAlerted,
|
|
1222
|
+
stats: { ...this._burstStats },
|
|
1223
|
+
},
|
|
1224
|
+
};
|
|
1225
|
+
}
|
|
536
1226
|
}
|
|
537
1227
|
|
|
538
1228
|
// ============================================================
|