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`))
parent
7d0b84e1de
commit
3f78e3de4c
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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]
|
||||
|
|
@ -168,9 +174,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 ------
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -69,6 +69,7 @@ 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,
|
||||
|
|
@ -77,6 +78,7 @@ from typing import (
|
|||
|
||||
import msgspec
|
||||
import multibase
|
||||
import trio
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from multiaddr import Multiaddr
|
||||
|
|
@ -174,6 +176,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.
|
||||
|
|
|
|||
16
uv.lock
16
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"
|
||||
|
|
@ -1133,6 +1142,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'" },
|
||||
|
|
@ -1194,11 +1208,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 = [
|
||||
|
|
|
|||
Loading…
Reference in New Issue