Support nested `wg` maddrs

Teach discovery to preserve WireGuard bearer and identity metadata
around a bindable TCP overlay.

Deats,
- encode `wg(8)` keys as strict 32-byte multibase values
- peel nested stacks with `Multiaddr.decapsulate_code()` and compose
  them with `.encapsulate()` instead of splitting strings
- integrate wrappers with `parse_maddr()`, `mk_maddr()`,
  `wrap_address()` and `parse_endpoints()`
- pin the unreleased py-multiaddr#108 codec in package metadata
- cover exact round trips, nesting, bad grammar and missing codecs

Prompt-IO: ai/prompt-io/opencode/20260818T075031Z_dd02c7c0_prompt_io.md

(this patch was generated in some part by `opencode` using `gpt-5.6-sol` (`openai`))
wkt/addr_unpacking
Gud Boi 2026-08-18 23:25:56 -04:00
parent dd02c7c09e
commit 3f675323fa
10 changed files with 698 additions and 27 deletions

View File

@ -0,0 +1,50 @@
---
model: gpt-5.6-sol
service: opencode
session: tractor-addr-unpacking-followup
timestamp: 2026-08-18T07:50:31Z
git_ref: dd02c7c0
scope: code
substantive: true
raw_file: 20260818T075031Z_dd02c7c0_prompt_io.raw.md
---
## Prompt
The human requested the complete tunnelled-maddr parsing/composition
slice as an unattended batch, with every numbered requirement finished
and atomic commit plans prepared at the end. Existing human decisions
required native `multiaddr` encapsulation APIs, no hand-written peeler,
and preservation of tractor's future bindspace lifecycle ownership.
## Response summary
Implemented strict WG key codecs and native single/nested tunnel maddr
parsing and composition, integrated them into discovery APIs, migrated
the multihost example off its duplicate parser, corrected package
dependency metadata, and added focused and end-to-end parser
regressions. Verified the complete tractor suite and built both package
artifacts.
## Files changed
- `tractor/discovery/` - WG codecs, parser/composer, wrapper typing,
public exports, and discovery dispatch.
- `tests/discovery/` - key, grammar, nesting, round-trip, and public
boundary regressions.
- `examples/multihost/wg_lan/` - production parser migration and
updated usage documentation.
- `pyproject.toml`, `uv.lock` - reproducible WG codec and multibase
dependencies for checkout and package installs.
- `ai/tpt-backends/03_wg_tunnel_bindspace.md` - current layer-A state
and future bindspace ownership.
## Human edits
The human selected the five-step scope and batch execution model,
required delegation to `multiaddr`'s encapsulation APIs, rejected
transport-shaped listener placeholders in the prerequisite commit, and
clarified that tractor will eventually provision the kernel-owned
bearer through its bindspace layer. The agent implemented and tested
those decisions; no direct manual source edits were observed during
this batch.

View File

