Skip to content

Commit

Permalink
Merge pull request #2802 from murgatroid99/grpc-js_certificate_provider
Browse files Browse the repository at this point in the history
grpc-js: Add file watcher certificate provider, and credentials that use them
  • Loading branch information
murgatroid99 authored Sep 6, 2024
2 parents 7e4c8f0 + ef73682 commit 21f4708
Show file tree
Hide file tree
Showing 10 changed files with 638 additions and 18 deletions.
171 changes: 171 additions & 0 deletions packages/grpc-js/src/certificate-provider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
/*
* Copyright 2024 gRPC authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/

import * as fs from 'fs/promises';
import * as logging from './logging';
import { LogVerbosity } from './constants';

const TRACER_NAME = 'certificate_provider';

function trace(text: string) {
logging.trace(LogVerbosity.DEBUG, TRACER_NAME, text);
}

export interface CaCertificateUpdate {
caCertificate: Buffer;
}

export interface IdentityCertificateUpdate {
certificate: Buffer;
privateKey: Buffer;
}

export interface CaCertificateUpdateListener {
(update: CaCertificateUpdate | null): void;
}

export interface IdentityCertificateUpdateListener {
(update: IdentityCertificateUpdate | null) : void;
}

export interface CertificateProvider {
addCaCertificateListener(listener: CaCertificateUpdateListener): void;
removeCaCertificateListener(listener: CaCertificateUpdateListener): void;
addIdentityCertificateListener(listener: IdentityCertificateUpdateListener): void;
removeIdentityCertificateListener(listener: IdentityCertificateUpdateListener): void;
}

export interface CertificateProviderProvider<Provider> {
getInstance(): Provider;
}

export interface FileWatcherCertificateProviderConfig {
certificateFile?: string | undefined;
privateKeyFile?: string | undefined;
caCertificateFile?: string | undefined;
refreshIntervalMs: number;
}

export class FileWatcherCertificateProvider implements CertificateProvider {
private refreshTimer: NodeJS.Timeout | null = null;
private fileResultPromise: Promise<[PromiseSettledResult<Buffer>, PromiseSettledResult<Buffer>, PromiseSettledResult<Buffer>]> | null = null;
private latestCaUpdate: CaCertificateUpdate | null = null;
private caListeners: Set<CaCertificateUpdateListener> = new Set();
private latestIdentityUpdate: IdentityCertificateUpdate | null = null;
private identityListeners: Set<IdentityCertificateUpdateListener> = new Set();
private lastUpdateTime: Date | null = null;

constructor(
private config: FileWatcherCertificateProviderConfig
) {
if ((config.certificateFile === undefined) !== (config.privateKeyFile === undefined)) {
throw new Error('certificateFile and privateKeyFile must be set or unset together');
}
if (config.certificateFile === undefined && config.caCertificateFile === undefined) {
throw new Error('At least one of certificateFile and caCertificateFile must be set');
}
trace('File watcher constructed with config ' + JSON.stringify(config));
}

private updateCertificates() {
if (this.fileResultPromise) {
return;
}
this.fileResultPromise = Promise.allSettled([
this.config.certificateFile ? fs.readFile(this.config.certificateFile) : Promise.reject<Buffer>(),
this.config.privateKeyFile ? fs.readFile(this.config.privateKeyFile) : Promise.reject<Buffer>(),
this.config.caCertificateFile ? fs.readFile(this.config.caCertificateFile) : Promise.reject<Buffer>()
]);
this.fileResultPromise.then(([certificateResult, privateKeyResult, caCertificateResult]) => {
if (!this.refreshTimer) {
return;
}
trace('File watcher read certificates certificate' + (certificateResult ? '!=' : '==') + 'null, privateKey' + (privateKeyResult ? '!=' : '==') + 'null, CA certificate' + (caCertificateResult ? '!=' : '==') + 'null');
this.lastUpdateTime = new Date();
this.fileResultPromise = null;
if (certificateResult.status === 'fulfilled' && privateKeyResult.status === 'fulfilled') {
this.latestIdentityUpdate = {
certificate: certificateResult.value,
privateKey: privateKeyResult.value
};
} else {
this.latestIdentityUpdate = null;
}
if (caCertificateResult.status === 'fulfilled') {
this.latestCaUpdate = {
caCertificate: caCertificateResult.value
};
}
for (const listener of this.identityListeners) {
listener(this.latestIdentityUpdate);
}
for (const listener of this.caListeners) {
listener(this.latestCaUpdate);
}
});
trace('File watcher initiated certificate update');
}

private maybeStartWatchingFiles() {
if (!this.refreshTimer) {
/* Perform the first read immediately, but only if there was not already
* a recent read, to avoid reading from the filesystem significantly more
* frequently than configured if the provider quickly switches between
* used and unused. */
const timeSinceLastUpdate = this.lastUpdateTime ? (new Date()).getTime() - this.lastUpdateTime.getTime() : Infinity;
if (timeSinceLastUpdate > this.config.refreshIntervalMs) {
this.updateCertificates();
}
if (timeSinceLastUpdate > this.config.refreshIntervalMs * 2) {
// Clear out old updates if they are definitely stale
this.latestCaUpdate = null;
this.latestIdentityUpdate = null;
}
this.refreshTimer = setInterval(() => this.updateCertificates(), this.config.refreshIntervalMs);
trace('File watcher started watching');
}
}

