diff --git a/java/google/registry/proxy/BUILD b/java/google/registry/proxy/BUILD new file mode 100644 index 000000000..8e75f4b78 --- /dev/null +++ b/java/google/registry/proxy/BUILD @@ -0,0 +1,50 @@ +# Description: +# This package contains the code for the binary that proxies TCP traffic from +# the GCE/GKE to AppEngine. + +package( + default_visibility = ["//java/google/registry:registry_project"], +) + +licenses(["notice"]) # Apache 2.0 + +java_library( + name = "proxy", + srcs = glob(["**/*.java"]), + resources = glob([ + "resources/*", + "config/*.yaml", + ]), + deps = [ + "//java/google/registry/config", + "//java/google/registry/monitoring/metrics", + "//java/google/registry/monitoring/metrics/stackdriver", + "//java/google/registry/util", + "@com_beust_jcommander", + "@com_google_api_client", + "@com_google_apis_google_api_services_cloudkms", + "@com_google_apis_google_api_services_monitoring", + "@com_google_auto_value", + "@com_google_code_findbugs_jsr305", + "@com_google_dagger", + "@com_google_guava", + "@io_netty_buffer", + "@io_netty_codec", + "@io_netty_codec_http", + "@io_netty_common", + "@io_netty_handler", + "@io_netty_transport", + "@javax_inject", + "@joda_time", + "@org_bouncycastle_bcpkix_jdk15on", + ], +) + +java_binary( + name = "proxy_server", + main_class = "google.registry.proxy.ProxyServer", + runtime_deps = [ + ":proxy", + "@io_netty_tcnative", + ], +) diff --git a/java/google/registry/proxy/CertificateModule.java b/java/google/registry/proxy/CertificateModule.java new file mode 100644 index 000000000..9482682fa --- /dev/null +++ b/java/google/registry/proxy/CertificateModule.java @@ -0,0 +1,163 @@ +// Copyright 2017 The Nomulus Authors. All Rights Reserved. +// +// 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. + +package google.registry.proxy; + +import static com.google.common.base.Preconditions.checkState; +import static com.google.common.collect.ImmutableList.toImmutableList; +import static java.nio.charset.StandardCharsets.UTF_8; + +import com.google.common.collect.ImmutableList; +import dagger.Module; +import dagger.Provides; +import google.registry.proxy.ProxyModule.PemBytes; +import google.registry.util.FormattingLogger; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStreamReader; +import java.security.PrivateKey; +import java.security.Security; +import java.security.cert.CertificateException; +import java.security.cert.X509Certificate; +import java.util.function.Function; +import javax.inject.Named; +import javax.inject.Singleton; +import org.bouncycastle.cert.X509CertificateHolder; +import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; +import org.bouncycastle.jce.provider.BouncyCastleProvider; +import org.bouncycastle.openssl.PEMException; +import org.bouncycastle.openssl.PEMKeyPair; +import org.bouncycastle.openssl.PEMParser; +import org.bouncycastle.openssl.jcajce.JcaPEMKeyConverter; + +/** + * Dagger module that provides bindings needed to inject EPP SSL certificate chain and private key. + * + *

The certificates and private key are stored in a .pem file that is encrypted by Cloud KMS. The + * .pem file can be generated by concatenating the .crt certificate files on the chain and the .key + * private file. + * + *

