From dd02c7c09ef6fa67fc07012b70f8d05282c2eec3 Mon Sep 17 00:00:00 2001 From: goodboy Date: Tue, 18 Aug 2026 02:01:30 -0400 Subject: [PATCH] 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`)) --- .../20260818T021729Z_d9a6e2e9_prompt_io.md | 69 +++++ ...20260818T021729Z_d9a6e2e9_prompt_io.raw.md | 66 +++++ ai/tpt-backends/03_wg_tunnel_bindspace.md | 18 +- tests/discovery/test_tunnelled_addr.py | 184 +++++++++++++ tractor/discovery/__init__.py | 16 +- tractor/discovery/_addr.py | 13 +- tractor/discovery/_tunnel.py | 241 ++++++++++++++++++ 7 files changed, 595 insertions(+), 12 deletions(-) create mode 100644 ai/prompt-io/opencode/20260818T021729Z_d9a6e2e9_prompt_io.md create mode 100644 ai/prompt-io/opencode/20260818T021729Z_d9a6e2e9_prompt_io.raw.md create mode 100644 tests/discovery/test_tunnelled_addr.py create mode 100644 tractor/discovery/_tunnel.py diff --git a/ai/prompt-io/opencode/20260818T021729Z_d9a6e2e9_prompt_io.md b/ai/prompt-io/opencode/20260818T021729Z_d9a6e2e9_prompt_io.md new file mode 100644 index 00000000..001579d5 --- /dev/null +++ b/ai/prompt-io/opencode/20260818T021729Z_d9a6e2e9_prompt_io.md @@ -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. diff --git a/ai/prompt-io/opencode/20260818T021729Z_d9a6e2e9_prompt_io.raw.md b/ai/prompt-io/opencode/20260818T021729Z_d9a6e2e9_prompt_io.raw.md new file mode 100644 index 00000000..5902499f --- /dev/null +++ b/ai/prompt-io/opencode/20260818T021729Z_d9a6e2e9_prompt_io.raw.md @@ -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. diff --git a/ai/tpt-backends/03_wg_tunnel_bindspace.md b/ai/tpt-backends/03_wg_tunnel_bindspace.md index 36c975b5..bc754a4c 100644 --- a/ai/tpt-backends/03_wg_tunnel_bindspace.md +++ b/ai/tpt-backends/03_wg_tunnel_bindspace.md @@ -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/ip4/10.0.11.1/tcp/1616 \_______ 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 @@ -150,11 +150,11 @@ Observed protocol-name lists, for writing the `match`: - so the three parts have **three different owners**, and only the third is an `Endpoint`: - | part | bound by | in the runtime? | + | part | socket owner / provisioner | runtime role | | --- | --- | --- | - | bearer | kernel, via `wg-quick`/`pyroute2` | no | - | `/wg/u` | nothing — it's an identity | no, verified out-of-band | - | overlay | `tractor`'s `IPCServer` | **yes**, as `.overlay` | + | bearer | kernel-owned; externally provisioned in layer A, tractor bindspace-provisioned in layer C | control-plane metadata, never an `Endpoint` | + | `/wg/u` | nothing — it's an identity | parsed and explicitly verified | + | overlay | `tractor`'s `IPCServer` | application `MsgTransport`, as `.overlay` | This owner-split is the real axis of the design, *not* whether 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 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 ```python diff --git a/tests/discovery/test_tunnelled_addr.py b/tests/discovery/test_tunnelled_addr.py new file mode 100644 index 00000000..7a53d3a5 --- /dev/null +++ b/tests/discovery/test_tunnelled_addr.py @@ -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) diff --git a/tractor/discovery/__init__.py b/tractor/discovery/__init__.py index 1ac8415d..1ff0d595 100644 --- a/tractor/discovery/__init__.py +++ b/tractor/discovery/__init__.py @@ -18,11 +18,10 @@ Discovery (protocols) API for automatic addressing and location management of (service) actors. -NOTE: this ``__init__`` only eagerly imports the -``._multiaddr`` submodule (for public re-exports). -Heavier submodules like ``._addr`` and ``._api`` -are NOT imported here to avoid circular imports; -use direct module paths for those. +NOTE: this ``__init__`` only eagerly imports the lightweight +``._multiaddr`` and ``._tunnel`` submodules for public re-exports. +Heavier submodules like ``._addr`` and ``._api`` are NOT imported +here to avoid circular imports; use direct module paths for those. ''' from ._multiaddr import ( @@ -30,3 +29,10 @@ from ._multiaddr import ( parse_maddr as parse_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, +) diff --git a/tractor/discovery/_addr.py b/tractor/discovery/_addr.py index 2697c5c9..fe860bca 100644 --- a/tractor/discovery/_addr.py +++ b/tractor/discovery/_addr.py @@ -93,7 +93,7 @@ class Address(Protocol): # TODO, maybe `.netns` is a better name? @property - def namespace(self) -> tuple[str, int]|None: + def namespace(self) -> tuple[str, str|int]|None: ''' 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: - 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: diff --git a/tractor/discovery/_tunnel.py b/tractor/discovery/_tunnel.py new file mode 100644 index 00000000..89b4a4f1 --- /dev/null +++ b/tractor/discovery/_tunnel.py @@ -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 . +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/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 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)