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`))
Gud Boi 2026-08-25 20:28:28 -04:00
parent 5ec7cbaf98
commit 4c10ce2bd0
6 changed files with 243 additions and 68 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 = (
'<redacted>'
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 = (
'<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})'
f'peers={self.peers!r})'
)