MEHS / Knowledge base

Embedding MEHS in an iOS Network Extension

MEHS includes a callback-backed TUN runtime for an app's NEPacketTunnelProvider. The extension owns NEPacketTunnelNetworkSettings; MEHS owns its userspace TCP/IP stack and encrypted client session. Packets enter through mehs_ios_runtime_packet_in and leave through the packet-writer callback passed to mehs_ios_runtime_start. No tunnel file descriptor is used.

The iOS path is available in the release package, but has not been verified on a physical iOS device. The host app and Network Extension target are your app's responsibility.

Obtain and embed the library

The release XCFramework targets iOS 13.0 or newer on devices and Intel simulators; the ARM64 simulator slice requires iOS 14.0 or newer. The default minimum deployment target is 13.0 for the native dependencies, subject to each platform's minimum supported version. You may set a newer deployment target explicitly.

Download the iOS release package from MEHS releases. Embed its MEHS.xcframework in the app and Network Extension targets as appropriate, link NetworkExtension.framework, Security.framework, and the system C++ runtime (libc++.tbd), and include the public C interface in the Swift bridging header or module map. The release package archive is named mehs-client-<version>-ios.zip.

Xcode capabilities

  1. Add the Network Extensions capability with Packet Tunnel to the containing app's extension target and enable the com.apple.developer.networking.networkextension entitlement with packet-tunnel-provider.
  2. Add the same App Group entitlement (for example group.example.mehs) to the app and extension if they share configuration or status. The MEHS runtime itself does not access the group container.
  3. Add NSAppTransportSecurity exceptions only if your deployment requires them; MEHS uses TLS certificate verification and should not be configured to bypass it.
  4. In the containing app, configure NETunnelProviderManager with the extension's provider bundle identifier, save the VPN configuration, and start the tunnel after the user grants the system VPN prompt.

Provider outline

Create network settings before starting MEHS. The addresses below match the default MEHS TUN interface settings; set the same MTU in both places. Replace the sample tunnelRemoteAddress with the actual MEHS server IP. Add host routes excluding the resolved MEHS control and data server addresses so the encrypted carrier stays on the physical network path. For split tunneling, set only the intended included routes instead of the default routes.

import NetworkExtension
import Foundation
import MEHSClient

final class MehsPacketTunnelProvider: NEPacketTunnelProvider {
    private var runtime: OpaquePointer?
    private let mtu = 1400

    override func startTunnel(options: [String: NSObject]? = nil,
                              completionHandler: @escaping (Error?) -> Void) {
        let settings = NEPacketTunnelNetworkSettings(tunnelRemoteAddress: "198.51.100.10")
        let ipv4 = NEIPv4Settings(addresses: ["198.18.0.1"],
                                  subnetMasks: ["255.255.0.0"])
        ipv4.includedRoutes = [NEIPv4Route.default()]
        let carrierIPv4 = ["198.51.100.10"] // Replace with resolved endpoint IPs.
        ipv4.excludedRoutes = carrierIPv4.map {
            NEIPv4Route(destinationAddress: $0, subnetMask: "255.255.255.255")
        }
        settings.ipv4Settings = ipv4
        let ipv6 = NEIPv6Settings(addresses: ["fd77:6d65:6873::1"],
                                  networkPrefixLengths: [64])
        ipv6.includedRoutes = [NEIPv6Route.default()]
        let carrierIPv6 = [String]() // Add resolved IPv6 endpoint IPs, if any.
        ipv6.excludedRoutes = carrierIPv6.map {
            NEIPv6Route(destinationAddress: $0, networkPrefixLength: 128)
        }
        settings.ipv6Settings = ipv6
        settings.dnsSettings = NEDNSSettings(servers: ["1.1.1.1"])
        settings.mtu = NSNumber(value: mtu)

        setTunnelNetworkSettings(settings) { [weak self] error in
            guard let self, error == nil else {
                completionHandler(error)
                return
            }
            do {
                try self.startMehsRuntime()
                self.readPackets()
                completionHandler(nil)
            } catch {
                completionHandler(error)
            }
        }
    }

