Federate iPhones across Macs by registering one tunneld as an upstream of another.
Project description
tunneldup
Federate iPhones connected to one Mac into another Mac's pymobiledevice3 tunneld, over WireGuard.
You plug an iPhone into Mac A. Mac B runs one command and now pymobiledevice3 lockdown info --tunnel <UDID> on B works against A's iPhone, exactly as if the device were attached locally. The trick is a small REST extension to pymobiledevice3's tunneld (an /upstream registry) and a thin WireGuard layer so B can actually route to the device's tunnel address.
tunneldup does not rewrite or replace tunneld — it composes with the tunneld you already have running. A's tunneld serves A's iPhones; B's tunneld serves B's iPhones plus A's (after federation). You can chain more machines (C, D, …) the same way.
Architecture
┌─ Mac A (iPhones plugged in) ───────────┐ ┌─ Mac B (no iPhones) ────────────────┐
│ pymobiledevice3 remote tunneld │ │ pymobiledevice3 remote tunneld │
│ ← serves A's devices on :49151 │ │ ← upstream registry includes │
│ │ │ http://A:9246/tunneld │
│ tunneldup host --web │ <─WG──> │ tunneldup add A │
│ ← WireGuard server :51820 │ │ ← installs WG client + POSTs │
│ ← web UI :9246 (config, devices, │ │ /upstream to local tunneld │
│ /tunneld JSON passthrough) │ │ │
└────────────────────────────────────────┘ └─────────────────────────────────────┘
↓
pymobiledevice3 lockdown info --tunnel <A's UDID>
↳ tunneld GET / merges local + every /upstream
↳ TCP connect to A's iPhone tunnel address routes over WG
The federation is REST-only: POST /upstream {url} registers, DELETE /upstream {url} removes, GET / on tunneld fetches every registered upstream in parallel and merges the device entries by UDID. The WireGuard tunnel only exists so B can route to the per-device ULA addresses A's tunneld hands out (fd…::1/64 per session); the federation handshake itself is plain HTTP.
Requirements
- macOS, Python ≥ 3.10.
brew install wireguard-tools wireguard-goon every Mac that will runtunneldup hostortunneldup add.pymobiledevice3≥ the version that includes the/upstreamREST endpoints (the fork at github.com/doronz88/pymobiledevice3, branchfeature/tunneld-upstreams).
Install
# either:
uv tool install tunneldup --from git+https://github.com/doronz88/tunneldup.git
# or:
pip install git+https://github.com/doronz88/tunneldup.git
Usage
On the Mac with iPhones plugged in (A)
In separate shells:
sudo pymobiledevice3 remote tunneld # your normal tunneld
sudo tunneldup host --web # WG server + tunneld bridge + web UI
tunneldup host autodetects the LAN endpoint (192.168.x.x if present), falling back to the public WAN IP via ifconfig.me only when no private IP is available. To force a specific endpoint, pass --endpoint.
The web UI is at http://<A's-IP>:9246. It serves
| path | what |
|---|---|
/ |
HTML dashboard — device cards, per-device action buttons, command runner |
/devices |
flat JSON device list (UDID, tunnel address, transport, interface) |
/tunneld |
verbatim pass-through of A's tunneld GET / — this is the URL another machine registers as an upstream |
/config |
A's WireGuard client config (used by tunneldup add) |
POST /exec |
runs pymobiledevice3 <args> on A, returns stdout/stderr/exit code |
On a remote Mac (B)
sudo pymobiledevice3 remote tunneld # B's normal tunneld
sudo tunneldup add 192.168.0.175 # connect to A
tunneldup add does exactly four things, then holds until Ctrl-C:
GET http://A:9246/devicesto populate the interactive picker.GET http://A:9246/configand brings up WireGuard on B.POST http://127.0.0.1:49151/upstream {"url":"http://A:9246/tunneld"}against B's local tunneld.- Prints
pymobiledevice3 lockdown info --tunnel <UDID>snippets for the device you picked.
On Ctrl-C / SIGTERM, the cleanup finally block runs: DELETE /upstream then wg-quick down. No stale upstream entry, no leaked WG interface.
After that, on B:
pymobiledevice3 lockdown info --tunnel <A's-UDID>
pymobiledevice3 apps list --tunnel <A's-UDID>
pymobiledevice3 syslog live --tunnel <A's-UDID>
Manual upstream management (no WireGuard, REST only)
If A and B already share a network and A's tunneld is bound to a routable address, you don't need WireGuard. Just register manually:
tunneldup upstreams # list registered upstreams
tunneldup remove http://A:9246/tunneld # deregister one
add is the "give me the full lifecycle" command; upstreams / remove are the bare REST primitives.
CLI reference
| command | what |
|---|---|
tunneldup host [--web] [--endpoint <ip>] |
bring up WG server + tunneld bridge (+ web UI); regenerates client.conf |
tunneldup client <client.conf> |
bring up WG using a manually-shipped config (alternative to add) |
tunneldup add <host>[:<port>] |
one-shot: fetch conf, WG up, register upstream, picker, hold until Ctrl-C |
tunneldup upstreams |
list registered upstream URLs |
tunneldup remove <host> |
deregister an upstream |
tunneldup web |
run only the web UI (use host --web if you want WG too) |
tunneldup devices |
list iPhones reachable on this Mac via USB (requires sudo) |
tunneldup status |
show WG state + tunneld reachability |
tunneldup config |
print the host-side client config |
tunneldup down |
tear down WG on either side |
All commands respect TUNNELDUP_DIR (default ~/.config/tunneldup) for configs and keys, and TUNNELDUP_TUNNELD_URL to point the web UI at a non-default tunneld.
Configuration
| file | purpose |
|---|---|
~/.config/tunneldup/meta.json |
persisted WireGuard keypair for the host. Do not commit this. |
~/.config/tunneldup/server.conf |
WireGuard server config (regenerated each tunneldup host run) |
~/.config/tunneldup/client.conf |
WireGuard client config served at GET /config |
WireGuard networking:
| IPv4 | IPv6 | |
|---|---|---|
| WG net | 10.42.0.0/24 |
fdaa:1234::/64 |
| server | 10.42.0.1 |
fdaa:1234::1 |
| client | 10.42.0.2 |
fdaa:1234::2 |
| listen port | UDP 51820 |
|
| client AllowedIPs | 10.42.0.0/24, fdaa:1234::/64, fd00::/8 |
fd00::/8 is in AllowedIPs so the client can route to per-device iPhone tunnel ULAs (fd97:...::1/64 etc., generated fresh by pymobiledevice3 per session). It's wide on purpose: per-session prefixes can't be predicted in advance.
Development
uv venv && source .venv/bin/activate
uv pip install -e ".[dev]"
pre-commit install # one-time per checkout
pytest # 24 tests
The repo uses ruff for lint + format with a target-version = "py310" config matching pymobile's. pre-commit-config.yaml runs ruff-check and ruff-format on every commit.
Tests stand up real in-process TunneldRunner instances on random ports and exercise the actual REST surface — the federation + upstream-cleanup contract is covered end-to-end, not just at the mock layer.
Security notes
tunneldup hostexposes a WireGuard endpoint. The keypair is generated locally and never leaves the machine; sharing the client config with a peer is what grants them access.- The web UI binds to
0.0.0.0:9246by default, so it's reachable from your LAN. ThePOST /execendpoint runspymobiledevice3 <args>with the privileges of the process serving the UI (sudo, if you started it with sudo). Don't runtunneldup host --webon a network you don't trust. - The web UI has no built-in authentication. If you need it accessible only over WG, pass
--host 10.42.0.1to bind only to the WG interface.
License
MIT.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file tunneldup-0.0.1.tar.gz.
File metadata
- Download URL: tunneldup-0.0.1.tar.gz
- Upload date:
- Size: 33.0 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
bb6bbcab7b598bded9b4c7ef36e218d0b3940e49f78f7aaf7a5d4b668e40cd80
|
|
| MD5 |
e025cc2374ec19bbdebe7ee4e5311a7c
|
|
| BLAKE2b-256 |
aef1464fe370ac771a99236f90a8beec957230cd83de3a021737f0c320b7e7fc
|
Provenance
The following attestation bundles were made for tunneldup-0.0.1.tar.gz:
Publisher:
python-publish.yml on doronz88/tunneldup
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
tunneldup-0.0.1.tar.gz -
Subject digest:
bb6bbcab7b598bded9b4c7ef36e218d0b3940e49f78f7aaf7a5d4b668e40cd80 - Sigstore transparency entry: 1847473699
- Sigstore integration time:
-
Permalink:
doronz88/tunneldup@ec1d92fa9749cdcd1ab5f30932a588c216732ea7 -
Branch / Tag:
refs/tags/v0.0.1 - Owner: https://github.com/doronz88
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
python-publish.yml@ec1d92fa9749cdcd1ab5f30932a588c216732ea7 -
Trigger Event:
release
-
Statement type:
File details
Details for the file tunneldup-0.0.1-py3-none-any.whl.
File metadata
- Download URL: tunneldup-0.0.1-py3-none-any.whl
- Upload date:
- Size: 26.0 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
f5fa1d8da15c6a496738be39c308c68dc83f18004012904783f4874d7f7d1616
|
|
| MD5 |
8de26fec048f7343fb8d2b2de5527bb7
|
|
| BLAKE2b-256 |
4d0fb08f28d93ab480e76de8012be0b497aefa66402269b1aee7530b6c47483c
|
Provenance
The following attestation bundles were made for tunneldup-0.0.1-py3-none-any.whl:
Publisher:
python-publish.yml on doronz88/tunneldup
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
tunneldup-0.0.1-py3-none-any.whl -
Subject digest:
f5fa1d8da15c6a496738be39c308c68dc83f18004012904783f4874d7f7d1616 - Sigstore transparency entry: 1847473979
- Sigstore integration time:
-
Permalink:
doronz88/tunneldup@ec1d92fa9749cdcd1ab5f30932a588c216732ea7 -
Branch / Tag:
refs/tags/v0.0.1 - Owner: https://github.com/doronz88
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
python-publish.yml@ec1d92fa9749cdcd1ab5f30932a588c216732ea7 -
Trigger Event:
release
-
Statement type: