cli-tunnel 1.2.0-beta.9 → 1.2.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 CHANGED
@@ -3,11 +3,13 @@
3
3
  Tunnel any CLI app to your phone — see the exact terminal output in your browser and type back into it.
4
4
 
5
5
  ```bash
6
- npx cli-tunnel --tunnel copilot --yolo
7
- npx cli-tunnel --tunnel python -i
8
- npx cli-tunnel --tunnel htop
6
+ npx cli-tunnel copilot --yolo
7
+ npx cli-tunnel python -i
8
+ npx cli-tunnel k9s
9
9
  ```
10
10
 
11
+ ![Mobile terminal](docs/images/mobile-terminal.png)
12
+
11
13
  ## How It Works
12
14
 
13
15
  1. Your command runs in a **PTY** (pseudo-terminal) — full TUI with colors, diffs, interactive prompts
@@ -22,53 +24,104 @@ npx cli-tunnel --tunnel htop
22
24
  npm install -g cli-tunnel
23
25
  ```
24
26
 
25
- Or use directly with npx:
27
+ Or use directly with npx (no install needed):
28
+
29
+ ```bash
30
+ npx cli-tunnel <command> [args...]
31
+ ```
32
+
33
+ If devtunnel isn't installed, cli-tunnel will offer to install it for you automatically.
34
+
35
+ ## Quick Start
26
36
 
27
37
  ```bash
28
- npx cli-tunnel --tunnel <command> [args...]
38
+ # Run copilot and access it from your phone
39
+ cli-tunnel copilot --yolo
40
+
41
+ # A QR code appears — scan it with your phone
42
+ # Press any key to start the CLI tool
43
+ # Your phone now shows the exact same terminal!
29
44
  ```
30
45
 
31
46
  ## Usage
32
47
 
33
- Any flags after the command name are passed directly to the underlying app — cli-tunnel doesn't interpret them.
48
+ Devtunnel is enabled by default. All flags after the command name pass through to the underlying app — cli-tunnel doesn't interpret them.
34
49
 
35
50
  ```bash
36
- # Start copilot with remote access (--yolo is a copilot flag, not ours)
37
- cli-tunnel --tunnel copilot --yolo
51
+ # Run copilot with any flags
52
+ cli-tunnel copilot --yolo
53
+ cli-tunnel copilot --model claude-sonnet-4 --agent squad
38
54
 
39
- # Pass any flags to the underlying command
40
- cli-tunnel --tunnel copilot --model claude-sonnet-4 --agent squad
41
- cli-tunnel --tunnel copilot --allow-all --resume
55
+ # Name your session (shows in the hub dashboard)
56
+ cli-tunnel --name wizard copilot --agent squad
42
57
 
43
- # Name your session (shows in dashboard)
44
- cli-tunnel --tunnel --name wizard copilot --agent squad
58
+ # Works with any CLI app
59
+ cli-tunnel python -i
60
+ cli-tunnel vim myfile.txt
61
+ cli-tunnel htop
62
+ cli-tunnel k9s
63
+ cli-tunnel ssh user@server
45
64
 
46
65
  # Specific port
47
- cli-tunnel --tunnel --port 4000 copilot
66
+ cli-tunnel --port 4000 copilot
48
67
 
