Skip to content

Commit 614e5f9

Browse files
authored
Merge pull request #2866 from murgatroid99/grpc-js-xds_channel_credentials
grpc-js-xds: Add XdsChannelCredentials
2 parents 8f08bbe + 41f3fc0 commit 614e5f9

21 files changed

+687
-99
lines changed

packages/grpc-js-xds/src/index.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ import * as typed_struct_lb from './lb-policy-registry/typed-struct';
3131
import * as pick_first_lb from './lb-policy-registry/pick-first';
3232

3333
export { XdsServer } from './server';
34-
export { XdsServerCredentials } from './xds-credentials';
34+
export { XdsChannelCredentials, XdsServerCredentials } from './xds-credentials';
3535

3636
/**
3737
* Register the "xds:" name scheme with the @grpc/grpc-js library.

packages/grpc-js-xds/src/load-balancer-cds.ts

+154-7
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,11 @@ import { XdsConfig } from './xds-dependency-manager';
3030
import { LocalityEndpoint, PriorityChildRaw } from './load-balancer-priority';
3131
import { Locality__Output } from './generated/envoy/config/core/v3/Locality';
3232
import { AGGREGATE_CLUSTER_BACKWARDS_COMPAT, EXPERIMENTAL_OUTLIER_DETECTION } from './environment';
33-
import { XDS_CONFIG_KEY } from './resolver-xds';
33+
import { XDS_CLIENT_KEY, XDS_CONFIG_KEY } from './resolver-xds';
34+
import { ContainsValueMatcher, Matcher, PrefixValueMatcher, RejectValueMatcher, SafeRegexValueMatcher, SuffixValueMatcher, ValueMatcher } from './matcher';
35+
import { StringMatcher__Output } from './generated/envoy/type/matcher/v3/StringMatcher';
36+
import { isIPv6 } from 'net';
37+
import { formatIPv6, parseIPv6 } from './cidr';
3438

3539
const TRACER_NAME = 'cds_balancer';
3640

@@ -67,6 +71,125 @@ class CdsLoadBalancingConfig implements TypedLoadBalancingConfig {
6771
}
6872
}
6973

74+
type SupportedSanType = 'DNS' | 'URI' | 'email' | 'IP Address';
75+
76+
function isSupportedSanType(type: string): type is SupportedSanType {
77+
return ['DNS', 'URI', 'email', 'IP Address'].includes(type);
78+
}
79+
80+
class DnsExactValueMatcher implements ValueMatcher {
81+
constructor(private targetValue: string, private ignoreCase: boolean) {
82+
if (ignoreCase) {
83+
this.targetValue = this.targetValue.toLowerCase();
84+
}
85+
}
86+
apply(entry: string): boolean {
87+
let [type, value] = entry.split(':');
88+
if (!isSupportedSanType(type)) {
89+
return false;
90+
}
91+
if (!value) {
92+
return false;
93+
}
94+
if (this.ignoreCase) {
95+
value = value.toLowerCase();
96+
}
97+
if (type === 'DNS' && value.startsWith('*.') && this.targetValue.includes('.', 1)) {
98+
return value.substring(2) === this.targetValue.substring(this.targetValue.indexOf('.') + 1);
99+
} else {
100+
return value === this.targetValue;
101+
}
102+
}
103+
104+
toString() {
105+
return 'DnsExact(' + this.targetValue + ', ignore_case=' + this.ignoreCase + ')';
106+
}
107+
}
108+
109+
function canonicalizeSanEntryValue(type: SupportedSanType, value: string): string {
110+
if (type === 'IP Address' && isIPv6(value)) {
111+
return formatIPv6(parseIPv6(value));
112+
}
113+
return value;
114+
}
115+
116+
class SanEntryMatcher implements ValueMatcher {
117+
private childMatcher: ValueMatcher;
118+
constructor(matcherConfig: StringMatcher__Output) {
119+
const ignoreCase = matcherConfig.ignore_case;
120+
switch(matcherConfig.match_pattern) {
121+
case 'exact':
122+
throw new Error('Unexpected exact matcher in SAN entry matcher');
123+
case 'prefix':
124+
this.childMatcher = new PrefixValueMatcher(matcherConfig.prefix!, ignoreCase);
125+
break;
126+
case 'suffix':
127+
this.childMatcher = new SuffixValueMatcher(matcherConfig.suffix!, ignoreCase);
128+
break;
129+
case 'safe_regex':
130+
this.childMatcher = new SafeRegexValueMatcher(matcherConfig.safe_regex!.regex);
131+
break;
132+
case 'contains':
133+
this.childMatcher = new ContainsValueMatcher(matcherConfig.contains!, ignoreCase);
134+
break;
135+
default:
136+
this.childMatcher = new RejectValueMatcher();
137+
}
138+
}
139+
apply(entry: string): boolean {
140+
let [type, value] = entry.split(':');
141+
if (!isSupportedSanType(type)) {
142+
return false;
143+
}
144+
value = canonicalizeSanEntryValue(type, value);
145+
if (!entry) {
146+
return false;
147+
}
148+
return this.childMatcher.apply(value);
149+
}
150+
toString(): string {
151+
return this.childMatcher.toString();
152+
}
153+
154+
}
155+
156+
export class SanMatcher implements ValueMatcher {
157+
private childMatchers: ValueMatcher[];
158+
constructor(matcherConfigs: StringMatcher__Output[]) {
159+
this.childMatchers = matcherConfigs.map(config => {
160+
if (config.match_pattern === 'exact') {
161+
return new DnsExactValueMatcher(config.exact!, config.ignore_case);
162+
} else {
163+
return new SanEntryMatcher(config);
164+
}
165+
});
166+
}
167+
apply(value: string): boolean {
168+
if (this.childMatchers.length === 0) {
169+
return true;
170+
}
171+
for (const entry of value.split(', ')) {
172+
for (const matcher of this.childMatchers) {
173+
const checkResult = matcher.apply(entry);
174+
if (checkResult) {
175+
return true;
176+
}
177+
}
178+
}
179+
return false;
180+
}
181+
toString(): string {
182+
return 'SanMatcher(' + this.childMatchers.map(matcher => matcher.toString()).sort().join(', ') + ')';
183+
}
184+
185+
equals(other: SanMatcher): boolean {
186+
return this.toString() === other.toString();
187+
}
188+
}
189+
190+
export const CA_CERT_PROVIDER_KEY = 'grpc.internal.ca_cert_provider';
191+
export const IDENTITY_CERT_PROVIDER_KEY = 'grpc.internal.identity_cert_provider';
192+
export const SAN_MATCHER_KEY = 'grpc.internal.san_matcher';
70193

71194
const RECURSION_DEPTH_LIMIT = 15;
72195

@@ -102,6 +225,8 @@ export class CdsLoadBalancer implements LoadBalancer {
102225
private priorityNames: string[] = [];
103226
private nextPriorityChildNumber = 0;
104227

228+
private latestSanMatcher: SanMatcher | null = null;
229+
105230
constructor(private readonly channelControlHelper: ChannelControlHelper) {
106231
this.childBalancer = new ChildLoadBalancerHandler(channelControlHelper);
107232
}
@@ -140,7 +265,7 @@ export class CdsLoadBalancer implements LoadBalancer {
140265
leafClusters = getLeafClusters(xdsConfig, clusterName);
141266
} catch (e) {
142267
trace('xDS config parsing failed with error ' + (e as Error).message);
143-
this.channelControlHelper.updateState(connectivityState.TRANSIENT_FAILURE, new UnavailablePicker({code: status.UNAVAILABLE, details: `xDS config parsing failed with error ${(e as Error).message}`, metadata: new Metadata()}));
268+
this.channelControlHelper.updateState(connectivityState.TRANSIENT_FAILURE, new UnavailablePicker({code: status.UNAVAILABLE, details: `xDS config parsing failed with error ${(e as Error).message}`}));
144269
return;
145270
}
146271
const priorityChildren: {[name: string]: PriorityChildRaw} = {};
@@ -165,7 +290,7 @@ export class CdsLoadBalancer implements LoadBalancer {
165290
typedChildConfig = parseLoadBalancingConfig(childConfig);
166291
} catch (e) {
167292
trace('LB policy config parsing failed with error ' + (e as Error).message);
168-
this.channelControlHelper.updateState(connectivityState.TRANSIENT_FAILURE, new UnavailablePicker({code: status.UNAVAILABLE, details: `LB policy config parsing failed with error ${(e as Error).message}`, metadata: new Metadata()}));
293+
this.channelControlHelper.updateState(connectivityState.TRANSIENT_FAILURE, new UnavailablePicker({code: status.UNAVAILABLE, details: `LB policy config parsing failed with error ${(e as Error).message}`}));
169294
return;
170295
}
171296
this.childBalancer.updateAddressList(endpointList, typedChildConfig, {...options, [ROOT_CLUSTER_KEY]: clusterName});
@@ -272,17 +397,39 @@ export class CdsLoadBalancer implements LoadBalancer {
272397
} else {
273398
childConfig = xdsClusterImplConfig;
274399
}
275-
trace(JSON.stringify(childConfig, undefined, 2));
276400
let typedChildConfig: TypedLoadBalancingConfig;
277401
try {
278402
typedChildConfig = parseLoadBalancingConfig(childConfig);
279403
} catch (e) {
280404
trace('LB policy config parsing failed with error ' + (e as Error).message);
281-
this.channelControlHelper.updateState(connectivityState.TRANSIENT_FAILURE, new UnavailablePicker({code: status.UNAVAILABLE, details: `LB policy config parsing failed with error ${(e as Error).message}`, metadata: new Metadata()}));
405+
this.channelControlHelper.updateState(connectivityState.TRANSIENT_FAILURE, new UnavailablePicker({code: status.UNAVAILABLE, details: `LB policy config parsing failed with error ${(e as Error).message}`}));
282406
return;
283407
}
284-
trace(JSON.stringify(typedChildConfig.toJsonObject(), undefined, 2));
285-
this.childBalancer.updateAddressList(childEndpointList, typedChildConfig, options);
408+
const childOptions: ChannelOptions = {...options};
409+
if (clusterConfig.cluster.securityUpdate) {
410+
const securityUpdate = clusterConfig.cluster.securityUpdate;
411+
const xdsClient = options[XDS_CLIENT_KEY] as XdsClient;
412+
const caCertProvider = xdsClient.getCertificateProvider(securityUpdate.caCertificateProviderInstance);
413+
if (!caCertProvider) {
414+
this.channelControlHelper.updateState(connectivityState.TRANSIENT_FAILURE, new UnavailablePicker({code: status.UNAVAILABLE, details: `Cluster ${clusterName} configured with CA certificate provider ${securityUpdate.caCertificateProviderInstance} not in bootstrap`}));
415+
return;
416+
}
417+
if (securityUpdate.identityCertificateProviderInstance) {
418+
const identityCertProvider = xdsClient.getCertificateProvider(securityUpdate.identityCertificateProviderInstance);
419+
if (!identityCertProvider) {
420+
this.channelControlHelper.updateState(connectivityState.TRANSIENT_FAILURE, new UnavailablePicker({code: status.UNAVAILABLE, details: `Cluster ${clusterName} configured with identity certificate provider ${securityUpdate.identityCertificateProviderInstance} not in bootstrap`}));
421+
return;
422+
}
423+
childOptions[IDENTITY_CERT_PROVIDER_KEY] = identityCertProvider;
424+
}
425+
childOptions[CA_CERT_PROVIDER_KEY] = caCertProvider;
426+
const sanMatcher = new SanMatcher(securityUpdate.subjectAltNameMatchers);
427+
if (this.latestSanMatcher === null || !this.latestSanMatcher.equals(sanMatcher)) {
428+
this.latestSanMatcher = sanMatcher;
429+
}
430+
childOptions[SAN_MATCHER_KEY] = this.latestSanMatcher;
431+
}
432+
this.childBalancer.updateAddressList(childEndpointList, typedChildConfig, childOptions);
286433
}
287434
}
288435
exitIdle(): void {

packages/grpc-js-xds/src/resources.ts

+11-2
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import { ClusterConfig__Output } from './generated/envoy/extensions/clusters/agg
2929
import { HttpConnectionManager__Output } from './generated/envoy/extensions/filters/network/http_connection_manager/v3/HttpConnectionManager';
3030
import { EXPERIMENTAL_FEDERATION } from './environment';
3131
import { DownstreamTlsContext__Output } from './generated/envoy/extensions/transport_sockets/tls/v3/DownstreamTlsContext';
32+
import { UpstreamTlsContext__Output } from './generated/envoy/extensions/transport_sockets/tls/v3/UpstreamTlsContext';
3233

3334
export const EDS_TYPE_URL = 'type.googleapis.com/envoy.config.endpoint.v3.ClusterLoadAssignment';
3435
export const CDS_TYPE_URL = 'type.googleapis.com/envoy.config.cluster.v3.Cluster';
@@ -55,10 +56,16 @@ export const DOWNSTREAM_TLS_CONTEXT_TYPE_URL = 'type.googleapis.com/envoy.extens
5556

5657
export type DownstreamTlsContextTypeUrl = 'type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.DownstreamTlsContext';
5758

59+
export const UPSTREAM_TLS_CONTEXT_TYPE_URL = 'type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.UpstreamTlsContext';
60+
61+
export type UpstreamTlsContextTypeUrl = 'type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.UpstreamTlsContext';
62+
63+
export type ResourceTypeUrl = AdsTypeUrl | HttpConnectionManagerTypeUrl | ClusterConfigTypeUrl | DownstreamTlsContextTypeUrl | UpstreamTlsContextTypeUrl;
64+
5865
/**
5966
* Map type URLs to their corresponding message types
6067
*/
61-
export type AdsOutputType<T extends AdsTypeUrl | HttpConnectionManagerTypeUrl | ClusterConfigTypeUrl | DownstreamTlsContextTypeUrl> = T extends EdsTypeUrl
68+
export type AdsOutputType<T extends ResourceTypeUrl> = T extends EdsTypeUrl
6269
? ClusterLoadAssignment__Output
6370
: T extends CdsTypeUrl
6471
? Cluster__Output
@@ -70,6 +77,8 @@ export type AdsOutputType<T extends AdsTypeUrl | HttpConnectionManagerTypeUrl |
7077
? HttpConnectionManager__Output
7178
: T extends ClusterConfigTypeUrl
7279
? ClusterConfig__Output
80+
: T extends UpstreamTlsContextTypeUrl
81+
? UpstreamTlsContext__Output
7382
: DownstreamTlsContext__Output;
7483

