termbeam 1.2.7 → 1.2.9

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
@@ -104,7 +104,8 @@ Persisted tunnels save a tunnel ID to `~/.termbeam/tunnel.json` so the URL stays
104
104
  ```bash
105
105
  termbeam [shell] [args...] # start with a specific shell (default: auto-detect)
106
106
  termbeam --port 8080 # custom port (default: 3456)
107
- termbeam --host 127.0.0.1 # restrict to localhost (default: 0.0.0.0)
107
+ termbeam --host 0.0.0.0 # allow LAN access (default: 127.0.0.1)
108
+ termbeam --lan # shortcut for --host 0.0.0.0
108
109
  ```
109
110
 
110
111
  | Flag | Description | Default |
@@ -117,14 +118,15 @@ termbeam --host 127.0.0.1 # restrict to localhost (default: 0.0.0.0)
117
118
  | `--persisted-tunnel` | Create a reusable devtunnel URL | Off |
118
119
  | `--public` | Allow public tunnel access | Off |
119
120
  | `--port <port>` | Server port | `3456` |
120
- | `--host <addr>` | Bind address | `0.0.0.0` |
121
+ | `--host <addr>` | Bind address | `127.0.0.1` |
122
+ | `--lan` | Bind to all interfaces (LAN access) | Off |
121
123
  | `--log-level <level>` | Log verbosity (error/warn/info/debug) | `info` |
122
124
 
123
125
  Environment variables: `PORT`, `TERMBEAM_PASSWORD`, `TERMBEAM_CWD`, `TERMBEAM_LOG_LEVEL`, `SHELL` (Unix fallback), `COMSPEC` (Windows fallback). See [Configuration docs](https://dorlugasigal.github.io/TermBeam/configuration/).
124
126
 
125
127
  ## Security
126
128
 
127
- TermBeam auto-generates a password and creates a tunnel by default, so your terminal is protected out of the box. Be aware that the tunnel exposes your terminal to the internet use `--no-tunnel` for LAN-only access, or `--host 127.0.0.1` to restrict to your machine only.
129
+ TermBeam auto-generates a password and creates a tunnel by default, so your terminal is protected out of the box. By default, the server binds to `127.0.0.1` (localhost only). Use `--lan` or `--host 0.0.0.0` to allow LAN access, or `--no-tunnel` to disable the tunnel.
128
130
 
129
131
  Auth uses secure httpOnly cookies with 24-hour expiry, login is rate-limited to 5 attempts per minute, and security headers (X-Frame-Options, X-Content-Type-Options, etc.) are set on all responses. The QR code on startup embeds a share token for password-free login — the token is reusable within its 5-minute validity window, which handles tunnel proxy retries and link preview services. API clients that can't use cookies can authenticate with an `Authorization: Bearer <password>` header. See the [Security Guide](https://dorlugasigal.github.io/TermBeam/security/) for more.
130
132
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "termbeam",
3
- "version": "1.2.7",
3
+ "version": "1.2.9",
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": {
@@ -3203,23 +3203,7 @@
3203
3203
  }
3204
3204
 
3205
3205
  async function handlePaste() {
3206
- // Try clipboard API for text first (most common), then images, then fallback to modal
3207
- if (navigator.clipboard && navigator.clipboard.readText) {
3208
- try {
3209
- const text = await navigator.clipboard.readText();
3210
- if (text) {
3211
- const ms = managed.get(activeId);
3212
- if (ms && ms.ws && ms.ws.readyState === 1) {
3213
- ms.ws.send(JSON.stringify({ type: 'input', data: text }));
3214
- showToast('Pasted!');
3215
- }
3216
- return;
3217
- }
3218
- } catch (err) {
3219
- console.warn('clipboard.readText failed:', err.message);
3220
- }
3221
- }
3222
- // Image paste: try clipboard.read() only if readText didn't work
3206
+ // Try image first via clipboard.read()
3223
3207
  if (navigator.clipboard && navigator.clipboard.read) {
3224
3208
  try {
3225
3209
  const items = await navigator.clipboard.read();
@@ -3237,8 +3221,9 @@
3237
3221
  const data = await res.json();
3238
3222
  const ms = managed.get(activeId);
3239
3223
  if (ms && ms.ws && ms.ws.readyState === 1) {
3240
- ms.ws.send(JSON.stringify({ type: 'input', data: data.path + ' ' }));
3241
- showToast('Image pasted: ' + data.path.split(/[/\\]/).pop());
3224
+ ms.ws.send(
3225
+ JSON.stringify({ type: 'input', data: (data.path || data.url) + ' ' }),
3226
+ );
3242
3227
  }
3243
3228
  return;
3244
3229
  }
@@ -3247,6 +3232,22 @@
3247
3232
  console.warn('clipboard.read failed:', err.message);
3248
3233
  }
3249
3234
  }
3235
+ // Text paste
3236
+ if (navigator.clipboard && navigator.clipboard.readText) {
3237
+ try {
3238
+ const text = await navigator.clipboard.readText();
3239
+ if (text) {
3240
+ const ms = managed.get(activeId);
3241
+ if (ms && ms.ws && ms.ws.readyState === 1) {
3242
+ ms.ws.send(JSON.stringify({ type: 'input', data: text }));
3243
+ showToast('Pasted!');
3244
+ }
3245
+ return;
3246
+ }
3247
+ } catch (err) {
3248
+ console.warn('clipboard.readText failed:', err.message);
3249
+ }
3250
+ }
3250
3251
  openPasteModal();
3251
3252
  }
3252
3253
 
@@ -3392,36 +3393,44 @@
3392
3393
 
3393
3394
  // ===== Image Paste =====
