From 61349e09ab4b3f8d7530f76d6221eb829f267447 Mon Sep 17 00:00:00 2001 From: Michael Lumish Date: Tue, 11 Feb 2025 14:37:25 -0800 Subject: [PATCH 1/2] grpc-js-xds: NACK with detailed validation errors --- .../cluster-resource-type.ts | 432 +++++++++++------- .../endpoint-resource-type.ts | 96 ++-- .../listener-resource-type.ts | 276 +++++------ .../route-config-resource-type.ts | 118 +++-- .../xds-resource-type/xds-resource-type.ts | 12 + 5 files changed, 545 insertions(+), 389 deletions(-) diff --git a/packages/grpc-js-xds/src/xds-resource-type/cluster-resource-type.ts b/packages/grpc-js-xds/src/xds-resource-type/cluster-resource-type.ts index d6442622f..b27b39f45 100644 --- a/packages/grpc-js-xds/src/xds-resource-type/cluster-resource-type.ts +++ b/packages/grpc-js-xds/src/xds-resource-type/cluster-resource-type.ts @@ -16,7 +16,7 @@ */ import { CDS_TYPE_URL, CLUSTER_CONFIG_TYPE_URL, decodeSingleResource, UPSTREAM_TLS_CONTEXT_TYPE_URL } from "../resources"; -import { XdsDecodeContext, XdsDecodeResult, XdsResourceType } from "./xds-resource-type"; +import { ValidationResult, XdsDecodeContext, XdsDecodeResult, XdsResourceType } from "./xds-resource-type"; import { LoadBalancingConfig, experimental, logVerbosity } from "@grpc/grpc-js"; import { XdsServerConfig } from "../xds-bootstrap"; import { Duration__Output } from "../generated/google/protobuf/Duration"; @@ -33,6 +33,8 @@ import FailurePercentageEjectionConfig = experimental.FailurePercentageEjectionC import parseLoadBalancingConfig = experimental.parseLoadBalancingConfig; import { StringMatcher__Output } from "../generated/envoy/type/matcher/v3/StringMatcher"; import { CertificateValidationContext__Output } from "../generated/envoy/extensions/transport_sockets/tls/v3/CertificateValidationContext"; +import { SocketAddress__Output } from "../generated/envoy/config/core/v3/SocketAddress"; +import { TransportSocket__Output } from "../generated/envoy/config/core/v3/TransportSocket"; const TRACER_NAME = 'xds_client'; @@ -137,20 +139,124 @@ export class ClusterResourceType extends XdsResourceType { return percentage.value >=0 && percentage.value <= 100; } - private validateResource(context: XdsDecodeContext, message: Cluster__Output): CdsUpdate | null { - let lbPolicyConfig: LoadBalancingConfig; + private validateTransportSocket(context: XdsDecodeContext, transportSocket: TransportSocket__Output): ValidationResult { + const errors: string[] = []; + if (!transportSocket.typed_config) { + errors.push('transport_socket.typed_config unset'); + return { + valid: false, + errors + }; + } + if (transportSocket.typed_config.type_url !== UPSTREAM_TLS_CONTEXT_TYPE_URL) { + errors.push(`Unexpected transport_socket.typed_config.type_url: ${transportSocket.typed_config.type_url}`); + return { + valid: false, + errors + }; + } + const upstreamTlsContext = decodeSingleResource(UPSTREAM_TLS_CONTEXT_TYPE_URL, transportSocket.typed_config.value); + if (!upstreamTlsContext.common_tls_context) { + errors.push('UpstreamTlsContext.common_tls_context unset'); + return { + valid: false, + errors + }; + } + trace('Decoded UpstreamTlsContext: ' + JSON.stringify(upstreamTlsContext, undefined, 2)); + const commonTlsContext = upstreamTlsContext.common_tls_context; + if (commonTlsContext.tls_certificate_provider_instance) { + if (!(commonTlsContext.tls_certificate_provider_instance.instance_name in context.bootstrap.certificateProviders)) { + errors.push(`Unmatched UpstreamTlsContext.tls_certificate_provider_instance.instance_name: ${commonTlsContext.tls_certificate_provider_instance.instance_name}`); + } + } else { + if (commonTlsContext.tls_certificates.length > 0 ) { + errors.push('UpstreamTlsContext.common_tls_contexttls_certificate_provider_instance unset but UpstreamTlsContext.common_tls_context.tls_certificates populated'); + } + if (commonTlsContext.tls_certificate_sds_secret_configs.length > 0) { + errors.push('UpstreamTlsContext.common_tls_contexttls_certificate_provider_instance unset but UpstreamTlsContext.common_tls_context.tls_certificates_sds_secret_config populated'); + } + } + if (commonTlsContext.tls_params) { + errors.push('UpstreamTlsContext.common_tls_context.tls_params set'); + } + if (commonTlsContext.custom_handshaker) { + errors.push('UpstreamTlsContext.common_tls_context.custom_handshaker set'); + } + let validationContext: CertificateValidationContext__Output | null = null; + switch (commonTlsContext.validation_context_type) { + case 'validation_context_sds_secret_config': + errors.push('Unexpected UpstreamTlsContext.common_tls_context.validation_context_sds_secret_config'); + break; + case 'validation_context': + if (!commonTlsContext.validation_context) { + errors.push('Empty UpstreamTlsContext.common_tls_context.validation_context'); + break; + } + validationContext = commonTlsContext.validation_context; + break; + case 'combined_validation_context': + if (!commonTlsContext.combined_validation_context?.default_validation_context) { + errors.push('Empty UpstreamTlsContext.common_tls_context.combined_validation_context.default_validation_context'); + break; + } + validationContext = commonTlsContext.combined_validation_context.default_validation_context; + break; + default: + errors.push(`Unsupported UpstreamTlsContext.common_tls_context.validation_context_type: ${commonTlsContext.validation_context_type}`); + } + if (validationContext) { + if (validationContext.verify_certificate_spki.length > 0) { + errors.push('ValidationContext.verify_certificate_spki populated'); + } + if (validationContext.verify_certificate_hash.length > 0) { + errors.push('ValidationContext.verify_certificate_hash populated'); + } + if (validationContext.require_signed_certificate_timestamp) { + errors.push('ValidationContext.require_signed_certificate_timestamp set'); + } + if (validationContext.crl) { + errors.push('ValidationContext.crl set'); + } + if (validationContext.custom_validator_config) { + errors.push('ValidationContext.custom_validator_config set') + } + if (validationContext.ca_certificate_provider_instance) { + if (!(validationContext.ca_certificate_provider_instance.instance_name in context.bootstrap.certificateProviders)) { + errors.push(`Unmatched ValidationContext.ca_certificate_provider_instance.instance_name: ${validationContext.ca_certificate_provider_instance.instance_name}`); + } + if (errors.length === 0) { + return { + valid: true, + result: { + caCertificateProviderInstance: validationContext.ca_certificate_provider_instance.instance_name, + identityCertificateProviderInstance: commonTlsContext.tls_certificate_provider_instance?.instance_name, + subjectAltNameMatchers: validationContext.match_subject_alt_names + } + } + } + } else { + errors.push('ValidationContext.ca_certificate_provider_instance unset'); + } + } + return { + valid: false, + errors + } + } + + private validateResource(context: XdsDecodeContext, message: Cluster__Output): ValidationResult { + /* lbPolicyConfig starts as an empty config to satisfy the type checker. + * In all cases, either it should be reassigned or an error should be set. + * Either way, this empty config should never actually be used. */ + let lbPolicyConfig: LoadBalancingConfig = {}; + const errors: string[] = []; if (EXPERIMENTAL_CUSTOM_LB_CONFIG && message.load_balancing_policy) { try { lbPolicyConfig = convertToLoadBalancingConfig(message.load_balancing_policy); - } catch (e) { - trace('LB policy config parsing failed with error ' + e); - return null; - } - try { parseLoadBalancingConfig(lbPolicyConfig); } catch (e) { - trace('LB policy config parsing failed with error ' + e); - return null; + errors.push(`load_balancing_policy parsing failed with error ${(e as Error).message}`); } } else if (message.lb_policy === 'ROUND_ROBIN') { lbPolicyConfig = { @@ -159,151 +265,103 @@ export class ClusterResourceType extends XdsResourceType { } }; } else if(EXPERIMENTAL_RING_HASH && message.lb_policy === 'RING_HASH') { - if (message.ring_hash_lb_config && message.ring_hash_lb_config.hash_function !== 'XX_HASH') { - return null; - } - const minRingSize = message.ring_hash_lb_config?.minimum_ring_size ? Number(message.ring_hash_lb_config.minimum_ring_size.value) : 1024; - if (minRingSize > 8_388_608) { - return null; - } - const maxRingSize = message.ring_hash_lb_config?.maximum_ring_size ? Number(message.ring_hash_lb_config.maximum_ring_size.value) : 8_388_608; - if (maxRingSize > 8_388_608) { - return null; - } - lbPolicyConfig = { - ring_hash: { - min_ring_size: minRingSize, - max_ring_size: maxRingSize + if (message.ring_hash_lb_config) { + if (message.ring_hash_lb_config.hash_function !== 'XX_HASH') { + errors.push(`unsupported ring_hash_lb_config.hash_function: ${message.ring_hash_lb_config.hash_function}`); } - }; + const minRingSize = message.ring_hash_lb_config.minimum_ring_size ? Number(message.ring_hash_lb_config.minimum_ring_size.value) : 1024; + if (minRingSize > 8_388_608) { + errors.push(`ring_hash_lb_config.minimum_ring_size is too large: ${minRingSize}`); + } + const maxRingSize = message.ring_hash_lb_config.maximum_ring_size ? Number(message.ring_hash_lb_config.maximum_ring_size.value) : 8_388_608; + if (maxRingSize > 8_388_608) { + errors.push(`ring_hash_lb_config.maximum_ring_size is too large: ${maxRingSize}`); + } + lbPolicyConfig = { + ring_hash: { + min_ring_size: minRingSize, + max_ring_size: maxRingSize + } + }; + } else { + errors.push(`lb_policy == RING_HASH but ring_hash_lb_config is unset`); + } } else { - return null; + if (EXPERIMENTAL_CUSTOM_LB_CONFIG) { + errors.push(`load_balancing_policy unset and unsupported lb_policy: ${message.lb_policy}`); + } else { + errors.push(`unsupported lb_policy: ${message.lb_policy}`); + } } if (message.lrs_server) { if (!message.lrs_server.self) { - return null; + errors.push(`lrs_server set but lrs_server.self unset`); } } if (EXPERIMENTAL_OUTLIER_DETECTION) { if (message.outlier_detection) { if (!this.validateNonnegativeDuration(message.outlier_detection.interval)) { - return null; + errors.push('outlier_detection.interval out of range'); } if (!this.validateNonnegativeDuration(message.outlier_detection.base_ejection_time)) { - return null; + errors.push('outlier_detection.base_ejection_time out of range'); } if (!this.validateNonnegativeDuration(message.outlier_detection.max_ejection_time)) { - return null; + errors.push('outlier_detection.max_ejection_time out of range'); } if (!this.validatePercentage(message.outlier_detection.max_ejection_percent)) { - return null; + errors.push('outlier_detection.max_ejection_percent out of range'); } if (!this.validatePercentage(message.outlier_detection.enforcing_success_rate)) { - return null; + errors.push('outlier_detection.enforcing_success_rate out of range'); } if (!this.validatePercentage(message.outlier_detection.failure_percentage_threshold)) { - return null; + errors.push('outlier_detection.failure_percentage_threshold out of range'); } if (!this.validatePercentage(message.outlier_detection.enforcing_failure_percentage)) { - return null; + errors.push('outlier_detection.enforcing_failure_percentage out of range'); } } } let securityUpdate: SecurityUpdate | undefined = undefined; if (message.transport_socket) { - const transportSocket = message.transport_socket; - if (!transportSocket.typed_config) { - trace('transportSocket.typed_config missing'); - return null; - } - if (transportSocket.typed_config.type_url !== UPSTREAM_TLS_CONTEXT_TYPE_URL) { - trace('Incorrect transportSocket.typed_config.type_url: ' + transportSocket.typed_config.type_url) - return null; - } - const upstreamTlsContext = decodeSingleResource(UPSTREAM_TLS_CONTEXT_TYPE_URL, transportSocket.typed_config.value); - if (!upstreamTlsContext.common_tls_context) { - trace('Could not decode UpstreamTlsContext'); - return null; + const validationResult = this.validateTransportSocket(context, message.transport_socket); + if (validationResult.valid) { + securityUpdate = validationResult.result; + } else { + errors.push(...validationResult.errors); } - trace('Decoded UpstreamTlsContext: ' + JSON.stringify(upstreamTlsContext, undefined, 2)); - const commonTlsContext = upstreamTlsContext.common_tls_context; - let validationContext: CertificateValidationContext__Output; - switch (commonTlsContext.validation_context_type) { - case 'validation_context_sds_secret_config': - return null; - case 'validation_context': - if (!commonTlsContext.validation_context) { - return null; + } + if (message.cluster_discovery_type === 'cluster_type') { + if (message.cluster_type?.typed_config) { + if (message.cluster_type.typed_config.type_url === CLUSTER_CONFIG_TYPE_URL) { + const clusterConfig = decodeSingleResource(CLUSTER_CONFIG_TYPE_URL, message.cluster_type.typed_config.value); + if (clusterConfig.clusters.length === 0) { + errors.push(`cluster_type.typed_config.clusters.length == ${clusterConfig.clusters.length}`); } - validationContext = commonTlsContext.validation_context; - break; - case 'combined_validation_context': - if (!commonTlsContext.combined_validation_context?.default_validation_context) { - return null; + if (errors.length === 0) { + return { + valid: true, + result: { + type: 'AGGREGATE', + name: message.name, + aggregateChildren: clusterConfig.clusters, + outlierDetectionUpdate: convertOutlierDetectionUpdate(null), + lbPolicyConfig: [lbPolicyConfig], + securityUpdate: securityUpdate + } + }; } - validationContext = commonTlsContext.combined_validation_context.default_validation_context; - break; - default: - return null; - } - if (!validationContext.ca_certificate_provider_instance) { - return null; - } - if (!(validationContext.ca_certificate_provider_instance.instance_name in context.bootstrap.certificateProviders)) { - return null; - } - if (validationContext.verify_certificate_spki.length > 0) { - return null; - } - if (validationContext.verify_certificate_hash.length > 0) { - return null; - } - if (validationContext.require_signed_certificate_timestamp) { - return null; - } - if (validationContext.crl) { - return null; - } - if (validationContext.custom_validator_config) { - return null; - } - if (commonTlsContext.tls_certificate_provider_instance) { - if (!(commonTlsContext.tls_certificate_provider_instance.instance_name in context.bootstrap.certificateProviders)) { - return null; + } else { + errors.push(`Unexpected cluster_type.typed_config.type_url: ${message.cluster_type.typed_config.type_url}`); } } else { - if (commonTlsContext.tls_certificates.length > 0 || commonTlsContext.tls_certificate_sds_secret_configs.length > 0) { - return null; - } - } - if (commonTlsContext.tls_params) { - return null; - } - if (commonTlsContext.custom_handshaker) { - return null; - } - securityUpdate = { - caCertificateProviderInstance: validationContext.ca_certificate_provider_instance.instance_name, - identityCertificateProviderInstance: commonTlsContext.tls_certificate_provider_instance?.instance_name, - subjectAltNameMatchers: validationContext.match_subject_alt_names - } - } - if (message.cluster_discovery_type === 'cluster_type') { - if (!(message.cluster_type?.typed_config && message.cluster_type.typed_config.type_url === CLUSTER_CONFIG_TYPE_URL)) { - return null; - } - const clusterConfig = decodeSingleResource(CLUSTER_CONFIG_TYPE_URL, message.cluster_type.typed_config.value); - if (clusterConfig.clusters.length === 0) { - return null; + errors.push('cluster_type.typed_config unset') ; } return { - type: 'AGGREGATE', - name: message.name, - aggregateChildren: clusterConfig.clusters, - outlierDetectionUpdate: convertOutlierDetectionUpdate(null), - lbPolicyConfig: [lbPolicyConfig], - securityUpdate: securityUpdate - }; + valid: false, + errors + } } else { let maxConcurrentRequests: number | undefined = undefined; for (const threshold of message.circuit_breakers?.thresholds ?? []) { @@ -312,57 +370,91 @@ export class ClusterResourceType extends XdsResourceType { } } if (message.type === 'EDS') { - if (!message.eds_cluster_config?.eds_config?.ads && !message.eds_cluster_config?.eds_config?.self) { - return null; - } - if (message.name.startsWith('xdstp:') && message.eds_cluster_config.service_name === '') { - return null; + if (message.eds_cluster_config) { + if (!message.eds_cluster_config.eds_config?.ads && !message.eds_cluster_config.eds_config?.self) { + errors.push('eds_cluster_config.eds_config.ads and eds_cluster_config.eds_config.self both unset'); + } + if (message.name.startsWith('xdstp:') && message.eds_cluster_config.service_name === '') { + errors.push('name starts with "xdstp:" and eds_cluster_config.service_name is empty'); + } + } else { + errors.push('type == EDS but eds_cluster_config is unset'); } - return { - type: 'EDS', - name: message.name, - aggregateChildren: [], - maxConcurrentRequests: maxConcurrentRequests, - edsServiceName: message.eds_cluster_config.service_name === '' ? undefined : message.eds_cluster_config.service_name, - lrsLoadReportingServer: message.lrs_server ? context.server : undefined, - outlierDetectionUpdate: convertOutlierDetectionUpdate(message.outlier_detection), - lbPolicyConfig: [lbPolicyConfig], - securityUpdate: securityUpdate + if (errors.length === 0) { + return { + valid: true, + result: { + type: 'EDS', + name: message.name, + aggregateChildren: [], + maxConcurrentRequests: maxConcurrentRequests, + edsServiceName: message.eds_cluster_config!.service_name === '' ? undefined : message.eds_cluster_config!.service_name, + lrsLoadReportingServer: message.lrs_server ? context.server : undefined, + outlierDetectionUpdate: convertOutlierDetectionUpdate(message.outlier_detection), + lbPolicyConfig: [lbPolicyConfig], + securityUpdate: securityUpdate + } + } + } else { + return { + valid: false, + errors + }; } } else if (message.type === 'LOGICAL_DNS') { - if (!message.load_assignment) { - return null; - } - if (message.load_assignment.endpoints.length !== 1) { - return null; - } - if (message.load_assignment.endpoints[0].lb_endpoints.length !== 1) { - return null; - } - const socketAddress = message.load_assignment.endpoints[0].lb_endpoints[0].endpoint?.address?.socket_address; - if (!socketAddress) { - return null; - } - if (socketAddress.address === '') { - return null; + let socketAddress: SocketAddress__Output | null | undefined = undefined; + if (message.load_assignment) { + if (message.load_assignment.endpoints.length === 1) { + if (message.load_assignment.endpoints[0].lb_endpoints.length === 1) { + socketAddress = message.load_assignment.endpoints[0].lb_endpoints[0].endpoint?.address?.socket_address; + if (socketAddress) { + if (socketAddress.address === '') { + errors.push('load_assignment.endpoints[0].lb_endpoints[0].endpoint.address.socket_address.address is empty'); + } + if (socketAddress.port_specifier !== 'port_value') { + errors.push(`Unsupported load_assignment.endpoints[0].lb_endpoints[0].endpoint.address.socket_address.port_value: ${socketAddress.port_value}`); + } + } else { + errors.push('load_assignment.endpoints[0].lb_endpoints[0].endpoint.address.socket_address is not set'); + } + } else { + errors.push(`load_assignment.endpoints[0].lb_endpoints.length == ${message.load_assignment.endpoints[0].lb_endpoints.length}`); + } + } else { + errors.push(`load_assignment.endpoints.length == ${message.load_assignment.endpoints.length}`); + } + } else { + errors.push(`load_assignment unset`); } - if (socketAddress.port_specifier !== 'port_value') { - return null; + if (errors.length === 0) { + return { + valid: true, + result: { + type: 'LOGICAL_DNS', + name: message.name, + aggregateChildren: [], + maxConcurrentRequests: maxConcurrentRequests, + dnsHostname: `${socketAddress!.address}:${socketAddress!.port_value}`, + lrsLoadReportingServer: message.lrs_server ? context.server : undefined, + outlierDetectionUpdate: convertOutlierDetectionUpdate(message.outlier_detection), + lbPolicyConfig: [lbPolicyConfig], + securityUpdate: securityUpdate + } + }; + } else { + return { + valid: false, + errors + } } - return { - type: 'LOGICAL_DNS', - name: message.name, - aggregateChildren: [], - maxConcurrentRequests: maxConcurrentRequests, - dnsHostname: `${socketAddress.address}:${socketAddress.port_value}`, - lrsLoadReportingServer: message.lrs_server ? context.server : undefined, - outlierDetectionUpdate: convertOutlierDetectionUpdate(message.outlier_detection), - lbPolicyConfig: [lbPolicyConfig], - securityUpdate: securityUpdate - }; + } else { + errors.push(`Unsupported type ${message.type}`); } } - return null; + return { + valid: false, + errors + }; } decode(context:XdsDecodeContext, resource: Any__Output): XdsDecodeResult { @@ -373,16 +465,16 @@ export class ClusterResourceType extends XdsResourceType { } const message = decodeSingleResource(CDS_TYPE_URL, resource.value); trace('Decoded raw resource of type ' + CDS_TYPE_URL + ': ' + JSON.stringify(message, undefined, 2)); - const validatedMessage = this.validateResource(context, message); - if (validatedMessage) { + const validationResult = this.validateResource(context, message); + if (validationResult.valid) { return { - name: validatedMessage.name, - value: validatedMessage + name: validationResult.result.name, + value: validationResult.result }; } else { return { name: message.name, - error: 'Cluster message validation failed' + error: `Cluster message validation failed: [${validationResult.errors}]` }; } } diff --git a/packages/grpc-js-xds/src/xds-resource-type/endpoint-resource-type.ts b/packages/grpc-js-xds/src/xds-resource-type/endpoint-resource-type.ts index 0b1d94750..246d0fc6f 100644 --- a/packages/grpc-js-xds/src/xds-resource-type/endpoint-resource-type.ts +++ b/packages/grpc-js-xds/src/xds-resource-type/endpoint-resource-type.ts @@ -1,6 +1,6 @@ import { experimental, logVerbosity } from "@grpc/grpc-js"; import { ClusterLoadAssignment__Output } from "../generated/envoy/config/endpoint/v3/ClusterLoadAssignment"; -import { XdsDecodeContext, XdsDecodeResult, XdsResourceType } from "./xds-resource-type"; +import { ValidationResult, XdsDecodeContext, XdsDecodeResult, XdsResourceType } from "./xds-resource-type"; import { Locality__Output } from "../generated/envoy/config/core/v3/Locality"; import { SocketAddress__Output } from "../generated/envoy/config/core/v3/SocketAddress"; import { isIPv4, isIPv6 } from "net"; @@ -40,78 +40,86 @@ export class EndpointResourceType extends XdsResourceType { return 'envoy.config.endpoint.v3.ClusterLoadAssignment'; } - private validateAddress(socketAddress: SocketAddress__Output, seenAddresses: SocketAddress__Output[]): boolean { + /** + * + * @param socketAddress + * @param seenAddresses + * @returns A list of validation errors, if there are any. An empty list indicates success + */ + private validateAddress(socketAddress: SocketAddress__Output, seenAddresses: SocketAddress__Output[]): string[] { + const errors: string[] = []; if (socketAddress.port_specifier !== 'port_value') { - trace('EDS validation: socket_address.port_specifier !== "port_value"'); - return false; + errors.push(`Unsupported port_specifier: ${socketAddress.port_specifier}`); } if (!(isIPv4(socketAddress.address) || isIPv6(socketAddress.address))) { - trace('EDS validation: address not a valid IPv4 or IPv6 address: ' + socketAddress.address); - return false; + errors.push(`address is not a valid IPv4 or IPv6 address: ${socketAddress.address}`); } for (const address of seenAddresses) { if (addressesEqual(socketAddress, address)) { - trace('EDS validation: duplicate address seen: ' + address); - return false; + errors.push(`address is a duplicate of another address in the same endpoint: ${socketAddress.address}`); } } - return true; + return errors; } - private validateResource(message: ClusterLoadAssignment__Output): ClusterLoadAssignment__Output | null { + private validateResource(message: ClusterLoadAssignment__Output): ValidationResult { + const errors: string[] = []; const seenLocalities: {locality: Locality__Output, priority: number}[] = []; const seenAddresses: SocketAddress__Output[] = []; const priorityTotalWeights: Map = new Map(); - for (const endpoint of message.endpoints) { + for (const [index, endpoint] of message.endpoints.entries()) { + const errorPrefix = `endpoints[${index}]`; if (!endpoint.locality) { - trace('EDS validation: endpoint locality unset'); - return null; + errors.push(`${errorPrefix}.locality unset`); + continue; } for (const {locality, priority} of seenLocalities) { if (localitiesEqual(endpoint.locality, locality) && endpoint.priority === priority) { - trace('EDS validation: endpoint locality duplicated: ' + JSON.stringify(locality) + ', priority=' + priority); - return null; + errors.push(`${errorPrefix}.locality is a duplicate of another locality in the endpoint`); } } seenLocalities.push({locality: endpoint.locality, priority: endpoint.priority}); - for (const lb of endpoint.lb_endpoints) { + for (const [lbIndex, lb] of endpoint.lb_endpoints.entries()) { + const lbErrorPrefix = `${errorPrefix}.lb_endpoints[${lbIndex}].endpoint`; const socketAddress = lb.endpoint?.address?.socket_address; - if (!socketAddress) { - trace('EDS validation: endpoint socket_address not set'); - return null; + if (socketAddress) { + errors.push(...this.validateAddress(socketAddress, seenAddresses).map(error => `${lbErrorPrefix}: ${error}`)); + seenAddresses.push(socketAddress); + } else { + errors.push(`${lbErrorPrefix}.socket_address not set`); } - if (!this.validateAddress(socketAddress, seenAddresses)) { - return null; - } - seenAddresses.push(socketAddress); if (EXPERIMENTAL_DUALSTACK_ENDPOINTS && lb.endpoint?.additional_addresses) { - for (const additionalAddress of lb.endpoint.additional_addresses) { - if (!additionalAddress.address?.socket_address) { - trace('EDS validation: endpoint additional_addresses socket_address not set'); - return null; - } - if (!this.validateAddress(additionalAddress.address.socket_address, seenAddresses)) { - return null; + for (const [addressIndex, additionalAddress] of lb.endpoint.additional_addresses.entries()) { + if (additionalAddress.address?.socket_address) { + errors.push(...this.validateAddress(additionalAddress.address.socket_address, seenAddresses).map(error => `${lbErrorPrefix}.additional_addresses[${addressIndex}].address.socket_address: ${error}`)); + seenAddresses.push(additionalAddress.address.socket_address); + } else { + errors.push(`${lbErrorPrefix}.additional_addresses[${addressIndex}].address.socket_address unset`); } - seenAddresses.push(additionalAddress.address.socket_address); } } } priorityTotalWeights.set(endpoint.priority, (priorityTotalWeights.get(endpoint.priority) ?? 0) + (endpoint.load_balancing_weight?.value ?? 0)); } - for (const totalWeight of priorityTotalWeights.values()) { + for (const [priority, totalWeight] of priorityTotalWeights.entries()) { if (totalWeight > UINT32_MAX) { - trace('EDS validation: total weight > UINT32_MAX') - return null; + errors.push(`priority ${priority} has total weight greater than UINT32_MAX: ${totalWeight}`); } - } - for (const priority of priorityTotalWeights.keys()) { if (priority > 0 && !priorityTotalWeights.has(priority - 1)) { - trace('EDS validation: priorities not contiguous'); - return null; + errors.push(`Endpoints have priority ${priority} but not ${priority - 1}`); } } - return message; + if (errors.length === 0) { + return { + valid: true, + result: message + }; + } else { + return { + valid: false, + errors + }; + } } decode(context: XdsDecodeContext, resource: Any__Output): XdsDecodeResult { @@ -122,16 +130,16 @@ export class EndpointResourceType extends XdsResourceType { } const message = decodeSingleResource(EDS_TYPE_URL, resource.value); trace('Decoded raw resource of type ' + EDS_TYPE_URL + ': ' + JSON.stringify(message, undefined, 2)); - const validatedMessage = this.validateResource(message); - if (validatedMessage) { + const validationResult = this.validateResource(message); + if (validationResult.valid) { return { - name: validatedMessage.cluster_name, - value: validatedMessage + name: validationResult.result.cluster_name, + value: validationResult.result }; } else { return { name: message.cluster_name, - error: 'Endpoint message validation failed' + error: `ClusterLoadAssignment message validation failed: [${validationResult.errors}]` }; } } diff --git a/packages/grpc-js-xds/src/xds-resource-type/listener-resource-type.ts b/packages/grpc-js-xds/src/xds-resource-type/listener-resource-type.ts index dd7c1ce07..46538145b 100644 --- a/packages/grpc-js-xds/src/xds-resource-type/listener-resource-type.ts +++ b/packages/grpc-js-xds/src/xds-resource-type/listener-resource-type.ts @@ -20,7 +20,7 @@ import { EXPERIMENTAL_FAULT_INJECTION } from "../environment"; import { Listener__Output } from "../generated/envoy/config/listener/v3/Listener"; import { Any__Output } from "../generated/google/protobuf/Any"; import { DOWNSTREAM_TLS_CONTEXT_TYPE_URL, HTTP_CONNECTION_MANGER_TYPE_URL, LDS_TYPE_URL, decodeSingleResource } from "../resources"; -import { XdsDecodeContext, XdsDecodeResult, XdsResourceType } from "./xds-resource-type"; +import { ValidationResult, XdsDecodeContext, XdsDecodeResult, XdsResourceType } from "./xds-resource-type"; import { getTopLevelFilterUrl, validateTopLevelFilter } from "../http-filter"; import { RouteConfigurationResourceType } from "./route-config-resource-type"; import { Watcher, XdsClient } from "../xds-client"; @@ -30,6 +30,7 @@ import { crossProduct } from "../cross-product"; import { FilterChain__Output } from "../generated/envoy/config/listener/v3/FilterChain"; import { HttpConnectionManager__Output } from "../generated/envoy/extensions/filters/network/http_connection_manager/v3/HttpConnectionManager"; import { CertificateValidationContext__Output } from "../generated/envoy/extensions/transport_sockets/tls/v3/CertificateValidationContext"; +import { TransportSocket__Output } from "../generated/envoy/config/core/v3/TransportSocket"; const TRACER_NAME = 'xds_client'; @@ -84,31 +85,32 @@ function normalizeFilterChainMatch(filterChainMatch: FilterChainMatch__Output): })); } -function validateHttpConnectionManager(httpConnectionManager: HttpConnectionManager__Output): boolean { +/** + * @param httpConnectionManager + * @returns A list of validation errors, if there are any. An empty list indicates success + */ +function validateHttpConnectionManager(httpConnectionManager: HttpConnectionManager__Output): string[] { + const errors: string[] = []; if (EXPERIMENTAL_FAULT_INJECTION) { const filterNames = new Set(); for (const [index, httpFilter] of httpConnectionManager.http_filters.entries()) { if (filterNames.has(httpFilter.name)) { - trace('LDS response validation failed: duplicate HTTP filter name ' + httpFilter.name); - return false; + errors.push(`duplicate HTTP filter name: ${httpFilter.name}`); } filterNames.add(httpFilter.name); if (!validateTopLevelFilter(httpFilter)) { - trace('LDS response validation failed: ' + httpFilter.name + ' filter validation failed'); - return false; + errors.push(`${httpFilter.name} filter validation failed`); } /* Validate that the last filter, and only the last filter, is the * router filter. */ const filterUrl = getTopLevelFilterUrl(httpFilter.typed_config!) if (index < httpConnectionManager.http_filters.length - 1) { if (filterUrl === ROUTER_FILTER_URL) { - trace('LDS response validation failed: router filter is before end of list'); - return false; + errors.push('router filter is before the end of the list'); } } else { if (filterUrl !== ROUTER_FILTER_URL) { - trace('LDS response validation failed: final filter is ' + filterUrl); - return false; + errors.push(`final filter is ${filterUrl}`); } } } @@ -116,121 +118,133 @@ function validateHttpConnectionManager(httpConnectionManager: HttpConnectionMana switch (httpConnectionManager.route_specifier) { case 'rds': if (!httpConnectionManager.rds?.config_source?.ads && !httpConnectionManager.rds?.config_source?.self) { - return false; + errors.push('rds.config_source.ads and rds.config_source.self both unset'); } break; - case 'route_config': - if (!RouteConfigurationResourceType.get().validateResource(httpConnectionManager.route_config!)) { - return false; + case 'route_config': { + const routeConfigValidationResult = RouteConfigurationResourceType.get().validateResource(httpConnectionManager.route_config!); + if (!routeConfigValidationResult.valid) { + errors.push(...routeConfigValidationResult.errors.map(error => `route_config: ${error}`)); } break; - default: return false; + } + default: + errors.push(`unexpected route_specifier ${httpConnectionManager.route_specifier}`); } - return true; + return errors; } -function validateFilterChain(context: XdsDecodeContext, filterChain: FilterChain__Output): boolean { - if (filterChain.filters.length !== 1) { - return false; +/** + * @param context + * @param transportSocket A list of validation errors, if there are any. An empty list indicates success + */ +function validateTransportSocket(context: XdsDecodeContext, transportSocket: TransportSocket__Output): string[] { + const errors: string[] = [] + if (transportSocket.name !== 'envoy.transport_sockets.tls') { + errors.push(`Unexpected transport_socket.name: ${transportSocket.name}`); } - if (filterChain.filters[0].typed_config?.type_url !== HTTP_CONNECTION_MANGER_TYPE_URL) { - return false; + if (!transportSocket.typed_config) { + errors.push('transport_socket.typed_config missing'); + return errors; } - const httpConnectionManager = decodeSingleResource(HTTP_CONNECTION_MANGER_TYPE_URL, filterChain.filters[0].typed_config.value); - if (!validateHttpConnectionManager(httpConnectionManager)) { - return false; + if (transportSocket.typed_config.type_url !== DOWNSTREAM_TLS_CONTEXT_TYPE_URL) { + errors.push(`Unexpected transport_socket.typed_config.type_url: ${transportSocket.typed_config.type_url}`); + return errors; } - if (filterChain.transport_socket) { - const transportSocket = filterChain.transport_socket; - if (transportSocket.name !== 'envoy.transport_sockets.tls') { - trace('Wrong transportSocket.name'); - return false; - } - if (!transportSocket.typed_config) { - trace('No typed_config'); - return false; - } - if (transportSocket.typed_config?.type_url !== DOWNSTREAM_TLS_CONTEXT_TYPE_URL) { - trace(`Wrong typed_config type_url: ${transportSocket.typed_config?.type_url}`); - return false; - } - const downstreamTlsContext = decodeSingleResource(DOWNSTREAM_TLS_CONTEXT_TYPE_URL, transportSocket.typed_config.value); - if (!downstreamTlsContext.common_tls_context) { - trace('No common_tls_context'); - return false; - } - const commonTlsContext = downstreamTlsContext.common_tls_context; - if (!commonTlsContext.tls_certificate_provider_instance) { - trace('No tls_certificate_provider_instance'); - return false; - } - if (!(commonTlsContext.tls_certificate_provider_instance.instance_name in context.bootstrap.certificateProviders)) { - trace('Unmatched tls_certificate_provider_instance instance_name'); - return false; - } - let validationContext: CertificateValidationContext__Output | null; - switch (commonTlsContext.validation_context_type) { - case 'validation_context_sds_secret_config': - trace('Unexpected validation_context_sds_secret_config') - return false; - case 'validation_context': - if (!commonTlsContext.validation_context) { - trace('Missing validation_context'); - return false; - } - validationContext = commonTlsContext.validation_context; + const downstreamTlsContext = decodeSingleResource(DOWNSTREAM_TLS_CONTEXT_TYPE_URL, transportSocket.typed_config.value); + if (downstreamTlsContext.require_sni?.value) { + errors.push(`DownstreamTlsContext.require_sni set`); + } + if (downstreamTlsContext.ocsp_staple_policy !== 'LENIENT_STAPLING') { + errors.push(`Unsupported DownstreamTlsContext.ocsp_staple_policy: ${downstreamTlsContext.ocsp_staple_policy}`); + } + if (!downstreamTlsContext.common_tls_context) { + errors.push('Missing DownstreamTlsContext.common_tls_context'); + return errors; + } + const commonTlsContext = downstreamTlsContext.common_tls_context; + let validationContext: CertificateValidationContext__Output | null = null; + switch (commonTlsContext.validation_context_type) { + case 'validation_context_sds_secret_config': + errors.push('Unexpected DownstreamTlsContext.common_tls_context.validation_context_sds_secret_config'); + break; + case 'validation_context': + if (!commonTlsContext.validation_context) { + errors.push('Empty DownstreamTlsContext.common_tls_context.validation_context'); break; - case 'combined_validation_context': - if (!commonTlsContext.combined_validation_context) { - trace('Missing combined_validation_context') - return false; - } - validationContext = commonTlsContext.combined_validation_context.default_validation_context; + } + validationContext = commonTlsContext.validation_context; + break; + case 'combined_validation_context': + if (!commonTlsContext.combined_validation_context) { + errors.push('Empty DownstreamTlsContext.common_tls_context.combined_validation_context') break; - default: - return false; - } - if (validationContext?.ca_certificate_provider_instance && !(validationContext.ca_certificate_provider_instance.instance_name in context.bootstrap.certificateProviders)) { - trace('Unmatched validationContext instance_name'); - return false; - } - if (downstreamTlsContext.require_client_certificate && !validationContext) { - trace('require_client_certificate set without validationContext'); - return false; - } - if (validationContext && validationContext.verify_certificate_spki.length > 0) { - return false; - } - if (validationContext && validationContext.verify_certificate_hash.length > 0) { - return false; + } + validationContext = commonTlsContext.combined_validation_context.default_validation_context; + break; + default: + errors.push(`Unsupported DownstreamTlsContext.common_tls_context.validation_context_type: ${commonTlsContext.validation_context_type}`); + } + if (downstreamTlsContext.require_client_certificate && !validationContext) { + errors.push('DownstreamTlsContext.require_client_certificate set without any validationContext'); + } + if (validationContext) { + if (validationContext.ca_certificate_provider_instance && !(validationContext.ca_certificate_provider_instance.instance_name in context.bootstrap.certificateProviders)) { + errors.push(`Unmatched CertificateValidationContext.ca_certificate_provider.instance_name: ${validationContext.ca_certificate_provider_instance.instance_name}`); } - if (validationContext?.require_signed_certificate_timestamp) { - return false; + if (validationContext.verify_certificate_spki.length > 0) { + errors.push('CertificateValidationContext.verify_certificate_spki populated'); } - if (validationContext?.crl) { - return false; + if (validationContext.verify_certificate_hash.length > 0) { + errors.push('CertificateValidationContext.verify_certificate_hash populated'); } - if (validationContext?.custom_validator_config) { - return false; + if (validationContext.require_signed_certificate_timestamp) { + errors.push('CertificateValidationContext.require_signed_certificate_timestamp set'); } - if (commonTlsContext.tls_params) { - trace('tls_params set'); - return false; + if (validationContext.crl) { + errors.push('CertificateValidationContext.crl set'); } - if (commonTlsContext.custom_handshaker) { - trace('custom_handshaker set'); - return false; + if (validationContext.custom_validator_config) { + errors.push('CertificateValidationContext.custom_validator_config set'); } - if (downstreamTlsContext.require_sni?.value) { - trace('require_sni set'); - return false; + } + if (commonTlsContext.tls_certificate_provider_instance) { + if (!(commonTlsContext.tls_certificate_provider_instance.instance_name in context.bootstrap.certificateProviders)) { + errors.push(`Unmatched DownstreamTlsContext.tls_certificate_provider_instance.instance_name: ${commonTlsContext.tls_certificate_provider_instance.instance_name}`); } - if (downstreamTlsContext.ocsp_staple_policy !== 'LENIENT_STAPLING') { - trace('Unexpected ocsp_staple_policy'); - return false; + } else { + errors.push('DownstreamTlsContext.common_tls_context.tls_certificate_provider_instance'); + } + if (commonTlsContext.tls_params) { + errors.push('DownstreamTlsContext.common_tls_context.tls_params set'); + } + if (commonTlsContext.custom_handshaker) { + errors.push('DownstreamTlsContext.common_tls_context.custom_handshaker set'); + } + return errors; +} + +/** + * @param context + * @param filterChain + * @returns A list of validation errors, if there are any. An empty list indicates success + */ +function validateFilterChain(context: XdsDecodeContext, filterChain: FilterChain__Output): string[] { + const errors: string[] = []; + if (filterChain.filters.length === 1) { + if (filterChain.filters[0].typed_config?.type_url === HTTP_CONNECTION_MANGER_TYPE_URL) { + const httpConnectionManager = decodeSingleResource(HTTP_CONNECTION_MANGER_TYPE_URL, filterChain.filters[0].typed_config.value); + errors.push(...validateHttpConnectionManager(httpConnectionManager).map(error => `filters[0].typed_config: ${error}`)); + } else { + errors.push(`Unexpected value of filters[0].typed_config.type_url: ${filterChain.filters[0].typed_config?.type_url}`); } + } else { + errors.push(`Incorrect filters length: ${filterChain.filters.length}`); } - return true; + if (filterChain.transport_socket) { + errors.push(...validateTransportSocket(context, filterChain.transport_socket)); + } + return errors; } export class ListenerResourceType extends XdsResourceType { @@ -246,24 +260,22 @@ export class ListenerResourceType extends XdsResourceType { return 'envoy.config.listener.v3.Listener'; } - private validateResource(context: XdsDecodeContext, message: Listener__Output): Listener__Output | null { + private validateResource(context: XdsDecodeContext, message: Listener__Output): ValidationResult { + const errors: string[] = []; if ( - !( - message.api_listener?.api_listener && - message.api_listener.api_listener.type_url === HTTP_CONNECTION_MANGER_TYPE_URL - ) + message.api_listener?.api_listener && + message.api_listener.api_listener.type_url === HTTP_CONNECTION_MANGER_TYPE_URL ) { - return null; - } - const httpConnectionManager = decodeSingleResource(HTTP_CONNECTION_MANGER_TYPE_URL, message.api_listener!.api_listener.value); - if (!validateHttpConnectionManager(httpConnectionManager)) { - return null; + const httpConnectionManager = decodeSingleResource(HTTP_CONNECTION_MANGER_TYPE_URL, message.api_listener!.api_listener.value); + errors.push(...validateHttpConnectionManager(httpConnectionManager).map(error => `api_listener.api_listener: ${error}`)); + } else { + errors.push(`api_listener.api_listener.type_url != ${HTTP_CONNECTION_MANGER_TYPE_URL}`); } if (message.listener_filters.length > 0) { - return null; + errors.push('listener_filters populated'); } if (message.use_original_dst?.value === true) { - return null; + errors.push('use_original_dst.value == true'); } const seenMatches: NormalizedFilterChainMatch[] = []; for (const filterChain of message.filter_chains) { @@ -271,19 +283,27 @@ export class ListenerResourceType extends XdsResourceType { const normalizedMatches = normalizeFilterChainMatch(filterChain.filter_chain_match); for (const match of normalizedMatches) { if (seenMatches.some(prevMatch => normalizedFilterChainMatchEquals(match, prevMatch))) { - return null; + errors.push(`duplicate filter_chain_match entry in filter chain ${filterChain.name}`); } seenMatches.push(match); } } - if (!validateFilterChain(context, filterChain)) { - return null; - } + errors.push(...validateFilterChain(context, filterChain).map(error => `filter_chains[${filterChain.name}]: ${error}`)); + } + if (message.default_filter_chain) { + errors.push(...validateFilterChain(context, message.default_filter_chain).map(error => `default_filter_chain: ${error}`)); } - if (message.default_filter_chain && !validateFilterChain(context, message.default_filter_chain)) { - return null; + if (errors.length === 0) { + return { + valid: true, + result: message + }; + } else { + return { + valid: false, + errors + }; } - return message; } decode(context: XdsDecodeContext, resource: Any__Output): XdsDecodeResult { @@ -294,16 +314,16 @@ export class ListenerResourceType extends XdsResourceType { } const message = decodeSingleResource(LDS_TYPE_URL, resource.value); trace('Decoded raw resource of type ' + LDS_TYPE_URL + ': ' + JSON.stringify(message, (key, value) => (value && value.type === 'Buffer' && Array.isArray(value.data)) ? (value.data as Number[]).map(n => n.toString(16)).join('') : value, 2)); - const validatedMessage = this.validateResource(context, message); - if (validatedMessage) { + const validationResult = this.validateResource(context, message); + if (validationResult.valid) { return { - name: validatedMessage.name, - value: validatedMessage + name: validationResult.result.name, + value: validationResult.result }; } else { return { name: message.name, - error: 'Listener message validation failed' + error: `Listener message validation failed: [${validationResult.errors}]` }; } } diff --git a/packages/grpc-js-xds/src/xds-resource-type/route-config-resource-type.ts b/packages/grpc-js-xds/src/xds-resource-type/route-config-resource-type.ts index 407519651..f11489dfd 100644 --- a/packages/grpc-js-xds/src/xds-resource-type/route-config-resource-type.ts +++ b/packages/grpc-js-xds/src/xds-resource-type/route-config-resource-type.ts @@ -24,7 +24,7 @@ import { Duration__Output } from "../generated/google/protobuf/Duration"; import { validateOverrideFilter } from "../http-filter"; import { RDS_TYPE_URL, decodeSingleResource } from "../resources"; import { Watcher, XdsClient } from "../xds-client"; -import { XdsDecodeContext, XdsDecodeResult, XdsResourceType } from "./xds-resource-type"; +import { ValidationResult, XdsDecodeContext, XdsDecodeResult, XdsResourceType } from "./xds-resource-type"; const TRACER_NAME = 'xds_client'; function trace(text: string): void { @@ -66,100 +66,114 @@ export class RouteConfigurationResourceType extends XdsResourceType { return 'envoy.config.route.v3.RouteConfiguration'; } - private validateRetryPolicy(policy: RetryPolicy__Output | null): boolean { + /** + * @param policy + * @returns A list of validation errors, if there are any. An empty list indicates success + */ + private validateRetryPolicy(policy: RetryPolicy__Output | null): string[] { if (policy === null) { - return true; + return []; } + const errors: string[] = []; const numRetries = policy.num_retries?.value ?? 1 if (numRetries < 1) { - return false; + errors.push(`Invalid policy.num_retries.value: ${numRetries}`); } if (policy.retry_back_off) { - if (!policy.retry_back_off.base_interval) { - return false; - } - const baseInterval = durationToMs(policy.retry_back_off.base_interval)!; - const maxInterval = durationToMs(policy.retry_back_off.max_interval) ?? (10 * baseInterval); - if (!(maxInterval >= baseInterval) && (baseInterval > 0)) { - return false; + if (policy.retry_back_off.base_interval) { + const baseInterval = durationToMs(policy.retry_back_off.base_interval)!; + const maxInterval = durationToMs(policy.retry_back_off.max_interval) ?? (10 * baseInterval); + if (baseInterval <= 0) { + errors.push(`Invalid retry_back_off.base_interval: ${JSON.stringify(policy.retry_back_off.base_interval)}`); + } + if (maxInterval < baseInterval) { + errors.push(`retry_back_off.max_interval < retry_back_off.base_interval: ${JSON.stringify(policy.retry_back_off.max_interval)} vs ${JSON.stringify(policy.retry_back_off.base_interval)}`); + } + } else { + errors.push('retry_back_off.base_interval unset'); } } - return true; + return errors; } - public validateResource(message: RouteConfiguration__Output): RouteConfiguration__Output | null { + public validateResource(message: RouteConfiguration__Output): ValidationResult { + const errors: string[] = []; // https://github.com/grpc/proposal/blob/master/A28-xds-traffic-splitting-and-routing.md#response-validation for (const virtualHost of message.virtual_hosts) { + const errorPrefix = `virtual_hosts[${virtualHost.name}]`; for (const domainPattern of virtualHost.domains) { const starIndex = domainPattern.indexOf('*'); const lastStarIndex = domainPattern.lastIndexOf('*'); // A domain pattern can have at most one wildcard * if (starIndex !== lastStarIndex) { - return null; + errors.push(`${errorPrefix}: domains entry has multiple wildcards: ${domainPattern}`); } // A wildcard * can either be absent or at the beginning or end of the pattern if (!(starIndex === -1 || starIndex === 0 || starIndex === domainPattern.length - 1)) { - return null; + errors.push(`${errorPrefix}: domains entry has wildcard in the middle: ${domainPattern}`); } } if (EXPERIMENTAL_FAULT_INJECTION) { for (const filterConfig of Object.values(virtualHost.typed_per_filter_config ?? {})) { if (!validateOverrideFilter(filterConfig)) { - return null; + errors.push(`${errorPrefix}: typed_per_filter_config validation failed for type_url: ${filterConfig.type_url}`); } } } if (EXPERIMENTAL_RETRY) { - if (!this.validateRetryPolicy(virtualHost.retry_policy)) { - return null; - } + errors.push(...this.validateRetryPolicy(virtualHost.retry_policy).map(error => `${errorPrefix}.retry_policy: ${error}`)); } for (const route of virtualHost.routes) { + const routeErrorPrefix = `${errorPrefix}.routes[${route.name}]`; const match = route.match; - if (!match) { - return null; - } - if (SUPPORTED_PATH_SPECIFIERS.indexOf(match.path_specifier) < 0) { - return null; - } - for (const headers of match.headers) { - if (SUPPPORTED_HEADER_MATCH_SPECIFIERS.indexOf(headers.header_match_specifier) < 0) { - return null; + if (match) { + if (SUPPORTED_PATH_SPECIFIERS.indexOf(match.path_specifier) < 0) { + errors.push(`${routeErrorPrefix}.match: unsupported path_specifier: ${match.path_specifier}`); + } + for (const headers of match.headers) { + if (SUPPPORTED_HEADER_MATCH_SPECIFIERS.indexOf(headers.header_match_specifier) < 0) { + errors.push(`${routeErrorPrefix}.match.headers[${headers.name}]: unsupported header_match_specifier: ${headers.header_match_specifier}`); + } } + } else { + errors.push(`${routeErrorPrefix}.match unset`); } switch (route.action) { case 'route': { - if (route.action !== 'route') { - return null; + + if ((route.route === undefined) || (route.route === null)) { + errors.push(`${routeErrorPrefix}.route unset`); + break; } - if ((route.route === undefined) || (route.route === null) || SUPPORTED_CLUSTER_SPECIFIERS.indexOf(route.route.cluster_specifier) < 0) { - return null; + if (SUPPORTED_CLUSTER_SPECIFIERS.indexOf(route.route.cluster_specifier) < 0) { + errors.push(`${routeErrorPrefix}: unsupported route.cluster_specifier: ${route.route.cluster_specifier}`); } if (EXPERIMENTAL_FAULT_INJECTION) { for (const [name, filterConfig] of Object.entries(route.typed_per_filter_config ?? {})) { if (!validateOverrideFilter(filterConfig)) { - return null; + errors.push(`${routeErrorPrefix}.typed_per_filter_config[${name}] validation failed`); } } } if (EXPERIMENTAL_RETRY) { - if (!this.validateRetryPolicy(route.route.retry_policy)) { - return null; - } + errors.push(...this.validateRetryPolicy(route.route.retry_policy).map(error => `${routeErrorPrefix}.route.retry_policy: ${error}`)); } if (route.route!.cluster_specifier === 'weighted_clusters') { let weightSum = 0; for (const clusterWeight of route.route.weighted_clusters!.clusters) { weightSum += clusterWeight.weight?.value ?? 0; } - if (weightSum === 0 || weightSum > UINT32_MAX) { - return null; + if (weightSum === 0) { + errors.push(`${routeErrorPrefix}.route.weighted_clusters sum of weights is 0`); + } + if (weightSum > UINT32_MAX) { + errors.push(`${routeErrorPrefix}.route.weighted_clusters sum of weights is greater than UINT32_MAX`); } if (EXPERIMENTAL_FAULT_INJECTION) { for (const weightedCluster of route.route!.weighted_clusters!.clusters) { - for (const filterConfig of Object.values(weightedCluster.typed_per_filter_config ?? {})) { + for (const [name, filterConfig] of Object.entries(weightedCluster.typed_per_filter_config ?? {})) { if (!validateOverrideFilter(filterConfig)) { - return null; + errors.push(`${routeErrorPrefix}.route.weighted_clusters.clusters[${weightedCluster.name}].typed_per_filter_config[${name}] validation failed`); } } } @@ -170,11 +184,21 @@ export class RouteConfigurationResourceType extends XdsResourceType { case 'non_forwarding_action': continue; default: - return null; + errors.push(`${routeErrorPrefix}: unsupported action: ${route.action}`); } } } - return message; + if (errors.length === 0) { + return { + valid: true, + result: message + }; + } else { + return { + valid: false, + errors + } + } } decode(context: XdsDecodeContext, resource: Any__Output): XdsDecodeResult { @@ -185,16 +209,16 @@ export class RouteConfigurationResourceType extends XdsResourceType { } const message = decodeSingleResource(RDS_TYPE_URL, resource.value); trace('Decoded raw resource of type ' + RDS_TYPE_URL + ': ' + JSON.stringify(message, undefined, 2)); - const validatedMessage = this.validateResource(message); - if (validatedMessage) { + const validationResult = this.validateResource(message); + if (validationResult.valid) { return { - name: validatedMessage.name, - value: validatedMessage + name: validationResult.result.name, + value: validationResult.result }; } else { return { name: message.name, - error: 'Route configuration message validation failed' + error: `RouteConfiguration message validation failed: [${validationResult.errors}]` }; } } diff --git a/packages/grpc-js-xds/src/xds-resource-type/xds-resource-type.ts b/packages/grpc-js-xds/src/xds-resource-type/xds-resource-type.ts index a0b53c3a7..8e2fccd8a 100644 --- a/packages/grpc-js-xds/src/xds-resource-type/xds-resource-type.ts +++ b/packages/grpc-js-xds/src/xds-resource-type/xds-resource-type.ts @@ -69,6 +69,18 @@ function deepEqual(value1: ValueType, value2: ValueType): boolean { return false; } +export interface ValidationSuccess { + valid: true; + result: T; +} + +export interface ValidationFailure { + valid: false; + errors: string[] +} + +export type ValidationResult = ValidationSuccess | ValidationFailure; + export abstract class XdsResourceType { /** * The type URL as used in xdstp: names From fced35a7d1264beedab17fb25e15995e99a3fc94 Mon Sep 17 00:00:00 2001 From: Michael Lumish Date: Tue, 11 Feb 2025 15:22:06 -0800 Subject: [PATCH 2/2] Fix ring_hash validation --- .../cluster-resource-type.ts | 36 +++++++++---------- 1 file changed, 16 insertions(+), 20 deletions(-) diff --git a/packages/grpc-js-xds/src/xds-resource-type/cluster-resource-type.ts b/packages/grpc-js-xds/src/xds-resource-type/cluster-resource-type.ts index b27b39f45..c0c2d4c82 100644 --- a/packages/grpc-js-xds/src/xds-resource-type/cluster-resource-type.ts +++ b/packages/grpc-js-xds/src/xds-resource-type/cluster-resource-type.ts @@ -265,27 +265,23 @@ export class ClusterResourceType extends XdsResourceType { } }; } else if(EXPERIMENTAL_RING_HASH && message.lb_policy === 'RING_HASH') { - if (message.ring_hash_lb_config) { - if (message.ring_hash_lb_config.hash_function !== 'XX_HASH') { - errors.push(`unsupported ring_hash_lb_config.hash_function: ${message.ring_hash_lb_config.hash_function}`); - } - const minRingSize = message.ring_hash_lb_config.minimum_ring_size ? Number(message.ring_hash_lb_config.minimum_ring_size.value) : 1024; - if (minRingSize > 8_388_608) { - errors.push(`ring_hash_lb_config.minimum_ring_size is too large: ${minRingSize}`); - } - const maxRingSize = message.ring_hash_lb_config.maximum_ring_size ? Number(message.ring_hash_lb_config.maximum_ring_size.value) : 8_388_608; - if (maxRingSize > 8_388_608) { - errors.push(`ring_hash_lb_config.maximum_ring_size is too large: ${maxRingSize}`); - } - lbPolicyConfig = { - ring_hash: { - min_ring_size: minRingSize, - max_ring_size: maxRingSize - } - }; - } else { - errors.push(`lb_policy == RING_HASH but ring_hash_lb_config is unset`); + if (message.ring_hash_lb_config && message.ring_hash_lb_config.hash_function !== 'XX_HASH') { + errors.push(`unsupported ring_hash_lb_config.hash_function: ${message.ring_hash_lb_config.hash_function}`); } + const minRingSize = message.ring_hash_lb_config?.minimum_ring_size ? Number(message.ring_hash_lb_config.minimum_ring_size.value) : 1024; + if (minRingSize > 8_388_608) { + errors.push(`ring_hash_lb_config.minimum_ring_size is too large: ${minRingSize}`); + } + const maxRingSize = message.ring_hash_lb_config?.maximum_ring_size ? Number(message.ring_hash_lb_config.maximum_ring_size.value) : 8_388_608; + if (maxRingSize > 8_388_608) { + errors.push(`ring_hash_lb_config.maximum_ring_size is too large: ${maxRingSize}`); + } + lbPolicyConfig = { + ring_hash: { + min_ring_size: minRingSize, + max_ring_size: maxRingSize + } + }; } else { if (EXPERIMENTAL_CUSTOM_LB_CONFIG) { errors.push(`load_balancing_policy unset and unsupported lb_policy: ${message.lb_policy}`);