@ -0,0 +1,61 @@
---
model: gpt-5.6-sol
service: opencode
timestamp: 2026-08-18T07:50:31Z
git_ref: dd02c7c0
diff_cmd: git diff HEAD~1..HEAD
---
# Raw output - native WireGuard maddr integration
The human requested completion of the five-step tunnelled-maddr slice:
port the proven WireGuard parser, delegate to `py-multiaddr`'s native
tunnel APIs, integrate public parse and composition entry points, add
regressions, and return atomic commit plans after completing the batch.
## Generated code
> `git diff HEAD~1..HEAD -- tractor/discovery/_tunnel.py tractor/discovery/_multiaddr.py tractor/discovery/_addr.py tractor/discovery/__init__.py`
Added strict WireGuard standard-base64/multibase key codecs and native
WG maddr parsing/composition. Nested stacks peel the last `/wg/`
repeatedly with `.decapsulate_code()`, isolate segments through
`.split()`/`.join()`, and compose recursively with `.encapsulate()`.
Public discovery parsing, wrapping, endpoint-table parsing, and maddr
composition now preserve `TunnelledAddress` metadata locally.
> `git diff HEAD~1..HEAD -- pyproject.toml uv.lock`
Declared `py-multibase` directly and pinned the unreleased
py-multiaddr#108 WG codec revision in distribution metadata. Enabled
Hatch direct-reference metadata so editable, wheel, and sdist builds
use the same dependency contract.
> `git diff HEAD~1..HEAD -- tests/discovery/test_multiaddr.py tests/discovery/test_tunnelled_addr.py`
Added key-codec, single/nested WG round-trip, malformed key, invalid
bearer/overlay, missing codec, missing bearer, `wrap_address()`, and
`parse_endpoints()` coverage while retaining plain TCP/UDS regressions.
> `git diff HEAD~1..HEAD -- examples/multihost/wg_lan ai/tpt-backends/03_wg_tunnel_bindspace.md`
Removed the duplicate example parser, migrated callers to production
`TunnelledAddress`, retained only explicit `wg(8)` verification, and
updated dependency and future bindspace ownership documentation.
## Verification
- focused discovery files: `45 passed`
- discovery and IPC suites: `81 passed, 2 xpassed`
- full suite: `443 passed, 9 skipped, 7 xfailed, 4 xpassed`
- collection: `462 tests`
- Ruff: clean
- sdist and wheel builds: successful
## Review corrections
A review caught that `[tool.uv.sources]` alone would not constrain
built-package consumers to a WG-capable py-multiaddr revision. The
dependency was moved into PEP 621 metadata and the parser now converts
missing-codec failures into an actionable tractor error without
misclassifying Unix paths containing a `wg` directory.

View File

