cli-tunnel 1.2.0-beta.1 → 1.2.0-beta.3

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.
Files changed (3) hide show
  1. package/README.md +48 -5
  2. package/dist/index.js +115 -20
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -77,12 +77,55 @@ cli-tunnel copilot
77
77
 
78
78
  ## Security
79
79
 
80
- Tunnels are **private by default** — only the Microsoft/GitHub account that created the tunnel can connect. Auth is enforced at Microsoft's relay layer before traffic reaches your machine.
80
+ cli-tunnel uses a layered security model:
81
81
 
82
- - No inbound ports opened
83
- - No anonymous access
84
- - No central server
85
- - TLS encryption via devtunnel relay
82
+ **Network layer** — Microsoft Dev Tunnels are private by default. Only the Microsoft or GitHub account that created the tunnel can connect. TLS encryption is handled by Microsoft's relay infrastructure. No inbound ports are opened on your machine.
83
+
84
+ **Session authentication** Each session generates a unique token (cryptographic random UUID). All HTTP API and WebSocket connections require this token. The token is embedded in the URL you receive at startup — anyone without it cannot connect.
85
+
86
+ **WebSocket auth** — cli-tunnel uses a ticket-based handshake: the browser exchanges the session token for a single-use, short-lived ticket (60 seconds) to establish the WebSocket connection. This avoids keeping the long-lived token in WebSocket upgrade logs.
87
+
88
+ **Input validation** — Only structured JSON messages are accepted over WebSocket. Raw text is rejected and logged. Terminal resize commands are bounds-checked to prevent abuse.
89
+
90
+ **Environment isolation** — The child process receives a filtered set of environment variables (an allowlist of ~40 safe variables like PATH, HOME, TERM). Sensitive variables and NODE_OPTIONS are excluded to prevent code injection.
91
+
92
+ **Audit logging** — All remote keyboard input is logged to `~/.cli-tunnel/audit/` in JSONL format with timestamps and source addresses. Secrets are automatically redacted from audit entries.
93
+
94
+ **Connection limits** — Maximum 5 concurrent WebSocket connections. Sessions expire after 24 hours.
95
+
96
+ ## Terminal Size Behavior
97
+
98
+ cli-tunnel uses a single PTY (pseudo-terminal) shared between your local terminal and all remote viewers. When a phone or tablet connects, the PTY resizes to match the remote device's screen dimensions. This ensures the CLI app renders correctly on the device you're actively using to interact with it.
99
+
100
+ Because the PTY can only have one size at a time, the local terminal on your machine will reflect the remote device's dimensions while it's connected. This is by design — cli-tunnel prioritizes the remote viewing experience since the primary use case is controlling your CLI from another device.
101
+
102
+ **Tips for the best experience:**
103
+ - Rotate your phone to landscape for a wider terminal
104
+ - Use the key bar (↑↓←→ Tab Enter Esc Ctrl+C) at the bottom for navigation
105
+ - If multiple devices connect, the last one to resize wins
106
+
107
+ ## FAQ
108
+
109
+ **Can multiple devices connect to the same session?**
110
+ Yes, up to 5 devices simultaneously. All viewers see the same terminal output in real time. Input from any device goes to the same CLI session.
111
+
112
+ **What happens if my phone disconnects?**
113
+ The CLI session keeps running on your machine. When you reconnect, you'll see live output from that point forward. Use `--replay` to enable history replay so reconnecting devices catch up on what they missed.
114
+
115
+ **Does cli-tunnel work with any CLI app?**
116
+ Yes. Any command that runs in a terminal works — copilot, vim, htop, python, ssh, k9s, node, and more. cli-tunnel doesn't interpret the command's output; it streams raw terminal bytes.
117
+
118
+ **Is there a central server?**
119
+ No. cli-tunnel runs entirely on your machine. Microsoft Dev Tunnels provides the relay infrastructure, but no third-party server sees your terminal content.
120
+
121
+ **What about the anti-phishing page?**
122
+ The first time you open a devtunnel URL, Microsoft shows an interstitial warning page. This is a devtunnel security feature — it confirms you trust the tunnel. You only see it once per tunnel.
123
+
124
+ **Does the tool work without devtunnel?**
125
+ Yes. Use `--local` to skip tunnel creation. The terminal is available at `http://127.0.0.1:<port>` on your local network only.
126
+
127
+ **What's hub mode?**
128
+ Run `cli-tunnel` with no command to start hub mode — a sessions dashboard that shows all active cli-tunnel sessions on your machine. Tap any online session to connect to it.
86
129
 
87
130
  ## How It's Built
