Compare commits

...

4 Commits

Author SHA1 Message Date
Gud Boi d130431ca1 Compose WireGuard bindspace lifecycles
Add `open_wg_bindspace()` to enter one declared bindspace and an
ordered WireGuard interface stack as one async lifetime.

Deats,
- snapshot caller layer ordering before the first checkpoint
- enter interfaces outermost-first through `AsyncExitStack`
- unwind interfaces before releasing the namespace capability
- yield the live `BindspaceHandle` for endpoint allocation
- test mutable input and cancellation ordering with lifecycle fakes

Prompt-IO: ai/prompt-io/opencode/20260826T022434Z_2245f094_prompt_io.md

(this patch was generated in some part by `opencode` using `gpt-5.6-sol` (`openai`))
2026-08-25 22:53:51 -04:00
Gud Boi 2245f09432 Own WireGuard interface lifecycles
Add `open_wg_iface()` to create, configure and remove one WireGuard
interface inside a pinned bindspace through pyroute2.

Deats,
- validate listen/dial bearer policy before kernel side effects
- configure local addresses, private key, listen port and peers
- fill an omitted dial endpoint from the selected tunnel bearer
- clean partial synchronous failures before returning to Trio
- shield owned interface creation and teardown from cancellation

Prompt-IO: ai/prompt-io/opencode/20260826T003430Z_6dd39da0_prompt_io.md

(this patch was generated in some part by `opencode` using `gpt-5.6-sol` (`openai`))
2026-08-25 22:16:15 -04:00
Gud Boi 6dd39da058 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`))
2026-08-25 20:28:28 -04:00
Gud Boi dcdf4d82ad 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`))
2026-08-25 20:02:48 -04:00
13 changed files with 1343 additions and 5 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

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

@ -0,0 +1,42 @@
---
model: gpt-5.6-sol
service: opencode
session: tractor-addr-unpacking
timestamp: 2026-08-26T00:34:30Z
git_ref: 6dd39da0
scope: code
substantive: true
raw_file: 20260826T003430Z_6dd39da0_prompt_io.raw.md
---
## Prompt
After committing explicit WireGuard peer configuration, the human
authorized the next incremental lifecycle change.
## Response summary
Added owned WireGuard interface provisioning and teardown through
cancellation-shielded pyroute2 worker calls. Role-specific policy is
validated before mutation, local addresses and explicit peers are
configured, and owned interfaces are removed on exit.
Staged review clarified role-specific test assertions, added the
second peer's endpoint-port check, included conflicting values in
policy errors and aligned annotated assignment with `/py-codestyle`.
## Files changed
- `tractor/discovery/_tunnel.py` - role policy and WG iface lifecycle.
- `tractor/discovery/__init__.py` - public lifecycle and role exports.
- `tests/discovery/test_wg_iface_lifecycle.py` - policy and cancellation
coverage with privileged calls faked.
- `ai/tpt-backends/03_wg_tunnel_bindspace.md` - initial owned WG iface
lifecycle contract.
## Human edits
The human selected continuation from explicit peer modeling into the
owned WireGuard interface lifecycle. During staged review, the human
requested clearer role assertions, complete endpoint checks, concrete
error values and inline annotated assignment. The agent applied those
human-directed edits; no direct manual source edits were observed.

View File

@ -0,0 +1,29 @@
---
model: gpt-5.6-sol
service: opencode
timestamp: 2026-08-26T00:34:30Z
git_ref: 6dd39da0
diff_cmd: git diff HEAD~1..HEAD
---
# Raw output - own WireGuard interface lifecycle
After committing explicit peer configuration, the human authorized the
next incremental lifecycle change.
> `git diff HEAD~1..HEAD -- tractor/discovery/_tunnel.py tractor/discovery/__init__.py tests/discovery/test_wg_iface_lifecycle.py ai/tpt-backends/03_wg_tunnel_bindspace.md`
Added owned async `open_wg_iface()` lifecycle with role-policy
validation before side effects. Listen bearer ports configure the local
listener; dial bearers fill omitted endpoints only for the selected
maddr peer.
Pyroute2 worker code creates the WireGuard link, assigns local
addresses, configures private key/listen port and every explicit peer,
raises the link, and removes it on exit. Partial create failures clean
up synchronously; async create/remove calls are cancellation-shielded.
Privileged operations are faked in tests. Ruff and lock checks passed;
focused WG lifecycle/config/inspection coverage passed 22 tests. A
broad unrelated registrar cancellation parameter timed out after 76
passes and 2 xpasses; its isolated rerun passed.

