termbeam 1.7.0 → 1.8.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 +6 -0
- package/bin/termbeam.js +27 -12
- package/package.json +5 -1
- package/src/cli.js +6 -0
- package/src/interactive.js +269 -0
- package/src/prompts.js +146 -0
- package/src/routes.js +58 -20
- package/src/server.js +2 -2
- package/src/service.js +17 -133
- package/src/sessions.js +21 -2
- package/src/tunnel.js +2 -2
package/README.md
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
[](https://www.npmjs.com/package/termbeam)
|
|
9
9
|
[](https://github.com/dorlugasigal/TermBeam/actions/workflows/ci.yml)
|
|
10
10
|
[](https://github.com/dorlugasigal/TermBeam/actions/workflows/ci.yml)
|
|
11
|
+
[](https://securityscorecards.dev/viewer/?uri=github.com/dorlugasigal/TermBeam)
|
|
11
12
|
[](https://nodejs.org/)
|
|
12
13
|
[](https://opensource.org/licenses/MIT)
|
|
13
14
|
|
|
@@ -46,6 +47,8 @@ termbeam
|
|
|
46
47
|
|
|
47
48
|
Scan the QR code printed in your terminal, or open the URL on any device.
|
|
48
49
|
|
|
50
|
+
> **First time?** Run `termbeam -i` for a guided setup wizard that walks you through password, port, and access mode.
|
|
51
|
+
|
|
49
52
|
### Secure by default
|
|
50
53
|
|
|
51
54
|
TermBeam starts with a tunnel and auto-generated password out of the box — just run `termbeam` and scan the QR code.
|
|
@@ -55,6 +58,7 @@ termbeam # tunnel + auto-password (default)
|
|
|
55
58
|
termbeam --password mysecret # use a specific password
|
|
56
59
|
termbeam --no-tunnel # LAN-only (no tunnel)
|
|
57
60
|
termbeam --no-password # disable password protection
|
|
61
|
+
termbeam -i # interactive setup wizard
|
|
58
62
|
```
|
|
59
63
|
|
|
60
64
|
## Remote Access
|
|
@@ -85,6 +89,7 @@ termbeam [shell] [args...] # start with a specific shell (default: auto-d
|
|
|
85
89
|
termbeam --port 8080 # custom port (default: 3456)
|
|
86
90
|
termbeam --host 0.0.0.0 # allow LAN access (default: 127.0.0.1)
|
|
87
91
|
termbeam --lan # shortcut for --host 0.0.0.0
|
|
92
|
+
termbeam -i # interactive setup wizard
|
|
88
93
|
termbeam service install # interactive PM2 service setup wizard
|
|
89
94
|
termbeam service uninstall # stop & remove PM2 service
|
|
90
95
|
termbeam service status # show PM2 service status
|
|
@@ -105,6 +110,7 @@ termbeam service restart # restart PM2 service
|
|
|
105
110
|
| `--host <addr>` | Bind address | `127.0.0.1` |
|
|
106
111
|
| `--lan` | Bind to all interfaces (LAN access) | Off |
|
|
107
112
|
| `--log-level <level>` | Log verbosity (error/warn/info/debug) | `info` |
|
|
113
|
+
| `-i, --interactive` | Interactive setup wizard (guided configuration) | Off |
|
|
108
114
|
| `-h, --help` | Show help | — |
|
|
109
115
|
| `-v, --version` | Show version | — |
|
|
110
116
|
|
package/bin/termbeam.js
CHANGED
|
@@ -10,18 +10,33 @@ if (subcommand === 'service') {
|
|
|
10
10
|
});
|
|
11
11
|
} else {
|
|
12
12
|
const { createTermBeamServer } = require('../src/server.js');
|
|
13
|
-
const
|
|
13
|
+
const { parseArgs } = require('../src/cli');
|
|
14
|
+
const { runInteractiveSetup } = require('../src/interactive');
|
|
14
15
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
16
|
+
async function main() {
|
|
17
|
+
const baseConfig = parseArgs();
|
|
18
|
+
let config;
|
|
19
|
+
if (baseConfig.interactive) {
|
|
20
|
+
config = await runInteractiveSetup(baseConfig);
|
|
21
|
+
}
|
|
22
|
+
const instance = createTermBeamServer(config ? { config } : undefined);
|
|
23
|
+
|
|
24
|
+
process.on('SIGINT', () => {
|
|
25
|
+
console.log('\n[termbeam] Shutting down...');
|
|
26
|
+
instance.shutdown();
|
|
27
|
+
setTimeout(() => process.exit(0), 500).unref();
|
|
28
|
+
});
|
|
29
|
+
process.on('SIGTERM', () => {
|
|
30
|
+
console.log('\n[termbeam] Shutting down...');
|
|
31
|
+
instance.shutdown();
|
|
32
|
+
setTimeout(() => process.exit(0), 500).unref();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
instance.start();
|
|
36
|
+
}
|
|
25
37
|
|
|
26
|
-
|
|
38
|
+
main().catch((err) => {
|
|
39
|
+
console.error(err.message);
|
|
40
|
+
process.exit(1);
|
|
41
|
+
});
|
|
27
42
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "termbeam",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.8.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": {
|
|
@@ -68,13 +68,17 @@
|
|
|
68
68
|
"dependencies": {
|
|
69
69
|
"cookie-parser": "^1.4.7",
|
|
70
70
|
"express": "^5.2.1",
|
|
71
|
+
"express-rate-limit": "^8.2.1",
|
|
71
72
|
"node-pty": "^1.1.0",
|
|
72
73
|
"qrcode": "^1.5.4",
|
|
73
74
|
"ws": "^8.19.0"
|
|
74
75
|
},
|
|
75
76
|
"devDependencies": {
|
|
77
|
+
"@eslint/js": "^9.39.3",
|
|
76
78
|
"@playwright/test": "^1.58.2",
|
|
77
79
|
"c8": "^11.0.0",
|
|
80
|
+
"eslint": "^10.0.2",
|
|
81
|
+
"eslint-plugin-security": "^4.0.0",
|
|
78
82
|
"husky": "^9.1.7",
|
|
79
83
|
"lint-staged": "^16.2.7",
|
|
80
84
|
"prettier": "^3.8.1"
|
package/src/cli.js
CHANGED
|
@@ -31,6 +31,7 @@ Options:
|
|
|
31
31
|
--host <addr> Bind address (default: 127.0.0.1)
|
|
32
32
|
--lan Bind to 0.0.0.0 (allow LAN access, default: localhost only)
|
|
33
33
|
--log-level <level> Set log verbosity: error, warn, info, debug (default: info)
|
|
34
|
+
-i, --interactive Interactive setup wizard (guided configuration)
|
|
34
35
|
-h, --help Show this help
|
|
35
36
|
-v, --version Show version
|
|
36
37
|
|
|
@@ -47,6 +48,7 @@ Examples:
|
|
|
47
48
|
termbeam --password secret Start with specific password
|
|
48
49
|
termbeam --persisted-tunnel Stable tunnel URL across restarts
|
|
49
50
|
termbeam /bin/bash Use bash instead of default shell
|
|
51
|
+
termbeam --interactive Guided setup wizard
|
|
50
52
|
termbeam service install Set up as background service (PM2)
|
|
51
53
|
|
|
52
54
|
Environment:
|
|
@@ -241,6 +243,7 @@ function parseArgs() {
|
|
|
241
243
|
let noTunnel = false;
|
|
242
244
|
let persistedTunnel = false;
|
|
243
245
|
let publicTunnel = false;
|
|
246
|
+
let interactive = false;
|
|
244
247
|
let explicitPassword = !!password;
|
|
245
248
|
|
|
246
249
|
const args = process.argv.slice(2);
|
|
@@ -281,6 +284,8 @@ function parseArgs() {
|
|
|
281
284
|
host = '0.0.0.0';
|
|
282
285
|
} else if (args[i] === '--host' && args[i + 1]) {
|
|
283
286
|
host = args[++i];
|
|
287
|
+
} else if (args[i] === '--interactive' || (args[i] === '-i' && filteredArgs.length === 0)) {
|
|
288
|
+
interactive = true;
|
|
284
289
|
} else if (args[i] === '--log-level' && args[i + 1]) {
|
|
285
290
|
logLevel = args[++i];
|
|
286
291
|
} else {
|
|
@@ -335,6 +340,7 @@ function parseArgs() {
|
|
|
335
340
|
defaultShell,
|
|
336
341
|
version,
|
|
337
342
|
logLevel,
|
|
343
|
+
interactive,
|
|
338
344
|
};
|
|
339
345
|
}
|
|
340
346
|
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
const crypto = require('crypto');
|
|
2
|
+
const {
|
|
3
|
+
green,
|
|
4
|
+
yellow,
|
|
5
|
+
red,
|
|
6
|
+
cyan,
|
|
7
|
+
bold,
|
|
8
|
+
dim,
|
|
9
|
+
ask,
|
|
10
|
+
choose,
|
|
11
|
+
confirm,
|
|
12
|
+
createRL,
|
|
13
|
+
} = require('./prompts');
|
|
14
|
+
|
|
15
|
+
// ── Interactive Setup Wizard ─────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
async function runInteractiveSetup(baseConfig) {
|
|
18
|
+
// Enter alternate screen buffer for a clean wizard (like vim/htop)
|
|
19
|
+
process.stdout.write('\x1b[?1049h');
|
|
20
|
+
const exitAltScreen = () => process.stdout.write('\x1b[?1049l');
|
|
21
|
+
process.on('exit', exitAltScreen);
|
|
22
|
+
|
|
23
|
+
const rl = createRL();
|
|
24
|
+
|
|
25
|
+
const steps = ['Password', 'Port', 'Access', 'Log level', 'Confirm'];
|
|
26
|
+
const decisions = [];
|
|
27
|
+
|
|
28
|
+
function showProgress(stepIndex) {
|
|
29
|
+
process.stdout.write('\x1b[2J\x1b[H');
|
|
30
|
+
|
|
31
|
+
console.log(bold('🚀 TermBeam Interactive Setup'));
|
|
32
|
+
console.log('');
|
|
33
|
+
const total = steps.length;
|
|
34
|
+
const filled = stepIndex + 1;
|
|
35
|
+
const bar = steps
|
|
36
|
+
.map((s, i) => {
|
|
37
|
+
if (i < stepIndex) return green('●');
|
|
38
|
+
if (i === stepIndex) return cyan('●');
|
|
39
|
+
return dim('○');
|
|
40
|
+
})
|
|
41
|
+
.join(dim(' ─ '));
|
|
42
|
+
console.log(`${dim(`Step ${filled}/${total}`)} ${bar} ${cyan(steps[stepIndex])}`);
|
|
43
|
+
|
|
44
|
+
if (decisions.length > 0) {
|
|
45
|
+
console.log('');
|
|
46
|
+
for (const { label, value } of decisions) {
|
|
47
|
+
console.log(` ${dim(label + ':')} ${value}`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Build config from base
|
|
53
|
+
const config = {
|
|
54
|
+
port: baseConfig.port,
|
|
55
|
+
host: baseConfig.host,
|
|
56
|
+
password: baseConfig.password,
|
|
57
|
+
useTunnel: baseConfig.useTunnel,
|
|
58
|
+
persistedTunnel: baseConfig.persistedTunnel,
|
|
59
|
+
publicTunnel: baseConfig.publicTunnel,
|
|
60
|
+
shell: baseConfig.shell,
|
|
61
|
+
shellArgs: baseConfig.shellArgs,
|
|
62
|
+
cwd: baseConfig.cwd,
|
|
63
|
+
defaultShell: baseConfig.defaultShell,
|
|
64
|
+
version: baseConfig.version,
|
|
65
|
+
logLevel: baseConfig.logLevel,
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
// Step 1: Password
|
|
69
|
+
showProgress(0);
|
|
70
|
+
const pwChoice = await choose(rl, 'Password authentication:', [
|
|
71
|
+
{
|
|
72
|
+
label: 'Auto-generate',
|
|
73
|
+
hint: 'Random password, shown on screen and embedded in the QR code',
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
label: 'Custom password',
|
|
77
|
+
hint: 'You type a password to use for this session',
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
label: 'No password',
|
|
81
|
+
hint: '⚠ No authentication — anyone who can reach the server gets shell access',
|
|
82
|
+
warn: true,
|
|
83
|
+
},
|
|
84
|
+
]);
|
|
85
|
+
let passwordMode = 'auto';
|
|
86
|
+
if (pwChoice.index === 0) {
|
|
87
|
+
config.password = crypto.randomBytes(16).toString('base64url');
|
|
88
|
+
process.stdout.write(dim(` Generated password: ${config.password}`) + '\n');
|
|
89
|
+
} else if (pwChoice.index === 1) {
|
|
90
|
+
passwordMode = 'custom';
|
|
91
|
+
config.password = await ask(rl, 'Enter password:');
|
|
92
|
+
while (!config.password) {
|
|
93
|
+
console.log(red(' Password cannot be empty.'));
|
|
94
|
+
config.password = await ask(rl, 'Enter password:');
|
|
95
|
+
}
|
|
96
|
+
} else {
|
|
97
|
+
passwordMode = 'none';
|
|
98
|
+
config.password = null;
|
|
99
|
+
}
|
|
100
|
+
decisions.push({
|
|
101
|
+
label: 'Password',
|
|
102
|
+
value: config.password === null ? yellow('disabled') : '••••••••',
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
// Step 2: Port
|
|
106
|
+
showProgress(1);
|
|
107
|
+
const portStr = await ask(rl, 'Port:', String(config.port));
|
|
108
|
+
const portNum = parseInt(portStr, 10);
|
|
109
|
+
config.port = portNum >= 1 && portNum <= 65535 ? portNum : 3456;
|
|
110
|
+
decisions.push({ label: 'Port', value: String(config.port) });
|
|
111
|
+
|
|
112
|
+
// Step 3: Access mode
|
|
113
|
+
showProgress(2);
|
|
114
|
+
const accessChoice = await choose(rl, 'How will you connect to TermBeam?', [
|
|
115
|
+
{
|
|
116
|
+
label: 'DevTunnel (internet)',
|
|
117
|
+
hint: 'HTTPS tunnel — accessible from any network, secured with your Microsoft account',
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
label: 'LAN',
|
|
121
|
+
hint: 'Binds to 0.0.0.0 — accessible from devices on the same network',
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
label: 'Localhost only',
|
|
125
|
+
hint: 'Binds to 127.0.0.1 — only this machine can connect',
|
|
126
|
+
},
|
|
127
|
+
]);
|
|
128
|
+
|
|
129
|
+
if (accessChoice.index === 0) {
|
|
130
|
+
// DevTunnel mode
|
|
131
|
+
config.host = '127.0.0.1';
|
|
132
|
+
config.useTunnel = true;
|
|
133
|
+
|
|
134
|
+
// Sub-question: tunnel persistence
|
|
135
|
+
showProgress(2);
|
|
136
|
+
const persistChoice = await choose(rl, 'Tunnel persistence:', [
|
|
137
|
+
{
|
|
138
|
+
label: 'Ephemeral',
|
|
139
|
+
hint: 'New URL each run, automatically deleted when TermBeam exits',
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
label: 'Persisted',
|
|
143
|
+
hint: 'Stable URL that survives restarts (expires after 30 days idle)',
|
|
144
|
+
},
|
|
145
|
+
]);
|
|
146
|
+
config.persistedTunnel = persistChoice.index === 1;
|
|
147
|
+
|
|
148
|
+
// Sub-question: access level
|
|
149
|
+
showProgress(2);
|
|
150
|
+
const publicChoice = await choose(rl, 'Tunnel access:', [
|
|
151
|
+
{
|
|
152
|
+
label: 'Private (owner-only)',
|
|
153
|
+
hint: 'Only the Microsoft account that created the tunnel can access it',
|
|
154
|
+
},
|
|
155
|
+
{
|
|
156
|
+
label: 'Public',
|
|
157
|
+
hint: '🚨 No Microsoft login — anyone with the URL can reach your terminal',
|
|
158
|
+
danger: true,
|
|
159
|
+
},
|
|
160
|
+
]);
|
|
161
|
+
config.publicTunnel = publicChoice.index === 1;
|
|
162
|
+
|
|
163
|
+
// Auto-generate password if public tunnel with no password
|
|
164
|
+
if (config.publicTunnel && !config.password) {
|
|
165
|
+
console.log(yellow(' ⚠ Public tunnels require password authentication.'));
|
|
166
|
+
config.password = crypto.randomBytes(16).toString('base64url');
|
|
167
|
+
process.stdout.write(dim(` Auto-generated password: ${config.password}`) + '\n');
|
|
168
|
+
passwordMode = 'auto';
|
|
169
|
+
// Update the password decision
|
|
170
|
+
decisions[0] = { label: 'Password', value: '••••••••' };
|
|
171
|
+
}
|
|
172
|
+
} else if (accessChoice.index === 1) {
|
|
173
|
+
// LAN mode
|
|
174
|
+
config.host = '0.0.0.0';
|
|
175
|
+
config.useTunnel = false;
|
|
176
|
+
config.persistedTunnel = false;
|
|
177
|
+
config.publicTunnel = false;
|
|
178
|
+
} else {
|
|
179
|
+
// Localhost only
|
|
180
|
+
config.host = '127.0.0.1';
|
|
181
|
+
config.useTunnel = false;
|
|
182
|
+
config.persistedTunnel = false;
|
|
183
|
+
config.publicTunnel = false;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const accessLabel = !config.useTunnel
|
|
187
|
+
? config.host === '0.0.0.0'
|
|
188
|
+
? 'LAN (0.0.0.0)'
|
|
189
|
+
: 'Localhost only'
|
|
190
|
+
: config.publicTunnel
|
|
191
|
+
? 'DevTunnel (public)'
|
|
192
|
+
: 'DevTunnel (private)';
|
|
193
|
+
decisions.push({ label: 'Access', value: accessLabel });
|
|
194
|
+
|
|
195
|
+
// Step 4: Log level
|
|
196
|
+
showProgress(3);
|
|
197
|
+
const logChoice = await choose(
|
|
198
|
+
rl,
|
|
199
|
+
'Log level:',
|
|
200
|
+
[
|
|
201
|
+
{ label: 'info', hint: 'Logs startup, connections, sessions, and errors (default)' },
|
|
202
|
+
{ label: 'debug', hint: 'Includes all info logs plus WebSocket frames and internal state' },
|
|
203
|
+
{ label: 'warn', hint: 'Only logs warnings and errors' },
|
|
204
|
+
{ label: 'error', hint: 'Only logs critical errors' },
|
|
205
|
+
],
|
|
206
|
+
0,
|
|
207
|
+
);
|
|
208
|
+
config.logLevel = logChoice.value;
|
|
209
|
+
decisions.push({ label: 'Log level', value: config.logLevel });
|
|
210
|
+
|
|
211
|
+
// Step 5: Confirmation
|
|
212
|
+
showProgress(4);
|
|
213
|
+
console.log(bold('\n── Configuration Summary ──────────────────'));
|
|
214
|
+
console.log(
|
|
215
|
+
` Password: ${config.password === null ? yellow('disabled') : cyan('••••••••')}`,
|
|
216
|
+
);
|
|
217
|
+
console.log(` Port: ${cyan(String(config.port))}`);
|
|
218
|
+
console.log(
|
|
219
|
+
` Host: ${cyan(config.host === '0.0.0.0' ? '0.0.0.0 (LAN)' : config.host)}`,
|
|
220
|
+
);
|
|
221
|
+
console.log(` Tunnel: ${config.useTunnel ? cyan('enabled') : yellow('disabled')}`);
|
|
222
|
+
if (config.useTunnel) {
|
|
223
|
+
console.log(` Persisted: ${config.persistedTunnel ? cyan('yes') : dim('no')}`);
|
|
224
|
+
console.log(` Public: ${config.publicTunnel ? yellow('yes') : dim('no')}`);
|
|
225
|
+
}
|
|
226
|
+
console.log(` Shell: ${cyan(config.shell || 'default')}`);
|
|
227
|
+
console.log(` Directory: ${cyan(config.cwd)}`);
|
|
228
|
+
console.log(` Log level: ${cyan(config.logLevel)}`);
|
|
229
|
+
console.log(dim('─'.repeat(44)));
|
|
230
|
+
|
|
231
|
+
// Build the equivalent CLI command
|
|
232
|
+
const cmdParts = ['termbeam'];
|
|
233
|
+
if (passwordMode === 'none') {
|
|
234
|
+
cmdParts.push('--no-password');
|
|
235
|
+
} else if (passwordMode === 'custom') {
|
|
236
|
+
cmdParts.push('--password', '"<your-password>"');
|
|
237
|
+
}
|
|
238
|
+
// auto-generate is the default — no flag needed
|
|
239
|
+
if (config.port !== 3456) cmdParts.push('--port', String(config.port));
|
|
240
|
+
if (!config.useTunnel) {
|
|
241
|
+
cmdParts.push('--no-tunnel');
|
|
242
|
+
if (config.host === '0.0.0.0') cmdParts.push('--lan');
|
|
243
|
+
} else {
|
|
244
|
+
if (config.persistedTunnel) cmdParts.push('--persisted-tunnel');
|
|
245
|
+
if (config.publicTunnel) cmdParts.push('--public');
|
|
246
|
+
}
|
|
247
|
+
if (config.logLevel !== 'info') cmdParts.push('--log-level', config.logLevel);
|
|
248
|
+
const cliCommand = cmdParts.join(' ');
|
|
249
|
+
|
|
250
|
+
console.log('');
|
|
251
|
+
console.log(dim(' To reuse this configuration without the wizard:'));
|
|
252
|
+
console.log(` ${cyan(cliCommand)}`);
|
|
253
|
+
|
|
254
|
+
const proceed = await confirm(rl, '\nStart TermBeam with this configuration?', true);
|
|
255
|
+
rl.close();
|
|
256
|
+
|
|
257
|
+
// Exit alternate screen — return to normal terminal
|
|
258
|
+
exitAltScreen();
|
|
259
|
+
process.removeListener('exit', exitAltScreen);
|
|
260
|
+
|
|
261
|
+
if (!proceed) {
|
|
262
|
+
console.log(dim('Cancelled.'));
|
|
263
|
+
process.exit(0);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
return config;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
module.exports = { runInteractiveSetup };
|
package/src/prompts.js
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
const readline = require('readline');
|
|
2
|
+
|
|
3
|
+
// ── Color helpers ────────────────────────────────────────────────────────────
|
|
4
|
+
|
|
5
|
+
function color(code, text) {
|
|
6
|
+
return `\x1b[${code}m${text}\x1b[0m`;
|
|
7
|
+
}
|
|
8
|
+
const green = (t) => color('32', t);
|
|
9
|
+
const yellow = (t) => color('33', t);
|
|
10
|
+
const red = (t) => color('31', t);
|
|
11
|
+
const cyan = (t) => color('36', t);
|
|
12
|
+
const bold = (t) => color('1', t);
|
|
13
|
+
const dim = (t) => color('2', t);
|
|
14
|
+
|
|
15
|
+
// ── Interactive prompts ──────────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Prompt the user with a question. Returns the trimmed answer.
|
|
19
|
+
* If `defaultValue` is provided, it's shown in brackets and used when the user presses Enter.
|
|
20
|
+
*/
|
|
21
|
+
function ask(rl, question, defaultValue) {
|
|
22
|
+
const suffix = defaultValue != null ? ` ${dim(`[${defaultValue}]`)} ` : ' '; // eslint-disable-line eqeqeq
|
|
23
|
+
return new Promise((resolve) => {
|
|
24
|
+
rl.question(`${question}${suffix}`, (answer) => {
|
|
25
|
+
const trimmed = answer.trim();
|
|
26
|
+
resolve(trimmed || (defaultValue != null ? String(defaultValue) : '')); // eslint-disable-line eqeqeq
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Prompt the user with a list of choices using arrow keys.
|
|
33
|
+
* Each choice can be a string or { label, hint } object.
|
|
34
|
+
* Up/Down to move, Enter to select. Returns the chosen value.
|
|
35
|
+
*/
|
|
36
|
+
function choose(rl, question, choices, defaultIndex = 0) {
|
|
37
|
+
// Normalize choices to { label, hint } objects
|
|
38
|
+
const items = choices.map((c) => (typeof c === 'string' ? { label: c, hint: '' } : c));
|
|
39
|
+
|
|
40
|
+
return new Promise((resolve) => {
|
|
41
|
+
let selected = defaultIndex;
|
|
42
|
+
|
|
43
|
+
function lineCount() {
|
|
44
|
+
return items.reduce((n, item) => n + 1 + (item.hint ? 1 : 0), 0);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function render(clear) {
|
|
48
|
+
if (clear) {
|
|
49
|
+
process.stdout.write(`\x1b[${lineCount()}A\r\x1b[J`);
|
|
50
|
+
}
|
|
51
|
+
items.forEach((item, i) => {
|
|
52
|
+
const marker = i === selected ? cyan('→') : ' ';
|
|
53
|
+
const label = i === selected ? bold(item.label) : item.label;
|
|
54
|
+
process.stdout.write(` ${marker} ${label}\n`);
|
|
55
|
+
if (item.hint) {
|
|
56
|
+
const hintText = item.danger
|
|
57
|
+
? red(item.hint)
|
|
58
|
+
: item.warn
|
|
59
|
+
? yellow(item.hint)
|
|
60
|
+
: dim(item.hint);
|
|
61
|
+
process.stdout.write(` ${hintText}\n`);
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
process.stdout.write(dim(' ↑/↓ to move, Enter to select'));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
rl.pause();
|
|
68
|
+
console.log(`\n${question}`);
|
|
69
|
+
render(false);
|
|
70
|
+
|
|
71
|
+
const wasRaw = process.stdin.isRaw;
|
|
72
|
+
if (process.stdin.isTTY) {
|
|
73
|
+
process.stdin.setRawMode(true);
|
|
74
|
+
}
|
|
75
|
+
process.stdin.resume();
|
|
76
|
+
|
|
77
|
+
function onKey(buf) {
|
|
78
|
+
const key = buf.toString();
|
|
79
|
+
|
|
80
|
+
if (key === '\x1b[A' || key === 'k') {
|
|
81
|
+
selected = (selected - 1 + items.length) % items.length;
|
|
82
|
+
render(true);
|
|
83
|
+
} else if (key === '\x1b[B' || key === 'j') {
|
|
84
|
+
selected = (selected + 1) % items.length;
|
|
85
|
+
render(true);
|
|
86
|
+
} else if (key === '\r' || key === '\n') {
|
|
87
|
+
cleanup();
|
|
88
|
+
process.stdout.write('\r\x1b[K\n');
|
|
89
|
+
console.log(dim(` Selected: ${items[selected].label}`));
|
|
90
|
+
resolve({ index: selected, value: items[selected].label });
|
|
91
|
+
} else if (key === '\x03') {
|
|
92
|
+
cleanup();
|
|
93
|
+
process.stdout.write('\x1b[?1049l');
|
|
94
|
+
process.exit(0);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function cleanup() {
|
|
99
|
+
process.stdin.removeListener('data', onKey);
|
|
100
|
+
if (process.stdin.isTTY) {
|
|
101
|
+
process.stdin.setRawMode(wasRaw || false);
|
|
102
|
+
}
|
|
103
|
+
process.stdin.pause();
|
|
104
|
+
rl.resume();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
process.stdin.on('data', onKey);
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Ask a yes/no question. Returns boolean.
|
|
113
|
+
*/
|
|
114
|
+
function confirm(rl, question, defaultYes = true) {
|
|
115
|
+
const hint = defaultYes ? 'Y/n' : 'y/N';
|
|
116
|
+
return new Promise((resolve) => {
|
|
117
|
+
rl.question(`${question} ${dim(`[${hint}]`)} `, (answer) => {
|
|
118
|
+
const a = answer.trim().toLowerCase();
|
|
119
|
+
if (a === '') resolve(defaultYes);
|
|
120
|
+
else resolve(a === 'y' || a === 'yes');
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ── readline factory ─────────────────────────────────────────────────────────
|
|
126
|
+
|
|
127
|
+
function createRL() {
|
|
128
|
+
return readline.createInterface({
|
|
129
|
+
input: process.stdin,
|
|
130
|
+
output: process.stdout,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
module.exports = {
|
|
135
|
+
color,
|
|
136
|
+
green,
|
|
137
|
+
yellow,
|
|
138
|
+
red,
|
|
139
|
+
cyan,
|
|
140
|
+
bold,
|
|
141
|
+
dim,
|
|
142
|
+
ask,
|
|
143
|
+
choose,
|
|
144
|
+
confirm,
|
|
145
|
+
createRL,
|
|
146
|
+
};
|
package/src/routes.js
CHANGED
|
@@ -5,10 +5,29 @@ const crypto = require('crypto');
|
|
|
5
5
|
const express = require('express');
|
|
6
6
|
const { detectShells } = require('./shells');
|
|
7
7
|
const log = require('./logger');
|
|
8
|
+
const rateLimit = require('express-rate-limit');
|
|
8
9
|
|
|
9
10
|
const PUBLIC_DIR = path.join(__dirname, '..', 'public');
|
|
10
11
|
const uploadedFiles = new Map(); // id -> filepath
|
|
11
12
|
|
|
13
|
+
const pageRateLimit = rateLimit({
|
|
14
|
+
windowMs: 1 * 60 * 1000,
|
|
15
|
+
max: 120,
|
|
16
|
+
standardHeaders: true,
|
|
17
|
+
legacyHeaders: false,
|
|
18
|
+
handler: (_req, res) =>
|
|
19
|
+
res.status(429).json({ error: 'Too many requests, please try again later.' }),
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
const apiRateLimit = rateLimit({
|
|
23
|
+
windowMs: 1 * 60 * 1000,
|
|
24
|
+
max: 120,
|
|
25
|
+
standardHeaders: true,
|
|
26
|
+
legacyHeaders: false,
|
|
27
|
+
handler: (_req, res) =>
|
|
28
|
+
res.status(429).json({ error: 'Too many requests, please try again later.' }),
|
|
29
|
+
});
|
|
30
|
+
|
|
12
31
|
const IMAGE_SIGNATURES = [
|
|
13
32
|
{ type: 'image/png', bytes: [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a] },
|
|
14
33
|
{ type: 'image/jpeg', bytes: [0xff, 0xd8, 0xff] },
|
|
@@ -74,7 +93,7 @@ function setupRoutes(app, { auth, sessions, config, state }) {
|
|
|
74
93
|
if (!ott || !auth.password) return next();
|
|
75
94
|
// Already authenticated (e.g. DevTunnel anti-phishing re-sent the request) — just redirect
|
|
76
95
|
if (req.cookies.pty_token && auth.validateToken(req.cookies.pty_token)) {
|
|
77
|
-
return res.redirect(req.path);
|
|
96
|
+
return res.redirect(req.path === '/terminal' ? '/terminal' : '/');
|
|
78
97
|
}
|
|
79
98
|
if (auth.validateShareToken(ott)) {
|
|
80
99
|
const token = auth.generateToken();
|
|
@@ -86,17 +105,17 @@ function setupRoutes(app, { auth, sessions, config, state }) {
|
|
|
86
105
|
});
|
|
87
106
|
log.info(`Auth: share-token auto-login from ${req.ip}`);
|
|
88
107
|
// Redirect to the same path without ?ott= to keep the URL clean
|
|
89
|
-
return res.redirect(req.path);
|
|
108
|
+
return res.redirect(req.path === '/terminal' ? '/terminal' : '/');
|
|
90
109
|
}
|
|
91
110
|
log.warn(`Auth: invalid or expired share token from ${req.ip}`);
|
|
92
111
|
next();
|
|
93
112
|
}
|
|
94
113
|
|
|
95
114
|
// Pages
|
|
96
|
-
app.get('/', autoLogin, auth.middleware, (_req, res) =>
|
|
115
|
+
app.get('/', pageRateLimit, autoLogin, auth.middleware, (_req, res) =>
|
|
97
116
|
res.sendFile('index.html', { root: PUBLIC_DIR }),
|
|
98
117
|
);
|
|
99
|
-
app.get('/terminal', autoLogin, auth.middleware, (_req, res) =>
|
|
118
|
+
app.get('/terminal', pageRateLimit, autoLogin, auth.middleware, (_req, res) =>
|
|
100
119
|
res.sendFile('terminal.html', { root: PUBLIC_DIR }),
|
|
101
120
|
);
|
|
102
121
|
|
|
@@ -109,11 +128,11 @@ function setupRoutes(app, { auth, sessions, config, state }) {
|
|
|
109
128
|
});
|
|
110
129
|
|
|
111
130
|
// Session API
|
|
112
|
-
app.get('/api/sessions', auth.middleware, (_req, res) => {
|
|
131
|
+
app.get('/api/sessions', apiRateLimit, auth.middleware, (_req, res) => {
|
|
113
132
|
res.json(sessions.list());
|
|
114
133
|
});
|
|
115
134
|
|
|
116
|
-
app.post('/api/sessions', auth.middleware, (req, res) => {
|
|
135
|
+
app.post('/api/sessions', apiRateLimit, auth.middleware, (req, res) => {
|
|
117
136
|
const { name, shell, args: shellArgs, cwd, initialCommand, color, cols, rows } = req.body || {};
|
|
118
137
|
|
|
119
138
|
// Validate shell field
|
|
@@ -125,6 +144,20 @@ function setupRoutes(app, { auth, sessions, config, state }) {
|
|
|
125
144
|
}
|
|
126
145
|
}
|
|
127
146
|
|
|
147
|
+
// Validate args field — must be an array of strings
|
|
148
|
+
if (shellArgs !== undefined) {
|
|
149
|
+
if (!Array.isArray(shellArgs) || !shellArgs.every((a) => typeof a === 'string')) {
|
|
150
|
+
return res.status(400).json({ error: 'args must be an array of strings' });
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Validate initialCommand field — must be a string
|
|
155
|
+
if (initialCommand !== undefined && initialCommand !== null) {
|
|
156
|
+
if (typeof initialCommand !== 'string') {
|
|
157
|
+
return res.status(400).json({ error: 'initialCommand must be a string' });
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
128
161
|
// Validate cwd field
|
|
129
162
|
if (cwd) {
|
|
130
163
|
if (!path.isAbsolute(cwd)) {
|
|
@@ -139,16 +172,21 @@ function setupRoutes(app, { auth, sessions, config, state }) {
|
|
|
139
172
|
}
|
|
140
173
|
}
|
|
141
174
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
175
|
+
let id;
|
|
176
|
+
try {
|
|
177
|
+
id = sessions.create({
|
|
178
|
+
name: name || `Session ${sessions.sessions.size + 1}`,
|
|
179
|
+
shell: shell || config.defaultShell,
|
|
180
|
+
args: shellArgs || [],
|
|
181
|
+
cwd: cwd ? path.resolve(cwd) : config.cwd,
|
|
182
|
+
initialCommand: initialCommand ?? null,
|
|
183
|
+
color: color || null,
|
|
184
|
+
cols: typeof cols === 'number' && cols > 0 && cols <= 500 ? Math.floor(cols) : undefined,
|
|
185
|
+
rows: typeof rows === 'number' && rows > 0 && rows <= 200 ? Math.floor(rows) : undefined,
|
|
186
|
+
});
|
|
187
|
+
} catch (err) {
|
|
188
|
+
return res.status(400).json({ error: err.message || 'Failed to create session' });
|
|
189
|
+
}
|
|
152
190
|
res.json({ id, url: `/terminal?id=${id}` });
|
|
153
191
|
});
|
|
154
192
|
|
|
@@ -260,7 +298,7 @@ function setupRoutes(app, { auth, sessions, config, state }) {
|
|
|
260
298
|
});
|
|
261
299
|
|
|
262
300
|
// Serve uploaded files by opaque ID
|
|
263
|
-
app.get('/uploads/:id', auth.middleware, (req, res) => {
|
|
301
|
+
app.get('/uploads/:id', pageRateLimit, auth.middleware, (req, res) => {
|
|
264
302
|
const filepath = uploadedFiles.get(req.params.id);
|
|
265
303
|
if (!filepath) return res.status(404).json({ error: 'not found' });
|
|
266
304
|
if (!fs.existsSync(filepath)) {
|
|
@@ -271,10 +309,10 @@ function setupRoutes(app, { auth, sessions, config, state }) {
|
|
|
271
309
|
});
|
|
272
310
|
|
|
273
311
|
// Directory listing for folder browser
|
|
274
|
-
app.get('/api/dirs', auth.middleware, (req, res) => {
|
|
312
|
+
app.get('/api/dirs', apiRateLimit, auth.middleware, (req, res) => {
|
|
275
313
|
const query = req.query.q || config.cwd + path.sep;
|
|
276
314
|
const endsWithSep = query.endsWith('/') || query.endsWith('\\');
|
|
277
|
-
const dir = endsWithSep ? query : path.dirname(query);
|
|
315
|
+
const dir = path.resolve(endsWithSep ? query : path.dirname(query));
|
|
278
316
|
const prefix = endsWithSep ? '' : path.basename(query);
|
|
279
317
|
|
|
280
318
|
try {
|
|
@@ -292,7 +330,7 @@ function setupRoutes(app, { auth, sessions, config, state }) {
|
|
|
292
330
|
}
|
|
293
331
|
|
|
294
332
|
function cleanupUploadedFiles() {
|
|
295
|
-
for (const [
|
|
333
|
+
for (const [_id, filepath] of uploadedFiles) {
|
|
296
334
|
try {
|
|
297
335
|
if (fs.existsSync(filepath)) {
|
|
298
336
|
fs.unlinkSync(filepath);
|
package/src/server.js
CHANGED
|
@@ -172,7 +172,7 @@ function createTermBeamServer(overrides = {}) {
|
|
|
172
172
|
config.host === '0.0.0.0' || config.host === '::' || config.host === ip;
|
|
173
173
|
state.shareBaseUrl = isLanReachable ? localUrl : `http://localhost:${config.port}`;
|
|
174
174
|
const gn = '\x1b[38;5;114m'; // green
|
|
175
|
-
const
|
|
175
|
+
const _dm = '\x1b[2m'; // dim
|
|
176
176
|
|
|
177
177
|
const bl = '\x1b[38;5;75m'; // light blue
|
|
178
178
|
|
|
@@ -220,7 +220,7 @@ function createTermBeamServer(overrides = {}) {
|
|
|
220
220
|
}
|
|
221
221
|
|
|
222
222
|
console.log(` Scan the QR code or open: ${bl}${qrDisplayUrl}${rs}`);
|
|
223
|
-
if (config.password)
|
|
223
|
+
if (config.password) process.stdout.write(` Password: ${gn}${config.password}${rs}\n`);
|
|
224
224
|
console.log('');
|
|
225
225
|
|
|
226
226
|
resolve({ url: `http://localhost:${config.port}`, defaultId });
|
package/src/service.js
CHANGED
|
@@ -1,133 +1,26 @@
|
|
|
1
|
-
const { execFileSync
|
|
1
|
+
const { execFileSync } = require('child_process');
|
|
2
2
|
const fs = require('fs');
|
|
3
3
|
const path = require('path');
|
|
4
4
|
const os = require('os');
|
|
5
5
|
const crypto = require('crypto');
|
|
6
|
-
const
|
|
6
|
+
const {
|
|
7
|
+
color,
|
|
8
|
+
green,
|
|
9
|
+
yellow,
|
|
10
|
+
red,
|
|
11
|
+
cyan,
|
|
12
|
+
bold,
|
|
13
|
+
dim,
|
|
14
|
+
ask,
|
|
15
|
+
choose,
|
|
16
|
+
confirm,
|
|
17
|
+
createRL,
|
|
18
|
+
} = require('./prompts');
|
|
7
19
|
|
|
8
20
|
const TERMBEAM_DIR = path.join(os.homedir(), '.termbeam');
|
|
9
21
|
const ECOSYSTEM_FILE = path.join(TERMBEAM_DIR, 'ecosystem.config.js');
|
|
10
22
|
const DEFAULT_SERVICE_NAME = 'termbeam';
|
|
11
23
|
|
|
12
|
-
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
13
|
-
|
|
14
|
-
function color(code, text) {
|
|
15
|
-
return `\x1b[${code}m${text}\x1b[0m`;
|
|
16
|
-
}
|
|
17
|
-
const green = (t) => color('32', t);
|
|
18
|
-
const yellow = (t) => color('33', t);
|
|
19
|
-
const red = (t) => color('31', t);
|
|
20
|
-
const cyan = (t) => color('36', t);
|
|
21
|
-
const bold = (t) => color('1', t);
|
|
22
|
-
const dim = (t) => color('2', t);
|
|
23
|
-
|
|
24
|
-
/**
|
|
25
|
-
* Prompt the user with a question. Returns the trimmed answer.
|
|
26
|
-
* If `defaultValue` is provided, it's shown in brackets and used when the user presses Enter.
|
|
27
|
-
*/
|
|
28
|
-
function ask(rl, question, defaultValue) {
|
|
29
|
-
const suffix = defaultValue != null ? ` ${dim(`[${defaultValue}]`)} ` : ' ';
|
|
30
|
-
return new Promise((resolve) => {
|
|
31
|
-
rl.question(`${question}${suffix}`, (answer) => {
|
|
32
|
-
const trimmed = answer.trim();
|
|
33
|
-
resolve(trimmed || (defaultValue != null ? String(defaultValue) : ''));
|
|
34
|
-
});
|
|
35
|
-
});
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
/**
|
|
39
|
-
* Prompt the user with a list of choices using arrow keys.
|
|
40
|
-
* Each choice can be a string or { label, hint } object.
|
|
41
|
-
* Up/Down to move, Enter to select. Returns the chosen value.
|
|
42
|
-
*/
|
|
43
|
-
function choose(rl, question, choices, defaultIndex = 0) {
|
|
44
|
-
// Normalize choices to { label, hint } objects
|
|
45
|
-
const items = choices.map((c) => (typeof c === 'string' ? { label: c, hint: '' } : c));
|
|
46
|
-
|
|
47
|
-
return new Promise((resolve) => {
|
|
48
|
-
let selected = defaultIndex;
|
|
49
|
-
|
|
50
|
-
function lineCount() {
|
|
51
|
-
return items.reduce((n, item) => n + 1 + (item.hint ? 1 : 0), 0);
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
function render(clear) {
|
|
55
|
-
if (clear) {
|
|
56
|
-
process.stdout.write(`\x1b[${lineCount()}A\r\x1b[J`);
|
|
57
|
-
}
|
|
58
|
-
items.forEach((item, i) => {
|
|
59
|
-
const marker = i === selected ? cyan('→') : ' ';
|
|
60
|
-
const label = i === selected ? bold(item.label) : item.label;
|
|
61
|
-
process.stdout.write(` ${marker} ${label}\n`);
|
|
62
|
-
if (item.hint) {
|
|
63
|
-
const hintText = item.danger
|
|
64
|
-
? red(item.hint)
|
|
65
|
-
: item.warn
|
|
66
|
-
? yellow(item.hint)
|
|
67
|
-
: dim(item.hint);
|
|
68
|
-
process.stdout.write(` ${hintText}\n`);
|
|
69
|
-
}
|
|
70
|
-
});
|
|
71
|
-
process.stdout.write(dim(' ↑/↓ to move, Enter to select'));
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
rl.pause();
|
|
75
|
-
console.log(`\n${question}`);
|
|
76
|
-
render(false);
|
|
77
|
-
|
|
78
|
-
const wasRaw = process.stdin.isRaw;
|
|
79
|
-
if (process.stdin.isTTY) {
|
|
80
|
-
process.stdin.setRawMode(true);
|
|
81
|
-
}
|
|
82
|
-
process.stdin.resume();
|
|
83
|
-
|
|
84
|
-
function onKey(buf) {
|
|
85
|
-
const key = buf.toString();
|
|
86
|
-
|
|
87
|
-
if (key === '\x1b[A' || key === 'k') {
|
|
88
|
-
selected = (selected - 1 + items.length) % items.length;
|
|
89
|
-
render(true);
|
|
90
|
-
} else if (key === '\x1b[B' || key === 'j') {
|
|
91
|
-
selected = (selected + 1) % items.length;
|
|
92
|
-
render(true);
|
|
93
|
-
} else if (key === '\r' || key === '\n') {
|
|
94
|
-
cleanup();
|
|
95
|
-
process.stdout.write('\r\x1b[K\n');
|
|
96
|
-
console.log(dim(` Selected: ${items[selected].label}`));
|
|
97
|
-
resolve({ index: selected, value: items[selected].label });
|
|
98
|
-
} else if (key === '\x03') {
|
|
99
|
-
cleanup();
|
|
100
|
-
process.exit(0);
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
function cleanup() {
|
|
105
|
-
process.stdin.removeListener('data', onKey);
|
|
106
|
-
if (process.stdin.isTTY) {
|
|
107
|
-
process.stdin.setRawMode(wasRaw || false);
|
|
108
|
-
}
|
|
109
|
-
process.stdin.pause();
|
|
110
|
-
rl.resume();
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
process.stdin.on('data', onKey);
|
|
114
|
-
});
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
/**
|
|
118
|
-
* Ask a yes/no question. Returns boolean.
|
|
119
|
-
*/
|
|
120
|
-
function confirm(rl, question, defaultYes = true) {
|
|
121
|
-
const hint = defaultYes ? 'Y/n' : 'y/N';
|
|
122
|
-
return new Promise((resolve) => {
|
|
123
|
-
rl.question(`${question} ${dim(`[${hint}]`)} `, (answer) => {
|
|
124
|
-
const a = answer.trim().toLowerCase();
|
|
125
|
-
if (a === '') resolve(defaultYes);
|
|
126
|
-
else resolve(a === 'y' || a === 'yes');
|
|
127
|
-
});
|
|
128
|
-
});
|
|
129
|
-
}
|
|
130
|
-
|
|
131
24
|
// ── PM2 Detection ────────────────────────────────────────────────────────────
|
|
132
25
|
|
|
133
26
|
function findPm2() {
|
|
@@ -344,7 +237,7 @@ async function actionInstall() {
|
|
|
344
237
|
]);
|
|
345
238
|
if (pwChoice.index === 0) {
|
|
346
239
|
config.password = crypto.randomBytes(16).toString('base64url');
|
|
347
|
-
|
|
240
|
+
process.stdout.write(dim(` Generated password: ${config.password}`) + '\n');
|
|
348
241
|
} else if (pwChoice.index === 1) {
|
|
349
242
|
config.password = await ask(rl, 'Enter password:');
|
|
350
243
|
while (!config.password) {
|
|
@@ -404,7 +297,7 @@ async function actionInstall() {
|
|
|
404
297
|
if (config.publicTunnel && config.password === false) {
|
|
405
298
|
console.log(yellow(' ⚠ Public tunnels require password authentication.'));
|
|
406
299
|
config.password = crypto.randomBytes(16).toString('base64url');
|
|
407
|
-
|
|
300
|
+
process.stdout.write(dim(` Auto-generated password: ${config.password}`) + '\n');
|
|
408
301
|
}
|
|
409
302
|
} else if (accessChoice.index === 1) {
|
|
410
303
|
// LAN mode: bind to all interfaces, no tunnel
|
|
@@ -459,7 +352,7 @@ async function actionInstall() {
|
|
|
459
352
|
console.log(bold('\n── Configuration Summary ──────────────────'));
|
|
460
353
|
console.log(` Service name: ${cyan(config.name)}`);
|
|
461
354
|
console.log(
|
|
462
|
-
` Password: ${config.password === false ? yellow('disabled') : cyan(
|
|
355
|
+
` Password: ${config.password === false ? yellow('disabled') : cyan('••••••••')}`,
|
|
463
356
|
);
|
|
464
357
|
console.log(` Port: ${cyan(String(config.port))}`);
|
|
465
358
|
console.log(
|
|
@@ -651,15 +544,6 @@ function actionRestart() {
|
|
|
651
544
|
console.log(green('\n✓ TermBeam service restarted.\n'));
|
|
652
545
|
}
|
|
653
546
|
|
|
654
|
-
// ── readline factory ─────────────────────────────────────────────────────────
|
|
655
|
-
|
|
656
|
-
function createRL() {
|
|
657
|
-
return readline.createInterface({
|
|
658
|
-
input: process.stdin,
|
|
659
|
-
output: process.stdout,
|
|
660
|
-
});
|
|
661
|
-
}
|
|
662
|
-
|
|
663
547
|
// ── Entrypoint ───────────────────────────────────────────────────────────────
|
|
664
548
|
|
|
665
549
|
function printServiceHelp() {
|
package/src/sessions.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
const crypto = require('crypto');
|
|
2
|
+
const path = require('path');
|
|
2
3
|
const { execSync, exec } = require('child_process');
|
|
3
4
|
const fs = require('fs');
|
|
4
5
|
const pty = require('node-pty');
|
|
5
6
|
const log = require('./logger');
|
|
6
7
|
const { getGitInfo } = require('./git');
|
|
7
8
|
|
|
8
|
-
function
|
|
9
|
+
function _getProcessCwd(pid) {
|
|
9
10
|
try {
|
|
10
11
|
if (process.platform === 'linux') {
|
|
11
12
|
return fs.readlinkSync(`/proc/${pid}/cwd`);
|
|
@@ -108,6 +109,24 @@ class SessionManager {
|
|
|
108
109
|
cols = 120,
|
|
109
110
|
rows = 30,
|
|
110
111
|
}) {
|
|
112
|
+
// Defense-in-depth: reject shells with dangerous characters or relative paths
|
|
113
|
+
if (
|
|
114
|
+
typeof shell !== 'string' ||
|
|
115
|
+
!shell ||
|
|
116
|
+
/[;&|`$(){}\[\]!#~]/.test(shell) ||
|
|
117
|
+
(!path.isAbsolute(shell) && !shell.match(/^[a-zA-Z0-9._-]+(\.exe)?$/))
|
|
118
|
+
) {
|
|
119
|
+
throw new Error('Invalid shell');
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Defense-in-depth: validate args and initialCommand types
|
|
123
|
+
if (!Array.isArray(args) || !args.every((a) => typeof a === 'string')) {
|
|
124
|
+
throw new Error('args must be an array of strings');
|
|
125
|
+
}
|
|
126
|
+
if (initialCommand !== null && typeof initialCommand !== 'string') {
|
|
127
|
+
throw new Error('initialCommand must be a string');
|
|
128
|
+
}
|
|
129
|
+
|
|
111
130
|
const id = crypto.randomBytes(16).toString('hex');
|
|
112
131
|
if (!color) {
|
|
113
132
|
color = SESSION_COLORS[this.sessions.size % SESSION_COLORS.length];
|
|
@@ -209,7 +228,7 @@ class SessionManager {
|
|
|
209
228
|
}
|
|
210
229
|
|
|
211
230
|
shutdown() {
|
|
212
|
-
for (const [
|
|
231
|
+
for (const [_id, s] of this.sessions) {
|
|
213
232
|
try {
|
|
214
233
|
s.pty.kill();
|
|
215
234
|
} catch {
|
package/src/tunnel.js
CHANGED
|
@@ -67,7 +67,7 @@ function savePersistedTunnel(id) {
|
|
|
67
67
|
);
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
-
function
|
|
70
|
+
function _deletePersisted() {
|
|
71
71
|
const persisted = loadPersistedTunnel();
|
|
72
72
|
if (persisted) {
|
|
73
73
|
try {
|
|
@@ -139,7 +139,7 @@ async function startTunnel(port, options = {}) {
|
|
|
139
139
|
log.info('A code will be displayed — open the URL on any device to authenticate.');
|
|
140
140
|
try {
|
|
141
141
|
execFileSync(devtunnelCmd, ['user', 'login', '-d'], { stdio: 'inherit' });
|
|
142
|
-
} catch (
|
|
142
|
+
} catch (_loginErr) {
|
|
143
143
|
log.error('');
|
|
144
144
|
log.error(' DevTunnel login failed. To use tunnels, run:');
|
|
145
145
|
log.error(' devtunnel user login');
|