@miraj181/ipingyou 2.1.4 → 2.1.6

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.4",
3
+ "version": "2.1.6",
4
4
  "description": "SecureLink-CLI — Secure peer-to-peer remote access via SSH & Cloudflare Tunnels",
5
5
  "main": "src/cli.js",
6
6
  "bin": {
package/src/cli.js CHANGED
@@ -27,6 +27,7 @@ import { fileURLToPath } from 'node:url';
27
27
 
28
28
  import { detectOS, checkDependencies } from './lib/platform.js';
29
29
  import { cleanupAll, installShutdownHandlers, executePanicMode } from './lib/cleanup.js';
30
+ import { cleanupSessionLog } from './lib/session-log.js';
30
31
  import { startHostMode } from './modes/host.js';
31
32
  import { startClientMode } from './modes/client.js';
32
33
  import { startAIMode } from './modes/ai.js';
@@ -163,6 +164,7 @@ function fatal(context, err) {
163
164
  stackLines.forEach(line => console.error(chalk.dim(` ${line.trim()}`)));
164
165
  }
165
166
  console.error('');
167
+ cleanupSessionLog();
166
168
  cleanupAll().finally(() => process.exit(1));
167
169
  }
168
170
 
@@ -14,6 +14,7 @@ import fs from 'node:fs';
14
14
  import os from 'node:os';
15
15
  import path from 'node:path';
16
16
  import { execaCommand } from 'execa';
17
+ import { TMUX_SESSION_NAME, TMUX_SESSION_PREFIX, tmuxSocketArgs } from './tmux.js';
17
18
 
18
19
  /** @type {Set<number>} — Active child PIDs we manage */
19
20
  const trackedPIDs = new Set();
@@ -191,7 +192,15 @@ export async function executePanicMode() {
191
192
  } else {
192
193
  await execaCommand('pkill -9 -f cloudflared', { reject: false });
193
194
  await execaCommand('pkill -9 -f "sshd:.*@"', { reject: false });
194
- await execaCommand('tmux kill-session -t SecureLink_Session', { reject: false });
195
+ await execaCommand(`tmux ${tmuxSocketArgs().join(' ')} kill-server`, { reject: false });
196
+ const { stdout } = await execaCommand('tmux list-sessions -F "#{session_name}"', { reject: false });
197
+ const legacyNames = stdout
198
+ .split(/\r?\n/)
199
+ .filter(Boolean)
200
+ .filter(name => name === TMUX_SESSION_NAME || name.startsWith(TMUX_SESSION_PREFIX));
201
+ for (const name of legacyNames) {
202
+ await execaCommand(`tmux kill-session -t ${name}`, { reject: false });
203
+ }
195
204
  }
196
205
  } catch {}
197
206
 
@@ -109,11 +109,11 @@ export async function promptLocalPath(label, browserStart = process.cwd()) {
109
109
  return expandLocalPath(localPath);
110
110
  }
111
111
 
112
- async function listRemoteDirectory(username, hostname, privateKeyPath, remoteDir) {
112
+ async function listRemoteDirectory(username, hostname, privateKeyPath, remoteDir, persistKnownHosts = true) {
113
113
  const cdTarget = formatRemoteCd(remoteDir);
114
114
  const cdCommand = cdTarget ? `cd ${cdTarget}` : 'cd';
115
115
  const command = `${cdCommand} && printf '__SECURELINK_PWD__%s\\n' "$PWD" && ls -1Ap`;
116
- const sshArgs = buildSshArgs(hostname, privateKeyPath);
116
+ const sshArgs = buildSshArgs(hostname, privateKeyPath, [], { persistKnownHosts });
117
117
  sshArgs.push(`${username}@${hostname}`, command);
118
118
 
119
119
  const result = await execa('ssh', sshArgs, {
@@ -146,7 +146,8 @@ async function listRemoteDirectory(username, hostname, privateKeyPath, remoteDir
146
146
  return { pwd, entries };
147
147
  }
148
148
 
149
- export async function promptRemotePath(username, hostname, privateKeyPath, purpose, startDir = '~') {
149
+ export async function promptRemotePath(username, hostname, privateKeyPath, purpose, startDir = '~', options = {}) {
150
+ const { persistKnownHosts = true } = options;
150
151
  const browseLabel = purpose === 'source'
151
152
  ? 'Browse host files interactively'
152
153
  : 'Browse host folders interactively';
@@ -183,7 +184,7 @@ export async function promptRemotePath(username, hostname, privateKeyPath, purpo
183
184
  while (true) {
184
185
  let listing;
185
186
  try {
186
- listing = await listRemoteDirectory(username, hostname, privateKeyPath, currentDir);
187
+ listing = await listRemoteDirectory(username, hostname, privateKeyPath, currentDir, persistKnownHosts);
187
188
  } catch (err) {
188
189
  console.log(chalk.yellow(` ⚠️ Could not browse host files: ${err.message}`));
189
190
  if (/Operation not permitted/i.test(err.stderr || err.message)) {
package/src/lib/ssh.js CHANGED
@@ -34,11 +34,24 @@ export function getSshControlOptions(hostname) {
34
34
  ];
35
35
  }
36
36
 
37
- export function buildSshArgs(hostname, privateKeyPath, extraOptions = []) {
37
+ export function getKnownHostsOptions(persistKnownHosts = true) {
38
+ if (persistKnownHosts) {
39
+ return ['-o', 'StrictHostKeyChecking=accept-new'];
40
+ }
41
+ const nullDevice = process.platform === 'win32' ? 'NUL' : '/dev/null';
42
+ return [
43
+ '-o', 'StrictHostKeyChecking=no',
44
+ '-o', `UserKnownHostsFile=${nullDevice}`,
45
+ '-o', 'LogLevel=ERROR',
46
+ ];
47
+ }
48
+
49
+ export function buildSshArgs(hostname, privateKeyPath, extraOptions = [], options = {}) {
50
+ const { persistKnownHosts = true } = options;
38
51
  const proxyCommand = `cloudflared access tcp --hostname ${hostname}`;
39
52
  const sshArgs = [
40
53
  '-o', `ProxyCommand=${proxyCommand}`,
41
- '-o', 'StrictHostKeyChecking=accept-new',
54
+ ...getKnownHostsOptions(persistKnownHosts),
42
55
  '-o', 'IdentitiesOnly=yes',
43
56
  ...getSshControlOptions(hostname),
44
57
  ...extraOptions,
@@ -0,0 +1,23 @@
1
+ import os from 'node:os';
2
+ import path from 'node:path';
3
+
4
+ export const TMUX_SESSION_PREFIX = 'SecureLink_';
5
+ export const TMUX_SESSION_NAME = 'SecureLink_Session';
6
+ export const TMUX_SOCKET_PATH = path.join(os.tmpdir(), 'ipingyou-tmux.sock');
7
+
8
+ export function tmuxSocketArgs() {
9
+ return ['-S', TMUX_SOCKET_PATH];
10
+ }
11
+
12
+ export function tmuxSocketCommand() {
13
+ return `tmux -S ${TMUX_SOCKET_PATH}`;
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
+ }
@@ -24,7 +24,8 @@ import { getConfig, saveAlias } from '../lib/config.js';
24
24
  import { pushTelemetry, requestHostApproval, resolveUID, revokeUID, waitForApproval } from '../lib/broker.js';
25
25
  import { calculateChecksum } from '../lib/checksum.js';
26
26
  import { promptLocalPath, promptRemotePath } from '../lib/path-browser.js';
27
- import { buildSshArgs, extractHostname, formatScpRemotePath, getSshControlOptions, quoteRemoteShell } from '../lib/ssh.js';
27
+ import { buildSshArgs, extractHostname, formatScpRemotePath, getKnownHostsOptions, getSshControlOptions, quoteRemoteShell } from '../lib/ssh.js';
28
+ import { buildTmuxSessionName, tmuxSocketCommand } from '../lib/tmux.js';
28
29
  import open from 'open';
29
30
  import { cleanupSessionLog, initSessionLog, logSessionEvent, recordEvent } from '../lib/session-log.js';
30
31
 
@@ -68,7 +69,7 @@ async function writeEphemeralPrivateKey(privateKey) {
68
69
  /**
69
70
  * Start SSH connection through the Cloudflare tunnel.
70
71
  */
71
- async function connectSSH(username, hostname, privateKeyPath) {
72
+ async function connectSSH(username, hostname, privateKeyPath, persistKnownHosts = true) {
72
73
  console.log('');
73
74
  console.log(chalk.bold(' 🔗 Establishing SSH Connection'));
74
75
  console.log(chalk.dim(' ─────────────────────────────────'));
@@ -84,10 +85,13 @@ async function connectSSH(username, hostname, privateKeyPath) {
84
85
  const sshArgs = buildSshArgs(hostname, privateKeyPath, [
85
86
  '-o', 'ServerAliveInterval=30',
86
87
  '-o', 'ServerAliveCountMax=3'
87
- ]);
88
+ ], { persistKnownHosts });
88
89
 
89
90
  sshArgs.push(`${username}@${hostname}`);
90
- sshArgs.push('-t', 'tmux new-session -A -s SecureLink_Session 2>/dev/null || exec $SHELL -l');
91
+ const tmuxSession = buildTmuxSessionName(username);
92
+ const tmuxPrepare = `${tmuxSocketCommand()} has-session -t ${tmuxSession} 2>/dev/null || ${tmuxSocketCommand()} new-session -d -s ${tmuxSession}`;
93
+ const tmuxAttach = `${tmuxSocketCommand()} attach -t ${tmuxSession}`;
94
+ sshArgs.push('-t', `${tmuxPrepare} && ${tmuxAttach} || exec $SHELL -l`);
91
95
 
92
96
  const child = execa('ssh', sshArgs, {
93
97
  stdio: 'inherit',
@@ -119,9 +123,9 @@ async function connectSSH(username, hostname, privateKeyPath) {
119
123
  /**
120
124
  * Perform an SCP file transfer through the Cloudflare tunnel.
121
125
  */
122
- async function chooseRemoteTransferPath(username, hostname, privateKeyPath, direction, sharedDropPath) {
126
+ async function chooseRemoteTransferPath(username, hostname, privateKeyPath, direction, sharedDropPath, persistKnownHosts = true) {
123
127
  if (!sharedDropPath) {
124
- return promptRemotePath(username, hostname, privateKeyPath, direction === 'upload' ? 'destination' : 'source');
128
+ return promptRemotePath(username, hostname, privateKeyPath, direction === 'upload' ? 'destination' : 'source', '~', { persistKnownHosts });
125
129
  }
126
130
 
127
131
  const { dropChoice } = await inquirer.prompt([{
@@ -145,7 +149,7 @@ async function chooseRemoteTransferPath(username, hostname, privateKeyPath, dire
145
149
 
146
150
  if (dropChoice === 'drop') return sharedDropPath;
147
151
  if (dropChoice === 'drop_browse') {
148
- return promptRemotePath(username, hostname, privateKeyPath, 'source', sharedDropPath);
152
+ return promptRemotePath(username, hostname, privateKeyPath, 'source', sharedDropPath, { persistKnownHosts });
149
153
  }
150
154
  if (dropChoice === 'manual') {
151
155
  const { remotePath } = await inquirer.prompt([{
@@ -157,10 +161,10 @@ async function chooseRemoteTransferPath(username, hostname, privateKeyPath, dire
157
161
  return remotePath.trim();
158
162
  }
159
163
 
160
- return promptRemotePath(username, hostname, privateKeyPath, direction === 'upload' ? 'destination' : 'source');
164
+ return promptRemotePath(username, hostname, privateKeyPath, direction === 'upload' ? 'destination' : 'source', '~', { persistKnownHosts });
161
165
  }
162
166
 
163
- async function performSCP(username, hostname, direction, privateKeyPath, sharedDropPath = null) {
167
+ async function performSCP(username, hostname, direction, privateKeyPath, sharedDropPath = null, persistKnownHosts = true) {
164
168
  console.log('');
165
169
  console.log(chalk.bold(` 📦 SCP Transfer (${direction})`));
166
170
  console.log(chalk.dim(' ─────────────────────────────────'));
@@ -169,11 +173,11 @@ async function performSCP(username, hostname, direction, privateKeyPath, sharedD
169
173
  let remotePath;
170
174
 
171
175
  if (direction === 'upload') {
172
- remotePath = await chooseRemoteTransferPath(username, hostname, privateKeyPath, direction, sharedDropPath);
176
+ remotePath = await chooseRemoteTransferPath(username, hostname, privateKeyPath, direction, sharedDropPath, persistKnownHosts);
173
177
  localPath = await promptLocalPath('client file/folder to upload');
174
178
  } else {
175
179
  localPath = await promptLocalPath('client destination');
176
- remotePath = await chooseRemoteTransferPath(username, hostname, privateKeyPath, direction, sharedDropPath);
180
+ remotePath = await chooseRemoteTransferPath(username, hostname, privateKeyPath, direction, sharedDropPath, persistKnownHosts);
177
181
  }
178
182
 
179
183
  await showConnectionTrace('Local', 'Remote SCP');
@@ -184,7 +188,7 @@ async function performSCP(username, hostname, direction, privateKeyPath, sharedD
184
188
  const scpArgs = [
185
189
  '-r', // recursive just in case
186
190
  '-o', `ProxyCommand=${proxyCommand}`,
187
- '-o', 'StrictHostKeyChecking=accept-new',
191
+ ...getKnownHostsOptions(persistKnownHosts),
188
192
  '-o', 'IdentitiesOnly=yes',
189
193
  ...getSshControlOptions(hostname)
190
194
  ];
@@ -230,13 +234,7 @@ async function performSCP(username, hostname, direction, privateKeyPath, sharedD
230
234
  console.log(chalk.dim(' 🔍 Verifying remote SHA-256 checksum...'));
231
235
  try {
232
236
  const remoteChecksumPath = joinRemotePath(remotePath, path.basename(localPath));
233
- const sshArgs = [
234
- '-o', `ProxyCommand=${proxyCommand}`,
235
- '-o', 'StrictHostKeyChecking=accept-new',
236
- '-o', 'IdentitiesOnly=yes',
237
- ...getSshControlOptions(hostname)
238
- ];
239
- if (privateKeyPath) sshArgs.push('-i', privateKeyPath, '-o', 'IdentityAgent=none');
237
+ const sshArgs = buildSshArgs(hostname, privateKeyPath, [], { persistKnownHosts });
240
238
  sshArgs.push(`${username}@${hostname}`, `shasum -a 256 ${quoteRemoteShell(remoteChecksumPath)} 2>/dev/null || sha256sum ${quoteRemoteShell(remoteChecksumPath)} 2>/dev/null || shasum -a 256 ${quoteRemoteShell(remotePath)} 2>/dev/null || sha256sum ${quoteRemoteShell(remotePath)}`);
241
239
 
242
240
  const { stdout } = await execa('ssh', sshArgs, { reject: false });
@@ -268,13 +266,13 @@ async function performSCP(username, hostname, direction, privateKeyPath, sharedD
268
266
  }
269
267
  }
270
268
 
271
- async function downloadSpecificRemotePath(username, hostname, privateKeyPath, remotePath, localPath) {
269
+ async function downloadSpecificRemotePath(username, hostname, privateKeyPath, remotePath, localPath, persistKnownHosts = true) {
272
270
  await showConnectionTrace('Local', 'Remote SCP');
273
271
  const proxyCommand = `cloudflared access tcp --hostname ${hostname}`;
274
272
  const scpArgs = [
275
273
  '-r',
276
274
  '-o', `ProxyCommand=${proxyCommand}`,
277
- '-o', 'StrictHostKeyChecking=accept-new',
275
+ ...getKnownHostsOptions(persistKnownHosts),
278
276
  '-o', 'IdentitiesOnly=yes',
279
277
  ...getSshControlOptions(hostname),
280
278
  ];
@@ -343,6 +341,7 @@ export async function startClientMode(options = {}) {
343
341
  let targetUid = null;
344
342
  let targetPassword = null;
345
343
  let targetUsername = null;
344
+ let persistKnownHosts = false;
346
345
 
347
346
  if (aliasKeys.length > 0) {
348
347
  const { useAlias } = await inquirer.prompt([
@@ -362,6 +361,7 @@ export async function startClientMode(options = {}) {
362
361
  targetUid = aliasData.uid;
363
362
  targetPassword = aliasData.password;
364
363
  targetUsername = aliasData.username;
364
+ persistKnownHosts = true;
365
365
  logSessionEvent('client_alias_selected', { alias: useAlias, uid: targetUid });
366
366
  }
367
367
  }
@@ -552,7 +552,7 @@ export async function startClientMode(options = {}) {
552
552
  await pushTelemetry(BROKER_URL, targetUid, targetPassword, username, 'one-time-download');
553
553
  logSessionEvent('client_one_time_download_start', { uid: targetUid, remotePath: payload.oneTimeSharePath });
554
554
 
555
- const success = await downloadSpecificRemotePath(username, hostname, privateKeyPath, payload.oneTimeSharePath, localDest);
555
+ const success = await downloadSpecificRemotePath(username, hostname, privateKeyPath, payload.oneTimeSharePath, localDest, persistKnownHosts);
556
556
 
557
557
  if (success) {
558
558
  console.log(chalk.green(' ✅ One-time file transfer completed successfully!'));
@@ -596,6 +596,7 @@ export async function startClientMode(options = {}) {
596
596
  saveAlias(aliasName.trim(), { uid: targetUid, password: targetPassword, username });
597
597
  console.log(chalk.green(` ✓ Saved as alias: ${chalk.bold(aliasName.trim())}\n`));
598
598
  logSessionEvent('client_alias_saved', { alias: aliasName.trim(), uid: targetUid });
599
+ persistKnownHosts = true;
599
600
  }
600
601
  }
601
602
 
@@ -620,11 +621,11 @@ export async function startClientMode(options = {}) {
620
621
  if (action === 'chat') {
621
622
  await handleClientChat(targetUid, targetPassword, payload.chatUrl);
622
623
  } else if (action === 'ssh') {
623
- await connectSSH(username, hostname, privateKeyPath);
624
+ await connectSSH(username, hostname, privateKeyPath, persistKnownHosts);
624
625
  } else if (action === 'reverse') {
625
- await performReverseForward(username, hostname, privateKeyPath);
626
+ await performReverseForward(username, hostname, privateKeyPath, persistKnownHosts);
626
627
  } else {
627
- await performSCP(username, hostname, action, privateKeyPath, payload.sharedDropPath);
628
+ await performSCP(username, hostname, action, privateKeyPath, payload.sharedDropPath, persistKnownHosts);
628
629
  }
629
630
 
630
631
  console.log('');
@@ -638,7 +639,7 @@ export async function startClientMode(options = {}) {
638
639
  ]);
639
640
 
640
641
  if (reconnect) {
641
- await handleSubsequentActions(username, hostname, privateKeyPath, targetUid, targetPassword, payload.sharedDropPath);
642
+ await handleSubsequentActions(username, hostname, privateKeyPath, targetUid, targetPassword, payload.sharedDropPath, persistKnownHosts);
642
643
  }
643
644
 
644
645
  logSessionEvent('client_session_exit');
@@ -650,7 +651,7 @@ export async function startClientMode(options = {}) {
650
651
  * params: { brokerUrl, uid, password, username, direction, localPath, remotePath }
651
652
  */
652
653
  export async function performSCPNonInteractive(params = {}) {
653
- const { brokerUrl, uid, password, username, direction, localPath, remotePath } = params;
654
+ const { brokerUrl, uid, password, username, direction, localPath, remotePath, persistKnownHosts = true } = params;
654
655
  if (!brokerUrl || !uid || !password) throw new Error('Missing broker connection info');
655
656
 
656
657
  const payload = await resolveUID(brokerUrl, uid, password);
@@ -662,7 +663,7 @@ export async function performSCPNonInteractive(params = {}) {
662
663
 
663
664
  // Build scp args similar to performSCP
664
665
  const proxyCommand = `cloudflared access tcp --hostname ${hostname}`;
665
- const scpArgs = ['-r', '-o', `ProxyCommand=${proxyCommand}`, '-o', 'StrictHostKeyChecking=accept-new', '-o', 'IdentitiesOnly=yes', ...getSshControlOptions(hostname)];
666
+ const scpArgs = ['-r', '-o', `ProxyCommand=${proxyCommand}`, ...getKnownHostsOptions(persistKnownHosts), '-o', 'IdentitiesOnly=yes', ...getSshControlOptions(hostname)];
666
667
  if (privateKeyPath) scpArgs.push('-i', privateKeyPath, '-o', 'IdentityAgent=none');
667
668
 
668
669
  const remoteSpec = `${username}@${hostname}:${formatScpRemotePath(remotePath)}`;
@@ -707,7 +708,7 @@ async function handleClientChat(uid, password, cachedChatUrl) {
707
708
  }
708
709
  }
709
710
 
710
- async function handleSubsequentActions(username, hostname, privateKeyPath, targetUid, targetPassword, sharedDropPath = null) {
711
+ async function handleSubsequentActions(username, hostname, privateKeyPath, targetUid, targetPassword, sharedDropPath = null, persistKnownHosts = true) {
711
712
  const { action } = await inquirer.prompt([
712
713
  {
713
714
  type: 'list',
@@ -732,11 +733,11 @@ async function handleSubsequentActions(username, hostname, privateKeyPath, targe
732
733
  if (action === 'chat') {
733
734
  await handleClientChat(targetUid, targetPassword, null);
734
735
  } else if (action === 'ssh') {
735
- await connectSSH(username, hostname, privateKeyPath);
736
+ await connectSSH(username, hostname, privateKeyPath, persistKnownHosts);
736
737
  } else if (action === 'reverse') {
737
- await performReverseForward(username, hostname, privateKeyPath);
738
+ await performReverseForward(username, hostname, privateKeyPath, persistKnownHosts);
738
739
  } else {
739
- await performSCP(username, hostname, action, privateKeyPath, sharedDropPath);
740
+ await performSCP(username, hostname, action, privateKeyPath, sharedDropPath, persistKnownHosts);
740
741
  }
741
742
 
742
743
  const { reconnect } = await inquirer.prompt([
@@ -749,11 +750,11 @@ async function handleSubsequentActions(username, hostname, privateKeyPath, targe
749
750
  ]);
750
751
 
751
752
  if (reconnect) {
752
- await handleSubsequentActions(username, hostname, privateKeyPath, targetUid, targetPassword, sharedDropPath);
753
+ await handleSubsequentActions(username, hostname, privateKeyPath, targetUid, targetPassword, sharedDropPath, persistKnownHosts);
753
754
  }
754
755
  }
755
756
 
756
- async function performReverseForward(username, hostname, privateKeyPath) {
757
+ async function performReverseForward(username, hostname, privateKeyPath, persistKnownHosts = true) {
757
758
  console.log('');
758
759
  console.log(chalk.bold.cyan(' 🔄 Reverse Port Forwarding'));
759
760
  console.log(chalk.dim(' ──────────────────────────────────────'));
@@ -782,7 +783,7 @@ async function performReverseForward(username, hostname, privateKeyPath) {
782
783
  '-N',
783
784
  '-R', portMap,
784
785
  '-o', 'ExitOnForwardFailure=yes',
785
- ]);
786
+ ], { persistKnownHosts });
786
787
  sshArgs.push(`${username}@${hostname}`);
787
788
 
788
789
  console.log('');
package/src/modes/host.js CHANGED
@@ -29,6 +29,7 @@ import { startChatServer, openLocalChatUI } from '../lib/chat.js';
29
29
  import { spawnTunnelSupervised } from '../lib/tunnel.js';
30
30
  import { decideApprovalRequest, fetchApprovalRequests, pingBroker, registerWithBroker, revokeUID } from '../lib/broker.js';
31
31
  import { cleanupSessionLog, getSessionLogPath, initSessionLog, logSessionEvent, recordEvent } from '../lib/session-log.js';
32
+ import { TMUX_SESSION_NAME, TMUX_SESSION_PREFIX, tmuxSocketArgs } from '../lib/tmux.js';
32
33
 
33
34
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
34
35
  let BROKER_URL = process.env.BROKER_URL || 'https://ipingyou.onrender.com';
@@ -138,6 +139,37 @@ async function ensureTmuxInstalled() {
138
139
  throw new Error('Homebrew is required to install tmux on macOS');
139
140
  }
140
141
  }
142
+
143
+ function isSecureLinkSession(name) {
144
+ return name === TMUX_SESSION_NAME || name.startsWith(TMUX_SESSION_PREFIX);
145
+ }
146
+
147
+ async function listTmuxSessions(socketArgs = []) {
148
+ const result = await execa('tmux', [...socketArgs, 'list-sessions', '-F', '#{session_name}|#{session_created}'], { reject: false });
149
+ if (result.exitCode !== 0) return [];
150
+ return result.stdout
151
+ .split(/\r?\n/)
152
+ .filter(Boolean)
153
+ .map(line => {
154
+ const [name, createdAt] = line.split('|');
155
+ return { name, createdAt: Number(createdAt) || null };
156
+ });
157
+ }
158
+
159
+ async function getMirrorableSessions() {
160
+ const sessions = [];
161
+ const customSessions = await listTmuxSessions(tmuxSocketArgs());
162
+ customSessions
163
+ .filter(s => isSecureLinkSession(s.name))
164
+ .forEach(s => sessions.push({ ...s, socketArgs: tmuxSocketArgs(), source: 'custom' }));
165
+
166
+ const legacySessions = await listTmuxSessions();
167
+ legacySessions
168
+ .filter(s => isSecureLinkSession(s.name))
169
+ .forEach(s => sessions.push({ ...s, socketArgs: [], source: 'legacy' }));
170
+
171
+ return sessions;
172
+ }
141
173
  }
142
174
  } catch (err) {
143
175
  spinner.fail(`tmux check/install failed: ${err.message}`);
@@ -274,12 +306,37 @@ async function startLocalHostDashboard(uid, password, serviceConfig, sessionStat
274
306
  } catch { }
275
307
  };
276
308
 
277
- const iv = setInterval(push, 3000);
309
+ const iv = setInterval(push, 5000);
278
310
  req.on('close', () => { clearInterval(iv); closed = true; });
279
311
  // send initial payload
280
312
  await push();
281
313
  });
282
314
 
315
+ app.get('/api/clients', async (_req, res) => {
316
+ try {
317
+ const brokerRes = await fetch(`${BROKER_URL}/clients/${uid}`, {
318
+ headers: sessionState.hostToken ? { 'x-host-token': sessionState.hostToken } : {}
319
+ });
320
+ if (!brokerRes.ok) {
321
+ const data = await brokerRes.json().catch(() => ({}));
322
+ return res.status(brokerRes.status).json({ error: data.error || 'Failed to fetch clients' });
323
+ }
324
+ const data = await brokerRes.json();
325
+ const clients = (data.clients || []).map((clientBlob) => {
326
+ try {
327
+ const decrypted = decrypt(clientBlob.iv, clientBlob.ciphertext, password, clientBlob.salt);
328
+ const t = JSON.parse(decrypted);
329
+ return { ...t, seenAt: clientBlob.seenAt || null };
330
+ } catch {
331
+ return { error: 'decrypt_failed', seenAt: clientBlob.seenAt || null };
332
+ }
333
+ });
334
+ res.json({ clients });
335
+ } catch (err) {
336
+ res.status(500).json({ error: err.message });
337
+ }
338
+ });
339
+
283
340
  // CSRF Protection Middleware for all mutating endpoints
284
341
  app.use((req, res, next) => {
285
342
  if (req.method !== 'POST') return next();
@@ -404,6 +461,16 @@ function showToast(msg) {
404
461
  t.classList.add('show');
405
462
  setTimeout(() => t.classList.remove('show'), 2500);
406
463
  }
464
+
465
+ function escapeHtml(value) {
466
+ return String(value || '').replace(/[&<>"']/g, (m) => ({
467
+ '&': '&amp;',
468
+ '<': '&lt;',
469
+ '>': '&gt;',
470
+ '"': '&quot;',
471
+ "'": '&#39;',
472
+ })[m]);
473
+ }
407
474
 
408
475
  function updateUptime() {
409
476
  const diff = Math.floor((Date.now() - startedAt.getTime()) / 1000);
@@ -490,14 +557,47 @@ async function revokeSession() {
490
557
  // Poll client telemetry (separate from SSE for simplicity)
491
558
  async function pollClients() {
492
559
  try {
493
- const res = await fetch('/api/status');
560
+ const res = await fetch('/api/clients');
494
561
  if (!res.ok) return;
495
- // Client data is fetched from broker by SSE push. Let's also add a client endpoint.
562
+ const data = await res.json();
563
+ renderClients(data.clients || []);
496
564
  } catch {}
497
565
  }
566
+
567
+ function renderClients(clients) {
568
+ const container = document.getElementById('clients');
569
+ if (!clients || clients.length === 0) {
570
+ container.innerHTML = '<p class="empty">No clients connected yet</p>';
571
+ document.getElementById('client-count').textContent = '';
572
+ return;
573
+ }
574
+
575
+ document.getElementById('client-count').textContent = '(' + clients.length + ' connected)';
576
+ let html = '';
577
+ clients.forEach((c, idx) => {
578
+ if (c.error) {
579
+ html += '<div class="client-card"><strong>Client #' + (idx + 1) + '</strong> — payload decryption failed</div>';
580
+ return;
581
+ }
582
+ const when = c.time || (c.seenAt ? new Date(c.seenAt).toLocaleTimeString() : 'Unknown');
583
+ html += '<div class="client-card">'
584
+ + '<strong>' + escapeHtml(c.username || 'Unknown') + '</strong>'
585
+ + ' — ' + escapeHtml(c.action || 'connected') + '<br>'
586
+ + '<span class="meta">IP: ' + escapeHtml(c.ip || 'Unknown') + '</span><br>'
587
+ + '<span class="meta">OS: ' + escapeHtml(c.os || 'Unknown') + '</span><br>'
588
+ + '<span class="meta">CPU: ' + escapeHtml(c.cpu || 'Unknown') + '</span><br>'
589
+ + '<span class="meta">RAM: ' + escapeHtml(c.ram || 'Unknown') + '</span><br>'
590
+ + '<span class="meta">Time: ' + escapeHtml(when) + '</span>'
591
+ + '</div>';
592
+ });
593
+ container.innerHTML = html;
594
+ }
595
+
596
+ setInterval(pollClients, 5000);
597
+ pollClients();
498
598
  </script>
499
599
  </body></html>`);
500
- });
600
+ });
501
601
 
502
602
  return new Promise((resolve) => {
503
603
  const server = app.listen(0, '127.0.0.1', async () => {
@@ -742,16 +842,32 @@ async function hostDashboard(uid, password, serviceConfig, tunnelProcess, sessio
742
842
 
743
843
  try {
744
844
  await execaCommand('tmux -V', { reject: true });
745
- const sessionCheck = await execa('tmux', ['has-session', '-t', 'SecureLink_Session'], { reject: false });
746
- if (sessionCheck.exitCode !== 0) {
845
+ const sessions = await getMirrorableSessions();
846
+ if (sessions.length === 0) {
747
847
  console.log(chalk.yellow(' ⚠️ No mirrored terminal session is active yet.'));
748
848
  console.log(chalk.dim(' A client must choose "Connect via SSH" first. SCP-only clients do not create a tmux session.'));
749
849
  console.log(chalk.dim(' tmux is needed on the host machine only; the client does not need tmux.'));
750
850
  logSessionEvent('host_mirror_missing_session', {}, 'warn');
751
851
  return waitForAction();
752
852
  }
753
- await execaCommand('tmux attach -t SecureLink_Session -r', { stdio: 'inherit' });
754
- logSessionEvent('host_mirror_attached');
853
+
854
+ let target = sessions[0];
855
+ if (sessions.length > 1) {
856
+ const { sessionChoice } = await inquirer.prompt([{
857
+ type: 'list',
858
+ name: 'sessionChoice',
859
+ message: 'Select an active client session to mirror:',
860
+ choices: sessions.map((s, idx) => {
861
+ const created = s.createdAt ? new Date(s.createdAt * 1000).toLocaleTimeString() : 'Unknown';
862
+ const label = `${s.name} ${chalk.dim(`(started ${created})`)}`;
863
+ return { name: label, value: String(idx) };
864
+ }),
865
+ }]);
866
+ target = sessions[parseInt(sessionChoice, 10)] || sessions[0];
867
+ }
868
+
869
+ await execa('tmux', [...target.socketArgs, 'attach', '-t', target.name, '-r'], { stdio: 'inherit', reject: false });
870
+ logSessionEvent('host_mirror_attached', { session: target.name, source: target.source });
755
871
  } catch (err) {
756
872
  console.log(chalk.yellow(' ⚠️ Could not attach to tmux.'));
757
873
  console.log(chalk.dim(` ${err.message}`));
@@ -815,7 +931,13 @@ async function hostDashboard(uid, password, serviceConfig, tunnelProcess, sessio
815
931
  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 });
816
932
  } else {
817
933
  await execaCommand("pkill -f 'sshd:.*@'", { shell: true, reject: false });
818
- await execaCommand('tmux kill-session -t SecureLink_Session', { reject: false });
934
+ await execa('tmux', [...tmuxSocketArgs(), 'kill-server'], { reject: false });
935
+ const legacySessions = await listTmuxSessions();
936
+ for (const session of legacySessions) {
937
+ if (isSecureLinkSession(session.name)) {
938
+ await execa('tmux', ['kill-session', '-t', session.name], { reject: false });
939
+ }
940
+ }
819
941
  }
820
942
  spinner.succeed('All client SSH sessions terminated');
821
943
  logSessionEvent('host_sessions_terminated');
package/src/server.js CHANGED
@@ -68,7 +68,7 @@ app.use((req, res, next) => {
68
68
  // Trust proxy is required if the server is behind a reverse proxy (like Render, Heroku, Cloudflare)
69
69
  app.set('trust proxy', 1);
70
70
 
71
- // General rate limiter for all requests (100 reqs per 15 minutes per IP)
71
+ // General rate limiter for public requests (100 reqs per 15 minutes per IP)
72
72
  const generalLimiter = rateLimit({
73
73
  windowMs: 15 * 60 * 1000,
74
74
  max: 100,
@@ -76,7 +76,6 @@ const generalLimiter = rateLimit({
76
76
  standardHeaders: true,
77
77
  legacyHeaders: false,
78
78
  });
79
- app.use(generalLimiter);
80
79
 
81
80
  // Stricter rate limiter for registration/revocation endpoints (e.g. 20 reqs per 15 mins)
82
81
  const strictLimiter = rateLimit({
@@ -87,6 +86,15 @@ const strictLimiter = rateLimit({
87
86
  legacyHeaders: false,
88
87
  });
89
88
 
89
+ // Higher limiter for host-authenticated endpoints (dashboard polling, approvals, telemetry views)
90
+ const hostLimiter = rateLimit({
91
+ windowMs: 15 * 60 * 1000,
92
+ max: 600,
93
+ message: { error: 'Too many host requests. Please try again later.' },
94
+ standardHeaders: true,
95
+ legacyHeaders: false,
96
+ });
97
+
90
98
  // ─── Active Defense & IP Blacklisting (IDS) ──────────────────
91
99
  const ipViolations = new Map(); // ip → violation_count
92
100
  const blacklistedIPs = new Set();
@@ -173,7 +181,7 @@ setInterval(pruneExpired, 5 * 60 * 1000);
173
181
  // ─── Routes ──────────────────────────────────────────────────
174
182
 
175
183
  // Health
176
- app.get('/health', (_req, res) => {
184
+ app.get('/health', generalLimiter, (_req, res) => {
177
185
  res.json({ status: 'ok', uptime: process.uptime(), activeUIDs: store.size });
178
186
  });
179
187
 
@@ -253,7 +261,7 @@ app.post('/register', strictLimiter, (req, res) => {
253
261
  * Returns the ENCRYPTED blob { iv, ciphertext } for a given UID.
254
262
  * The client CLI decrypts it locally — the broker never sees plaintext.
255
263
  */
256
- app.get('/resolve/:uid', (req, res) => {
264
+ app.get('/resolve/:uid', generalLimiter, (req, res) => {
257
265
  try {
258
266
  const { uid } = req.params;
259
267
  if (!isSafeParam(uid)) {
@@ -355,7 +363,7 @@ app.post('/approval-request/:uid', generalLimiter, (req, res) => {
355
363
  });
356
364
 
357
365
  // HOST-ONLY: List approval requests (requires host token)
358
- app.get('/approval-requests/:uid', generalLimiter, (req, res) => {
366
+ app.get('/approval-requests/:uid', hostLimiter, (req, res) => {
359
367
  if (!isSafeParam(req.params.uid)) return res.status(400).json({ error: 'Invalid UID' });
360
368
  const entry = store.get(req.params.uid);
361
369
  if (!entry) return res.status(404).json({ error: 'UID not found' });
@@ -434,7 +442,7 @@ app.post('/client-info/:uid', generalLimiter, (req, res) => {
434
442
  * Host retrieves all securely encrypted client telemetry blobs.
435
443
  */
436
444
  // HOST-ONLY: View client telemetry (requires host token)
437
- app.get('/clients/:uid', generalLimiter, (req, res) => {
445
+ app.get('/clients/:uid', hostLimiter, (req, res) => {
438
446
  try {
439
447
  const { uid } = req.params;
440
448
  if (!isSafeParam(uid)) return res.status(400).json({ error: 'Invalid UID format' });