cli-tunnel 1.2.0-beta.8 → 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 +227 -64
- package/dist/redact.d.ts +1 -0
- package/dist/redact.js +26 -0
- package/package.json +7 -5
- package/remote-ui/app.js +472 -26
- package/remote-ui/index.html +10 -10
- package/remote-ui/styles.css +169 -0
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) => {
|
|
@@ -113,8 +114,46 @@ function getGitInfo() {
|
|
|
113
114
|
}
|
|
114
115
|
// ─── Security: Session token for WebSocket auth ────────────
|
|
115
116
|
const sessionToken = crypto.randomUUID();
|
|
116
|
-
// ───
|
|
117
|
-
const
|
|
117
|
+
// ─── Session file registry (IPC via filesystem) ────────────
|
|
118
|
+
const sessionsDir = path.join(os.homedir(), '.cli-tunnel', 'sessions');
|
|
119
|
+
fs.mkdirSync(sessionsDir, { recursive: true, mode: 0o700 });
|
|
120
|
+
let sessionFilePath = null;
|
|
121
|
+
function writeSessionFile(tunnelId, tunnelUrl, port) {
|
|
122
|
+
sessionFilePath = path.join(sessionsDir, `${tunnelId}.json`);
|
|
123
|
+
const data = JSON.stringify({
|
|
124
|
+
token: sessionToken, name: sessionName || command,
|
|
125
|
+
tunnelId, tunnelUrl, port, hubMode,
|
|
126
|
+
machine: os.hostname(), pid: process.pid,
|
|
127
|
+
createdAt: new Date().toISOString(),
|
|
128
|
+
});
|
|
129
|
+
fs.writeFileSync(sessionFilePath, data, { mode: 0o600 });
|
|
130
|
+
}
|
|
131
|
+
function removeSessionFile() {
|
|
132
|
+
if (sessionFilePath) {
|
|
133
|
+
try {
|
|
134
|
+
fs.unlinkSync(sessionFilePath);
|
|
135
|
+
}
|
|
136
|
+
catch { }
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
function readLocalSessions() {
|
|
140
|
+
try {
|
|
141
|
+
return fs.readdirSync(sessionsDir)
|
|
142
|
+
.filter(f => f.endsWith('.json'))
|
|
143
|
+
.map(f => { try {
|
|
144
|
+
return JSON.parse(fs.readFileSync(path.join(sessionsDir, f), 'utf-8'));
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
return null;
|
|
148
|
+
} })
|
|
149
|
+
.filter((s) => s !== null && !s.hubMode);
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
return [];
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
// ─── F-18: Session TTL (4 hours) ───────────────────────────
|
|
156
|
+
const SESSION_TTL = 4 * 60 * 60 * 1000; // 4 hours
|
|
118
157
|
const sessionCreatedAt = Date.now();
|
|
119
158
|
// ─── F-02: One-time ticket store for WebSocket auth ────────
|
|
120
159
|
const tickets = new Map();
|
|
@@ -127,24 +166,6 @@ setInterval(() => {
|
|
|
127
166
|
}
|
|
128
167
|
}, 30000);
|
|
129
168
|
// ─── Security: Redact secrets from replay events ────────────
|
|
130
|
-
function redactSecrets(text) {
|
|
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]');
|
|
147
|
-
}
|
|
148
169
|
// ─── Bridge server ──────────────────────────────────────────
|
|
149
170
|
const acpEventLog = [];
|
|
150
171
|
const connections = new Map();
|
|
@@ -157,7 +178,51 @@ setInterval(() => {
|
|
|
157
178
|
}
|
|
158
179
|
}
|
|
159
180
|
}, 60000);
|
|
160
|
-
|
|
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
|
+
}
|
|
161
226
|
// F-18: Session expiry check for API routes
|
|
162
227
|
if (!hubMode && req.url?.startsWith('/api/') && Date.now() - sessionCreatedAt > SESSION_TTL) {
|
|
163
228
|
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
@@ -173,13 +238,14 @@ const server = http.createServer((req, res) => {
|
|
|
173
238
|
return;
|
|
174
239
|
}
|
|
175
240
|
const ticket = crypto.randomUUID();
|
|
176
|
-
|
|
241
|
+
const expiresAt = Date.now() + 60000;
|
|
242
|
+
tickets.set(ticket, { expires: expiresAt });
|
|
177
243
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
178
|
-
res.end(JSON.stringify({ ticket, expires:
|
|
244
|
+
res.end(JSON.stringify({ ticket, expires: expiresAt }));
|
|
179
245
|
return;
|
|
180
246
|
}
|
|
181
|
-
// F-01: Session token check for all API routes
|
|
182
|
-
if (
|
|
247
|
+
// F-01: Session token check for all API routes
|
|
248
|
+
if (req.url?.startsWith('/api/')) {
|
|
183
249
|
const reqUrl = new URL(req.url, `http://${req.headers.host}`);
|
|
184
250
|
const authToken = req.headers.authorization?.replace('Bearer ', '') || reqUrl.searchParams.get('token');
|
|
185
251
|
if (authToken !== sessionToken) {
|
|
@@ -188,27 +254,78 @@ const server = http.createServer((req, res) => {
|
|
|
188
254
|
return;
|
|
189
255
|
}
|
|
190
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
|
+
}
|
|
191
297
|
// Sessions API
|
|
192
|
-
if (req.url === '/api/sessions' && req.method === 'GET') {
|
|
298
|
+
if ((req.url === '/api/sessions' || req.url?.startsWith('/api/sessions?')) && req.method === 'GET') {
|
|
193
299
|
try {
|
|
194
300
|
const output = execFileSync('devtunnel', ['list', '--labels', 'cli-tunnel', '--json'], { encoding: 'utf-8', timeout: 10000, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
195
301
|
const data = JSON.parse(output);
|
|
302
|
+
const localMachine = os.hostname();
|
|
303
|
+
const localSessions = hubMode ? readLocalSessions() : [];
|
|
304
|
+
const tokenMap = new Map(localSessions.map(s => [s.tunnelId, s.token]));
|
|
196
305
|
const sessions = (data.tunnels || []).map((t) => {
|
|
197
306
|
const labels = t.labels || [];
|
|
198
307
|
const id = t.tunnelId?.replace(/\.\w+$/, '') || t.tunnelId;
|
|
199
308
|
const cluster = t.tunnelId?.split('.').pop() || 'euw';
|
|
200
309
|
const portLabel = labels.find((l) => l.startsWith('port-'));
|
|
201
310
|
const p = portLabel ? parseInt(portLabel.replace('port-', ''), 10) : 3456;
|
|
202
|
-
|
|
311
|
+
const machine = labels[4] || 'unknown';
|
|
312
|
+
const session = {
|
|
203
313
|
id, tunnelId: t.tunnelId,
|
|
204
314
|
name: labels[1] || 'unnamed',
|
|
205
315
|
repo: labels[2] || 'unknown',
|
|
206
316
|
branch: (labels[3] || 'unknown').replace(/_/g, '/'),
|
|
207
|
-
machine
|
|
317
|
+
machine,
|
|
208
318
|
online: (t.hostConnections || 0) > 0,
|
|
209
319
|
port: p,
|
|
210
320
|
url: `https://${id}-${p}.${cluster}.devtunnels.ms`,
|
|
321
|
+
isLocal: machine === localMachine,
|
|
211
322
|
};
|
|
323
|
+
// Attach token from local session files (hub mode only)
|
|
324
|
+
const baseId = t.tunnelId?.split('.')[0] || t.tunnelId;
|
|
325
|
+
const token = tokenMap.get(baseId) || tokenMap.get(t.tunnelId);
|
|
326
|
+
if (token)
|
|
327
|
+
session.hasToken = true;
|
|
328
|
+
return session;
|
|
212
329
|
});
|
|
213
330
|
res.writeHead(200, { 'Content-Type': 'application/json', 'X-Frame-Options': 'DENY', 'X-Content-Type-Options': 'nosniff' });
|
|
214
331
|
res.end(JSON.stringify({ sessions }));
|
|
@@ -286,9 +403,10 @@ const server = http.createServer((req, res) => {
|
|
|
286
403
|
'Content-Type': mimes[ext] || 'application/octet-stream',
|
|
287
404
|
'X-Frame-Options': 'DENY',
|
|
288
405
|
'X-Content-Type-Options': 'nosniff',
|
|
289
|
-
'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;",
|
|
290
407
|
'Referrer-Policy': 'no-referrer',
|
|
291
408
|
'Cache-Control': 'no-store',
|
|
409
|
+
'Strict-Transport-Security': 'max-age=31536000; includeSubDomains',
|
|
292
410
|
};
|
|
293
411
|
res.writeHead(200, securityHeaders);
|
|
294
412
|
// #8: Handle createReadStream errors
|
|
@@ -302,24 +420,10 @@ const wss = new WebSocketServer({
|
|
|
302
420
|
server,
|
|
303
421
|
maxPayload: 1048576,
|
|
304
422
|
verifyClient: (info) => {
|
|
305
|
-
if (hubMode)
|
|
306
|
-
return true; // Hub mode doesn't need WS auth
|
|
307
423
|
// F-18: Session expiry
|
|
308
424
|
if (Date.now() - sessionCreatedAt > SESSION_TTL)
|
|
309
425
|
return false;
|
|
310
|
-
|
|
311
|
-
// F-02: Accept one-time ticket
|
|
312
|
-
const ticket = url.searchParams.get('ticket');
|
|
313
|
-
if (ticket && tickets.has(ticket)) {
|
|
314
|
-
const t = tickets.get(ticket);
|
|
315
|
-
tickets.delete(ticket); // Single use
|
|
316
|
-
return t.expires > Date.now();
|
|
317
|
-
}
|
|
318
|
-
// Backward compat: accept token
|
|
319
|
-
if (url.searchParams.get('token') !== sessionToken)
|
|
320
|
-
return false;
|
|
321
|
-
// Validate origin if present
|
|
322
|
-
// #28: Proper origin validation using URL parsing
|
|
426
|
+
// F-3: Validate origin BEFORE ticket acceptance
|
|
323
427
|
const origin = info.req.headers.origin;
|
|
324
428
|
if (origin) {
|
|
325
429
|
try {
|
|
@@ -333,7 +437,15 @@ const wss = new WebSocketServer({
|
|
|
333
437
|
return false;
|
|
334
438
|
}
|
|
335
439
|
}
|
|
336
|
-
|
|
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;
|
|
337
449
|
},
|
|
338
450
|
});
|
|
339
451
|
// ─── Security: Audit log for remote PTY input ──────────────
|
|
@@ -343,14 +455,27 @@ const auditLogPath = path.join(auditDir, `audit-${new Date().toISOString().slice
|
|
|
343
455
|
const auditLog = fs.createWriteStream(auditLogPath, { flags: 'a' });
|
|
344
456
|
auditLog.on('error', (err) => { console.error('Audit log error:', err.message); });
|
|
345
457
|
wss.on('connection', (ws, req) => {
|
|
346
|
-
// F-10: Connection cap
|
|
458
|
+
// F-10: Connection cap (global + per-IP)
|
|
347
459
|
if (connections.size >= 5) {
|
|
348
460
|
ws.close(1013, 'Max connections reached');
|
|
349
461
|
return;
|
|
350
462
|
}
|
|
351
|
-
const id = crypto.randomUUID();
|
|
352
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;
|
|
353
475
|
connections.set(id, ws);
|
|
476
|
+
// F-10: WS ping/pong heartbeat
|
|
477
|
+
ws._isAlive = true;
|
|
478
|
+
ws.on('pong', () => { ws._isAlive = true; });
|
|
354
479
|
// Replay history with secrets redacted (only if replay is enabled)
|
|
355
480
|
if (hasReplay) {
|
|
356
481
|
for (const event of acpEventLog) {
|
|
@@ -382,6 +507,18 @@ wss.on('connection', (ws, req) => {
|
|
|
382
507
|
});
|
|
383
508
|
ws.on('close', () => connections.delete(id));
|
|
384
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);
|
|
385
522
|
function broadcast(data) {
|
|
386
523
|
const msg = JSON.stringify({ type: 'pty', data });
|
|
387
524
|
if (hasReplay) {
|
|
@@ -410,7 +547,8 @@ async function main() {
|
|
|
410
547
|
console.log(`\n${BOLD}cli-tunnel${RESET} ${DIM}v1.1.0${RESET}\n`);
|
|
411
548
|
if (hubMode) {
|
|
412
549
|
console.log(` ${BOLD}📋 Hub Mode${RESET} — sessions dashboard`);
|
|
413
|
-
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`);
|
|
414
552
|
}
|
|
415
553
|
else {
|
|
416
554
|
console.log(` ${DIM}Command:${RESET} ${command} ${commandArgs.join(' ')}`);
|
|
@@ -531,19 +669,21 @@ async function main() {
|
|
|
531
669
|
});
|
|
532
670
|
hostProc.on('error', (e) => { clearTimeout(timeout); reject(e); });
|
|
533
671
|
});
|
|
534
|
-
const tunnelUrlWithToken = `${url}?token=${sessionToken}`;
|
|
672
|
+
const tunnelUrlWithToken = `${url}?token=${sessionToken}${hubMode ? '&hub=1' : ''}`;
|
|
535
673
|
console.log(` ${GREEN}✓${RESET} Tunnel: ${BOLD}${tunnelUrlWithToken}${RESET}\n`);
|
|
674
|
+
// Write session file for hub discovery
|
|
675
|
+
writeSessionFile(tunnelId, url, actualPort);
|
|
536
676
|
try {
|
|
537
677
|
// @ts-ignore
|
|
538
678
|
const qr = (await import('qrcode-terminal'));
|
|
539
679
|
qr.default.generate(tunnelUrlWithToken, { small: true }, (code) => console.log(code));
|
|
540
680
|
}
|
|
541
681
|
catch { }
|
|
542
|
-
process.on('SIGINT', () => { hostProc.kill(); try {
|
|
682
|
+
process.on('SIGINT', () => { removeSessionFile(); hostProc.kill(); try {
|
|
543
683
|
execFileSync('devtunnel', ['delete', tunnelId, '--force'], { stdio: 'pipe' });
|
|
544
684
|
}
|
|
545
685
|
catch { } });
|
|
546
|
-
process.on('exit', () => { hostProc.kill(); try {
|
|
686
|
+
process.on('exit', () => { removeSessionFile(); hostProc.kill(); try {
|
|
547
687
|
execFileSync('devtunnel', ['delete', tunnelId, '--force'], { stdio: 'pipe' });
|
|
548
688
|
}
|
|
549
689
|
catch { } });
|
|
@@ -582,6 +722,21 @@ async function main() {
|
|
|
582
722
|
// Keep process alive
|
|
583
723
|
await new Promise(() => { });
|
|
584
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
|
+
}
|
|
585
740
|
console.log(` ${DIM}Starting ${command}...${RESET}\n`);
|
|
586
741
|
// Spawn PTY
|
|
587
742
|
const nodePty = await import('node-pty');
|
|
@@ -609,8 +764,11 @@ async function main() {
|
|
|
609
764
|
// Blocklist approach: pass everything except known dangerous vars and secrets
|
|
610
765
|
const DANGEROUS_VARS = new Set(['NODE_OPTIONS', 'NODE_REPL_HISTORY', 'NODE_EXTRA_CA_CERTS',
|
|
611
766
|
'NODE_PATH', 'NODE_REDIRECT_WARNINGS', 'NODE_PENDING_DEPRECATION',
|
|
612
|
-
'UV_THREADPOOL_SIZE', 'LD_PRELOAD', 'DYLD_INSERT_LIBRARIES'
|
|
613
|
-
|
|
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;
|
|
614
772
|
const safeEnv = {};
|
|
615
773
|
for (const [k, v] of Object.entries(process.env)) {
|
|
616
774
|
if (v !== undefined && !DANGEROUS_VARS.has(k) && !sensitivePattern.test(k)) {
|
|
@@ -623,23 +781,28 @@ async function main() {
|
|
|
623
781
|
env: safeEnv,
|
|
624
782
|
});
|
|
625
783
|
// Detect CSPRNG crash (rare Node.js + PTY issue) and show helpful message
|
|
626
|
-
let
|
|
784
|
+
let earlyExitCode = null;
|
|
627
785
|
const earlyExitCheck = new Promise((resolve) => {
|
|
628
786
|
ptyProcess.onExit(({ exitCode }) => {
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
resolve();
|
|
632
|
-
}
|
|
787
|
+
earlyExitCode = exitCode;
|
|
788
|
+
resolve();
|
|
633
789
|
});
|
|
634
790
|
setTimeout(resolve, 2000);
|
|
635
791
|
});
|
|
636
792
|
await earlyExitCheck;
|
|
637
|
-
if (
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
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
|
+
}
|
|
643
806
|
}
|
|
644
807
|
ptyProcess.onData((data) => {
|
|
645
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
|
}
|