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 +98 -40
- package/dist/index.js +172 -60
- package/dist/redact.d.ts +1 -0
- package/dist/redact.js +26 -0
- package/package.json +7 -5
- package/remote-ui/app.js +365 -71
- package/remote-ui/index.html +10 -10
- package/remote-ui/styles.css +131 -3
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
|
|
7
|
-
npx cli-tunnel
|
|
8
|
-
npx cli-tunnel
|
|
6
|
+
npx cli-tunnel copilot --yolo
|
|
7
|
+
npx cli-tunnel python -i
|
|
8
|
+
npx cli-tunnel k9s
|
|
9
9
|
```
|
|
10
10
|
|
|
11
|
+

|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
37
|
-
cli-tunnel
|
|
51
|
+
# Run copilot with any flags
|
|
52
|
+
cli-tunnel copilot --yolo
|
|
53
|
+
cli-tunnel copilot --model claude-sonnet-4 --agent squad
|
|
38
54
|
|
|
39
|
-
#
|
|
40
|
-
cli-tunnel --
|
|
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
|
-
#
|
|
44
|
-
cli-tunnel
|
|
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 --
|
|
66
|
+
cli-tunnel --port 4000 copilot
|
|
48
67
|
|
|
49
|
-
#
|
|
50
|
-
cli-tunnel --
|
|
51
|
-
|
|
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
|
-
|
|
56
|
-
|
|
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
|
-
|
|
82
|
+

|
|
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
|
+

|
|
96
|
+
|
|
97
|
+
### ⊟ Tmux — Split Panels
|
|
98
|
+
Equal split panels with layout presets: **Equal**, **Main+Side**, and **Stacked**.
|
|
99
|
+
|
|
100
|
+

|
|
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
|
+

|
|
106
|
+
|
|
107
|
+
### ⊡ Fullscreen
|
|
108
|
+
Single terminal with key bar for mobile input. "← Grid" button to go back.
|
|
109
|
+
|
|
110
|
+

|
|
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
|
|
66
|
-
- **
|
|
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)
|
|
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
|
|
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** —
|
|
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
|
-
**
|
|
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
|
-
**
|
|
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
|
-
**
|
|
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
|
-
**
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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 (
|
|
155
|
-
const SESSION_TTL =
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
244
|
+
res.end(JSON.stringify({ ticket, expires: expiresAt }));
|
|
217
245
|
return;
|
|
218
246
|
}
|
|
219
|
-
// F-01: Session token check for all API routes
|
|
220
|
-
if (
|
|
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.
|
|
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'
|
|
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
|
-
|
|
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
|
-
|
|
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}
|
|
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
|
-
|
|
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
|
|
784
|
+
let earlyExitCode = null;
|
|
678
785
|
const earlyExitCheck = new Promise((resolve) => {
|
|
679
786
|
ptyProcess.onExit(({ exitCode }) => {
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
resolve();
|
|
683
|
-
}
|
|
787
|
+
earlyExitCode = exitCode;
|
|
788
|
+
resolve();
|
|
684
789
|
});
|
|
685
790
|
setTimeout(resolve, 2000);
|
|
686
791
|
});
|
|
687
792
|
await earlyExitCheck;
|
|
688
|
-
if (
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
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);
|
package/dist/redact.d.ts
ADDED
|
@@ -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
|
|
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": "
|
|
39
|
-
"qrcode-terminal": "
|
|
40
|
-
"ws": "
|
|
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
|
}
|