From e4e27ec71defdec300bee17ba55d61d2bfdd0cc4 Mon Sep 17 00:00:00 2001 From: goodboy Date: Mon, 31 Aug 2026 12:28:19 -0400 Subject: [PATCH] Harden the wg LAN example for real hosts Validate WireGuard keys and tunnel descriptors strictly. Inspect iface keys asynchronously by local/peer role and reject unsupported nested tunnels. Correct both host binds and service publication, document an unprivileged two-host setup and pin the merged `py-multiaddr` codec revision in the lock. Prompt-IO: ai/prompt-io/opencode/20260831T022317Z_768b5316_prompt_io.md (this patch was generated in some part by `opencode` using `gpt-5.6-sol` (`openai`)) --- .../20260831T022317Z_768b5316_prompt_io.md | 72 +++++++++++ ...20260831T022317Z_768b5316_prompt_io.raw.md | 60 +++++++++ ai/tpt-backends/03_wg_tunnel_bindspace.md | 39 +++--- examples/multihost/wg_lan/README.md | 79 ++++++++---- examples/multihost/wg_lan/host_a_srv.py | 25 ++-- examples/multihost/wg_lan/host_b_client.py | 23 +++- examples/multihost/wg_lan/wg_maddr.py | 117 +++++++++++++----- uv.lock | 14 +-- 8 files changed, 339 insertions(+), 90 deletions(-) create mode 100644 ai/prompt-io/opencode/20260831T022317Z_768b5316_prompt_io.md create mode 100644 ai/prompt-io/opencode/20260831T022317Z_768b5316_prompt_io.raw.md diff --git a/ai/prompt-io/opencode/20260831T022317Z_768b5316_prompt_io.md b/ai/prompt-io/opencode/20260831T022317Z_768b5316_prompt_io.md new file mode 100644 index 00000000..4d8fa7d5 --- /dev/null +++ b/ai/prompt-io/opencode/20260831T022317Z_768b5316_prompt_io.md @@ -0,0 +1,72 @@ +--- +model: gpt-5.6-sol +service: opencode +session: f3a5eef0-9695-48ab-a336-861187473f8a +timestamp: 2026-08-31T02:23:17Z +git_ref: ng_tpts_planning +diff_cmd: git diff 768b531662a88037e3988885102d2ff24a7ce34e..HEAD +scope: code +substantive: true +raw_file: 20260831T022317Z_768b5316_prompt_io.raw.md +--- + +## Prompt + +The human asked to continue the retained PR landing sequence and +selected "Repair all blockers" after a read-only review of PR #492. +They then corrected the TIPC assessment: + +> oh one thing on the tipc findings, they may have already been +> addressed in the downstream patch branch no? + +The repair therefore had to use downstream #493 as implementation +evidence, avoid duplicating solved TIPC work, preserve the dirty +downstream worktree, and repair the remaining shared, QUIC, +WireGuard, lock, and provenance defects without committing, +pushing, or publishing. + +## Response summary + +Reworked PR #492's plans and executable WireGuard example into a +consistent landing candidate. The shared and TIPC documents now +match current runtime/downstream behavior while naming remaining +risks. The QUIC plan now defines transport ownership across the +whole actor lifecycle. The WireGuard example validates keys and +binds both hosts and the service actor correctly without elevating +Tractor. The dependency lock and historical provenance pointers +are synchronized. + +## Files changed + +- `ai/tpt-backends/00_shared_backend_contract.md` - current backend + contract, dispatch, capability, and locality rules. +- `ai/tpt-backends/01_tipc_backend.md` - #493 reconciliation and + bounded unresolved work. +- `ai/tpt-backends/02_quic_iroh_backend.md` - lifecycle-safe QUIC + and UniFFI implementation plan. +- `ai/tpt-backends/03_wg_tunnel_bindspace.md` - corrected address + registry and privilege semantics. +- `ai/tpt-backends/README.md` - refreshed plan summaries. +- `examples/multihost/wg_lan/README.md` - unprivileged two-host + setup and accurate verification limits. +- `examples/multihost/wg_lan/host_a_srv.py` - local key check, + overlay child bind, and stable RPC exposure. +- `examples/multihost/wg_lan/host_b_client.py` - peer key check, + host-B bind, and explicit missing-service failure. +- `examples/multihost/wg_lan/wg_maddr.py` - strict parsing and + asynchronous role-specific key inspection. +- `uv.lock` - exact `py-multiaddr` Git source resolution. +- `ai/prompt-io/claude/20260813T001102Z_27c34aeb_prompt_io.md` - + valid scope and immutable Git reference. +- `ai/prompt-io/claude/20260813T001102Z_27c34aeb_prompt_io.raw.md` + - immutable historical diff pointers. + +## Human edits + +The human chose the full repair path rather than reducing the PR +to planning documents or publishing the initial review. They also +identified that the initial TIPC review had not accounted for the +downstream implementation branch. That correction materially +changed the work: solved TIPC items were backported into the plan, +remaining defects were separated from implemented behavior, and +the dirty downstream worktree was kept read-only. diff --git a/ai/prompt-io/opencode/20260831T022317Z_768b5316_prompt_io.raw.md b/ai/prompt-io/opencode/20260831T022317Z_768b5316_prompt_io.raw.md new file mode 100644 index 00000000..a41cc032 --- /dev/null +++ b/ai/prompt-io/opencode/20260831T022317Z_768b5316_prompt_io.raw.md @@ -0,0 +1,60 @@ +--- +model: gpt-5.6-sol +service: opencode +timestamp: 2026-08-31T02:23:17Z +git_ref: ng_tpts_planning +diff_cmd: git diff 768b531662a88037e3988885102d2ff24a7ce34e..HEAD +--- + +# Raw output - repair PR #492 for landing + +## Generated changes + +> `git diff 768b531662a88037e3988885102d2ff24a7ce34e..HEAD -- ai/tpt-backends/` + +- Reconciled the shared backend contract with current address, + dispatch, capability, locality, and listener-rebind APIs. +- Updated the TIPC plan from downstream #493 implementation and + tests, preserving unresolved registrar election, collision, + locality, and socket-cleanup work as explicit follow-ups. +- Reworked the QUIC plan around a launch-time transport bootstrap, + one actor-owned endpoint, a transport nursery spanning parent + dial through deregistration, supervised UniFFI cleanup, complete + routable addresses, connection leases, and listener-owned tasks. +- Corrected the WireGuard bindspace plan to distinguish the + build-registered address registry from runtime capability. + +> `git diff 768b531662a88037e3988885102d2ff24a7ce34e..HEAD -- examples/multihost/wg_lan/` + +- Hardened WireGuard key conversion and parsing with strict + 32-byte validation, lazy protocol lookup, and explicit rejection + of unsupported nested tunnel descriptors. +- Made interface inspection asynchronous, bounded at its + cancellation request, role-specific, and separable from + privileged preflight commands. +- Corrected host-local binds, subactor overlay publication, stable + RPC module exposure, and missing-actor handling in the two-host + example. +- Updated the README so Tractor remains unprivileged and the local + versus remote overlay roles are explicit. + +> `git diff 768b531662a88037e3988885102d2ff24a7ce34e..HEAD -- pyproject.toml uv.lock ai/prompt-io/` + +- Regenerated `uv.lock` for the exact merged `py-multiaddr` WireGuard + codec revision. +- Replaced mutable historical prompt diff pointers with immutable + refs and normalized the substantive scope without rewriting the + historical raw response. + +## Verification output + +- `git diff --check`: passed. +- `uv lock --check`: passed. +- Ruff on the WireGuard example directory: passed. +- Python compilation of the WireGuard example directory: passed. +- Tractor imported from the PR worktree's existing environment. +- WireGuard address round-trip, local/peer role checks, malformed + base64 rejection, and nested-tunnel rejection: passed. +- Three independent final re-reviews reported no actionable + findings in the shared/TIPC, QUIC, or WireGuard slices. +- No live TIPC, iroh/UniFFI, or WireGuard network test was run. diff --git a/ai/tpt-backends/03_wg_tunnel_bindspace.md b/ai/tpt-backends/03_wg_tunnel_bindspace.md index 36c975b5..bdb0d579 100644 --- a/ai/tpt-backends/03_wg_tunnel_bindspace.md +++ b/ai/tpt-backends/03_wg_tunnel_bindspace.md @@ -33,7 +33,7 @@ onto `trio` as the library's sans-io layer allows. 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 - `(host, port)`, verify the pubkey against the live tunnel, + `(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. - `Address.namespace` exists in the Protocol @@ -82,9 +82,10 @@ does not create a new address type.** Two candidate encodings; 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()` — a `bidict` of - proto_key→type. `TunnelledAddress` isn't in it and must not - be (it's not 1:1 with a proto). So either add an explicit + `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. @@ -103,7 +104,7 @@ class WGTunnelSpec( msgspec.Struct, frozen=True, ): - peer_pubkey: str # std-base64 `wg(8)` form + pubkey: str # std-base64 `wg(8)` form iface: str = 'wg0' netns: str|None = None # layer-C-only fields, unset in layer A @@ -191,9 +192,10 @@ Observed protocol-name lists, for writing the `match`: ...))` w/ the bearer recorded in the spec. - keep the existing 2-proto cases byte-identical; add the new case *after* them. -- nesting (wg-in-wg) falls out of `.decapsulate_code()` cutting - at the *last* occurrence — peel repeatedly rather than - recursing through a bespoke splitter. +- 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 `.encapsulate()` composition; don't rebuild `str`s by hand. - **pending an upstream release**: py-multiaddr#108 is merged, so @@ -215,12 +217,21 @@ Port #482 §2's helpers into `tractor/discovery/_tunnel.py` as ```python def parse_wg_maddr(maddr: str) -> TunnelledAddress: ... # pure def wg8_pubkey(multibase_key: str) -> str: ... # pure -def verify_wg_peer(spec: WGTunnelSpec) -> bool: ... # impure probe +async def verify_wg_key( + spec: WGTunnelSpec, + role: Literal['local', 'peer'], + inspection: str|None = None, +) -> bool: ... # impure probe ``` -In layer A `verify_wg_peer()` may shell out (`wg show -peers`), but it must be a *single* function so layer B swaps -only its body. Never call it implicitly from +In layer A `verify_wg_key()` may shell out to role-specific +`wg show public-key|peers` queries, but it must be a *single* +async, time-bounded function so it never blocks trio's run thread +and layer B swaps only its body. It verifies key presence only, +not `Endpoint`, `AllowedIPs`, handshake state, or routing. Never +run `tractor` as root: privileged inspection stays a separate +step whose public-key output can be passed as `inspection`. Never +call it implicitly from `wrap_address()`/`parse_maddr()` — parsing must stay pure and side-effect-free; verification is the *caller's* explicit step (and later, the bindspace `@acm`'s). @@ -281,7 +292,7 @@ Three integration options, in increasing trio-nativeness: - (3) reimplement the codecs. Never. **Recommended split**: ship (1) first so layer B is a small, -reviewable, behaviour-preserving swap of `verify_wg_peer()`'s +reviewable, behaviour-preserving swap of `verify_wg_key()`'s body; then land (2) as a follow-up commit for the read path (`wg get`, `link get`) where the sans-io surface is smallest, and keep (1) for the privileged mutating ops. Measure before @@ -306,7 +317,7 @@ async def read_wg_peers( async def read_wg_pubkey(iface: str = 'wg0', ...) -> str: ... ``` -and `verify_wg_peer()` becomes a thin composition over the two. +and `verify_wg_key()` becomes a thin composition over the two. Note the pure-getter rule: no `read_wg_peers(..., create=True)`. --- diff --git a/examples/multihost/wg_lan/README.md b/examples/multihost/wg_lan/README.md index c8e5ba54..4a1c75f8 100644 --- a/examples/multihost/wg_lan/README.md +++ b/examples/multihost/wg_lan/README.md @@ -49,11 +49,9 @@ uv sync ``` gets you a `wg`-aware `multiaddr`. That pin goes away once a -release carries the codec. You also need `multibase`: - -```bash -uv pip install multibase -``` +release carries the codec. Its `py-multibase` dependency provides +the imported `multibase` module; no separate install command is +needed. Without the codec `parse_wg_maddr()` raises immediately with an actionable message — there is deliberately **no** degraded @@ -64,8 +62,9 @@ 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. +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. ## 0. tunnel setup (out-of-band, both hosts) @@ -106,8 +105,11 @@ AllowedIPs = 10.0.11.1/32 PersistentKeepalive = 25 ``` -Note how `ListenPort` and `Endpoint` are exactly the maddr's -bearer segment, and `[Interface] Address` is its overlay host. +This example configures host A's `ListenPort` and host B's +`Endpoint` from the maddr bearer, and configures host A's +`[Interface] Address` from its overlay host. The verification +step below checks keys only; it does not inspect those fields or +either peer's `AllowedIPs`. ```bash sudo wg-quick up wg0 # both hosts @@ -118,16 +120,40 @@ ping -c1 10.0.11.1 # from B ```bash python -c " -import base64, multibase +from wg_maddr import mb_pubkey key = open('wg_pub.key').read().strip() -print(multibase.encode('base64url', base64.b64decode(key)).decode()) +print(mb_pubkey(key)) " ``` Paste the `u...` output into `WG_MADDR` in both scripts (they use the same string — A's bearer, A's key, A's overlay ep). -## 2. run +## 2. verify the keys + +Interface inspection commonly needs `CAP_NET_ADMIN`. Keep that +privileged operation separate from the `tractor` processes: + +```bash +# host A: output must equal the maddr's A_pub key +export WG_KEY_INSPECTION="$(sudo wg show wg0 public-key)" + +# host B: output must contain the maddr's A_pub key +export WG_KEY_INSPECTION="$(sudo wg show wg0 peers)" +``` + +These checks establish only that host A uses the declared local +key and host B has that key as a configured peer. They do not +verify `Endpoint`, `AllowedIPs`, a recent handshake, or routing. +The exported text contains public keys only. Each script passes it +to `verify_wg_key()` with its host-specific role before starting +`tractor`. Callers that already have permission to inspect the +interface may omit that argument; the helper's direct query is +async and requests cancellation after five seconds. Trio's +subprocess termination escalation can make final process cleanup +take longer than that cancellation deadline. + +## 3. run ```bash # host A @@ -137,6 +163,17 @@ python host_a_srv.py python host_b_client.py ``` +Run both `tractor` programs as the normal application account, +not as root. Privilege is needed only for tunnel setup and the +separate inspection above. If using that preflight, keep the +host-specific `WG_KEY_INSPECTION` value exported in each +program's shell. + +The client binds its own actor listener to `10.0.11.2:0`, while +the service actor binds to host A's `10.0.11.1` overlay host with +a random port. Keep `LOCAL_OVERLAY_BIND` aligned with host B's +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. @@ -153,19 +190,19 @@ Four corrections, all from all. `parse_wg_maddr()` now rejects it with an actionable error. 2. **parsing is pure.** #482's 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. + to the parse; `verify_wg_key()` is now a separate, explicitly + composed step for inspection-capable callers. 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`. + 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` 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` doesn't have. + 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` doesn't have. ## next diff --git a/examples/multihost/wg_lan/host_a_srv.py b/examples/multihost/wg_lan/host_a_srv.py index 21ccc50a..6d545dbe 100644 --- a/examples/multihost/wg_lan/host_a_srv.py +++ b/examples/multihost/wg_lan/host_a_srv.py @@ -6,14 +6,17 @@ 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 ( parse_wg_maddr, - verify_wg_peer, + verify_wg_key, WGTunnelledAddr, ) @@ -34,11 +37,17 @@ async def echo(msg: str) -> str: async def main(): addr: WGTunnelledAddr = parse_wg_maddr(WG_MADDR) - assert verify_wg_peer(addr), ( - f'wg pubkey from maddr not active on wg0 !\n' - f'maddr: {WG_MADDR}\n' - f'key: {addr.peer_pubkey}\n' - ) + inspection: str | None = os.environ.get('WG_KEY_INSPECTION') + if not await verify_wg_key( + addr, + role='local', + inspection=inspection, + ): + raise RuntimeError( + f'Maddr key is not wg0 local public key!\n' + f'maddr: {WG_MADDR}\n' + f'key: {addr.wg_pubkey}\n' + ) print( f'wg bearer (kernel-owned): {addr.bearer}\n' f'tractor overlay ep: {addr.overlay}\n' @@ -51,7 +60,9 @@ async def main(): ) as an: await an.start_actor( 'echo_srv', - enable_modules=[__name__], + bind_addrs=[(addr.overlay[0], 0)], + enable_transports=[addr.overlay_proto], + enable_modules=['host_a_srv'], ) print(f'echo_srv up on\n {addr.maddr}\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 cd9970be..86e3e59c 100644 --- a/examples/multihost/wg_lan/host_b_client.py +++ b/examples/multihost/wg_lan/host_b_client.py @@ -4,15 +4,18 @@ Host B: workstation dialing host A's actor tree through the `wg` tunnel. ''' + from __future__ import annotations +import os + import tractor import trio from host_a_srv import echo # noqa: F401 (RPC refs it by mod path) from wg_maddr import ( parse_wg_maddr, - verify_wg_peer, + verify_wg_key, WGTunnelledAddr, ) @@ -22,23 +25,33 @@ WG_MADDR: str = ( '/wg/u' '/ip4/10.0.11.1/tcp/1616' ) +LOCAL_OVERLAY_BIND: tuple[str, int] = ('10.0.11.2', 0) async def main(): addr: WGTunnelledAddr = parse_wg_maddr(WG_MADDR) - assert verify_wg_peer(addr), ( - f'wg pubkey from maddr not a peer on wg0 !\n' - f'maddr: {WG_MADDR}\n' - ) + inspection: str | None = os.environ.get('WG_KEY_INSPECTION') + if not await verify_wg_key( + addr, + role='peer', + inspection=inspection, + ): + raise RuntimeError( + f'Maddr key is not a configured wg0 peer!\n' + f'maddr: {WG_MADDR}\n' + f'key: {addr.wg_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], ), tractor.find_actor( 'echo_srv', registry_addrs=[addr.overlay], + raise_on_none=True, ) as portal, ): res: str = await portal.run( diff --git a/examples/multihost/wg_lan/wg_maddr.py b/examples/multihost/wg_lan/wg_maddr.py index 67549568..f6e664ee 100644 --- a/examples/multihost/wg_lan/wg_maddr.py +++ b/examples/multihost/wg_lan/wg_maddr.py @@ -26,7 +26,7 @@ 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 the tunnel peer's Curve25519 pubkey +`/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. @@ -43,28 +43,33 @@ Design rules this module follows (see 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 a tunnel - stack* every bit as much as to decoding a single proto. + 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_peer()` when it wants it; nothing implicit. + 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` (a `bidict`, so 1:1 - proto-key<->type) bc it has no `MsgTransport` of its own. The + `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 import base64 -import subprocess from typing import Literal import msgspec from multiaddr import Multiaddr -from multiaddr.protocols import P_WG +import trio IPProto = Literal['ip4', 'ip6'] @@ -76,15 +81,16 @@ class WGTunnelledAddr( ): ''' A `wg`-tunnelled endpoint: the underlay bearer, the tunnel - peer key, and the overlay addr `tractor` binds/dials. + key, and the overlay addr `tractor` binds/dials. ''' + # underlay, owned by `wg(8)`/the kernel — NEVER bound by us bearer: tuple[str, int] - # tunnel peer pubkey in the std-base64 `wg(8)` form, i.e. - # directly comparable to `wg show peers` output - peer_pubkey: str + # 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()` @@ -108,7 +114,7 @@ class WGTunnelledAddr( return ( Multiaddr(f'/{self.bearer_ip}/{b_host}/udp/{b_port}') .encapsulate( - Multiaddr(f'/wg/{mb_pubkey(self.peer_pubkey)}') + Multiaddr(f'/wg/{mb_pubkey(self.wg_pubkey)}') ) .encapsulate( Multiaddr( @@ -133,7 +139,13 @@ def mb_pubkey(wg8_key: str) -> str: ''' import multibase - raw: bytes = base64.b64decode(wg8_key) + + 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') @@ -143,11 +155,17 @@ def wg8_pubkey(mb_key: str) -> str: ''' 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 +_wg_proto_known: bool | None = None def _have_wg_maddr_proto() -> bool: @@ -167,6 +185,7 @@ def _have_wg_maddr_proto() -> bool: 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 @@ -177,7 +196,7 @@ def _have_wg_maddr_proto() -> bool: def parse_wg_maddr( - maddr: str|Multiaddr, + maddr: str | Multiaddr, ) -> WGTunnelledAddr: ''' Peel a `wg`-tunnelled maddr into its bearer/key/overlay @@ -208,18 +227,31 @@ def parse_wg_maddr( for seg in segs for proto in seg.protocols() ] - if 'wg' not in names: + 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(P_WG) + bearer_ma: Multiaddr = ma.decapsulate_code(wg_code) overlay_ma: Multiaddr = Multiaddr.join( *segs[names.index('wg') + 1:] ) @@ -259,7 +291,7 @@ def parse_wg_maddr( return WGTunnelledAddr( bearer=bearer, - peer_pubkey=wg8_pubkey(ma.value_for_protocol('wg')), + wg_pubkey=wg8_pubkey(ma.value_for_protocol('wg')), overlay=overlay, overlay_proto=l4, bearer_ip=b_ip, @@ -267,13 +299,23 @@ def parse_wg_maddr( ) -def verify_wg_peer( +async def verify_wg_key( addr: WGTunnelledAddr, + role: Literal['local', 'peer'], iface: str = 'wg0', + timeout: float = 5, + inspection: str | None = None, ) -> bool: ''' - True iff `addr.peer_pubkey` is a configured peer (or our own - pubkey) on `iface`. + Verify the declared key in the role required on this host. + + A bearer host uses `role='local'`; a dialer uses `role='peer'`. + This verifies only key presence. It does not inspect the peer's + endpoint, AllowedIPs, handshake state, or iface addresses. + + `inspection` accepts output captured by a separate privileged + `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()`. @@ -284,16 +326,23 @@ def verify_wg_peer( netns unless `netns=` is passed down. ''' - def _wg(*args: str) -> str: - return subprocess.run( - ['wg', 'show', iface, *args], - capture_output=True, - text=True, - check=True, - ).stdout + match role: + case 'local': + field = 'public-key' + case 'peer': + field = 'peers' + case _: + raise ValueError(f'Unknown WireGuard key role: {role!r}') - return ( - addr.peer_pubkey in _wg('peers').split() - or - addr.peer_pubkey == _wg('public-key').strip() - ) + if inspection is None: + with trio.fail_after(timeout): + proc = await trio.run_process( + ['wg', 'show', iface, field], + capture_stdout=True, + check=True, + ) + inspection = proc.stdout.decode() + + if role == 'local': + return addr.wg_pubkey == inspection.strip() + return addr.wg_pubkey in inspection.split() diff --git a/uv.lock b/uv.lock index fb63d5ce..8a033627 100644 --- a/uv.lock +++ b/uv.lock @@ -273,9 +273,9 @@ name = "greenback" version = "1.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "greenlet", marker = "python_full_version < '3.14'" }, - { name = "outcome", marker = "python_full_version < '3.14'" }, - { name = "sniffio", marker = "python_full_version < '3.14'" }, + { name = "greenlet" }, + { name = "outcome" }, + { name = "sniffio" }, ] sdist = { url = "https://files.pythonhosted.org/packages/dc/c1/ab3a42c0f3ed56df9cd33de1539b3198d98c6ccbaf88a73d6be0b72d85e0/greenback-1.2.1.tar.gz", hash = "sha256:de3ca656885c03b96dab36079f3de74bb5ba061da9bfe3bb69dccc866ef95ea3", size = 42597, upload-time = "2024-02-20T21:23:13.239Z" } wheels = [ @@ -518,7 +518,7 @@ wheels = [ [[package]] name = "multiaddr" version = "0.2.0" -source = { registry = "https://pypi.org/simple" } +source = { git = "https://github.com/multiformats/py-multiaddr.git?rev=f86519daaa21699023d0037c58cdff600313dd09#f86519daaa21699023d0037c58cdff600313dd09" } dependencies = [ { name = "base58" }, { name = "dnspython" }, @@ -533,10 +533,6 @@ dependencies = [ { name = "trio-typing" }, { name = "varint" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c7/10/4e26a8577cfce1c0febc8d83087e1373e93c695c6e73ad010546fb67e229/multiaddr-0.2.0.tar.gz", hash = "sha256:acb6b25c332ec1b2f1f8fef8d03a8c63385d34a87d690df0f4bba43cdf6efe8d", size = 58356, upload-time = "2026-03-17T21:51:00.274Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b5/13/56e503d01218d1ca27ea9fda862045a4b400cae5e756f47315f5aaba0eee/multiaddr-0.2.0-py3-none-any.whl", hash = "sha256:bcff7bf3d7de3d6da0b865b25423bcb411de1d20d70cc6abfacf75170d17866c", size = 40424, upload-time = "2026-03-17T21:50:58.833Z" }, -] [[package]] name = "mypy-extensions" @@ -1193,7 +1189,7 @@ requires-dist = [ { name = "bidict", specifier = ">=0.23.1" }, { name = "colorlog", specifier = ">=6.8.2,<7" }, { name = "msgspec", specifier = ">=0.20.0" }, - { name = "multiaddr", specifier = ">=0.2.0" }, + { name = "multiaddr", git = "https://github.com/multiformats/py-multiaddr.git?rev=f86519daaa21699023d0037c58cdff600313dd09" }, { name = "pdbp", specifier = ">=1.8.2,<2" }, { name = "platformdirs", specifier = ">=4.4.0" }, { name = "setproctitle", specifier = ">=1.3,<2" },