Skip to content

Commit

Permalink
Merge pull request emissary-ingress#4246 from emissary-ingress/lausti…
Browse files Browse the repository at this point in the history
…n/envoy-1.22-http3

[v3.0] add downstream HTTP/3 support
  • Loading branch information
Lance Austin authored Jun 22, 2022
2 parents d78891f + 6e6f71b commit ce9dc45
Show file tree
Hide file tree
Showing 13 changed files with 527 additions and 131 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,10 @@ it will be removed; but as it won't be user-visible this isn't considered a brea
`grpc_stats.services` or `grpc_stats.all_methods` would result in crashing. Now it behaves as if
`grpc_stats.all_methods=false`.

- Feature: With the ugprade to Envoy 1.22, Emissary-ingress can now be configured to listen for
HTTP/3 connections using QUIC and the UDP network protocol. It currently only supports for
connections between downstream clients and Emissary-ingress.

## [2.3.1] June 09, 2022
[2.3.1]: https://github.com/emissary-ingress/emissary/compare/v2.3.0...v2.3.1

Expand Down
6 changes: 6 additions & 0 deletions docs/releaseNotes.yml
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,12 @@ items:
Previously setting <code>grpc_stats</code> in the <code>ambassador</code> <code>Module</code>
without setting either <code>grpc_stats.services</code> or <code>grpc_stats.all_methods</code>
would result in crashing. Now it behaves as if <code>grpc_stats.all_methods=false</code>.
- title: Downstream HTTP/3 support
type: feature
body: >-
With the ugprade to Envoy 1.22, $productName$ can now be configured to listen for HTTP/3
connections using QUIC and the UDP network protocol. It currently only supports for connections
between downstream clients and $productName$.
- version: 2.3.1
date: '2022-06-09'
notes:
Expand Down
1 change: 1 addition & 0 deletions pkg/ambex/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ import (
_ "github.com/emissary-ingress/emissary/v3/pkg/api/envoy/extensions/filters/http/router/v3"
_ "github.com/emissary-ingress/emissary/v3/pkg/api/envoy/extensions/filters/network/http_connection_manager/v3"
_ "github.com/emissary-ingress/emissary/v3/pkg/api/envoy/extensions/filters/network/tcp_proxy/v3"
_ "github.com/emissary-ingress/emissary/v3/pkg/api/envoy/extensions/transport_sockets/quic/v3"
v3cluster "github.com/emissary-ingress/emissary/v3/pkg/api/envoy/service/cluster/v3"
v3discovery "github.com/emissary-ingress/emissary/v3/pkg/api/envoy/service/discovery/v3"
v3endpoint "github.com/emissary-ingress/emissary/v3/pkg/api/envoy/service/endpoint/v3"
Expand Down
87 changes: 76 additions & 11 deletions python/ambassador/envoy/v3/v3listener.py
Original file line number Diff line number Diff line change
Expand Up @@ -124,11 +124,13 @@ def __init__(self, config: 'V3Config', irlistener: IRListener) -> None:
super().__init__()

self.config = config
self.http3_enabled = irlistener.http3_enabled
self.socket_protocol = irlistener.socket_protocol
self.bind_address = irlistener.bind_address
self.port = irlistener.port
self.bind_to = f"{self.bind_address}-{self.port}"
self.bind_to = irlistener.bind_to()

bindstr = f"-{self.bind_address}" if (self.bind_address != "0.0.0.0") else ""
bindstr = f"-{irlistener.socket_protocol.lower()}-{self.bind_address}" if (self.bind_address != "0.0.0.0") else ""
self.name = irlistener.name or f"ambassador-listener{bindstr}-{self.port}"

self.use_proxy_proto = False
Expand Down Expand Up @@ -177,9 +179,13 @@ def __init__(self, config: 'V3Config', irlistener: IRListener) -> None:
# listener is OK with TLS-y things like a termination context, SNI,
# etc.
self._tls_ok = True
self.listener_filters.append({
'name': 'envoy.filters.listener.tls_inspector'
})

## When UDP we assume it is http/3 listener and configured for quic which has TLS built into the protocol
## therefore, we only need to add this when socket_protocol is TCP
if self.isProtocolTCP():
self.listener_filters.append({
'name': 'envoy.filters.listener.tls_inspector'
})

if proto == "TCP":
# TCP doesn't require any specific listener filters, but it
Expand All @@ -193,7 +199,6 @@ def __init__(self, config: 'V3Config', irlistener: IRListener) -> None:
# ...and make sure the group in question wants the same bind
# address that we do.
if irgroup.bind_to() != self.bind_to:
# self.config.ir.logger.debug("V3Listener %s: skip TCPMappingGroup on %s", self.bind_to, irgroup.bind_to())
continue

self.add_tcp_group(irgroup)
Expand Down Expand Up @@ -384,6 +389,12 @@ def base_http_config(self) -> Dict[str, Any]:
'normalize_path': True
}

