From 9dfdd54e0fd94f035898c1a967cc130b52322393 Mon Sep 17 00:00:00 2001 From: goodboy Date: Tue, 25 Aug 2026 20:28:28 -0400 Subject: [PATCH] Model explicit WireGuard peers Add process-local `WGPeerConfig` entries and make `WGInterfaceConfig` own a unique peer tuple for listener and dial provisioning. Deats, - carry peer public keys, allowed CIDRs and optional endpoints - redact per-peer preshared keys while blocking wire encoding - validate peer routes, endpoint ports and keepalive intervals - reject duplicate peers before future kernel mutation - support multi-peer listeners without overloading tunnel identity Prompt-IO: ai/prompt-io/opencode/20260826T001442Z_dcdf4d82_prompt_io.md (this patch was generated in some part by `opencode` using `gpt-5.6-sol` (`openai`)) --- .../20260826T001442Z_dcdf4d82_prompt_io.md | 37 +++++ ...20260826T001442Z_dcdf4d82_prompt_io.raw.md | 27 ++++ ai/tpt-backends/03_wg_tunnel_bindspace.md | 13 +- tests/discovery/test_wg_config.py | 97 ++++++++++--- tractor/discovery/__init__.py | 1 + tractor/discovery/_tunnel.py | 136 +++++++++++++----- 6 files changed, 243 insertions(+), 68 deletions(-) create mode 100644 ai/prompt-io/opencode/20260826T001442Z_dcdf4d82_prompt_io.md create mode 100644 ai/prompt-io/opencode/20260826T001442Z_dcdf4d82_prompt_io.raw.md diff --git a/ai/prompt-io/opencode/20260826T001442Z_dcdf4d82_prompt_io.md b/ai/prompt-io/opencode/20260826T001442Z_dcdf4d82_prompt_io.md new file mode 100644 index 00000000..5ccc3b7e --- /dev/null +++ b/ai/prompt-io/opencode/20260826T001442Z_dcdf4d82_prompt_io.md @@ -0,0 +1,37 @@ +--- +model: gpt-5.6-sol +service: opencode +session: tractor-addr-unpacking +timestamp: 2026-08-26T00:14:42Z +git_ref: dcdf4d82 +scope: code +substantive: true +raw_file: 20260826T001442Z_dcdf4d82_prompt_io.raw.md +--- + +## Prompt + +The human selected an explicit peer-list model rather than a +dial-only single-peer shortcut before `open_wg_iface()`. + +## Response summary + +Added process-local per-peer configuration and refactored interface +configuration to own a unique peer tuple. This supports multi-peer +listeners and dial targets without overloading role-dependent tunnel +maddr identity. + +## Files changed + +- `tractor/discovery/_tunnel.py` - peer model and interface peer list. +- `tractor/discovery/__init__.py` - public peer-config export. +- `tests/discovery/test_wg_config.py` - peer secrecy and validation. +- `ai/tpt-backends/03_wg_tunnel_bindspace.md` - explicit multi-peer + provisioning contract. + +## Human edits + +The human chose explicit peer lists over a dial-only implementation so +listeners can represent multiple client public keys and routing +policies. The agent implemented that human-selected design; no direct +manual source edits were observed. diff --git a/ai/prompt-io/opencode/20260826T001442Z_dcdf4d82_prompt_io.raw.md b/ai/prompt-io/opencode/20260826T001442Z_dcdf4d82_prompt_io.raw.md new file mode 100644 index 00000000..5248df50 --- /dev/null +++ b/ai/prompt-io/opencode/20260826T001442Z_dcdf4d82_prompt_io.raw.md @@ -0,0 +1,27 @@ +--- +model: gpt-5.6-sol +service: opencode +timestamp: 2026-08-26T00:14:42Z +git_ref: dcdf4d82 +diff_cmd: git diff HEAD~1..HEAD +--- + +# Raw output - model explicit WireGuard peers + +After committing local WG interface configuration, the human selected +an explicit peer-list model before interface provisioning. + +> `git diff HEAD~1..HEAD -- tractor/discovery/_tunnel.py tractor/discovery/__init__.py tests/discovery/test_wg_config.py ai/tpt-backends/03_wg_tunnel_bindspace.md` + +Added process-local `WGPeerConfig` with public key, allowed CIDRs, +optional endpoint, preshared key and keepalive. Refactored +`WGInterfaceConfig` to own a tuple of unique peers beside its private +key, local addresses and listen port. + +Peer PSKs remain redacted and nested `ProcessLocal` sentinels prevent +default wire encoding. Validation covers keys, routes, endpoints, +ports, keepalive and duplicate peers. + +Ruff and lock checks passed. Focused peer/config coverage passed 11 +tests. A broad unrelated registrar cancellation case timed out after +86 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 f047559a..2ab6fe05 100644 --- a/ai/tpt-backends/03_wg_tunnel_bindspace.md +++ b/ai/tpt-backends/03_wg_tunnel_bindspace.md @@ -514,12 +514,13 @@ async def open_wg_iface( ) -> AsyncGenerator[WGTunnelSpec, None]: ... ``` -`WGInterfaceConfig` is process-local, redacts its private/preshared -keys and is rejected by the global `ProcessLocal` wire guard. It owns -local interface addresses, peer allowed CIDRs, listen port and -persistent keepalive policy. `WGTunnelSpec` remains serializable and -contains only public maddr-derived identity/endpoint data. This split -keeps secret material out of address declarations and actor payloads. +`WGInterfaceConfig` and each `WGPeerConfig` are process-local and +rejected by the global `ProcessLocal` wire guard. The interface config +owns its private key, local addresses and listen port; each peer owns +its public key, allowed CIDRs, optional endpoint, preshared key and +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. and a driver that folds a list of specs into nested contexts (`contextlib.AsyncExitStack` for the N-deep case). The diff --git a/tests/discovery/test_wg_config.py b/tests/discovery/test_wg_config.py index 498ea021..fa712b92 100644 --- a/tests/discovery/test_wg_config.py +++ b/tests/discovery/test_wg_config.py @@ -7,11 +7,15 @@ from __future__ import annotations import msgspec import pytest -from tractor.discovery import WGInterfaceConfig +from tractor.discovery import ( + WGInterfaceConfig, + WGPeerConfig, +) from tractor.msg import ProcessLocal _PRIVATE_KEY: str = 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=' +_PEER_KEY: str = 'r1LKM1pqhuY9Z6L4y5jQ2fGX67kJSrq5kRV5Jk2ywEo=' _PRESHARED_KEY: str = 'BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBA=' @@ -24,13 +28,18 @@ def test_wg_config_is_process_local_and_redacted() -> None: Verify `ProcessLocal` blocks default msgpack encoding. ''' + peer: WGPeerConfig = WGPeerConfig( + public_key=_PEER_KEY, + allowed_ips=('10.1.0.0/16', 'fd01::/64'), + endpoint=('192.0.2.1', 51820), + preshared_key=_PRESHARED_KEY, + persistent_keepalive=25, + ) config: WGInterfaceConfig = WGInterfaceConfig( private_key=_PRIVATE_KEY, addresses=('10.0.0.1/24', 'fd00::1/64'), - allowed_ips=('10.1.0.0/16', 'fd01::/64'), listen_port=51820, - preshared_key=_PRESHARED_KEY, - persistent_keepalive=25, + peers=(peer,), ) config_repr: str = repr(config) @@ -38,7 +47,7 @@ def test_wg_config_is_process_local_and_redacted() -> None: assert _PRIVATE_KEY not in config_repr assert _PRESHARED_KEY not in config_repr assert config.addresses[0] in config_repr - assert config.allowed_ips[0] in config_repr + assert peer.allowed_ips[0] in config_repr with pytest.raises( TypeError, match='_ProcessLocalToken.*unsupported', @@ -54,14 +63,6 @@ def test_wg_config_is_process_local_and_redacted() -> None: ValueError, id='private-key', ), - pytest.param( - { - 'private_key': _PRIVATE_KEY, - 'preshared_key': 'not-base64', - }, - ValueError, - id='preshared-key', - ), pytest.param( { 'private_key': _PRIVATE_KEY, @@ -70,14 +71,6 @@ def test_wg_config_is_process_local_and_redacted() -> None: ValueError, id='address', ), - pytest.param( - { - 'private_key': _PRIVATE_KEY, - 'allowed_ips': ('not-a-network',), - }, - ValueError, - id='allowed-ip', - ), pytest.param( { 'private_key': _PRIVATE_KEY, @@ -89,10 +82,13 @@ def test_wg_config_is_process_local_and_redacted() -> None: pytest.param( { 'private_key': _PRIVATE_KEY, - 'persistent_keepalive': -1, + 'peers': ( + WGPeerConfig(public_key=_PEER_KEY), + WGPeerConfig(public_key=_PEER_KEY), + ), }, ValueError, - id='keepalive', + id='duplicate-peer', ), ), ) @@ -110,3 +106,58 @@ def test_wg_config_rejects_invalid_values( ''' with pytest.raises(error): WGInterfaceConfig(**kwargs) # type: ignore[arg-type] + + +@pytest.mark.parametrize( + 'kwargs', + ( + pytest.param( + {'public_key': 'not-base64'}, + id='public-key', + ), + pytest.param( + { + 'public_key': _PEER_KEY, + 'preshared_key': 'not-base64', + }, + id='preshared-key', + ), + pytest.param( + { + 'public_key': _PEER_KEY, + 'allowed_ips': ('not-a-network',), + }, + id='allowed-ip', + ), + pytest.param( + { + 'public_key': _PEER_KEY, + 'endpoint': ('not-an-ip', 51820), + }, + id='endpoint-host', + ), + pytest.param( + { + 'public_key': _PEER_KEY, + 'endpoint': ('192.0.2.1', 65536), + }, + id='endpoint-port', + ), + pytest.param( + { + 'public_key': _PEER_KEY, + 'persistent_keepalive': -1, + }, + id='keepalive', + ), + ), +) +def test_wg_peer_config_rejects_invalid_values( + kwargs: dict[str, object], +) -> None: + ''' + Invalid peer policy must fail before kernel mutation. + + ''' + with pytest.raises(ValueError): + WGPeerConfig(**kwargs) # type: ignore[arg-type] diff --git a/tractor/discovery/__init__.py b/tractor/discovery/__init__.py index b9749fbb..444cd111 100644 --- a/tractor/discovery/__init__.py +++ b/tractor/discovery/__init__.py @@ -46,6 +46,7 @@ from ._tunnel import ( TunnelSpec as TunnelSpec, WGTunnelSpec as WGTunnelSpec, WGInterfaceConfig as WGInterfaceConfig, + WGPeerConfig as WGPeerConfig, mb_pubkey as mb_pubkey, mk_wg_maddr as mk_wg_maddr, parse_wg_maddr as parse_wg_maddr, diff --git a/tractor/discovery/_tunnel.py b/tractor/discovery/_tunnel.py index 84567fc8..83592825 100644 --- a/tractor/discovery/_tunnel.py +++ b/tractor/discovery/_tunnel.py @@ -197,6 +197,86 @@ def _wg8_key_str( return key +class WGPeerConfig( + ProcessLocal, +): + ''' + Process-local configuration for one WireGuard peer. + + ''' + public_key: str + allowed_ips: tuple[str, ...] = () + endpoint: tuple[str, int]|None = None + preshared_key: str|None = None + persistent_keepalive: int|None = None + + def __post_init__(self) -> None: + ''' + Validate peer identity, routes, endpoint and secret policy. + + ''' + _wg8_key_str(self.public_key) + if self.preshared_key is not None: + _wg8_key_str(self.preshared_key) + + # Validate each route. `strict=False` accepts host bits; + # pyroute2 will still receive each original declared string. + allowed_ip: str + for allowed_ip in self.allowed_ips: + ipaddress.ip_network( + allowed_ip, + strict=False, + ) + + endpoint: tuple[str, int]|None = self.endpoint + if endpoint is not None: + host, port = endpoint + ipaddress.ip_address(host) + if ( + type(port) is not int + or + not 1 <= port <= 65535 + ): + raise ValueError( + '`WGPeerConfig.endpoint` port must be in ' + '`1..65535`!' + ) + + keepalive: int|None = self.persistent_keepalive + if ( + keepalive is not None + and + ( + type(keepalive) is not int + or + not 0 <= keepalive <= 65535 + ) + ): + raise ValueError( + '`WGPeerConfig.persistent_keepalive` must be in ' + '`0..65535` or `None`!' + ) + + def __repr__(self) -> str: + ''' + Render public peer policy while redacting its preshared key. + + ''' + preshared: str|None = ( + '' + if self.preshared_key is not None + else None + ) + return ( + f'{type(self).__name__}(' + f'public_key={self.public_key!r}, ' + f'allowed_ips={self.allowed_ips!r}, ' + f'endpoint={self.endpoint!r}, ' + f'preshared_key={preshared!r}, ' + f'persistent_keepalive={self.persistent_keepalive!r})' + ) + + class WGInterfaceConfig( ProcessLocal, ): @@ -210,34 +290,21 @@ class WGInterfaceConfig( ''' private_key: str addresses: tuple[str, ...] = () - allowed_ips: tuple[str, ...] = () listen_port: int|None = None - preshared_key: str|None = None - persistent_keepalive: int|None = None + peers: tuple[WGPeerConfig, ...] = () def __post_init__(self) -> None: ''' - Validate secrets, CIDRs and bounded WireGuard integers. + Validate private identity, local CIDRs and peer uniqueness. ''' _wg8_key_str(self.private_key) - if self.preshared_key is not None: - _wg8_key_str(self.preshared_key) - # Validate every local CIDR; no entry is selected or consumed. + # Validate each local CIDR; no address is selected. address: str for address in self.addresses: ipaddress.ip_interface(address) - # Validate every peer route. `strict=False` accepts host bits; - # pyroute2 will still receive each original declared string. - allowed_ip: str - for allowed_ip in self.allowed_ips: - ipaddress.ip_network( - allowed_ip, - strict=False, - ) - listen_port: int|None = self.listen_port if ( listen_port is not None @@ -253,40 +320,31 @@ class WGInterfaceConfig( '`1..65535` or `None`!' ) - keepalive: int|None = self.persistent_keepalive - if ( - keepalive is not None - and - ( - type(keepalive) is not int - or - not 0 <= keepalive <= 65535 - ) - ): - raise ValueError( - '`WGInterfaceConfig.persistent_keepalive` ' - 'must be in ' - '`0..65535` or `None`!' - ) + peer_keys: set[str] = set() + peer: WGPeerConfig + for peer in self.peers: + if not isinstance(peer, WGPeerConfig): + raise TypeError( + '`WGInterfaceConfig.peers` must contain ' + '`WGPeerConfig` values!' + ) + if peer.public_key in peer_keys: + raise ValueError( + f'Duplicate WireGuard peer: {peer.public_key!r}' + ) + peer_keys.add(peer.public_key) def __repr__(self) -> str: ''' Render non-secret policy while redacting key material. ''' - preshared: str|None = ( - '' - if self.preshared_key is not None - else None - ) return ( f'{type(self).__name__}(' f'private_key=, ' f'addresses={self.addresses!r}, ' - f'allowed_ips={self.allowed_ips!r}, ' f'listen_port={self.listen_port!r}, ' - f'preshared_key={preshared!r}, ' - f'persistent_keepalive={self.persistent_keepalive!r})' + f'peers={self.peers!r})' )