Own WireGuard interface lifecycles

Add `open_wg_iface()` to create, configure and remove one WireGuard
interface inside a pinned bindspace through pyroute2.

Deats,
- validate listen/dial bearer policy before kernel side effects
- configure local addresses, private key, listen port and peers
- fill an omitted dial endpoint from the selected tunnel bearer
- clean partial synchronous failures before returning to Trio
- shield owned interface creation and teardown from cancellation

Prompt-IO: ai/prompt-io/opencode/20260826T003430Z_6dd39da0_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-25 22:16:15 -04:00
parent 1fd857ce21
commit f7bf068d09
6 changed files with 597 additions and 3 deletions

View File

@ -0,0 +1,42 @@
---
model: gpt-5.6-sol
service: opencode
session: tractor-addr-unpacking
timestamp: 2026-08-26T00:34:30Z
git_ref: 6dd39da0
scope: code
substantive: true
raw_file: 20260826T003430Z_6dd39da0_prompt_io.raw.md
---
## Prompt
After committing explicit WireGuard peer configuration, the human
authorized the next incremental lifecycle change.
## Response summary
Added owned WireGuard interface provisioning and teardown through
cancellation-shielded pyroute2 worker calls. Role-specific policy is
validated before mutation, local addresses and explicit peers are
configured, and owned interfaces are removed on exit.
Staged review clarified role-specific test assertions, added the
second peer's endpoint-port check, included conflicting values in
policy errors and aligned annotated assignment with `/py-codestyle`.
## Files changed
- `tractor/discovery/_tunnel.py` - role policy and WG iface lifecycle.
- `tractor/discovery/__init__.py` - public lifecycle and role exports.
- `tests/discovery/test_wg_iface_lifecycle.py` - policy and cancellation
coverage with privileged calls faked.
- `ai/tpt-backends/03_wg_tunnel_bindspace.md` - initial owned WG iface
lifecycle contract.
## Human edits
The human selected continuation from explicit peer modeling into the
owned WireGuard interface lifecycle. During staged review, the human
requested clearer role assertions, complete endpoint checks, concrete
error values and inline annotated assignment. The agent applied those
human-directed edits; no direct manual source edits were observed.

View File

@ -0,0 +1,29 @@
---
model: gpt-5.6-sol
service: opencode
timestamp: 2026-08-26T00:34:30Z
git_ref: 6dd39da0
diff_cmd: git diff HEAD~1..HEAD
---
# Raw output - own WireGuard interface lifecycle
After committing explicit peer configuration, the human authorized the
next incremental lifecycle change.
> `git diff HEAD~1..HEAD -- tractor/discovery/_tunnel.py tractor/discovery/__init__.py tests/discovery/test_wg_iface_lifecycle.py ai/tpt-backends/03_wg_tunnel_bindspace.md`
Added owned async `open_wg_iface()` lifecycle with role-policy
validation before side effects. Listen bearer ports configure the local
listener; dial bearers fill omitted endpoints only for the selected
maddr peer.
Pyroute2 worker code creates the WireGuard link, assigns local
addresses, configures private key/listen port and every explicit peer,
raises the link, and removes it on exit. Partial create failures clean
up synchronously; async create/remove calls are cancellation-shielded.
Privileged operations are faked in tests. Ruff and lock checks passed;
focused WG lifecycle/config/inspection coverage passed 22 tests. A
broad unrelated registrar cancellation parameter timed out after 76
passes and 2 xpasses; its isolated rerun passed.

View File

