tractor/examples/multihost/wg_lan
Gud Boi 793ba9f94c Scope `open_root_actor()` to a `Bindspace`
Enter a realized netns before root registry, IPC and runtime
startup, then restore the calling thread before owned bindspace
teardown.

Deats,
- duplicate the namespace FD so caller ownership stays intact
- preserve primary body errors across restore and close failures
- reject persistent `mp_forkserver` roots with stale netns risk
- cover cancellation, real netns entry, UDS RPC and public WG
  composition

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

(this patch was generated in some part by `opencode` using `gpt-5.6-sol` (`openai`))
2026-08-31 16:55:58 -04:00
..
README.md Scope `open_root_actor()` to a `Bindspace` 2026-08-31 16:55:58 -04:00
host_a_srv.py Move network APIs to lazy `tractor.net` 2026-08-29 23:56:21 -04:00
host_b_client.py Move network APIs to lazy `tractor.net` 2026-08-29 23:56:21 -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 dependency.

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 a tunnel stack 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

Note how ListenPort and Endpoint are exactly the maddrs bearer segment, and [Interface] Address is its overlay host.

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. run

# host A
python host_a_srv.py

# host B
python host_b_client.py

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.
  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 table is a bidict (1:1 proto-key↔type) and _addr_to_transport wants a MsgTransport per addr-type, which wg doesnt 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 threads original namespace before the outer bindspace context removes owned WireGuard and netns resources.