pyre-cli 2.0.0 → 3.1.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 +98 -13
- package/dist/index.js +1278 -824
- package/package.json +1 -1
- package/src/formatters/index.ts +1 -1
- package/src/formatters/render.ts +2 -1
- package/src/index.ts +63 -3
- package/src/live/input.ts +99 -1
- package/src/live/render.ts +70 -32
- package/src/live/state.ts +22 -15
- package/src/live/types.ts +1 -1
- 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 +237 -29
- package/src/p2p/types.ts +30 -2
package/src/p2p/client.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
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
|
-
import { encodeAuth, encodePing, encodePong, encodeDisconnect } from './protocol.js';
|
|
6
|
+
import { encodeChallenge, encodeAuth, encodePing, encodePong, encodeDisconnect, hashPassword, computeHMAC, verifyHMAC, signMessage, decodeMessage, generateNonce } from './protocol.js';
|
|
4
7
|
import type { P2PClientOptions } from './types.js';
|
|
5
8
|
import type { StatsData } from '../monitors/types.js';
|
|
6
9
|
|
|
@@ -11,7 +14,7 @@ interface ReadState {
|
|
|
11
14
|
}
|
|
12
15
|
|
|
13
16
|
export class P2PClient {
|
|
14
|
-
private socket: net.Socket | null = null;
|
|
17
|
+
private socket: net.Socket | tls.TLSSocket | null = null;
|
|
15
18
|
private options: P2PClientOptions;
|
|
16
19
|
private running = false;
|
|
17
20
|
private readState: ReadState = { buffer: Buffer.alloc(0), expectingHeader: true, messageLength: 0 };
|
|
@@ -19,9 +22,13 @@ export class P2PClient {
|
|
|
19
22
|
private onStatusCallback: ((msg: string) => void) | null = null;
|
|
20
23
|
private reconnectTimer: NodeJS.Timeout | null = null;
|
|
21
24
|
private pingHandle: NodeJS.Timeout | null = null;
|
|
25
|
+
private pendingNonce: string | null = null;
|
|
26
|
+
private auditStream: fs.WriteStream | null = null;
|
|
27
|
+
private hmacKey: string;
|
|
22
28
|
|
|
23
29
|
constructor(options: P2PClientOptions) {
|
|
24
30
|
this.options = options;
|
|
31
|
+
this.hmacKey = deriveHMACKey(options.password);
|
|
25
32
|
}
|
|
26
33
|
|
|
27
34
|
setOnData(cb: (data: StatsData) => void): void {
|
|
@@ -33,27 +40,50 @@ export class P2PClient {
|
|
|
33
40
|
}
|
|
34
41
|
|
|
35
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
|
+
|
|
36
53
|
return new Promise((resolve, reject) => {
|
|
37
|
-
|
|
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
|
+
}
|
|
38
82
|
|
|
39
83
|
this.socket.on('data', (data) => this.onData(data));
|
|
40
84
|
this.socket.on('close', () => this.onClose());
|
|
41
85
|
this.socket.on('error', (err) => this.onError(err));
|
|
42
86
|
this.socket.on('timeout', () => this.onTimeout());
|
|
43
|
-
|
|
44
|
-
this.socket.setTimeout(10000);
|
|
45
|
-
|
|
46
|
-
this.socket.connect(this.options.port, this.options.host, () => {
|
|
47
|
-
this.running = true;
|
|
48
|
-
this.socket?.setTimeout(0);
|
|
49
|
-
this.socket?.write(encodeAuth(this.options.password));
|
|
50
|
-
this.status('Connecting...');
|
|
51
|
-
resolve();
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
this.socket.once('error', (err) => {
|
|
55
|
-
reject(err);
|
|
56
|
-
});
|
|
57
87
|
});
|
|
58
88
|
}
|
|
59
89
|
|
|
@@ -69,13 +99,17 @@ export class P2PClient {
|
|
|
69
99
|
}
|
|
70
100
|
if (this.socket) {
|
|
71
101
|
try {
|
|
72
|
-
this.
|
|
102
|
+
this.sendSigned(encodeDisconnect('Client closing'));
|
|
73
103
|
} catch {
|
|
74
104
|
// ignore
|
|
75
105
|
}
|
|
76
106
|
this.socket.destroy();
|
|
77
107
|
this.socket = null;
|
|
78
108
|
}
|
|
109
|
+
if (this.auditStream) {
|
|
110
|
+
this.auditStream.end();
|
|
111
|
+
this.auditStream = null;
|
|
112
|
+
}
|
|
79
113
|
this.status('Disconnected');
|
|
80
114
|
}
|
|
81
115
|
|
|
@@ -108,29 +142,49 @@ export class P2PClient {
|
|
|
108
142
|
|
|
109
143
|
private handleMessage(body: Buffer): void {
|
|
110
144
|
try {
|
|
111
|
-
const
|
|
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
|
+
}
|
|
112
152
|
|
|
113
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
|
+
|
|
114
164
|
case 'auth-ok':
|
|
115
165
|
this.status('Authenticated — receiving live data');
|
|
116
166
|
this.startPing();
|
|
117
167
|
break;
|
|
118
168
|
|
|
119
169
|
case 'auth-fail':
|
|
120
|
-
|
|
170
|
+
const failPayload = msg.payload as { reason?: string };
|
|
171
|
+
this.status(`Authentication failed: ${failPayload.reason || 'unknown'}`);
|
|
121
172
|
this.disconnect();
|
|
122
173
|
break;
|
|
123
174
|
|
|
124
175
|
case 'data':
|
|
125
|
-
if (this.onDataCallback
|
|
126
|
-
|
|
176
|
+
if (this.onDataCallback) {
|
|
177
|
+
const dataPayload = msg.payload as { stats?: StatsData };
|
|
178
|
+
if (dataPayload.stats) {
|
|
179
|
+
this.onDataCallback(dataPayload.stats);
|
|
180
|
+
}
|
|
127
181
|
}
|
|
128
182
|
break;
|
|
129
183
|
|
|
130
184
|
case 'ping':
|
|
131
185
|
if (this.socket && !this.socket.destroyed) {
|
|
132
186
|
const ts = (msg.payload as { ts: number }).ts;
|
|
133
|
-
this.
|
|
187
|
+
this.sendSigned(signMessage('pong', { ts }, this.hmacKey));
|
|
134
188
|
}
|
|
135
189
|
break;
|
|
136
190
|
|
|
@@ -138,7 +192,8 @@ export class P2PClient {
|
|
|
138
192
|
break;
|
|
139
193
|
|
|
140
194
|
case 'disconnect':
|
|
141
|
-
|
|
195
|
+
const disconnectPayload = msg.payload as { reason?: string };
|
|
196
|
+
this.status(`Server disconnected: ${disconnectPayload.reason || 'unknown'}`);
|
|
142
197
|
this.disconnect();
|
|
143
198
|
break;
|
|
144
199
|
|
|
@@ -150,10 +205,16 @@ export class P2PClient {
|
|
|
150
205
|
}
|
|
151
206
|
}
|
|
152
207
|
|
|
208
|
+
private sendSigned(message: Buffer): void {
|
|
209
|
+
if (this.socket && !this.socket.destroyed) {
|
|
210
|
+
this.socket.write(message);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
153
214
|
private startPing(): void {
|
|
154
215
|
this.pingHandle = setInterval(() => {
|
|
155
216
|
if (this.socket && !this.socket.destroyed) {
|
|
156
|
-
this.
|
|
217
|
+
this.sendSigned(signMessage('ping', { ts: Date.now() }, this.hmacKey));
|
|
157
218
|
}
|
|
158
219
|
}, 15000);
|
|
159
220
|
}
|
|
@@ -173,6 +234,8 @@ export class P2PClient {
|
|
|
173
234
|
this.status('Connection refused — is the server running?');
|
|
174
235
|
} else if (code === 'ETIMEDOUT') {
|
|
175
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');
|
|
176
239
|
} else {
|
|
177
240
|
this.status(`Connection error: ${err.message}`);
|
|
178
241
|
}
|
|
@@ -188,6 +251,17 @@ export class P2PClient {
|
|
|
188
251
|
this.onStatusCallback(msg);
|
|
189
252
|
}
|
|
190
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);
|
|
191
265
|
}
|
|
192
266
|
|
|
193
267
|
export async function connectP2PClient(options: P2PClientOptions): Promise<P2PClient> {
|
package/src/p2p/protocol.ts
CHANGED
|
@@ -1,19 +1,53 @@
|
|
|
1
|
-
import
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import type { P2PMessage, P2PMessageKind, P2PPayload, ChallengeMessage, AuthMessage } from './types.js';
|
|
2
3
|
import type { StatsData } from '../monitors/types.js';
|
|
3
4
|
|
|
4
5
|
const HEADER_SIZE = 4;
|
|
5
6
|
const MAX_MESSAGE_SIZE = 16 * 1024 * 1024;
|
|
7
|
+
const HMAC_ALGO = 'sha256';
|
|
6
8
|
|
|
7
|
-
function buildMessage(kind: P2PMessageKind, payload: P2PPayload): Buffer {
|
|
9
|
+
function buildMessage(kind: P2PMessageKind, payload: P2PPayload, hmac?: string): Buffer {
|
|
8
10
|
const envelope: P2PMessage = { kind, payload };
|
|
11
|
+
if (hmac) envelope.hmac = hmac;
|
|
9
12
|
const body = Buffer.from(JSON.stringify(envelope), 'utf-8');
|
|
10
13
|
const header = Buffer.alloc(HEADER_SIZE);
|
|
11
14
|
header.writeUInt32BE(body.length, 0);
|
|
12
15
|
return Buffer.concat([header, body]);
|
|
13
16
|
}
|
|
14
17
|
|
|
15
|
-
export function
|
|
16
|
-
return
|
|
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 });
|
|
17
51
|
}
|
|
18
52
|
|
|
19
53
|
export function encodeAuthOk(): Buffer {
|
|
@@ -46,6 +80,13 @@ export function decodeMessage(buf: Buffer): P2PMessage {
|
|
|
46
80
|
return msg;
|
|
47
81
|
}
|
|
48
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
|
+
|
|
49
90
|
export function readMessage(stream: { read(n: number): Buffer | null }): Buffer | null {
|
|
50
91
|
const header = stream.read(HEADER_SIZE);
|
|
51
92
|
if (!header || header.length < HEADER_SIZE) return null;
|
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,67 @@ 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;
|
|
87
|
+
private peerEvents: { type: string; detail: string; ts: number }[] = [];
|
|
88
|
+
private boundAddress = '';
|
|
50
89
|
|
|
51
90
|
constructor(options: P2PServerOptions) {
|
|
52
91
|
this.options = options;
|
|
92
|
+
this.hmacKey = deriveHMACKey(options.password);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
get peerEventHistory(): { type: string; detail: string; ts: number }[] {
|
|
96
|
+
return this.peerEvents;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
private log(msg: string): void {
|
|
100
|
+
if (this.options.onLog) {
|
|
101
|
+
this.options.onLog(msg.replace(/\x1b\[[0-9;]*m/g, ''));
|
|
102
|
+
} else {
|
|
103
|
+
console.log(msg);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
private emitPeerEvent(type: string, detail: string): void {
|
|
108
|
+
const evt = { type, detail, ts: Date.now() };
|
|
109
|
+
this.peerEvents.push(evt);
|
|
110
|
+
if (this.peerEvents.length > 200) this.peerEvents.shift();
|
|
111
|
+
if (this.options.onPeerEvent) {
|
|
112
|
+
this.options.onPeerEvent(evt as any);
|
|
113
|
+
}
|
|
53
114
|
}
|
|
54
115
|
|
|
55
116
|
get info(): P2PConnectionInfo {
|
|
@@ -61,16 +122,47 @@ export class P2PServer {
|
|
|
61
122
|
}
|
|
62
123
|
|
|
63
124
|
async start(): Promise<void> {
|
|
125
|
+
if (this.options.auditLog) {
|
|
126
|
+
try {
|
|
127
|
+
fs.mkdirSync(this.options.auditLog, { recursive: true });
|
|
128
|
+
const logFile = `${this.options.auditLog}/p2p-audit.log`;
|
|
129
|
+
this.auditStream = fs.createWriteStream(logFile, { flags: 'a' });
|
|
130
|
+
} catch {
|
|
131
|
+
this.log(chalk.yellow('Warning: could not open audit log'));
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
64
135
|
return new Promise((resolve, reject) => {
|
|
65
|
-
|
|
66
|
-
|
|
136
|
+
const useTLS = !!(this.options.tlsCert && this.options.tlsKey);
|
|
137
|
+
|
|
138
|
+
if (useTLS) {
|
|
139
|
+
const tlsOptions: tls.SecureContextOptions = {
|
|
140
|
+
key: fs.readFileSync(this.options.tlsKey!),
|
|
141
|
+
cert: fs.readFileSync(this.options.tlsCert!),
|
|
142
|
+
};
|
|
143
|
+
if (this.options.tlsCA) {
|
|
144
|
+
tlsOptions.ca = fs.readFileSync(this.options.tlsCA!);
|
|
145
|
+
}
|
|
146
|
+
this.server = tls.createServer(tlsOptions, (socket) => this.onConnection(socket));
|
|
147
|
+
} else {
|
|
148
|
+
this.server = net.createServer((socket) => this.onConnection(socket));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
this.server.on('error', (err: Error) => reject(err));
|
|
67
152
|
this.server.listen(this.options.port, this.options.host, () => {
|
|
68
153
|
this.running = true;
|
|
69
154
|
const addr = this.server?.address();
|
|
70
155
|
const bound = addr ? (typeof addr === 'object' ? `${addr.address}:${addr.port}` : String(addr)) : 'unknown';
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
156
|
+
this.boundAddress = typeof addr === 'object' && addr ? addr.address : String(addr);
|
|
157
|
+
const protocol = useTLS ? 'TLS' : 'TCP';
|
|
158
|
+
this.log(chalk.green(`P2P server listening on ${bound} (${protocol})`));
|
|
159
|
+
this.log(chalk.dim(` Password: ${this.options.password}`));
|
|
160
|
+
this.log(chalk.dim(` Interval: ${this.options.intervalMs}ms`));
|
|
161
|
+
const allowedIPs = this.options.allowedIPs ?? [];
|
|
162
|
+
const deniedIPs = this.options.deniedIPs ?? [];
|
|
163
|
+
if (allowedIPs.length > 0) this.log(chalk.dim(` Allowed IPs: ${allowedIPs.join(', ')}`));
|
|
164
|
+
if (deniedIPs.length > 0) this.log(chalk.dim(` Denied IPs: ${deniedIPs.join(', ')}`));
|
|
165
|
+
this.emitPeerEvent('connect', `Server listening on ${bound}`);
|
|
74
166
|
resolve();
|
|
75
167
|
});
|
|
76
168
|
});
|
|
@@ -82,13 +174,82 @@ export class P2PServer {
|
|
|
82
174
|
this.disconnectPeer(peer, 'Server shutting down');
|
|
83
175
|
}
|
|
84
176
|
this.peers.clear();
|
|
177
|
+
this.rateLimitMap.clear();
|
|
178
|
+
this.peerEvents = [];
|
|
85
179
|
if (this.server) {
|
|
86
180
|
this.server.close();
|
|
87
181
|
this.server = null;
|
|
88
182
|
}
|
|
183
|
+
if (this.auditStream) {
|
|
184
|
+
this.auditStream.end();
|
|
185
|
+
this.auditStream = null;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
private audit(ip: string, event: string, detail: string): void {
|
|
190
|
+
if (!this.auditStream) return;
|
|
191
|
+
const ts = new Date().toISOString();
|
|
192
|
+
this.auditStream.write(`${ts} ${ip} ${event} ${detail}\n`);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
private getPeerIP(socket: net.Socket): string {
|
|
196
|
+
const addr = socket.remoteAddress;
|
|
197
|
+
if (!addr) return 'unknown';
|
|
198
|
+
if (addr.startsWith('::ffff:')) {
|
|
199
|
+
return addr.slice(7);
|
|
200
|
+
}
|
|
201
|
+
return addr;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
private checkRateLimit(ip: string): boolean {
|
|
205
|
+
const maxAttempts = this.options.rateLimitMaxAttempts ?? DEFAULT_RATE_LIMIT_MAX;
|
|
206
|
+
const windowMs = this.options.rateLimitWindowMs ?? DEFAULT_RATE_LIMIT_WINDOW;
|
|
207
|
+
const now = Date.now();
|
|
208
|
+
|
|
209
|
+
const entry = this.rateLimitMap.get(ip);
|
|
210
|
+
if (!entry) {
|
|
211
|
+
this.rateLimitMap.set(ip, { attempts: 1, firstAttempt: now });
|
|
212
|
+
return true;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (now - entry.firstAttempt > windowMs) {
|
|
216
|
+
this.rateLimitMap.set(ip, { attempts: 1, firstAttempt: now });
|
|
217
|
+
return true;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
entry.attempts++;
|
|
221
|
+
if (entry.attempts > maxAttempts) {
|
|
222
|
+
return false;
|
|
223
|
+
}
|
|
224
|
+
return true;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
private cleanupRateLimit(ip: string): void {
|
|
228
|
+
this.rateLimitMap.delete(ip);
|
|
89
229
|
}
|
|
90
230
|
|
|
91
231
|
private onConnection(socket: net.Socket): void {
|
|
232
|
+
const ip = this.getPeerIP(socket);
|
|
233
|
+
|
|
234
|
+
const allowedIPs = this.options.allowedIPs ?? [];
|
|
235
|
+
const deniedIPs = this.options.deniedIPs ?? [];
|
|
236
|
+
|
|
237
|
+
if (!isIPAllowed(ip, allowedIPs, deniedIPs)) {
|
|
238
|
+
this.audit(ip, 'BLOCKED', 'IP not allowed');
|
|
239
|
+
this.log(chalk.yellow(`Blocked IP: ${ip}`));
|
|
240
|
+
this.emitPeerEvent('blocked', ip);
|
|
241
|
+
socket.destroy();
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (!this.checkRateLimit(ip)) {
|
|
246
|
+
this.audit(ip, 'RATE_LIMITED', 'Too many auth attempts');
|
|
247
|
+
this.log(chalk.yellow(`Rate limit exceeded for IP: ${ip}`));
|
|
248
|
+
this.emitPeerEvent('rate_limited', ip);
|
|
249
|
+
socket.destroy();
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
|
|
92
253
|
const peer: Peer = {
|
|
93
254
|
socket,
|
|
94
255
|
authenticated: false,
|
|
@@ -97,13 +258,17 @@ export class P2PServer {
|
|
|
97
258
|
readBuffer: Buffer.alloc(0),
|
|
98
259
|
readExpectingHeader: true,
|
|
99
260
|
readMessageLength: 0,
|
|
261
|
+
ip,
|
|
262
|
+
pendingNonce: null,
|
|
100
263
|
};
|
|
101
264
|
this.peers.set(socket, peer);
|
|
102
265
|
|
|
103
266
|
const remoteAddr = socket.remoteAddress
|
|
104
267
|
? `${socket.remoteAddress}:${socket.remotePort}`
|
|
105
268
|
: 'unknown';
|
|
106
|
-
|
|
269
|
+
this.log(chalk.cyan(`Peer connected: ${remoteAddr}`));
|
|
270
|
+
this.audit(ip, 'CONNECT', remoteAddr);
|
|
271
|
+
this.emitPeerEvent('connect', remoteAddr);
|
|
107
272
|
|
|
108
273
|
socket.on('data', (data) => this.onData(peer, data));
|
|
109
274
|
socket.on('close', () => this.onPeerClose(peer));
|
|
@@ -115,6 +280,10 @@ export class P2PServer {
|
|
|
115
280
|
this.disconnectPeer(peer, 'Authentication timeout');
|
|
116
281
|
}
|
|
117
282
|
});
|
|
283
|
+
|
|
284
|
+
const nonce = generateNonce();
|
|
285
|
+
this.sendSigned(peer, signMessage('challenge', { nonce }, this.hmacKey));
|
|
286
|
+
peer.pendingNonce = nonce;
|
|
118
287
|
}
|
|
119
288
|
|
|
120
289
|
private onData(peer: Peer, data: Buffer): void {
|
|
@@ -131,34 +300,67 @@ export class P2PServer {
|
|
|
131
300
|
|
|
132
301
|
private handleAuthMessage(peer: Peer, body: Buffer): void {
|
|
133
302
|
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'));
|
|
303
|
+
const raw = body.toString('utf-8');
|
|
304
|
+
const msg = decodeMessage(body);
|
|
305
|
+
if (msg.kind !== 'auth') {
|
|
306
|
+
peer.socket.write(signMessage('auth-fail', { ok: false, reason: 'Expected auth message first' }, this.hmacKey));
|
|
148
307
|
this.disconnectPeer(peer, 'Expected auth message');
|
|
308
|
+
this.emitPeerEvent('error', `Expected auth message from ${peer.ip}`);
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const authPayload = msg.payload as { passwordHash?: string; nonce?: string };
|
|
313
|
+
if (!authPayload.passwordHash || !authPayload.nonce) {
|
|
314
|
+
peer.socket.write(signMessage('auth-fail', { ok: false, reason: 'Invalid auth payload' }, this.hmacKey));
|
|
315
|
+
this.disconnectPeer(peer, 'Invalid auth payload');
|
|
316
|
+
this.emitPeerEvent('error', `Invalid auth payload from ${peer.ip}`);
|
|
317
|
+
return;
|
|
149
318
|
}
|
|
319
|
+
|
|
320
|
+
if (peer.pendingNonce && authPayload.nonce !== peer.pendingNonce) {
|
|
321
|
+
this.audit(peer.ip, 'AUTH_FAIL', 'Nonce mismatch');
|
|
322
|
+
peer.socket.write(signMessage('auth-fail', { ok: false, reason: 'Invalid nonce' }, this.hmacKey));
|
|
323
|
+
this.disconnectPeer(peer, 'Invalid nonce');
|
|
324
|
+
this.emitPeerEvent('error', `Invalid nonce from ${peer.ip}`);
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const expectedHash = hashPassword(this.options.password, authPayload.nonce);
|
|
329
|
+
if (authPayload.passwordHash !== expectedHash) {
|
|
330
|
+
this.audit(peer.ip, 'AUTH_FAIL', 'Invalid password hash');
|
|
331
|
+
peer.socket.write(signMessage('auth-fail', { ok: false, reason: 'Invalid password' }, this.hmacKey));
|
|
332
|
+
this.disconnectPeer(peer, 'Authentication failed');
|
|
333
|
+
this.emitPeerEvent('error', `Invalid password from ${peer.ip}`);
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
peer.authenticated = true;
|
|
338
|
+
this.cleanupRateLimit(peer.ip);
|
|
339
|
+
peer.pendingNonce = null;
|
|
340
|
+
peer.socket.write(signMessage('auth-ok', { ok: true }, this.hmacKey));
|
|
341
|
+
this.log(chalk.green(`Peer authenticated: ${peer.socket.remoteAddress}`));
|
|
342
|
+
this.audit(peer.ip, 'AUTH_OK', 'Challenge-response successful');
|
|
343
|
+
this.emitPeerEvent('auth', peer.socket.remoteAddress ?? peer.ip);
|
|
344
|
+
this.startDataStream(peer);
|
|
345
|
+
this.startPing(peer);
|
|
150
346
|
} catch {
|
|
151
|
-
peer.socket.write(
|
|
347
|
+
peer.socket.write(signMessage('auth-fail', { ok: false, reason: 'Invalid message format' }, this.hmacKey));
|
|
152
348
|
this.disconnectPeer(peer, 'Invalid message format');
|
|
153
349
|
}
|
|
154
350
|
}
|
|
155
351
|
|
|
352
|
+
private sendSigned(peer: Peer, message: Buffer): void {
|
|
353
|
+
if (!peer.socket.destroyed) {
|
|
354
|
+
peer.socket.write(message);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
156
358
|
private startDataStream(peer: Peer): void {
|
|
157
359
|
const send = async () => {
|
|
158
360
|
if (!peer.authenticated || peer.socket.destroyed) return;
|
|
159
361
|
try {
|
|
160
362
|
const data = await collectAll({ detailed: this.options.detailed });
|
|
161
|
-
|
|
363
|
+
this.sendSigned(peer, signMessage('data', { stats: data }, this.hmacKey));
|
|
162
364
|
} catch {
|
|
163
365
|
// skip failed tick
|
|
164
366
|
}
|
|
@@ -171,28 +373,34 @@ export class P2PServer {
|
|
|
171
373
|
private startPing(peer: Peer): void {
|
|
172
374
|
peer.pingHandle = setInterval(() => {
|
|
173
375
|
if (peer.socket.destroyed) return;
|
|
174
|
-
peer.
|
|
376
|
+
this.sendSigned(peer, signMessage('ping', { ts: Date.now() }, this.hmacKey));
|
|
175
377
|
}, 5000);
|
|
176
378
|
}
|
|
177
379
|
|
|
178
380
|
private onPeerClose(peer: Peer): void {
|
|
179
381
|
this.cleanupPeer(peer);
|
|
180
|
-
|
|
382
|
+
this.log(chalk.yellow(`Peer disconnected: ${peer.socket.remoteAddress}`));
|
|
383
|
+
this.emitPeerEvent('disconnect', peer.socket.remoteAddress ?? peer.ip);
|
|
384
|
+
this.audit(peer.ip, 'DISCONNECT', 'Peer closed connection');
|
|
181
385
|
}
|
|
182
386
|
|
|
183
387
|
private onPeerError(peer: Peer, err: Error): void {
|
|
184
388
|
const code = (err as any).code;
|
|
185
389
|
if (code === 'ECONNRESET') {
|
|
186
|
-
|
|
390
|
+
this.log(chalk.yellow(`Peer connection reset: ${peer.socket.remoteAddress}`));
|
|
391
|
+
this.audit(peer.ip, 'ERROR', 'Connection reset');
|
|
392
|
+
this.emitPeerEvent('error', `connection reset: ${peer.socket.remoteAddress}`);
|
|
187
393
|
} else {
|
|
188
|
-
|
|
394
|
+
this.log(chalk.red(`Peer error: ${err.message}`));
|
|
395
|
+
this.audit(peer.ip, 'ERROR', err.message);
|
|
396
|
+
this.emitPeerEvent('error', err.message);
|
|
189
397
|
}
|
|
190
398
|
this.cleanupPeer(peer);
|
|
191
399
|
}
|
|
192
400
|
|
|
193
401
|
private disconnectPeer(peer: Peer, reason: string): void {
|
|
194
402
|
try {
|
|
195
|
-
|
|
403
|
+
this.sendSigned(peer, signMessage('disconnect', { reason }, this.hmacKey));
|
|
196
404
|
} catch {
|
|
197
405
|
// socket may already be closed
|
|
198
406
|
}
|