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 +290 -64
- package/package.json +1 -1
- package/remote-ui/app.js +81 -9
- package/remote-ui/index.html +3 -3
- package/remote-ui/styles.css +3 -0
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';
|
|
@@ -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')
|
|
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
|
-
--
|
|
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 --
|
|
47
|
-
cli-tunnel
|
|
48
|
-
cli-tunnel
|
|
49
|
-
cli-tunnel --
|
|
50
|
-
cli-tunnel --
|
|
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
|
-
|
|
53
|
-
app. cli-tunnel's own flags
|
|
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
|
|
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 (
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
161
|
-
|
|
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
|
-
|
|
165
|
-
|
|
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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
-
|
|
173
|
-
|
|
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
|
-
|
|
184
|
-
|
|
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
|
-
|
|
189
|
-
|
|
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
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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.
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
265
|
-
|
|
266
|
-
const createOut =
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
486
|
+
execFileSync('devtunnel', ['delete', tunnelId, '--force'], { stdio: 'pipe' });
|
|
293
487
|
}
|
|
294
488
|
catch { } });
|
|
295
489
|
process.on('exit', () => { hostProc.kill(); try {
|
|
296
|
-
|
|
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 =
|
|
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:
|
|
557
|
+
env: safeEnv,
|
|
332
558
|
});
|
|
333
559
|
ptyProcess.onData((data) => {
|
|
334
560
|
process.stdout.write(data);
|
package/package.json
CHANGED
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 ? '
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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"
|
|
481
|
-
<button class="btn-approve"
|
|
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, ''');
|
|
538
610
|
}
|
|
539
611
|
function formatText(text) {
|
|
540
612
|
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>
|