@ghostty-web/demo 0.4.0-next.13.g65ed96f → 0.4.0-next.18.gbec9e16
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 +11 -27
- package/bin/auth.js +335 -0
- package/bin/demo.js +213 -30
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -13,11 +13,12 @@ Works on **Linux** and **macOS** (no Windows support yet).
|
|
|
13
13
|
|
|
14
14
|
## What it does
|
|
15
15
|
|
|
16
|
-
- Starts an HTTP server on
|
|
16
|
+
- Starts an HTTP server on `127.0.0.1:8080` by default (`PORT` and `HOST` are configurable)
|
|
17
17
|
- Serves WebSocket PTY on the same port at `/ws` endpoint
|
|
18
|
+
- Protects `/ws` with a per-run same-origin token from `/api/token`
|
|
19
|
+
- Rejects cross-origin WebSocket handshakes
|
|
18
20
|
- Opens a real shell session (bash, zsh, etc.)
|
|
19
21
|
- Provides full PTY support (colors, cursor positioning, resize, etc.)
|
|
20
|
-
- Supports reverse proxies (ngrok, nginx, etc.) via X-Forwarded-\* headers
|
|
21
22
|
|
|
22
23
|
## Usage
|
|
23
24
|
|
|
@@ -27,31 +28,18 @@ npx @ghostty-web/demo@next
|
|
|
27
28
|
|
|
28
29
|
# Custom port
|
|
29
30
|
PORT=3000 npx @ghostty-web/demo@next
|
|
30
|
-
```
|
|
31
|
-
|
|
32
|
-
Then open http://localhost:8080 in your browser.
|
|
33
|
-
|
|
34
|
-
## Reverse Proxy Support
|
|
35
|
-
|
|
36
|
-
The server now supports reverse proxies like ngrok, nginx, and others by:
|
|
37
|
-
|
|
38
|
-
- Serving WebSocket on the same HTTP port (no separate port needed)
|
|
39
|
-
- Using relative WebSocket URLs on the client side
|
|
40
|
-
- Automatic protocol detection (HTTP/HTTPS, WS/WSS)
|
|
41
31
|
|
|
42
|
-
|
|
32
|
+
# Explicit bind host for intentional non-default access
|
|
33
|
+
HOST=192.0.2.10 GHOSTTY_ALLOWED_HOSTS=demo.example npx @ghostty-web/demo@next
|
|
34
|
+
```
|
|
43
35
|
|
|
44
|
-
|
|
36
|
+
Then open http://127.0.0.1:8080 in your browser.
|
|
45
37
|
|
|
46
|
-
|
|
47
|
-
# Start the demo server
|
|
48
|
-
npx @ghostty-web/demo@next
|
|
38
|
+
## Bind host and proxy configuration
|
|
49
39
|
|
|
50
|
-
|
|
51
|
-
ngrok http 8080
|
|
52
|
-
```
|
|
40
|
+
The demo binds to `127.0.0.1` by default and only allows loopback hostnames (`localhost`, `127.0.0.1`, and `::1`) unless configured otherwise. Set `HOST=<host>` to change the bind address. If you serve the demo through another hostname, or bind to a wildcard such as `HOST=0.0.0.0`, add the browser-visible hostnames with `GHOSTTY_ALLOWED_HOSTS=host1,host2`.
|
|
53
41
|
|
|
54
|
-
The
|
|
42
|
+
The browser client fetches `/api/token` from the same origin before opening `/ws`, and the server rejects `/ws` when the token is missing, the `Host` is not allowed, or the WebSocket `Origin` does not match the request host. Do not set permissive CORS in front of `/api/token`.
|
|
55
43
|
|
|
56
44
|
### Example with nginx
|
|
57
45
|
|
|
@@ -66,10 +54,6 @@ server {
|
|
|
66
54
|
proxy_set_header Upgrade $http_upgrade;
|
|
67
55
|
proxy_set_header Connection "upgrade";
|
|
68
56
|
proxy_set_header Host $host;
|
|
69
|
-
proxy_set_header X-Forwarded-Host $host;
|
|
70
|
-
proxy_set_header X-Forwarded-Proto $scheme;
|
|
71
|
-
proxy_set_header X-Real-IP $remote_addr;
|
|
72
|
-
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
73
57
|
}
|
|
74
58
|
}
|
|
75
59
|
```
|
|
@@ -78,4 +62,4 @@ server {
|
|
|
78
62
|
|
|
79
63
|
⚠️ **This server provides full shell access.**
|
|
80
64
|
|
|
81
|
-
Only use for local development and demos.
|
|
65
|
+
Only use for local development and demos. Keep the default loopback bind unless you intentionally need remote access and have configured `HOST` and `GHOSTTY_ALLOWED_HOSTS` for the exact hostnames you trust.
|
package/bin/auth.js
ADDED
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
import assert from 'assert';
|
|
2
|
+
import { randomBytes, timingSafeEqual } from 'crypto';
|
|
3
|
+
import { isIP } from 'net';
|
|
4
|
+
|
|
5
|
+
const LOOPBACK_HOSTS = Object.freeze(['localhost', '127.0.0.1', '::1']);
|
|
6
|
+
const WILDCARD_BIND_HOSTS = Object.freeze(['0.0.0.0', '::', '*']);
|
|
7
|
+
|
|
8
|
+
function decision(status, reason) {
|
|
9
|
+
return { ok: false, status, reason };
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function badRequest() {
|
|
13
|
+
return decision(400, 'Bad Request');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function forbidden() {
|
|
17
|
+
return decision(403, 'Forbidden');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function unauthorized() {
|
|
21
|
+
return decision(401, 'Unauthorized');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function parseAllowedHosts(value) {
|
|
25
|
+
if (!value) {
|
|
26
|
+
return [];
|
|
27
|
+
}
|
|
28
|
+
return value
|
|
29
|
+
.split(',')
|
|
30
|
+
.map((host) => host.trim())
|
|
31
|
+
.filter((host) => host.length > 0);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function normalizeHostname(hostname) {
|
|
35
|
+
if (typeof hostname !== 'string') {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
let value = hostname.trim().toLowerCase();
|
|
40
|
+
if (value.length === 0 || /\s/.test(value)) {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (value.startsWith('[') || value.endsWith(']')) {
|
|
45
|
+
if (!value.startsWith('[') || !value.endsWith(']')) {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
value = value.slice(1, -1);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (value.length === 0 || value.includes('/') || value.includes('\\') || value.includes('@')) {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (isIP(value) !== 0) {
|
|
56
|
+
return value;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (value.includes(':') || !/^[a-z0-9.-]+$/.test(value)) {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const labels = value.split('.');
|
|
64
|
+
if (labels.some((label) => label.length === 0 || label.length > 63)) {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
for (const label of labels) {
|
|
69
|
+
if (label.startsWith('-') || label.endsWith('-')) {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return value;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function addAllowedHost(allowedHosts, host) {
|
|
78
|
+
const normalized = normalizeHostname(host);
|
|
79
|
+
assert(normalized, `Allowed host must be a hostname or IP address: ${host}`);
|
|
80
|
+
allowedHosts.add(normalized);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function parsePort(port) {
|
|
84
|
+
if (port === undefined || port === '') {
|
|
85
|
+
return '';
|
|
86
|
+
}
|
|
87
|
+
if (!/^[0-9]+$/.test(port)) {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
const value = Number.parseInt(port, 10);
|
|
91
|
+
if (!Number.isInteger(value) || value < 1 || value > 65535) {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
return String(value);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function parseHostHeader(hostHeader) {
|
|
98
|
+
if (
|
|
99
|
+
typeof hostHeader !== 'string' ||
|
|
100
|
+
hostHeader.length === 0 ||
|
|
101
|
+
hostHeader.trim() !== hostHeader
|
|
102
|
+
) {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
let hostname = '';
|
|
107
|
+
let port = '';
|
|
108
|
+
|
|
109
|
+
if (hostHeader.startsWith('[')) {
|
|
110
|
+
const match = /^\[([^\]]+)\](?::([0-9]+))?$/.exec(hostHeader);
|
|
111
|
+
if (!match) {
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
hostname = match[1];
|
|
115
|
+
const parsedPort = parsePort(match[2]);
|
|
116
|
+
if (parsedPort === null) {
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
port = parsedPort;
|
|
120
|
+
} else {
|
|
121
|
+
const colonCount = (hostHeader.match(/:/g) || []).length;
|
|
122
|
+
if (colonCount === 0) {
|
|
123
|
+
hostname = hostHeader;
|
|
124
|
+
} else if (colonCount === 1) {
|
|
125
|
+
const parts = hostHeader.split(':');
|
|
126
|
+
hostname = parts[0];
|
|
127
|
+
const parsedPort = parsePort(parts[1]);
|
|
128
|
+
if (parsedPort === null) {
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
port = parsedPort;
|
|
132
|
+
} else {
|
|
133
|
+
hostname = hostHeader;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const normalizedHostname = normalizeHostname(hostname);
|
|
138
|
+
if (!normalizedHostname) {
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return { hostname: normalizedHostname, port };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function parseOriginHeader(originHeader) {
|
|
146
|
+
if (
|
|
147
|
+
typeof originHeader !== 'string' ||
|
|
148
|
+
originHeader.length === 0 ||
|
|
149
|
+
originHeader.trim() !== originHeader
|
|
150
|
+
) {
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
let origin;
|
|
155
|
+
try {
|
|
156
|
+
origin = new URL(originHeader);
|
|
157
|
+
} catch (_error) {
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (origin.protocol !== 'http:' && origin.protocol !== 'https:') {
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (
|
|
166
|
+
origin.username ||
|
|
167
|
+
origin.password ||
|
|
168
|
+
origin.pathname !== '/' ||
|
|
169
|
+
origin.search ||
|
|
170
|
+
origin.hash
|
|
171
|
+
) {
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const host = parseHostHeader(origin.host);
|
|
176
|
+
if (!host) {
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return { protocol: origin.protocol, ...host };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function defaultPort(protocol) {
|
|
184
|
+
assert(protocol === 'http:' || protocol === 'https:', `Unexpected origin protocol: ${protocol}`);
|
|
185
|
+
return protocol === 'https:' ? '443' : '80';
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function originMatchesHost(origin, host) {
|
|
189
|
+
const fallbackPort = defaultPort(origin.protocol);
|
|
190
|
+
return (
|
|
191
|
+
origin.hostname === host.hostname &&
|
|
192
|
+
(origin.port || fallbackPort) === (host.port || fallbackPort)
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function validateAllowedHost(config, hostHeader) {
|
|
197
|
+
assertAuthConfig(config);
|
|
198
|
+
const host = parseHostHeader(hostHeader);
|
|
199
|
+
if (!host) {
|
|
200
|
+
return { ...badRequest(), host: null };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (!config.allowedHosts.includes(host.hostname)) {
|
|
204
|
+
return { ...forbidden(), host };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return { ok: true, host };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function validateMatchingOrigin(originHeader, host, required) {
|
|
211
|
+
if (originHeader === undefined && !required) {
|
|
212
|
+
return { ok: true };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (originHeader === undefined) {
|
|
216
|
+
return forbidden();
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const origin = parseOriginHeader(originHeader);
|
|
220
|
+
if (!origin) {
|
|
221
|
+
return badRequest();
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (!originMatchesHost(origin, host)) {
|
|
225
|
+
return forbidden();
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return { ok: true };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function safeTokenEquals(expected, actual) {
|
|
232
|
+
assert(
|
|
233
|
+
typeof expected === 'string' && expected.length > 0,
|
|
234
|
+
'Expected auth token must be non-empty'
|
|
235
|
+
);
|
|
236
|
+
if (typeof actual !== 'string' || actual.length === 0) {
|
|
237
|
+
return false;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const expectedBuffer = Buffer.from(expected, 'utf8');
|
|
241
|
+
const actualBuffer = Buffer.from(actual, 'utf8');
|
|
242
|
+
if (expectedBuffer.length !== actualBuffer.length) {
|
|
243
|
+
return false;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return timingSafeEqual(expectedBuffer, actualBuffer);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function assertAuthConfig(config) {
|
|
250
|
+
assert(config && typeof config === 'object', 'Auth config must be an object');
|
|
251
|
+
assert(
|
|
252
|
+
typeof config.token === 'string' && config.token.length > 0,
|
|
253
|
+
'Auth token must be non-empty'
|
|
254
|
+
);
|
|
255
|
+
assert(
|
|
256
|
+
Array.isArray(config.allowedHosts) && config.allowedHosts.length > 0,
|
|
257
|
+
'Allowed host list must be non-empty'
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export function generateSessionToken() {
|
|
262
|
+
const token = randomBytes(32).toString('base64url');
|
|
263
|
+
assert(token.length >= 32, 'Generated session token must contain enough entropy');
|
|
264
|
+
return token;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
export function isWildcardBindHost(host) {
|
|
268
|
+
const normalized = normalizeHostname(host);
|
|
269
|
+
return (
|
|
270
|
+
WILDCARD_BIND_HOSTS.includes(host) ||
|
|
271
|
+
(normalized !== null && WILDCARD_BIND_HOSTS.includes(normalized))
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export function isLoopbackHost(host) {
|
|
276
|
+
const normalized = normalizeHostname(host);
|
|
277
|
+
return normalized !== null && LOOPBACK_HOSTS.includes(normalized);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
export function createAuthConfig(options = {}) {
|
|
281
|
+
const env = options.env ?? process.env;
|
|
282
|
+
const bindHost = options.bindHost ?? env.HOST ?? '127.0.0.1';
|
|
283
|
+
const token = options.token ?? generateSessionToken();
|
|
284
|
+
|
|
285
|
+
assert(typeof bindHost === 'string' && bindHost.length > 0, 'Bind host must be non-empty');
|
|
286
|
+
assert(typeof token === 'string' && token.length > 0, 'Auth token must be non-empty');
|
|
287
|
+
assert(LOOPBACK_HOSTS.length > 0, 'Loopback host allowlist must not be empty');
|
|
288
|
+
|
|
289
|
+
const allowedHosts = new Set(LOOPBACK_HOSTS);
|
|
290
|
+
for (const host of options.allowedHosts ?? parseAllowedHosts(env.GHOSTTY_ALLOWED_HOSTS)) {
|
|
291
|
+
addAllowedHost(allowedHosts, host);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
if (!isWildcardBindHost(bindHost)) {
|
|
295
|
+
addAllowedHost(allowedHosts, bindHost);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
return Object.freeze({
|
|
299
|
+
token,
|
|
300
|
+
bindHost,
|
|
301
|
+
allowedHosts: Object.freeze([...allowedHosts]),
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
export function validateTokenRequest(config, request) {
|
|
306
|
+
const hostDecision = validateAllowedHost(config, request.host);
|
|
307
|
+
if (!hostDecision.ok) {
|
|
308
|
+
return hostDecision;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const originDecision = validateMatchingOrigin(request.origin, hostDecision.host, false);
|
|
312
|
+
if (!originDecision.ok) {
|
|
313
|
+
return originDecision;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
return { ok: true };
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
export function validateWebSocketRequest(config, request) {
|
|
320
|
+
const hostDecision = validateAllowedHost(config, request.host);
|
|
321
|
+
if (!hostDecision.ok) {
|
|
322
|
+
return hostDecision;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const originDecision = validateMatchingOrigin(request.origin, hostDecision.host, true);
|
|
326
|
+
if (!originDecision.ok) {
|
|
327
|
+
return originDecision;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
if (!safeTokenEquals(config.token, request.token)) {
|
|
331
|
+
return unauthorized();
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
return { ok: true };
|
|
335
|
+
}
|
package/bin/demo.js
CHANGED
|
@@ -18,11 +18,29 @@ import pty from '@lydell/node-pty';
|
|
|
18
18
|
// WebSocket server
|
|
19
19
|
import { WebSocketServer } from 'ws';
|
|
20
20
|
|
|
21
|
+
import {
|
|
22
|
+
createAuthConfig,
|
|
23
|
+
isLoopbackHost,
|
|
24
|
+
isWildcardBindHost,
|
|
25
|
+
validateTokenRequest,
|
|
26
|
+
validateWebSocketRequest,
|
|
27
|
+
} from './auth.js';
|
|
28
|
+
|
|
21
29
|
const __filename = fileURLToPath(import.meta.url);
|
|
22
30
|
const __dirname = path.dirname(__filename);
|
|
23
31
|
|
|
24
32
|
const DEV_MODE = process.argv.includes('--dev');
|
|
25
|
-
const HTTP_PORT = process.env.PORT || (DEV_MODE ? 8000 : 8080);
|
|
33
|
+
const HTTP_PORT = parsePort(process.env.PORT || (DEV_MODE ? '8000' : '8080'));
|
|
34
|
+
const AUTH_CONFIG = createAuthConfig();
|
|
35
|
+
const HOST = AUTH_CONFIG.bindHost;
|
|
36
|
+
|
|
37
|
+
function parsePort(value) {
|
|
38
|
+
const port = Number.parseInt(value, 10);
|
|
39
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
40
|
+
throw new Error(`PORT must be an integer from 1 to 65535: ${value}`);
|
|
41
|
+
}
|
|
42
|
+
return port;
|
|
43
|
+
}
|
|
26
44
|
|
|
27
45
|
// ============================================================================
|
|
28
46
|
// Locate ghostty-web assets
|
|
@@ -240,12 +258,46 @@ const HTML_TEMPLATE = `<!doctype html>
|
|
|
240
258
|
|
|
241
259
|
// Connect to WebSocket PTY server (use same origin as HTTP server)
|
|
242
260
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
243
|
-
const wsUrl = protocol + '//' + window.location.host + '/ws?cols=' + term.cols + '&rows=' + term.rows;
|
|
244
261
|
let ws;
|
|
245
262
|
|
|
246
|
-
function
|
|
263
|
+
async function fetchAuthToken() {
|
|
264
|
+
const response = await fetch('/api/token', { cache: 'no-store' });
|
|
265
|
+
if (!response.ok) {
|
|
266
|
+
throw new Error('Token request failed with HTTP ' + response.status);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const body = await response.json();
|
|
270
|
+
if (!body || typeof body.token !== 'string' || body.token.length === 0) {
|
|
271
|
+
throw new Error('Token response did not include a token');
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
return body.token;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function buildWebSocketUrl(token) {
|
|
278
|
+
const params = new URLSearchParams();
|
|
279
|
+
params.set('cols', String(term.cols));
|
|
280
|
+
params.set('rows', String(term.rows));
|
|
281
|
+
params.set('token', token);
|
|
282
|
+
return protocol + '//' + window.location.host + '/ws?' + params.toString();
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
async function connect() {
|
|
286
|
+
setStatus('connecting', 'Authenticating...');
|
|
287
|
+
|
|
288
|
+
let token;
|
|
289
|
+
try {
|
|
290
|
+
token = await fetchAuthToken();
|
|
291
|
+
} catch (error) {
|
|
292
|
+
console.error('Authentication failed:', error);
|
|
293
|
+
setStatus('disconnected', 'Auth error');
|
|
294
|
+
term.write('\\r\\n\\x1b[31mAuthentication failed. Retrying in 2s...\\x1b[0m\\r\\n');
|
|
295
|
+
setTimeout(connect, 2000);
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
|
|
247
299
|
setStatus('connecting', 'Connecting...');
|
|
248
|
-
ws = new WebSocket(
|
|
300
|
+
ws = new WebSocket(buildWebSocketUrl(token));
|
|
249
301
|
|
|
250
302
|
ws.onopen = () => {
|
|
251
303
|
setStatus('connected', 'Connected');
|
|
@@ -338,7 +390,16 @@ const MIME_TYPES = {
|
|
|
338
390
|
// ============================================================================
|
|
339
391
|
|
|
340
392
|
const httpServer = http.createServer((req, res) => {
|
|
341
|
-
const url =
|
|
393
|
+
const url = parseRequestUrl(req);
|
|
394
|
+
if (!url) {
|
|
395
|
+
writeHttpDecision(res, { status: 400, reason: 'Bad Request' });
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
if (handleTokenRequest(req, res, url)) {
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
|
|
342
403
|
const pathname = url.pathname;
|
|
343
404
|
|
|
344
405
|
// Serve index page
|
|
@@ -381,6 +442,60 @@ function serveFile(filePath, res) {
|
|
|
381
442
|
});
|
|
382
443
|
}
|
|
383
444
|
|
|
445
|
+
function parseRequestUrl(req) {
|
|
446
|
+
try {
|
|
447
|
+
return new URL(req.url || '/', 'http://127.0.0.1');
|
|
448
|
+
} catch (_error) {
|
|
449
|
+
return null;
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function writeHttpDecision(res, decision) {
|
|
454
|
+
res.writeHead(decision.status, {
|
|
455
|
+
'Content-Type': 'text/plain; charset=utf-8',
|
|
456
|
+
'X-Content-Type-Options': 'nosniff',
|
|
457
|
+
});
|
|
458
|
+
res.end(decision.reason);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function writeTokenResponse(res) {
|
|
462
|
+
res.writeHead(200, {
|
|
463
|
+
'Content-Type': 'application/json',
|
|
464
|
+
'Cache-Control': 'no-store',
|
|
465
|
+
'X-Content-Type-Options': 'nosniff',
|
|
466
|
+
});
|
|
467
|
+
res.end(JSON.stringify({ token: AUTH_CONFIG.token }));
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function handleTokenRequest(req, res, url) {
|
|
471
|
+
if (url.pathname !== '/api/token') {
|
|
472
|
+
return false;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
if (req.method !== 'GET') {
|
|
476
|
+
res.writeHead(405, {
|
|
477
|
+
Allow: 'GET',
|
|
478
|
+
'Content-Type': 'text/plain; charset=utf-8',
|
|
479
|
+
'X-Content-Type-Options': 'nosniff',
|
|
480
|
+
});
|
|
481
|
+
res.end('Method Not Allowed');
|
|
482
|
+
return true;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
const decision = validateTokenRequest(AUTH_CONFIG, {
|
|
486
|
+
host: req.headers.host,
|
|
487
|
+
origin: req.headers.origin,
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
if (!decision.ok) {
|
|
491
|
+
writeHttpDecision(res, decision);
|
|
492
|
+
return true;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
writeTokenResponse(res);
|
|
496
|
+
return true;
|
|
497
|
+
}
|
|
498
|
+
|
|
384
499
|
// ============================================================================
|
|
385
500
|
// WebSocket Server (using ws package)
|
|
386
501
|
// ============================================================================
|
|
@@ -416,23 +531,67 @@ function createPtySession(cols, rows) {
|
|
|
416
531
|
// WebSocket server attached to HTTP server (same port)
|
|
417
532
|
const wss = new WebSocketServer({ noServer: true });
|
|
418
533
|
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
534
|
+
function rejectUpgrade(socket, decision) {
|
|
535
|
+
if (socket.destroyed) {
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
const body = decision.reason + '\n';
|
|
540
|
+
socket.write(
|
|
541
|
+
`HTTP/1.1 ${decision.status} ${decision.reason}\r\n` +
|
|
542
|
+
'Connection: close\r\n' +
|
|
543
|
+
'Content-Type: text/plain; charset=utf-8\r\n' +
|
|
544
|
+
'X-Content-Type-Options: nosniff\r\n' +
|
|
545
|
+
`Content-Length: ${Buffer.byteLength(body)}\r\n` +
|
|
546
|
+
'\r\n' +
|
|
547
|
+
body
|
|
548
|
+
);
|
|
549
|
+
socket.destroy();
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
function handleWebSocketUpgrade(req, socket, head) {
|
|
553
|
+
const url = parseRequestUrl(req);
|
|
554
|
+
if (!url) {
|
|
555
|
+
rejectUpgrade(socket, { status: 400, reason: 'Bad Request' });
|
|
556
|
+
return true;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
if (url.pathname !== '/ws') {
|
|
560
|
+
return false;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
const decision = validateWebSocketRequest(AUTH_CONFIG, {
|
|
564
|
+
host: req.headers.host,
|
|
565
|
+
origin: req.headers.origin,
|
|
566
|
+
token: url.searchParams.get('token'),
|
|
567
|
+
});
|
|
422
568
|
|
|
423
|
-
if (
|
|
424
|
-
|
|
425
|
-
|
|
569
|
+
if (!decision.ok) {
|
|
570
|
+
rejectUpgrade(socket, decision);
|
|
571
|
+
return true;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
if (!socket.destroyed && !socket.readableEnded) {
|
|
426
575
|
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
427
576
|
wss.emit('connection', ws, req);
|
|
428
577
|
});
|
|
429
|
-
}
|
|
578
|
+
}
|
|
579
|
+
return true;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// Handle HTTP upgrade for WebSocket connections
|
|
583
|
+
httpServer.on('upgrade', (req, socket, head) => {
|
|
584
|
+
if (!handleWebSocketUpgrade(req, socket, head)) {
|
|
430
585
|
socket.destroy();
|
|
431
586
|
}
|
|
432
587
|
});
|
|
433
588
|
|
|
434
589
|
wss.on('connection', (ws, req) => {
|
|
435
|
-
const url =
|
|
590
|
+
const url = parseRequestUrl(req);
|
|
591
|
+
if (!url) {
|
|
592
|
+
ws.close();
|
|
593
|
+
return;
|
|
594
|
+
}
|
|
436
595
|
const cols = Number.parseInt(url.searchParams.get('cols') || '80');
|
|
437
596
|
const rows = Number.parseInt(url.searchParams.get('rows') || '24');
|
|
438
597
|
|
|
@@ -508,12 +667,17 @@ wss.on('connection', (ws, req) => {
|
|
|
508
667
|
// Startup
|
|
509
668
|
// ============================================================================
|
|
510
669
|
|
|
670
|
+
function formatUrlHost(host) {
|
|
671
|
+
return host.includes(':') && !host.startsWith('[') ? `[${host}]` : host;
|
|
672
|
+
}
|
|
673
|
+
|
|
511
674
|
function printBanner(url) {
|
|
512
675
|
console.log('\n' + '═'.repeat(60));
|
|
513
676
|
console.log(' 🚀 ghostty-web demo server' + (DEV_MODE ? ' (dev mode)' : ''));
|
|
514
677
|
console.log('═'.repeat(60));
|
|
515
678
|
console.log(`\n 📺 Open: ${url}`);
|
|
516
679
|
console.log(` 📡 WebSocket PTY: same endpoint /ws`);
|
|
680
|
+
console.log(' 🔐 WebSocket auth: per-run same-origin token');
|
|
517
681
|
console.log(` 🐚 Shell: ${getShell()}`);
|
|
518
682
|
console.log(` 📁 Home: ${homedir()}`);
|
|
519
683
|
if (DEV_MODE) {
|
|
@@ -522,6 +686,12 @@ function printBanner(url) {
|
|
|
522
686
|
console.log(` 📦 Using local build: ${distPath}`);
|
|
523
687
|
}
|
|
524
688
|
console.log('\n ⚠️ This server provides shell access.');
|
|
689
|
+
console.log(' It binds to ' + HOST + ' and rejects cross-origin WebSockets.');
|
|
690
|
+
if (isWildcardBindHost(HOST) || !isLoopbackHost(HOST)) {
|
|
691
|
+
console.log(
|
|
692
|
+
' Remote access requires GHOSTTY_ALLOWED_HOSTS and can expose your shell if misconfigured.'
|
|
693
|
+
);
|
|
694
|
+
}
|
|
525
695
|
console.log(' Only use for local development.\n');
|
|
526
696
|
console.log('═'.repeat(60));
|
|
527
697
|
console.log(' Press Ctrl+C to stop.\n');
|
|
@@ -544,9 +714,32 @@ if (DEV_MODE) {
|
|
|
544
714
|
const { createServer } = await import('vite');
|
|
545
715
|
const vite = await createServer({
|
|
546
716
|
root: repoRoot,
|
|
717
|
+
plugins: [
|
|
718
|
+
{
|
|
719
|
+
name: 'ghostty-demo-auth',
|
|
720
|
+
configureServer(server) {
|
|
721
|
+
server.middlewares.use((req, res, next) => {
|
|
722
|
+
const url = parseRequestUrl(req);
|
|
723
|
+
if (!url) {
|
|
724
|
+
writeHttpDecision(res, { status: 400, reason: 'Bad Request' });
|
|
725
|
+
return;
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
if (handleTokenRequest(req, res, url)) {
|
|
729
|
+
return;
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
next();
|
|
733
|
+
});
|
|
734
|
+
},
|
|
735
|
+
},
|
|
736
|
+
],
|
|
547
737
|
server: {
|
|
738
|
+
host: HOST,
|
|
548
739
|
port: HTTP_PORT,
|
|
549
740
|
strictPort: true,
|
|
741
|
+
cors: false,
|
|
742
|
+
allowedHosts: AUTH_CONFIG.allowedHosts,
|
|
550
743
|
},
|
|
551
744
|
});
|
|
552
745
|
|
|
@@ -557,30 +750,20 @@ if (DEV_MODE) {
|
|
|
557
750
|
// This ensures our handler runs BEFORE Vite's handlers
|
|
558
751
|
if (vite.httpServer) {
|
|
559
752
|
vite.httpServer.prependListener('upgrade', (req, socket, head) => {
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
// ONLY handle /ws - everything else passes through unchanged to Vite
|
|
563
|
-
if (pathname === '/ws') {
|
|
564
|
-
if (!socket.destroyed && !socket.readableEnded) {
|
|
565
|
-
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
566
|
-
wss.emit('connection', ws, req);
|
|
567
|
-
});
|
|
568
|
-
}
|
|
569
|
-
// Stop here - we handled it, socket is consumed
|
|
570
|
-
// Don't call other listeners
|
|
753
|
+
// ONLY handle /ws - everything else passes through unchanged to Vite.
|
|
754
|
+
if (handleWebSocketUpgrade(req, socket, head)) {
|
|
571
755
|
return;
|
|
572
756
|
}
|
|
573
757
|
|
|
574
|
-
// For non-/ws paths, explicitly do nothing and let the event propagate
|
|
575
|
-
// The key is: don't return, don't touch the socket, just let
|
|
576
|
-
// Vite's handlers (which were added before ours via prependListener) will process it
|
|
758
|
+
// For non-/ws paths, explicitly do nothing and let the event propagate.
|
|
759
|
+
// The key is: don't return, don't touch the socket, just let Vite process it.
|
|
577
760
|
});
|
|
578
761
|
}
|
|
579
762
|
|
|
580
|
-
printBanner(`http
|
|
763
|
+
printBanner(`http://${formatUrlHost(HOST)}:${HTTP_PORT}/demo/`);
|
|
581
764
|
} else {
|
|
582
765
|
// Production mode: static file server
|
|
583
|
-
httpServer.listen(HTTP_PORT, () => {
|
|
584
|
-
printBanner(`http
|
|
766
|
+
httpServer.listen(HTTP_PORT, HOST, () => {
|
|
767
|
+
printBanner(`http://${formatUrlHost(HOST)}:${HTTP_PORT}`);
|
|
585
768
|
});
|
|
586
769
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ghostty-web/demo",
|
|
3
|
-
"version": "0.4.0-next.
|
|
3
|
+
"version": "0.4.0-next.18.gbec9e16",
|
|
4
4
|
"description": "Cross-platform demo server for ghostty-web terminal emulator",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
},
|
|
13
13
|
"dependencies": {
|
|
14
14
|
"@lydell/node-pty": "^1.0.1",
|
|
15
|
-
"ghostty-web": "0.4.0-next.
|
|
15
|
+
"ghostty-web": "0.4.0-next.18.gbec9e16",
|
|
16
16
|
"ws": "^8.18.0"
|
|
17
17
|
},
|
|
18
18
|
"files": [
|