@miraj181/ipingyou 2.1.3 โ†’ 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/README.md CHANGED
@@ -25,7 +25,7 @@ No firewalls to configure. No port forwarding. No plaintext leakage.
25
25
  * ๐Ÿงญ **Approval Gate**: Require the Host to explicitly approve clients before they receive tunnel/key material.
26
26
  * ๐Ÿ“ฆ **One-Time File Share**: Serve a single file/folder over SCP and revoke after use.
27
27
  * ๐ŸŒ **HTTP & TCP Exposure**: Share a local web app or any TCP service (DB/RDP/VNC) beyond SSH.
28
- * ๐Ÿ“‚ **Shared Drop Folder**: Auto-prepared dropbox folder for safe file transfers (macOS friendly).
28
+ * ๐Ÿ“‚ **Shared Drop Folder**: Auto-prepared dropbox folder for safe file transfers (macOS friendly), removed on exit.
29
29
  * ๐Ÿงพ **Live Session Logs**: Host/client/broker write ephemeral per-session logs with actions and request/response status, removed on exit.
30
30
 
31
31
  ---
@@ -60,22 +60,43 @@ securelink
60
60
 
61
61
  ## ๐Ÿ”’ Zero-Knowledge Architecture
62
62
 
63
- The public broker server exists solely to rendezvous connections. It is fundamentally a **"Dumb Pipe"**.
63
+ The public broker server exists solely to rendezvous connections and approvals. It is fundamentally a **"Dumb Pipe"**.
64
+
65
+ ### Session Bootstrap & Data Path
64
66
 
65
67
  ```mermaid
66
68
  graph LR
67
- H[Host CLI] -->|AES-256-CBC Encrypted Payload| B((Broker Relay))
68
- B -->|Encrypted Payload| C[Client CLI]
69
+ H[Host CLI] -->|AES-256-CBC Encrypted Session Payload| B((Broker Relay))
70
+ H -->|Host Auth Token for approvals and telemetry| B
71
+ B -->|Encrypted Session Payload| C[Client CLI]
69
72
  C -->|Locally Decrypts Password| C
70
- C -->|Direct Cloudflare SSH| H
73
+ C -->|Direct Cloudflare SSH/TCP| H
71
74
  C -->|E2E AES-GCM WebSockets| H
72
75
  ```
73
76
 
74
- 1. **Host** starts up, spawns `cloudflared` tunnels for SSH and Chat, and generates a random, offline **AES-256 Session Password**.
75
- 2. **Host** encrypts the tunnel data with the password and sends the *ciphertext* to the Broker alongside a short UID.
77
+ 1. **Host** starts up, spawns `cloudflared` tunnels for SSH/HTTP/TCP and Chat, then generates a random **AES-256 Session Password** plus a **host-only auth token**.
78
+ 2. **Host** encrypts the session payload with the password and registers the ciphertext (plus the host token) with the Broker under a short UID.
76
79
  3. **Client** runs `ipingyou connect`, enters the UID and Password.
77
- 4. **Client** fetches the ciphertext, decrypts it locally, and connects directly via SSH and WebSockets.
78
- 5. On `Ctrl+C`, `tree-kill` initiates a graceful shutdown, revokes the UID from the broker, and scrubs `/tmp` memory.
80
+ 4. **Client** fetches the ciphertext, decrypts it locally, and connects directly via SSH or WebSockets.
81
+ 5. On `Ctrl+C`, `tree-kill` initiates a graceful shutdown, revokes the UID from the broker, and removes session artifacts.
82
+
83
+ ### Approval Gate Flow (Optional)
84
+
85
+ ```mermaid
86
+ sequenceDiagram
87
+ participant C as Client CLI
88
+ participant B as Broker Relay
89
+ participant H as Host CLI
90
+ C->>B: approval-request encrypted metadata
91
+ H->>B: fetch approvals with x-host-token
92
+ H->>B: approve/deny with x-host-token
93
+ C->>B: poll approval status
94
+ B-->>C: approved/denied
95
+ ```
96
+
97
+ 1. **Client** submits encrypted approval metadata (username, host, intent) to the Broker.
98
+ 2. **Host** lists and decides approvals using its host-only auth token; the Broker never shares this token with clients.
99
+ 3. **Client** polls for approval status and proceeds only when approved.
79
100
 