7584

@@ -100,7 +109,7 @@ const toObjectOptions = {
100109
oneofs: true
101110
}
102111

103-
export function decodeSingleResource<T extends AdsTypeUrl | HttpConnectionManagerTypeUrl | ClusterConfigTypeUrl | DownstreamTlsContextTypeUrl>(targetTypeUrl: T, message: Buffer): AdsOutputType<T> {
112+
export function decodeSingleResource<T extends ResourceTypeUrl>(targetTypeUrl: T, message: Buffer): AdsOutputType<T> {
104113
const name = targetTypeUrl.substring(targetTypeUrl.lastIndexOf('/') + 1);
105114
const type = resourceRoot.lookup(name);
106115
if (type) {

packages/grpc-js-xds/src/xds-credentials.ts

+37-1
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,43 @@
1515
*
1616
*/
1717

18-
import { ServerCredentials } from "@grpc/grpc-js";
18+
import { CallCredentials, ChannelCredentials, ChannelOptions, ServerCredentials, VerifyOptions, experimental } from "@grpc/grpc-js";
19+
import { CA_CERT_PROVIDER_KEY, IDENTITY_CERT_PROVIDER_KEY, SAN_MATCHER_KEY, SanMatcher } from "./load-balancer-cds";
20+
import GrpcUri = experimental.GrpcUri;
21+
import SecureConnector = experimental.SecureConnector;
22+
import createCertificateProviderChannelCredentials = experimental.createCertificateProviderChannelCredentials;
23+
24+
export class XdsChannelCredentials extends ChannelCredentials {
25+
constructor(private fallbackCredentials: ChannelCredentials) {
26+
super();
27+
}
28+
_isSecure(): boolean {
29+
return true;
30+
}
31+
_equals(other: ChannelCredentials): boolean {
32+
return other instanceof XdsChannelCredentials && this.fallbackCredentials === other.fallbackCredentials;
33+
}
34+
_createSecureConnector(channelTarget: GrpcUri, options: ChannelOptions, callCredentials?: CallCredentials): SecureConnector {
35+
if (options[CA_CERT_PROVIDER_KEY]) {
36+
const verifyOptions: VerifyOptions = {};
37+
if (options[SAN_MATCHER_KEY]) {
38+
const matcher = options[SAN_MATCHER_KEY] as SanMatcher;
39+
verifyOptions.checkServerIdentity = (hostname, cert) => {
40+
if (cert.subjectaltname && matcher.apply(cert.subjectaltname)) {
41+
return undefined;
42+
} else {
43+
return new Error('No matching subject alternative name found in certificate');
44+
}
45+
}
46+
}
47+
const certProviderCreds = createCertificateProviderChannelCredentials(options[CA_CERT_PROVIDER_KEY], options[IDENTITY_CERT_PROVIDER_KEY] ?? null, verifyOptions);
48+
return certProviderCreds._createSecureConnector(channelTarget, options, callCredentials);
49+
} else {
50+
return this.fallbackCredentials._createSecureConnector(channelTarget, options, callCredentials);
51+
}
52+
}
53+
54+
}
1955

2056
export class XdsServerCredentials extends ServerCredentials {
2157
constructor(private fallbackCredentials: ServerCredentials) {

0 commit comments

Comments
 (0)