From 7f2654770a4159658e3eb2dec908b76a75fdcfab Mon Sep 17 00:00:00 2001 From: goodboy Date: Fri, 21 Aug 2026 22:21:22 -0400 Subject: [PATCH] Read WireGuard iface keys through `pyroute2` Add async `read_wg_pubkey()` and `read_wg_peers()` helpers which offload `WireGuard.info()` calls to a `trio` worker thread. Deats, - add the Linux-only `wg` extra and pin `pyroute2` - pass `flags=0` so a read never creates a named netns - normalize multipart replies, validate keys and de-dup peers - always close the netlink client, including error paths - test thread offload, netns forwarding and client cleanup Prompt-IO: ai/prompt-io/opencode/20260821T233204Z_5d92595f_prompt_io.md (this patch was generated in some part by `opencode` using `gpt-5.6-sol` (`openai`)) --- .../20260821T233204Z_5d92595f_prompt_io.md | 49 ++++ ...20260821T233204Z_5d92595f_prompt_io.raw.md | 31 +++ pyproject.toml | 11 +- tests/discovery/test_wg_inspection.py | 217 ++++++++++++++++++ tractor/discovery/__init__.py | 2 + tractor/discovery/_tunnel.py | 142 ++++++++++++ uv.lock | 16 ++ 7 files changed, 465 insertions(+), 3 deletions(-) create mode 100644 ai/prompt-io/opencode/20260821T233204Z_5d92595f_prompt_io.md create mode 100644 ai/prompt-io/opencode/20260821T233204Z_5d92595f_prompt_io.raw.md create mode 100644 tests/discovery/test_wg_inspection.py diff --git a/ai/prompt-io/opencode/20260821T233204Z_5d92595f_prompt_io.md b/ai/prompt-io/opencode/20260821T233204Z_5d92595f_prompt_io.md new file mode 100644 index 00000000..50692783 --- /dev/null +++ b/ai/prompt-io/opencode/20260821T233204Z_5d92595f_prompt_io.md @@ -0,0 +1,49 @@ +--- +model: gpt-5.6-sol +service: opencode +session: tractor-addr-unpacking +timestamp: 2026-08-21T23:32:04Z +git_ref: 5d92595f +scope: code +substantive: true +raw_file: 20260821T233204Z_5d92595f_prompt_io.raw.md +--- + +## Prompt + +The human asked to proceed with WireGuard Layer B from PR #505's +head, using the established one-change-per-turn workflow and +finishing with a commit plan. This turn was limited to read-only +pyroute2 inspection; peer verification and bindspace provisioning +remain later changes. + +During review, the human required the generated Python to follow the +deployed `/py-codestyle` rules and asked for the medium-term plan to +remove pyroute2's asyncio I/O runtime from the Trio read path. + +## Response summary + +Added Linux-only read helpers for WireGuard device and peer public +keys. Pyroute2's synchronous API is fully contained in a Trio worker +thread, supports named netns reads without creation side effects, +validates decoded keys and always closes the netlink client. +Follow-up edits added the required local annotations, boolean layout, +helper docstrings and 69-column source formatting. + +## Files changed + +- `pyproject.toml` - Linux-only `wg` optional dependency. +- `uv.lock` - resolved pyroute2 0.9.6 metadata. +- `tractor/discovery/_tunnel.py` - read-only WireGuard helpers. +- `tractor/discovery/__init__.py` - public helper exports. +- `tests/discovery/test_wg_inspection.py` - fake-backed netlink and + worker-thread regressions. + +## Human edits + +The human chose the exact #505 head as a stacked base, required +incremental changes ending in commit plans, and limited this turn to +read-only pyroute2 inspection. The human then identified that Ruff +success had not established `/py-codestyle` compliance and directed +the agent to correct the Python-specific annotation, documentation +and layout rules. No direct manual source edits were observed. diff --git a/ai/prompt-io/opencode/20260821T233204Z_5d92595f_prompt_io.raw.md b/ai/prompt-io/opencode/20260821T233204Z_5d92595f_prompt_io.raw.md new file mode 100644 index 00000000..1bb8b642 --- /dev/null +++ b/ai/prompt-io/opencode/20260821T233204Z_5d92595f_prompt_io.raw.md @@ -0,0 +1,31 @@ +--- +model: gpt-5.6-sol +service: opencode +timestamp: 2026-08-21T23:32:04Z +git_ref: 5d92595f +diff_cmd: git diff HEAD~1..HEAD +--- + +# Raw output - read WireGuard state through pyroute2 + +The human authorized the first incremental WireGuard Layer B change +as a stacked branch from tractor PR #505, with one atomic change and +a commit plan at the end of the turn. + +> `git diff HEAD~1..HEAD -- pyproject.toml uv.lock tractor/discovery/__init__.py tractor/discovery/_tunnel.py tests/discovery/test_wg_inspection.py` + +Added a Linux-only `wg` extra using pyroute2 0.9.6, plus +asynchronous public helpers for reading one interface's public key +and peer keys. The complete synchronous open/read/parse/close +sequence runs in a Trio worker thread because pyroute2 owns a private +asyncio loop internally. + +Named namespace reads pass `flags=0` to override pyroute2's `O_CREAT` +default, ensuring inspection cannot create a missing namespace. Fake +netlink messages cover multipart dumps, key validation, stable peer +deduplication, worker-thread execution, netns selection and cleanup +on success/error. + +Ruff and lock checks passed. Focused tunnel/multiaddr coverage passed +47 tests; the complete discovery suite passed 88 tests with 2 +xpasses. diff --git a/pyproject.toml b/pyproject.toml index 01c667cd..f579dfcf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -62,6 +62,12 @@ dependencies = [ "setproctitle>=1.3,<2", ] +[project.optional-dependencies] +wg = [ + # read/provision Linux WireGuard state through netlink + "pyroute2>=0.9.6,<0.10 ; sys_platform == 'linux'", +] + # ------ project ------ [dependency-groups] @@ -165,9 +171,8 @@ sync_pause = {requires-python = ">=3.13, <3.14"} # editable = true # ------ tool.uv.sources ------ -# TODO, distributed (multi-host) extensions -# linux kernel networking -# 'pyroute2 +# Linux kernel networking is provided by the optional `wg` extra. +# Add any temporary `pyroute2` source overrides here. # ------ tool.uv.sources ------ diff --git a/tests/discovery/test_wg_inspection.py b/tests/discovery/test_wg_inspection.py new file mode 100644 index 00000000..78690527 --- /dev/null +++ b/tests/discovery/test_wg_inspection.py @@ -0,0 +1,217 @@ +''' +Read-only WireGuard netlink inspection tests. + +''' +from __future__ import annotations + +import threading +from typing import ( + Any, + NoReturn, +) + +import pytest +import trio + +from tractor.discovery import ( + read_wg_peers, + read_wg_pubkey, +) + +pyroute2: Any = pytest.importorskip('pyroute2') + + +_PUBKEY: str = 'g3x7z0AdV1rM6UQU22CC7IL3/ivn4DzrE7ikDhCZ/Dc=' +_PEER_1: str = '7PClzcj8o1yAjyPJb0zL2Gt0s2J7yZ6c0JXYqNBGr0E=' +_PEER_2: str = 'H7bJbl1bpY7VzDlB5wI3KjA7JsiYoMWGDJd8dYgc5iw=' + + +class Attrs: + ''' + Minimal pyroute2 netlink-attribute message fake. + + ''' + def __init__( + self, + **attrs: Any, + ) -> None: + ''' + Store attributes for `.get_attr()` lookups. + + ''' + self._attrs: dict[str, Any] = attrs + + def get_attr( + self, + name: str, + ) -> Any: + ''' + Return the named fake netlink attribute. + + ''' + return self._attrs.get(name) + + +def test_read_wg_keys_in_worker_thread( + monkeypatch: pytest.MonkeyPatch, +) -> None: + ''' + Pyroute2's synchronous `WireGuard` API owns a private asyncio + loop. Running it in the Trio thread would either block Trio or + introduce that foreign loop into the actor runtime. + + Replace `pyroute2.WireGuard` with a fake which records thread, + iface, netns and close state. Return a multipart dump containing + duplicate peers, then prove both public helpers execute + off-thread, preserve named-netns selection, validate keys, + deduplicate peers in kernel order and close every netlink client. + + ''' + trio_thread: int = threading.get_ident() + + class FakeWireGuard: + ''' + Record each read-only `pyroute2.WireGuard` interaction. + + ''' + def __init__( + self, + *, + netns: str|None, + flags: int, + ) -> None: + ''' + Record namespace selection without opening netlink. + + ''' + self.netns = netns + self.flags = flags + self.closed = False + self.thread_id: int|None = None + self.iface: str|None = None + instances.append(self) + + def info( + self, + iface: str, + ) -> tuple[Attrs, Attrs]: + ''' + Return a multipart WireGuard device dump. + + ''' + self.thread_id = threading.get_ident() + self.iface = iface + peer_1: Attrs = Attrs( + WGPEER_A_PUBLIC_KEY=_PEER_1.encode(), + ) + peer_2: Attrs = Attrs( + WGPEER_A_PUBLIC_KEY=_PEER_2.encode(), + ) + return ( + Attrs( + WGDEVICE_A_PUBLIC_KEY=_PUBKEY.encode(), + WGDEVICE_A_PEERS=[peer_1], + ), + Attrs( + WGDEVICE_A_PUBLIC_KEY=_PUBKEY.encode(), + WGDEVICE_A_PEERS=[peer_2, peer_1], + ), + ) + + def close(self) -> None: + ''' + Record netlink-client cleanup. + + ''' + self.closed = True + + instances: list[FakeWireGuard] = [] + monkeypatch.setattr( + pyroute2, + 'WireGuard', + FakeWireGuard, + ) + + async def main() -> None: + ''' + Read both key views from Trio's run thread. + + ''' + assert await read_wg_pubkey( + iface='wg-test', + netns='actor-net', + ) == _PUBKEY + assert await read_wg_peers( + iface='wg-test', + netns='actor-net', + ) == (_PEER_1, _PEER_2) + + trio.run(main) + + assert len(instances) == 2 + instance: FakeWireGuard + for instance in instances: + assert instance.netns == 'actor-net' + assert instance.flags == 0 + assert instance.iface == 'wg-test' + assert instance.thread_id != trio_thread + assert instance.closed + + +def test_wg_client_closes_when_read_fails( + monkeypatch: pytest.MonkeyPatch, +) -> None: + ''' + A failed netlink read must not leak pyroute2's socket or private + event loop. Raise from the fake `.info()` call and prove the same + error reaches the Trio caller only after `.close()` runs. + + ''' + class FakeWireGuard: + ''' + Raise during device inspection and record cleanup. + + ''' + def __init__( + self, + *, + netns: str|None, + flags: int, + ) -> None: + ''' + Publish this fake instance for the cleanup assertion. + + ''' + nonlocal instance + self.closed = False + instance = self + + def info(self, iface: str) -> NoReturn: + ''' + Simulate a failing netlink device read. + + ''' + raise OSError('netlink read failed') + + def close(self) -> None: + ''' + Record cleanup after the failed read. + + ''' + self.closed = True + + instance: FakeWireGuard|None = None + monkeypatch.setattr( + pyroute2, + 'WireGuard', + FakeWireGuard, + ) + + with pytest.raises( + OSError, + match='netlink read failed', + ): + trio.run(read_wg_pubkey) + + assert instance is not None + assert instance.closed diff --git a/tractor/discovery/__init__.py b/tractor/discovery/__init__.py index 4720765c..c5089561 100644 --- a/tractor/discovery/__init__.py +++ b/tractor/discovery/__init__.py @@ -36,6 +36,8 @@ from ._tunnel import ( mb_pubkey as mb_pubkey, mk_wg_maddr as mk_wg_maddr, parse_wg_maddr as parse_wg_maddr, + read_wg_peers as read_wg_peers, + read_wg_pubkey as read_wg_pubkey, strip_tunnels as strip_tunnels, tunnels_of as tunnels_of, wg8_pubkey as wg8_pubkey, diff --git a/tractor/discovery/_tunnel.py b/tractor/discovery/_tunnel.py index a3c80be7..13749bc2 100644 --- a/tractor/discovery/_tunnel.py +++ b/tractor/discovery/_tunnel.py @@ -69,7 +69,9 @@ Unwrap at the parse or bindspace boundary; see `.overlay` and from __future__ import annotations import base64 import ipaddress +import sys from typing import ( + Any, ClassVar, TYPE_CHECKING, ) @@ -79,6 +81,7 @@ import multibase from multiaddr import Multiaddr from multiaddr.exceptions import ProtocolNotFoundError from multiaddr.protocols import protocol_with_name +import trio if TYPE_CHECKING: from ._addr import ( @@ -170,6 +173,145 @@ def wg8_pubkey( return base64.b64encode(raw).decode('ascii') +def _wg8_key_str( + value: bytes|str, +) -> str: + ''' + Validate and normalize one pyroute2-decoded WireGuard key. + + ''' + if isinstance(value, bytes): + try: + key: str = value.decode('ascii') + except UnicodeDecodeError as exc: + raise ValueError( + 'WireGuard key is not base64 ASCII!' + ) from exc + else: + key = value + + # Reuse `mb_pubkey()`'s strict base64 + 32-byte validation. + mb_pubkey(key) + return key + + +def _read_wg_keys( + iface: str, + netns: str|None, +) -> tuple[str, tuple[str, ...]]: + ''' + Read one WireGuard device using pyroute2's synchronous API. + + This whole function runs in a worker thread because pyroute2's + synchronous netlink API owns a private asyncio loop. + + ''' + if sys.platform != 'linux': + raise NotImplementedError( + 'WireGuard netlink inspection is Linux-only!' + ) + + try: + from pyroute2 import WireGuard + except ImportError as exc: + raise RuntimeError( + 'WireGuard inspection requires the `tractor[wg]` extra.' + ) from exc + + # Pyroute2 defaults namespace flags to `os.O_CREAT`; a read must + # never create a missing namespace as a side effect. + wg: Any = WireGuard( + netns=netns, + flags=0, + ) + try: + infos: tuple[Any, ...] = tuple(wg.info(iface)) + finally: + wg.close() + + pubkey: str|None = None + peers: list[str] = [] + info: Any + for info in infos: + raw_pubkey: Any + if raw_pubkey := info.get_attr( + 'WGDEVICE_A_PUBLIC_KEY' + ): + next_pubkey: str = _wg8_key_str(raw_pubkey) + if ( + pubkey is not None + and + pubkey != next_pubkey + ): + raise RuntimeError( + f'Conflicting public keys returned for ' + f'{iface!r}!' + ) + pubkey = next_pubkey + + peer: Any + for peer in ( + info.get_attr('WGDEVICE_A_PEERS') + or () + ): + raw_peer: Any + if raw_peer := peer.get_attr( + 'WGPEER_A_PUBLIC_KEY' + ): + peers.append(_wg8_key_str(raw_peer)) + + if pubkey is None: + raise RuntimeError( + f'No public key returned for WireGuard iface ' + f'{iface!r}!' + ) + + return ( + pubkey, + tuple(dict.fromkeys(peers)), + ) + + +async def read_wg_pubkey( + iface: str = 'wg0', + netns: str|None = None, +) -> str: + ''' + Read a WireGuard interface's public key through netlink. + + ''' + keys: tuple[ + str, + tuple[str, ...], + ] = await trio.to_thread.run_sync( + _read_wg_keys, + iface, + netns, + abandon_on_cancel=False, + ) + return keys[0] + + +async def read_wg_peers( + iface: str = 'wg0', + netns: str|None = None, +) -> tuple[str, ...]: + ''' + Read configured peer public keys through netlink. + + ''' + keys: tuple[ + str, + tuple[str, ...], + ] = await trio.to_thread.run_sync( + _read_wg_keys, + iface, + netns, + abandon_on_cancel=False, + ) + return keys[1] + + def _wg_proto_code() -> int: ''' Deliver the installed `py-multiaddr` `/wg/` protocol code. diff --git a/uv.lock b/uv.lock index 75f31992..1a4c59fb 100644 --- a/uv.lock +++ b/uv.lock @@ -762,6 +762,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5a/dc/491b7661614ab97483abf2056be1deee4dc2490ecbf7bff9ab5cdbac86e1/pyreadline3-3.5.4-py3-none-any.whl", hash = "sha256:eaf8e6cc3c49bcccf145fc6067ba8643d1df34d604a1ec0eccbf7a18e6d3fae6", size = 83178, upload-time = "2024-09-19T02:40:08.598Z" }, ] +[[package]] +name = "pyroute2" +version = "0.9.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9b/3c/cae3aa8a07522d4fd625958f690ab6eb4ffbd9c94e30e2995f585fded630/pyroute2-0.9.6.tar.gz", hash = "sha256:6bc5e2ea9a372ded682b4ede4028ba00236bd6e35b42d833f39a96b219ef1db2", size = 478486, upload-time = "2026-04-15T18:26:07.408Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/f5/77292e847cb2bcd94f0e7be214ad09972de5db6a6914e47117293ed0f4a8/pyroute2-0.9.6-py3-none-any.whl", hash = "sha256:3334091326e560a506635449af03b26920d22d4e5a7996aed354363d106fcef8", size = 483440, upload-time = "2026-04-15T18:26:03.14Z" }, +] + [[package]] name = "pytest" version = "9.1.0" @@ -1120,6 +1129,11 @@ dependencies = [ { name = "wrapt" }, ] +[package.optional-dependencies] +wg = [ + { name = "pyroute2", marker = "sys_platform == 'linux'" }, +] + [package.dev-dependencies] dev = [ { name = "greenback", marker = "python_full_version < '3.14'" }, @@ -1179,11 +1193,13 @@ requires-dist = [ { name = "pdbp", specifier = ">=1.8.2,<2" }, { name = "platformdirs", specifier = ">=4.4.0" }, { name = "py-multibase", specifier = ">=2.0.0,<3" }, + { name = "pyroute2", marker = "sys_platform == 'linux' and extra == 'wg'", specifier = ">=0.9.6,<0.10" }, { name = "setproctitle", specifier = ">=1.3,<2" }, { name = "tricycle", specifier = ">=0.4.1,<0.5" }, { name = "trio", specifier = ">0.27" }, { name = "wrapt", specifier = ">=1.16.0,<2" }, ] +provides-extras = ["wg"] [package.metadata.requires-dev] dev = [