88
131
 
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';
@@ -421,35 +428,83 @@ async function main() {
421
428
  }
422
429
  catch {
423
430
  console.log(`\n ${YELLOW}⚠ devtunnel CLI not found!${RESET}\n`);
424
- console.log(` ${BOLD}To enable remote access, install Microsoft Dev Tunnels:${RESET}\n`);
431
+ let installCmd = '';
425
432
  if (process.platform === 'win32') {
426
- console.log(` ${GREEN}winget install Microsoft.devtunnel${RESET}`);
433
+ installCmd = 'winget install Microsoft.devtunnel';
427
434
  }
428
435
  else if (process.platform === 'darwin') {
429
- console.log(` ${GREEN}brew install --cask devtunnel${RESET}`);
436
+ installCmd = 'brew install --cask devtunnel';
430
437
  }
431
438
  else {
432
- console.log(` ${GREEN}curl -sL https://aka.ms/DevTunnelCliInstall | bash${RESET}`);
439
+ installCmd = 'curl -sL https://aka.ms/DevTunnelCliInstall | bash';
433
440
  }
434
- console.log(`\n Then authenticate once:\n`);
435
- console.log(` ${GREEN}devtunnel user login${RESET}\n`);
436
- console.log(` ${DIM}More info: https://aka.ms/devtunnels/doc${RESET}\n`);
437
- console.log(` ${DIM}Continuing without tunnel (local only)...${RESET}\n`);
438
- }
439
- // Check if logged in
440
- if (devtunnelInstalled) {
441
- try {
442
- const userInfo = execFileSync('devtunnel', ['user', 'show'], { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] });
443
- if (userInfo.includes('not logged in') || userInfo.includes('No user')) {
444
- throw new Error('not logged in');
441
+ const answer = await askUser(` Would you like to install it now? (${GREEN}${installCmd}${RESET}) [Y/n] `);
442
+ if (answer === '' || answer === 'y' || answer === 'yes') {
443
+ console.log(`\n ${DIM}Installing devtunnel...${RESET}\n`);
444
+ try {
445
+ const installParts = installCmd.split(' ');
446
+ const installProc = spawn(installParts[0], installParts.slice(1), { stdio: 'inherit', shell: process.platform !== 'win32' && installCmd.includes('|') });
447
+ await new Promise((resolve, reject) => {
448
+ installProc.on('close', (code) => code === 0 ? resolve() : reject(new Error(`Install exited with code ${code}`)));
449
+ installProc.on('error', reject);
450
+ });
451
+ // Refresh PATH winget updates the registry but current process has stale PATH
452
+ if (process.platform === 'win32') {
453
+ try {
454
+ const userPath = execFileSync('reg', ['query', 'HKCU\\Environment', '/v', 'Path'], { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] });
455
+ const sysPath = execFileSync('reg', ['query', 'HKLM\\SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment', '/v', 'Path'], { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] });
456
+ 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() || '';
457
+ process.env.PATH = `${extractPath(userPath)};${extractPath(sysPath)}`;
458
+ }
459
+ catch { /* keep existing PATH */ }
460
+ }
461
+ // Verify installation
462
+ execFileSync('devtunnel', ['--version'], { stdio: 'pipe' });
463
+ console.log(`\n ${GREEN}✓${RESET} devtunnel installed successfully!\n`);
464
+ devtunnelInstalled = true;
465
+ }
466
+ catch (err) {
467
+ console.log(`\n ${YELLOW}⚠${RESET} Installation failed: ${err.message}`);
468
+ console.log(` ${DIM}You can install it manually: ${installCmd}${RESET}\n`);
469
+ console.log(` ${DIM}Continuing without tunnel (local only)...${RESET}\n`);
445
470
  }
446
471
  }