View File

@ -0,0 +1,47 @@
---
model: gpt-5.6-sol
service: opencode
session: tractor-addr-unpacking
timestamp: 2026-08-26T02:24:34Z
git_ref: 2245f094
scope: code
substantive: true
raw_file: 20260826T022434Z_2245f094_prompt_io.raw.md
---
## Prompt
After committing the owned WireGuard interface lifecycle, the human
asked to continue with the next established incremental step.
## Response summary
Added a WG-specific composition context which owns one declared
bindspace and an ordered interface stack. It snapshots caller-owned
layer order before checkpointing, enters interfaces outermost-first,
and guarantees inside-out interface teardown before bindspace release.
Its inbound type shows the `(WGTunnelSpec, WGInterfaceConfig)` pairing
directly, and its docstring depicts the stack and unwind order.
## Files changed
- `tractor/discovery/_tunnel.py` - bindspace/interface stack driver.
- `tractor/discovery/__init__.py` - public layer and driver exports.
- `tests/discovery/test_wg_iface_lifecycle.py` - mutable-input,
cancellation and nested teardown-order coverage.
- `ai/tpt-backends/03_wg_tunnel_bindspace.md` - implemented composition
and ownership-order contract.
## Human edits
The human accepted and committed the preceding reviewed lifecycle
boundary, then explicitly requested the next step. That selected the
previously established bindspace/WireGuard composition boundary. In
staged review, the human requested an explicit interface-stack analogy
and a small ASCII diagram explaining entry and exit order. The human
then identified `WGInterfaceLayer` as an unnecessary single-use alias
which obscured the inbound tuple shape. The agent applied both
human-directed edits. The human accepted the fake lifecycle test as a
focused control-flow check which avoids pyroute2 overhead, while noting
their general preference for end-to-end coverage. No direct manual
source edits were observed.

View File

@ -0,0 +1,28 @@
---
model: gpt-5.6-sol
service: opencode
timestamp: 2026-08-26T02:24:34Z
git_ref: 2245f094
diff_cmd: git diff HEAD~1..HEAD
---
# Raw output - compose WireGuard bindspace lifecycles
After committing the owned WireGuard interface lifecycle, the human
requested the next incremental change.
> `git diff HEAD~1..HEAD -- tractor/discovery/_tunnel.py tractor/discovery/__init__.py tests/discovery/test_wg_iface_lifecycle.py ai/tpt-backends/03_wg_tunnel_bindspace.md`
Added `open_wg_bindspace()` to compose one declared bindspace with an
ordered sequence of WireGuard interface layers. The context enters the
bindspace first, enters interfaces outermost-first through
`AsyncExitStack`, yields the live `BindspaceHandle`, and removes
interfaces inside-out before releasing or removing the namespace.
The caller's layer sequence is snapshotted before the first checkpoint,
so concurrent mutation cannot alter resource entry or unwind order.
Fake lifecycle tests clear a mutable input list during bindspace entry,
cancel from the yielded scope, and prove the original stack unwinds in
dependency-safe order.
Ruff passed and focused bindspace/WireGuard coverage passed 39 tests.

View File

@ -508,13 +508,30 @@ 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]: ...
``` ```
and a driver that folds a list of specs into nested contexts `WGInterfaceConfig` and each `WGPeerConfig` are process-local and
(`contextlib.AsyncExitStack` for the N-deep case). The 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.
The initial `open_wg_iface()` lifecycle is owned and Linux-only. It
validates role-dependent bearer policy before side effects, creates the
iface and addresses through `IPRoute`, configures keys/peers through
`WireGuard`, raises the link, and removes it on every post-creation
exit. Creation/removal run in shielded Trio worker calls. A listen
bearer supplies the local listen port; a dial bearer supplies an
omitted endpoint only for the selected maddr peer.
The composition driver folds a list of specs into nested contexts with
`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:
it already returns it already returns
`dict[name, list[Address|TunnelledAddress]]` and the `dict[name, list[Address|TunnelledAddress]]` and the
@ -522,6 +539,14 @@ it already returns
`dict[str, list[Address]]|dict[...]` return for tunnelled `dict[str, list[Address]]|dict[...]` return for tunnelled
entries. Extend it to carry the tunnel stack, not to *enter* it. entries. Extend it to carry the tunnel stack, not to *enter* it.
`open_wg_bindspace()` is the initial driver for one bindspace and an
ordered sequence of `(WGTunnelSpec, WGInterfaceConfig)` layers. It
opens the bindspace first, enters WG interfaces outermost-first through
`AsyncExitStack`, and yields the live `BindspaceHandle` for endpoint
allocation. Exit is inside-out, so every interface is removed while the
namespace FD remains pinned; only then can an owned namespace be
removed. Endpoint/channel lifetimes belong inside the yielded scope.
The caller supplies `role` to tunnel-resource contexts such as The caller supplies `role` to tunnel-resource contexts such as
`open_wg_iface()`; do not infer it from maddr shape. Bindspace `open_wg_iface()`; do not infer it from maddr shape. Bindspace
lifecycle remains the independent explicit policy above. The same lifecycle remains the independent explicit policy above. The same

View File

@ -0,0 +1,163 @@
'''
Process-local WireGuard interface configuration contracts.
'''
from __future__ import annotations
import msgspec
import pytest
from tractor.discovery import (
WGInterfaceConfig,
WGPeerConfig,
)
from tractor.msg import ProcessLocal
_PRIVATE_KEY: str = 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA='
_PEER_KEY: str = 'r1LKM1pqhuY9Z6L4y5jQ2fGX67kJSrq5kRV5Jk2ywEo='
_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.
'''
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'),
listen_port=51820,
peers=(peer,),
)
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 peer.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,
'addresses': ('not-an-interface',),
},
ValueError,
id='address',
),
pytest.param(
{
'private_key': _PRIVATE_KEY,
'listen_port': 65536,
},
ValueError,
id='listen-port',
),
pytest.param(
{
'private_key': _PRIVATE_KEY,
'peers': (
WGPeerConfig(public_key=_PEER_KEY),
WGPeerConfig(public_key=_PEER_KEY),
),
},
ValueError,
id='duplicate-peer',
),
),
)
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]
@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

@ -0,0 +1,378 @@
'''
WireGuard interface policy and owned lifecycle contracts.
'''
from __future__ import annotations
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager as acm
import os
from pathlib import Path
from typing import BinaryIO
import pytest
import trio
from tractor.discovery import (
BindspaceHandle,
BindspaceIdentity,
BindspaceSpec,
WGInterfaceConfig,
WGPeerConfig,
WGTunnelSpec,
open_wg_bindspace,
open_wg_iface,
)
from tractor.discovery import _tunnel
_LOCAL_KEY: str = 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA='
_PEER_KEY: str = 'r1LKM1pqhuY9Z6L4y5jQ2fGX67kJSrq5kRV5Jk2ywEo='
def test_wg_iface_settings_follow_role() -> None:
'''
Listen and dial roles interpret the tunnel bearer differently.
Prove listen derives its local port from the bearer while dial
applies the bearer as the selected peer's omitted endpoint. Other
explicit peers retain their own endpoint and routing policy.
'''
selected: WGPeerConfig = WGPeerConfig(
public_key=_PEER_KEY,
allowed_ips=('10.1.0.0/16',),
)
other: WGPeerConfig = WGPeerConfig(
public_key=_LOCAL_KEY,
endpoint=('198.51.100.2', 51821),
)
config: WGInterfaceConfig = WGInterfaceConfig(
private_key=_LOCAL_KEY,
peers=(selected, other),
)
spec: WGTunnelSpec = WGTunnelSpec(
peer_pubkey=_PEER_KEY,
bearer=('192.0.2.1', 51820),
)
listen_port: int|None
listen_peers: tuple[dict[str, object], ...]
listen_port, listen_peers = _tunnel._wg_iface_settings(
spec,
config,
'listen',
)
assert listen_port == 51820
# A listen bearer configures the local port, not a peer endpoint.
assert 'endpoint_addr' not in listen_peers[0]
dial_port: int|None
dial_peers: tuple[dict[str, object], ...]
dial_port, dial_peers = _tunnel._wg_iface_settings(
spec,
config,
'dial',
)
# No local dial listen port was declared; the bearer is remote.
assert dial_port is None
assert dial_peers[0]['endpoint_addr'] == '192.0.2.1'
assert dial_peers[0]['endpoint_port'] == 51820
assert dial_peers[0]['allowed_ips'] == ['10.1.0.0/16']
assert dial_peers[1]['endpoint_addr'] == '198.51.100.2'
assert dial_peers[1]['endpoint_port'] == 51821
@pytest.mark.parametrize(
('config', 'role', 'match'),
(
pytest.param(
WGInterfaceConfig(
private_key=_LOCAL_KEY,
),
'dial',
'not in.*configured peer keys',
id='missing-dial-peer',
),
pytest.param(
WGInterfaceConfig(
private_key=_LOCAL_KEY,
listen_port=51821,
),
'listen',
'51821.*51820',
id='listen-port-51821-vs-bearer-51820',
),
pytest.param(
WGInterfaceConfig(
private_key=_LOCAL_KEY,
peers=(
WGPeerConfig(
public_key=_PEER_KEY,
endpoint=('198.51.100.1', 51820),
),
),
),
'dial',
'198.51.100.1.*192.0.2.1',
id='dial-endpoint-conflict',
),
),
)
def test_wg_iface_settings_reject_conflicts(
config: WGInterfaceConfig,
role: str,
match: str,
) -> None:
'''
Role-dependent conflicts must fail before pyroute2 side effects.
'''
spec: WGTunnelSpec = WGTunnelSpec(
peer_pubkey=_PEER_KEY,
bearer=('192.0.2.1', 51820),
)
with pytest.raises(ValueError, match=match):
_tunnel._wg_iface_settings(
spec,
config,
role, # type: ignore[arg-type]
)
def test_open_wg_iface_shields_cancelled_cleanup(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
'''
Cancellation after creation must still remove the owned WG iface.
Pin a stand-in namespace FD and fake privileged create/remove
calls. Cancel inside the yielded context and prove shielded
teardown runs before cancellation leaves the enclosing scope.
'''
token_path: Path = tmp_path / 'netns'
token_path.touch()
events: list[str] = []
def create(
spec: WGTunnelSpec,
config: WGInterfaceConfig,
bindspace: BindspaceHandle,
listen_port: int|None,
peers: tuple[dict[str, object], ...],
) -> None:
'''
Record the validated create request.
'''
assert bindspace.namespace_fd is not None
assert listen_port is None
assert peers[0]['public_key'] == _PEER_KEY
events.append('create')
def remove(
spec: WGTunnelSpec,
bindspace: BindspaceHandle,
) -> None:
'''
Record shielded removal after cancellation.
'''
events.append('remove')
monkeypatch.setattr(
_tunnel,
'_sync_create_wg_iface',
create,
)
monkeypatch.setattr(
_tunnel,
'_sync_remove_wg_iface',
remove,
)
namespace_file: BinaryIO
with token_path.open('rb') as namespace_file:
namespace_fd: int = namespace_file.fileno()
bindspace_spec: BindspaceSpec = BindspaceSpec(
kind='netns',
key='tractor-wg0',
)
bindspace: BindspaceHandle = BindspaceHandle(
spec=bindspace_spec,
identity=BindspaceIdentity(
kind='netns',
key='tractor-wg0',
inode=os.fstat(namespace_fd).st_ino,
),
namespace_fd=namespace_fd,
ownership='borrowed',
)
peer: WGPeerConfig = WGPeerConfig(
public_key=_PEER_KEY,
)
config: WGInterfaceConfig = WGInterfaceConfig(
private_key=_LOCAL_KEY,
peers=(peer,),
)
spec: WGTunnelSpec = WGTunnelSpec(
peer_pubkey=_PEER_KEY,
)
async def main() -> None:
'''
Cancel while the fake WG iface is owned.
'''
with trio.CancelScope() as scope:
async with open_wg_iface(
spec,
config,
bindspace,
'dial',
):
events.append('yield')
scope.cancel()
await trio.sleep_forever()
trio.run(main)
assert events == ['create', 'yield', 'remove']
def test_open_wg_bindspace_nests_resource_lifetimes(
monkeypatch: pytest.MonkeyPatch,
) -> None:
'''
Nested WG interfaces must exit before their bindspace capability.
Fake two interface layers over one bindspace. Clear the caller's
mutable layer list at bindspace entry, then cancel from inside the
yielded application scope and checkpoint. The trace proves the
stack snapshots its declaration before entry, layers enter
outermost-first, cancellation exits them inside-out, and the live
bindspace remains available through every interface exit.
'''
events: list[str] = []
calls: list[
tuple[
WGTunnelSpec,
WGInterfaceConfig,
BindspaceHandle,
_tunnel.WGRole,
]
] = []
bindspace_spec: BindspaceSpec = BindspaceSpec(
kind='netns',
)
bindspace: BindspaceHandle = BindspaceHandle(
spec=bindspace_spec,
identity=BindspaceIdentity(
kind='netns',
key=None,
inode=1,
),
namespace_fd=None,
ownership='borrowed',
)
outer_spec: WGTunnelSpec = WGTunnelSpec(
peer_pubkey=_PEER_KEY,
iface='wg-outer',
)
inner_spec: WGTunnelSpec = WGTunnelSpec(
peer_pubkey=_LOCAL_KEY,
iface='wg-inner',
)
outer_config: WGInterfaceConfig = WGInterfaceConfig(
private_key=_LOCAL_KEY,
)
inner_config: WGInterfaceConfig = WGInterfaceConfig(
private_key=_PEER_KEY,
)
layers: list[
tuple[WGTunnelSpec, WGInterfaceConfig]
] = [
(outer_spec, outer_config),
(inner_spec, inner_config),
]
@acm
async def fake_open_bindspace(
spec: BindspaceSpec,
) -> AsyncIterator[BindspaceHandle]:
'''
Yield the stand-in bindspace and record its full lifetime.
'''
assert spec is bindspace_spec
events.append('bindspace-enter')
layers.clear()
try:
yield bindspace
finally:
events.append('bindspace-exit')
@acm
async def fake_open_wg_iface(
spec: WGTunnelSpec,
config: WGInterfaceConfig,
handle: BindspaceHandle,
role: _tunnel.WGRole,
) -> AsyncIterator[WGTunnelSpec]:
'''
Record one interface's arguments and nested lifetime.
'''
calls.append((spec, config, handle, role))
events.append(f'{spec.iface}-enter')
try:
yield spec
finally:
assert handle is bindspace
events.append(f'{spec.iface}-exit')
monkeypatch.setattr(
_tunnel,
'open_bindspace',
fake_open_bindspace,
)
monkeypatch.setattr(
_tunnel,
'open_wg_iface',
fake_open_wg_iface,
)
async def main() -> None:
'''
Cancel while both interface layers are live.
'''
with trio.CancelScope() as scope:
async with open_wg_bindspace(
bindspace_spec,
layers,
'dial',
) as handle:
assert handle is bindspace
events.append('yield')
scope.cancel()
await trio.sleep_forever()
trio.run(main)
assert calls == [
(outer_spec, outer_config, bindspace, 'dial'),
(inner_spec, inner_config, bindspace, 'dial'),
]
assert events == [
'bindspace-enter',
'wg-outer-enter',
'wg-inner-enter',
'yield',
'wg-inner-exit',
'wg-outer-exit',
'bindspace-exit',
]

View File

@ -45,8 +45,13 @@ from ._tunnel import (
TunnelledAddress as TunnelledAddress, TunnelledAddress as TunnelledAddress,
TunnelSpec as TunnelSpec, TunnelSpec as TunnelSpec,
WGTunnelSpec as WGTunnelSpec, WGTunnelSpec as WGTunnelSpec,
WGInterfaceConfig as WGInterfaceConfig,
WGPeerConfig as WGPeerConfig,
WGRole as WGRole,
mb_pubkey as mb_pubkey, mb_pubkey as mb_pubkey,
mk_wg_maddr as mk_wg_maddr, mk_wg_maddr as mk_wg_maddr,
open_wg_bindspace as open_wg_bindspace,
open_wg_iface as open_wg_iface,
parse_wg_maddr as parse_wg_maddr, parse_wg_maddr as parse_wg_maddr,
read_wg_peers as read_wg_peers, read_wg_peers as read_wg_peers,
read_wg_pubkey as read_wg_pubkey, read_wg_pubkey as read_wg_pubkey,

View File

@ -67,12 +67,23 @@ Unwrap at the parse or bindspace boundary; see `.overlay` and
''' '''
from __future__ import annotations from __future__ import annotations
from collections.abc import (
AsyncIterator,
Sequence,
)
import base64 import base64
from contextlib import (
AsyncExitStack,
asynccontextmanager as acm,
)
import ipaddress import ipaddress
import sys import sys
from typing import ( from typing import (
Any, Any,
ClassVar, ClassVar,
get_args,
Literal,
TYPE_CHECKING, TYPE_CHECKING,
) )
@ -83,6 +94,13 @@ from multiaddr.exceptions import ProtocolNotFoundError
from multiaddr.protocols import protocol_with_name from multiaddr.protocols import protocol_with_name
import trio import trio
from ..msg._local import ProcessLocal
from ._bindspace import (
BindspaceHandle,
BindspaceSpec,
open_bindspace,
)
if TYPE_CHECKING: if TYPE_CHECKING:
from ._addr import ( from ._addr import (
Address, Address,
@ -117,9 +135,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'
@ -195,6 +210,478 @@ def _wg8_key_str(
return key 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: str
port: int
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 '
f'`1..65535`, not {port!r}!'
)
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 '
f'`0..65535` or `None`, not {keepalive!r}!'
)
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,
):
'''
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, ...] = ()
listen_port: int|None = None
peers: tuple[WGPeerConfig, ...] = ()
def __post_init__(self) -> None:
'''
Validate private identity, local CIDRs and peer uniqueness.
'''
_wg8_key_str(self.private_key)
# Validate each local CIDR; no address is selected.
address: str
for address in self.addresses:
ipaddress.ip_interface(address)
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 '
f'`1..65535` or `None`, not {listen_port!r}!'
)
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.
'''
return (
f'{type(self).__name__}('
f'private_key=<redacted>, '
f'addresses={self.addresses!r}, '
f'listen_port={self.listen_port!r}, '
f'peers={self.peers!r})'
)
WGRole = Literal['listen', 'dial']
def _wg_iface_settings(
spec: WGTunnelSpec,
config: WGInterfaceConfig,
role: WGRole,
) -> tuple[int|None, tuple[dict[str, object], ...]]:
'''
Validate role policy and build pyroute2 WireGuard settings.
'''
if role not in get_args(WGRole):
raise ValueError(
f'Unsupported WireGuard role: {role!r}'
)
listen_port: int|None = config.listen_port
bearer: tuple[str, int]|None = spec.bearer
if (
role == 'listen'
and
bearer is not None
):
bearer_port: int = bearer[1]
if (
listen_port is not None
and
listen_port != bearer_port
):
raise ValueError(
f'`WGInterfaceConfig.listen_port={listen_port!r}` '
f'conflicts with bearer port {bearer_port!r}!'
)
listen_port = bearer_port
selected_peer: bool = False
peer_settings: list[dict[str, object]] = []
peer: WGPeerConfig
for peer in config.peers:
endpoint: tuple[str, int]|None = peer.endpoint
if (
role == 'dial'
and
peer.public_key == spec.peer_pubkey
):
selected_peer = True
if (
endpoint is not None
and
bearer is not None
and
endpoint != bearer
):
raise ValueError(
f'`WGPeerConfig.endpoint={endpoint!r}` conflicts '
f'with `WGTunnelSpec.bearer={bearer!r}`!'
)
endpoint = endpoint or bearer
values: dict[str, object] = {
'public_key': peer.public_key,
}
if peer.allowed_ips:
values['allowed_ips'] = list(peer.allowed_ips)
values['replace_allowed_ips'] = True
if endpoint is not None:
values['endpoint_addr'] = endpoint[0]
values['endpoint_port'] = endpoint[1]
if peer.preshared_key is not None:
values['preshared_key'] = peer.preshared_key
if peer.persistent_keepalive is not None:
values['persistent_keepalive'] = (
peer.persistent_keepalive
)
peer_settings.append(values)
if (
role == 'dial'
and
not selected_peer
):
configured_keys: tuple[str, ...] = tuple(
peer.public_key
for peer in config.peers
)
raise ValueError(
f'Dial target {spec.peer_pubkey!r} is not in '
f'configured peer keys {configured_keys!r}!'
)
return listen_port, tuple(peer_settings)
def _sync_create_wg_iface(
spec: WGTunnelSpec,
config: WGInterfaceConfig,
bindspace: BindspaceHandle,
listen_port: int|None,
peers: tuple[dict[str, object], ...],
) -> None:
'''
Create and configure one WireGuard iface through pyroute2.
'''
try:
from pyroute2 import (
IPRoute,
WireGuard,
)
except ImportError as exc:
raise RuntimeError(
'WireGuard provisioning requires the '
'`tractor[wg]` extra.'
) from exc
namespace_fd: int|None = bindspace.namespace_fd
ipr: Any = IPRoute(
netns=namespace_fd,
flags=0,
)
created: bool = False
try:
ipr.link(
'add',
ifname=spec.iface,
kind='wireguard',
)
created = True
indices: list[int] = ipr.link_lookup(
ifname=spec.iface,
)
if len(indices) != 1:
raise RuntimeError(
f'Expected one index for WG iface {spec.iface!r}, '
f'got {indices!r}!'
)
index: int = indices[0]
address: str
for address in config.addresses:
interface: (
ipaddress.IPv4Interface
| ipaddress.IPv6Interface
) = ipaddress.ip_interface(address)
ipr.addr(
'add',
index=index,
address=str(interface.ip),
prefixlen=interface.network.prefixlen,
)
wg: Any = WireGuard(
netns=namespace_fd,
flags=0,
)
try:
wg.set(
spec.iface,
private_key=config.private_key,
listen_port=listen_port,
)
peer: dict[str, object]
for peer in peers:
wg.set(
spec.iface,
peer=peer,
)
finally:
wg.close()
ipr.link(
'set',
index=index,
state='up',
)
except BaseException:
if created:
indices = ipr.link_lookup(
ifname=spec.iface,
)
if indices:
ipr.link(
'del',
index=indices[0],
)
raise
finally:
ipr.close()
def _sync_remove_wg_iface(
spec: WGTunnelSpec,
bindspace: BindspaceHandle,
) -> None:
'''
Remove one owned WireGuard iface when it still exists.
'''
try:
from pyroute2 import IPRoute
except ImportError as exc:
raise RuntimeError(
'WireGuard teardown requires the `tractor[wg]` extra.'
) from exc
ipr: Any = IPRoute(
netns=bindspace.namespace_fd,
flags=0,
)
try:
indices: list[int] = ipr.link_lookup(
ifname=spec.iface,
)
if indices:
ipr.link(
'del',
index=indices[0],
)
finally:
ipr.close()
@acm
async def open_wg_iface(
spec: WGTunnelSpec,
config: WGInterfaceConfig,
bindspace: BindspaceHandle,
role: WGRole,
) -> AsyncIterator[WGTunnelSpec]:
'''
Create, configure and own one WireGuard interface.
'''
listen_port: int|None
peers: tuple[dict[str, object], ...]
listen_port, peers = _wg_iface_settings(
spec,
config,
role,
)
created: bool = False
try:
with trio.CancelScope(shield=True):
await trio.to_thread.run_sync(
_sync_create_wg_iface,
spec,
config,
bindspace,
listen_port,
peers,
abandon_on_cancel=False,
)
created = True
yield spec
finally:
if created:
with trio.CancelScope(shield=True):
await trio.to_thread.run_sync(
_sync_remove_wg_iface,
spec,
bindspace,
abandon_on_cancel=False,
)
@acm
async def open_wg_bindspace(
bindspace_spec: BindspaceSpec,
layers: Sequence[tuple[WGTunnelSpec, WGInterfaceConfig]],
role: WGRole,
) -> AsyncIterator[BindspaceHandle]:
'''
Open one bindspace and its ordered WireGuard interface stack.
`layers` is an interface stack declared outermost first:
application scope
|
layers[-1] <- last entered, first exited
|
...
|
layers[0] <- first entered, last exited
|
bindspace
`AsyncExitStack` builds it bottom-up in declaration order and
unwinds it top-down before the bindspace closes.
'''
layer_stack: tuple[
tuple[WGTunnelSpec, WGInterfaceConfig],
...,
] = tuple(layers)
async with AsyncExitStack() as stack:
bindspace: BindspaceHandle = await (
stack.enter_async_context(
open_bindspace(bindspace_spec)
)
)
layer: tuple[WGTunnelSpec, WGInterfaceConfig]
for layer in layer_stack:
tunnel_spec: WGTunnelSpec
config: WGInterfaceConfig
tunnel_spec, config = layer
await stack.enter_async_context(
open_wg_iface(
tunnel_spec,
config,
bindspace,
role,
)
)
yield bindspace
def _sync_read_wg_keys( def _sync_read_wg_keys(
iface: str, iface: str,
netns: str|None, netns: str|None,