TunnelVision

TunnelVision — Architecture

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.

Process model

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.

Data flow

  1. Tunnel establishment. The app configures an 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.
  2. Read loop. The extension reads batches of raw IP datagrams from packetFlow.readPackets(completionHandler:). Each datagram is a complete IP packet with no framing of our own.
  3. Parsing. For every packet we read the IP version, then the L4 header:
    • IPv4/IPv6 header → protocol, source/destination address.
    • TCP/UDP header → source/destination port; TCP sequence/ack/flags. The 5-tuple (proto, src ip:port, dst ip:port) is the flow key.
  4. Flow table. Packets are attached to a flow entry. UDP flows are stateless from our perspective. TCP flows carry reassembly state.
  5. Routing decision. Based on flow classification (below) a packet is either relayed as-is (passthrough) or handed to the userspace TCP path for inspection.
  6. Relay. Outbound data leaves the extension through 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.
  7. Record and surface. Flow records go to SQLite; packet metadata is pushed into the ring buffer; full captures are streamed to pcap. The app renders from both.

Flow classification

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:

The userspace TCP path

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:

Flows that are only relayed (the common case) never enter this path; their packets are forwarded with minimal per-packet work.

Memory strategy

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:

Inter-process communication

Two channels, each with a single writer and single reader, both in the App Group container:

Data formats

pcap

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.

SQLite schema (conceptual)

Indexes on flows.last_seen and packets.flow_id back the timeline and inspector queries. Migrations are versioned through GRDB.

Ring-buffer record

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.

Security model and non-goals

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.