pyre-cli 2.0.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/src/p2p/server.ts CHANGED
@@ -1,7 +1,10 @@
1
1
  import net from 'node:net';
2
+ import tls from 'node:tls';
3
+ import fs from 'node:fs';
4
+ import crypto from 'node:crypto';
2
5
  import chalk from 'chalk';
3
6
  import { collectAll } from '../monitors/index.js';
4
- import { encodeAuthOk, encodeAuthFail, encodeData, encodePing, encodePong, encodeDisconnect } from './protocol.js';
7
+ import { encodeAuthOk, encodeAuthFail, encodeData, encodePing, encodePong, encodeDisconnect, hashPassword, computeHMAC, verifyHMAC, signMessage, decodeMessage, generateNonce } from './protocol.js';
5
8
  import type { P2PServerOptions, P2PConnectionInfo } from './types.js';
6
9
 
7
10
  interface Peer {
@@ -12,9 +15,14 @@ interface Peer {
12
15
  readBuffer: Buffer;
13
16
  readExpectingHeader: boolean;
14
17
  readMessageLength: number;
18
+ ip: string;
19
+ pendingNonce: string | null;
15
20
  }
16
21
 
17
22
  const HEADER_SIZE = 4;
23
+ const DEFAULT_RATE_LIMIT_MAX = 5;
24
+ const DEFAULT_RATE_LIMIT_WINDOW = 60000;
25
+ const HMAC_KEY_LENGTH = 32;
18
26
 
19
27
  function readMessageFromPeer(peer: Peer): Buffer | null {
20
28
  while (peer.readBuffer.length > 0) {
@@ -42,14 +50,44 @@ function readMessageFromPeer(peer: Peer): Buffer | null {
42
50
  return null;
43
51
  }
44
52
 
53
+ function isIPAllowed(ip: string, allowedIPs: string[], deniedIPs: string[]): boolean {
54
+ for (const denied of deniedIPs) {
55
+ if (ip === denied || ip.startsWith(denied)) {
56
+ return false;
57
+ }
58
+ }
59
+ if (allowedIPs.length === 0) {
60
+ return true;
61
+ }
62
+ for (const allowed of allowedIPs) {
63
+ if (ip === allowed || ip.startsWith(allowed)) {
64
+ return true;
65
+ }
66
+ }
67
+ return false;
68
+ }
69
+
70
+ interface RateLimitEntry {
71
+ attempts: number;
72
+ firstAttempt: number;
73
+ }
74
+
75
+ function deriveHMACKey(password: string): string {
76
+ return crypto.createHash('sha256').update(password).digest('hex').slice(0, HMAC_KEY_LENGTH);
77
+ }
78
+
45
79
  export class P2PServer {
46
- private server: net.Server | null = null;
80
+ private server: net.Server | tls.Server | null = null;
47
81
  private peers: Map<net.Socket, Peer> = new Map();
48
82
  private options: P2PServerOptions;
49
83
  private running = false;
84
+ private rateLimitMap: Map<string, RateLimitEntry> = new Map();
85
+ private auditStream: fs.WriteStream | null = null;
86
+ private hmacKey: string;
50
87
 
51
88
  constructor(options: P2PServerOptions) {
52
89
  this.options = options;
90
+ this.hmacKey = deriveHMACKey(options.password);
53
91
  }
54
92
 
55
93
  get info(): P2PConnectionInfo {
@@ -61,16 +99,45 @@ export class P2PServer {
61
99
  }
62
100
 
63
101
  async start(): Promise<void> {
102
+ if (this.options.auditLog) {
103
+ try {
104
+ fs.mkdirSync(this.options.auditLog, { recursive: true });
105
+ const logFile = `${this.options.auditLog}/p2p-audit.log`;
106
+ this.auditStream = fs.createWriteStream(logFile, { flags: 'a' });
107
+ } catch {
108
+ console.log(chalk.yellow('Warning: could not open audit log'));
109
+ }
110
+ }
111
+
64
112
  return new Promise((resolve, reject) => {
65
- this.server = net.createServer((socket) => this.onConnection(socket));
66
- this.server.on('error', (err) => reject(err));
113
+ const useTLS = !!(this.options.tlsCert && this.options.tlsKey);
114
+
115
+ if (useTLS) {
116
+ const tlsOptions: tls.SecureContextOptions = {
117
+ key: fs.readFileSync(this.options.tlsKey!),
118
+ cert: fs.readFileSync(this.options.tlsCert!),
119
+ };
120
+ if (this.options.tlsCA) {
121
+ tlsOptions.ca = fs.readFileSync(this.options.tlsCA!);
122
+ }
123
+ this.server = tls.createServer(tlsOptions, (socket) => this.onConnection(socket));
124
+ } else {
125
+ this.server = net.createServer((socket) => this.onConnection(socket));
126
+ }
127
+
128
+ this.server.on('error', (err: Error) => reject(err));
67
129
  this.server.listen(this.options.port, this.options.host, () => {
68
130
  this.running = true;
69
131
  const addr = this.server?.address();
70
132
  const bound = addr ? (typeof addr === 'object' ? `${addr.address}:${addr.port}` : String(addr)) : 'unknown';
71
- console.log(chalk.green(`P2P server listening on ${bound}`));
133
+ const protocol = useTLS ? 'TLS' : 'TCP';
134
+ console.log(chalk.green(`P2P server listening on ${bound} (${protocol})`));
72
135
  console.log(chalk.dim(` Password: ${this.options.password}`));
73
136
  console.log(chalk.dim(` Interval: ${this.options.intervalMs}ms`));
137
+ const allowedIPs = this.options.allowedIPs ?? [];
138
+ const deniedIPs = this.options.deniedIPs ?? [];
139
+ if (allowedIPs.length > 0) console.log(chalk.dim(` Allowed IPs: ${allowedIPs.join(', ')}`));
140
+ if (deniedIPs.length > 0) console.log(chalk.dim(` Denied IPs: ${deniedIPs.join(', ')}`));
74
141
  resolve();
75
142
  });
76
143
  });
@@ -82,13 +149,79 @@ export class P2PServer {
82
149
  this.disconnectPeer(peer, 'Server shutting down');
83
150
  }
84
151
  this.peers.clear();
152
+ this.rateLimitMap.clear();
85
153
  if (this.server) {
86
154
  this.server.close();
87
155
  this.server = null;
88
156
  }
157
+ if (this.auditStream) {
158
+ this.auditStream.end();
159
+ this.auditStream = null;
160
+ }
161
+ }
162
+
163
+ private audit(ip: string, event: string, detail: string): void {
164
+ if (!this.auditStream) return;
165
+ const ts = new Date().toISOString();
166
+ this.auditStream.write(`${ts} ${ip} ${event} ${detail}\n`);
167
+ }
168
+
169
+ private getPeerIP(socket: net.Socket): string {
170
+ const addr = socket.remoteAddress;
171
+ if (!addr) return 'unknown';
172
+ if (addr.startsWith('::ffff:')) {
173
+ return addr.slice(7);
174
+ }
175
+ return addr;
176
+ }
177
+
178
+ private checkRateLimit(ip: string): boolean {
179
+ const maxAttempts = this.options.rateLimitMaxAttempts ?? DEFAULT_RATE_LIMIT_MAX;
180
+ const windowMs = this.options.rateLimitWindowMs ?? DEFAULT_RATE_LIMIT_WINDOW;
181
+ const now = Date.now();
182
+
183
+ const entry = this.rateLimitMap.get(ip);
184
+ if (!entry) {
185
+ this.rateLimitMap.set(ip, { attempts: 1, firstAttempt: now });
186
+ return true;
187
+ }
188
+
189
+ if (now - entry.firstAttempt > windowMs) {
190
+ this.rateLimitMap.set(ip, { attempts: 1, firstAttempt: now });
191
+ return true;
192
+ }
193
+
194
+ entry.attempts++;
195
+ if (entry.attempts > maxAttempts) {
196
+ return false;
197
+ }
198
+ return true;
199
+ }
200
+
201
+ private cleanupRateLimit(ip: string): void {
202
+ this.rateLimitMap.delete(ip);
89
203
  }
90
204
 
91
205
  private onConnection(socket: net.Socket): void {
206
+ const ip = this.getPeerIP(socket);
207
+
208
+ const allowedIPs = this.options.allowedIPs ?? [];
209
+ const deniedIPs = this.options.deniedIPs ?? [];
210
+
211
+ if (!isIPAllowed(ip, allowedIPs, deniedIPs)) {
212
+ this.audit(ip, 'BLOCKED', 'IP not allowed');
213
+ console.log(chalk.yellow(`Blocked IP: ${ip}`));
214
+ socket.destroy();
215
+ return;
216
+ }
217
+
218
+ if (!this.checkRateLimit(ip)) {
219
+ this.audit(ip, 'RATE_LIMITED', 'Too many auth attempts');
220
+ console.log(chalk.yellow(`Rate limit exceeded for IP: ${ip}`));
221
+ socket.destroy();
222
+ return;
223
+ }
224
+
92
225
  const peer: Peer = {
93
226
  socket,
94
227
  authenticated: false,
@@ -97,6 +230,8 @@ export class P2PServer {
97
230
  readBuffer: Buffer.alloc(0),
98
231
  readExpectingHeader: true,
99
232
  readMessageLength: 0,
233
+ ip,
234
+ pendingNonce: null,
100
235
  };
101
236
  this.peers.set(socket, peer);
102
237
 
@@ -104,6 +239,7 @@ export class P2PServer {
104
239
  ? `${socket.remoteAddress}:${socket.remotePort}`
105
240
  : 'unknown';
106
241
  console.log(chalk.cyan(`Peer connected: ${remoteAddr}`));
242
+ this.audit(ip, 'CONNECT', remoteAddr);
107
243
 
108
244
  socket.on('data', (data) => this.onData(peer, data));
109
245
  socket.on('close', () => this.onPeerClose(peer));
@@ -115,6 +251,10 @@ export class P2PServer {
115
251
  this.disconnectPeer(peer, 'Authentication timeout');
116
252
  }
117
253
  });
254
+
255
+ const nonce = generateNonce();
256
+ this.sendSigned(peer, signMessage('challenge', { nonce }, this.hmacKey));
257
+ peer.pendingNonce = nonce;
118
258
  }
119
259
 
120
260
  private onData(peer: Peer, data: Buffer): void {
@@ -131,34 +271,62 @@ export class P2PServer {
131
271
 
132
272
  private handleAuthMessage(peer: Peer, body: Buffer): void {
133
273
  try {
134
- const msg = JSON.parse(body.toString('utf-8'));
135
- if (msg.kind === 'auth' && typeof msg.payload?.password === 'string') {
136
- if (msg.payload.password === this.options.password) {
137
- peer.authenticated = true;
138
- peer.socket.write(encodeAuthOk());
139
- console.log(chalk.green(`Peer authenticated: ${peer.socket.remoteAddress}`));
140
- this.startDataStream(peer);
141
- this.startPing(peer);
142
- } else {
143
- peer.socket.write(encodeAuthFail('Invalid password'));
144
- this.disconnectPeer(peer, 'Authentication failed');
145
- }
146
- } else {
147
- peer.socket.write(encodeAuthFail('Expected auth message first'));
274
+ const raw = body.toString('utf-8');
275
+ const msg = decodeMessage(body);
276
+ if (msg.kind !== 'auth') {
277
+ peer.socket.write(signMessage('auth-fail', { ok: false, reason: 'Expected auth message first' }, this.hmacKey));
148
278
  this.disconnectPeer(peer, 'Expected auth message');
279
+ return;
280
+ }
281
+
282
+ const authPayload = msg.payload as { passwordHash?: string; nonce?: string };
283
+ if (!authPayload.passwordHash || !authPayload.nonce) {
284
+ peer.socket.write(signMessage('auth-fail', { ok: false, reason: 'Invalid auth payload' }, this.hmacKey));
285
+ this.disconnectPeer(peer, 'Invalid auth payload');
286
+ return;
287
+ }
288
+
289
+ if (peer.pendingNonce && authPayload.nonce !== peer.pendingNonce) {
290
+ this.audit(peer.ip, 'AUTH_FAIL', 'Nonce mismatch');
291
+ peer.socket.write(signMessage('auth-fail', { ok: false, reason: 'Invalid nonce' }, this.hmacKey));
292
+ this.disconnectPeer(peer, 'Invalid nonce');
293
+ return;
294
+ }
295
+
296
+ const expectedHash = hashPassword(this.options.password, authPayload.nonce);
297
+ if (authPayload.passwordHash !== expectedHash) {
298
+ this.audit(peer.ip, 'AUTH_FAIL', 'Invalid password hash');
299
+ peer.socket.write(signMessage('auth-fail', { ok: false, reason: 'Invalid password' }, this.hmacKey));
300
+ this.disconnectPeer(peer, 'Authentication failed');
301
+ return;
149
302
  }
303
+
304
+ peer.authenticated = true;
305
+ this.cleanupRateLimit(peer.ip);
306
+ peer.pendingNonce = null;
307
+ peer.socket.write(signMessage('auth-ok', { ok: true }, this.hmacKey));
308
+ console.log(chalk.green(`Peer authenticated: ${peer.socket.remoteAddress}`));
309
+ this.audit(peer.ip, 'AUTH_OK', 'Challenge-response successful');
310
+ this.startDataStream(peer);
311
+ this.startPing(peer);
150
312
  } catch {
151
- peer.socket.write(encodeAuthFail('Invalid message format'));
313
+ peer.socket.write(signMessage('auth-fail', { ok: false, reason: 'Invalid message format' }, this.hmacKey));
152
314
  this.disconnectPeer(peer, 'Invalid message format');
153
315
  }
154
316
  }
155
317
 
318
+ private sendSigned(peer: Peer, message: Buffer): void {
319
+ if (!peer.socket.destroyed) {
320
+ peer.socket.write(message);
321
+ }
322
+ }
323
+
156
324
  private startDataStream(peer: Peer): void {
157
325
  const send = async () => {
158
326
  if (!peer.authenticated || peer.socket.destroyed) return;
159
327
  try {
160
328
  const data = await collectAll({ detailed: this.options.detailed });
161
- peer.socket.write(encodeData(data));
329
+ this.sendSigned(peer, signMessage('data', { stats: data }, this.hmacKey));
162
330
  } catch {
163
331
  // skip failed tick
164
332
  }
@@ -171,28 +339,31 @@ export class P2PServer {
171
339
  private startPing(peer: Peer): void {
172
340
  peer.pingHandle = setInterval(() => {
173
341
  if (peer.socket.destroyed) return;
174
- peer.socket.write(encodePing());
342
+ this.sendSigned(peer, signMessage('ping', { ts: Date.now() }, this.hmacKey));
175
343
  }, 5000);
176
344
  }
177
345
 
178
346
  private onPeerClose(peer: Peer): void {
179
347
  this.cleanupPeer(peer);
180
348
  console.log(chalk.yellow(`Peer disconnected: ${peer.socket.remoteAddress}`));
349
+ this.audit(peer.ip, 'DISCONNECT', 'Peer closed connection');
181
350
  }
182
351
 
183
352
  private onPeerError(peer: Peer, err: Error): void {
184
353
  const code = (err as any).code;
185
354
  if (code === 'ECONNRESET') {
186
355
  console.log(chalk.yellow(`Peer connection reset: ${peer.socket.remoteAddress}`));
356
+ this.audit(peer.ip, 'ERROR', 'Connection reset');
187
357
  } else {
188
358
  console.log(chalk.red(`Peer error: ${err.message}`));
359
+ this.audit(peer.ip, 'ERROR', err.message);
189
360
  }
190
361
  this.cleanupPeer(peer);
191
362
  }
192
363
 
193
364
  private disconnectPeer(peer: Peer, reason: string): void {
194
365
  try {
195
- peer.socket.write(encodeDisconnect(reason));
366
+ this.sendSigned(peer, signMessage('disconnect', { reason }, this.hmacKey));
196
367
  } catch {
197
368
  // socket may already be closed
198
369
  }
package/src/p2p/types.ts CHANGED
@@ -5,15 +5,21 @@
5
5
  * (host) and a pyre client (peer) over a TCP connection.
6
6
  */
7
7
 
8
- export type P2PMessageKind = 'auth' | 'auth-ok' | 'auth-fail' | 'data' | 'ping' | 'pong' | 'disconnect';
8
+ export type P2PMessageKind = 'auth' | 'auth-ok' | 'auth-fail' | 'data' | 'ping' | 'pong' | 'disconnect' | 'challenge';
9
9
 
10
10
  export interface P2PMessage {
11
11
  kind: P2PMessageKind;
12
12
  payload: unknown;
13
+ hmac?: string;
14
+ }
15
+
16
+ export interface ChallengeMessage {
17
+ nonce: string;
13
18
  }
14
19
 
15
20
  export interface AuthMessage {
16
- password: string;
21
+ passwordHash: string;
22
+ nonce: string;
17
23
  }
18
24
 
19
25
  export interface AuthOkMessage {
@@ -42,6 +48,7 @@ export interface DisconnectMessage {
42
48
  }
43
49
 
44
50
  export type P2PPayload =
51
+ | ChallengeMessage
45
52
  | AuthMessage
46
53
  | AuthOkMessage
47
54
  | AuthFailMessage
@@ -56,16 +63,35 @@ export interface P2PServerOptions {
56
63
  password: string;
57
64
  intervalMs: number;
58
65
  detailed: boolean;
66
+ tlsCert?: string;
67
+ tlsKey?: string;
68
+ tlsCA?: string;
69
+ rateLimitMaxAttempts?: number;
70
+ rateLimitWindowMs?: number;
71
+ allowedIPs?: string[];
72
+ deniedIPs?: string[];
73
+ auditLog?: string;
74
+ hmacKey?: string;
59
75
  }
60
76
 
61
77
  export interface P2PClientOptions {
62
78
  host: string;
63
79
  port: number;
64
80
  password: string;
81
+ tls?: boolean;
82
+ tlsCA?: string;
83
+ tlsInsecure?: boolean;
84
+ auditLog?: string;
85
+ hmacKey?: string;
65
86
  }
66
87
 
67
88
  export interface P2PConnectionInfo {
68
89
  host: string;
69
90
  port: number;
70
91
  peersConnected: number;
92
+ }
93
+
94
+ interface RateLimitEntry {
95
+ attempts: number;
96
+ firstAttempt: number;
71
97
  }