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
parent
d9a6e2e9b4
commit
dd02c7c09e
|
|
@ -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.
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
Loading…
Reference in New Issue