mirror of https://github.com/grpc/grpc-node.git
Merge pull request #2802 from murgatroid99/grpc-js_certificate_provider
grpc-js: Add file watcher certificate provider, and credentials that use them
This commit is contained in:
commit
21f470824c
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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 =
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -309,6 +309,7 @@ export class PickFirstLoadBalancer implements LoadBalancer {
|
|||
this.requestReresolution();
|
||||
}
|
||||
if (this.stickyTransientFailureMode) {
|
||||
this.calculateAndReportNewState();
|
||||
return;
|
||||
}
|
||||
this.stickyTransientFailureMode = true;
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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],
|
||||
|
|
|
@ -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'];
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -74,7 +74,7 @@ describe('ChannelCredentials Implementation', () => {
|
|||
const creds = assert2.noThrowAndReturn(() =>
|
||||
ChannelCredentials.createInsecure()
|
||||
);
|
||||
assert.ok(!creds._getConnectionOptions());
|
||||
assert.ok(!creds._getConnectionOptions()?.secureContext);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -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<any, any>, callback: sendUnaryData<any>) {
|
||||
callback(null, call.request);
|
||||
},
|
||||
echoBidiStream(call: ServerDuplexStream<any, any>) {
|
||||
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);
|
||||
});
|
Loading…
Reference in New Issue