Skip to content

Commit 7c8b231

Browse files
committed
Cache secure contexts for TLS performance
1 parent 4aa9453 commit 7c8b231

2 files changed

Lines changed: 124 additions & 59 deletions

File tree

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import * as tls from 'tls';
2+
3+
interface CacheEntry {
4+
context: tls.SecureContext;
5+
expiry: number;
6+
}
7+
8+
const DEFAULT_MAX_SIZE = 1000;
9+
const ONE_DAY = 24 * 60 * 60 * 1000;
10+
11+
export class SecureContextCache {
12+
private cache = new Map<string, CacheEntry>();
13+
14+
constructor(private maxSize: number = DEFAULT_MAX_SIZE) {}
15+
16+
async getOrCreate(
17+
key: string,
18+
factory: () => Promise<{ context: tls.SecureContext; expiry: number }>
19+
): Promise<tls.SecureContext> {
20+
const cached = this.cache.get(key);
21+
const now = Date.now();
22+
23+
if (cached && cached.expiry > now) {
24+
// Move to end for LRU
25+
this.cache.delete(key);
26+
this.cache.set(key, cached);
27+
return cached.context;
28+
}
29+
30+
// Remove expired entry if present
31+
if (cached) {
32+
this.cache.delete(key);
33+
}
34+
35+
// Create new context
36+
const { context, expiry } = await factory();
37+
38+
// Cap at 1 day, but don't exceed cert's actual expiry (unless already expired)
39+
const maxExpiry = expiry <= now
40+
? now + ONE_DAY // Already expired: cache for 1 day anyway
41+
: Math.min(expiry, now + ONE_DAY); // Non-expired: don't exceed cert expiry
42+
43+
// Evict oldest entries if full
44+
while (this.cache.size >= this.maxSize) {
45+
const oldestKey = this.cache.keys().next().value;
46+
if (oldestKey) this.cache.delete(oldestKey);
47+
}
48+
49+
this.cache.set(key, { context, expiry: maxExpiry });
50+
return context;
51+
}
52+
53+
get size(): number {
54+
return this.cache.size;
55+
}
56+
}

src/tls-handler.ts

Lines changed: 68 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,31 @@
11
import * as tls from 'tls';
2+
import * as crypto from 'node:crypto';
23

34
import { ConnectionProcessor } from './process-connection.js';
45
import { LocalCA } from './tls-certificates/local-ca.js';
5-
import { CertOptions } from './tls-certificates/cert-definitions.js';
6+
import { CertOptions, calculateCertCacheKey } from './tls-certificates/cert-definitions.js';
7+
import { SecureContextCache } from './tls-certificates/secure-context-cache.js';
68
import { tlsEndpoints } from './endpoints/endpoint-index.js';
79

