lobstakit-cloud 1.3.13 → 1.3.14

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "lobstakit-cloud",
3
- "version": "1.3.13",
4
- "description": "LobstaKit Cloud Setup wizard and management for LobstaCloud gateways",
3
+ "version": "1.3.14",
4
+ "description": "LobstaKit Cloud \u2014 Setup wizard and management for LobstaCloud gateways",
5
5
  "main": "server.js",
6
6
  "bin": {
7
7
  "lobstakit-cloud": "./bin/lobstakit.js"
@@ -12,6 +12,8 @@
12
12
  "dependencies": {
13
13
  "better-sqlite3": "^12.6.2",
14
14
  "express": "^4.21.0",
15
- "http-proxy-middleware": "^3.0.0"
15
+ "http-proxy-middleware": "^3.0.0",
16
+ "node-pty": "^1.1.0",
17
+ "ws": "^8.19.0"
16
18
  }
17
- }
19
+ }
@@ -96,8 +96,8 @@ const SETTINGS_PROVIDER_FALLBACK = {
96
96
 
97
97
  let settingsProviders = SETTINGS_PROVIDER_FALLBACK;
98
98
  let currentAiSettings = null;
99
- let terminalHistory = [];
100
- let terminalHistoryIndex = -1;
99
+ let xtermInstance = null;
100
+ let terminalWs = null;
101
101
 
102
102
  async function logout() {
103
103
  const token = localStorage.getItem('lobstakit_token');
@@ -255,103 +255,86 @@ function escapeHtml(value) {
255
255
  .replace(/'/g, ''');
256
256
  }
257
257
 
258
- function appendTerminalOutput(html) {
259
- const outputEl = document.getElementById('terminal-output');
260
- if (!outputEl) return;
261
- outputEl.insertAdjacentHTML('beforeend', html);
262
- outputEl.scrollTop = outputEl.scrollHeight;
263
- }
264
-
265
- function setTerminalRunning(isRunning) {
266
- const runBtn = document.getElementById('terminal-run-btn');
267
- if (!runBtn) return;
268
- runBtn.disabled = isRunning;
269
- runBtn.textContent = isRunning ? 'Running...' : 'Run';
270
- }
271
-
272
- function terminalHistoryUp(inputEl) {
273
- if (!terminalHistory.length) return;
274
- if (terminalHistoryIndex <= 0) {
275
- terminalHistoryIndex = 0;
276
- } else {
277
- terminalHistoryIndex -= 1;
278
- }
279
- inputEl.value = terminalHistory[terminalHistoryIndex];
280
- }
258
+ function initTerminal() {
259
+ const container = document.getElementById('terminal-container');
260
+ if (!container || typeof Terminal === 'undefined') return;
261
+
262
+ // Clean up previous instance if any
263
+ if (terminalWs) { try { terminalWs.close(); } catch (e) {} terminalWs = null; }
264
+ if (xtermInstance) { try { xtermInstance.dispose(); } catch (e) {} xtermInstance = null; }
265
+
266
+ const term = new Terminal({
267
+ cursorBlink: true,
268
+ fontSize: 13,
269
+ fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'Menlo', monospace",
270
+ theme: {
271
+ background: '#0d1117',
272
+ foreground: '#e6edf3',
273
+ cursor: '#58a6ff',
274
+ selectionBackground: '#264f78',
275
+ black: '#0d1117',
276
+ red: '#f85149',
277
+ green: '#56d364',
278
+ yellow: '#e3b341',
279
+ blue: '#58a6ff',
280
+ magenta: '#bc8cff',
281
+ cyan: '#39c5cf',
282
+ white: '#e6edf3'
283
+ },
284
+ allowProposedApi: true
285
+ });
281
286
 
282
- function terminalHistoryDown(inputEl) {
283
- if (!terminalHistory.length) return;
284
- if (terminalHistoryIndex >= terminalHistory.length - 1) {
285
- terminalHistoryIndex = terminalHistory.length;
286
- inputEl.value = '';
287
- return;
288
- }
289
- terminalHistoryIndex += 1;
290
- inputEl.value = terminalHistory[terminalHistoryIndex];
291
- }
287
+ const fitAddon = new FitAddon.FitAddon();
288
+ term.loadAddon(fitAddon);
289
+ term.open(container);
290
+ fitAddon.fit();
291
+ xtermInstance = term;
292
292
 
293
- async function runTerminalCommand() {
294
- const inputEl = document.getElementById('terminal-input');
295
- if (!inputEl) return;
293
+ // Connect WebSocket with auth token
294
+ const token = localStorage.getItem('lobstakit_token');
295
+ const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
296
+ const wsUrl = `${proto}//${location.host}/ws/terminal${token ? '?token=' + encodeURIComponent(token) : ''}`;
297
+ const ws = new WebSocket(wsUrl);
298
+ terminalWs = ws;
299
+
300
+ ws.onopen = () => {
301
+ // Send initial resize
302
+ ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }));
303
+ };
296
304
 
297
- const command = inputEl.value.trim();
298
- if (!command) return;
305
+ ws.onmessage = (event) => {
306
+ term.write(event.data);
307
+ };
299
308
 
300
- terminalHistory.push(command);
301
- terminalHistoryIndex = terminalHistory.length;
302
- inputEl.value = '';
303
- setTerminalRunning(true);
309
+ ws.onclose = () => {
310
+ term.write('\r\n\x1b[31m[Connection closed]\x1b[0m\r\n');
311
+ };
304
312
 
305
- try {
306
- const res = await fetch('/api/run-command', authFetchOpts({
307
- method: 'POST',
308
- headers: { 'Content-Type': 'application/json' },
309
- body: JSON.stringify({ command: command })
310
- }));
311
- const data = await res.json();
313
+ ws.onerror = () => {
314
+ term.write('\r\n\x1b[31m[WebSocket error]\x1b[0m\r\n');
315
+ };
312
316
 
313
- if (!res.ok) {
314
- const requestError = escapeHtml(data.error || 'Request failed');
315
- appendTerminalOutput(`$ ${escapeHtml(command)}\n<span style="color:#f85149">${requestError}</span>\n\n`);
316
- return;
317
+ term.onData((data) => {
318
+ if (ws.readyState === WebSocket.OPEN) {
319
+ ws.send(data);
317
320
  }
321
+ });
318
322
 
319
- const stdout = escapeHtml(data.stdout || '');
320
- const stderr = escapeHtml(data.stderr || '');
321
- const hasError = Number(data.exitCode) !== 0;
322
- const stderrBlock = stderr
323
- ? (hasError ? `<span style="color:#f85149">${stderr}</span>` : stderr)
324
- : '';
323
+ term.onResize(({ cols, rows }) => {
324
+ if (ws.readyState === WebSocket.OPEN) {
325
+ ws.send(JSON.stringify({ type: 'resize', cols, rows }));
326
+ }
327
+ });
325
328
 
326
- appendTerminalOutput(`$ ${escapeHtml(command)}\n${stdout}${stderrBlock}\n\n`);
327
- } catch (err) {
328
- appendTerminalOutput(`$ ${escapeHtml(command)}\n<span style="color:#f85149">${escapeHtml(err.message || 'Connection error')}</span>\n\n`);
329
- } finally {
330
- setTerminalRunning(false);
331
- }
332
- }
329
+ // Responsive resize
330
+ const resizeObserver = new ResizeObserver(() => {
331
+ try { fitAddon.fit(); } catch (e) {}
332
+ });
333
+ resizeObserver.observe(container);
333
334
 
334
- function initTerminal() {
335
- const inputEl = document.getElementById('terminal-input');
336
- const runBtn = document.getElementById('terminal-run-btn');
337
- if (!inputEl || !runBtn) return;
338
-
339
- runBtn.addEventListener('click', runTerminalCommand);
340
- inputEl.addEventListener('keydown', (event) => {
341
- if (event.key === 'Enter' && (event.ctrlKey || event.metaKey)) {
342
- event.preventDefault();
343
- runTerminalCommand();
344
- return;
345
- }
346
- if (event.key === 'ArrowUp') {
347
- event.preventDefault();
348
- terminalHistoryUp(inputEl);
349
- return;
350
- }
351
- if (event.key === 'ArrowDown') {
352
- event.preventDefault();
353
- terminalHistoryDown(inputEl);
354
- }
335
+ // Clean up on page unload
336
+ window.addEventListener('beforeunload', () => {
337
+ if (terminalWs) { try { terminalWs.close(); } catch (e) {} }
355
338
  });
356
339
  }
357
340
 
@@ -189,12 +189,11 @@
189
189
 
190
190
  <div class="card" id="terminal-section">
191
191
  <h3>🖥️ Terminal</h3>
192
- <div class="terminal-output" id="terminal-output" style="min-height:120px;max-height:300px;overflow-y:auto;background:#0d1117;color:#e6edf3;font-family:monospace;font-size:13px;padding:12px;border-radius:6px;margin-bottom:10px;white-space:pre-wrap;"></div>
193
- <div style="display:flex;gap:8px;">
194
- <textarea id="terminal-input" placeholder="Enter command... (Ctrl+Enter to run)" rows="2" style="flex:1;font-family:monospace;font-size:13px;color:#e6edf3;background:#0d1117;border:1px solid #30363d;padding:6px 10px;border-radius:6px;outline:none;resize:vertical;"></textarea>
195
- <button id="terminal-run-btn" class="btn btn-secondary">Run</button>
196
- </div>
192
+ <div id="terminal-container" style="height:400px;background:#0d1117;border-radius:6px;overflow:hidden;"></div>
197
193
  </div>
194
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.css">
195
+ <script src="https://cdn.jsdelivr.net/npm/xterm@5.3.0/lib/xterm.min.js"></script>
196
+ <script src="https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/lib/addon-fit.min.js"></script>
198
197
 
199
198
  <!-- Channels Management -->
200
199
  <div class="card" id="channels-card">
package/server.js CHANGED
@@ -16,6 +16,8 @@ const crypto = require('crypto');
16
16
  const config = require('./lib/config');
17
17
  const gateway = require('./lib/gateway');
18
18
  const proxyMiddleware = require('./lib/proxy');
19
+ const pty = require('node-pty');
20
+ const { WebSocketServer } = require('ws');
19
21
 
20
22
  // Timing-safe string comparison to prevent timing attacks on secrets
21
23
  function timingSafeCompare(a, b) {
@@ -2257,8 +2259,92 @@ const server = app.listen(PORT, () => {
2257
2259
  }
2258
2260
  });
2259
2261
 
2260
- // WebSocket upgrade support for proxy
2262
+ // ─── Interactive Terminal (xterm.js + node-pty over WebSocket) ───────────────
2263
+
2264
+ const terminalWss = new WebSocketServer({ noServer: true });
2265
+
2266
+ terminalWss.on('connection', (ws) => {
2267
+ const shell = process.env.SHELL || '/bin/bash';
2268
+ const cwd = process.env.HOME || '/root';
2269
+ const ptyProcess = pty.spawn(shell, [], {
2270
+ name: 'xterm-256color',
2271
+ cols: 80,
2272
+ rows: 24,
2273
+ cwd: cwd,
2274
+ env: Object.assign({}, process.env, { TERM: 'xterm-256color' })
2275
+ });
2276
+
2277
+ ptyProcess.onData((data) => {
2278
+ try { ws.send(data); } catch (e) { /* ws closed */ }
2279
+ });
2280
+
2281
+ ptyProcess.onExit(() => {
2282
+ try { ws.close(); } catch (e) { /* already closed */ }
2283
+ });
2284
+
2285
+ ws.on('message', (msg) => {
2286
+ const data = msg.toString();
2287
+ // Check for resize messages: JSON { type: 'resize', cols, rows }
2288
+ if (data.startsWith('{')) {
2289
+ try {
2290
+ const parsed = JSON.parse(data);
2291
+ if (parsed.type === 'resize' && parsed.cols && parsed.rows) {
2292
+ ptyProcess.resize(Math.max(1, parsed.cols), Math.max(1, parsed.rows));
2293
+ return;
2294
+ }
2295
+ } catch (e) { /* not JSON, treat as input */ }
2296
+ }
2297
+ ptyProcess.write(data);
2298
+ });
2299
+
2300
+ ws.on('close', () => {
2301
+ try { ptyProcess.kill(); } catch (e) { /* already dead */ }
2302
+ });
2303
+
2304
+ ws.on('error', () => {
2305
+ try { ptyProcess.kill(); } catch (e) { /* already dead */ }
2306
+ });
2307
+ });
2308
+
2309
+ // WebSocket upgrade support — terminal + proxy
2261
2310
  server.on('upgrade', (req, socket, head) => {
2311
+ const url = new URL(req.url, `http://${req.headers.host}`);
2312
+
2313
+ if (url.pathname === '/ws/terminal') {
2314
+ // Auth check: require valid session token as query param
2315
+ const token = url.searchParams.get('token');
2316
+ const lobstaConfig = getLobstaKitConfig();
2317
+
2318
+ // If no password set yet, allow with setupToken
2319
+ if (!lobstaConfig.passwordHash) {
2320
+ if (!verifySetupToken(req)) {
2321
+ socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
2322
+ socket.destroy();
2323
+ return;
2324
+ }
2325
+ } else {
2326
+ // Require valid session token
2327
+ if (!token || !activeSessions.has(token)) {
2328
+ socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
2329
+ socket.destroy();
2330
+ return;
2331
+ }
2332
+ const session = activeSessions.get(token);
2333
+ if (Date.now() - session.created > SESSION_MAX_LIFETIME_MS) {
2334
+ activeSessions.delete(token);
2335
+ socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
2336
+ socket.destroy();
2337
+ return;
2338
+ }
2339
+ }
2340
+
2341
+ terminalWss.handleUpgrade(req, socket, head, (ws) => {
2342
+ terminalWss.emit('connection', ws, req);
2343
+ });
2344
+ return;
2345
+ }
2346
+
2347
+ // All other upgrades — proxy if configured
2262
2348
  if (config.isConfigured()) {
2263
2349
  proxyMiddleware.upgrade(req, socket, head);
2264
2350
  } else {