termbeam 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +123 -0
- package/bin/termbeam.js +2 -0
- package/package.json +69 -0
- package/public/index.html +779 -0
- package/public/terminal.html +490 -0
- package/src/auth.js +124 -0
- package/src/cli.js +81 -0
- package/src/routes.js +87 -0
- package/src/server.js +122 -0
- package/src/sessions.js +90 -0
- package/src/tunnel.js +69 -0
- package/src/version.js +34 -0
- package/src/websocket.js +72 -0
|
@@ -0,0 +1,490 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta
|
|
6
|
+
name="viewport"
|
|
7
|
+
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"
|
|
8
|
+
/>
|
|
9
|
+
<meta name="apple-mobile-web-app-capable" content="yes" />
|
|
10
|
+
<meta name="mobile-web-app-capable" content="yes" />
|
|
11
|
+
<meta name="theme-color" content="#1a1a2e" />
|
|
12
|
+
<title>TermBeam — Terminal</title>
|
|
13
|
+
<link
|
|
14
|
+
rel="stylesheet"
|
|
15
|
+
href="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.min.css"
|
|
16
|
+
/>
|
|
17
|
+
<style>
|
|
18
|
+
@font-face {
|
|
19
|
+
font-family: 'NerdFont';
|
|
20
|
+
src: url('https://cdn.jsdelivr.net/gh/ryanoasis/nerd-fonts@latest/patched-fonts/JetBrainsMono/Ligatures/Regular/JetBrainsMonoNerdFont-Regular.ttf')
|
|
21
|
+
format('truetype');
|
|
22
|
+
font-weight: normal;
|
|
23
|
+
font-style: normal;
|
|
24
|
+
font-display: swap;
|
|
25
|
+
}
|
|
26
|
+
@font-face {
|
|
27
|
+
font-family: 'NerdFont';
|
|
28
|
+
src: url('https://cdn.jsdelivr.net/gh/ryanoasis/nerd-fonts@latest/patched-fonts/JetBrainsMono/Ligatures/Bold/JetBrainsMonoNerdFont-Bold.ttf')
|
|
29
|
+
format('truetype');
|
|
30
|
+
font-weight: bold;
|
|
31
|
+
font-style: normal;
|
|
32
|
+
font-display: swap;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
* {
|
|
36
|
+
margin: 0;
|
|
37
|
+
padding: 0;
|
|
38
|
+
box-sizing: border-box;
|
|
39
|
+
}
|
|
40
|
+
html,
|
|
41
|
+
body {
|
|
42
|
+
height: 100%;
|
|
43
|
+
width: 100%;
|
|
44
|
+
background: #1a1a2e;
|
|
45
|
+
color: #e0e0e0;
|
|
46
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
|
47
|
+
overflow: hidden;
|
|
48
|
+
touch-action: manipulation;
|
|
49
|
+
/* Use dvh to account for mobile browser chrome + keyboard */
|
|
50
|
+
height: 100dvh;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
#status-bar {
|
|
54
|
+
height: 36px;
|
|
55
|
+
display: flex;
|
|
56
|
+
align-items: center;
|
|
57
|
+
justify-content: space-between;
|
|
58
|
+
padding: 0 12px;
|
|
59
|
+
background: #16213e;
|
|
60
|
+
border-bottom: 1px solid #0f3460;
|
|
61
|
+
font-size: 13px;
|
|
62
|
+
}
|
|
63
|
+
#status-bar .left {
|
|
64
|
+
display: flex;
|
|
65
|
+
align-items: center;
|
|
66
|
+
gap: 8px;
|
|
67
|
+
}
|
|
68
|
+
#back-btn {
|
|
69
|
+
background: none;
|
|
70
|
+
border: none;
|
|
71
|
+
color: #888;
|
|
72
|
+
font-size: 18px;
|
|
73
|
+
cursor: pointer;
|
|
74
|
+
padding: 0 4px;
|
|
75
|
+
}
|
|
76
|
+
#back-btn:active {
|
|
77
|
+
color: #e0e0e0;
|
|
78
|
+
}
|
|
79
|
+
#status-dot {
|
|
80
|
+
width: 8px;
|
|
81
|
+
height: 8px;
|
|
82
|
+
border-radius: 50%;
|
|
83
|
+
background: #e74c3c;
|
|
84
|
+
display: inline-block;
|
|
85
|
+
}
|
|
86
|
+
#status-dot.connected {
|
|
87
|
+
background: #2ecc71;
|
|
88
|
+
}
|
|
89
|
+
#status-text {
|
|
90
|
+
color: #aaa;
|
|
91
|
+
}
|
|
92
|
+
#session-name {
|
|
93
|
+
font-weight: 600;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
#terminal-container {
|
|
97
|
+
position: absolute;
|
|
98
|
+
top: 36px;
|
|
99
|
+
left: 0;
|
|
100
|
+
right: 0;
|
|
101
|
+
bottom: 44px;
|
|
102
|
+
padding: 2px;
|
|
103
|
+
overflow: hidden;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
#key-bar {
|
|
107
|
+
position: fixed;
|
|
108
|
+
bottom: 0;
|
|
109
|
+
left: 0;
|
|
110
|
+
right: 0;
|
|
111
|
+
height: 44px;
|
|
112
|
+
display: flex;
|
|
113
|
+
align-items: center;
|
|
114
|
+
background: #16213e;
|
|
115
|
+
border-top: 1px solid #0f3460;
|
|
116
|
+
padding: 0 3px;
|
|
117
|
+
padding-bottom: env(safe-area-inset-bottom, 0);
|
|
118
|
+
gap: 3px;
|
|
119
|
+
overflow-x: auto;
|
|
120
|
+
z-index: 50;
|
|
121
|
+
}
|
|
122
|
+
.key-btn {
|
|
123
|
+
min-width: 40px;
|
|
124
|
+
height: 32px;
|
|
125
|
+
background: #0f3460;
|
|
126
|
+
color: #e0e0e0;
|
|
127
|
+
border: 1px solid #1a1a5e;
|
|
128
|
+
border-radius: 6px;
|
|
129
|
+
font-size: 11px;
|
|
130
|
+
font-weight: 600;
|
|
131
|
+
cursor: pointer;
|
|
132
|
+
display: flex;
|
|
133
|
+
align-items: center;
|
|
134
|
+
justify-content: center;
|
|
135
|
+
-webkit-tap-highlight-color: transparent;
|
|
136
|
+
user-select: none;
|
|
137
|
+
white-space: nowrap;
|
|
138
|
+
padding: 0 6px;
|
|
139
|
+
flex-shrink: 0;
|
|
140
|
+
}
|
|
141
|
+
.key-btn:active {
|
|
142
|
+
background: #533483;
|
|
143
|
+
}
|
|
144
|
+
.key-btn.wide {
|
|
145
|
+
min-width: 52px;
|
|
146
|
+
}
|
|
147
|
+
.key-sep {
|
|
148
|
+
width: 1px;
|
|
149
|
+
height: 20px;
|
|
150
|
+
background: #0f3460;
|
|
151
|
+
flex-shrink: 0;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
.xterm {
|
|
155
|
+
height: 100% !important;
|
|
156
|
+
}
|
|
157
|
+
.xterm-viewport {
|
|
158
|
+
overflow-y: hidden !important;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
#reconnect-overlay {
|
|
162
|
+
display: none;
|
|
163
|
+
position: fixed;
|
|
164
|
+
top: 0;
|
|
165
|
+
left: 0;
|
|
166
|
+
right: 0;
|
|
167
|
+
bottom: 0;
|
|
168
|
+
background: rgba(0, 0, 0, 0.85);
|
|
169
|
+
z-index: 100;
|
|
170
|
+
flex-direction: column;
|
|
171
|
+
align-items: center;
|
|
172
|
+
justify-content: center;
|
|
173
|
+
gap: 16px;
|
|
174
|
+
}
|
|
175
|
+
#reconnect-overlay.visible {
|
|
176
|
+
display: flex;
|
|
177
|
+
}
|
|
178
|
+
#reconnect-overlay .msg {
|
|
179
|
+
font-size: 17px;
|
|
180
|
+
}
|
|
181
|
+
.overlay-actions {
|
|
182
|
+
display: flex;
|
|
183
|
+
gap: 12px;
|
|
184
|
+
}
|
|
185
|
+
.overlay-actions button {
|
|
186
|
+
padding: 10px 24px;
|
|
187
|
+
border: none;
|
|
188
|
+
border-radius: 8px;
|
|
189
|
+
font-size: 15px;
|
|
190
|
+
font-weight: 600;
|
|
191
|
+
cursor: pointer;
|
|
192
|
+
}
|
|
193
|
+
#reconnect-btn {
|
|
194
|
+
background: #533483;
|
|
195
|
+
color: white;
|
|
196
|
+
}
|
|
197
|
+
#back-to-sessions {
|
|
198
|
+
background: #0f3460;
|
|
199
|
+
color: #e0e0e0;
|
|
200
|
+
}
|
|
201
|
+
</style>
|
|
202
|
+
</head>
|
|
203
|
+
<body>
|
|
204
|
+
<div id="status-bar">
|
|
205
|
+
<div class="left">
|
|
206
|
+
<button id="back-btn" onclick="location.href = '/'">‹</button>
|
|
207
|
+
<span id="status-dot"></span>
|
|
208
|
+
<span id="session-name">…</span>
|
|
209
|
+
</div>
|
|
210
|
+
<span id="status-text">Connecting…</span>
|
|
211
|
+
<span id="version-text" style="font-size: 11px; color: #555; margin-left: 8px"></span>
|
|
212
|
+
</div>
|
|
213
|
+
|
|
214
|
+
<div id="terminal-container"></div>
|
|
215
|
+
|
|
216
|
+
<div id="key-bar">
|
|
217
|
+
<button class="key-btn" data-key="[A">↑</button>
|
|
218
|
+
<button class="key-btn" data-key="[B">↓</button>
|
|
219
|
+
<button class="key-btn" data-key="[D">←</button>
|
|
220
|
+
<button class="key-btn" data-key="[C">→</button>
|
|
221
|
+
<div class="key-sep"></div>
|
|
222
|
+
<button class="key-btn wide" data-key="	">Tab</button>
|
|
223
|
+
<button class="key-btn wide" data-key="
">Enter</button>
|
|
224
|
+
<button class="key-btn" data-key="">Esc</button>
|
|
225
|
+
<div class="key-sep"></div>
|
|
226
|
+
<button class="key-btn" data-key="">^C</button>
|
|
227
|
+
<button class="key-btn" data-key="">^D</button>
|
|
228
|
+
<button class="key-btn" data-key="">^Z</button>
|
|
229
|
+
<button class="key-btn" data-key="">^L</button>
|
|
230
|
+
<div class="key-sep"></div>
|
|
231
|
+
<button class="key-btn" id="zoom-out">A-</button>
|
|
232
|
+
<button class="key-btn" id="zoom-in">A+</button>
|
|
233
|
+
</div>
|
|
234
|
+
|
|
235
|
+
<div id="reconnect-overlay">
|
|
236
|
+
<div class="msg">Session disconnected</div>
|
|
237
|
+
<div class="overlay-actions">
|
|
238
|
+
<button id="back-to-sessions" onclick="location.href = '/'">Sessions</button>
|
|
239
|
+
<button id="reconnect-btn">Reconnect</button>
|
|
240
|
+
</div>
|
|
241
|
+
</div>
|
|
242
|
+
|
|
243
|
+
<script src="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.min.js"></script>
|
|
244
|
+
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/lib/addon-fit.min.js"></script>
|
|
245
|
+
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-web-links@0.11.0/lib/addon-web-links.min.js"></script>
|
|
246
|
+
<script>
|
|
247
|
+
const sessionId = new URLSearchParams(location.search).get('id');
|
|
248
|
+
if (!sessionId) {
|
|
249
|
+
location.href = '/';
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const statusDot = document.getElementById('status-dot');
|
|
253
|
+
const statusText = document.getElementById('status-text');
|
|
254
|
+
const sessionName = document.getElementById('session-name');
|
|
255
|
+
const reconnectOverlay = document.getElementById('reconnect-overlay');
|
|
256
|
+
|
|
257
|
+
// Load Nerd Font, then init terminal
|
|
258
|
+
const nerdFont = new FontFace(
|
|
259
|
+
'NerdFont',
|
|
260
|
+
"url('https://cdn.jsdelivr.net/gh/ryanoasis/nerd-fonts@latest/patched-fonts/JetBrainsMono/Ligatures/Regular/JetBrainsMonoNerdFont-Regular.ttf')",
|
|
261
|
+
);
|
|
262
|
+
|
|
263
|
+
nerdFont
|
|
264
|
+
.load()
|
|
265
|
+
.then((font) => {
|
|
266
|
+
document.fonts.add(font);
|
|
267
|
+
initTerminal();
|
|
268
|
+
})
|
|
269
|
+
.catch(() => {
|
|
270
|
+
// Fallback: init without Nerd Font
|
|
271
|
+
console.warn('Nerd Font failed to load, using fallback');
|
|
272
|
+
initTerminal();
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
function initTerminal() {
|
|
276
|
+
const savedFontSize = parseInt(localStorage.getItem('termbeam-fontsize') || '8', 10);
|
|
277
|
+
const term = new window.Terminal({
|
|
278
|
+
cursorBlink: true,
|
|
279
|
+
fontSize: savedFontSize,
|
|
280
|
+
fontFamily:
|
|
281
|
+
"'NerdFont', 'JetBrains Mono', 'MesloLGS NF', 'Hack Nerd Font', 'Fira Code', Menlo, monospace",
|
|
282
|
+
fontWeight: 'normal',
|
|
283
|
+
fontWeightBold: 'bold',
|
|
284
|
+
letterSpacing: 0,
|
|
285
|
+
lineHeight: 1.1,
|
|
286
|
+
theme: {
|
|
287
|
+
background: '#1a1a2e',
|
|
288
|
+
foreground: '#e0e0e0',
|
|
289
|
+
cursor: '#533483',
|
|
290
|
+
cursorAccent: '#1a1a2e',
|
|
291
|
+
selectionBackground: 'rgba(83, 52, 131, 0.4)',
|
|
292
|
+
black: '#1a1a2e',
|
|
293
|
+
red: '#e74c3c',
|
|
294
|
+
green: '#2ecc71',
|
|
295
|
+
yellow: '#f1c40f',
|
|
296
|
+
blue: '#3498db',
|
|
297
|
+
magenta: '#9b59b6',
|
|
298
|
+
cyan: '#1abc9c',
|
|
299
|
+
white: '#ecf0f1',
|
|
300
|
+
brightBlack: '#636e72',
|
|
301
|
+
brightRed: '#ff6b6b',
|
|
302
|
+
brightGreen: '#55efc4',
|
|
303
|
+
brightYellow: '#ffeaa7',
|
|
304
|
+
brightBlue: '#74b9ff',
|
|
305
|
+
brightMagenta: '#a29bfe',
|
|
306
|
+
brightCyan: '#81ecec',
|
|
307
|
+
brightWhite: '#ffffff',
|
|
308
|
+
},
|
|
309
|
+
allowProposedApi: true,
|
|
310
|
+
scrollback: 10000,
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
const fitAddon = new window.FitAddon.FitAddon();
|
|
314
|
+
const webLinksAddon = new window.WebLinksAddon.WebLinksAddon();
|
|
315
|
+
term.loadAddon(fitAddon);
|
|
316
|
+
term.loadAddon(webLinksAddon);
|
|
317
|
+
|
|
318
|
+
const container = document.getElementById('terminal-container');
|
|
319
|
+
term.open(container);
|
|
320
|
+
fitAddon.fit();
|
|
321
|
+
|
|
322
|
+
let ws = null;
|
|
323
|
+
|
|
324
|
+
function connect() {
|
|
325
|
+
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
|
326
|
+
ws = new WebSocket(`${proto}://${location.host}/ws`);
|
|
327
|
+
|
|
328
|
+
ws.onopen = () => {
|
|
329
|
+
statusDot.className = 'connected';
|
|
330
|
+
statusText.textContent = 'Connected';
|
|
331
|
+
reconnectOverlay.classList.remove('visible');
|
|
332
|
+
// Attach to session
|
|
333
|
+
ws.send(JSON.stringify({ type: 'attach', sessionId }));
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
ws.onmessage = (event) => {
|
|
337
|
+
try {
|
|
338
|
+
const msg = JSON.parse(event.data);
|
|
339
|
+
if (msg.type === 'output') {
|
|
340
|
+
term.write(msg.data);
|
|
341
|
+
} else if (msg.type === 'attached') {
|
|
342
|
+
// Send terminal size after attach
|
|
343
|
+
const dims = fitAddon.proposeDimensions();
|
|
344
|
+
if (dims) {
|
|
345
|
+
ws.send(JSON.stringify({ type: 'resize', cols: dims.cols, rows: dims.rows }));
|
|
346
|
+
}
|
|
347
|
+
} else if (msg.type === 'exit') {
|
|
348
|
+
statusText.textContent = `Exited (code ${msg.code})`;
|
|
349
|
+
statusDot.className = '';
|
|
350
|
+
reconnectOverlay.querySelector('.msg').textContent =
|
|
351
|
+
`Session exited (code ${msg.code})`;
|
|
352
|
+
reconnectOverlay.classList.add('visible');
|
|
353
|
+
} else if (msg.type === 'error') {
|
|
354
|
+
statusText.textContent = msg.message;
|
|
355
|
+
reconnectOverlay.querySelector('.msg').textContent = msg.message;
|
|
356
|
+
reconnectOverlay.classList.add('visible');
|
|
357
|
+
}
|
|
358
|
+
} catch {
|
|
359
|
+
term.write(event.data);
|
|
360
|
+
}
|
|
361
|
+
};
|
|
362
|
+
|
|
363
|
+
ws.onclose = () => {
|
|
364
|
+
statusDot.className = '';
|
|
365
|
+
statusText.textContent = 'Disconnected';
|
|
366
|
+
reconnectOverlay.classList.add('visible');
|
|
367
|
+
};
|
|
368
|
+
|
|
369
|
+
ws.onerror = () => {
|
|
370
|
+
statusText.textContent = 'Connection error';
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// Terminal input → WebSocket
|
|
375
|
+
term.onData((data) => {
|
|
376
|
+
if (ws && ws.readyState === 1) {
|
|
377
|
+
ws.send(JSON.stringify({ type: 'input', data }));
|
|
378
|
+
}
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
// Resize
|
|
382
|
+
function doResize() {
|
|
383
|
+
fitAddon.fit();
|
|
384
|
+
if (ws && ws.readyState === 1) {
|
|
385
|
+
const dims = fitAddon.proposeDimensions();
|
|
386
|
+
if (dims) {
|
|
387
|
+
ws.send(JSON.stringify({ type: 'resize', cols: dims.cols, rows: dims.rows }));
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
window.addEventListener('resize', doResize);
|
|
392
|
+
screen.orientation?.addEventListener('change', () => setTimeout(doResize, 150));
|
|
393
|
+
|
|
394
|
+
// Key bar (skip zoom buttons — handled separately)
|
|
395
|
+
// Use mousedown + preventDefault to stop buttons from stealing focus/opening keyboard
|
|
396
|
+
document.getElementById('key-bar').addEventListener('mousedown', (e) => {
|
|
397
|
+
// Only prevent default on buttons, not the scrollable bar itself
|
|
398
|
+
if (e.target.closest('.key-btn')) {
|
|
399
|
+
e.preventDefault();
|
|
400
|
+
}
|
|
401
|
+
});
|
|
402
|
+
// touchstart must be passive to allow native horizontal scrolling
|
|
403
|
+
document
|
|
404
|
+
.getElementById('key-bar')
|
|
405
|
+
.addEventListener('touchstart', () => {}, { passive: true });
|
|
406
|
+
document.getElementById('key-bar').addEventListener('click', (e) => {
|
|
407
|
+
const btn = e.target.closest('.key-btn');
|
|
408
|
+
if (!btn || btn.id === 'zoom-in' || btn.id === 'zoom-out') return;
|
|
409
|
+
if (ws && ws.readyState === 1) {
|
|
410
|
+
ws.send(JSON.stringify({ type: 'input', data: btn.dataset.key }));
|
|
411
|
+
}
|
|
412
|
+
// Don't call term.focus() here — it opens the soft keyboard
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
// Zoom
|
|
416
|
+
const MIN_FONT = 2,
|
|
417
|
+
MAX_FONT = 28;
|
|
418
|
+
let fontSize = parseInt(localStorage.getItem('termbeam-fontsize') || '8', 10);
|
|
419
|
+
|
|
420
|
+
function applyZoom(size) {
|
|
421
|
+
fontSize = Math.max(MIN_FONT, Math.min(MAX_FONT, size));
|
|
422
|
+
term.options.fontSize = fontSize;
|
|
423
|
+
localStorage.setItem('termbeam-fontsize', fontSize);
|
|
424
|
+
doResize();
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
document.getElementById('zoom-in').addEventListener('click', () => {
|
|
428
|
+
applyZoom(fontSize + 2);
|
|
429
|
+
});
|
|
430
|
+
document.getElementById('zoom-out').addEventListener('click', () => {
|
|
431
|
+
applyZoom(fontSize - 2);
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
// Reconnect
|
|
435
|
+
document.getElementById('reconnect-btn').addEventListener('click', () => {
|
|
436
|
+
term.clear();
|
|
437
|
+
connect();
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
// Tap terminal area to toggle keyboard (intentional user action)
|
|
441
|
+
container.addEventListener('click', () => term.focus());
|
|
442
|
+
|
|
443
|
+
// Handle mobile soft keyboard via visualViewport
|
|
444
|
+
// When keyboard opens, the viewport shrinks — reposition key bar and resize terminal
|
|
445
|
+
if (window.visualViewport) {
|
|
446
|
+
const keyBar = document.getElementById('key-bar');
|
|
447
|
+
const statusBar = document.getElementById('status-bar');
|
|
448
|
+
|
|
449
|
+
function onViewportResize() {
|
|
450
|
+
const vv = window.visualViewport;
|
|
451
|
+
const keyboardHeight = window.innerHeight - vv.height;
|
|
452
|
+
|
|
453
|
+
if (keyboardHeight > 50) {
|
|
454
|
+
// Keyboard is open — move key bar above it
|
|
455
|
+
keyBar.style.bottom = keyboardHeight + 'px';
|
|
456
|
+
container.style.bottom = 44 + keyboardHeight + 'px';
|
|
457
|
+
} else {
|
|
458
|
+
// Keyboard closed
|
|
459
|
+
keyBar.style.bottom = '0px';
|
|
460
|
+
container.style.bottom = '44px';
|
|
461
|
+
}
|
|
462
|
+
// Refit terminal to new available space
|
|
463
|
+
setTimeout(() => doResize(), 50);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
window.visualViewport.addEventListener('resize', onViewportResize);
|
|
467
|
+
window.visualViewport.addEventListener('scroll', onViewportResize);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// Fetch session name
|
|
471
|
+
fetch('/api/sessions')
|
|
472
|
+
.then((r) => r.json())
|
|
473
|
+
.then((sessions) => {
|
|
474
|
+
const s = sessions.find((s) => s.id === sessionId);
|
|
475
|
+
if (s) sessionName.textContent = s.name;
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
// Fetch version
|
|
479
|
+
fetch('/api/version')
|
|
480
|
+
.then((r) => r.json())
|
|
481
|
+
.then((d) => {
|
|
482
|
+
document.getElementById('version-text').textContent = 'v' + d.version;
|
|
483
|
+
})
|
|
484
|
+
.catch(() => {});
|
|
485
|
+
|
|
486
|
+
connect();
|
|
487
|
+
}
|
|
488
|
+
</script>
|
|
489
|
+
</body>
|
|
490
|
+
</html>
|
package/src/auth.js
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
const crypto = require('crypto');
|
|
2
|
+
|
|
3
|
+
const LOGIN_HTML = `<!DOCTYPE html>
|
|
4
|
+
<html lang="en">
|
|
5
|
+
<head>
|
|
6
|
+
<meta charset="UTF-8" />
|
|
7
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
|
8
|
+
<meta name="theme-color" content="#1a1a2e" />
|
|
9
|
+
<title>TermBeam — Login</title>
|
|
10
|
+
<style>
|
|
11
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
12
|
+
html, body { height: 100%; background: #1a1a2e; color: #e0e0e0;
|
|
13
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
|
14
|
+
display: flex; align-items: center; justify-content: center; }
|
|
15
|
+
.card { background: #16213e; border: 1px solid #0f3460; border-radius: 16px;
|
|
16
|
+
padding: 32px 24px; width: 320px; text-align: center; }
|
|
17
|
+
h1 { font-size: 20px; margin-bottom: 8px; }
|
|
18
|
+
h1 span { color: #533483; }
|
|
19
|
+
p { font-size: 13px; color: #888; margin-bottom: 24px; }
|
|
20
|
+
input { width: 100%; padding: 12px; background: #1a1a2e; border: 1px solid #0f3460;
|
|
21
|
+
border-radius: 8px; color: #e0e0e0; font-size: 16px; outline: none;
|
|
22
|
+
text-align: center; letter-spacing: 2px; }
|
|
23
|
+
input:focus { border-color: #533483; }
|
|
24
|
+
button { width: 100%; padding: 12px; margin-top: 16px; background: #533483;
|
|
25
|
+
color: white; border: none; border-radius: 8px; font-size: 16px;
|
|
26
|
+
font-weight: 600; cursor: pointer; }
|
|
27
|
+
button:active { background: #6a42a8; }
|
|
28
|
+
.error { color: #e74c3c; font-size: 13px; margin-top: 12px; display: none; }
|
|
29
|
+
</style>
|
|
30
|
+
</head>
|
|
31
|
+
<body>
|
|
32
|
+
<div class="card">
|
|
33
|
+
<h1>📡 Term<span>Beam</span></h1>
|
|
34
|
+
<p>Enter the access password</p>
|
|
35
|
+
<form id="form">
|
|
36
|
+
<input type="password" id="pw" placeholder="Password" autocomplete="off" autofocus />
|
|
37
|
+
<button type="submit">Unlock</button>
|
|
38
|
+
</form>
|
|
39
|
+
<div class="error" id="err">Incorrect password</div>
|
|
40
|
+
</div>
|
|
41
|
+
<script>
|
|
42
|
+
document.getElementById('form').addEventListener('submit', async (e) => {
|
|
43
|
+
e.preventDefault();
|
|
44
|
+
const pw = document.getElementById('pw').value;
|
|
45
|
+
const res = await fetch('/api/auth', {
|
|
46
|
+
method: 'POST',
|
|
47
|
+
headers: { 'Content-Type': 'application/json' },
|
|
48
|
+
body: JSON.stringify({ password: pw }),
|
|
49
|
+
});
|
|
50
|
+
if (res.ok) { location.href = '/'; }
|
|
51
|
+
else {
|
|
52
|
+
document.getElementById('err').style.display = 'block';
|
|
53
|
+
document.getElementById('pw').value = '';
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
</script>
|
|
57
|
+
</body>
|
|
58
|
+
</html>`;
|
|
59
|
+
|
|
60
|
+
function createAuth(password) {
|
|
61
|
+
const tokens = new Map();
|
|
62
|
+
const authAttempts = new Map();
|
|
63
|
+
|
|
64
|
+
function generateToken() {
|
|
65
|
+
const token = crypto.randomBytes(32).toString('hex');
|
|
66
|
+
tokens.set(token, Date.now() + 24 * 60 * 60 * 1000);
|
|
67
|
+
return token;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function validateToken(token) {
|
|
71
|
+
const expiry = tokens.get(token);
|
|
72
|
+
if (!expiry) return false;
|
|
73
|
+
if (Date.now() > expiry) {
|
|
74
|
+
tokens.delete(token);
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function middleware(req, res, next) {
|
|
81
|
+
if (!password) return next();
|
|
82
|
+
if (req.cookies.pty_token && validateToken(req.cookies.pty_token)) return next();
|
|
83
|
+
const authHeader = req.headers.authorization;
|
|
84
|
+
if (authHeader === `Bearer ${password}`) return next();
|
|
85
|
+
if (req.accepts('html')) return res.redirect('/login');
|
|
86
|
+
res.status(401).json({ error: 'unauthorized' });
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function rateLimit(req, res, next) {
|
|
90
|
+
const ip = req.ip || req.socket.remoteAddress;
|
|
91
|
+
const now = Date.now();
|
|
92
|
+
const window = 60 * 1000;
|
|
93
|
+
const maxAttempts = 5;
|
|
94
|
+
const attempts = authAttempts.get(ip) || [];
|
|
95
|
+
const recent = attempts.filter((t) => now - t < window);
|
|
96
|
+
if (recent.length >= maxAttempts) {
|
|
97
|
+
return res.status(429).json({ error: 'Too many attempts. Try again later.' });
|
|
98
|
+
}
|
|
99
|
+
recent.push(now);
|
|
100
|
+
authAttempts.set(ip, recent);
|
|
101
|
+
next();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function parseCookies(str) {
|
|
105
|
+
const cookies = {};
|
|
106
|
+
str.split(';').forEach((c) => {
|
|
107
|
+
const [k, ...v] = c.trim().split('=');
|
|
108
|
+
if (k) cookies[k] = v.join('=');
|
|
109
|
+
});
|
|
110
|
+
return cookies;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
password,
|
|
115
|
+
generateToken,
|
|
116
|
+
validateToken,
|
|
117
|
+
middleware,
|
|
118
|
+
rateLimit,
|
|
119
|
+
parseCookies,
|
|
120
|
+
loginHTML: LOGIN_HTML,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
module.exports = { createAuth };
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
const os = require('os');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const crypto = require('crypto');
|
|
4
|
+
|
|
5
|
+
function printHelp() {
|
|
6
|
+
console.log(`
|
|
7
|
+
termbeam — Beam your terminal to any device
|
|
8
|
+
|
|
9
|
+
Usage:
|
|
10
|
+
termbeam [options] [shell] [args...]
|
|
11
|
+
|
|
12
|
+
Options:
|
|
13
|
+
--password <pw> Set access password (or TERMBEAM_PASSWORD env var)
|
|
14
|
+
--generate-password Auto-generate a secure password
|
|
15
|
+
--tunnel Create a public devtunnel URL
|
|
16
|
+
--port <port> Set port (default: 3456, or PORT env var)
|
|
17
|
+
--host <addr> Bind address (default: 0.0.0.0)
|
|
18
|
+
-h, --help Show this help
|
|
19
|
+
-v, --version Show version
|
|
20
|
+
|
|
21
|
+
Examples:
|
|
22
|
+
termbeam Start with default shell
|
|
23
|
+
termbeam --password secret Start with password auth
|
|
24
|
+
termbeam --generate-password Start with auto-generated password
|
|
25
|
+
termbeam --tunnel --password pw Start with public tunnel
|
|
26
|
+
termbeam /bin/bash Use bash instead of default shell
|
|
27
|
+
|
|
28
|
+
Environment:
|
|
29
|
+
PORT Server port (default: 3456)
|
|
30
|
+
TERMBEAM_PASSWORD Access password
|
|
31
|
+
TERMBEAM_CWD Working directory
|
|
32
|
+
`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function parseArgs() {
|
|
36
|
+
let port = parseInt(process.env.PORT || '3456', 10);
|
|
37
|
+
let host = '0.0.0.0';
|
|
38
|
+
const defaultShell = process.env.SHELL || '/bin/zsh';
|
|
39
|
+
const cwd = process.env.TERMBEAM_CWD || process.env.PTY_CWD || process.cwd();
|
|
40
|
+
let password = process.env.TERMBEAM_PASSWORD || process.env.PTY_PASSWORD || null;
|
|
41
|
+
let useTunnel = false;
|
|
42
|
+
|
|
43
|
+
const args = process.argv.slice(2);
|
|
44
|
+
const filteredArgs = [];
|
|
45
|
+
|
|
46
|
+
for (let i = 0; i < args.length; i++) {
|
|
47
|
+
if (args[i] === '--password' && args[i + 1]) {
|
|
48
|
+
password = args[++i];
|
|
49
|
+
} else if (args[i] === '--tunnel') {
|
|
50
|
+
useTunnel = true;
|
|
51
|
+
} else if (args[i].startsWith('--password=')) {
|
|
52
|
+
password = args[i].split('=')[1];
|
|
53
|
+
} else if (args[i] === '--help' || args[i] === '-h') {
|
|
54
|
+
printHelp();
|
|
55
|
+
process.exit(0);
|
|
56
|
+
} else if (args[i] === '--version' || args[i] === '-v') {
|
|
57
|
+
const { getVersion } = require('./version');
|
|
58
|
+
console.log(`termbeam v${getVersion()}`);
|
|
59
|
+
process.exit(0);
|
|
60
|
+
} else if (args[i] === '--generate-password') {
|
|
61
|
+
password = crypto.randomBytes(16).toString('base64url');
|
|
62
|
+
console.log(`Generated password: ${password}`);
|
|
63
|
+
} else if (args[i] === '--port' && args[i + 1]) {
|
|
64
|
+
port = parseInt(args[++i], 10);
|
|
65
|
+
} else if (args[i] === '--host' && args[i + 1]) {
|
|
66
|
+
host = args[++i];
|
|
67
|
+
} else {
|
|
68
|
+
filteredArgs.push(args[i]);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const shell = filteredArgs[0] || defaultShell;
|
|
73
|
+
const shellArgs = filteredArgs.slice(1);
|
|
74
|
+
|
|
75
|
+
const { getVersion } = require('./version');
|
|
76
|
+
const version = getVersion();
|
|
77
|
+
|
|
78
|
+
return { port, host, password, useTunnel, shell, shellArgs, cwd, defaultShell, version };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
module.exports = { parseArgs, printHelp };
|