cli-tunnel 1.1.0 → 1.2.0-beta.2
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 +326 -82
- package/package.json +2 -2
- package/remote-ui/app.js +74 -9
- package/remote-ui/styles.css +3 -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';
|
|
@@ -29,40 +36,45 @@ const GREEN = '\x1b[32m';
|
|
|
29
36
|
const YELLOW = '\x1b[33m';
|
|
30
37
|
// ─── Parse args ─────────────────────────────────────────────
|
|
31
38
|
const args = process.argv.slice(2);
|
|
32
|
-
if (args.includes('--help') || args.includes('-h')
|
|
39
|
+
if (args.includes('--help') || args.includes('-h')) {
|
|
33
40
|
console.log(`
|
|
34
41
|
${BOLD}cli-tunnel${RESET} — Tunnel any CLI app to your phone
|
|
35
42
|
|
|
36
43
|
${BOLD}Usage:${RESET}
|
|
37
44
|
cli-tunnel [options] <command> [args...]
|
|
45
|
+
cli-tunnel # hub mode — sessions dashboard only
|
|
38
46
|
|
|
39
47
|
${BOLD}Options:${RESET}
|
|
40
|
-
--
|
|
48
|
+
--local Disable devtunnel (localhost only)
|
|
41
49
|
--port <n> Bridge port (default: random)
|
|
42
50
|
--name <name> Session name (shown in dashboard)
|
|
51
|
+
--replay Enable replay buffer (off by default)
|
|
43
52
|
--help, -h Show this help
|
|
44
53
|
|
|
45
54
|
${BOLD}Examples:${RESET}
|
|
46
|
-
cli-tunnel copilot
|
|
47
|
-
cli-tunnel --
|
|
48
|
-
cli-tunnel
|
|
49
|
-
cli-tunnel
|
|
50
|
-
cli-tunnel --
|
|
51
|
-
cli-tunnel --
|
|
55
|
+
cli-tunnel copilot --yolo # tunnel + run copilot
|
|
56
|
+
cli-tunnel copilot --model claude-sonnet-4 --agent squad
|
|
57
|
+
cli-tunnel k9s # tunnel + run k9s
|
|
58
|
+
cli-tunnel python -i # tunnel + run python
|
|
59
|
+
cli-tunnel --name wizard copilot # named session
|
|
60
|
+
cli-tunnel --local copilot --yolo # localhost only, no devtunnel
|
|
61
|
+
cli-tunnel # hub: see all active sessions
|
|
52
62
|
|
|
53
|
-
|
|
54
|
-
app. cli-tunnel's own flags
|
|
55
|
-
before the command.
|
|
63
|
+
Devtunnel is enabled by default. All flags after the command name
|
|
64
|
+
pass through to the underlying app. cli-tunnel's own flags
|
|
65
|
+
(--local, --port, --name) must come before the command.
|
|
56
66
|
`);
|
|
57
67
|
process.exit(0);
|
|
58
68
|
}
|
|
59
|
-
const
|
|
69
|
+
const hasLocal = args.includes('--local');
|
|
70
|
+
const hasTunnel = !hasLocal;
|
|
71
|
+
const hasReplay = args.includes('--replay');
|
|
60
72
|
const portIdx = args.indexOf('--port');
|
|
61
73
|
const port = (portIdx !== -1 && args[portIdx + 1]) ? parseInt(args[portIdx + 1], 10) : 0;
|
|
62
74
|
const nameIdx = args.indexOf('--name');
|
|
63
75
|
const sessionName = (nameIdx !== -1 && args[nameIdx + 1]) ? args[nameIdx + 1] : '';
|
|
64
76
|
// Everything that's not our flags is the command
|
|
65
|
-
const ourFlags = new Set(['--tunnel', '--port', '--name']);
|
|
77
|
+
const ourFlags = new Set(['--local', '--tunnel', '--port', '--name', '--replay']);
|
|
66
78
|
const cmdArgs = [];
|
|
67
79
|
let skip = false;
|
|
68
80
|
for (let i = 0; i < args.length; i++) {
|
|
@@ -70,20 +82,18 @@ for (let i = 0; i < args.length; i++) {
|
|
|
70
82
|
skip = false;
|
|
71
83
|
continue;
|
|
72
84
|
}
|
|
73
|
-
if (
|
|
85
|
+
if (args[i] === '--port' || args[i] === '--name') {
|
|
74
86
|
skip = true;
|
|
75
87
|
continue;
|
|
76
88
|
}
|
|
77
|
-
if (args[i] === '--tunnel')
|
|
89
|
+
if (args[i] === '--local' || args[i] === '--tunnel' || args[i] === '--replay')
|
|
78
90
|
continue;
|
|
79
91
|
cmdArgs.push(args[i]);
|
|
80
92
|
}
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
const command = cmdArgs[0];
|
|
86
|
-
const commandArgs = cmdArgs.slice(1);
|
|
93
|
+
// Hub mode — no command, just show sessions dashboard
|
|
94
|
+
const hubMode = cmdArgs.length === 0;
|
|
95
|
+
const command = hubMode ? '' : cmdArgs[0];
|
|
96
|
+
const commandArgs = hubMode ? [] : cmdArgs.slice(1);
|
|
87
97
|
const cwd = process.cwd();
|
|
88
98
|
// ─── Tunnel helpers ─────────────────────────────────────────
|
|
89
99
|
function sanitizeLabel(l) {
|
|
@@ -103,18 +113,85 @@ function getGitInfo() {
|
|
|
103
113
|
}
|
|
104
114
|
// ─── Security: Session token for WebSocket auth ────────────
|
|
105
115
|
const sessionToken = crypto.randomUUID();
|
|
116
|
+
// ─── F-18: Session TTL (24 hours) ──────────────────────────
|
|
117
|
+
const SESSION_TTL = 24 * 60 * 60 * 1000; // 24 hours
|
|
118
|
+
const sessionCreatedAt = Date.now();
|
|
119
|
+
// ─── F-02: One-time ticket store for WebSocket auth ────────
|
|
120
|
+
const tickets = new Map();
|
|
121
|
+
// #30: Ticket GC — clean expired tickets every 30s
|
|
122
|
+
setInterval(() => {
|
|
123
|
+
const now = Date.now();
|
|
124
|
+
for (const [id, t] of tickets) {
|
|
125
|
+
if (t.expires < now)
|
|
126
|
+
tickets.delete(id);
|
|
127
|
+
}
|
|
128
|
+
}, 30000);
|
|
106
129
|
// ─── Security: Redact secrets from replay events ────────────
|
|
107
130
|
function redactSecrets(text) {
|
|
108
|
-
return text
|
|
131
|
+
return text
|
|
132
|
+
// Generic patterns: key=value, key: value, key="value"
|
|
133
|
+
.replace(/(?:token|secret|key|password|credential|authorization|api_key|private_key|access_key|connection_string|db_pass|signing)[\s:="']+\S{8,}/gi, '[REDACTED]')
|
|
134
|
+
// OpenAI keys
|
|
135
|
+
.replace(/sk-[a-zA-Z0-9]{20,}/g, '[REDACTED]')
|
|
136
|
+
// GitHub tokens
|
|
137
|
+
.replace(/gh[ps]_[a-zA-Z0-9]{36,}/g, '[REDACTED]')
|
|
138
|
+
// AWS keys
|
|
139
|
+
.replace(/AKIA[A-Z0-9]{16}/g, '[REDACTED]')
|
|
140
|
+
// Azure connection strings
|
|
141
|
+
.replace(/DefaultEndpointsProtocol=[^;\s]{20,}/gi, '[REDACTED]')
|
|
142
|
+
.replace(/AccountKey=[^;\s]{20,}/gi, 'AccountKey=[REDACTED]')
|
|
143
|
+
// Database URLs
|
|
144
|
+
.replace(/(postgres|mongodb|mysql|redis):\/\/[^\s"']{10,}/gi, '[REDACTED]')
|
|
145
|
+
// Bearer tokens in headers
|
|
146
|
+
.replace(/Bearer\s+[a-zA-Z0-9._-]{20,}/gi, 'Bearer [REDACTED]');
|
|
109
147
|
}
|
|
110
148
|
// ─── Bridge server ──────────────────────────────────────────
|
|
111
149
|
const acpEventLog = [];
|
|
112
150
|
const connections = new Map();
|
|
151
|
+
// #10: Session TTL enforcement — periodically close expired connections
|
|
152
|
+
setInterval(() => {
|
|
153
|
+
if (Date.now() - sessionCreatedAt > SESSION_TTL) {
|
|
154
|
+
for (const [id, ws] of connections) {
|
|
155
|
+
ws.close(1000, 'Session expired');
|
|
156
|
+
connections.delete(id);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}, 60000);
|
|
113
160
|
const server = http.createServer((req, res) => {
|
|
161
|
+
// F-18: Session expiry check for API routes
|
|
162
|
+
if (!hubMode && req.url?.startsWith('/api/') && Date.now() - sessionCreatedAt > SESSION_TTL) {
|
|
163
|
+
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
164
|
+
res.end(JSON.stringify({ error: 'Session expired' }));
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
// F-02: Ticket endpoint — exchange session token for one-time WS ticket
|
|
168
|
+
if (req.url === '/api/auth/ticket' && req.method === 'POST') {
|
|
169
|
+
const auth = req.headers.authorization?.replace('Bearer ', '');
|
|
170
|
+
if (auth !== sessionToken) {
|
|
171
|
+
res.writeHead(401);
|
|
172
|
+
res.end();
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
const ticket = crypto.randomUUID();
|
|
176
|
+
tickets.set(ticket, { expires: Date.now() + 60000 });
|
|
177
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
178
|
+
res.end(JSON.stringify({ ticket, expires: Date.now() + 60000 }));
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
// F-01: Session token check for all API routes (skip in hub mode)
|
|
182
|
+
if (!hubMode && req.url?.startsWith('/api/')) {
|
|
183
|
+
const reqUrl = new URL(req.url, `http://${req.headers.host}`);
|
|
184
|
+
const authToken = req.headers.authorization?.replace('Bearer ', '') || reqUrl.searchParams.get('token');
|
|
185
|
+
if (authToken !== sessionToken) {
|
|
186
|
+
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
187
|
+
res.end(JSON.stringify({ error: 'Unauthorized' }));
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
114
191
|
// Sessions API
|
|
115
192
|
if (req.url === '/api/sessions' && req.method === 'GET') {
|
|
116
193
|
try {
|
|
117
|
-
const output =
|
|
194
|
+
const output = execFileSync('devtunnel', ['list', '--labels', 'cli-tunnel', '--json'], { encoding: 'utf-8', timeout: 10000, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
118
195
|
const data = JSON.parse(output);
|
|
119
196
|
const sessions = (data.tunnels || []).map((t) => {
|
|
120
197
|
const labels = t.labels || [];
|
|
@@ -163,7 +240,16 @@ const server = http.createServer((req, res) => {
|
|
|
163
240
|
}
|
|
164
241
|
// Static files
|
|
165
242
|
const uiDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../remote-ui');
|
|
166
|
-
|
|
243
|
+
// #18: Guard against malformed URI encoding
|
|
244
|
+
let decodedUrl;
|
|
245
|
+
try {
|
|
246
|
+
decodedUrl = decodeURIComponent(req.url || '/');
|
|
247
|
+
}
|
|
248
|
+
catch {
|
|
249
|
+
res.writeHead(400);
|
|
250
|
+
res.end();
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
167
253
|
if (decodedUrl.includes('..')) {
|
|
168
254
|
res.writeHead(400);
|
|
169
255
|
res.end();
|
|
@@ -175,65 +261,132 @@ const server = http.createServer((req, res) => {
|
|
|
175
261
|
res.end();
|
|
176
262
|
return;
|
|
177
263
|
}
|
|
178
|
-
if
|
|
179
|
-
|
|
264
|
+
// #2: EISDIR guard — check if path is a directory before createReadStream
|
|
265
|
+
try {
|
|
266
|
+
const stat = fs.statSync(filePath);
|
|
267
|
+
if (stat.isDirectory()) {
|
|
268
|
+
filePath = path.join(filePath, 'index.html');
|
|
269
|
+
if (!fs.existsSync(filePath)) {
|
|
270
|
+
res.writeHead(404);
|
|
271
|
+
res.end();
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
catch {
|
|
277
|
+
res.writeHead(404);
|
|
278
|
+
res.end();
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
180
281
|
const ext = path.extname(filePath);
|
|
181
282
|
const mimes = { '.html': 'text/html', '.js': 'application/javascript', '.css': 'text/css', '.json': 'application/json' };
|
|
182
283
|
const securityHeaders = {
|
|
183
284
|
'Content-Type': mimes[ext] || 'application/octet-stream',
|
|
184
285
|
'X-Frame-Options': 'DENY',
|
|
185
286
|
'X-Content-Type-Options': 'nosniff',
|
|
186
|
-
'Content-Security-Policy': "default-src 'self'; script-src 'self' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; connect-src 'self' ws
|
|
287
|
+
'Content-Security-Policy': "default-src 'self'; script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; connect-src 'self' ws://localhost:* wss://*.devtunnels.ms;",
|
|
288
|
+
'Referrer-Policy': 'no-referrer',
|
|
289
|
+
'Cache-Control': 'no-store',
|
|
187
290
|
};
|
|
188
291
|
res.writeHead(200, securityHeaders);
|
|
189
|
-
|
|
292
|
+
// #8: Handle createReadStream errors
|
|
293
|
+
const stream = fs.createReadStream(filePath);
|
|
294
|
+
stream.on('error', () => { if (!res.headersSent) {
|
|
295
|
+
res.writeHead(500);
|
|
296
|
+
} res.end(); });
|
|
297
|
+
stream.pipe(res);
|
|
190
298
|
});
|
|
191
299
|
const wss = new WebSocketServer({
|
|
192
300
|
server,
|
|
193
301
|
maxPayload: 1048576,
|
|
194
302
|
verifyClient: (info) => {
|
|
303
|
+
if (hubMode)
|
|
304
|
+
return true; // Hub mode doesn't need WS auth
|
|
305
|
+
// F-18: Session expiry
|
|
306
|
+
if (Date.now() - sessionCreatedAt > SESSION_TTL)
|
|
307
|
+
return false;
|
|
195
308
|
const url = new URL(info.req.url, `http://${info.req.headers.host}`);
|
|
196
|
-
|
|
309
|
+
// F-02: Accept one-time ticket
|
|
310
|
+
const ticket = url.searchParams.get('ticket');
|
|
311
|
+
if (ticket && tickets.has(ticket)) {
|
|
312
|
+
const t = tickets.get(ticket);
|
|
313
|
+
tickets.delete(ticket); // Single use
|
|
314
|
+
return t.expires > Date.now();
|
|
315
|
+
}
|
|
316
|
+
// Backward compat: accept token
|
|
317
|
+
if (url.searchParams.get('token') !== sessionToken)
|
|
318
|
+
return false;
|
|
319
|
+
// Validate origin if present
|
|
320
|
+
// #28: Proper origin validation using URL parsing
|
|
321
|
+
const origin = info.req.headers.origin;
|
|
322
|
+
if (origin) {
|
|
323
|
+
try {
|
|
324
|
+
const originUrl = new URL(origin);
|
|
325
|
+
const host = originUrl.hostname;
|
|
326
|
+
if (host !== 'localhost' && host !== '127.0.0.1' && !host.endsWith('.devtunnels.ms')) {
|
|
327
|
+
return false;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
catch {
|
|
331
|
+
return false;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
return true;
|
|
197
335
|
},
|
|
198
336
|
});
|
|
199
337
|
// ─── Security: Audit log for remote PTY input ──────────────
|
|
200
|
-
const
|
|
338
|
+
const auditDir = path.join(os.homedir(), '.cli-tunnel', 'audit');
|
|
339
|
+
fs.mkdirSync(auditDir, { recursive: true, mode: 0o700 });
|
|
340
|
+
const auditLogPath = path.join(auditDir, `audit-${new Date().toISOString().slice(0, 10)}.jsonl`);
|
|
201
341
|
const auditLog = fs.createWriteStream(auditLogPath, { flags: 'a' });
|
|
342
|
+
auditLog.on('error', (err) => { console.error('Audit log error:', err.message); });
|
|
202
343
|
wss.on('connection', (ws, req) => {
|
|
203
|
-
|
|
344
|
+
// F-10: Connection cap
|
|
345
|
+
if (connections.size >= 5) {
|
|
346
|
+
ws.close(1013, 'Max connections reached');
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
const id = crypto.randomUUID();
|
|
204
350
|
const remoteAddress = req.socket.remoteAddress || 'unknown';
|
|
205
351
|
connections.set(id, ws);
|
|
206
|
-
// Replay history with secrets redacted
|
|
207
|
-
|
|
208
|
-
|
|
352
|
+
// Replay history with secrets redacted (only if replay is enabled)
|
|
353
|
+
if (hasReplay) {
|
|
354
|
+
for (const event of acpEventLog) {
|
|
355
|
+
ws.send(JSON.stringify({ type: '_replay', data: redactSecrets(event) }));
|
|
356
|
+
}
|
|
357
|
+
ws.send(JSON.stringify({ type: '_replay_done' }));
|
|
209
358
|
}
|
|
210
|
-
ws.send(JSON.stringify({ type: '_replay_done' }));
|
|
211
359
|
ws.on('message', (data) => {
|
|
212
360
|
const raw = data.toString();
|
|
213
361
|
try {
|
|
214
362
|
const msg = JSON.parse(raw);
|
|
215
363
|
if (msg.type === 'pty_input' && ptyProcess) {
|
|
216
|
-
auditLog.write(
|
|
364
|
+
auditLog.write(JSON.stringify({ ts: new Date().toISOString(), src: remoteAddress, type: 'pty_input', data: redactSecrets(JSON.stringify(msg.data)) }) + '\n');
|
|
217
365
|
ptyProcess.write(msg.data);
|
|
218
366
|
}
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
const
|
|
222
|
-
|
|
367
|
+
// #7: NaN guard on pty_resize
|
|
368
|
+
if (msg.type === 'pty_resize') {
|
|
369
|
+
const cols = Number(msg.cols);
|
|
370
|
+
const rows = Number(msg.rows);
|
|
371
|
+
if (Number.isFinite(cols) && Number.isFinite(rows) && ptyProcess) {
|
|
372
|
+
ptyProcess.resize(Math.max(1, Math.min(500, cols)), Math.max(1, Math.min(200, rows)));
|
|
373
|
+
}
|
|
223
374
|
}
|
|
224
375
|
}
|
|
225
376
|
catch {
|
|
226
|
-
|
|
227
|
-
|
|
377
|
+
// #3: Log but do NOT write to PTY — only structured pty_input messages allowed
|
|
378
|
+
auditLog.write(JSON.stringify({ ts: new Date().toISOString(), type: 'rejected', reason: 'non-json', length: raw.length }) + '\n');
|
|
228
379
|
}
|
|
229
380
|
});
|
|
230
381
|
ws.on('close', () => connections.delete(id));
|
|
231
382
|
});
|
|
232
383
|
function broadcast(data) {
|
|
233
384
|
const msg = JSON.stringify({ type: 'pty', data });
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
385
|
+
if (hasReplay) {
|
|
386
|
+
acpEventLog.push(msg);
|
|
387
|
+
if (acpEventLog.length > 2000)
|
|
388
|
+
acpEventLog.splice(0, acpEventLog.length - 2000);
|
|
389
|
+
}
|
|
237
390
|
for (const [, ws] of connections) {
|
|
238
391
|
if (ws.readyState === WebSocket.OPEN)
|
|
239
392
|
ws.send(msg);
|
|
@@ -243,7 +396,7 @@ function broadcast(data) {
|
|
|
243
396
|
let ptyProcess = null;
|
|
244
397
|
async function main() {
|
|
245
398
|
const actualPort = await new Promise((resolve, reject) => {
|
|
246
|
-
server.listen(port, () => {
|
|
399
|
+
server.listen(port, '127.0.0.1', () => {
|
|
247
400
|
const addr = server.address();
|
|
248
401
|
resolve(typeof addr === 'object' ? addr.port : port);
|
|
249
402
|
});
|
|
@@ -252,60 +405,106 @@ async function main() {
|
|
|
252
405
|
const { repo, branch } = getGitInfo();
|
|
253
406
|
const machine = os.hostname();
|
|
254
407
|
const displayName = sessionName || command;
|
|
255
|
-
console.log(`\n${BOLD}cli-tunnel${RESET} ${DIM}v1.
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
408
|
+
console.log(`\n${BOLD}cli-tunnel${RESET} ${DIM}v1.1.0${RESET}\n`);
|
|
409
|
+
if (hubMode) {
|
|
410
|
+
console.log(` ${BOLD}📋 Hub Mode${RESET} — sessions dashboard`);
|
|
411
|
+
console.log(` ${DIM}Port:${RESET} ${actualPort}\n`);
|
|
412
|
+
}
|
|
413
|
+
else {
|
|
414
|
+
console.log(` ${DIM}Command:${RESET} ${command} ${commandArgs.join(' ')}`);
|
|
415
|
+
console.log(` ${DIM}Name:${RESET} ${displayName}`);
|
|
416
|
+
console.log(` ${DIM}Port:${RESET} ${actualPort}`);
|
|
417
|
+
console.log(` ${DIM}Audit log:${RESET} ${auditLogPath}`);
|
|
418
|
+
console.log(` ${DIM}Local URL:${RESET} http://127.0.0.1:${actualPort}?token=${sessionToken}`);
|
|
419
|
+
console.log(` ${DIM}Session expires:${RESET} ${new Date(sessionCreatedAt + SESSION_TTL).toLocaleTimeString()}`);
|
|
420
|
+
}
|
|
260
421
|
// Tunnel
|
|
261
422
|
if (hasTunnel) {
|
|
262
423
|
// Check if devtunnel is installed
|
|
263
424
|
let devtunnelInstalled = false;
|
|
264
425
|
try {
|
|
265
|
-
|
|
426
|
+
execFileSync('devtunnel', ['--version'], { stdio: 'pipe' });
|
|
266
427
|
devtunnelInstalled = true;
|
|
267
428
|
}
|
|
268
429
|
catch {
|
|
269
430
|
console.log(`\n ${YELLOW}⚠ devtunnel CLI not found!${RESET}\n`);
|
|
270
|
-
|
|
431
|
+
let installCmd = '';
|
|
271
432
|
if (process.platform === 'win32') {
|
|
272
|
-
|
|
433
|
+
installCmd = 'winget install Microsoft.devtunnel';
|
|
273
434
|
}
|
|
274
435
|
else if (process.platform === 'darwin') {
|
|
275
|
-
|
|
436
|
+
installCmd = 'brew install --cask devtunnel';
|
|
276
437
|
}
|
|
277
438
|
else {
|
|
278
|
-
|
|
439
|
+
installCmd = 'curl -sL https://aka.ms/DevTunnelCliInstall | bash';
|
|
279
440
|
}
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
441
|
+
const answer = await askUser(` Would you like to install it now? (${GREEN}${installCmd}${RESET}) [Y/n] `);
|
|
442
|
+
if (answer === '' || answer === 'y' || answer === 'yes') {
|
|
443
|
+
console.log(`\n ${DIM}Installing devtunnel...${RESET}\n`);
|
|
444
|
+
try {
|
|
445
|
+
const installParts = installCmd.split(' ');
|
|
446
|
+
const installProc = spawn(installParts[0], installParts.slice(1), { stdio: 'inherit', shell: process.platform !== 'win32' && installCmd.includes('|') });
|
|
447
|
+
await new Promise((resolve, reject) => {
|
|
448
|
+
installProc.on('close', (code) => code === 0 ? resolve() : reject(new Error(`Install exited with code ${code}`)));
|
|
449
|
+
installProc.on('error', reject);
|
|
450
|
+
});
|
|
451
|
+
// Verify installation
|
|
452
|
+
execFileSync('devtunnel', ['--version'], { stdio: 'pipe' });
|
|
453
|
+
console.log(`\n ${GREEN}✓${RESET} devtunnel installed successfully!\n`);
|
|
454
|
+
devtunnelInstalled = true;
|
|
455
|
+
}
|
|
456
|
+
catch (err) {
|
|
457
|
+
console.log(`\n ${YELLOW}⚠${RESET} Installation failed: ${err.message}`);
|
|
458
|
+
console.log(` ${DIM}You can install it manually: ${installCmd}${RESET}\n`);
|
|
459
|
+
console.log(` ${DIM}Continuing without tunnel (local only)...${RESET}\n`);
|
|
291
460
|
}
|
|
292
461
|
}
|
|
293
|
-
|
|
294
|
-
console.log(`\n ${
|
|
295
|
-
console.log(` Run this once to log in:\n`);
|
|
296
|
-
console.log(` ${GREEN}devtunnel user login${RESET}\n`);
|
|
462
|
+
else {
|
|
463
|
+
console.log(`\n ${DIM}More info: https://aka.ms/devtunnels/doc${RESET}`);
|
|
297
464
|
console.log(` ${DIM}Continuing without tunnel (local only)...${RESET}\n`);
|
|
298
|
-
|
|
465
|
+
}
|
|
466
|
+
// If just installed, check login
|
|
467
|
+
if (devtunnelInstalled) {
|
|
468
|
+
try {
|
|
469
|
+
const userInfo = execFileSync('devtunnel', ['user', 'show'], { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] });
|
|
470
|
+
if (userInfo.includes('not logged in') || userInfo.includes('No user')) {
|
|
471
|
+
throw new Error('not logged in');
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
catch {
|
|
475
|
+
console.log(` ${YELLOW}⚠ devtunnel not authenticated.${RESET}\n`);
|
|
476
|
+
const loginAnswer = await askUser(` Would you like to log in now? [Y/n] `);
|
|
477
|
+
if (loginAnswer === '' || loginAnswer === 'y' || loginAnswer === 'yes') {
|
|
478
|
+
try {
|
|
479
|
+
const loginProc = spawn('devtunnel', ['user', 'login'], { stdio: 'inherit' });
|
|
480
|
+
await new Promise((resolve, reject) => {
|
|
481
|
+
loginProc.on('close', (code) => code === 0 ? resolve() : reject(new Error(`Login exited with code ${code}`)));
|
|
482
|
+
loginProc.on('error', reject);
|
|
483
|
+
});
|
|
484
|
+
console.log(`\n ${GREEN}✓${RESET} Logged in successfully!\n`);
|
|
485
|
+
}
|
|
486
|
+
catch {
|
|
487
|
+
console.log(`\n ${YELLOW}⚠${RESET} Login failed. Run manually: ${GREEN}devtunnel user login${RESET}\n`);
|
|
488
|
+
console.log(` ${DIM}Continuing without tunnel (local only)...${RESET}\n`);
|
|
489
|
+
devtunnelInstalled = false;
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
else {
|
|
493
|
+
console.log(`\n ${DIM}Run this once to log in: ${GREEN}devtunnel user login${RESET}`);
|
|
494
|
+
console.log(` ${DIM}Continuing without tunnel (local only)...${RESET}\n`);
|
|
495
|
+
devtunnelInstalled = false;
|
|
496
|
+
}
|
|
497
|
+
}
|
|
299
498
|
}
|
|
300
499
|
}
|
|
301
500
|
if (devtunnelInstalled) {
|
|
302
501
|
try {
|
|
303
|
-
const
|
|
304
|
-
|
|
305
|
-
const createOut =
|
|
502
|
+
const labelValues = ['cli-tunnel', sanitizeLabel(sessionName || command), sanitizeLabel(repo), sanitizeLabel(branch), sanitizeLabel(machine), `port-${actualPort}`];
|
|
503
|
+
const labelArgs = labelValues.flatMap(l => ['--labels', l]);
|
|
504
|
+
const createOut = execFileSync('devtunnel', ['create', ...labelArgs, '--expiration', '1d', '--json'], { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] });
|
|
306
505
|
const tunnelId = JSON.parse(createOut).tunnel?.tunnelId?.split('.')[0];
|
|
307
506
|
const cluster = JSON.parse(createOut).tunnel?.tunnelId?.split('.')[1] || 'euw';
|
|
308
|
-
|
|
507
|
+
execFileSync('devtunnel', ['port', 'create', tunnelId, '-p', String(actualPort), '--protocol', 'http'], { stdio: 'pipe' });
|
|
309
508
|
const hostProc = spawn('devtunnel', ['host', tunnelId], { stdio: 'pipe', detached: false });
|
|
310
509
|
const url = await new Promise((resolve, reject) => {
|
|
311
510
|
const timeout = setTimeout(() => reject(new Error('Tunnel timeout')), 15000);
|
|
@@ -329,11 +528,11 @@ async function main() {
|
|
|
329
528
|
}
|
|
330
529
|
catch { }
|
|
331
530
|
process.on('SIGINT', () => { hostProc.kill(); try {
|
|
332
|
-
|
|
531
|
+
execFileSync('devtunnel', ['delete', tunnelId, '--force'], { stdio: 'pipe' });
|
|
333
532
|
}
|
|
334
533
|
catch { } });
|
|
335
534
|
process.on('exit', () => { hostProc.kill(); try {
|
|
336
|
-
|
|
535
|
+
execFileSync('devtunnel', ['delete', tunnelId, '--force'], { stdio: 'pipe' });
|
|
337
536
|
}
|
|
338
537
|
catch { } });
|
|
339
538
|
}
|
|
@@ -342,6 +541,14 @@ async function main() {
|
|
|
342
541
|
}
|
|
343
542
|
} // end if (devtunnelInstalled)
|
|
344
543
|
}
|
|
544
|
+
if (hubMode) {
|
|
545
|
+
// Hub mode — just serve the sessions dashboard, no PTY
|
|
546
|
+
console.log(` ${GREEN}✓${RESET} Hub running — open in browser to see all sessions\n`);
|
|
547
|
+
console.log(` ${DIM}Press Ctrl+C to stop.${RESET}\n`);
|
|
548
|
+
process.on('SIGINT', () => { server.close(); process.exit(0); });
|
|
549
|
+
// Keep process alive
|
|
550
|
+
await new Promise(() => { });
|
|
551
|
+
}
|
|
345
552
|
console.log(` ${DIM}Starting ${command}...${RESET}\n`);
|
|
346
553
|
// Spawn PTY
|
|
347
554
|
const nodePty = await import('node-pty');
|
|
@@ -351,7 +558,7 @@ async function main() {
|
|
|
351
558
|
let resolvedCmd = command;
|
|
352
559
|
if (process.platform === 'win32') {
|
|
353
560
|
try {
|
|
354
|
-
const wherePaths =
|
|
561
|
+
const wherePaths = execFileSync('where', [command], { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim().split('\n');
|
|
355
562
|
// Prefer .exe or .cmd over .ps1 for node-pty compatibility
|
|
356
563
|
const exePath = wherePaths.find(p => p.trim().endsWith('.exe')) || wherePaths.find(p => p.trim().endsWith('.cmd'));
|
|
357
564
|
if (exePath) {
|
|
@@ -365,11 +572,27 @@ async function main() {
|
|
|
365
572
|
}
|
|
366
573
|
catch { /* use as-is */ }
|
|
367
574
|
}
|
|
368
|
-
//
|
|
575
|
+
// F-07: Security — allowlist safe environment variables for PTY
|
|
576
|
+
const SAFE_ENV_VARS = new Set([
|
|
577
|
+
'PATH', 'HOME', 'USERPROFILE', 'SHELL', 'TERM', 'LANG', 'LC_ALL', 'LC_CTYPE',
|
|
578
|
+
'USER', 'LOGNAME', 'EDITOR', 'VISUAL', 'COLORTERM', 'TERM_PROGRAM',
|
|
579
|
+
'HOSTNAME', 'COMPUTERNAME', 'PWD', 'OLDPWD', 'SHLVL', 'TMPDIR', 'TMP', 'TEMP',
|
|
580
|
+
'XDG_RUNTIME_DIR', 'XDG_DATA_HOME', 'XDG_CONFIG_HOME', 'XDG_CACHE_HOME',
|
|
581
|
+
'DISPLAY', 'WAYLAND_DISPLAY', 'DBUS_SESSION_BUS_ADDRESS',
|
|
582
|
+
'PROGRAMFILES', 'PROGRAMFILES(X86)', 'SYSTEMROOT', 'WINDIR', 'COMSPEC',
|
|
583
|
+
'APPDATA', 'LOCALAPPDATA', 'PROGRAMDATA',
|
|
584
|
+
'NODE_ENV',
|
|
585
|
+
'GOPATH', 'GOROOT', 'CARGO_HOME', 'RUSTUP_HOME',
|
|
586
|
+
'JAVA_HOME', 'MAVEN_HOME', 'GRADLE_HOME',
|
|
587
|
+
'PYTHONPATH', 'VIRTUAL_ENV', 'CONDA_DEFAULT_ENV',
|
|
588
|
+
'KUBECONFIG', 'DOCKER_HOST', 'DOCKER_CONFIG',
|
|
589
|
+
'GIT_AUTHOR_NAME', 'GIT_AUTHOR_EMAIL', 'GIT_COMMITTER_NAME', 'GIT_COMMITTER_EMAIL',
|
|
590
|
+
'HTTP_PROXY', 'HTTPS_PROXY', 'NO_PROXY', 'http_proxy', 'https_proxy', 'no_proxy',
|
|
591
|
+
'SSH_AUTH_SOCK', 'GPG_TTY',
|
|
592
|
+
]);
|
|
369
593
|
const safeEnv = {};
|
|
370
|
-
const sensitivePatterns = /token|secret|key|password|credential|api_key|private/i;
|
|
371
594
|
for (const [k, v] of Object.entries(process.env)) {
|
|
372
|
-
if (
|
|
595
|
+
if (SAFE_ENV_VARS.has(k) && v !== undefined) {
|
|
373
596
|
safeEnv[k] = v;
|
|
374
597
|
}
|
|
375
598
|
}
|
|
@@ -378,6 +601,27 @@ async function main() {
|
|
|
378
601
|
cols, rows, cwd,
|
|
379
602
|
env: safeEnv,
|
|
380
603
|
});
|
|
604
|
+
// Detect CSPRNG crash (Node.js + node-pty + ConPTY issue) and retry with useConpty: false
|
|
605
|
+
let ptyExitedEarly = false;
|
|
606
|
+
const earlyExitCheck = new Promise((resolve) => {
|
|
607
|
+
ptyProcess.onExit(({ exitCode }) => {
|
|
608
|
+
if (exitCode === 134 || exitCode === 3221226505) { // 134 = SIGABRT, 3221226505 = STATUS_BREAKPOINT
|
|
609
|
+
ptyExitedEarly = true;
|
|
610
|
+
resolve();
|
|
611
|
+
}
|
|
612
|
+
});
|
|
613
|
+
setTimeout(resolve, 2000); // Wait 2s — if still running, it's fine
|
|
614
|
+
});
|
|
615
|
+
await earlyExitCheck;
|
|
616
|
+
if (ptyExitedEarly && process.platform === 'win32') {
|
|
617
|
+
console.log(` ${YELLOW}⚠${RESET} ConPTY crash detected, retrying with legacy PTY backend...\n`);
|
|
618
|
+
ptyProcess = nodePty.spawn(resolvedCmd, commandArgs, {
|
|
619
|
+
name: 'xterm-256color',
|
|
620
|
+
cols, rows, cwd,
|
|
621
|
+
env: safeEnv,
|
|
622
|
+
useConpty: false,
|
|
623
|
+
});
|
|
624
|
+
}
|
|
381
625
|
ptyProcess.onData((data) => {
|
|
382
626
|
process.stdout.write(data);
|
|
383
627
|
broadcast(data);
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cli-tunnel",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "Tunnel any CLI app to your phone
|
|
3
|
+
"version": "1.2.0-beta.2",
|
|
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": {
|
package/remote-ui/app.js
CHANGED
|
@@ -5,6 +5,27 @@
|
|
|
5
5
|
(function () {
|
|
6
6
|
'use strict';
|
|
7
7
|
|
|
8
|
+
// ─── Mobile keyboard viewport fix ────────────────────────
|
|
9
|
+
// Keep the key bar visible above the on-screen keyboard
|
|
10
|
+
if (window.visualViewport) {
|
|
11
|
+
window.visualViewport.addEventListener('resize', () => {
|
|
12
|
+
const vv = window.visualViewport;
|
|
13
|
+
const inputArea = document.getElementById('input-area');
|
|
14
|
+
if (inputArea && vv) {
|
|
15
|
+
const offset = window.innerHeight - vv.height - vv.offsetTop;
|
|
16
|
+
inputArea.style.transform = offset > 0 ? `translateY(-${offset}px)` : '';
|
|
17
|
+
}
|
|
18
|
+
});
|
|
19
|
+
window.visualViewport.addEventListener('scroll', () => {
|
|
20
|
+
const vv = window.visualViewport;
|
|
21
|
+
const inputArea = document.getElementById('input-area');
|
|
22
|
+
if (inputArea && vv) {
|
|
23
|
+
const offset = window.innerHeight - vv.height - vv.offsetTop;
|
|
24
|
+
inputArea.style.transform = offset > 0 ? `translateY(-${offset}px)` : '';
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
8
29
|
let ws = null;
|
|
9
30
|
let connected = false;
|
|
10
31
|
let sessionId = null;
|
|
@@ -98,7 +119,7 @@
|
|
|
98
119
|
const data = await resp.json();
|
|
99
120
|
renderDashboard(data.sessions || []);
|
|
100
121
|
} catch (err) {
|
|
101
|
-
dashboard.innerHTML = '<div style="padding:12px;color:var(--red)">Failed to load sessions: ' + err.message + '</div>';
|
|
122
|
+
dashboard.innerHTML = '<div style="padding:12px;color:var(--red)">' + escapeHtml('Failed to load sessions: ' + err.message) + '</div>';
|
|
102
123
|
}
|
|
103
124
|
}
|
|
104
125
|
|
|
@@ -121,7 +142,7 @@
|
|
|
121
142
|
'</div>';
|
|
122
143
|
} else {
|
|
123
144
|
html += filtered.map(s => `
|
|
124
|
-
<div class="session-card" ${s.online ? '
|
|
145
|
+
<div class="session-card" ${s.online ? 'data-session-url="' + escapeHtml(s.url) + '"' : ''}>
|
|
125
146
|
<span class="status-dot ${s.online ? 'online' : 'offline'}"></span>
|
|
126
147
|
<div class="info">
|
|
127
148
|
<div class="repo">📦 ${escapeHtml(s.repo)}</div>
|
|
@@ -129,11 +150,18 @@
|
|
|
129
150
|
<div class="machine">💻 ${escapeHtml(s.machine)}</div>
|
|
130
151
|
</div>
|
|
131
152
|
${s.online ? '<span class="arrow">→</span>' :
|
|
132
|
-
'<button
|
|
153
|
+
'<button data-delete-id="' + escapeHtml(s.id) + '" style="background:none;border:none;color:var(--red);cursor:pointer;font-size:14px" title="Remove">✕</button>'}
|
|
133
154
|
</div>
|
|
134
155
|
`).join('');
|
|
135
156
|
}
|
|
136
157
|
dashboard.innerHTML = html;
|
|
158
|
+
// #16: XSS fix — use event delegation instead of inline onclick
|
|
159
|
+
dashboard.querySelectorAll('.session-card[data-session-url]').forEach(function(card) {
|
|
160
|
+
card.addEventListener('click', function() { openSession(card.dataset.sessionUrl); });
|
|
161
|
+
});
|
|
162
|
+
dashboard.querySelectorAll('[data-delete-id]').forEach(function(btn) {
|
|
163
|
+
btn.addEventListener('click', function(e) { e.stopPropagation(); deleteSession(btn.dataset.deleteId); });
|
|
164
|
+
});
|
|
137
165
|
}
|
|
138
166
|
|
|
139
167
|
window.openSession = (url) => {
|
|
@@ -338,14 +366,49 @@
|
|
|
338
366
|
}
|
|
339
367
|
}
|
|
340
368
|
|
|
369
|
+
// ─── Detect hub mode (no token in URL) ────────────────────
|
|
370
|
+
const isHubMode = !new URLSearchParams(window.location.search).get('token');
|
|
371
|
+
|
|
341
372
|
// ─── WebSocket ───────────────────────────────────────────
|
|
342
373
|
let reconnectAttempt = 0;
|
|
343
374
|
|
|
344
|
-
function connect() {
|
|
345
|
-
|
|
375
|
+
async function connect() {
|
|
376
|
+
if (isHubMode) {
|
|
377
|
+
// Hub mode — hide terminal UI, show sessions only
|
|
378
|
+
setStatus('online', 'Hub');
|
|
379
|
+
terminal.classList.add('hidden');
|
|
380
|
+
termContainer.classList.add('hidden');
|
|
381
|
+
$('#input-area').classList.add('hidden');
|
|
382
|
+
$('#btn-sessions').classList.add('hidden');
|
|
383
|
+
dashboard.classList.remove('hidden');
|
|
384
|
+
loadSessions();
|
|
385
|
+
// Auto-refresh every 10s
|
|
386
|
+
setInterval(loadSessions, 10000);
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
|
|
346
390
|
const tokenParam = new URLSearchParams(window.location.search).get('token');
|
|
347
|
-
|
|
348
|
-
|
|
391
|
+
if (!tokenParam) { setStatus('offline', 'No credentials'); return; }
|
|
392
|
+
|
|
393
|
+
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
394
|
+
|
|
395
|
+
// F-02: Try ticket-based auth first
|
|
396
|
+
try {
|
|
397
|
+
const resp = await fetch('/api/auth/ticket', {
|
|
398
|
+
method: 'POST',
|
|
399
|
+
headers: { 'Authorization': 'Bearer ' + tokenParam }
|
|
400
|
+
});
|
|
401
|
+
if (resp.ok) {
|
|
402
|
+
const { ticket } = await resp.json();
|
|
403
|
+
ws = new WebSocket(`${proto}//${location.host}?ticket=${encodeURIComponent(ticket)}`);
|
|
404
|
+
} else {
|
|
405
|
+
// Fallback to token-in-URL (backward compat)
|
|
406
|
+
ws = new WebSocket(`${proto}//${location.host}?token=${encodeURIComponent(tokenParam)}`);
|
|
407
|
+
}
|
|
408
|
+
} catch {
|
|
409
|
+
// Fallback to token-in-URL
|
|
410
|
+
ws = new WebSocket(`${proto}//${location.host}?token=${encodeURIComponent(tokenParam)}`);
|
|
411
|
+
}
|
|
349
412
|
setStatus('connecting', 'Connecting...');
|
|
350
413
|
|
|
351
414
|
ws.onopen = () => {
|
|
@@ -484,10 +547,12 @@
|
|
|
484
547
|
<h3>${icon} ${escapeHtml(title)}</h3>
|
|
485
548
|
<p>${escapeHtml(shortCmd || JSON.stringify(p).substring(0, 200))}</p>
|
|
486
549
|
<div class="perm-actions">
|
|
487
|
-
<button class="btn-deny"
|
|
488
|
-
<button class="btn-approve"
|
|
550
|
+
<button class="btn-deny">Deny</button>
|
|
551
|
+
<button class="btn-approve">Approve</button>
|
|
489
552
|
</div>
|
|
490
553
|
</div>`;
|
|
554
|
+
permOverlay.querySelector('.btn-deny').addEventListener('click', () => window.handlePerm(msg.id, false));
|
|
555
|
+
permOverlay.querySelector('.btn-approve').addEventListener('click', () => window.handlePerm(msg.id, true));
|
|
491
556
|
}
|
|
492
557
|
window.handlePerm = (id, approved) => {
|
|
493
558
|
if (ws?.readyState === WebSocket.OPEN) {
|