@ -522,6 +522,14 @@ keepalive. Reprs redact private/preshared keys. `WGTunnelSpec` remains
serializable public maddr-derived identity/endpoint data. This split serializable public maddr-derived identity/endpoint data. This split
supports multi-peer listeners without overloading the tunnel maddr. supports multi-peer listeners without overloading the tunnel maddr.
The initial `open_wg_iface()` lifecycle is owned and Linux-only. It
validates role-dependent bearer policy before side effects, creates the
iface and addresses through `IPRoute`, configures keys/peers through
`WireGuard`, raises the link, and removes it on every post-creation
exit. Creation/removal run in shielded Trio worker calls. A listen
bearer supplies the local listen port; a dial bearer supplies an
omitted endpoint only for the selected maddr peer.
and a driver that folds a list of specs into nested contexts and a driver that folds a list of specs into nested contexts
(`contextlib.AsyncExitStack` for the N-deep case). The (`contextlib.AsyncExitStack` for the N-deep case). The
`parse_endpoints()` API (`_multiaddr.py:153`) is the front door: `parse_endpoints()` API (`_multiaddr.py:153`) is the front door:

View File

@ -0,0 +1,239 @@
'''
WireGuard interface policy and owned lifecycle contracts.
'''
from __future__ import annotations
import os
from pathlib import Path
from typing import BinaryIO
import pytest
import trio
from tractor.discovery import (
BindspaceHandle,
BindspaceIdentity,
BindspaceSpec,
WGInterfaceConfig,
WGPeerConfig,
WGTunnelSpec,
open_wg_iface,
)
from tractor.discovery import _tunnel
_LOCAL_KEY: str = 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA='
_PEER_KEY: str = 'r1LKM1pqhuY9Z6L4y5jQ2fGX67kJSrq5kRV5Jk2ywEo='
def test_wg_iface_settings_follow_role() -> None:
'''
Listen and dial roles interpret the tunnel bearer differently.
Prove listen derives its local port from the bearer while dial
applies the bearer as the selected peer's omitted endpoint. Other
explicit peers retain their own endpoint and routing policy.
'''
selected: WGPeerConfig = WGPeerConfig(
public_key=_PEER_KEY,
allowed_ips=('10.1.0.0/16',),
)
other: WGPeerConfig = WGPeerConfig(
public_key=_LOCAL_KEY,
endpoint=('198.51.100.2', 51821),
)
config: WGInterfaceConfig = WGInterfaceConfig(
private_key=_LOCAL_KEY,
peers=(selected, other),
)
spec: WGTunnelSpec = WGTunnelSpec(
peer_pubkey=_PEER_KEY,
bearer=('192.0.2.1', 51820),
)
listen_port: int|None
listen_peers: tuple[dict[str, object], ...]
listen_port, listen_peers = _tunnel._wg_iface_settings(
spec,
config,
'listen',
)
assert listen_port == 51820
# A listen bearer configures the local port, not a peer endpoint.
assert 'endpoint_addr' not in listen_peers[0]
dial_port: int|None
dial_peers: tuple[dict[str, object], ...]
dial_port, dial_peers = _tunnel._wg_iface_settings(
spec,
config,
'dial',
)
# No local dial listen port was declared; the bearer is remote.
assert dial_port is None
assert dial_peers[0]['endpoint_addr'] == '192.0.2.1'
assert dial_peers[0]['endpoint_port'] == 51820
assert dial_peers[0]['allowed_ips'] == ['10.1.0.0/16']
assert dial_peers[1]['endpoint_addr'] == '198.51.100.2'
assert dial_peers[1]['endpoint_port'] == 51821
@pytest.mark.parametrize(
('config', 'role', 'match'),
(
pytest.param(
WGInterfaceConfig(
private_key=_LOCAL_KEY,
),
'dial',
'not in.*configured peer keys',
id='missing-dial-peer',
),
pytest.param(
WGInterfaceConfig(
private_key=_LOCAL_KEY,
listen_port=51821,
),
'listen',
'51821.*51820',
id='listen-port-51821-vs-bearer-51820',
),
pytest.param(
WGInterfaceConfig(
private_key=_LOCAL_KEY,
peers=(
WGPeerConfig(
public_key=_PEER_KEY,
endpoint=('198.51.100.1', 51820),
),
),
),
'dial',
'198.51.100.1.*192.0.2.1',
id='dial-endpoint-conflict',
),
),
)
def test_wg_iface_settings_reject_conflicts(
config: WGInterfaceConfig,
role: str,
match: str,
) -> None:
'''
Role-dependent conflicts must fail before pyroute2 side effects.
'''
spec: WGTunnelSpec = WGTunnelSpec(
peer_pubkey=_PEER_KEY,
bearer=('192.0.2.1', 51820),
)
with pytest.raises(ValueError, match=match):
_tunnel._wg_iface_settings(
spec,
config,
role, # type: ignore[arg-type]
)
def test_open_wg_iface_shields_cancelled_cleanup(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
'''
Cancellation after creation must still remove the owned WG iface.
Pin a stand-in namespace FD and fake privileged create/remove
calls. Cancel inside the yielded context and prove shielded
teardown runs before cancellation leaves the enclosing scope.
'''
token_path: Path = tmp_path / 'netns'
token_path.touch()
events: list[str] = []
def create(
spec: WGTunnelSpec,
config: WGInterfaceConfig,
bindspace: BindspaceHandle,
listen_port: int|None,
peers: tuple[dict[str, object], ...],
) -> None:
'''
Record the validated create request.
'''
assert bindspace.namespace_fd is not None
assert listen_port is None
assert peers[0]['public_key'] == _PEER_KEY
events.append('create')
def remove(
spec: WGTunnelSpec,
bindspace: BindspaceHandle,
) -> None:
'''
Record shielded removal after cancellation.
'''
events.append('remove')
monkeypatch.setattr(
_tunnel,
'_sync_create_wg_iface',
create,
)
monkeypatch.setattr(
_tunnel,
'_sync_remove_wg_iface',
remove,
)
namespace_file: BinaryIO
with token_path.open('rb') as namespace_file:
namespace_fd: int = namespace_file.fileno()
bindspace_spec: BindspaceSpec = BindspaceSpec(
kind='netns',
key='tractor-wg0',
)
bindspace: BindspaceHandle = BindspaceHandle(
spec=bindspace_spec,
identity=BindspaceIdentity(
kind='netns',
key='tractor-wg0',
inode=os.fstat(namespace_fd).st_ino,
),
namespace_fd=namespace_fd,
ownership='borrowed',
)
peer: WGPeerConfig = WGPeerConfig(
public_key=_PEER_KEY,
)
config: WGInterfaceConfig = WGInterfaceConfig(
private_key=_LOCAL_KEY,
peers=(peer,),
)
spec: WGTunnelSpec = WGTunnelSpec(
peer_pubkey=_PEER_KEY,
)
async def main() -> None:
'''
Cancel while the fake WG iface is owned.
'''
with trio.CancelScope() as scope:
async with open_wg_iface(
spec,
config,
bindspace,
'dial',
):
events.append('yield')
scope.cancel()
await trio.sleep_forever()
trio.run(main)
assert events == ['create', 'yield', 'remove']

View File

@ -47,8 +47,10 @@ from ._tunnel import (
WGTunnelSpec as WGTunnelSpec, WGTunnelSpec as WGTunnelSpec,
WGInterfaceConfig as WGInterfaceConfig, WGInterfaceConfig as WGInterfaceConfig,
WGPeerConfig as WGPeerConfig, WGPeerConfig as WGPeerConfig,
WGRole as WGRole,
mb_pubkey as mb_pubkey, mb_pubkey as mb_pubkey,
mk_wg_maddr as mk_wg_maddr, mk_wg_maddr as mk_wg_maddr,
open_wg_iface as open_wg_iface,
parse_wg_maddr as parse_wg_maddr, parse_wg_maddr as parse_wg_maddr,
read_wg_peers as read_wg_peers, read_wg_peers as read_wg_peers,
read_wg_pubkey as read_wg_pubkey, read_wg_pubkey as read_wg_pubkey,

View File

@ -67,12 +67,16 @@ Unwrap at the parse or bindspace boundary; see `.overlay` and
''' '''
from __future__ import annotations from __future__ import annotations
from collections.abc import AsyncIterator
import base64 import base64
from contextlib import asynccontextmanager as acm
import ipaddress import ipaddress
import sys import sys
from typing import ( from typing import (
Any, Any,
ClassVar, ClassVar,
get_args,
Literal,
TYPE_CHECKING, TYPE_CHECKING,
) )
@ -89,8 +93,10 @@ if TYPE_CHECKING:
Address, Address,
UnwrappedAddress, UnwrappedAddress,
) )
from ._bindspace import BindspaceHandle
else: else:
Address = Any Address = Any
BindspaceHandle = Any
Multiaddr = Any Multiaddr = Any
UnwrappedAddress = Any UnwrappedAddress = Any
@ -230,6 +236,8 @@ class WGPeerConfig(
endpoint: tuple[str, int]|None = self.endpoint endpoint: tuple[str, int]|None = self.endpoint
if endpoint is not None: if endpoint is not None:
host: str
port: int
host, port = endpoint host, port = endpoint
ipaddress.ip_address(host) ipaddress.ip_address(host)
if ( if (
@ -239,7 +247,7 @@ class WGPeerConfig(
): ):
raise ValueError( raise ValueError(
'`WGPeerConfig.endpoint` port must be in ' '`WGPeerConfig.endpoint` port must be in '
'`1..65535`!' f'`1..65535`, not {port!r}!'
) )
keepalive: int|None = self.persistent_keepalive keepalive: int|None = self.persistent_keepalive
@ -254,7 +262,7 @@ class WGPeerConfig(
): ):
raise ValueError( raise ValueError(
'`WGPeerConfig.persistent_keepalive` must be in ' '`WGPeerConfig.persistent_keepalive` must be in '
'`0..65535` or `None`!' f'`0..65535` or `None`, not {keepalive!r}!'
) )
def __repr__(self) -> str: def __repr__(self) -> str:
@ -317,7 +325,7 @@ class WGInterfaceConfig(
): ):
raise ValueError( raise ValueError(
'`WGInterfaceConfig.listen_port` must be in ' '`WGInterfaceConfig.listen_port` must be in '
'`1..65535` or `None`!' f'`1..65535` or `None`, not {listen_port!r}!'
) )
peer_keys: set[str] = set() peer_keys: set[str] = set()
@ -348,6 +356,272 @@ class WGInterfaceConfig(
) )
WGRole = Literal['listen', 'dial']
def _wg_iface_settings(
spec: WGTunnelSpec,
config: WGInterfaceConfig,
role: WGRole,
) -> tuple[int|None, tuple[dict[str, object], ...]]:
'''
Validate role policy and build pyroute2 WireGuard settings.
'''
if role not in get_args(WGRole):
raise ValueError(
f'Unsupported WireGuard role: {role!r}'
)
listen_port: int|None = config.listen_port
bearer: tuple[str, int]|None = spec.bearer
if (
role == 'listen'
and
bearer is not None
):
bearer_port: int = bearer[1]
if (
listen_port is not None
and
listen_port != bearer_port
):
raise ValueError(
f'`WGInterfaceConfig.listen_port={listen_port!r}` '
f'conflicts with bearer port {bearer_port!r}!'
)
listen_port = bearer_port
selected_peer: bool = False
peer_settings: list[dict[str, object]] = []
peer: WGPeerConfig
for peer in config.peers:
endpoint: tuple[str, int]|None = peer.endpoint
if (
role == 'dial'
and
peer.public_key == spec.peer_pubkey
):
selected_peer = True
if (
endpoint is not None
and
bearer is not None
and
endpoint != bearer
):
raise ValueError(
f'`WGPeerConfig.endpoint={endpoint!r}` conflicts '
f'with `WGTunnelSpec.bearer={bearer!r}`!'
)
endpoint = endpoint or bearer
values: dict[str, object] = {
'public_key': peer.public_key,
}
if peer.allowed_ips:
values['allowed_ips'] = list(peer.allowed_ips)
values['replace_allowed_ips'] = True
if endpoint is not None:
values['endpoint_addr'] = endpoint[0]
values['endpoint_port'] = endpoint[1]
if peer.preshared_key is not None:
values['preshared_key'] = peer.preshared_key
if peer.persistent_keepalive is not None:
values['persistent_keepalive'] = (
peer.persistent_keepalive
)
peer_settings.append(values)
if (
role == 'dial'
and
not selected_peer
):
configured_keys: tuple[str, ...] = tuple(
peer.public_key
for peer in config.peers
)
raise ValueError(
f'Dial target {spec.peer_pubkey!r} is not in '
f'configured peer keys {configured_keys!r}!'
)
return listen_port, tuple(peer_settings)
def _sync_create_wg_iface(
spec: WGTunnelSpec,
config: WGInterfaceConfig,
bindspace: BindspaceHandle,
listen_port: int|None,
peers: tuple[dict[str, object], ...],
) -> None:
'''
Create and configure one WireGuard iface through pyroute2.
'''
try:
from pyroute2 import (
IPRoute,
WireGuard,
)
except ImportError as exc:
raise RuntimeError(
'WireGuard provisioning requires the '
'`tractor[wg]` extra.'
) from exc
namespace_fd: int|None = bindspace.namespace_fd
ipr: Any = IPRoute(
netns=namespace_fd,
flags=0,
)
created: bool = False
try:
ipr.link(
'add',
ifname=spec.iface,
kind='wireguard',
)
created = True
indices: list[int] = ipr.link_lookup(
ifname=spec.iface,
)
if len(indices) != 1:
raise RuntimeError(
f'Expected one index for WG iface {spec.iface!r}, '
f'got {indices!r}!'
)
index: int = indices[0]
address: str
for address in config.addresses:
interface: (
ipaddress.IPv4Interface
| ipaddress.IPv6Interface
) = ipaddress.ip_interface(address)
ipr.addr(
'add',
index=index,
address=str(interface.ip),
prefixlen=interface.network.prefixlen,
)
wg: Any = WireGuard(
netns=namespace_fd,
flags=0,
)
try:
wg.set(
spec.iface,
private_key=config.private_key,
listen_port=listen_port,
)
peer: dict[str, object]
for peer in peers:
wg.set(
spec.iface,
peer=peer,
)
finally:
wg.close()
ipr.link(
'set',
index=index,
state='up',
)
except BaseException:
if created:
indices = ipr.link_lookup(
ifname=spec.iface,
)
if indices:
ipr.link(
'del',
index=indices[0],
)
raise
finally:
ipr.close()
def _sync_remove_wg_iface(
spec: WGTunnelSpec,
bindspace: BindspaceHandle,
) -> None:
'''
Remove one owned WireGuard iface when it still exists.
'''
try:
from pyroute2 import IPRoute
except ImportError as exc:
raise RuntimeError(
'WireGuard teardown requires the `tractor[wg]` extra.'
) from exc
ipr: Any = IPRoute(
netns=bindspace.namespace_fd,
flags=0,
)
try:
indices: list[int] = ipr.link_lookup(
ifname=spec.iface,
)
if indices:
ipr.link(
'del',
index=indices[0],
)
finally:
ipr.close()
@acm
async def open_wg_iface(
spec: WGTunnelSpec,
config: WGInterfaceConfig,
bindspace: BindspaceHandle,
role: WGRole,
) -> AsyncIterator[WGTunnelSpec]:
'''
Create, configure and own one WireGuard interface.
'''
listen_port: int|None
peers: tuple[dict[str, object], ...]
listen_port, peers = _wg_iface_settings(
spec,
config,
role,
)
created: bool = False
try:
with trio.CancelScope(shield=True):
await trio.to_thread.run_sync(
_sync_create_wg_iface,
spec,
config,
bindspace,
listen_port,
peers,
abandon_on_cancel=False,
)
created = True
yield spec
finally:
if created:
with trio.CancelScope(shield=True):
await trio.to_thread.run_sync(
_sync_remove_wg_iface,
spec,
bindspace,
abandon_on_cancel=False,
)
def _sync_read_wg_keys( def _sync_read_wg_keys(
iface: str, iface: str,
netns: str|None, netns: str|None,