<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Kevin Paterson</title>
    <description>The latest articles on DEV Community by Kevin Paterson (@kvnpt).</description>
    <link>https://dev.to/kvnpt</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3928739%2F819c7be1-fd6e-4978-9fed-dd19c67e2a89.jpg</url>
      <title>DEV Community: Kevin Paterson</title>
      <link>https://dev.to/kvnpt</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/kvnpt"/>
    <language>en</language>
    <item>
      <title>How to remotely iterate &amp; deploy your sideloaded iOS-apps over tailnet</title>
      <dc:creator>Kevin Paterson</dc:creator>
      <pubDate>Tue, 19 May 2026 01:53:34 +0000</pubDate>
      <link>https://dev.to/kvnpt/how-to-remotely-iterate-deploy-your-sideloaded-ios-apps-over-tailnet-jak</link>
      <guid>https://dev.to/kvnpt/how-to-remotely-iterate-deploy-your-sideloaded-ios-apps-over-tailnet-jak</guid>
      <description>&lt;h2&gt;
  
  
  Sideloading an iPhone over Tailscale from anywhere, no USB, Touch grass while CI/CD on iOS.
&lt;/h2&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;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 &lt;code&gt;xcrun devicectl&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;dns-sd -P&lt;/code&gt; Bonjour fabrication and &lt;code&gt;socat&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;This post walks through the protocol stack, the wrong turns (instructive), and the actual working bridge.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Build host&lt;/strong&gt; (Linux): edits the SwiftUI source, drives &lt;code&gt;xcodebuild&lt;/code&gt; over SSH, holds the canonical project files. Reachable on a Tailscale tailnet (Headscale-coordinated). (I'm on an OCI free-tier shape running Claude Code - &lt;a href="//guide.kevinpaul.au"&gt;how to get your own&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mac&lt;/strong&gt;: only the build slave. Has Xcode, the signing identity, and runs &lt;code&gt;xcodebuild&lt;/code&gt; / &lt;code&gt;xcrun devicectl&lt;/code&gt;. On the same tailnet.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;iPhone&lt;/strong&gt;: 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 &lt;code&gt;.ipa&lt;/code&gt; and run it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The pipeline is &lt;code&gt;edit on Linux → sync to Mac → xcodebuild archive + export → devicectl install → iPhone runs the app&lt;/code&gt;. The first four steps were already working over SSH/rsync; the last step is the focus here.&lt;/p&gt;

&lt;h2&gt;
  
  
  The iOS-17+ developer-services stack
&lt;/h2&gt;

&lt;p&gt;For our purposes, the relevant protocol layers on an iPhone are:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Bonjour / &lt;code&gt;_remotepairing._tcp.local.&lt;/code&gt;&lt;/strong&gt;: iPhone advertises a CoreDevice front-door service on TCP port &lt;code&gt;49152&lt;/code&gt; via mDNS. Discovery is &lt;em&gt;intentionally&lt;/em&gt; link-local: this is what makes Xcode "see" connected iPhones on the same Wi-Fi.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;RemoteServiceDiscovery (RSD)&lt;/strong&gt;: 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Trusted tunnel&lt;/strong&gt;: 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 &lt;code&gt;55110-55115&lt;/code&gt; across attempts on iOS 26.5.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CoreDevice service multiplexer&lt;/strong&gt;: AFC file transfers, &lt;code&gt;installation_proxy&lt;/code&gt;, debug interfaces, etc. all ride the trusted tunnel.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The whole thing is gated on Bonjour announcing the iPhone to &lt;code&gt;mDNSResponder&lt;/code&gt; on Mac, with &lt;code&gt;remoted&lt;/code&gt; then driving the rest of the stack.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Tailscale (or any L3 VPN) breaks the default flow
&lt;/h2&gt;

&lt;p&gt;Tailscale gives you a unicast IP route between any two devices in your tailnet, anywhere on the internet. That's &lt;em&gt;all&lt;/em&gt; it gives you: no broadcast, no multicast, no L2 frames. Bonjour relies entirely on link-local multicast (&lt;code&gt;224.0.0.251&lt;/code&gt;, TTL 1). iOS's &lt;code&gt;mDNSResponder&lt;/code&gt; &lt;em&gt;explicitly skips point-to-point interfaces&lt;/em&gt; (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.&lt;/p&gt;

&lt;p&gt;So a roaming iPhone on Tailscale is, from Mac's &lt;code&gt;remoted&lt;/code&gt; viewpoint, invisible. &lt;code&gt;xcrun devicectl list devices&lt;/code&gt; shows the iPhone as &lt;code&gt;unavailable&lt;/code&gt;. No install possible.&lt;/p&gt;

&lt;h2&gt;
  
  
  The wrong turns
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Bonjour reflectors
&lt;/h3&gt;

&lt;p&gt;The obvious-looking answer is "run a Bonjour reflector". There are several: &lt;code&gt;avahi-daemon --reflector&lt;/code&gt;, &lt;code&gt;Gandem/bonjour-reflector&lt;/code&gt;, 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.&lt;/p&gt;

&lt;p&gt;None of these apply to Tailscale. Reflectors operate at the L2 frame layer (&lt;code&gt;gopacket&lt;/code&gt;+&lt;code&gt;pcap&lt;/code&gt;+802.1Q tags, or at minimum a &lt;code&gt;tcpdump&lt;/code&gt;-able interface receiving multicast). &lt;code&gt;tailscale0&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  Direct lockdownd on TCP 62078 (the legacy protocol)
&lt;/h3&gt;

&lt;p&gt;Before iOS 17, iPhones spoke plain TLS-wrapped property-list messages on TCP &lt;code&gt;62078&lt;/code&gt; via &lt;code&gt;usbmuxd&lt;/code&gt;. The protocol is still present on the device. &lt;code&gt;libimobiledevice&lt;/code&gt; / &lt;code&gt;ideviceinstaller&lt;/code&gt; and &lt;code&gt;pymobiledevice3&lt;/code&gt;'s &lt;code&gt;LockdownClient&lt;/code&gt; both speak it. You'd think: just skip Bonjour, dial &lt;code&gt;iphone-tailnet-ip:62078&lt;/code&gt; directly with a stored pair record, ask &lt;code&gt;installation_proxy&lt;/code&gt; to install the IPA.&lt;/p&gt;

&lt;p&gt;I tried this. TCP &lt;code&gt;62078&lt;/code&gt; 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 &lt;code&gt;lockdownd&lt;/code&gt; 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.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;pymobiledevice3 apps install --rsd HOST PORT&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;pymobiledevice3&lt;/code&gt;'s CLI accepts &lt;code&gt;--rsd HOST PORT&lt;/code&gt; for a direct RSD endpoint. I tried &lt;code&gt;--rsd 100.64.0.5 58783&lt;/code&gt; (the documented "hardcoded" RSD port) — refused. &lt;code&gt;--rsd 100.64.0.5 49152&lt;/code&gt; — 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).&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  Naive Bonjour spoof on Mac
&lt;/h3&gt;

&lt;p&gt;The promising-looking idea: &lt;code&gt;dns-sd -P&lt;/code&gt; lets you register fake Bonjour records into Mac's own &lt;code&gt;mDNSResponder&lt;/code&gt;. So advertise iPhone's services locally on Mac with the hostname targeting iPhone's tailnet IP. &lt;code&gt;remoted&lt;/code&gt; 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 &lt;code&gt;dns-sd -P&lt;/code&gt; registration.&lt;/p&gt;

&lt;p&gt;This &lt;em&gt;almost&lt;/em&gt; works. Discovery succeeds — &lt;code&gt;xcrun devicectl list devices&lt;/code&gt; shows the iPhone as &lt;code&gt;available (paired)&lt;/code&gt;. But &lt;code&gt;devicectl install&lt;/code&gt; fails. The reason, from &lt;code&gt;log show --predicate 'process == "remotepairingd"'&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[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]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;remotepairingd&lt;/code&gt; scopes the outgoing TCP connection to the interface that announced the Bonjour record — &lt;code&gt;en0&lt;/code&gt; (Mac's Wi-Fi). It's calling &lt;code&gt;nw_parameters_set_required_interface&lt;/code&gt; somewhere internal. So when our spoof points the hostname at &lt;code&gt;100.64.0.5&lt;/code&gt; (iPhone's tailnet IP), the SYN goes out &lt;code&gt;en0&lt;/code&gt; — which has no route to &lt;code&gt;100.64.0.5&lt;/code&gt; — into nothing. Refused or timed out.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;I tried scoping the &lt;code&gt;dns-sd -P&lt;/code&gt; to the Tailscale interface (&lt;code&gt;-i utun4&lt;/code&gt;) — registration succeeds but the records are invisible because &lt;code&gt;utun4&lt;/code&gt; is point-to-point and &lt;code&gt;mDNSResponder&lt;/code&gt; doesn't browse on it. Confirms Apple's own constraint that we can't sneak around.&lt;/p&gt;

&lt;p&gt;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 &lt;strong&gt;QUIC over UDP&lt;/strong&gt;, not TCP. The same UDP port responded fine on a UDP probe (&lt;code&gt;nc -vzu&lt;/code&gt;). That oversight cost a few hours.&lt;/p&gt;

&lt;h2&gt;
  
  
  The actual working architecture
&lt;/h2&gt;

&lt;p&gt;Once both real facts were on the table — &lt;code&gt;remotepairingd&lt;/code&gt; scopes to the discovery interface, and iPhone's trusted-tunnel listener IS reachable on tailnet (UDP) — the answer falls out:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;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.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;remotepairingd&lt;/code&gt; 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 &lt;code&gt;remotepairingd&lt;/code&gt; 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.&lt;/p&gt;

&lt;h3&gt;
  
  
  Components
&lt;/h3&gt;

&lt;p&gt;The Mac runs a single LaunchAgent (&lt;code&gt;com.example.coredevice-tailnet&lt;/code&gt;) that does three things in parallel:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Bonjour spoof.&lt;/strong&gt; &lt;code&gt;dns-sd -P&lt;/code&gt; registers three records into &lt;code&gt;mDNSResponder&lt;/code&gt;:

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;_remotepairing._tcp&lt;/code&gt; (instance UUID + TXT pulled from the iPhone via one-time USB capture)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;_remoted._tcp&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;_apple-mobdev2._tcp&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;All target the hostname &lt;code&gt;My-iPhone.local&lt;/code&gt; (the iPhone's actual self-reported &lt;code&gt;.local&lt;/code&gt; name). &lt;code&gt;dns-sd -P&lt;/code&gt;'s integrated A/AAAA registration resolves that hostname to &lt;strong&gt;Mac's own en0 IP&lt;/strong&gt; — e.g. &lt;code&gt;192.168.1.190&lt;/code&gt;. &lt;code&gt;/etc/hosts&lt;/code&gt; mirrors that as belt-and-braces.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;RemotePairing proxy.&lt;/strong&gt; A &lt;code&gt;socat&lt;/code&gt; instance binding &lt;code&gt;en0_ip:49152&lt;/code&gt; and forwarding to &lt;code&gt;iphone_tailnet_ip:49152&lt;/code&gt; for both TCP and UDP. This is the CoreDevice front door — the first thing &lt;code&gt;remotepairingd&lt;/code&gt; connects to after discovering the spoofed Bonjour record.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Trusted-tunnel port-range proxy.&lt;/strong&gt; A range of &lt;code&gt;socat&lt;/code&gt; instances binding &lt;code&gt;en0_ip:55000-55300&lt;/code&gt; and 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 observed &lt;code&gt;55110-55115&lt;/code&gt;); the range covers comfortably.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The script:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/bin/bash&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-euo&lt;/span&gt; pipefail

&lt;span class="nv"&gt;IPHONE_TAILNET_IP&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"100.64.0.5"&lt;/span&gt;
&lt;span class="nv"&gt;SPOOF_HOSTNAME&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"My-iPhone.local"&lt;/span&gt;
&lt;span class="nv"&gt;MAC_EN0_IP&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;ifconfig en0 | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'/inet / {print $2; exit}'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

dns-sd &lt;span class="nt"&gt;-P&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$RP_INSTANCE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; _remotepairing._tcp &lt;span class="nb"&gt;local &lt;/span&gt;49152 &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SPOOF_HOSTNAME&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$MAC_EN0_IP&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nv"&gt;identifier&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$RP_INSTANCE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nv"&gt;authTag&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$RP_AUTHTAG&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nv"&gt;ver&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;24 &lt;span class="nv"&gt;minVer&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;8 &lt;span class="nv"&gt;flags&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0 &amp;amp;

socat TCP-LISTEN:49152,bind&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$MAC_EN0_IP&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;,reuseaddr,fork TCP:&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$IPHONE_TAILNET_IP&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;:49152 &amp;amp;
socat UDP-LISTEN:49152,bind&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$MAC_EN0_IP&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;,reuseaddr,fork UDP:&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$IPHONE_TAILNET_IP&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;:49152 &amp;amp;

&lt;span class="k"&gt;for &lt;/span&gt;port &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;seq &lt;/span&gt;55000 55300&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
    &lt;/span&gt;socat TCP-LISTEN:&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$port&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;,bind&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$MAC_EN0_IP&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;,reuseaddr,fork TCP:&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$IPHONE_TAILNET_IP&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;:&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$port&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &amp;amp;
    socat UDP-LISTEN:&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$port&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;,bind&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$MAC_EN0_IP&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;,reuseaddr,fork UDP:&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$IPHONE_TAILNET_IP&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;:&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$port&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &amp;amp;
&lt;span class="k"&gt;done
&lt;/span&gt;&lt;span class="nb"&gt;wait&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Wrapped in a LaunchAgent with &lt;code&gt;KeepAlive: { SuccessfulExit: false, NetworkState: true }&lt;/code&gt; so it restarts on network changes; &lt;code&gt;ThrottleInterval: 30s&lt;/code&gt;, &lt;code&gt;NumberOfFiles: 4096&lt;/code&gt; for the open-file headroom.&lt;/p&gt;

&lt;h3&gt;
  
  
  Capturing the Bonjour TXT data
&lt;/h3&gt;

&lt;p&gt;The &lt;code&gt;$RP_INSTANCE&lt;/code&gt; UUID and &lt;code&gt;$RP_AUTHTAG&lt;/code&gt; are pulled from iPhone's real Bonjour announcement via a one-time USB capture:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# iPhone plugged via USB, Mac sees its native Bonjour announcement on&lt;/span&gt;
&lt;span class="c"&gt;# the USB-Ethernet interface&lt;/span&gt;
dns-sd &lt;span class="nt"&gt;-B&lt;/span&gt; _remotepairing._tcp local.
&lt;span class="c"&gt;# note instance UUID&lt;/span&gt;
dns-sd &lt;span class="nt"&gt;-L&lt;/span&gt; &lt;span class="s2"&gt;"&amp;lt;instance&amp;gt;"&lt;/span&gt; _remotepairing._tcp local.
&lt;span class="c"&gt;# note port (49152), target hostname, and TXT record (identifier, authTag, ver, minVer, flags)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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 &lt;code&gt;remoted&lt;/code&gt;'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 &lt;code&gt;pymobiledevice3 lockdown save-pair-record + dns-sd capture&lt;/code&gt; step to the runbook.&lt;/p&gt;

&lt;h3&gt;
  
  
  Verification
&lt;/h3&gt;

&lt;p&gt;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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;make sideload
...
ARCHIVE SUCCEEDED
EXPORT SUCCEEDED
&lt;span class="o"&gt;[&lt;/span&gt;sideload] preflight checks...
  ok: coredevice-tailnet LaunchAgent running
&lt;span class="o"&gt;[&lt;/span&gt;sideload] device 09C62718-... &lt;span class="nv"&gt;state&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;available &lt;span class="o"&gt;(&lt;/span&gt;paired&lt;span class="o"&gt;)&lt;/span&gt;
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
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;Acquired tunnel connection to device&lt;/code&gt; is the moneymaker: that's &lt;code&gt;remoted&lt;/code&gt; completing the CoreDevice handshake over Tailscale via the local proxy. From there everything else (DDI services, install) just works.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this is robust
&lt;/h2&gt;

&lt;p&gt;Every layer here uses Apple's own APIs as documented:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;dns-sd -P&lt;/code&gt; is the documented "proxy registration" mode of &lt;code&gt;DNSServiceRegister&lt;/code&gt; with &lt;code&gt;kDNSServiceFlagsProxy&lt;/code&gt;. macOS treats the records exactly as if they came from a real Bonjour announcer on the LAN.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;socat&lt;/code&gt; is byte-relaying TCP/UDP; nothing private about it.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;remotepairingd&lt;/code&gt;'s interface-scoping invariant is preserved. It connects locally on &lt;code&gt;en0&lt;/code&gt; because that's where it discovered the service.&lt;/li&gt;
&lt;li&gt;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.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;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 (&lt;code&gt;socat&lt;/code&gt;).&lt;/p&gt;

&lt;h2&gt;
  
  
  Known fragilities
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Bonjour record rotation.&lt;/strong&gt; If iPhone rotates the &lt;code&gt;_remotepairing._tcp&lt;/code&gt; UUID or authTag and Apple eventually checks them, the captured values need refreshing. Mitigation: USB-plug for 30 seconds + recapture.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mac en0 IP changes.&lt;/strong&gt; When DHCP gives Mac a new LAN IP, restart the LaunchAgent (it reads en0 IP at startup) and update &lt;code&gt;/etc/hosts&lt;/code&gt;. Could be scripted with a small sudoers-permitted helper.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tunnel port range.&lt;/strong&gt; Observed &lt;code&gt;55110-55115&lt;/code&gt;; covered &lt;code&gt;55000-55300&lt;/code&gt;. If Apple ever shifts the allocator out of this window, expand.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DDI staging.&lt;/strong&gt; 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.)&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Free Personal Team 7-day cert expiry.&lt;/strong&gt; Orthogonal — re-sideload weekly. The Tailscale path makes re-sideloads cost ~10 seconds with no physical access.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;iPhone must be associated to a Wi-Fi SSID&lt;/strong&gt; — 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.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This is consistent with Apple's broader pattern: developer services hang off the same &lt;code&gt;WiFiManager&lt;/code&gt; association gate that governs AirDrop, Handoff, and Continuity activation. The framework distinguishes between &lt;em&gt;radio-on-scanning&lt;/em&gt; and &lt;em&gt;associated-to-network&lt;/em&gt; as separate states; subsystems including &lt;code&gt;remotepairingd&lt;/code&gt; key off the latter. Tailscale's &lt;code&gt;utun&lt;/code&gt; interface doesn't count — only the real Wi-Fi association flips the bit.&lt;/p&gt;

&lt;p&gt;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 &lt;em&gt;some&lt;/em&gt; Wi-Fi network within reach, and that's enough.&lt;/p&gt;

&lt;p&gt;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.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// 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.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://github.com/apple-oss-distributions/mDNSResponder/blob/8f70f98fc1d0cf439ca3a6470be6ad8ac2bcc019/mDNSMacOSX/mDNSMacOSX.c#L192" rel="noopener noreferrer"&gt;Commit&lt;/a&gt; [Source]&lt;/p&gt;

&lt;h2&gt;
  
  
  Why publish this
&lt;/h2&gt;

&lt;p&gt;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 (&lt;code&gt;mDNSResponder&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;The trick that opens it is &lt;strong&gt;letting the OS-level invariants stand, and bridging at the layer above&lt;/strong&gt;. &lt;code&gt;remotepairingd&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;Three things made this work:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Reading the syslog (&lt;code&gt;log show --predicate 'process == "remotepairingd"'&lt;/code&gt;) and seeing the &lt;code&gt;connectx... [Connection refused]&lt;/code&gt; with &lt;code&gt;srcif=14, scoped, ipv4, dns, uses wifi&lt;/code&gt; — the smoking gun for interface scoping.&lt;/li&gt;
&lt;li&gt;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.&lt;/li&gt;
&lt;li&gt;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.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;For the next person hitting this wall: read &lt;code&gt;log show&lt;/code&gt; on &lt;code&gt;remotepairingd&lt;/code&gt; and look at what interface the connection scopes to. The answer is in that line.&lt;/p&gt;

</description>
      <category>ios</category>
      <category>tailscale</category>
      <category>networking</category>
      <category>macos</category>
    </item>
  </channel>
</rss>