447
- catch {
448
- console.log(`\n ${YELLOW} devtunnel not authenticated!${RESET}\n`);
449
- console.log(` Run this once to log in:\n`);
450
- console.log(` ${GREEN}devtunnel user login${RESET}\n`);
472
+ else {
473
+ console.log(`\n ${DIM}More info: https://aka.ms/devtunnels/doc${RESET}`);
451
474
  console.log(` ${DIM}Continuing without tunnel (local only)...${RESET}\n`);
452
- devtunnelInstalled = false;
475
+ }
476
+ // If just installed, check login
477
+ if (devtunnelInstalled) {
478
+ try {
479
+ const userInfo = execFileSync('devtunnel', ['user', 'show'], { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] });
480
+ if (userInfo.includes('not logged in') || userInfo.includes('No user')) {
481
+ throw new Error('not logged in');
482
+ }
483
+ }
484
+ catch {
485
+ console.log(` ${YELLOW}⚠ devtunnel not authenticated.${RESET}\n`);
486
+ const loginAnswer = await askUser(` Would you like to log in now? [Y/n] `);
487
+ if (loginAnswer === '' || loginAnswer === 'y' || loginAnswer === 'yes') {
488
+ try {
489
+ const loginProc = spawn('devtunnel', ['user', 'login'], { stdio: 'inherit' });
490
+ await new Promise((resolve, reject) => {
491
+ loginProc.on('close', (code) => code === 0 ? resolve() : reject(new Error(`Login exited with code ${code}`)));
492
+ loginProc.on('error', reject);
493
+ });
494
+ console.log(`\n ${GREEN}✓${RESET} Logged in successfully!\n`);
495
+ }
496
+ catch {
497
+ console.log(`\n ${YELLOW}⚠${RESET} Login failed. Run manually: ${GREEN}devtunnel user login${RESET}\n`);
498
+ console.log(` ${DIM}Continuing without tunnel (local only)...${RESET}\n`);
499
+ devtunnelInstalled = false;
500
+ }
501
+ }
502
+ else {
503
+ console.log(`\n ${DIM}Run this once to log in: ${GREEN}devtunnel user login${RESET}`);
504
+ console.log(` ${DIM}Continuing without tunnel (local only)...${RESET}\n`);
505
+ devtunnelInstalled = false;
506
+ }
507
+ }
453
508
  }
454
509
  }
455
510
  if (devtunnelInstalled) {
@@ -556,6 +611,46 @@ async function main() {
556
611
  cols, rows, cwd,
557
612
  env: safeEnv,
558
613
  });
614
+ // Detect CSPRNG crash (Node.js 23 + node-pty issue) and retry via cmd.exe wrapper
615
+ let ptyExitedEarly = false;
616
+ const earlyExitCheck = new Promise((resolve) => {
617
+ ptyProcess.onExit(({ exitCode }) => {
618
+ if (exitCode === 134 || exitCode === 3221226505) { // 134 = SIGABRT, 3221226505 = STATUS_BREAKPOINT
619
+ ptyExitedEarly = true;
620
+ resolve();
621
+ }
622
+ });
623
+ setTimeout(resolve, 2000); // Wait 2s — if still running, it's fine
624
+ });
625
+ await earlyExitCheck;
626
+ if (ptyExitedEarly && process.platform === 'win32') {
627
+ console.log(` ${YELLOW}⚠${RESET} CSPRNG crash detected (Node.js + PTY issue), retrying via cmd.exe wrapper...\n`);
628
+ // Spawn through cmd.exe /c — this adds a shell layer that avoids the crash
629
+ const cmdLine = [resolvedCmd, ...commandArgs].map(a => a.includes(' ') ? `"${a}"` : a).join(' ');
630
+ ptyProcess = nodePty.spawn('cmd.exe', ['/c', cmdLine], {
631
+ name: 'xterm-256color',
632
+ cols, rows, cwd,
633
+ env: safeEnv,
634
+ });
635
+ // Check if cmd.exe wrapper also fails
636
+ let retryFailed = false;
637
+ const retryCheck = new Promise((resolve) => {
638
+ ptyProcess.onExit(({ exitCode }) => {
639
+ if (exitCode === 134 || exitCode === 3221226505) {
640
+ retryFailed = true;
641
+ resolve();
642
+ }
643
+ });
644
+ setTimeout(resolve, 2000);
645
+ });
646
+ await retryCheck;
647
+ if (retryFailed) {
648
+ const nodeVer = process.version;
649
+ console.log(` ${YELLOW}⚠${RESET} The command crashed due to a known Node.js ${nodeVer} + PTY compatibility issue.`);
650
+ console.log(` ${BOLD}Fix:${RESET} Install Node.js 22 LTS: ${GREEN}nvm install 22${RESET} or ${GREEN}winget install OpenJS.NodeJS.LTS${RESET}\n`);
651
+ process.exit(1);
652
+ }
653
+ }
559
654
  ptyProcess.onData((data) => {
560
655
  process.stdout.write(data);
561
656
  broadcast(data);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "cli-tunnel",
3
- "version": "1.2.0-beta.1",
4
- "description": "Tunnel any CLI app to your phone - PTY + devtunnel + xterm.js",
3
+ "version": "1.2.0-beta.3",
4
+ "description": "Tunnel any CLI app to your phone PTY + devtunnel + xterm.js",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
7
7
  "bin": {