pyre-cli 1.4.0 → 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/README.md +78 -11
- package/dist/index.js +1325 -33
- package/package.json +2 -2
- package/src/formatters/index.ts +1 -1
- package/src/formatters/output.ts +47 -19
- package/src/formatters/render.ts +207 -16
- package/src/formatters/types.ts +4 -0
- package/src/history.ts +85 -47
- package/src/index.ts +200 -20
- package/src/live/export.ts +5 -2
- package/src/live/input.ts +296 -164
- package/src/live/render.ts +32 -28
- package/src/live/state.ts +41 -21
- package/src/monitors/collectors.ts +220 -17
- package/src/monitors/index.ts +2 -2
- package/src/monitors/types.ts +42 -1
- package/src/p2p/client.ts +271 -0
- package/src/p2p/index.ts +3 -0
- package/src/p2p/protocol.ts +112 -0
- package/src/p2p/server.ts +390 -0
- package/src/p2p/types.ts +97 -0
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
import net from 'node:net';
|
|
2
|
+
import tls from 'node:tls';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import crypto from 'node:crypto';
|
|
5
|
+
import chalk from 'chalk';
|
|
6
|
+
import { encodeChallenge, encodeAuth, encodePing, encodePong, encodeDisconnect, hashPassword, computeHMAC, verifyHMAC, signMessage, decodeMessage, generateNonce } from './protocol.js';
|
|
7
|
+
import type { P2PClientOptions } from './types.js';
|
|
8
|
+
import type { StatsData } from '../monitors/types.js';
|
|
9
|
+
|
|
10
|
+
interface ReadState {
|
|
11
|
+
buffer: Buffer;
|
|
12
|
+
expectingHeader: boolean;
|
|
13
|
+
messageLength: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export class P2PClient {
|
|
17
|
+
private socket: net.Socket | tls.TLSSocket | null = null;
|
|
18
|
+
private options: P2PClientOptions;
|
|
19
|
+
private running = false;
|
|
20
|
+
private readState: ReadState = { buffer: Buffer.alloc(0), expectingHeader: true, messageLength: 0 };
|
|
21
|
+
private onDataCallback: ((data: StatsData) => void) | null = null;
|
|
22
|
+
private onStatusCallback: ((msg: string) => void) | null = null;
|
|
23
|
+
private reconnectTimer: NodeJS.Timeout | null = null;
|
|
24
|
+
private pingHandle: NodeJS.Timeout | null = null;
|
|
25
|
+
private pendingNonce: string | null = null;
|
|
26
|
+
private auditStream: fs.WriteStream | null = null;
|
|
27
|
+
private hmacKey: string;
|
|
28
|
+
|
|
29
|
+
constructor(options: P2PClientOptions) {
|
|
30
|
+
this.options = options;
|
|
31
|
+
this.hmacKey = deriveHMACKey(options.password);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
setOnData(cb: (data: StatsData) => void): void {
|
|
35
|
+
this.onDataCallback = cb;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
setOnStatus(cb: (msg: string) => void): void {
|
|
39
|
+
this.onStatusCallback = cb;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async connect(): Promise<void> {
|
|
43
|
+
if (this.options.auditLog) {
|
|
44
|
+
try {
|
|
45
|
+
fs.mkdirSync(this.options.auditLog, { recursive: true });
|
|
46
|
+
const logFile = `${this.options.auditLog}/p2p-client-audit.log`;
|
|
47
|
+
this.auditStream = fs.createWriteStream(logFile, { flags: 'a' });
|
|
48
|
+
} catch {
|
|
49
|
+
console.log(chalk.yellow('Warning: could not open audit log'));
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return new Promise((resolve, reject) => {
|
|
54
|
+
const useTLS = !!this.options.tls;
|
|
55
|
+
|
|
56
|
+
if (useTLS) {
|
|
57
|
+
const tlsOptions: tls.ConnectionOptions = {
|
|
58
|
+
host: this.options.host,
|
|
59
|
+
port: this.options.port,
|
|
60
|
+
rejectUnauthorized: !this.options.tlsInsecure,
|
|
61
|
+
};
|
|
62
|
+
if (this.options.tlsCA) {
|
|
63
|
+
tlsOptions.ca = fs.readFileSync(this.options.tlsCA);
|
|
64
|
+
}
|
|
65
|
+
this.socket = tls.connect(tlsOptions, () => {
|
|
66
|
+
this.status('Connecting...');
|
|
67
|
+
});
|
|
68
|
+
} else {
|
|
69
|
+
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
|
+
this.socket.setTimeout(10000);
|
|
75
|
+
this.socket.connect(this.options.port, this.options.host, () => {
|
|
76
|
+
this.status('Connecting...');
|
|
77
|
+
});
|
|
78
|
+
this.socket.once('error', (err) => {
|
|
79
|
+
reject(err);
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
this.socket.on('data', (data) => this.onData(data));
|
|
84
|
+
this.socket.on('close', () => this.onClose());
|
|
85
|
+
this.socket.on('error', (err) => this.onError(err));
|
|
86
|
+
this.socket.on('timeout', () => this.onTimeout());
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
disconnect(): void {
|
|
91
|
+
this.running = false;
|
|
92
|
+
if (this.reconnectTimer) {
|
|
93
|
+
clearTimeout(this.reconnectTimer);
|
|
94
|
+
this.reconnectTimer = null;
|
|
95
|
+
}
|
|
96
|
+
if (this.pingHandle) {
|
|
97
|
+
clearInterval(this.pingHandle);
|
|
98
|
+
this.pingHandle = null;
|
|
99
|
+
}
|
|
100
|
+
if (this.socket) {
|
|
101
|
+
try {
|
|
102
|
+
this.sendSigned(encodeDisconnect('Client closing'));
|
|
103
|
+
} catch {
|
|
104
|
+
// ignore
|
|
105
|
+
}
|
|
106
|
+
this.socket.destroy();
|
|
107
|
+
this.socket = null;
|
|
108
|
+
}
|
|
109
|
+
if (this.auditStream) {
|
|
110
|
+
this.auditStream.end();
|
|
111
|
+
this.auditStream = null;
|
|
112
|
+
}
|
|
113
|
+
this.status('Disconnected');
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
private onData(data: Buffer): void {
|
|
117
|
+
this.readState.buffer = Buffer.concat([this.readState.buffer, data]);
|
|
118
|
+
|
|
119
|
+
while (this.readState.buffer.length > 0) {
|
|
120
|
+
if (this.readState.expectingHeader) {
|
|
121
|
+
if (this.readState.buffer.length >= 4) {
|
|
122
|
+
this.readState.messageLength = this.readState.buffer.readUInt32BE(0);
|
|
123
|
+
this.readState.buffer = this.readState.buffer.subarray(4);
|
|
124
|
+
this.readState.expectingHeader = false;
|
|
125
|
+
} else {
|
|
126
|
+
break;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (!this.readState.expectingHeader) {
|
|
131
|
+
if (this.readState.buffer.length >= this.readState.messageLength) {
|
|
132
|
+
const body = this.readState.buffer.subarray(0, this.readState.messageLength);
|
|
133
|
+
this.readState.buffer = this.readState.buffer.subarray(this.readState.messageLength);
|
|
134
|
+
this.readState.expectingHeader = true;
|
|
135
|
+
this.handleMessage(body);
|
|
136
|
+
} else {
|
|
137
|
+
break;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
private handleMessage(body: Buffer): void {
|
|
144
|
+
try {
|
|
145
|
+
const raw = body.toString('utf-8');
|
|
146
|
+
const msg = decodeMessage(body);
|
|
147
|
+
|
|
148
|
+
if (!verifyHMAC(raw, msg.hmac ?? '', this.hmacKey)) {
|
|
149
|
+
this.status('Message integrity check failed');
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
switch (msg.kind) {
|
|
154
|
+
case 'challenge':
|
|
155
|
+
const challengePayload = msg.payload as { nonce?: string };
|
|
156
|
+
if (challengePayload.nonce) {
|
|
157
|
+
this.pendingNonce = challengePayload.nonce;
|
|
158
|
+
const passwordHash = hashPassword(this.options.password, challengePayload.nonce);
|
|
159
|
+
this.sendSigned(signMessage('auth', { passwordHash, nonce: challengePayload.nonce }, this.hmacKey));
|
|
160
|
+
this.status('Authenticating...');
|
|
161
|
+
}
|
|
162
|
+
break;
|
|
163
|
+
|
|
164
|
+
case 'auth-ok':
|
|
165
|
+
this.status('Authenticated — receiving live data');
|
|
166
|
+
this.startPing();
|
|
167
|
+
break;
|
|
168
|
+
|
|
169
|
+
case 'auth-fail':
|
|
170
|
+
const failPayload = msg.payload as { reason?: string };
|
|
171
|
+
this.status(`Authentication failed: ${failPayload.reason || 'unknown'}`);
|
|
172
|
+
this.disconnect();
|
|
173
|
+
break;
|
|
174
|
+
|
|
175
|
+
case 'data':
|
|
176
|
+
if (this.onDataCallback) {
|
|
177
|
+
const dataPayload = msg.payload as { stats?: StatsData };
|
|
178
|
+
if (dataPayload.stats) {
|
|
179
|
+
this.onDataCallback(dataPayload.stats);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
break;
|
|
183
|
+
|
|
184
|
+
case 'ping':
|
|
185
|
+
if (this.socket && !this.socket.destroyed) {
|
|
186
|
+
const ts = (msg.payload as { ts: number }).ts;
|
|
187
|
+
this.sendSigned(signMessage('pong', { ts }, this.hmacKey));
|
|
188
|
+
}
|
|
189
|
+
break;
|
|
190
|
+
|
|
191
|
+
case 'pong':
|
|
192
|
+
break;
|
|
193
|
+
|
|
194
|
+
case 'disconnect':
|
|
195
|
+
const disconnectPayload = msg.payload as { reason?: string };
|
|
196
|
+
this.status(`Server disconnected: ${disconnectPayload.reason || 'unknown'}`);
|
|
197
|
+
this.disconnect();
|
|
198
|
+
break;
|
|
199
|
+
|
|
200
|
+
default:
|
|
201
|
+
break;
|
|
202
|
+
}
|
|
203
|
+
} catch {
|
|
204
|
+
// ignore malformed messages
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
private sendSigned(message: Buffer): void {
|
|
209
|
+
if (this.socket && !this.socket.destroyed) {
|
|
210
|
+
this.socket.write(message);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
private startPing(): void {
|
|
215
|
+
this.pingHandle = setInterval(() => {
|
|
216
|
+
if (this.socket && !this.socket.destroyed) {
|
|
217
|
+
this.sendSigned(signMessage('ping', { ts: Date.now() }, this.hmacKey));
|
|
218
|
+
}
|
|
219
|
+
}, 15000);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
private onClose(): void {
|
|
223
|
+
if (this.running) {
|
|
224
|
+
this.status('Connection closed — reconnecting in 3s');
|
|
225
|
+
this.reconnectTimer = setTimeout(() => {
|
|
226
|
+
if (this.running) this.connect().catch(() => {});
|
|
227
|
+
}, 3000);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
private onError(err: Error): void {
|
|
232
|
+
const code = (err as any).code;
|
|
233
|
+
if (code === 'ECONNREFUSED') {
|
|
234
|
+
this.status('Connection refused — is the server running?');
|
|
235
|
+
} else if (code === 'ETIMEDOUT') {
|
|
236
|
+
this.status('Connection timed out');
|
|
237
|
+
} else if (code === 'UNABLE_TO_VERIFY_LEAF_SIGNATURE') {
|
|
238
|
+
this.status('TLS certificate verification failed — use --p2p-insecure to skip');
|
|
239
|
+
} else {
|
|
240
|
+
this.status(`Connection error: ${err.message}`);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
private onTimeout(): void {
|
|
245
|
+
this.status('Connection timed out');
|
|
246
|
+
this.disconnect();
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
private status(msg: string): void {
|
|
250
|
+
if (this.onStatusCallback) {
|
|
251
|
+
this.onStatusCallback(msg);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
audit(event: string, detail: string): void {
|
|
256
|
+
if (!this.auditStream) return;
|
|
257
|
+
const ts = new Date().toISOString();
|
|
258
|
+
const ip = this.options.host;
|
|
259
|
+
this.auditStream.write(`${ts} ${ip} ${event} ${detail}\n`);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function deriveHMACKey(password: string): string {
|
|
264
|
+
return crypto.createHash('sha256').update(password).digest('hex').slice(0, 32);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
export async function connectP2PClient(options: P2PClientOptions): Promise<P2PClient> {
|
|
268
|
+
const client = new P2PClient(options);
|
|
269
|
+
await client.connect();
|
|
270
|
+
return client;
|
|
271
|
+
}
|
package/src/p2p/index.ts
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import type { P2PMessage, P2PMessageKind, P2PPayload, ChallengeMessage, AuthMessage } from './types.js';
|
|
3
|
+
import type { StatsData } from '../monitors/types.js';
|
|
4
|
+
|
|
5
|
+
const HEADER_SIZE = 4;
|
|
6
|
+
const MAX_MESSAGE_SIZE = 16 * 1024 * 1024;
|
|
7
|
+
const HMAC_ALGO = 'sha256';
|
|
8
|
+
|
|
9
|
+
function buildMessage(kind: P2PMessageKind, payload: P2PPayload, hmac?: string): Buffer {
|
|
10
|
+
const envelope: P2PMessage = { kind, payload };
|
|
11
|
+
if (hmac) envelope.hmac = hmac;
|
|
12
|
+
const body = Buffer.from(JSON.stringify(envelope), 'utf-8');
|
|
13
|
+
const header = Buffer.alloc(HEADER_SIZE);
|
|
14
|
+
header.writeUInt32BE(body.length, 0);
|
|
15
|
+
return Buffer.concat([header, body]);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function generateNonce(): string {
|
|
19
|
+
return crypto.randomBytes(32).toString('hex');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function hashPassword(password: string, nonce: string): string {
|
|
23
|
+
return crypto.createHash('sha256').update(`${password}:${nonce}`).digest('hex');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function computeHMAC(body: string, key?: string): string | undefined {
|
|
27
|
+
if (!key) return undefined;
|
|
28
|
+
return crypto.createHmac(HMAC_ALGO, key).update(body).digest('hex');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function verifyHMAC(body: string, expectedHmac: string, key?: string): boolean {
|
|
32
|
+
if (!key) return expectedHmac === undefined;
|
|
33
|
+
if (!expectedHmac) return false;
|
|
34
|
+
try {
|
|
35
|
+
const parsed = JSON.parse(body) as P2PMessage;
|
|
36
|
+
const payloadWithoutHmac = { ...parsed, hmac: undefined };
|
|
37
|
+
const canonical = JSON.stringify(payloadWithoutHmac);
|
|
38
|
+
const computed = crypto.createHmac(HMAC_ALGO, key).update(canonical).digest('hex');
|
|
39
|
+
return crypto.timingSafeEqual(Buffer.from(computed), Buffer.from(expectedHmac));
|
|
40
|
+
} catch {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function encodeChallenge(nonce: string): Buffer {
|
|
46
|
+
return buildMessage('challenge', { nonce });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function encodeAuth(passwordHash: string, nonce: string): Buffer {
|
|
50
|
+
return buildMessage('auth', { passwordHash, nonce });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function encodeAuthOk(): Buffer {
|
|
54
|
+
return buildMessage('auth-ok', { ok: true });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function encodeAuthFail(reason: string): Buffer {
|
|
58
|
+
return buildMessage('auth-fail', { ok: false, reason });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function encodeData(stats: StatsData): Buffer {
|
|
62
|
+
return buildMessage('data', { stats });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function encodePing(): Buffer {
|
|
66
|
+
return buildMessage('ping', { ts: Date.now() });
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function encodePong(ts: number): Buffer {
|
|
70
|
+
return buildMessage('pong', { ts });
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function encodeDisconnect(reason: string): Buffer {
|
|
74
|
+
return buildMessage('disconnect', { reason });
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function decodeMessage(buf: Buffer): P2PMessage {
|
|
78
|
+
const str = buf.toString('utf-8');
|
|
79
|
+
const msg: P2PMessage = JSON.parse(str);
|
|
80
|
+
return msg;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function signMessage(kind: P2PMessageKind, payload: P2PPayload, key?: string): Buffer {
|
|
84
|
+
const envelope: P2PMessage = { kind, payload };
|
|
85
|
+
const bodyJson = JSON.stringify(envelope);
|
|
86
|
+
const hmac = computeHMAC(bodyJson, key);
|
|
87
|
+
return buildMessage(kind, payload, hmac);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function readMessage(stream: { read(n: number): Buffer | null }): Buffer | null {
|
|
91
|
+
const header = stream.read(HEADER_SIZE);
|
|
92
|
+
if (!header || header.length < HEADER_SIZE) return null;
|
|
93
|
+
const length = header.readUInt32BE(0);
|
|
94
|
+
if (length > MAX_MESSAGE_SIZE) {
|
|
95
|
+
throw new Error(`Message too large: ${length} bytes`);
|
|
96
|
+
}
|
|
97
|
+
const body = stream.read(length);
|
|
98
|
+
if (!body || body.length < length) return null;
|
|
99
|
+
return body;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function readAll(stream: { read(n: number): Buffer | null }, total: number): Buffer {
|
|
103
|
+
const chunks: Buffer[] = [];
|
|
104
|
+
let remaining = total;
|
|
105
|
+
while (remaining > 0) {
|
|
106
|
+
const chunk = stream.read(remaining);
|
|
107
|
+
if (!chunk) break;
|
|
108
|
+
chunks.push(chunk);
|
|
109
|
+
remaining -= chunk.length;
|
|
110
|
+
}
|
|
111
|
+
return Buffer.concat(chunks);
|
|
112
|
+
}
|