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
- Add the Network Extensions capability with Packet Tunnel to the containing app's extension target and enable the
com.apple.developer.networking.networkextensionentitlement withpacket-tunnel-provider. - 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. - Add
NSAppTransportSecurityexceptions only if your deployment requires them; MEHS uses TLS certificate verification and should not be configured to bypass it. - In the containing app, configure
NETunnelProviderManagerwith 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
- The extension, not the MEHS library, sets tunnel addresses, MTU, included/excluded routes, and DNS.
- Exclude the control and data endpoint IPs from the tunnel routes. Otherwise the carrier can be captured by the tunnel it is responsible for carrying.
- Poll
mehs_runtime_statusat a modest interval for startup and diagnostics. Wait until the statusinterfaceis non-null before forwarding packets; an initial packet-in call before adapter setup returnsMEHS_INVALID_STATE. - The packet input queue is bounded. If
mehs_ios_runtime_packet_inreturnsMEHS_QUEUE_FULL, retain the packet and retry after draining the queue rather than dropping it. - The runtime uses the same packet contract as the platform adapter (complete IP packets, bounded by the configured MTU, orderly shutdown) and automatically reconnects after carrier failures. The iOS FFI does not expose a network-generation counter or explicit sleep/wake calls; if Network Extension replaces the packet-flow object, stop the old runtime and start a new one. On wake, verify the physical carrier path and its exclusion routes before forwarding again.
- Keep secrets in the app's Keychain, pass the secret through the dedicated FFI parameter, and never include it in logged status or diagnostics.
See platform support and the platform lifecycle guide.