Add `TunnelledAddress` wrapper primitives

Introduce the first layer-A address type from the `wg` bindspace
plan without treating a transparent tunnel as a `MsgTransport`.

Deats,
- add frozen `WGTunnelSpec` and `TunnelledAddress` structs which
  delegate proto identity, bindspace, validity and wire
  serialization to their overlay
- add `strip_tunnels()` and `tunnels_of()` for nested wrappers
- recognize wrappers in `is_wrapped_addr()` while keeping them out
  of `_address_types`
- cover delegation, namespace fallback and nested peeling semantics

Also,
- widen `Address.namespace` ids for named netns
- export the new discovery API
- clarify that tractor's layer-C bindspace lifecycle may provision
  the kernel-owned bearer without making it a `MsgTransport`

Prompt-IO: ai/prompt-io/opencode/20260818T021729Z_d9a6e2e9_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 02:01:30 -04:00
parent d9a6e2e9b4
commit dd02c7c09e
7 changed files with 595 additions and 12 deletions

View File

@ -0,0 +1,69 @@
---
model: gpt-5.6-sol
service: opencode
session: intercepted-claude-7b9c97c4-fff7-4ac4-97fb-35720453308e
timestamp: 2026-08-18T02:17:29Z
git_ref: d9a6e2e9
scope: code
substantive: true
raw_file: 20260818T021729Z_d9a6e2e9_prompt_io.raw.md
---
## Prompt
> Intercept Claude session
> `7b9c97c4-fff7-4ac4-97fb-35720453308e`, pick up where it
> stopped in its open worktree, finish the tunnelled-address change
> and commit plan, and prepare any outstanding context for another
> provider.
The recovered final prompt specifically called out the `uds` versus
`unix` boundary decision, absent concrete `Address.namespace`
implementations, the corrected namespace test, and the passing
focused and wider suites.
During review, the human further required that tunnelled-maddr work
delegate to `multiaddr`'s encapsulation APIs, challenged the premature
transport-shaped listener hooks and directed their removal, corrected
the long-term bearer provisioning model, and confirmed the intended
split between discovery metadata and bindspace lifecycle code.
## Response summary
Recovered the transcript and matched it to `wkts/addr_unpacking`,
audited the staged implementation, and completed the interrupted
verification and commit-plan work. The audit removed premature
transport-shaped listener hooks, widened the namespace identifier
type, updated stale import documentation, and removed an
invalid-escape warning from the maddr diagram. It also preserved the
layer-C design where tractor provisions the kernel-owned tunnel
bearer without treating it as a message transport.
## Files changed
- `tractor/discovery/_tunnel.py` - tunnel specs, address wrapper, and
peeling helpers.
- `tractor/discovery/_addr.py` - wrapped-address recognition and
namespace typing.
- `tractor/discovery/__init__.py` - public tunnel API exports.
- `tests/discovery/test_tunnelled_addr.py` - delegation and boundary
regression coverage.
- `ai/tpt-backends/03_wg_tunnel_bindspace.md` - distinguish
tractor-owned bindspace provisioning from kernel socket ownership.
## Human edits
Substantial human-directed editing occurred over several review turns:
- required use of `multiaddr`'s `.encapsulate()`/`.decapsulate()`
family rather than a hand-rolled tunnel peeler
- rejected the premature `start_listener()`/`close_listener()` hooks
and directed their removal from this foundational change
- corrected the documentation so tractor retains ownership of future
bindspace provisioning while the kernel owns the bearer socket
- reviewed and accepted the placement of declarative tunnel metadata
under `tractor.discovery`, with lifecycle code kept separate
The final source lines were applied through the coding agents, but
these design corrections and deletion decisions came from the human
review and materially shaped the patch.

View File

