cli-tunnel 1.1.0 → 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 +241 -63
- package/package.json +1 -1
- package/remote-ui/app.js +74 -9
- package/remote-ui/styles.css +3 -0
package/dist/index.js
CHANGED
|
@@ -29,40 +29,45 @@ const GREEN = '\x1b[32m';
|
|
|
29
29
|
const YELLOW = '\x1b[33m';
|
|
30
30
|
// ─── Parse args ─────────────────────────────────────────────
|
|
31
31
|
const args = process.argv.slice(2);
|
|
32
|
-
if (args.includes('--help') || args.includes('-h')
|
|
32
|
+
if (args.includes('--help') || args.includes('-h')) {
|
|
33
33
|
console.log(`
|
|
34
34
|
${BOLD}cli-tunnel${RESET} — Tunnel any CLI app to your phone
|
|
35
35
|
|
|
36
36
|
${BOLD}Usage:${RESET}
|
|
37
37
|
cli-tunnel [options] <command> [args...]
|
|
38
|
+
cli-tunnel # hub mode — sessions dashboard only
|
|
38
39
|
|
|
39
40
|
${BOLD}Options:${RESET}
|
|
40
|
-
--
|
|
41
|
+
--local Disable devtunnel (localhost only)
|
|
41
42
|
--port <n> Bridge port (default: random)
|
|
42
43
|
--name <name> Session name (shown in dashboard)
|
|
44
|
+
--replay Enable replay buffer (off by default)
|
|
43
45
|
--help, -h Show this help
|
|
44
46
|
|
|
45
47
|
${BOLD}Examples:${RESET}
|
|
46
|
-
cli-tunnel copilot
|
|
47
|
-
cli-tunnel --
|
|
48
|
-
cli-tunnel
|
|
49
|
-
cli-tunnel
|
|
50
|
-
cli-tunnel --
|
|
51
|
-
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
|
|
52
55
|
|
|
53
|
-
|
|
54
|
-
app. cli-tunnel's own flags
|
|
55
|
-
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.
|
|
56
59
|
`);
|
|
57
60
|
process.exit(0);
|
|
58
61
|
}
|
|
59
|
-
const
|
|
62
|
+
const hasLocal = args.includes('--local');
|
|
63
|
+
const hasTunnel = !hasLocal;
|
|
64
|
+
const hasReplay = args.includes('--replay');
|
|
60
65
|
const portIdx = args.indexOf('--port');
|
|
61
66
|
const port = (portIdx !== -1 && args[portIdx + 1]) ? parseInt(args[portIdx + 1], 10) : 0;
|
|
62
67
|
const nameIdx = args.indexOf('--name');
|
|
63
68
|
const sessionName = (nameIdx !== -1 && args[nameIdx + 1]) ? args[nameIdx + 1] : '';
|
|
64
69
|
// Everything that's not our flags is the command
|
|
65
|
-
const ourFlags = new Set(['--tunnel', '--port', '--name']);
|
|
70
|
+
const ourFlags = new Set(['--local', '--tunnel', '--port', '--name', '--replay']);
|
|
66
71
|
const cmdArgs = [];
|
|
67
72
|
let skip = false;
|
|
68
73
|
for (let i = 0; i < args.length; i++) {
|
|
@@ -70,20 +75,18 @@ for (let i = 0; i < args.length; i++) {
|
|
|
70
75
|
skip = false;
|
|
71
76
|
continue;
|
|
72
77
|
}
|
|
73
|
-
if (
|
|
78
|
+
if (args[i] === '--port' || args[i] === '--name') {
|
|
74
79
|
skip = true;
|
|
75
80
|
continue;
|
|
76
81
|
}
|
|
77
|
-
if (args[i] === '--tunnel')
|
|
82
|
+
if (args[i] === '--local' || args[i] === '--tunnel' || args[i] === '--replay')
|
|
78
83
|
continue;
|
|
79
84
|
cmdArgs.push(args[i]);
|
|
80
85
|
}
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
const command = cmdArgs[0];
|
|
86
|
-
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);
|
|
87
90
|
const cwd = process.cwd();
|
|
88
91
|
// ─── Tunnel helpers ─────────────────────────────────────────
|
|
89
92
|
function sanitizeLabel(l) {
|
|
@@ -103,18 +106,85 @@ function getGitInfo() {
|
|
|
103
106
|
}
|
|
104
107
|
// ─── Security: Session token for WebSocket auth ────────────
|
|
105
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);
|
|
106
122
|
// ─── Security: Redact secrets from replay events ────────────
|
|
107
123
|
function redactSecrets(text) {
|
|
108
|
-
return 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]');
|
|
109
140
|
}
|
|
110
141
|
// ─── Bridge server ──────────────────────────────────────────
|
|
111
142
|
const acpEventLog = [];
|
|
112
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);
|
|
113
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
|
+
}
|
|
114
184
|
// Sessions API
|
|
115
185
|
if (req.url === '/api/sessions' && req.method === 'GET') {
|
|
116
186
|
try {
|
|
117
|
-
const output =
|
|
187
|
+
const output = execFileSync('devtunnel', ['list', '--labels', 'cli-tunnel', '--json'], { encoding: 'utf-8', timeout: 10000, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
118
188
|
const data = JSON.parse(output);
|
|
119
189
|
const sessions = (data.tunnels || []).map((t) => {
|
|
120
190
|
const labels = t.labels || [];
|
|
@@ -163,7 +233,16 @@ const server = http.createServer((req, res) => {
|
|
|
163
233
|
}
|
|
164
234
|
// Static files
|
|
165
235
|
const uiDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../remote-ui');
|
|
166
|
-
|
|
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
|
+
}
|
|
167
246
|
if (decodedUrl.includes('..')) {
|
|
168
247
|
res.writeHead(400);
|
|
169
248
|
res.end();
|
|
@@ -175,65 +254,132 @@ const server = http.createServer((req, res) => {
|
|
|
175
254
|
res.end();
|
|
176
255
|
return;
|
|
177
256
|
}
|
|
178
|
-
if
|
|
179
|
-
|
|
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
|
+
}
|
|
180
274
|
const ext = path.extname(filePath);
|
|
181
275
|
const mimes = { '.html': 'text/html', '.js': 'application/javascript', '.css': 'text/css', '.json': 'application/json' };
|
|
182
276
|
const securityHeaders = {
|
|
183
277
|
'Content-Type': mimes[ext] || 'application/octet-stream',
|
|
184
278
|
'X-Frame-Options': 'DENY',
|
|
185
279
|
'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
|
|
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',
|
|
187
283
|
};
|
|
188
284
|
res.writeHead(200, securityHeaders);
|
|
189
|
-
|
|
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);
|
|
190
291
|
});
|
|
191
292
|
const wss = new WebSocketServer({
|
|
192
293
|
server,
|
|
193
294
|
maxPayload: 1048576,
|
|
194
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;
|
|
195
301
|
const url = new URL(info.req.url, `http://${info.req.headers.host}`);
|
|
196
|
-
|
|
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;
|
|
197
328
|
},
|
|
198
329
|
});
|
|
199
330
|
// ─── Security: Audit log for remote PTY input ──────────────
|
|
200
|
-
const
|
|
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`);
|
|
201
334
|
const auditLog = fs.createWriteStream(auditLogPath, { flags: 'a' });
|
|
335
|
+
auditLog.on('error', (err) => { console.error('Audit log error:', err.message); });
|
|
202
336
|
wss.on('connection', (ws, req) => {
|
|
203
|
-
|
|
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();
|
|
204
343
|
const remoteAddress = req.socket.remoteAddress || 'unknown';
|
|
205
344
|
connections.set(id, ws);
|
|
206
|
-
// Replay history with secrets redacted
|
|
207
|
-
|
|
208
|
-
|
|
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' }));
|
|
209
351
|
}
|
|
210
|
-
ws.send(JSON.stringify({ type: '_replay_done' }));
|
|
211
352
|
ws.on('message', (data) => {
|
|
212
353
|
const raw = data.toString();
|
|
213
354
|
try {
|
|
214
355
|
const msg = JSON.parse(raw);
|
|
215
356
|
if (msg.type === 'pty_input' && ptyProcess) {
|
|
216
|
-
auditLog.write(
|
|
357
|
+
auditLog.write(JSON.stringify({ ts: new Date().toISOString(), src: remoteAddress, type: 'pty_input', data: redactSecrets(JSON.stringify(msg.data)) }) + '\n');
|
|
217
358
|
ptyProcess.write(msg.data);
|
|
218
359
|
}
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
const
|
|
222
|
-
|
|
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
|
+
}
|
|
223
367
|
}
|
|
224
368
|
}
|
|
225
369
|
catch {
|
|
226
|
-
|
|
227
|
-
|
|
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');
|
|
228
372
|
}
|
|
229
373
|
});
|
|
230
374
|
ws.on('close', () => connections.delete(id));
|
|
231
375
|
});
|
|
232
376
|
function broadcast(data) {
|
|
233
377
|
const msg = JSON.stringify({ type: 'pty', data });
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
378
|
+
if (hasReplay) {
|
|
379
|
+
acpEventLog.push(msg);
|
|
380
|
+
if (acpEventLog.length > 2000)
|
|
381
|
+
acpEventLog.splice(0, acpEventLog.length - 2000);
|
|
382
|
+
}
|
|
237
383
|
for (const [, ws] of connections) {
|
|
238
384
|
if (ws.readyState === WebSocket.OPEN)
|
|
239
385
|
ws.send(msg);
|
|
@@ -243,7 +389,7 @@ function broadcast(data) {
|
|
|
243
389
|
let ptyProcess = null;
|
|
244
390
|
async function main() {
|
|
245
391
|
const actualPort = await new Promise((resolve, reject) => {
|
|
246
|
-
server.listen(port, () => {
|
|
392
|
+
server.listen(port, '127.0.0.1', () => {
|
|
247
393
|
const addr = server.address();
|
|
248
394
|
resolve(typeof addr === 'object' ? addr.port : port);
|
|
249
395
|
});
|
|
@@ -252,17 +398,25 @@ async function main() {
|
|
|
252
398
|
const { repo, branch } = getGitInfo();
|
|
253
399
|
const machine = os.hostname();
|
|
254
400
|
const displayName = sessionName || command;
|
|
255
|
-
console.log(`\n${BOLD}cli-tunnel${RESET} ${DIM}v1.
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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
|
+
}
|
|
260
414
|
// Tunnel
|
|
261
415
|
if (hasTunnel) {
|
|
262
416
|
// Check if devtunnel is installed
|
|
263
417
|
let devtunnelInstalled = false;
|
|
264
418
|
try {
|
|
265
|
-
|
|
419
|
+
execFileSync('devtunnel', ['--version'], { stdio: 'pipe' });
|
|
266
420
|
devtunnelInstalled = true;
|
|
267
421
|
}
|
|
268
422
|
catch {
|
|
@@ -285,7 +439,7 @@ async function main() {
|
|
|
285
439
|
// Check if logged in
|
|
286
440
|
if (devtunnelInstalled) {
|
|
287
441
|
try {
|
|
288
|
-
const userInfo =
|
|
442
|
+
const userInfo = execFileSync('devtunnel', ['user', 'show'], { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] });
|
|
289
443
|
if (userInfo.includes('not logged in') || userInfo.includes('No user')) {
|
|
290
444
|
throw new Error('not logged in');
|
|
291
445
|
}
|
|
@@ -300,12 +454,12 @@ async function main() {
|
|
|
300
454
|
}
|
|
301
455
|
if (devtunnelInstalled) {
|
|
302
456
|
try {
|
|
303
|
-
const
|
|
304
|
-
|
|
305
|
-
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'] });
|
|
306
460
|
const tunnelId = JSON.parse(createOut).tunnel?.tunnelId?.split('.')[0];
|
|
307
461
|
const cluster = JSON.parse(createOut).tunnel?.tunnelId?.split('.')[1] || 'euw';
|
|
308
|
-
|
|
462
|
+
execFileSync('devtunnel', ['port', 'create', tunnelId, '-p', String(actualPort), '--protocol', 'http'], { stdio: 'pipe' });
|
|
309
463
|
const hostProc = spawn('devtunnel', ['host', tunnelId], { stdio: 'pipe', detached: false });
|
|
310
464
|
const url = await new Promise((resolve, reject) => {
|
|
311
465
|
const timeout = setTimeout(() => reject(new Error('Tunnel timeout')), 15000);
|
|
@@ -329,11 +483,11 @@ async function main() {
|
|
|
329
483
|
}
|
|
330
484
|
catch { }
|
|
331
485
|
process.on('SIGINT', () => { hostProc.kill(); try {
|
|
332
|
-
|
|
486
|
+
execFileSync('devtunnel', ['delete', tunnelId, '--force'], { stdio: 'pipe' });
|
|
333
487
|
}
|
|
334
488
|
catch { } });
|
|
335
489
|
process.on('exit', () => { hostProc.kill(); try {
|
|
336
|
-
|
|
490
|
+
execFileSync('devtunnel', ['delete', tunnelId, '--force'], { stdio: 'pipe' });
|
|
337
491
|
}
|
|
338
492
|
catch { } });
|
|
339
493
|
}
|
|
@@ -342,6 +496,14 @@ async function main() {
|
|
|
342
496
|
}
|
|
343
497
|
} // end if (devtunnelInstalled)
|
|
344
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
|
+
}
|
|
345
507
|
console.log(` ${DIM}Starting ${command}...${RESET}\n`);
|
|
346
508
|
// Spawn PTY
|
|
347
509
|
const nodePty = await import('node-pty');
|
|
@@ -351,7 +513,7 @@ async function main() {
|
|
|
351
513
|
let resolvedCmd = command;
|
|
352
514
|
if (process.platform === 'win32') {
|
|
353
515
|
try {
|
|
354
|
-
const wherePaths =
|
|
516
|
+
const wherePaths = execFileSync('where', [command], { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim().split('\n');
|
|
355
517
|
// Prefer .exe or .cmd over .ps1 for node-pty compatibility
|
|
356
518
|
const exePath = wherePaths.find(p => p.trim().endsWith('.exe')) || wherePaths.find(p => p.trim().endsWith('.cmd'));
|
|
357
519
|
if (exePath) {
|
|
@@ -365,11 +527,27 @@ async function main() {
|
|
|
365
527
|
}
|
|
366
528
|
catch { /* use as-is */ }
|
|
367
529
|
}
|
|
368
|
-
//
|
|
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
|
+
]);
|
|
369
548
|
const safeEnv = {};
|
|
370
|
-
const sensitivePatterns = /token|secret|key|password|credential|api_key|private/i;
|
|
371
549
|
for (const [k, v] of Object.entries(process.env)) {
|
|
372
|
-
if (
|
|
550
|
+
if (SAFE_ENV_VARS.has(k) && v !== undefined) {
|
|
373
551
|
safeEnv[k] = v;
|
|
374
552
|
}
|
|
375
553
|
}
|
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,14 +366,49 @@
|
|
|
338
366
|
}
|
|
339
367
|
}
|
|
340
368
|
|
|
369
|
+
// ─── Detect hub mode (no token in URL) ────────────────────
|
|
370
|
+
const isHubMode = !new URLSearchParams(window.location.search).get('token');
|
|
371
|
+
|
|
341
372
|
// ─── WebSocket ───────────────────────────────────────────
|
|
342
373
|
let reconnectAttempt = 0;
|
|
343
374
|
|
|
344
|
-
function connect() {
|
|
345
|
-
|
|
375
|
+
async function connect() {
|
|
376
|
+
if (isHubMode) {
|
|
377
|
+
// Hub mode — hide terminal UI, show sessions only
|
|
378
|
+
setStatus('online', 'Hub');
|
|
379
|
+
terminal.classList.add('hidden');
|
|
380
|
+
termContainer.classList.add('hidden');
|
|
381
|
+
$('#input-area').classList.add('hidden');
|
|
382
|
+
$('#btn-sessions').classList.add('hidden');
|
|
383
|
+
dashboard.classList.remove('hidden');
|
|
384
|
+
loadSessions();
|
|
385
|
+
// Auto-refresh every 10s
|
|
386
|
+
setInterval(loadSessions, 10000);
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
|
|
346
390
|
const tokenParam = new URLSearchParams(window.location.search).get('token');
|
|
347
|
-
|
|
348
|
-
|
|
391
|
+
if (!tokenParam) { setStatus('offline', 'No credentials'); return; }
|
|
392
|
+
|
|
393
|
+
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
394
|
+
|
|
395
|
+
// F-02: Try ticket-based auth first
|
|
396
|
+
try {
|
|
397
|
+
const resp = await fetch('/api/auth/ticket', {
|
|
398
|
+
method: 'POST',
|
|
399
|
+
headers: { 'Authorization': 'Bearer ' + tokenParam }
|
|
400
|
+
});
|
|
401
|
+
if (resp.ok) {
|
|
402
|
+
const { ticket } = await resp.json();
|
|
403
|
+
ws = new WebSocket(`${proto}//${location.host}?ticket=${encodeURIComponent(ticket)}`);
|
|
404
|
+
} else {
|
|
405
|
+
// Fallback to token-in-URL (backward compat)
|
|
406
|
+
ws = new WebSocket(`${proto}//${location.host}?token=${encodeURIComponent(tokenParam)}`);
|
|
407
|
+
}
|
|
408
|
+
} catch {
|
|
409
|
+
// Fallback to token-in-URL
|
|
410
|
+
ws = new WebSocket(`${proto}//${location.host}?token=${encodeURIComponent(tokenParam)}`);
|
|
411
|
+
}
|
|
349
412
|
setStatus('connecting', 'Connecting...');
|
|
350
413
|
|
|
351
414
|
ws.onopen = () => {
|
|
@@ -484,10 +547,12 @@
|
|
|
484
547
|
<h3>${icon} ${escapeHtml(title)}</h3>
|
|
485
548
|
<p>${escapeHtml(shortCmd || JSON.stringify(p).substring(0, 200))}</p>
|
|
486
549
|
<div class="perm-actions">
|
|
487
|
-
<button class="btn-deny"
|
|
488
|
-
<button class="btn-approve"
|
|
550
|
+
<button class="btn-deny">Deny</button>
|
|
551
|
+
<button class="btn-approve">Approve</button>
|
|
489
552
|
</div>
|
|
490
553
|
</div>`;
|
|
554
|
+
permOverlay.querySelector('.btn-deny').addEventListener('click', () => window.handlePerm(msg.id, false));
|
|
555
|
+
permOverlay.querySelector('.btn-approve').addEventListener('click', () => window.handlePerm(msg.id, true));
|
|
491
556
|
}
|
|
492
557
|
window.handlePerm = (id, approved) => {
|
|
493
558
|
if (ws?.readyState === WebSocket.OPEN) {
|