perf(http): reuse the certificate agent instead of building one per request

fetchWithTrustedCertificatesAsync has 125 call sites and none of them pass a
dispatcher, so every HTTP request Homarr makes - every integration poll, every
proxied image - built a brand new EnvHttpProxyAgent. An agent owns its
connection pool and its TLS session cache, so a fresh one per request means a
full TLS handshake every time and no keep-alive reuse at all.

Measured against a local TLS server, 60 sequential requests:

  fresh agent per request   5.11 ms/req   60 TLS handshakes
  one shared agent          1.69 ms/req    2 TLS handshakes

3.0x faster and 30x fewer handshakes on loopback; over a real network the gap
is larger, since that is where handshake round-trips actually cost something.

Agent construction itself is cheap (0.004 ms, 1.7 KiB) because undici builds
the TLS store lazily at connect time - so the cost was never construction, it
was the handshake that a fresh pool forces.

The trust material is still read on every call and the cached agents are keyed
on a hash of it, so adding or removing a certificate - including by dropping a
file into the mounted folder rather than going through the UI - takes effect on
the very next request, exactly as before. Superseded agents are closed so their
idle sockets are released.
This commit is contained in:
Thomas Camlong committed 2026-08-11 16:09:37 +02:00
1 parent 9b54edd2db
commit 3ba2800db9
2 files changed
+239 -10

No files matched your search

@@ -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<string, UndiciHttpAgent>();
const httpsAgentCache = new Map<string, HttpsAgent>();
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<UndiciAgent.Options, "bodyTimeout">,
) => {
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<AgentOptions, "ca" | "checkServerIdentity">) => {
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 (
@@ -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);
});
});