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 +5 -3
- package/package.json +1 -1
- package/public/terminal.html +55 -46
- package/src/auth.js +24 -10
- package/src/cli.js +5 -2
- package/src/routes.js +20 -8
- package/src/server.js +9 -4
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
|
|
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 | `
|
|
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.
|
|
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
package/public/terminal.html
CHANGED
|
@@ -3203,23 +3203,7 @@
|
|
|
3203
3203
|
}
|
|
3204
3204
|
|
|
3205
3205
|
async function handlePaste() {
|
|
3206
|
-
// Try
|
|
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(
|
|
3241
|
-
|
|
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
|
-
|
|
3396
|
-
|
|
3397
|
-
|
|
3398
|
-
|
|
3399
|
-
|
|
3400
|
-
if (
|
|
3401
|
-
|
|
3402
|
-
|
|
3403
|
-
if (
|
|
3404
|
-
|
|
3405
|
-
|
|
3406
|
-
const
|
|
3407
|
-
|
|
3408
|
-
|
|
3409
|
-
|
|
3410
|
-
|
|
3411
|
-
|
|
3412
|
-
|
|
3413
|
-
|
|
3414
|
-
|
|
3415
|
-
|
|
3416
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
103
|
+
log.warn('Share: expired token presented');
|
|
104
104
|
return false;
|
|
105
105
|
}
|
|
106
|
-
|
|
107
|
-
|
|
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
|
|
133
|
-
|
|
134
|
-
|
|
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:
|
|
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 = '
|
|
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:
|
|
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:
|
|
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
|
|
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.
|
|
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.
|
|
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(`
|
|
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
|
|