Compare commits
8 Commits
0d95a9e63c
...
51f4e31ce0
| Author | SHA1 | Date |
|---|---|---|
|
|
51f4e31ce0 | |
|
|
0a741282af | |
|
|
419ce6a631 | |
|
|
176e0c3e61 | |
|
|
6b8e9ad298 | |
|
|
bcf8f87ea5 | |
|
|
c7451bb2cb | |
|
|
fa8067f79f |
|
|
@ -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.
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
---
|
||||||
|
model: gpt-5.6-sol
|
||||||
|
service: opencode
|
||||||
|
session: tractor-addr-unpacking-followup
|
||||||
|
timestamp: 2026-08-19T21:31:44Z
|
||||||
|
git_ref: f81fc5e5
|
||||||
|
scope: code
|
||||||
|
substantive: true
|
||||||
|
raw_file: 20260819T213144Z_f81fc5e5_prompt_io.raw.md
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prompt
|
||||||
|
|
||||||
|
The human requested runtime boundary integration with the established
|
||||||
|
per-step implementation and commit-plan workflow.
|
||||||
|
|
||||||
|
## Response summary
|
||||||
|
|
||||||
|
Kept `TunnelledAddress` available to callers while peeling it at the
|
||||||
|
last outbound boundary before transport lookup and dialing. Added a
|
||||||
|
regression which captures both transport arguments and confirms plain
|
||||||
|
TCP behavior is unchanged.
|
||||||
|
|
||||||
|
## Files changed
|
||||||
|
|
||||||
|
- `tractor/ipc/_chan.py` - peel tunnel annotations before outbound
|
||||||
|
transport dispatch and connection.
|
||||||
|
- `tests/ipc/test_channel_tunnel_boundary.py` - verify plain and
|
||||||
|
tunnelled channel inputs deliver only TCP overlays.
|
||||||
|
|
||||||
|
## Human edits
|
||||||
|
|
||||||
|
The human chose the runtime-boundary slice, required the existing
|
||||||
|
per-step commit-plan flow, and previously established that wrappers
|
||||||
|
must retain bindspace metadata without impersonating transports. The
|
||||||
|
agent implemented those constraints; no direct manual source edits were
|
||||||
|
observed during this step.
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
---
|
||||||
|
model: gpt-5.6-sol
|
||||||
|
service: opencode
|
||||||
|
timestamp: 2026-08-19T21:31:44Z
|
||||||
|
git_ref: f81fc5e5
|
||||||
|
diff_cmd: git diff HEAD~1..HEAD
|
||||||
|
---
|
||||||
|
|
||||||
|
# Raw output - outbound tunnel boundary
|
||||||
|
|
||||||
|
The human requested the next tunnelled-address slice using the same
|
||||||
|
per-step commit-plan flow. Existing design decisions require retaining
|
||||||
|
tunnel metadata until the narrow IPC transport boundary and never
|
||||||
|
teaching exact-type transport tables about tunnel wrappers.
|
||||||
|
|
||||||
|
> `git diff HEAD~1..HEAD -- tractor/ipc/_chan.py tests/ipc/test_channel_tunnel_boundary.py`
|
||||||
|
|
||||||
|
Extended channel address inputs to accept tunnel declarations, then
|
||||||
|
called `strip_tunnels()` immediately before exact-type transport lookup
|
||||||
|
and `connect_to()`. Added plain/tunnel parameterized coverage proving
|
||||||
|
both operations receive the identical TCP overlay while the original
|
||||||
|
wrapper retains its tunnel spec.
|
||||||
|
|
||||||
|
Verification included focused IPC tests, Ruff, discovery/IPC suites,
|
||||||
|
and the full tractor suite.
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
---
|
||||||
|
model: gpt-5.6-sol
|
||||||
|
service: opencode
|
||||||
|
session: tractor-addr-unpacking-followup
|
||||||
|
timestamp: 2026-08-19T21:31:45Z
|
||||||
|
git_ref: f81fc5e5
|
||||||
|
scope: code
|
||||||
|
substantive: true
|
||||||
|
raw_file: 20260819T213145Z_f81fc5e5_prompt_io.raw.md
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prompt
|
||||||
|
|
||||||
|
The human requested completion of inbound runtime peeling using the
|
||||||
|
same per-step implementation and commit-plan workflow.
|
||||||
|
|
||||||
|
## Response summary
|
||||||
|
|
||||||
|
Preserved tunnel declarations through listener configuration, peeled
|
||||||
|
them immediately before `Endpoint` construction, and used the overlay
|
||||||
|
for backend-specific random listener allocation after registry
|
||||||
|
discovery. Added a real listener regression for the reflection and
|
||||||
|
exact-type boundary.
|
||||||
|
|
||||||
|
## Files changed
|
||||||
|
|
||||||
|
- `tractor/ipc/_server.py` - accept wrapper declarations and peel at
|
||||||
|
`Endpoint` construction.
|
||||||
|
- `tractor/_root.py` - allocate random transport addresses from the
|
||||||
|
contacted registry's overlay.
|
||||||
|
- `tests/ipc/test_server_tunnel_boundary.py` - verify a real listener
|
||||||
|
stores only TCP while preserving the source annotation.
|
||||||
|
|
||||||
|
## Human edits
|
||||||
|
|
||||||
|
The human selected the runtime-boundary work and previously corrected
|
||||||
|
the architecture so tractor retains future bindspace provisioning
|
||||||
|
ownership while `Endpoint` sees only application transports. The agent
|
||||||
|
implemented and tested that direction; no direct manual source edits
|
||||||
|
were observed during this step.
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
---
|
||||||
|
model: gpt-5.6-sol
|
||||||
|
service: opencode
|
||||||
|
timestamp: 2026-08-19T21:31:45Z
|
||||||
|
git_ref: f81fc5e5
|
||||||
|
diff_cmd: git diff HEAD~1..HEAD
|
||||||
|
---
|
||||||
|
|
||||||
|
# Raw output - inbound tunnel boundary
|
||||||
|
|
||||||
|
The human requested runtime boundary integration while preserving the
|
||||||
|
future tractor-owned bindspace lifecycle.
|
||||||
|
|
||||||
|
> `git diff HEAD~1..HEAD -- tractor/ipc/_server.py tractor/_root.py tests/ipc/test_server_tunnel_boundary.py`
|
||||||
|
|
||||||
|
Broadened listener declarations to carry tunnel wrappers until
|
||||||
|
`_serve_ipc_eps()` and peeled immediately before `Endpoint`
|
||||||
|
construction. Also peeled a contacted tunnelled registry before
|
||||||
|
backend-specific random listener allocation. Added a real TCP listener
|
||||||
|
regression proving `Endpoint` stores only the resolved overlay while
|
||||||
|
the original declaration retains bindspace metadata.
|
||||||
|
|
||||||
|
Verification included `465` collected tests, `84` passing
|
||||||
|
discovery/IPC tests with two xpasses, Ruff, and the full suite with
|
||||||
|
`447` passes.
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
---
|
||||||
|
model: gpt-5.6-sol
|
||||||
|
service: opencode
|
||||||
|
session: tractor-addr-unpacking-followup
|
||||||
|
timestamp: 2026-08-20T02:15:16Z
|
||||||
|
git_ref: dfad66a0
|
||||||
|
scope: docs
|
||||||
|
substantive: true
|
||||||
|
raw_file: 20260820T021516Z_dfad66a0_prompt_io.raw.md
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prompt
|
||||||
|
|
||||||
|
The human requested that the bindspace plan preserve the agreed
|
||||||
|
capability, spawn-bootstrap, endpoint-role, namespace augmentation,
|
||||||
|
random-address, and teardown semantics, using `github/ns_aware` as
|
||||||
|
prototype input.
|
||||||
|
|
||||||
|
## Response summary
|
||||||
|
|
||||||
|
Updated plan-03 and the shared backend contract to separate serializable
|
||||||
|
bindspace declarations from scoped live capabilities, make namespace
|
||||||
|
entry a pre-runtime spawn operation, keep maddr paths role-neutral, and
|
||||||
|
define listen/dial provisioning plus ownership-sensitive teardown.
|
||||||
|
|
||||||
|
## Files changed
|
||||||
|
|
||||||
|
- `ai/tpt-backends/03_wg_tunnel_bindspace.md` - layer-C capability,
|
||||||
|
bootstrap, role, teardown, test, and risk model.
|
||||||
|
- `ai/tpt-backends/00_shared_backend_contract.md` - distinguish
|
||||||
|
transport bind selectors from process namespace lifecycle.
|
||||||
|
|
||||||
|
## Human edits
|
||||||
|
|
||||||
|
The human supplied the core architecture: structured scoped
|
||||||
|
capabilities, spawn-time namespace entry, orthogonal namespace
|
||||||
|
augmentation, source/destination-dependent provisioning, and
|
||||||
|
role-dependent teardown. They also rejected premature assumptions about
|
||||||
|
`open_bindspace()` returning an address and requested grounding in the
|
||||||
|
existing namespace prototype. The agent translated those decisions into
|
||||||
|
the plan text; no direct manual source edits were observed.
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
---
|
||||||
|
model: gpt-5.6-sol
|
||||||
|
service: opencode
|
||||||
|
timestamp: 2026-08-20T02:15:16Z
|
||||||
|
git_ref: dfad66a0
|
||||||
|
diff_cmd: git diff HEAD~1..HEAD
|
||||||
|
---
|
||||||
|
|
||||||
|
# Raw output - bindspace capability design
|
||||||
|
|
||||||
|
The human corrected the layer-C design around local network-stack
|
||||||
|
realization. They established that bindspace state should be both
|
||||||
|
structured and a scoped capability; namespace entry belongs in
|
||||||
|
subactor bootstrap; maddrs can describe source or destination network
|
||||||
|
paths while namespace selection augments them orthogonally; random
|
||||||
|
address and teardown behavior depend on operation role and ownership.
|
||||||
|
|
||||||
|
They directed comparison with the prototype on `github/ns_aware` and
|
||||||
|
requested these decisions be preserved in the plan.
|
||||||
|
|
||||||
|
> `git diff HEAD~1..HEAD -- ai/tpt-backends/03_wg_tunnel_bindspace.md ai/tpt-backends/00_shared_backend_contract.md`
|
||||||
|
|
||||||
|
Reworked layer C around serializable `BindspaceSpec`, stable
|
||||||
|
`BindspaceIdentity`, and scoped non-serializable `BindspaceHandle`
|
||||||
|
concepts. Namespace FDs pin identity and lifetime; parent/supervisor
|
||||||
|
provisioning transfers entry capability through spawn; the child enters
|
||||||
|
before runtime, channels, listeners, sockets, or worker threads and then
|
||||||
|
drops authority. Listen/dial roles and owned/borrowed teardown are
|
||||||
|
explicit, while maddrs remain role-neutral network-path declarations.
|
||||||
|
|
||||||
|
The shared backend contract now separates transport-level `.bindspace`
|
||||||
|
selectors from process namespace lifecycle. Added tests/risks for FD
|
||||||
|
identity, bootstrap ordering, privilege drop, role ownership, and
|
||||||
|
shared-resource teardown.
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
---
|
||||||
|
model: gpt-5.6-sol
|
||||||
|
service: opencode
|
||||||
|
session: tractor-addr-unpacking
|
||||||
|
timestamp: 2026-08-20T03:31:07Z
|
||||||
|
git_ref: ba07e09d
|
||||||
|
scope: code
|
||||||
|
substantive: true
|
||||||
|
raw_file: 20260820T033107Z_ba07e09d_prompt_io.raw.md
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prompt
|
||||||
|
|
||||||
|
The human requested canonical tagged transport addresses with a
|
||||||
|
reader-first migration. TCP should decode `('tcp', host, port)`, Unix
|
||||||
|
should decode `('unix', path)`, `uds` should remain an accepted input
|
||||||
|
alias and internal transport key, and legacy tuple/list inputs must keep
|
||||||
|
working before writers switch formats.
|
||||||
|
|
||||||
|
## Response summary
|
||||||
|
|
||||||
|
Introduced canonical and compatibility address aliases, explicit tagged
|
||||||
|
dispatch, transport-specific tagged readers, and focused serialization
|
||||||
|
tests. Kept legacy pair inputs and native IPv6 socket values readable so
|
||||||
|
this boundary can ship before tagged emission.
|
||||||
|
|
||||||
|
## Files changed
|
||||||
|
|
||||||
|
- `tractor/discovery/_addr.py` - address aliases and tagged dispatch.
|
||||||
|
- `tractor/ipc/_tcp.py` - tagged, legacy, and IPv6 TCP decoding.
|
||||||
|
- `tractor/ipc/_uds.py` - canonical Unix and UDS-alias decoding.
|
||||||
|
- `tests/discovery/test_address_serialization.py` - reader compatibility
|
||||||
|
coverage.
|
||||||
|
|
||||||
|
## Human edits
|
||||||
|
|
||||||
|
The human supplied the canonical `tcp` and `unix` forms, chose `uds` as
|
||||||
|
an input-only serialization alias while preserving it as the runtime
|
||||||
|
transport key, and required a reader-first commit boundary. The agent
|
||||||
|
implemented those decisions; no direct manual source edits were
|
||||||
|
observed.
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
---
|
||||||
|
model: gpt-5.6-sol
|
||||||
|
service: opencode
|
||||||
|
timestamp: 2026-08-20T03:31:07Z
|
||||||
|
git_ref: ba07e09d
|
||||||
|
diff_cmd: git diff HEAD~1..HEAD
|
||||||
|
---
|
||||||
|
|
||||||
|
# Raw output - tagged address readers
|
||||||
|
|
||||||
|
The human requested a migration away from ambiguous untagged transport
|
||||||
|
tuples. They established `('tcp', host, port)` and `('unix', path)` as
|
||||||
|
canonical forms, retained `('uds', path)` as an input alias, and required
|
||||||
|
a reader-first compatibility boundary before changing emitted values.
|
||||||
|
|
||||||
|
> `git diff HEAD~1..HEAD -- tractor/discovery/_addr.py tractor/ipc/_tcp.py tractor/ipc/_uds.py tests/discovery/test_address_serialization.py`
|
||||||
|
|
||||||
|
Added explicit tagged address aliases and dispatch, taught TCP and UDS
|
||||||
|
readers to decode tagged tuple/list payloads, preserved legacy pair input,
|
||||||
|
and retained native IPv6 socket-address decoding. Added focused tests for
|
||||||
|
canonical tags, the UDS alias, msgpack-style lists, legacy pairs, and IPv6
|
||||||
|
socket values.
|
||||||
|
|
||||||
|
Focused reader tests and Ruff checks passed before the writer migration
|
||||||
|
was applied.
|
||||||
|
|
@ -0,0 +1,45 @@
|
||||||
|
---
|
||||||
|
model: gpt-5.6-sol
|
||||||
|
service: opencode
|
||||||
|
session: tractor-addr-unpacking
|
||||||
|
timestamp: 2026-08-20T03:31:08Z
|
||||||
|
git_ref: ba07e09d
|
||||||
|
scope: code
|
||||||
|
substantive: true
|
||||||
|
raw_file: 20260820T033108Z_ba07e09d_prompt_io.raw.md
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prompt
|
||||||
|
|
||||||
|
The human asked the agent to continue after adding tagged readers,
|
||||||
|
complete canonical address emission without dropping legacy input
|
||||||
|
compatibility, verify the migration, and prepare a complete multi-commit
|
||||||
|
package when the turn was done.
|
||||||
|
|
||||||
|
## Response summary
|
||||||
|
|
||||||
|
Changed `.unwrap()` to emit tagged TCP and Unix addresses, updated direct
|
||||||
|
tuple consumers and spawn payload declarations, and aligned multiaddr,
|
||||||
|
runtime, IPC, and discovery tests with canonical serialized equality.
|
||||||
|
Kept untagged tuples and the `uds` spelling readable at input boundaries.
|
||||||
|
|
||||||
|
## Files changed
|
||||||
|
|
||||||
|
- `tractor/discovery/_addr.py` - canonical output alias.
|
||||||
|
- `tractor/discovery/_multiaddr.py` - tagged address composition.
|
||||||
|
- `tractor/ipc/_tcp.py` - tagged emission and direct socket dialing.
|
||||||
|
- `tractor/ipc/_uds.py` - tagged full-path emission.
|
||||||
|
- `tractor/msg/types.py` - protocol-neutral spawn tuple containers.
|
||||||
|
- `tests/discovery/test_address_serialization.py` - writer assertions.
|
||||||
|
- `tests/discovery/test_multiaddr.py` - canonical round-trip assertions.
|
||||||
|
- `tests/discovery/test_tpt_bind_addrs.py` - tagged bind assertions.
|
||||||
|
- `tests/ipc/test_each_tpt.py` - canonical runtime address assertions.
|
||||||
|
- `tests/ipc/test_server_tunnel_boundary.py` - tagged TCP destructuring.
|
||||||
|
- `tests/test_local.py` - canonical registry comparison.
|
||||||
|
|
||||||
|
## Human edits
|
||||||
|
|
||||||
|
The human established the reader-before-writer sequencing, canonical tag
|
||||||
|
spellings, retained compatibility expectations, and requested final
|
||||||
|
multi-commit packaging. The agent implemented and tested those choices;
|
||||||
|
no direct manual source edits were observed.
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
---
|
||||||
|
model: gpt-5.6-sol
|
||||||
|
service: opencode
|
||||||
|
timestamp: 2026-08-20T03:31:08Z
|
||||||
|
git_ref: ba07e09d
|
||||||
|
diff_cmd: git diff HEAD~1..HEAD
|
||||||
|
---
|
||||||
|
|
||||||
|
# Raw output - canonical tagged address writers
|
||||||
|
|
||||||
|
After the reader compatibility boundary, the human asked the agent to
|
||||||
|
continue the migration and package the completed work as dependency-
|
||||||
|
ordered commits.
|
||||||
|
|
||||||
|
> `git diff HEAD~1..HEAD -- tractor/discovery/_addr.py tractor/discovery/_multiaddr.py tractor/ipc/_tcp.py tractor/ipc/_uds.py tractor/msg/types.py tests/discovery/test_address_serialization.py tests/discovery/test_multiaddr.py tests/discovery/test_tpt_bind_addrs.py tests/ipc/test_each_tpt.py tests/ipc/test_server_tunnel_boundary.py tests/test_local.py`
|
||||||
|
|
||||||
|
Switched TCP and Unix `.unwrap()` output to canonical tagged tuples,
|
||||||
|
updated direct transport and multiaddr consumers, widened spawn message
|
||||||
|
tuple containers for protocol-specific shapes, and migrated runtime and
|
||||||
|
test comparisons to serialized address equality. Legacy inputs remain
|
||||||
|
accepted at `wrap_address()` and backend reader boundaries.
|
||||||
|
|
||||||
|
Ruff and focused tests passed. The complete non-debugger TCP suite passed
|
||||||
|
with 412 tests; the UDS suite reached 80% without failure before the
|
||||||
|
harness timeout, then all 97 remaining tests passed on resume. Debugger
|
||||||
|
PTY coverage was excluded after an unrelated timeout.
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -112,7 +112,12 @@ Hard constraints learned from the existing two:
|
||||||
- **`.bindspace` semantics**: "the address' bindable space" —
|
- **`.bindspace` semantics**: "the address' bindable space" —
|
||||||
ip/host for `tcp`, the socket-file *directory* for `uds`. For
|
ip/host for `tcp`, the socket-file *directory* for `uds`. For
|
||||||
the new backends: the TIPC *scope* (§1 of plan 01), the iroh
|
the new backends: the TIPC *scope* (§1 of plan 01), the iroh
|
||||||
*ALPN + relay/discovery realm* (plan 02), the netns (plan 03).
|
*ALPN + relay/discovery realm* (plan 02). Do not overload this
|
||||||
|
transport-level bind selector with process namespace lifecycle.
|
||||||
|
Plan 03 augments an maddr/address declaration with a serializable
|
||||||
|
`BindspaceSpec` and a scoped, non-serializable `BindspaceHandle`;
|
||||||
|
the latter owns namespace identity/FD/lifetime and is consumed at
|
||||||
|
spawn bootstrap before a concrete address reaches transport bind.
|
||||||
`Address.namespace` is already spec'd in the Protocol as
|
`Address.namespace` is already spec'd in the Protocol as
|
||||||
"the if-available OS-specific network namespace key" and is
|
"the if-available OS-specific network namespace key" and is
|
||||||
currently unimplemented by both backends — plan 03 is the
|
currently unimplemented by both backends — plan 03 is the
|
||||||
|
|
|
||||||
|
|
@ -22,24 +22,23 @@ onto `trio` as the library's sans-io layer allows.
|
||||||
## 1. What exists today (verified, per #482)
|
## 1. What exists today (verified, per #482)
|
||||||
|
|
||||||
- `wrap_address()` accepts maddr `str`s (leading-`/` dispatch,
|
- `wrap_address()` accepts maddr `str`s (leading-`/` dispatch,
|
||||||
`_addr.py:262`) but `parse_maddr()` only knows
|
`_addr.py:262`). `parse_maddr()` and `mk_maddr()` support plain
|
||||||
`/ip4|ip6/<h>/tcp/<p>` and `/unix/<p>`; a `.../wg/u<key>`
|
TCP/UDS addresses plus nested, canonical bearer-first `/wg/`
|
||||||
maddr raises `ValueError('Unsupported multiaddr protocol
|
stacks represented locally as `TunnelledAddress` wrappers.
|
||||||
combo')`.
|
|
||||||
- there is no `wg` proto in the multiaddr *spec* yet, but
|
- there is no `wg` proto in the multiaddr *spec* yet, but
|
||||||
multiformats/py-multiaddr#108 (key form `u<base64url>`) is
|
multiformats/py-multiaddr#108 (key form `u<base64url>`) is
|
||||||
**merged** as of 2026-07-28 (`f86519da`) — and unreleased, the
|
**merged** as of 2026-07-28 (`f86519da`) — and unreleased, the
|
||||||
latest `0.2.0` predating it. Spec registration is still tracked
|
latest `0.2.0` predating it. Spec registration is still tracked
|
||||||
by multiformats/py-multiaddr#107 and gh #483.
|
by multiformats/py-multiaddr#107 and gh #483.
|
||||||
- so **today's deployable story is declarative**: run `wg-quick`
|
- **today's deployable story remains declarative**: run `wg-quick`
|
||||||
out-of-band, parse the maddr, strip to the overlay
|
out-of-band, parse the maddr, strip its wrapper to the overlay
|
||||||
`(host, port)`, verify the pubkey against the live tunnel,
|
`(host, port)`, verify the pubkey against the live tunnel,
|
||||||
hand the overlay addr to `registry_addrs=`/`tpt_bind_addrs=`.
|
hand the overlay addr to `registry_addrs=`/`tpt_bind_addrs=`.
|
||||||
#482 already contains working example code for exactly this.
|
#482 already contains working example code for exactly this.
|
||||||
- `Address.namespace` exists in the Protocol
|
- `Address.namespace` exists in the Protocol
|
||||||
(`_addr.py:94-101`, "the if-available OS-specific network
|
(`_addr.py:94-101`, "the if-available OS-specific network
|
||||||
namespace key") and **no backend implements it**. This plan is
|
namespace key"). `TunnelledAddress` implements it from its spec;
|
||||||
its first consumer.
|
no concrete transport backend implements it yet.
|
||||||
|
|
||||||
## 2. Three layers, three PRs
|
## 2. Three layers, three PRs
|
||||||
|
|
||||||
|
|
@ -123,7 +122,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 +149,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).
|
||||||
|
|
@ -197,9 +196,9 @@ Observed protocol-name lists, for writing the `match`:
|
||||||
- `mk_maddr()` inverse for `TunnelledAddress` is just
|
- `mk_maddr()` inverse for `TunnelledAddress` is just
|
||||||
`.encapsulate()` composition; don't rebuild `str`s by hand.
|
`.encapsulate()` composition; don't rebuild `str`s by hand.
|
||||||
- **pending an upstream release**: py-multiaddr#108 is merged, so
|
- **pending an upstream release**: py-multiaddr#108 is merged, so
|
||||||
`Multiaddr('/…/wg/u…')` parses — but off a `[tool.uv.sources]`
|
`Multiaddr('/…/wg/u…')` parses off a PEP 621 direct-revision pin,
|
||||||
`rev` pin, since no release carries the codec. Gate the tests
|
since no release carries the codec. Gate parser entry on
|
||||||
on `_have_wg_maddr_proto()`, implemented as
|
`_wg_proto_code()`, implemented as
|
||||||
`protocols.protocol_with_name('wg')` under
|
`protocols.protocol_with_name('wg')` under
|
||||||
`except ProtocolNotFoundError`. Do **not** probe by parsing a
|
`except ProtocolNotFoundError`. Do **not** probe by parsing a
|
||||||
dummy like `Multiaddr('/wg/uAAAA')` — the codec enforces a
|
dummy like `Multiaddr('/wg/uAAAA')` — the codec enforces a
|
||||||
|
|
@ -209,13 +208,14 @@ Observed protocol-name lists, for writing the `match`:
|
||||||
|
|
||||||
### 3.3 verification helper (pure, composable)
|
### 3.3 verification helper (pure, composable)
|
||||||
|
|
||||||
Port #482 §2's helpers into `tractor/discovery/_tunnel.py` as
|
Port #482 §2's pure helpers into
|
||||||
*pure functions* + one impure probe, cleanly separated:
|
`tractor/discovery/_tunnel.py`, keeping the impure probe cleanly
|
||||||
|
separated until layer B:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
def parse_wg_maddr(maddr: str) -> TunnelledAddress: ... # pure
|
def parse_wg_maddr(maddr: str) -> TunnelledAddress: ... # pure
|
||||||
def wg8_pubkey(multibase_key: str) -> str: ... # pure
|
def wg8_pubkey(multibase_key: str) -> str: ... # pure
|
||||||
def verify_wg_peer(spec: WGTunnelSpec) -> bool: ... # impure probe
|
def verify_wg_peer(spec: WGTunnelSpec) -> bool: ... # layer B
|
||||||
```
|
```
|
||||||
|
|
||||||
In layer A `verify_wg_peer()` may shell out (`wg show <if>
|
In layer A `verify_wg_peer()` may shell out (`wg show <if>
|
||||||
|
|
@ -319,41 +319,119 @@ 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
|
||||||
|
|
||||||
|
The maddr describes the composed network path and can be used as
|
||||||
|
either a source/listen or destination/dial handle. It does **not**
|
||||||
|
select the local instance of that network stack. A netns, VRF,
|
||||||
|
interface, user namespace, or equivalent platform resource is
|
||||||
|
orthogonal augmentation carried alongside/below the maddr.
|
||||||
|
|
||||||
|
Keep two bindspace representations with deliberately different
|
||||||
|
lifetimes:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
|
class BindspaceSpec(msgspec.Struct, frozen=True):
|
||||||
|
'''Serializable spawn/config declaration.'''
|
||||||
|
kind: str # `netns`, later `vrf`, ...
|
||||||
|
key: str|None # requested name/key, if any
|
||||||
|
|
||||||
|
|
||||||
|
class BindspaceIdentity(msgspec.Struct, frozen=True):
|
||||||
|
'''Stable identity of the realized platform resource.'''
|
||||||
|
kind: str
|
||||||
|
key: str|None
|
||||||
|
inode: int|None # Linux namespace identity
|
||||||
|
|
||||||
|
|
||||||
|
class BindspaceHandle:
|
||||||
|
'''Scoped, non-serializable capability for one live bindspace.'''
|
||||||
|
spec: BindspaceSpec
|
||||||
|
identity: BindspaceIdentity
|
||||||
|
namespace_fd: int|None
|
||||||
|
ownership: Literal['owned', 'borrowed']
|
||||||
|
|
||||||
|
|
||||||
@acm
|
@acm
|
||||||
async def open_bindspace(
|
async def open_bindspace(
|
||||||
addr: TunnelledAddress,
|
spec: BindspaceSpec,
|
||||||
) -> AsyncGenerator[Address, None]:
|
*,
|
||||||
|
role: Literal['listen', 'dial'],
|
||||||
|
) -> AsyncGenerator[BindspaceHandle, None]:
|
||||||
'''
|
'''
|
||||||
Enter the net-bindspace implied by `addr`'s tunnel stack,
|
Provision/borrow one bindspace and yield its live capability.
|
||||||
yielding the *overlay* `Address` ready to bind/connect.
|
|
||||||
|
|
||||||
Nests: one `@acm` per tunnel segment, outermost-first, so
|
|
||||||
a 2-deep stack is just two nested `async with`s and the
|
|
||||||
teardown order is guaranteed by `trio`.
|
|
||||||
|
|
||||||
'''
|
'''
|
||||||
```
|
```
|
||||||
|
|
||||||
with per-tunnel-kind implementations:
|
The exact field set remains design work; the required split does not:
|
||||||
|
`BindspaceSpec` crosses config/spawn serialization, while
|
||||||
|
`BindspaceHandle` contains live OS resources (especially an open
|
||||||
|
namespace FD), pins identity/lifetime, and must never cross msgpack.
|
||||||
|
An FD is a stronger capability than a namespace name: it avoids
|
||||||
|
name-resolution TOCTOU, survives rename/unlink, and identifies the
|
||||||
|
exact namespace the parent provisioned.
|
||||||
|
|
||||||
|
`open_bindspace()` is **not** an address factory and does not return a
|
||||||
|
`TunnelledAddress`. At the declaration layer, listener allocation can
|
||||||
|
use the handle to replace an overlay while preserving every tunnel:
|
||||||
|
|
||||||
|
```python
|
||||||
|
async with open_bindspace(
|
||||||
|
bindspace_spec,
|
||||||
|
role='listen',
|
||||||
|
) as bindspace:
|
||||||
|
listen_decl = declared_addr.get_random(
|
||||||
|
bindspace=bindspace,
|
||||||
|
)
|
||||||
|
transport_addr = strip_tunnels(listen_decl)
|
||||||
|
```
|
||||||
|
|
||||||
|
That sketch intentionally leaves the `.get_random()`/bindspace value
|
||||||
|
contract open. A concrete transport call returns a concrete overlay;
|
||||||
|
a declaration-level call may replace the overlay and return a new
|
||||||
|
`TunnelledAddress`. In either case wrappers remain until the final
|
||||||
|
transport bind/dial boundary, where `strip_tunnels()` is mandatory.
|
||||||
|
|
||||||
|
Per-platform provisioning still composes one resource context per
|
||||||
|
tunnel/bindspace layer:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
@acm
|
@acm
|
||||||
async def open_netns(name: str) -> AsyncGenerator[None, None]: ...
|
async def open_netns(
|
||||||
|
spec: BindspaceSpec,
|
||||||
|
role: Literal['listen', 'dial'],
|
||||||
|
) -> AsyncGenerator[BindspaceHandle, None]: ...
|
||||||
|
|
||||||
@acm
|
@acm
|
||||||
async def open_wg_iface(spec: WGTunnelSpec) -> AsyncGenerator[WGTunnelSpec, None]: ...
|
async def open_wg_iface(
|
||||||
|
spec: WGTunnelSpec,
|
||||||
|
bindspace: BindspaceHandle,
|
||||||
|
role: Literal['listen', 'dial'],
|
||||||
|
) -> AsyncGenerator[WGTunnelSpec, None]: ...
|
||||||
```
|
```
|
||||||
|
|
||||||
and a driver that folds a list of specs into nested contexts
|
and a driver that folds a list of specs into nested contexts
|
||||||
(`contextlib.AsyncExitStack` for the N-deep case). The
|
(`contextlib.AsyncExitStack` for the N-deep case). The
|
||||||
`parse_endpoints()` API (`_multiaddr.py:153`) is the front door:
|
`parse_endpoints()` API (`_multiaddr.py:153`) is the front door:
|
||||||
it already returns `dict[name, list[Address]]` and the
|
it already returns
|
||||||
|
`dict[name, list[Address|TunnelledAddress]]` and the
|
||||||
`multiaddr_declare_eps.md` sketch anticipates the recursive
|
`multiaddr_declare_eps.md` sketch anticipates the recursive
|
||||||
`dict[str, list[Address]]|dict[...]` return for tunnelled
|
`dict[str, list[Address]]|dict[...]` return for tunnelled
|
||||||
entries. Extend it to carry the tunnel stack, not to *enter* it.
|
entries. Extend it to carry the tunnel stack, not to *enter* it.
|
||||||
|
|
||||||
|
The caller supplies `role`; do not infer it from maddr shape. The same
|
||||||
|
composed maddr can name a server source or client destination, and the
|
||||||
|
required local provisioning/ownership differs (§5.3).
|
||||||
|
|
||||||
### 5.2 `Address.namespace`, at last
|
### 5.2 `Address.namespace`, at last
|
||||||
|
|
||||||
- `TunnelledAddress.namespace` → `(kind, id)` e.g.
|
- `TunnelledAddress.namespace` → `(kind, id)` e.g.
|
||||||
|
|
@ -369,6 +447,13 @@ entries. Extend it to carry the tunnel stack, not to *enter* it.
|
||||||
`Endpoint.pformat()`, `_server.py:645`). Fill that in; it's
|
`Endpoint.pformat()`, `_server.py:645`). Fill that in; it's
|
||||||
the cheapest possible proof the layer is wired.
|
the cheapest possible proof the layer is wired.
|
||||||
|
|
||||||
|
Use `github/ns_aware@e4688cad` as prototype evidence, not code to
|
||||||
|
cherry-pick unchanged. Its `/proc/<pid>/ns/<type>` inode reader and
|
||||||
|
`ip netns identify` probe establish the useful `(key, inode)` identity
|
||||||
|
pair. Layer C should move that shape into `BindspaceIdentity`, avoid a
|
||||||
|
subprocess where netlink/procfs suffices, and hold the namespace FD in
|
||||||
|
`BindspaceHandle` to pin the identity.
|
||||||
|
|
||||||
### 5.3 the netns/process reality — read this before designing
|
### 5.3 the netns/process reality — read this before designing
|
||||||
|
|
||||||
**The headline consequence, stated up front**: netns is a
|
**The headline consequence, stated up front**: netns is a
|
||||||
|
|
@ -396,26 +481,52 @@ server bound in the old namespace.
|
||||||
- entering a netns is *process-global-ish and irreversible-ish*
|
- entering a netns is *process-global-ish and irreversible-ish*
|
||||||
in practice. Therefore: **netns membership belongs to the
|
in practice. Therefore: **netns membership belongs to the
|
||||||
actor process, decided before the runtime binds**, not to a
|
actor process, decided before the runtime binds**, not to a
|
||||||
mid-life `@acm`. Design:
|
mid-life actor API. Design:
|
||||||
- the root/parent decides the netns for a subactor and passes
|
- the root/parent decides the `BindspaceSpec`, provisions or
|
||||||
it in the spawn spec (there's already
|
borrows it, and passes the spec plus an inherited/transferred
|
||||||
|
namespace-FD capability through the spawn backend (there's already
|
||||||
`enable_transports`/`accept_addrs` plumbing at
|
`enable_transports`/`accept_addrs` plumbing at
|
||||||
`_runtime.py:1595-1615` — the netns rides alongside).
|
`_runtime.py:1595-1615` — the netns rides alongside).
|
||||||
- the child, in `_runtime.async_main()` **before**
|
- the child spawn/bootstrap trampoline calls `setns()` **before**
|
||||||
`IPCServer.listen_on()`, enters it.
|
`_runtime.async_main()`, `IPCServer.listen_on()`, parent-channel
|
||||||
- the mid-life `@acm` form is then only for the *root* /
|
connection, or creation of any worker thread/socket.
|
||||||
single-actor case, and for iface creation (which is
|
- only after successful entry does the child drop namespace-entry
|
||||||
genuinely scoped).
|
privileges and initialize the actor runtime.
|
||||||
|
- a root/single-actor process follows the same ordering: enter during
|
||||||
|
root bootstrap, never after actor runtime startup.
|
||||||
|
- iface/route/WG provisioning is genuinely scoped and remains under
|
||||||
|
the parent/supervisor's `BindspaceHandle` context.
|
||||||
- document the constraint rather than hiding it; a
|
- document the constraint rather than hiding it; a
|
||||||
`RuntimeError` if `open_netns()` is entered after any
|
`RuntimeError` if namespace entry is attempted after bootstrap.
|
||||||
listener exists.
|
- capabilities: iface/netns creation/config needs `CAP_NET_ADMIN`;
|
||||||
- privileges: iface/netns creation needs `CAP_NET_ADMIN`.
|
entering an existing Linux namespace normally requires
|
||||||
Never `sudo` from inside the runtime. Two supported modes:
|
`CAP_SYS_ADMIN` in the owning user namespace. Never `sudo` from
|
||||||
|
inside the runtime. A privileged parent/helper should provision the
|
||||||
|
stack and open the namespace FD; the child receives only the scoped
|
||||||
|
capability and temporary authority needed to enter it, then drops
|
||||||
|
that authority before actor code runs. This separates create/config
|
||||||
|
authority from enter/use authority and fits user-namespace/capability
|
||||||
|
deployments without granting every actor broad ambient caps.
|
||||||
|
Two supported modes remain:
|
||||||
(i) pre-provisioned out-of-band (layers A/B — the default,
|
(i) pre-provisioned out-of-band (layers A/B — the default,
|
||||||
and what #482 documents), (ii) runtime-managed when the
|
and what #482 documents), (ii) runtime-managed when the supervising
|
||||||
process already holds the cap. Detect with a cheap
|
process/helper holds the required caps. Probe exact required caps and
|
||||||
`os.geteuid()==0 or CAP_NET_ADMIN in /proc/self/status`
|
*fail loudly with an actionable message* otherwise.
|
||||||
probe and *fail loudly with an actionable message* otherwise.
|
- role semantics are explicit:
|
||||||
|
- `listen`: may create/own the local bindspace, iface, routes, WG
|
||||||
|
peer/listener state, and random local overlay; lifetime normally
|
||||||
|
extends through all listeners and the actor process.
|
||||||
|
- `dial`: may borrow an actor-wide bindspace or ensure local routing
|
||||||
|
and tunnel state reaches the remote stack; it does not own the
|
||||||
|
remote maddr and may need no new local resource at all.
|
||||||
|
- source/destination use is an operation property, never permanently
|
||||||
|
encoded into the maddr or inferred from segment ordering.
|
||||||
|
- teardown follows capability ownership, not just address type:
|
||||||
|
- owned listener bindspaces tear down after endpoints/channels and
|
||||||
|
the actor process have exited;
|
||||||
|
- borrowed dial/actor-wide bindspaces only release their handle;
|
||||||
|
- nested resources exit inside-out, but shared resources remain until
|
||||||
|
their owning supervisor drops the final capability.
|
||||||
- teardown must be idempotent and tolerant: an iface/netns
|
- teardown must be idempotent and tolerant: an iface/netns
|
||||||
already gone must not strand the rest of the teardown — the
|
already gone must not strand the rest of the teardown — the
|
||||||
exact lesson `_uds.close_listener()`'s `FileNotFoundError`
|
exact lesson `_uds.close_listener()`'s `FileNotFoundError`
|
||||||
|
|
@ -434,6 +545,13 @@ server bound in the old namespace.
|
||||||
self-contained — no second host, no `sudo` in the test body.
|
self-contained — no second host, no `sudo` in the test body.
|
||||||
- the `to_thread`-netns-mismatch regression from §5.3, written
|
- the `to_thread`-netns-mismatch regression from §5.3, written
|
||||||
**first** (red), then the fix (green), per project convention.
|
**first** (red), then the fix (green), per project convention.
|
||||||
|
- bootstrap ordering: assert the child reports the expected namespace
|
||||||
|
inode before parent-channel connect and listener creation.
|
||||||
|
- FD capability: rename/unlink the namespace name after opening its FD
|
||||||
|
and prove child entry still selects the pinned inode.
|
||||||
|
- privilege drop: prove actor code lacks provisioning caps after entry.
|
||||||
|
- role/ownership: fake listen/dial resources and assert owned listener
|
||||||
|
teardown versus borrowed dial-handle release.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -467,8 +585,12 @@ consider doing it *first* for exactly that reason.
|
||||||
| risk | mitigation |
|
| risk | mitigation |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `to_thread` worker runs in the wrong netns | §5.3; pass `netns=` to pyroute2 or pin a worker; test-first |
|
| `to_thread` worker runs in the wrong netns | §5.3; pass `netns=` to pyroute2 or pin a worker; test-first |
|
||||||
| py-multiaddr#108 merged but unreleased | `[tool.uv.sources]` `rev` pin + `_have_wg_maddr_proto()` gate; layer A's overlay-addr path works regardless |
|
| namespace name is renamed/replaced between provision and spawn | pass an open namespace FD; verify `(key, inode)` after child entry |
|
||||||
| `TunnelledAddress` leaks into `Endpoint` and breaks `inspect.getmodule()` | unwrap at parse/bindspace boundary; assert `not isinstance(ep.addr, TunnelledAddress)` in `Endpoint.__post_init__` |
|
| child starts sockets/threads before `setns()` | enter in the spawn bootstrap trampoline before `_runtime.async_main()`; assert inode ordering |
|
||||||
|
| ambient capabilities leak into actor app code | split provision/enter authority and drop caps before runtime initialization |
|
||||||
|
| dial path tears down a shared actor bindspace | encode ownership in `BindspaceHandle`; borrowed handles never remove resources |
|
||||||
|
| py-multiaddr#108 merged but unreleased | PEP 621 direct-revision pin + `_wg_proto_code()` gate; replace with a release floor once published |
|
||||||
|
| `TunnelledAddress` leaks into transport reflection/type dispatch | keep wrappers through declaration/bindspace handling, call `strip_tunnels()` at channel/endpoint boundaries, and retain the boundary regressions |
|
||||||
| privileged ops in a library | never `sudo`; explicit cap probe + actionable error; pre-provisioned is the default |
|
| privileged ops in a library | never `sudo`; explicit cap probe + actionable error; pre-provisioned is the default |
|
||||||
| pyroute2 0.9 asyncio core drags a loop into the actor | option (1) is a *thread*, not a loop; forbid `trio-asyncio` here (§4.1) |
|
| pyroute2 0.9 asyncio core drags a loop into the actor | option (1) is a *thread*, not a loop; forbid `trio-asyncio` here (§4.1) |
|
||||||
| netns teardown strands actor teardown | idempotent/tolerant teardown mirroring `_uds.close_listener()` |
|
| netns teardown strands actor teardown | idempotent/tolerant teardown mirroring `_uds.close_listener()` |
|
||||||
|
|
|
||||||
|
|
@ -21,16 +21,16 @@ Supersedes the example set in gh
|
||||||
/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
|
||||||
\____ wg bearer ___________/\__ key __/\____ tractor ep _____/
|
\____ wg bearer ___________/\__ key __/\____ tractor ep _____/
|
||||||
underlay, wg `ListenPort` overlay, on the wg iface
|
underlay, wg `ListenPort` overlay, on the wg iface
|
||||||
(kernel/`wg(8)` owns it) (the ONLY part tractor binds)
|
(kernel owns the socket) (`MsgTransport` binds this)
|
||||||
```
|
```
|
||||||
|
|
||||||
Three parts, three different owners:
|
Three parts, three different owners:
|
||||||
|
|
||||||
| part | who binds it | in the runtime? |
|
| part | socket owner / provisioner | runtime role |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `/ip4/../udp/51820` bearer | kernel via `wg-quick`/`pyroute2` | no |
|
| `/ip4/../udp/51820` bearer | kernel-owned; `wg-quick` now, tractor bindspace later | control-plane metadata |
|
||||||
| `/wg/u<key>` | nothing — it's an identity | no, verified out-of-band |
|
| `/wg/u<key>` | nothing — it's an identity | parsed, verified explicitly |
|
||||||
| `/ip4/../tcp/1616` overlay | `tractor`'s `IPCServer` | **yes**, as `.overlay` |
|
| `/ip4/../tcp/1616` overlay | `tractor`'s `IPCServer` | application `MsgTransport` |
|
||||||
|
|
||||||
Verified against py-multiaddr
|
Verified against py-multiaddr
|
||||||
[#108](https://github.com/multiformats/py-multiaddr/pull/108):
|
[#108](https://github.com/multiformats/py-multiaddr/pull/108):
|
||||||
|
|
@ -41,23 +41,20 @@ this composed form parses and round-trips
|
||||||
|
|
||||||
py-multiaddr #108 is **merged** (2026-07-28) but ships in no
|
py-multiaddr #108 is **merged** (2026-07-28) but ships in no
|
||||||
release yet — the latest `0.2.0` (2026-03-17) predates it and has
|
release yet — the latest `0.2.0` (2026-03-17) predates it and has
|
||||||
no `wg` codec. So `pyproject.toml` carries a temporary
|
no `wg` codec. So `pyproject.toml` temporarily pins the merge commit
|
||||||
`[tool.uv.sources]` `rev` pin at the merge commit, and a plain
|
in its PEP 621 dependency metadata, and a plain
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uv sync
|
uv sync
|
||||||
```
|
```
|
||||||
|
|
||||||
gets you a `wg`-aware `multiaddr`. That pin goes away once a
|
gets you a `wg`-aware `multiaddr`. That pin goes away once a
|
||||||
release carries the codec. You also need `multibase`:
|
release carries the codec. `py-multibase` is a direct dependency.
|
||||||
|
|
||||||
```bash
|
|
||||||
uv pip install multibase
|
|
||||||
```
|
|
||||||
|
|
||||||
Without the codec `parse_wg_maddr()` raises immediately with an
|
Without the codec `parse_wg_maddr()` raises immediately with an
|
||||||
actionable message — there is deliberately **no** degraded
|
actionable message — there is deliberately **no** degraded
|
||||||
hand-split fallback. `_have_wg_maddr_proto()` is the predicate.
|
hand-split fallback. `_wg_proto_code()` performs the capability
|
||||||
|
check before parsing.
|
||||||
|
|
||||||
Every peel and re-compose here goes through `py-multiaddr`'s own
|
Every peel and re-compose here goes through `py-multiaddr`'s own
|
||||||
tunnel API (`.decapsulate_code()`, `.split()`, `.join()`,
|
tunnel API (`.decapsulate_code()`, `.split()`, `.join()`,
|
||||||
|
|
@ -118,9 +115,9 @@ ping -c1 10.0.11.1 # from B
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python -c "
|
python -c "
|
||||||
import base64, multibase
|
from tractor.discovery import mb_pubkey
|
||||||
key = open('wg_pub.key').read().strip()
|
key = open('wg_pub.key').read().strip()
|
||||||
print(multibase.encode('base64url', base64.b64decode(key)).decode())
|
print(mb_pubkey(key))
|
||||||
"
|
"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -161,7 +158,7 @@ Four corrections, all from
|
||||||
setups; if yours needs root, run the script as root rather
|
setups; if yours needs root, run the script as root rather
|
||||||
than embedding `sudo`.
|
than embedding `sudo`.
|
||||||
4. **no new `Address` proto-type.** The tunnel rides *beside* the
|
4. **no new `Address` proto-type.** The tunnel rides *beside* the
|
||||||
overlay addr in a frozen `WGTunnelledAddr`, and only `.overlay`
|
overlay addr in a frozen `TunnelledAddress`, and only `.overlay`
|
||||||
crosses into `open_nursery()`. #482 §6 floated a `WGAddress`
|
crosses into `open_nursery()`. #482 §6 floated a `WGAddress`
|
||||||
registered in `_address_types` — that table is a `bidict`
|
registered in `_address_types` — that table is a `bidict`
|
||||||
(1:1 proto-key↔type) and `_addr_to_transport` wants a
|
(1:1 proto-key↔type) and `_addr_to_transport` wants a
|
||||||
|
|
@ -169,8 +166,7 @@ Four corrections, all from
|
||||||
|
|
||||||
## next
|
## next
|
||||||
|
|
||||||
`WGTunnelledAddr` is deliberately example-local. Promoting it to
|
Layer A's `TunnelledAddress` and native maddr parser now live in
|
||||||
`tractor.discovery` as a `TunnelledAddress` whose
|
`tractor.discovery`. Next, replace this example's `wg(8)` verification
|
||||||
`.proto_key`/`.unwrap()` delegate to `.overlay`, plus
|
probe with `pyroute2`, then add `open_bindspace()` `@acm`s which
|
||||||
`open_bindspace()` `@acm`s that create/tear down the iface +
|
create/tear down the iface and netns.
|
||||||
netns via `pyroute2`, is layers A→C of the plan doc.
|
|
||||||
|
|
|
||||||
|
|
@ -10,13 +10,14 @@ from __future__ import annotations
|
||||||
|
|
||||||
import tractor
|
import tractor
|
||||||
import trio
|
import trio
|
||||||
|
from tractor.discovery import (
|
||||||
from wg_maddr import (
|
TunnelledAddress,
|
||||||
|
mk_maddr,
|
||||||
parse_wg_maddr,
|
parse_wg_maddr,
|
||||||
verify_wg_peer,
|
|
||||||
WGTunnelledAddr,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from wg_maddr import verify_wg_peer
|
||||||
|
|
||||||
# bearer = host A's underlay `(ip, wg ListenPort)`
|
# bearer = host A's underlay `(ip, wg ListenPort)`
|
||||||
# key = host A's OWN tunnel pubkey
|
# key = host A's OWN tunnel pubkey
|
||||||
# overlay = the ep `tractor` binds, on the wg iface's addr
|
# overlay = the ep `tractor` binds, on the wg iface's addr
|
||||||
|
|
@ -33,27 +34,27 @@ async def echo(msg: str) -> str:
|
||||||
|
|
||||||
|
|
||||||
async def main():
|
async def main():
|
||||||
addr: WGTunnelledAddr = parse_wg_maddr(WG_MADDR)
|
addr: TunnelledAddress = parse_wg_maddr(WG_MADDR)
|
||||||
assert verify_wg_peer(addr), (
|
assert verify_wg_peer(addr), (
|
||||||
f'wg pubkey from maddr not active on wg0 !\n'
|
f'wg pubkey from maddr not active on wg0 !\n'
|
||||||
f'maddr: {WG_MADDR}\n'
|
f'maddr: {WG_MADDR}\n'
|
||||||
f'key: {addr.peer_pubkey}\n'
|
f'key: {addr.tunnel.peer_pubkey}\n'
|
||||||
)
|
)
|
||||||
print(
|
print(
|
||||||
f'wg bearer (kernel-owned): {addr.bearer}\n'
|
f'wg bearer (kernel-owned): {addr.tunnel.bearer}\n'
|
||||||
f'tractor overlay ep: {addr.overlay}\n'
|
f'tractor overlay ep: {addr.overlay}\n'
|
||||||
)
|
)
|
||||||
async with tractor.open_nursery(
|
async with tractor.open_nursery(
|
||||||
# XXX only `.overlay` crosses into the runtime; the bearer
|
# XXX only `.overlay` crosses into the runtime; the bearer
|
||||||
# + key are iface-layer concerns `tractor` never binds.
|
# + key are bindspace metadata, never `Endpoint` addrs.
|
||||||
registry_addrs=[addr.overlay],
|
registry_addrs=[addr.overlay],
|
||||||
enable_transports=[addr.overlay_proto],
|
enable_transports=[addr.overlay.proto_key],
|
||||||
) as an:
|
) as an:
|
||||||
await an.start_actor(
|
await an.start_actor(
|
||||||
'echo_srv',
|
'echo_srv',
|
||||||
enable_modules=[__name__],
|
enable_modules=[__name__],
|
||||||
)
|
)
|
||||||
print(f'echo_srv up on\n {addr.maddr}\n')
|
print(f'echo_srv up on\n {mk_maddr(addr)}\n')
|
||||||
await trio.sleep_forever()
|
await trio.sleep_forever()
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,13 +8,13 @@ from __future__ import annotations
|
||||||
|
|
||||||
import tractor
|
import tractor
|
||||||
import trio
|
import trio
|
||||||
|
from tractor.discovery import (
|
||||||
|
TunnelledAddress,
|
||||||
|
parse_wg_maddr,
|
||||||
|
)
|
||||||
|
|
||||||
from host_a_srv import echo # noqa: F401 (RPC refs it by mod path)
|
from host_a_srv import echo # noqa: F401 (RPC refs it by mod path)
|
||||||
from wg_maddr import (
|
from wg_maddr import verify_wg_peer
|
||||||
parse_wg_maddr,
|
|
||||||
verify_wg_peer,
|
|
||||||
WGTunnelledAddr,
|
|
||||||
)
|
|
||||||
|
|
||||||
# same maddr as host A: A's bearer, A's key, A's overlay ep
|
# same maddr as host A: A's bearer, A's key, A's overlay ep
|
||||||
WG_MADDR: str = (
|
WG_MADDR: str = (
|
||||||
|
|
@ -25,7 +25,7 @@ WG_MADDR: str = (
|
||||||
|
|
||||||
|
|
||||||
async def main():
|
async def main():
|
||||||
addr: WGTunnelledAddr = parse_wg_maddr(WG_MADDR)
|
addr: TunnelledAddress = parse_wg_maddr(WG_MADDR)
|
||||||
assert verify_wg_peer(addr), (
|
assert verify_wg_peer(addr), (
|
||||||
f'wg pubkey from maddr not a peer on wg0 !\n'
|
f'wg pubkey from maddr not a peer on wg0 !\n'
|
||||||
f'maddr: {WG_MADDR}\n'
|
f'maddr: {WG_MADDR}\n'
|
||||||
|
|
@ -34,7 +34,7 @@ async def main():
|
||||||
tractor.open_root_actor(
|
tractor.open_root_actor(
|
||||||
name='wg_client',
|
name='wg_client',
|
||||||
registry_addrs=[addr.overlay],
|
registry_addrs=[addr.overlay],
|
||||||
enable_transports=[addr.overlay_proto],
|
enable_transports=[addr.overlay.proto_key],
|
||||||
),
|
),
|
||||||
tractor.find_actor(
|
tractor.find_actor(
|
||||||
'echo_srv',
|
'echo_srv',
|
||||||
|
|
|
||||||
|
|
@ -1,289 +1,53 @@
|
||||||
# tractor: distributed structured concurrency.
|
# tractor: distributed structured concurrency.
|
||||||
r'''
|
r'''
|
||||||
Parse `wg`-tunnelled multiaddrs into `tractor`-ready addrs.
|
Verify `wg` peers declared by tractor's multiaddr parser.
|
||||||
|
|
||||||
The canonical form (per py-multiaddr #108, verified against its
|
`tractor.discovery.parse_wg_maddr()` owns pure parsing and delegates
|
||||||
upstream merge) nests the *overlay* endpoint **after** the `/wg/`
|
all tunnel peeling to `py-multiaddr`. This example keeps only the
|
||||||
segment:
|
explicit impure probe used by the two-host demo; parsing never shells
|
||||||
|
out or verifies local interface state implicitly.
|
||||||
|
|
||||||
|
The canonical maddr form is:
|
||||||
|
|
||||||
/ip4/10.0.0.1/udp/51820/wg/u<key>/ip4/10.0.11.1/tcp/1616
|
/ip4/10.0.0.1/udp/51820/wg/u<key>/ip4/10.0.11.1/tcp/1616
|
||||||
\_______ wg bearer ______/\_ key _/\____ tractor ep _____/
|
\_______ wg bearer ______/\_ key _/\____ tractor ep _____/
|
||||||
(underlay, wg
|
|
||||||
`ListenPort`)
|
|
||||||
|
|
||||||
Naming follows `py-multiaddr`'s own encapsulation model, where
|
The kernel owns the bearer socket. A future tractor bindspace may
|
||||||
earlier segments *wrap* later ones (`.encapsulate()` appends), so
|
provision it through netlink, but only the overlay is an application
|
||||||
the two roles are:
|
`MsgTransport` endpoint.
|
||||||
|
|
||||||
- **bearer**: the segs *before* `/wg/`, i.e. the underlay
|
|
||||||
`(ip, udp-port)` that `wg(8)` itself listens on. Nothing in
|
|
||||||
`tractor` ever binds this — the kernel/`wg` iface owns it.
|
|
||||||
- **overlay**: the segs *after*, i.e. the addr `tractor` actually
|
|
||||||
binds/dials. The only part the runtime ever sees.
|
|
||||||
|
|
||||||
We deliberately avoid `inner`/`outer` for these two: 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 — two opposite intuitions on one word.
|
|
||||||
|
|
||||||
`/wg/u<key>` itself carries the tunnel peer's Curve25519 pubkey
|
|
||||||
as multibase base64url (std base64 from `wg(8)` contains `/` and
|
|
||||||
so can't go in a `/`-delimited maddr). It binds nothing at all;
|
|
||||||
it's an identity, verified out-of-band.
|
|
||||||
|
|
||||||
XXX NOTE, `tractor`'s own `parse_maddr()` can't parse this yet
|
|
||||||
(`ValueError('Unsupported multiaddr protocol combo')`), which is
|
|
||||||
why this module exists: peel here, hand `.overlay` to the
|
|
||||||
runtime.
|
|
||||||
|
|
||||||
Design rules this module follows (see
|
|
||||||
`ai/tpt-backends/03_wg_tunnel_bindspace.md`):
|
|
||||||
|
|
||||||
- **let `py-multiaddr` do the parsing**. Every peel/compose goes
|
|
||||||
through `.decapsulate_code()`, `.split()`, `.join()`,
|
|
||||||
`.encapsulate()` and `.value_for_protocol()`. We hand-roll no
|
|
||||||
segment splitting whatsoever — the whole point of gh #429 was
|
|
||||||
dropping the NIH parser, and that applies to *peeling a tunnel
|
|
||||||
stack* every bit as much as to decoding a single proto.
|
|
||||||
- **parsing is pure**. `parse_wg_maddr()` does no I/O, no
|
|
||||||
`subprocess`, no netlink. A parser that shells out is a nasty
|
|
||||||
surprise.
|
|
||||||
- **verification is an explicit, separate step**. The caller
|
|
||||||
composes `verify_wg_peer()` when it wants it; nothing implicit.
|
|
||||||
- **no new `Address` proto-type**. `wg` gets no entry in
|
|
||||||
`tractor.discovery._addr._address_types` (a `bidict`, so 1:1
|
|
||||||
proto-key<->type) bc it has no `MsgTransport` of its own. The
|
|
||||||
tunnel is a *bindspace*, so we carry it beside the overlay
|
|
||||||
addr and strip to `.overlay` at bind/dial time.
|
|
||||||
|
|
||||||
'''
|
'''
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
import base64
|
|
||||||
import subprocess
|
import subprocess
|
||||||
from typing import Literal
|
|
||||||
|
|
||||||
import msgspec
|
from tractor.discovery import (
|
||||||
from multiaddr import Multiaddr
|
TunnelledAddress,
|
||||||
from multiaddr.protocols import P_WG
|
WGTunnelSpec,
|
||||||
|
)
|
||||||
|
|
||||||
IPProto = Literal['ip4', 'ip6']
|
|
||||||
|
|
||||||
|
|
||||||
class WGTunnelledAddr(
|
|
||||||
msgspec.Struct,
|
|
||||||
frozen=True,
|
|
||||||
):
|
|
||||||
'''
|
|
||||||
A `wg`-tunnelled endpoint: the underlay bearer, the tunnel
|
|
||||||
peer key, and the overlay addr `tractor` binds/dials.
|
|
||||||
|
|
||||||
'''
|
|
||||||
# underlay, owned by `wg(8)`/the kernel — NEVER bound by us
|
|
||||||
bearer: tuple[str, int]
|
|
||||||
|
|
||||||
# tunnel peer pubkey in the std-base64 `wg(8)` form, i.e.
|
|
||||||
# directly comparable to `wg show <if> peers` output
|
|
||||||
peer_pubkey: str
|
|
||||||
|
|
||||||
# overlay ep: an `UnwrappedAddress` as accepted by
|
|
||||||
# `tractor.discovery.wrap_address()`
|
|
||||||
overlay: tuple[str, int]
|
|
||||||
overlay_proto: Literal['tcp'] = 'tcp'
|
|
||||||
|
|
||||||
# kept so `.as_multiaddr()` re-renders the same ip family it
|
|
||||||
# was parsed from, rather than assuming v4
|
|
||||||
bearer_ip: IPProto = 'ip4'
|
|
||||||
overlay_ip: IPProto = 'ip4'
|
|
||||||
|
|
||||||
def as_multiaddr(self) -> Multiaddr:
|
|
||||||
'''
|
|
||||||
Re-compose the canonical `Multiaddr`, bearer outward-in,
|
|
||||||
using `.encapsulate()` exactly as py-multiaddr's own
|
|
||||||
tunneling example does.
|
|
||||||
|
|
||||||
'''
|
|
||||||
b_host, b_port = self.bearer
|
|
||||||
o_host, o_port = self.overlay
|
|
||||||
return (
|
|
||||||
Multiaddr(f'/{self.bearer_ip}/{b_host}/udp/{b_port}')
|
|
||||||
.encapsulate(
|
|
||||||
Multiaddr(f'/wg/{mb_pubkey(self.peer_pubkey)}')
|
|
||||||
)
|
|
||||||
.encapsulate(
|
|
||||||
Multiaddr(
|
|
||||||
f'/{self.overlay_ip}/{o_host}'
|
|
||||||
f'/{self.overlay_proto}/{o_port}'
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def maddr(self) -> str:
|
|
||||||
'''
|
|
||||||
The canonical maddr `str` form.
|
|
||||||
|
|
||||||
'''
|
|
||||||
return str(self.as_multiaddr())
|
|
||||||
|
|
||||||
|
|
||||||
def mb_pubkey(wg8_key: str) -> str:
|
|
||||||
'''
|
|
||||||
`wg(8)` std-base64 pubkey -> multibase base64url (`u`-prefixed).
|
|
||||||
|
|
||||||
'''
|
|
||||||
import multibase
|
|
||||||
raw: bytes = base64.b64decode(wg8_key)
|
|
||||||
return multibase.encode('base64url', raw).decode('ascii')
|
|
||||||
|
|
||||||
|
|
||||||
def wg8_pubkey(mb_key: str) -> str:
|
|
||||||
'''
|
|
||||||
Inverse of `mb_pubkey()`: multibase -> `wg(8)` std-base64.
|
|
||||||
|
|
||||||
'''
|
|
||||||
import multibase
|
|
||||||
raw: bytes = multibase.decode(mb_key)
|
|
||||||
return base64.b64encode(raw).decode('ascii')
|
|
||||||
|
|
||||||
|
|
||||||
_wg_proto_known: bool|None = None
|
|
||||||
|
|
||||||
|
|
||||||
def _have_wg_maddr_proto() -> bool:
|
|
||||||
'''
|
|
||||||
True iff the installed `py-multiaddr` knows the `/wg/` proto,
|
|
||||||
i.e. carries py-multiaddr#108.
|
|
||||||
|
|
||||||
Merged upstream 2026-07-28 (`f86519da`) but in no release as
|
|
||||||
of `0.2.0`, hence the `[tool.uv.sources]` `rev` pin in
|
|
||||||
`pyproject.toml`.
|
|
||||||
|
|
||||||
Pure predicate; result cached since it can't change without a
|
|
||||||
reinstall.
|
|
||||||
|
|
||||||
'''
|
|
||||||
global _wg_proto_known
|
|
||||||
if _wg_proto_known is None:
|
|
||||||
from multiaddr.protocols import protocol_with_name
|
|
||||||
from multiaddr.exceptions import ProtocolNotFoundError
|
|
||||||
try:
|
|
||||||
protocol_with_name('wg')
|
|
||||||
_wg_proto_known = True
|
|
||||||
except ProtocolNotFoundError:
|
|
||||||
_wg_proto_known = False
|
|
||||||
|
|
||||||
return _wg_proto_known
|
|
||||||
|
|
||||||
|
|
||||||
def parse_wg_maddr(
|
|
||||||
maddr: str|Multiaddr,
|
|
||||||
) -> WGTunnelledAddr:
|
|
||||||
'''
|
|
||||||
Peel a `wg`-tunnelled maddr into its bearer/key/overlay
|
|
||||||
parts. Pure — no I/O.
|
|
||||||
|
|
||||||
Every cut is made by `py-multiaddr`, so a malformed maddr
|
|
||||||
(incl. a `wg` key that isn't exactly 32B) raises out of
|
|
||||||
`Multiaddr()` rather than yielding a struct quietly built
|
|
||||||
from garbage segs.
|
|
||||||
|
|
||||||
'''
|
|
||||||
if not _have_wg_maddr_proto():
|
|
||||||
raise RuntimeError(
|
|
||||||
f'Installed `py-multiaddr` has no `/wg/` proto!\n'
|
|
||||||
f'Needs py-multiaddr#108, merged upstream but not\n'
|
|
||||||
f'yet released; a `uv sync` picks up the pinned rev.\n'
|
|
||||||
f'maddr: {maddr!r}\n'
|
|
||||||
)
|
|
||||||
|
|
||||||
ma: Multiaddr = (
|
|
||||||
maddr
|
|
||||||
if isinstance(maddr, Multiaddr)
|
|
||||||
else Multiaddr(maddr)
|
|
||||||
)
|
|
||||||
segs: list[Multiaddr] = ma.split()
|
|
||||||
names: list[str] = [
|
|
||||||
proto.name
|
|
||||||
for seg in segs
|
|
||||||
for proto in seg.protocols()
|
|
||||||
]
|
|
||||||
if 'wg' not in names:
|
|
||||||
raise ValueError(
|
|
||||||
f'Not a `wg`-tunnelled maddr, no `/wg/` segment ??\n'
|
|
||||||
f'maddr: {ma}\n'
|
|
||||||
)
|
|
||||||
|
|
||||||
# NOTE, `.decapsulate_code()` cuts at the LAST occurrence of
|
|
||||||
# the proto and keeps the *prefix*, which is exactly the
|
|
||||||
# bearer. It handles `/wg/` cleanly precisely bc it cuts on
|
|
||||||
# proto-code and never tries to match an addr value — the
|
|
||||||
# key seg has no addr of its own.
|
|
||||||
bearer_ma: Multiaddr = ma.decapsulate_code(P_WG)
|
|
||||||
overlay_ma: Multiaddr = Multiaddr.join(
|
|
||||||
*segs[names.index('wg') + 1:]
|
|
||||||
)
|
|
||||||
|
|
||||||
match [proto.name for proto in bearer_ma.protocols()]:
|
|
||||||
case [('ip4' | 'ip6') as b_ip, 'udp']:
|
|
||||||
bearer = (
|
|
||||||
bearer_ma.value_for_protocol(b_ip),
|
|
||||||
int(bearer_ma.value_for_protocol('udp')),
|
|
||||||
)
|
|
||||||
case _:
|
|
||||||
raise ValueError(
|
|
||||||
f'Bad `wg` bearer, expected `/ip4|ip6/<h>/udp/<p>`\n'
|
|
||||||
f'got: {bearer_ma}\n'
|
|
||||||
f'from maddr: {ma}\n'
|
|
||||||
)
|
|
||||||
|
|
||||||
match [proto.name for proto in overlay_ma.protocols()]:
|
|
||||||
case [('ip4' | 'ip6') as o_ip, ('tcp') as l4]:
|
|
||||||
overlay = (
|
|
||||||
overlay_ma.value_for_protocol(o_ip),
|
|
||||||
int(overlay_ma.value_for_protocol(l4)),
|
|
||||||
)
|
|
||||||
case []:
|
|
||||||
raise ValueError(
|
|
||||||
f'`wg` maddr declares no overlay endpoint!\n'
|
|
||||||
f'A bare `/…/wg/<key>` names only the tunnel; '
|
|
||||||
f'append the ep `tractor` should bind, e.g.\n'
|
|
||||||
f' {ma}/ip4/10.0.11.1/tcp/1616\n'
|
|
||||||
)
|
|
||||||
case _:
|
|
||||||
raise ValueError(
|
|
||||||
f'Unsupported `wg` overlay proto combo\n'
|
|
||||||
f'got: {overlay_ma}\n'
|
|
||||||
f'from maddr: {ma}\n'
|
|
||||||
)
|
|
||||||
|
|
||||||
return WGTunnelledAddr(
|
|
||||||
bearer=bearer,
|
|
||||||
peer_pubkey=wg8_pubkey(ma.value_for_protocol('wg')),
|
|
||||||
overlay=overlay,
|
|
||||||
overlay_proto=l4,
|
|
||||||
bearer_ip=b_ip,
|
|
||||||
overlay_ip=o_ip,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def verify_wg_peer(
|
def verify_wg_peer(
|
||||||
addr: WGTunnelledAddr,
|
addr: TunnelledAddress,
|
||||||
iface: str = 'wg0',
|
iface: str|None = None,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
'''
|
'''
|
||||||
True iff `addr.peer_pubkey` is a configured peer (or our own
|
Check the outer tunnel's key against one local `wg` iface.
|
||||||
pubkey) on `iface`.
|
|
||||||
|
|
||||||
IMPURE + explicit by design: never called from
|
IMPURE + explicit by design: neither `parse_wg_maddr()` nor
|
||||||
`parse_wg_maddr()`.
|
`tractor.discovery.parse_maddr()` calls this probe.
|
||||||
|
|
||||||
?TODO, per plan-03 layer B, swap this body for `pyroute2`
|
?TODO, per plan-03 layer B, swap this body for `pyroute2`
|
||||||
(keeping the signature) — and note `setns(2)` is *per-thread*,
|
while retaining the explicit verification boundary.
|
||||||
so a query issued via `trio.to_thread` lands in the ORIGINAL
|
|
||||||
netns unless `netns=` is passed down.
|
|
||||||
|
|
||||||
'''
|
'''
|
||||||
|
spec = addr.tunnel
|
||||||
|
if not isinstance(spec, WGTunnelSpec):
|
||||||
|
raise TypeError(
|
||||||
|
f'Unsupported tunnel spec: {type(spec)!r}'
|
||||||
|
)
|
||||||
|
|
||||||
|
iface = iface or spec.iface
|
||||||
|
|
||||||
def _wg(*args: str) -> str:
|
def _wg(*args: str) -> str:
|
||||||
return subprocess.run(
|
return subprocess.run(
|
||||||
['wg', 'show', iface, *args],
|
['wg', 'show', iface, *args],
|
||||||
|
|
@ -293,7 +57,7 @@ def verify_wg_peer(
|
||||||
).stdout
|
).stdout
|
||||||
|
|
||||||
return (
|
return (
|
||||||
addr.peer_pubkey in _wg('peers').split()
|
spec.peer_pubkey in _wg('peers').split()
|
||||||
or
|
or
|
||||||
addr.peer_pubkey == _wg('public-key').strip()
|
spec.peer_pubkey == _wg('public-key').strip()
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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"]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,100 @@
|
||||||
|
'''
|
||||||
|
Canonical tagged-address decoding and legacy input compatibility.
|
||||||
|
|
||||||
|
'''
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from tractor.discovery._addr import wrap_address
|
||||||
|
from tractor.ipc._tcp import TCPAddress
|
||||||
|
from tractor.ipc._uds import UDSAddress
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
'value',
|
||||||
|
[
|
||||||
|
('tcp', '127.0.0.1', 1616),
|
||||||
|
['tcp', '127.0.0.1', 1616],
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_decode_tagged_tcp_address(value):
|
||||||
|
'''
|
||||||
|
Shape-only decoding cannot distinguish future transport address
|
||||||
|
forms. Feed canonical tuple and msgpack-style list values through
|
||||||
|
the compatibility boundary and prove the explicit `tcp` tag
|
||||||
|
selects the TCP backend and emits the canonical tagged form.
|
||||||
|
|
||||||
|
'''
|
||||||
|
addr = wrap_address(value)
|
||||||
|
|
||||||
|
assert type(addr) is TCPAddress
|
||||||
|
assert addr.unwrap() == ('tcp', '127.0.0.1', 1616)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
'tag',
|
||||||
|
['unix', 'uds'],
|
||||||
|
)
|
||||||
|
@pytest.mark.parametrize('container', [tuple, list])
|
||||||
|
def test_decode_tagged_unix_address(
|
||||||
|
tag: str,
|
||||||
|
container: type,
|
||||||
|
):
|
||||||
|
'''
|
||||||
|
Multiaddr calls the protocol `unix` while tractor's transport key
|
||||||
|
remains `uds`. Decode both spellings from tuple/list containers,
|
||||||
|
normalize them to one `UDSAddress`, and emit the canonical `unix`
|
||||||
|
spelling.
|
||||||
|
|
||||||
|
'''
|
||||||
|
value = container((tag, '/tmp/tractor/registry.sock'))
|
||||||
|
addr = wrap_address(value)
|
||||||
|
|
||||||
|
assert type(addr) is UDSAddress
|
||||||
|
assert addr.sockpath == Path('/tmp/tractor/registry.sock')
|
||||||
|
assert addr.unwrap() == (
|
||||||
|
'unix',
|
||||||
|
'/tmp/tractor/registry.sock',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
'value, expected_type',
|
||||||
|
[
|
||||||
|
(('127.0.0.1', 1616), TCPAddress),
|
||||||
|
(['127.0.0.1', 1616], TCPAddress),
|
||||||
|
(('/tmp/tractor', 'registry.sock'), UDSAddress),
|
||||||
|
(['/tmp/tractor', 'registry.sock'], UDSAddress),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_decode_legacy_address_forms(
|
||||||
|
value,
|
||||||
|
expected_type: type,
|
||||||
|
):
|
||||||
|
'''
|
||||||
|
Existing callers, config, and older msgpack payloads still
|
||||||
|
provide untagged pairs. Keep tuple/list forms readable while
|
||||||
|
canonical tagged emission is introduced, proving the writer
|
||||||
|
migration does not break shipped input behavior.
|
||||||
|
|
||||||
|
'''
|
||||||
|
addr = wrap_address(value)
|
||||||
|
|
||||||
|
assert type(addr) is expected_type
|
||||||
|
assert addr.unwrap()[0] in {'tcp', 'unix'}
|
||||||
|
|
||||||
|
|
||||||
|
def test_tcp_from_native_ipv6_sockname():
|
||||||
|
'''
|
||||||
|
`socket.getsockname()` returns a four-item IPv6 sockaddr which is
|
||||||
|
neither a wire form nor a legacy two-item pair. Preserve it as an
|
||||||
|
OS compatibility boundary and intentionally ignore unsupported
|
||||||
|
flow-info/scope-id fields when constructing `TCPAddress`.
|
||||||
|
|
||||||
|
'''
|
||||||
|
addr = TCPAddress.from_addr(
|
||||||
|
('::1', 1616, 0, 0)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert addr.unwrap() == ('tcp', '::1', 1616)
|
||||||
|
|
@ -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`
|
||||||
|
|
@ -167,7 +188,7 @@ def test_parse_maddr_tcp_ipv4():
|
||||||
result = parse_maddr('/ip4/127.0.0.1/tcp/1234')
|
result = parse_maddr('/ip4/127.0.0.1/tcp/1234')
|
||||||
|
|
||||||
assert isinstance(result, TCPAddress)
|
assert isinstance(result, TCPAddress)
|
||||||
assert result.unwrap() == ('127.0.0.1', 1234)
|
assert result.unwrap() == ('tcp', '127.0.0.1', 1234)
|
||||||
|
|
||||||
|
|
||||||
def test_parse_maddr_tcp_ipv6():
|
def test_parse_maddr_tcp_ipv6():
|
||||||
|
|
@ -179,7 +200,7 @@ def test_parse_maddr_tcp_ipv6():
|
||||||
result = parse_maddr('/ip6/::1/tcp/5678')
|
result = parse_maddr('/ip6/::1/tcp/5678')
|
||||||
|
|
||||||
assert isinstance(result, TCPAddress)
|
assert isinstance(result, TCPAddress)
|
||||||
assert result.unwrap() == ('::1', 5678)
|
assert result.unwrap() == ('tcp', '::1', 5678)
|
||||||
|
|
||||||
|
|
||||||
def test_parse_maddr_uds():
|
def test_parse_maddr_uds():
|
||||||
|
|
@ -192,9 +213,10 @@ def test_parse_maddr_uds():
|
||||||
result = parse_maddr('/unix/tmp/tractor_test/test.sock')
|
result = parse_maddr('/unix/tmp/tractor_test/test.sock')
|
||||||
|
|
||||||
assert isinstance(result, UDSAddress)
|
assert isinstance(result, UDSAddress)
|
||||||
filedir, filename = result.unwrap()
|
assert result.unwrap() == (
|
||||||
assert filename == 'test.sock'
|
'unix',
|
||||||
assert str(filedir) == '/tmp/tractor_test'
|
'/tmp/tractor_test/test.sock',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_parse_maddr_unsupported():
|
def test_parse_maddr_unsupported():
|
||||||
|
|
@ -210,6 +232,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() == ('tcp', '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',
|
||||||
[
|
[
|
||||||
|
|
@ -249,7 +446,22 @@ def test_wrap_address_maddr_str():
|
||||||
result = wrap_address('/ip4/127.0.0.1/tcp/9999')
|
result = wrap_address('/ip4/127.0.0.1/tcp/9999')
|
||||||
|
|
||||||
assert isinstance(result, TCPAddress)
|
assert isinstance(result, TCPAddress)
|
||||||
assert result.unwrap() == ('127.0.0.1', 9999)
|
assert result.unwrap() == ('tcp', '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() == ('tcp', '10.0.11.1', 1616)
|
||||||
|
|
||||||
|
|
||||||
# ------ parse_endpoints() tests ------
|
# ------ parse_endpoints() tests ------
|
||||||
|
|
@ -270,11 +482,11 @@ def test_parse_endpoints_tcp_only():
|
||||||
|
|
||||||
reg_addr = result['registry'][0]
|
reg_addr = result['registry'][0]
|
||||||
assert isinstance(reg_addr, TCPAddress)
|
assert isinstance(reg_addr, TCPAddress)
|
||||||
assert reg_addr.unwrap() == ('127.0.0.1', 1616)
|
assert reg_addr.unwrap() == ('tcp', '127.0.0.1', 1616)
|
||||||
|
|
||||||
feed_addr = result['data_feed'][0]
|
feed_addr = result['data_feed'][0]
|
||||||
assert isinstance(feed_addr, TCPAddress)
|
assert isinstance(feed_addr, TCPAddress)
|
||||||
assert feed_addr.unwrap() == ('0.0.0.0', 5555)
|
assert feed_addr.unwrap() == ('tcp', '0.0.0.0', 5555)
|
||||||
|
|
||||||
|
|
||||||
def test_parse_endpoints_mixed_tpts():
|
def test_parse_endpoints_mixed_tpts():
|
||||||
|
|
@ -294,12 +506,37 @@ def test_parse_endpoints_mixed_tpts():
|
||||||
|
|
||||||
assert len(addrs) == 2
|
assert len(addrs) == 2
|
||||||
assert isinstance(addrs[0], TCPAddress)
|
assert isinstance(addrs[0], TCPAddress)
|
||||||
assert addrs[0].unwrap() == ('127.0.0.1', 4040)
|
assert addrs[0].unwrap() == ('tcp', '127.0.0.1', 4040)
|
||||||
|
|
||||||
assert isinstance(addrs[1], UDSAddress)
|
assert isinstance(addrs[1], UDSAddress)
|
||||||
filedir, filename = addrs[1].unwrap()
|
assert addrs[1].unwrap() == (
|
||||||
assert filename == 'broker.sock'
|
'unix',
|
||||||
assert str(filedir) == '/tmp/tractor'
|
'/tmp/tractor/broker.sock',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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():
|
||||||
|
|
@ -315,7 +552,7 @@ def test_parse_endpoints_unwrapped_tuples():
|
||||||
|
|
||||||
addr = result['ems'][0]
|
addr = result['ems'][0]
|
||||||
assert isinstance(addr, TCPAddress)
|
assert isinstance(addr, TCPAddress)
|
||||||
assert addr.unwrap() == ('127.0.0.1', 6666)
|
assert addr.unwrap() == ('tcp', '127.0.0.1', 6666)
|
||||||
|
|
||||||
|
|
||||||
def test_parse_endpoints_mixed_str_and_tuple():
|
def test_parse_endpoints_mixed_str_and_tuple():
|
||||||
|
|
@ -335,10 +572,10 @@ def test_parse_endpoints_mixed_str_and_tuple():
|
||||||
|
|
||||||
assert len(addrs) == 2
|
assert len(addrs) == 2
|
||||||
assert isinstance(addrs[0], TCPAddress)
|
assert isinstance(addrs[0], TCPAddress)
|
||||||
assert addrs[0].unwrap() == ('127.0.0.1', 7777)
|
assert addrs[0].unwrap() == ('tcp', '127.0.0.1', 7777)
|
||||||
|
|
||||||
assert isinstance(addrs[1], TCPAddress)
|
assert isinstance(addrs[1], TCPAddress)
|
||||||
assert addrs[1].unwrap() == ('127.0.0.1', 8888)
|
assert addrs[1].unwrap() == ('tcp', '127.0.0.1', 8888)
|
||||||
|
|
||||||
|
|
||||||
def test_parse_endpoints_unsupported_proto():
|
def test_parse_endpoints_unsupported_proto():
|
||||||
|
|
|
||||||
|
|
@ -371,7 +371,7 @@ def test_non_registrar_root_tpt_bind_addrs(
|
||||||
for uw_addr in bound:
|
for uw_addr in bound:
|
||||||
w = wrap_address(uw_addr)
|
w = wrap_address(uw_addr)
|
||||||
if w.proto_key == 'tcp':
|
if w.proto_key == 'tcp':
|
||||||
_host, port = uw_addr
|
_, _host, port = uw_addr
|
||||||
assert port > 0
|
assert port > 0
|
||||||
|
|
||||||
trio.run(_main)
|
trio.run(_main)
|
||||||
|
|
@ -443,7 +443,7 @@ def test_tpt_bind_addrs_as_maddr_str(
|
||||||
for uw_addr in actor.accept_addrs:
|
for uw_addr in actor.accept_addrs:
|
||||||
w = wrap_address(uw_addr)
|
w = wrap_address(uw_addr)
|
||||||
if w.proto_key == 'tcp':
|
if w.proto_key == 'tcp':
|
||||||
_host, port = uw_addr
|
_, _host, port = uw_addr
|
||||||
assert port > 0
|
assert port > 0
|
||||||
|
|
||||||
trio.run(_main)
|
trio.run(_main)
|
||||||
|
|
@ -475,7 +475,7 @@ def test_registrar_merge_binds_union(
|
||||||
# actually differ (always true for TCP, may
|
# actually differ (always true for TCP, may
|
||||||
# collide for UDS).
|
# collide for UDS).
|
||||||
expect_disjoint: bool = (
|
expect_disjoint: bool = (
|
||||||
tuple(reg_addr) != rando.unwrap()
|
reg_wrapped.unwrap() != rando.unwrap()
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _main():
|
async def _main():
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,234 @@
|
||||||
|
'''
|
||||||
|
`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,
|
||||||
|
mb_pubkey,
|
||||||
|
strip_tunnels,
|
||||||
|
tunnels_of,
|
||||||
|
wg8_pubkey,
|
||||||
|
)
|
||||||
|
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_wg_pubkey_codec_roundtrip():
|
||||||
|
'''
|
||||||
|
Standard `wg(8)` base64 keys can contain `/`, which cannot be
|
||||||
|
embedded unchanged in a slash-delimited maddr. Prove the helper
|
||||||
|
emits `u`-prefixed multibase base64url without `/` and decodes
|
||||||
|
it back to the exact original 32-byte key.
|
||||||
|
|
||||||
|
'''
|
||||||
|
mb_key: str = mb_pubkey(_PUBKEY)
|
||||||
|
|
||||||
|
assert mb_key.startswith('u')
|
||||||
|
assert '/' not in mb_key
|
||||||
|
assert wg8_pubkey(mb_key) == _PUBKEY
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
'key, converter',
|
||||||
|
[
|
||||||
|
pytest.param(
|
||||||
|
'dG9vIHNob3J0',
|
||||||
|
mb_pubkey,
|
||||||
|
id='wg8-base64',
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
'udG9vIHNob3J0',
|
||||||
|
wg8_pubkey,
|
||||||
|
id='multibase',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_wg_pubkey_codec_rejects_wrong_size(
|
||||||
|
key: str,
|
||||||
|
converter,
|
||||||
|
):
|
||||||
|
'''
|
||||||
|
WireGuard silently-corrupt key handling would let an invalid
|
||||||
|
identity reach peer verification. Exercise both input encodings
|
||||||
|
with a short payload and prove conversion rejects it before a
|
||||||
|
tunnel spec or maddr can be constructed.
|
||||||
|
|
||||||
|
'''
|
||||||
|
with pytest.raises(
|
||||||
|
ValueError,
|
||||||
|
match='must decode to 32 bytes',
|
||||||
|
):
|
||||||
|
converter(key)
|
||||||
|
|
||||||
|
|
||||||
|
def test_proto_key_delegates(
|
||||||
|
tunnelled: TunnelledAddress,
|
||||||
|
overlay: TCPAddress,
|
||||||
|
):
|
||||||
|
'''
|
||||||
|
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)
|
||||||
|
|
@ -0,0 +1,91 @@
|
||||||
|
'''
|
||||||
|
Tunnel annotation peeling at the outbound IPC transport boundary.
|
||||||
|
|
||||||
|
'''
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import trio
|
||||||
|
|
||||||
|
from tractor.discovery import (
|
||||||
|
TunnelledAddress,
|
||||||
|
WGTunnelSpec,
|
||||||
|
tunnels_of,
|
||||||
|
)
|
||||||
|
from tractor.ipc import _chan
|
||||||
|
from tractor.ipc._tcp import TCPAddress
|
||||||
|
|
||||||
|
|
||||||
|
_PUBKEY: str = 'g3x7z0AdV1rM6UQU22CC7IL3/ivn4DzrE7ikDhCZ/Dc='
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def overlay() -> TCPAddress:
|
||||||
|
return TCPAddress('127.0.0.1', 0)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def tunnelled(
|
||||||
|
overlay: TCPAddress,
|
||||||
|
) -> TunnelledAddress:
|
||||||
|
return TunnelledAddress(
|
||||||
|
overlay=overlay,
|
||||||
|
tunnel=WGTunnelSpec(
|
||||||
|
peer_pubkey=_PUBKEY,
|
||||||
|
bearer=('192.168.1.50', 51820),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('use_tunnel', [False, True])
|
||||||
|
def test_channel_peels_before_transport_dispatch(
|
||||||
|
monkeypatch,
|
||||||
|
overlay: TCPAddress,
|
||||||
|
tunnelled: TunnelledAddress,
|
||||||
|
use_tunnel: bool,
|
||||||
|
):
|
||||||
|
'''
|
||||||
|
Exact-type transport lookup cannot dispatch a `TunnelledAddress`,
|
||||||
|
and passing one onward would make TCP dial the wrong object. Feed
|
||||||
|
both a plain overlay and its annotated wrapper into
|
||||||
|
`Channel.from_addr()`, capture lookup and connect arguments, and
|
||||||
|
prove both transport operations receive only the same bindable
|
||||||
|
TCP address while the caller's tunnel metadata remains intact.
|
||||||
|
|
||||||
|
'''
|
||||||
|
seen: list[tuple[str, TCPAddress]] = []
|
||||||
|
|
||||||
|
class FakeTransport:
|
||||||
|
@classmethod
|
||||||
|
async def connect_to(
|
||||||
|
cls,
|
||||||
|
addr: TCPAddress,
|
||||||
|
**kwargs,
|
||||||
|
) -> FakeTransport:
|
||||||
|
seen.append(('connect', addr))
|
||||||
|
return cls()
|
||||||
|
|
||||||
|
def fake_transport_from_addr(
|
||||||
|
addr: TCPAddress,
|
||||||
|
) -> type[FakeTransport]:
|
||||||
|
seen.append(('lookup', addr))
|
||||||
|
return FakeTransport
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
_chan,
|
||||||
|
'transport_from_addr',
|
||||||
|
fake_transport_from_addr,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def main() -> None:
|
||||||
|
declared = tunnelled if use_tunnel else overlay
|
||||||
|
chan = await _chan.Channel.from_addr(declared)
|
||||||
|
assert isinstance(chan.transport, FakeTransport)
|
||||||
|
|
||||||
|
trio.run(main)
|
||||||
|
|
||||||
|
assert seen == [
|
||||||
|
('lookup', overlay),
|
||||||
|
('connect', overlay),
|
||||||
|
]
|
||||||
|
assert tunnels_of(tunnelled) == (tunnelled.tunnel,)
|
||||||
|
|
@ -703,17 +703,20 @@ def test_uds_bindspace_created_implicitly(
|
||||||
|
|
||||||
root: Actor = tractor.current_actor()
|
root: Actor = tractor.current_actor()
|
||||||
assert root.is_registrar
|
assert root.is_registrar
|
||||||
|
canonical_addr = _addr.wrap_address(
|
||||||
|
registry_addr,
|
||||||
|
).unwrap()
|
||||||
|
|
||||||
assert registry_addr in root.reg_addrs
|
assert canonical_addr in root.reg_addrs
|
||||||
assert (
|
assert (
|
||||||
registry_addr
|
canonical_addr
|
||||||
in
|
in
|
||||||
_state._runtime_vars['_registry_addrs']
|
_state._runtime_vars['_registry_addrs']
|
||||||
)
|
)
|
||||||
assert (
|
assert (
|
||||||
_addr.wrap_address(registry_addr)
|
canonical_addr
|
||||||
in
|
in
|
||||||
root.registry_addrs
|
[addr.unwrap() for addr in root.registry_addrs]
|
||||||
)
|
)
|
||||||
|
|
||||||
trio.run(main)
|
trio.run(main)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,59 @@
|
||||||
|
'''
|
||||||
|
Tunnel annotation peeling at the inbound IPC transport boundary.
|
||||||
|
|
||||||
|
'''
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import trio
|
||||||
|
|
||||||
|
from tractor.discovery import (
|
||||||
|
TunnelledAddress,
|
||||||
|
WGTunnelSpec,
|
||||||
|
tunnels_of,
|
||||||
|
)
|
||||||
|
from tractor.ipc._server import open_ipc_server
|
||||||
|
from tractor.ipc._tcp import TCPAddress
|
||||||
|
|
||||||
|
|
||||||
|
_PUBKEY: str = 'g3x7z0AdV1rM6UQU22CC7IL3/ivn4DzrE7ikDhCZ/Dc='
|
||||||
|
|
||||||
|
|
||||||
|
def test_server_peels_before_endpoint_construction():
|
||||||
|
'''
|
||||||
|
`Endpoint.start_listener()` reflects on its address's declaring
|
||||||
|
module, so retaining a tunnel wrapper there selects `._tunnel`
|
||||||
|
instead of the TCP backend. Start a real listener from the
|
||||||
|
wrapper, assert the resulting `Endpoint` contains only a resolved
|
||||||
|
`TCPAddress`, and prove the original declaration still carries
|
||||||
|
its tunnel spec for the future bindspace lifecycle.
|
||||||
|
|
||||||
|
'''
|
||||||
|
overlay = TCPAddress('127.0.0.1', 0)
|
||||||
|
tunnelled = TunnelledAddress(
|
||||||
|
overlay=overlay,
|
||||||
|
tunnel=WGTunnelSpec(
|
||||||
|
peer_pubkey=_PUBKEY,
|
||||||
|
bearer=('192.168.1.50', 51820),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def main() -> None:
|
||||||
|
async with open_ipc_server() as server:
|
||||||
|
eps = await server.listen_on(
|
||||||
|
accept_addrs=[tunnelled],
|
||||||
|
)
|
||||||
|
assert len(eps) == 1
|
||||||
|
endpoint = eps[0]
|
||||||
|
|
||||||
|
assert type(endpoint.addr) is TCPAddress
|
||||||
|
_, host, port = endpoint.addr.unwrap()
|
||||||
|
assert host == overlay.unwrap()[1]
|
||||||
|
assert port > 0
|
||||||
|
assert endpoint.addr is not tunnelled
|
||||||
|
assert tunnels_of(tunnelled) == (
|
||||||
|
tunnelled.tunnel,
|
||||||
|
)
|
||||||
|
|
||||||
|
server.cancel()
|
||||||
|
|
||||||
|
trio.run(main)
|
||||||
|
|
@ -8,6 +8,7 @@ import trio
|
||||||
import tractor
|
import tractor
|
||||||
|
|
||||||
from tractor._testing import tractor_test
|
from tractor._testing import tractor_test
|
||||||
|
from tractor.discovery._addr import wrap_address
|
||||||
|
|
||||||
|
|
||||||
def test_no_runtime():
|
def test_no_runtime():
|
||||||
|
|
@ -48,7 +49,7 @@ async def test_self_is_registered_localportal(reg_addr):
|
||||||
with trio.fail_after(0.2):
|
with trio.fail_after(0.2):
|
||||||
sockaddr = await portal.run_from_ns(
|
sockaddr = await portal.run_from_ns(
|
||||||
'self', 'wait_for_actor', name='root')
|
'self', 'wait_for_actor', name='root')
|
||||||
assert sockaddr[0] == reg_addr
|
assert sockaddr[0] == wrap_address(reg_addr).unwrap()
|
||||||
|
|
||||||
|
|
||||||
def test_local_actor_async_func(reg_addr):
|
def test_local_actor_async_func(reg_addr):
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,7 @@ from .discovery._addr import (
|
||||||
mk_uuid,
|
mk_uuid,
|
||||||
wrap_address,
|
wrap_address,
|
||||||
)
|
)
|
||||||
|
from .discovery._tunnel import strip_tunnels
|
||||||
from .trionics import (
|
from .trionics import (
|
||||||
is_multi_cancelled,
|
is_multi_cancelled,
|
||||||
collapse_eg,
|
collapse_eg,
|
||||||
|
|
@ -505,6 +506,7 @@ async def open_root_actor(
|
||||||
# proto if not already provided.
|
# proto if not already provided.
|
||||||
if not tpt_bind_addrs:
|
if not tpt_bind_addrs:
|
||||||
for addr in ponged_addrs:
|
for addr in ponged_addrs:
|
||||||
|
bindable_addr: Address = strip_tunnels(addr)
|
||||||
tpt_bind_addrs.append(
|
tpt_bind_addrs.append(
|
||||||
# XXX, these are `Address` NOT `UnwrappedAddress`.
|
# XXX, these are `Address` NOT `UnwrappedAddress`.
|
||||||
#
|
#
|
||||||
|
|
@ -512,8 +514,8 @@ async def open_root_actor(
|
||||||
# protos we allocate port=0 such that the system
|
# protos we allocate port=0 such that the system
|
||||||
# allocates a random value at bind time; this
|
# allocates a random value at bind time; this
|
||||||
# happens in the `.ipc.*` stack's backend.
|
# happens in the `.ipc.*` stack's backend.
|
||||||
addr.get_random(
|
bindable_addr.get_random(
|
||||||
bindspace=addr.bindspace,
|
bindspace=bindable_addr.bindspace,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,14 @@ 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,
|
||||||
|
mb_pubkey as mb_pubkey,
|
||||||
|
mk_wg_maddr as mk_wg_maddr,
|
||||||
|
parse_wg_maddr as parse_wg_maddr,
|
||||||
|
strip_tunnels as strip_tunnels,
|
||||||
|
tunnels_of as tunnels_of,
|
||||||
|
wg8_pubkey as wg8_pubkey,
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,9 @@ from typing import (
|
||||||
Any,
|
Any,
|
||||||
Protocol,
|
Protocol,
|
||||||
ClassVar,
|
ClassVar,
|
||||||
|
Literal,
|
||||||
Type,
|
Type,
|
||||||
|
TypeAlias,
|
||||||
TYPE_CHECKING,
|
TYPE_CHECKING,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -40,9 +42,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()
|
||||||
|
|
||||||
|
|
@ -69,25 +75,44 @@ log = get_logger()
|
||||||
# seems like the right name as per,
|
# seems like the right name as per,
|
||||||
# https://www.geeksforgeeks.org/introduction-to-address-descriptor/
|
# https://www.geeksforgeeks.org/introduction-to-address-descriptor/
|
||||||
#
|
#
|
||||||
UnwrappedAddress = (
|
TaggedTCPAddress: TypeAlias = tuple[
|
||||||
# tcp/udp/uds
|
Literal['tcp'],
|
||||||
tuple[
|
str,
|
||||||
str, # host/domain(tcp), filesys-dir(uds)
|
int,
|
||||||
int|str, # port/path(uds)
|
]
|
||||||
]
|
TaggedUnixAddress: TypeAlias = tuple[
|
||||||
# ?TODO? should we also include another 2 fields from
|
Literal['unix'],
|
||||||
# our `Aid` msg such that we include the runtime `Actor.uid`
|
str,
|
||||||
# of `.name` and `.uuid`?
|
]
|
||||||
# - would ensure uniqueness across entire net?
|
TaggedUDSAlias: TypeAlias = tuple[
|
||||||
# - allows for easier runtime-level filtering of "actors by
|
Literal['uds'],
|
||||||
# service name"
|
str,
|
||||||
|
]
|
||||||
|
TaggedAddress: TypeAlias = (
|
||||||
|
TaggedTCPAddress
|
||||||
|
|TaggedUnixAddress
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Input-only compatibility forms retained for older callers and
|
||||||
|
# serialized payloads.
|
||||||
|
LegacyTCPAddress: TypeAlias = tuple[str, int]
|
||||||
|
LegacyUDSAddress: TypeAlias = tuple[str, str]
|
||||||
|
LegacyUnwrappedAddress: TypeAlias = (
|
||||||
|
LegacyTCPAddress
|
||||||
|
|LegacyUDSAddress
|
||||||
|
)
|
||||||
|
UnwrappedAddress = TaggedAddress
|
||||||
|
# ?TODO? should we also include another 2 fields from our `Aid` msg
|
||||||
|
# such that we include the runtime `Actor.uid` of `.name` and `.uuid`?
|
||||||
|
# - would ensure uniqueness across entire net?
|
||||||
|
# - allows for easier runtime-level filtering of "actors by service
|
||||||
|
# name"
|
||||||
|
|
||||||
|
|
||||||
# TODO, maybe rename to `SocketAddress`?
|
# TODO, maybe rename to `SocketAddress`?
|
||||||
class Address(Protocol):
|
class Address(Protocol):
|
||||||
proto_key: ClassVar[str]
|
proto_key: ClassVar[str]
|
||||||
unwrapped_type: ClassVar[UnwrappedAddress]
|
unwrapped_type: ClassVar[type]
|
||||||
|
|
||||||
# TODO, i feel like an `.is_bound()` is a better thing to
|
# TODO, i feel like an `.is_bound()` is a better thing to
|
||||||
# support?
|
# support?
|
||||||
|
|
@ -99,7 +124,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.
|
||||||
|
|
||||||
|
|
@ -209,7 +234,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:
|
||||||
|
|
@ -223,8 +257,16 @@ def mk_uuid() -> str:
|
||||||
|
|
||||||
|
|
||||||
def wrap_address(
|
def wrap_address(
|
||||||
addr: UnwrappedAddress|str,
|
addr: (
|
||||||
) -> Address:
|
TaggedAddress
|
||||||
|
|TaggedUDSAlias
|
||||||
|
|LegacyUnwrappedAddress
|
||||||
|
|list[str|int]
|
||||||
|
|str
|
||||||
|
|Address
|
||||||
|
|TunnelledAddress
|
||||||
|
),
|
||||||
|
) -> 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
|
||||||
|
|
@ -246,6 +288,20 @@ def wrap_address(
|
||||||
# import pdbp; pdbp.set_trace()
|
# import pdbp; pdbp.set_trace()
|
||||||
match addr:
|
match addr:
|
||||||
|
|
||||||
|
case (
|
||||||
|
('tcp', str(), int())
|
||||||
|
|
|
||||||
|
['tcp', str(), int()]
|
||||||
|
):
|
||||||
|
return TCPAddress.from_addr(addr)
|
||||||
|
|
||||||
|
case (
|
||||||
|
(('unix' | 'uds'), str())
|
||||||
|
|
|
||||||
|
[('unix' | 'uds'), str()]
|
||||||
|
):
|
||||||
|
return UDSAddress.from_addr(addr)
|
||||||
|
|
||||||
# classic network socket-address as tuple/list
|
# classic network socket-address as tuple/list
|
||||||
case (
|
case (
|
||||||
(str(), int())
|
(str(), int())
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
@ -76,7 +87,7 @@ def mk_maddr(
|
||||||
|
|
||||||
match proto_key:
|
match proto_key:
|
||||||
case 'tcp':
|
case 'tcp':
|
||||||
host, port = addr.unwrap()
|
_, host, port = addr.unwrap()
|
||||||
ip = ipaddress.ip_address(host)
|
ip = ipaddress.ip_address(host)
|
||||||
net_proto: str = (
|
net_proto: str = (
|
||||||
'ip4' if ip.version == 4
|
'ip4' if ip.version == 4
|
||||||
|
|
@ -87,13 +98,12 @@ def mk_maddr(
|
||||||
)
|
)
|
||||||
|
|
||||||
case 'uds':
|
case 'uds':
|
||||||
filedir, filename = addr.unwrap()
|
_, sockpath = addr.unwrap()
|
||||||
filepath = Path(filedir) / filename
|
|
||||||
# NOTE, strip any leading `/` to avoid
|
# NOTE, strip any leading `/` to avoid
|
||||||
# double-slash `/unix//run/..` which the
|
# double-slash `/unix//run/..` which the
|
||||||
# multiaddr parser rejects as "empty
|
# multiaddr parser rejects as "empty
|
||||||
# protocol path".
|
# protocol path".
|
||||||
fpath_str: str = str(filepath).lstrip('/')
|
fpath_str: str = sockpath.lstrip('/')
|
||||||
return Multiaddr(
|
return Multiaddr(
|
||||||
f'/{maddr_proto}/{fpath_str}'
|
f'/{maddr_proto}/{fpath_str}'
|
||||||
)
|
)
|
||||||
|
|
@ -101,7 +111,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 +123,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
|
||||||
|
|
||||||
maddr = Multiaddr(maddr_str)
|
try:
|
||||||
|
maddr = Multiaddr(maddr_str)
|
||||||
|
except ValueError:
|
||||||
|
# Diagnose an unavailable WG codec after upstream parsing
|
||||||
|
# fails. Pre-checking the raw string would misclassify valid
|
||||||
|
# values such as `/unix/tmp/wg/service.sock`.
|
||||||
|
if '/wg/' in maddr_str:
|
||||||
|
from ._tunnel import _wg_proto_code
|
||||||
|
_wg_proto_code()
|
||||||
|
raise
|
||||||
proto_names: list[str] = [
|
proto_names: list[str] = [
|
||||||
p.name for p in maddr.protocols()
|
p.name for p in maddr.protocols()
|
||||||
]
|
]
|
||||||
|
|
@ -136,6 +155,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 +177,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 +190,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 +202,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`
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,502 @@
|
||||||
|
# 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
|
||||||
|
import base64
|
||||||
|
import ipaddress
|
||||||
|
from typing import (
|
||||||
|
Any,
|
||||||
|
ClassVar,
|
||||||
|
TYPE_CHECKING,
|
||||||
|
)
|
||||||
|
|
||||||
|
import msgspec
|
||||||
|
import multibase
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from multiaddr import Multiaddr
|
||||||
|
|
||||||
|
from ._addr import (
|
||||||
|
Address,
|
||||||
|
UnwrappedAddress,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
Address = Any
|
||||||
|
Multiaddr = Any
|
||||||
|
UnwrappedAddress = Any
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
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(
|
||||||
|
msgspec.Struct,
|
||||||
|
frozen=True,
|
||||||
|
):
|
||||||
|
'''
|
||||||
|
An `Address` annotated with the tunnel it must be reached
|
||||||
|
*through*.
|
||||||
|
|
||||||
|
Address-level properties delegate to `.overlay`, so proto-key
|
||||||
|
guards and `.unwrap()` retain their existing meaning and
|
||||||
|
**nothing new crosses the wire**. Transport boundaries which
|
||||||
|
dispatch on exact type or declaring module must first call
|
||||||
|
`strip_tunnels()`.
|
||||||
|
|
||||||
|
'''
|
||||||
|
overlay: Address|TunnelledAddress
|
||||||
|
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 _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(
|
||||||
|
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)
|
||||||
|
|
@ -46,6 +46,10 @@ from tractor.discovery._addr import (
|
||||||
Address,
|
Address,
|
||||||
UnwrappedAddress,
|
UnwrappedAddress,
|
||||||
)
|
)
|
||||||
|
from tractor.discovery._tunnel import (
|
||||||
|
TunnelledAddress,
|
||||||
|
strip_tunnels,
|
||||||
|
)
|
||||||
from tractor.log import get_logger
|
from tractor.log import get_logger
|
||||||
from tractor._exceptions import (
|
from tractor._exceptions import (
|
||||||
MsgTypeError,
|
MsgTypeError,
|
||||||
|
|
@ -182,16 +186,17 @@ class Channel:
|
||||||
@classmethod
|
@classmethod
|
||||||
async def from_addr(
|
async def from_addr(
|
||||||
cls,
|
cls,
|
||||||
addr: UnwrappedAddress,
|
addr: UnwrappedAddress|Address|TunnelledAddress,
|
||||||
**kwargs
|
**kwargs
|
||||||
) -> Channel:
|
) -> Channel:
|
||||||
|
|
||||||
if not is_wrapped_addr(addr):
|
if not is_wrapped_addr(addr):
|
||||||
addr: Address = wrap_address(addr)
|
addr = wrap_address(addr)
|
||||||
|
|
||||||
transport_cls = transport_from_addr(addr)
|
transport_addr: Address = strip_tunnels(addr)
|
||||||
|
transport_cls = transport_from_addr(transport_addr)
|
||||||
transport = await transport_cls.connect_to(
|
transport = await transport_cls.connect_to(
|
||||||
addr,
|
transport_addr,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
)
|
)
|
||||||
# XXX, for UDS *no!* since we recv the peer-pid and build out
|
# XXX, for UDS *no!* since we recv the peer-pid and build out
|
||||||
|
|
@ -551,7 +556,7 @@ class Channel:
|
||||||
|
|
||||||
@acm
|
@acm
|
||||||
async def _connect_chan(
|
async def _connect_chan(
|
||||||
addr: UnwrappedAddress,
|
addr: UnwrappedAddress|Address|TunnelledAddress,
|
||||||
close_timeout: float|None = None,
|
close_timeout: float|None = None,
|
||||||
) -> typing.AsyncGenerator[Channel, None]:
|
) -> typing.AsyncGenerator[Channel, None]:
|
||||||
'''
|
'''
|
||||||
|
|
|
||||||
|
|
@ -59,12 +59,16 @@ from ..msg import (
|
||||||
from ..trionics import maybe_open_nursery
|
from ..trionics import maybe_open_nursery
|
||||||
from ..runtime import _state
|
from ..runtime import _state
|
||||||
from .. import log
|
from .. import log
|
||||||
from ..discovery._addr import Address
|
from ..discovery._addr import (
|
||||||
|
Address,
|
||||||
|
UnwrappedAddress,
|
||||||
|
)
|
||||||
from ._chan import Channel
|
from ._chan import Channel
|
||||||
from ._transport import MsgTransport
|
from ._transport import MsgTransport
|
||||||
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
from ..discovery._tunnel import TunnelledAddress
|
||||||
from ..runtime._runtime import Actor
|
from ..runtime._runtime import Actor
|
||||||
from ..runtime._supervise import ActorNursery
|
from ..runtime._supervise import ActorNursery
|
||||||
|
|
||||||
|
|
@ -965,7 +969,9 @@ class Server(Struct):
|
||||||
async def listen_on(
|
async def listen_on(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
accept_addrs: list[tuple[str, int|str]]|None = None,
|
accept_addrs: list[
|
||||||
|
UnwrappedAddress|Address|TunnelledAddress
|
||||||
|
]|None = None,
|
||||||
stream_handler_nursery: Nursery|None = None,
|
stream_handler_nursery: Nursery|None = None,
|
||||||
) -> list[Endpoint]:
|
) -> list[Endpoint]:
|
||||||
'''
|
'''
|
||||||
|
|
@ -1048,7 +1054,7 @@ async def _serve_ipc_eps(
|
||||||
*,
|
*,
|
||||||
server: IPCServer,
|
server: IPCServer,
|
||||||
stream_handler_tn: Nursery,
|
stream_handler_tn: Nursery,
|
||||||
listen_addrs: list[tuple[str, int|str]],
|
listen_addrs: list[Address|TunnelledAddress],
|
||||||
|
|
||||||
task_status: TaskStatus[
|
task_status: TaskStatus[
|
||||||
Nursery,
|
Nursery,
|
||||||
|
|
@ -1064,6 +1070,8 @@ async def _serve_ipc_eps(
|
||||||
`.cancel_server()` is called.
|
`.cancel_server()` is called.
|
||||||
|
|
||||||
'''
|
'''
|
||||||
|
from ..discovery._tunnel import strip_tunnels
|
||||||
|
|
||||||
try:
|
try:
|
||||||
listen_tn: Nursery
|
listen_tn: Nursery
|
||||||
async with trio.open_nursery() as listen_tn:
|
async with trio.open_nursery() as listen_tn:
|
||||||
|
|
@ -1072,7 +1080,8 @@ async def _serve_ipc_eps(
|
||||||
# XXX NOTE, required to call `serve_listeners()` below.
|
# XXX NOTE, required to call `serve_listeners()` below.
|
||||||
# ?TODO, maybe just pass `list(eps.values()` tho?
|
# ?TODO, maybe just pass `list(eps.values()` tho?
|
||||||
listeners: list[trio.abc.Listener] = []
|
listeners: list[trio.abc.Listener] = []
|
||||||
for addr in listen_addrs:
|
for declared_addr in listen_addrs:
|
||||||
|
addr: Address = strip_tunnels(declared_addr)
|
||||||
ep = Endpoint(
|
ep = Endpoint(
|
||||||
addr=addr,
|
addr=addr,
|
||||||
listen_tn=listen_tn,
|
listen_tn=listen_tn,
|
||||||
|
|
|
||||||
|
|
@ -47,8 +47,10 @@ if TYPE_CHECKING:
|
||||||
# ONLY type-annots, the eager import costs
|
# ONLY type-annots, the eager import costs
|
||||||
# `import tractor` wall-time (gh #470).
|
# `import tractor` wall-time (gh #470).
|
||||||
from multiaddr import Multiaddr
|
from multiaddr import Multiaddr
|
||||||
|
from tractor.discovery._addr import TaggedTCPAddress
|
||||||
else:
|
else:
|
||||||
Multiaddr = Any
|
Multiaddr = Any
|
||||||
|
TaggedTCPAddress = Any
|
||||||
|
|
||||||
|
|
||||||
log = get_logger()
|
log = get_logger()
|
||||||
|
|
@ -70,7 +72,7 @@ class TCPAddress(
|
||||||
) from valerr
|
) from valerr
|
||||||
|
|
||||||
proto_key: ClassVar[str] = 'tcp'
|
proto_key: ClassVar[str] = 'tcp'
|
||||||
unwrapped_type: ClassVar[type] = tuple[str, int]
|
unwrapped_type: ClassVar[type] = tuple
|
||||||
def_bindspace: ClassVar[str] = '127.0.0.1'
|
def_bindspace: ClassVar[str] = '127.0.0.1'
|
||||||
|
|
||||||
# ?TODO, actually validate ipv4/6 with stdlib's `ipaddress`
|
# ?TODO, actually validate ipv4/6 with stdlib's `ipaddress`
|
||||||
|
|
@ -112,19 +114,35 @@ class TCPAddress(
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_addr(
|
def from_addr(
|
||||||
cls,
|
cls,
|
||||||
addr: tuple[str, int]
|
addr: tuple|list,
|
||||||
) -> TCPAddress:
|
) -> TCPAddress:
|
||||||
match addr:
|
match addr:
|
||||||
case (str(), int()):
|
case (
|
||||||
return TCPAddress(addr[0], addr[1])
|
('tcp', str() as host, int() as port)
|
||||||
|
|
|
||||||
|
['tcp', str() as host, int() as port]
|
||||||
|
|
|
||||||
|
(str() as host, int() as port)
|
||||||
|
|
|
||||||
|
[str() as host, int() as port]
|
||||||
|
|
|
||||||
|
(
|
||||||
|
str() as host,
|
||||||
|
int() as port,
|
||||||
|
int(),
|
||||||
|
int(),
|
||||||
|
)
|
||||||
|
):
|
||||||
|
return TCPAddress(host, port)
|
||||||
case _:
|
case _:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f'Invalid unwrapped address for {cls}\n'
|
f'Invalid unwrapped address for {cls}\n'
|
||||||
f'{addr}\n'
|
f'{addr}\n'
|
||||||
)
|
)
|
||||||
|
|
||||||
def unwrap(self) -> tuple[str, int]:
|
def unwrap(self) -> TaggedTCPAddress:
|
||||||
return (
|
return (
|
||||||
|
self.proto_key,
|
||||||
self._host,
|
self._host,
|
||||||
self._port,
|
self._port,
|
||||||
)
|
)
|
||||||
|
|
@ -223,7 +241,8 @@ class MsgpackTCPStream(MsgpackTransport):
|
||||||
**kwargs
|
**kwargs
|
||||||
) -> MsgpackTCPStream:
|
) -> MsgpackTCPStream:
|
||||||
stream = await trio.open_tcp_stream(
|
stream = await trio.open_tcp_stream(
|
||||||
*destaddr.unwrap(),
|
destaddr._host,
|
||||||
|
destaddr._port,
|
||||||
**kwargs
|
**kwargs
|
||||||
)
|
)
|
||||||
return MsgpackTCPStream(
|
return MsgpackTCPStream(
|
||||||
|
|
|
||||||
|
|
@ -77,10 +77,12 @@ if TYPE_CHECKING:
|
||||||
# ONLY type-annots, the eager import costs
|
# ONLY type-annots, the eager import costs
|
||||||
# `import tractor` wall-time (gh #470).
|
# `import tractor` wall-time (gh #470).
|
||||||
from multiaddr import Multiaddr
|
from multiaddr import Multiaddr
|
||||||
|
from tractor.discovery._addr import TaggedUnixAddress
|
||||||
from tractor.runtime._runtime import Actor
|
from tractor.runtime._runtime import Actor
|
||||||
else:
|
else:
|
||||||
Multiaddr = Any
|
Multiaddr = Any
|
||||||
Actor = Any
|
Actor = Any
|
||||||
|
TaggedUnixAddress = Any
|
||||||
|
|
||||||
|
|
||||||
# Platform-specific credential passing constants
|
# Platform-specific credential passing constants
|
||||||
|
|
@ -147,7 +149,7 @@ class UDSAddress(
|
||||||
# -[ ] need to check what other mult-transport frameworks do
|
# -[ ] need to check what other mult-transport frameworks do
|
||||||
# like zmq, nng, uri-spec et al!
|
# like zmq, nng, uri-spec et al!
|
||||||
proto_key: ClassVar[str] = 'uds'
|
proto_key: ClassVar[str] = 'uds'
|
||||||
unwrapped_type: ClassVar[type] = tuple[str, int]
|
unwrapped_type: ClassVar[type] = tuple
|
||||||
def_bindspace: ClassVar[Path] = get_rt_dir()
|
def_bindspace: ClassVar[Path] = get_rt_dir()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|
@ -165,7 +167,7 @@ class UDSAddress(
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def sockpath(self) -> Path:
|
def sockpath(self) -> Path:
|
||||||
return self.bindspace / self.filename
|
return Path(self.bindspace) / self.filename
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_valid(self) -> bool:
|
def is_valid(self) -> bool:
|
||||||
|
|
@ -179,16 +181,26 @@ class UDSAddress(
|
||||||
def from_addr(
|
def from_addr(
|
||||||
cls,
|
cls,
|
||||||
addr: (
|
addr: (
|
||||||
tuple[Path|str, Path|str]|Path|str
|
tuple|list|Path|str
|
||||||
),
|
),
|
||||||
) -> UDSAddress:
|
) -> UDSAddress:
|
||||||
match addr:
|
match addr:
|
||||||
case tuple()|list():
|
case (
|
||||||
filedir = Path(addr[0])
|
(('unix' | 'uds'), str()|Path() as sockpath)
|
||||||
filename = Path(addr[1])
|
|
|
||||||
|
[('unix' | 'uds'), str()|Path() as sockpath]
|
||||||
|
):
|
||||||
|
path = Path(sockpath)
|
||||||
|
return UDSAddress(*unwrap_sockpath(path))
|
||||||
|
|
||||||
|
case (
|
||||||
|
(str()|Path() as filedir, str()|Path() as filename)
|
||||||
|
|
|
||||||
|
[str()|Path() as filedir, str()|Path() as filename]
|
||||||
|
):
|
||||||
return UDSAddress(
|
return UDSAddress(
|
||||||
filedir=filedir,
|
filedir=Path(filedir),
|
||||||
filename=filename,
|
filename=Path(filename),
|
||||||
# maybe_pid=pid,
|
# maybe_pid=pid,
|
||||||
)
|
)
|
||||||
# NOTE, in case we ever decide to just `.unwrap()`
|
# NOTE, in case we ever decide to just `.unwrap()`
|
||||||
|
|
@ -203,12 +215,10 @@ class UDSAddress(
|
||||||
f'{addr!r}\n'
|
f'{addr!r}\n'
|
||||||
)
|
)
|
||||||
|
|
||||||
def unwrap(self) -> tuple[str, int]:
|
def unwrap(self) -> TaggedUnixAddress:
|
||||||
# XXX NOTE, since this gets passed DIRECTLY to
|
|
||||||
# `.ipc._uds.open_unix_socket_w_passcred()`
|
|
||||||
return (
|
return (
|
||||||
str(self.filedir),
|
'unix',
|
||||||
str(self.filename),
|
str(self.sockpath),
|
||||||
)
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|
|
||||||
|
|
@ -213,10 +213,11 @@ class SpawnSpec(
|
||||||
# module import capability
|
# module import capability
|
||||||
enable_modules: dict[str, str]
|
enable_modules: dict[str, str]
|
||||||
|
|
||||||
# TODO: not just sockaddr pairs?
|
# Tagged addresses have protocol-specific tuple shapes which
|
||||||
# -[ ] abstract into a `TransportAddr` type?
|
# msgspec cannot express as one decodable union. `wrap_address()`
|
||||||
reg_addrs: list[tuple[str, str|int]]
|
# validates each tuple at the transport boundary.
|
||||||
bind_addrs: list[tuple[str, str|int]]|None
|
reg_addrs: list[tuple]
|
||||||
|
bind_addrs: list[tuple]|None
|
||||||
|
|
||||||
|
|
||||||
# TODO: caps based RPC support in the payload?
|
# TODO: caps based RPC support in the payload?
|
||||||
|
|
|
||||||
10
uv.lock
10
uv.lock
|
|
@ -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" },
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue