pyre-cli 3.5.0 → 3.7.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/dist/index.js CHANGED
@@ -2354,22 +2354,22 @@ var P2PClient = class {
2354
2354
  tlsOptions.ca = fs3.readFileSync(this.options.tlsCA);
2355
2355
  }
2356
2356
  this.socket = tls2.connect(tlsOptions, () => {
2357
- this.status("Connecting...");
2357
+ this.running = true;
2358
+ this.status("Connected");
2359
+ resolve();
2358
2360
  });
2359
2361
  } else {
2360
2362
  this.socket = new net2.Socket();
2361
- this.socket.on("data", (data) => this.onData(data));
2362
- this.socket.on("close", () => this.onClose());
2363
- this.socket.on("error", (err) => this.onError(err));
2364
- this.socket.on("timeout", () => this.onTimeout());
2365
2363
  this.socket.setTimeout(1e4);
2366
2364
  this.socket.connect(this.options.port, this.options.host, () => {
2367
- this.status("Connecting...");
2368
- });
2369
- this.socket.once("error", (err) => {
2370
- reject(err);
2365
+ this.running = true;
2366
+ this.status("Connected");
2367
+ resolve();
2371
2368
  });
2372
2369
  }
2370
+ this.socket.once("error", (err) => {
2371
+ reject(err);
2372
+ });
2373
2373
  this.socket.on("data", (data) => this.onData(data));
2374
2374
  this.socket.on("close", () => this.onClose());
2375
2375
  this.socket.on("error", (err) => this.onError(err));
