671 lines
18 KiB
Python
671 lines
18 KiB
Python
# tractor: structured concurrent "actors".
|
|
# Copyright 2018-eternity Tyler Goodlet.
|
|
|
|
# This program is free software: you can redistribute it and/or modify
|
|
# it under the terms of the GNU Affero General Public License as published by
|
|
# the Free Software Foundation, either version 3 of the License, or
|
|
# (at your option) any later version.
|
|
|
|
# This program is distributed in the hope that it will be useful,
|
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
# GNU Affero General Public License for more details.
|
|
|
|
# You should have received a copy of the GNU Affero General Public License
|
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
r'''
|
|
Tunnelled addresses: an `Address` that rides *inside* a tunnel.
|
|
|
|
A tunnel (`wg`, and later plain ip-in-udp, `veth`-in-netns, ..) is
|
|
**not** a `MsgTransport`. Its data plane is transparent to the
|
|
application's `socket(2)`, so it never gets its own entry in
|
|
`._addr._address_types` nor a `MsgpackTransport` impl. Instead it
|
|
*annotates* an existing L4 addr, and this module carries that
|
|
annotation beside it.
|
|
|
|
That does not mean tractor can never provision the tunnel. Layer A
|
|
assumes an externally configured iface; a later bindspace lifecycle
|
|
may create its iface, netns, routes, and kernel-owned UDP listener
|
|
through netlink/`pyroute2`. The distinction is that this
|
|
control-plane work does not turn the bearer into an application
|
|
`Endpoint`.
|
|
|
|
Naming follows `py-multiaddr`'s encapsulation model, where earlier
|
|
maddr segs wrap later ones (`.encapsulate()` appends):
|
|
|
|
/ip4/192.168.1.50/udp/51820/wg/u<key>/ip4/10.0.11.1/tcp/1616
|
|
\_______ bearer __________/\__ key __/\______ overlay ______/
|
|
|
|
- **bearer**: the underlay ep the tunnel iface listens on
|
|
(`wg(8)`'s `ListenPort`). The kernel owns this data-plane socket;
|
|
tractor may later provision it through a bindspace lifecycle but
|
|
never treats it as a `MsgTransport` listener.
|
|
- **overlay**: the ep `tractor` actually binds/dials, i.e. the
|
|
application IPC endpoint handled by `Endpoint`/`MsgTransport`.
|
|
|
|
We avoid `inner`/`outer` deliberately: in a *call* stack "inner"
|
|
reads as higher-up and later-called, whereas here the
|
|
encapsulated addr is bound *first* and sits deeper in the maddr.
|
|
|
|
XXX XXX READ THIS BEFORE USING XXX XXX
|
|
--------------------------------------
|
|
A `TunnelledAddress` **must be unwrapped to `.overlay` before it
|
|
reaches `Endpoint`**. `Endpoint.start_listener()` resolves its
|
|
listener fns by `inspect.getmodule(self.addr)`, so a wrapper
|
|
would resolve to *this* module rather than the transport's and
|
|
silently fail to find `start_listener()`.
|
|
|
|
If a wrapper reaches `Endpoint`, its backend lookup resolves this
|
|
module instead of the overlay transport module:
|
|
|
|
tpt_mod = inspect.getmodule(self.addr)
|
|
await tpt_mod.start_listener(addr=self.addr)
|
|
|
|
This module intentionally does not impersonate that transport API.
|
|
Unwrap at the parse or bindspace boundary; see `.overlay` and
|
|
`strip_tunnels()`.
|
|
|
|
'''
|
|
from __future__ import annotations
|
|
import base64
|
|
import ipaddress
|
|
import sys
|
|
from typing import (
|
|
Any,
|
|
ClassVar,
|
|
TYPE_CHECKING,
|
|
)
|
|
|
|
import msgspec
|
|
import multibase
|
|
import trio
|
|
|
|
if TYPE_CHECKING:
|
|
from multiaddr import Multiaddr
|
|
|
|
from ._addr import (
|
|
Address,
|
|
UnwrappedAddress,
|
|
)
|
|
else:
|
|
Address = Any
|
|
Multiaddr = Any
|
|
UnwrappedAddress = Any
|
|
|
|
|
|
class WGTunnelSpec(
|
|
msgspec.Struct,
|
|
frozen=True,
|
|
):
|
|
'''
|
|
The `wg`-specific half of a tunnel annotation.
|
|
|
|
Everything here is an *interface-layer* concern owned by
|
|
`wg(8)`/the kernel. A later tractor bindspace lifecycle may
|
|
provision it through netlink, but it is never an application
|
|
`MsgTransport` endpoint.
|
|
|
|
'''
|
|
# tunnel peer pubkey in the std-base64 `wg(8)` form, i.e.
|
|
# directly comparable to `wg show <if> peers` output
|
|
peer_pubkey: str
|
|
|
|
# the underlay `(ip, udp-port)` the wg iface listens on, i.e.
|
|
# wg's `ListenPort`. The kernel owns the socket even when a
|
|
# tractor bindspace lifecycle provisions it. `None` when the
|
|
# maddr declared only a key (identity) and the bearer is
|
|
# implied by local cfg.
|
|
bearer: tuple[str, int]|None = None
|
|
|
|
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'
|
|
|
|
|
|
# the tunnel-spec union; grows as new tunnel kinds land
|
|
# (plain ip-in-udp, `veth`-in-netns, ..)
|
|
TunnelSpec = WGTunnelSpec
|
|
|
|
|
|
def mb_pubkey(
|
|
wg8_key: str,
|
|
) -> str:
|
|
'''
|
|
Encode a `wg(8)` public key as multibase base64url.
|
|
|
|
WireGuard public keys are exactly 32 bytes. Enforce that here
|
|
before handing the `u`-prefixed result to `py-multiaddr`'s
|
|
`/wg/` codec.
|
|
|
|
'''
|
|
raw: bytes = base64.b64decode(
|
|
wg8_key,
|
|
validate=True,
|
|
)
|
|
if (nbytes := len(raw)) != 32:
|
|
raise ValueError(
|
|
f'A `wg` public key must decode to 32 bytes, '
|
|
f'not {nbytes}!'
|
|
)
|
|
|
|
return multibase.encode(
|
|
'base64url',
|
|
raw,
|
|
).decode('ascii')
|
|
|
|
|
|
def wg8_pubkey(
|
|
mb_key: str,
|
|
) -> str:
|
|
'''
|
|
Decode a multibase public key to `wg(8)` standard base64.
|
|
|
|
'''
|
|
raw: bytes = multibase.decode(mb_key)
|
|
if (nbytes := len(raw)) != 32:
|
|
raise ValueError(
|
|
f'A `wg` public key must decode to 32 bytes, '
|
|
f'not {nbytes}!'
|
|
)
|
|
|
|
return base64.b64encode(raw).decode('ascii')
|
|
|
|
|
|
def _wg8_key_str(
|
|
value: bytes|str,
|
|
) -> str:
|
|
'''
|
|
Validate and normalize one pyroute2-decoded WireGuard key.
|
|
|
|
'''
|
|
if isinstance(value, bytes):
|
|
try:
|
|
key: str = value.decode('ascii')
|
|
except UnicodeDecodeError as exc:
|
|
raise ValueError(
|
|
'WireGuard key is not base64 ASCII!'
|
|
) from exc
|
|
else:
|
|
key = value
|
|
|
|
# Reuse `mb_pubkey()`'s strict base64 + 32-byte validation.
|
|
mb_pubkey(key)
|
|
return key
|
|
|
|
|
|
def _sync_read_wg_keys(
|
|
iface: str,
|
|
netns: str|None,
|
|
) -> tuple[str, tuple[str, ...]]:
|
|
'''
|
|
Read one WireGuard device using pyroute2's synchronous API.
|
|
|
|
This whole function runs in a worker thread because pyroute2's
|
|
synchronous netlink API owns a private asyncio loop.
|
|
|
|
'''
|
|
if sys.platform != 'linux':
|
|
raise NotImplementedError(
|
|
'WireGuard netlink inspection is Linux-only!'
|
|
)
|
|
|
|
try:
|
|
from pyroute2 import WireGuard
|
|
except ImportError as exc:
|
|
raise RuntimeError(
|
|
'WireGuard inspection requires the `tractor[wg]` extra.'
|
|
) from exc
|
|
|
|
# Pyroute2 defaults namespace flags to `os.O_CREAT`; a read must
|
|
# never create a missing namespace as a side effect.
|
|
wg: Any = WireGuard(
|
|
netns=netns,
|
|
flags=0,
|
|
)
|
|
try:
|
|
infos: tuple[Any, ...] = tuple(wg.info(iface))
|
|
finally:
|
|
wg.close()
|
|
|
|
pubkey: str|None = None
|
|
peers: list[str] = []
|
|
info: Any
|
|
for info in infos:
|
|
raw_pubkey: Any
|
|
if raw_pubkey := info.get_attr(
|
|
'WGDEVICE_A_PUBLIC_KEY'
|
|
):
|
|
next_pubkey: str = _wg8_key_str(raw_pubkey)
|
|
if (
|
|
pubkey is not None
|
|
and
|
|
pubkey != next_pubkey
|
|
):
|
|
raise RuntimeError(
|
|
f'Conflicting public keys returned for '
|
|
f'{iface!r}!'
|
|
)
|
|
pubkey = next_pubkey
|
|
|
|
peer: Any
|
|
for peer in (
|
|
info.get_attr('WGDEVICE_A_PEERS')
|
|
or ()
|
|
):
|
|
raw_peer: Any
|
|
if raw_peer := peer.get_attr(
|
|
'WGPEER_A_PUBLIC_KEY'
|
|
):
|
|
peers.append(_wg8_key_str(raw_peer))
|
|
|
|
if pubkey is None:
|
|
raise RuntimeError(
|
|
f'No public key returned for WireGuard iface '
|
|
f'{iface!r}!'
|
|
)
|
|
|
|
return (
|
|
pubkey,
|
|
tuple(dict.fromkeys(peers)),
|
|
)
|
|
|
|
|
|
async def _read_wg_keys(
|
|
iface: str,
|
|
netns: str|None,
|
|
) -> tuple[str, tuple[str, ...]]:
|
|
'''
|
|
Read one WireGuard key snapshot without blocking Trio.
|
|
|
|
'''
|
|
return await trio.to_thread.run_sync(
|
|
_sync_read_wg_keys,
|
|
iface,
|
|
netns,
|
|
abandon_on_cancel=False,
|
|
)
|
|
|
|
|
|
async def read_wg_pubkey(
|
|
iface: str = 'wg0',
|
|
netns: str|None = None,
|
|
) -> str:
|
|
'''
|
|
Read a WireGuard interface's public key through netlink.
|
|
|
|
'''
|
|
keys: tuple[
|
|
str,
|
|
tuple[str, ...],
|
|
] = await _read_wg_keys(
|
|
iface,
|
|
netns,
|
|
)
|
|
return keys[0]
|
|
|
|
|
|
async def read_wg_peers(
|
|
iface: str = 'wg0',
|
|
netns: str|None = None,
|
|
) -> tuple[str, ...]:
|
|
'''
|
|
Read configured peer public keys through netlink.
|
|
|
|
'''
|
|
keys: tuple[
|
|
str,
|
|
tuple[str, ...],
|
|
] = await _read_wg_keys(
|
|
iface,
|
|
netns,
|
|
)
|
|
return keys[1]
|
|
|
|
|
|
async def verify_wg_peer(
|
|
spec: WGTunnelSpec,
|
|
) -> bool:
|
|
'''
|
|
Verify a declared WireGuard identity against local kernel state.
|
|
|
|
A source/listen maddr names the local interface key, while a
|
|
destination/dial maddr names one configured peer. Accept either
|
|
match without making verification an implicit part of parsing.
|
|
|
|
'''
|
|
declared_key: str = _wg8_key_str(spec.peer_pubkey)
|
|
keys: tuple[
|
|
str,
|
|
tuple[str, ...],
|
|
] = await _read_wg_keys(
|
|
spec.iface,
|
|
spec.netns,
|
|
)
|
|
return (
|
|
declared_key == keys[0]
|
|
or
|
|
declared_key in keys[1]
|
|
)
|
|
|
|
|
|
def _wg_proto_code() -> int:
|
|
'''
|
|
Deliver the installed `py-multiaddr` `/wg/` protocol code.
|
|
|
|
`wg` support is merged upstream but not yet in a release, so
|
|
fail clearly when tractor was installed without the pinned rev.
|
|
|
|
'''
|
|
from multiaddr.exceptions import ProtocolNotFoundError
|
|
from multiaddr.protocols import protocol_with_name
|
|
|
|
try:
|
|
return protocol_with_name('wg').code
|
|
except ProtocolNotFoundError as exc:
|
|
raise RuntimeError(
|
|
'Installed `py-multiaddr` has no `/wg/` protocol!\n'
|
|
'Install py-multiaddr#108 or use tractor\'s pinned '
|
|
'dependency revision.\n'
|
|
) from exc
|
|
|
|
|
|
class TunnelledAddress(
|
|
msgspec.Struct,
|
|
frozen=True,
|
|
):
|
|
'''
|
|
An `Address` annotated with the tunnel it must be reached
|
|
*through*.
|
|
|
|
Address-level properties delegate to `.overlay`, so proto-key
|
|
guards and `.unwrap()` retain their existing meaning and
|
|
**nothing new crosses the wire**. Transport boundaries which
|
|
dispatch on exact type or declaring module must first call
|
|
`strip_tunnels()`.
|
|
|
|
'''
|
|
overlay: Address|TunnelledAddress
|
|
tunnel: TunnelSpec
|
|
|
|
# ---- delegated, so the runtime can't tell the difference ----
|
|
|
|
@property
|
|
def proto_key(self) -> str:
|
|
'''
|
|
The *overlay's* proto-key — a tunnel has no transport of
|
|
its own.
|
|
|
|
NOTE, this is a property whereas `Address.proto_key` is
|
|
spec'd as a `ClassVar`. That's deliberate: the value is
|
|
only knowable per-instance here, and this type is never
|
|
registered in `_address_types`, so no class-level access
|
|
of it should ever occur.
|
|
|
|
'''
|
|
return self.overlay.proto_key
|
|
|
|
@property
|
|
def is_valid(self) -> bool:
|
|
return self.overlay.is_valid
|
|
|
|
@property
|
|
def bindspace(self) -> str:
|
|
return self.overlay.bindspace
|
|
|
|
def unwrap(self) -> UnwrappedAddress:
|
|
'''
|
|
Delegate to `.overlay`, so the tunnel annotation is
|
|
**not** serialized and no peer needs to understand it.
|
|
|
|
'''
|
|
return self.overlay.unwrap()
|
|
|
|
# ---- the tunnel's own contribution ----
|
|
|
|
@property
|
|
def namespace(self) -> tuple[str, str|int]|None:
|
|
'''
|
|
The tunnel's netns, when it declares one.
|
|
|
|
'''
|
|
if (netns := self.tunnel.netns) is None:
|
|
return self.overlay.namespace
|
|
|
|
return ('netns', netns)
|
|
|
|
def __repr__(self) -> str:
|
|
return (
|
|
f'{type(self).__name__}(\n'
|
|
f' overlay={self.overlay!r},\n'
|
|
f' via={self.tunnel.tunnel_key!r} '
|
|
f'iface={self.tunnel.iface!r},\n'
|
|
f')'
|
|
)
|
|
|
|
|
|
def _wg_bearer(
|
|
bearer_ma: Multiaddr,
|
|
source_ma: Multiaddr,
|
|
) -> tuple[str, int]:
|
|
'''
|
|
Parse one kernel-owned `wg` bearer endpoint.
|
|
|
|
'''
|
|
proto_names: list[str] = [
|
|
proto.name
|
|
for proto in bearer_ma.protocols()
|
|
]
|
|
match proto_names:
|
|
case [('ip4' | 'ip6') as ip_proto, 'udp']:
|
|
return (
|
|
bearer_ma.value_for_protocol(ip_proto),
|
|
int(bearer_ma.value_for_protocol('udp')),
|
|
)
|
|
|
|
case _:
|
|
raise ValueError(
|
|
f'Bad `wg` bearer, expected '
|
|
f'`/ip4|ip6/<host>/udp/<port>`\n'
|
|
f'got: {bearer_ma}\n'
|
|
f'from maddr: {source_ma}\n'
|
|
)
|
|
|
|
|
|
def parse_wg_maddr(
|
|
maddr: str|Multiaddr,
|
|
) -> TunnelledAddress:
|
|
'''
|
|
Parse a `wg` maddr stack into nested tunnel annotations.
|
|
|
|
Pure: every segment operation delegates to `py-multiaddr`.
|
|
Repeated `.decapsulate_code()` calls peel the last `/wg/`
|
|
first, while `.split()` and `.join()` isolate that tunnel's
|
|
bearer without parsing slash-delimited strings ourselves.
|
|
|
|
'''
|
|
from multiaddr import Multiaddr
|
|
|
|
ma: Multiaddr = (
|
|
maddr
|
|
if isinstance(maddr, Multiaddr)
|
|
else Multiaddr(maddr)
|
|
)
|
|
wg_code: int = _wg_proto_code()
|
|
segs: list[Multiaddr] = ma.split()
|
|
proto_names: list[str] = [
|
|
proto.name
|
|
for seg in segs
|
|
for proto in seg.protocols()
|
|
]
|
|
if 'wg' not in proto_names:
|
|
raise ValueError(
|
|
f'Not a `wg`-tunnelled maddr; no `/wg/` segment!\n'
|
|
f'maddr: {ma}\n'
|
|
)
|
|
|
|
final_wg_i: int = len(proto_names) - 1
|
|
final_wg_i -= proto_names[::-1].index('wg')
|
|
overlay_ma: Multiaddr = Multiaddr.join(
|
|
*segs[final_wg_i + 1:]
|
|
)
|
|
overlay_names: list[str] = [
|
|
proto.name
|
|
for proto in overlay_ma.protocols()
|
|
]
|
|
match overlay_names:
|
|
case [('ip4' | 'ip6'), 'tcp']:
|
|
from ._multiaddr import parse_maddr
|
|
overlay: Address|TunnelledAddress = parse_maddr(
|
|
str(overlay_ma)
|
|
)
|
|
|
|
case []:
|
|
raise ValueError(
|
|
f'`wg` maddr declares no overlay endpoint!\n'
|
|
f'Append the endpoint tractor should bind.\n'
|
|
f'maddr: {ma}\n'
|
|
)
|
|
|
|
case _:
|
|
raise ValueError(
|
|
f'Unsupported `wg` overlay protocol combo: '
|
|
f'{overlay_names!r}\n'
|
|
f'overlay: {overlay_ma}\n'
|
|
f'from maddr: {ma}\n'
|
|
)
|
|
|
|
cursor: Multiaddr = ma
|
|
while any(
|
|
proto.name == 'wg'
|
|
for proto in cursor.protocols()
|
|
):
|
|
cursor_segs: list[Multiaddr] = cursor.split()
|
|
cursor_names: list[str] = [
|
|
proto.name
|
|
for seg in cursor_segs
|
|
for proto in seg.protocols()
|
|
]
|
|
wg_i: int = len(cursor_names) - 1
|
|
wg_i -= cursor_names[::-1].index('wg')
|
|
mb_key: str = cursor_segs[wg_i].value_for_protocol('wg')
|
|
|
|
bearer_prefix: Multiaddr = cursor.decapsulate_code(
|
|
wg_code
|
|
)
|
|
prefix_segs: list[Multiaddr] = bearer_prefix.split()
|
|
prefix_names: list[str] = [
|
|
proto.name
|
|
for seg in prefix_segs
|
|
for proto in seg.protocols()
|
|
]
|
|
prior_wg_i: int = (
|
|
len(prefix_names) - 1
|
|
- prefix_names[::-1].index('wg')
|
|
if 'wg' in prefix_names
|
|
else -1
|
|
)
|
|
bearer_ma: Multiaddr = Multiaddr.join(
|
|
*prefix_segs[prior_wg_i + 1:]
|
|
)
|
|
overlay = TunnelledAddress(
|
|
overlay=overlay,
|
|
tunnel=WGTunnelSpec(
|
|
peer_pubkey=wg8_pubkey(mb_key),
|
|
bearer=_wg_bearer(bearer_ma, ma),
|
|
),
|
|
)
|
|
cursor = bearer_prefix
|
|
|
|
return overlay
|
|
|
|
|
|
def mk_wg_maddr(
|
|
addr: TunnelledAddress,
|
|
) -> Multiaddr:
|
|
'''
|
|
Compose nested tunnel annotations as a canonical `wg` maddr.
|
|
|
|
Only the peer key and bearer have maddr representations. Local
|
|
interface, namespace, and allowed-IP config remains local.
|
|
|
|
'''
|
|
from multiaddr import Multiaddr
|
|
|
|
_wg_proto_code()
|
|
if (bearer := addr.tunnel.bearer) is None:
|
|
raise ValueError(
|
|
f'Can not compose a `wg` maddr without a bearer!\n'
|
|
f'tunnel: {addr.tunnel!r}\n'
|
|
)
|
|
|
|
bindable: Address = strip_tunnels(addr)
|
|
if bindable.proto_key != 'tcp':
|
|
raise ValueError(
|
|
f'Unsupported `wg` overlay proto-key: '
|
|
f'{bindable.proto_key!r}\n'
|
|
f'overlay: {bindable!r}\n'
|
|
)
|
|
|
|
host, port = bearer
|
|
ip = ipaddress.ip_address(host)
|
|
ip_proto: str = (
|
|
'ip4'
|
|
if ip.version == 4
|
|
else 'ip6'
|
|
)
|
|
bearer_ma = Multiaddr(
|
|
f'/{ip_proto}/{host}/udp/{port}'
|
|
)
|
|
key_ma = Multiaddr(
|
|
f'/wg/{mb_pubkey(addr.tunnel.peer_pubkey)}'
|
|
)
|
|
|
|
from ._multiaddr import mk_maddr
|
|
overlay_ma: Multiaddr = mk_maddr(addr.overlay)
|
|
return (
|
|
bearer_ma
|
|
.encapsulate(key_ma)
|
|
.encapsulate(overlay_ma)
|
|
)
|
|
|
|
|
|
def strip_tunnels(
|
|
addr: Address|TunnelledAddress,
|
|
) -> Address:
|
|
'''
|
|
Deliver the bindable `Address`, peeling any tunnel
|
|
annotation(s).
|
|
|
|
Pure. Idempotent on an un-tunnelled `Address`, and loops so
|
|
a nested (tunnel-in-tunnel) stack collapses in one call.
|
|
|
|
Call this at every bind/dial boundary.
|
|
|
|
'''
|
|
while isinstance(addr, TunnelledAddress):
|
|
addr = addr.overlay
|
|
|
|
return addr
|
|
|
|
|
|
def tunnels_of(
|
|
addr: Address|TunnelledAddress,
|
|
) -> tuple[TunnelSpec, ...]:
|
|
'''
|
|
Deliver every tunnel spec wrapping `addr`, outermost first.
|
|
|
|
Pure; empty for an un-tunnelled `Address`.
|
|
|
|
'''
|
|
specs: list[TunnelSpec] = []
|
|
while isinstance(addr, TunnelledAddress):
|
|
specs.append(addr.tunnel)
|
|
addr = addr.overlay
|
|
|
|
return tuple(specs)
|