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`))
parent
2db2b35a4e
commit
5ec7cbaf98
|
|
@ -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.
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -508,11 +508,19 @@ async def open_netns(
|
||||||
@acm
|
@acm
|
||||||
async def open_wg_iface(
|
async def open_wg_iface(
|
||||||
spec: WGTunnelSpec,
|
spec: WGTunnelSpec,
|
||||||
|
config: WGInterfaceConfig,
|
||||||
bindspace: BindspaceHandle,
|
bindspace: BindspaceHandle,
|
||||||
role: Literal['listen', 'dial'],
|
role: Literal['listen', 'dial'],
|
||||||
) -> AsyncGenerator[WGTunnelSpec, None]: ...
|
) -> 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
|
and a driver that folds a list of specs into nested contexts
|
||||||
(`contextlib.AsyncExitStack` for the N-deep case). The
|
(`contextlib.AsyncExitStack` for the N-deep case). The
|
||||||
`parse_endpoints()` API (`_multiaddr.py:153`) is the front door:
|
`parse_endpoints()` API (`_multiaddr.py:153`) is the front door:
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
@ -45,6 +45,7 @@ from ._tunnel import (
|
||||||
TunnelledAddress as TunnelledAddress,
|
TunnelledAddress as TunnelledAddress,
|
||||||
TunnelSpec as TunnelSpec,
|
TunnelSpec as TunnelSpec,
|
||||||
WGTunnelSpec as WGTunnelSpec,
|
WGTunnelSpec as WGTunnelSpec,
|
||||||
|
WGInterfaceConfig as WGInterfaceConfig,
|
||||||
mb_pubkey as mb_pubkey,
|
mb_pubkey as mb_pubkey,
|
||||||
mk_wg_maddr as mk_wg_maddr,
|
mk_wg_maddr as mk_wg_maddr,
|
||||||
parse_wg_maddr as parse_wg_maddr,
|
parse_wg_maddr as parse_wg_maddr,
|
||||||
|
|
|
||||||
|
|
@ -80,6 +80,8 @@ import msgspec
|
||||||
import multibase
|
import multibase
|
||||||
import trio
|
import trio
|
||||||
|
|
||||||
|
from ..msg._local import ProcessLocal
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from multiaddr import Multiaddr
|
from multiaddr import Multiaddr
|
||||||
|
|
||||||
|
|
@ -120,9 +122,6 @@ class WGTunnelSpec(
|
||||||
iface: str = 'wg0'
|
iface: str = 'wg0'
|
||||||
netns: str|None = None
|
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
|
# the `multiaddr` proto name for this tunnel kind
|
||||||
tunnel_key: ClassVar[str] = 'wg'
|
tunnel_key: ClassVar[str] = 'wg'
|
||||||
|
|
||||||
|
|
@ -198,6 +197,99 @@ def _wg8_key_str(
|
||||||
return key
|
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(
|
def _sync_read_wg_keys(
|
||||||
iface: str,
|
iface: str,
|
||||||
netns: str|None,
|
netns: str|None,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue