Skip to content

DNS filtering with a Network Extension

Filtering every app on iOS means intercepting something every app uses. Almost everything is reached by name first, so the domain lookup is the one point that covers the whole device. Blue Guard uses this for its app-wide blocking.

The provider that looks right, and is not available

NEDNSProxyProvider exists for exactly this. It hands an extension every DNS flow the device makes, with no packet parsing.

It requires the com.apple.developer.networking.networkextension entitlement with the dns-proxy value, and iOS grants that only to apps installed on a supervised device through Mobile Device Management. An app distributed on the App Store cannot use it.

This is a distribution constraint rather than a technical one. The same code with the same entitlement works on a supervised device. Confirm it against Apple's current documentation before designing around it, because entitlement policy changes between releases.

What is available

NEPacketTunnelProvider. It is intended for VPNs, and it gives the extension the device's IP packets.

The tunnel does not have to go anywhere. Configure it with DNS servers pointing at the tunnel itself and no remote endpoint, and the effect is a local filter: the system routes DNS to the extension, and the extension answers or forwards.

import NetworkExtension

override func startTunnel(options: [String: NSObject]?) async throws {
    let settings = NEPacketTunnelNetworkSettings(tunnelRemoteAddress: "127.0.0.1")

    let ipv4 = NEIPv4Settings(addresses: ["10.64.0.2"], subnetMasks: ["255.255.255.0"])
    ipv4.includedRoutes = []
    settings.ipv4Settings = ipv4

    let dns = NEDNSSettings(servers: ["10.64.0.1"])
    dns.matchDomains = [""]
    settings.dnsSettings = dns

    try await setTunnelNetworkSettings(settings)
}

Two lines carry the design.

includedRoutes = [] means no traffic is routed into the tunnel. Without it, this would be a VPN carrying everything.

matchDomains = [""] matches every domain, so all DNS is directed at the address in servers, which is inside the tunnel. The result is that domain lookups reach the extension and nothing else does.

Reading packets

The provider reads packets, and for a DNS filter it inspects the UDP payload.

private func readPackets() {
    packetFlow.readPackets { [weak self] packets, protocols in
        guard let self else { return }
        Task { await self.handle(packets, protocols) }
        self.readPackets()
    }
}

readPackets delivers one batch and must be called again to receive the next, so the loop is re-armed on every callback rather than driven by a while.

A DNS query arrives as a UDP datagram inside an IP packet. Answering locally means building the response, wrapping it in UDP and IP headers with the addresses reversed, and writing it back with packetFlow.writePackets(_:withProtocols:). Forwarding means sending the query upstream and returning whatever comes back.

What the user is asked to accept

iOS shows a VPN configuration prompt the first time this starts, and a VPN indicator while it runs. Both are accurate, because this is the VPN mechanism. Neither means traffic is leaving the device through a server.

Limits

Domain granularity only. The filter decides on a name. An advert served from the same domain as the app's own content cannot be refused without refusing the app.

No page manipulation. Hiding an element needs a selector applied to a rendered page, which only a browser has.

One tunnel at a time. iOS runs a single packet tunnel. An app using this cannot coexist with a VPN the user also wants running.

Memory is tight. Network Extension processes have a low memory limit and are terminated for exceeding it. Rule sets are indexed for lookup rather than held as parsed structures.

For what this covers in Blue Guard, see Block trackers in every app.