Skip to content

Commit 464fb0e

Browse files
cdiddy77claude
andcommitted
fix(onboard): omit TLS servername for IP-literal gateway health probe hosts
Node 25 turned DEP0123 (TLS ServerName set to an IP address) into a thrown ERR_INVALID_ARG_VALUE. The Docker-driver gateway health probe passed the probe URL hostname (127.0.0.1) as servername, so http2.connect threw on every poll, the catch treated it as not-ready, and onboarding failed the health wait against a healthy gateway. Send servername only for DNS hostnames; IP endpoints verify against the certificate's IP SANs without SNI. Fixes #7527 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Charles Parker <charles@parkerfolk.net>
1 parent dbac677 commit 464fb0e

2 files changed

Lines changed: 110 additions & 4 deletions

File tree

‎src/lib/onboard/gateway-http-readiness.test.ts‎

Lines changed: 99 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,17 @@
11
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
22
// SPDX-License-Identifier: Apache-2.0
33

4+
import { EventEmitter } from "node:events";
5+
import fs from "node:fs";
46
import http from "node:http";
7+
import http2 from "node:http2";
58
import type { AddressInfo } from "node:net";
9+
import os from "node:os";
10+
import path from "node:path";
611

7-
import { afterEach, describe, expect, it } from "vitest";
12+
import { afterEach, describe, expect, it, vi } from "vitest";
813

9-
import { isGatewayHttpReady } from "./gateway-http-readiness";
14+
import { isDockerDriverGatewayHttpReady, isGatewayHttpReady } from "./gateway-http-readiness";
1015

1116
const servers: http.Server[] = [];
1217

@@ -84,3 +89,95 @@ describe("isGatewayHttpReady abort handling", () => {
8489
await expect(probe).resolves.toBe(false);
8590
});
8691
});
92+
93+
describe("isDockerDriverGatewayHttpReady TLS servername", () => {
94+
const tlsDirs: string[] = [];
95+
96+
afterEach(() => {
97+
for (const dir of tlsDirs.splice(0)) {
98+
fs.rmSync(dir, { force: true, recursive: true });
99+
}
100+
});
101+
102+
function writeLocalTlsDir(): string {
103+
const tlsDir = fs.mkdtempSync(path.join(os.tmpdir(), "gateway-tls-"));
104+
tlsDirs.push(tlsDir);
105+
fs.writeFileSync(path.join(tlsDir, "ca.crt"), "ca");
106+
fs.mkdirSync(path.join(tlsDir, "client"));
107+
fs.writeFileSync(path.join(tlsDir, "client", "tls.crt"), "cert");
108+
fs.writeFileSync(path.join(tlsDir, "client", "tls.key"), "key");
109+
return tlsDir;
110+
}
111+
112+
function healthySessionStub(): http2.ClientHttp2Session {
113+
const stream = new EventEmitter() as EventEmitter & {
114+
close: () => void;
115+
end: (payload?: Buffer) => void;
116+
};
117+
stream.close = () => undefined;
118+
stream.end = () => {
119+
setImmediate(() => {
120+
stream.emit("response", {
121+
[http2.constants.HTTP2_HEADER_STATUS]: 200,
122+
[http2.constants.HTTP2_HEADER_CONTENT_TYPE]: "application/grpc",
123+
"grpc-status": "0",
124+
});
125+
stream.emit("end");
126+
});
127+
};
128+
const client = new EventEmitter() as EventEmitter & {
129+
close: () => void;
130+
request: () => typeof stream;
131+
};
132+
client.close = () => undefined;
133+
client.request = () => stream;
134+
return client as unknown as http2.ClientHttp2Session;
135+
}
136+
137+
function spyOnHttp2Connect() {
138+
return vi.spyOn(http2, "connect").mockImplementation(() => healthySessionStub());
139+
}
140+
141+
function connectOptionsFrom(
142+
connect: ReturnType<typeof spyOnHttp2Connect>,
143+
): Record<string, unknown> {
144+
expect(connect).toHaveBeenCalledTimes(1);
145+
return connect.mock.calls[0]?.[1] as Record<string, unknown>;
146+
}
147+
148+
it("omits servername for an IP-literal gateway host, which Node 25 rejects as a TLS ServerName (#7527)", async () => {
149+
vi.stubEnv("OPENSHELL_LOCAL_TLS_DIR", writeLocalTlsDir());
150+
const connect = spyOnHttp2Connect();
151+
152+
await expect(
153+
isDockerDriverGatewayHttpReady(1_000, "https://127.0.0.1:8080/openshell.v1.OpenShell/Health"),
154+
).resolves.toBe(true);
155+
156+
expect(connectOptionsFrom(connect)).not.toHaveProperty("servername");
157+
});
158+
159+
it("omits servername for a bracketed IPv6 gateway host (#7527)", async () => {
160+
vi.stubEnv("OPENSHELL_LOCAL_TLS_DIR", writeLocalTlsDir());
161+
const connect = spyOnHttp2Connect();
162+
163+
await expect(
164+
isDockerDriverGatewayHttpReady(1_000, "https://[::1]:8080/openshell.v1.OpenShell/Health"),
165+
).resolves.toBe(true);
166+
167+
expect(connectOptionsFrom(connect)).not.toHaveProperty("servername");
168+
});
169+
170+
it("keeps servername for a DNS gateway hostname (#7527)", async () => {
171+
vi.stubEnv("OPENSHELL_LOCAL_TLS_DIR", writeLocalTlsDir());
172+
const connect = spyOnHttp2Connect();
173+
174+
await expect(
175+
isDockerDriverGatewayHttpReady(
176+
1_000,
177+
"https://host.openshell.internal:8080/openshell.v1.OpenShell/Health",
178+
),
179+
).resolves.toBe(true);
180+
181+
expect(connectOptionsFrom(connect).servername).toBe("host.openshell.internal");
182+
});
183+
});

‎src/lib/onboard/gateway-http-readiness.ts‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
import fs from "node:fs";
1414
import http from "node:http";
1515
import http2 from "node:http2";
16+
import net from "node:net";
1617
import path from "node:path";
1718

1819
import { getGatewayHttpEndpoint, getGatewayHttpsEndpoint } from "../core/gateway-address";
@@ -229,13 +230,21 @@ function dockerDriverGatewayHttp2ConnectOptions(
229230
const localTlsDir = process.env.OPENSHELL_LOCAL_TLS_DIR;
230231
if (!localTlsDir) return undefined;
231232
try {
232-
return {
233+
const options: http2.SecureClientSessionOptions = {
233234
ca: fs.readFileSync(path.join(localTlsDir, "ca.crt")),
234235
cert: fs.readFileSync(path.join(localTlsDir, "client", "tls.crt")),
235236
key: fs.readFileSync(path.join(localTlsDir, "client", "tls.key")),
236237
rejectUnauthorized: true,
237-
servername: parsed.hostname,
238238
};
239+
// Node 25 rejects an IP-literal TLS ServerName (RFC 6066; DEP0123 became a
240+
// thrown error). For IP endpoints, certificate verification matches the
241+
// connection IP against the certificate's IP SANs without SNI, so only
242+
// send servername for DNS hostnames.
243+
const bareHostname = parsed.hostname.replace(/^\[(.*)\]$/, "$1");
244+
if (net.isIP(bareHostname) === 0) {
245+
options.servername = parsed.hostname;
246+
}
247+
return options;
239248
} catch {
240249
return undefined;
241250
}

0 commit comments

Comments
 (0)