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 fa8067f79f
commit c7451bb2cb
12 changed files with 794 additions and 33 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

@ -0,0 +1,45 @@
---
model: gpt-5.6-sol
service: opencode
session: addr-unpacking-rebase-conflict
timestamp: 2026-08-28T00:38:05Z
git_ref: e40c39da
scope: code
substantive: true
raw_file: 20260828T003805Z_e40c39da_prompt_io.raw.md
---
## Prompt
The human asked the agent to resolve the first conflict in the
`wkt/addr_unpacking` rebase because the replayed history was primarily
agent-generated.
## Response summary
Preserved the lazy-import and platform-aware behavior from the new
base while restoring the replayed WireGuard address parsing and
composition behavior. Reconciled the changed dependency lock with the
replayed direct dependency contract and validated the combined result.
## Files changed
- `tractor/discovery/_addr.py` - combined address-platform and tunnel
typing changes.
- `tractor/discovery/_multiaddr.py` - combined lazy imports with WG
parse and composition dispatch.
- `tractor/discovery/_tunnel.py` - retained cold-import behavior for
the newly added WG implementation.
- `tests/discovery/test_multiaddr.py` - adapted missing-codec
monkeypatching to the lazily imported upstream protocol module.
- `uv.lock` - restored the WG-capable multiaddr source and direct
multibase metadata.
## Human edits
The human chose to abort the first rebase attempt, restart with an
explicit old-base boundary, and delegated the conflict resolution to
the agent. The human also chose not to preserve the superseded
module-level protocol-lookup test seam; the agent redirected the test
to the upstream protocol module. No direct manual source edits were
observed during this resolution.

View File

@ -0,0 +1,32 @@
---
model: gpt-5.6-sol
service: opencode
timestamp: 2026-08-28T00:38:05Z
git_ref: e40c39da
diff_cmd: git diff HEAD~1..HEAD
---
# Raw output - addr-unpacking rebase resolution
The human delegated resolution of the first conflict while rebasing
`wkt/addr_unpacking` from `d9a6e2e9` onto `85a44588`.
## Generated code
> `git diff HEAD~1..HEAD -- tractor/discovery/_addr.py tractor/discovery/_multiaddr.py tractor/discovery/_tunnel.py`
Combined the rebased base's platform-aware address registries and lazy
optional-dependency imports with the replayed `TunnelledAddress` typing
and WireGuard multiaddr dispatch. Adapted the new tunnel module so
importing tractor does not eagerly load `multiaddr`.
> `git diff HEAD~1..HEAD -- uv.lock`
Regenerated the lockfile to retain the replayed WG-capable
`py-multiaddr` revision and direct `py-multibase` dependency after the
new base's independently changed lockfile silently omitted that hunk.
## Verification
Conflict-marker, syntax, lock consistency, lint, collection, focused
discovery, and cold-import checks were selected for the resolution.

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`).
@ -169,17 +172,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]
@ -194,6 +186,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 multiaddr import protocols
def no_wg_proto(name: str):
raise ProtocolNotFoundError(name)
monkeypatch.setattr(
protocols,
'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

@ -40,9 +40,13 @@ from ..ipc._uds import (
if TYPE_CHECKING: if TYPE_CHECKING:
# ONLY type-annots, the eager import costs ~4.5ms # ONLY type-annots, the eager import costs ~4.5ms
# of `import tractor` wall-time (gh #470). # of `import tractor` wall-time (gh #470).
from ._tunnel import (
TunnelledAddress,
)
from ..runtime._runtime import Actor from ..runtime._runtime import Actor
else: else:
Actor = Any Actor = Any
TunnelledAddress = Any
log = get_logger() log = get_logger()
@ -232,8 +236,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

@ -38,9 +38,13 @@ if TYPE_CHECKING:
# `import tractor` path (gh #470). # `import tractor` path (gh #470).
from multiaddr import Multiaddr from multiaddr import Multiaddr
from tractor.discovery._addr import Address from tractor.discovery._addr import Address
from tractor.discovery._tunnel import (
TunnelledAddress,
)
else: else:
Multiaddr = Any Multiaddr = Any
Address = Any Address = Any
TunnelledAddress = Any
# 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.
@ -57,7 +61,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,
@ -67,6 +71,13 @@ def mk_maddr(
''' '''
from multiaddr import Multiaddr from multiaddr import Multiaddr
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:
@ -101,7 +112,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`.
@ -113,7 +124,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
try:
maddr = Multiaddr(maddr_str) 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()
] ]
@ -136,6 +156,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: '
@ -154,11 +178,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'],
] ]
@ -167,7 +191,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
@ -179,6 +203,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,18 +67,28 @@ 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 (
Any,
ClassVar, ClassVar,
TYPE_CHECKING, TYPE_CHECKING,
) )
import msgspec import msgspec
import multibase
if TYPE_CHECKING: if TYPE_CHECKING:
from multiaddr import Multiaddr
from ._addr import ( from ._addr import (
Address, Address,
UnwrappedAddress, UnwrappedAddress,
) )
else:
Address = Any
Multiaddr = Any
UnwrappedAddress = Any
class WGTunnelSpec( class WGTunnelSpec(
@ -120,6 +130,71 @@ 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.
'''
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( class TunnelledAddress(
msgspec.Struct, msgspec.Struct,
frozen=True, frozen=True,
@ -128,14 +203,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 +280,192 @@ 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.
'''
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( def strip_tunnels(
addr: Address|TunnelledAddress, addr: Address|TunnelledAddress,
) -> Address: ) -> Address:

10
uv.lock
View File

@ -518,7 +518,7 @@ wheels = [
[[package]] [[package]]
name = "multiaddr" name = "multiaddr"
version = "0.2.0" version = "0.2.0"
source = { registry = "https://pypi.org/simple" } source = { git = "https://github.com/multiformats/py-multiaddr.git?rev=f86519daaa21699023d0037c58cdff600313dd09#f86519daaa21699023d0037c58cdff600313dd09" }
dependencies = [ dependencies = [
{ name = "base58" }, { name = "base58" },
{ name = "dnspython" }, { name = "dnspython" },
@ -533,10 +533,6 @@ dependencies = [
{ name = "trio-typing" }, { name = "trio-typing" },
{ name = "varint" }, { name = "varint" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/c7/10/4e26a8577cfce1c0febc8d83087e1373e93c695c6e73ad010546fb67e229/multiaddr-0.2.0.tar.gz", hash = "sha256:acb6b25c332ec1b2f1f8fef8d03a8c63385d34a87d690df0f4bba43cdf6efe8d", size = 58356, upload-time = "2026-03-17T21:51:00.274Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b5/13/56e503d01218d1ca27ea9fda862045a4b400cae5e756f47315f5aaba0eee/multiaddr-0.2.0-py3-none-any.whl", hash = "sha256:bcff7bf3d7de3d6da0b865b25423bcb411de1d20d70cc6abfacf75170d17866c", size = 40424, upload-time = "2026-03-17T21:50:58.833Z" },
]
[[package]] [[package]]
name = "mypy-extensions" name = "mypy-extensions"
@ -1130,6 +1126,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" },
@ -1193,9 +1190,10 @@ requires-dist = [
{ name = "bidict", specifier = ">=0.23.1" }, { name = "bidict", specifier = ">=0.23.1" },
{ name = "colorlog", specifier = ">=6.8.2,<7" }, { name = "colorlog", specifier = ">=6.8.2,<7" },
{ name = "msgspec", specifier = ">=0.20.0" }, { name = "msgspec", specifier = ">=0.20.0" },
{ name = "multiaddr", specifier = ">=0.2.0" }, { 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" },