termbeam 1.10.0 → 1.10.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 CHANGED
@@ -109,6 +109,7 @@ flowchart LR
109
109
  | `--port <port>` | Server port | `3456` |
110
110
  | `--host <addr>` | Bind address | `127.0.0.1` |
111
111
  | `--lan` | Bind to all interfaces (LAN access) | Off |
112
+ | `--public` | Allow public tunnel access (no Microsoft login) | Off |
112
113
  | `-i, --interactive` | Interactive setup wizard | Off |
113
114
  | `--log-level <level>` | Log verbosity (error/warn/info/debug) | `info` |
114
115
 
package/bin/termbeam.js CHANGED
@@ -8,7 +8,7 @@ if (subcommand === 'service') {
8
8
  console.error(err.message);
9
9
  process.exit(1);
10
10
  });
11
- } else if (subcommand === 'resume') {
11
+ } else if (subcommand === 'resume' || subcommand === 'attach') {
12
12
  const { resume } = require('../src/resume');
13
13
  resume(process.argv.slice(3)).catch((err) => {
14
14
  console.error(err.message);
@@ -129,7 +129,7 @@ if (subcommand === 'service') {
129
129
  const displayHost = existing.host === '127.0.0.1' ? 'localhost' : existing.host;
130
130
  console.error(
131
131
  `TermBeam is already running on http://${displayHost}:${existing.port}\n` +
132
- 'Use "termbeam resume" to reconnect, "termbeam list" to list sessions,\n' +
132
+ 'Use "termbeam resume" (or "termbeam attach") to reconnect, "termbeam list" to list sessions,\n' +
133
133
  'or "termbeam --force" to stop the existing server and start a new one.',
134
134
  );
135
135
  process.exit(1);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "termbeam",
3
- "version": "1.10.0",
3
+ "version": "1.10.2",
4
4
  "description": "Beam your terminal to any device — mobile-optimized web terminal with multi-session support",
5
5
  "main": "src/server.js",
6
6
  "bin": {
package/public/index.html CHANGED
@@ -991,17 +991,22 @@
991
991
  }
992
992
 
993
993
  async function loadSessions() {
994
- const res = await fetch('/api/sessions');
995
- const sessions = await res.json();
994
+ try {
995
+ const res = await fetch('/api/sessions');
996
+ if (!res.ok) {
997
+ console.error(`Failed to load sessions: ${res.status}`);
998
+ return;
999
+ }
1000
+ const sessions = await res.json();
996
1001
 
997
- if (sessions.length === 0) {
998
- listEl.innerHTML = '<div class="empty-state">No active sessions</div>';
999
- return;
1000
- }
1002
+ if (sessions.length === 0) {
1003
+ listEl.innerHTML = '<div class="empty-state">No active sessions</div>';
1004
+ return;
1005
+ }
1001
1006
 
1002
- listEl.innerHTML = sessions
1003
- .map(
1004
- (s) => `
1007
+ listEl.innerHTML = sessions
1008
+ .map(
1009
+ (s) => `
1005
1010
  <div class="swipe-wrap" data-session-id="${esc(s.id)}">
1006
1011
  <div class="swipe-delete">
1007
1012
  <button data-delete-id="${esc(s.id)}"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg><span>Delete</span></button>
@@ -1031,22 +1036,25 @@
1031
1036
  </div>
1032
1037
  </div>
1033
1038
  `,
1034
- )
1035
- .join('');
1039
+ )
1040
+ .join('');
1036
1041
 
1037
- // Attach swipe handlers and click handlers after rendering
1038
- listEl.querySelectorAll('.swipe-wrap').forEach(initSwipe);
1039
- listEl.querySelectorAll('[data-delete-id]').forEach((btn) => {
1040
- btn.addEventListener('click', (e) => deleteSession(btn.dataset.deleteId, e));
1041
- });
1042
- listEl.querySelectorAll('[data-nav-id]').forEach((card) => {
1043
- card.addEventListener('click', () => {
1044
- location.href = '/terminal?id=' + encodeURIComponent(card.dataset.navId);
1042
+ // Attach swipe handlers and click handlers after rendering
1043
+ listEl.querySelectorAll('.swipe-wrap').forEach(initSwipe);
1044
+ listEl.querySelectorAll('[data-delete-id]').forEach((btn) => {
1045
+ btn.addEventListener('click', (e) => deleteSession(btn.dataset.deleteId, e));
1045
1046
  });
1046
- });
1047
- listEl.querySelectorAll('.dot[data-color]').forEach((dot) => {
1048
- dot.style.background = dot.dataset.color || 'var(--success)';
1049
- });
1047
+ listEl.querySelectorAll('[data-nav-id]').forEach((card) => {
1048
+ card.addEventListener('click', () => {
1049
+ location.href = '/terminal?id=' + encodeURIComponent(card.dataset.navId);
1050
+ });
1051
+ });
1052
+ listEl.querySelectorAll('.dot[data-color]').forEach((dot) => {
1053
+ dot.style.background = dot.dataset.color || 'var(--success)';
1054
+ });
1055
+ } catch (err) {
1056
+ console.error('Failed to load sessions:', err);
1057
+ }
1050
1058
  }
1051
1059
 
1052
1060
  document.getElementById('new-session-btn').addEventListener('click', () => {
@@ -1085,13 +1093,21 @@
1085
1093
  if (initialCommand) body.initialCommand = initialCommand;
1086
1094
  if (color) body.color = color;
1087
1095
 
1088
- const res = await fetch('/api/sessions', {
1089
- method: 'POST',
1090
- headers: { 'Content-Type': 'application/json' },
1091
- body: JSON.stringify(body),
1092
- });
1093
- const data = await res.json();
1094
- location.href = data.url;
1096
+ try {
1097
+ const res = await fetch('/api/sessions', {
1098
+ method: 'POST',
1099
+ headers: { 'Content-Type': 'application/json' },
1100
+ body: JSON.stringify(body),
1101
+ });
1102
+ if (!res.ok) {
1103
+ console.error(`Failed to create session: ${res.status}`);
1104
+ return;
1105
+ }
1106
+ const data = await res.json();
1107
+ location.href = data.url;
1108
+ } catch (err) {
1109
+ console.error('Failed to create session:', err);
1110
+ }
1095
1111
  });
1096
1112
 
1097
1113
  // --- Shell detection ---
@@ -1101,6 +1117,7 @@
1101
1117
  const shellSelect = document.getElementById('sess-shell');
1102
1118
  try {
1103
1119
  const res = await fetch('/api/shells');
1120
+ if (!res.ok) throw new Error(`Failed to load shells: ${res.status}`);
1104
1121
  const data = await res.json();
1105
1122
  if (data.cwd) {
1106
1123
  document.getElementById('sess-cwd').placeholder = data.cwd;
@@ -1225,7 +1242,10 @@
1225
1242
  document.getElementById('browse-btn').addEventListener('click', async () => {
1226
1243
  if (hubServerCwd === '/') {
1227
1244
  try {
1228
- const data = await fetch('/api/shells').then((r) => r.json());
1245
+ const data = await fetch('/api/shells').then((r) => {
1246
+ if (!r.ok) throw new Error(`${r.status}`);
1247
+ return r.json();
1248
+ });
1229
1249
  if (data.cwd) hubServerCwd = data.cwd;
1230
1250
  } catch {}
1231
1251
  }
@@ -1254,6 +1274,7 @@
1254
1274
 
1255
1275
  try {
1256
1276
  const res = await fetch(`/api/dirs?q=${encodeURIComponent(dir + '/')}`);
1277
+ if (!res.ok) throw new Error(`Failed to load directories: ${res.status}`);
1257
1278
  const data = await res.json();
1258
1279
  let items = '';
1259
1280
  // Add parent (..) entry unless at root
@@ -1310,7 +1331,10 @@
1310
1331
 
1311
1332
  // Fetch version
1312
1333
  fetch('/api/version')
1313
- .then((r) => r.json())
1334
+ .then((r) => {
1335
+ if (!r.ok) throw new Error(`${r.status}`);
1336
+ return r.json();
1337
+ })
1314
1338
  .then((d) => {
1315
1339
  document.getElementById('version').textContent = 'v' + d.version;
1316
1340
  })
@@ -2476,7 +2476,14 @@
2476
2476
 
2477
2477
  // ===== Init =====
2478
2478
  async function init() {
2479
- const sessionList = await fetch('/api/sessions').then((r) => r.json());
2479
+ let sessionList = [];
2480
+ try {
2481
+ const res = await fetch('/api/sessions');
2482
+ if (!res.ok) throw new Error(`${res.status}`);
2483
+ sessionList = await res.json();
2484
+ } catch (err) {
2485
+ console.error('Failed to load sessions:', err);
2486
+ }
2480
2487
  const initialId = new URLSearchParams(location.search).get('id');
2481
2488
 
2482
2489
  for (const s of sessionList) addSession(s);
@@ -2678,7 +2685,10 @@
2678
2685
 
2679
2686
  // Version
2680
2687
  fetch('/api/version')
2681
- .then((r) => r.json())
2688
+ .then((r) => {
2689
+ if (!r.ok) throw new Error(`${r.status}`);
2690
+ return r.json();
2691
+ })
2682
2692
  .then((d) => {
2683
2693
  window._termbeamVersion = 'v' + d.version;
2684
2694
  document.getElementById('side-panel-version').textContent = 'v' + d.version;
@@ -3884,6 +3894,7 @@
3884
3894
  const res = await fetch('/api/dirs?q=' + encodeURIComponent(q ? q + '/' : ''), {
3885
3895
  credentials: 'same-origin',
3886
3896
  });
3897
+ if (!res.ok) throw new Error(`Failed to browse directories: ${res.status}`);
3887
3898
  const data = await res.json();
3888
3899
  if (!data.dirs || !data.dirs.length) return;
3889
3900
  browseDropdown = document.createElement('div');
@@ -3997,7 +4008,10 @@
3997
4008
  document.getElementById('ns-browse-btn').addEventListener('click', async () => {
3998
4009
  if (serverCwd === '/') {
3999
4010
  try {
4000
- const data = await fetch('/api/shells').then((r) => r.json());
4011
+ const data = await fetch('/api/shells').then((r) => {
4012
+ if (!r.ok) throw new Error(`${r.status}`);
4013
+ return r.json();
4014
+ });
4001
4015
  if (data.cwd) serverCwd = data.cwd;
4002
4016
  } catch {}
4003
4017
  }
@@ -4025,6 +4039,7 @@
4025
4039
  nsBrowserList.innerHTML = '<div class="browser-empty">Loading…</div>';
4026
4040
  try {
4027
4041
  const res = await fetch(`/api/dirs?q=${encodeURIComponent(dir + '/')}`);
4042
+ if (!res.ok) throw new Error(`Failed to load directories: ${res.status}`);
4028
4043
  const data = await res.json();
4029
4044
  let items = '';
4030
4045
  // Add parent (..) entry unless at root
@@ -4082,7 +4097,10 @@
4082
4097
  if (shellsLoaded) return;
4083
4098
  const sel = document.getElementById('ns-shell');
4084
4099
  try {
4085
- const data = await fetch('/api/shells').then((r) => r.json());
4100
+ const data = await fetch('/api/shells').then((r) => {
4101
+ if (!r.ok) throw new Error(`${r.status}`);
4102
+ return r.json();
4103
+ });
4086
4104
  if (data.cwd) {
4087
4105
  serverCwd = data.cwd;
4088
4106
  document.getElementById('ns-cwd').placeholder = data.cwd;
@@ -4140,10 +4158,16 @@
4140
4158
  headers: { 'Content-Type': 'application/json' },
4141
4159
  body: JSON.stringify(body),
4142
4160
  });
4161
+ if (!res.ok) {
4162
+ console.error(`Failed to create session: ${res.status}`);
4163
+ return;
4164
+ }
4143
4165
  const data = await res.json();
4144
4166
 
4145
4167
  // Fetch full session list to get the new session data
4146
- const list = await fetch('/api/sessions').then((r) => r.json());
4168
+ const listRes = await fetch('/api/sessions');
4169
+ if (!listRes.ok) throw new Error(`Failed to list sessions: ${listRes.status}`);
4170
+ const list = await listRes.json();
4147
4171
  const newSession = list.find((s) => s.id === data.id);
4148
4172
  if (newSession) {
4149
4173
  addSession(newSession);
@@ -4163,7 +4187,9 @@
4163
4187
  function startPolling() {
4164
4188
  setInterval(async () => {
4165
4189
  try {
4166
- const list = await fetch('/api/sessions').then((r) => r.json());
4190
+ const pollRes = await fetch('/api/sessions');
4191
+ if (!pollRes.ok) throw new Error(`${pollRes.status}`);
4192
+ const list = await pollRes.json();
4167
4193
  const serverIds = new Set(list.map((s) => s.id));
4168
4194
 
4169
4195
  // Add new sessions created elsewhere
package/src/auth.js CHANGED
@@ -177,7 +177,7 @@ function createAuth(password) {
177
177
  const shareTokens = new Map(); // share tokens: token -> expiry
178
178
 
179
179
  // Periodically clean up expired tokens and stale rate-limit entries
180
- setInterval(
180
+ const cleanupInterval = setInterval(
181
181
  () => {
182
182
  const now = Date.now();
183
183
  for (const [token, expiry] of tokens) {
@@ -296,6 +296,7 @@ function createAuth(password) {
296
296
  rateLimit,
297
297
  parseCookies,
298
298
  loginHTML: LOGIN_HTML,
299
+ cleanup: () => clearInterval(cleanupInterval),
299
300
  };
300
301
  }
301
302
 
package/src/cli.js CHANGED
@@ -10,7 +10,7 @@ termbeam — Beam your terminal to any device
10
10
 
11
11
  Usage:
12
12
  termbeam [options] [shell] [args...]
13
- termbeam resume [name] [options] Reconnect to a running session
13
+ termbeam resume [name] [options] Reconnect to a running session (alias: attach)
14
14
  termbeam list List running sessions
15
15
  termbeam service <action> Manage as a background service (PM2)
16
16
 
@@ -54,6 +54,7 @@ Examples:
54
54
  termbeam --interactive Guided setup wizard
55
55
  termbeam service install Set up as background service (PM2)
56
56
  termbeam resume Reconnect to an active session
57
+ termbeam attach my-session Attach to a named session (alias for resume)
57
58
  termbeam list List all active sessions
58
59
 
59
60
  Environment:
@@ -270,6 +271,10 @@ function parseArgs() {
270
271
  publicTunnel = true;
271
272
  } else if (args[i].startsWith('--password=')) {
272
273
  password = args[i].split('=')[1];
274
+ if (!password) {
275
+ console.error('Error: --password= requires a non-empty value\n');
276
+ process.exit(1);
277
+ }
273
278
  explicitPassword = true;
274
279
  } else if (args[i] === '--help' || args[i] === '-h') {
275
280
  printHelp();
@@ -286,6 +291,10 @@ function parseArgs() {
286
291
  explicitPassword = true;
287
292
  } else if (args[i] === '--port' && args[i + 1]) {
288
293
  port = parseInt(args[++i], 10);
294
+ if (!Number.isFinite(port) || port < 1 || port > 65535) {
295
+ console.error('Error: --port must be a number between 1 and 65535\n');
296
+ process.exit(1);
297
+ }
289
298
  } else if (args[i] === '--lan') {
290
299
  host = '0.0.0.0';
291
300
  } else if (args[i] === '--host' && args[i + 1]) {
@@ -307,6 +316,12 @@ function parseArgs() {
307
316
  }
308
317
  }
309
318
 
319
+ const validLogLevels = ['error', 'warn', 'info', 'debug'];
320
+ if (!validLogLevels.includes(logLevel)) {
321
+ console.error(`Error: --log-level must be one of: ${validLogLevels.join(', ')}\n`);
322
+ process.exit(1);
323
+ }
324
+
310
325
  // Default: auto-generate password if none specified
311
326
  if (!explicitPassword && !password) {
312
327
  password = crypto.randomBytes(16).toString('base64url');
package/src/client.js CHANGED
@@ -80,7 +80,7 @@ function createTerminalClient({
80
80
  try {
81
81
  msg = JSON.parse(raw);
82
82
  } catch {
83
- return;
83
+ return; // Silently drop unparseable messages from server
84
84
  }
85
85
 
86
86
  if (msg.type === 'auth_ok') {
package/src/preview.js CHANGED
@@ -96,7 +96,7 @@ function createPreviewProxy() {
96
96
  proxyReq.on('error', (err) => {
97
97
  log.warn(`Preview proxy error (port ${port}): ${err.message}`);
98
98
  if (!res.headersSent) {
99
- res.status(502).json({ error: `Bad gateway: ${err.message}` });
99
+ res.status(502).json({ error: 'Bad gateway: upstream server is not responding' });
100
100
  }
101
101
  });
102
102
 
package/src/resume.js CHANGED
@@ -155,7 +155,7 @@ function detachKeyLabel(key) {
155
155
 
156
156
  function printResumeHelp() {
157
157
  console.log(`
158
- ${bold('termbeam resume')} — Reconnect to a running session
158
+ ${bold('termbeam resume')} (alias: ${bold('attach')}) — Reconnect to a running session
159
159
 
160
160
  ${bold('Usage:')}
161
161
  termbeam resume [name] [options]
@@ -243,7 +243,7 @@ async function resume(args) {
243
243
  const { host, port, password, sessions, opts } = conn;
244
244
 
245
245
  if (sessions.length === 0) {
246
- console.error(red(' No active sessions on the server.'));
246
+ console.error(red(` Connected to server on ${conn.displayUrl} — no active sessions.`));
247
247
  process.exit(1);
248
248
  }
249
249
 
@@ -345,7 +345,7 @@ async function list() {
345
345
  }
346
346
 
347
347
  if (sessions.length === 0) {
348
- console.log(dim(' No active sessions.'));
348
+ console.log(dim(` Connected to server on ${displayUrl} — no active sessions.`));
349
349
  return;
350
350
  }
351
351
 
package/src/routes.js CHANGED
@@ -185,9 +185,10 @@ function setupRoutes(app, { auth, sessions, config, state }) {
185
185
  rows: typeof rows === 'number' && rows > 0 && rows <= 200 ? Math.floor(rows) : undefined,
186
186
  });
187
187
  } catch (err) {
188
- return res.status(400).json({ error: err.message || 'Failed to create session' });
188
+ log.warn(`Session creation failed: ${err.message}`);
189
+ return res.status(400).json({ error: 'Failed to create session' });
189
190
  }
190
- res.json({ id, url: `/terminal?id=${id}` });
191
+ res.status(201).json({ id, url: `/terminal?id=${id}` });
191
192
  });
192
193
 
193
194
  // Available shells
@@ -220,7 +221,7 @@ function setupRoutes(app, { auth, sessions, config, state }) {
220
221
 
221
222
  app.delete('/api/sessions/:id', auth.middleware, (req, res) => {
222
223
  if (sessions.delete(req.params.id)) {
223
- res.json({ ok: true });
224
+ res.status(204).end();
224
225
  } else {
225
226
  res.status(404).json({ error: 'not found' });
226
227
  }
@@ -288,7 +289,7 @@ function setupRoutes(app, { auth, sessions, config, state }) {
288
289
  fs.writeFileSync(filepath, buffer);
289
290
  uploadedFiles.set(id, filepath);
290
291
  log.info(`Upload: ${filename} (${buffer.length} bytes)`);
291
- res.json({ id, url: `/uploads/${id}`, path: filepath });
292
+ res.status(201).json({ id, url: `/uploads/${id}`, path: filepath });
292
293
  });
293
294
 
294
295
  req.on('error', (err) => {
@@ -405,7 +406,7 @@ function setupRoutes(app, { auth, sessions, config, state }) {
405
406
  }
406
407
  const finalName = path.basename(destPath);
407
408
  log.info(`File upload: ${finalName} → ${targetDir} (${buffer.length} bytes)`);
408
- res.json({ name: finalName, path: destPath, size: buffer.length });
409
+ res.status(201).json({ name: finalName, path: destPath, size: buffer.length });
409
410
  });
410
411
 
411
412
  req.on('error', (err) => {
package/src/server.js CHANGED
@@ -83,10 +83,14 @@ function createTermBeamServer(overrides = {}) {
83
83
  function shutdown() {
84
84
  if (shuttingDown) return;
85
85
  shuttingDown = true;
86
+ auth.cleanup();
86
87
  sessions.shutdown();
87
88
  cleanupUploadedFiles();
88
89
  cleanupTunnel();
89
90
  removeConnectionConfig();
91
+ for (const client of wss.clients) {
92
+ client.close(1001, 'Server shutting down');
93
+ }
90
94
  server.close();
91
95
  wss.close();
92
96
  }
@@ -262,6 +266,12 @@ function createTermBeamServer(overrides = {}) {
262
266
  console.log(` Scan the QR code or open: ${bl}${qrDisplayUrl}${rs}`);
263
267
  if (config.password) process.stdout.write(` Password: ${gn}${config.password}${rs}\n`);
264
268
  console.log('');
269
+ console.log(`${_dm} From another terminal:${rs}`);
270
+ console.log(`${_dm} termbeam list List active sessions${rs}`);
271
+ console.log(
272
+ `${_dm} termbeam resume Attach to a session (or: termbeam attach)${rs}`,
273
+ );
274
+ console.log('');
265
275
 
266
276
  resolve({ url: `http://localhost:${actualPort}`, defaultId });
267
277
  });
package/src/service.js CHANGED
@@ -460,9 +460,13 @@ async function actionInstall() {
460
460
  }
461
461
 
462
462
  console.log(dim('\nUseful commands:'));
463
- console.log(` ${cyan('termbeam service status')} Check service status`);
464
- console.log(` ${cyan('termbeam service logs')} — View logs`);
465
- console.log(` ${cyan('termbeam service restart')} Restart service`);
463
+ console.log(` ${cyan('termbeam list')} List active sessions`);
464
+ console.log(
465
+ ` ${cyan('termbeam resume')} Attach to a session (or: termbeam attach)`,
466
+ );
467
+ console.log(` ${cyan('termbeam service status')} — Check service status`);
468
+ console.log(` ${cyan('termbeam service logs')} — View logs`);
469
+ console.log(` ${cyan('termbeam service restart')} — Restart service`);
466
470
  console.log(` ${cyan('termbeam service uninstall')} — Remove service\n`);
467
471
  }
468
472
 
package/src/sessions.js CHANGED
@@ -1,30 +1,10 @@
1
1
  const crypto = require('crypto');
2
2
  const path = require('path');
3
- const { execSync, exec } = require('child_process');
4
- const fs = require('fs');
3
+ const { exec } = require('child_process');
5
4
  const pty = require('node-pty');
6
5
  const log = require('./logger');
7
6
  const { getGitInfo } = require('./git');
8
7
 
9
- function _getProcessCwd(pid) {
10
- try {
11
- if (process.platform === 'linux') {
12
- return fs.readlinkSync(`/proc/${pid}/cwd`);
13
- }
14
- if (process.platform === 'darwin') {
15
- const out = execSync(`lsof -a -p ${pid} -d cwd -Fn`, {
16
- stdio: 'pipe',
17
- timeout: 2000,
18
- }).toString();
19
- const match = out.match(/\nn(.+)/);
20
- if (match) return match[1];
21
- }
22
- } catch {
23
- /* process may have exited */
24
- }
25
- return null;
26
- }
27
-
28
8
  // Cache git info per session to avoid blocking the event loop on every list() call.
29
9
  // lsof + git commands take ~200-500ms and block WebSocket traffic, causing
30
10
  // xterm.js cursor position report responses to leak as visible text.
@@ -69,6 +49,7 @@ function scheduleGitRefresh(sessionId, pid, originalCwd) {
69
49
  }
70
50
 
71
51
  exec(cmd, { timeout: 2000 }, (err, stdout) => {
52
+ if (err) log.debug(`Git cwd detection failed: ${err.message}`);
72
53
  let liveCwd = originalCwd;
73
54
  if (!err && stdout) {
74
55
  if (process.platform === 'darwin') {
package/src/tunnel.js CHANGED
@@ -67,21 +67,6 @@ function savePersistedTunnel(id) {
67
67
  );
68
68
  }
69
69
 
70
- function _deletePersisted() {
71
- const persisted = loadPersistedTunnel();
72
- if (persisted) {
73
- try {
74
- if (SAFE_ID_RE.test(persisted.tunnelId)) {
75
- execFileSync(devtunnelCmd, ['delete', persisted.tunnelId, '-f'], { stdio: 'pipe' });
76
- log.info(`Deleted persisted tunnel ${persisted.tunnelId}`);
77
- }
78
- } catch {}
79
- try {
80
- fs.unlinkSync(TUNNEL_CONFIG_PATH);
81
- } catch {}
82
- }
83
- }
84
-
85
70
  function isTunnelValid(id) {
86
71
  try {
87
72
  if (!SAFE_ID_RE.test(id)) return false;
package/src/websocket.js CHANGED
@@ -17,6 +17,10 @@ function recalcPtySize(session) {
17
17
  }
18
18
 
19
19
  function setupWebSocket(wss, { auth, sessions }) {
20
+ const wsAuthAttempts = new Map(); // ip -> [timestamps]
21
+ const WS_AUTH_WINDOW = 60 * 1000; // 1 minute
22
+ const WS_MAX_AUTH_ATTEMPTS = 5;
23
+
20
24
  wss.on('connection', (ws, req) => {
21
25
  const origin = req.headers.origin;
22
26
  if (origin) {
@@ -51,11 +55,26 @@ function setupWebSocket(wss, { auth, sessions }) {
51
55
  const msg = JSON.parse(raw);
52
56
 
53
57
  if (msg.type === 'auth') {
58
+ const ip = req.socket.remoteAddress;
59
+ const now = Date.now();
60
+ const attempts = (wsAuthAttempts.get(ip) || []).filter((t) => now - t < WS_AUTH_WINDOW);
61
+
62
+ if (attempts.length >= WS_MAX_AUTH_ATTEMPTS) {
63
+ log.warn(`WS: rate limit exceeded for ${ip}`);
64
+ ws.send(
65
+ JSON.stringify({ type: 'error', message: 'Too many attempts. Try again later.' }),
66
+ );
67
+ ws.close();
68
+ return;
69
+ }
70
+
54
71
  if (msg.password === auth.password || auth.validateToken(msg.token)) {
55
72
  authenticated = true;
56
73
  ws.send(JSON.stringify({ type: 'auth_ok' }));
57
74
  log.info('WS: auth success');
58
75
  } else {
76
+ attempts.push(now);
77
+ wsAuthAttempts.set(ip, attempts);
59
78
  log.warn('WS: auth failed');
60
79
  ws.send(JSON.stringify({ type: 'error', message: 'Unauthorized' }));
61
80
  ws.close();