cli-tunnel 1.2.0-beta.1 → 1.2.0-beta.10
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 +48 -5
- package/dist/index.js +273 -65
- package/package.json +8 -6
- package/remote-ui/app.js +204 -19
- package/remote-ui/index.html +10 -10
- package/remote-ui/styles.css +41 -0
package/README.md
CHANGED
|
@@ -77,12 +77,55 @@ cli-tunnel copilot
|
|
|
77
77
|
|
|
78
78
|
## Security
|
|
79
79
|
|
|
80
|
-
|
|
80
|
+
cli-tunnel uses a layered security model:
|
|
81
81
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
82
|
+
**Network layer** — Microsoft Dev Tunnels are private by default. Only the Microsoft or GitHub account that created the tunnel can connect. TLS encryption is handled by Microsoft's relay infrastructure. No inbound ports are opened on your machine.
|
|
83
|
+
|
|
84
|
+
**Session authentication** — Each session generates a unique token (cryptographic random UUID). All HTTP API and WebSocket connections require this token. The token is embedded in the URL you receive at startup — anyone without it cannot connect.
|
|
85
|
+
|
|
86
|
+
**WebSocket auth** — cli-tunnel uses a ticket-based handshake: the browser exchanges the session token for a single-use, short-lived ticket (60 seconds) to establish the WebSocket connection. This avoids keeping the long-lived token in WebSocket upgrade logs.
|
|
87
|
+
|
|
88
|
+
**Input validation** — Only structured JSON messages are accepted over WebSocket. Raw text is rejected and logged. Terminal resize commands are bounds-checked to prevent abuse.
|
|
89
|
+
|
|
90
|
+
**Environment isolation** — The child process receives a filtered set of environment variables (an allowlist of ~40 safe variables like PATH, HOME, TERM). Sensitive variables and NODE_OPTIONS are excluded to prevent code injection.
|
|
91
|
+
|
|
92
|
+
**Audit logging** — All remote keyboard input is logged to `~/.cli-tunnel/audit/` in JSONL format with timestamps and source addresses. Secrets are automatically redacted from audit entries.
|
|
93
|
+
|
|
94
|
+
**Connection limits** — Maximum 5 concurrent WebSocket connections. Sessions expire after 24 hours.
|
|
95
|
+
|
|
96
|
+
## Terminal Size Behavior
|
|
97
|
+
|
|
98
|
+
cli-tunnel uses a single PTY (pseudo-terminal) shared between your local terminal and all remote viewers. When a phone or tablet connects, the PTY resizes to match the remote device's screen dimensions. This ensures the CLI app renders correctly on the device you're actively using to interact with it.
|
|
99
|
+
|
|
100
|
+
Because the PTY can only have one size at a time, the local terminal on your machine will reflect the remote device's dimensions while it's connected. This is by design — cli-tunnel prioritizes the remote viewing experience since the primary use case is controlling your CLI from another device.
|
|
101
|
+
|
|
102
|
+
**Tips for the best experience:**
|
|
103
|
+
- Rotate your phone to landscape for a wider terminal
|
|
104
|
+
- Use the key bar (↑↓←→ Tab Enter Esc Ctrl+C) at the bottom for navigation
|
|
105
|
+
- If multiple devices connect, the last one to resize wins
|
|
106
|
+
|
|
107
|
+
## FAQ
|
|
108
|
+
|
|
109
|
+
**Can multiple devices connect to the same session?**
|
|
110
|
+
Yes, up to 5 devices simultaneously. All viewers see the same terminal output in real time. Input from any device goes to the same CLI session.
|
|
111
|
+
|
|
112
|
+
**What happens if my phone disconnects?**
|
|
113
|
+
The CLI session keeps running on your machine. When you reconnect, you'll see live output from that point forward. Use `--replay` to enable history replay so reconnecting devices catch up on what they missed.
|
|
114
|
+
|
|
115
|
+
**Does cli-tunnel work with any CLI app?**
|
|
116
|
+
Yes. Any command that runs in a terminal works — copilot, vim, htop, python, ssh, k9s, node, and more. cli-tunnel doesn't interpret the command's output; it streams raw terminal bytes.
|
|
117
|
+
|
|
118
|
+
**Is there a central server?**
|
|
119
|
+
No. cli-tunnel runs entirely on your machine. Microsoft Dev Tunnels provides the relay infrastructure, but no third-party server sees your terminal content.
|
|
120
|
+
|
|
121
|
+
**What about the anti-phishing page?**
|
|
122
|
+
The first time you open a devtunnel URL, Microsoft shows an interstitial warning page. This is a devtunnel security feature — it confirms you trust the tunnel. You only see it once per tunnel.
|
|
123
|
+
|
|
124
|
+
**Does the tool work without devtunnel?**
|
|
125
|
+
Yes. Use `--local` to skip tunnel creation. The terminal is available at `http://127.0.0.1:<port>` on your local network only.
|
|
126
|
+
|
|
127
|
+
**What's hub mode?**
|
|
128
|
+
Run `cli-tunnel` with no command to start hub mode — a sessions dashboard that shows all active cli-tunnel sessions on your machine. Tap any online session to connect to it.
|
|
86
129
|
|
|
87
130
|
## How It's Built
|
|
88
131
|
|
package/dist/index.js
CHANGED
|
@@ -20,8 +20,15 @@ import crypto from 'node:crypto';
|
|
|
20
20
|
import { execSync, execFileSync, spawn } from 'node:child_process';
|
|
21
21
|
import { fileURLToPath } from 'node:url';
|
|
22
22
|
import http from 'node:http';
|
|
23
|
+
import readline from 'node:readline';
|
|
23
24
|
import { WebSocketServer, WebSocket } from 'ws';
|
|
24
25
|
import os from 'node:os';
|
|
26
|
+
function askUser(question) {
|
|
27
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
28
|
+
return new Promise((resolve) => {
|
|
29
|
+
rl.question(question, (answer) => { rl.close(); resolve(answer.trim().toLowerCase()); });
|
|
30
|
+
});
|
|
31
|
+
}
|
|
25
32
|
const BOLD = '\x1b[1m';
|
|
26
33
|
const RESET = '\x1b[0m';
|
|
27
34
|
const DIM = '\x1b[2m';
|
|
@@ -106,8 +113,46 @@ function getGitInfo() {
|
|
|
106
113
|
}
|
|
107
114
|
// ─── Security: Session token for WebSocket auth ────────────
|
|
108
115
|
const sessionToken = crypto.randomUUID();
|
|
109
|
-
// ───
|
|
110
|
-
const
|
|
116
|
+
// ─── Session file registry (IPC via filesystem) ────────────
|
|
117
|
+
const sessionsDir = path.join(os.homedir(), '.cli-tunnel', 'sessions');
|
|
118
|
+
fs.mkdirSync(sessionsDir, { recursive: true, mode: 0o700 });
|
|
119
|
+
let sessionFilePath = null;
|
|
120
|
+
function writeSessionFile(tunnelId, tunnelUrl, port) {
|
|
121
|
+
sessionFilePath = path.join(sessionsDir, `${tunnelId}.json`);
|
|
122
|
+
const data = JSON.stringify({
|
|
123
|
+
token: sessionToken, name: sessionName || command,
|
|
124
|
+
tunnelId, tunnelUrl, port, hubMode,
|
|
125
|
+
machine: os.hostname(), pid: process.pid,
|
|
126
|
+
createdAt: new Date().toISOString(),
|
|
127
|
+
});
|
|
128
|
+
fs.writeFileSync(sessionFilePath, data, { mode: 0o600 });
|
|
129
|
+
}
|
|
130
|
+
function removeSessionFile() {
|
|
131
|
+
if (sessionFilePath) {
|
|
132
|
+
try {
|
|
133
|
+
fs.unlinkSync(sessionFilePath);
|
|
134
|
+
}
|
|
135
|
+
catch { }
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
function readLocalSessions() {
|
|
139
|
+
try {
|
|
140
|
+
return fs.readdirSync(sessionsDir)
|
|
141
|
+
.filter(f => f.endsWith('.json'))
|
|
142
|
+
.map(f => { try {
|
|
143
|
+
return JSON.parse(fs.readFileSync(path.join(sessionsDir, f), 'utf-8'));
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
return null;
|
|
147
|
+
} })
|
|
148
|
+
.filter((s) => s !== null && !s.hubMode);
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
return [];
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
// ─── F-18: Session TTL (4 hours) ───────────────────────────
|
|
155
|
+
const SESSION_TTL = 4 * 60 * 60 * 1000; // 4 hours
|
|
111
156
|
const sessionCreatedAt = Date.now();
|
|
112
157
|
// ─── F-02: One-time ticket store for WebSocket auth ────────
|
|
113
158
|
const tickets = new Map();
|
|
@@ -136,7 +181,15 @@ function redactSecrets(text) {
|
|
|
136
181
|
// Database URLs
|
|
137
182
|
.replace(/(postgres|mongodb|mysql|redis):\/\/[^\s"']{10,}/gi, '[REDACTED]')
|
|
138
183
|
// Bearer tokens in headers
|
|
139
|
-
.replace(/Bearer\s+[a-zA-Z0-9._-]{20,}/gi, 'Bearer [REDACTED]')
|
|
184
|
+
.replace(/Bearer\s+[a-zA-Z0-9._-]{20,}/gi, 'Bearer [REDACTED]')
|
|
185
|
+
// JWT tokens
|
|
186
|
+
.replace(/eyJ[a-zA-Z0-9_-]{10,}\.[a-zA-Z0-9_-]{10,}\.[a-zA-Z0-9_-]{10,}/g, '[REDACTED]')
|
|
187
|
+
// Slack tokens
|
|
188
|
+
.replace(/xox[bpras]-[a-zA-Z0-9-]{10,}/g, '[REDACTED]')
|
|
189
|
+
// npm tokens
|
|
190
|
+
.replace(/npm_[a-zA-Z0-9]{20,}/g, '[REDACTED]')
|
|
191
|
+
// PEM private keys
|
|
192
|
+
.replace(/-----BEGIN [A-Z ]+ PRIVATE KEY-----[\s\S]*?-----END [A-Z ]+ PRIVATE KEY-----/g, '[REDACTED]');
|
|
140
193
|
}
|
|
141
194
|
// ─── Bridge server ──────────────────────────────────────────
|
|
142
195
|
const acpEventLog = [];
|
|
@@ -150,7 +203,47 @@ setInterval(() => {
|
|
|
150
203
|
}
|
|
151
204
|
}
|
|
152
205
|
}, 60000);
|
|
206
|
+
// ─── F-8: Per-IP rate limiter ───────────────────────────────
|
|
207
|
+
const rateLimits = new Map();
|
|
208
|
+
const ticketRateLimits = new Map();
|
|
209
|
+
function checkRateLimit(ip, map, maxRequests) {
|
|
210
|
+
const now = Date.now();
|
|
211
|
+
const entry = map.get(ip);
|
|
212
|
+
if (!entry || entry.resetAt < now) {
|
|
213
|
+
map.set(ip, { count: 1, resetAt: now + 60000 });
|
|
214
|
+
return true;
|
|
215
|
+
}
|
|
216
|
+
entry.count++;
|
|
217
|
+
return entry.count <= maxRequests;
|
|
218
|
+
}
|
|
219
|
+
// Clean up rate limit maps every 60s
|
|
220
|
+
setInterval(() => {
|
|
221
|
+
const now = Date.now();
|
|
222
|
+
for (const [ip, entry] of rateLimits) {
|
|
223
|
+
if (entry.resetAt < now)
|
|
224
|
+
rateLimits.delete(ip);
|
|
225
|
+
}
|
|
226
|
+
for (const [ip, entry] of ticketRateLimits) {
|
|
227
|
+
if (entry.resetAt < now)
|
|
228
|
+
ticketRateLimits.delete(ip);
|
|
229
|
+
}
|
|
230
|
+
}, 60000);
|
|
153
231
|
const server = http.createServer((req, res) => {
|
|
232
|
+
const clientIp = req.socket.remoteAddress || 'unknown';
|
|
233
|
+
// F-8: Rate limiting for HTTP endpoints
|
|
234
|
+
if (req.url?.startsWith('/api/')) {
|
|
235
|
+
const isTicket = req.url === '/api/auth/ticket';
|
|
236
|
+
if (isTicket && !checkRateLimit(clientIp, ticketRateLimits, 10)) {
|
|
237
|
+
res.writeHead(429, { 'Content-Type': 'application/json' });
|
|
238
|
+
res.end(JSON.stringify({ error: 'Too Many Requests' }));
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
if (!checkRateLimit(clientIp, rateLimits, 30)) {
|
|
242
|
+
res.writeHead(429, { 'Content-Type': 'application/json' });
|
|
243
|
+
res.end(JSON.stringify({ error: 'Too Many Requests' }));
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
154
247
|
// F-18: Session expiry check for API routes
|
|
155
248
|
if (!hubMode && req.url?.startsWith('/api/') && Date.now() - sessionCreatedAt > SESSION_TTL) {
|
|
156
249
|
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
@@ -171,8 +264,8 @@ const server = http.createServer((req, res) => {
|
|
|
171
264
|
res.end(JSON.stringify({ ticket, expires: Date.now() + 60000 }));
|
|
172
265
|
return;
|
|
173
266
|
}
|
|
174
|
-
// F-01: Session token check for all API routes
|
|
175
|
-
if (
|
|
267
|
+
// F-01: Session token check for all API routes
|
|
268
|
+
if (req.url?.startsWith('/api/')) {
|
|
176
269
|
const reqUrl = new URL(req.url, `http://${req.headers.host}`);
|
|
177
270
|
const authToken = req.headers.authorization?.replace('Bearer ', '') || reqUrl.searchParams.get('token');
|
|
178
271
|
if (authToken !== sessionToken) {
|
|
@@ -182,26 +275,37 @@ const server = http.createServer((req, res) => {
|
|
|
182
275
|
}
|
|
183
276
|
}
|
|
184
277
|
// Sessions API
|
|
185
|
-
if (req.url === '/api/sessions' && req.method === 'GET') {
|
|
278
|
+
if ((req.url === '/api/sessions' || req.url?.startsWith('/api/sessions?')) && req.method === 'GET') {
|
|
186
279
|
try {
|
|
187
280
|
const output = execFileSync('devtunnel', ['list', '--labels', 'cli-tunnel', '--json'], { encoding: 'utf-8', timeout: 10000, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
188
281
|
const data = JSON.parse(output);
|
|
282
|
+
const localMachine = os.hostname();
|
|
283
|
+
const localSessions = hubMode ? readLocalSessions() : [];
|
|
284
|
+
const tokenMap = new Map(localSessions.map(s => [s.tunnelId, s.token]));
|
|
189
285
|
const sessions = (data.tunnels || []).map((t) => {
|
|
190
286
|
const labels = t.labels || [];
|
|
191
287
|
const id = t.tunnelId?.replace(/\.\w+$/, '') || t.tunnelId;
|
|
192
288
|
const cluster = t.tunnelId?.split('.').pop() || 'euw';
|
|
193
289
|
const portLabel = labels.find((l) => l.startsWith('port-'));
|
|
194
290
|
const p = portLabel ? parseInt(portLabel.replace('port-', ''), 10) : 3456;
|
|
195
|
-
|
|
291
|
+
const machine = labels[4] || 'unknown';
|
|
292
|
+
const session = {
|
|
196
293
|
id, tunnelId: t.tunnelId,
|
|
197
294
|
name: labels[1] || 'unnamed',
|
|
198
295
|
repo: labels[2] || 'unknown',
|
|
199
296
|
branch: (labels[3] || 'unknown').replace(/_/g, '/'),
|
|
200
|
-
machine
|
|
297
|
+
machine,
|
|
201
298
|
online: (t.hostConnections || 0) > 0,
|
|
202
299
|
port: p,
|
|
203
300
|
url: `https://${id}-${p}.${cluster}.devtunnels.ms`,
|
|
301
|
+
isLocal: machine === localMachine,
|
|
204
302
|
};
|
|
303
|
+
// Attach token from local session files (hub mode only)
|
|
304
|
+
const baseId = t.tunnelId?.split('.')[0] || t.tunnelId;
|
|
305
|
+
const token = tokenMap.get(baseId) || tokenMap.get(t.tunnelId);
|
|
306
|
+
if (token)
|
|
307
|
+
session.token = token;
|
|
308
|
+
return session;
|
|
205
309
|
});
|
|
206
310
|
res.writeHead(200, { 'Content-Type': 'application/json', 'X-Frame-Options': 'DENY', 'X-Content-Type-Options': 'nosniff' });
|
|
207
311
|
res.end(JSON.stringify({ sessions }));
|
|
@@ -236,7 +340,9 @@ const server = http.createServer((req, res) => {
|
|
|
236
340
|
// #18: Guard against malformed URI encoding
|
|
237
341
|
let decodedUrl;
|
|
238
342
|
try {
|
|
239
|
-
|
|
343
|
+
// Strip query string before resolving file path
|
|
344
|
+
const urlPath = (req.url || '/').split('?')[0];
|
|
345
|
+
decodedUrl = decodeURIComponent(urlPath);
|
|
240
346
|
}
|
|
241
347
|
catch {
|
|
242
348
|
res.writeHead(400);
|
|
@@ -277,9 +383,10 @@ const server = http.createServer((req, res) => {
|
|
|
277
383
|
'Content-Type': mimes[ext] || 'application/octet-stream',
|
|
278
384
|
'X-Frame-Options': 'DENY',
|
|
279
385
|
'X-Content-Type-Options': 'nosniff',
|
|
280
|
-
'Content-Security-Policy': "default-src 'self'; script-src 'self'
|
|
386
|
+
'Content-Security-Policy': "default-src 'self'; script-src 'self' https://cdn.jsdelivr.net; style-src 'self' https://cdn.jsdelivr.net; connect-src 'self' ws://localhost:* wss://*.devtunnels.ms;",
|
|
281
387
|
'Referrer-Policy': 'no-referrer',
|
|
282
388
|
'Cache-Control': 'no-store',
|
|
389
|
+
'Strict-Transport-Security': 'max-age=31536000; includeSubDomains',
|
|
283
390
|
};
|
|
284
391
|
res.writeHead(200, securityHeaders);
|
|
285
392
|
// #8: Handle createReadStream errors
|
|
@@ -298,19 +405,7 @@ const wss = new WebSocketServer({
|
|
|
298
405
|
// F-18: Session expiry
|
|
299
406
|
if (Date.now() - sessionCreatedAt > SESSION_TTL)
|
|
300
407
|
return false;
|
|
301
|
-
|
|
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
|
|
408
|
+
// F-3: Validate origin BEFORE ticket acceptance
|
|
314
409
|
const origin = info.req.headers.origin;
|
|
315
410
|
if (origin) {
|
|
316
411
|
try {
|
|
@@ -324,7 +419,15 @@ const wss = new WebSocketServer({
|
|
|
324
419
|
return false;
|
|
325
420
|
}
|
|
326
421
|
}
|
|
327
|
-
|
|
422
|
+
const url = new URL(info.req.url, `http://${info.req.headers.host}`);
|
|
423
|
+
// F-02: Accept one-time ticket (only auth method for WS)
|
|
424
|
+
const ticket = url.searchParams.get('ticket');
|
|
425
|
+
if (ticket && tickets.has(ticket)) {
|
|
426
|
+
const t = tickets.get(ticket);
|
|
427
|
+
tickets.delete(ticket); // Single use
|
|
428
|
+
return t.expires > Date.now();
|
|
429
|
+
}
|
|
430
|
+
return false;
|
|
328
431
|
},
|
|
329
432
|
});
|
|
330
433
|
// ─── Security: Audit log for remote PTY input ──────────────
|
|
@@ -334,14 +437,27 @@ const auditLogPath = path.join(auditDir, `audit-${new Date().toISOString().slice
|
|
|
334
437
|
const auditLog = fs.createWriteStream(auditLogPath, { flags: 'a' });
|
|
335
438
|
auditLog.on('error', (err) => { console.error('Audit log error:', err.message); });
|
|
336
439
|
wss.on('connection', (ws, req) => {
|
|
337
|
-
// F-10: Connection cap
|
|
440
|
+
// F-10: Connection cap (global + per-IP)
|
|
338
441
|
if (connections.size >= 5) {
|
|
339
442
|
ws.close(1013, 'Max connections reached');
|
|
340
443
|
return;
|
|
341
444
|
}
|
|
342
|
-
const id = crypto.randomUUID();
|
|
343
445
|
const remoteAddress = req.socket.remoteAddress || 'unknown';
|
|
446
|
+
let perIpCount = 0;
|
|
447
|
+
for (const [, c] of connections) {
|
|
448
|
+
if (c._remoteAddress === remoteAddress)
|
|
449
|
+
perIpCount++;
|
|
450
|
+
}
|
|
451
|
+
if (perIpCount >= 2) {
|
|
452
|
+
ws.close(1013, 'Max connections per IP reached');
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
const id = crypto.randomUUID();
|
|
456
|
+
ws._remoteAddress = remoteAddress;
|
|
344
457
|
connections.set(id, ws);
|
|
458
|
+
// F-10: WS ping/pong heartbeat
|
|
459
|
+
ws._isAlive = true;
|
|
460
|
+
ws.on('pong', () => { ws._isAlive = true; });
|
|
345
461
|
// Replay history with secrets redacted (only if replay is enabled)
|
|
346
462
|
if (hasReplay) {
|
|
347
463
|
for (const event of acpEventLog) {
|
|
@@ -373,6 +489,18 @@ wss.on('connection', (ws, req) => {
|
|
|
373
489
|
});
|
|
374
490
|
ws.on('close', () => connections.delete(id));
|
|
375
491
|
});
|
|
492
|
+
// F-10: WS heartbeat — ping every 30s, close unresponsive after 10s
|
|
493
|
+
setInterval(() => {
|
|
494
|
+
for (const [id, ws] of connections) {
|
|
495
|
+
if (ws._isAlive === false) {
|
|
496
|
+
ws.terminate();
|
|
497
|
+
connections.delete(id);
|
|
498
|
+
continue;
|
|
499
|
+
}
|
|
500
|
+
ws._isAlive = false;
|
|
501
|
+
ws.ping();
|
|
502
|
+
}
|
|
503
|
+
}, 30000);
|
|
376
504
|
function broadcast(data) {
|
|
377
505
|
const msg = JSON.stringify({ type: 'pty', data });
|
|
378
506
|
if (hasReplay) {
|
|
@@ -401,7 +529,8 @@ async function main() {
|
|
|
401
529
|
console.log(`\n${BOLD}cli-tunnel${RESET} ${DIM}v1.1.0${RESET}\n`);
|
|
402
530
|
if (hubMode) {
|
|
403
531
|
console.log(` ${BOLD}📋 Hub Mode${RESET} — sessions dashboard`);
|
|
404
|
-
console.log(` ${DIM}Port:${RESET} ${actualPort}
|
|
532
|
+
console.log(` ${DIM}Port:${RESET} ${actualPort}`);
|
|
533
|
+
console.log(` ${DIM}Local URL:${RESET} http://127.0.0.1:${actualPort}?token=${sessionToken}&hub=1\n`);
|
|
405
534
|
}
|
|
406
535
|
else {
|
|
407
536
|
console.log(` ${DIM}Command:${RESET} ${command} ${commandArgs.join(' ')}`);
|
|
@@ -421,35 +550,83 @@ async function main() {
|
|
|
421
550
|
}
|
|
422
551
|
catch {
|
|
423
552
|
console.log(`\n ${YELLOW}⚠ devtunnel CLI not found!${RESET}\n`);
|
|
424
|
-
|
|
553
|
+
let installCmd = '';
|
|
425
554
|
if (process.platform === 'win32') {
|
|
426
|
-
|
|
555
|
+
installCmd = 'winget install Microsoft.devtunnel';
|
|
427
556
|
}
|
|
428
557
|
else if (process.platform === 'darwin') {
|
|
429
|
-
|
|
558
|
+
installCmd = 'brew install --cask devtunnel';
|
|
559
|
+
}
|
|
560
|
+
else {
|
|
561
|
+
installCmd = 'curl -sL https://aka.ms/DevTunnelCliInstall | bash';
|
|
562
|
+
}
|
|
563
|
+
const answer = await askUser(` Would you like to install it now? (${GREEN}${installCmd}${RESET}) [Y/n] `);
|
|
564
|
+
if (answer === '' || answer === 'y' || answer === 'yes') {
|
|
565
|
+
console.log(`\n ${DIM}Installing devtunnel...${RESET}\n`);
|
|
566
|
+
try {
|
|
567
|
+
const installParts = installCmd.split(' ');
|
|
568
|
+
const installProc = spawn(installParts[0], installParts.slice(1), { stdio: 'inherit', shell: process.platform !== 'win32' && installCmd.includes('|') });
|
|
569
|
+
await new Promise((resolve, reject) => {
|
|
570
|
+
installProc.on('close', (code) => code === 0 ? resolve() : reject(new Error(`Install exited with code ${code}`)));
|
|
571
|
+
installProc.on('error', reject);
|
|
572
|
+
});
|
|
573
|
+
// Refresh PATH — winget updates the registry but current process has stale PATH
|
|
574
|
+
if (process.platform === 'win32') {
|
|
575
|
+
try {
|
|
576
|
+
const userPath = execFileSync('reg', ['query', 'HKCU\\Environment', '/v', 'Path'], { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] });
|
|
577
|
+
const sysPath = execFileSync('reg', ['query', 'HKLM\\SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment', '/v', 'Path'], { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] });
|
|
578
|
+
const extractPath = (out) => out.split('\n').find(l => l.includes('REG_'))?.split('REG_EXPAND_SZ')[1]?.trim() || out.split('\n').find(l => l.includes('REG_'))?.split('REG_SZ')[1]?.trim() || '';
|
|
579
|
+
process.env.PATH = `${extractPath(userPath)};${extractPath(sysPath)}`;
|
|
580
|
+
}
|
|
581
|
+
catch { /* keep existing PATH */ }
|
|
582
|
+
}
|
|
583
|
+
// Verify installation
|
|
584
|
+
execFileSync('devtunnel', ['--version'], { stdio: 'pipe' });
|
|
585
|
+
console.log(`\n ${GREEN}✓${RESET} devtunnel installed successfully!\n`);
|
|
586
|
+
devtunnelInstalled = true;
|
|
587
|
+
}
|
|
588
|
+
catch (err) {
|
|
589
|
+
console.log(`\n ${YELLOW}⚠${RESET} Installation failed: ${err.message}`);
|
|
590
|
+
console.log(` ${DIM}You can install it manually: ${installCmd}${RESET}\n`);
|
|
591
|
+
console.log(` ${DIM}Continuing without tunnel (local only)...${RESET}\n`);
|
|
592
|
+
}
|
|
430
593
|
}
|
|
431
594
|
else {
|
|
432
|
-
console.log(
|
|
595
|
+
console.log(`\n ${DIM}More info: https://aka.ms/devtunnels/doc${RESET}`);
|
|
596
|
+
console.log(` ${DIM}Continuing without tunnel (local only)...${RESET}\n`);
|
|
433
597
|
}
|
|
434
|
-
console.log(`\n Then authenticate once:\n`);
|
|
435
|
-
console.log(` ${GREEN}devtunnel user login${RESET}\n`);
|
|
436
|
-
console.log(` ${DIM}More info: https://aka.ms/devtunnels/doc${RESET}\n`);
|
|
437
|
-
console.log(` ${DIM}Continuing without tunnel (local only)...${RESET}\n`);
|
|
438
598
|
}
|
|
439
|
-
// Check if logged in
|
|
440
599
|
if (devtunnelInstalled) {
|
|
600
|
+
// Check if logged in before attempting tunnel creation
|
|
441
601
|
try {
|
|
442
602
|
const userInfo = execFileSync('devtunnel', ['user', 'show'], { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] });
|
|
443
|
-
if (userInfo.includes('not logged in') || userInfo.includes('No user')) {
|
|
603
|
+
if (userInfo.includes('not logged in') || userInfo.includes('No user') || userInfo.includes('Anonymous')) {
|
|
444
604
|
throw new Error('not logged in');
|
|
445
605
|
}
|
|
446
606
|
}
|
|
447
607
|
catch {
|
|
448
|
-
console.log(`\n ${YELLOW}⚠ devtunnel not authenticated
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
608
|
+
console.log(`\n ${YELLOW}⚠ devtunnel not authenticated.${RESET}\n`);
|
|
609
|
+
const loginAnswer = await askUser(` Would you like to log in now? [Y/n] `);
|
|
610
|
+
if (loginAnswer === '' || loginAnswer === 'y' || loginAnswer === 'yes') {
|
|
611
|
+
try {
|
|
612
|
+
const loginProc = spawn('devtunnel', ['user', 'login'], { stdio: 'inherit' });
|
|
613
|
+
await new Promise((resolve, reject) => {
|
|
614
|
+
loginProc.on('close', (code) => code === 0 ? resolve() : reject(new Error(`Login exited with code ${code}`)));
|
|
615
|
+
loginProc.on('error', reject);
|
|
616
|
+
});
|
|
617
|
+
console.log(`\n ${GREEN}✓${RESET} Logged in successfully!\n`);
|
|
618
|
+
}
|
|
619
|
+
catch {
|
|
620
|
+
console.log(`\n ${YELLOW}⚠${RESET} Login failed. Run manually: ${GREEN}devtunnel user login${RESET}\n`);
|
|
621
|
+
console.log(` ${DIM}Continuing without tunnel (local only)...${RESET}\n`);
|
|
622
|
+
devtunnelInstalled = false;
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
else {
|
|
626
|
+
console.log(`\n ${DIM}Run this once to log in: ${GREEN}devtunnel user login${RESET}`);
|
|
627
|
+
console.log(` ${DIM}Continuing without tunnel (local only)...${RESET}\n`);
|
|
628
|
+
devtunnelInstalled = false;
|
|
629
|
+
}
|
|
453
630
|
}
|
|
454
631
|
}
|
|
455
632
|
if (devtunnelInstalled) {
|
|
@@ -474,25 +651,48 @@ async function main() {
|
|
|
474
651
|
});
|
|
475
652
|
hostProc.on('error', (e) => { clearTimeout(timeout); reject(e); });
|
|
476
653
|
});
|
|
477
|
-
const tunnelUrlWithToken = `${url}?token=${sessionToken}`;
|
|
654
|
+
const tunnelUrlWithToken = `${url}?token=${sessionToken}${hubMode ? '&hub=1' : ''}`;
|
|
478
655
|
console.log(` ${GREEN}✓${RESET} Tunnel: ${BOLD}${tunnelUrlWithToken}${RESET}\n`);
|
|
656
|
+
// Write session file for hub discovery
|
|
657
|
+
writeSessionFile(tunnelId, url, actualPort);
|
|
479
658
|
try {
|
|
480
659
|
// @ts-ignore
|
|
481
660
|
const qr = (await import('qrcode-terminal'));
|
|
482
661
|
qr.default.generate(tunnelUrlWithToken, { small: true }, (code) => console.log(code));
|
|
483
662
|
}
|
|
484
663
|
catch { }
|
|
485
|
-
process.on('SIGINT', () => { hostProc.kill(); try {
|
|
664
|
+
process.on('SIGINT', () => { removeSessionFile(); hostProc.kill(); try {
|
|
486
665
|
execFileSync('devtunnel', ['delete', tunnelId, '--force'], { stdio: 'pipe' });
|
|
487
666
|
}
|
|
488
667
|
catch { } });
|
|
489
|
-
process.on('exit', () => { hostProc.kill(); try {
|
|
668
|
+
process.on('exit', () => { removeSessionFile(); hostProc.kill(); try {
|
|
490
669
|
execFileSync('devtunnel', ['delete', tunnelId, '--force'], { stdio: 'pipe' });
|
|
491
670
|
}
|
|
492
671
|
catch { } });
|
|
493
672
|
}
|
|
494
673
|
catch (err) {
|
|
495
|
-
|
|
674
|
+
const errMsg = err.message || '';
|
|
675
|
+
// Detect auth failure at create time (expired token, anonymous, etc.)
|
|
676
|
+
if (errMsg.includes('Anonymous') || errMsg.includes('Unauthorized') || errMsg.includes('not permitted')) {
|
|
677
|
+
console.log(`\n ${YELLOW}⚠ devtunnel session expired or not authenticated.${RESET}\n`);
|
|
678
|
+
const loginAnswer = await askUser(` Would you like to log in now? [Y/n] `);
|
|
679
|
+
if (loginAnswer === '' || loginAnswer === 'y' || loginAnswer === 'yes') {
|
|
680
|
+
try {
|
|
681
|
+
const loginProc = spawn('devtunnel', ['user', 'login'], { stdio: 'inherit' });
|
|
682
|
+
await new Promise((resolve, reject) => {
|
|
683
|
+
loginProc.on('close', (code) => code === 0 ? resolve() : reject(new Error(`Login exited with code ${code}`)));
|
|
684
|
+
loginProc.on('error', reject);
|
|
685
|
+
});
|
|
686
|
+
console.log(`\n ${GREEN}✓${RESET} Logged in! Please run cli-tunnel again to create the tunnel.\n`);
|
|
687
|
+
}
|
|
688
|
+
catch {
|
|
689
|
+
console.log(`\n ${YELLOW}⚠${RESET} Login failed. Run manually: ${GREEN}devtunnel user login${RESET}\n`);
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
else {
|
|
694
|
+
console.log(` ${YELLOW}⚠${RESET} Tunnel failed: ${errMsg}\n`);
|
|
695
|
+
}
|
|
496
696
|
}
|
|
497
697
|
} // end if (devtunnelInstalled)
|
|
498
698
|
}
|
|
@@ -527,27 +727,16 @@ async function main() {
|
|
|
527
727
|
}
|
|
528
728
|
catch { /* use as-is */ }
|
|
529
729
|
}
|
|
530
|
-
// F-07: Security —
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
'
|
|
534
|
-
'
|
|
535
|
-
'
|
|
536
|
-
|
|
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
|
-
]);
|
|
730
|
+
// F-07: Security — filter dangerous environment variables for PTY
|
|
731
|
+
// Blocklist approach: pass everything except known dangerous vars and secrets
|
|
732
|
+
const DANGEROUS_VARS = new Set(['NODE_OPTIONS', 'NODE_REPL_HISTORY', 'NODE_EXTRA_CA_CERTS',
|
|
733
|
+
'NODE_PATH', 'NODE_REDIRECT_WARNINGS', 'NODE_PENDING_DEPRECATION',
|
|
734
|
+
'UV_THREADPOOL_SIZE', 'LD_PRELOAD', 'DYLD_INSERT_LIBRARIES',
|
|
735
|
+
'SSH_AUTH_SOCK', 'GPG_TTY']);
|
|
736
|
+
const sensitivePattern = /token|secret|key|password|credential|api_key|private_key|access_key|connection_string|auth|kubeconfig|docker_host|docker_config/i;
|
|
548
737
|
const safeEnv = {};
|
|
549
738
|
for (const [k, v] of Object.entries(process.env)) {
|
|
550
|
-
if (
|
|
739
|
+
if (v !== undefined && !DANGEROUS_VARS.has(k) && !sensitivePattern.test(k)) {
|
|
551
740
|
safeEnv[k] = v;
|
|
552
741
|
}
|
|
553
742
|
}
|
|
@@ -556,6 +745,25 @@ async function main() {
|
|
|
556
745
|
cols, rows, cwd,
|
|
557
746
|
env: safeEnv,
|
|
558
747
|
});
|
|
748
|
+
// Detect CSPRNG crash (rare Node.js + PTY issue) and show helpful message
|
|
749
|
+
let ptyExitedEarly = false;
|
|
750
|
+
const earlyExitCheck = new Promise((resolve) => {
|
|
751
|
+
ptyProcess.onExit(({ exitCode }) => {
|
|
752
|
+
if (exitCode === 134 || exitCode === 3221226505) {
|
|
753
|
+
ptyExitedEarly = true;
|
|
754
|
+
resolve();
|
|
755
|
+
}
|
|
756
|
+
});
|
|
757
|
+
setTimeout(resolve, 2000);
|
|
758
|
+
});
|
|
759
|
+
await earlyExitCheck;
|
|
760
|
+
if (ptyExitedEarly) {
|
|
761
|
+
const nodeVer = process.version;
|
|
762
|
+
console.log(` ${YELLOW}⚠${RESET} The command crashed (CSPRNG assertion failure).`);
|
|
763
|
+
console.log(` This is a known issue with Node.js ${nodeVer} + PTY on Windows.`);
|
|
764
|
+
console.log(` ${BOLD}Fix:${RESET} Install Node.js 22 LTS: ${GREEN}nvm install 22${RESET} or ${GREEN}winget install OpenJS.NodeJS.LTS${RESET}\n`);
|
|
765
|
+
process.exit(1);
|
|
766
|
+
}
|
|
559
767
|
ptyProcess.onData((data) => {
|
|
560
768
|
process.stdout.write(data);
|
|
561
769
|
broadcast(data);
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cli-tunnel",
|
|
3
|
-
"version": "1.2.0-beta.
|
|
4
|
-
"description": "Tunnel any CLI app to your phone
|
|
3
|
+
"version": "1.2.0-beta.10",
|
|
4
|
+
"description": "Tunnel any CLI app to your phone — PTY + devtunnel + xterm.js",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
7
7
|
"bin": {
|
|
@@ -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
|
}
|
package/remote-ui/app.js
CHANGED
|
@@ -45,7 +45,9 @@
|
|
|
45
45
|
const permOverlay = $('#permission-overlay');
|
|
46
46
|
const dashboard = $('#dashboard');
|
|
47
47
|
const termContainer = $('#terminal-container');
|
|
48
|
-
let currentView = 'terminal'; // 'dashboard' or '
|
|
48
|
+
let currentView = 'terminal'; // 'dashboard', 'terminal', or 'grid'
|
|
49
|
+
let cachedSessions = [];
|
|
50
|
+
let gridTerminals = []; // { xterm, fitAddon, ws, session }
|
|
49
51
|
|
|
50
52
|
// ─── xterm.js Terminal ───────────────────────────────────
|
|
51
53
|
let xterm = null;
|
|
@@ -127,41 +129,52 @@
|
|
|
127
129
|
const filtered = showOffline ? sessions : sessions.filter(s => s.online);
|
|
128
130
|
const offlineCount = sessions.filter(s => !s.online).length;
|
|
129
131
|
const onlineCount = sessions.filter(s => s.online).length;
|
|
132
|
+
const connectable = filtered.filter(s => s.online && s.token);
|
|
130
133
|
|
|
131
134
|
let html = `<div style="padding:8px 4px;display:flex;align-items:center;gap:8px">
|
|
132
135
|
<span style="color:var(--text-dim);font-size:12px">${onlineCount} online${offlineCount > 0 ? ', ' + offlineCount + ' offline' : ''}</span>
|
|
133
136
|
<span style="flex:1"></span>
|
|
134
|
-
<button
|
|
135
|
-
|
|
136
|
-
<button
|
|
137
|
+
${connectable.length > 1 ? '<button data-action="grid-view" style="background:none;border:1px solid var(--blue);color:var(--blue);font-family:var(--font);font-size:11px;padding:3px 8px;border-radius:4px;cursor:pointer">⊞ Grid</button>' : ''}
|
|
138
|
+
<button data-action="toggle-offline" style="background:none;border:1px solid var(--border);color:var(--text-dim);font-family:var(--font);font-size:11px;padding:3px 8px;border-radius:4px;cursor:pointer">${showOffline ? 'Hide offline' : 'Show offline'}</button>
|
|
139
|
+
${offlineCount > 0 ? '<button data-action="clean-offline" style="background:none;border:1px solid var(--red);color:var(--red);font-family:var(--font);font-size:11px;padding:3px 8px;border-radius:4px;cursor:pointer">Clean offline</button>' : ''}
|
|
140
|
+
<button data-action="refresh" style="background:none;border:1px solid var(--border);color:var(--text-dim);font-family:var(--font);font-size:11px;padding:3px 8px;border-radius:4px;cursor:pointer">↻</button>
|
|
137
141
|
</div>`;
|
|
138
142
|
|
|
139
143
|
if (filtered.length === 0) {
|
|
140
144
|
html += '<div style="padding:20px 12px;color:var(--text-dim);text-align:center">' +
|
|
141
|
-
(sessions.length === 0 ? 'No
|
|
145
|
+
(sessions.length === 0 ? 'No cli-tunnel sessions found.' : 'No online sessions. Tap "Show offline" to see stale ones.') +
|
|
142
146
|
'</div>';
|
|
143
147
|
} else {
|
|
144
|
-
html += filtered.map(s =>
|
|
145
|
-
|
|
148
|
+
html += filtered.map(s => {
|
|
149
|
+
const sessionUrl = s.token ? s.url + '?token=' + encodeURIComponent(s.token) : '';
|
|
150
|
+
return `
|
|
151
|
+
<div class="session-card" ${s.online && sessionUrl ? 'data-session-url="' + escapeHtml(sessionUrl) + '"' : ''}>
|
|
146
152
|
<span class="status-dot ${s.online ? 'online' : 'offline'}"></span>
|
|
147
153
|
<div class="info">
|
|
154
|
+
<div class="session-name">${escapeHtml(s.name)}</div>
|
|
148
155
|
<div class="repo">📦 ${escapeHtml(s.repo)}</div>
|
|
149
156
|
<div class="branch">🌿 ${escapeHtml(s.branch)}</div>
|
|
150
|
-
<div class="machine">💻 ${escapeHtml(s.machine)}</div>
|
|
157
|
+
<div class="machine">💻 ${escapeHtml(s.machine)}${!s.token && s.online ? ' 🔒' : ''}</div>
|
|
151
158
|
</div>
|
|
152
|
-
${s.online ? '<span class="arrow">→</span>' :
|
|
153
|
-
'<button data-delete-id="' + escapeHtml(s.id) + '" style="background:none;border:none;color:var(--red);cursor:pointer;font-size:14px" title="Remove">✕</button>'
|
|
154
|
-
|
|
155
|
-
|
|
159
|
+
${s.online && sessionUrl ? '<span class="arrow">→</span>' :
|
|
160
|
+
!s.online ? '<button data-delete-id="' + escapeHtml(s.id) + '" style="background:none;border:none;color:var(--red);cursor:pointer;font-size:14px" title="Remove">✕</button>'
|
|
161
|
+
: '<span style="color:var(--text-dim);font-size:11px">remote</span>'}
|
|
162
|
+
</div>`;
|
|
163
|
+
}).join('');
|
|
156
164
|
}
|
|
157
165
|
dashboard.innerHTML = html;
|
|
158
|
-
|
|
166
|
+
cachedSessions = sessions;
|
|
167
|
+
// Event delegation
|
|
159
168
|
dashboard.querySelectorAll('.session-card[data-session-url]').forEach(function(card) {
|
|
160
169
|
card.addEventListener('click', function() { openSession(card.dataset.sessionUrl); });
|
|
161
170
|
});
|
|
162
171
|
dashboard.querySelectorAll('[data-delete-id]').forEach(function(btn) {
|
|
163
172
|
btn.addEventListener('click', function(e) { e.stopPropagation(); deleteSession(btn.dataset.deleteId); });
|
|
164
173
|
});
|
|
174
|
+
dashboard.querySelector('[data-action="toggle-offline"]')?.addEventListener('click', function() { toggleOffline(); });
|
|
175
|
+
dashboard.querySelector('[data-action="clean-offline"]')?.addEventListener('click', function() { cleanOffline(); });
|
|
176
|
+
dashboard.querySelector('[data-action="refresh"]')?.addEventListener('click', function() { loadSessions(); });
|
|
177
|
+
dashboard.querySelector('[data-action="grid-view"]')?.addEventListener('click', function() { showGridView(sessions); });
|
|
165
178
|
}
|
|
166
179
|
|
|
167
180
|
window.openSession = (url) => {
|
|
@@ -188,7 +201,159 @@
|
|
|
188
201
|
loadSessions();
|
|
189
202
|
};
|
|
190
203
|
|
|
204
|
+
// ─── Grid View (tmux-style multi-terminal) ────────────────
|
|
205
|
+
function showGridView(sessions) {
|
|
206
|
+
const connectable = sessions.filter(function(s) { return s.online && s.token; });
|
|
207
|
+
if (connectable.length === 0) return;
|
|
208
|
+
|
|
209
|
+
// Clean up previous grid
|
|
210
|
+
destroyGrid();
|
|
211
|
+
|
|
212
|
+
currentView = 'grid';
|
|
213
|
+
dashboard.classList.add('hidden');
|
|
214
|
+
terminal.classList.add('hidden');
|
|
215
|
+
termContainer.classList.add('hidden');
|
|
216
|
+
$('#input-area').classList.add('hidden');
|
|
217
|
+
|
|
218
|
+
var gridEl = document.getElementById('grid-view');
|
|
219
|
+
if (!gridEl) {
|
|
220
|
+
gridEl = document.createElement('div');
|
|
221
|
+
gridEl.id = 'grid-view';
|
|
222
|
+
document.getElementById('app').insertBefore(gridEl, document.getElementById('input-area'));
|
|
223
|
+
}
|
|
224
|
+
gridEl.classList.remove('hidden');
|
|
225
|
+
gridEl.innerHTML = '';
|
|
226
|
+
|
|
227
|
+
// Calculate grid dimensions
|
|
228
|
+
var cols = connectable.length <= 2 ? connectable.length : connectable.length <= 4 ? 2 : 3;
|
|
229
|
+
gridEl.style.gridTemplateColumns = 'repeat(' + cols + ', 1fr)';
|
|
230
|
+
|
|
231
|
+
connectable.forEach(function(s) {
|
|
232
|
+
var panel = document.createElement('div');
|
|
233
|
+
panel.className = 'grid-panel';
|
|
234
|
+
|
|
235
|
+
// Header
|
|
236
|
+
var header = document.createElement('div');
|
|
237
|
+
header.className = 'grid-panel-header';
|
|
238
|
+
header.innerHTML = '<span class="grid-panel-name">' + escapeHtml(s.name) + '</span>' +
|
|
239
|
+
'<span class="grid-panel-machine">' + escapeHtml(s.machine) + '</span>' +
|
|
240
|
+
'<span class="grid-panel-status">●</span>';
|
|
241
|
+
panel.appendChild(header);
|
|
242
|
+
|
|
243
|
+
// Terminal container
|
|
244
|
+
var termDiv = document.createElement('div');
|
|
245
|
+
termDiv.className = 'grid-panel-terminal';
|
|
246
|
+
panel.appendChild(termDiv);
|
|
247
|
+
|
|
248
|
+
gridEl.appendChild(panel);
|
|
249
|
+
|
|
250
|
+
// Create xterm instance for this panel
|
|
251
|
+
var panelXterm = new Terminal({
|
|
252
|
+
theme: {
|
|
253
|
+
background: '#0d1117', foreground: '#c9d1d9', cursor: '#3fb950',
|
|
254
|
+
selectionBackground: '#264f78',
|
|
255
|
+
},
|
|
256
|
+
fontFamily: "'Cascadia Code', 'SF Mono', 'Fira Code', 'Menlo', monospace",
|
|
257
|
+
fontSize: 11,
|
|
258
|
+
scrollback: 1000,
|
|
259
|
+
cursorBlink: true,
|
|
260
|
+
});
|
|
261
|
+
var panelFit = new FitAddon.FitAddon();
|
|
262
|
+
panelXterm.loadAddon(panelFit);
|
|
263
|
+
panelXterm.open(termDiv);
|
|
264
|
+
|
|
265
|
+
// Delay fit to ensure container has size
|
|
266
|
+
setTimeout(function() { panelFit.fit(); }, 100);
|
|
267
|
+
|
|
268
|
+
// Connect WebSocket to this session via ticket auth
|
|
269
|
+
var statusDot = header.querySelector('.grid-panel-status');
|
|
270
|
+
var panelWs = null;
|
|
271
|
+
|
|
272
|
+
(function connectPanel() {
|
|
273
|
+
var sessionOrigin = new URL(s.url).origin;
|
|
274
|
+
fetch(sessionOrigin + '/api/auth/ticket', {
|
|
275
|
+
method: 'POST',
|
|
276
|
+
headers: { 'Authorization': 'Bearer ' + s.token }
|
|
277
|
+
}).then(function(resp) {
|
|
278
|
+
if (!resp.ok) throw new Error('Auth failed');
|
|
279
|
+
return resp.json();
|
|
280
|
+
}).then(function(data) {
|
|
281
|
+
panelWs = new WebSocket(sessionOrigin.replace('https://', 'wss://') + '?ticket=' + encodeURIComponent(data.ticket));
|
|
282
|
+
|
|
283
|
+
panelWs.onopen = function() {
|
|
284
|
+
if (statusDot) { statusDot.style.color = 'var(--green)'; statusDot.title = 'Connected'; }
|
|
285
|
+
panelWs.send(JSON.stringify({ type: 'pty_resize', cols: panelXterm.cols, rows: panelXterm.rows }));
|
|
286
|
+
};
|
|
287
|
+
panelWs.onclose = function() {
|
|
288
|
+
if (statusDot) { statusDot.style.color = 'var(--red)'; statusDot.title = 'Disconnected'; }
|
|
289
|
+
};
|
|
290
|
+
panelWs.onerror = function() {
|
|
291
|
+
if (statusDot) { statusDot.style.color = 'var(--red)'; }
|
|
292
|
+
};
|
|
293
|
+
panelWs.onmessage = function(e) {
|
|
294
|
+
try {
|
|
295
|
+
var msg = JSON.parse(e.data);
|
|
296
|
+
if (msg.type === 'pty') {
|
|
297
|
+
panelXterm.write(msg.data);
|
|
298
|
+
}
|
|
299
|
+
} catch (err) {}
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
panelXterm.onData(function(data) {
|
|
303
|
+
if (panelWs && panelWs.readyState === WebSocket.OPEN) {
|
|
304
|
+
panelWs.send(JSON.stringify({ type: 'pty_input', data: data }));
|
|
305
|
+
}
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
gridTerminals.push({ xterm: panelXterm, fitAddon: panelFit, ws: panelWs, session: s });
|
|
309
|
+
}).catch(function() {
|
|
310
|
+
if (statusDot) { statusDot.style.color = 'var(--red)'; statusDot.title = 'Auth failed'; }
|
|
311
|
+
gridTerminals.push({ xterm: panelXterm, fitAddon: panelFit, ws: null, session: s });
|
|
312
|
+
});
|
|
313
|
+
})();
|
|
314
|
+
|
|
315
|
+
// Click header to go full-screen on this session
|
|
316
|
+
header.addEventListener('click', function() {
|
|
317
|
+
window.location.href = s.url + '?token=' + encodeURIComponent(s.token);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
gridTerminals.push({ xterm: panelXterm, fitAddon: panelFit, ws: panelWs, session: s });
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
// Handle window resize for grid panels
|
|
324
|
+
window.addEventListener('resize', fitGridPanels);
|
|
325
|
+
|
|
326
|
+
// Add back button
|
|
327
|
+
if ($('#btn-sessions')) { $('#btn-sessions').textContent = 'List'; }
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function fitGridPanels() {
|
|
331
|
+
gridTerminals.forEach(function(gt) {
|
|
332
|
+
if (gt.fitAddon) { try { gt.fitAddon.fit(); } catch(e) {} }
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function destroyGrid() {
|
|
337
|
+
gridTerminals.forEach(function(gt) {
|
|
338
|
+
if (gt.ws) { try { gt.ws.close(); } catch(e) {} }
|
|
339
|
+
if (gt.xterm) { try { gt.xterm.dispose(); } catch(e) {} }
|
|
340
|
+
});
|
|
341
|
+
gridTerminals = [];
|
|
342
|
+
window.removeEventListener('resize', fitGridPanels);
|
|
343
|
+
var gridEl = document.getElementById('grid-view');
|
|
344
|
+
if (gridEl) { gridEl.innerHTML = ''; gridEl.classList.add('hidden'); }
|
|
345
|
+
}
|
|
346
|
+
|
|
191
347
|
window.toggleView = () => {
|
|
348
|
+
if (currentView === 'grid') {
|
|
349
|
+
// Grid → dashboard (list view)
|
|
350
|
+
destroyGrid();
|
|
351
|
+
currentView = 'dashboard';
|
|
352
|
+
dashboard.classList.remove('hidden');
|
|
353
|
+
$('#btn-sessions').textContent = 'Terminal';
|
|
354
|
+
loadSessions();
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
192
357
|
if (currentView === 'terminal') {
|
|
193
358
|
currentView = 'dashboard';
|
|
194
359
|
terminal.classList.add('hidden');
|
|
@@ -198,6 +363,7 @@
|
|
|
198
363
|
$('#btn-sessions').textContent = 'Terminal';
|
|
199
364
|
loadSessions();
|
|
200
365
|
} else {
|
|
366
|
+
destroyGrid();
|
|
201
367
|
currentView = 'terminal';
|
|
202
368
|
dashboard.classList.add('hidden');
|
|
203
369
|
$('#input-area').classList.remove('hidden');
|
|
@@ -367,7 +533,7 @@
|
|
|
367
533
|
}
|
|
368
534
|
|
|
369
535
|
// ─── Detect hub mode (no token in URL) ────────────────────
|
|
370
|
-
const isHubMode =
|
|
536
|
+
const isHubMode = new URLSearchParams(window.location.search).get('hub') === '1';
|
|
371
537
|
|
|
372
538
|
// ─── WebSocket ───────────────────────────────────────────
|
|
373
539
|
let reconnectAttempt = 0;
|
|
@@ -392,7 +558,7 @@
|
|
|
392
558
|
|
|
393
559
|
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
394
560
|
|
|
395
|
-
// F-02:
|
|
561
|
+
// F-02: Ticket-based auth (required)
|
|
396
562
|
try {
|
|
397
563
|
const resp = await fetch('/api/auth/ticket', {
|
|
398
564
|
method: 'POST',
|
|
@@ -402,12 +568,12 @@
|
|
|
402
568
|
const { ticket } = await resp.json();
|
|
403
569
|
ws = new WebSocket(`${proto}//${location.host}?ticket=${encodeURIComponent(ticket)}`);
|
|
404
570
|
} else {
|
|
405
|
-
|
|
406
|
-
|
|
571
|
+
setStatus('offline', 'Auth failed');
|
|
572
|
+
return;
|
|
407
573
|
}
|
|
408
574
|
} catch {
|
|
409
|
-
|
|
410
|
-
|
|
575
|
+
setStatus('offline', 'Auth failed');
|
|
576
|
+
return;
|
|
411
577
|
}
|
|
412
578
|
setStatus('connecting', 'Connecting...');
|
|
413
579
|
|
|
@@ -562,6 +728,25 @@
|
|
|
562
728
|
};
|
|
563
729
|
|
|
564
730
|
// ─── Mobile Key Bar ───────────────────────────────────────
|
|
731
|
+
// F-5: Event delegation for key-bar buttons (no inline onclick)
|
|
732
|
+
const keyBar = document.getElementById('key-bar');
|
|
733
|
+
if (keyBar) {
|
|
734
|
+
const keyMap: Record<string, string> = {
|
|
735
|
+
'\\x1b[A': '\x1b[A', '\\x1b[B': '\x1b[B', '\\x1b[C': '\x1b[C', '\\x1b[D': '\x1b[D',
|
|
736
|
+
'\\t': '\t', '\\r': '\r', '\\x1b': '\x1b', '\\x03': '\x03', ' ': ' ', '\\x7f': '\x7f',
|
|
737
|
+
};
|
|
738
|
+
keyBar.addEventListener('click', function(e) {
|
|
739
|
+
var btn = e.target;
|
|
740
|
+
if (btn && btn.tagName === 'BUTTON' && btn.dataset.key) {
|
|
741
|
+
var key = keyMap[btn.dataset.key] || btn.dataset.key;
|
|
742
|
+
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
743
|
+
ws.send(JSON.stringify({ type: 'pty_input', data: key }));
|
|
744
|
+
}
|
|
745
|
+
if (xterm) xterm.focus();
|
|
746
|
+
}
|
|
747
|
+
});
|
|
748
|
+
}
|
|
749
|
+
|
|
565
750
|
window.sendKey = (key) => {
|
|
566
751
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
567
752
|
ws.send(JSON.stringify({ type: 'pty_input', data: key }));
|
package/remote-ui/index.html
CHANGED
|
@@ -32,16 +32,16 @@
|
|
|
32
32
|
|
|
33
33
|
<footer id="input-area">
|
|
34
34
|
<div id="key-bar">
|
|
35
|
-
<button
|
|
36
|
-
<button
|
|
37
|
-
<button
|
|
38
|
-
<button
|
|
39
|
-
<button
|
|
40
|
-
<button
|
|
41
|
-
<button
|
|
42
|
-
<button
|
|
43
|
-
<button
|
|
44
|
-
<button
|
|
35
|
+
<button data-key="\x1b[A">↑</button>
|
|
36
|
+
<button data-key="\x1b[B">↓</button>
|
|
37
|
+
<button data-key="\x1b[C">→</button>
|
|
38
|
+
<button data-key="\x1b[D">←</button>
|
|
39
|
+
<button data-key="\t">Tab</button>
|
|
40
|
+
<button data-key="\r">Enter</button>
|
|
41
|
+
<button data-key="\x1b">Esc</button>
|
|
42
|
+
<button data-key="\x03">Ctrl+C</button>
|
|
43
|
+
<button data-key=" ">Space</button>
|
|
44
|
+
<button data-key="\x7f">⌫</button>
|
|
45
45
|
</div>
|
|
46
46
|
<form id="input-form">
|
|
47
47
|
<span class="prompt">></span>
|
package/remote-ui/styles.css
CHANGED
|
@@ -242,10 +242,51 @@ header {
|
|
|
242
242
|
.session-card .status-dot.offline { background: var(--text-dim); }
|
|
243
243
|
.session-card .info { flex: 1; min-width: 0; }
|
|
244
244
|
.session-card .repo { color: var(--blue); font-weight: bold; font-size: 13px; }
|
|
245
|
+
.session-card .session-name { color: var(--text-bright); font-weight: bold; font-size: 14px; }
|
|
245
246
|
.session-card .branch { color: var(--text-dim); font-size: 11px; }
|
|
246
247
|
.session-card .machine { color: var(--text-dim); font-size: 11px; }
|
|
247
248
|
.session-card .arrow { color: var(--text-dim); }
|
|
248
249
|
|
|
250
|
+
/* Grid View (tmux-style multi-terminal) */
|
|
251
|
+
#grid-view {
|
|
252
|
+
flex: 1;
|
|
253
|
+
display: grid;
|
|
254
|
+
gap: 2px;
|
|
255
|
+
padding: 2px;
|
|
256
|
+
overflow: hidden;
|
|
257
|
+
background: var(--border);
|
|
258
|
+
}
|
|
259
|
+
.grid-panel {
|
|
260
|
+
display: flex;
|
|
261
|
+
flex-direction: column;
|
|
262
|
+
background: var(--bg);
|
|
263
|
+
overflow: hidden;
|
|
264
|
+
min-height: 0;
|
|
265
|
+
}
|
|
266
|
+
.grid-panel-header {
|
|
267
|
+
display: flex;
|
|
268
|
+
align-items: center;
|
|
269
|
+
gap: 6px;
|
|
270
|
+
padding: 3px 8px;
|
|
271
|
+
background: var(--bg-tool);
|
|
272
|
+
border-bottom: 1px solid var(--border);
|
|
273
|
+
flex-shrink: 0;
|
|
274
|
+
cursor: pointer;
|
|
275
|
+
font-size: 11px;
|
|
276
|
+
}
|
|
277
|
+
.grid-panel-header:hover { background: var(--border); }
|
|
278
|
+
.grid-panel-name { color: var(--blue); font-weight: bold; }
|
|
279
|
+
.grid-panel-machine { color: var(--text-dim); flex: 1; }
|
|
280
|
+
.grid-panel-status { font-size: 8px; color: var(--yellow); }
|
|
281
|
+
.grid-panel-terminal {
|
|
282
|
+
flex: 1;
|
|
283
|
+
overflow: hidden;
|
|
284
|
+
}
|
|
285
|
+
.grid-panel-terminal .xterm {
|
|
286
|
+
height: 100%;
|
|
287
|
+
padding: 2px;
|
|
288
|
+
}
|
|
289
|
+
|
|
249
290
|
/* Scrollbar */
|
|
250
291
|
::-webkit-scrollbar { width: 6px; }
|
|
251
292
|
::-webkit-scrollbar-track { background: transparent; }
|