@miraj181/ipingyou 2.1.5 → 2.1.9
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 +5 -7
- package/src/cli.js +9 -9
- package/src/lib/broker.js +13 -4
- package/src/lib/chat.js +2 -1
- package/src/lib/cleanup.js +25 -7
- package/src/lib/platform.js +33 -10
- package/src/lib/secure-print.js +80 -0
- package/src/lib/ssh.js +1 -1
- package/src/lib/tmux.js +10 -0
- package/src/modes/ai.js +9 -3
- package/src/modes/client.js +11 -5
- package/src/modes/doctor.js +11 -7
- package/src/modes/host.js +81 -26
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@miraj181/ipingyou",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.9",
|
|
4
4
|
"description": "SecureLink-CLI — Secure peer-to-peer remote access via SSH & Cloudflare Tunnels",
|
|
5
5
|
"main": "src/cli.js",
|
|
6
6
|
"bin": {
|
|
@@ -45,17 +45,15 @@
|
|
|
45
45
|
},
|
|
46
46
|
"dependencies": {
|
|
47
47
|
"chalk": "^5.3.0",
|
|
48
|
-
"commander": "^
|
|
48
|
+
"commander": "^15.0.0",
|
|
49
49
|
"execa": "^9.5.2",
|
|
50
50
|
"inquirer": "^12.11.1",
|
|
51
51
|
"nanoid": "^5.0.9",
|
|
52
52
|
"open": "^11.0.0",
|
|
53
53
|
"ora": "^9.4.0",
|
|
54
|
+
"shell-quote": "^1.8.4",
|
|
54
55
|
"tree-kill": "^1.2.2",
|
|
55
|
-
"ws": "^8.20.1"
|
|
56
|
-
"express": "^5.2.1",
|
|
57
|
-
"express-rate-limit": "^8.5.2",
|
|
58
|
-
"helmet": "^8.2.0"
|
|
56
|
+
"ws": "^8.20.1"
|
|
59
57
|
},
|
|
60
58
|
"devDependencies": {
|
|
61
59
|
"nodemon": "^3.1.4"
|
|
@@ -64,4 +62,4 @@
|
|
|
64
62
|
"node": ">=18.0.0"
|
|
65
63
|
},
|
|
66
64
|
"type": "module"
|
|
67
|
-
}
|
|
65
|
+
}
|
package/src/cli.js
CHANGED
|
@@ -348,22 +348,22 @@ program
|
|
|
348
348
|
console.log(chalk.bold.cyan(' 👻 Background Service Manager'));
|
|
349
349
|
console.log(chalk.dim(' ──────────────────────────────────────'));
|
|
350
350
|
|
|
351
|
-
const {
|
|
351
|
+
const { execa } = await import('execa');
|
|
352
352
|
|
|
353
353
|
if (action === 'install') {
|
|
354
354
|
console.log(chalk.dim(' Installing PM2 globally and starting host...'));
|
|
355
|
-
await
|
|
356
|
-
await
|
|
357
|
-
await
|
|
358
|
-
await
|
|
355
|
+
await execa('npm', ['install', '-g', 'pm2'], { stdio: 'inherit' });
|
|
356
|
+
await execa('pm2', ['start', 'ipingyou', '--name', 'ipingyou-host', '--', 'host'], { stdio: 'inherit' });
|
|
357
|
+
await execa('pm2', ['save'], { stdio: 'inherit' });
|
|
358
|
+
await execa('pm2', ['startup'], { stdio: 'inherit' });
|
|
359
359
|
console.log(chalk.green('\n ✅ Service installed and running in the background.'));
|
|
360
360
|
} else if (action === 'stop') {
|
|
361
|
-
await
|
|
362
|
-
await
|
|
363
|
-
await
|
|
361
|
+
await execa('pm2', ['stop', 'ipingyou-host'], { stdio: 'inherit' });
|
|
362
|
+
await execa('pm2', ['delete', 'ipingyou-host'], { stdio: 'inherit' });
|
|
363
|
+
await execa('pm2', ['save'], { stdio: 'inherit' });
|
|
364
364
|
console.log(chalk.green('\n ✅ Service stopped and removed.'));
|
|
365
365
|
} else if (action === 'status') {
|
|
366
|
-
await
|
|
366
|
+
await execa('pm2', ['status', 'ipingyou-host'], { stdio: 'inherit' });
|
|
367
367
|
} else {
|
|
368
368
|
console.log(chalk.red(` ❌ Unknown action: ${action}. Use install, stop, or status.`));
|
|
369
369
|
}
|
package/src/lib/broker.js
CHANGED
|
@@ -244,7 +244,15 @@ export async function resolveUID(brokerUrl, uid, password, silent = false, reque
|
|
|
244
244
|
}
|
|
245
245
|
|
|
246
246
|
export async function pushTelemetry(brokerUrl, uid, password, username, action = 'connected') {
|
|
247
|
+
// Telemetry can be disabled via env var for privacy or testing
|
|
248
|
+
if (process.env.IPINGYOU_DISABLE_TELEMETRY === '1' || process.env.NODE_ENV === 'test') return;
|
|
249
|
+
|
|
247
250
|
try {
|
|
251
|
+
// Validate brokerUrl: only send telemetry to HTTPS brokers
|
|
252
|
+
let parsed;
|
|
253
|
+
try { parsed = new URL(brokerUrl); } catch { parsed = null; }
|
|
254
|
+
if (!parsed || parsed.protocol !== 'https:') return;
|
|
255
|
+
|
|
248
256
|
let publicIp = 'Unknown';
|
|
249
257
|
try {
|
|
250
258
|
publicIp = await fetch('https://api.ipify.org').then(r => r.text());
|
|
@@ -254,15 +262,16 @@ export async function pushTelemetry(brokerUrl, uid, password, username, action =
|
|
|
254
262
|
username,
|
|
255
263
|
ip: publicIp,
|
|
256
264
|
os: `${os.type()} ${os.release()} (${os.arch()})`,
|
|
257
|
-
|
|
258
|
-
|
|
265
|
+
// Limit fingerprint detail to reduce privacy surface
|
|
266
|
+
cpu: os.cpus()[0]?.model ? os.cpus()[0].model.replace(/\s{2,}/g, ' ').trim() : 'Unknown',
|
|
267
|
+
ramGB: Math.round(os.totalmem() / 1024 / 1024 / 1024),
|
|
259
268
|
action,
|
|
260
|
-
time: new Date().
|
|
269
|
+
time: new Date().toISOString()
|
|
261
270
|
};
|
|
262
271
|
|
|
263
272
|
const { iv, ciphertext, salt } = encrypt(JSON.stringify(telemetry), password);
|
|
264
273
|
|
|
265
|
-
await fetchWithLog('telemetry', `${
|
|
274
|
+
await fetchWithLog('telemetry', `${parsed.origin}/client-info/${encodeURIComponent(uid)}`, {
|
|
266
275
|
method: 'POST',
|
|
267
276
|
headers: { 'Content-Type': 'application/json' },
|
|
268
277
|
body: JSON.stringify({ iv, ciphertext, salt }),
|
package/src/lib/chat.js
CHANGED
|
@@ -2,6 +2,7 @@ import http from 'node:http';
|
|
|
2
2
|
import { WebSocketServer } from 'ws';
|
|
3
3
|
import open from 'open';
|
|
4
4
|
import chalk from 'chalk';
|
|
5
|
+
import { secureSensitiveUrl } from './secure-print.js';
|
|
5
6
|
|
|
6
7
|
const HTML_CONTENT = `
|
|
7
8
|
<!DOCTYPE html>
|
|
@@ -322,6 +323,6 @@ export async function openLocalChatUI(port, password) {
|
|
|
322
323
|
const chatUrl = `http://localhost:${port}#${password}`;
|
|
323
324
|
await open(chatUrl);
|
|
324
325
|
} catch {
|
|
325
|
-
console.log(chalk.dim(` Unable to auto-open browser. Visit http://localhost:${port}
|
|
326
|
+
console.log(chalk.dim(` Unable to auto-open browser. Visit ${secureSensitiveUrl(`http://localhost:${port}`, password)}`));
|
|
326
327
|
}
|
|
327
328
|
}
|
package/src/lib/cleanup.js
CHANGED
|
@@ -13,8 +13,8 @@ import chalk from 'chalk';
|
|
|
13
13
|
import fs from 'node:fs';
|
|
14
14
|
import os from 'node:os';
|
|
15
15
|
import path from 'node:path';
|
|
16
|
-
import {
|
|
17
|
-
import { TMUX_SESSION_NAME, tmuxSocketArgs } from './tmux.js';
|
|
16
|
+
import { execa } from 'execa';
|
|
17
|
+
import { TMUX_SESSION_NAME, TMUX_SESSION_PREFIX, tmuxSocketArgs } from './tmux.js';
|
|
18
18
|
|
|
19
19
|
/** @type {Set<number>} — Active child PIDs we manage */
|
|
20
20
|
const trackedPIDs = new Set();
|
|
@@ -188,13 +188,31 @@ export async function executePanicMode() {
|
|
|
188
188
|
console.log(chalk.dim(' [1/4] Terminating all tunnel and host processes...'));
|
|
189
189
|
try {
|
|
190
190
|
if (process.platform === 'win32') {
|
|
191
|
-
|
|
191
|
+
// taskkill expects a command string on Windows; pass args safely
|
|
192
|
+
await execa('taskkill', ['/F', '/IM', 'cloudflared.exe'], { reject: false });
|
|
192
193
|
} else {
|
|
193
|
-
|
|
194
|
-
await
|
|
195
|
-
await
|
|
194
|
+
// Use argument arrays to avoid shell interpolation
|
|
195
|
+
await execa('pkill', ['-9', '-f', 'cloudflared'], { reject: false });
|
|
196
|
+
await execa('pkill', ['-9', '-f', 'sshd:.*@'], { reject: false });
|
|
197
|
+
|
|
198
|
+
const socketArgs = Array.isArray(tmuxSocketArgs) ? tmuxSocketArgs() : [];
|
|
199
|
+
// Ensure socketArgs are strings and safe-ish before passing to execa
|
|
200
|
+
const safeSocketArgs = (socketArgs || []).map(a => String(a));
|
|
201
|
+
await execa('tmux', [...safeSocketArgs, 'kill-server'], { reject: false });
|
|
202
|
+
|
|
203
|
+
const { stdout } = await execa('tmux', ['list-sessions', '-F', '#{session_name}'], { reject: false });
|
|
204
|
+
const legacyNames = stdout
|
|
205
|
+
.split(/\r?\n/)
|
|
206
|
+
.filter(Boolean)
|
|
207
|
+
.filter(name => name === TMUX_SESSION_NAME || name.startsWith(TMUX_SESSION_PREFIX));
|
|
208
|
+
for (const name of legacyNames) {
|
|
209
|
+
await execa('tmux', ['kill-session', '-t', name], { reject: false });
|
|
210
|
+
}
|
|
196
211
|
}
|
|
197
|
-
} catch {
|
|
212
|
+
} catch (err) {
|
|
213
|
+
// Best-effort cleanup; log debug info without exposing stack in normal flow
|
|
214
|
+
// (keep behavior unchanged otherwise)
|
|
215
|
+
}
|
|
198
216
|
|
|
199
217
|
// 2. Delete configuration and aliases
|
|
200
218
|
console.log(chalk.dim(' [2/4] Wiping configuration files...'));
|
package/src/lib/platform.js
CHANGED
|
@@ -8,10 +8,11 @@
|
|
|
8
8
|
* ============================================================
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import {
|
|
11
|
+
import { execa } from 'execa';
|
|
12
12
|
import chalk from 'chalk';
|
|
13
13
|
import ora from 'ora';
|
|
14
14
|
import os from 'node:os';
|
|
15
|
+
import fs from 'node:fs';
|
|
15
16
|
import { createWriteStream } from 'node:fs';
|
|
16
17
|
import { pipeline } from 'node:stream/promises';
|
|
17
18
|
import { chmod, mkdir, stat, rename } from 'node:fs/promises';
|
|
@@ -42,8 +43,9 @@ export function detectOS() {
|
|
|
42
43
|
*/
|
|
43
44
|
export async function detectLinuxDistro() {
|
|
44
45
|
try {
|
|
45
|
-
|
|
46
|
-
const
|
|
46
|
+
// Read the os-release file directly instead of spawning a process
|
|
47
|
+
const data = await fs.promises.readFile('/etc/os-release', 'utf8');
|
|
48
|
+
const lower = data.toLowerCase();
|
|
47
49
|
if (lower.includes('ubuntu') || lower.includes('debian') || lower.includes('kali') || lower.includes('mint')) {
|
|
48
50
|
return 'debian';
|
|
49
51
|
}
|
|
@@ -66,8 +68,12 @@ export async function detectLinuxDistro() {
|
|
|
66
68
|
*/
|
|
67
69
|
export async function commandExists(cmd) {
|
|
68
70
|
try {
|
|
69
|
-
|
|
70
|
-
|
|
71
|
+
// Prefer direct exec of the check command to avoid shell parsing
|
|
72
|
+
if (process.platform === 'win32') {
|
|
73
|
+
await execa('where', [cmd], { reject: true });
|
|
74
|
+
} else {
|
|
75
|
+
await execa('which', [cmd], { reject: true });
|
|
76
|
+
}
|
|
71
77
|
return true;
|
|
72
78
|
} catch {
|
|
73
79
|
return false;
|
|
@@ -93,7 +99,20 @@ export async function hasSudo() {
|
|
|
93
99
|
async function runWithSpinner(label, cmd, opts = {}) {
|
|
94
100
|
const spinner = ora(label).start();
|
|
95
101
|
try {
|
|
96
|
-
|
|
102
|
+
// Support both string shell commands and argument arrays.
|
|
103
|
+
if (Array.isArray(cmd)) {
|
|
104
|
+
const [c, ...args] = cmd;
|
|
105
|
+
await execa(c, args, { stdio: 'pipe', reject: true, ...opts });
|
|
106
|
+
} else if (typeof cmd === 'string') {
|
|
107
|
+
if (process.platform === 'win32') {
|
|
108
|
+
await execa('cmd', ['/c', cmd], { stdio: 'pipe', reject: true, ...opts });
|
|
109
|
+
} else {
|
|
110
|
+
await execa('sh', ['-c', cmd], { stdio: 'pipe', reject: true, ...opts });
|
|
111
|
+
}
|
|
112
|
+
} else {
|
|
113
|
+
throw new Error('Unsupported command type');
|
|
114
|
+
}
|
|
115
|
+
|
|
97
116
|
spinner.succeed(label.replace('...', '') + chalk.green(' ✓'));
|
|
98
117
|
return true;
|
|
99
118
|
} catch (err) {
|
|
@@ -198,14 +217,18 @@ async function ensureDownloader() {
|
|
|
198
217
|
*/
|
|
199
218
|
async function downloadFile(url, destPath, downloader) {
|
|
200
219
|
const cmds = {
|
|
201
|
-
curl:
|
|
202
|
-
wget:
|
|
203
|
-
powershell:
|
|
220
|
+
curl: ['curl', ['-fsSL', '-o', destPath, url]],
|
|
221
|
+
wget: ['wget', ['-q', '-O', destPath, url]],
|
|
222
|
+
powershell: ['powershell', ["-Command", `Invoke-WebRequest -Uri '${url}' -OutFile '${destPath}'`]],
|
|
204
223
|
};
|
|
205
224
|
|
|
225
|
+
const entry = cmds[downloader];
|
|
226
|
+
if (!entry) return false;
|
|
227
|
+
const [bin, args] = entry;
|
|
228
|
+
|
|
206
229
|
return runWithSpinner(
|
|
207
230
|
` Downloading ${chalk.cyan(url.split('/').pop())}...`,
|
|
208
|
-
|
|
231
|
+
[bin, ...args]
|
|
209
232
|
);
|
|
210
233
|
}
|
|
211
234
|
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ============================================================
|
|
3
|
+
* Secure Print — Log-safe sensitive value display
|
|
4
|
+
* ============================================================
|
|
5
|
+
* Passwords and secrets are shown in plaintext ONLY when:
|
|
6
|
+
* 1. stdout is an interactive TTY (not piped/redirected)
|
|
7
|
+
* 2. The OS user running the process matches the session owner
|
|
8
|
+
*
|
|
9
|
+
* In all other contexts (CI, PM2, piped output, log files),
|
|
10
|
+
* values are replaced with a SHA-256 hash prefix so they can
|
|
11
|
+
* be correlated without exposing the secret.
|
|
12
|
+
* ============================================================
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import crypto from 'node:crypto';
|
|
16
|
+
import os from 'node:os';
|
|
17
|
+
|
|
18
|
+
// Keyed secret for deterministic, non-reversible log masking tokens.
|
|
19
|
+
// Can be overridden for stable cross-process correlation if needed.
|
|
20
|
+
const MASKING_KEY = process.env.SECURE_PRINT_MASK_KEY || crypto.randomBytes(32).toString('hex');
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Verify the current process is being run by the same OS user interactively.
|
|
24
|
+
* Returns true if the caller is the legitimate host user on an interactive terminal.
|
|
25
|
+
*/
|
|
26
|
+
function isVerifiedHostUser() {
|
|
27
|
+
// Must be a real interactive TTY (not piped/redirected)
|
|
28
|
+
if (!process.stdout.isTTY) return false;
|
|
29
|
+
|
|
30
|
+
// On Unix, verify process UID matches the logged-in user
|
|
31
|
+
if (typeof process.getuid === 'function') {
|
|
32
|
+
try {
|
|
33
|
+
const processUid = process.getuid();
|
|
34
|
+
const userUid = os.userInfo().uid;
|
|
35
|
+
if (processUid !== userUid) return false;
|
|
36
|
+
} catch {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Mask a sensitive value for log-safe output.
|
|
46
|
+
* Returns a PBKDF2-derived hash prefix so the value can be correlated without revealing it.
|
|
47
|
+
*/
|
|
48
|
+
function maskSensitive(value) {
|
|
49
|
+
const normalized = String(value);
|
|
50
|
+
const hash = crypto
|
|
51
|
+
.pbkdf2Sync(MASKING_KEY, normalized, 210000, 32, 'sha256')
|
|
52
|
+
.toString('hex');
|
|
53
|
+
return `[pbkdf2:${hash.slice(0, 12)}…]`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Render a sensitive value for display.
|
|
58
|
+
* - Interactive TTY + verified host user → show plaintext
|
|
59
|
+
* - Otherwise → show masked hash
|
|
60
|
+
*
|
|
61
|
+
* @param {string} value — the sensitive value (e.g. password)
|
|
62
|
+
* @returns {string}
|
|
63
|
+
*/
|
|
64
|
+
export function secureSensitive(value) {
|
|
65
|
+
if (isVerifiedHostUser()) return String(value);
|
|
66
|
+
return maskSensitive(value);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Build a log-safe URL by masking the password fragment.
|
|
71
|
+
* e.g. http://localhost:3000#mypass → http://localhost:3000#[sha256:a1b2...]
|
|
72
|
+
*
|
|
73
|
+
* @param {string} baseUrl — URL without the fragment
|
|
74
|
+
* @param {string} password — the secret to place after #
|
|
75
|
+
* @returns {string}
|
|
76
|
+
*/
|
|
77
|
+
export function secureSensitiveUrl(baseUrl, password) {
|
|
78
|
+
if (isVerifiedHostUser()) return `${baseUrl}#${password}`;
|
|
79
|
+
return `${baseUrl}#${maskSensitive(password)}`;
|
|
80
|
+
}
|
package/src/lib/ssh.js
CHANGED
|
@@ -26,7 +26,7 @@ export function formatScpRemotePath(remotePath) {
|
|
|
26
26
|
|
|
27
27
|
export function getSshControlOptions(hostname) {
|
|
28
28
|
if (process.platform === 'win32') return [];
|
|
29
|
-
const hash = crypto.createHash('
|
|
29
|
+
const hash = crypto.createHash('sha256').update(hostname).digest('hex').slice(0, 10);
|
|
30
30
|
return [
|
|
31
31
|
'-o', 'ControlMaster=auto',
|
|
32
32
|
'-o', 'ControlPersist=5m',
|
package/src/lib/tmux.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import os from 'node:os';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
|
|
4
|
+
export const TMUX_SESSION_PREFIX = 'SecureLink_';
|
|
4
5
|
export const TMUX_SESSION_NAME = 'SecureLink_Session';
|
|
5
6
|
export const TMUX_SOCKET_PATH = path.join(os.tmpdir(), 'ipingyou-tmux.sock');
|
|
6
7
|
|
|
@@ -11,3 +12,12 @@ export function tmuxSocketArgs() {
|
|
|
11
12
|
export function tmuxSocketCommand() {
|
|
12
13
|
return `tmux -S ${TMUX_SOCKET_PATH}`;
|
|
13
14
|
}
|
|
15
|
+
|
|
16
|
+
export function buildTmuxSessionName(label) {
|
|
17
|
+
const safeLabel = String(label || 'client')
|
|
18
|
+
.replace(/[^a-zA-Z0-9_-]/g, '')
|
|
19
|
+
.slice(0, 24) || 'client';
|
|
20
|
+
const stamp = Date.now().toString(36);
|
|
21
|
+
const rand = Math.random().toString(36).slice(2, 6);
|
|
22
|
+
return `${TMUX_SESSION_PREFIX}${safeLabel}_${stamp}${rand}`;
|
|
23
|
+
}
|
package/src/modes/ai.js
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
* AI Mode — privacy-first local/remote task assistant powered by Groq.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import { execa
|
|
5
|
+
import { execa } from 'execa';
|
|
6
|
+
import { parse as shellParse } from 'shell-quote';
|
|
6
7
|
import chalk from 'chalk';
|
|
7
8
|
import inquirer from 'inquirer';
|
|
8
9
|
import fs from 'node:fs';
|
|
@@ -210,8 +211,13 @@ function showRateLimitWarnings(rateLimit) {
|
|
|
210
211
|
}
|
|
211
212
|
|
|
212
213
|
async function runLocalCommand(command) {
|
|
213
|
-
const
|
|
214
|
-
|
|
214
|
+
const parsed = shellParse(command);
|
|
215
|
+
// Filter out non-string tokens (shell operators like |, &&, ; etc.) to prevent injection
|
|
216
|
+
const args = parsed.filter(token => typeof token === 'string');
|
|
217
|
+
if (args.length === 0) {
|
|
218
|
+
return { exitCode: 1, stdout: '', stderr: 'Empty or unsafe command after parsing' };
|
|
219
|
+
}
|
|
220
|
+
const result = await execa(args[0], args.slice(1), {
|
|
215
221
|
reject: false,
|
|
216
222
|
timeout: 30000,
|
|
217
223
|
maxBuffer: 1024 * 1024,
|
package/src/modes/client.js
CHANGED
|
@@ -25,8 +25,9 @@ import { pushTelemetry, requestHostApproval, resolveUID, revokeUID, waitForAppro
|
|
|
25
25
|
import { calculateChecksum } from '../lib/checksum.js';
|
|
26
26
|
import { promptLocalPath, promptRemotePath } from '../lib/path-browser.js';
|
|
27
27
|
import { buildSshArgs, extractHostname, formatScpRemotePath, getKnownHostsOptions, getSshControlOptions, quoteRemoteShell } from '../lib/ssh.js';
|
|
28
|
-
import {
|
|
28
|
+
import { buildTmuxSessionName, TMUX_SOCKET_PATH } from '../lib/tmux.js';
|
|
29
29
|
import open from 'open';
|
|
30
|
+
import { secureSensitiveUrl } from '../lib/secure-print.js';
|
|
30
31
|
import { cleanupSessionLog, initSessionLog, logSessionEvent, recordEvent } from '../lib/session-log.js';
|
|
31
32
|
|
|
32
33
|
let BROKER_URL = process.env.BROKER_URL || 'https://ipingyou.onrender.com';
|
|
@@ -88,9 +89,14 @@ async function connectSSH(username, hostname, privateKeyPath, persistKnownHosts
|
|
|
88
89
|
], { persistKnownHosts });
|
|
89
90
|
|
|
90
91
|
sshArgs.push(`${username}@${hostname}`);
|
|
91
|
-
const
|
|
92
|
-
const
|
|
93
|
-
|
|
92
|
+
const tmuxSession = buildTmuxSessionName(username);
|
|
93
|
+
const quotedSocket = quoteRemoteShell(TMUX_SOCKET_PATH);
|
|
94
|
+
const quotedSession = quoteRemoteShell(tmuxSession);
|
|
95
|
+
const tmuxSocketCmdStr = `tmux -S ${quotedSocket}`;
|
|
96
|
+
const tmuxPrepare = `${tmuxSocketCmdStr} has-session -t ${quotedSession} 2>/dev/null || ${tmuxSocketCmdStr} new-session -d -s ${quotedSession}`;
|
|
97
|
+
const tmuxAttach = `${tmuxSocketCmdStr} attach -t ${quotedSession}`;
|
|
98
|
+
const tmuxCommand = `if command -v tmux >/dev/null 2>&1; then (${tmuxPrepare} && ${tmuxAttach}) || exec $SHELL -l; else exec $SHELL -l; fi`;
|
|
99
|
+
sshArgs.push('-t', tmuxCommand);
|
|
94
100
|
|
|
95
101
|
const child = execa('ssh', sshArgs, {
|
|
96
102
|
stdio: 'inherit',
|
|
@@ -700,7 +706,7 @@ async function handleClientChat(uid, password, cachedChatUrl) {
|
|
|
700
706
|
const fullUrl = `${chatUrl}#${password}`;
|
|
701
707
|
await open(fullUrl);
|
|
702
708
|
} catch {
|
|
703
|
-
console.log(chalk.cyan(` 👉 Please open: ${chatUrl
|
|
709
|
+
console.log(chalk.cyan(` 👉 Please open: ${secureSensitiveUrl(chatUrl, password)}`));
|
|
704
710
|
}
|
|
705
711
|
} else {
|
|
706
712
|
spinner.warn('The host has not started a chat room yet.');
|
package/src/modes/doctor.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Doctor Mode — non-invasive diagnostics for iPingYou.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import { execa
|
|
5
|
+
import { execa } from 'execa';
|
|
6
6
|
import chalk from 'chalk';
|
|
7
7
|
import fs from 'node:fs';
|
|
8
8
|
import os from 'node:os';
|
|
@@ -66,7 +66,7 @@ async function commandFound(command) {
|
|
|
66
66
|
async function checkSshService() {
|
|
67
67
|
const osInfo = detectOS();
|
|
68
68
|
if (osInfo.isMac) {
|
|
69
|
-
const launchctl = await
|
|
69
|
+
const launchctl = await execa('launchctl', ['print-disabled', 'system'], { reject: false, timeout: 5000 });
|
|
70
70
|
const launchctlOutput = `${launchctl.stdout || ''}\n${launchctl.stderr || ''}`.toLowerCase();
|
|
71
71
|
const launchctlMatch = launchctlOutput.match(/"com\.openssh\.sshd"\s*=>\s*(enabled|disabled)/);
|
|
72
72
|
if (launchctlMatch) {
|
|
@@ -78,7 +78,7 @@ async function checkSshService() {
|
|
|
78
78
|
};
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
-
const result = await
|
|
81
|
+
const result = await execa('systemsetup', ['-getremotelogin'], { reject: false, timeout: 5000 });
|
|
82
82
|
const output = result.stdout || result.stderr || '';
|
|
83
83
|
if (/on/i.test(output)) return { status: 'pass', detail: 'Remote Login is on' };
|
|
84
84
|
if (/off/i.test(output)) {
|
|
@@ -96,8 +96,8 @@ async function checkSshService() {
|
|
|
96
96
|
}
|
|
97
97
|
|
|
98
98
|
if (osInfo.isLinux) {
|
|
99
|
-
const ssh = await
|
|
100
|
-
const sshd = ssh.exitCode === 0 ? ssh : await
|
|
99
|
+
const ssh = await execa('systemctl', ['is-active', 'ssh'], { reject: false, timeout: 5000 });
|
|
100
|
+
const sshd = ssh.exitCode === 0 ? ssh : await execa('systemctl', ['is-active', 'sshd'], { reject: false, timeout: 5000 });
|
|
101
101
|
if (sshd.exitCode === 0) return { status: 'pass', detail: 'SSH service is active' };
|
|
102
102
|
return {
|
|
103
103
|
status: 'warn',
|
|
@@ -107,7 +107,7 @@ async function checkSshService() {
|
|
|
107
107
|
}
|
|
108
108
|
|
|
109
109
|
if (osInfo.isWindows) {
|
|
110
|
-
const result = await
|
|
110
|
+
const result = await execa('sc', ['query', 'sshd'], { reject: false, timeout: 5000 });
|
|
111
111
|
if (/RUNNING/i.test(result.stdout)) return { status: 'pass', detail: 'OpenSSH Server is running' };
|
|
112
112
|
return {
|
|
113
113
|
status: 'warn',
|
|
@@ -196,7 +196,11 @@ function checkAiSafety() {
|
|
|
196
196
|
}
|
|
197
197
|
|
|
198
198
|
async function runProjectSelfTest(label, command) {
|
|
199
|
-
|
|
199
|
+
// Prefer splitting simple commands into args to avoid shell interpolation
|
|
200
|
+
const parts = String(command).split(' ').filter(Boolean);
|
|
201
|
+
const cmd = parts.shift();
|
|
202
|
+
const args = parts;
|
|
203
|
+
const result = await execa(cmd, args, {
|
|
200
204
|
reject: false,
|
|
201
205
|
timeout: 30000,
|
|
202
206
|
maxBuffer: 1024 * 1024,
|
package/src/modes/host.js
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* ============================================================
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
-
import { execa
|
|
16
|
+
import { execa } from 'execa';
|
|
17
17
|
import chalk from 'chalk';
|
|
18
18
|
import inquirer from 'inquirer';
|
|
19
19
|
import path from 'node:path';
|
|
@@ -26,10 +26,11 @@ import { cleanupAll, killProcessTree, trackPID, untrackPID, setRevokeOnExit, add
|
|
|
26
26
|
import { detectOS } from '../lib/platform.js';
|
|
27
27
|
import { createSpinner, networkSpinner, typeText } from '../lib/animations.js';
|
|
28
28
|
import { startChatServer, openLocalChatUI } from '../lib/chat.js';
|
|
29
|
+
import { secureSensitive } from '../lib/secure-print.js';
|
|
29
30
|
import { spawnTunnelSupervised } from '../lib/tunnel.js';
|
|
30
31
|
import { decideApprovalRequest, fetchApprovalRequests, pingBroker, registerWithBroker, revokeUID } from '../lib/broker.js';
|
|
31
32
|
import { cleanupSessionLog, getSessionLogPath, initSessionLog, logSessionEvent, recordEvent } from '../lib/session-log.js';
|
|
32
|
-
import { TMUX_SESSION_NAME, tmuxSocketArgs } from '../lib/tmux.js';
|
|
33
|
+
import { TMUX_SESSION_NAME, TMUX_SESSION_PREFIX, tmuxSocketArgs } from '../lib/tmux.js';
|
|
33
34
|
|
|
34
35
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
35
36
|
let BROKER_URL = process.env.BROKER_URL || 'https://ipingyou.onrender.com';
|
|
@@ -55,24 +56,24 @@ async function ensureSSHRunning() {
|
|
|
55
56
|
try {
|
|
56
57
|
if (osInfo.isLinux) {
|
|
57
58
|
try {
|
|
58
|
-
await
|
|
59
|
+
await execa('systemctl', ['is-active', 'ssh'], { reject: true });
|
|
59
60
|
spinner.succeed('SSH service is active');
|
|
60
61
|
} catch {
|
|
61
62
|
spinner.text = 'Starting SSH service...';
|
|
62
63
|
try {
|
|
63
|
-
await
|
|
64
|
+
await execa('sudo', ['systemctl', 'start', 'ssh'], { stdio: 'inherit' });
|
|
64
65
|
spinner.succeed('SSH service started');
|
|
65
66
|
} catch {
|
|
66
|
-
await
|
|
67
|
+
await execa('sudo', ['systemctl', 'start', 'sshd'], { stdio: 'inherit' });
|
|
67
68
|
spinner.succeed('SSH service started (sshd)');
|
|
68
69
|
}
|
|
69
70
|
}
|
|
70
71
|
} else if (osInfo.isMac) {
|
|
71
72
|
try {
|
|
72
|
-
const { stdout } = await
|
|
73
|
+
const { stdout } = await execa('sudo', ['systemsetup', '-getremotelogin'], { reject: false });
|
|
73
74
|
if (stdout.toLowerCase().includes('off')) {
|
|
74
75
|
spinner.text = 'Enabling Remote Login...';
|
|
75
|
-
await
|
|
76
|
+
await execa('sudo', ['systemsetup', '-setremotelogin', 'on'], { stdio: 'inherit' });
|
|
76
77
|
spinner.succeed('Remote Login enabled');
|
|
77
78
|
} else {
|
|
78
79
|
spinner.succeed('SSH (Remote Login) is active');
|
|
@@ -82,10 +83,10 @@ async function ensureSSHRunning() {
|
|
|
82
83
|
}
|
|
83
84
|
} else if (osInfo.isWindows) {
|
|
84
85
|
try {
|
|
85
|
-
const { stdout } = await
|
|
86
|
+
const { stdout } = await execa('sc', ['query', 'sshd'], { reject: false });
|
|
86
87
|
if (stdout.includes('STOPPED')) {
|
|
87
88
|
spinner.text = 'Starting OpenSSH Server...';
|
|
88
|
-
await
|
|
89
|
+
await execa('net', ['start', 'sshd'], { stdio: 'inherit' });
|
|
89
90
|
spinner.succeed('OpenSSH Server started');
|
|
90
91
|
} else if (stdout.includes('RUNNING')) {
|
|
91
92
|
spinner.succeed('OpenSSH Server is running');
|
|
@@ -112,28 +113,29 @@ async function ensureTmuxInstalled() {
|
|
|
112
113
|
const spinner = createSpinner('Checking tmux installation...', networkSpinner).start();
|
|
113
114
|
try {
|
|
114
115
|
try {
|
|
115
|
-
await
|
|
116
|
+
await execa('tmux', ['-V'], { reject: true });
|
|
116
117
|
spinner.succeed('tmux is installed (Terminal Mirroring available)');
|
|
117
118
|
} catch {
|
|
118
119
|
spinner.text = 'tmux not found. Attempting to install...';
|
|
119
120
|
if (osInfo.isLinux) {
|
|
120
121
|
if (fs.existsSync('/usr/bin/apt') || fs.existsSync('/usr/bin/apt-get')) {
|
|
121
|
-
await
|
|
122
|
+
await execa('sudo', ['apt-get', 'update', '-qq'], { stdio: 'inherit' });
|
|
123
|
+
await execa('sudo', ['apt-get', 'install', '-y', 'tmux'], { stdio: 'inherit' });
|
|
122
124
|
} else if (fs.existsSync('/usr/bin/dnf')) {
|
|
123
|
-
await
|
|
125
|
+
await execa('sudo', ['dnf', 'install', '-y', 'tmux'], { stdio: 'inherit' });
|
|
124
126
|
} else if (fs.existsSync('/usr/bin/yum')) {
|
|
125
|
-
await
|
|
127
|
+
await execa('sudo', ['yum', 'install', '-y', 'tmux'], { stdio: 'inherit' });
|
|
126
128
|
} else if (fs.existsSync('/usr/bin/pacman')) {
|
|
127
|
-
await
|
|
129
|
+
await execa('sudo', ['pacman', '-S', '--noconfirm', 'tmux'], { stdio: 'inherit' });
|
|
128
130
|
} else if (fs.existsSync('/sbin/apk')) {
|
|
129
|
-
await
|
|
131
|
+
await execa('sudo', ['apk', 'add', 'tmux'], { stdio: 'inherit' });
|
|
130
132
|
} else {
|
|
131
133
|
throw new Error('Unsupported Linux package manager');
|
|
132
134
|
}
|
|
133
135
|
spinner.succeed('tmux installed successfully (Terminal Mirroring available)');
|
|
134
136
|
} else if (osInfo.isMac) {
|
|
135
137
|
try {
|
|
136
|
-
await
|
|
138
|
+
await execa('brew', ['install', 'tmux'], { stdio: 'inherit' });
|
|
137
139
|
spinner.succeed('tmux installed successfully (Terminal Mirroring available)');
|
|
138
140
|
} catch {
|
|
139
141
|
throw new Error('Homebrew is required to install tmux on macOS');
|
|
@@ -146,6 +148,37 @@ async function ensureTmuxInstalled() {
|
|
|
146
148
|
}
|
|
147
149
|
}
|
|
148
150
|
|
|
151
|
+
function isSecureLinkSession(name) {
|
|
152
|
+
return name === TMUX_SESSION_NAME || name.startsWith(TMUX_SESSION_PREFIX);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function listTmuxSessions(socketArgs = []) {
|
|
156
|
+
const result = await execa('tmux', [...socketArgs, 'list-sessions', '-F', '#{session_name}|#{session_created}'], { reject: false });
|
|
157
|
+
if (result.exitCode !== 0) return [];
|
|
158
|
+
return result.stdout
|
|
159
|
+
.split(/\r?\n/)
|
|
160
|
+
.filter(Boolean)
|
|
161
|
+
.map(line => {
|
|
162
|
+
const [name, createdAt] = line.split('|');
|
|
163
|
+
return { name, createdAt: Number(createdAt) || null };
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function getMirrorableSessions() {
|
|
168
|
+
const sessions = [];
|
|
169
|
+
const customSessions = await listTmuxSessions(tmuxSocketArgs());
|
|
170
|
+
customSessions
|
|
171
|
+
.filter(s => isSecureLinkSession(s.name))
|
|
172
|
+
.forEach(s => sessions.push({ ...s, socketArgs: tmuxSocketArgs(), source: 'custom' }));
|
|
173
|
+
|
|
174
|
+
const legacySessions = await listTmuxSessions();
|
|
175
|
+
legacySessions
|
|
176
|
+
.filter(s => isSecureLinkSession(s.name))
|
|
177
|
+
.forEach(s => sessions.push({ ...s, socketArgs: [], source: 'legacy' }));
|
|
178
|
+
|
|
179
|
+
return sessions;
|
|
180
|
+
}
|
|
181
|
+
|
|
149
182
|
// ─── Ephemeral SSH Key Management ────────────────────────────
|
|
150
183
|
async function generateEphemeralKey() {
|
|
151
184
|
const tmpDir = os.tmpdir() || process.env.TMPDIR || process.env.TEMP || process.env.TMP;
|
|
@@ -643,7 +676,7 @@ async function hostDashboard(uid, password, serviceConfig, tunnelProcess, sessio
|
|
|
643
676
|
console.log(chalk.bold(' ║ 🛡️ SecureLink — HOST MODE ACTIVE ║'));
|
|
644
677
|
console.log(chalk.bold(' ╠════════════════════════════════════════════════════╣'));
|
|
645
678
|
console.log(` ║ ${chalk.cyan('UID:')} ${chalk.bold.white(uid.padEnd(30))}║`);
|
|
646
|
-
console.log(` ║ ${chalk.cyan('Password:')} ${chalk.bold.white(password.padEnd(30))}║`);
|
|
679
|
+
console.log(` ║ ${chalk.cyan('Password:')} ${chalk.bold.white(secureSensitive(password).padEnd(30))}║`);
|
|
647
680
|
console.log(` ║ ${chalk.cyan('Service:')} ${chalk.dim(serviceConfig.type.toUpperCase() + ' (Port ' + serviceConfig.port + ')').padEnd(30)}║`);
|
|
648
681
|
console.log(` ║ ${chalk.cyan('Tunnel:')} ${chalk.dim(sessionState.tunnelUrl.substring(0, 40))} ║`);
|
|
649
682
|
if (serviceConfig.chatUrl) {
|
|
@@ -810,17 +843,33 @@ async function hostDashboard(uid, password, serviceConfig, tunnelProcess, sessio
|
|
|
810
843
|
console.log('');
|
|
811
844
|
|
|
812
845
|
try {
|
|
813
|
-
await
|
|
814
|
-
const
|
|
815
|
-
if (
|
|
846
|
+
await execa('tmux', ['-V'], { reject: true });
|
|
847
|
+
const sessions = await getMirrorableSessions();
|
|
848
|
+
if (sessions.length === 0) {
|
|
816
849
|
console.log(chalk.yellow(' ⚠️ No mirrored terminal session is active yet.'));
|
|
817
850
|
console.log(chalk.dim(' A client must choose "Connect via SSH" first. SCP-only clients do not create a tmux session.'));
|
|
818
851
|
console.log(chalk.dim(' tmux is needed on the host machine only; the client does not need tmux.'));
|
|
819
852
|
logSessionEvent('host_mirror_missing_session', {}, 'warn');
|
|
820
853
|
return waitForAction();
|
|
821
854
|
}
|
|
822
|
-
|
|
823
|
-
|
|
855
|
+
|
|
856
|
+
let target = sessions[0];
|
|
857
|
+
if (sessions.length > 1) {
|
|
858
|
+
const { sessionChoice } = await inquirer.prompt([{
|
|
859
|
+
type: 'list',
|
|
860
|
+
name: 'sessionChoice',
|
|
861
|
+
message: 'Select an active client session to mirror:',
|
|
862
|
+
choices: sessions.map((s, idx) => {
|
|
863
|
+
const created = s.createdAt ? new Date(s.createdAt * 1000).toLocaleTimeString() : 'Unknown';
|
|
864
|
+
const label = `${s.name} ${chalk.dim(`(started ${created})`)}`;
|
|
865
|
+
return { name: label, value: String(idx) };
|
|
866
|
+
}),
|
|
867
|
+
}]);
|
|
868
|
+
target = sessions[parseInt(sessionChoice, 10)] || sessions[0];
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
await execa('tmux', [...target.socketArgs, 'attach', '-t', target.name, '-r'], { stdio: 'inherit', reject: false });
|
|
872
|
+
logSessionEvent('host_mirror_attached', { session: target.name, source: target.source });
|
|
824
873
|
} catch (err) {
|
|
825
874
|
console.log(chalk.yellow(' ⚠️ Could not attach to tmux.'));
|
|
826
875
|
console.log(chalk.dim(` ${err.message}`));
|
|
@@ -881,10 +930,16 @@ async function hostDashboard(uid, password, serviceConfig, tunnelProcess, sessio
|
|
|
881
930
|
const spinner = createSpinner('Terminating active SSH sessions...', networkSpinner).start();
|
|
882
931
|
try {
|
|
883
932
|
if (process.platform === 'win32') {
|
|
884
|
-
await
|
|
933
|
+
await execa('powershell', ['-NoProfile', '-Command', "Get-CimInstance Win32_Process -Filter \"name = 'sshd.exe'\" | Where-Object { $_.CommandLine -match 'sshd:.*@' } | ForEach-Object { Stop-Process -Id $_.ProcessId -Force }"], { reject: false });
|
|
885
934
|
} else {
|
|
886
|
-
await
|
|
887
|
-
await execa('tmux', [...tmuxSocketArgs(), 'kill-
|
|
935
|
+
await execa('pkill', ['-f', 'sshd:.*@'], { reject: false });
|
|
936
|
+
await execa('tmux', [...tmuxSocketArgs(), 'kill-server'], { reject: false });
|
|
937
|
+
const legacySessions = await listTmuxSessions();
|
|
938
|
+
for (const session of legacySessions) {
|
|
939
|
+
if (isSecureLinkSession(session.name)) {
|
|
940
|
+
await execa('tmux', ['kill-session', '-t', session.name], { reject: false });
|
|
941
|
+
}
|
|
942
|
+
}
|
|
888
943
|
}
|
|
889
944
|
spinner.succeed('All client SSH sessions terminated');
|
|
890
945
|
logSessionEvent('host_sessions_terminated');
|
|
@@ -941,7 +996,7 @@ export async function startHostMode() {
|
|
|
941
996
|
},
|
|
942
997
|
]);
|
|
943
998
|
const password = pwdInput.trim() || generateUID();
|
|
944
|
-
console.log(` ${chalk.green('✓')} Password: ${chalk.bold.white(password)}`);
|
|
999
|
+
console.log(` ${chalk.green('✓')} Password: ${chalk.bold.white(secureSensitive(password))}`);
|
|
945
1000
|
console.log('');
|
|
946
1001
|
|
|
947
1002
|
// ─── Broker Selection ───
|