Sideloading an iPhone over Tailscale from anywhere, no USB, Touch grass while CI/CD on iOS.
TL;DR
iOS 17+ moved iPhone development tooling onto a stack — CoreDevice + RemoteXPC + Bonjour — that's deliberately scoped to local-network adjacency. The official position is: USB-tether to the Mac, or have the iPhone on the Mac's Wi-Fi. There is no "remote install" knob in Xcode or xcrun devicectl.
I wanted a roaming pipeline: edit code on a Linux build host, sideload onto an iPhone that's on cellular two countries away, all over Tailscale. After a long detour into the wrong rabbit holes, the working architecture turned out to be a one-LaunchAgent combination of dns-sd -P Bonjour fabrication and socat TCP/UDP proxying. The whole bridge fits in ~50 lines of bash and works because every "structural" blocker is actually a layering choice — and each layer can be sidestepped without violating the layer above.
This post walks through the protocol stack, the wrong turns (instructive), and the actual working bridge.
The setup
-
Build host (Linux): edits the SwiftUI source, drives
xcodebuildover SSH, holds the canonical project files. Reachable on a Tailscale tailnet (Headscale-coordinated). (I'm on an OCI free-tier shape running Claude Code - how to get your own) -
Mac: only the build slave. Has Xcode, the signing identity, and runs
xcodebuild/xcrun devicectl. On the same tailnet. -
iPhone: a real iOS 26 device with a free Apple Developer Personal Team. On the same tailnet via the Tailscale iOS app. Wants to receive the built
.ipaand run it.
The pipeline is edit on Linux → sync to Mac → xcodebuild archive + export → devicectl install → iPhone runs the app. The first four steps were already working over SSH/rsync; the last step is the focus here.
The iOS-17+ developer-services stack
For our purposes, the relevant protocol layers on an iPhone are:
-
Bonjour /
_remotepairing._tcp.local.: iPhone advertises a CoreDevice front-door service on TCP port49152via mDNS. Discovery is intentionally link-local: this is what makes Xcode "see" connected iPhones on the same Wi-Fi. - RemoteServiceDiscovery (RSD): a small JSON/HTTP-2 service reachable at the advertised port that returns a manifest of available developer services (DebugProxy, AFC, InstallationProxy, etc.) and the dynamic ports they listen on.
-
Trusted tunnel: once Mac and iPhone have RSD-handshaked, they establish a QUIC tunnel for the actual developer-services traffic. The tunnel listens on a dynamic port iPhone picks per session — we observed
55110-55115across attempts on iOS 26.5. -
CoreDevice service multiplexer: AFC file transfers,
installation_proxy, debug interfaces, etc. all ride the trusted tunnel.
The whole thing is gated on Bonjour announcing the iPhone to mDNSResponder on Mac, with remoted then driving the rest of the stack.
Why Tailscale (or any L3 VPN) breaks the default flow
Tailscale gives you a unicast IP route between any two devices in your tailnet, anywhere on the internet. That's all it gives you: no broadcast, no multicast, no L2 frames. Bonjour relies entirely on link-local multicast (224.0.0.251, TTL 1). iOS's mDNSResponder explicitly skips point-to-point interfaces (this is documented and there's nothing you can configure) — even if you somehow forced multicast onto the WireGuard tunnel, Apple wouldn't announce on it.
So a roaming iPhone on Tailscale is, from Mac's remoted viewpoint, invisible. xcrun devicectl list devices shows the iPhone as unavailable. No install possible.
The wrong turns
Bonjour reflectors
The obvious-looking answer is "run a Bonjour reflector". There are several: avahi-daemon --reflector, Gandem/bonjour-reflector, the Spiceworks community's Linux gateway recipe, even Palo Alto firewalls. They all work the same way: a daemon sits on a host with NICs in two L2 segments and rebroadcasts mDNS packets between them.
None of these apply to Tailscale. Reflectors operate at the L2 frame layer (gopacket+pcap+802.1Q tags, or at minimum a tcpdump-able interface receiving multicast). tailscale0 is a userspace WireGuard tunnel exposing IP packets only — there are no Ethernet frames to capture, no broadcast domain on the tunnel side. The packets the reflectors need to relay don't exist on the tunnel side.
The only way to push mDNS across a WireGuard tunnel is to wrap the tunnel in an L2 overlay (VXLAN, GRETAP, L2TPv3 inside WireGuard). The iPhone cannot participate in such an overlay — there's no API for it without jailbreaking — so this is a dead end for roaming.
Direct lockdownd on TCP 62078 (the legacy protocol)
Before iOS 17, iPhones spoke plain TLS-wrapped property-list messages on TCP 62078 via usbmuxd. The protocol is still present on the device. libimobiledevice / ideviceinstaller and pymobiledevice3's LockdownClient both speak it. You'd think: just skip Bonjour, dial iphone-tailnet-ip:62078 directly with a stored pair record, ask installation_proxy to install the IPA.
I tried this. TCP 62078 accepts the connection, then resets on the first byte of any plausible protocol (length-prefixed XML plist, HTTP/2, HTTP/1.1, raw TLS). I sent literal zero bytes — connection idled, then closed cleanly. Sent anything meaningful — instant RST. Apple appears to have tightened the network-side lockdownd listener in iOS 17+ to require an active CoreDevice trusted tunnel before serving anything. The legacy protocol is effectively no longer exposed over Wi-Fi/tailnet.
pymobiledevice3 apps install --rsd HOST PORT
pymobiledevice3's CLI accepts --rsd HOST PORT for a direct RSD endpoint. I tried --rsd 100.64.0.5 58783 (the documented "hardcoded" RSD port) — refused. --rsd 100.64.0.5 49152 — TCP open but resets on handshake (wrong protocol — that port is RemotePairing, not RSD; the actual RSD port is allocated dynamically and announced via Bonjour TXT records we can't see over tailnet).
This is the recursive trap: to talk to RSD you need its dynamic port; to know the port you need Bonjour; we don't have Bonjour over tailnet.
Naive Bonjour spoof on Mac
The promising-looking idea: dns-sd -P lets you register fake Bonjour records into Mac's own mDNSResponder. So advertise iPhone's services locally on Mac with the hostname targeting iPhone's tailnet IP. remoted browses Bonjour, sees the announcement, connects to the iPhone over tailnet. Apple's mDNS API can't tell whether a record came from a real device on the LAN or from a local dns-sd -P registration.
This almost works. Discovery succeeds — xcrun devicectl list devices shows the iPhone as available (paired). But devicectl install fails. The reason, from log show --predicate 'process == "remotepairingd"':
[C905.1.1 Hostname#... interface: en0[802.11], scoped, ipv4, dns, uses wifi, ...]
nw_socket_connect connectx [C905:2] connectx(..., [srcif=14, ...]) failed: [61: Connection refused]
remotepairingd scopes the outgoing TCP connection to the interface that announced the Bonjour record — en0 (Mac's Wi-Fi). It's calling nw_parameters_set_required_interface somewhere internal. So when our spoof points the hostname at 100.64.0.5 (iPhone's tailnet IP), the SYN goes out en0 — which has no route to 100.64.0.5 — into nothing. Refused or timed out.
This is by design: Bonjour is a same-link discovery system, and CoreDevice trusts that property by binding to the link. Excellent engineering for the home-network case; a wall for our roaming case.
I tried scoping the dns-sd -P to the Tailscale interface (-i utun4) — registration succeeds but the records are invisible because utun4 is point-to-point and mDNSResponder doesn't browse on it. Confirms Apple's own constraint that we can't sneak around.
I also confused myself by thinking the trusted tunnel listener was bound only to the USB-Ethernet IPv6 interface (because TCP probes to the dynamic port over tailnet were refused). They were refused because the trusted tunnel is QUIC over UDP, not TCP. The same UDP port responded fine on a UDP probe (nc -vzu). That oversight cost a few hours.
The actual working architecture
Once both real facts were on the table — remotepairingd scopes to the discovery interface, and iPhone's trusted-tunnel listener IS reachable on tailnet (UDP) — the answer falls out:
Spoof Bonjour pointing the iPhone's hostname to Mac's own en0 IP, and run a local proxy that bridges to the iPhone over Tailscale.
remotepairingd is satisfied: the discovered service appears reachable on the discovery interface (en0), so its scoped connection lands on a port that's actually listening — on Mac itself. The proxy then forwards the bytes to the iPhone via Tailscale. The CoreDevice handshake and TLS are end-to-end between remotepairingd and the iPhone; the proxy is a dumb byte relay, never inspecting or terminating TLS. Apple's interface-scoping invariant is honored at the OS level; the bridging happens at the application level above it.
Components
The Mac runs a single LaunchAgent (com.example.coredevice-tailnet) that does three things in parallel:
-
Bonjour spoof.
dns-sd -Pregisters three records intomDNSResponder:-
_remotepairing._tcp(instance UUID + TXT pulled from the iPhone via one-time USB capture) _remoted._tcp_apple-mobdev2._tcp
-
All target the hostname My-iPhone.local (the iPhone's actual self-reported .local name). dns-sd -P's integrated A/AAAA registration resolves that hostname to Mac's own en0 IP — e.g. 192.168.1.190. /etc/hosts mirrors that as belt-and-braces.
RemotePairing proxy. A
socatinstance bindingen0_ip:49152and forwarding toiphone_tailnet_ip:49152for both TCP and UDP. This is the CoreDevice front door — the first thingremotepairingdconnects to after discovering the spoofed Bonjour record.Trusted-tunnel port-range proxy. A range of
socatinstances bindingen0_ip:55000-55300and forwarding to the corresponding port on the iPhone's tailnet IP, both protocols. iPhone allocates its trusted-tunnel listener on a fresh port per pairing session (we observed55110-55115); the range covers comfortably.
The script:
#!/bin/bash
set -euo pipefail
IPHONE_TAILNET_IP="100.64.0.5"
SPOOF_HOSTNAME="My-iPhone.local"
MAC_EN0_IP="$(ifconfig en0 | awk '/inet / {print $2; exit}')"
dns-sd -P "$RP_INSTANCE" _remotepairing._tcp local 49152 \
"$SPOOF_HOSTNAME" "$MAC_EN0_IP" \
identifier="$RP_INSTANCE" authTag="$RP_AUTHTAG" ver=24 minVer=8 flags=0 &
socat TCP-LISTEN:49152,bind="$MAC_EN0_IP",reuseaddr,fork TCP:"$IPHONE_TAILNET_IP":49152 &
socat UDP-LISTEN:49152,bind="$MAC_EN0_IP",reuseaddr,fork UDP:"$IPHONE_TAILNET_IP":49152 &
for port in $(seq 55000 55300); do
socat TCP-LISTEN:"$port",bind="$MAC_EN0_IP",reuseaddr,fork TCP:"$IPHONE_TAILNET_IP":"$port" &
socat UDP-LISTEN:"$port",bind="$MAC_EN0_IP",reuseaddr,fork UDP:"$IPHONE_TAILNET_IP":"$port" &
done
wait
Wrapped in a LaunchAgent with KeepAlive: { SuccessfulExit: false, NetworkState: true } so it restarts on network changes; ThrottleInterval: 30s, NumberOfFiles: 4096 for the open-file headroom.
Capturing the Bonjour TXT data
The $RP_INSTANCE UUID and $RP_AUTHTAG are pulled from iPhone's real Bonjour announcement via a one-time USB capture:
# iPhone plugged via USB, Mac sees its native Bonjour announcement on
# the USB-Ethernet interface
dns-sd -B _remotepairing._tcp local.
# note instance UUID
dns-sd -L "<instance>" _remotepairing._tcp local.
# note port (49152), target hostname, and TXT record (identifier, authTag, ver, minVer, flags)
The values went straight into the script. iPhone rotates the UUID/authTag per Wi-Fi session — we observed at least one rotation. So far the stale captured values keep passing remoted's pairing handshake in our tests, because CoreDevice auth is certificate-based (the pair record) rather than TXT-based. If a future iOS tightens this, we'd add a pymobiledevice3 lockdown save-pair-record + dns-sd capture step to the runbook.
Verification
iPhone unplugged from USB, sitting on a personal hotspot's Wi-Fi (different SSID, different LAN, Mac not on that network), Tailscale active. From the Linux build host:
$ make sideload
...
ARCHIVE SUCCEEDED
EXPORT SUCCEEDED
[sideload] preflight checks...
ok: coredevice-tailnet LaunchAgent running
[sideload] device 09C62718-... state=available (paired)
00:22:06 Acquired tunnel connection to device.
00:22:06 Enabling developer disk image services.
00:22:07 Acquired usage assertion.
App installed:
• bundleID: com.example.hellotest
The Acquired tunnel connection to device is the moneymaker: that's remoted completing the CoreDevice handshake over Tailscale via the local proxy. From there everything else (DDI services, install) just works.
Why this is robust
Every layer here uses Apple's own APIs as documented:
-
dns-sd -Pis the documented "proxy registration" mode ofDNSServiceRegisterwithkDNSServiceFlagsProxy. macOS treats the records exactly as if they came from a real Bonjour announcer on the LAN. -
socatis byte-relaying TCP/UDP; nothing private about it. -
remotepairingd's interface-scoping invariant is preserved. It connects locally onen0because that's where it discovered the service. - The proxy never inspects, terminates, or modifies TLS. CoreDevice's certificate-based pairing auth runs end-to-end remoted ↔ iPhone exactly as it would over USB or LAN.
No kernel extensions. No PF rules. No mDNS daemon patching. No SIP-disabled tricks. No jailbreak. No modification of Apple binaries. The whole thing is a user-mode LaunchAgent and three CLI tools that ship with macOS + Homebrew (socat).
Known fragilities
-
Bonjour record rotation. If iPhone rotates the
_remotepairing._tcpUUID or authTag and Apple eventually checks them, the captured values need refreshing. Mitigation: USB-plug for 30 seconds + recapture. -
Mac en0 IP changes. When DHCP gives Mac a new LAN IP, restart the LaunchAgent (it reads en0 IP at startup) and update
/etc/hosts. Could be scripted with a small sudoers-permitted helper. -
Tunnel port range. Observed
55110-55115; covered55000-55300. If Apple ever shifts the allocator out of this window, expand. - DDI staging. First USB pair stages the personalized Developer Disk Image on iPhone. Sticky until iPhone reboot, after which a brief USB re-tether is required to re-stage. (This is the only reason USB isn't permanently absent from the workflow.)
Free Personal Team 7-day cert expiry. Orthogonal — re-sideload weekly. The Tailscale path makes re-sideloads cost ~10 seconds with no physical access.
iPhone must be associated to a Wi-Fi SSID — radio-on-no-association is not sufficient. Confirmed by testing: Wi-Fi off entirely → all CoreDevice ports refused, listener not bound. Wi-Fi radio on but not joined to any SSID (auto-connect disabled, manually toggled) → same outcome, ports refused. Wi-Fi joined to any SSID (tethered phone hotspot, captive Wi-Fi, foreign Wi-Fi) → listener binds, install works over Tailscale.
This is consistent with Apple's broader pattern: developer services hang off the same WiFiManager association gate that governs AirDrop, Handoff, and Continuity activation. The framework distinguishes between radio-on-scanning and associated-to-network as separate states; subsystems including remotepairingd key off the latter. Tailscale's utun interface doesn't count — only the real Wi-Fi association flips the bit.
Practical impact is small. The SSID need not provide internet, need not reach Mac directly, and need not be trusted — Tailscale carries the actual transport. Any joined Wi-Fi (including tethering off a second phone's hotspot) suffices. The genuine pure-cellular case where no Wi-Fi exists anywhere is rare enough that this isn't a deal-breaker; in practice the iPhone has some Wi-Fi network within reach, and that's enough.
Apple's own mDNSResponder documentation (code-comments) suggest their reason for restricting multi-cast to Wifi is to reduce cellular data bills. Early 2010s thinking.
// We only attempt to send and receive multicast packets on interfaces that are
// (a) flagged as multicast-capable
// (b) *not* flagged as point-to-point (e.g. modem)
// Typically point-to-point interfaces are modems (including mobile-phone pseudo-modems), and we don't want
// to run up the user's bill sending multicast traffic over a link where there's only a single device at the
// other end, and that device (e.g. a modem bank) is probably not answering Multicast DNS queries anyway.
Commit [Source]
Why publish this
There are a lot of "you can't sideload from outside the LAN" results in the iOS-dev community, and they're all true relative to the official tools. The structural blockers people cite (mDNSResponder skips point-to-point interfaces, CoreDevice scopes to the discovery link, RSD ports are dynamic, etc.) are all real. But the conclusion that follows — "therefore you can't" — only holds if you accept the layering.
The trick that opens it is letting the OS-level invariants stand, and bridging at the layer above. remotepairingd thinks it's talking to a Bonjour-announced peer on en0. It is — that peer just happens to forward bytes onward over Tailscale. Apple's invariants are preserved on the Mac side; on the wire, what matters is end-to-end TLS to a real iPhone, and Tailscale carries that fine.
Three things made this work:
- Reading the syslog (
log show --predicate 'process == "remotepairingd"') and seeing theconnectx... [Connection refused]withsrcif=14, scoped, ipv4, dns, uses wifi— the smoking gun for interface scoping. - Re-reading the port probes carefully: UDP-succeeded means the listener exists, even if TCP refused on the same port. The trusted tunnel is QUIC.
- An external reviewer who looked at the data and suggested "what if the proxy isn't a remote interceptor but a local destination?" That reframe turned a research project into a working pipeline.
For the next person hitting this wall: read log show on remotepairingd and look at what interface the connection scopes to. The answer is in that line.
Top comments (0)