mirror of https://github.com/grpc/grpc-node.git
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:
commit
5eded95069
|
@ -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" ]
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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]});
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -83,6 +83,7 @@ interface ConfigParameters {
|
|||
createConnectionInjector: (credentials: ServerCredentials) => ConnectionInjector;
|
||||
drainGraceTimeMs: number;
|
||||
listenerResourceNameTemplate: string;
|
||||
unregisterChannelzRef: () => void;
|
||||
}
|
||||
|
||||
class FilterChainEntry {
|
||||
|
@ -159,13 +160,15 @@ 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;
|
||||
let validationContext: CertificateValidationContext__Output | null = null;
|
||||
if (commonTlsContext?.validation_context_type) {
|
||||
switch (commonTlsContext?.validation_context_type) {
|
||||
case 'validation_context':
|
||||
validationContext = commonTlsContext.validation_context!;
|
||||
|
@ -176,6 +179,7 @@ class FilterChainEntry {
|
|||
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) {
|
||||
caCertificateProvider = configParameters.xdsClient.getCertificateProvider(validationContext.ca_certificate_provider_instance.instance_name) ?? null;
|
||||
|
@ -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 => {
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -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,6 +165,7 @@ function validateTransportSocket(context: XdsDecodeContext, transportSocket: Tra
|
|||
}
|
||||
const commonTlsContext = downstreamTlsContext.common_tls_context;
|
||||
let validationContext: CertificateValidationContext__Output | null = null;
|
||||
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');
|
||||
|
@ -185,6 +187,7 @@ function validateTransportSocket(context: XdsDecodeContext, transportSocket: Tra
|
|||
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,8 +265,8 @@ export class ListenerResourceType extends XdsResourceType {
|
|||
|
||||
private validateResource(context: XdsDecodeContext, message: Listener__Output): ValidationResult<Listener__Output> {
|
||||
const errors: string[] = [];
|
||||
if (message.api_listener?.api_listener) {
|
||||
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);
|
||||
|
@ -271,6 +274,7 @@ export class ListenerResourceType extends XdsResourceType {
|
|||
} 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,
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
|
|
@ -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', () => {
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
}
|
Loading…
Reference in New Issue