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:
+ *
+ *
+ * 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.
+ * Add handlers for the {@link FrontendProtocol} to the inbound {@link Channel}.
+ * Establish an outbound {@link Channel} that serves as the relay channel of the inbound
+ * {@link Channel}, as specified by {@link FrontendProtocol#relayProtocol}.
+ * 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 extends ChannelHandler> 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 extends I> 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 extends ChannelHandler> 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
+
+
+