10+
const secureContextCache = new SecureContextCache();
11+
12+
function calculateContextCacheKey(
13+
domain: string,
14+
certOptions: CertOptions,
15+
tlsOptions: tls.SecureContextOptions
16+
): string {
17+
const certKey = calculateCertCacheKey(domain, certOptions);
18+
const tlsKey = Object.keys(tlsOptions).length > 0
19+
? '|' + JSON.stringify(tlsOptions, Object.keys(tlsOptions).sort())
20+
: '';
21+
return certKey + tlsKey;
22+
}
23+
24+
function getCertExpiry(certPem: string): number {
25+
const cert = new crypto.X509Certificate(certPem);
26+
return new Date(cert.validTo).getTime();
27+
}
28+
829
export type CertGenerator = (domain: string, certOptions: CertOptions) => Promise<{
930
key: string,
1031
cert: string,
@@ -38,33 +59,37 @@ const MAX_SNI_PARTS = 3;
3859
const PROACTIVE_DOMAIN_REFRESH_INTERVAL = 1000 * 60 * 60 * 24; // Daily cert check for proactive domains
3960

4061
function proactivelyRefreshDomains(rootDomain: string, domains: string[], certGenerator: CertGenerator) {
41-
domains.forEach(domain => {
42-
const serverNameParts = getSNIPrefixParts(domain, rootDomain);
43-
44-
const endpoints = getEndpoints(serverNameParts);
45-
let certOptions: CertOptions = {};
46-
for (let endpoint of endpoints) {
47-
certOptions = Object.assign(certOptions, endpoint.configureCertOptions?.());
48-
}
62+
for (const domain of domains) {
63+
const { certOptions } = getEndpointConfig(getSNIPrefixParts(domain, rootDomain));
4964

50-
console.log(`Proactively checking cert at startup for ${domain}`);
51-
certGenerator(domain, certOptions).catch(e => console.error(`Failed to generate cert for ${domain}:`, e));
52-
53-
setInterval(() => {
65+
const refresh = () => {
5466
console.log(`Proactively checking cert for ${domain}`);
55-
certGenerator(domain, certOptions).catch(e => console.error(`Failed to generate cert for ${domain}:`, e));
56-
}, PROACTIVE_DOMAIN_REFRESH_INTERVAL);
57-
});
67+
certGenerator(domain, certOptions).catch(e =>
68+
console.error(`Failed to generate cert for ${domain}:`, e)
69+
);
70+
};
71+
72+
refresh();
73+
setInterval(refresh, PROACTIVE_DOMAIN_REFRESH_INTERVAL);
74+
}
5875
}
5976

60-
function getEndpoints(serverNameParts: string[]) {
61-
return serverNameParts.map((part) => {
62-
const endpoint = tlsEndpoints.find(e => e.sniPart === part)
77+
function getEndpointConfig(serverNameParts: string[]) {
78+
let certOptions: CertOptions = {};
79+
let tlsOptions: tls.SecureContextOptions = {};
80+
let alpnPreferences: string[] = [];
81+
82+
for (const part of serverNameParts) {
83+
const endpoint = tlsEndpoints.find(e => e.sniPart === part);
6384
if (!endpoint) {
6485
throw new Error(`Unknown SNI part ${part}`);
6586
}
66-
return endpoint;
67-
});
87+
certOptions = Object.assign(certOptions, endpoint.configureCertOptions?.());
88+
tlsOptions = endpoint.configureTlsOptions?.(tlsOptions) ?? tlsOptions;
89+
alpnPreferences = endpoint.configureAlpnPreferences?.(alpnPreferences) ?? alpnPreferences;
90+
}
91+
92+
return { certOptions, tlsOptions, alpnPreferences };
6893
}
6994

7095
export async function createTlsHandler(
@@ -77,24 +102,10 @@ export async function createTlsHandler(
77102
ca: [tlsConfig.ca],
78103

79104
ALPNCallback: ({ servername, protocols: clientProtocols }) => {
80-
// If specific protocol(s) are provided as part of the server name,
81-
// only negotiate those via ALPN.
82-
const serverNameParts = getSNIPrefixParts(servername, tlsConfig.rootDomain);
83-
const endpoints = getEndpoints(serverNameParts);
84-
85-
let alpnPreferences: string[] = [];
86-
for (let endpoint of endpoints) {
87-
alpnPreferences = endpoint.configureAlpnPreferences?.(alpnPreferences) ?? alpnPreferences;
88-
}
89-
90-
if (alpnPreferences.length === 0) {
91-
alpnPreferences = DEFAULT_ALPN_PROTOCOLS;
92-
}
93-
94-
// Enforce our own protocol preference over the client's (they can
95-
// specify a preference via SNI, if they so choose). This also means
96-
// we accept a preference order in our SNI as well e.g. http2.http1.*.
97-
return alpnPreferences.find(protocol => clientProtocols.includes(protocol));
105+
const { alpnPreferences } = getEndpointConfig(getSNIPrefixParts(servername, tlsConfig.rootDomain));
106+
const protocols = alpnPreferences.length > 0 ? alpnPreferences : DEFAULT_ALPN_PROTOCOLS;
107+
// Enforce our own preference order (client can specify via SNI e.g. http2.http1.*)
108+
return protocols.find(p => clientProtocols.includes(p));
98109
},
99110
SNICallback: async (domain: string, cb: Function) => {
100111
try {
@@ -109,30 +120,28 @@ export async function createTlsHandler(
109120
return cb(new Error(`Duplicate SNI parts in '${domain}'`), null);
110121
}
111122

112-
const endpoints = getEndpoints(serverNameParts);
113-
114-
let certOptions: CertOptions = {};
115-
let tlsOptions: tls.SecureContextOptions = {};
116-
for (let endpoint of endpoints) {
117-
// Cert options are merged together directly:
118-
certOptions = Object.assign(certOptions, endpoint.configureCertOptions?.());
123+
const { certOptions, tlsOptions } = getEndpointConfig(serverNameParts);
119124

120-
// TLS options may be combined in more clever ways:
121-
tlsOptions = endpoint.configureTlsOptions?.(tlsOptions) ?? tlsOptions;
122-
}
123-
124-
const certDomain = (certOptions.overridePrefix)
125+
const certDomain = certOptions.overridePrefix
125126
? `${certOptions.overridePrefix}.${tlsConfig.rootDomain}`
126127
: domain;
127128

128-
const generatedCert = await tlsConfig.generateCertificate(certDomain, certOptions);
129-
130-
cb(null, tls.createSecureContext({
131-
key: generatedCert.key,
132-
cert: generatedCert.cert,
133-
ca: generatedCert.ca,
134-
...tlsOptions
135-
}));
129+
const cacheKey = calculateContextCacheKey(certDomain, certOptions, tlsOptions);
130+
131+
const secureContext = await secureContextCache.getOrCreate(cacheKey, async () => {
132+
const cert = await tlsConfig.generateCertificate(certDomain, certOptions);
133+
return {
134+
context: tls.createSecureContext({
135+
key: cert.key,
136+
cert: cert.cert,
137+
ca: cert.ca,
138+
...tlsOptions
139+
}),
140+
expiry: getCertExpiry(cert.cert)
141+
};
142+
});
143+
144+
cb(null, secureContext);
136145
} catch (e) {
137146
console.error('TLS setup error', e);
138147
cb(e);

0 commit comments

Comments
 (0)