@ -0,0 +1,66 @@
---
model: gpt-5.6-sol
service: opencode
timestamp: 2026-08-18T02:17:29Z
git_ref: d9a6e2e9
diff_cmd: git diff HEAD~1..HEAD
---
# Raw output - tunnelled-address handoff completion
Recovered Claude Code session
`7b9c97c4-fff7-4ac4-97fb-35720453308e` and continued its
interrupted `wkts/addr_unpacking` changes.
## Generated code
> `git diff HEAD~1..HEAD -- tractor/discovery/_tunnel.py`
Added frozen `WGTunnelSpec` and `TunnelledAddress` structs. The
wrapper delegates transport identity, validity, bindspace, and wire
serialization to its overlay while retaining tunnel metadata locally.
Added pure helpers to peel nested wrappers and enumerate their tunnel
specs. The module documents why wrappers must be peeled before
`Endpoint` selects the overlay transport backend.
> `git diff HEAD~1..HEAD -- tractor/discovery/_addr.py`
Extended `is_wrapped_addr()` to recognize `TunnelledAddress` without
registering tunnels as message transports, and widened the namespace
identifier type to cover named network namespaces.
> `git diff HEAD~1..HEAD -- tractor/discovery/__init__.py`
Exported the tunnel address API from `tractor.discovery` and updated
the eager-import documentation.
> `git diff HEAD~1..HEAD -- tests/discovery/test_tunnelled_addr.py`
Added focused coverage for delegation, serialization, rewrapping,
namespace fallback, nested peeling order, and frozen structs.
> `git diff HEAD~1..HEAD -- ai/tpt-backends/03_wg_tunnel_bindspace.md`
Clarified that tractor owns the eventual bindspace lifecycle and may
provision the WireGuard iface, routes, and kernel UDP listener through
netlink/`pyroute2`. Kernel socket ownership does not make the bearer an
application `MsgTransport` endpoint.
## Verification
Focused tests:
```text
9 passed in 0.03s
```
Discovery and IPC suites:
```text
67 passed, 2 xpassed
```
The audit removed premature module-level listener hooks. Runtime
integration must peel the wrapper explicitly at bind and dial
boundaries rather than make `._tunnel` impersonate a transport
backend.

View File

@ -123,7 +123,7 @@ that the key decodes to exactly 32 bytes, so a truncated key is a
``` ```
/ip4/192.168.1.50/udp/51820/wg/u<A_pub>/ip4/10.0.11.1/tcp/1616 /ip4/192.168.1.50/udp/51820/wg/u<A_pub>/ip4/10.0.11.1/tcp/1616
\_______ bearer __________/\__ key __/\______ overlay ______/ \_______ bearer __________/\__ key __/\______ overlay ______/
underlay, wg `ListenPort` the ONLY part we bind underlay, wg `ListenPort` the `MsgTransport` bind
``` ```
The `/wg/` segment is **infix, not suffix** — the segments The `/wg/` segment is **infix, not suffix** — the segments
@ -150,11 +150,11 @@ Observed protocol-name lists, for writing the `match`:
- so the three parts have **three different owners**, and only the - so the three parts have **three different owners**, and only the
third is an `Endpoint`: third is an `Endpoint`:
| part | bound by | in the runtime? | | part | socket owner / provisioner | runtime role |
| --- | --- | --- | | --- | --- | --- |
| bearer | kernel, via `wg-quick`/`pyroute2` | no | | bearer | kernel-owned; externally provisioned in layer A, tractor bindspace-provisioned in layer C | control-plane metadata, never an `Endpoint` |
| `/wg/u<key>` | nothing — it's an identity | no, verified out-of-band | | `/wg/u<key>` | nothing — it's an identity | parsed and explicitly verified |
| overlay | `tractor`'s `IPCServer` | **yes**, as `.overlay` | | overlay | `tractor`'s `IPCServer` | application `MsgTransport`, as `.overlay` |
This owner-split is the real axis of the design, *not* whether This owner-split is the real axis of the design, *not* whether
the maddr stack is "composed" (it is). the maddr stack is "composed" (it is).
@ -319,6 +319,14 @@ data-structure which can easily be passed to nested `@acm`s
which consecutively setup nested net bindspaces for binding the which consecutively setup nested net bindspaces for binding the
endpoint addrs"*. endpoint addrs"*.
Layer C is where tractor takes ownership of bindspace orchestration.
For a fully bootstrapped deployment it may create the netns and wg
iface, configure peers/routes, and ask the kernel to establish the
bearer's UDP `ListenPort` through netlink/`pyroute2`. "Kernel-owned"
describes the data-plane socket, not who provisions it: tractor owns
the lifecycle while `Endpoint`/`MsgTransport` remain responsible only
for the overlay application socket.
### 5.1 the composition ### 5.1 the composition
```python ```python

View File

