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`))
wkt/wg_pyroute2_read
Gud Boi 2026-08-21 22:21:22 -04:00
parent 51f4e31ce0
commit 0a0aa64305
7 changed files with 464 additions and 3 deletions

View File

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

View File

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

View File

@ -62,6 +62,12 @@ dependencies = [
"setproctitle>=1.3,<2", "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 ------ # ------ project ------
[dependency-groups] [dependency-groups]
@ -168,9 +174,8 @@ sync_pause = {requires-python = ">=3.13, <3.14"}
# editable = true # editable = true
# ------ tool.uv.sources ------ # ------ tool.uv.sources ------
# TODO, distributed (multi-host) extensions # Linux kernel networking is provided by the optional `wg` extra.
# linux kernel networking # Add any temporary `pyroute2` source overrides here.
# 'pyroute2
# ------ tool.uv.sources ------ # ------ tool.uv.sources ------

View File

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

View File

@ -36,6 +36,8 @@ from ._tunnel import (
mb_pubkey as mb_pubkey, mb_pubkey as mb_pubkey,
mk_wg_maddr as mk_wg_maddr, mk_wg_maddr as mk_wg_maddr,
parse_wg_maddr as parse_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, strip_tunnels as strip_tunnels,
tunnels_of as tunnels_of, tunnels_of as tunnels_of,
wg8_pubkey as wg8_pubkey, wg8_pubkey as wg8_pubkey,

View File

@ -69,6 +69,7 @@ Unwrap at the parse or bindspace boundary; see `.overlay` and
from __future__ import annotations from __future__ import annotations
import base64 import base64
import ipaddress import ipaddress
import sys
from typing import ( from typing import (
Any, Any,
ClassVar, ClassVar,
@ -77,6 +78,7 @@ from typing import (
import msgspec import msgspec
import multibase import multibase
import trio
if TYPE_CHECKING: if TYPE_CHECKING:
from multiaddr import Multiaddr from multiaddr import Multiaddr
@ -174,6 +176,145 @@ def wg8_pubkey(
return base64.b64encode(raw).decode('ascii') 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: def _wg_proto_code() -> int:
''' '''
Deliver the installed `py-multiaddr` `/wg/` protocol code. Deliver the installed `py-multiaddr` `/wg/` protocol code.

16
uv.lock
View File

@ -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" }, { 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]] [[package]]
name = "pytest" name = "pytest"
version = "9.1.0" version = "9.1.0"
@ -1133,6 +1142,11 @@ dependencies = [
{ name = "wrapt" }, { name = "wrapt" },
] ]
[package.optional-dependencies]
wg = [
{ name = "pyroute2", marker = "sys_platform == 'linux'" },
]
[package.dev-dependencies] [package.dev-dependencies]
dev = [ dev = [
{ name = "greenback", marker = "python_full_version < '3.14'" }, { name = "greenback", marker = "python_full_version < '3.14'" },
@ -1194,11 +1208,13 @@ requires-dist = [
{ name = "pdbp", specifier = ">=1.8.2,<2" }, { name = "pdbp", specifier = ">=1.8.2,<2" },
{ name = "platformdirs", specifier = ">=4.4.0" }, { name = "platformdirs", specifier = ">=4.4.0" },
{ name = "py-multibase", specifier = ">=2.0.0,<3" }, { 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 = "setproctitle", specifier = ">=1.3,<2" },
{ name = "tricycle", specifier = ">=0.4.1,<0.5" }, { name = "tricycle", specifier = ">=0.4.1,<0.5" },
{ name = "trio", specifier = ">0.27" }, { name = "trio", specifier = ">0.27" },
{ name = "wrapt", specifier = ">=1.16.0,<2" }, { name = "wrapt", specifier = ">=1.16.0,<2" },
] ]
provides-extras = ["wg"]
[package.metadata.requires-dev] [package.metadata.requires-dev]
dev = [ dev = [