@@ -2505,6 +2505,8 @@ var P2PClient = class {
2505
2505
  this.status("Connection refused \u2014 is the server running?");
2506
2506
  } else if (code === "ETIMEDOUT") {
2507
2507
  this.status("Connection timed out");
2508
+ } else if (code === "EHOSTDOWN" || code === "EHOSTUNREACH") {
2509
+ this.status(`Host unreachable \u2014 ${err.message}`);
2508
2510
  } else if (code === "UNABLE_TO_VERIFY_LEAF_SIGNATURE") {
2509
2511
  this.status("TLS certificate verification failed \u2014 use --p2p-insecure to skip");
2510
2512
  } else {
@@ -3361,7 +3363,26 @@ async function runP2PConnect() {
3361
3363
  try {
3362
3364
  await client.connect();
3363
3365
  } catch (err) {
3364
- console.log(chalk9.red(`Connection failed: ${err.message}`));
3366
+ const code = err.code;
3367
+ const msg = err.message || String(err);
3368
+ console.log(chalk9.red(`Connection failed: ${msg}`));
3369
+ if (code === "ECONNREFUSED") {
3370
+ console.log(chalk9.yellow(" Hints:"));
3371
+ console.log(chalk9.dim(" 1. Start the server: pyre p2p server --p2p-host 0.0.0.0 --p2p-port " + port + " --p2p-password <password>"));
3372
+ console.log(chalk9.dim(" 2. Check the server IP with: hostname -I"));
3373
+ console.log(chalk9.dim(" 3. Open the port on the server firewall: sudo ufw allow " + port + "/tcp"));
3374
+ } else if (code === "ETIMEDOUT") {
3375
+ console.log(chalk9.yellow(" Hints:"));
3376
+ console.log(chalk9.dim(" 1. Verify the server IP is correct"));
3377
+ console.log(chalk9.dim(" 2. Check that both machines are on the same network/subnet"));
3378
+ console.log(chalk9.dim(" 3. Open the port on the server firewall"));
3379
+ } else if (code === "EHOSTDOWN" || code === "EHOSTUNREACH") {
3380
+ console.log(chalk9.yellow(" Hints:"));
3381
+ console.log(chalk9.dim(" 1. The server at " + host + " is not responding"));
3382
+ console.log(chalk9.dim(" 2. Start the server first: pyre p2p server --p2p-host 0.0.0.0 --p2p-port " + port + " --p2p-password <password>"));
3383
+ console.log(chalk9.dim(" 3. Check connectivity: ping " + host));
3384
+ console.log(chalk9.dim(" 4. Ensure both machines are on the same LAN (ping each other first)"));
3385
+ }
3365
3386
  process.exit(1);
3366
3387
  }
3367
3388
  process.once("SIGINT", () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pyre-cli",
3
- "version": "3.5.0",
3
+ "version": "3.7.0",
4
4
  "description": "CLI Mac monitoring tool: temps, system stats, live dashboard, packet monitor, battery predictor, export",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",
package/src/index.ts CHANGED
@@ -254,7 +254,26 @@ async function runP2PConnect(): Promise<void> {
254
254
  try {
255
255
  await client.connect();
256
256
  } catch (err: any) {
257
- console.log(chalk.red(`Connection failed: ${err.message}`));
257
+ const code = (err as any).code;
258
+ const msg = err.message || String(err);
259
+ console.log(chalk.red(`Connection failed: ${msg}`));
260
+ if (code === 'ECONNREFUSED') {
261
+ console.log(chalk.yellow(' Hints:'));
262
+ console.log(chalk.dim(' 1. Start the server: pyre p2p server --p2p-host 0.0.0.0 --p2p-port ' + port + ' --p2p-password <password>'));
263
+ console.log(chalk.dim(' 2. Check the server IP with: hostname -I'));
264
+ console.log(chalk.dim(' 3. Open the port on the server firewall: sudo ufw allow ' + port + '/tcp'));
265
+ } else if (code === 'ETIMEDOUT') {
266
+ console.log(chalk.yellow(' Hints:'));
267
+ console.log(chalk.dim(' 1. Verify the server IP is correct'));
268
+ console.log(chalk.dim(' 2. Check that both machines are on the same network/subnet'));
269
+ console.log(chalk.dim(' 3. Open the port on the server firewall'));
270
+ } else if (code === 'EHOSTDOWN' || code === 'EHOSTUNREACH') {
271
+ console.log(chalk.yellow(' Hints:'));
272
+ console.log(chalk.dim(' 1. The server at ' + host + ' is not responding'));
273
+ console.log(chalk.dim(' 2. Start the server first: pyre p2p server --p2p-host 0.0.0.0 --p2p-port ' + port + ' --p2p-password <password>'));
274
+ console.log(chalk.dim(' 3. Check connectivity: ping ' + host));
275
+ console.log(chalk.dim(' 4. Ensure both machines are on the same LAN (ping each other first)'));
276
+ }
258
277
  process.exit(1);
259
278
  }
260
279
 
package/src/p2p/client.ts CHANGED
@@ -63,23 +63,23 @@ export class P2PClient {
63
63
  tlsOptions.ca = fs.readFileSync(this.options.tlsCA);
64
64
  }
65
65
  this.socket = tls.connect(tlsOptions, () => {
66
- this.status('Connecting...');
66
+ this.running = true;
67
+ this.status('Connected');
68
+ resolve();
67
69
  });
68
70
  } else {
69
71
  this.socket = new net.Socket();
70
- this.socket.on('data', (data) => this.onData(data));
71
- this.socket.on('close', () => this.onClose());
72
- this.socket.on('error', (err) => this.onError(err));
73
- this.socket.on('timeout', () => this.onTimeout());
74
72
  this.socket.setTimeout(10000);
75
73
  this.socket.connect(this.options.port, this.options.host, () => {
76
- this.status('Connecting...');
77
- });
78
- this.socket.once('error', (err) => {
79
- reject(err);
74
+ this.running = true;
75
+ this.status('Connected');
76
+ resolve();
80
77
  });
81
78
  }
82
79
 
80
+ this.socket.once('error', (err) => {
81
+ reject(err);
82
+ });
83
83
  this.socket.on('data', (data) => this.onData(data));
84
84
  this.socket.on('close', () => this.onClose());
85
85
  this.socket.on('error', (err) => this.onError(err));
@@ -234,6 +234,8 @@ export class P2PClient {
234
234
  this.status('Connection refused — is the server running?');
235
235
  } else if (code === 'ETIMEDOUT') {
236
236
  this.status('Connection timed out');
237
+ } else if (code === 'EHOSTDOWN' || code === 'EHOSTUNREACH') {
238
+ this.status(`Host unreachable — ${err.message}`);
237
239
  } else if (code === 'UNABLE_TO_VERIFY_LEAF_SIGNATURE') {
238
240
  this.status('TLS certificate verification failed — use --p2p-insecure to skip');
239
241
  } else {
@@ -0,0 +1,543 @@
1
+ import { describe, it, after, afterEach } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import net from 'node:net';
4
+ import { P2PServer, startP2PServer } from './server.js';
5
+ import { hashPassword, generateNonce, signMessage, decodeMessage } from './protocol.js';
6
+
7
+ const TEST_PASSWORD = 'test-secret-password';
8
+ const TEST_HOST = '127.0.0.1';
9
+ const HEADER_SIZE = 4;
10
+
11
+ function getFreePort(): Promise<number> {
12
+ return new Promise((resolve, reject) => {
13
+ const srv = net.createServer();
14
+ srv.listen(0, '127.0.0.1', () => {
15
+ const addr = srv.address();
16
+ const port = typeof addr === 'object' ? addr.port : 0;
17
+ srv.close(() => resolve(port));
18
+ });
19
+ srv.on('error', reject);
20
+ });
21
+ }
22
+
23
+ function delay(ms: number): Promise<void> {
24
+ return new Promise(resolve => setTimeout(resolve, ms));
25
+ }
26
+
27
+ function readMessage(socket: net.Socket): Promise<Buffer> {
28
+ return new Promise((resolve, reject) => {
29
+ function readHeader(): void {
30
+ if (socket.readableLength < HEADER_SIZE) {
31
+ socket.once('readable', readHeader);
32
+ return;
33
+ }
34
+ const header = socket.read(HEADER_SIZE);
35
+ if (!header || header.length < HEADER_SIZE) {
36
+ reject(new Error('Failed to read message header'));
37
+ return;
38
+ }
39
+ const length = header.readUInt32BE(0);
40
+ readBody(length);
41
+ }
42
+
43
+ function readBody(length: number): void {
44
+ if (socket.readableLength < length) {
45
+ socket.once('readable', () => readBody(length));
46
+ return;
47
+ }
48
+ const body = socket.read(length);
49
+ if (!body || body.length < length) {
50
+ reject(new Error('Failed to read message body'));
51
+ return;
52
+ }
53
+ resolve(body);
54
+ }
55
+
56
+ readHeader();
57
+ });
58
+ }
59
+
60
+ function makeAuthMessage(password: string, nonce: string) {
61
+ const passwordHash = hashPassword(password, nonce);
62
+ return signMessage('auth', { passwordHash, nonce }, password);
63
+ }
64
+
65
+ describe('P2PServer - server-side p2p system', () => {
66
+ let server: P2PServer | null = null;
67
+ let serverPort: number = 0;
68
+
69
+ afterEach(async () => {
70
+ if (server) {
71
+ server.stop();
72
+ server = null;
73
+ }
74
+ });
75
+
76
+ after(async () => {
77
+ if (server) {
78
+ server.stop();
79
+ }
80
+ });
81
+
82
+ describe('server lifecycle', () => {
83
+ it('should start and listen on a TCP port', async () => {
84
+ serverPort = await getFreePort();
85
+ server = new P2PServer({
86
+ host: TEST_HOST,
87
+ port: serverPort,
88
+ password: TEST_PASSWORD,
89
+ intervalMs: 1000,
90
+ detailed: false,
91
+ });
92
+ await server.start();
93
+ assert.strictEqual(server.info.port, serverPort);
94
+ assert.strictEqual(server.info.host, TEST_HOST);
95
+ assert.strictEqual(server.info.peersConnected, 0);
96
+ });
97
+
98
+ it('should report correct connection info after start', async () => {
99
+ serverPort = await getFreePort();
100
+ server = new P2PServer({
101
+ host: TEST_HOST,
102
+ port: serverPort,
103
+ password: TEST_PASSWORD,
104
+ intervalMs: 1000,
105
+ detailed: false,
106
+ });
107
+ await server.start();
108
+ const info = server.info;
109
+ assert.strictEqual(info.host, TEST_HOST);
110
+ assert.strictEqual(info.port, serverPort);
111
+ assert.strictEqual(info.peersConnected, 0);
112
+ });
113
+
114
+ it('should stop and clear all peers', async () => {
115
+ serverPort = await getFreePort();
116
+ server = new P2PServer({
117
+ host: TEST_HOST,
118
+ port: serverPort,
119
+ password: TEST_PASSWORD,
120
+ intervalMs: 1000,
121
+ detailed: false,
122
+ });
123
+ await server.start();
124
+ server.stop();
125
+ assert.strictEqual(server.info.peersConnected, 0);
126
+ });
127
+
128
+ it('should emit peer events on startup', async () => {
129
+ serverPort = await getFreePort();
130
+ server = new P2PServer({
131
+ host: TEST_HOST,
132
+ port: serverPort,
133
+ password: TEST_PASSWORD,
134
+ intervalMs: 1000,
135
+ detailed: false,
136
+ });
137
+ await server.start();
138
+ const events = server.peerEventHistory;
139
+ assert.ok(events.length > 0, 'Expected at least one peer event');
140
+ const connectEvent = events.find(e => e.type === 'connect');
141
+ assert.ok(connectEvent, 'Expected a connect event in history');
142
+ });
143
+
144
+ it('should support startP2PServer convenience function', async () => {
145
+ serverPort = await getFreePort();
146
+ server = await startP2PServer({
147
+ host: TEST_HOST,
148
+ port: serverPort,
149
+ password: TEST_PASSWORD,
150
+ intervalMs: 1000,
151
+ detailed: false,
152
+ });
153
+ assert.strictEqual(server.info.port, serverPort);
154
+ });
155
+ });
156
+
157
+ describe('challenge-response authentication', () => {
158
+ it('should send a challenge with a nonce on new connection', async () => {
159
+ serverPort = await getFreePort();
160
+ server = new P2PServer({
161
+ host: TEST_HOST,
162
+ port: serverPort,
163
+ password: TEST_PASSWORD,
164
+ intervalMs: 1000,
165
+ detailed: false,
166
+ });
167
+ await server.start();
168
+
169
+ const client = new net.Socket();
170
+ client.connect(serverPort, TEST_HOST);
171
+
172
+ const body = await readMessage(client);
173
+ const msg = decodeMessage(body);
174
+ assert.strictEqual(msg.kind, 'challenge');
175
+ const payload = msg.payload as { nonce: string };
176
+ assert.ok(payload.nonce, 'Expected a nonce in the challenge message');
177
+ client.destroy();
178
+ });
179
+
180
+ it('should authenticate a client with the correct password', async () => {
181
+ serverPort = await getFreePort();
182
+ server = new P2PServer({
183
+ host: TEST_HOST,
184
+ port: serverPort,
185
+ password: TEST_PASSWORD,
186
+ intervalMs: 1000,
187
+ detailed: false,
188
+ });
189
+ await server.start();
190
+
191
+ const client = new net.Socket();
192
+ client.connect(serverPort, TEST_HOST);
193
+
194
+ const challengeBody = await readMessage(client);
195
+ const challengeMsg = decodeMessage(challengeBody);
196
+ const nonce = (challengeMsg.payload as { nonce: string }).nonce;
197
+ client.write(makeAuthMessage(TEST_PASSWORD, nonce));
198
+
199
+ const authBody = await readMessage(client);
200
+ const authMsg = decodeMessage(authBody);
201
+ assert.strictEqual(authMsg.kind, 'auth-ok');
202
+ assert.strictEqual((authMsg.payload as { ok: boolean }).ok, true);
203
+ assert.strictEqual(server!.info.peersConnected, 1);
204
+ assert.ok(server!.peerEventHistory.some(e => e.type === 'auth'), 'Expected auth event in history');
205
+
206
+ client.destroy();
207
+ });
208
+
209
+ it('should reject authentication with wrong password', async () => {
210
+ serverPort = await getFreePort();
211
+ server = new P2PServer({
212
+ host: TEST_HOST,
213
+ port: serverPort,
214
+ password: TEST_PASSWORD,
215
+ intervalMs: 1000,
216
+ detailed: false,
217
+ });
218
+ await server.start();
219
+
220
+ const client = new net.Socket();
221
+ client.connect(serverPort, TEST_HOST);
222
+
223
+ const challengeBody = await readMessage(client);
224
+ const challengeMsg = decodeMessage(challengeBody);
225
+ const nonce = (challengeMsg.payload as { nonce: string }).nonce;
226
+ client.write(makeAuthMessage('wrong-password', nonce));
227
+
228
+ const failBody = await readMessage(client);
229
+ const failMsg = decodeMessage(failBody);
230
+ assert.strictEqual(failMsg.kind, 'auth-fail');
231
+ const failPayload = failMsg.payload as { reason: string };
232
+ assert.ok(
233
+ failPayload.reason.toLowerCase().includes('password') || failPayload.reason.toLowerCase().includes('invalid'),
234
+ `Expected password-related failure reason, got: ${failPayload.reason}`
235
+ );
236
+ assert.strictEqual(server!.info.peersConnected, 0, 'No peers should be connected after failed auth');
237
+
238
+ client.destroy();
239
+ });
240
+
241
+ it('should reject auth message with empty nonce', async () => {
242
+ serverPort = await getFreePort();
243
+ server = new P2PServer({
244
+ host: TEST_HOST,
245
+ port: serverPort,
246
+ password: TEST_PASSWORD,
247
+ intervalMs: 1000,
248
+ detailed: false,
249
+ });
250
+ await server.start();
251
+
252
+ const client = new net.Socket();
253
+ client.connect(serverPort, TEST_HOST);
254
+
255
+ const challengeBody = await readMessage(client);
256
+ const challengeMsg = decodeMessage(challengeBody);
257
+ const nonce = (challengeMsg.payload as { nonce: string }).nonce;
258
+
259
+ client.write(signMessage('auth', { passwordHash: 'somehash', nonce: '' }, TEST_PASSWORD));
260
+
261
+ const failBody = await readMessage(client);
262
+ const failMsg = decodeMessage(failBody);
263
+ assert.strictEqual(failMsg.kind, 'auth-fail');
264
+
265
+ client.destroy();
266
+ });
267
+
268
+ it('should reject non-auth messages sent before authentication', async () => {
269
+ serverPort = await getFreePort();
270
+ server = new P2PServer({
271
+ host: TEST_HOST,
272
+ port: serverPort,
273
+ password: TEST_PASSWORD,
274
+ intervalMs: 1000,
275
+ detailed: false,
276
+ });
277
+ await server.start();
278
+
279
+ const client = new net.Socket();
280
+ client.connect(serverPort, TEST_HOST);
281
+
282
+ const challengeBody = await readMessage(client);
283
+ const challengeMsg = decodeMessage(challengeBody);
284
+ const nonce = (challengeMsg.payload as { nonce: string }).nonce;
285
+
286
+ client.write(signMessage('data', { stats: {} }, TEST_PASSWORD));
287
+
288
+ const failBody = await readMessage(client);
289
+ const failMsg = decodeMessage(failBody);
290
+ assert.strictEqual(failMsg.kind, 'auth-fail');
291
+
292
+ client.destroy();
293
+ });
294
+
295
+ it('should disconnect client after wrong password attempt', async () => {
296
+ serverPort = await getFreePort();
297
+ server = new P2PServer({
298
+ host: TEST_HOST,
299
+ port: serverPort,
300
+ password: TEST_PASSWORD,
301
+ intervalMs: 1000,
302
+ detailed: false,
303
+ });
304
+ await server.start();
305
+
306
+ const client = new net.Socket();
307
+ client.connect(serverPort, TEST_HOST);
308
+
309
+ const challengeBody = await readMessage(client);
310
+ const challengeMsg = decodeMessage(challengeBody);
311
+ const nonce = (challengeMsg.payload as { nonce: string }).nonce;
312
+ client.write(makeAuthMessage('wrong-password', nonce));
313
+
314
+ const failBody = await readMessage(client);
315
+ decodeMessage(failBody);
316
+
317
+ await delay(200);
318
+ assert.ok(server!.info.peersConnected === 0, 'Server should have no connected peers after failed auth');
319
+
320
+ client.destroy();
321
+ });
322
+ });
323
+
324
+ describe('data streaming', () => {
325
+ it('should stream StatsData to an authenticated client', async () => {
326
+ serverPort = await getFreePort();
327
+ server = new P2PServer({
328
+ host: TEST_HOST,
329
+ port: serverPort,
330
+ password: TEST_PASSWORD,
331
+ intervalMs: 200,
332
+ detailed: false,
333
+ });
334
+ await server.start();
335
+
336
+ const client = new net.Socket();
337
+ client.connect(serverPort, TEST_HOST);
338
+
339
+ const challengeBody = await readMessage(client);
340
+ const challengeMsg = decodeMessage(challengeBody);
341
+ const nonce = (challengeMsg.payload as { nonce: string }).nonce;
342
+ client.write(makeAuthMessage(TEST_PASSWORD, nonce));
343
+
344
+ const authBody = await readMessage(client);
345
+ const authMsg = decodeMessage(authBody);
346
+ assert.strictEqual(authMsg.kind, 'auth-ok');
347
+
348
+ const dataBody = await readMessage(client);
349
+ const dataMsg = decodeMessage(dataBody);
350
+ assert.strictEqual(dataMsg.kind, 'data');
351
+
352
+ client.destroy();
353
+ });
354
+
355
+ it('should send multiple data messages on interval', async () => {
356
+ serverPort = await getFreePort();
357
+ server = new P2PServer({
358
+ host: TEST_HOST,
359
+ port: serverPort,
360
+ password: TEST_PASSWORD,
361
+ intervalMs: 150,
362
+ detailed: false,
363
+ });
364
+ await server.start();
365
+
366
+ const client = new net.Socket();
367
+ client.connect(serverPort, TEST_HOST);
368
+
369
+ const challengeBody = await readMessage(client);
370
+ const challengeMsg = decodeMessage(challengeBody);
371
+ const nonce = (challengeMsg.payload as { nonce: string }).nonce;
372
+ client.write(makeAuthMessage(TEST_PASSWORD, nonce));
373
+
374
+ const authBody = await readMessage(client);
375
+ const authMsg = decodeMessage(authBody);
376
+ assert.strictEqual(authMsg.kind, 'auth-ok');
377
+
378
+ const dataBody1 = await readMessage(client);
379
+ assert.strictEqual(decodeMessage(dataBody1).kind, 'data');
380
+
381
+ const dataBody2 = await readMessage(client);
382
+ assert.strictEqual(decodeMessage(dataBody2).kind, 'data');
383
+
384
+ client.destroy();
385
+ });
386
+ });
387
+
388
+ describe('ping/pong', () => {
389
+ it('should exchange ping and pong after authentication', async () => {
390
+ serverPort = await getFreePort();
391
+ server = new P2PServer({
392
+ host: TEST_HOST,
393
+ port: serverPort,
394
+ password: TEST_PASSWORD,
395
+ intervalMs: 1000,
396
+ detailed: false,
397
+ });
398
+ await server.start();
399
+
400
+ const client = new net.Socket();
401
+ client.connect(serverPort, TEST_HOST);
402
+
403
+ const challengeBody = await readMessage(client);
404
+ const challengeMsg = decodeMessage(challengeBody);
405
+ const nonce = (challengeMsg.payload as { nonce: string }).nonce;
406
+ client.write(makeAuthMessage(TEST_PASSWORD, nonce));
407
+
408
+ const authBody = await readMessage(client);
409
+ assert.strictEqual(decodeMessage(authBody).kind, 'auth-ok');
410
+
411
+ const dataBody = await readMessage(client);
412
+ assert.strictEqual(decodeMessage(dataBody).kind, 'data');
413
+
414
+ let pingCount = 0;
415
+ const deadline = Date.now() + 12000;
416
+ while (Date.now() < deadline && pingCount < 1) {
417
+ const msg = await readMessage(client);
418
+ const kind = decodeMessage(msg).kind;
419
+ if (kind === 'ping') {
420
+ pingCount++;
421
+ const ts = (decodeMessage(msg).payload as { ts: number }).ts;
422
+ client.write(signMessage('pong', { ts }, TEST_PASSWORD));
423
+ }
424
+ }
425
+ assert.ok(pingCount >= 1, 'Expected at least 1 ping after authentication');
426
+
427
+ client.destroy();
428
+ });
429
+
430
+ it('should continue pinging a long-lived connection', async () => {
431
+ serverPort = await getFreePort();
432
+ server = new P2PServer({
433
+ host: TEST_HOST,
434
+ port: serverPort,
435
+ password: TEST_PASSWORD,
436
+ intervalMs: 1000,
437
+ detailed: false,
438
+ });
439
+ await server.start();
440
+
441
+ const client = new net.Socket();
442
+ client.connect(serverPort, TEST_HOST);
443
+
444
+ const challengeBody = await readMessage(client);
445
+ const challengeMsg = decodeMessage(challengeBody);
446
+ const nonce = (challengeMsg.payload as { nonce: string }).nonce;
447
+ client.write(makeAuthMessage(TEST_PASSWORD, nonce));
448
+
449
+ const authBody = await readMessage(client);
450
+ assert.strictEqual(decodeMessage(authBody).kind, 'auth-ok');
451
+
452
+ const firstData = await readMessage(client);
453
+ assert.strictEqual(decodeMessage(firstData).kind, 'data');
454
+
455
+ let pingCount = 0;
456
+ const deadline = Date.now() + 16000;
457
+ while (Date.now() < deadline && pingCount < 3) {
458
+ const msg = await readMessage(client);
459
+ const kind = decodeMessage(msg).kind;
460
+ if (kind === 'ping') {
461
+ pingCount++;
462
+ const ts = (decodeMessage(msg).payload as { ts: number }).ts;
463
+ client.write(signMessage('pong', { ts }, TEST_PASSWORD));
464
+ }
465
+ }
466
+ assert.ok(pingCount >= 3, `Expected at least 3 pings, got ${pingCount}`);
467
+
468
+ client.destroy();
469
+ });
470
+ });
471
+
472
+ describe('peer event history', () => {
473
+ it('should track connect, auth, and disconnect events', async () => {
474
+ serverPort = await getFreePort();
475
+ server = new P2PServer({
476
+ host: TEST_HOST,
477
+ port: serverPort,
478
+ password: TEST_PASSWORD,
479
+ intervalMs: 1000,
480
+ detailed: false,
481
+ });
482
+ await server.start();
483
+
484
+ const client = new net.Socket();
485
+ client.connect(serverPort, TEST_HOST);
486
+
487
+ const challengeBody = await readMessage(client);
488
+ const challengeMsg = decodeMessage(challengeBody);
489
+ const nonce = (challengeMsg.payload as { nonce: string }).nonce;
490
+ client.write(makeAuthMessage(TEST_PASSWORD, nonce));
491
+
492
+ const authBody = await readMessage(client);
493
+ assert.strictEqual(decodeMessage(authBody).kind, 'auth-ok');
494
+
495
+ assert.ok(
496
+ server!.peerEventHistory.some(e => e.type === 'connect'),
497
+ 'Expected connect event in history'
498
+ );
499
+ assert.ok(
500
+ server!.peerEventHistory.some(e => e.type === 'auth'),
501
+ 'Expected auth event in history'
502
+ );
503
+
504
+ client.destroy();
505
+
506
+ await delay(200);
507
+
508
+ assert.ok(
509
+ server!.peerEventHistory.some(e => e.type === 'disconnect'),
510
+ 'Expected disconnect event in history'
511
+ );
512
+ });
513
+
514
+ it('should cap peer event history at 200 entries', async () => {
515
+ serverPort = await getFreePort();
516
+ server = new P2PServer({
517
+ host: TEST_HOST,
518
+ port: serverPort,
519
+ password: TEST_PASSWORD,
520
+ intervalMs: 1000,
521
+ detailed: false,
522
+ });
523
+ await server.start();
524
+
525
+ assert.ok(server.peerEventHistory.length <= 200, 'Expected event history to be capped at 200');
526
+ });
527
+ });
528
+
529
+ describe('convenience start function', () => {
530
+ it('startP2PServer should create and start a server', async () => {
531
+ serverPort = await getFreePort();
532
+ server = await startP2PServer({
533
+ host: TEST_HOST,
534
+ port: serverPort,
535
+ password: TEST_PASSWORD,
536
+ intervalMs: 1000,
537
+ detailed: false,
538
+ });
539
+ assert.strictEqual(server.info.port, serverPort);
540
+ assert.strictEqual(server.info.host, TEST_HOST);
541
+ });
542
+ });
543
+ });