iOS 17+ Developer Services via Tunnel¶
Starting with iOS 17.0, Apple moved developer service access to CoreDevice/RemoteXPC flows, so
developer commands (and a few others) need an RSD tunnel to the device.
By default pymobiledevice3 establishes this tunnel for you — in-process, with no root/admin. Just run the command; there is nothing to start beforehand:
A privileged tunneld is only needed for specific cases (external tools such as lldb, a
shared/persistent tunnel, or iOS 17.0-17.3.1 — see
When you still need tunneld).
Working from Python?
See Choosing a connection in the Python API guide for
when to use lockdown vs an RSD tunnel (userspace vs tunneld).
Reference protocol details: RemoteXPC
Tip
For diagrams of what each transport path looks like under the hood — usbmux, Wi-Fi lockdown, kernel tunnels, and the userspace tunnel — see Understanding the network stacks.
The default: a no-root tunnel, automatically¶
When a developer command needs an RSD tunnel and you passed no transport flag (--rsd /
--tunnel / --userspace / --native), pymobiledevice3 brings up a no-root tunnel for you,
built when the command starts and torn down when it exits. The kind depends on the host:
- macOS rides Apple's own
remotedtunnel (the native path) by piggybackingremotepairingd— no root, no Xcode, andremotedis left running so it coexists with Xcode/devicectl. Its address is kernel-routable, so it is faster host->device and lower latency than the userspace stack. See Native remoted tunnel. - Linux/Windows bring up an in-process userspace tunnel using a pure-Python network stack (PyTCP) — no kernel interface, so no root/admin either.
If the native path is unavailable on macOS it falls back automatically (userspace, then tunneld).
Either way you just run the command. This covers iOS 17.4+ over USB with no privileges (it uses the
CoreDeviceProxy lockdown service); iOS 17.0-17.3.1 is handled differently — see the note below.
Set PYMOBILEDEVICE3_DEFAULT_FALLBACK=native|userspace|tunneld to change which transport the
automatic selection prefers (see Environment variables for every
variable the CLI honors).
iOS 17.0-17.3.1 uses tunneld by default
These versions predate the CoreDeviceProxy service, so the userspace tunnel can only reach them
over the RemotePairing path — which is Wi-Fi-only and, on macOS, races remoted (the no-root
path can't suspend it without root). Rather than depend on that fragile path, pymobiledevice3
routes iOS 17.0-17.3.1 to tunneld on every platform, so keep one running:
You can still force the no-root path with --userspace where it applies (a device on Wi-Fi;
unreliable on macOS).
Support Notes¶
| Host OS | iOS 17.0-17.3.1 | iOS 17.4+ |
|---|---|---|
| macOS | Uses tunneld (root) |
Supported (no-root) |
| Windows | Uses tunneld (root) + additional drivers |
Supported (no-root) |
| Linux | Uses tunneld (root) |
Supported (no-root) |
When you still need a privileged tunneld¶
The in-process tunnel's device address lives only inside the pymobiledevice3 process, so it is not reachable from any other process on your machine. Use a kernel-routable tunnel (the sections below) when:
- An external tool must reach the device —
developer debugserver lldb, ordeveloper debugserver start-serverwithout--local-port, drive an externallldband therefore refuse over the userspace tunnel. Pass--tunnel(or--local-portforstart-server, which forwards to a local port and does work over the userspace tunnel). - You want one shared/persistent tunnel reused across many invocations instead of rebuilding it per command.
- iOS 17.0-17.3.1 (any host OS) — routed to
tunneldautomatically; see the warning above.
Running tunneld¶
# If the device supports remote pairing (for example, Corellium/Apple TV), pair first.
# Standard iOS devices usually do not need this step.
python3 -m pymobiledevice3 remote pair
# On Windows, run from a privileged shell.
sudo python3 -m pymobiledevice3 remote tunneld
With tunneld running, point a command at it with --tunnel (empty value = pick automatically, or
pass a UDID):
tunneld binds 127.0.0.1:49151 by default. To relocate it, pass --host/--port; the
--tunnel value accepts the matching UDID:PORT suffix (the UDID part may be empty).
When the client cannot reach the tunnel interface tunneld created — for example, tunneld runs
in a different docker network stack, or on a different host altogether — the RSD addresses reported
over GET / are unreachable. The /connect websocket endpoint bridges into the tunnel through the
HTTP API instead: binary websocket messages are forwarded as-is over a TCP connection opened on the
device's tunnel address (to ?port=, defaulting to the RSD port), and vice versa:
import websockets # any websocket client works
async with websockets.connect(f'ws://127.0.0.1:49151/connect?udid={udid}') as websocket:
# speak RSD (RemoteXPC) over the websocket, or pass ?port=<service-port>
# to reach any other service published over the tunnel
...
pymobiledevice3 itself consumes this bridge through the --tunnel UDID@HOST[:PORT] form (IPv6
in brackets), so every command works against a remote tunneld without any extra tooling:
# on the machine with the device attached
sudo python3 -m pymobiledevice3 remote tunneld --host 0.0.0.0
# anywhere that can reach it
python3 -m pymobiledevice3 developer dvt ls / --tunnel 'UDID@lab-mac:49151'
Library consumers get the same via get_tunneld_devices(('lab-mac', 49151)) /
get_tunneld_device_by_udid(...) — connections are bridged automatically whenever the tunneld
host is non-loopback, and the bridge=True parameter forces it for addresses that only look local
(e.g. an SSH port-forward such as ssh -L 49151:127.0.0.1:49151 lab-mac, where the @ form does
the same on the CLI: --tunnel UDID@127.0.0.1).
Warning
Like the rest of the tunneld HTTP API, /connect is unauthenticated — anyone able to reach
the listener gains access to the services exposed over the tunnel, so only bind non-loopback
addresses on trusted networks.
Fronting several hosts with one tunneld¶
A tunneld can federate others with --upstream (repeatable, also POST /upstream at runtime).
Their devices appear in its GET / listing, and a /connect for a device it does not serve itself
is relayed to the upstream that owns it — so a client needs a route to the front tunneld alone,
with no VPN and no route to each host's tunnel interface:
# on every host with devices attached
sudo python3 -m pymobiledevice3 remote tunneld --host 0.0.0.0
# on the one host clients can reach. Disable the monitors if devices are attached here too,
# unless you want this instance to serve them itself: otherwise it builds its own tunnels
# alongside the ones it federates, and each such device is then listed twice
sudo python3 -m pymobiledevice3 remote tunneld --host 0.0.0.0 \
--no-usb --no-wifi --no-usbmux --no-mobdev2 \
--upstream http://lab-1:49151 --upstream http://lab-2:49151
# from anywhere: any device in the lab, addressed through the front tunneld
python3 -m pymobiledevice3 developer dvt ls / --tunnel 'UDID@front:49151'
Upstreams are addressed as HOST, HOST:PORT or http://HOST[:PORT] (IPv6 in brackets), all
stored canonically as http://HOST:PORT with tunneld's default port filled in. https:// is
rejected: the relay speaks plaintext, so carry federation over a trusted network or an SSH forward.
Each listing entry carries an origin: null for devices the queried instance serves directly,
otherwise the URL of the upstream it came from. A client that can reach that upstream may skip
the relay and address it directly, which saves a hop.
Note
Federated requests carry a hop budget (x-tunneld-hops-remaining, 4 by default), so tunnelds
registering each other terminate instead of recursing, and a device reachable through several
paths is listed once. Relaying costs an extra hop through the front instance's event loop —
negligible for control traffic, measurable on bulk transfers such as DDI mounts.
To make tunneld the default fallback — so commands route to it automatically without passing
--tunnel, restoring the pre-userspace-default behavior — set
PYMOBILEDEVICE3_DEFAULT_FALLBACK=tunneld:
Starting a tunnel manually¶
# macOS (the default there): no sudo — publishes Apple's own remoted tunnel
# (kernel-routable, so other tools can use the printed --rsd too). See "Native
# remoted tunnel" in the network stacks guide.
python3 -m pymobiledevice3 remote start-tunnel
# Optional for remote-pairing devices.
python3 -m pymobiledevice3 remote pair
# iOS 17.4+ (faster lockdown tunnel)
sudo python3 -m pymobiledevice3 lockdown start-tunnel
# Optional: allow Wi-Fi connections over lockdown
python3 -m pymobiledevice3 lockdown wifi-connections on
# iOS 17.0-17.3.1 fallback, and the default off macOS.
# Add `-t wifi` to force Wi-Fi transport (on macOS this routes to this classic
# tunnel automatically, as does --no-native or any other classic-tunnel option).
sudo python3 -m pymobiledevice3 remote start-tunnel
Example output:
Interface: utun6
RSD Address: fd7b:e5b:6f53::1
RSD Port: 64337
Use the following connection option:
--rsd fd7b:e5b:6f53::1 64337
The classic tunnel creation command must run with elevated privileges because it creates a TUN/TAP
interface. The native path (--native, the macOS default) is the exception: it rides Apple's
already-existing tunnel instead of creating one, so it needs no privileges. Device selection there
is by --udid; the classic-tunnel-shaping options (--protocol/--secrets/--max-idle-timeout/
-t) don't apply to it, and passing one routes the command to the classic tunnel (as does
--no-native, or PYMOBILEDEVICE3_DEFAULT_FALLBACK set to anything but native). The same
default applies to remote browse: on macOS it lists devices via remotepairingd with no root
(--no-native forces the bonjour browse, which needs root to suspend remoted).
Bootstrap the RemotePairing record over USB (no Trust dialog)
remote start-tunnel (the RSD/Wi-Fi path) needs a RemotePairing pair record. You can create it
over USB, promptlessly, via the com.apple.dt.remotepairingdeviced.lockdown control channel:
Because this runs over the already-trusted lockdownd (USB) transport, pairing is promptless (no
on-device Trust dialog) and writes the same pair record remote start-tunnel / remote pair use.
Without --pair the command just performs a handshake and prints the device's control-channel info
(add --raw to keep the deviceKVSData blob base64-encoded). This control channel does not create
tunnels itself.
Forcing the userspace tunnel (--userspace)¶
The userspace tunnel is already the default, so you rarely need the flag. Pass --userspace
explicitly to force the no-root in-process tunnel and skip the automatic tunneld fallback
(iOS 17.0-17.3.1) — any establishment failure is then surfaced as an error rather than masked:
Requirements:
- Python >= 3.9 — the
pmd-pytcpdependency that powers the userspace stack is installed on every supported interpreter, so the userspace tunnel is always available. - iOS 17.0+ over USB (uses the CoreDeviceProxy lockdown service on 17.4+, or RemotePairing over bonjour/Wi-Fi on 17.0-17.3.1).
What works over the userspace tunnel: the host-initiated developer services (dvt, fetch-symbols,
core-device …), and the device-initiated AV/HID paths — display serve-web, display serve-vnc,
display start-video-stream / start-audio-stream, and the HID gesture commands.
Service connections are handed off in-process over unix-socket relays; on Windows (no AF_UNIX)
the relays use loopback TCP instead. Set PYMOBILEDEVICE3_USERSPACE_TCP_RELAY (any non-empty
value) to force the loopback-TCP relays on any platform — useful for reproducing Windows-specific
relay behavior elsewhere.
Limitation: in-process only¶
The device's tunnel address lives only inside the pymobiledevice3 process, so it is not reachable
from any other process on your machine. Commands that hand the device address to an external tool
therefore cannot use the userspace tunnel — see
When you still need tunneld.
Because the tunnel is rebuilt per invocation (no persistent daemon), expect a little extra startup
latency compared with attaching to an already-running tunneld.
From Python¶
To establish this tunnel programmatically, use the UserspaceRsdTunnel handle — see the
UserspaceRsdTunnel example in the
protocol-layers guide, listed there alongside the other ways to obtain an RSD. The in-process-only
limitation above applies equally to the Python API.
Use tunnel details in commands¶
# Default: no flag, no root — an in-process userspace tunnel is established automatically
python3 -m pymobiledevice3 developer dvt ls /
# Force the no-root tunnel (skip the automatic tunneld fallback)
python3 -m pymobiledevice3 developer dvt ls / --userspace
# Use a running tunneld ('' = pick automatically, or pass a UDID)
python3 -m pymobiledevice3 developer dvt ls / --tunnel ''
# Use manual RSD connection details (from `start-tunnel`)
python3 -m pymobiledevice3 developer dvt ls / --rsd fd7b:e5b:6f53::1 64337
# Non-developer command over a running tunneld
python3 -m pymobiledevice3 syslog live --tunnel ''
Troubleshooting¶
- Most developer commands need no flag — the no-root tunnel is established for you. If one fails to
establish a tunnel, the two explicit routes are
--tunnel ''(uses a runningtunneld) or--userspace(forces the no-root in-process tunnel and surfaces the real error). - iOS 17.0-17.3.1 is routed to
tunneldon every platform (the no-root path only reaches those over the fragile Wi-Fi RemotePairing route), so start one.--userspacecan still force that no-root path over Wi-Fi if you prefer. - Verify the tunnel process is running and the device is trusted/paired.
- On Windows for iOS 17.0-17.3.1, ensure required additional drivers are installed.