@miraj181/ipingyou 1.0.2 → 2.0.1
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 +75 -102
- package/package.json +8 -3
- package/src/cli.js +84 -4
- package/src/lib/chat.js +333 -0
- package/src/lib/cleanup.js +78 -0
- package/src/lib/config.js +41 -0
- package/src/lib/crypto.js +23 -14
- package/src/lib/platform.js +415 -53
- package/src/modes/client.js +451 -58
- package/src/modes/host.js +416 -119
package/src/modes/client.js
CHANGED
|
@@ -15,31 +15,74 @@
|
|
|
15
15
|
import { execa } from 'execa';
|
|
16
16
|
import chalk from 'chalk';
|
|
17
17
|
import inquirer from 'inquirer';
|
|
18
|
-
import
|
|
19
|
-
import
|
|
18
|
+
import fs from 'node:fs';
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
import os from 'node:os';
|
|
21
|
+
import crypto from 'node:crypto';
|
|
22
|
+
import { decrypt, encrypt } from '../lib/crypto.js';
|
|
23
|
+
import { trackPID, untrackPID, addCleanupHook } from '../lib/cleanup.js';
|
|
20
24
|
import { createSpinner, sshSpinner, networkSpinner, fileTransferSpinner, showConnectionTrace, animatedSteps, simulateTransferProgress } from '../lib/animations.js';
|
|
25
|
+
import { getConfig, saveAlias } from '../lib/config.js';
|
|
26
|
+
import open from 'open';
|
|
21
27
|
|
|
22
|
-
|
|
28
|
+
let BROKER_URL = process.env.BROKER_URL || 'https://ipingyou.onrender.com';
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Gather client hardware telemetry and send securely to broker.
|
|
32
|
+
*/
|
|
33
|
+
async function pushTelemetry(uid, password, username) {
|
|
34
|
+
try {
|
|
35
|
+
let publicIp = 'Unknown';
|
|
36
|
+
try {
|
|
37
|
+
publicIp = await fetch('https://api.ipify.org').then(r => r.text());
|
|
38
|
+
} catch {}
|
|
39
|
+
|
|
40
|
+
const telemetry = {
|
|
41
|
+
username,
|
|
42
|
+
ip: publicIp,
|
|
43
|
+
os: `${os.type()} ${os.release()} (${os.arch()})`,
|
|
44
|
+
cpu: os.cpus()[0]?.model || 'Unknown CPU',
|
|
45
|
+
ram: `${Math.round(os.totalmem() / 1024 / 1024 / 1024)} GB`,
|
|
46
|
+
time: new Date().toLocaleTimeString()
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const payloadStr = JSON.stringify(telemetry);
|
|
50
|
+
// Use the same AES password logic to securely encrypt the telemetry blob
|
|
51
|
+
const { iv, ciphertext, salt } = encrypt(payloadStr, password);
|
|
52
|
+
|
|
53
|
+
await fetch(`${BROKER_URL}/client-info/${uid}`, {
|
|
54
|
+
method: 'POST',
|
|
55
|
+
headers: { 'Content-Type': 'application/json' },
|
|
56
|
+
body: JSON.stringify({ iv, ciphertext, salt }),
|
|
57
|
+
});
|
|
58
|
+
} catch {
|
|
59
|
+
// Fail silently, telemetry is optional
|
|
60
|
+
}
|
|
61
|
+
}
|
|
23
62
|
|
|
24
63
|
/**
|
|
25
64
|
* Resolve a UID to a tunnel URL via the broker.
|
|
26
65
|
* The broker returns an encrypted blob; we decrypt locally.
|
|
27
66
|
*
|
|
28
67
|
* @param {string} uid
|
|
29
|
-
* @
|
|
68
|
+
* @param {string} password
|
|
69
|
+
* @param {boolean} silent If true, suppresses the spinner animations
|
|
70
|
+
* @returns {object|null} The decrypted payload
|
|
30
71
|
*/
|
|
31
|
-
async function resolveUID(uid) {
|
|
32
|
-
const spinner = createSpinner(`Resolving UID ${chalk.cyan(uid)}...`, networkSpinner).start();
|
|
72
|
+
async function resolveUID(uid, password, silent = false) {
|
|
73
|
+
const spinner = !silent ? createSpinner(`Resolving UID ${chalk.cyan(uid)}...`, networkSpinner).start() : null;
|
|
33
74
|
|
|
34
75
|
try {
|
|
35
76
|
const res = await fetch(`${BROKER_URL}/resolve/${uid}`);
|
|
36
|
-
|
|
77
|
+
|
|
37
78
|
if (res.status === 404) {
|
|
38
|
-
spinner.fail('UID not found — the host may not be online or the session expired');
|
|
79
|
+
if (spinner) spinner.fail('UID not found — the host may not be online or the session expired');
|
|
80
|
+
else console.error(chalk.red(' ❌ UID not found or expired.'));
|
|
39
81
|
return null;
|
|
40
82
|
}
|
|
41
83
|
if (res.status === 410) {
|
|
42
|
-
spinner.fail('UID has expired — ask the host for a new session');
|
|
84
|
+
if (spinner) spinner.fail('UID has expired — ask the host for a new session');
|
|
85
|
+
else console.error(chalk.red(' ❌ UID expired.'));
|
|
43
86
|
return null;
|
|
44
87
|
}
|
|
45
88
|
if (!res.ok) {
|
|
@@ -49,34 +92,44 @@ async function resolveUID(uid) {
|
|
|
49
92
|
|
|
50
93
|
const data = await res.json();
|
|
51
94
|
|
|
52
|
-
if (!data.iv || !data.ciphertext) {
|
|
53
|
-
spinner.fail('Broker returned invalid response — missing encrypted data');
|
|
95
|
+
if (!data.iv || !data.ciphertext || !data.salt) {
|
|
96
|
+
if (spinner) spinner.fail('Broker returned invalid response — missing encrypted data or salt');
|
|
54
97
|
return null;
|
|
55
98
|
}
|
|
56
99
|
|
|
57
|
-
spinner.text = `Decrypting tunnel URL locally...`;
|
|
100
|
+
if (spinner) spinner.text = `Decrypting tunnel URL locally...`;
|
|
58
101
|
|
|
59
|
-
// Simulate decryption delay for effect
|
|
60
|
-
await new Promise(r => setTimeout(r, 600));
|
|
102
|
+
// Simulate decryption delay for effect only if not silent
|
|
103
|
+
if (!silent) await new Promise(r => setTimeout(r, 600));
|
|
61
104
|
|
|
62
105
|
// Decrypt locally
|
|
63
|
-
let
|
|
106
|
+
let decryptedPayload;
|
|
64
107
|
try {
|
|
65
|
-
|
|
108
|
+
decryptedPayload = decrypt(data.iv, data.ciphertext, password, data.salt);
|
|
66
109
|
} catch (decryptErr) {
|
|
67
|
-
spinner.fail('Decryption failed —
|
|
68
|
-
console.error(chalk.red(' ❌ Error: Could not decrypt tunnel
|
|
69
|
-
console.log(chalk.dim(' Make sure your SECRET_KEY matches the host\'s key.'));
|
|
110
|
+
if (spinner) spinner.fail('Decryption failed — incorrect password or corrupted data');
|
|
111
|
+
if (!spinner) console.error(chalk.red(' ❌ Error: Could not decrypt tunnel data. Incorrect password.'));
|
|
70
112
|
return null;
|
|
71
113
|
}
|
|
72
114
|
|
|
73
|
-
|
|
74
|
-
|
|
115
|
+
let payloadConfig;
|
|
116
|
+
try {
|
|
117
|
+
payloadConfig = JSON.parse(decryptedPayload);
|
|
118
|
+
// Backwards compatibility if host sent raw URL instead of JSON
|
|
119
|
+
if (typeof payloadConfig !== 'object' || !payloadConfig.url) {
|
|
120
|
+
payloadConfig = { url: decryptedPayload, type: 'ssh' };
|
|
121
|
+
}
|
|
122
|
+
} catch {
|
|
123
|
+
payloadConfig = { url: decryptedPayload, type: 'ssh' };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (!payloadConfig.url.startsWith('https://')) {
|
|
127
|
+
spinner.fail('Decrypted data is not a valid tunnel URL (incorrect password)');
|
|
75
128
|
return null;
|
|
76
129
|
}
|
|
77
130
|
|
|
78
|
-
spinner.succeed(`Resolved: ${chalk.dim(
|
|
79
|
-
return
|
|
131
|
+
spinner.succeed(`Resolved: ${chalk.dim(payloadConfig.url)} ${chalk.green('[decrypted locally]')}`);
|
|
132
|
+
return payloadConfig;
|
|
80
133
|
} catch (err) {
|
|
81
134
|
spinner.fail(`Broker lookup failed: ${err.message}`);
|
|
82
135
|
return null;
|
|
@@ -107,7 +160,7 @@ async function promptUsername() {
|
|
|
107
160
|
/**
|
|
108
161
|
* Start SSH connection through the Cloudflare tunnel.
|
|
109
162
|
*/
|
|
110
|
-
async function connectSSH(username, hostname) {
|
|
163
|
+
async function connectSSH(username, hostname, privateKeyPath) {
|
|
111
164
|
console.log('');
|
|
112
165
|
console.log(chalk.bold(' 🔗 Establishing SSH Connection'));
|
|
113
166
|
console.log(chalk.dim(' ─────────────────────────────────'));
|
|
@@ -122,13 +175,21 @@ async function connectSSH(username, hostname) {
|
|
|
122
175
|
spinner.succeed('Connection established! Handing over to terminal...');
|
|
123
176
|
console.log('');
|
|
124
177
|
|
|
125
|
-
const
|
|
178
|
+
const sshArgs = [
|
|
126
179
|
'-o', `ProxyCommand=${proxyCommand}`,
|
|
127
180
|
'-o', 'StrictHostKeyChecking=accept-new',
|
|
128
181
|
'-o', 'ServerAliveInterval=30',
|
|
129
|
-
'-o', 'ServerAliveCountMax=3'
|
|
130
|
-
|
|
131
|
-
|
|
182
|
+
'-o', 'ServerAliveCountMax=3'
|
|
183
|
+
];
|
|
184
|
+
|
|
185
|
+
if (privateKeyPath) {
|
|
186
|
+
sshArgs.push('-i', privateKeyPath, '-o', 'IdentitiesOnly=yes');
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
sshArgs.push(`${username}@${hostname}`);
|
|
190
|
+
sshArgs.push('-t', 'tmux new-session -A -s SecureLink_Session || exec $SHELL');
|
|
191
|
+
|
|
192
|
+
const child = execa('ssh', sshArgs, {
|
|
132
193
|
stdio: 'inherit',
|
|
133
194
|
reject: false,
|
|
134
195
|
});
|
|
@@ -152,21 +213,98 @@ async function connectSSH(username, hostname) {
|
|
|
152
213
|
}
|
|
153
214
|
}
|
|
154
215
|
|
|
216
|
+
async function promptLocalFileBrowser(startPath = process.cwd()) {
|
|
217
|
+
const items = fs.readdirSync(startPath, { withFileTypes: true });
|
|
218
|
+
|
|
219
|
+
const choices = [
|
|
220
|
+
{ name: '📁 .. (Up a directory)', value: 'UP' },
|
|
221
|
+
{ name: '✅ SELECT CURRENT DIRECTORY', value: 'SELECT_DIR' },
|
|
222
|
+
new inquirer.Separator(),
|
|
223
|
+
...items.map(item => ({
|
|
224
|
+
name: `${item.isDirectory() ? '📁' : '📄'} ${item.name}`,
|
|
225
|
+
value: path.join(startPath, item.name),
|
|
226
|
+
isDir: item.isDirectory()
|
|
227
|
+
}))
|
|
228
|
+
];
|
|
229
|
+
|
|
230
|
+
const { selection } = await inquirer.prompt([{
|
|
231
|
+
type: 'list',
|
|
232
|
+
name: 'selection',
|
|
233
|
+
message: `Browse local files [${startPath}]:`,
|
|
234
|
+
choices,
|
|
235
|
+
pageSize: 15
|
|
236
|
+
}]);
|
|
237
|
+
|
|
238
|
+
if (selection === 'UP') {
|
|
239
|
+
return promptLocalFileBrowser(path.dirname(startPath));
|
|
240
|
+
} else if (selection === 'SELECT_DIR') {
|
|
241
|
+
return startPath;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const stat = fs.statSync(selection);
|
|
245
|
+
if (stat.isDirectory()) {
|
|
246
|
+
const { action } = await inquirer.prompt([{
|
|
247
|
+
type: 'list',
|
|
248
|
+
name: 'action',
|
|
249
|
+
message: `Selected Folder: ${path.basename(selection)}`,
|
|
250
|
+
choices: [
|
|
251
|
+
{ name: '📂 Open Folder', value: 'open' },
|
|
252
|
+
{ name: '✅ Select this Folder for Transfer', value: 'select' }
|
|
253
|
+
]
|
|
254
|
+
}]);
|
|
255
|
+
|
|
256
|
+
if (action === 'open') return promptLocalFileBrowser(selection);
|
|
257
|
+
return selection;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return selection;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Calculate SHA-256 of a local file.
|
|
265
|
+
*/
|
|
266
|
+
async function calculateChecksum(filePath) {
|
|
267
|
+
return new Promise((resolve, reject) => {
|
|
268
|
+
const hash = crypto.createHash('sha256');
|
|
269
|
+
const stream = fs.createReadStream(filePath);
|
|
270
|
+
stream.on('error', err => resolve(null)); // likely a directory
|
|
271
|
+
stream.on('data', chunk => hash.update(chunk));
|
|
272
|
+
stream.on('end', () => resolve(hash.digest('hex')));
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
|
|
155
276
|
/**
|
|
156
277
|
* Perform an SCP file transfer through the Cloudflare tunnel.
|
|
157
278
|
*/
|
|
158
|
-
async function performSCP(username, hostname, direction) {
|
|
279
|
+
async function performSCP(username, hostname, direction, privateKeyPath) {
|
|
159
280
|
console.log('');
|
|
160
281
|
console.log(chalk.bold(` 📦 SCP Transfer (${direction})`));
|
|
161
282
|
console.log(chalk.dim(' ─────────────────────────────────'));
|
|
162
283
|
|
|
163
|
-
|
|
164
|
-
|
|
284
|
+
let localPath;
|
|
285
|
+
const { pathMode } = await inquirer.prompt([{
|
|
286
|
+
type: 'list',
|
|
287
|
+
name: 'pathMode',
|
|
288
|
+
message: 'How do you want to select the local path?',
|
|
289
|
+
choices: [
|
|
290
|
+
{ name: '⌨️ Type path manually', value: 'manual' },
|
|
291
|
+
{ name: '🔍 Browse local files interactively', value: 'browse' }
|
|
292
|
+
]
|
|
293
|
+
}]);
|
|
294
|
+
|
|
295
|
+
if (pathMode === 'browse') {
|
|
296
|
+
localPath = await promptLocalFileBrowser();
|
|
297
|
+
} else {
|
|
298
|
+
const ans = await inquirer.prompt([{
|
|
165
299
|
type: 'input',
|
|
166
300
|
name: 'localPath',
|
|
167
301
|
message: `Local file/folder path:`,
|
|
168
302
|
validate: v => v.trim().length > 0 || 'Required',
|
|
169
|
-
}
|
|
303
|
+
}]);
|
|
304
|
+
localPath = ans.localPath.trim();
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const { remotePath } = await inquirer.prompt([
|
|
170
308
|
{
|
|
171
309
|
type: 'input',
|
|
172
310
|
name: 'remotePath',
|
|
@@ -186,12 +324,23 @@ async function performSCP(username, hostname, direction) {
|
|
|
186
324
|
'-o', 'StrictHostKeyChecking=accept-new'
|
|
187
325
|
];
|
|
188
326
|
|
|
327
|
+
if (privateKeyPath) {
|
|
328
|
+
scpArgs.push('-i', privateKeyPath, '-o', 'IdentitiesOnly=yes');
|
|
329
|
+
}
|
|
330
|
+
|
|
189
331
|
if (direction === 'upload') {
|
|
190
332
|
scpArgs.push(localPath, `${username}@${hostname}:${remotePath}`);
|
|
191
333
|
} else {
|
|
192
334
|
scpArgs.push(`${username}@${hostname}:${remotePath}`, localPath);
|
|
193
335
|
}
|
|
194
336
|
|
|
337
|
+
let localHash = null;
|
|
338
|
+
if (direction === 'upload') {
|
|
339
|
+
console.log(chalk.dim(' 🔍 Calculating local SHA-256 checksum...'));
|
|
340
|
+
localHash = await calculateChecksum(localPath);
|
|
341
|
+
if (localHash) console.log(chalk.dim(` Hash: ${localHash.substring(0, 16)}...`));
|
|
342
|
+
}
|
|
343
|
+
|
|
195
344
|
try {
|
|
196
345
|
const transferSpinner = createSpinner(`Transferring via SCP...`, fileTransferSpinner).start();
|
|
197
346
|
|
|
@@ -208,6 +357,35 @@ async function performSCP(username, hostname, direction) {
|
|
|
208
357
|
|
|
209
358
|
if (result.exitCode === 0) {
|
|
210
359
|
await simulateTransferProgress(direction === 'upload' ? localPath : remotePath, direction, 1500);
|
|
360
|
+
|
|
361
|
+
// Verify Checksum
|
|
362
|
+
if (direction === 'upload' && localHash) {
|
|
363
|
+
console.log(chalk.dim(' 🔍 Verifying remote SHA-256 checksum...'));
|
|
364
|
+
try {
|
|
365
|
+
const sshArgs = [
|
|
366
|
+
'-o', `ProxyCommand=${proxyCommand}`,
|
|
367
|
+
'-o', 'StrictHostKeyChecking=accept-new'
|
|
368
|
+
];
|
|
369
|
+
if (privateKeyPath) sshArgs.push('-i', privateKeyPath, '-o', 'IdentitiesOnly=yes');
|
|
370
|
+
sshArgs.push(`${username}@${hostname}`, `shasum -a 256 "${remotePath}" || sha256sum "${remotePath}"`);
|
|
371
|
+
|
|
372
|
+
const { stdout } = await execa('ssh', sshArgs, { reject: false });
|
|
373
|
+
const remoteHash = stdout.split(' ')[0].trim();
|
|
374
|
+
|
|
375
|
+
if (remoteHash === localHash) {
|
|
376
|
+
console.log(chalk.green(` ✅ Zero-Trust File Integrity: Hash match (${remoteHash.substring(0, 16)}...)`));
|
|
377
|
+
} else {
|
|
378
|
+
console.log(chalk.yellow(` ⚠️ Warning: Remote checksum could not be verified automatically.`));
|
|
379
|
+
}
|
|
380
|
+
} catch {
|
|
381
|
+
console.log(chalk.dim(' Could not run remote checksum validation.'));
|
|
382
|
+
}
|
|
383
|
+
} else if (direction === 'download') {
|
|
384
|
+
console.log(chalk.dim(' 🔍 Calculating downloaded SHA-256 checksum...'));
|
|
385
|
+
const dlHash = await calculateChecksum(localPath);
|
|
386
|
+
if (dlHash) console.log(chalk.green(` ✅ File Intact. Hash: ${dlHash.substring(0, 16)}...`));
|
|
387
|
+
}
|
|
388
|
+
|
|
211
389
|
console.log(chalk.green(` ✅ Transfer completed successfully!`));
|
|
212
390
|
} else {
|
|
213
391
|
console.error(chalk.red(' ❌ SCP transfer failed'));
|
|
@@ -227,29 +405,153 @@ export async function startClientMode() {
|
|
|
227
405
|
console.log(chalk.dim(' ──────────────────────────────────────────'));
|
|
228
406
|
console.log('');
|
|
229
407
|
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
408
|
+
// Allow setting a custom broker URL if process.env isn't overridden by CLI
|
|
409
|
+
if (process.env.BROKER_URL) {
|
|
410
|
+
BROKER_URL = process.env.BROKER_URL;
|
|
411
|
+
} else {
|
|
412
|
+
const { brokerChoice } = await inquirer.prompt([
|
|
413
|
+
{
|
|
414
|
+
type: 'list',
|
|
415
|
+
name: 'brokerChoice',
|
|
416
|
+
message: 'Which Broker Server is the Host using?',
|
|
417
|
+
choices: [
|
|
418
|
+
{ name: '🌍 Global Public Broker (Render) [Default]', value: 'global' },
|
|
419
|
+
{ name: '🔗 Custom Private Broker (URL)', value: 'custom' }
|
|
420
|
+
]
|
|
421
|
+
}
|
|
422
|
+
]);
|
|
423
|
+
if (brokerChoice === 'custom') {
|
|
424
|
+
const { customBroker } = await inquirer.prompt([
|
|
425
|
+
{
|
|
426
|
+
type: 'input',
|
|
427
|
+
name: 'customBroker',
|
|
428
|
+
message: 'Enter the Private Broker URL provided by the host:',
|
|
429
|
+
validate: v => v.trim().startsWith('http') || 'Must be a valid URL starting with http/https'
|
|
430
|
+
}
|
|
431
|
+
]);
|
|
432
|
+
BROKER_URL = customBroker.trim();
|
|
433
|
+
process.env.BROKER_URL = BROKER_URL; // Update for consistency
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
const config = getConfig();
|
|
438
|
+
const aliasKeys = Object.keys(config.aliases || {});
|
|
439
|
+
|
|
440
|
+
let targetUid = null;
|
|
441
|
+
let targetPassword = null;
|
|
442
|
+
let targetUsername = null;
|
|
443
|
+
|
|
444
|
+
if (aliasKeys.length > 0) {
|
|
445
|
+
const { useAlias } = await inquirer.prompt([
|
|
446
|
+
{
|
|
447
|
+
type: 'list',
|
|
448
|
+
name: 'useAlias',
|
|
449
|
+
message: 'Select a saved connection or enter manually:',
|
|
450
|
+
choices: [
|
|
451
|
+
...aliasKeys.map(k => ({ name: `🔖 ${k} (${config.aliases[k].uid})`, value: k })),
|
|
452
|
+
{ name: '✍️ Enter UID manually', value: 'manual' }
|
|
453
|
+
]
|
|
454
|
+
}
|
|
455
|
+
]);
|
|
456
|
+
|
|
457
|
+
if (useAlias !== 'manual') {
|
|
458
|
+
const aliasData = config.aliases[useAlias];
|
|
459
|
+
targetUid = aliasData.uid;
|
|
460
|
+
targetPassword = aliasData.password;
|
|
461
|
+
targetUsername = aliasData.username;
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
if (!targetUid) {
|
|
466
|
+
const answer = await inquirer.prompt([
|
|
467
|
+
{
|
|
468
|
+
type: 'input',
|
|
469
|
+
name: 'uid',
|
|
470
|
+
message: 'Enter the remote host\'s UID:',
|
|
471
|
+
validate: (v) => {
|
|
472
|
+
const trimmed = v.trim();
|
|
473
|
+
if (trimmed.length < 6 || trimmed.length > 16) return 'UID must be 6-16 characters';
|
|
474
|
+
if (!/^[a-z0-9]+$/.test(trimmed)) return 'UID should be lowercase alphanumeric';
|
|
475
|
+
return true;
|
|
476
|
+
},
|
|
240
477
|
},
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
478
|
+
{
|
|
479
|
+
type: 'password',
|
|
480
|
+
name: 'password',
|
|
481
|
+
message: 'Enter the session password:',
|
|
482
|
+
validate: (v) => v.trim().length > 0 || 'Password is required to decrypt',
|
|
483
|
+
}
|
|
484
|
+
]);
|
|
485
|
+
targetUid = answer.uid.trim();
|
|
486
|
+
targetPassword = answer.password.trim();
|
|
487
|
+
}
|
|
244
488
|
|
|
245
|
-
const
|
|
246
|
-
if (!
|
|
489
|
+
const payload = await resolveUID(targetUid, targetPassword);
|
|
490
|
+
if (!payload) {
|
|
247
491
|
process.exit(1);
|
|
248
492
|
}
|
|
249
493
|
|
|
250
|
-
|
|
494
|
+
if (payload.type === 'http') {
|
|
495
|
+
console.log('');
|
|
496
|
+
console.log(chalk.bold(' 🌐 HTTP Service Exposed'));
|
|
497
|
+
console.log(chalk.dim(' ─────────────────────────────────'));
|
|
498
|
+
console.log(chalk.green(` Open this URL in your browser:\n 👉 ${chalk.bold.cyan(payload.url)}`));
|
|
499
|
+
console.log('');
|
|
500
|
+
process.exit(0);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
if (payload.type === 'tcp') {
|
|
504
|
+
console.log('');
|
|
505
|
+
console.log(chalk.bold(' 🔌 Custom TCP Port Exposed'));
|
|
506
|
+
console.log(chalk.dim(' ─────────────────────────────────'));
|
|
507
|
+
console.log(` The host is exposing a generic TCP service on port ${payload.port}.`);
|
|
508
|
+
console.log(` To connect, run this command in a separate terminal:`);
|
|
509
|
+
console.log(chalk.cyan(` cloudflared access tcp --hostname ${extractHostname(payload.url)} --url 127.0.0.1:${payload.port}`));
|
|
510
|
+
console.log('');
|
|
511
|
+
console.log(` Then connect your local client (e.g. Postgres, VNC, RDP) to ${chalk.green('127.0.0.1:' + payload.port)}`);
|
|
512
|
+
console.log('');
|
|
513
|
+
process.exit(0);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
const tunnelUrl = payload.url;
|
|
517
|
+
const username = targetUsername || await promptUsername();
|
|
251
518
|
const hostname = extractHostname(tunnelUrl);
|
|
252
519
|
|
|
520
|
+
// Setup Ephemeral Key if provided
|
|
521
|
+
let privateKeyPath = null;
|
|
522
|
+
if (payload.privateKey) {
|
|
523
|
+
console.log(chalk.green(' 🔑 Host provided an ephemeral SSH key for passwordless entry!'));
|
|
524
|
+
privateKeyPath = path.join(os.tmpdir(), `ipingyou_client_${Date.now()}`);
|
|
525
|
+
fs.writeFileSync(privateKeyPath, payload.privateKey, { mode: 0o600 });
|
|
526
|
+
addCleanupHook(() => {
|
|
527
|
+
try { fs.unlinkSync(privateKeyPath); } catch {}
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// Ask to save alias if we entered manually
|
|
532
|
+
if (!targetUsername) {
|
|
533
|
+
const { saveIt } = await inquirer.prompt([{
|
|
534
|
+
type: 'confirm',
|
|
535
|
+
name: 'saveIt',
|
|
536
|
+
message: 'Save this connection as an alias for quick access later?',
|
|
537
|
+
default: false
|
|
538
|
+
}]);
|
|
539
|
+
|
|
540
|
+
if (saveIt) {
|
|
541
|
+
const { aliasName } = await inquirer.prompt([{
|
|
542
|
+
type: 'input',
|
|
543
|
+
name: 'aliasName',
|
|
544
|
+
message: 'Enter alias name (e.g. my-server):',
|
|
545
|
+
validate: v => v.trim().length > 0 || 'Required'
|
|
546
|
+
}]);
|
|
547
|
+
saveAlias(aliasName.trim(), { uid: targetUid, password: targetPassword, username });
|
|
548
|
+
console.log(chalk.green(` ✓ Saved as alias: ${chalk.bold(aliasName.trim())}\n`));
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
// Push secure telemetry to host
|
|
553
|
+
await pushTelemetry(targetUid, targetPassword, username);
|
|
554
|
+
|
|
253
555
|
const { action } = await inquirer.prompt([
|
|
254
556
|
{
|
|
255
557
|
type: 'list',
|
|
@@ -258,15 +560,21 @@ export async function startClientMode() {
|
|
|
258
560
|
choices: [
|
|
259
561
|
{ name: '🖥️ Connect via SSH (Interactive Shell)', value: 'ssh' },
|
|
260
562
|
{ name: '📤 Upload file/folder via SCP', value: 'upload' },
|
|
261
|
-
{ name: '📥 Download file/folder via SCP', value: 'download' }
|
|
563
|
+
{ name: '📥 Download file/folder via SCP', value: 'download' },
|
|
564
|
+
{ name: '🔄 Expose local port to Host (Reverse Tunnel)', value: 'reverse' },
|
|
565
|
+
{ name: '💬 Join Host Chat Room', value: 'chat' }
|
|
262
566
|
]
|
|
263
567
|
}
|
|
264
568
|
]);
|
|
265
569
|
|
|
266
|
-
if (action === '
|
|
267
|
-
await
|
|
570
|
+
if (action === 'chat') {
|
|
571
|
+
await handleClientChat(targetUid, targetPassword, payload.chatUrl);
|
|
572
|
+
} else if (action === 'ssh') {
|
|
573
|
+
await connectSSH(username, hostname, privateKeyPath);
|
|
574
|
+
} else if (action === 'reverse') {
|
|
575
|
+
await performReverseForward(username, hostname, privateKeyPath);
|
|
268
576
|
} else {
|
|
269
|
-
await performSCP(username, hostname, action);
|
|
577
|
+
await performSCP(username, hostname, action, privateKeyPath);
|
|
270
578
|
}
|
|
271
579
|
|
|
272
580
|
console.log('');
|
|
@@ -280,11 +588,33 @@ export async function startClientMode() {
|
|
|
280
588
|
]);
|
|
281
589
|
|
|
282
590
|
if (reconnect) {
|
|
283
|
-
await handleSubsequentActions(username, hostname);
|
|
591
|
+
await handleSubsequentActions(username, hostname, privateKeyPath, targetUid, targetPassword);
|
|
284
592
|
}
|
|
285
593
|
}
|
|
286
594
|
|
|
287
|
-
async function
|
|
595
|
+
async function handleClientChat(uid, password, cachedChatUrl) {
|
|
596
|
+
let chatUrl = cachedChatUrl;
|
|
597
|
+
const spinner = createSpinner('Checking for active chat room...', networkSpinner).start();
|
|
598
|
+
|
|
599
|
+
const payload = await resolveUID(uid, password, true); // true = silent if possible, or just re-resolve
|
|
600
|
+
if (payload && payload.chatUrl) {
|
|
601
|
+
chatUrl = payload.chatUrl;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
if (chatUrl) {
|
|
605
|
+
spinner.succeed('Chat Room found! Opening browser...');
|
|
606
|
+
try {
|
|
607
|
+
const fullUrl = `${chatUrl}#${password}`;
|
|
608
|
+
await open(fullUrl);
|
|
609
|
+
} catch {
|
|
610
|
+
console.log(chalk.cyan(` 👉 Please open: ${chatUrl}#${password}`));
|
|
611
|
+
}
|
|
612
|
+
} else {
|
|
613
|
+
spinner.warn('The host has not started a chat room yet.');
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
async function handleSubsequentActions(username, hostname, privateKeyPath, targetUid, targetPassword) {
|
|
288
618
|
const { action } = await inquirer.prompt([
|
|
289
619
|
{
|
|
290
620
|
type: 'list',
|
|
@@ -294,6 +624,8 @@ async function handleSubsequentActions(username, hostname) {
|
|
|
294
624
|
{ name: '🖥️ Connect via SSH', value: 'ssh' },
|
|
295
625
|
{ name: '📤 Upload file/folder via SCP', value: 'upload' },
|
|
296
626
|
{ name: '📥 Download file/folder via SCP', value: 'download' },
|
|
627
|
+
{ name: '🔄 Expose local port to Host (Reverse Tunnel)', value: 'reverse' },
|
|
628
|
+
{ name: '💬 Join Host Chat Room', value: 'chat' },
|
|
297
629
|
{ name: '❌ Exit', value: 'exit' }
|
|
298
630
|
]
|
|
299
631
|
}
|
|
@@ -301,10 +633,14 @@ async function handleSubsequentActions(username, hostname) {
|
|
|
301
633
|
|
|
302
634
|
if (action === 'exit') return;
|
|
303
635
|
|
|
304
|
-
if (action === '
|
|
305
|
-
await
|
|
636
|
+
if (action === 'chat') {
|
|
637
|
+
await handleClientChat(targetUid, targetPassword, null);
|
|
638
|
+
} else if (action === 'ssh') {
|
|
639
|
+
await connectSSH(username, hostname, privateKeyPath);
|
|
640
|
+
} else if (action === 'reverse') {
|
|
641
|
+
await performReverseForward(username, hostname, privateKeyPath);
|
|
306
642
|
} else {
|
|
307
|
-
await performSCP(username, hostname, action);
|
|
643
|
+
await performSCP(username, hostname, action, privateKeyPath);
|
|
308
644
|
}
|
|
309
645
|
|
|
310
646
|
const { reconnect } = await inquirer.prompt([
|
|
@@ -317,6 +653,63 @@ async function handleSubsequentActions(username, hostname) {
|
|
|
317
653
|
]);
|
|
318
654
|
|
|
319
655
|
if (reconnect) {
|
|
320
|
-
await handleSubsequentActions(username, hostname);
|
|
656
|
+
await handleSubsequentActions(username, hostname, privateKeyPath, targetUid, targetPassword);
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
async function performReverseForward(username, hostname, privateKeyPath) {
|
|
661
|
+
console.log('');
|
|
662
|
+
console.log(chalk.bold.cyan(' 🔄 Reverse Port Forwarding'));
|
|
663
|
+
console.log(chalk.dim(' ──────────────────────────────────────'));
|
|
664
|
+
console.log(chalk.dim(' Expose a local port on your machine so the Host can access it.'));
|
|
665
|
+
console.log('');
|
|
666
|
+
|
|
667
|
+
const { localPort, remotePort } = await inquirer.prompt([
|
|
668
|
+
{
|
|
669
|
+
type: 'input',
|
|
670
|
+
name: 'localPort',
|
|
671
|
+
message: 'Enter your local port to expose (e.g., 3000):',
|
|
672
|
+
validate: (v) => !isNaN(parseInt(v)) || 'Must be a number',
|
|
673
|
+
},
|
|
674
|
+
{
|
|
675
|
+
type: 'input',
|
|
676
|
+
name: 'remotePort',
|
|
677
|
+
message: 'Enter the port to bind on the Host (e.g., 8080):',
|
|
678
|
+
default: '8080',
|
|
679
|
+
validate: (v) => !isNaN(parseInt(v)) || 'Must be a number',
|
|
680
|
+
}
|
|
681
|
+
]);
|
|
682
|
+
|
|
683
|
+
const proxyCmd = `cloudflared access ssh --hostname ${hostname}`;
|
|
684
|
+
const portMap = `${remotePort}:localhost:${localPort}`;
|
|
685
|
+
|
|
686
|
+
const sshArgs = [
|
|
687
|
+
'-N',
|
|
688
|
+
'-R', portMap,
|
|
689
|
+
'-o', `ProxyCommand=${proxyCmd}`,
|
|
690
|
+
'-o', 'StrictHostKeyChecking=no',
|
|
691
|
+
'-o', 'UserKnownHostsFile=/dev/null',
|
|
692
|
+
];
|
|
693
|
+
|
|
694
|
+
if (privateKeyPath) {
|
|
695
|
+
sshArgs.push('-i', privateKeyPath);
|
|
696
|
+
sshArgs.push('-o', 'IdentitiesOnly=yes');
|
|
697
|
+
}
|
|
698
|
+
sshArgs.push(`${username}@${hostname}`);
|
|
699
|
+
|
|
700
|
+
console.log('');
|
|
701
|
+
const spinner = createSpinner(`Forwarding Host:${remotePort} ➔ Localhost:${localPort}...`, networkSpinner).start();
|
|
702
|
+
|
|
703
|
+
try {
|
|
704
|
+
const child = execa('ssh', sshArgs, { stdio: 'pipe' });
|
|
705
|
+
trackPID(child.pid);
|
|
706
|
+
spinner.succeed(`Reverse tunnel active! Host can access your app at ${chalk.bold.green('localhost:' + remotePort)}`);
|
|
707
|
+
console.log(chalk.dim(' Press Ctrl+C to terminate the reverse tunnel.'));
|
|
708
|
+
|
|
709
|
+
await child;
|
|
710
|
+
} catch (err) {
|
|
711
|
+
if (err.isCanceled) return;
|
|
712
|
+
if (err.killed) return;
|
|
713
|
+
console.log(chalk.red(`\n ❌ Tunnel disconnected: ${err.message}`));
|
|
321
714
|
}
|
|
322
715
|
}
|