@miraj181/ipingyou 2.0.14 â 2.1.0
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 +8 -1
- package/package.json +7 -6
- package/src/cli.js +216 -0
- package/src/lib/ai/groq.js +116 -0
- package/src/lib/ai/safety.js +114 -0
- package/src/lib/allowlist.js +35 -0
- package/src/lib/broker.js +139 -13
- package/src/lib/cleanup.js +14 -6
- package/src/lib/config.js +8 -3
- package/src/lib/path-browser.js +2 -2
- package/src/lib/platform.js +0 -31
- package/src/lib/session-log.js +93 -0
- package/src/modes/ai.js +627 -0
- package/src/modes/client.js +288 -31
- package/src/modes/doctor.js +293 -0
- package/src/modes/host.js +485 -38
- package/src/server.js +226 -14
package/src/modes/host.js
CHANGED
|
@@ -27,7 +27,8 @@ import { detectOS } from '../lib/platform.js';
|
|
|
27
27
|
import { createSpinner, networkSpinner, typeText } from '../lib/animations.js';
|
|
28
28
|
import { startChatServer, openLocalChatUI } from '../lib/chat.js';
|
|
29
29
|
import { spawnTunnelSupervised } from '../lib/tunnel.js';
|
|
30
|
-
import { pingBroker, registerWithBroker } from '../lib/broker.js';
|
|
30
|
+
import { decideApprovalRequest, fetchApprovalRequests, pingBroker, registerWithBroker, revokeUID } from '../lib/broker.js';
|
|
31
|
+
import { cleanupSessionLog, getSessionLogPath, initSessionLog, logSessionEvent, recordEvent } from '../lib/session-log.js';
|
|
31
32
|
|
|
32
33
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
33
34
|
let BROKER_URL = process.env.BROKER_URL || 'https://ipingyou.onrender.com';
|
|
@@ -151,12 +152,12 @@ async function generateEphemeralKey() {
|
|
|
151
152
|
throw new Error('Could not resolve a temporary directory for SSH key generation');
|
|
152
153
|
}
|
|
153
154
|
const keyPath = path.join(tmpDir, `ipingyou_${Date.now()}`);
|
|
154
|
-
|
|
155
|
+
|
|
155
156
|
await execa('ssh-keygen', ['-t', 'ed25519', '-C', 'ipingyou-ephemeral', '-f', keyPath, '-N', '']);
|
|
156
|
-
|
|
157
|
+
|
|
157
158
|
const privKey = await fs.promises.readFile(keyPath, 'utf8');
|
|
158
159
|
const pubKey = (await fs.promises.readFile(`${keyPath}.pub`, 'utf8')).trim();
|
|
159
|
-
|
|
160
|
+
|
|
160
161
|
return { keyPath, privKey, pubKey };
|
|
161
162
|
}
|
|
162
163
|
|
|
@@ -167,11 +168,11 @@ async function injectPublicKey(pubKey) {
|
|
|
167
168
|
}
|
|
168
169
|
|
|
169
170
|
const sshDir = path.join(homedir, '.ssh');
|
|
170
|
-
|
|
171
|
+
|
|
171
172
|
if (!fs.existsSync(sshDir)) {
|
|
172
173
|
await fs.promises.mkdir(sshDir, { mode: 0o700, recursive: true });
|
|
173
174
|
}
|
|
174
|
-
|
|
175
|
+
|
|
175
176
|
const authKeysPath = path.join(sshDir, 'authorized_keys');
|
|
176
177
|
await fs.promises.appendFile(authKeysPath, `\n${pubKey}\n`);
|
|
177
178
|
return authKeysPath;
|
|
@@ -185,12 +186,319 @@ async function removePublicKey(authKeysPath, pubKey) {
|
|
|
185
186
|
}
|
|
186
187
|
}
|
|
187
188
|
|
|
189
|
+
async function prepareSharedDropFolder(uid) {
|
|
190
|
+
const dropPath = path.join(os.homedir(), `ipingyou-dropbox-${uid}`);
|
|
191
|
+
await fs.promises.mkdir(dropPath, { recursive: true, mode: 0o700 });
|
|
192
|
+
try { await fs.promises.chmod(dropPath, 0o700); } catch { }
|
|
193
|
+
return dropPath;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function showMacPrivacyPreflight(sharedDropPath) {
|
|
197
|
+
if (process.platform !== 'darwin') return;
|
|
198
|
+
|
|
199
|
+
console.log('');
|
|
200
|
+
console.log(chalk.bold.cyan(' đ macOS SSH File Access Preflight'));
|
|
201
|
+
console.log(chalk.dim(' ââââââââââââââââââââââââââââââââââââââ'));
|
|
202
|
+
console.log(chalk.dim(' macOS may block SSH sessions from browsing Downloads, Desktop, or Documents.'));
|
|
203
|
+
console.log(chalk.dim(' For reliable SCP transfers, use the session drop folder:'));
|
|
204
|
+
console.log(chalk.green(` ${sharedDropPath}`));
|
|
205
|
+
console.log(chalk.dim(' To browse protected folders over SSH, grant Full Disk Access to sshd/Remote Login.'));
|
|
206
|
+
console.log('');
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async function promptOneTimeSharePath() {
|
|
210
|
+
const { sharePath } = await inquirer.prompt([{
|
|
211
|
+
type: 'input',
|
|
212
|
+
name: 'sharePath',
|
|
213
|
+
message: 'Local file/folder to share one time:',
|
|
214
|
+
validate: value => {
|
|
215
|
+
const trimmed = value.trim();
|
|
216
|
+
if (!trimmed) return 'Required';
|
|
217
|
+
const expanded = trimmed === '~' ? os.homedir() : trimmed.replace(/^~(?=\/)/, os.homedir());
|
|
218
|
+
return fs.existsSync(expanded) || 'Path does not exist';
|
|
219
|
+
},
|
|
220
|
+
}]);
|
|
221
|
+
return sharePath.trim() === '~' ? os.homedir() : sharePath.trim().replace(/^~(?=\/)/, os.homedir());
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async function startLocalHostDashboard(uid, password, serviceConfig, hostToken) {
|
|
225
|
+
const { default: express } = await import('express');
|
|
226
|
+
const { default: open } = await import('open');
|
|
227
|
+
const app = express();
|
|
228
|
+
const startedAt = new Date().toISOString();
|
|
229
|
+
|
|
230
|
+
app.get('/api/status', (_req, res) => {
|
|
231
|
+
res.json({
|
|
232
|
+
uid,
|
|
233
|
+
startedAt,
|
|
234
|
+
service: serviceConfig.type,
|
|
235
|
+
port: serviceConfig.port,
|
|
236
|
+
approvalRequired: Boolean(serviceConfig.approvalRequired),
|
|
237
|
+
sharedDropPath: serviceConfig.sharedDropPath || null,
|
|
238
|
+
oneTimeSharePath: serviceConfig.oneTimeSharePath || null,
|
|
239
|
+
chatUrl: serviceConfig.chatUrl ? '[configured]' : null,
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
app.use(express.json());
|
|
244
|
+
|
|
245
|
+
// Server-Sent Events for live telemetry & approvals
|
|
246
|
+
app.get('/api/events', async (req, res) => {
|
|
247
|
+
res.set({ 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive' });
|
|
248
|
+
res.flushHeaders?.();
|
|
249
|
+
|
|
250
|
+
let closed = false;
|
|
251
|
+
const push = async () => {
|
|
252
|
+
if (closed) return;
|
|
253
|
+
try {
|
|
254
|
+
const data = await fetchApprovalRequests(BROKER_URL, uid, hostToken).catch(() => ({ approvals: [] }));
|
|
255
|
+
res.write(`event: approvals\n`);
|
|
256
|
+
res.write(`data: ${JSON.stringify(data)}\n\n`);
|
|
257
|
+
} catch { }
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
const iv = setInterval(push, 3000);
|
|
261
|
+
req.on('close', () => { clearInterval(iv); closed = true; });
|
|
262
|
+
// send initial payload
|
|
263
|
+
await push();
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
// CSRF Protection Middleware for all mutating endpoints
|
|
267
|
+
app.use((req, res, next) => {
|
|
268
|
+
if (req.method !== 'POST') return next();
|
|
269
|
+
|
|
270
|
+
// Express runs on localhost, ensure requests originate from the same host
|
|
271
|
+
const origin = req.headers.origin;
|
|
272
|
+
const referer = req.headers.referer;
|
|
273
|
+
|
|
274
|
+
// We expect origin to be http://127.0.0.1:<port>
|
|
275
|
+
const isLocalOrigin = origin && (origin.startsWith('http://127.0.0.1:') || origin.startsWith('http://localhost:'));
|
|
276
|
+
const isLocalReferer = referer && (referer.startsWith('http://127.0.0.1:') || referer.startsWith('http://localhost:'));
|
|
277
|
+
|
|
278
|
+
if (!isLocalOrigin && !isLocalReferer) {
|
|
279
|
+
console.warn(chalk.yellow(`\n â ī¸ Blocked potential CSRF attack from origin: ${origin || referer || 'unknown'}`));
|
|
280
|
+
return res.status(403).json({ error: 'CSRF validation failed' });
|
|
281
|
+
}
|
|
282
|
+
next();
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
app.post('/api/approval', async (req, res) => {
|
|
286
|
+
const { requestId, decision } = req.body || {};
|
|
287
|
+
if (!requestId || !decision) return res.status(400).json({ error: 'requestId and decision required' });
|
|
288
|
+
try {
|
|
289
|
+
await decideApprovalRequest(BROKER_URL, uid, requestId, decision, hostToken);
|
|
290
|
+
recordEvent('approval_decision', { uid, requestId, decision, via: 'dashboard' });
|
|
291
|
+
res.json({ ok: true });
|
|
292
|
+
} catch (err) {
|
|
293
|
+
res.status(500).json({ error: err.message });
|
|
294
|
+
}
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
app.post('/api/revoke', async (_req, res) => {
|
|
298
|
+
try {
|
|
299
|
+
await revokeUID(BROKER_URL, uid, hostToken);
|
|
300
|
+
recordEvent('uid_revoked', { uid, via: 'dashboard' });
|
|
301
|
+
res.json({ ok: true });
|
|
302
|
+
} catch (err) {
|
|
303
|
+
res.status(500).json({ error: err.message });
|
|
304
|
+
}
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
app.get('/', (_req, res) => {
|
|
308
|
+
res.type('html').send(`<!doctype html>
|
|
309
|
+
<html lang="en"><head><meta charset="utf-8"><title>iPingYou Host Dashboard</title>
|
|
310
|
+
<style>
|
|
311
|
+
:root{--bg:#0f172a;--panel:#1e293b;--border:#334155;--text:#f8fafc;--primary:#38bdf8;--accent:#818cf8;--green:#22c55e;--red:#ef4444;--yellow:#eab308;--dim:#94a3b8}
|
|
312
|
+
*{box-sizing:border-box;margin:0;padding:0}
|
|
313
|
+
body{font-family:'Inter',system-ui,sans-serif;background:var(--bg);color:var(--text);min-height:100vh;padding:2rem}
|
|
314
|
+
h1{font-size:1.5rem;margin-bottom:0.5rem;display:flex;align-items:center;gap:0.5rem}
|
|
315
|
+
h2{font-size:1.1rem;color:var(--primary);margin-bottom:1rem;text-transform:uppercase;letter-spacing:0.05em}
|
|
316
|
+
.header{background:var(--panel);border:1px solid var(--border);border-radius:12px;padding:1.5rem 2rem;margin-bottom:1.5rem;display:flex;justify-content:space-between;align-items:center}
|
|
317
|
+
.badge{background:var(--green);color:#000;padding:0.2rem 0.6rem;border-radius:999px;font-size:0.75rem;font-weight:700;animation:pulse 2s infinite}
|
|
318
|
+
@keyframes pulse{0%,100%{opacity:1}50%{opacity:0.6}}
|
|
319
|
+
.grid{display:grid;grid-template-columns:1fr 1fr;gap:1.5rem}
|
|
320
|
+
@media(max-width:800px){.grid{grid-template-columns:1fr}}
|
|
321
|
+
.card{background:var(--panel);border:1px solid var(--border);border-radius:12px;padding:1.5rem}
|
|
322
|
+
.info-row{display:flex;justify-content:space-between;padding:0.5rem 0;border-bottom:1px solid var(--border)}
|
|
323
|
+
.info-row:last-child{border:none}
|
|
324
|
+
.info-label{color:var(--dim);font-size:0.85rem}
|
|
325
|
+
.info-value{font-weight:600;font-size:0.85rem}
|
|
326
|
+
code{background:var(--bg);padding:2px 8px;border-radius:4px;font-size:0.85rem}
|
|
327
|
+
.btn{border:none;padding:0.6rem 1.2rem;border-radius:8px;font-weight:700;cursor:pointer;font-size:0.85rem;transition:all 0.2s}
|
|
328
|
+
.btn:hover{transform:translateY(-1px);box-shadow:0 4px 12px rgba(0,0,0,0.3)}
|
|
329
|
+
.btn:active{transform:scale(0.97)}
|
|
330
|
+
.btn-approve{background:var(--green);color:#000}.btn-deny{background:var(--red);color:white}
|
|
331
|
+
.btn-revoke{background:var(--red);color:white;padding:0.5rem 1rem}
|
|
332
|
+
.approval-item{background:var(--bg);border:1px solid var(--border);border-radius:8px;padding:1rem;margin-bottom:0.75rem;animation:fadeIn 0.3s ease}
|
|
333
|
+
@keyframes fadeIn{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:translateY(0)}}
|
|
334
|
+
.approval-item .meta{font-size:0.8rem;color:var(--dim);margin-bottom:0.5rem}
|
|
335
|
+
.approval-item .actions{display:flex;gap:0.5rem;margin-top:0.75rem}
|
|
336
|
+
.status-badge{display:inline-block;padding:0.15rem 0.5rem;border-radius:999px;font-size:0.75rem;font-weight:600}
|
|
337
|
+
.status-pending{background:var(--yellow);color:#000}
|
|
338
|
+
.status-approved{background:var(--green);color:#000}
|
|
339
|
+
.status-denied{background:var(--red);color:white}
|
|
340
|
+
.empty{color:var(--dim);font-style:italic;font-size:0.9rem;padding:1rem 0}
|
|
341
|
+
.client-card{background:var(--bg);border:1px solid var(--border);border-radius:8px;padding:0.8rem 1rem;margin-bottom:0.5rem;font-size:0.85rem}
|
|
342
|
+
.client-card strong{color:var(--primary)}
|
|
343
|
+
#toast{position:fixed;bottom:2rem;right:2rem;background:var(--green);color:#000;padding:0.8rem 1.5rem;border-radius:8px;font-weight:700;opacity:0;transition:opacity 0.3s;pointer-events:none}
|
|
344
|
+
#toast.show{opacity:1}
|
|
345
|
+
</style></head>
|
|
346
|
+
<body>
|
|
347
|
+
<div class="header">
|
|
348
|
+
<div><h1>đĄī¸ iPingYou Host Dashboard</h1><span style="color:var(--dim);font-size:0.85rem">UID: <code>${uid}</code></span></div>
|
|
349
|
+
<div style="display:flex;gap:1rem;align-items:center">
|
|
350
|
+
<span class="badge">â LIVE</span>
|
|
351
|
+
<button class="btn btn-revoke" onclick="revokeSession()">đĢ Revoke Session</button>
|
|
352
|
+
</div>
|
|
353
|
+
</div>
|
|
354
|
+
|
|
355
|
+
<div class="grid">
|
|
356
|
+
<div class="card">
|
|
357
|
+
<h2>đ Session Info</h2>
|
|
358
|
+
<div class="info-row"><span class="info-label">UID</span><span class="info-value"><code>${uid}</code></span></div>
|
|
359
|
+
<div class="info-row"><span class="info-label">Password</span><span class="info-value"><code>[Hidden â see terminal]</code></span></div>
|
|
360
|
+
<div class="info-row"><span class="info-label">Service</span><span class="info-value">${serviceConfig.type.toUpperCase()} on port ${serviceConfig.port}</span></div>
|
|
361
|
+
<div class="info-row"><span class="info-label">Approval Gate</span><span class="info-value">${serviceConfig.approvalRequired ? '<span style="color:var(--green)">â Enabled</span>' : '<span style="color:var(--dim)">Disabled</span>'}</span></div>
|
|
362
|
+
<div class="info-row"><span class="info-label">Drop Folder</span><span class="info-value"><code>${serviceConfig.sharedDropPath || 'none'}</code></span></div>
|
|
363
|
+
<div class="info-row"><span class="info-label">One-Time Share</span><span class="info-value"><code>${serviceConfig.oneTimeSharePath || 'none'}</code></span></div>
|
|
364
|
+
<div class="info-row"><span class="info-label">Chat</span><span class="info-value">${serviceConfig.chatUrl ? '<span style="color:var(--green)">Active</span>' : '<span style="color:var(--dim)">Not started</span>'}</span></div>
|
|
365
|
+
<div class="info-row"><span class="info-label">Uptime</span><span class="info-value" id="uptime">â</span></div>
|
|
366
|
+
</div>
|
|
367
|
+
|
|
368
|
+
<div class="card">
|
|
369
|
+
<h2>â
Pending Approvals <span id="approval-count" style="color:var(--dim);font-size:0.8rem"></span></h2>
|
|
370
|
+
<div id="approvals"><p class="empty">No pending requests</p></div>
|
|
371
|
+
</div>
|
|
372
|
+
</div>
|
|
373
|
+
|
|
374
|
+
<div class="card" style="margin-top:1.5rem">
|
|
375
|
+
<h2>đĄ Connected Clients <span id="client-count" style="color:var(--dim);font-size:0.8rem"></span></h2>
|
|
376
|
+
<div id="clients"><p class="empty">No clients connected yet</p></div>
|
|
377
|
+
</div>
|
|
378
|
+
|
|
379
|
+
<div id="toast"></div>
|
|
380
|
+
|
|
381
|
+
<script>
|
|
382
|
+
const startedAt = new Date("${startedAt}");
|
|
383
|
+
|
|
384
|
+
function showToast(msg) {
|
|
385
|
+
const t = document.getElementById('toast');
|
|
386
|
+
t.textContent = msg;
|
|
387
|
+
t.classList.add('show');
|
|
388
|
+
setTimeout(() => t.classList.remove('show'), 2500);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function updateUptime() {
|
|
392
|
+
const diff = Math.floor((Date.now() - startedAt.getTime()) / 1000);
|
|
393
|
+
const h = Math.floor(diff / 3600);
|
|
394
|
+
const m = Math.floor((diff % 3600) / 60);
|
|
395
|
+
const s = diff % 60;
|
|
396
|
+
document.getElementById('uptime').textContent = h + 'h ' + m + 'm ' + s + 's';
|
|
397
|
+
}
|
|
398
|
+
setInterval(updateUptime, 1000);
|
|
399
|
+
updateUptime();
|
|
400
|
+
|
|
401
|
+
// SSE for live updates
|
|
402
|
+
const es = new EventSource('/api/events');
|
|
403
|
+
es.addEventListener('approvals', (e) => {
|
|
404
|
+
try {
|
|
405
|
+
const data = JSON.parse(e.data);
|
|
406
|
+
renderApprovals(data.approvals || []);
|
|
407
|
+
} catch {}
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
function renderApprovals(approvals) {
|
|
411
|
+
const container = document.getElementById('approvals');
|
|
412
|
+
const pending = approvals.filter(a => a.status === 'pending');
|
|
413
|
+
const decided = approvals.filter(a => a.status !== 'pending').slice(-5);
|
|
414
|
+
document.getElementById('approval-count').textContent = pending.length > 0 ? '(' + pending.length + ' pending)' : '';
|
|
415
|
+
|
|
416
|
+
if (approvals.length === 0) {
|
|
417
|
+
container.innerHTML = '<p class="empty">No approval requests yet</p>';
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
let html = '';
|
|
422
|
+
for (const req of pending) {
|
|
423
|
+
html += '<div class="approval-item">'
|
|
424
|
+
+ '<div style="display:flex;justify-content:space-between;align-items:center">'
|
|
425
|
+
+ '<strong>Request ' + req.id + '</strong>'
|
|
426
|
+
+ '<span class="status-badge status-pending">PENDING</span></div>'
|
|
427
|
+
+ '<div class="meta">Submitted: ' + new Date(req.createdAt).toLocaleTimeString() + '</div>'
|
|
428
|
+
+ '<div class="actions">'
|
|
429
|
+
+ '<button class="btn btn-approve" data-id="' + req.id + '" data-decision="approved">â
Approve</button>'
|
|
430
|
+
+ '<button class="btn btn-deny" data-id="' + req.id + '" data-decision="denied">â Deny</button>'
|
|
431
|
+
+ '</div></div>';
|
|
432
|
+
}
|
|
433
|
+
for (const req of decided) {
|
|
434
|
+
const cls = req.status === 'approved' ? 'status-approved' : 'status-denied';
|
|
435
|
+
html += '<div class="approval-item" style="opacity:0.6">'
|
|
436
|
+
+ '<div style="display:flex;justify-content:space-between;align-items:center">'
|
|
437
|
+
+ '<strong>Request ' + req.id + '</strong>'
|
|
438
|
+
+ '<span class="status-badge ' + cls + '">' + req.status.toUpperCase() + '</span></div>'
|
|
439
|
+
+ '<div class="meta">Decided: ' + (req.decidedAt ? new Date(req.decidedAt).toLocaleTimeString() : 'N/A') + '</div>'
|
|
440
|
+
+ '</div>';
|
|
441
|
+
}
|
|
442
|
+
container.innerHTML = html;
|
|
443
|
+
|
|
444
|
+
// Attach click handlers via event delegation (avoids inline quote escaping issues)
|
|
445
|
+
container.querySelectorAll('[data-decision]').forEach(function(btn) {
|
|
446
|
+
btn.addEventListener('click', function() {
|
|
447
|
+
decide(btn.getAttribute('data-id'), btn.getAttribute('data-decision'));
|
|
448
|
+
});
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
async function decide(requestId, decision) {
|
|
453
|
+
try {
|
|
454
|
+
const res = await fetch('/api/approval', {
|
|
455
|
+
method: 'POST',
|
|
456
|
+
headers: { 'Content-Type': 'application/json' },
|
|
457
|
+
body: JSON.stringify({ requestId, decision })
|
|
458
|
+
});
|
|
459
|
+
if (res.ok) showToast(decision === 'approved' ? 'â
Approved!' : 'â Denied!');
|
|
460
|
+
else showToast('Failed: ' + (await res.json()).error);
|
|
461
|
+
} catch (err) { showToast('Error: ' + err.message); }
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
async function revokeSession() {
|
|
465
|
+
if (!confirm('Are you sure you want to revoke this session? All clients will lose access.')) return;
|
|
466
|
+
try {
|
|
467
|
+
const res = await fetch('/api/revoke', { method: 'POST' });
|
|
468
|
+
if (res.ok) { showToast('đĢ Session revoked!'); document.querySelector('.badge').textContent = 'â REVOKED'; document.querySelector('.badge').style.background = 'var(--red)'; }
|
|
469
|
+
else showToast('Failed to revoke');
|
|
470
|
+
} catch (err) { showToast('Error: ' + err.message); }
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// Poll client telemetry (separate from SSE for simplicity)
|
|
474
|
+
async function pollClients() {
|
|
475
|
+
try {
|
|
476
|
+
const res = await fetch('/api/status');
|
|
477
|
+
if (!res.ok) return;
|
|
478
|
+
// Client data is fetched from broker by SSE push. Let's also add a client endpoint.
|
|
479
|
+
} catch {}
|
|
480
|
+
}
|
|
481
|
+
</script>
|
|
482
|
+
</body></html>`);
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
return new Promise((resolve) => {
|
|
486
|
+
const server = app.listen(0, '127.0.0.1', async () => {
|
|
487
|
+
const port = server.address().port;
|
|
488
|
+
const url = `http://127.0.0.1:${port}`;
|
|
489
|
+
console.log(chalk.green(` â Local dashboard: ${url}`));
|
|
490
|
+
try { await open(url); } catch { }
|
|
491
|
+
resolve({ url, close: () => server.close() });
|
|
492
|
+
});
|
|
493
|
+
});
|
|
494
|
+
}
|
|
495
|
+
|
|
188
496
|
/**
|
|
189
497
|
* Auto-spawn a Private Broker locally and wrap it in a Cloudflare tunnel.
|
|
190
498
|
*/
|
|
191
499
|
async function spawnPrivateBroker() {
|
|
192
500
|
console.log(chalk.yellow('\n â ī¸ Public Broker is unreachable. Spawning Private Broker...'));
|
|
193
|
-
|
|
501
|
+
|
|
194
502
|
// 1. Spawn the broker server process
|
|
195
503
|
const brokerProcess = execa('node', [path.join(__dirname, '../server.js')], {
|
|
196
504
|
env: { ...process.env, PORT: '4040' },
|
|
@@ -222,7 +530,7 @@ async function spawnPrivateBroker() {
|
|
|
222
530
|
}, 30000, 'Private broker tunnel startup');
|
|
223
531
|
|
|
224
532
|
console.log(chalk.green(` â
Private Broker Active: ${chalk.bold.cyan(brokerTunnelUrl)}\n`));
|
|
225
|
-
|
|
533
|
+
|
|
226
534
|
return {
|
|
227
535
|
url: brokerTunnelUrl,
|
|
228
536
|
kill: () => {
|
|
@@ -237,9 +545,10 @@ async function spawnPrivateBroker() {
|
|
|
237
545
|
/**
|
|
238
546
|
* Display the host dashboard and handle user input.
|
|
239
547
|
*/
|
|
240
|
-
async function hostDashboard(uid, tunnelUrl, password, serviceConfig, tunnelProcess) {
|
|
548
|
+
async function hostDashboard(uid, tunnelUrl, password, serviceConfig, tunnelProcess, hostToken) {
|
|
241
549
|
let chatServerInstance = null;
|
|
242
550
|
let chatTunnelProcess = null;
|
|
551
|
+
let dashboardInstance = null;
|
|
243
552
|
|
|
244
553
|
const renderDashboard = () => {
|
|
245
554
|
console.clear();
|
|
@@ -254,6 +563,10 @@ async function hostDashboard(uid, tunnelUrl, password, serviceConfig, tunnelProc
|
|
|
254
563
|
if (serviceConfig.chatUrl) {
|
|
255
564
|
console.log(` â ${chalk.cyan('Chat URL:')} ${chalk.dim(serviceConfig.chatUrl.substring(0, 40))} â`);
|
|
256
565
|
}
|
|
566
|
+
if (serviceConfig.sharedDropPath) {
|
|
567
|
+
console.log(` â ${chalk.cyan('Drop Box:')} ${chalk.dim(serviceConfig.sharedDropPath.substring(0, 40))} â`);
|
|
568
|
+
}
|
|
569
|
+
console.log(` â ${chalk.cyan('Approval:')} ${serviceConfig.approvalRequired ? chalk.green('Required').padEnd(39) : chalk.dim('Not required').padEnd(39)}â`);
|
|
257
570
|
console.log(` â ${chalk.cyan('Broker:')} ${chalk.dim(BROKER_URL.substring(0, 40))} â`);
|
|
258
571
|
console.log(` â ${chalk.cyan('Crypto:')} ${chalk.green('AES-256-CBC E2E (PBKDF2)')} â`);
|
|
259
572
|
console.log(chalk.bold(' â âââââââââââââââââââââââââââââââââââââââââââââââââââââŖ'));
|
|
@@ -261,6 +574,11 @@ async function hostDashboard(uid, tunnelUrl, password, serviceConfig, tunnelProc
|
|
|
261
574
|
console.log(` â ${chalk.dim('Press Ctrl+C to terminate the session')} â`);
|
|
262
575
|
console.log(chalk.bold(' ââââââââââââââââââââââââââââââââââââââââââââââââââââââ'));
|
|
263
576
|
console.log('');
|
|
577
|
+
const logPath = getSessionLogPath();
|
|
578
|
+
if (logPath) {
|
|
579
|
+
console.log(chalk.dim(` đ Session log: ${logPath}`));
|
|
580
|
+
console.log('');
|
|
581
|
+
}
|
|
264
582
|
};
|
|
265
583
|
|
|
266
584
|
renderDashboard();
|
|
@@ -270,6 +588,7 @@ async function hostDashboard(uid, tunnelUrl, password, serviceConfig, tunnelProc
|
|
|
270
588
|
const waitForAction = async () => {
|
|
271
589
|
try {
|
|
272
590
|
const choices = [
|
|
591
|
+
{ name: 'â
Review pending client approvals', value: 'approvals' },
|
|
273
592
|
{ name: 'đĄ See detailed client telemetry', value: 'show' },
|
|
274
593
|
{ name: 'đē Mirror Client Terminal (requires tmux)', value: 'mirror' },
|
|
275
594
|
{ name: 'đ Re-register with broker', value: 'reregister' }
|
|
@@ -280,6 +599,11 @@ async function hostDashboard(uid, tunnelUrl, password, serviceConfig, tunnelProc
|
|
|
280
599
|
} else {
|
|
281
600
|
choices.push({ name: 'đŦ Re-open Chat Room in Browser', value: 'reopen_chat' });
|
|
282
601
|
}
|
|
602
|
+
if (!dashboardInstance) {
|
|
603
|
+
choices.push({ name: 'đ Open Local Web Dashboard', value: 'dashboard' });
|
|
604
|
+
} else {
|
|
605
|
+
choices.push({ name: 'đ Show Local Web Dashboard URL', value: 'dashboard_url' });
|
|
606
|
+
}
|
|
283
607
|
|
|
284
608
|
choices.push(
|
|
285
609
|
{ name: 'đĢ Terminate all connections', value: 'terminate' },
|
|
@@ -295,36 +619,99 @@ async function hostDashboard(uid, tunnelUrl, password, serviceConfig, tunnelProc
|
|
|
295
619
|
},
|
|
296
620
|
]);
|
|
297
621
|
|
|
622
|
+
logSessionEvent('host_action_selected', { action });
|
|
623
|
+
|
|
298
624
|
switch (action) {
|
|
625
|
+
case 'approvals': {
|
|
626
|
+
try {
|
|
627
|
+
const data = await fetchApprovalRequests(BROKER_URL, uid);
|
|
628
|
+
const pending = (data.approvals || []).filter(item => item.status === 'pending');
|
|
629
|
+
if (pending.length === 0) {
|
|
630
|
+
console.log(chalk.yellow(' No pending approval requests.'));
|
|
631
|
+
return waitForAction();
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
for (const request of pending) {
|
|
635
|
+
let details = {};
|
|
636
|
+
try {
|
|
637
|
+
details = JSON.parse(decrypt(request.iv, request.ciphertext, password, request.salt));
|
|
638
|
+
} catch {
|
|
639
|
+
details = { error: 'Could not decrypt request metadata' };
|
|
640
|
+
}
|
|
641
|
+
console.log('');
|
|
642
|
+
console.log(chalk.bold.cyan(` Approval Request ${request.id}`));
|
|
643
|
+
console.log(` User: ${details.username || 'unknown'}`);
|
|
644
|
+
console.log(` Host: ${details.hostname || 'unknown'}`);
|
|
645
|
+
console.log(` OS: ${details.os || 'unknown'}`);
|
|
646
|
+
console.log(` Intent: ${details.intent || 'connect'}`);
|
|
647
|
+
|
|
648
|
+
const { decision } = await inquirer.prompt([{
|
|
649
|
+
type: 'list',
|
|
650
|
+
name: 'decision',
|
|
651
|
+
message: 'Decision:',
|
|
652
|
+
choices: [
|
|
653
|
+
{ name: 'Approve', value: 'approved' },
|
|
654
|
+
{ name: 'Deny', value: 'denied' },
|
|
655
|
+
{ name: 'Skip', value: 'skip' },
|
|
656
|
+
],
|
|
657
|
+
}]);
|
|
658
|
+
if (decision !== 'skip') {
|
|
659
|
+
await decideApprovalRequest(BROKER_URL, uid, request.id, decision);
|
|
660
|
+
recordEvent('approval_decision', { uid, requestId: request.id, decision, username: details.username });
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
} catch (err) {
|
|
664
|
+
console.log(chalk.red(` Could not review approvals: ${err.message}`));
|
|
665
|
+
logSessionEvent('host_approvals_error', { error: err.message }, 'error');
|
|
666
|
+
}
|
|
667
|
+
return waitForAction();
|
|
668
|
+
}
|
|
669
|
+
|
|
299
670
|
case 'chat': {
|
|
300
671
|
console.log(chalk.dim('\n Starting chat server...'));
|
|
301
672
|
chatServerInstance = await startChatServer(async () => {
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
673
|
+
if (chatTunnelProcess) {
|
|
674
|
+
chatTunnelProcess.kill();
|
|
675
|
+
chatTunnelProcess = null;
|
|
676
|
+
chatServerInstance = null;
|
|
677
|
+
delete serviceConfig.chatUrl;
|
|
678
|
+
const res = await registerWithBroker(BROKER_URL, uid, tunnelUrl, password, serviceConfig);
|
|
679
|
+
if (res.success && res.hostToken) hostToken = res.hostToken;
|
|
680
|
+
renderDashboard();
|
|
681
|
+
}
|
|
310
682
|
});
|
|
311
683
|
|
|
312
684
|
console.log(chalk.dim(' Provisioning Cloudflare tunnel for chat...'));
|
|
313
685
|
chatTunnelProcess = await spawnTunnelSupervised(`http://localhost:${chatServerInstance.port}`, async (newUrl) => {
|
|
314
686
|
serviceConfig.chatUrl = newUrl;
|
|
315
|
-
await registerWithBroker(BROKER_URL, uid, tunnelUrl, password, serviceConfig);
|
|
687
|
+
const res = await registerWithBroker(BROKER_URL, uid, tunnelUrl, password, serviceConfig);
|
|
688
|
+
if (res.success && res.hostToken) hostToken = res.hostToken;
|
|
316
689
|
renderDashboard();
|
|
317
690
|
});
|
|
318
691
|
|
|
319
692
|
await waitForValue(() => serviceConfig.chatUrl, 30000, 'Chat tunnel startup');
|
|
320
693
|
|
|
321
694
|
console.log(chalk.green(' â
Chat Room Live! Clients can now join.'));
|
|
695
|
+
logSessionEvent('host_chat_started');
|
|
322
696
|
await openLocalChatUI(chatServerInstance.port, password);
|
|
323
697
|
return waitForAction();
|
|
324
698
|
}
|
|
325
699
|
|
|
700
|
+
case 'dashboard': {
|
|
701
|
+
dashboardInstance = await startLocalHostDashboard(uid, password, serviceConfig, hostToken);
|
|
702
|
+
logSessionEvent('host_dashboard_opened');
|
|
703
|
+
return waitForAction();
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
case 'dashboard_url': {
|
|
707
|
+
console.log(chalk.green(` Dashboard: ${dashboardInstance.url}`));
|
|
708
|
+
logSessionEvent('host_dashboard_url_shown');
|
|
709
|
+
return waitForAction();
|
|
710
|
+
}
|
|
711
|
+
|
|
326
712
|
case 'reopen_chat': {
|
|
327
713
|
if (chatServerInstance) await openLocalChatUI(chatServerInstance.port, password);
|
|
714
|
+
logSessionEvent('host_chat_reopened');
|
|
328
715
|
return waitForAction();
|
|
329
716
|
}
|
|
330
717
|
|
|
@@ -335,7 +722,7 @@ async function hostDashboard(uid, tunnelUrl, password, serviceConfig, tunnelProc
|
|
|
335
722
|
console.log(chalk.dim(' Attaching to the tmux session created by an interactive SSH client.'));
|
|
336
723
|
console.log(chalk.dim(' Press Ctrl+b then d to detach gracefully.'));
|
|
337
724
|
console.log('');
|
|
338
|
-
|
|
725
|
+
|
|
339
726
|
try {
|
|
340
727
|
await execaCommand('tmux -V', { reject: true });
|
|
341
728
|
const sessionCheck = await execa('tmux', ['has-session', '-t', 'SecureLink_Session'], { reject: false });
|
|
@@ -343,13 +730,16 @@ async function hostDashboard(uid, tunnelUrl, password, serviceConfig, tunnelProc
|
|
|
343
730
|
console.log(chalk.yellow(' â ī¸ No mirrored terminal session is active yet.'));
|
|
344
731
|
console.log(chalk.dim(' A client must choose "Connect via SSH" first. SCP-only clients do not create a tmux session.'));
|
|
345
732
|
console.log(chalk.dim(' tmux is needed on the host machine only; the client does not need tmux.'));
|
|
733
|
+
logSessionEvent('host_mirror_missing_session', {}, 'warn');
|
|
346
734
|
return waitForAction();
|
|
347
735
|
}
|
|
348
736
|
await execaCommand('tmux attach -t SecureLink_Session -r', { stdio: 'inherit' });
|
|
737
|
+
logSessionEvent('host_mirror_attached');
|
|
349
738
|
} catch (err) {
|
|
350
739
|
console.log(chalk.yellow(' â ī¸ Could not attach to tmux.'));
|
|
351
740
|
console.log(chalk.dim(` ${err.message}`));
|
|
352
741
|
console.log(chalk.dim(' Terminal mirroring requires tmux on the host machine and an active interactive SSH client.'));
|
|
742
|
+
logSessionEvent('host_mirror_error', { error: err.message }, 'warn');
|
|
353
743
|
}
|
|
354
744
|
return waitForAction();
|
|
355
745
|
}
|
|
@@ -360,38 +750,43 @@ async function hostDashboard(uid, tunnelUrl, password, serviceConfig, tunnelProc
|
|
|
360
750
|
const res = await fetch(`${BROKER_URL}/clients/${uid}`);
|
|
361
751
|
if (!res.ok) throw new Error('Failed to fetch from broker');
|
|
362
752
|
const data = await res.json();
|
|
363
|
-
|
|
753
|
+
|
|
364
754
|
if (!data.clients || data.clients.length === 0) {
|
|
365
755
|
spinner.warn('No clients have successfully connected and sent telemetry yet.');
|
|
366
756
|
} else {
|
|
367
757
|
spinner.succeed(`Found ${data.clients.length} recent connection(s):`);
|
|
368
|
-
|
|
758
|
+
|
|
369
759
|
data.clients.forEach((clientBlob, i) => {
|
|
370
760
|
try {
|
|
371
761
|
// Decrypt using the unique salt the client generated for this payload
|
|
372
762
|
const decrypted = decrypt(clientBlob.iv, clientBlob.ciphertext, password, clientBlob.salt);
|
|
373
763
|
const t = JSON.parse(decrypted);
|
|
374
|
-
|
|
375
|
-
console.log(chalk.bold.blue(`\n Client #${i+1} (${t.username})`));
|
|
764
|
+
|
|
765
|
+
console.log(chalk.bold.blue(`\n Client #${i + 1} (${t.username})`));
|
|
376
766
|
console.log(` IP: ${chalk.white(t.ip)}`);
|
|
377
767
|
console.log(` OS: ${chalk.dim(t.os)}`);
|
|
378
768
|
console.log(` CPU: ${chalk.dim(t.cpu)}`);
|
|
379
769
|
console.log(` RAM: ${chalk.dim(t.ram)}`);
|
|
770
|
+
console.log(` Action: ${chalk.yellow(t.action || 'connected')}`);
|
|
380
771
|
console.log(` Time: ${chalk.dim(t.time)}`);
|
|
381
772
|
} catch (e) {
|
|
382
|
-
console.log(chalk.yellow(`\n Client #${i+1}: Payload decryption failed (wrong password or corrupted).`));
|
|
773
|
+
console.log(chalk.yellow(`\n Client #${i + 1}: Payload decryption failed (wrong password or corrupted).`));
|
|
774
|
+
logSessionEvent('host_telemetry_decrypt_failed', { index: i + 1 }, 'warn');
|
|
383
775
|
}
|
|
384
776
|
});
|
|
385
777
|
}
|
|
386
778
|
} catch (e) {
|
|
387
|
-
|
|
779
|
+
spinner.fail('Could not reach broker.');
|
|
780
|
+
logSessionEvent('host_telemetry_fetch_failed', { error: e.message }, 'warn');
|
|
388
781
|
}
|
|
389
782
|
console.log('');
|
|
390
783
|
return waitForAction();
|
|
391
784
|
}
|
|
392
785
|
|
|
393
786
|
case 'reregister':
|
|
394
|
-
await registerWithBroker(BROKER_URL, uid, tunnelUrl, password, serviceConfig);
|
|
787
|
+
const res = await registerWithBroker(BROKER_URL, uid, tunnelUrl, password, serviceConfig);
|
|
788
|
+
if (res.success && res.hostToken) hostToken = res.hostToken;
|
|
789
|
+
logSessionEvent('host_broker_reregistered');
|
|
395
790
|
return waitForAction();
|
|
396
791
|
|
|
397
792
|
case 'terminate': {
|
|
@@ -404,16 +799,20 @@ async function hostDashboard(uid, tunnelUrl, password, serviceConfig, tunnelProc
|
|
|
404
799
|
await execaCommand('tmux kill-session -t SecureLink_Session', { reject: false });
|
|
405
800
|
}
|
|
406
801
|
spinner.succeed('All client SSH sessions terminated');
|
|
802
|
+
logSessionEvent('host_sessions_terminated');
|
|
407
803
|
} catch {
|
|
408
804
|
spinner.warn('Could not terminate sessions (none active?)');
|
|
805
|
+
logSessionEvent('host_sessions_terminate_failed', {}, 'warn');
|
|
409
806
|
}
|
|
410
807
|
return waitForAction();
|
|
411
808
|
}
|
|
412
809
|
|
|
413
810
|
case 'exit':
|
|
811
|
+
if (dashboardInstance) dashboardInstance.close();
|
|
414
812
|
if (chatTunnelProcess) chatTunnelProcess.kill();
|
|
415
813
|
if (global.privateBrokerInstance) global.privateBrokerInstance.kill();
|
|
416
814
|
if (tunnelProcess) tunnelProcess.kill();
|
|
815
|
+
logSessionEvent('host_session_exit');
|
|
417
816
|
await cleanupAll();
|
|
418
817
|
return;
|
|
419
818
|
}
|
|
@@ -434,9 +833,17 @@ export async function startHostMode() {
|
|
|
434
833
|
console.log(chalk.dim(' âââââââââââââââââââââââââââââââââââââ'));
|
|
435
834
|
console.log('');
|
|
436
835
|
|
|
836
|
+
const sessionLogPath = initSessionLog('host');
|
|
837
|
+
if (sessionLogPath) {
|
|
838
|
+
console.log(chalk.dim(` đ Session log: ${sessionLogPath}`));
|
|
839
|
+
addCleanupHook(() => cleanupSessionLog());
|
|
840
|
+
}
|
|
841
|
+
logSessionEvent('host_mode_started');
|
|
842
|
+
|
|
437
843
|
const uid = generateUID();
|
|
438
844
|
console.log(` ${chalk.green('â')} Session UID: ${chalk.bold.white(uid)}`);
|
|
439
845
|
console.log('');
|
|
846
|
+
logSessionEvent('host_uid_generated', { uid });
|
|
440
847
|
|
|
441
848
|
const { pwdInput } = await inquirer.prompt([
|
|
442
849
|
{
|
|
@@ -467,17 +874,20 @@ export async function startHostMode() {
|
|
|
467
874
|
global.privateBrokerInstance = await spawnPrivateBroker();
|
|
468
875
|
BROKER_URL = global.privateBrokerInstance.url;
|
|
469
876
|
}
|
|
877
|
+
logSessionEvent('host_broker_selected', { choice: brokerChoice, broker: BROKER_URL });
|
|
470
878
|
}
|
|
471
879
|
|
|
472
880
|
// Only ping if we haven't just created a private broker
|
|
473
881
|
if (!global.privateBrokerInstance) {
|
|
474
882
|
const spinner = createSpinner(`Checking broker status at ${BROKER_URL}...`, networkSpinner).start();
|
|
475
883
|
const brokerOnline = await pingBroker(BROKER_URL);
|
|
476
|
-
|
|
884
|
+
|
|
477
885
|
if (brokerOnline) {
|
|
478
886
|
spinner.succeed(`Broker is online ${chalk.dim(`(${BROKER_URL})`)}`);
|
|
887
|
+
logSessionEvent('host_broker_online', { broker: BROKER_URL });
|
|
479
888
|
} else {
|
|
480
889
|
spinner.warn(`Broker is unreachable ${chalk.dim(`(${BROKER_URL})`)}`);
|
|
890
|
+
logSessionEvent('host_broker_unreachable', { broker: BROKER_URL }, 'warn');
|
|
481
891
|
const { startPrivate } = await inquirer.prompt([
|
|
482
892
|
{
|
|
483
893
|
type: 'confirm',
|
|
@@ -489,8 +899,10 @@ export async function startHostMode() {
|
|
|
489
899
|
if (startPrivate) {
|
|
490
900
|
global.privateBrokerInstance = await spawnPrivateBroker();
|
|
491
901
|
BROKER_URL = global.privateBrokerInstance.url;
|
|
902
|
+
logSessionEvent('host_private_broker_spawned', { broker: BROKER_URL });
|
|
492
903
|
} else {
|
|
493
904
|
console.log(chalk.red('\n â FATAL: Cannot continue without a broker.'));
|
|
905
|
+
logSessionEvent('host_broker_missing_exit', { broker: BROKER_URL }, 'error');
|
|
494
906
|
process.exit(1);
|
|
495
907
|
}
|
|
496
908
|
}
|
|
@@ -504,6 +916,7 @@ export async function startHostMode() {
|
|
|
504
916
|
message: 'What service do you want to expose?',
|
|
505
917
|
choices: [
|
|
506
918
|
{ name: 'đĨī¸ SSH (Port 22)', value: 'ssh' },
|
|
919
|
+
{ name: 'đĻ One-Time File Share (SCP over SSH)', value: 'share' },
|
|
507
920
|
{ name: 'đ Web/HTTP (Custom Port)', value: 'http' },
|
|
508
921
|
{ name: 'đ Custom TCP Port (e.g. Database, RDP, VNC)', value: 'tcp' }
|
|
509
922
|
]
|
|
@@ -513,7 +926,10 @@ export async function startHostMode() {
|
|
|
513
926
|
let targetPort = 22;
|
|
514
927
|
let protocol = 'ssh';
|
|
515
928
|
|
|
516
|
-
if (serviceType === '
|
|
929
|
+
if (serviceType === 'share') {
|
|
930
|
+
targetPort = 22;
|
|
931
|
+
protocol = 'ssh';
|
|
932
|
+
} else if (serviceType === 'http') {
|
|
517
933
|
const ans = await inquirer.prompt([{ name: 'port', message: 'Enter local HTTP port (e.g. 3000):', default: '3000' }]);
|
|
518
934
|
targetPort = ans.port;
|
|
519
935
|
protocol = 'http';
|
|
@@ -523,49 +939,80 @@ export async function startHostMode() {
|
|
|
523
939
|
protocol = 'tcp';
|
|
524
940
|
}
|
|
525
941
|
|
|
526
|
-
const serviceConfig = { type: serviceType, port: targetPort, protocol };
|
|
942
|
+
const serviceConfig = { type: serviceType === 'share' ? 'ssh' : serviceType, port: targetPort, protocol };
|
|
527
943
|
const targetUrl = `${protocol}://localhost:${targetPort}`;
|
|
944
|
+
logSessionEvent('host_service_selected', { serviceType, protocol, port: targetPort });
|
|
528
945
|
|
|
529
|
-
if (serviceType === 'ssh') {
|
|
946
|
+
if (serviceType === 'ssh' || serviceType === 'share') {
|
|
530
947
|
await ensureSSHRunning();
|
|
531
948
|
await ensureTmuxInstalled();
|
|
949
|
+
|
|
950
|
+
try {
|
|
951
|
+
serviceConfig.sharedDropPath = await prepareSharedDropFolder(uid);
|
|
952
|
+
console.log(chalk.green(` â Shared drop folder ready: ${serviceConfig.sharedDropPath}`));
|
|
953
|
+
showMacPrivacyPreflight(serviceConfig.sharedDropPath);
|
|
954
|
+
} catch (err) {
|
|
955
|
+
console.log(chalk.yellow(` â ī¸ Could not prepare shared drop folder: ${err.message}`));
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
if (serviceType === 'share') {
|
|
959
|
+
serviceConfig.oneTimeSharePath = await promptOneTimeSharePath();
|
|
960
|
+
serviceConfig.oneTime = true;
|
|
961
|
+
console.log(chalk.green(` â One-time share selected: ${serviceConfig.oneTimeSharePath}`));
|
|
962
|
+
logSessionEvent('host_one_time_share_selected');
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
const { approvalRequired } = await inquirer.prompt([{
|
|
966
|
+
type: 'confirm',
|
|
967
|
+
name: 'approvalRequired',
|
|
968
|
+
message: 'Require host approval before clients receive tunnel/key material?',
|
|
969
|
+
default: serviceType !== 'share',
|
|
970
|
+
}]);
|
|
971
|
+
serviceConfig.approvalRequired = approvalRequired;
|
|
972
|
+
logSessionEvent('host_approval_required', { approvalRequired });
|
|
973
|
+
|
|
532
974
|
console.log(chalk.dim(' đ Generating ephemeral SSH key for passwordless entry...'));
|
|
533
975
|
try {
|
|
534
976
|
const ephemeralKey = await generateEphemeralKey();
|
|
535
977
|
const authKeysPath = await injectPublicKey(ephemeralKey.pubKey);
|
|
536
|
-
|
|
978
|
+
|
|
537
979
|
serviceConfig.privateKey = ephemeralKey.privKey;
|
|
538
|
-
|
|
980
|
+
|
|
539
981
|
addCleanupHook(async () => {
|
|
540
982
|
console.log(chalk.dim(' Removing ephemeral public key...'));
|
|
541
983
|
await removePublicKey(authKeysPath, ephemeralKey.pubKey);
|
|
542
|
-
try { await fs.promises.unlink(ephemeralKey.keyPath); } catch {}
|
|
543
|
-
try { await fs.promises.unlink(`${ephemeralKey.keyPath}.pub`); } catch {}
|
|
984
|
+
try { await fs.promises.unlink(ephemeralKey.keyPath); } catch { }
|
|
985
|
+
try { await fs.promises.unlink(`${ephemeralKey.keyPath}.pub`); } catch { }
|
|
544
986
|
});
|
|
545
987
|
console.log(chalk.green(' â Ephemeral key injected. Client will connect without system password!'));
|
|
988
|
+
logSessionEvent('host_ephemeral_key_ready');
|
|
546
989
|
} catch (err) {
|
|
547
990
|
console.log(chalk.yellow(` â ī¸ Could not prepare ephemeral SSH key: ${err.message}`));
|
|
548
991
|
console.log(chalk.dim(' Client will need to use standard OS password.'));
|
|
992
|
+
logSessionEvent('host_ephemeral_key_failed', { error: err.message }, 'warn');
|
|
549
993
|
}
|
|
550
994
|
} else {
|
|
551
995
|
console.log(chalk.dim(` âšī¸ Ensure your ${protocol.toUpperCase()} service is running on port ${targetPort}.`));
|
|
552
996
|
}
|
|
553
997
|
|
|
554
998
|
let tunnelUrl = null;
|
|
999
|
+
let hostToken = null;
|
|
555
1000
|
const tunnelProcess = await spawnTunnelSupervised(targetUrl, async (newUrl) => {
|
|
556
1001
|
tunnelUrl = newUrl;
|
|
557
1002
|
// Register or re-register with broker when tunnel is spawned/respawned
|
|
558
|
-
const
|
|
559
|
-
if (!
|
|
1003
|
+
const res = await registerWithBroker(BROKER_URL, uid, tunnelUrl, password, serviceConfig);
|
|
1004
|
+
if (!res.success) {
|
|
560
1005
|
console.error(chalk.red(`\n â FATAL: Could not register with broker at ${BROKER_URL}`));
|
|
1006
|
+
logSessionEvent('host_broker_register_failed', { broker: BROKER_URL }, 'error');
|
|
561
1007
|
process.exit(1);
|
|
562
1008
|
}
|
|
1009
|
+
if (res.hostToken) hostToken = res.hostToken;
|
|
563
1010
|
});
|
|
564
1011
|
|
|
565
1012
|
// Wait for the first URL to be generated before showing the dashboard
|
|
566
1013
|
await waitForValue(() => tunnelUrl, 30000, 'Cloudflare tunnel startup');
|
|
567
1014
|
|
|
568
|
-
setRevokeOnExit(uid, BROKER_URL);
|
|
1015
|
+
setRevokeOnExit(uid, BROKER_URL, () => hostToken);
|
|
569
1016
|
|
|
570
|
-
await hostDashboard(uid, tunnelUrl, password, serviceConfig, tunnelProcess);
|
|
1017
|
+
await hostDashboard(uid, tunnelUrl, password, serviceConfig, tunnelProcess, hostToken);
|
|
571
1018
|
}
|