Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

quiche: implement QUIC specific TransportSocketFactory for TLS context #8091

Merged
merged 25 commits into from
Sep 19, 2019

Conversation

danzh2010
Copy link
Contributor

@danzh2010 danzh2010 commented Aug 29, 2019

Implement TransportSocketFactory for QUIC which carries Ssl::ContextConfig. The Ssl::ContextConfig instance is created based on envoy::api::v2::auth::DownstreamTlsContext in envoy::api::v2::listener::FilterChain::transport_socket.

QuicServer|ClientTransportSocketFactory has new interface server|clientContextConfig() to provide access toTLS context in addition to inherited TransportSocketFactory interface. QuicTransportSocketFactory::createTransportSocket() shouldn't be called in current implementation because QUICHE stack already does all the transport layer work.

When listener config socket type is Datagram and udp_listener_config.udp_listener_name is "quiche_quic_listener", envoy::api::v2::listener::FilterChain::transport_socket has to be configured with envoy::api::v2::auth::DownstreamTlsContext or envoy::api::v2::auth::UpstreamTlsContext.

ListenerImpl() will create a QuicTransportSocketFactory instance as part of filter chain in the same way as creating other transport socket factories.

Risk Level: medium, a bad config can hit RELEASE_ASSERT.
Testing: added unit tests for ListenerManagerImpl
Part of #2557

Signed-off-by: Dan Zhang <danzh@google.com>
Signed-off-by: Dan Zhang <danzh@google.com>
Signed-off-by: Dan Zhang <danzh@google.com>
Signed-off-by: Dan Zhang <danzh@google.com>
Signed-off-by: Dan Zhang <danzh@google.com>
Signed-off-by: Dan Zhang <danzh@google.com>
@danzh2010 danzh2010 requested a review from lizan as a code owner August 29, 2019 20:06
Signed-off-by: Dan Zhang <danzh@google.com>
@danzh2010
Copy link
Contributor Author

/assign @alyssawilk

Signed-off-by: Dan Zhang <danzh@google.com>
Copy link
Contributor

