termbeam 0.1.1 → 1.0.1
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 +2 -1
- package/package.json +14 -5
- package/public/index.html +9 -2
- package/public/terminal.html +17 -5
- package/src/auth.js +2 -1
- package/src/cli.js +26 -10
- package/src/logger.js +32 -0
- package/src/routes.js +47 -7
- package/src/server.js +155 -118
- package/src/sessions.js +5 -4
- package/src/tunnel.js +26 -25
- package/src/websocket.js +25 -6
package/README.md
CHANGED
|
@@ -105,8 +105,9 @@ termbeam --host 127.0.0.1 # restrict to localhost (default: 0.0.0.0)
|
|
|
105
105
|
| `--persisted-tunnel` | Create a reusable devtunnel URL | Off |
|
|
106
106
|
| `--port <port>` | Server port | `3456` |
|
|
107
107
|
| `--host <addr>` | Bind address | `0.0.0.0` |
|
|
108
|
+
| `--log-level <level>` | Log verbosity (error/warn/info/debug) | `info` |
|
|
108
109
|
|
|
109
|
-
Environment variables: `PORT`, `TERMBEAM_PASSWORD`, `TERMBEAM_CWD`, `SHELL` (Unix fallback), `COMSPEC` (Windows fallback). See [Configuration docs](https://dorlugasigal.github.io/TermBeam/configuration/).
|
|
110
|
+
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/).
|
|
110
111
|
|
|
111
112
|
## Security
|
|
112
113
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "termbeam",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "1.0.1",
|
|
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": {
|
|
@@ -9,8 +9,8 @@
|
|
|
9
9
|
"scripts": {
|
|
10
10
|
"start": "node bin/termbeam.js",
|
|
11
11
|
"dev": "node bin/termbeam.js --generate-password",
|
|
12
|
-
"test": "node --test
|
|
13
|
-
"test:coverage": "c8 --reporter=text --reporter=lcov --reporter=json-summary --reporter=json node --test
|
|
12
|
+
"test": "node -e \"require('child_process').execFileSync(process.execPath,['--test',...require('fs').readdirSync('test').filter(f=>f.endsWith('.test.js')).map(f=>'test/'+f)],{stdio:'inherit'})\"",
|
|
13
|
+
"test:coverage": "c8 --exclude=src/tunnel.js --reporter=text --reporter=lcov --reporter=json-summary --reporter=json node -e \"require('child_process').execFileSync(process.execPath,['--test','--test-reporter=spec','--test-reporter-destination=stdout',...require('fs').readdirSync('test').filter(f=>f.endsWith('.test.js')).map(f=>'test/'+f)],{stdio:'inherit'})\"",
|
|
14
14
|
"prepare": "husky",
|
|
15
15
|
"format": "prettier --write .",
|
|
16
16
|
"lint": "node --check src/*.js bin/*.js",
|
|
@@ -24,9 +24,18 @@
|
|
|
24
24
|
"remote-terminal",
|
|
25
25
|
"xterm",
|
|
26
26
|
"websocket",
|
|
27
|
-
"ssh-alternative"
|
|
27
|
+
"ssh-alternative",
|
|
28
|
+
"mobile-terminal",
|
|
29
|
+
"terminal-sharing",
|
|
30
|
+
"browser-terminal",
|
|
31
|
+
"remote-access",
|
|
32
|
+
"qr-code",
|
|
33
|
+
"touch-terminal",
|
|
34
|
+
"terminal-emulator",
|
|
35
|
+
"devtools",
|
|
36
|
+
"cli"
|
|
28
37
|
],
|
|
29
|
-
"author": "",
|
|
38
|
+
"author": "Dor Lugasi <dorlugasigal@gmail.com>",
|
|
30
39
|
"license": "MIT",
|
|
31
40
|
"homepage": "https://github.com/dorlugasigal/TermBeam",
|
|
32
41
|
"repository": {
|
package/public/index.html
CHANGED
|
@@ -9,9 +9,10 @@
|
|
|
9
9
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
|
10
10
|
<meta name="mobile-web-app-capable" content="yes" />
|
|
11
11
|
<meta name="theme-color" content="#1e1e1e" />
|
|
12
|
+
<meta name="description" content="TermBeam — beam your terminal to any device. Mobile-optimized web terminal with multi-session support, touch controls, and QR code connection. No SSH needed." />
|
|
12
13
|
<link rel="manifest" href="/manifest.json" />
|
|
13
14
|
<link rel="apple-touch-icon" href="/icons/icon-192.png" />
|
|
14
|
-
<title>TermBeam</title>
|
|
15
|
+
<title>TermBeam — Beam Your Terminal to Any Device</title>
|
|
15
16
|
<style>
|
|
16
17
|
:root {
|
|
17
18
|
--bg: #1e1e1e;
|
|
@@ -958,7 +959,13 @@
|
|
|
958
959
|
let currentBrowsePath = '/';
|
|
959
960
|
let hubServerCwd = '/';
|
|
960
961
|
|
|
961
|
-
document.getElementById('browse-btn').addEventListener('click', () => {
|
|
962
|
+
document.getElementById('browse-btn').addEventListener('click', async () => {
|
|
963
|
+
if (hubServerCwd === '/') {
|
|
964
|
+
try {
|
|
965
|
+
const data = await fetch('/api/shells').then(r => r.json());
|
|
966
|
+
if (data.cwd) hubServerCwd = data.cwd;
|
|
967
|
+
} catch {}
|
|
968
|
+
}
|
|
962
969
|
const initial = cwdInput.value.trim() || hubServerCwd;
|
|
963
970
|
navigateTo(initial);
|
|
964
971
|
browserOverlay.classList.add('visible');
|
package/public/terminal.html
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
|
7
7
|
<meta name="mobile-web-app-capable" content="yes" />
|
|
8
8
|
<meta name="theme-color" content="#1e1e1e" />
|
|
9
|
+
<meta name="description" content="TermBeam terminal session — access your terminal remotely from any browser with a mobile-optimized touch interface." />
|
|
9
10
|
<link rel="manifest" href="/manifest.json" />
|
|
10
11
|
<link rel="apple-touch-icon" href="/icons/icon-192.png" />
|
|
11
12
|
<title>TermBeam — Terminal</title>
|
|
@@ -1349,12 +1350,16 @@
|
|
|
1349
1350
|
}
|
|
1350
1351
|
|
|
1351
1352
|
async function removeSession(id) {
|
|
1352
|
-
try { await fetch('/api/sessions/' + encodeURIComponent(id), { method: 'DELETE' }); } catch {}
|
|
1353
|
-
|
|
1354
1353
|
const ms = managed.get(id);
|
|
1355
1354
|
if (ms) {
|
|
1355
|
+
ms.exited = true;
|
|
1356
|
+
if (ms.reconnectTimer) { clearTimeout(ms.reconnectTimer); ms.reconnectTimer = null; }
|
|
1356
1357
|
if (ms.ws) try { ms.ws.close(); } catch {}
|
|
1357
|
-
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
try { await fetch('/api/sessions/' + encodeURIComponent(id), { method: 'DELETE' }); } catch {}
|
|
1361
|
+
|
|
1362
|
+
if (ms) {
|
|
1358
1363
|
ms.term.dispose();
|
|
1359
1364
|
ms.container.remove();
|
|
1360
1365
|
managed.delete(id);
|
|
@@ -2007,7 +2012,13 @@
|
|
|
2007
2012
|
let nsBrowsePath = '/';
|
|
2008
2013
|
let serverCwd = '/';
|
|
2009
2014
|
|
|
2010
|
-
document.getElementById('ns-browse-btn').addEventListener('click', () => {
|
|
2015
|
+
document.getElementById('ns-browse-btn').addEventListener('click', async () => {
|
|
2016
|
+
if (serverCwd === '/') {
|
|
2017
|
+
try {
|
|
2018
|
+
const data = await fetch('/api/shells').then(r => r.json());
|
|
2019
|
+
if (data.cwd) serverCwd = data.cwd;
|
|
2020
|
+
} catch {}
|
|
2021
|
+
}
|
|
2011
2022
|
const initial = nsCwdInput.value.trim() || serverCwd;
|
|
2012
2023
|
nsBrowseNavigate(initial);
|
|
2013
2024
|
nsBrowserOverlay.classList.add('visible');
|
|
@@ -2162,8 +2173,9 @@
|
|
|
2162
2173
|
for (const id of [...managed.keys()]) {
|
|
2163
2174
|
if (!serverIds.has(id)) {
|
|
2164
2175
|
const ms = managed.get(id);
|
|
2176
|
+
ms.exited = true;
|
|
2177
|
+
if (ms.reconnectTimer) { clearTimeout(ms.reconnectTimer); ms.reconnectTimer = null; }
|
|
2165
2178
|
if (ms.ws) try { ms.ws.close(); } catch {}
|
|
2166
|
-
if (ms.reconnectTimer) clearTimeout(ms.reconnectTimer);
|
|
2167
2179
|
ms.term.dispose();
|
|
2168
2180
|
ms.container.remove();
|
|
2169
2181
|
managed.delete(id);
|
package/src/auth.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
const crypto = require('crypto');
|
|
2
|
+
const log = require('./logger');
|
|
2
3
|
|
|
3
4
|
const LOGIN_HTML = `<!DOCTYPE html>
|
|
4
5
|
<html lang="en">
|
|
@@ -107,7 +108,7 @@ function createAuth(password) {
|
|
|
107
108
|
const attempts = authAttempts.get(ip) || [];
|
|
108
109
|
const recent = attempts.filter((t) => now - t < window);
|
|
109
110
|
if (recent.length >= maxAttempts) {
|
|
110
|
-
|
|
111
|
+
log.warn(`Auth: rate limit exceeded for ${ip}`);
|
|
111
112
|
return res.status(429).json({ error: 'Too many attempts. Try again later.' });
|
|
112
113
|
}
|
|
113
114
|
recent.push(now);
|
package/src/cli.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
const os = require('os');
|
|
2
2
|
const path = require('path');
|
|
3
3
|
const crypto = require('crypto');
|
|
4
|
+
const log = require('./logger');
|
|
4
5
|
|
|
5
6
|
function printHelp() {
|
|
6
7
|
console.log(`
|
|
@@ -18,6 +19,7 @@ Options:
|
|
|
18
19
|
--persisted-tunnel Create a reusable devtunnel URL (stable across restarts)
|
|
19
20
|
--port <port> Set port (default: 3456, or PORT env var)
|
|
20
21
|
--host <addr> Bind address (default: 0.0.0.0)
|
|
22
|
+
--log-level <level> Set log verbosity: error, warn, info, debug (default: info)
|
|
21
23
|
-h, --help Show this help
|
|
22
24
|
-v, --version Show version
|
|
23
25
|
|
|
@@ -37,6 +39,7 @@ Environment:
|
|
|
37
39
|
PORT Server port (default: 3456)
|
|
38
40
|
TERMBEAM_PASSWORD Access password
|
|
39
41
|
TERMBEAM_CWD Working directory
|
|
42
|
+
TERMBEAM_LOG_LEVEL Log level (default: info)
|
|
40
43
|
`);
|
|
41
44
|
}
|
|
42
45
|
|
|
@@ -82,13 +85,13 @@ function getWindowsAncestors(startPid, maxDepth = 4) {
|
|
|
82
85
|
for (let i = 0; i < maxDepth; i++) {
|
|
83
86
|
const proc = processes.get(currentPid);
|
|
84
87
|
if (!proc) break;
|
|
85
|
-
|
|
88
|
+
log.debug(`Process tree: ${proc.name}`);
|
|
86
89
|
names.push(proc.name);
|
|
87
90
|
if (!Number.isFinite(proc.ppid) || proc.ppid === 0 || proc.ppid === currentPid) break;
|
|
88
91
|
currentPid = proc.ppid;
|
|
89
92
|
}
|
|
90
93
|
} catch (err) {
|
|
91
|
-
|
|
94
|
+
log.debug(`Could not query process tree: ${err.message}`);
|
|
92
95
|
}
|
|
93
96
|
|
|
94
97
|
return names;
|
|
@@ -97,7 +100,7 @@ function getWindowsAncestors(startPid, maxDepth = 4) {
|
|
|
97
100
|
function getDefaultShell() {
|
|
98
101
|
const { execFileSync } = require('child_process');
|
|
99
102
|
const ppid = process.ppid;
|
|
100
|
-
|
|
103
|
+
log.debug(`Detecting shell (parent PID: ${ppid}, platform: ${os.platform()})`);
|
|
101
104
|
|
|
102
105
|
if (os.platform() === 'win32') {
|
|
103
106
|
// Walk up the process tree (up to 4 ancestors) to find the real user shell.
|
|
@@ -109,18 +112,18 @@ function getDefaultShell() {
|
|
|
109
112
|
let foundCmd = false;
|
|
110
113
|
for (const name of ancestors) {
|
|
111
114
|
if (preferredShells.includes(name)) {
|
|
112
|
-
|
|
115
|
+
log.debug(`Found shell in process tree: ${name}`);
|
|
113
116
|
return name;
|
|
114
117
|
}
|
|
115
118
|
if (name === 'cmd.exe') foundCmd = true;
|
|
116
119
|
}
|
|
117
120
|
|
|
118
121
|
if (foundCmd) {
|
|
119
|
-
|
|
122
|
+
log.debug(`Using detected shell: cmd.exe`);
|
|
120
123
|
return 'cmd.exe';
|
|
121
124
|
}
|
|
122
125
|
const fallback = process.env.COMSPEC || 'cmd.exe';
|
|
123
|
-
|
|
126
|
+
log.debug(`Falling back to: ${fallback}`);
|
|
124
127
|
return fallback;
|
|
125
128
|
}
|
|
126
129
|
|
|
@@ -134,22 +137,33 @@ function getDefaultShell() {
|
|
|
134
137
|
const comm = result.trim();
|
|
135
138
|
if (comm) {
|
|
136
139
|
const shell = comm.startsWith('-') ? comm.slice(1) : comm;
|
|
137
|
-
|
|
140
|
+
log.debug(`Detected parent shell: ${shell}`);
|
|
138
141
|
return shell;
|
|
139
142
|
}
|
|
140
143
|
} catch (err) {
|
|
141
|
-
|
|
144
|
+
log.debug(`Could not detect parent shell: ${err.message}`);
|
|
142
145
|
}
|
|
143
146
|
|
|
144
147
|
// Fallback to SHELL env or /bin/sh
|
|
145
148
|
const fallback = process.env.SHELL || '/bin/sh';
|
|
146
|
-
|
|
149
|
+
log.debug(`Falling back to: ${fallback}`);
|
|
147
150
|
return fallback;
|
|
148
151
|
}
|
|
149
152
|
|
|
150
153
|
function parseArgs() {
|
|
151
154
|
let port = parseInt(process.env.PORT || '3456', 10);
|
|
152
155
|
let host = '0.0.0.0';
|
|
156
|
+
|
|
157
|
+
// Resolve log level early (env + args) so shell detection logs are visible
|
|
158
|
+
let logLevel = process.env.TERMBEAM_LOG_LEVEL || 'info';
|
|
159
|
+
for (const arg of process.argv.slice(2)) {
|
|
160
|
+
if (arg.startsWith('--log-level=')) { logLevel = arg.split('=')[1]; break; }
|
|
161
|
+
}
|
|
162
|
+
for (let i = 2; i < process.argv.length; i++) {
|
|
163
|
+
if (process.argv[i] === '--log-level' && process.argv[i + 1]) { logLevel = process.argv[i + 1]; break; }
|
|
164
|
+
}
|
|
165
|
+
log.setLevel(logLevel);
|
|
166
|
+
|
|
153
167
|
const defaultShell = getDefaultShell();
|
|
154
168
|
const cwd = process.env.TERMBEAM_CWD || process.env.PTY_CWD || process.cwd();
|
|
155
169
|
let password = process.env.TERMBEAM_PASSWORD || process.env.PTY_PASSWORD || null;
|
|
@@ -192,6 +206,8 @@ function parseArgs() {
|
|
|
192
206
|
port = parseInt(args[++i], 10);
|
|
193
207
|
} else if (args[i] === '--host' && args[i + 1]) {
|
|
194
208
|
host = args[++i];
|
|
209
|
+
} else if (args[i] === '--log-level' && args[i + 1]) {
|
|
210
|
+
logLevel = args[++i];
|
|
195
211
|
} else {
|
|
196
212
|
filteredArgs.push(args[i]);
|
|
197
213
|
}
|
|
@@ -211,7 +227,7 @@ function parseArgs() {
|
|
|
211
227
|
const { getVersion } = require('./version');
|
|
212
228
|
const version = getVersion();
|
|
213
229
|
|
|
214
|
-
return { port, host, password, useTunnel, persistedTunnel, shell, shellArgs, cwd, defaultShell, version };
|
|
230
|
+
return { port, host, password, useTunnel, persistedTunnel, shell, shellArgs, cwd, defaultShell, version, logLevel };
|
|
215
231
|
}
|
|
216
232
|
|
|
217
233
|
module.exports = { parseArgs, printHelp };
|
package/src/logger.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
const LEVELS = { error: 0, warn: 1, info: 2, debug: 3 };
|
|
2
|
+
const LABELS = { error: 'ERROR', warn: 'WARN', info: 'INFO', debug: 'DEBUG' };
|
|
3
|
+
|
|
4
|
+
let currentLevel = LEVELS.info;
|
|
5
|
+
|
|
6
|
+
function timestamp() {
|
|
7
|
+
return new Date().toLocaleTimeString('en-GB', { hour12: false });
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const log = {
|
|
11
|
+
setLevel(level) {
|
|
12
|
+
const l = LEVELS[level];
|
|
13
|
+
if (l !== undefined) currentLevel = l;
|
|
14
|
+
},
|
|
15
|
+
getLevel() {
|
|
16
|
+
return Object.keys(LEVELS).find(k => LEVELS[k] === currentLevel);
|
|
17
|
+
},
|
|
18
|
+
error(...args) {
|
|
19
|
+
if (currentLevel >= LEVELS.error) console.error(`[${timestamp()}]`, `[${LABELS.error}]`, ...args);
|
|
20
|
+
},
|
|
21
|
+
warn(...args) {
|
|
22
|
+
if (currentLevel >= LEVELS.warn) console.warn(`[${timestamp()}]`, `[${LABELS.warn}]`, ...args);
|
|
23
|
+
},
|
|
24
|
+
info(...args) {
|
|
25
|
+
if (currentLevel >= LEVELS.info) console.log(`[${timestamp()}]`, `[${LABELS.info}]`, ...args);
|
|
26
|
+
},
|
|
27
|
+
debug(...args) {
|
|
28
|
+
if (currentLevel >= LEVELS.debug) console.log(`[${timestamp()}]`, `[${LABELS.debug}]`, ...args);
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
module.exports = log;
|
package/src/routes.js
CHANGED
|
@@ -4,8 +4,10 @@ const fs = require('fs');
|
|
|
4
4
|
const crypto = require('crypto');
|
|
5
5
|
const express = require('express');
|
|
6
6
|
const { detectShells } = require('./shells');
|
|
7
|
+
const log = require('./logger');
|
|
7
8
|
|
|
8
9
|
const PUBLIC_DIR = path.join(__dirname, '..', 'public');
|
|
10
|
+
const uploadedFiles = [];
|
|
9
11
|
|
|
10
12
|
function setupRoutes(app, { auth, sessions, config }) {
|
|
11
13
|
// Serve static files (manifest.json, sw.js, icons, etc.)
|
|
@@ -28,10 +30,10 @@ function setupRoutes(app, { auth, sessions, config }) {
|
|
|
28
30
|
maxAge: 24 * 60 * 60 * 1000,
|
|
29
31
|
secure: false,
|
|
30
32
|
});
|
|
31
|
-
|
|
33
|
+
log.info(`Auth: login success from ${req.ip}`);
|
|
32
34
|
res.json({ ok: true });
|
|
33
35
|
} else {
|
|
34
|
-
|
|
36
|
+
log.warn(`Auth: login failed from ${req.ip}`);
|
|
35
37
|
res.status(401).json({ error: 'wrong password' });
|
|
36
38
|
}
|
|
37
39
|
});
|
|
@@ -55,6 +57,30 @@ function setupRoutes(app, { auth, sessions, config }) {
|
|
|
55
57
|
|
|
56
58
|
app.post('/api/sessions', auth.middleware, (req, res) => {
|
|
57
59
|
const { name, shell, args: shellArgs, cwd, initialCommand, color } = req.body || {};
|
|
60
|
+
|
|
61
|
+
// Validate shell field
|
|
62
|
+
if (shell) {
|
|
63
|
+
const availableShells = detectShells();
|
|
64
|
+
const isValid = availableShells.some(s => s.path === shell || s.cmd === shell);
|
|
65
|
+
if (!isValid) {
|
|
66
|
+
return res.status(400).json({ error: 'Invalid shell' });
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Validate cwd field
|
|
71
|
+
if (cwd) {
|
|
72
|
+
if (!path.isAbsolute(cwd)) {
|
|
73
|
+
return res.status(400).json({ error: 'cwd must be an absolute path' });
|
|
74
|
+
}
|
|
75
|
+
try {
|
|
76
|
+
if (!fs.statSync(cwd).isDirectory()) {
|
|
77
|
+
return res.status(400).json({ error: 'cwd is not a directory' });
|
|
78
|
+
}
|
|
79
|
+
} catch {
|
|
80
|
+
return res.status(400).json({ error: 'cwd does not exist' });
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
58
84
|
const id = sessions.create({
|
|
59
85
|
name: name || `Session ${sessions.sessions.size + 1}`,
|
|
60
86
|
shell: shell || config.defaultShell,
|
|
@@ -96,7 +122,7 @@ function setupRoutes(app, { auth, sessions, config }) {
|
|
|
96
122
|
app.post('/api/upload', auth.middleware, (req, res) => {
|
|
97
123
|
const contentType = req.headers['content-type'] || '';
|
|
98
124
|
if (!contentType.startsWith('image/')) {
|
|
99
|
-
|
|
125
|
+
log.warn(`Upload rejected: invalid content-type "${contentType}"`);
|
|
100
126
|
return res.status(400).json({ error: 'Invalid content type' });
|
|
101
127
|
}
|
|
102
128
|
|
|
@@ -110,7 +136,7 @@ function setupRoutes(app, { auth, sessions, config }) {
|
|
|
110
136
|
size += chunk.length;
|
|
111
137
|
if (size > limit) {
|
|
112
138
|
aborted = true;
|
|
113
|
-
|
|
139
|
+
log.warn(`Upload rejected: file too large (${size} bytes)`);
|
|
114
140
|
res.status(413).json({ error: 'File too large' });
|
|
115
141
|
req.resume(); // drain remaining data
|
|
116
142
|
return;
|
|
@@ -134,12 +160,13 @@ function setupRoutes(app, { auth, sessions, config }) {
|
|
|
134
160
|
const filename = `termbeam-${crypto.randomUUID()}${ext}`;
|
|
135
161
|
const filepath = path.join(os.tmpdir(), filename);
|
|
136
162
|
fs.writeFileSync(filepath, buffer);
|
|
137
|
-
|
|
163
|
+
uploadedFiles.push(filepath);
|
|
164
|
+
log.info(`Upload: ${filename} (${buffer.length} bytes)`);
|
|
138
165
|
res.json({ path: filepath });
|
|
139
166
|
});
|
|
140
167
|
|
|
141
168
|
req.on('error', (err) => {
|
|
142
|
-
|
|
169
|
+
log.error(`Upload error: ${err.message}`);
|
|
143
170
|
res.status(500).json({ error: 'Upload failed' });
|
|
144
171
|
});
|
|
145
172
|
});
|
|
@@ -165,4 +192,17 @@ function setupRoutes(app, { auth, sessions, config }) {
|
|
|
165
192
|
});
|
|
166
193
|
}
|
|
167
194
|
|
|
168
|
-
|
|
195
|
+
function cleanupUploadedFiles() {
|
|
196
|
+
for (const filepath of uploadedFiles) {
|
|
197
|
+
try {
|
|
198
|
+
if (fs.existsSync(filepath)) {
|
|
199
|
+
fs.unlinkSync(filepath);
|
|
200
|
+
}
|
|
201
|
+
} catch (err) {
|
|
202
|
+
log.error(`Failed to cleanup ${filepath}: ${err.message}`);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
uploadedFiles.length = 0;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
module.exports = { setupRoutes, cleanupUploadedFiles };
|
package/src/server.js
CHANGED
|
@@ -10,57 +10,11 @@ const QRCode = require('qrcode');
|
|
|
10
10
|
const { parseArgs } = require('./cli');
|
|
11
11
|
const { createAuth } = require('./auth');
|
|
12
12
|
const { SessionManager } = require('./sessions');
|
|
13
|
-
const { setupRoutes } = require('./routes');
|
|
13
|
+
const { setupRoutes, cleanupUploadedFiles } = require('./routes');
|
|
14
14
|
const { setupWebSocket } = require('./websocket');
|
|
15
15
|
const { startTunnel, cleanupTunnel } = require('./tunnel');
|
|
16
16
|
|
|
17
|
-
// ---
|
|
18
|
-
const config = parseArgs();
|
|
19
|
-
const auth = createAuth(config.password);
|
|
20
|
-
const sessions = new SessionManager();
|
|
21
|
-
|
|
22
|
-
// --- Express ---
|
|
23
|
-
const app = express();
|
|
24
|
-
app.use(express.json());
|
|
25
|
-
app.use(cookieParser());
|
|
26
|
-
app.use((_req, res, next) => {
|
|
27
|
-
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
28
|
-
res.setHeader('X-Frame-Options', 'DENY');
|
|
29
|
-
res.setHeader('Referrer-Policy', 'no-referrer');
|
|
30
|
-
res.setHeader('Cache-Control', 'no-store');
|
|
31
|
-
res.setHeader('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: wss:; font-src 'self' https://cdn.jsdelivr.net");
|
|
32
|
-
next();
|
|
33
|
-
});
|
|
34
|
-
|
|
35
|
-
const server = http.createServer(app);
|
|
36
|
-
const wss = new WebSocketServer({ server, path: '/ws', maxPayload: 1 * 1024 * 1024 });
|
|
37
|
-
|
|
38
|
-
setupRoutes(app, { auth, sessions, config });
|
|
39
|
-
setupWebSocket(wss, { auth, sessions });
|
|
40
|
-
|
|
41
|
-
// --- Lifecycle ---
|
|
42
|
-
let shuttingDown = false;
|
|
43
|
-
function shutdown() {
|
|
44
|
-
if (shuttingDown) return;
|
|
45
|
-
shuttingDown = true;
|
|
46
|
-
console.log('\n[termbeam] Shutting down...');
|
|
47
|
-
sessions.shutdown();
|
|
48
|
-
cleanupTunnel();
|
|
49
|
-
server.close();
|
|
50
|
-
wss.close();
|
|
51
|
-
// Force exit after giving connections time to close
|
|
52
|
-
setTimeout(() => process.exit(0), 500).unref();
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
process.on('SIGINT', shutdown);
|
|
56
|
-
process.on('SIGTERM', shutdown);
|
|
57
|
-
process.on('uncaughtException', (err) => {
|
|
58
|
-
console.error('[termbeam] Uncaught exception:', err.message);
|
|
59
|
-
cleanupTunnel();
|
|
60
|
-
process.exit(1);
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
// --- Start ---
|
|
17
|
+
// --- Helpers ---
|
|
64
18
|
function getLocalIP() {
|
|
65
19
|
const interfaces = os.networkInterfaces();
|
|
66
20
|
for (const name of Object.keys(interfaces)) {
|
|
@@ -71,80 +25,163 @@ function getLocalIP() {
|
|
|
71
25
|
return '127.0.0.1';
|
|
72
26
|
}
|
|
73
27
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
28
|
+
/**
|
|
29
|
+
* Create a TermBeam server instance without starting it.
|
|
30
|
+
* @param {object} [overrides] - Optional overrides
|
|
31
|
+
* @param {object} [overrides.config] - Full config object (skips parseArgs)
|
|
32
|
+
* @returns {{ app, server, wss, sessions, config, auth, start, shutdown }}
|
|
33
|
+
*/
|
|
34
|
+
function createTermBeamServer(overrides = {}) {
|
|
35
|
+
const config = overrides.config || parseArgs();
|
|
36
|
+
const log = require('./logger');
|
|
37
|
+
if (config.logLevel) log.setLevel(config.logLevel);
|
|
38
|
+
const auth = createAuth(config.password);
|
|
39
|
+
const sessions = new SessionManager();
|
|
40
|
+
|
|
41
|
+
// --- Express ---
|
|
42
|
+
const app = express();
|
|
43
|
+
app.use(express.json());
|
|
44
|
+
app.use(cookieParser());
|
|
45
|
+
app.use((_req, res, next) => {
|
|
46
|
+
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
47
|
+
res.setHeader('X-Frame-Options', 'DENY');
|
|
48
|
+
res.setHeader('Referrer-Policy', 'no-referrer');
|
|
49
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
50
|
+
res.setHeader(
|
|
51
|
+
'Content-Security-Policy',
|
|
52
|
+
"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: wss:; font-src 'self' https://cdn.jsdelivr.net",
|
|
53
|
+
);
|
|
54
|
+
next();
|
|
83
55
|
});
|
|
84
56
|
|
|
85
|
-
const
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
)
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
`${lp} ██║ ███████╗██║ ██║██║ ╚═╝ ██║██████╔╝███████╗██║ ██║██║ ╚═╝ ██║${rs}`,
|
|
102
|
-
);
|
|
103
|
-
console.log(
|
|
104
|
-
`${lp} ╚═╝ ╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝╚═════╝ ╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝${rs}`,
|
|
105
|
-
);
|
|
106
|
-
console.log('');
|
|
107
|
-
console.log(` Beam your terminal to any device 📡 v${config.version}`);
|
|
108
|
-
console.log('');
|
|
109
|
-
const isLanReachable = config.host === '0.0.0.0' || config.host === '::' || config.host === ip;
|
|
110
|
-
const gn = '\x1b[38;5;114m'; // green
|
|
111
|
-
const dm = '\x1b[2m'; // dim
|
|
112
|
-
|
|
113
|
-
let publicUrl = null;
|
|
114
|
-
if (config.useTunnel) {
|
|
115
|
-
const tunnel = await startTunnel(config.port, { persisted: config.persistedTunnel });
|
|
116
|
-
if (tunnel) {
|
|
117
|
-
publicUrl = tunnel.url;
|
|
118
|
-
} else {
|
|
119
|
-
console.log(' ⚠️ Tunnel failed to start. Using LAN only.');
|
|
120
|
-
}
|
|
57
|
+
const server = http.createServer(app);
|
|
58
|
+
const wss = new WebSocketServer({ server, path: '/ws', maxPayload: 1 * 1024 * 1024 });
|
|
59
|
+
|
|
60
|
+
setupRoutes(app, { auth, sessions, config });
|
|
61
|
+
setupWebSocket(wss, { auth, sessions });
|
|
62
|
+
|
|
63
|
+
// --- Lifecycle ---
|
|
64
|
+
let shuttingDown = false;
|
|
65
|
+
function shutdown() {
|
|
66
|
+
if (shuttingDown) return;
|
|
67
|
+
shuttingDown = true;
|
|
68
|
+
sessions.shutdown();
|
|
69
|
+
cleanupUploadedFiles();
|
|
70
|
+
cleanupTunnel();
|
|
71
|
+
server.close();
|
|
72
|
+
wss.close();
|
|
121
73
|
}
|
|
122
74
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
75
|
+
function start() {
|
|
76
|
+
return new Promise((resolve) => {
|
|
77
|
+
server.listen(config.port, config.host, async () => {
|
|
78
|
+
const ip = getLocalIP();
|
|
79
|
+
const localUrl = `http://${ip}:${config.port}`;
|
|
80
|
+
|
|
81
|
+
const defaultId = sessions.create({
|
|
82
|
+
name: path.basename(config.cwd),
|
|
83
|
+
shell: config.shell,
|
|
84
|
+
args: config.shellArgs,
|
|
85
|
+
cwd: config.cwd,
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
const lp = '\x1b[38;5;141m'; // light purple
|
|
89
|
+
const rs = '\x1b[0m'; // reset
|
|
90
|
+
console.log('');
|
|
91
|
+
console.log(
|
|
92
|
+
`${lp} ████████╗███████╗██████╗ ███╗ ███╗██████╗ ███████╗ █████╗ ███╗ ███╗${rs}`,
|
|
93
|
+
);
|
|
94
|
+
console.log(
|
|
95
|
+
`${lp} ╚══██╔══╝██╔════╝██╔══██╗████╗ ████║██╔══██╗██╔════╝██╔══██╗████╗ ████║${rs}`,
|
|
96
|
+
);
|
|
97
|
+
console.log(
|
|
98
|
+
`${lp} ██║ █████╗ ██████╔╝██╔████╔██║██████╔╝█████╗ ███████║██╔████╔██║${rs}`,
|
|
99
|
+
);
|
|
100
|
+
console.log(
|
|
101
|
+
`${lp} ██║ ██╔══╝ ██╔══██╗██║╚██╔╝██║██╔══██╗██╔══╝ ██╔══██║██║╚██╔╝██║${rs}`,
|
|
102
|
+
);
|
|
103
|
+
console.log(
|
|
104
|
+
`${lp} ██║ ███████╗██║ ██║██║ ╚═╝ ██║██████╔╝███████╗██║ ██║██║ ╚═╝ ██║${rs}`,
|
|
105
|
+
);
|
|
106
|
+
console.log(
|
|
107
|
+
`${lp} ╚═╝ ╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝╚═════╝ ╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝${rs}`,
|
|
108
|
+
);
|
|
109
|
+
console.log('');
|
|
110
|
+
console.log(` Beam your terminal to any device 📡 v${config.version}`);
|
|
111
|
+
console.log('');
|
|
112
|
+
const isLanReachable =
|
|
113
|
+
config.host === '0.0.0.0' || config.host === '::' || config.host === ip;
|
|
114
|
+
const gn = '\x1b[38;5;114m'; // green
|
|
115
|
+
const dm = '\x1b[2m'; // dim
|
|
116
|
+
|
|
117
|
+
let publicUrl = null;
|
|
118
|
+
if (config.useTunnel) {
|
|
119
|
+
const tunnel = await startTunnel(config.port, { persisted: config.persistedTunnel });
|
|
120
|
+
if (tunnel) {
|
|
121
|
+
publicUrl = tunnel.url;
|
|
122
|
+
} else {
|
|
123
|
+
console.log(' ⚠️ Tunnel failed to start. Using LAN only.');
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
console.log(` Shell: ${config.shell}`);
|
|
128
|
+
console.log(` Session: ${defaultId}`);
|
|
129
|
+
console.log(` Auth: ${config.password ? `${gn}🔒 password${rs}` : '🔓 none'}`);
|
|
130
|
+
console.log('');
|
|
131
|
+
|
|
132
|
+
if (publicUrl) {
|
|
133
|
+
console.log(` 🌐 Public: ${publicUrl}`);
|
|
134
|
+
}
|
|
135
|
+
console.log(` Local: http://localhost:${config.port}`);
|
|
136
|
+
if (isLanReachable) {
|
|
137
|
+
console.log(` LAN: ${localUrl}`);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const qrUrl = publicUrl || (isLanReachable ? localUrl : `http://localhost:${config.port}`);
|
|
141
|
+
console.log('');
|
|
142
|
+
console.log(` ${dm}📋 Clipboard requires HTTPS — use the Public or localhost URL${rs}`);
|
|
143
|
+
console.log('');
|
|
144
|
+
try {
|
|
145
|
+
const qr = await QRCode.toString(qrUrl, { type: 'terminal', small: true });
|
|
146
|
+
console.log(qr);
|
|
147
|
+
} catch {
|
|
148
|
+
/* ignore */
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
console.log(` Scan the QR code or open: ${qrUrl}`);
|
|
152
|
+
if (config.password) console.log(` Password: ${gn}${config.password}${rs}`);
|
|
153
|
+
console.log('');
|
|
154
|
+
|
|
155
|
+
resolve({ url: `http://localhost:${config.port}`, defaultId });
|
|
156
|
+
});
|
|
157
|
+
});
|
|
134
158
|
}
|
|
135
159
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
160
|
+
return { app, server, wss, sessions, config, auth, start, shutdown };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
module.exports = { createTermBeamServer };
|
|
164
|
+
|
|
165
|
+
// Auto-start when run directly (CLI entry point)
|
|
166
|
+
const _entryBase = path.basename(process.argv[1] || '');
|
|
167
|
+
if (require.main === module || _entryBase === 'termbeam' || _entryBase === 'termbeam.js') {
|
|
168
|
+
const instance = createTermBeamServer();
|
|
169
|
+
|
|
170
|
+
process.on('SIGINT', () => {
|
|
171
|
+
console.log('\n[termbeam] Shutting down...');
|
|
172
|
+
instance.shutdown();
|
|
173
|
+
setTimeout(() => process.exit(0), 500).unref();
|
|
174
|
+
});
|
|
175
|
+
process.on('SIGTERM', () => {
|
|
176
|
+
console.log('\n[termbeam] Shutting down...');
|
|
177
|
+
instance.shutdown();
|
|
178
|
+
setTimeout(() => process.exit(0), 500).unref();
|
|
179
|
+
});
|
|
180
|
+
process.on('uncaughtException', (err) => {
|
|
181
|
+
console.error('[termbeam] Uncaught exception:', err.message);
|
|
182
|
+
cleanupTunnel();
|
|
183
|
+
process.exit(1);
|
|
184
|
+
});
|
|
146
185
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
console.log('');
|
|
150
|
-
});
|
|
186
|
+
instance.start();
|
|
187
|
+
}
|
package/src/sessions.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
const crypto = require('crypto');
|
|
2
2
|
const pty = require('node-pty');
|
|
3
|
+
const log = require('./logger');
|
|
3
4
|
|
|
4
5
|
const SESSION_COLORS = [
|
|
5
6
|
'#4a9eff', '#4ade80', '#fbbf24', '#c084fc',
|
|
@@ -12,7 +13,7 @@ class SessionManager {
|
|
|
12
13
|
}
|
|
13
14
|
|
|
14
15
|
create({ name, shell, args = [], cwd, initialCommand = null, color = null }) {
|
|
15
|
-
const id = crypto.randomBytes(
|
|
16
|
+
const id = crypto.randomBytes(16).toString('hex');
|
|
16
17
|
if (!color) {
|
|
17
18
|
color = SESSION_COLORS[this.sessions.size % SESSION_COLORS.length];
|
|
18
19
|
}
|
|
@@ -55,7 +56,7 @@ class SessionManager {
|
|
|
55
56
|
});
|
|
56
57
|
|
|
57
58
|
ptyProcess.onExit(({ exitCode }) => {
|
|
58
|
-
|
|
59
|
+
log.info(`Session "${name}" (${id}) exited (code ${exitCode})`);
|
|
59
60
|
for (const ws of session.clients) {
|
|
60
61
|
if (ws.readyState === 1) ws.send(JSON.stringify({ type: 'exit', code: exitCode }));
|
|
61
62
|
}
|
|
@@ -63,7 +64,7 @@ class SessionManager {
|
|
|
63
64
|
});
|
|
64
65
|
|
|
65
66
|
this.sessions.set(id, session);
|
|
66
|
-
|
|
67
|
+
log.info(`Session "${name}" created (id=${id}, pid=${ptyProcess.pid})`);
|
|
67
68
|
return id;
|
|
68
69
|
}
|
|
69
70
|
|
|
@@ -82,7 +83,7 @@ class SessionManager {
|
|
|
82
83
|
delete(id) {
|
|
83
84
|
const s = this.sessions.get(id);
|
|
84
85
|
if (!s) return false;
|
|
85
|
-
|
|
86
|
+
log.info(`Session "${s.name}" deleted (id=${id})`);
|
|
86
87
|
s.pty.kill();
|
|
87
88
|
return true;
|
|
88
89
|
}
|
package/src/tunnel.js
CHANGED
|
@@ -2,6 +2,7 @@ const { execSync, execFileSync, spawn } = require('child_process');
|
|
|
2
2
|
const path = require('path');
|
|
3
3
|
const fs = require('fs');
|
|
4
4
|
const os = require('os');
|
|
5
|
+
const log = require('./logger');
|
|
5
6
|
|
|
6
7
|
const TUNNEL_CONFIG_DIR = path.join(os.homedir(), '.termbeam');
|
|
7
8
|
const TUNNEL_CONFIG_PATH = path.join(TUNNEL_CONFIG_DIR, 'tunnel.json');
|
|
@@ -55,7 +56,7 @@ function deletePersisted() {
|
|
|
55
56
|
try {
|
|
56
57
|
if (SAFE_ID_RE.test(persisted.tunnelId)) {
|
|
57
58
|
execFileSync(devtunnelCmd, ['delete', persisted.tunnelId, '-f'], { stdio: 'pipe' });
|
|
58
|
-
|
|
59
|
+
log.info(`Deleted persisted tunnel ${persisted.tunnelId}`);
|
|
59
60
|
}
|
|
60
61
|
} catch {}
|
|
61
62
|
try {
|
|
@@ -80,24 +81,24 @@ async function startTunnel(port, options = {}) {
|
|
|
80
81
|
// Check if devtunnel CLI is installed
|
|
81
82
|
const found = findDevtunnel();
|
|
82
83
|
if (!found) {
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
84
|
+
log.error('❌ devtunnel CLI is not installed.');
|
|
85
|
+
log.error('');
|
|
86
|
+
log.error(' The --tunnel flag requires the Azure Dev Tunnels CLI.');
|
|
87
|
+
log.error('');
|
|
88
|
+
log.error(' Install it:');
|
|
89
|
+
log.error(' Windows: winget install Microsoft.devtunnel');
|
|
90
|
+
log.error(' or: Invoke-WebRequest -Uri https://aka.ms/TunnelsCliDownload/win-x64 -OutFile devtunnel.exe');
|
|
91
|
+
log.error(' macOS: brew install --cask devtunnel');
|
|
92
|
+
log.error(' Linux: curl -sL https://aka.ms/DevTunnelCliInstall | bash');
|
|
93
|
+
log.error('');
|
|
94
|
+
log.error(' Then restart your terminal and try again.');
|
|
95
|
+
log.error(' Docs: https://learn.microsoft.com/en-us/azure/developer/dev-tunnels/get-started');
|
|
96
|
+
log.error('');
|
|
96
97
|
return null;
|
|
97
98
|
}
|
|
98
99
|
devtunnelCmd = found;
|
|
99
100
|
|
|
100
|
-
|
|
101
|
+
log.info('Starting devtunnel...');
|
|
101
102
|
try {
|
|
102
103
|
// Ensure user is logged in
|
|
103
104
|
let loggedIn = false;
|
|
@@ -108,8 +109,8 @@ async function startTunnel(port, options = {}) {
|
|
|
108
109
|
} catch {}
|
|
109
110
|
|
|
110
111
|
if (!loggedIn) {
|
|
111
|
-
|
|
112
|
-
|
|
112
|
+
log.info('devtunnel not logged in, launching login...');
|
|
113
|
+
log.info('A browser window will open for authentication.');
|
|
113
114
|
execFileSync(devtunnelCmd, ['user', 'login'], { stdio: 'inherit' });
|
|
114
115
|
}
|
|
115
116
|
|
|
@@ -124,16 +125,16 @@ async function startTunnel(port, options = {}) {
|
|
|
124
125
|
const saved = loadPersistedTunnel();
|
|
125
126
|
if (saved && isTunnelValid(saved.tunnelId)) {
|
|
126
127
|
tunnelId = saved.tunnelId;
|
|
127
|
-
|
|
128
|
+
log.info(`Reusing persisted tunnel ${tunnelId}`);
|
|
128
129
|
} else {
|
|
129
130
|
if (saved) {
|
|
130
|
-
|
|
131
|
+
log.info('Persisted tunnel expired, creating new one');
|
|
131
132
|
}
|
|
132
133
|
const createOut = execFileSync(devtunnelCmd, ['create', '--expiration', '30d', '--json'], { encoding: 'utf-8' });
|
|
133
134
|
const tunnelData = JSON.parse(createOut);
|
|
134
135
|
tunnelId = tunnelData.tunnel.tunnelId;
|
|
135
136
|
savePersistedTunnel(tunnelId);
|
|
136
|
-
|
|
137
|
+
log.info(`Created new persisted tunnel ${tunnelId}`);
|
|
137
138
|
}
|
|
138
139
|
} else {
|
|
139
140
|
tunnelMode = 'ephemeral';
|
|
@@ -142,7 +143,7 @@ async function startTunnel(port, options = {}) {
|
|
|
142
143
|
const createOut = execFileSync(devtunnelCmd, ['create', '--expiration', '1d', '--json'], { encoding: 'utf-8' });
|
|
143
144
|
const tunnelData = JSON.parse(createOut);
|
|
144
145
|
tunnelId = tunnelData.tunnel.tunnelId;
|
|
145
|
-
|
|
146
|
+
log.info(`Created ephemeral tunnel ${tunnelId}`);
|
|
146
147
|
}
|
|
147
148
|
|
|
148
149
|
// Idempotent port and access setup
|
|
@@ -174,13 +175,13 @@ async function startTunnel(port, options = {}) {
|
|
|
174
175
|
output += data.toString();
|
|
175
176
|
});
|
|
176
177
|
hostProc.on('error', (err) => {
|
|
177
|
-
|
|
178
|
+
log.error(`Tunnel process error: ${err.message}`);
|
|
178
179
|
clearTimeout(timeout);
|
|
179
180
|
resolve(null);
|
|
180
181
|
});
|
|
181
182
|
});
|
|
182
183
|
} catch (e) {
|
|
183
|
-
|
|
184
|
+
log.error(`Tunnel error: ${e.message}`);
|
|
184
185
|
return null;
|
|
185
186
|
}
|
|
186
187
|
}
|
|
@@ -205,11 +206,11 @@ function cleanupTunnel() {
|
|
|
205
206
|
if (id) {
|
|
206
207
|
tunnelId = null;
|
|
207
208
|
if (isPersisted) {
|
|
208
|
-
|
|
209
|
+
log.info('Tunnel host stopped (tunnel preserved for reuse)');
|
|
209
210
|
} else {
|
|
210
211
|
try {
|
|
211
212
|
execFileSync(devtunnelCmd, ['delete', id, '-f'], { stdio: 'pipe', timeout: 10000 });
|
|
212
|
-
|
|
213
|
+
log.info('Tunnel cleaned up');
|
|
213
214
|
} catch {
|
|
214
215
|
/* best effort — tunnel will expire on its own */
|
|
215
216
|
}
|
package/src/websocket.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
const log = require('./logger');
|
|
2
|
+
|
|
1
3
|
function recalcPtySize(session) {
|
|
2
4
|
let minCols = Infinity;
|
|
3
5
|
let minRows = Infinity;
|
|
@@ -16,6 +18,23 @@ function recalcPtySize(session) {
|
|
|
16
18
|
|
|
17
19
|
function setupWebSocket(wss, { auth, sessions }) {
|
|
18
20
|
wss.on('connection', (ws, req) => {
|
|
21
|
+
const origin = req.headers.origin;
|
|
22
|
+
if (origin) {
|
|
23
|
+
try {
|
|
24
|
+
const originHost = new URL(origin).hostname;
|
|
25
|
+
const reqHost = (req.headers.host || '').split(':')[0];
|
|
26
|
+
if (originHost !== reqHost && originHost !== 'localhost' && reqHost !== 'localhost') {
|
|
27
|
+
log.warn(`WS: rejected cross-origin connection from ${origin}`);
|
|
28
|
+
ws.close(1008, 'Origin not allowed');
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
} catch {
|
|
32
|
+
log.warn(`WS: rejected invalid origin: ${origin}`);
|
|
33
|
+
ws.close(1008, 'Invalid origin');
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
19
38
|
let authenticated = !auth.password;
|
|
20
39
|
let attached = null;
|
|
21
40
|
|
|
@@ -35,9 +54,9 @@ function setupWebSocket(wss, { auth, sessions }) {
|
|
|
35
54
|
if (msg.password === auth.password || auth.validateToken(msg.token)) {
|
|
36
55
|
authenticated = true;
|
|
37
56
|
ws.send(JSON.stringify({ type: 'auth_ok' }));
|
|
38
|
-
|
|
57
|
+
log.info('WS: auth success');
|
|
39
58
|
} else {
|
|
40
|
-
|
|
59
|
+
log.warn('WS: auth failed');
|
|
41
60
|
ws.send(JSON.stringify({ type: 'error', message: 'Unauthorized' }));
|
|
42
61
|
ws.close();
|
|
43
62
|
}
|
|
@@ -54,7 +73,7 @@ function setupWebSocket(wss, { auth, sessions }) {
|
|
|
54
73
|
const session = sessions.get(msg.sessionId);
|
|
55
74
|
if (!session) {
|
|
56
75
|
ws.send(JSON.stringify({ type: 'error', message: 'Session not found' }));
|
|
57
|
-
|
|
76
|
+
log.warn(`WS: attach failed — session ${msg.sessionId} not found`);
|
|
58
77
|
return;
|
|
59
78
|
}
|
|
60
79
|
attached = session;
|
|
@@ -63,7 +82,7 @@ function setupWebSocket(wss, { auth, sessions }) {
|
|
|
63
82
|
ws.send(JSON.stringify({ type: 'output', data: session.scrollbackBuf }));
|
|
64
83
|
}
|
|
65
84
|
ws.send(JSON.stringify({ type: 'attached', sessionId: msg.sessionId }));
|
|
66
|
-
|
|
85
|
+
log.info(`Client attached to session ${msg.sessionId}`);
|
|
67
86
|
return;
|
|
68
87
|
}
|
|
69
88
|
|
|
@@ -80,7 +99,7 @@ function setupWebSocket(wss, { auth, sessions }) {
|
|
|
80
99
|
}
|
|
81
100
|
}
|
|
82
101
|
} catch (err) {
|
|
83
|
-
|
|
102
|
+
log.warn(`WS: dropped unparseable message: ${err.message}`);
|
|
84
103
|
}
|
|
85
104
|
});
|
|
86
105
|
|
|
@@ -88,7 +107,7 @@ function setupWebSocket(wss, { auth, sessions }) {
|
|
|
88
107
|
if (attached) {
|
|
89
108
|
attached.clients.delete(ws);
|
|
90
109
|
recalcPtySize(attached);
|
|
91
|
-
|
|
110
|
+
log.info('Client detached');
|
|
92
111
|
}
|
|
93
112
|
});
|
|
94
113
|
});
|