# Instructs the HTTP Connection Mananger to support http/3. This is required for both TCP and UDP Listeners
if self.http3_enabled:
base_http_config['http3_protocol_options'] = {}
if self.isProtocolUDP():
base_http_config['codec_type'] = "HTTP3"

# Assemble base HTTP filters
from .v3httpfilter import V3HTTPFilter
for f in self.config.ir.filters:
Expand Down Expand Up @@ -551,7 +562,7 @@ def finalize(self) -> None:
"socket_address": {
"address": self.bind_address,
"port_value": self.port,
"protocol": "TCP"
"protocol": self.socket_protocol ## "TCP" or "UDP"
}
}

Expand Down Expand Up @@ -837,6 +848,12 @@ def finalize_http(self) -> None:

filter_chain: Optional[Dict[str, Any]] = None

# http/3 is built on quic which has TLS built-in. This means that our UDP Listener will only ever need routes
# that match the TLS Filter chain and will diverge from the TCP listener in that it will not support redirect
# therefore, we can exclude duplicating the filterchain and routes so hitting this endpoint using non-tls http will fail
if (chain.type == "http") and self.isProtocolUDP() and self.http3_enabled:
continue

if chain.type == "http":
# All HTTP chains get collapsed into one here, using domains to separate them.
# This works because we don't need to offer TLS certs (we can't anyway), and
Expand Down Expand Up @@ -885,23 +902,43 @@ def finalize_http(self) -> None:
if (len(chain_hosts) > 0) and ("*" not in chain_hosts):
filter_chain_match['server_names'] = chain_hosts

# Likewise, an HTTPS chain will ask for TLS.
filter_chain_match["transport_protocol"] = "tls"
# Likewise, an HTTPS chain will ask for TLS or QUIC (when udp)
filter_chain_match["transport_protocol"] = "quic" if self.isProtocolUDP() and self.http3_enabled else "tls"

if chain.context:
# ...uh. How could we not have a context if we're doing TLS?
# Note that we're modifying the filter_chain itself here, not
# filter_chain_match.
envoy_ctx = V3TLSContext(chain.context)

