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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pyre-cli",
3
- "version": "2.0.0",
3
+ "version": "3.0.0",
4
4
  "description": "CLI Mac monitoring tool: temps, system stats, live dashboard, packet monitor, battery predictor, export",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",
package/src/index.ts CHANGED
@@ -37,7 +37,17 @@ program
37
37
  .option('--limit <n>', 'Max number of processes to include in --once/--json/--csv/--tsv snapshots (0 = all)', '10')
38
38
  .option('--p2p-host <host>', 'P2P host address (server: bind address, client: server address)', '0.0.0.0')
39
39
  .option('--p2p-port <port>', 'P2P port number', '9876')
40
- .option('--p2p-password <password>', 'Password for P2P authentication');
40
+ .option('--p2p-password <password>', 'Password for P2P authentication')
41
+ .option('--p2p-tls', 'Enable TLS encryption for P2P connections')
42
+ .option('--p2p-cert <file>', 'TLS certificate file (PEM) for P2P server')
43
+ .option('--p2p-key <file>', 'TLS private key file (PEM) for P2P server')
44
+ .option('--p2p-ca <file>', 'TLS CA certificate file (PEM) for P2P client')
45
+ .option('--p2p-insecure', 'Skip TLS certificate verification (client only)')
46
+ .option('--p2p-rate-limit <n>', 'Max auth attempts per IP per minute (default: 5)', '5')
47
+ .option('--p2p-allow <ips>', 'Comma-separated list of allowed IPs (empty = all)')
48
+ .option('--p2p-deny <ips>', 'Comma-separated list of denied IPs')
49
+ .option('--p2p-audit-log <dir>', 'Directory for P2P audit logs')
50
+ .option('--p2p-hmac-key <key>', 'HMAC key for message signing (default: derived from password)');
41
51
 
42
52
  program.parse(process.argv);
43
53
 
@@ -116,20 +126,56 @@ async function runP2PServer(): Promise<void> {
116
126
  const password = opts.p2pPassword || '';
117
127
  const interval = parseFloat(opts.interval) || 2;
118
128
  const detailed = !!opts.detailed;
129
+ const tlsCert = opts.p2pCert;
130
+ const tlsKey = opts.p2pKey;
131
+ const tlsCA = opts.p2pCA;
132
+ const rateLimit = parseInt(opts.p2pRateLimit, 10) || 5;
133
+ const allowList = opts.p2pAllow ? opts.p2pAllow.split(',').map((s: string) => s.trim()).filter(Boolean) : [];
134
+ const denyList = opts.p2pDeny ? opts.p2pDeny.split(',').map((s: string) => s.trim()).filter(Boolean) : [];
119
135
 
120
136
  if (!password) {
121
137
  console.log(chalk.red('Error: a password is required for P2P server mode. Use --p2p-password <password>'));
122
138
  process.exit(1);
123
139
  }
124
140
 
125
- const server = new P2PServer({ host, port, password, intervalMs: interval * 1000, detailed });
141
+ if (tlsCert && !tlsKey) {
142
+ console.log(chalk.red('Error: --p2p-key is required when --p2p-cert is specified'));
143
+ process.exit(1);
144
+ }
145
+ if (tlsKey && !tlsCert) {
146
+ console.log(chalk.red('Error: --p2p-cert is required when --p2p-key is specified'));
147
+ process.exit(1);
148
+ }
149
+
150
+ const server = new P2PServer({
151
+ host,
152
+ port,
153
+ password,
154
+ intervalMs: interval * 1000,
155
+ detailed,
156
+ tlsCert,
157
+ tlsKey,
158
+ tlsCA,
159
+ rateLimitMaxAttempts: rateLimit,
160
+ rateLimitWindowMs: 60000,
161
+ allowedIPs: allowList,
162
+ deniedIPs: denyList,
163
+ auditLog: opts.p2pAuditLog,
164
+ hmacKey: opts.p2pHmacKey,
165
+ });
166
+
167
+ const tlsEnabled = !!(tlsCert && tlsKey);
126
168
 
127
169
  console.log(chalk.bold(`\n pyre P2P Server`));
128
170
  console.log(chalk.dim(` Host: ${host}`));
129
171
  console.log(chalk.dim(` Port: ${port}`));
172
+ console.log(chalk.dim(` Protocol: ${tlsEnabled ? 'TLS' : 'TCP'}`));
130
173
  console.log(chalk.dim(` Password: ${password}`));
131
174
  console.log(chalk.dim(` Interval: ${interval}s`));
132
175
  console.log(chalk.dim(` Detailed: ${detailed ? 'on' : 'off'}`));
176
+ console.log(chalk.dim(` Rate limit: ${rateLimit} attempts/min`));
177
+ if (allowList.length > 0) console.log(chalk.dim(` Allowed IPs: ${allowList.join(', ')}`));
178
+ if (denyList.length > 0) console.log(chalk.dim(` Denied IPs: ${denyList.join(', ')}`));
133
179
  console.log(chalk.dim(` Waiting for peers...\n`));
134
180
 
135
181
  await server.start();
@@ -158,13 +204,25 @@ async function runP2PConnect(): Promise<void> {
158
204
  const host = opts.p2pHost || '127.0.0.1';
159
205
  const port = parseInt(opts.p2pPort, 10) || 9876;
160
206
  const password = opts.p2pPassword || '';
207
+ const useTLS = !!opts.p2pTls;
208
+ const tlsCA = opts.p2pCA;
209
+ const tlsInsecure = !!opts.p2pInsecure;
161
210
 
162
211
  if (!password) {
163
212
  console.log(chalk.red('Error: a password is required for P2P connect mode. Use --p2p-password <password>'));
164
213
  process.exit(1);
165
214
  }
166
215
 
167
- const client = new P2PClient({ host, port, password });
216
+ const client = new P2PClient({
217
+ host,
218
+ port,
219
+ password,
220
+ tls: useTLS,
221
+ tlsCA,
222
+ tlsInsecure,
223
+ auditLog: opts.p2pAuditLog,
224
+ hmacKey: opts.p2pHmacKey,
225
+ });
168
226
 
169
227
  client.setOnStatus((msg) => {
170
228
  process.stdout.write(`\r${chalk.dim(msg)}${' '.repeat(40)}\r`);
@@ -183,7 +241,9 @@ async function runP2PConnect(): Promise<void> {
183
241
  }));
184
242
  });
