tractor/tests/discovery/test_wg_iface_lifecycle.py

379 lines
10 KiB
Python

'''
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',
]