termbeam 0.0.9 → 0.1.0
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 +14 -4
- package/package.json +1 -1
- package/public/index.html +142 -12
- package/public/sw.js +23 -5
- package/public/terminal.html +1636 -483
- package/src/auth.js +13 -0
- package/src/routes.js +14 -1
- package/src/sessions.js +28 -4
- package/src/shells.js +1 -1
- package/src/tunnel.js +17 -12
- package/src/websocket.js +25 -3
package/src/auth.js
CHANGED
|
@@ -61,6 +61,19 @@ function createAuth(password) {
|
|
|
61
61
|
const tokens = new Map();
|
|
62
62
|
const authAttempts = new Map();
|
|
63
63
|
|
|
64
|
+
// Periodically clean up expired tokens and stale rate-limit entries
|
|
65
|
+
setInterval(() => {
|
|
66
|
+
const now = Date.now();
|
|
67
|
+
for (const [token, expiry] of tokens) {
|
|
68
|
+
if (now > expiry) tokens.delete(token);
|
|
69
|
+
}
|
|
70
|
+
for (const [ip, attempts] of authAttempts) {
|
|
71
|
+
const recent = attempts.filter((t) => now - t < 60 * 1000);
|
|
72
|
+
if (recent.length === 0) authAttempts.delete(ip);
|
|
73
|
+
else authAttempts.set(ip, recent);
|
|
74
|
+
}
|
|
75
|
+
}, 60 * 60 * 1000).unref();
|
|
76
|
+
|
|
64
77
|
function generateToken() {
|
|
65
78
|
const token = crypto.randomBytes(32).toString('hex');
|
|
66
79
|
tokens.set(token, Date.now() + 24 * 60 * 60 * 1000);
|
package/src/routes.js
CHANGED
|
@@ -51,13 +51,14 @@ function setupRoutes(app, { auth, sessions, config }) {
|
|
|
51
51
|
});
|
|
52
52
|
|
|
53
53
|
app.post('/api/sessions', auth.middleware, (req, res) => {
|
|
54
|
-
const { name, shell, args: shellArgs, cwd, initialCommand } = req.body || {};
|
|
54
|
+
const { name, shell, args: shellArgs, cwd, initialCommand, color } = req.body || {};
|
|
55
55
|
const id = sessions.create({
|
|
56
56
|
name: name || `Session ${sessions.sessions.size + 1}`,
|
|
57
57
|
shell: shell || config.defaultShell,
|
|
58
58
|
args: shellArgs || [],
|
|
59
59
|
cwd: cwd || config.cwd,
|
|
60
60
|
initialCommand: initialCommand || null,
|
|
61
|
+
color: color || null,
|
|
61
62
|
});
|
|
62
63
|
res.json({ id, url: `/terminal?id=${id}` });
|
|
63
64
|
});
|
|
@@ -76,6 +77,18 @@ function setupRoutes(app, { auth, sessions, config }) {
|
|
|
76
77
|
}
|
|
77
78
|
});
|
|
78
79
|
|
|
80
|
+
app.patch('/api/sessions/:id', auth.middleware, (req, res) => {
|
|
81
|
+
const { color, name } = req.body || {};
|
|
82
|
+
const updates = {};
|
|
83
|
+
if (color !== undefined) updates.color = color;
|
|
84
|
+
if (name !== undefined) updates.name = name;
|
|
85
|
+
if (sessions.update(req.params.id, updates)) {
|
|
86
|
+
res.json({ ok: true });
|
|
87
|
+
} else {
|
|
88
|
+
res.status(404).json({ error: 'not found' });
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
|
|
79
92
|
// Directory listing for folder browser
|
|
80
93
|
app.get('/api/dirs', auth.middleware, (req, res) => {
|
|
81
94
|
const query = req.query.q || os.homedir();
|
package/src/sessions.js
CHANGED
|
@@ -1,13 +1,21 @@
|
|
|
1
1
|
const crypto = require('crypto');
|
|
2
2
|
const pty = require('node-pty');
|
|
3
3
|
|
|
4
|
+
const SESSION_COLORS = [
|
|
5
|
+
'#4a9eff', '#4ade80', '#fbbf24', '#c084fc',
|
|
6
|
+
'#f87171', '#22d3ee', '#fb923c', '#f472b6',
|
|
7
|
+
];
|
|
8
|
+
|
|
4
9
|
class SessionManager {
|
|
5
10
|
constructor() {
|
|
6
11
|
this.sessions = new Map();
|
|
7
12
|
}
|
|
8
13
|
|
|
9
|
-
create({ name, shell, args = [], cwd, initialCommand = null }) {
|
|
14
|
+
create({ name, shell, args = [], cwd, initialCommand = null, color = null }) {
|
|
10
15
|
const id = crypto.randomBytes(4).toString('hex');
|
|
16
|
+
if (!color) {
|
|
17
|
+
color = SESSION_COLORS[this.sessions.size % SESSION_COLORS.length];
|
|
18
|
+
}
|
|
11
19
|
const ptyProcess = pty.spawn(shell, args, {
|
|
12
20
|
name: 'xterm-256color',
|
|
13
21
|
cols: 120,
|
|
@@ -26,14 +34,21 @@ class SessionManager {
|
|
|
26
34
|
name,
|
|
27
35
|
shell,
|
|
28
36
|
cwd,
|
|
37
|
+
color,
|
|
29
38
|
createdAt: new Date().toISOString(),
|
|
39
|
+
lastActivity: Date.now(),
|
|
30
40
|
clients: new Set(),
|
|
31
41
|
scrollback: [],
|
|
42
|
+
scrollbackBuf: '',
|
|
32
43
|
};
|
|
33
44
|
|
|
34
45
|
ptyProcess.onData((data) => {
|
|
35
|
-
session.
|
|
36
|
-
|
|
46
|
+
session.lastActivity = Date.now();
|
|
47
|
+
session.scrollbackBuf += data;
|
|
48
|
+
// Cap scrollback at ~200KB
|
|
49
|
+
if (session.scrollbackBuf.length > 200000) {
|
|
50
|
+
session.scrollbackBuf = session.scrollbackBuf.slice(-100000);
|
|
51
|
+
}
|
|
37
52
|
for (const ws of session.clients) {
|
|
38
53
|
if (ws.readyState === 1) ws.send(JSON.stringify({ type: 'output', data }));
|
|
39
54
|
}
|
|
@@ -56,11 +71,18 @@ class SessionManager {
|
|
|
56
71
|
return this.sessions.get(id);
|
|
57
72
|
}
|
|
58
73
|
|
|
74
|
+
update(id, fields) {
|
|
75
|
+
const s = this.sessions.get(id);
|
|
76
|
+
if (!s) return false;
|
|
77
|
+
if (fields.color !== undefined) s.color = fields.color;
|
|
78
|
+
if (fields.name !== undefined) s.name = fields.name;
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
|
|
59
82
|
delete(id) {
|
|
60
83
|
const s = this.sessions.get(id);
|
|
61
84
|
if (!s) return false;
|
|
62
85
|
s.pty.kill();
|
|
63
|
-
this.sessions.delete(id);
|
|
64
86
|
return true;
|
|
65
87
|
}
|
|
66
88
|
|
|
@@ -75,6 +97,8 @@ class SessionManager {
|
|
|
75
97
|
pid: s.pty.pid,
|
|
76
98
|
clients: s.clients.size,
|
|
77
99
|
createdAt: s.createdAt,
|
|
100
|
+
color: s.color,
|
|
101
|
+
lastActivity: s.lastActivity,
|
|
78
102
|
});
|
|
79
103
|
}
|
|
80
104
|
return list;
|
package/src/shells.js
CHANGED
package/src/tunnel.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
const { execSync, spawn } = require('child_process');
|
|
1
|
+
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');
|
|
@@ -10,6 +10,8 @@ let tunnelId = null;
|
|
|
10
10
|
let tunnelProc = null;
|
|
11
11
|
let devtunnelCmd = 'devtunnel';
|
|
12
12
|
|
|
13
|
+
const SAFE_ID_RE = /^[a-zA-Z0-9._-]+$/;
|
|
14
|
+
|
|
13
15
|
function findDevtunnel() {
|
|
14
16
|
// Try devtunnel directly
|
|
15
17
|
try {
|
|
@@ -51,8 +53,10 @@ function deletePersisted() {
|
|
|
51
53
|
const persisted = loadPersistedTunnel();
|
|
52
54
|
if (persisted) {
|
|
53
55
|
try {
|
|
54
|
-
|
|
55
|
-
|
|
56
|
+
if (SAFE_ID_RE.test(persisted.tunnelId)) {
|
|
57
|
+
execFileSync(devtunnelCmd, ['delete', persisted.tunnelId, '-f'], { stdio: 'pipe' });
|
|
58
|
+
console.log(`[termbeam] Deleted persisted tunnel ${persisted.tunnelId}`);
|
|
59
|
+
}
|
|
56
60
|
} catch {}
|
|
57
61
|
try {
|
|
58
62
|
fs.unlinkSync(TUNNEL_CONFIG_PATH);
|
|
@@ -62,7 +66,8 @@ function deletePersisted() {
|
|
|
62
66
|
|
|
63
67
|
function isTunnelValid(id) {
|
|
64
68
|
try {
|
|
65
|
-
|
|
69
|
+
if (!SAFE_ID_RE.test(id)) return false;
|
|
70
|
+
execFileSync(devtunnelCmd, ['show', id, '--json'], { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] });
|
|
66
71
|
return true;
|
|
67
72
|
} catch {
|
|
68
73
|
return false;
|
|
@@ -97,7 +102,7 @@ async function startTunnel(port, options = {}) {
|
|
|
97
102
|
// Ensure user is logged in
|
|
98
103
|
let loggedIn = false;
|
|
99
104
|
try {
|
|
100
|
-
const userOut =
|
|
105
|
+
const userOut = execFileSync(devtunnelCmd, ['user', 'show'], { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] });
|
|
101
106
|
// user show can succeed but show "not logged in" status
|
|
102
107
|
loggedIn = userOut && !userOut.toLowerCase().includes('not logged in');
|
|
103
108
|
} catch {}
|
|
@@ -105,7 +110,7 @@ async function startTunnel(port, options = {}) {
|
|
|
105
110
|
if (!loggedIn) {
|
|
106
111
|
console.log('[termbeam] devtunnel not logged in, launching login...');
|
|
107
112
|
console.log('[termbeam] A browser window will open for authentication.');
|
|
108
|
-
|
|
113
|
+
execFileSync(devtunnelCmd, ['user', 'login'], { stdio: 'inherit' });
|
|
109
114
|
}
|
|
110
115
|
|
|
111
116
|
const persisted = options.persisted;
|
|
@@ -124,7 +129,7 @@ async function startTunnel(port, options = {}) {
|
|
|
124
129
|
if (saved) {
|
|
125
130
|
console.log('[termbeam] Persisted tunnel expired, creating new one');
|
|
126
131
|
}
|
|
127
|
-
const createOut =
|
|
132
|
+
const createOut = execFileSync(devtunnelCmd, ['create', '--expiration', '30d', '--json'], { encoding: 'utf-8' });
|
|
128
133
|
const tunnelData = JSON.parse(createOut);
|
|
129
134
|
tunnelId = tunnelData.tunnel.tunnelId;
|
|
130
135
|
savePersistedTunnel(tunnelId);
|
|
@@ -134,7 +139,7 @@ async function startTunnel(port, options = {}) {
|
|
|
134
139
|
tunnelMode = 'ephemeral';
|
|
135
140
|
tunnelExpiry = '1 day';
|
|
136
141
|
// Ephemeral tunnel — create fresh, will be deleted on shutdown
|
|
137
|
-
const createOut =
|
|
142
|
+
const createOut = execFileSync(devtunnelCmd, ['create', '--expiration', '1d', '--json'], { encoding: 'utf-8' });
|
|
138
143
|
const tunnelData = JSON.parse(createOut);
|
|
139
144
|
tunnelId = tunnelData.tunnel.tunnelId;
|
|
140
145
|
console.log(`[termbeam] Created ephemeral tunnel ${tunnelId}`);
|
|
@@ -142,10 +147,10 @@ async function startTunnel(port, options = {}) {
|
|
|
142
147
|
|
|
143
148
|
// Idempotent port and access setup
|
|
144
149
|
try {
|
|
145
|
-
|
|
150
|
+
execFileSync(devtunnelCmd, ['port', 'create', tunnelId, '-p', String(port), '--protocol', 'http'], { stdio: 'pipe' });
|
|
146
151
|
} catch {}
|
|
147
152
|
try {
|
|
148
|
-
|
|
153
|
+
execFileSync(devtunnelCmd, ['access', 'create', tunnelId, '-p', String(port), '--anonymous'], { stdio: 'pipe' });
|
|
149
154
|
} catch {}
|
|
150
155
|
|
|
151
156
|
const hostProc = spawn(devtunnelCmd, ['host', tunnelId], {
|
|
@@ -186,7 +191,7 @@ function cleanupTunnel() {
|
|
|
186
191
|
// On Windows, kill the process tree to ensure all children die
|
|
187
192
|
if (process.platform === 'win32' && tunnelProc.pid) {
|
|
188
193
|
try {
|
|
189
|
-
|
|
194
|
+
execFileSync('taskkill', ['/pid', String(tunnelProc.pid), '/T', '/F'], { stdio: 'pipe', timeout: 5000 });
|
|
190
195
|
} catch { /* best effort */ }
|
|
191
196
|
} else {
|
|
192
197
|
tunnelProc.kill('SIGKILL');
|
|
@@ -202,7 +207,7 @@ function cleanupTunnel() {
|
|
|
202
207
|
console.log('[termbeam] Tunnel host stopped (tunnel preserved for reuse)');
|
|
203
208
|
} else {
|
|
204
209
|
try {
|
|
205
|
-
|
|
210
|
+
execFileSync(devtunnelCmd, ['delete', id, '-f'], { stdio: 'pipe', timeout: 10000 });
|
|
206
211
|
console.log('[termbeam] Tunnel cleaned up');
|
|
207
212
|
} catch {
|
|
208
213
|
/* best effort — tunnel will expire on its own */
|
package/src/websocket.js
CHANGED
|
@@ -1,3 +1,19 @@
|
|
|
1
|
+
function recalcPtySize(session) {
|
|
2
|
+
let minCols = Infinity;
|
|
3
|
+
let minRows = Infinity;
|
|
4
|
+
for (const client of session.clients) {
|
|
5
|
+
if (client._dims) {
|
|
6
|
+
minCols = Math.min(minCols, client._dims.cols);
|
|
7
|
+
minRows = Math.min(minRows, client._dims.rows);
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
if (minCols === Infinity || minRows === Infinity) return;
|
|
11
|
+
if (minCols === session._lastCols && minRows === session._lastRows) return;
|
|
12
|
+
session._lastCols = minCols;
|
|
13
|
+
session._lastRows = minRows;
|
|
14
|
+
session.pty.resize(minCols, minRows);
|
|
15
|
+
}
|
|
16
|
+
|
|
1
17
|
function setupWebSocket(wss, { auth, sessions }) {
|
|
2
18
|
wss.on('connection', (ws, req) => {
|
|
3
19
|
let authenticated = !auth.password;
|
|
@@ -40,8 +56,8 @@ function setupWebSocket(wss, { auth, sessions }) {
|
|
|
40
56
|
}
|
|
41
57
|
attached = session;
|
|
42
58
|
session.clients.add(ws);
|
|
43
|
-
if (session.
|
|
44
|
-
ws.send(JSON.stringify({ type: 'output', data: session.
|
|
59
|
+
if (session.scrollbackBuf.length > 0) {
|
|
60
|
+
ws.send(JSON.stringify({ type: 'output', data: session.scrollbackBuf }));
|
|
45
61
|
}
|
|
46
62
|
ws.send(JSON.stringify({ type: 'attached', sessionId: msg.sessionId }));
|
|
47
63
|
console.log(`[termbeam] Client attached to session ${msg.sessionId}`);
|
|
@@ -53,7 +69,12 @@ function setupWebSocket(wss, { auth, sessions }) {
|
|
|
53
69
|
if (msg.type === 'input') {
|
|
54
70
|
attached.pty.write(msg.data);
|
|
55
71
|
} else if (msg.type === 'resize') {
|
|
56
|
-
|
|
72
|
+
const cols = Math.floor(msg.cols);
|
|
73
|
+
const rows = Math.floor(msg.rows);
|
|
74
|
+
if (cols > 0 && cols <= 500 && rows > 0 && rows <= 200) {
|
|
75
|
+
ws._dims = { cols, rows };
|
|
76
|
+
recalcPtySize(attached);
|
|
77
|
+
}
|
|
57
78
|
}
|
|
58
79
|
} catch {
|
|
59
80
|
if (attached) attached.pty.write(raw.toString());
|
|
@@ -63,6 +84,7 @@ function setupWebSocket(wss, { auth, sessions }) {
|
|
|
63
84
|
ws.on('close', () => {
|
|
64
85
|
if (attached) {
|
|
65
86
|
attached.clients.delete(ws);
|
|
87
|
+
recalcPtySize(attached);
|
|
66
88
|
console.log('[termbeam] Client detached');
|
|
67
89
|
}
|
|
68
90
|
});
|