@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 +1 -1
- package/src/cli.js +2 -0
- package/src/lib/cleanup.js +10 -1
- package/src/lib/path-browser.js +5 -4
- package/src/lib/ssh.js +15 -2
- package/src/lib/tmux.js +23 -0
- package/src/modes/client.js +36 -35
- package/src/modes/host.js +131 -9
- package/src/server.js +14 -6
package/package.json
CHANGED
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
|
|
package/src/lib/cleanup.js
CHANGED
|
@@ -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(
|
|
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
|
|
package/src/lib/path-browser.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
54
|
+
...getKnownHostsOptions(persistKnownHosts),
|
|
42
55
|
'-o', 'IdentitiesOnly=yes',
|
|
43
56
|
...getSshControlOptions(hostname),
|
|
44
57
|
...extraOptions,
|
package/src/lib/tmux.js
ADDED
|
@@ -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
|
+
}
|
package/src/modes/client.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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}`,
|
|
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,
|
|
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
|
+
'&': '&',
|
|
468
|
+
'<': '<',
|
|
469
|
+
'>': '>',
|
|
470
|
+
'"': '"',
|
|
471
|
+
"'": ''',
|
|
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/
|
|
560
|
+
const res = await fetch('/api/clients');
|
|
494
561
|
if (!res.ok) return;
|
|
495
|
-
|
|
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
|
|
746
|
-
if (
|
|
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
|
-
|
|
754
|
-
|
|
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
|
|
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
|
|
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',
|
|
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',
|
|
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' });
|