@lambdai lambdai left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems the quic filter chain is quite different...

} else {
transport_socket.set_name(
Extensions::TransportSockets::TransportSocketNames::get().RawBuffer);
std::vector<std::string> server_names(filter_chain.filter_chain_match().server_names().begin(),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: move into if (!is_quic_)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

@@ -390,13 +390,15 @@ class ListenerImpl : public Network::ListenerConfig,
class ListenerFilterChainFactoryBuilder : public FilterChainFactoryBuilder {
public:
ListenerFilterChainFactoryBuilder(
ListenerImpl& listener, Configuration::TransportSocketFactoryContextImpl& factory_context);
ListenerImpl& listener, Configuration::TransportSocketFactoryContextImpl& factory_context,
bool is_quic);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My impression is that TCPListenerFilterChainFactoryBuilder and QUIC one has nothing shared... Why not derived QUIC one from FilterChainFactoryBuilder?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

both of them access ListenerImpl and TransportSocketFactoryContextImpl. I made QuicListenerFilterChainFactoryBuilder a stand alone class deriving from ListenerFilterChainFactoryBuilder.

if (socket_type_ == Network::Address::SocketType::Datagram) {
// Needed for recvmsg to return destination address in IP header.
addListenSocketOptions(Network::SocketOptionFactory::buildIpPacketInfoOptions());
// Needed to return receive buffer overflown indicator.
addListenSocketOptions(Network::SocketOptionFactory::buildRxQueueOverFlowOptions());
std::string listener_name =
config.has_udp_listener_config() ? config.udp_listener_config().udp_listener_name() : "";
is_quic = listener_name == UdpListenerNames::get().Quic;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: is_quic = config.has_udp_listener_config() && config.udp_listener_config().udp_listener_name() == UdpListenerNames::get().Quic;

Also you can create const ref of udp_listener_config here and declare listener_name const

const auto & udp_config = config.has_udp_listener_config() ? config.udp_listener_config() : envoy::api::v2::listener::UdpListenerConfig();
const auto listener_name = udp_config.udp_listener_name().empty() ? udp_config.udp_listener_name() : "";

The idea is to figure out the final listener_name at one place

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

// Network::FilterChain
const Network::TransportSocketFactory* transportSocketFactory() const override { return nullptr; }

Ssl::ContextConfig* tlsContextConfig() const { return tls_context_config_.get(); }
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Where is this one used?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not used yet. Will be used to create EnvoyQuicProofSource which is a fake one now.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can this return a const reference or just a reference?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes

@mattklein123
Copy link
Member

@danzh2010 friendly request to please update the PR description to something more descriptive. I will take a look when the review is a bit further along. Thank you!

/wait

Signed-off-by: Dan Zhang <danzh@google.com>
Signed-off-by: Dan Zhang <danzh@google.com>
Signed-off-by: Dan Zhang <danzh@google.com>
@danzh2010
Copy link
Contributor Author

/retest

@repokitteh-read-only
Copy link

🔨 rebuilding ci/circleci: asan (failed build)

🐱

Caused by: a #8091 (comment) was created by @danzh2010.

see: more, trace.

@danzh2010 danzh2010 changed the title Filterchain quiche: implement QUIC specific FilterChain Sep 9, 2019
@danzh2010
Copy link
Contributor Author

I updated PR description. It's good for another around of review.

lambdai
lambdai previously approved these changes Sep 9, 2019
Copy link
Contributor

@lambdai lambdai left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for the update!

@danzh2010
Copy link
Contributor Author

/assign @goaway

*/
virtual const TransportSocketFactory& transportSocketFactory() const PURE;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think envoy standard might be using optional, but I'm a fan of pointers so we'll see what Matt says ;-)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

an optional reference wrapper is "nicer" but I'm fine either way.

@@ -26,6 +26,8 @@ void ConnectionHandlerImpl::addListener(Network::ListenerConfig& config) {
} else {
ASSERT(socket_type == Network::Address::SocketType::Datagram,
"Only datagram/stream listener supported");
RELEASE_ASSERT(config.udpListenerFactory() != nullptr,
"UDP listner factory is not initialized.");
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

listener

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you add a TODO to make this a proper config check?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This won't be a config error, we will create UdpListenerFactory anyway in ListenerImpl(). If not specified in config, we create the raw UDP listener factory.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you can remove this assert then? It would crash in an obvious way on the next line.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you didn't remove it, just converted it. Would it actually fail under createActiveUdpListener or is this assert required?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ListenerConfig.udpListenerFactory() is populated to default RawUdp listener if not specified in config. So this ASSERT is to check we actually have logic to do this before reaching here. Otherwise below config.udpListenerFactory()->createActiveUdpListener(*this, dispatcher_, logger_, config); would crash because of accessing members of nullptr.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gotcha. If we plan to keep it in, let's at least change
listner -> listener

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

@@ -50,7 +50,8 @@ bool ipFamilySupported(int domain) {
Api::OsSysCalls& os_sys_calls = Api::OsSysCallsSingleton::get();
const Api::SysCallIntResult result = os_sys_calls.socket(domain, SOCK_STREAM, 0);
if (result.rc_ >= 0) {
RELEASE_ASSERT(os_sys_calls.close(result.rc_).rc_ == 0, "");
RELEASE_ASSERT(os_sys_calls.close(result.rc_).rc_ == 0,
absl::StrCat("fail to close fd ", result.rc_));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for adding details here.

maybe -> "failed to close fd: response code " ?
since right now it looks like you're printing the fd, not the error code.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

parent_.server_.random(), parent_.server_.stats(), parent_.server_.singletonManager(),
parent_.server_.threadLocal(), validation_visitor, parent_.server_.api());
factory_context.setInitManager(initManager());
ListenerFilterChainFactoryBuilderPtr builder =
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if I'm not lost in braces, we'll now do work for raw UDP that we didn't used to. Is that correct, and is it intentional?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, we are initializing network filters for raw UDP too. I think it doesn't hurt as long as they don't config any network filters but leave them unused.

Copy link
Member

@mattklein123 mattklein123 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Flushing out a few questions/comments from a first pass. Thank you!

/wait

*/
virtual const TransportSocketFactory& transportSocketFactory() const PURE;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

an optional reference wrapper is "nicer" but I'm fine either way.


virtual ContextConfigPtr
createSslContextConfig(const Protobuf::Message& config,
Server::Configuration::TransportSocketFactoryContext& context) PURE;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's a little strange to create a transport socket factory context when making an SSL context. Should we just define whatever context we need in this file and then have TransportSocketFactoryContext derive from that if needed?

namespace Tls {

/**
* A factory to create server side Tls context config from a protobuf.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Tls/TLS here and elsewhere in comments.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

const Protobuf::Message& config,
Server::Configuration::TransportSocketFactoryContext& context) {
return std::make_unique<ServerContextConfigImpl>(
dynamic_cast<const envoy::api::v2::auth::DownstreamTlsContext&>(config), context);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we universally do a dynamic_cast here why not just pass this type as a parameter?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

createSslContextConfig() is public interface which will also be used on client side which should downcast message to UpstreamTlsContext.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@lizan any thoughts on this one? I would need to sit down and page in all of this code but something seems off here. Maybe this is related to your other comment.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

at interface level this seems fine, though at higher level I think this should just be a TransportSocketConfigFactory

@@ -26,6 +26,8 @@ void ConnectionHandlerImpl::addListener(Network::ListenerConfig& config) {
} else {
ASSERT(socket_type == Network::Address::SocketType::Datagram,
"Only datagram/stream listener supported");
RELEASE_ASSERT(config.udpListenerFactory() != nullptr,
"UDP listner factory is not initialized.");
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you add a TODO to make this a proper config check?

// Network::FilterChain
const Network::TransportSocketFactory* transportSocketFactory() const override { return nullptr; }

Ssl::ContextConfig* tlsContextConfig() const { return tls_context_config_.get(); }
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can this return a const reference or just a reference?

.createActiveUdpListenerFactory(config.has_udp_listener_config()
? config.udp_listener_config()
: envoy::api::v2::listener::UdpListenerConfig());
} catch (EnvoyException ex) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

const EnvoyException&

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

? config.udp_listener_config()
: envoy::api::v2::listener::UdpListenerConfig());
} catch (EnvoyException ex) {
// TODO(danzh): add implementation for quic_listener and do not catch
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm confused. Why do we need/want this fallback?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just because we don't have QuicListenerFactoryConfig registered yet. Once PR #7896 gets in, this is not needed.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we just assert this then or wait until the other one is merged?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll wait for the other PR merge first and change this line. It is as such now to make listener mgr test pass in a meaningful way.

const ::envoy::api::v2::listener::FilterChain& filter_chain) const {
// Initialize filter chain for QUIC.
if (!filter_chain.has_tls_context()) {
return nullptr;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be a config error?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes. Is it okay to lead to connection close? Or what's the best way of handling such error?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should check this during config load and fail the config, it should be an assert at this point IMO.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With removal of QuicFilterChain, this builder can be unified with tcp one too. This check no longer apply.

return nullptr;
}
auto& tls_context_factory =
Config::Utility::getAndCheckFactory<Ssl::ContextConfigFactory>("downstream_tls_context");
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"downstream_tls_context" should be a constant somewhere.

Can you talk a bit more about why we need to load the factory on this way vs. just directly instantiating it? It's a little confusing. I suppose this is due to pluggable TLS implementations?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, all the registered class work in this PR is just to make envoy core code not depends on TLS extensions.

Signed-off-by: Dan Zhang <danzh@google.com>
Signed-off-by: Dan Zhang <danzh@google.com>
Signed-off-by: Dan Zhang <danzh@google.com>
Signed-off-by: Dan Zhang <danzh@google.com>
Signed-off-by: Dan Zhang <danzh@google.com>
@@ -120,6 +120,9 @@ class ActiveUdpListenerFactory {
virtual ConnectionHandler::ActiveListenerPtr
createActiveUdpListener(ConnectionHandler& parent, Event::Dispatcher& disptacher,
spdlog::logger& logger, Network::ListenerConfig& config) const PURE;

// @return true if the protocol above UDP doesn't form a connection.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

super nitty but /** .. */ style comments
Also maybe
"if the protocol above UDP doesn't form a connection." -> "If the UDP passing through listener doesn't form stateful connections" ?

might even be nicer to invert and have isStatefulConnection() but maybe that's just me.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

@@ -26,6 +26,8 @@ void ConnectionHandlerImpl::addListener(Network::ListenerConfig& config) {
} else {
ASSERT(socket_type == Network::Address::SocketType::Datagram,
"Only datagram/stream listener supported");
RELEASE_ASSERT(config.udpListenerFactory() != nullptr,
"UDP listner factory is not initialized.");
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you didn't remove it, just converted it. Would it actually fail under createActiveUdpListener or is this assert required?

Signed-off-by: Dan Zhang <danzh@google.com>
@mattklein123
Copy link
Member

Let's merge master and then we can get back to reviewing this one.

/wait

Signed-off-by: Dan Zhang <danzh@google.com>
@danzh2010
Copy link
Contributor Author

Let's merge master and then we can get back to reviewing this one.

/wait

Done. PTAL

@danzh2010 danzh2010 changed the title quiche: implement QUIC specific FilterChain quiche: implement QUIC specific TransportSocketFactory for TLS context Sep 16, 2019
Signed-off-by: Dan Zhang <danzh@google.com>
Copy link
Contributor

@alyssawilk alyssawilk left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looking good. just one nit left on my end.

@@ -26,6 +26,8 @@ void ConnectionHandlerImpl::addListener(Network::ListenerConfig& config) {
} else {
ASSERT(socket_type == Network::Address::SocketType::Datagram,
"Only datagram/stream listener supported");
RELEASE_ASSERT(config.udpListenerFactory() != nullptr,
"UDP listner factory is not initialized.");
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gotcha. If we plan to keep it in, let's at least change
listner -> listener

Signed-off-by: Dan Zhang <danzh@google.com>
Copy link
Member

@mattklein123 mattklein123 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, LGTM with some small comments.

/wait

// server and client.
class QuicTransportSocketFactoryBase : public Network::TransportSocketFactory {
public:
~QuicTransportSocketFactoryBase() override = default;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: delete

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

Ssl::ServerContextConfig& serverContextConfig() const { return *config_; }

private:
Ssl::ServerContextConfigPtr config_;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: const here and below

class QuicTransportSocketConfigFactory
: public virtual Server::Configuration::TransportSocketConfigFactory {
public:
~QuicTransportSocketConfigFactory() override = default;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: delete

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

@@ -31,8 +31,8 @@ void ConnectionHandlerImpl::addListener(Network::ListenerConfig& config) {
if (socket_type == Network::Address::SocketType::Stream) {
listener = std::make_unique<ActiveTcpListener>(*this, config);
} else {
ASSERT(socket_type == Network::Address::SocketType::Datagram,
"Only datagram/stream listener supported");
RELEASE_ASSERT(config.udpListenerFactory() != nullptr,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The previous conversation was lost, but this should never happen due to being checked via config elsewhere, right? If so can we make this a normal ASSERT?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Early on ListenerImpl() will always create some sort of ActiveUdpListener, if not configured, ActiveRawUdpListener will be created. So yes here ASSERT is sufficient.

} else if (udp_listener_factory_ != nullptr &&
!udp_listener_factory_->isTransportConnectionless()) {
for (auto& filter_chain : config.filter_chains()) {
// Early fail if any filter chain doesn't have transport socket configured.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm a little confused by this. If the underlying filter chain builder implementation requires a transport socket (like for QUIC) can't it just fail in that code? IMO that would be more clear? WDYT?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

filter chain builder doesn't know if this listener is UDP or TCP, nor does it know about if it's connectionless or not. For non-Quic listener, it's empty transport_socket is allowed, and in that case a factory for RawBufferSocket will be created..

@@ -13,6 +13,7 @@ namespace Server {
class UdpListenerNameValues {
public:
const std::string RawUdp = "raw_udp_listener";
const std::string Quic = "quic_listener";
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

revert?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

alyssawilk
alyssawilk previously approved these changes Sep 17, 2019
Copy link
Contributor

@alyssawilk alyssawilk left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, modulo Matt's comments

Signed-off-by: Dan Zhang <danzh@google.com>
@danzh2010
Copy link
Contributor Author

Ping?

Copy link
Member

@mattklein123 mattklein123 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's ship and iterate! Well done. :)

@mattklein123 mattklein123 merged commit 272ee70 into envoyproxy:master Sep 19, 2019
danzh2010 added a commit to danzh2010/envoy that referenced this pull request Sep 24, 2019
danzh2010 added a commit to danzh2010/envoy that referenced this pull request Oct 4, 2019
danzh2010 added a commit to danzh2010/envoy that referenced this pull request Oct 4, 2019
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

7 participants