cli-tunnel 1.1.0 → 1.2.0-beta.10

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/dist/index.js CHANGED
@@ -20,8 +20,15 @@ import crypto from 'node:crypto';
20
20
  import { execSync, execFileSync, spawn } from 'node:child_process';
21
21
  import { fileURLToPath } from 'node:url';
22
22
  import http from 'node:http';
23
+ import readline from 'node:readline';
23
24
  import { WebSocketServer, WebSocket } from 'ws';
24
25
  import os from 'node:os';
26
+ function askUser(question) {
27
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
28
+ return new Promise((resolve) => {
29
+ rl.question(question, (answer) => { rl.close(); resolve(answer.trim().toLowerCase()); });
30
+ });
31
+ }
25
32
  const BOLD = '\x1b[1m';
26
33
  const RESET = '\x1b[0m';
27
34
  const DIM = '\x1b[2m';
@@ -29,40 +36,45 @@ const GREEN = '\x1b[32m';
29
36
  const YELLOW = '\x1b[33m';
30
37
  // ─── Parse args ─────────────────────────────────────────────
31
38
  const args = process.argv.slice(2);
32
- if (args.includes('--help') || args.includes('-h') || args.length === 0) {
39
+ if (args.includes('--help') || args.includes('-h')) {
33
40
  console.log(`
34
41
  ${BOLD}cli-tunnel${RESET} — Tunnel any CLI app to your phone
35
42
 
36
43
  ${BOLD}Usage:${RESET}
37
44
  cli-tunnel [options] <command> [args...]
45
+ cli-tunnel # hub mode — sessions dashboard only
38
46
 
39
47
  ${BOLD}Options:${RESET}
40
- --tunnel Enable remote access via devtunnel
48
+ --local Disable devtunnel (localhost only)
41
49
  --port <n> Bridge port (default: random)
42
50
  --name <name> Session name (shown in dashboard)
51
+ --replay Enable replay buffer (off by default)
43
52
  --help, -h Show this help
44
53
 
45
54
  ${BOLD}Examples:${RESET}
46
- cli-tunnel copilot
47
- cli-tunnel --tunnel copilot --yolo
48
- cli-tunnel --tunnel copilot --model claude-sonnet-4 --agent squad
49
- cli-tunnel --tunnel --name wizard copilot --allow-all
50
- cli-tunnel --tunnel python -i
51
- cli-tunnel --tunnel htop
55
+ cli-tunnel copilot --yolo # tunnel + run copilot
56
+ cli-tunnel copilot --model claude-sonnet-4 --agent squad
57
+ cli-tunnel k9s # tunnel + run k9s
58
+ cli-tunnel python -i # tunnel + run python
59
+ cli-tunnel --name wizard copilot # named session
60
+ cli-tunnel --local copilot --yolo # localhost only, no devtunnel
61
+ cli-tunnel # hub: see all active sessions
52
62
 
53
- Any flags after the command name pass through to the underlying
54
- app. cli-tunnel's own flags (--tunnel, --port, --name) must come
55
- before the command.
63
+ Devtunnel is enabled by default. All flags after the command name
64
+ pass through to the underlying app. cli-tunnel's own flags
65
+ (--local, --port, --name) must come before the command.
56
66
  `);
57
67
  process.exit(0);
58
68
  }
59
- const hasTunnel = args.includes('--tunnel');
69
+ const hasLocal = args.includes('--local');
70
+ const hasTunnel = !hasLocal;
71
+ const hasReplay = args.includes('--replay');
60
72
  const portIdx = args.indexOf('--port');
61
73
  const port = (portIdx !== -1 && args[portIdx + 1]) ? parseInt(args[portIdx + 1], 10) : 0;
62
74
  const nameIdx = args.indexOf('--name');
63
75
  const sessionName = (nameIdx !== -1 && args[nameIdx + 1]) ? args[nameIdx + 1] : '';
64
76
  // Everything that's not our flags is the command
65
- const ourFlags = new Set(['--tunnel', '--port', '--name']);
77
+ const ourFlags = new Set(['--local', '--tunnel', '--port', '--name', '--replay']);
66
78
  const cmdArgs = [];
67
79
  let skip = false;
68
80
  for (let i = 0; i < args.length; i++) {
@@ -70,20 +82,18 @@ for (let i = 0; i < args.length; i++) {
70
82
  skip = false;
71
83
  continue;
72
84
  }
73
- if (ourFlags.has(args[i]) && args[i] !== '--tunnel') {
85
+ if (args[i] === '--port' || args[i] === '--name') {
74
86
  skip = true;
75
87
  continue;
76
88
  }
77
- if (args[i] === '--tunnel')
89
+ if (args[i] === '--local' || args[i] === '--tunnel' || args[i] === '--replay')
78
90
  continue;
79
91
  cmdArgs.push(args[i]);
80
92
  }
81
- if (cmdArgs.length === 0) {
82
- console.error('Error: no command specified. Run cli-tunnel --help for usage.');
83
- process.exit(1);
84
- }
85
- const command = cmdArgs[0];
86
- const commandArgs = cmdArgs.slice(1);
93
+ // Hub mode no command, just show sessions dashboard
94
+ const hubMode = cmdArgs.length === 0;
95
+ const command = hubMode ? '' : cmdArgs[0];
96
+ const commandArgs = hubMode ? [] : cmdArgs.slice(1);
87
97
  const cwd = process.cwd();
88
98
  // ─── Tunnel helpers ─────────────────────────────────────────
89
99
  function sanitizeLabel(l) {
@@ -103,35 +113,199 @@ function getGitInfo() {
103
113
  }
104
114
  // ─── Security: Session token for WebSocket auth ────────────
105
115
  const sessionToken = crypto.randomUUID();
116
+ // ─── Session file registry (IPC via filesystem) ────────────
117
+ const sessionsDir = path.join(os.homedir(), '.cli-tunnel', 'sessions');
118
+ fs.mkdirSync(sessionsDir, { recursive: true, mode: 0o700 });
119
+ let sessionFilePath = null;
120
+ function writeSessionFile(tunnelId, tunnelUrl, port) {
121
+ sessionFilePath = path.join(sessionsDir, `${tunnelId}.json`);
122
+ const data = JSON.stringify({
123
+ token: sessionToken, name: sessionName || command,
124
+ tunnelId, tunnelUrl, port, hubMode,
125
+ machine: os.hostname(), pid: process.pid,
126
+ createdAt: new Date().toISOString(),
127
+ });
128
+ fs.writeFileSync(sessionFilePath, data, { mode: 0o600 });
129
+ }
130
+ function removeSessionFile() {
131
+ if (sessionFilePath) {
132
+ try {
133
+ fs.unlinkSync(sessionFilePath);
134
+ }
135
+ catch { }
136
+ }
137
+ }
138
+ function readLocalSessions() {
139
+ try {
140
+ return fs.readdirSync(sessionsDir)
141
+ .filter(f => f.endsWith('.json'))
142
+ .map(f => { try {
143
+ return JSON.parse(fs.readFileSync(path.join(sessionsDir, f), 'utf-8'));
144
+ }
145
+ catch {
146
+ return null;
147
+ } })
148
+ .filter((s) => s !== null && !s.hubMode);
149
+ }
150
+ catch {
151
+ return [];
152
+ }
153
+ }
154
+ // ─── F-18: Session TTL (4 hours) ───────────────────────────
155
+ const SESSION_TTL = 4 * 60 * 60 * 1000; // 4 hours
156
+ const sessionCreatedAt = Date.now();
157
+ // ─── F-02: One-time ticket store for WebSocket auth ────────
158
+ const tickets = new Map();
159
+ // #30: Ticket GC — clean expired tickets every 30s
160
+ setInterval(() => {
161
+ const now = Date.now();
162
+ for (const [id, t] of tickets) {
163
+ if (t.expires < now)
164
+ tickets.delete(id);
165
+ }
166
+ }, 30000);
106
167
  // ─── Security: Redact secrets from replay events ────────────
107
168
  function redactSecrets(text) {
108
- return text.replace(/(?:token|secret|key|password|credential|authorization)[\s:="']+[^\s"']{8,}/gi, '$& [REDACTED]');
169
+ return text
170
+ // Generic patterns: key=value, key: value, key="value"
171
+ .replace(/(?:token|secret|key|password|credential|authorization|api_key|private_key|access_key|connection_string|db_pass|signing)[\s:="']+\S{8,}/gi, '[REDACTED]')
172
+ // OpenAI keys
173
+ .replace(/sk-[a-zA-Z0-9]{20,}/g, '[REDACTED]')
174
+ // GitHub tokens
175
+ .replace(/gh[ps]_[a-zA-Z0-9]{36,}/g, '[REDACTED]')
176
+ // AWS keys
177
+ .replace(/AKIA[A-Z0-9]{16}/g, '[REDACTED]')
178
+ // Azure connection strings
179
+ .replace(/DefaultEndpointsProtocol=[^;\s]{20,}/gi, '[REDACTED]')
180
+ .replace(/AccountKey=[^;\s]{20,}/gi, 'AccountKey=[REDACTED]')
181
+ // Database URLs
182
+ .replace(/(postgres|mongodb|mysql|redis):\/\/[^\s"']{10,}/gi, '[REDACTED]')
183
+ // Bearer tokens in headers
184
+ .replace(/Bearer\s+[a-zA-Z0-9._-]{20,}/gi, 'Bearer [REDACTED]')
185
+ // JWT tokens
186
+ .replace(/eyJ[a-zA-Z0-9_-]{10,}\.[a-zA-Z0-9_-]{10,}\.[a-zA-Z0-9_-]{10,}/g, '[REDACTED]')
187
+ // Slack tokens
188
+ .replace(/xox[bpras]-[a-zA-Z0-9-]{10,}/g, '[REDACTED]')
189
+ // npm tokens
190
+ .replace(/npm_[a-zA-Z0-9]{20,}/g, '[REDACTED]')
191
+ // PEM private keys
192
+ .replace(/-----BEGIN [A-Z ]+ PRIVATE KEY-----[\s\S]*?-----END [A-Z ]+ PRIVATE KEY-----/g, '[REDACTED]');
109
193
  }
110
194
  // ─── Bridge server ──────────────────────────────────────────
111
195
  const acpEventLog = [];
112
196
  const connections = new Map();
197
+ // #10: Session TTL enforcement — periodically close expired connections
198
+ setInterval(() => {
199
+ if (Date.now() - sessionCreatedAt > SESSION_TTL) {
200
+ for (const [id, ws] of connections) {
201
+ ws.close(1000, 'Session expired');
202
+ connections.delete(id);
203
+ }
204
+ }
205
+ }, 60000);
206
+ // ─── F-8: Per-IP rate limiter ───────────────────────────────
207
+ const rateLimits = new Map();
208
+ const ticketRateLimits = new Map();
209
+ function checkRateLimit(ip, map, maxRequests) {
210
+ const now = Date.now();
211
+ const entry = map.get(ip);
212
+ if (!entry || entry.resetAt < now) {
213
+ map.set(ip, { count: 1, resetAt: now + 60000 });
214
+ return true;
215
+ }
216
+ entry.count++;
217
+ return entry.count <= maxRequests;
218
+ }
219
+ // Clean up rate limit maps every 60s
220
+ setInterval(() => {
221
+ const now = Date.now();
222
+ for (const [ip, entry] of rateLimits) {
223
+ if (entry.resetAt < now)
224
+ rateLimits.delete(ip);
225
+ }
226
+ for (const [ip, entry] of ticketRateLimits) {
227
+ if (entry.resetAt < now)
228
+ ticketRateLimits.delete(ip);
229
+ }
230
+ }, 60000);
113
231
  const server = http.createServer((req, res) => {
232
+ const clientIp = req.socket.remoteAddress || 'unknown';
233
+ // F-8: Rate limiting for HTTP endpoints
234
+ if (req.url?.startsWith('/api/')) {
235
+ const isTicket = req.url === '/api/auth/ticket';
236
+ if (isTicket && !checkRateLimit(clientIp, ticketRateLimits, 10)) {
237
+ res.writeHead(429, { 'Content-Type': 'application/json' });
238
+ res.end(JSON.stringify({ error: 'Too Many Requests' }));
239
+ return;
240
+ }
241
+ if (!checkRateLimit(clientIp, rateLimits, 30)) {
242
+ res.writeHead(429, { 'Content-Type': 'application/json' });
243
+ res.end(JSON.stringify({ error: 'Too Many Requests' }));
244
+ return;
245
+ }
246
+ }
247
+ // F-18: Session expiry check for API routes
248
+ if (!hubMode && req.url?.startsWith('/api/') && Date.now() - sessionCreatedAt > SESSION_TTL) {
249
+ res.writeHead(401, { 'Content-Type': 'application/json' });
250
+ res.end(JSON.stringify({ error: 'Session expired' }));
251
+ return;
252
+ }
253
+ // F-02: Ticket endpoint — exchange session token for one-time WS ticket
254
+ if (req.url === '/api/auth/ticket' && req.method === 'POST') {
255
+ const auth = req.headers.authorization?.replace('Bearer ', '');
256
+ if (auth !== sessionToken) {
257
+ res.writeHead(401);
258
+ res.end();
259
+ return;
260
+ }
261
+ const ticket = crypto.randomUUID();
262
+ tickets.set(ticket, { expires: Date.now() + 60000 });
263
+ res.writeHead(200, { 'Content-Type': 'application/json' });
264
+ res.end(JSON.stringify({ ticket, expires: Date.now() + 60000 }));
265
+ return;
266
+ }
267
+ // F-01: Session token check for all API routes
268
+ if (req.url?.startsWith('/api/')) {
269
+ const reqUrl = new URL(req.url, `http://${req.headers.host}`);
270
+ const authToken = req.headers.authorization?.replace('Bearer ', '') || reqUrl.searchParams.get('token');
271
+ if (authToken !== sessionToken) {
272
+ res.writeHead(401, { 'Content-Type': 'application/json' });
273
+ res.end(JSON.stringify({ error: 'Unauthorized' }));
274
+ return;
275
+ }
276
+ }
114
277
  // Sessions API
115
- if (req.url === '/api/sessions' && req.method === 'GET') {
278
+ if ((req.url === '/api/sessions' || req.url?.startsWith('/api/sessions?')) && req.method === 'GET') {
116
279
  try {
117
- const output = execSync('devtunnel list --labels cli-tunnel --json', { encoding: 'utf-8', timeout: 10000, stdio: ['pipe', 'pipe', 'pipe'] });
280
+ const output = execFileSync('devtunnel', ['list', '--labels', 'cli-tunnel', '--json'], { encoding: 'utf-8', timeout: 10000, stdio: ['pipe', 'pipe', 'pipe'] });
118
281
  const data = JSON.parse(output);
282
+ const localMachine = os.hostname();
283
+ const localSessions = hubMode ? readLocalSessions() : [];
284
+ const tokenMap = new Map(localSessions.map(s => [s.tunnelId, s.token]));
119
285
  const sessions = (data.tunnels || []).map((t) => {
120
286
  const labels = t.labels || [];
121
287
  const id = t.tunnelId?.replace(/\.\w+$/, '') || t.tunnelId;
122
288
  const cluster = t.tunnelId?.split('.').pop() || 'euw';
123
289
  const portLabel = labels.find((l) => l.startsWith('port-'));
124
290
  const p = portLabel ? parseInt(portLabel.replace('port-', ''), 10) : 3456;
125
- return {
291
+ const machine = labels[4] || 'unknown';
292
+ const session = {
126
293
  id, tunnelId: t.tunnelId,
127
294
  name: labels[1] || 'unnamed',
128
295
  repo: labels[2] || 'unknown',
129
296
  branch: (labels[3] || 'unknown').replace(/_/g, '/'),
130
- machine: labels[4] || 'unknown',
297
+ machine,
131
298
  online: (t.hostConnections || 0) > 0,
132
299
  port: p,
133
300
  url: `https://${id}-${p}.${cluster}.devtunnels.ms`,
301
+ isLocal: machine === localMachine,
134
302
  };
303
+ // Attach token from local session files (hub mode only)
304
+ const baseId = t.tunnelId?.split('.')[0] || t.tunnelId;
305
+ const token = tokenMap.get(baseId) || tokenMap.get(t.tunnelId);
306
+ if (token)
307
+ session.token = token;
308
+ return session;
135
309
  });
136
310
  res.writeHead(200, { 'Content-Type': 'application/json', 'X-Frame-Options': 'DENY', 'X-Content-Type-Options': 'nosniff' });
137
311
  res.end(JSON.stringify({ sessions }));
@@ -163,7 +337,18 @@ const server = http.createServer((req, res) => {
163
337
  }
164
338
  // Static files
165
339
  const uiDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../remote-ui');
166
- const decodedUrl = decodeURIComponent(req.url || '/');
340
+ // #18: Guard against malformed URI encoding
341
+ let decodedUrl;
342
+ try {
343
+ // Strip query string before resolving file path
344
+ const urlPath = (req.url || '/').split('?')[0];
345
+ decodedUrl = decodeURIComponent(urlPath);
346
+ }
347
+ catch {
348
+ res.writeHead(400);
349
+ res.end();
350
+ return;
351
+ }
167
352
  if (decodedUrl.includes('..')) {
168
353
  res.writeHead(400);
169
354
  res.end();
@@ -175,65 +360,154 @@ const server = http.createServer((req, res) => {
175
360
  res.end();
176
361
  return;
177
362
  }
178
- if (!fs.existsSync(filePath))
179
- filePath = path.resolve(uiDir, 'index.html');
363
+ // #2: EISDIR guard — check if path is a directory before createReadStream
364
+ try {
365
+ const stat = fs.statSync(filePath);
366
+ if (stat.isDirectory()) {
367
+ filePath = path.join(filePath, 'index.html');
368
+ if (!fs.existsSync(filePath)) {
369
+ res.writeHead(404);
370
+ res.end();
371
+ return;
372
+ }
373
+ }
374
+ }
375
+ catch {
376
+ res.writeHead(404);
377
+ res.end();
378
+ return;
379
+ }
180
380
  const ext = path.extname(filePath);
181
381
  const mimes = { '.html': 'text/html', '.js': 'application/javascript', '.css': 'text/css', '.json': 'application/json' };
182
382
  const securityHeaders = {
183
383
  'Content-Type': mimes[ext] || 'application/octet-stream',
184
384
  'X-Frame-Options': 'DENY',
185
385
  'X-Content-Type-Options': 'nosniff',
186
- 'Content-Security-Policy': "default-src 'self'; script-src 'self' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; connect-src 'self' ws: wss:;",
386
+ 'Content-Security-Policy': "default-src 'self'; script-src 'self' https://cdn.jsdelivr.net; style-src 'self' https://cdn.jsdelivr.net; connect-src 'self' ws://localhost:* wss://*.devtunnels.ms;",
387
+ 'Referrer-Policy': 'no-referrer',
388
+ 'Cache-Control': 'no-store',
389
+ 'Strict-Transport-Security': 'max-age=31536000; includeSubDomains',
187
390
  };
188
391
  res.writeHead(200, securityHeaders);
189
- fs.createReadStream(filePath).pipe(res);
392
+ // #8: Handle createReadStream errors
393
+ const stream = fs.createReadStream(filePath);
394
+ stream.on('error', () => { if (!res.headersSent) {
395
+ res.writeHead(500);
396
+ } res.end(); });
397
+ stream.pipe(res);
190
398
  });
191
399
  const wss = new WebSocketServer({
192
400
  server,
193
401
  maxPayload: 1048576,
194
402
  verifyClient: (info) => {
403
+ if (hubMode)
404
+ return true; // Hub mode doesn't need WS auth
405
+ // F-18: Session expiry
406
+ if (Date.now() - sessionCreatedAt > SESSION_TTL)
407
+ return false;
408
+ // F-3: Validate origin BEFORE ticket acceptance
409
+ const origin = info.req.headers.origin;
410
+ if (origin) {
411
+ try {
412
+ const originUrl = new URL(origin);
413
+ const host = originUrl.hostname;
414
+ if (host !== 'localhost' && host !== '127.0.0.1' && !host.endsWith('.devtunnels.ms')) {
415
+ return false;
416
+ }
417
+ }
418
+ catch {
419
+ return false;
420
+ }
421
+ }
195
422
  const url = new URL(info.req.url, `http://${info.req.headers.host}`);
196
- return url.searchParams.get('token') === sessionToken;
423
+ // F-02: Accept one-time ticket (only auth method for WS)
424
+ const ticket = url.searchParams.get('ticket');
425
+ if (ticket && tickets.has(ticket)) {
426
+ const t = tickets.get(ticket);
427
+ tickets.delete(ticket); // Single use
428
+ return t.expires > Date.now();
429
+ }
430
+ return false;
197
431
  },
198
432
  });
199
433
  // ─── Security: Audit log for remote PTY input ──────────────
200
- const auditLogPath = path.join(os.tmpdir(), `cli-tunnel-audit-${Date.now()}.log`);
434
+ const auditDir = path.join(os.homedir(), '.cli-tunnel', 'audit');
435
+ fs.mkdirSync(auditDir, { recursive: true, mode: 0o700 });
436
+ const auditLogPath = path.join(auditDir, `audit-${new Date().toISOString().slice(0, 10)}.jsonl`);
201
437
  const auditLog = fs.createWriteStream(auditLogPath, { flags: 'a' });
438
+ auditLog.on('error', (err) => { console.error('Audit log error:', err.message); });
202
439
  wss.on('connection', (ws, req) => {
203
- const id = Math.random().toString(36).substring(2);
440
+ // F-10: Connection cap (global + per-IP)
441
+ if (connections.size >= 5) {
442
+ ws.close(1013, 'Max connections reached');
443
+ return;
444
+ }
204
445
  const remoteAddress = req.socket.remoteAddress || 'unknown';
446
+ let perIpCount = 0;
447
+ for (const [, c] of connections) {
448
+ if (c._remoteAddress === remoteAddress)
449
+ perIpCount++;
450
+ }
451
+ if (perIpCount >= 2) {
452
+ ws.close(1013, 'Max connections per IP reached');
453
+ return;
454
+ }
455
+ const id = crypto.randomUUID();
456
+ ws._remoteAddress = remoteAddress;
205
457
  connections.set(id, ws);
206
- // Replay history with secrets redacted
207
- for (const event of acpEventLog) {
208
- ws.send(JSON.stringify({ type: '_replay', data: redactSecrets(event) }));
458
+ // F-10: WS ping/pong heartbeat
459
+ ws._isAlive = true;
460
+ ws.on('pong', () => { ws._isAlive = true; });
461
+ // Replay history with secrets redacted (only if replay is enabled)
462
+ if (hasReplay) {
463
+ for (const event of acpEventLog) {
464
+ ws.send(JSON.stringify({ type: '_replay', data: redactSecrets(event) }));
465
+ }
466
+ ws.send(JSON.stringify({ type: '_replay_done' }));
209
467
  }
210
- ws.send(JSON.stringify({ type: '_replay_done' }));
211
468
  ws.on('message', (data) => {
212
469
  const raw = data.toString();
213
470
  try {
214
471
  const msg = JSON.parse(raw);
215
472
  if (msg.type === 'pty_input' && ptyProcess) {
216
- auditLog.write(`${new Date().toISOString()} [${remoteAddress}] ${JSON.stringify(msg.data)}\n`);
473
+ auditLog.write(JSON.stringify({ ts: new Date().toISOString(), src: remoteAddress, type: 'pty_input', data: redactSecrets(JSON.stringify(msg.data)) }) + '\n');
217
474
  ptyProcess.write(msg.data);
218
475
  }
219
- if (msg.type === 'pty_resize' && ptyProcess) {
220
- const cols = Math.max(1, Math.min(500, msg.cols));
221
- const rows = Math.max(1, Math.min(200, msg.rows));
222
- ptyProcess.resize(cols, rows);
476
+ // #7: NaN guard on pty_resize
477
+ if (msg.type === 'pty_resize') {
478
+ const cols = Number(msg.cols);
479
+ const rows = Number(msg.rows);
480
+ if (Number.isFinite(cols) && Number.isFinite(rows) && ptyProcess) {
481
+ ptyProcess.resize(Math.max(1, Math.min(500, cols)), Math.max(1, Math.min(200, rows)));
482
+ }
223
483
  }
224
484
  }
225
485
  catch {
226
- if (ptyProcess)
227
- ptyProcess.write(raw + '\r');
486
+ // #3: Log but do NOT write to PTY — only structured pty_input messages allowed
487
+ auditLog.write(JSON.stringify({ ts: new Date().toISOString(), type: 'rejected', reason: 'non-json', length: raw.length }) + '\n');
228
488
  }
229
489
  });
230
490
  ws.on('close', () => connections.delete(id));
231
491
  });
492
+ // F-10: WS heartbeat — ping every 30s, close unresponsive after 10s
493
+ setInterval(() => {
494
+ for (const [id, ws] of connections) {
495
+ if (ws._isAlive === false) {
496
+ ws.terminate();
497
+ connections.delete(id);
498
+ continue;
499
+ }
500
+ ws._isAlive = false;
501
+ ws.ping();
502
+ }
503
+ }, 30000);
232
504
  function broadcast(data) {
233
505
  const msg = JSON.stringify({ type: 'pty', data });
234
- acpEventLog.push(msg);
235
- if (acpEventLog.length > 2000)
236
- acpEventLog.splice(0, acpEventLog.length - 2000);
506
+ if (hasReplay) {
507
+ acpEventLog.push(msg);
508
+ if (acpEventLog.length > 2000)
509
+ acpEventLog.splice(0, acpEventLog.length - 2000);
510
+ }
237
511
  for (const [, ws] of connections) {
238
512
  if (ws.readyState === WebSocket.OPEN)
239
513
  ws.send(msg);
@@ -243,7 +517,7 @@ function broadcast(data) {
243
517
  let ptyProcess = null;
244
518
  async function main() {
245
519
  const actualPort = await new Promise((resolve, reject) => {
246
- server.listen(port, () => {
520
+ server.listen(port, '127.0.0.1', () => {
247
521
  const addr = server.address();
248
522
  resolve(typeof addr === 'object' ? addr.port : port);
249
523
  });
@@ -252,60 +526,117 @@ async function main() {
252
526
  const { repo, branch } = getGitInfo();
253
527
  const machine = os.hostname();
254
528
  const displayName = sessionName || command;
255
- console.log(`\n${BOLD}cli-tunnel${RESET} ${DIM}v1.0.0${RESET}\n`);
256
- console.log(` ${DIM}Command:${RESET} ${command} ${commandArgs.join(' ')}`);
257
- console.log(` ${DIM}Name:${RESET} ${displayName}`);
258
- console.log(` ${DIM}Port:${RESET} ${actualPort}`);
259
- console.log(` ${DIM}Audit log:${RESET} ${auditLogPath}`);
529
+ console.log(`\n${BOLD}cli-tunnel${RESET} ${DIM}v1.1.0${RESET}\n`);
530
+ if (hubMode) {
531
+ console.log(` ${BOLD}📋 Hub Mode${RESET} — sessions dashboard`);
532
+ console.log(` ${DIM}Port:${RESET} ${actualPort}`);
533
+ console.log(` ${DIM}Local URL:${RESET} http://127.0.0.1:${actualPort}?token=${sessionToken}&hub=1\n`);
534
+ }
535
+ else {
536
+ console.log(` ${DIM}Command:${RESET} ${command} ${commandArgs.join(' ')}`);
537
+ console.log(` ${DIM}Name:${RESET} ${displayName}`);
538
+ console.log(` ${DIM}Port:${RESET} ${actualPort}`);
539
+ console.log(` ${DIM}Audit log:${RESET} ${auditLogPath}`);
540
+ console.log(` ${DIM}Local URL:${RESET} http://127.0.0.1:${actualPort}?token=${sessionToken}`);
541
+ console.log(` ${DIM}Session expires:${RESET} ${new Date(sessionCreatedAt + SESSION_TTL).toLocaleTimeString()}`);
542
+ }
260
543
  // Tunnel
261
544
  if (hasTunnel) {
262
545
  // Check if devtunnel is installed
263
546
  let devtunnelInstalled = false;
264
547
  try {
265
- execSync('devtunnel --version', { stdio: 'pipe' });
548
+ execFileSync('devtunnel', ['--version'], { stdio: 'pipe' });
266
549
  devtunnelInstalled = true;
267
550
  }
268
551
  catch {
269
552
  console.log(`\n ${YELLOW}⚠ devtunnel CLI not found!${RESET}\n`);
270
- console.log(` ${BOLD}To enable remote access, install Microsoft Dev Tunnels:${RESET}\n`);
553
+ let installCmd = '';
271
554
  if (process.platform === 'win32') {
272
- console.log(` ${GREEN}winget install Microsoft.devtunnel${RESET}`);
555
+ installCmd = 'winget install Microsoft.devtunnel';
273
556
  }
274
557
  else if (process.platform === 'darwin') {
275
- console.log(` ${GREEN}brew install --cask devtunnel${RESET}`);
558
+ installCmd = 'brew install --cask devtunnel';
559
+ }
560
+ else {
561
+ installCmd = 'curl -sL https://aka.ms/DevTunnelCliInstall | bash';
562
+ }
563
+ const answer = await askUser(` Would you like to install it now? (${GREEN}${installCmd}${RESET}) [Y/n] `);
564
+ if (answer === '' || answer === 'y' || answer === 'yes') {
565
+ console.log(`\n ${DIM}Installing devtunnel...${RESET}\n`);
566
+ try {
567
+ const installParts = installCmd.split(' ');
568
+ const installProc = spawn(installParts[0], installParts.slice(1), { stdio: 'inherit', shell: process.platform !== 'win32' && installCmd.includes('|') });
569
+ await new Promise((resolve, reject) => {
570
+ installProc.on('close', (code) => code === 0 ? resolve() : reject(new Error(`Install exited with code ${code}`)));
571
+ installProc.on('error', reject);
572
+ });
573
+ // Refresh PATH — winget updates the registry but current process has stale PATH
574
+ if (process.platform === 'win32') {
575
+ try {
576
+ const userPath = execFileSync('reg', ['query', 'HKCU\\Environment', '/v', 'Path'], { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] });
577
+ const sysPath = execFileSync('reg', ['query', 'HKLM\\SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment', '/v', 'Path'], { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] });
578
+ const extractPath = (out) => out.split('\n').find(l => l.includes('REG_'))?.split('REG_EXPAND_SZ')[1]?.trim() || out.split('\n').find(l => l.includes('REG_'))?.split('REG_SZ')[1]?.trim() || '';
579
+ process.env.PATH = `${extractPath(userPath)};${extractPath(sysPath)}`;
580
+ }
581
+ catch { /* keep existing PATH */ }
582
+ }
583
+ // Verify installation
584
+ execFileSync('devtunnel', ['--version'], { stdio: 'pipe' });
585
+ console.log(`\n ${GREEN}✓${RESET} devtunnel installed successfully!\n`);
586
+ devtunnelInstalled = true;
587
+ }
588
+ catch (err) {
589
+ console.log(`\n ${YELLOW}⚠${RESET} Installation failed: ${err.message}`);
590
+ console.log(` ${DIM}You can install it manually: ${installCmd}${RESET}\n`);
591
+ console.log(` ${DIM}Continuing without tunnel (local only)...${RESET}\n`);
592
+ }
276
593
  }
277
594
  else {
278
- console.log(` ${GREEN}curl -sL https://aka.ms/DevTunnelCliInstall | bash${RESET}`);
595
+ console.log(`\n ${DIM}More info: https://aka.ms/devtunnels/doc${RESET}`);
596
+ console.log(` ${DIM}Continuing without tunnel (local only)...${RESET}\n`);
279
597
  }
280
- console.log(`\n Then authenticate once:\n`);
281
- console.log(` ${GREEN}devtunnel user login${RESET}\n`);
282
- console.log(` ${DIM}More info: https://aka.ms/devtunnels/doc${RESET}\n`);
283
- console.log(` ${DIM}Continuing without tunnel (local only)...${RESET}\n`);
284
598
  }
285
- // Check if logged in
286
599
  if (devtunnelInstalled) {
600
+ // Check if logged in before attempting tunnel creation
287
601
  try {
288
- const userInfo = execSync('devtunnel user show', { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] });
289
- if (userInfo.includes('not logged in') || userInfo.includes('No user')) {
602
+ const userInfo = execFileSync('devtunnel', ['user', 'show'], { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] });
603
+ if (userInfo.includes('not logged in') || userInfo.includes('No user') || userInfo.includes('Anonymous')) {
290
604
  throw new Error('not logged in');
291
605
  }
292
606
  }
293
607
  catch {
294
- console.log(`\n ${YELLOW}⚠ devtunnel not authenticated!${RESET}\n`);
295
- console.log(` Run this once to log in:\n`);
296
- console.log(` ${GREEN}devtunnel user login${RESET}\n`);
297
- console.log(` ${DIM}Continuing without tunnel (local only)...${RESET}\n`);
298
- devtunnelInstalled = false;
608
+ console.log(`\n ${YELLOW}⚠ devtunnel not authenticated.${RESET}\n`);
609
+ const loginAnswer = await askUser(` Would you like to log in now? [Y/n] `);
610
+ if (loginAnswer === '' || loginAnswer === 'y' || loginAnswer === 'yes') {
611
+ try {
612
+ const loginProc = spawn('devtunnel', ['user', 'login'], { stdio: 'inherit' });
613
+ await new Promise((resolve, reject) => {
614
+ loginProc.on('close', (code) => code === 0 ? resolve() : reject(new Error(`Login exited with code ${code}`)));
615
+ loginProc.on('error', reject);
616
+ });
617
+ console.log(`\n ${GREEN}✓${RESET} Logged in successfully!\n`);
618
+ }
619
+ catch {
620
+ console.log(`\n ${YELLOW}⚠${RESET} Login failed. Run manually: ${GREEN}devtunnel user login${RESET}\n`);
621
+ console.log(` ${DIM}Continuing without tunnel (local only)...${RESET}\n`);
622
+ devtunnelInstalled = false;
623
+ }
624
+ }
625
+ else {
626
+ console.log(`\n ${DIM}Run this once to log in: ${GREEN}devtunnel user login${RESET}`);
627
+ console.log(` ${DIM}Continuing without tunnel (local only)...${RESET}\n`);
628
+ devtunnelInstalled = false;
629
+ }
299
630
  }
300
631
  }
301
632
  if (devtunnelInstalled) {
302
633
  try {
303
- const labels = ['cli-tunnel', sanitizeLabel(sessionName || command), sanitizeLabel(repo), sanitizeLabel(branch), sanitizeLabel(machine), `port-${actualPort}`]
304
- .map(l => `--labels ${l}`).join(' ');
305
- const createOut = execSync(`devtunnel create ${labels} --expiration 1d --json`, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] });
634
+ const labelValues = ['cli-tunnel', sanitizeLabel(sessionName || command), sanitizeLabel(repo), sanitizeLabel(branch), sanitizeLabel(machine), `port-${actualPort}`];
635
+ const labelArgs = labelValues.flatMap(l => ['--labels', l]);
636
+ const createOut = execFileSync('devtunnel', ['create', ...labelArgs, '--expiration', '1d', '--json'], { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] });
306
637
  const tunnelId = JSON.parse(createOut).tunnel?.tunnelId?.split('.')[0];
307
638
  const cluster = JSON.parse(createOut).tunnel?.tunnelId?.split('.')[1] || 'euw';
308
- execSync(`devtunnel port create ${tunnelId} -p ${actualPort} --protocol http`, { stdio: 'pipe' });
639
+ execFileSync('devtunnel', ['port', 'create', tunnelId, '-p', String(actualPort), '--protocol', 'http'], { stdio: 'pipe' });
309
640
  const hostProc = spawn('devtunnel', ['host', tunnelId], { stdio: 'pipe', detached: false });
310
641
  const url = await new Promise((resolve, reject) => {
311
642
  const timeout = setTimeout(() => reject(new Error('Tunnel timeout')), 15000);
@@ -320,28 +651,59 @@ async function main() {
320
651
  });
321
652
  hostProc.on('error', (e) => { clearTimeout(timeout); reject(e); });
322
653
  });
323
- const tunnelUrlWithToken = `${url}?token=${sessionToken}`;
654
+ const tunnelUrlWithToken = `${url}?token=${sessionToken}${hubMode ? '&hub=1' : ''}`;
324
655
  console.log(` ${GREEN}✓${RESET} Tunnel: ${BOLD}${tunnelUrlWithToken}${RESET}\n`);
656
+ // Write session file for hub discovery
657
+ writeSessionFile(tunnelId, url, actualPort);
325
658
  try {
326
659
  // @ts-ignore
327
660
  const qr = (await import('qrcode-terminal'));
328
661
  qr.default.generate(tunnelUrlWithToken, { small: true }, (code) => console.log(code));
329
662
  }
330
663
  catch { }
331
- process.on('SIGINT', () => { hostProc.kill(); try {
332
- execSync(`devtunnel delete ${tunnelId} --force`, { stdio: 'pipe' });
664
+ process.on('SIGINT', () => { removeSessionFile(); hostProc.kill(); try {
665
+ execFileSync('devtunnel', ['delete', tunnelId, '--force'], { stdio: 'pipe' });
333
666
  }
334
667
  catch { } });
335
- process.on('exit', () => { hostProc.kill(); try {
336
- execSync(`devtunnel delete ${tunnelId} --force`, { stdio: 'pipe' });
668
+ process.on('exit', () => { removeSessionFile(); hostProc.kill(); try {
669
+ execFileSync('devtunnel', ['delete', tunnelId, '--force'], { stdio: 'pipe' });
337
670
  }
338
671
  catch { } });
339
672
  }
340
673
  catch (err) {
341
- console.log(` ${YELLOW}⚠${RESET} Tunnel failed: ${err.message}\n`);
674
+ const errMsg = err.message || '';
675
+ // Detect auth failure at create time (expired token, anonymous, etc.)
676
+ if (errMsg.includes('Anonymous') || errMsg.includes('Unauthorized') || errMsg.includes('not permitted')) {
677
+ console.log(`\n ${YELLOW}⚠ devtunnel session expired or not authenticated.${RESET}\n`);
678
+ const loginAnswer = await askUser(` Would you like to log in now? [Y/n] `);
679
+ if (loginAnswer === '' || loginAnswer === 'y' || loginAnswer === 'yes') {
680
+ try {
681
+ const loginProc = spawn('devtunnel', ['user', 'login'], { stdio: 'inherit' });
682
+ await new Promise((resolve, reject) => {
683
+ loginProc.on('close', (code) => code === 0 ? resolve() : reject(new Error(`Login exited with code ${code}`)));
684
+ loginProc.on('error', reject);
685
+ });
686
+ console.log(`\n ${GREEN}✓${RESET} Logged in! Please run cli-tunnel again to create the tunnel.\n`);
687
+ }
688
+ catch {
689
+ console.log(`\n ${YELLOW}⚠${RESET} Login failed. Run manually: ${GREEN}devtunnel user login${RESET}\n`);
690
+ }
691
+ }
692
+ }
693
+ else {
694
+ console.log(` ${YELLOW}⚠${RESET} Tunnel failed: ${errMsg}\n`);
695
+ }
342
696
  }
343
697
  } // end if (devtunnelInstalled)
344
698
  }
699
+ if (hubMode) {
700
+ // Hub mode — just serve the sessions dashboard, no PTY
701
+ console.log(` ${GREEN}✓${RESET} Hub running — open in browser to see all sessions\n`);
702
+ console.log(` ${DIM}Press Ctrl+C to stop.${RESET}\n`);
703
+ process.on('SIGINT', () => { server.close(); process.exit(0); });
704
+ // Keep process alive
705
+ await new Promise(() => { });
706
+ }
345
707
  console.log(` ${DIM}Starting ${command}...${RESET}\n`);
346
708
  // Spawn PTY
347
709
  const nodePty = await import('node-pty');
@@ -351,7 +713,7 @@ async function main() {
351
713
  let resolvedCmd = command;
352
714
  if (process.platform === 'win32') {
353
715
  try {
354
- const wherePaths = execSync(`where ${command}`, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim().split('\n');
716
+ const wherePaths = execFileSync('where', [command], { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim().split('\n');
355
717
  // Prefer .exe or .cmd over .ps1 for node-pty compatibility
356
718
  const exePath = wherePaths.find(p => p.trim().endsWith('.exe')) || wherePaths.find(p => p.trim().endsWith('.cmd'));
357
719
  if (exePath) {
@@ -365,11 +727,16 @@ async function main() {
365
727
  }
366
728
  catch { /* use as-is */ }
367
729
  }
368
- // Security: filter sensitive environment variables
730
+ // F-07: Security — filter dangerous environment variables for PTY
731
+ // Blocklist approach: pass everything except known dangerous vars and secrets
732
+ const DANGEROUS_VARS = new Set(['NODE_OPTIONS', 'NODE_REPL_HISTORY', 'NODE_EXTRA_CA_CERTS',
733
+ 'NODE_PATH', 'NODE_REDIRECT_WARNINGS', 'NODE_PENDING_DEPRECATION',
734
+ 'UV_THREADPOOL_SIZE', 'LD_PRELOAD', 'DYLD_INSERT_LIBRARIES',
735
+ 'SSH_AUTH_SOCK', 'GPG_TTY']);
736
+ const sensitivePattern = /token|secret|key|password|credential|api_key|private_key|access_key|connection_string|auth|kubeconfig|docker_host|docker_config/i;
369
737
  const safeEnv = {};
370
- const sensitivePatterns = /token|secret|key|password|credential|api_key|private/i;
371
738
  for (const [k, v] of Object.entries(process.env)) {
372
- if (!sensitivePatterns.test(k) && v !== undefined) {
739
+ if (v !== undefined && !DANGEROUS_VARS.has(k) && !sensitivePattern.test(k)) {
373
740
  safeEnv[k] = v;
374
741
  }
375
742
  }
@@ -378,6 +745,25 @@ async function main() {
378
745
  cols, rows, cwd,
379
746
  env: safeEnv,
380
747
  });
748
+ // Detect CSPRNG crash (rare Node.js + PTY issue) and show helpful message
749
+ let ptyExitedEarly = false;
750
+ const earlyExitCheck = new Promise((resolve) => {
751
+ ptyProcess.onExit(({ exitCode }) => {
752
+ if (exitCode === 134 || exitCode === 3221226505) {
753
+ ptyExitedEarly = true;
754
+ resolve();
755
+ }
756
+ });
757
+ setTimeout(resolve, 2000);
758
+ });
759
+ await earlyExitCheck;
760
+ if (ptyExitedEarly) {
761
+ const nodeVer = process.version;
762
+ console.log(` ${YELLOW}⚠${RESET} The command crashed (CSPRNG assertion failure).`);
763
+ console.log(` This is a known issue with Node.js ${nodeVer} + PTY on Windows.`);
764
+ console.log(` ${BOLD}Fix:${RESET} Install Node.js 22 LTS: ${GREEN}nvm install 22${RESET} or ${GREEN}winget install OpenJS.NodeJS.LTS${RESET}\n`);
765
+ process.exit(1);
766
+ }
381
767
  ptyProcess.onData((data) => {
382
768
  process.stdout.write(data);
383
769
  broadcast(data);