3394
3395
  function setupImagePaste() {
3395
- document.addEventListener('paste', async (e) => {
3396
- const items = e.clipboardData && e.clipboardData.items;
3397
- if (!items) return;
3398
-
3399
- for (const item of items) {
3400
- if (item.type.startsWith('image/')) {
3401
- e.preventDefault();
3402
- const blob = item.getAsFile();
3403
- if (!blob) return;
3404
-
3405
- try {
3406
- const res = await fetch('/api/upload', {
3407
- method: 'POST',
3408
- headers: { 'Content-Type': item.type },
3409
- body: blob,
3410
- });
3411
- if (!res.ok) throw new Error('Upload failed');
3412
- const data = await res.json();
3413
- const ms = managed.get(activeId);
3414
- if (ms && ms.ws && ms.ws.readyState === 1) {
3415
- ms.ws.send(JSON.stringify({ type: 'input', data: data.path + ' ' }));
3416
- showToast('Image pasted: ' + data.path.split(/[/\\]/).pop());
3396
+ // Use capture phase so we intercept before xterm.js pastes a file path as text
3397
+ document.addEventListener(
3398
+ 'paste',
3399
+ async (e) => {
3400
+ const items = e.clipboardData && e.clipboardData.items;
3401
+ if (!items) return;
3402
+
3403
+ for (const item of items) {
3404
+ if (item.type.startsWith('image/')) {
3405
+ e.preventDefault();
3406
+ e.stopPropagation();
3407
+ const blob = item.getAsFile();
3408
+ if (!blob) return;
3409
+
3410
+ try {
3411
+ const res = await fetch('/api/upload', {
3412
+ method: 'POST',
3413
+ headers: { 'Content-Type': item.type },
3414
+ body: blob,
3415
+ credentials: 'same-origin',
3416
+ });
3417
+ if (!res.ok) throw new Error('Upload failed');
3418
+ const data = await res.json();
3419
+ const ms = managed.get(activeId);
3420
+ if (ms && ms.ws && ms.ws.readyState === 1) {
3421
+ ms.ws.send(
3422
+ JSON.stringify({ type: 'input', data: (data.path || data.url) + ' ' }),
3423
+ );
3424
+ }
3425
+ } catch (err) {
3426
+ showToast('Image paste failed');
3417
3427
  }
3418
- } catch (err) {
3419
- showToast('Image paste failed');
3428
+ return;
3420
3429
  }
3421
- return;
3422
3430
  }
3423
- }
3424
- });
3431
+ },
3432
+ true,
3433
+ );
3425
3434
  }
3426
3435
 
3427
3436
  // ===== New Session Modal =====
package/src/auth.js CHANGED
@@ -86,26 +86,25 @@ function createAuth(password) {
86
86
  const token = crypto.randomBytes(32).toString('hex');
87
87
  const expiry = Date.now() + 5 * 60 * 1000;
88
88
  shareTokens.set(token, expiry); // 5 minute expiry
89
- log.info(`Share: created ${token.slice(0, 8)}… (expires in 5m)`);
89
+ log.info('Share: created new token (expires in 5m)');
90
+ log.debug(`Share: token expires at ${new Date(expiry).toISOString()}`);
90
91
  return token;
91
92
  }
92
93
 
93
94
  function validateShareToken(token) {
94
95
  const expiry = shareTokens.get(token);
95
- const tag = token.slice(0, 8);
96
96
  if (!expiry) {
97
- log.warn(`Share: unknown token ${tag}…`);
97
+ log.warn('Share: unknown token presented');
98
98
  return false;
99
99
  }
100
100
  const remaining = Math.round((expiry - Date.now()) / 1000);
101
101
  if (remaining <= 0) {
102
102
  shareTokens.delete(token);
103
- log.warn(`Share: expired token ${tag}…`);
103
+ log.warn('Share: expired token presented');
104
104
  return false;
105
105
  }
106
- const min = Math.floor(remaining / 60);
107
- const sec = remaining % 60;
108
- log.info(`Share: valid token ${tag}… (${min}m ${sec}s remaining)`);
106
+ shareTokens.delete(token);
107
+ log.info('share token consumed');
109
108
  return true;
110
109
  }
111
110
 
@@ -129,9 +128,24 @@ function createAuth(password) {
129
128
  if (!password) return next();
130
129
  if (req.cookies.pty_token && validateToken(req.cookies.pty_token)) return next();
131
130
  const authHeader = req.headers.authorization;
132
- if (authHeader === `Bearer ${password}`) return next();
133
- if (req.accepts('html')) return res.redirect('/login');
134
- res.status(401).json({ error: 'unauthorized' });
131
+ if (authHeader && authHeader.startsWith('Bearer ')) {
132
+ const ip = req.ip || req.socket.remoteAddress;
133
+ const now = Date.now();
134
+ const window = 60 * 1000;
135
+ const maxAttempts = 5;
136
+ const attempts = authAttempts.get(ip) || [];
137
+ const recent = attempts.filter((t) => now - t < window);
138
+ if (recent.length >= maxAttempts) {
139
+ log.warn(`Auth: rate limit exceeded for ${ip}`);
140
+ return res.status(429).json({ error: 'Too many attempts. Try again later.' });
141
+ }
142
+ if (authHeader === `Bearer ${password}`) return next();
143
+ recent.push(now);
144
+ authAttempts.set(ip, recent);
145
+ return res.status(401).json({ error: 'unauthorized' });
146
+ }
147
+ if (req.path.startsWith('/api/')) return res.status(401).json({ error: 'unauthorized' });
148
+ res.redirect('/login');
135
149
  }
136
150
 
137
151
  function rateLimit(req, res, next) {
package/src/cli.js CHANGED
@@ -20,7 +20,8 @@ Options:
20
20
  --persisted-tunnel Create a reusable devtunnel URL (stable across restarts)
21
21
  --public Allow public tunnel access (default: private, owner-only)
22
22
  --port <port> Set port (default: 3456, or PORT env var)
23
- --host <addr> Bind address (default: 0.0.0.0)
23
+ --host <addr> Bind address (default: 127.0.0.1)
24
+ --lan Bind to 0.0.0.0 (allow LAN access, default: localhost only)
24
25
  --log-level <level> Set log verbosity: error, warn, info, debug (default: info)
25
26
  -h, --help Show this help
26
27
  -v, --version Show version
@@ -206,7 +207,7 @@ function getDefaultShell() {
206
207
 
207
208
  function parseArgs() {
208
209
  let port = parseInt(process.env.PORT || '3456', 10);
209
- let host = '0.0.0.0';
210
+ let host = '127.0.0.1';
210
211
 
211
212
  // Resolve log level early (env + args) so shell detection logs are visible
212
213
  let logLevel = process.env.TERMBEAM_LOG_LEVEL || 'info';
@@ -267,6 +268,8 @@ function parseArgs() {
267
268
  explicitPassword = true;
268
269
  } else if (args[i] === '--port' && args[i + 1]) {
269
270
  port = parseInt(args[++i], 10);
271
+ } else if (args[i] === '--lan') {
272
+ host = '0.0.0.0';
270
273
  } else if (args[i] === '--host' && args[i + 1]) {
271
274
  host = args[++i];
272
275
  } else if (args[i] === '--log-level' && args[i + 1]) {
package/src/routes.js CHANGED
@@ -7,7 +7,7 @@ const { detectShells } = require('./shells');
7
7
  const log = require('./logger');
8
8
 
9
9
  const PUBLIC_DIR = path.join(__dirname, '..', 'public');
10
- const uploadedFiles = [];
10
+ const uploadedFiles = new Map(); // id -> filepath
11
11
 
12
12
  function setupRoutes(app, { auth, sessions, config, state }) {
13
13
  // Serve static files (manifest.json, sw.js, icons, etc.)
@@ -28,7 +28,7 @@ function setupRoutes(app, { auth, sessions, config, state }) {
28
28
  httpOnly: true,
29
29
  sameSite: 'lax',
30
30
  maxAge: 24 * 60 * 60 * 1000,
31
- secure: false,
31
+ secure: req.secure,
32
32
  });
33
33
  log.info(`Auth: login success from ${req.ip}`);
34
34
  res.json({ ok: true });
@@ -58,7 +58,7 @@ function setupRoutes(app, { auth, sessions, config, state }) {
58
58
  httpOnly: true,
59
59
  sameSite: 'lax',
60
60
  maxAge: 24 * 60 * 60 * 1000,
61
- secure: false,
61
+ secure: req.secure,
62
62
  });
63
63
  log.info(`Auth: share-token auto-login from ${req.ip}`);
64
64
  // Redirect to the same path without ?ott= to keep the URL clean
@@ -212,12 +212,13 @@ function setupRoutes(app, { auth, sessions, config, state }) {
212
212
  'image/webp': '.webp',
213
213
  'image/bmp': '.bmp',
214
214
  }[contentType] || '.png';
215
- const filename = `termbeam-${crypto.randomUUID()}${ext}`;
215
+ const id = crypto.randomUUID();
216
+ const filename = `termbeam-${id}${ext}`;
216
217
  const filepath = path.join(os.tmpdir(), filename);
217
218
  fs.writeFileSync(filepath, buffer);
218
- uploadedFiles.push(filepath);
219
+ uploadedFiles.set(id, filepath);
219
220
  log.info(`Upload: ${filename} (${buffer.length} bytes)`);
220
- res.json({ path: filepath });
221
+ res.json({ id, url: `/uploads/${id}`, path: filepath });
221
222
  });
222
223
 
223
224
  req.on('error', (err) => {
@@ -226,6 +227,17 @@ function setupRoutes(app, { auth, sessions, config, state }) {
226
227
  });
227
228
  });
228
229
 
230
+ // Serve uploaded files by opaque ID
231
+ app.get('/uploads/:id', auth.middleware, (req, res) => {
232
+ const filepath = uploadedFiles.get(req.params.id);
233
+ if (!filepath) return res.status(404).json({ error: 'not found' });
234
+ if (!fs.existsSync(filepath)) {
235
+ uploadedFiles.delete(req.params.id);
236
+ return res.status(404).json({ error: 'not found' });
237
+ }
238
+ res.sendFile(filepath);
239
+ });
240
+
229
241
  // Directory listing for folder browser
230
242
  app.get('/api/dirs', auth.middleware, (req, res) => {
231
243
  const query = req.query.q || config.cwd + path.sep;
@@ -248,7 +260,7 @@ function setupRoutes(app, { auth, sessions, config, state }) {
248
260
  }
249
261
 
250
262
  function cleanupUploadedFiles() {
251
- for (const filepath of uploadedFiles) {
263
+ for (const [id, filepath] of uploadedFiles) {
252
264
  try {
253
265
  if (fs.existsSync(filepath)) {
254
266
  fs.unlinkSync(filepath);
@@ -257,7 +269,7 @@ function cleanupUploadedFiles() {
257
269
  log.error(`Failed to cleanup ${filepath}: ${err.message}`);
258
270
  }
259
271
  }
260
- uploadedFiles.length = 0;
272
+ uploadedFiles.clear();
261
273
  }
262
274
 
263
275
  module.exports = { setupRoutes, cleanupUploadedFiles };
package/src/server.js CHANGED
@@ -174,6 +174,8 @@ function createTermBeamServer(overrides = {}) {
174
174
  const gn = '\x1b[38;5;114m'; // green
175
175
  const dm = '\x1b[2m'; // dim
176
176
 
177
+ const bl = '\x1b[38;5;75m'; // light blue
178
+
177
179
  let publicUrl = null;
178
180
  if (config.useTunnel) {
179
181
  const tunnel = await startTunnel(config.port, {
@@ -191,10 +193,15 @@ function createTermBeamServer(overrides = {}) {
191
193
  console.log(` Shell: ${config.shell}`);
192
194
  console.log(` Session: ${defaultId}`);
193
195
  console.log(` Auth: ${config.password ? `${gn}🔒 password${rs}` : '🔓 none'}`);
196
+ if (isLanReachable) {
197
+ console.log(` Bind: ${config.host} (LAN accessible)`);
198
+ } else {
199
+ console.log(` Bind: ${config.host} (localhost only)`);
200
+ }
194
201
  console.log('');
195
202
 
196
203
  if (publicUrl) {
197
- console.log(` 🌐 Public: ${publicUrl}`);
204
+ console.log(` Public: ${bl}${publicUrl}${rs}`);
198
205
  }
199
206
  console.log(` Local: http://localhost:${config.port}`);
200
207
  if (isLanReachable) {
@@ -205,8 +212,6 @@ function createTermBeamServer(overrides = {}) {
205
212
  const qrDisplayUrl = qrUrl; // clean URL shown in console text
206
213
  const qrCodeUrl = config.password ? `${qrUrl}?ott=${auth.generateShareToken()}` : qrUrl;
207
214
  console.log('');
208
- console.log(` ${dm}📋 Clipboard requires HTTPS — use the Public or localhost URL${rs}`);
209
- console.log('');
210
215
  try {
211
216
  const qr = await QRCode.toString(qrCodeUrl, { type: 'terminal', small: true });
212
217
  console.log(qr);
@@ -214,7 +219,7 @@ function createTermBeamServer(overrides = {}) {
214
219
  /* ignore */
215
220
  }
216
221
 
217
- console.log(` Scan the QR code or open: ${qrDisplayUrl}`);
222
+ console.log(` Scan the QR code or open: ${bl}${qrDisplayUrl}${rs}`);
218
223
  if (config.password) console.log(` Password: ${gn}${config.password}${rs}`);
219
224
  console.log('');
220
225