Merge pull request #2909 from murgatroid99/grpc-js-xds_security_tests

grpc-js-xds: Implement and enable security interop tests
This commit is contained in:
Michael Lumish 2025-02-27 15:37:10 -08:00 committed by GitHub
commit 5eded95069
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 659 additions and 165 deletions

View File

@ -42,7 +42,7 @@ COPY --from=build /node/src/grpc-node/packages/grpc-js ./packages/grpc-js/
COPY --from=build /node/src/grpc-node/packages/grpc-js-xds ./packages/grpc-js-xds/
ENV GRPC_VERBOSITY="DEBUG"
ENV GRPC_TRACE=xds_client,xds_resolver,xds_cluster_manager,cds_balancer,xds_cluster_resolver,xds_cluster_impl,priority,weighted_target,round_robin,resolving_load_balancer,subchannel,keepalive,dns_resolver,fault_injection,http_filter,csds,outlier_detection,server,server_call,ring_hash
ENV GRPC_TRACE=xds_client,xds_resolver,xds_cluster_manager,cds_balancer,xds_cluster_resolver,xds_cluster_impl,priority,weighted_target,round_robin,resolving_load_balancer,subchannel,keepalive,dns_resolver,fault_injection,http_filter,csds,outlier_detection,server,server_call,ring_hash,transport,certificate_provider,xds_channel_credentials
ENV NODE_XDS_INTEROP_VERBOSITY=1
ENTRYPOINT [ "/nodejs/bin/node", "/node/src/grpc-node/packages/grpc-js-xds/build/interop/xds-interop-client" ]

View File

@ -46,7 +46,7 @@ COPY --from=build /node/src/grpc-node/packages/grpc-js ./packages/grpc-js/
COPY --from=build /node/src/grpc-node/packages/grpc-js-xds ./packages/grpc-js-xds/
ENV GRPC_VERBOSITY="DEBUG"
ENV GRPC_TRACE=xds_client,server,xds_server
ENV GRPC_TRACE=xds_client,server,xds_server,http_filter,certificate_provider
# tini serves as PID 1 and enables the server to properly respond to signals.
COPY --from=build /tini /tini

View File