@ -48,7 +48,10 @@ dependencies = [
# typed IPC msging # typed IPC msging
"msgspec>=0.20.0", "msgspec>=0.20.0",
"bidict>=0.23.1", "bidict>=0.23.1",
"multiaddr>=0.2.0", # unreleased `/wg/` codec from py-multiaddr#108
"multiaddr @ git+https://github.com/multiformats/py-multiaddr.git@f86519daaa21699023d0037c58cdff600313dd09",
# encode/decode `wg` pubkeys carried by multiaddrs
"py-multibase>=2.0.0,<3",
"platformdirs>=4.4.0", "platformdirs>=4.4.0",
# per-actor `argv[0]` proc-title for OS-level diag tools # per-actor `argv[0]` proc-title for OS-level diag tools
# (`ps`, `top`, `psutil`-backed tooling like `acli.pytree`). # (`ps`, `top`, `psutil`-backed tooling like `acli.pytree`).
@ -166,17 +169,6 @@ sync_pause = {requires-python = ">=3.13, <3.14"}
# linux kernel networking # linux kernel networking
# 'pyroute2 # 'pyroute2
# XXX TEMP, the `/wg/u<key>` maddr proto is MERGED upstream (in
# py-multiaddr#108, 2026-07-28) but is in NO release yet; the
# latest `0.2.0` (2026-03-17) predates the merge by ~4 months.
# Pinned by `rev` (not `branch`) so CI stays reproducible.
#
# Drop this pin (and bump the `multiaddr` dep floor above) the
# moment a release carries the `wg` codec; the only consumer is
# `examples/multihost/wg_lan/`.
# |_https://github.com/multiformats/py-multiaddr/pull/108
multiaddr = { git = 'https://github.com/multiformats/py-multiaddr.git', rev = 'f86519daaa21699023d0037c58cdff600313dd09' }
# ------ tool.uv.sources ------ # ------ tool.uv.sources ------
[tool.uv] [tool.uv]
@ -191,6 +183,9 @@ python-preference = 'system'
# ------ tool.uv ------ # ------ tool.uv ------
[tool.hatch.metadata]
allow-direct-references = true
[tool.hatch.build.targets.sdist] [tool.hatch.build.targets.sdist]
include = ["tractor"] include = ["tractor"]

View File

@ -10,6 +10,14 @@ from types import SimpleNamespace
import pytest import pytest
from multiaddr import Multiaddr from multiaddr import Multiaddr
from tractor.discovery import (
TunnelledAddress,
WGTunnelSpec,
mb_pubkey,
mk_wg_maddr,
parse_wg_maddr,
tunnels_of,
)
from tractor.ipc._tcp import TCPAddress from tractor.ipc._tcp import TCPAddress
from tractor.ipc._uds import UDSAddress from tractor.ipc._uds import UDSAddress
from tractor.discovery._multiaddr import ( from tractor.discovery._multiaddr import (
@ -22,6 +30,19 @@ from tractor.discovery._multiaddr import (
from tractor.discovery._addr import wrap_address from tractor.discovery._addr import wrap_address
_WG_PUBKEY: str = (
'g3x7z0AdV1rM6UQU22CC7IL3/ivn4DzrE7ikDhCZ/Dc='
)
_WG_PUBKEY_2: str = (
'AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8='
)
_WG_MADDR: str = (
f'/ip4/192.168.1.50/udp/51820'
f'/wg/{mb_pubkey(_WG_PUBKEY)}'
f'/ip4/10.0.11.1/tcp/1616'
)
def test_tpt_proto_to_maddr_mapping(): def test_tpt_proto_to_maddr_mapping():
''' '''
`_tpt_proto_to_maddr` maps all supported `proto_key` `_tpt_proto_to_maddr` maps all supported `proto_key`
@ -210,6 +231,181 @@ def test_parse_maddr_unsupported():
parse_maddr('/ip4/127.0.0.1/udp/1234') parse_maddr('/ip4/127.0.0.1/udp/1234')
def test_parse_wg_maddr():
'''
`parse_maddr()` previously rejected the canonical infix `/wg/`
grammar even though `py-multiaddr` parsed it. Feed a bearer,
identity, and TCP overlay through both the WG-specific and public
parsers, then prove they produce the same local-only tunnel
annotation without changing the bindable overlay.
'''
parsed = parse_wg_maddr(_WG_MADDR)
assert parse_maddr(_WG_MADDR) == parsed
assert isinstance(parsed, TunnelledAddress)
assert parsed.tunnel == WGTunnelSpec(
peer_pubkey=_WG_PUBKEY,
bearer=('192.168.1.50', 51820),
)
assert isinstance(parsed.overlay, TCPAddress)
assert parsed.overlay.unwrap() == ('10.0.11.1', 1616)
def test_mk_wg_maddr_roundtrip():
'''
`mk_maddr()` previously saw only the wrapper's delegated TCP
proto-key and silently dropped all tunnel metadata. Parse the
canonical maddr, compose it through both public entry points, and
prove bearer, key, and overlay survive byte-for-byte.
'''
parsed = parse_wg_maddr(_WG_MADDR)
assert str(mk_wg_maddr(parsed)) == _WG_MADDR
assert str(mk_maddr(parsed)) == _WG_MADDR
def test_nested_wg_maddr_roundtrip():
'''
A single first-match lookup confuses nested WG keys and bearers.
Arrange an IPv4 outer bearer around an IPv6 inner bearer, parse
from the last `/wg/` outward, and assert tunnel ordering plus an
exact re-composition of the original stack.
'''
nested_maddr: str = (
f'/ip4/192.168.1.50/udp/51820'
f'/wg/{mb_pubkey(_WG_PUBKEY)}'
f'/ip6/2001:db8::2/udp/51821'
f'/wg/{mb_pubkey(_WG_PUBKEY_2)}'
f'/ip4/10.0.11.1/tcp/1616'
)
parsed = parse_maddr(nested_maddr)
specs = tunnels_of(parsed)
assert len(specs) == 2
assert specs[0].peer_pubkey == _WG_PUBKEY
assert specs[0].bearer == ('192.168.1.50', 51820)
assert specs[1].peer_pubkey == _WG_PUBKEY_2
assert specs[1].bearer == ('2001:db8::2', 51821)
assert str(mk_maddr(parsed)) == nested_maddr
@pytest.mark.parametrize(
'maddr, match',
[
pytest.param(
(
f'/ip4/192.168.1.50/tcp/51820'
f'/wg/{mb_pubkey(_WG_PUBKEY)}'
f'/ip4/10.0.11.1/tcp/1616'
),
'Bad `wg` bearer',
id='non-udp-bearer',
),
pytest.param(
(
f'/ip4/192.168.1.50/udp/51820'
f'/wg/{mb_pubkey(_WG_PUBKEY)}'
),
'no overlay endpoint',
id='missing-overlay',
),
pytest.param(
(
f'/ip4/192.168.1.50/udp/51820'
f'/wg/{mb_pubkey(_WG_PUBKEY)}'
f'/ip4/10.0.11.1/udp/1616'
),
'Unsupported `wg` overlay',
id='non-tcp-overlay',
),
],
)
def test_parse_wg_maddr_rejects_bad_grammar(
maddr: str,
match: str,
):
'''
Accepting an invalid bearer or overlay assigns an endpoint to the
wrong runtime owner. Exercise parseable but unsupported protocol
combinations and prove each fails before constructing a wrapper,
with an error identifying the violated WG grammar boundary.
'''
with pytest.raises(ValueError, match=match):
parse_maddr(maddr)
def test_parse_wg_maddr_rejects_malformed_key():
'''
A truncated multibase key used to be vulnerable to silent
identity corruption in hand-written parsers. Give the upstream
`/wg/` codec a short key and prove `Multiaddr()` rejects it
before tractor's wrapper parser runs.
'''
maddr: str = (
'/ip4/192.168.1.50/udp/51820'
'/wg/udG9vIHNob3J0'
'/ip4/10.0.11.1/tcp/1616'
)
with pytest.raises(ValueError):
parse_maddr(maddr)
def test_parse_wg_maddr_reports_missing_codec(
monkeypatch,
):
'''
Released `multiaddr==0.2.0` does not know `/wg/` and emits an
opaque unknown-protocol parse error. Simulate that registry and
prove an actual WG stack reports the dependency action while a
Unix path containing a `wg` directory remains ordinary UDS data.
'''
from multiaddr.exceptions import ProtocolNotFoundError
from tractor.discovery import _tunnel
def no_wg_proto(name: str):
raise ProtocolNotFoundError(name)
monkeypatch.setattr(
_tunnel,
'protocol_with_name',
no_wg_proto,
)
uds = parse_maddr('/unix/tmp/wg/service.sock')
assert isinstance(uds, UDSAddress)
with pytest.raises(
RuntimeError,
match='py-multiaddr#108',
):
parse_maddr(_WG_MADDR)
def test_mk_wg_maddr_requires_bearer():
'''
A key-only tunnel spec relies on local configuration and cannot
be reconstructed as the canonical bearer-first maddr. Build that
incomplete annotation and prove composition raises instead of
emitting a misleading overlay-only address.
'''
addr = TunnelledAddress(
overlay=TCPAddress('10.0.11.1', 1616),
tunnel=WGTunnelSpec(peer_pubkey=_WG_PUBKEY),
)
with pytest.raises(
ValueError,
match='without a bearer',
):
mk_maddr(addr)
@pytest.mark.parametrize( @pytest.mark.parametrize(
'addr', 'addr',
[ [
@ -252,6 +448,21 @@ def test_wrap_address_maddr_str():
assert result.unwrap() == ('127.0.0.1', 9999) assert result.unwrap() == ('127.0.0.1', 9999)
def test_wrap_address_wg_maddr_str():
'''
`wrap_address()` delegates slash-prefixed strings to
`parse_maddr()`. Pass a canonical WG maddr through that public
boundary and prove it preserves the tunnel annotation rather than
rejecting the protocol stack or returning only its TCP overlay.
'''
result = wrap_address(_WG_MADDR)
assert isinstance(result, TunnelledAddress)
assert result.tunnel.peer_pubkey == _WG_PUBKEY
assert result.overlay.unwrap() == ('10.0.11.1', 1616)
# ------ parse_endpoints() tests ------ # ------ parse_endpoints() tests ------
def test_parse_endpoints_tcp_only(): def test_parse_endpoints_tcp_only():
@ -302,6 +513,30 @@ def test_parse_endpoints_mixed_tpts():
assert str(filedir) == '/tmp/tractor' assert str(filedir) == '/tmp/tractor'
def test_parse_endpoints_wg_maddr():
'''
Service endpoint tables previously rejected WG protocol stacks.
Put a tunnelled maddr beside a plain TCP address and prove
`parse_endpoints()` retains input order while delivering the
wrapper needed by the future bindspace lifecycle.
'''
table = {
'registry': [
_WG_MADDR,
'/ip4/127.0.0.1/tcp/1616',
],
}
addrs = parse_endpoints(table)['registry']
assert isinstance(addrs[0], TunnelledAddress)
assert addrs[0].tunnel.bearer == (
'192.168.1.50',
51820,
)
assert isinstance(addrs[1], TCPAddress)
def test_parse_endpoints_unwrapped_tuples(): def test_parse_endpoints_unwrapped_tuples():
''' '''
`parse_endpoints()` accept raw `(host, port)` tuples `parse_endpoints()` accept raw `(host, port)` tuples

View File

@ -17,8 +17,10 @@ import pytest
from tractor.discovery import ( from tractor.discovery import (
TunnelledAddress, TunnelledAddress,
WGTunnelSpec, WGTunnelSpec,
mb_pubkey,
strip_tunnels, strip_tunnels,
tunnels_of, tunnels_of,
wg8_pubkey,
) )
from tractor.discovery._addr import ( from tractor.discovery._addr import (
is_wrapped_addr, is_wrapped_addr,
@ -52,6 +54,54 @@ def tunnelled(
return TunnelledAddress(overlay=overlay, tunnel=spec) return TunnelledAddress(overlay=overlay, tunnel=spec)
def test_wg_pubkey_codec_roundtrip():
'''
Standard `wg(8)` base64 keys can contain `/`, which cannot be
embedded unchanged in a slash-delimited maddr. Prove the helper
emits `u`-prefixed multibase base64url without `/` and decodes
it back to the exact original 32-byte key.
'''
mb_key: str = mb_pubkey(_PUBKEY)
assert mb_key.startswith('u')
assert '/' not in mb_key
assert wg8_pubkey(mb_key) == _PUBKEY
@pytest.mark.parametrize(
'key, converter',
[
pytest.param(
'dG9vIHNob3J0',
mb_pubkey,
id='wg8-base64',
),
pytest.param(
'udG9vIHNob3J0',
wg8_pubkey,
id='multibase',
),
],
)
def test_wg_pubkey_codec_rejects_wrong_size(
key: str,
converter,
):
'''
WireGuard silently-corrupt key handling would let an invalid
identity reach peer verification. Exercise both input encodings
with a short payload and prove conversion rejects it before a
tunnel spec or maddr can be constructed.
'''
with pytest.raises(
ValueError,
match='must decode to 32 bytes',
):
converter(key)
def test_proto_key_delegates( def test_proto_key_delegates(
tunnelled: TunnelledAddress, tunnelled: TunnelledAddress,
overlay: TCPAddress, overlay: TCPAddress,

View File

@ -33,6 +33,10 @@ from ._tunnel import (
TunnelledAddress as TunnelledAddress, TunnelledAddress as TunnelledAddress,
TunnelSpec as TunnelSpec, TunnelSpec as TunnelSpec,
WGTunnelSpec as WGTunnelSpec, WGTunnelSpec as WGTunnelSpec,
mb_pubkey as mb_pubkey,
mk_wg_maddr as mk_wg_maddr,
parse_wg_maddr as parse_wg_maddr,
strip_tunnels as strip_tunnels, strip_tunnels as strip_tunnels,
tunnels_of as tunnels_of, tunnels_of as tunnels_of,
wg8_pubkey as wg8_pubkey,
) )

View File

@ -35,6 +35,7 @@ from ..ipc._tcp import TCPAddress
from ..ipc._uds import UDSAddress from ..ipc._uds import UDSAddress
if TYPE_CHECKING: if TYPE_CHECKING:
from ._tunnel import TunnelledAddress
from ..runtime._runtime import Actor from ..runtime._runtime import Actor
log = get_logger() log = get_logger()
@ -215,8 +216,8 @@ def mk_uuid() -> str:
def wrap_address( def wrap_address(
addr: UnwrappedAddress|str, addr: UnwrappedAddress|str|Address|TunnelledAddress,
) -> Address: ) -> Address|TunnelledAddress:
''' '''
Wrap an `UnwrappedAddress` as an `Address`-type based Wrap an `UnwrappedAddress` as an `Address`-type based
on matching builtin python data-structures which we adhoc on matching builtin python data-structures which we adhoc

View File

@ -32,6 +32,7 @@ from multiaddr import Multiaddr
if TYPE_CHECKING: if TYPE_CHECKING:
from tractor.discovery._addr import Address from tractor.discovery._addr import Address
from tractor.discovery._tunnel import TunnelledAddress
# map from tractor-internal `proto_key` identifiers # map from tractor-internal `proto_key` identifiers
# to the standard multiaddr protocol name strings. # to the standard multiaddr protocol name strings.
@ -48,7 +49,7 @@ _maddr_to_tpt_proto: dict[str, str] = {
def mk_maddr( def mk_maddr(
addr: 'Address', addr: 'Address|TunnelledAddress',
) -> Multiaddr: ) -> Multiaddr:
''' '''
Construct a `Multiaddr` from a tractor `Address` instance, Construct a `Multiaddr` from a tractor `Address` instance,
@ -56,6 +57,13 @@ def mk_maddr(
multiaddr-spec-compliant protocol path. multiaddr-spec-compliant protocol path.
''' '''
from ._tunnel import (
TunnelledAddress,
mk_wg_maddr,
)
if isinstance(addr, TunnelledAddress):
return mk_wg_maddr(addr)
proto_key: str = addr.proto_key proto_key: str = addr.proto_key
maddr_proto: str|None = _tpt_proto_to_maddr.get(proto_key) maddr_proto: str|None = _tpt_proto_to_maddr.get(proto_key)
if maddr_proto is None: if maddr_proto is None:
@ -90,7 +98,7 @@ def mk_maddr(
def parse_maddr( def parse_maddr(
maddr_str: str, maddr_str: str,
) -> 'Address': ) -> 'Address|TunnelledAddress':
''' '''
Parse a multiaddr string into a tractor `Address`. Parse a multiaddr string into a tractor `Address`.
@ -101,7 +109,16 @@ def parse_maddr(
from tractor.ipc._tcp import TCPAddress from tractor.ipc._tcp import TCPAddress
from tractor.ipc._uds import UDSAddress from tractor.ipc._uds import UDSAddress
maddr = Multiaddr(maddr_str) try:
maddr = Multiaddr(maddr_str)
except ValueError:
# Diagnose an unavailable WG codec after upstream parsing
# fails. Pre-checking the raw string would misclassify valid
# values such as `/unix/tmp/wg/service.sock`.
if '/wg/' in maddr_str:
from ._tunnel import _wg_proto_code
_wg_proto_code()
raise
proto_names: list[str] = [ proto_names: list[str] = [
p.name for p in maddr.protocols() p.name for p in maddr.protocols()
] ]
@ -124,6 +141,10 @@ def parse_maddr(
filename=sockpath.name, filename=sockpath.name,
) )
case _ if 'wg' in proto_names:
from ._tunnel import parse_wg_maddr
return parse_wg_maddr(maddr)
case _: case _:
raise ValueError( raise ValueError(
f'Unsupported multiaddr protocol combo: ' f'Unsupported multiaddr protocol combo: '
@ -142,11 +163,11 @@ EndpointsTable = dict[
list[str|tuple], # maddr strs or UnwrappedAddress list[str|tuple], # maddr strs or UnwrappedAddress
] ]
# output table: actor/service name -> list of wrapped # output table: actor/service name -> list of wrapped address
# `Address` instances ready for transport binding. # declarations ready for bindspace handling.
ParsedEndpoints = dict[ ParsedEndpoints = dict[
str, # actor/service name str, # actor/service name
list['Address'], list['Address|TunnelledAddress'],
] ]
@ -155,7 +176,7 @@ def parse_endpoints(
) -> ParsedEndpoints: ) -> ParsedEndpoints:
''' '''
Parse a service-endpoint config table into wrapped Parse a service-endpoint config table into wrapped
`Address` instances suitable for transport binding. address declarations suitable for bindspace handling.
Each key is an actor/service name and each value is Each key is an actor/service name and each value is
a list of addresses in any format accepted by a list of addresses in any format accepted by
@ -167,6 +188,8 @@ def parse_endpoints(
``/uds/`` proto_key) ``/uds/`` proto_key)
- raw unwrapped tuples: ``('127.0.0.1', 1616)`` - raw unwrapped tuples: ``('127.0.0.1', 1616)``
- pre-wrapped `Address` objects (passed through) - pre-wrapped `Address` objects (passed through)
- `wg` maddrs, returned as `TunnelledAddress` wrappers which
must be peeled at the eventual bind/dial boundary
Returns a new `dict` with the same keys, where each Returns a new `dict` with the same keys, where each
value list contains the corresponding `Address` value list contains the corresponding `Address`

View File

@ -67,12 +67,18 @@ Unwrap at the parse or bindspace boundary; see `.overlay` and
''' '''
from __future__ import annotations from __future__ import annotations
import base64
import ipaddress
from typing import ( from typing import (
ClassVar, ClassVar,
TYPE_CHECKING, TYPE_CHECKING,
) )
import msgspec import msgspec
import multibase
from multiaddr import Multiaddr
from multiaddr.exceptions import ProtocolNotFoundError
from multiaddr.protocols import protocol_with_name
if TYPE_CHECKING: if TYPE_CHECKING:
from ._addr import ( from ._addr import (
@ -120,6 +126,68 @@ class WGTunnelSpec(
TunnelSpec = WGTunnelSpec 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 _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.
'''
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( class TunnelledAddress(
msgspec.Struct, msgspec.Struct,
frozen=True, frozen=True,
@ -128,14 +196,14 @@ class TunnelledAddress(
An `Address` annotated with the tunnel it must be reached An `Address` annotated with the tunnel it must be reached
*through*. *through*.
Everything addressy delegates to `.overlay`, so every Address-level properties delegate to `.overlay`, so proto-key
existing table lookup (`_addr_to_transport`, the guards and `.unwrap()` retain their existing meaning and
`enable_transports` guard, `transport_from_addr()`) keeps **nothing new crosses the wire**. Transport boundaries which
working untouched, and `.unwrap()` delegating means **nothing dispatch on exact type or declaring module must first call
new crosses the wire**. `strip_tunnels()`.
''' '''
overlay: Address overlay: Address|TunnelledAddress
tunnel: TunnelSpec tunnel: TunnelSpec
# ---- delegated, so the runtime can't tell the difference ---- # ---- delegated, so the runtime can't tell the difference ----
@ -205,6 +273,188 @@ class TunnelledAddress(
) )
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.
'''
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.
'''
_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( def strip_tunnels(
addr: Address|TunnelledAddress, addr: Address|TunnelledAddress,
) -> Address: ) -> Address:

View File

@ -1113,6 +1113,7 @@ dependencies = [
{ name = "multiaddr" }, { name = "multiaddr" },
{ name = "pdbp" }, { name = "pdbp" },
{ name = "platformdirs" }, { name = "platformdirs" },
{ name = "py-multibase" },
{ name = "setproctitle" }, { name = "setproctitle" },
{ name = "tricycle" }, { name = "tricycle" },
{ name = "trio" }, { name = "trio" },
@ -1177,6 +1178,7 @@ requires-dist = [
{ name = "multiaddr", git = "https://github.com/multiformats/py-multiaddr.git?rev=f86519daaa21699023d0037c58cdff600313dd09" }, { name = "multiaddr", git = "https://github.com/multiformats/py-multiaddr.git?rev=f86519daaa21699023d0037c58cdff600313dd09" },
{ name = "pdbp", specifier = ">=1.8.2,<2" }, { name = "pdbp", specifier = ">=1.8.2,<2" },
{ name = "platformdirs", specifier = ">=4.4.0" }, { name = "platformdirs", specifier = ">=4.4.0" },
{ name = "py-multibase", specifier = ">=2.0.0,<3" },
{ name = "setproctitle", specifier = ">=1.3,<2" }, { name = "setproctitle", specifier = ">=1.3,<2" },
{ name = "tricycle", specifier = ">=0.4.1,<0.5" }, { name = "tricycle", specifier = ">=0.4.1,<0.5" },
{ name = "trio", specifier = ">0.27" }, { name = "trio", specifier = ">0.27" },