185
243
 
244
+ const protocolLabel = useTLS ? 'TLS' : 'TCP';
186
245
  console.log(chalk.bold(`\n pyre P2P Client`));
246
+ console.log(chalk.dim(` Protocol: ${protocolLabel}`));
187
247
  console.log(chalk.dim(` Connecting to ${host}:${port}...`));
188
248
  console.log(chalk.dim(` Press Ctrl+C to disconnect\n`));
189
249
 
@@ -627,7 +627,7 @@ export async function collectPackets(): Promise<PacketData | null> {
627
627
  let topProcesses: { pid: number; command: string; rxBytes: number; txBytes: number }[] = [];
628
628
  let allProcesses: { pid: number; command: string; rxBytes: number; txBytes: number; rxPackets?: number; txPackets?: number }[] = [];
629
629
  try {
630
- const lsof = (await run('lsof -i -n -P -r 1 2>/dev/null | tail -n +2 | awk \'{print $1, $2}\' | sort | uniq -c | sort -rn', '')).trim();
630
+ const lsof = (await run('lsof -i -n -P 2>/dev/null | tail -n +2 | awk \'{print $1, $2}\' | sort | uniq -c | sort -rn', '')).trim();
631
631
  const lsofLines = lsof.split('\n');
632
632
  topProcesses = lsofLines.map(line => {
633
633
  const parts = line.trim().split(/\s+/);
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
- this.socket = new net.Socket();
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.socket.write(encodeDisconnect('Client closing'));
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 msg = JSON.parse(body.toString('utf-8'));
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
- this.status(`Authentication failed: ${(msg.payload as { reason: string }).reason}`);
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 && msg.payload?.stats) {
126
- this.onDataCallback(msg.payload.stats as StatsData);
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.socket.write(encodePong(ts));
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
- this.status(`Server disconnected: ${(msg.payload as { reason: string }).reason}`);
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.socket.write(encodePing());
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> {
@@ -1,19 +1,53 @@
1
- import type { P2PMessage, P2PMessageKind, P2PPayload } from './types.js';
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 encodeAuth(password: string): Buffer {
16
- return buildMessage('auth', { password });
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;