From b16e1c9fe4bcc000a5117ebae18b6b1b8e06ee22 Mon Sep 17 00:00:00 2001 From: Michael Lumish Date: Wed, 11 Sep 2024 17:31:41 -0700 Subject: [PATCH] grpc-js-xds: Add bootstrap certificate provider config handling --- packages/grpc-js-xds/src/xds-bootstrap.ts | 84 +++++++++++++++++++- packages/grpc-js-xds/src/xds-client.ts | 25 +++++- packages/grpc-js/src/certificate-provider.ts | 4 - packages/grpc-js/src/duration.ts | 12 +++ packages/grpc-js/src/experimental.ts | 2 +- 5 files changed, 119 insertions(+), 8 deletions(-) diff --git a/packages/grpc-js-xds/src/xds-bootstrap.ts b/packages/grpc-js-xds/src/xds-bootstrap.ts index 2216e35f7..7f217a239 100644 --- a/packages/grpc-js-xds/src/xds-bootstrap.ts +++ b/packages/grpc-js-xds/src/xds-bootstrap.ts @@ -19,6 +19,11 @@ import * as fs from 'fs'; import { EXPERIMENTAL_FEDERATION } from './environment'; import { Struct } from './generated/google/protobuf/Struct'; import { Value } from './generated/google/protobuf/Value'; +import { experimental } from '@grpc/grpc-js'; + +import parseDuration = experimental.parseDuration; +import durationToMs = experimental.durationToMs; +import FileWatcherCertificateProviderConfig = experimental.FileWatcherCertificateProviderConfig; /* eslint-disable @typescript-eslint/no-explicit-any */ @@ -51,12 +56,20 @@ export interface Authority { xdsServers?: XdsServerConfig[]; } +export type PluginConfig = FileWatcherCertificateProviderConfig; + +export interface CertificateProviderConfig { + pluginName: string; + config: PluginConfig; +} + export interface BootstrapInfo { xdsServers: XdsServerConfig[]; node: Node; authorities: {[authorityName: string]: Authority}; clientDefaultListenerResourceNameTemplate: string; serverListenerResourceNameTemplate: string | null; + certificateProviders: {[instanceName: string]: CertificateProviderConfig}; } const KNOWN_SERVER_FEATURES = ['ignore_resource_deletion']; @@ -306,6 +319,71 @@ function validateAuthoritiesMap(obj: any): {[authorityName: string]: Authority} return result; } +function validateFileWatcherPluginConfig(obj: any, instanceName: string): FileWatcherCertificateProviderConfig { + if ('certificate_file' in obj && typeof obj.certificate_file !== 'string') { + throw new Error(`certificate_providers[${instanceName}].config.certificate_file: expected string, got ${typeof obj.certificate_file}`); + } + if ('private_key_file' in obj && typeof obj.private_key_file !== 'string') { + throw new Error(`certificate_providers[${instanceName}].config.private_key_file: expected string, got ${typeof obj.private_key_file}`); + } + if ('ca_certificate_file' in obj && typeof obj.ca_certificate_file !== 'string') { + throw new Error(`certificate_providers[${instanceName}].config.ca_certificate_file: expected string, got ${typeof obj.ca_certificate_file}`); + } + if (typeof obj.refresh_interval !== 'string') { + throw new Error(`certificate_providers[${instanceName}].config.refresh_interval: expected string, got ${typeof obj.refresh_interval}`); + } + if (('private_key_file' in obj) !== ('certificate_file' in obj)) { + throw new Error(`certificate_providers[${instanceName}].config: private_key_file and certificate_file must be provided or omitted together`); + } + if (!('private_key_file' in obj) && !('ca_certificate_file' in obj)) { + throw new Error(`certificate_providers[${instanceName}].config: either private_key_file and certificate_file or ca_certificate_file must be set`); + } + const refreshDuration = parseDuration(obj.refresh_interval); + if (!refreshDuration) { + throw new Error(`certificate_providers[${instanceName}].config.refresh_interval: failed to parse duration from value ${obj.refresh_interval}`); + } + return { + certificateFile: obj.certificate_file, + privateKeyFile: obj.private_key_file, + caCertificateFile: obj.caCertificateFile, + refreshIntervalMs: durationToMs(refreshDuration) + }; +} + +const pluginConfigValidators: {[typeName: string]: (obj: any, instanceName: string) => PluginConfig} = { + 'file_watcher': validateFileWatcherPluginConfig +}; + +function validateCertificateProvider(obj: any, instanceName: string): CertificateProviderConfig { + if (!('plugin_name' in obj) || typeof obj.plugin_name !== 'string') { + throw new Error(`certificate_providers[${instanceName}].plugin_name: expected string, got ${typeof obj.plugin_name}`); + } + if (!(obj.plugin_name in pluginConfigValidators)) { + throw new Error(`certificate_providers[${instanceName}]: unknown plugin_name ${obj.plugin_name}`); + } + if (!obj.config) { + throw new Error(`certificate_providers[${instanceName}].config: expected object, got ${typeof obj.config}`); + } + if (!(obj.plugin_name in pluginConfigValidators)) { + throw new Error(`certificate_providers[${instanceName}].config: unknown plugin_name ${obj.plugin_name}`); + } + return { + pluginName: obj.plugin_name, + config: pluginConfigValidators[obj.plugin_name]!(obj.config, instanceName) + }; +} + +function validateCertificateProvidersMap(obj: any): {[instanceName: string]: CertificateProviderConfig} { + if (!obj) { + return {}; + } + const result: {[instanceName: string]: CertificateProviderConfig} = {}; + for (const [name, provider] of Object.entries(obj)) { + result[name] = validateCertificateProvider(provider, name); + } + return result; +} + export function validateBootstrapConfig(obj: any): BootstrapInfo { const xdsServers = obj.xds_servers.map(validateXdsServerConfig); const node = validateNode(obj.node); @@ -325,7 +403,8 @@ export function validateBootstrapConfig(obj: any): BootstrapInfo { node: node, authorities: validateAuthoritiesMap(obj.authorities), clientDefaultListenerResourceNameTemplate: obj.client_default_listener_resource_name_template ?? '%s', - serverListenerResourceNameTemplate: obj.server_listener_resource_name_template ?? null + serverListenerResourceNameTemplate: obj.server_listener_resource_name_template ?? null, + certificateProviders: validateCertificateProvidersMap(obj.certificate_providers) }; } else { return { @@ -333,7 +412,8 @@ export function validateBootstrapConfig(obj: any): BootstrapInfo { node: node, authorities: {}, clientDefaultListenerResourceNameTemplate: '%s', - serverListenerResourceNameTemplate: obj.server_listener_resource_name_template ?? null + serverListenerResourceNameTemplate: obj.server_listener_resource_name_template ?? null, + certificateProviders: validateCertificateProvidersMap(obj.certificate_providers) }; } } diff --git a/packages/grpc-js-xds/src/xds-client.ts b/packages/grpc-js-xds/src/xds-client.ts index 550ddbb0a..f3df183ff 100644 --- a/packages/grpc-js-xds/src/xds-client.ts +++ b/packages/grpc-js-xds/src/xds-client.ts @@ -19,7 +19,7 @@ import { Channel, ChannelCredentials, ClientDuplexStream, Metadata, StatusObject import { XdsDecodeContext, XdsDecodeResult, XdsResourceType } from "./xds-resource-type/xds-resource-type"; import { XdsResourceName, parseXdsResourceName, xdsResourceNameToString } from "./resources"; import { Node } from "./generated/envoy/config/core/v3/Node"; -import { BootstrapInfo, XdsServerConfig, loadBootstrapInfo, serverConfigEqual } from "./xds-bootstrap"; +import { BootstrapInfo, CertificateProviderConfig, XdsServerConfig, loadBootstrapInfo, serverConfigEqual } from "./xds-bootstrap"; import BackoffTimeout = experimental.BackoffTimeout; import { DiscoveryRequest } from "./generated/envoy/service/discovery/v3/DiscoveryRequest"; import { DiscoveryResponse__Output } from "./generated/envoy/service/discovery/v3/DiscoveryResponse"; @@ -35,6 +35,8 @@ import { LoadStatsResponse__Output } from "./generated/envoy/service/load_stats/ import { Locality, Locality__Output } from "./generated/envoy/config/core/v3/Locality"; import { Duration } from "./generated/google/protobuf/Duration"; import { registerXdsClientWithCsds } from "./csds"; +import CertificateProvider = experimental.CertificateProvider; +import FileWatcherCertificateProvider = experimental.FileWatcherCertificateProvider; const TRACER_NAME = 'xds_client'; @@ -1111,6 +1113,15 @@ interface AuthorityState { const userAgentName = 'gRPC Node Pure JS'; +function createCertificateProvider(config: CertificateProviderConfig) { + switch (config.pluginName) { + case 'file_watcher': + return new FileWatcherCertificateProvider(config.config); + default: + throw new Error(`Unexpected certificate provider plugin name ${config.pluginName}`); + } +} + export class XdsClient { /** * authority -> authority state @@ -1119,6 +1130,8 @@ export class XdsClient { private clients: ClientMapEntry[] = []; private typeRegistry: Map = new Map(); private bootstrapInfo: BootstrapInfo | null = null; + private certificateProviderRegistry: Map = new Map(); + private certificateProviderRegistryPopulated = false; constructor(bootstrapInfoOverride?: BootstrapInfo) { if (bootstrapInfoOverride) { @@ -1298,6 +1311,16 @@ export class XdsClient { removeClusterLocalityStats(lrsServer: XdsServerConfig, clusterName: string, edsServiceName: string, locality: Locality__Output) { this.getClient(lrsServer)?.removeClusterLocalityStats(clusterName, edsServiceName, locality); } + + getCertificateProvider(instanceName: string): CertificateProvider | undefined { + if (!this.certificateProviderRegistryPopulated) { + for (const [name, config] of Object.entries(this.getBootstrapInfo().certificateProviders)) { + this.certificateProviderRegistry.set(name, createCertificateProvider(config)); + } + this.certificateProviderRegistryPopulated = true; + } + return this.certificateProviderRegistry.get(instanceName); + } } let singletonXdsClient: XdsClient | null = null; diff --git a/packages/grpc-js/src/certificate-provider.ts b/packages/grpc-js/src/certificate-provider.ts index ce5efe85e..e8bcaed8f 100644 --- a/packages/grpc-js/src/certificate-provider.ts +++ b/packages/grpc-js/src/certificate-provider.ts @@ -49,10 +49,6 @@ export interface CertificateProvider { removeIdentityCertificateListener(listener: IdentityCertificateUpdateListener): void; } -export interface CertificateProviderProvider { - getInstance(): Provider; -} - export interface FileWatcherCertificateProviderConfig { certificateFile?: string | undefined; privateKeyFile?: string | undefined; diff --git a/packages/grpc-js/src/duration.ts b/packages/grpc-js/src/duration.ts index ff77dba25..0bda0216b 100644 --- a/packages/grpc-js/src/duration.ts +++ b/packages/grpc-js/src/duration.ts @@ -34,3 +34,15 @@ export function durationToMs(duration: Duration): number { export function isDuration(value: any): value is Duration { return typeof value.seconds === 'number' && typeof value.nanos === 'number'; } + +const durationRegex = /^(\d+)(?:\.(\d+))?s$/; +export function parseDuration(value: string): Duration | null { + const match = value.match(durationRegex); + if (!match) { + return null; + } + return { + seconds: Number.parseInt(match[1], 10), + nanos: Number.parseInt(match[2].padEnd(9, '0'), 10) + }; +} diff --git a/packages/grpc-js/src/experimental.ts b/packages/grpc-js/src/experimental.ts index fa19ca896..13dfe7463 100644 --- a/packages/grpc-js/src/experimental.ts +++ b/packages/grpc-js/src/experimental.ts @@ -7,7 +7,7 @@ export { createResolver, } from './resolver'; export { GrpcUri, uriToString, splitHostPort, HostPort } from './uri-parser'; -export { Duration, durationToMs } from './duration'; +export { Duration, durationToMs, parseDuration } from './duration'; export { BackoffTimeout } from './backoff-timeout'; export { LoadBalancer,