From f7bf068d09ec0cced9e45bbce965b0e4185b5472 Mon Sep 17 00:00:00 2001 From: goodboy Date: Tue, 25 Aug 2026 22:16:15 -0400 Subject: [PATCH] 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`)) --- .../20260826T003430Z_6dd39da0_prompt_io.md | 42 +++ ...20260826T003430Z_6dd39da0_prompt_io.raw.md | 29 ++ ai/tpt-backends/03_wg_tunnel_bindspace.md | 8 + tests/discovery/test_wg_iface_lifecycle.py | 239 +++++++++++++++ tractor/discovery/__init__.py | 2 + tractor/discovery/_tunnel.py | 280 +++++++++++++++++- 6 files changed, 597 insertions(+), 3 deletions(-) create mode 100644 ai/prompt-io/opencode/20260826T003430Z_6dd39da0_prompt_io.md create mode 100644 ai/prompt-io/opencode/20260826T003430Z_6dd39da0_prompt_io.raw.md create mode 100644 tests/discovery/test_wg_iface_lifecycle.py diff --git a/ai/prompt-io/opencode/20260826T003430Z_6dd39da0_prompt_io.md b/ai/prompt-io/opencode/20260826T003430Z_6dd39da0_prompt_io.md new file mode 100644 index 00000000..0c2aeea4 --- /dev/null +++ b/ai/prompt-io/opencode/20260826T003430Z_6dd39da0_prompt_io.md @@ -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. diff --git a/ai/prompt-io/opencode/20260826T003430Z_6dd39da0_prompt_io.raw.md b/ai/prompt-io/opencode/20260826T003430Z_6dd39da0_prompt_io.raw.md new file mode 100644 index 00000000..126646d0 --- /dev/null +++ b/ai/prompt-io/opencode/20260826T003430Z_6dd39da0_prompt_io.raw.md @@ -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. diff --git a/ai/tpt-backends/03_wg_tunnel_bindspace.md b/ai/tpt-backends/03_wg_tunnel_bindspace.md index 2ab6fe05..25f598f0 100644 --- a/ai/tpt-backends/03_wg_tunnel_bindspace.md +++ b/ai/tpt-backends/03_wg_tunnel_bindspace.md @@ -522,6 +522,14 @@ keepalive. Reprs redact private/preshared keys. `WGTunnelSpec` remains serializable public maddr-derived identity/endpoint data. This split 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 (`contextlib.AsyncExitStack` for the N-deep case). The `parse_endpoints()` API (`_multiaddr.py:153`) is the front door: diff --git a/tests/discovery/test_wg_iface_lifecycle.py b/tests/discovery/test_wg_iface_lifecycle.py new file mode 100644 index 00000000..07a39960 --- /dev/null +++ b/tests/discovery/test_wg_iface_lifecycle.py @@ -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'] diff --git a/tractor/discovery/__init__.py b/tractor/discovery/__init__.py index 444cd111..b33ecd7a 100644 --- a/tractor/discovery/__init__.py +++ b/tractor/discovery/__init__.py @@ -47,8 +47,10 @@ from ._tunnel import ( WGTunnelSpec as WGTunnelSpec, WGInterfaceConfig as WGInterfaceConfig, WGPeerConfig as WGPeerConfig, + WGRole as WGRole, mb_pubkey as mb_pubkey, mk_wg_maddr as mk_wg_maddr, + open_wg_iface as open_wg_iface, parse_wg_maddr as parse_wg_maddr, read_wg_peers as read_wg_peers, read_wg_pubkey as read_wg_pubkey, diff --git a/tractor/discovery/_tunnel.py b/tractor/discovery/_tunnel.py index 83592825..d48e5750 100644 --- a/tractor/discovery/_tunnel.py +++ b/tractor/discovery/_tunnel.py @@ -67,12 +67,16 @@ Unwrap at the parse or bindspace boundary; see `.overlay` and ''' from __future__ import annotations +from collections.abc import AsyncIterator import base64 +from contextlib import asynccontextmanager as acm import ipaddress import sys from typing import ( Any, ClassVar, + get_args, + Literal, TYPE_CHECKING, ) @@ -89,8 +93,10 @@ if TYPE_CHECKING: Address, UnwrappedAddress, ) + from ._bindspace import BindspaceHandle else: Address = Any + BindspaceHandle = Any Multiaddr = Any UnwrappedAddress = Any @@ -230,6 +236,8 @@ class WGPeerConfig( endpoint: tuple[str, int]|None = self.endpoint if endpoint is not None: + host: str + port: int host, port = endpoint ipaddress.ip_address(host) if ( @@ -239,7 +247,7 @@ class WGPeerConfig( ): raise ValueError( '`WGPeerConfig.endpoint` port must be in ' - '`1..65535`!' + f'`1..65535`, not {port!r}!' ) keepalive: int|None = self.persistent_keepalive @@ -254,7 +262,7 @@ class WGPeerConfig( ): raise ValueError( '`WGPeerConfig.persistent_keepalive` must be in ' - '`0..65535` or `None`!' + f'`0..65535` or `None`, not {keepalive!r}!' ) def __repr__(self) -> str: @@ -317,7 +325,7 @@ class WGInterfaceConfig( ): raise ValueError( '`WGInterfaceConfig.listen_port` must be in ' - '`1..65535` or `None`!' + f'`1..65535` or `None`, not {listen_port!r}!' ) 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( iface: str, netns: str|None,