@ -0,0 +1,184 @@
'''
`TunnelledAddress` delegation + peeling semantics.
A tunnel annotates an existing L4 addr rather than being its own
transport, so the contract under test is mostly *delegation*: the
runtime must not be able to tell a tunnelled addr from its
overlay, and **nothing** about the tunnel may cross the wire.
See `ai/tpt-backends/03_wg_tunnel_bindspace.md` §3.1/§3.4.
'''
from __future__ import annotations
import msgspec
import pytest
from tractor.discovery import (
TunnelledAddress,
WGTunnelSpec,
strip_tunnels,
tunnels_of,
)
from tractor.discovery._addr import (
is_wrapped_addr,
wrap_address,
)
from tractor.ipc._tcp import TCPAddress
# a valid-looking std-base64 `wg(8)` pubkey (32B -> 44 chars)
_PUBKEY: str = 'g3x7z0AdV1rM6UQU22CC7IL3/ivn4DzrE7ikDhCZ/Dc='
@pytest.fixture
def overlay() -> TCPAddress:
return TCPAddress('10.0.11.1', 1616)
@pytest.fixture
def spec() -> WGTunnelSpec:
return WGTunnelSpec(
peer_pubkey=_PUBKEY,
bearer=('192.168.1.50', 51820),
)
@pytest.fixture
def tunnelled(
overlay: TCPAddress,
spec: WGTunnelSpec,
) -> TunnelledAddress:
return TunnelledAddress(overlay=overlay, tunnel=spec)
def test_proto_key_delegates(
tunnelled: TunnelledAddress,
overlay: TCPAddress,
):
'''
A tunnel has no transport of its own, so every table lookup
must see the *overlay's* proto-key.
'''
assert tunnelled.proto_key == overlay.proto_key == 'tcp'
def test_unwrap_is_identical_to_overlay(
tunnelled: TunnelledAddress,
overlay: TCPAddress,
):
'''
The whole point: nothing new crosses the wire, so a peer
never has to understand tunnels.
'''
assert tunnelled.unwrap() == overlay.unwrap()
# and it must survive msgpack as-is
enc: bytes = msgspec.msgpack.encode(tunnelled.unwrap())
assert msgspec.msgpack.decode(enc) == list(overlay.unwrap())
def test_unwrap_roundtrips_back_to_plain_overlay(
tunnelled: TunnelledAddress,
overlay: TCPAddress,
):
'''
`wrap_address()` on a tunnelled addr's unwrapped form yields
the *plain* overlay type the tunnel is simply absent, which
is correct: it was never on the wire.
'''
rewrapped = wrap_address(tunnelled.unwrap())
assert type(rewrapped) is TCPAddress
assert rewrapped == overlay
assert not isinstance(rewrapped, TunnelledAddress)
def test_bindspace_and_validity_delegate(
tunnelled: TunnelledAddress,
overlay: TCPAddress,
):
assert tunnelled.bindspace == overlay.bindspace
assert tunnelled.is_valid == overlay.is_valid
def test_is_wrapped_addr_accepts_tunnelled(
tunnelled: TunnelledAddress,
overlay: TCPAddress,
):
'''
`TunnelledAddress` is deliberately absent from
`_address_types`, so `is_wrapped_addr()` needs its own
clause.
'''
assert is_wrapped_addr(overlay)
assert is_wrapped_addr(tunnelled)
# the unwrapped form is NOT a wrapped addr
assert not is_wrapped_addr(tunnelled.unwrap())
def test_namespace_comes_from_the_tunnel(
overlay: TCPAddress,
):
'''
First real consumer of `Address.namespace`, spec'd in the
protocol since day one and implemented by no backend.
'''
# XXX, "no backend implements it" is literal — the member
# isn't even declared, so this is `AttributeError` not `None`.
# This assert is the guard: when a backend finally declares
# `.namespace`, it fails and the `getattr()` fallback in
# `TunnelledAddress.namespace` can go.
assert not hasattr(overlay, 'namespace')
no_ns = TunnelledAddress(
overlay=overlay,
tunnel=WGTunnelSpec(peer_pubkey=_PUBKEY),
)
assert no_ns.namespace is None
in_ns = TunnelledAddress(
overlay=overlay,
tunnel=WGTunnelSpec(peer_pubkey=_PUBKEY, netns='wg-test'),
)
assert in_ns.namespace == ('netns', 'wg-test')
def test_strip_tunnels(
tunnelled: TunnelledAddress,
overlay: TCPAddress,
spec: WGTunnelSpec,
):
# idempotent on a plain addr
assert strip_tunnels(overlay) is overlay
# peels one
assert strip_tunnels(tunnelled) is overlay
# and collapses a nested stack in one call
nested = TunnelledAddress(overlay=tunnelled, tunnel=spec)
assert strip_tunnels(nested) is overlay
def test_tunnels_of_is_outermost_first(
tunnelled: TunnelledAddress,
overlay: TCPAddress,
):
assert tunnels_of(overlay) == ()
assert tunnels_of(tunnelled) == (tunnelled.tunnel,)
inner_spec = WGTunnelSpec(peer_pubkey=_PUBKEY, iface='wg1')
nested = TunnelledAddress(
overlay=tunnelled,
tunnel=inner_spec,
)
assert tunnels_of(nested) == (inner_spec, tunnelled.tunnel)
def test_frozen(
tunnelled: TunnelledAddress,
):
with pytest.raises(AttributeError):
tunnelled.overlay = TCPAddress('127.0.0.1', 1)

