pyre-cli 3.4.0 → 3.6.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 +414 -187
- package/package.json +1 -1
- package/src/formatters/formatters.md +164 -0
- package/src/formatters/output.ts +15 -9
- package/src/formatters/render.ts +68 -36
- package/src/history.ts +6 -0
- package/src/index.ts +20 -1
- package/src/live/input.ts +79 -57
- package/src/live/live.md +282 -0
- package/src/live/render.ts +182 -68
- package/src/live/state.ts +22 -1
- package/src/monitors/collectors.ts +126 -97
- package/src/monitors/monitors.md +165 -0
- package/src/monitors/run.ts +7 -3
- package/src/monitors/smc.ts +1 -1
- package/src/p2p/client.ts +2 -0
- package/src/p2p/p2p.md +284 -0
- package/src/p2p/server.test.ts +543 -0
- package/src/rest.md +129 -0
- package/src/sparkline.ts +8 -2
- package/src/ux.ts +62 -0
|
@@ -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
|
+
});
|
package/src/rest.md
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# src/ — Top-Level Module Files
|
|
2
|
+
|
|
3
|
+
These files live directly in `src/` (not in subdirectories) and provide shared utilities and the main CLI entry point for pyre.
|
|
4
|
+
|
|
5
|
+
## Files
|
|
6
|
+
|
|
7
|
+
| File | Purpose |
|
|
8
|
+
|------|---------|
|
|
9
|
+
| `index.ts` | Main CLI entry point — argument parsing, mode dispatch, snapshot output |
|
|
10
|
+
| `sparkline.ts` | Unicode block-character sparkline renderer |
|
|
11
|
+
| `splash.ts` | ASCII fire-effect splash screen shown on startup |
|
|
12
|
+
| `history.ts` | Rolling window of metric samples for graph rendering |
|
|
13
|
+
|
|
14
|
+
## `index.ts` — Main CLI Entry Point
|
|
15
|
+
|
|
16
|
+
The primary entry point for the pyre application. Parses CLI arguments with `commander`, dispatches to the appropriate mode (live dashboard, one-shot snapshot, P2P server/client), and handles output formatting.
|
|
17
|
+
|
|
18
|
+
### CLI Options
|
|
19
|
+
|
|
20
|
+
| Option | Description |
|
|
21
|
+
|--------|-------------|
|
|
22
|
+
| `-j, --json` | Output as JSON |
|
|
23
|
+
| `-c, --csv` | Output as CSV |
|
|
24
|
+
| `-t, --tsv` | Output as TSV |
|
|
25
|
+
| `--detailed` | Include detailed system info and sensor readings |
|
|
26
|
+
| `--theme <name>` | Default theme for live mode (default, dracula, cyberpunk, monochrome, nord, gruvbox) |
|
|
27
|
+
| `--interval <seconds>` | Refresh interval for live mode (default 2) |
|
|
28
|
+
| `--once` | Show a single static snapshot instead of live feed |
|
|
29
|
+
| `--out <file>` | Also write snapshot output to a file |
|
|
30
|
+
| `--export-dir <dir>` | Directory for live-mode snapshot exports and logs |
|
|
31
|
+
| `--log` | Start continuous CSV logging immediately when live mode starts |
|
|
32
|
+
| `--tree` | Show process tree view instead of flat list |
|
|
33
|
+
| `--sort <key>` | Sort processes by: cpu, mem, pid, user, command, state, threads, runtime |
|
|
34
|
+
| `--packets` | Include packet monitor panel in output |
|
|
35
|
+
| `--limit <n>` | Max processes in snapshot (0 = all) |
|
|
36
|
+
| `--p2p-host <host>` | P2P host address |
|
|
37
|
+
| `--p2p-port <port>` | P2P port number (default 9876) |
|
|
38
|
+
| `--p2p-password <password>` | P2P authentication password |
|
|
39
|
+
| `--p2p-tls` | Enable TLS for P2P connections |
|
|
40
|
+
| `--p2p-cert <file>` | TLS certificate file (PEM) |
|
|
41
|
+
| `--p2p-key <file>` | TLS private key file (PEM) |
|
|
42
|
+
| `--p2p-ca <file>` | TLS CA certificate file (PEM) |
|
|
43
|
+
| `--p2p-insecure` | Skip TLS certificate verification |
|
|
44
|
+
| `--p2p-rate-limit <n>` | Max auth attempts per IP per minute (default 5) |
|
|
45
|
+
| `--p2p-allow <ips>` | Comma-separated allowed IPs |
|
|
46
|
+
| `--p2p-deny <ips>` | Comma-separated denied IPs |
|
|
47
|
+
| `--p2p-audit-log <dir>` | Directory for P2P audit logs |
|
|
48
|
+
| `--p2p-hmac-key <key>` | HMAC key for message signing |
|
|
49
|
+
|
|
50
|
+
### Mode Dispatch
|
|
51
|
+
|
|
52
|
+
1. **Live mode** (`pyre live` or default without export/once flags) — starts the interactive dashboard via `startLive()`.
|
|
53
|
+
2. **P2P mode** (`pyre p2p <server|connect>`) — starts a P2P server or connects as a client.
|
|
54
|
+
3. **Export/once mode** (`--json`, `--csv`, `--tsv`, or `--once`) — collects a single snapshot and outputs it.
|
|
55
|
+
|
|
56
|
+
### Snapshot Output Flow
|
|
57
|
+
|
|
58
|
+
For one-shot modes:
|
|
59
|
+
1. Calls `collectAll()` to gather system metrics.
|
|
60
|
+
2. Formats output via `formatTable()`, `formatJson()`, `formatCsv()`, or `formatTsv()` depending on flags.
|
|
61
|
+
3. Writes to stdout and optionally to a file (`--out`).
|
|
62
|
+
|
|
63
|
+
### Imports
|
|
64
|
+
|
|
65
|
+
- `collectAll` from `./monitors/index.js`
|
|
66
|
+
- `formatTable`, `formatJson`, `formatCsv`, `formatTsv` from `./formatters/index.js`
|
|
67
|
+
- `startLive`, `stopLive` from `./live/index.js`
|
|
68
|
+
- `showSplash` from `./splash.js`
|
|
69
|
+
- `P2PServer`, `P2PClient` from `./p2p/index.js`
|
|
70
|
+
|
|
71
|
+
## `sparkline.ts` — Sparkline Renderer
|
|
72
|
+
|
|
73
|
+
Renders a series of numbers as a compact Unicode block-character sparkline suitable for inline terminal graphs.
|
|
74
|
+
|
|
75
|
+
### `sparkline(values, opts?): string`
|
|
76
|
+
|
|
77
|
+
- `values: number[]` — numeric series to render
|
|
78
|
+
- `opts.min?: number` — minimum value for normalisation (auto-computed if omitted)
|
|
79
|
+
- `opts.max?: number` — maximum value for normalisation (auto-computed if omitted)
|
|
80
|
+
- Returns a string of block characters (`▁▂▃▄▅▆█`) whose length equals `values.length`
|
|
81
|
+
|
|
82
|
+
Uses 8 Unicode block characters to represent the range. Values are normalised against `min`/`max` (auto-computed from the data if not supplied). Returns an empty string for an empty input array.
|
|
83
|
+
|
|
84
|
+
## `splash.ts` — ASCII Splash Screen
|
|
85
|
+
|
|
86
|
+
Displays an animated ASCII fire-effect splash screen on startup, based on the Doom fire effect algorithm.
|
|
87
|
+
|
|
88
|
+
### `showSplash(): Promise<void>`
|
|
89
|
+
|
|
90
|
+
Renders a 60×15 fire animation using:
|
|
91
|
+
- A `Uint8Array` heat grid with random ignition at the bottom row
|
|
92
|
+
- Fire propagation with decay and horizontal drift
|
|
93
|
+
- A 36-level colour palette mapping heat values to RGB colours
|
|
94
|
+
- Unicode character ramp (` ` → `.` → `` ` `` → `,` → `:` → `;` → `+` → `*` → `?` → `%` → `#` → `@`) for intensity representation
|
|
95
|
+
- Centered "🔥 pyre — system monitor (warming up sensors...)" text
|
|
96
|
+
- Auto-exits after 5 seconds or on any keypress
|
|
97
|
+
|
|
98
|
+
Uses ANSI escape sequences to clear the screen and position the output. Returns a Promise that resolves when the splash is dismissed.
|
|
99
|
+
|
|
100
|
+
## `history.ts` — Rolling Metric Window
|
|
101
|
+
|
|
102
|
+
Maintains a rolling window of recent metric samples used to render sparkline graphs in the live dashboard. Network rates (bytes/sec) are derived from cumulative counters by computing deltas between consecutive samples.
|
|
103
|
+
|
|
104
|
+
### `History` Class
|
|
105
|
+
|
|
106
|
+
#### Constructor
|
|
107
|
+
`new History(maxLen?)` — creates a rolling window retaining up to `maxLen` samples (default 40).
|
|
108
|
+
|
|
109
|
+
#### Properties
|
|
110
|
+
- `maxLen: number` — maximum samples retained
|
|
111
|
+
- `cpuUsage: number[]` — rolling window of CPU usage percentages (0–100)
|
|
112
|
+
- `memUsage: number[]` — rolling window of memory usage percentages (0–100)
|
|
113
|
+
- `temp: number[]` — rolling window of CPU die temperatures in °C
|
|
114
|
+
- `netRxRate: number[]` — rolling window of network receive rates in bytes/sec
|
|
115
|
+
- `netTxRate: number[]` — rolling window of network transmit rates in bytes/sec
|
|
116
|
+
- `gpuUtil: number[]` — rolling window of GPU utilization percentages (0–100)
|
|
117
|
+
- `powerWatts: number[]` — rolling window of combined power draw in watts
|
|
118
|
+
- `rxPacketRate: number[]` — rolling window of network receive packet rates in packets/sec
|
|
119
|
+
- `txPacketRate: number[]` — rolling window of network transmit packet rates in packets/sec
|
|
120
|
+
- `connections: number[]` — rolling window of active TCP connections
|
|
121
|
+
|
|
122
|
+
#### Methods
|
|
123
|
+
|
|
124
|
+
- `push(sample)` — pushes a new sample into the rolling window. Computes network rates as bytes/sec from cumulative counters. The first sample always reports 0 for rates.
|
|
125
|
+
- `sample` fields: `cpuUsage`, `memUsage`, `temp` (nullable), `rxBytes`, `txBytes`, `gpuUtil?`, `powerWatts?`, `rxPackets?`, `txPackets?`, `connections?`
|
|
126
|
+
|
|
127
|
+
- `reset()` — clears all rolling windows and resets cumulative byte counters.
|
|
128
|
+
|
|
129
|
+
- `setMaxLen(n)` — grows or shrinks the rolling window, trimming oldest samples. `n` is clamped to at least 1.
|
package/src/sparkline.ts
CHANGED
|
@@ -20,8 +20,14 @@ const BLOCKS = '▁▂▃▄▅▆▇█';
|
|
|
20
20
|
*/
|
|
21
21
|
export function sparkline(values: number[], opts: { min?: number; max?: number } = {}): string {
|
|
22
22
|
if (!values.length) return '';
|
|
23
|
-
|
|
24
|
-
|
|
23
|
+
let min = opts.min ?? Infinity;
|
|
24
|
+
let max = opts.max ?? -Infinity;
|
|
25
|
+
if (opts.min === undefined || opts.max === undefined) {
|
|
26
|
+
for (const v of values) {
|
|
27
|
+
if (v < min) min = v;
|
|
28
|
+
if (v > max) max = v;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
25
31
|
const range = max - min || 1;
|
|
26
32
|
|
|
27
33
|
return values
|