@miraj181/ipingyou 2.0.13 → 2.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 +8 -1
- package/package.json +7 -6
- package/src/cli.js +216 -0
- package/src/lib/ai/groq.js +116 -0
- package/src/lib/ai/safety.js +114 -0
- package/src/lib/allowlist.js +35 -0
- package/src/lib/broker.js +139 -13
- package/src/lib/cleanup.js +14 -6
- package/src/lib/config.js +8 -3
- package/src/lib/path-browser.js +26 -4
- package/src/lib/platform.js +0 -31
- package/src/lib/session-log.js +93 -0
- package/src/modes/ai.js +627 -0
- package/src/modes/client.js +327 -35
- package/src/modes/doctor.js +293 -0
- package/src/modes/host.js +486 -39
- package/src/server.js +226 -14
package/src/modes/client.js
CHANGED
|
@@ -21,11 +21,12 @@ import os from 'node:os';
|
|
|
21
21
|
import { cleanupAll, trackPID, untrackPID, addCleanupHook } from '../lib/cleanup.js';
|
|
22
22
|
import { createSpinner, sshSpinner, networkSpinner, fileTransferSpinner, showConnectionTrace, simulateTransferProgress } from '../lib/animations.js';
|
|
23
23
|
import { getConfig, saveAlias } from '../lib/config.js';
|
|
24
|
-
import { pushTelemetry, resolveUID } from '../lib/broker.js';
|
|
24
|
+
import { pushTelemetry, requestHostApproval, resolveUID, revokeUID, waitForApproval } from '../lib/broker.js';
|
|
25
25
|
import { calculateChecksum } from '../lib/checksum.js';
|
|
26
26
|
import { promptLocalPath, promptRemotePath } from '../lib/path-browser.js';
|
|
27
27
|
import { buildSshArgs, extractHostname, formatScpRemotePath, getSshControlOptions, quoteRemoteShell } from '../lib/ssh.js';
|
|
28
28
|
import open from 'open';
|
|
29
|
+
import { cleanupSessionLog, initSessionLog, logSessionEvent, recordEvent } from '../lib/session-log.js';
|
|
29
30
|
|
|
30
31
|
let BROKER_URL = process.env.BROKER_URL || 'https://ipingyou.onrender.com';
|
|
31
32
|
|
|
@@ -42,6 +43,28 @@ async function promptUsername() {
|
|
|
42
43
|
return username.trim();
|
|
43
44
|
}
|
|
44
45
|
|
|
46
|
+
function normalizePrivateKey(privateKey) {
|
|
47
|
+
const normalized = String(privateKey || '').replace(/\\n/g, '\n').replace(/\r\n/g, '\n');
|
|
48
|
+
return normalized.endsWith('\n') ? normalized : `${normalized}\n`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function writeEphemeralPrivateKey(privateKey) {
|
|
52
|
+
const keyPath = path.join(os.tmpdir(), `ipingyou_client_${Date.now()}`);
|
|
53
|
+
fs.writeFileSync(keyPath, normalizePrivateKey(privateKey), { mode: 0o600 });
|
|
54
|
+
|
|
55
|
+
const result = await execa('ssh-keygen', ['-y', '-f', keyPath], {
|
|
56
|
+
reject: false,
|
|
57
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
if (result.exitCode !== 0) {
|
|
61
|
+
try { fs.unlinkSync(keyPath); } catch { }
|
|
62
|
+
throw new Error(result.stderr.trim() || 'OpenSSH could not parse the host-provided private key');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return keyPath;
|
|
66
|
+
}
|
|
67
|
+
|
|
45
68
|
/**
|
|
46
69
|
* Start SSH connection through the Cloudflare tunnel.
|
|
47
70
|
*/
|
|
@@ -78,12 +101,15 @@ async function connectSSH(username, hostname, privateKeyPath) {
|
|
|
78
101
|
if (result.exitCode === 0) {
|
|
79
102
|
console.log('');
|
|
80
103
|
console.log(chalk.green(' ✅ SSH session ended cleanly'));
|
|
104
|
+
recordEvent('ssh_session_ended', { hostname, exitCode: 0 });
|
|
81
105
|
} else if (result.exitCode === 255) {
|
|
82
106
|
console.log('');
|
|
83
107
|
console.error(chalk.red(' ❌ SSH connection failed (exit code 255)'));
|
|
108
|
+
recordEvent('ssh_session_failed', { hostname, exitCode: 255 });
|
|
84
109
|
} else {
|
|
85
110
|
console.log('');
|
|
86
111
|
console.error(chalk.red(` ❌ SSH exited with code ${result.exitCode}`));
|
|
112
|
+
recordEvent('ssh_session_ended', { hostname, exitCode: result.exitCode });
|
|
87
113
|
}
|
|
88
114
|
} catch (err) {
|
|
89
115
|
console.error(chalk.red(` ❌ SSH error: ${err.message}`));
|
|
@@ -93,7 +119,48 @@ async function connectSSH(username, hostname, privateKeyPath) {
|
|
|
93
119
|
/**
|
|
94
120
|
* Perform an SCP file transfer through the Cloudflare tunnel.
|
|
95
121
|
*/
|
|
96
|
-
async function
|
|
122
|
+
async function chooseRemoteTransferPath(username, hostname, privateKeyPath, direction, sharedDropPath) {
|
|
123
|
+
if (!sharedDropPath) {
|
|
124
|
+
return promptRemotePath(username, hostname, privateKeyPath, direction === 'upload' ? 'destination' : 'source');
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const { dropChoice } = await inquirer.prompt([{
|
|
128
|
+
type: 'list',
|
|
129
|
+
name: 'dropChoice',
|
|
130
|
+
message: direction === 'upload'
|
|
131
|
+
? 'Where should the file/folder go on the host?'
|
|
132
|
+
: 'Where should browsing start on the host?',
|
|
133
|
+
choices: direction === 'upload'
|
|
134
|
+
? [
|
|
135
|
+
{ name: `📥 Use host shared drop folder (${sharedDropPath})`, value: 'drop' },
|
|
136
|
+
{ name: '🔍 Browse host folders', value: 'browse' },
|
|
137
|
+
{ name: '⌨️ Type host destination path manually', value: 'manual' }
|
|
138
|
+
]
|
|
139
|
+
: [
|
|
140
|
+
{ name: `📥 Start in host shared drop folder (${sharedDropPath})`, value: 'drop_browse' },
|
|
141
|
+
{ name: '🔍 Browse from host home folder', value: 'browse' },
|
|
142
|
+
{ name: '⌨️ Type host file/folder path manually', value: 'manual' }
|
|
143
|
+
]
|
|
144
|
+
}]);
|
|
145
|
+
|
|
146
|
+
if (dropChoice === 'drop') return sharedDropPath;
|
|
147
|
+
if (dropChoice === 'drop_browse') {
|
|
148
|
+
return promptRemotePath(username, hostname, privateKeyPath, 'source', sharedDropPath);
|
|
149
|
+
}
|
|
150
|
+
if (dropChoice === 'manual') {
|
|
151
|
+
const { remotePath } = await inquirer.prompt([{
|
|
152
|
+
type: 'input',
|
|
153
|
+
name: 'remotePath',
|
|
154
|
+
message: direction === 'upload' ? 'Host destination path:' : 'Host file/folder path:',
|
|
155
|
+
validate: v => v.trim().length > 0 || 'Required',
|
|
156
|
+
}]);
|
|
157
|
+
return remotePath.trim();
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return promptRemotePath(username, hostname, privateKeyPath, direction === 'upload' ? 'destination' : 'source');
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async function performSCP(username, hostname, direction, privateKeyPath, sharedDropPath = null) {
|
|
97
164
|
console.log('');
|
|
98
165
|
console.log(chalk.bold(` 📦 SCP Transfer (${direction})`));
|
|
99
166
|
console.log(chalk.dim(' ─────────────────────────────────'));
|
|
@@ -102,11 +169,11 @@ async function performSCP(username, hostname, direction, privateKeyPath) {
|
|
|
102
169
|
let remotePath;
|
|
103
170
|
|
|
104
171
|
if (direction === 'upload') {
|
|
105
|
-
remotePath = await
|
|
172
|
+
remotePath = await chooseRemoteTransferPath(username, hostname, privateKeyPath, direction, sharedDropPath);
|
|
106
173
|
localPath = await promptLocalPath('client file/folder to upload');
|
|
107
174
|
} else {
|
|
108
175
|
localPath = await promptLocalPath('client destination');
|
|
109
|
-
remotePath = await
|
|
176
|
+
remotePath = await chooseRemoteTransferPath(username, hostname, privateKeyPath, direction, sharedDropPath);
|
|
110
177
|
}
|
|
111
178
|
|
|
112
179
|
await showConnectionTrace('Local', 'Remote SCP');
|
|
@@ -157,11 +224,12 @@ async function performSCP(username, hostname, direction, privateKeyPath) {
|
|
|
157
224
|
|
|
158
225
|
if (result.exitCode === 0) {
|
|
159
226
|
await simulateTransferProgress(direction === 'upload' ? localPath : remotePath, direction, 1500);
|
|
160
|
-
|
|
227
|
+
|
|
161
228
|
// Verify Checksum
|
|
162
229
|
if (direction === 'upload' && localHash) {
|
|
163
230
|
console.log(chalk.dim(' 🔍 Verifying remote SHA-256 checksum...'));
|
|
164
231
|
try {
|
|
232
|
+
const remoteChecksumPath = joinRemotePath(remotePath, path.basename(localPath));
|
|
165
233
|
const sshArgs = [
|
|
166
234
|
'-o', `ProxyCommand=${proxyCommand}`,
|
|
167
235
|
'-o', 'StrictHostKeyChecking=accept-new',
|
|
@@ -169,11 +237,11 @@ async function performSCP(username, hostname, direction, privateKeyPath) {
|
|
|
169
237
|
...getSshControlOptions(hostname)
|
|
170
238
|
];
|
|
171
239
|
if (privateKeyPath) sshArgs.push('-i', privateKeyPath, '-o', 'IdentityAgent=none');
|
|
172
|
-
sshArgs.push(`${username}@${hostname}`, `shasum -a 256 ${quoteRemoteShell(remotePath)} || sha256sum ${quoteRemoteShell(remotePath)}`);
|
|
173
|
-
|
|
240
|
+
sshArgs.push(`${username}@${hostname}`, `shasum -a 256 ${quoteRemoteShell(remoteChecksumPath)} 2>/dev/null || sha256sum ${quoteRemoteShell(remoteChecksumPath)} 2>/dev/null || shasum -a 256 ${quoteRemoteShell(remotePath)} 2>/dev/null || sha256sum ${quoteRemoteShell(remotePath)}`);
|
|
241
|
+
|
|
174
242
|
const { stdout } = await execa('ssh', sshArgs, { reject: false });
|
|
175
243
|
const remoteHash = stdout.split(' ')[0].trim();
|
|
176
|
-
|
|
244
|
+
|
|
177
245
|
if (remoteHash === localHash) {
|
|
178
246
|
console.log(chalk.green(` ✅ Zero-Trust File Integrity: Hash match (${remoteHash.substring(0, 16)}...)`));
|
|
179
247
|
} else {
|
|
@@ -189,15 +257,40 @@ async function performSCP(username, hostname, direction, privateKeyPath) {
|
|
|
189
257
|
}
|
|
190
258
|
|
|
191
259
|
console.log(chalk.green(` ✅ Transfer completed successfully!`));
|
|
260
|
+
recordEvent('scp_transfer_success', { direction, localPath, remotePath, hostname });
|
|
192
261
|
} else {
|
|
193
262
|
console.error(chalk.red(' ❌ SCP transfer failed'));
|
|
194
263
|
if (result.stderr) console.error(chalk.dim(` ${result.stderr.trim()}`));
|
|
264
|
+
recordEvent('scp_transfer_failed', { direction, localPath, remotePath, hostname, error: result.stderr });
|
|
195
265
|
}
|
|
196
266
|
} catch (err) {
|
|
197
267
|
console.error(chalk.red(` ❌ SCP error: ${err.message}`));
|
|
198
268
|
}
|
|
199
269
|
}
|
|
200
270
|
|
|
271
|
+
async function downloadSpecificRemotePath(username, hostname, privateKeyPath, remotePath, localPath) {
|
|
272
|
+
await showConnectionTrace('Local', 'Remote SCP');
|
|
273
|
+
const proxyCommand = `cloudflared access tcp --hostname ${hostname}`;
|
|
274
|
+
const scpArgs = [
|
|
275
|
+
'-r',
|
|
276
|
+
'-o', `ProxyCommand=${proxyCommand}`,
|
|
277
|
+
'-o', 'StrictHostKeyChecking=accept-new',
|
|
278
|
+
'-o', 'IdentitiesOnly=yes',
|
|
279
|
+
...getSshControlOptions(hostname),
|
|
280
|
+
];
|
|
281
|
+
if (privateKeyPath) scpArgs.push('-i', privateKeyPath, '-o', 'IdentityAgent=none');
|
|
282
|
+
scpArgs.push(`${username}@${hostname}:${formatScpRemotePath(remotePath)}`, localPath);
|
|
283
|
+
const result = await execa('scp', scpArgs, { stdio: 'inherit', reject: false });
|
|
284
|
+
return result.exitCode === 0;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function joinRemotePath(parent, child) {
|
|
288
|
+
const cleanParent = String(parent || '').replace(/\/+$/, '');
|
|
289
|
+
if (!cleanParent) return child;
|
|
290
|
+
if (cleanParent === '/') return `/${child}`;
|
|
291
|
+
return `${cleanParent}/${child}`;
|
|
292
|
+
}
|
|
293
|
+
|
|
201
294
|
/**
|
|
202
295
|
* Main Client Mode entry point.
|
|
203
296
|
*/
|
|
@@ -207,6 +300,13 @@ export async function startClientMode(options = {}) {
|
|
|
207
300
|
console.log(chalk.dim(' ──────────────────────────────────────────'));
|
|
208
301
|
console.log('');
|
|
209
302
|
|
|
303
|
+
const sessionLogPath = initSessionLog('client');
|
|
304
|
+
if (sessionLogPath) {
|
|
305
|
+
console.log(chalk.dim(` 📜 Session log: ${sessionLogPath}`));
|
|
306
|
+
addCleanupHook(() => cleanupSessionLog());
|
|
307
|
+
}
|
|
308
|
+
logSessionEvent('client_mode_started');
|
|
309
|
+
|
|
210
310
|
// Allow setting a custom broker URL if process.env isn't overridden by CLI
|
|
211
311
|
if (process.env.BROKER_URL) {
|
|
212
312
|
BROKER_URL = process.env.BROKER_URL;
|
|
@@ -234,11 +334,12 @@ export async function startClientMode(options = {}) {
|
|
|
234
334
|
BROKER_URL = customBroker.trim();
|
|
235
335
|
process.env.BROKER_URL = BROKER_URL; // Update for consistency
|
|
236
336
|
}
|
|
337
|
+
logSessionEvent('client_broker_selected', { choice: brokerChoice, broker: BROKER_URL });
|
|
237
338
|
}
|
|
238
339
|
|
|
239
340
|
const config = getConfig();
|
|
240
341
|
const aliasKeys = Object.keys(config.aliases || {});
|
|
241
|
-
|
|
342
|
+
|
|
242
343
|
let targetUid = null;
|
|
243
344
|
let targetPassword = null;
|
|
244
345
|
let targetUsername = null;
|
|
@@ -261,6 +362,7 @@ export async function startClientMode(options = {}) {
|
|
|
261
362
|
targetUid = aliasData.uid;
|
|
262
363
|
targetPassword = aliasData.password;
|
|
263
364
|
targetUsername = aliasData.username;
|
|
365
|
+
logSessionEvent('client_alias_selected', { alias: useAlias, uid: targetUid });
|
|
264
366
|
}
|
|
265
367
|
}
|
|
266
368
|
|
|
@@ -273,6 +375,7 @@ export async function startClientMode(options = {}) {
|
|
|
273
375
|
validate: (v) => v.trim().length > 0 || 'Password is required to decrypt',
|
|
274
376
|
}]);
|
|
275
377
|
targetPassword = password.trim();
|
|
378
|
+
logSessionEvent('client_uid_provided', { uid: targetUid });
|
|
276
379
|
}
|
|
277
380
|
|
|
278
381
|
if (!targetUid) {
|
|
@@ -297,10 +400,61 @@ export async function startClientMode(options = {}) {
|
|
|
297
400
|
]);
|
|
298
401
|
targetUid = answer.uid.trim();
|
|
299
402
|
targetPassword = answer.password.trim();
|
|
403
|
+
logSessionEvent('client_uid_provided', { uid: targetUid });
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
let payload = await resolveUID(BROKER_URL, targetUid, targetPassword);
|
|
407
|
+
|
|
408
|
+
// ─── Approval Flow ──────────────────────────────────────────
|
|
409
|
+
if (payload && payload.needsApproval) {
|
|
410
|
+
console.log('');
|
|
411
|
+
console.log(chalk.bold.yellow(' 🔐 Host Approval Required'));
|
|
412
|
+
console.log(chalk.dim(' ──────────────────────────────────────'));
|
|
413
|
+
console.log(chalk.dim(' The host has enabled approval gating. Submitting your access request...'));
|
|
414
|
+
|
|
415
|
+
try {
|
|
416
|
+
const approvalDetails = {
|
|
417
|
+
username: os.userInfo().username,
|
|
418
|
+
hostname: os.hostname(),
|
|
419
|
+
os: `${os.type()} ${os.release()} (${os.arch()})`,
|
|
420
|
+
intent: 'connect',
|
|
421
|
+
time: new Date().toISOString(),
|
|
422
|
+
};
|
|
423
|
+
|
|
424
|
+
const { requestId, status: reqStatus, approvalRequired } = await requestHostApproval(
|
|
425
|
+
BROKER_URL, targetUid, targetPassword, approvalDetails
|
|
426
|
+
);
|
|
427
|
+
|
|
428
|
+
logSessionEvent('client_approval_submitted', { uid: targetUid, requestId });
|
|
429
|
+
|
|
430
|
+
if (!approvalRequired || reqStatus === 'approved') {
|
|
431
|
+
console.log(chalk.green(' ✅ Access auto-approved (host does not require manual approval for this session).'));
|
|
432
|
+
payload = await resolveUID(BROKER_URL, targetUid, targetPassword, false, requestId);
|
|
433
|
+
} else {
|
|
434
|
+
console.log(chalk.yellow(` ⏳ Waiting for host to approve your request (ID: ${requestId})...`));
|
|
435
|
+
console.log(chalk.dim(' This may take a few minutes. Press Ctrl+C to cancel.'));
|
|
436
|
+
console.log('');
|
|
437
|
+
|
|
438
|
+
const approved = await waitForApproval(BROKER_URL, targetUid, requestId, 300000);
|
|
439
|
+
|
|
440
|
+
if (approved) {
|
|
441
|
+
console.log(chalk.green(' ✅ Host approved your access request!'));
|
|
442
|
+
logSessionEvent('client_approval_granted', { uid: targetUid, requestId });
|
|
443
|
+
payload = await resolveUID(BROKER_URL, targetUid, targetPassword, false, requestId);
|
|
444
|
+
} else {
|
|
445
|
+
console.log(chalk.red(' ❌ Host denied your access request.'));
|
|
446
|
+
logSessionEvent('client_approval_denied', { uid: targetUid, requestId });
|
|
447
|
+
process.exit(1);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
} catch (err) {
|
|
451
|
+
console.log(chalk.red(` ❌ Approval flow failed: ${err.message}`));
|
|
452
|
+
logSessionEvent('client_approval_error', { uid: targetUid, error: err.message }, 'error');
|
|
453
|
+
process.exit(1);
|
|
454
|
+
}
|
|
300
455
|
}
|
|
301
456
|
|
|
302
|
-
|
|
303
|
-
if (!payload) {
|
|
457
|
+
if (!payload || payload.needsApproval) {
|
|
304
458
|
process.exit(1);
|
|
305
459
|
}
|
|
306
460
|
|
|
@@ -308,9 +462,17 @@ export async function startClientMode(options = {}) {
|
|
|
308
462
|
console.log('');
|
|
309
463
|
console.log(chalk.bold(' 🌐 HTTP Service Exposed'));
|
|
310
464
|
console.log(chalk.dim(' ─────────────────────────────────'));
|
|
311
|
-
console.log(chalk.green(`
|
|
465
|
+
console.log(chalk.green(` Opening in browser: ${chalk.bold.cyan(payload.url)}`));
|
|
312
466
|
console.log('');
|
|
313
|
-
|
|
467
|
+
logSessionEvent('client_http_mode', { uid: targetUid, port: payload.port || null });
|
|
468
|
+
try {
|
|
469
|
+
await open(payload.url);
|
|
470
|
+
} catch {
|
|
471
|
+
console.log(chalk.dim(` Could not auto-open. Please visit: ${payload.url}`));
|
|
472
|
+
}
|
|
473
|
+
console.log(chalk.dim(' Press Ctrl+C to exit.'));
|
|
474
|
+
// Keep process alive so cleanup handlers work
|
|
475
|
+
await new Promise(() => {});
|
|
314
476
|
}
|
|
315
477
|
|
|
316
478
|
if (payload.type === 'tcp') {
|
|
@@ -318,12 +480,40 @@ export async function startClientMode(options = {}) {
|
|
|
318
480
|
console.log(chalk.bold(' 🔌 Custom TCP Port Exposed'));
|
|
319
481
|
console.log(chalk.dim(' ─────────────────────────────────'));
|
|
320
482
|
console.log(` The host is exposing a generic TCP service on port ${payload.port}.`);
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
483
|
+
|
|
484
|
+
const hostname = extractHostname(payload.url);
|
|
485
|
+
const localPort = payload.port;
|
|
486
|
+
|
|
487
|
+
console.log(chalk.dim(` Starting local cloudflared proxy to ${hostname}...`));
|
|
325
488
|
console.log('');
|
|
326
|
-
|
|
489
|
+
|
|
490
|
+
try {
|
|
491
|
+
const { execa: execaFn } = await import('execa');
|
|
492
|
+
const child = execaFn('cloudflared', ['access', 'tcp', '--hostname', hostname, '--url', `127.0.0.1:${localPort}`], {
|
|
493
|
+
stdio: 'inherit',
|
|
494
|
+
reject: false,
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
trackPID(child.pid);
|
|
498
|
+
console.log(chalk.green(` ✅ Local proxy active! Connect your client to ${chalk.bold('127.0.0.1:' + localPort)}`));
|
|
499
|
+
console.log(chalk.dim(' Press Ctrl+C to terminate the tunnel.'));
|
|
500
|
+
logSessionEvent('client_tcp_mode', { uid: targetUid, port: localPort, hostname });
|
|
501
|
+
|
|
502
|
+
const result = await child;
|
|
503
|
+
untrackPID(child.pid);
|
|
504
|
+
|
|
505
|
+
if (result.exitCode !== 0) {
|
|
506
|
+
console.log(chalk.red(` ❌ Cloudflared exited with code ${result.exitCode}`));
|
|
507
|
+
}
|
|
508
|
+
} catch (err) {
|
|
509
|
+
console.log(chalk.red(` ❌ Could not start cloudflared proxy: ${err.message}`));
|
|
510
|
+
console.log(chalk.dim(' Fallback: run manually in another terminal:'));
|
|
511
|
+
console.log(chalk.cyan(` cloudflared access tcp --hostname ${hostname} --url 127.0.0.1:${localPort}`));
|
|
512
|
+
console.log('');
|
|
513
|
+
console.log(` Then connect your local client to ${chalk.green('127.0.0.1:' + localPort)}`);
|
|
514
|
+
}
|
|
515
|
+
await cleanupAll();
|
|
516
|
+
return;
|
|
327
517
|
}
|
|
328
518
|
|
|
329
519
|
const tunnelUrl = payload.url;
|
|
@@ -334,11 +524,57 @@ export async function startClientMode(options = {}) {
|
|
|
334
524
|
let privateKeyPath = null;
|
|
335
525
|
if (payload.privateKey) {
|
|
336
526
|
console.log(chalk.green(' 🔑 Host provided an ephemeral SSH key for passwordless entry!'));
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
527
|
+
try {
|
|
528
|
+
privateKeyPath = await writeEphemeralPrivateKey(payload.privateKey);
|
|
529
|
+
addCleanupHook(() => {
|
|
530
|
+
try { fs.unlinkSync(privateKeyPath); } catch { }
|
|
531
|
+
});
|
|
532
|
+
logSessionEvent('client_ephemeral_key_ready');
|
|
533
|
+
} catch (err) {
|
|
534
|
+
console.log(chalk.yellow(` ⚠️ Could not use ephemeral SSH key: ${err.message}`));
|
|
535
|
+
console.log(chalk.dim(' Falling back to standard OS password.'));
|
|
536
|
+
logSessionEvent('client_ephemeral_key_failed', { error: err.message }, 'warn');
|
|
537
|
+
privateKeyPath = null;
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// ─── One-Time File Share Auto-Download ────────────────────
|
|
542
|
+
if (payload.oneTime && payload.oneTimeSharePath) {
|
|
543
|
+
console.log('');
|
|
544
|
+
console.log(chalk.bold.magenta(' 📦 One-Time File Share'));
|
|
545
|
+
console.log(chalk.dim(' ──────────────────────────────────────'));
|
|
546
|
+
console.log(chalk.dim(` Host is sharing: ${chalk.cyan(payload.oneTimeSharePath)}`));
|
|
547
|
+
console.log(chalk.dim(' This is a one-time transfer — the session will be revoked after download.'));
|
|
548
|
+
console.log('');
|
|
549
|
+
|
|
550
|
+
const localDest = await promptLocalPath('download destination');
|
|
551
|
+
|
|
552
|
+
await pushTelemetry(BROKER_URL, targetUid, targetPassword, username, 'one-time-download');
|
|
553
|
+
logSessionEvent('client_one_time_download_start', { uid: targetUid, remotePath: payload.oneTimeSharePath });
|
|
554
|
+
|
|
555
|
+
const success = await downloadSpecificRemotePath(username, hostname, privateKeyPath, payload.oneTimeSharePath, localDest);
|
|
556
|
+
|
|
557
|
+
if (success) {
|
|
558
|
+
console.log(chalk.green(' ✅ One-time file transfer completed successfully!'));
|
|
559
|
+
|
|
560
|
+
// Verify download checksum
|
|
561
|
+
const dlHash = await calculateChecksum(localDest);
|
|
562
|
+
if (dlHash) console.log(chalk.green(` 🔍 File Intact. SHA-256: ${dlHash.substring(0, 16)}...`));
|
|
563
|
+
|
|
564
|
+
// Auto-revoke the UID from broker
|
|
565
|
+
console.log(chalk.dim(' Revoking session from broker...'));
|
|
566
|
+
await revokeUID(BROKER_URL, targetUid);
|
|
567
|
+
console.log(chalk.green(' 🔒 Session revoked. No further access is possible.'));
|
|
568
|
+
|
|
569
|
+
logSessionEvent('client_one_time_download_complete', { uid: targetUid });
|
|
570
|
+
recordEvent('one_time_transfer_complete', { uid: targetUid, localDest });
|
|
571
|
+
} else {
|
|
572
|
+
console.log(chalk.red(' ❌ One-time file transfer failed.'));
|
|
573
|
+
logSessionEvent('client_one_time_download_failed', { uid: targetUid }, 'error');
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
await cleanupAll();
|
|
577
|
+
return;
|
|
342
578
|
}
|
|
343
579
|
|
|
344
580
|
// Ask to save alias if we entered manually
|
|
@@ -359,12 +595,10 @@ export async function startClientMode(options = {}) {
|
|
|
359
595
|
}]);
|
|
360
596
|
saveAlias(aliasName.trim(), { uid: targetUid, password: targetPassword, username });
|
|
361
597
|
console.log(chalk.green(` ✓ Saved as alias: ${chalk.bold(aliasName.trim())}\n`));
|
|
598
|
+
logSessionEvent('client_alias_saved', { alias: aliasName.trim(), uid: targetUid });
|
|
362
599
|
}
|
|
363
600
|
}
|
|
364
601
|
|
|
365
|
-
// Push secure telemetry to host
|
|
366
|
-
await pushTelemetry(BROKER_URL, targetUid, targetPassword, username);
|
|
367
|
-
|
|
368
602
|
const { action } = await inquirer.prompt([
|
|
369
603
|
{
|
|
370
604
|
type: 'list',
|
|
@@ -380,6 +614,9 @@ export async function startClientMode(options = {}) {
|
|
|
380
614
|
}
|
|
381
615
|
]);
|
|
382
616
|
|
|
617
|
+
await pushTelemetry(BROKER_URL, targetUid, targetPassword, username, action);
|
|
618
|
+
logSessionEvent('client_action_selected', { action });
|
|
619
|
+
|
|
383
620
|
if (action === 'chat') {
|
|
384
621
|
await handleClientChat(targetUid, targetPassword, payload.chatUrl);
|
|
385
622
|
} else if (action === 'ssh') {
|
|
@@ -387,7 +624,7 @@ export async function startClientMode(options = {}) {
|
|
|
387
624
|
} else if (action === 'reverse') {
|
|
388
625
|
await performReverseForward(username, hostname, privateKeyPath);
|
|
389
626
|
} else {
|
|
390
|
-
await performSCP(username, hostname, action, privateKeyPath);
|
|
627
|
+
await performSCP(username, hostname, action, privateKeyPath, payload.sharedDropPath);
|
|
391
628
|
}
|
|
392
629
|
|
|
393
630
|
console.log('');
|
|
@@ -401,16 +638,57 @@ export async function startClientMode(options = {}) {
|
|
|
401
638
|
]);
|
|
402
639
|
|
|
403
640
|
if (reconnect) {
|
|
404
|
-
await handleSubsequentActions(username, hostname, privateKeyPath, targetUid, targetPassword);
|
|
641
|
+
await handleSubsequentActions(username, hostname, privateKeyPath, targetUid, targetPassword, payload.sharedDropPath);
|
|
405
642
|
}
|
|
406
643
|
|
|
644
|
+
logSessionEvent('client_session_exit');
|
|
407
645
|
await cleanupAll();
|
|
408
646
|
}
|
|
409
647
|
|
|
648
|
+
/**
|
|
649
|
+
* Perform a non-interactive SCP transfer (used by AI Transfer Assistant).
|
|
650
|
+
* params: { brokerUrl, uid, password, username, direction, localPath, remotePath }
|
|
651
|
+
*/
|
|
652
|
+
export async function performSCPNonInteractive(params = {}) {
|
|
653
|
+
const { brokerUrl, uid, password, username, direction, localPath, remotePath } = params;
|
|
654
|
+
if (!brokerUrl || !uid || !password) throw new Error('Missing broker connection info');
|
|
655
|
+
|
|
656
|
+
const payload = await resolveUID(brokerUrl, uid, password);
|
|
657
|
+
if (!payload) throw new Error('Could not resolve UID/payload');
|
|
658
|
+
|
|
659
|
+
const tunnelUrl = payload.url;
|
|
660
|
+
const hostname = extractHostname(tunnelUrl);
|
|
661
|
+
const privateKeyPath = payload.privateKey ? await writeEphemeralPrivateKey(payload.privateKey) : null;
|
|
662
|
+
|
|
663
|
+
// Build scp args similar to performSCP
|
|
664
|
+
const proxyCommand = `cloudflared access tcp --hostname ${hostname}`;
|
|
665
|
+
const scpArgs = ['-r', '-o', `ProxyCommand=${proxyCommand}`, '-o', 'StrictHostKeyChecking=accept-new', '-o', 'IdentitiesOnly=yes', ...getSshControlOptions(hostname)];
|
|
666
|
+
if (privateKeyPath) scpArgs.push('-i', privateKeyPath, '-o', 'IdentityAgent=none');
|
|
667
|
+
|
|
668
|
+
const remoteSpec = `${username}@${hostname}:${formatScpRemotePath(remotePath)}`;
|
|
669
|
+
if (direction === 'upload') {
|
|
670
|
+
scpArgs.push(localPath, remoteSpec);
|
|
671
|
+
} else {
|
|
672
|
+
scpArgs.push(remoteSpec, localPath);
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
try {
|
|
676
|
+
const result = await execa('scp', scpArgs, { stdio: 'inherit', reject: false });
|
|
677
|
+
if (result.exitCode === 0) {
|
|
678
|
+
recordEvent('scp_transfer_success', { direction, localPath, remotePath, hostname, automated: true });
|
|
679
|
+
return true;
|
|
680
|
+
}
|
|
681
|
+
recordEvent('scp_transfer_failed', { direction, localPath, remotePath, hostname, error: result.stderr, automated: true });
|
|
682
|
+
return false;
|
|
683
|
+
} finally {
|
|
684
|
+
try { if (privateKeyPath) fs.unlinkSync(privateKeyPath); } catch { }
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
|
|
410
688
|
async function handleClientChat(uid, password, cachedChatUrl) {
|
|
411
689
|
let chatUrl = cachedChatUrl;
|
|
412
690
|
const spinner = createSpinner('Checking for active chat room...', networkSpinner).start();
|
|
413
|
-
|
|
691
|
+
|
|
414
692
|
const payload = await resolveUID(BROKER_URL, uid, password, true); // true = silent if possible, or just re-resolve
|
|
415
693
|
if (payload && payload.chatUrl) {
|
|
416
694
|
chatUrl = payload.chatUrl;
|
|
@@ -429,7 +707,7 @@ async function handleClientChat(uid, password, cachedChatUrl) {
|
|
|
429
707
|
}
|
|
430
708
|
}
|
|
431
709
|
|
|
432
|
-
async function handleSubsequentActions(username, hostname, privateKeyPath, targetUid, targetPassword) {
|
|
710
|
+
async function handleSubsequentActions(username, hostname, privateKeyPath, targetUid, targetPassword, sharedDropPath = null) {
|
|
433
711
|
const { action } = await inquirer.prompt([
|
|
434
712
|
{
|
|
435
713
|
type: 'list',
|
|
@@ -448,6 +726,9 @@ async function handleSubsequentActions(username, hostname, privateKeyPath, targe
|
|
|
448
726
|
|
|
449
727
|
if (action === 'exit') return;
|
|
450
728
|
|
|
729
|
+
await pushTelemetry(BROKER_URL, targetUid, targetPassword, username, action);
|
|
730
|
+
logSessionEvent('client_action_selected', { action });
|
|
731
|
+
|
|
451
732
|
if (action === 'chat') {
|
|
452
733
|
await handleClientChat(targetUid, targetPassword, null);
|
|
453
734
|
} else if (action === 'ssh') {
|
|
@@ -455,7 +736,7 @@ async function handleSubsequentActions(username, hostname, privateKeyPath, targe
|
|
|
455
736
|
} else if (action === 'reverse') {
|
|
456
737
|
await performReverseForward(username, hostname, privateKeyPath);
|
|
457
738
|
} else {
|
|
458
|
-
await performSCP(username, hostname, action, privateKeyPath);
|
|
739
|
+
await performSCP(username, hostname, action, privateKeyPath, sharedDropPath);
|
|
459
740
|
}
|
|
460
741
|
|
|
461
742
|
const { reconnect } = await inquirer.prompt([
|
|
@@ -468,7 +749,7 @@ async function handleSubsequentActions(username, hostname, privateKeyPath, targe
|
|
|
468
749
|
]);
|
|
469
750
|
|
|
470
751
|
if (reconnect) {
|
|
471
|
-
await handleSubsequentActions(username, hostname, privateKeyPath, targetUid, targetPassword);
|
|
752
|
+
await handleSubsequentActions(username, hostname, privateKeyPath, targetUid, targetPassword, sharedDropPath);
|
|
472
753
|
}
|
|
473
754
|
}
|
|
474
755
|
|
|
@@ -507,16 +788,27 @@ async function performReverseForward(username, hostname, privateKeyPath) {
|
|
|
507
788
|
console.log('');
|
|
508
789
|
const spinner = createSpinner(`Forwarding Host:${remotePort} ➔ Localhost:${localPort}...`, networkSpinner).start();
|
|
509
790
|
|
|
791
|
+
let child = null;
|
|
510
792
|
try {
|
|
511
|
-
|
|
793
|
+
child = execa('ssh', sshArgs, { stdio: 'inherit', reject: false });
|
|
512
794
|
trackPID(child.pid);
|
|
513
795
|
spinner.succeed(`Reverse tunnel active! Host can access your app at ${chalk.bold.green('localhost:' + remotePort)}`);
|
|
514
796
|
console.log(chalk.dim(' Press Ctrl+C to terminate the reverse tunnel.'));
|
|
515
|
-
|
|
516
|
-
|
|
797
|
+
|
|
798
|
+
recordEvent('reverse_forward_started', { localPort, remotePort, hostname });
|
|
799
|
+
const result = await child;
|
|
800
|
+
if (result.exitCode === 0) {
|
|
801
|
+
recordEvent('reverse_forward_ended', { localPort, remotePort, hostname, exitCode: 0 });
|
|
802
|
+
} else {
|
|
803
|
+
console.log(chalk.red(`\n ❌ Reverse tunnel exited with code ${result.exitCode}`));
|
|
804
|
+
recordEvent('reverse_forward_failed', { localPort, remotePort, hostname, exitCode: result.exitCode });
|
|
805
|
+
}
|
|
517
806
|
} catch (err) {
|
|
518
807
|
if (err.isCanceled) return;
|
|
519
808
|
if (err.killed) return;
|
|
809
|
+
spinner.fail('Reverse tunnel failed to start');
|
|
520
810
|
console.log(chalk.red(`\n ❌ Tunnel disconnected: ${err.message}`));
|
|
811
|
+
} finally {
|
|
812
|
+
if (child?.pid) untrackPID(child.pid);
|
|
521
813
|
}
|
|
522
814
|
}
|