Skip to content

Commit ed53ea6

Browse files
committed
grpc-js: Add file watcher certificate provider, and credentials that use them
1 parent 7e4c8f0 commit ed53ea6

10 files changed

+642
-18
lines changed
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
/*
2+
* Copyright 2024 gRPC authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*
16+
*/
17+
18+
import * as fs from 'fs/promises';
19+
import * as logging from './logging';
20+
import { LogVerbosity } from './constants';
21+
22+
const TRACER_NAME = 'certificate_provider';
23+
24+
function trace(text: string) {
25+
logging.trace(LogVerbosity.DEBUG, TRACER_NAME, text);
26+
}
27+
28+
export interface CaCertificateUpdate {
29+
caCertificate: Buffer;
30+
}
31+
32+
export interface IdentityCertificateUpdate {
33+
certificate: Buffer;
34+
privateKey: Buffer;
35+
}
36+
37+
export interface CaCertificateUpdateListener {
38+
(update: CaCertificateUpdate | null): void;
39+
}
40+
41+
export interface IdentityCertificateUpdateListener {
42+
(update: IdentityCertificateUpdate | null) : void;
43+
}
44+
45+
export interface CertificateProvider {
46+
addCaCertificateListener(listener: CaCertificateUpdateListener): void;
47+
removeCaCertificateListener(listener: CaCertificateUpdateListener): void;
48+
addIdentityCertificateListener(listener: IdentityCertificateUpdateListener): void;
49+
removeIdentityCertificateListener(listener: IdentityCertificateUpdateListener): void;
50+
}
51+
52+
export interface CertificateProviderProvider<Provider> {
53+
getInstance(): Provider;
54+
}
55+
56+
export interface FileWatcherCertificateProviderConfig {
57+
certificateFile?: string | undefined;
58+
privateKeyFile?: string | undefined;
59+
caCertificateFile?: string | undefined;
60+
refreshIntervalMs: number;
61+
}
62+
63+
export class FileWatcherCertificateProvider implements CertificateProvider {
64+
private refreshTimer: NodeJS.Timeout | null = null;
65+
private fileReadCanceller: AbortController | null = null;
66+
private fileResultPromise: Promise<[PromiseSettledResult<Buffer>, PromiseSettledResult<Buffer>, PromiseSettledResult<Buffer>]> | null = null;
67+
private latestCaUpdate: CaCertificateUpdate | null = null;
68+
private caListeners: Set<CaCertificateUpdateListener> = new Set();
69+
private latestIdentityUpdate: IdentityCertificateUpdate | null = null;
70+
private identityListeners: Set<IdentityCertificateUpdateListener> = new Set();
71+
private lastUpdateTime: Date | null = null;
72+
73+
constructor(
74+
private config: FileWatcherCertificateProviderConfig
75+
) {
76+
if ((config.certificateFile === undefined) !== (config.privateKeyFile === undefined)) {
77+
throw new Error('certificateFile and privateKeyFile must be set or unset together');
78+
}
79+
if (config.certificateFile === undefined && config.caCertificateFile === undefined) {
80+
throw new Error('At least one of certificateFile and caCertificateFile must be set');
81+
}
82+
trace('File watcher constructed with config ' + JSON.stringify(config));
83+
}
84+
85+
private updateCertificates() {
86+
if (this.fileResultPromise) {
87+
return;
88+
}
89+
this.fileReadCanceller = new AbortController();
90+
this.fileResultPromise = Promise.allSettled([
91+
this.config.certificateFile ? fs.readFile(this.config.certificateFile, {signal: this.fileReadCanceller.signal}) : Promise.reject<Buffer>(),
92+
this.config.privateKeyFile ? fs.readFile(this.config.privateKeyFile, {signal: this.fileReadCanceller.signal}) : Promise.reject<Buffer>(),
93+
this.config.caCertificateFile ? fs.readFile(this.config.caCertificateFile, {signal: this.fileReadCanceller.signal}) : Promise.reject<Buffer>()
94+
]);
95+
this.fileResultPromise.then(([certificateResult, privateKeyResult, caCertificateResult]) => {
96+
if (this.fileReadCanceller?.signal.aborted) {
97+
return;
98+
}
99+
trace('File watcher read certificates certificate' + (certificateResult ? '!=' : '==') + 'null, privateKey' + (privateKeyResult ? '!=' : '==') + 'null, CA certificate' + (caCertificateResult ? '!=' : '==') + 'null');
100+
this.lastUpdateTime = new Date();
101+
this.fileResultPromise = null;
102+
this.fileReadCanceller = null;
103+
if (certificateResult.status === 'fulfilled' && privateKeyResult.status === 'fulfilled') {
104+
this.latestIdentityUpdate = {
105+
certificate: certificateResult.value,
106+
privateKey: privateKeyResult.value
107+
};
108+
} else {
109+
this.latestIdentityUpdate = null;
110+
}
111+
if (caCertificateResult.status === 'fulfilled') {
112+
this.latestCaUpdate = {
113+
caCertificate: caCertificateResult.value
114+
};
115+
}
116+
for (const listener of this.identityListeners) {
117+
listener(this.latestIdentityUpdate);
118+
}
119+
for (const listener of this.caListeners) {
120+
listener(this.latestCaUpdate);
121+
}
122+
});
123+
trace('File watcher initiated certificate update');
124+
}
125+
126+
private maybeStartWatchingFiles() {
127+
if (!this.refreshTimer) {
128+
/* Perform the first read immediately, but only if there was not already
129+
* a recent read, to avoid reading from the filesystem significantly more
130+
* frequently than configured if the provider quickly switches between
131+
* used and unused. */
132+
const timeSinceLastUpdate = this.lastUpdateTime ? (new Date()).getTime() - this.lastUpdateTime.getTime() : Infinity;
133+
if (timeSinceLastUpdate > this.config.refreshIntervalMs) {
134+
this.updateCertificates();
135+
}
136+
if (timeSinceLastUpdate > this.config.refreshIntervalMs * 2) {
137+
// Clear out old updates if they are definitely stale
138+
this.latestCaUpdate = null;
139+
this.latestIdentityUpdate = null;
140+
}
141+
this.refreshTimer = setInterval(() => this.updateCertificates(), this.config.refreshIntervalMs);
142+
trace('File watcher started watching');
143+
}
144+
}
145+
146+
private maybeStopWatchingFiles() {
147+
if (this.caListeners.size === 0 && this.identityListeners.size === 0) {
148+
this.fileReadCanceller?.abort();
149+
this.fileResultPromise = null;
150+
if (this.refreshTimer) {
151+
clearInterval(this.refreshTimer);
152+
this.refreshTimer = null;
153+
}
154+
}
155+
}
156+
157+
addCaCertificateListener(listener: CaCertificateUpdateListener): void {
158+
this.caListeners.add(listener);
159+
this.maybeStartWatchingFiles();
160+
process.nextTick(listener, this.latestCaUpdate);
161+
}
162+
removeCaCertificateListener(listener: CaCertificateUpdateListener): void {
163+
this.caListeners.delete(listener);
164+
this.maybeStopWatchingFiles();
165+
}
166+
addIdentityCertificateListener(listener: IdentityCertificateUpdateListener): void {
167+
this.identityListeners.add(listener);
168+
this.maybeStartWatchingFiles();
169+
process.nextTick(listener, this.latestIdentityUpdate);
170+
}
171+
removeIdentityCertificateListener(listener: IdentityCertificateUpdateListener): void {
172+
this.identityListeners.delete(listener);
173+
this.maybeStopWatchingFiles();
174+
}
175+
}

