DNS Proxy Provider remains active after app uninstall | iOS

Hi,

I've encountered a strange behavior in the DNS Proxy Provider extension. Our app implements both DNS Proxy Provider and Content Filter Providers extensions, configured via MDM.

When the app is uninstalled, the behavior of the providers differs:

  • For Content Filter Providers (both Filter Control and Filter Data Providers), the providers stop as expected with the stop reason:
/** @const NEProviderStopReasonProviderDisabled The provider was disabled. */
case providerDisabled = 5
  • However, for the DNS Proxy Provider, the provider remains in the "Running" state, even though there is no app available to match the provider's bundle ID in the uploaded configuration profile.

When the app is reinstalled:

  • The Content Filter Providers start as expected.
  • The DNS Proxy Provider stops with the stop reason:
/** @const NEProviderStopReasonAppUpdate The NEProvider is being updated */
@available(iOS 13.0, *)
case appUpdate = 16

At this point, the DNS Proxy Provider remains in an 'Invalid' state. Reinstalling the app a second time seems to resolve the issue, with both the DNS Proxy Provider and Content Filter Providers starting as expected.

This issue seems to occur only if some time has passed after the DNS Proxy Provider entered the 'Running' state. It appears as though the system retains a stale configuration for the DNS Proxy Provider, even after the app has been removed.

Steps to reproduce:

  1. Install the app and configure both DNS Proxy Provider and Content Filter Providers using MDM.
  2. Uninstall the app.
  • Content Filter Providers are stopped as expected (NEProviderStopReason.providerDisabled = 5).
  • DNS Proxy Provider remains in the 'Running' state.
  1. Reinstall the app.
  • Content Filter Providers start as expected.
  • DNS Proxy Provider stops with NEProviderStopReason.appUpdate (16) and remains 'Invalid'.
  1. Reinstall the app again.
  • DNS Proxy Provider now starts as expected.

This behavior raises concerns about how the system manages the lifecycle of DNS Proxy Provider, because DNS Proxy Provider is matched with provider bundle id in .mobileconfig file.

Has anyone else experienced this issue? Any suggestions on how to address or debug this behavior would be highly appreciated.

Thank you!

Answered by DTS Engineer in 839716022
Here’s an example of multiple flows observed with the same sourceAppSigningIdentifier and bytes, but different local ports:

That’s not flow duplication. When talking about UDP, flows are identifier by the tuple local IP / local port / remote IP / remote port. If the flows have different local ports, they are different flows.

I’m not sure what’s happening with Set in this case. Honestly, I could research that but I think that are bigger fish to fry here.

all DNS proxy functionality is now contained within a single class.

That’s impressive, because creating a DNS proxy is a complex task. However, looking at the code it’s clear that you’re not handling your flows correctly.

The issue is that you read one set of requests from the flow, resolves those, write the responses, and then close the flow. That’s not how a DNS proxy is supposed to work. Rather, it should stream queries in from the flow, resolve them, and then stream replies back.

IMPORTANT You don’t have to reply in order because the DNS client is expected to match replies to queries via the message’s Transaction ID field.

That explains why things are working for your DNS test tool (case 1 above) but failing for the real DNS client (case 2). It also explains why you’re seeing so many flows. My experience with DNS proxies is that they see very few flows. You get a flow from mDNSResponder and all DNS traffic runs through that until you either a) run an app or tool that doesn’t using the built-in resolver, like dig, or b) something causes that flow to stop, like a network reconfiguration. You might see a few extra flows here and there, but nothing like the number you’re seeing.

Now, mDNSResponder should be able to handle the flow being closed and recover. It’s possible that iOS 18.4 has introduced a bug that’s causing that to fail. Regardless, the current behaviour of your proxy is incorrect and you should fix that. That might help with this problem, but it’s the right thing to do anyway.

Share and Enjoy

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

Here’s an example of multiple flows observed with the same sourceAppSigningIdentifier and bytes, but different local ports:

That’s not flow duplication. When talking about UDP, flows are identifier by the tuple local IP / local port / remote IP / remote port. If the flows have different local ports, they are different flows.

I’m not sure what’s happening with Set in this case. Honestly, I could research that but I think that are bigger fish to fry here.