    private func startMehsRuntime() throws {
        // Use mehs_ios_runtime_start(args_json, secret, callback, context,
        // &runtime, error, capacity, &error_len). Pass ["--control", ...,
        // "--client-id", ..., "--mode", "tun", "--tun-mtu", "1400"].
        // Do not pass --tun-fd. Set tunnelRemoteAddress above to the actual
        // server address; store the returned handle in `runtime`. This helper
        // must wait until status.interface is non-null before returning because
        // the FFI start function itself returns before adapter initialization.
        // The C callback below forwards Rust output packets to packetFlow.
    }

    private func readPackets() {
        packetFlow.readPackets { [weak self] packets, _ in
            guard let self, let runtime = self.runtime else { return }
            for index in packets.indices {
                let packet = packets[index]
                let result = packet.withUnsafeBytes { bytes in
                    mehs_ios_runtime_packet_in(runtime,
                                               bytes.bindMemory(to: UInt8.self).baseAddress,
                                               packet.count)
                }
                if result == MEHS_QUEUE_FULL {
                    // Keep this packet and the remaining packets, retrying
                    // them after the bounded queue has drained.
                    self.retryPackets(Array(packets[index...]))
                    return
                }
                guard result == MEHS_OK else {
                    return
                }
            }
            self.readPackets()
        }
    }

    private func retryPackets(_ packets: [Data], startingAt start: Int = 0) {
        DispatchQueue.global().asyncAfter(deadline: .now() + .milliseconds(5)) { [weak self] in
            guard let self, let runtime = self.runtime else { return }
            for index in start..<packets.count {
                let packet = packets[index]
                let result = packet.withUnsafeBytes { bytes in
                    mehs_ios_runtime_packet_in(runtime,
                                               bytes.bindMemory(to: UInt8.self).baseAddress,
                                               packet.count)
                }
                if result == MEHS_QUEUE_FULL {
                    self.retryPackets(packets, startingAt: index)
                    return
                }
                guard result == MEHS_OK else { return }
            }
            self.readPackets()
        }
    }

    override func stopTunnel(with reason: NEProviderStopReason,
                             completionHandler: @escaping () -> Void) {
        if let runtime {
            mehs_runtime_stop(runtime)
            self.runtime = nil
        }
        completionHandler()
    }
}

Implement the callback as a C-callable Swift function with the MehsPacketWriteCallback signature. It should copy the bytes into Data, infer IPv4 or IPv6 from the first nibble, and call:

packetFlow.writePackets([data], withProtocols: [NSNumber(value: addressFamily)])

Return 1 only when writePackets accepts the packet; return 0 on failure. The callback runs synchronously on a MEHS runtime thread and must not unwind across the C boundary. Keep its context/provider alive until mehs_runtime_stop returns. Stop the runtime exactly once on extension teardown; stopping it closes the packet-flow adapter and the userspace stack. Network Extension removes its assigned routes and settings when the provider stops.

The callback can be a file-level @_cdecl function whose context is the provider:

import Darwin

@_cdecl("mehs_packet_write")
func mehs_packet_write(_ context: UnsafeMutableRawPointer?,
                       _ packet: UnsafePointer<UInt8>?,
                       _ packetLen: Int) -> UInt8 {
    guard let context, let packet, packetLen > 0 else { return 0 }
    let provider = Unmanaged<MehsPacketTunnelProvider>
        .fromOpaque(context).takeUnretainedValue()
    let data = Data(bytes: packet, count: packetLen)
    let version = data[ data.startIndex ] >> 4
    let family = version == 4 ? AF_INET : (version == 6 ? AF_INET6 : 0)
    guard family != 0 else { return 0 }
    return provider.packetFlow.writePackets(
        [data], withProtocols: [NSNumber(value: family)]
    ) ? 1 : 0
}

The C call shape is:

mehs_ios_runtime_start(args_json, secret, packet_writer, provider_context,
                       &runtime, error, sizeof(error), &error_len);

args_json is a NUL-terminated JSON array of CLI arguments; pass the secret separately. Use mehs_runtime_status for status and mehs_ios_runtime_packet_in(runtime, bytes, length) for every packet returned by readPackets. mehs_ios_runtime_packet_in copies the packet before returning. The callback pointer and context must remain valid until the handle has been stopped.

Lifecycle and routing

See platform support and the platform lifecycle guide.