Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 99 additions & 2 deletions src/lib/onboard/gateway-http-readiness.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { EventEmitter } from "node:events";
import fs from "node:fs";
import http from "node:http";
import http2 from "node:http2";
import type { AddressInfo } from "node:net";
import os from "node:os";
import path from "node:path";

import { afterEach, describe, expect, it } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";

import { isGatewayHttpReady } from "./gateway-http-readiness";
import { isDockerDriverGatewayHttpReady, isGatewayHttpReady } from "./gateway-http-readiness";

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

Expand Down Expand Up @@ -84,3 +89,95 @@ describe("isGatewayHttpReady abort handling", () => {
await expect(probe).resolves.toBe(false);
});
});

describe("isDockerDriverGatewayHttpReady TLS servername", () => {
const tlsDirs: string[] = [];

afterEach(() => {
for (const dir of tlsDirs.splice(0)) {
fs.rmSync(dir, { force: true, recursive: true });
}
});

function writeLocalTlsDir(): string {
const tlsDir = fs.mkdtempSync(path.join(os.tmpdir(), "gateway-tls-"));
tlsDirs.push(tlsDir);
fs.writeFileSync(path.join(tlsDir, "ca.crt"), "ca");
fs.mkdirSync(path.join(tlsDir, "client"));
fs.writeFileSync(path.join(tlsDir, "client", "tls.crt"), "cert");
fs.writeFileSync(path.join(tlsDir, "client", "tls.key"), "key");
return tlsDir;
}

function healthySessionStub(): http2.ClientHttp2Session {
const stream = new EventEmitter() as EventEmitter & {
close: () => void;
end: (payload?: Buffer) => void;
};
stream.close = () => undefined;
stream.end = () => {
setImmediate(() => {
stream.emit("response", {
[http2.constants.HTTP2_HEADER_STATUS]: 200,
[http2.constants.HTTP2_HEADER_CONTENT_TYPE]: "application/grpc",
"grpc-status": "0",
});
stream.emit("end");
});
};
const client = new EventEmitter() as EventEmitter & {
close: () => void;
request: () => typeof stream;
};
client.close = () => undefined;
client.request = () => stream;
return client as unknown as http2.ClientHttp2Session;
}

function spyOnHttp2Connect() {
return vi.spyOn(http2, "connect").mockImplementation(() => healthySessionStub());
}

function connectOptionsFrom(
connect: ReturnType<typeof spyOnHttp2Connect>,
): Record<string, unknown> {
expect(connect).toHaveBeenCalledTimes(1);
return connect.mock.calls[0]?.[1] as Record<string, unknown>;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

it("omits servername for an IP-literal gateway host, which Node 25 rejects as a TLS ServerName (#7527)", async () => {
vi.stubEnv("OPENSHELL_LOCAL_TLS_DIR", writeLocalTlsDir());
const connect = spyOnHttp2Connect();

await expect(
isDockerDriverGatewayHttpReady(1_000, "https://127.0.0.1:8080/openshell.v1.OpenShell/Health"),
).resolves.toBe(true);

expect(connectOptionsFrom(connect)).not.toHaveProperty("servername");
});

it("omits servername for a bracketed IPv6 gateway host (#7527)", async () => {
vi.stubEnv("OPENSHELL_LOCAL_TLS_DIR", writeLocalTlsDir());
const connect = spyOnHttp2Connect();

await expect(
isDockerDriverGatewayHttpReady(1_000, "https://[::1]:8080/openshell.v1.OpenShell/Health"),
).resolves.toBe(true);

expect(connectOptionsFrom(connect)).not.toHaveProperty("servername");
});

it("keeps servername for a DNS gateway hostname (#7527)", async () => {
vi.stubEnv("OPENSHELL_LOCAL_TLS_DIR", writeLocalTlsDir());
const connect = spyOnHttp2Connect();

await expect(
isDockerDriverGatewayHttpReady(
1_000,
"https://host.openshell.internal:8080/openshell.v1.OpenShell/Health",
),
).resolves.toBe(true);

expect(connectOptionsFrom(connect).servername).toBe("host.openshell.internal");
});
});
13 changes: 11 additions & 2 deletions src/lib/onboard/gateway-http-readiness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import fs from "node:fs";
import http from "node:http";
import http2 from "node:http2";
import net from "node:net";
import path from "node:path";

import { getGatewayHttpEndpoint, getGatewayHttpsEndpoint } from "../core/gateway-address";
Expand Down Expand Up @@ -229,13 +230,21 @@ function dockerDriverGatewayHttp2ConnectOptions(
const localTlsDir = process.env.OPENSHELL_LOCAL_TLS_DIR;
if (!localTlsDir) return undefined;
try {
return {
const options: http2.SecureClientSessionOptions = {
ca: fs.readFileSync(path.join(localTlsDir, "ca.crt")),
cert: fs.readFileSync(path.join(localTlsDir, "client", "tls.crt")),
key: fs.readFileSync(path.join(localTlsDir, "client", "tls.key")),
rejectUnauthorized: true,
servername: parsed.hostname,
};
// Node 25 rejects an IP-literal TLS ServerName (RFC 6066; DEP0123 became a
// thrown error). For IP endpoints, certificate verification matches the
// connection IP against the certificate's IP SANs without SNI, so only
// send servername for DNS hostnames.
const bareHostname = parsed.hostname.replace(/^\[(.*)\]$/, "$1");
if (net.isIP(bareHostname) === 0) {
options.servername = parsed.hostname;
}
return options;
} catch {
return undefined;
}
Expand Down
Loading