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
|
||||
async def open_wg_iface(
|
||||
spec: WGTunnelSpec,
|
||||
config: WGInterfaceConfig,
|
||||
bindspace: BindspaceHandle,
|
||||
role: Literal['listen', 'dial'],
|
||||
) -> AsyncGenerator[WGTunnelSpec, None]: ...
|
||||
```
|
||||
|
||||
`WGInterfaceConfig` and each `WGPeerConfig` are process-local and
|
||||
rejected by the global `ProcessLocal` wire guard. The interface config
|
||||
owns its private key, local addresses and listen port; each peer owns
|
||||
its public key, allowed CIDRs, optional endpoint, preshared key and
|
||||
keepalive. Reprs redact private/preshared keys. `WGTunnelSpec` remains
|
||||
serializable public maddr-derived identity/endpoint data. This split
|
||||
supports multi-peer listeners without overloading the tunnel maddr.
|
||||
|
||||
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
|
||||
and a driver that folds a list of specs into nested contexts
|
||||
(`contextlib.AsyncExitStack` for the N-deep case). The
|
||||
`parse_endpoints()` API (`_multiaddr.py:153`) is the front door:
|
||||
it already returns
|
||||
`dict[name, list[Address|TunnelledAddress]]` and the
|
||||
|
|
@ -539,14 +522,6 @@ it already returns
|
|||
`dict[str, list[Address]]|dict[...]` return for tunnelled
|
||||
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
|
||||
`open_wg_iface()`; do not infer it from maddr shape. Bindspace
|
||||
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,
|
||||
TunnelSpec as TunnelSpec,
|
||||
WGTunnelSpec as WGTunnelSpec,
|
||||
WGInterfaceConfig as WGInterfaceConfig,
|
||||
WGPeerConfig as WGPeerConfig,
|
||||
WGRole as WGRole,
|
||||
mb_pubkey as mb_pubkey,
|
||||
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,
|
||||
read_wg_peers as read_wg_peers,
|
||||
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 collections.abc import (
|
||||
AsyncIterator,
|
||||
Sequence,
|
||||
)
|
||||
import base64
|
||||
from contextlib import (
|
||||
AsyncExitStack,
|
||||
asynccontextmanager as acm,
|
||||
)
|
||||
import ipaddress
|
||||
import sys
|
||||
from typing import (
|
||||
Any,
|
||||
ClassVar,
|
||||
get_args,
|
||||
Literal,
|
||||
TYPE_CHECKING,
|
||||
)
|
||||
|
||||
|
|
@ -94,13 +83,6 @@ from multiaddr.exceptions import ProtocolNotFoundError
|
|||
from multiaddr.protocols import protocol_with_name
|
||||
import trio
|
||||
|
||||
from ..msg._local import ProcessLocal
|
||||
from ._bindspace import (
|
||||
BindspaceHandle,
|
||||
BindspaceSpec,
|
||||
open_bindspace,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ._addr import (
|
||||
Address,
|
||||
|
|
@ -135,6 +117,9 @@ class WGTunnelSpec(
|
|||
iface: str = 'wg0'
|
||||
netns: str|None = None
|
||||
|
||||
# layer-C-only fields, unset in layer A
|
||||
maybe_allowed_ips: tuple[str, ...] = ()
|
||||
|
||||
# the `multiaddr` proto name for this tunnel kind
|
||||
tunnel_key: ClassVar[str] = 'wg'
|
||||
|
||||
|
|
@ -210,478 +195,6 @@ def _wg8_key_str(
|
|||
return key
|
||||
|
||||
|
||||
class WGPeerConfig(
|
||||
ProcessLocal,
|
||||
):
|
||||
'''
|
||||
Process-local configuration for one WireGuard peer.
|
||||
|
||||
'''
|
||||
public_key: str
|
||||
allowed_ips: tuple[str, ...] = ()
|
||||
endpoint: tuple[str, int]|None = None
|
||||
preshared_key: str|None = None
|
||||
persistent_keepalive: int|None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
'''
|
||||
Validate peer identity, routes, endpoint and secret policy.
|
||||
|
||||
'''
|
||||
_wg8_key_str(self.public_key)
|
||||
if self.preshared_key is not None:
|
||||
_wg8_key_str(self.preshared_key)
|
||||
|
||||
# Validate each route. `strict=False` accepts host bits;
|
||||
# pyroute2 will still receive each original declared string.
|
||||
allowed_ip: str
|
||||
for allowed_ip in self.allowed_ips:
|
||||
ipaddress.ip_network(
|
||||
allowed_ip,
|
||||
strict=False,
|
||||
)
|
||||
|
||||
endpoint: tuple[str, int]|None = self.endpoint
|
||||
if endpoint is not None:
|
||||
host: 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(
|
||||
iface: str,
|
||||
netns: str|None,
|
||||
|
|
|
|||
Loading…
Reference in New Issue