Compare commits
No commits in common. "d130431ca1981429c45e3c9d738cd4408e84211e" and "b973e78c2aa070f4e3f2334b261b9b37a3da76f8" have entirely different histories.
d130431ca1
...
b973e78c2a
|
|
@ -1,42 +0,0 @@
|
||||||
---
|
|
||||||
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.
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
---
|
|
||||||
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.
|
|
||||||
|
|
@ -1,37 +0,0 @@
|
||||||
---
|
|
||||||
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.
|
|
||||||
|
|
@ -1,27 +0,0 @@
|
||||||
---
|
|
||||||
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.
|
|
||||||
|
|
@ -1,42 +0,0 @@
|
||||||
---
|
|
||||||
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.
|
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
---
|
|
||||||
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.
|
|
||||||
|
|
@ -1,47 +0,0 @@
|
||||||
---
|
|
||||||
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.
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
---
|
|
||||||
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.
|
|
||||||
|
|
@ -508,30 +508,13 @@ 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` and each `WGPeerConfig` are process-local and
|
and a driver that folds a list of specs into nested contexts
|
||||||
rejected by the global `ProcessLocal` wire guard. The interface config
|
(`contextlib.AsyncExitStack` for the N-deep case). The
|
||||||
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
|
||||||
|
|
@ -539,14 +522,6 @@ 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
|
||||||
|
|
|
||||||
|
|
@ -1,163 +0,0 @@
|
||||||
'''
|
|
||||||
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]
|
|
||||||
|
|
@ -1,378 +0,0 @@
|
||||||
'''
|
|
||||||
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',
|
|
||||||
]
|
|
||||||
|
|
@ -45,13 +45,8 @@ 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,
|
||||||
|
|
|
||||||
|
|
@ -67,23 +67,12 @@ 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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -94,13 +83,6 @@ 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,
|
||||||
|
|
@ -135,6 +117,9 @@ 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'
|
||||||
|
|
||||||
|
|
@ -210,478 +195,6 @@ 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,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue