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`))
wkt/wg_pyroute2_read
Gud Boi 2026-08-21 23:19:17 -04:00
parent 35e496c166
commit ece3827bdf
10 changed files with 236 additions and 96 deletions

View File

@ -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.

View File

@ -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.

View File

@ -206,7 +206,7 @@ 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 codecs + explicit verification
Port #482 §2's pure helpers into Port #482 §2's pure helpers into
`tractor/discovery/_tunnel.py`, keeping the impure probe cleanly `tractor/discovery/_tunnel.py`, keeping the impure probe cleanly
@ -215,12 +215,12 @@ separated until layer B:
```python ```python
def parse_wg_maddr(maddr: str) -> TunnelledAddress: ... # pure def parse_wg_maddr(maddr: str) -> TunnelledAddress: ... # pure
def wg8_pubkey(multibase_key: str) -> str: ... # 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 <if> Layer A's example-local `verify_wg_peer()` may shell out (`wg show
peers`), but it must be a *single* function so layer B swaps <if> peers`), but layer B replaces that probe with one explicit async
only its body. Never call it implicitly from function backed by pyroute2. Never call it implicitly from
`wrap_address()`/`parse_maddr()` — parsing must stay pure and `wrap_address()`/`parse_maddr()` — parsing must stay pure and
side-effect-free; verification is the *caller's* explicit step side-effect-free; verification is the *caller's* explicit step
(and later, the bindspace `@acm`'s). (and later, the bindspace `@acm`'s).
@ -309,7 +309,8 @@ async def read_wg_peers(
async def read_wg_pubkey(iface: str = 'wg0', ...) -> str: ... 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)`. Note the pure-getter rule: no `read_wg_peers(..., create=True)`.
--- ---

View File

@ -45,11 +45,12 @@ no `wg` codec. So `pyproject.toml` temporarily pins the merge commit
in its PEP 621 dependency metadata, and a plain in its PEP 621 dependency metadata, and a plain
```bash ```bash
uv sync uv sync --extra wg
``` ```
gets you a `wg`-aware `multiaddr`. That pin goes away once a gets you a `wg`-aware `multiaddr` plus pyroute2's Linux netlink API.
release carries the codec. `py-multibase` is a direct dependency. 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 Without the codec `parse_wg_maddr()` raises immediately with an
actionable message — there is deliberately **no** degraded 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 all. `parse_wg_maddr()` now rejects it with an actionable
error. error.
2. **parsing is pure.** #482's helper had the key-check adjacent 2. **parsing is pure.** #482's helper had the key-check adjacent
to the parse; `verify_wg_peer()` is now a separate, explicitly to the parse; async `verify_wg_peer()` is now a separate,
composed step that the caller invokes. A parser that shells explicitly composed step that the caller invokes. Implicit
out is a nasty surprise. kernel inspection from a parser is a nasty surprise.
3. **no `sudo`.** #482 ran `sudo wg show`; a library/example must 3. **no `sudo` or subprocess.** #482 ran `sudo wg show`; tractor's
never escalate. `wg show` works unprivileged for read on most helper reads generic netlink through pyroute2 and never attempts
setups; if yours needs root, run the script as root rather privilege escalation or namespace creation.
than embedding `sudo`.
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 `TunnelledAddress`, 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`
@ -166,7 +166,7 @@ Four corrections, all from
## next ## next
Layer A's `TunnelledAddress` and native maddr parser now live in Layer A's `TunnelledAddress` and native maddr parser plus Layer B's
`tractor.discovery`. Next, replace this example's `wg(8)` verification explicit pyroute2 verification now live in `tractor.discovery`. Next,
probe with `pyroute2`, then add `open_bindspace()` `@acm`s which add `open_bindspace()` `@acm`s which create/tear down the iface and
create/tear down the iface and netns. netns.

View File

@ -14,10 +14,9 @@ from tractor.discovery import (
TunnelledAddress, TunnelledAddress,
mk_maddr, mk_maddr,
parse_wg_maddr, parse_wg_maddr,
verify_wg_peer,
) )
from wg_maddr import verify_wg_peer
# 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
@ -35,7 +34,7 @@ async def echo(msg: str) -> str:
async def main(): async def main():
addr: TunnelledAddress = parse_wg_maddr(WG_MADDR) 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'wg pubkey from maddr not active on wg0 !\n'
f'maddr: {WG_MADDR}\n' f'maddr: {WG_MADDR}\n'
f'key: {addr.tunnel.peer_pubkey}\n' f'key: {addr.tunnel.peer_pubkey}\n'

View File

@ -11,11 +11,10 @@ import trio
from tractor.discovery import ( from tractor.discovery import (
TunnelledAddress, TunnelledAddress,
parse_wg_maddr, parse_wg_maddr,
verify_wg_peer,
) )
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 verify_wg_peer
# 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 = (
'/ip4/192.168.1.50/udp/51820' '/ip4/192.168.1.50/udp/51820'
@ -26,7 +25,7 @@ WG_MADDR: str = (
async def main(): async def main():
addr: TunnelledAddress = parse_wg_maddr(WG_MADDR) 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'wg pubkey from maddr not a peer on wg0 !\n'
f'maddr: {WG_MADDR}\n' f'maddr: {WG_MADDR}\n'
) )

View File

@ -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<key>/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()
)

View File

@ -16,7 +16,10 @@ import trio
from tractor.discovery import ( from tractor.discovery import (
read_wg_peers, read_wg_peers,
read_wg_pubkey, read_wg_pubkey,
verify_wg_peer,
WGTunnelSpec,
) )
from tractor.discovery import _tunnel
pyroute2: Any = pytest.importorskip('pyroute2') pyroute2: Any = pytest.importorskip('pyroute2')
@ -24,6 +27,7 @@ pyroute2: Any = pytest.importorskip('pyroute2')
_PUBKEY: str = 'g3x7z0AdV1rM6UQU22CC7IL3/ivn4DzrE7ikDhCZ/Dc=' _PUBKEY: str = 'g3x7z0AdV1rM6UQU22CC7IL3/ivn4DzrE7ikDhCZ/Dc='
_PEER_1: str = '7PClzcj8o1yAjyPJb0zL2Gt0s2J7yZ6c0JXYqNBGr0E=' _PEER_1: str = '7PClzcj8o1yAjyPJb0zL2Gt0s2J7yZ6c0JXYqNBGr0E='
_PEER_2: str = 'H7bJbl1bpY7VzDlB5wI3KjA7JsiYoMWGDJd8dYgc5iw=' _PEER_2: str = 'H7bJbl1bpY7VzDlB5wI3KjA7JsiYoMWGDJd8dYgc5iw='
_MISSING_KEY: str = 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA='
class Attrs: class Attrs:
@ -215,3 +219,87 @@ def test_wg_client_closes_when_read_fails(
assert instance is not None assert instance is not None
assert instance.closed 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)

View File

@ -40,5 +40,6 @@ from ._tunnel import (
read_wg_pubkey as read_wg_pubkey, read_wg_pubkey as read_wg_pubkey,
strip_tunnels as strip_tunnels, strip_tunnels as strip_tunnels,
tunnels_of as tunnels_of, tunnels_of as tunnels_of,
verify_wg_peer as verify_wg_peer,
wg8_pubkey as wg8_pubkey, wg8_pubkey as wg8_pubkey,
) )

View File

@ -198,7 +198,7 @@ def _wg8_key_str(
return key return key
def _read_wg_keys( def _sync_read_wg_keys(
iface: str, iface: str,
netns: str|None, netns: str|None,
) -> tuple[str, tuple[str, ...]]: ) -> 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( async def read_wg_pubkey(
iface: str = 'wg0', iface: str = 'wg0',
netns: str|None = None, netns: str|None = None,
@ -286,11 +302,9 @@ async def read_wg_pubkey(
keys: tuple[ keys: tuple[
str, str,
tuple[str, ...], tuple[str, ...],
] = await trio.to_thread.run_sync( ] = await _read_wg_keys(
_read_wg_keys,
iface, iface,
netns, netns,
abandon_on_cancel=False,
) )
return keys[0] return keys[0]
@ -306,15 +320,39 @@ async def read_wg_peers(
keys: tuple[ keys: tuple[
str, str,
tuple[str, ...], tuple[str, ...],
] = await trio.to_thread.run_sync( ] = await _read_wg_keys(
_read_wg_keys,
iface, iface,
netns, netns,
abandon_on_cancel=False,
) )
return keys[1] 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: def _wg_proto_code() -> int:
''' '''
Deliver the installed `py-multiaddr` `/wg/` protocol code. Deliver the installed `py-multiaddr` `/wg/` protocol code.