private maybeStopWatchingFiles() {
if (this.caListeners.size === 0 && this.identityListeners.size === 0) {
this.fileResultPromise = null;
if (this.refreshTimer) {
clearInterval(this.refreshTimer);
this.refreshTimer = null;
}
}
}

addCaCertificateListener(listener: CaCertificateUpdateListener): void {
this.caListeners.add(listener);
this.maybeStartWatchingFiles();
process.nextTick(listener, this.latestCaUpdate);
}
removeCaCertificateListener(listener: CaCertificateUpdateListener): void {
this.caListeners.delete(listener);
this.maybeStopWatchingFiles();
}
addIdentityCertificateListener(listener: IdentityCertificateUpdateListener): void {
this.identityListeners.add(listener);
this.maybeStartWatchingFiles();
process.nextTick(listener, this.latestIdentityUpdate);
}
removeIdentityCertificateListener(listener: IdentityCertificateUpdateListener): void {
this.identityListeners.delete(listener);
this.maybeStopWatchingFiles();
}
}
101 changes: 99 additions & 2 deletions packages/grpc-js/src/channel-credentials.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {

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

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

_ref(): void {
// Do nothing by default
}

_unref(): void {
// Do nothing by default
}

/**
* Return a new ChannelCredentials instance with a given set of credentials.
* The resulting instance can be used to construct a Channel that communicates
Expand Down Expand Up @@ -172,7 +181,7 @@ class InsecureChannelCredentialsImpl extends ChannelCredentials {
}

_getConnectionOptions(): ConnectionOptions | null {
return null;
return {};
}
_isSecure(): boolean {
return false;
Expand Down Expand Up @@ -229,12 +238,100 @@ class SecureChannelCredentialsImpl extends ChannelCredentials {
}
}

class CertificateProviderChannelCredentialsImpl extends ChannelCredentials {
private refcount: number = 0;
private latestCaUpdate: CaCertificateUpdate | null = null;
private latestIdentityUpdate: IdentityCertificateUpdate | null = null;
private caCertificateUpdateListener: CaCertificateUpdateListener = this.handleCaCertificateUpdate.bind(this);
private identityCertificateUpdateListener: IdentityCertificateUpdateListener = this.handleIdentityCertitificateUpdate.bind(this);
constructor(
private caCertificateProvider: CertificateProvider,
private identityCertificateProvider: CertificateProvider | null,
private verifyOptions: VerifyOptions | null
) {
super();
}
compose(callCredentials: CallCredentials): ChannelCredentials {
const combinedCallCredentials =
this.callCredentials.compose(callCredentials);
return new ComposedChannelCredentialsImpl(
this,
combinedCallCredentials
);
}
_getConnectionOptions(): ConnectionOptions | null {
if (this.latestCaUpdate === null) {
return null;
}
if (this.identityCertificateProvider !== null && this.latestIdentityUpdate === null) {
return null;
}
const secureContext: SecureContext = createSecureContext({
ca: this.latestCaUpdate.caCertificate,
key: this.latestIdentityUpdate?.privateKey,
cert: this.latestIdentityUpdate?.certificate,
ciphers: CIPHER_SUITES
});
const options: ConnectionOptions = {
secureContext: secureContext
};
if (this.verifyOptions?.checkServerIdentity) {
options.checkServerIdentity = this.verifyOptions.checkServerIdentity;
}
return options;
}
_isSecure(): boolean {
return true;
}
_equals(other: ChannelCredentials): boolean {
if (this === other) {
return true;
}
if (other instanceof CertificateProviderChannelCredentialsImpl) {
return this.caCertificateProvider === other.caCertificateProvider &&
this.identityCertificateProvider === other.identityCertificateProvider &&
this.verifyOptions?.checkServerIdentity === other.verifyOptions?.checkServerIdentity;
} else {
return false;
}
}
_ref(): void {
if (this.refcount === 0) {
this.caCertificateProvider.addCaCertificateListener(this.caCertificateUpdateListener);
this.identityCertificateProvider?.addIdentityCertificateListener(this.identityCertificateUpdateListener);
}
this.refcount += 1;
}
_unref(): void {
this.refcount -= 1;
if (this.refcount === 0) {
this.caCertificateProvider.removeCaCertificateListener(this.caCertificateUpdateListener);
this.identityCertificateProvider?.removeIdentityCertificateListener(this.identityCertificateUpdateListener);
}
}

private handleCaCertificateUpdate(update: CaCertificateUpdate | null) {
this.latestCaUpdate = update;
}

private handleIdentityCertitificateUpdate(update: IdentityCertificateUpdate | null) {
this.latestIdentityUpdate = update;
}
}

export function createCertificateProviderChannelCredentials(caCertificateProvider: CertificateProvider, identityCertificateProvider: CertificateProvider | null, verifyOptions?: VerifyOptions) {
return new CertificateProviderChannelCredentialsImpl(caCertificateProvider, identityCertificateProvider, verifyOptions ?? null);
}

class ComposedChannelCredentialsImpl extends ChannelCredentials {
constructor(
private channelCredentials: SecureChannelCredentialsImpl,
private channelCredentials: ChannelCredentials,
callCreds: CallCredentials
) {
super(callCreds);
if (!channelCredentials._isSecure()) {
throw new Error('Cannot compose insecure credentials');
}
}
compose(callCredentials: CallCredentials) {
const combinedCallCredentials =
Expand Down
12 changes: 11 additions & 1 deletion packages/grpc-js/src/experimental.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,4 +53,14 @@ export {
FailurePercentageEjectionConfig,
} from './load-balancer-outlier-detection';

export { createServerCredentialsWithInterceptors } from './server-credentials';
export { createServerCredentialsWithInterceptors, createCertificateProviderServerCredentials } from './server-credentials';
export {
CaCertificateUpdate,
CaCertificateUpdateListener,
IdentityCertificateUpdate,
IdentityCertificateUpdateListener,
CertificateProvider,
FileWatcherCertificateProvider,
FileWatcherCertificateProviderConfig
} from './certificate-provider';
export { createCertificateProviderChannelCredentials } from './channel-credentials';
1 change: 1 addition & 0 deletions packages/grpc-js/src/load-balancer-pick-first.ts
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,7 @@ export class PickFirstLoadBalancer implements LoadBalancer {
this.requestReresolution();
}
if (this.stickyTransientFailureMode) {
this.calculateAndReportNewState();
return;
}
this.stickyTransientFailureMode = true;
Expand Down
Loading

0 comments on commit 21f4708

Please sign in to comment.