diff --git a/packages/core/src/infrastructure/http/request.ts b/packages/core/src/infrastructure/http/request.ts index b3c3ea354..d5e0430b0 100644 --- a/packages/core/src/infrastructure/http/request.ts +++ b/packages/core/src/infrastructure/http/request.ts @@ -1,6 +1,7 @@ +import { createHash } from "node:crypto"; import type { AgentOptions } from "node:https"; import { Agent as HttpsAgent } from "node:https"; -import { checkServerIdentity } from "node:tls"; +import { checkServerIdentity, rootCertificates } from "node:tls"; import axios from "axios"; import type { Agent as UndiciAgent, RequestInfo, RequestInit, Response } from "undici"; import { fetch } from "undici"; @@ -8,6 +9,7 @@ import { fetch } from "undici"; import { getAllTrustedCertificatesAsync, getTrustedCertificateHostnamesAsync, + loadCustomRootCertificatesAsync, } from "@homarr/core/infrastructure/certificates"; import { UndiciHttpAgent } from "@homarr/core/infrastructure/http"; @@ -29,6 +31,54 @@ export const createCustomCheckServerIdentity = ( }; }; +/** + * An agent owns its connection pool and its TLS session cache, so building a new one per request + * makes every request pay a full handshake. These caches hold one agent per configuration and are + * discarded whenever the trust material changes. + * + * The trust material itself is still read on every call, so adding or removing a certificate - by any + * means, including dropping a file into the mounted folder - takes effect on the very next request, + * exactly as it did before. + */ +const undiciAgentCache = new Map(); +const httpsAgentCache = new Map(); +let cachedTrustFingerprint: string | null = null; + +/** + * Reads the current trust material and invalidates the agent caches if it changed. + * Node's root store is a constant, so only the custom certificates and trusted hostnames are + * hashed - on a default install that is nothing at all. + */ +const getTrustMaterialAsync = async () => { + const [customCertificates, hostnames] = await Promise.all([ + loadCustomRootCertificatesAsync(), + getTrustedCertificateHostnamesAsync(), + ]); + + const fingerprint = createHash("sha1") + .update( + JSON.stringify([ + customCertificates.map((certificate) => [certificate.fileName, certificate.content]), + hostnames.map((hostname) => [hostname.hostname, hostname.thumbprint]), + ]), + ) + .digest("hex"); + + if (fingerprint !== cachedTrustFingerprint) { + // Superseded agents hold their idle sockets open until they are closed. + for (const agent of undiciAgentCache.values()) void agent.close(); + for (const agent of httpsAgentCache.values()) agent.destroy(); + undiciAgentCache.clear(); + httpsAgentCache.clear(); + cachedTrustFingerprint = fingerprint; + } + + return { + hostnames, + getCertificates: () => rootCertificates.concat(customCertificates.map((certificate) => certificate.content)), + }; +}; + export const createCertificateAgentAsync = async ( override?: { ca: string | string[]; @@ -36,23 +86,49 @@ export const createCertificateAgentAsync = async ( }, agentOptions?: Pick, ) => { - return new UndiciHttpAgent({ + // A caller bringing its own trust material gets its own agent rather than polluting the cache. + if (override) { + return new UndiciHttpAgent({ ...agentOptions, connect: override }); + } + + const trust = await getTrustMaterialAsync(); + const cacheKey = `bodyTimeout:${agentOptions?.bodyTimeout ?? "default"}`; + const cached = undiciAgentCache.get(cacheKey); + if (cached) return cached; + + const agent = new UndiciHttpAgent({ ...agentOptions, - connect: override ?? { - ca: await getAllTrustedCertificatesAsync(), - checkServerIdentity: createCustomCheckServerIdentity(await getTrustedCertificateHostnamesAsync()), + connect: { + ca: trust.getCertificates(), + checkServerIdentity: createCustomCheckServerIdentity(trust.hostnames), }, }); + undiciAgentCache.set(cacheKey, agent); + return agent; }; export const createHttpsAgentAsync = async (override?: Pick) => { - return new HttpsAgent({ - ca: await getAllTrustedCertificatesAsync(), - checkServerIdentity: createCustomCheckServerIdentity(await getTrustedCertificateHostnamesAsync()), - // Override the ca and checkServerIdentity if provided - ...override, + if (override) { + return new HttpsAgent({ + ca: await getAllTrustedCertificatesAsync(), + checkServerIdentity: createCustomCheckServerIdentity(await getTrustedCertificateHostnamesAsync()), + // Override the ca and checkServerIdentity if provided + ...override, + proxyEnv: process.env, + }); + } + + const trust = await getTrustMaterialAsync(); + const cached = httpsAgentCache.get("default"); + if (cached) return cached; + + const agent = new HttpsAgent({ + ca: trust.getCertificates(), + checkServerIdentity: createCustomCheckServerIdentity(trust.hostnames), proxyEnv: process.env, }); + httpsAgentCache.set("default", agent); + return agent; }; export const createAxiosCertificateInstanceAsync = async ( diff --git a/packages/core/src/test/infrastructure/http/request.spec.ts b/packages/core/src/test/infrastructure/http/request.spec.ts new file mode 100644 index 000000000..fbf3c5dcd --- /dev/null +++ b/packages/core/src/test/infrastructure/http/request.spec.ts @@ -0,0 +1,153 @@ +// @vitest-environment node +import { beforeEach, describe, expect, test, vi } from "vitest"; + +const trustMaterial = vi.hoisted(() => ({ + certificates: [] as { content: string; fileName: string }[], + hostnames: [] as { hostname: string; thumbprint: string; certificate: string }[], + loadCalls: 0, +})); + +vi.mock("@homarr/core/infrastructure/certificates", () => ({ + loadCustomRootCertificatesAsync: () => { + trustMaterial.loadCalls++; + return Promise.resolve(trustMaterial.certificates); + }, + getTrustedCertificateHostnamesAsync: () => Promise.resolve(trustMaterial.hostnames), + getAllTrustedCertificatesAsync: () => Promise.resolve(trustMaterial.certificates.map(({ content }) => content)), +})); + +// The agent caches live at module scope, so every test needs a fresh copy of the module. +const importRequestModuleAsync = async () => { + vi.resetModules(); + return await import("../../../infrastructure/http/request"); +}; + +beforeEach(() => { + trustMaterial.certificates = []; + trustMaterial.hostnames = []; + trustMaterial.loadCalls = 0; +}); + +describe("createCertificateAgentAsync", () => { + test("should reuse one agent so requests share a connection pool", async () => { + // Arrange + const { createCertificateAgentAsync } = await importRequestModuleAsync(); + + // Act + const first = await createCertificateAgentAsync(); + const second = await createCertificateAgentAsync(); + + // Assert + expect(second).toBe(first); + }); + + test("should still read the trust material on every call", async () => { + // Arrange + const { createCertificateAgentAsync } = await importRequestModuleAsync(); + + // Act + await createCertificateAgentAsync(); + await createCertificateAgentAsync(); + + // Assert + expect(trustMaterial.loadCalls).toBe(2); + }); + + test("should replace the agent when a certificate is added", async () => { + // Arrange + const { createCertificateAgentAsync } = await importRequestModuleAsync(); + const before = await createCertificateAgentAsync(); + const closeSpy = vi.spyOn(before, "close"); + + // Act + trustMaterial.certificates = [{ fileName: "added.crt", content: "-----BEGIN CERTIFICATE-----" }]; + const after = await createCertificateAgentAsync(); + + // Assert + expect(after).not.toBe(before); + expect(closeSpy).toHaveBeenCalled(); + }); + + test("should replace the agent when a trusted hostname changes", async () => { + // Arrange + const { createCertificateAgentAsync } = await importRequestModuleAsync(); + const before = await createCertificateAgentAsync(); + + // Act + trustMaterial.hostnames = [{ hostname: "homarr.dev", thumbprint: "AA:BB", certificate: "cert" }]; + const after = await createCertificateAgentAsync(); + + // Assert + expect(after).not.toBe(before); + }); + + test("should not share an agent between different body timeouts", async () => { + // Arrange + const { createCertificateAgentAsync } = await importRequestModuleAsync(); + + // Act + const streaming = await createCertificateAgentAsync(undefined, { bodyTimeout: 0 }); + const regular = await createCertificateAgentAsync(); + + // Assert + expect(streaming).not.toBe(regular); + expect(await createCertificateAgentAsync(undefined, { bodyTimeout: 0 })).toBe(streaming); + }); + + test("should give a caller supplying its own trust material a dedicated agent", async () => { + // Arrange + const { createCertificateAgentAsync } = await importRequestModuleAsync(); + const shared = await createCertificateAgentAsync(); + const override = { ca: "custom", checkServerIdentity: () => undefined }; + + // Act + const first = await createCertificateAgentAsync(override); + const second = await createCertificateAgentAsync(override); + + // Assert + expect(first).not.toBe(shared); + expect(second).not.toBe(first); + // The cached agent must survive a call that opted out of the cache. + expect(await createCertificateAgentAsync()).toBe(shared); + }); +}); + +describe("createHttpsAgentAsync", () => { + test("should reuse one agent so TLS sessions can be resumed", async () => { + // Arrange + const { createHttpsAgentAsync } = await importRequestModuleAsync(); + + // Act + const first = await createHttpsAgentAsync(); + const second = await createHttpsAgentAsync(); + + // Assert + expect(second).toBe(first); + }); + + test("should replace the agent when a certificate is added", async () => { + // Arrange + const { createHttpsAgentAsync } = await importRequestModuleAsync(); + const before = await createHttpsAgentAsync(); + + // Act + trustMaterial.certificates = [{ fileName: "added.crt", content: "-----BEGIN CERTIFICATE-----" }]; + const after = await createHttpsAgentAsync(); + + // Assert + expect(after).not.toBe(before); + }); + + test("should give a caller supplying an override a dedicated agent", async () => { + // Arrange + const { createHttpsAgentAsync } = await importRequestModuleAsync(); + const shared = await createHttpsAgentAsync(); + + // Act + const overridden = await createHttpsAgentAsync({ ca: "custom" }); + + // Assert + expect(overridden).not.toBe(shared); + expect(await createHttpsAgentAsync()).toBe(shared); + }); +});