the-invisible-billion-cli 1.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/LICENSE +21 -0
- package/README.md +371 -0
- package/bin/ib.js +90 -0
- package/package.json +28 -0
- package/src/cli/commands/file.js +20 -0
- package/src/cli/commands/scan.js +50 -0
- package/src/cli/commands/send.js +113 -0
- package/src/cli/commands/start.js +126 -0
- package/src/cli/commands/status.js +43 -0
- package/src/cli/commands/stop.js +15 -0
- package/src/cli/commands/sync.js +31 -0
- package/src/cli/ipc.js +127 -0
- package/src/crypto/encrypt.js +100 -0
- package/src/crypto/keys.js +95 -0
- package/src/daemon/epidemic.js +103 -0
- package/src/daemon/index.js +683 -0
- package/src/daemon/network.js +84 -0
- package/src/daemon/tcp.js +200 -0
- package/src/daemon/udp.js +282 -0
- package/src/db/cleanup.js +39 -0
- package/src/db/index.js +74 -0
- package/src/db/messages.js +213 -0
- package/src/identity/index.js +92 -0
|
@@ -0,0 +1,683 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The Invisible Billion Daemon — background process entry point.
|
|
5
|
+
*
|
|
6
|
+
* Spawned by `ib start` with:
|
|
7
|
+
* child_process.spawn('node', [__filename], { detached: true, stdio: 'ignore', windowsHide: true })
|
|
8
|
+
*
|
|
9
|
+
* All logging goes to ~/.ib/daemon.log.
|
|
10
|
+
* Never reads from stdin.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const fs = require('fs');
|
|
14
|
+
const path = require('path');
|
|
15
|
+
const net = require('net');
|
|
16
|
+
const os = require('os');
|
|
17
|
+
|
|
18
|
+
// Redirect console to log file
|
|
19
|
+
const IB_DIR = path.join(os.homedir(), '.ib');
|
|
20
|
+
if (!fs.existsSync(IB_DIR)) fs.mkdirSync(IB_DIR, { recursive: true });
|
|
21
|
+
|
|
22
|
+
const LOG_FILE = path.join(IB_DIR, 'daemon.log');
|
|
23
|
+
const logStream = fs.createWriteStream(LOG_FILE, { flags: 'a' });
|
|
24
|
+
|
|
25
|
+
function log(...args) {
|
|
26
|
+
const ts = new Date().toISOString();
|
|
27
|
+
const msg = `[${ts}] ${args.join(' ')}\n`;
|
|
28
|
+
logStream.write(msg);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
console.log = log;
|
|
32
|
+
console.error = log;
|
|
33
|
+
console.warn = log;
|
|
34
|
+
|
|
35
|
+
// Imports
|
|
36
|
+
const { loadIdentity } = require('../identity/index');
|
|
37
|
+
const { loadOrCreateKeypair } = require('../crypto/keys');
|
|
38
|
+
const { encrypt, decrypt } = require('../crypto/encrypt');
|
|
39
|
+
const { openDb } = require('../db/index');
|
|
40
|
+
const { cleanExpired } = require('../db/cleanup');
|
|
41
|
+
const {
|
|
42
|
+
insertMessage,
|
|
43
|
+
getMessageById,
|
|
44
|
+
getAllPending,
|
|
45
|
+
markDelivered,
|
|
46
|
+
markInTransit,
|
|
47
|
+
updateMessagePayload,
|
|
48
|
+
getMessagesForDestination,
|
|
49
|
+
upsertMessage,
|
|
50
|
+
upsertPeer,
|
|
51
|
+
getPeerByIdentity,
|
|
52
|
+
getAllPeers,
|
|
53
|
+
getActivePeers,
|
|
54
|
+
markPeerOffline,
|
|
55
|
+
clearAllPeerIPs,
|
|
56
|
+
removeInactivePeers,
|
|
57
|
+
} = require('../db/messages');
|
|
58
|
+
const { startNetworkWatcher } = require('./network');
|
|
59
|
+
const { startUDP, broadcastNow, broadcastGoodbye, sendDirectMessage } = require('./udp');
|
|
60
|
+
const { startTCP, sendMessage } = require('./tcp');
|
|
61
|
+
const { forwardMessages, sendAck } = require('./epidemic');
|
|
62
|
+
|
|
63
|
+
// ── Constants ──────────────────────────────────────────────────────────────────
|
|
64
|
+
const PID_FILE = path.join(IB_DIR, 'daemon.pid');
|
|
65
|
+
const IPC_SOCKET = getIPCPath();
|
|
66
|
+
|
|
67
|
+
function getIPCPath() {
|
|
68
|
+
if (process.platform === 'win32') return '\\\\.\\pipe\\ib-ipc';
|
|
69
|
+
return path.join(IB_DIR, 'daemon.sock');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ── Write PID ─────────────────────────────────────────────────────────────────
|
|
73
|
+
fs.writeFileSync(PID_FILE, String(process.pid), 'utf8');
|
|
74
|
+
log(`Daemon started. PID=${process.pid}`);
|
|
75
|
+
|
|
76
|
+
// ── Load identity & keys ──────────────────────────────────────────────────────
|
|
77
|
+
let identity, publicKey, privateKey;
|
|
78
|
+
try {
|
|
79
|
+
({ identity } = loadIdentity());
|
|
80
|
+
({ publicKey, privateKey } = loadOrCreateKeypair());
|
|
81
|
+
log(`Identity: ${identity}`);
|
|
82
|
+
} catch (err) {
|
|
83
|
+
log('FATAL: Cannot load identity/keys:', err.message);
|
|
84
|
+
process.exit(1);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ── Open database & run initial cleanup ───────────────────────────────────────
|
|
88
|
+
openDb();
|
|
89
|
+
cleanExpired();
|
|
90
|
+
log('Initial cleanup done.');
|
|
91
|
+
setInterval(cleanExpired, 60 * 60 * 1000).unref();
|
|
92
|
+
|
|
93
|
+
// ── Clear stale peer IPs from previous session ───────────────────────────────
|
|
94
|
+
// Peers will re-announce via UDP within seconds if they are online
|
|
95
|
+
clearAllPeerIPs();
|
|
96
|
+
log('Cleared stale peer IPs — waiting for fresh UDP announcements.');
|
|
97
|
+
|
|
98
|
+
// ── Message delivery handler ──────────────────────────────────────────────────
|
|
99
|
+
/**
|
|
100
|
+
* Called by TCP server when a message frame arrives.
|
|
101
|
+
*
|
|
102
|
+
* @param {object} message - The raw message object from the peer
|
|
103
|
+
* @param {string} remoteIP - IP address of the sender
|
|
104
|
+
* @param {boolean} isRelay - true if message is for someone else (store for Phase 4)
|
|
105
|
+
*/
|
|
106
|
+
function handleIncomingMessage(message, remoteIP, isRelay = false) {
|
|
107
|
+
if (!message || !message.id || !message.destination || !message.payload) {
|
|
108
|
+
log(`[deliver] Malformed message from ${remoteIP} — missing fields`);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (isRelay) {
|
|
113
|
+
if (message.destination === identity) {
|
|
114
|
+
log(`[epidemic] Received relayed message destined for us! (id=${message.id})`);
|
|
115
|
+
// It's for us! Don't just store it as a relay, process it for delivery.
|
|
116
|
+
isRelay = false;
|
|
117
|
+
} else {
|
|
118
|
+
// It's for someone else — store for epidemic forwarding
|
|
119
|
+
// Store as 'undelivered' so it participates in retry sweeps and epidemic forwarding
|
|
120
|
+
const result = upsertMessage({
|
|
121
|
+
id: message.id,
|
|
122
|
+
from_identity: message.from_identity || 'unknown',
|
|
123
|
+
destination: message.destination,
|
|
124
|
+
payload: message.payload,
|
|
125
|
+
status: 'undelivered',
|
|
126
|
+
hop_count: (message.hop_count || 0) + 1,
|
|
127
|
+
ttl: message.ttl,
|
|
128
|
+
created_at: message.created_at || Date.now(),
|
|
129
|
+
});
|
|
130
|
+
log(`[relay] Stored relay msg id=${message.id} → ${message.destination} (${result})`);
|
|
131
|
+
|
|
132
|
+
// Continue the epidemic: immediately forward this new message to all OTHER known peers
|
|
133
|
+
// This ensures the virus keeps spreading as soon as we receive it
|
|
134
|
+
if (result === 'inserted') {
|
|
135
|
+
const peerIPs = getActivePeers()
|
|
136
|
+
.map(p => p.ip)
|
|
137
|
+
.filter(ip => ip && ip !== remoteIP); // Don't send back to source
|
|
138
|
+
if (peerIPs.length > 0) {
|
|
139
|
+
log(`[epidemic] Spreading relay msg ${message.id} to ${peerIPs.length} other peer(s)`);
|
|
140
|
+
forwardMessages(peerIPs).catch(err => {
|
|
141
|
+
log(`[epidemic] Relay spread failed: ${err.message}`);
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Message is for us — decrypt and deliver
|
|
150
|
+
if (message.destination !== identity) {
|
|
151
|
+
log(`[deliver] Ignoring message for ${message.destination} (we are ${identity})`);
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Upsert message as delivered (idempotent if already received)
|
|
156
|
+
const existing = getMessageById(message.id);
|
|
157
|
+
if (existing && existing.status === 'delivered') {
|
|
158
|
+
log(`[deliver] Duplicate message id=${message.id} — already delivered`);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
try {
|
|
163
|
+
const payloadObj = JSON.parse(message.payload);
|
|
164
|
+
|
|
165
|
+
// Try to decrypt
|
|
166
|
+
let plaintext;
|
|
167
|
+
if (payloadObj.__pending_encryption) {
|
|
168
|
+
plaintext = payloadObj.__phase1_plaintext || '[encrypted — key exchange pending]';
|
|
169
|
+
} else {
|
|
170
|
+
plaintext = decrypt(payloadObj, privateKey);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Check if the decrypted payload is an ACK (Phase 4)
|
|
174
|
+
let isAck = false;
|
|
175
|
+
try {
|
|
176
|
+
const parsedPlaintext = JSON.parse(plaintext);
|
|
177
|
+
if (parsedPlaintext.__type === 'ack' && parsedPlaintext.ackMessageId) {
|
|
178
|
+
isAck = true;
|
|
179
|
+
const ackedId = parsedPlaintext.ackMessageId;
|
|
180
|
+
const origMsg = getMessageById(ackedId);
|
|
181
|
+
if (origMsg && origMsg.status !== 'delivered') {
|
|
182
|
+
markDelivered(ackedId);
|
|
183
|
+
log(`[epidemic] ✅ Delivery confirmed for message ${ackedId}`);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
} catch (e) {
|
|
187
|
+
// Not JSON, so it's a normal message
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (!isAck) {
|
|
191
|
+
// Store as delivered
|
|
192
|
+
upsertMessage({
|
|
193
|
+
id: message.id,
|
|
194
|
+
from_identity: message.from_identity || 'unknown',
|
|
195
|
+
destination: message.destination,
|
|
196
|
+
payload: message.payload,
|
|
197
|
+
status: 'delivered',
|
|
198
|
+
hop_count: message.hop_count || 0,
|
|
199
|
+
ttl: message.ttl || Date.now() + 7 * 24 * 60 * 60 * 1000,
|
|
200
|
+
created_at: message.created_at || Date.now(),
|
|
201
|
+
});
|
|
202
|
+
markDelivered(message.id);
|
|
203
|
+
|
|
204
|
+
log(`[deliver] ✅ Message delivered! From: ${message.from_identity} | Content: "${plaintext}"`);
|
|
205
|
+
|
|
206
|
+
// Append to inbox file for easy reading
|
|
207
|
+
const inboxFile = path.join(IB_DIR, 'inbox.log');
|
|
208
|
+
const entry = `[${new Date().toISOString()}] From: ${message.from_identity}\n${plaintext}\n---\n`;
|
|
209
|
+
fs.appendFileSync(inboxFile, entry, 'utf8');
|
|
210
|
+
|
|
211
|
+
// Send ACK back through epidemic network (Phase 4)
|
|
212
|
+
if (message.from_identity && message.from_identity !== 'unknown') {
|
|
213
|
+
sendAck(message.id, message.from_identity, identity);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
} catch (err) {
|
|
218
|
+
log(`[deliver] Decryption failed for id=${message.id}: ${err.message}`);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// ── Attempt live delivery to a peer ──────────────────────────────────────────
|
|
223
|
+
/**
|
|
224
|
+
* Try to deliver a stored message directly to its destination peer via TCP.
|
|
225
|
+
*
|
|
226
|
+
* PHASE 3 KEY LOGIC:
|
|
227
|
+
* If the stored payload has __pending_encryption (peer was unknown when message was sent),
|
|
228
|
+
* we now have the peer's public key — re-encrypt the message properly before sending.
|
|
229
|
+
* Update the stored payload in SQLite so future retries also use the encrypted version.
|
|
230
|
+
*
|
|
231
|
+
* @param {object} msg - Full message row from SQLite
|
|
232
|
+
* @returns {Promise<boolean>} true if delivered
|
|
233
|
+
*/
|
|
234
|
+
async function attemptDelivery(msg) {
|
|
235
|
+
const peer = getPeerByIdentity(msg.destination);
|
|
236
|
+
if (!peer || !peer.ip) {
|
|
237
|
+
log(`[dtn] No IP known for ${msg.destination} — stays queued for later`);
|
|
238
|
+
return false;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// ── Re-encrypt if payload was stored as pending ────────────────────────────
|
|
242
|
+
let payloadToSend = msg.payload;
|
|
243
|
+
try {
|
|
244
|
+
const parsed = JSON.parse(msg.payload);
|
|
245
|
+
if (parsed.__pending_encryption) {
|
|
246
|
+
const plaintext = parsed.__phase1_plaintext || '';
|
|
247
|
+
if (!peer.public_key) {
|
|
248
|
+
log(`[dtn] No public key for ${msg.destination} — cannot re-encrypt yet`);
|
|
249
|
+
return false;
|
|
250
|
+
}
|
|
251
|
+
log(`[dtn] Re-encrypting pending message ${msg.id} for ${msg.destination}`);
|
|
252
|
+
const blob = encrypt(plaintext, peer.public_key);
|
|
253
|
+
payloadToSend = JSON.stringify(blob);
|
|
254
|
+
// Persist the encrypted payload so retries don't re-encrypt
|
|
255
|
+
updateMessagePayload(msg.id, payloadToSend);
|
|
256
|
+
}
|
|
257
|
+
} catch (parseErr) {
|
|
258
|
+
// Payload is not JSON or already properly encrypted — send as-is
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
log(`[dtn] Delivering: ${msg.id} → ${msg.destination} at ${peer.ip}`);
|
|
262
|
+
const deliverPayload = {
|
|
263
|
+
id: msg.id,
|
|
264
|
+
from_identity: msg.from_identity || identity, // Preserve original sender
|
|
265
|
+
destination: msg.destination,
|
|
266
|
+
payload: payloadToSend,
|
|
267
|
+
hop_count: msg.hop_count || 0,
|
|
268
|
+
ttl: msg.ttl,
|
|
269
|
+
created_at: msg.created_at,
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
try {
|
|
273
|
+
markInTransit(msg.id);
|
|
274
|
+
|
|
275
|
+
// Try TCP first (reliable, with ACK)
|
|
276
|
+
let delivered = false;
|
|
277
|
+
try {
|
|
278
|
+
const result = await sendMessage(peer.ip, 'deliver', deliverPayload);
|
|
279
|
+
if (result.ok) {
|
|
280
|
+
delivered = true;
|
|
281
|
+
} else {
|
|
282
|
+
log(`[dtn] TCP: Peer rejected msg ${msg.id}: ${result.error || 'unknown'}`);
|
|
283
|
+
}
|
|
284
|
+
} catch (tcpErr) {
|
|
285
|
+
log(`[dtn] TCP failed for ${msg.id}: ${tcpErr.message} — trying UDP fallback...`);
|
|
286
|
+
|
|
287
|
+
// Fallback to UDP (works even when firewall blocks TCP)
|
|
288
|
+
try {
|
|
289
|
+
const udpResult = await sendDirectMessage(peer.ip, 'deliver', deliverPayload);
|
|
290
|
+
if (udpResult.ok) {
|
|
291
|
+
delivered = true;
|
|
292
|
+
log(`[dtn] ✅ UDP fallback succeeded for ${msg.id}`);
|
|
293
|
+
}
|
|
294
|
+
} catch (udpErr) {
|
|
295
|
+
log(`[dtn] UDP fallback also failed for ${msg.id}: ${udpErr.message}`);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
if (delivered) {
|
|
300
|
+
markDelivered(msg.id);
|
|
301
|
+
log(`[dtn] ✅ Delivered ${msg.id} to ${msg.destination}`);
|
|
302
|
+
return true;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Both TCP and UDP failed — revert to undelivered for retry
|
|
306
|
+
openDb().prepare(`UPDATE messages SET status='undelivered' WHERE id=?`).run(msg.id);
|
|
307
|
+
return false;
|
|
308
|
+
} catch (err) {
|
|
309
|
+
log(`[dtn] Delivery failed for ${msg.id}: ${err.message} — will retry later`);
|
|
310
|
+
openDb().prepare(`UPDATE messages SET status='undelivered' WHERE id=?`).run(msg.id);
|
|
311
|
+
return false;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// ── Start networking subsystems ───────────────────────────────────────────────
|
|
316
|
+
const stopUDP = startUDP({
|
|
317
|
+
identity,
|
|
318
|
+
publicKey,
|
|
319
|
+
onPeer: (peerIdentity, peerPublicKey, ip) => {
|
|
320
|
+
upsertPeer(peerIdentity, peerPublicKey, ip);
|
|
321
|
+
log(`[udp] Peer discovered: ${peerIdentity} at ${ip}`);
|
|
322
|
+
|
|
323
|
+
// Phase 3: when a peer appears, deliver ALL queued messages for them
|
|
324
|
+
// Including messages stored with __pending_encryption — attemptDelivery
|
|
325
|
+
// will re-encrypt them now that we have the peer's public key.
|
|
326
|
+
const pending = getMessagesForDestination(peerIdentity);
|
|
327
|
+
if (pending.length > 0) {
|
|
328
|
+
log(`[dtn] Peer ${peerIdentity} online — attempting ${pending.length} pending message(s)`);
|
|
329
|
+
for (const msg of pending) {
|
|
330
|
+
attemptDelivery(msg).catch((err) =>
|
|
331
|
+
log(`[dtn] Auto-deliver failed for ${msg.id}: ${err.message}`)
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// Phase 4: trigger epidemic bundle exchange with this new peer
|
|
337
|
+
// We push all our pending messages (for anyone) to them so they can spread it
|
|
338
|
+
forwardMessages([ip]).catch(err => {
|
|
339
|
+
log(`[epidemic] Failed to forward bundle to ${ip}: ${err.message}`);
|
|
340
|
+
});
|
|
341
|
+
},
|
|
342
|
+
onPeerGoodbye: (peerIdentity) => {
|
|
343
|
+
markPeerOffline(peerIdentity);
|
|
344
|
+
log(`[udp] Peer ${peerIdentity} went offline — marked as unavailable`);
|
|
345
|
+
},
|
|
346
|
+
onMessage: handleIncomingMessage,
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
const stopTCP = startTCP({
|
|
350
|
+
identity,
|
|
351
|
+
privateKey,
|
|
352
|
+
onMessage: handleIncomingMessage,
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
// ── Network change watcher ────────────────────────────────────────────────────
|
|
356
|
+
const stopNetworkWatcher = startNetworkWatcher((newIP, previousIP) => {
|
|
357
|
+
log(`[network] Changed: ${previousIP} → ${newIP}. Re-announcing and sweeping pending...`);
|
|
358
|
+
broadcastNow();
|
|
359
|
+
startupDeliverySweep(); // re-try all pending to known peers on new network
|
|
360
|
+
|
|
361
|
+
// Phase 4: Trigger bundle exchange of all pending messages with recently active peers
|
|
362
|
+
const peerIPs = getActivePeers().map(p => p.ip).filter(ip => ip);
|
|
363
|
+
if (peerIPs.length > 0) {
|
|
364
|
+
log(`[epidemic] Network change triggered bundle exchange with ${peerIPs.length} peer(s)`);
|
|
365
|
+
forwardMessages(peerIPs);
|
|
366
|
+
}
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
// ── Phase 3: Startup / network-change delivery sweep ─────────────────────────
|
|
370
|
+
/**
|
|
371
|
+
* Sweep ALL pending messages: for each message whose destination peer has a
|
|
372
|
+
* known IP in SQLite, attempt delivery immediately.
|
|
373
|
+
*
|
|
374
|
+
* Also resets 'intransit' status back to 'undelivered' so mid-flight messages
|
|
375
|
+
* from a previous daemon run can be cleanly retried.
|
|
376
|
+
*/
|
|
377
|
+
function startupDeliverySweep() {
|
|
378
|
+
const db = openDb();
|
|
379
|
+
|
|
380
|
+
// Reset lingering 'intransit' messages — they were mid-flight when daemon stopped
|
|
381
|
+
const resetCount = db.prepare(
|
|
382
|
+
`UPDATE messages SET status='undelivered' WHERE status='intransit'`
|
|
383
|
+
).run().changes;
|
|
384
|
+
if (resetCount > 0) {
|
|
385
|
+
log(`[dtn] Reset ${resetCount} intransit → undelivered for clean retry`);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
const pending = getAllPending();
|
|
389
|
+
if (pending.length === 0) {
|
|
390
|
+
log('[dtn] Startup sweep: no pending messages.');
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
log(`[dtn] Startup sweep: ${pending.length} queued message(s)`);
|
|
394
|
+
|
|
395
|
+
let attempted = 0;
|
|
396
|
+
for (const msg of pending) {
|
|
397
|
+
const peer = getPeerByIdentity(msg.destination);
|
|
398
|
+
if (peer && peer.ip) {
|
|
399
|
+
attemptDelivery(msg).catch((err) =>
|
|
400
|
+
log(`[dtn] Startup delivery failed for ${msg.id}: ${err.message}`)
|
|
401
|
+
);
|
|
402
|
+
attempted++;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
if (attempted > 0) {
|
|
406
|
+
log(`[dtn] Startup sweep: attempting ${attempted}/${pending.length} to known peers`);
|
|
407
|
+
} else {
|
|
408
|
+
log(`[dtn] Startup sweep: no peers with known IPs yet — waiting for UDP discovery`);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// Run 2 s after startup so UDP has a chance to discover peers first
|
|
413
|
+
setTimeout(startupDeliverySweep, 2000).unref();
|
|
414
|
+
|
|
415
|
+
// ── Periodic delivery retry sweep ─────────────────────────────────────────────
|
|
416
|
+
// Every 30 s, retry all pending messages to known online peers.
|
|
417
|
+
// This handles transient failures, network changes, and delayed peer discovery.
|
|
418
|
+
const RETRY_SWEEP_INTERVAL_MS = 30_000;
|
|
419
|
+
setInterval(() => {
|
|
420
|
+
const pending = getAllPending();
|
|
421
|
+
if (pending.length === 0) return;
|
|
422
|
+
|
|
423
|
+
let attempted = 0;
|
|
424
|
+
for (const msg of pending) {
|
|
425
|
+
const peer = getPeerByIdentity(msg.destination);
|
|
426
|
+
if (peer && peer.ip) {
|
|
427
|
+
attemptDelivery(msg).catch((err) =>
|
|
428
|
+
log(`[dtn] Retry sweep failed for ${msg.id}: ${err.message}`)
|
|
429
|
+
);
|
|
430
|
+
attempted++;
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
if (attempted > 0) {
|
|
434
|
+
log(`[dtn] Retry sweep: attempting ${attempted}/${pending.length} pending message(s)`);
|
|
435
|
+
}
|
|
436
|
+
}, RETRY_SWEEP_INTERVAL_MS).unref();
|
|
437
|
+
|
|
438
|
+
// ── Periodic stale peer cleanup ───────────────────────────────────────────────
|
|
439
|
+
// Every 60 s, remove peers not seen in 3 minutes (12+ missed 15s heartbeats).
|
|
440
|
+
const STALE_PEER_INTERVAL_MS = 60_000;
|
|
441
|
+
const STALE_PEER_THRESHOLD_MS = 3 * 60 * 1000;
|
|
442
|
+
setInterval(() => {
|
|
443
|
+
const removed = removeInactivePeers(STALE_PEER_THRESHOLD_MS);
|
|
444
|
+
if (removed > 0) {
|
|
445
|
+
log(`[cleanup] Removed ${removed} stale peer(s) (not seen in 3 min)`);
|
|
446
|
+
}
|
|
447
|
+
}, STALE_PEER_INTERVAL_MS).unref();
|
|
448
|
+
|
|
449
|
+
// ── IPC Server ────────────────────────────────────────────────────────────────
|
|
450
|
+
if (process.platform !== 'win32' && fs.existsSync(IPC_SOCKET)) {
|
|
451
|
+
fs.unlinkSync(IPC_SOCKET);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
const ipcServer = net.createServer((socket) => {
|
|
455
|
+
let buffer = '';
|
|
456
|
+
|
|
457
|
+
socket.on('data', (chunk) => {
|
|
458
|
+
buffer += chunk.toString();
|
|
459
|
+
const lines = buffer.split('\n');
|
|
460
|
+
buffer = lines.pop();
|
|
461
|
+
|
|
462
|
+
for (const line of lines) {
|
|
463
|
+
if (!line.trim()) continue;
|
|
464
|
+
let cmd;
|
|
465
|
+
try {
|
|
466
|
+
cmd = JSON.parse(line);
|
|
467
|
+
} catch {
|
|
468
|
+
sendResponse(socket, { ok: false, error: 'Invalid JSON command' });
|
|
469
|
+
continue;
|
|
470
|
+
}
|
|
471
|
+
handleCommand(cmd, socket);
|
|
472
|
+
}
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
socket.on('error', () => { });
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
ipcServer.listen(IPC_SOCKET, () => {
|
|
479
|
+
log(`IPC server listening on ${IPC_SOCKET}`);
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
ipcServer.on('error', (err) => {
|
|
483
|
+
log('FATAL: IPC server error:', err.message);
|
|
484
|
+
process.exit(1);
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
// ── IPC Command Dispatcher ────────────────────────────────────────────────────
|
|
488
|
+
function handleCommand(cmd, socket) {
|
|
489
|
+
log(`IPC command: ${cmd.type}`);
|
|
490
|
+
|
|
491
|
+
switch (cmd.type) {
|
|
492
|
+
|
|
493
|
+
case 'ping':
|
|
494
|
+
sendResponse(socket, { ok: true, identity, version: '1.0.0' });
|
|
495
|
+
break;
|
|
496
|
+
|
|
497
|
+
case 'stop':
|
|
498
|
+
sendResponse(socket, { ok: true, message: 'Daemon shutting down...' });
|
|
499
|
+
shutdown();
|
|
500
|
+
break;
|
|
501
|
+
|
|
502
|
+
case 'scan': {
|
|
503
|
+
// Return only peers seen within the last 2 minutes (recent heartbeat)
|
|
504
|
+
const SCAN_FRESHNESS_MS = 2 * 60 * 1000;
|
|
505
|
+
const peers = getAllPeers(SCAN_FRESHNESS_MS);
|
|
506
|
+
sendResponse(socket, { ok: true, peers });
|
|
507
|
+
break;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// ── Phase 2: CLI asks daemon for a peer's public key before encrypting ──
|
|
511
|
+
case 'get-peer-key': {
|
|
512
|
+
const { identity: targetIdentity } = cmd;
|
|
513
|
+
if (!targetIdentity) {
|
|
514
|
+
sendResponse(socket, { ok: false, error: 'Missing identity' });
|
|
515
|
+
break;
|
|
516
|
+
}
|
|
517
|
+
const peer = getPeerByIdentity(targetIdentity);
|
|
518
|
+
if (peer) {
|
|
519
|
+
sendResponse(socket, { ok: true, publicKey: peer.public_key, ip: peer.ip });
|
|
520
|
+
} else {
|
|
521
|
+
sendResponse(socket, { ok: false, error: 'Peer not found' });
|
|
522
|
+
}
|
|
523
|
+
break;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
case 'send': {
|
|
527
|
+
const { id, from_identity, destination, payload, ttl } = cmd;
|
|
528
|
+
if (!id || !destination || !payload) {
|
|
529
|
+
sendResponse(socket, { ok: false, error: 'Missing required fields: id, destination, payload' });
|
|
530
|
+
break;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// ── Store first, always ──────────────────────────────────────────
|
|
534
|
+
try {
|
|
535
|
+
insertMessage({
|
|
536
|
+
id,
|
|
537
|
+
from_identity: from_identity || identity,
|
|
538
|
+
destination,
|
|
539
|
+
payload,
|
|
540
|
+
ttl,
|
|
541
|
+
status: 'undelivered',
|
|
542
|
+
});
|
|
543
|
+
log(`[send] Stored message ${id} → ${destination}`);
|
|
544
|
+
} catch (err) {
|
|
545
|
+
sendResponse(socket, { ok: false, error: `DB insert failed: ${err.message}` });
|
|
546
|
+
break;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// If destination is our own identity — deliver immediately to ourselves
|
|
550
|
+
if (destination === identity) {
|
|
551
|
+
const msg = getMessageById(id);
|
|
552
|
+
if (msg) {
|
|
553
|
+
handleIncomingMessage(msg, '127.0.0.1', false);
|
|
554
|
+
sendResponse(socket, { ok: true, messageId: id, status: 'delivered' });
|
|
555
|
+
break;
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// ── Attempt live delivery asynchronously ─────────────────────────
|
|
560
|
+
const msg = getMessageById(id);
|
|
561
|
+
attemptDelivery(msg)
|
|
562
|
+
.then((delivered) => {
|
|
563
|
+
const finalStatus = delivered ? 'delivered' : 'undelivered';
|
|
564
|
+
sendResponse(socket, { ok: true, messageId: id, status: finalStatus });
|
|
565
|
+
})
|
|
566
|
+
.catch(() => {
|
|
567
|
+
sendResponse(socket, { ok: true, messageId: id, status: 'undelivered' });
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
// ── BUG 1 FIX: Epidemic spread — push this new message to ALL known peers ──
|
|
571
|
+
// This is the core of epidemic routing: every peer on your current network
|
|
572
|
+
// gets a copy so they can carry it to other networks.
|
|
573
|
+
const activePeerIPs = getActivePeers().map(p => p.ip).filter(ip => ip);
|
|
574
|
+
if (activePeerIPs.length > 0) {
|
|
575
|
+
log(`[epidemic] Spreading new message ${id} to ${activePeerIPs.length} peer(s) on this network`);
|
|
576
|
+
forwardMessages(activePeerIPs).catch(err => {
|
|
577
|
+
log(`[epidemic] Failed epidemic spread for ${id}: ${err.message}`);
|
|
578
|
+
});
|
|
579
|
+
}
|
|
580
|
+
break;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
case 'status': {
|
|
584
|
+
const { messageId } = cmd;
|
|
585
|
+
if (!messageId) {
|
|
586
|
+
sendResponse(socket, { ok: false, error: 'Missing messageId' });
|
|
587
|
+
break;
|
|
588
|
+
}
|
|
589
|
+
const msg = getMessageById(messageId);
|
|
590
|
+
if (!msg) {
|
|
591
|
+
sendResponse(socket, { ok: false, error: 'Message not found' });
|
|
592
|
+
} else {
|
|
593
|
+
sendResponse(socket, {
|
|
594
|
+
ok: true,
|
|
595
|
+
messageId: msg.id,
|
|
596
|
+
status: msg.status,
|
|
597
|
+
destination: msg.destination,
|
|
598
|
+
hop_count: msg.hop_count,
|
|
599
|
+
created_at: msg.created_at,
|
|
600
|
+
ttl: msg.ttl,
|
|
601
|
+
});
|
|
602
|
+
}
|
|
603
|
+
break;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
case 'sync': {
|
|
607
|
+
const activePeers = getActivePeers();
|
|
608
|
+
const allPending = getAllPending();
|
|
609
|
+
log(`[sync] Manual sync triggered: ${allPending.length} pending, ${activePeers.length} active peers`);
|
|
610
|
+
|
|
611
|
+
// Count how many have a known IP (can be attempted right now)
|
|
612
|
+
const deliverable = allPending.filter(m => {
|
|
613
|
+
const p = getPeerByIdentity(m.destination);
|
|
614
|
+
return p && p.ip;
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
// Run the full sweep (handles intransit reset, re-encryption, and delivery)
|
|
618
|
+
startupDeliverySweep();
|
|
619
|
+
|
|
620
|
+
// Also trigger epidemic forwarding to all active peers
|
|
621
|
+
const syncPeerIPs = activePeers.map(p => p.ip).filter(ip => ip);
|
|
622
|
+
if (syncPeerIPs.length > 0) {
|
|
623
|
+
forwardMessages(syncPeerIPs).catch(err => {
|
|
624
|
+
log(`[epidemic] Sync forward failed: ${err.message}`);
|
|
625
|
+
});
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
sendResponse(socket, {
|
|
629
|
+
ok: true,
|
|
630
|
+
message: `Sync complete.`,
|
|
631
|
+
pending: allPending.length,
|
|
632
|
+
deliverable: deliverable.length,
|
|
633
|
+
peers: activePeers.length,
|
|
634
|
+
});
|
|
635
|
+
break;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
default:
|
|
639
|
+
sendResponse(socket, { ok: false, error: `Unknown command: ${cmd.type}` });
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
function sendResponse(socket, data) {
|
|
644
|
+
try {
|
|
645
|
+
socket.write(JSON.stringify(data) + '\n');
|
|
646
|
+
} catch (_) { }
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
// ── Graceful shutdown ─────────────────────────────────────────────────────────
|
|
650
|
+
function shutdown() {
|
|
651
|
+
log('Daemon shutting down...');
|
|
652
|
+
|
|
653
|
+
// Broadcast goodbye so peers immediately know we are leaving
|
|
654
|
+
try { broadcastGoodbye(); } catch (_) { }
|
|
655
|
+
|
|
656
|
+
// Give the goodbye packet a moment to be sent before closing the socket
|
|
657
|
+
setTimeout(() => {
|
|
658
|
+
stopNetworkWatcher();
|
|
659
|
+
stopUDP();
|
|
660
|
+
stopTCP();
|
|
661
|
+
ipcServer.close(() => {
|
|
662
|
+
if (process.platform !== 'win32' && fs.existsSync(IPC_SOCKET)) {
|
|
663
|
+
try { fs.unlinkSync(IPC_SOCKET); } catch (_) { }
|
|
664
|
+
}
|
|
665
|
+
if (fs.existsSync(PID_FILE)) {
|
|
666
|
+
try { fs.unlinkSync(PID_FILE); } catch (_) { }
|
|
667
|
+
}
|
|
668
|
+
log('Daemon stopped cleanly.');
|
|
669
|
+
logStream.end(() => process.exit(0));
|
|
670
|
+
});
|
|
671
|
+
}, 200);
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
process.on('SIGTERM', shutdown);
|
|
675
|
+
process.on('SIGINT', shutdown);
|
|
676
|
+
|
|
677
|
+
process.on('uncaughtException', (err) => {
|
|
678
|
+
log('Uncaught exception:', err.message, err.stack);
|
|
679
|
+
});
|
|
680
|
+
|
|
681
|
+
process.on('unhandledRejection', (reason) => {
|
|
682
|
+
log('Unhandled rejection:', String(reason));
|
|
683
|
+
});
|