Own WireGuard interface lifecycles
Add `open_wg_iface()` to create, configure and remove one WireGuard interface inside a pinned bindspace through pyroute2. Deats, - validate listen/dial bearer policy before kernel side effects - configure local addresses, private key, listen port and peers - fill an omitted dial endpoint from the selected tunnel bearer - clean partial synchronous failures before returning to Trio - shield owned interface creation and teardown from cancellation Prompt-IO: ai/prompt-io/opencode/20260826T003430Z_6dd39da0_prompt_io.md (this patch was generated in some part by `opencode` using `gpt-5.6-sol` (`openai`))wkt/wg_pyroute2_read
parent
1fd857ce21
commit
f7bf068d09
|
|
@ -0,0 +1,42 @@
|
||||||
|
---
|
||||||
|
model: gpt-5.6-sol
|
||||||
|
service: opencode
|
||||||
|
session: tractor-addr-unpacking
|
||||||
|
timestamp: 2026-08-26T00:34:30Z
|
||||||
|
git_ref: 6dd39da0
|
||||||
|
scope: code
|
||||||
|
substantive: true
|
||||||
|
raw_file: 20260826T003430Z_6dd39da0_prompt_io.raw.md
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prompt
|
||||||
|
|
||||||
|
After committing explicit WireGuard peer configuration, the human
|
||||||
|
authorized the next incremental lifecycle change.
|
||||||
|
|
||||||
|
## Response summary
|
||||||
|
|
||||||
|
Added owned WireGuard interface provisioning and teardown through
|
||||||
|
cancellation-shielded pyroute2 worker calls. Role-specific policy is
|
||||||
|
validated before mutation, local addresses and explicit peers are
|
||||||
|
configured, and owned interfaces are removed on exit.
|
||||||
|
Staged review clarified role-specific test assertions, added the
|
||||||
|
second peer's endpoint-port check, included conflicting values in
|
||||||
|
policy errors and aligned annotated assignment with `/py-codestyle`.
|
||||||
|
|
||||||
|
## Files changed
|
||||||
|
|
||||||
|
- `tractor/discovery/_tunnel.py` - role policy and WG iface lifecycle.
|
||||||
|
- `tractor/discovery/__init__.py` - public lifecycle and role exports.
|
||||||
|
- `tests/discovery/test_wg_iface_lifecycle.py` - policy and cancellation
|
||||||
|
coverage with privileged calls faked.
|
||||||
|
- `ai/tpt-backends/03_wg_tunnel_bindspace.md` - initial owned WG iface
|
||||||
|
lifecycle contract.
|
||||||
|
|
||||||
|
## Human edits
|
||||||
|
|
||||||
|
The human selected continuation from explicit peer modeling into the
|
||||||
|
owned WireGuard interface lifecycle. During staged review, the human
|
||||||
|
requested clearer role assertions, complete endpoint checks, concrete
|
||||||
|
error values and inline annotated assignment. The agent applied those
|
||||||
|
human-directed edits; no direct manual source edits were observed.
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
---
|
||||||
|
model: gpt-5.6-sol
|
||||||
|
service: opencode
|
||||||
|
timestamp: 2026-08-26T00:34:30Z
|
||||||
|
git_ref: 6dd39da0
|
||||||
|
diff_cmd: git diff HEAD~1..HEAD
|
||||||
|
---
|
||||||
|
|
||||||
|
# Raw output - own WireGuard interface lifecycle
|
||||||
|
|
||||||
|
After committing explicit peer configuration, the human authorized the
|
||||||
|
next incremental lifecycle change.
|
||||||
|
|
||||||
|
> `git diff HEAD~1..HEAD -- tractor/discovery/_tunnel.py tractor/discovery/__init__.py tests/discovery/test_wg_iface_lifecycle.py ai/tpt-backends/03_wg_tunnel_bindspace.md`
|
||||||
|
|
||||||
|
Added owned async `open_wg_iface()` lifecycle with role-policy
|
||||||
|
validation before side effects. Listen bearer ports configure the local
|
||||||
|
listener; dial bearers fill omitted endpoints only for the selected
|
||||||
|
maddr peer.
|
||||||
|
|
||||||
|
Pyroute2 worker code creates the WireGuard link, assigns local
|
||||||
|
addresses, configures private key/listen port and every explicit peer,
|
||||||
|
raises the link, and removes it on exit. Partial create failures clean
|
||||||
|
up synchronously; async create/remove calls are cancellation-shielded.
|
||||||
|
|
||||||
|
Privileged operations are faked in tests. Ruff and lock checks passed;
|
||||||
|
focused WG lifecycle/config/inspection coverage passed 22 tests. A
|
||||||
|
broad unrelated registrar cancellation parameter timed out after 76
|
||||||
|
passes and 2 xpasses; its isolated rerun passed.
|
||||||
|
|
@ -522,6 +522,14 @@ keepalive. Reprs redact private/preshared keys. `WGTunnelSpec` remains
|
||||||
serializable public maddr-derived identity/endpoint data. This split
|
serializable public maddr-derived identity/endpoint data. This split
|
||||||
supports multi-peer listeners without overloading the tunnel maddr.
|
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.
|
||||||
|
|
||||||
and a driver that folds a list of specs into nested contexts
|
and a driver that folds a list of specs into nested contexts
|
||||||
(`contextlib.AsyncExitStack` for the N-deep case). The
|
(`contextlib.AsyncExitStack` for the N-deep case). The
|
||||||
`parse_endpoints()` API (`_multiaddr.py:153`) is the front door:
|
`parse_endpoints()` API (`_multiaddr.py:153`) is the front door:
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,239 @@
|
||||||
|
'''
|
||||||
|
WireGuard interface policy and owned lifecycle contracts.
|
||||||
|
|
||||||
|
'''
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
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_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']
|
||||||
|
|
@ -47,8 +47,10 @@ from ._tunnel import (
|
||||||
WGTunnelSpec as WGTunnelSpec,
|
WGTunnelSpec as WGTunnelSpec,
|
||||||
WGInterfaceConfig as WGInterfaceConfig,
|
WGInterfaceConfig as WGInterfaceConfig,
|
||||||
WGPeerConfig as WGPeerConfig,
|
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_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,12 +67,16 @@ Unwrap at the parse or bindspace boundary; see `.overlay` and
|
||||||
|
|
||||||
'''
|
'''
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
from collections.abc import AsyncIterator
|
||||||
import base64
|
import base64
|
||||||
|
from contextlib import 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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -89,8 +93,10 @@ if TYPE_CHECKING:
|
||||||
Address,
|
Address,
|
||||||
UnwrappedAddress,
|
UnwrappedAddress,
|
||||||
)
|
)
|
||||||
|
from ._bindspace import BindspaceHandle
|
||||||
else:
|
else:
|
||||||
Address = Any
|
Address = Any
|
||||||
|
BindspaceHandle = Any
|
||||||
Multiaddr = Any
|
Multiaddr = Any
|
||||||
UnwrappedAddress = Any
|
UnwrappedAddress = Any
|
||||||
|
|
||||||
|
|
@ -230,6 +236,8 @@ class WGPeerConfig(
|
||||||
|
|
||||||
endpoint: tuple[str, int]|None = self.endpoint
|
endpoint: tuple[str, int]|None = self.endpoint
|
||||||
if endpoint is not None:
|
if endpoint is not None:
|
||||||
|
host: str
|
||||||
|
port: int
|
||||||
host, port = endpoint
|
host, port = endpoint
|
||||||
ipaddress.ip_address(host)
|
ipaddress.ip_address(host)
|
||||||
if (
|
if (
|
||||||
|
|
@ -239,7 +247,7 @@ class WGPeerConfig(
|
||||||
):
|
):
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
'`WGPeerConfig.endpoint` port must be in '
|
'`WGPeerConfig.endpoint` port must be in '
|
||||||
'`1..65535`!'
|
f'`1..65535`, not {port!r}!'
|
||||||
)
|
)
|
||||||
|
|
||||||
keepalive: int|None = self.persistent_keepalive
|
keepalive: int|None = self.persistent_keepalive
|
||||||
|
|
@ -254,7 +262,7 @@ class WGPeerConfig(
|
||||||
):
|
):
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
'`WGPeerConfig.persistent_keepalive` must be in '
|
'`WGPeerConfig.persistent_keepalive` must be in '
|
||||||
'`0..65535` or `None`!'
|
f'`0..65535` or `None`, not {keepalive!r}!'
|
||||||
)
|
)
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
|
|
@ -317,7 +325,7 @@ class WGInterfaceConfig(
|
||||||
):
|
):
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
'`WGInterfaceConfig.listen_port` must be in '
|
'`WGInterfaceConfig.listen_port` must be in '
|
||||||
'`1..65535` or `None`!'
|
f'`1..65535` or `None`, not {listen_port!r}!'
|
||||||
)
|
)
|
||||||
|
|
||||||
peer_keys: set[str] = set()
|
peer_keys: set[str] = set()
|
||||||
|
|
@ -348,6 +356,272 @@ class WGInterfaceConfig(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
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