Compare commits
8 Commits
5d92595fb4
...
b973e78c2a
| Author | SHA1 | Date |
|---|---|---|
|
|
b973e78c2a | |
|
|
5b2a064ae8 | |
|
|
e100754709 | |
|
|
fdccfd7e85 | |
|
|
29141f0b71 | |
|
|
d35c802b1c | |
|
|
59a8ecfd0e | |
|
|
7f2654770a |
|
|
@ -0,0 +1,49 @@
|
||||||
|
---
|
||||||
|
model: gpt-5.6-sol
|
||||||
|
service: opencode
|
||||||
|
session: tractor-addr-unpacking
|
||||||
|
timestamp: 2026-08-21T23:32:04Z
|
||||||
|
git_ref: 5d92595f
|
||||||
|
scope: code
|
||||||
|
substantive: true
|
||||||
|
raw_file: 20260821T233204Z_5d92595f_prompt_io.raw.md
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prompt
|
||||||
|
|
||||||
|
The human asked to proceed with WireGuard Layer B from PR #505's
|
||||||
|
head, using the established one-change-per-turn workflow and
|
||||||
|
finishing with a commit plan. This turn was limited to read-only
|
||||||
|
pyroute2 inspection; peer verification and bindspace provisioning
|
||||||
|
remain later changes.
|
||||||
|
|
||||||
|
During review, the human required the generated Python to follow the
|
||||||
|
deployed `/py-codestyle` rules and asked for the medium-term plan to
|
||||||
|
remove pyroute2's asyncio I/O runtime from the Trio read path.
|
||||||
|
|
||||||
|
## Response summary
|
||||||
|
|
||||||
|
Added Linux-only read helpers for WireGuard device and peer public
|
||||||
|
keys. Pyroute2's synchronous API is fully contained in a Trio worker
|
||||||
|
thread, supports named netns reads without creation side effects,
|
||||||
|
validates decoded keys and always closes the netlink client.
|
||||||
|
Follow-up edits added the required local annotations, boolean layout,
|
||||||
|
helper docstrings and 69-column source formatting.
|
||||||
|
|
||||||
|
## Files changed
|
||||||
|
|
||||||
|
- `pyproject.toml` - Linux-only `wg` optional dependency.
|
||||||
|
- `uv.lock` - resolved pyroute2 0.9.6 metadata.
|
||||||
|
- `tractor/discovery/_tunnel.py` - read-only WireGuard helpers.
|
||||||
|
- `tractor/discovery/__init__.py` - public helper exports.
|
||||||
|
- `tests/discovery/test_wg_inspection.py` - fake-backed netlink and
|
||||||
|
worker-thread regressions.
|
||||||
|
|
||||||
|
## Human edits
|
||||||
|
|
||||||
|
The human chose the exact #505 head as a stacked base, required
|
||||||
|
incremental changes ending in commit plans, and limited this turn to
|
||||||
|
read-only pyroute2 inspection. The human then identified that Ruff
|
||||||
|
success had not established `/py-codestyle` compliance and directed
|
||||||
|
the agent to correct the Python-specific annotation, documentation
|
||||||
|
and layout rules. No direct manual source edits were observed.
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
---
|
||||||
|
model: gpt-5.6-sol
|
||||||
|
service: opencode
|
||||||
|
timestamp: 2026-08-21T23:32:04Z
|
||||||
|
git_ref: 5d92595f
|
||||||
|
diff_cmd: git diff HEAD~1..HEAD
|
||||||
|
---
|
||||||
|
|
||||||
|
# Raw output - read WireGuard state through pyroute2
|
||||||
|
|
||||||
|
The human authorized the first incremental WireGuard Layer B change
|
||||||
|
as a stacked branch from tractor PR #505, with one atomic change and
|
||||||
|
a commit plan at the end of the turn.
|
||||||
|
|
||||||
|
> `git diff HEAD~1..HEAD -- pyproject.toml uv.lock tractor/discovery/__init__.py tractor/discovery/_tunnel.py tests/discovery/test_wg_inspection.py`
|
||||||
|
|
||||||
|
Added a Linux-only `wg` extra using pyroute2 0.9.6, plus
|
||||||
|
asynchronous public helpers for reading one interface's public key
|
||||||
|
and peer keys. The complete synchronous open/read/parse/close
|
||||||
|
sequence runs in a Trio worker thread because pyroute2 owns a private
|
||||||
|
asyncio loop internally.
|
||||||
|
|
||||||
|
Named namespace reads pass `flags=0` to override pyroute2's `O_CREAT`
|
||||||
|
default, ensuring inspection cannot create a missing namespace. Fake
|
||||||
|
netlink messages cover multipart dumps, key validation, stable peer
|
||||||
|
deduplication, worker-thread execution, netns selection and cleanup
|
||||||
|
on success/error.
|
||||||
|
|
||||||
|
Ruff and lock checks passed. Focused tunnel/multiaddr coverage passed
|
||||||
|
47 tests; the complete discovery suite passed 88 tests with 2
|
||||||
|
xpasses.
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
---
|
||||||
|
model: gpt-5.6-sol
|
||||||
|
service: opencode
|
||||||
|
session: tractor-addr-unpacking
|
||||||
|
timestamp: 2026-08-22T02:32:26Z
|
||||||
|
git_ref: 59a8ecfd
|
||||||
|
scope: code
|
||||||
|
substantive: true
|
||||||
|
raw_file: 20260822T023226Z_59a8ecfd_prompt_io.raw.md
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prompt
|
||||||
|
|
||||||
|
After committing the read-only pyroute2 helpers and `wgman` design
|
||||||
|
update, the human authorized the next isolated Layer B change:
|
||||||
|
explicit `verify_wg_peer()` composition over WireGuard inspection.
|
||||||
|
|
||||||
|
## Response summary
|
||||||
|
|
||||||
|
Added and exported async `verify_wg_peer()` using one validated
|
||||||
|
WireGuard key snapshot. It recognizes local-interface and configured
|
||||||
|
peer identities without coupling kernel inspection to address
|
||||||
|
parsing. Updated the multihost examples to use the production helper
|
||||||
|
and removed their subprocess-based probe.
|
||||||
|
|
||||||
|
## Files changed
|
||||||
|
|
||||||
|
- `tractor/discovery/_tunnel.py` - shared async snapshot reader and
|
||||||
|
explicit verification helper.
|
||||||
|
- `tractor/discovery/__init__.py` - public verification export.
|
||||||
|
- `tests/discovery/test_wg_inspection.py` - local, peer, absent and
|
||||||
|
malformed-key verification coverage.
|
||||||
|
- `examples/multihost/wg_lan/host_a_srv.py` - async local-key check.
|
||||||
|
- `examples/multihost/wg_lan/host_b_client.py` - async peer-key check.
|
||||||
|
- `examples/multihost/wg_lan/wg_maddr.py` - removed obsolete
|
||||||
|
subprocess probe.
|
||||||
|
- `examples/multihost/wg_lan/README.md` - pyroute2 requirements and
|
||||||
|
verification workflow.
|
||||||
|
- `ai/tpt-backends/03_wg_tunnel_bindspace.md` - async API contract.
|
||||||
|
|
||||||
|
## Human edits
|
||||||
|
|
||||||
|
The human selected this pre-agreed verification layer as the next
|
||||||
|
atomic change after reviewing and committing the preceding read and
|
||||||
|
architecture changes. The agent implemented the source changes; no
|
||||||
|
direct manual edits or follow-up corrections were observed.
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
---
|
||||||
|
model: gpt-5.6-sol
|
||||||
|
service: opencode
|
||||||
|
timestamp: 2026-08-22T02:32:26Z
|
||||||
|
git_ref: 59a8ecfd
|
||||||
|
diff_cmd: git diff HEAD~1..HEAD
|
||||||
|
---
|
||||||
|
|
||||||
|
# Raw output - verify declared WireGuard identities
|
||||||
|
|
||||||
|
The human authorized the next incremental Layer B change after
|
||||||
|
committing the read-only pyroute2 helpers and first-child `wgman`
|
||||||
|
design update.
|
||||||
|
|
||||||
|
> `git diff HEAD~1..HEAD -- tractor/discovery/_tunnel.py tractor/discovery/__init__.py tests/discovery/test_wg_inspection.py examples/multihost/wg_lan ai/tpt-backends/03_wg_tunnel_bindspace.md`
|
||||||
|
|
||||||
|
Added async `verify_wg_peer()` over one WireGuard key snapshot. It
|
||||||
|
validates the declared `WGTunnelSpec.peer_pubkey` before I/O, forwards
|
||||||
|
the spec's iface/netns, and accepts either the local interface key for
|
||||||
|
a source/listen declaration or a configured peer key for a
|
||||||
|
destination/dial declaration.
|
||||||
|
|
||||||
|
Refactored worker offload behind one shared async reader so
|
||||||
|
verification cannot compare two different netlink snapshots. Exported
|
||||||
|
the helper, added local/peer/absent/malformed-key coverage, and moved
|
||||||
|
the multihost examples from their local `wg show` subprocess probe to
|
||||||
|
the production API.
|
||||||
|
|
||||||
|
Ruff and lock checks passed. Focused WireGuard/tunnel/multiaddr
|
||||||
|
coverage passed 51 tests; the complete discovery suite passed 92
|
||||||
|
tests with 2 xpasses.
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
---
|
||||||
|
model: gpt-5.6-sol
|
||||||
|
service: opencode
|
||||||
|
session: tractor-addr-unpacking
|
||||||
|
timestamp: 2026-08-22T03:25:20Z
|
||||||
|
git_ref: d35c802b
|
||||||
|
scope: code
|
||||||
|
substantive: true
|
||||||
|
raw_file: 20260822T032520Z_d35c802b_prompt_io.raw.md
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prompt
|
||||||
|
|
||||||
|
The human reported the explicit WireGuard verification commit done,
|
||||||
|
asked the agent to check it, and authorized the next incremental
|
||||||
|
Layer C change.
|
||||||
|
|
||||||
|
## Response summary
|
||||||
|
|
||||||
|
Verified commit `d35c802b`, made plain transport namespace behavior
|
||||||
|
explicit, and retained each original listener declaration beside its
|
||||||
|
peeled transport address. Endpoint and server diagnostics can now
|
||||||
|
report a tunnel's namespace without violating the transport boundary.
|
||||||
|
|
||||||
|
## Files changed
|
||||||
|
|
||||||
|
- `tractor/ipc/_tcp.py` - explicit plain-address namespace property.
|
||||||
|
- `tractor/ipc/_uds.py` - explicit plain-address namespace property.
|
||||||
|
- `tractor/discovery/_tunnel.py` - direct overlay namespace delegation.
|
||||||
|
- `tractor/ipc/_server.py` - retained declaration and namespace
|
||||||
|
diagnostics.
|
||||||
|
- `tests/discovery/test_tunnelled_addr.py` - plain and tunnel namespace
|
||||||
|
behavior.
|
||||||
|
- `tests/ipc/test_server_tunnel_boundary.py` - declaration retention
|
||||||
|
and diagnostic coverage.
|
||||||
|
- `ai/tpt-backends/03_wg_tunnel_bindspace.md` - concrete endpoint
|
||||||
|
boundary contract.
|
||||||
|
|
||||||
|
## Human edits
|
||||||
|
|
||||||
|
The human selected continued incremental implementation after
|
||||||
|
reviewing and committing the preceding verification layer. The agent
|
||||||
|
implemented this dependency-ordered namespace slice; no direct manual
|
||||||
|
edits or follow-up corrections were observed.
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
---
|
||||||
|
model: gpt-5.6-sol
|
||||||
|
service: opencode
|
||||||
|
timestamp: 2026-08-22T03:25:20Z
|
||||||
|
git_ref: d35c802b
|
||||||
|
diff_cmd: git diff HEAD~1..HEAD
|
||||||
|
---
|
||||||
|
|
||||||
|
# Raw output - retain endpoint namespace declarations
|
||||||
|
|
||||||
|
The human reported the explicit WireGuard verification commit done,
|
||||||
|
asked for it to be checked, and authorized the next incremental
|
||||||
|
change.
|
||||||
|
|
||||||
|
> `git diff HEAD~1..HEAD -- tractor/ipc/_tcp.py tractor/ipc/_uds.py tractor/discovery/_tunnel.py tractor/ipc/_server.py tests/discovery/test_tunnelled_addr.py tests/ipc/test_server_tunnel_boundary.py ai/tpt-backends/03_wg_tunnel_bindspace.md`
|
||||||
|
|
||||||
|
Confirmed commit `d35c802b` and a clean worktree, then implemented the
|
||||||
|
smallest dependency-ordered Layer C slice. Plain TCP and UDS addresses
|
||||||
|
now explicitly report no namespace, allowing `TunnelledAddress` to
|
||||||
|
delegate without an attribute fallback.
|
||||||
|
|
||||||
|
Added required `Endpoint.declared_addr` metadata beside the peeled,
|
||||||
|
resolved `Endpoint.addr`. Endpoint and server diagnostics expose the
|
||||||
|
declaration's namespace without passing a tunnel wrapper into
|
||||||
|
transport reflection. Updated the Layer C plan and tests for plain,
|
||||||
|
tunnelled, endpoint and server namespace behavior.
|
||||||
|
|
||||||
|
Ruff passed. Focused namespace tests passed 13 tests; combined
|
||||||
|
discovery and IPC coverage passed 101 tests with 2 xpasses.
|
||||||
|
|
@ -0,0 +1,64 @@
|
||||||
|
---
|
||||||
|
model: gpt-5.6-sol
|
||||||
|
service: opencode
|
||||||
|
session: tractor-addr-unpacking
|
||||||
|
timestamp: 2026-08-22T04:20:26Z
|
||||||
|
git_ref: 29141f0b
|
||||||
|
scope: code
|
||||||
|
substantive: true
|
||||||
|
raw_file: 20260822T042026Z_29141f0b_prompt_io.raw.md
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prompt
|
||||||
|
|
||||||
|
After committing endpoint namespace visibility, the human authorized
|
||||||
|
continued Layer C implementation.
|
||||||
|
|
||||||
|
During staged review, the human requested Literal-derived validation,
|
||||||
|
ownership documentation, stable-inode clarification, explicit
|
||||||
|
non-serialization rationale and consolidated invalid-model tests.
|
||||||
|
|
||||||
|
## Response summary
|
||||||
|
|
||||||
|
Added the foundational bindspace model: serializable declarations and
|
||||||
|
stable identities are separated from a process-local live capability.
|
||||||
|
The handle validates names, ownership and FD identity. A global
|
||||||
|
`ProcessLocal` sentinel blocks default encoding while retaining
|
||||||
|
msgspec struct behavior.
|
||||||
|
Review fixes require a positive inode for every realized netns,
|
||||||
|
derive runtime choices from the Literal aliases and clarify that an FD
|
||||||
|
integer is not transferable capability authority.
|
||||||
|
The human then clarified that msgspec structs are useful generic
|
||||||
|
storage independently of serialization policy, so the live handle now
|
||||||
|
uses a struct while remaining process-local by contract.
|
||||||
|
The human first selected an opaque FD wrapper, then recognized that
|
||||||
|
future process-local handles need the same guard and directed a global
|
||||||
|
marker under `tractor.msg` instead.
|
||||||
|
|
||||||
|
## Files changed
|
||||||
|
|
||||||
|
- `tractor/discovery/_bindspace.py` - declaration, identity and live
|
||||||
|
capability models.
|
||||||
|
- `tractor/discovery/__init__.py` - public bindspace exports.
|
||||||
|
- `tractor/msg/_local.py` - reusable process-local struct marker.
|
||||||
|
- `tractor/msg/__init__.py` - public `ProcessLocal` export.
|
||||||
|
- `tests/discovery/test_bindspace.py` - serialization and capability
|
||||||
|
invariant coverage.
|
||||||
|
- `tests/msg/test_process_local.py` - direct and nested wire rejection.
|
||||||
|
- `ai/tpt-backends/03_wg_tunnel_bindspace.md` - concrete initial model
|
||||||
|
contract.
|
||||||
|
|
||||||
|
## Human edits
|
||||||
|
|
||||||
|
The human selected the foundational Layer C capability model, then
|
||||||
|
reviewed the staged patch and directed all eight follow-up changes:
|
||||||
|
remove distracting ownership variation, consolidate invalid-model
|
||||||
|
tests, derive containment checks from Literal aliases, require a
|
||||||
|
stable inode and explain why the live handle is not a msgspec struct.
|
||||||
|
The human subsequently corrected that last design choice: struct use
|
||||||
|
does not imply IPC authorization, so `BindspaceHandle` should retain
|
||||||
|
msgspec's generic storage benefits. The agent applied those
|
||||||
|
human-directed edits. The human first chose an opaque process-local FD
|
||||||
|
wrapper, then refined that decision to a reusable global marker under
|
||||||
|
`tractor.msg` for future local handles. No direct manual source edits
|
||||||
|
were observed.
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
---
|
||||||
|
model: gpt-5.6-sol
|
||||||
|
service: opencode
|
||||||
|
timestamp: 2026-08-22T04:20:26Z
|
||||||
|
git_ref: 29141f0b
|
||||||
|
diff_cmd: git diff HEAD~1..HEAD
|
||||||
|
---
|
||||||
|
|
||||||
|
# Raw output - model bindspace capabilities
|
||||||
|
|
||||||
|
The human authorized the next incremental Layer C change after
|
||||||
|
committing endpoint namespace visibility.
|
||||||
|
|
||||||
|
> `git diff HEAD~1..HEAD -- tractor/discovery/_bindspace.py tractor/discovery/__init__.py tests/discovery/test_bindspace.py ai/tpt-backends/03_wg_tunnel_bindspace.md`
|
||||||
|
|
||||||
|
Added frozen, serializable `BindspaceSpec` and
|
||||||
|
`BindspaceIdentity` models for the initial Linux netns kind. Added a
|
||||||
|
plain slotted `BindspaceHandle` which carries ownership and an optional
|
||||||
|
namespace FD without being serializable by default msgspec encoding.
|
||||||
|
|
||||||
|
The live handle validates declaration/identity kind and name, ownership
|
||||||
|
values, non-negative FDs and FD/inode correspondence. Serializable
|
||||||
|
models reject empty identities and kinds without implementations.
|
||||||
|
|
||||||
|
Added round-trip, non-serialization, identity-mismatch, empty-identity
|
||||||
|
and unsupported-kind tests. Ruff passed; discovery plus
|
||||||
|
message-serialization coverage passed 125 tests with 2 xpasses.
|
||||||
|
|
@ -0,0 +1,48 @@
|
||||||
|
---
|
||||||
|
model: gpt-5.6-sol
|
||||||
|
service: opencode
|
||||||
|
session: tractor-addr-unpacking
|
||||||
|
timestamp: 2026-08-25T04:55:57Z
|
||||||
|
git_ref: fdccfd7e
|
||||||
|
scope: code
|
||||||
|
substantive: true
|
||||||
|
raw_file: 20260825T045557Z_fdccfd7e_prompt_io.raw.md
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prompt
|
||||||
|
|
||||||
|
After committing the foundational bindspace capability model, the
|
||||||
|
human authorized the next incremental Layer C change.
|
||||||
|
|
||||||
|
## Response summary
|
||||||
|
|
||||||
|
Added a borrow-only existing-netns context manager which pins stable
|
||||||
|
identity through an open FD and always releases that local descriptor.
|
||||||
|
The API does not create, enter or remove namespaces and rejects named
|
||||||
|
path traversal.
|
||||||
|
Staged review renamed the context to `attach_netns()`, introduced the
|
||||||
|
readable `CURRENT_NETNS` alias and made key validation errors identify
|
||||||
|
their exact owning field.
|
||||||
|
Follow-up review made the empty-key error spell out that `None` is
|
||||||
|
written as `CURRENT_NETNS` for `BindspaceSpec.key` declarations.
|
||||||
|
|
||||||
|
## Files changed
|
||||||
|
|
||||||
|
- `tractor/discovery/_bindspace.py` - existing-netns lifecycle and key
|
||||||
|
validation.
|
||||||
|
- `tractor/discovery/__init__.py` - public lifecycle export.
|
||||||
|
- `tests/discovery/test_bindspace.py` - current, named, missing and
|
||||||
|
traversal coverage.
|
||||||
|
- `ai/tpt-backends/03_wg_tunnel_bindspace.md` - borrow-only lifecycle
|
||||||
|
contract.
|
||||||
|
|
||||||
|
## Human edits
|
||||||
|
|
||||||
|
The human selected the previously deferred borrow-only netns lifecycle
|
||||||
|
as the next incremental Layer C change. The agent implemented the
|
||||||
|
source changes. During staged review, the human selected
|
||||||
|
`attach_netns()` terminology, requested explicit
|
||||||
|
`BindspaceSpec.key = CURRENT_NETNS` semantics and field-specific key
|
||||||
|
validation. Follow-up review requested the validation error itself
|
||||||
|
connect `None` to `CURRENT_NETNS`. The agent applied those
|
||||||
|
human-directed edits; no direct manual source edits were observed.
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
---
|
||||||
|
model: gpt-5.6-sol
|
||||||
|
service: opencode
|
||||||
|
timestamp: 2026-08-25T04:55:57Z
|
||||||
|
git_ref: fdccfd7e
|
||||||
|
diff_cmd: git diff HEAD~1..HEAD
|
||||||
|
---
|
||||||
|
|
||||||
|
# Raw output - borrow existing netns bindspaces
|
||||||
|
|
||||||
|
The human reported the bindspace capability model committed and
|
||||||
|
authorized the next incremental Layer C change.
|
||||||
|
|
||||||
|
> `git diff HEAD~1..HEAD -- tractor/discovery/_bindspace.py tractor/discovery/__init__.py tests/discovery/test_bindspace.py ai/tpt-backends/03_wg_tunnel_bindspace.md`
|
||||||
|
|
||||||
|
Added async `open_existing_netns()` as a borrow-only context manager.
|
||||||
|
It opens the current process netns or a named entry under the standard
|
||||||
|
iproute2 run directory, derives stable identity from the opened FD,
|
||||||
|
and yields a borrowed process-local `BindspaceHandle`.
|
||||||
|
|
||||||
|
The context uses `O_CLOEXEC`, never creates, enters or removes a
|
||||||
|
namespace, and synchronously closes only its FD on exit. Netns keys
|
||||||
|
reject paths to keep named lookup beneath the run directory.
|
||||||
|
|
||||||
|
Added current, named, missing and traversal tests. Ruff and lock checks
|
||||||
|
passed; discovery plus message coverage passed 132 tests with 2
|
||||||
|
xpasses.
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
---
|
||||||
|
model: gpt-5.6-sol
|
||||||
|
service: opencode
|
||||||
|
session: tractor-addr-unpacking
|
||||||
|
timestamp: 2026-08-25T19:05:29Z
|
||||||
|
git_ref: e1007547
|
||||||
|
scope: code
|
||||||
|
substantive: true
|
||||||
|
raw_file: 20260825T190529Z_e1007547_prompt_io.raw.md
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prompt
|
||||||
|
|
||||||
|
After committing the attach-only network namespace lifecycle, the
|
||||||
|
human authorized the next incremental Layer C change.
|
||||||
|
|
||||||
|
## Response summary
|
||||||
|
|
||||||
|
Added owned `open_netns()` creation and teardown through shielded
|
||||||
|
pyroute2 worker calls. The context pins identity through
|
||||||
|
`attach_netns()`, yields an owned handle, closes its FD before removal
|
||||||
|
and cleans up despite caller cancellation.
|
||||||
|
|
||||||
|
## Files changed
|
||||||
|
|
||||||
|
- `tractor/discovery/_bindspace.py` - owned netns lifecycle.
|
||||||
|
- `tractor/discovery/__init__.py` - public lifecycle export.
|
||||||
|
- `tests/discovery/test_bindspace.py` - ownership, ordering,
|
||||||
|
cancellation and name requirements.
|
||||||
|
- `ai/tpt-backends/03_wg_tunnel_bindspace.md` - owned lifecycle and
|
||||||
|
spawn-boundary contract.
|
||||||
|
|
||||||
|
## Human edits
|
||||||
|
|
||||||
|
The human selected owned network namespace creation as the next
|
||||||
|
incremental Layer C change. The agent implemented the source changes;
|
||||||
|
no direct manual edits or follow-up corrections were observed.
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
---
|
||||||
|
model: gpt-5.6-sol
|
||||||
|
service: opencode
|
||||||
|
timestamp: 2026-08-25T19:05:29Z
|
||||||
|
git_ref: e1007547
|
||||||
|
diff_cmd: git diff HEAD~1..HEAD
|
||||||
|
---
|
||||||
|
|
||||||
|
# Raw output - own created netns bindspaces
|
||||||
|
|
||||||
|
The human reported the attach-only netns lifecycle committed and
|
||||||
|
authorized the next incremental Layer C change.
|
||||||
|
|
||||||
|
> `git diff HEAD~1..HEAD -- tractor/discovery/_bindspace.py tractor/discovery/__init__.py tests/discovery/test_bindspace.py ai/tpt-backends/03_wg_tunnel_bindspace.md`
|
||||||
|
|
||||||
|
Added async `open_netns()` as the owned counterpart to
|
||||||
|
`attach_netns()`. It requires a named spec, creates through pyroute2 in
|
||||||
|
a shielded worker call, attaches the resulting namespace FD and yields
|
||||||
|
an owned process-local `BindspaceHandle`.
|
||||||
|
|
||||||
|
FD closure occurs before shielded namespace removal on normal,
|
||||||
|
exceptional and cancelled exits. The context never calls `setns()`;
|
||||||
|
namespace entry remains a spawn/bootstrap responsibility.
|
||||||
|
|
||||||
|
Privileged operations are faked in tests. Ruff and lock checks passed;
|
||||||
|
discovery plus message coverage passed 137 tests with 2 xpasses.
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
---
|
||||||
|
model: gpt-5.6-sol
|
||||||
|
service: opencode
|
||||||
|
session: tractor-addr-unpacking
|
||||||
|
timestamp: 2026-08-25T19:18:45Z
|
||||||
|
git_ref: 5b2a064a
|
||||||
|
scope: code
|
||||||
|
substantive: true
|
||||||
|
raw_file: 20260825T191845Z_5b2a064a_prompt_io.raw.md
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prompt
|
||||||
|
|
||||||
|
The human pointed out that the agent had produced no changes after the
|
||||||
|
previous commit and expected the next incremental implementation.
|
||||||
|
|
||||||
|
## Response summary
|
||||||
|
|
||||||
|
Corrected the sequencing mistake and added explicit serialized
|
||||||
|
bindspace lifecycle policy plus generic `open_bindspace()` dispatch.
|
||||||
|
Borrowed and owned netns contexts share a policy-neutral FD-pinning
|
||||||
|
primitive while validating their public lifecycle before side effects.
|
||||||
|
|
||||||
|
## Files changed
|
||||||
|
|
||||||
|
- `tractor/discovery/_bindspace.py` - lifecycle policy, ownership
|
||||||
|
invariants, shared pinning and dispatch.
|
||||||
|
- `tractor/discovery/__init__.py` - public policy and dispatcher exports.
|
||||||
|
- `tests/discovery/test_bindspace.py` - lifecycle serialization,
|
||||||
|
validation and both dispatcher branches.
|
||||||
|
- `ai/tpt-backends/03_wg_tunnel_bindspace.md` - explicit lifecycle
|
||||||
|
policy independent of transport role.
|
||||||
|
|
||||||
|
## Human edits
|
||||||
|
|
||||||
|
The human identified that the agent had accidentally repeated a
|
||||||
|
summary of already committed work instead of implementing the next
|
||||||
|
slice. That correction directly caused this lifecycle/dispatcher
|
||||||
|
change to be implemented. The agent wrote the source changes; no
|
||||||
|
direct manual edits were observed.
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
---
|
||||||
|
model: gpt-5.6-sol
|
||||||
|
service: opencode
|
||||||
|
timestamp: 2026-08-25T19:18:45Z
|
||||||
|
git_ref: 5b2a064a
|
||||||
|
diff_cmd: git diff HEAD~1..HEAD
|
||||||
|
---
|
||||||
|
|
||||||
|
# Raw output - dispatch explicit bindspace lifecycle
|
||||||
|
|
||||||
|
The human noticed the agent had re-reported already committed work
|
||||||
|
without changing the worktree. The agent confirmed the sequencing
|
||||||
|
error and proceeded to the actual next Layer C slice.
|
||||||
|
|
||||||
|
> `git diff HEAD~1..HEAD -- tractor/discovery/_bindspace.py tractor/discovery/__init__.py tests/discovery/test_bindspace.py ai/tpt-backends/03_wg_tunnel_bindspace.md`
|
||||||
|
|
||||||
|
Added serialized `BindspaceSpec.lifecycle` policy with explicit
|
||||||
|
`attach` and `open` choices. Added `open_bindspace()` dispatch without
|
||||||
|
inferring resource ownership from listen/dial role.
|
||||||
|
|
||||||
|
Refactored FD attachment into policy-neutral `_pin_netns()` so
|
||||||
|
borrowed and owned public lifecycles can share identity pinning while
|
||||||
|
enforcing their own policy before side effects. Handle construction
|
||||||
|
also verifies lifecycle and resulting ownership agree.
|
||||||
|
|
||||||
|
One composition error introduced during implementation was caught by
|
||||||
|
the focused test: owned creation initially called the newly guarded
|
||||||
|
public attach API. `_pin_netns()` fixed that layering error. Ruff and
|
||||||
|
lock checks passed; discovery plus message coverage passed 138 tests
|
||||||
|
with 2 xpasses.
|
||||||
|
|
@ -206,7 +206,7 @@ Observed protocol-name lists, for writing the `match`:
|
||||||
**not** hand-roll a `wg` parser in `tractor` — the whole point
|
**not** hand-roll a `wg` parser in `tractor` — the whole point
|
||||||
of #429 was dropping the NIH parser.
|
of #429 was dropping the NIH parser.
|
||||||
|
|
||||||
### 3.3 verification helper (pure, composable)
|
### 3.3 pure codecs + explicit verification
|
||||||
|
|
||||||
Port #482 §2's pure helpers into
|
Port #482 §2's pure helpers into
|
||||||
`tractor/discovery/_tunnel.py`, keeping the impure probe cleanly
|
`tractor/discovery/_tunnel.py`, keeping the impure probe cleanly
|
||||||
|
|
@ -215,12 +215,12 @@ 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: ... # layer B
|
async def verify_wg_peer(spec: WGTunnelSpec) -> bool: ... # layer B
|
||||||
```
|
```
|
||||||
|
|
||||||
In layer A `verify_wg_peer()` may shell out (`wg show <if>
|
Layer A's example-local `verify_wg_peer()` may shell out (`wg show
|
||||||
peers`), but it must be a *single* function so layer B swaps
|
<if> peers`), but layer B replaces that probe with one explicit async
|
||||||
only its body. Never call it implicitly from
|
function backed by pyroute2. Never call it implicitly from
|
||||||
`wrap_address()`/`parse_maddr()` — parsing must stay pure and
|
`wrap_address()`/`parse_maddr()` — parsing must stay pure and
|
||||||
side-effect-free; verification is the *caller's* explicit step
|
side-effect-free; verification is the *caller's* explicit step
|
||||||
(and later, the bindspace `@acm`'s).
|
(and later, the bindspace `@acm`'s).
|
||||||
|
|
@ -243,7 +243,7 @@ side-effect-free; verification is the *caller's* explicit step
|
||||||
(`proto_key`/`unwrap` identical to overlay), `wrap_address()`
|
(`proto_key`/`unwrap` identical to overlay), `wrap_address()`
|
||||||
regression (a tunnelled maddr `str` → `TunnelledAddress`; a
|
regression (a tunnelled maddr `str` → `TunnelledAddress`; a
|
||||||
plain one → unchanged), and **a real end-to-end over a
|
plain one → unchanged), and **a real end-to-end over a
|
||||||
locally-created wg pair** gated on `CAP_NET_ADMIN` (see §5.3).
|
locally-created wg pair** gated on `CAP_NET_ADMIN` (see §5.4).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -289,9 +289,12 @@ converting anything else — there is no perf argument here, only
|
||||||
a "no foreign event loop in a trio actor" argument, which (1)
|
a "no foreign event loop in a trio actor" argument, which (1)
|
||||||
already satisfies (a thread is not an event loop).
|
already satisfies (a thread is not an event loop).
|
||||||
|
|
||||||
Explicitly **do not** pull in `trio-asyncio` for pyroute2: it
|
Explicitly **do not** pull in `trio-asyncio` for pyroute2 or infect
|
||||||
would be the one place in the runtime where an asyncio loop
|
every wg-using actor merely to service one-shot netlink calls. A
|
||||||
exists for no reason.
|
dedicated `wgman` actor (§5.1) is the one plausible asyncio-hosted
|
||||||
|
shape: it can use tractor's own `.to_asyncio` task linkage while
|
||||||
|
keeping the foreign loop and provisioning authority out of ordinary
|
||||||
|
actor processes.
|
||||||
|
|
||||||
### 4.2 API shape
|
### 4.2 API shape
|
||||||
|
|
||||||
|
|
@ -306,7 +309,8 @@ async def read_wg_peers(
|
||||||
async def read_wg_pubkey(iface: str = 'wg0', ...) -> str: ...
|
async def read_wg_pubkey(iface: str = 'wg0', ...) -> str: ...
|
||||||
```
|
```
|
||||||
|
|
||||||
and `verify_wg_peer()` becomes a thin composition over the two.
|
and `verify_wg_peer()` becomes a thin composition over one shared key
|
||||||
|
snapshot.
|
||||||
Note the pure-getter rule: no `read_wg_peers(..., create=True)`.
|
Note the pure-getter rule: no `read_wg_peers(..., create=True)`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -327,7 +331,68 @@ describes the data-plane socket, not who provisions it: tractor owns
|
||||||
the lifecycle while `Endpoint`/`MsgTransport` remain responsible only
|
the lifecycle while `Endpoint`/`MsgTransport` remain responsible only
|
||||||
for the overlay application socket.
|
for the overlay application socket.
|
||||||
|
|
||||||
### 5.1 the composition
|
### 5.1 candidate default: first-child `wgman`
|
||||||
|
|
||||||
|
For a WG-enabled deployment profile, consider eagerly spawning one
|
||||||
|
private **WireGuard manager** (`wgman`) as the root actor's logical
|
||||||
|
first child. It is a narrow network-control-plane service, not a
|
||||||
|
general worker and not an application-visible transport endpoint.
|
||||||
|
The naive profile gets one manager for the actor tree; advanced
|
||||||
|
deployments may disable it for pre-provisioned networking or place
|
||||||
|
one manager in each capability/bindspace security domain.
|
||||||
|
|
||||||
|
"First child" describes supervision and teardown ordering, not a
|
||||||
|
serial startup barrier. Submit the `wgman` spawn in the same startup
|
||||||
|
wave as ordinary children, start its pyroute2 import, generic-netlink
|
||||||
|
discovery and declared-tunnel reconciliation immediately, and publish
|
||||||
|
a readiness signal separately. Sibling processes can boot in parallel;
|
||||||
|
only their first WG-dependent bind/dial waits for manager readiness.
|
||||||
|
This overlaps setup with actor-tree startup and avoids every sibling
|
||||||
|
paying its own pyroute2/loop/socket initialization latency.
|
||||||
|
|
||||||
|
The initial manager can still call the sync helpers from §4.1. A
|
||||||
|
natural follow-up is to spawn it with `infect_asyncio=True` and keep
|
||||||
|
`AsyncWireGuard` clients alive on asyncio's host loop through
|
||||||
|
`tractor.to_asyncio.run_task()`. Tractor then owns cross-loop task
|
||||||
|
linkage, cancellation and error propagation, while normal siblings
|
||||||
|
remain plain Trio actors. Keep one client per realized namespace or
|
||||||
|
other kernel control domain; do not share a pyroute2 socket across
|
||||||
|
domains merely to reduce object count.
|
||||||
|
|
||||||
|
Keep the authority surface deliberately small:
|
||||||
|
|
||||||
|
- accept structured inspect/verify/ensure/release requests derived
|
||||||
|
from `WGTunnelSpec`, `BindspaceSpec` and explicit `role`; never
|
||||||
|
expose arbitrary pyroute2 calls, shell commands or `setns()` RPC;
|
||||||
|
- let the root/supervisor mediate access initially, or hand siblings
|
||||||
|
a scoped manager capability; do not register a privileged `wgman`
|
||||||
|
endpoint for unrestricted cluster-wide discovery;
|
||||||
|
- never return private keys or namespace FDs to application actors;
|
||||||
|
pass secrets and live capabilities into the manager through the
|
||||||
|
supervisor-owned bootstrap path;
|
||||||
|
- grant only the capabilities required for the manager's assigned
|
||||||
|
domain. Prefer a manager already placed in that user/net namespace
|
||||||
|
over one process holding ambient authority across every namespace;
|
||||||
|
- make ensure/release idempotent and reference-count ownership so one
|
||||||
|
sibling cannot tear down a tunnel still borrowed by another.
|
||||||
|
|
||||||
|
The root owns the manager's lifetime. `wgman` must outlive all
|
||||||
|
siblings borrowing its tunnels and exit before the root drops the
|
||||||
|
underlying namespace/capability handles. A manager crash fails closed:
|
||||||
|
dependent operations receive an explicit service error; restart, if
|
||||||
|
enabled, reconciles declared state idempotently before advertising
|
||||||
|
readiness again. Do not silently let siblings fall back to privileged
|
||||||
|
local provisioning, since that defeats both the security boundary and
|
||||||
|
the single warm control-plane benefit.
|
||||||
|
|
||||||
|
Treat eager `wgman` as a measured deployment-profile choice. Compare
|
||||||
|
root startup with no WG declarations, pre-provisioned read-only WG,
|
||||||
|
and runtime-managed tunnels before making it unconditional whenever
|
||||||
|
the `wg` extra is installed. The intended invariant is "one warm
|
||||||
|
manager per simple WG actor tree", not "every tractor program spawns
|
||||||
|
a privileged child".
|
||||||
|
|
||||||
|
### 5.2 the composition
|
||||||
|
|
||||||
The maddr describes the composed network path and can be used as
|
The maddr describes the composed network path and can be used as
|
||||||
either a source/listen or destination/dial handle. It does **not**
|
either a source/listen or destination/dial handle. It does **not**
|
||||||
|
|
@ -343,16 +408,17 @@ class BindspaceSpec(msgspec.Struct, frozen=True):
|
||||||
'''Serializable spawn/config declaration.'''
|
'''Serializable spawn/config declaration.'''
|
||||||
kind: str # `netns`, later `vrf`, ...
|
kind: str # `netns`, later `vrf`, ...
|
||||||
key: str|None # requested name/key, if any
|
key: str|None # requested name/key, if any
|
||||||
|
lifecycle: Literal['attach', 'open']
|
||||||
|
|
||||||
|
|
||||||
class BindspaceIdentity(msgspec.Struct, frozen=True):
|
class BindspaceIdentity(msgspec.Struct, frozen=True):
|
||||||
'''Stable identity of the realized platform resource.'''
|
'''Stable identity of the realized platform resource.'''
|
||||||
kind: str
|
kind: str
|
||||||
key: str|None
|
key: str|None # mutable name, absent after unlink
|
||||||
inode: int|None # Linux namespace identity
|
inode: int # stable Linux namespace identity
|
||||||
|
|
||||||
|
|
||||||
class BindspaceHandle:
|
class BindspaceHandle(ProcessLocal):
|
||||||
'''Scoped, non-serializable capability for one live bindspace.'''
|
'''Scoped, non-serializable capability for one live bindspace.'''
|
||||||
spec: BindspaceSpec
|
spec: BindspaceSpec
|
||||||
identity: BindspaceIdentity
|
identity: BindspaceIdentity
|
||||||
|
|
@ -363,8 +429,6 @@ class BindspaceHandle:
|
||||||
@acm
|
@acm
|
||||||
async def open_bindspace(
|
async def open_bindspace(
|
||||||
spec: BindspaceSpec,
|
spec: BindspaceSpec,
|
||||||
*,
|
|
||||||
role: Literal['listen', 'dial'],
|
|
||||||
) -> AsyncGenerator[BindspaceHandle, None]:
|
) -> AsyncGenerator[BindspaceHandle, None]:
|
||||||
'''
|
'''
|
||||||
Provision/borrow one bindspace and yield its live capability.
|
Provision/borrow one bindspace and yield its live capability.
|
||||||
|
|
@ -372,13 +436,41 @@ async def open_bindspace(
|
||||||
'''
|
'''
|
||||||
```
|
```
|
||||||
|
|
||||||
The exact field set remains design work; the required split does not:
|
The initial model limits `BindspaceKind` to `netns` while preserving
|
||||||
`BindspaceSpec` crosses config/spawn serialization, while
|
the required lifetime split. `BindspaceSpec` and
|
||||||
`BindspaceHandle` contains live OS resources (especially an open
|
`BindspaceIdentity` are frozen msgspec structs which cross
|
||||||
namespace FD), pins identity/lifetime, and must never cross msgpack.
|
config/spawn serialization. `BindspaceHandle` also uses msgspec's
|
||||||
An FD is a stronger capability than a namespace name: it avoids
|
generic struct storage by inheriting the global
|
||||||
name-resolution TOCTOU, survives rename/unlink, and identifies the
|
`tractor.msg.ProcessLocal` marker. Its hidden unsupported sentinel
|
||||||
exact namespace the parent provisioned.
|
blocks direct and nested default msgspec encoding without a recursive
|
||||||
|
IPC hot-path scan. The handle validates any supplied FD against
|
||||||
|
`BindspaceIdentity.inode`; explicit FD transfer belongs to the
|
||||||
|
supervisor bootstrap path. An FD avoids name-resolution TOCTOU,
|
||||||
|
survives rename/unlink, and identifies the exact namespace the parent
|
||||||
|
provisioned. Extend the kind/field union only when a second platform
|
||||||
|
resource is implemented.
|
||||||
|
|
||||||
|
`BindspaceSpec.lifecycle` is explicit serialized policy:
|
||||||
|
`'attach'` borrows an existing resource and `'open'` creates/owns one.
|
||||||
|
`open_bindspace()` dispatches that policy by bindspace kind. Never
|
||||||
|
infer it from a listen/dial role: either role may use pre-provisioned
|
||||||
|
or locally owned networking.
|
||||||
|
|
||||||
|
The first lifecycle implementation is deliberately borrow-only:
|
||||||
|
`attach_netns()` opens either `/proc/self/ns/net` when
|
||||||
|
`BindspaceSpec.key = CURRENT_NETNS`, or a named entry beneath
|
||||||
|
`/var/run/netns`. It derives identity from the opened FD, yields
|
||||||
|
`ownership='borrowed'`, and closes only that FD on exit. "Attach" does
|
||||||
|
not call `setns()`; it never creates, enters or removes a namespace.
|
||||||
|
Future `open_netns()` creation and owned teardown remain a separate
|
||||||
|
privileged supervisor change.
|
||||||
|
|
||||||
|
`open_netns()` is that owned counterpart: it requires a named spec,
|
||||||
|
creates through pyroute2 in a shielded worker call, attaches the live
|
||||||
|
FD, and yields `ownership='owned'`. FD closure precedes another
|
||||||
|
shielded pyroute2 removal call on every post-creation exit, including
|
||||||
|
cancellation. It still never calls `setns()`; process entry remains a
|
||||||
|
spawn/bootstrap operation.
|
||||||
|
|
||||||
`open_bindspace()` is **not** an address factory and does not return a
|
`open_bindspace()` is **not** an address factory and does not return a
|
||||||
`TunnelledAddress`. At the declaration layer, listener allocation can
|
`TunnelledAddress`. At the declaration layer, listener allocation can
|
||||||
|
|
@ -387,7 +479,6 @@ use the handle to replace an overlay while preserving every tunnel:
|
||||||
```python
|
```python
|
||||||
async with open_bindspace(
|
async with open_bindspace(
|
||||||
bindspace_spec,
|
bindspace_spec,
|
||||||
role='listen',
|
|
||||||
) as bindspace:
|
) as bindspace:
|
||||||
listen_decl = declared_addr.get_random(
|
listen_decl = declared_addr.get_random(
|
||||||
bindspace=bindspace,
|
bindspace=bindspace,
|
||||||
|
|
@ -400,6 +491,10 @@ contract open. A concrete transport call returns a concrete overlay;
|
||||||
a declaration-level call may replace the overlay and return a new
|
a declaration-level call may replace the overlay and return a new
|
||||||
`TunnelledAddress`. In either case wrappers remain until the final
|
`TunnelledAddress`. In either case wrappers remain until the final
|
||||||
transport bind/dial boundary, where `strip_tunnels()` is mandatory.
|
transport bind/dial boundary, where `strip_tunnels()` is mandatory.
|
||||||
|
At the listener boundary, keep the split explicit:
|
||||||
|
`Endpoint.addr` is the peeled concrete address used for transport
|
||||||
|
reflection, while `Endpoint.declared_addr` retains the original
|
||||||
|
wrapper for namespace diagnostics and later bindspace orchestration.
|
||||||
|
|
||||||
Per-platform provisioning still composes one resource context per
|
Per-platform provisioning still composes one resource context per
|
||||||
tunnel/bindspace layer:
|
tunnel/bindspace layer:
|
||||||
|
|
@ -408,7 +503,6 @@ tunnel/bindspace layer:
|
||||||
@acm
|
@acm
|
||||||
async def open_netns(
|
async def open_netns(
|
||||||
spec: BindspaceSpec,
|
spec: BindspaceSpec,
|
||||||
role: Literal['listen', 'dial'],
|
|
||||||
) -> AsyncGenerator[BindspaceHandle, None]: ...
|
) -> AsyncGenerator[BindspaceHandle, None]: ...
|
||||||
|
|
||||||
@acm
|
@acm
|
||||||
|
|
@ -428,24 +522,22 @@ it already returns
|
||||||
`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
|
The caller supplies `role` to tunnel-resource contexts such as
|
||||||
composed maddr can name a server source or client destination, and the
|
`open_wg_iface()`; do not infer it from maddr shape. Bindspace
|
||||||
required local provisioning/ownership differs (§5.3).
|
lifecycle remains the independent explicit policy above. The same
|
||||||
|
composed maddr can name a server source or client destination (§5.4).
|
||||||
|
|
||||||
### 5.2 `Address.namespace`, at last
|
### 5.3 `Address.namespace`, at last
|
||||||
|
|
||||||
- `TunnelledAddress.namespace` → `(kind, id)` e.g.
|
- `TunnelledAddress.namespace` → `(kind, id)` e.g.
|
||||||
`('netns', 'tractor-wg0')`.
|
`('netns', 'tractor-wg0')`.
|
||||||
- **and** the existing backends should implement it as `None`
|
- existing plain backends implement it explicitly as `None`, so the
|
||||||
explicitly (they currently just don't define it), so the
|
Protocol does not lie and tunnel delegation needs no `getattr()`
|
||||||
Protocol stops lying.
|
fallback.
|
||||||
- consumers to audit: nothing reads `.namespace` today — so
|
- `Endpoint.namespace` reads the retained declaration rather than its
|
||||||
adding it is safe, but the *point* is that
|
peeled transport addr; both `Endpoint.pformat()` and
|
||||||
`Endpoint`/`Server.pformat()` should start showing it (there's
|
`Server.pformat()` expose that value as the cheapest proof the layer
|
||||||
already a `# !TODO, always be ns aware!` +
|
is wired.
|
||||||
`f'|_netns: {netns}\n'` placeholder sitting in
|
|
||||||
`Endpoint.pformat()`, `_server.py:645`). Fill that in; it's
|
|
||||||
the cheapest possible proof the layer is wired.
|
|
||||||
|
|
||||||
Use `github/ns_aware@e4688cad` as prototype evidence, not code to
|
Use `github/ns_aware@e4688cad` as prototype evidence, not code to
|
||||||
cherry-pick unchanged. Its `/proc/<pid>/ns/<type>` inode reader and
|
cherry-pick unchanged. Its `/proc/<pid>/ns/<type>` inode reader and
|
||||||
|
|
@ -454,7 +546,7 @@ pair. Layer C should move that shape into `BindspaceIdentity`, avoid a
|
||||||
subprocess where netlink/procfs suffices, and hold the namespace FD in
|
subprocess where netlink/procfs suffices, and hold the namespace FD in
|
||||||
`BindspaceHandle` to pin the identity.
|
`BindspaceHandle` to pin the identity.
|
||||||
|
|
||||||
### 5.3 the netns/process reality — read this before designing
|
### 5.4 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
|
||||||
**runtime-level config API, not an actor-app-code API.** It is
|
**runtime-level config API, not an actor-app-code API.** It is
|
||||||
|
|
@ -533,7 +625,7 @@ server bound in the old namespace.
|
||||||
tolerance and `_serve_ipc_eps()`'s per-ep `try/except`
|
tolerance and `_serve_ipc_eps()`'s per-ep `try/except`
|
||||||
encode. Mirror both.
|
encode. Mirror both.
|
||||||
|
|
||||||
### 5.4 tests for layer C
|
### 5.5 tests for layer C
|
||||||
|
|
||||||
- unit: fold-N-tunnel-specs-into-nested-`@acm`s, with fakes; assert
|
- unit: fold-N-tunnel-specs-into-nested-`@acm`s, with fakes; assert
|
||||||
enter/exit ordering (outermost-last-out) via a trace list.
|
enter/exit ordering (outermost-last-out) via a trace list.
|
||||||
|
|
@ -543,7 +635,7 @@ server bound in the old namespace.
|
||||||
one and a subactor in the other, `find_actor()` across the
|
one and a subactor in the other, `find_actor()` across the
|
||||||
tunnel. This is a *fantastic* test to have and is fully
|
tunnel. This is a *fantastic* test to have and is fully
|
||||||
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.4, 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
|
- bootstrap ordering: assert the child reports the expected namespace
|
||||||
inode before parent-channel connect and listener creation.
|
inode before parent-channel connect and listener creation.
|
||||||
|
|
@ -552,6 +644,14 @@ server bound in the old namespace.
|
||||||
- privilege drop: prove actor code lacks provisioning caps after entry.
|
- privilege drop: prove actor code lacks provisioning caps after entry.
|
||||||
- role/ownership: fake listen/dial resources and assert owned listener
|
- role/ownership: fake listen/dial resources and assert owned listener
|
||||||
teardown versus borrowed dial-handle release.
|
teardown versus borrowed dial-handle release.
|
||||||
|
- `wgman` bootstrap: prove sibling process startup overlaps manager
|
||||||
|
reconciliation while the first WG operation still waits for its
|
||||||
|
readiness signal.
|
||||||
|
- `wgman` authority: reject arbitrary callers/operations and prove an
|
||||||
|
unprivileged sibling cannot receive secrets, FDs or provisioning
|
||||||
|
authority through the manager API.
|
||||||
|
- `wgman` lifetime: prove it outlives tunnel borrowers, fails pending
|
||||||
|
requests explicitly on crash and reconciles before restart-ready.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -566,7 +666,7 @@ machinery covers any iface-layer tunnel `pyroute2` can drive —
|
||||||
`kind: ClassVar[str]`, and dispatch `open_*` by `match` on it.
|
`kind: ClassVar[str]`, and dispatch `open_*` by `match` on it.
|
||||||
Design for it now (union + `match`), implement only `wg` +
|
Design for it now (union + `match`), implement only `wg` +
|
||||||
`netns`. `veth`-pairs-in-netns is the natural second one because
|
`netns`. `veth`-pairs-in-netns is the natural second one because
|
||||||
it makes the §5.4 integration test possible without wg at all —
|
it makes the §5.5 integration test possible without wg at all —
|
||||||
consider doing it *first* for exactly that reason.
|
consider doing it *first* for exactly that reason.
|
||||||
|
|
||||||
## 7. Non-goals
|
## 7. Non-goals
|
||||||
|
|
@ -584,7 +684,7 @@ 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.4; pass `netns=` to pyroute2 or pin a worker; test-first |
|
||||||
| namespace name is renamed/replaced between provision and spawn | pass an open namespace FD; verify `(key, inode)` after child entry |
|
| namespace name is renamed/replaced between provision and spawn | pass an open namespace FD; verify `(key, inode)` after child entry |
|
||||||
| child starts sockets/threads before `setns()` | enter in the spawn bootstrap trampoline before `_runtime.async_main()`; assert inode ordering |
|
| 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 |
|
| ambient capabilities leak into actor app code | split provision/enter authority and drop caps before runtime initialization |
|
||||||
|
|
@ -592,7 +692,9 @@ consider doing it *first* for exactly that reason.
|
||||||
| py-multiaddr#108 merged but unreleased | PEP 621 direct-revision pin + `_wg_proto_code()` gate; replace with a release floor once published |
|
| 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 |
|
| `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 every actor | use a worker for one-shots; confine persistent asyncio to an infected `wgman` (§4.1, §5.1) |
|
||||||
|
| eager `wgman` serializes or slows root bootstrap | spawn it in parallel; gate only WG-dependent operations on readiness; measure before making the profile unconditional |
|
||||||
|
| `wgman` becomes a cluster-wide privilege oracle | keep it private/scoped, expose structured verbs only and split managers by capability domain |
|
||||||
| netns teardown strands actor teardown | idempotent/tolerant teardown mirroring `_uds.close_listener()` |
|
| netns teardown strands actor teardown | idempotent/tolerant teardown mirroring `_uds.close_listener()` |
|
||||||
|
|
||||||
## 9. Follow-up issue seeds
|
## 9. Follow-up issue seeds
|
||||||
|
|
@ -604,6 +706,7 @@ consider doing it *first* for exactly that reason.
|
||||||
- `wg` proto into the multiaddr **spec** (gh #483), then flip
|
- `wg` proto into the multiaddr **spec** (gh #483), then flip
|
||||||
`MsgTransport.maddr` to always return `Multiaddr` (the third
|
`MsgTransport.maddr` to always return `Multiaddr` (the third
|
||||||
#443 bullet)
|
#443 bullet)
|
||||||
- runtime-managed wg key rotation / peer add-remove as a
|
- first-child `wgman` prototype: concurrent bootstrap, scoped sibling
|
||||||
`tractor` service actor — the natural "actor that owns the
|
access, infected-asyncio pyroute2 ownership and restart reconciliation
|
||||||
network" demo
|
- runtime-managed wg key rotation / peer add-remove through `wgman` —
|
||||||
|
the natural "actor that owns the network" demo
|
||||||
|
|
|
||||||
|
|
@ -45,11 +45,12 @@ no `wg` codec. So `pyproject.toml` temporarily pins the merge commit
|
||||||
in its PEP 621 dependency metadata, and a plain
|
in its PEP 621 dependency metadata, and a plain
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uv sync
|
uv sync --extra wg
|
||||||
```
|
```
|
||||||
|
|
||||||
gets you a `wg`-aware `multiaddr`. That pin goes away once a
|
gets you a `wg`-aware `multiaddr` plus pyroute2's Linux netlink API.
|
||||||
release carries the codec. `py-multibase` is a direct dependency.
|
The multiaddr pin goes away once a release carries the codec.
|
||||||
|
`py-multibase` is a direct dependency.
|
||||||
|
|
||||||
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
|
||||||
|
|
@ -150,13 +151,12 @@ Four corrections, all from
|
||||||
all. `parse_wg_maddr()` now rejects it with an actionable
|
all. `parse_wg_maddr()` now rejects it with an actionable
|
||||||
error.
|
error.
|
||||||
2. **parsing is pure.** #482's helper had the key-check adjacent
|
2. **parsing is pure.** #482's helper had the key-check adjacent
|
||||||
to the parse; `verify_wg_peer()` is now a separate, explicitly
|
to the parse; async `verify_wg_peer()` is now a separate,
|
||||||
composed step that the caller invokes. A parser that shells
|
explicitly composed step that the caller invokes. Implicit
|
||||||
out is a nasty surprise.
|
kernel inspection from a parser is a nasty surprise.
|
||||||
3. **no `sudo`.** #482 ran `sudo wg show`; a library/example must
|
3. **no `sudo` or subprocess.** #482 ran `sudo wg show`; tractor's
|
||||||
never escalate. `wg show` works unprivileged for read on most
|
helper reads generic netlink through pyroute2 and never attempts
|
||||||
setups; if yours needs root, run the script as root rather
|
privilege escalation or namespace creation.
|
||||||
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 `TunnelledAddress`, 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`
|
||||||
|
|
@ -166,7 +166,7 @@ Four corrections, all from
|
||||||
|
|
||||||
## next
|
## next
|
||||||
|
|
||||||
Layer A's `TunnelledAddress` and native maddr parser now live in
|
Layer A's `TunnelledAddress` and native maddr parser plus Layer B's
|
||||||
`tractor.discovery`. Next, replace this example's `wg(8)` verification
|
explicit pyroute2 verification now live in `tractor.discovery`. Next,
|
||||||
probe with `pyroute2`, then add `open_bindspace()` `@acm`s which
|
add `open_bindspace()` `@acm`s which create/tear down the iface and
|
||||||
create/tear down the iface and netns.
|
netns.
|
||||||
|
|
|
||||||
|
|
@ -14,10 +14,9 @@ from tractor.discovery import (
|
||||||
TunnelledAddress,
|
TunnelledAddress,
|
||||||
mk_maddr,
|
mk_maddr,
|
||||||
parse_wg_maddr,
|
parse_wg_maddr,
|
||||||
|
verify_wg_peer,
|
||||||
)
|
)
|
||||||
|
|
||||||
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
|
||||||
|
|
@ -35,7 +34,7 @@ async def echo(msg: str) -> str:
|
||||||
|
|
||||||
async def main():
|
async def main():
|
||||||
addr: TunnelledAddress = parse_wg_maddr(WG_MADDR)
|
addr: TunnelledAddress = parse_wg_maddr(WG_MADDR)
|
||||||
assert verify_wg_peer(addr), (
|
assert await verify_wg_peer(addr.tunnel), (
|
||||||
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.tunnel.peer_pubkey}\n'
|
f'key: {addr.tunnel.peer_pubkey}\n'
|
||||||
|
|
|
||||||
|
|
@ -11,11 +11,10 @@ import trio
|
||||||
from tractor.discovery import (
|
from tractor.discovery import (
|
||||||
TunnelledAddress,
|
TunnelledAddress,
|
||||||
parse_wg_maddr,
|
parse_wg_maddr,
|
||||||
|
verify_wg_peer,
|
||||||
)
|
)
|
||||||
|
|
||||||
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 verify_wg_peer
|
|
||||||
|
|
||||||
# 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 = (
|
||||||
'/ip4/192.168.1.50/udp/51820'
|
'/ip4/192.168.1.50/udp/51820'
|
||||||
|
|
@ -26,7 +25,7 @@ WG_MADDR: str = (
|
||||||
|
|
||||||
async def main():
|
async def main():
|
||||||
addr: TunnelledAddress = parse_wg_maddr(WG_MADDR)
|
addr: TunnelledAddress = parse_wg_maddr(WG_MADDR)
|
||||||
assert verify_wg_peer(addr), (
|
assert await verify_wg_peer(addr.tunnel), (
|
||||||
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'
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,63 +0,0 @@
|
||||||
# tractor: distributed structured concurrency.
|
|
||||||
r'''
|
|
||||||
Verify `wg` peers declared by tractor's multiaddr parser.
|
|
||||||
|
|
||||||
`tractor.discovery.parse_wg_maddr()` owns pure parsing and delegates
|
|
||||||
all tunnel peeling to `py-multiaddr`. This example keeps only the
|
|
||||||
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
|
|
||||||
\_______ wg bearer ______/\_ key _/\____ tractor ep _____/
|
|
||||||
|
|
||||||
The kernel owns the bearer socket. A future tractor bindspace may
|
|
||||||
provision it through netlink, but only the overlay is an application
|
|
||||||
`MsgTransport` endpoint.
|
|
||||||
|
|
||||||
'''
|
|
||||||
from __future__ import annotations
|
|
||||||
import subprocess
|
|
||||||
|
|
||||||
from tractor.discovery import (
|
|
||||||
TunnelledAddress,
|
|
||||||
WGTunnelSpec,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def verify_wg_peer(
|
|
||||||
addr: TunnelledAddress,
|
|
||||||
iface: str|None = None,
|
|
||||||
) -> bool:
|
|
||||||
'''
|
|
||||||
Check the outer tunnel's key against one local `wg` iface.
|
|
||||||
|
|
||||||
IMPURE + explicit by design: neither `parse_wg_maddr()` nor
|
|
||||||
`tractor.discovery.parse_maddr()` calls this probe.
|
|
||||||
|
|
||||||
?TODO, per plan-03 layer B, swap this body for `pyroute2`
|
|
||||||
while retaining the explicit verification boundary.
|
|
||||||
|
|
||||||
'''
|
|
||||||
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:
|
|
||||||
return subprocess.run(
|
|
||||||
['wg', 'show', iface, *args],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
check=True,
|
|
||||||
).stdout
|
|
||||||
|
|
||||||
return (
|
|
||||||
spec.peer_pubkey in _wg('peers').split()
|
|
||||||
or
|
|
||||||
spec.peer_pubkey == _wg('public-key').strip()
|
|
||||||
)
|
|
||||||
|
|
@ -62,6 +62,12 @@ dependencies = [
|
||||||
"setproctitle>=1.3,<2",
|
"setproctitle>=1.3,<2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
wg = [
|
||||||
|
# read/provision Linux WireGuard state through netlink
|
||||||
|
"pyroute2>=0.9.6,<0.10 ; sys_platform == 'linux'",
|
||||||
|
]
|
||||||
|
|
||||||
# ------ project ------
|
# ------ project ------
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
|
|
@ -165,9 +171,8 @@ sync_pause = {requires-python = ">=3.13, <3.14"}
|
||||||
# editable = true
|
# editable = true
|
||||||
|
|
||||||
# ------ tool.uv.sources ------
|
# ------ tool.uv.sources ------
|
||||||
# TODO, distributed (multi-host) extensions
|
# Linux kernel networking is provided by the optional `wg` extra.
|
||||||
# linux kernel networking
|
# Add any temporary `pyroute2` source overrides here.
|
||||||
# 'pyroute2
|
|
||||||
|
|
||||||
# ------ tool.uv.sources ------
|
# ------ tool.uv.sources ------
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,567 @@
|
||||||
|
'''
|
||||||
|
Bindspace declaration, identity and live-capability contracts.
|
||||||
|
|
||||||
|
'''
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from typing import BinaryIO
|
||||||
|
|
||||||
|
import msgspec
|
||||||
|
import pytest
|
||||||
|
import trio
|
||||||
|
|
||||||
|
from tractor.discovery import (
|
||||||
|
BindspaceHandle,
|
||||||
|
BindspaceIdentity,
|
||||||
|
BindspaceOwnership,
|
||||||
|
BindspaceSpec,
|
||||||
|
CURRENT_NETNS,
|
||||||
|
attach_netns,
|
||||||
|
open_bindspace,
|
||||||
|
open_netns,
|
||||||
|
)
|
||||||
|
from tractor.discovery import _bindspace
|
||||||
|
from tractor.msg import ProcessLocal
|
||||||
|
|
||||||
|
|
||||||
|
def test_bindspace_declarations_roundtrip() -> None:
|
||||||
|
'''
|
||||||
|
Spawn configuration and realized identity must cross actor IPC.
|
||||||
|
|
||||||
|
Encode both frozen structs through msgpack and decode with their
|
||||||
|
concrete types, proving names and stable inode identity survive
|
||||||
|
without carrying any process-local capability state.
|
||||||
|
|
||||||
|
'''
|
||||||
|
values: tuple[
|
||||||
|
BindspaceSpec|BindspaceIdentity,
|
||||||
|
...,
|
||||||
|
] = (
|
||||||
|
BindspaceSpec(
|
||||||
|
kind='netns',
|
||||||
|
key='tractor-wg0',
|
||||||
|
lifecycle='open',
|
||||||
|
),
|
||||||
|
BindspaceIdentity(
|
||||||
|
kind='netns',
|
||||||
|
key='tractor-wg0',
|
||||||
|
inode=1234,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
value: BindspaceSpec|BindspaceIdentity
|
||||||
|
for value in values:
|
||||||
|
encoded: bytes = msgspec.msgpack.encode(value)
|
||||||
|
decoded: BindspaceSpec|BindspaceIdentity = (
|
||||||
|
msgspec.msgpack.decode(
|
||||||
|
encoded,
|
||||||
|
type=type(value),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert decoded == value
|
||||||
|
|
||||||
|
|
||||||
|
def test_bindspace_handle_pins_local_capability(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
'''
|
||||||
|
A live handle pins one exact FD and realized identity.
|
||||||
|
|
||||||
|
Open a stand-in platform handle, record its inode in the realized
|
||||||
|
identity and construct an owned capability. Prove the generic
|
||||||
|
msgspec struct retains that exact local state. Its ability to
|
||||||
|
encode ordinary fields is not authority to transfer the handle.
|
||||||
|
|
||||||
|
'''
|
||||||
|
token_path: Path = tmp_path / 'bindspace'
|
||||||
|
token_path.touch()
|
||||||
|
namespace_file: BinaryIO
|
||||||
|
with token_path.open('rb') as namespace_file:
|
||||||
|
namespace_fd: int = namespace_file.fileno()
|
||||||
|
inode: int = token_path.stat().st_ino
|
||||||
|
spec: BindspaceSpec = BindspaceSpec(
|
||||||
|
kind='netns',
|
||||||
|
key='tractor-wg0',
|
||||||
|
lifecycle='open',
|
||||||
|
)
|
||||||
|
identity: BindspaceIdentity = BindspaceIdentity(
|
||||||
|
kind='netns',
|
||||||
|
key='tractor-wg0',
|
||||||
|
inode=inode,
|
||||||
|
)
|
||||||
|
handle: BindspaceHandle = BindspaceHandle(
|
||||||
|
spec=spec,
|
||||||
|
identity=identity,
|
||||||
|
namespace_fd=namespace_fd,
|
||||||
|
ownership='owned',
|
||||||
|
)
|
||||||
|
|
||||||
|
assert handle.spec is spec
|
||||||
|
assert handle.identity is identity
|
||||||
|
assert handle.namespace_fd == namespace_file.fileno()
|
||||||
|
assert handle.ownership == 'owned'
|
||||||
|
assert isinstance(handle, msgspec.Struct)
|
||||||
|
assert isinstance(handle, ProcessLocal)
|
||||||
|
with pytest.raises(
|
||||||
|
TypeError,
|
||||||
|
match='_ProcessLocalToken.*unsupported',
|
||||||
|
):
|
||||||
|
msgspec.msgpack.encode(handle)
|
||||||
|
|
||||||
|
|
||||||
|
def test_bindspace_handle_rejects_mismatched_identity(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
'''
|
||||||
|
A name or inode mismatch would make a handle stale authority.
|
||||||
|
|
||||||
|
Construct a requested named spec, then prove both a different
|
||||||
|
realized name and an inode not belonging to the supplied FD are
|
||||||
|
rejected before either can become a live capability.
|
||||||
|
|
||||||
|
'''
|
||||||
|
token_path: Path = tmp_path / 'bindspace'
|
||||||
|
token_path.touch()
|
||||||
|
spec: BindspaceSpec = BindspaceSpec(
|
||||||
|
kind='netns',
|
||||||
|
key='tractor-wg0',
|
||||||
|
)
|
||||||
|
# Keep ownership and FD fixed so only identity changes below.
|
||||||
|
ownership: BindspaceOwnership = 'borrowed'
|
||||||
|
namespace_file: BinaryIO
|
||||||
|
with token_path.open('rb') as namespace_file:
|
||||||
|
namespace_fd: int = namespace_file.fileno()
|
||||||
|
wrong_name: BindspaceIdentity = BindspaceIdentity(
|
||||||
|
kind='netns',
|
||||||
|
key='other-wg',
|
||||||
|
inode=token_path.stat().st_ino,
|
||||||
|
)
|
||||||
|
with pytest.raises(
|
||||||
|
ValueError,
|
||||||
|
match='Spec.key.*Identity.key',
|
||||||
|
):
|
||||||
|
BindspaceHandle(
|
||||||
|
spec=spec,
|
||||||
|
identity=wrong_name,
|
||||||
|
namespace_fd=namespace_fd,
|
||||||
|
ownership=ownership,
|
||||||
|
)
|
||||||
|
|
||||||
|
wrong_inode: BindspaceIdentity = BindspaceIdentity(
|
||||||
|
kind='netns',
|
||||||
|
key='tractor-wg0',
|
||||||
|
inode=token_path.stat().st_ino + 1,
|
||||||
|
)
|
||||||
|
with pytest.raises(
|
||||||
|
ValueError,
|
||||||
|
match='FD inode.*identity inode',
|
||||||
|
):
|
||||||
|
BindspaceHandle(
|
||||||
|
spec=spec,
|
||||||
|
identity=wrong_inode,
|
||||||
|
namespace_fd=namespace_fd,
|
||||||
|
ownership=ownership,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
('model', 'kwargs', 'match'),
|
||||||
|
(
|
||||||
|
pytest.param(
|
||||||
|
BindspaceIdentity,
|
||||||
|
{
|
||||||
|
'kind': 'netns',
|
||||||
|
'key': None,
|
||||||
|
'inode': None,
|
||||||
|
},
|
||||||
|
'must be a positive `int`',
|
||||||
|
id='identity-requires-inode',
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
BindspaceSpec,
|
||||||
|
{'kind': 'vrf'},
|
||||||
|
'Unsupported bindspace kind',
|
||||||
|
id='spec-rejects-kind',
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
BindspaceIdentity,
|
||||||
|
{
|
||||||
|
'kind': 'vrf',
|
||||||
|
'key': 'blue',
|
||||||
|
'inode': 1234,
|
||||||
|
},
|
||||||
|
'Unsupported bindspace kind',
|
||||||
|
id='identity-rejects-kind',
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
BindspaceSpec,
|
||||||
|
{
|
||||||
|
'kind': 'netns',
|
||||||
|
'key': '../outside',
|
||||||
|
},
|
||||||
|
'Invalid netns name',
|
||||||
|
id='spec-rejects-path',
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
BindspaceSpec,
|
||||||
|
{
|
||||||
|
'kind': 'netns',
|
||||||
|
'key': '',
|
||||||
|
},
|
||||||
|
'BindspaceSpec.key',
|
||||||
|
id='spec-rejects-empty-key',
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
BindspaceSpec,
|
||||||
|
{
|
||||||
|
'kind': 'netns',
|
||||||
|
'key': 'tractor-wg0',
|
||||||
|
'lifecycle': 'replace',
|
||||||
|
},
|
||||||
|
'Unsupported bindspace lifecycle',
|
||||||
|
id='spec-rejects-lifecycle',
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
BindspaceIdentity,
|
||||||
|
{
|
||||||
|
'kind': 'netns',
|
||||||
|
'key': '',
|
||||||
|
'inode': 1234,
|
||||||
|
},
|
||||||
|
'BindspaceIdentity.key',
|
||||||
|
id='identity-rejects-empty-key',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
def test_bindspace_models_reject_invalid_values(
|
||||||
|
model: type[BindspaceSpec]|type[BindspaceIdentity],
|
||||||
|
kwargs: dict[str, object],
|
||||||
|
match: str,
|
||||||
|
) -> None:
|
||||||
|
'''
|
||||||
|
Direct msgspec construction does not enforce field annotations.
|
||||||
|
|
||||||
|
Parameterize the missing stable inode and future, unimplemented
|
||||||
|
kinds. Prove neither serializable model can carry invalid
|
||||||
|
identity or provisioning instructions into spawn configuration.
|
||||||
|
|
||||||
|
'''
|
||||||
|
with pytest.raises(ValueError, match=match):
|
||||||
|
model(**kwargs) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(
|
||||||
|
sys.platform != 'linux',
|
||||||
|
reason='Linux netns API',
|
||||||
|
)
|
||||||
|
def test_open_bindspace_attaches_current_netns() -> None:
|
||||||
|
'''
|
||||||
|
The unnamed spec must borrow and pin the caller's current netns.
|
||||||
|
|
||||||
|
Open `/proc/self/ns/net`, prove the yielded handle records its
|
||||||
|
stable inode and borrowed ownership, then exit the context and
|
||||||
|
prove the exact descriptor was closed without altering the
|
||||||
|
namespace itself.
|
||||||
|
|
||||||
|
'''
|
||||||
|
async def main() -> int:
|
||||||
|
'''
|
||||||
|
Borrow the current netns and return its descriptor number.
|
||||||
|
|
||||||
|
'''
|
||||||
|
spec: BindspaceSpec = BindspaceSpec(
|
||||||
|
kind='netns',
|
||||||
|
)
|
||||||
|
assert spec.key is CURRENT_NETNS
|
||||||
|
async with open_bindspace(spec) as handle:
|
||||||
|
namespace_fd: int|None = handle.namespace_fd
|
||||||
|
assert namespace_fd is not None
|
||||||
|
assert handle.spec is spec
|
||||||
|
assert handle.identity.key is None
|
||||||
|
assert handle.identity.inode == os.fstat(
|
||||||
|
namespace_fd
|
||||||
|
).st_ino
|
||||||
|
assert handle.ownership == 'borrowed'
|
||||||
|
return namespace_fd
|
||||||
|
|
||||||
|
namespace_fd: int = trio.run(main)
|
||||||
|
with pytest.raises(OSError):
|
||||||
|
os.fstat(namespace_fd)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(
|
||||||
|
sys.platform != 'linux',
|
||||||
|
reason='Linux netns API',
|
||||||
|
)
|
||||||
|
def test_attach_named_netns_uses_run_directory(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
'''
|
||||||
|
A named spec must resolve only beneath the configured netns dir.
|
||||||
|
|
||||||
|
Replace the run directory with a temporary stand-in, borrow its
|
||||||
|
named inode, and prove the context neither deletes the existing
|
||||||
|
resource nor leaves its descriptor open after exit.
|
||||||
|
|
||||||
|
'''
|
||||||
|
netns_path: Path = tmp_path / 'tractor-wg0'
|
||||||
|
netns_path.touch()
|
||||||
|
monkeypatch.setattr(
|
||||||
|
_bindspace,
|
||||||
|
'_NETNS_RUN_DIR',
|
||||||
|
tmp_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def main() -> int:
|
||||||
|
'''
|
||||||
|
Borrow the named stand-in and return its descriptor number.
|
||||||
|
|
||||||
|
'''
|
||||||
|
spec: BindspaceSpec = BindspaceSpec(
|
||||||
|
kind='netns',
|
||||||
|
key='tractor-wg0',
|
||||||
|
)
|
||||||
|
async with attach_netns(spec) as handle:
|
||||||
|
namespace_fd: int|None = handle.namespace_fd
|
||||||
|
assert namespace_fd is not None
|
||||||
|
assert handle.identity.key == 'tractor-wg0'
|
||||||
|
assert handle.identity.inode == netns_path.stat().st_ino
|
||||||
|
assert handle.ownership == 'borrowed'
|
||||||
|
return namespace_fd
|
||||||
|
|
||||||
|
namespace_fd: int = trio.run(main)
|
||||||
|
assert netns_path.exists()
|
||||||
|
with pytest.raises(OSError):
|
||||||
|
os.fstat(namespace_fd)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(
|
||||||
|
sys.platform != 'linux',
|
||||||
|
reason='Linux netns API',
|
||||||
|
)
|
||||||
|
def test_attach_named_netns_never_creates(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
'''
|
||||||
|
Borrow-only lookup must fail without creating a missing resource.
|
||||||
|
|
||||||
|
Point the run directory at an empty location, request one named
|
||||||
|
netns, and prove the open error propagates while no path appears.
|
||||||
|
|
||||||
|
'''
|
||||||
|
monkeypatch.setattr(
|
||||||
|
_bindspace,
|
||||||
|
'_NETNS_RUN_DIR',
|
||||||
|
tmp_path,
|
||||||
|
)
|
||||||
|
missing_path: Path = tmp_path / 'missing'
|
||||||
|
|
||||||
|
async def main() -> None:
|
||||||
|
'''
|
||||||
|
Attempt to borrow one absent named netns.
|
||||||
|
|
||||||
|
'''
|
||||||
|
spec: BindspaceSpec = BindspaceSpec(
|
||||||
|
kind='netns',
|
||||||
|
key='missing',
|
||||||
|
)
|
||||||
|
async with attach_netns(spec):
|
||||||
|
raise AssertionError('Missing netns unexpectedly opened')
|
||||||
|
|
||||||
|
with pytest.raises(FileNotFoundError):
|
||||||
|
trio.run(main)
|
||||||
|
assert not missing_path.exists()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(
|
||||||
|
sys.platform != 'linux',
|
||||||
|
reason='Linux netns API',
|
||||||
|
)
|
||||||
|
def test_open_netns_owns_lifecycle(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
'''
|
||||||
|
Successful creation must yield ownership and remove on exit.
|
||||||
|
|
||||||
|
Fake pyroute2 creation with a named stand-in file, verify the
|
||||||
|
yielded FD and identity while it exists, then prove FD closure
|
||||||
|
precedes resource removal when the context exits.
|
||||||
|
|
||||||
|
'''
|
||||||
|
events: list[str] = []
|
||||||
|
namespace_fds: list[int] = []
|
||||||
|
netns_path: Path = tmp_path / 'tractor-wg0'
|
||||||
|
|
||||||
|
def create(key: str) -> None:
|
||||||
|
'''
|
||||||
|
Create the named stand-in and record lifecycle order.
|
||||||
|
|
||||||
|
'''
|
||||||
|
assert key == 'tractor-wg0'
|
||||||
|
netns_path.touch()
|
||||||
|
events.append('create')
|
||||||
|
|
||||||
|
def remove(key: str) -> None:
|
||||||
|
'''
|
||||||
|
Remove the stand-in after its FD has closed.
|
||||||
|
|
||||||
|
'''
|
||||||
|
assert key == 'tractor-wg0'
|
||||||
|
events.append('fd-closed')
|
||||||
|
with pytest.raises(OSError):
|
||||||
|
os.fstat(namespace_fds[0])
|
||||||
|
netns_path.unlink()
|
||||||
|
events.append('remove')
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
_bindspace,
|
||||||
|
'_NETNS_RUN_DIR',
|
||||||
|
tmp_path,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
_bindspace,
|
||||||
|
'_create_netns',
|
||||||
|
create,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
_bindspace,
|
||||||
|
'_remove_netns',
|
||||||
|
remove,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def main() -> None:
|
||||||
|
'''
|
||||||
|
Open the fake netns and publish its live descriptor number.
|
||||||
|
|
||||||
|
'''
|
||||||
|
spec: BindspaceSpec = BindspaceSpec(
|
||||||
|
kind='netns',
|
||||||
|
key='tractor-wg0',
|
||||||
|
lifecycle='open',
|
||||||
|
)
|
||||||
|
async with open_bindspace(spec) as handle:
|
||||||
|
fd: int|None = handle.namespace_fd
|
||||||
|
assert fd is not None
|
||||||
|
assert handle.ownership == 'owned'
|
||||||
|
assert handle.identity.inode == os.fstat(fd).st_ino
|
||||||
|
namespace_fds.append(fd)
|
||||||
|
events.append('yield')
|
||||||
|
|
||||||
|
trio.run(main)
|
||||||
|
assert events == [
|
||||||
|
'create',
|
||||||
|
'yield',
|
||||||
|
'fd-closed',
|
||||||
|
'remove',
|
||||||
|
]
|
||||||
|
assert not netns_path.exists()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(
|
||||||
|
sys.platform != 'linux',
|
||||||
|
reason='Linux netns API',
|
||||||
|
)
|
||||||
|
def test_open_netns_shields_cancelled_cleanup(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
'''
|
||||||
|
Cancellation after creation must not leak an owned namespace.
|
||||||
|
|
||||||
|
Cancel the caller inside the yielded context and checkpoint.
|
||||||
|
Prove shielded teardown still removes the stand-in before
|
||||||
|
cancellation leaves the enclosing scope.
|
||||||
|
|
||||||
|
'''
|
||||||
|
netns_path: Path = tmp_path / 'tractor-wg0'
|
||||||
|
removed: list[str] = []
|
||||||
|
|
||||||
|
def create(key: str) -> None:
|
||||||
|
'''
|
||||||
|
Create the named stand-in before cancellation.
|
||||||
|
|
||||||
|
'''
|
||||||
|
netns_path.touch()
|
||||||
|
|
||||||
|
def remove(key: str) -> None:
|
||||||
|
'''
|
||||||
|
Remove the stand-in despite caller cancellation.
|
||||||
|
|
||||||
|
'''
|
||||||
|
netns_path.unlink()
|
||||||
|
removed.append(key)
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
_bindspace,
|
||||||
|
'_NETNS_RUN_DIR',
|
||||||
|
tmp_path,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
_bindspace,
|
||||||
|
'_create_netns',
|
||||||
|
create,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
_bindspace,
|
||||||
|
'_remove_netns',
|
||||||
|
remove,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def main() -> None:
|
||||||
|
'''
|
||||||
|
Cancel while borrowing the newly owned namespace.
|
||||||
|
|
||||||
|
'''
|
||||||
|
spec: BindspaceSpec = BindspaceSpec(
|
||||||
|
kind='netns',
|
||||||
|
key='tractor-wg0',
|
||||||
|
lifecycle='open',
|
||||||
|
)
|
||||||
|
with trio.CancelScope() as scope:
|
||||||
|
async with open_netns(spec):
|
||||||
|
scope.cancel()
|
||||||
|
await trio.sleep_forever()
|
||||||
|
|
||||||
|
trio.run(main)
|
||||||
|
assert removed == ['tractor-wg0']
|
||||||
|
assert not netns_path.exists()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(
|
||||||
|
sys.platform != 'linux',
|
||||||
|
reason='Linux netns API',
|
||||||
|
)
|
||||||
|
def test_open_netns_requires_name() -> None:
|
||||||
|
'''
|
||||||
|
Creation cannot target the caller's current netns.
|
||||||
|
|
||||||
|
Pass `CURRENT_NETNS` and prove validation rejects it before any
|
||||||
|
privileged pyroute2 operation can run.
|
||||||
|
|
||||||
|
'''
|
||||||
|
spec: BindspaceSpec = BindspaceSpec(
|
||||||
|
kind='netns',
|
||||||
|
key=CURRENT_NETNS,
|
||||||
|
lifecycle='open',
|
||||||
|
)
|
||||||
|
|
||||||
|
async def main() -> None:
|
||||||
|
'''
|
||||||
|
Attempt to create the unnamed current namespace.
|
||||||
|
|
||||||
|
'''
|
||||||
|
async with open_netns(spec):
|
||||||
|
raise AssertionError(
|
||||||
|
'Current netns unexpectedly created'
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(
|
||||||
|
ValueError,
|
||||||
|
match='requires a named',
|
||||||
|
):
|
||||||
|
trio.run(main)
|
||||||
|
|
@ -27,6 +27,7 @@ from tractor.discovery._addr import (
|
||||||
wrap_address,
|
wrap_address,
|
||||||
)
|
)
|
||||||
from tractor.ipc._tcp import TCPAddress
|
from tractor.ipc._tcp import TCPAddress
|
||||||
|
from tractor.ipc._uds import UDSAddress
|
||||||
|
|
||||||
|
|
||||||
# a valid-looking std-base64 `wg(8)` pubkey (32B -> 44 chars)
|
# a valid-looking std-base64 `wg(8)` pubkey (32B -> 44 chars)
|
||||||
|
|
@ -174,16 +175,13 @@ def test_namespace_comes_from_the_tunnel(
|
||||||
overlay: TCPAddress,
|
overlay: TCPAddress,
|
||||||
):
|
):
|
||||||
'''
|
'''
|
||||||
First real consumer of `Address.namespace`, spec'd in the
|
Plain transport addresses explicitly select no namespace, while
|
||||||
protocol since day one and implemented by no backend.
|
a tunnel can select one for the same concrete overlay.
|
||||||
|
|
||||||
'''
|
'''
|
||||||
# XXX, "no backend implements it" is literal — the member
|
uds_addr: UDSAddress = UDSAddress('/tmp', 'tractor-test.sock')
|
||||||
# isn't even declared, so this is `AttributeError` not `None`.
|
assert overlay.namespace is None
|
||||||
# This assert is the guard: when a backend finally declares
|
assert uds_addr.namespace is None
|
||||||
# `.namespace`, it fails and the `getattr()` fallback in
|
|
||||||
# `TunnelledAddress.namespace` can go.
|
|
||||||
assert not hasattr(overlay, 'namespace')
|
|
||||||
|
|
||||||
no_ns = TunnelledAddress(
|
no_ns = TunnelledAddress(
|
||||||
overlay=overlay,
|
overlay=overlay,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,305 @@
|
||||||
|
'''
|
||||||
|
Read-only WireGuard netlink inspection tests.
|
||||||
|
|
||||||
|
'''
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import threading
|
||||||
|
from typing import (
|
||||||
|
Any,
|
||||||
|
NoReturn,
|
||||||
|
)
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import trio
|
||||||
|
|
||||||
|
from tractor.discovery import (
|
||||||
|
read_wg_peers,
|
||||||
|
read_wg_pubkey,
|
||||||
|
verify_wg_peer,
|
||||||
|
WGTunnelSpec,
|
||||||
|
)
|
||||||
|
from tractor.discovery import _tunnel
|
||||||
|
|
||||||
|
pyroute2: Any = pytest.importorskip('pyroute2')
|
||||||
|
|
||||||
|
|
||||||
|
_PUBKEY: str = 'g3x7z0AdV1rM6UQU22CC7IL3/ivn4DzrE7ikDhCZ/Dc='
|
||||||
|
_PEER_1: str = '7PClzcj8o1yAjyPJb0zL2Gt0s2J7yZ6c0JXYqNBGr0E='
|
||||||
|
_PEER_2: str = 'H7bJbl1bpY7VzDlB5wI3KjA7JsiYoMWGDJd8dYgc5iw='
|
||||||
|
_MISSING_KEY: str = 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA='
|
||||||
|
|
||||||
|
|
||||||
|
class Attrs:
|
||||||
|
'''
|
||||||
|
Minimal pyroute2 netlink-attribute message fake.
|
||||||
|
|
||||||
|
'''
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
**attrs: Any,
|
||||||
|
) -> None:
|
||||||
|
'''
|
||||||
|
Store attributes for `.get_attr()` lookups.
|
||||||
|
|
||||||
|
'''
|
||||||
|
self._attrs: dict[str, Any] = attrs
|
||||||
|
|
||||||
|
def get_attr(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
) -> Any:
|
||||||
|
'''
|
||||||
|
Return the named fake netlink attribute.
|
||||||
|
|
||||||
|
'''
|
||||||
|
return self._attrs.get(name)
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_wg_keys_in_worker_thread(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
'''
|
||||||
|
Pyroute2's synchronous `WireGuard` API owns a private asyncio
|
||||||
|
loop. Running it in the Trio thread would either block Trio or
|
||||||
|
introduce that foreign loop into the actor runtime.
|
||||||
|
|
||||||
|
Replace `pyroute2.WireGuard` with a fake which records thread,
|
||||||
|
iface, netns and close state. Return a multipart dump containing
|
||||||
|
duplicate peers, then prove both public helpers execute
|
||||||
|
off-thread, preserve named-netns selection, validate keys,
|
||||||
|
deduplicate peers in kernel order and close every netlink client.
|
||||||
|
|
||||||
|
'''
|
||||||
|
trio_thread: int = threading.get_ident()
|
||||||
|
|
||||||
|
class FakeWireGuard:
|
||||||
|
'''
|
||||||
|
Record each read-only `pyroute2.WireGuard` interaction.
|
||||||
|
|
||||||
|
'''
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
netns: str|None,
|
||||||
|
flags: int,
|
||||||
|
) -> None:
|
||||||
|
'''
|
||||||
|
Record namespace selection without opening netlink.
|
||||||
|
|
||||||
|
'''
|
||||||
|
self.netns = netns
|
||||||
|
self.flags = flags
|
||||||
|
self.closed = False
|
||||||
|
self.thread_id: int|None = None
|
||||||
|
self.iface: str|None = None
|
||||||
|
instances.append(self)
|
||||||
|
|
||||||
|
def info(
|
||||||
|
self,
|
||||||
|
iface: str,
|
||||||
|
) -> tuple[Attrs, Attrs]:
|
||||||
|
'''
|
||||||
|
Return a multipart WireGuard device dump.
|
||||||
|
|
||||||
|
'''
|
||||||
|
self.thread_id = threading.get_ident()
|
||||||
|
self.iface = iface
|
||||||
|
peer_1: Attrs = Attrs(
|
||||||
|
WGPEER_A_PUBLIC_KEY=_PEER_1.encode(),
|
||||||
|
)
|
||||||
|
peer_2: Attrs = Attrs(
|
||||||
|
WGPEER_A_PUBLIC_KEY=_PEER_2.encode(),
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
Attrs(
|
||||||
|
WGDEVICE_A_PUBLIC_KEY=_PUBKEY.encode(),
|
||||||
|
WGDEVICE_A_PEERS=[peer_1],
|
||||||
|
),
|
||||||
|
Attrs(
|
||||||
|
WGDEVICE_A_PUBLIC_KEY=_PUBKEY.encode(),
|
||||||
|
WGDEVICE_A_PEERS=[peer_2, peer_1],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
'''
|
||||||
|
Record netlink-client cleanup.
|
||||||
|
|
||||||
|
'''
|
||||||
|
self.closed = True
|
||||||
|
|
||||||
|
instances: list[FakeWireGuard] = []
|
||||||
|
monkeypatch.setattr(
|
||||||
|
pyroute2,
|
||||||
|
'WireGuard',
|
||||||
|
FakeWireGuard,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def main() -> None:
|
||||||
|
'''
|
||||||
|
Read both key views from Trio's run thread.
|
||||||
|
|
||||||
|
'''
|
||||||
|
assert await read_wg_pubkey(
|
||||||
|
iface='wg-test',
|
||||||
|
netns='actor-net',
|
||||||
|
) == _PUBKEY
|
||||||
|
assert await read_wg_peers(
|
||||||
|
iface='wg-test',
|
||||||
|
netns='actor-net',
|
||||||
|
) == (_PEER_1, _PEER_2)
|
||||||
|
|
||||||
|
trio.run(main)
|
||||||
|
|
||||||
|
assert len(instances) == 2
|
||||||
|
instance: FakeWireGuard
|
||||||
|
for instance in instances:
|
||||||
|
assert instance.netns == 'actor-net'
|
||||||
|
assert instance.flags == 0
|
||||||
|
assert instance.iface == 'wg-test'
|
||||||
|
assert instance.thread_id != trio_thread
|
||||||
|
assert instance.closed
|
||||||
|
|
||||||
|
|
||||||
|
def test_wg_client_closes_when_read_fails(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
'''
|
||||||
|
A failed netlink read must not leak pyroute2's socket or private
|
||||||
|
event loop. Raise from the fake `.info()` call and prove the same
|
||||||
|
error reaches the Trio caller only after `.close()` runs.
|
||||||
|
|
||||||
|
'''
|
||||||
|
class FakeWireGuard:
|
||||||
|
'''
|
||||||
|
Raise during device inspection and record cleanup.
|
||||||
|
|
||||||
|
'''
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
netns: str|None,
|
||||||
|
flags: int,
|
||||||
|
) -> None:
|
||||||
|
'''
|
||||||
|
Publish this fake instance for the cleanup assertion.
|
||||||
|
|
||||||
|
'''
|
||||||
|
nonlocal instance
|
||||||
|
self.closed = False
|
||||||
|
instance = self
|
||||||
|
|
||||||
|
def info(self, iface: str) -> NoReturn:
|
||||||
|
'''
|
||||||
|
Simulate a failing netlink device read.
|
||||||
|
|
||||||
|
'''
|
||||||
|
raise OSError('netlink read failed')
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
'''
|
||||||
|
Record cleanup after the failed read.
|
||||||
|
|
||||||
|
'''
|
||||||
|
self.closed = True
|
||||||
|
|
||||||
|
instance: FakeWireGuard|None = None
|
||||||
|
monkeypatch.setattr(
|
||||||
|
pyroute2,
|
||||||
|
'WireGuard',
|
||||||
|
FakeWireGuard,
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(
|
||||||
|
OSError,
|
||||||
|
match='netlink read failed',
|
||||||
|
):
|
||||||
|
trio.run(read_wg_pubkey)
|
||||||
|
|
||||||
|
assert instance is not None
|
||||||
|
assert instance.closed
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
('declared_key', 'expected'),
|
||||||
|
(
|
||||||
|
(_PUBKEY, True),
|
||||||
|
(_PEER_2, True),
|
||||||
|
(_MISSING_KEY, False),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
def test_verify_wg_peer(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
declared_key: str,
|
||||||
|
expected: bool,
|
||||||
|
) -> None:
|
||||||
|
'''
|
||||||
|
A tunnel declaration can identify either side of one local iface.
|
||||||
|
|
||||||
|
Return one stable key snapshot from the async reader, then prove
|
||||||
|
a local interface key and configured peer both verify while an
|
||||||
|
absent key does not. Also prove the spec selects the iface/netns
|
||||||
|
supplied to the read instead of silently using process defaults.
|
||||||
|
|
||||||
|
'''
|
||||||
|
reads: list[tuple[str, str|None]] = []
|
||||||
|
|
||||||
|
async def read_keys(
|
||||||
|
iface: str,
|
||||||
|
netns: str|None,
|
||||||
|
) -> tuple[str, tuple[str, ...]]:
|
||||||
|
'''
|
||||||
|
Return one deterministic WireGuard key snapshot.
|
||||||
|
|
||||||
|
'''
|
||||||
|
reads.append((iface, netns))
|
||||||
|
return _PUBKEY, (_PEER_1, _PEER_2)
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
_tunnel,
|
||||||
|
'_read_wg_keys',
|
||||||
|
read_keys,
|
||||||
|
)
|
||||||
|
spec: WGTunnelSpec = WGTunnelSpec(
|
||||||
|
peer_pubkey=declared_key,
|
||||||
|
iface='wg-test',
|
||||||
|
netns='actor-net',
|
||||||
|
)
|
||||||
|
|
||||||
|
assert trio.run(verify_wg_peer, spec) is expected
|
||||||
|
assert reads == [('wg-test', 'actor-net')]
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_wg_peer_validates_before_read(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
'''
|
||||||
|
A directly constructed tunnel spec can contain a malformed key.
|
||||||
|
|
||||||
|
Install a reader which would fail if called, pass malformed
|
||||||
|
base64, and prove validation rejects the declaration before any
|
||||||
|
kernel-state inspection occurs.
|
||||||
|
|
||||||
|
'''
|
||||||
|
async def unexpected_read(
|
||||||
|
iface: str,
|
||||||
|
netns: str|None,
|
||||||
|
) -> NoReturn:
|
||||||
|
'''
|
||||||
|
Fail if malformed-key validation reaches the read boundary.
|
||||||
|
|
||||||
|
'''
|
||||||
|
raise AssertionError('WireGuard read must not run')
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
_tunnel,
|
||||||
|
'_read_wg_keys',
|
||||||
|
unexpected_read,
|
||||||
|
)
|
||||||
|
spec: WGTunnelSpec = WGTunnelSpec(
|
||||||
|
peer_pubkey='not-base64',
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
trio.run(verify_wg_peer, spec)
|
||||||
|
|
@ -18,14 +18,15 @@ from tractor.ipc._tcp import TCPAddress
|
||||||
_PUBKEY: str = 'g3x7z0AdV1rM6UQU22CC7IL3/ivn4DzrE7ikDhCZ/Dc='
|
_PUBKEY: str = 'g3x7z0AdV1rM6UQU22CC7IL3/ivn4DzrE7ikDhCZ/Dc='
|
||||||
|
|
||||||
|
|
||||||
def test_server_peels_before_endpoint_construction():
|
def test_server_peels_before_endpoint_construction() -> None:
|
||||||
'''
|
'''
|
||||||
`Endpoint.start_listener()` reflects on its address's declaring
|
`Endpoint.start_listener()` reflects on its address's declaring
|
||||||
module, so retaining a tunnel wrapper there selects `._tunnel`
|
module, so retaining a tunnel wrapper there selects `._tunnel`
|
||||||
instead of the TCP backend. Start a real listener from the
|
instead of the TCP backend. Start a real listener from the
|
||||||
wrapper, assert the resulting `Endpoint` contains only a resolved
|
wrapper, assert the resulting `Endpoint` contains only a resolved
|
||||||
`TCPAddress`, and prove the original declaration still carries
|
`TCPAddress`. Prove `Endpoint.declared_addr` still retains the
|
||||||
its tunnel spec for the future bindspace lifecycle.
|
original tunnel namespace for diagnostics and the future
|
||||||
|
bindspace lifecycle.
|
||||||
|
|
||||||
'''
|
'''
|
||||||
overlay = TCPAddress('127.0.0.1', 0)
|
overlay = TCPAddress('127.0.0.1', 0)
|
||||||
|
|
@ -34,6 +35,7 @@ def test_server_peels_before_endpoint_construction():
|
||||||
tunnel=WGTunnelSpec(
|
tunnel=WGTunnelSpec(
|
||||||
peer_pubkey=_PUBKEY,
|
peer_pubkey=_PUBKEY,
|
||||||
bearer=('192.168.1.50', 51820),
|
bearer=('192.168.1.50', 51820),
|
||||||
|
netns='actor-net',
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -50,6 +52,16 @@ def test_server_peels_before_endpoint_construction():
|
||||||
assert host == overlay.unwrap()[1]
|
assert host == overlay.unwrap()[1]
|
||||||
assert port > 0
|
assert port > 0
|
||||||
assert endpoint.addr is not tunnelled
|
assert endpoint.addr is not tunnelled
|
||||||
|
assert endpoint.declared_addr is tunnelled
|
||||||
|
namespace: tuple[str, str] = ('netns', 'actor-net')
|
||||||
|
assert endpoint.namespace == namespace
|
||||||
|
endpoint_repr: str = endpoint.pformat()
|
||||||
|
server_repr: str = server.pformat()
|
||||||
|
expected_namespace: str = f'namespace: {namespace!r}'
|
||||||
|
assert expected_namespace in endpoint_repr
|
||||||
|
assert ' |_namespaces:' in server_repr
|
||||||
|
assert 'netns' in server_repr
|
||||||
|
assert 'actor-net' in server_repr
|
||||||
assert tunnels_of(tunnelled) == (
|
assert tunnels_of(tunnelled) == (
|
||||||
tunnelled.tunnel,
|
tunnelled.tunnel,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,57 @@
|
||||||
|
'''
|
||||||
|
Process-local struct wire-encoding guards.
|
||||||
|
|
||||||
|
'''
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import msgspec
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from tractor.msg import ProcessLocal
|
||||||
|
|
||||||
|
|
||||||
|
class LocalHandle(ProcessLocal):
|
||||||
|
'''
|
||||||
|
Minimal process-local struct used to exercise the global marker.
|
||||||
|
|
||||||
|
'''
|
||||||
|
resource_id: int
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
'nested',
|
||||||
|
(
|
||||||
|
pytest.param(
|
||||||
|
False,
|
||||||
|
id='direct',
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
True,
|
||||||
|
id='nested',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
def test_process_local_rejects_default_encoding(
|
||||||
|
nested: bool,
|
||||||
|
) -> None:
|
||||||
|
'''
|
||||||
|
Process-local values can appear directly or deep in a payload.
|
||||||
|
|
||||||
|
Embed the same marked struct at both depths and prove msgspec's
|
||||||
|
normal traversal reaches the unsupported sentinel without a
|
||||||
|
tractor-specific recursive payload scan.
|
||||||
|
|
||||||
|
'''
|
||||||
|
handle: LocalHandle = LocalHandle(resource_id=1)
|
||||||
|
value: object = (
|
||||||
|
{'nested': [handle]}
|
||||||
|
if nested
|
||||||
|
else handle
|
||||||
|
)
|
||||||
|
|
||||||
|
assert repr(handle) == 'LocalHandle(resource_id=1)'
|
||||||
|
with pytest.raises(
|
||||||
|
TypeError,
|
||||||
|
match='_ProcessLocalToken.*unsupported',
|
||||||
|
):
|
||||||
|
msgspec.msgpack.encode(value)
|
||||||
|
|
@ -24,6 +24,18 @@ Heavier submodules like ``._addr`` and ``._api`` are NOT imported
|
||||||
here to avoid circular imports; use direct module paths for those.
|
here to avoid circular imports; use direct module paths for those.
|
||||||
|
|
||||||
'''
|
'''
|
||||||
|
from ._bindspace import (
|
||||||
|
BindspaceHandle as BindspaceHandle,
|
||||||
|
BindspaceIdentity as BindspaceIdentity,
|
||||||
|
BindspaceKind as BindspaceKind,
|
||||||
|
BindspaceLifecycle as BindspaceLifecycle,
|
||||||
|
BindspaceOwnership as BindspaceOwnership,
|
||||||
|
BindspaceSpec as BindspaceSpec,
|
||||||
|
CURRENT_NETNS as CURRENT_NETNS,
|
||||||
|
attach_netns as attach_netns,
|
||||||
|
open_bindspace as open_bindspace,
|
||||||
|
open_netns as open_netns,
|
||||||
|
)
|
||||||
from ._multiaddr import (
|
from ._multiaddr import (
|
||||||
parse_endpoints as parse_endpoints,
|
parse_endpoints as parse_endpoints,
|
||||||
parse_maddr as parse_maddr,
|
parse_maddr as parse_maddr,
|
||||||
|
|
@ -36,7 +48,10 @@ from ._tunnel import (
|
||||||
mb_pubkey as mb_pubkey,
|
mb_pubkey as mb_pubkey,
|
||||||
mk_wg_maddr as mk_wg_maddr,
|
mk_wg_maddr as mk_wg_maddr,
|
||||||
parse_wg_maddr as parse_wg_maddr,
|
parse_wg_maddr as parse_wg_maddr,
|
||||||
|
read_wg_peers as read_wg_peers,
|
||||||
|
read_wg_pubkey as read_wg_pubkey,
|
||||||
strip_tunnels as strip_tunnels,
|
strip_tunnels as strip_tunnels,
|
||||||
tunnels_of as tunnels_of,
|
tunnels_of as tunnels_of,
|
||||||
|
verify_wg_peer as verify_wg_peer,
|
||||||
wg8_pubkey as wg8_pubkey,
|
wg8_pubkey as wg8_pubkey,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,435 @@
|
||||||
|
# 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/>.
|
||||||
|
'''
|
||||||
|
Serializable bindspace declarations and live capability handles.
|
||||||
|
|
||||||
|
'''
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import AsyncIterator
|
||||||
|
from contextlib import asynccontextmanager as acm
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
from typing import (
|
||||||
|
Final,
|
||||||
|
get_args,
|
||||||
|
Literal,
|
||||||
|
TypeAlias,
|
||||||
|
)
|
||||||
|
|
||||||
|
import msgspec
|
||||||
|
import trio
|
||||||
|
|
||||||
|
from ..msg._local import ProcessLocal
|
||||||
|
|
||||||
|
|
||||||
|
BindspaceKind: TypeAlias = Literal[
|
||||||
|
'netns',
|
||||||
|
]
|
||||||
|
BindspaceLifecycle: TypeAlias = Literal[
|
||||||
|
'attach', # borrow one existing platform resource
|
||||||
|
'open', # create, own and remove one platform resource
|
||||||
|
]
|
||||||
|
BindspaceOwnership: TypeAlias = Literal[
|
||||||
|
'owned', # manager tears down the resource after final release
|
||||||
|
'borrowed', # manager leaves the pre-existing resource intact
|
||||||
|
]
|
||||||
|
|
||||||
|
_NETNS_RUN_DIR: Path = Path('/var/run/netns')
|
||||||
|
_SELF_NETNS: Path = Path('/proc/self/ns/net')
|
||||||
|
|
||||||
|
CURRENT_NETNS: Final[None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_bindspace_kind(
|
||||||
|
kind: BindspaceKind,
|
||||||
|
) -> None:
|
||||||
|
'''
|
||||||
|
Reject platform-resource kinds without an implementation.
|
||||||
|
|
||||||
|
'''
|
||||||
|
if kind not in get_args(BindspaceKind):
|
||||||
|
raise ValueError(
|
||||||
|
f'Unsupported bindspace kind: {kind!r}'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_bindspace_lifecycle(
|
||||||
|
lifecycle: BindspaceLifecycle,
|
||||||
|
) -> None:
|
||||||
|
'''
|
||||||
|
Reject lifecycle policies without an implementation.
|
||||||
|
|
||||||
|
'''
|
||||||
|
if lifecycle not in get_args(BindspaceLifecycle):
|
||||||
|
raise ValueError(
|
||||||
|
f'Unsupported bindspace lifecycle: {lifecycle!r}'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_bindspace_key(
|
||||||
|
kind: BindspaceKind,
|
||||||
|
key: str|None,
|
||||||
|
field: str,
|
||||||
|
) -> None:
|
||||||
|
'''
|
||||||
|
Reject empty or path-like platform-resource names.
|
||||||
|
|
||||||
|
`None` is valid. Spell it `CURRENT_NETNS` for
|
||||||
|
`BindspaceSpec.key`; `BindspaceIdentity.key = None` records an
|
||||||
|
unnamed realized netns.
|
||||||
|
|
||||||
|
'''
|
||||||
|
if key == '':
|
||||||
|
raise ValueError(
|
||||||
|
f'`{field}` must be a non-empty name or `None` '
|
||||||
|
f'(`CURRENT_NETNS` for `BindspaceSpec.key`)!'
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
kind == 'netns'
|
||||||
|
and
|
||||||
|
key is not None
|
||||||
|
and
|
||||||
|
(
|
||||||
|
Path(key).name != key
|
||||||
|
or
|
||||||
|
key in ('.', '..')
|
||||||
|
)
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
f'Invalid netns name: {key!r}'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class BindspaceSpec(
|
||||||
|
msgspec.Struct,
|
||||||
|
frozen=True,
|
||||||
|
):
|
||||||
|
'''
|
||||||
|
Serializable declaration of one requested bindspace.
|
||||||
|
|
||||||
|
For a netns spec, `.key = CURRENT_NETNS` selects the calling
|
||||||
|
process's current namespace without a named-path lookup.
|
||||||
|
|
||||||
|
'''
|
||||||
|
kind: BindspaceKind
|
||||||
|
key: str|None = CURRENT_NETNS
|
||||||
|
lifecycle: BindspaceLifecycle = 'attach'
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
'''
|
||||||
|
Reject an empty platform-resource key.
|
||||||
|
|
||||||
|
'''
|
||||||
|
_validate_bindspace_kind(self.kind)
|
||||||
|
_validate_bindspace_lifecycle(self.lifecycle)
|
||||||
|
_validate_bindspace_key(
|
||||||
|
self.kind,
|
||||||
|
self.key,
|
||||||
|
'BindspaceSpec.key',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class BindspaceIdentity(
|
||||||
|
msgspec.Struct,
|
||||||
|
frozen=True,
|
||||||
|
):
|
||||||
|
'''
|
||||||
|
Serializable stable identity of one realized bindspace.
|
||||||
|
|
||||||
|
`.key` is an optional, mutable namespace name. `.inode` is the
|
||||||
|
required kernel identity which remains stable after rename or
|
||||||
|
unlink.
|
||||||
|
|
||||||
|
'''
|
||||||
|
kind: BindspaceKind
|
||||||
|
key: str|None
|
||||||
|
inode: int
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
'''
|
||||||
|
Require a stable platform identity and an optional name.
|
||||||
|
|
||||||
|
'''
|
||||||
|
_validate_bindspace_kind(self.kind)
|
||||||
|
_validate_bindspace_key(
|
||||||
|
self.kind,
|
||||||
|
self.key,
|
||||||
|
'BindspaceIdentity.key',
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
type(self.inode) is not int
|
||||||
|
or
|
||||||
|
self.inode <= 0
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
'`BindspaceIdentity.inode` must be a positive `int`!'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class BindspaceHandle(
|
||||||
|
ProcessLocal,
|
||||||
|
):
|
||||||
|
'''
|
||||||
|
Process-local capability for one live realized bindspace.
|
||||||
|
|
||||||
|
`ProcessLocal` provides compact typed storage plus a default
|
||||||
|
wire-encoding guard. Explicit FD transfer and handle construction
|
||||||
|
belong to the supervisor's spawn/bootstrap path.
|
||||||
|
|
||||||
|
'''
|
||||||
|
spec: BindspaceSpec
|
||||||
|
identity: BindspaceIdentity
|
||||||
|
namespace_fd: int|None
|
||||||
|
ownership: BindspaceOwnership
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
'''
|
||||||
|
Validate and retain one scoped bindspace capability.
|
||||||
|
|
||||||
|
'''
|
||||||
|
spec: BindspaceSpec = self.spec
|
||||||
|
identity: BindspaceIdentity = self.identity
|
||||||
|
namespace_fd: int|None = self.namespace_fd
|
||||||
|
ownership: BindspaceOwnership = self.ownership
|
||||||
|
|
||||||
|
if spec.kind != identity.kind:
|
||||||
|
raise ValueError(
|
||||||
|
'`BindspaceSpec.kind` does not match '
|
||||||
|
'`BindspaceIdentity.kind`!'
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
spec.key is not None
|
||||||
|
and
|
||||||
|
spec.key != identity.key
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
'`BindspaceSpec.key` does not match '
|
||||||
|
'`BindspaceIdentity.key`!'
|
||||||
|
)
|
||||||
|
if ownership not in get_args(BindspaceOwnership):
|
||||||
|
raise ValueError(
|
||||||
|
f'Invalid bindspace ownership: {ownership!r}'
|
||||||
|
)
|
||||||
|
expected_ownership: BindspaceOwnership = (
|
||||||
|
'borrowed'
|
||||||
|
if spec.lifecycle == 'attach'
|
||||||
|
else 'owned'
|
||||||
|
)
|
||||||
|
if ownership != expected_ownership:
|
||||||
|
raise ValueError(
|
||||||
|
f'`BindspaceSpec.lifecycle={spec.lifecycle!r}` '
|
||||||
|
f'requires ownership={expected_ownership!r}!'
|
||||||
|
)
|
||||||
|
if namespace_fd is not None:
|
||||||
|
if (
|
||||||
|
type(namespace_fd) is not int
|
||||||
|
or
|
||||||
|
namespace_fd < 0
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
'`namespace_fd` must be non-negative or `None`!'
|
||||||
|
)
|
||||||
|
fd_inode: int = os.fstat(namespace_fd).st_ino
|
||||||
|
if identity.inode != fd_inode:
|
||||||
|
raise ValueError(
|
||||||
|
f'Namespace FD inode {fd_inode} does not match '
|
||||||
|
f'identity inode {identity.inode}!'
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
'''
|
||||||
|
Render capability identity without dereferencing its FD.
|
||||||
|
|
||||||
|
'''
|
||||||
|
return (
|
||||||
|
f'{type(self).__name__}('
|
||||||
|
f'identity={self.identity!r}, '
|
||||||
|
f'ownership={self.ownership!r}, '
|
||||||
|
f'namespace_fd={self.namespace_fd!r})'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@acm
|
||||||
|
async def _pin_netns(
|
||||||
|
spec: BindspaceSpec,
|
||||||
|
ownership: BindspaceOwnership,
|
||||||
|
) -> AsyncIterator[BindspaceHandle]:
|
||||||
|
'''
|
||||||
|
Pin one existing Linux network namespace with explicit ownership.
|
||||||
|
|
||||||
|
'''
|
||||||
|
key: str|None = spec.key
|
||||||
|
namespace_path: Path = (
|
||||||
|
_SELF_NETNS
|
||||||
|
if key is CURRENT_NETNS
|
||||||
|
else _NETNS_RUN_DIR / key
|
||||||
|
)
|
||||||
|
namespace_fd: int = os.open(
|
||||||
|
namespace_path,
|
||||||
|
os.O_RDONLY | os.O_CLOEXEC,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
inode: int = os.fstat(namespace_fd).st_ino
|
||||||
|
identity: BindspaceIdentity = BindspaceIdentity(
|
||||||
|
kind='netns',
|
||||||
|
key=key,
|
||||||
|
inode=inode,
|
||||||
|
)
|
||||||
|
handle: BindspaceHandle = BindspaceHandle(
|
||||||
|
spec=spec,
|
||||||
|
identity=identity,
|
||||||
|
namespace_fd=namespace_fd,
|
||||||
|
ownership=ownership,
|
||||||
|
)
|
||||||
|
yield handle
|
||||||
|
finally:
|
||||||
|
os.close(namespace_fd)
|
||||||
|
|
||||||
|
|
||||||
|
@acm
|
||||||
|
async def attach_netns(
|
||||||
|
spec: BindspaceSpec,
|
||||||
|
) -> AsyncIterator[BindspaceHandle]:
|
||||||
|
'''
|
||||||
|
Borrow and pin one existing Linux network namespace.
|
||||||
|
|
||||||
|
`BindspaceSpec.key = CURRENT_NETNS` selects the calling process's
|
||||||
|
current netns. A named key resolves beneath the standard iproute2
|
||||||
|
netns run directory. "Attach" pins an existing namespace FD; this
|
||||||
|
context never calls `setns()` or creates/removes a namespace.
|
||||||
|
|
||||||
|
'''
|
||||||
|
if sys.platform != 'linux':
|
||||||
|
raise NotImplementedError(
|
||||||
|
'Network namespace bindspaces are Linux-only!'
|
||||||
|
)
|
||||||
|
if spec.lifecycle != 'attach':
|
||||||
|
raise ValueError(
|
||||||
|
'`attach_netns()` requires lifecycle=`attach`!'
|
||||||
|
)
|
||||||
|
async with _pin_netns(
|
||||||
|
spec,
|
||||||
|
ownership='borrowed',
|
||||||
|
) as handle:
|
||||||
|
yield handle
|
||||||
|
|
||||||
|
|
||||||
|
def _create_netns(
|
||||||
|
key: str,
|
||||||
|
) -> None:
|
||||||
|
'''
|
||||||
|
Create one named netns through pyroute2's synchronous API.
|
||||||
|
|
||||||
|
'''
|
||||||
|
try:
|
||||||
|
from pyroute2 import netns
|
||||||
|
except ImportError as exc:
|
||||||
|
raise RuntimeError(
|
||||||
|
'Netns creation requires the `tractor[wg]` extra.'
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
netns.create(key)
|
||||||
|
|
||||||
|
|
||||||
|
def _remove_netns(
|
||||||
|
key: str,
|
||||||
|
) -> None:
|
||||||
|
'''
|
||||||
|
Remove one named netns through pyroute2's synchronous API.
|
||||||
|
|
||||||
|
'''
|
||||||
|
try:
|
||||||
|
from pyroute2 import netns
|
||||||
|
except ImportError as exc:
|
||||||
|
raise RuntimeError(
|
||||||
|
'Netns removal requires the `tractor[wg]` extra.'
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
netns.remove(key)
|
||||||
|
|
||||||
|
|
||||||
|
@acm
|
||||||
|
async def open_netns(
|
||||||
|
spec: BindspaceSpec,
|
||||||
|
) -> AsyncIterator[BindspaceHandle]:
|
||||||
|
'''
|
||||||
|
Create, pin and own one named Linux network namespace.
|
||||||
|
|
||||||
|
Creation and removal are shielded synchronous pyroute2 calls in a
|
||||||
|
worker thread. This context never enters the namespace.
|
||||||
|
Spawn-time bootstrap remains responsible for eventual `setns()`.
|
||||||
|
|
||||||
|
'''
|
||||||
|
if sys.platform != 'linux':
|
||||||
|
raise NotImplementedError(
|
||||||
|
'Network namespace bindspaces are Linux-only!'
|
||||||
|
)
|
||||||
|
if spec.lifecycle != 'open':
|
||||||
|
raise ValueError(
|
||||||
|
'`open_netns()` requires lifecycle=`open`!'
|
||||||
|
)
|
||||||
|
|
||||||
|
key: str|None = spec.key
|
||||||
|
if key is CURRENT_NETNS:
|
||||||
|
raise ValueError(
|
||||||
|
'`open_netns()` requires a named `BindspaceSpec.key`!'
|
||||||
|
)
|
||||||
|
|
||||||
|
created: bool = False
|
||||||
|
try:
|
||||||
|
with trio.CancelScope(shield=True):
|
||||||
|
await trio.to_thread.run_sync(
|
||||||
|
_create_netns,
|
||||||
|
key,
|
||||||
|
abandon_on_cancel=False,
|
||||||
|
)
|
||||||
|
created = True
|
||||||
|
|
||||||
|
async with _pin_netns(
|
||||||
|
spec,
|
||||||
|
ownership='owned',
|
||||||
|
) as handle:
|
||||||
|
yield handle
|
||||||
|
finally:
|
||||||
|
if created:
|
||||||
|
with trio.CancelScope(shield=True):
|
||||||
|
await trio.to_thread.run_sync(
|
||||||
|
_remove_netns,
|
||||||
|
key,
|
||||||
|
abandon_on_cancel=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@acm
|
||||||
|
async def open_bindspace(
|
||||||
|
spec: BindspaceSpec,
|
||||||
|
) -> AsyncIterator[BindspaceHandle]:
|
||||||
|
'''
|
||||||
|
Dispatch one declared bindspace lifecycle.
|
||||||
|
|
||||||
|
Lifecycle is explicit serialized policy. It is never inferred
|
||||||
|
from whether the eventual transport role is listen or dial.
|
||||||
|
|
||||||
|
'''
|
||||||
|
if spec.lifecycle == 'attach':
|
||||||
|
async with attach_netns(spec) as handle:
|
||||||
|
yield handle
|
||||||
|
else:
|
||||||
|
async with open_netns(spec) as handle:
|
||||||
|
yield handle
|
||||||
|
|
@ -69,7 +69,9 @@ Unwrap at the parse or bindspace boundary; see `.overlay` and
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
import base64
|
import base64
|
||||||
import ipaddress
|
import ipaddress
|
||||||
|
import sys
|
||||||
from typing import (
|
from typing import (
|
||||||
|
Any,
|
||||||
ClassVar,
|
ClassVar,
|
||||||
TYPE_CHECKING,
|
TYPE_CHECKING,
|
||||||
)
|
)
|
||||||
|
|
@ -79,6 +81,7 @@ import multibase
|
||||||
from multiaddr import Multiaddr
|
from multiaddr import Multiaddr
|
||||||
from multiaddr.exceptions import ProtocolNotFoundError
|
from multiaddr.exceptions import ProtocolNotFoundError
|
||||||
from multiaddr.protocols import protocol_with_name
|
from multiaddr.protocols import protocol_with_name
|
||||||
|
import trio
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from ._addr import (
|
from ._addr import (
|
||||||
|
|
@ -170,6 +173,183 @@ def wg8_pubkey(
|
||||||
return base64.b64encode(raw).decode('ascii')
|
return base64.b64encode(raw).decode('ascii')
|
||||||
|
|
||||||
|
|
||||||
|
def _wg8_key_str(
|
||||||
|
value: bytes|str,
|
||||||
|
) -> str:
|
||||||
|
'''
|
||||||
|
Validate and normalize one pyroute2-decoded WireGuard key.
|
||||||
|
|
||||||
|
'''
|
||||||
|
if isinstance(value, bytes):
|
||||||
|
try:
|
||||||
|
key: str = value.decode('ascii')
|
||||||
|
except UnicodeDecodeError as exc:
|
||||||
|
raise ValueError(
|
||||||
|
'WireGuard key is not base64 ASCII!'
|
||||||
|
) from exc
|
||||||
|
else:
|
||||||
|
key = value
|
||||||
|
|
||||||
|
# Reuse `mb_pubkey()`'s strict base64 + 32-byte validation.
|
||||||
|
mb_pubkey(key)
|
||||||
|
return key
|
||||||
|
|
||||||
|
|
||||||
|
def _sync_read_wg_keys(
|
||||||
|
iface: str,
|
||||||
|
netns: str|None,
|
||||||
|
) -> tuple[str, tuple[str, ...]]:
|
||||||
|
'''
|
||||||
|
Read one WireGuard device using pyroute2's synchronous API.
|
||||||
|
|
||||||
|
This whole function runs in a worker thread because pyroute2's
|
||||||
|
synchronous netlink API owns a private asyncio loop.
|
||||||
|
|
||||||
|
'''
|
||||||
|
if sys.platform != 'linux':
|
||||||
|
raise NotImplementedError(
|
||||||
|
'WireGuard netlink inspection is Linux-only!'
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from pyroute2 import WireGuard
|
||||||
|
except ImportError as exc:
|
||||||
|
raise RuntimeError(
|
||||||
|
'WireGuard inspection requires the `tractor[wg]` extra.'
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
# Pyroute2 defaults namespace flags to `os.O_CREAT`; a read must
|
||||||
|
# never create a missing namespace as a side effect.
|
||||||
|
wg: Any = WireGuard(
|
||||||
|
netns=netns,
|
||||||
|
flags=0,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
infos: tuple[Any, ...] = tuple(wg.info(iface))
|
||||||
|
finally:
|
||||||
|
wg.close()
|
||||||
|
|
||||||
|
pubkey: str|None = None
|
||||||
|
peers: list[str] = []
|
||||||
|
info: Any
|
||||||
|
for info in infos:
|
||||||
|
raw_pubkey: Any
|
||||||
|
if raw_pubkey := info.get_attr(
|
||||||
|
'WGDEVICE_A_PUBLIC_KEY'
|
||||||
|
):
|
||||||
|
next_pubkey: str = _wg8_key_str(raw_pubkey)
|
||||||
|
if (
|
||||||
|
pubkey is not None
|
||||||
|
and
|
||||||
|
pubkey != next_pubkey
|
||||||
|
):
|
||||||
|
raise RuntimeError(
|
||||||
|
f'Conflicting public keys returned for '
|
||||||
|
f'{iface!r}!'
|
||||||
|
)
|
||||||
|
pubkey = next_pubkey
|
||||||
|
|
||||||
|
peer: Any
|
||||||
|
for peer in (
|
||||||
|
info.get_attr('WGDEVICE_A_PEERS')
|
||||||
|
or ()
|
||||||
|
):
|
||||||
|
raw_peer: Any
|
||||||
|
if raw_peer := peer.get_attr(
|
||||||
|
'WGPEER_A_PUBLIC_KEY'
|
||||||
|
):
|
||||||
|
peers.append(_wg8_key_str(raw_peer))
|
||||||
|
|
||||||
|
if pubkey is None:
|
||||||
|
raise RuntimeError(
|
||||||
|
f'No public key returned for WireGuard iface '
|
||||||
|
f'{iface!r}!'
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
pubkey,
|
||||||
|
tuple(dict.fromkeys(peers)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _read_wg_keys(
|
||||||
|
iface: str,
|
||||||
|
netns: str|None,
|
||||||
|
) -> tuple[str, tuple[str, ...]]:
|
||||||
|
'''
|
||||||
|
Read one WireGuard key snapshot without blocking Trio.
|
||||||
|
|
||||||
|
'''
|
||||||
|
return await trio.to_thread.run_sync(
|
||||||
|
_sync_read_wg_keys,
|
||||||
|
iface,
|
||||||
|
netns,
|
||||||
|
abandon_on_cancel=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def read_wg_pubkey(
|
||||||
|
iface: str = 'wg0',
|
||||||
|
netns: str|None = None,
|
||||||
|
) -> str:
|
||||||
|
'''
|
||||||
|
Read a WireGuard interface's public key through netlink.
|
||||||
|
|
||||||
|
'''
|
||||||
|
keys: tuple[
|
||||||
|
str,
|
||||||
|
tuple[str, ...],
|
||||||
|
] = await _read_wg_keys(
|
||||||
|
iface,
|
||||||
|
netns,
|
||||||
|
)
|
||||||
|
return keys[0]
|
||||||
|
|
||||||
|
|
||||||
|
async def read_wg_peers(
|
||||||
|
iface: str = 'wg0',
|
||||||
|
netns: str|None = None,
|
||||||
|
) -> tuple[str, ...]:
|
||||||
|
'''
|
||||||
|
Read configured peer public keys through netlink.
|
||||||
|
|
||||||
|
'''
|
||||||
|
keys: tuple[
|
||||||
|
str,
|
||||||
|
tuple[str, ...],
|
||||||
|
] = await _read_wg_keys(
|
||||||
|
iface,
|
||||||
|
netns,
|
||||||
|
)
|
||||||
|
return keys[1]
|
||||||
|
|
||||||
|
|
||||||
|
async def verify_wg_peer(
|
||||||
|
spec: WGTunnelSpec,
|
||||||
|
) -> bool:
|
||||||
|
'''
|
||||||
|
Verify a declared WireGuard identity against local kernel state.
|
||||||
|
|
||||||
|
A source/listen maddr names the local interface key, while a
|
||||||
|
destination/dial maddr names one configured peer. Accept either
|
||||||
|
match without making verification an implicit part of parsing.
|
||||||
|
|
||||||
|
'''
|
||||||
|
declared_key: str = _wg8_key_str(spec.peer_pubkey)
|
||||||
|
keys: tuple[
|
||||||
|
str,
|
||||||
|
tuple[str, ...],
|
||||||
|
] = await _read_wg_keys(
|
||||||
|
spec.iface,
|
||||||
|
spec.netns,
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
declared_key == keys[0]
|
||||||
|
or
|
||||||
|
declared_key in keys[1]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _wg_proto_code() -> int:
|
def _wg_proto_code() -> int:
|
||||||
'''
|
'''
|
||||||
Deliver the installed `py-multiaddr` `/wg/` protocol code.
|
Deliver the installed `py-multiaddr` `/wg/` protocol code.
|
||||||
|
|
@ -246,20 +426,9 @@ class TunnelledAddress(
|
||||||
'''
|
'''
|
||||||
The tunnel's netns, when it declares one.
|
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:
|
if (netns := self.tunnel.netns) is None:
|
||||||
return getattr(self.overlay, 'namespace', None)
|
return self.overlay.namespace
|
||||||
|
|
||||||
return ('netns', netns)
|
return ('netns', netns)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -625,6 +625,7 @@ class Endpoint(Struct):
|
||||||
|
|
||||||
'''
|
'''
|
||||||
addr: Address
|
addr: Address
|
||||||
|
declared_addr: Address|TunnelledAddress
|
||||||
listen_tn: Nursery
|
listen_tn: Nursery
|
||||||
stream_handler_tn: Nursery|None = None
|
stream_handler_tn: Nursery|None = None
|
||||||
|
|
||||||
|
|
@ -639,15 +640,27 @@ class Endpoint(Struct):
|
||||||
MsgTransport, # handle to encoded-msg transport stream
|
MsgTransport, # handle to encoded-msg transport stream
|
||||||
] = {}
|
] = {}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def namespace(self) -> tuple[str, str|int]|None:
|
||||||
|
'''
|
||||||
|
Return the original address declaration's namespace.
|
||||||
|
|
||||||
|
`Endpoint.addr` is peeled to its concrete transport before
|
||||||
|
listener reflection, so `.declared_addr` retains bindspace
|
||||||
|
metadata for diagnostics and later provisioning.
|
||||||
|
|
||||||
|
'''
|
||||||
|
return self.declared_addr.namespace
|
||||||
|
|
||||||
def pformat(
|
def pformat(
|
||||||
self,
|
self,
|
||||||
indent: int = 0,
|
indent: int = 0,
|
||||||
privates: bool = False,
|
privates: bool = False,
|
||||||
) -> str:
|
) -> str:
|
||||||
type_repr: str = type(self).__name__
|
type_repr: str = type(self).__name__
|
||||||
|
namespace: tuple[str, str|int]|None = self.namespace
|
||||||
fmtstr: str = (
|
fmtstr: str = (
|
||||||
# !TODO, always be ns aware!
|
f' |.namespace: {namespace!r}\n'
|
||||||
# f'|_netns: {netns}\n'
|
|
||||||
f' |.addr: {self.addr!r}\n'
|
f' |.addr: {self.addr!r}\n'
|
||||||
f' |_peers: {len(self.peer_tpts)}\n'
|
f' |_peers: {len(self.peer_tpts)}\n'
|
||||||
)
|
)
|
||||||
|
|
@ -925,9 +938,17 @@ class Server(Struct):
|
||||||
ep.addr for ep in eps
|
ep.addr for ep in eps
|
||||||
]
|
]
|
||||||
repr_eps: str = ppfmt(addrs)
|
repr_eps: str = ppfmt(addrs)
|
||||||
|
namespaces: list[
|
||||||
|
tuple[str, str|int]|None
|
||||||
|
] = []
|
||||||
|
ep: Endpoint
|
||||||
|
for ep in eps:
|
||||||
|
namespaces.append(ep.namespace)
|
||||||
|
repr_namespaces: str = ppfmt(namespaces)
|
||||||
|
|
||||||
fmtstr += (
|
fmtstr += (
|
||||||
f' |_endpoints: {repr_eps}\n'
|
f' |_endpoints: {repr_eps}\n'
|
||||||
|
f' |_namespaces: {repr_namespaces}\n'
|
||||||
# ^TODO? how to indent closing ']'..
|
# ^TODO? how to indent closing ']'..
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -1080,6 +1101,7 @@ async def _serve_ipc_eps(
|
||||||
addr=addr,
|
addr=addr,
|
||||||
listen_tn=listen_tn,
|
listen_tn=listen_tn,
|
||||||
stream_handler_tn=stream_handler_tn,
|
stream_handler_tn=stream_handler_tn,
|
||||||
|
declared_addr=declared_addr,
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
ep_sclang: str = nest_from_op(
|
ep_sclang: str = nest_from_op(
|
||||||
|
|
|
||||||
|
|
@ -101,6 +101,14 @@ class TCPAddress(
|
||||||
def bindspace(self) -> str:
|
def bindspace(self) -> str:
|
||||||
return self._host
|
return self._host
|
||||||
|
|
||||||
|
@property
|
||||||
|
def namespace(self) -> None:
|
||||||
|
'''
|
||||||
|
Report that plain TCP uses the process's current namespace.
|
||||||
|
|
||||||
|
'''
|
||||||
|
return None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def domain(self) -> str:
|
def domain(self) -> str:
|
||||||
return self._host
|
return self._host
|
||||||
|
|
|
||||||
|
|
@ -131,6 +131,14 @@ class UDSAddress(
|
||||||
self.def_bindspace
|
self.def_bindspace
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def namespace(self) -> None:
|
||||||
|
'''
|
||||||
|
Report that plain UDS uses the process's current namespace.
|
||||||
|
|
||||||
|
'''
|
||||||
|
return None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def sockpath(self) -> Path:
|
def sockpath(self) -> Path:
|
||||||
return Path(self.bindspace) / self.filename
|
return Path(self.bindspace) / self.filename
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,9 @@ from .ptr import (
|
||||||
from .pretty_struct import (
|
from .pretty_struct import (
|
||||||
Struct as Struct,
|
Struct as Struct,
|
||||||
)
|
)
|
||||||
|
from ._local import (
|
||||||
|
ProcessLocal as ProcessLocal,
|
||||||
|
)
|
||||||
from ._codec import (
|
from ._codec import (
|
||||||
_def_msgspec_codec as _def_msgspec_codec,
|
_def_msgspec_codec as _def_msgspec_codec,
|
||||||
_ctxvar_MsgCodec as _ctxvar_MsgCodec,
|
_ctxvar_MsgCodec as _ctxvar_MsgCodec,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,54 @@
|
||||||
|
# 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/>.
|
||||||
|
'''
|
||||||
|
Markers for process-local values which must not cross actor IPC.
|
||||||
|
|
||||||
|
'''
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import msgspec
|
||||||
|
|
||||||
|
|
||||||
|
class _ProcessLocalToken:
|
||||||
|
'''
|
||||||
|
Unsupported msgspec value embedded in every `ProcessLocal`.
|
||||||
|
|
||||||
|
'''
|
||||||
|
__slots__ = ()
|
||||||
|
|
||||||
|
|
||||||
|
_PROCESS_LOCAL_TOKEN: _ProcessLocalToken = _ProcessLocalToken()
|
||||||
|
|
||||||
|
|
||||||
|
class ProcessLocal(
|
||||||
|
msgspec.Struct,
|
||||||
|
kw_only=True,
|
||||||
|
repr_omit_defaults=True,
|
||||||
|
):
|
||||||
|
'''
|
||||||
|
Generic struct marker which rejects default msgspec encoding.
|
||||||
|
|
||||||
|
The hidden sentinel remains part of the encoded field set, so
|
||||||
|
msgspec encounters `_ProcessLocalToken` and raises `TypeError`
|
||||||
|
even when this value is nested inside another supported payload.
|
||||||
|
A custom encode hook may explicitly override that safeguard.
|
||||||
|
|
||||||
|
Keyword-only fields let subclasses add required fields after the
|
||||||
|
marker's default sentinel.
|
||||||
|
|
||||||
|
'''
|
||||||
|
_process_local: _ProcessLocalToken = _PROCESS_LOCAL_TOKEN
|
||||||
16
uv.lock
16
uv.lock
|
|
@ -762,6 +762,15 @@ wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/5a/dc/491b7661614ab97483abf2056be1deee4dc2490ecbf7bff9ab5cdbac86e1/pyreadline3-3.5.4-py3-none-any.whl", hash = "sha256:eaf8e6cc3c49bcccf145fc6067ba8643d1df34d604a1ec0eccbf7a18e6d3fae6", size = 83178, upload-time = "2024-09-19T02:40:08.598Z" },
|
{ url = "https://files.pythonhosted.org/packages/5a/dc/491b7661614ab97483abf2056be1deee4dc2490ecbf7bff9ab5cdbac86e1/pyreadline3-3.5.4-py3-none-any.whl", hash = "sha256:eaf8e6cc3c49bcccf145fc6067ba8643d1df34d604a1ec0eccbf7a18e6d3fae6", size = 83178, upload-time = "2024-09-19T02:40:08.598Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pyroute2"
|
||||||
|
version = "0.9.6"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/9b/3c/cae3aa8a07522d4fd625958f690ab6eb4ffbd9c94e30e2995f585fded630/pyroute2-0.9.6.tar.gz", hash = "sha256:6bc5e2ea9a372ded682b4ede4028ba00236bd6e35b42d833f39a96b219ef1db2", size = 478486, upload-time = "2026-04-15T18:26:07.408Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/14/f5/77292e847cb2bcd94f0e7be214ad09972de5db6a6914e47117293ed0f4a8/pyroute2-0.9.6-py3-none-any.whl", hash = "sha256:3334091326e560a506635449af03b26920d22d4e5a7996aed354363d106fcef8", size = 483440, upload-time = "2026-04-15T18:26:03.14Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pytest"
|
name = "pytest"
|
||||||
version = "9.1.0"
|
version = "9.1.0"
|
||||||
|
|
@ -1120,6 +1129,11 @@ dependencies = [
|
||||||
{ name = "wrapt" },
|
{ name = "wrapt" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[package.optional-dependencies]
|
||||||
|
wg = [
|
||||||
|
{ name = "pyroute2", marker = "sys_platform == 'linux'" },
|
||||||
|
]
|
||||||
|
|
||||||
[package.dev-dependencies]
|
[package.dev-dependencies]
|
||||||
dev = [
|
dev = [
|
||||||
{ name = "greenback", marker = "python_full_version < '3.14'" },
|
{ name = "greenback", marker = "python_full_version < '3.14'" },
|
||||||
|
|
@ -1179,11 +1193,13 @@ requires-dist = [
|
||||||
{ 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 = "py-multibase", specifier = ">=2.0.0,<3" },
|
||||||
|
{ name = "pyroute2", marker = "sys_platform == 'linux' and extra == 'wg'", specifier = ">=0.9.6,<0.10" },
|
||||||
{ 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" },
|
||||||
{ name = "wrapt", specifier = ">=1.16.0,<2" },
|
{ name = "wrapt", specifier = ">=1.16.0,<2" },
|
||||||
]
|
]
|
||||||
|
provides-extras = ["wg"]
|
||||||
|
|
||||||
[package.metadata.requires-dev]
|
[package.metadata.requires-dev]
|
||||||
dev = [
|
dev = [
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue