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`))
parent
59a8ecfd0e
commit
d35c802b1c
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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 <if>
|
||||
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
|
||||
<if> 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)`.
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
)
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -195,7 +195,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, ...]]:
|
||||
|
|
@ -272,6 +272,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,
|
||||
|
|
@ -283,11 +299,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]
|
||||
|
||||
|
|
@ -303,15 +317,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.
|
||||
|
|
|
|||
Loading…
Reference in New Issue