How to make HTTPS and DNS traffic inside app extension also go through VPN tunnel just like those from the containing app

I've coded an iOS VPN App based on PacketTunnelProvider mimicking SimpleTunnel sample. It works on iphone. Traffic from containing app, and other apps such as browsers is routed through the VPN Tunnel as expected.

Recently I found that HTTPS and DNS queries inside the app extension are not going through the tunnel. In Wireshark I can see that DNS queries initiated from app extension are targeting the old DNS server such as my home router IP address 192.168.0.1 instead of the VPN server. Similar DNS queries from the containing app goes through the VPN server correctly as expected.

When an HTTPS JSON API is initiated from within the app extension, the JSON server side log shows that the IP address of of the requester isn't the VPN server IP, instead it's the external facing IP address of my home network.

Did Apple design it this way on purpose: VPN tunnel is only meant for other apps, but NOT the app extension?

Are there any settings or tricks I can leverage to make both the App extension and the containing app route traffic through the established VPN tunnel?

I use some complicated shared code with 2 threads with one handling moving data between the iOS network interface and remote VPN server and the other handling business logic through JSON API and other HTTPS requests, and they communicate with each other. W/o a good solution, I'll have to divide the shared code between the App Extension and the containing app and manage the synchronization between them which is a daunting task and I'd prefer to avoid at any cost.

I'd very much appreciate any ideas from the folks with similar experience, and the experts at Apple support.


FYI on the related settings:

newSettings.ipv4Settings?.includedRoutes  is set to NEIPv4Route.default()

I've also tried these and they didn't help:
  • added our JSON web server IP/mask into includedRoutes

  • Instead of the default, added 0.0.0.0/0.0.0.0 - same as the default.

  • tried these tricks found on this forum such in setting searchDomains and matchDomains to things like [], [""], nil

  • I've also tried to add a gatewayAddress for the default route

Code Block
     let defaultRoute = NEIPv4Route.default()
defaultRoute.gatewayAddress = vpnDnsServerIp // such as 10.0.0.1
    includedRoutes.append(defaultRoute)
newSettings.ipv4Settings?.includedRoutes = includedRoutes

    

Hi @a2zit 
Can I ask in which APIs did you use?
I asked the same question ~2 years ago, and the suggestion was to use
Code Block
-createTCPConnectionThroughTunnelToEndpoint:enableTLS:TLSParameters:delegate:
-createUDPSessionThroughTunnelToEndpoint:fromEndpoint:

But I didn't test it yet. Any chance you are already using those functions?
P.S: My question from 2 years back: https://developer.apple.com/forums/thread/94430?answerId=288108022#288108022
Thank you @roee84 for the timely response! This is very encouraging. I will look into changing the code to leverage the recommended approach.
Hi @roee84,

To report back, createTCPConnectionThroughTunnel(to: endpoint, enableTLS:false, tlsParameters:nil, delegate:nil) seems to work! At least I can see in wireShark this call triggered DNS queries are now going through the VPN tunnel, instead of my home router IP. I believe HTTPS calls with this connection will also go through the tunnel.

