cli-tunnel 1.0.1 → 1.1.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/dist/index.js +137 -49
- package/package.json +1 -1
- package/remote-ui/app.js +10 -3
- package/remote-ui/index.html +3 -3
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
|
|
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';
|
|
@@ -100,6 +101,12 @@ function getGitInfo() {
|
|
|
100
101
|
return { repo: path.basename(cwd), branch: 'unknown' };
|
|
101
102
|
}
|
|
102
103
|
}
|
|
104
|
+
// ─── Security: Session token for WebSocket auth ────────────
|
|
105
|
+
const sessionToken = crypto.randomUUID();
|
|
106
|
+
// ─── Security: Redact secrets from replay events ────────────
|
|
107
|
+
function redactSecrets(text) {
|
|
108
|
+
return text.replace(/(?:token|secret|key|password|credential|authorization)[\s:="']+[^\s"']{8,}/gi, '$& [REDACTED]');
|
|
109
|
+
}
|
|
103
110
|
// ─── Bridge server ──────────────────────────────────────────
|
|
104
111
|
const acpEventLog = [];
|
|
105
112
|
const connections = new Map();
|
|
@@ -126,11 +133,11 @@ const server = http.createServer((req, res) => {
|
|
|
126
133
|
url: `https://${id}-${p}.${cluster}.devtunnels.ms`,
|
|
127
134
|
};
|
|
128
135
|
});
|
|
129
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
136
|
+
res.writeHead(200, { 'Content-Type': 'application/json', 'X-Frame-Options': 'DENY', 'X-Content-Type-Options': 'nosniff' });
|
|
130
137
|
res.end(JSON.stringify({ sessions }));
|
|
131
138
|
}
|
|
132
139
|
catch {
|
|
133
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
140
|
+
res.writeHead(200, { 'Content-Type': 'application/json', 'X-Frame-Options': 'DENY', 'X-Content-Type-Options': 'nosniff' });
|
|
134
141
|
res.end(JSON.stringify({ sessions: [] }));
|
|
135
142
|
}
|
|
136
143
|
return;
|
|
@@ -138,39 +145,67 @@ const server = http.createServer((req, res) => {
|
|
|
138
145
|
// Delete session
|
|
139
146
|
if (req.url?.startsWith('/api/sessions/') && req.method === 'DELETE') {
|
|
140
147
|
const tunnelId = req.url.replace('/api/sessions/', '').replace(/\.\w+$/, '');
|
|
148
|
+
if (!/^[a-zA-Z0-9._-]+$/.test(tunnelId)) {
|
|
149
|
+
res.writeHead(400, { 'Content-Type': 'application/json', 'X-Frame-Options': 'DENY', 'X-Content-Type-Options': 'nosniff' });
|
|
150
|
+
res.end(JSON.stringify({ error: 'Invalid tunnel ID' }));
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
141
153
|
try {
|
|
142
|
-
|
|
143
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
154
|
+
execFileSync('devtunnel', ['delete', tunnelId, '--force'], { encoding: 'utf-8', timeout: 10000, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
155
|
+
res.writeHead(200, { 'Content-Type': 'application/json', 'X-Frame-Options': 'DENY', 'X-Content-Type-Options': 'nosniff' });
|
|
144
156
|
res.end(JSON.stringify({ deleted: true }));
|
|
145
157
|
}
|
|
146
158
|
catch {
|
|
147
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
159
|
+
res.writeHead(200, { 'Content-Type': 'application/json', 'X-Frame-Options': 'DENY', 'X-Content-Type-Options': 'nosniff' });
|
|
148
160
|
res.end(JSON.stringify({ deleted: false }));
|
|
149
161
|
}
|
|
150
162
|
return;
|
|
151
163
|
}
|
|
152
164
|
// Static files
|
|
153
165
|
const uiDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../remote-ui');
|
|
154
|
-
|
|
166
|
+
const decodedUrl = decodeURIComponent(req.url || '/');
|
|
167
|
+
if (decodedUrl.includes('..')) {
|
|
168
|
+
res.writeHead(400);
|
|
169
|
+
res.end();
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
let filePath = path.resolve(uiDir, decodedUrl === '/' ? 'index.html' : decodedUrl.replace(/^\//, ''));
|
|
155
173
|
if (!filePath.startsWith(uiDir)) {
|
|
156
174
|
res.writeHead(403);
|
|
157
175
|
res.end();
|
|
158
176
|
return;
|
|
159
177
|
}
|
|
160
178
|
if (!fs.existsSync(filePath))
|
|
161
|
-
filePath = path.
|
|
179
|
+
filePath = path.resolve(uiDir, 'index.html');
|
|
162
180
|
const ext = path.extname(filePath);
|
|
163
181
|
const mimes = { '.html': 'text/html', '.js': 'application/javascript', '.css': 'text/css', '.json': 'application/json' };
|
|
164
|
-
|
|
182
|
+
const securityHeaders = {
|
|
183
|
+
'Content-Type': mimes[ext] || 'application/octet-stream',
|
|
184
|
+
'X-Frame-Options': 'DENY',
|
|
185
|
+
'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:;",
|
|
187
|
+
};
|
|
188
|
+
res.writeHead(200, securityHeaders);
|
|
165
189
|
fs.createReadStream(filePath).pipe(res);
|
|
166
190
|
});
|
|
167
|
-
const wss = new WebSocketServer({
|
|
168
|
-
|
|
191
|
+
const wss = new WebSocketServer({
|
|
192
|
+
server,
|
|
193
|
+
maxPayload: 1048576,
|
|
194
|
+
verifyClient: (info) => {
|
|
195
|
+
const url = new URL(info.req.url, `http://${info.req.headers.host}`);
|
|
196
|
+
return url.searchParams.get('token') === sessionToken;
|
|
197
|
+
},
|
|
198
|
+
});
|
|
199
|
+
// ─── Security: Audit log for remote PTY input ──────────────
|
|
200
|
+
const auditLogPath = path.join(os.tmpdir(), `cli-tunnel-audit-${Date.now()}.log`);
|
|
201
|
+
const auditLog = fs.createWriteStream(auditLogPath, { flags: 'a' });
|
|
202
|
+
wss.on('connection', (ws, req) => {
|
|
169
203
|
const id = Math.random().toString(36).substring(2);
|
|
204
|
+
const remoteAddress = req.socket.remoteAddress || 'unknown';
|
|
170
205
|
connections.set(id, ws);
|
|
171
|
-
// Replay history
|
|
206
|
+
// Replay history with secrets redacted
|
|
172
207
|
for (const event of acpEventLog) {
|
|
173
|
-
ws.send(JSON.stringify({ type: '_replay', data: event }));
|
|
208
|
+
ws.send(JSON.stringify({ type: '_replay', data: redactSecrets(event) }));
|
|
174
209
|
}
|
|
175
210
|
ws.send(JSON.stringify({ type: '_replay_done' }));
|
|
176
211
|
ws.on('message', (data) => {
|
|
@@ -178,10 +213,13 @@ wss.on('connection', (ws) => {
|
|
|
178
213
|
try {
|
|
179
214
|
const msg = JSON.parse(raw);
|
|
180
215
|
if (msg.type === 'pty_input' && ptyProcess) {
|
|
216
|
+
auditLog.write(`${new Date().toISOString()} [${remoteAddress}] ${JSON.stringify(msg.data)}\n`);
|
|
181
217
|
ptyProcess.write(msg.data);
|
|
182
218
|
}
|
|
183
219
|
if (msg.type === 'pty_resize' && ptyProcess) {
|
|
184
|
-
|
|
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);
|
|
185
223
|
}
|
|
186
224
|
}
|
|
187
225
|
catch {
|
|
@@ -218,49 +256,91 @@ async function main() {
|
|
|
218
256
|
console.log(` ${DIM}Command:${RESET} ${command} ${commandArgs.join(' ')}`);
|
|
219
257
|
console.log(` ${DIM}Name:${RESET} ${displayName}`);
|
|
220
258
|
console.log(` ${DIM}Port:${RESET} ${actualPort}`);
|
|
259
|
+
console.log(` ${DIM}Audit log:${RESET} ${auditLogPath}`);
|
|
221
260
|
// Tunnel
|
|
222
261
|
if (hasTunnel) {
|
|
262
|
+
// Check if devtunnel is installed
|
|
263
|
+
let devtunnelInstalled = false;
|
|
223
264
|
try {
|
|
224
265
|
execSync('devtunnel --version', { stdio: 'pipe' });
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
const url = await new Promise((resolve, reject) => {
|
|
233
|
-
const timeout = setTimeout(() => reject(new Error('Tunnel timeout')), 15000);
|
|
234
|
-
let out = '';
|
|
235
|
-
hostProc.stdout?.on('data', (d) => {
|
|
236
|
-
out += d.toString();
|
|
237
|
-
const match = out.match(/https:\/\/[^\s]+/);
|
|
238
|
-
if (match) {
|
|
239
|
-
clearTimeout(timeout);
|
|
240
|
-
resolve(match[0]);
|
|
241
|
-
}
|
|
242
|
-
});
|
|
243
|
-
hostProc.on('error', (e) => { clearTimeout(timeout); reject(e); });
|
|
244
|
-
});
|
|
245
|
-
console.log(` ${GREEN}✓${RESET} Tunnel: ${BOLD}${url}${RESET}\n`);
|
|
246
|
-
try {
|
|
247
|
-
// @ts-ignore
|
|
248
|
-
const qr = (await import('qrcode-terminal'));
|
|
249
|
-
qr.default.generate(url, { small: true }, (code) => console.log(code));
|
|
266
|
+
devtunnelInstalled = true;
|
|
267
|
+
}
|
|
268
|
+
catch {
|
|
269
|
+
console.log(`\n ${YELLOW}⚠ devtunnel CLI not found!${RESET}\n`);
|
|
270
|
+
console.log(` ${BOLD}To enable remote access, install Microsoft Dev Tunnels:${RESET}\n`);
|
|
271
|
+
if (process.platform === 'win32') {
|
|
272
|
+
console.log(` ${GREEN}winget install Microsoft.devtunnel${RESET}`);
|
|
250
273
|
}
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
execSync(`devtunnel delete ${tunnelId} --force`, { stdio: 'pipe' });
|
|
274
|
+
else if (process.platform === 'darwin') {
|
|
275
|
+
console.log(` ${GREEN}brew install --cask devtunnel${RESET}`);
|
|
254
276
|
}
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
execSync(`devtunnel delete ${tunnelId} --force`, { stdio: 'pipe' });
|
|
277
|
+
else {
|
|
278
|
+
console.log(` ${GREEN}curl -sL https://aka.ms/DevTunnelCliInstall | bash${RESET}`);
|
|
258
279
|
}
|
|
259
|
-
|
|
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`);
|
|
260
284
|
}
|
|
261
|
-
|
|
262
|
-
|
|
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');
|
|
291
|
+
}
|
|
292
|
+
}
|
|
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`);
|
|
297
|
+
console.log(` ${DIM}Continuing without tunnel (local only)...${RESET}\n`);
|
|
298
|
+
devtunnelInstalled = false;
|
|
299
|
+
}
|
|
263
300
|
}
|
|
301
|
+
if (devtunnelInstalled) {
|
|
302
|
+
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'] });
|
|
306
|
+
const tunnelId = JSON.parse(createOut).tunnel?.tunnelId?.split('.')[0];
|
|
307
|
+
const cluster = JSON.parse(createOut).tunnel?.tunnelId?.split('.')[1] || 'euw';
|
|
308
|
+
execSync(`devtunnel port create ${tunnelId} -p ${actualPort} --protocol http`, { stdio: 'pipe' });
|
|
309
|
+
const hostProc = spawn('devtunnel', ['host', tunnelId], { stdio: 'pipe', detached: false });
|
|
310
|
+
const url = await new Promise((resolve, reject) => {
|
|
311
|
+
const timeout = setTimeout(() => reject(new Error('Tunnel timeout')), 15000);
|
|
312
|
+
let out = '';
|
|
313
|
+
hostProc.stdout?.on('data', (d) => {
|
|
314
|
+
out += d.toString();
|
|
315
|
+
const match = out.match(/https:\/\/[^\s]+/);
|
|
316
|
+
if (match) {
|
|
317
|
+
clearTimeout(timeout);
|
|
318
|
+
resolve(match[0]);
|
|
319
|
+
}
|
|
320
|
+
});
|
|
321
|
+
hostProc.on('error', (e) => { clearTimeout(timeout); reject(e); });
|
|
322
|
+
});
|
|
323
|
+
const tunnelUrlWithToken = `${url}?token=${sessionToken}`;
|
|
324
|
+
console.log(` ${GREEN}✓${RESET} Tunnel: ${BOLD}${tunnelUrlWithToken}${RESET}\n`);
|
|
325
|
+
try {
|
|
326
|
+
// @ts-ignore
|
|
327
|
+
const qr = (await import('qrcode-terminal'));
|
|
328
|
+
qr.default.generate(tunnelUrlWithToken, { small: true }, (code) => console.log(code));
|
|
329
|
+
}
|
|
330
|
+
catch { }
|
|
331
|
+
process.on('SIGINT', () => { hostProc.kill(); try {
|
|
332
|
+
execSync(`devtunnel delete ${tunnelId} --force`, { stdio: 'pipe' });
|
|
333
|
+
}
|
|
334
|
+
catch { } });
|
|
335
|
+
process.on('exit', () => { hostProc.kill(); try {
|
|
336
|
+
execSync(`devtunnel delete ${tunnelId} --force`, { stdio: 'pipe' });
|
|
337
|
+
}
|
|
338
|
+
catch { } });
|
|
339
|
+
}
|
|
340
|
+
catch (err) {
|
|
341
|
+
console.log(` ${YELLOW}⚠${RESET} Tunnel failed: ${err.message}\n`);
|
|
342
|
+
}
|
|
343
|
+
} // end if (devtunnelInstalled)
|
|
264
344
|
}
|
|
265
345
|
console.log(` ${DIM}Starting ${command}...${RESET}\n`);
|
|
266
346
|
// Spawn PTY
|
|
@@ -285,10 +365,18 @@ async function main() {
|
|
|
285
365
|
}
|
|
286
366
|
catch { /* use as-is */ }
|
|
287
367
|
}
|
|
368
|
+
// Security: filter sensitive environment variables
|
|
369
|
+
const safeEnv = {};
|
|
370
|
+
const sensitivePatterns = /token|secret|key|password|credential|api_key|private/i;
|
|
371
|
+
for (const [k, v] of Object.entries(process.env)) {
|
|
372
|
+
if (!sensitivePatterns.test(k) && v !== undefined) {
|
|
373
|
+
safeEnv[k] = v;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
288
376
|
ptyProcess = nodePty.spawn(resolvedCmd, commandArgs, {
|
|
289
377
|
name: 'xterm-256color',
|
|
290
378
|
cols, rows, cwd,
|
|
291
|
-
env:
|
|
379
|
+
env: safeEnv,
|
|
292
380
|
});
|
|
293
381
|
ptyProcess.onData((data) => {
|
|
294
382
|
process.stdout.write(data);
|
package/package.json
CHANGED
package/remote-ui/app.js
CHANGED
|
@@ -339,19 +339,26 @@
|
|
|
339
339
|
}
|
|
340
340
|
|
|
341
341
|
// ─── WebSocket ───────────────────────────────────────────
|
|
342
|
+
let reconnectAttempt = 0;
|
|
343
|
+
|
|
342
344
|
function connect() {
|
|
343
345
|
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
344
|
-
|
|
346
|
+
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);
|
|
345
349
|
setStatus('connecting', 'Connecting...');
|
|
346
350
|
|
|
347
351
|
ws.onopen = () => {
|
|
348
352
|
connected = true;
|
|
353
|
+
reconnectAttempt = 0;
|
|
349
354
|
setTimeout(() => initializeACP(1), 1000);
|
|
350
355
|
};
|
|
351
356
|
ws.onclose = () => {
|
|
352
357
|
connected = false; acpReady = false; sessionId = null;
|
|
353
358
|
setStatus('offline', 'Disconnected');
|
|
354
|
-
|
|
359
|
+
const delay = Math.min(30000, 1000 * Math.pow(2, reconnectAttempt)) + Math.random() * 1000;
|
|
360
|
+
reconnectAttempt++;
|
|
361
|
+
setTimeout(connect, delay);
|
|
355
362
|
};
|
|
356
363
|
ws.onerror = () => setStatus('offline', 'Error');
|
|
357
364
|
ws.onmessage = (e) => {
|
|
@@ -534,7 +541,7 @@
|
|
|
534
541
|
requestAnimationFrame(() => { terminal.scrollTop = terminal.scrollHeight; });
|
|
535
542
|
}
|
|
536
543
|
function escapeHtml(s) {
|
|
537
|
-
const d = document.createElement('div'); d.textContent = s || ''; return d.innerHTML;
|
|
544
|
+
const d = document.createElement('div'); d.textContent = s || ''; return d.innerHTML.replace(/'/g, ''');
|
|
538
545
|
}
|
|
539
546
|
function formatText(text) {
|
|
540
547
|
return escapeHtml(text)
|
package/remote-ui/index.html
CHANGED
|
@@ -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>
|