Skip to content

Reduce number of egress atunnels by multiplexing CONNECTs over H/2 - #2425

Open
yanavlasov wants to merge 19 commits into
agent-substrate:mainfrom
yanavlasov:atunnel-egress-h2
Open

yanavlasov wants to merge 19 commits into
agent-substrate:mainfrom
yanavlasov:atunnel-egress-h2

Conversation

@yanavlasov

@yanavlasov yanavlasov commented Oct 9, 2026 •

Copy link
Copy Markdown
Collaborator

Atunnel was hardcoded to use CONNECT over H/1. This causes atunnel to open a new mTLS connection to egress gw for each new atunnel connection, causing elevated memory and CPU usage in egress gateway.

  • Tests pass
  • Appropriate changes to documentation are included in the PR

Signed-off-by: Yan Avlasov <yavlasov@google.com>
Signed-off-by: Yan Avlasov <yavlasov@google.com>
Signed-off-by: Yan Avlasov <yavlasov@google.com>
Signed-off-by: Yan Avlasov <yavlasov@google.com>
Signed-off-by: Yan Avlasov <yavlasov@google.com>
Signed-off-by: Yan Avlasov <yavlasov@google.com>
return nil, err
}
if h1Conn != nil {
return c.connectH1(h1Conn, destination)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Do we need to support http 1.1? http2 seems strictly superior for the CONNECT use case. And this traffic is internal to the install --- it's perfectly fine for us to say "If you want to be a drop-in agent substrate egress gateway, you must support CONNECT over http/2

@yanavlasov yanavlasov Oct 9, 2026 •

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

I can remove H/1 support. agentgateway folks have any opinions? I was keeping it as a fallback in case there are gremlins in my H/2 changes. Since this is a simple config change on gateway.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

@grosskur Alan Grosskurth (grosskur) Oct 9, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I'm not an agentgateway maintainer, but if we make atunnel use H/2 CONNECT then IMO that puts a limitation on any drop-in replacement for atunnel in the future because it requires those replacements also be L7 proxies with H/2 support. Whereas H/1 CONNECT is simple enough that it can be implemented by an L4-only proxy.

On the server side, I agree with Taahir Ahmed (@ahmedtd) that terminating H/2 CONNECT shouldn't be an issue for egress gateways, since AIUI any egress gateway will need to have L7 functionality anyway. My concern is about forcing an H/2 CONNECT requirement on the client side. For that reason, it might be beneficial to keep the H/1 CONNECT support for a little while longer until decisions are made about (a) whether atunnel intends to be replaceable or not, and (b) if so, whether such a replacement is required to implement an H/2 CONNECT client.

Signed-off-by: Yan Avlasov <yavlasov@google.com>
Signed-off-by: Yan Avlasov <yavlasov@google.com>

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I like the h2 for performance obviously but I think we need to understand how this impacts policy freshness and correctness when connection is reused (e.g like the bug that led to #2213)

yanavlasov you had a comment about other complications with multiplexing tcp streams over h2 earlier in July - #338 (comment). Are those concerns resolved?

@yanavlasov

Copy link
Copy Markdown
Collaborator Author

I like the h2 for performance obviously but I think we need to understand how this impacts policy freshness and correctness when connection is reused (e.g like the bug that led to #2213)

yanavlasov you had a comment about other complications with multiplexing tcp streams over h2 earlier in July - #338 (comment). Are those concerns resolved?

If you mean freshness of egress policy, then this PR does not change anything. What is changes is that CONNECT tunnel instead of having its own dedicated TCP and TLS transport is sharing it with up to 100 others. Processing of CONNECT requests on egress proxy however does not change.

I do not know what #2213 was for, so can not answer that. Is it possible that it was a hallucinated "fix"?

Regarding #338 (comment)

  1. was likely my confusion - atenet-egress uses cluster IP, which will do connection load balancing.
  2. I have addressed important concerns in this PR - this change limits number of CONNECT tunnels per H/2 connection (Egress tunnel capture + agent identity #338 had all CONNECTs over single H/2 connection), helping to balance the load on inner connections. The complexity of the internal listener however is already here, this PR does not change that.

This change is an improvement for the highly asymmetric cost of opening new actor connections. For a single SYN packet from actor, atunnel + egress open mTLS connection, roundtrip CONNECT and make ext_proc callout. An actor aggressively retrying denied connections can DoS the dataplane. This and #2200 are means of bringing this asymmetry down.

return &bufferedConn{Conn: tlsConn, reader: reader}, nil
}

func (c *Client) connectH2(ctx context.Context, h2Conn *h2ClientConn, wasVerified bool, destination string) (net.Conn, bool, error) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

OK, I think I'm getting a handle on the overall implementation strategy here:

  1. Create our own tls.Conn(s) and remember them.
  2. Ask the net/http library to create a new http2 client connection over the tls.Conn, represented with a net/http.ClientConn.
  3. Whenever the actor opens a new TCP connection, we need to run the http2 connect process on a new http 2 stream.
  4. We ask the net/http.ClientConn to send an http.Request with method CONNECT. This allocates a new stream 123, and sends the CONNECT request over it. Assuming the server supports CONNECT, the response will be 200 OK. The http2 stream 123 still exists.
  5. When we have new TCP data from the actor to forward:
    a) We somehow trick the http.ClientConn into sending a frame header for stream 123, or write the frame header ourselves (I don't yet see where this is happening).
    b) We lock the tls.Conn, and write the data bytes directly to it.
  6. We somehow run the reverse process for response data (I also don't yet see how this is happening).

Overall feedback and questions:

  • Obviously it would be a lot better if net/http support connect in some coherent way. It looks like there is work being done on this, at least for http2 [1] [2]. We should track that work and use it as soon as it is usable for us.
  • Where do we send END_STREAM / RST_STREAM frames? Are we just leaking stream IDs until we run out? (Answered for myself after reading the RFC --- http/2 just leaks stream IDs by design. If you run out, you have to open a new connection). However, is envoy going to keep some state open if it doesn't get END_STREAM? Also, it seems like we will exceed SETTINGS_MAX_CONCURRENT_STREAMS.
  • We should extract all of the connection management into a sub-package that just exposes an io.ReadWriter or net.Conn interface for atunnel to push data into and out of.

[1] golang/go#53208
[2] golang/go#49918

@ahmedtd Taahir Ahmed (ahmedtd) Oct 10, 2026 •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Also, are you sure this code is actually getting exercised, and the e2e tests aren't just falling back to HTTP 1 Connect? I would expect to see a call to foo.raw.Write(...) or io.Copy(foo.raw, ...) somewhere in the added code, but I don't see it.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

This whole complexity is to support both H/1 and H/2 with ALPN. If we ditch H/1 CONNECT from egress atunnel 3/4 of this code will go away.
Specifically:

  1. we need raw TLS connection just to see what ALPN server offered.
  2. client.DialContext returns net.Conn interface, which is convenient for H/1, but cumbersome for H/2. If we use only H/2 an interface convenient for H/2 could be used, and a lot of code could be removed.
  3. The h2StreamConn bridges net.Conn and body reader/writer channels provided to the http.Request when CONNECT was "roundtripped". This is how actor's connection is bound to H/2 stream. This answers 5a.
  4. We do not directly write into TLS connection in H/2 case. We write into HTTP request body writer, which codec converts to H/2 frames and writes into TLS connection. Answer for 5b.
  5. h2StreamConn Read reads from the body reader channel. This is how net.Conn Read method is bridged to H/2 stream reader. Answer for 6.
  6. We not send END_STREAM explicitly. H/2 codec does this for us when body writer channel is closed. Without END_STREAM Envoy would not complete requests. TestActorEgressMultiplexing test reads stats at the end and verifies that all requests have completed.

I think it may be better to just ditch H/1 as it will remove most of this cruft.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Also, are you sure this code is actually getting exercised, and the e2e tests aren't just falling back to HTTP 1 Connect? I would expect to see a call to foo.raw.Write(...) or io.Copy(foo.raw, ...) somewhere in the added code, but I don't see it.

Yes. TestActorEgressMultiplexing specifically reads the stat for the number of H/2 connections and checks that it is more than 1.

See h2StreamConn Read and Write for how actors's TCP connection is bridged to H/2 stream.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

h2StreamConn bridges net.Conn and body reader/writer channels

Ah, OK, I missed this. That makes sense, and it's pretty interesting that it works. So from the perspective of net/http it's one long request.

I agree, it seems like this could be much simpler if we only supported HTTP 2.

Signed-off-by: Yan Avlasov <yavlasov@google.com>
Signed-off-by: Yan Avlasov <yavlasov@google.com>
@yanavlasov

Copy link
Copy Markdown
Collaborator Author

I've added some clarifying comments to the code.

However I'm more inclined to ditch H/1 support and greatly simplify this code.

@swiftdiaries

Adhita Selvaraj (swiftdiaries) commented Oct 11, 2026 •

Copy link
Copy Markdown
Contributor

I think this change can let a new CONNECT tunnel use old policy.
With HTTP/1, each downstream connection carries one tunnel. With H2, several tunnels share that connection and can select the same Envoy pool.
The pool keeps the first tunnel’s authority and policy. Ordinary envoy.string values do not affect pool selection. So a new tunnel can receive fresh policy from ext_proc but still use a pool with old rules. Even a new TCP connection from that pool can receive those old rules.
We should change factory_key to envoy.hashable_string for:

Suggested regression test to repro:

A regression test should hold a TLS passthrough tunnel open, revoke its SNI rule while retaining another valid rule, wait past policy-cache expiry, and verify that a second CONNECT on the same H2 connection cannot use the revoked rule.

Did a 🤖 review for the PR with both this repo and the envoyproxy repo in context.

@LiorLieberman

Copy link
Copy Markdown
Collaborator

I think this change can let a new CONNECT tunnel use old policy.

With HTTP/1, each downstream connection carries one tunnel. With H2, several tunnels share that connection and can select the same Envoy pool.

The pool keeps the first tunnel’s authority and policy. Ordinary envoy.string values do not affect pool selection. So a new tunnel can receive fresh policy from ext_proc but still use a pool with old rules. Even a new TCP connection from that pool can receive those old rules.

We should change factory_key to envoy.hashable_string for:

This separates pools when the destination or policy differs. Keep connection_pool_per_downstream_connection to preserve isolation between authenticated connections.

Right this is what was fixed in #2213. I am not sure the fix was a good long term solution but I looks like thus pr as it is introduce this problem again

After we solve this, in any case we we should not remove h1 at the same pr, we should keep it for some period of time and discuss removal separately

@yanavlasov

Copy link
Copy Markdown
Collaborator Author

I think this change can let a new CONNECT tunnel use old policy.
With HTTP/1, each downstream connection carries one tunnel. With H2, several tunnels share that connection and can select the same Envoy pool.
The pool keeps the first tunnel’s authority and policy. Ordinary envoy.string values do not affect pool selection. So a new tunnel can receive fresh policy from ext_proc but still use a pool with old rules. Even a new TCP connection from that pool can receive those old rules.
We should change factory_key to envoy.hashable_string for:

This separates pools when the destination or policy differs. Keep connection_pool_per_downstream_connection to preserve isolation between authenticated connections.

Right this is what was fixed in #2213. I am not sure the fix was a good long term solution but I looks like thus pr as it is introduce this problem again

After we solve this, in any case we we should not remove h1 at the same pr, we should keep it for some period of time and discuss removal separately

Why does this look like this PR introduces this problem again? This PR changes nothing about how pooling is configured or works. No existing tests fail. Can you suggest an e2e test to validate your concern?

Signed-off-by: Yan Avlasov <yavlasov@google.com>
@yanavlasov

Copy link
Copy Markdown
Collaborator Author

Suggested regression test to repro:

A regression test should hold a TLS passthrough tunnel open, revoke its SNI rule while retaining another valid rule, wait past policy-cache expiry, and verify that a second CONNECT on the same H2 connection cannot use the revoked rule.

Did a 🤖 review for the PR with both this repo and the envoyproxy repo in context.

Added TestActorEgressPassthroughPolicyRefresh

Signed-off-by: Yan Avlasov <yavlasov@google.com>
@yanavlasov

Copy link
Copy Markdown
Collaborator Author

Thanks Lior Lieberman (@LiorLieberman) and Adhita Selvaraj (@swiftdiaries) for pointing the issue with the filter state being captured on creation of connection pool to the internal listener. I have addressed this issue by making pool selection based on hash of actor cert, CONNECT authority (which includes port) and egress policy. I have also added TestActorEgressPendingPlaintextHeadersDoNotBlockMITM test to validate that pools for different policies are not shared.

Signed-off-by: Yan Avlasov <yavlasov@google.com>
@LiorLieberman

Copy link
Copy Markdown
Collaborator

Thanks Yan! Nit: why are we hashing the egress policy? To avoid staleness? I wonder whether hashing only the authority and actor cert is enough

For egress policy - are you hashing the whole object at realtime?

# Create one pool per unique tuple, containing:
# actor ID
# CONNECT authority
# Egress policy

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

nit: can we also add the object keys associated with each of these entities (dev.ate.policy.egress) in the comment?

And separately, not super sure how this translates across gateways. I understand that might not be a blocker for V1. But just surfacing this up.
cc Eitan Yarmush (@EItanya) Keith Mattix II (@keithmattix) for agentgateway

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

It definitely is, we have an egress contract and this is changing it. I'll defer to John Howard (@howardjohn) as he'll know the answer offhand for sure as to whether agentgateway already supports this, my guess is yes.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The AGW connection pool isn't per policy, but otherwise, it looks pretty similar

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Done

@yanavlasov

Copy link
Copy Markdown
Collaborator Author

Thanks Yan! Nit: why are we hashing the egress policy? To avoid staleness? I wonder whether hashing only the authority and actor cert is enough

For egress policy - are you hashing the whole object at realtime?

Yes, it is to avoid using pools that may have stale egress policy attached to them. If the policy was changed to be more permissive (i.e. more SNIs were added), then reusing old policy will be a problem, since SNI match is evaluated the listener filter. And correct, the whole policy string is hashed.

@yanavlasov

Copy link
Copy Markdown
Collaborator Author

Benchmark results for single core egress gateway:
1000QPS of small, short lived H/1 requests.

Overall:
40% bump to QPS
7x improvement to 99.0 latency
3.x improvement to 99.9 latency

Before:

Initiation to completion (4337 samples)
  min: 0s 000ms 002us | mean: 0s 015ms 645us | max: 0s 159ms 072us | pstdev: 0s 014ms 777us

  Percentile  Count       Value          
  0.5         2169        0s 013ms 733us 
  0.75        3254        0s 015ms 028us 
  0.8         3471        0s 015ms 428us 
  0.9         3904        0s 016ms 614us 
  0.95        4121        0s 018ms 034us 
  0.990625    4297        0s 128ms 016us 
  0.99902344  4333        0s 151ms 846us 

Counter                                 Value       Per second
benchmark.http_2xx                      4238        70.63

After:

Initiation to completion (7172 samples)
  min: 0s 000ms 015us | mean: 0s 008ms 558us | max: 0s 087ms 257us | pstdev: 0s 003ms 688us

  Percentile  Count       Value          
  0.5         3587        0s 008ms 091us 
  0.75        5379        0s 008ms 967us 
  0.8         5738        0s 009ms 219us 
  0.9         6455        0s 010ms 038us 
  0.95        6815        0s 010ms 793us 
  0.990625    7105        0s 018ms 442us 
  0.99902344  7165        0s 058ms 900us 

Counter                                 Value       Per second
benchmark.http_2xx                      7073        117.88

@LiorLieberman

Copy link
Copy Markdown
Collaborator

Benchmarks looks good, taking another look. At this point I am not sure we need to hash the whole egress policy for connection pool. shouldnt egress policy freshess be handles with whatever caching semantics we have?

taking a look

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Ok looks good and some nits inline


// This test validates that TLS passthrough connection is denied when policy changes
// even if there are outstanding connections that were allowed by the previous policy.
func TestActorEgressPassthroughPolicyRefresh(t *testing.T) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This test still sets an empty policy, and an actor with no rules gets a 403 on CONNECT so the second tunnel never reaches a pool, and the test passes with or without the policy hash.
Maybe keep one unrelated rule so it actually tests the hash.

Comment on lines +195 to +196
// Allow connection to be fully established before changing the policy.
time.Sleep(1 * time.Second)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Should wait for the first CONNECT in the gateway access log (assertEgressGatewayConnect) rather than sleeping 1s, which can be flaky?

ditto for the other sleep 1 below

}
if got := str(entry, "factory_key"); got != "envoy.string" {
if got := str(entry, "factory_key"); got != "envoy.hashable_string" {
t.Errorf("%s uses factory %q, want envoy.string, the string accessor the module reads", extproc.EgressPolicyMetadataNamespace, got)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Suggested change
t.Errorf("%s uses factory %q, want envoy.string, the string accessor the module reads", extproc.EgressPolicyMetadataNamespace, got)
t.Errorf("%s uses factory %q, want envoy.hashable_string, the string accessor the module reads", extproc.EgressPolicyMetadataNamespace, got)

Comment on lines -444 to -446
if ok, _ := cluster["connection_pool_per_downstream_connection"].(bool); !ok {
t.Errorf("internal cluster %q shares its pool across tunnels; want connection_pool_per_downstream_connection: true", str(cluster, "name"))
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Can you add one instead for the new rule: every filter state object the CONNECT leg shares with upstream uses envoy.hashable_string? Otherwise the next shared object someone adds brings this bug back.

@LiorLieberman

Copy link
Copy Markdown
Collaborator

Can you add a todo to edit the md file to describe the new transport (h2 and alpn?)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

8 participants