termbeam 1.10.0 → 1.10.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 +1 -0
- 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 +14 -0
- package/src/client.js +1 -1
- package/src/preview.js +1 -1
- package/src/routes.js +6 -5
- package/src/server.js +4 -0
- 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/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
|
@@ -270,6 +270,10 @@ function parseArgs() {
|
|
|
270
270
|
publicTunnel = true;
|
|
271
271
|
} else if (args[i].startsWith('--password=')) {
|
|
272
272
|
password = args[i].split('=')[1];
|
|
273
|
+
if (!password) {
|
|
274
|
+
console.error('Error: --password= requires a non-empty value\n');
|
|
275
|
+
process.exit(1);
|
|
276
|
+
}
|
|
273
277
|
explicitPassword = true;
|
|
274
278
|
} else if (args[i] === '--help' || args[i] === '-h') {
|
|
275
279
|
printHelp();
|
|
@@ -286,6 +290,10 @@ function parseArgs() {
|
|
|
286
290
|
explicitPassword = true;
|
|
287
291
|
} else if (args[i] === '--port' && args[i + 1]) {
|
|
288
292
|
port = parseInt(args[++i], 10);
|
|
293
|
+
if (!Number.isFinite(port) || port < 1 || port > 65535) {
|
|
294
|
+
console.error('Error: --port must be a number between 1 and 65535\n');
|
|
295
|
+
process.exit(1);
|
|
296
|
+
}
|
|
289
297
|
} else if (args[i] === '--lan') {
|
|
290
298
|
host = '0.0.0.0';
|
|
291
299
|
} else if (args[i] === '--host' && args[i + 1]) {
|
|
@@ -307,6 +315,12 @@ function parseArgs() {
|
|
|
307
315
|
}
|
|
308
316
|
}
|
|
309
317
|
|
|
318
|
+
const validLogLevels = ['error', 'warn', 'info', 'debug'];
|
|
319
|
+
if (!validLogLevels.includes(logLevel)) {
|
|
320
|
+
console.error(`Error: --log-level must be one of: ${validLogLevels.join(', ')}\n`);
|
|
321
|
+
process.exit(1);
|
|
322
|
+
}
|
|
323
|
+
|
|
310
324
|
// Default: auto-generate password if none specified
|
|
311
325
|
if (!explicitPassword && !password) {
|
|
312
326
|
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/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
|
}
|
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();
|