The certificates in the .pem file must be stored in order, where the next certificate's + * subject is the previous certificate's issuer. + * + * @see Cloud Key Management Service + */ +@Module +public class CertificateModule { + + private static final FormattingLogger logger = FormattingLogger.getLoggerForCallerClass(); + + static { + Security.addProvider(new BouncyCastleProvider()); + } + + /** + * Select specific type from a given {@link ImmutableList} and convert them using the converter. + * + * @param objects the {@link ImmutableList} to filter from. + * @param clazz the class to filter. + * @param converter the converter function to act on the items in the filtered list. + */ + private static ImmutableList filterAndConvert( + ImmutableList objects, Class clazz, Function converter) { + return objects + .stream() + .filter(obj -> clazz.isInstance(obj)) + .map(obj -> clazz.cast(obj)) + .map(converter) + .collect(toImmutableList()); + } + + @Singleton + @Provides + @Named("pemObjects") + static ImmutableList providePemObjects(PemBytes pemBytes) { + PEMParser pemParser = + new PEMParser(new InputStreamReader(new ByteArrayInputStream(pemBytes.getBytes()), UTF_8)); + ImmutableList.Builder listBuilder = new ImmutableList.Builder<>(); + Object obj; + // PEMParser returns an object (private key, certificate, etc) each time readObject() is called, + // until no more object is to be read from the file. + while (true) { + try { + obj = pemParser.readObject(); + if (obj == null) { + break; + } else { + listBuilder.add(obj); + } + } catch (IOException e) { + logger.severe(e, "Cannot parse PEM file correctly."); + throw new RuntimeException(e); + } + } + return listBuilder.build(); + } + + @Singleton + @Provides + static PrivateKey providePrivateKey(@Named("pemObjects") ImmutableList pemObjects) { + JcaPEMKeyConverter converter = new JcaPEMKeyConverter().setProvider("BC"); + Function privateKeyConverter = + pemKeyPair -> { + try { + return converter.getKeyPair(pemKeyPair).getPrivate(); + } catch (PEMException e) { + logger.severefmt(e, "Error converting private key: %s", pemKeyPair); + throw new RuntimeException(e); + } + }; + ImmutableList privateKeys = + filterAndConvert(pemObjects, PEMKeyPair.class, privateKeyConverter); + checkState( + privateKeys.size() == 1, + "The pem file must contain exactly one private key, but %s keys are found", + privateKeys.size()); + return privateKeys.get(0); + } + + @Singleton + @Provides + @Named("eppServerCertificates") + static X509Certificate[] provideCertificates( + @Named("pemObjects") ImmutableList pemObject) { + JcaX509CertificateConverter converter = new JcaX509CertificateConverter().setProvider("BC"); + Function certificateConverter = + certificateHolder -> { + try { + return converter.getCertificate(certificateHolder); + } catch (CertificateException e) { + logger.severefmt(e, "Error converting certificate: %s", certificateHolder); + throw new RuntimeException(e); + } + }; + ImmutableList certificates = + filterAndConvert(pemObject, X509CertificateHolder.class, certificateConverter); + checkState(certificates.size() != 0, "No certificates found in the pem file"); + X509Certificate lastCert = null; + for (X509Certificate cert : certificates) { + if (lastCert != null) { + checkState( + lastCert.getIssuerX500Principal().equals(cert.getSubjectX500Principal()), + "Certificate chain error:\n%s\nis not signed by\n%s", + lastCert, + cert); + } + lastCert = cert; + } + X509Certificate[] certificateArray = new X509Certificate[certificates.size()]; + certificates.toArray(certificateArray); + return certificateArray; + } +} diff --git a/java/google/registry/proxy/EppProtocolModule.java b/java/google/registry/proxy/EppProtocolModule.java new file mode 100644 index 000000000..3a3531e2b --- /dev/null +++ b/java/google/registry/proxy/EppProtocolModule.java @@ -0,0 +1,152 @@ +// Copyright 2017 The Nomulus Authors. All Rights Reserved. +// +// 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. + +package google.registry.proxy; + +import static google.registry.util.ResourceUtils.readResourceBytes; + +import com.google.common.base.Supplier; +import com.google.common.collect.ImmutableList; +import dagger.Module; +import dagger.Provides; +import dagger.multibindings.IntoSet; +import google.registry.proxy.HttpsRelayProtocolModule.HttpsRelayProtocol; +import google.registry.proxy.Protocol.BackendProtocol; +import google.registry.proxy.Protocol.FrontendProtocol; +import google.registry.proxy.handler.EppServiceHandler; +import google.registry.proxy.handler.ProxyProtocolHandler; +import google.registry.proxy.handler.RelayHandler.FullHttpRequestRelayHandler; +import google.registry.proxy.handler.SslServerInitializer; +import google.registry.proxy.metric.FrontendMetrics; +import google.registry.util.FormattingLogger; +import io.netty.channel.ChannelHandler; +import io.netty.channel.socket.nio.NioSocketChannel; +import io.netty.handler.codec.LengthFieldBasedFrameDecoder; +import io.netty.handler.codec.LengthFieldPrepender; +import io.netty.handler.logging.LoggingHandler; +import io.netty.handler.timeout.ReadTimeoutHandler; +import java.io.IOException; +import javax.inject.Named; +import javax.inject.Provider; +import javax.inject.Qualifier; +import javax.inject.Singleton; + +/** A module that provides the {@link FrontendProtocol} used for epp protocol. */ +@Module +class EppProtocolModule { + + private static final FormattingLogger logger = FormattingLogger.getLoggerForCallerClass(); + + /** Dagger qualifier to provide epp protocol related handlers and other bindings. */ + @Qualifier + @interface EppProtocol {}; + + private static final String PROTOCOL_NAME = "epp"; + + @Singleton + @Provides + @IntoSet + static FrontendProtocol provideProtocol( + ProxyConfig config, + @EppProtocol int eppPort, + @EppProtocol ImmutableList> handlerProviders, + @HttpsRelayProtocol BackendProtocol.Builder backendProtocolBuilder) { + return Protocol.frontendBuilder() + .name(PROTOCOL_NAME) + .port(eppPort) + .handlerProviders(handlerProviders) + .relayProtocol(backendProtocolBuilder.host(config.epp.relayHost).build()) + .build(); + } + + @Provides + @EppProtocol + static ImmutableList> provideHandlerProviders( + Provider> sslServerInitializerProvider, + Provider proxyProtocolHandlerProvider, + @EppProtocol Provider readTimeoutHandlerProvider, + Provider lengthFieldBasedFrameDecoderProvider, + Provider lengthFieldPrependerProvider, + Provider eppServiceHandlerProvider, + Provider loggingHandlerProvider, + Provider relayHandlerProvider) { + return ImmutableList.of( + proxyProtocolHandlerProvider, + sslServerInitializerProvider, + readTimeoutHandlerProvider, + lengthFieldBasedFrameDecoderProvider, + lengthFieldPrependerProvider, + eppServiceHandlerProvider, + loggingHandlerProvider, + relayHandlerProvider); + } + + @Provides + static LengthFieldBasedFrameDecoder provideLengthFieldBasedFrameDecoder(ProxyConfig config) { + return new LengthFieldBasedFrameDecoder( + // Max message length. + config.epp.maxMessageLengthBytes, + // Header field location offset. + 0, + // Header field length. + config.epp.headerLengthBytes, + // Adjustment applied to the header field value in order to obtain message length. + -config.epp.headerLengthBytes, + // Initial bytes to strip (i. e. strip the length header). + config.epp.headerLengthBytes); + } + + @Singleton + @Provides + static LengthFieldPrepender provideLengthFieldPrepender(ProxyConfig config) { + return new LengthFieldPrepender( + // Header field length. + config.epp.headerLengthBytes, + // Length includes header field length. + true); + } + + @Provides + @EppProtocol + static ReadTimeoutHandler provideReadTimeoutHandler(ProxyConfig config) { + return new ReadTimeoutHandler(config.epp.readTimeoutSeconds); + } + + @Singleton + @Provides + @Named("hello") + static byte[] provideHelloBytes() { + try { + return readResourceBytes(EppProtocolModule.class, "resources/hello.xml").read(); + } catch (IOException e) { + logger.severe(e, "Cannot read EPP message file."); + throw new RuntimeException(e); + } + } + + @Provides + static EppServiceHandler provideEppServiceHandler( + @Named("accessToken") Supplier accessTokenSupplier, + @Named("hello") byte[] helloBytes, + FrontendMetrics metrics, + ProxyConfig config) { + return new EppServiceHandler( + config.epp.relayHost, + config.epp.relayPath, + accessTokenSupplier, + config.epp.serverHostname, + helloBytes, + metrics); + } +} diff --git a/java/google/registry/proxy/HealthCheckProtocolModule.java b/java/google/registry/proxy/HealthCheckProtocolModule.java new file mode 100644 index 000000000..32fd37236 --- /dev/null +++ b/java/google/registry/proxy/HealthCheckProtocolModule.java @@ -0,0 +1,76 @@ +// Copyright 2017 The Nomulus Authors. All Rights Reserved. +// +// 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. + +package google.registry.proxy; + +import com.google.common.collect.ImmutableList; +import dagger.Module; +import dagger.Provides; +import dagger.multibindings.IntoSet; +import google.registry.proxy.Protocol.FrontendProtocol; +import google.registry.proxy.handler.HealthCheckHandler; +import io.netty.channel.ChannelHandler; +import io.netty.handler.codec.FixedLengthFrameDecoder; +import javax.inject.Provider; +import javax.inject.Qualifier; +import javax.inject.Singleton; + +/** + * Module that provides a {@link FrontendProtocol} used for GCP load balancer health checking. + * + *

The load balancer sends health checking messages to the GCE instances to assess whether they + * are ready to receive traffic. No relay channel needs to be established for this protocol. + */ +@Module +public class HealthCheckProtocolModule { + + /** Dagger qualifier to provide health check protocol related handlers and other bindings. */ + @Qualifier + @interface HealthCheckProtocol {} + + private static final String PROTOCOL_NAME = "health_check"; + + @Singleton + @Provides + @IntoSet + static FrontendProtocol provideProtocol( + @HealthCheckProtocol int healthCheckPort, + @HealthCheckProtocol ImmutableList> handlerProviders) { + return Protocol.frontendBuilder() + .name(PROTOCOL_NAME) + .port(healthCheckPort) + .isHealthCheck(true) + .handlerProviders(handlerProviders) + .build(); + } + + @Provides + @HealthCheckProtocol + static ImmutableList> provideHandlerProviders( + Provider fixedLengthFrameDecoderProvider, + Provider healthCheckHandlerProvider) { + return ImmutableList.of(fixedLengthFrameDecoderProvider, healthCheckHandlerProvider); + } + + @Provides + static FixedLengthFrameDecoder provideFixedLengthFrameDecoder(ProxyConfig config) { + return new FixedLengthFrameDecoder(config.healthCheck.checkRequest.length()); + } + + @Provides + static HealthCheckHandler provideHealthCheckHandler(ProxyConfig config) { + return new HealthCheckHandler( + config.healthCheck.checkRequest, config.healthCheck.checkResponse); + } +} diff --git a/java/google/registry/proxy/HttpsRelayProtocolModule.java b/java/google/registry/proxy/HttpsRelayProtocolModule.java new file mode 100644 index 000000000..230d0db1f --- /dev/null +++ b/java/google/registry/proxy/HttpsRelayProtocolModule.java @@ -0,0 +1,97 @@ +// Copyright 2017 The Nomulus Authors. All Rights Reserved. +// +// 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. + +package google.registry.proxy; + +import com.google.common.collect.ImmutableList; +import dagger.Module; +import dagger.Provides; +import google.registry.proxy.Protocol.BackendProtocol; +import google.registry.proxy.handler.BackendMetricsHandler; +import google.registry.proxy.handler.RelayHandler.FullHttpResponseRelayHandler; +import google.registry.proxy.handler.SslClientInitializer; +import io.netty.channel.ChannelHandler; +import io.netty.channel.socket.nio.NioSocketChannel; +import io.netty.handler.codec.http.HttpClientCodec; +import io.netty.handler.codec.http.HttpObjectAggregator; +import io.netty.handler.logging.LoggingHandler; +import java.security.cert.X509Certificate; +import javax.annotation.Nullable; +import javax.inject.Named; +import javax.inject.Provider; +import javax.inject.Qualifier; + +/** + * Module that provides a {@link BackendProtocol.Builder} for HTTPS protocol. + * + *

Only a builder is provided because the client protocol itself depends on the remote host + * address, which is provided in the server protocol module that relays to this client protocol + * module, e. g. {@link WhoisProtocolModule}. + */ +@Module +public class HttpsRelayProtocolModule { + + /** Dagger qualifier to provide https relay protocol related handlers and other bindings. */ + @Qualifier + @interface HttpsRelayProtocol {} + + private static final String PROTOCOL_NAME = "https_relay"; + + @Provides + @HttpsRelayProtocol + static BackendProtocol.Builder provideProtocolBuilder( + ProxyConfig config, + @HttpsRelayProtocol ImmutableList> handlerProviders) { + return Protocol.backendBuilder() + .name(PROTOCOL_NAME) + .port(config.httpsRelay.port) + .handlerProviders(handlerProviders); + } + + @Provides + @HttpsRelayProtocol + static ImmutableList> provideHandlerProviders( + Provider> sslClientInitializerProvider, + Provider httpClientCodecProvider, + Provider httpObjectAggregatorProvider, + Provider backendMetricsHandlerProvider, + Provider loggingHandlerProvider, + Provider relayHandlerProvider) { + return ImmutableList.of( + sslClientInitializerProvider, + httpClientCodecProvider, + httpObjectAggregatorProvider, + backendMetricsHandlerProvider, + loggingHandlerProvider, + relayHandlerProvider); + } + + @Provides + static HttpClientCodec provideHttpClientCodec() { + return new HttpClientCodec(); + } + + @Provides + static HttpObjectAggregator provideHttpObjectAggregator(ProxyConfig config) { + return new HttpObjectAggregator(config.httpsRelay.maxMessageLengthBytes); + } + + @Nullable + @Provides + @Named("relayTrustedCertificates") + public static X509Certificate[] provideTrustedCertificates() { + // null uses the system default trust store. + return null; + } +} diff --git a/java/google/registry/proxy/MetricsModule.java b/java/google/registry/proxy/MetricsModule.java new file mode 100644 index 000000000..ab226d371 --- /dev/null +++ b/java/google/registry/proxy/MetricsModule.java @@ -0,0 +1,78 @@ +// Copyright 2017 The Nomulus Authors. All Rights Reserved. +// +// 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. + +package google.registry.proxy; + +import com.google.api.client.googleapis.auth.oauth2.GoogleCredential; +import com.google.api.client.googleapis.util.Utils; +import com.google.api.services.monitoring.v3.Monitoring; +import com.google.api.services.monitoring.v3.model.MonitoredResource; +import com.google.common.collect.ImmutableMap; +import com.google.common.util.concurrent.ThreadFactoryBuilder; +import dagger.Component; +import dagger.Module; +import dagger.Provides; +import google.registry.monitoring.metrics.MetricReporter; +import google.registry.monitoring.metrics.MetricWriter; +import google.registry.monitoring.metrics.stackdriver.StackdriverWriter; +import javax.inject.Singleton; + +/** Module that provides necessary bindings to instantiate a {@link MetricReporter} */ +@Module +public class MetricsModule { + + // TODO (b/64765479): change to GKE cluster and config in YAML file. + private static final String MONITORED_RESOURCE_TYPE = "gce_instance"; + private static final String GCE_INSTANCE_ZONE = "us-east4-c"; + private static final String GCE_INSTANCE_ID = "5401454098973297721"; + + @Singleton + @Provides + static Monitoring provideMonitoring(GoogleCredential credential, ProxyConfig config) { + return new Monitoring.Builder( + Utils.getDefaultTransport(), Utils.getDefaultJsonFactory(), credential) + .setApplicationName(config.projectId) + .build(); + } + + @Singleton + @Provides + static MetricWriter provideMetricWriter(Monitoring monitoringClient, ProxyConfig config) { + // The MonitoredResource for GAE apps is not writable (and missing fields anyway) so we just + // use the gce_instance resource type instead. + return new StackdriverWriter( + monitoringClient, + config.projectId, + new MonitoredResource() + .setType(MONITORED_RESOURCE_TYPE) + .setLabels(ImmutableMap.of("zone", GCE_INSTANCE_ZONE, "instance_id", GCE_INSTANCE_ID)), + config.metrics.stackdriverMaxQps, + config.metrics.stackdriverMaxPointsPerRequest); + } + + @Singleton + @Provides + static MetricReporter provideMetricReporter(MetricWriter metricWriter, ProxyConfig config) { + return new MetricReporter( + metricWriter, + config.metrics.writeIntervalSeconds, + new ThreadFactoryBuilder().setDaemon(true).build()); + } + + @Singleton + @Component(modules = {MetricsModule.class, ProxyModule.class}) + interface MetricsComponent { + MetricReporter metricReporter(); + } +} diff --git a/java/google/registry/proxy/Protocol.java b/java/google/registry/proxy/Protocol.java new file mode 100644 index 000000000..9ffe68027 --- /dev/null +++ b/java/google/registry/proxy/Protocol.java @@ -0,0 +1,130 @@ +// Copyright 2017 The Nomulus Authors. All Rights Reserved. +// +// 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. + +package google.registry.proxy; + +import com.google.auto.value.AutoValue; +import com.google.common.base.Preconditions; +import com.google.common.collect.ImmutableList; +import io.netty.channel.Channel; +import io.netty.channel.ChannelHandler; +import io.netty.channel.socket.nio.NioSocketChannel; +import io.netty.util.Attribute; +import io.netty.util.AttributeKey; +import javax.annotation.Nullable; +import javax.inject.Provider; + +/** Value class that encapsulates parameters of a specific connection. */ +public interface Protocol { + + /** Key used to retrieve the {@link Protocol} from a {@link Channel}'s {@link Attribute}. */ + AttributeKey PROTOCOL_KEY = AttributeKey.valueOf("PROTOCOL_KEY"); + + /** Protocol name. */ + String name(); + + /** + * Port to bind to (for {@link FrontendProtocol}) or to connect to (for {@link BackendProtocol}). + */ + int port(); + + /** The {@link ChannelHandler} providers to use for the protocol, in order. */ + ImmutableList> handlerProviders(); + + /** + * A builder for {@link FrontendProtocol}, default is non-health-checking. + * + * @see HealthCheckProtocolModule + */ + static FrontendProtocol.Builder frontendBuilder() { + return new AutoValue_Protocol_FrontendProtocol.Builder().isHealthCheck(false); + } + + static BackendProtocol.Builder backendBuilder() { + return new AutoValue_Protocol_BackendProtocol.Builder(); + } + + /** + * Generic builder enabling chaining for concrete implementations. + * + * @param builder of the concrete subtype of {@link Protocol}. + * @param

type of the concrete subtype of {@link Protocol}. + */ + abstract class Builder, P extends Protocol> { + + public abstract B name(String value); + + public abstract B port(int port); + + public abstract B handlerProviders(ImmutableList> value); + + public abstract P build(); + } + + /** + * Connection parameters for a connection from the client to the proxy. + * + *

This protocol is associated to a {@link NioSocketChannel} established by remote peer + * connecting to the given {@code port} that the proxy is listening on. + */ + @AutoValue + abstract class FrontendProtocol implements Protocol { + + /** + * The {@link BackendProtocol} used to establish a relay channel and relay the traffic to. Not + * required for health check protocol. + */ + @Nullable + public abstract BackendProtocol relayProtocol(); + + public abstract boolean isHealthCheck(); + + @AutoValue.Builder + public abstract static class Builder extends Protocol.Builder { + public abstract Builder relayProtocol(BackendProtocol value); + + public abstract Builder isHealthCheck(boolean value); + + abstract FrontendProtocol autoBuild(); + + @Override + public FrontendProtocol build() { + FrontendProtocol frontendProtocol = autoBuild(); + Preconditions.checkState( + frontendProtocol.isHealthCheck() || frontendProtocol.relayProtocol() != null, + "Frontend protocol %s must define a relay protocol.", + frontendProtocol.name()); + return frontendProtocol; + } + } + } + + /** + * Connection parameters for a connection from the proxy to the GAE app. + * + *

This protocol is associated to a {@link NioSocketChannel} established by the proxy + * connecting to a remote peer. + */ + @AutoValue + abstract class BackendProtocol implements Protocol { + /** The hostname that the proxy connects to. */ + public abstract String host(); + + /** Builder of {@link BackendProtocol}. */ + @AutoValue.Builder + public abstract static class Builder extends Protocol.Builder { + public abstract Builder host(String value); + } + } +} diff --git a/java/google/registry/proxy/ProxyConfig.java b/java/google/registry/proxy/ProxyConfig.java new file mode 100644 index 000000000..e3796eb84 --- /dev/null +++ b/java/google/registry/proxy/ProxyConfig.java @@ -0,0 +1,102 @@ +// Copyright 2017 The Nomulus Authors. All Rights Reserved. +// +// 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. + +package google.registry.proxy; + +import static google.registry.config.YamlUtils.getConfigSettings; +import static google.registry.util.ResourceUtils.readResourceUtf8; + +import java.util.List; + +/** The POJO that YAML config files are deserialized into. */ +public class ProxyConfig { + + enum Environment { + PRODUCTION, + SANDBOX, + ALPHA, + LOCAL, + TEST, + } + + private static final String DEFAULT_CONFIG = "config/default-config.yaml"; + private static final String CUSTOM_CONFIG_FORMATTER = "config/proxy-config-%s.yaml"; + + public String projectId; + public List gcpScopes; + public int accessTokenValidPeriodSeconds; + public int accessTokenRefreshBeforeExpirySeconds; + public String sslPemFilename; + public Kms kms; + public Epp epp; + public Whois whois; + public HealthCheck healthCheck; + public HttpsRelay httpsRelay; + public Metrics metrics; + + /** Configuration options that apply to Cloud KMS. */ + public static class Kms { + public String location; + public String keyRing; + public String cryptoKey; + } + + /** Configuration options that apply to EPP protocol. */ + public static class Epp { + public int port; + public String relayHost; + public String relayPath; + public int maxMessageLengthBytes; + public int headerLengthBytes; + public int readTimeoutSeconds; + public String serverHostname; + } + + /** Configuration options that apply to WHOIS protocol. */ + public static class Whois { + public int port; + public String relayHost; + public String relayPath; + public int maxMessageLengthBytes; + public int readTimeoutSeconds; + } + + /** Configuration options that apply to GCP load balancer health check protocol. */ + public static class HealthCheck { + public int port; + public String checkRequest; + public String checkResponse; + } + + /** Configuration options that apply to HTTPS relay protocol. */ + public static class HttpsRelay { + public int port; + public int maxMessageLengthBytes; + } + + /** Configuration options that apply to Stackdriver monitoring metrics. */ + public static class Metrics { + public int stackdriverMaxQps; + public int stackdriverMaxPointsPerRequest; + public int writeIntervalSeconds; + } + + static ProxyConfig getProxyConfig(Environment env) { + String defaultYaml = readResourceUtf8(ProxyConfig.class, DEFAULT_CONFIG); + String customYaml = + readResourceUtf8( + ProxyConfig.class, String.format(CUSTOM_CONFIG_FORMATTER, env.name().toLowerCase())); + return getConfigSettings(defaultYaml, customYaml, ProxyConfig.class); + } +} diff --git a/java/google/registry/proxy/ProxyModule.java b/java/google/registry/proxy/ProxyModule.java new file mode 100644 index 000000000..3330038a9 --- /dev/null +++ b/java/google/registry/proxy/ProxyModule.java @@ -0,0 +1,304 @@ +// Copyright 2017 The Nomulus Authors. All Rights Reserved. +// +// 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. + +package google.registry.proxy; + +import static com.google.common.base.Suppliers.memoizeWithExpiration; +import static google.registry.proxy.ProxyConfig.getProxyConfig; +import static google.registry.util.ResourceUtils.readResourceBytes; +import static java.util.concurrent.TimeUnit.SECONDS; + +import com.beust.jcommander.JCommander; +import com.beust.jcommander.Parameter; +import com.beust.jcommander.ParameterException; +import com.google.api.client.googleapis.auth.oauth2.GoogleCredential; +import com.google.api.client.googleapis.util.Utils; +import com.google.api.services.cloudkms.v1.CloudKMS; +import com.google.api.services.cloudkms.v1.model.DecryptRequest; +import com.google.common.base.Optional; +import com.google.common.base.Supplier; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.Maps; +import dagger.Component; +import dagger.Module; +import dagger.Provides; +import google.registry.proxy.EppProtocolModule.EppProtocol; +import google.registry.proxy.HealthCheckProtocolModule.HealthCheckProtocol; +import google.registry.proxy.Protocol.FrontendProtocol; +import google.registry.proxy.ProxyConfig.Environment; +import google.registry.proxy.WhoisProtocolModule.WhoisProtocol; +import google.registry.util.Clock; +import google.registry.util.FormattingLogger; +import google.registry.util.SystemClock; +import io.netty.handler.logging.LoggingHandler; +import io.netty.handler.ssl.OpenSsl; +import io.netty.handler.ssl.SslProvider; +import java.io.IOException; +import java.util.Objects; +import java.util.Set; +import java.util.logging.ConsoleHandler; +import java.util.logging.Handler; +import java.util.logging.Level; +import java.util.logging.Logger; +import javax.inject.Named; +import javax.inject.Singleton; + +/** + * A module that provides the port-to-protocol map and other configs that are used to bootstrap the + * server. + */ +@Module +public class ProxyModule { + + private static final FormattingLogger logger = FormattingLogger.getLoggerForCallerClass(); + + @Parameter(names = "--whois", description = "Port for WHOIS") + private Integer whoisPort; + + @Parameter(names = "--epp", description = "Port for EPP") + private Integer eppPort; + + @Parameter(names = "--health_check", description = "Port for health check protocol") + private Integer healthCheckPort; + + @Parameter(names = "--env", description = "Environment to run the proxy in") + private Environment env = Environment.LOCAL; + + @Parameter(names = "--log", description = "Whether to log activities for debugging") + boolean log; + + /** + * Enable FINE level logging. + * + *

Set the loggers log level to {@code FINE}, and also add a console handler that actually + * output {@code FINE} level messages to stdout. The handler filters out all non FINE level log + * record to avoid double logging. Log records at level higher than FINE (e. g. INFO) will be + * handled at parent loggers. Note that {@code FINE} level corresponds to {@code DEBUG} level in + * Netty. + */ + private static void enableDebugLevelLogging() { + ImmutableList parentLoggers = + ImmutableList.of( + Logger.getLogger("io.netty.handler.logging.LoggingHandler"), + // Parent of all FormattingLoggers, so that we do not have to configure each + // FormattingLogger individually. + Logger.getLogger("google.registry.proxy")); + for (Logger logger : parentLoggers) { + logger.setLevel(Level.FINE); + Handler handler = new ConsoleHandler(); + handler.setFilter(record -> Objects.equals(record.getLevel(), Level.FINE)); + handler.setLevel(Level.FINE); + logger.addHandler(handler); + } + } + + /** + * Parses command line arguments. Show usage if wrong arguments are given. + * + * @param args list of {@code String} arguments + * @return this {@code ProxyModule} object + */ + ProxyModule parse(String[] args) { + JCommander jCommander = new JCommander(this); + jCommander.setProgramName("proxy_server"); + try { + jCommander.parse(args); + } catch (ParameterException e) { + jCommander.usage(); + throw e; + } + if (log) { + logger.info("DEBUG LOGGING: ENABLED"); + enableDebugLevelLogging(); + } + return this; + } + + @Provides + @WhoisProtocol + int provideWhoisPort(ProxyConfig config) { + return Optional.fromNullable(whoisPort).or(config.whois.port); + } + + @Provides + @EppProtocol + int provideEppPort(ProxyConfig config) { + return Optional.fromNullable(eppPort).or(config.epp.port); + } + + @Provides + @HealthCheckProtocol + int provideHealthCheckPort(ProxyConfig config) { + return Optional.fromNullable(healthCheckPort).or(config.healthCheck.port); + } + + @Provides + ImmutableMap providePortToProtocolMap( + Set protocolSet) { + return Maps.uniqueIndex(protocolSet, Protocol::port); + } + + @Provides + Environment provideEnvironment() { + return env; + } + + /** Shared logging handler, set to default DEBUG(FINE) level. */ + @Singleton + @Provides + static LoggingHandler provideLoggingHandler() { + return new LoggingHandler(); + } + + @Singleton + @Provides + static GoogleCredential provideCredential(ProxyConfig config) { + try { + GoogleCredential credential = GoogleCredential.getApplicationDefault(); + if (credential.createScopedRequired()) { + credential = credential.createScoped(config.gcpScopes); + } + return credential; + } catch (IOException e) { + logger.severe(e, "Unable to obtain OAuth2 credential."); + throw new RuntimeException(e); + } + } + + /** Access token supplier that auto refreshes 1 minute before expiry. */ + @Singleton + @Provides + @Named("accessToken") + static Supplier provideAccessTokenSupplier( + GoogleCredential credential, ProxyConfig config) { + return memoizeWithExpiration( + () -> { + try { + credential.refreshToken(); + } catch (IOException e) { + logger.severe(e, "Cannot refresh access token."); + throw new RuntimeException(e); + } + return credential.getAccessToken(); + }, + config.accessTokenValidPeriodSeconds - config.accessTokenRefreshBeforeExpirySeconds, + SECONDS); + } + + @Singleton + @Provides + static CloudKMS provideCloudKms(GoogleCredential credential, ProxyConfig config) { + return new CloudKMS.Builder( + Utils.getDefaultTransport(), Utils.getDefaultJsonFactory(), credential) + .setApplicationName(config.projectId) + .build(); + } + + @Singleton + @Provides + @Named("encryptedPemBytes") + static byte[] provideEncryptedPemBytes(ProxyConfig config) { + try { + return readResourceBytes(ProxyModule.class, "resources/" + config.sslPemFilename).read(); + } catch (IOException e) { + logger.severefmt(e, "Error reading encrypted PEM file: %s", config.sslPemFilename); + throw new RuntimeException(e); + } + } + + @Singleton + @Provides + static PemBytes providePemBytes( + CloudKMS cloudKms, @Named("encryptedPemBytes") byte[] encryptedPemBytes, ProxyConfig config) { + String cryptoKeyUrl = + String.format( + "projects/%s/locations/%s/keyRings/%s/cryptoKeys/%s", + config.projectId, config.kms.location, config.kms.keyRing, config.kms.cryptoKey); + try { + DecryptRequest decryptRequest = new DecryptRequest().encodeCiphertext(encryptedPemBytes); + return PemBytes.create( + cloudKms + .projects() + .locations() + .keyRings() + .cryptoKeys() + .decrypt(cryptoKeyUrl, decryptRequest) + .execute() + .decodePlaintext()); + } catch (IOException e) { + logger.severefmt(e, "PEM file decryption failed using CryptoKey: %s", cryptoKeyUrl); + throw new RuntimeException(e); + } + } + + @Provides + static SslProvider provideSslProvider() { + // Prefer OpenSSL. + return OpenSsl.isAvailable() ? SslProvider.OPENSSL : SslProvider.JDK; + } + + @Provides + @Singleton + static Clock provideClock() { + return new SystemClock(); + } + + @Singleton + @Provides + ProxyConfig provideProxyConfig(Environment env) { + return getProxyConfig(env); + } + + /** + * A wrapper class for decrypted bytes of the PEM file. + * + *

Note that this should not be an @AutoValue class because we need a clone of the bytes to be + * returned, otherwise the wrapper class becomes mutable. + */ + // TODO: remove this class once FOSS build can use @BindsInstance to bind a byte[] + // (https://github.com/bazelbuild/bazel/issues/4138) + static class PemBytes { + + private final byte[] bytes; + + static final PemBytes create(byte[] bytes) { + return new PemBytes(bytes); + } + + private PemBytes(byte[] bytes) { + this.bytes = bytes; + } + + byte[] getBytes() { + return bytes.clone(); + } + } + + /** Root level component that exposes the port-to-protocol map. */ + @Singleton + @Component( + modules = { + ProxyModule.class, + CertificateModule.class, + HttpsRelayProtocolModule.class, + WhoisProtocolModule.class, + EppProtocolModule.class, + HealthCheckProtocolModule.class + } + ) + interface ProxyComponent { + ImmutableMap portToProtocolMap(); + } +} diff --git a/java/google/registry/proxy/ProxyServer.java b/java/google/registry/proxy/ProxyServer.java new file mode 100644 index 000000000..2e664163e --- /dev/null +++ b/java/google/registry/proxy/ProxyServer.java @@ -0,0 +1,239 @@ +// Copyright 2017 The Nomulus Authors. All Rights Reserved. +// +// 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. + +package google.registry.proxy; + +import static google.registry.proxy.Protocol.PROTOCOL_KEY; +import static google.registry.proxy.handler.RelayHandler.RELAY_CHANNEL_KEY; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import google.registry.monitoring.metrics.MetricReporter; +import google.registry.proxy.Protocol.BackendProtocol; +import google.registry.proxy.Protocol.FrontendProtocol; +import google.registry.proxy.ProxyModule.ProxyComponent; +import google.registry.util.FormattingLogger; +import io.netty.bootstrap.Bootstrap; +import io.netty.bootstrap.ServerBootstrap; +import io.netty.channel.Channel; +import io.netty.channel.ChannelFuture; +import io.netty.channel.ChannelHandler; +import io.netty.channel.ChannelInitializer; +import io.netty.channel.ChannelOption; +import io.netty.channel.ChannelPipeline; +import io.netty.channel.EventLoopGroup; +import io.netty.channel.nio.NioEventLoopGroup; +import io.netty.channel.socket.nio.NioServerSocketChannel; +import io.netty.channel.socket.nio.NioSocketChannel; +import io.netty.util.concurrent.Future; +import io.netty.util.internal.logging.InternalLoggerFactory; +import io.netty.util.internal.logging.JdkLoggerFactory; +import java.util.HashMap; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import javax.inject.Provider; + +/** + * A multi-protocol proxy server that listens on port(s) specified in {@link + * ProxyModule.ProxyComponent#portToProtocolMap()} }. + */ +public class ProxyServer implements Runnable { + + private static final FormattingLogger logger = FormattingLogger.getLoggerForCallerClass(); + private static final MetricReporter metricReporter = + DaggerMetricsModule_MetricsComponent.create().metricReporter(); + /** Maximum length of the queue of incoming connections. */ + private static final int MAX_SOCKET_BACKLOG = 128; + + private final ImmutableMap portToProtocolMap; + private final HashMap portToChannelMap = new HashMap<>(); + private final EventLoopGroup eventGroup = new NioEventLoopGroup(); + + ProxyServer(ProxyComponent proxyComponent) { + this.portToProtocolMap = proxyComponent.portToProtocolMap(); + } + + /** + * A {@link ChannelInitializer} for connections from a client of a certain protocol. + * + *

The {@link #initChannel} method does the following: + * + *

    + *
  1. Determine the {@link FrontendProtocol} of the inbound {@link Channel} from its parent + * {@link Channel}, i. e. the {@link Channel} that binds to local port and listens. + *
  2. Add handlers for the {@link FrontendProtocol} to the inbound {@link Channel}. + *
  3. Establish an outbound {@link Channel} that serves as the relay channel of the inbound + * {@link Channel}, as specified by {@link FrontendProtocol#relayProtocol}. + *
  4. After the outbound {@link Channel} connects successfully, enable {@link + * ChannelOption#AUTO_READ} on the inbound {@link Channel} to start reading. + *
+ */ + private static class ServerChannelInitializer extends ChannelInitializer { + @Override + protected void initChannel(NioSocketChannel inboundChannel) throws Exception { + // Add inbound channel handlers. + FrontendProtocol inboundProtocol = + (FrontendProtocol) inboundChannel.parent().attr(PROTOCOL_KEY).get(); + inboundChannel.attr(PROTOCOL_KEY).set(inboundProtocol); + addHandlers(inboundChannel.pipeline(), inboundProtocol.handlerProviders()); + + if (inboundProtocol.isHealthCheck()) { + // A health check server protocol has no relay channel. It simply replies to incoming + // request with a preset response. + inboundChannel.config().setAutoRead(true); + } else { + // Connect to the relay (outbound) channel specified by the BackendProtocol. + BackendProtocol outboundProtocol = inboundProtocol.relayProtocol(); + Bootstrap bootstrap = + new Bootstrap() + // Use the same thread to connect to the relay channel, therefore avoiding + // synchronization handling due to interactions between the two channels + .group(inboundChannel.eventLoop()) + .channel(NioSocketChannel.class) + .handler( + new ChannelInitializer() { + @Override + protected void initChannel(NioSocketChannel outboundChannel) + throws Exception { + addHandlers( + outboundChannel.pipeline(), outboundProtocol.handlerProviders()); + } + }) + .option(ChannelOption.SO_KEEPALIVE, true) + // Outbound channel relays to inbound channel. + .attr(RELAY_CHANNEL_KEY, inboundChannel) + .attr(PROTOCOL_KEY, outboundProtocol); + ChannelFuture outboundChannelFuture = + bootstrap.connect(outboundProtocol.host(), outboundProtocol.port()); + outboundChannelFuture.addListener( + (ChannelFuture future) -> { + if (future.isSuccess()) { + Channel outboundChannel = future.channel(); + // Inbound channel relays to outbound channel. + inboundChannel.attr(RELAY_CHANNEL_KEY).set(outboundChannel); + // Outbound channel established successfully, inbound channel can start reading. + // This setter also calls channel.read() to request read operation. + inboundChannel.config().setAutoRead(true); + logger.infofmt( + "Relay established: %s <-> %s\nSERVER: %s\nCLIENT: %s", + inboundProtocol.name(), + outboundProtocol.name(), + inboundChannel, + outboundChannel); + } else { + logger.severefmt( + future.cause(), + "Cannot connect to relay channel for %s protocol connection from %s.", + inboundProtocol.name(), + inboundChannel.remoteAddress().getHostName()); + } + }); + } + } + + private static void addHandlers( + ChannelPipeline channelPipeline, + ImmutableList> handlerProviders) { + for (Provider handlerProvider : handlerProviders) { + channelPipeline.addLast(handlerProvider.get()); + } + } + } + + @Override + public void run() { + try { + ServerBootstrap serverBootstrap = + new ServerBootstrap() + .group(eventGroup) + .channel(NioServerSocketChannel.class) + .childHandler(new ServerChannelInitializer()) + .option(ChannelOption.SO_BACKLOG, MAX_SOCKET_BACKLOG) + .childOption(ChannelOption.SO_KEEPALIVE, true) + // Do not read before relay channel is established. + .childOption(ChannelOption.AUTO_READ, false); + + // Bind to each port specified in portToHandlersMap. + portToProtocolMap.forEach( + (port, protocol) -> { + try { + // Wait for binding to be established for each listening port. + ChannelFuture serverChannelFuture = serverBootstrap.bind(port).sync(); + if (serverChannelFuture.isSuccess()) { + logger.infofmt( + "Start listening on port %s for %s protocol.", port, protocol.name()); + Channel serverChannel = serverChannelFuture.channel(); + serverChannel.attr(PROTOCOL_KEY).set(protocol); + portToChannelMap.put(port, serverChannel); + } + } catch (InterruptedException e) { + logger.severefmt( + e, "Cannot listen on port %s for %s protocol.", port, protocol.name()); + } + }); + + // Wait for all listening ports to close. + portToChannelMap.forEach( + (port, channel) -> { + try { + // Block until all server channels are closed. + ChannelFuture unusedFuture = channel.closeFuture().sync(); + logger.infofmt( + "Stop listening on port %s for %s protocol.", + port, channel.attr(PROTOCOL_KEY).get().name()); + } catch (InterruptedException e) { + logger.severefmt( + e, + "Listening on port %s for %s protocol interrupted.", + port, + channel.attr(PROTOCOL_KEY).get().name()); + } + }); + } finally { + logger.info("Shutting down server..."); + Future unusedFuture = eventGroup.shutdownGracefully(); + } + } + + public static void main(String[] args) throws Exception { + // Use JDK logger for Netty's LoggingHandler, + // which is what google.registry.util.FormattingLog uses under the hood. + InternalLoggerFactory.setDefaultFactory(JdkLoggerFactory.INSTANCE); + + try { + metricReporter.startAsync().awaitRunning(10, TimeUnit.SECONDS); + logger.info("Started up MetricReporter"); + } catch (TimeoutException timeoutException) { + logger.severefmt("Failed to initialize MetricReporter: %s", timeoutException); + } + + Runtime.getRuntime() + .addShutdownHook( + new Thread( + () -> { + try { + metricReporter.stopAsync().awaitTerminated(10, TimeUnit.SECONDS); + logger.info("Shut down MetricReporter"); + } catch (TimeoutException timeoutException) { + logger.severefmt("Failed to stop MetricReporter: %s", timeoutException); + } + })); + + ProxyComponent proxyComponent = + DaggerProxyModule_ProxyComponent.builder() + .proxyModule(new ProxyModule().parse(args)) + .build(); + new ProxyServer(proxyComponent).run(); + } +} diff --git a/java/google/registry/proxy/WhoisProtocolModule.java b/java/google/registry/proxy/WhoisProtocolModule.java new file mode 100644 index 000000000..c453dddf6 --- /dev/null +++ b/java/google/registry/proxy/WhoisProtocolModule.java @@ -0,0 +1,98 @@ +// Copyright 2017 The Nomulus Authors. All Rights Reserved. +// +// 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. + +package google.registry.proxy; + +import com.google.common.base.Supplier; +import com.google.common.collect.ImmutableList; +import dagger.Module; +import dagger.Provides; +import dagger.multibindings.IntoSet; +import google.registry.proxy.HttpsRelayProtocolModule.HttpsRelayProtocol; +import google.registry.proxy.Protocol.BackendProtocol; +import google.registry.proxy.Protocol.FrontendProtocol; +import google.registry.proxy.handler.RelayHandler.FullHttpRequestRelayHandler; +import google.registry.proxy.handler.WhoisServiceHandler; +import google.registry.proxy.metric.FrontendMetrics; +import io.netty.channel.ChannelHandler; +import io.netty.handler.codec.LineBasedFrameDecoder; +import io.netty.handler.logging.LoggingHandler; +import io.netty.handler.timeout.ReadTimeoutHandler; +import javax.inject.Named; +import javax.inject.Provider; +import javax.inject.Qualifier; +import javax.inject.Singleton; + +/** A module that provides the {@link FrontendProtocol} used for whois protocol. */ +@Module +class WhoisProtocolModule { + + /** Dagger qualifier to provide whois protocol related handlers and other bindings. */ + @Qualifier + @interface WhoisProtocol {}; + + private static final String PROTOCOL_NAME = "whois"; + + @Singleton + @Provides + @IntoSet + static FrontendProtocol provideProtocol( + ProxyConfig config, + @WhoisProtocol int whoisPort, + @WhoisProtocol ImmutableList> handlerProviders, + @HttpsRelayProtocol BackendProtocol.Builder backendProtocolBuilder) { + return Protocol.frontendBuilder() + .name(PROTOCOL_NAME) + .port(whoisPort) + .handlerProviders(handlerProviders) + .relayProtocol(backendProtocolBuilder.host(config.whois.relayHost).build()) + .build(); + } + + @Provides + @WhoisProtocol + static ImmutableList> provideHandlerProviders( + @WhoisProtocol Provider readTimeoutHandlerProvider, + Provider lineBasedFrameDecoderProvider, + Provider whoisServiceHandlerProvider, + Provider loggingHandlerProvider, + Provider relayHandlerProvider) { + return ImmutableList.of( + readTimeoutHandlerProvider, + lineBasedFrameDecoderProvider, + whoisServiceHandlerProvider, + loggingHandlerProvider, + relayHandlerProvider); + } + + @Provides + static WhoisServiceHandler provideWhoisServiceHandler( + ProxyConfig config, + @Named("accessToken") Supplier accessTokenSupplier, + FrontendMetrics metrics) { + return new WhoisServiceHandler( + config.whois.relayHost, config.whois.relayPath, accessTokenSupplier, metrics); + } + + @Provides + static LineBasedFrameDecoder provideLineBasedFrameDecoder(ProxyConfig config) { + return new LineBasedFrameDecoder(config.whois.maxMessageLengthBytes); + } + + @Provides + @WhoisProtocol + static ReadTimeoutHandler provideReadTimeoutHandler(ProxyConfig config) { + return new ReadTimeoutHandler(config.whois.readTimeoutSeconds); + } +} diff --git a/java/google/registry/proxy/config/default-config.yaml b/java/google/registry/proxy/config/default-config.yaml new file mode 100644 index 000000000..639d50db3 --- /dev/null +++ b/java/google/registry/proxy/config/default-config.yaml @@ -0,0 +1,128 @@ +# This is the default configuration file for the proxy. Do not make changes to +# it unless you are writing new features that requires you to. To customize an +# individual deployment or environment, create a proxy-config.yaml file in the +# same directory overriding only the values you wish to change. You may need +# to override some of these values to configure and enable some services used in +# production environments. + +# GCP project ID +projectId: your-gcp-project-id + +# OAuth scope that the GoogleCredential will be constructed with. This list +# should include all service scopes that the proxy depends on. +gcpScopes: + # The default OAuth scope granted to GCE instances. Local development instance + # needs this scope to mimic running on GCE. Currently it is used to access + # Cloud KMS and Stackdriver Monitoring APIs. + - https://www.googleapis.com/auth/cloud-platform + + # The OAuth scope required to be included in the access token for the GAE app + # to authenticate. + - https://www.googleapis.com/auth/userinfo.email + +# Access token is valid for 60 minutes. +# +# See also: Data store +# (https://developers.google.com/api-client-library/java/google-api-java-client/oauth2#data_store). +accessTokenValidPeriodSeconds: 3600 + +# Access token is refreshed 1 minutes before expiry. +# +# This is the default refresh time used by +# com.google.api.client.auth.oauth2.Credential#intercept. +accessTokenRefreshBeforeExpirySeconds: 60 + +# Name of the encrypted PEM file. +sslPemFilename: your-ssl.pem + +# Strings used to construct the KMS crypto key URL. +# See: https://cloud.google.com/kms/docs/reference/rest/v1/projects.locations.keyRings.cryptoKeys +kms: + # Location where your key ring is stored (global, us-east1, etc). + location: your-kms-location + + # Name of the KeyRing that contains the CryptoKey file. + keyRing: your-kms-keyRing + + # Name of the CryptoKey used to encrypt the PEM file. + cryptoKey: your-kms-cryptoKey + +epp: + port: 700 + relayHost: registry-project-id.appspot.com + relayPath: /_dr/epp + + # Maximum input message length in bytes. + # + # The first 4 bytes in a message is the total length of message, in bytes. + # + # We accept a message up to 1 GB, which should be plentiful, if not over the + # top. In fact we should probably limit this to a more reasonable number, as a + # 1 GB message will likely cause the proxy to go out of memory. + # + # See also: RFC 5734 4 Data Unit Format + # (https://tools.ietf.org/html/rfc5734#section-4). + maxMessageLengthBytes: 1073741824 + + # Length of the header field in bytes. + # + # Note that value of the header field is the total length (in bytes) of the + # message, including the header itself, the length of the epp xml instance is + # therefore 4 bytes shorter than this value. + headerLengthBytes: 4 + + # Time after which an idle connection will be closed. + # + # The RFC gives registry discretionary power to set a timeout period. 1 hr + # should be reasonable enough for any registrar to login and submit their + # request. + readTimeoutSeconds: 3600 + + # Hostname of the EPP server. + # TODO(b/64510444) Remove this after nomulus no longer check sni header. + serverHostname: epp.yourdomain.tld + +whois: + port: 43 + relayHost: registry-project-id.appspot.com + relayPath: /_dr/whois + + # Maximum input message length in bytes. + # + # Domain name cannot be longer than 256 characters. 512-character message + # length should be safe for most cases, including registrar queries. + # + # See also: RFC 1035 2.3.4 Size limits + # (http://www.freesoft.org/CIE/RFC/1035/9.htm). + maxMessageLengthBytes: 512 + + # Whois protocol is transient, the client should not establish a long lasting + # idle connection. + readTimeoutSeconds: 60 + +healthCheck: + port: 11111 + + # Health checker request message, defined in GCP load balancer backend. + checkRequest: HEALTH_CHECK_REQUEST + + # Health checker response message, defined in GCP load balancer backend. + checkResponse: HEALTH_CHECK_RESPONSE + +httpsRelay: + port: 443 + + # Maximum size of an HTTP message in bytes. + maxMessageLengthBytes: 524288 + +metrics: + # Max queries per second for the Google Cloud Monitoring V3 (aka Stackdriver) + # API. The limit can be adjusted by contacting Cloud Support. + stackdriverMaxQps: 30 + + # Max number of points that can be sent to Stackdriver in a single + # TimeSeries.Create API call. + stackdriverMaxPointsPerRequest: 200 + + # How often metrics are written. + writeIntervalSeconds: 60 diff --git a/java/google/registry/proxy/config/proxy-config-alpha.yaml b/java/google/registry/proxy/config/proxy-config-alpha.yaml new file mode 100644 index 000000000..ea71687e3 --- /dev/null +++ b/java/google/registry/proxy/config/proxy-config-alpha.yaml @@ -0,0 +1 @@ +# Add environment-specific proxy configuration here. diff --git a/java/google/registry/proxy/config/proxy-config-local.yaml b/java/google/registry/proxy/config/proxy-config-local.yaml new file mode 100644 index 000000000..ea71687e3 --- /dev/null +++ b/java/google/registry/proxy/config/proxy-config-local.yaml @@ -0,0 +1 @@ +# Add environment-specific proxy configuration here. diff --git a/java/google/registry/proxy/config/proxy-config-production.yaml b/java/google/registry/proxy/config/proxy-config-production.yaml new file mode 100644 index 000000000..ea71687e3 --- /dev/null +++ b/java/google/registry/proxy/config/proxy-config-production.yaml @@ -0,0 +1 @@ +# Add environment-specific proxy configuration here. diff --git a/java/google/registry/proxy/config/proxy-config-sandbox.yaml b/java/google/registry/proxy/config/proxy-config-sandbox.yaml new file mode 100644 index 000000000..ea71687e3 --- /dev/null +++ b/java/google/registry/proxy/config/proxy-config-sandbox.yaml @@ -0,0 +1 @@ +# Add environment-specific proxy configuration here. diff --git a/java/google/registry/proxy/config/proxy-config-test.yaml b/java/google/registry/proxy/config/proxy-config-test.yaml new file mode 100644 index 000000000..70d433c07 --- /dev/null +++ b/java/google/registry/proxy/config/proxy-config-test.yaml @@ -0,0 +1 @@ +# This file is for test only. Leave it blank. diff --git a/java/google/registry/proxy/handler/BackendMetricsHandler.java b/java/google/registry/proxy/handler/BackendMetricsHandler.java new file mode 100644 index 000000000..7f0d81844 --- /dev/null +++ b/java/google/registry/proxy/handler/BackendMetricsHandler.java @@ -0,0 +1,129 @@ +// Copyright 2017 The Nomulus Authors. All Rights Reserved. +// +// 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. + +package google.registry.proxy.handler; + +import static com.google.common.base.Preconditions.checkArgument; +import static com.google.common.base.Preconditions.checkNotNull; +import static com.google.common.base.Preconditions.checkState; +import static google.registry.proxy.Protocol.PROTOCOL_KEY; +import static google.registry.proxy.handler.EppServiceHandler.CLIENT_CERTIFICATE_HASH_KEY; +import static google.registry.proxy.handler.RelayHandler.RELAY_CHANNEL_KEY; + +import google.registry.proxy.handler.RelayHandler.FullHttpResponseRelayHandler; +import google.registry.proxy.metric.BackendMetrics; +import google.registry.util.Clock; +import io.netty.channel.Channel; +import io.netty.channel.ChannelDuplexHandler; +import io.netty.channel.ChannelFuture; +import io.netty.channel.ChannelHandlerContext; +import io.netty.channel.ChannelPromise; +import io.netty.handler.codec.http.FullHttpRequest; +import io.netty.handler.codec.http.FullHttpResponse; +import java.util.ArrayDeque; +import java.util.Optional; +import java.util.Queue; +import javax.inject.Inject; +import org.joda.time.DateTime; + +/** + * Handler that records metrics a backend channel. + * + *

This handler is added right before {@link FullHttpResponseRelayHandler} in the backend + * protocol handler provider method. {@link FullHttpRequest} outbound messages encounter this first + * before being handed over to HTTP related handler. {@link FullHttpResponse} inbound messages are + * first constructed (from plain bytes) by preceding handlers and then logged in this handler. + */ +public class BackendMetricsHandler extends ChannelDuplexHandler { + + private final Clock clock; + private final BackendMetrics metrics; + + private String relayedProtocolName; + private String clientCertHash; + private Channel relayedChannel; + + /** + * A queue that saves the time at which a request is sent to the GAE app. + * + *

This queue is used to calculate HTTP request-response latency. HTTP 1.1 specification allows + * for pipelining, in which a client can sent multiple requests without waiting for each + * responses. Therefore a queue is needed to record all the requests that are sent but have not + * yet received a response. + * + *

A server must send its response in the same order it receives requests. This invariance + * guarantees that the request time at the head of the queue always corresponds to the response + * received in {@link #channelRead}. + * + * @see RFC 2616 8.1.2.2 + * Pipelining + */ + private final Queue requestSentTimeQueue = new ArrayDeque<>(); + + @Inject + BackendMetricsHandler(Clock clock, BackendMetrics metrics) { + this.clock = clock; + this.metrics = metrics; + } + + @Override + public void channelActive(ChannelHandlerContext ctx) throws Exception { + // Backend channel is always established after a frontend channel is connected, so this + relayedChannel = ctx.channel().attr(RELAY_CHANNEL_KEY).get(); + checkNotNull(relayedChannel, "No frontend channel found."); + relayedProtocolName = relayedChannel.attr(PROTOCOL_KEY).get().name(); + super.channelActive(ctx); + } + + @Override + public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception { + checkArgument(msg instanceof FullHttpResponse, "Incoming response must be FullHttpResponse."); + checkState(!requestSentTimeQueue.isEmpty(), "Response received before request is sent."); + metrics.responseReceived( + relayedProtocolName, + clientCertHash, + (FullHttpResponse) msg, + clock.nowUtc().getMillis() - requestSentTimeQueue.remove().getMillis()); + super.channelRead(ctx, msg); + } + + @Override + public void write(ChannelHandlerContext ctx, Object msg, ChannelPromise promise) + throws Exception { + checkArgument(msg instanceof FullHttpRequest, "Outgoing request must be FullHttpRequest."); + // For WHOIS, client certificate hash is always set to "none". + // For EPP, the client hash attribute is set upon handshake completion, before the first HELLO + // is sent to the server. Therefore the first call to write() with HELLO payload has access to + // the hash in its channel attribute. + if (clientCertHash == null) { + clientCertHash = + Optional.ofNullable(relayedChannel.attr(CLIENT_CERTIFICATE_HASH_KEY).get()) + .orElse("none"); + } + FullHttpRequest request = (FullHttpRequest) msg; + + // Record sent time before write finishes allows us to take network latency into account. + DateTime sentTime = clock.nowUtc(); + ChannelFuture unusedFuture = + ctx.write(msg, promise) + .addListener( + future -> { + if (future.isSuccess()) { + // Only instrument request metrics when the request is actually sent to GAE. + metrics.requestSent(relayedProtocolName, clientCertHash, request); + requestSentTimeQueue.add(sentTime); + } + }); + } +} diff --git a/java/google/registry/proxy/handler/EppServiceHandler.java b/java/google/registry/proxy/handler/EppServiceHandler.java new file mode 100644 index 000000000..c9f82a977 --- /dev/null +++ b/java/google/registry/proxy/handler/EppServiceHandler.java @@ -0,0 +1,154 @@ +// Copyright 2017 The Nomulus Authors. All Rights Reserved. +// +// 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. + +package google.registry.proxy.handler; + +import static com.google.common.base.Preconditions.checkArgument; +import static com.google.common.base.Preconditions.checkNotNull; +import static google.registry.proxy.handler.ProxyProtocolHandler.REMOTE_ADDRESS_KEY; +import static google.registry.proxy.handler.SslServerInitializer.CLIENT_CERTIFICATE_PROMISE_KEY; +import static google.registry.util.X509Utils.getCertificateHash; + +import com.google.common.base.Supplier; +import google.registry.proxy.metric.FrontendMetrics; +import google.registry.util.FormattingLogger; +import io.netty.buffer.ByteBuf; +import io.netty.buffer.Unpooled; +import io.netty.channel.ChannelFuture; +import io.netty.channel.ChannelFutureListener; +import io.netty.channel.ChannelHandlerContext; +import io.netty.channel.ChannelPromise; +import io.netty.handler.codec.http.FullHttpRequest; +import io.netty.handler.codec.http.HttpHeaderNames; +import io.netty.handler.codec.http.HttpResponse; +import io.netty.handler.ssl.SslHandshakeCompletionEvent; +import io.netty.util.AttributeKey; +import io.netty.util.concurrent.Promise; +import java.security.cert.X509Certificate; + +/** Handler that processes EPP protocol logic. */ +public class EppServiceHandler extends HttpsRelayServiceHandler { + + private static final FormattingLogger logger = FormattingLogger.getLoggerForCallerClass(); + + /** + * Attribute key to the client certificate hash whose value is set when the certificate promise is + * fulfilled. + */ + public static final AttributeKey CLIENT_CERTIFICATE_HASH_KEY = + AttributeKey.valueOf("CLIENT_CERTIFICATE_HASH_KEY"); + + /** Name of the HTTP header that stores the client certificate hash. */ + public static final String SSL_CLIENT_CERTIFICATE_HASH_FIELD = "X-SSL-Certificate"; + + /** Name of the HTTP header that stores the epp server name requested by the client using SNI. */ + // TODO(b/64510444): remove this header entirely when borg proxy is retired. + public static final String REQUESTED_SERVERNAME_VIA_SNI_FIELD = "X-Requested-Servername-SNI"; + + /** Name of the HTTP header that stores the client IP address. */ + public static final String FORWARDED_FOR_FIELD = "X-Forwarded-For"; + + /** Name of the HTTP header that indicates if the EPP session should be closed. */ + public static final String EPP_SESSION_FIELD = "Epp-Session"; + + public static final String EPP_CONTENT_TYPE = "application/epp+xml"; + + private final String serverHostname; + private final byte[] helloBytes; + + private String sslClientCertificateHash; + private String clientAddress; + + public EppServiceHandler( + String relayHost, + String relayPath, + Supplier accessTokenSupplier, + String serverHostname, + byte[] helloBytes, + FrontendMetrics metrics) { + super(relayHost, relayPath, accessTokenSupplier, metrics); + this.serverHostname = serverHostname; + this.helloBytes = helloBytes; + } + + /** + * Write to the server after SSL handshake completion to request + * + *

When handling EPP over TCP, the server should issue a to the client when a + * connection is established. Nomulus app however does not automatically sends the upon + * connection. The proxy therefore first sends a to registry to request a + * response. + * + *

The request is only sent after SSL handshake is completed between the client and the + * proxy so that the client certificate hash is available, which is needed to communicate with the + * server. Because {@link SslHandshakeCompletionEvent} is triggered before any calls to {@link + * #channelRead} are scheduled by the event loop executor, the request is guaranteed to be + * the first message sent to the server. + * + * @see RFC 5732 EPP Transport over TCP + * @see The Proxy + * Protocol + */ + @Override + public void channelActive(ChannelHandlerContext ctx) throws Exception { + Promise unusedPromise = + ctx.channel() + .attr(CLIENT_CERTIFICATE_PROMISE_KEY) + .get() + .addListener( + (Promise promise) -> { + if (promise.isSuccess()) { + sslClientCertificateHash = getCertificateHash(promise.get()); + // Set the client cert hash key attribute for both this channel, + // used for collecting metrics on specific clients. + ctx.channel().attr(CLIENT_CERTIFICATE_HASH_KEY).set(sslClientCertificateHash); + clientAddress = ctx.channel().attr(REMOTE_ADDRESS_KEY).get(); + metrics.registerActiveConnection( + "epp", sslClientCertificateHash, ctx.channel()); + channelRead(ctx, Unpooled.wrappedBuffer(helloBytes)); + } else { + logger.severefmt(promise.cause(), "Cannot finish handshake."); + ChannelFuture unusedFuture = ctx.close(); + } + }); + super.channelActive(ctx); + } + + @Override + protected FullHttpRequest decodeFullHttpRequest(ByteBuf byteBuf) { + checkNotNull(clientAddress, "Cannot obtain client address."); + checkNotNull(sslClientCertificateHash, "Cannot obtain client certificate hash."); + FullHttpRequest request = super.decodeFullHttpRequest(byteBuf); + request + .headers() + .set(SSL_CLIENT_CERTIFICATE_HASH_FIELD, sslClientCertificateHash) + .set(REQUESTED_SERVERNAME_VIA_SNI_FIELD, serverHostname) + .set(FORWARDED_FOR_FIELD, clientAddress) + .set(HttpHeaderNames.CONTENT_TYPE, EPP_CONTENT_TYPE) + .set(HttpHeaderNames.ACCEPT, EPP_CONTENT_TYPE); + return request; + } + + @Override + public void write(ChannelHandlerContext ctx, Object msg, ChannelPromise promise) + throws Exception { + checkArgument(msg instanceof HttpResponse); + HttpResponse response = (HttpResponse) msg; + String sessionAliveValue = response.headers().get(EPP_SESSION_FIELD); + if (sessionAliveValue != null && sessionAliveValue.equals("close")) { + promise.addListener(ChannelFutureListener.CLOSE); + } + super.write(ctx, msg, promise); + } +} diff --git a/java/google/registry/proxy/handler/HealthCheckHandler.java b/java/google/registry/proxy/handler/HealthCheckHandler.java new file mode 100644 index 000000000..3d1ce36a5 --- /dev/null +++ b/java/google/registry/proxy/handler/HealthCheckHandler.java @@ -0,0 +1,42 @@ +// Copyright 2017 The Nomulus Authors. All Rights Reserved. +// +// 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. + +package google.registry.proxy.handler; + +import io.netty.buffer.ByteBuf; +import io.netty.buffer.Unpooled; +import io.netty.channel.ChannelHandlerContext; +import io.netty.channel.ChannelInboundHandlerAdapter; +import java.nio.charset.StandardCharsets; + +/** A handler that responds to GCP load balancer health check message */ +public class HealthCheckHandler extends ChannelInboundHandlerAdapter { + + private final ByteBuf checkRequest; + private final ByteBuf checkResponse; + + public HealthCheckHandler(String checkRequest, String checkResponse) { + this.checkRequest = Unpooled.wrappedBuffer(checkRequest.getBytes(StandardCharsets.US_ASCII)); + this.checkResponse = Unpooled.wrappedBuffer(checkResponse.getBytes(StandardCharsets.US_ASCII)); + } + + @Override + public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception { + ByteBuf buf = (ByteBuf) msg; + if (buf.equals(checkRequest)) { + ctx.writeAndFlush(checkResponse); + } + buf.release(); + } +} diff --git a/java/google/registry/proxy/handler/HttpsRelayServiceHandler.java b/java/google/registry/proxy/handler/HttpsRelayServiceHandler.java new file mode 100644 index 000000000..981ad2919 --- /dev/null +++ b/java/google/registry/proxy/handler/HttpsRelayServiceHandler.java @@ -0,0 +1,185 @@ +// Copyright 2017 The Nomulus Authors. All Rights Reserved. +// +// 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. + +package google.registry.proxy.handler; + +import static com.google.common.base.Preconditions.checkArgument; +import static java.nio.charset.StandardCharsets.UTF_8; + +import com.google.common.base.Supplier; +import google.registry.proxy.metric.FrontendMetrics; +import google.registry.util.FormattingLogger; +import io.netty.buffer.ByteBuf; +import io.netty.channel.ChannelFuture; +import io.netty.channel.ChannelHandlerContext; +import io.netty.channel.ChannelPromise; +import io.netty.handler.codec.ByteToMessageCodec; +import io.netty.handler.codec.http.DefaultFullHttpRequest; +import io.netty.handler.codec.http.FullHttpRequest; +import io.netty.handler.codec.http.FullHttpResponse; +import io.netty.handler.codec.http.HttpHeaderNames; +import io.netty.handler.codec.http.HttpMethod; +import io.netty.handler.codec.http.HttpResponseStatus; +import io.netty.handler.codec.http.HttpVersion; +import io.netty.handler.codec.http.cookie.ClientCookieDecoder; +import io.netty.handler.codec.http.cookie.ClientCookieEncoder; +import io.netty.handler.codec.http.cookie.Cookie; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Handler that relays a single (framed) ByteBuf message to an HTTPS server. + * + *

This handler reads in a {@link ByteBuf}, converts it to an {@link FullHttpRequest}, and passes + * it to the {@code channelRead} method of the next inbound handler the channel pipeline, which is + * usually a {@link RelayHandler}. The relay handler writes the request to the + * relay channel, which is connected to an HTTPS endpoint. After the relay channel receives a {@link + * FullHttpResponse} back, its own relay handler writes the response back to this channel, which is + * the relay channel of the relay channel. This handler then handles write request by encoding the + * {@link FullHttpResponse} to a plain {@link ByteBuf}, and pass it down to the {@code write} method + * of the next outbound handler in the channel pipeline, which eventually writes the response bytes + * to the remote peer of this channel. + * + *

This handler is session aware and will store all the session cookies that the are contained in + * the HTTP response headers, which are added back to headers of subsequent HTTP requests. + */ +abstract class HttpsRelayServiceHandler extends ByteToMessageCodec { + + private static final FormattingLogger logger = FormattingLogger.getLoggerForCallerClass(); + + private final Map cookieStore = new LinkedHashMap<>(); + private final String relayHost; + private final String relayPath; + private final Supplier accessTokenSupplier; + + protected final FrontendMetrics metrics; + + HttpsRelayServiceHandler( + String relayHost, + String relayPath, + Supplier accessTokenSupplier, + FrontendMetrics metrics) { + this.relayHost = relayHost; + this.relayPath = relayPath; + this.accessTokenSupplier = accessTokenSupplier; + this.metrics = metrics; + } + + /** + * Construct the {@link FullHttpRequest}. + * + *

This default method creates a bare-bone {@link FullHttpRequest} that may need to be + * modified, e. g. adding headers specific for each protocol. + * + * @param byteBuf inbound message. + */ + protected FullHttpRequest decodeFullHttpRequest(ByteBuf byteBuf) { + FullHttpRequest request = + new DefaultFullHttpRequest(HttpVersion.HTTP_1_1, HttpMethod.POST, relayPath); + request + .headers() + .set(HttpHeaderNames.USER_AGENT, "Proxy") + .set(HttpHeaderNames.HOST, relayHost) + .set(HttpHeaderNames.AUTHORIZATION, "Bearer " + accessTokenSupplier.get()) + .set(HttpHeaderNames.CONTENT_LENGTH, byteBuf.readableBytes()); + request.content().writeBytes(byteBuf); + return request; + } + + /** + * Load session cookies in the cookie store and write them in to the HTTP request. + * + *

Multiple cookies are folded into one {@code Cookie} header per RFC 6265. + * + * @see RFC 6265 5.4.The Cookie + * Header + */ + private void loadCookies(FullHttpRequest request) { + if (!cookieStore.isEmpty()) { + request + .headers() + .set(HttpHeaderNames.COOKIE, ClientCookieEncoder.STRICT.encode(cookieStore.values())); + } + } + + @Override + protected void decode(ChannelHandlerContext ctx, ByteBuf byteBuf, List out) + throws Exception { + FullHttpRequest request = decodeFullHttpRequest(byteBuf); + loadCookies(request); + out.add(request); + } + + /** + * Construct the {@link ByteBuf} + * + *

This default method puts all the response payload into the {@link ByteBuf}. + * + * @param fullHttpResponse outbound http response. + */ + ByteBuf encodeFullHttpResponse(FullHttpResponse fullHttpResponse) { + return fullHttpResponse.content(); + } + + /** + * Save session cookies from the HTTP response header to the cookie store. + * + *

Multiple cookies are not folded in to one {@code Set-Cookie} header per RFC 6265. + * + * @see RFC 6265 3.Overview + */ + private void saveCookies(FullHttpResponse response) { + for (String cookieString : response.headers().getAll(HttpHeaderNames.SET_COOKIE)) { + Cookie cookie = ClientCookieDecoder.STRICT.decode(cookieString); + cookieStore.put(cookie.name(), cookie); + } + } + + @Override + protected void encode(ChannelHandlerContext ctx, FullHttpResponse response, ByteBuf byteBuf) + throws Exception { + checkArgument( + response.status().equals(HttpResponseStatus.OK), + "Cannot relay HTTP response status \"%s\"\n%s", + response.status(), + response.content().toString(UTF_8)); + saveCookies(response); + byteBuf.writeBytes(encodeFullHttpResponse(response)); + } + + /** Terminates connection upon inbound exception. */ + @Override + public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception { + logger.severefmt(cause, "Inbound exception caught for channel %s", ctx.channel()); + ChannelFuture unusedFuture = ctx.close(); + } + + /** Terminates connection upon outbound exception. */ + @Override + public void write(ChannelHandlerContext ctx, Object msg, ChannelPromise promise) + throws Exception { + promise.addListener( + (ChannelFuture channelFuture) -> { + if (!channelFuture.isSuccess()) { + logger.severefmt( + channelFuture.cause(), + "Outbound exception caught for channel %s", + channelFuture.channel()); + ChannelFuture unusedFuture = channelFuture.channel().close(); + } + }); + super.write(ctx, msg, promise); + } +} diff --git a/java/google/registry/proxy/handler/ProxyProtocolHandler.java b/java/google/registry/proxy/handler/ProxyProtocolHandler.java new file mode 100644 index 000000000..58275e696 --- /dev/null +++ b/java/google/registry/proxy/handler/ProxyProtocolHandler.java @@ -0,0 +1,167 @@ +// Copyright 2017 The Nomulus Authors. All Rights Reserved. +// +// 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. + +package google.registry.proxy.handler; + +import static com.google.common.base.Preconditions.checkState; +import static java.nio.charset.StandardCharsets.US_ASCII; + +import google.registry.util.FormattingLogger; +import io.netty.buffer.ByteBuf; +import io.netty.channel.ChannelHandlerContext; +import io.netty.handler.codec.ByteToMessageDecoder; +import io.netty.util.AttributeKey; +import java.net.InetSocketAddress; +import java.net.SocketAddress; +import java.util.List; +import javax.inject.Inject; + +/** + * Handler that processes possible existence of a PROXY protocol v1 header. + * + *

When an EPP client connects to the registry (through the proxy), the registry performs two + * validations to ensure that only known registrars are allowed. First it checks the sha265 hash of + * the client SSL certificate and match it to the hash stored in datastore for the registrar. It + * then checks if the connection is from an whitelisted IP address that belongs to that registrar. + * + *

The proxy receives client connects via the GCP load balancer, which results in the loss of + * original client IP from the channel. Luckily, the load balancer supports the PROXY protocol v1, + * which adds a header with source IP information, among other things, to the TCP request at the + * start of the connection. + * + *

This handler determines if a connection is proxied (PROXY protocol v1 header present) and + * correctly sets the source IP address to the channel's attribute regardless of whether it is + * proxied. After that it removes itself from the channel pipeline because the proxy header is only + * present at the beginning of the connection. + * + *

This handler must be the very first handler in a protocol, even before SSL handlers, because + * PROXY protocol header comes as the very first thing, even before SSL handshake request. + * + * @see The PROXY protocol + */ +public class ProxyProtocolHandler extends ByteToMessageDecoder { + + /** Key used to retrieve origin IP address from a channel's attribute. */ + public static final AttributeKey REMOTE_ADDRESS_KEY = + AttributeKey.valueOf("REMOTE_ADDRESS_KEY"); + + private static final FormattingLogger logger = FormattingLogger.getLoggerForCallerClass(); + + // The proxy header must start with this prefix. + // Sample header: "PROXY TCP4 255.255.255.255 255.255.255.255 65535 65535\r\n". + private static final byte[] HEADER_PREFIX = "PROXY".getBytes(US_ASCII); + + private boolean finished = false; + private String proxyHeader = null; + + @Inject + ProxyProtocolHandler() {} + + @Override + public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception { + super.channelRead(ctx, msg); + if (finished) { + if (proxyHeader != null) { + logger.finefmt("PROXIED CONNECTION: %s", ctx.channel()); + logger.finefmt("PROXY HEADER: %s", proxyHeader); + ctx.channel().attr(REMOTE_ADDRESS_KEY).set(proxyHeader.split(" ")[2]); + } else { + SocketAddress remoteAddress = ctx.channel().remoteAddress(); + if (remoteAddress instanceof InetSocketAddress) { + ctx.channel() + .attr(REMOTE_ADDRESS_KEY) + .set(((InetSocketAddress) remoteAddress).getAddress().getHostAddress()); + logger.finefmt("REMOTE IP ADDRESS: %s", ctx.channel().attr(REMOTE_ADDRESS_KEY).get()); + } + } + ctx.pipeline().remove(this); + } + } + + /** + * Attempts to decode an internally accumulated buffer and find the proxy protocol header. + * + *

When the connection is not proxied (i. e. the initial bytes are not "PROXY"), simply set + * {@link #finished} to true and allow the handler to be removed. Otherwise the handler waits + * until there's enough bytes to parse the header, save the parsed header to {@link #proxyHeader}, + * and then mark {@link #finished}. + * + * @param in internally accumulated buffer, newly arrived bytes are appended to it. + * @param out objects passed to the next handler, in this case nothing is ever passed because the + * header itself is processed and written to the attribute of the proxy, and the handler is + * then removed from the pipeline. + */ + @Override + protected void decode(ChannelHandlerContext ctx, ByteBuf in, List out) throws Exception { + // Wait until there are more bytes available than the header's length before processing. + if (in.readableBytes() >= HEADER_PREFIX.length) { + if (containsHeader(in)) { + // The inbound message contains the header, it must be a proxied connection. Note that + // currently proxied connection is only used for EPP protocol, which requires the connection + // to be SSL enabled. So the beginning of the inbound message upon connection can only be + // either the proxy header (when proxied), or SSL handshake request (when not proxied), + // which does not start with "PROXY". Therefore it is safe to assume that if the beginning + // of the message contains "PROXY", it must be proxied, and must contain \r\n. + int eol = findEndOfLine(in); + // If eol is not found, that is because that we do not yet have enough inbound message, do + // nothing and wait for more bytes to be readable. eol will eventually be positive because + // of the reasoning above: The connection starts with "PROXY", so it must be a proxied + // connection and contain \r\n. + if (eol >= 0) { + // ByteBuf.readBytes is called so that the header is processed and not passed to handlers + // further in the pipeline. + proxyHeader = in.readBytes(eol).toString(US_ASCII); + // Skip \r\n. + in.skipBytes(2); + // Proxy header processed, mark finished so that this handler is removed. + finished = true; + } + } else { + // The inbound message does not contain a proxy header, mark finished so that this handler + // is removed. Note that no inbound bytes are actually processed by this handler because we + // did not call ByteBuf.readBytes(), but ByteBuf.getByte(), which does not change reader + // index of the ByteBuf. So any inbound byte is then passed to the next handler to process. + finished = true; + } + } + } + + /** + * Returns the index in the buffer of the end of line found. Returns -1 if no end of line was + * found in the buffer. + */ + private static int findEndOfLine(final ByteBuf buffer) { + final int n = buffer.writerIndex(); + for (int i = buffer.readerIndex(); i < n; i++) { + final byte b = buffer.getByte(i); + if (b == '\r' && i < n - 1 && buffer.getByte(i + 1) == '\n') { + return i; // \r\n + } + } + return -1; // Not found. + } + + /** Checks if the given buffer contains the proxy header prefix. */ + private boolean containsHeader(ByteBuf buffer) { + // The readable bytes is always more or equal to the size of the header prefix because this + // method is only called when this condition is true. + checkState(buffer.readableBytes() >= HEADER_PREFIX.length); + for (int i = 0; i < HEADER_PREFIX.length; ++i) { + if (buffer.getByte(buffer.readerIndex() + i) != HEADER_PREFIX[i]) { + return false; + } + } + return true; + } +} diff --git a/java/google/registry/proxy/handler/RelayHandler.java b/java/google/registry/proxy/handler/RelayHandler.java new file mode 100644 index 000000000..6df16e9ea --- /dev/null +++ b/java/google/registry/proxy/handler/RelayHandler.java @@ -0,0 +1,99 @@ +// Copyright 2017 The Nomulus Authors. All Rights Reserved. +// +// 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. + +package google.registry.proxy.handler; + +import static com.google.common.base.Preconditions.checkNotNull; + +import google.registry.util.FormattingLogger; +import io.netty.channel.Channel; +import io.netty.channel.ChannelFuture; +import io.netty.channel.ChannelHandlerContext; +import io.netty.channel.SimpleChannelInboundHandler; +import io.netty.handler.codec.http.FullHttpRequest; +import io.netty.handler.codec.http.FullHttpResponse; +import io.netty.util.Attribute; +import io.netty.util.AttributeKey; +import javax.inject.Inject; + +/** + * Receives inbound massage of type {@code I}, and writes it to the {@code relayChannel} stored in + * the inbound channel's attribute. + */ +public class RelayHandler extends SimpleChannelInboundHandler { + + private static final FormattingLogger logger = FormattingLogger.getLoggerForCallerClass(); + + /** Key used to retrieve the relay channel from a {@link Channel}'s {@link Attribute}. */ + public static final AttributeKey RELAY_CHANNEL_KEY = + AttributeKey.valueOf("RELAY_CHANNEL"); + + public RelayHandler(Class clazz) { + super(clazz, false); + } + + /** Terminate connection when an exception is caught during inbound IO. */ + @Override + public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception { + logger.severefmt(cause, "Inbound exception caught for channel %s", ctx.channel()); + ctx.close(); + } + + /** Close relay channel if this channel is closed. */ + @Override + public void channelInactive(ChannelHandlerContext ctx) throws Exception { + Channel relayChannel = ctx.channel().attr(RELAY_CHANNEL_KEY).get(); + if (relayChannel != null) { + relayChannel.close(); + } + ctx.fireChannelInactive(); + } + + /** Read message of type {@code I}, write it as-is into the relay channel. */ + @Override + protected void channelRead0(ChannelHandlerContext ctx, I msg) throws Exception { + Channel relayChannel = ctx.channel().attr(RELAY_CHANNEL_KEY).get(); + checkNotNull(relayChannel, "Relay channel not specified for channel: %s", ctx.channel()); + if (relayChannel.isActive()) { + // Relay channel is open, write to it. + ChannelFuture channelFuture = relayChannel.writeAndFlush(msg); + channelFuture.addListener( + future -> { + // Cannot write into relay channel, close this channel. + if (!future.isSuccess()) { + ctx.close(); + } + }); + } else { + // close this channel if the relay channel is closed. + ctx.close(); + } + } + + /** Specialized {@link RelayHandler} that takes a {@link FullHttpRequest} as inbound payload. */ + public static class FullHttpRequestRelayHandler extends RelayHandler { + @Inject + public FullHttpRequestRelayHandler() { + super(FullHttpRequest.class); + } + } + + /** Specialized {@link RelayHandler} that takes a {@link FullHttpResponse} as inbound payload. */ + public static class FullHttpResponseRelayHandler extends RelayHandler { + @Inject + public FullHttpResponseRelayHandler() { + super(FullHttpResponse.class); + } + } +} diff --git a/java/google/registry/proxy/handler/SslClientInitializer.java b/java/google/registry/proxy/handler/SslClientInitializer.java new file mode 100644 index 000000000..4335ed685 --- /dev/null +++ b/java/google/registry/proxy/handler/SslClientInitializer.java @@ -0,0 +1,80 @@ +// Copyright 2017 The Nomulus Authors. All Rights Reserved. +// +// 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. + +package google.registry.proxy.handler; + +import static com.google.common.base.Preconditions.checkNotNull; +import static google.registry.proxy.Protocol.PROTOCOL_KEY; + +import google.registry.proxy.Protocol.BackendProtocol; +import google.registry.util.FormattingLogger; +import io.netty.channel.Channel; +import io.netty.channel.ChannelHandler.Sharable; +import io.netty.channel.ChannelInitializer; +import io.netty.channel.embedded.EmbeddedChannel; +import io.netty.handler.ssl.SslContextBuilder; +import io.netty.handler.ssl.SslHandler; +import io.netty.handler.ssl.SslProvider; +import java.security.cert.X509Certificate; +import javax.annotation.Nullable; +import javax.inject.Inject; +import javax.inject.Named; +import javax.inject.Singleton; +import javax.net.ssl.SSLEngine; +import javax.net.ssl.SSLParameters; + +/** + * Adds a client side SSL handler to the channel pipeline. + * + *

This must be the first handler provided for any handler provider list, if it is + * provided. The type parameter {@code C} is needed so that unit tests can construct this handler + * that works with {@link EmbeddedChannel}; + */ +@Singleton +@Sharable +public class SslClientInitializer extends ChannelInitializer { + + private static final FormattingLogger logger = FormattingLogger.getLoggerForCallerClass(); + private final SslProvider sslProvider; + private final X509Certificate[] trustedCertificates; + + @Inject + SslClientInitializer( + SslProvider sslProvider, + @Nullable @Named("relayTrustedCertificates") X509Certificate... trustCertificates) { + logger.finefmt("Client SSL Provider: %s", sslProvider); + this.sslProvider = sslProvider; + this.trustedCertificates = trustCertificates; + } + + @Override + protected void initChannel(C channel) throws Exception { + BackendProtocol protocol = (BackendProtocol) channel.attr(PROTOCOL_KEY).get(); + checkNotNull(protocol, "Protocol is not set for channel: %s", channel); + SslHandler sslHandler = + SslContextBuilder.forClient() + .sslProvider(sslProvider) + .trustManager(trustedCertificates) + .build() + .newHandler(channel.alloc(), protocol.host(), protocol.port()); + + // Enable hostname verification. + SSLEngine sslEngine = sslHandler.engine(); + SSLParameters sslParameters = sslEngine.getSSLParameters(); + sslParameters.setEndpointIdentificationAlgorithm("HTTPS"); + sslEngine.setSSLParameters(sslParameters); + + channel.pipeline().addLast(sslHandler); + } +} diff --git a/java/google/registry/proxy/handler/SslServerInitializer.java b/java/google/registry/proxy/handler/SslServerInitializer.java new file mode 100644 index 000000000..a99907612 --- /dev/null +++ b/java/google/registry/proxy/handler/SslServerInitializer.java @@ -0,0 +1,105 @@ +// Copyright 2017 The Nomulus Authors. All Rights Reserved. +// +// 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. + +package google.registry.proxy.handler; + +import google.registry.util.FormattingLogger; +import io.netty.channel.Channel; +import io.netty.channel.ChannelHandler.Sharable; +import io.netty.channel.ChannelInitializer; +import io.netty.channel.embedded.EmbeddedChannel; +import io.netty.handler.ssl.ClientAuth; +import io.netty.handler.ssl.SslContextBuilder; +import io.netty.handler.ssl.SslHandler; +import io.netty.handler.ssl.SslProvider; +import io.netty.handler.ssl.util.InsecureTrustManagerFactory; +import io.netty.util.AttributeKey; +import io.netty.util.concurrent.Future; +import io.netty.util.concurrent.Promise; +import java.security.PrivateKey; +import java.security.cert.X509Certificate; +import javax.inject.Inject; +import javax.inject.Named; +import javax.inject.Singleton; + +/** + * Adds a server side SSL handler to the channel pipeline. + * + *

This should be the first handler provided for any handler provider list, if it is + * provided. Unless you wish to first process the PROXY header with {@link ProxyProtocolHandler}, + * which should come before this handler. The type parameter {@code C} is needed so that unit tests + * can construct this handler that works with {@link EmbeddedChannel}; + * + *

The ssl handler added requires client authentication, but it uses an {@link + * InsecureTrustManagerFactory}, which accepts any ssl certificate presented by the client, as long + * as the client uses the corresponding private key to establish SSL handshake. The client + * certificate hash will be passed along to GAE as an HTTP header for verification (not handled by + * this handler). + */ +@Singleton +@Sharable +public class SslServerInitializer extends ChannelInitializer { + + /** + * Attribute key to the client certificate promise whose value is set when SSL handshake completes + * successfully. + */ + public static final AttributeKey> CLIENT_CERTIFICATE_PROMISE_KEY = + AttributeKey.valueOf("CLIENT_CERTIFICATE_PROMISE_KEY"); + + private static final FormattingLogger logger = FormattingLogger.getLoggerForCallerClass(); + private final SslProvider sslProvider; + private final PrivateKey privateKey; + private final X509Certificate[] certificates; + + @Inject + SslServerInitializer( + SslProvider sslProvider, + PrivateKey privateKey, + @Named("eppServerCertificates") X509Certificate... certificates) { + logger.finefmt("Server SSL Provider: %s", sslProvider); + this.sslProvider = sslProvider; + this.privateKey = privateKey; + this.certificates = certificates; + } + + @Override + protected void initChannel(C channel) throws Exception { + SslHandler sslHandler = + SslContextBuilder.forServer(privateKey, certificates) + .sslProvider(sslProvider) + .trustManager(InsecureTrustManagerFactory.INSTANCE) + .clientAuth(ClientAuth.REQUIRE) + .build() + .newHandler(channel.alloc()); + Promise clientCertificatePromise = channel.eventLoop().newPromise(); + Future unusedFuture = + sslHandler + .handshakeFuture() + .addListener( + future -> { + if (future.isSuccess()) { + Promise unusedPromise = + clientCertificatePromise.setSuccess( + (X509Certificate) + sslHandler.engine().getSession().getPeerCertificates()[0]); + } else { + Promise unusedPromise = + clientCertificatePromise.setFailure(future.cause()); + } + }); + channel.attr(CLIENT_CERTIFICATE_PROMISE_KEY).set(clientCertificatePromise); + channel.pipeline().addLast(sslHandler); + } +} diff --git a/java/google/registry/proxy/handler/WhoisServiceHandler.java b/java/google/registry/proxy/handler/WhoisServiceHandler.java new file mode 100644 index 000000000..1e12542b2 --- /dev/null +++ b/java/google/registry/proxy/handler/WhoisServiceHandler.java @@ -0,0 +1,54 @@ +// Copyright 2017 The Nomulus Authors. All Rights Reserved. +// +// 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. + +package google.registry.proxy.handler; + +import com.google.common.base.Supplier; +import google.registry.proxy.metric.FrontendMetrics; +import io.netty.buffer.ByteBuf; +import io.netty.channel.ChannelHandlerContext; +import io.netty.handler.codec.http.FullHttpRequest; +import io.netty.handler.codec.http.HttpHeaderNames; +import io.netty.handler.codec.http.HttpHeaderValues; + +/** Handler that processes WHOIS protocol logic. */ +public final class WhoisServiceHandler extends HttpsRelayServiceHandler { + + public WhoisServiceHandler( + String relayHost, + String relayPath, + Supplier accessTokenSupplier, + FrontendMetrics metrics) { + super(relayHost, relayPath, accessTokenSupplier, metrics); + } + + @Override + public void channelActive(ChannelHandlerContext ctx) throws Exception { + metrics.registerActiveConnection("whois", "none", ctx.channel()); + super.channelActive(ctx); + } + + @Override + protected FullHttpRequest decodeFullHttpRequest(ByteBuf byteBuf) { + FullHttpRequest request = super.decodeFullHttpRequest(byteBuf); + request + .headers() + // Close connection after a response is received, per RFC-3912 + // https://tools.ietf.org/html/rfc3912 + .set(HttpHeaderNames.CONNECTION, HttpHeaderValues.CLOSE) + .set(HttpHeaderNames.CONTENT_TYPE, HttpHeaderValues.TEXT_PLAIN) + .set(HttpHeaderNames.ACCEPT, HttpHeaderValues.TEXT_PLAIN); + return request; + } +} diff --git a/java/google/registry/proxy/metric/BackendMetrics.java b/java/google/registry/proxy/metric/BackendMetrics.java new file mode 100644 index 000000000..f33202d9a --- /dev/null +++ b/java/google/registry/proxy/metric/BackendMetrics.java @@ -0,0 +1,125 @@ +// Copyright 2017 The Nomulus Authors. All Rights Reserved. +// +// 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. + +package google.registry.proxy.metric; + +import com.google.common.collect.ImmutableSet; +import google.registry.monitoring.metrics.CustomFitter; +import google.registry.monitoring.metrics.EventMetric; +import google.registry.monitoring.metrics.ExponentialFitter; +import google.registry.monitoring.metrics.FibonacciFitter; +import google.registry.monitoring.metrics.IncrementableMetric; +import google.registry.monitoring.metrics.LabelDescriptor; +import google.registry.monitoring.metrics.MetricRegistryImpl; +import google.registry.util.NonFinalForTesting; +import io.netty.handler.codec.http.FullHttpRequest; +import io.netty.handler.codec.http.FullHttpResponse; +import javax.inject.Inject; + +/** Backend metrics instrumentation. */ +public class BackendMetrics { + + // Maximum request size is defined in the config file, this is not realistic and we'd be out of + // memory when the size approach 1 GB. + private static final CustomFitter DEFAULT_SIZE_FITTER = FibonacciFitter.create(1073741824); + + // Maximum 1 hour latency, this is not specified by the spec, but given we have a one hour idle + // timeout, it seems reasonable that maximum latency is set to 1 hour as well. If we are + // approaching anywhere near 1 hour latency, we'd be way out of SLO anyway. + private static final ExponentialFitter DEFAULT_LATENCY_FITTER = + ExponentialFitter.create(22, 2, 1.0); + + private static final ImmutableSet LABELS = + ImmutableSet.of( + LabelDescriptor.create("protocol", "Name of the protocol."), + LabelDescriptor.create( + "client_cert_hash", "SHA256 hash of the client certificate, if available.")); + + static final IncrementableMetric requestsCounter = + MetricRegistryImpl.getDefault() + .newIncrementableMetric( + "/proxy/backend/requests", + "Total number of requests send to the backend.", + "Requests", + LABELS); + + static final IncrementableMetric responsesCounter = + MetricRegistryImpl.getDefault() + .newIncrementableMetric( + "/proxy/backend/responses", + "Total number of responses received by the backend.", + "Responses", + ImmutableSet.builder() + .addAll(LABELS) + .add(LabelDescriptor.create("status", "HTTP status code.")) + .build()); + + static final EventMetric requestBytes = + MetricRegistryImpl.getDefault() + .newEventMetric( + "/proxy/backend/request_bytes", + "Size of the backend requests sent.", + "Bytes", + LABELS, + DEFAULT_SIZE_FITTER); + + static final EventMetric responseBytes = + MetricRegistryImpl.getDefault() + .newEventMetric( + "/proxy/backend/response_bytes", + "Size of the backend responses received.", + "Bytes", + LABELS, + DEFAULT_SIZE_FITTER); + + static final EventMetric latencyMs = + MetricRegistryImpl.getDefault() + .newEventMetric( + "/proxy/backend/latency_ms", + "Round-trip time between a request sent and its corresponding response received.", + "Milliseconds", + LABELS, + DEFAULT_LATENCY_FITTER); + + @Inject + BackendMetrics() {} + + /** + * Resets all backend metrics. + * + *

This should only used in tests to clear out states. No production code should call this + * function. + */ + void resetMetric() { + requestBytes.reset(); + requestsCounter.reset(); + responseBytes.reset(); + responsesCounter.reset(); + latencyMs.reset(); + } + + @NonFinalForTesting + public void requestSent(String protocol, String certHash, FullHttpRequest request) { + requestsCounter.increment(protocol, certHash); + requestBytes.record(request.content().readableBytes(), protocol, certHash); + } + + @NonFinalForTesting + public void responseReceived( + String protocol, String certHash, FullHttpResponse response, long latency) { + latencyMs.record(latency, protocol, certHash); + responseBytes.record(response.content().readableBytes(), protocol, certHash); + responsesCounter.increment(protocol, certHash, response.status().toString()); + } +} diff --git a/java/google/registry/proxy/metric/FrontendMetrics.java b/java/google/registry/proxy/metric/FrontendMetrics.java new file mode 100644 index 000000000..c216c099a --- /dev/null +++ b/java/google/registry/proxy/metric/FrontendMetrics.java @@ -0,0 +1,99 @@ +// Copyright 2017 The Nomulus Authors. All Rights Reserved. +// +// 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. + +package google.registry.proxy.metric; + +import com.google.common.annotations.VisibleForTesting; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.ImmutableSet; +import google.registry.monitoring.metrics.IncrementableMetric; +import google.registry.monitoring.metrics.LabelDescriptor; +import google.registry.monitoring.metrics.Metric; +import google.registry.monitoring.metrics.MetricRegistryImpl; +import google.registry.util.NonFinalForTesting; +import io.netty.channel.Channel; +import io.netty.channel.group.ChannelGroup; +import io.netty.channel.group.DefaultChannelGroup; +import io.netty.util.concurrent.GlobalEventExecutor; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import javax.inject.Inject; + +/** Frontend metrics instrumentation. */ +public class FrontendMetrics { + + private static final ImmutableSet LABELS = + ImmutableSet.of( + LabelDescriptor.create("protocol", "Name of the protocol."), + LabelDescriptor.create( + "client_cert_hash", "SHA256 hash of the client certificate, if available.")); + + private static final ConcurrentMap, ChannelGroup> activeConnections = + new ConcurrentHashMap<>(); + + static final Metric activeConnectionsGauge = + MetricRegistryImpl.getDefault() + .newGauge( + "/proxy/frontend/active_connections", + "Number of active connections from clients to the proxy.", + "Connections", + LABELS, + () -> + activeConnections + .entrySet() + .stream() + .collect( + ImmutableMap.toImmutableMap( + Map.Entry::getKey, entry -> (long) entry.getValue().size())), + Long.class); + + static final IncrementableMetric totalConnectionsCounter = + MetricRegistryImpl.getDefault() + .newIncrementableMetric( + "/proxy/frontend/total_connections", + "Total number connections ever made from clients to the proxy.", + "Connections", + LABELS); + + @Inject + public FrontendMetrics() {} + + /** + * Resets all frontend metrics. + * + *

This should only be used in tests to reset states. Production code should not call this + * method. + */ + @VisibleForTesting + void resetMetrics() { + totalConnectionsCounter.reset(); + activeConnections.clear(); + } + + @NonFinalForTesting + public void registerActiveConnection(String protocol, String certHash, Channel channel) { + totalConnectionsCounter.increment(protocol, certHash); + ImmutableList labels = ImmutableList.of(protocol, certHash); + ChannelGroup channelGroup; + if (activeConnections.containsKey(labels)) { + channelGroup = activeConnections.get(labels); + } else { + channelGroup = new DefaultChannelGroup(GlobalEventExecutor.INSTANCE); + activeConnections.put(labels, channelGroup); + } + channelGroup.add(channel); + } +} diff --git a/java/google/registry/proxy/resources/hello.xml b/java/google/registry/proxy/resources/hello.xml new file mode 100644 index 000000000..30fb4a0f7 --- /dev/null +++ b/java/google/registry/proxy/resources/hello.xml @@ -0,0 +1,4 @@ + + + + diff --git a/java/google/registry/repositories.bzl b/java/google/registry/repositories.bzl index 2c36c5dca..8c6cfda66 100644 --- a/java/google/registry/repositories.bzl +++ b/java/google/registry/repositories.bzl @@ -66,7 +66,9 @@ def domain_registry_repositories( omit_com_google_dagger_compiler=False, omit_com_google_dagger_producers=False, omit_com_google_errorprone_error_prone_annotations=False, + omit_com_google_errorprone_javac_shaded=False, omit_com_google_gdata_core=False, + omit_com_google_googlejavaformat_google_java_format=False, omit_com_google_guava=False, omit_com_google_guava_testlib=False, omit_com_google_http_client=False, @@ -86,6 +88,7 @@ def domain_registry_repositories( omit_com_googlecode_json_simple=False, omit_com_ibm_icu_icu4j=False, omit_com_jcraft_jzlib=False, + omit_com_squareup_javapoet=False, omit_com_squareup_javawriter=False, omit_com_sun_xml_bind_jaxb_core=False, omit_com_sun_xml_bind_jaxb_impl=False, @@ -94,8 +97,17 @@ def domain_registry_repositories( omit_commons_codec=False, omit_commons_logging=False, omit_dnsjava=False, + omit_io_netty_buffer=False, + omit_io_netty_codec=False, + omit_io_netty_codec_http=False, + omit_io_netty_common=False, + omit_io_netty_handler=False, + omit_io_netty_resolver=False, + omit_io_netty_tcnative=False, + omit_io_netty_transport=False, omit_it_unimi_dsi_fastutil=False, omit_javax_activation=False, + omit_javax_annotation_jsr250_api=False, omit_javax_inject=False, omit_javax_mail=False, omit_javax_servlet_api=False, @@ -227,8 +239,12 @@ def domain_registry_repositories( com_google_dagger_producers() if not omit_com_google_errorprone_error_prone_annotations: com_google_errorprone_error_prone_annotations() + if not omit_com_google_errorprone_javac_shaded: + com_google_errorprone_javac_shaded() if not omit_com_google_gdata_core: com_google_gdata_core() + if not omit_com_google_googlejavaformat_google_java_format: + com_google_googlejavaformat_google_java_format() if not omit_com_google_guava: com_google_guava() if not omit_com_google_guava_testlib: @@ -267,6 +283,8 @@ def domain_registry_repositories( com_ibm_icu_icu4j() if not omit_com_jcraft_jzlib: com_jcraft_jzlib() + if not omit_com_squareup_javapoet: + com_squareup_javapoet() if not omit_com_squareup_javawriter: com_squareup_javawriter() if not omit_com_sun_xml_bind_jaxb_core: @@ -283,10 +301,28 @@ def domain_registry_repositories( commons_logging() if not omit_dnsjava: dnsjava() + if not omit_io_netty_buffer: + io_netty_buffer() + if not omit_io_netty_codec: + io_netty_codec() + if not omit_io_netty_codec_http: + io_netty_codec_http() + if not omit_io_netty_common: + io_netty_common() + if not omit_io_netty_handler: + io_netty_handler() + if not omit_io_netty_resolver: + io_netty_resolver() + if not omit_io_netty_tcnative: + io_netty_tcnative() + if not omit_io_netty_transport: + io_netty_transport() if not omit_it_unimi_dsi_fastutil: it_unimi_dsi_fastutil() if not omit_javax_activation: javax_activation() + if not omit_javax_annotation_jsr250_api: + javax_annotation_jsr250_api() if not omit_javax_inject: javax_inject() if not omit_javax_mail: @@ -888,21 +924,21 @@ def com_google_auto_value(): def com_google_code_findbugs_jsr305(): java_import_external( name = "com_google_code_findbugs_jsr305", - jar_sha256 = "905721a0eea90a81534abb7ee6ef4ea2e5e645fa1def0a5cd88402df1b46c9ed", - jar_urls = [ - "http://domain-registry-maven.storage.googleapis.com/repo1.maven.org/maven2/com/google/code/findbugs/jsr305/1.3.9/jsr305-1.3.9.jar", - "http://repo1.maven.org/maven2/com/google/code/findbugs/jsr305/1.3.9/jsr305-1.3.9.jar", - ], licenses = ["notice"], # The Apache Software License, Version 2.0 + jar_sha256 = "c885ce34249682bc0236b4a7d56efcc12048e6135a5baf7a9cde8ad8cda13fcd", + jar_urls = [ + "http://repo1.maven.org/maven2/com/google/code/findbugs/jsr305/3.0.1/jsr305-3.0.1.jar", + "http://maven.ibiblio.org/maven2/com/google/code/findbugs/jsr305/3.0.1/jsr305-3.0.1.jar", + ], ) def com_google_dagger(): java_import_external( name = "com_google_dagger", - jar_sha256 = "5070e1dff5c551a4908ba7b93125c0243de2a688aed3d2f475357d86d9d7c0ad", + jar_sha256 = "b2142693bc7413f0b74330f0a92ca44ea95a12a22b659972ed6aa9832e8352e4", jar_urls = [ - "http://domain-registry-maven.storage.googleapis.com/repo1.maven.org/maven2/com/google/dagger/dagger/2.8/dagger-2.8.jar", - "http://repo1.maven.org/maven2/com/google/dagger/dagger/2.8/dagger-2.8.jar", + "http://repo1.maven.org/maven2/com/google/dagger/dagger/2.13/dagger-2.13.jar", + "http://maven.ibiblio.org/maven2/com/google/dagger/dagger/2.13/dagger-2.13.jar", ], licenses = ["notice"], # Apache 2.0 deps = ["@javax_inject"], @@ -922,17 +958,21 @@ def com_google_dagger(): def com_google_dagger_compiler(): java_import_external( name = "com_google_dagger_compiler", - jar_sha256 = "7b2686f94907868c5364e9965601ffe2f020ba4af1849ad9b57dad5fe3fa6242", + jar_sha256 = "8b711253c9cbb58bd2c019cb38afb32ee79f283e1bb3030c8c85b645c7a6d25f", jar_urls = [ - "http://domain-registry-maven.storage.googleapis.com/repo1.maven.org/maven2/com/google/dagger/dagger-compiler/2.8/dagger-compiler-2.8.jar", - "http://repo1.maven.org/maven2/com/google/dagger/dagger-compiler/2.8/dagger-compiler-2.8.jar", + "http://maven.ibiblio.org/maven2/com/google/dagger/dagger-compiler/2.13/dagger-compiler-2.13.jar", + "http://repo1.maven.org/maven2/com/google/dagger/dagger-compiler/2.13/dagger-compiler-2.13.jar", ], licenses = ["notice"], # Apache 2.0 deps = [ - "@com_google_code_findbugs_jsr305", "@com_google_dagger//:runtime", "@com_google_dagger_producers//:runtime", + "@com_google_code_findbugs_jsr305", + "@com_google_googlejavaformat_google_java_format", "@com_google_guava", + "@com_squareup_javapoet", + "@javax_annotation_jsr250_api", + "@javax_inject", ], extra_build_file_content = "\n".join([ "java_plugin(", @@ -952,15 +992,17 @@ def com_google_dagger_compiler(): def com_google_dagger_producers(): java_import_external( name = "com_google_dagger_producers", - jar_sha256 = "1e4043e85f67de381d19e22c7932aaf7ff1611091be7e1aaae93f2c37f331cf2", + jar_sha256 = "cf35b21c634939917eee9ffcd72a9f5f6e261ad57a4c0f0d15cf6f1430262bb0", jar_urls = [ - "http://domain-registry-maven.storage.googleapis.com/repo1.maven.org/maven2/com/google/dagger/dagger-producers/2.8/dagger-producers-2.8.jar", - "http://repo1.maven.org/maven2/com/google/dagger/dagger-producers/2.8/dagger-producers-2.8.jar", + "http://repo1.maven.org/maven2/com/google/dagger/dagger-producers/2.13/dagger-producers-2.13.jar", + "http://maven.ibiblio.org/maven2/com/google/dagger/dagger-producers/2.13/dagger-producers-2.13.jar", ], licenses = ["notice"], # Apache 2.0 deps = [ "@com_google_dagger//:runtime", + "@com_google_code_findbugs_jsr305", "@com_google_guava", + "@javax_inject", ], generated_rule_name = "runtime", extra_build_file_content = "\n".join([ @@ -986,6 +1028,56 @@ def com_google_errorprone_error_prone_annotations(): licenses = ["notice"], # Apache 2.0 ) +def com_google_errorprone_javac_shaded(): + java_import_external( + name = "com_google_errorprone_javac_shaded", + # GNU General Public License, version 2, with the Classpath Exception + # http://openjdk.java.net/legal/gplv2+ce.html + licenses = ["TODO"], + jar_sha256 = "65bfccf60986c47fbc17c9ebab0be626afc41741e0a6ec7109e0768817a36f30", + jar_urls = [ + "http://repo1.maven.org/maven2/com/google/errorprone/javac-shaded/9-dev-r4023-3/javac-shaded-9-dev-r4023-3.jar", + "http://maven.ibiblio.org/maven2/com/google/errorprone/javac-shaded/9-dev-r4023-3/javac-shaded-9-dev-r4023-3.jar", + ], + ) + +def com_google_googlejavaformat_google_java_format(): + java_import_external( + name = "com_google_googlejavaformat_google_java_format", + licenses = ["notice"], # The Apache Software License, Version 2.0 + jar_sha256 = "39d18ec9ab610097074bf49e971285488eaf5d0bc2369df0a0d5a3f9f9de2faa", + jar_urls = [ + "http://maven.ibiblio.org/maven2/com/google/googlejavaformat/google-java-format/1.4/google-java-format-1.4.jar", + "http://repo1.maven.org/maven2/com/google/googlejavaformat/google-java-format/1.4/google-java-format-1.4.jar", + ], + deps = [ + "@com_google_guava", + "@com_google_errorprone_javac_shaded", + ], + ) + +def com_squareup_javapoet(): + java_import_external( + name = "com_squareup_javapoet", + licenses = ["notice"], # Apache 2.0 + jar_sha256 = "8e108c92027bb428196f10fa11cffbe589f7648a6af2016d652279385fdfd789", + jar_urls = [ + "http://maven.ibiblio.org/maven2/com/squareup/javapoet/1.8.0/javapoet-1.8.0.jar", + "http://repo1.maven.org/maven2/com/squareup/javapoet/1.8.0/javapoet-1.8.0.jar", + ], + ) + +def javax_annotation_jsr250_api(): + java_import_external( + name = "javax_annotation_jsr250_api", + licenses = ["reciprocal"], # COMMON DEVELOPMENT AND DISTRIBUTION LICENSE (CDDL) Version 1.0 + jar_sha256 = "a1a922d0d9b6d183ed3800dfac01d1e1eb159f0e8c6f94736931c1def54a941f", + jar_urls = [ + "http://repo1.maven.org/maven2/javax/annotation/jsr250-api/1.0/jsr250-api-1.0.jar", + "http://maven.ibiblio.org/maven2/javax/annotation/jsr250-api/1.0/jsr250-api-1.0.jar", + ], + ) + def com_google_gdata_core(): java_import_external( name = "com_google_gdata_core", @@ -2147,6 +2239,102 @@ def xpp3(): ], ) +def io_netty_buffer(): + java_import_external( + name = "io_netty_buffer", + licenses = ["notice"], # Apache License, Version 2.0 + jar_sha256 = "b24a28e2129fc11e1f6124ebf93725d1f9c0904ea679d261da7b2e21d4c8615e", + jar_urls = [ + "http://maven.ibiblio.org/maven2/io/netty/netty-buffer/4.1.17.Final/netty-buffer-4.1.17.Final.jar", + "http://repo1.maven.org/maven2/io/netty/netty-buffer/4.1.17.Final/netty-buffer-4.1.17.Final.jar", + ], + deps = ["@io_netty_common"], + ) + +def io_netty_codec(): + java_import_external( + name = "io_netty_codec", + licenses = ["notice"], # Apache License, Version 2.0 + jar_sha256 = "790ce1b7694fc41663131579d776a370e332e3b3fe2fe6543662fd5a40a948e1", + jar_urls = [ + "http://repo1.maven.org/maven2/io/netty/netty-codec/4.1.17.Final/netty-codec-4.1.17.Final.jar", + "http://maven.ibiblio.org/maven2/io/netty/netty-codec/4.1.17.Final/netty-codec-4.1.17.Final.jar", + ], + deps = ["@io_netty_transport"], + ) + +def io_netty_codec_http(): + java_import_external( + name = "io_netty_codec_http", + licenses = ["notice"], # Apache License, Version 2.0 + jar_sha256 = "fc05d02755c5d204ccc848be8399ef5d48d5a80da9b93f075287c57eb9381e5b", + jar_urls = [ + "http://repo1.maven.org/maven2/io/netty/netty-codec-http/4.1.17.Final/netty-codec-http-4.1.17.Final.jar", + "http://maven.ibiblio.org/maven2/io/netty/netty-codec-http/4.1.17.Final/netty-codec-http-4.1.17.Final.jar", + ], + deps = ["@io_netty_codec"], + ) + +def io_netty_common(): + java_import_external( + name = "io_netty_common", + licenses = ["notice"], # Apache License, Version 2.0 + jar_sha256 = "dddabdec01959180da44129d130301b84c23b473411288f143d5e29e0b098d26", + jar_urls = [ + "http://repo1.maven.org/maven2/io/netty/netty-common/4.1.17.Final/netty-common-4.1.17.Final.jar", + "http://maven.ibiblio.org/maven2/io/netty/netty-common/4.1.17.Final/netty-common-4.1.17.Final.jar", + ], + ) + +def io_netty_handler(): + java_import_external( + name = "io_netty_handler", + licenses = ["notice"], # Apache License, Version 2.0 + jar_sha256 = "85bada604fe14bc358da7b140583264a88d7a45ca12daba1216c4225aadb0c7b", + jar_urls = [ + "http://repo1.maven.org/maven2/io/netty/netty-handler/4.1.17.Final/netty-handler-4.1.17.Final.jar", + "http://maven.ibiblio.org/maven2/io/netty/netty-handler/4.1.17.Final/netty-handler-4.1.17.Final.jar", + ], + ) + +def io_netty_resolver(): + java_import_external( + name = "io_netty_resolver", + licenses = ["notice"], # Apache License, Version 2.0 + jar_sha256 = "082ac49149cb72c675c7ed1615ba35923d3167e65bfb37c4a1422ec499137cb1", + jar_urls = [ + "http://maven.ibiblio.org/maven2/io/netty/netty-resolver/4.1.17.Final/netty-resolver-4.1.17.Final.jar", + "http://repo1.maven.org/maven2/io/netty/netty-resolver/4.1.17.Final/netty-resolver-4.1.17.Final.jar", + ], + deps = ["@io_netty_common"], + ) + +def io_netty_tcnative(): + java_import_external( + name = "io_netty_tcnative", + licenses = ["notice"], # Apache License, Version 2.0 + jar_sha256 = "cd49317267a8f2fd617075d22e25ceb3aef98e6b64bd6f66cca95f8825cdc1f3", + jar_urls = [ + "http://repo1.maven.org/maven2/io/netty/netty-tcnative/2.0.7.Final/netty-tcnative-2.0.7.Final.jar", + "http://maven.ibiblio.org/maven2/io/netty/netty-tcnative/2.0.7.Final/netty-tcnative-2.0.7.Final.jar", + ], + ) + +def io_netty_transport(): + java_import_external( + name = "io_netty_transport", + licenses = ["notice"], # Apache License, Version 2.0 + jar_sha256 = "60763426c79dd930c70d0da95e474f662bd17a58d3d57b332696d089cf208089", + jar_urls = [ + "http://maven.ibiblio.org/maven2/io/netty/netty-transport/4.1.17.Final/netty-transport-4.1.17.Final.jar", + "http://repo1.maven.org/maven2/io/netty/netty-transport/4.1.17.Final/netty-transport-4.1.17.Final.jar", + ], + deps = [ + "@io_netty_buffer", + "@io_netty_resolver", + ], + ) + def _check_bazel_version(project, bazel_version): if "bazel_version" not in dir(native): fail("%s requires Bazel >=%s but was <0.2.1" % (project, bazel_version)) diff --git a/javatests/google/registry/proxy/BUILD b/javatests/google/registry/proxy/BUILD new file mode 100644 index 000000000..328423572 --- /dev/null +++ b/javatests/google/registry/proxy/BUILD @@ -0,0 +1,45 @@ +package( + default_testonly = 1, + default_visibility = ["//java/google/registry:registry_project"], +) + +licenses(["notice"]) # Apache 2.0 + +load("//java/com/google/testing/builddefs:GenTestRules.bzl", "GenTestRules") + +java_library( + name = "proxy", + srcs = glob(["**/*.java"]), + resources = glob(["testdata/*.xml"]), + deps = [ + "//java/google/registry/monitoring/metrics", + "//java/google/registry/monitoring/metrics/contrib", + "//java/google/registry/proxy", + "//java/google/registry/util", + "//javatests/google/registry/testing", + "@com_beust_jcommander", + "@com_google_dagger", + "@com_google_guava", + "@com_google_truth", + "@com_google_truth_extensions_truth_java8_extension", + "@io_netty_buffer", + "@io_netty_codec", + "@io_netty_codec_http", + "@io_netty_common", + "@io_netty_handler", + "@io_netty_transport", + "@joda_time", + "@junit", + "@org_bouncycastle_bcpkix_jdk15on", + "@org_mockito_all", + ], +) + +GenTestRules( + name = "GeneratedTestRules", + test_files = glob( + ["**/*Test.java"], + exclude = ["ProtocolModuleTest.java"], + ), + deps = [":proxy"], +) diff --git a/javatests/google/registry/proxy/CertificateModuleTest.java b/javatests/google/registry/proxy/CertificateModuleTest.java new file mode 100644 index 000000000..5151440a2 --- /dev/null +++ b/javatests/google/registry/proxy/CertificateModuleTest.java @@ -0,0 +1,158 @@ +// Copyright 2017 The Nomulus Authors. All Rights Reserved. +// +// 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. + +package google.registry.proxy; + +import static com.google.common.truth.Truth.assertThat; +import static google.registry.proxy.handler.SslInitializerTestUtils.getKeyPair; +import static google.registry.proxy.handler.SslInitializerTestUtils.signKeyPair; +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.junit.Assert.fail; + +import dagger.BindsInstance; +import dagger.Component; +import google.registry.proxy.ProxyModule.PemBytes; +import io.netty.handler.ssl.util.SelfSignedCertificate; +import java.io.ByteArrayOutputStream; +import java.io.OutputStreamWriter; +import java.security.KeyPair; +import java.security.PrivateKey; +import java.security.cert.Certificate; +import java.security.cert.X509Certificate; +import javax.inject.Named; +import javax.inject.Singleton; +import org.bouncycastle.openssl.PEMWriter; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +/** Unit tests for {@link CertificateModule}. */ +@RunWith(JUnit4.class) +public class CertificateModuleTest { + + private SelfSignedCertificate ssc; + private PrivateKey key; + private Certificate cert; + private TestComponent component; + + private static byte[] getPemBytes(Object... objects) throws Exception { + ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream(); + try (PEMWriter pemWriter = + new PEMWriter(new OutputStreamWriter(byteArrayOutputStream, UTF_8))) { + for (Object object : objects) { + pemWriter.writeObject(object); + } + } + return byteArrayOutputStream.toByteArray(); + } + + /** Create a component with bindings to the given bytes[] as the contents from a PEM file. */ + private TestComponent createComponent(byte[] bytes) { + return DaggerCertificateModuleTest_TestComponent.builder() + .pemBytes(PemBytes.create(bytes)) + .build(); + } + + @Before + public void setUp() throws Exception { + ssc = new SelfSignedCertificate(); + KeyPair keyPair = getKeyPair(); + key = keyPair.getPrivate(); + cert = signKeyPair(ssc, keyPair, "example.tld"); + } + + @Test + public void testSuccess() throws Exception { + byte[] pemBytes = getPemBytes(cert, ssc.cert(), key); + component = createComponent(pemBytes); + assertThat(component.privateKey()).isEqualTo(key); + assertThat(component.certificates()).asList().containsExactly(cert, ssc.cert()).inOrder(); + } + + @Test + public void testSuccess_certificateChainNotContinuous() throws Exception { + byte[] pemBytes = getPemBytes(cert, key, ssc.cert()); + component = createComponent(pemBytes); + assertThat(component.privateKey()).isEqualTo(key); + assertThat(component.certificates()).asList().containsExactly(cert, ssc.cert()).inOrder(); + } + + @Test + public void testFailure_noPrivateKey() throws Exception { + byte[] pemBytes = getPemBytes(cert, ssc.cert()); + component = createComponent(pemBytes); + try { + component.privateKey(); + fail("Expect IllegalStateException."); + } catch (IllegalStateException e) { + assertThat(e).hasMessageThat().contains("0 keys are found"); + } + } + + @Test + public void testFailure_twoPrivateKeys() throws Exception { + byte[] pemBytes = getPemBytes(cert, ssc.cert(), key, ssc.key()); + component = createComponent(pemBytes); + try { + component.privateKey(); + fail("Expect IllegalStateException."); + } catch (IllegalStateException e) { + assertThat(e).hasMessageThat().contains("2 keys are found"); + } + } + + @Test + public void testFailure_certificatesOutOfOrder() throws Exception { + byte[] pemBytes = getPemBytes(ssc.cert(), cert, key); + component = createComponent(pemBytes); + try { + component.certificates(); + fail("Expect IllegalStateException."); + } catch (IllegalStateException e) { + assertThat(e).hasMessageThat().contains("is not signed by"); + } + } + + @Test + public void testFailure_noCertificates() throws Exception { + byte[] pemBytes = getPemBytes(key); + component = createComponent(pemBytes); + try { + component.certificates(); + fail("Expect IllegalStateException."); + } catch (IllegalStateException e) { + assertThat(e).hasMessageThat().contains("No certificates"); + } + } + + @Singleton + @Component(modules = {CertificateModule.class}) + interface TestComponent { + + PrivateKey privateKey(); + + @Named("eppServerCertificates") + X509Certificate[] certificates(); + + @Component.Builder + interface Builder { + + @BindsInstance + Builder pemBytes(PemBytes pemBytes); + + TestComponent build(); + } + } +} diff --git a/javatests/google/registry/proxy/EppProtocolModuleTest.java b/javatests/google/registry/proxy/EppProtocolModuleTest.java new file mode 100644 index 000000000..0a53cf0ed --- /dev/null +++ b/javatests/google/registry/proxy/EppProtocolModuleTest.java @@ -0,0 +1,251 @@ +// Copyright 2017 The Nomulus Authors. All Rights Reserved. +// +// 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. + +package google.registry.proxy; + +import static com.google.common.truth.Truth.assertThat; +import static google.registry.proxy.handler.ProxyProtocolHandler.REMOTE_ADDRESS_KEY; +import static google.registry.proxy.handler.SslServerInitializer.CLIENT_CERTIFICATE_PROMISE_KEY; +import static google.registry.util.ResourceUtils.readResourceBytes; +import static google.registry.util.X509Utils.getCertificateHash; +import static java.nio.charset.StandardCharsets.UTF_8; + +import google.registry.testing.FakeClock; +import io.netty.buffer.ByteBuf; +import io.netty.buffer.Unpooled; +import io.netty.channel.embedded.EmbeddedChannel; +import io.netty.handler.codec.http.FullHttpRequest; +import io.netty.handler.codec.http.FullHttpResponse; +import io.netty.handler.codec.http.HttpResponseStatus; +import io.netty.handler.codec.http.cookie.Cookie; +import io.netty.handler.codec.http.cookie.DefaultCookie; +import io.netty.handler.ssl.util.SelfSignedCertificate; +import io.netty.util.concurrent.Promise; +import java.security.cert.X509Certificate; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +/** End-to-end tests for {@link EppProtocolModule}. */ +@RunWith(JUnit4.class) +public class EppProtocolModuleTest extends ProtocolModuleTest { + + private static final int HEADER_LENGTH = 4; + + private static final String CLIENT_ADDRESS = "epp.client.tld"; + + private static final byte[] HELLO_BYTES = + ("\n" + + "\n" + + " \n" + + "\n") + .getBytes(UTF_8); + + private X509Certificate certificate; + + public EppProtocolModuleTest() { + super(TestComponent::eppHandlers); + } + + /** Verifies that the epp message content is represented by the buffers. */ + private static void assertBufferRepresentsContent(ByteBuf buffer, byte[] expectedContents) { + // First make sure that buffer length is expected content length plus header length. + assertThat(buffer.readableBytes()).isEqualTo(expectedContents.length + HEADER_LENGTH); + // Then check if the header value is indeed expected content length plus header length. + assertThat(buffer.readInt()).isEqualTo(expectedContents.length + HEADER_LENGTH); + // Finally check the buffer contains the expected contents. + byte[] actualContents = new byte[expectedContents.length]; + buffer.readBytes(actualContents); + assertThat(actualContents).isEqualTo(expectedContents); + } + + /** + * Read all available outbound frames and make a composite {@link ByteBuf} consisting all of them. + * + *

This is needed because {@link io.netty.handler.codec.LengthFieldPrepender} does not + * necessary output only one {@link ByteBuf} from one input message. We need to reassemble the + * frames together in order to obtain the processed message (prepended with length header). + */ + private static ByteBuf getAllOutboundFrames(EmbeddedChannel channel) { + ByteBuf combinedBuffer = Unpooled.buffer(); + ByteBuf buffer; + while ((buffer = channel.readOutbound()) != null) { + combinedBuffer.writeBytes(buffer); + } + return combinedBuffer; + } + + /** Get a {@link ByteBuf} that represents the raw epp request with the given content. */ + private ByteBuf getByteBufFromContent(byte[] content) { + ByteBuf buffer = Unpooled.buffer(); + buffer.writeInt(content.length + HEADER_LENGTH); + buffer.writeBytes(content); + return buffer; + } + + private FullHttpRequest makeEppHttpRequest(byte[] content, Cookie... cookies) { + return TestUtils.makeEppHttpRequest( + new String(content, UTF_8), + PROXY_CONFIG.epp.relayHost, + PROXY_CONFIG.epp.relayPath, + TestModule.provideFakeAccessToken().get(), + getCertificateHash(certificate), + PROXY_CONFIG.epp.serverHostname, + CLIENT_ADDRESS, + cookies); + } + + private FullHttpResponse makeEppHttpResponse(byte[] content, Cookie... cookies) { + return TestUtils.makeEppHttpResponse( + new String(content, UTF_8), HttpResponseStatus.OK, cookies); + } + + @Override + @Before + public void setUp() throws Exception { + testComponent = makeTestComponent(new FakeClock()); + certificate = new SelfSignedCertificate().cert(); + initializeChannel( + ch -> { + ch.attr(REMOTE_ADDRESS_KEY).set(CLIENT_ADDRESS); + ch.attr(CLIENT_CERTIFICATE_PROMISE_KEY).set(ch.eventLoop().newPromise()); + addAllTestableHandlers(ch); + }); + Promise unusedPromise = + channel.attr(CLIENT_CERTIFICATE_PROMISE_KEY).get().setSuccess(certificate); + } + + @Test + public void testSuccess_singleFrameInboundMessage() throws Exception { + // First inbound message is hello. + assertThat((FullHttpRequest) channel.readInbound()).isEqualTo(makeEppHttpRequest(HELLO_BYTES)); + + byte[] inputBytes = readResourceBytes(getClass(), "testdata/login.xml").read(); + + // Verify inbound message is as expected. + assertThat(channel.writeInbound(getByteBufFromContent(inputBytes))).isTrue(); + assertThat((FullHttpRequest) channel.readInbound()).isEqualTo(makeEppHttpRequest(inputBytes)); + + // Nothing more to read. + assertThat((Object) channel.readInbound()).isNull(); + assertThat(channel.isActive()).isTrue(); + } + + @Test + public void testSuccess_SingleFrame_MultipleInboundMessages() throws Exception { + // First inbound message is hello. + channel.readInbound(); + + byte[] inputBytes1 = readResourceBytes(getClass(), "testdata/login.xml").read(); + byte[] inputBytes2 = readResourceBytes(getClass(), "testdata/logout.xml").read(); + + // Verify inbound messages are as expected. + assertThat( + channel.writeInbound( + Unpooled.wrappedBuffer( + getByteBufFromContent(inputBytes1), getByteBufFromContent(inputBytes2)))) + .isTrue(); + assertThat((FullHttpRequest) channel.readInbound()).isEqualTo(makeEppHttpRequest(inputBytes1)); + assertThat((FullHttpRequest) channel.readInbound()).isEqualTo(makeEppHttpRequest(inputBytes2)); + + // Nothing more to read. + assertThat((Object) channel.readInbound()).isNull(); + assertThat(channel.isActive()).isTrue(); + } + + @Test + public void testSuccess_MultipleFrames_MultipleInboundMessages() throws Exception { + // First inbound message is hello. + channel.readInbound(); + + byte[] inputBytes1 = readResourceBytes(getClass(), "testdata/login.xml").read(); + byte[] inputBytes2 = readResourceBytes(getClass(), "testdata/logout.xml").read(); + ByteBuf inputBuffer = + Unpooled.wrappedBuffer( + getByteBufFromContent(inputBytes1), getByteBufFromContent(inputBytes2)); + + // The first frame does not contain the entire first message because it is missing 4 byte of + // header length. + assertThat(channel.writeInbound(inputBuffer.readBytes(inputBytes1.length))).isFalse(); + + // The second frame contains the first message, and part of the second message. + assertThat(channel.writeInbound(inputBuffer.readBytes(inputBytes2.length))).isTrue(); + assertThat((FullHttpRequest) channel.readInbound()).isEqualTo(makeEppHttpRequest(inputBytes1)); + + // The third frame contains the rest of the second message. + assertThat(channel.writeInbound(inputBuffer)).isTrue(); + assertThat((FullHttpRequest) channel.readInbound()).isEqualTo(makeEppHttpRequest(inputBytes2)); + + // Nothing more to read. + assertThat((Object) channel.readInbound()).isNull(); + assertThat(channel.isActive()).isTrue(); + } + + @Test + public void testSuccess_simpleOutboundMessage() throws Exception { + // First inbound message is hello. + channel.readInbound(); + + byte[] outputBytes = readResourceBytes(getClass(), "testdata/login_response.xml").read(); + + // Verify outbound message is as expected. + assertThat(channel.writeOutbound(makeEppHttpResponse(outputBytes))).isTrue(); + assertBufferRepresentsContent(getAllOutboundFrames(channel), outputBytes); + + // Nothing more to write. + assertThat((Object) channel.readOutbound()).isNull(); + assertThat(channel.isActive()).isTrue(); + } + + @Test + public void testSuccess_setAndReadCookies() throws Exception { + // First inbound message is hello. + channel.readInbound(); + + byte[] outputBytes1 = readResourceBytes(getClass(), "testdata/login_response.xml").read(); + Cookie cookie1 = new DefaultCookie("name1", "value1"); + Cookie cookie2 = new DefaultCookie("name2", "value2"); + + // Verify outbound message is as expected. + assertThat(channel.writeOutbound(makeEppHttpResponse(outputBytes1, cookie1, cookie2))).isTrue(); + assertBufferRepresentsContent(getAllOutboundFrames(channel), outputBytes1); + + // Verify inbound message contains cookies. + byte[] inputBytes1 = readResourceBytes(getClass(), "testdata/logout.xml").read(); + assertThat(channel.writeInbound(getByteBufFromContent(inputBytes1))).isTrue(); + assertThat((FullHttpRequest) channel.readInbound()) + .isEqualTo(makeEppHttpRequest(inputBytes1, cookie1, cookie2)); + + // Second outbound message change cookies. + byte[] outputBytes2 = readResourceBytes(getClass(), "testdata/logout_response.xml").read(); + Cookie cookie3 = new DefaultCookie("name3", "value3"); + cookie2 = new DefaultCookie("name2", "newValue2"); + + // Verify outbound message is as expected. + assertThat(channel.writeOutbound(makeEppHttpResponse(outputBytes2, cookie2, cookie3))).isTrue(); + assertBufferRepresentsContent(getAllOutboundFrames(channel), outputBytes2); + + // Verify inbound message contains updated cookies. + byte[] inputBytes2 = readResourceBytes(getClass(), "testdata/login.xml").read(); + assertThat(channel.writeInbound(getByteBufFromContent(inputBytes2))).isTrue(); + assertThat((FullHttpRequest) channel.readInbound()) + .isEqualTo(makeEppHttpRequest(inputBytes2, cookie1, cookie2, cookie3)); + + // Nothing more to write or read. + assertThat((Object) channel.readOutbound()).isNull(); + assertThat((Object) channel.readInbound()).isNull(); + assertThat(channel.isActive()).isTrue(); + } +} diff --git a/javatests/google/registry/proxy/HealthCheckProtocolModuleTest.java b/javatests/google/registry/proxy/HealthCheckProtocolModuleTest.java new file mode 100644 index 000000000..3ab6a7352 --- /dev/null +++ b/javatests/google/registry/proxy/HealthCheckProtocolModuleTest.java @@ -0,0 +1,88 @@ +// Copyright 2017 The Nomulus Authors. All Rights Reserved. +// +// 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. + +package google.registry.proxy; + +import static com.google.common.truth.Truth.assertThat; +import static java.nio.charset.StandardCharsets.US_ASCII; + +import io.netty.buffer.ByteBuf; +import io.netty.buffer.Unpooled; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +/** End-to-end tests for {@link HealthCheckProtocolModule}. */ +@RunWith(JUnit4.class) +public class HealthCheckProtocolModuleTest extends ProtocolModuleTest { + + public HealthCheckProtocolModuleTest() { + super(TestComponent::healthCheckHandlers); + } + + @Test + public void testSuccess_expectedInboundMessage() { + // no inbound message passed along. + assertThat( + channel.writeInbound( + Unpooled.wrappedBuffer(PROXY_CONFIG.healthCheck.checkRequest.getBytes(US_ASCII)))) + .isFalse(); + ByteBuf outputBuffer = channel.readOutbound(); + // response written to channel. + assertThat(outputBuffer.toString(US_ASCII)).isEqualTo(PROXY_CONFIG.healthCheck.checkResponse); + assertThat(channel.isActive()).isTrue(); + // nothing more to write. + assertThat((Object) channel.readOutbound()).isNull(); + } + + @Test + public void testSuccess_InboundMessageTooShort() { + String shortRequest = "HEALTH_CHECK"; + // no inbound message passed along. + assertThat(channel.writeInbound(Unpooled.wrappedBuffer(shortRequest.getBytes(US_ASCII)))) + .isFalse(); + // nothing to write. + assertThat(channel.isActive()).isTrue(); + assertThat((Object) channel.readOutbound()).isNull(); + } + + @Test + public void testSuccess_InboundMessageTooLong() { + String longRequest = "HEALTH_CHECK_REQUEST HELLO"; + // no inbound message passed along. + assertThat(channel.writeInbound(Unpooled.wrappedBuffer(longRequest.getBytes(US_ASCII)))) + .isFalse(); + ByteBuf outputBuffer = channel.readOutbound(); + // The fixed length frame decoder will decode the first inbound message as "HEALTH_CHECK_ + // REQUEST", which is what this handler expects. So it will respond with the pre-defined + // response message. This is an acceptable false-positive because the GCP health checker will + // only send the pre-defined request message. As long as the health check can receive the + // request it expects, we do not care if the protocol also respond to other requests. + assertThat(outputBuffer.toString(US_ASCII)).isEqualTo(PROXY_CONFIG.healthCheck.checkResponse); + assertThat(channel.isActive()).isTrue(); + // nothing more to write. + assertThat((Object) channel.readOutbound()).isNull(); + } + + @Test + public void testSuccess_InboundMessageNotMatch() { + String invalidRequest = "HEALTH_CHECK_REQUESX"; + // no inbound message passed along. + assertThat(channel.writeInbound(Unpooled.wrappedBuffer(invalidRequest.getBytes(US_ASCII)))) + .isFalse(); + // nothing to write. + assertThat(channel.isActive()).isTrue(); + assertThat((Object) channel.readOutbound()).isNull(); + } +} diff --git a/javatests/google/registry/proxy/HttpsRelayProtocolModuleTest.java b/javatests/google/registry/proxy/HttpsRelayProtocolModuleTest.java new file mode 100644 index 000000000..9ed0bd5a8 --- /dev/null +++ b/javatests/google/registry/proxy/HttpsRelayProtocolModuleTest.java @@ -0,0 +1,103 @@ +// Copyright 2017 The Nomulus Authors. All Rights Reserved. +// +// 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. + +package google.registry.proxy; + +import static com.google.common.truth.Truth.assertThat; +import static google.registry.proxy.TestUtils.assertHttpRequestEquivalent; +import static google.registry.proxy.TestUtils.assertHttpResponseEquivalent; +import static google.registry.proxy.TestUtils.makeHttpPostRequest; +import static google.registry.proxy.TestUtils.makeHttpResponse; + +import io.netty.buffer.ByteBuf; +import io.netty.channel.embedded.EmbeddedChannel; +import io.netty.handler.codec.http.FullHttpRequest; +import io.netty.handler.codec.http.FullHttpResponse; +import io.netty.handler.codec.http.HttpObjectAggregator; +import io.netty.handler.codec.http.HttpResponseStatus; +import io.netty.handler.codec.http.HttpServerCodec; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +/** + * End-to-end tests for {@link HttpsRelayProtocolModule}. + * + *

This protocol defines a connection in which the proxy behaves as a standard http client (sans + * the relay operation which is excluded in end-to-end testing). Because non user-defined handlers + * are used, the tests here focus on verifying that the request written to the network socket by the + * client is reconstructed faithfully by a server, and vice versa, that the response the client + * decoded from incoming bytes is equivalent to the response sent by the server. + * + *

These tests only ensure that the client represented by this protocol is compatible with a + * server implementation provided by Netty itself. They test the self-consistency of various Netty + * handlers that deal with HTTP protocol, but not whether the handlers converts between bytes and + * HTTP messages correctly, which is presumed correct. + */ +@RunWith(JUnit4.class) +public class HttpsRelayProtocolModuleTest extends ProtocolModuleTest { + + private static final String HOST = "test.tld"; + private static final String PATH = "/path/to/test"; + private static final String CONTENT = "content to test\nnext line\n"; + + private final EmbeddedChannel serverChannel = + new EmbeddedChannel(new HttpServerCodec(), new HttpObjectAggregator(512 * 1024)); + + public HttpsRelayProtocolModuleTest() { + super(TestComponent::httpsRelayHandlers); + } + + /** + * Tests that the client converts given {@link FullHttpRequest} to bytes, which is sent to the + * server and reconstructed to a {@link FullHttpRequest} that is equivalent to the original. Then + * test that the server converts given {@link FullHttpResponse} to bytes, which is sent to the + * client and reconstructed to a {@link FullHttpResponse} that is equivalent to the original. + * + *

The request and response equivalences are tested in the same method because the client codec + * tries to pair the response it receives with the request it sends. Receiving a response without + * sending a request first will cause the {@link HttpObjectAggregator} to fail to aggregate + * properly. + */ + private void requestAndRespondWithStatus(HttpResponseStatus status) { + ByteBuf buffer; + FullHttpRequest requestSent = makeHttpPostRequest(CONTENT, HOST, PATH); + // Need to send a copy as the content read index will advance after the request is written to + // the outbound of client channel, making comparison with requestReceived fail. + assertThat(channel.writeOutbound(requestSent.copy())).isTrue(); + buffer = channel.readOutbound(); + assertThat(serverChannel.writeInbound(buffer)).isTrue(); + FullHttpRequest requestReceived = serverChannel.readInbound(); + // Verify that the request received is the same as the request sent. + assertHttpRequestEquivalent(requestSent, requestReceived); + + FullHttpResponse responseSent = makeHttpResponse(CONTENT, status); + assertThat(serverChannel.writeOutbound(responseSent.copy())).isTrue(); + buffer = serverChannel.readOutbound(); + assertThat(channel.writeInbound(buffer)).isTrue(); + FullHttpResponse responseReceived = channel.readInbound(); + // Verify that the request received is the same as the request sent. + assertHttpResponseEquivalent(responseSent, responseReceived); + } + + @Test + public void testSuccess_OkResponse() { + requestAndRespondWithStatus(HttpResponseStatus.OK); + } + + @Test + public void testSuccess_NonOkResponse() { + requestAndRespondWithStatus(HttpResponseStatus.BAD_REQUEST); + } +} diff --git a/javatests/google/registry/proxy/ProtocolModuleTest.java b/javatests/google/registry/proxy/ProtocolModuleTest.java new file mode 100644 index 000000000..1b1124d37 --- /dev/null +++ b/javatests/google/registry/proxy/ProtocolModuleTest.java @@ -0,0 +1,269 @@ +// Copyright 2017 The Nomulus Authors. All Rights Reserved. +// +// 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. + +package google.registry.proxy; + +import static com.google.common.collect.ImmutableList.toImmutableList; +import static google.registry.proxy.ProxyConfig.Environment.TEST; +import static google.registry.proxy.ProxyConfig.getProxyConfig; + +import com.google.common.base.Supplier; +import com.google.common.base.Suppliers; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableSet; +import dagger.Component; +import dagger.Module; +import dagger.Provides; +import google.registry.proxy.EppProtocolModule.EppProtocol; +import google.registry.proxy.HealthCheckProtocolModule.HealthCheckProtocol; +import google.registry.proxy.HttpsRelayProtocolModule.HttpsRelayProtocol; +import google.registry.proxy.WhoisProtocolModule.WhoisProtocol; +import google.registry.proxy.handler.BackendMetricsHandler; +import google.registry.proxy.handler.ProxyProtocolHandler; +import google.registry.proxy.handler.RelayHandler.FullHttpRequestRelayHandler; +import google.registry.proxy.handler.RelayHandler.FullHttpResponseRelayHandler; +import google.registry.proxy.handler.SslClientInitializer; +import google.registry.proxy.handler.SslServerInitializer; +import google.registry.testing.FakeClock; +import google.registry.util.Clock; +import io.netty.channel.Channel; +import io.netty.channel.ChannelHandler; +import io.netty.channel.ChannelInitializer; +import io.netty.channel.embedded.EmbeddedChannel; +import io.netty.handler.logging.LoggingHandler; +import io.netty.handler.ssl.SslProvider; +import io.netty.handler.ssl.util.SelfSignedCertificate; +import io.netty.handler.timeout.ReadTimeoutHandler; +import java.security.PrivateKey; +import java.security.cert.X509Certificate; +import java.util.function.Consumer; +import java.util.function.Function; +import javax.inject.Named; +import javax.inject.Provider; +import javax.inject.Singleton; +import org.junit.Before; + +/** + * Base class for end-to-end tests of a {@link Protocol}. + * + *

The end-to-end tests ensures that the business logic that a {@link Protocol} defines are + * correctly performed by various handlers attached to its pipeline. Non-business essential handlers + * should be excluded. + * + *

Subclass should implement an no-arg constructor that calls constructors of this class, + * providing the method reference of the {@link TestComponent} method to call to obtain the list of + * {@link ChannelHandler} providers for the {@link Protocol} to test, and optionally a set of {@link + * ChannelHandler} classes to exclude from testing. + */ +public abstract class ProtocolModuleTest { + + protected static final ProxyConfig PROXY_CONFIG = getProxyConfig(TEST); + + protected TestComponent testComponent; + + /** + * Default list of handler classes that are not of interest in end-to-end testing of the {@link + * Protocol}. + */ + private static final ImmutableSet> DEFAULT_EXCLUDED_HANDLERS = + ImmutableSet.of( + // The PROXY protocol is only used when the proxy is behind the GCP load balancer. It is + // not part of any business logic. + ProxyProtocolHandler.class, + // SSL is part of the business logic for some protocol (EPP for example), but its + // impact is isolated. Including it makes tests much more complicated. It should be tested + // separately in its own unit tests. + SslClientInitializer.class, + SslServerInitializer.class, + // These two handlers provide essential functionalities for the proxy to operate, but they + // do not directly implement the business logic of a well-defined protocol. They should be + // tested separately in their respective unit tests. + FullHttpRequestRelayHandler.class, + FullHttpResponseRelayHandler.class, + // The rest are not part of business logic and do not need to be tested, obviously. + LoggingHandler.class, + // Metrics instrumentation is tested separately. + BackendMetricsHandler.class, + ReadTimeoutHandler.class); + + protected EmbeddedChannel channel; + + /** + * Method reference to the component method that exposes the list of handler providers for the + * specific {@link Protocol} in interest. + */ + protected final Function>> + handlerProvidersMethod; + + protected final ImmutableSet> excludedHandlers; + + protected ProtocolModuleTest( + Function>> + handlerProvidersMethod, + ImmutableSet> excludedHandlers) { + this.handlerProvidersMethod = handlerProvidersMethod; + this.excludedHandlers = excludedHandlers; + } + + protected ProtocolModuleTest( + Function>> + handlerProvidersMethod) { + this(handlerProvidersMethod, DEFAULT_EXCLUDED_HANDLERS); + } + + /** Excludes handler providers that are not of interested for testing. */ + private ImmutableList> excludeHandlerProvidersForTesting( + ImmutableList> handlerProviders) { + return handlerProviders + .stream() + .filter(handlerProvider -> !excludedHandlers.contains(handlerProvider.get().getClass())) + .collect(toImmutableList()); + } + + protected void initializeChannel(Consumer initializer) { + channel = + new EmbeddedChannel( + new ChannelInitializer() { + @Override + protected void initChannel(Channel ch) throws Exception { + initializer.accept(ch); + } + }); + } + + /** Adds handlers to the channel pipeline, excluding any one in {@link #excludedHandlers}. */ + void addAllTestableHandlers(Channel ch) { + for (Provider handlerProvider : + excludeHandlerProvidersForTesting(handlerProvidersMethod.apply(testComponent))) { + ch.pipeline().addLast(handlerProvider.get()); + } + } + + static TestComponent makeTestComponent(FakeClock fakeClock) { + return DaggerProtocolModuleTest_TestComponent.builder() + .testModule(new TestModule(new FakeClock())) + .build(); + } + + @Before + public void setUp() throws Exception { + testComponent = makeTestComponent(new FakeClock()); + initializeChannel(this::addAllTestableHandlers); + } + + /** + * Component used to obtain the list of {@link ChannelHandler} providers for each {@link + * Protocol}. + */ + @Singleton + @Component( + modules = { + TestModule.class, + WhoisProtocolModule.class, + EppProtocolModule.class, + HealthCheckProtocolModule.class, + HttpsRelayProtocolModule.class + } + ) + interface TestComponent { + @WhoisProtocol + ImmutableList> whoisHandlers(); + + @EppProtocol + ImmutableList> eppHandlers(); + + @HealthCheckProtocol + ImmutableList> healthCheckHandlers(); + + @HttpsRelayProtocol + ImmutableList> httpsRelayHandlers(); + } + + /** + * Module that provides bindings used in tests. + * + *

Most of the binding provided in this module should be either a fake, or a {@link + * ChannelHandler} that is excluded, and annotated with {@code @Singleton}. This module acts as a + * replacement for {@link ProxyModule} used in production component. Providing a handler that is + * part of the business logic of a {@link Protocol} from this module is a sign that the binding + * should be provided in the respective {@code ProtocolModule} instead. + */ + @Module + static class TestModule { + + /** + * A fake clock that is explicitly provided. Users can construct a module with a controller + * clock. + */ + private final FakeClock fakeClock; + + TestModule(FakeClock fakeClock) { + this.fakeClock = fakeClock; + } + + @Singleton + @Provides + static ProxyConfig provideProxyConfig() { + return getProxyConfig(TEST); + } + + @Singleton + @Provides + static SslProvider provideSslProvider() { + return SslProvider.JDK; + } + + @Singleton + @Provides + @Named("accessToken") + static Supplier provideFakeAccessToken() { + return Suppliers.ofInstance("fake.test.token"); + } + + @Singleton + @Provides + static LoggingHandler provideLoggingHandler() { + return new LoggingHandler(); + } + + @Singleton + @Provides + static SelfSignedCertificate provideSelfSignedCertificate() { + try { + return new SelfSignedCertificate(); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + @Singleton + @Provides + @Named("eppServerCertificates") + static X509Certificate[] provideCertificate(SelfSignedCertificate ssc) { + return new X509Certificate[] {ssc.cert()}; + } + + @Singleton + @Provides + static PrivateKey providePrivateKey(SelfSignedCertificate ssc) { + return ssc.key(); + } + + @Singleton + @Provides + Clock provideFakeClock() { + return fakeClock; + } + } +} diff --git a/javatests/google/registry/proxy/ProxyModuleTest.java b/javatests/google/registry/proxy/ProxyModuleTest.java new file mode 100644 index 000000000..652389e44 --- /dev/null +++ b/javatests/google/registry/proxy/ProxyModuleTest.java @@ -0,0 +1,105 @@ +// Copyright 2017 The Nomulus Authors. All Rights Reserved. +// +// 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. + +package google.registry.proxy; + +import static com.google.common.truth.Truth.assertThat; +import static google.registry.proxy.ProxyConfig.Environment.TEST; +import static google.registry.proxy.ProxyConfig.getProxyConfig; +import static google.registry.testing.JUnitBackports.expectThrows; +import static org.junit.Assert.fail; + +import com.beust.jcommander.ParameterException; +import google.registry.proxy.ProxyConfig.Environment; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +/** Unit tests for {@link ProxyModule}. */ +@RunWith(JUnit4.class) +public class ProxyModuleTest { + + private static final ProxyConfig PROXY_CONFIG = getProxyConfig(TEST); + private final ProxyModule proxyModule = new ProxyModule(); + + @Test + public void testSuccess_parseArgs_defaultArgs() { + String[] args = {}; + proxyModule.parse(args); + assertThat(proxyModule.provideWhoisPort(PROXY_CONFIG)).isEqualTo(PROXY_CONFIG.whois.port); + assertThat(proxyModule.provideEppPort(PROXY_CONFIG)).isEqualTo(PROXY_CONFIG.epp.port); + assertThat(proxyModule.provideHealthCheckPort(PROXY_CONFIG)) + .isEqualTo(PROXY_CONFIG.healthCheck.port); + assertThat(proxyModule.provideEnvironment()).isEqualTo(Environment.LOCAL); + assertThat(proxyModule.log).isFalse(); + } + + @Test + public void testFailure_parseArgs_wrongArguments() { + String[] args = {"--wrong_flag", "some_value"}; + try { + proxyModule.parse(args); + fail("Expected ParameterException."); + } catch (ParameterException e) { + assertThat(e).hasMessageThat().contains("--wrong_flag"); + } + } + + @Test + public void testSuccess_parseArgs_log() { + String[] args = {"--log"}; + proxyModule.parse(args); + assertThat(proxyModule.log).isTrue(); + } + + @Test + public void testSuccess_parseArgs_customWhoisPort() { + String[] args = {"--whois", "12345"}; + proxyModule.parse(args); + assertThat(proxyModule.provideWhoisPort(PROXY_CONFIG)).isEqualTo(12345); + } + + @Test + public void testSuccess_parseArgs_customEppPort() { + String[] args = {"--epp", "22222"}; + proxyModule.parse(args); + assertThat(proxyModule.provideEppPort(PROXY_CONFIG)).isEqualTo(22222); + } + + @Test + public void testSuccess_parseArgs_customHealthCheckPort() { + String[] args = {"--health_check", "23456"}; + proxyModule.parse(args); + assertThat(proxyModule.provideHealthCheckPort(PROXY_CONFIG)).isEqualTo(23456); + } + + @Test + public void testSuccess_parseArgs_customEnvironment() { + String[] args = {"--env", "ALpHa"}; + proxyModule.parse(args); + assertThat(proxyModule.provideEnvironment()).isEqualTo(Environment.ALPHA); + } + + @Test + public void testFailure_parseArgs_wrongEnvironment() { + ParameterException e = + expectThrows( + ParameterException.class, + () -> { + String[] args = {"--env", "beta"}; + proxyModule.parse(args); + }); + assertThat(e).hasMessageThat().contains("Invalid value for --env parameter"); + } +} diff --git a/javatests/google/registry/proxy/TestUtils.java b/javatests/google/registry/proxy/TestUtils.java new file mode 100644 index 000000000..de4ed05fe --- /dev/null +++ b/javatests/google/registry/proxy/TestUtils.java @@ -0,0 +1,139 @@ +// Copyright 2017 The Nomulus Authors. All Rights Reserved. +// +// 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. + +package google.registry.proxy; + +import static com.google.common.truth.Truth.assertThat; +import static google.registry.proxy.handler.EppServiceHandler.EPP_CONTENT_TYPE; +import static google.registry.proxy.handler.EppServiceHandler.FORWARDED_FOR_FIELD; +import static google.registry.proxy.handler.EppServiceHandler.REQUESTED_SERVERNAME_VIA_SNI_FIELD; +import static google.registry.proxy.handler.EppServiceHandler.SSL_CLIENT_CERTIFICATE_HASH_FIELD; +import static java.nio.charset.StandardCharsets.US_ASCII; + +import io.netty.buffer.ByteBuf; +import io.netty.buffer.Unpooled; +import io.netty.handler.codec.http.DefaultFullHttpRequest; +import io.netty.handler.codec.http.DefaultFullHttpResponse; +import io.netty.handler.codec.http.FullHttpMessage; +import io.netty.handler.codec.http.FullHttpRequest; +import io.netty.handler.codec.http.FullHttpResponse; +import io.netty.handler.codec.http.HttpHeaderNames; +import io.netty.handler.codec.http.HttpHeaderValues; +import io.netty.handler.codec.http.HttpMethod; +import io.netty.handler.codec.http.HttpResponseStatus; +import io.netty.handler.codec.http.HttpVersion; +import io.netty.handler.codec.http.cookie.ClientCookieEncoder; +import io.netty.handler.codec.http.cookie.Cookie; +import io.netty.handler.codec.http.cookie.ServerCookieEncoder; + +/** Utility class for various helper methods used in testing. */ +public class TestUtils { + + public static FullHttpRequest makeHttpPostRequest(String content, String host, String path) { + ByteBuf buf = Unpooled.wrappedBuffer(content.getBytes(US_ASCII)); + FullHttpRequest request = + new DefaultFullHttpRequest(HttpVersion.HTTP_1_1, HttpMethod.POST, path, buf); + request + .headers() + .set(HttpHeaderNames.USER_AGENT, "Proxy") + .set(HttpHeaderNames.HOST, host) + .set(HttpHeaderNames.CONTENT_LENGTH, buf.readableBytes()); + return request; + } + + public static FullHttpResponse makeHttpResponse(String content, HttpResponseStatus status) { + ByteBuf buf = Unpooled.wrappedBuffer(content.getBytes(US_ASCII)); + FullHttpResponse response = new DefaultFullHttpResponse(HttpVersion.HTTP_1_1, status, buf); + response.headers().set(HttpHeaderNames.CONTENT_LENGTH, buf.readableBytes()); + return response; + } + + public static FullHttpRequest makeWhoisHttpRequest( + String content, String host, String path, String accessToken) { + FullHttpRequest request = makeHttpPostRequest(content, host, path); + request + .headers() + .set(HttpHeaderNames.CONNECTION, HttpHeaderValues.CLOSE) + .set(HttpHeaderNames.AUTHORIZATION, "Bearer " + accessToken) + .set(HttpHeaderNames.CONTENT_TYPE, HttpHeaderValues.TEXT_PLAIN) + .set(HttpHeaderNames.ACCEPT, HttpHeaderValues.TEXT_PLAIN); + return request; + } + + public static FullHttpRequest makeEppHttpRequest( + String content, + String host, + String path, + String accessToken, + String sslClientCertificateHash, + String serverHostname, + String clientAddress, + Cookie... cookies) { + FullHttpRequest request = makeHttpPostRequest(content, host, path); + request + .headers() + .set(HttpHeaderNames.AUTHORIZATION, "Bearer " + accessToken) + .set(HttpHeaderNames.CONTENT_TYPE, EPP_CONTENT_TYPE) + .set(HttpHeaderNames.ACCEPT, EPP_CONTENT_TYPE) + .set(SSL_CLIENT_CERTIFICATE_HASH_FIELD, sslClientCertificateHash) + .set(REQUESTED_SERVERNAME_VIA_SNI_FIELD, serverHostname) + .set(FORWARDED_FOR_FIELD, clientAddress); + if (cookies.length != 0) { + request.headers().set(HttpHeaderNames.COOKIE, ClientCookieEncoder.STRICT.encode(cookies)); + } + return request; + } + + public static FullHttpResponse makeWhoisHttpResponse(String content, HttpResponseStatus status) { + FullHttpResponse response = makeHttpResponse(content, status); + response.headers().set(HttpHeaderNames.CONTENT_TYPE, HttpHeaderValues.TEXT_PLAIN); + return response; + } + + public static FullHttpResponse makeEppHttpResponse( + String content, HttpResponseStatus status, Cookie... cookies) { + FullHttpResponse response = makeHttpResponse(content, status); + response.headers().set(HttpHeaderNames.CONTENT_TYPE, EPP_CONTENT_TYPE); + for (Cookie cookie : cookies) { + response.headers().add(HttpHeaderNames.SET_COOKIE, ServerCookieEncoder.STRICT.encode(cookie)); + } + return response; + } + + /** + * Compares two {@link FullHttpMessage} for equivalency. + * + *

This method is needed because an HTTP message decoded and aggregated from inbound {@link + * ByteBuf} is of a different class than the one written to the outbound {@link ByteBuf}, and The + * {@link ByteBuf} implementations that hold the content of the HTTP messages are different, even + * though the actual content, headers, etc are the same. + * + *

This method is not type-safe, msg1 & msg2 can be a request and a response, respectively. Do + * not use this method directly. + */ + private static void assertHttpMessageEquivalent(FullHttpMessage msg1, FullHttpMessage msg2) { + assertThat(msg1.protocolVersion()).isEqualTo(msg2.protocolVersion()); + assertThat(msg1.content()).isEqualTo(msg2.content()); + assertThat(msg1.headers()).isEqualTo(msg2.headers()); + } + + public static void assertHttpResponseEquivalent(FullHttpResponse res1, FullHttpResponse res2) { + assertThat(res1.status()).isEqualTo(res2.status()); + assertHttpMessageEquivalent(res1, res2); + } + + public static void assertHttpRequestEquivalent(FullHttpRequest req1, FullHttpRequest req2) { + assertHttpMessageEquivalent(req1, req2); + } +} diff --git a/javatests/google/registry/proxy/WhoisProtocolModuleTest.java b/javatests/google/registry/proxy/WhoisProtocolModuleTest.java new file mode 100644 index 000000000..395d9d826 --- /dev/null +++ b/javatests/google/registry/proxy/WhoisProtocolModuleTest.java @@ -0,0 +1,165 @@ +// Copyright 2017 The Nomulus Authors. All Rights Reserved. +// +// 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. + +package google.registry.proxy; + +import static com.google.common.truth.Truth.assertThat; +import static google.registry.proxy.TestUtils.makeWhoisHttpRequest; +import static google.registry.proxy.TestUtils.makeWhoisHttpResponse; +import static java.nio.charset.StandardCharsets.US_ASCII; +import static java.util.stream.Collectors.joining; +import static org.junit.Assert.fail; + +import io.netty.buffer.ByteBuf; +import io.netty.buffer.Unpooled; +import io.netty.handler.codec.http.FullHttpRequest; +import io.netty.handler.codec.http.FullHttpResponse; +import io.netty.handler.codec.http.HttpResponseStatus; +import java.util.stream.Stream; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +/** End-to-end tests for {@link WhoisProtocolModule}. */ +@RunWith(JUnit4.class) +public class WhoisProtocolModuleTest extends ProtocolModuleTest { + + public WhoisProtocolModuleTest() { + super(TestComponent::whoisHandlers); + } + + @Test + public void testSuccess_singleFrameInboundMessage() { + String inputString = "test.tld\r\n"; + // Inbound message processed and passed along. + assertThat(channel.writeInbound(Unpooled.wrappedBuffer(inputString.getBytes(US_ASCII)))) + .isTrue(); + + FullHttpRequest actualRequest = channel.readInbound(); + FullHttpRequest expectedRequest = + makeWhoisHttpRequest( + "test.tld", + PROXY_CONFIG.whois.relayHost, + PROXY_CONFIG.whois.relayPath, + TestModule.provideFakeAccessToken().get()); + assertThat(expectedRequest).isEqualTo(actualRequest); + assertThat(channel.isActive()).isTrue(); + // Nothing more to read. + assertThat((Object) channel.readInbound()).isNull(); + } + + @Test + public void testSuccess_noNewlineInboundMessage() { + String inputString = "test.tld"; + // No newline encountered, no message formed. + assertThat(channel.writeInbound(Unpooled.wrappedBuffer(inputString.getBytes(US_ASCII)))) + .isFalse(); + assertThat(channel.isActive()).isTrue(); + } + + @Test + public void testSuccess_multiFrameInboundMessage() { + String frame1 = "test"; + String frame2 = "1.tld"; + String frame3 = "\r\nte"; + String frame4 = "st2.tld\r"; + String frame5 = "\ntest3.tld"; + // No newline yet. + assertThat(channel.writeInbound(Unpooled.wrappedBuffer(frame1.getBytes(US_ASCII)))).isFalse(); + // Still no newline yet. + assertThat(channel.writeInbound(Unpooled.wrappedBuffer(frame2.getBytes(US_ASCII)))).isFalse(); + // First newline encountered. + assertThat(channel.writeInbound(Unpooled.wrappedBuffer(frame3.getBytes(US_ASCII)))).isTrue(); + FullHttpRequest actualRequest1 = channel.readInbound(); + FullHttpRequest expectedRequest1 = + makeWhoisHttpRequest( + "test1.tld", + PROXY_CONFIG.whois.relayHost, + PROXY_CONFIG.whois.relayPath, + TestModule.provideFakeAccessToken().get()); + assertThat(expectedRequest1).isEqualTo(actualRequest1); + // No more message at this point. + assertThat((Object) channel.readInbound()).isNull(); + // More inbound bytes, but no newline. + assertThat(channel.writeInbound(Unpooled.wrappedBuffer(frame4.getBytes(US_ASCII)))).isFalse(); + // Second message read. + assertThat(channel.writeInbound(Unpooled.wrappedBuffer(frame5.getBytes(US_ASCII)))).isTrue(); + FullHttpRequest actualRequest2 = channel.readInbound(); + FullHttpRequest expectedRequest2 = + makeWhoisHttpRequest( + "test2.tld", + PROXY_CONFIG.whois.relayHost, + PROXY_CONFIG.whois.relayPath, + TestModule.provideFakeAccessToken().get()); + assertThat(expectedRequest2).isEqualTo(actualRequest2); + // The third message is not complete yet. + assertThat(channel.isActive()).isTrue(); + assertThat((Object) channel.readInbound()).isNull(); + } + + @Test + public void testSuccess_inboundMessageTooLong() { + String inputString = Stream.generate(() -> "x").limit(513).collect(joining()) + "\r\n"; + // Nothing gets propagated further. + assertThat(channel.writeInbound(Unpooled.wrappedBuffer(inputString.getBytes(US_ASCII)))) + .isFalse(); + // Connection is closed due to inbound message overflow. + assertThat(channel.isActive()).isFalse(); + } + + @Test + public void testSuccess_parseSingleOutboundHttpResponse() { + String outputString = "line1\r\nline2\r\n"; + FullHttpResponse response = makeWhoisHttpResponse(outputString, HttpResponseStatus.OK); + // Http response parsed and passed along. + assertThat(channel.writeOutbound(response)).isTrue(); + ByteBuf outputBuffer = channel.readOutbound(); + assertThat(outputBuffer.toString(US_ASCII)).isEqualTo(outputString); + assertThat(channel.isActive()).isTrue(); + // Nothing more to write. + assertThat((Object) channel.readOutbound()).isNull(); + } + + @Test + public void testSuccess_parseMultipleOutboundHttpResponse() { + String outputString1 = "line1\r\nline2\r\n"; + String outputString2 = "line3\r\nline4\r\nline5\r\n"; + FullHttpResponse response1 = makeWhoisHttpResponse(outputString1, HttpResponseStatus.OK); + FullHttpResponse response2 = makeWhoisHttpResponse(outputString2, HttpResponseStatus.OK); + assertThat(channel.writeOutbound(response1, response2)).isTrue(); + // First Http response parsed + ByteBuf outputBuffer1 = channel.readOutbound(); + assertThat(outputBuffer1.toString(US_ASCII)).isEqualTo(outputString1); + // Second Http response parsed + ByteBuf outputBuffer2 = channel.readOutbound(); + assertThat(outputBuffer2.toString(US_ASCII)).isEqualTo(outputString2); + assertThat(channel.isActive()).isTrue(); + // Nothing more to write. + assertThat((Object) channel.readOutbound()).isNull(); + } + + @Test + public void testFailure_outboundResponseStatusNotOK() { + String outputString = "line1\r\nline2\r\n"; + FullHttpResponse response = makeWhoisHttpResponse(outputString, HttpResponseStatus.BAD_REQUEST); + try { + channel.writeOutbound(response); + fail("Expected failure due to non-OK HTTP response status"); + } catch (Exception e) { + assertThat(e).hasCauseThat().isInstanceOf(IllegalArgumentException.class); + assertThat(e).hasMessageThat().contains("400 Bad Request"); + } + assertThat(channel.isActive()).isFalse(); + } +} diff --git a/javatests/google/registry/proxy/handler/BackendMetricsHandlerTest.java b/javatests/google/registry/proxy/handler/BackendMetricsHandlerTest.java new file mode 100644 index 000000000..01afb4bf3 --- /dev/null +++ b/javatests/google/registry/proxy/handler/BackendMetricsHandlerTest.java @@ -0,0 +1,227 @@ +// Copyright 2017 The Nomulus Authors. All Rights Reserved. +// +// 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. + +package google.registry.proxy.handler; + +import static com.google.common.truth.Truth.assertThat; +import static google.registry.proxy.Protocol.PROTOCOL_KEY; +import static google.registry.proxy.TestUtils.assertHttpRequestEquivalent; +import static google.registry.proxy.TestUtils.assertHttpResponseEquivalent; +import static google.registry.proxy.TestUtils.makeHttpPostRequest; +import static google.registry.proxy.TestUtils.makeHttpResponse; +import static google.registry.proxy.handler.EppServiceHandler.CLIENT_CERTIFICATE_HASH_KEY; +import static google.registry.proxy.handler.RelayHandler.RELAY_CHANNEL_KEY; +import static google.registry.testing.JUnitBackports.expectThrows; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; + +import com.google.common.collect.ImmutableList; +import google.registry.proxy.Protocol; +import google.registry.proxy.Protocol.BackendProtocol; +import google.registry.proxy.Protocol.FrontendProtocol; +import google.registry.proxy.metric.BackendMetrics; +import google.registry.testing.FakeClock; +import io.netty.channel.ChannelInitializer; +import io.netty.channel.embedded.EmbeddedChannel; +import io.netty.handler.codec.http.FullHttpRequest; +import io.netty.handler.codec.http.FullHttpResponse; +import io.netty.handler.codec.http.HttpResponseStatus; +import org.joda.time.DateTime; +import org.joda.time.Duration; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +/** Unit tests for {@link BackendMetricsHandler}. */ +@RunWith(JUnit4.class) +public class BackendMetricsHandlerTest { + + private static final String HOST = "host.tld"; + private static final String CLIENT_CERT_HASH = "blah12345"; + private static final String RELAYED_PROTOCOL_NAME = "frontend protocol"; + + private final FakeClock fakeClock = new FakeClock(); + private final BackendMetrics metrics = mock(BackendMetrics.class); + private final BackendMetricsHandler handler = new BackendMetricsHandler(fakeClock, metrics); + + private final BackendProtocol backendProtocol = + Protocol.backendBuilder() + .name("backend protocol") + .host(HOST) + .port(1) + .handlerProviders(ImmutableList.of()) + .build(); + + private final FrontendProtocol frontendProtocol = + Protocol.frontendBuilder() + .name(RELAYED_PROTOCOL_NAME) + .port(2) + .relayProtocol(backendProtocol) + .handlerProviders(ImmutableList.of()) + .build(); + + private EmbeddedChannel channel; + + @Before + public void setUp() { + EmbeddedChannel frontendChannel = new EmbeddedChannel(); + frontendChannel.attr(PROTOCOL_KEY).set(frontendProtocol); + frontendChannel.attr(CLIENT_CERTIFICATE_HASH_KEY).set(CLIENT_CERT_HASH); + channel = + new EmbeddedChannel( + new ChannelInitializer() { + @Override + protected void initChannel(EmbeddedChannel ch) throws Exception { + ch.attr(PROTOCOL_KEY).set(backendProtocol); + ch.attr(RELAY_CHANNEL_KEY).set(frontendChannel); + ch.pipeline().addLast(handler); + } + }); + } + + @Test + public void testFailure_outbound_wrongType() { + Object request = new Object(); + IllegalArgumentException e = + expectThrows(IllegalArgumentException.class, () -> channel.writeOutbound(request)); + assertThat(e).hasMessageThat().isEqualTo("Outgoing request must be FullHttpRequest."); + } + + @Test + public void testFailure_inbound_wrongType() { + Object response = new Object(); + IllegalArgumentException e = + expectThrows(IllegalArgumentException.class, () -> channel.writeInbound(response)); + assertThat(e).hasMessageThat().isEqualTo("Incoming response must be FullHttpResponse."); + } + + @Test + public void testSuccess_oneRequest() { + FullHttpRequest request = makeHttpPostRequest("some content", HOST, "/"); + // outbound message passed to the next handler. + assertThat(channel.writeOutbound(request)).isTrue(); + assertHttpRequestEquivalent(request, channel.readOutbound()); + verify(metrics).requestSent(RELAYED_PROTOCOL_NAME, CLIENT_CERT_HASH, request); + verifyNoMoreInteractions(metrics); + } + + @Test + public void testSuccess_oneRequest_oneResponse() { + FullHttpRequest request = makeHttpPostRequest("some request", HOST, "/"); + FullHttpResponse response = makeHttpResponse("some response", HttpResponseStatus.OK); + // outbound message passed to the next handler. + assertThat(channel.writeOutbound(request)).isTrue(); + assertHttpRequestEquivalent(request, channel.readOutbound()); + fakeClock.advanceOneMilli(); + // inbound message passed to the next handler. + assertThat(channel.writeInbound(response)).isTrue(); + assertHttpResponseEquivalent(response, channel.readInbound()); + + verify(metrics).requestSent(RELAYED_PROTOCOL_NAME, CLIENT_CERT_HASH, request); + verify(metrics).responseReceived(RELAYED_PROTOCOL_NAME, CLIENT_CERT_HASH, response, 1); + verifyNoMoreInteractions(metrics); + } + + @Test + public void testSuccess_badResponse() { + FullHttpRequest request = makeHttpPostRequest("some request", HOST, "/"); + FullHttpResponse response = + makeHttpResponse("some bad response", HttpResponseStatus.BAD_REQUEST); + // outbound message passed to the next handler. + assertThat(channel.writeOutbound(request)).isTrue(); + assertHttpRequestEquivalent(request, channel.readOutbound()); + fakeClock.advanceOneMilli(); + // inbound message passed to the next handler. + // Even though the response status is not OK, the metrics handler only logs it and pass it + // along to the next handler, which handles it. + assertThat(channel.writeInbound(response)).isTrue(); + assertHttpResponseEquivalent(response, channel.readInbound()); + + verify(metrics).requestSent(RELAYED_PROTOCOL_NAME, CLIENT_CERT_HASH, request); + verify(metrics).responseReceived(RELAYED_PROTOCOL_NAME, CLIENT_CERT_HASH, response, 1); + verifyNoMoreInteractions(metrics); + } + + @Test + public void testFailure_responseBeforeRequest() { + FullHttpResponse response = makeHttpResponse("phantom response", HttpResponseStatus.OK); + IllegalStateException e = + expectThrows(IllegalStateException.class, () -> channel.writeInbound(response)); + assertThat(e).hasMessageThat().isEqualTo("Response received before request is sent."); + } + + @Test + public void testSuccess_pipelinedResponses() { + FullHttpRequest request1 = makeHttpPostRequest("request 1", HOST, "/"); + FullHttpResponse response1 = makeHttpResponse("response 1", HttpResponseStatus.OK); + FullHttpRequest request2 = makeHttpPostRequest("request 2", HOST, "/"); + FullHttpResponse response2 = makeHttpResponse("response 2", HttpResponseStatus.OK); + FullHttpRequest request3 = makeHttpPostRequest("request 3", HOST, "/"); + FullHttpResponse response3 = makeHttpResponse("response 3", HttpResponseStatus.OK); + + // First request, time = 0 + assertThat(channel.writeOutbound(request1)).isTrue(); + assertHttpRequestEquivalent(request1, channel.readOutbound()); + DateTime sentTime1 = fakeClock.nowUtc(); + + fakeClock.advanceBy(Duration.millis(5)); + + // Second request, time = 5 + assertThat(channel.writeOutbound(request2)).isTrue(); + assertHttpRequestEquivalent(request2, channel.readOutbound()); + DateTime sentTime2 = fakeClock.nowUtc(); + + fakeClock.advanceBy(Duration.millis(7)); + + // First response, time = 12, latency = 12 - 0 = 12 + assertThat(channel.writeInbound(response1)).isTrue(); + assertHttpResponseEquivalent(response1, channel.readInbound()); + DateTime receivedTime1 = fakeClock.nowUtc(); + + fakeClock.advanceBy(Duration.millis(11)); + + // Third request, time = 23 + assertThat(channel.writeOutbound(request3)).isTrue(); + assertHttpRequestEquivalent(request3, channel.readOutbound()); + DateTime sentTime3 = fakeClock.nowUtc(); + + fakeClock.advanceBy(Duration.millis(2)); + + // Second response, time = 25, latency = 25 - 5 = 20 + assertThat(channel.writeInbound(response2)).isTrue(); + assertHttpResponseEquivalent(response2, channel.readInbound()); + DateTime receivedTime2 = fakeClock.nowUtc(); + + fakeClock.advanceBy(Duration.millis(4)); + + // Third response, time = 29, latency = 29 - 23 = 6 + assertThat(channel.writeInbound(response3)).isTrue(); + assertHttpResponseEquivalent(response3, channel.readInbound()); + DateTime receivedTime3 = fakeClock.nowUtc(); + + long latency1 = new Duration(sentTime1, receivedTime1).getMillis(); + long latency2 = new Duration(sentTime2, receivedTime2).getMillis(); + long latency3 = new Duration(sentTime3, receivedTime3).getMillis(); + + verify(metrics).requestSent(RELAYED_PROTOCOL_NAME, CLIENT_CERT_HASH, request1); + verify(metrics).requestSent(RELAYED_PROTOCOL_NAME, CLIENT_CERT_HASH, request2); + verify(metrics).requestSent(RELAYED_PROTOCOL_NAME, CLIENT_CERT_HASH, request3); + verify(metrics).responseReceived(RELAYED_PROTOCOL_NAME, CLIENT_CERT_HASH, response1, latency1); + verify(metrics).responseReceived(RELAYED_PROTOCOL_NAME, CLIENT_CERT_HASH, response2, latency2); + verify(metrics).responseReceived(RELAYED_PROTOCOL_NAME, CLIENT_CERT_HASH, response3, latency3); + verifyNoMoreInteractions(metrics); + } +} diff --git a/javatests/google/registry/proxy/handler/EppServiceHandlerTest.java b/javatests/google/registry/proxy/handler/EppServiceHandlerTest.java new file mode 100644 index 000000000..f7ae274b5 --- /dev/null +++ b/javatests/google/registry/proxy/handler/EppServiceHandlerTest.java @@ -0,0 +1,330 @@ +// Copyright 2017 The Nomulus Authors. All Rights Reserved. +// +// 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. + +package google.registry.proxy.handler; + +import static com.google.common.truth.Truth.assertThat; +import static google.registry.proxy.TestUtils.assertHttpRequestEquivalent; +import static google.registry.proxy.TestUtils.makeEppHttpResponse; +import static google.registry.proxy.handler.ProxyProtocolHandler.REMOTE_ADDRESS_KEY; +import static google.registry.proxy.handler.SslServerInitializer.CLIENT_CERTIFICATE_PROMISE_KEY; +import static google.registry.util.X509Utils.getCertificateHash; +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.junit.Assert.fail; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; + +import google.registry.proxy.TestUtils; +import google.registry.proxy.metric.FrontendMetrics; +import io.netty.buffer.ByteBuf; +import io.netty.buffer.Unpooled; +import io.netty.channel.ChannelInitializer; +import io.netty.channel.DefaultChannelId; +import io.netty.channel.embedded.EmbeddedChannel; +import io.netty.handler.codec.EncoderException; +import io.netty.handler.codec.http.FullHttpRequest; +import io.netty.handler.codec.http.HttpResponse; +import io.netty.handler.codec.http.HttpResponseStatus; +import io.netty.handler.codec.http.cookie.Cookie; +import io.netty.handler.codec.http.cookie.DefaultCookie; +import io.netty.handler.ssl.util.SelfSignedCertificate; +import io.netty.util.concurrent.Promise; +import java.security.cert.X509Certificate; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +/** Unit tests for {@link EppServiceHandler}. */ +@RunWith(JUnit4.class) +public class EppServiceHandlerTest { + + private static final String HELLO = + "\n" + + "\n" + + " \n" + + "\n"; + + private static final String RELAY_HOST = "registry.example.tld"; + private static final String RELAY_PATH = "/epp"; + private static final String ACCESS_TOKEN = "this.access.token"; + private static final String SERVER_HOSTNAME = "epp.example.tld"; + private static final String CLIENT_ADDRESS = "epp.client.tld"; + private static final String PROTOCOL = "epp"; + + private X509Certificate clientCertificate; + + private final FrontendMetrics metrics = mock(FrontendMetrics.class); + + private final EppServiceHandler eppServiceHandler = + new EppServiceHandler( + RELAY_HOST, + RELAY_PATH, + () -> ACCESS_TOKEN, + SERVER_HOSTNAME, + HELLO.getBytes(UTF_8), + metrics); + + private EmbeddedChannel channel; + + private void setHandshakeSuccess(EmbeddedChannel channel, X509Certificate certificate) + throws Exception { + Promise unusedPromise = + channel.attr(CLIENT_CERTIFICATE_PROMISE_KEY).get().setSuccess(certificate); + } + + private void setHandshakeSuccess() throws Exception { + setHandshakeSuccess(channel, clientCertificate); + } + + private void setHandshakeFailure(EmbeddedChannel channel) throws Exception { + Promise unusedPromise = + channel + .attr(CLIENT_CERTIFICATE_PROMISE_KEY) + .get() + .setFailure(new Exception("Handshake Failure")); + } + + private void setHandshakeFailure() throws Exception { + setHandshakeFailure(channel); + } + + private FullHttpRequest makeEppHttpRequest(String content, Cookie... cookies) { + return TestUtils.makeEppHttpRequest( + content, + RELAY_HOST, + RELAY_PATH, + ACCESS_TOKEN, + getCertificateHash(clientCertificate), + SERVER_HOSTNAME, + CLIENT_ADDRESS, + cookies); + } + + @Before + public void setUp() throws Exception { + clientCertificate = new SelfSignedCertificate().cert(); + channel = setUpNewChannel(eppServiceHandler); + } + + private EmbeddedChannel setUpNewChannel(EppServiceHandler handler) throws Exception { + return new EmbeddedChannel( + DefaultChannelId.newInstance(), + new ChannelInitializer() { + @Override + protected void initChannel(EmbeddedChannel ch) throws Exception { + ch.attr(REMOTE_ADDRESS_KEY).set(CLIENT_ADDRESS); + ch.attr(CLIENT_CERTIFICATE_PROMISE_KEY).set(ch.eventLoop().newPromise()); + ch.pipeline().addLast(handler); + } + }); + } + + @Test + public void testSuccess_connectionMetrics_oneConnection() throws Exception { + setHandshakeSuccess(); + String certHash = getCertificateHash(clientCertificate); + assertThat(channel.isActive()).isTrue(); + verify(metrics).registerActiveConnection(PROTOCOL, certHash, channel); + verifyNoMoreInteractions(metrics); + } + + @Test + public void testSuccess_connectionMetrics_twoConnections_sameClient() throws Exception { + setHandshakeSuccess(); + String certHash = getCertificateHash(clientCertificate); + assertThat(channel.isActive()).isTrue(); + + // Setup the second channel. + EppServiceHandler eppServiceHandler2 = + new EppServiceHandler( + RELAY_HOST, + RELAY_PATH, + () -> ACCESS_TOKEN, + SERVER_HOSTNAME, + HELLO.getBytes(UTF_8), + metrics); + EmbeddedChannel channel2 = setUpNewChannel(eppServiceHandler2); + setHandshakeSuccess(channel2, clientCertificate); + + assertThat(channel2.isActive()).isTrue(); + + verify(metrics).registerActiveConnection(PROTOCOL, certHash, channel); + verify(metrics).registerActiveConnection(PROTOCOL, certHash, channel2); + verifyNoMoreInteractions(metrics); + } + + @Test + public void testSuccess_connectionMetrics_twoConnections_differentClients() throws Exception { + setHandshakeSuccess(); + String certHash = getCertificateHash(clientCertificate); + assertThat(channel.isActive()).isTrue(); + + // Setup the second channel. + EppServiceHandler eppServiceHandler2 = + new EppServiceHandler( + RELAY_HOST, + RELAY_PATH, + () -> ACCESS_TOKEN, + SERVER_HOSTNAME, + HELLO.getBytes(UTF_8), + metrics); + EmbeddedChannel channel2 = setUpNewChannel(eppServiceHandler2); + X509Certificate clientCertificate2 = new SelfSignedCertificate().cert(); + setHandshakeSuccess(channel2, clientCertificate2); + String certHash2 = getCertificateHash(clientCertificate2); + + assertThat(channel2.isActive()).isTrue(); + + verify(metrics).registerActiveConnection(PROTOCOL, certHash, channel); + verify(metrics).registerActiveConnection(PROTOCOL, certHash2, channel2); + verifyNoMoreInteractions(metrics); + } + + @Test + public void testSuccess_sendHelloUponHandshakeSuccess() throws Exception { + // Nothing to pass to the next handler. + assertThat((Object) channel.readInbound()).isNull(); + setHandshakeSuccess(); + // hello bytes should be passed to the next handler. + FullHttpRequest helloRequest = channel.readInbound(); + assertThat(helloRequest).isEqualTo(makeEppHttpRequest(HELLO)); + // Nothing further to pass to the next handler. + assertThat((Object) channel.readInbound()).isNull(); + assertThat(channel.isActive()).isTrue(); + } + + @Test + public void testSuccess_disconnectUponHandshakeFailure() throws Exception { + // Nothing to pass to the next handler. + assertThat((Object) channel.readInbound()).isNull(); + setHandshakeFailure(); + assertThat(channel.isActive()).isFalse(); + } + + @Test + public void testSuccess_sendRequestToNextHandler() throws Exception { + setHandshakeSuccess(); + // First inbound message is hello. + channel.readInbound(); + String content = "stuff"; + channel.writeInbound(Unpooled.wrappedBuffer(content.getBytes(UTF_8))); + FullHttpRequest request = channel.readInbound(); + assertThat(request).isEqualTo(makeEppHttpRequest(content)); + // Nothing further to pass to the next handler. + assertThat((Object) channel.readInbound()).isNull(); + assertThat(channel.isActive()).isTrue(); + } + + @Test + public void testSuccess_sendResponseToNextHandler() throws Exception { + setHandshakeSuccess(); + String content = "stuff"; + channel.writeOutbound(makeEppHttpResponse(content, HttpResponseStatus.OK)); + ByteBuf response = channel.readOutbound(); + assertThat(response).isEqualTo(Unpooled.wrappedBuffer(content.getBytes(UTF_8))); + // Nothing further to pass to the next handler. + assertThat((Object) channel.readOutbound()).isNull(); + assertThat(channel.isActive()).isTrue(); + } + + @Test + public void testSuccess_sendResponseToNextHandler_andDisconnect() throws Exception { + setHandshakeSuccess(); + String content = "stuff"; + HttpResponse response = makeEppHttpResponse(content, HttpResponseStatus.OK); + response.headers().set("Epp-Session", "close"); + channel.writeOutbound(response); + ByteBuf expectedResponse = channel.readOutbound(); + assertThat(expectedResponse).isEqualTo(Unpooled.wrappedBuffer(content.getBytes(UTF_8))); + // Nothing further to pass to the next handler. + assertThat((Object) channel.readOutbound()).isNull(); + // Channel is disconnected. + assertThat(channel.isActive()).isFalse(); + } + + @Test + public void testFailure_disconnectOnNonOKResponseStatus() throws Exception { + setHandshakeSuccess(); + String content = "stuff"; + try { + channel.writeOutbound(makeEppHttpResponse(content, HttpResponseStatus.BAD_REQUEST)); + fail("Expected EncoderException"); + } catch (EncoderException e) { + assertThat(e).hasCauseThat().isInstanceOf(IllegalArgumentException.class); + assertThat(e).hasMessageThat().contains(HttpResponseStatus.BAD_REQUEST.toString()); + assertThat(channel.isActive()).isFalse(); + } + } + + @Test + public void testSuccess_setCookies() throws Exception { + setHandshakeSuccess(); + // First inbound message is hello. + channel.readInbound(); + String responseContent = "response"; + Cookie cookie1 = new DefaultCookie("name1", "value1"); + Cookie cookie2 = new DefaultCookie("name2", "value2"); + channel.writeOutbound( + makeEppHttpResponse(responseContent, HttpResponseStatus.OK, cookie1, cookie2)); + ByteBuf response = channel.readOutbound(); + assertThat(response).isEqualTo(Unpooled.wrappedBuffer(responseContent.getBytes(UTF_8))); + String requestContent = "request"; + channel.writeInbound(Unpooled.wrappedBuffer(requestContent.getBytes(UTF_8))); + FullHttpRequest request = channel.readInbound(); + assertHttpRequestEquivalent(request, makeEppHttpRequest(requestContent, cookie1, cookie2)); + // Nothing further to pass to the next handler. + assertThat((Object) channel.readInbound()).isNull(); + assertThat((Object) channel.readOutbound()).isNull(); + assertThat(channel.isActive()).isTrue(); + } + + @Test + public void testSuccess_updateCookies() throws Exception { + setHandshakeSuccess(); + // First inbound message is hello. + channel.readInbound(); + String responseContent1 = "response1"; + Cookie cookie1 = new DefaultCookie("name1", "value1"); + Cookie cookie2 = new DefaultCookie("name2", "value2"); + // First response written. + channel.writeOutbound( + makeEppHttpResponse(responseContent1, HttpResponseStatus.OK, cookie1, cookie2)); + channel.readOutbound(); + String requestContent1 = "request1"; + // First request written. + channel.writeInbound(Unpooled.wrappedBuffer(requestContent1.getBytes(UTF_8))); + FullHttpRequest request1 = channel.readInbound(); + assertHttpRequestEquivalent(request1, makeEppHttpRequest(requestContent1, cookie1, cookie2)); + String responseContent2 = "response2"; + Cookie cookie3 = new DefaultCookie("name3", "value3"); + Cookie newCookie2 = new DefaultCookie("name2", "newValue"); + // Second response written. + channel.writeOutbound( + makeEppHttpResponse(responseContent2, HttpResponseStatus.OK, cookie3, newCookie2)); + channel.readOutbound(); + String requestContent2 = "request2"; + // Second request written. + channel.writeInbound(Unpooled.wrappedBuffer(requestContent2.getBytes(UTF_8))); + FullHttpRequest request2 = channel.readInbound(); + // Cookies in second request should be updated. + assertHttpRequestEquivalent( + request2, makeEppHttpRequest(requestContent2, cookie1, newCookie2, cookie3)); + // Nothing further to pass to the next handler. + assertThat((Object) channel.readInbound()).isNull(); + assertThat((Object) channel.readOutbound()).isNull(); + assertThat(channel.isActive()).isTrue(); + } +} diff --git a/javatests/google/registry/proxy/handler/HealthCheckHandlerTest.java b/javatests/google/registry/proxy/handler/HealthCheckHandlerTest.java new file mode 100644 index 000000000..634163f9d --- /dev/null +++ b/javatests/google/registry/proxy/handler/HealthCheckHandlerTest.java @@ -0,0 +1,58 @@ +// Copyright 2017 The Nomulus Authors. All Rights Reserved. +// +// 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. + +package google.registry.proxy.handler; + +import static com.google.common.truth.Truth.assertThat; +import static java.nio.charset.StandardCharsets.US_ASCII; + +import io.netty.buffer.ByteBuf; +import io.netty.buffer.Unpooled; +import io.netty.channel.embedded.EmbeddedChannel; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +/** Unit tests for {@link HealthCheckHandler}. */ +@RunWith(JUnit4.class) +public class HealthCheckHandlerTest { + + private static final String CHECK_REQ = "REQUEST"; + private static final String CHECK_RES = "RESPONSE"; + + private final HealthCheckHandler healthCheckHandler = + new HealthCheckHandler(CHECK_REQ, CHECK_RES); + private final EmbeddedChannel channel = new EmbeddedChannel(healthCheckHandler); + + @Test + public void testSuccess_ResponseSent() { + ByteBuf input = Unpooled.wrappedBuffer(CHECK_REQ.getBytes(US_ASCII)); + // No inbound message passed to the next handler. + assertThat(channel.writeInbound(input)).isFalse(); + ByteBuf output = channel.readOutbound(); + assertThat(channel.isActive()).isTrue(); + assertThat(output.toString(US_ASCII)).isEqualTo(CHECK_RES); + } + + @Test + public void testSuccess_IgnoreUnrecognizedRequest() { + String unrecognizedInput = "1234567"; + ByteBuf input = Unpooled.wrappedBuffer(unrecognizedInput.getBytes(US_ASCII)); + // No inbound message passed to the next handler. + assertThat(channel.writeInbound(input)).isFalse(); + // No response is sent. + assertThat(channel.isActive()).isTrue(); + assertThat((Object) channel.readOutbound()).isNull(); + } +} diff --git a/javatests/google/registry/proxy/handler/ProxyProtocolHandlerTest.java b/javatests/google/registry/proxy/handler/ProxyProtocolHandlerTest.java new file mode 100644 index 000000000..5e39419f4 --- /dev/null +++ b/javatests/google/registry/proxy/handler/ProxyProtocolHandlerTest.java @@ -0,0 +1,118 @@ +// Copyright 2017 The Nomulus Authors. All Rights Reserved. +// +// 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. + +package google.registry.proxy.handler; + +import static com.google.common.truth.Truth.assertThat; +import static google.registry.proxy.handler.ProxyProtocolHandler.REMOTE_ADDRESS_KEY; +import static java.nio.charset.StandardCharsets.UTF_8; + +import io.netty.buffer.ByteBuf; +import io.netty.buffer.Unpooled; +import io.netty.channel.embedded.EmbeddedChannel; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +/** Unit tests for {@link ProxyProtocolHandler}. */ +@RunWith(JUnit4.class) +public class ProxyProtocolHandlerTest { + + private static final String HEADER_TEMPLATE = "PROXY TCP%d %s %s %s %s\r\n"; + + private final ProxyProtocolHandler handler = new ProxyProtocolHandler(); + private final EmbeddedChannel channel = new EmbeddedChannel(handler); + + private String header; + + @Test + public void testSuccess_proxyHeaderPresent_singleFrame() { + header = String.format(HEADER_TEMPLATE, 4, "172.0.0.1", "255.255.255.255", "234", "123"); + String message = "some message"; + // Header processed, rest of the message passed along. + assertThat(channel.writeInbound(Unpooled.wrappedBuffer((header + message).getBytes(UTF_8)))) + .isTrue(); + assertThat(((ByteBuf) channel.readInbound()).toString(UTF_8)).isEqualTo(message); + assertThat(channel.attr(REMOTE_ADDRESS_KEY).get()).isEqualTo("172.0.0.1"); + assertThat(channel.pipeline().get(ProxyProtocolHandler.class)).isNull(); + assertThat(channel.isActive()).isTrue(); + } + + @Test + public void testSuccess_proxyHeaderPresent_multipleFrames() { + header = String.format(HEADER_TEMPLATE, 4, "172.0.0.1", "255.255.255.255", "234", "123"); + String frame1 = header.substring(0, 4); + String frame2 = header.substring(4, 7); + String frame3 = header.substring(7, 15); + String frame4 = header.substring(15, header.length() - 1); + String frame5 = header.substring(header.length() - 1) + "some message"; + // Have not had enough bytes to determine the presence of a header, no message passed along. + assertThat(channel.writeInbound(Unpooled.wrappedBuffer(frame1.getBytes(UTF_8)))).isFalse(); + // Have not had enough bytes to determine the end a header, no message passed along. + assertThat(channel.writeInbound(Unpooled.wrappedBuffer(frame2.getBytes(UTF_8)))).isFalse(); + assertThat(channel.writeInbound(Unpooled.wrappedBuffer(frame3.getBytes(UTF_8)))).isFalse(); + assertThat(channel.writeInbound(Unpooled.wrappedBuffer(frame4.getBytes(UTF_8)))).isFalse(); + // Now there are enough bytes to construct a header. + assertThat(channel.writeInbound(Unpooled.wrappedBuffer(frame5.getBytes(UTF_8)))).isTrue(); + assertThat(((ByteBuf) channel.readInbound()).toString(UTF_8)).isEqualTo("some message"); + assertThat(channel.attr(REMOTE_ADDRESS_KEY).get()).isEqualTo("172.0.0.1"); + assertThat(channel.pipeline().get(ProxyProtocolHandler.class)).isNull(); + assertThat(channel.isActive()).isTrue(); + } + + @Test + public void testSuccess_proxyHeaderPresent_singleFrame_ipv6() { + header = + String.format(HEADER_TEMPLATE, 6, "2001:db8:0:1:1:1:1:1", "0:0:0:0:0:0:0:1", "234", "123"); + String message = "some message"; + // Header processed, rest of the message passed along. + assertThat(channel.writeInbound(Unpooled.wrappedBuffer((header + message).getBytes(UTF_8)))) + .isTrue(); + assertThat(((ByteBuf) channel.readInbound()).toString(UTF_8)).isEqualTo(message); + assertThat(channel.attr(REMOTE_ADDRESS_KEY).get()).isEqualTo("2001:db8:0:1:1:1:1:1"); + assertThat(channel.pipeline().get(ProxyProtocolHandler.class)).isNull(); + assertThat(channel.isActive()).isTrue(); + } + + @Test + public void testSuccess_proxyHeaderNotPresent_singleFrame() { + String message = "some message"; + // No header present, rest of the message passed along. + assertThat(channel.writeInbound(Unpooled.wrappedBuffer(message.getBytes(UTF_8)))).isTrue(); + assertThat(((ByteBuf) channel.readInbound()).toString(UTF_8)).isEqualTo(message); + assertThat(channel.attr(REMOTE_ADDRESS_KEY).get()).isNull(); + assertThat(channel.pipeline().get(ProxyProtocolHandler.class)).isNull(); + assertThat(channel.isActive()).isTrue(); + } + + @Test + public void testSuccess_proxyHeaderNotPresent_multipleFrames() { + String frame1 = "som"; + String frame2 = "e mess"; + String frame3 = "age\nis not"; + String frame4 = "meant to be good.\n"; + // Have not had enough bytes to determine the presence of a header, no message passed along. + assertThat(channel.writeInbound(Unpooled.wrappedBuffer(frame1.getBytes(UTF_8)))).isFalse(); + // Now we have more than five bytes to determine if it starts with "PROXY" + assertThat(channel.writeInbound(Unpooled.wrappedBuffer(frame2.getBytes(UTF_8)))).isTrue(); + assertThat(((ByteBuf) channel.readInbound()).toString(UTF_8)).isEqualTo(frame1 + frame2); + assertThat(channel.writeInbound(Unpooled.wrappedBuffer(frame3.getBytes(UTF_8)))).isTrue(); + assertThat(((ByteBuf) channel.readInbound()).toString(UTF_8)).isEqualTo(frame3); + assertThat(channel.writeInbound(Unpooled.wrappedBuffer(frame4.getBytes(UTF_8)))).isTrue(); + assertThat(((ByteBuf) channel.readInbound()).toString(UTF_8)).isEqualTo(frame4); + assertThat(channel.attr(REMOTE_ADDRESS_KEY).get()).isNull(); + assertThat(channel.pipeline().get(ProxyProtocolHandler.class)).isNull(); + assertThat(channel.isActive()).isTrue(); + } +} diff --git a/javatests/google/registry/proxy/handler/RelayHandlerTest.java b/javatests/google/registry/proxy/handler/RelayHandlerTest.java new file mode 100644 index 000000000..4f98bd05e --- /dev/null +++ b/javatests/google/registry/proxy/handler/RelayHandlerTest.java @@ -0,0 +1,91 @@ +// Copyright 2017 The Nomulus Authors. All Rights Reserved. +// +// 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. + +package google.registry.proxy.handler; + +import static com.google.common.truth.Truth.assertThat; +import static google.registry.proxy.handler.RelayHandler.RELAY_CHANNEL_KEY; + +import io.netty.channel.ChannelFuture; +import io.netty.channel.embedded.EmbeddedChannel; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +/** Unit tests for {@link RelayHandler}. */ +@RunWith(JUnit4.class) +public class RelayHandlerTest { + + private static final class ExpectedType {} + + private static final class OtherType {} + + private final RelayHandler relayHandler = new RelayHandler<>(ExpectedType.class); + private final EmbeddedChannel inboundChannel = new EmbeddedChannel(relayHandler); + private final EmbeddedChannel outboundChannel = new EmbeddedChannel(); + + @Before + public void setUp() { + inboundChannel.attr(RELAY_CHANNEL_KEY).set(outboundChannel); + } + + @Test + public void testSuccess_relayInboundMessageOfExpectedType() { + ExpectedType inboundMessage = new ExpectedType(); + // Relay handler intercepted the message, no further inbound message. + assertThat(inboundChannel.writeInbound(inboundMessage)).isFalse(); + // Message wrote to outbound channel as-is. + ExpectedType relayedMessage = outboundChannel.readOutbound(); + assertThat(relayedMessage).isEqualTo(inboundMessage); + } + + @Test + public void testSuccess_ignoreInboundMessageOfOtherType() { + OtherType inboundMessage = new OtherType(); + // Relay handler ignores inbound message of other types, the inbound message is passed along. + assertThat(inboundChannel.writeInbound(inboundMessage)).isTrue(); + // Nothing is written into the outbound channel. + ExpectedType relayedMessage = outboundChannel.readOutbound(); + assertThat(relayedMessage).isNull(); + } + + @Test + public void testSuccess_disconnectIfRelayIsUnsuccessful() { + ExpectedType inboundMessage = new ExpectedType(); + // Outbound channel is closed. + outboundChannel.finish(); + assertThat(inboundChannel.writeInbound(inboundMessage)).isFalse(); + ExpectedType relayedMessage = outboundChannel.readOutbound(); + assertThat(relayedMessage).isNull(); + // Inbound channel is closed as well. + assertThat(inboundChannel.isActive()).isFalse(); + } + + @Test + public void testSuccess_disconnectRelayChannelIfInactive() { + ChannelFuture unusedFuture = inboundChannel.close(); + assertThat(outboundChannel.isActive()).isFalse(); + } + + @Test + public void testSuccess_channelRead_relayNotSet() { + ExpectedType inboundMessage = new ExpectedType(); + inboundChannel.attr(RELAY_CHANNEL_KEY).set(null); + // Nothing to read. + assertThat(inboundChannel.writeInbound(inboundMessage)).isFalse(); + // Inbound channel is closed. + assertThat(inboundChannel.isActive()).isFalse(); + } +} diff --git a/javatests/google/registry/proxy/handler/SslClientInitializerTest.java b/javatests/google/registry/proxy/handler/SslClientInitializerTest.java new file mode 100644 index 000000000..dc286573f --- /dev/null +++ b/javatests/google/registry/proxy/handler/SslClientInitializerTest.java @@ -0,0 +1,288 @@ +// Copyright 2017 The Nomulus Authors. All Rights Reserved. +// +// 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. + +package google.registry.proxy.handler; + +import static com.google.common.truth.Truth.assertThat; +import static google.registry.proxy.Protocol.PROTOCOL_KEY; +import static google.registry.proxy.handler.SslInitializerTestUtils.getKeyPair; +import static google.registry.proxy.handler.SslInitializerTestUtils.setUpClient; +import static google.registry.proxy.handler.SslInitializerTestUtils.setUpServer; +import static google.registry.proxy.handler.SslInitializerTestUtils.signKeyPair; +import static google.registry.proxy.handler.SslInitializerTestUtils.verifySslChannel; + +import com.google.common.collect.ImmutableList; +import google.registry.proxy.Protocol; +import google.registry.proxy.Protocol.BackendProtocol; +import google.registry.proxy.handler.SslInitializerTestUtils.DumpHandler; +import google.registry.proxy.handler.SslInitializerTestUtils.EchoHandler; +import io.netty.buffer.ByteBuf; +import io.netty.buffer.Unpooled; +import io.netty.channel.Channel; +import io.netty.channel.ChannelHandler; +import io.netty.channel.ChannelInitializer; +import io.netty.channel.ChannelPipeline; +import io.netty.channel.EventLoopGroup; +import io.netty.channel.embedded.EmbeddedChannel; +import io.netty.channel.local.LocalAddress; +import io.netty.channel.local.LocalChannel; +import io.netty.handler.codec.DecoderException; +import io.netty.handler.ssl.SslContext; +import io.netty.handler.ssl.SslContextBuilder; +import io.netty.handler.ssl.SslHandler; +import io.netty.handler.ssl.SslProvider; +import io.netty.handler.ssl.util.SelfSignedCertificate; +import io.netty.util.concurrent.Future; +import java.security.KeyPair; +import java.security.PrivateKey; +import java.security.cert.CertificateException; +import java.security.cert.X509Certificate; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReentrantLock; +import javax.net.ssl.SSLException; +import javax.net.ssl.SSLHandshakeException; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +/** + * Unit tests for {@link SslClientInitializer}. + * + *

To validate that the handler accepts & rejects connections as expected, a test server and a + * test client are spun up, and both connect to the {@link LocalAddress} within the JVM. This avoids + * the overhead of routing traffic through the network layer, even if it were to go through + * loopback. It also alleviates the need to pick a free port to use. + * + *

The local addresses used in each test method must to be different, otherwise tests run in + * parallel may interfere with each other. + */ +@RunWith(JUnit4.class) +public class SslClientInitializerTest { + + /** Fake host to test if the SSL engine gets the correct peer host. */ + private static final String SSL_HOST = "www.example.tld"; + + /** Fake port to test if the SSL engine gets the correct peer port. */ + private static final int SSL_PORT = 12345; + + /** Fake protocol saved in channel attribute. */ + private static final BackendProtocol PROTOCOL = + Protocol.backendBuilder() + .name("ssl") + .host(SSL_HOST) + .port(SSL_PORT) + .handlerProviders(ImmutableList.of()) + .build(); + + private ChannelInitializer getServerInitializer( + PrivateKey privateKey, + X509Certificate certificate, + Lock serverLock, + Exception serverException) + throws Exception { + SslContext sslContext = SslContextBuilder.forServer(privateKey, certificate).build(); + return new ChannelInitializer() { + @Override + protected void initChannel(LocalChannel ch) throws Exception { + ch.pipeline() + .addLast( + sslContext.newHandler(ch.alloc()), new EchoHandler(serverLock, serverException)); + } + }; + } + + private ChannelInitializer getClientInitializer( + SslClientInitializer sslClientInitializer, + Lock clientLock, + ByteBuf buffer, + Exception clientException) { + return new ChannelInitializer() { + @Override + protected void initChannel(LocalChannel ch) throws Exception { + ch.pipeline() + .addLast(sslClientInitializer, new DumpHandler(clientLock, buffer, clientException)); + } + }; + } + + @Test + public void testSuccess_swappedInitializerWithSslHandler() throws Exception { + SslClientInitializer sslClientInitializer = + new SslClientInitializer<>(SslProvider.JDK, (X509Certificate[]) null); + EmbeddedChannel channel = new EmbeddedChannel(); + channel.attr(PROTOCOL_KEY).set(PROTOCOL); + ChannelPipeline pipeline = channel.pipeline(); + pipeline.addLast(sslClientInitializer); + ChannelHandler firstHandler = pipeline.first(); + assertThat(firstHandler.getClass()).isEqualTo(SslHandler.class); + SslHandler sslHandler = (SslHandler) firstHandler; + assertThat(sslHandler.engine().getPeerHost()).isEqualTo(SSL_HOST); + assertThat(sslHandler.engine().getPeerPort()).isEqualTo(SSL_PORT); + assertThat(channel.isActive()).isTrue(); + } + + @Test + public void testSuccess_protocolAttributeNotSet() { + SslClientInitializer sslClientInitializer = + new SslClientInitializer<>(SslProvider.JDK, (X509Certificate[]) null); + EmbeddedChannel channel = new EmbeddedChannel(); + ChannelPipeline pipeline = channel.pipeline(); + pipeline.addLast(sslClientInitializer); + // Channel initializer swallows error thrown, and closes the connection. + assertThat(channel.isActive()).isFalse(); + } + + @Test + public void testFailure_defaultTrustManager_rejectSelfSignedCert() throws Exception { + SelfSignedCertificate ssc = new SelfSignedCertificate(SSL_HOST); + LocalAddress localAddress = new LocalAddress("DEFAULT_TRUST_MANAGER_REJECT_SELF_SIGNED_CERT"); + Lock clientLock = new ReentrantLock(); + Lock serverLock = new ReentrantLock(); + ByteBuf buffer = Unpooled.buffer(); + Exception clientException = new Exception(); + Exception serverException = new Exception(); + EventLoopGroup eventLoopGroup = + setUpServer( + getServerInitializer(ssc.key(), ssc.cert(), serverLock, serverException), localAddress); + SslClientInitializer sslClientInitializer = + new SslClientInitializer<>(SslProvider.JDK, (X509Certificate[]) null); + Channel channel = + setUpClient( + eventLoopGroup, + getClientInitializer(sslClientInitializer, clientLock, buffer, clientException), + localAddress, + PROTOCOL); + // Wait for handshake exception to throw. + clientLock.lock(); + serverLock.lock(); + // The connection is now terminated, both the client side and the server side should get + // exceptions (caught in the caughtException method in EchoHandler and DumpHandler, + // respectively). + assertThat(clientException).hasCauseThat().isInstanceOf(DecoderException.class); + assertThat(clientException) + .hasCauseThat() + .hasCauseThat() + .isInstanceOf(SSLHandshakeException.class); + assertThat(serverException).hasCauseThat().isInstanceOf(DecoderException.class); + assertThat(serverException).hasCauseThat().hasCauseThat().isInstanceOf(SSLException.class); + assertThat(channel.isActive()).isFalse(); + + Future unusedFuture = eventLoopGroup.shutdownGracefully().syncUninterruptibly(); + } + + @Test + public void testSuccess_customTrustManager_acceptCertSignedByTrustedCa() throws Exception { + LocalAddress localAddress = + new LocalAddress("CUSTOM_TRUST_MANAGER_ACCEPT_CERT_SIGNED_BY_TRUSTED_CA"); + Lock clientLock = new ReentrantLock(); + Lock serverLock = new ReentrantLock(); + ByteBuf buffer = Unpooled.buffer(); + Exception clientException = new Exception(); + Exception serverException = new Exception(); + + // Generate a new key pair. + KeyPair keyPair = getKeyPair(); + + // Generate a self signed certificate, and use it to sign the key pair. + SelfSignedCertificate ssc = new SelfSignedCertificate(); + X509Certificate cert = signKeyPair(ssc, keyPair, SSL_HOST); + + // Set up the server to use the signed cert and private key to perform handshake; + PrivateKey privateKey = keyPair.getPrivate(); + EventLoopGroup eventLoopGroup = + setUpServer( + getServerInitializer(privateKey, cert, serverLock, serverException), localAddress); + + // Set up the client to trust the self signed cert used to sign the cert that server provides. + SslClientInitializer sslClientInitializer = + new SslClientInitializer<>(SslProvider.JDK, ssc.cert()); + Channel channel = + setUpClient( + eventLoopGroup, + getClientInitializer(sslClientInitializer, clientLock, buffer, clientException), + localAddress, + PROTOCOL); + + verifySslChannel(channel, ImmutableList.of(cert), clientLock, serverLock, buffer, SSL_HOST); + + Future unusedFuture = eventLoopGroup.shutdownGracefully().syncUninterruptibly(); + } + + @Test + public void testFailure_customTrustManager_wrongHostnameInCertificate() throws Exception { + LocalAddress localAddress = new LocalAddress("CUSTOM_TRUST_MANAGER_WRONG_HOSTNAME"); + Lock clientLock = new ReentrantLock(); + Lock serverLock = new ReentrantLock(); + ByteBuf buffer = Unpooled.buffer(); + Exception clientException = new Exception(); + Exception serverException = new Exception(); + + // Generate a new key pair. + KeyPair keyPair = getKeyPair(); + + // Generate a self signed certificate, and use it to sign the key pair. + SelfSignedCertificate ssc = new SelfSignedCertificate(); + X509Certificate cert = signKeyPair(ssc, keyPair, "wrong.com"); + + // Set up the server to use the signed cert and private key to perform handshake; + PrivateKey privateKey = keyPair.getPrivate(); + EventLoopGroup eventLoopGroup = + setUpServer( + getServerInitializer(privateKey, cert, serverLock, serverException), localAddress); + + // Set up the client to trust the self signed cert used to sign the cert that server provides. + SslClientInitializer sslClientInitializer = + new SslClientInitializer<>(SslProvider.JDK, ssc.cert()); + Channel channel = + setUpClient( + eventLoopGroup, + getClientInitializer(sslClientInitializer, clientLock, buffer, clientException), + localAddress, + PROTOCOL); + + serverLock.lock(); + clientLock.lock(); + + // When the client rejects the server cert due to wrong hostname, the client error is wrapped + // several layers in the exception. The server also throws an exception. + assertThat(clientException).hasCauseThat().isInstanceOf(DecoderException.class); + assertThat(clientException) + .hasCauseThat() + .hasCauseThat() + .isInstanceOf(SSLHandshakeException.class); + assertThat(clientException) + .hasCauseThat() + .hasCauseThat() + .hasCauseThat() + .isInstanceOf(SSLHandshakeException.class); + assertThat(clientException) + .hasCauseThat() + .hasCauseThat() + .hasCauseThat() + .hasCauseThat() + .isInstanceOf(CertificateException.class); + assertThat(clientException) + .hasCauseThat() + .hasCauseThat() + .hasCauseThat() + .hasCauseThat() + .hasMessageThat() + .contains(SSL_HOST); + assertThat(serverException).hasCauseThat().isInstanceOf(DecoderException.class); + assertThat(serverException).hasCauseThat().hasCauseThat().isInstanceOf(SSLException.class); + assertThat(channel.isActive()).isFalse(); + + Future unusedFuture = eventLoopGroup.shutdownGracefully().syncUninterruptibly(); + } +} diff --git a/javatests/google/registry/proxy/handler/SslInitializerTestUtils.java b/javatests/google/registry/proxy/handler/SslInitializerTestUtils.java new file mode 100644 index 000000000..5ddeedd86 --- /dev/null +++ b/javatests/google/registry/proxy/handler/SslInitializerTestUtils.java @@ -0,0 +1,277 @@ +// Copyright 2017 The Nomulus Authors. All Rights Reserved. +// +// 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. + +package google.registry.proxy.handler; + +import static com.google.common.truth.Truth.assertThat; +import static google.registry.proxy.Protocol.PROTOCOL_KEY; +import static java.nio.charset.StandardCharsets.UTF_8; + +import com.google.common.collect.ImmutableList; +import google.registry.proxy.Protocol.BackendProtocol; +import io.netty.bootstrap.Bootstrap; +import io.netty.bootstrap.ServerBootstrap; +import io.netty.buffer.ByteBuf; +import io.netty.buffer.Unpooled; +import io.netty.channel.Channel; +import io.netty.channel.ChannelFuture; +import io.netty.channel.ChannelHandlerContext; +import io.netty.channel.ChannelInboundHandlerAdapter; +import io.netty.channel.ChannelInitializer; +import io.netty.channel.EventLoopGroup; +import io.netty.channel.local.LocalAddress; +import io.netty.channel.local.LocalChannel; +import io.netty.channel.local.LocalServerChannel; +import io.netty.channel.nio.NioEventLoopGroup; +import io.netty.handler.ssl.SslHandler; +import io.netty.handler.ssl.util.SelfSignedCertificate; +import java.math.BigInteger; +import java.nio.charset.StandardCharsets; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.SecureRandom; +import java.security.Security; +import java.security.cert.X509Certificate; +import java.time.Duration; +import java.time.Instant; +import java.util.Date; +import java.util.concurrent.locks.Lock; +import javax.net.ssl.SSLSession; +import javax.security.auth.x500.X500Principal; +import org.bouncycastle.jce.provider.BouncyCastleProvider; +import org.bouncycastle.x509.X509V3CertificateGenerator; + +/** + * Utility class that provides methods used by {@link SslClientInitializerTest} and {@link + * SslServerInitializerTest}. + */ +public class SslInitializerTestUtils { + + static { + Security.addProvider(new BouncyCastleProvider()); + } + + /** + * Sets up a server channel bound to the given local address. + * + * @return the event loop group used to process incoming connections. + */ + static EventLoopGroup setUpServer( + ChannelInitializer serverInitializer, LocalAddress localAddress) + throws Exception { + // Only use one thread in the event loop group. The same event loop group will be used to + // register client channels during setUpClient as well. This ensures that all I/O activities + // in both channels happen in the same thread, making debugging easier (i. e. no need to jump + // between threads when debugging, everything happens synchronously within the only I/O + // effectively). Note that the main thread is still separate from the I/O thread and + // synchronization (using the lock field) is still needed when the main thread needs to verify + // properties calculated by the I/O thread. + EventLoopGroup eventLoopGroup = new NioEventLoopGroup(1); + ServerBootstrap sb = + new ServerBootstrap() + .group(eventLoopGroup) + .channel(LocalServerChannel.class) + .childHandler(serverInitializer); + ChannelFuture unusedFuture = sb.bind(localAddress).syncUninterruptibly(); + return eventLoopGroup; + } + + /** + * Sets up a client channel connecting to the give local address. + * + * @param eventLoopGroup the same {@link EventLoopGroup} that is used to bootstrap server. + * @return the connected client channel. + */ + static Channel setUpClient( + EventLoopGroup eventLoopGroup, + ChannelInitializer clientInitializer, + LocalAddress localAddress, + BackendProtocol protocol) + throws Exception { + Bootstrap b = + new Bootstrap() + .group(eventLoopGroup) + .channel(LocalChannel.class) + .handler(clientInitializer) + .attr(PROTOCOL_KEY, protocol); + return b.connect(localAddress).syncUninterruptibly().channel(); + } + + /** A handler that echoes back its inbound message. Used in test server. */ + static class EchoHandler extends ChannelInboundHandlerAdapter { + + /** + * A lock that synchronizes server I/O activity with the main thread. Acquired by the server I/O + * thread when the handler is constructed, released when the server echoes back, or when an + * exception is caught (during SSH handshake for example). + */ + private final Lock lock; + + /** + * Exception that would be initialized with the exception caught during SSL handshake. This + * field is constructed in the main thread and passed in the constructor. After a failure the + * main thread can inspect this object to assert the cause of the failure. + */ + private final Exception serverException; + + EchoHandler(Lock lock, Exception serverException) { + // This handler is constructed within getClientInitializer, which is called in the I/O thread. + // The server lock is therefore locked by the I/O thread. + lock.lock(); + this.lock = lock; + this.serverException = serverException; + } + + @Override + public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception { + // Always unlock regardless of whether the write is successful. + ctx.writeAndFlush(msg).addListener(future -> lock.unlock()); + } + + /** Saves any inbound error into the server exception field. */ + @Override + public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception { + serverException.initCause(cause); + // If an exception is caught, we should also release the lock so that the main thread knows + // there is an exception to inspect now. + lock.unlock(); + } + } + + /** A handler that dumps its inbound message in to {@link ByteBuf}. */ + static class DumpHandler extends ChannelInboundHandlerAdapter { + + /** + * A lock that synchronizes server I/O activity with the main thread. Acquired by the server I/O + * thread when the handler is constructed, released when the server echoes back, or when an + * exception is caught (during SSH handshake for example). + */ + private final Lock lock; + + /** + * A Buffer that is used to store incoming message. Constructed in the main thread and passed in + * the constructor. The main thread can inspect this object to assert that the incoming message + * is as expected. + */ + private final ByteBuf buffer; + + /** + * Exception that would be initialized with the exception caught during SSL handshake. This + * field is constructed in the main thread and passed in the constructor. After a failure the + * main thread can inspect this object to assert the cause of the failure. + */ + private final Exception clientException; + + DumpHandler(Lock lock, ByteBuf buffer, Exception clientException) { + super(); + // This handler is constructed within getClientInitializer, which is called in the I/O thread. + // The client lock is therefore locked by the I/O thread. + lock.lock(); + this.lock = lock; + this.buffer = buffer; + this.clientException = clientException; + } + + @Override + public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception { + buffer.writeBytes((ByteBuf) msg); + // If a message is received here, the main thread must be waiting to acquire the lock from + // the I/O thread in order to verify it. Releasing the lock to notify the main thread it can + // continue now that the message has been written. + lock.unlock(); + } + + /** Saves any inbound error into clientException. */ + @Override + public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception { + clientException.initCause(cause); + // If an exception is caught here, the main thread must be waiting to acquire the lock from + // the I/O thread in order to verify it. Releasing the lock to notify the main thread it can + // continue now that the exception has been written. + lock.unlock(); + } + } + + public static KeyPair getKeyPair() throws Exception { + KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA", "BC"); + keyPairGenerator.initialize(2048, new SecureRandom()); + return keyPairGenerator.generateKeyPair(); + } + + /** + * Signs the given key pair with the given self signed certificate. + * + * @return signed public key (of the key pair) certificate + */ + public static X509Certificate signKeyPair( + SelfSignedCertificate ssc, KeyPair keyPair, String hostname) throws Exception { + X509V3CertificateGenerator certGen = new X509V3CertificateGenerator(); + X500Principal dnName = new X500Principal("CN=" + hostname); + certGen.setSerialNumber(BigInteger.valueOf(System.currentTimeMillis())); + certGen.setSubjectDN(dnName); + certGen.setIssuerDN(ssc.cert().getSubjectX500Principal()); + certGen.setNotBefore(Date.from(Instant.now().minus(Duration.ofDays(1)))); + certGen.setNotAfter(Date.from(Instant.now().plus(Duration.ofDays(1)))); + certGen.setPublicKey(keyPair.getPublic()); + certGen.setSignatureAlgorithm("SHA256WithRSAEncryption"); + return certGen.generate(ssc.key(), "BC"); + } + + /** + * Verifies tha the SSL channel is established as expected, and also sends a message to the server + * and verifies if it is echoed back correctly. + * + * @param certs The certificate that the server should provide. + * @return The SSL session in current channel, can be used for further validation. + */ + static SSLSession verifySslChannel( + Channel channel, + ImmutableList certs, + Lock clientLock, + Lock serverLock, + ByteBuf buffer, + String sniHostname) + throws Exception { + SslHandler sslHandler = channel.pipeline().get(SslHandler.class); + // Wait till the handshake is complete. + sslHandler.handshakeFuture().get(); + + assertThat(channel.isActive()).isTrue(); + assertThat(sslHandler.handshakeFuture().isSuccess()).isTrue(); + assertThat(sslHandler.engine().getSession().isValid()).isTrue(); + assertThat(sslHandler.engine().getSession().getPeerCertificates()) + .asList() + .containsExactly(certs.toArray()); + // Verify that the client sent expected SNI name during handshake. + assertThat(sslHandler.engine().getSSLParameters().getServerNames()).hasSize(1); + assertThat(sslHandler.engine().getSSLParameters().getServerNames().get(0).getEncoded()) + .isEqualTo(sniHostname.getBytes(UTF_8)); + + // Test that message can go through, bound inbound and outbound. + String inputString = "Hello, world!"; + // The client writes the message to the server, which echos it back. The client receives the + // echo and writes to BUFFER. All these activities happens in the I/O thread, and this call + // returns immediately. + ChannelFuture unusedFuture = + channel.writeAndFlush( + Unpooled.wrappedBuffer(inputString.getBytes(StandardCharsets.US_ASCII))); + // The lock is acquired by the I/O thread when the client's DumpHandler is constructed. + // Attempting to acquire it here blocks the main thread, until the I/O thread releases the lock + // after the DumpHandler writes the echo back to the buffer. + clientLock.lock(); + serverLock.lock(); + assertThat(buffer.toString(StandardCharsets.US_ASCII)).isEqualTo(inputString); + return sslHandler.engine().getSession(); + } +} diff --git a/javatests/google/registry/proxy/handler/SslServerInitializerTest.java b/javatests/google/registry/proxy/handler/SslServerInitializerTest.java new file mode 100644 index 000000000..718dbe3aa --- /dev/null +++ b/javatests/google/registry/proxy/handler/SslServerInitializerTest.java @@ -0,0 +1,347 @@ +// Copyright 2017 The Nomulus Authors. All Rights Reserved. +// +// 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. + +package google.registry.proxy.handler; + +import static com.google.common.truth.Truth.assertThat; +import static google.registry.proxy.handler.SslInitializerTestUtils.getKeyPair; +import static google.registry.proxy.handler.SslInitializerTestUtils.setUpClient; +import static google.registry.proxy.handler.SslInitializerTestUtils.setUpServer; +import static google.registry.proxy.handler.SslInitializerTestUtils.signKeyPair; +import static google.registry.proxy.handler.SslInitializerTestUtils.verifySslChannel; + +import com.google.common.collect.ImmutableList; +import google.registry.proxy.Protocol; +import google.registry.proxy.Protocol.BackendProtocol; +import google.registry.proxy.handler.SslInitializerTestUtils.DumpHandler; +import google.registry.proxy.handler.SslInitializerTestUtils.EchoHandler; +import io.netty.buffer.ByteBuf; +import io.netty.buffer.Unpooled; +import io.netty.channel.Channel; +import io.netty.channel.ChannelHandler; +import io.netty.channel.ChannelInitializer; +import io.netty.channel.ChannelPipeline; +import io.netty.channel.EventLoopGroup; +import io.netty.channel.embedded.EmbeddedChannel; +import io.netty.channel.local.LocalAddress; +import io.netty.channel.local.LocalChannel; +import io.netty.handler.codec.DecoderException; +import io.netty.handler.ssl.SslContextBuilder; +import io.netty.handler.ssl.SslHandler; +import io.netty.handler.ssl.SslProvider; +import io.netty.handler.ssl.util.SelfSignedCertificate; +import io.netty.util.concurrent.Future; +import java.security.KeyPair; +import java.security.PrivateKey; +import java.security.cert.CertificateException; +import java.security.cert.X509Certificate; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReentrantLock; +import javax.net.ssl.SSLEngine; +import javax.net.ssl.SSLException; +import javax.net.ssl.SSLHandshakeException; +import javax.net.ssl.SSLParameters; +import javax.net.ssl.SSLSession; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +/** + * Unit tests for {@link SslServerInitializer}. + * + *

To validate that the handler accepts & rejects connections as expected, a test server and a + * test client are spun up, and both connect to the {@link LocalAddress} within the JVM. This avoids + * the overhead of routing traffic through the network layer, even if it were to go through + * loopback. It also alleviates the need to pick a free port to use. + * + *

The local addresses used in each test method must to be different, otherwise tests run in + * parallel may interfere with each other. + */ +@RunWith(JUnit4.class) +public class SslServerInitializerTest { + + /** Fake host to test if the SSL engine gets the correct peer host. */ + private static final String SSL_HOST = "www.example.tld"; + + /** Fake port to test if the SSL engine gets the correct peer port. */ + private static final int SSL_PORT = 12345; + + /** Fake protocol saved in channel attribute. */ + private static final BackendProtocol PROTOCOL = + Protocol.backendBuilder() + .name("ssl") + .host(SSL_HOST) + .port(SSL_PORT) + .handlerProviders(ImmutableList.of()) + .build(); + + private ChannelInitializer getServerInitializer( + Lock serverLock, + Exception serverException, + PrivateKey privateKey, + X509Certificate... certificates) + throws Exception { + return new ChannelInitializer() { + @Override + protected void initChannel(LocalChannel ch) throws Exception { + ch.pipeline() + .addLast( + new SslServerInitializer(SslProvider.JDK, privateKey, certificates), + new EchoHandler(serverLock, serverException)); + } + }; + } + + private ChannelInitializer getClientInitializer( + X509Certificate trustedCertificate, + PrivateKey privateKey, + X509Certificate certificate, + Lock clientLock, + ByteBuf buffer, + Exception clientException) { + return new ChannelInitializer() { + @Override + protected void initChannel(LocalChannel ch) throws Exception { + SslContextBuilder sslContextBuilder = + SslContextBuilder.forClient().trustManager(trustedCertificate); + if (privateKey != null && certificate != null) { + sslContextBuilder.keyManager(privateKey, certificate); + } + SslHandler sslHandler = + sslContextBuilder.build().newHandler(ch.alloc(), SSL_HOST, SSL_PORT); + + // Enable hostname verification. + SSLEngine sslEngine = sslHandler.engine(); + SSLParameters sslParameters = sslEngine.getSSLParameters(); + sslParameters.setEndpointIdentificationAlgorithm("HTTPS"); + sslEngine.setSSLParameters(sslParameters); + + ch.pipeline().addLast("Client SSL Handler", sslHandler); + ch.pipeline().addLast(new DumpHandler(clientLock, buffer, clientException)); + } + }; + } + + @Test + public void testSuccess_swappedInitializerWithSslHandler() throws Exception { + SelfSignedCertificate ssc = new SelfSignedCertificate(SSL_HOST); + SslServerInitializer sslServerInitializer = + new SslServerInitializer<>(SslProvider.JDK, ssc.key(), ssc.cert()); + EmbeddedChannel channel = new EmbeddedChannel(); + ChannelPipeline pipeline = channel.pipeline(); + pipeline.addLast(sslServerInitializer); + ChannelHandler firstHandler = pipeline.first(); + assertThat(firstHandler.getClass()).isEqualTo(SslHandler.class); + SslHandler sslHandler = (SslHandler) firstHandler; + assertThat(sslHandler.engine().getNeedClientAuth()).isTrue(); + assertThat(channel.isActive()).isTrue(); + } + + @Test + public void testSuccess_trustAnyClientCert() throws Exception { + SelfSignedCertificate serverSsc = new SelfSignedCertificate(SSL_HOST); + LocalAddress localAddress = new LocalAddress("TRUST_ANY_CLIENT_CERT"); + Lock clientLock = new ReentrantLock(); + Lock serverLock = new ReentrantLock(); + ByteBuf buffer = Unpooled.buffer(); + Exception clientException = new Exception(); + Exception serverException = new Exception(); + EventLoopGroup eventLoopGroup = + setUpServer( + getServerInitializer(serverLock, serverException, serverSsc.key(), serverSsc.cert()), + localAddress); + SelfSignedCertificate clientSsc = new SelfSignedCertificate(); + Channel channel = + setUpClient( + eventLoopGroup, + getClientInitializer( + serverSsc.cert(), + clientSsc.key(), + clientSsc.cert(), + clientLock, + buffer, + clientException), + localAddress, + PROTOCOL); + + SSLSession sslSession = + verifySslChannel( + channel, ImmutableList.of(serverSsc.cert()), clientLock, serverLock, buffer, SSL_HOST); + // Verify that the SSL session gets the client cert. Note that this SslSession is for the client + // channel, therefore its local certificates are the remote certificates of the SslSession for + // the server channel, and vice versa. + assertThat(sslSession.getLocalCertificates()).asList().containsExactly(clientSsc.cert()); + assertThat(sslSession.getPeerCertificates()).asList().containsExactly(serverSsc.cert()); + + Future unusedFuture = eventLoopGroup.shutdownGracefully().syncUninterruptibly(); + } + + @Test + public void testSuccess_CertSignedByOtherCA() throws Exception { + // The self-signed cert of the CA. + SelfSignedCertificate caSsc = new SelfSignedCertificate(); + KeyPair keyPair = getKeyPair(); + X509Certificate serverCert = signKeyPair(caSsc, keyPair, SSL_HOST); + LocalAddress localAddress = new LocalAddress("CERT_SIGNED_BY_OTHER_CA"); + Lock clientLock = new ReentrantLock(); + Lock serverLock = new ReentrantLock(); + ByteBuf buffer = Unpooled.buffer(); + Exception clientException = new Exception(); + Exception serverException = new Exception(); + EventLoopGroup eventLoopGroup = + setUpServer( + getServerInitializer( + serverLock, + serverException, + keyPair.getPrivate(), + // Serving both the server cert, and the CA cert + serverCert, + caSsc.cert()), + localAddress); + SelfSignedCertificate clientSsc = new SelfSignedCertificate(); + Channel channel = + setUpClient( + eventLoopGroup, + getClientInitializer( + // Client trusts the CA cert + caSsc.cert(), + clientSsc.key(), + clientSsc.cert(), + clientLock, + buffer, + clientException), + localAddress, + PROTOCOL); + + SSLSession sslSession = + verifySslChannel( + channel, + ImmutableList.of(serverCert, caSsc.cert()), + clientLock, + serverLock, + buffer, + SSL_HOST); + + assertThat(sslSession.getLocalCertificates()).asList().containsExactly(clientSsc.cert()); + assertThat(sslSession.getPeerCertificates()) + .asList() + .containsExactly(serverCert, caSsc.cert()) + .inOrder(); + + Future unusedFuture = eventLoopGroup.shutdownGracefully().syncUninterruptibly(); + } + + @Test + public void testFailure_requireClientCertificate() throws Exception { + SelfSignedCertificate serverSsc = new SelfSignedCertificate(SSL_HOST); + LocalAddress localAddress = new LocalAddress("REQUIRE_CLIENT_CERT"); + Lock clientLock = new ReentrantLock(); + Lock serverLock = new ReentrantLock(); + ByteBuf buffer = Unpooled.buffer(); + Exception clientException = new Exception(); + Exception serverException = new Exception(); + EventLoopGroup eventLoopGroup = + setUpServer( + getServerInitializer(serverLock, serverException, serverSsc.key(), serverSsc.cert()), + localAddress); + Channel channel = + setUpClient( + eventLoopGroup, + getClientInitializer( + serverSsc.cert(), + // No client cert/private key used. + null, + null, + clientLock, + buffer, + clientException), + localAddress, + PROTOCOL); + + serverLock.lock(); + + // When the server rejects the client during handshake due to lack of client certificate, only + // the server throws an exception. + assertThat(serverException).hasCauseThat().isInstanceOf(DecoderException.class); + assertThat(serverException) + .hasCauseThat() + .hasCauseThat() + .isInstanceOf(SSLHandshakeException.class); + assertThat(channel.isActive()).isFalse(); + + Future unusedFuture = eventLoopGroup.shutdownGracefully().syncUninterruptibly(); + } + + @Test + public void testFailure_wrongHostnameInCertificate() throws Exception { + SelfSignedCertificate serverSsc = new SelfSignedCertificate("wrong.com"); + LocalAddress localAddress = new LocalAddress("REQUIRE_CLIENT_CERT"); + Lock clientLock = new ReentrantLock(); + Lock serverLock = new ReentrantLock(); + ByteBuf buffer = Unpooled.buffer(); + Exception clientException = new Exception(); + Exception serverException = new Exception(); + EventLoopGroup eventLoopGroup = + setUpServer( + getServerInitializer(serverLock, serverException, serverSsc.key(), serverSsc.cert()), + localAddress); + SelfSignedCertificate clientSsc = new SelfSignedCertificate(); + Channel channel = + setUpClient( + eventLoopGroup, + getClientInitializer( + serverSsc.cert(), + clientSsc.key(), + clientSsc.cert(), + clientLock, + buffer, + clientException), + localAddress, + PROTOCOL); + + serverLock.lock(); + clientLock.lock(); + + // When the client rejects the server cert due to wrong hostname, the client error is wrapped + // several layers in the exception. The server also throws an exception. + assertThat(clientException).hasCauseThat().isInstanceOf(DecoderException.class); + assertThat(clientException) + .hasCauseThat() + .hasCauseThat() + .isInstanceOf(SSLHandshakeException.class); + assertThat(clientException) + .hasCauseThat() + .hasCauseThat() + .hasCauseThat() + .isInstanceOf(SSLHandshakeException.class); + assertThat(clientException) + .hasCauseThat() + .hasCauseThat() + .hasCauseThat() + .hasCauseThat() + .isInstanceOf(CertificateException.class); + assertThat(clientException) + .hasCauseThat() + .hasCauseThat() + .hasCauseThat() + .hasCauseThat() + .hasMessageThat() + .contains(SSL_HOST); + assertThat(serverException).hasCauseThat().isInstanceOf(DecoderException.class); + assertThat(serverException).hasCauseThat().hasCauseThat().isInstanceOf(SSLException.class); + assertThat(channel.isActive()).isFalse(); + + Future unusedFuture = eventLoopGroup.shutdownGracefully().syncUninterruptibly(); + } +} diff --git a/javatests/google/registry/proxy/handler/WhoisServiceHandlerTest.java b/javatests/google/registry/proxy/handler/WhoisServiceHandlerTest.java new file mode 100644 index 000000000..92567e69e --- /dev/null +++ b/javatests/google/registry/proxy/handler/WhoisServiceHandlerTest.java @@ -0,0 +1,128 @@ +// Copyright 2017 The Nomulus Authors. All Rights Reserved. +// +// 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. + +package google.registry.proxy.handler; + +import static com.google.common.truth.Truth.assertThat; +import static google.registry.proxy.TestUtils.makeWhoisHttpRequest; +import static google.registry.proxy.TestUtils.makeWhoisHttpResponse; +import static java.nio.charset.StandardCharsets.US_ASCII; +import static org.junit.Assert.fail; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; + +import google.registry.proxy.metric.FrontendMetrics; +import io.netty.buffer.ByteBuf; +import io.netty.buffer.Unpooled; +import io.netty.channel.DefaultChannelId; +import io.netty.channel.embedded.EmbeddedChannel; +import io.netty.handler.codec.http.FullHttpRequest; +import io.netty.handler.codec.http.FullHttpResponse; +import io.netty.handler.codec.http.HttpResponseStatus; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +/** Unit tests for {@link WhoisServiceHandler}. */ +@RunWith(JUnit4.class) +public class WhoisServiceHandlerTest { + + private static final String RELAY_HOST = "www.example.tld"; + private static final String RELAY_PATH = "/test"; + private static final String QUERY_CONTENT = "test.tld"; + private static final String ACCESS_TOKEN = "this.access.token"; + private static final String PROTOCOL = "whois"; + private static final String CLIENT_HASH = "none"; + + private final FrontendMetrics metrics = mock(FrontendMetrics.class); + + private final WhoisServiceHandler whoisServiceHandler = + new WhoisServiceHandler(RELAY_HOST, RELAY_PATH, () -> ACCESS_TOKEN, metrics); + private EmbeddedChannel channel; + + @Before + public void setUp() { + // Need to reset metrics for each test method, since they are static fields on the class and + // shared between each run. + channel = new EmbeddedChannel(whoisServiceHandler); + } + + @Test + public void testSuccess_connectionMetrics_oneChannel() { + assertThat(channel.isActive()).isTrue(); + verify(metrics).registerActiveConnection(PROTOCOL, CLIENT_HASH, channel); + verifyNoMoreInteractions(metrics); + } + + @Test + public void testSuccess_ConnectionMetrics_twoConnections() { + assertThat(channel.isActive()).isTrue(); + verify(metrics).registerActiveConnection(PROTOCOL, CLIENT_HASH, channel); + + // Setup second channel. + WhoisServiceHandler whoisServiceHandler2 = + new WhoisServiceHandler(RELAY_HOST, RELAY_PATH, () -> ACCESS_TOKEN, metrics); + EmbeddedChannel channel2 = + // We need a new channel id so that it has a different hash code. + // This only is needed for EmbeddedChannel because it has a dummy hash code implementation. + new EmbeddedChannel(DefaultChannelId.newInstance(), whoisServiceHandler2); + assertThat(channel2.isActive()).isTrue(); + verify(metrics).registerActiveConnection(PROTOCOL, CLIENT_HASH, channel2); + verifyNoMoreInteractions(metrics); + } + + @Test + public void testSuccess_fireInboundHttpRequest() { + ByteBuf inputBuffer = Unpooled.wrappedBuffer(QUERY_CONTENT.getBytes(US_ASCII)); + FullHttpRequest expectedRequest = + makeWhoisHttpRequest(QUERY_CONTENT, RELAY_HOST, RELAY_PATH, ACCESS_TOKEN); + // Input data passed to next handler + assertThat(channel.writeInbound(inputBuffer)).isTrue(); + FullHttpRequest inputRequest = channel.readInbound(); + assertThat(inputRequest).isEqualTo(expectedRequest); + // The channel is still open, and nothing else is to be read from it. + assertThat((Object) channel.readInbound()).isNull(); + assertThat(channel.isActive()).isTrue(); + } + + @Test + public void testSuccess_parseOutboundHttpResponse() { + String outputString = "line1\r\nline2\r\n"; + FullHttpResponse outputResponse = makeWhoisHttpResponse(outputString, HttpResponseStatus.OK); + // output data passed to next handler + assertThat(channel.writeOutbound(outputResponse)).isTrue(); + ByteBuf parsedBuffer = channel.readOutbound(); + assertThat(parsedBuffer.toString(US_ASCII)).isEqualTo(outputString); + // The channel is still open, and nothing else is to be written to it. + assertThat((Object) channel.readOutbound()).isNull(); + assertThat(channel.isActive()).isTrue(); + } + + @Test + public void testFailure_OutboundHttpResponseNotOK() { + String outputString = "line1\r\nline2\r\n"; + FullHttpResponse outputResponse = + makeWhoisHttpResponse(outputString, HttpResponseStatus.BAD_REQUEST); + try { + channel.writeOutbound(outputResponse); + fail("Expected failure due to non-OK HTTP response status."); + } catch (Exception e) { + assertThat(e).hasCauseThat().isInstanceOf(IllegalArgumentException.class); + assertThat(e).hasMessageThat().contains("400 Bad Request"); + } + assertThat(channel.isActive()).isFalse(); + } +} diff --git a/javatests/google/registry/proxy/metric/BackendMetricsTest.java b/javatests/google/registry/proxy/metric/BackendMetricsTest.java new file mode 100644 index 000000000..83f7c6a93 --- /dev/null +++ b/javatests/google/registry/proxy/metric/BackendMetricsTest.java @@ -0,0 +1,173 @@ +// Copyright 2017 The Nomulus Authors. All Rights Reserved. +// +// 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. + +package google.registry.proxy.metric; + +import static google.registry.monitoring.metrics.contrib.DistributionMetricSubject.assertThat; +import static google.registry.monitoring.metrics.contrib.LongMetricSubject.assertThat; +import static google.registry.proxy.TestUtils.makeHttpPostRequest; +import static google.registry.proxy.TestUtils.makeHttpResponse; + +import com.google.common.collect.ImmutableSet; +import io.netty.handler.codec.http.FullHttpRequest; +import io.netty.handler.codec.http.FullHttpResponse; +import io.netty.handler.codec.http.HttpResponseStatus; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +/** Unit tests for {@link BackendMetrics}. */ +@RunWith(JUnit4.class) +public class BackendMetricsTest { + + private final String host = "host.tld"; + private final String certHash = "blah12345"; + private final String protocol = "frontend protocol"; + + private final BackendMetrics metrics = new BackendMetrics(); + + @Before + public void setUp() { + metrics.resetMetric(); + } + + @Test + public void testSuccess_oneRequest() { + String content = "some content"; + FullHttpRequest request = makeHttpPostRequest(content, host, "/"); + metrics.requestSent(protocol, certHash, request); + + assertThat(BackendMetrics.requestsCounter) + .hasValueForLabels(1, protocol, certHash) + .and() + .hasNoOtherValues(); + assertThat(BackendMetrics.requestBytes) + .hasDataSetForLabels(ImmutableSet.of(content.length()), protocol, certHash) + .and() + .hasNoOtherValues(); + assertThat(BackendMetrics.responsesCounter).hasNoOtherValues(); + assertThat(BackendMetrics.responseBytes).hasNoOtherValues(); + assertThat(BackendMetrics.latencyMs).hasNoOtherValues(); + } + + @Test + public void testSuccess_multipleRequests() { + String content1 = "some content"; + String content2 = "some other content"; + FullHttpRequest request1 = makeHttpPostRequest(content1, host, "/"); + FullHttpRequest request2 = makeHttpPostRequest(content2, host, "/"); + metrics.requestSent(protocol, certHash, request1); + metrics.requestSent(protocol, certHash, request2); + + assertThat(BackendMetrics.requestsCounter) + .hasValueForLabels(2, protocol, certHash) + .and() + .hasNoOtherValues(); + assertThat(BackendMetrics.requestBytes) + .hasDataSetForLabels( + ImmutableSet.of(content1.length(), content2.length()), protocol, certHash) + .and() + .hasNoOtherValues(); + assertThat(BackendMetrics.responsesCounter).hasNoOtherValues(); + assertThat(BackendMetrics.responseBytes).hasNoOtherValues(); + assertThat(BackendMetrics.latencyMs).hasNoOtherValues(); + } + + @Test + public void testSuccess_oneResponse() { + String content = "some response"; + FullHttpResponse response = makeHttpResponse(content, HttpResponseStatus.OK); + metrics.responseReceived(protocol, certHash, response, 5); + + assertThat(BackendMetrics.requestsCounter).hasNoOtherValues(); + assertThat(BackendMetrics.requestBytes).hasNoOtherValues(); + assertThat(BackendMetrics.responsesCounter) + .hasValueForLabels(1, protocol, certHash, "200 OK") + .and() + .hasNoOtherValues(); + assertThat(BackendMetrics.responseBytes) + .hasDataSetForLabels(ImmutableSet.of(content.length()), protocol, certHash) + .and() + .hasNoOtherValues(); + assertThat(BackendMetrics.latencyMs) + .hasDataSetForLabels(ImmutableSet.of(5), protocol, certHash) + .and() + .hasNoOtherValues(); + } + + @Test + public void testSuccess_multipleResponses() { + String content1 = "some response"; + String content2 = "other response"; + String content3 = "a very bad response"; + FullHttpResponse response1 = makeHttpResponse(content1, HttpResponseStatus.OK); + FullHttpResponse response2 = makeHttpResponse(content2, HttpResponseStatus.OK); + FullHttpResponse response3 = makeHttpResponse(content3, HttpResponseStatus.BAD_REQUEST); + metrics.responseReceived(protocol, certHash, response1, 5); + metrics.responseReceived(protocol, certHash, response2, 8); + metrics.responseReceived(protocol, certHash, response3, 2); + + assertThat(BackendMetrics.requestsCounter).hasNoOtherValues(); + assertThat(BackendMetrics.requestBytes).hasNoOtherValues(); + assertThat(BackendMetrics.responsesCounter) + .hasValueForLabels(2, protocol, certHash, "200 OK") + .and() + .hasValueForLabels(1, protocol, certHash, "400 Bad Request") + .and() + .hasNoOtherValues(); + assertThat(BackendMetrics.responseBytes) + .hasDataSetForLabels( + ImmutableSet.of(content1.length(), content2.length(), content3.length()), + protocol, + certHash) + .and() + .hasNoOtherValues(); + assertThat(BackendMetrics.latencyMs) + .hasDataSetForLabels(ImmutableSet.of(5, 8, 2), protocol, certHash) + .and() + .hasNoOtherValues(); + } + + @Test + public void testSuccess_oneRequest_oneResponse() { + String requestContent = "some request"; + String responseContent = "the only response"; + FullHttpRequest request = makeHttpPostRequest(requestContent, host, "/"); + FullHttpResponse response = makeHttpResponse(responseContent, HttpResponseStatus.OK); + metrics.requestSent(protocol, certHash, request); + metrics.responseReceived(protocol, certHash, response, 10); + + assertThat(BackendMetrics.requestsCounter) + .hasValueForLabels(1, protocol, certHash) + .and() + .hasNoOtherValues(); + assertThat(BackendMetrics.responsesCounter) + .hasValueForLabels(1, protocol, certHash, "200 OK") + .and() + .hasNoOtherValues(); + assertThat(BackendMetrics.requestBytes) + .hasDataSetForLabels(ImmutableSet.of(requestContent.length()), protocol, certHash) + .and() + .hasNoOtherValues(); + assertThat(BackendMetrics.responseBytes) + .hasDataSetForLabels(ImmutableSet.of(responseContent.length()), protocol, certHash) + .and() + .hasNoOtherValues(); + assertThat(BackendMetrics.latencyMs) + .hasDataSetForLabels(ImmutableSet.of(10), protocol, certHash) + .and() + .hasNoOtherValues(); + } +} diff --git a/javatests/google/registry/proxy/metric/FrontendMetricsTest.java b/javatests/google/registry/proxy/metric/FrontendMetricsTest.java new file mode 100644 index 000000000..1b1849364 --- /dev/null +++ b/javatests/google/registry/proxy/metric/FrontendMetricsTest.java @@ -0,0 +1,166 @@ +// Copyright 2017 The Nomulus Authors. All Rights Reserved. +// +// 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. + +package google.registry.proxy.metric; + +import static com.google.common.truth.Truth.assertThat; +import static google.registry.monitoring.metrics.contrib.LongMetricSubject.assertThat; + +import io.netty.channel.ChannelFuture; +import io.netty.channel.DefaultChannelId; +import io.netty.channel.embedded.EmbeddedChannel; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +/** Unit tests for {@link FrontendMetrics}. */ +@RunWith(JUnit4.class) +public class FrontendMetricsTest { + + private static final String PROTOCOL = "some protocol"; + private static final String CERT_HASH = "abc_blah_1134zdf"; + private final FrontendMetrics metrics = new FrontendMetrics(); + + @Before + public void setUp() { + metrics.resetMetrics(); + } + + @Test + public void testSuccess_oneConnection() { + EmbeddedChannel channel = new EmbeddedChannel(); + metrics.registerActiveConnection(PROTOCOL, CERT_HASH, channel); + assertThat(channel.isActive()).isTrue(); + assertThat(FrontendMetrics.activeConnectionsGauge) + .hasValueForLabels(1, PROTOCOL, CERT_HASH) + .and() + .hasNoOtherValues(); + assertThat(FrontendMetrics.totalConnectionsCounter) + .hasValueForLabels(1, PROTOCOL, CERT_HASH) + .and() + .hasNoOtherValues(); + + ChannelFuture unusedFuture = channel.close(); + assertThat(channel.isActive()).isFalse(); + assertThat(FrontendMetrics.activeConnectionsGauge).hasNoOtherValues(); + assertThat(FrontendMetrics.totalConnectionsCounter) + .hasValueForLabels(1, PROTOCOL, CERT_HASH) + .and() + .hasNoOtherValues(); + } + + @Test + public void testSuccess_twoConnections_sameClient() { + EmbeddedChannel channel1 = new EmbeddedChannel(); + EmbeddedChannel channel2 = new EmbeddedChannel(DefaultChannelId.newInstance()); + + metrics.registerActiveConnection(PROTOCOL, CERT_HASH, channel1); + assertThat(channel1.isActive()).isTrue(); + assertThat(FrontendMetrics.activeConnectionsGauge) + .hasValueForLabels(1, PROTOCOL, CERT_HASH) + .and() + .hasNoOtherValues(); + assertThat(FrontendMetrics.totalConnectionsCounter) + .hasValueForLabels(1, PROTOCOL, CERT_HASH) + .and() + .hasNoOtherValues(); + + metrics.registerActiveConnection(PROTOCOL, CERT_HASH, channel2); + assertThat(channel2.isActive()).isTrue(); + assertThat(FrontendMetrics.activeConnectionsGauge) + .hasValueForLabels(2, PROTOCOL, CERT_HASH) + .and() + .hasNoOtherValues(); + assertThat(FrontendMetrics.totalConnectionsCounter) + .hasValueForLabels(2, PROTOCOL, CERT_HASH) + .and() + .hasNoOtherValues(); + + ChannelFuture unusedFuture = channel1.close(); + assertThat(channel1.isActive()).isFalse(); + assertThat(FrontendMetrics.activeConnectionsGauge) + .hasValueForLabels(1, PROTOCOL, CERT_HASH) + .and() + .hasNoOtherValues(); + assertThat(FrontendMetrics.totalConnectionsCounter) + .hasValueForLabels(2, PROTOCOL, CERT_HASH) + .and() + .hasNoOtherValues(); + + unusedFuture = channel2.close(); + assertThat(channel2.isActive()).isFalse(); + assertThat(FrontendMetrics.activeConnectionsGauge).hasNoOtherValues(); + assertThat(FrontendMetrics.totalConnectionsCounter) + .hasValueForLabels(2, PROTOCOL, CERT_HASH) + .and() + .hasNoOtherValues(); + } + + @Test + public void testSuccess_twoConnections_differentClients() { + EmbeddedChannel channel1 = new EmbeddedChannel(); + EmbeddedChannel channel2 = new EmbeddedChannel(DefaultChannelId.newInstance()); + String certHash2 = "blahblah_lol_234"; + + metrics.registerActiveConnection(PROTOCOL, CERT_HASH, channel1); + assertThat(channel1.isActive()).isTrue(); + assertThat(FrontendMetrics.activeConnectionsGauge) + .hasValueForLabels(1, PROTOCOL, CERT_HASH) + .and() + .hasNoOtherValues(); + assertThat(FrontendMetrics.totalConnectionsCounter) + .hasValueForLabels(1, PROTOCOL, CERT_HASH) + .and() + .hasNoOtherValues(); + + metrics.registerActiveConnection(PROTOCOL, certHash2, channel2); + assertThat(channel2.isActive()).isTrue(); + assertThat(FrontendMetrics.activeConnectionsGauge) + .hasValueForLabels(1, PROTOCOL, CERT_HASH) + .and() + .hasValueForLabels(1, PROTOCOL, certHash2) + .and() + .hasNoOtherValues(); + assertThat(FrontendMetrics.totalConnectionsCounter) + .hasValueForLabels(1, PROTOCOL, CERT_HASH) + .and() + .hasValueForLabels(1, PROTOCOL, certHash2) + .and() + .hasNoOtherValues(); + + ChannelFuture unusedFuture = channel1.close(); + assertThat(channel1.isActive()).isFalse(); + assertThat(FrontendMetrics.activeConnectionsGauge) + .hasValueForLabels(1, PROTOCOL, certHash2) + .and() + .hasNoOtherValues(); + assertThat(FrontendMetrics.totalConnectionsCounter) + .hasValueForLabels(1, PROTOCOL, CERT_HASH) + .and() + .hasValueForLabels(1, PROTOCOL, certHash2) + .and() + .hasNoOtherValues(); + + unusedFuture = channel2.close(); + assertThat(channel2.isActive()).isFalse(); + assertThat(FrontendMetrics.activeConnectionsGauge).hasNoOtherValues(); + assertThat(FrontendMetrics.totalConnectionsCounter) + .hasValueForLabels(1, PROTOCOL, CERT_HASH) + .and() + .hasValueForLabels(1, PROTOCOL, certHash2) + .and() + .hasNoOtherValues(); + } +} diff --git a/javatests/google/registry/proxy/testdata/login.xml b/javatests/google/registry/proxy/testdata/login.xml new file mode 100644 index 000000000..c1b709a98 --- /dev/null +++ b/javatests/google/registry/proxy/testdata/login.xml @@ -0,0 +1,23 @@ + + + + + ClientX + foo-BAR2 + bar-FOO2 + + 1.0 + en + + + urn:ietf:params:xml:ns:obj1 + urn:ietf:params:xml:ns:obj2 + urn:ietf:params:xml:ns:obj3 + + http://custom/obj1ext-1.0 + + + + ABC-12345 + + diff --git a/javatests/google/registry/proxy/testdata/login_response.xml b/javatests/google/registry/proxy/testdata/login_response.xml new file mode 100644 index 000000000..6ace7df28 --- /dev/null +++ b/javatests/google/registry/proxy/testdata/login_response.xml @@ -0,0 +1,12 @@ + + + + + Command completed successfully + + + proxy-login + inlxipwsQKaXS3VmbKOmBA==-a + + + diff --git a/javatests/google/registry/proxy/testdata/logout.xml b/javatests/google/registry/proxy/testdata/logout.xml new file mode 100644 index 000000000..dc49dca14 --- /dev/null +++ b/javatests/google/registry/proxy/testdata/logout.xml @@ -0,0 +1,7 @@ + + + + + ABC-12345 + + diff --git a/javatests/google/registry/proxy/testdata/logout_response.xml b/javatests/google/registry/proxy/testdata/logout_response.xml new file mode 100644 index 000000000..e8c49c07e --- /dev/null +++ b/javatests/google/registry/proxy/testdata/logout_response.xml @@ -0,0 +1,12 @@ + + + + + Command completed successfully; ending session + + + proxy-logout + inlxipwsQKaXS3VmbKOmBA==-c + + +