cli-tunnel 1.0.2 → 1.2.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -16,7 +16,8 @@
16
16
  */
17
17
  import path from 'node:path';
18
18
  import fs from 'node:fs';
19
- import { execSync, spawn } from 'node:child_process';
19
+ import crypto from 'node:crypto';
20
+ import { execSync, execFileSync, spawn } from 'node:child_process';
20
21
  import { fileURLToPath } from 'node:url';
21
22
  import http from 'node:http';
22
23
  import { WebSocketServer, WebSocket } from 'ws';
@@ -28,40 +29,45 @@ const GREEN = '\x1b[32m';
28
29
  const YELLOW = '\x1b[33m';
29
30
  // ─── Parse args ─────────────────────────────────────────────
30
31
  const args = process.argv.slice(2);
31
- if (args.includes('--help') || args.includes('-h') || args.length === 0) {
32
+ if (args.includes('--help') || args.includes('-h')) {
32
33
  console.log(`
33
34
  ${BOLD}cli-tunnel${RESET} — Tunnel any CLI app to your phone
34
35
 
35
36
  ${BOLD}Usage:${RESET}
36
37
  cli-tunnel [options] <command> [args...]
38
+ cli-tunnel # hub mode — sessions dashboard only
37
39
 
38
40
  ${BOLD}Options:${RESET}
39
- --tunnel Enable remote access via devtunnel
41
+ --local Disable devtunnel (localhost only)
40
42
  --port <n> Bridge port (default: random)
41
43
  --name <name> Session name (shown in dashboard)
44
+ --replay Enable replay buffer (off by default)
42
45
  --help, -h Show this help
43
46
 
44
47
  ${BOLD}Examples:${RESET}
45
- cli-tunnel copilot
46
- cli-tunnel --tunnel copilot --yolo
47
- cli-tunnel --tunnel copilot --model claude-sonnet-4 --agent squad
48
- cli-tunnel --tunnel --name wizard copilot --allow-all
49
- cli-tunnel --tunnel python -i
50
- cli-tunnel --tunnel htop
48
+ cli-tunnel copilot --yolo # tunnel + run copilot
49
+ cli-tunnel copilot --model claude-sonnet-4 --agent squad
50
+ cli-tunnel k9s # tunnel + run k9s
51
+ cli-tunnel python -i # tunnel + run python
52
+ cli-tunnel --name wizard copilot # named session
53
+ cli-tunnel --local copilot --yolo # localhost only, no devtunnel
54
+ cli-tunnel # hub: see all active sessions
51
55
 
52
- Any flags after the command name pass through to the underlying
53
- app. cli-tunnel's own flags (--tunnel, --port, --name) must come
54
- before the command.
56
+ Devtunnel is enabled by default. All flags after the command name
57
+ pass through to the underlying app. cli-tunnel's own flags
58
+ (--local, --port, --name) must come before the command.
55
59
  `);
56
60
  process.exit(0);
57
61
  }
58
- const hasTunnel = args.includes('--tunnel');
62
+ const hasLocal = args.includes('--local');
63
+ const hasTunnel = !hasLocal;
64
+ const hasReplay = args.includes('--replay');
59
65
  const portIdx = args.indexOf('--port');
60
66
  const port = (portIdx !== -1 && args[portIdx + 1]) ? parseInt(args[portIdx + 1], 10) : 0;
61
67
  const nameIdx = args.indexOf('--name');
62
68
  const sessionName = (nameIdx !== -1 && args[nameIdx + 1]) ? args[nameIdx + 1] : '';
63
69
  // Everything that's not our flags is the command
64
- const ourFlags = new Set(['--tunnel', '--port', '--name']);
70
+ const ourFlags = new Set(['--local', '--tunnel', '--port', '--name', '--replay']);
65
71
  const cmdArgs = [];
66
72
  let skip = false;
67
73
  for (let i = 0; i < args.length; i++) {
@@ -69,20 +75,18 @@ for (let i = 0; i < args.length; i++) {
69
75
  skip = false;
70
76
  continue;
71
77
  }
72
- if (ourFlags.has(args[i]) && args[i] !== '--tunnel') {
78
+ if (args[i] === '--port' || args[i] === '--name') {
73
79
  skip = true;
74
80
  continue;
75
81
  }
76
- if (args[i] === '--tunnel')
82
+ if (args[i] === '--local' || args[i] === '--tunnel' || args[i] === '--replay')
77
83
  continue;
78
84
  cmdArgs.push(args[i]);
79
85
  }
80
- if (cmdArgs.length === 0) {
81
- console.error('Error: no command specified. Run cli-tunnel --help for usage.');
82
- process.exit(1);
83
- }
84
- const command = cmdArgs[0];
85
- const commandArgs = cmdArgs.slice(1);
86
+ // Hub mode no command, just show sessions dashboard
87
+ const hubMode = cmdArgs.length === 0;
88
+ const command = hubMode ? '' : cmdArgs[0];
89
+ const commandArgs = hubMode ? [] : cmdArgs.slice(1);
86
90
  const cwd = process.cwd();
87
91
  // ─── Tunnel helpers ─────────────────────────────────────────
88
92
  function sanitizeLabel(l) {
@@ -100,14 +104,87 @@ function getGitInfo() {
100
104
  return { repo: path.basename(cwd), branch: 'unknown' };
101
105
  }
102
106
  }
107
+ // ─── Security: Session token for WebSocket auth ────────────
108
+ const sessionToken = crypto.randomUUID();
109
+ // ─── F-18: Session TTL (24 hours) ──────────────────────────
110
+ const SESSION_TTL = 24 * 60 * 60 * 1000; // 24 hours
111
+ const sessionCreatedAt = Date.now();
112
+ // ─── F-02: One-time ticket store for WebSocket auth ────────
113
+ const tickets = new Map();
114
+ // #30: Ticket GC — clean expired tickets every 30s
115
+ setInterval(() => {
116
+ const now = Date.now();
117
+ for (const [id, t] of tickets) {
118
+ if (t.expires < now)
119
+ tickets.delete(id);
120
+ }
121
+ }, 30000);
122
+ // ─── Security: Redact secrets from replay events ────────────
123
+ function redactSecrets(text) {
124
+ return text
125
+ // Generic patterns: key=value, key: value, key="value"
126
+ .replace(/(?:token|secret|key|password|credential|authorization|api_key|private_key|access_key|connection_string|db_pass|signing)[\s:="']+\S{8,}/gi, '[REDACTED]')
127
+ // OpenAI keys
128
+ .replace(/sk-[a-zA-Z0-9]{20,}/g, '[REDACTED]')
129
+ // GitHub tokens
130
+ .replace(/gh[ps]_[a-zA-Z0-9]{36,}/g, '[REDACTED]')
131
+ // AWS keys
132
+ .replace(/AKIA[A-Z0-9]{16}/g, '[REDACTED]')
133
+ // Azure connection strings
134
+ .replace(/DefaultEndpointsProtocol=[^;\s]{20,}/gi, '[REDACTED]')
135
+ .replace(/AccountKey=[^;\s]{20,}/gi, 'AccountKey=[REDACTED]')
136
+ // Database URLs
137
+ .replace(/(postgres|mongodb|mysql|redis):\/\/[^\s"']{10,}/gi, '[REDACTED]')
138
+ // Bearer tokens in headers
139
+ .replace(/Bearer\s+[a-zA-Z0-9._-]{20,}/gi, 'Bearer [REDACTED]');
140
+ }
103
141
  // ─── Bridge server ──────────────────────────────────────────
104
142
  const acpEventLog = [];
105
143
  const connections = new Map();
144
+ // #10: Session TTL enforcement — periodically close expired connections
145
+ setInterval(() => {
146
+ if (Date.now() - sessionCreatedAt > SESSION_TTL) {
147
+ for (const [id, ws] of connections) {
148
+ ws.close(1000, 'Session expired');
149
+ connections.delete(id);
150
+ }
151
+ }
152
+ }, 60000);
106
153
  const server = http.createServer((req, res) => {
154
+ // F-18: Session expiry check for API routes
155
+ if (!hubMode && req.url?.startsWith('/api/') && Date.now() - sessionCreatedAt > SESSION_TTL) {
156
+ res.writeHead(401, { 'Content-Type': 'application/json' });
157
+ res.end(JSON.stringify({ error: 'Session expired' }));
158
+ return;
159
+ }
160
+ // F-02: Ticket endpoint — exchange session token for one-time WS ticket
161
+ if (req.url === '/api/auth/ticket' && req.method === 'POST') {
162
+ const auth = req.headers.authorization?.replace('Bearer ', '');
163
+ if (auth !== sessionToken) {
164
+ res.writeHead(401);
165
+ res.end();
166
+ return;
167
+ }
168
+ const ticket = crypto.randomUUID();
169
+ tickets.set(ticket, { expires: Date.now() + 60000 });
170
+ res.writeHead(200, { 'Content-Type': 'application/json' });
171
+ res.end(JSON.stringify({ ticket, expires: Date.now() + 60000 }));
172
+ return;
173
+ }
174
+ // F-01: Session token check for all API routes (skip in hub mode)
175
+ if (!hubMode && req.url?.startsWith('/api/')) {
176
+ const reqUrl = new URL(req.url, `http://${req.headers.host}`);
177
+ const authToken = req.headers.authorization?.replace('Bearer ', '') || reqUrl.searchParams.get('token');
178
+ if (authToken !== sessionToken) {
179
+ res.writeHead(401, { 'Content-Type': 'application/json' });
180
+ res.end(JSON.stringify({ error: 'Unauthorized' }));
181
+ return;
182
+ }
183
+ }
107
184
  // Sessions API
108
185
  if (req.url === '/api/sessions' && req.method === 'GET') {
109
186
  try {
110
- const output = execSync('devtunnel list --labels cli-tunnel --json', { encoding: 'utf-8', timeout: 10000, stdio: ['pipe', 'pipe', 'pipe'] });
187
+ const output = execFileSync('devtunnel', ['list', '--labels', 'cli-tunnel', '--json'], { encoding: 'utf-8', timeout: 10000, stdio: ['pipe', 'pipe', 'pipe'] });
111
188
  const data = JSON.parse(output);
112
189
  const sessions = (data.tunnels || []).map((t) => {
113
190
  const labels = t.labels || [];
@@ -126,11 +203,11 @@ const server = http.createServer((req, res) => {
126
203
  url: `https://${id}-${p}.${cluster}.devtunnels.ms`,
127
204
  };
128
205
  });
129
- res.writeHead(200, { 'Content-Type': 'application/json' });
206
+ res.writeHead(200, { 'Content-Type': 'application/json', 'X-Frame-Options': 'DENY', 'X-Content-Type-Options': 'nosniff' });
130
207
  res.end(JSON.stringify({ sessions }));
131
208
  }
132
209
  catch {
133
- res.writeHead(200, { 'Content-Type': 'application/json' });
210
+ res.writeHead(200, { 'Content-Type': 'application/json', 'X-Frame-Options': 'DENY', 'X-Content-Type-Options': 'nosniff' });
134
211
  res.end(JSON.stringify({ sessions: [] }));
135
212
  }
136
213
  return;
@@ -138,64 +215,171 @@ const server = http.createServer((req, res) => {
138
215
  // Delete session
139
216
  if (req.url?.startsWith('/api/sessions/') && req.method === 'DELETE') {
140
217
  const tunnelId = req.url.replace('/api/sessions/', '').replace(/\.\w+$/, '');
218
+ if (!/^[a-zA-Z0-9._-]+$/.test(tunnelId)) {
219
+ res.writeHead(400, { 'Content-Type': 'application/json', 'X-Frame-Options': 'DENY', 'X-Content-Type-Options': 'nosniff' });
220
+ res.end(JSON.stringify({ error: 'Invalid tunnel ID' }));
221
+ return;
222
+ }
141
223
  try {
142
- execSync(`devtunnel delete ${tunnelId} --force`, { encoding: 'utf-8', timeout: 10000, stdio: ['pipe', 'pipe', 'pipe'] });
143
- res.writeHead(200, { 'Content-Type': 'application/json' });
224
+ execFileSync('devtunnel', ['delete', tunnelId, '--force'], { encoding: 'utf-8', timeout: 10000, stdio: ['pipe', 'pipe', 'pipe'] });
225
+ res.writeHead(200, { 'Content-Type': 'application/json', 'X-Frame-Options': 'DENY', 'X-Content-Type-Options': 'nosniff' });
144
226
  res.end(JSON.stringify({ deleted: true }));
145
227
  }
146
228
  catch {
147
- res.writeHead(200, { 'Content-Type': 'application/json' });
229
+ res.writeHead(200, { 'Content-Type': 'application/json', 'X-Frame-Options': 'DENY', 'X-Content-Type-Options': 'nosniff' });
148
230
  res.end(JSON.stringify({ deleted: false }));
149
231
  }
150
232
  return;
151
233
  }
152
234
  // Static files
153
235
  const uiDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../remote-ui');
154
- let filePath = path.join(uiDir, req.url === '/' ? 'index.html' : req.url || 'index.html');
236
+ // #18: Guard against malformed URI encoding
237
+ let decodedUrl;
238
+ try {
239
+ decodedUrl = decodeURIComponent(req.url || '/');
240
+ }
241
+ catch {
242
+ res.writeHead(400);
243
+ res.end();
244
+ return;
245
+ }
246
+ if (decodedUrl.includes('..')) {
247
+ res.writeHead(400);
248
+ res.end();
249
+ return;
250
+ }
251
+ let filePath = path.resolve(uiDir, decodedUrl === '/' ? 'index.html' : decodedUrl.replace(/^\//, ''));
155
252
  if (!filePath.startsWith(uiDir)) {
156
253
  res.writeHead(403);
157
254
  res.end();
158
255
  return;
159
256
  }
160
- if (!fs.existsSync(filePath))
161
- filePath = path.join(uiDir, 'index.html');
257
+ // #2: EISDIR guard — check if path is a directory before createReadStream
258
+ try {
259
+ const stat = fs.statSync(filePath);
260
+ if (stat.isDirectory()) {
261
+ filePath = path.join(filePath, 'index.html');
262
+ if (!fs.existsSync(filePath)) {
263
+ res.writeHead(404);
264
+ res.end();
265
+ return;
266
+ }
267
+ }
268
+ }
269
+ catch {
270
+ res.writeHead(404);
271
+ res.end();
272
+ return;
273
+ }
162
274
  const ext = path.extname(filePath);
163
275
  const mimes = { '.html': 'text/html', '.js': 'application/javascript', '.css': 'text/css', '.json': 'application/json' };
164
- res.writeHead(200, { 'Content-Type': mimes[ext] || 'application/octet-stream' });
165
- fs.createReadStream(filePath).pipe(res);
276
+ const securityHeaders = {
277
+ 'Content-Type': mimes[ext] || 'application/octet-stream',
278
+ 'X-Frame-Options': 'DENY',
279
+ 'X-Content-Type-Options': 'nosniff',
280
+ '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;",
281
+ 'Referrer-Policy': 'no-referrer',
282
+ 'Cache-Control': 'no-store',
283
+ };
284
+ res.writeHead(200, securityHeaders);
285
+ // #8: Handle createReadStream errors
286
+ const stream = fs.createReadStream(filePath);
287
+ stream.on('error', () => { if (!res.headersSent) {
288
+ res.writeHead(500);
289
+ } res.end(); });
290
+ stream.pipe(res);
291
+ });
292
+ const wss = new WebSocketServer({
293
+ server,
294
+ maxPayload: 1048576,
295
+ verifyClient: (info) => {
296
+ if (hubMode)
297
+ return true; // Hub mode doesn't need WS auth
298
+ // F-18: Session expiry
299
+ if (Date.now() - sessionCreatedAt > SESSION_TTL)
300
+ return false;
301
+ const url = new URL(info.req.url, `http://${info.req.headers.host}`);
302
+ // F-02: Accept one-time ticket
303
+ const ticket = url.searchParams.get('ticket');
304
+ if (ticket && tickets.has(ticket)) {
305
+ const t = tickets.get(ticket);
306
+ tickets.delete(ticket); // Single use
307
+ return t.expires > Date.now();
308
+ }
309
+ // Backward compat: accept token
310
+ if (url.searchParams.get('token') !== sessionToken)
311
+ return false;
312
+ // Validate origin if present
313
+ // #28: Proper origin validation using URL parsing
314
+ const origin = info.req.headers.origin;
315
+ if (origin) {
316
+ try {
317
+ const originUrl = new URL(origin);
318
+ const host = originUrl.hostname;
319
+ if (host !== 'localhost' && host !== '127.0.0.1' && !host.endsWith('.devtunnels.ms')) {
320
+ return false;
321
+ }
322
+ }
323
+ catch {
324
+ return false;
325
+ }
326
+ }
327
+ return true;
328
+ },
166
329
  });
167
- const wss = new WebSocketServer({ server });
168
- wss.on('connection', (ws) => {
169
- const id = Math.random().toString(36).substring(2);
330
+ // ─── Security: Audit log for remote PTY input ──────────────
331
+ const auditDir = path.join(os.homedir(), '.cli-tunnel', 'audit');
332
+ fs.mkdirSync(auditDir, { recursive: true, mode: 0o700 });
333
+ const auditLogPath = path.join(auditDir, `audit-${new Date().toISOString().slice(0, 10)}.jsonl`);
334
+ const auditLog = fs.createWriteStream(auditLogPath, { flags: 'a' });
335
+ auditLog.on('error', (err) => { console.error('Audit log error:', err.message); });
336
+ wss.on('connection', (ws, req) => {
337
+ // F-10: Connection cap
338
+ if (connections.size >= 5) {
339
+ ws.close(1013, 'Max connections reached');
340
+ return;
341
+ }
342
+ const id = crypto.randomUUID();
343
+ const remoteAddress = req.socket.remoteAddress || 'unknown';
170
344
  connections.set(id, ws);
171
- // Replay history
172
- for (const event of acpEventLog) {
173
- ws.send(JSON.stringify({ type: '_replay', data: event }));
345
+ // Replay history with secrets redacted (only if replay is enabled)
346
+ if (hasReplay) {
347
+ for (const event of acpEventLog) {
348
+ ws.send(JSON.stringify({ type: '_replay', data: redactSecrets(event) }));
349
+ }
350
+ ws.send(JSON.stringify({ type: '_replay_done' }));
174
351
  }
175
- ws.send(JSON.stringify({ type: '_replay_done' }));
176
352
  ws.on('message', (data) => {
177
353
  const raw = data.toString();
178
354
  try {
179
355
  const msg = JSON.parse(raw);
180
356
  if (msg.type === 'pty_input' && ptyProcess) {
357
+ auditLog.write(JSON.stringify({ ts: new Date().toISOString(), src: remoteAddress, type: 'pty_input', data: redactSecrets(JSON.stringify(msg.data)) }) + '\n');
181
358
  ptyProcess.write(msg.data);
182
359
  }
183
- if (msg.type === 'pty_resize' && ptyProcess) {
184
- ptyProcess.resize(msg.cols, msg.rows);
360
+ // #7: NaN guard on pty_resize
361
+ if (msg.type === 'pty_resize') {
362
+ const cols = Number(msg.cols);
363
+ const rows = Number(msg.rows);
364
+ if (Number.isFinite(cols) && Number.isFinite(rows) && ptyProcess) {
365
+ ptyProcess.resize(Math.max(1, Math.min(500, cols)), Math.max(1, Math.min(200, rows)));
366
+ }
185
367
  }
186
368
  }
187
369
  catch {
188
- if (ptyProcess)
189
- ptyProcess.write(raw + '\r');
370
+ // #3: Log but do NOT write to PTY — only structured pty_input messages allowed
371
+ auditLog.write(JSON.stringify({ ts: new Date().toISOString(), type: 'rejected', reason: 'non-json', length: raw.length }) + '\n');
190
372
  }
191
373
  });
192
374
  ws.on('close', () => connections.delete(id));
193
375
  });
194
376
  function broadcast(data) {
195
377
  const msg = JSON.stringify({ type: 'pty', data });
196
- acpEventLog.push(msg);
197
- if (acpEventLog.length > 2000)
198
- acpEventLog.splice(0, acpEventLog.length - 2000);
378
+ if (hasReplay) {
379
+ acpEventLog.push(msg);
380
+ if (acpEventLog.length > 2000)
381
+ acpEventLog.splice(0, acpEventLog.length - 2000);
382
+ }
199
383
  for (const [, ws] of connections) {
200
384
  if (ws.readyState === WebSocket.OPEN)
201
385
  ws.send(msg);
@@ -205,7 +389,7 @@ function broadcast(data) {
205
389
  let ptyProcess = null;
206
390
  async function main() {
207
391
  const actualPort = await new Promise((resolve, reject) => {
208
- server.listen(port, () => {
392
+ server.listen(port, '127.0.0.1', () => {
209
393
  const addr = server.address();
210
394
  resolve(typeof addr === 'object' ? addr.port : port);
211
395
  });
@@ -214,16 +398,25 @@ async function main() {
214
398
  const { repo, branch } = getGitInfo();
215
399
  const machine = os.hostname();
216
400
  const displayName = sessionName || command;
217
- console.log(`\n${BOLD}cli-tunnel${RESET} ${DIM}v1.0.0${RESET}\n`);
218
- console.log(` ${DIM}Command:${RESET} ${command} ${commandArgs.join(' ')}`);
219
- console.log(` ${DIM}Name:${RESET} ${displayName}`);
220
- console.log(` ${DIM}Port:${RESET} ${actualPort}`);
401
+ console.log(`\n${BOLD}cli-tunnel${RESET} ${DIM}v1.1.0${RESET}\n`);
402
+ if (hubMode) {
403
+ console.log(` ${BOLD}📋 Hub Mode${RESET} — sessions dashboard`);
404
+ console.log(` ${DIM}Port:${RESET} ${actualPort}\n`);
405
+ }
406
+ else {
407
+ console.log(` ${DIM}Command:${RESET} ${command} ${commandArgs.join(' ')}`);
408
+ console.log(` ${DIM}Name:${RESET} ${displayName}`);
409
+ console.log(` ${DIM}Port:${RESET} ${actualPort}`);
410
+ console.log(` ${DIM}Audit log:${RESET} ${auditLogPath}`);
411
+ console.log(` ${DIM}Local URL:${RESET} http://127.0.0.1:${actualPort}?token=${sessionToken}`);
412
+ console.log(` ${DIM}Session expires:${RESET} ${new Date(sessionCreatedAt + SESSION_TTL).toLocaleTimeString()}`);
413
+ }
221
414
  // Tunnel
222
415
  if (hasTunnel) {
223
416
  // Check if devtunnel is installed
224
417
  let devtunnelInstalled = false;
225
418
  try {
226
- execSync('devtunnel --version', { stdio: 'pipe' });
419
+ execFileSync('devtunnel', ['--version'], { stdio: 'pipe' });
227
420
  devtunnelInstalled = true;
228
421
  }
229
422
  catch {
@@ -246,7 +439,7 @@ async function main() {
246
439
  // Check if logged in
247
440
  if (devtunnelInstalled) {
248
441
  try {
249
- const userInfo = execSync('devtunnel user show', { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] });
442
+ const userInfo = execFileSync('devtunnel', ['user', 'show'], { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] });
250
443
  if (userInfo.includes('not logged in') || userInfo.includes('No user')) {
251
444
  throw new Error('not logged in');
252
445
  }
@@ -261,12 +454,12 @@ async function main() {
261
454
  }
262
455
  if (devtunnelInstalled) {
263
456
  try {
264
- const labels = ['cli-tunnel', sanitizeLabel(sessionName || command), sanitizeLabel(repo), sanitizeLabel(branch), sanitizeLabel(machine), `port-${actualPort}`]
265
- .map(l => `--labels ${l}`).join(' ');
266
- const createOut = execSync(`devtunnel create ${labels} --expiration 1d --json`, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] });
457
+ const labelValues = ['cli-tunnel', sanitizeLabel(sessionName || command), sanitizeLabel(repo), sanitizeLabel(branch), sanitizeLabel(machine), `port-${actualPort}`];
458
+ const labelArgs = labelValues.flatMap(l => ['--labels', l]);
459
+ const createOut = execFileSync('devtunnel', ['create', ...labelArgs, '--expiration', '1d', '--json'], { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] });
267
460
  const tunnelId = JSON.parse(createOut).tunnel?.tunnelId?.split('.')[0];
268
461
  const cluster = JSON.parse(createOut).tunnel?.tunnelId?.split('.')[1] || 'euw';
269
- execSync(`devtunnel port create ${tunnelId} -p ${actualPort} --protocol http`, { stdio: 'pipe' });
462
+ execFileSync('devtunnel', ['port', 'create', tunnelId, '-p', String(actualPort), '--protocol', 'http'], { stdio: 'pipe' });
270
463
  const hostProc = spawn('devtunnel', ['host', tunnelId], { stdio: 'pipe', detached: false });
271
464
  const url = await new Promise((resolve, reject) => {
272
465
  const timeout = setTimeout(() => reject(new Error('Tunnel timeout')), 15000);
@@ -281,19 +474,20 @@ async function main() {
281
474
  });
282
475
  hostProc.on('error', (e) => { clearTimeout(timeout); reject(e); });
283
476
  });
284
- console.log(` ${GREEN}✓${RESET} Tunnel: ${BOLD}${url}${RESET}\n`);
477
+ const tunnelUrlWithToken = `${url}?token=${sessionToken}`;
478
+ console.log(` ${GREEN}✓${RESET} Tunnel: ${BOLD}${tunnelUrlWithToken}${RESET}\n`);
285
479
  try {
286
480
  // @ts-ignore
287
481
  const qr = (await import('qrcode-terminal'));
288
- qr.default.generate(url, { small: true }, (code) => console.log(code));
482
+ qr.default.generate(tunnelUrlWithToken, { small: true }, (code) => console.log(code));
289
483
  }
290
484
  catch { }
291
485
  process.on('SIGINT', () => { hostProc.kill(); try {
292
- execSync(`devtunnel delete ${tunnelId} --force`, { stdio: 'pipe' });
486
+ execFileSync('devtunnel', ['delete', tunnelId, '--force'], { stdio: 'pipe' });
293
487
  }
294
488
  catch { } });
295
489
  process.on('exit', () => { hostProc.kill(); try {
296
- execSync(`devtunnel delete ${tunnelId} --force`, { stdio: 'pipe' });
490
+ execFileSync('devtunnel', ['delete', tunnelId, '--force'], { stdio: 'pipe' });
297
491
  }
298
492
  catch { } });
299
493
  }
@@ -302,6 +496,14 @@ async function main() {
302
496
  }
303
497
  } // end if (devtunnelInstalled)
304
498
  }
499
+ if (hubMode) {
500
+ // Hub mode — just serve the sessions dashboard, no PTY
501
+ console.log(` ${GREEN}✓${RESET} Hub running — open in browser to see all sessions\n`);
502
+ console.log(` ${DIM}Press Ctrl+C to stop.${RESET}\n`);
503
+ process.on('SIGINT', () => { server.close(); process.exit(0); });
504
+ // Keep process alive
505
+ await new Promise(() => { });
506
+ }
305
507
  console.log(` ${DIM}Starting ${command}...${RESET}\n`);
306
508
  // Spawn PTY
307
509
  const nodePty = await import('node-pty');
@@ -311,7 +513,7 @@ async function main() {
311
513
  let resolvedCmd = command;
312
514
  if (process.platform === 'win32') {
313
515
  try {
314
- const wherePaths = execSync(`where ${command}`, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim().split('\n');
516
+ const wherePaths = execFileSync('where', [command], { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim().split('\n');
315
517
  // Prefer .exe or .cmd over .ps1 for node-pty compatibility
316
518
  const exePath = wherePaths.find(p => p.trim().endsWith('.exe')) || wherePaths.find(p => p.trim().endsWith('.cmd'));
317
519
  if (exePath) {
@@ -325,10 +527,34 @@ async function main() {
325
527
  }
326
528
  catch { /* use as-is */ }
327
529
  }
530
+ // F-07: Security — allowlist safe environment variables for PTY
531
+ const SAFE_ENV_VARS = new Set([
532
+ 'PATH', 'HOME', 'USERPROFILE', 'SHELL', 'TERM', 'LANG', 'LC_ALL', 'LC_CTYPE',
533
+ 'USER', 'LOGNAME', 'EDITOR', 'VISUAL', 'COLORTERM', 'TERM_PROGRAM',
534
+ 'HOSTNAME', 'COMPUTERNAME', 'PWD', 'OLDPWD', 'SHLVL', 'TMPDIR', 'TMP', 'TEMP',
535
+ 'XDG_RUNTIME_DIR', 'XDG_DATA_HOME', 'XDG_CONFIG_HOME', 'XDG_CACHE_HOME',
536
+ 'DISPLAY', 'WAYLAND_DISPLAY', 'DBUS_SESSION_BUS_ADDRESS',
537
+ 'PROGRAMFILES', 'PROGRAMFILES(X86)', 'SYSTEMROOT', 'WINDIR', 'COMSPEC',
538
+ 'APPDATA', 'LOCALAPPDATA', 'PROGRAMDATA',
539
+ 'NODE_ENV',
540
+ 'GOPATH', 'GOROOT', 'CARGO_HOME', 'RUSTUP_HOME',
541
+ 'JAVA_HOME', 'MAVEN_HOME', 'GRADLE_HOME',
542
+ 'PYTHONPATH', 'VIRTUAL_ENV', 'CONDA_DEFAULT_ENV',
543
+ 'KUBECONFIG', 'DOCKER_HOST', 'DOCKER_CONFIG',
544
+ 'GIT_AUTHOR_NAME', 'GIT_AUTHOR_EMAIL', 'GIT_COMMITTER_NAME', 'GIT_COMMITTER_EMAIL',
545
+ 'HTTP_PROXY', 'HTTPS_PROXY', 'NO_PROXY', 'http_proxy', 'https_proxy', 'no_proxy',
546
+ 'SSH_AUTH_SOCK', 'GPG_TTY',
547
+ ]);
548
+ const safeEnv = {};
549
+ for (const [k, v] of Object.entries(process.env)) {
550
+ if (SAFE_ENV_VARS.has(k) && v !== undefined) {
551
+ safeEnv[k] = v;
552
+ }
553
+ }
328
554
  ptyProcess = nodePty.spawn(resolvedCmd, commandArgs, {
329
555
  name: 'xterm-256color',
330
556
  cols, rows, cwd,
331
- env: process.env,
557
+ env: safeEnv,
332
558
  });
333
559
  ptyProcess.onData((data) => {
334
560
  process.stdout.write(data);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cli-tunnel",
3
- "version": "1.0.2",
3
+ "version": "1.2.0-beta.1",
4
4
  "description": "Tunnel any CLI app to your phone - PTY + devtunnel + xterm.js",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
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,20 +366,62 @@
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
- function connect() {
373
+ let reconnectAttempt = 0;
374
+
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
+
390
+ const tokenParam = new URLSearchParams(window.location.search).get('token');
391
+ if (!tokenParam) { setStatus('offline', 'No credentials'); return; }
392
+
343
393
  const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
344
- ws = new WebSocket(`${proto}//${location.host}`);
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
+ }
345
412
  setStatus('connecting', 'Connecting...');
346
413
 
347
414
  ws.onopen = () => {
348
415
  connected = true;
416
+ reconnectAttempt = 0;
349
417
  setTimeout(() => initializeACP(1), 1000);
350
418
  };
351
419
  ws.onclose = () => {
352
420
  connected = false; acpReady = false; sessionId = null;
353
421
  setStatus('offline', 'Disconnected');
354
- setTimeout(connect, 3000);
422
+ const delay = Math.min(30000, 1000 * Math.pow(2, reconnectAttempt)) + Math.random() * 1000;
423
+ reconnectAttempt++;
424
+ setTimeout(connect, delay);
355
425
  };
356
426
  ws.onerror = () => setStatus('offline', 'Error');
357
427
  ws.onmessage = (e) => {
@@ -477,10 +547,12 @@
477
547
  <h3>${icon} ${escapeHtml(title)}</h3>
478
548
  <p>${escapeHtml(shortCmd || JSON.stringify(p).substring(0, 200))}</p>
479
549
  <div class="perm-actions">
480
- <button class="btn-deny" onclick="handlePerm(${msg.id}, false)">Deny</button>
481
- <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>
482
552
  </div>
483
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));
484
556
  }
485
557
  window.handlePerm = (id, approved) => {
486
558
  if (ws?.readyState === WebSocket.OPEN) {
@@ -534,7 +606,7 @@
534
606
  requestAnimationFrame(() => { terminal.scrollTop = terminal.scrollHeight; });
535
607
  }
536
608
  function escapeHtml(s) {
537
- const d = document.createElement('div'); d.textContent = s || ''; return d.innerHTML;
609
+ const d = document.createElement('div'); d.textContent = s || ''; return d.innerHTML.replace(/'/g, '&#39;');
538
610
  }
539
611
  function formatText(text) {
540
612
  return escapeHtml(text)
@@ -7,7 +7,7 @@
7
7
  <meta name="apple-mobile-web-app-capable" content="yes">
8
8
  <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
9
9
  <title>cli-tunnel</title>
10
- <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.min.css">
10
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.min.css" integrity="sha384-tStR1zLfWgsiXCF3IgfB3lBa8KmBe/lG287CL9WCeKgQYcp1bjb4/+mwN6oti4Co" crossorigin="anonymous">
11
11
  <link rel="stylesheet" href="/styles.css">
12
12
  </head>
13
13
  <body>
@@ -50,8 +50,8 @@
50
50
  </footer>
51
51
  </div>
52
52
  <div id="permission-overlay" class="hidden"></div>
53
- <script src="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.min.js"></script>
54
- <script src="https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/lib/addon-fit.min.js"></script>
53
+ <script src="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.min.js" integrity="sha384-J4qzUjBl1FxyLsl/kQPQIOeINsmp17OHYXDOMpMxlKX53ZfYsL+aWHpgArvOuof9" crossorigin="anonymous"></script>
54
+ <script src="https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/lib/addon-fit.min.js" integrity="sha384-XGqKrV8Jrukp1NITJbOEHwg01tNkuXr6uB6YEj69ebpYU3v7FvoGgEg23C1Gcehk" crossorigin="anonymous"></script>
55
55
  <script src="/app.js"></script>
56
56
  </body>
57
57
  </html>
@@ -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;