cli-tunnel 1.2.0-beta.1 → 1.2.0-beta.11
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 +308 -66
- package/package.json +8 -6
- package/remote-ui/app.js +218 -23
- 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);
|
|
153
|
-
|
|
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);
|
|
231
|
+
const server = http.createServer(async (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) {
|
|
@@ -181,27 +274,72 @@ const server = http.createServer((req, res) => {
|
|
|
181
274
|
return;
|
|
182
275
|
}
|
|
183
276
|
}
|
|
277
|
+
// Hub ticket proxy — fetch ticket from local session on behalf of grid client
|
|
278
|
+
if (hubMode && req.url?.startsWith('/api/proxy/ticket/') && req.method === 'POST') {
|
|
279
|
+
const targetPort = parseInt(req.url.replace('/api/proxy/ticket/', ''), 10);
|
|
280
|
+
if (!Number.isFinite(targetPort) || targetPort < 1 || targetPort > 65535) {
|
|
281
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
282
|
+
res.end(JSON.stringify({ error: 'Invalid port' }));
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
// Find token for this port from session files
|
|
286
|
+
const localSessions = readLocalSessions();
|
|
287
|
+
const session = localSessions.find(s => s.port === targetPort);
|
|
288
|
+
if (!session) {
|
|
289
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
290
|
+
res.end(JSON.stringify({ error: 'Session not found' }));
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
try {
|
|
294
|
+
const ticketResp = await fetch(`http://127.0.0.1:${targetPort}/api/auth/ticket`, {
|
|
295
|
+
method: 'POST', headers: { 'Authorization': `Bearer ${session.token}` },
|
|
296
|
+
signal: AbortSignal.timeout(3000),
|
|
297
|
+
});
|
|
298
|
+
if (!ticketResp.ok)
|
|
299
|
+
throw new Error('Ticket request failed');
|
|
300
|
+
const ticketData = await ticketResp.json();
|
|
301
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
302
|
+
res.end(JSON.stringify({ ticket: ticketData.ticket, port: targetPort }));
|
|
303
|
+
}
|
|
304
|
+
catch {
|
|
305
|
+
res.writeHead(502, { 'Content-Type': 'application/json' });
|
|
306
|
+
res.end(JSON.stringify({ error: 'Session unreachable' }));
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
184
311
|
// Sessions API
|
|
185
|
-
if (req.url === '/api/sessions' && req.method === 'GET') {
|
|
312
|
+
if ((req.url === '/api/sessions' || req.url?.startsWith('/api/sessions?')) && req.method === 'GET') {
|
|
186
313
|
try {
|
|
187
314
|
const output = execFileSync('devtunnel', ['list', '--labels', 'cli-tunnel', '--json'], { encoding: 'utf-8', timeout: 10000, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
188
315
|
const data = JSON.parse(output);
|
|
316
|
+
const localMachine = os.hostname();
|
|
317
|
+
const localSessions = hubMode ? readLocalSessions() : [];
|
|
318
|
+
const tokenMap = new Map(localSessions.map(s => [s.tunnelId, s.token]));
|
|
189
319
|
const sessions = (data.tunnels || []).map((t) => {
|
|
190
320
|
const labels = t.labels || [];
|
|
191
321
|
const id = t.tunnelId?.replace(/\.\w+$/, '') || t.tunnelId;
|
|
192
322
|
const cluster = t.tunnelId?.split('.').pop() || 'euw';
|
|
193
323
|
const portLabel = labels.find((l) => l.startsWith('port-'));
|
|
194
324
|
const p = portLabel ? parseInt(portLabel.replace('port-', ''), 10) : 3456;
|
|
195
|
-
|
|
325
|
+
const machine = labels[4] || 'unknown';
|
|
326
|
+
const session = {
|
|
196
327
|
id, tunnelId: t.tunnelId,
|
|
197
328
|
name: labels[1] || 'unnamed',
|
|
198
329
|
repo: labels[2] || 'unknown',
|
|
199
330
|
branch: (labels[3] || 'unknown').replace(/_/g, '/'),
|
|
200
|
-
machine
|
|
331
|
+
machine,
|
|
201
332
|
online: (t.hostConnections || 0) > 0,
|
|
202
333
|
port: p,
|
|
203
334
|
url: `https://${id}-${p}.${cluster}.devtunnels.ms`,
|
|
335
|
+
isLocal: machine === localMachine,
|
|
204
336
|
};
|
|
337
|
+
// Attach token from local session files (hub mode only)
|
|
338
|
+
const baseId = t.tunnelId?.split('.')[0] || t.tunnelId;
|
|
339
|
+
const token = tokenMap.get(baseId) || tokenMap.get(t.tunnelId);
|
|
340
|
+
if (token)
|
|
341
|
+
session.token = token;
|
|
342
|
+
return session;
|
|
205
343
|
});
|
|
206
344
|
res.writeHead(200, { 'Content-Type': 'application/json', 'X-Frame-Options': 'DENY', 'X-Content-Type-Options': 'nosniff' });
|
|
207
345
|
res.end(JSON.stringify({ sessions }));
|
|
@@ -236,7 +374,9 @@ const server = http.createServer((req, res) => {
|
|
|
236
374
|
// #18: Guard against malformed URI encoding
|
|
237
375
|
let decodedUrl;
|
|
238
376
|
try {
|
|
239
|
-
|
|
377
|
+
// Strip query string before resolving file path
|
|
378
|
+
const urlPath = (req.url || '/').split('?')[0];
|
|
379
|
+
decodedUrl = decodeURIComponent(urlPath);
|
|
240
380
|
}
|
|
241
381
|
catch {
|
|
242
382
|
res.writeHead(400);
|
|
@@ -277,9 +417,10 @@ const server = http.createServer((req, res) => {
|
|
|
277
417
|
'Content-Type': mimes[ext] || 'application/octet-stream',
|
|
278
418
|
'X-Frame-Options': 'DENY',
|
|
279
419
|
'X-Content-Type-Options': 'nosniff',
|
|
280
|
-
'Content-Security-Policy': "default-src 'self'; script-src 'self'
|
|
420
|
+
'Content-Security-Policy': "default-src 'self'; script-src 'self' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; connect-src 'self' ws://localhost:* ws://127.0.0.1:* http://127.0.0.1:* wss://*.devtunnels.ms https://*.devtunnels.ms;",
|
|
281
421
|
'Referrer-Policy': 'no-referrer',
|
|
282
422
|
'Cache-Control': 'no-store',
|
|
423
|
+
'Strict-Transport-Security': 'max-age=31536000; includeSubDomains',
|
|
283
424
|
};
|
|
284
425
|
res.writeHead(200, securityHeaders);
|
|
285
426
|
// #8: Handle createReadStream errors
|
|
@@ -298,19 +439,7 @@ const wss = new WebSocketServer({
|
|
|
298
439
|
// F-18: Session expiry
|
|
299
440
|
if (Date.now() - sessionCreatedAt > SESSION_TTL)
|
|
300
441
|
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
|
|
442
|
+
// F-3: Validate origin BEFORE ticket acceptance
|
|
314
443
|
const origin = info.req.headers.origin;
|
|
315
444
|
if (origin) {
|
|
316
445
|
try {
|
|
@@ -324,7 +453,15 @@ const wss = new WebSocketServer({
|
|
|
324
453
|
return false;
|
|
325
454
|
}
|
|
326
455
|
}
|
|
327
|
-
|
|
456
|
+
const url = new URL(info.req.url, `http://${info.req.headers.host}`);
|
|
457
|
+
// F-02: Accept one-time ticket (only auth method for WS)
|
|
458
|
+
const ticket = url.searchParams.get('ticket');
|
|
459
|
+
if (ticket && tickets.has(ticket)) {
|
|
460
|
+
const t = tickets.get(ticket);
|
|
461
|
+
tickets.delete(ticket); // Single use
|
|
462
|
+
return t.expires > Date.now();
|
|
463
|
+
}
|
|
464
|
+
return false;
|
|
328
465
|
},
|
|
329
466
|
});
|
|
330
467
|
// ─── Security: Audit log for remote PTY input ──────────────
|
|
@@ -334,14 +471,27 @@ const auditLogPath = path.join(auditDir, `audit-${new Date().toISOString().slice
|
|
|
334
471
|
const auditLog = fs.createWriteStream(auditLogPath, { flags: 'a' });
|
|
335
472
|
auditLog.on('error', (err) => { console.error('Audit log error:', err.message); });
|
|
336
473
|
wss.on('connection', (ws, req) => {
|
|
337
|
-
// F-10: Connection cap
|
|
474
|
+
// F-10: Connection cap (global + per-IP)
|
|
338
475
|
if (connections.size >= 5) {
|
|
339
476
|
ws.close(1013, 'Max connections reached');
|
|
340
477
|
return;
|
|
341
478
|
}
|
|
342
|
-
const id = crypto.randomUUID();
|
|
343
479
|
const remoteAddress = req.socket.remoteAddress || 'unknown';
|
|
480
|
+
let perIpCount = 0;
|
|
481
|
+
for (const [, c] of connections) {
|
|
482
|
+
if (c._remoteAddress === remoteAddress)
|
|
483
|
+
perIpCount++;
|
|
484
|
+
}
|
|
485
|
+
if (perIpCount >= 2) {
|
|
486
|
+
ws.close(1013, 'Max connections per IP reached');
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
const id = crypto.randomUUID();
|
|
490
|
+
ws._remoteAddress = remoteAddress;
|
|
344
491
|
connections.set(id, ws);
|
|
492
|
+
// F-10: WS ping/pong heartbeat
|
|
493
|
+
ws._isAlive = true;
|
|
494
|
+
ws.on('pong', () => { ws._isAlive = true; });
|
|
345
495
|
// Replay history with secrets redacted (only if replay is enabled)
|
|
346
496
|
if (hasReplay) {
|
|
347
497
|
for (const event of acpEventLog) {
|
|
@@ -373,6 +523,18 @@ wss.on('connection', (ws, req) => {
|
|
|
373
523
|
});
|
|
374
524
|
ws.on('close', () => connections.delete(id));
|
|
375
525
|
});
|
|
526
|
+
// F-10: WS heartbeat — ping every 30s, close unresponsive after 10s
|
|
527
|
+
setInterval(() => {
|
|
528
|
+
for (const [id, ws] of connections) {
|
|
529
|
+
if (ws._isAlive === false) {
|
|
530
|
+
ws.terminate();
|
|
531
|
+
connections.delete(id);
|
|
532
|
+
continue;
|
|
533
|
+
}
|
|
534
|
+
ws._isAlive = false;
|
|
535
|
+
ws.ping();
|
|
536
|
+
}
|
|
537
|
+
}, 30000);
|
|
376
538
|
function broadcast(data) {
|
|
377
539
|
const msg = JSON.stringify({ type: 'pty', data });
|
|
378
540
|
if (hasReplay) {
|
|
@@ -401,7 +563,8 @@ async function main() {
|
|
|
401
563
|
console.log(`\n${BOLD}cli-tunnel${RESET} ${DIM}v1.1.0${RESET}\n`);
|
|
402
564
|
if (hubMode) {
|
|
403
565
|
console.log(` ${BOLD}📋 Hub Mode${RESET} — sessions dashboard`);
|
|
404
|
-
console.log(` ${DIM}Port:${RESET} ${actualPort}
|
|
566
|
+
console.log(` ${DIM}Port:${RESET} ${actualPort}`);
|
|
567
|
+
console.log(` ${DIM}Local URL:${RESET} http://127.0.0.1:${actualPort}?token=${sessionToken}&hub=1\n`);
|
|
405
568
|
}
|
|
406
569
|
else {
|
|
407
570
|
console.log(` ${DIM}Command:${RESET} ${command} ${commandArgs.join(' ')}`);
|
|
@@ -421,35 +584,83 @@ async function main() {
|
|
|
421
584
|
}
|
|
422
585
|
catch {
|
|
423
586
|
console.log(`\n ${YELLOW}⚠ devtunnel CLI not found!${RESET}\n`);
|
|
424
|
-
|
|
587
|
+
let installCmd = '';
|
|
425
588
|
if (process.platform === 'win32') {
|
|
426
|
-
|
|
589
|
+
installCmd = 'winget install Microsoft.devtunnel';
|
|
427
590
|
}
|
|
428
591
|
else if (process.platform === 'darwin') {
|
|
429
|
-
|
|
592
|
+
installCmd = 'brew install --cask devtunnel';
|
|
430
593
|
}
|
|
431
594
|
else {
|
|
432
|
-
|
|
595
|
+
installCmd = 'curl -sL https://aka.ms/DevTunnelCliInstall | bash';
|
|
596
|
+
}
|
|
597
|
+
const answer = await askUser(` Would you like to install it now? (${GREEN}${installCmd}${RESET}) [Y/n] `);
|
|
598
|
+
if (answer === '' || answer === 'y' || answer === 'yes') {
|
|
599
|
+
console.log(`\n ${DIM}Installing devtunnel...${RESET}\n`);
|
|
600
|
+
try {
|
|
601
|
+
const installParts = installCmd.split(' ');
|
|
602
|
+
const installProc = spawn(installParts[0], installParts.slice(1), { stdio: 'inherit', shell: process.platform !== 'win32' && installCmd.includes('|') });
|
|
603
|
+
await new Promise((resolve, reject) => {
|
|
604
|
+
installProc.on('close', (code) => code === 0 ? resolve() : reject(new Error(`Install exited with code ${code}`)));
|
|
605
|
+
installProc.on('error', reject);
|
|
606
|
+
});
|
|
607
|
+
// Refresh PATH — winget updates the registry but current process has stale PATH
|
|
608
|
+
if (process.platform === 'win32') {
|
|
609
|
+
try {
|
|
610
|
+
const userPath = execFileSync('reg', ['query', 'HKCU\\Environment', '/v', 'Path'], { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] });
|
|
611
|
+
const sysPath = execFileSync('reg', ['query', 'HKLM\\SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment', '/v', 'Path'], { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] });
|
|
612
|
+
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() || '';
|
|
613
|
+
process.env.PATH = `${extractPath(userPath)};${extractPath(sysPath)}`;
|
|
614
|
+
}
|
|
615
|
+
catch { /* keep existing PATH */ }
|
|
616
|
+
}
|
|
617
|
+
// Verify installation
|
|
618
|
+
execFileSync('devtunnel', ['--version'], { stdio: 'pipe' });
|
|
619
|
+
console.log(`\n ${GREEN}✓${RESET} devtunnel installed successfully!\n`);
|
|
620
|
+
devtunnelInstalled = true;
|
|
621
|
+
}
|
|
622
|
+
catch (err) {
|
|
623
|
+
console.log(`\n ${YELLOW}⚠${RESET} Installation failed: ${err.message}`);
|
|
624
|
+
console.log(` ${DIM}You can install it manually: ${installCmd}${RESET}\n`);
|
|
625
|
+
console.log(` ${DIM}Continuing without tunnel (local only)...${RESET}\n`);
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
else {
|
|
629
|
+
console.log(`\n ${DIM}More info: https://aka.ms/devtunnels/doc${RESET}`);
|
|
630
|
+
console.log(` ${DIM}Continuing without tunnel (local only)...${RESET}\n`);
|
|
433
631
|
}
|
|
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
632
|
}
|
|
439
|
-
// Check if logged in
|
|
440
633
|
if (devtunnelInstalled) {
|
|
634
|
+
// Check if logged in before attempting tunnel creation
|
|
441
635
|
try {
|
|
442
636
|
const userInfo = execFileSync('devtunnel', ['user', 'show'], { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] });
|
|
443
|
-
if (userInfo.includes('not logged in') || userInfo.includes('No user')) {
|
|
637
|
+
if (userInfo.includes('not logged in') || userInfo.includes('No user') || userInfo.includes('Anonymous')) {
|
|
444
638
|
throw new Error('not logged in');
|
|
445
639
|
}
|
|
446
640
|
}
|
|
447
641
|
catch {
|
|
448
|
-
console.log(`\n ${YELLOW}⚠ devtunnel not authenticated
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
642
|
+
console.log(`\n ${YELLOW}⚠ devtunnel not authenticated.${RESET}\n`);
|
|
643
|
+
const loginAnswer = await askUser(` Would you like to log in now? [Y/n] `);
|
|
644
|
+
if (loginAnswer === '' || loginAnswer === 'y' || loginAnswer === 'yes') {
|
|
645
|
+
try {
|
|
646
|
+
const loginProc = spawn('devtunnel', ['user', 'login'], { stdio: 'inherit' });
|
|
647
|
+
await new Promise((resolve, reject) => {
|
|
648
|
+
loginProc.on('close', (code) => code === 0 ? resolve() : reject(new Error(`Login exited with code ${code}`)));
|
|
649
|
+
loginProc.on('error', reject);
|
|
650
|
+
});
|
|
651
|
+
console.log(`\n ${GREEN}✓${RESET} Logged in successfully!\n`);
|
|
652
|
+
}
|
|
653
|
+
catch {
|
|
654
|
+
console.log(`\n ${YELLOW}⚠${RESET} Login failed. Run manually: ${GREEN}devtunnel user login${RESET}\n`);
|
|
655
|
+
console.log(` ${DIM}Continuing without tunnel (local only)...${RESET}\n`);
|
|
656
|
+
devtunnelInstalled = false;
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
else {
|
|
660
|
+
console.log(`\n ${DIM}Run this once to log in: ${GREEN}devtunnel user login${RESET}`);
|
|
661
|
+
console.log(` ${DIM}Continuing without tunnel (local only)...${RESET}\n`);
|
|
662
|
+
devtunnelInstalled = false;
|
|
663
|
+
}
|
|
453
664
|
}
|
|
454
665
|
}
|
|
455
666
|
if (devtunnelInstalled) {
|
|
@@ -474,25 +685,48 @@ async function main() {
|
|
|
474
685
|
});
|
|
475
686
|
hostProc.on('error', (e) => { clearTimeout(timeout); reject(e); });
|
|
476
687
|
});
|
|
477
|
-
const tunnelUrlWithToken = `${url}?token=${sessionToken}`;
|
|
688
|
+
const tunnelUrlWithToken = `${url}?token=${sessionToken}${hubMode ? '&hub=1' : ''}`;
|
|
478
689
|
console.log(` ${GREEN}✓${RESET} Tunnel: ${BOLD}${tunnelUrlWithToken}${RESET}\n`);
|
|
690
|
+
// Write session file for hub discovery
|
|
691
|
+
writeSessionFile(tunnelId, url, actualPort);
|
|
479
692
|
try {
|
|
480
693
|
// @ts-ignore
|
|
481
694
|
const qr = (await import('qrcode-terminal'));
|
|
482
695
|
qr.default.generate(tunnelUrlWithToken, { small: true }, (code) => console.log(code));
|
|
483
696
|
}
|
|
484
697
|
catch { }
|
|
485
|
-
process.on('SIGINT', () => { hostProc.kill(); try {
|
|
698
|
+
process.on('SIGINT', () => { removeSessionFile(); hostProc.kill(); try {
|
|
486
699
|
execFileSync('devtunnel', ['delete', tunnelId, '--force'], { stdio: 'pipe' });
|
|
487
700
|
}
|
|
488
701
|
catch { } });
|
|
489
|
-
process.on('exit', () => { hostProc.kill(); try {
|
|
702
|
+
process.on('exit', () => { removeSessionFile(); hostProc.kill(); try {
|
|
490
703
|
execFileSync('devtunnel', ['delete', tunnelId, '--force'], { stdio: 'pipe' });
|
|
491
704
|
}
|
|
492
705
|
catch { } });
|
|
493
706
|
}
|
|
494
707
|
catch (err) {
|
|
495
|
-
|
|
708
|
+
const errMsg = err.message || '';
|
|
709
|
+
// Detect auth failure at create time (expired token, anonymous, etc.)
|
|
710
|
+
if (errMsg.includes('Anonymous') || errMsg.includes('Unauthorized') || errMsg.includes('not permitted')) {
|
|
711
|
+
console.log(`\n ${YELLOW}⚠ devtunnel session expired or not authenticated.${RESET}\n`);
|
|
712
|
+
const loginAnswer = await askUser(` Would you like to log in now? [Y/n] `);
|
|
713
|
+
if (loginAnswer === '' || loginAnswer === 'y' || loginAnswer === 'yes') {
|
|
714
|
+
try {
|
|
715
|
+
const loginProc = spawn('devtunnel', ['user', 'login'], { stdio: 'inherit' });
|
|
716
|
+
await new Promise((resolve, reject) => {
|
|
717
|
+
loginProc.on('close', (code) => code === 0 ? resolve() : reject(new Error(`Login exited with code ${code}`)));
|
|
718
|
+
loginProc.on('error', reject);
|
|
719
|
+
});
|
|
720
|
+
console.log(`\n ${GREEN}✓${RESET} Logged in! Please run cli-tunnel again to create the tunnel.\n`);
|
|
721
|
+
}
|
|
722
|
+
catch {
|
|
723
|
+
console.log(`\n ${YELLOW}⚠${RESET} Login failed. Run manually: ${GREEN}devtunnel user login${RESET}\n`);
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
else {
|
|
728
|
+
console.log(` ${YELLOW}⚠${RESET} Tunnel failed: ${errMsg}\n`);
|
|
729
|
+
}
|
|
496
730
|
}
|
|
497
731
|
} // end if (devtunnelInstalled)
|
|
498
732
|
}
|
|
@@ -527,27 +761,16 @@ async function main() {
|
|
|
527
761
|
}
|
|
528
762
|
catch { /* use as-is */ }
|
|
529
763
|
}
|
|
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
|
-
]);
|
|
764
|
+
// F-07: Security — filter dangerous environment variables for PTY
|
|
765
|
+
// Blocklist approach: pass everything except known dangerous vars and secrets
|
|
766
|
+
const DANGEROUS_VARS = new Set(['NODE_OPTIONS', 'NODE_REPL_HISTORY', 'NODE_EXTRA_CA_CERTS',
|
|
767
|
+
'NODE_PATH', 'NODE_REDIRECT_WARNINGS', 'NODE_PENDING_DEPRECATION',
|
|
768
|
+
'UV_THREADPOOL_SIZE', 'LD_PRELOAD', 'DYLD_INSERT_LIBRARIES',
|
|
769
|
+
'SSH_AUTH_SOCK', 'GPG_TTY']);
|
|
770
|
+
const sensitivePattern = /token|secret|key|password|credential|api_key|private_key|access_key|connection_string|auth|kubeconfig|docker_host|docker_config/i;
|
|
548
771
|
const safeEnv = {};
|
|
549
772
|
for (const [k, v] of Object.entries(process.env)) {
|
|
550
|
-
if (
|
|
773
|
+
if (v !== undefined && !DANGEROUS_VARS.has(k) && !sensitivePattern.test(k)) {
|
|
551
774
|
safeEnv[k] = v;
|
|
552
775
|
}
|
|
553
776
|
}
|
|
@@ -556,6 +779,25 @@ async function main() {
|
|
|
556
779
|
cols, rows, cwd,
|
|
557
780
|
env: safeEnv,
|
|
558
781
|
});
|
|
782
|
+
// Detect CSPRNG crash (rare Node.js + PTY issue) and show helpful message
|
|
783
|
+
let ptyExitedEarly = false;
|
|
784
|
+
const earlyExitCheck = new Promise((resolve) => {
|
|
785
|
+
ptyProcess.onExit(({ exitCode }) => {
|
|
786
|
+
if (exitCode === 134 || exitCode === 3221226505) {
|
|
787
|
+
ptyExitedEarly = true;
|
|
788
|
+
resolve();
|
|
789
|
+
}
|
|
790
|
+
});
|
|
791
|
+
setTimeout(resolve, 2000);
|
|
792
|
+
});
|
|
793
|
+
await earlyExitCheck;
|
|
794
|
+
if (ptyExitedEarly) {
|
|
795
|
+
const nodeVer = process.version;
|
|
796
|
+
console.log(` ${YELLOW}⚠${RESET} The command crashed (CSPRNG assertion failure).`);
|
|
797
|
+
console.log(` This is a known issue with Node.js ${nodeVer} + PTY on Windows.`);
|
|
798
|
+
console.log(` ${BOLD}Fix:${RESET} Install Node.js 22 LTS: ${GREEN}nvm install 22${RESET} or ${GREEN}winget install OpenJS.NodeJS.LTS${RESET}\n`);
|
|
799
|
+
process.exit(1);
|
|
800
|
+
}
|
|
559
801
|
ptyProcess.onData((data) => {
|
|
560
802
|
process.stdout.write(data);
|
|
561
803
|
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.11",
|
|
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;
|
|
@@ -115,7 +117,9 @@
|
|
|
115
117
|
|
|
116
118
|
async function loadSessions() {
|
|
117
119
|
try {
|
|
118
|
-
const
|
|
120
|
+
const tokenParam = new URLSearchParams(window.location.search).get('token');
|
|
121
|
+
const headers = tokenParam ? { 'Authorization': 'Bearer ' + tokenParam } : {};
|
|
122
|
+
const resp = await fetch('/api/sessions', { headers });
|
|
119
123
|
const data = await resp.json();
|
|
120
124
|
renderDashboard(data.sessions || []);
|
|
121
125
|
} catch (err) {
|
|
@@ -127,41 +131,52 @@
|
|
|
127
131
|
const filtered = showOffline ? sessions : sessions.filter(s => s.online);
|
|
128
132
|
const offlineCount = sessions.filter(s => !s.online).length;
|
|
129
133
|
const onlineCount = sessions.filter(s => s.online).length;
|
|
134
|
+
const connectable = filtered.filter(s => s.online && s.token);
|
|
130
135
|
|
|
131
136
|
let html = `<div style="padding:8px 4px;display:flex;align-items:center;gap:8px">
|
|
132
137
|
<span style="color:var(--text-dim);font-size:12px">${onlineCount} online${offlineCount > 0 ? ', ' + offlineCount + ' offline' : ''}</span>
|
|
133
138
|
<span style="flex:1"></span>
|
|
134
|
-
<button
|
|
135
|
-
|
|
136
|
-
<button
|
|
139
|
+
${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>' : ''}
|
|
140
|
+
<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>
|
|
141
|
+
${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>' : ''}
|
|
142
|
+
<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
143
|
</div>`;
|
|
138
144
|
|
|
139
145
|
if (filtered.length === 0) {
|
|
140
146
|
html += '<div style="padding:20px 12px;color:var(--text-dim);text-align:center">' +
|
|
141
|
-
(sessions.length === 0 ? 'No
|
|
147
|
+
(sessions.length === 0 ? 'No cli-tunnel sessions found.' : 'No online sessions. Tap "Show offline" to see stale ones.') +
|
|
142
148
|
'</div>';
|
|
143
149
|
} else {
|
|
144
|
-
html += filtered.map(s =>
|
|
145
|
-
|
|
150
|
+
html += filtered.map(s => {
|
|
151
|
+
const sessionUrl = s.token ? s.url + '?token=' + encodeURIComponent(s.token) : '';
|
|
152
|
+
return `
|
|
153
|
+
<div class="session-card" ${s.online && sessionUrl ? 'data-session-url="' + escapeHtml(sessionUrl) + '"' : ''}>
|
|
146
154
|
<span class="status-dot ${s.online ? 'online' : 'offline'}"></span>
|
|
147
155
|
<div class="info">
|
|
156
|
+
<div class="session-name">${escapeHtml(s.name)}</div>
|
|
148
157
|
<div class="repo">📦 ${escapeHtml(s.repo)}</div>
|
|
149
158
|
<div class="branch">🌿 ${escapeHtml(s.branch)}</div>
|
|
150
|
-
<div class="machine">💻 ${escapeHtml(s.machine)}</div>
|
|
159
|
+
<div class="machine">💻 ${escapeHtml(s.machine)}${!s.token && s.online ? ' 🔒' : ''}</div>
|
|
151
160
|
</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
|
-
|
|
161
|
+
${s.online && sessionUrl ? '<span class="arrow">→</span>' :
|
|
162
|
+
!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>'
|
|
163
|
+
: '<span style="color:var(--text-dim);font-size:11px">remote</span>'}
|
|
164
|
+
</div>`;
|
|
165
|
+
}).join('');
|
|
156
166
|
}
|
|
157
167
|
dashboard.innerHTML = html;
|
|
158
|
-
|
|
168
|
+
cachedSessions = sessions;
|
|
169
|
+
// Event delegation
|
|
159
170
|
dashboard.querySelectorAll('.session-card[data-session-url]').forEach(function(card) {
|
|
160
171
|
card.addEventListener('click', function() { openSession(card.dataset.sessionUrl); });
|
|
161
172
|
});
|
|
162
173
|
dashboard.querySelectorAll('[data-delete-id]').forEach(function(btn) {
|
|
163
174
|
btn.addEventListener('click', function(e) { e.stopPropagation(); deleteSession(btn.dataset.deleteId); });
|
|
164
175
|
});
|
|
176
|
+
dashboard.querySelector('[data-action="toggle-offline"]')?.addEventListener('click', function() { toggleOffline(); });
|
|
177
|
+
dashboard.querySelector('[data-action="clean-offline"]')?.addEventListener('click', function() { cleanOffline(); });
|
|
178
|
+
dashboard.querySelector('[data-action="refresh"]')?.addEventListener('click', function() { loadSessions(); });
|
|
179
|
+
dashboard.querySelector('[data-action="grid-view"]')?.addEventListener('click', function() { showGridView(sessions); });
|
|
165
180
|
}
|
|
166
181
|
|
|
167
182
|
window.openSession = (url) => {
|
|
@@ -174,21 +189,181 @@
|
|
|
174
189
|
};
|
|
175
190
|
|
|
176
191
|
window.cleanOffline = async () => {
|
|
177
|
-
const
|
|
192
|
+
const tokenParam = new URLSearchParams(window.location.search).get('token');
|
|
193
|
+
const headers = tokenParam ? { 'Authorization': 'Bearer ' + tokenParam } : {};
|
|
194
|
+
const resp = await fetch('/api/sessions', { headers });
|
|
178
195
|
const data = await resp.json();
|
|
179
196
|
const offline = (data.sessions || []).filter(s => !s.online);
|
|
180
197
|
for (const s of offline) {
|
|
181
|
-
await fetch('/api/sessions/' + s.id, { method: 'DELETE' });
|
|
198
|
+
await fetch('/api/sessions/' + s.id, { method: 'DELETE', headers });
|
|
182
199
|
}
|
|
183
200
|
loadSessions();
|
|
184
201
|
};
|
|
185
202
|
|
|
186
203
|
window.deleteSession = async (id) => {
|
|
187
|
-
|
|
204
|
+
const tokenParam = new URLSearchParams(window.location.search).get('token');
|
|
205
|
+
const headers = tokenParam ? { 'Authorization': 'Bearer ' + tokenParam } : {};
|
|
206
|
+
await fetch('/api/sessions/' + id, { method: 'DELETE', headers });
|
|
188
207
|
loadSessions();
|
|
189
208
|
};
|
|
190
209
|
|
|
210
|
+
// ─── Grid View (tmux-style multi-terminal) ────────────────
|
|
211
|
+
function showGridView(sessions) {
|
|
212
|
+
const connectable = sessions.filter(function(s) { return s.online && s.token; });
|
|
213
|
+
if (connectable.length === 0) return;
|
|
214
|
+
|
|
215
|
+
// Clean up previous grid
|
|
216
|
+
destroyGrid();
|
|
217
|
+
|
|
218
|
+
currentView = 'grid';
|
|
219
|
+
dashboard.classList.add('hidden');
|
|
220
|
+
terminal.classList.add('hidden');
|
|
221
|
+
termContainer.classList.add('hidden');
|
|
222
|
+
$('#input-area').classList.add('hidden');
|
|
223
|
+
|
|
224
|
+
var gridEl = document.getElementById('grid-view');
|
|
225
|
+
if (!gridEl) {
|
|
226
|
+
gridEl = document.createElement('div');
|
|
227
|
+
gridEl.id = 'grid-view';
|
|
228
|
+
document.getElementById('app').insertBefore(gridEl, document.getElementById('input-area'));
|
|
229
|
+
}
|
|
230
|
+
gridEl.classList.remove('hidden');
|
|
231
|
+
gridEl.innerHTML = '';
|
|
232
|
+
|
|
233
|
+
// Calculate grid dimensions
|
|
234
|
+
var cols = connectable.length <= 2 ? connectable.length : connectable.length <= 4 ? 2 : 3;
|
|
235
|
+
gridEl.style.gridTemplateColumns = 'repeat(' + cols + ', 1fr)';
|
|
236
|
+
|
|
237
|
+
connectable.forEach(function(s) {
|
|
238
|
+
var panel = document.createElement('div');
|
|
239
|
+
panel.className = 'grid-panel';
|
|
240
|
+
|
|
241
|
+
// Header
|
|
242
|
+
var header = document.createElement('div');
|
|
243
|
+
header.className = 'grid-panel-header';
|
|
244
|
+
header.innerHTML = '<span class="grid-panel-name">' + escapeHtml(s.name) + '</span>' +
|
|
245
|
+
'<span class="grid-panel-machine">' + escapeHtml(s.machine) + '</span>' +
|
|
246
|
+
'<span class="grid-panel-status">●</span>';
|
|
247
|
+
panel.appendChild(header);
|
|
248
|
+
|
|
249
|
+
// Terminal container
|
|
250
|
+
var termDiv = document.createElement('div');
|
|
251
|
+
termDiv.className = 'grid-panel-terminal';
|
|
252
|
+
panel.appendChild(termDiv);
|
|
253
|
+
|
|
254
|
+
gridEl.appendChild(panel);
|
|
255
|
+
|
|
256
|
+
// Create xterm instance for this panel
|
|
257
|
+
var panelXterm = new Terminal({
|
|
258
|
+
theme: {
|
|
259
|
+
background: '#0d1117', foreground: '#c9d1d9', cursor: '#3fb950',
|
|
260
|
+
selectionBackground: '#264f78',
|
|
261
|
+
},
|
|
262
|
+
fontFamily: "'Cascadia Code', 'SF Mono', 'Fira Code', 'Menlo', monospace",
|
|
263
|
+
fontSize: 11,
|
|
264
|
+
scrollback: 1000,
|
|
265
|
+
cursorBlink: true,
|
|
266
|
+
});
|
|
267
|
+
var panelFit = new FitAddon.FitAddon();
|
|
268
|
+
panelXterm.loadAddon(panelFit);
|
|
269
|
+
panelXterm.open(termDiv);
|
|
270
|
+
|
|
271
|
+
// Delay fit to ensure container has size
|
|
272
|
+
setTimeout(function() { panelFit.fit(); }, 100);
|
|
273
|
+
|
|
274
|
+
// Connect WebSocket to this session
|
|
275
|
+
var statusDot = header.querySelector('.grid-panel-status');
|
|
276
|
+
var panelWs = null;
|
|
277
|
+
|
|
278
|
+
(function connectPanel() {
|
|
279
|
+
// Use hub's proxy endpoint to get a ticket for the session
|
|
280
|
+
var tokenParam = new URLSearchParams(window.location.search).get('token');
|
|
281
|
+
var proxyUrl = '/api/proxy/ticket/' + s.port;
|
|
282
|
+
var wsBase = s.isLocal ? 'ws://127.0.0.1:' + s.port : s.url.replace('https://', 'wss://');
|
|
283
|
+
|
|
284
|
+
fetch(proxyUrl, {
|
|
285
|
+
method: 'POST',
|
|
286
|
+
headers: { 'Authorization': 'Bearer ' + tokenParam }
|
|
287
|
+
}).then(function(resp) {
|
|
288
|
+
if (!resp.ok) throw new Error('Auth failed');
|
|
289
|
+
return resp.json();
|
|
290
|
+
}).then(function(data) {
|
|
291
|
+
panelWs = new WebSocket(wsBase + '?ticket=' + encodeURIComponent(data.ticket));
|
|
292
|
+
|
|
293
|
+
panelWs.onopen = function() {
|
|
294
|
+
if (statusDot) { statusDot.style.color = 'var(--green)'; statusDot.title = 'Connected'; }
|
|
295
|
+
panelWs.send(JSON.stringify({ type: 'pty_resize', cols: panelXterm.cols, rows: panelXterm.rows }));
|
|
296
|
+
};
|
|
297
|
+
panelWs.onclose = function() {
|
|
298
|
+
if (statusDot) { statusDot.style.color = 'var(--red)'; statusDot.title = 'Disconnected'; }
|
|
299
|
+
};
|
|
300
|
+
panelWs.onerror = function() {
|
|
301
|
+
if (statusDot) { statusDot.style.color = 'var(--red)'; }
|
|
302
|
+
};
|
|
303
|
+
panelWs.onmessage = function(e) {
|
|
304
|
+
try {
|
|
305
|
+
var msg = JSON.parse(e.data);
|
|
306
|
+
if (msg.type === 'pty') {
|
|
307
|
+
panelXterm.write(msg.data);
|
|
308
|
+
}
|
|
309
|
+
} catch (err) {}
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
panelXterm.onData(function(data) {
|
|
313
|
+
if (panelWs && panelWs.readyState === WebSocket.OPEN) {
|
|
314
|
+
panelWs.send(JSON.stringify({ type: 'pty_input', data: data }));
|
|
315
|
+
}
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
gridTerminals.push({ xterm: panelXterm, fitAddon: panelFit, ws: panelWs, session: s });
|
|
319
|
+
}).catch(function() {
|
|
320
|
+
if (statusDot) { statusDot.style.color = 'var(--red)'; statusDot.title = 'Auth failed'; }
|
|
321
|
+
gridTerminals.push({ xterm: panelXterm, fitAddon: panelFit, ws: null, session: s });
|
|
322
|
+
});
|
|
323
|
+
})();
|
|
324
|
+
|
|
325
|
+
// Click header to go full-screen on this session
|
|
326
|
+
header.addEventListener('click', function() {
|
|
327
|
+
window.location.href = s.url + '?token=' + encodeURIComponent(s.token);
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
gridTerminals.push({ xterm: panelXterm, fitAddon: panelFit, ws: panelWs, session: s });
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
// Handle window resize for grid panels
|
|
334
|
+
window.addEventListener('resize', fitGridPanels);
|
|
335
|
+
|
|
336
|
+
// Add back button
|
|
337
|
+
if ($('#btn-sessions')) { $('#btn-sessions').textContent = 'List'; }
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function fitGridPanels() {
|
|
341
|
+
gridTerminals.forEach(function(gt) {
|
|
342
|
+
if (gt.fitAddon) { try { gt.fitAddon.fit(); } catch(e) {} }
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function destroyGrid() {
|
|
347
|
+
gridTerminals.forEach(function(gt) {
|
|
348
|
+
if (gt.ws) { try { gt.ws.close(); } catch(e) {} }
|
|
349
|
+
if (gt.xterm) { try { gt.xterm.dispose(); } catch(e) {} }
|
|
350
|
+
});
|
|
351
|
+
gridTerminals = [];
|
|
352
|
+
window.removeEventListener('resize', fitGridPanels);
|
|
353
|
+
var gridEl = document.getElementById('grid-view');
|
|
354
|
+
if (gridEl) { gridEl.innerHTML = ''; gridEl.classList.add('hidden'); }
|
|
355
|
+
}
|
|
356
|
+
|
|
191
357
|
window.toggleView = () => {
|
|
358
|
+
if (currentView === 'grid') {
|
|
359
|
+
// Grid → dashboard (list view)
|
|
360
|
+
destroyGrid();
|
|
361
|
+
currentView = 'dashboard';
|
|
362
|
+
dashboard.classList.remove('hidden');
|
|
363
|
+
$('#btn-sessions').textContent = 'Terminal';
|
|
364
|
+
loadSessions();
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
192
367
|
if (currentView === 'terminal') {
|
|
193
368
|
currentView = 'dashboard';
|
|
194
369
|
terminal.classList.add('hidden');
|
|
@@ -198,6 +373,7 @@
|
|
|
198
373
|
$('#btn-sessions').textContent = 'Terminal';
|
|
199
374
|
loadSessions();
|
|
200
375
|
} else {
|
|
376
|
+
destroyGrid();
|
|
201
377
|
currentView = 'terminal';
|
|
202
378
|
dashboard.classList.add('hidden');
|
|
203
379
|
$('#input-area').classList.remove('hidden');
|
|
@@ -367,7 +543,7 @@
|
|
|
367
543
|
}
|
|
368
544
|
|
|
369
545
|
// ─── Detect hub mode (no token in URL) ────────────────────
|
|
370
|
-
const isHubMode =
|
|
546
|
+
const isHubMode = new URLSearchParams(window.location.search).get('hub') === '1';
|
|
371
547
|
|
|
372
548
|
// ─── WebSocket ───────────────────────────────────────────
|
|
373
549
|
let reconnectAttempt = 0;
|
|
@@ -392,7 +568,7 @@
|
|
|
392
568
|
|
|
393
569
|
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
394
570
|
|
|
395
|
-
// F-02:
|
|
571
|
+
// F-02: Ticket-based auth (required)
|
|
396
572
|
try {
|
|
397
573
|
const resp = await fetch('/api/auth/ticket', {
|
|
398
574
|
method: 'POST',
|
|
@@ -402,12 +578,12 @@
|
|
|
402
578
|
const { ticket } = await resp.json();
|
|
403
579
|
ws = new WebSocket(`${proto}//${location.host}?ticket=${encodeURIComponent(ticket)}`);
|
|
404
580
|
} else {
|
|
405
|
-
|
|
406
|
-
|
|
581
|
+
setStatus('offline', 'Auth failed');
|
|
582
|
+
return;
|
|
407
583
|
}
|
|
408
584
|
} catch {
|
|
409
|
-
|
|
410
|
-
|
|
585
|
+
setStatus('offline', 'Auth failed');
|
|
586
|
+
return;
|
|
411
587
|
}
|
|
412
588
|
setStatus('connecting', 'Connecting...');
|
|
413
589
|
|
|
@@ -562,6 +738,25 @@
|
|
|
562
738
|
};
|
|
563
739
|
|
|
564
740
|
// ─── Mobile Key Bar ───────────────────────────────────────
|
|
741
|
+
// F-5: Event delegation for key-bar buttons (no inline onclick)
|
|
742
|
+
const keyBar = document.getElementById('key-bar');
|
|
743
|
+
if (keyBar) {
|
|
744
|
+
var keyMap = {
|
|
745
|
+
'\\x1b[A': '\x1b[A', '\\x1b[B': '\x1b[B', '\\x1b[C': '\x1b[C', '\\x1b[D': '\x1b[D',
|
|
746
|
+
'\\t': '\t', '\\r': '\r', '\\x1b': '\x1b', '\\x03': '\x03', ' ': ' ', '\\x7f': '\x7f',
|
|
747
|
+
};
|
|
748
|
+
keyBar.addEventListener('click', function(e) {
|
|
749
|
+
var btn = e.target;
|
|
750
|
+
if (btn && btn.tagName === 'BUTTON' && btn.dataset.key) {
|
|
751
|
+
var key = keyMap[btn.dataset.key] || btn.dataset.key;
|
|
752
|
+
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
753
|
+
ws.send(JSON.stringify({ type: 'pty_input', data: key }));
|
|
754
|
+
}
|
|
755
|
+
if (xterm) xterm.focus();
|
|
756
|
+
}
|
|
757
|
+
});
|
|
758
|
+
}
|
|
759
|
+
|
|
565
760
|
window.sendKey = (key) => {
|
|
566
761
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
567
762
|
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; }
|