From 5ec7cbaf98da8743aa0ac47db820e8cd4abe7c2f Mon Sep 17 00:00:00 2001 From: goodboy Date: Tue, 25 Aug 2026 20:02:48 -0400 Subject: [PATCH] Separate local WireGuard configuration Add process-local `WGInterfaceConfig` for key material, interface addresses and peer-routing policy required by future provisioning. Deats, - redact private and preshared keys from representation - block config from default actor-IPC encoding via `ProcessLocal` - validate keys, interface CIDRs, allowed CIDRs and bounded integers - keep public endpoint and peer identity in `WGTunnelSpec` - move allowed-IP policy out of the serializable tunnel declaration Prompt-IO: ai/prompt-io/opencode/20260825T234631Z_b973e78c_prompt_io.md (this patch was generated in some part by `opencode` using `gpt-5.6-sol` (`openai`)) --- .../20260825T234631Z_b973e78c_prompt_io.md | 42 +++++++ ...20260825T234631Z_b973e78c_prompt_io.raw.md | 28 +++++ ai/tpt-backends/03_wg_tunnel_bindspace.md | 8 ++ tests/discovery/test_wg_config.py | 112 ++++++++++++++++++ tractor/discovery/__init__.py | 1 + tractor/discovery/_tunnel.py | 98 ++++++++++++++- 6 files changed, 286 insertions(+), 3 deletions(-) create mode 100644 ai/prompt-io/opencode/20260825T234631Z_b973e78c_prompt_io.md create mode 100644 ai/prompt-io/opencode/20260825T234631Z_b973e78c_prompt_io.raw.md create mode 100644 tests/discovery/test_wg_config.py diff --git a/ai/prompt-io/opencode/20260825T234631Z_b973e78c_prompt_io.md b/ai/prompt-io/opencode/20260825T234631Z_b973e78c_prompt_io.md new file mode 100644 index 00000000..54729534 --- /dev/null +++ b/ai/prompt-io/opencode/20260825T234631Z_b973e78c_prompt_io.md @@ -0,0 +1,42 @@ +--- +model: gpt-5.6-sol +service: opencode +session: tractor-addr-unpacking +timestamp: 2026-08-25T23:46:31Z +git_ref: b973e78c +scope: code +substantive: true +raw_file: 20260825T234631Z_b973e78c_prompt_io.raw.md +--- + +## Prompt + +The human pointed out that the agent had again produced no worktree +changes after the latest commit and expected actual next-layer work. + +## Response summary + +Corrected the sequencing mistake and added a process-local WireGuard +interface configuration boundary. Secret/routing policy is separated +from serializable maddr-derived tunnel declarations, redacted from +repr and blocked from default wire encoding. +Staged review clarified that address/network parser calls validate +every declaration and do not select or consume a first address. + +## Files changed + +- `tractor/discovery/_tunnel.py` - local WG config and validation. +- `tractor/discovery/__init__.py` - public config export. +- `tests/discovery/test_wg_config.py` - secrecy and validation coverage. +- `ai/tpt-backends/03_wg_tunnel_bindspace.md` - secret/config boundary + for future interface provisioning. + +## Human edits + +The human identified the second sequencing failure where the agent +summarized an already committed slice instead of implementing the next +one. That correction directly caused this new WG configuration layer +to be implemented. During staged review, the human requested explicit +explanation of the address and allowed-route validation loops. The +agent applied that human-directed clarification; no direct manual edits +were observed. diff --git a/ai/prompt-io/opencode/20260825T234631Z_b973e78c_prompt_io.raw.md b/ai/prompt-io/opencode/20260825T234631Z_b973e78c_prompt_io.raw.md new file mode 100644 index 00000000..69b040d1 --- /dev/null +++ b/ai/prompt-io/opencode/20260825T234631Z_b973e78c_prompt_io.raw.md @@ -0,0 +1,28 @@ +--- +model: gpt-5.6-sol +service: opencode +timestamp: 2026-08-25T23:46:31Z +git_ref: b973e78c +diff_cmd: git diff HEAD~1..HEAD +--- + +# Raw output - separate local WireGuard configuration + +The human again noticed the agent had re-reported already committed +work without changing the worktree. The agent confirmed the sequencing +mistake and moved to the next unimplemented dependency. + +> `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 `WGInterfaceConfig` for private/preshared keys, +local interface CIDRs, peer allowed CIDRs, listen port and persistent +keepalive. Secrets are redacted from repr and the global +`ProcessLocal` marker blocks default wire encoding. + +Removed the unused serialized `WGTunnelSpec.maybe_allowed_ips` +placeholder so maddr-derived declarations retain only public identity, +endpoint and interface selection. Added validation for keys, CIDRs, +ports and keepalive before future kernel mutation. + +Ruff and lock checks passed. Focused WG coverage passed 25 tests; +discovery plus message coverage passed 145 tests with 2 xpasses. diff --git a/ai/tpt-backends/03_wg_tunnel_bindspace.md b/ai/tpt-backends/03_wg_tunnel_bindspace.md index c43d1a7f..f047559a 100644 --- a/ai/tpt-backends/03_wg_tunnel_bindspace.md +++ b/ai/tpt-backends/03_wg_tunnel_bindspace.md @@ -508,11 +508,19 @@ async def open_netns( @acm async def open_wg_iface( spec: WGTunnelSpec, + config: WGInterfaceConfig, bindspace: BindspaceHandle, role: Literal['listen', 'dial'], ) -> 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. + 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_config.py b/tests/discovery/test_wg_config.py new file mode 100644 index 00000000..498ea021 --- /dev/null +++ b/tests/discovery/test_wg_config.py @@ -0,0 +1,112 @@ +''' +Process-local WireGuard interface configuration contracts. + +''' +from __future__ import annotations + +import msgspec +import pytest + +from tractor.discovery import WGInterfaceConfig +from tractor.msg import ProcessLocal + + +_PRIVATE_KEY: str = 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=' +_PRESHARED_KEY: str = 'BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBA=' + + +def test_wg_config_is_process_local_and_redacted() -> None: + ''' + Private WireGuard configuration must neither print nor cross IPC. + + Construct a complete local config and prove its public routing + policy remains inspectable while both keys are absent from repr. + Verify `ProcessLocal` blocks default msgpack encoding. + + ''' + 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, + ) + + config_repr: str = repr(config) + assert isinstance(config, ProcessLocal) + 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 + with pytest.raises( + TypeError, + match='_ProcessLocalToken.*unsupported', + ): + msgspec.msgpack.encode(config) + + +@pytest.mark.parametrize( + ('kwargs', 'error'), + ( + pytest.param( + {'private_key': 'not-base64'}, + ValueError, + id='private-key', + ), + pytest.param( + { + 'private_key': _PRIVATE_KEY, + 'preshared_key': 'not-base64', + }, + ValueError, + id='preshared-key', + ), + pytest.param( + { + 'private_key': _PRIVATE_KEY, + 'addresses': ('not-an-interface',), + }, + ValueError, + id='address', + ), + pytest.param( + { + 'private_key': _PRIVATE_KEY, + 'allowed_ips': ('not-a-network',), + }, + ValueError, + id='allowed-ip', + ), + pytest.param( + { + 'private_key': _PRIVATE_KEY, + 'listen_port': 65536, + }, + ValueError, + id='listen-port', + ), + pytest.param( + { + 'private_key': _PRIVATE_KEY, + 'persistent_keepalive': -1, + }, + ValueError, + id='keepalive', + ), + ), +) +def test_wg_config_rejects_invalid_values( + kwargs: dict[str, object], + error: type[Exception], +) -> None: + ''' + Invalid config must fail before kernel mutation. + + Parameterize every validated input class and prove direct msgspec + construction cannot carry malformed configuration into a future + pyroute2 interface lifecycle. + + ''' + with pytest.raises(error): + WGInterfaceConfig(**kwargs) # type: ignore[arg-type] diff --git a/tractor/discovery/__init__.py b/tractor/discovery/__init__.py index 3a98eb1c..b9749fbb 100644 --- a/tractor/discovery/__init__.py +++ b/tractor/discovery/__init__.py @@ -45,6 +45,7 @@ from ._tunnel import ( TunnelledAddress as TunnelledAddress, TunnelSpec as TunnelSpec, WGTunnelSpec as WGTunnelSpec, + WGInterfaceConfig as WGInterfaceConfig, 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 234e503c..84567fc8 100644 --- a/tractor/discovery/_tunnel.py +++ b/tractor/discovery/_tunnel.py @@ -80,6 +80,8 @@ import msgspec import multibase import trio +from ..msg._local import ProcessLocal + if TYPE_CHECKING: from multiaddr import Multiaddr @@ -120,9 +122,6 @@ class WGTunnelSpec( iface: str = 'wg0' netns: str|None = None - # layer-C-only fields, unset in layer A - maybe_allowed_ips: tuple[str, ...] = () - # the `multiaddr` proto name for this tunnel kind tunnel_key: ClassVar[str] = 'wg' @@ -198,6 +197,99 @@ def _wg8_key_str( return key +class WGInterfaceConfig( + ProcessLocal, +): + ''' + Process-local secrets and routing inputs for one WireGuard iface. + + Public peer identity, endpoint and iface selection remain in + `WGTunnelSpec`; private key material and local routing policy do + not belong in an maddr-derived serializable declaration. + + ''' + 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 + + def __post_init__(self) -> None: + ''' + Validate secrets, CIDRs and bounded WireGuard integers. + + ''' + _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. + 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 + and + ( + type(listen_port) is not int + or + not 1 <= listen_port <= 65535 + ) + ): + raise ValueError( + '`WGInterfaceConfig.listen_port` must be in ' + '`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`!' + ) + + 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})' + ) + + def _sync_read_wg_keys( iface: str, netns: str|None,