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.
- package/README.md +12 -18
- package/index.js +40 -447
- package/package.json +5 -9
- package/src/config/env.js +24 -0
- package/src/core/socket.js +56 -0
- package/src/core/terminal.js +93 -0
- package/src/core/tunnel.js +40 -0
- package/src/server/app.js +45 -0
- package/src/templates/viewer.html +482 -0
- package/src/utils/args.js +52 -0
- package/src/utils/system.js +34 -0
package/README.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# Terminal Expose
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Interactive terminal sharing from your own machine.
|
|
4
4
|
|
|
5
|
-
`terminal-expose` starts a real shell or command on the host where it is run, prints a private
|
|
5
|
+
`terminal-expose` starts a real shell or command on the host where it is run, prints a private URL, and allows collaborative, interactive access to anyone who opens that URL.
|
|
6
6
|
|
|
7
7
|
## Install
|
|
8
8
|
|
|
@@ -34,15 +34,15 @@ terminal-expose bash -lc "npm test"
|
|
|
34
34
|
The CLI prints a URL like:
|
|
35
35
|
|
|
36
36
|
```text
|
|
37
|
-
Local URL : http://localhost:
|
|
38
|
-
LAN URL : http://192.168.1.10:
|
|
37
|
+
Local URL : http://localhost:5555/s/<token>
|
|
38
|
+
LAN URL : http://192.168.1.10:5555/s/<token>
|
|
39
39
|
```
|
|
40
40
|
|
|
41
|
-
Share the full token URL with
|
|
41
|
+
Share the full token URL with your collaborators. They will have full read and write access to the terminal.
|
|
42
42
|
|
|
43
43
|
## Public Internet Access
|
|
44
44
|
|
|
45
|
-
For quick public sharing, start a
|
|
45
|
+
For quick public sharing, start a secure edge tunnel (powered by Cloudflare Quick Tunnels):
|
|
46
46
|
|
|
47
47
|
```sh
|
|
48
48
|
terminal-expose --public bash
|
|
@@ -51,22 +51,17 @@ terminal-expose --public bash
|
|
|
51
51
|
The CLI prints a URL like:
|
|
52
52
|
|
|
53
53
|
```text
|
|
54
|
-
Public URL: https://random-name.
|
|
55
|
-
Tunnel password: <localtunnel-password>
|
|
54
|
+
Public URL: https://random-name.trycloudflare.com/s/<token>
|
|
56
55
|
```
|
|
57
56
|
|
|
58
|
-
|
|
57
|
+
_(Note: Custom subdomains are not currently supported by free Cloudflare Quick Tunnels.)_
|
|
59
58
|
|
|
60
|
-
|
|
61
|
-
terminal-expose --public --subdomain my-terminal-demo bash
|
|
62
|
-
```
|
|
63
|
-
|
|
64
|
-
The tunnel URL works outside your Wi-Fi as long as the CLI is still running. If `loca.lt` asks the viewer for a tunnel password, share the printed tunnel password too.
|
|
59
|
+
The tunnel URL works outside your Wi-Fi as long as the CLI is still running.
|
|
65
60
|
|
|
66
|
-
For a permanent setup, run it on a public server or forward TCP port
|
|
61
|
+
For a permanent setup, run it on a public server or forward TCP port 5555 to the machine running the CLI:
|
|
67
62
|
|
|
68
63
|
```sh
|
|
69
|
-
EXTERNAL_URL="http://YOUR_PUBLIC_IP:
|
|
64
|
+
EXTERNAL_URL="http://YOUR_PUBLIC_IP:5555" terminal-expose bash
|
|
70
65
|
```
|
|
71
66
|
|
|
72
67
|
## Options
|
|
@@ -77,10 +72,9 @@ Use environment variables:
|
|
|
77
72
|
PORT=4000 terminal-expose bash
|
|
78
73
|
SESSION_TOKEN="change-this-secret" terminal-expose bash
|
|
79
74
|
PUBLIC_TUNNEL=1 terminal-expose bash
|
|
80
|
-
TUNNEL_SUBDOMAIN="my-terminal-demo" PUBLIC_TUNNEL=1 terminal-expose bash
|
|
81
75
|
TERM_COLS=160 TERM_ROWS=48 terminal-expose bash
|
|
82
76
|
```
|
|
83
77
|
|
|
84
78
|
## Security
|
|
85
79
|
|
|
86
|
-
Anyone with the full URL can
|
|
80
|
+
Anyone with the full URL can view and interact with the session. Do not type passwords, API keys, or private commands in a shared terminal.
|
package/index.js
CHANGED
|
@@ -1,81 +1,21 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
const express = require("express");
|
|
4
3
|
const http = require("http");
|
|
5
|
-
const {
|
|
6
|
-
const
|
|
7
|
-
const
|
|
8
|
-
const
|
|
9
|
-
const
|
|
10
|
-
const
|
|
11
|
-
const localtunnel = require("localtunnel");
|
|
4
|
+
const { parseArgs } = require("./src/utils/args");
|
|
5
|
+
const { getDefaultShell, getLanUrls } = require("./src/utils/system");
|
|
6
|
+
const { TerminalSession } = require("./src/core/terminal");
|
|
7
|
+
const { setupSocket } = require("./src/core/socket");
|
|
8
|
+
const { startPublicTunnel } = require("./src/core/tunnel");
|
|
9
|
+
const { createApp } = require("./src/server/app");
|
|
12
10
|
|
|
13
|
-
const
|
|
14
|
-
const server = http.createServer(app);
|
|
11
|
+
const { PORT, HOST, SESSION_TOKEN, EXTERNAL_URL } = require("./src/config/env");
|
|
15
12
|
|
|
16
|
-
|
|
17
|
-
const HOST = process.env.HOST || "0.0.0.0";
|
|
18
|
-
const MAX_BUFFER = Number(process.env.MAX_BUFFER || 100000);
|
|
19
|
-
const ECHO_TO_STDOUT = process.env.ECHO_TO_STDOUT !== "0";
|
|
20
|
-
const SESSION_TOKEN =
|
|
21
|
-
process.env.SESSION_TOKEN || crypto.randomBytes(18).toString("base64url");
|
|
13
|
+
// arguments
|
|
22
14
|
const rawArgs = process.argv.slice(2);
|
|
23
15
|
|
|
24
|
-
function parseArgs(args) {
|
|
25
|
-
const options = {
|
|
26
|
-
publicTunnel: false,
|
|
27
|
-
tunnelSubdomain: process.env.TUNNEL_SUBDOMAIN || null,
|
|
28
|
-
tunnelHost: process.env.TUNNEL_HOST || undefined,
|
|
29
|
-
};
|
|
30
|
-
const commandArgs = [];
|
|
31
|
-
|
|
32
|
-
for (let index = 0; index < args.length; index += 1) {
|
|
33
|
-
const arg = args[index];
|
|
34
|
-
|
|
35
|
-
if (arg === "--public" || arg === "--tunnel") {
|
|
36
|
-
options.publicTunnel = true;
|
|
37
|
-
continue;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
if (arg === "--subdomain") {
|
|
41
|
-
options.tunnelSubdomain = args[index + 1] || null;
|
|
42
|
-
index += 1;
|
|
43
|
-
continue;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
if (arg.startsWith("--subdomain=")) {
|
|
47
|
-
options.tunnelSubdomain = arg.slice("--subdomain=".length) || null;
|
|
48
|
-
continue;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
if (arg === "--tunnel-host") {
|
|
52
|
-
options.tunnelHost = args[index + 1] || undefined;
|
|
53
|
-
index += 1;
|
|
54
|
-
continue;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
if (arg.startsWith("--tunnel-host=")) {
|
|
58
|
-
options.tunnelHost = arg.slice("--tunnel-host=".length) || undefined;
|
|
59
|
-
continue;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
commandArgs.push(arg);
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
if (
|
|
66
|
-
process.env.PUBLIC_TUNNEL === "1" ||
|
|
67
|
-
process.env.PUBLIC_TUNNEL === "true"
|
|
68
|
-
) {
|
|
69
|
-
options.publicTunnel = true;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
return { options, commandArgs };
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
const { options, commandArgs: command } = parseArgs(rawArgs);
|
|
76
|
-
|
|
77
16
|
if (rawArgs.includes("--help") || rawArgs.includes("-h")) {
|
|
78
17
|
console.log(`Terminal Expose
|
|
18
|
+
Interactive terminal session sharing over HTTP and WebSocket.
|
|
79
19
|
|
|
80
20
|
Usage:
|
|
81
21
|
terminal-expose [options] [command] [args...]
|
|
@@ -86,23 +26,18 @@ Examples:
|
|
|
86
26
|
terminal-expose zsh
|
|
87
27
|
terminal-expose bash -lc "cd ~/project && npm test"
|
|
88
28
|
terminal-expose --public bash
|
|
89
|
-
terminal-expose --public --subdomain my-demo bash
|
|
90
29
|
|
|
91
30
|
Environment:
|
|
92
|
-
PORT=
|
|
31
|
+
PORT=5555
|
|
93
32
|
HOST=0.0.0.0
|
|
94
33
|
SESSION_TOKEN=<custom-token>
|
|
95
|
-
EXTERNAL_URL=http://YOUR_PUBLIC_IP:
|
|
34
|
+
EXTERNAL_URL=http://YOUR_PUBLIC_IP:5555
|
|
96
35
|
PUBLIC_TUNNEL=1
|
|
97
|
-
TUNNEL_SUBDOMAIN=my-demo
|
|
98
|
-
TUNNEL_HOST=https://localtunnel.me
|
|
99
36
|
TERM_COLS=120
|
|
100
37
|
TERM_ROWS=40
|
|
101
38
|
|
|
102
39
|
Options:
|
|
103
40
|
--public, --tunnel Create a public HTTPS tunnel.
|
|
104
|
-
--subdomain <name> Request a localtunnel subdomain.
|
|
105
|
-
--tunnel-host <url> Use a different localtunnel server.
|
|
106
41
|
`);
|
|
107
42
|
process.exit(0);
|
|
108
43
|
}
|
|
@@ -113,385 +48,41 @@ if (rawArgs.includes("--version") || rawArgs.includes("-v")) {
|
|
|
113
48
|
process.exit(0);
|
|
114
49
|
}
|
|
115
50
|
|
|
116
|
-
const
|
|
117
|
-
command.length > 0
|
|
118
|
-
? command[0]
|
|
119
|
-
: process.platform === "win32"
|
|
120
|
-
? "powershell.exe"
|
|
121
|
-
: process.env.SHELL || "bash";
|
|
122
|
-
const shellArgs = command.length > 0 ? command.slice(1) : [];
|
|
123
|
-
|
|
124
|
-
function getLanUrls() {
|
|
125
|
-
const urls = [];
|
|
126
|
-
const interfaces = os.networkInterfaces();
|
|
127
|
-
|
|
128
|
-
Object.values(interfaces).forEach((addresses = []) => {
|
|
129
|
-
addresses
|
|
130
|
-
.filter((address) => address.family === "IPv4" && !address.internal)
|
|
131
|
-
.forEach((address) => {
|
|
132
|
-
urls.push(`http://${address.address}:${PORT}/s/${SESSION_TOKEN}`);
|
|
133
|
-
});
|
|
134
|
-
});
|
|
135
|
-
|
|
136
|
-
return urls;
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
function getLocaltunnelPassword() {
|
|
140
|
-
return new Promise((resolve) => {
|
|
141
|
-
let settled = false;
|
|
142
|
-
|
|
143
|
-
function finish(value) {
|
|
144
|
-
if (settled) {
|
|
145
|
-
return;
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
settled = true;
|
|
149
|
-
resolve(value);
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
const request = https.get("https://loca.lt/mytunnelpassword", (response) => {
|
|
153
|
-
if (response.statusCode !== 200) {
|
|
154
|
-
response.resume();
|
|
155
|
-
finish(null);
|
|
156
|
-
return;
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
let body = "";
|
|
160
|
-
response.setEncoding("utf8");
|
|
161
|
-
response.on("data", (chunk) => {
|
|
162
|
-
body += chunk;
|
|
163
|
-
});
|
|
164
|
-
response.on("end", () => {
|
|
165
|
-
finish(body.trim() || null);
|
|
166
|
-
});
|
|
167
|
-
});
|
|
168
|
-
|
|
169
|
-
request.setTimeout(3000, () => {
|
|
170
|
-
request.destroy();
|
|
171
|
-
finish(null);
|
|
172
|
-
});
|
|
173
|
-
|
|
174
|
-
request.on("error", () => {
|
|
175
|
-
finish(null);
|
|
176
|
-
});
|
|
177
|
-
});
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
async function startPublicTunnel() {
|
|
181
|
-
const localHost =
|
|
182
|
-
HOST === "0.0.0.0" || HOST === "::" ? "127.0.0.1" : HOST;
|
|
183
|
-
const tunnelOptions = {
|
|
184
|
-
port: PORT,
|
|
185
|
-
local_host: localHost,
|
|
186
|
-
};
|
|
187
|
-
|
|
188
|
-
if (options.tunnelSubdomain) {
|
|
189
|
-
tunnelOptions.subdomain = options.tunnelSubdomain;
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
if (options.tunnelHost) {
|
|
193
|
-
tunnelOptions.host = options.tunnelHost;
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
console.log("Public tunnel: starting...");
|
|
197
|
-
|
|
198
|
-
const tunnel = await localtunnel(tunnelOptions);
|
|
199
|
-
const publicUrl = `${tunnel.url.replace(/\/$/, "")}/s/${SESSION_TOKEN}`;
|
|
200
|
-
|
|
201
|
-
console.log(`Public URL: ${publicUrl}`);
|
|
202
|
-
|
|
203
|
-
if (new URL(tunnel.url).hostname.endsWith(".loca.lt")) {
|
|
204
|
-
const password = await getLocaltunnelPassword();
|
|
205
|
-
|
|
206
|
-
if (password) {
|
|
207
|
-
console.log(`Tunnel password: ${password}`);
|
|
208
|
-
}
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
tunnel.on("error", (error) => {
|
|
212
|
-
console.error(`Public tunnel error: ${error.message}`);
|
|
213
|
-
});
|
|
214
|
-
|
|
215
|
-
tunnel.on("close", () => {
|
|
216
|
-
console.log("Public tunnel closed.");
|
|
217
|
-
});
|
|
218
|
-
|
|
219
|
-
["SIGINT", "SIGTERM"].forEach((signal) => {
|
|
220
|
-
process.once(signal, () => {
|
|
221
|
-
tunnel.close();
|
|
222
|
-
process.kill(process.pid, signal);
|
|
223
|
-
});
|
|
224
|
-
});
|
|
225
|
-
|
|
226
|
-
return tunnel;
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
const VIEWER_HTML = String.raw`<!doctype html>
|
|
230
|
-
<html>
|
|
231
|
-
<head>
|
|
232
|
-
<meta charset="utf-8" />
|
|
233
|
-
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
234
|
-
<title>Terminal Viewer</title>
|
|
235
|
-
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm/css/xterm.css" />
|
|
236
|
-
<style>
|
|
237
|
-
html,
|
|
238
|
-
body {
|
|
239
|
-
margin: 0;
|
|
240
|
-
padding: 0;
|
|
241
|
-
background: #050505;
|
|
242
|
-
color: #f2f2f2;
|
|
243
|
-
width: 100%;
|
|
244
|
-
height: 100%;
|
|
245
|
-
overflow: hidden;
|
|
246
|
-
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
.topbar {
|
|
250
|
-
align-items: center;
|
|
251
|
-
background: #111;
|
|
252
|
-
border-bottom: 1px solid #2a2a2a;
|
|
253
|
-
box-sizing: border-box;
|
|
254
|
-
display: flex;
|
|
255
|
-
gap: 12px;
|
|
256
|
-
height: 42px;
|
|
257
|
-
justify-content: space-between;
|
|
258
|
-
padding: 0 14px;
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
.title {
|
|
262
|
-
font-size: 13px;
|
|
263
|
-
font-weight: 650;
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
.status {
|
|
267
|
-
align-items: center;
|
|
268
|
-
color: #b8b8b8;
|
|
269
|
-
display: flex;
|
|
270
|
-
font-size: 12px;
|
|
271
|
-
gap: 8px;
|
|
272
|
-
min-width: 0;
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
.dot {
|
|
276
|
-
background: #9ca3af;
|
|
277
|
-
border-radius: 999px;
|
|
278
|
-
flex: 0 0 auto;
|
|
279
|
-
height: 8px;
|
|
280
|
-
width: 8px;
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
.dot.connected {
|
|
284
|
-
background: #22c55e;
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
.dot.disconnected {
|
|
288
|
-
background: #ef4444;
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
#terminal {
|
|
292
|
-
width: 100vw;
|
|
293
|
-
height: calc(100vh - 42px);
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
.xterm {
|
|
297
|
-
height: 100%;
|
|
298
|
-
}
|
|
299
|
-
</style>
|
|
300
|
-
</head>
|
|
301
|
-
<body>
|
|
302
|
-
<header class="topbar">
|
|
303
|
-
<div class="title">Terminal Expose</div>
|
|
304
|
-
<div class="status">
|
|
305
|
-
<span id="dot" class="dot"></span>
|
|
306
|
-
<span id="status">Connecting</span>
|
|
307
|
-
</div>
|
|
308
|
-
</header>
|
|
309
|
-
<div id="terminal"></div>
|
|
310
|
-
<script src="/socket.io/socket.io.js"></script>
|
|
311
|
-
<script src="https://cdn.jsdelivr.net/npm/xterm/lib/xterm.js"></script>
|
|
312
|
-
<script>
|
|
313
|
-
const token = window.location.pathname.split('/').filter(Boolean).pop();
|
|
314
|
-
const statusText = document.getElementById('status');
|
|
315
|
-
const dot = document.getElementById('dot');
|
|
316
|
-
const term = new Terminal({
|
|
317
|
-
disableStdin: true,
|
|
318
|
-
cursorBlink: false,
|
|
319
|
-
convertEol: true,
|
|
320
|
-
fontFamily: '"JetBrains Mono", "SFMono-Regular", Consolas, monospace',
|
|
321
|
-
fontSize: 14,
|
|
322
|
-
scrollback: 10000,
|
|
323
|
-
theme: {
|
|
324
|
-
background: '#050505',
|
|
325
|
-
foreground: '#f4f4f5',
|
|
326
|
-
cursor: '#f4f4f5',
|
|
327
|
-
black: '#18181b',
|
|
328
|
-
red: '#ef4444',
|
|
329
|
-
green: '#22c55e',
|
|
330
|
-
yellow: '#eab308',
|
|
331
|
-
blue: '#3b82f6',
|
|
332
|
-
magenta: '#a855f7',
|
|
333
|
-
cyan: '#06b6d4',
|
|
334
|
-
white: '#e4e4e7'
|
|
335
|
-
}
|
|
336
|
-
});
|
|
337
|
-
|
|
338
|
-
term.open(document.getElementById('terminal'));
|
|
339
|
-
|
|
340
|
-
function setStatus(text, className) {
|
|
341
|
-
statusText.textContent = text;
|
|
342
|
-
dot.className = 'dot ' + (className || '');
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
const socket = io({
|
|
346
|
-
auth: { token },
|
|
347
|
-
reconnectionAttempts: 10,
|
|
348
|
-
timeout: 5000
|
|
349
|
-
});
|
|
350
|
-
|
|
351
|
-
socket.on('connect', () => {
|
|
352
|
-
setStatus('Connected', 'connected');
|
|
353
|
-
});
|
|
354
|
-
|
|
355
|
-
socket.on('terminal-output', (data) => {
|
|
356
|
-
term.write(data);
|
|
357
|
-
});
|
|
358
|
-
|
|
359
|
-
socket.on('terminal-exit', ({ exitCode, signal }) => {
|
|
360
|
-
const suffix = signal ? 'signal ' + signal : 'exit code ' + exitCode;
|
|
361
|
-
setStatus('Command ended: ' + suffix, 'disconnected');
|
|
362
|
-
term.writeln('\r\n[Command ended: ' + suffix + ']');
|
|
363
|
-
});
|
|
364
|
-
|
|
365
|
-
socket.on('connect_error', (error) => {
|
|
366
|
-
setStatus(error.message || 'Connection error', 'disconnected');
|
|
367
|
-
});
|
|
368
|
-
|
|
369
|
-
socket.on('disconnect', () => {
|
|
370
|
-
setStatus('Disconnected', 'disconnected');
|
|
371
|
-
});
|
|
372
|
-
</script>
|
|
373
|
-
</body>
|
|
374
|
-
</html>`;
|
|
375
|
-
|
|
376
|
-
const io = new Server(server, {
|
|
377
|
-
cors: {
|
|
378
|
-
origin: false,
|
|
379
|
-
},
|
|
380
|
-
});
|
|
381
|
-
|
|
382
|
-
io.use((socket, next) => {
|
|
383
|
-
const token = socket.handshake.auth?.token;
|
|
384
|
-
|
|
385
|
-
if (token !== SESSION_TOKEN) {
|
|
386
|
-
return next(new Error("unauthorized"));
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
return next();
|
|
390
|
-
});
|
|
391
|
-
|
|
392
|
-
const shell = pty.spawn(shellCommand, shellArgs, {
|
|
393
|
-
name: "xterm-256color",
|
|
394
|
-
cols: Number(process.env.TERM_COLS || 120),
|
|
395
|
-
rows: Number(process.env.TERM_ROWS || 40),
|
|
396
|
-
cwd: process.env.WORKDIR || process.cwd(),
|
|
397
|
-
env: {
|
|
398
|
-
...process.env,
|
|
399
|
-
TERM: "xterm-256color",
|
|
400
|
-
},
|
|
401
|
-
});
|
|
402
|
-
|
|
403
|
-
let terminalBuffer = "";
|
|
404
|
-
let terminalExit = null;
|
|
405
|
-
|
|
406
|
-
if (process.stdin.isTTY) {
|
|
407
|
-
process.stdin.setRawMode(true);
|
|
408
|
-
}
|
|
409
|
-
|
|
410
|
-
process.stdin.resume();
|
|
411
|
-
process.stdin.on("data", (data) => {
|
|
412
|
-
shell.write(data);
|
|
413
|
-
});
|
|
414
|
-
|
|
415
|
-
shell.onData((data) => {
|
|
416
|
-
if (ECHO_TO_STDOUT) {
|
|
417
|
-
process.stdout.write(data);
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
terminalBuffer += data;
|
|
421
|
-
|
|
422
|
-
if (terminalBuffer.length > MAX_BUFFER) {
|
|
423
|
-
terminalBuffer = terminalBuffer.slice(-MAX_BUFFER);
|
|
424
|
-
}
|
|
425
|
-
|
|
426
|
-
io.emit("terminal-output", data);
|
|
427
|
-
});
|
|
428
|
-
|
|
429
|
-
shell.onExit(({ exitCode, signal }) => {
|
|
430
|
-
terminalExit = { exitCode, signal };
|
|
431
|
-
io.emit("terminal-exit", terminalExit);
|
|
432
|
-
console.log(`Terminal command exited: code=${exitCode} signal=${signal}`);
|
|
51
|
+
const { options, commandArgs: command } = parseArgs(rawArgs);
|
|
433
52
|
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
}
|
|
53
|
+
const shellCommand = command.length > 0 ? command[0] : getDefaultShell();
|
|
54
|
+
const shellArgs = command.length > 0 ? command.slice(1) : [];
|
|
437
55
|
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
}, 250);
|
|
441
|
-
});
|
|
56
|
+
// Initialize Terminal
|
|
57
|
+
const terminalSession = new TerminalSession(shellCommand, shellArgs);
|
|
442
58
|
|
|
443
|
-
|
|
59
|
+
// Initialize Express App
|
|
60
|
+
let ioInstance = null;
|
|
61
|
+
const app = createApp(() => ioInstance, terminalSession);
|
|
62
|
+
const server = http.createServer(app);
|
|
444
63
|
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
<html>
|
|
448
|
-
<head>
|
|
449
|
-
<meta charset="utf-8" />
|
|
450
|
-
<title>Terminal Expose</title>
|
|
451
|
-
</head>
|
|
452
|
-
<body style="font-family: system-ui, sans-serif; line-height: 1.5; padding: 32px;">
|
|
453
|
-
<h1>Terminal Expose</h1>
|
|
454
|
-
<p>This server is running. Use the private session URL printed in the container logs.</p>
|
|
455
|
-
</body>
|
|
456
|
-
</html>`);
|
|
457
|
-
});
|
|
64
|
+
// Initialize Socket.io
|
|
65
|
+
ioInstance = setupSocket(server, terminalSession);
|
|
458
66
|
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
67
|
+
// Close server and process gracefully on terminal exit
|
|
68
|
+
terminalSession.on("exit", () => {
|
|
69
|
+
console.log("Shutting down server port...");
|
|
70
|
+
server.close(() => {
|
|
71
|
+
// Ensure process is fully killed after server cleanly exits
|
|
72
|
+
process.exit(terminalSession.exit?.exitCode ?? 0);
|
|
464
73
|
});
|
|
465
|
-
});
|
|
466
|
-
|
|
467
|
-
app.get("/s/:token", (req, res) => {
|
|
468
|
-
if (req.params.token !== SESSION_TOKEN) {
|
|
469
|
-
return res.status(403).send("Invalid session token");
|
|
470
|
-
}
|
|
471
|
-
|
|
472
|
-
res.type("html").send(VIEWER_HTML);
|
|
473
|
-
});
|
|
474
74
|
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
}
|
|
481
|
-
|
|
482
|
-
if (terminalExit) {
|
|
483
|
-
socket.emit("terminal-exit", terminalExit);
|
|
484
|
-
}
|
|
485
|
-
|
|
486
|
-
socket.on("disconnect", () => {
|
|
487
|
-
console.log(`Viewer disconnected: ${socket.id}`);
|
|
488
|
-
});
|
|
75
|
+
// Force exit if server.close() hangs (e.g., active keep-alive connections)
|
|
76
|
+
setTimeout(
|
|
77
|
+
() => process.exit(terminalSession.exit?.exitCode ?? 0),
|
|
78
|
+
1000,
|
|
79
|
+
).unref();
|
|
489
80
|
});
|
|
490
81
|
|
|
491
82
|
server.listen(PORT, HOST, () => {
|
|
492
83
|
const localUrl = `http://localhost:${PORT}/s/${SESSION_TOKEN}`;
|
|
493
|
-
const externalUrl =
|
|
494
|
-
? `${
|
|
84
|
+
const externalUrl = EXTERNAL_URL
|
|
85
|
+
? `${EXTERNAL_URL.replace(/\/$/, "")}/s/${SESSION_TOKEN}`
|
|
495
86
|
: null;
|
|
496
87
|
|
|
497
88
|
console.log("\n==============================");
|
|
@@ -500,20 +91,22 @@ server.listen(PORT, HOST, () => {
|
|
|
500
91
|
console.log(`Command : ${[shellCommand, ...shellArgs].join(" ")}`);
|
|
501
92
|
console.log(`Local URL : ${localUrl}`);
|
|
502
93
|
|
|
503
|
-
getLanUrls().forEach((url) =>
|
|
94
|
+
getLanUrls({ port: PORT, session_token: SESSION_TOKEN }).forEach((url) =>
|
|
95
|
+
console.log(`LAN URL : ${url}`),
|
|
96
|
+
);
|
|
504
97
|
|
|
505
98
|
if (externalUrl) {
|
|
506
99
|
console.log(`Public URL: ${externalUrl}`);
|
|
507
100
|
}
|
|
508
101
|
|
|
509
102
|
if (options.publicTunnel) {
|
|
510
|
-
startPublicTunnel().catch((error) => {
|
|
103
|
+
startPublicTunnel(options, { HOST, PORT, SESSION_TOKEN }).catch((error) => {
|
|
511
104
|
console.error(`Public tunnel failed: ${error.message}`);
|
|
512
105
|
});
|
|
513
106
|
}
|
|
514
107
|
|
|
515
108
|
console.log("");
|
|
516
|
-
console.log("Share only the session URL with trusted
|
|
109
|
+
console.log("Share only the session URL with trusted collaborators.");
|
|
517
110
|
console.log("Use --public for internet access without router forwarding.");
|
|
518
111
|
console.log("==============================\n");
|
|
519
112
|
});
|
package/package.json
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "terminal-expose",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "1.2.0",
|
|
4
|
+
"description": "Interactive terminal session sharing over HTTP and WebSocket.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"bin": {
|
|
7
7
|
"terminal-expose": "index.js"
|
|
8
8
|
},
|
|
9
9
|
"files": [
|
|
10
10
|
"index.js",
|
|
11
|
+
"src",
|
|
11
12
|
"public"
|
|
12
13
|
],
|
|
13
14
|
"scripts": {
|
|
@@ -32,13 +33,8 @@
|
|
|
32
33
|
},
|
|
33
34
|
"dependencies": {
|
|
34
35
|
"express": "^5.2.1",
|
|
35
|
-
"localtunnel": "^2.0.2",
|
|
36
36
|
"node-pty": "^1.1.0",
|
|
37
|
-
"socket.io": "^4.8.3"
|
|
38
|
-
|
|
39
|
-
"overrides": {
|
|
40
|
-
"localtunnel": {
|
|
41
|
-
"axios": "^1.16.1"
|
|
42
|
-
}
|
|
37
|
+
"socket.io": "^4.8.3",
|
|
38
|
+
"untun": "^0.1.3"
|
|
43
39
|
}
|
|
44
40
|
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
const crypto = require("crypto");
|
|
2
|
+
|
|
3
|
+
const PORT = Number(process.env.PORT || 5555);
|
|
4
|
+
const HOST = process.env.HOST || "0.0.0.0";
|
|
5
|
+
const MAX_BUFFER = Number(process.env.MAX_BUFFER || 100000);
|
|
6
|
+
const ECHO_TO_STDOUT = process.env.ECHO_TO_STDOUT !== "0";
|
|
7
|
+
const SESSION_TOKEN =
|
|
8
|
+
process.env.SESSION_TOKEN || crypto.randomBytes(18).toString("base64url");
|
|
9
|
+
const TERM_COLS = Number(process.env.TERM_COLS || 120);
|
|
10
|
+
const TERM_ROWS = Number(process.env.TERM_ROWS || 40);
|
|
11
|
+
const WORKDIR = process.env.WORKDIR || process.cwd();
|
|
12
|
+
const EXTERNAL_URL = process.env.EXTERNAL_URL || null;
|
|
13
|
+
|
|
14
|
+
module.exports = {
|
|
15
|
+
PORT,
|
|
16
|
+
HOST,
|
|
17
|
+
MAX_BUFFER,
|
|
18
|
+
ECHO_TO_STDOUT,
|
|
19
|
+
SESSION_TOKEN,
|
|
20
|
+
TERM_COLS,
|
|
21
|
+
TERM_ROWS,
|
|
22
|
+
WORKDIR,
|
|
23
|
+
EXTERNAL_URL,
|
|
24
|
+
};
|