Cannot open Chrome UDP flows in Transparent Proxy Provider

We are implementing a Transparent Proxy for HTTPS (via TCP and QUIC). The following rules are set in startProxy:

settings.includedNetworkRules = [
    NENetworkRule(destinationNetwork: NWHostEndpoint(hostname: "0.0.0.0", port: "443"), prefix: 0, protocol: .TCP),
    NENetworkRule(destinationNetwork: NWHostEndpoint(hostname: "::", port: "443"), prefix: 0, protocol: .TCP),
    NENetworkRule(destinationNetwork: NWHostEndpoint(hostname: "0.0.0.0", port: "443"), prefix: 0, protocol: .UDP),
    NENetworkRule(destinationNetwork: NWHostEndpoint(hostname: "::", port: "443"), prefix: 0, protocol: .UDP)
]

Handling TCP connections seems to work fine. But opening UDP flows from Chrome (or Brave) always fails with

Error Domain=NEAppProxyFlowErrorDomain Code=2 "The peer closed the flow"

(Doing the same for Firefox works!)

BTW: We first create a remote UDP connection (using the Network framework) and when it is in the ready state, we use connection?.currentPath?.localEndpoint as the localEndpoint parameter in the open method of the flow.

Is it a known issue that QUIC connections from Chrome cannot be handled by a Transparent Proxy Provider?

Answered by Pelea in 896813022

I ran more tests and, I think, I can now explain the Chrome QUIC failures. change_fdguard_np is not the problem: a UDP socket guarded with GUARD_CLOSE | GUARD_DUP (applied before connect(), exactly like Chromium) proxies flawlessly through my NETransparentProxyProvider (both datagram directions).

The actual cause: on a flow-diverted UDP socket,

setsockopt(..., IPPROTO_IP, IP_DONTFRAG, ...)

and

setsockopt(..., IPPROTO_IP, IP_RECVTOS, ...)

fail with ECONNRESET (errno 54).

Without the proxy, they succeed. Chromium applies exactly these options immediately after connect() (QuicSessionPool::FinishConnectAndConfigureSocket, net/quic/quic_session_pool.cc) and aborts QUIC session creation on any failure, closing the socket within microseconds of the flow being created. The provider's subsequent flow.open() therefore always fails with

NEAppProxyFlowErrorDomain Code=2 "The peer closed the flow".

Repro without Chrome: create a UDP socket, connect() to any destination covered by the proxy's rules, then call setsockopt(..., IPPROTO_IP, IP_DONTFRAG, ...): It returns ECONNRESET whenever the transparent proxy is active.

I will create a new bug report.

Doing the same for Firefox works!

And Safari?

Share and Enjoy

Quinn “The Eskimo!” @ Developer Technical Support @ Apple
let myEmail = "eskimo" + "1" + "@" + "apple.com"

With Safari flows it works as well.

And Safari?

It seems that it is a problem with Chromium-based UDP flows.

For Chromium, Chrome, Brave, Opera I get the "The peer closed the flow" errors, for Safari and Firefox not.

The problem still occurs on macOS 26 and 27.

I asked Claude to find a reason in the Chromium source code. Its findings:

Finding 1: All Chrome QUIC UDP sockets are guarded immediately after creation

In net/socket/udp_socket_posix.cc, ConfigureOpenedSocket() is called as the very last step of Open(), before any data is sent:

// Called from Open() — runs before connect() or any I/O
int UDPSocketPosix::ConfigureOpenedSocket() {
#if BUILDFLAG(IS_APPLE) && !BUILDFLAG(CRONET_BUILD) && !BUILDFLAG(IS_IOS_TVOS)
  guardid_t guardid = reinterpret_cast<guardid_t>(this);
  PCHECK(change_fdguard_np(socket_, nullptr, 0, &guardid,
      GUARD_CLOSE | GUARD_DUP, nullptr) == 0);
#endif
  // ...
}

change_fdguard_np is a private macOS API that prevents any external code — including the kernel itself — from calling dup() or close() on this file descriptor. GUARD_DUP specifically blocks duplication of the fd.

This is Apple-only code, excluded from Cronet builds. Firefox and Safari have no equivalent guard.

Finding 2: The kernel's transparent proxy mechanism likely needs to dup the socket

When NETransparentProxyProvider claims a flow via open(), the kernel needs to maintain a reference to Chrome's socket to deliver proxy responses back to it — even if Chrome closes it. The standard mechanism for this at the kernel level is to dup() the fd internally. GUARD_DUP blocks that operation, causing the kernel to immediately give up on the flow and report NEAppProxyFlowErrorDomain Code=2 "The peer closed the flow".