I use a trick found on this forum by @spensaurus to convert packetFlow to an int so C code can use it as a socket (I'm aware Apple recommends against it.) In the future I might have to port all such C code to Swift. But it's a lot of work! For now, I'm just glad that this trick still works and so I can reuse the same kind of C code that works fine on android.

Code Block
let tunFd = self.packetFlow.value(forKeyPath: "socket.fileDescriptor") as! Int32;


Is there a similar trick to get hold of the socket of the connection returned from this Swift call createTCPConnectionThroughTunnel() for C to use?

Thanks



@a2zit That's great!

I'm also using C code (also for a common code for iOS and Android), but I'm just passing the packets from the packetFlow. I though that using this trick might not work well at the future, so on my side only Android uses FDs. Because of that I don't know the answer to your question, and I'm guessing I'll have to deal with it soon for parts in my code. This, and a similar issue where I want lib curl to send traffic via the tunnel from the extension.
Update: createTCPConnectionThroughTunnel() does allow app extension to send Https Json call through the tunnel. This is good news.

My remote server did receive the json request sent by my app extension, and it shows the request was from the IP of the VPN server.

Having a hard time getting the response though. My code crashes the app extension. I don't know how to properly handle Http request/response on a NWTCPConnection.

I created a class: 
Code Block
class HttpClientConnection: NSObject


in which there is a method: 
Code Block
open func startConnection(_ provider: NETunnelProvider, _ serverNamePort: String, _ httpRequestData: String, _ size: Int ) -> Bool  {
...
       connection = (provider as! NEPacketTunnelProvider).createTCPConnectionThroughTunnel(to: endpoint, enableTLS:true, tlsParameters:nil, delegate:nil)
       connection!.addObserver(self, forKeyPath: "state", options: .initial, context: &connection)


The observer: 
Code Block
 open override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey: Any]?, context: UnsafeMutableRawPointer?) {
     guard keyPath == "state" && context?.assumingMemoryBound(to: Optional<NWTCPConnection>.self).pointee == connection else {
       super.observeValue(forKeyPath: keyPath, of: object, change: change, context: context)
       return
     }
     
     simpleTunnelLog("Tunnel connection state changed to \(connection!.state)")
     switch connection!.state {
       case .connected:
         if let remoteAddress = self.connection!.remoteAddress as? NWHostEndpoint {
           remoteHost = remoteAddress.hostname
         }
         // connect? We send the http request:
         sendHttpRequest(httpRequestData, httpRequestDataSize) {_ in
           simpleTunnelLog("Sent Http Request")
           let response = self.receiveHttpResponse()
         }         
         
       case .disconnected:
         closeHttpConnectionWithError(connection!.error as NSError?)
       case .cancelled:
         connection!.removeObserver(self, forKeyPath:"state", context:&connection)
         connection = nil
//         delegate?.tunnelDidClose(self)
       default:
         break
     }
   }


This is the code I use to send out the JSON Http request in function: 
Code Block
   open func sendHttpRequest(_ httpRequestData: String, _ size: Int, completionHandler: @escaping(Error?) -> Void) {
    if let rawData = httpRequestData.data(using: .utf8) {
       simpleTunnelLog("sendHttpRequest - to call write")
       connection?.write(rawData, completionHandler: completionHandler) // this call crashes the app extension
     }


Then, I tried to get the response. The key part is below method. It's not working, and causing memory issue. readMinimumLength() seems to come back w/o reading any data. I searched but couldn't find a good example on how to handle receiving of http response on a NWTCPConnection in Swift. I wish URLSession can be subclassed to use this connection returned by createTCPConnectionThroughTunnel().

Code Block
func HttpRecvALine() -> String {
     guard let targetConnection = connection else {
       closeHttpConnectionWithError(SimpleTunnelError.badConnection as NSError)
       return ""
     }
     
     var charThisRead = ""
     var textRead = ""
     while true {
       targetConnection.readMinimumLength(1, maximumLength: 1) { data, error in
         if let readError = error {
           simpleTunnelLog("Got an error on the tunnel connection: \(readError)")
           self.closeHttpConnectionWithError(readError as NSError?)
           return
         }
         if let data = data {
           charThisRead = String(decoding: data, as: UTF8.self)
           textRead += charThisRead
           simpleTunnelLog("textRead: \(textRead) - data\(data)") // nothing was read here.
         } else {
           simpleTunnelLog("No more data to read")
           return
         }
       }
       if charThisRead == "\n" {
         // found end of line
         simpleTunnelLog("found end of line - so far:\(textRead)")
         break
       }
     }
     return textRead
   }


Any insight and pointers would be greatly appreciated.

Found the root cause of the issue. I was assuming that the readMinimumLength() is a blocking call but in reality it's an async call. After I moved the code of reading/parsing the HTTP response into the closure of this call, it started working.
How to make HTTPS and DNS traffic inside app extension also go through VPN tunnel just like those from the containing app
 
 
Q