@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miraj181/ipingyou",
3
- "version": "2.1.5",
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": "^14.0.3",
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 { execaCommand } = await import('execa');
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 execaCommand('npm install -g pm2', { stdio: 'inherit' });
356
- await execaCommand('pm2 start ipingyou --name "ipingyou-host" -- host', { stdio: 'inherit' });
357
- await execaCommand('pm2 save', { stdio: 'inherit' });
358
- await execaCommand('pm2 startup', { stdio: 'inherit' });
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 execaCommand('pm2 stop ipingyou-host', { stdio: 'inherit' });
362
- await execaCommand('pm2 delete ipingyou-host', { stdio: 'inherit' });
363
- await execaCommand('pm2 save', { stdio: 'inherit' });
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 execaCommand('pm2 status ipingyou-host', { stdio: 'inherit' });
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
- cpu: os.cpus()[0]?.model || 'Unknown CPU',
258
- ram: `${Math.round(os.totalmem() / 1024 / 1024 / 1024)} GB`,
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().toLocaleTimeString()
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', `${brokerUrl}/client-info/${uid}`, {
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}#${password}`));
326
+ console.log(chalk.dim(` Unable to auto-open browser. Visit ${secureSensitiveUrl(`http://localhost:${port}`, password)}`));
326
327
  }
327
328
  }
@@ -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 { execaCommand } from 'execa';
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
- await execaCommand('taskkill /F /IM cloudflared.exe', { reject: false });
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
- await execaCommand('pkill -9 -f cloudflared', { reject: false });
194
- await execaCommand('pkill -9 -f "sshd:.*@"', { reject: false });
195
- await execaCommand(`tmux ${tmuxSocketArgs().join(' ')} kill-session -t ${TMUX_SESSION_NAME}`, { reject: false });
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...'));
@@ -8,10 +8,11 @@
8
8
  * ============================================================
9
9
  */
10
10
 
11
- import { execaCommand } from 'execa';
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
- const { stdout } = await execaCommand('cat /etc/os-release', { reject: false });
46
- const lower = stdout.toLowerCase();
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
- const checkCmd = process.platform === 'win32' ? `where ${cmd}` : `which ${cmd}`;
70
- await execaCommand(checkCmd, { reject: true });
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
- await execaCommand(cmd, { stdio: 'pipe', reject: true, ...opts });
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: `curl -fsSL -o "${destPath}" "${url}"`,
202
- wget: `wget -q -O "${destPath}" "${url}"`,
203
- powershell: `powershell -Command "Invoke-WebRequest -Uri '${url}' -OutFile '${destPath}'"`,
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
- cmds[downloader]
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('sha1').update(hostname).digest('hex').slice(0, 10);
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, execaCommand } from '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 result = await execaCommand(command, {
214
- shell: true,
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,
@@ -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 { TMUX_SESSION_NAME, tmuxSocketCommand } from '../lib/tmux.js';
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 tmuxPrepare = `${tmuxSocketCommand()} has-session -t ${TMUX_SESSION_NAME} 2>/dev/null || ${tmuxSocketCommand()} new-session -d -s ${TMUX_SESSION_NAME}`;
92
- const tmuxAttach = `${tmuxSocketCommand()} attach -t ${TMUX_SESSION_NAME}`;
93
- sshArgs.push('-t', `${tmuxPrepare} && ${tmuxAttach} || exec $SHELL -l`);
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}#${password}`));
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.');
@@ -2,7 +2,7 @@
2
2
  * Doctor Mode — non-invasive diagnostics for iPingYou.
3
3
  */
4
4
 
5
- import { execa, execaCommand } from '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 execaCommand('launchctl print-disabled system', { reject: false, timeout: 5000 });
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 execaCommand('systemsetup -getremotelogin', { reject: false, timeout: 5000 });
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 execaCommand('systemctl is-active ssh', { reject: false, timeout: 5000 });
100
- const sshd = ssh.exitCode === 0 ? ssh : await execaCommand('systemctl is-active sshd', { reject: false, timeout: 5000 });
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 execaCommand('sc query sshd', { reject: false, timeout: 5000 });
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
- const result = await execaCommand(command, {
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, execaCommand } from '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 execaCommand('systemctl is-active ssh', { reject: true });
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 execaCommand('sudo systemctl start ssh', { stdio: 'inherit' });
64
+ await execa('sudo', ['systemctl', 'start', 'ssh'], { stdio: 'inherit' });
64
65
  spinner.succeed('SSH service started');
65
66
  } catch {
66
- await execaCommand('sudo systemctl start sshd', { stdio: 'inherit' });
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 execaCommand('sudo systemsetup -getremotelogin', { reject: false });
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 execaCommand('sudo systemsetup -setremotelogin on', { stdio: 'inherit' });
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 execaCommand('sc query sshd', { reject: false });
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 execaCommand('net start sshd', { stdio: 'inherit' });
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 execaCommand('tmux -V', { reject: true });
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 execaCommand('sudo apt-get update && sudo apt-get install -y tmux', { shell: true, stdio: 'inherit' });
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 execaCommand('sudo dnf install -y tmux', { shell: true, stdio: 'inherit' });
125
+ await execa('sudo', ['dnf', 'install', '-y', 'tmux'], { stdio: 'inherit' });
124
126
  } else if (fs.existsSync('/usr/bin/yum')) {
125
- await execaCommand('sudo yum install -y tmux', { shell: true, stdio: 'inherit' });
127
+ await execa('sudo', ['yum', 'install', '-y', 'tmux'], { stdio: 'inherit' });
126
128
  } else if (fs.existsSync('/usr/bin/pacman')) {
127
- await execaCommand('sudo pacman -S --noconfirm tmux', { shell: true, stdio: 'inherit' });
129
+ await execa('sudo', ['pacman', '-S', '--noconfirm', 'tmux'], { stdio: 'inherit' });
128
130
  } else if (fs.existsSync('/sbin/apk')) {
129
- await execaCommand('sudo apk add tmux', { shell: true, stdio: 'inherit' });
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 execaCommand('brew install tmux', { shell: true, stdio: 'inherit' });
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 execaCommand('tmux -V', { reject: true });
814
- const sessionCheck = await execa('tmux', [...tmuxSocketArgs(), 'has-session', '-t', TMUX_SESSION_NAME], { reject: false });
815
- if (sessionCheck.exitCode !== 0) {
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
- await execa('tmux', [...tmuxSocketArgs(), 'attach', '-t', TMUX_SESSION_NAME, '-r'], { stdio: 'inherit', reject: false });
823
- logSessionEvent('host_mirror_attached');
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 execaCommand('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 });
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 execaCommand("pkill -f 'sshd:.*@'", { shell: true, reject: false });
887
- await execa('tmux', [...tmuxSocketArgs(), 'kill-session', '-t', TMUX_SESSION_NAME], { reject: false });
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 ───