@specific.dev/spectest 0.101.0 → 0.102.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/dist/daemon.js +40 -72
- package/dist/harness/ingress-server.d.ts +26 -0
- package/dist/harness/ingress-server.js +166 -0
- package/dist/harness/tls-terminator.js +5 -9
- package/package.json +4 -2
- package/src/daemon.ts +42 -84
- package/src/harness/ingress-cors.test.ts +3 -2
- package/src/harness/ingress-server.test.ts +250 -0
- package/src/harness/ingress-server.ts +175 -0
- package/src/harness/tls-terminator.test.ts +2 -2
- package/src/harness/tls-terminator.ts +5 -9
- package/src/harness/ws-bridge.test.ts +4 -5
package/dist/daemon.js
CHANGED
|
@@ -57,9 +57,10 @@ import { bindInstrumentationScope, createInstrumentationScope, runInstrumented,
|
|
|
57
57
|
import { encodeRegistry } from "./harness/names-registry.js";
|
|
58
58
|
import { InterceptRegistry, parseTarget, runChain, } from "./harness/intercept.js";
|
|
59
59
|
import { HOP_BY_HOP_HEADERS, withIngressCors, upstreamRequestHeaders, } from "./harness/http-proxy.js";
|
|
60
|
-
import {
|
|
60
|
+
import { openUpstreamWebSocket, parseSubprotocols, upstreamHandshakeHeaders, } from "./harness/ws-bridge.js";
|
|
61
61
|
import { certCovers as hostmatchCertCovers, hostWithoutPort, matchRoute, selectCertName, wildcardSuffix, } from "./harness/hostmatch.js";
|
|
62
62
|
import { INGRESS_HTTPS_PORT, INGRESS_HTTP_PORT, bindRoute, clearTables, emptyTables, planBind, registryTarget, routesFor, unbindRoute, } from "./harness/ingress-table.js";
|
|
63
|
+
import { startIngressServer } from "./harness/ingress-server.js";
|
|
63
64
|
import { startHttp2Proxy } from "./harness/http2-proxy.js";
|
|
64
65
|
import { startTlsTerminator } from "./harness/tls-terminator.js";
|
|
65
66
|
import { runContainerArgs } from "./harness/container-run.js";
|
|
@@ -1495,11 +1496,10 @@ let LOWERED = {
|
|
|
1495
1496
|
aliasesByService: {},
|
|
1496
1497
|
wildcards: [],
|
|
1497
1498
|
};
|
|
1498
|
-
/** Running HTTP servers per port
|
|
1499
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1499
|
+
/** Running HTTP servers per port. Rebuilt on /load. */
|
|
1500
1500
|
const INGRESS_HTTP_SERVERS = new Map();
|
|
1501
|
+
const INGRESS_HTTP_STARTING = new Map();
|
|
1501
1502
|
/** Running HTTPS servers per port (currently always {INGRESS_HTTPS_PORT}). */
|
|
1502
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1503
1503
|
const INGRESS_HTTPS_SERVERS = new Map();
|
|
1504
1504
|
/**
|
|
1505
1505
|
* The :443 TLS terminator, once per harness process.
|
|
@@ -1564,6 +1564,7 @@ function stopIngressServers() {
|
|
|
1564
1564
|
}
|
|
1565
1565
|
}
|
|
1566
1566
|
INGRESS_HTTP_SERVERS.clear();
|
|
1567
|
+
INGRESS_HTTP_STARTING.clear();
|
|
1567
1568
|
for (const [port, srv] of INGRESS_HTTPS_SERVERS) {
|
|
1568
1569
|
try {
|
|
1569
1570
|
srv.stop?.(true);
|
|
@@ -1691,7 +1692,7 @@ async function startIngress() {
|
|
|
1691
1692
|
// eslint-disable-next-line no-console
|
|
1692
1693
|
console.warn(`[ingress] root CA missing at ${CA_PATH}; skipping HTTPS bind (fakes/proxies will be HTTP-only)`);
|
|
1693
1694
|
}
|
|
1694
|
-
|
|
1695
|
+
requireBun();
|
|
1695
1696
|
// Resolve every ingress hostname to its handler into the live per-port
|
|
1696
1697
|
// route tables (module scope, so runtime `tls` can extend them later).
|
|
1697
1698
|
// Fakes run an in-daemon handler on their declared port; proxies
|
|
@@ -1732,13 +1733,13 @@ async function startIngress() {
|
|
|
1732
1733
|
for (const [port, byHost] of INGRESS.routesByPort) {
|
|
1733
1734
|
if (port === INGRESS_HTTPS_PORT)
|
|
1734
1735
|
continue;
|
|
1735
|
-
|
|
1736
|
+
await ensureHttpIngress(port, byHost);
|
|
1736
1737
|
// eslint-disable-next-line no-console
|
|
1737
1738
|
console.log(`[ingress] http :${port} for ${[...byHost.keys()].join(", ")}`);
|
|
1738
1739
|
}
|
|
1739
1740
|
// ── HTTPS listener on INGRESS_HTTPS_PORT: SNI per certificated hostname.
|
|
1740
1741
|
if (INGRESS.certByHost.size > 0)
|
|
1741
|
-
await ensureHttpsIngress(
|
|
1742
|
+
await ensureHttpsIngress();
|
|
1742
1743
|
// Seed the resolver's names registry: ingress hostnames (fakes, TLS
|
|
1743
1744
|
// proxies, dnsName(→ingress)) → bridge gateway, plus ingress-targeted
|
|
1744
1745
|
// wildcards. Service-targeted wildcards wait for the post-container pass
|
|
@@ -1750,17 +1751,17 @@ function requireBun() {
|
|
|
1750
1751
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1751
1752
|
const Bun = globalThis.Bun;
|
|
1752
1753
|
if (!Bun?.serve) {
|
|
1753
|
-
throw new Error("ingress requires Bun
|
|
1754
|
+
throw new Error("ingress requires Bun; the daemon must run under Bun (it does in-VM)");
|
|
1754
1755
|
}
|
|
1755
1756
|
return Bun;
|
|
1756
1757
|
}
|
|
1757
1758
|
/**
|
|
1758
1759
|
* Bring :443 up, once.
|
|
1759
1760
|
*
|
|
1760
|
-
* Two pieces: a plaintext
|
|
1761
|
+
* Two pieces: a plaintext HTTP server on an ephemeral loopback port that
|
|
1761
1762
|
* carries the HTTPS route table (and therefore all the routing, fake
|
|
1762
|
-
* dispatch, interception, reverse-proxying and
|
|
1763
|
-
*
|
|
1763
|
+
* dispatch, interception, reverse-proxying and WebSocket
|
|
1764
|
+
* bridging that :80 already gets, unchanged), and the TLS
|
|
1764
1765
|
* terminator in front of it on :443.
|
|
1765
1766
|
*
|
|
1766
1767
|
* **This is idempotent, and that is the feature.** Adding a certificate
|
|
@@ -1781,27 +1782,25 @@ function requireBun() {
|
|
|
1781
1782
|
* container can reach (containers arrive at the bridge gateway), so the
|
|
1782
1783
|
* only way in is through the terminator.
|
|
1783
1784
|
*/
|
|
1784
|
-
|
|
1785
|
-
async function ensureHttpsIngress(Bun) {
|
|
1785
|
+
async function ensureHttpsIngress() {
|
|
1786
1786
|
if (INGRESS_HTTPS_SERVERS.has(INGRESS_HTTPS_PORT))
|
|
1787
1787
|
return;
|
|
1788
1788
|
if (!HTTPS_INGRESS_STARTING) {
|
|
1789
1789
|
// Cleared on both outcomes: once bound, the check above short-circuits
|
|
1790
1790
|
// every later caller, and a failed bind must be retryable rather than
|
|
1791
1791
|
// remembered as a rejection for the life of the project.
|
|
1792
|
-
HTTPS_INGRESS_STARTING = startHttpsIngress(
|
|
1792
|
+
HTTPS_INGRESS_STARTING = startHttpsIngress().finally(() => {
|
|
1793
1793
|
HTTPS_INGRESS_STARTING = undefined;
|
|
1794
1794
|
});
|
|
1795
1795
|
}
|
|
1796
1796
|
return HTTPS_INGRESS_STARTING;
|
|
1797
1797
|
}
|
|
1798
|
-
|
|
1799
|
-
async function startHttpsIngress(Bun) {
|
|
1798
|
+
async function startHttpsIngress() {
|
|
1800
1799
|
const routes = routesFor(INGRESS, INGRESS_HTTPS_PORT);
|
|
1801
1800
|
// `proto` is passed rather than inferred: this listener speaks plain
|
|
1802
1801
|
// HTTP, but everything reaching it arrived over TLS, so X-Forwarded-Proto
|
|
1803
1802
|
// must say https.
|
|
1804
|
-
const plain = bindIngressServer(
|
|
1803
|
+
const plain = await bindIngressServer(0, routes, `https :${INGRESS_HTTPS_PORT}`, {
|
|
1805
1804
|
proto: "https",
|
|
1806
1805
|
hostname: "127.0.0.1",
|
|
1807
1806
|
});
|
|
@@ -1884,7 +1883,7 @@ async function bindRuntimeTls(hostname, service, port, http2, cors) {
|
|
|
1884
1883
|
if (!existsSync(CA_PATH) || !existsSync(CA_KEY_PATH)) {
|
|
1885
1884
|
throw new Error(`runtime tls for ${JSON.stringify(host)} requires the in-VM root CA at ${CA_PATH}`);
|
|
1886
1885
|
}
|
|
1887
|
-
|
|
1886
|
+
requireBun();
|
|
1888
1887
|
const route = { kind: "proxy", service, port, http2, cors };
|
|
1889
1888
|
// Decide the cost before mutating anything: an already-covered hostname
|
|
1890
1889
|
// (an exact duplicate, or one under a boot wildcard) needs only a route.
|
|
@@ -1899,9 +1898,7 @@ async function bindRuntimeTls(hostname, service, port, http2, cors) {
|
|
|
1899
1898
|
// the route is only reachable once the hostname resolves, which is the
|
|
1900
1899
|
// registry write at the end.
|
|
1901
1900
|
bindRoute(INGRESS, host, route);
|
|
1902
|
-
|
|
1903
|
-
INGRESS_HTTP_SERVERS.set(INGRESS_HTTP_PORT, bindIngressServer(Bun, INGRESS_HTTP_PORT, routesFor(INGRESS, INGRESS_HTTP_PORT), `port ${INGRESS_HTTP_PORT}`));
|
|
1904
|
-
}
|
|
1901
|
+
await ensureHttpIngress(INGRESS_HTTP_PORT, routesFor(INGRESS, INGRESS_HTTP_PORT));
|
|
1905
1902
|
if (plan.needsCert) {
|
|
1906
1903
|
// The terminator reads this table on every handshake, so the leaf is
|
|
1907
1904
|
// live the moment it lands — no listener is touched, and no connection
|
|
@@ -1909,7 +1906,7 @@ async function bindRuntimeTls(hostname, service, port, http2, cors) {
|
|
|
1909
1906
|
INGRESS.certByHost.set(host, await generateHostCert(host, [host]));
|
|
1910
1907
|
}
|
|
1911
1908
|
if (plan.needsHttpsListener)
|
|
1912
|
-
await ensureHttpsIngress(
|
|
1909
|
+
await ensureHttpsIngress();
|
|
1913
1910
|
// Resolve the hostname to the daemon gateway (where :443/:80 listen).
|
|
1914
1911
|
// A wildcard can only live in the resolver's suffix table.
|
|
1915
1912
|
const gw = await bridgeGatewayIp();
|
|
@@ -1946,57 +1943,27 @@ async function unbindRuntimeTls(hostname) {
|
|
|
1946
1943
|
await writeRegistry();
|
|
1947
1944
|
}
|
|
1948
1945
|
}
|
|
1949
|
-
/**
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
// redirect depend on.
|
|
1966
|
-
serve = { proto: "http" }) {
|
|
1967
|
-
const proto = serve.proto;
|
|
1968
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1969
|
-
const opts = {
|
|
1946
|
+
/** Concurrent runtime routes must share the asynchronous first bind. */
|
|
1947
|
+
async function ensureHttpIngress(port, byHost) {
|
|
1948
|
+
if (INGRESS_HTTP_SERVERS.has(port))
|
|
1949
|
+
return;
|
|
1950
|
+
let starting = INGRESS_HTTP_STARTING.get(port);
|
|
1951
|
+
if (!starting) {
|
|
1952
|
+
starting = bindIngressServer(port, byHost, `port ${port}`).then(server => {
|
|
1953
|
+
INGRESS_HTTP_SERVERS.set(port, server);
|
|
1954
|
+
}).finally(() => INGRESS_HTTP_STARTING.delete(port));
|
|
1955
|
+
INGRESS_HTTP_STARTING.set(port, starting);
|
|
1956
|
+
}
|
|
1957
|
+
await starting;
|
|
1958
|
+
}
|
|
1959
|
+
/** Bind the shared HTTP/1 ingress handler, including the listener behind TLS. */
|
|
1960
|
+
async function bindIngressServer(port, byHost, listenerLabel, serve = { proto: "http" }) {
|
|
1961
|
+
return startIngressServer({
|
|
1970
1962
|
port,
|
|
1971
1963
|
hostname: serve.hostname ?? "0.0.0.0",
|
|
1972
|
-
// SO_REUSEPORT on every ingress listener. It was introduced so a
|
|
1973
|
-
// replacement :443 could overlap the original during a certificate
|
|
1974
|
-
// swap; the terminator removed that swap, and this stays for the
|
|
1975
|
-
// remaining case — the `/load` teardown force-closes and rebinds, and
|
|
1976
|
-
// a socket the kernel has not finished releasing would otherwise fail
|
|
1977
|
-
// the next bind with "Is port 443 in use?". Note the flip side, which
|
|
1978
|
-
// {@link stopIngressServers} depends on: a half-alive listener shares
|
|
1979
|
-
// the port silently instead of colliding loudly.
|
|
1980
1964
|
reusePort: true,
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
maxRequestBodySize: 1024 ** 4,
|
|
1984
|
-
// Bun.serve defaults to a 10s idleTimeout, which kills any proxied
|
|
1985
|
-
// request whose upstream takes >10s to produce bytes — under parallel
|
|
1986
|
-
// test load that surfaced as "fetch failed"/"other side closed" on
|
|
1987
|
-
// deploy-archive uploads and ERR_EMPTY_RESPONSE in browser tests
|
|
1988
|
-
// ([Bun.serve]: request timed out after 10 seconds). Ingress fronts
|
|
1989
|
-
// arbitrarily slow app endpoints (deploys can legitimately take
|
|
1990
|
-
// minutes), so disable the idle timeout entirely; forked test VMs are
|
|
1991
|
-
// short-lived, leaked-connection risk is bounded by the fork.
|
|
1992
|
-
idleTimeout: 0,
|
|
1993
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1994
|
-
fetch: (req, server) => dispatchIngress(req, server, byHost, listenerLabel, proto),
|
|
1995
|
-
// The frame relay for bridged WebSocket upgrades; `proxyToService`
|
|
1996
|
-
// opens the upstream before it upgrades the client (harness/ws-bridge.ts).
|
|
1997
|
-
websocket: WS_BRIDGE_HANDLERS,
|
|
1998
|
-
};
|
|
1999
|
-
return Bun.serve(opts);
|
|
1965
|
+
fetch: (req, server) => dispatchIngress(req, server, byHost, listenerLabel, serve.proto),
|
|
1966
|
+
});
|
|
2000
1967
|
}
|
|
2001
1968
|
/**
|
|
2002
1969
|
* Resolve a Host header to a route: an exact entry first, then the longest
|
|
@@ -2128,7 +2095,7 @@ server, service, port, listenerLabel, proto) {
|
|
|
2128
2095
|
headers: selected ? { "sec-websocket-protocol": selected } : undefined,
|
|
2129
2096
|
});
|
|
2130
2097
|
if (ok) {
|
|
2131
|
-
//
|
|
2098
|
+
// The WebSocket has already taken over the response — return a stub.
|
|
2132
2099
|
return new Response(null, { status: 101 });
|
|
2133
2100
|
}
|
|
2134
2101
|
try {
|
|
@@ -2189,6 +2156,7 @@ server, service, port, listenerLabel, proto) {
|
|
|
2189
2156
|
headers: fwdHeaders,
|
|
2190
2157
|
body: hasBody ? (bufferedBody ?? req.body) : undefined,
|
|
2191
2158
|
redirect: "manual",
|
|
2159
|
+
signal: req.signal,
|
|
2192
2160
|
});
|
|
2193
2161
|
// decompress:false → forward the encoded body untouched (see fn doc).
|
|
2194
2162
|
// keepalive:false → fresh connection per request (see fwdHeaders above).
|
|
@@ -2198,7 +2166,7 @@ server, service, port, listenerLabel, proto) {
|
|
|
2198
2166
|
keepalive: false,
|
|
2199
2167
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
2200
2168
|
});
|
|
2201
|
-
// Strip
|
|
2169
|
+
// Strip connection framing while preserving the unchanged body's length.
|
|
2202
2170
|
const respHeaders = new Headers();
|
|
2203
2171
|
for (const [k, v] of upstreamRes.headers) {
|
|
2204
2172
|
if (HOP_BY_HOP_HEADERS.has(k.toLowerCase()))
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { type WsBridgeData } from "./ws-bridge.js";
|
|
2
|
+
export interface IngressServer {
|
|
3
|
+
port: number;
|
|
4
|
+
stop(force?: boolean): void;
|
|
5
|
+
requestIP(req: Request): {
|
|
6
|
+
address: string;
|
|
7
|
+
port: number;
|
|
8
|
+
} | undefined;
|
|
9
|
+
upgrade(req: Request, options: {
|
|
10
|
+
data: WsBridgeData;
|
|
11
|
+
headers?: Record<string, string>;
|
|
12
|
+
}): boolean;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Serve the ingress's Fetch handlers through node:http. Bun.serve discards
|
|
16
|
+
* Content-Length on a still-streaming Response, even when its bytes are
|
|
17
|
+
* unchanged. ServerResponse preserves the declared length while pipeline
|
|
18
|
+
* propagates backpressure and cancellation without buffering the whole body.
|
|
19
|
+
* This is also the HTTP/1 listener behind the TLS terminator.
|
|
20
|
+
*/
|
|
21
|
+
export declare function startIngressServer(options: {
|
|
22
|
+
port: number;
|
|
23
|
+
hostname?: string;
|
|
24
|
+
reusePort?: boolean;
|
|
25
|
+
fetch(req: Request, server: IngressServer): Response | undefined | Promise<Response | undefined>;
|
|
26
|
+
}): Promise<IngressServer>;
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import http from "node:http";
|
|
2
|
+
import { Readable } from "node:stream";
|
|
3
|
+
import { pipeline } from "node:stream/promises";
|
|
4
|
+
import { createRequire } from "node:module";
|
|
5
|
+
import { dirname, join } from "node:path";
|
|
6
|
+
import { WS_BRIDGE_HANDLERS } from "./ws-bridge.js";
|
|
7
|
+
// Load the installed implementation, not Bun's built-in `ws` replacement.
|
|
8
|
+
// On Bun 1.4.0 that replacement cannot upgrade node:http sockets after the
|
|
9
|
+
// asynchronous upstream handshake. The package uses the ordinary socket API.
|
|
10
|
+
const require = createRequire(import.meta.url);
|
|
11
|
+
const { WebSocketServer } = require(join(dirname(require.resolve("ws/package.json")), "index.js"));
|
|
12
|
+
/**
|
|
13
|
+
* Serve the ingress's Fetch handlers through node:http. Bun.serve discards
|
|
14
|
+
* Content-Length on a still-streaming Response, even when its bytes are
|
|
15
|
+
* unchanged. ServerResponse preserves the declared length while pipeline
|
|
16
|
+
* propagates backpressure and cancellation without buffering the whole body.
|
|
17
|
+
* This is also the HTTP/1 listener behind the TLS terminator.
|
|
18
|
+
*/
|
|
19
|
+
export async function startIngressServer(options) {
|
|
20
|
+
const peers = new WeakMap();
|
|
21
|
+
const upgrades = new WeakMap();
|
|
22
|
+
const protocols = new WeakMap();
|
|
23
|
+
const sockets = new Set();
|
|
24
|
+
const websockets = new WebSocketServer({
|
|
25
|
+
noServer: true,
|
|
26
|
+
// The upstream handshake has already selected a protocol. Never select
|
|
27
|
+
// the client's first offer on its behalf.
|
|
28
|
+
handleProtocols: (_offered, req) => protocols.get(req) || false,
|
|
29
|
+
});
|
|
30
|
+
const server = http.createServer({ requestTimeout: 0 }, (req, res) => {
|
|
31
|
+
void dispatch(req, res);
|
|
32
|
+
});
|
|
33
|
+
server.on("connection", socket => {
|
|
34
|
+
sockets.add(socket);
|
|
35
|
+
socket.once("close", () => sockets.delete(socket));
|
|
36
|
+
});
|
|
37
|
+
server.on("upgrade", (req, socket, head) => {
|
|
38
|
+
void dispatch(req, undefined, { req, socket: socket, head, upgraded: false });
|
|
39
|
+
});
|
|
40
|
+
const facade = {
|
|
41
|
+
port: 0,
|
|
42
|
+
stop(force = false) {
|
|
43
|
+
if (force) {
|
|
44
|
+
for (const ws of websockets.clients)
|
|
45
|
+
ws.terminate();
|
|
46
|
+
for (const socket of sockets)
|
|
47
|
+
socket.destroy();
|
|
48
|
+
server.closeAllConnections();
|
|
49
|
+
}
|
|
50
|
+
websockets.close();
|
|
51
|
+
server.close();
|
|
52
|
+
},
|
|
53
|
+
requestIP(req) {
|
|
54
|
+
const socket = peers.get(req);
|
|
55
|
+
return socket?.remoteAddress && socket.remotePort
|
|
56
|
+
? { address: socket.remoteAddress, port: socket.remotePort } : undefined;
|
|
57
|
+
},
|
|
58
|
+
upgrade(req, opts) {
|
|
59
|
+
const pending = upgrades.get(req);
|
|
60
|
+
if (!pending || pending.upgraded || pending.socket.destroyed)
|
|
61
|
+
return false;
|
|
62
|
+
protocols.set(pending.req, opts.headers?.["sec-websocket-protocol"]);
|
|
63
|
+
websockets.handleUpgrade(pending.req, pending.socket, pending.head, ws => {
|
|
64
|
+
pending.upgraded = true;
|
|
65
|
+
const client = {
|
|
66
|
+
data: opts.data,
|
|
67
|
+
send: (data) => ws.send(data),
|
|
68
|
+
close: (code, reason) => ws.close(code, reason),
|
|
69
|
+
};
|
|
70
|
+
ws.on("message", (data, binary) => WS_BRIDGE_HANDLERS.message(client, binary ? Buffer.from(data) : data.toString()));
|
|
71
|
+
ws.on("close", (code, reason) => WS_BRIDGE_HANDLERS.close(client, code, reason.toString()));
|
|
72
|
+
ws.on("error", () => opts.data.upstream.close());
|
|
73
|
+
WS_BRIDGE_HANDLERS.open(client);
|
|
74
|
+
});
|
|
75
|
+
return pending.upgraded;
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
async function dispatch(incoming, outgoing, upgrade) {
|
|
79
|
+
const abort = new AbortController();
|
|
80
|
+
const onClose = () => {
|
|
81
|
+
if (!outgoing?.writableFinished && !upgrade?.upgraded)
|
|
82
|
+
abort.abort();
|
|
83
|
+
};
|
|
84
|
+
const connection = outgoing ?? upgrade.socket;
|
|
85
|
+
connection.once("close", onClose);
|
|
86
|
+
const responseWriter = () => {
|
|
87
|
+
if (!outgoing) {
|
|
88
|
+
// Only attach an HTTP response after a refused upgrade. Attaching
|
|
89
|
+
// one before handleUpgrade conflicts with Bun's native handshake.
|
|
90
|
+
outgoing = new http.ServerResponse(incoming);
|
|
91
|
+
outgoing.assignSocket(upgrade.socket);
|
|
92
|
+
outgoing.once("finish", () => upgrade.socket.end());
|
|
93
|
+
}
|
|
94
|
+
return outgoing;
|
|
95
|
+
};
|
|
96
|
+
let response;
|
|
97
|
+
try {
|
|
98
|
+
const headers = new Headers();
|
|
99
|
+
for (let i = 0; i < incoming.rawHeaders.length; i += 2) {
|
|
100
|
+
headers.append(incoming.rawHeaders[i], incoming.rawHeaders[i + 1]);
|
|
101
|
+
}
|
|
102
|
+
const method = incoming.method ?? "GET";
|
|
103
|
+
const req = new Request(new URL(incoming.url ?? "/", `http://${headers.get("host") ?? "localhost"}`), {
|
|
104
|
+
method, headers, signal: abort.signal,
|
|
105
|
+
body: method === "GET" || method === "HEAD" ? undefined : Readable.toWeb(incoming),
|
|
106
|
+
duplex: "half",
|
|
107
|
+
});
|
|
108
|
+
peers.set(req, incoming.socket);
|
|
109
|
+
if (upgrade)
|
|
110
|
+
upgrades.set(req, upgrade);
|
|
111
|
+
response = await options.fetch(req, facade);
|
|
112
|
+
if (upgrade?.upgraded)
|
|
113
|
+
return;
|
|
114
|
+
if (!response)
|
|
115
|
+
throw new Error("Ingress handler returned no response");
|
|
116
|
+
if (abort.signal.aborted) {
|
|
117
|
+
await response.body?.cancel();
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
const writer = responseWriter();
|
|
121
|
+
writer.statusCode = response.status;
|
|
122
|
+
writer.statusMessage = response.statusText;
|
|
123
|
+
response.headers.forEach((value, key) => {
|
|
124
|
+
if (key !== "set-cookie")
|
|
125
|
+
writer.setHeader(key, value);
|
|
126
|
+
});
|
|
127
|
+
const cookies = response.headers.getSetCookie();
|
|
128
|
+
if (cookies.length)
|
|
129
|
+
writer.setHeader("set-cookie", cookies);
|
|
130
|
+
// Flush even for a body already buffered by fetch. An upstream with
|
|
131
|
+
// no length must not acquire an invented Content-Length from end().
|
|
132
|
+
writer.flushHeaders();
|
|
133
|
+
if (method === "HEAD" || response.status === 204 || response.status === 304 || !response.body) {
|
|
134
|
+
await response.body?.cancel();
|
|
135
|
+
writer.end();
|
|
136
|
+
}
|
|
137
|
+
else {
|
|
138
|
+
await pipeline(Readable.fromWeb(response.body), writer);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
catch (error) {
|
|
142
|
+
if (upgrade?.upgraded)
|
|
143
|
+
return;
|
|
144
|
+
outgoing = responseWriter();
|
|
145
|
+
if (!outgoing.headersSent && !outgoing.destroyed) {
|
|
146
|
+
outgoing.writeHead(500, { "content-type": "text/plain" });
|
|
147
|
+
outgoing.end("spectest-daemon: ingress response failed\n");
|
|
148
|
+
}
|
|
149
|
+
else {
|
|
150
|
+
outgoing.destroy(error instanceof Error ? error : new Error(String(error)));
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
finally {
|
|
154
|
+
connection.off("close", onClose);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
await new Promise((resolve, reject) => {
|
|
158
|
+
server.once("error", reject);
|
|
159
|
+
server.listen({ port: options.port, host: options.hostname ?? "0.0.0.0", reusePort: options.reusePort }, () => {
|
|
160
|
+
server.off("error", reject);
|
|
161
|
+
resolve();
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
facade.port = server.address().port;
|
|
165
|
+
return facade;
|
|
166
|
+
}
|
|
@@ -33,15 +33,11 @@
|
|
|
33
33
|
* ## The shape, and why it is a byte pipe
|
|
34
34
|
*
|
|
35
35
|
* The terminator does TLS and *nothing else*: it accepts on :443, picks a
|
|
36
|
-
* leaf by SNI, and pipes the decrypted stream to
|
|
37
|
-
* bound on loopback. Opted-in HTTP/2 routes select a separate stream
|
|
38
|
-
* by ALPN; HTTP/1.1 and WebSockets
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
* unchanged. Terminating HTTP here instead (`node:https`, `(req, res)`)
|
|
42
|
-
* would have meant reimplementing the WebSocket bridge against a
|
|
43
|
-
* different API, which is exactly the kind of trade this change exists to
|
|
44
|
-
* avoid: one flake removed, another introduced somewhere subtler.
|
|
36
|
+
* leaf by SNI, and pipes the decrypted stream to the plaintext ingress
|
|
37
|
+
* server bound on loopback. Opted-in HTTP/2 routes select a separate stream
|
|
38
|
+
* router by ALPN; HTTP/1.1 and WebSockets use the shared ingress listener.
|
|
39
|
+
* Routing, fakes, interceptors and reverse-proxying stay in that listener;
|
|
40
|
+
* this layer never parses or rewrites HTTP response framing.
|
|
45
41
|
*
|
|
46
42
|
* Measured cost of the extra hop on this host (Bun 1.4.0, undici client,
|
|
47
43
|
* 50 MB bodies): ~11 % fewer small requests per second, download
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@specific.dev/spectest",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.102.0",
|
|
4
4
|
"description": "Spectest SDK for defining test environments in TypeScript.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -56,11 +56,13 @@
|
|
|
56
56
|
"@kubernetes/client-node": "^1.0.0",
|
|
57
57
|
"@xterm/headless": "^5.5.0",
|
|
58
58
|
"dns-packet": "^5.6.1",
|
|
59
|
-
"playwright-core": "^1.61.1"
|
|
59
|
+
"playwright-core": "^1.61.1",
|
|
60
|
+
"ws": "^8.20.1"
|
|
60
61
|
},
|
|
61
62
|
"devDependencies": {
|
|
62
63
|
"@types/dns-packet": "^5.6.5",
|
|
63
64
|
"@types/bun": "^1.3.0",
|
|
65
|
+
"@types/ws": "^8",
|
|
64
66
|
"typescript": "^5.6.3"
|
|
65
67
|
},
|
|
66
68
|
"spectestCapabilities": [
|
package/src/daemon.ts
CHANGED
|
@@ -131,7 +131,6 @@ import {
|
|
|
131
131
|
upstreamRequestHeaders,
|
|
132
132
|
} from "./harness/http-proxy.js";
|
|
133
133
|
import {
|
|
134
|
-
WS_BRIDGE_HANDLERS,
|
|
135
134
|
openUpstreamWebSocket,
|
|
136
135
|
parseSubprotocols,
|
|
137
136
|
upstreamHandshakeHeaders,
|
|
@@ -157,6 +156,7 @@ import {
|
|
|
157
156
|
unbindRoute,
|
|
158
157
|
type Route as IngressRoute,
|
|
159
158
|
} from "./harness/ingress-table.js";
|
|
159
|
+
import { startIngressServer, type IngressServer } from "./harness/ingress-server.js";
|
|
160
160
|
import { startHttp2Proxy } from "./harness/http2-proxy.js";
|
|
161
161
|
import { startTlsTerminator, type TlsTerminator } from "./harness/tls-terminator.js";
|
|
162
162
|
import { runContainerArgs } from "./harness/container-run.js";
|
|
@@ -1920,12 +1920,11 @@ let LOWERED: LoweredIngress = {
|
|
|
1920
1920
|
aliasesByService: {},
|
|
1921
1921
|
wildcards: [],
|
|
1922
1922
|
};
|
|
1923
|
-
/** Running HTTP servers per port
|
|
1924
|
-
|
|
1925
|
-
const
|
|
1923
|
+
/** Running HTTP servers per port. Rebuilt on /load. */
|
|
1924
|
+
const INGRESS_HTTP_SERVERS = new Map<number, IngressServer>();
|
|
1925
|
+
const INGRESS_HTTP_STARTING = new Map<number, Promise<void>>();
|
|
1926
1926
|
/** Running HTTPS servers per port (currently always {INGRESS_HTTPS_PORT}). */
|
|
1927
|
-
|
|
1928
|
-
const INGRESS_HTTPS_SERVERS = new Map<number, any>();
|
|
1927
|
+
const INGRESS_HTTPS_SERVERS = new Map<number, IngressServer>();
|
|
1929
1928
|
/**
|
|
1930
1929
|
* The :443 TLS terminator, once per harness process.
|
|
1931
1930
|
*
|
|
@@ -1989,6 +1988,7 @@ function stopIngressServers(): void {
|
|
|
1989
1988
|
}
|
|
1990
1989
|
}
|
|
1991
1990
|
INGRESS_HTTP_SERVERS.clear();
|
|
1991
|
+
INGRESS_HTTP_STARTING.clear();
|
|
1992
1992
|
for (const [port, srv] of INGRESS_HTTPS_SERVERS) {
|
|
1993
1993
|
try {
|
|
1994
1994
|
srv.stop?.(true);
|
|
@@ -2129,7 +2129,7 @@ async function startIngress(): Promise<void> {
|
|
|
2129
2129
|
);
|
|
2130
2130
|
}
|
|
2131
2131
|
|
|
2132
|
-
|
|
2132
|
+
requireBun();
|
|
2133
2133
|
|
|
2134
2134
|
// Resolve every ingress hostname to its handler into the live per-port
|
|
2135
2135
|
// route tables (module scope, so runtime `tls` can extend them later).
|
|
@@ -2168,12 +2168,12 @@ async function startIngress(): Promise<void> {
|
|
|
2168
2168
|
// ── HTTP listeners (one per non-443 port).
|
|
2169
2169
|
for (const [port, byHost] of INGRESS.routesByPort) {
|
|
2170
2170
|
if (port === INGRESS_HTTPS_PORT) continue;
|
|
2171
|
-
|
|
2171
|
+
await ensureHttpIngress(port, byHost);
|
|
2172
2172
|
// eslint-disable-next-line no-console
|
|
2173
2173
|
console.log(`[ingress] http :${port} for ${[...byHost.keys()].join(", ")}`);
|
|
2174
2174
|
}
|
|
2175
2175
|
// ── HTTPS listener on INGRESS_HTTPS_PORT: SNI per certificated hostname.
|
|
2176
|
-
if (INGRESS.certByHost.size > 0) await ensureHttpsIngress(
|
|
2176
|
+
if (INGRESS.certByHost.size > 0) await ensureHttpsIngress();
|
|
2177
2177
|
|
|
2178
2178
|
// Seed the resolver's names registry: ingress hostnames (fakes, TLS
|
|
2179
2179
|
// proxies, dnsName(→ingress)) → bridge gateway, plus ingress-targeted
|
|
@@ -2188,7 +2188,7 @@ function requireBun(): any {
|
|
|
2188
2188
|
const Bun = (globalThis as any).Bun;
|
|
2189
2189
|
if (!Bun?.serve) {
|
|
2190
2190
|
throw new Error(
|
|
2191
|
-
"ingress requires Bun
|
|
2191
|
+
"ingress requires Bun; the daemon must run under Bun (it does in-VM)",
|
|
2192
2192
|
);
|
|
2193
2193
|
}
|
|
2194
2194
|
return Bun;
|
|
@@ -2198,10 +2198,10 @@ function requireBun(): any {
|
|
|
2198
2198
|
/**
|
|
2199
2199
|
* Bring :443 up, once.
|
|
2200
2200
|
*
|
|
2201
|
-
* Two pieces: a plaintext
|
|
2201
|
+
* Two pieces: a plaintext HTTP server on an ephemeral loopback port that
|
|
2202
2202
|
* carries the HTTPS route table (and therefore all the routing, fake
|
|
2203
|
-
* dispatch, interception, reverse-proxying and
|
|
2204
|
-
*
|
|
2203
|
+
* dispatch, interception, reverse-proxying and WebSocket
|
|
2204
|
+
* bridging that :80 already gets, unchanged), and the TLS
|
|
2205
2205
|
* terminator in front of it on :443.
|
|
2206
2206
|
*
|
|
2207
2207
|
* **This is idempotent, and that is the feature.** Adding a certificate
|
|
@@ -2222,27 +2222,25 @@ function requireBun(): any {
|
|
|
2222
2222
|
* container can reach (containers arrive at the bridge gateway), so the
|
|
2223
2223
|
* only way in is through the terminator.
|
|
2224
2224
|
*/
|
|
2225
|
-
|
|
2226
|
-
async function ensureHttpsIngress(Bun: any): Promise<void> {
|
|
2225
|
+
async function ensureHttpsIngress(): Promise<void> {
|
|
2227
2226
|
if (INGRESS_HTTPS_SERVERS.has(INGRESS_HTTPS_PORT)) return;
|
|
2228
2227
|
if (!HTTPS_INGRESS_STARTING) {
|
|
2229
2228
|
// Cleared on both outcomes: once bound, the check above short-circuits
|
|
2230
2229
|
// every later caller, and a failed bind must be retryable rather than
|
|
2231
2230
|
// remembered as a rejection for the life of the project.
|
|
2232
|
-
HTTPS_INGRESS_STARTING = startHttpsIngress(
|
|
2231
|
+
HTTPS_INGRESS_STARTING = startHttpsIngress().finally(() => {
|
|
2233
2232
|
HTTPS_INGRESS_STARTING = undefined;
|
|
2234
2233
|
});
|
|
2235
2234
|
}
|
|
2236
2235
|
return HTTPS_INGRESS_STARTING;
|
|
2237
2236
|
}
|
|
2238
2237
|
|
|
2239
|
-
|
|
2240
|
-
async function startHttpsIngress(Bun: any): Promise<void> {
|
|
2238
|
+
async function startHttpsIngress(): Promise<void> {
|
|
2241
2239
|
const routes = routesFor(INGRESS, INGRESS_HTTPS_PORT);
|
|
2242
2240
|
// `proto` is passed rather than inferred: this listener speaks plain
|
|
2243
2241
|
// HTTP, but everything reaching it arrived over TLS, so X-Forwarded-Proto
|
|
2244
2242
|
// must say https.
|
|
2245
|
-
const plain = bindIngressServer(
|
|
2243
|
+
const plain = await bindIngressServer(0, routes, `https :${INGRESS_HTTPS_PORT}`, {
|
|
2246
2244
|
proto: "https",
|
|
2247
2245
|
hostname: "127.0.0.1",
|
|
2248
2246
|
});
|
|
@@ -2327,7 +2325,7 @@ async function bindRuntimeTls(hostname: string, service: string, port: number, h
|
|
|
2327
2325
|
`runtime tls for ${JSON.stringify(host)} requires the in-VM root CA at ${CA_PATH}`,
|
|
2328
2326
|
);
|
|
2329
2327
|
}
|
|
2330
|
-
|
|
2328
|
+
requireBun();
|
|
2331
2329
|
const route: Route = { kind: "proxy", service, port, http2, cors };
|
|
2332
2330
|
|
|
2333
2331
|
// Decide the cost before mutating anything: an already-covered hostname
|
|
@@ -2344,24 +2342,14 @@ async function bindRuntimeTls(hostname: string, service: string, port: number, h
|
|
|
2344
2342
|
// the route is only reachable once the hostname resolves, which is the
|
|
2345
2343
|
// registry write at the end.
|
|
2346
2344
|
bindRoute(INGRESS, host, route);
|
|
2347
|
-
|
|
2348
|
-
INGRESS_HTTP_SERVERS.set(
|
|
2349
|
-
INGRESS_HTTP_PORT,
|
|
2350
|
-
bindIngressServer(
|
|
2351
|
-
Bun,
|
|
2352
|
-
INGRESS_HTTP_PORT,
|
|
2353
|
-
routesFor(INGRESS, INGRESS_HTTP_PORT),
|
|
2354
|
-
`port ${INGRESS_HTTP_PORT}`,
|
|
2355
|
-
),
|
|
2356
|
-
);
|
|
2357
|
-
}
|
|
2345
|
+
await ensureHttpIngress(INGRESS_HTTP_PORT, routesFor(INGRESS, INGRESS_HTTP_PORT));
|
|
2358
2346
|
if (plan.needsCert) {
|
|
2359
2347
|
// The terminator reads this table on every handshake, so the leaf is
|
|
2360
2348
|
// live the moment it lands — no listener is touched, and no connection
|
|
2361
2349
|
// already on :443 notices.
|
|
2362
2350
|
INGRESS.certByHost.set(host, await generateHostCert(host, [host]));
|
|
2363
2351
|
}
|
|
2364
|
-
if (plan.needsHttpsListener) await ensureHttpsIngress(
|
|
2352
|
+
if (plan.needsHttpsListener) await ensureHttpsIngress();
|
|
2365
2353
|
|
|
2366
2354
|
// Resolve the hostname to the daemon gateway (where :443/:80 listen).
|
|
2367
2355
|
// A wildcard can only live in the resolver's suffix table.
|
|
@@ -2398,63 +2386,32 @@ async function unbindRuntimeTls(hostname: string): Promise<void> {
|
|
|
2398
2386
|
}
|
|
2399
2387
|
}
|
|
2400
2388
|
|
|
2401
|
-
/**
|
|
2402
|
-
|
|
2403
|
-
|
|
2404
|
-
|
|
2405
|
-
|
|
2406
|
-
|
|
2407
|
-
|
|
2408
|
-
|
|
2409
|
-
|
|
2410
|
-
|
|
2411
|
-
|
|
2412
|
-
|
|
2389
|
+
/** Concurrent runtime routes must share the asynchronous first bind. */
|
|
2390
|
+
async function ensureHttpIngress(port: number, byHost: Map<string, Route>): Promise<void> {
|
|
2391
|
+
if (INGRESS_HTTP_SERVERS.has(port)) return;
|
|
2392
|
+
let starting = INGRESS_HTTP_STARTING.get(port);
|
|
2393
|
+
if (!starting) {
|
|
2394
|
+
starting = bindIngressServer(port, byHost, `port ${port}`).then(server => {
|
|
2395
|
+
INGRESS_HTTP_SERVERS.set(port, server);
|
|
2396
|
+
}).finally(() => INGRESS_HTTP_STARTING.delete(port));
|
|
2397
|
+
INGRESS_HTTP_STARTING.set(port, starting);
|
|
2398
|
+
}
|
|
2399
|
+
await starting;
|
|
2400
|
+
}
|
|
2401
|
+
|
|
2402
|
+
/** Bind the shared HTTP/1 ingress handler, including the listener behind TLS. */
|
|
2403
|
+
async function bindIngressServer(
|
|
2413
2404
|
port: number,
|
|
2414
2405
|
byHost: Map<string, Route>,
|
|
2415
2406
|
listenerLabel: string,
|
|
2416
|
-
// Every ingress listener speaks plain HTTP now — the one behind :443
|
|
2417
|
-
// sits under the TLS terminator. So the scheme a client actually used
|
|
2418
|
-
// can no longer be inferred from the socket and is declared instead; it
|
|
2419
|
-
// stamps X-Forwarded-Proto, which upstreams that build absolute URLs or
|
|
2420
|
-
// redirect depend on.
|
|
2421
2407
|
serve: { proto: "http" | "https"; hostname?: string } = { proto: "http" },
|
|
2422
|
-
|
|
2423
|
-
|
|
2424
|
-
const proto = serve.proto;
|
|
2425
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
2426
|
-
const opts: Record<string, any> = {
|
|
2408
|
+
): Promise<IngressServer> {
|
|
2409
|
+
return startIngressServer({
|
|
2427
2410
|
port,
|
|
2428
2411
|
hostname: serve.hostname ?? "0.0.0.0",
|
|
2429
|
-
// SO_REUSEPORT on every ingress listener. It was introduced so a
|
|
2430
|
-
// replacement :443 could overlap the original during a certificate
|
|
2431
|
-
// swap; the terminator removed that swap, and this stays for the
|
|
2432
|
-
// remaining case — the `/load` teardown force-closes and rebinds, and
|
|
2433
|
-
// a socket the kernel has not finished releasing would otherwise fail
|
|
2434
|
-
// the next bind with "Is port 443 in use?". Note the flip side, which
|
|
2435
|
-
// {@link stopIngressServers} depends on: a half-alive listener shares
|
|
2436
|
-
// the port silently instead of colliding loudly.
|
|
2437
2412
|
reusePort: true,
|
|
2438
|
-
|
|
2439
|
-
|
|
2440
|
-
maxRequestBodySize: 1024 ** 4,
|
|
2441
|
-
// Bun.serve defaults to a 10s idleTimeout, which kills any proxied
|
|
2442
|
-
// request whose upstream takes >10s to produce bytes — under parallel
|
|
2443
|
-
// test load that surfaced as "fetch failed"/"other side closed" on
|
|
2444
|
-
// deploy-archive uploads and ERR_EMPTY_RESPONSE in browser tests
|
|
2445
|
-
// ([Bun.serve]: request timed out after 10 seconds). Ingress fronts
|
|
2446
|
-
// arbitrarily slow app endpoints (deploys can legitimately take
|
|
2447
|
-
// minutes), so disable the idle timeout entirely; forked test VMs are
|
|
2448
|
-
// short-lived, leaked-connection risk is bounded by the fork.
|
|
2449
|
-
idleTimeout: 0,
|
|
2450
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
2451
|
-
fetch: (req: Request, server: any): Response | Promise<Response> =>
|
|
2452
|
-
dispatchIngress(req, server, byHost, listenerLabel, proto),
|
|
2453
|
-
// The frame relay for bridged WebSocket upgrades; `proxyToService`
|
|
2454
|
-
// opens the upstream before it upgrades the client (harness/ws-bridge.ts).
|
|
2455
|
-
websocket: WS_BRIDGE_HANDLERS,
|
|
2456
|
-
};
|
|
2457
|
-
return Bun.serve(opts);
|
|
2413
|
+
fetch: (req, server) => dispatchIngress(req, server, byHost, listenerLabel, serve.proto),
|
|
2414
|
+
});
|
|
2458
2415
|
}
|
|
2459
2416
|
|
|
2460
2417
|
/**
|
|
@@ -2610,7 +2567,7 @@ async function proxyToService(
|
|
|
2610
2567
|
headers: selected ? { "sec-websocket-protocol": selected } : undefined,
|
|
2611
2568
|
});
|
|
2612
2569
|
if (ok) {
|
|
2613
|
-
//
|
|
2570
|
+
// The WebSocket has already taken over the response — return a stub.
|
|
2614
2571
|
return new Response(null, { status: 101 });
|
|
2615
2572
|
}
|
|
2616
2573
|
try {
|
|
@@ -2676,6 +2633,7 @@ async function proxyToService(
|
|
|
2676
2633
|
headers: fwdHeaders,
|
|
2677
2634
|
body: hasBody ? (bufferedBody ?? req.body) : undefined,
|
|
2678
2635
|
redirect: "manual",
|
|
2636
|
+
signal: req.signal,
|
|
2679
2637
|
});
|
|
2680
2638
|
// decompress:false → forward the encoded body untouched (see fn doc).
|
|
2681
2639
|
// keepalive:false → fresh connection per request (see fwdHeaders above).
|
|
@@ -2685,7 +2643,7 @@ async function proxyToService(
|
|
|
2685
2643
|
keepalive: false,
|
|
2686
2644
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
2687
2645
|
} as any);
|
|
2688
|
-
// Strip
|
|
2646
|
+
// Strip connection framing while preserving the unchanged body's length.
|
|
2689
2647
|
const respHeaders = new Headers();
|
|
2690
2648
|
for (const [k, v] of upstreamRes.headers) {
|
|
2691
2649
|
if (HOP_BY_HOP_HEADERS.has(k.toLowerCase())) continue;
|
|
@@ -3,6 +3,7 @@ import { defineEnvironment, defineFake, lowerIngress, provides, proxy, type Cors
|
|
|
3
3
|
import { supabase } from "../components/supabase.js";
|
|
4
4
|
import { withIngressCors, upstreamRequestHeaders } from "./http-proxy.js";
|
|
5
5
|
import { InterceptRegistry, runChain } from "./intercept.js";
|
|
6
|
+
import { startIngressServer } from "./ingress-server.js";
|
|
6
7
|
|
|
7
8
|
const allowed = "https://app.tangent.test";
|
|
8
9
|
const denied = "https://untrusted.test";
|
|
@@ -56,12 +57,12 @@ for (const kind of ["fake", "service"] as const) {
|
|
|
56
57
|
method: req.method, headers: upstreamRequestHeaders(req.headers, { proto: "https" }),
|
|
57
58
|
body: req.body, redirect: "manual",
|
|
58
59
|
});
|
|
59
|
-
const ingress =
|
|
60
|
+
const ingress = await startIngressServer({ port: 0, hostname: "127.0.0.1", fetch: req =>
|
|
60
61
|
withIngressCors(req, mode, current => runChain(
|
|
61
62
|
interceptors.chainFor("bucket.test", new URL(current.url).pathname), current, forward)),
|
|
62
63
|
});
|
|
63
64
|
try {
|
|
64
|
-
const send = (path: string, init: RequestInit) => fetch(new URL(path, ingress.
|
|
65
|
+
const send = (path: string, init: RequestInit) => fetch(new URL(path, `http://127.0.0.1:${ingress.port}`), init);
|
|
65
66
|
const ok = await send("/probe", preflight(allowed));
|
|
66
67
|
expect(ok.status).toBe(204);
|
|
67
68
|
expect(ok.headers.get("access-control-allow-origin")).toBe(allowed);
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import http from "node:http";
|
|
3
|
+
import https from "node:https";
|
|
4
|
+
import { once } from "node:events";
|
|
5
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
6
|
+
import { gzipSync } from "node:zlib";
|
|
7
|
+
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
8
|
+
import { tmpdir } from "node:os";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { execFileSync } from "node:child_process";
|
|
11
|
+
import { forwardableHeaders, withIngressCors } from "./http-proxy";
|
|
12
|
+
import { startIngressServer, type IngressServer } from "./ingress-server";
|
|
13
|
+
import { startTlsTerminator, type TlsTerminator } from "./tls-terminator";
|
|
14
|
+
|
|
15
|
+
const object = randomBytes(65536);
|
|
16
|
+
const compressed = gzipSync(object);
|
|
17
|
+
const digest = (body: Uint8Array) => createHash("sha256").update(body).digest("hex");
|
|
18
|
+
let upstream: http.Server;
|
|
19
|
+
let upstreamPort: number;
|
|
20
|
+
let ingress: IngressServer;
|
|
21
|
+
let tls: TlsTerminator;
|
|
22
|
+
let dir: string;
|
|
23
|
+
let release: (() => void) | undefined;
|
|
24
|
+
let upstreamClosed: Promise<unknown>;
|
|
25
|
+
|
|
26
|
+
beforeAll(async () => {
|
|
27
|
+
upstream = http.createServer((req, res) => {
|
|
28
|
+
const path = req.url!;
|
|
29
|
+
let body = path === "/8960" ? object.subarray(0, 8960) : path === "/gzip" ? compressed : object;
|
|
30
|
+
res.setHeader("content-type", "application/octet-stream");
|
|
31
|
+
res.setHeader("etag", '"original"');
|
|
32
|
+
res.setHeader("set-cookie", ["a=1", "b=2"]);
|
|
33
|
+
if (path === "/empty") body = Buffer.alloc(0);
|
|
34
|
+
if (path === "/gzip") res.setHeader("content-encoding", "gzip");
|
|
35
|
+
if (req.headers.range) {
|
|
36
|
+
body = body.subarray(512, 8704);
|
|
37
|
+
res.statusCode = 206;
|
|
38
|
+
res.setHeader("content-range", `bytes 512-8703/${object.length}`);
|
|
39
|
+
}
|
|
40
|
+
if (path !== "/unknown" && path !== "/unknown-small") res.setHeader("content-length", body.length);
|
|
41
|
+
if (path === "/large") res.setHeader("content-length", 1024 ** 3);
|
|
42
|
+
if (path === "/not-modified") res.statusCode = 304;
|
|
43
|
+
if (path === "/no-content") { res.statusCode = 204; res.removeHeader("content-length"); }
|
|
44
|
+
if (req.method === "HEAD" || [204, 304].includes(res.statusCode) || body.length === 0) { res.end(); return; }
|
|
45
|
+
if (path === "/unknown-small") { res.write("small"); res.end(); return; }
|
|
46
|
+
// Force multiple reads by sending the tail after headers + first chunk.
|
|
47
|
+
res.write(body.subarray(0, 1024));
|
|
48
|
+
if (path === "/gated" || path === "/large" || path === "/unknown") {
|
|
49
|
+
upstreamClosed = once(res, "close");
|
|
50
|
+
release = () => res.end(body.subarray(1024));
|
|
51
|
+
} else {
|
|
52
|
+
setTimeout(() => res.end(body.subarray(1024)), 5);
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
upstream.listen(0, "127.0.0.1");
|
|
56
|
+
await once(upstream, "listening");
|
|
57
|
+
upstreamPort = (upstream.address() as { port: number }).port;
|
|
58
|
+
ingress = await startIngressServer({
|
|
59
|
+
port: 0, hostname: "127.0.0.1",
|
|
60
|
+
fetch: req => withIngressCors(req, "permissive", async current => {
|
|
61
|
+
const res = await fetch(`http://127.0.0.1:${upstreamPort}${new URL(current.url).pathname}`, {
|
|
62
|
+
method: current.method, headers: current.headers, signal: current.signal,
|
|
63
|
+
decompress: false, keepalive: false,
|
|
64
|
+
});
|
|
65
|
+
return new Response(res.body, {
|
|
66
|
+
status: res.status, statusText: res.statusText, headers: forwardableHeaders(res.headers),
|
|
67
|
+
});
|
|
68
|
+
}),
|
|
69
|
+
});
|
|
70
|
+
dir = mkdtempSync(join(tmpdir(), "spectest-length-"));
|
|
71
|
+
execFileSync("openssl", ["req", "-x509", "-newkey", "rsa:2048", "-nodes", "-days", "2",
|
|
72
|
+
"-subj", "/CN=bucket.test", "-keyout", join(dir, "key.pem"), "-out", join(dir, "cert.pem")], { stdio: "ignore" });
|
|
73
|
+
const leaf = { key: readFileSync(join(dir, "key.pem"), "utf8"), cert: readFileSync(join(dir, "cert.pem"), "utf8") };
|
|
74
|
+
tls = await startTlsTerminator({ port: 0, hostname: "127.0.0.1", upstreamPort: ingress.port, certFor: () => leaf });
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
afterAll(() => {
|
|
78
|
+
release?.();
|
|
79
|
+
tls?.close();
|
|
80
|
+
ingress?.stop(true);
|
|
81
|
+
upstream?.closeAllConnections();
|
|
82
|
+
upstream?.close();
|
|
83
|
+
if (dir) rmSync(dir, { recursive: true, force: true });
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
function request(path: string, options: { boundary?: "storage" | "http" | "https"; method?: string; headers?: Record<string, string> } = {}) {
|
|
87
|
+
const boundary = options.boundary ?? "https";
|
|
88
|
+
return new Promise<http.IncomingMessage>((resolve, reject) => {
|
|
89
|
+
const req = (boundary === "https" ? https : http).request({
|
|
90
|
+
host: "127.0.0.1", port: boundary === "https" ? tls.port : boundary === "http" ? ingress.port : upstreamPort,
|
|
91
|
+
path, method: options.method ?? "GET", headers: { host: "bucket.test", ...options.headers },
|
|
92
|
+
agent: false, rejectUnauthorized: false,
|
|
93
|
+
}, resolve);
|
|
94
|
+
req.once("error", reject);
|
|
95
|
+
req.setTimeout(2000, () => req.destroy(new Error("ingress request timed out")));
|
|
96
|
+
req.end();
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function bytes(res: http.IncomingMessage) {
|
|
101
|
+
const chunks: Buffer[] = [];
|
|
102
|
+
for await (const chunk of res) chunks.push(Buffer.from(chunk));
|
|
103
|
+
return Buffer.concat(chunks);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
describe("ingress response framing", () => {
|
|
107
|
+
for (const boundary of ["storage", "http", "https"] as const) {
|
|
108
|
+
test(`${boundary}: repeated multi-chunk GETs keep length and digest`, async () => {
|
|
109
|
+
for (const size of [8960, 65536]) {
|
|
110
|
+
for (let n = 0; n < 20; n++) {
|
|
111
|
+
const res = await request(`/${size}`, { boundary });
|
|
112
|
+
const body = await bytes(res);
|
|
113
|
+
expect(res.statusCode).toBe(200);
|
|
114
|
+
expect(res.headers["content-length"]).toBe(String(size));
|
|
115
|
+
expect(res.headers["transfer-encoding"]).toBeUndefined();
|
|
116
|
+
expect(body.length).toBe(size);
|
|
117
|
+
expect(digest(body)).toBe(digest(object.subarray(0, size)));
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
test("HEAD, empty objects, ranges, cookies and CORS preserve their metadata", async () => {
|
|
124
|
+
const head = await request("/65536", { method: "HEAD" });
|
|
125
|
+
expect(head.headers["content-length"]).toBe("65536");
|
|
126
|
+
expect((await bytes(head)).length).toBe(0);
|
|
127
|
+
const empty = await request("/empty");
|
|
128
|
+
expect(empty.headers["content-length"]).toBe("0");
|
|
129
|
+
expect((await bytes(empty)).length).toBe(0);
|
|
130
|
+
const range = await request("/65536", { headers: { range: "bytes=512-8703", origin: "https://app.test" } });
|
|
131
|
+
expect(range.statusCode).toBe(206);
|
|
132
|
+
expect(range.headers["content-length"]).toBe("8192");
|
|
133
|
+
expect(range.headers["content-range"]).toBe("bytes 512-8703/65536");
|
|
134
|
+
expect(range.headers.etag).toBe('"original"');
|
|
135
|
+
expect(range.headers["set-cookie"]).toEqual(["a=1", "b=2"]);
|
|
136
|
+
expect(range.headers["access-control-allow-origin"]).toBe("https://app.test");
|
|
137
|
+
expect(digest(await bytes(range))).toBe(digest(object.subarray(512, 8704)));
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("compressed bytes and their encoded length remain unchanged", async () => {
|
|
141
|
+
const res = await request("/gzip", { headers: { "accept-encoding": "gzip" } });
|
|
142
|
+
expect(res.headers["content-length"]).toBe(String(compressed.length));
|
|
143
|
+
expect(res.headers["content-encoding"]).toBe("gzip");
|
|
144
|
+
expect(digest(await bytes(res))).toBe(digest(compressed));
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("no length is invented for an already-complete unknown-length body", async () => {
|
|
148
|
+
const res = await request("/unknown-small");
|
|
149
|
+
expect(res.headers["content-length"]).toBeUndefined();
|
|
150
|
+
expect((await bytes(res)).toString()).toBe("small");
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("bodyless statuses stay bodyless", async () => {
|
|
154
|
+
for (const [path, status] of [["/not-modified", 304], ["/no-content", 204]] as const) {
|
|
155
|
+
const res = await request(path);
|
|
156
|
+
expect(res.statusCode).toBe(status);
|
|
157
|
+
expect(res.headers["transfer-encoding"]).toBeUndefined();
|
|
158
|
+
if (status === 304) expect(res.headers["content-length"]).toBe("65536");
|
|
159
|
+
expect((await bytes(res)).length).toBe(0);
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
for (const path of ["/gated", "/unknown", "/large"]) {
|
|
164
|
+
test(`${path}: first bytes arrive before upstream finishes; disconnect cancels`, async () => {
|
|
165
|
+
// The source cannot finish until the test releases it. This fails if
|
|
166
|
+
// ingress waits for an entire body, regardless of any buffering cap.
|
|
167
|
+
const res = await request(path);
|
|
168
|
+
const first = await once(res, "data");
|
|
169
|
+
expect(Buffer.from(first[0])).toEqual(object.subarray(0, 1024));
|
|
170
|
+
expect(res.headers["content-length"]).toBe(path === "/unknown" ? undefined : path === "/large" ? String(1024 ** 3) : "65536");
|
|
171
|
+
if (path === "/large") {
|
|
172
|
+
res.destroy();
|
|
173
|
+
await upstreamClosed;
|
|
174
|
+
} else {
|
|
175
|
+
const rest = bytes(res);
|
|
176
|
+
release!();
|
|
177
|
+
expect(digest(Buffer.concat([first[0], await rest]))).toBe(digest(object));
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
describe("ingress request lifecycle", () => {
|
|
184
|
+
test("streams uploads and preserves the request URL, headers and peer", async () => {
|
|
185
|
+
const server = await startIngressServer({
|
|
186
|
+
port: 0, hostname: "127.0.0.1",
|
|
187
|
+
async fetch(req, ingress) {
|
|
188
|
+
const body = await req.arrayBuffer();
|
|
189
|
+
return Response.json({ method: req.method, url: req.url, digest: digest(new Uint8Array(body)),
|
|
190
|
+
authorization: req.headers.get("authorization"), peer: ingress.requestIP(req)?.address });
|
|
191
|
+
},
|
|
192
|
+
});
|
|
193
|
+
try {
|
|
194
|
+
for (const chunked of [false, true]) {
|
|
195
|
+
const result = new Promise<http.IncomingMessage>((resolve, reject) => {
|
|
196
|
+
const req = http.request({ host: "127.0.0.1", port: server.port, path: "/upload?part=2", method: "PUT",
|
|
197
|
+
headers: { host: "bucket.test:8443", authorization: "signed", ...(chunked ? {} : { "content-length": object.length }) },
|
|
198
|
+
}, resolve);
|
|
199
|
+
req.on("error", reject);
|
|
200
|
+
req.write(object.subarray(0, 1024));
|
|
201
|
+
setTimeout(() => req.end(object.subarray(1024)), 5);
|
|
202
|
+
});
|
|
203
|
+
expect(JSON.parse((await bytes(await result)).toString())).toEqual({ method: "PUT",
|
|
204
|
+
url: "http://bucket.test:8443/upload?part=2", digest: digest(object), authorization: "signed", peer: "127.0.0.1" });
|
|
205
|
+
}
|
|
206
|
+
} finally { server.stop(true); }
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test("disconnect before response headers aborts the handler", async () => {
|
|
210
|
+
let entered!: () => void;
|
|
211
|
+
const started = new Promise<void>(resolve => { entered = resolve; });
|
|
212
|
+
let cancelled!: () => void;
|
|
213
|
+
const aborted = new Promise<void>(resolve => { cancelled = resolve; });
|
|
214
|
+
const server = await startIngressServer({
|
|
215
|
+
port: 0, hostname: "127.0.0.1",
|
|
216
|
+
async fetch(req) {
|
|
217
|
+
req.signal.addEventListener("abort", cancelled, { once: true });
|
|
218
|
+
entered();
|
|
219
|
+
await aborted;
|
|
220
|
+
return new Response("cancelled");
|
|
221
|
+
},
|
|
222
|
+
});
|
|
223
|
+
try {
|
|
224
|
+
const req = http.get(`http://127.0.0.1:${server.port}/`);
|
|
225
|
+
req.on("error", () => {});
|
|
226
|
+
await started;
|
|
227
|
+
req.destroy();
|
|
228
|
+
await aborted;
|
|
229
|
+
} finally { server.stop(true); }
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test("an error after headers closes the stream instead of appending a success or error body", async () => {
|
|
233
|
+
const server = await startIngressServer({
|
|
234
|
+
port: 0, hostname: "127.0.0.1",
|
|
235
|
+
fetch: () => new Response(new ReadableStream({
|
|
236
|
+
start(controller) {
|
|
237
|
+
controller.enqueue(object.subarray(0, 1024));
|
|
238
|
+
setTimeout(() => controller.error(new Error("upstream truncated")), 20);
|
|
239
|
+
},
|
|
240
|
+
}), { headers: { "content-length": String(object.length) } }),
|
|
241
|
+
});
|
|
242
|
+
try {
|
|
243
|
+
const res = await new Promise<http.IncomingMessage>((resolve, reject) => {
|
|
244
|
+
http.get(`http://127.0.0.1:${server.port}/`, resolve).on("error", reject);
|
|
245
|
+
});
|
|
246
|
+
expect(res.headers["content-length"]).toBe(String(object.length));
|
|
247
|
+
await expect(bytes(res)).rejects.toThrow();
|
|
248
|
+
} finally { server.stop(true); }
|
|
249
|
+
});
|
|
250
|
+
});
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import http, { type IncomingMessage, type ServerResponse } from "node:http";
|
|
2
|
+
import { Readable } from "node:stream";
|
|
3
|
+
import { pipeline } from "node:stream/promises";
|
|
4
|
+
import type { Socket } from "node:net";
|
|
5
|
+
import { createRequire } from "node:module";
|
|
6
|
+
import { dirname, join } from "node:path";
|
|
7
|
+
import { WS_BRIDGE_HANDLERS, type WsBridgeData } from "./ws-bridge.js";
|
|
8
|
+
|
|
9
|
+
// Load the installed implementation, not Bun's built-in `ws` replacement.
|
|
10
|
+
// On Bun 1.4.0 that replacement cannot upgrade node:http sockets after the
|
|
11
|
+
// asynchronous upstream handshake. The package uses the ordinary socket API.
|
|
12
|
+
const require = createRequire(import.meta.url);
|
|
13
|
+
const { WebSocketServer } = require(join(dirname(require.resolve("ws/package.json")), "index.js")) as typeof import("ws");
|
|
14
|
+
|
|
15
|
+
export interface IngressServer {
|
|
16
|
+
port: number;
|
|
17
|
+
stop(force?: boolean): void;
|
|
18
|
+
requestIP(req: Request): { address: string; port: number } | undefined;
|
|
19
|
+
upgrade(req: Request, options: { data: WsBridgeData; headers?: Record<string, string> }): boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Serve the ingress's Fetch handlers through node:http. Bun.serve discards
|
|
24
|
+
* Content-Length on a still-streaming Response, even when its bytes are
|
|
25
|
+
* unchanged. ServerResponse preserves the declared length while pipeline
|
|
26
|
+
* propagates backpressure and cancellation without buffering the whole body.
|
|
27
|
+
* This is also the HTTP/1 listener behind the TLS terminator.
|
|
28
|
+
*/
|
|
29
|
+
export async function startIngressServer(options: {
|
|
30
|
+
port: number;
|
|
31
|
+
hostname?: string;
|
|
32
|
+
reusePort?: boolean;
|
|
33
|
+
fetch(req: Request, server: IngressServer): Response | undefined | Promise<Response | undefined>;
|
|
34
|
+
}): Promise<IngressServer> {
|
|
35
|
+
const peers = new WeakMap<Request, Socket>();
|
|
36
|
+
const upgrades = new WeakMap<Request, { req: IncomingMessage; socket: Socket; head: Buffer; upgraded: boolean }>();
|
|
37
|
+
const protocols = new WeakMap<IncomingMessage, string | undefined>();
|
|
38
|
+
const sockets = new Set<Socket>();
|
|
39
|
+
const websockets = new WebSocketServer({
|
|
40
|
+
noServer: true,
|
|
41
|
+
// The upstream handshake has already selected a protocol. Never select
|
|
42
|
+
// the client's first offer on its behalf.
|
|
43
|
+
handleProtocols: (_offered, req) => protocols.get(req) || false,
|
|
44
|
+
});
|
|
45
|
+
const server = http.createServer({ requestTimeout: 0 }, (req, res) => {
|
|
46
|
+
void dispatch(req, res);
|
|
47
|
+
});
|
|
48
|
+
server.on("connection", socket => {
|
|
49
|
+
sockets.add(socket);
|
|
50
|
+
socket.once("close", () => sockets.delete(socket));
|
|
51
|
+
});
|
|
52
|
+
server.on("upgrade", (req, socket, head) => {
|
|
53
|
+
void dispatch(req, undefined, { req, socket: socket as Socket, head, upgraded: false });
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const facade: IngressServer = {
|
|
57
|
+
port: 0,
|
|
58
|
+
stop(force = false) {
|
|
59
|
+
if (force) {
|
|
60
|
+
for (const ws of websockets.clients) ws.terminate();
|
|
61
|
+
for (const socket of sockets) socket.destroy();
|
|
62
|
+
server.closeAllConnections();
|
|
63
|
+
}
|
|
64
|
+
websockets.close();
|
|
65
|
+
server.close();
|
|
66
|
+
},
|
|
67
|
+
requestIP(req) {
|
|
68
|
+
const socket = peers.get(req);
|
|
69
|
+
return socket?.remoteAddress && socket.remotePort
|
|
70
|
+
? { address: socket.remoteAddress, port: socket.remotePort } : undefined;
|
|
71
|
+
},
|
|
72
|
+
upgrade(req, opts) {
|
|
73
|
+
const pending = upgrades.get(req);
|
|
74
|
+
if (!pending || pending.upgraded || pending.socket.destroyed) return false;
|
|
75
|
+
protocols.set(pending.req, opts.headers?.["sec-websocket-protocol"]);
|
|
76
|
+
websockets.handleUpgrade(pending.req, pending.socket, pending.head, ws => {
|
|
77
|
+
pending.upgraded = true;
|
|
78
|
+
const client = {
|
|
79
|
+
data: opts.data,
|
|
80
|
+
send: (data: string | ArrayBuffer | Uint8Array) => ws.send(data),
|
|
81
|
+
close: (code?: number, reason?: string) => ws.close(code, reason),
|
|
82
|
+
};
|
|
83
|
+
ws.on("message", (data, binary) => WS_BRIDGE_HANDLERS.message(client,
|
|
84
|
+
binary ? Buffer.from(data as Buffer) : data.toString()));
|
|
85
|
+
ws.on("close", (code, reason) => WS_BRIDGE_HANDLERS.close(client, code, reason.toString()));
|
|
86
|
+
ws.on("error", () => opts.data.upstream.close());
|
|
87
|
+
WS_BRIDGE_HANDLERS.open(client);
|
|
88
|
+
});
|
|
89
|
+
return pending.upgraded;
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
async function dispatch(
|
|
94
|
+
incoming: IncomingMessage,
|
|
95
|
+
outgoing: ServerResponse | undefined,
|
|
96
|
+
upgrade?: { req: IncomingMessage; socket: Socket; head: Buffer; upgraded: boolean },
|
|
97
|
+
): Promise<void> {
|
|
98
|
+
const abort = new AbortController();
|
|
99
|
+
const onClose = () => {
|
|
100
|
+
if (!outgoing?.writableFinished && !upgrade?.upgraded) abort.abort();
|
|
101
|
+
};
|
|
102
|
+
const connection = outgoing ?? upgrade!.socket;
|
|
103
|
+
connection.once("close", onClose);
|
|
104
|
+
const responseWriter = () => {
|
|
105
|
+
if (!outgoing) {
|
|
106
|
+
// Only attach an HTTP response after a refused upgrade. Attaching
|
|
107
|
+
// one before handleUpgrade conflicts with Bun's native handshake.
|
|
108
|
+
outgoing = new http.ServerResponse(incoming);
|
|
109
|
+
outgoing.assignSocket(upgrade!.socket);
|
|
110
|
+
outgoing.once("finish", () => upgrade!.socket.end());
|
|
111
|
+
}
|
|
112
|
+
return outgoing;
|
|
113
|
+
};
|
|
114
|
+
let response: Response | undefined;
|
|
115
|
+
try {
|
|
116
|
+
const headers = new Headers();
|
|
117
|
+
for (let i = 0; i < incoming.rawHeaders.length; i += 2) {
|
|
118
|
+
headers.append(incoming.rawHeaders[i], incoming.rawHeaders[i + 1]);
|
|
119
|
+
}
|
|
120
|
+
const method = incoming.method ?? "GET";
|
|
121
|
+
const req = new Request(new URL(incoming.url ?? "/", `http://${headers.get("host") ?? "localhost"}`), {
|
|
122
|
+
method, headers, signal: abort.signal,
|
|
123
|
+
body: method === "GET" || method === "HEAD" ? undefined : Readable.toWeb(incoming) as unknown as ReadableStream<Uint8Array>,
|
|
124
|
+
duplex: "half",
|
|
125
|
+
} as RequestInit);
|
|
126
|
+
peers.set(req, incoming.socket);
|
|
127
|
+
if (upgrade) upgrades.set(req, upgrade);
|
|
128
|
+
response = await options.fetch(req, facade);
|
|
129
|
+
if (upgrade?.upgraded) return;
|
|
130
|
+
if (!response) throw new Error("Ingress handler returned no response");
|
|
131
|
+
if (abort.signal.aborted) {
|
|
132
|
+
await response.body?.cancel();
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
const writer = responseWriter();
|
|
136
|
+
writer.statusCode = response.status;
|
|
137
|
+
writer.statusMessage = response.statusText;
|
|
138
|
+
response.headers.forEach((value, key) => {
|
|
139
|
+
if (key !== "set-cookie") writer.setHeader(key, value);
|
|
140
|
+
});
|
|
141
|
+
const cookies = response.headers.getSetCookie();
|
|
142
|
+
if (cookies.length) writer.setHeader("set-cookie", cookies);
|
|
143
|
+
// Flush even for a body already buffered by fetch. An upstream with
|
|
144
|
+
// no length must not acquire an invented Content-Length from end().
|
|
145
|
+
writer.flushHeaders();
|
|
146
|
+
if (method === "HEAD" || response.status === 204 || response.status === 304 || !response.body) {
|
|
147
|
+
await response.body?.cancel();
|
|
148
|
+
writer.end();
|
|
149
|
+
} else {
|
|
150
|
+
await pipeline(Readable.fromWeb(response.body as unknown as import("node:stream/web").ReadableStream), writer);
|
|
151
|
+
}
|
|
152
|
+
} catch (error) {
|
|
153
|
+
if (upgrade?.upgraded) return;
|
|
154
|
+
outgoing = responseWriter();
|
|
155
|
+
if (!outgoing.headersSent && !outgoing.destroyed) {
|
|
156
|
+
outgoing.writeHead(500, { "content-type": "text/plain" });
|
|
157
|
+
outgoing.end("spectest-daemon: ingress response failed\n");
|
|
158
|
+
} else {
|
|
159
|
+
outgoing.destroy(error instanceof Error ? error : new Error(String(error)));
|
|
160
|
+
}
|
|
161
|
+
} finally {
|
|
162
|
+
connection.off("close", onClose);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
await new Promise<void>((resolve, reject) => {
|
|
167
|
+
server.once("error", reject);
|
|
168
|
+
server.listen({ port: options.port, host: options.hostname ?? "0.0.0.0", reusePort: options.reusePort }, () => {
|
|
169
|
+
server.off("error", reject);
|
|
170
|
+
resolve();
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
facade.port = (server.address() as { port: number }).port;
|
|
174
|
+
return facade;
|
|
175
|
+
}
|
|
@@ -28,6 +28,7 @@ import tls from "node:tls";
|
|
|
28
28
|
import type { Leaf } from "./ingress-table";
|
|
29
29
|
import { selectCertName } from "./hostmatch";
|
|
30
30
|
import { startTlsTerminator, type TlsTerminator } from "./tls-terminator";
|
|
31
|
+
import { startIngressServer } from "./ingress-server";
|
|
31
32
|
|
|
32
33
|
// ── a throwaway CA and leaves, so the client can verify for real ──────────
|
|
33
34
|
let dir: string;
|
|
@@ -80,10 +81,9 @@ let seenClientIp: string | undefined;
|
|
|
80
81
|
|
|
81
82
|
beforeAll(async () => {
|
|
82
83
|
certByHost = new Map([["app.test", leaves.get("app.test")!]]);
|
|
83
|
-
plain =
|
|
84
|
+
plain = await startIngressServer({
|
|
84
85
|
port: 0,
|
|
85
86
|
hostname: "127.0.0.1",
|
|
86
|
-
idleTimeout: 0,
|
|
87
87
|
fetch(req, server) {
|
|
88
88
|
const peer = server.requestIP(req);
|
|
89
89
|
seenClientIp = term.clientIpFor(peer?.port) ?? peer?.address;
|
|
@@ -33,15 +33,11 @@
|
|
|
33
33
|
* ## The shape, and why it is a byte pipe
|
|
34
34
|
*
|
|
35
35
|
* The terminator does TLS and *nothing else*: it accepts on :443, picks a
|
|
36
|
-
* leaf by SNI, and pipes the decrypted stream to
|
|
37
|
-
* bound on loopback. Opted-in HTTP/2 routes select a separate stream
|
|
38
|
-
* by ALPN; HTTP/1.1 and WebSockets
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
* unchanged. Terminating HTTP here instead (`node:https`, `(req, res)`)
|
|
42
|
-
* would have meant reimplementing the WebSocket bridge against a
|
|
43
|
-
* different API, which is exactly the kind of trade this change exists to
|
|
44
|
-
* avoid: one flake removed, another introduced somewhere subtler.
|
|
36
|
+
* leaf by SNI, and pipes the decrypted stream to the plaintext ingress
|
|
37
|
+
* server bound on loopback. Opted-in HTTP/2 routes select a separate stream
|
|
38
|
+
* router by ALPN; HTTP/1.1 and WebSockets use the shared ingress listener.
|
|
39
|
+
* Routing, fakes, interceptors and reverse-proxying stay in that listener;
|
|
40
|
+
* this layer never parses or rewrites HTTP response framing.
|
|
45
41
|
*
|
|
46
42
|
* Measured cost of the extra hop on this host (Bun 1.4.0, undici client,
|
|
47
43
|
* 50 MB bodies): ~11 % fewer small requests per second, download
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { startIngressServer, type IngressServer } from "./ingress-server";
|
|
2
3
|
|
|
3
4
|
import {
|
|
4
|
-
WS_BRIDGE_HANDLERS,
|
|
5
5
|
openUpstreamWebSocket,
|
|
6
6
|
parseSubprotocols,
|
|
7
7
|
upstreamHandshakeHeaders,
|
|
@@ -65,11 +65,11 @@ describe("upstreamHandshakeHeaders", () => {
|
|
|
65
65
|
*/
|
|
66
66
|
describe("the bridge on loopback", () => {
|
|
67
67
|
let upstream: ReturnType<typeof Bun.serve>;
|
|
68
|
-
let bridge:
|
|
68
|
+
let bridge: IngressServer;
|
|
69
69
|
/** What the upstream saw on its last handshake. */
|
|
70
70
|
let seen: { protocols: string[]; cookie: string | null; host: string | null; xfh: string | null } | null = null;
|
|
71
71
|
|
|
72
|
-
beforeAll(() => {
|
|
72
|
+
beforeAll(async () => {
|
|
73
73
|
upstream = Bun.serve({
|
|
74
74
|
port: 0,
|
|
75
75
|
fetch(req, server) {
|
|
@@ -102,7 +102,7 @@ describe("the bridge on loopback", () => {
|
|
|
102
102
|
},
|
|
103
103
|
});
|
|
104
104
|
|
|
105
|
-
bridge =
|
|
105
|
+
bridge = await startIngressServer({
|
|
106
106
|
port: 0,
|
|
107
107
|
async fetch(req, server) {
|
|
108
108
|
const protocols = parseSubprotocols(req.headers.get("sec-websocket-protocol"));
|
|
@@ -128,7 +128,6 @@ describe("the bridge on loopback", () => {
|
|
|
128
128
|
}
|
|
129
129
|
return undefined;
|
|
130
130
|
},
|
|
131
|
-
websocket: WS_BRIDGE_HANDLERS,
|
|
132
131
|
});
|
|
133
132
|
});
|
|
134
133
|
|