From 22e154900bf59f2bc19c25bbe6fc26b27d02d24f Mon Sep 17 00:00:00 2001 From: goodboy Date: Wed, 19 Aug 2026 17:14:52 -0400 Subject: [PATCH] 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`)) --- ai/tpt-backends/03_wg_tunnel_bindspace.md | 125 ++++---- examples/multihost/wg_lan/README.md | 40 ++- examples/multihost/wg_lan/host_a_srv.py | 27 +- examples/multihost/wg_lan/host_b_client.py | 17 +- examples/multihost/wg_lan/wg_maddr.py | 327 ++------------------- 5 files changed, 132 insertions(+), 404 deletions(-) diff --git a/ai/tpt-backends/03_wg_tunnel_bindspace.md b/ai/tpt-backends/03_wg_tunnel_bindspace.md index 0a7f7a01..2e5bf861 100644 --- a/ai/tpt-backends/03_wg_tunnel_bindspace.md +++ b/ai/tpt-backends/03_wg_tunnel_bindspace.md @@ -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, - `_addr.py:262`) but `parse_maddr()` only knows - `/ip4|ip6//tcp/

` and `/unix/

`; a `.../wg/u` - maddr raises `ValueError('Unsupported multiaddr protocol - combo')`. + `_addr.py:262`). `parse_maddr()` and `mk_maddr()` support plain + TCP/UDS addresses plus nested, canonical bearer-first `/wg/` + stacks represented locally as `TunnelledAddress` wrappers. - there is no `wg` proto in the multiaddr *spec* yet, but multiformats/py-multiaddr#108 (key form `u`) is **merged** as of 2026-07-28 (`f86519da`) — and unreleased, the latest `0.2.0` predating it. Spec registration is still tracked by multiformats/py-multiaddr#107 and gh #483. -- so **today's deployable story is declarative**: run `wg-quick` - out-of-band, parse the maddr, strip to the overlay +- **today's deployable story remains declarative**: run `wg-quick` + out-of-band, parse the maddr, strip its wrapper to the overlay `(host, port)`, verify the pubkey in its host-specific role, 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 (`_addr.py:94-101`, "the if-available OS-specific network - namespace key") and **no backend implements it**. This plan is - its first consumer. + namespace key"). `TunnelledAddress` implements it from its spec; + no concrete transport backend implements it yet. ## 2. Three layers, three PRs | layer | what | dep | ships | | --- | --- | --- | --- | -| **A. declarative** | commit #482's examples; `parse_maddr()` learns `/wg/u` → overlay `Address` + verified pubkey | `multiaddr` (already), `wg(8)` CLI | first | -| **B. `pyroute2` read/verify** | replace the `subprocess.run(['sudo','wg','show'])` shelling 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 | +| **A. declarative** | land repaired examples derived from #482; `parse_maddr()` learns `/wg/u` → `TunnelledAddress` wrappers carrying overlay `Address` values and declared WG pubkeys | `multiaddr`, `py-multibase`, `wg(8)` CLI | first | +| **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*, 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. **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 tunnel: WGTunnelSpec # proto-specific, frozen ``` - with `.proto_key` **delegating to `overlay.proto_key`** so every - existing table lookup (`_addr_to_transport`, - `enable_transports` guard at `_root.py:391`, - `transport_from_addr()`) keeps working untouched, and - `.unwrap()` delegating to `overlay.unwrap()` so **nothing new - crosses the wire**. `.namespace` and `.bindspace` come from - the tunnel spec. The wrapper is stripped (`→ .overlay`) at the - moment of bind/connect. - - ⚠️ `is_wrapped_addr()` (`_addr.py:194`) tests - `type(addr) in _address_types.values()` — the build-registered - 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. + with `.proto_key`, `.bindspace`, and `.unwrap()` delegating to + the overlay so transport guards retain their existing meaning + and **nothing new crosses the wire**. `.namespace` derives from + the tunnel spec. Exact-type dispatch through + `_addr_to_transport`/`transport_from_addr()` still requires the + wrapper to be stripped (`→ .overlay`) at bind/connect time. + - ⚠️ `is_wrapped_addr()` explicitly recognizes + `TunnelledAddress` even though the wrapper is deliberately not + in `_address_types`: it has no `MsgTransport` of its own and + therefore gets no build-registered proto-key entry. - the reflection in `Endpoint.start_listener()` (`inspect.getmodule(self.addr)`) would resolve to the *wrapper's* module, not the transport's. **So the wrapper must be unwrapped before it reaches `Endpoint`** — i.e. by - the bindspace `@acm` (layer C) or by `parse_maddr()` - (layer A). State this loudly in the docstring; it's the #1 - way to get this wrong. + the bindspace `@acm` (layer C) or explicitly via `.overlay` + or `strip_tunnels()` at each bind/dial boundary (layer A). + State this loudly in the docstring; it's the #1 way to get + this wrong. - (b) add fields to each existing `Address` type. Rejected: duplicates tunnel logic per-backend and pollutes `.unwrap()`. @@ -104,11 +99,11 @@ class WGTunnelSpec( msgspec.Struct, 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' netns: str|None = None # layer-C-only fields, unset in layer A - maybe_endpoint: tuple[str, int]|None = None 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`. That parses, but it is semantically inverted: it puts the overlay addr where the bearer belongs, `tcp` where wg's `udp` `ListenPort` goes, and declares -no overlay endpoint at all. `parse_wg_maddr()` in -`examples/multihost/wg_lan/` now rejects it with an actionable -error. +no overlay endpoint at all. `tractor.discovery.parse_wg_maddr()` +now rejects it with an actionable error. Observed protocol-name lists, for writing the `match`: | maddr | `[p.name for p in m.protocols()]` | @@ -169,7 +163,7 @@ Observed protocol-name lists, for writing the `match`: | 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)` | | per-seg maddrs | `ma.split()` | | 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. Always call it on a peeled sub-maddr, never the whole stack. -- `parse_maddr()` gains a case on - `[('ip4'|'ip6'), 'udp', 'wg', ('ip4'|'ip6'), ]` → - peel w/ the API above, decode the multibase key to std-base64, - and return `TunnelledAddress(overlay=..., tunnel=WGTunnelSpec( - ...))` w/ the bearer recorded in the spec. -- keep the existing 2-proto cases byte-identical; add the new - 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. +- keep the existing 2-proto cases byte-identical; add + `case _ if 'wg' in proto_names:` after them. +- that case delegates to `parse_wg_maddr()`, which repeatedly + peels the last `/wg/`, decodes its key to std-base64, records + its bearer in `WGTunnelSpec`, and wraps the overlay in one + `TunnelledAddress` per segment. - `mk_maddr()` inverse for `TunnelledAddress` is just `.encapsulate()` composition; don't rebuild `str`s by hand. - **pending an upstream release**: py-multiaddr#108 is merged, so - `Multiaddr('/…/wg/u…')` parses — but off a `[tool.uv.sources]` - `rev` pin, since no release carries the codec. Gate the tests - on `_have_wg_maddr_proto()`, implemented as + `Multiaddr('/…/wg/u…')` parses off a PEP 621 direct-revision pin, + since no release carries the codec. Gate parser entry on + `_wg_proto_code()`, implemented as `protocols.protocol_with_name('wg')` under `except ProtocolNotFoundError`. Do **not** probe by parsing 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 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 -*pure functions* + one impure probe, cleanly separated: +The parser/key-codec helpers live in +`tractor/discovery/_tunnel.py`; the impure verifier remains +example-local until layer B: ```python -def parse_wg_maddr(maddr: str) -> TunnelledAddress: ... # pure -def wg8_pubkey(multibase_key: str) -> str: ... # pure +def parse_wg_maddr(maddr: str|Multiaddr) -> TunnelledAddress: ... +def mb_pubkey(wg8_key: str) -> str: ... +def wg8_pubkey(multibase_key: str) -> str: ... async def verify_wg_key( - spec: WGTunnelSpec, + addr: TunnelledAddress, role: Literal['local', 'peer'], + iface: str|None = None, + timeout: float = 5, inspection: str|None = None, -) -> bool: ... # impure probe +) -> bool: ... # example-local impure probe ``` 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 (`contextlib.AsyncExitStack` for the N-deep case). The -`parse_endpoints()` API (`_multiaddr.py:153`) is the front door: -it already returns `dict[name, list[Address]]` and the -`multiaddr_declare_eps.md` sketch anticipates the recursive -`dict[str, list[Address]]|dict[...]` return for tunnelled -entries. Extend it to carry the tunnel stack, not to *enter* it. +`parse_endpoints()` API (`_multiaddr.py:189`) is the front door: +its `ParsedEndpoints` values already contain +`Address|TunnelledAddress` declarations and preserve each tunnel +stack for the eventual bindspace handler. It carries declarations; +it does not *enter* their bindspaces. ### 5.2 `Address.namespace`, at last @@ -486,8 +479,8 @@ consider doing it *first* for exactly that reason. | risk | mitigation | | --- | --- | | `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 | -| `TunnelledAddress` leaks into `Endpoint` and breaks `inspect.getmodule()` | unwrap at parse/bindspace boundary; assert `not isinstance(ep.addr, TunnelledAddress)` in `Endpoint.__post_init__` | +| 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 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 | | 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()` | diff --git a/examples/multihost/wg_lan/README.md b/examples/multihost/wg_lan/README.md index 4a1c75f8..816392f5 100644 --- a/examples/multihost/wg_lan/README.md +++ b/examples/multihost/wg_lan/README.md @@ -21,16 +21,16 @@ Supersedes the example set in gh /ip4/192.168.1.50/udp/51820/wg/u/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) + (kernel owns the socket) (`MsgTransport` binds this) ``` 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 | -| `/wg/u` | nothing — it's an identity | no, verified out-of-band | -| `/ip4/../tcp/1616` overlay | `tractor`'s `IPCServer` | **yes**, as `.overlay` | +| `/ip4/../udp/51820` bearer | kernel-owned; `wg-quick` now, tractor bindspace later | control-plane metadata | +| `/wg/u` | nothing — it's an identity | parsed, verified explicitly | +| `/ip4/../tcp/1616` overlay | `tractor`'s `IPCServer` | application `MsgTransport` | Verified against py-multiaddr [#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 release yet — the latest `0.2.0` (2026-03-17) predates it and has -no `wg` codec. So `pyproject.toml` carries a temporary -`[tool.uv.sources]` `rev` pin at the merge commit, and a plain +no `wg` codec. So `pyproject.toml` temporarily pins the merge commit +in its PEP 621 dependency metadata, and a plain ```bash uv sync ``` gets you a `wg`-aware `multiaddr`. That pin goes away once a -release carries the codec. Its `py-multibase` dependency provides -the imported `multibase` module; no separate install command is -needed. +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. `_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 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 this composed maddr just as -much as to decoding one proto. This example rejects multiple -`/wg/` segments because `WGTunnelledAddr` stores one tunnel. +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) @@ -120,7 +119,7 @@ ping -c1 10.0.11.1 # from B ```bash python -c " -from wg_maddr import mb_pubkey +from tractor.discovery import mb_pubkey key = open('wg_pub.key').read().strip() print(mb_pubkey(key)) " @@ -197,7 +196,7 @@ Four corrections, all from never escalate or run `tractor` as root. Privileged tunnel setup and key inspection are separate shell steps. 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` registered in `_address_types` — that registry maps available transport keys to concrete address types, and @@ -206,8 +205,7 @@ Four corrections, all from ## next -`WGTunnelledAddr` is deliberately example-local. Promoting it to -`tractor.discovery` as a `TunnelledAddress` whose -`.proto_key`/`.unwrap()` delegate to `.overlay`, plus -`open_bindspace()` `@acm`s that create/tear down the iface + -netns via `pyroute2`, is layers A→C of the plan doc. +Layer A's `TunnelledAddress` and native maddr parser now live in +`tractor.discovery`. Next, replace this example's `wg(8)` verification +probe with `pyroute2`, then add `open_bindspace()` `@acm`s which +create/tear down the iface and netns. diff --git a/examples/multihost/wg_lan/host_a_srv.py b/examples/multihost/wg_lan/host_a_srv.py index 6d545dbe..fc380d6b 100644 --- a/examples/multihost/wg_lan/host_a_srv.py +++ b/examples/multihost/wg_lan/host_a_srv.py @@ -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. ''' - from __future__ import annotations import os import tractor import trio - -from wg_maddr import ( +from tractor.discovery import ( + TunnelledAddress, + mk_maddr, parse_wg_maddr, - verify_wg_key, - WGTunnelledAddr, ) +from wg_maddr import verify_wg_key + # bearer = host A's underlay `(ip, wg ListenPort)` # key = host A's OWN tunnel pubkey # overlay = the ep `tractor` binds, on the wg iface's addr @@ -36,7 +36,7 @@ async def echo(msg: str) -> str: 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') if not await verify_wg_key( addr, @@ -46,25 +46,26 @@ async def main(): raise RuntimeError( f'Maddr key is not wg0 local public key!\n' f'maddr: {WG_MADDR}\n' - f'key: {addr.wg_pubkey}\n' + f'key: {addr.tunnel.peer_pubkey}\n' ) 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' ) async with tractor.open_nursery( # 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], - enable_transports=[addr.overlay_proto], + enable_transports=[addr.overlay.proto_key], ) as an: + overlay_host, _ = addr.unwrap() await an.start_actor( 'echo_srv', - bind_addrs=[(addr.overlay[0], 0)], - enable_transports=[addr.overlay_proto], + bind_addrs=[(overlay_host, 0)], + enable_transports=[addr.overlay.proto_key], 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() diff --git a/examples/multihost/wg_lan/host_b_client.py b/examples/multihost/wg_lan/host_b_client.py index 86e3e59c..d2eb75a3 100644 --- a/examples/multihost/wg_lan/host_b_client.py +++ b/examples/multihost/wg_lan/host_b_client.py @@ -4,20 +4,19 @@ Host B: workstation dialing host A's actor tree through the `wg` tunnel. ''' - from __future__ import annotations import os import tractor 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 wg_maddr import ( - parse_wg_maddr, - verify_wg_key, - WGTunnelledAddr, -) +from wg_maddr import verify_wg_key # same maddr as host A: A's bearer, A's key, A's overlay ep WG_MADDR: str = ( @@ -29,7 +28,7 @@ LOCAL_OVERLAY_BIND: tuple[str, int] = ('10.0.11.2', 0) 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') if not await verify_wg_key( addr, @@ -39,14 +38,14 @@ async def main(): raise RuntimeError( f'Maddr key is not a configured wg0 peer!\n' f'maddr: {WG_MADDR}\n' - f'key: {addr.wg_pubkey}\n' + f'key: {addr.tunnel.peer_pubkey}\n' ) async with ( tractor.open_root_actor( name='wg_client', tpt_bind_addrs=[LOCAL_OVERLAY_BIND], registry_addrs=[addr.overlay], - enable_transports=[addr.overlay_proto], + enable_transports=[addr.overlay.proto_key], ), tractor.find_actor( 'echo_srv', diff --git a/examples/multihost/wg_lan/wg_maddr.py b/examples/multihost/wg_lan/wg_maddr.py index f6e664ee..e729c06d 100644 --- a/examples/multihost/wg_lan/wg_maddr.py +++ b/examples/multihost/wg_lan/wg_maddr.py @@ -1,308 +1,37 @@ # tractor: distributed structured concurrency. 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 -upstream merge) nests the *overlay* endpoint **after** the `/wg/` -segment: +`tractor.discovery.parse_wg_maddr()` owns pure parsing and delegates +all tunnel peeling to `py-multiaddr`. This example keeps only the +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/ip4/10.0.11.1/tcp/1616 \_______ wg bearer ______/\_ key _/\____ tractor ep _____/ - (underlay, wg - `ListenPort`) -Naming follows `py-multiaddr`'s own encapsulation model, where -earlier segments *wrap* later ones (`.encapsulate()` appends), so -the two roles are: - -- **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` 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. +The kernel owns the bearer socket. A future tractor bindspace may +provision it through netlink, but only the overlay is an application +`MsgTransport` endpoint. ''' - from __future__ import annotations -import base64 from typing import Literal -import msgspec -from multiaddr import Multiaddr import trio - -IPProto = Literal['ip4', 'ip6'] - - -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//udp/

`\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/` 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, - ) +from tractor.discovery import ( + TunnelledAddress, + WGTunnelSpec, +) async def verify_wg_key( - addr: WGTunnelledAddr, + addr: TunnelledAddress, role: Literal['local', 'peer'], - iface: str = 'wg0', + iface: str | None = None, timeout: float = 5, inspection: str | None = None, ) -> bool: @@ -317,22 +46,30 @@ async def verify_wg_key( `wg show` step. Without it, query asynchronously for callers which already have interface-inspection permission. - IMPURE + explicit by design: never called from - `parse_wg_maddr()`. + IMPURE + explicit by design: neither `parse_wg_maddr()` nor + `tractor.discovery.parse_maddr()` calls this probe. ?TODO, per plan-03 layer B, swap this body for `pyroute2` - (keeping the signature) — and note `setns(2)` is *per-thread*, - so a query issued via `trio.to_thread` lands in the ORIGINAL - netns unless `netns=` is passed down. + while retaining the explicit verification boundary. ''' + spec = addr.tunnel + if not isinstance(spec, WGTunnelSpec): + raise TypeError( + f'Unsupported tunnel spec: {type(spec)!r}' + ) + + iface = iface or spec.iface + match role: case 'local': field = 'public-key' case 'peer': field = 'peers' case _: - raise ValueError(f'Unknown WireGuard key role: {role!r}') + raise ValueError( + f'Unknown WireGuard key role: {role!r}' + ) if inspection is None: with trio.fail_after(timeout): @@ -344,5 +81,5 @@ async def verify_wg_key( inspection = proc.stdout.decode() if role == 'local': - return addr.wg_pubkey == inspection.strip() - return addr.wg_pubkey in inspection.split() + return spec.peer_pubkey == inspection.strip() + return spec.peer_pubkey in inspection.split()