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/README.md +0 -1
- package/dist/index.js +365 -83
- package/package.json +1 -1
- package/src/index.ts +63 -3
- package/src/monitors/collectors.ts +1 -1
- package/src/p2p/client.ts +99 -25
- package/src/p2p/protocol.ts +45 -4
- package/src/p2p/server.ts +194 -23
- package/src/p2p/types.ts +28 -2
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
|
-
|
|
66
|
-
|
|
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
|
-
|
|
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
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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(
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|