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