View File

@ -18,11 +18,10 @@
Discovery (protocols) API for automatic addressing Discovery (protocols) API for automatic addressing
and location management of (service) actors. and location management of (service) actors.
NOTE: this ``__init__`` only eagerly imports the NOTE: this ``__init__`` only eagerly imports the lightweight
``._multiaddr`` submodule (for public re-exports). ``._multiaddr`` and ``._tunnel`` submodules for public re-exports.
Heavier submodules like ``._addr`` and ``._api`` Heavier submodules like ``._addr`` and ``._api`` are NOT imported
are NOT imported here to avoid circular imports; here to avoid circular imports; use direct module paths for those.
use direct module paths for those.
''' '''
from ._multiaddr import ( from ._multiaddr import (
@ -30,3 +29,10 @@ from ._multiaddr import (
parse_maddr as parse_maddr, parse_maddr as parse_maddr,
mk_maddr as mk_maddr, mk_maddr as mk_maddr,
) )
from ._tunnel import (
TunnelledAddress as TunnelledAddress,
TunnelSpec as TunnelSpec,
WGTunnelSpec as WGTunnelSpec,
strip_tunnels as strip_tunnels,
tunnels_of as tunnels_of,
)

View File

@ -93,7 +93,7 @@ class Address(Protocol):
# TODO, maybe `.netns` is a better name? # TODO, maybe `.netns` is a better name?
@property @property
def namespace(self) -> tuple[str, int]|None: def namespace(self) -> tuple[str, str|int]|None:
''' '''
The if-available, OS-specific "network namespace" key. The if-available, OS-specific "network namespace" key.
@ -192,7 +192,16 @@ def get_address_cls(name: str) -> Type[Address]:
def is_wrapped_addr(addr: any) -> bool: def is_wrapped_addr(addr: any) -> bool:
return type(addr) in _address_types.values() # XXX NOTE, a `TunnelledAddress` is genuinely "wrapped" but is
# deliberately NOT in `_address_types`: it has no
# `MsgTransport` of its own (a tunnel is transparent to
# `socket(2)`), so it gets no proto-key entry. See `._tunnel`.
from ._tunnel import TunnelledAddress
return (
type(addr) in _address_types.values()
or
isinstance(addr, TunnelledAddress)
)
def mk_uuid() -> str: def mk_uuid() -> str:

View File

@ -0,0 +1,241 @@
# 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
from typing import (
ClassVar,
TYPE_CHECKING,
)
import msgspec
if TYPE_CHECKING:
from ._addr import (
Address,
UnwrappedAddress,
)
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
class TunnelledAddress(
msgspec.Struct,
frozen=True,
):
'''
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**.
'''
overlay: Address
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.
This is the first real consumer of `Address.namespace`,
spec'd in the `Address` protocol since day one and
implemented by no backend.
XXX NOTE, "implemented by no backend" is literal: neither
`TCPAddress` nor `UDSAddress` defines `.namespace` at all,
so a plain attr access on an overlay raises
`AttributeError` rather than yielding `None`. Hence the
`getattr()` drop it once the backends actually declare
the member.
'''
if (netns := self.tunnel.netns) is None:
return getattr(self.overlay, 'namespace', None)
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 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)