@ -519,7 +519,9 @@ function main() {
* channels do not share any subchannels. It does not have any
* inherent function. */
console.log(`Interop client channel ${i} starting sending ${argv.qps} QPS to ${argv.server}`);
sendConstantQps(new loadedProto.grpc.testing.TestService(argv.server, grpc.credentials.createInsecure(), {'unique': i}),
const insecureCreds = grpc.credentials.createInsecure();
const creds = new grpc_xds.XdsChannelCredentials(insecureCreds);
sendConstantQps(new loadedProto.grpc.testing.TestService(argv.server, creds, {'unique': i}),
argv.qps,
argv.fail_on_failed_rpcs === 'true',
callStatsTracker);

View File

@ -30,6 +30,8 @@ import { SimpleRequest__Output } from './generated/grpc/testing/SimpleRequest';
import { SimpleResponse } from './generated/grpc/testing/SimpleResponse';
import { ReflectionService } from '@grpc/reflection';
grpc_xds.register();
const packageDefinition = protoLoader.loadSync('grpc/testing/test.proto', {
keepCase: true,
defaults: true,
@ -158,6 +160,10 @@ function adminServiceInterceptor(methodDescriptor: grpc.ServerMethodDefinition<a
const responder: grpc.Responder = {
start: next => {
next(listener);
},
sendMessage: (message, next) => {
console.log(`Responded to request to method ${methodDescriptor.path}: ${JSON.stringify(message)}`);
next(message);
}
};
return new grpc.ServerInterceptingCall(call, responder);
@ -228,11 +234,10 @@ function getIPv6Addresses(): string[] {
async function main() {
const argv = yargs
.string(['port', 'maintenance_port', 'address_type'])
.boolean(['secure_mode'])
.string(['port', 'maintenance_port', 'address_type', 'secure_mode'])
.demandOption(['port'])
.default('address_type', 'IPV4_IPV6')
.default('secure_mode', false)
.default('secure_mode', 'false')
.parse()
console.log('Starting xDS interop server. Args: ', argv);
const healthImpl = new HealthImplementation({'': 'NOT_SERVING'});
@ -250,7 +255,8 @@ async function main() {
services: ['grpc.testing.TestService']
})
const addressType = argv.address_type.toUpperCase();
if (argv.secure_mode) {
const secureMode = argv.secure_mode.toLowerCase() == 'true';
if (secureMode) {
if (addressType !== 'IPV4_IPV6') {
throw new Error('Secure mode only supports IPV4_IPV6 address type');
}
@ -265,7 +271,7 @@ async function main() {
const xdsCreds = new grpc_xds.XdsServerCredentials(grpc.ServerCredentials.createInsecure());
await Promise.all([
serverBindPromise(maintenanceServer, `[::]:${argv.maintenance_port}`, grpc.ServerCredentials.createInsecure()),
serverBindPromise(server, `[::]:${argv.port}`, xdsCreds)
serverBindPromise(server, `0.0.0.0:${argv.port}`, xdsCreds)
]);
} else {
const server = new grpc.Server({interceptors: [unifiedInterceptor]});

View File

@ -33,10 +33,16 @@ import * as pick_first_lb from './lb-policy-registry/pick-first';
export { XdsServer } from './server';
export { XdsChannelCredentials, XdsServerCredentials } from './xds-credentials';
let registered = false;
/**
* Register the "xds:" name scheme with the @grpc/grpc-js library.
*/
export function register() {
if (registered) {
return;
}
registered = true;
resolver_xds.setup();
load_balancer_cds.setup();
xds_cluster_impl.setup();

View File

@ -84,11 +84,13 @@ class DnsExactValueMatcher implements ValueMatcher {
}
}
apply(entry: string): boolean {
let [type, value] = entry.split(':');
if (!isSupportedSanType(type)) {
const colonIndex = entry.indexOf(':');
if (colonIndex < 0) {
return false;
}
if (!value) {
const type = entry.substring(0, colonIndex);
let value = entry.substring(colonIndex + 1);
if (!isSupportedSanType(type)) {
return false;
}
if (this.ignoreCase) {
@ -137,14 +139,16 @@ class SanEntryMatcher implements ValueMatcher {
}
}
apply(entry: string): boolean {
let [type, value] = entry.split(':');
const colonIndex = entry.indexOf(':');
if (colonIndex < 0) {
return false;
}
const type = entry.substring(0, colonIndex);
let value = entry.substring(colonIndex + 1);
if (!isSupportedSanType(type)) {
return false;
}
value = canonicalizeSanEntryValue(type, value);
if (!entry) {
return false;
}
return this.childMatcher.apply(value);
}
toString(): string {
@ -433,6 +437,7 @@ export class CdsLoadBalancer implements LoadBalancer {
if (this.latestSanMatcher === null || !this.latestSanMatcher.equals(sanMatcher)) {
this.latestSanMatcher = sanMatcher;
}
trace('Configured subject alternative name matcher: ' + sanMatcher);
childOptions[SAN_MATCHER_KEY] = this.latestSanMatcher;
}
this.childBalancer.updateAddressList(childEndpointList, typedChildConfig, childOptions);

View File

@ -83,6 +83,7 @@ interface ConfigParameters {
createConnectionInjector: (credentials: ServerCredentials) => ConnectionInjector;
drainGraceTimeMs: number;
listenerResourceNameTemplate: string;
unregisterChannelzRef: () => void;
}
class FilterChainEntry {
@ -159,22 +160,25 @@ class FilterChainEntry {
}
if (credentials instanceof XdsServerCredentials) {
if (filterChain.transport_socket) {
trace('Using secure credentials');
const downstreamTlsContext = decodeSingleResource(DOWNSTREAM_TLS_CONTEXT_TYPE_URL, filterChain.transport_socket.typed_config!.value);
const commonTlsContext = downstreamTlsContext.common_tls_context!;
const instanceCertificateProvider = configParameters.xdsClient.getCertificateProvider(commonTlsContext.tls_certificate_provider_instance!.instance_name);
if (!instanceCertificateProvider) {
throw new Error(`Invalid TLS context detected: unrecognized certificate instance name: ${commonTlsContext.tls_certificate_provider_instance!.instance_name}`);
}
let validationContext: CertificateValidationContext__Output | null;
switch (commonTlsContext?.validation_context_type) {
case 'validation_context':
validationContext = commonTlsContext.validation_context!;
break;
case 'combined_validation_context':
validationContext = commonTlsContext.combined_validation_context!.default_validation_context;
break;
default:
throw new Error(`Invalid TLS context detected: invalid validation_context_type: ${commonTlsContext.validation_context_type}`);
let validationContext: CertificateValidationContext__Output | null = null;
if (commonTlsContext?.validation_context_type) {
switch (commonTlsContext?.validation_context_type) {
case 'validation_context':
validationContext = commonTlsContext.validation_context!;
break;
case 'combined_validation_context':
validationContext = commonTlsContext.combined_validation_context!.default_validation_context;
break;
default:
throw new Error(`Invalid TLS context detected: invalid validation_context_type: ${commonTlsContext.validation_context_type}`);
}
}
let caCertificateProvider: experimental.CertificateProvider | null = null;
if (validationContext?.ca_certificate_provider_instance) {
@ -185,6 +189,7 @@ class FilterChainEntry {
}
credentials = experimental.createCertificateProviderServerCredentials(instanceCertificateProvider, caCertificateProvider, downstreamTlsContext.require_client_certificate?.value ?? false);
} else {
trace('Using fallback credentials');
credentials = credentials.getFallbackCredentials();
}
}
@ -287,6 +292,7 @@ class ListenerConfig {
handleConnection(socket: net.Socket) {
const matchingFilter = selectMostSpecificallyMatchingFilter(this.filterChainEntries, socket) ?? this.defaultFilterChain;
if (!matchingFilter) {
trace('Rejecting connection from ' + socket.remoteAddress + ': No filter matched');
socket.destroy();
return;
}
@ -449,12 +455,25 @@ class BoundPortEntry {
this.tcpServer.close();
const resourceName = formatTemplateString(this.configParameters.listenerResourceNameTemplate, this.boundAddress);
ListenerResourceType.cancelWatch(this.configParameters.xdsClient, resourceName, this.listenerWatcher);
this.configParameters.unregisterChannelzRef();
}
}
function normalizeFilterChainMatch(filterChainMatch: FilterChainMatch__Output | null): NormalizedFilterChainMatch[] {
if (!filterChainMatch) {
return [];
filterChainMatch = {
address_suffix: '',
application_protocols: [],
destination_port: null,
direct_source_prefix_ranges: [],
prefix_ranges: [],
server_names: [],
source_ports: [],
source_prefix_ranges: [],
source_type: 'ANY',
suffix_len: null,
transport_protocol: 'raw_buffer'
};
}
if (filterChainMatch.destination_port) {
return [];
@ -613,11 +632,13 @@ export class XdsServer extends Server {
if (!hostPort || !isValidIpPort(hostPort)) {
throw new Error(`Listening port string must have the format IP:port with non-zero port, got ${port}`);
}
const channelzRef = this.experimentalRegisterListenerToChannelz({host: hostPort.host, port: hostPort.port!});
const configParameters: ConfigParameters = {
createConnectionInjector: (credentials) => this.createConnectionInjector(credentials),
createConnectionInjector: (credentials) => this.experimentalCreateConnectionInjectorWithChannelzRef(credentials, channelzRef),
drainGraceTimeMs: this.drainGraceTimeMs,
listenerResourceNameTemplate: this.listenerResourceNameTemplate,
xdsClient: this.xdsClient
xdsClient: this.xdsClient,
unregisterChannelzRef: () => this.experimentalUnregisterListenerFromChannelz(channelzRef)
};
const portEntry = new BoundPortEntry(configParameters, port, creds);
const servingStatusListener: ServingStatusListener = statusObject => {

View File

@ -15,12 +15,18 @@
*
*/
import { CallCredentials, ChannelCredentials, ChannelOptions, ServerCredentials, VerifyOptions, experimental } from "@grpc/grpc-js";
import { CallCredentials, ChannelCredentials, ChannelOptions, ServerCredentials, VerifyOptions, experimental, logVerbosity } from "@grpc/grpc-js";
import { CA_CERT_PROVIDER_KEY, IDENTITY_CERT_PROVIDER_KEY, SAN_MATCHER_KEY, SanMatcher } from "./load-balancer-cds";
import GrpcUri = experimental.GrpcUri;
import SecureConnector = experimental.SecureConnector;
import createCertificateProviderChannelCredentials = experimental.createCertificateProviderChannelCredentials;
const TRACER_NAME = 'xds_channel_credentials';
function trace(text: string) {
experimental.trace(logVerbosity.DEBUG, TRACER_NAME, text);
}
export class XdsChannelCredentials extends ChannelCredentials {
constructor(private fallbackCredentials: ChannelCredentials) {
super();
@ -33,6 +39,7 @@ export class XdsChannelCredentials extends ChannelCredentials {
}
_createSecureConnector(channelTarget: GrpcUri, options: ChannelOptions, callCredentials?: CallCredentials): SecureConnector {
if (options[CA_CERT_PROVIDER_KEY]) {
trace('Using secure credentials');
const verifyOptions: VerifyOptions = {};
if (options[SAN_MATCHER_KEY]) {
const matcher = options[SAN_MATCHER_KEY] as SanMatcher;
@ -40,6 +47,7 @@ export class XdsChannelCredentials extends ChannelCredentials {
if (cert.subjectaltname && matcher.apply(cert.subjectaltname)) {
return undefined;
} else {
trace('Subject alternative name not matched: ' + cert.subjectaltname);
return new Error('No matching subject alternative name found in certificate');
}
}
@ -47,6 +55,7 @@ export class XdsChannelCredentials extends ChannelCredentials {
const certProviderCreds = createCertificateProviderChannelCredentials(options[CA_CERT_PROVIDER_KEY], options[IDENTITY_CERT_PROVIDER_KEY] ?? null, verifyOptions);
return certProviderCreds._createSecureConnector(channelTarget, options, callCredentials);
} else {
trace('Using fallback credentials');
return this.fallbackCredentials._createSecureConnector(channelTarget, options, callCredentials);
}
}
@ -55,7 +64,7 @@ export class XdsChannelCredentials extends ChannelCredentials {
export class XdsServerCredentials extends ServerCredentials {
constructor(private fallbackCredentials: ServerCredentials) {
super();
super({});
}
getFallbackCredentials() {

View File

@ -360,6 +360,11 @@ export class XdsDependencyManager {
constructor(private xdsClient: XdsClient, private listenerResourceName: string, private dataPlaneAuthority: string, private watcher: XdsConfigWatcher) {
this.ldsWatcher = new Watcher<Listener__Output>({
onResourceChanged: (update: Listener__Output) => {
if (!update.api_listener) {
this.trace('Received Listener resource not usable on client');
this.handleListenerDoesNotExist();
return;
}
this.latestListener = update;
const httpConnectionManager = decodeSingleResource(HTTP_CONNECTION_MANGER_TYPE_URL, update.api_listener!.api_listener!.value);
switch (httpConnectionManager.route_specifier) {
@ -401,15 +406,7 @@ export class XdsDependencyManager {
},
onResourceDoesNotExist: () => {
this.trace('Resolution error: LDS resource does not exist');
if (this.latestRouteConfigName) {
this.trace('RDS.cancelWatch(' + this.latestRouteConfigName + '): LDS resource does not exist');
RouteConfigurationResourceType.cancelWatch(this.xdsClient, this.latestRouteConfigName, this.rdsWatcher);
this.latestRouteConfigName = null;
this.latestRouteConfiguration = null;
this.clusterRoots = [];
this.pruneOrphanClusters();
}
this.watcher.onResourceDoesNotExist(`Listener ${listenerResourceName}`);
this.handleListenerDoesNotExist();
}
});
this.rdsWatcher = new Watcher<RouteConfiguration__Output>({
@ -435,6 +432,19 @@ export class XdsDependencyManager {
trace('[' + this.listenerResourceName + '] ' + text);
}
private handleListenerDoesNotExist() {
if (this.latestRouteConfigName) {
this.trace('RDS.cancelWatch(' + this.latestRouteConfigName + '): LDS resource does not exist');
RouteConfigurationResourceType.cancelWatch(this.xdsClient, this.latestRouteConfigName, this.rdsWatcher);
this.latestRouteConfigName = null;
this.latestRouteConfiguration = null;
this.clusterRoots = [];
this.pruneOrphanClusters();
}
this.watcher.onResourceDoesNotExist(`Listener ${this.listenerResourceName}`);
}
private maybeSendUpdate() {
if (!this.latestListener) {
this.trace('Not sending update: no Listener update received');

View File

@ -152,6 +152,7 @@ function validateTransportSocket(context: XdsDecodeContext, transportSocket: Tra
return errors;
}
const downstreamTlsContext = decodeSingleResource(DOWNSTREAM_TLS_CONTEXT_TYPE_URL, transportSocket.typed_config.value);
trace('Decoded DownstreamTlsContext: ' + JSON.stringify(downstreamTlsContext, undefined, 2));
if (downstreamTlsContext.require_sni?.value) {
errors.push(`DownstreamTlsContext.require_sni set`);
}
@ -164,26 +165,28 @@ function validateTransportSocket(context: XdsDecodeContext, transportSocket: Tra
}
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');
if (commonTlsContext.validation_context_type) {
switch (commonTlsContext.validation_context_type) {
case 'validation_context_sds_secret_config':
errors.push('Unexpected DownstreamTlsContext.common_tls_context.validation_context_sds_secret_config');
break;
}
validationContext = commonTlsContext.validation_context;
break;
case 'combined_validation_context':
if (!commonTlsContext.combined_validation_context) {
errors.push('Empty DownstreamTlsContext.common_tls_context.combined_validation_context')
case 'validation_context':
if (!commonTlsContext.validation_context) {
errors.push('Empty DownstreamTlsContext.common_tls_context.validation_context');
break;
}
validationContext = commonTlsContext.validation_context;
break;
}
validationContext = commonTlsContext.combined_validation_context.default_validation_context;
break;
default:
errors.push(`Unsupported DownstreamTlsContext.common_tls_context.validation_context_type: ${commonTlsContext.validation_context_type}`);
case 'combined_validation_context':
if (!commonTlsContext.combined_validation_context) {
errors.push('Empty DownstreamTlsContext.common_tls_context.combined_validation_context')
break;
}
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');
@ -262,14 +265,15 @@ export class ListenerResourceType extends XdsResourceType {
private validateResource(context: XdsDecodeContext, message: Listener__Output): ValidationResult<Listener__Output> {
const errors: string[] = [];
if (
message.api_listener?.api_listener &&
message.api_listener.api_listener.type_url === HTTP_CONNECTION_MANGER_TYPE_URL
) {
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.api_listener?.api_listener) {
if (
message.api_listener.api_listener.type_url === HTTP_CONNECTION_MANGER_TYPE_URL
) {
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) {
errors.push('listener_filters populated');
@ -293,6 +297,9 @@ export class ListenerResourceType extends XdsResourceType {
if (message.default_filter_chain) {
errors.push(...validateFilterChain(context, message.default_filter_chain).map(error => `default_filter_chain: ${error}`));
}
if (!message.api_listener && !message.default_filter_chain && message.filter_chains.length === 0) {
errors.push('No api_listener and no filter_chains and no default_filter_chain');
}
if (errors.length === 0) {
return {
valid: true,

View File

@ -21,7 +21,7 @@ import { FakeEdsCluster, FakeRouteGroup, FakeServerRoute } from './framework';
import { ControlPlaneServer } from './xds-server';
import { XdsTestClient } from './client';
import { XdsChannelCredentials, XdsServerCredentials } from '../src';
import { credentials, ServerCredentials, experimental } from '@grpc/grpc-js';
import { credentials, ServerCredentials, experimental, status } from '@grpc/grpc-js';
import { readFileSync } from 'fs';
import * as path from 'path';
import { Listener } from '../src/generated/envoy/config/listener/v3/Listener';
@ -366,4 +366,140 @@ describe('Client xDS credentials', () => {
});
}
});
describe('Client and server xDS credentials', () => {
let xdsServer: ControlPlaneServer;
let client: XdsTestClient;
beforeEach(done => {
xdsServer = new ControlPlaneServer();
xdsServer.startServer(error => {
done(error);
});
});
afterEach(() => {
client?.close();
xdsServer?.shutdownServer();
});
it('Should use identity and CA certificates when configured', async () => {
const [backend] = await createBackends(1, true, new XdsServerCredentials(ServerCredentials.createInsecure()));
const downstreamTlsContext: DownstreamTlsContext & AnyExtension = {
'@type': DOWNSTREAM_TLS_CONTEXT_TYPE_URL,
common_tls_context: {
tls_certificate_provider_instance: {
instance_name: 'test_certificates'
},
validation_context: {
ca_certificate_provider_instance: {
instance_name: 'test_certificates'
}
}
},
ocsp_staple_policy: 'LENIENT_STAPLING',
require_client_certificate: {
value: true
}
}
const baseServerListener: Listener = {
default_filter_chain: {
filter_chain_match: {
source_type: 'SAME_IP_OR_LOOPBACK'
},
transport_socket: {
name: 'envoy.transport_sockets.tls',
typed_config: downstreamTlsContext
}
}
}
const serverRoute = new FakeServerRoute(backend.getPort(), 'serverRoute', baseServerListener);
xdsServer.setRdsResource(serverRoute.getRouteConfiguration());
xdsServer.setLdsResource(serverRoute.getListener());
xdsServer.addResponseListener((typeUrl, responseState) => {
if (responseState.state === 'NACKED') {
client?.stopCalls();
assert.fail(`Client NACKED ${typeUrl} resource with message ${responseState.errorMessage}`);
}
});
const upstreamTlsContext: UpstreamTlsContext = {
common_tls_context: {
tls_certificate_provider_instance: {
instance_name: 'test_certificates'
},
validation_context: {
ca_certificate_provider_instance: {
instance_name: 'test_certificates'
}
}
}
};
const cluster = new FakeEdsCluster('cluster1', 'endpoint1', [{backends: [backend], locality:{region: 'region1'}}], undefined, upstreamTlsContext);
const routeGroup = new FakeRouteGroup('listener1', 'route1', [{cluster: cluster}]);
await routeGroup.startAllBackends(xdsServer);
xdsServer.setEdsResource(cluster.getEndpointConfig());
xdsServer.setCdsResource(cluster.getClusterConfig());
xdsServer.setRdsResource(routeGroup.getRouteConfiguration());
xdsServer.setLdsResource(routeGroup.getListener());
client = XdsTestClient.createFromServer('listener1', xdsServer, new XdsChannelCredentials(credentials.createInsecure()));
const error = await client.sendOneCallAsync();
assert.strictEqual(error, null);
});
it('Should fail when the server expects a certificate and does not get one', async () => {
const [backend] = await createBackends(1, true, new XdsServerCredentials(ServerCredentials.createInsecure()));
const downstreamTlsContext: DownstreamTlsContext & AnyExtension = {
'@type': DOWNSTREAM_TLS_CONTEXT_TYPE_URL,
common_tls_context: {
tls_certificate_provider_instance: {
instance_name: 'test_certificates'
},
validation_context: {
ca_certificate_provider_instance: {
instance_name: 'test_certificates'
}
}
},
ocsp_staple_policy: 'LENIENT_STAPLING',
require_client_certificate: {
value: true
}
}
const baseServerListener: Listener = {
default_filter_chain: {
filter_chain_match: {
source_type: 'SAME_IP_OR_LOOPBACK'
},
transport_socket: {
name: 'envoy.transport_sockets.tls',
typed_config: downstreamTlsContext
}
}
}
const serverRoute = new FakeServerRoute(backend.getPort(), 'serverRoute', baseServerListener);
xdsServer.setRdsResource(serverRoute.getRouteConfiguration());
xdsServer.setLdsResource(serverRoute.getListener());
xdsServer.addResponseListener((typeUrl, responseState) => {
if (responseState.state === 'NACKED') {
client?.stopCalls();
assert.fail(`Client NACKED ${typeUrl} resource with message ${responseState.errorMessage}`);
}
});
const upstreamTlsContext: UpstreamTlsContext = {
common_tls_context: {
validation_context: {
ca_certificate_provider_instance: {
instance_name: 'test_certificates'
}
}
}
};
const cluster = new FakeEdsCluster('cluster1', 'endpoint1', [{backends: [backend], locality:{region: 'region1'}}], undefined, upstreamTlsContext);
const routeGroup = new FakeRouteGroup('listener1', 'route1', [{cluster: cluster}]);
await routeGroup.startAllBackends(xdsServer);
xdsServer.setEdsResource(cluster.getEndpointConfig());
xdsServer.setCdsResource(cluster.getClusterConfig());
xdsServer.setRdsResource(routeGroup.getRouteConfiguration());
xdsServer.setLdsResource(routeGroup.getListener());
client = XdsTestClient.createFromServer('listener1', xdsServer, new XdsChannelCredentials(credentials.createInsecure()));
const error = await client.sendOneCallAsync();
assert(error);
assert.strictEqual(error.code, status.UNAVAILABLE);
});
});
});

View File

@ -31,6 +31,8 @@ import { Socket } from 'net';
import { ChannelOptions } from './channel-options';
import { GrpcUri, parseUri, splitHostPort } from './uri-parser';
import { getDefaultAuthority } from './resolver';
import { log } from './logging';
import { LogVerbosity } from './constants';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function verifyIsBufferOrNull(obj: any, friendlyName: string): void {
@ -70,6 +72,7 @@ export interface SecureConnectResult {
export interface SecureConnector {
connect(socket: Socket): Promise<SecureConnectResult>;
waitForReady(): Promise<void>;
getCallCredentials(): CallCredentials;
destroy(): void;
}
@ -188,6 +191,9 @@ class InsecureChannelCredentialsImpl extends ChannelCredentials {
secure: false
});
},
waitForReady: () => {
return Promise.resolve();
},
getCallCredentials: () => {
return callCredentials ?? CallCredentials.createEmpty();
},
@ -262,6 +268,10 @@ class SecureConnectorImpl implements SecureConnector {
};
return new Promise<SecureConnectResult>((resolve, reject) => {
const tlsSocket = tlsConnect(tlsConnectOptions, () => {
if (!tlsSocket.authorized) {
reject(tlsSocket.authorizationError);
return;
}
resolve({
socket: tlsSocket,
secure: true
@ -272,6 +282,9 @@ class SecureConnectorImpl implements SecureConnector {
});
});
}
waitForReady(): Promise<void> {
return Promise.resolve();
}
getCallCredentials(): CallCredentials {
return this.callCredentials;
}
@ -328,29 +341,47 @@ class CertificateProviderChannelCredentialsImpl extends ChannelCredentials {
constructor(private parent: CertificateProviderChannelCredentialsImpl, private channelTarget: GrpcUri, private options: ChannelOptions, private callCredentials: CallCredentials) {}
connect(socket: Socket): Promise<SecureConnectResult> {
return new Promise(async (resolve, reject) => {
const secureContext = await this.parent.getSecureContext();
return new Promise((resolve, reject) => {
const secureContext = this.parent.getLatestSecureContext();
if (!secureContext) {
reject(new Error('Failed to load credentials'));
return;
}
if (socket.closed) {
reject(new Error('Socket closed while loading credentials'));
}
const connnectionOptions = getConnectionOptions(secureContext, this.parent.verifyOptions, this.channelTarget, this.options);
const tlsConnectOptions: ConnectionOptions = {
socket: socket,
...connnectionOptions
}
const closeCallback = () => {
reject(new Error('Socket closed'));
};
const errorCallback = (error: Error) => {
reject(error);
}
const tlsSocket = tlsConnect(tlsConnectOptions, () => {
tlsSocket.removeListener('close', closeCallback);
tlsSocket.removeListener('error', errorCallback);
if (!tlsSocket.authorized) {
reject(tlsSocket.authorizationError);
return;
}
resolve({
socket: tlsSocket,
secure: true
});
});
tlsSocket.on('error', (error: Error) => {
reject(error);
});
tlsSocket.once('close', closeCallback);
tlsSocket.once('error', errorCallback);
});
}
async waitForReady(): Promise<void> {
await this.parent.getSecureContext();
}
getCallCredentials(): CallCredentials {
return this.callCredentials;
}
@ -446,12 +477,17 @@ class CertificateProviderChannelCredentialsImpl extends ChannelCredentials {
if (this.identityCertificateProvider !== null && !this.latestIdentityUpdate) {
return null;
}
return createSecureContext({
ca: this.latestCaUpdate.caCertificate,
key: this.latestIdentityUpdate?.privateKey,
cert: this.latestIdentityUpdate?.certificate,
ciphers: CIPHER_SUITES
});
try {
return createSecureContext({
ca: this.latestCaUpdate.caCertificate,
key: this.latestIdentityUpdate?.privateKey,
cert: this.latestIdentityUpdate?.certificate,
ciphers: CIPHER_SUITES
});
} catch (e) {
log(LogVerbosity.ERROR, 'Failed to createSecureContext with error ' + (e as Error).message);
return null;
}
}
}

View File

@ -460,6 +460,23 @@ function parseIPv6Chunk(addressChunk: string): number[] {
return result.concat(...bytePairs);
}
function isIPv6MappedIPv4(ipAddress: string) {
return isIPv6(ipAddress) && ipAddress.toLowerCase().startsWith('::ffff:') && isIPv4(ipAddress.substring(7));
}
/**
* Prerequisite: isIPv4(ipAddress)
* @param ipAddress
* @returns
*/
function ipv4AddressStringToBuffer(ipAddress: string): Buffer {
return Buffer.from(
Uint8Array.from(
ipAddress.split('.').map(segment => Number.parseInt(segment))
)
);
}
/**
* Converts an IPv4 or IPv6 address from string representation to binary
* representation
@ -468,11 +485,9 @@ function parseIPv6Chunk(addressChunk: string): number[] {
*/
function ipAddressStringToBuffer(ipAddress: string): Buffer | null {
if (isIPv4(ipAddress)) {
return Buffer.from(
Uint8Array.from(
ipAddress.split('.').map(segment => Number.parseInt(segment))
)
);
return ipv4AddressStringToBuffer(ipAddress);
} else if (isIPv6MappedIPv4(ipAddress)) {
return ipv4AddressStringToBuffer(ipAddress.substring(7));
} else if (isIPv6(ipAddress)) {
let leftSection: string;
let rightSection: string;

View File

@ -32,7 +32,11 @@ export interface SecureContextWatcher {
export abstract class ServerCredentials {
private watchers: Set<SecureContextWatcher> = new Set();
private latestContextOptions: SecureServerOptions | null = null;
private latestContextOptions: SecureContextOptions | null = null;
constructor(private serverConstructorOptions: SecureServerOptions | null, contextOptions?: SecureContextOptions) {
this.latestContextOptions = contextOptions ?? null;
}
_addWatcher(watcher: SecureContextWatcher) {
this.watchers.add(watcher);
}
@ -42,16 +46,21 @@ export abstract class ServerCredentials {
protected getWatcherCount() {
return this.watchers.size;
}
protected updateSecureContextOptions(options: SecureServerOptions | null) {
protected updateSecureContextOptions(options: SecureContextOptions | null) {
this.latestContextOptions = options;
for (const watcher of this.watchers) {
watcher(this.latestContextOptions);
}
}
abstract _isSecure(): boolean;
_getSettings(): SecureServerOptions | null {
_isSecure(): boolean {
return this.serverConstructorOptions !== null;
}
_getSecureContextOptions(): SecureContextOptions | null {
return this.latestContextOptions;
}
_getConstructorOptions(): SecureServerOptions | null {
return this.serverConstructorOptions;
}
_getInterceptors(): ServerInterceptor[] {
return [];
}
@ -101,18 +110,19 @@ export abstract class ServerCredentials {
}
return new SecureServerCredentials({
requestCert: checkClientCertificate,
ciphers: CIPHER_SUITES,
}, {
ca: rootCerts ?? getDefaultRootsData() ?? undefined,
cert,
key,
requestCert: checkClientCertificate,
ciphers: CIPHER_SUITES,
});
}
}
class InsecureServerCredentials extends ServerCredentials {
_isSecure(): boolean {
return false;
constructor() {
super(null);
}
_getSettings(): null {
@ -127,17 +137,9 @@ class InsecureServerCredentials extends ServerCredentials {
class SecureServerCredentials extends ServerCredentials {
private options: SecureServerOptions;
constructor(options: SecureServerOptions) {
super();
this.options = options;
}
_isSecure(): boolean {
return true;
}
_getSettings(): SecureServerOptions {
return this.options;
constructor(constructorOptions: SecureServerOptions, contextOptions: SecureContextOptions) {
super(constructorOptions, contextOptions);
this.options = {...constructorOptions, ...contextOptions};
}
/**
@ -229,7 +231,11 @@ class CertificateProviderServerCredentials extends ServerCredentials {
private caCertificateProvider: CertificateProvider | null,
private requireClientCertificate: boolean
) {
super();
super({
requestCert: caCertificateProvider !== null,
rejectUnauthorized: requireClientCertificate,
ciphers: CIPHER_SUITES
});
}
_addWatcher(watcher: SecureContextWatcher): void {
if (this.getWatcherCount() === 0) {
@ -245,9 +251,6 @@ class CertificateProviderServerCredentials extends ServerCredentials {
this.identityCertificateProvider.removeIdentityCertificateListener(this.identityCertificateUpdateListener);
}
}
_isSecure(): boolean {
return true;
}
_equals(other: ServerCredentials): boolean {
if (this === other) {
return true;
@ -262,7 +265,7 @@ class CertificateProviderServerCredentials extends ServerCredentials {
)
}
private calculateSecureContextOptions(): SecureServerOptions | null {
private calculateSecureContextOptions(): SecureContextOptions | null {
if (this.latestIdentityUpdate === null) {
return null;
}
@ -271,10 +274,8 @@ class CertificateProviderServerCredentials extends ServerCredentials {
}
return {
ca: this.latestCaUpdate?.caCertificate,
cert: this.latestIdentityUpdate.certificate,
key: this.latestIdentityUpdate.privateKey,
requestCert: this.latestIdentityUpdate !== null,
rejectUnauthorized: this.requireClientCertificate
cert: [this.latestIdentityUpdate.certificate],
key: [this.latestIdentityUpdate.privateKey],
};
}
@ -307,7 +308,7 @@ export function createCertificateProviderServerCredentials(
class InterceptorServerCredentials extends ServerCredentials {
constructor(private readonly childCredentials: ServerCredentials, private readonly interceptors: ServerInterceptor[]) {
super();
super({});
}
_isSecure(): boolean {
return this.childCredentials._isSecure();
@ -338,8 +339,11 @@ class InterceptorServerCredentials extends ServerCredentials {
override _removeWatcher(watcher: SecureContextWatcher): void {
this.childCredentials._removeWatcher(watcher);
}
override _getSettings(): SecureServerOptions | null {
return this.childCredentials._getSettings();
override _getConstructorOptions(): SecureServerOptions | null {
return this.childCredentials._getConstructorOptions();
}
override _getSecureContextOptions(): SecureContextOptions | null {
return this.childCredentials._getSecureContextOptions();
}
}

View File

@ -246,6 +246,7 @@ interface BoundPort {
interface Http2ServerInfo {
channelzRef: SocketRef;
sessions: Set<http2.ServerHttp2Session>;
ownsChannelzRef: boolean;
}
interface SessionIdleTimeoutTracker {
@ -539,7 +540,12 @@ export class Server {
throw new Error('Not implemented. Use bindAsync() instead');
}
private registerListenerToChannelz(boundAddress: SubchannelAddress) {
/**
* This API is experimental, so API stability is not guaranteed across minor versions.
* @param boundAddress
* @returns
*/
protected experimentalRegisterListenerToChannelz(boundAddress: SubchannelAddress) {
return registerChannelzSocket(
subchannelAddressToString(boundAddress),
() => {
@ -566,19 +572,27 @@ export class Server {
);
}
protected experimentalUnregisterListenerFromChannelz(channelzRef: SocketRef) {
unregisterChannelzRef(channelzRef);
}
private createHttp2Server(credentials: ServerCredentials) {
let http2Server: http2.Http2Server | http2.Http2SecureServer;
if (credentials._isSecure()) {
const credentialsSettings = credentials._getSettings();
const constructorOptions = credentials._getConstructorOptions();
const contextOptions = credentials._getSecureContextOptions();
const secureServerOptions: http2.SecureServerOptions = {
...this.commonServerOptions,
...credentialsSettings,
...constructorOptions,
...contextOptions,
enableTrace: this.options['grpc-node.tls_enable_trace'] === 1
};
let areCredentialsValid = credentialsSettings !== null;
let areCredentialsValid = contextOptions !== null;
this.trace('Initial credentials valid: ' + areCredentialsValid);
http2Server = http2.createSecureServer(secureServerOptions);
http2Server.on('connection', (socket: Socket) => {
http2Server.prependListener('connection', (socket: Socket) => {
if (!areCredentialsValid) {
this.trace('Dropped connection from ' + JSON.stringify(socket.address()) + ' due to unloaded credentials');
socket.destroy();
}
});
@ -593,9 +607,16 @@ export class Server {
});
const credsWatcher: SecureContextWatcher = options => {
if (options) {
(http2Server as http2.Http2SecureServer).setSecureContext(options);
const secureServer = http2Server as http2.Http2SecureServer;
try {
secureServer.setSecureContext(options);
} catch (e) {
logging.log(LogVerbosity.ERROR, 'Failed to set secure context with error ' + (e as Error).message);
options = null;
}
}
areCredentialsValid = options !== null;
this.trace('Post-update credentials valid: ' + areCredentialsValid);
}
credentials._addWatcher(credsWatcher);
http2Server.on('close', () => {
@ -646,7 +667,7 @@ export class Server {
};
}
const channelzRef = this.registerListenerToChannelz(
const channelzRef = this.experimentalRegisterListenerToChannelz(
boundSubchannelAddress
);
this.listenerChildrenTracker.refChild(channelzRef);
@ -654,6 +675,7 @@ export class Server {
this.http2Servers.set(http2Server, {
channelzRef: channelzRef,
sessions: new Set(),
ownsChannelzRef: true
});
boundPortObject.listeningServers.add(http2Server);
this.trace(
@ -942,19 +964,25 @@ export class Server {
);
}
createConnectionInjector(credentials: ServerCredentials): ConnectionInjector {
/**
* This API is experimental, so API stability is not guaranteed across minor versions.
* @param credentials
* @param channelzRef
* @returns
*/
protected experimentalCreateConnectionInjectorWithChannelzRef(credentials: ServerCredentials, channelzRef: SocketRef, ownsChannelzRef=false) {
if (credentials === null || !(credentials instanceof ServerCredentials)) {
throw new TypeError('creds must be a ServerCredentials object');
}
const server = this.createHttp2Server(credentials);
const channelzRef = this.registerInjectorToChannelz();
if (this.channelzEnabled) {
this.listenerChildrenTracker.refChild(channelzRef);
}
const server = this.createHttp2Server(credentials);
const sessionsSet: Set<http2.ServerHttp2Session> = new Set();
this.http2Servers.set(server, {
channelzRef: channelzRef,
sessions: sessionsSet
sessions: sessionsSet,
ownsChannelzRef
});
return {
injectConnection: (connection: Duplex) => {
@ -979,13 +1007,21 @@ export class Server {
};
}
createConnectionInjector(credentials: ServerCredentials): ConnectionInjector {
if (credentials === null || !(credentials instanceof ServerCredentials)) {
throw new TypeError('creds must be a ServerCredentials object');
}
const channelzRef = this.registerInjectorToChannelz();
return this.experimentalCreateConnectionInjectorWithChannelzRef(credentials, channelzRef, true);
}
private closeServer(server: AnyHttp2Server, callback?: () => void) {
this.trace(
'Closing server with address ' + JSON.stringify(server.address())
);
const serverInfo = this.http2Servers.get(server);
server.close(() => {
if (serverInfo) {
if (serverInfo && serverInfo.ownsChannelzRef) {
this.listenerChildrenTracker.unrefChild(serverInfo.channelzRef);
unregisterChannelzRef(serverInfo.channelzRef);
}

View File

@ -225,8 +225,8 @@ class Http2Transport implements Transport {
this.handleDisconnect();
});
session.socket.once('close', () => {
this.trace('connection closed');
session.socket.once('close', (hadError) => {
this.trace('connection closed. hadError=' + hadError);
this.handleDisconnect();
});
@ -659,6 +659,10 @@ export class Http2SubchannelConnector implements SubchannelConnector {
return Promise.reject();
}
if (secureConnectResult.socket.closed) {
return Promise.reject('Connection closed before starting HTTP/2 handshake');
}
return new Promise<Http2Transport>((resolve, reject) => {
let remoteName: string | null = null;
let realTarget: GrpcUri = this.channelTarget;
@ -671,6 +675,26 @@ export class Http2SubchannelConnector implements SubchannelConnector {
}
const scheme = secureConnectResult.secure ? 'https' : 'http';
const targetPath = getDefaultAuthority(realTarget);
const closeHandler = () => {
this.session?.destroy();
this.session = null;
// Leave time for error event to happen before rejecting
setImmediate(() => {
if (!reportedError) {
reportedError = true;
reject(`${errorMessage.trim()} (${new Date().toISOString()})`);
}
});
};
const errorHandler = (error: Error) => {
this.session?.destroy();
errorMessage = (error as Error).message;
this.trace('connection failed with error ' + errorMessage);
if (!reportedError) {
reportedError = true;
reject(`${errorMessage} (${new Date().toISOString()})`);
}
};
const session = http2.connect(`${scheme}://${targetPath}`, {
createConnection: (authority, option) => {
return secureConnectResult.socket;
@ -685,29 +709,17 @@ export class Http2SubchannelConnector implements SubchannelConnector {
let errorMessage = 'Failed to connect';
let reportedError = false;
session.unref();
session.once('connect', () => {
session.once('remoteSettings', () => {
session.removeAllListeners();
secureConnectResult.socket.removeListener('close', closeHandler);
secureConnectResult.socket.removeListener('error', errorHandler);
resolve(new Http2Transport(session, address, options, remoteName));
this.session = null;
});
session.once('close', () => {
this.session = null;
// Leave time for error event to happen before rejecting
setImmediate(() => {
if (!reportedError) {
reportedError = true;
reject(`${errorMessage} (${new Date().toISOString()})`);
}
});
});
session.once('error', error => {
errorMessage = (error as Error).message;
this.trace('connection failed with error ' + errorMessage);
if (!reportedError) {
reportedError = true;
reject(`${errorMessage} (${new Date().toISOString()})`);
}
});
session.once('close', closeHandler);
session.once('error', errorHandler);
secureConnectResult.socket.once('close', closeHandler);
secureConnectResult.socket.once('error', errorHandler);
});
}
@ -717,12 +729,19 @@ export class Http2SubchannelConnector implements SubchannelConnector {
return proxiedSocket;
} else {
return new Promise<Socket>((resolve, reject) => {
const closeCallback = () => {
reject(new Error('Socket closed'));
};
const errorCallback = (error: Error) => {
reject(error);
}
const socket = net.connect(address, () => {
socket.removeListener('close', closeCallback);
socket.removeListener('error', errorCallback);
resolve(socket);
});
socket.once('error', (error) => {
reject(error);
});
socket.once('close', closeCallback);
socket.once('error', errorCallback);
});
}
});
@ -738,9 +757,15 @@ export class Http2SubchannelConnector implements SubchannelConnector {
}
let tcpConnection: net.Socket | null = null;
let secureConnectResult: SecureConnectResult | null = null;
const addressString = subchannelAddressToString(address);
try {
this.trace(addressString + ' Waiting for secureConnector to be ready');
await secureConnector.waitForReady();
this.trace(addressString + ' secureConnector is ready');
tcpConnection = await this.tcpConnect(address, options);
this.trace(addressString + ' Established TCP connection');
secureConnectResult = await secureConnector.connect(tcpConnection);
this.trace(addressString + ' Established secure connection');
return this.createSession(secureConnectResult, address, options);
} catch (e) {
tcpConnection?.destroy();

View File

@ -28,6 +28,7 @@ import { ServiceClient, ServiceClientConstructor } from '../src/make-client';
import { assert2, loadProtoFile, mockFunction } from './common';
import { sendUnaryData, ServerUnaryCall, ServiceError } from '../src';
import { FileWatcherCertificateProvider } from '../src/certificate-provider';
import { createCertificateProviderServerCredentials } from '../src/server-credentials';
const protoFile = path.join(__dirname, 'fixtures', 'echo_service.proto');
const echoService = loadProtoFile(protoFile)
@ -183,7 +184,7 @@ describe('ChannelCredentials usage', () => {
const certificateProvider = new FileWatcherCertificateProvider({
caCertificateFile: `${__dirname}/fixtures/ca.pem`,
certificateFile: `${__dirname}/fixtures/server1.pem`,
privateKeyFile: `${__dirname}/fixtures/server1.pem`,
privateKeyFile: `${__dirname}/fixtures/server1.key`,
refreshIntervalMs: 1000
});
const channelCreds = createCertificateProviderChannelCredentials(certificateProvider, null);
@ -193,7 +194,6 @@ describe('ChannelCredentials usage', () => {
});
client.echo(
{ value: 'test value', value2: 3 },
new grpc.Metadata({waitForReady: true}),
(error: ServiceError, response: any) => {
client.close();
assert.ifError(error);
@ -201,6 +201,151 @@ describe('ChannelCredentials usage', () => {
done();
}
);
})
});
it('Should never connect when using insecure creds with a secure server', done => {
const client = new echoService(`localhost:${portNum}`, grpc.credentials.createInsecure());
const deadline = new Date();
deadline.setSeconds(deadline.getSeconds() + 1);
client.echo(
{ value: 'test value', value2: 3 },
new grpc.Metadata({waitForReady: true}),
{deadline},
(error: ServiceError, response: any) => {
client.close();
assert(error);
assert.strictEqual(error.code, grpc.status.DEADLINE_EXCEEDED);
done();
}
);
});
});
describe('Channel credentials mtls', () => {
let client: ServiceClient;
let server: grpc.Server;
let portNum: number;
let caCert: Buffer;
let keyValue: Buffer;
let certValue: Buffer;
const hostnameOverride = 'foo.test.google.fr';
before(async () => {
const { ca, key, cert } = await pFixtures;
caCert = ca;
keyValue = key;
certValue = cert;
const serverCreds = grpc.ServerCredentials.createSsl(ca, [
{ private_key: key, cert_chain: cert },
], true);
return new Promise<void>((resolve, reject) => {
server = new grpc.Server();
server.addService(echoService.service, {
echo(call: ServerUnaryCall<any, any>, callback: sendUnaryData<any>) {
call.sendMetadata(call.metadata);
callback(null, call.request);
},
});
server.bindAsync('localhost:0', serverCreds, (err, port) => {
if (err) {
reject(err);
return;
}
portNum = port;
resolve();
});
});
});
afterEach(() => {
client.close();
});
after(() => {
server.forceShutdown();
});
it('Should work with client provided certificates', done => {
const channelCreds = ChannelCredentials.createSsl(caCert, keyValue, certValue);
client = new echoService(`localhost:${portNum}`, channelCreds, {
'grpc.ssl_target_name_override': hostnameOverride,
'grpc.default_authority': hostnameOverride,
});
client.echo({ value: 'test value', value2: 3 }, (error: ServiceError, response: any) => {
assert.ifError(error);
done();
});
});
it('Should fail if the client does not provide certificates', done => {
const channelCreds = ChannelCredentials.createSsl(caCert);
client = new echoService(`localhost:${portNum}`, channelCreds, {
'grpc.ssl_target_name_override': hostnameOverride,
'grpc.default_authority': hostnameOverride,
});
client.echo({ value: 'test value', value2: 3 }, (error: ServiceError, response: any) => {
assert(error);
assert.strictEqual(error.code, grpc.status.UNAVAILABLE);
done();
});
});
});
describe('Channel credentials certificate provider mtls', () => {
const certificateProvider = new FileWatcherCertificateProvider({
caCertificateFile: `${__dirname}/fixtures/ca.pem`,
certificateFile: `${__dirname}/fixtures/server1.pem`,
privateKeyFile: `${__dirname}/fixtures/server1.key`,
refreshIntervalMs: 1000
});
const hostnameOverride = 'foo.test.google.fr';
let client: ServiceClient;
let server: grpc.Server;
let portNum: number;
before(done => {
const serverCreds = createCertificateProviderServerCredentials(certificateProvider, certificateProvider, true);
server = new grpc.Server();
server.addService(echoService.service, {
echo(call: ServerUnaryCall<any, any>, callback: sendUnaryData<any>) {
call.sendMetadata(call.metadata);
callback(null, call.request);
},
});
server.bindAsync('localhost:0', serverCreds, (err, port) => {
if (err) {
done(err);
return;
}
portNum = port;
done();
});
});
afterEach(() => {
client.close();
});
after(() => {
server.forceShutdown();
});
it('Should work with client provided certificates', done => {
const channelCreds = createCertificateProviderChannelCredentials(certificateProvider, certificateProvider);
client = new echoService(`localhost:${portNum}`, channelCreds, {
'grpc.ssl_target_name_override': hostnameOverride,
'grpc.default_authority': hostnameOverride,
});
client.echo({ value: 'test value', value2: 3 }, (error: ServiceError, response: any) => {
assert.ifError(error);
done();
});
});
it('Should fail if the client does not provide certificates', done => {
const channelCreds = createCertificateProviderChannelCredentials(certificateProvider, null);
client = new echoService(`localhost:${portNum}`, channelCreds, {
'grpc.ssl_target_name_override': hostnameOverride,
'grpc.default_authority': hostnameOverride,
});
client.echo({ value: 'test value', value2: 3 }, (error: ServiceError, response: any) => {
assert(error);
assert.strictEqual(error.code, grpc.status.UNAVAILABLE);
done();
});
});
});

View File

@ -32,7 +32,7 @@ describe('Server Credentials', () => {
const creds = ServerCredentials.createInsecure();
assert.strictEqual(creds._isSecure(), false);
assert.strictEqual(creds._getSettings(), null);
assert.strictEqual(creds._getSecureContextOptions(), null);
});
});
@ -41,16 +41,17 @@ describe('Server Credentials', () => {
const creds = ServerCredentials.createSsl(ca, []);
assert.strictEqual(creds._isSecure(), true);
assert.strictEqual(creds._getSettings()?.ca, ca);
assert.strictEqual(creds._getSecureContextOptions()?.ca, ca);
});
it('accepts a boolean as the third argument', () => {
const creds = ServerCredentials.createSsl(ca, [], true);
assert.strictEqual(creds._isSecure(), true);
const settings = creds._getSettings();
assert.strictEqual(settings?.ca, ca);
assert.strictEqual(settings?.requestCert, true);
const constructorOptions = creds._getConstructorOptions();
const contextOptions = creds._getSecureContextOptions();
assert.strictEqual(contextOptions?.ca, ca);
assert.strictEqual(constructorOptions?.requestCert, true);
});
it('accepts an object with two buffers in the second argument', () => {
@ -58,9 +59,9 @@ describe('Server Credentials', () => {
const creds = ServerCredentials.createSsl(null, keyCertPairs);
assert.strictEqual(creds._isSecure(), true);
const settings = creds._getSettings();
assert.deepStrictEqual(settings?.cert, [cert]);
assert.deepStrictEqual(settings?.key, [key]);
const contextOptions = creds._getSecureContextOptions();
assert.deepStrictEqual(contextOptions?.cert, [cert]);
assert.deepStrictEqual(contextOptions?.key, [key]);
});
it('accepts multiple objects in the second argument', () => {
@ -71,9 +72,9 @@ describe('Server Credentials', () => {
const creds = ServerCredentials.createSsl(null, keyCertPairs, false);
assert.strictEqual(creds._isSecure(), true);
const settings = creds._getSettings();
assert.deepStrictEqual(settings?.cert, [cert, cert]);
assert.deepStrictEqual(settings?.key, [key, key]);
const contextOptions = creds._getSecureContextOptions();
assert.deepStrictEqual(contextOptions?.cert, [cert, cert]);
assert.deepStrictEqual(contextOptions?.key, [key, key]);
});
it('fails if the second argument is not an Array', () => {

View File

@ -801,7 +801,7 @@ describe('Echo service', () => {
class ToggleableSecureServerCredentials extends ServerCredentials {
private contextOptions: SecureContextOptions;
constructor(key: Buffer, cert: Buffer) {
super();
super({});
this.contextOptions = {key, cert};
this.enable();
}

View File

@ -0,0 +1,30 @@
# Copyright 2025 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.
# Config file for Kokoro (in protobuf text format)
# Location of the continuous shell script in repository.
build_file: "grpc-node/packages/grpc-js-xds/scripts/psm-interop-test-node.sh"
timeout_mins: 360
action {
define_artifacts {
regex: "artifacts/**/*sponge_log.xml"
regex: "artifacts/**/*.log"
strip_prefix: "artifacts"
}
}
env_vars {
key: "PSM_TEST_SUITE"
value: "security"
}