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`))
Gud Boi 2026-08-25 20:02:48 -04:00
parent 02f1e9f72c
commit d68b101717
6 changed files with 286 additions and 3 deletions

View File

@ -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.

View File

@ -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.

View File

@ -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:

View File

@ -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]

View File

@ -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,

View File

@ -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 = (
'<redacted>'
if self.preshared_key is not None
else None
)
return (
f'{type(self).__name__}('
f'private_key=<redacted>, '
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,