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

websocket: tunneling websockets (and upgrades in general) over H2 #4188

Merged
merged 6 commits into from
Aug 28, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions api/envoy/api/v2/core/protocol.proto
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,20 @@ message Http2ProtocolOptions {
// window. Currently, this has the same minimum/maximum/default as *initial_stream_window_size*.
google.protobuf.UInt32Value initial_connection_window_size = 4
[(validate.rules).uint32 = {gte: 65535, lte: 2147483647}];

// [#not-implemented-hide:] Hiding until nghttp2 has native support.
//
// Allows proxying Websocket and other upgrades over H2 connect.
//
// THIS IS NOT SAFE TO USE IN PRODUCTION
//
// This currently works via disabling all HTTP sanity checks for H2 traffic
// which is a much larger hammer than we'd like to use. Eventually when
// https://github.com/nghttp2/nghttp2/issues/1181 is resolved, this will work
// with simply enabling CONNECT for H2. This may require some tweaks to the
// headers making pre-CONNECT-support proxying not backwards compatible with
// post-CONNECT-support proxying.
bool allow_connect = 5;
}

// [#not-implemented-hide:]
Expand Down
23 changes: 23 additions & 0 deletions docs/root/intro/arch_overview/websocket.rst
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,29 @@ one can set up custom
for the given upgrade type, up to and including only using the router filter to send the WebSocket
data upstream.

Handling H2 hops (implementation in progress)
---------------------------------------------

Envoy currently has an alpha implementation of tunneling websockets over H2 streams for deployments
that prefer a uniform H2 mesh throughout, for example, for a deployment of the form:

[Client] ---- HTTP/1.1 ---- [Front Envoy] ---- HTTP/2 ---- [Sidecar Envoy ---- H1 ---- App]

In this case, if a client is for example using WebSocket, we want the Websocket to arive at the
upstream server functionally intact, which means it needs to traverse the HTTP/2 hop.

TODO(alyssawilk) copy the warnings from the config here, or just land the docs when we unhide.

This is accomplished via
`extended CONNECT <https://tools.ietf.org/html/draft-mcmanus-httpbis-h2-websockets>`_ support. The
WebSocket request will be transformed into an HTTP/2 CONNECT stream, with :protocol header
indicating the original upgrade, traverse the HTTP/2 hop, and be downgraded back into an HTTP/1
WebSocket Upgrade. This same Upgrade-CONNECT-Upgrade transformation will be performed on any
HTTP/2 hop, with the documented flaw that the HTTP/1.1 method is always assumed to be GET.
Non-WebSocket upgrades are allowed to use any valid HTTP method (i.e. POST) and the current
upgrade/downgrade mechanism will drop the original method and transform the Upgrade request to
a GET method on the final Envoy-Upstream hop.

Old style WebSocket support
===========================

