@miraj181/ipingyou 2.1.4 → 2.1.5

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.5",
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, 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,7 @@ 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-session -t ${TMUX_SESSION_NAME}`, { reject: false });
195
196
  }
196
197
  } catch {}
197
198
 
@@ -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,13 @@
1
+ import os from 'node:os';
2
+ import path from 'node:path';
3
+
4
+ export const TMUX_SESSION_NAME = 'SecureLink_Session';
5
+ export const TMUX_SOCKET_PATH = path.join(os.tmpdir(), 'ipingyou-tmux.sock');
6
+
7
+ export function tmuxSocketArgs() {
8
+ return ['-S', TMUX_SOCKET_PATH];
9
+ }
10
+
11
+ export function tmuxSocketCommand() {
12
+ return `tmux -S ${TMUX_SOCKET_PATH}`;
13
+ }
@@ -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 { TMUX_SESSION_NAME, 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,12 @@ 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 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`);
91
94
 
92
95
  const child = execa('ssh', sshArgs, {
93
96
  stdio: 'inherit',
@@ -119,9 +122,9 @@ async function connectSSH(username, hostname, privateKeyPath) {
119
122
  /**
120
123
  * Perform an SCP file transfer through the Cloudflare tunnel.
121
124
  */
122
- async function chooseRemoteTransferPath(username, hostname, privateKeyPath, direction, sharedDropPath) {
125
+ async function chooseRemoteTransferPath(username, hostname, privateKeyPath, direction, sharedDropPath, persistKnownHosts = true) {
123
126
  if (!sharedDropPath) {
124
- return promptRemotePath(username, hostname, privateKeyPath, direction === 'upload' ? 'destination' : 'source');
127
+ return promptRemotePath(username, hostname, privateKeyPath, direction === 'upload' ? 'destination' : 'source', '~', { persistKnownHosts });
125
128
  }
126
129
 
127
130
  const { dropChoice } = await inquirer.prompt([{
@@ -145,7 +148,7 @@ async function chooseRemoteTransferPath(username, hostname, privateKeyPath, dire
145
148
 
146
149
  if (dropChoice === 'drop') return sharedDropPath;
147
150
  if (dropChoice === 'drop_browse') {
148
- return promptRemotePath(username, hostname, privateKeyPath, 'source', sharedDropPath);
151
+ return promptRemotePath(username, hostname, privateKeyPath, 'source', sharedDropPath, { persistKnownHosts });
149
152
  }
150
153
  if (dropChoice === 'manual') {
151
154
  const { remotePath } = await inquirer.prompt([{
@@ -157,10 +160,10 @@ async function chooseRemoteTransferPath(username, hostname, privateKeyPath, dire
157
160
  return remotePath.trim();
158
161
  }
159
162
 
160
- return promptRemotePath(username, hostname, privateKeyPath, direction === 'upload' ? 'destination' : 'source');
163
+ return promptRemotePath(username, hostname, privateKeyPath, direction === 'upload' ? 'destination' : 'source', '~', { persistKnownHosts });
161
164
  }
162
165
 
163
- async function performSCP(username, hostname, direction, privateKeyPath, sharedDropPath = null) {
166
+ async function performSCP(username, hostname, direction, privateKeyPath, sharedDropPath = null, persistKnownHosts = true) {
164
167
  console.log('');
165
168
  console.log(chalk.bold(` 📦 SCP Transfer (${direction})`));
166
169
  console.log(chalk.dim(' ─────────────────────────────────'));
@@ -169,11 +172,11 @@ async function performSCP(username, hostname, direction, privateKeyPath, sharedD
169
172
  let remotePath;
170
173
 
171
174
  if (direction === 'upload') {
172
- remotePath = await chooseRemoteTransferPath(username, hostname, privateKeyPath, direction, sharedDropPath);
175
+ remotePath = await chooseRemoteTransferPath(username, hostname, privateKeyPath, direction, sharedDropPath, persistKnownHosts);
173
176
  localPath = await promptLocalPath('client file/folder to upload');
174
177
  } else {
175
178
  localPath = await promptLocalPath('client destination');
176
- remotePath = await chooseRemoteTransferPath(username, hostname, privateKeyPath, direction, sharedDropPath);
179
+ remotePath = await chooseRemoteTransferPath(username, hostname, privateKeyPath, direction, sharedDropPath, persistKnownHosts);
177
180
  }
178
181
 
179
182
  await showConnectionTrace('Local', 'Remote SCP');
@@ -184,7 +187,7 @@ async function performSCP(username, hostname, direction, privateKeyPath, sharedD
184
187
  const scpArgs = [
185
188
  '-r', // recursive just in case
186
189
  '-o', `ProxyCommand=${proxyCommand}`,
187
- '-o', 'StrictHostKeyChecking=accept-new',
190
+ ...getKnownHostsOptions(persistKnownHosts),
188
191
  '-o', 'IdentitiesOnly=yes',
189
192
  ...getSshControlOptions(hostname)
190
193
  ];
@@ -230,13 +233,7 @@ async function performSCP(username, hostname, direction, privateKeyPath, sharedD
230
233
  console.log(chalk.dim(' 🔍 Verifying remote SHA-256 checksum...'));
231
234
  try {
232
235
  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');
236
+ const sshArgs = buildSshArgs(hostname, privateKeyPath, [], { persistKnownHosts });
240
237
  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
238
 
242
239
  const { stdout } = await execa('ssh', sshArgs, { reject: false });
@@ -268,13 +265,13 @@ async function performSCP(username, hostname, direction, privateKeyPath, sharedD
268
265
  }
269
266
  }
270
267
 
271
- async function downloadSpecificRemotePath(username, hostname, privateKeyPath, remotePath, localPath) {
268
+ async function downloadSpecificRemotePath(username, hostname, privateKeyPath, remotePath, localPath, persistKnownHosts = true) {
272
269
  await showConnectionTrace('Local', 'Remote SCP');
273
270
  const proxyCommand = `cloudflared access tcp --hostname ${hostname}`;
274
271
  const scpArgs = [
275
272
  '-r',
276
273
  '-o', `ProxyCommand=${proxyCommand}`,
277
- '-o', 'StrictHostKeyChecking=accept-new',
274
+ ...getKnownHostsOptions(persistKnownHosts),
278
275
  '-o', 'IdentitiesOnly=yes',
279
276
  ...getSshControlOptions(hostname),
280
277
  ];
@@ -343,6 +340,7 @@ export async function startClientMode(options = {}) {
343
340
  let targetUid = null;
344
341
  let targetPassword = null;
345
342
  let targetUsername = null;
343
+ let persistKnownHosts = false;
346
344
 
347
345
  if (aliasKeys.length > 0) {
348
346
  const { useAlias } = await inquirer.prompt([
@@ -362,6 +360,7 @@ export async function startClientMode(options = {}) {
362
360
  targetUid = aliasData.uid;
363
361
  targetPassword = aliasData.password;
364
362
  targetUsername = aliasData.username;
363
+ persistKnownHosts = true;
365
364
  logSessionEvent('client_alias_selected', { alias: useAlias, uid: targetUid });
366
365
  }
367
366
  }
@@ -552,7 +551,7 @@ export async function startClientMode(options = {}) {
552
551
  await pushTelemetry(BROKER_URL, targetUid, targetPassword, username, 'one-time-download');
553
552
  logSessionEvent('client_one_time_download_start', { uid: targetUid, remotePath: payload.oneTimeSharePath });
554
553
 
555
- const success = await downloadSpecificRemotePath(username, hostname, privateKeyPath, payload.oneTimeSharePath, localDest);
554
+ const success = await downloadSpecificRemotePath(username, hostname, privateKeyPath, payload.oneTimeSharePath, localDest, persistKnownHosts);
556
555
 
557
556
  if (success) {
558
557
  console.log(chalk.green(' ✅ One-time file transfer completed successfully!'));
@@ -596,6 +595,7 @@ export async function startClientMode(options = {}) {
596
595
  saveAlias(aliasName.trim(), { uid: targetUid, password: targetPassword, username });
597
596
  console.log(chalk.green(` ✓ Saved as alias: ${chalk.bold(aliasName.trim())}\n`));
598
597
  logSessionEvent('client_alias_saved', { alias: aliasName.trim(), uid: targetUid });
598
+ persistKnownHosts = true;
599
599
  }
600
600
  }
601
601
 
@@ -620,11 +620,11 @@ export async function startClientMode(options = {}) {
620
620
  if (action === 'chat') {
621
621
  await handleClientChat(targetUid, targetPassword, payload.chatUrl);
622
622
  } else if (action === 'ssh') {
623
- await connectSSH(username, hostname, privateKeyPath);
623
+ await connectSSH(username, hostname, privateKeyPath, persistKnownHosts);
624
624
  } else if (action === 'reverse') {
625
- await performReverseForward(username, hostname, privateKeyPath);
625
+ await performReverseForward(username, hostname, privateKeyPath, persistKnownHosts);
626
626
  } else {
627
- await performSCP(username, hostname, action, privateKeyPath, payload.sharedDropPath);
627
+ await performSCP(username, hostname, action, privateKeyPath, payload.sharedDropPath, persistKnownHosts);
628
628
  }
629
629
 
630
630
  console.log('');
@@ -638,7 +638,7 @@ export async function startClientMode(options = {}) {
638
638
  ]);
639
639
 
640
640
  if (reconnect) {
641
- await handleSubsequentActions(username, hostname, privateKeyPath, targetUid, targetPassword, payload.sharedDropPath);
641
+ await handleSubsequentActions(username, hostname, privateKeyPath, targetUid, targetPassword, payload.sharedDropPath, persistKnownHosts);
642
642
  }
643
643
 
644
644
  logSessionEvent('client_session_exit');
@@ -650,7 +650,7 @@ export async function startClientMode(options = {}) {
650
650
  * params: { brokerUrl, uid, password, username, direction, localPath, remotePath }
651
651
  */
652
652
  export async function performSCPNonInteractive(params = {}) {
653
- const { brokerUrl, uid, password, username, direction, localPath, remotePath } = params;
653
+ const { brokerUrl, uid, password, username, direction, localPath, remotePath, persistKnownHosts = true } = params;
654
654
  if (!brokerUrl || !uid || !password) throw new Error('Missing broker connection info');
655
655
 
656
656
  const payload = await resolveUID(brokerUrl, uid, password);
@@ -662,7 +662,7 @@ export async function performSCPNonInteractive(params = {}) {
662
662
 
663
663
  // Build scp args similar to performSCP
664
664
  const proxyCommand = `cloudflared access tcp --hostname ${hostname}`;
665
- const scpArgs = ['-r', '-o', `ProxyCommand=${proxyCommand}`, '-o', 'StrictHostKeyChecking=accept-new', '-o', 'IdentitiesOnly=yes', ...getSshControlOptions(hostname)];
665
+ const scpArgs = ['-r', '-o', `ProxyCommand=${proxyCommand}`, ...getKnownHostsOptions(persistKnownHosts), '-o', 'IdentitiesOnly=yes', ...getSshControlOptions(hostname)];
666
666
  if (privateKeyPath) scpArgs.push('-i', privateKeyPath, '-o', 'IdentityAgent=none');
667
667
 
668
668
  const remoteSpec = `${username}@${hostname}:${formatScpRemotePath(remotePath)}`;
@@ -707,7 +707,7 @@ async function handleClientChat(uid, password, cachedChatUrl) {
707
707
  }
708
708
  }
709
709
 
710
- async function handleSubsequentActions(username, hostname, privateKeyPath, targetUid, targetPassword, sharedDropPath = null) {
710
+ async function handleSubsequentActions(username, hostname, privateKeyPath, targetUid, targetPassword, sharedDropPath = null, persistKnownHosts = true) {
711
711
  const { action } = await inquirer.prompt([
712
712
  {
713
713
  type: 'list',
@@ -732,11 +732,11 @@ async function handleSubsequentActions(username, hostname, privateKeyPath, targe
732
732
  if (action === 'chat') {
733
733
  await handleClientChat(targetUid, targetPassword, null);
734
734
  } else if (action === 'ssh') {
735
- await connectSSH(username, hostname, privateKeyPath);
735
+ await connectSSH(username, hostname, privateKeyPath, persistKnownHosts);
736
736
  } else if (action === 'reverse') {
737
- await performReverseForward(username, hostname, privateKeyPath);
737
+ await performReverseForward(username, hostname, privateKeyPath, persistKnownHosts);
738
738
  } else {
739
- await performSCP(username, hostname, action, privateKeyPath, sharedDropPath);
739
+ await performSCP(username, hostname, action, privateKeyPath, sharedDropPath, persistKnownHosts);
740
740
  }
741
741
 
742
742
  const { reconnect } = await inquirer.prompt([
@@ -749,11 +749,11 @@ async function handleSubsequentActions(username, hostname, privateKeyPath, targe
749
749
  ]);
750
750
 
751
751
  if (reconnect) {
752
- await handleSubsequentActions(username, hostname, privateKeyPath, targetUid, targetPassword, sharedDropPath);
752
+ await handleSubsequentActions(username, hostname, privateKeyPath, targetUid, targetPassword, sharedDropPath, persistKnownHosts);
753
753
  }
754
754
  }
755
755
 
756
- async function performReverseForward(username, hostname, privateKeyPath) {
756
+ async function performReverseForward(username, hostname, privateKeyPath, persistKnownHosts = true) {
757
757
  console.log('');
758
758
  console.log(chalk.bold.cyan(' 🔄 Reverse Port Forwarding'));
759
759
  console.log(chalk.dim(' ──────────────────────────────────────'));
@@ -782,7 +782,7 @@ async function performReverseForward(username, hostname, privateKeyPath) {
782
782
  '-N',
783
783
  '-R', portMap,
784
784
  '-o', 'ExitOnForwardFailure=yes',
785
- ]);
785
+ ], { persistKnownHosts });
786
786
  sshArgs.push(`${username}@${hostname}`);
787
787
 
788
788
  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, 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';
@@ -274,12 +275,37 @@ async function startLocalHostDashboard(uid, password, serviceConfig, sessionStat
274
275
  } catch { }
275
276
  };
276
277
 
277
- const iv = setInterval(push, 3000);
278
+ const iv = setInterval(push, 5000);
278
279
  req.on('close', () => { clearInterval(iv); closed = true; });
279
280
  // send initial payload
280
281
  await push();
281
282
  });
282
283
 
284
+ app.get('/api/clients', async (_req, res) => {
285
+ try {
286
+ const brokerRes = await fetch(`${BROKER_URL}/clients/${uid}`, {
287
+ headers: sessionState.hostToken ? { 'x-host-token': sessionState.hostToken } : {}
288
+ });
289
+ if (!brokerRes.ok) {
290
+ const data = await brokerRes.json().catch(() => ({}));
291
+ return res.status(brokerRes.status).json({ error: data.error || 'Failed to fetch clients' });
292
+ }
293
+ const data = await brokerRes.json();
294
+ const clients = (data.clients || []).map((clientBlob) => {
295
+ try {
296
+ const decrypted = decrypt(clientBlob.iv, clientBlob.ciphertext, password, clientBlob.salt);
297
+ const t = JSON.parse(decrypted);
298
+ return { ...t, seenAt: clientBlob.seenAt || null };
299
+ } catch {
300
+ return { error: 'decrypt_failed', seenAt: clientBlob.seenAt || null };
301
+ }
302
+ });
303
+ res.json({ clients });
304
+ } catch (err) {
305
+ res.status(500).json({ error: err.message });
306
+ }
307
+ });
308
+
283
309
  // CSRF Protection Middleware for all mutating endpoints
284
310
  app.use((req, res, next) => {
285
311
  if (req.method !== 'POST') return next();
@@ -404,6 +430,16 @@ function showToast(msg) {
404
430
  t.classList.add('show');
405
431
  setTimeout(() => t.classList.remove('show'), 2500);
406
432
  }
433
+
434
+ function escapeHtml(value) {
435
+ return String(value || '').replace(/[&<>"']/g, (m) => ({
436
+ '&': '&amp;',
437
+ '<': '&lt;',
438
+ '>': '&gt;',
439
+ '"': '&quot;',
440
+ "'": '&#39;',
441
+ })[m]);
442
+ }
407
443
 
408
444
  function updateUptime() {
409
445
  const diff = Math.floor((Date.now() - startedAt.getTime()) / 1000);
@@ -490,14 +526,47 @@ async function revokeSession() {
490
526
  // Poll client telemetry (separate from SSE for simplicity)
491
527
  async function pollClients() {
492
528
  try {
493
- const res = await fetch('/api/status');
529
+ const res = await fetch('/api/clients');
494
530
  if (!res.ok) return;
495
- // Client data is fetched from broker by SSE push. Let's also add a client endpoint.
531
+ const data = await res.json();
532
+ renderClients(data.clients || []);
496
533
  } catch {}
497
534
  }
535
+
536
+ function renderClients(clients) {
537
+ const container = document.getElementById('clients');
538
+ if (!clients || clients.length === 0) {
539
+ container.innerHTML = '<p class="empty">No clients connected yet</p>';
540
+ document.getElementById('client-count').textContent = '';
541
+ return;
542
+ }
543
+
544
+ document.getElementById('client-count').textContent = '(' + clients.length + ' connected)';
545
+ let html = '';
546
+ clients.forEach((c, idx) => {
547
+ if (c.error) {
548
+ html += '<div class="client-card"><strong>Client #' + (idx + 1) + '</strong> — payload decryption failed</div>';
549
+ return;
550
+ }
551
+ const when = c.time || (c.seenAt ? new Date(c.seenAt).toLocaleTimeString() : 'Unknown');
552
+ html += '<div class="client-card">'
553
+ + '<strong>' + escapeHtml(c.username || 'Unknown') + '</strong>'
554
+ + ' — ' + escapeHtml(c.action || 'connected') + '<br>'
555
+ + '<span class="meta">IP: ' + escapeHtml(c.ip || 'Unknown') + '</span><br>'
556
+ + '<span class="meta">OS: ' + escapeHtml(c.os || 'Unknown') + '</span><br>'
557
+ + '<span class="meta">CPU: ' + escapeHtml(c.cpu || 'Unknown') + '</span><br>'
558
+ + '<span class="meta">RAM: ' + escapeHtml(c.ram || 'Unknown') + '</span><br>'
559
+ + '<span class="meta">Time: ' + escapeHtml(when) + '</span>'
560
+ + '</div>';
561
+ });
562
+ container.innerHTML = html;
563
+ }
564
+
565
+ setInterval(pollClients, 5000);
566
+ pollClients();
498
567
  </script>
499
568
  </body></html>`);
500
- });
569
+ });
501
570
 
502
571
  return new Promise((resolve) => {
503
572
  const server = app.listen(0, '127.0.0.1', async () => {
@@ -742,7 +811,7 @@ async function hostDashboard(uid, password, serviceConfig, tunnelProcess, sessio
742
811
 
743
812
  try {
744
813
  await execaCommand('tmux -V', { reject: true });
745
- const sessionCheck = await execa('tmux', ['has-session', '-t', 'SecureLink_Session'], { reject: false });
814
+ const sessionCheck = await execa('tmux', [...tmuxSocketArgs(), 'has-session', '-t', TMUX_SESSION_NAME], { reject: false });
746
815
  if (sessionCheck.exitCode !== 0) {
747
816
  console.log(chalk.yellow(' ⚠️ No mirrored terminal session is active yet.'));
748
817
  console.log(chalk.dim(' A client must choose "Connect via SSH" first. SCP-only clients do not create a tmux session.'));
@@ -750,7 +819,7 @@ async function hostDashboard(uid, password, serviceConfig, tunnelProcess, sessio
750
819
  logSessionEvent('host_mirror_missing_session', {}, 'warn');
751
820
  return waitForAction();
752
821
  }
753
- await execaCommand('tmux attach -t SecureLink_Session -r', { stdio: 'inherit' });
822
+ await execa('tmux', [...tmuxSocketArgs(), 'attach', '-t', TMUX_SESSION_NAME, '-r'], { stdio: 'inherit', reject: false });
754
823
  logSessionEvent('host_mirror_attached');
755
824
  } catch (err) {
756
825
  console.log(chalk.yellow(' ⚠️ Could not attach to tmux.'));
@@ -815,7 +884,7 @@ async function hostDashboard(uid, password, serviceConfig, tunnelProcess, sessio
815
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 });
816
885
  } else {
817
886
  await execaCommand("pkill -f 'sshd:.*@'", { shell: true, reject: false });
818
- await execaCommand('tmux kill-session -t SecureLink_Session', { reject: false });
887
+ await execa('tmux', [...tmuxSocketArgs(), 'kill-session', '-t', TMUX_SESSION_NAME], { reject: false });
819
888
  }
820
889
  spinner.succeed('All client SSH sessions terminated');
821
890
  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' });