@specific.dev/spectest 0.89.0 → 0.89.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/dist/daemon.js +40 -85
- package/dist/harness/ws-bridge.d.ts +78 -0
- package/dist/harness/ws-bridge.js +180 -0
- package/package.json +1 -1
- package/src/daemon.ts +48 -86
- package/src/harness/ws-bridge.test.ts +224 -0
- package/src/harness/ws-bridge.ts +210 -0
package/dist/daemon.js
CHANGED
|
@@ -51,6 +51,7 @@ import { bindInstrumentationScope, createInstrumentationScope, runInstrumented,
|
|
|
51
51
|
import { encodeRegistry } from "./harness/names-registry.js";
|
|
52
52
|
import { InterceptRegistry, parseTarget, runChain, } from "./harness/intercept.js";
|
|
53
53
|
import { HOP_BY_HOP_HEADERS, augmentCorsResponse, corsPreflightResponse, isCorsPreflight, } from "./harness/http-proxy.js";
|
|
54
|
+
import { WS_BRIDGE_HANDLERS, openUpstreamWebSocket, parseSubprotocols, upstreamHandshakeHeaders, } from "./harness/ws-bridge.js";
|
|
54
55
|
import { certCovers as hostmatchCertCovers, hostWithoutPort, matchRoute, selectCertName, wildcardSuffix, } from "./harness/hostmatch.js";
|
|
55
56
|
import { INGRESS_HTTPS_PORT, INGRESS_HTTP_PORT, bindRoute, clearTables, emptyTables, planBind, registryTarget, routesFor, unbindRoute, } from "./harness/ingress-table.js";
|
|
56
57
|
import { startTlsTerminator } from "./harness/tls-terminator.js";
|
|
@@ -1977,8 +1978,8 @@ async function unbindRuntimeTls(hostname) {
|
|
|
1977
1978
|
* Spin up one Bun.serve listener bound to (port, optional TLS) that
|
|
1978
1979
|
* dispatches every request to the matching Route by Host header.
|
|
1979
1980
|
*
|
|
1980
|
-
* Shared by the HTTP and HTTPS branches. Also
|
|
1981
|
-
* handler so reverse-proxy targets can transparently bridge WS
|
|
1981
|
+
* Shared by the HTTP and HTTPS branches. Also carries the `websocket`
|
|
1982
|
+
* handler set so reverse-proxy targets can transparently bridge WS
|
|
1982
1983
|
* upgrades through to their upstream service.
|
|
1983
1984
|
*/
|
|
1984
1985
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
@@ -2016,81 +2017,9 @@ serve = { proto: "http" }) {
|
|
|
2016
2017
|
idleTimeout: 0,
|
|
2017
2018
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
2018
2019
|
fetch: (req, server) => dispatchIngress(req, server, byHost, listenerLabel, proto),
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
const data = ws.data;
|
|
2023
|
-
try {
|
|
2024
|
-
const upstream = new WebSocket(data.upstreamUrl);
|
|
2025
|
-
// ArrayBuffer so binary frames can be ws.send()'d to the
|
|
2026
|
-
// downstream client verbatim — Blob would need an extra
|
|
2027
|
-
// .arrayBuffer() round-trip on every message.
|
|
2028
|
-
upstream.binaryType = "arraybuffer";
|
|
2029
|
-
data.upstream = upstream;
|
|
2030
|
-
upstream.addEventListener("open", () => {
|
|
2031
|
-
for (const m of data.pending)
|
|
2032
|
-
upstream.send(m);
|
|
2033
|
-
data.pending = [];
|
|
2034
|
-
});
|
|
2035
|
-
upstream.addEventListener("message", (ev) => {
|
|
2036
|
-
try {
|
|
2037
|
-
ws.send(ev.data);
|
|
2038
|
-
}
|
|
2039
|
-
catch {
|
|
2040
|
-
/* client gone */
|
|
2041
|
-
}
|
|
2042
|
-
});
|
|
2043
|
-
upstream.addEventListener("close", (ev) => {
|
|
2044
|
-
try {
|
|
2045
|
-
ws.close(ev.code, ev.reason);
|
|
2046
|
-
}
|
|
2047
|
-
catch {
|
|
2048
|
-
/* already closed */
|
|
2049
|
-
}
|
|
2050
|
-
});
|
|
2051
|
-
upstream.addEventListener("error", () => {
|
|
2052
|
-
try {
|
|
2053
|
-
ws.close(1011, "upstream error");
|
|
2054
|
-
}
|
|
2055
|
-
catch {
|
|
2056
|
-
/* already closed */
|
|
2057
|
-
}
|
|
2058
|
-
});
|
|
2059
|
-
}
|
|
2060
|
-
catch (err) {
|
|
2061
|
-
// eslint-disable-next-line no-console
|
|
2062
|
-
console.warn(`[ingress] ws upstream open failed for ${data.upstreamUrl}:`, err);
|
|
2063
|
-
try {
|
|
2064
|
-
ws.close(1011, "upstream open failed");
|
|
2065
|
-
}
|
|
2066
|
-
catch {
|
|
2067
|
-
/* ignore */
|
|
2068
|
-
}
|
|
2069
|
-
}
|
|
2070
|
-
},
|
|
2071
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
2072
|
-
message(ws, message) {
|
|
2073
|
-
const data = ws.data;
|
|
2074
|
-
const payload = typeof message === "string" ? message : new Uint8Array(message);
|
|
2075
|
-
if (data.upstream && data.upstream.readyState === WebSocket.OPEN) {
|
|
2076
|
-
data.upstream.send(payload);
|
|
2077
|
-
}
|
|
2078
|
-
else {
|
|
2079
|
-
// Buffer until the upstream finishes its handshake.
|
|
2080
|
-
data.pending.push(payload);
|
|
2081
|
-
}
|
|
2082
|
-
},
|
|
2083
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
2084
|
-
close(ws, code, reason) {
|
|
2085
|
-
const data = ws.data;
|
|
2086
|
-
try {
|
|
2087
|
-
data.upstream?.close(code, reason);
|
|
2088
|
-
}
|
|
2089
|
-
catch {
|
|
2090
|
-
/* ignore */
|
|
2091
|
-
}
|
|
2092
|
-
},
|
|
2093
|
-
},
|
|
2020
|
+
// The frame relay for bridged WebSocket upgrades; `proxyToService`
|
|
2021
|
+
// opens the upstream before it upgrades the client (harness/ws-bridge.ts).
|
|
2022
|
+
websocket: WS_BRIDGE_HANDLERS,
|
|
2094
2023
|
};
|
|
2095
2024
|
return Bun.serve(opts);
|
|
2096
2025
|
}
|
|
@@ -2181,8 +2110,12 @@ function ingressClientIp(server, req, proto) {
|
|
|
2181
2110
|
* Reverse-proxy a request to `http://<service>:<port>` on
|
|
2182
2111
|
* `spectest-net`. Handles plain HTTP/1.1 + 2 and WebSocket upgrades:
|
|
2183
2112
|
*
|
|
2184
|
-
* - WS upgrade requests
|
|
2185
|
-
*
|
|
2113
|
+
* - WS upgrade requests open the upstream WebSocket first, with the
|
|
2114
|
+
* client's full `Sec-WebSocket-Protocol` list and its forwardable
|
|
2115
|
+
* headers (cookies, authorization, origin, x-forwarded-*). Only after
|
|
2116
|
+
* that handshake succeeds is the client upgraded, with the protocol
|
|
2117
|
+
* the upstream selected; a refused upstream handshake is a 502, not a
|
|
2118
|
+
* 101 followed by a close. The open upstream rides on `ws.data`; the shared `websocket`
|
|
2186
2119
|
* handler opens the upstream and bridges frames both ways.
|
|
2187
2120
|
* - Plain requests pass through via `fetch()` with hop-by-hop
|
|
2188
2121
|
* headers stripped; the response body is a ReadableStream returned
|
|
@@ -2205,16 +2138,38 @@ server, service, port, listenerLabel, proto) {
|
|
|
2205
2138
|
const upgrade = req.headers.get("upgrade")?.toLowerCase() ?? "";
|
|
2206
2139
|
if (upgrade === "websocket") {
|
|
2207
2140
|
const upstreamUrl = `ws://${await proxyUpstreamHost(service)}:${port}${upstreamPath}`;
|
|
2208
|
-
|
|
2209
|
-
|
|
2210
|
-
|
|
2211
|
-
|
|
2212
|
-
|
|
2213
|
-
|
|
2141
|
+
// The upstream handshake carries what the client's did: the whole
|
|
2142
|
+
// subprotocol list (a console ticket often rides as the second entry)
|
|
2143
|
+
// and the forwardable headers. Same provenance stamps as the HTTP path.
|
|
2144
|
+
const protocols = parseSubprotocols(req.headers.get("sec-websocket-protocol"));
|
|
2145
|
+
const headers = upstreamHandshakeHeaders(req.headers, {
|
|
2146
|
+
service,
|
|
2147
|
+
port,
|
|
2148
|
+
proto,
|
|
2149
|
+
clientIp: ingressClientIp(server, req, proto),
|
|
2150
|
+
});
|
|
2151
|
+
const opened = await openUpstreamWebSocket(upstreamUrl, { protocols, headers });
|
|
2152
|
+
if (!opened.ok) {
|
|
2153
|
+
return new Response(`spectest-daemon: ${opened.reason} (${upstreamUrl}) on ${listenerLabel}\n`, { status: 502, headers: { "content-type": "text/plain" } });
|
|
2154
|
+
}
|
|
2155
|
+
const wsData = { upstreamUrl, upstream: opened.socket };
|
|
2156
|
+
// Echo the protocol the upstream selected; Bun would otherwise pick
|
|
2157
|
+
// the client's first entry by itself, which can differ.
|
|
2158
|
+
const selected = opened.socket.protocol;
|
|
2159
|
+
const ok = server.upgrade(req, {
|
|
2160
|
+
data: wsData,
|
|
2161
|
+
headers: selected ? { "sec-websocket-protocol": selected } : undefined,
|
|
2162
|
+
});
|
|
2214
2163
|
if (ok) {
|
|
2215
2164
|
// Bun has already taken over the response — return a stub.
|
|
2216
2165
|
return new Response(null, { status: 101 });
|
|
2217
2166
|
}
|
|
2167
|
+
try {
|
|
2168
|
+
opened.socket.close(1011, "client upgrade refused");
|
|
2169
|
+
}
|
|
2170
|
+
catch {
|
|
2171
|
+
/* ignore */
|
|
2172
|
+
}
|
|
2218
2173
|
return new Response(`spectest-daemon: ws upgrade refused on ${listenerLabel}\n`, { status: 426, headers: { "content-type": "text/plain" } });
|
|
2219
2174
|
}
|
|
2220
2175
|
const fwdHeaders = new Headers();
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ingress WebSocket bridge: one client WebSocket on an ingress
|
|
3
|
+
* listener, one upstream WebSocket to the service, frames copied in both
|
|
4
|
+
* directions.
|
|
5
|
+
*
|
|
6
|
+
* The handshake is the part that matters. A client that sends
|
|
7
|
+
* `Sec-WebSocket-Protocol: console, <ticket>` expects the upstream to see
|
|
8
|
+
* the full list, and expects to get back the one the upstream selected.
|
|
9
|
+
* The first version of this bridge opened the upstream with no protocols
|
|
10
|
+
* and no headers, so a ticket or a cookie that rode on the handshake never
|
|
11
|
+
* reached the service, and the service refused the connection after the
|
|
12
|
+
* client already saw a 101. So the bridge now opens the upstream FIRST,
|
|
13
|
+
* with the client's protocol list and forwardable headers, waits for the
|
|
14
|
+
* upstream handshake, and only then upgrades the client with the protocol
|
|
15
|
+
* the upstream selected. An upstream that refuses the handshake becomes a
|
|
16
|
+
* 502 to the client, the same as the HTTP proxy path.
|
|
17
|
+
*
|
|
18
|
+
* `daemon.ts` owns the listener and the route lookup; this module owns
|
|
19
|
+
* the handshake and the frame relay so the two can be tested on loopback
|
|
20
|
+
* without a VM.
|
|
21
|
+
*/
|
|
22
|
+
/** Per-bridge context stored on `ws.data` of the client socket. */
|
|
23
|
+
export interface WsBridgeData {
|
|
24
|
+
upstreamUrl: string;
|
|
25
|
+
/** Open by the time the client socket exists: the handshake awaited it. */
|
|
26
|
+
upstream: WebSocket;
|
|
27
|
+
}
|
|
28
|
+
/** `Sec-WebSocket-Protocol` is a comma-separated list; each token is
|
|
29
|
+
* trimmed, empty tokens are dropped, order is kept (RFC 6455 §4.1 lets
|
|
30
|
+
* the server pick from the list, so order is the client's preference). */
|
|
31
|
+
export declare function parseSubprotocols(header: string | null | undefined): string[];
|
|
32
|
+
/**
|
|
33
|
+
* The headers the upstream handshake carries: everything forwardable from
|
|
34
|
+
* the client's request (cookies, authorization, origin, user-agent …),
|
|
35
|
+
* minus the client's own handshake fields, plus the proxy provenance
|
|
36
|
+
* headers and a Host that names the service.
|
|
37
|
+
*/
|
|
38
|
+
export declare function upstreamHandshakeHeaders(req: Headers, opts: {
|
|
39
|
+
service: string;
|
|
40
|
+
port: number;
|
|
41
|
+
proto: string;
|
|
42
|
+
clientIp?: string;
|
|
43
|
+
}): Record<string, string>;
|
|
44
|
+
export type UpstreamOpen = {
|
|
45
|
+
ok: true;
|
|
46
|
+
socket: WebSocket;
|
|
47
|
+
} | {
|
|
48
|
+
ok: false;
|
|
49
|
+
reason: string;
|
|
50
|
+
};
|
|
51
|
+
/**
|
|
52
|
+
* Open the upstream WebSocket and wait for its handshake. Resolves once
|
|
53
|
+
* the socket is open, or once it failed; the handshake listeners are
|
|
54
|
+
* removed either way so a later close does not fire them again.
|
|
55
|
+
*/
|
|
56
|
+
export declare function openUpstreamWebSocket(url: string, opts: {
|
|
57
|
+
protocols: string[];
|
|
58
|
+
headers: Record<string, string>;
|
|
59
|
+
timeoutMs?: number;
|
|
60
|
+
}): Promise<UpstreamOpen>;
|
|
61
|
+
/** The minimum of Bun's ServerWebSocket that the relay uses. */
|
|
62
|
+
interface ClientSocket {
|
|
63
|
+
data: WsBridgeData;
|
|
64
|
+
send(data: string | ArrayBuffer | Uint8Array): unknown;
|
|
65
|
+
close(code?: number, reason?: string): unknown;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* The `websocket` handler set for `Bun.serve`. The upstream is already
|
|
69
|
+
* open when `open()` runs, so frames go straight through in both
|
|
70
|
+
* directions, and a close on either side closes the other with the same
|
|
71
|
+
* code and reason.
|
|
72
|
+
*/
|
|
73
|
+
export declare const WS_BRIDGE_HANDLERS: {
|
|
74
|
+
open(ws: ClientSocket): void;
|
|
75
|
+
message(ws: ClientSocket, message: string | Buffer): void;
|
|
76
|
+
close(ws: ClientSocket, code: number, reason: string): void;
|
|
77
|
+
};
|
|
78
|
+
export {};
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ingress WebSocket bridge: one client WebSocket on an ingress
|
|
3
|
+
* listener, one upstream WebSocket to the service, frames copied in both
|
|
4
|
+
* directions.
|
|
5
|
+
*
|
|
6
|
+
* The handshake is the part that matters. A client that sends
|
|
7
|
+
* `Sec-WebSocket-Protocol: console, <ticket>` expects the upstream to see
|
|
8
|
+
* the full list, and expects to get back the one the upstream selected.
|
|
9
|
+
* The first version of this bridge opened the upstream with no protocols
|
|
10
|
+
* and no headers, so a ticket or a cookie that rode on the handshake never
|
|
11
|
+
* reached the service, and the service refused the connection after the
|
|
12
|
+
* client already saw a 101. So the bridge now opens the upstream FIRST,
|
|
13
|
+
* with the client's protocol list and forwardable headers, waits for the
|
|
14
|
+
* upstream handshake, and only then upgrades the client with the protocol
|
|
15
|
+
* the upstream selected. An upstream that refuses the handshake becomes a
|
|
16
|
+
* 502 to the client, the same as the HTTP proxy path.
|
|
17
|
+
*
|
|
18
|
+
* `daemon.ts` owns the listener and the route lookup; this module owns
|
|
19
|
+
* the handshake and the frame relay so the two can be tested on loopback
|
|
20
|
+
* without a VM.
|
|
21
|
+
*/
|
|
22
|
+
import { forwardableHeaders } from "./http-proxy.js";
|
|
23
|
+
/** Headers that belong to the client's own WebSocket handshake. Bun's
|
|
24
|
+
* WebSocket client mints its own key and version, and the protocol list
|
|
25
|
+
* goes through the constructor option, so these never travel as plain
|
|
26
|
+
* headers. */
|
|
27
|
+
const HANDSHAKE_HEADERS = new Set([
|
|
28
|
+
"sec-websocket-key",
|
|
29
|
+
"sec-websocket-version",
|
|
30
|
+
"sec-websocket-protocol",
|
|
31
|
+
"sec-websocket-extensions",
|
|
32
|
+
"sec-websocket-accept",
|
|
33
|
+
]);
|
|
34
|
+
/** `Sec-WebSocket-Protocol` is a comma-separated list; each token is
|
|
35
|
+
* trimmed, empty tokens are dropped, order is kept (RFC 6455 §4.1 lets
|
|
36
|
+
* the server pick from the list, so order is the client's preference). */
|
|
37
|
+
export function parseSubprotocols(header) {
|
|
38
|
+
if (!header)
|
|
39
|
+
return [];
|
|
40
|
+
return header
|
|
41
|
+
.split(",")
|
|
42
|
+
.map((s) => s.trim())
|
|
43
|
+
.filter((s) => s.length > 0);
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* The headers the upstream handshake carries: everything forwardable from
|
|
47
|
+
* the client's request (cookies, authorization, origin, user-agent …),
|
|
48
|
+
* minus the client's own handshake fields, plus the proxy provenance
|
|
49
|
+
* headers and a Host that names the service.
|
|
50
|
+
*/
|
|
51
|
+
export function upstreamHandshakeHeaders(req, opts) {
|
|
52
|
+
const out = {};
|
|
53
|
+
forwardableHeaders(req).forEach((value, key) => {
|
|
54
|
+
if (HANDSHAKE_HEADERS.has(key))
|
|
55
|
+
return;
|
|
56
|
+
out[key] = value;
|
|
57
|
+
});
|
|
58
|
+
const priorXff = req.get("x-forwarded-for");
|
|
59
|
+
const xff = opts.clientIp
|
|
60
|
+
? priorXff
|
|
61
|
+
? `${priorXff}, ${opts.clientIp}`
|
|
62
|
+
: opts.clientIp
|
|
63
|
+
: priorXff;
|
|
64
|
+
if (xff)
|
|
65
|
+
out["x-forwarded-for"] = xff;
|
|
66
|
+
out["x-forwarded-proto"] = opts.proto;
|
|
67
|
+
const publicHost = req.get("host");
|
|
68
|
+
if (publicHost)
|
|
69
|
+
out["x-forwarded-host"] = publicHost;
|
|
70
|
+
out["host"] = `${opts.service}:${opts.port}`;
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Open the upstream WebSocket and wait for its handshake. Resolves once
|
|
75
|
+
* the socket is open, or once it failed; the handshake listeners are
|
|
76
|
+
* removed either way so a later close does not fire them again.
|
|
77
|
+
*/
|
|
78
|
+
export function openUpstreamWebSocket(url, opts) {
|
|
79
|
+
const Ctor = WebSocket;
|
|
80
|
+
let socket;
|
|
81
|
+
try {
|
|
82
|
+
socket = new Ctor(url, { protocols: opts.protocols, headers: opts.headers });
|
|
83
|
+
}
|
|
84
|
+
catch (err) {
|
|
85
|
+
return Promise.resolve({ ok: false, reason: String(err) });
|
|
86
|
+
}
|
|
87
|
+
// ArrayBuffer so binary frames can be sent to the client verbatim.
|
|
88
|
+
socket.binaryType = "arraybuffer";
|
|
89
|
+
return new Promise((resolve) => {
|
|
90
|
+
let settled = false;
|
|
91
|
+
const timeoutMs = opts.timeoutMs ?? 30_000;
|
|
92
|
+
const timer = setTimeout(() => finish({ ok: false, reason: "upstream handshake timed out" }), timeoutMs);
|
|
93
|
+
const onOpen = () => finish({ ok: true, socket });
|
|
94
|
+
const onError = (ev) => {
|
|
95
|
+
const msg = ev.message;
|
|
96
|
+
finish({ ok: false, reason: msg ? `upstream handshake failed: ${msg}` : "upstream handshake failed" });
|
|
97
|
+
};
|
|
98
|
+
const onClose = (ev) => finish({ ok: false, reason: `upstream closed during handshake (${ev.code}${ev.reason ? ` ${ev.reason}` : ""})` });
|
|
99
|
+
const finish = (result) => {
|
|
100
|
+
if (settled)
|
|
101
|
+
return;
|
|
102
|
+
settled = true;
|
|
103
|
+
clearTimeout(timer);
|
|
104
|
+
socket.removeEventListener("open", onOpen);
|
|
105
|
+
socket.removeEventListener("error", onError);
|
|
106
|
+
socket.removeEventListener("close", onClose);
|
|
107
|
+
if (!result.ok) {
|
|
108
|
+
try {
|
|
109
|
+
socket.close();
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
/* already closed */
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
resolve(result);
|
|
116
|
+
};
|
|
117
|
+
socket.addEventListener("open", onOpen);
|
|
118
|
+
socket.addEventListener("error", onError);
|
|
119
|
+
socket.addEventListener("close", onClose);
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* The `websocket` handler set for `Bun.serve`. The upstream is already
|
|
124
|
+
* open when `open()` runs, so frames go straight through in both
|
|
125
|
+
* directions, and a close on either side closes the other with the same
|
|
126
|
+
* code and reason.
|
|
127
|
+
*/
|
|
128
|
+
export const WS_BRIDGE_HANDLERS = {
|
|
129
|
+
open(ws) {
|
|
130
|
+
const { upstream } = ws.data;
|
|
131
|
+
upstream.addEventListener("message", (ev) => {
|
|
132
|
+
try {
|
|
133
|
+
ws.send(ev.data);
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
/* client gone */
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
upstream.addEventListener("close", (ev) => {
|
|
140
|
+
try {
|
|
141
|
+
ws.close(ev.code, ev.reason);
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
/* already closed */
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
upstream.addEventListener("error", () => {
|
|
148
|
+
try {
|
|
149
|
+
ws.close(1011, "upstream error");
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
/* already closed */
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
// The upstream can have closed between the handshake and this
|
|
156
|
+
// callback; the close listener above was not attached yet then.
|
|
157
|
+
if (upstream.readyState !== WebSocket.OPEN) {
|
|
158
|
+
try {
|
|
159
|
+
ws.close(1011, "upstream closed");
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
/* already closed */
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
},
|
|
166
|
+
message(ws, message) {
|
|
167
|
+
const { upstream } = ws.data;
|
|
168
|
+
const payload = typeof message === "string" ? message : new Uint8Array(message);
|
|
169
|
+
if (upstream.readyState === WebSocket.OPEN)
|
|
170
|
+
upstream.send(payload);
|
|
171
|
+
},
|
|
172
|
+
close(ws, code, reason) {
|
|
173
|
+
try {
|
|
174
|
+
ws.data.upstream.close(code, reason);
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
/* ignore */
|
|
178
|
+
}
|
|
179
|
+
},
|
|
180
|
+
};
|
package/package.json
CHANGED
package/src/daemon.ts
CHANGED
|
@@ -124,6 +124,13 @@ import {
|
|
|
124
124
|
corsPreflightResponse,
|
|
125
125
|
isCorsPreflight,
|
|
126
126
|
} from "./harness/http-proxy.js";
|
|
127
|
+
import {
|
|
128
|
+
WS_BRIDGE_HANDLERS,
|
|
129
|
+
openUpstreamWebSocket,
|
|
130
|
+
parseSubprotocols,
|
|
131
|
+
upstreamHandshakeHeaders,
|
|
132
|
+
type WsBridgeData,
|
|
133
|
+
} from "./harness/ws-bridge.js";
|
|
127
134
|
import {
|
|
128
135
|
certCovers as hostmatchCertCovers,
|
|
129
136
|
hostWithoutPort,
|
|
@@ -2112,14 +2119,6 @@ async function bridgeGatewayIp(): Promise<string> {
|
|
|
2112
2119
|
* The shape is `harness/ingress-table.ts`'s, generic over that record. */
|
|
2113
2120
|
type Route = IngressRoute<FakeRuntime>;
|
|
2114
2121
|
|
|
2115
|
-
/** Per-WS-upgrade context: the upstream URL we'll open in `open()` and
|
|
2116
|
-
* keep the bridge in `message()`/`close()`. Stored on `ws.data`. */
|
|
2117
|
-
interface WsBridgeData {
|
|
2118
|
-
upstreamUrl: string;
|
|
2119
|
-
upstream: WebSocket | null;
|
|
2120
|
-
pending: Array<string | ArrayBuffer | Uint8Array>;
|
|
2121
|
-
}
|
|
2122
|
-
|
|
2123
2122
|
/**
|
|
2124
2123
|
* Pristine `fetch` captured at module load, before any test-scoped
|
|
2125
2124
|
* fetch wrapper can monkey-patch `globalThis.fetch`. The reverse-proxy
|
|
@@ -2440,8 +2439,8 @@ async function unbindRuntimeTls(hostname: string): Promise<void> {
|
|
|
2440
2439
|
* Spin up one Bun.serve listener bound to (port, optional TLS) that
|
|
2441
2440
|
* dispatches every request to the matching Route by Host header.
|
|
2442
2441
|
*
|
|
2443
|
-
* Shared by the HTTP and HTTPS branches. Also
|
|
2444
|
-
* handler so reverse-proxy targets can transparently bridge WS
|
|
2442
|
+
* Shared by the HTTP and HTTPS branches. Also carries the `websocket`
|
|
2443
|
+
* handler set so reverse-proxy targets can transparently bridge WS
|
|
2445
2444
|
* upgrades through to their upstream service.
|
|
2446
2445
|
*/
|
|
2447
2446
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
@@ -2485,74 +2484,9 @@ function bindIngressServer(
|
|
|
2485
2484
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
2486
2485
|
fetch: (req: Request, server: any): Response | Promise<Response> =>
|
|
2487
2486
|
dispatchIngress(req, server, byHost, listenerLabel, proto),
|
|
2488
|
-
|
|
2489
|
-
|
|
2490
|
-
|
|
2491
|
-
const data = ws.data as WsBridgeData;
|
|
2492
|
-
try {
|
|
2493
|
-
const upstream = new WebSocket(data.upstreamUrl);
|
|
2494
|
-
// ArrayBuffer so binary frames can be ws.send()'d to the
|
|
2495
|
-
// downstream client verbatim — Blob would need an extra
|
|
2496
|
-
// .arrayBuffer() round-trip on every message.
|
|
2497
|
-
upstream.binaryType = "arraybuffer";
|
|
2498
|
-
data.upstream = upstream;
|
|
2499
|
-
upstream.addEventListener("open", () => {
|
|
2500
|
-
for (const m of data.pending) upstream.send(m);
|
|
2501
|
-
data.pending = [];
|
|
2502
|
-
});
|
|
2503
|
-
upstream.addEventListener("message", (ev: MessageEvent) => {
|
|
2504
|
-
try {
|
|
2505
|
-
ws.send(ev.data);
|
|
2506
|
-
} catch {
|
|
2507
|
-
/* client gone */
|
|
2508
|
-
}
|
|
2509
|
-
});
|
|
2510
|
-
upstream.addEventListener("close", (ev: CloseEvent) => {
|
|
2511
|
-
try {
|
|
2512
|
-
ws.close(ev.code, ev.reason);
|
|
2513
|
-
} catch {
|
|
2514
|
-
/* already closed */
|
|
2515
|
-
}
|
|
2516
|
-
});
|
|
2517
|
-
upstream.addEventListener("error", () => {
|
|
2518
|
-
try {
|
|
2519
|
-
ws.close(1011, "upstream error");
|
|
2520
|
-
} catch {
|
|
2521
|
-
/* already closed */
|
|
2522
|
-
}
|
|
2523
|
-
});
|
|
2524
|
-
} catch (err) {
|
|
2525
|
-
// eslint-disable-next-line no-console
|
|
2526
|
-
console.warn(`[ingress] ws upstream open failed for ${data.upstreamUrl}:`, err);
|
|
2527
|
-
try {
|
|
2528
|
-
ws.close(1011, "upstream open failed");
|
|
2529
|
-
} catch {
|
|
2530
|
-
/* ignore */
|
|
2531
|
-
}
|
|
2532
|
-
}
|
|
2533
|
-
},
|
|
2534
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
2535
|
-
message(ws: any, message: string | Buffer) {
|
|
2536
|
-
const data = ws.data as WsBridgeData;
|
|
2537
|
-
const payload =
|
|
2538
|
-
typeof message === "string" ? message : new Uint8Array(message);
|
|
2539
|
-
if (data.upstream && data.upstream.readyState === WebSocket.OPEN) {
|
|
2540
|
-
data.upstream.send(payload);
|
|
2541
|
-
} else {
|
|
2542
|
-
// Buffer until the upstream finishes its handshake.
|
|
2543
|
-
data.pending.push(payload);
|
|
2544
|
-
}
|
|
2545
|
-
},
|
|
2546
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
2547
|
-
close(ws: any, code: number, reason: string) {
|
|
2548
|
-
const data = ws.data as WsBridgeData;
|
|
2549
|
-
try {
|
|
2550
|
-
data.upstream?.close(code, reason);
|
|
2551
|
-
} catch {
|
|
2552
|
-
/* ignore */
|
|
2553
|
-
}
|
|
2554
|
-
},
|
|
2555
|
-
},
|
|
2487
|
+
// The frame relay for bridged WebSocket upgrades; `proxyToService`
|
|
2488
|
+
// opens the upstream before it upgrades the client (harness/ws-bridge.ts).
|
|
2489
|
+
websocket: WS_BRIDGE_HANDLERS,
|
|
2556
2490
|
};
|
|
2557
2491
|
return Bun.serve(opts);
|
|
2558
2492
|
}
|
|
@@ -2657,8 +2591,12 @@ function ingressClientIp(server: any, req: Request, proto: string): string | und
|
|
|
2657
2591
|
* Reverse-proxy a request to `http://<service>:<port>` on
|
|
2658
2592
|
* `spectest-net`. Handles plain HTTP/1.1 + 2 and WebSocket upgrades:
|
|
2659
2593
|
*
|
|
2660
|
-
* - WS upgrade requests
|
|
2661
|
-
*
|
|
2594
|
+
* - WS upgrade requests open the upstream WebSocket first, with the
|
|
2595
|
+
* client's full `Sec-WebSocket-Protocol` list and its forwardable
|
|
2596
|
+
* headers (cookies, authorization, origin, x-forwarded-*). Only after
|
|
2597
|
+
* that handshake succeeds is the client upgraded, with the protocol
|
|
2598
|
+
* the upstream selected; a refused upstream handshake is a 502, not a
|
|
2599
|
+
* 101 followed by a close. The open upstream rides on `ws.data`; the shared `websocket`
|
|
2662
2600
|
* handler opens the upstream and bridges frames both ways.
|
|
2663
2601
|
* - Plain requests pass through via `fetch()` with hop-by-hop
|
|
2664
2602
|
* headers stripped; the response body is a ReadableStream returned
|
|
@@ -2688,16 +2626,40 @@ async function proxyToService(
|
|
|
2688
2626
|
const upgrade = req.headers.get("upgrade")?.toLowerCase() ?? "";
|
|
2689
2627
|
if (upgrade === "websocket") {
|
|
2690
2628
|
const upstreamUrl = `ws://${await proxyUpstreamHost(service)}:${port}${upstreamPath}`;
|
|
2691
|
-
|
|
2692
|
-
|
|
2693
|
-
|
|
2694
|
-
|
|
2695
|
-
|
|
2696
|
-
|
|
2629
|
+
// The upstream handshake carries what the client's did: the whole
|
|
2630
|
+
// subprotocol list (a console ticket often rides as the second entry)
|
|
2631
|
+
// and the forwardable headers. Same provenance stamps as the HTTP path.
|
|
2632
|
+
const protocols = parseSubprotocols(req.headers.get("sec-websocket-protocol"));
|
|
2633
|
+
const headers = upstreamHandshakeHeaders(req.headers, {
|
|
2634
|
+
service,
|
|
2635
|
+
port,
|
|
2636
|
+
proto,
|
|
2637
|
+
clientIp: ingressClientIp(server, req, proto),
|
|
2638
|
+
});
|
|
2639
|
+
const opened = await openUpstreamWebSocket(upstreamUrl, { protocols, headers });
|
|
2640
|
+
if (!opened.ok) {
|
|
2641
|
+
return new Response(
|
|
2642
|
+
`spectest-daemon: ${opened.reason} (${upstreamUrl}) on ${listenerLabel}\n`,
|
|
2643
|
+
{ status: 502, headers: { "content-type": "text/plain" } },
|
|
2644
|
+
);
|
|
2645
|
+
}
|
|
2646
|
+
const wsData: WsBridgeData = { upstreamUrl, upstream: opened.socket };
|
|
2647
|
+
// Echo the protocol the upstream selected; Bun would otherwise pick
|
|
2648
|
+
// the client's first entry by itself, which can differ.
|
|
2649
|
+
const selected = opened.socket.protocol;
|
|
2650
|
+
const ok = server.upgrade(req, {
|
|
2651
|
+
data: wsData,
|
|
2652
|
+
headers: selected ? { "sec-websocket-protocol": selected } : undefined,
|
|
2653
|
+
});
|
|
2697
2654
|
if (ok) {
|
|
2698
2655
|
// Bun has already taken over the response — return a stub.
|
|
2699
2656
|
return new Response(null, { status: 101 });
|
|
2700
2657
|
}
|
|
2658
|
+
try {
|
|
2659
|
+
opened.socket.close(1011, "client upgrade refused");
|
|
2660
|
+
} catch {
|
|
2661
|
+
/* ignore */
|
|
2662
|
+
}
|
|
2701
2663
|
return new Response(
|
|
2702
2664
|
`spectest-daemon: ws upgrade refused on ${listenerLabel}\n`,
|
|
2703
2665
|
{ status: 426, headers: { "content-type": "text/plain" } },
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
WS_BRIDGE_HANDLERS,
|
|
5
|
+
openUpstreamWebSocket,
|
|
6
|
+
parseSubprotocols,
|
|
7
|
+
upstreamHandshakeHeaders,
|
|
8
|
+
type WsBridgeData,
|
|
9
|
+
} from "./ws-bridge";
|
|
10
|
+
|
|
11
|
+
describe("parseSubprotocols", () => {
|
|
12
|
+
test("splits, trims, drops empty tokens, keeps order", () => {
|
|
13
|
+
expect(parseSubprotocols("console, ticket-abc")).toEqual(["console", "ticket-abc"]);
|
|
14
|
+
expect(parseSubprotocols(" a ,, b ,")).toEqual(["a", "b"]);
|
|
15
|
+
expect(parseSubprotocols(null)).toEqual([]);
|
|
16
|
+
expect(parseSubprotocols("")).toEqual([]);
|
|
17
|
+
});
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
describe("upstreamHandshakeHeaders", () => {
|
|
21
|
+
test("forwards cookies and origin, drops the client's handshake fields, stamps provenance", () => {
|
|
22
|
+
const req = new Headers({
|
|
23
|
+
host: "api.prod.specific.dev",
|
|
24
|
+
connection: "Upgrade",
|
|
25
|
+
upgrade: "websocket",
|
|
26
|
+
"sec-websocket-key": "abc==",
|
|
27
|
+
"sec-websocket-version": "13",
|
|
28
|
+
"sec-websocket-protocol": "console, ticket",
|
|
29
|
+
"sec-websocket-extensions": "permessage-deflate",
|
|
30
|
+
cookie: "sid=1",
|
|
31
|
+
origin: "https://app.test",
|
|
32
|
+
authorization: "Bearer t",
|
|
33
|
+
});
|
|
34
|
+
const out = upstreamHandshakeHeaders(req, {
|
|
35
|
+
service: "api",
|
|
36
|
+
port: 4000,
|
|
37
|
+
proto: "https",
|
|
38
|
+
clientIp: "10.0.0.7",
|
|
39
|
+
});
|
|
40
|
+
expect(out["cookie"]).toBe("sid=1");
|
|
41
|
+
expect(out["origin"]).toBe("https://app.test");
|
|
42
|
+
expect(out["authorization"]).toBe("Bearer t");
|
|
43
|
+
expect(out["host"]).toBe("api:4000");
|
|
44
|
+
expect(out["x-forwarded-host"]).toBe("api.prod.specific.dev");
|
|
45
|
+
expect(out["x-forwarded-proto"]).toBe("https");
|
|
46
|
+
expect(out["x-forwarded-for"]).toBe("10.0.0.7");
|
|
47
|
+
for (const k of Object.keys(out)) {
|
|
48
|
+
expect(k.startsWith("sec-websocket-")).toBe(false);
|
|
49
|
+
expect(["connection", "upgrade"]).not.toContain(k);
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("appends the client to an existing x-forwarded-for", () => {
|
|
54
|
+
const out = upstreamHandshakeHeaders(new Headers({ "x-forwarded-for": "1.2.3.4" }), {
|
|
55
|
+
service: "api",
|
|
56
|
+
port: 1,
|
|
57
|
+
proto: "http",
|
|
58
|
+
clientIp: "10.0.0.7",
|
|
59
|
+
});
|
|
60
|
+
expect(out["x-forwarded-for"]).toBe("1.2.3.4, 10.0.0.7");
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* A loopback copy of the daemon's arrangement: an upstream that admits a
|
|
66
|
+
* connection only when the SECOND subprotocol is the ticket (the shape of
|
|
67
|
+
* Specific's console API), a bridge listener in front of it that does what
|
|
68
|
+
* `proxyToService` does, and a client through the bridge.
|
|
69
|
+
*/
|
|
70
|
+
describe("the bridge on loopback", () => {
|
|
71
|
+
let upstream: ReturnType<typeof Bun.serve>;
|
|
72
|
+
let bridge: ReturnType<typeof Bun.serve>;
|
|
73
|
+
/** What the upstream saw on its last handshake. */
|
|
74
|
+
let seen: { protocols: string[]; cookie: string | null; xfh: string | null } | null = null;
|
|
75
|
+
|
|
76
|
+
beforeAll(() => {
|
|
77
|
+
upstream = Bun.serve({
|
|
78
|
+
port: 0,
|
|
79
|
+
fetch(req, server) {
|
|
80
|
+
const protocols = parseSubprotocols(req.headers.get("sec-websocket-protocol"));
|
|
81
|
+
seen = {
|
|
82
|
+
protocols,
|
|
83
|
+
cookie: req.headers.get("cookie"),
|
|
84
|
+
xfh: req.headers.get("x-forwarded-host"),
|
|
85
|
+
};
|
|
86
|
+
if (protocols[1] !== "ticket-ok") return new Response("Unauthorized", { status: 401 });
|
|
87
|
+
const ok = server.upgrade(req, {
|
|
88
|
+
headers: { "sec-websocket-protocol": protocols[0] },
|
|
89
|
+
data: { admitted: protocols[1] },
|
|
90
|
+
});
|
|
91
|
+
return ok ? undefined : new Response("refused", { status: 426 });
|
|
92
|
+
},
|
|
93
|
+
websocket: {
|
|
94
|
+
open(ws) {
|
|
95
|
+
ws.send("ready");
|
|
96
|
+
},
|
|
97
|
+
message(ws, m) {
|
|
98
|
+
if (typeof m === "string") {
|
|
99
|
+
if (m === "close-me") ws.close(4402, "app said so");
|
|
100
|
+
else ws.send(`echo:${m}`);
|
|
101
|
+
} else {
|
|
102
|
+
ws.send(new Uint8Array(m).reverse());
|
|
103
|
+
}
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
bridge = Bun.serve({
|
|
109
|
+
port: 0,
|
|
110
|
+
async fetch(req, server) {
|
|
111
|
+
const protocols = parseSubprotocols(req.headers.get("sec-websocket-protocol"));
|
|
112
|
+
const headers = upstreamHandshakeHeaders(req.headers, {
|
|
113
|
+
service: "api",
|
|
114
|
+
port: upstream.port!,
|
|
115
|
+
proto: "https",
|
|
116
|
+
});
|
|
117
|
+
const url = new URL(req.url);
|
|
118
|
+
const opened = await openUpstreamWebSocket(
|
|
119
|
+
`ws://127.0.0.1:${upstream.port}${url.pathname}${url.search}`,
|
|
120
|
+
{ protocols, headers },
|
|
121
|
+
);
|
|
122
|
+
if (!opened.ok) return new Response(opened.reason, { status: 502 });
|
|
123
|
+
const data: WsBridgeData = { upstreamUrl: "", upstream: opened.socket };
|
|
124
|
+
const ok = server.upgrade(req, {
|
|
125
|
+
headers: opened.socket.protocol
|
|
126
|
+
? { "sec-websocket-protocol": opened.socket.protocol }
|
|
127
|
+
: undefined,
|
|
128
|
+
data,
|
|
129
|
+
});
|
|
130
|
+
if (!ok) {
|
|
131
|
+
opened.socket.close();
|
|
132
|
+
return new Response("refused", { status: 426 });
|
|
133
|
+
}
|
|
134
|
+
return undefined;
|
|
135
|
+
},
|
|
136
|
+
websocket: WS_BRIDGE_HANDLERS,
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
afterAll(() => {
|
|
141
|
+
upstream.stop(true);
|
|
142
|
+
bridge.stop(true);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
function connect(protocols: string[]): Promise<{
|
|
146
|
+
ws: WebSocket;
|
|
147
|
+
events: Array<{ kind: "open" | "message" | "close" | "error"; value?: unknown }>;
|
|
148
|
+
done: Promise<void>;
|
|
149
|
+
}> {
|
|
150
|
+
const Ctor = WebSocket as unknown as new (
|
|
151
|
+
url: string,
|
|
152
|
+
opts: { protocols?: string[]; headers?: Record<string, string> },
|
|
153
|
+
) => WebSocket;
|
|
154
|
+
const ws = new Ctor(`ws://127.0.0.1:${bridge.port}/console?x=1`, {
|
|
155
|
+
protocols,
|
|
156
|
+
headers: { cookie: "sid=abc", host: "api.prod.specific.dev" },
|
|
157
|
+
});
|
|
158
|
+
ws.binaryType = "arraybuffer";
|
|
159
|
+
const events: Array<{ kind: "open" | "message" | "close" | "error"; value?: unknown }> = [];
|
|
160
|
+
const done = new Promise<void>((resolve) => {
|
|
161
|
+
ws.addEventListener("close", (ev) => {
|
|
162
|
+
events.push({ kind: "close", value: { code: ev.code, reason: ev.reason } });
|
|
163
|
+
resolve();
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
ws.addEventListener("error", (ev) => events.push({ kind: "error", value: (ev as { message?: string }).message }));
|
|
167
|
+
ws.addEventListener("message", (ev) => events.push({ kind: "message", value: ev.data }));
|
|
168
|
+
return new Promise((resolve, reject) => {
|
|
169
|
+
ws.addEventListener("open", () => {
|
|
170
|
+
events.push({ kind: "open" });
|
|
171
|
+
resolve({ ws, events, done });
|
|
172
|
+
});
|
|
173
|
+
done.then(() => reject(new Error(`closed before open: ${JSON.stringify(events)}`)));
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
test("the full protocol list and the headers reach the upstream; the selected protocol comes back", async () => {
|
|
178
|
+
const { ws, events, done } = await connect(["console", "ticket-ok"]);
|
|
179
|
+
expect(ws.protocol).toBe("console");
|
|
180
|
+
expect(seen).toEqual({
|
|
181
|
+
protocols: ["console", "ticket-ok"],
|
|
182
|
+
cookie: "sid=abc",
|
|
183
|
+
xfh: "api.prod.specific.dev",
|
|
184
|
+
});
|
|
185
|
+
await Bun.sleep(20);
|
|
186
|
+
ws.send("hi");
|
|
187
|
+
ws.send(new Uint8Array([1, 2, 3]));
|
|
188
|
+
await Bun.sleep(50);
|
|
189
|
+
ws.close(1000, "bye");
|
|
190
|
+
await done;
|
|
191
|
+
const messages = events.filter((e) => e.kind === "message").map((e) => e.value);
|
|
192
|
+
expect(messages[0]).toBe("ready");
|
|
193
|
+
expect(messages[1]).toBe("echo:hi");
|
|
194
|
+
expect(Array.from(new Uint8Array(messages[2] as ArrayBuffer))).toEqual([3, 2, 1]);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test("an upstream close code after the handshake is relayed to the client", async () => {
|
|
198
|
+
const { ws, events, done } = await connect(["console", "ticket-ok"]);
|
|
199
|
+
await Bun.sleep(20);
|
|
200
|
+
ws.send("close-me");
|
|
201
|
+
await done;
|
|
202
|
+
const close = events.find((e) => e.kind === "close")?.value as { code: number; reason: string };
|
|
203
|
+
expect(close.code).toBe(4402);
|
|
204
|
+
expect(close.reason).toBe("app said so");
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test("an upstream that refuses the handshake never gives the client a 101", async () => {
|
|
208
|
+
// A plain fetch with the upgrade headers shows the status the bridge
|
|
209
|
+
// answers with; a WebSocket client only reports a failed handshake.
|
|
210
|
+
const res = await fetch(`http://127.0.0.1:${bridge.port}/console`, {
|
|
211
|
+
headers: {
|
|
212
|
+
connection: "Upgrade",
|
|
213
|
+
upgrade: "websocket",
|
|
214
|
+
"sec-websocket-version": "13",
|
|
215
|
+
"sec-websocket-key": "dGhlIHNhbXBsZSBub25jZQ==",
|
|
216
|
+
"sec-websocket-protocol": "console, ticket-bad",
|
|
217
|
+
},
|
|
218
|
+
});
|
|
219
|
+
expect(res.status).toBe(502);
|
|
220
|
+
expect(await res.text()).toContain("upstream handshake failed");
|
|
221
|
+
expect(seen?.protocols).toEqual(["console", "ticket-bad"]);
|
|
222
|
+
await expect(connect(["console", "ticket-bad"])).rejects.toThrow(/closed before open/);
|
|
223
|
+
});
|
|
224
|
+
});
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ingress WebSocket bridge: one client WebSocket on an ingress
|
|
3
|
+
* listener, one upstream WebSocket to the service, frames copied in both
|
|
4
|
+
* directions.
|
|
5
|
+
*
|
|
6
|
+
* The handshake is the part that matters. A client that sends
|
|
7
|
+
* `Sec-WebSocket-Protocol: console, <ticket>` expects the upstream to see
|
|
8
|
+
* the full list, and expects to get back the one the upstream selected.
|
|
9
|
+
* The first version of this bridge opened the upstream with no protocols
|
|
10
|
+
* and no headers, so a ticket or a cookie that rode on the handshake never
|
|
11
|
+
* reached the service, and the service refused the connection after the
|
|
12
|
+
* client already saw a 101. So the bridge now opens the upstream FIRST,
|
|
13
|
+
* with the client's protocol list and forwardable headers, waits for the
|
|
14
|
+
* upstream handshake, and only then upgrades the client with the protocol
|
|
15
|
+
* the upstream selected. An upstream that refuses the handshake becomes a
|
|
16
|
+
* 502 to the client, the same as the HTTP proxy path.
|
|
17
|
+
*
|
|
18
|
+
* `daemon.ts` owns the listener and the route lookup; this module owns
|
|
19
|
+
* the handshake and the frame relay so the two can be tested on loopback
|
|
20
|
+
* without a VM.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { forwardableHeaders } from "./http-proxy.js";
|
|
24
|
+
|
|
25
|
+
/** Headers that belong to the client's own WebSocket handshake. Bun's
|
|
26
|
+
* WebSocket client mints its own key and version, and the protocol list
|
|
27
|
+
* goes through the constructor option, so these never travel as plain
|
|
28
|
+
* headers. */
|
|
29
|
+
const HANDSHAKE_HEADERS: ReadonlySet<string> = new Set([
|
|
30
|
+
"sec-websocket-key",
|
|
31
|
+
"sec-websocket-version",
|
|
32
|
+
"sec-websocket-protocol",
|
|
33
|
+
"sec-websocket-extensions",
|
|
34
|
+
"sec-websocket-accept",
|
|
35
|
+
]);
|
|
36
|
+
|
|
37
|
+
/** Per-bridge context stored on `ws.data` of the client socket. */
|
|
38
|
+
export interface WsBridgeData {
|
|
39
|
+
upstreamUrl: string;
|
|
40
|
+
/** Open by the time the client socket exists: the handshake awaited it. */
|
|
41
|
+
upstream: WebSocket;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** `Sec-WebSocket-Protocol` is a comma-separated list; each token is
|
|
45
|
+
* trimmed, empty tokens are dropped, order is kept (RFC 6455 §4.1 lets
|
|
46
|
+
* the server pick from the list, so order is the client's preference). */
|
|
47
|
+
export function parseSubprotocols(header: string | null | undefined): string[] {
|
|
48
|
+
if (!header) return [];
|
|
49
|
+
return header
|
|
50
|
+
.split(",")
|
|
51
|
+
.map((s) => s.trim())
|
|
52
|
+
.filter((s) => s.length > 0);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The headers the upstream handshake carries: everything forwardable from
|
|
57
|
+
* the client's request (cookies, authorization, origin, user-agent …),
|
|
58
|
+
* minus the client's own handshake fields, plus the proxy provenance
|
|
59
|
+
* headers and a Host that names the service.
|
|
60
|
+
*/
|
|
61
|
+
export function upstreamHandshakeHeaders(
|
|
62
|
+
req: Headers,
|
|
63
|
+
opts: {
|
|
64
|
+
service: string;
|
|
65
|
+
port: number;
|
|
66
|
+
proto: string;
|
|
67
|
+
clientIp?: string;
|
|
68
|
+
},
|
|
69
|
+
): Record<string, string> {
|
|
70
|
+
const out: Record<string, string> = {};
|
|
71
|
+
forwardableHeaders(req).forEach((value, key) => {
|
|
72
|
+
if (HANDSHAKE_HEADERS.has(key)) return;
|
|
73
|
+
out[key] = value;
|
|
74
|
+
});
|
|
75
|
+
const priorXff = req.get("x-forwarded-for");
|
|
76
|
+
const xff = opts.clientIp
|
|
77
|
+
? priorXff
|
|
78
|
+
? `${priorXff}, ${opts.clientIp}`
|
|
79
|
+
: opts.clientIp
|
|
80
|
+
: priorXff;
|
|
81
|
+
if (xff) out["x-forwarded-for"] = xff;
|
|
82
|
+
out["x-forwarded-proto"] = opts.proto;
|
|
83
|
+
const publicHost = req.get("host");
|
|
84
|
+
if (publicHost) out["x-forwarded-host"] = publicHost;
|
|
85
|
+
out["host"] = `${opts.service}:${opts.port}`;
|
|
86
|
+
return out;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Bun's WebSocket constructor takes an options object with `headers`;
|
|
90
|
+
* the bundled types only declare the WHATWG `protocols` form. */
|
|
91
|
+
type BunWebSocketCtor = new (
|
|
92
|
+
url: string,
|
|
93
|
+
opts: { protocols?: string[]; headers?: Record<string, string> },
|
|
94
|
+
) => WebSocket;
|
|
95
|
+
|
|
96
|
+
export type UpstreamOpen =
|
|
97
|
+
| { ok: true; socket: WebSocket }
|
|
98
|
+
| { ok: false; reason: string };
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Open the upstream WebSocket and wait for its handshake. Resolves once
|
|
102
|
+
* the socket is open, or once it failed; the handshake listeners are
|
|
103
|
+
* removed either way so a later close does not fire them again.
|
|
104
|
+
*/
|
|
105
|
+
export function openUpstreamWebSocket(
|
|
106
|
+
url: string,
|
|
107
|
+
opts: { protocols: string[]; headers: Record<string, string>; timeoutMs?: number },
|
|
108
|
+
): Promise<UpstreamOpen> {
|
|
109
|
+
const Ctor = WebSocket as unknown as BunWebSocketCtor;
|
|
110
|
+
let socket: WebSocket;
|
|
111
|
+
try {
|
|
112
|
+
socket = new Ctor(url, { protocols: opts.protocols, headers: opts.headers });
|
|
113
|
+
} catch (err) {
|
|
114
|
+
return Promise.resolve({ ok: false, reason: String(err) });
|
|
115
|
+
}
|
|
116
|
+
// ArrayBuffer so binary frames can be sent to the client verbatim.
|
|
117
|
+
socket.binaryType = "arraybuffer";
|
|
118
|
+
return new Promise<UpstreamOpen>((resolve) => {
|
|
119
|
+
let settled = false;
|
|
120
|
+
const timeoutMs = opts.timeoutMs ?? 30_000;
|
|
121
|
+
const timer = setTimeout(() => finish({ ok: false, reason: "upstream handshake timed out" }), timeoutMs);
|
|
122
|
+
const onOpen = (): void => finish({ ok: true, socket });
|
|
123
|
+
const onError = (ev: Event): void => {
|
|
124
|
+
const msg = (ev as { message?: string }).message;
|
|
125
|
+
finish({ ok: false, reason: msg ? `upstream handshake failed: ${msg}` : "upstream handshake failed" });
|
|
126
|
+
};
|
|
127
|
+
const onClose = (ev: CloseEvent): void =>
|
|
128
|
+
finish({ ok: false, reason: `upstream closed during handshake (${ev.code}${ev.reason ? ` ${ev.reason}` : ""})` });
|
|
129
|
+
const finish = (result: UpstreamOpen): void => {
|
|
130
|
+
if (settled) return;
|
|
131
|
+
settled = true;
|
|
132
|
+
clearTimeout(timer);
|
|
133
|
+
socket.removeEventListener("open", onOpen);
|
|
134
|
+
socket.removeEventListener("error", onError);
|
|
135
|
+
socket.removeEventListener("close", onClose);
|
|
136
|
+
if (!result.ok) {
|
|
137
|
+
try {
|
|
138
|
+
socket.close();
|
|
139
|
+
} catch {
|
|
140
|
+
/* already closed */
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
resolve(result);
|
|
144
|
+
};
|
|
145
|
+
socket.addEventListener("open", onOpen);
|
|
146
|
+
socket.addEventListener("error", onError);
|
|
147
|
+
socket.addEventListener("close", onClose);
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** The minimum of Bun's ServerWebSocket that the relay uses. */
|
|
152
|
+
interface ClientSocket {
|
|
153
|
+
data: WsBridgeData;
|
|
154
|
+
send(data: string | ArrayBuffer | Uint8Array): unknown;
|
|
155
|
+
close(code?: number, reason?: string): unknown;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* The `websocket` handler set for `Bun.serve`. The upstream is already
|
|
160
|
+
* open when `open()` runs, so frames go straight through in both
|
|
161
|
+
* directions, and a close on either side closes the other with the same
|
|
162
|
+
* code and reason.
|
|
163
|
+
*/
|
|
164
|
+
export const WS_BRIDGE_HANDLERS = {
|
|
165
|
+
open(ws: ClientSocket): void {
|
|
166
|
+
const { upstream } = ws.data;
|
|
167
|
+
upstream.addEventListener("message", (ev: MessageEvent) => {
|
|
168
|
+
try {
|
|
169
|
+
ws.send(ev.data);
|
|
170
|
+
} catch {
|
|
171
|
+
/* client gone */
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
upstream.addEventListener("close", (ev: CloseEvent) => {
|
|
175
|
+
try {
|
|
176
|
+
ws.close(ev.code, ev.reason);
|
|
177
|
+
} catch {
|
|
178
|
+
/* already closed */
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
upstream.addEventListener("error", () => {
|
|
182
|
+
try {
|
|
183
|
+
ws.close(1011, "upstream error");
|
|
184
|
+
} catch {
|
|
185
|
+
/* already closed */
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
// The upstream can have closed between the handshake and this
|
|
189
|
+
// callback; the close listener above was not attached yet then.
|
|
190
|
+
if (upstream.readyState !== WebSocket.OPEN) {
|
|
191
|
+
try {
|
|
192
|
+
ws.close(1011, "upstream closed");
|
|
193
|
+
} catch {
|
|
194
|
+
/* already closed */
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
},
|
|
198
|
+
message(ws: ClientSocket, message: string | Buffer): void {
|
|
199
|
+
const { upstream } = ws.data;
|
|
200
|
+
const payload = typeof message === "string" ? message : new Uint8Array(message);
|
|
201
|
+
if (upstream.readyState === WebSocket.OPEN) upstream.send(payload);
|
|
202
|
+
},
|
|
203
|
+
close(ws: ClientSocket, code: number, reason: string): void {
|
|
204
|
+
try {
|
|
205
|
+
ws.data.upstream.close(code, reason);
|
|
206
|
+
} catch {
|
|
207
|
+
/* ignore */
|
|
208
|
+
}
|
|
209
|
+
},
|
|
210
|
+
};
|