Use discovery's `wg` parser in examples

Drop the example-local address struct and hand-rolled single-tunnel
parser now that discovery owns the production implementation.

Keep only the explicit `wg(8)` peer probe in the multihost helper,
and update the examples and plan for nested parsing, packaged codec
dependencies and tractor-owned bindspace provisioning.

Prompt-IO: ai/prompt-io/opencode/20260818T075031Z_dd02c7c0_prompt_io.md

(this patch was generated in some part by `opencode` using `gpt-5.6-sol` (`openai`))
piker_pin
Gud Boi 2026-08-19 17:14:52 -04:00
parent a26a0a48b6
commit 22e154900b
5 changed files with 132 additions and 404 deletions

View File

@ -19,35 +19,35 @@ onto `trio` as the library's sans-io layer allows.
--- ---
## 1. What exists today (verified, per #482) ## 1. What exists today (derived from #482)
- `wrap_address()` accepts maddr `str`s (leading-`/` dispatch, - `wrap_address()` accepts maddr `str`s (leading-`/` dispatch,
`_addr.py:262`) but `parse_maddr()` only knows `_addr.py:262`). `parse_maddr()` and `mk_maddr()` support plain
`/ip4|ip6/<h>/tcp/<p>` and `/unix/<p>`; a `.../wg/u<key>` TCP/UDS addresses plus nested, canonical bearer-first `/wg/`
maddr raises `ValueError('Unsupported multiaddr protocol stacks represented locally as `TunnelledAddress` wrappers.
combo')`.
- there is no `wg` proto in the multiaddr *spec* yet, but - there is no `wg` proto in the multiaddr *spec* yet, but
multiformats/py-multiaddr#108 (key form `u<base64url>`) is multiformats/py-multiaddr#108 (key form `u<base64url>`) is
**merged** as of 2026-07-28 (`f86519da`) — and unreleased, the **merged** as of 2026-07-28 (`f86519da`) — and unreleased, the
latest `0.2.0` predating it. Spec registration is still tracked latest `0.2.0` predating it. Spec registration is still tracked
by multiformats/py-multiaddr#107 and gh #483. by multiformats/py-multiaddr#107 and gh #483.
- so **today's deployable story is declarative**: run `wg-quick` - **today's deployable story remains declarative**: run `wg-quick`
out-of-band, parse the maddr, strip to the overlay out-of-band, parse the maddr, strip its wrapper to the overlay
`(host, port)`, verify the pubkey in its host-specific role, `(host, port)`, verify the pubkey in its host-specific role,
hand the overlay addr to `registry_addrs=`/`tpt_bind_addrs=`. hand the overlay addr to `registry_addrs=`/`tpt_bind_addrs=`.
#482 already contains working example code for exactly this. The repaired `examples/multihost/wg_lan/` implementation derives
from and supersedes #482's original example.
- `Address.namespace` exists in the Protocol - `Address.namespace` exists in the Protocol
(`_addr.py:94-101`, "the if-available OS-specific network (`_addr.py:94-101`, "the if-available OS-specific network
namespace key") and **no backend implements it**. This plan is namespace key"). `TunnelledAddress` implements it from its spec;
its first consumer. no concrete transport backend implements it yet.
## 2. Three layers, three PRs ## 2. Three layers, three PRs
| layer | what | dep | ships | | layer | what | dep | ships |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| **A. declarative** | commit #482's examples; `parse_maddr()` learns `/wg/u<key>` → overlay `Address` + verified pubkey | `multiaddr` (already), `wg(8)` CLI | first | | **A. declarative** | land repaired examples derived from #482; `parse_maddr()` learns `/wg/u<key>``TunnelledAddress` wrappers carrying overlay `Address` values and declared WG pubkeys | `multiaddr`, `py-multibase`, `wg(8)` CLI | first |
| **B. `pyroute2` read/verify** | replace the `subprocess.run(['sudo','wg','show'])` shelling with netlink queries | `pyroute2` extra | second | | **B. `pyroute2` read/verify** | replace the example-local, role-aware async `wg(8)` verification probe with netlink queries | `pyroute2` extra | second |
| **C. `@acm` lifecycle** | create/configure/tear down wg ifaces + netns *from the runtime*, as nested bindspaces; implement `Address.namespace` | `pyroute2` + `CAP_NET_ADMIN` | third | | **C. `@acm` lifecycle** | create/configure/tear down wg ifaces + netns *from the runtime*, consume `Address.namespace` for nested bindspaces, and implement explicit `None` on concrete transports | `pyroute2` + `CAP_NET_ADMIN` | third |
Each is independently valuable and independently reviewable. Each is independently valuable and independently reviewable.
**Do not attempt C first** — the interesting design (nested **Do not attempt C first** — the interesting design (nested
@ -73,29 +73,24 @@ does not create a new address type.** Two candidate encodings;
overlay: Address # e.g. TCPAddress overlay: Address # e.g. TCPAddress
tunnel: WGTunnelSpec # proto-specific, frozen tunnel: WGTunnelSpec # proto-specific, frozen
``` ```
with `.proto_key` **delegating to `overlay.proto_key`** so every with `.proto_key`, `.bindspace`, and `.unwrap()` delegating to
existing table lookup (`_addr_to_transport`, the overlay so transport guards retain their existing meaning
`enable_transports` guard at `_root.py:391`, and **nothing new crosses the wire**. `.namespace` derives from
`transport_from_addr()`) keeps working untouched, and the tunnel spec. Exact-type dispatch through
`.unwrap()` delegating to `overlay.unwrap()` so **nothing new `_addr_to_transport`/`transport_from_addr()` still requires the
crosses the wire**. `.namespace` and `.bindspace` come from wrapper to be stripped (`→ .overlay`) at bind/connect time.
the tunnel spec. The wrapper is stripped (`→ .overlay`) at the - ⚠️ `is_wrapped_addr()` explicitly recognizes
moment of bind/connect. `TunnelledAddress` even though the wrapper is deliberately not
- ⚠️ `is_wrapped_addr()` (`_addr.py:194`) tests in `_address_types`: it has no `MsgTransport` of its own and
`type(addr) in _address_types.values()` — the build-registered therefore gets no build-registered proto-key entry.
protocol-key-to-address-type registry. `TunnelledAddress`
isn't in it and must not be (it has no transport of its own).
So either add an explicit
`isinstance(addr, TunnelledAddress)` clause there, or give
the wrapper a marker and test structurally. Do the former;
it's two lines and honest.
- the reflection in `Endpoint.start_listener()` - the reflection in `Endpoint.start_listener()`
(`inspect.getmodule(self.addr)`) would resolve to the (`inspect.getmodule(self.addr)`) would resolve to the
*wrapper's* module, not the transport's. **So the wrapper *wrapper's* module, not the transport's. **So the wrapper
must be unwrapped before it reaches `Endpoint`** — i.e. by must be unwrapped before it reaches `Endpoint`** — i.e. by
the bindspace `@acm` (layer C) or by `parse_maddr()` the bindspace `@acm` (layer C) or explicitly via `.overlay`
(layer A). State this loudly in the docstring; it's the #1 or `strip_tunnels()` at each bind/dial boundary (layer A).
way to get this wrong. State this loudly in the docstring; it's the #1 way to get
this wrong.
- (b) add fields to each existing `Address` type. Rejected: - (b) add fields to each existing `Address` type. Rejected:
duplicates tunnel logic per-backend and pollutes `.unwrap()`. duplicates tunnel logic per-backend and pollutes `.unwrap()`.
@ -104,11 +99,11 @@ class WGTunnelSpec(
msgspec.Struct, msgspec.Struct,
frozen=True, frozen=True,
): ):
pubkey: str # std-base64 `wg(8)` form peer_pubkey: str # std-base64 `wg(8)` form
bearer: tuple[str, int]|None = None
iface: str = 'wg0' iface: str = 'wg0'
netns: str|None = None netns: str|None = None
# layer-C-only fields, unset in layer A # layer-C-only fields, unset in layer A
maybe_endpoint: tuple[str, int]|None = None
maybe_allowed_ips: tuple[str, ...] = () maybe_allowed_ips: tuple[str, ...] = ()
``` ```
@ -138,9 +133,8 @@ examples in gh #482) used a *suffix* form
`/ip4/10.0.11.1/tcp/1616/wg/u<key>`. That parses, but it is `/ip4/10.0.11.1/tcp/1616/wg/u<key>`. That parses, but it is
semantically inverted: it puts the overlay addr where the bearer semantically inverted: it puts the overlay addr where the bearer
belongs, `tcp` where wg's `udp` `ListenPort` goes, and declares belongs, `tcp` where wg's `udp` `ListenPort` goes, and declares
no overlay endpoint at all. `parse_wg_maddr()` in no overlay endpoint at all. `tractor.discovery.parse_wg_maddr()`
`examples/multihost/wg_lan/` now rejects it with an actionable now rejects it with an actionable error.
error.
Observed protocol-name lists, for writing the `match`: Observed protocol-name lists, for writing the `match`:
| maddr | `[p.name for p in m.protocols()]` | | maddr | `[p.name for p in m.protocols()]` |
@ -169,7 +163,7 @@ Observed protocol-name lists, for writing the `match`:
| need | API | | need | API |
| --- | --- | | --- | --- |
| isolate the bearer | `ma.decapsulate_code(P_WG)` | | isolate the bearer | `ma.decapsulate_code(_wg_proto_code())` |
| drop the overlay, keep bearer+key | `ma.decapsulate(overlay_ma)` | | drop the overlay, keep bearer+key | `ma.decapsulate(overlay_ma)` |
| per-seg maddrs | `ma.split()` | | per-seg maddrs | `ma.split()` |
| rejoin a seg tail | `Multiaddr.join(*segs)` | | rejoin a seg tail | `Multiaddr.join(*segs)` |
@ -185,23 +179,18 @@ Observed protocol-name lists, for writing the `match`:
silently returns the **first** match, i.e. the bearer's host. silently returns the **first** match, i.e. the bearer's host.
Always call it on a peeled sub-maddr, never the whole stack. Always call it on a peeled sub-maddr, never the whole stack.
- `parse_maddr()` gains a case on - keep the existing 2-proto cases byte-identical; add
`[('ip4'|'ip6'), 'udp', 'wg', ('ip4'|'ip6'), <overlay-l4>]` `case _ if 'wg' in proto_names:` after them.
peel w/ the API above, decode the multibase key to std-base64, - that case delegates to `parse_wg_maddr()`, which repeatedly
and return `TunnelledAddress(overlay=..., tunnel=WGTunnelSpec( peels the last `/wg/`, decodes its key to std-base64, records
...))` w/ the bearer recorded in the spec. its bearer in `WGTunnelSpec`, and wraps the overlay in one
- keep the existing 2-proto cases byte-identical; add the new `TunnelledAddress` per segment.
case *after* them.
- layer A rejects more than one `/wg/` segment. Its wrapper stores
one bearer, key, and overlay, so accepting wg-in-wg would
silently misrepresent the maddr. Nested tunnel support needs a
different data shape and belongs in a later layer.
- `mk_maddr()` inverse for `TunnelledAddress` is just - `mk_maddr()` inverse for `TunnelledAddress` is just
`.encapsulate()` composition; don't rebuild `str`s by hand. `.encapsulate()` composition; don't rebuild `str`s by hand.
- **pending an upstream release**: py-multiaddr#108 is merged, so - **pending an upstream release**: py-multiaddr#108 is merged, so
`Multiaddr('/…/wg/u…')` parses — but off a `[tool.uv.sources]` `Multiaddr('/…/wg/u…')` parses off a PEP 621 direct-revision pin,
`rev` pin, since no release carries the codec. Gate the tests since no release carries the codec. Gate parser entry on
on `_have_wg_maddr_proto()`, implemented as `_wg_proto_code()`, implemented as
`protocols.protocol_with_name('wg')` under `protocols.protocol_with_name('wg')` under
`except ProtocolNotFoundError`. Do **not** probe by parsing a `except ProtocolNotFoundError`. Do **not** probe by parsing a
dummy like `Multiaddr('/wg/uAAAA')` — the codec enforces a dummy like `Multiaddr('/wg/uAAAA')` — the codec enforces a
@ -209,19 +198,23 @@ Observed protocol-name lists, for writing the `match`:
**not** hand-roll a `wg` parser in `tractor` — the whole point **not** hand-roll a `wg` parser in `tractor` — the whole point
of #429 was dropping the NIH parser. of #429 was dropping the NIH parser.
### 3.3 verification helper (pure, composable) ### 3.3 pure parser helpers + explicit verification
Port #482 §2's helpers into `tractor/discovery/_tunnel.py` as The parser/key-codec helpers live in
*pure functions* + one impure probe, cleanly separated: `tractor/discovery/_tunnel.py`; the impure verifier remains
example-local until layer B:
```python ```python
def parse_wg_maddr(maddr: str) -> TunnelledAddress: ... # pure def parse_wg_maddr(maddr: str|Multiaddr) -> TunnelledAddress: ...
def wg8_pubkey(multibase_key: str) -> str: ... # pure def mb_pubkey(wg8_key: str) -> str: ...
def wg8_pubkey(multibase_key: str) -> str: ...
async def verify_wg_key( async def verify_wg_key(
spec: WGTunnelSpec, addr: TunnelledAddress,
role: Literal['local', 'peer'], role: Literal['local', 'peer'],
iface: str|None = None,
timeout: float = 5,
inspection: str|None = None, inspection: str|None = None,
) -> bool: ... # impure probe ) -> bool: ... # example-local impure probe
``` ```
In layer A `verify_wg_key()` may shell out to role-specific In layer A `verify_wg_key()` may shell out to role-specific
@ -367,11 +360,11 @@ async def open_wg_iface(spec: WGTunnelSpec) -> AsyncGenerator[WGTunnelSpec, None
and a driver that folds a list of specs into nested contexts and a driver that folds a list of specs into nested contexts
(`contextlib.AsyncExitStack` for the N-deep case). The (`contextlib.AsyncExitStack` for the N-deep case). The
`parse_endpoints()` API (`_multiaddr.py:153`) is the front door: `parse_endpoints()` API (`_multiaddr.py:189`) is the front door:
it already returns `dict[name, list[Address]]` and the its `ParsedEndpoints` values already contain
`multiaddr_declare_eps.md` sketch anticipates the recursive `Address|TunnelledAddress` declarations and preserve each tunnel
`dict[str, list[Address]]|dict[...]` return for tunnelled stack for the eventual bindspace handler. It carries declarations;
entries. Extend it to carry the tunnel stack, not to *enter* it. it does not *enter* their bindspaces.
### 5.2 `Address.namespace`, at last ### 5.2 `Address.namespace`, at last
@ -486,8 +479,8 @@ consider doing it *first* for exactly that reason.
| risk | mitigation | | risk | mitigation |
| --- | --- | | --- | --- |
| `to_thread` worker runs in the wrong netns | §5.3; pass `netns=` to pyroute2 or pin a worker; test-first | | `to_thread` worker runs in the wrong netns | §5.3; pass `netns=` to pyroute2 or pin a worker; test-first |
| py-multiaddr#108 merged but unreleased | `[tool.uv.sources]` `rev` pin + `_have_wg_maddr_proto()` gate; layer A's overlay-addr path works regardless | | py-multiaddr#108 merged but unreleased | PEP 621 direct-revision pin + `_wg_proto_code()` gate; replace with a release floor once published |
| `TunnelledAddress` leaks into `Endpoint` and breaks `inspect.getmodule()` | unwrap at parse/bindspace boundary; assert `not isinstance(ep.addr, TunnelledAddress)` in `Endpoint.__post_init__` | | `TunnelledAddress` leaks into `Endpoint` and breaks `inspect.getmodule()` | unwrap with `.overlay`/`strip_tunnels()` at every bind/dial or bindspace boundary; assert `not isinstance(ep.addr, TunnelledAddress)` in `Endpoint.__post_init__` |
| privileged ops in a library | never `sudo`; explicit cap probe + actionable error; pre-provisioned is the default | | privileged ops in a library | never `sudo`; explicit cap probe + actionable error; pre-provisioned is the default |
| pyroute2 0.9 asyncio core drags a loop into the actor | option (1) is a *thread*, not a loop; forbid `trio-asyncio` here (§4.1) | | pyroute2 0.9 asyncio core drags a loop into the actor | option (1) is a *thread*, not a loop; forbid `trio-asyncio` here (§4.1) |
| netns teardown strands actor teardown | idempotent/tolerant teardown mirroring `_uds.close_listener()` | | netns teardown strands actor teardown | idempotent/tolerant teardown mirroring `_uds.close_listener()` |

View File

@ -21,16 +21,16 @@ Supersedes the example set in gh
/ip4/192.168.1.50/udp/51820/wg/u<A_pub>/ip4/10.0.11.1/tcp/1616 /ip4/192.168.1.50/udp/51820/wg/u<A_pub>/ip4/10.0.11.1/tcp/1616
\____ wg bearer ___________/\__ key __/\____ tractor ep _____/ \____ wg bearer ___________/\__ key __/\____ tractor ep _____/
underlay, wg `ListenPort` overlay, on the wg iface underlay, wg `ListenPort` overlay, on the wg iface
(kernel/`wg(8)` owns it) (the ONLY part tractor binds) (kernel owns the socket) (`MsgTransport` binds this)
``` ```
Three parts, three different owners: Three parts, three different owners:
| part | who binds it | in the runtime? | | part | socket owner / provisioner | runtime role |
| --- | --- | --- | | --- | --- | --- |
| `/ip4/../udp/51820` bearer | kernel via `wg-quick`/`pyroute2` | no | | `/ip4/../udp/51820` bearer | kernel-owned; `wg-quick` now, tractor bindspace later | control-plane metadata |
| `/wg/u<key>` | nothing — it's an identity | no, verified out-of-band | | `/wg/u<key>` | nothing — it's an identity | parsed, verified explicitly |
| `/ip4/../tcp/1616` overlay | `tractor`'s `IPCServer` | **yes**, as `.overlay` | | `/ip4/../tcp/1616` overlay | `tractor`'s `IPCServer` | application `MsgTransport` |
Verified against py-multiaddr Verified against py-multiaddr
[#108](https://github.com/multiformats/py-multiaddr/pull/108): [#108](https://github.com/multiformats/py-multiaddr/pull/108):
@ -41,30 +41,29 @@ this composed form parses and round-trips
py-multiaddr #108 is **merged** (2026-07-28) but ships in no 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 release yet — the latest `0.2.0` (2026-03-17) predates it and has
no `wg` codec. So `pyproject.toml` carries a temporary no `wg` codec. So `pyproject.toml` temporarily pins the merge commit
`[tool.uv.sources]` `rev` pin at the merge commit, and a plain in its PEP 621 dependency metadata, and a plain
```bash ```bash
uv sync uv sync
``` ```
gets you a `wg`-aware `multiaddr`. That pin goes away once a gets you a `wg`-aware `multiaddr`. That pin goes away once a
release carries the codec. Its `py-multibase` dependency provides release carries the codec. `py-multibase` is a direct project
the imported `multibase` module; no separate install command is dependency, so no separate install command is needed.
needed.
Without the codec `parse_wg_maddr()` raises immediately with an Without the codec `parse_wg_maddr()` raises immediately with an
actionable message — there is deliberately **no** degraded actionable message — there is deliberately **no** degraded
hand-split fallback. `_have_wg_maddr_proto()` is the predicate. hand-split fallback. `_wg_proto_code()` performs the capability
check before parsing.
Every peel and re-compose here goes through `py-multiaddr`'s own Every peel and re-compose here goes through `py-multiaddr`'s own
tunnel API (`.decapsulate_code()`, `.split()`, `.join()`, tunnel API (`.decapsulate_code()`, `.split()`, `.join()`,
`.encapsulate()`, `.value_for_protocol()`) rather than any `.encapsulate()`, `.value_for_protocol()`) rather than any
bespoke segment slicing — see its README "En/decapsulate" and bespoke segment slicing — see its README "En/decapsulate" and
"Tunneling" sections. gh #429 was about *dropping* our NIH "Tunneling" sections. gh #429 was about *dropping* our NIH
parser, and that applies to peeling this composed maddr just as parser, and that applies to peeling nested tunnel stacks just as
much as to decoding one proto. This example rejects multiple much as to decoding one proto.
`/wg/` segments because `WGTunnelledAddr` stores one tunnel.
## 0. tunnel setup (out-of-band, both hosts) ## 0. tunnel setup (out-of-band, both hosts)
@ -120,7 +119,7 @@ ping -c1 10.0.11.1 # from B
```bash ```bash
python -c " python -c "
from wg_maddr import mb_pubkey from tractor.discovery import mb_pubkey
key = open('wg_pub.key').read().strip() key = open('wg_pub.key').read().strip()
print(mb_pubkey(key)) print(mb_pubkey(key))
" "
@ -197,7 +196,7 @@ Four corrections, all from
never escalate or run `tractor` as root. Privileged tunnel never escalate or run `tractor` as root. Privileged tunnel
setup and key inspection are separate shell steps. setup and key inspection are separate shell steps.
4. **no new `Address` proto-type.** The tunnel rides *beside* the 4. **no new `Address` proto-type.** The tunnel rides *beside* the
overlay addr in a frozen `WGTunnelledAddr`, and only `.overlay` overlay addr in a frozen `TunnelledAddress`, and only `.overlay`
crosses into `open_nursery()`. #482 §6 floated a `WGAddress` crosses into `open_nursery()`. #482 §6 floated a `WGAddress`
registered in `_address_types` — that registry maps available registered in `_address_types` — that registry maps available
transport keys to concrete address types, and transport keys to concrete address types, and
@ -206,8 +205,7 @@ Four corrections, all from
## next ## next
`WGTunnelledAddr` is deliberately example-local. Promoting it to Layer A's `TunnelledAddress` and native maddr parser now live in
`tractor.discovery` as a `TunnelledAddress` whose `tractor.discovery`. Next, replace this example's `wg(8)` verification
`.proto_key`/`.unwrap()` delegate to `.overlay`, plus probe with `pyroute2`, then add `open_bindspace()` `@acm`s which
`open_bindspace()` `@acm`s that create/tear down the iface + create/tear down the iface and netns.
netns via `pyroute2`, is layers A→C of the plan doc.

View File

@ -6,20 +6,20 @@ Binds `tractor`'s registrar + an `echo_srv` sub-actor on the
tunnel's *overlay* addr, declared as a single `wg` maddr. tunnel's *overlay* addr, declared as a single `wg` maddr.
''' '''
from __future__ import annotations from __future__ import annotations
import os import os
import tractor import tractor
import trio import trio
from tractor.discovery import (
from wg_maddr import ( TunnelledAddress,
mk_maddr,
parse_wg_maddr, parse_wg_maddr,
verify_wg_key,
WGTunnelledAddr,
) )
from wg_maddr import verify_wg_key
# bearer = host A's underlay `(ip, wg ListenPort)` # bearer = host A's underlay `(ip, wg ListenPort)`
# key = host A's OWN tunnel pubkey # key = host A's OWN tunnel pubkey
# overlay = the ep `tractor` binds, on the wg iface's addr # overlay = the ep `tractor` binds, on the wg iface's addr
@ -36,7 +36,7 @@ async def echo(msg: str) -> str:
async def main(): async def main():
addr: WGTunnelledAddr = parse_wg_maddr(WG_MADDR) addr: TunnelledAddress = parse_wg_maddr(WG_MADDR)
inspection: str | None = os.environ.get('WG_KEY_INSPECTION') inspection: str | None = os.environ.get('WG_KEY_INSPECTION')
if not await verify_wg_key( if not await verify_wg_key(
addr, addr,
@ -46,25 +46,26 @@ async def main():
raise RuntimeError( raise RuntimeError(
f'Maddr key is not wg0 local public key!\n' f'Maddr key is not wg0 local public key!\n'
f'maddr: {WG_MADDR}\n' f'maddr: {WG_MADDR}\n'
f'key: {addr.wg_pubkey}\n' f'key: {addr.tunnel.peer_pubkey}\n'
) )
print( print(
f'wg bearer (kernel-owned): {addr.bearer}\n' f'wg bearer (kernel-owned): {addr.tunnel.bearer}\n'
f'tractor overlay ep: {addr.overlay}\n' f'tractor overlay ep: {addr.overlay}\n'
) )
async with tractor.open_nursery( async with tractor.open_nursery(
# XXX only `.overlay` crosses into the runtime; the bearer # XXX only `.overlay` crosses into the runtime; the bearer
# + key are iface-layer concerns `tractor` never binds. # + key are bindspace metadata, never `Endpoint` addrs.
registry_addrs=[addr.overlay], registry_addrs=[addr.overlay],
enable_transports=[addr.overlay_proto], enable_transports=[addr.overlay.proto_key],
) as an: ) as an:
overlay_host, _ = addr.unwrap()
await an.start_actor( await an.start_actor(
'echo_srv', 'echo_srv',
bind_addrs=[(addr.overlay[0], 0)], bind_addrs=[(overlay_host, 0)],
enable_transports=[addr.overlay_proto], enable_transports=[addr.overlay.proto_key],
enable_modules=['host_a_srv'], enable_modules=['host_a_srv'],
) )
print(f'echo_srv up on\n {addr.maddr}\n') print(f'echo_srv up on\n {mk_maddr(addr)}\n')
await trio.sleep_forever() await trio.sleep_forever()

View File

@ -4,20 +4,19 @@ Host B: workstation dialing host A's actor tree through the
`wg` tunnel. `wg` tunnel.
''' '''
from __future__ import annotations from __future__ import annotations
import os import os
import tractor import tractor
import trio import trio
from tractor.discovery import (
TunnelledAddress,
parse_wg_maddr,
)
from host_a_srv import echo # noqa: F401 (RPC refs it by mod path) from host_a_srv import echo # noqa: F401 (RPC refs it by mod path)
from wg_maddr import ( from wg_maddr import verify_wg_key
parse_wg_maddr,
verify_wg_key,
WGTunnelledAddr,
)
# same maddr as host A: A's bearer, A's key, A's overlay ep # same maddr as host A: A's bearer, A's key, A's overlay ep
WG_MADDR: str = ( WG_MADDR: str = (
@ -29,7 +28,7 @@ LOCAL_OVERLAY_BIND: tuple[str, int] = ('10.0.11.2', 0)
async def main(): async def main():
addr: WGTunnelledAddr = parse_wg_maddr(WG_MADDR) addr: TunnelledAddress = parse_wg_maddr(WG_MADDR)
inspection: str | None = os.environ.get('WG_KEY_INSPECTION') inspection: str | None = os.environ.get('WG_KEY_INSPECTION')
if not await verify_wg_key( if not await verify_wg_key(
addr, addr,
@ -39,14 +38,14 @@ async def main():
raise RuntimeError( raise RuntimeError(
f'Maddr key is not a configured wg0 peer!\n' f'Maddr key is not a configured wg0 peer!\n'
f'maddr: {WG_MADDR}\n' f'maddr: {WG_MADDR}\n'
f'key: {addr.wg_pubkey}\n' f'key: {addr.tunnel.peer_pubkey}\n'
) )
async with ( async with (
tractor.open_root_actor( tractor.open_root_actor(
name='wg_client', name='wg_client',
tpt_bind_addrs=[LOCAL_OVERLAY_BIND], tpt_bind_addrs=[LOCAL_OVERLAY_BIND],
registry_addrs=[addr.overlay], registry_addrs=[addr.overlay],
enable_transports=[addr.overlay_proto], enable_transports=[addr.overlay.proto_key],
), ),
tractor.find_actor( tractor.find_actor(
'echo_srv', 'echo_srv',

View File

@ -1,308 +1,37 @@
# tractor: distributed structured concurrency. # tractor: distributed structured concurrency.
r''' r'''
Parse `wg`-tunnelled multiaddrs into `tractor`-ready addrs. Verify `wg` keys declared by tractor's multiaddr parser.
The canonical form (per py-multiaddr #108, verified against its `tractor.discovery.parse_wg_maddr()` owns pure parsing and delegates
upstream merge) nests the *overlay* endpoint **after** the `/wg/` all tunnel peeling to `py-multiaddr`. This example keeps only the
segment: explicit impure probe used by the two-host demo; parsing never shells
out or verifies local interface state implicitly.
The canonical maddr form is:
/ip4/10.0.0.1/udp/51820/wg/u<key>/ip4/10.0.11.1/tcp/1616 /ip4/10.0.0.1/udp/51820/wg/u<key>/ip4/10.0.11.1/tcp/1616
\_______ wg bearer ______/\_ key _/\____ tractor ep _____/ \_______ wg bearer ______/\_ key _/\____ tractor ep _____/
(underlay, wg
`ListenPort`)
Naming follows `py-multiaddr`'s own encapsulation model, where The kernel owns the bearer socket. A future tractor bindspace may
earlier segments *wrap* later ones (`.encapsulate()` appends), so provision it through netlink, but only the overlay is an application
the two roles are: `MsgTransport` endpoint.
- **bearer**: the segs *before* `/wg/`, i.e. the underlay
`(ip, udp-port)` that `wg(8)` itself listens on. Nothing in
`tractor` ever binds this the kernel/`wg` iface owns it.
- **overlay**: the segs *after*, i.e. the addr `tractor` actually
binds/dials. The only part the runtime ever sees.
We deliberately avoid `inner`/`outer` for these two: in a *call*
stack "inner" reads as higher-up and later-called, whereas here
the encapsulated addr is bound *first* and sits deeper in the
maddr two opposite intuitions on one word.
`/wg/u<key>` itself carries a declared Curve25519 pubkey
as multibase base64url (std base64 from `wg(8)` contains `/` and
so can't go in a `/`-delimited maddr). It binds nothing at all;
it's an identity, verified out-of-band.
XXX NOTE, `tractor`'s own `parse_maddr()` can't parse this yet
(`ValueError('Unsupported multiaddr protocol combo')`), which is
why this module exists: peel here, hand `.overlay` to the
runtime.
Design rules this module follows (see
`ai/tpt-backends/03_wg_tunnel_bindspace.md`):
- **let `py-multiaddr` do the parsing**. Every peel/compose goes
through `.decapsulate_code()`, `.split()`, `.join()`,
`.encapsulate()` and `.value_for_protocol()`. We hand-roll no
segment splitting whatsoever the whole point of gh #429 was
dropping the NIH parser, and that applies to peeling this
composed maddr every bit as much as to decoding one proto.
- **parsing is pure**. `parse_wg_maddr()` does no I/O, no
`subprocess`, no netlink. A parser that shells out is a nasty
surprise.
- **verification is an explicit, separate step**. The caller
composes `verify_wg_key()` when it has permission to inspect
the iface; nothing implicit.
- **exactly one `wg` segment is supported**. `WGTunnelledAddr`
stores one bearer and one key, so accepting another segment
would silently misrepresent the maddr.
- **no new `Address` proto-type**. `wg` gets no entry in
`tractor.discovery._addr._address_types`, which maps available
transport keys to concrete address types, bc it has no
`MsgTransport` of its own. The
tunnel is a *bindspace*, so we carry it beside the overlay
addr and strip to `.overlay` at bind/dial time.
''' '''
from __future__ import annotations from __future__ import annotations
import base64
from typing import Literal from typing import Literal
import msgspec
from multiaddr import Multiaddr
import trio import trio
from tractor.discovery import (
IPProto = Literal['ip4', 'ip6'] TunnelledAddress,
WGTunnelSpec,
)
class WGTunnelledAddr(
msgspec.Struct,
frozen=True,
):
'''
A `wg`-tunnelled endpoint: the underlay bearer, the tunnel
key, and the overlay addr `tractor` binds/dials.
'''
# underlay, owned by `wg(8)`/the kernel — NEVER bound by us
bearer: tuple[str, int]
# declared wg pubkey in std-base64 `wg(8)` form; it is the
# local key on the bearer host and a configured peer on a dialer
wg_pubkey: str
# overlay ep: an `UnwrappedAddress` as accepted by
# `tractor.discovery.wrap_address()`
overlay: tuple[str, int]
overlay_proto: Literal['tcp'] = 'tcp'
# kept so `.as_multiaddr()` re-renders the same ip family it
# was parsed from, rather than assuming v4
bearer_ip: IPProto = 'ip4'
overlay_ip: IPProto = 'ip4'
def as_multiaddr(self) -> Multiaddr:
'''
Re-compose the canonical `Multiaddr`, bearer outward-in,
using `.encapsulate()` exactly as py-multiaddr's own
tunneling example does.
'''
b_host, b_port = self.bearer
o_host, o_port = self.overlay
return (
Multiaddr(f'/{self.bearer_ip}/{b_host}/udp/{b_port}')
.encapsulate(
Multiaddr(f'/wg/{mb_pubkey(self.wg_pubkey)}')
)
.encapsulate(
Multiaddr(
f'/{self.overlay_ip}/{o_host}'
f'/{self.overlay_proto}/{o_port}'
)
)
)
@property
def maddr(self) -> str:
'''
The canonical maddr `str` form.
'''
return str(self.as_multiaddr())
def mb_pubkey(wg8_key: str) -> str:
'''
`wg(8)` std-base64 pubkey -> multibase base64url (`u`-prefixed).
'''
import multibase
raw: bytes = base64.b64decode(wg8_key, validate=True)
if len(raw) != 32:
raise ValueError(
f'WireGuard public keys must decode to 32 bytes, '
f'not {len(raw)}'
)
return multibase.encode('base64url', raw).decode('ascii')
def wg8_pubkey(mb_key: str) -> str:
'''
Inverse of `mb_pubkey()`: multibase -> `wg(8)` std-base64.
'''
import multibase
raw: bytes = multibase.decode(mb_key)
if len(raw) != 32:
raise ValueError(
f'WireGuard public keys must decode to 32 bytes, '
f'not {len(raw)}'
)
return base64.b64encode(raw).decode('ascii')
_wg_proto_known: bool | None = None
def _have_wg_maddr_proto() -> bool:
'''
True iff the installed `py-multiaddr` knows the `/wg/` proto,
i.e. carries py-multiaddr#108.
Merged upstream 2026-07-28 (`f86519da`) but in no release as
of `0.2.0`, hence the `[tool.uv.sources]` `rev` pin in
`pyproject.toml`.
Pure predicate; result cached since it can't change without a
reinstall.
'''
global _wg_proto_known
if _wg_proto_known is None:
from multiaddr.protocols import protocol_with_name
from multiaddr.exceptions import ProtocolNotFoundError
try:
protocol_with_name('wg')
_wg_proto_known = True
except ProtocolNotFoundError:
_wg_proto_known = False
return _wg_proto_known
def parse_wg_maddr(
maddr: str | Multiaddr,
) -> WGTunnelledAddr:
'''
Peel a `wg`-tunnelled maddr into its bearer/key/overlay
parts. Pure no I/O.
Every cut is made by `py-multiaddr`, so a malformed maddr
(incl. a `wg` key that isn't exactly 32B) raises out of
`Multiaddr()` rather than yielding a struct quietly built
from garbage segs.
'''
if not _have_wg_maddr_proto():
raise RuntimeError(
f'Installed `py-multiaddr` has no `/wg/` proto!\n'
f'Needs py-multiaddr#108, merged upstream but not\n'
f'yet released; a `uv sync` picks up the pinned rev.\n'
f'maddr: {maddr!r}\n'
)
ma: Multiaddr = (
maddr
if isinstance(maddr, Multiaddr)
else Multiaddr(maddr)
)
segs: list[Multiaddr] = ma.split()
names: list[str] = [
proto.name
for seg in segs
for proto in seg.protocols()
]
wg_count: int = names.count('wg')
if not wg_count:
raise ValueError(
f'Not a `wg`-tunnelled maddr, no `/wg/` segment ??\n'
f'maddr: {ma}\n'
)
if wg_count > 1:
raise ValueError(
f'Nested `wg` segments are not supported; '
f'`WGTunnelledAddr` stores one tunnel only.\n'
f'maddr: {ma}\n'
)
# Resolve the unreleased protocol only after the capability
# check, so importing this module works with released multiaddr.
from multiaddr.protocols import protocol_with_name
wg_code: int = protocol_with_name('wg').code
# NOTE, `.decapsulate_code()` cuts at the LAST occurrence of
# the proto and keeps the *prefix*, which is exactly the
# bearer. It handles `/wg/` cleanly precisely bc it cuts on
# proto-code and never tries to match an addr value — the
# key seg has no addr of its own.
bearer_ma: Multiaddr = ma.decapsulate_code(wg_code)
overlay_ma: Multiaddr = Multiaddr.join(
*segs[names.index('wg') + 1:]
)
match [proto.name for proto in bearer_ma.protocols()]:
case [('ip4' | 'ip6') as b_ip, 'udp']:
bearer = (
bearer_ma.value_for_protocol(b_ip),
int(bearer_ma.value_for_protocol('udp')),
)
case _:
raise ValueError(
f'Bad `wg` bearer, expected `/ip4|ip6/<h>/udp/<p>`\n'
f'got: {bearer_ma}\n'
f'from maddr: {ma}\n'
)
match [proto.name for proto in overlay_ma.protocols()]:
case [('ip4' | 'ip6') as o_ip, ('tcp') as l4]:
overlay = (
overlay_ma.value_for_protocol(o_ip),
int(overlay_ma.value_for_protocol(l4)),
)
case []:
raise ValueError(
f'`wg` maddr declares no overlay endpoint!\n'
f'A bare `/…/wg/<key>` names only the tunnel; '
f'append the ep `tractor` should bind, e.g.\n'
f' {ma}/ip4/10.0.11.1/tcp/1616\n'
)
case _:
raise ValueError(
f'Unsupported `wg` overlay proto combo\n'
f'got: {overlay_ma}\n'
f'from maddr: {ma}\n'
)
return WGTunnelledAddr(
bearer=bearer,
wg_pubkey=wg8_pubkey(ma.value_for_protocol('wg')),
overlay=overlay,
overlay_proto=l4,
bearer_ip=b_ip,
overlay_ip=o_ip,
)
async def verify_wg_key( async def verify_wg_key(
addr: WGTunnelledAddr, addr: TunnelledAddress,
role: Literal['local', 'peer'], role: Literal['local', 'peer'],
iface: str = 'wg0', iface: str | None = None,
timeout: float = 5, timeout: float = 5,
inspection: str | None = None, inspection: str | None = None,
) -> bool: ) -> bool:
@ -317,22 +46,30 @@ async def verify_wg_key(
`wg show` step. Without it, query asynchronously for callers `wg show` step. Without it, query asynchronously for callers
which already have interface-inspection permission. which already have interface-inspection permission.
IMPURE + explicit by design: never called from IMPURE + explicit by design: neither `parse_wg_maddr()` nor
`parse_wg_maddr()`. `tractor.discovery.parse_maddr()` calls this probe.
?TODO, per plan-03 layer B, swap this body for `pyroute2` ?TODO, per plan-03 layer B, swap this body for `pyroute2`
(keeping the signature) and note `setns(2)` is *per-thread*, while retaining the explicit verification boundary.
so a query issued via `trio.to_thread` lands in the ORIGINAL
netns unless `netns=` is passed down.
''' '''
spec = addr.tunnel
if not isinstance(spec, WGTunnelSpec):
raise TypeError(
f'Unsupported tunnel spec: {type(spec)!r}'
)
iface = iface or spec.iface
match role: match role:
case 'local': case 'local':
field = 'public-key' field = 'public-key'
case 'peer': case 'peer':
field = 'peers' field = 'peers'
case _: case _:
raise ValueError(f'Unknown WireGuard key role: {role!r}') raise ValueError(
f'Unknown WireGuard key role: {role!r}'
)
if inspection is None: if inspection is None:
with trio.fail_after(timeout): with trio.fail_after(timeout):
@ -344,5 +81,5 @@ async def verify_wg_key(
inspection = proc.stdout.decode() inspection = proc.stdout.decode()
if role == 'local': if role == 'local':
return addr.wg_pubkey == inspection.strip() return spec.peer_pubkey == inspection.strip()
return addr.wg_pubkey in inspection.split() return spec.peer_pubkey in inspection.split()