80
101
  ---
81
102
 
@@ -111,6 +132,8 @@ These alerts (e.g., "AI-detected potential code anomaly", "Shell access", "Netwo
111
132
  | `ipingyou service install` | ๐Ÿ‘ป Installs Host mode as an always-on background daemon. |
112
133
  | `ipingyou service stop` | Stops and removes the background daemon. |
113
134
  | `ipingyou service status` | Shows background daemon status. |
135
+ | `ipingyou allowlist` | Manage the AI command allowlist (list/add/remove). |
136
+ | `ipingyou history` | View session event logs from `~/.ipingyou/logs`. |
114
137
 
115
138
  ---
116
139
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miraj181/ipingyou",
3
- "version": "2.1.3",
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';
@@ -122,6 +123,8 @@ function showRichHelp() {
122
123
  console.log(` โ€ข Cloudflare Tunnels punch through NAT/Firewalls securely.`);
123
124
  console.log(` โ€ข ${chalk.green('End-to-End Encryption')}: Tunnel URLs are AES-256 encrypted locally.`);
124
125
  console.log(` โ€ข The Broker never sees your plaintext URL, only ciphertext.`);
126
+ console.log(` โ€ข ${chalk.green('Host Auth Token')}: Host-only token gates approvals & telemetry access.`);
127
+ console.log(` โ€ข ${chalk.green('Approval Gate')}: Clients submit encrypted metadata; Host approves/denies.`);
125
128
  console.log('');
126
129
 
127
130
  console.log(chalk.bold.white(' ๐Ÿ”ฅ Advanced Features:'));
@@ -130,6 +133,8 @@ function showRichHelp() {
130
133
  console.log(` โ€ข ${chalk.green('E2E Chat Room')} : Real-time Web Crypto AES-GCM secure chat UI for Host & Clients.`);
131
134
  console.log(` โ€ข ${chalk.green('Daemonization')} : Run Host mode as a background service via PM2.`);
132
135
  console.log(` โ€ข ${chalk.green('Panic Kill-Switch')} : Instantly purge all processes, configurations, and traces.`);
136
+ console.log(` โ€ข ${chalk.green('Shared Drop Folder')} : Session dropbox auto-removed on exit.`);
137
+ console.log(` โ€ข ${chalk.green('Live Session Logs')} : Host/client/broker events written per session.`);
133
138
  console.log('');
134
139
 
135
140
  console.log(chalk.bold.white(' ๐Ÿ’ก Examples:'));
@@ -159,6 +164,7 @@ function fatal(context, err) {
159
164
  stackLines.forEach(line => console.error(chalk.dim(` ${line.trim()}`)));
160
165
  }
161
166
  console.error('');
167
+ cleanupSessionLog();
162
168
  cleanupAll().finally(() => process.exit(1));
163
169
  }
164
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';
@@ -193,6 +194,23 @@ async function prepareSharedDropFolder(uid) {
193
194
  return dropPath;
194
195
  }
195
196
 
197
+ async function cleanupSharedDropFolder(dropPath, uid) {
198
+ if (!dropPath) return;
199
+ const expectedPath = path.join(os.homedir(), `ipingyou-dropbox-${uid}`);
200
+ if (path.resolve(dropPath) !== path.resolve(expectedPath)) {
201
+ console.log(chalk.yellow(' Skipping drop folder cleanup (unexpected path).'));
202
+ return;
203
+ }
204
+ try {
205
+ const stat = await fs.promises.lstat(dropPath).catch(() => null);
206
+ if (!stat || !stat.isDirectory() || stat.isSymbolicLink()) return;
207
+ console.log(chalk.dim(' Removing shared drop folder...'));
208
+ await fs.promises.rm(dropPath, { recursive: true, force: true });
209
+ } catch (err) {
210
+ console.log(chalk.yellow(` Could not remove drop folder: ${err.message}`));
211
+ }
212
+ }
213
+
196
214
  function showMacPrivacyPreflight(sharedDropPath) {
197
215
  if (process.platform !== 'darwin') return;
198
216
 
@@ -257,12 +275,37 @@ async function startLocalHostDashboard(uid, password, serviceConfig, sessionStat
257
275
  } catch { }
258
276
  };
259
277
 
260
- const iv = setInterval(push, 3000);
278
+ const iv = setInterval(push, 5000);
261
279
  req.on('close', () => { clearInterval(iv); closed = true; });
262
280
  // send initial payload
263
281
  await push();
264
282
  });
