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(); + } +}