49
- # Works with any CLI app all their flags pass through
50
- cli-tunnel --tunnel python -i
51
- cli-tunnel --tunnel vim myfile.txt
52
- cli-tunnel --tunnel htop
53
- cli-tunnel --tunnel ssh user@server
68
+ # Local only (no tunnel, localhost access only)
69
+ cli-tunnel --local copilot --yolo
70
+ ```
54
71
 
55
- # Local only (no tunnel)
56
- cli-tunnel copilot
72
+ **cli-tunnel's own flags** (`--local`, `--port`, `--name`, `--replay`) must come **before** the command.
73
+
74
+ ## Hub Mode — Sessions Dashboard
75
+
76
+ Run `cli-tunnel` with no command to start **hub mode** — a dashboard that shows all your active sessions across machines.
77
+
78
+ ```bash
79
+ cli-tunnel
57
80
  ```
58
81
 
59
- **cli-tunnel's own flags** (`--tunnel`, `--port`, `--name`) must come **before** the command. Everything after the command name passes through unchanged.
82
+ ![Hub dashboard](docs/images/hub-dashboard.png)
83
+
84
+ The hub discovers sessions via devtunnel labels. Sessions on the same machine are directly connectable — tap a session card to open it. Remote sessions (other machines) are visible but shown with a 🔒 icon.
85
+
86
+ ## Grid View — Monitor All Sessions
87
+
88
+ When the hub has 2+ connectable sessions, a **⊞ Grid** button appears. Click it to see all sessions as live terminals — like tmux in your browser.
89
+
90
+ Four layout modes, switchable without reconnecting:
91
+
92
+ ### ⊞ Tiles — Overview
93
+ Scaled-down terminal previews in a card grid, like Windows Task View. Click any tile to go fullscreen.
94
+
95
+ ![Tiles view](docs/images/grid-tiles.png)
96
+
97
+ ### ⊟ Tmux — Split Panels
98
+ Equal split panels with layout presets: **Equal**, **Main+Side**, and **Stacked**.
99
+
100
+ ![Tmux view](docs/images/grid-tmux.png)
101
+
102
+ ### ◉ Focus — Presentation Mode
103
+ One terminal takes the full screen. Other sessions shown as clickable strips at the bottom — tap to swap.
104
+
105
+ ![Focus view](docs/images/grid-focus.png)
106
+
107
+ ### ⊡ Fullscreen
108
+ Single terminal with key bar for mobile input. "← Grid" button to go back.
109
+
110
+ ![Fullscreen view](docs/images/grid-fullscreen.png)
111
+
112
+ All modes share the same WebSocket connections — switching is instant, no reconnection needed.
60
113
 
61
114
  ## What You See on Your Phone
62
115
 
63
116
  - **Full terminal** rendered by xterm.js — exact same output as your local terminal
64
117
  - **Key bar** with ↑ ↓ → ← Tab Enter Esc Ctrl+C for mobile navigation
65
- - **Sessions dashboard** — see all running sessions, tap to connect
66
- - **Session cleanup** — remove stale tunnels
118
+ - **Sessions button** — switch between terminal and sessions dashboard
119
+ - **QR code** — scan from your phone to connect instantly
67
120
 
68
121
  ## Prerequisites
69
122
 
70
- - [Node.js](https://nodejs.org/) 22+
71
- - [Microsoft Dev Tunnels CLI](https://aka.ms/devtunnels/doc) (for `--tunnel` mode)
123
+ - [Node.js](https://nodejs.org/) 22+ (Node 20 works too; Node 23 may need the latest beta)
124
+ - [Microsoft Dev Tunnels CLI](https://aka.ms/devtunnels/doc) cli-tunnel offers to install it if missing
72
125
  ```bash
73
126
  winget install Microsoft.devtunnel # Windows
74
127
  brew install --cask devtunnel # macOS
@@ -81,23 +134,25 @@ cli-tunnel uses a layered security model:
81
134
 
82
135
  **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
136
 
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.
137
+ **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.
85
138
 
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.
139
+ **Ticket-based WebSocket auth** — 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
140
 
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.
141
+ **Rate limiting** — Per-IP rate limits on all endpoints (30 requests/minute for HTTP, 10/minute for ticket minting). Returns 429 Too Many Requests when exceeded.
89
142
 
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.
143
+ **Input validation** — Only structured JSON messages are accepted over WebSocket. Raw text is rejected and logged. Terminal resize commands are bounds-checked (1–500 cols, 1–200 rows).
91
144
 
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.
145
+ **Environment isolation** — The child process receives filtered environment variables. Dangerous variables (NODE_OPTIONS, BASH_ENV, LD_PRELOAD, etc.) and secrets (tokens, keys, passwords) are stripped.
93
146
 
94
- **Connection limits** — Maximum 5 concurrent WebSocket connections. Sessions expire after 24 hours.
147
+ **Audit logging** — All remote keyboard input is logged to `~/.cli-tunnel/audit/` in JSONL format with timestamps and source addresses. Secrets are automatically redacted (OpenAI, GitHub, AWS, JWT, Slack, npm, PEM, Bearer tokens).
95
148
 
