Skip to content

Commit

Permalink
Add support for Optional TLS negotiation for http/1 and http/2 (#2871)
Browse files Browse the repository at this point in the history
Motivation
----------
In certain deployment scenarios it can be useful to accept incoming traffic
on the same port for both TLS and non-TLS connections. ServiceTalk does not
support this at this point, so this changeset aims to add the feature.

Modifications
-------------
User-facing, a new overload option for the sslConfig is introduced which allows
the user to specify that insecure connections should also be accepted. When
set to true, both http/1 and http/2 protocols are supported, although ServiceTalk
does not support h2c cleartext upgrades so this combination is still not supported.

This also works with ALPN and SNI, since the negotiation happens before the
TLS connection is fully established.

Internally, the OptionalSslNegotiator will determine if the incoming first bytes
of the connection signify a TLS connection or not and will hand it to the
right channel initializer afterwards.
  • Loading branch information
daschl authored Mar 14, 2024
1 parent d4012e6 commit fc8b1df
Show file tree
Hide file tree
Showing 24 changed files with 801 additions and 172 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,12 @@ public HttpServerBuilder sslConfig(final ServerSslConfig defaultConfig, final Ma
return this;
}

@Override
public HttpServerBuilder sslConfig(final ServerSslConfig config, final boolean acceptInsecureConnections) {
delegate = delegate.sslConfig(config, acceptInsecureConnections);
return this;
}

@Override
public <T> HttpServerBuilder socketOption(final SocketOption<T> option, final T value) {
delegate = delegate.socketOption(option, value);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,18 @@ public interface HttpServerBuilder {
*/
HttpServerBuilder sslConfig(ServerSslConfig config);

/**
* Set the SSL/TLS configuration and allows to specify if insecure connections should also be allowed.
*
* @param config The configuration to use.
* @param acceptInsecureConnections if non-TLS connections are accepted on the same socket.
* @return {@code this}.
*/
default HttpServerBuilder sslConfig(ServerSslConfig config, boolean acceptInsecureConnections) {
throw new UnsupportedOperationException(
"sslConfig(ServerSslConfig, boolean) is not supported by " + getClass());
}

/**
* Set the SSL/TLS and <a href="https://tools.ietf.org/html/rfc6066#section-3">SNI</a> configuration.
*
Expand Down Expand Up @@ -97,7 +109,7 @@ public interface HttpServerBuilder {
default HttpServerBuilder sslConfig(ServerSslConfig defaultConfig, Map<String, ServerSslConfig> sniMap,
int maxClientHelloLength, Duration clientHelloTimeout) {
throw new UnsupportedOperationException(
"sslConfig(ServerSslConfig, Map, int, Durations) is not supported by " + getClass());
"sslConfig(ServerSslConfig, Map, int, Duration) is not supported by " + getClass());
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -135,26 +135,4 @@ private boolean failSubscriber(final Throwable cause, final Channel channel) {
return false;
}
}

/**
* {@link ChannelInitializer} that does not do anything.
*/
static final class NoopChannelInitializer implements ChannelInitializer {

static final ChannelInitializer INSTANCE = new NoopChannelInitializer();

private NoopChannelInitializer() {
// Singleton
}

@Override
public void init(final Channel channel) {
// NOOP
}

@Override
public ChannelInitializer andThen(final ChannelInitializer after) {
return after;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@
import io.servicetalk.http.api.HttpProtocolVersion;
import io.servicetalk.http.api.StreamingHttpConnectionFilterFactory;
import io.servicetalk.http.api.StreamingHttpRequestResponseFactory;
import io.servicetalk.http.netty.AlpnChannelSingle.NoopChannelInitializer;
import io.servicetalk.tcp.netty.internal.ReadOnlyTcpClientConfig;
import io.servicetalk.tcp.netty.internal.TcpClientChannelInitializer;
import io.servicetalk.tcp.netty.internal.TcpConnector;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -204,20 +204,29 @@ public HttpServerBuilder protocols(final HttpProtocolConfig... protocols) {

@Override
public HttpServerBuilder sslConfig(final ServerSslConfig config) {
this.config.tcpConfig().sslConfig(config);
this.config.tcpConfig().sslConfig(requireNonNull(config, "config"));
return this;
}

@Override
public HttpServerBuilder sslConfig(final ServerSslConfig defaultConfig, final Map<String, ServerSslConfig> sniMap) {
this.config.tcpConfig().sslConfig(defaultConfig, sniMap);
this.config.tcpConfig().sslConfig(requireNonNull(defaultConfig, "defaultConfig"),
requireNonNull(sniMap, "sniMap"));
return this;
}

@Override
public HttpServerBuilder sslConfig(final ServerSslConfig defaultConfig, final Map<String, ServerSslConfig> sniMap,
final int maxClientHelloLength, final Duration clientHelloTimeout) {
this.config.tcpConfig().sslConfig(defaultConfig, sniMap, maxClientHelloLength, clientHelloTimeout);
this.config.tcpConfig().sslConfig(requireNonNull(defaultConfig, "defaultConfig"),
requireNonNull(sniMap, "sniMap"), maxClientHelloLength,
requireNonNull(clientHelloTimeout, "clientHelloTimeout"));
return this;
}

@Override
public HttpServerBuilder sslConfig(final ServerSslConfig config, final boolean acceptInsecureConnections) {
this.config.tcpConfig().sslConfig(config, acceptInsecureConnections);
return this;
}

Expand Down Expand Up @@ -399,7 +408,14 @@ private Single<HttpServerContext> doBind(final HttpExecutionContext executionCon
ReadOnlyHttpServerConfig roConfig = config.asReadOnly();
StreamingHttpService filteredService = applyInternalFilters(service, roConfig.lifecycleObserver());

if (roConfig.tcpConfig().isAlpnConfigured()) {
if (roConfig.tcpConfig().sslConfig() != null && roConfig.tcpConfig().acceptInsecureConnections()) {
HttpServerConfig configWithoutSsl = new HttpServerConfig(config);
configWithoutSsl.tcpConfig().sslConfig(null);
ReadOnlyHttpServerConfig roConfigWithoutSsl = configWithoutSsl.asReadOnly();
return OptionalSslNegotiator.bind(executionContext, roConfig, roConfigWithoutSsl, address,
connectionAcceptor, service, drainRequestPayloadBody, earlyConnectionAcceptor,
lateConnectionAcceptor);
} else if (roConfig.tcpConfig().isAlpnConfigured()) {
return DeferredServerChannelBinder.bind(executionContext, roConfig, address, connectionAcceptor,
filteredService, drainRequestPayloadBody, false, earlyConnectionAcceptor, lateConnectionAcceptor);
} else if (roConfig.tcpConfig().sniMapping() != null) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@
import io.servicetalk.http.api.HttpExecutionContext;
import io.servicetalk.http.api.HttpServerContext;
import io.servicetalk.http.api.StreamingHttpService;
import io.servicetalk.http.netty.AlpnChannelSingle.NoopChannelInitializer;
import io.servicetalk.http.netty.NettyHttpServer.NettyHttpServerConnection;
import io.servicetalk.tcp.netty.internal.ReadOnlyTcpServerConfig;
import io.servicetalk.tcp.netty.internal.TcpServerBinder;
Expand Down Expand Up @@ -84,7 +83,7 @@ static Single<HttpServerContext> bind(final HttpExecutionContext executionContex
});
}

private static Single<NettyConnectionContext> alpnInitChannel(final SocketAddress listenAddress,
static Single<NettyConnectionContext> alpnInitChannel(final SocketAddress listenAddress,
final Channel channel,
final ReadOnlyHttpServerConfig config,
final HttpExecutionContext httpExecutionContext,
Expand All @@ -110,7 +109,7 @@ private static Single<NettyConnectionContext> alpnInitChannel(final SocketAddres
});
}

private static Single<NettyConnectionContext> sniInitChannel(final SocketAddress listenAddress,
static Single<NettyConnectionContext> sniInitChannel(final SocketAddress listenAddress,
final Channel channel,
final ReadOnlyHttpServerConfig config,
final HttpExecutionContext httpExecutionContext,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,12 @@ final class HttpServerConfig {
});
}

HttpServerConfig(final HttpServerConfig from) {
tcpConfig = new TcpServerConfig(from.tcpConfig);
httpConfig = new HttpConfig(from.httpConfig);
lifecycleObserver = from.lifecycleObserver;
}

TcpServerConfig tcpConfig() {
return tcpConfig;
}
Expand Down Expand Up @@ -83,7 +89,7 @@ private void applySslConfigOverrides() {
httpAlpnProtocols(sslConfig.alpnProtocols(), httpConfig.supportedAlpnProtocols()));
Map<String, ServerSslConfig> sniMap = tcpConfig.sniConfig();
if (sniMap == null) {
tcpConfig.sslConfig(sslConfig);
tcpConfig.sslConfig(sslConfig, tcpConfig.acceptInsecureConnections());
} else {
// Make a copy in case the original map is unmodifiable. Use LinkedHashMap to preserve iteration order
// in case there is order precedence in the matching algorithm.
Expand All @@ -93,7 +99,8 @@ private void applySslConfigOverrides() {
sniMapOverrides.put(sniConfigEntry.getKey(), new DelegatingHttpServerSslConfig(sniConfig,
httpAlpnProtocols(sniConfig.alpnProtocols(), httpConfig.supportedAlpnProtocols())));
}
tcpConfig.sslConfig(sslConfig, sniMapOverrides);
tcpConfig.sslConfig(sslConfig, sniMapOverrides, tcpConfig.sniMaxClientHelloLength(),
tcpConfig.sniClientHelloTimeout(), tcpConfig.acceptInsecureConnections());
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/*
* Copyright © 2024 Apple Inc. and the ServiceTalk project authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.servicetalk.http.netty;

import io.servicetalk.transport.netty.internal.ChannelInitializer;

import io.netty.channel.Channel;

/**
* {@link ChannelInitializer} that does not do anything.
*/
final class NoopChannelInitializer implements ChannelInitializer {

static final ChannelInitializer INSTANCE = new NoopChannelInitializer();

private NoopChannelInitializer() {
// Singleton
}

@Override
public void init(final Channel channel) {
// NOOP
}

@Override
public ChannelInitializer andThen(final ChannelInitializer after) {
return after;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
/*
* Copyright © 2024 Apple Inc. and the ServiceTalk project authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.servicetalk.http.netty;

import io.servicetalk.concurrent.SingleSource;

import io.netty.buffer.ByteBuf;
import io.netty.channel.Channel;
import io.netty.channel.ChannelHandler;
import io.netty.channel.ChannelHandlerContext;
import io.netty.handler.codec.ByteToMessageDecoder;
import io.netty.handler.ssl.SslHandler;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.util.List;
import javax.annotation.Nullable;

/**
* Part of a {@link io.netty.channel.ChannelInitializer} which negotiates SSL/non-SSL connections when SSL is enabled.
*/
final class OptionalSslChannelSingle extends ChannelInitSingle<Boolean> {

OptionalSslChannelSingle(final Channel channel) {
super(channel, NoopChannelInitializer.INSTANCE);
}

@Override
protected ChannelHandler newChannelHandler(final Subscriber<? super Boolean> subscriber) {
return new OptionalSslHandler(subscriber);
}

private static final class OptionalSslHandler extends ByteToMessageDecoder {

private static final Logger LOGGER = LoggerFactory.getLogger(OptionalSslHandler.class);

/**
* the length of the ssl record header (in bytes)
*/
private static final int SSL_RECORD_HEADER_LENGTH = 5;

@Nullable
SingleSource.Subscriber<? super Boolean> subscriber;

OptionalSslHandler(final SingleSource.Subscriber<? super Boolean> subscriber) {
this.subscriber = subscriber;
}

@Override
public void handlerAdded(final ChannelHandlerContext ctx) throws Exception {
if (ctx.channel().isActive()) {
ctx.read(); // we need to force a read to detect SSL yes/no
}
super.handlerAdded(ctx);
}

@Override
public void channelActive(final ChannelHandlerContext ctx) throws Exception {
ctx.read(); // we need to force a read to detect SSL yes/no
ctx.fireChannelActive();
}

@Override
protected void decode(final ChannelHandlerContext ctx, final ByteBuf in, final List<Object> out) {
if (in.readableBytes() < SSL_RECORD_HEADER_LENGTH || subscriber == null) {
return;
}
boolean isEncrypted = SslHandler.isEncrypted(in);
LOGGER.debug("{} Detected TLS for this connection: {}", ctx.channel(), isEncrypted);
final SingleSource.Subscriber<? super Boolean> subscriberCopy = subscriber;
subscriber = null;
subscriberCopy.onSuccess(isEncrypted);

// Need to make sure that when this handler is removed, there is another handler in the pipeline
// to pick up the read bytes from ByteToMessageDecoder when this handler is removed.
assert ctx.executor().inEventLoop();
assert ctx.pipeline().last() != this;
ctx.pipeline().remove(this);
}
}
}
Loading

0 comments on commit fc8b1df

Please sign in to comment.