From ece3827bdf250a870ec78a2cb399a93bfe80da21 Mon Sep 17 00:00:00 2001 From: goodboy Date: Fri, 21 Aug 2026 23:19:17 -0400 Subject: [PATCH] Add explicit `verify_wg_peer()` inspection Validate a declared tunnel key against one `pyroute2` snapshot containing the iface's own key and configured peers. Deats, - share worker offload across all WireGuard key readers - forward `WGTunnelSpec.iface` and `.netns` to the read - reject malformed declarations before netlink I/O - export the async helper and cover local, peer and absent keys - replace multihost's `wg show` subprocess probe Prompt-IO: ai/prompt-io/opencode/20260822T023226Z_59a8ecfd_prompt_io.md (this patch was generated in some part by `opencode` using `gpt-5.6-sol` (`openai`)) --- .../20260822T023226Z_59a8ecfd_prompt_io.md | 46 ++++++++++ ...20260822T023226Z_59a8ecfd_prompt_io.raw.md | 31 +++++++ ai/tpt-backends/03_wg_tunnel_bindspace.md | 13 +-- examples/multihost/wg_lan/README.md | 28 +++--- examples/multihost/wg_lan/host_a_srv.py | 5 +- examples/multihost/wg_lan/host_b_client.py | 5 +- examples/multihost/wg_lan/wg_maddr.py | 63 ------------- tests/discovery/test_wg_inspection.py | 88 +++++++++++++++++++ tractor/discovery/__init__.py | 1 + tractor/discovery/_tunnel.py | 52 +++++++++-- 10 files changed, 236 insertions(+), 96 deletions(-) create mode 100644 ai/prompt-io/opencode/20260822T023226Z_59a8ecfd_prompt_io.md create mode 100644 ai/prompt-io/opencode/20260822T023226Z_59a8ecfd_prompt_io.raw.md delete mode 100644 examples/multihost/wg_lan/wg_maddr.py diff --git a/ai/prompt-io/opencode/20260822T023226Z_59a8ecfd_prompt_io.md b/ai/prompt-io/opencode/20260822T023226Z_59a8ecfd_prompt_io.md new file mode 100644 index 00000000..91973f79 --- /dev/null +++ b/ai/prompt-io/opencode/20260822T023226Z_59a8ecfd_prompt_io.md @@ -0,0 +1,46 @@ +--- +model: gpt-5.6-sol +service: opencode +session: tractor-addr-unpacking +timestamp: 2026-08-22T02:32:26Z +git_ref: 59a8ecfd +scope: code +substantive: true +raw_file: 20260822T023226Z_59a8ecfd_prompt_io.raw.md +--- + +## Prompt + +After committing the read-only pyroute2 helpers and `wgman` design +update, the human authorized the next isolated Layer B change: +explicit `verify_wg_peer()` composition over WireGuard inspection. + +## Response summary + +Added and exported async `verify_wg_peer()` using one validated +WireGuard key snapshot. It recognizes local-interface and configured +peer identities without coupling kernel inspection to address +parsing. Updated the multihost examples to use the production helper +and removed their subprocess-based probe. + +## Files changed + +- `tractor/discovery/_tunnel.py` - shared async snapshot reader and + explicit verification helper. +- `tractor/discovery/__init__.py` - public verification export. +- `tests/discovery/test_wg_inspection.py` - local, peer, absent and + malformed-key verification coverage. +- `examples/multihost/wg_lan/host_a_srv.py` - async local-key check. +- `examples/multihost/wg_lan/host_b_client.py` - async peer-key check. +- `examples/multihost/wg_lan/wg_maddr.py` - removed obsolete + subprocess probe. +- `examples/multihost/wg_lan/README.md` - pyroute2 requirements and + verification workflow. +- `ai/tpt-backends/03_wg_tunnel_bindspace.md` - async API contract. + +## Human edits + +The human selected this pre-agreed verification layer as the next +atomic change after reviewing and committing the preceding read and +architecture changes. The agent implemented the source changes; no +direct manual edits or follow-up corrections were observed. diff --git a/ai/prompt-io/opencode/20260822T023226Z_59a8ecfd_prompt_io.raw.md b/ai/prompt-io/opencode/20260822T023226Z_59a8ecfd_prompt_io.raw.md new file mode 100644 index 00000000..1b086c10 --- /dev/null +++ b/ai/prompt-io/opencode/20260822T023226Z_59a8ecfd_prompt_io.raw.md @@ -0,0 +1,31 @@ +--- +model: gpt-5.6-sol +service: opencode +timestamp: 2026-08-22T02:32:26Z +git_ref: 59a8ecfd +diff_cmd: git diff HEAD~1..HEAD +--- + +# Raw output - verify declared WireGuard identities + +The human authorized the next incremental Layer B change after +committing the read-only pyroute2 helpers and first-child `wgman` +design update. + +> `git diff HEAD~1..HEAD -- tractor/discovery/_tunnel.py tractor/discovery/__init__.py tests/discovery/test_wg_inspection.py examples/multihost/wg_lan ai/tpt-backends/03_wg_tunnel_bindspace.md` + +Added async `verify_wg_peer()` over one WireGuard key snapshot. It +validates the declared `WGTunnelSpec.peer_pubkey` before I/O, forwards +the spec's iface/netns, and accepts either the local interface key for +a source/listen declaration or a configured peer key for a +destination/dial declaration. + +Refactored worker offload behind one shared async reader so +verification cannot compare two different netlink snapshots. Exported +the helper, added local/peer/absent/malformed-key coverage, and moved +the multihost examples from their local `wg show` subprocess probe to +the production API. + +Ruff and lock checks passed. Focused WireGuard/tunnel/multiaddr +coverage passed 51 tests; the complete discovery suite passed 92 +tests with 2 xpasses. diff --git a/ai/tpt-backends/03_wg_tunnel_bindspace.md b/ai/tpt-backends/03_wg_tunnel_bindspace.md index 060b23f4..f11cb74d 100644 --- a/ai/tpt-backends/03_wg_tunnel_bindspace.md +++ b/ai/tpt-backends/03_wg_tunnel_bindspace.md @@ -206,7 +206,7 @@ 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 codecs + explicit verification Port #482 §2's pure helpers into `tractor/discovery/_tunnel.py`, keeping the impure probe cleanly @@ -215,12 +215,12 @@ separated until layer B: ```python def parse_wg_maddr(maddr: str) -> TunnelledAddress: ... # pure def wg8_pubkey(multibase_key: str) -> str: ... # pure -def verify_wg_peer(spec: WGTunnelSpec) -> bool: ... # layer B +async def verify_wg_peer(spec: WGTunnelSpec) -> bool: ... # layer B ``` -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 +Layer A's example-local `verify_wg_peer()` may shell out (`wg show + peers`), but layer B replaces that probe with one explicit async +function backed by pyroute2. 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). @@ -309,7 +309,8 @@ 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_peer()` becomes a thin composition over one shared key +snapshot. 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 36fe080a..2f57c368 100644 --- a/examples/multihost/wg_lan/README.md +++ b/examples/multihost/wg_lan/README.md @@ -45,11 +45,12 @@ no `wg` codec. So `pyproject.toml` temporarily pins the merge commit in its PEP 621 dependency metadata, and a plain ```bash -uv sync +uv sync --extra wg ``` -gets you a `wg`-aware `multiaddr`. That pin goes away once a -release carries the codec. `py-multibase` is a direct dependency. +gets you a `wg`-aware `multiaddr` plus pyroute2's Linux netlink API. +The multiaddr pin goes away once a release carries the codec. +`py-multibase` is a direct dependency. Without the codec `parse_wg_maddr()` raises immediately with an actionable message — there is deliberately **no** degraded @@ -150,13 +151,12 @@ 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. -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`. + to the parse; async `verify_wg_peer()` is now a separate, + explicitly composed step that the caller invokes. Implicit + kernel inspection from a parser is a nasty surprise. +3. **no `sudo` or subprocess.** #482 ran `sudo wg show`; tractor's + helper reads generic netlink through pyroute2 and never attempts + privilege escalation or namespace creation. 4. **no new `Address` proto-type.** The tunnel rides *beside* the overlay addr in a frozen `TunnelledAddress`, and only `.overlay` crosses into `open_nursery()`. #482 §6 floated a `WGAddress` @@ -166,7 +166,7 @@ Four corrections, all from ## next -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. +Layer A's `TunnelledAddress` and native maddr parser plus Layer B's +explicit pyroute2 verification now live in `tractor.discovery`. Next, +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 297b0389..cbde4ab8 100644 --- a/examples/multihost/wg_lan/host_a_srv.py +++ b/examples/multihost/wg_lan/host_a_srv.py @@ -14,10 +14,9 @@ from tractor.discovery import ( TunnelledAddress, mk_maddr, parse_wg_maddr, + verify_wg_peer, ) -from wg_maddr import verify_wg_peer - # 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 @@ -35,7 +34,7 @@ async def echo(msg: str) -> str: async def main(): addr: TunnelledAddress = parse_wg_maddr(WG_MADDR) - assert verify_wg_peer(addr), ( + assert await verify_wg_peer(addr.tunnel), ( f'wg pubkey from maddr not active on wg0 !\n' f'maddr: {WG_MADDR}\n' f'key: {addr.tunnel.peer_pubkey}\n' diff --git a/examples/multihost/wg_lan/host_b_client.py b/examples/multihost/wg_lan/host_b_client.py index 2c5d61f3..98b867e8 100644 --- a/examples/multihost/wg_lan/host_b_client.py +++ b/examples/multihost/wg_lan/host_b_client.py @@ -11,11 +11,10 @@ import trio from tractor.discovery import ( TunnelledAddress, parse_wg_maddr, + verify_wg_peer, ) from host_a_srv import echo # noqa: F401 (RPC refs it by mod path) -from wg_maddr import verify_wg_peer - # same maddr as host A: A's bearer, A's key, A's overlay ep WG_MADDR: str = ( '/ip4/192.168.1.50/udp/51820' @@ -26,7 +25,7 @@ WG_MADDR: str = ( async def main(): addr: TunnelledAddress = parse_wg_maddr(WG_MADDR) - assert verify_wg_peer(addr), ( + assert await verify_wg_peer(addr.tunnel), ( f'wg pubkey from maddr not a peer on wg0 !\n' f'maddr: {WG_MADDR}\n' ) diff --git a/examples/multihost/wg_lan/wg_maddr.py b/examples/multihost/wg_lan/wg_maddr.py deleted file mode 100644 index 28b36dac..00000000 --- a/examples/multihost/wg_lan/wg_maddr.py +++ /dev/null @@ -1,63 +0,0 @@ -# tractor: distributed structured concurrency. -r''' -Verify `wg` peers declared by tractor's multiaddr parser. - -`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 _____/ - -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 subprocess - -from tractor.discovery import ( - TunnelledAddress, - WGTunnelSpec, -) - - -def verify_wg_peer( - addr: TunnelledAddress, - iface: str|None = None, -) -> bool: - ''' - Check the outer tunnel's key against one local `wg` iface. - - 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` - 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 - - def _wg(*args: str) -> str: - return subprocess.run( - ['wg', 'show', iface, *args], - capture_output=True, - text=True, - check=True, - ).stdout - - return ( - spec.peer_pubkey in _wg('peers').split() - or - spec.peer_pubkey == _wg('public-key').strip() - ) diff --git a/tests/discovery/test_wg_inspection.py b/tests/discovery/test_wg_inspection.py index 78690527..8e333517 100644 --- a/tests/discovery/test_wg_inspection.py +++ b/tests/discovery/test_wg_inspection.py @@ -16,7 +16,10 @@ import trio from tractor.discovery import ( read_wg_peers, read_wg_pubkey, + verify_wg_peer, + WGTunnelSpec, ) +from tractor.discovery import _tunnel pyroute2: Any = pytest.importorskip('pyroute2') @@ -24,6 +27,7 @@ pyroute2: Any = pytest.importorskip('pyroute2') _PUBKEY: str = 'g3x7z0AdV1rM6UQU22CC7IL3/ivn4DzrE7ikDhCZ/Dc=' _PEER_1: str = '7PClzcj8o1yAjyPJb0zL2Gt0s2J7yZ6c0JXYqNBGr0E=' _PEER_2: str = 'H7bJbl1bpY7VzDlB5wI3KjA7JsiYoMWGDJd8dYgc5iw=' +_MISSING_KEY: str = 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=' class Attrs: @@ -215,3 +219,87 @@ def test_wg_client_closes_when_read_fails( assert instance is not None assert instance.closed + + +@pytest.mark.parametrize( + ('declared_key', 'expected'), + ( + (_PUBKEY, True), + (_PEER_2, True), + (_MISSING_KEY, False), + ), +) +def test_verify_wg_peer( + monkeypatch: pytest.MonkeyPatch, + declared_key: str, + expected: bool, +) -> None: + ''' + A tunnel declaration can identify either side of one local iface. + + Return one stable key snapshot from the async reader, then prove + a local interface key and configured peer both verify while an + absent key does not. Also prove the spec selects the iface/netns + supplied to the read instead of silently using process defaults. + + ''' + reads: list[tuple[str, str|None]] = [] + + async def read_keys( + iface: str, + netns: str|None, + ) -> tuple[str, tuple[str, ...]]: + ''' + Return one deterministic WireGuard key snapshot. + + ''' + reads.append((iface, netns)) + return _PUBKEY, (_PEER_1, _PEER_2) + + monkeypatch.setattr( + _tunnel, + '_read_wg_keys', + read_keys, + ) + spec: WGTunnelSpec = WGTunnelSpec( + peer_pubkey=declared_key, + iface='wg-test', + netns='actor-net', + ) + + assert trio.run(verify_wg_peer, spec) is expected + assert reads == [('wg-test', 'actor-net')] + + +def test_verify_wg_peer_validates_before_read( + monkeypatch: pytest.MonkeyPatch, +) -> None: + ''' + A directly constructed tunnel spec can contain a malformed key. + + Install a reader which would fail if called, pass malformed + base64, and prove validation rejects the declaration before any + kernel-state inspection occurs. + + ''' + async def unexpected_read( + iface: str, + netns: str|None, + ) -> NoReturn: + ''' + Fail if malformed-key validation reaches the read boundary. + + ''' + raise AssertionError('WireGuard read must not run') + + monkeypatch.setattr( + _tunnel, + '_read_wg_keys', + unexpected_read, + ) + spec: WGTunnelSpec = WGTunnelSpec( + peer_pubkey='not-base64', + ) + + with pytest.raises(ValueError): + trio.run(verify_wg_peer, spec) diff --git a/tractor/discovery/__init__.py b/tractor/discovery/__init__.py index c5089561..cfa01cf5 100644 --- a/tractor/discovery/__init__.py +++ b/tractor/discovery/__init__.py @@ -40,5 +40,6 @@ from ._tunnel import ( read_wg_pubkey as read_wg_pubkey, strip_tunnels as strip_tunnels, tunnels_of as tunnels_of, + verify_wg_peer as verify_wg_peer, wg8_pubkey as wg8_pubkey, ) diff --git a/tractor/discovery/_tunnel.py b/tractor/discovery/_tunnel.py index 68b1fd2e..1fb3139a 100644 --- a/tractor/discovery/_tunnel.py +++ b/tractor/discovery/_tunnel.py @@ -198,7 +198,7 @@ def _wg8_key_str( return key -def _read_wg_keys( +def _sync_read_wg_keys( iface: str, netns: str|None, ) -> tuple[str, tuple[str, ...]]: @@ -275,6 +275,22 @@ def _read_wg_keys( ) +async def _read_wg_keys( + iface: str, + netns: str|None, +) -> tuple[str, tuple[str, ...]]: + ''' + Read one WireGuard key snapshot without blocking Trio. + + ''' + return await trio.to_thread.run_sync( + _sync_read_wg_keys, + iface, + netns, + abandon_on_cancel=False, + ) + + async def read_wg_pubkey( iface: str = 'wg0', netns: str|None = None, @@ -286,11 +302,9 @@ async def read_wg_pubkey( keys: tuple[ str, tuple[str, ...], - ] = await trio.to_thread.run_sync( - _read_wg_keys, + ] = await _read_wg_keys( iface, netns, - abandon_on_cancel=False, ) return keys[0] @@ -306,15 +320,39 @@ async def read_wg_peers( keys: tuple[ str, tuple[str, ...], - ] = await trio.to_thread.run_sync( - _read_wg_keys, + ] = await _read_wg_keys( iface, netns, - abandon_on_cancel=False, ) return keys[1] +async def verify_wg_peer( + spec: WGTunnelSpec, +) -> bool: + ''' + Verify a declared WireGuard identity against local kernel state. + + A source/listen maddr names the local interface key, while a + destination/dial maddr names one configured peer. Accept either + match without making verification an implicit part of parsing. + + ''' + declared_key: str = _wg8_key_str(spec.peer_pubkey) + keys: tuple[ + str, + tuple[str, ...], + ] = await _read_wg_keys( + spec.iface, + spec.netns, + ) + return ( + declared_key == keys[0] + or + declared_key in keys[1] + ) + + def _wg_proto_code() -> int: ''' Deliver the installed `py-multiaddr` `/wg/` protocol code.