7.3 KiB
tractor over a WireGuard tunnel, declared as one maddr
A two-host LAN setup: a tractor actor tree on host A, dialed from host B, with the endpoint declared as a single wg multiaddr.
Supersedes the example set in gh #482 — see what changed.
Why
examples/multihost/?tests/test_docs_examples.pywalksexamples/recursively and runs everything it collects as a subproc, assertingrc == 0. These need a real second host and a livewgtunnel, so they can’t satisfy that;'multihost' not in p[0]is already in the test’s exclusion list, which is what keeps them out of CI.
the maddr form
/ip4/192.168.1.50/udp/51820/wg/u<A_pub>/ip4/10.0.11.1/tcp/1616
\____ wg bearer ___________/\__ key __/\____ tractor ep _____/
underlay, wg `ListenPort` overlay, on the wg iface
(kernel owns the socket) (`MsgTransport` binds this)
Three parts, three different owners:
| part | socket owner / provisioner | runtime role |
|---|---|---|
/ip4/../udp/51820 bearer |
kernel-owned; wg-quick now, tractor bindspace later |
control-plane metadata |
/wg/u<key> |
nothing — it’s an identity | parsed, verified explicitly |
/ip4/../tcp/1616 overlay |
tractor’s IPCServer |
application MsgTransport |
Verified against py-multiaddr #108: this composed form parses and round-trips (['ip4','udp','wg','ip4','tcp']).
requirements
py-multiaddr #108 is merged (2026-07-28) but ships in no release yet — the latest 0.2.0 (2026-03-17) predates it and has no wg codec. So pyproject.toml temporarily pins the merge commit in its PEP 621 dependency metadata, and a plain
uv sync --extra wggets you a wg-aware multiaddr plus pyroute2’s Linux netlink API. The multiaddr pin goes away once a release carries the codec. py-multibase is a direct project dependency, so no separate install command is needed.
Without the codec parse_wg_maddr() raises immediately with an actionable message — there is deliberately no degraded hand-split fallback. _wg_proto_code() performs the capability check before parsing.
Every peel and re-compose here goes through py-multiaddr’s own tunnel API (.decapsulate_code(), .split(), .join(), .encapsulate(), .value_for_protocol()) rather than any bespoke segment slicing — see its README “En/decapsulate” and “Tunneling” sections. gh #429 was about dropping our NIH parser, and that applies to peeling nested tunnel stacks just as much as to decoding one proto.
0. tunnel setup (out-of-band, both hosts)
Host A is the service host (underlay e.g. 192.168.1.50), host B your workstation. Overlay net 10.0.11.0/24.
umask 077
wg genkey | tee wg_priv.key | wg pubkey > wg_pub.key/etc/wireguard/wg0.conf on host A:
[Interface]
PrivateKey = <A_priv>
Address = 10.0.11.1/24
ListenPort = 51820[Peer]
PublicKey = <B_pub>
AllowedIPs = 10.0.11.2/32on host B:
[Interface]
PrivateKey = <B_priv>
Address = 10.0.11.2/24[Peer]
PublicKey = <A_pub>
Endpoint = 192.168.1.50:51820
AllowedIPs = 10.0.11.1/32
PersistentKeepalive = 25This example configures host A’s ListenPort and host B’s Endpoint from the maddr bearer, and configures host A’s [Interface] Address from its overlay host. The verification step below checks keys only; it does not inspect those fields or either peer’s AllowedIPs.
sudo wg-quick up wg0 # both hosts
ping -c1 10.0.11.1 # from B1. get your pubkey into the maddr
python -c "
from tractor.net import mb_pubkey
key = open('wg_pub.key').read().strip()
print(mb_pubkey(key))
"Paste the u... output into WG_MADDR in both scripts (they use the same string — A’s bearer, A’s key, A’s overlay ep).
2. verify the keys
Both scripts explicitly call await verify_wg_peer(addr.tunnel) before starting tractor. The helper validates the maddr’s declared key, reads one wg0 key snapshot through pyroute2’s Linux generic-netlink API, and accepts the key when it is either the interface’s own public key or one of its configured peers.
This establishes key presence only. It does not enforce a host-specific local/peer role and does not verify Endpoint, AllowedIPs, a recent handshake, or routing.
Interface inspection commonly requires CAP_NET_ADMIN in the user namespace that owns the target network namespace. Run each program in a security context that already has the required inspection authority. The helper never invokes sudo or wg(8), escalates privileges, or creates a namespace.
3. run
# host A
python host_a_srv.py
# host B
python host_b_client.pyRun both tractor programs as the normal application account in a security context with the inspection authority described above. No WG_KEY_INSPECTION export or subprocess preflight is used; tunnel setup remains out-of-band. Do not run the applications as root.
The client binds its own actor listener to 10.0.11.2:0, while the service actor binds to host A’s 10.0.11.1 overlay host with a random port. Keep LOCAL_OVERLAY_BIND aligned with host B’s WireGuard interface address if adapting this example.
host_a_srv.py must be importable on host B too, since portal.run() refs the fn by module path — standard tractor RPC semantics.
what changed vs #482
Four corrections, all from ai/tpt-backends/03_wg_tunnel_bindspace.md:
- the maddr semantics were inverted. #482 used
/ip4/10.0.11.1/tcp/1616/wg/u<key>— that parses, but it puts the overlay addr where the bearer belongs andtcpwhere wg’sudpListenPortgoes, and it declares no overlay ep at all.parse_wg_maddr()now rejects it with an actionable error. - parsing is pure. #482’s helper had the key-check adjacent to the parse; async
verify_wg_peer()is now a separate, explicitly composed step that the caller invokes. Implicit kernel inspection from a parser is a nasty surprise. - no
sudoor subprocess. #482 ransudo wg show; tractor’s helper reads generic netlink through pyroute2 and never attempts privilege escalation or namespace creation. The caller must already have the required inspection authority. - no new
Addressproto-type. The tunnel rides beside the overlay addr in a frozenTunnelledAddress, and only.overlaycrosses intoopen_nursery(). #482 §6 floated aWGAddressregistered in_address_types— that registry maps available transport keys to concrete address types, and_addr_to_transportwants aMsgTransportper addr-type, whichwgdoesn’t have.
root composition
The TunnelledAddress, native maddr parser, bindspace lifecycle, and explicit pyroute2 verification APIs live in tractor.net. Keep the owning bindspace context outside the root actor so its namespace FD remains live through complete actor teardown:
async with tractor.net.open_wg_bindspace(
bindspace_spec,
layers,
role='listen',
) as bindspace:
async with tractor.open_root_actor(bindspace=bindspace):
...The root actor enters before registry or IPC setup and restores the calling thread’s original namespace before the outer bindspace context removes owned WireGuard and netns resources.