96
- ## Terminal Size Behavior
149
+ **Connection limits** Maximum 5 concurrent WebSocket connections (2 per IP). Ping/pong heartbeat every 30 seconds cleans stale connections. Sessions expire after 4 hours.
97
150
 
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.
151
+ **Security headers** CSP (no unsafe-inline for scripts), HSTS, X-Frame-Options: DENY, X-Content-Type-Options: nosniff, Referrer-Policy: no-referrer, Cache-Control: no-store.
99
152
 
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.
153
+ ## Terminal Size Behavior
154
+
155
+ cli-tunnel uses a single PTY shared between your local terminal and all remote viewers. When a phone connects, the PTY resizes to match the remote device's screen dimensions — the CLI app renders correctly on the device you're actively using.
101
156
 
102
157
  **Tips for the best experience:**
103
158
  - Rotate your phone to landscape for a wider terminal
@@ -107,7 +162,7 @@ Because the PTY can only have one size at a time, the local terminal on your mac
107
162
  ## FAQ
108
163
 
109
164
  **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.
165
+ Yes, up to 5 devices simultaneously (2 per IP). All viewers see the same terminal output in real time. Input from any device goes to the same CLI session.
111
166
 
112
167
  **What happens if my phone disconnects?**
113
168
  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.
@@ -119,18 +174,21 @@ Yes. Any command that runs in a terminal works — copilot, vim, htop, python, s
119
174
  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
175
 
121
176
  **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.
177
+ The first time you open a devtunnel URL, Microsoft shows an interstitial warning page. This is a devtunnel security feature. You only see it once per tunnel.
123
178
 
124
179
  **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.
180
+ Yes. Use `--local` to skip tunnel creation. The terminal is available at `http://127.0.0.1:<port>` on localhost only.
126
181
 
127
182
  **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.
183
+ Run `cli-tunnel` with no command to start hub mode — a sessions dashboard that shows all active cli-tunnel sessions. Tap any session to connect, or use Grid view to monitor all sessions simultaneously.
184
+
185
+ **How does the Grid view connect to sessions?**
186
+ The hub reads session tokens from `~/.cli-tunnel/sessions/` (files with owner-only permissions). It proxies ticket requests to each session's local port — no tokens are exposed to the browser client.
129
187
 
130
188
  ## How It's Built
131
189
 
132
190
  - **[node-pty](https://github.com/microsoft/node-pty)** — spawns the command in a pseudo-terminal
133
- - **[xterm.js](https://xtermjs.org/)** — terminal emulator in the browser (loaded from CDN)
191
+ - **[xterm.js](https://xtermjs.org/)** — terminal emulator in the browser (loaded from CDN with SRI hashes)
134
192
  - **[ws](https://github.com/websockets/ws)** — WebSocket server for real-time streaming
135
193
  - **[Dev Tunnels](https://learn.microsoft.com/en-us/azure/developer/dev-tunnels/)** — authenticated HTTPS relay
136
194
 
package/dist/index.js CHANGED
@@ -23,6 +23,7 @@ import http from 'node:http';
23
23
  import readline from 'node:readline';
24
24
  import { WebSocketServer, WebSocket } from 'ws';
25
25
  import os from 'node:os';
26
+ import { redactSecrets } from './redact.js';
26
27
  function askUser(question) {
27
28
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
28
29
  return new Promise((resolve) => {
@@ -151,8 +152,8 @@ function readLocalSessions() {
151
152
  return [];
152
153
  }
153
154
  }
154
- // ─── F-18: Session TTL (24 hours) ──────────────────────────
155
- const SESSION_TTL = 24 * 60 * 60 * 1000; // 24 hours
155
+ // ─── F-18: Session TTL (4 hours) ───────────────────────────
156
+ const SESSION_TTL = 4 * 60 * 60 * 1000; // 4 hours
156
157
  const sessionCreatedAt = Date.now();
157
158
  // ─── F-02: One-time ticket store for WebSocket auth ────────
158
159
  const tickets = new Map();
@@ -165,24 +166,6 @@ setInterval(() => {
165
166
  }
166
167
  }, 30000);
167
168
  // ─── Security: Redact secrets from replay events ────────────
168
- function redactSecrets(text) {
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
- }
186
169
  // ─── Bridge server ──────────────────────────────────────────
187
170
  const acpEventLog = [];
188
171
  const connections = new Map();
@@ -195,7 +178,51 @@ setInterval(() => {
195
178
  }
196
179
  }
197
180
  }, 60000);
198
- const server = http.createServer((req, res) => {
181
+ // ─── F-8: Per-IP rate limiter ───────────────────────────────
182
+ const rateLimits = new Map();
183
+ const ticketRateLimits = new Map();
184
+ function checkRateLimit(ip, map, maxRequests) {
185
+ const now = Date.now();
186
+ const entry = map.get(ip);
187
+ if (!entry || entry.resetAt < now) {
188
+ map.set(ip, { count: 1, resetAt: now + 60000 });
189
+ return true;
190
+ }
191
+ entry.count++;
192
+ return entry.count <= maxRequests;
193
+ }
194
+ // Clean up rate limit maps every 60s
195
+ setInterval(() => {
196
+ const now = Date.now();
197
+ for (const [ip, entry] of rateLimits) {
198
+ if (entry.resetAt < now)
199
+ rateLimits.delete(ip);
200
+ }
201
+ for (const [ip, entry] of ticketRateLimits) {
202
+ if (entry.resetAt < now)
203
+ ticketRateLimits.delete(ip);
204
+ }
205
+ }, 60000);
206
+ const server = http.createServer(async (req, res) => {
207
+ const clientIp = req.socket.remoteAddress || 'unknown';
208
+ // F-8: Rate limiting for HTTP endpoints
209
+ if (req.url?.startsWith('/api/')) {
210
+ const isTicket = req.url === '/api/auth/ticket';
211
+ if (isTicket) {
212
+ if (!checkRateLimit(clientIp, ticketRateLimits, 10)) {
213
+ res.writeHead(429, { 'Content-Type': 'application/json' });
214
+ res.end(JSON.stringify({ error: 'Too Many Requests' }));
215
+ return;
216
+ }
217
+ }
218
+ else {
219
+ if (!checkRateLimit(clientIp, rateLimits, 30)) {
220
+ res.writeHead(429, { 'Content-Type': 'application/json' });
221
+ res.end(JSON.stringify({ error: 'Too Many Requests' }));
222
+ return;
223
+ }
224
+ }
225
+ }
199
226
  // F-18: Session expiry check for API routes
200
227
  if (!hubMode && req.url?.startsWith('/api/') && Date.now() - sessionCreatedAt > SESSION_TTL) {
201
228
  res.writeHead(401, { 'Content-Type': 'application/json' });
@@ -211,13 +238,14 @@ const server = http.createServer((req, res) => {
211
238
  return;
212
239
  }
213
240
  const ticket = crypto.randomUUID();
214
- tickets.set(ticket, { expires: Date.now() + 60000 });
241
+ const expiresAt = Date.now() + 60000;
242
+ tickets.set(ticket, { expires: expiresAt });
215
243
  res.writeHead(200, { 'Content-Type': 'application/json' });
216
- res.end(JSON.stringify({ ticket, expires: Date.now() + 60000 }));
244
+ res.end(JSON.stringify({ ticket, expires: expiresAt }));
217
245
  return;
218
246
  }
219
- // F-01: Session token check for all API routes (skip in hub mode)
220
- if (!hubMode && req.url?.startsWith('/api/')) {
247
+ // F-01: Session token check for all API routes
248
+ if (req.url?.startsWith('/api/')) {
221
249
  const reqUrl = new URL(req.url, `http://${req.headers.host}`);
222
250
  const authToken = req.headers.authorization?.replace('Bearer ', '') || reqUrl.searchParams.get('token');
223
251
  if (authToken !== sessionToken) {
@@ -226,6 +254,46 @@ const server = http.createServer((req, res) => {
226
254
  return;
227
255
  }
228
256
  }
257
+ // Hub ticket proxy — fetch ticket from local session on behalf of grid client
258
+ if (hubMode && req.url?.startsWith('/api/proxy/ticket/') && req.method === 'POST') {
259
+ const ticketPathMatch = req.url?.match(/^\/api\/proxy\/ticket\/(\d+)$/);
260
+ if (!ticketPathMatch) {
261
+ res.writeHead(400, { 'Content-Type': 'application/json' });
262
+ res.end(JSON.stringify({ error: 'Invalid port' }));
263
+ return;
264
+ }
265
+ const targetPort = parseInt(ticketPathMatch[1], 10);
266
+ if (!Number.isFinite(targetPort) || targetPort < 1 || targetPort > 65535) {
267
+ res.writeHead(400, { 'Content-Type': 'application/json' });
268
+ res.end(JSON.stringify({ error: 'Invalid port' }));
269
+ return;
270
+ }
271
+ // Find token for this port from session files
272
+ const localSessions = readLocalSessions();
273
+ const session = localSessions.find(s => s.port === targetPort);
274
+ if (!session) {
275
+ res.writeHead(404, { 'Content-Type': 'application/json' });
276
+ res.end(JSON.stringify({ error: 'Session not found' }));
277
+ return;
278
+ }
279
+ try {
280
+ const ticketResp = await fetch(`http://127.0.0.1:${targetPort}/api/auth/ticket`, {
281
+ method: 'POST', headers: { 'Authorization': `Bearer ${session.token}` },
282
+ signal: AbortSignal.timeout(3000),
283
+ });
284
+ if (!ticketResp.ok)
285
+ throw new Error('Ticket request failed');
286
+ const ticketData = await ticketResp.json();
287
+ res.writeHead(200, { 'Content-Type': 'application/json' });
288
+ res.end(JSON.stringify({ ticket: ticketData.ticket, port: targetPort }));
289
+ }
290
+ catch {
291
+ res.writeHead(502, { 'Content-Type': 'application/json' });
292
+ res.end(JSON.stringify({ error: 'Session unreachable' }));
293
+ return;
294
+ }
295
+ return;
296
+ }
229
297
  // Sessions API
230
298
  if ((req.url === '/api/sessions' || req.url?.startsWith('/api/sessions?')) && req.method === 'GET') {
231
299
  try {
@@ -256,7 +324,7 @@ const server = http.createServer((req, res) => {
256
324
  const baseId = t.tunnelId?.split('.')[0] || t.tunnelId;
257
325
  const token = tokenMap.get(baseId) || tokenMap.get(t.tunnelId);
258
326
  if (token)
259
- session.token = token;
327
+ session.hasToken = true;
260
328
  return session;
261
329
  });
262
330
  res.writeHead(200, { 'Content-Type': 'application/json', 'X-Frame-Options': 'DENY', 'X-Content-Type-Options': 'nosniff' });
@@ -335,9 +403,10 @@ const server = http.createServer((req, res) => {
335
403
  'Content-Type': mimes[ext] || 'application/octet-stream',
336
404
  'X-Frame-Options': 'DENY',
337
405
  'X-Content-Type-Options': 'nosniff',
338
- 'Content-Security-Policy': "default-src 'self'; script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; connect-src 'self' ws://localhost:* wss://*.devtunnels.ms;",
406
+ '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://localhost:* ws://127.0.0.1:* wss://*.devtunnels.ms https://*.devtunnels.ms;",
339
407
  'Referrer-Policy': 'no-referrer',
340
408
  'Cache-Control': 'no-store',
409
+ 'Strict-Transport-Security': 'max-age=31536000; includeSubDomains',
341
410
  };
342
411
  res.writeHead(200, securityHeaders);
343
412
  // #8: Handle createReadStream errors
@@ -351,24 +420,10 @@ const wss = new WebSocketServer({
351
420
  server,
352
421
  maxPayload: 1048576,
353
422
  verifyClient: (info) => {
354
- if (hubMode)
355
- return true; // Hub mode doesn't need WS auth
356
423
  // F-18: Session expiry
357
424
  if (Date.now() - sessionCreatedAt > SESSION_TTL)
358
425
  return false;
359
- const url = new URL(info.req.url, `http://${info.req.headers.host}`);
360
- // F-02: Accept one-time ticket
361
- const ticket = url.searchParams.get('ticket');
362
- if (ticket && tickets.has(ticket)) {
363
- const t = tickets.get(ticket);
364
- tickets.delete(ticket); // Single use
365
- return t.expires > Date.now();
366
- }
367
- // Backward compat: accept token
368
- if (url.searchParams.get('token') !== sessionToken)
369
- return false;
370
- // Validate origin if present
371
- // #28: Proper origin validation using URL parsing
426
+ // F-3: Validate origin BEFORE ticket acceptance
372
427
  const origin = info.req.headers.origin;
373
428
  if (origin) {
374
429
  try {
@@ -382,7 +437,15 @@ const wss = new WebSocketServer({
382
437
  return false;
383
438
  }
384
439
  }
385
- return true;
440
+ const url = new URL(info.req.url, `http://${info.req.headers.host}`);
441
+ // F-02: Accept one-time ticket (only auth method for WS)
442
+ const ticket = url.searchParams.get('ticket');
443
+ if (ticket && tickets.has(ticket)) {
444
+ const t = tickets.get(ticket);
445
+ tickets.delete(ticket); // Single use
446
+ return t.expires > Date.now();
447
+ }
448
+ return false;
386
449
  },
387
450
  });
388
451
  // ─── Security: Audit log for remote PTY input ──────────────
@@ -392,14 +455,27 @@ const auditLogPath = path.join(auditDir, `audit-${new Date().toISOString().slice
392
455
  const auditLog = fs.createWriteStream(auditLogPath, { flags: 'a' });
393
456
  auditLog.on('error', (err) => { console.error('Audit log error:', err.message); });
394
457
  wss.on('connection', (ws, req) => {
395
- // F-10: Connection cap
458
+ // F-10: Connection cap (global + per-IP)
396
459
  if (connections.size >= 5) {
397
460
  ws.close(1013, 'Max connections reached');
398
461
  return;
399
462
  }
400
- const id = crypto.randomUUID();
401
463
  const remoteAddress = req.socket.remoteAddress || 'unknown';
464
+ let perIpCount = 0;
465
+ for (const [, c] of connections) {
466
+ if (c._remoteAddress === remoteAddress)
467
+ perIpCount++;
468
+ }
469
+ if (perIpCount >= 2) {
470
+ ws.close(1013, 'Max connections per IP reached');
471
+ return;
472
+ }
473
+ const id = crypto.randomUUID();
474
+ ws._remoteAddress = remoteAddress;
402
475
  connections.set(id, ws);
476
+ // F-10: WS ping/pong heartbeat
477
+ ws._isAlive = true;
478
+ ws.on('pong', () => { ws._isAlive = true; });
403
479
  // Replay history with secrets redacted (only if replay is enabled)
404
480
  if (hasReplay) {
405
481
  for (const event of acpEventLog) {
@@ -431,6 +507,18 @@ wss.on('connection', (ws, req) => {
431
507
  });
432
508
  ws.on('close', () => connections.delete(id));
433
509
  });
510
+ // F-10: WS heartbeat — ping every 30s, close unresponsive after 10s
511
+ setInterval(() => {
512
+ for (const [id, ws] of connections) {
513
+ if (ws._isAlive === false) {
514
+ ws.terminate();
515
+ connections.delete(id);
516
+ continue;
517
+ }
518
+ ws._isAlive = false;
519
+ ws.ping();
520
+ }
521
+ }, 30000);
434
522
  function broadcast(data) {
435
523
  const msg = JSON.stringify({ type: 'pty', data });
436
524
  if (hasReplay) {
@@ -459,7 +547,8 @@ async function main() {
459
547
  console.log(`\n${BOLD}cli-tunnel${RESET} ${DIM}v1.1.0${RESET}\n`);
460
548
  if (hubMode) {
461
549
  console.log(` ${BOLD}📋 Hub Mode${RESET} — sessions dashboard`);
462
- console.log(` ${DIM}Port:${RESET} ${actualPort}\n`);
550
+ console.log(` ${DIM}Port:${RESET} ${actualPort}`);
551
+ console.log(` ${DIM}Local URL:${RESET} http://127.0.0.1:${actualPort}?token=${sessionToken}&hub=1\n`);
463
552
  }
464
553
  else {
465
554
  console.log(` ${DIM}Command:${RESET} ${command} ${commandArgs.join(' ')}`);
@@ -580,7 +669,7 @@ async function main() {
580
669
  });
581
670
  hostProc.on('error', (e) => { clearTimeout(timeout); reject(e); });
582
671
  });
583
- const tunnelUrlWithToken = `${url}?token=${sessionToken}`;
672
+ const tunnelUrlWithToken = `${url}?token=${sessionToken}${hubMode ? '&hub=1' : ''}`;
584
673
  console.log(` ${GREEN}✓${RESET} Tunnel: ${BOLD}${tunnelUrlWithToken}${RESET}\n`);
585
674
  // Write session file for hub discovery
586
675
  writeSessionFile(tunnelId, url, actualPort);
@@ -633,6 +722,21 @@ async function main() {
633
722
  // Keep process alive
634
723
  await new Promise(() => { });
635
724
  }
725
+ // Wait for user to scan QR / copy URL before starting the CLI tool
726
+ if (hasTunnel) {
727
+ console.log(` ${BOLD}Press any key to start ${command}...${RESET}`);
728
+ await new Promise((resolve) => {
729
+ if (process.stdin.isTTY)
730
+ process.stdin.setRawMode(true);
731
+ process.stdin.resume();
732
+ process.stdin.once('data', () => {
733
+ if (process.stdin.isTTY)
734
+ process.stdin.setRawMode(false);
735
+ process.stdin.pause();
736
+ resolve();
737
+ });
738
+ });
739
+ }
636
740
  console.log(` ${DIM}Starting ${command}...${RESET}\n`);
637
741
  // Spawn PTY
638
742
  const nodePty = await import('node-pty');
@@ -660,8 +764,11 @@ async function main() {
660
764
  // Blocklist approach: pass everything except known dangerous vars and secrets
661
765
  const DANGEROUS_VARS = new Set(['NODE_OPTIONS', 'NODE_REPL_HISTORY', 'NODE_EXTRA_CA_CERTS',
662
766
  'NODE_PATH', 'NODE_REDIRECT_WARNINGS', 'NODE_PENDING_DEPRECATION',
663
- 'UV_THREADPOOL_SIZE', 'LD_PRELOAD', 'DYLD_INSERT_LIBRARIES']);
664
- const sensitivePattern = /token|secret|key|password|credential|api_key|private_key|access_key|connection_string|auth/i;
767
+ 'UV_THREADPOOL_SIZE', 'LD_PRELOAD', 'DYLD_INSERT_LIBRARIES',
768
+ 'SSH_AUTH_SOCK', 'GPG_TTY',
769
+ 'PYTHONPATH', 'BASH_ENV', 'BASH_FUNC', 'JAVA_TOOL_OPTIONS', 'JAVA_OPTIONS', '_JAVA_OPTIONS',
770
+ 'PROMPT_COMMAND', 'ENV', 'ZDOTDIR', 'PERL5OPT', 'RUBYOPT']);
771
+ const sensitivePattern = /token|secret|key|password|credential|api_key|private_key|access_key|connection_string|auth|kubeconfig|docker_host|docker_config/i;
665
772
  const safeEnv = {};
666
773
  for (const [k, v] of Object.entries(process.env)) {
667
774
  if (v !== undefined && !DANGEROUS_VARS.has(k) && !sensitivePattern.test(k)) {
@@ -674,23 +781,28 @@ async function main() {
674
781
  env: safeEnv,
675
782
  });
676
783
  // Detect CSPRNG crash (rare Node.js + PTY issue) and show helpful message
677
- let ptyExitedEarly = false;
784
+ let earlyExitCode = null;
678
785
  const earlyExitCheck = new Promise((resolve) => {
679
786
  ptyProcess.onExit(({ exitCode }) => {
680
- if (exitCode === 134 || exitCode === 3221226505) {
681
- ptyExitedEarly = true;
682
- resolve();
683
- }
787
+ earlyExitCode = exitCode;
788
+ resolve();
684
789
  });
685
790
  setTimeout(resolve, 2000);
686
791
  });
687
792
  await earlyExitCheck;
688
- if (ptyExitedEarly) {
689
- const nodeVer = process.version;
690
- console.log(` ${YELLOW}⚠${RESET} The command crashed (CSPRNG assertion failure).`);
691
- console.log(` This is a known issue with Node.js ${nodeVer} + PTY on Windows.`);
692
- console.log(` ${BOLD}Fix:${RESET} Install Node.js 22 LTS: ${GREEN}nvm install 22${RESET} or ${GREEN}winget install OpenJS.NodeJS.LTS${RESET}\n`);
693
- process.exit(1);
793
+ if (earlyExitCode !== null) {
794
+ if (earlyExitCode === 134 || earlyExitCode === 3221226505) {
795
+ const nodeVer = process.version;
796
+ console.log(` ${YELLOW}⚠${RESET} The command crashed (CSPRNG assertion failure).`);
797
+ console.log(` This is a known issue with Node.js ${nodeVer} + PTY on Windows.`);
798
+ console.log(` ${BOLD}Fix:${RESET} Install Node.js 22 LTS: ${GREEN}nvm install 22${RESET} or ${GREEN}winget install OpenJS.NodeJS.LTS${RESET}\n`);
799
+ process.exit(1);
800
+ }
801
+ else {
802
+ console.log(`\n${DIM}Process exited (code ${earlyExitCode}).${RESET}`);
803
+ server.close();
804
+ process.exit(earlyExitCode);
805
+ }
694
806
  }
695
807
  ptyProcess.onData((data) => {
696
808
  process.stdout.write(data);
@@ -0,0 +1 @@
1
+ export declare function redactSecrets(text: string): string;
package/dist/redact.js ADDED
@@ -0,0 +1,26 @@
1
+ export function redactSecrets(text) {
2
+ return text
3
+ // Generic patterns: key=value, key: value, key="value"
4
+ .replace(/(?:token|secret|key|password|credential|authorization|api_key|private_key|access_key|connection_string|db_pass|signing)[\s:="']+\S{8,}/gi, '[REDACTED]')
5
+ // OpenAI keys
6
+ .replace(/sk-[a-zA-Z0-9]{20,}/g, '[REDACTED]')
7
+ // GitHub tokens
8
+ .replace(/gh[ps]_[a-zA-Z0-9]{36,}/g, '[REDACTED]')
9
+ // AWS keys
10
+ .replace(/AKIA[A-Z0-9]{16}/g, '[REDACTED]')
11
+ // Azure connection strings
12
+ .replace(/DefaultEndpointsProtocol=[^;\s]{20,}/gi, '[REDACTED]')
13
+ .replace(/AccountKey=[^;\s]{20,}/gi, 'AccountKey=[REDACTED]')
14
+ // Database URLs
15
+ .replace(/(postgres|mongodb|mysql|redis):\/\/[^\s"']{10,}/gi, '[REDACTED]')
16
+ // Bearer tokens in headers
17
+ .replace(/Bearer\s+[a-zA-Z0-9._-]{20,}/gi, 'Bearer [REDACTED]')
18
+ // JWT tokens
19
+ .replace(/eyJ[a-zA-Z0-9_-]{10,}\.[a-zA-Z0-9_-]{10,}\.[a-zA-Z0-9_-]{10,}/g, '[REDACTED]')
20
+ // Slack tokens
21
+ .replace(/xox[bpras]-[a-zA-Z0-9-]{10,}/g, '[REDACTED]')
22
+ // npm tokens
23
+ .replace(/npm_[a-zA-Z0-9]{20,}/g, '[REDACTED]')
24
+ // PEM private keys
25
+ .replace(/-----BEGIN [A-Z ]+ PRIVATE KEY-----[\s\S]*?-----END [A-Z ]+ PRIVATE KEY-----/g, '[REDACTED]');
26
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cli-tunnel",
3
- "version": "1.2.0-beta.9",
3
+ "version": "1.2.0",
4
4
  "description": "Tunnel any CLI app to your phone — PTY + devtunnel + xterm.js",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -13,7 +13,8 @@
13
13
  ],
14
14
  "scripts": {
15
15
  "build": "tsc",
16
- "test": "vitest run"
16
+ "test": "vitest run",
17
+ "test:coverage": "vitest run --coverage"
17
18
  },
18
19
  "keywords": [
19
20
  "cli",
@@ -35,13 +36,14 @@
35
36
  "node": ">=22.0.0"
36
37
  },
37
38
  "dependencies": {
38
- "node-pty": "^1.1.0",
39
- "qrcode-terminal": "^0.12.0",
40
- "ws": "^8.19.0"
39
+ "node-pty": "1.1.0",
40
+ "qrcode-terminal": "0.12.0",
41
+ "ws": "8.19.0"
41
42
  },
42
43
  "devDependencies": {
43
44
  "@types/node": "^25.3.2",
44
45
  "@types/ws": "^8.18.1",
46
+ "@vitest/coverage-v8": "^4.0.18",
45
47
  "typescript": "^5.9.3",
46
48
  "vitest": "^4.0.18"
47
49
  }