@specific.dev/spectest 0.93.1 → 0.95.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +35 -0
- package/dist/components/emulate/entry.mjs +2 -3
- package/dist/components/index.d.ts +1 -0
- package/dist/components/index.js +1 -0
- package/dist/components/pubsub.d.ts +77 -0
- package/dist/components/pubsub.js +139 -0
- package/dist/components/s3.d.ts +3 -3
- package/dist/daemon.js +31 -31
- package/dist/harness/http-proxy.d.ts +7 -2
- package/dist/harness/http-proxy.js +17 -3
- package/dist/harness/http2-proxy.d.ts +10 -0
- package/dist/harness/http2-proxy.js +108 -0
- package/dist/harness/ingress-table.d.ts +2 -1
- package/dist/harness/tls-terminator.d.ts +3 -0
- package/dist/harness/tls-terminator.js +8 -4
- package/dist/harness/ws-bridge.d.ts +1 -3
- package/dist/harness/ws-bridge.js +3 -16
- package/dist/index.d.ts +3 -0
- package/dist/index.js +2 -0
- package/dist/ingress.d.ts +3 -0
- package/dist/ingress.js +4 -2
- package/package.json +1 -1
- package/src/components/emulate/entry.mjs +2 -3
- package/src/components/index.ts +2 -0
- package/src/components/pubsub.test.ts +88 -0
- package/src/components/pubsub.ts +182 -0
- package/src/components/s3.ts +3 -3
- package/src/daemon.ts +30 -27
- package/src/harness/http-proxy.test.ts +66 -4
- package/src/harness/http-proxy.ts +19 -3
- package/src/harness/http2-proxy.test.ts +91 -0
- package/src/harness/http2-proxy.ts +96 -0
- package/src/harness/ingress-table.ts +2 -2
- package/src/harness/tls-terminator.test.ts +29 -0
- package/src/harness/tls-terminator.ts +12 -4
- package/src/harness/ws-bridge.test.ts +8 -12
- package/src/harness/ws-bridge.ts +3 -16
- package/src/index.ts +4 -0
- package/src/ingress.ts +7 -6
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
forwardableHeaders,
|
|
7
7
|
isCorsPreflight,
|
|
8
8
|
isHopByHop,
|
|
9
|
+
upstreamRequestHeaders,
|
|
9
10
|
} from "./http-proxy";
|
|
10
11
|
|
|
11
12
|
describe("hop-by-hop headers", () => {
|
|
@@ -13,7 +14,7 @@ describe("hop-by-hop headers", () => {
|
|
|
13
14
|
expect(isHopByHop("Connection")).toBe(true);
|
|
14
15
|
expect(isHopByHop("TRANSFER-ENCODING")).toBe(true);
|
|
15
16
|
expect(isHopByHop("upgrade")).toBe(true);
|
|
16
|
-
expect(isHopByHop("host")).toBe(
|
|
17
|
+
expect(isHopByHop("host")).toBe(false);
|
|
17
18
|
});
|
|
18
19
|
|
|
19
20
|
test("ordinary headers are forwardable", () => {
|
|
@@ -23,8 +24,7 @@ describe("hop-by-hop headers", () => {
|
|
|
23
24
|
});
|
|
24
25
|
|
|
25
26
|
/** Forwarding transfer-encoding from a re-encoded body produces a
|
|
26
|
-
* response the client cannot frame
|
|
27
|
-
* service the upstream actually is. */
|
|
27
|
+
* response the client cannot frame. Host identifies the requested origin. */
|
|
28
28
|
test("forwardableHeaders drops them and keeps the rest", () => {
|
|
29
29
|
const h = new Headers({
|
|
30
30
|
host: "app.test",
|
|
@@ -34,7 +34,7 @@ describe("hop-by-hop headers", () => {
|
|
|
34
34
|
"content-type": "application/json",
|
|
35
35
|
});
|
|
36
36
|
const out = forwardableHeaders(h);
|
|
37
|
-
expect(out.get("host")).
|
|
37
|
+
expect(out.get("host")).toBe("app.test");
|
|
38
38
|
expect(out.get("connection")).toBeNull();
|
|
39
39
|
expect(out.get("transfer-encoding")).toBeNull();
|
|
40
40
|
expect(out.get("authorization")).toBe("Bearer x");
|
|
@@ -49,6 +49,68 @@ describe("hop-by-hop headers", () => {
|
|
|
49
49
|
});
|
|
50
50
|
});
|
|
51
51
|
|
|
52
|
+
describe("upstream request headers", () => {
|
|
53
|
+
test("preserves authority and stamps forwarding headers from the incoming request", () => {
|
|
54
|
+
const headers = upstreamRequestHeaders(new Headers({
|
|
55
|
+
host: "bucket.storage.test:8443",
|
|
56
|
+
"x-forwarded-host": "unrelated.test",
|
|
57
|
+
"x-forwarded-proto": "http",
|
|
58
|
+
"x-forwarded-for": "10.0.0.1",
|
|
59
|
+
authorization: "signed-request",
|
|
60
|
+
}), { proto: "https", clientIp: "10.0.0.2" });
|
|
61
|
+
expect(headers.get("host")).toBe("bucket.storage.test:8443");
|
|
62
|
+
expect(headers.get("x-forwarded-host")).toBe("bucket.storage.test:8443");
|
|
63
|
+
expect(headers.get("x-forwarded-proto")).toBe("https");
|
|
64
|
+
expect(headers.get("x-forwarded-for")).toBe("10.0.0.1, 10.0.0.2");
|
|
65
|
+
expect(headers.get("authorization")).toBe("signed-request");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test.each(["http", "https"])("%s ingress forwards Host to a separately addressed upstream", async (proto) => {
|
|
69
|
+
const upstream = Bun.serve({
|
|
70
|
+
port: 0, hostname: "127.0.0.1",
|
|
71
|
+
async fetch(req) {
|
|
72
|
+
const url = new URL(req.url);
|
|
73
|
+
return Response.json({
|
|
74
|
+
host: req.headers.get("host"),
|
|
75
|
+
forwardedHost: req.headers.get("x-forwarded-host"),
|
|
76
|
+
forwardedProto: req.headers.get("x-forwarded-proto"),
|
|
77
|
+
authorization: req.headers.get("authorization"),
|
|
78
|
+
method: req.method, path: url.pathname + url.search, body: await req.text(),
|
|
79
|
+
});
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
// Both listeners in the daemon speak HTTP; HTTPS reaches one through the
|
|
83
|
+
// TLS terminator, with proto supplied explicitly to the forwarding path.
|
|
84
|
+
const proxy = Bun.serve({
|
|
85
|
+
port: 0, hostname: "127.0.0.1",
|
|
86
|
+
fetch(req) {
|
|
87
|
+
const url = new URL(req.url);
|
|
88
|
+
const headers = upstreamRequestHeaders(req.headers, { proto });
|
|
89
|
+
return fetch(new Request(new URL(url.pathname + url.search, upstream.url), {
|
|
90
|
+
method: req.method, headers, body: req.body, redirect: "manual",
|
|
91
|
+
}));
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
try {
|
|
95
|
+
for (const host of ["storage.test", "bucket.storage.test:8443"]) {
|
|
96
|
+
const res = await fetch(new URL("/uploads/file?generation=7", proxy.url), {
|
|
97
|
+
method: "PUT", body: "contents",
|
|
98
|
+
headers: { host, authorization: "signed-request" },
|
|
99
|
+
});
|
|
100
|
+
expect(res.status).toBe(200);
|
|
101
|
+
expect(await res.json()).toEqual({
|
|
102
|
+
host, forwardedHost: host, forwardedProto: proto,
|
|
103
|
+
authorization: "signed-request", method: "PUT",
|
|
104
|
+
path: "/uploads/file?generation=7", body: "contents",
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
} finally {
|
|
108
|
+
proxy.stop(true);
|
|
109
|
+
upstream.stop(true);
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
52
114
|
describe("preflight detection", () => {
|
|
53
115
|
const req = (method: string, headers: Record<string, string>) =>
|
|
54
116
|
new Request("http://app.test/", { method, headers });
|
|
@@ -13,8 +13,7 @@
|
|
|
13
13
|
* Forwarding `transfer-encoding` or `content-length` from a re-encoded
|
|
14
14
|
* body is the classic way to produce a response the client cannot frame;
|
|
15
15
|
* forwarding `connection` leaks our keep-alive intent to the upstream.
|
|
16
|
-
* `host`
|
|
17
|
-
* the service it actually is, not the public name the client asked for.
|
|
16
|
+
* `host` identifies the requested origin and must survive the proxy hop.
|
|
18
17
|
*/
|
|
19
18
|
export const HOP_BY_HOP_HEADERS: ReadonlySet<string> = new Set([
|
|
20
19
|
"connection",
|
|
@@ -25,7 +24,6 @@ export const HOP_BY_HOP_HEADERS: ReadonlySet<string> = new Set([
|
|
|
25
24
|
"trailers",
|
|
26
25
|
"transfer-encoding",
|
|
27
26
|
"upgrade",
|
|
28
|
-
"host",
|
|
29
27
|
]);
|
|
30
28
|
|
|
31
29
|
export function isHopByHop(name: string): boolean {
|
|
@@ -41,6 +39,24 @@ export function forwardableHeaders(headers: Headers): Headers {
|
|
|
41
39
|
return out;
|
|
42
40
|
}
|
|
43
41
|
|
|
42
|
+
/** Preserve the requested Host (including its port) while dialing the service's
|
|
43
|
+
* internal address. HTTP and WebSocket forwarding share this policy. */
|
|
44
|
+
export function upstreamRequestHeaders(
|
|
45
|
+
headers: Headers,
|
|
46
|
+
opts: { proto: string; clientIp?: string },
|
|
47
|
+
): Headers {
|
|
48
|
+
const out = forwardableHeaders(headers);
|
|
49
|
+
const priorXff = headers.get("x-forwarded-for");
|
|
50
|
+
const xff = opts.clientIp
|
|
51
|
+
? priorXff ? `${priorXff}, ${opts.clientIp}` : opts.clientIp
|
|
52
|
+
: priorXff;
|
|
53
|
+
if (xff) out.set("x-forwarded-for", xff);
|
|
54
|
+
out.set("x-forwarded-proto", opts.proto);
|
|
55
|
+
const host = headers.get("host");
|
|
56
|
+
if (host) out.set("x-forwarded-host", host);
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
|
|
44
60
|
/**
|
|
45
61
|
* A CORS preflight is an `OPTIONS` carrying both `Origin` and
|
|
46
62
|
* `Access-Control-Request-Method`. An `OPTIONS` without them is a normal
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { afterEach, expect, test } from "bun:test";
|
|
2
|
+
import http2 from "node:http2";
|
|
3
|
+
import { once } from "node:events";
|
|
4
|
+
import { startHttp2Proxy } from "./http2-proxy";
|
|
5
|
+
|
|
6
|
+
const cleanup: (() => void)[] = [];
|
|
7
|
+
afterEach(() => { for (const close of cleanup.splice(0).reverse()) close(); });
|
|
8
|
+
async function backend(handler: (stream: http2.ServerHttp2Stream, headers: http2.IncomingHttpHeaders) => void) {
|
|
9
|
+
const server = http2.createServer();
|
|
10
|
+
const sessions = new Set<http2.ServerHttp2Session>();
|
|
11
|
+
server.on("session", session => { sessions.add(session); session.on("error", () => {}); });
|
|
12
|
+
server.on("stream", (stream, headers) => { stream.on("error", () => {}); handler(stream, headers); });
|
|
13
|
+
server.listen(0, "127.0.0.1");
|
|
14
|
+
await once(server, "listening");
|
|
15
|
+
cleanup.push(() => { for (const session of sessions) session.destroy(); server.close(); });
|
|
16
|
+
return `http://127.0.0.1:${(server.address() as { port: number }).port}`;
|
|
17
|
+
}
|
|
18
|
+
async function frontend(routes: Map<string, string>) {
|
|
19
|
+
const proxy = await startHttp2Proxy({ upstreamFor: async host => routes.get(host), clientIpFor: () => "10.0.0.2" });
|
|
20
|
+
cleanup.push(() => proxy.close());
|
|
21
|
+
const session = http2.connect(`http://127.0.0.1:${proxy.port}`);
|
|
22
|
+
session.on("error", () => {});
|
|
23
|
+
cleanup.push(() => session.destroy());
|
|
24
|
+
return session;
|
|
25
|
+
}
|
|
26
|
+
async function request(session: http2.ClientHttp2Session, authority: string) {
|
|
27
|
+
const stream = session.request({ ":authority": authority, ":path": "/rpc" });
|
|
28
|
+
let text = "";
|
|
29
|
+
stream.setEncoding("utf8"); stream.on("data", chunk => { text += chunk; });
|
|
30
|
+
const [headers] = await once(stream, "response");
|
|
31
|
+
await once(stream, "end");
|
|
32
|
+
return { text, headers };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
test("routes each HTTP/2 authority, including routes added to an existing connection", async () => {
|
|
36
|
+
const a = await backend(s => { s.respond(); s.end("a"); });
|
|
37
|
+
const b = await backend(s => { s.respond(); s.end("b"); });
|
|
38
|
+
const routes = new Map([["a.test", a]]);
|
|
39
|
+
const client = await frontend(routes);
|
|
40
|
+
expect((await request(client, "a.test")).text).toBe("a");
|
|
41
|
+
routes.set("b.test", b);
|
|
42
|
+
expect((await request(client, "b.test:443")).text).toBe("b");
|
|
43
|
+
expect((await request(client, "missing.test")).headers[":status"]).toBe(421);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("streams both directions before request end and forwards request/response trailers", async () => {
|
|
47
|
+
let upstreamHeaders: http2.IncomingHttpHeaders | undefined;
|
|
48
|
+
let requestTrailers: http2.IncomingHttpHeaders | undefined;
|
|
49
|
+
const url = await backend((s, h) => {
|
|
50
|
+
upstreamHeaders = h;
|
|
51
|
+
s.respond({ ":status": 200, "content-type": "application/grpc", "metadata-bin": "AQID" }, { waitForTrailers: true });
|
|
52
|
+
s.on("trailers", t => { requestTrailers = t; });
|
|
53
|
+
s.on("data", data => s.write(data));
|
|
54
|
+
s.on("end", () => s.end());
|
|
55
|
+
s.on("wantTrailers", () => s.sendTrailers({ "grpc-status": "7", "grpc-message": "Permission%20denied" }));
|
|
56
|
+
});
|
|
57
|
+
const client = await frontend(new Map([["grpc.test", url]]));
|
|
58
|
+
const stream = client.request({ ":authority": "grpc.test", ":path": "/package.Service/Call", ":method": "POST", "content-type": "application/grpc", te: "trailers" }, { waitForTrailers: true });
|
|
59
|
+
stream.on("wantTrailers", () => stream.sendTrailers({ "request-trailer": "preserved" }));
|
|
60
|
+
const response = once(stream, "response");
|
|
61
|
+
const first = once(stream, "data");
|
|
62
|
+
stream.write(Buffer.from([0, 0, 0, 0, 3, 0, 128, 255]));
|
|
63
|
+
expect((await first)[0]).toEqual(Buffer.from([0, 0, 0, 0, 3, 0, 128, 255]));
|
|
64
|
+
const trailers = once(stream, "trailers");
|
|
65
|
+
stream.end();
|
|
66
|
+
expect((await trailers)[0]["grpc-status"]).toBe("7");
|
|
67
|
+
expect((await response)[0]["metadata-bin"]).toBe("AQID");
|
|
68
|
+
expect(requestTrailers?.["request-trailer"]).toBe("preserved");
|
|
69
|
+
expect(upstreamHeaders?.[":authority"]).toBe("grpc.test");
|
|
70
|
+
expect(upstreamHeaders?.["x-forwarded-proto"]).toBe("https");
|
|
71
|
+
expect(upstreamHeaders?.["x-forwarded-for"]).toBe("10.0.0.2");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("client cancellation reaches upstream without breaking another multiplexed RPC", async () => {
|
|
75
|
+
let cancelled!: Promise<unknown[]>;
|
|
76
|
+
const url = await backend((s, h) => {
|
|
77
|
+
if (h[":path"] === "/cancel") { cancelled = once(s, "aborted"); s.respond(); s.write("ready"); }
|
|
78
|
+
else { s.respond(); s.end("healthy"); }
|
|
79
|
+
});
|
|
80
|
+
const client = await frontend(new Map([["grpc.test", url]]));
|
|
81
|
+
const stream = client.request({ ":authority": "grpc.test", ":path": "/cancel" });
|
|
82
|
+
await once(stream, "data");
|
|
83
|
+
stream.close(http2.constants.NGHTTP2_CANCEL);
|
|
84
|
+
await cancelled;
|
|
85
|
+
expect((await request(client, "grpc.test")).text).toBe("healthy");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("an unavailable upstream produces a bounded error response", async () => {
|
|
89
|
+
const client = await frontend(new Map([["grpc.test", "http://127.0.0.1:1"]]));
|
|
90
|
+
expect((await request(client, "grpc.test")).headers[":status"]).toBe(502);
|
|
91
|
+
});
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import http2, { type IncomingHttpHeaders, type OutgoingHttpHeaders, type ServerHttp2Stream } from "node:http2";
|
|
2
|
+
import { isHopByHop } from "./http-proxy";
|
|
3
|
+
import { hostWithoutPort } from "./hostmatch";
|
|
4
|
+
|
|
5
|
+
export interface Http2ProxyOptions {
|
|
6
|
+
/** Resolve each stream independently; one connection can carry multiple authorities. */
|
|
7
|
+
upstreamFor(hostname: string): Promise<string | undefined>;
|
|
8
|
+
clientIpFor?(port: number | undefined): string | undefined;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function headersForHop(headers: IncomingHttpHeaders, pseudo: boolean): OutgoingHttpHeaders {
|
|
12
|
+
const result: OutgoingHttpHeaders = {};
|
|
13
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
14
|
+
if ((!pseudo && name.startsWith(":")) || (isHopByHop(name) && !(name === "te" && value === "trailers"))) continue;
|
|
15
|
+
result[name] = value;
|
|
16
|
+
}
|
|
17
|
+
return result;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** A stream proxy rather than fetch: gRPC depends on duplex bodies and trailers. */
|
|
21
|
+
export async function startHttp2Proxy(options: Http2ProxyOptions) {
|
|
22
|
+
const server = http2.createServer();
|
|
23
|
+
const sessions = new Set<http2.Http2Session>();
|
|
24
|
+
const track = (session: http2.Http2Session) => {
|
|
25
|
+
sessions.add(session);
|
|
26
|
+
session.on("error", () => {});
|
|
27
|
+
session.once("close", () => sessions.delete(session));
|
|
28
|
+
};
|
|
29
|
+
server.on("session", track);
|
|
30
|
+
server.on("stream", (downstream: ServerHttp2Stream, headers) => {
|
|
31
|
+
downstream.on("error", () => {});
|
|
32
|
+
void forward(downstream, headers).catch(() => {
|
|
33
|
+
if (downstream.destroyed || downstream.closed) return;
|
|
34
|
+
if (downstream.headersSent) downstream.close(http2.constants.NGHTTP2_INTERNAL_ERROR);
|
|
35
|
+
else { downstream.respond({ ":status": 502 }); downstream.end("HTTP/2 upstream unavailable"); }
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
async function forward(downstream: ServerHttp2Stream, headers: IncomingHttpHeaders) {
|
|
40
|
+
const authority = String(headers[":authority"] ?? "");
|
|
41
|
+
const upstreamUrl = await options.upstreamFor(hostWithoutPort(authority.toLowerCase()));
|
|
42
|
+
if (downstream.destroyed || downstream.closed) return;
|
|
43
|
+
if (!upstreamUrl) {
|
|
44
|
+
// A coalesced connection must not silently route an authority to its SNI backend.
|
|
45
|
+
downstream.respond({ ":status": 421 });
|
|
46
|
+
downstream.end("No HTTP/2 route for this authority");
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
const session = http2.connect(upstreamUrl);
|
|
50
|
+
track(session);
|
|
51
|
+
let requestTrailers: OutgoingHttpHeaders = {};
|
|
52
|
+
let responseTrailers: OutgoingHttpHeaders = {};
|
|
53
|
+
const requestHeaders = headersForHop(headers, true);
|
|
54
|
+
requestHeaders[":scheme"] = "http";
|
|
55
|
+
requestHeaders["x-forwarded-proto"] = "https";
|
|
56
|
+
requestHeaders["x-forwarded-host"] = authority;
|
|
57
|
+
const clientIp = options.clientIpFor?.(downstream.session?.socket.remotePort);
|
|
58
|
+
if (clientIp) requestHeaders["x-forwarded-for"] = [headers["x-forwarded-for"], clientIp].filter(Boolean).join(", ");
|
|
59
|
+
let upstream: http2.ClientHttp2Stream;
|
|
60
|
+
try { upstream = session.request(requestHeaders, { endStream: false, waitForTrailers: true }); }
|
|
61
|
+
catch (error) { session.destroy(); throw error; }
|
|
62
|
+
const fail = () => {
|
|
63
|
+
if (!downstream.closed && !downstream.destroyed) {
|
|
64
|
+
if (downstream.headersSent) downstream.close(http2.constants.NGHTTP2_INTERNAL_ERROR);
|
|
65
|
+
else { downstream.respond({ ":status": 502 }); downstream.end("HTTP/2 upstream unavailable"); }
|
|
66
|
+
}
|
|
67
|
+
session.destroy();
|
|
68
|
+
};
|
|
69
|
+
session.on("error", fail);
|
|
70
|
+
upstream.on("error", fail);
|
|
71
|
+
downstream.on("trailers", trailers => { requestTrailers = headersForHop(trailers, false); });
|
|
72
|
+
upstream.on("wantTrailers", () => { if (!upstream.destroyed && !upstream.closed) upstream.sendTrailers(requestTrailers); });
|
|
73
|
+
upstream.on("trailers", trailers => { responseTrailers = headersForHop(trailers, false); });
|
|
74
|
+
downstream.on("wantTrailers", () => {
|
|
75
|
+
if (!downstream.destroyed && !downstream.closed) downstream.sendTrailers(responseTrailers);
|
|
76
|
+
});
|
|
77
|
+
upstream.on("response", response => {
|
|
78
|
+
if (downstream.destroyed || downstream.closed) return;
|
|
79
|
+
downstream.respond(headersForHop(response, true), { waitForTrailers: true });
|
|
80
|
+
upstream.pipe(downstream);
|
|
81
|
+
});
|
|
82
|
+
downstream.on("aborted", () => upstream.close(downstream.rstCode || http2.constants.NGHTTP2_CANCEL));
|
|
83
|
+
upstream.on("aborted", () => { if (!downstream.closed) downstream.close(upstream.rstCode || http2.constants.NGHTTP2_CANCEL); });
|
|
84
|
+
downstream.once("close", () => session.destroy());
|
|
85
|
+
downstream.pipe(upstream);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
await new Promise<void>((resolve, reject) => {
|
|
89
|
+
server.once("error", reject);
|
|
90
|
+
server.listen(0, "127.0.0.1", () => { server.removeListener("error", reject); resolve(); });
|
|
91
|
+
});
|
|
92
|
+
return {
|
|
93
|
+
port: (server.address() as { port: number }).port,
|
|
94
|
+
close() { for (const session of sessions) session.destroy(); server.close(); },
|
|
95
|
+
};
|
|
96
|
+
}
|
|
@@ -53,7 +53,7 @@ import { certCovers, isWildcard, wildcardSuffix } from "./hostmatch";
|
|
|
53
53
|
*/
|
|
54
54
|
export type Route<F = unknown> =
|
|
55
55
|
| { kind: "fake"; fake: F }
|
|
56
|
-
| { kind: "proxy"; service: string; port: number };
|
|
56
|
+
| { kind: "proxy"; service: string; port: number; http2?: boolean };
|
|
57
57
|
|
|
58
58
|
/** A PEM leaf + key, as handed to `Bun.serve`'s TLS config. */
|
|
59
59
|
export interface Leaf {
|
|
@@ -70,7 +70,7 @@ export interface Leaf {
|
|
|
70
70
|
export interface IngressTables<F = unknown> {
|
|
71
71
|
/** Listen port → (hostname → route). Values are mutated in place. */
|
|
72
72
|
routesByPort: Map<number, Map<string, Route<F>>>;
|
|
73
|
-
/** SNI server name → leaf.
|
|
73
|
+
/** SNI server name → leaf. Read live by the TLS terminator without rebinding. */
|
|
74
74
|
certByHost: Map<string, Leaf>;
|
|
75
75
|
}
|
|
76
76
|
|
|
@@ -18,6 +18,9 @@ import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test
|
|
|
18
18
|
import { spawnSync } from "node:child_process";
|
|
19
19
|
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
20
20
|
import net from "node:net";
|
|
21
|
+
import http2 from "node:http2";
|
|
22
|
+
import { once } from "node:events";
|
|
23
|
+
import { startHttp2Proxy } from "./http2-proxy";
|
|
21
24
|
import { tmpdir } from "node:os";
|
|
22
25
|
import { join } from "node:path";
|
|
23
26
|
import tls from "node:tls";
|
|
@@ -238,3 +241,29 @@ describe("the pipe", () => {
|
|
|
238
241
|
expect(term.connectionCount()).toBe(0);
|
|
239
242
|
});
|
|
240
243
|
});
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
test("ALPN selects HTTP/2 only for opted-in routes and keeps HTTP/1.1 working", async () => {
|
|
247
|
+
const backend = http2.createServer();
|
|
248
|
+
const sessions = new Set<http2.Http2Session>();
|
|
249
|
+
backend.on("session", s => { sessions.add(s); s.on("error", () => {}); });
|
|
250
|
+
backend.on("stream", (s: http2.ServerHttp2Stream) => { s.respond({ ":status": 200 }); s.end("grpc backend"); });
|
|
251
|
+
backend.listen(0, "127.0.0.1");
|
|
252
|
+
await once(backend, "listening");
|
|
253
|
+
const h2 = await startHttp2Proxy({ upstreamFor: async () => `http://127.0.0.1:${(backend.address() as net.AddressInfo).port}` });
|
|
254
|
+
const optedIn = new Set(["app.test"]);
|
|
255
|
+
const terminator = await startTlsTerminator({ port: 0, hostname: "127.0.0.1", upstreamPort: plain.port,
|
|
256
|
+
http2Port: h2.port, supportsHttp2: name => optedIn.has(name), certFor: name => leaves.get(name ?? "app.test") });
|
|
257
|
+
const client = http2.connect(`https://127.0.0.1:${terminator.port}`, { servername: "app.test", ca: caPem });
|
|
258
|
+
try {
|
|
259
|
+
const stream = client.request({ ":authority": "app.test", ":path": "/" });
|
|
260
|
+
stream.setEncoding("utf8"); let body = ""; stream.on("data", chunk => { body += chunk; });
|
|
261
|
+
await once(stream, "end");
|
|
262
|
+
expect(client.alpnProtocol).toBe("h2"); expect(body).toBe("grpc backend");
|
|
263
|
+
const socket = tls.connect({ host: "127.0.0.1", port: terminator.port, servername: "late.test", ca: caPem, ALPNProtocols: ["h2", "http/1.1"] });
|
|
264
|
+
try { await once(socket, "secureConnect"); expect(socket.alpnProtocol).toBe("http/1.1"); } finally { socket.destroy(); }
|
|
265
|
+
optedIn.add("late.test");
|
|
266
|
+
const late = tls.connect({ host: "127.0.0.1", port: terminator.port, servername: "late.test", ca: caPem, ALPNProtocols: ["h2", "http/1.1"] });
|
|
267
|
+
try { await once(late, "secureConnect"); expect(late.alpnProtocol).toBe("h2"); } finally { late.destroy(); }
|
|
268
|
+
} finally { client.destroy(); terminator.close(); h2.close(); for (const s of sessions) s.destroy(); backend.close(); }
|
|
269
|
+
});
|
|
@@ -34,7 +34,8 @@
|
|
|
34
34
|
*
|
|
35
35
|
* The terminator does TLS and *nothing else*: it accepts on :443, picks a
|
|
36
36
|
* leaf by SNI, and pipes the decrypted stream to a plaintext `Bun.serve`
|
|
37
|
-
* bound on loopback.
|
|
37
|
+
* bound on loopback. Opted-in HTTP/2 routes select a separate stream router
|
|
38
|
+
* by ALPN; HTTP/1.1 and WebSockets retain the existing listener. Everything downstream — routing, fakes,
|
|
38
39
|
* interceptors, reverse-proxying, and `server.upgrade()` WebSocket
|
|
39
40
|
* bridging — is the same `Bun.serve` code that already serves :80, run
|
|
40
41
|
* unchanged. Terminating HTTP here instead (`node:https`, `(req, res)`)
|
|
@@ -69,6 +70,9 @@ export interface TlsTerminatorOptions {
|
|
|
69
70
|
hostname: string;
|
|
70
71
|
/** Loopback port of the plaintext ingress server to pipe into. */
|
|
71
72
|
upstreamPort: number;
|
|
73
|
+
/** Loopback HTTP/2 router, selected only for an opted-in SNI hostname. */
|
|
74
|
+
http2Port?: number;
|
|
75
|
+
supportsHttp2?(serverName: string): boolean;
|
|
72
76
|
/**
|
|
73
77
|
* The leaf to serve for an SNI server name, read live on every
|
|
74
78
|
* handshake — this is what lets a certificate be added with no restart.
|
|
@@ -153,6 +157,9 @@ export function startTlsTerminator(opts: TlsTerminatorOptions): Promise<TlsTermi
|
|
|
153
157
|
const server = tls.createServer(
|
|
154
158
|
{
|
|
155
159
|
...(fallback ? { cert: fallback.cert, key: fallback.key } : {}),
|
|
160
|
+
...(opts.http2Port ? { ALPNCallback: ({ servername, protocols }: { servername: string; protocols: string[] }) =>
|
|
161
|
+
protocols.includes("h2") && opts.supportsHttp2?.(servername) ? "h2"
|
|
162
|
+
: protocols.includes("http/1.1") ? "http/1.1" : undefined } : {}),
|
|
156
163
|
SNICallback: (serverName, cb) => {
|
|
157
164
|
const leaf = opts.certFor(serverName) ?? fallback;
|
|
158
165
|
// No certificate at all is a handshake we cannot complete. Answer
|
|
@@ -175,7 +182,8 @@ export function startTlsTerminator(opts: TlsTerminatorOptions): Promise<TlsTermi
|
|
|
175
182
|
// its bytes are buffered rather than dropped — which is the normal
|
|
176
183
|
// case, since a client sends its first request the instant the
|
|
177
184
|
// handshake completes.
|
|
178
|
-
const
|
|
185
|
+
const upstreamPort = client.alpnProtocol === "h2" ? opts.http2Port! : opts.upstreamPort;
|
|
186
|
+
const upstream = net.connect(upstreamPort, "127.0.0.1");
|
|
179
187
|
upstream.setTimeout(0);
|
|
180
188
|
upstream.setNoDelay(true);
|
|
181
189
|
client.setNoDelay(true);
|
|
@@ -204,7 +212,7 @@ export function startTlsTerminator(opts: TlsTerminatorOptions): Promise<TlsTermi
|
|
|
204
212
|
teardown();
|
|
205
213
|
});
|
|
206
214
|
upstream.on("error", (err) => {
|
|
207
|
-
warn(`ingress upstream :${
|
|
215
|
+
warn(`ingress upstream :${upstreamPort} failed`, err);
|
|
208
216
|
teardown();
|
|
209
217
|
});
|
|
210
218
|
client.on("close", teardown);
|
|
@@ -224,7 +232,7 @@ export function startTlsTerminator(opts: TlsTerminatorOptions): Promise<TlsTermi
|
|
|
224
232
|
server.listen(opts.port, opts.hostname, () => {
|
|
225
233
|
server.removeListener("error", reject);
|
|
226
234
|
resolve({
|
|
227
|
-
port:
|
|
235
|
+
port: (server.address() as net.AddressInfo).port,
|
|
228
236
|
clientIpFor: (upstreamPort) =>
|
|
229
237
|
upstreamPort === undefined ? undefined : clientIpByUpstreamPort.get(upstreamPort),
|
|
230
238
|
connectionCount: () => live.size,
|
|
@@ -20,7 +20,7 @@ describe("parseSubprotocols", () => {
|
|
|
20
20
|
describe("upstreamHandshakeHeaders", () => {
|
|
21
21
|
test("forwards cookies and origin, drops the client's handshake fields, stamps provenance", () => {
|
|
22
22
|
const req = new Headers({
|
|
23
|
-
host: "api.prod.specific.dev",
|
|
23
|
+
host: "api.prod.specific.dev:8443",
|
|
24
24
|
connection: "Upgrade",
|
|
25
25
|
upgrade: "websocket",
|
|
26
26
|
"sec-websocket-key": "abc==",
|
|
@@ -32,16 +32,14 @@ describe("upstreamHandshakeHeaders", () => {
|
|
|
32
32
|
authorization: "Bearer t",
|
|
33
33
|
});
|
|
34
34
|
const out = upstreamHandshakeHeaders(req, {
|
|
35
|
-
service: "api",
|
|
36
|
-
port: 4000,
|
|
37
35
|
proto: "https",
|
|
38
36
|
clientIp: "10.0.0.7",
|
|
39
37
|
});
|
|
40
38
|
expect(out["cookie"]).toBe("sid=1");
|
|
41
39
|
expect(out["origin"]).toBe("https://app.test");
|
|
42
40
|
expect(out["authorization"]).toBe("Bearer t");
|
|
43
|
-
expect(out["host"]).toBe("api:
|
|
44
|
-
expect(out["x-forwarded-host"]).toBe("api.prod.specific.dev");
|
|
41
|
+
expect(out["host"]).toBe("api.prod.specific.dev:8443");
|
|
42
|
+
expect(out["x-forwarded-host"]).toBe("api.prod.specific.dev:8443");
|
|
45
43
|
expect(out["x-forwarded-proto"]).toBe("https");
|
|
46
44
|
expect(out["x-forwarded-for"]).toBe("10.0.0.7");
|
|
47
45
|
for (const k of Object.keys(out)) {
|
|
@@ -52,8 +50,6 @@ describe("upstreamHandshakeHeaders", () => {
|
|
|
52
50
|
|
|
53
51
|
test("appends the client to an existing x-forwarded-for", () => {
|
|
54
52
|
const out = upstreamHandshakeHeaders(new Headers({ "x-forwarded-for": "1.2.3.4" }), {
|
|
55
|
-
service: "api",
|
|
56
|
-
port: 1,
|
|
57
53
|
proto: "http",
|
|
58
54
|
clientIp: "10.0.0.7",
|
|
59
55
|
});
|
|
@@ -71,7 +67,7 @@ describe("the bridge on loopback", () => {
|
|
|
71
67
|
let upstream: ReturnType<typeof Bun.serve>;
|
|
72
68
|
let bridge: ReturnType<typeof Bun.serve>;
|
|
73
69
|
/** What the upstream saw on its last handshake. */
|
|
74
|
-
let seen: { protocols: string[]; cookie: string | null; xfh: string | null } | null = null;
|
|
70
|
+
let seen: { protocols: string[]; cookie: string | null; host: string | null; xfh: string | null } | null = null;
|
|
75
71
|
|
|
76
72
|
beforeAll(() => {
|
|
77
73
|
upstream = Bun.serve({
|
|
@@ -81,6 +77,7 @@ describe("the bridge on loopback", () => {
|
|
|
81
77
|
seen = {
|
|
82
78
|
protocols,
|
|
83
79
|
cookie: req.headers.get("cookie"),
|
|
80
|
+
host: req.headers.get("host"),
|
|
84
81
|
xfh: req.headers.get("x-forwarded-host"),
|
|
85
82
|
};
|
|
86
83
|
if (protocols[1] !== "ticket-ok") return new Response("Unauthorized", { status: 401 });
|
|
@@ -110,8 +107,6 @@ describe("the bridge on loopback", () => {
|
|
|
110
107
|
async fetch(req, server) {
|
|
111
108
|
const protocols = parseSubprotocols(req.headers.get("sec-websocket-protocol"));
|
|
112
109
|
const headers = upstreamHandshakeHeaders(req.headers, {
|
|
113
|
-
service: "api",
|
|
114
|
-
port: upstream.port!,
|
|
115
110
|
proto: "https",
|
|
116
111
|
});
|
|
117
112
|
const url = new URL(req.url);
|
|
@@ -153,7 +148,7 @@ describe("the bridge on loopback", () => {
|
|
|
153
148
|
) => WebSocket;
|
|
154
149
|
const ws = new Ctor(`ws://127.0.0.1:${bridge.port}/console?x=1`, {
|
|
155
150
|
protocols,
|
|
156
|
-
headers: { cookie: "sid=abc", host: "api.prod.specific.dev" },
|
|
151
|
+
headers: { cookie: "sid=abc", host: "api.prod.specific.dev:8443" },
|
|
157
152
|
});
|
|
158
153
|
ws.binaryType = "arraybuffer";
|
|
159
154
|
const events: Array<{ kind: "open" | "message" | "close" | "error"; value?: unknown }> = [];
|
|
@@ -180,7 +175,8 @@ describe("the bridge on loopback", () => {
|
|
|
180
175
|
expect(seen).toEqual({
|
|
181
176
|
protocols: ["console", "ticket-ok"],
|
|
182
177
|
cookie: "sid=abc",
|
|
183
|
-
|
|
178
|
+
host: "api.prod.specific.dev:8443",
|
|
179
|
+
xfh: "api.prod.specific.dev:8443",
|
|
184
180
|
});
|
|
185
181
|
await Bun.sleep(20);
|
|
186
182
|
ws.send("hi");
|
package/src/harness/ws-bridge.ts
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
* without a VM.
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
|
-
import {
|
|
23
|
+
import { upstreamRequestHeaders } from "./http-proxy.js";
|
|
24
24
|
|
|
25
25
|
/** Headers that belong to the client's own WebSocket handshake. Bun's
|
|
26
26
|
* WebSocket client mints its own key and version, and the protocol list
|
|
@@ -56,33 +56,20 @@ export function parseSubprotocols(header: string | null | undefined): string[] {
|
|
|
56
56
|
* The headers the upstream handshake carries: everything forwardable from
|
|
57
57
|
* the client's request (cookies, authorization, origin, user-agent …),
|
|
58
58
|
* minus the client's own handshake fields, plus the proxy provenance
|
|
59
|
-
* headers and
|
|
59
|
+
* headers and the original Host.
|
|
60
60
|
*/
|
|
61
61
|
export function upstreamHandshakeHeaders(
|
|
62
62
|
req: Headers,
|
|
63
63
|
opts: {
|
|
64
|
-
service: string;
|
|
65
|
-
port: number;
|
|
66
64
|
proto: string;
|
|
67
65
|
clientIp?: string;
|
|
68
66
|
},
|
|
69
67
|
): Record<string, string> {
|
|
70
68
|
const out: Record<string, string> = {};
|
|
71
|
-
|
|
69
|
+
upstreamRequestHeaders(req, opts).forEach((value, key) => {
|
|
72
70
|
if (HANDSHAKE_HEADERS.has(key)) return;
|
|
73
71
|
out[key] = value;
|
|
74
72
|
});
|
|
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
73
|
return out;
|
|
87
74
|
}
|
|
88
75
|
|
package/src/index.ts
CHANGED
|
@@ -1161,6 +1161,9 @@ export interface ServiceTls {
|
|
|
1161
1161
|
/** HTTP port the service listens on inside its container. The
|
|
1162
1162
|
* daemon forwards proxied requests here over `spectest-net`. */
|
|
1163
1163
|
port: number;
|
|
1164
|
+
/** Accept HTTP/2 over TLS and forward it as plaintext HTTP/2 (h2c).
|
|
1165
|
+
* Required for gRPC. HTTP/1.1 continues to use the existing HTTP proxy. */
|
|
1166
|
+
http2?: boolean;
|
|
1164
1167
|
}
|
|
1165
1168
|
|
|
1166
1169
|
/** @internal Normalized wire representation; legacy definitions remain accepted. */
|
|
@@ -1661,6 +1664,7 @@ function validateEnvironmentConfig<S extends ServicesMap>(
|
|
|
1661
1664
|
`service "${name}" tls entry must be { hostname: string, port: number }; got ${JSON.stringify(entry)}`,
|
|
1662
1665
|
);
|
|
1663
1666
|
}
|
|
1667
|
+
if (entry.http2 !== undefined && typeof entry.http2 !== "boolean") throw new Error(`service "${name}" tls http2 must be a boolean`);
|
|
1664
1668
|
const h = entry.hostname.toLowerCase();
|
|
1665
1669
|
if (!HOSTNAME_RE.test(hostPatternBody(h))) {
|
|
1666
1670
|
throw new Error(
|
package/src/ingress.ts
CHANGED
|
@@ -40,7 +40,7 @@ export interface DnsDecl {
|
|
|
40
40
|
export interface ProxyDecl {
|
|
41
41
|
kind: "proxy";
|
|
42
42
|
hostname: string;
|
|
43
|
-
upstream: { service: string; port: number };
|
|
43
|
+
upstream: { service: string; port: number; http2?: boolean };
|
|
44
44
|
}
|
|
45
45
|
|
|
46
46
|
export type IngressDecl = CertificateDecl | DnsDecl | ProxyDecl;
|
|
@@ -137,7 +137,7 @@ export function dnsName(hostname: string, target: DnsTarget): DnsDecl {
|
|
|
137
137
|
*/
|
|
138
138
|
export function proxy(
|
|
139
139
|
hostname: string,
|
|
140
|
-
upstream: { service: string; port: number },
|
|
140
|
+
upstream: { service: string; port: number; http2?: boolean },
|
|
141
141
|
): ProxyDecl {
|
|
142
142
|
assertNameOrWildcard(hostname, "proxy()");
|
|
143
143
|
if (!upstream || typeof upstream.service !== "string" || typeof upstream.port !== "number") {
|
|
@@ -145,6 +145,7 @@ export function proxy(
|
|
|
145
145
|
`proxy(${JSON.stringify(hostname)}): upstream must be { service: string, port: number }`,
|
|
146
146
|
);
|
|
147
147
|
}
|
|
148
|
+
if (upstream.http2 !== undefined && typeof upstream.http2 !== "boolean") throw new Error("proxy(): http2 must be a boolean");
|
|
148
149
|
return { kind: "proxy", hostname: hostname.toLowerCase(), upstream };
|
|
149
150
|
}
|
|
150
151
|
|
|
@@ -197,7 +198,7 @@ export interface LoweredIngress {
|
|
|
197
198
|
/** One leaf cert per group; SANs = the group's hostnames. */
|
|
198
199
|
certificates: { hostnames: string[] }[];
|
|
199
200
|
/** Reverse-proxy routes: hostname → upstream service:port. */
|
|
200
|
-
proxies: { hostname: string; service: string; port: number }[];
|
|
201
|
+
proxies: { hostname: string; service: string; port: number; http2?: boolean }[];
|
|
201
202
|
/** Hostnames that resolve to the daemon ingress gateway (DNS registry
|
|
202
203
|
* + `--add-host` on every container). Covers fakes, TLS proxies, and
|
|
203
204
|
* any `dnsName(h, { ingress: true })`. */
|
|
@@ -226,7 +227,7 @@ function resolveSelf(service: string, selfKey: string): string {
|
|
|
226
227
|
*/
|
|
227
228
|
export function lowerIngress(project: Project): LoweredIngress {
|
|
228
229
|
const certificates: { hostnames: string[] }[] = [];
|
|
229
|
-
const proxies: { hostname: string; service: string; port: number }[] = [];
|
|
230
|
+
const proxies: { hostname: string; service: string; port: number; http2?: boolean }[] = [];
|
|
230
231
|
const ingressSet = new Set<string>();
|
|
231
232
|
const aliasesByService: Record<string, string[]> = {};
|
|
232
233
|
const wildcards: { pattern: string; target: DnsTarget }[] = [];
|
|
@@ -241,7 +242,7 @@ export function lowerIngress(project: Project): LoweredIngress {
|
|
|
241
242
|
break;
|
|
242
243
|
case "proxy": {
|
|
243
244
|
const service = resolveSelf(decl.upstream.service, selfKey);
|
|
244
|
-
proxies.push({ hostname: decl.hostname, service, port: decl.upstream.port });
|
|
245
|
+
proxies.push({ hostname: decl.hostname, service, port: decl.upstream.port, ...(decl.upstream.http2 ? { http2: true } : {}) });
|
|
245
246
|
// A proxied hostname must route to the daemon. A wildcard one can
|
|
246
247
|
// only be answered by the resolver (there is no `--add-host` for a
|
|
247
248
|
// pattern), so it goes in the wildcard table pointed at the ingress.
|
|
@@ -278,7 +279,7 @@ export function lowerIngress(project: Project): LoweredIngress {
|
|
|
278
279
|
// `tls: [{ hostname, port }]` → cert + TLS-terminated reverse proxy.
|
|
279
280
|
for (const entry of (svc as ServiceConfig).tls ?? []) {
|
|
280
281
|
applyDecl(certificate([entry.hostname]), name);
|
|
281
|
-
applyDecl(proxy(entry.hostname, { service: name, port: entry.port }), name);
|
|
282
|
+
applyDecl(proxy(entry.hostname, { service: name, port: entry.port, ...(entry.http2 ? { http2: true } : {}) }), name);
|
|
282
283
|
}
|
|
283
284
|
// `hostnames: [h]` → plain peer alias (no daemon hop).
|
|
284
285
|
for (const h of (svc as ServiceConfig).hostnames ?? []) {
|