cli-tunnel 1.1.0 → 1.2.0-beta.2

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
@@ -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';
@@ -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,18 +113,85 @@ function getGitInfo() {
103
113
  }
104
114
  // ─── Security: Session token for WebSocket auth ────────────
105
115
  const sessionToken = crypto.randomUUID();
116
+ // ─── F-18: Session TTL (24 hours) ──────────────────────────
117
+ const SESSION_TTL = 24 * 60 * 60 * 1000; // 24 hours
118
+ const sessionCreatedAt = Date.now();
119
+ // ─── F-02: One-time ticket store for WebSocket auth ────────
120
+ const tickets = new Map();
121
+ // #30: Ticket GC — clean expired tickets every 30s
122
+ setInterval(() => {
123
+ const now = Date.now();
124
+ for (const [id, t] of tickets) {
125
+ if (t.expires < now)
126
+ tickets.delete(id);
127
+ }
128
+ }, 30000);
106
129
  // ─── Security: Redact secrets from replay events ────────────
107
130
  function redactSecrets(text) {
108
- return text.replace(/(?:token|secret|key|password|credential|authorization)[\s:="']+[^\s"']{8,}/gi, '$& [REDACTED]');
131
+ return text
132
+ // Generic patterns: key=value, key: value, key="value"
133
+ .replace(/(?:token|secret|key|password|credential|authorization|api_key|private_key|access_key|connection_string|db_pass|signing)[\s:="']+\S{8,}/gi, '[REDACTED]')
134
+ // OpenAI keys
135
+ .replace(/sk-[a-zA-Z0-9]{20,}/g, '[REDACTED]')
136
+ // GitHub tokens
137
+ .replace(/gh[ps]_[a-zA-Z0-9]{36,}/g, '[REDACTED]')
138
+ // AWS keys
139
+ .replace(/AKIA[A-Z0-9]{16}/g, '[REDACTED]')
140
+ // Azure connection strings
141
+ .replace(/DefaultEndpointsProtocol=[^;\s]{20,}/gi, '[REDACTED]')
142
+ .replace(/AccountKey=[^;\s]{20,}/gi, 'AccountKey=[REDACTED]')
143
+ // Database URLs
144
+ .replace(/(postgres|mongodb|mysql|redis):\/\/[^\s"']{10,}/gi, '[REDACTED]')
145
+ // Bearer tokens in headers
146
+ .replace(/Bearer\s+[a-zA-Z0-9._-]{20,}/gi, 'Bearer [REDACTED]');
109
147
  }
110
148
  // ─── Bridge server ──────────────────────────────────────────
111
149
  const acpEventLog = [];
112
150
  const connections = new Map();
151
+ // #10: Session TTL enforcement — periodically close expired connections
152
+ setInterval(() => {
153
+ if (Date.now() - sessionCreatedAt > SESSION_TTL) {
154
+ for (const [id, ws] of connections) {
155
+ ws.close(1000, 'Session expired');
156
+ connections.delete(id);
157
+ }
158
+ }
159
+ }, 60000);
113
160
  const server = http.createServer((req, res) => {
161
+ // F-18: Session expiry check for API routes
162
+ if (!hubMode && req.url?.startsWith('/api/') && Date.now() - sessionCreatedAt > SESSION_TTL) {
163
+ res.writeHead(401, { 'Content-Type': 'application/json' });
164
+ res.end(JSON.stringify({ error: 'Session expired' }));
165
+ return;
166
+ }
167
+ // F-02: Ticket endpoint — exchange session token for one-time WS ticket
168
+ if (req.url === '/api/auth/ticket' && req.method === 'POST') {
169
+ const auth = req.headers.authorization?.replace('Bearer ', '');
170
+ if (auth !== sessionToken) {
171
+ res.writeHead(401);
172
+ res.end();
173
+ return;
174
+ }
175
+ const ticket = crypto.randomUUID();
176
+ tickets.set(ticket, { expires: Date.now() + 60000 });
177
+ res.writeHead(200, { 'Content-Type': 'application/json' });
178
+ res.end(JSON.stringify({ ticket, expires: Date.now() + 60000 }));
179
+ return;
180
+ }
181
+ // F-01: Session token check for all API routes (skip in hub mode)
182
+ if (!hubMode && req.url?.startsWith('/api/')) {
183
+ const reqUrl = new URL(req.url, `http://${req.headers.host}`);
184
+ const authToken = req.headers.authorization?.replace('Bearer ', '') || reqUrl.searchParams.get('token');
185
+ if (authToken !== sessionToken) {
186
+ res.writeHead(401, { 'Content-Type': 'application/json' });
187
+ res.end(JSON.stringify({ error: 'Unauthorized' }));
188
+ return;
189
+ }
190
+ }
114
191
  // Sessions API
115
192
  if (req.url === '/api/sessions' && req.method === 'GET') {
116
193
  try {
117
- const output = execSync('devtunnel list --labels cli-tunnel --json', { encoding: 'utf-8', timeout: 10000, stdio: ['pipe', 'pipe', 'pipe'] });
194
+ const output = execFileSync('devtunnel', ['list', '--labels', 'cli-tunnel', '--json'], { encoding: 'utf-8', timeout: 10000, stdio: ['pipe', 'pipe', 'pipe'] });
118
195
  const data = JSON.parse(output);
119
196
  const sessions = (data.tunnels || []).map((t) => {
120
197
  const labels = t.labels || [];
@@ -163,7 +240,16 @@ const server = http.createServer((req, res) => {
163
240
  }
164
241
  // Static files
165
242
  const uiDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../remote-ui');
166
- const decodedUrl = decodeURIComponent(req.url || '/');
243
+ // #18: Guard against malformed URI encoding
244
+ let decodedUrl;
245
+ try {
246
+ decodedUrl = decodeURIComponent(req.url || '/');
247
+ }
248
+ catch {
249
+ res.writeHead(400);
250
+ res.end();
251
+ return;
252
+ }
167
253
  if (decodedUrl.includes('..')) {
168
254
  res.writeHead(400);
169
255
  res.end();
@@ -175,65 +261,132 @@ const server = http.createServer((req, res) => {
175
261
  res.end();
176
262
  return;
177
263
  }
178
- if (!fs.existsSync(filePath))
179
- filePath = path.resolve(uiDir, 'index.html');
264
+ // #2: EISDIR guard — check if path is a directory before createReadStream
265
+ try {
266
+ const stat = fs.statSync(filePath);
267
+ if (stat.isDirectory()) {
268
+ filePath = path.join(filePath, 'index.html');
269
+ if (!fs.existsSync(filePath)) {
270
+ res.writeHead(404);
271
+ res.end();
272
+ return;
273
+ }
274
+ }
275
+ }
276
+ catch {
277
+ res.writeHead(404);
278
+ res.end();
279
+ return;
280
+ }
180
281
  const ext = path.extname(filePath);
181
282
  const mimes = { '.html': 'text/html', '.js': 'application/javascript', '.css': 'text/css', '.json': 'application/json' };
182
283
  const securityHeaders = {
183
284
  'Content-Type': mimes[ext] || 'application/octet-stream',
184
285
  'X-Frame-Options': 'DENY',
185
286
  '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:;",
287
+ '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;",
288
+ 'Referrer-Policy': 'no-referrer',
289
+ 'Cache-Control': 'no-store',
187
290
  };
188
291
  res.writeHead(200, securityHeaders);
189
- fs.createReadStream(filePath).pipe(res);
292
+ // #8: Handle createReadStream errors
293
+ const stream = fs.createReadStream(filePath);
294
+ stream.on('error', () => { if (!res.headersSent) {
295
+ res.writeHead(500);
296
+ } res.end(); });
297
+ stream.pipe(res);
190
298
  });
191
299
  const wss = new WebSocketServer({
192
300
  server,
193
301
  maxPayload: 1048576,
194
302
  verifyClient: (info) => {
303
+ if (hubMode)
304
+ return true; // Hub mode doesn't need WS auth
305
+ // F-18: Session expiry
306
+ if (Date.now() - sessionCreatedAt > SESSION_TTL)
307
+ return false;
195
308
  const url = new URL(info.req.url, `http://${info.req.headers.host}`);
196
- return url.searchParams.get('token') === sessionToken;
309
+ // F-02: Accept one-time ticket
310
+ const ticket = url.searchParams.get('ticket');
311
+ if (ticket && tickets.has(ticket)) {
312
+ const t = tickets.get(ticket);
313
+ tickets.delete(ticket); // Single use
314
+ return t.expires > Date.now();
315
+ }
316
+ // Backward compat: accept token
317
+ if (url.searchParams.get('token') !== sessionToken)
318
+ return false;
319
+ // Validate origin if present
320
+ // #28: Proper origin validation using URL parsing
321
+ const origin = info.req.headers.origin;
322
+ if (origin) {
323
+ try {
324
+ const originUrl = new URL(origin);
325
+ const host = originUrl.hostname;
326
+ if (host !== 'localhost' && host !== '127.0.0.1' && !host.endsWith('.devtunnels.ms')) {
327
+ return false;
328
+ }
329
+ }
330
+ catch {
331
+ return false;
332
+ }
333
+ }
334
+ return true;
197
335
  },
198
336
  });
199
337
  // ─── Security: Audit log for remote PTY input ──────────────
200
- const auditLogPath = path.join(os.tmpdir(), `cli-tunnel-audit-${Date.now()}.log`);
338
+ const auditDir = path.join(os.homedir(), '.cli-tunnel', 'audit');
339
+ fs.mkdirSync(auditDir, { recursive: true, mode: 0o700 });
340
+ const auditLogPath = path.join(auditDir, `audit-${new Date().toISOString().slice(0, 10)}.jsonl`);
201
341
  const auditLog = fs.createWriteStream(auditLogPath, { flags: 'a' });
342
+ auditLog.on('error', (err) => { console.error('Audit log error:', err.message); });
202
343
  wss.on('connection', (ws, req) => {
203
- const id = Math.random().toString(36).substring(2);
344
+ // F-10: Connection cap
345
+ if (connections.size >= 5) {
346
+ ws.close(1013, 'Max connections reached');
347
+ return;
348
+ }
349
+ const id = crypto.randomUUID();
204
350
  const remoteAddress = req.socket.remoteAddress || 'unknown';
205
351
  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) }));
352
+ // Replay history with secrets redacted (only if replay is enabled)
353
+ if (hasReplay) {
354
+ for (const event of acpEventLog) {
355
+ ws.send(JSON.stringify({ type: '_replay', data: redactSecrets(event) }));
356
+ }
357
+ ws.send(JSON.stringify({ type: '_replay_done' }));
209
358
  }
210
- ws.send(JSON.stringify({ type: '_replay_done' }));
211
359
  ws.on('message', (data) => {
212
360
  const raw = data.toString();
213
361
  try {
214
362
  const msg = JSON.parse(raw);
215
363
  if (msg.type === 'pty_input' && ptyProcess) {
216
- auditLog.write(`${new Date().toISOString()} [${remoteAddress}] ${JSON.stringify(msg.data)}\n`);
364
+ auditLog.write(JSON.stringify({ ts: new Date().toISOString(), src: remoteAddress, type: 'pty_input', data: redactSecrets(JSON.stringify(msg.data)) }) + '\n');
217
365
  ptyProcess.write(msg.data);
218
366
  }
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);
367
+ // #7: NaN guard on pty_resize
368
+ if (msg.type === 'pty_resize') {
369
+ const cols = Number(msg.cols);
370
+ const rows = Number(msg.rows);
371
+ if (Number.isFinite(cols) && Number.isFinite(rows) && ptyProcess) {
372
+ ptyProcess.resize(Math.max(1, Math.min(500, cols)), Math.max(1, Math.min(200, rows)));
373
+ }
223
374
  }
224
375
  }
225
376
  catch {
226
- if (ptyProcess)
227
- ptyProcess.write(raw + '\r');
377
+ // #3: Log but do NOT write to PTY — only structured pty_input messages allowed
378
+ auditLog.write(JSON.stringify({ ts: new Date().toISOString(), type: 'rejected', reason: 'non-json', length: raw.length }) + '\n');
228
379
  }
229
380
  });
230
381
  ws.on('close', () => connections.delete(id));
231
382
  });
232
383
  function broadcast(data) {
233
384
  const msg = JSON.stringify({ type: 'pty', data });
234
- acpEventLog.push(msg);
235
- if (acpEventLog.length > 2000)
236
- acpEventLog.splice(0, acpEventLog.length - 2000);
385
+ if (hasReplay) {
386
+ acpEventLog.push(msg);
387
+ if (acpEventLog.length > 2000)
388
+ acpEventLog.splice(0, acpEventLog.length - 2000);
389
+ }
237
390
  for (const [, ws] of connections) {
238
391
  if (ws.readyState === WebSocket.OPEN)
239
392
  ws.send(msg);
@@ -243,7 +396,7 @@ function broadcast(data) {
243
396
  let ptyProcess = null;
244
397
  async function main() {
245
398
  const actualPort = await new Promise((resolve, reject) => {
246
- server.listen(port, () => {
399
+ server.listen(port, '127.0.0.1', () => {
247
400
  const addr = server.address();
248
401
  resolve(typeof addr === 'object' ? addr.port : port);
249
402
  });
@@ -252,60 +405,106 @@ async function main() {
252
405
  const { repo, branch } = getGitInfo();
253
406
  const machine = os.hostname();
254
407
  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}`);
408
+ console.log(`\n${BOLD}cli-tunnel${RESET} ${DIM}v1.1.0${RESET}\n`);
409
+ if (hubMode) {
410
+ console.log(` ${BOLD}📋 Hub Mode${RESET} — sessions dashboard`);
411
+ console.log(` ${DIM}Port:${RESET} ${actualPort}\n`);
412
+ }
413
+ else {
414
+ console.log(` ${DIM}Command:${RESET} ${command} ${commandArgs.join(' ')}`);
415
+ console.log(` ${DIM}Name:${RESET} ${displayName}`);
416
+ console.log(` ${DIM}Port:${RESET} ${actualPort}`);
417
+ console.log(` ${DIM}Audit log:${RESET} ${auditLogPath}`);
418
+ console.log(` ${DIM}Local URL:${RESET} http://127.0.0.1:${actualPort}?token=${sessionToken}`);
419
+ console.log(` ${DIM}Session expires:${RESET} ${new Date(sessionCreatedAt + SESSION_TTL).toLocaleTimeString()}`);
420
+ }
260
421
  // Tunnel
261
422
  if (hasTunnel) {
262
423
  // Check if devtunnel is installed
263
424
  let devtunnelInstalled = false;
264
425
  try {
265
- execSync('devtunnel --version', { stdio: 'pipe' });
426
+ execFileSync('devtunnel', ['--version'], { stdio: 'pipe' });
266
427
  devtunnelInstalled = true;
267
428
  }
268
429
  catch {
269
430
  console.log(`\n ${YELLOW}⚠ devtunnel CLI not found!${RESET}\n`);
270
- console.log(` ${BOLD}To enable remote access, install Microsoft Dev Tunnels:${RESET}\n`);
431
+ let installCmd = '';
271
432
  if (process.platform === 'win32') {
272
- console.log(` ${GREEN}winget install Microsoft.devtunnel${RESET}`);
433
+ installCmd = 'winget install Microsoft.devtunnel';
273
434
  }
274
435
  else if (process.platform === 'darwin') {
275
- console.log(` ${GREEN}brew install --cask devtunnel${RESET}`);
436
+ installCmd = 'brew install --cask devtunnel';
276
437
  }
277
438
  else {
278
- console.log(` ${GREEN}curl -sL https://aka.ms/DevTunnelCliInstall | bash${RESET}`);
439
+ installCmd = 'curl -sL https://aka.ms/DevTunnelCliInstall | bash';
279
440
  }
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
- }
285
- // Check if logged in
286
- if (devtunnelInstalled) {
287
- 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')) {
290
- 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
+ // Verify installation
452
+ execFileSync('devtunnel', ['--version'], { stdio: 'pipe' });
453
+ console.log(`\n ${GREEN}✓${RESET} devtunnel installed successfully!\n`);
454
+ devtunnelInstalled = true;
455
+ }
456
+ catch (err) {
457
+ console.log(`\n ${YELLOW}⚠${RESET} Installation failed: ${err.message}`);
458
+ console.log(` ${DIM}You can install it manually: ${installCmd}${RESET}\n`);
459
+ console.log(` ${DIM}Continuing without tunnel (local only)...${RESET}\n`);
291
460
  }
292
461
  }
293
- 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`);
462
+ else {
463
+ console.log(`\n ${DIM}More info: https://aka.ms/devtunnels/doc${RESET}`);
297
464
  console.log(` ${DIM}Continuing without tunnel (local only)...${RESET}\n`);
298
- devtunnelInstalled = false;
465
+ }
466
+ // If just installed, check login
467
+ if (devtunnelInstalled) {
468
+ try {
469
+ const userInfo = execFileSync('devtunnel', ['user', 'show'], { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] });
470
+ if (userInfo.includes('not logged in') || userInfo.includes('No user')) {
471
+ throw new Error('not logged in');
472
+ }
473
+ }
474
+ catch {
475
+ console.log(` ${YELLOW}⚠ devtunnel not authenticated.${RESET}\n`);
476
+ const loginAnswer = await askUser(` Would you like to log in now? [Y/n] `);
477
+ if (loginAnswer === '' || loginAnswer === 'y' || loginAnswer === 'yes') {
478
+ try {
479
+ const loginProc = spawn('devtunnel', ['user', 'login'], { stdio: 'inherit' });
480
+ await new Promise((resolve, reject) => {
481
+ loginProc.on('close', (code) => code === 0 ? resolve() : reject(new Error(`Login exited with code ${code}`)));
482
+ loginProc.on('error', reject);
483
+ });
484
+ console.log(`\n ${GREEN}✓${RESET} Logged in successfully!\n`);
485
+ }
486
+ catch {
487
+ console.log(`\n ${YELLOW}⚠${RESET} Login failed. Run manually: ${GREEN}devtunnel user login${RESET}\n`);
488
+ console.log(` ${DIM}Continuing without tunnel (local only)...${RESET}\n`);
489
+ devtunnelInstalled = false;
490
+ }
491
+ }
492
+ else {
493
+ console.log(`\n ${DIM}Run this once to log in: ${GREEN}devtunnel user login${RESET}`);
494
+ console.log(` ${DIM}Continuing without tunnel (local only)...${RESET}\n`);
495
+ devtunnelInstalled = false;
496
+ }
497
+ }
299
498
  }
300
499
  }
301
500
  if (devtunnelInstalled) {
302
501
  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'] });
502
+ const labelValues = ['cli-tunnel', sanitizeLabel(sessionName || command), sanitizeLabel(repo), sanitizeLabel(branch), sanitizeLabel(machine), `port-${actualPort}`];
503
+ const labelArgs = labelValues.flatMap(l => ['--labels', l]);
504
+ const createOut = execFileSync('devtunnel', ['create', ...labelArgs, '--expiration', '1d', '--json'], { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] });
306
505
  const tunnelId = JSON.parse(createOut).tunnel?.tunnelId?.split('.')[0];
307
506
  const cluster = JSON.parse(createOut).tunnel?.tunnelId?.split('.')[1] || 'euw';
308
- execSync(`devtunnel port create ${tunnelId} -p ${actualPort} --protocol http`, { stdio: 'pipe' });
507
+ execFileSync('devtunnel', ['port', 'create', tunnelId, '-p', String(actualPort), '--protocol', 'http'], { stdio: 'pipe' });
309
508
  const hostProc = spawn('devtunnel', ['host', tunnelId], { stdio: 'pipe', detached: false });
310
509
  const url = await new Promise((resolve, reject) => {
311
510
  const timeout = setTimeout(() => reject(new Error('Tunnel timeout')), 15000);
@@ -329,11 +528,11 @@ async function main() {
329
528
  }
330
529
  catch { }
331
530
  process.on('SIGINT', () => { hostProc.kill(); try {
332
- execSync(`devtunnel delete ${tunnelId} --force`, { stdio: 'pipe' });
531
+ execFileSync('devtunnel', ['delete', tunnelId, '--force'], { stdio: 'pipe' });
333
532
  }
334
533
  catch { } });
335
534
  process.on('exit', () => { hostProc.kill(); try {
336
- execSync(`devtunnel delete ${tunnelId} --force`, { stdio: 'pipe' });
535
+ execFileSync('devtunnel', ['delete', tunnelId, '--force'], { stdio: 'pipe' });
337
536
  }
338
537
  catch { } });
339
538
  }
@@ -342,6 +541,14 @@ async function main() {
342
541
  }
343
542
  } // end if (devtunnelInstalled)
344
543
  }
544
+ if (hubMode) {
545
+ // Hub mode — just serve the sessions dashboard, no PTY
546
+ console.log(` ${GREEN}✓${RESET} Hub running — open in browser to see all sessions\n`);
547
+ console.log(` ${DIM}Press Ctrl+C to stop.${RESET}\n`);
548
+ process.on('SIGINT', () => { server.close(); process.exit(0); });
549
+ // Keep process alive
550
+ await new Promise(() => { });
551
+ }
345
552
  console.log(` ${DIM}Starting ${command}...${RESET}\n`);
346
553
  // Spawn PTY
347
554
  const nodePty = await import('node-pty');
@@ -351,7 +558,7 @@ async function main() {
351
558
  let resolvedCmd = command;
352
559
  if (process.platform === 'win32') {
353
560
  try {
354
- const wherePaths = execSync(`where ${command}`, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim().split('\n');
561
+ const wherePaths = execFileSync('where', [command], { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim().split('\n');
355
562
  // Prefer .exe or .cmd over .ps1 for node-pty compatibility
356
563
  const exePath = wherePaths.find(p => p.trim().endsWith('.exe')) || wherePaths.find(p => p.trim().endsWith('.cmd'));
357
564
  if (exePath) {
@@ -365,11 +572,27 @@ async function main() {
365
572
  }
366
573
  catch { /* use as-is */ }
367
574
  }
368
- // Security: filter sensitive environment variables
575
+ // F-07: Security allowlist safe environment variables for PTY
576
+ const SAFE_ENV_VARS = new Set([
577
+ 'PATH', 'HOME', 'USERPROFILE', 'SHELL', 'TERM', 'LANG', 'LC_ALL', 'LC_CTYPE',
578
+ 'USER', 'LOGNAME', 'EDITOR', 'VISUAL', 'COLORTERM', 'TERM_PROGRAM',
579
+ 'HOSTNAME', 'COMPUTERNAME', 'PWD', 'OLDPWD', 'SHLVL', 'TMPDIR', 'TMP', 'TEMP',
580
+ 'XDG_RUNTIME_DIR', 'XDG_DATA_HOME', 'XDG_CONFIG_HOME', 'XDG_CACHE_HOME',
581
+ 'DISPLAY', 'WAYLAND_DISPLAY', 'DBUS_SESSION_BUS_ADDRESS',
582
+ 'PROGRAMFILES', 'PROGRAMFILES(X86)', 'SYSTEMROOT', 'WINDIR', 'COMSPEC',
583
+ 'APPDATA', 'LOCALAPPDATA', 'PROGRAMDATA',
584
+ 'NODE_ENV',
585
+ 'GOPATH', 'GOROOT', 'CARGO_HOME', 'RUSTUP_HOME',
586
+ 'JAVA_HOME', 'MAVEN_HOME', 'GRADLE_HOME',
587
+ 'PYTHONPATH', 'VIRTUAL_ENV', 'CONDA_DEFAULT_ENV',
588
+ 'KUBECONFIG', 'DOCKER_HOST', 'DOCKER_CONFIG',
589
+ 'GIT_AUTHOR_NAME', 'GIT_AUTHOR_EMAIL', 'GIT_COMMITTER_NAME', 'GIT_COMMITTER_EMAIL',
590
+ 'HTTP_PROXY', 'HTTPS_PROXY', 'NO_PROXY', 'http_proxy', 'https_proxy', 'no_proxy',
591
+ 'SSH_AUTH_SOCK', 'GPG_TTY',
592
+ ]);
369
593
  const safeEnv = {};
370
- const sensitivePatterns = /token|secret|key|password|credential|api_key|private/i;
371
594
  for (const [k, v] of Object.entries(process.env)) {
372
- if (!sensitivePatterns.test(k) && v !== undefined) {
595
+ if (SAFE_ENV_VARS.has(k) && v !== undefined) {
373
596
  safeEnv[k] = v;
374
597
  }
375
598
  }
@@ -378,6 +601,27 @@ async function main() {
378
601
  cols, rows, cwd,
379
602
  env: safeEnv,
380
603
  });
604
+ // Detect CSPRNG crash (Node.js + node-pty + ConPTY issue) and retry with useConpty: false
605
+ let ptyExitedEarly = false;
606
+ const earlyExitCheck = new Promise((resolve) => {
607
+ ptyProcess.onExit(({ exitCode }) => {
608
+ if (exitCode === 134 || exitCode === 3221226505) { // 134 = SIGABRT, 3221226505 = STATUS_BREAKPOINT
609
+ ptyExitedEarly = true;
610
+ resolve();
611
+ }
612
+ });
613
+ setTimeout(resolve, 2000); // Wait 2s — if still running, it's fine
614
+ });
615
+ await earlyExitCheck;
616
+ if (ptyExitedEarly && process.platform === 'win32') {
617
+ console.log(` ${YELLOW}⚠${RESET} ConPTY crash detected, retrying with legacy PTY backend...\n`);
618
+ ptyProcess = nodePty.spawn(resolvedCmd, commandArgs, {
619
+ name: 'xterm-256color',
620
+ cols, rows, cwd,
621
+ env: safeEnv,
622
+ useConpty: false,
623
+ });
624
+ }
381
625
  ptyProcess.onData((data) => {
382
626
  process.stdout.write(data);
383
627
  broadcast(data);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "cli-tunnel",
3
- "version": "1.1.0",
4
- "description": "Tunnel any CLI app to your phone - PTY + devtunnel + xterm.js",
3
+ "version": "1.2.0-beta.2",
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": {
package/remote-ui/app.js CHANGED
@@ -5,6 +5,27 @@
5
5
  (function () {
6
6
  'use strict';
7
7
 
8
+ // ─── Mobile keyboard viewport fix ────────────────────────
9
+ // Keep the key bar visible above the on-screen keyboard
10
+ if (window.visualViewport) {
11
+ window.visualViewport.addEventListener('resize', () => {
12
+ const vv = window.visualViewport;
13
+ const inputArea = document.getElementById('input-area');
14
+ if (inputArea && vv) {
15
+ const offset = window.innerHeight - vv.height - vv.offsetTop;
16
+ inputArea.style.transform = offset > 0 ? `translateY(-${offset}px)` : '';
17
+ }
18
+ });
19
+ window.visualViewport.addEventListener('scroll', () => {
20
+ const vv = window.visualViewport;
21
+ const inputArea = document.getElementById('input-area');
22
+ if (inputArea && vv) {
23
+ const offset = window.innerHeight - vv.height - vv.offsetTop;
24
+ inputArea.style.transform = offset > 0 ? `translateY(-${offset}px)` : '';
25
+ }
26
+ });
27
+ }
28
+
8
29
  let ws = null;
9
30
  let connected = false;
10
31
  let sessionId = null;
@@ -98,7 +119,7 @@
98
119
  const data = await resp.json();
99
120
  renderDashboard(data.sessions || []);
100
121
  } catch (err) {
101
- dashboard.innerHTML = '<div style="padding:12px;color:var(--red)">Failed to load sessions: ' + err.message + '</div>';
122
+ dashboard.innerHTML = '<div style="padding:12px;color:var(--red)">' + escapeHtml('Failed to load sessions: ' + err.message) + '</div>';
102
123
  }
103
124
  }
104
125
 
@@ -121,7 +142,7 @@
121
142
  '</div>';
122
143
  } else {
123
144
  html += filtered.map(s => `
124
- <div class="session-card" ${s.online ? 'onclick="openSession(\'' + escapeHtml(s.url) + '\')"' : ''}>
145
+ <div class="session-card" ${s.online ? 'data-session-url="' + escapeHtml(s.url) + '"' : ''}>
125
146
  <span class="status-dot ${s.online ? 'online' : 'offline'}"></span>
126
147
  <div class="info">
127
148
  <div class="repo">📦 ${escapeHtml(s.repo)}</div>
@@ -129,11 +150,18 @@
129
150
  <div class="machine">💻 ${escapeHtml(s.machine)}</div>
130
151
  </div>
131
152
  ${s.online ? '<span class="arrow">→</span>' :
132
- '<button onclick="event.stopPropagation();deleteSession(\'' + escapeHtml(s.id) + '\')" style="background:none;border:none;color:var(--red);cursor:pointer;font-size:14px" title="Remove">✕</button>'}
153
+ '<button data-delete-id="' + escapeHtml(s.id) + '" style="background:none;border:none;color:var(--red);cursor:pointer;font-size:14px" title="Remove">✕</button>'}
133
154
  </div>
134
155
  `).join('');
135
156
  }
136
157
  dashboard.innerHTML = html;
158
+ // #16: XSS fix — use event delegation instead of inline onclick
159
+ dashboard.querySelectorAll('.session-card[data-session-url]').forEach(function(card) {
160
+ card.addEventListener('click', function() { openSession(card.dataset.sessionUrl); });
161
+ });
162
+ dashboard.querySelectorAll('[data-delete-id]').forEach(function(btn) {
163
+ btn.addEventListener('click', function(e) { e.stopPropagation(); deleteSession(btn.dataset.deleteId); });
164
+ });
137
165
  }
138
166
 
139
167
  window.openSession = (url) => {
@@ -338,14 +366,49 @@
338
366
  }
339
367
  }
340
368
 
369
+ // ─── Detect hub mode (no token in URL) ────────────────────
370
+ const isHubMode = !new URLSearchParams(window.location.search).get('token');
371
+
341
372
  // ─── WebSocket ───────────────────────────────────────────
342
373
  let reconnectAttempt = 0;
343
374
 
344
- function connect() {
345
- const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
375
+ async function connect() {
376
+ if (isHubMode) {
377
+ // Hub mode — hide terminal UI, show sessions only
378
+ setStatus('online', 'Hub');
379
+ terminal.classList.add('hidden');
380
+ termContainer.classList.add('hidden');
381
+ $('#input-area').classList.add('hidden');
382
+ $('#btn-sessions').classList.add('hidden');
383
+ dashboard.classList.remove('hidden');
384
+ loadSessions();
385
+ // Auto-refresh every 10s
386
+ setInterval(loadSessions, 10000);
387
+ return;
388
+ }
389
+
346
390
  const tokenParam = new URLSearchParams(window.location.search).get('token');
347
- const wsUrl = tokenParam ? `${proto}//${location.host}?token=${encodeURIComponent(tokenParam)}` : `${proto}//${location.host}`;
348
- ws = new WebSocket(wsUrl);
391
+ if (!tokenParam) { setStatus('offline', 'No credentials'); return; }
392
+
393
+ const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
394
+
395
+ // F-02: Try ticket-based auth first
396
+ try {
397
+ const resp = await fetch('/api/auth/ticket', {
398
+ method: 'POST',
399
+ headers: { 'Authorization': 'Bearer ' + tokenParam }
400
+ });
401
+ if (resp.ok) {
402
+ const { ticket } = await resp.json();
403
+ ws = new WebSocket(`${proto}//${location.host}?ticket=${encodeURIComponent(ticket)}`);
404
+ } else {
405
+ // Fallback to token-in-URL (backward compat)
406
+ ws = new WebSocket(`${proto}//${location.host}?token=${encodeURIComponent(tokenParam)}`);
407
+ }
408
+ } catch {
409
+ // Fallback to token-in-URL
410
+ ws = new WebSocket(`${proto}//${location.host}?token=${encodeURIComponent(tokenParam)}`);
411
+ }
349
412
  setStatus('connecting', 'Connecting...');
350
413
 
351
414
  ws.onopen = () => {
@@ -484,10 +547,12 @@
484
547
  <h3>${icon} ${escapeHtml(title)}</h3>
485
548
  <p>${escapeHtml(shortCmd || JSON.stringify(p).substring(0, 200))}</p>
486
549
  <div class="perm-actions">
487
- <button class="btn-deny" onclick="handlePerm(${msg.id}, false)">Deny</button>
488
- <button class="btn-approve" onclick="handlePerm(${msg.id}, true)">Approve</button>
550
+ <button class="btn-deny">Deny</button>
551
+ <button class="btn-approve">Approve</button>
489
552
  </div>
490
553
  </div>`;
554
+ permOverlay.querySelector('.btn-deny').addEventListener('click', () => window.handlePerm(msg.id, false));
555
+ permOverlay.querySelector('.btn-approve').addEventListener('click', () => window.handlePerm(msg.id, true));
491
556
  }
492
557
  window.handlePerm = (id, approved) => {
493
558
  if (ws?.readyState === WebSocket.OPEN) {
@@ -172,6 +172,9 @@ header {
172
172
  background: var(--bg-tool);
173
173
  border-top: 1px solid var(--border);
174
174
  flex-shrink: 0;
175
+ position: sticky;
176
+ bottom: 0;
177
+ z-index: 10;
175
178
  }
176
179
  #key-bar {
177
180
  display: flex;