From 4a5b3170167c493c797cd3011113750cd34534e2 Mon Sep 17 00:00:00 2001 From: jianglai Date: Wed, 1 Aug 2018 09:57:25 -0700 Subject: [PATCH] Add web WHOIS redirect support Opened two ports (30010 and 30011 by default) that handles HTTP(S) GET requests. the HTTP request is redirected to the corresponding HTTPS site, whereas the HTTPS request is redirected to a site that supports web WHOIS. The GCLB currently exposes port 80, but not port 443 on its TCP proxy load balancer (see https://cloud.google.com/load-balancing/docs/choosing-load-balancer). As a result, the HTTP traffic has to be routed by the HTTP load balancer, which requires a separate HTTP health check (as opposed to the TCP health check that the TCP proxy LB uses). This CL also added support for HTTP health check. There is not a strong case for adding an end-to-end test for WebWhoisProtocolsModule (like those for EppProtocolModule, etc) as it just assembles standard HTTP codecs used for an HTTP server, plus the WebWhoisRedirectHandler, which is tested. The end-to-end test would just be testing if the Netty provided HTTP handlers correctly parse raw HTTP messages. Sever other small improvement is also included: [1] Use setInt other than set when setting content length in HTTP headers. I don't think it is necessary, but it is nevertheless a better practice to use a more specialized setter. [2] Do not write metrics when running locally. [3] Rename the qualifier @EppCertificates to @ServerSertificate as it now provides the certificate used in HTTPS traffic as well. ------------- Created by MOE: https://github.com/google/moe MOE_MIGRATED_REVID=206944843 --- java/google/registry/proxy/BUILD | 2 + .../registry/proxy/CertificateModule.java | 21 +- .../registry/proxy/EppProtocolModule.java | 16 +- .../proxy/HealthCheckProtocolModule.java | 2 +- java/google/registry/proxy/Protocol.java | 20 +- java/google/registry/proxy/ProxyConfig.java | 8 + java/google/registry/proxy/ProxyModule.java | 42 ++-- java/google/registry/proxy/ProxyServer.java | 55 +++--- .../proxy/WebWhoisProtocolsModule.java | 139 +++++++++++++ .../registry/proxy/config/default-config.yaml | 8 + .../handler/HttpsRelayServiceHandler.java | 2 +- .../proxy/handler/SslServerInitializer.java | 52 ++--- .../handler/WebWhoisRedirectHandler.java | 137 +++++++++++++ .../registry/proxy/ProtocolModuleTest.java | 17 +- .../registry/proxy/ProxyModuleTest.java | 18 ++ .../google/registry/proxy/TestUtils.java | 11 +- .../handler/SslServerInitializerTest.java | 48 ++++- .../handler/WebWhoisRedirectHandlerTest.java | 185 ++++++++++++++++++ 18 files changed, 686 insertions(+), 97 deletions(-) create mode 100644 java/google/registry/proxy/WebWhoisProtocolsModule.java create mode 100644 java/google/registry/proxy/handler/WebWhoisRedirectHandler.java create mode 100644 javatests/google/registry/proxy/handler/WebWhoisRedirectHandlerTest.java diff --git a/java/google/registry/proxy/BUILD b/java/google/registry/proxy/BUILD index 79719ae3e..8c9415278 100644 --- a/java/google/registry/proxy/BUILD +++ b/java/google/registry/proxy/BUILD @@ -70,6 +70,8 @@ container_image( "30000", "30001", "30002", + "30010", + "30011", ], ) diff --git a/java/google/registry/proxy/CertificateModule.java b/java/google/registry/proxy/CertificateModule.java index d5f712818..abfdbe42a 100644 --- a/java/google/registry/proxy/CertificateModule.java +++ b/java/google/registry/proxy/CertificateModule.java @@ -44,7 +44,7 @@ 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. + * Dagger module that provides bindings needed to inject server certificate chain and private key. * *

The production 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 @@ -60,17 +60,22 @@ import org.bouncycastle.openssl.jcajce.JcaPEMKeyConverter; @Module public class CertificateModule { - /** Dagger qualifier to provide bindings related to EPP certificates */ + /** Dagger qualifier to provide bindings related to the certificates that the server provides. */ @Qualifier - public @interface EppCertificates {} + @interface ServerCertificates {} /** Dagger qualifier to provide bindings when running locally. */ @Qualifier - public @interface Local {} + @interface Local {} - /** Dagger qualifier to provide bindings when running in production. */ + /** + * Dagger qualifier to provide bindings when running in production. + * + *

The "production" here means that the proxy runs on GKE, as apposed to on a local machine. It + * does not necessary mean the production environment. + */ @Qualifier - public @interface Prod {} + @interface Prod {} static { Security.addProvider(new BouncyCastleProvider()); @@ -95,7 +100,7 @@ public class CertificateModule { @Singleton @Provides - @EppCertificates + @ServerCertificates static X509Certificate[] provideCertificates( Environment env, @Local Lazy localCertificates, @@ -105,7 +110,7 @@ public class CertificateModule { @Singleton @Provides - @EppCertificates + @ServerCertificates static PrivateKey providePrivateKey( Environment env, @Local Lazy localPrivateKey, diff --git a/java/google/registry/proxy/EppProtocolModule.java b/java/google/registry/proxy/EppProtocolModule.java index fa82703dc..e5d3f8d06 100644 --- a/java/google/registry/proxy/EppProtocolModule.java +++ b/java/google/registry/proxy/EppProtocolModule.java @@ -20,6 +20,7 @@ import com.google.common.collect.ImmutableList; import dagger.Module; import dagger.Provides; import dagger.multibindings.IntoSet; +import google.registry.proxy.CertificateModule.ServerCertificates; import google.registry.proxy.HttpsRelayProtocolModule.HttpsRelayProtocol; import google.registry.proxy.Protocol.BackendProtocol; import google.registry.proxy.Protocol.FrontendProtocol; @@ -37,8 +38,11 @@ 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.ssl.SslProvider; import io.netty.handler.timeout.ReadTimeoutHandler; import java.io.IOException; +import java.security.PrivateKey; +import java.security.cert.X509Certificate; import java.util.concurrent.ExecutorService; import java.util.concurrent.ScheduledExecutorService; import java.util.function.Supplier; @@ -76,8 +80,8 @@ public class EppProtocolModule { @Provides @EppProtocol static ImmutableList> provideHandlerProviders( - Provider> sslServerInitializerProvider, Provider proxyProtocolHandlerProvider, + @EppProtocol Provider> sslServerInitializerProvider, @EppProtocol Provider readTimeoutHandlerProvider, Provider lengthFieldBasedFrameDecoderProvider, Provider lengthFieldPrependerProvider, @@ -152,6 +156,16 @@ public class EppProtocolModule { metrics); } + @Singleton + @Provides + @EppProtocol + static SslServerInitializer provideSslServerInitializer( + SslProvider sslProvider, + @ServerCertificates PrivateKey privateKey, + @ServerCertificates X509Certificate... certificates) { + return new SslServerInitializer<>(true, sslProvider, privateKey, certificates); + } + @Provides @EppProtocol static TokenStore provideTokenStore( diff --git a/java/google/registry/proxy/HealthCheckProtocolModule.java b/java/google/registry/proxy/HealthCheckProtocolModule.java index 32fd37236..b40da6f1b 100644 --- a/java/google/registry/proxy/HealthCheckProtocolModule.java +++ b/java/google/registry/proxy/HealthCheckProtocolModule.java @@ -50,7 +50,7 @@ public class HealthCheckProtocolModule { return Protocol.frontendBuilder() .name(PROTOCOL_NAME) .port(healthCheckPort) - .isHealthCheck(true) + .hasBackend(false) .handlerProviders(handlerProviders) .build(); } diff --git a/java/google/registry/proxy/Protocol.java b/java/google/registry/proxy/Protocol.java index 9ffe68027..2f00a20d2 100644 --- a/java/google/registry/proxy/Protocol.java +++ b/java/google/registry/proxy/Protocol.java @@ -42,13 +42,9 @@ public interface Protocol { /** 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 - */ + /** A builder for {@link FrontendProtocol}, by default there is a backend associated with it. */ static FrontendProtocol.Builder frontendBuilder() { - return new AutoValue_Protocol_FrontendProtocol.Builder().isHealthCheck(false); + return new AutoValue_Protocol_FrontendProtocol.Builder().hasBackend(true); } static BackendProtocol.Builder backendBuilder() { @@ -83,18 +79,22 @@ public interface Protocol { /** * The {@link BackendProtocol} used to establish a relay channel and relay the traffic to. Not - * required for health check protocol. + * required for health check protocol or HTTP(S) redirect. */ @Nullable public abstract BackendProtocol relayProtocol(); - public abstract boolean isHealthCheck(); + /** + * Whether this {@code FrontendProtocol} relays to a {@code BackendProtocol}. All proxied + * traffic must be represented by a protocol that has a backend. + */ + public abstract boolean hasBackend(); @AutoValue.Builder public abstract static class Builder extends Protocol.Builder { public abstract Builder relayProtocol(BackendProtocol value); - public abstract Builder isHealthCheck(boolean value); + public abstract Builder hasBackend(boolean value); abstract FrontendProtocol autoBuild(); @@ -102,7 +102,7 @@ public interface Protocol { public FrontendProtocol build() { FrontendProtocol frontendProtocol = autoBuild(); Preconditions.checkState( - frontendProtocol.isHealthCheck() || frontendProtocol.relayProtocol() != null, + !frontendProtocol.hasBackend() || frontendProtocol.relayProtocol() != null, "Frontend protocol %s must define a relay protocol.", frontendProtocol.name()); return frontendProtocol; diff --git a/java/google/registry/proxy/ProxyConfig.java b/java/google/registry/proxy/ProxyConfig.java index ceecad627..16a14bab2 100644 --- a/java/google/registry/proxy/ProxyConfig.java +++ b/java/google/registry/proxy/ProxyConfig.java @@ -44,6 +44,7 @@ public class ProxyConfig { public Epp epp; public Whois whois; public HealthCheck healthCheck; + public WebWhois webWhois; public HttpsRelay httpsRelay; public Metrics metrics; @@ -89,6 +90,13 @@ public class ProxyConfig { public String checkResponse; } + /** Configuration options that apply to web WHOIS redirects. */ + public static class WebWhois { + public int httpPort; + public int httpsPort; + public String redirectHost; + } + /** Configuration options that apply to HTTPS relay protocol. */ public static class HttpsRelay { public int port; diff --git a/java/google/registry/proxy/ProxyModule.java b/java/google/registry/proxy/ProxyModule.java index c4f1c8aae..1c3bf9109 100644 --- a/java/google/registry/proxy/ProxyModule.java +++ b/java/google/registry/proxy/ProxyModule.java @@ -38,6 +38,8 @@ 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.WebWhoisProtocolsModule.HttpWhoisProtocol; +import google.registry.proxy.WebWhoisProtocolsModule.HttpsWhoisProtocol; import google.registry.proxy.WhoisProtocolModule.WhoisProtocol; import google.registry.proxy.handler.ProxyProtocolHandler; import google.registry.util.Clock; @@ -75,9 +77,15 @@ public class ProxyModule { @Parameter(names = "--epp", description = "Port for EPP") private Integer eppPort; - @Parameter(names = "--health_check", description = "Port for health check protocol") + @Parameter(names = "--health_check", description = "Port for health check") private Integer healthCheckPort; + @Parameter(names = "--http_whois", description = "Port for HTTP WHOIS") + private Integer httpWhoisPort; + + @Parameter(names = "--https_whois", description = "Port for HTTPS WHOIS") + private Integer httpsWhoisPort; + @Parameter(names = "--env", description = "Environment to run the proxy in") private Environment env = Environment.LOCAL; @@ -165,6 +173,18 @@ public class ProxyModule { return Optional.ofNullable(healthCheckPort).orElse(config.healthCheck.port); } + @Provides + @HttpWhoisProtocol + int provideHttpWhoisProtocol(ProxyConfig config) { + return Optional.ofNullable(httpWhoisPort).orElse(config.webWhois.httpPort); + } + + @Provides + @HttpsWhoisProtocol + int provideHttpsWhoisProtocol(ProxyConfig config) { + return Optional.ofNullable(httpsWhoisPort).orElse(config.webWhois.httpsPort); + } + @Provides ImmutableMap providePortToProtocolMap( Set protocolSet) { @@ -316,16 +336,16 @@ public class ProxyModule { /** 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, - MetricsModule.class - } - ) + modules = { + ProxyModule.class, + CertificateModule.class, + HttpsRelayProtocolModule.class, + WhoisProtocolModule.class, + WebWhoisProtocolsModule.class, + EppProtocolModule.class, + HealthCheckProtocolModule.class, + MetricsModule.class + }) interface ProxyComponent { ImmutableMap portToProtocolMap(); diff --git a/java/google/registry/proxy/ProxyServer.java b/java/google/registry/proxy/ProxyServer.java index 4a223173f..a02a4c94e 100644 --- a/java/google/registry/proxy/ProxyServer.java +++ b/java/google/registry/proxy/ProxyServer.java @@ -23,6 +23,7 @@ import com.google.common.flogger.FluentLogger; import com.google.monitoring.metrics.MetricReporter; import google.registry.proxy.Protocol.BackendProtocol; import google.registry.proxy.Protocol.FrontendProtocol; +import google.registry.proxy.ProxyConfig.Environment; import google.registry.proxy.ProxyModule.ProxyComponent; import io.netty.bootstrap.Bootstrap; import io.netty.bootstrap.ServerBootstrap; @@ -87,9 +88,9 @@ public class ProxyServer implements Runnable { 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. + if (!inboundProtocol.hasBackend()) { + // If the frontend has no backend to relay to (health check, web WHOIS redirect, etc), start + // reading immediately. inboundChannel.config().setAutoRead(true); } else { // Connect to the relay (outbound) channel specified by the BackendProtocol. @@ -208,33 +209,35 @@ public class ProxyServer implements Runnable { // Configure the components, this needs to run first so that the logging format is properly // configured for each environment. + ProxyModule proxyModule = new ProxyModule().parse(args); ProxyComponent proxyComponent = - DaggerProxyModule_ProxyComponent.builder() - .proxyModule(new ProxyModule().parse(args)) - .build(); + DaggerProxyModule_ProxyComponent.builder().proxyModule(proxyModule).build(); - MetricReporter metricReporter = proxyComponent.metricReporter(); - try { - metricReporter.startAsync().awaitRunning(10, TimeUnit.SECONDS); - logger.atInfo().log("Started up MetricReporter"); - } catch (TimeoutException timeoutException) { - logger.atSevere().withCause(timeoutException).log( - "Failed to initialize MetricReporter: %s", timeoutException); + // Do not write metrics when running locally. + if (proxyModule.provideEnvironment() != Environment.LOCAL) { + MetricReporter metricReporter = proxyComponent.metricReporter(); + try { + metricReporter.startAsync().awaitRunning(10, TimeUnit.SECONDS); + logger.atInfo().log("Started up MetricReporter"); + } catch (TimeoutException timeoutException) { + logger.atSevere().withCause(timeoutException).log( + "Failed to initialize MetricReporter: %s", timeoutException); + } + Runtime.getRuntime() + .addShutdownHook( + new Thread( + () -> { + try { + metricReporter.stopAsync().awaitTerminated(10, TimeUnit.SECONDS); + logger.atInfo().log("Shut down MetricReporter"); + } catch (TimeoutException timeoutException) { + logger.atWarning().withCause(timeoutException).log( + "Failed to stop MetricReporter: %s", timeoutException); + } + })); } - Runtime.getRuntime() - .addShutdownHook( - new Thread( - () -> { - try { - metricReporter.stopAsync().awaitTerminated(10, TimeUnit.SECONDS); - logger.atInfo().log("Shut down MetricReporter"); - } catch (TimeoutException timeoutException) { - logger.atWarning().withCause(timeoutException).log( - "Failed to stop MetricReporter: %s", timeoutException); - } - })); - + // Start the proxy. new ProxyServer(proxyComponent).run(); } } diff --git a/java/google/registry/proxy/WebWhoisProtocolsModule.java b/java/google/registry/proxy/WebWhoisProtocolsModule.java new file mode 100644 index 000000000..eb612b53b --- /dev/null +++ b/java/google/registry/proxy/WebWhoisProtocolsModule.java @@ -0,0 +1,139 @@ +// Copyright 2018 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.CertificateModule.ServerCertificates; +import google.registry.proxy.Protocol.FrontendProtocol; +import google.registry.proxy.handler.SslServerInitializer; +import google.registry.proxy.handler.WebWhoisRedirectHandler; +import io.netty.channel.ChannelHandler; +import io.netty.channel.socket.nio.NioSocketChannel; +import io.netty.handler.codec.http.HttpServerCodec; +import io.netty.handler.codec.http.HttpServerExpectContinueHandler; +import io.netty.handler.ssl.SslProvider; +import java.security.PrivateKey; +import java.security.cert.X509Certificate; +import javax.inject.Provider; +import javax.inject.Qualifier; +import javax.inject.Singleton; + +/** A module that provides the {@link FrontendProtocol}s to redirect HTTP(S) web WHOIS requests. */ +@Module +public class WebWhoisProtocolsModule { + + /** Dagger qualifier to provide HTTP whois protocol related handlers and other bindings. */ + @Qualifier + @interface HttpWhoisProtocol {} + + /** Dagger qualifier to provide HTTPS whois protocol related handlers and other bindings. */ + @Qualifier + @interface HttpsWhoisProtocol {} + + private static final String HTTP_PROTOCOL_NAME = "whois_http"; + private static final String HTTPS_PROTOCOL_NAME = "whois_https"; + + @Singleton + @Provides + @IntoSet + static FrontendProtocol provideHttpWhoisProtocol( + @HttpWhoisProtocol int httpWhoisPort, + @HttpWhoisProtocol ImmutableList> handlerProviders) { + return google.registry.proxy.Protocol.frontendBuilder() + .name(HTTP_PROTOCOL_NAME) + .port(httpWhoisPort) + .hasBackend(false) + .handlerProviders(handlerProviders) + .build(); + } + + @Singleton + @Provides + @IntoSet + static FrontendProtocol provideHttpsWhoisProtocol( + @HttpsWhoisProtocol int httpsWhoisPort, + @HttpsWhoisProtocol ImmutableList> handlerProviders) { + return google.registry.proxy.Protocol.frontendBuilder() + .name(HTTPS_PROTOCOL_NAME) + .port(httpsWhoisPort) + .hasBackend(false) + .handlerProviders(handlerProviders) + .build(); + } + + @Provides + @HttpWhoisProtocol + static ImmutableList> providerHttpWhoisHandlerProviders( + Provider httpServerCodecProvider, + Provider httpServerExpectContinueHandlerProvider, + @HttpWhoisProtocol Provider webWhoisRedirectHandlerProvides) { + return ImmutableList.of( + httpServerCodecProvider, + httpServerExpectContinueHandlerProvider, + webWhoisRedirectHandlerProvides); + }; + + @Provides + @HttpsWhoisProtocol + static ImmutableList> providerHttpsWhoisHandlerProviders( + @HttpsWhoisProtocol + Provider> sslServerInitializerProvider, + Provider httpServerCodecProvider, + Provider httpServerExpectContinueHandlerProvider, + @HttpsWhoisProtocol Provider webWhoisRedirectHandlerProvides) { + return ImmutableList.of( + sslServerInitializerProvider, + httpServerCodecProvider, + httpServerExpectContinueHandlerProvider, + webWhoisRedirectHandlerProvides); + }; + + @Provides + static HttpServerCodec provideHttpServerCodec() { + return new HttpServerCodec(); + } + + @Provides + @HttpWhoisProtocol + static WebWhoisRedirectHandler provideHttpRedirectHandler( + google.registry.proxy.ProxyConfig config) { + return new WebWhoisRedirectHandler(false, config.webWhois.redirectHost); + } + + @Provides + @HttpsWhoisProtocol + static WebWhoisRedirectHandler provideHttpsRedirectHandler( + google.registry.proxy.ProxyConfig config) { + return new WebWhoisRedirectHandler(true, config.webWhois.redirectHost); + } + + @Provides + static HttpServerExpectContinueHandler provideHttpServerExpectContinueHandler() { + return new HttpServerExpectContinueHandler(); + } + + @Singleton + @Provides + @HttpsWhoisProtocol + static SslServerInitializer provideSslServerInitializer( + SslProvider sslProvider, + @ServerCertificates PrivateKey privateKey, + @ServerCertificates X509Certificate... certificates) { + return new SslServerInitializer<>(false, sslProvider, privateKey, certificates); + } +} diff --git a/java/google/registry/proxy/config/default-config.yaml b/java/google/registry/proxy/config/default-config.yaml index aaf5e0145..5bf54e835 100644 --- a/java/google/registry/proxy/config/default-config.yaml +++ b/java/google/registry/proxy/config/default-config.yaml @@ -187,6 +187,14 @@ httpsRelay: # Maximum size of an HTTP message in bytes. maxMessageLengthBytes: 524288 +webWhois: + httpPort: 30010 + httpsPort: 30011 + + # The 302 redirect destination of HTTPS web WHOIS GET requests. + # HTTP web WHOIS GET requests will be 301 redirected to HTTPS first. + redirectHost: whois.yourdomain.tld + metrics: # Max queries per second for the Google Cloud Monitoring V3 (aka Stackdriver) # API. The limit can be adjusted by contacting Cloud Support. diff --git a/java/google/registry/proxy/handler/HttpsRelayServiceHandler.java b/java/google/registry/proxy/handler/HttpsRelayServiceHandler.java index 4dfe8dc0a..4e6974aff 100644 --- a/java/google/registry/proxy/handler/HttpsRelayServiceHandler.java +++ b/java/google/registry/proxy/handler/HttpsRelayServiceHandler.java @@ -93,7 +93,7 @@ abstract class HttpsRelayServiceHandler extends ByteToMessageCodec extends ChannelInitializer { @@ -59,16 +55,18 @@ public class SslServerInitializer extends ChannelInitializer< AttributeKey.valueOf("CLIENT_CERTIFICATE_PROMISE_KEY"); private static final FluentLogger logger = FluentLogger.forEnclosingClass(); + private final boolean requireClientCert; private final SslProvider sslProvider; private final PrivateKey privateKey; private final X509Certificate[] certificates; - @Inject - SslServerInitializer( + public SslServerInitializer( + boolean requireClientCert, SslProvider sslProvider, - @EppCertificates PrivateKey privateKey, - @EppCertificates X509Certificate... certificates) { + PrivateKey privateKey, + X509Certificate... certificates) { logger.atInfo().log("Server SSL Provider: %s", sslProvider); + this.requireClientCert = requireClientCert; this.sslProvider = sslProvider; this.privateKey = privateKey; this.certificates = certificates; @@ -80,26 +78,28 @@ public class SslServerInitializer extends ChannelInitializer< SslContextBuilder.forServer(privateKey, certificates) .sslProvider(sslProvider) .trustManager(InsecureTrustManagerFactory.INSTANCE) - .clientAuth(ClientAuth.REQUIRE) + .clientAuth(requireClientCert ? ClientAuth.REQUIRE : ClientAuth.NONE) .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); + if (requireClientCert) { + 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/WebWhoisRedirectHandler.java b/java/google/registry/proxy/handler/WebWhoisRedirectHandler.java new file mode 100644 index 000000000..b0b77062a --- /dev/null +++ b/java/google/registry/proxy/handler/WebWhoisRedirectHandler.java @@ -0,0 +1,137 @@ +// Copyright 2018 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 io.netty.handler.codec.http.HttpHeaderNames.CONNECTION; +import static io.netty.handler.codec.http.HttpHeaderNames.CONTENT_LENGTH; +import static io.netty.handler.codec.http.HttpHeaderNames.CONTENT_TYPE; +import static io.netty.handler.codec.http.HttpHeaderNames.HOST; +import static io.netty.handler.codec.http.HttpHeaderNames.LOCATION; +import static io.netty.handler.codec.http.HttpHeaderValues.KEEP_ALIVE; +import static io.netty.handler.codec.http.HttpHeaderValues.TEXT_PLAIN; +import static io.netty.handler.codec.http.HttpMethod.GET; +import static io.netty.handler.codec.http.HttpResponseStatus.FORBIDDEN; +import static io.netty.handler.codec.http.HttpResponseStatus.FOUND; +import static io.netty.handler.codec.http.HttpResponseStatus.METHOD_NOT_ALLOWED; +import static io.netty.handler.codec.http.HttpResponseStatus.MOVED_PERMANENTLY; +import static io.netty.handler.codec.http.HttpResponseStatus.OK; +import static io.netty.handler.codec.http.HttpVersion.HTTP_1_1; + +import com.google.common.base.Splitter; +import com.google.common.flogger.FluentLogger; +import io.netty.channel.ChannelFuture; +import io.netty.channel.ChannelFutureListener; +import io.netty.channel.ChannelHandlerContext; +import io.netty.channel.SimpleChannelInboundHandler; +import io.netty.handler.codec.http.DefaultFullHttpResponse; +import io.netty.handler.codec.http.FullHttpResponse; +import io.netty.handler.codec.http.HttpRequest; +import io.netty.handler.codec.http.HttpUtil; +import java.time.Duration; + +/** + * Handler that redirects web WHOIS requests to a canonical website. + * + *

ICANN requires that port 43 and web-based WHOIS are both available on whois.nic.TLD. Since we + * expose a single IPv4/IPv6 anycast external IP address for the proxy, we need the load balancer to + * router port 80/443 traffic to the proxy to support web WHOIS. + * + *

HTTP (port 80) traffic is simply upgraded to HTTPS (port 443) on the same host, while HTTPS + * requests are redirected to the {@code redirectHost}, which is the canonical website that provide + * the web WHOIS service. + * + * @see + * REGISTRY AGREEMENT + */ +public class WebWhoisRedirectHandler extends SimpleChannelInboundHandler { + + private static final FluentLogger logger = FluentLogger.forEnclosingClass(); + + /** + * HTTP health check sent by GCP HTTP load balancer is set to use this host name. + * + *

Status 200 must be returned in order for a health check to be considered successful. + * + * @see + * HTTP, HTTPS, and HTTP/2 health checks + */ + private static final String HEALTH_CHECK_HOST = "health-check.invalid"; + + private static final String HSTS_HEADER_NAME = "Strict-Transport-Security"; + private static final Duration HSTS_MAX_AGE = Duration.ofDays(365); + + private final boolean isHttps; + private final String redirectHost; + + public WebWhoisRedirectHandler(boolean isHttps, String redirectHost) { + this.isHttps = isHttps; + this.redirectHost = redirectHost; + } + + @Override + protected void channelRead0(ChannelHandlerContext ctx, HttpRequest msg) { + FullHttpResponse response; + // We only support GET, any other HTTP method should result in 405 error. + if (!msg.method().equals(GET)) { + response = new DefaultFullHttpResponse(HTTP_1_1, METHOD_NOT_ALLOWED); + } else { + // All HTTP/1.1 request must contain a Host header with the format "host:[port]". + // See https://tools.ietf.org/html/rfc2616#section-14.23 + String host = Splitter.on(':').split(msg.headers().get(HOST)).iterator().next(); + if (host.equals(HEALTH_CHECK_HOST)) { + // The health check request should always be sent to the HTTP port. + response = + isHttps + ? new DefaultFullHttpResponse(HTTP_1_1, FORBIDDEN) + : new DefaultFullHttpResponse(HTTP_1_1, OK); + ; + } else { + // HTTP -> HTTPS is a 301 redirect, whereas HTTPS -> web WHOIS site is 302 redirect. + response = new DefaultFullHttpResponse(HTTP_1_1, isHttps ? FOUND : MOVED_PERMANENTLY); + String redirectUrl = String.format("https://%s/", isHttps ? redirectHost : host); + response.headers().set(LOCATION, redirectUrl); + } + } + // Add HSTS header to HTTPS response. + if (isHttps) { + response + .headers() + .set(HSTS_HEADER_NAME, String.format("max-age=%d", HSTS_MAX_AGE.getSeconds())); + } + response + .headers() + .set(CONTENT_TYPE, TEXT_PLAIN) + .setInt(CONTENT_LENGTH, response.content().readableBytes()); + + // Close the connection if keep-alive is not set in the request. + if (!HttpUtil.isKeepAlive(msg)) { + ChannelFuture unusedFuture = + ctx.writeAndFlush(response).addListener(ChannelFutureListener.CLOSE); + } else { + response.headers().set(CONNECTION, KEEP_ALIVE); + ChannelFuture unusedFuture = ctx.writeAndFlush(response); + } + } + + @Override + public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) { + logger.atSevere().withCause(cause).log( + (isHttps ? "HTTPS" : "HTTP") + " WHOIS inbound exception caught for channel %s", + ctx.channel()); + ChannelFuture unusedFuture = ctx.close(); + } +} diff --git a/javatests/google/registry/proxy/ProtocolModuleTest.java b/javatests/google/registry/proxy/ProtocolModuleTest.java index 161687bdd..03a40ec62 100644 --- a/javatests/google/registry/proxy/ProtocolModuleTest.java +++ b/javatests/google/registry/proxy/ProtocolModuleTest.java @@ -175,15 +175,14 @@ public abstract class ProtocolModuleTest { */ @Singleton @Component( - modules = { - TestModule.class, - CertificateModule.class, - WhoisProtocolModule.class, - EppProtocolModule.class, - HealthCheckProtocolModule.class, - HttpsRelayProtocolModule.class - } - ) + modules = { + TestModule.class, + CertificateModule.class, + WhoisProtocolModule.class, + EppProtocolModule.class, + HealthCheckProtocolModule.class, + HttpsRelayProtocolModule.class + }) interface TestComponent { @WhoisProtocol ImmutableList> whoisHandlers(); diff --git a/javatests/google/registry/proxy/ProxyModuleTest.java b/javatests/google/registry/proxy/ProxyModuleTest.java index 3536e2747..40916f455 100644 --- a/javatests/google/registry/proxy/ProxyModuleTest.java +++ b/javatests/google/registry/proxy/ProxyModuleTest.java @@ -41,6 +41,10 @@ public class ProxyModuleTest { assertThat(proxyModule.provideEppPort(PROXY_CONFIG)).isEqualTo(PROXY_CONFIG.epp.port); assertThat(proxyModule.provideHealthCheckPort(PROXY_CONFIG)) .isEqualTo(PROXY_CONFIG.healthCheck.port); + assertThat(proxyModule.provideHttpWhoisProtocol(PROXY_CONFIG)) + .isEqualTo(PROXY_CONFIG.webWhois.httpPort); + assertThat(proxyModule.provideHttpsWhoisProtocol(PROXY_CONFIG)) + .isEqualTo(PROXY_CONFIG.webWhois.httpsPort); assertThat(proxyModule.provideEnvironment()).isEqualTo(LOCAL); assertThat(proxyModule.log).isFalse(); } @@ -98,6 +102,20 @@ public class ProxyModuleTest { assertThat(proxyModule.provideHealthCheckPort(PROXY_CONFIG)).isEqualTo(23456); } + @Test + public void testSuccess_parseArgs_customhttpWhoisPort() { + String[] args = {"--http_whois", "12121"}; + proxyModule.parse(args); + assertThat(proxyModule.provideHttpWhoisProtocol(PROXY_CONFIG)).isEqualTo(12121); + } + + @Test + public void testSuccess_parseArgs_customhttpsWhoisPort() { + String[] args = {"--https_whois", "21212"}; + proxyModule.parse(args); + assertThat(proxyModule.provideHttpsWhoisProtocol(PROXY_CONFIG)).isEqualTo(21212); + } + @Test public void testSuccess_parseArgs_customEnvironment() { String[] args = {"--env", "ALpHa"}; diff --git a/javatests/google/registry/proxy/TestUtils.java b/javatests/google/registry/proxy/TestUtils.java index de4ed05fe..31d44d0ad 100644 --- a/javatests/google/registry/proxy/TestUtils.java +++ b/javatests/google/registry/proxy/TestUtils.java @@ -48,14 +48,21 @@ public class TestUtils { .headers() .set(HttpHeaderNames.USER_AGENT, "Proxy") .set(HttpHeaderNames.HOST, host) - .set(HttpHeaderNames.CONTENT_LENGTH, buf.readableBytes()); + .setInt(HttpHeaderNames.CONTENT_LENGTH, buf.readableBytes()); + return request; + } + + public static FullHttpRequest makeHttpGetRequest(String host, String path) { + FullHttpRequest request = + new DefaultFullHttpRequest(HttpVersion.HTTP_1_1, HttpMethod.GET, path); + request.headers().set(HttpHeaderNames.HOST, host).setInt(HttpHeaderNames.CONTENT_LENGTH, 0); 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()); + response.headers().setInt(HttpHeaderNames.CONTENT_LENGTH, buf.readableBytes()); return response; } diff --git a/javatests/google/registry/proxy/handler/SslServerInitializerTest.java b/javatests/google/registry/proxy/handler/SslServerInitializerTest.java index 718dbe3aa..d18ca44aa 100644 --- a/javatests/google/registry/proxy/handler/SslServerInitializerTest.java +++ b/javatests/google/registry/proxy/handler/SslServerInitializerTest.java @@ -87,6 +87,7 @@ public class SslServerInitializerTest { .build(); private ChannelInitializer getServerInitializer( + boolean requireClientCert, Lock serverLock, Exception serverException, PrivateKey privateKey, @@ -97,12 +98,22 @@ public class SslServerInitializerTest { protected void initChannel(LocalChannel ch) throws Exception { ch.pipeline() .addLast( - new SslServerInitializer(SslProvider.JDK, privateKey, certificates), + new SslServerInitializer( + requireClientCert, SslProvider.JDK, privateKey, certificates), new EchoHandler(serverLock, serverException)); } }; } + private ChannelInitializer getServerInitializer( + Lock serverLock, + Exception serverException, + PrivateKey privateKey, + X509Certificate... certificates) + throws Exception { + return getServerInitializer(true, serverLock, serverException, privateKey, certificates); + } + private ChannelInitializer getClientInitializer( X509Certificate trustedCertificate, PrivateKey privateKey, @@ -137,7 +148,7 @@ public class SslServerInitializerTest { public void testSuccess_swappedInitializerWithSslHandler() throws Exception { SelfSignedCertificate ssc = new SelfSignedCertificate(SSL_HOST); SslServerInitializer sslServerInitializer = - new SslServerInitializer<>(SslProvider.JDK, ssc.key(), ssc.cert()); + new SslServerInitializer<>(true, SslProvider.JDK, ssc.key(), ssc.cert()); EmbeddedChannel channel = new EmbeddedChannel(); ChannelPipeline pipeline = channel.pipeline(); pipeline.addLast(sslServerInitializer); @@ -187,6 +198,39 @@ public class SslServerInitializerTest { Future unusedFuture = eventLoopGroup.shutdownGracefully().syncUninterruptibly(); } + @Test + public void testSuccess_doesNotRequireClientCert() throws Exception { + SelfSignedCertificate serverSsc = new SelfSignedCertificate(SSL_HOST); + LocalAddress localAddress = new LocalAddress("DOES_NOT_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( + false, serverLock, serverException, serverSsc.key(), serverSsc.cert()), + localAddress); + Channel channel = + setUpClient( + eventLoopGroup, + getClientInitializer(serverSsc.cert(), null, null, clientLock, buffer, clientException), + localAddress, + PROTOCOL); + + SSLSession sslSession = + verifySslChannel( + channel, ImmutableList.of(serverSsc.cert()), clientLock, serverLock, buffer, SSL_HOST); + // Verify that the SSL session does not contain any 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()).isNull(); + 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. diff --git a/javatests/google/registry/proxy/handler/WebWhoisRedirectHandlerTest.java b/javatests/google/registry/proxy/handler/WebWhoisRedirectHandlerTest.java new file mode 100644 index 000000000..f7ad2efe7 --- /dev/null +++ b/javatests/google/registry/proxy/handler/WebWhoisRedirectHandlerTest.java @@ -0,0 +1,185 @@ +// Copyright 2018 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.assertHttpResponseEquivalent; +import static google.registry.proxy.TestUtils.makeHttpGetRequest; +import static google.registry.proxy.TestUtils.makeHttpPostRequest; +import static google.registry.proxy.TestUtils.makeHttpResponse; + +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.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +/** Unit tests for {@link WebWhoisRedirectHandler}. */ +@RunWith(JUnit4.class) +public class WebWhoisRedirectHandlerTest { + + private static final String REDIRECT_HOST = "www.example.com"; + private static final String TARGET_HOST = "whois.nic.tld"; + + private WebWhoisRedirectHandler redirectHandler; + private EmbeddedChannel channel; + private FullHttpRequest request; + private FullHttpResponse response; + + private static FullHttpResponse makeRedirectResponse( + HttpResponseStatus status, String location, boolean keepAlive, boolean isHttps) { + FullHttpResponse response = makeHttpResponse("", status); + response.headers().set("content-type", "text/plain").set("content-length", "0"); + if (location != null) { + response.headers().set("location", location); + } + if (keepAlive) { + response.headers().set("connection", "keep-alive"); + } + if (isHttps) { + response.headers().set("Strict-Transport-Security", "max-age=31536000"); + } + return response; + } + + @Test + public void testSuccess_http_redirectToHttps() { + redirectHandler = new WebWhoisRedirectHandler(false, REDIRECT_HOST); + channel = new EmbeddedChannel(redirectHandler); + request = makeHttpGetRequest(TARGET_HOST, "/"); + // No inbound message passed to the next handler. + assertThat(channel.writeInbound(request)).isFalse(); + response = channel.readOutbound(); + assertHttpResponseEquivalent( + response, + makeRedirectResponse( + HttpResponseStatus.MOVED_PERMANENTLY, "https://whois.nic.tld/", true, false)); + assertThat(channel.isActive()).isTrue(); + } + + @Test + public void testSuccess_http_redirectToHttps_hostAndPort() { + redirectHandler = new WebWhoisRedirectHandler(false, REDIRECT_HOST); + channel = new EmbeddedChannel(redirectHandler); + request = makeHttpGetRequest(TARGET_HOST + ":80", "/"); + // No inbound message passed to the next handler. + assertThat(channel.writeInbound(request)).isFalse(); + response = channel.readOutbound(); + assertHttpResponseEquivalent( + response, + makeRedirectResponse( + HttpResponseStatus.MOVED_PERMANENTLY, "https://whois.nic.tld/", true, false)); + assertThat(channel.isActive()).isTrue(); + } + + @Test + public void testSuccess_http_redirectToHttps_noKeepAlive() { + redirectHandler = new WebWhoisRedirectHandler(false, REDIRECT_HOST); + channel = new EmbeddedChannel(redirectHandler); + request = makeHttpGetRequest(TARGET_HOST, "/"); + request.headers().set("connection", "close"); + // No inbound message passed to the next handler. + assertThat(channel.writeInbound(request)).isFalse(); + response = channel.readOutbound(); + assertHttpResponseEquivalent( + response, + makeRedirectResponse( + HttpResponseStatus.MOVED_PERMANENTLY, "https://whois.nic.tld/", false, false)); + assertThat(channel.isActive()).isFalse(); + } + + @Test + public void testSuccess_http_notGet() { + redirectHandler = new WebWhoisRedirectHandler(false, REDIRECT_HOST); + channel = new EmbeddedChannel(redirectHandler); + request = makeHttpPostRequest("", TARGET_HOST, "/"); + // No inbound message passed to the next handler. + assertThat(channel.writeInbound(request)).isFalse(); + response = channel.readOutbound(); + assertHttpResponseEquivalent( + response, makeRedirectResponse(HttpResponseStatus.METHOD_NOT_ALLOWED, null, true, false)); + assertThat(channel.isActive()).isTrue(); + } + + @Test + public void testSuccess_http_healthCheck() { + redirectHandler = new WebWhoisRedirectHandler(false, REDIRECT_HOST); + channel = new EmbeddedChannel(redirectHandler); + request = makeHttpPostRequest("", TARGET_HOST, "/"); + // No inbound message passed to the next handler. + assertThat(channel.writeInbound(request)).isFalse(); + response = channel.readOutbound(); + assertHttpResponseEquivalent( + response, makeRedirectResponse(HttpResponseStatus.METHOD_NOT_ALLOWED, null, true, false)); + assertThat(channel.isActive()).isTrue(); + } + + @Test + public void testSuccess_https_redirectToDestination() { + redirectHandler = new WebWhoisRedirectHandler(true, REDIRECT_HOST); + channel = new EmbeddedChannel(redirectHandler); + request = makeHttpGetRequest(TARGET_HOST, "/"); + // No inbound message passed to the next handler. + assertThat(channel.writeInbound(request)).isFalse(); + response = channel.readOutbound(); + assertHttpResponseEquivalent( + response, + makeRedirectResponse(HttpResponseStatus.FOUND, "https://www.example.com/", true, true)); + assertThat(channel.isActive()).isTrue(); + } + + @Test + public void testSuccess_https_redirectToDestination_noKeepAlive() { + redirectHandler = new WebWhoisRedirectHandler(true, REDIRECT_HOST); + channel = new EmbeddedChannel(redirectHandler); + request = makeHttpGetRequest(TARGET_HOST, "/"); + request.headers().set("connection", "close"); + // No inbound message passed to the next handler. + assertThat(channel.writeInbound(request)).isFalse(); + response = channel.readOutbound(); + assertHttpResponseEquivalent( + response, + makeRedirectResponse(HttpResponseStatus.FOUND, "https://www.example.com/", false, true)); + assertThat(channel.isActive()).isFalse(); + } + + @Test + public void testSuccess_https_notGet() { + redirectHandler = new WebWhoisRedirectHandler(true, REDIRECT_HOST); + channel = new EmbeddedChannel(redirectHandler); + request = makeHttpPostRequest("", TARGET_HOST, "/"); + // No inbound message passed to the next handler. + assertThat(channel.writeInbound(request)).isFalse(); + response = channel.readOutbound(); + assertHttpResponseEquivalent( + response, makeRedirectResponse(HttpResponseStatus.METHOD_NOT_ALLOWED, null, true, true)); + assertThat(channel.isActive()).isTrue(); + } + + @Test + public void testSuccess_https_healthCheck() { + redirectHandler = new WebWhoisRedirectHandler(true, REDIRECT_HOST); + channel = new EmbeddedChannel(redirectHandler); + request = makeHttpGetRequest("health-check.invalid", "/"); + // No inbound message passed to the next handler. + assertThat(channel.writeInbound(request)).isFalse(); + response = channel.readOutbound(); + assertHttpResponseEquivalent( + response, makeRedirectResponse(HttpResponseStatus.FORBIDDEN, null, true, true)); + assertThat(channel.isActive()).isTrue(); + } +}