Expand Down
3 changes: 3 additions & 0 deletions include/envoy/http/codec.h
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,7 @@ struct Http2Settings {
uint32_t max_concurrent_streams_{DEFAULT_MAX_CONCURRENT_STREAMS};
uint32_t initial_stream_window_size_{DEFAULT_INITIAL_STREAM_WINDOW_SIZE};
uint32_t initial_connection_window_size_{DEFAULT_INITIAL_CONNECTION_WINDOW_SIZE};
bool allow_connect_{DEFAULT_ALLOW_CONNECT};

// disable HPACK compression
static const uint32_t MIN_HPACK_TABLE_SIZE = 0;
Expand Down Expand Up @@ -241,6 +242,8 @@ struct Http2Settings {
// our default connection-level window also equals to our stream-level
static const uint32_t DEFAULT_INITIAL_CONNECTION_WINDOW_SIZE = 256 * 1024 * 1024;
static const uint32_t MAX_INITIAL_CONNECTION_WINDOW_SIZE = (1U << 31) - 1;
// By default both nghttp2 and Envoy do not allow CONNECT over H2.
static const bool DEFAULT_ALLOW_CONNECT = false;
};

/**
Expand Down
1 change: 1 addition & 0 deletions include/envoy/http/header_map.h
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,7 @@ class HeaderEntry {
HEADER_FUNC(Origin) \
HEADER_FUNC(OtSpanContext) \
HEADER_FUNC(Path) \
HEADER_FUNC(Protocol) \
HEADER_FUNC(ProxyConnection) \
HEADER_FUNC(Referer) \
HEADER_FUNC(RequestId) \
Expand Down
2 changes: 1 addition & 1 deletion source/common/http/conn_manager_impl.cc
Original file line number Diff line number Diff line change
Expand Up @@ -576,7 +576,7 @@ void ConnectionManagerImpl::ActiveStream::decodeHeaders(HeaderMapPtr&& headers,

// Modify the downstream remote address depending on configuration and headers.
request_info_.setDownstreamRemoteAddress(ConnectionManagerUtility::mutateRequestHeaders(
*request_headers_, protocol, connection_manager_.read_callbacks_->connection(),
*request_headers_, connection_manager_.read_callbacks_->connection(),
connection_manager_.config_, *snapped_route_config_, connection_manager_.random_generator_,
connection_manager_.runtime_, connection_manager_.local_info_));
ASSERT(request_info_.downstreamRemoteAddress() != nullptr);
Expand Down
4 changes: 2 additions & 2 deletions source/common/http/conn_manager_utility.cc
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,13 @@ namespace Envoy {
namespace Http {

Network::Address::InstanceConstSharedPtr ConnectionManagerUtility::mutateRequestHeaders(
Http::HeaderMap& request_headers, Protocol protocol, Network::Connection& connection,
Http::HeaderMap& request_headers, Network::Connection& connection,
ConnectionManagerConfig& config, const Router::Config& route_config,
Runtime::RandomGenerator& random, Runtime::Loader& runtime,
const LocalInfo::LocalInfo& local_info) {
// If this is a Upgrade request, do not remove the Connection and Upgrade headers,
// as we forward them verbatim to the upstream hosts.
if (protocol == Protocol::Http11 && Utility::isUpgrade(request_headers)) {
if (Utility::isUpgrade(request_headers)) {
Copy link
Member

Choose a reason for hiding this comment

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

why remove the check? the extended connect protocol does not use any upgrade headers from what I see.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Because as docced up, for filter chain consistency, we consistently transform upgrades to H1 style headers at the codec layer. If there's
client - H1 upgrade - Envoy - H2 hop - another Envoy - H2 hop - something else
the Envoy sending and receiving the upgrade in H2 form will still see HTTP/1.1 style upgrade headers at the http connection manager and in the HTTP filters.

Copy link
Contributor

Choose a reason for hiding this comment

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

Previously this check was excluding Http2 and Http10. If I'm understanding this correctly, you want to allow Http2 here. Do we need to still exclude Http10?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

We definitely need to include HTTP/2 here given the way we're doing upgrades in Envoy. Upgrade is an HTTP/1.1 header so arguably we could protocol checks back in and explicitly disallow something we don't expect folks to do. Given that 1.0 support is off by default and I don't think anyone's trying to do 1.0 upgrades I lean towards not worrying about it but I'm happy to add it back if you'd prefer!

Copy link
Contributor

Choose a reason for hiding this comment

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

Ok, I'm fine with it as-is.

// The current WebSocket implementation re-uses the HTTP1 codec to send upgrade headers to
// the upstream host. This adds the "transfer-encoding: chunked" request header if the stream
// has not ended and content-length does not exist. In HTTP1.1, if transfer-encoding and
Expand Down
8 changes: 4 additions & 4 deletions source/common/http/conn_manager_utility.h
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,10 @@ class ConnectionManagerUtility {
* existence of the x-forwarded-for header. Again see the method for more details.
*/
static Network::Address::InstanceConstSharedPtr
mutateRequestHeaders(Http::HeaderMap& request_headers, Protocol protocol,
Network::Connection& connection, ConnectionManagerConfig& config,
const Router::Config& route_config, Runtime::RandomGenerator& random,
Runtime::Loader& runtime, const LocalInfo::LocalInfo& local_info);
mutateRequestHeaders(Http::HeaderMap& request_headers, Network::Connection& connection,
ConnectionManagerConfig& config, const Router::Config& route_config,
Runtime::RandomGenerator& random, Runtime::Loader& runtime,
const LocalInfo::LocalInfo& local_info);

static void mutateResponseHeaders(Http::HeaderMap& response_headers,
const Http::HeaderMap* request_headers, const std::string& via);
Expand Down
2 changes: 2 additions & 0 deletions source/common/http/headers.h
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ class HeaderValues {
const LowerCaseString Origin{"origin"};
const LowerCaseString OtSpanContext{"x-ot-span-context"};
const LowerCaseString Path{":path"};
const LowerCaseString Protocol{":protocol"};
const LowerCaseString ProxyConnection{"proxy-connection"};
const LowerCaseString Referer{"referer"};
const LowerCaseString RequestId{"x-request-id"};
Expand Down Expand Up @@ -158,6 +159,7 @@ class HeaderValues {
} ExpectValues;

struct {
const std::string Connect{"CONNECT"};
const std::string Get{"GET"};
const std::string Head{"HEAD"};
const std::string Post{"POST"};
Expand Down
26 changes: 21 additions & 5 deletions source/common/http/http2/codec_impl.cc
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@
#include "common/http/codes.h"
#include "common/http/exception.h"
#include "common/http/headers.h"
#include "common/http/utility.h"

namespace Envoy {
namespace Http {
Expand Down Expand Up @@ -90,7 +89,15 @@ void ConnectionImpl::StreamImpl::encode100ContinueHeaders(const HeaderMap& heade

void ConnectionImpl::StreamImpl::encodeHeaders(const HeaderMap& headers, bool end_stream) {
std::vector<nghttp2_nv> final_headers;
buildHeaders(final_headers, headers);

Http::HeaderMapPtr modified_headers;
if (Http::Utility::isUpgrade(headers)) {
modified_headers = std::make_unique<Http::HeaderMapImpl>(headers);
transformUpgradeFromH1toH2(*modified_headers);
buildHeaders(final_headers, *modified_headers);
} else {
buildHeaders(final_headers, headers);
}

nghttp2_data_provider provider;
if (!end_stream) {
Expand Down Expand Up @@ -151,6 +158,11 @@ void ConnectionImpl::StreamImpl::pendingRecvBufferLowWatermark() {
readDisable(false);
}

void ConnectionImpl::StreamImpl::decodeHeaders() {
maybeTransformUpgradeFromH2ToH1();
decoder_->decodeHeaders(std::move(headers_), remote_end_stream_);
}

void ConnectionImpl::StreamImpl::pendingSendBufferHighWatermark() {
ENVOY_CONN_LOG(debug, "send buffer over limit ", parent_.connection_);
ASSERT(!pending_send_buffer_high_watermark_called_);
Expand Down Expand Up @@ -366,13 +378,13 @@ int ConnectionImpl::onFrameReceived(const nghttp2_frame* frame) {
ASSERT(!stream->remote_end_stream_);
stream->decoder_->decode100ContinueHeaders(std::move(stream->headers_));
} else {
stream->decoder_->decodeHeaders(std::move(stream->headers_), stream->remote_end_stream_);
stream->decodeHeaders();
}
break;
}

case NGHTTP2_HCAT_REQUEST: {
stream->decoder_->decodeHeaders(std::move(stream->headers_), stream->remote_end_stream_);
stream->decodeHeaders();
break;
}

Expand Down Expand Up @@ -401,7 +413,7 @@ int ConnectionImpl::onFrameReceived(const nghttp2_frame* frame) {
// start out with. In this case, raise as headers. nghttp2 message checking guarantees
// proper flow here.
ASSERT(!stream->headers_->Status() || stream->headers_->Status()->value() != "100");
stream->decoder_->decodeHeaders(std::move(stream->headers_), stream->remote_end_stream_);
stream->decodeHeaders();
}
}

Expand Down Expand Up @@ -734,6 +746,10 @@ ConnectionImpl::Http2Options::Http2Options(const Http2Settings& http2_settings)
if (http2_settings.hpack_table_size_ != NGHTTP2_DEFAULT_HEADER_TABLE_SIZE) {
nghttp2_option_set_max_deflate_dynamic_table_size(options_, http2_settings.hpack_table_size_);
}
if (http2_settings.allow_connect_) {
// TODO(alyssawilk) change to ENABLE_CONNECT_PROTOCOL when it's available.
nghttp2_option_set_no_http_messaging(options_, 1);
}
}

ConnectionImpl::Http2Options::~Http2Options() { nghttp2_option_del(options_); }
Expand Down
26 changes: 26 additions & 0 deletions source/common/http/http2/codec_impl.h
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
#include "common/common/logger.h"
#include "common/http/codec_helper.h"
#include "common/http/header_map_impl.h"
#include "common/http/utility.h"

#include "absl/types/optional.h"
#include "nghttp2/nghttp2.h"
Expand Down Expand Up @@ -187,6 +188,13 @@ class ConnectionImpl : public virtual Connection, protected Logger::Loggable<Log
// I don't fully understand.
static const uint64_t MAX_HEADER_SIZE = 63 * 1024;

// Does any necessary WebSocket/Upgrade conversion, then passes the headers
// to the decoder_.
void decodeHeaders();

virtual void transformUpgradeFromH1toH2(HeaderMap& headers) PURE;
virtual void maybeTransformUpgradeFromH2ToH1() PURE;

bool buffers_overrun() const { return read_disable_count_ > 0; }

ConnectionImpl& parent_;
Expand Down Expand Up @@ -224,6 +232,16 @@ class ConnectionImpl : public virtual Connection, protected Logger::Loggable<Log
// StreamImpl
void submitHeaders(const std::vector<nghttp2_nv>& final_headers,
nghttp2_data_provider* provider) override;
void transformUpgradeFromH1toH2(HeaderMap& headers) override {
upgrade_type_ = headers.Upgrade()->value().c_str();
Http::Utility::transformUpgradeRequestFromH1toH2(headers);
}
void maybeTransformUpgradeFromH2ToH1() override {
if (!upgrade_type_.empty() && headers_->Status()) {
Http::Utility::transformUpgradeResponseFromH2toH1(*headers_, upgrade_type_);
}
}
std::string upgrade_type_;
};

/**
Expand All @@ -235,6 +253,14 @@ class ConnectionImpl : public virtual Connection, protected Logger::Loggable<Log
// StreamImpl
void submitHeaders(const std::vector<nghttp2_nv>& final_headers,
nghttp2_data_provider* provider) override;
void transformUpgradeFromH1toH2(HeaderMap& headers) override {
Http::Utility::transformUpgradeResponseFromH1toH2(headers);
}
void maybeTransformUpgradeFromH2ToH1() override {
if (Http::Utility::isH2UpgradeRequest(*headers_)) {
Http::Utility::transformUpgradeRequestFromH2toH1(*headers_);
}
}
};

ConnectionImpl* base() { return this; }
Expand Down
51 changes: 51 additions & 0 deletions source/common/http/utility.cc
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,12 @@ bool Utility::isUpgrade(const HeaderMap& headers) {
Http::Headers::get().ConnectionValues.Upgrade.c_str()));
}

bool Utility::isH2UpgradeRequest(const HeaderMap& headers) {
return headers.Method() &&
headers.Method()->value().c_str() == Http::Headers::get().MethodValues.Connect &&
headers.Protocol() && !headers.Protocol()->value().empty();
}

bool Utility::isWebSocketUpgradeRequest(const HeaderMap& headers) {
return (isUpgrade(headers) && (0 == StringUtil::caseInsensitiveCompare(
headers.Upgrade()->value().c_str(),
Expand All @@ -227,6 +233,7 @@ Utility::parseHttp2Settings(const envoy::api::v2::core::Http2ProtocolOptions& co
ret.initial_connection_window_size_ =
PROTOBUF_GET_WRAPPED_OR_DEFAULT(config, initial_connection_window_size,
Http::Http2Settings::DEFAULT_INITIAL_CONNECTION_WINDOW_SIZE);
ret.allow_connect_ = config.allow_connect();
return ret;
}

Expand Down Expand Up @@ -392,5 +399,49 @@ std::string Utility::queryParamsToString(const QueryParams& params) {
return out;
}

void Utility::transformUpgradeRequestFromH1toH2(HeaderMap& headers) {
ASSERT(Utility::isUpgrade(headers));

const HeaderString& upgrade = headers.Upgrade()->value();
headers.insertMethod().value().setReference(Http::Headers::get().MethodValues.Connect);
headers.insertProtocol().value().setCopy(upgrade.c_str(), upgrade.size());
headers.removeUpgrade();
headers.removeConnection();
}

void Utility::transformUpgradeResponseFromH1toH2(HeaderMap& headers) {
if (getResponseStatus(headers) == 101) {
headers.insertStatus().value().setInteger(200);
}
headers.removeUpgrade();
headers.removeConnection();
}

void Utility::transformUpgradeRequestFromH2toH1(HeaderMap& headers) {
ASSERT(Utility::isH2UpgradeRequest(headers));

Copy link
Member

Choose a reason for hiding this comment

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

Yeah, this definitely needs to be qualified to websocket alone. Coz for other protos, we are likely to initiate outbound TCP proxy connections, where we simply strip the H2 framing

Copy link
Contributor Author

Choose a reason for hiding this comment

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

When you say initiate outbound TCP proxy connections, what do you mean? you can't strip H2 framing over an H2 connection. I can definitely think of other non-websocket upgrades we're going to do over H2 extended connect, and I'd prefer a consistent mechanism for handling them, so I'd prefer to not limit this to websocket.

Keep in mind, these are configurable on a per-upgrade basis. We can always add a different type of upgrade, or configure an upgrade for different behavior, to extend Envoy functionality later without changing this code at all. Basically since the mechanism for this type of support and any new type of support are all done via config, I don't think having flexibility in this implementation will come back to bite us.

Copy link
Member

Choose a reason for hiding this comment

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

may be I am missing something here. I am envisioning being able to tunnel mySQL connections over H2 streams to the envoy on the other end, and having the server Envoy hand over a plain tcp connection to the mysql container in the same pod.

The client-side envoy would receive the inbound connection over a TCP_PROXY, and and shove the bytes into a H2 stream and send to an upstream Envoy.

Now, if there are multiple envoys between the client and server side envoys, then these intermediaries are going to be receiving a H2 stream with extended CONNECT header, and have to forward the same H2 stream to the next envoy.

Reading this code, I got the impression that with websockets, an intermediary envoy would strip the H2 framing and convert the extended connect/upgrade into standard H1 upgrade for websocket. Then when handing over the same connection to the cluster manager, the codec will re-wrap the H1 upgrade headers back into a H2 stream and forward. And my concern here (and elsewhere) was this H2-->H1 unwrapping where there is an implicit assumption that these protocols are all somehow h1 friendly.

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 think if we're tunneling upgrades, the payload has to be HTTP2/HTTP1 friendly, today.

In the long run, I think we're going to have code which both encaps TCP in an HTTP/H2 upgrade, and decaps HTTP/H2 upgrades back to TCP. We don't have that code yet. I think we're absolutely going to have it, but the point of this PR is to allow Envoy to pass along encaped data, not to do the decap

Our main use case for this is multiplexing transparent proxying. If we have a frontline envoy without certs, it can do TCP termination, forward the payload over a pre-warmed H2 hop, demux it at the far end, and then treat each H2 stream as if it were a new incoming TCP connection (which will often have TLS/H2 payload, requiring another layer of demuxing). Some second line Envoys might be configured to "foward raw_tcp upgrades untouched" and some might be configured to "demux and handle raw_tcp upgrades". I do think there's an advantage to allowing multiple types of upgrade (assuming the eventual nghttp2 implementation allows) so the config can do one thing for mysql_upgrades and a different thing for raw_tcp upgrades. It would allow the frontline Envoy which is doing the encap in HTTP1/HTTP2 to specify the upgrade type, and then the next Envoy in the chain could be configured to pass it along or terminate. But all this PR does is allow the upgrades to survive the H2 hop.

const HeaderString& protocol = headers.Protocol()->value();
headers.insertMethod().value().setReference(Http::Headers::get().MethodValues.Get);
headers.insertUpgrade().value().setCopy(protocol.c_str(), protocol.size());
headers.insertConnection().value().setReference(Http::Headers::get().ConnectionValues.Upgrade);
headers.removeProtocol();
if (headers.ContentLength() == nullptr) {
headers.insertTransferEncoding().value().setReference(
Http::Headers::get().TransferEncodingValues.Chunked);
}
}

void Utility::transformUpgradeResponseFromH2toH1(HeaderMap& headers, absl::string_view upgrade) {
if (getResponseStatus(headers) == 200) {
headers.insertUpgrade().value().setCopy(upgrade.data(), upgrade.size());
headers.insertConnection().value().setReference(Http::Headers::get().ConnectionValues.Upgrade);
if (headers.ContentLength() == nullptr) {
headers.insertTransferEncoding().value().setReference(
Http::Headers::get().TransferEncodingValues.Chunked);
}
headers.insertStatus().value().setInteger(101);
}
}

} // namespace Http
} // namespace Envoy
33 changes: 33 additions & 0 deletions source/common/http/utility.h
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,11 @@ uint64_t getResponseStatus(const HeaderMap& headers);
*/
bool isUpgrade(const HeaderMap& headers);

/**
* @return true if this is a CONNECT request with a :protocol header present, false otherwise.
*/
bool isH2UpgradeRequest(const HeaderMap& headers);

/**
* Determine whether this is a WebSocket Upgrade request.
* This function returns true if the following HTTP headers and values are present:
Expand Down Expand Up @@ -198,6 +203,34 @@ MessagePtr prepareHeaders(const ::envoy::api::v2::core::HttpUri& http_uri);
*/
std::string queryParamsToString(const QueryParams& query_params);

/**
* Transforms the supplied headers from an HTTP/1 Upgrade request to an H2 style upgrade.
* Changes the method to connection, moves the Upgrade to a :protocol header,
* @param headers the headers to convert.
*/
void transformUpgradeRequestFromH1toH2(HeaderMap& headers);

/**
* Transforms the supplied headers from an HTTP/1 Upgrade response to an H2 style upgrade response.
* Changes the 101 upgrade response to a 200 for the CONNECT response.
* @param headers the headers to convert.
*/
void transformUpgradeResponseFromH1toH2(HeaderMap& headers);

/**
* Transforms the supplied headers from an H2 "CONNECT"-with-:protocol-header to an HTTP/1 style
* Upgrade response.
* @param headers the headers to convert.
*/
void transformUpgradeRequestFromH2toH1(HeaderMap& headers);

/**
* Transforms the supplied headers from an H2 "CONNECT success" to an HTTP/1 style Upgrade response.
* The caller is responsible for ensuring this only happens on upgraded streams.
* @param headers the headers to convert.
*/
void transformUpgradeResponseFromH2toH1(HeaderMap& headers, absl::string_view upgrade);

} // namespace Utility
} // namespace Http
} // namespace Envoy
Loading