all DNS proxy functionality is now contained within a single class.

That’s impressive, because creating a DNS proxy is a complex task. However, looking at the code it’s clear that you’re not handling your flows correctly.

The issue is that you read one set of requests from the flow, resolves those, write the responses, and then close the flow. That’s not how a DNS proxy is supposed to work. Rather, it should stream queries in from the flow, resolve them, and then stream replies back.

IMPORTANT You don’t have to reply in order because the DNS client is expected to match replies to queries via the message’s Transaction ID field.

That explains why things are working for your DNS test tool (case 1 above) but failing for the real DNS client (case 2). It also explains why you’re seeing so many flows. My experience with DNS proxies is that they see very few flows. You get a flow from mDNSResponder and all DNS traffic runs through that until you either a) run an app or tool that doesn’t using the built-in resolver, like dig, or b) something causes that flow to stop, like a network reconfiguration. You might see a few extra flows here and there, but nothing like the number you’re seeing.

Now, mDNSResponder should be able to handle the flow being closed and recover. It’s possible that iOS 18.4 has introduced a bug that’s causing that to fail. Regardless, the current behaviour of your proxy is incorrect and you should fix that. That might help with this problem, but it’s the right thing to do anyway.

Share and Enjoy

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

Thank you so much for the clarification!

Just to make sure I fully understand: you're suggesting that the correct approach is to continuously loop over readDatagrams() until it either returns an error like NEAppProxyFlowError.aborted or no datagrams. Within each iteration, I should add a new Task (or handle them concurrently) for processing the datagrams, while keeping the overall flow open and tracking task state if needed. Is that correct?

Yes and no. You wrote:

while keeping the overall flow open and tracking task state if needed. Is that correct?

This bit is definitely true. The tricky part relates to how you read and process the requests. The obvious approach looks something like this:

func readAndProcessRequests(_ flow: NEAppProxyUDPFlow) async throws {
    while true {
        let requests = try await flow.readRequests()
        for request in requests {
            let response = await resolveRequest(request)
            try await flow.writeResponse(response)
        }
    }
}

This suffers from a head-of-line blocking problem. If request N takes a long time then requests N+1, N+2, and so on will all be delayed.

The alternative is something like this:

func readAndProcessRequests(_ flow: NEAppProxyUDPFlow) async throws {
    while true {
        let requests = try await flow.readRequests()
        for request in requests {
            Task {
                let response = await resolveRequest(request)
                try await flow.writeResponse(response)
            }
        }
    }
}

WARNING Don’t copy this code. A real implementation of this would use structured concurrency. I’m not doing that here because it complicates the code without fixing the actual problem I’m trying to illustrate here.

The problem with this approach is that there’s no flow control (aka backpressure). If requests arrive faster than resolveRequest(_:) can deal with them, they back up in your provider. If that continues for a long time, you might hit the provider’s memory limit and… presto!… welcome to jetsam.

Solving this properly is not easy, even with the support of Swift concurrency. You need something like this:

func readAndProcessRequests(_ flow: NEAppProxyUDPFlow) async throws {
    while true {
        let requests = try await flow.readRequests()
        for request in requests {
            await scheduleRequest(request)
        }
    }
}

… and a separate mechanism  to write response …

where scheduleRequest(_:) blocks if there are too many outstanding requests. That allows you have run things in parallel up to a limit, propagating the flow control from your resolver to NE when you hit that limit.

Having said that, you don’t need to deal with this in order to run the experiment that I’m suggesting above. The naïve code should work well enough to tell you whether you’re on the right track or not.

Share and Enjoy

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

Thank you for your response, it was incredibly helpful in organizing my approach to flow lifecycle management.

I also wanted to share that the main issue appeared to stem from how the flow was being opened. Initially, I was using:

try await flow.open(withLocalFlowEndpoint: flow.localFlowEndpoint)

After updating to:

try await flow.open(withLocalFlowEndpoint: nil)

…the same code began working as expected on iOS versions > 18.4.1.

Really appreciate all your help throughout the investigation!

DNS Proxy Provider remains active after app uninstall | iOS
 
 
Q