265
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
+
266
309
  // CSRF Protection Middleware for all mutating endpoints
267
310
  app.use((req, res, next) => {
268
311
  if (req.method !== 'POST') return next();
@@ -387,6 +430,16 @@ function showToast(msg) {
387
430
  t.classList.add('show');
388
431
  setTimeout(() => t.classList.remove('show'), 2500);
389
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
+ }
390
443
 
391
444
  function updateUptime() {
392
445
  const diff = Math.floor((Date.now() - startedAt.getTime()) / 1000);
@@ -473,14 +526,47 @@ async function revokeSession() {
473
526
  // Poll client telemetry (separate from SSE for simplicity)
474
527
  async function pollClients() {
475
528
  try {
476
- const res = await fetch('/api/status');
529
+ const res = await fetch('/api/clients');
477
530
  if (!res.ok) return;
478
- // 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 || []);
479
533
  } catch {}
480
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();
481
567
  </script>
482
568
  </body></html>`);
483
- });
569
+ });
484
570
 
485
571
  return new Promise((resolve) => {
486
572
  const server = app.listen(0, '127.0.0.1', async () => {
@@ -725,7 +811,7 @@ async function hostDashboard(uid, password, serviceConfig, tunnelProcess, sessio
725
811
 
726
812
  try {
727
813
  await execaCommand('tmux -V', { reject: true });
728
- 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 });
729
815
  if (sessionCheck.exitCode !== 0) {
730
816
  console.log(chalk.yellow(' โš ๏ธ No mirrored terminal session is active yet.'));
731
817
  console.log(chalk.dim(' A client must choose "Connect via SSH" first. SCP-only clients do not create a tmux session.'));
@@ -733,7 +819,7 @@ async function hostDashboard(uid, password, serviceConfig, tunnelProcess, sessio
733
819
  logSessionEvent('host_mirror_missing_session', {}, 'warn');
734
820
  return waitForAction();
735
821
  }
736
- 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 });
737
823
  logSessionEvent('host_mirror_attached');
738
824
  } catch (err) {
739
825
  console.log(chalk.yellow(' โš ๏ธ Could not attach to tmux.'));
@@ -798,7 +884,7 @@ async function hostDashboard(uid, password, serviceConfig, tunnelProcess, sessio
798
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 });
799
885
  } else {
800
886
  await execaCommand("pkill -f 'sshd:.*@'", { shell: true, reject: false });
801
- await execaCommand('tmux kill-session -t SecureLink_Session', { reject: false });
887
+ await execa('tmux', [...tmuxSocketArgs(), 'kill-session', '-t', TMUX_SESSION_NAME], { reject: false });
802
888
  }
803
889
  spinner.succeed('All client SSH sessions terminated');
804
890
  logSessionEvent('host_sessions_terminated');
@@ -953,6 +1039,7 @@ export async function startHostMode() {
953
1039
  serviceConfig.sharedDropPath = await prepareSharedDropFolder(uid);
954
1040
  console.log(chalk.green(` โœ“ Shared drop folder ready: ${serviceConfig.sharedDropPath}`));
955
1041
  showMacPrivacyPreflight(serviceConfig.sharedDropPath);
1042
+ addCleanupHook(() => cleanupSharedDropFolder(serviceConfig.sharedDropPath, uid));
956
1043
  } catch (err) {
957
1044
  console.log(chalk.yellow(` โš ๏ธ Could not prepare shared drop folder: ${err.message}`));
958
1045
  }
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' });