@miraj181/ipingyou 2.0.10 → 2.0.12
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 +1 -1
- package/src/cli.js +10 -15
- package/src/lib/broker.js +144 -0
- package/src/lib/checksum.js +12 -0
- package/src/lib/cleanup.js +37 -15
- package/src/lib/config.js +7 -3
- package/src/lib/path-browser.js +232 -0
- package/src/lib/ssh.js +55 -0
- package/src/lib/tunnel.js +66 -0
- package/src/modes/client.js +48 -241
- package/src/modes/host.js +59 -151
- package/src/server.js +6 -1
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -21,12 +21,17 @@
|
|
|
21
21
|
import { Command } from 'commander';
|
|
22
22
|
import inquirer from 'inquirer';
|
|
23
23
|
import chalk from 'chalk';
|
|
24
|
+
import fs from 'node:fs';
|
|
25
|
+
import path from 'node:path';
|
|
26
|
+
import { fileURLToPath } from 'node:url';
|
|
24
27
|
|
|
25
28
|
import { detectOS, checkDependencies } from './lib/platform.js';
|
|
26
|
-
import { installShutdownHandlers, executePanicMode } from './lib/cleanup.js';
|
|
29
|
+
import { cleanupAll, installShutdownHandlers, executePanicMode } from './lib/cleanup.js';
|
|
27
30
|
import { startHostMode } from './modes/host.js';
|
|
28
31
|
import { startClientMode } from './modes/client.js';
|
|
29
32
|
|
|
33
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
34
|
+
const packageJson = JSON.parse(fs.readFileSync(path.join(__dirname, '../package.json'), 'utf8'));
|
|
30
35
|
|
|
31
36
|
// ─── ASCII Banner ────────────────────────────────────────────
|
|
32
37
|
const wolfAscii = [
|
|
@@ -146,19 +151,9 @@ function fatal(context, err) {
|
|
|
146
151
|
stackLines.forEach(line => console.error(chalk.dim(` ${line.trim()}`)));
|
|
147
152
|
}
|
|
148
153
|
console.error('');
|
|
149
|
-
process.exit(1);
|
|
154
|
+
cleanupAll().finally(() => process.exit(1));
|
|
150
155
|
}
|
|
151
156
|
|
|
152
|
-
// ─── Process-Level Error Catching ────────────────────────────
|
|
153
|
-
process.on('uncaughtException', (err) => {
|
|
154
|
-
fatal('uncaughtException', err);
|
|
155
|
-
});
|
|
156
|
-
|
|
157
|
-
process.on('unhandledRejection', (reason) => {
|
|
158
|
-
const err = reason instanceof Error ? reason : new Error(String(reason));
|
|
159
|
-
fatal('unhandledRejection', err);
|
|
160
|
-
});
|
|
161
|
-
|
|
162
157
|
// ─── Interactive Mode Selection ──────────────────────────────
|
|
163
158
|
async function interactiveMode() {
|
|
164
159
|
showBanner();
|
|
@@ -225,7 +220,7 @@ const program = new Command();
|
|
|
225
220
|
program
|
|
226
221
|
.name('ipingyou')
|
|
227
222
|
.description('SecureLink-CLI — Secure P2P remote access via SSH & Cloudflare Tunnels')
|
|
228
|
-
.version(
|
|
223
|
+
.version(packageJson.version)
|
|
229
224
|
.option('-b, --broker <url>', 'Override the central broker URL')
|
|
230
225
|
.addHelpText('beforeAll', () => {
|
|
231
226
|
showBanner();
|
|
@@ -255,7 +250,7 @@ program
|
|
|
255
250
|
.command('connect')
|
|
256
251
|
.description('Connect to a remote machine via its UID (SSH or SCP)')
|
|
257
252
|
.option('-u, --uid <uid>', 'The remote host UID')
|
|
258
|
-
.action(async () => {
|
|
253
|
+
.action(async (commandOptions) => {
|
|
259
254
|
try {
|
|
260
255
|
const opts = program.opts();
|
|
261
256
|
if (opts.broker) process.env.BROKER_URL = opts.broker;
|
|
@@ -264,7 +259,7 @@ program
|
|
|
264
259
|
showSystemInfo();
|
|
265
260
|
installShutdownHandlers();
|
|
266
261
|
await checkDependencies();
|
|
267
|
-
await startClientMode();
|
|
262
|
+
await startClientMode({ uid: commandOptions.uid });
|
|
268
263
|
} catch (err) {
|
|
269
264
|
fatal('connect', err);
|
|
270
265
|
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import { decrypt, encrypt } from './crypto.js';
|
|
4
|
+
import { createSpinner, cryptoSpinner, networkSpinner } from './animations.js';
|
|
5
|
+
|
|
6
|
+
export async function pingBroker(url) {
|
|
7
|
+
try {
|
|
8
|
+
const controller = new AbortController();
|
|
9
|
+
const id = setTimeout(() => controller.abort(), 3000);
|
|
10
|
+
const res = await fetch(`${url}/health`, { signal: controller.signal });
|
|
11
|
+
clearTimeout(id);
|
|
12
|
+
return res.ok;
|
|
13
|
+
} catch {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function registerWithBroker(brokerUrl, uid, tunnelUrl, password, serviceConfig) {
|
|
19
|
+
const spinner = createSpinner('Encrypting session data...', cryptoSpinner).start();
|
|
20
|
+
|
|
21
|
+
try {
|
|
22
|
+
await new Promise(r => setTimeout(r, 600));
|
|
23
|
+
const payload = JSON.stringify({ url: tunnelUrl, ...serviceConfig });
|
|
24
|
+
const encrypted = encrypt(payload, password);
|
|
25
|
+
|
|
26
|
+
spinner.text = 'Registering with broker...';
|
|
27
|
+
|
|
28
|
+
const res = await fetch(`${brokerUrl}/register`, {
|
|
29
|
+
method: 'POST',
|
|
30
|
+
headers: { 'Content-Type': 'application/json' },
|
|
31
|
+
body: JSON.stringify({
|
|
32
|
+
uid,
|
|
33
|
+
iv: encrypted.iv,
|
|
34
|
+
ciphertext: encrypted.ciphertext,
|
|
35
|
+
salt: encrypted.salt,
|
|
36
|
+
}),
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
if (!res.ok) {
|
|
40
|
+
const data = await res.json().catch(() => ({}));
|
|
41
|
+
throw new Error(data.error || `HTTP ${res.status}`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
spinner.succeed(`Registered with broker ${chalk.dim(`(${brokerUrl})`)} ${chalk.green('[E2E encrypted]')}`);
|
|
45
|
+
return true;
|
|
46
|
+
} catch (err) {
|
|
47
|
+
spinner.fail(`Broker registration failed: ${err.message}`);
|
|
48
|
+
console.error(chalk.red(` ❌ Error: ${err.message}`));
|
|
49
|
+
console.log(chalk.yellow(' ⚠️ Remote clients won\'t be able to find you without the broker.'));
|
|
50
|
+
console.log(chalk.dim(' Share the tunnel URL directly if needed.'));
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function resolveUID(brokerUrl, uid, password, silent = false) {
|
|
56
|
+
const spinner = !silent ? createSpinner(`Resolving UID ${chalk.cyan(uid)}...`, networkSpinner).start() : null;
|
|
57
|
+
|
|
58
|
+
try {
|
|
59
|
+
const res = await fetch(`${brokerUrl}/resolve/${uid}`);
|
|
60
|
+
|
|
61
|
+
if (res.status === 404) {
|
|
62
|
+
if (spinner) spinner.fail('UID not found — the host may not be online or the session expired');
|
|
63
|
+
else console.error(chalk.red(' ❌ UID not found or expired.'));
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
if (res.status === 410) {
|
|
67
|
+
if (spinner) spinner.fail('UID has expired — ask the host for a new session');
|
|
68
|
+
else console.error(chalk.red(' ❌ UID expired.'));
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
if (!res.ok) {
|
|
72
|
+
const data = await res.json().catch(() => ({}));
|
|
73
|
+
throw new Error(data.error || `HTTP ${res.status}`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const data = await res.json();
|
|
77
|
+
|
|
78
|
+
if (!data.iv || !data.ciphertext || !data.salt) {
|
|
79
|
+
if (spinner) spinner.fail('Broker returned invalid response — missing encrypted data or salt');
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (spinner) spinner.text = 'Decrypting tunnel URL locally...';
|
|
84
|
+
if (!silent) await new Promise(r => setTimeout(r, 600));
|
|
85
|
+
|
|
86
|
+
let decryptedPayload;
|
|
87
|
+
try {
|
|
88
|
+
decryptedPayload = decrypt(data.iv, data.ciphertext, password, data.salt);
|
|
89
|
+
} catch {
|
|
90
|
+
if (spinner) spinner.fail('Decryption failed — incorrect password or corrupted data');
|
|
91
|
+
if (!spinner) console.error(chalk.red(' ❌ Error: Could not decrypt tunnel data. Incorrect password.'));
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
let payloadConfig;
|
|
96
|
+
try {
|
|
97
|
+
payloadConfig = JSON.parse(decryptedPayload);
|
|
98
|
+
if (typeof payloadConfig !== 'object' || !payloadConfig.url) {
|
|
99
|
+
payloadConfig = { url: decryptedPayload, type: 'ssh' };
|
|
100
|
+
}
|
|
101
|
+
} catch {
|
|
102
|
+
payloadConfig = { url: decryptedPayload, type: 'ssh' };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (!payloadConfig.url.startsWith('https://')) {
|
|
106
|
+
if (spinner) spinner.fail('Decrypted data is not a valid tunnel URL (incorrect password)');
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (spinner) spinner.succeed(`Resolved: ${chalk.dim(payloadConfig.url)} ${chalk.green('[decrypted locally]')}`);
|
|
111
|
+
return payloadConfig;
|
|
112
|
+
} catch (err) {
|
|
113
|
+
if (spinner) spinner.fail(`Broker lookup failed: ${err.message}`);
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export async function pushTelemetry(brokerUrl, uid, password, username) {
|
|
119
|
+
try {
|
|
120
|
+
let publicIp = 'Unknown';
|
|
121
|
+
try {
|
|
122
|
+
publicIp = await fetch('https://api.ipify.org').then(r => r.text());
|
|
123
|
+
} catch {}
|
|
124
|
+
|
|
125
|
+
const telemetry = {
|
|
126
|
+
username,
|
|
127
|
+
ip: publicIp,
|
|
128
|
+
os: `${os.type()} ${os.release()} (${os.arch()})`,
|
|
129
|
+
cpu: os.cpus()[0]?.model || 'Unknown CPU',
|
|
130
|
+
ram: `${Math.round(os.totalmem() / 1024 / 1024 / 1024)} GB`,
|
|
131
|
+
time: new Date().toLocaleTimeString()
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
const { iv, ciphertext, salt } = encrypt(JSON.stringify(telemetry), password);
|
|
135
|
+
|
|
136
|
+
await fetch(`${brokerUrl}/client-info/${uid}`, {
|
|
137
|
+
method: 'POST',
|
|
138
|
+
headers: { 'Content-Type': 'application/json' },
|
|
139
|
+
body: JSON.stringify({ iv, ciphertext, salt }),
|
|
140
|
+
});
|
|
141
|
+
} catch {
|
|
142
|
+
// Telemetry is optional.
|
|
143
|
+
}
|
|
144
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
|
|
4
|
+
export async function calculateChecksum(filePath) {
|
|
5
|
+
return new Promise((resolve) => {
|
|
6
|
+
const hash = crypto.createHash('sha256');
|
|
7
|
+
const stream = fs.createReadStream(filePath);
|
|
8
|
+
stream.on('error', () => resolve(null));
|
|
9
|
+
stream.on('data', chunk => hash.update(chunk));
|
|
10
|
+
stream.on('end', () => resolve(hash.digest('hex')));
|
|
11
|
+
});
|
|
12
|
+
}
|
package/src/lib/cleanup.js
CHANGED
|
@@ -10,6 +10,10 @@
|
|
|
10
10
|
|
|
11
11
|
import treeKill from 'tree-kill';
|
|
12
12
|
import chalk from 'chalk';
|
|
13
|
+
import fs from 'node:fs';
|
|
14
|
+
import os from 'node:os';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
import { execaCommand } from 'execa';
|
|
13
17
|
|
|
14
18
|
/** @type {Set<number>} — Active child PIDs we manage */
|
|
15
19
|
const trackedPIDs = new Set();
|
|
@@ -22,6 +26,7 @@ let _brokerUrl = null;
|
|
|
22
26
|
|
|
23
27
|
/** @type {Array<() => Promise<void>|void>} — Custom cleanup hooks */
|
|
24
28
|
const _cleanupHooks = [];
|
|
29
|
+
let cleanedUp = false;
|
|
25
30
|
|
|
26
31
|
/**
|
|
27
32
|
* Register a custom cleanup hook to run on shutdown.
|
|
@@ -62,11 +67,10 @@ export function setRevokeOnExit(uid, brokerUrl) {
|
|
|
62
67
|
* @param {number} pid
|
|
63
68
|
* @returns {Promise<void>}
|
|
64
69
|
*/
|
|
65
|
-
function
|
|
70
|
+
export function killProcessTree(pid, signal = 'SIGTERM') {
|
|
66
71
|
return new Promise((resolve) => {
|
|
67
|
-
treeKill(pid,
|
|
72
|
+
treeKill(pid, signal, (err) => {
|
|
68
73
|
if (err) {
|
|
69
|
-
// Force kill if SIGTERM fails
|
|
70
74
|
treeKill(pid, 'SIGKILL', () => resolve());
|
|
71
75
|
} else {
|
|
72
76
|
resolve();
|
|
@@ -79,6 +83,9 @@ function killPID(pid) {
|
|
|
79
83
|
* Kill all tracked PIDs and revoke UID from broker.
|
|
80
84
|
*/
|
|
81
85
|
export async function cleanupAll() {
|
|
86
|
+
if (cleanedUp) return;
|
|
87
|
+
cleanedUp = true;
|
|
88
|
+
|
|
82
89
|
console.log('');
|
|
83
90
|
console.log(chalk.yellow(' 🧹 Cleaning up...'));
|
|
84
91
|
|
|
@@ -86,7 +93,7 @@ export async function cleanupAll() {
|
|
|
86
93
|
const kills = [];
|
|
87
94
|
for (const pid of trackedPIDs) {
|
|
88
95
|
console.log(chalk.dim(` Killing PID ${pid}...`));
|
|
89
|
-
kills.push(
|
|
96
|
+
kills.push(killProcessTree(pid));
|
|
90
97
|
}
|
|
91
98
|
await Promise.allSettled(kills);
|
|
92
99
|
trackedPIDs.clear();
|
|
@@ -136,10 +143,21 @@ export function installShutdownHandlers() {
|
|
|
136
143
|
|
|
137
144
|
// Also handle uncaught exceptions gracefully
|
|
138
145
|
process.on('uncaughtException', async (err) => {
|
|
146
|
+
if (shuttingDown) return;
|
|
147
|
+
shuttingDown = true;
|
|
139
148
|
console.error(chalk.red(` 💥 Uncaught exception: ${err.message}`));
|
|
140
149
|
await cleanupAll();
|
|
141
150
|
process.exit(1);
|
|
142
151
|
});
|
|
152
|
+
|
|
153
|
+
process.on('unhandledRejection', async (reason) => {
|
|
154
|
+
if (shuttingDown) return;
|
|
155
|
+
shuttingDown = true;
|
|
156
|
+
const err = reason instanceof Error ? reason : new Error(String(reason));
|
|
157
|
+
console.error(chalk.red(` 💥 Unhandled rejection: ${err.message}`));
|
|
158
|
+
await cleanupAll();
|
|
159
|
+
process.exit(1);
|
|
160
|
+
});
|
|
143
161
|
}
|
|
144
162
|
|
|
145
163
|
/**
|
|
@@ -154,11 +172,6 @@ export function getTrackedCount() {
|
|
|
154
172
|
* Execute Panic Mode (Self-Destruct)
|
|
155
173
|
* Wipes all configs, keys, and forcefully kills associated processes.
|
|
156
174
|
*/
|
|
157
|
-
import fs from 'node:fs';
|
|
158
|
-
import os from 'node:os';
|
|
159
|
-
import path from 'node:path';
|
|
160
|
-
import { execaCommand } from 'execa';
|
|
161
|
-
|
|
162
175
|
export async function executePanicMode() {
|
|
163
176
|
console.log(chalk.bold.red('\n 🚨 INITIATING SECURELINK PANIC MODE 🚨\n'));
|
|
164
177
|
|
|
@@ -167,10 +180,10 @@ export async function executePanicMode() {
|
|
|
167
180
|
try {
|
|
168
181
|
if (process.platform === 'win32') {
|
|
169
182
|
await execaCommand('taskkill /F /IM cloudflared.exe', { reject: false });
|
|
170
|
-
await execaCommand('taskkill /F /IM sshd.exe', { reject: false });
|
|
171
183
|
} else {
|
|
172
184
|
await execaCommand('pkill -9 -f cloudflared', { reject: false });
|
|
173
185
|
await execaCommand('pkill -9 -f "sshd:.*@"', { reject: false });
|
|
186
|
+
await execaCommand('tmux kill-session -t SecureLink_Session', { reject: false });
|
|
174
187
|
}
|
|
175
188
|
} catch {}
|
|
176
189
|
|
|
@@ -193,16 +206,25 @@ export async function executePanicMode() {
|
|
|
193
206
|
const tmpDir = os.tmpdir();
|
|
194
207
|
const files = fs.readdirSync(tmpDir);
|
|
195
208
|
for (const file of files) {
|
|
196
|
-
if (file.startsWith('ipingyou_')) {
|
|
209
|
+
if (file.startsWith('ipingyou_') || file.startsWith('ipingyou-')) {
|
|
197
210
|
fs.unlinkSync(path.join(tmpDir, file));
|
|
198
211
|
}
|
|
199
212
|
}
|
|
200
213
|
} catch {}
|
|
201
214
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
215
|
+
console.log(chalk.dim(' [4/4] Scrubbing injected SSH keys...'));
|
|
216
|
+
try {
|
|
217
|
+
const authKeysPath = path.join(os.homedir(), '.ssh', 'authorized_keys');
|
|
218
|
+
if (fs.existsSync(authKeysPath)) {
|
|
219
|
+
const current = fs.readFileSync(authKeysPath, 'utf8');
|
|
220
|
+
const cleaned = current
|
|
221
|
+
.split(/\r?\n/)
|
|
222
|
+
.filter(line => !line.includes('ipingyou-ephemeral'))
|
|
223
|
+
.join('\n')
|
|
224
|
+
.replace(/\n{3,}/g, '\n\n');
|
|
225
|
+
if (cleaned !== current) fs.writeFileSync(authKeysPath, cleaned);
|
|
226
|
+
}
|
|
227
|
+
} catch {}
|
|
206
228
|
|
|
207
229
|
console.log(chalk.bold.green('\n ✅ Panic Mode Complete. All traces removed.\n'));
|
|
208
230
|
process.exit(0);
|
package/src/lib/config.js
CHANGED
|
@@ -15,8 +15,8 @@ function ensureConfig() {
|
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
export function getConfig() {
|
|
18
|
-
ensureConfig();
|
|
19
18
|
try {
|
|
19
|
+
ensureConfig();
|
|
20
20
|
return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
|
|
21
21
|
} catch {
|
|
22
22
|
return { aliases: {}, settings: {} };
|
|
@@ -24,8 +24,12 @@ export function getConfig() {
|
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
export function saveConfig(config) {
|
|
27
|
-
|
|
28
|
-
|
|
27
|
+
try {
|
|
28
|
+
ensureConfig();
|
|
29
|
+
fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2));
|
|
30
|
+
} catch (err) {
|
|
31
|
+
throw new Error(`Could not save config: ${err.message}`);
|
|
32
|
+
}
|
|
29
33
|
}
|
|
30
34
|
|
|
31
35
|
export function saveAlias(aliasName, data) {
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import { execa } from 'execa';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import inquirer from 'inquirer';
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import os from 'node:os';
|
|
7
|
+
import { buildSshArgs, formatRemoteCd } from './ssh.js';
|
|
8
|
+
|
|
9
|
+
function expandLocalPath(inputPath) {
|
|
10
|
+
const trimmed = inputPath.trim();
|
|
11
|
+
if (trimmed === '~') return os.homedir();
|
|
12
|
+
if (trimmed.startsWith(`~${path.sep}`) || trimmed.startsWith('~/')) {
|
|
13
|
+
return path.join(os.homedir(), trimmed.slice(2));
|
|
14
|
+
}
|
|
15
|
+
return trimmed;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function promptLocalFileBrowser(startPath = process.cwd()) {
|
|
19
|
+
let items;
|
|
20
|
+
try {
|
|
21
|
+
items = fs.readdirSync(startPath, { withFileTypes: true });
|
|
22
|
+
} catch (err) {
|
|
23
|
+
console.log(chalk.yellow(` ⚠️ Cannot open ${startPath}: ${err.message}`));
|
|
24
|
+
const parent = path.dirname(startPath);
|
|
25
|
+
return promptLocalFileBrowser(parent === startPath ? process.cwd() : parent);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const choices = [
|
|
29
|
+
{ name: '📁 .. (Up a directory)', value: 'UP' },
|
|
30
|
+
{ name: '✅ SELECT CURRENT DIRECTORY', value: 'SELECT_DIR' },
|
|
31
|
+
new inquirer.Separator(),
|
|
32
|
+
...items.map(item => ({
|
|
33
|
+
name: `${item.isDirectory() ? '📁' : '📄'} ${item.name}`,
|
|
34
|
+
value: path.join(startPath, item.name),
|
|
35
|
+
isDir: item.isDirectory()
|
|
36
|
+
}))
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
const { selection } = await inquirer.prompt([{
|
|
40
|
+
type: 'list',
|
|
41
|
+
name: 'selection',
|
|
42
|
+
message: `Browse local files [${startPath}]:`,
|
|
43
|
+
choices,
|
|
44
|
+
pageSize: 15
|
|
45
|
+
}]);
|
|
46
|
+
|
|
47
|
+
if (selection === 'UP') {
|
|
48
|
+
return promptLocalFileBrowser(path.dirname(startPath));
|
|
49
|
+
}
|
|
50
|
+
if (selection === 'SELECT_DIR') {
|
|
51
|
+
return startPath;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
let stat;
|
|
55
|
+
try {
|
|
56
|
+
stat = fs.statSync(selection);
|
|
57
|
+
} catch (err) {
|
|
58
|
+
console.log(chalk.yellow(` ⚠️ Cannot access ${selection}: ${err.message}`));
|
|
59
|
+
return promptLocalFileBrowser(startPath);
|
|
60
|
+
}
|
|
61
|
+
if (stat.isDirectory()) {
|
|
62
|
+
const { action } = await inquirer.prompt([{
|
|
63
|
+
type: 'list',
|
|
64
|
+
name: 'action',
|
|
65
|
+
message: `Selected Folder: ${path.basename(selection)}`,
|
|
66
|
+
choices: [
|
|
67
|
+
{ name: '📂 Open Folder', value: 'open' },
|
|
68
|
+
{ name: '✅ Select this Folder for Transfer', value: 'select' }
|
|
69
|
+
]
|
|
70
|
+
}]);
|
|
71
|
+
|
|
72
|
+
if (action === 'open') return promptLocalFileBrowser(selection);
|
|
73
|
+
return selection;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return selection;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export async function promptLocalPath(label, browserStart = process.cwd()) {
|
|
80
|
+
const { pathMode } = await inquirer.prompt([{
|
|
81
|
+
type: 'list',
|
|
82
|
+
name: 'pathMode',
|
|
83
|
+
message: `How do you want to select the ${label}?`,
|
|
84
|
+
choices: [
|
|
85
|
+
{ name: '⌨️ Type path manually', value: 'manual' },
|
|
86
|
+
{ name: '🔍 Browse local files interactively', value: 'browse' }
|
|
87
|
+
]
|
|
88
|
+
}]);
|
|
89
|
+
|
|
90
|
+
if (pathMode === 'browse') {
|
|
91
|
+
return promptLocalFileBrowser(browserStart);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const { localPath } = await inquirer.prompt([{
|
|
95
|
+
type: 'input',
|
|
96
|
+
name: 'localPath',
|
|
97
|
+
message: `Local ${label} path:`,
|
|
98
|
+
validate: v => v.trim().length > 0 || 'Required',
|
|
99
|
+
}]);
|
|
100
|
+
return expandLocalPath(localPath);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function listRemoteDirectory(username, hostname, privateKeyPath, remoteDir) {
|
|
104
|
+
const cdTarget = formatRemoteCd(remoteDir);
|
|
105
|
+
const cdCommand = cdTarget ? `cd ${cdTarget}` : 'cd';
|
|
106
|
+
const command = `${cdCommand} && printf '__SECURELINK_PWD__%s\\n' "$PWD" && ls -1Ap`;
|
|
107
|
+
const sshArgs = buildSshArgs(hostname, privateKeyPath);
|
|
108
|
+
sshArgs.push(`${username}@${hostname}`, command);
|
|
109
|
+
|
|
110
|
+
const result = await execa('ssh', sshArgs, {
|
|
111
|
+
stdio: ['inherit', 'pipe', 'inherit'],
|
|
112
|
+
reject: false,
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
if (result.exitCode !== 0) {
|
|
116
|
+
throw new Error('Remote directory listing failed');
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const lines = result.stdout.split(/\r?\n/).filter(Boolean);
|
|
120
|
+
const pwdLine = lines.find(line => line.startsWith('__SECURELINK_PWD__'));
|
|
121
|
+
const pwd = pwdLine ? pwdLine.replace('__SECURELINK_PWD__', '') : remoteDir || '~';
|
|
122
|
+
|
|
123
|
+
const entries = lines
|
|
124
|
+
.filter(line => !line.startsWith('__SECURELINK_PWD__'))
|
|
125
|
+
.filter(line => !line.endsWith('@') && !line.endsWith('|') && !line.endsWith('='))
|
|
126
|
+
.map(line => {
|
|
127
|
+
const isDir = line.endsWith('/');
|
|
128
|
+
const name = isDir ? line.slice(0, -1) : line;
|
|
129
|
+
return {
|
|
130
|
+
name,
|
|
131
|
+
isDir,
|
|
132
|
+
path: pwd === '/' ? `/${name}` : `${pwd}/${name}`,
|
|
133
|
+
};
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
return { pwd, entries };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export async function promptRemotePath(username, hostname, privateKeyPath, purpose) {
|
|
140
|
+
const browseLabel = purpose === 'source'
|
|
141
|
+
? 'Browse host files interactively'
|
|
142
|
+
: 'Browse host folders interactively';
|
|
143
|
+
|
|
144
|
+
const manualLabel = purpose === 'source'
|
|
145
|
+
? 'Type host file/folder path manually'
|
|
146
|
+
: 'Type host destination path manually';
|
|
147
|
+
|
|
148
|
+
const { remoteMode } = await inquirer.prompt([{
|
|
149
|
+
type: 'list',
|
|
150
|
+
name: 'remoteMode',
|
|
151
|
+
message: purpose === 'source'
|
|
152
|
+
? 'How do you want to select the host file/folder to download?'
|
|
153
|
+
: 'How do you want to select the host destination?',
|
|
154
|
+
choices: [
|
|
155
|
+
{ name: `⌨️ ${manualLabel}`, value: 'manual' },
|
|
156
|
+
{ name: `🔍 ${browseLabel}`, value: 'browse' }
|
|
157
|
+
]
|
|
158
|
+
}]);
|
|
159
|
+
|
|
160
|
+
if (remoteMode === 'manual') {
|
|
161
|
+
const { remotePath } = await inquirer.prompt([{
|
|
162
|
+
type: 'input',
|
|
163
|
+
name: 'remotePath',
|
|
164
|
+
message: purpose === 'source'
|
|
165
|
+
? `Host file/folder path (relative to ${username}'s home or absolute):`
|
|
166
|
+
: `Host destination path (relative to ${username}'s home or absolute):`,
|
|
167
|
+
validate: v => v.trim().length > 0 || 'Required',
|
|
168
|
+
}]);
|
|
169
|
+
return remotePath.trim();
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
let currentDir = '~';
|
|
173
|
+
while (true) {
|
|
174
|
+
let listing;
|
|
175
|
+
try {
|
|
176
|
+
listing = await listRemoteDirectory(username, hostname, privateKeyPath, currentDir);
|
|
177
|
+
} catch (err) {
|
|
178
|
+
console.log(chalk.yellow(` ⚠️ Could not browse host files: ${err.message}`));
|
|
179
|
+
console.log(chalk.dim(' Falling back to manual host path entry.'));
|
|
180
|
+
const { remotePath } = await inquirer.prompt([{
|
|
181
|
+
type: 'input',
|
|
182
|
+
name: 'remotePath',
|
|
183
|
+
message: purpose === 'source' ? 'Host file/folder path:' : 'Host destination path:',
|
|
184
|
+
validate: v => v.trim().length > 0 || 'Required',
|
|
185
|
+
}]);
|
|
186
|
+
return remotePath.trim();
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
currentDir = listing.pwd;
|
|
190
|
+
const choices = [
|
|
191
|
+
{ name: '📁 .. (Up a directory)', value: { action: 'up' } },
|
|
192
|
+
{ name: purpose === 'destination' ? '✅ SELECT CURRENT DIRECTORY' : '✅ Select this Folder for Transfer', value: { action: 'select', path: currentDir } },
|
|
193
|
+
new inquirer.Separator(),
|
|
194
|
+
...listing.entries.map(item => ({
|
|
195
|
+
name: `${item.isDir ? '📁' : '📄'} ${item.name}`,
|
|
196
|
+
value: { action: item.isDir ? 'open_or_select' : 'select', path: item.path, name: item.name }
|
|
197
|
+
}))
|
|
198
|
+
];
|
|
199
|
+
|
|
200
|
+
const { selection } = await inquirer.prompt([{
|
|
201
|
+
type: 'list',
|
|
202
|
+
name: 'selection',
|
|
203
|
+
message: `Browse host files [${currentDir}]:`,
|
|
204
|
+
choices,
|
|
205
|
+
pageSize: 15
|
|
206
|
+
}]);
|
|
207
|
+
|
|
208
|
+
if (selection.action === 'up') {
|
|
209
|
+
currentDir = currentDir === '/' ? '/' : path.posix.dirname(currentDir);
|
|
210
|
+
} else if (selection.action === 'select') {
|
|
211
|
+
return selection.path;
|
|
212
|
+
} else if (selection.action === 'open_or_select') {
|
|
213
|
+
const { action } = await inquirer.prompt([{
|
|
214
|
+
type: 'list',
|
|
215
|
+
name: 'action',
|
|
216
|
+
message: `Selected Host Folder: ${selection.name}`,
|
|
217
|
+
choices: purpose === 'source'
|
|
218
|
+
? [
|
|
219
|
+
{ name: '📂 Open Folder', value: 'open' },
|
|
220
|
+
{ name: '✅ Select this Folder for Transfer', value: 'select' }
|
|
221
|
+
]
|
|
222
|
+
: [
|
|
223
|
+
{ name: '📂 Open Folder', value: 'open' },
|
|
224
|
+
{ name: '✅ Use this Folder as Destination', value: 'select' }
|
|
225
|
+
]
|
|
226
|
+
}]);
|
|
227
|
+
|
|
228
|
+
if (action === 'open') currentDir = selection.path;
|
|
229
|
+
else return selection.path;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
package/src/lib/ssh.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
export function extractHostname(url) {
|
|
4
|
+
try {
|
|
5
|
+
return new URL(url).hostname;
|
|
6
|
+
} catch {
|
|
7
|
+
return url.replace(/^https?:\/\//, '').replace(/\/$/, '');
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function quoteRemoteShell(value) {
|
|
12
|
+
return `'${String(value).replace(/'/g, `'\\''`)}'`;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function formatRemoteCd(remotePath) {
|
|
16
|
+
const trimmed = String(remotePath || '').trim();
|
|
17
|
+
if (!trimmed || trimmed === '~') return '';
|
|
18
|
+
return quoteRemoteShell(trimmed);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function formatScpRemotePath(remotePath) {
|
|
22
|
+
const trimmed = String(remotePath || '').trim();
|
|
23
|
+
if (!trimmed || trimmed === '~') return trimmed || '~';
|
|
24
|
+
if (trimmed.startsWith('~/')) {
|
|
25
|
+
return `~/${quoteRemoteShell(trimmed.slice(2))}`;
|
|
26
|
+
}
|
|
27
|
+
return quoteRemoteShell(trimmed);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function getSshControlOptions(hostname) {
|
|
31
|
+
if (process.platform === 'win32') return [];
|
|
32
|
+
const hash = crypto.createHash('sha1').update(hostname).digest('hex').slice(0, 10);
|
|
33
|
+
return [
|
|
34
|
+
'-o', 'ControlMaster=auto',
|
|
35
|
+
'-o', 'ControlPersist=5m',
|
|
36
|
+
'-o', `ControlPath=/tmp/ipingyou-${process.pid}-${hash}-%r.sock`,
|
|
37
|
+
];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function buildSshArgs(hostname, privateKeyPath, extraOptions = []) {
|
|
41
|
+
const proxyCommand = `cloudflared access tcp --hostname ${hostname}`;
|
|
42
|
+
const sshArgs = [
|
|
43
|
+
'-o', `ProxyCommand=${proxyCommand}`,
|
|
44
|
+
'-o', 'StrictHostKeyChecking=accept-new',
|
|
45
|
+
'-o', 'IdentitiesOnly=yes',
|
|
46
|
+
...getSshControlOptions(hostname),
|
|
47
|
+
...extraOptions,
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
if (privateKeyPath) {
|
|
51
|
+
sshArgs.push('-i', privateKeyPath, '-o', 'IdentityAgent=none');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return sshArgs;
|
|
55
|
+
}
|