tractor/examples/wg_lan
Gud Boi bf974c9870 Add a `wg`-tunnelled 2-host example set
Re-renders gh #482's examples w/ the corrected (infix) maddr
grammar, as the "layer A" slice of the wg plan: declarative
maddrs only, tunnel pre-provisioned out-of-band, zero runtime
changes.

- `wg_maddr.py`: a `frozen=True` `msgspec.Struct` addr carrying
  `bearer`/`peer_pubkey`/`inner` (+ `inner_proto`), a `.maddr`
  property that re-renders the canonical form, and pure
  `mb_pubkey()`/`wg8_pubkey()`/`parse_wg_maddr()`. The parser
  rejects #482's inverted suffix form w/ an actionable error and
  stays **side-effect free** — `verify_wg_peer()` is a separate,
  explicitly impure step the caller composes, never something a
  parse path shells out to.
- `host_a_srv.py`/`host_b_client.py`: the two-host runs, passing
  only `addr.inner` into `open_nursery()`/`open_root_actor()`,
  which is the whole point — the bearer + key layers are already
  established before any bind happens.
- `README.md`: the grammar + the 3-owners table, the `#108`
  branch install line, tunnel setup, and a "what changed vs
  #482" section enumerating the corrections.

Runnable-shaped but **not yet run against a live tunnel**; that's
next, and the reason these sit on the planning branch rather than
in `examples/` proper. `_segments()` marks its stopgap for when
the `wg` codec isn't installed.

(this patch was generated in some part by `claude-code` using `claude-opus-5` (`anthropic`))
2026-08-12 20:08:15 -04:00
..
README.md Add a `wg`-tunnelled 2-host example set 2026-08-12 20:08:15 -04:00
host_a_srv.py Add a `wg`-tunnelled 2-host example set 2026-08-12 20:08:15 -04:00
host_b_client.py Add a `wg`-tunnelled 2-host example set 2026-08-12 20:08:15 -04:00
wg_maddr.py Add a `wg`-tunnelled 2-host example set 2026-08-12 20:08:15 -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.

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/`wg(8)` owns it)               (the ONLY part tractor binds)

Three parts, three different owners:

part who binds it in the runtime?
/ip4/../udp/51820 bearer kernel via wg-quick/pyroute2 no
/wg/u<key> nothing — its an identity no, verified out-of-band
/ip4/../tcp/1616 overlay tractors IPCServer yes, as .inner

Verified against py-multiaddr #108: this composed form parses and round-trips (['ip4','udp','wg','ip4','tcp']).

requirements

The wg proto isnt in released py-multiaddr yet (0.2.0 has no wg codec), so until #108 lands:

uv pip install 'git+https://github.com/baudco/py-multiaddr.git@wg_support' multibase

wg_maddr.py degrades to a plain segment split when the codec is absent, so the examples still run — but you lose per-segment validation. It deliberately does not hand-roll a wg codec (gh #429 was about dropping our NIH parser).

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 "
import base64, multibase
key = open('wg_pub.key').read().strip()
print(multibase.encode('base64url', base64.b64decode(key)).decode())
"

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; verify_wg_peer() is now a separate, explicitly composed step that the caller invokes. A parser that shells out is a nasty surprise.
  3. no sudo. #482 ran sudo wg show; a library/example must never escalate. wg show works unprivileged for read on most setups; if yours needs root, run the script as root rather than embedding sudo.
  4. no new Address proto-type. The tunnel rides beside the inner addr in a frozen WGTunnelledAddr, and only .inner 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.

next

WGTunnelledAddr is deliberately example-local. Promoting it to tractor.discovery as a TunnelledAddress whose .proto_key/.unwrap() delegate to .inner, plus open_bindspace() @acms that create/tear down the iface + netns via pyroute2, is layers A→C of the plan doc.