diff --git a/ai/prompt-io/opencode/20260818T075031Z_dd02c7c0_prompt_io.md b/ai/prompt-io/opencode/20260818T075031Z_dd02c7c0_prompt_io.md new file mode 100644 index 00000000..d4908c73 --- /dev/null +++ b/ai/prompt-io/opencode/20260818T075031Z_dd02c7c0_prompt_io.md @@ -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. diff --git a/ai/prompt-io/opencode/20260818T075031Z_dd02c7c0_prompt_io.raw.md b/ai/prompt-io/opencode/20260818T075031Z_dd02c7c0_prompt_io.raw.md new file mode 100644 index 00000000..a7d64f9c --- /dev/null +++ b/ai/prompt-io/opencode/20260818T075031Z_dd02c7c0_prompt_io.raw.md @@ -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. diff --git a/pyproject.toml b/pyproject.toml index 7fc61023..01c667cd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,7 +48,10 @@ dependencies = [ # typed IPC msging "msgspec>=0.20.0", "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", # per-actor `argv[0]` proc-title for OS-level diag tools # (`ps`, `top`, `psutil`-backed tooling like `acli.pytree`). @@ -166,17 +169,6 @@ sync_pause = {requires-python = ">=3.13, <3.14"} # linux kernel networking # 'pyroute2 -# XXX TEMP, the `/wg/u` 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] @@ -191,6 +183,9 @@ python-preference = 'system' # ------ tool.uv ------ +[tool.hatch.metadata] +allow-direct-references = true + [tool.hatch.build.targets.sdist] include = ["tractor"] diff --git a/tests/discovery/test_multiaddr.py b/tests/discovery/test_multiaddr.py index 9b1520d6..67d73934 100644 --- a/tests/discovery/test_multiaddr.py +++ b/tests/discovery/test_multiaddr.py @@ -10,6 +10,14 @@ from types import SimpleNamespace import pytest 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._uds import UDSAddress from tractor.discovery._multiaddr import ( @@ -22,6 +30,19 @@ from tractor.discovery._multiaddr import ( 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(): ''' `_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') +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( 'addr', [ @@ -252,6 +448,21 @@ def test_wrap_address_maddr_str(): 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 ------ def test_parse_endpoints_tcp_only(): @@ -302,6 +513,30 @@ def test_parse_endpoints_mixed_tpts(): 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(): ''' `parse_endpoints()` accept raw `(host, port)` tuples diff --git a/tests/discovery/test_tunnelled_addr.py b/tests/discovery/test_tunnelled_addr.py index 7a53d3a5..e9b6b471 100644 --- a/tests/discovery/test_tunnelled_addr.py +++ b/tests/discovery/test_tunnelled_addr.py @@ -17,8 +17,10 @@ import pytest from tractor.discovery import ( TunnelledAddress, WGTunnelSpec, + mb_pubkey, strip_tunnels, tunnels_of, + wg8_pubkey, ) from tractor.discovery._addr import ( is_wrapped_addr, @@ -52,6 +54,54 @@ def tunnelled( 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( tunnelled: TunnelledAddress, overlay: TCPAddress, diff --git a/tractor/discovery/__init__.py b/tractor/discovery/__init__.py index 1ff0d595..4720765c 100644 --- a/tractor/discovery/__init__.py +++ b/tractor/discovery/__init__.py @@ -33,6 +33,10 @@ from ._tunnel import ( TunnelledAddress as TunnelledAddress, TunnelSpec as TunnelSpec, 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, tunnels_of as tunnels_of, + wg8_pubkey as wg8_pubkey, ) diff --git a/tractor/discovery/_addr.py b/tractor/discovery/_addr.py index fe860bca..8d688432 100644 --- a/tractor/discovery/_addr.py +++ b/tractor/discovery/_addr.py @@ -35,6 +35,7 @@ from ..ipc._tcp import TCPAddress from ..ipc._uds import UDSAddress if TYPE_CHECKING: + from ._tunnel import TunnelledAddress from ..runtime._runtime import Actor log = get_logger() @@ -215,8 +216,8 @@ def mk_uuid() -> str: def wrap_address( - addr: UnwrappedAddress|str, -) -> Address: + addr: UnwrappedAddress|str|Address|TunnelledAddress, +) -> Address|TunnelledAddress: ''' Wrap an `UnwrappedAddress` as an `Address`-type based on matching builtin python data-structures which we adhoc diff --git a/tractor/discovery/_multiaddr.py b/tractor/discovery/_multiaddr.py index 74076fd4..8f626700 100644 --- a/tractor/discovery/_multiaddr.py +++ b/tractor/discovery/_multiaddr.py @@ -32,6 +32,7 @@ from multiaddr import Multiaddr if TYPE_CHECKING: from tractor.discovery._addr import Address + from tractor.discovery._tunnel import TunnelledAddress # map from tractor-internal `proto_key` identifiers # to the standard multiaddr protocol name strings. @@ -48,7 +49,7 @@ _maddr_to_tpt_proto: dict[str, str] = { def mk_maddr( - addr: 'Address', + addr: 'Address|TunnelledAddress', ) -> Multiaddr: ''' Construct a `Multiaddr` from a tractor `Address` instance, @@ -56,6 +57,13 @@ def mk_maddr( 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 maddr_proto: str|None = _tpt_proto_to_maddr.get(proto_key) if maddr_proto is None: @@ -90,7 +98,7 @@ def mk_maddr( def parse_maddr( maddr_str: str, -) -> 'Address': +) -> 'Address|TunnelledAddress': ''' Parse a multiaddr string into a tractor `Address`. @@ -101,7 +109,16 @@ def parse_maddr( from tractor.ipc._tcp import TCPAddress 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] = [ p.name for p in maddr.protocols() ] @@ -124,6 +141,10 @@ def parse_maddr( filename=sockpath.name, ) + case _ if 'wg' in proto_names: + from ._tunnel import parse_wg_maddr + return parse_wg_maddr(maddr) + case _: raise ValueError( f'Unsupported multiaddr protocol combo: ' @@ -142,11 +163,11 @@ EndpointsTable = dict[ list[str|tuple], # maddr strs or UnwrappedAddress ] -# output table: actor/service name -> list of wrapped -# `Address` instances ready for transport binding. +# output table: actor/service name -> list of wrapped address +# declarations ready for bindspace handling. ParsedEndpoints = dict[ str, # actor/service name - list['Address'], + list['Address|TunnelledAddress'], ] @@ -155,7 +176,7 @@ def parse_endpoints( ) -> ParsedEndpoints: ''' 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 a list of addresses in any format accepted by @@ -167,6 +188,8 @@ def parse_endpoints( ``/uds/`` proto_key) - raw unwrapped tuples: ``('127.0.0.1', 1616)`` - 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 value list contains the corresponding `Address` diff --git a/tractor/discovery/_tunnel.py b/tractor/discovery/_tunnel.py index 89b4a4f1..a3c80be7 100644 --- a/tractor/discovery/_tunnel.py +++ b/tractor/discovery/_tunnel.py @@ -67,12 +67,18 @@ Unwrap at the parse or bindspace boundary; see `.overlay` and ''' from __future__ import annotations +import base64 +import ipaddress from typing import ( ClassVar, TYPE_CHECKING, ) import msgspec +import multibase +from multiaddr import Multiaddr +from multiaddr.exceptions import ProtocolNotFoundError +from multiaddr.protocols import protocol_with_name if TYPE_CHECKING: from ._addr import ( @@ -120,6 +126,68 @@ class 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( msgspec.Struct, frozen=True, @@ -128,14 +196,14 @@ class TunnelledAddress( An `Address` annotated with the tunnel it must be reached *through*. - Everything addressy delegates to `.overlay`, so every - existing table lookup (`_addr_to_transport`, the - `enable_transports` guard, `transport_from_addr()`) keeps - working untouched, and `.unwrap()` delegating means **nothing - new crosses the wire**. + 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 + overlay: Address|TunnelledAddress tunnel: TunnelSpec # ---- 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//udp/`\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( addr: Address|TunnelledAddress, ) -> Address: diff --git a/uv.lock b/uv.lock index 9eb377f0..75f31992 100644 --- a/uv.lock +++ b/uv.lock @@ -1113,6 +1113,7 @@ dependencies = [ { name = "multiaddr" }, { name = "pdbp" }, { name = "platformdirs" }, + { name = "py-multibase" }, { name = "setproctitle" }, { name = "tricycle" }, { name = "trio" }, @@ -1177,6 +1178,7 @@ requires-dist = [ { name = "multiaddr", git = "https://github.com/multiformats/py-multiaddr.git?rev=f86519daaa21699023d0037c58cdff600313dd09" }, { name = "pdbp", specifier = ">=1.8.2,<2" }, { name = "platformdirs", specifier = ">=4.4.0" }, + { name = "py-multibase", specifier = ">=2.0.0,<3" }, { name = "setproctitle", specifier = ">=1.3,<2" }, { name = "tricycle", specifier = ">=0.4.1,<0.5" }, { name = "trio", specifier = ">0.27" },