Finding 3: QUIC path probing creates additional short-lived sockets

In quic_chromium_client_session.cc, QUIC path validation creates extra UDP sockets via QuicChromiumPathValidationContext — all of which also get GUARD_CLOSE | GUARD_DUP applied. When a probe fails, the socket is discarded immediately. These probe sockets add to the volume of flows your proxy sees that all fail to open.

So maybe the NETransparentProxyProvider is incompatible with Chromium QUIC flows?

Sorry I didn’t respond to your previous post. I suspect I was out of the office at the time.

So maybe the NETransparentProxyProvider is incompatible with Chromium QUIC flows?

I went looking for bugs related to this and I found FB18141248, which looks like it was filed by you (or perhaps one of your colleagues).

Note If you’re discussing an issue and you’ve already filed a bug about it, it speeds things up if your include your bug number. See tip 7 in Quinn’s Top Ten DevForums Tips.

On the bug front:

  • My general advice when filing a bug like this is to include a minimal NE provider test project that reproduces the issue. That confirms that this is a system issue rather than something being caused by the other code in your real product.
  • I also recommend that you enable additional NE debugging, trigger a sysdiagnose log after reproducing the issue, and add that to your bug report. See our Bug Reporting > Profiles and Logs for more about that stuff.

It looks like you’ve done the first thing (thanks!) but not the second. I recommend that you do that now.

As to the response generated by your LLM, it looks plausible but that’s not saying much [1]. If you want to go the extra mile you could test this for yourself:

  1. Create a small app that generates traffic on a UDP socket.
  2. Confirm that your transparent proxy sees its traffic.
  3. Update the app to call the change_fdguard_np on your socket.
  4. Repeat step 2 and see what happens.

If you confirm that change_fdguard_np is the cause of the problem, add that info to your bug report, because I’m sure that NE engineering will find it useful. Also reply back here, because I’m curious either way.


Oh, one last thing. Your LLM generated:

change_fdguard_np is a private macOS API

IMO there’s no such thing as private APIs. Rather, there are APIs and implementation details. See my response here for more about that.

Normally I’m reluctant to discuss implementation details here on the forums. A key exception to that policy is debugging. When you’re debugging a weird problem, sometimes it’s important to know how things actually work.

And this is a perfect example of that. change_fdguard_np is not a secret. You can see the interface and implementation for it in Darwin. However, it’s not an API. I strongly recommend against building a product that relies on it. However, building a test project that uses it to investigate this problem is absolutely fine.

Share and Enjoy

Quinn “The Eskimo!” @ Developer Technical Support @ Apple
let myEmail = "eskimo" + "1" + "@" + "apple.com"

[1] LLMs by their nature generate plausible responses; the challenge is generating accurate responses )-:

Thanks for the reply!

I tried to reproduce the issue with a test app sending simple SNTP requests, but unfortunately I couldn't. 😢 Opening the flows through the transparent proxy worked fine both with and without calling change_fdguard_np on the socket before connecting it.

I ran more tests and, I think, I can now explain the Chrome QUIC failures. change_fdguard_np is not the problem: a UDP socket guarded with GUARD_CLOSE | GUARD_DUP (applied before connect(), exactly like Chromium) proxies flawlessly through my NETransparentProxyProvider (both datagram directions).

The actual cause: on a flow-diverted UDP socket,

setsockopt(..., IPPROTO_IP, IP_DONTFRAG, ...)

and

setsockopt(..., IPPROTO_IP, IP_RECVTOS, ...)

fail with ECONNRESET (errno 54).

Without the proxy, they succeed. Chromium applies exactly these options immediately after connect() (QuicSessionPool::FinishConnectAndConfigureSocket, net/quic/quic_session_pool.cc) and aborts QUIC session creation on any failure, closing the socket within microseconds of the flow being created. The provider's subsequent flow.open() therefore always fails with

NEAppProxyFlowErrorDomain Code=2 "The peer closed the flow".

Repro without Chrome: create a UDP socket, connect() to any destination covered by the proxy's rules, then call setsockopt(..., IPPROTO_IP, IP_DONTFRAG, ...): It returns ECONNRESET whenever the transparent proxy is active.

I will create a new bug report.

Created new bug report: FB23636906

That’s some excellent debugging!

Created new bug report: FB23636906

Thanks for that.

Share and Enjoy

Quinn “The Eskimo!” @ Developer Technical Support @ Apple
let myEmail = "eskimo" + "1" + "@" + "apple.com"

Cannot open Chrome UDP flows in Transparent Proxy Provider
 
 
Q