tractor/examples/multihost/wg_lan
Gud Boi 299e044d49 Move network APIs to lazy `tractor.net`
Move bindspace, tunnel and WireGuard lifecycles out of actor
discovery and expose them through one lazy public package.

Deats,
- keep `import tractor` free of multiaddr, pyroute2 and WG impls
- move network-focused tests under `tests/net`
- update IPC, spawn, docs and multihost callers to the new API
- pin `CURRENT_NETNS` through the calling thread's procfs link

Prompt-IO: ai/prompt-io/opencode/20260830T025201Z_b1f6ade8_prompt_io.md

(this patch was generated in some part by `opencode` using `gpt-5.6-sol` (`openai`))
2026-09-01 18:10:53 -04:00
..
README.md Move network APIs to lazy `tractor.net` 2026-09-01 18:10:53 -04:00
host_a_srv.py Move network APIs to lazy `tractor.net` 2026-09-01 18:10:53 -04:00
host_b_client.py Move network APIs to lazy `tractor.net` 2026-09-01 18:10:53 -04:00

README.md

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.py walks examples/ recursively and runs everything it collects as a subproc, asserting rc == 0. These need a real second host and a live wg tunnel, so they cant satisfy that; 'multihost' not in p[0] is already in the tests 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 — its an identity parsed, verified explicitly
/ip4/../tcp/1616 overlay tractors 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 wg

gets you a wg-aware multiaddr plus pyroute2s 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-multiaddrs 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/32

on 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 = 25

This example configures host As ListenPort and host Bs Endpoint from the maddr bearer, and configures host As [Interface] Address from its overlay host. The verification step below checks keys only; it does not inspect those fields or either peers AllowedIPs.

sudo wg-quick up wg0   # both hosts
ping -c1 10.0.11.1     # from B

1. 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 — As bearer, As key, As overlay ep).

2. verify the keys

Both scripts explicitly call await verify_wg_peer(addr.tunnel) before starting tractor. The helper validates the maddrs declared key, reads one wg0 key snapshot through pyroute2s Linux generic-netlink API, and accepts the key when it is either the interfaces 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.py

Run 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 As 10.0.11.1 overlay host with a random port. Keep LOCAL_OVERLAY_BIND aligned with host Bs 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:

  1. 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 and tcp where wgs udp ListenPort goes, and it declares no overlay ep at all. parse_wg_maddr() now rejects it with an actionable error.
  2. parsing is pure. #482s 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.
  3. no sudo or subprocess. #482 ran sudo wg show; tractors helper reads generic netlink through pyroute2 and never attempts privilege escalation or namespace creation. The caller must already have the required inspection authority.
  4. no new Address proto-type. The tunnel rides beside the overlay addr in a frozen TunnelledAddress, and only .overlay crosses into open_nursery(). #482 §6 floated a WGAddress registered in _address_types — that registry maps available transport keys to concrete address types, and _addr_to_transport wants a MsgTransport per addr-type, which wg doesnt have.

next

The TunnelledAddress, native maddr parser, bindspace lifecycle, and explicit pyroute2 verification APIs live in tractor.net. Root actor bindspace integration remains future work; callers compose these lifecycles explicitly for now.