terminal-expose 1.0.0 → 1.2.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.
@@ -0,0 +1,56 @@
1
+ const { Server } = require("socket.io");
2
+ const { SESSION_TOKEN } = require("../config/env");
3
+
4
+ function setupSocket(server, terminalSession) {
5
+ const io = new Server(server, {
6
+ cors: {
7
+ origin: false,
8
+ },
9
+ });
10
+
11
+ io.use((socket, next) => {
12
+ const token = socket.handshake.auth?.token;
13
+
14
+ if (token !== SESSION_TOKEN) {
15
+ return next(new Error("unauthorized"));
16
+ }
17
+
18
+ return next();
19
+ });
20
+
21
+ io.on("connection", (socket) => {
22
+ console.log(`Client connected: ${socket.id}`);
23
+
24
+ if (terminalSession.buffer) {
25
+ socket.emit("terminal-output", terminalSession.buffer);
26
+ }
27
+
28
+ if (terminalSession.exit) {
29
+ socket.emit("terminal-exit", terminalSession.exit);
30
+ }
31
+
32
+ socket.on("terminal-input", (data) => {
33
+ terminalSession.write(data);
34
+ });
35
+
36
+ socket.on("terminal-resize", ({ cols, rows }) => {
37
+ terminalSession.resize(cols, rows);
38
+ });
39
+
40
+ socket.on("disconnect", () => {
41
+ console.log(`Client disconnected: ${socket.id}`);
42
+ });
43
+ });
44
+
45
+ terminalSession.on("data", (data) => {
46
+ io.emit("terminal-output", data);
47
+ });
48
+
49
+ terminalSession.on("exit", (exitState) => {
50
+ io.emit("terminal-exit", exitState);
51
+ });
52
+
53
+ return io;
54
+ }
55
+
56
+ module.exports = { setupSocket };
@@ -0,0 +1,93 @@
1
+ const pty = require("node-pty");
2
+ const {
3
+ ECHO_TO_STDOUT,
4
+ MAX_BUFFER,
5
+ TERM_COLS,
6
+ TERM_ROWS,
7
+ WORKDIR,
8
+ } = require("../config/env");
9
+
10
+ class TerminalSession {
11
+ constructor(shellCommand, shellArgs) {
12
+ this.buffer = "";
13
+ this.exit = null;
14
+ this.listeners = [];
15
+
16
+ this.shell = pty.spawn(shellCommand, shellArgs, {
17
+ name: "xterm-256color",
18
+ cwd: WORKDIR,
19
+ env: {
20
+ ...process.env,
21
+ TERM: "xterm-256color",
22
+ },
23
+ });
24
+
25
+ // verify if terminal was available or not bro
26
+ if (process.stdin.isTTY) {
27
+ // listening to every stroke of host terminal input
28
+ process.stdin.setRawMode(true);
29
+ }
30
+ process.stdin.resume();
31
+
32
+ // write to shell after getting the input from host terminal
33
+ process.stdin.on("data", (data) => {
34
+ this.shell.write(data);
35
+ });
36
+
37
+ // after getting the data from shell writing it to host terminal
38
+ this.shell.onData((data) => {
39
+ if (ECHO_TO_STDOUT) {
40
+ // this write the user input to the host terminal
41
+ process.stdout.write(data);
42
+ }
43
+
44
+ this.buffer += data;
45
+ if (this.buffer.length > MAX_BUFFER) {
46
+ this.buffer = this.buffer.slice(-MAX_BUFFER);
47
+ }
48
+
49
+ // ! need to verify it was need or not
50
+ this.emit("data", data);
51
+ });
52
+
53
+ // if the terminal was exited in host machine exit the process and inform the listeing clients
54
+ this.shell.onExit(({ exitCode, signal }) => {
55
+ this.exit = { exitCode, signal };
56
+ this.emit("exit", this.exit);
57
+
58
+ console.log(`Terminal-Expose exited: code=${exitCode} signal=${signal}`);
59
+
60
+ if (process.stdin.isTTY) {
61
+ process.stdin.setRawMode(false);
62
+ }
63
+ });
64
+ }
65
+
66
+ write(data) {
67
+ if (this.shell) {
68
+ this.shell.write(data);
69
+ }
70
+ }
71
+
72
+ resize(cols, rows) {
73
+ if (this.shell && cols && rows) {
74
+ try {
75
+ this.shell.resize(cols, rows);
76
+ } catch (err) {
77
+ console.error("Failed to resize terminal", err);
78
+ }
79
+ }
80
+ }
81
+
82
+ on(event, callback) {
83
+ this.listeners.push({ event, callback });
84
+ }
85
+
86
+ emit(event, data) {
87
+ this.listeners
88
+ .filter((listener) => listener.event === event)
89
+ .forEach((listener) => listener.callback(data));
90
+ }
91
+ }
92
+
93
+ module.exports = { TerminalSession };
@@ -0,0 +1,40 @@
1
+ const { startTunnel } = require("untun");
2
+
3
+ async function startPublicTunnel(options, { HOST, PORT, SESSION_TOKEN }) {
4
+ const localHost = HOST === "0.0.0.0" || HOST === "::" ? "127.0.0.1" : HOST;
5
+
6
+ if (options.tunnelSubdomain || options.tunnelHost) {
7
+ console.warn(
8
+ "Warning: Custom subdomain and tunnel host are not supported with Cloudflare Quick Tunnels.",
9
+ );
10
+ }
11
+
12
+ console.log("Public tunnel: starting cloudflared...");
13
+
14
+ try {
15
+ const tunnel = await startTunnel({
16
+ port: PORT,
17
+ hostname: localHost,
18
+ acceptCloudflareNotice: true,
19
+ });
20
+
21
+ const url = await tunnel.getURL();
22
+ const publicUrl = `${url.replace(/\/$/, "")}/s/${SESSION_TOKEN}`;
23
+
24
+ console.log(`Public URL: ${publicUrl}`);
25
+
26
+ ["SIGINT", "SIGTERM"].forEach((signal) => {
27
+ process.once(signal, async () => {
28
+ await tunnel.close();
29
+ process.kill(process.pid, signal);
30
+ });
31
+ });
32
+
33
+ return tunnel;
34
+ } catch (error) {
35
+ console.error(`Public tunnel error: ${error.message}`);
36
+ throw error;
37
+ }
38
+ }
39
+
40
+ module.exports = { startPublicTunnel };
@@ -0,0 +1,45 @@
1
+ const express = require("express");
2
+ const path = require("path");
3
+ const { SESSION_TOKEN } = require("../config/env");
4
+
5
+ function createApp(ioGetter, terminalSession) {
6
+ const app = express();
7
+
8
+ // We are referring to public directory in root
9
+ app.use("/assets", express.static(path.join(__dirname, "../../public")));
10
+
11
+ app.get("/", (_, res) => {
12
+ res.type("html").send(`<!doctype html>
13
+ <html>
14
+ <head>
15
+ <meta charset="utf-8" />
16
+ <title>Terminal Expose</title>
17
+ </head>
18
+ <body style="font-family: system-ui, sans-serif; line-height: 1.5; padding: 32px;">
19
+ <h1>Terminal Expose</h1>
20
+ <p>This server is running. Use the private session URL printed in the container logs.</p>
21
+ </body>
22
+ </html>`);
23
+ });
24
+
25
+ app.get("/health", (_, res) => {
26
+ const io = ioGetter();
27
+ res.json({
28
+ ok: true,
29
+ clients: io ? io.engine.clientsCount : 0,
30
+ exited: terminalSession.exit,
31
+ });
32
+ });
33
+
34
+ app.get("/s/:token", (req, res) => {
35
+ if (req.params.token !== SESSION_TOKEN) {
36
+ return res.status(403).send("Invalid session token");
37
+ }
38
+
39
+ res.sendFile(path.join(__dirname, "../templates/viewer.html"));
40
+ });
41
+
42
+ return app;
43
+ }
44
+
45
+ module.exports = { createApp };
@@ -0,0 +1,482 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+
7
+ <title>Terminal Session</title>
8
+
9
+ <link
10
+ rel="stylesheet"
11
+ href="https://cdn.jsdelivr.net/npm/xterm/css/xterm.css"
12
+ />
13
+
14
+ <style>
15
+ * {
16
+ box-sizing: border-box;
17
+ }
18
+
19
+ html,
20
+ body {
21
+ width: 100%;
22
+ height: 100%;
23
+ margin: 0;
24
+
25
+ overflow: hidden;
26
+
27
+ background: #09090b;
28
+ color: #ffffff;
29
+
30
+ font-family:
31
+ Inter,
32
+ system-ui,
33
+ sans-serif;
34
+ }
35
+
36
+
37
+ /* ======================
38
+ Layout
39
+ ====================== */
40
+
41
+ .app {
42
+ height: 100vh;
43
+
44
+ display: flex;
45
+ flex-direction: column;
46
+ }
47
+
48
+
49
+ /* ======================
50
+ Header
51
+ ====================== */
52
+
53
+ .header {
54
+ height: 48px;
55
+
56
+ padding: 0 16px;
57
+
58
+ display: flex;
59
+ align-items: center;
60
+ justify-content: space-between;
61
+
62
+ background:
63
+ linear-gradient(
64
+ 180deg,
65
+ #18181b,
66
+ #09090b
67
+ );
68
+
69
+ border-bottom: 1px solid #27272a;
70
+
71
+ user-select: none;
72
+ }
73
+
74
+
75
+ .title {
76
+ font-size: 14px;
77
+ font-weight: 500;
78
+
79
+ color: #e4e4e7;
80
+ }
81
+
82
+
83
+ .actions {
84
+ display: flex;
85
+ align-items: center;
86
+
87
+ gap: 14px;
88
+
89
+ font-size: 13px;
90
+ }
91
+
92
+
93
+
94
+ /* ======================
95
+ Status
96
+ ====================== */
97
+
98
+
99
+ .status {
100
+ display: flex;
101
+ align-items: center;
102
+
103
+ gap: 6px;
104
+ }
105
+
106
+
107
+ .status-dot {
108
+ width: 9px;
109
+ height: 9px;
110
+
111
+ border-radius: 999px;
112
+
113
+ background: currentColor;
114
+
115
+ box-shadow:
116
+ 0 0 12px currentColor;
117
+ }
118
+
119
+
120
+ .connected {
121
+ color: #22c55e;
122
+ }
123
+
124
+
125
+ .connecting {
126
+ color: #f97316;
127
+ }
128
+
129
+
130
+ .disconnected {
131
+ color: #ef4444;
132
+ }
133
+
134
+
135
+
136
+ /* ======================
137
+ Buttons
138
+ ====================== */
139
+
140
+
141
+ button {
142
+ padding: 5px 10px;
143
+
144
+ cursor: pointer;
145
+
146
+ background: #18181b;
147
+ color: #e4e4e7;
148
+
149
+ border:
150
+ 1px solid #3f3f46;
151
+
152
+ border-radius: 6px;
153
+ }
154
+
155
+
156
+ button:hover {
157
+ background: #27272a;
158
+ }
159
+
160
+
161
+
162
+
163
+ /* ======================
164
+ Terminal
165
+ ====================== */
166
+
167
+
168
+ #terminal {
169
+ flex: 1;
170
+
171
+ padding: 12px;
172
+
173
+ overflow: hidden;
174
+ }
175
+
176
+
177
+ .xterm {
178
+ height: 100%;
179
+ }
180
+
181
+
182
+
183
+ /* ======================
184
+ Scrollbar
185
+ ====================== */
186
+
187
+
188
+ .xterm-viewport::-webkit-scrollbar {
189
+ width: 8px;
190
+ }
191
+
192
+
193
+ .xterm-viewport::-webkit-scrollbar-track {
194
+ background: transparent;
195
+ }
196
+
197
+
198
+ .xterm-viewport::-webkit-scrollbar-thumb {
199
+ background: #3f3f46;
200
+
201
+ border-radius: 20px;
202
+ }
203
+
204
+
205
+ .xterm-viewport::-webkit-scrollbar-thumb:hover {
206
+ background: #71717a;
207
+ }
208
+
209
+
210
+
211
+ @media(max-width: 600px) {
212
+
213
+ .title {
214
+ font-size: 12px;
215
+ }
216
+
217
+
218
+ .actions {
219
+ gap: 8px;
220
+ }
221
+
222
+ }
223
+ </style>
224
+ </head>
225
+
226
+ <body>
227
+ <div class="app">
228
+ <header class="header">
229
+ <div class="title">⚡ Remote Terminal</div>
230
+
231
+ <div class="actions">
232
+ <span id="timer"> 00:00 </span>
233
+
234
+ <div id="status" class="status connecting">
235
+ <span class="status-dot"></span>
236
+
237
+ <span id="statusText"> Connecting </span>
238
+ </div>
239
+
240
+ <button id="fullscreen">⛶</button>
241
+ </div>
242
+ </header>
243
+
244
+ <main id="terminal"></main>
245
+ </div>
246
+
247
+ <script src="/socket.io/socket.io.js"></script>
248
+ <script src="https://cdn.jsdelivr.net/npm/xterm/lib/xterm.js"></script>
249
+ <script src="https://cdn.jsdelivr.net/npm/xterm-addon-fit/lib/xterm-addon-fit.js"></script>
250
+
251
+ <script>
252
+
253
+
254
+ const token =
255
+ location.pathname
256
+ .split("/")
257
+ .filter(Boolean)
258
+ .pop();
259
+
260
+
261
+
262
+ const term =
263
+ new Terminal({
264
+ cursorBlink: true,
265
+ convertEol: true,
266
+ fontFamily:
267
+ `"JetBrains Mono", monospace`,
268
+ fontSize: 14,
269
+ lineHeight: 1.3,
270
+ scrollback: 20000,
271
+ theme: {
272
+ background: "#09090b",
273
+ foreground: "#fafafa",
274
+ cursor: "#22c55e",
275
+ selection: "#334155"
276
+ }
277
+ });
278
+
279
+
280
+
281
+ const fitAddon =
282
+ new FitAddon.FitAddon();
283
+
284
+
285
+
286
+ term.loadAddon(
287
+ fitAddon
288
+ );
289
+
290
+
291
+ term.open(
292
+ document.getElementById(
293
+ "terminal"
294
+ )
295
+ );
296
+
297
+
298
+ fitAddon.fit();
299
+
300
+
301
+
302
+ const socket =
303
+ io({
304
+ auth:{
305
+ token
306
+ },
307
+ reconnection: true,
308
+ reconnectionAttempts:20,
309
+ timeout:5000
310
+ });
311
+
312
+ const statusBox =
313
+ document.getElementById(
314
+ "status"
315
+ );
316
+
317
+ const statusText =
318
+ document.getElementById(
319
+ "statusText"
320
+ );
321
+
322
+
323
+ function setStatus(
324
+ text,
325
+ type
326
+ ){
327
+ statusBox.className =
328
+ "status " + type;
329
+ statusText.textContent =
330
+ text;
331
+
332
+ }
333
+
334
+
335
+
336
+
337
+ socket.on(
338
+ "connect",
339
+ () => {
340
+
341
+ setStatus(
342
+ "Connected",
343
+ "connected"
344
+ );
345
+
346
+
347
+ socket.emit(
348
+ "terminal-resize",
349
+ {
350
+ cols: term.cols,
351
+ rows: term.rows
352
+ }
353
+ );
354
+
355
+ }
356
+ );
357
+
358
+
359
+
360
+
361
+
362
+ socket.on(
363
+ "disconnect",
364
+ () => {
365
+
366
+ setStatus(
367
+ "Disconnected",
368
+ "disconnected"
369
+ );
370
+
371
+ }
372
+ );
373
+
374
+
375
+
376
+
377
+ socket.io.on(
378
+ "reconnect_attempt",
379
+ () => {
380
+
381
+ setStatus(
382
+ "Reconnecting",
383
+ "connecting"
384
+ );
385
+
386
+ }
387
+ );
388
+
389
+
390
+
391
+
392
+ socket.on(
393
+ "terminal-output",
394
+ data => {
395
+
396
+ term.write(data);
397
+
398
+ }
399
+ );
400
+
401
+
402
+
403
+
404
+
405
+ term.onData(
406
+ data => {
407
+
408
+ socket.emit(
409
+ "terminal-input",
410
+ data
411
+ );
412
+
413
+ }
414
+ );
415
+
416
+
417
+
418
+
419
+ window.addEventListener(
420
+ "resize",
421
+ () => {
422
+
423
+ fitAddon.fit();
424
+
425
+
426
+ socket.emit(
427
+ "terminal-resize",
428
+ {
429
+ cols: term.cols,
430
+ rows: term.rows
431
+ }
432
+ );
433
+
434
+ }
435
+ );
436
+
437
+
438
+
439
+
440
+
441
+ let seconds = 0;
442
+
443
+
444
+ setInterval(
445
+ () => {
446
+
447
+ if(!socket.connected)
448
+ return;
449
+
450
+
451
+ seconds++;
452
+
453
+
454
+ timer.textContent =
455
+ new Date(
456
+ seconds * 1000
457
+ )
458
+ .toISOString()
459
+ .substring(
460
+ 14,
461
+ 19
462
+ );
463
+
464
+
465
+ },
466
+ 1000
467
+ );
468
+
469
+
470
+
471
+
472
+
473
+ fullscreen.onclick =
474
+ () => {
475
+
476
+ document.documentElement
477
+ .requestFullscreen();
478
+
479
+ };
480
+ </script>
481
+ </body>
482
+ </html>