This document describes how TunnelVision captures and processes traffic on-device, the constraints imposed by iOS, and the security model that bounds what the tool will and will not do.
TunnelVision ships as four build products in one Xcode project:
| Component | Kind | Role |
|---|---|---|
TunnelVision |
iOS app | SwiftUI UI, tunnel lifecycle control, history queries |
PacketTunnel |
App extension (NEPacketTunnelProvider) |
packet capture, parsing, relay, optional TLS termination |
AuditControls |
App extension (WidgetKit, iOS 18+) | the Control Center control that places an audit marker without opening the app |
Shared |
Framework | data models, persistence, and IPC layout shared by all three |
The app and the tunnel extension are separate processes that never share memory directly. They communicate only through a shared App Group container: a SQLite database for durable history and a memory-mapped ring buffer for the live feed.
AuditControls is a third process with a much narrower reach: it holds the App Group entitlement and nothing else, and all it does with it is write one marker row into that database, or read which audit session is open (spec/audit.md § Marking from outside the app). It never touches the ring buffer, the capture files, the keychain or the tunnel.
NETunnelProviderManager and starts the tunnel. The extension’s startTunnel(options:) sets NEPacketTunnelNetworkSettings — addresses, DNS, and the included routes that pull the device’s traffic into the tunnel. iOS creates a utun virtual interface and begins delivering the device’s outbound IP packets to the extension.packetFlow.readPackets(completionHandler:). Each datagram is a complete IP packet with no framing of our own.Network.framework (NWConnection). Connections originated by the extension are not re-captured by the tunnel, so they reach the real internet directly. Response bytes are written back into the tunnel via packetFlow.writePackets.pcap. The app renders from both.Not every packet deserves the same amount of work, and doing too much per packet is what wrecks both the memory budget and the battery. Flows are classified on first sight:
pcap. Plaintext protocols (HTTP, etc.) are decoded for the inspector.The extension only ever receives raw IP packets, so to read or terminate a TCP byte stream we cannot lean on the kernel socket layer for the client side — we reconstruct it ourselves:
NWConnection to the destination, speaking TLS as a normal client with standard system trust, and shuttle bytes between the two — logging the plaintext in the middle. TunnelVision is a client to the real server exactly like any other app; it earns no special trust and cannot see servers it can’t legitimately reach.Flows that are only relayed (the common case) never enter this path; their packets are forwarded with minimal per-packet work.
A NEPacketTunnelProvider runs in a memory-constrained extension process (historically ~15 MB; larger but still tight on current iOS, and the OS will terminate the extension if it exceeds its budget). Everything is designed around that:
pcap files incrementally as packets arrive; a multi-hour capture never needs to be resident.Two channels, each with a single writer and single reader, both in the App Group container:
CFNotificationCenter / notify(3)) is posted as a wakeup only — Darwin notifications carry no payload, so they signal “new data available” and the app then drains the ring. This corrects a common misconception that Darwin notifications can transport packet data; they cannot, and mmap is what actually moves the bytes.Standard libpcap format: a 24-byte global header followed by per-packet records (timestamp, captured length, original length, then the raw bytes). Written as an append stream. Files open directly in Wireshark and tcpdump.
flows — id, flow_key (proto, src/dst addr+port), first_seen, last_seen, bytes_out, bytes_in, packets, tls_status (plaintext / encrypted / inspected / not_inspectable), sni.packets — id, flow_id, ts, direction, length, tcp_flags, pcap_offset.Indexes on flows.last_seen and packets.flow_id back the timeline and inspector queries. Migrations are versioned through GRDB.
A fixed-width packed struct: monotonic timestamp, flow key, direction, length, and a compact flags field. Fixed width keeps the ring lock-free and lets the app compute slot offsets without parsing.
TunnelVision is a first-party, consent-based analyzer of the device owner’s own traffic. The following are explicit non-goals, and building them would be out of scope:
These boundaries are what keep TunnelVision a legitimate engineering and auditing tool — comparable to Charles, Proxyman, or Wireshark, and squarely within the category Apple’s NetworkExtension APIs are meant to serve.