@miraj181/ipingyou 2.1.19 → 2.1.23

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/src/modes/host.js CHANGED
@@ -22,18 +22,17 @@ import { createRequire } from 'node:module';
22
22
  import fs from 'node:fs';
23
23
  import os from 'node:os';
24
24
  import crypto from 'node:crypto';
25
- import { generateUID } from '../lib/uid.js';
26
- import { openUrl } from '../lib/open-url.js';
27
- import { decryptAsync } from '../lib/crypto.js';
28
- import { cleanupAll, killProcessTree, trackPID, untrackPID, setRevokeOnExit, addCleanupHook } from '../lib/cleanup.js';
29
- import { detectOS } from '../lib/platform.js';
30
- import { createSpinner, networkSpinner, typeText } from '../lib/animations.js';
31
- import { startChatServer, openLocalChatUI } from '../lib/chat.js';
32
- import { secureSensitive } from '../lib/secure-print.js';
33
- import { spawnTunnelSupervised } from '../lib/tunnel.js';
34
- import { decideApprovalRequest, fetchApprovalRequests, pingBroker, registerWithBroker, revokeUID } from '../lib/broker.js';
35
- import { cleanupSessionLog, getSessionLogPath, initSessionLog, logSessionEvent, recordEvent } from '../lib/session-log.js';
36
- import { TMUX_SESSION_NAME, TMUX_SESSION_PREFIX, tmuxSocketArgs } from '../lib/tmux.js';
25
+ import { generateUID } from '../lib/mod/uid.js';
26
+ import { openUrl } from '../lib/mod/open-url.js';
27
+ import { decryptAsync, encryptAsync } from '../lib/mod/crypto.js';
28
+ import { cleanupAll, killProcessTree, trackPID, untrackPID, setRevokeOnExit, addCleanupHook } from '../lib/mod/cleanup.js';
29
+ import { detectOS, isLinuxSSHActive, startLinuxSSH } from '../lib/services/platform.js';
30
+ import { createSpinner, networkSpinner, typeText } from '../lib/mod/animations.js';
31
+ import { startChatServer, openLocalChatUI } from '../lib/services/chat.js';
32
+ import { secureSensitive } from '../lib/mod/secure-print.js';
33
+ import { spawnTunnelSupervised } from '../lib/services/tunnel.js';
34
+ import { decideApprovalRequest, fetchApprovalRequests, pingBroker, registerWithBroker, revokeUID } from '../lib/client/broker.js';
35
+ import { cleanupSessionLog, getSessionLogPath, initSessionLog, logSessionEvent, recordEvent } from '../lib/mod/session-log.js';
37
36
 
38
37
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
39
38
  let BROKER_URL = process.env.BROKER_URL || 'https://ipingyou.onrender.com';
@@ -68,18 +67,13 @@ async function ensureSSHRunning() {
68
67
 
69
68
  try {
70
69
  if (osInfo.isLinux) {
71
- try {
72
- await execa('systemctl', ['is-active', 'ssh'], { reject: true });
70
+ const active = await isLinuxSSHActive();
71
+ if (active) {
73
72
  spinner.succeed('SSH service is active');
74
- } catch {
73
+ } else {
75
74
  spinner.text = 'Starting SSH service...';
76
- try {
77
- await execa('sudo', ['systemctl', 'start', 'ssh'], { stdio: 'inherit' });
78
- spinner.succeed('SSH service started');
79
- } catch {
80
- await execa('sudo', ['systemctl', 'start', 'sshd'], { stdio: 'inherit' });
81
- spinner.succeed('SSH service started (sshd)');
82
- }
75
+ await startLinuxSSH();
76
+ spinner.succeed('SSH service started');
83
77
  }
84
78
  } else if (osInfo.isMac) {
85
79
  try {
@@ -116,82 +110,191 @@ async function ensureSSHRunning() {
116
110
  }
117
111
  }
118
112
 
119
- /**
120
- * Ensure tmux is installed for terminal mirroring.
121
- */
122
- async function ensureTmuxInstalled() {
123
- const osInfo = detectOS();
124
- if (osInfo.isWindows) return;
113
+ function formatAndPrintLogLine(line) {
114
+ try {
115
+ const data = JSON.parse(line);
116
+ const time = new Date(data.time || data.timestamp).toLocaleTimeString();
117
+ const typeLabel = chalk.bold(data.type);
118
+
119
+ let color = chalk.white;
120
+ let icon = 'ℹ️';
121
+ if (data.level === 'warn') {
122
+ color = chalk.yellow;
123
+ icon = '⚠️';
124
+ } else if (data.level === 'error') {
125
+ color = chalk.red;
126
+ icon = '❌';
127
+ } else if (data.type.includes('success') || data.type.includes('complete') || data.type.includes('granted') || data.type.includes('start')) {
128
+ color = chalk.green;
129
+ icon = '✓';
130
+ }
131
+
132
+ const detailsStr = Object.keys(data.details || {}).length > 0
133
+ ? chalk.dim(JSON.stringify(data.details))
134
+ : '';
135
+
136
+ console.log(` [${chalk.dim(time)}] ${color(icon)} ${color(typeLabel)} ${detailsStr}`);
137
+ } catch {
138
+ if (line.trim()) {
139
+ console.log(` ${chalk.dim(line)}`);
140
+ }
141
+ }
142
+ }
143
+
144
+ async function viewLiveClientLogs(sharedDropPath) {
145
+ if (!sharedDropPath) {
146
+ console.log(chalk.red(' ❌ Error: Shared drop path is not configured.'));
147
+ return;
148
+ }
125
149
 
126
- const spinner = createSpinner('Checking tmux installation...', networkSpinner).start();
127
150
  try {
128
- try {
129
- await execa('tmux', ['-V'], { reject: true });
130
- spinner.succeed('tmux is installed (Terminal Mirroring available)');
131
- } catch {
132
- spinner.text = 'tmux not found. Attempting to install...';
133
- if (osInfo.isLinux) {
134
- if (fs.existsSync('/usr/bin/apt') || fs.existsSync('/usr/bin/apt-get')) {
135
- await execa('sudo', ['apt-get', 'update', '-qq'], { stdio: 'inherit' });
136
- await execa('sudo', ['apt-get', 'install', '-y', 'tmux'], { stdio: 'inherit' });
137
- } else if (fs.existsSync('/usr/bin/dnf')) {
138
- await execa('sudo', ['dnf', 'install', '-y', 'tmux'], { stdio: 'inherit' });
139
- } else if (fs.existsSync('/usr/bin/yum')) {
140
- await execa('sudo', ['yum', 'install', '-y', 'tmux'], { stdio: 'inherit' });
141
- } else if (fs.existsSync('/usr/bin/pacman')) {
142
- await execa('sudo', ['pacman', '-S', '--noconfirm', 'tmux'], { stdio: 'inherit' });
143
- } else if (fs.existsSync('/sbin/apk')) {
144
- await execa('sudo', ['apk', 'add', 'tmux'], { stdio: 'inherit' });
145
- } else {
146
- throw new Error('Unsupported Linux package manager');
147
- }
148
- spinner.succeed('tmux installed successfully (Terminal Mirroring available)');
149
- } else if (osInfo.isMac) {
150
- try {
151
- await execa('brew', ['install', 'tmux'], { stdio: 'inherit' });
152
- spinner.succeed('tmux installed successfully (Terminal Mirroring available)');
153
- } catch {
154
- throw new Error('Homebrew is required to install tmux on macOS');
155
- }
151
+ const files = await fs.promises.readdir(sharedDropPath);
152
+ const clientLogs = files.filter(f => f.startsWith('client-') && f.endsWith('.log'));
153
+
154
+ if (clientLogs.length === 0) {
155
+ console.log(chalk.yellow('\n No active client logs found in the shared drop folder.'));
156
+ console.log(chalk.dim(' Clients automatically share their logs once connected.'));
157
+ return;
158
+ }
159
+
160
+ const { selectedLog } = await inquirer.prompt([
161
+ {
162
+ type: 'list',
163
+ name: 'selectedLog',
164
+ message: 'Select a client to monitor activity in live:',
165
+ choices: clientLogs.map(f => {
166
+ const clientName = f.replace('client-', '').replace('.log', '');
167
+ return { name: `👤 ${clientName}`, value: f };
168
+ })
169
+ }
170
+ ]);
171
+
172
+ const logFilePath = path.join(sharedDropPath, selectedLog);
173
+ const clientName = selectedLog.replace('client-', '').replace('.log', '');
174
+
175
+ console.log('');
176
+ console.log(chalk.bold.cyan(` 📊 Live Monitor: ${clientName}`));
177
+ console.log(chalk.dim(' ──────────────────────────────────────────────────'));
178
+ console.log(chalk.dim(' Showing log stream. Press Enter to exit.'));
179
+ console.log('');
180
+
181
+ let filePosition = 0;
182
+ let keepWatching = true;
183
+
184
+ if (fs.existsSync(logFilePath)) {
185
+ const stats = fs.statSync(logFilePath);
186
+ filePosition = stats.size;
187
+ const content = fs.readFileSync(logFilePath, 'utf8');
188
+ const lines = content.split('\n').filter(Boolean);
189
+ const lastLines = lines.slice(-10);
190
+ for (const line of lastLines) {
191
+ formatAndPrintLogLine(line);
156
192
  }
157
193
  }
194
+
195
+ const intervalId = setInterval(() => {
196
+ if (!keepWatching) return;
197
+ try {
198
+ if (!fs.existsSync(logFilePath)) return;
199
+ const stats = fs.statSync(logFilePath);
200
+ if (stats.size > filePosition) {
201
+ const fd = fs.openSync(logFilePath, 'r');
202
+ const bufferSize = stats.size - filePosition;
203
+ const buffer = Buffer.allocUnsafe(bufferSize);
204
+ fs.readSync(fd, buffer, 0, bufferSize, filePosition);
205
+ fs.closeSync(fd);
206
+
207
+ filePosition = stats.size;
208
+ const newContent = buffer.toString('utf8');
209
+ const lines = newContent.split('\n').filter(Boolean);
210
+ for (const line of lines) {
211
+ formatAndPrintLogLine(line);
212
+ }
213
+ }
214
+ } catch {
215
+ // Ignore file access errors
216
+ }
217
+ }, 1000);
218
+
219
+ await inquirer.prompt([{
220
+ type: 'input',
221
+ name: 'exit',
222
+ message: 'Press Enter to stop monitoring...'
223
+ }]);
224
+
225
+ keepWatching = false;
226
+ clearInterval(intervalId);
227
+ console.log(chalk.cyan(' Stopped monitoring client logs.'));
158
228
  } catch (err) {
159
- spinner.fail(`tmux check/install failed: ${err.message}`);
160
- console.log(chalk.dim(' Terminal Mirroring feature will not be available.'));
229
+ console.log(chalk.red(` Could not read client logs: ${err.message}`));
161
230
  }
162
231
  }
163
232
 
164
- function isSecureLinkSession(name) {
165
- return name === TMUX_SESSION_NAME || name.startsWith(TMUX_SESSION_PREFIX);
166
- }
233
+ async function viewHostLiveLogs() {
234
+ const logFilePath = getSessionLogPath();
235
+ if (!logFilePath || !fs.existsSync(logFilePath)) {
236
+ console.log(chalk.red(" ❌ Error: Host session log is not active or doesn't exist yet."));
237
+ return;
238
+ }
167
239
 
168
- async function listTmuxSessions(socketArgs = []) {
169
- const result = await execa('tmux', [...socketArgs, 'list-sessions', '-F', '#{session_name}|#{session_created}'], { reject: false });
170
- if (result.exitCode !== 0) return [];
171
- return result.stdout
172
- .split(/\r?\n/)
173
- .filter(Boolean)
174
- .map(line => {
175
- const [name, createdAt] = line.split('|');
176
- return { name, createdAt: Number(createdAt) || null };
177
- });
178
- }
240
+ console.log('');
241
+ console.log(chalk.bold.cyan(` 📊 Live Monitor: Host Session Activity`));
242
+ console.log(chalk.dim(' ──────────────────────────────────────────────────'));
243
+ console.log(chalk.dim(' Showing log stream. Press Enter to exit.'));
244
+ console.log('');
179
245
 
180
- async function getMirrorableSessions() {
181
- const sessions = [];
182
- const customSessions = await listTmuxSessions(tmuxSocketArgs());
183
- customSessions
184
- .filter(s => isSecureLinkSession(s.name))
185
- .forEach(s => sessions.push({ ...s, socketArgs: tmuxSocketArgs(), source: 'custom' }));
246
+ let filePosition = 0;
247
+ let keepWatching = true;
248
+
249
+ try {
250
+ const stats = fs.statSync(logFilePath);
251
+ filePosition = stats.size;
252
+ const content = fs.readFileSync(logFilePath, 'utf8');
253
+ const lines = content.split('\n').filter(Boolean);
254
+ const lastLines = lines.slice(-10);
255
+ for (const line of lastLines) {
256
+ formatAndPrintLogLine(line);
257
+ }
258
+
259
+ const intervalId = setInterval(() => {
260
+ if (!keepWatching) return;
261
+ try {
262
+ if (!fs.existsSync(logFilePath)) return;
263
+ const stats = fs.statSync(logFilePath);
264
+ if (stats.size > filePosition) {
265
+ const fd = fs.openSync(logFilePath, 'r');
266
+ const bufferSize = stats.size - filePosition;
267
+ const buffer = Buffer.allocUnsafe(bufferSize);
268
+ fs.readSync(fd, buffer, 0, bufferSize, filePosition);
269
+ fs.closeSync(fd);
270
+
271
+ filePosition = stats.size;
272
+ const newContent = buffer.toString('utf8');
273
+ const lines = newContent.split('\n').filter(Boolean);
274
+ for (const line of lines) {
275
+ formatAndPrintLogLine(line);
276
+ }
277
+ }
278
+ } catch {
279
+ // Ignore file access errors
280
+ }
281
+ }, 1000);
186
282
 
187
- const legacySessions = await listTmuxSessions();
188
- legacySessions
189
- .filter(s => isSecureLinkSession(s.name))
190
- .forEach(s => sessions.push({ ...s, socketArgs: [], source: 'legacy' }));
283
+ await inquirer.prompt([{
284
+ type: 'input',
285
+ name: 'exit',
286
+ message: 'Press Enter to stop monitoring...'
287
+ }]);
191
288
 
192
- return sessions;
289
+ keepWatching = false;
290
+ clearInterval(intervalId);
291
+ console.log(chalk.cyan(' Stopped monitoring host logs.'));
292
+ } catch (err) {
293
+ console.log(chalk.red(` Could not read host logs: ${err.message}`));
294
+ }
193
295
  }
194
296
 
297
+
195
298
  // ─── Ephemeral SSH Key Management ────────────────────────────
196
299
  async function generateEphemeralKey() {
197
300
  const tmpDir = os.tmpdir() || process.env.TMPDIR || process.env.TEMP || process.env.TMP;
@@ -214,11 +317,20 @@ async function injectPublicKey(pubKey) {
214
317
  throw new Error('Could not resolve the current user home directory for authorized_keys');
215
318
  }
216
319
 
320
+ if (process.platform !== 'win32') {
321
+ try {
322
+ await fs.promises.chmod(homedir, 0o755);
323
+ } catch {}
324
+ }
325
+
217
326
  const sshDir = path.join(homedir, '.ssh');
218
327
 
219
328
  if (!fs.existsSync(sshDir)) {
220
329
  await fs.promises.mkdir(sshDir, { mode: 0o700, recursive: true });
221
330
  }
331
+ try {
332
+ await fs.promises.chmod(sshDir, 0o700);
333
+ } catch {}
222
334
 
223
335
  const authKeysPath = path.join(sshDir, 'authorized_keys');
224
336
  const existing = await fs.promises.lstat(authKeysPath).catch(() => null);
@@ -227,20 +339,47 @@ async function injectPublicKey(pubKey) {
227
339
  }
228
340
  const authorizedKey = `no-agent-forwarding,no-X11-forwarding ${pubKey}`;
229
341
  await fs.promises.appendFile(authKeysPath, `\n${authorizedKey}\n`, { mode: 0o600 });
230
- await fs.promises.chmod(authKeysPath, 0o600);
231
- return { authKeysPath, authorizedKey };
342
+ try {
343
+ await fs.promises.chmod(authKeysPath, 0o600);
344
+ } catch {}
345
+
346
+ // Windows Administrators authorized keys handling
347
+ let adminAuthKeysPath = null;
348
+ if (process.platform === 'win32') {
349
+ const programData = process.env.PROGRAMDATA || 'C:\\ProgramData';
350
+ const adminKeysPath = path.join(programData, 'ssh', 'administrators_authorized_keys');
351
+ try {
352
+ if (fs.existsSync(path.dirname(adminKeysPath))) {
353
+ await fs.promises.appendFile(adminKeysPath, `\n${authorizedKey}\n`, { mode: 0o600 });
354
+ try {
355
+ await execa('icacls', [adminKeysPath, '/inheritance:r', '/grant', '*S-1-5-32-544:F', '/grant', '*S-1-5-18:F']);
356
+ } catch {}
357
+ adminAuthKeysPath = adminKeysPath;
358
+ }
359
+ } catch {}
360
+ }
361
+
362
+ return { authKeysPath, adminAuthKeysPath, authorizedKey };
232
363
  }
233
364
 
234
- async function removePublicKey(authKeysPath, authorizedKey) {
365
+ async function removePublicKey(authKeysPath, authorizedKey, adminAuthKeysPath = null) {
235
366
  if (fs.existsSync(authKeysPath)) {
236
367
  const stat = await fs.promises.lstat(authKeysPath);
237
- if (stat.isSymbolicLink()) {
238
- throw new Error('Refusing to modify a symlinked authorized_keys file');
368
+ if (!stat.isSymbolicLink()) {
369
+ let keys = await fs.promises.readFile(authKeysPath, 'utf8');
370
+ keys = keys.replace(`\n${authorizedKey}\n`, '');
371
+ await fs.promises.writeFile(authKeysPath, keys);
372
+ try {
373
+ await fs.promises.chmod(authKeysPath, 0o600);
374
+ } catch {}
239
375
  }
240
- let keys = await fs.promises.readFile(authKeysPath, 'utf8');
241
- keys = keys.replace(`\n${authorizedKey}\n`, '');
242
- await fs.promises.writeFile(authKeysPath, keys);
243
- await fs.promises.chmod(authKeysPath, 0o600);
376
+ }
377
+ if (adminAuthKeysPath && fs.existsSync(adminAuthKeysPath)) {
378
+ try {
379
+ let keys = await fs.promises.readFile(adminAuthKeysPath, 'utf8');
380
+ keys = keys.replace(`\n${authorizedKey}\n`, '');
381
+ await fs.promises.writeFile(adminAuthKeysPath, keys);
382
+ } catch {}
244
383
  }
245
384
  }
246
385
 
@@ -347,12 +486,19 @@ async function startLocalHostDashboard(uid, password, serviceConfig, sessionStat
347
486
  async function fetchDecryptedApprovals() {
348
487
  const data = await fetchApprovalRequests(BROKER_URL, uid, sessionState.hostToken);
349
488
  const decryptedApprovals = await Promise.all((data.approvals || []).map(async (a) => {
350
- const base = { id: a.id, status: a.status, createdAt: a.createdAt, decidedAt: a.decidedAt };
489
+ const base = { id: a.id, status: a.status, createdAt: a.createdAt, decidedAt: a.decidedAt, ip: a.ip || 'unknown' };
351
490
  if (!a.iv || !a.ciphertext || !a.salt) return base;
352
491
  try {
353
492
  const decrypted = await decryptAsync(a.iv, a.ciphertext, password, a.salt);
354
493
  const details = JSON.parse(decrypted);
355
- return { ...base, username: details.username, hostname: details.hostname, os: details.os, intent: details.intent };
494
+ return {
495
+ ...base,
496
+ username: details.username,
497
+ hostname: details.hostname,
498
+ os: details.os,
499
+ intent: details.intent,
500
+ localIp: details.localIp || 'unknown'
501
+ };
356
502
  } catch {
357
503
  return base;
358
504
  }
@@ -488,7 +634,31 @@ async function startLocalHostDashboard(uid, password, serviceConfig, sessionStat
488
634
  const { requestId, decision } = req.body || {};
489
635
  if (!requestId || !decision) return res.status(400).json({ error: 'requestId and decision required' });
490
636
  try {
491
- await decideApprovalRequest(BROKER_URL, uid, requestId, decision, sessionState.hostToken);
637
+ let approvedPayload = null;
638
+ if (decision === 'approved') {
639
+ const data = await fetchApprovalRequests(BROKER_URL, uid, sessionState.hostToken);
640
+ const request = (data.approvals || []).find(item => item.id === requestId);
641
+ if (!request) return res.status(404).json({ error: 'Request not found' });
642
+
643
+ let details = {};
644
+ try {
645
+ details = JSON.parse(await decryptAsync(request.iv, request.ciphertext, password, request.salt));
646
+ } catch {}
647
+
648
+ const clientKeySalt = [
649
+ password,
650
+ request.ip || 'unknown',
651
+ details.username || 'unknown',
652
+ details.hostname || 'unknown',
653
+ details.os || 'unknown'
654
+ ].join('|');
655
+ const clientPwd = crypto.createHash('sha256').update(clientKeySalt).digest('hex');
656
+
657
+ const payload = JSON.stringify({ url: sessionState.tunnelUrl, ...serviceConfig });
658
+ approvedPayload = await encryptAsync(payload, clientPwd);
659
+ }
660
+
661
+ await decideApprovalRequest(BROKER_URL, uid, requestId, decision, sessionState.hostToken, approvedPayload);
492
662
  recordEvent('approval_decision', { uid, requestId, decision, via: 'dashboard' });
493
663
  res.json({ ok: true });
494
664
  } catch (err) {
@@ -684,7 +854,7 @@ function renderApprovals(approvals) {
684
854
  heading.append(title, badge);
685
855
  const details = document.createElement('div');
686
856
  details.className = 'meta';
687
- details.textContent = 'User: ' + String(req.username || 'unknown') + ' | Host: ' + String(req.hostname || 'unknown') + ' | OS: ' + String(req.os || 'unknown');
857
+ details.textContent = 'User: ' + String(req.username || 'unknown') + ' | Host: ' + String(req.hostname || 'unknown') + ' | OS: ' + String(req.os || 'unknown') + ' | IP: ' + String(req.ip || 'unknown') + ' (Local: ' + String(req.localIp || 'unknown') + ')';
688
858
  const meta = document.createElement('div');
689
859
  meta.className = 'meta';
690
860
  meta.textContent = 'Submitted: ' + new Date(req.createdAt).toLocaleTimeString();
@@ -932,7 +1102,8 @@ async function hostDashboard(uid, password, serviceConfig, tunnelProcess, sessio
932
1102
  const choices = [
933
1103
  { name: '✅ Review pending client approvals', value: 'approvals' },
934
1104
  { name: '📡 See detailed client telemetry', value: 'show' },
935
- { name: '📺 Mirror Client Terminal (requires tmux)', value: 'mirror' },
1105
+ { name: '📄 View live client activity logs', value: 'logs' },
1106
+ { name: '📄 View host session activity logs', value: 'host_logs' },
936
1107
  { name: '🔄 Re-register with broker', value: 'reregister' }
937
1108
  ];
938
1109
 
@@ -975,10 +1146,18 @@ async function hostDashboard(uid, password, serviceConfig, tunnelProcess, sessio
975
1146
 
976
1147
  for (const request of pending) {
977
1148
  let details = {};
978
- try {
979
- details = JSON.parse(await decryptAsync(request.iv, request.ciphertext, password, request.salt));
980
- } catch {
981
- details = { error: 'Could not decrypt request metadata' };
1149
+ if (!request.iv || !request.ciphertext || !request.salt) {
1150
+ console.log(chalk.yellow(' ⚠️ Broker did not return encrypted client metadata.'));
1151
+ console.log(chalk.dim(' This usually means the broker is running an older version.'));
1152
+ console.log(chalk.dim(' Redeploy the broker with the latest server.js to fix this.'));
1153
+ details = { error: 'Broker returned no encrypted metadata' };
1154
+ } else {
1155
+ try {
1156
+ details = JSON.parse(await decryptAsync(request.iv, request.ciphertext, password, request.salt));
1157
+ } catch (decErr) {
1158
+ console.log(chalk.yellow(` ⚠️ Could not decrypt client details: ${decErr.message}`));
1159
+ details = { error: 'Decryption failed' };
1160
+ }
982
1161
  }
983
1162
  console.log('');
984
1163
  console.log(chalk.bold.cyan(` Approval Request ${request.id}`));
@@ -986,6 +1165,7 @@ async function hostDashboard(uid, password, serviceConfig, tunnelProcess, sessio
986
1165
  console.log(` Host: ${details.hostname || 'unknown'}`);
987
1166
  console.log(` OS: ${details.os || 'unknown'}`);
988
1167
  console.log(` Intent: ${details.intent || 'connect'}`);
1168
+ console.log(` IP: ${request.ip || 'unknown'} (Local: ${details.localIp || 'unknown'})`);
989
1169
 
990
1170
  const { decision } = await inquirer.prompt([{
991
1171
  type: 'list',
@@ -998,7 +1178,23 @@ async function hostDashboard(uid, password, serviceConfig, tunnelProcess, sessio
998
1178
  ],
999
1179
  }]);
1000
1180
  if (decision !== 'skip') {
1001
- await decideApprovalRequest(BROKER_URL, uid, request.id, decision, sessionState.hostToken);
1181
+ let approvedPayload = null;
1182
+ if (decision === 'approved') {
1183
+ // Key derivation uses ONLY values both sides reliably know:
1184
+ // password + broker-observed IP. No decrypted client metadata
1185
+ // (which may fail if broker is stale or encryption differs).
1186
+ const clientKeySalt = [
1187
+ password,
1188
+ request.ip || 'unknown',
1189
+ uid
1190
+ ].join('|');
1191
+ const clientPwd = crypto.createHash('sha256').update(clientKeySalt).digest('hex');
1192
+
1193
+ const payload = JSON.stringify({ url: sessionState.tunnelUrl, ...serviceConfig });
1194
+ approvedPayload = await encryptAsync(payload, clientPwd);
1195
+ }
1196
+
1197
+ await decideApprovalRequest(BROKER_URL, uid, request.id, decision, sessionState.hostToken, approvedPayload);
1002
1198
  recordEvent('approval_decision', { uid, requestId: request.id, decision, username: details.username });
1003
1199
  }
1004
1200
  }
@@ -1023,6 +1219,14 @@ async function hostDashboard(uid, password, serviceConfig, tunnelProcess, sessio
1023
1219
  }
1024
1220
  });
1025
1221
 
1222
+ addCleanupHook(() => {
1223
+ try {
1224
+ if (chatServerInstance && chatServerInstance.server) {
1225
+ chatServerInstance.server.close();
1226
+ }
1227
+ } catch {}
1228
+ });
1229
+
1026
1230
  console.log(chalk.dim(' Provisioning Cloudflare tunnel for chat...'));
1027
1231
  chatTunnelProcess = await spawnTunnelSupervised(`http://localhost:${chatServerInstance.port}`, async (newUrl) => {
1028
1232
  serviceConfig.chatUrl = newUrl;
@@ -1041,6 +1245,9 @@ async function hostDashboard(uid, password, serviceConfig, tunnelProcess, sessio
1041
1245
 
1042
1246
  case 'dashboard': {
1043
1247
  dashboardInstance = await startLocalHostDashboard(uid, password, serviceConfig, sessionState);
1248
+ addCleanupHook(() => {
1249
+ try { if (dashboardInstance) dashboardInstance.close(); } catch {}
1250
+ });
1044
1251
  logSessionEvent('host_dashboard_opened');
1045
1252
  return waitForAction();
1046
1253
  }
@@ -1057,48 +1264,12 @@ async function hostDashboard(uid, password, serviceConfig, tunnelProcess, sessio
1057
1264
  return waitForAction();
1058
1265
  }
1059
1266
 
1060
- case 'mirror': {
1061
- console.log('');
1062
- console.log(chalk.bold.cyan(' 📺 Terminal Mirroring'));
1063
- console.log(chalk.dim(' ──────────────────────────────────────'));
1064
- console.log(chalk.dim(' Attaching to the tmux session created by an interactive SSH client.'));
1065
- console.log(chalk.dim(' Press Ctrl+b then d to detach gracefully.'));
1066
- console.log('');
1067
-
1068
- try {
1069
- await execa('tmux', ['-V'], { reject: true });
1070
- const sessions = await getMirrorableSessions();
1071
- if (sessions.length === 0) {
1072
- console.log(chalk.yellow(' ⚠️ No mirrored terminal session is active yet.'));
1073
- console.log(chalk.dim(' A client must choose "Connect via SSH" first. SCP-only clients do not create a tmux session.'));
1074
- console.log(chalk.dim(' tmux is needed on the host machine only; the client does not need tmux.'));
1075
- logSessionEvent('host_mirror_missing_session', {}, 'warn');
1076
- return waitForAction();
1077
- }
1078
-
1079
- let target = sessions[0];
1080
- if (sessions.length > 1) {
1081
- const { sessionChoice } = await inquirer.prompt([{
1082
- type: 'list',
1083
- name: 'sessionChoice',
1084
- message: 'Select an active client session to mirror:',
1085
- choices: sessions.map((s, idx) => {
1086
- const created = s.createdAt ? new Date(s.createdAt * 1000).toLocaleTimeString() : 'Unknown';
1087
- const label = `${s.name} ${chalk.dim(`(started ${created})`)}`;
1088
- return { name: label, value: String(idx) };
1089
- }),
1090
- }]);
1091
- target = sessions[parseInt(sessionChoice, 10)] || sessions[0];
1092
- }
1093
-
1094
- await execa('tmux', [...target.socketArgs, 'attach', '-t', target.name, '-r'], { stdio: 'inherit', reject: false });
1095
- logSessionEvent('host_mirror_attached', { session: target.name, source: target.source });
1096
- } catch (err) {
1097
- console.log(chalk.yellow(' ⚠️ Could not attach to tmux.'));
1098
- console.log(chalk.dim(` ${err.message}`));
1099
- console.log(chalk.dim(' Terminal mirroring requires tmux on the host machine and an active interactive SSH client.'));
1100
- logSessionEvent('host_mirror_error', { error: err.message }, 'warn');
1101
- }
1267
+ case 'logs': {
1268
+ await viewLiveClientLogs(serviceConfig.sharedDropPath);
1269
+ return waitForAction();
1270
+ }
1271
+ case 'host_logs': {
1272
+ await viewHostLiveLogs();
1102
1273
  return waitForAction();
1103
1274
  }
1104
1275
 
@@ -1154,15 +1325,7 @@ async function hostDashboard(uid, password, serviceConfig, tunnelProcess, sessio
1154
1325
  try {
1155
1326
  await revokeUID(BROKER_URL, uid, sessionState.hostToken);
1156
1327
  tunnelProcess.kill();
1157
- if (process.platform !== 'win32') {
1158
- await execa('tmux', [...tmuxSocketArgs(), 'kill-server'], { reject: false });
1159
- const legacySessions = await listTmuxSessions();
1160
- for (const session of legacySessions) {
1161
- if (isSecureLinkSession(session.name)) {
1162
- await execa('tmux', ['kill-session', '-t', session.name], { reject: false });
1163
- }
1164
- }
1165
- }
1328
+ // No tmux server to terminate since mirroring was discarded
1166
1329
  spinner.succeed('Session revoked and iPingYou-owned connections terminated');
1167
1330
  logSessionEvent('host_sessions_terminated');
1168
1331
  } catch {
@@ -1174,6 +1337,9 @@ async function hostDashboard(uid, password, serviceConfig, tunnelProcess, sessio
1174
1337
 
1175
1338
  case 'exit':
1176
1339
  if (dashboardInstance) dashboardInstance.close();
1340
+ if (chatServerInstance && chatServerInstance.server) {
1341
+ try { chatServerInstance.server.close(); } catch {}
1342
+ }
1177
1343
  if (chatTunnelProcess) chatTunnelProcess.kill();
1178
1344
  if (global.privateBrokerInstance) global.privateBrokerInstance.kill();
1179
1345
  if (tunnelProcess) tunnelProcess.kill();
@@ -1310,7 +1476,6 @@ export async function startHostMode() {
1310
1476
 
1311
1477
  if (serviceType === 'ssh' || serviceType === 'share') {
1312
1478
  await ensureSSHRunning();
1313
- await ensureTmuxInstalled();
1314
1479
 
1315
1480
  try {
1316
1481
  serviceConfig.sharedDropPath = await prepareSharedDropFolder(uid);
@@ -1340,13 +1505,13 @@ export async function startHostMode() {
1340
1505
  console.log(chalk.dim(' 🔑 Generating ephemeral SSH key for passwordless entry...'));
1341
1506
  try {
1342
1507
  const ephemeralKey = await generateEphemeralKey();
1343
- const { authKeysPath, authorizedKey } = await injectPublicKey(ephemeralKey.pubKey);
1508
+ const { authKeysPath, adminAuthKeysPath, authorizedKey } = await injectPublicKey(ephemeralKey.pubKey);
1344
1509
 
1345
1510
  serviceConfig.privateKey = ephemeralKey.privKey;
1346
1511
 
1347
1512
  addCleanupHook(async () => {
1348
1513
  console.log(chalk.dim(' Removing ephemeral public key...'));
1349
- await removePublicKey(authKeysPath, authorizedKey);
1514
+ await removePublicKey(authKeysPath, authorizedKey, adminAuthKeysPath);
1350
1515
  try { await fs.promises.unlink(ephemeralKey.keyPath); } catch { }
1351
1516
  try { await fs.promises.unlink(`${ephemeralKey.keyPath}.pub`); } catch { }
1352
1517
  });