diff --git a/packages/grpc-js/src/certificate-provider.ts b/packages/grpc-js/src/certificate-provider.ts new file mode 100644 index 000000000..ce5efe85e --- /dev/null +++ b/packages/grpc-js/src/certificate-provider.ts @@ -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 { + 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, PromiseSettledResult, PromiseSettledResult]> | null = null; + private latestCaUpdate: CaCertificateUpdate | null = null; + private caListeners: Set = new Set(); + private latestIdentityUpdate: IdentityCertificateUpdate | null = null; + private identityListeners: Set = 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(), + this.config.privateKeyFile ? fs.readFile(this.config.privateKeyFile) : Promise.reject(), + this.config.caCertificateFile ? fs.readFile(this.config.caCertificateFile) : Promise.reject() + ]); + 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(); + } +} diff --git a/packages/grpc-js/src/channel-credentials.ts b/packages/grpc-js/src/channel-credentials.ts index 2ed18507f..d238748b4 100644 --- a/packages/grpc-js/src/channel-credentials.ts +++ b/packages/grpc-js/src/channel-credentials.ts @@ -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 { @@ -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 @@ -172,7 +181,7 @@ class InsecureChannelCredentialsImpl extends ChannelCredentials { } _getConnectionOptions(): ConnectionOptions | null { - return null; + return {}; } _isSecure(): boolean { return false; @@ -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 = diff --git a/packages/grpc-js/src/experimental.ts b/packages/grpc-js/src/experimental.ts index 9993b7487..fa19ca896 100644 --- a/packages/grpc-js/src/experimental.ts +++ b/packages/grpc-js/src/experimental.ts @@ -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'; diff --git a/packages/grpc-js/src/load-balancer-pick-first.ts b/packages/grpc-js/src/load-balancer-pick-first.ts index e042e1161..81c817e3e 100644 --- a/packages/grpc-js/src/load-balancer-pick-first.ts +++ b/packages/grpc-js/src/load-balancer-pick-first.ts @@ -309,6 +309,7 @@ export class PickFirstLoadBalancer implements LoadBalancer { this.requestReresolution(); } if (this.stickyTransientFailureMode) { + this.calculateAndReportNewState(); return; } this.stickyTransientFailureMode = true; diff --git a/packages/grpc-js/src/server-credentials.ts b/packages/grpc-js/src/server-credentials.ts index c22593057..b77bdb249 100644 --- a/packages/grpc-js/src/server-credentials.ts +++ b/packages/grpc-js/src/server-credentials.ts @@ -19,6 +19,7 @@ import { SecureServerOptions } from 'http2'; import { CIPHER_SUITES, getDefaultRootsData } from './tls-helpers'; import { SecureContextOptions } from 'tls'; import { ServerInterceptor } from '.'; +import { CaCertificateUpdate, CaCertificateUpdateListener, CertificateProvider, IdentityCertificateUpdate, IdentityCertificateUpdateListener } from './certificate-provider'; export interface KeyCertPair { private_key: Buffer; @@ -38,12 +39,11 @@ export abstract class ServerCredentials { _removeWatcher(watcher: SecureContextWatcher) { this.watchers.delete(watcher); } + protected getWatcherCount() { + return this.watchers.size; + } protected updateSecureContextOptions(options: SecureServerOptions | null) { - if (options) { - this.latestContextOptions = options; - } else { - this.latestContextOptions = null; - } + this.latestContextOptions = options; for (const watcher of this.watchers) { watcher(this.latestContextOptions); } @@ -219,6 +219,91 @@ class SecureServerCredentials extends ServerCredentials { } } +class CertificateProviderServerCredentials extends ServerCredentials { + 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 identityCertificateProvider: CertificateProvider, + private caCertificateProvider: CertificateProvider | null, + private requireClientCertificate: boolean + ) { + super(); + } + _addWatcher(watcher: SecureContextWatcher): void { + if (this.getWatcherCount() === 0) { + this.caCertificateProvider?.addCaCertificateListener(this.caCertificateUpdateListener); + this.identityCertificateProvider.addIdentityCertificateListener(this.identityCertificateUpdateListener); + } + super._addWatcher(watcher); + } + _removeWatcher(watcher: SecureContextWatcher): void { + super._removeWatcher(watcher); + if (this.getWatcherCount() === 0) { + this.caCertificateProvider?.removeCaCertificateListener(this.caCertificateUpdateListener); + this.identityCertificateProvider.removeIdentityCertificateListener(this.identityCertificateUpdateListener); + } + } + _isSecure(): boolean { + return true; + } + _equals(other: ServerCredentials): boolean { + if (this === other) { + return true; + } + if (!(other instanceof CertificateProviderServerCredentials)) { + return false; + } + return ( + this.caCertificateProvider === other.caCertificateProvider && + this.identityCertificateProvider === other.identityCertificateProvider && + this.requireClientCertificate === other.requireClientCertificate + ) + } + + private calculateSecureContextOptions(): SecureServerOptions | null { + if (this.latestIdentityUpdate === null) { + return null; + } + if (this.caCertificateProvider !== null && this.latestCaUpdate === null) { + return null; + } + return { + ca: this.latestCaUpdate?.caCertificate, + cert: this.latestIdentityUpdate.certificate, + key: this.latestIdentityUpdate.privateKey, + requestCert: this.latestIdentityUpdate !== null, + rejectUnauthorized: this.requireClientCertificate + }; + } + + private finalizeUpdate() { + this.updateSecureContextOptions(this.calculateSecureContextOptions()); + } + + private handleCaCertificateUpdate(update: CaCertificateUpdate | null) { + this.latestCaUpdate = update; + this.finalizeUpdate(); + } + + private handleIdentityCertitificateUpdate(update: IdentityCertificateUpdate | null) { + this.latestIdentityUpdate = update; + this.finalizeUpdate(); + } +} + +export function createCertificateProviderServerCredentials( + caCertificateProvider: CertificateProvider, + identityCertificateProvider: CertificateProvider | null, + requireClientCertificate: boolean +) { + return new CertificateProviderServerCredentials( + caCertificateProvider, + identityCertificateProvider, + requireClientCertificate); +} + class InterceptorServerCredentials extends ServerCredentials { constructor(private readonly childCredentials: ServerCredentials, private readonly interceptors: ServerInterceptor[]) { super(); diff --git a/packages/grpc-js/src/subchannel.ts b/packages/grpc-js/src/subchannel.ts index 95b600c4c..6a10a22f2 100644 --- a/packages/grpc-js/src/subchannel.ts +++ b/packages/grpc-js/src/subchannel.ts @@ -155,6 +155,7 @@ export class Subchannel { 'Subchannel constructed with options ' + JSON.stringify(options, undefined, 2) ); + credentials._ref(); } private getChannelzInfo(): SubchannelInfo { @@ -290,11 +291,21 @@ export class Subchannel { if (oldStates.indexOf(this.connectivityState) === -1) { return false; } - this.trace( - ConnectivityState[this.connectivityState] + - ' -> ' + - ConnectivityState[newState] - ); + if (errorMessage) { + this.trace( + ConnectivityState[this.connectivityState] + + ' -> ' + + ConnectivityState[newState] + + ' with error "' + errorMessage + '"' + ); + + } else { + this.trace( + ConnectivityState[this.connectivityState] + + ' -> ' + + ConnectivityState[newState] + ); + } if (this.channelzEnabled) { this.channelzTrace.addTrace( 'CT_INFO', @@ -354,6 +365,7 @@ export class Subchannel { if (this.refcount === 0) { this.channelzTrace.addTrace('CT_INFO', 'Shutting down'); unregisterChannelzRef(this.channelzRef); + this.credentials._unref(); process.nextTick(() => { this.transitionToState( [ConnectivityState.CONNECTING, ConnectivityState.READY], diff --git a/packages/grpc-js/src/transport.ts b/packages/grpc-js/src/transport.ts index 1acbab40e..0a824056e 100644 --- a/packages/grpc-js/src/transport.ts +++ b/packages/grpc-js/src/transport.ts @@ -676,8 +676,13 @@ export class Http2SubchannelConnector implements SubchannelConnector { const targetAuthority = getDefaultAuthority( proxyConnectionResult.realTarget ?? this.channelTarget ); - let connectionOptions: http2.SecureClientSessionOptions = - credentials._getConnectionOptions() || {}; + let connectionOptions: http2.SecureClientSessionOptions | null = + credentials._getConnectionOptions(); + + if (!connectionOptions) { + reject('Credentials not loaded'); + return; + } connectionOptions.maxSendHeaderBlockLength = Number.MAX_SAFE_INTEGER; if ('grpc-node.max_session_memory' in options) { connectionOptions.maxSessionMemory = @@ -800,8 +805,12 @@ export class Http2SubchannelConnector implements SubchannelConnector { * upgrade it's connection to support tls if needed. * This is a workaround for https://github.com/nodejs/node/issues/32922 * See https://github.com/grpc/grpc-node/pull/1369 for more info. */ - const connectionOptions: ConnectionOptions = - credentials._getConnectionOptions() || {}; + const connectionOptions: ConnectionOptions | null = + credentials._getConnectionOptions(); + + if (!connectionOptions) { + return Promise.reject('Credentials not loaded'); + } if ('secureContext' in connectionOptions) { connectionOptions.ALPNProtocols = ['h2']; diff --git a/packages/grpc-js/test/test-certificate-provider.ts b/packages/grpc-js/test/test-certificate-provider.ts new file mode 100644 index 000000000..6500ea857 --- /dev/null +++ b/packages/grpc-js/test/test-certificate-provider.ts @@ -0,0 +1,155 @@ +/* + * 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 assert from 'assert'; +import * as path from 'path'; +import * as fs from 'fs/promises'; +import { experimental } from '../src'; + +describe('Certificate providers', () => { + describe('File watcher', () => { + const [caPath, keyPath, certPath] = ['ca.pem', 'server1.key', 'server1.pem'].map(file => path.join(__dirname, 'fixtures', file)); + let caData: Buffer, keyData: Buffer, certData: Buffer; + before(async () => { + [caData, keyData, certData] = await Promise.all([caPath, keyPath, certPath].map(filePath => fs.readFile(filePath))); + }); + it('Should reject a config with no files', () => { + const config: experimental.FileWatcherCertificateProviderConfig = { + refreshIntervalMs: 1000 + }; + assert.throws(() => { + new experimental.FileWatcherCertificateProvider(config); + }); + }); + it('Should accept a config with just a CA certificate', () => { + const config: experimental.FileWatcherCertificateProviderConfig = { + caCertificateFile: caPath, + refreshIntervalMs: 1000 + }; + assert.doesNotThrow(() => { + new experimental.FileWatcherCertificateProvider(config); + }); + }); + it('Should accept a config with just a key and certificate', () => { + const config: experimental.FileWatcherCertificateProviderConfig = { + certificateFile: certPath, + privateKeyFile: keyPath, + refreshIntervalMs: 1000 + }; + assert.doesNotThrow(() => { + new experimental.FileWatcherCertificateProvider(config); + }); + }); + it('Should accept a config with all files', () => { + const config: experimental.FileWatcherCertificateProviderConfig = { + caCertificateFile: caPath, + certificateFile: certPath, + privateKeyFile: keyPath, + refreshIntervalMs: 1000 + }; + assert.doesNotThrow(() => { + new experimental.FileWatcherCertificateProvider(config); + }); + }); + it('Should reject a config with a key but no certificate', () => { + const config: experimental.FileWatcherCertificateProviderConfig = { + caCertificateFile: caPath, + privateKeyFile: keyPath, + refreshIntervalMs: 1000 + }; + assert.throws(() => { + new experimental.FileWatcherCertificateProvider(config); + }); + }); + it('Should reject a config with a certificate but no key', () => { + const config: experimental.FileWatcherCertificateProviderConfig = { + caCertificateFile: caPath, + privateKeyFile: keyPath, + refreshIntervalMs: 1000 + }; + assert.throws(() => { + new experimental.FileWatcherCertificateProvider(config); + }); + }); + it('Should find the CA file when configured for it', done => { + const config: experimental.FileWatcherCertificateProviderConfig = { + caCertificateFile: caPath, + refreshIntervalMs: 1000 + }; + const provider = new experimental.FileWatcherCertificateProvider(config); + const listener: experimental.CaCertificateUpdateListener = update => { + if (update) { + provider.removeCaCertificateListener(listener); + assert(update.caCertificate.equals(caData)); + done(); + } + }; + provider.addCaCertificateListener(listener); + }); + it('Should find the identity certificate files when configured for it', done => { + const config: experimental.FileWatcherCertificateProviderConfig = { + certificateFile: certPath, + privateKeyFile: keyPath, + refreshIntervalMs: 1000 + }; + const provider = new experimental.FileWatcherCertificateProvider(config); + const listener: experimental.IdentityCertificateUpdateListener = update => { + if (update) { + provider.removeIdentityCertificateListener(listener); + assert(update.certificate.equals(certData)); + assert(update.privateKey.equals(keyData)); + done(); + } + }; + provider.addIdentityCertificateListener(listener); + }); + it('Should find all files when configured for it', done => { + const config: experimental.FileWatcherCertificateProviderConfig = { + caCertificateFile: caPath, + certificateFile: certPath, + privateKeyFile: keyPath, + refreshIntervalMs: 1000 + }; + const provider = new experimental.FileWatcherCertificateProvider(config); + let seenCaUpdate = false; + let seenIdentityUpdate = false; + const caListener: experimental.CaCertificateUpdateListener = update => { + if (update) { + provider.removeCaCertificateListener(caListener); + assert(update.caCertificate.equals(caData)); + seenCaUpdate = true; + if (seenIdentityUpdate) { + done(); + } + } + }; + const identityListener: experimental.IdentityCertificateUpdateListener = update => { + if (update) { + provider.removeIdentityCertificateListener(identityListener); + assert(update.certificate.equals(certData)); + assert(update.privateKey.equals(keyData)); + seenIdentityUpdate = true; + if (seenCaUpdate) { + done(); + } + } + }; + provider.addCaCertificateListener(caListener); + provider.addIdentityCertificateListener(identityListener); + }); + }); +}); diff --git a/packages/grpc-js/test/test-channel-credentials.ts b/packages/grpc-js/test/test-channel-credentials.ts index b5c011581..dfd0cd378 100644 --- a/packages/grpc-js/test/test-channel-credentials.ts +++ b/packages/grpc-js/test/test-channel-credentials.ts @@ -74,7 +74,7 @@ describe('ChannelCredentials Implementation', () => { const creds = assert2.noThrowAndReturn(() => ChannelCredentials.createInsecure() ); - assert.ok(!creds._getConnectionOptions()); + assert.ok(!creds._getConnectionOptions()?.secureContext); }); }); diff --git a/packages/grpc-js/test/test-end-to-end.ts b/packages/grpc-js/test/test-end-to-end.ts new file mode 100644 index 000000000..c7de2d6a6 --- /dev/null +++ b/packages/grpc-js/test/test-end-to-end.ts @@ -0,0 +1,80 @@ +/* + * 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 assert from 'assert'; +import * as path from 'path'; +import { loadProtoFile } from './common'; +import { Metadata, Server, ServerDuplexStream, ServerUnaryCall, ServiceClientConstructor, ServiceError, experimental, sendUnaryData } from '../src'; +import { ServiceClient } from '../src/make-client'; + +const protoFile = path.join(__dirname, 'fixtures', 'echo_service.proto'); +const EchoService = loadProtoFile(protoFile) + .EchoService as ServiceClientConstructor; +const echoServiceImplementation = { + echo(call: ServerUnaryCall, callback: sendUnaryData) { + callback(null, call.request); + }, + echoBidiStream(call: ServerDuplexStream) { + call.on('data', data => { + call.write(data); + }); + call.on('end', () => { + call.end(); + }); + }, +}; + +describe('Client should successfully communicate with server', () => { + let server: Server | null = null; + let client: ServiceClient | null = null; + afterEach(() => { + client?.close(); + client = null; + server?.forceShutdown(); + server = null; + }) + it('With file watcher credentials', done => { + const [caPath, keyPath, certPath] = ['ca.pem', 'server1.key', 'server1.pem'].map(file => path.join(__dirname, 'fixtures', file)); + const fileWatcherConfig: experimental.FileWatcherCertificateProviderConfig = { + caCertificateFile: caPath, + certificateFile: certPath, + privateKeyFile: keyPath, + refreshIntervalMs: 1000 + }; + const certificateProvider: experimental.CertificateProvider = new experimental.FileWatcherCertificateProvider(fileWatcherConfig); + const serverCreds = experimental.createCertificateProviderServerCredentials(certificateProvider, certificateProvider, true); + const clientCreds = experimental.createCertificateProviderChannelCredentials(certificateProvider, certificateProvider); + server = new Server(); + server.addService(EchoService.service, echoServiceImplementation); + server.bindAsync('localhost:0', serverCreds, (error, port) => { + assert.ifError(error); + client = new EchoService(`localhost:${port}`, clientCreds, { + 'grpc.ssl_target_name_override': 'foo.test.google.fr', + 'grpc.default_authority': 'foo.test.google.fr' + }); + const metadata = new Metadata({waitForReady: true}); + const deadline = new Date(); + deadline.setSeconds(deadline.getSeconds() + 3); + const testMessage = { value: 'test value', value2: 3 }; + client.echo(testMessage, metadata, { deadline }, (error: ServiceError, value: any) => { + assert.ifError(error); + assert.deepStrictEqual(value, testMessage); + done(); + }); + }); + }).timeout(5000); +});