/* * Copyright 2020 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'; 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 */ export interface Locality { region?: string; zone?: string; sub_zone?: string; } export interface Node { id: string, locality: Locality; cluster?: string; metadata?: Struct; } export interface ChannelCredsConfig { type: string; config?: object; } export interface XdsServerConfig { server_uri: string; channel_creds: ChannelCredsConfig[]; server_features: string[]; } export interface Authority { clientListenerResourceNameTemplate: string; 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']; export function serverConfigEqual(config1: XdsServerConfig, config2: XdsServerConfig): boolean { if (config1.server_uri !== config2.server_uri) { return false; } for (const feature of KNOWN_SERVER_FEATURES) { if ((feature in config1.server_features) !== (feature in config2.server_features)) { return false; } } if (config1.channel_creds.length !== config2.channel_creds.length) { return false; } for (const [index, creds1] of config1.channel_creds.entries()) { const creds2 = config2.channel_creds[index]; if (creds1.type !== creds2.type) { return false; } if (JSON.stringify(creds1) !== JSON.stringify(creds2)) { return false; } } return true; } function validateChannelCredsConfig(obj: any): ChannelCredsConfig { if (!('type' in obj)) { throw new Error('type field missing in xds_servers.channel_creds element'); } if (typeof obj.type !== 'string') { throw new Error( `xds_servers.channel_creds.type field: expected string, got ${typeof obj.type}` ); } if ('config' in obj && obj.config !== undefined) { if (typeof obj.config !== 'object' || obj.config === null) { throw new Error( 'xds_servers.channel_creds config field must be an object if provided' ); } } return { type: obj.type, config: obj.config, }; } const SUPPORTED_CHANNEL_CREDS_TYPES = [ 'google_default', 'insecure' ]; export function validateXdsServerConfig(obj: any): XdsServerConfig { if (!(typeof obj === 'object' && obj !== null)) { throw new Error('xDS server config must be an object'); } if (!('server_uri' in obj)) { throw new Error('server_uri field missing in xds_servers element'); } if (typeof obj.server_uri !== 'string') { throw new Error( `xds_servers.server_uri field: expected string, got ${typeof obj.server_uri}` ); } if (!('channel_creds' in obj)) { throw new Error('channel_creds missing in xds_servers element'); } if (!Array.isArray(obj.channel_creds)) { throw new Error( `xds_servers.channel_creds field: expected array, got ${typeof obj.channel_creds}` ); } let foundSupported = false; for (const cred of obj.channel_creds) { if (SUPPORTED_CHANNEL_CREDS_TYPES.includes(cred.type)) { foundSupported = true; } } if (!foundSupported) { throw new Error( `xds_servers.channel_creds field: must contain at least one entry with a type in [${SUPPORTED_CHANNEL_CREDS_TYPES}]` ); } if ('server_features' in obj) { if (!Array.isArray(obj.server_features)) { throw new Error( `xds_servers.server_features field: expected array, got ${typeof obj.server_features}` ); } for (const feature of obj.server_features) { if (typeof feature !== 'string') { `xds_servers.server_features field element: expected string, got ${typeof feature}` } } } return { server_uri: obj.server_uri, channel_creds: obj.channel_creds.map(validateChannelCredsConfig), server_features: obj.server_features ?? [] }; } function validateValue(obj: any): Value { if (Array.isArray(obj)) { return { kind: 'listValue', listValue: { values: obj.map((value) => validateValue(value)), }, }; } else { switch (typeof obj) { case 'boolean': return { kind: 'boolValue', boolValue: obj, }; case 'number': return { kind: 'numberValue', numberValue: obj, }; case 'string': return { kind: 'stringValue', stringValue: obj, }; case 'object': if (obj === null) { return { kind: 'nullValue', nullValue: 'NULL_VALUE', }; } else { return { kind: 'structValue', structValue: getStructFromJson(obj), }; } default: throw new Error(`Could not handle struct value of type ${typeof obj}`); } } } function getStructFromJson(obj: any): Struct { if (typeof obj !== 'object' || obj === null) { throw new Error('Invalid JSON object for Struct field'); } const fields: { [key: string]: Value } = {}; for (const [fieldName, value] of Object.entries(obj)) { fields[fieldName] = validateValue(value); } return { fields, }; } /** * Validate that the input obj is a valid Node proto message. Only checks the * fields we expect to see: id, cluster, locality, and metadata. * @param obj */ function validateNode(obj: any): Node { const result: Node = { id: '', locality: {} }; if (!('id' in obj)) { throw new Error('id field missing in node element'); } if (typeof obj.id !== 'string') { throw new Error(`node.id field: expected string, got ${typeof obj.id}`); } result.id = obj.id; if (!('locality' in obj)) { throw new Error('locality field missing in node element'); } result.locality = {}; if ('region' in obj.locality) { if (typeof obj.locality.region !== 'string') { throw new Error( `node.locality.region field: expected string, got ${typeof obj.locality .region}` ); } result.locality.region = obj.locality.region; } if ('zone' in obj.locality) { if (typeof obj.locality.zone !== 'string') { throw new Error( `node.locality.zone field: expected string, got ${typeof obj.locality .zone}` ); } result.locality.zone = obj.locality.zone; } if ('sub_zone' in obj.locality) { if (typeof obj.locality.sub_zone !== 'string') { throw new Error( `node.locality.sub_zone field: expected string, got ${typeof obj .locality.sub_zone}` ); } result.locality.sub_zone = obj.locality.sub_zone; } if ('cluster' in obj) { if (typeof obj.cluster !== 'string') { throw new Error( `node.cluster field: expected string, got ${typeof obj.cluster}` ); } result.cluster = obj.cluster; } if ('metadata' in obj) { result.metadata = getStructFromJson(obj.metadata); } return result; } function validateAuthority(obj: any, authorityName: string): Authority { if ('client_listener_resource_name_template' in obj) { if (typeof obj.client_listener_resource_name_template !== 'string') { throw new Error(`authorities[${authorityName}].client_listener_resource_name_template: expected string, got ${typeof obj.client_listener_resource_name_template}`); } if (!obj.client_listener_resource_name_template.startsWith(`xdstp://${authorityName}/`)) { throw new Error(`authorities[${authorityName}].client_listener_resource_name_template must start with "xdstp://${authorityName}/"`); } } return { clientListenerResourceNameTemplate: obj.client_listener_resource_name_template ?? `xdstp://${authorityName}/envoy.config.listener.v3.Listener/%s`, xdsServers: obj.xds_servers?.map(validateXdsServerConfig) }; } function validateAuthoritiesMap(obj: any): {[authorityName: string]: Authority} { if (!obj) { return {}; } const result: {[authorityName: string]: Authority} = {}; for (const [name, authority] of Object.entries(obj)) { result[name] = validateAuthority(authority, name); } 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.ca_certificate_file, 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); if ('server_listener_resource_name_template' in obj) { if (typeof obj.server_listener_resource_name_template !== 'string') { throw new Error(`server_listener_resource_name_template: expected string, got ${typeof obj.server_listener_resource_name_template}`); } } if (EXPERIMENTAL_FEDERATION) { if ('client_default_listener_resource_name_template' in obj) { if (typeof obj.client_default_listener_resource_name_template !== 'string') { throw new Error(`client_default_listener_resource_name_template: expected string, got ${typeof obj.client_default_listener_resource_name_template}`); } } return { xdsServers: xdsServers, node: node, authorities: validateAuthoritiesMap(obj.authorities), clientDefaultListenerResourceNameTemplate: obj.client_default_listener_resource_name_template ?? '%s', serverListenerResourceNameTemplate: obj.server_listener_resource_name_template ?? null, certificateProviders: validateCertificateProvidersMap(obj.certificate_providers) }; } else { return { xdsServers: xdsServers, node: node, authorities: {}, clientDefaultListenerResourceNameTemplate: '%s', serverListenerResourceNameTemplate: obj.server_listener_resource_name_template ?? null, certificateProviders: validateCertificateProvidersMap(obj.certificate_providers) }; } } let loadedBootstrapInfo: BootstrapInfo | null = null; /** * Load the bootstrap information from the location determined by the * GRPC_XDS_BOOTSTRAP environment variable, or if that is unset, from the * GRPC_XDS_BOOTSTRAP_CONFIG environment variable. The value is cached, so any * calls after the first will just return the cached value. * @returns */ export function loadBootstrapInfo(): BootstrapInfo { if (loadedBootstrapInfo !== null) { return loadedBootstrapInfo; } /** * If GRPC_XDS_BOOTSTRAP exists * then use its value as the name of the bootstrap file. * * If the file is missing or the contents of the file are malformed, * return an error. */ const bootstrapPath = process.env.GRPC_XDS_BOOTSTRAP; if (bootstrapPath) { let rawBootstrap: string; try { rawBootstrap = fs.readFileSync(bootstrapPath, { encoding: 'utf8'}); } catch (e) { throw new Error(`Failed to read xDS bootstrap file from path ${bootstrapPath} with error ${(e as Error).message}`); } try { const parsedFile = JSON.parse(rawBootstrap); loadedBootstrapInfo = validateBootstrapConfig(parsedFile); return loadedBootstrapInfo; } catch (e) { throw new Error(`Failed to parse xDS bootstrap file at path ${bootstrapPath} with error ${(e as Error).message}`) } } /** * Else, if GRPC_XDS_BOOTSTRAP_CONFIG exists * then use its value as the bootstrap config. * * If the value is malformed, return an error. * * See: https://github.com/grpc/grpc-node/issues/1868 */ const bootstrapConfig = process.env.GRPC_XDS_BOOTSTRAP_CONFIG; if (bootstrapConfig) { try { const parsedConfig = JSON.parse(bootstrapConfig); loadedBootstrapInfo = validateBootstrapConfig(parsedConfig); } catch (e) { throw new Error( `Failed to parse xDS bootstrap config from environment variable GRPC_XDS_BOOTSTRAP_CONFIG with error ${(e as Error).message}` ); } return loadedBootstrapInfo; } throw new Error( 'The GRPC_XDS_BOOTSTRAP or GRPC_XDS_BOOTSTRAP_CONFIG environment variables need to be set to the path to the bootstrap file to use xDS' ); } /** * Encode a text string as a valid path of a URI, as specified in RFC-3986 section 3.3 * @param uriPath A value representing an unencoded URI path * @returns */ export function encodeURIPath(uriPath: string): string { return uriPath.replace(/[^A-Za-z0-9._~!$&^()*+,;=/-]/g, substring => encodeURIComponent(substring)); } export function formatTemplateString(templateString: string, value: string): string { if (templateString.startsWith('xdstp:')) { return templateString.replace(/%s/g, encodeURIPath(value)); } else { return templateString.replace(/%s/g, value); } }