filter_chain['transport_socket'] = {
envoy_tls_config = {
'name': 'envoy.transport_sockets.tls',
'typed_config': {
'@type': 'type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.DownstreamTlsContext',
**envoy_ctx
}
}

if self.isProtocolUDP():
envoy_tls_config = {
'name': 'envoy.transport_sockets.quic',
'typed_config': {
'@type': 'type.googleapis.com/envoy.extensions.transport_sockets.quic.v3.QuicDownstreamTransport',
'downstream_tls_context':{
**envoy_ctx
}
}
}

filter_chain['transport_socket'] = envoy_tls_config

else:
# Envoy doesn't like having a UDP Listener with QUIC filter chain that doesn't have a "transport_socket" set with a TLS
# certificate. If the fall-back cert is removed or no certificate is provided then we should not add the quic filter chain
if self.isProtocolUDP() and self.http3_enabled:
self._irlistener.logger.warn(f"Listener: quic network protocol requires a TLSContext to be provided, no tls context found for Listener: {self._irlistener.bind_to()}")
continue

# Finally, stash the match in the chain...
filter_chain["filter_chain_match"] = filter_chain_match

Expand Down Expand Up @@ -929,10 +966,24 @@ def finalize_http(self) -> None:
if not vhost:
vhost = {
"name": f"{self.name}-{host.hostname}",
"response_headers_to_add": [],
"domains": [ host.hostname ],
"routes": []
}

if self.http3_enabled and (self.socket_protocol == "TCP"):
# Setting the alternative service header, tells the client to use the alternate location for future requests.
# Clients such as chrome/firefox, etc... require this to instruct it to start speaking http/3 with the server.
#
# Additional reading on alt-svc header: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Alt-Svc
#
# The default sets the max-age in seconds to be 1 day and supports clients that speak h3 & h3-29 specifications
alt_svc_hdr = { "key": "alt-svc", "value": f'h3=":443"; ma=86400, h3-29=":443"; ma=86400'}

vhost['response_headers_to_add'].append({ "header": alt_svc_hdr})
else:
del(vhost['response_headers_to_add'])

filter_chain["_vhosts"][host.hostname] = vhost

vhost["routes"] += routes
Expand Down Expand Up @@ -974,13 +1025,19 @@ def finalize_http(self) -> None:
self._filter_chains.append(filter_chain)

def as_dict(self) -> dict:
listener = {
listener: dict = {
"name": self.name,
"address": self.address,
"filter_chains": self._filter_chains,
"traffic_direction": self.traffic_direction
}

if self.isProtocolUDP():
listener['udp_listener_config'] = {
'quic_options': {},
'downstream_socket_config': { 'prefer_gro': True }
}

# We only want to add the buffer limit setting to the listener if specified in the module.
# Otherwise, we want to leave it unset and allow Envoys Default 1MiB setting.
if self.per_connection_buffer_limit_bytes:
Expand All @@ -1005,6 +1062,14 @@ def __str__(self) -> str:
self.name, self.bind_address, self.port, self._security_model
)

def isProtocolTCP(self) -> bool:
"""Whether the listener is configured to use the TCP protocol or not?"""
return (self.socket_protocol == "TCP")

def isProtocolUDP(self) -> bool:
"""Whether the listener is configured to use the UDP protocol or not?"""
return (self.socket_protocol == "UDP")

@classmethod
def generate(cls, config: 'V3Config') -> None:
config.listeners = []
Expand Down
31 changes: 27 additions & 4 deletions python/ambassador/ir/ir.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ class IR:
hosts: Dict[str, IRHost]
invalid: List[Dict]
invalidate_groups_for: List[str]
# The key for listeners is "{bindaddr}-{port}" (see IRListener.bind_to())
# The key for listeners is "{socket_protocol}-{bindaddr}-{port}" (see IRListener.bind_to())
listeners: Dict[str, IRListener]
log_services: Dict[str, IRLogService]
ratelimit: Optional[IRRateLimit]
Expand Down Expand Up @@ -328,6 +328,29 @@ def __init__(self, aconf: Config,
# After TLSContexts, grab Listeners...
ListenerFactory.load_all(self, aconf)

# Now that we know all of the listeners, we can check to see if there are any shared bindings
# accross protocols (TCP & UDP sharing same addres & port). When a TCP/HTTP listener binds
# to the same address and port of the UPD/HTTP Listener then it will be marked as http3_enabled=True.
# This causes the `alt-svc` header to be auto-injected into http responses on the TCP/HTTP responses.
# The alt-service header notifies clients (browsers, curl, libraries) that they can upgrade
# TCP connections to UDP (HTTP/3) connections.
#
# Note: at first glance it would seem this logic should sit inside the Listener class but
# we wait until all the listeners are loaded so that we can check for the existance of a
# "companion" TCP Listener. If a UDP listener was the first to be parsed then
# we wouldn't know at that time. Thus we need to wait until after all of them have been loaded.
udp_listeners = (l for l in self.listeners.values() if l.socket_protocol == "UDP")
for udp_listener in udp_listeners:
## this matches the `listener.bind_to` for the tcp listener
tcp_listener_key = f"tcp-{udp_listener.bind_address}-{udp_listener.port}"
tcp_listener = self.listeners.get(tcp_listener_key, None)

if tcp_listener is not None:
tcp_listener.http3_enabled = True

if "HTTP" in tcp_listener.protocolStack:
tcp_listener.http3_enabled = True

# ...then grab whatever we know about Hosts...
HostFactory.load_all(self, aconf)

Expand Down Expand Up @@ -876,10 +899,10 @@ def save_listener(self, listener: IRListener) -> None:

extant_listener = self.listeners.get(listener_key, None)
is_valid = True

if extant_listener:
self.post_error("Duplicate listener %s on %s:%d; keeping definition from %s" %
(listener.name, listener.bind_address, listener.port, extant_listener.location))
err_msg = f"Duplicate listener {listener.name} on {listener.socket_protocol.lower()}://{listener.bind_address}:{listener.port};" \
f" keeping definition from {extant_listener.location}"
self.post_error(err_msg)
is_valid = False

if is_valid:
Expand Down
Loading

0 comments on commit ce9dc45

Please sign in to comment.