packages/grpc-js/src/channel-credentials.ts

Lines changed: 99 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424

2525
import { CallCredentials } from './call-credentials';
2626
import { CIPHER_SUITES, getDefaultRootsData } from './tls-helpers';
27+
import { CaCertificateUpdate, CaCertificateUpdateListener, CertificateProvider, IdentityCertificateUpdate, IdentityCertificateUpdateListener } from './certificate-provider';
2728

2829
// eslint-disable-next-line @typescript-eslint/no-explicit-any
2930
function verifyIsBufferOrNull(obj: any, friendlyName: string): void {
@@ -100,6 +101,14 @@ export abstract class ChannelCredentials {
100101
*/
101102
abstract _equals(other: ChannelCredentials): boolean;
102103

104+
_ref(): void {
105+
// Do nothing by default
106+
}
107+
108+
_unref(): void {
109+
// Do nothing by default
110+
}
111+
103112
/**
104113
* Return a new ChannelCredentials instance with a given set of credentials.
105114
* The resulting instance can be used to construct a Channel that communicates
@@ -172,7 +181,7 @@ class InsecureChannelCredentialsImpl extends ChannelCredentials {
172181
}
173182

174183
_getConnectionOptions(): ConnectionOptions | null {
175-
return null;
184+
return {};
176185
}
177186
_isSecure(): boolean {
178187
return false;
@@ -229,12 +238,100 @@ class SecureChannelCredentialsImpl extends ChannelCredentials {
229238
}
230239
}
231240

241+
class CertificateProviderChannelCredentialsImpl extends ChannelCredentials {
242+
private refcount: number = 0;
243+
private latestCaUpdate: CaCertificateUpdate | null = null;
244+
private latestIdentityUpdate: IdentityCertificateUpdate | null = null;
245+
private caCertificateUpdateListener: CaCertificateUpdateListener = this.handleCaCertificateUpdate.bind(this);
246+
private identityCertificateUpdateListener: IdentityCertificateUpdateListener = this.handleIdentityCertitificateUpdate.bind(this);
247+
constructor(
248+
private caCertificateProvider: CertificateProvider,
249+
private identityCertificateProvider: CertificateProvider | null,
250+
private verifyOptions: VerifyOptions | null
251+
) {
252+
super();
253+
}
254+
compose(callCredentials: CallCredentials): ChannelCredentials {
255+
const combinedCallCredentials =
256+
this.callCredentials.compose(callCredentials);
257+
return new ComposedChannelCredentialsImpl(
258+
this,
259+
combinedCallCredentials
260+
);
261+
}
262+
_getConnectionOptions(): ConnectionOptions | null {
263+
if (this.latestCaUpdate === null) {
264+
return null;
265+
}
266+
if (this.identityCertificateProvider !== null && this.latestIdentityUpdate === null) {
267+
return null;
268+
}
269+
const secureContext: SecureContext = createSecureContext({
270+
ca: this.latestCaUpdate.caCertificate,
271+
key: this.latestIdentityUpdate?.privateKey,
272+
cert: this.latestIdentityUpdate?.certificate,
273+
ciphers: CIPHER_SUITES
274+
});
275+
const options: ConnectionOptions = {
276+
secureContext: secureContext
277+
};
278+
if (this.verifyOptions?.checkServerIdentity) {
279+
options.checkServerIdentity = this.verifyOptions.checkServerIdentity;
280+
}
281+
return options;
282+
}
283+
_isSecure(): boolean {
284+
return true;
285+
}
286+
_equals(other: ChannelCredentials): boolean {
287+
if (this === other) {
288+
return true;
289+
}
290+
if (other instanceof CertificateProviderChannelCredentialsImpl) {
291+
return this.caCertificateProvider === other.caCertificateProvider &&
292+
this.identityCertificateProvider === other.identityCertificateProvider &&
293+
this.verifyOptions?.checkServerIdentity === other.verifyOptions?.checkServerIdentity;
294+
} else {
295+
return false;
296+
}
297+
}
298+
_ref(): void {
299+
if (this.refcount === 0) {
300+
this.caCertificateProvider.addCaCertificateListener(this.caCertificateUpdateListener);
301+
this.identityCertificateProvider?.addIdentityCertificateListener(this.identityCertificateUpdateListener);
302+
}
303+
this.refcount += 1;
304+
}
305+
_unref(): void {
306+
this.refcount -= 1;
307+
if (this.refcount === 0) {
308+
this.caCertificateProvider.removeCaCertificateListener(this.caCertificateUpdateListener);
309+
this.identityCertificateProvider?.removeIdentityCertificateListener(this.identityCertificateUpdateListener);
310+
}
311+
}
312+
313+
private handleCaCertificateUpdate(update: CaCertificateUpdate | null) {
314+
this.latestCaUpdate = update;
315+
}
316+
317+
private handleIdentityCertitificateUpdate(update: IdentityCertificateUpdate | null) {
318+
this.latestIdentityUpdate = update;
319+
}
320+
}
321+
322+
export function createCertificateProviderChannelCredentials(caCertificateProvider: CertificateProvider, identityCertificateProvider: CertificateProvider | null, verifyOptions?: VerifyOptions) {
323+
return new CertificateProviderChannelCredentialsImpl(caCertificateProvider, identityCertificateProvider, verifyOptions ?? null);
324+
}
325+
232326
class ComposedChannelCredentialsImpl extends ChannelCredentials {
233327
constructor(
234-
private channelCredentials: SecureChannelCredentialsImpl,
328+
private channelCredentials: ChannelCredentials,
235329
callCreds: CallCredentials
236330
) {
237331
super(callCreds);
332+
if (!channelCredentials._isSecure()) {
333+
throw new Error('Cannot compose insecure credentials');
334+
}
238335
}
239336
compose(callCredentials: CallCredentials) {
240337
const combinedCallCredentials =

packages/grpc-js/src/experimental.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,4 +53,14 @@ export {
5353
FailurePercentageEjectionConfig,
5454
} from './load-balancer-outlier-detection';
5555

56-
export { createServerCredentialsWithInterceptors } from './server-credentials';
56+
export { createServerCredentialsWithInterceptors, createCertificateProviderServerCredentials } from './server-credentials';
57+
export {
58+
CaCertificateUpdate,
59+
CaCertificateUpdateListener,
60+
IdentityCertificateUpdate,
61+
IdentityCertificateUpdateListener,
62+
CertificateProvider,
63+
FileWatcherCertificateProvider,
64+
FileWatcherCertificateProviderConfig
65+
} from './certificate-provider';
66+
export { createCertificateProviderChannelCredentials } from './channel-credentials';

packages/grpc-js/src/load-balancer-pick-first.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -309,6 +309,7 @@ export class PickFirstLoadBalancer implements LoadBalancer {
309309
this.requestReresolution();
310310
}
311311
if (this.stickyTransientFailureMode) {
312+
this.calculateAndReportNewState();
312313
return;
313314
}
314315
this.stickyTransientFailureMode = true;

0 commit comments

Comments
 (0)