diff --git a/ai/prompt-io/opencode/20260821T233204Z_5d92595f_prompt_io.md b/ai/prompt-io/opencode/20260821T233204Z_5d92595f_prompt_io.md new file mode 100644 index 000000000..506927833 --- /dev/null +++ b/ai/prompt-io/opencode/20260821T233204Z_5d92595f_prompt_io.md @@ -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. diff --git a/ai/prompt-io/opencode/20260821T233204Z_5d92595f_prompt_io.raw.md b/ai/prompt-io/opencode/20260821T233204Z_5d92595f_prompt_io.raw.md new file mode 100644 index 000000000..1bb8b6422 --- /dev/null +++ b/ai/prompt-io/opencode/20260821T233204Z_5d92595f_prompt_io.raw.md @@ -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. diff --git a/ai/prompt-io/opencode/20260822T023226Z_59a8ecfd_prompt_io.md b/ai/prompt-io/opencode/20260822T023226Z_59a8ecfd_prompt_io.md new file mode 100644 index 000000000..91973f796 --- /dev/null +++ b/ai/prompt-io/opencode/20260822T023226Z_59a8ecfd_prompt_io.md @@ -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. diff --git a/ai/prompt-io/opencode/20260822T023226Z_59a8ecfd_prompt_io.raw.md b/ai/prompt-io/opencode/20260822T023226Z_59a8ecfd_prompt_io.raw.md new file mode 100644 index 000000000..1b086c10c --- /dev/null +++ b/ai/prompt-io/opencode/20260822T023226Z_59a8ecfd_prompt_io.raw.md @@ -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. diff --git a/ai/prompt-io/opencode/20260822T032520Z_d35c802b_prompt_io.md b/ai/prompt-io/opencode/20260822T032520Z_d35c802b_prompt_io.md new file mode 100644 index 000000000..aef0a7d75 --- /dev/null +++ b/ai/prompt-io/opencode/20260822T032520Z_d35c802b_prompt_io.md @@ -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. diff --git a/ai/prompt-io/opencode/20260822T032520Z_d35c802b_prompt_io.raw.md b/ai/prompt-io/opencode/20260822T032520Z_d35c802b_prompt_io.raw.md new file mode 100644 index 000000000..30cdc291f --- /dev/null +++ b/ai/prompt-io/opencode/20260822T032520Z_d35c802b_prompt_io.raw.md @@ -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. diff --git a/ai/prompt-io/opencode/20260822T042026Z_29141f0b_prompt_io.md b/ai/prompt-io/opencode/20260822T042026Z_29141f0b_prompt_io.md new file mode 100644 index 000000000..0148f31d6 --- /dev/null +++ b/ai/prompt-io/opencode/20260822T042026Z_29141f0b_prompt_io.md @@ -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. diff --git a/ai/prompt-io/opencode/20260822T042026Z_29141f0b_prompt_io.raw.md b/ai/prompt-io/opencode/20260822T042026Z_29141f0b_prompt_io.raw.md new file mode 100644 index 000000000..8fb196f40 --- /dev/null +++ b/ai/prompt-io/opencode/20260822T042026Z_29141f0b_prompt_io.raw.md @@ -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. diff --git a/ai/prompt-io/opencode/20260825T045557Z_fdccfd7e_prompt_io.md b/ai/prompt-io/opencode/20260825T045557Z_fdccfd7e_prompt_io.md new file mode 100644 index 000000000..827e82f53 --- /dev/null +++ b/ai/prompt-io/opencode/20260825T045557Z_fdccfd7e_prompt_io.md @@ -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. diff --git a/ai/prompt-io/opencode/20260825T045557Z_fdccfd7e_prompt_io.raw.md b/ai/prompt-io/opencode/20260825T045557Z_fdccfd7e_prompt_io.raw.md new file mode 100644 index 000000000..bf4b032b3 --- /dev/null +++ b/ai/prompt-io/opencode/20260825T045557Z_fdccfd7e_prompt_io.raw.md @@ -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. diff --git a/ai/prompt-io/opencode/20260825T190529Z_e1007547_prompt_io.md b/ai/prompt-io/opencode/20260825T190529Z_e1007547_prompt_io.md new file mode 100644 index 000000000..4e20eed85 --- /dev/null +++ b/ai/prompt-io/opencode/20260825T190529Z_e1007547_prompt_io.md @@ -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. diff --git a/ai/prompt-io/opencode/20260825T190529Z_e1007547_prompt_io.raw.md b/ai/prompt-io/opencode/20260825T190529Z_e1007547_prompt_io.raw.md new file mode 100644 index 000000000..faf5ab2b5 --- /dev/null +++ b/ai/prompt-io/opencode/20260825T190529Z_e1007547_prompt_io.raw.md @@ -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. diff --git a/ai/prompt-io/opencode/20260825T191845Z_5b2a064a_prompt_io.md b/ai/prompt-io/opencode/20260825T191845Z_5b2a064a_prompt_io.md new file mode 100644 index 000000000..98fc2d0ea --- /dev/null +++ b/ai/prompt-io/opencode/20260825T191845Z_5b2a064a_prompt_io.md @@ -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. diff --git a/ai/prompt-io/opencode/20260825T191845Z_5b2a064a_prompt_io.raw.md b/ai/prompt-io/opencode/20260825T191845Z_5b2a064a_prompt_io.raw.md new file mode 100644 index 000000000..045147cb7 --- /dev/null +++ b/ai/prompt-io/opencode/20260825T191845Z_5b2a064a_prompt_io.raw.md @@ -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. diff --git a/ai/prompt-io/opencode/20260825T234631Z_b973e78c_prompt_io.md b/ai/prompt-io/opencode/20260825T234631Z_b973e78c_prompt_io.md new file mode 100644 index 000000000..54729534d --- /dev/null +++ b/ai/prompt-io/opencode/20260825T234631Z_b973e78c_prompt_io.md @@ -0,0 +1,42 @@ +--- +model: gpt-5.6-sol +service: opencode +session: tractor-addr-unpacking +timestamp: 2026-08-25T23:46:31Z +git_ref: b973e78c +scope: code +substantive: true +raw_file: 20260825T234631Z_b973e78c_prompt_io.raw.md +--- + +## Prompt + +The human pointed out that the agent had again produced no worktree +changes after the latest commit and expected actual next-layer work. + +## Response summary + +Corrected the sequencing mistake and added a process-local WireGuard +interface configuration boundary. Secret/routing policy is separated +from serializable maddr-derived tunnel declarations, redacted from +repr and blocked from default wire encoding. +Staged review clarified that address/network parser calls validate +every declaration and do not select or consume a first address. + +## Files changed + +- `tractor/discovery/_tunnel.py` - local WG config and validation. +- `tractor/discovery/__init__.py` - public config export. +- `tests/discovery/test_wg_config.py` - secrecy and validation coverage. +- `ai/tpt-backends/03_wg_tunnel_bindspace.md` - secret/config boundary + for future interface provisioning. + +## Human edits + +The human identified the second sequencing failure where the agent +summarized an already committed slice instead of implementing the next +one. That correction directly caused this new WG configuration layer +to be implemented. During staged review, the human requested explicit +explanation of the address and allowed-route validation loops. The +agent applied that human-directed clarification; no direct manual edits +were observed. diff --git a/ai/prompt-io/opencode/20260825T234631Z_b973e78c_prompt_io.raw.md b/ai/prompt-io/opencode/20260825T234631Z_b973e78c_prompt_io.raw.md new file mode 100644 index 000000000..69b040d16 --- /dev/null +++ b/ai/prompt-io/opencode/20260825T234631Z_b973e78c_prompt_io.raw.md @@ -0,0 +1,28 @@ +--- +model: gpt-5.6-sol +service: opencode +timestamp: 2026-08-25T23:46:31Z +git_ref: b973e78c +diff_cmd: git diff HEAD~1..HEAD +--- + +# Raw output - separate local WireGuard configuration + +The human again noticed the agent had re-reported already committed +work without changing the worktree. The agent confirmed the sequencing +mistake and moved to the next unimplemented dependency. + +> `git diff HEAD~1..HEAD -- tractor/discovery/_tunnel.py tractor/discovery/__init__.py tests/discovery/test_wg_config.py ai/tpt-backends/03_wg_tunnel_bindspace.md` + +Added process-local `WGInterfaceConfig` for private/preshared keys, +local interface CIDRs, peer allowed CIDRs, listen port and persistent +keepalive. Secrets are redacted from repr and the global +`ProcessLocal` marker blocks default wire encoding. + +Removed the unused serialized `WGTunnelSpec.maybe_allowed_ips` +placeholder so maddr-derived declarations retain only public identity, +endpoint and interface selection. Added validation for keys, CIDRs, +ports and keepalive before future kernel mutation. + +Ruff and lock checks passed. Focused WG coverage passed 25 tests; +discovery plus message coverage passed 145 tests with 2 xpasses. diff --git a/ai/prompt-io/opencode/20260826T001442Z_dcdf4d82_prompt_io.md b/ai/prompt-io/opencode/20260826T001442Z_dcdf4d82_prompt_io.md new file mode 100644 index 000000000..5ccc3b7e5 --- /dev/null +++ b/ai/prompt-io/opencode/20260826T001442Z_dcdf4d82_prompt_io.md @@ -0,0 +1,37 @@ +--- +model: gpt-5.6-sol +service: opencode +session: tractor-addr-unpacking +timestamp: 2026-08-26T00:14:42Z +git_ref: dcdf4d82 +scope: code +substantive: true +raw_file: 20260826T001442Z_dcdf4d82_prompt_io.raw.md +--- + +## Prompt + +The human selected an explicit peer-list model rather than a +dial-only single-peer shortcut before `open_wg_iface()`. + +## Response summary + +Added process-local per-peer configuration and refactored interface +configuration to own a unique peer tuple. This supports multi-peer +listeners and dial targets without overloading role-dependent tunnel +maddr identity. + +## Files changed + +- `tractor/discovery/_tunnel.py` - peer model and interface peer list. +- `tractor/discovery/__init__.py` - public peer-config export. +- `tests/discovery/test_wg_config.py` - peer secrecy and validation. +- `ai/tpt-backends/03_wg_tunnel_bindspace.md` - explicit multi-peer + provisioning contract. + +## Human edits + +The human chose explicit peer lists over a dial-only implementation so +listeners can represent multiple client public keys and routing +policies. The agent implemented that human-selected design; no direct +manual source edits were observed. diff --git a/ai/prompt-io/opencode/20260826T001442Z_dcdf4d82_prompt_io.raw.md b/ai/prompt-io/opencode/20260826T001442Z_dcdf4d82_prompt_io.raw.md new file mode 100644 index 000000000..5248df50d --- /dev/null +++ b/ai/prompt-io/opencode/20260826T001442Z_dcdf4d82_prompt_io.raw.md @@ -0,0 +1,27 @@ +--- +model: gpt-5.6-sol +service: opencode +timestamp: 2026-08-26T00:14:42Z +git_ref: dcdf4d82 +diff_cmd: git diff HEAD~1..HEAD +--- + +# Raw output - model explicit WireGuard peers + +After committing local WG interface configuration, the human selected +an explicit peer-list model before interface provisioning. + +> `git diff HEAD~1..HEAD -- tractor/discovery/_tunnel.py tractor/discovery/__init__.py tests/discovery/test_wg_config.py ai/tpt-backends/03_wg_tunnel_bindspace.md` + +Added process-local `WGPeerConfig` with public key, allowed CIDRs, +optional endpoint, preshared key and keepalive. Refactored +`WGInterfaceConfig` to own a tuple of unique peers beside its private +key, local addresses and listen port. + +Peer PSKs remain redacted and nested `ProcessLocal` sentinels prevent +default wire encoding. Validation covers keys, routes, endpoints, +ports, keepalive and duplicate peers. + +Ruff and lock checks passed. Focused peer/config coverage passed 11 +tests. A broad unrelated registrar cancellation case timed out after +86 passes and 2 xpasses; its isolated rerun passed. diff --git a/ai/prompt-io/opencode/20260826T003430Z_6dd39da0_prompt_io.md b/ai/prompt-io/opencode/20260826T003430Z_6dd39da0_prompt_io.md new file mode 100644 index 000000000..0c2aeea4d --- /dev/null +++ b/ai/prompt-io/opencode/20260826T003430Z_6dd39da0_prompt_io.md @@ -0,0 +1,42 @@ +--- +model: gpt-5.6-sol +service: opencode +session: tractor-addr-unpacking +timestamp: 2026-08-26T00:34:30Z +git_ref: 6dd39da0 +scope: code +substantive: true +raw_file: 20260826T003430Z_6dd39da0_prompt_io.raw.md +--- + +## Prompt + +After committing explicit WireGuard peer configuration, the human +authorized the next incremental lifecycle change. + +## Response summary + +Added owned WireGuard interface provisioning and teardown through +cancellation-shielded pyroute2 worker calls. Role-specific policy is +validated before mutation, local addresses and explicit peers are +configured, and owned interfaces are removed on exit. +Staged review clarified role-specific test assertions, added the +second peer's endpoint-port check, included conflicting values in +policy errors and aligned annotated assignment with `/py-codestyle`. + +## Files changed + +- `tractor/discovery/_tunnel.py` - role policy and WG iface lifecycle. +- `tractor/discovery/__init__.py` - public lifecycle and role exports. +- `tests/discovery/test_wg_iface_lifecycle.py` - policy and cancellation + coverage with privileged calls faked. +- `ai/tpt-backends/03_wg_tunnel_bindspace.md` - initial owned WG iface + lifecycle contract. + +## Human edits + +The human selected continuation from explicit peer modeling into the +owned WireGuard interface lifecycle. During staged review, the human +requested clearer role assertions, complete endpoint checks, concrete +error values and inline annotated assignment. The agent applied those +human-directed edits; no direct manual source edits were observed. diff --git a/ai/prompt-io/opencode/20260826T003430Z_6dd39da0_prompt_io.raw.md b/ai/prompt-io/opencode/20260826T003430Z_6dd39da0_prompt_io.raw.md new file mode 100644 index 000000000..126646d03 --- /dev/null +++ b/ai/prompt-io/opencode/20260826T003430Z_6dd39da0_prompt_io.raw.md @@ -0,0 +1,29 @@ +--- +model: gpt-5.6-sol +service: opencode +timestamp: 2026-08-26T00:34:30Z +git_ref: 6dd39da0 +diff_cmd: git diff HEAD~1..HEAD +--- + +# Raw output - own WireGuard interface lifecycle + +After committing explicit peer configuration, the human authorized the +next incremental lifecycle change. + +> `git diff HEAD~1..HEAD -- tractor/discovery/_tunnel.py tractor/discovery/__init__.py tests/discovery/test_wg_iface_lifecycle.py ai/tpt-backends/03_wg_tunnel_bindspace.md` + +Added owned async `open_wg_iface()` lifecycle with role-policy +validation before side effects. Listen bearer ports configure the local +listener; dial bearers fill omitted endpoints only for the selected +maddr peer. + +Pyroute2 worker code creates the WireGuard link, assigns local +addresses, configures private key/listen port and every explicit peer, +raises the link, and removes it on exit. Partial create failures clean +up synchronously; async create/remove calls are cancellation-shielded. + +Privileged operations are faked in tests. Ruff and lock checks passed; +focused WG lifecycle/config/inspection coverage passed 22 tests. A +broad unrelated registrar cancellation parameter timed out after 76 +passes and 2 xpasses; its isolated rerun passed. diff --git a/ai/prompt-io/opencode/20260826T022434Z_2245f094_prompt_io.md b/ai/prompt-io/opencode/20260826T022434Z_2245f094_prompt_io.md new file mode 100644 index 000000000..3f5962fec --- /dev/null +++ b/ai/prompt-io/opencode/20260826T022434Z_2245f094_prompt_io.md @@ -0,0 +1,47 @@ +--- +model: gpt-5.6-sol +service: opencode +session: tractor-addr-unpacking +timestamp: 2026-08-26T02:24:34Z +git_ref: 2245f094 +scope: code +substantive: true +raw_file: 20260826T022434Z_2245f094_prompt_io.raw.md +--- + +## Prompt + +After committing the owned WireGuard interface lifecycle, the human +asked to continue with the next established incremental step. + +## Response summary + +Added a WG-specific composition context which owns one declared +bindspace and an ordered interface stack. It snapshots caller-owned +layer order before checkpointing, enters interfaces outermost-first, +and guarantees inside-out interface teardown before bindspace release. +Its inbound type shows the `(WGTunnelSpec, WGInterfaceConfig)` pairing +directly, and its docstring depicts the stack and unwind order. + +## Files changed + +- `tractor/discovery/_tunnel.py` - bindspace/interface stack driver. +- `tractor/discovery/__init__.py` - public layer and driver exports. +- `tests/discovery/test_wg_iface_lifecycle.py` - mutable-input, + cancellation and nested teardown-order coverage. +- `ai/tpt-backends/03_wg_tunnel_bindspace.md` - implemented composition + and ownership-order contract. + +## Human edits + +The human accepted and committed the preceding reviewed lifecycle +boundary, then explicitly requested the next step. That selected the +previously established bindspace/WireGuard composition boundary. In +staged review, the human requested an explicit interface-stack analogy +and a small ASCII diagram explaining entry and exit order. The human +then identified `WGInterfaceLayer` as an unnecessary single-use alias +which obscured the inbound tuple shape. The agent applied both +human-directed edits. The human accepted the fake lifecycle test as a +focused control-flow check which avoids pyroute2 overhead, while noting +their general preference for end-to-end coverage. No direct manual +source edits were observed. diff --git a/ai/prompt-io/opencode/20260826T022434Z_2245f094_prompt_io.raw.md b/ai/prompt-io/opencode/20260826T022434Z_2245f094_prompt_io.raw.md new file mode 100644 index 000000000..34a25f271 --- /dev/null +++ b/ai/prompt-io/opencode/20260826T022434Z_2245f094_prompt_io.raw.md @@ -0,0 +1,28 @@ +--- +model: gpt-5.6-sol +service: opencode +timestamp: 2026-08-26T02:24:34Z +git_ref: 2245f094 +diff_cmd: git diff HEAD~1..HEAD +--- + +# Raw output - compose WireGuard bindspace lifecycles + +After committing the owned WireGuard interface lifecycle, the human +requested the next incremental change. + +> `git diff HEAD~1..HEAD -- tractor/discovery/_tunnel.py tractor/discovery/__init__.py tests/discovery/test_wg_iface_lifecycle.py ai/tpt-backends/03_wg_tunnel_bindspace.md` + +Added `open_wg_bindspace()` to compose one declared bindspace with an +ordered sequence of WireGuard interface layers. The context enters the +bindspace first, enters interfaces outermost-first through +`AsyncExitStack`, yields the live `BindspaceHandle`, and removes +interfaces inside-out before releasing or removing the namespace. + +The caller's layer sequence is snapshotted before the first checkpoint, +so concurrent mutation cannot alter resource entry or unwind order. +Fake lifecycle tests clear a mutable input list during bindspace entry, +cancel from the yielded scope, and prove the original stack unwinds in +dependency-safe order. + +Ruff passed and focused bindspace/WireGuard coverage passed 39 tests. diff --git a/ai/prompt-io/opencode/20260826T030534Z_d130431c_prompt_io.md b/ai/prompt-io/opencode/20260826T030534Z_d130431c_prompt_io.md new file mode 100644 index 000000000..42465427b --- /dev/null +++ b/ai/prompt-io/opencode/20260826T030534Z_d130431c_prompt_io.md @@ -0,0 +1,54 @@ +--- +model: gpt-5.6-sol +service: opencode +session: tractor-addr-unpacking +timestamp: 2026-08-26T03:05:34Z +git_ref: d130431c +scope: code +substantive: true +raw_file: 20260826T030534Z_d130431c_prompt_io.raw.md +--- + +## Prompt + +After committing the reviewed bindspace/interface stack, the human +asked to continue with the next established Layer C step. + +## Response summary + +Retained a serializable realized `BindspaceRef` on frozen tunnelled +address declarations without carrying the process-local `Bindspace` +capability. The namespace API remains tuple-shaped, using the declared +key before realization and stable inode afterward. Existing transport +peeling and unrealized serialization remain unchanged. + +## Files changed + +- `tractor/discovery/_tunnel.py` - realized bindspace-ref retention and + immutable annotation method. +- `tests/discovery/test_tunnelled_addr.py` - ref, compatibility and + mismatch coverage. +- `tests/ipc/test_server_tunnel_boundary.py` - real listener diagnostic + coverage for the realized inode. +- `ai/tpt-backends/03_wg_tunnel_bindspace.md` - realized namespace + visibility contract. + +## Human edits + +The human accepted and committed the preceding reviewed composition +boundary, then requested the next step. Their stated preference for +end-to-end tests informed use of the existing real TCP listener path to +verify endpoint/server diagnostics, while focused unit coverage handles +metadata invariants without pyroute2 overhead. During review, the human +chose the final unshipped terminology: `BindspaceRef` for the +serializable non-owning reference, `Bindspace` for the live +process-local capability, `.ref` for that capability's reference, and +`bindspace_ref` at the tunnel declaration API. +They then requested the prerequisite model rename as a separate commit +before this feature. The human also replaced the module-level helper +with `TunnelledAddress.with_bindspace_ref()` and requested inline +msgspec encode/decode expressions in the serialization assertions. The +human chose not to add a second typed `.namespace` projection, and +requested native tagged `TunnelledAddress` decoding remain as a separate +design-plan follow-up. The agent applied those human-directed changes; +no direct manual source edits were observed. diff --git a/ai/prompt-io/opencode/20260826T030534Z_d130431c_prompt_io.raw.md b/ai/prompt-io/opencode/20260826T030534Z_d130431c_prompt_io.raw.md new file mode 100644 index 000000000..8e3805e41 --- /dev/null +++ b/ai/prompt-io/opencode/20260826T030534Z_d130431c_prompt_io.raw.md @@ -0,0 +1,31 @@ +--- +model: gpt-5.6-sol +service: opencode +timestamp: 2026-08-26T03:05:34Z +git_ref: d130431c +diff_cmd: git diff HEAD~1..HEAD +--- + +# Raw output - retain realized bindspace identity + +After committing WireGuard bindspace composition, the human requested +the next incremental Layer C change. + +> `git diff HEAD~1..HEAD -- tractor/discovery/_tunnel.py tractor/discovery/__init__.py tests/discovery/test_tunnelled_addr.py tests/ipc/test_server_tunnel_boundary.py ai/tpt-backends/03_wg_tunnel_bindspace.md` + +Added optional `BindspaceIdentity` metadata to frozen +`TunnelledAddress` declarations and a pure +`with_bindspace_identity()` annotation helper. Unrealized declarations +retain their prior serialized shape. Realized declarations retain only +serializable key/inode identity, never the FD-bearing capability. + +The existing `.namespace` tuple contract remains compatible: +unrealized declarations report `(kind, key)`, while realized +declarations report the stable `(kind, inode)`. Name mismatches between +the tunnel declaration and realized bindspace are rejected. + +Unit coverage verifies immutability, serialization, delegation and +mismatch handling. The existing real TCP listener test proves endpoint +and server diagnostics expose the retained inode without implying that +the process entered that namespace. Ruff passed and focused tunnel, +listener and bindspace coverage passed 30 tests. diff --git a/ai/prompt-io/opencode/20260827T211115Z_d130431c_prompt_io.md b/ai/prompt-io/opencode/20260827T211115Z_d130431c_prompt_io.md new file mode 100644 index 000000000..a09b808c4 --- /dev/null +++ b/ai/prompt-io/opencode/20260827T211115Z_d130431c_prompt_io.md @@ -0,0 +1,43 @@ +--- +model: gpt-5.6-sol +service: opencode +session: tractor-addr-unpacking +timestamp: 2026-08-27T21:11:15Z +git_ref: d130431c +scope: code +substantive: true +raw_file: 20260827T211115Z_d130431c_prompt_io.raw.md +--- + +## Prompt + +During review, the human requested concrete names for the existing +bindspace abstractions and asked to isolate that rename before realized +reference retention. + +## Response summary + +Renamed the serializable resource record to `BindspaceRef`, the live +FD-backed capability to `Bindspace`, and the capability's record field +to `.ref`. Updated existing lifecycle APIs, tests and active design +contracts without compatibility aliases. + +## Files changed + +- `tractor/discovery/_bindspace.py` - renamed models and `.ref` field. +- `tractor/discovery/_tunnel.py` - existing lifecycle annotations. +- `tractor/discovery/__init__.py` - renamed public model exports. +- `tests/discovery/test_bindspace.py` - renamed model contracts. +- `tests/discovery/test_wg_iface_lifecycle.py` - live bindspace usage. +- `ai/tpt-backends/00_shared_backend_contract.md` - shared terminology. +- `ai/tpt-backends/03_wg_tunnel_bindspace.md` - request, ref and live + capability terminology. + +## Human edits + +The human identified the earlier identity/handle terminology as too +abstract for an IPC-passed non-owning reference and live resource. They +selected `BindspaceRef`, `Bindspace`, and `.ref`, then requested this +rename as a prerequisite commit separate from the realized-ref feature. +The agent applied those human-directed names; no direct manual source +edits were observed. diff --git a/ai/prompt-io/opencode/20260827T211115Z_d130431c_prompt_io.raw.md b/ai/prompt-io/opencode/20260827T211115Z_d130431c_prompt_io.raw.md new file mode 100644 index 000000000..1a0bc4bdd --- /dev/null +++ b/ai/prompt-io/opencode/20260827T211115Z_d130431c_prompt_io.raw.md @@ -0,0 +1,26 @@ +--- +model: gpt-5.6-sol +service: opencode +timestamp: 2026-08-27T21:11:15Z +git_ref: d130431c +diff_cmd: git diff HEAD~1..HEAD +--- + +# Raw output - simplify bindspace model names + +While reviewing realized bindspace metadata, the human requested more +concrete names for the existing bindspace model family and asked that +the rename remain separate from the subsequent feature. + +> `git diff HEAD~1..HEAD -- tractor/discovery/_bindspace.py tractor/discovery/_tunnel.py tractor/discovery/__init__.py tests/discovery/test_bindspace.py tests/discovery/test_wg_iface_lifecycle.py ai/tpt-backends/00_shared_backend_contract.md ai/tpt-backends/03_wg_tunnel_bindspace.md` + +Renamed `BindspaceIdentity` to `BindspaceRef` for the serializable, +non-owning, host-local resource record. Renamed `BindspaceHandle` to +`Bindspace` for the live process-local FD-backed capability, and renamed +its `.identity` field to `.ref`. + +No compatibility aliases were added because the models are unshipped. +Active design contracts now distinguish the serialized request, the +wire-safe non-owning ref, and the live capability. Ruff passed, 518 +tests collected, and 53 focused bindspace/WireGuard/listener tests +passed across the final two-commit tree. diff --git a/ai/prompt-io/opencode/20260827T232500Z_d067505a_prompt_io.md b/ai/prompt-io/opencode/20260827T232500Z_d067505a_prompt_io.md new file mode 100644 index 000000000..ba94cf2c0 --- /dev/null +++ b/ai/prompt-io/opencode/20260827T232500Z_d067505a_prompt_io.md @@ -0,0 +1,41 @@ +--- +model: gpt-5.6-sol +service: opencode +session: tractor-addr-unpacking +timestamp: 2026-08-27T23:25:00Z +git_ref: d067505a +scope: code +substantive: true +raw_file: 20260827T232500Z_d067505a_prompt_io.raw.md +--- + +## Prompt + +After committing the reviewed bindspace-reference changes, the human +asked to continue with the next Layer C increment. + +## Response summary + +Added the private Linux network-namespace entry primitive required by +future child bootstrap wiring. It verifies the inherited capability +before `setns()`, constrains entry to network namespaces, and verifies +the resulting process namespace before runtime initialization proceeds. + +## Files changed + +- `tractor/spawn/_netns.py` - synchronous verified netns entry. +- `tests/test_netns_spawn.py` - stale-FD, syscall and post-entry guards. +- `ai/tpt-backends/03_wg_tunnel_bindspace.md` - primitive ownership and + remaining integration boundary. + +## Human edits + +The human requested the next incremental implementation after accepting +the prior split commits. Their established preference for end-to-end +tests informed use of real FDs where possible; only privileged `setns()` +and procfs observation are replaced to keep the suite unprivileged. In +staged review, the human requested the full project license header, +explicit comments on both intentional inode mismatches, and independent +call traces proving both fake syscall and procfs hooks execute. The +agent applied those human-directed revisions; no direct manual source +edits were observed. diff --git a/ai/prompt-io/opencode/20260827T232500Z_d067505a_prompt_io.raw.md b/ai/prompt-io/opencode/20260827T232500Z_d067505a_prompt_io.raw.md new file mode 100644 index 000000000..4b03038ec --- /dev/null +++ b/ai/prompt-io/opencode/20260827T232500Z_d067505a_prompt_io.raw.md @@ -0,0 +1,25 @@ +--- +model: gpt-5.6-sol +service: opencode +timestamp: 2026-08-27T23:25:00Z +git_ref: d067505a +diff_cmd: git diff HEAD~1..HEAD +--- + +# Raw output - verify pre-runtime netns entry + +After committing realized bindspace references and their deferred codec +plan, the human requested the next Layer C increment. + +> `git diff HEAD~1..HEAD -- tractor/spawn/_netns.py tests/test_netns_spawn.py ai/tpt-backends/03_wg_tunnel_bindspace.md` + +Added a private synchronous `enter_netns()` bootstrap primitive. It +validates the inherited FD and expected inode, constrains `setns()` to +`CLONE_NEWNET`, verifies `/proc/self/ns/net` afterward, and leaves FD +closure to the future spawn-bootstrap caller. + +Tests use real stand-in FDs while replacing only the privileged syscall +and post-entry procfs observation. They prove stale FDs fail before +entry, the syscall receives the exact namespace type, and bootstrap +rejects an unexpected post-entry namespace. Ruff and all three focused +tests passed. diff --git a/ai/prompt-io/opencode/20260828T012205Z_9ae7cd86_prompt_io.md b/ai/prompt-io/opencode/20260828T012205Z_9ae7cd86_prompt_io.md new file mode 100644 index 000000000..f5be10c4a --- /dev/null +++ b/ai/prompt-io/opencode/20260828T012205Z_9ae7cd86_prompt_io.md @@ -0,0 +1,38 @@ +--- +model: gpt-5.6-sol +service: opencode +session: wg-netns-bootstrap-entry +timestamp: 2026-08-28T01:22:05Z +git_ref: 9ae7cd86 +scope: code +substantive: true +raw_file: 20260828T012205Z_9ae7cd86_prompt_io.raw.md +--- + +## Prompt + +After completing and auditing the stacked rebase, the human approved +the next WireGuard Layer C slice: integrate the verified network +namespace entry primitive into child spawn bootstrap. + +## Response summary + +Wire an optional child-owned namespace FD and expected inode into both +child entrypoints. Keep namespace entry and FD closure synchronous and +ordered before actor runtime initialization, while deliberately +deferring backend FD transport and public API plumbing. + +## Files changed + +- `tractor/spawn/_entry.py` - enter and release an optional inherited + namespace capability before multiprocessing child runtime startup. +- `tractor/_child.py` - consume the capability at the start of the + Trio child bootloader, before patching or actor construction. +- `tests/test_netns_spawn.py` - prove entry ordering, failure isolation, + and exact FD ownership for both child entrypoints. + +## Human edits + +The human selected spawn-time namespace integration as the next work +slice and authorized implementation. No direct manual source edits +were observed during generation. diff --git a/ai/prompt-io/opencode/20260828T012205Z_9ae7cd86_prompt_io.raw.md b/ai/prompt-io/opencode/20260828T012205Z_9ae7cd86_prompt_io.raw.md new file mode 100644 index 000000000..007155851 --- /dev/null +++ b/ai/prompt-io/opencode/20260828T012205Z_9ae7cd86_prompt_io.raw.md @@ -0,0 +1,36 @@ +--- +model: gpt-5.6-sol +service: opencode +timestamp: 2026-08-28T01:22:05Z +git_ref: 9ae7cd86 +diff_cmd: git diff HEAD~1..HEAD +--- + +# Raw output - enter inherited netns during child bootstrap + +The human asked to continue the WireGuard Layer C implementation after +the rebased branch landed the verified `spawn._netns.enter_netns()` +primitive. + +## Generated code + +> `git diff HEAD~1..HEAD -- tractor/_child.py tractor/spawn/_entry.py tests/test_netns_spawn.py` + +Add one optional atomic `(namespace_fd, expected_inode)` bootstrap +capability to the multiprocessing entrypoint and Trio child bootloader. +Enter and verify the namespace before Trio patching, actor construction, +runtime state, logging, Trio startup, parent connection, sockets, or +worker threads, then close the child-owned inherited FD before +continuing. + +Add privilege-free direct-entrypoint tests using real stand-in FDs and +a fake namespace syscall boundary. Cover helper-level no-op behavior, +successful entry ordering, exact FD closure, malformed-FD isolation, +primary-error preservation, and failure cleanup for both child +bootstrap paths. + +## Scope boundary + +This increment does not transfer FDs through spawn backends or expose a +public actor API. Backend-specific FD duplication, bootstrap failure +reporting, and root-process namespace entry remain follow-up work. diff --git a/ai/prompt-io/opencode/20260828T045119Z_fb6d81d3_prompt_io.md b/ai/prompt-io/opencode/20260828T045119Z_fb6d81d3_prompt_io.md new file mode 100644 index 000000000..b3ee4bba4 --- /dev/null +++ b/ai/prompt-io/opencode/20260828T045119Z_fb6d81d3_prompt_io.md @@ -0,0 +1,47 @@ +--- +model: gpt-5.6-sol +service: opencode +session: trio-pre-handshake-child-death +timestamp: 2026-08-28T04:51:19Z +git_ref: fb6d81d3 +scope: code +substantive: true +raw_file: 20260828T045119Z_fb6d81d3_prompt_io.raw.md +--- + +## Prompt + +The human approved the next WireGuard Layer C implementation step after +committing child-side network namespace bootstrap. + +## Response summary + +Restore and harden the boot-race design originally implemented by +`3b0724eba85b4014170ed95773e1e41a60d5c513` before transferring +namespace FDs. Race the initial peer handshake against child-process +death so a pre-connection bootstrap failure surfaces instead of +parking `ActorNursery.start_actor()` forever. + +## Files changed + +- `tractor/spawn/_spawn.py` - provide the generic handshake/process + death race. +- `tractor/spawn/_trio.py` - use the race during Trio child startup. +- `tests/test_spawning.py` - cover handshake-first and death-first + schedules, simultaneous outcomes, waiter errors, and backend cleanup. + +## Human edits + +The human approved continued Layer C implementation. The agent narrowed +the next slice to failure detection after analysis showed FD transfer +would otherwise introduce a parent hang. The human then identified the +earlier `wait_for_peer_or_proc_death()` implementation and required +explicit credit; the source docstring and commit message now cite its +original commit rather than a later cherry-pick. The human directly +reflowed `_trio.py` to the project line limit and normalized nearby +quotes. The human also required naming the race helper in its lifecycle +comment, explicit `trio.Cancelled` handling in waiter tests, expected +cancellation diagnostics, and a `trio.Process`-constrained test double. +After mainline portability verification, the human chose to commit the +fix on the current WireGuard branch and require its cherry-pick onto +`main` as a blocker in the eventual WireGuard pull request. diff --git a/ai/prompt-io/opencode/20260828T045119Z_fb6d81d3_prompt_io.raw.md b/ai/prompt-io/opencode/20260828T045119Z_fb6d81d3_prompt_io.raw.md new file mode 100644 index 000000000..865560f61 --- /dev/null +++ b/ai/prompt-io/opencode/20260828T045119Z_fb6d81d3_prompt_io.raw.md @@ -0,0 +1,34 @@ +--- +model: gpt-5.6-sol +service: opencode +timestamp: 2026-08-28T04:51:19Z +git_ref: fb6d81d3 +diff_cmd: git diff HEAD~1..HEAD +--- + +# Raw output - detect child death before parent handshake + +The human asked to continue WireGuard Layer C after child entrypoints +learned to consume inherited network namespace capabilities. + +## Generated code + +> `git diff HEAD~1..HEAD -- tractor/spawn/_spawn.py tractor/spawn/_trio.py tests/test_spawning.py` + +Race a spawned child's initial parent handshake against its process +wait operation. Return the established channel when the handshake wins; +raise `ActorFailure` with the exit status when the process dies first. +Use the race in the Trio exec backend instead of waiting indefinitely +on `IPCServer.wait_for_peer()`. + +Add deterministic Trio tests for handshake-first, death-first, +simultaneous, and waiter-error schedules, controlling readiness with +events rather than sleeps. Exercise the full Trio backend with a fake +dead process to prove pre-publication event registration and exact +failed-startup cleanup. + +## Scope boundary + +This prerequisite prevents pre-connection namespace-entry failures +from hanging their parent. Namespace FD transfer itself remains the +next commit. diff --git a/ai/prompt-io/opencode/20260828T172943Z_2ca8c570_prompt_io.md b/ai/prompt-io/opencode/20260828T172943Z_2ca8c570_prompt_io.md new file mode 100644 index 000000000..8325d9d50 --- /dev/null +++ b/ai/prompt-io/opencode/20260828T172943Z_2ca8c570_prompt_io.md @@ -0,0 +1,53 @@ +--- +model: gpt-5.6-sol +service: opencode +session: trio-bindspace-fd-transport +timestamp: 2026-08-28T17:29:43Z +git_ref: 2ca8c570 +scope: code +substantive: true +raw_file: 20260828T172943Z_2ca8c570_prompt_io.raw.md +--- + +## Prompt + +After committing generic pre-handshake child-death detection, the human +approved the proposed next Layer C slice: transport a live bindspace +capability through the Trio spawn backend. + +## Response summary + +Plumb an optional process-local `Bindspace` through actor spawn APIs. +Give a Trio exec child its own inherited namespace descriptor, preserve +existing caller `pass_fds`, transport the FD/inode pair through the CLI, +and close the temporary parent descriptor on every open-process outcome. +Reject unsupported multiprocessing transport explicitly. Exercise the +complete Trio path with a real actor that enters a distinct network +namespace and reports its namespace and inherited-FD inodes over RPC. + +## Files changed + +- `tractor/runtime/_supervise.py` - accept and relay a child bindspace. +- `tractor/to_actor/_api.py` - expose bindspace spawn configuration. +- `tractor/spawn/_spawn.py` - relay the bindspace to spawn backends. +- `tractor/spawn/_trio.py` - duplicate, transfer, and close the FD. +- `tractor/spawn/_mp.py` - reject unsupported MP transport. +- `tractor/_child.py` - parse and forward the atomic CLI capability. +- `tests/test_netns_spawn.py` - cover transport and cleanup schedules. + +## Human edits + +The human approved the Trio-first transport boundary after reviewing +the proposed dependency order, then supplied staged-diff comments across +multiple review passes. Human-directed revisions replaced abstract +ownership wording with concrete FD and process terminology, clarified +descriptor cleanup, preserved existing `pass_fds` explicitly, and +replaced the mocked success-path test with a real child actor, real +`setns()`, handshake, and RPC round trip inside unprivileged user/network +namespaces. Follow-up review added direct parent FD-table verification +that the temporary child-bound duplicate is closed after successful +spawn. + +Multiprocessing FD reduction and root-process namespace entry remain +separate follow-up slices. No direct manual source edits were observed; +the review-driven source revisions were applied by the agent. diff --git a/ai/prompt-io/opencode/20260828T172943Z_2ca8c570_prompt_io.raw.md b/ai/prompt-io/opencode/20260828T172943Z_2ca8c570_prompt_io.raw.md new file mode 100644 index 000000000..179699266 --- /dev/null +++ b/ai/prompt-io/opencode/20260828T172943Z_2ca8c570_prompt_io.raw.md @@ -0,0 +1,34 @@ +--- +model: gpt-5.6-sol +service: opencode +timestamp: 2026-08-28T17:29:43Z +git_ref: 2ca8c570 +diff_cmd: git diff HEAD~1..HEAD +--- + +# Raw output - transport bindspace FD through Trio spawn + +The human approved the next WireGuard Layer C increment after landing +pre-handshake child-death detection. + +## Generated code + +> `git diff HEAD~1..HEAD -- tractor/_child.py tractor/runtime/_supervise.py tractor/spawn/_spawn.py tractor/spawn/_trio.py tractor/spawn/_mp.py tractor/to_actor/_api.py tests/test_netns_spawn.py` + +Thread an optional live `Bindspace` through actor spawn APIs. For the +Trio backend, duplicate its namespace FD, append that temporary +descriptor to existing `pass_fds`, and send the atomic descriptor/inode +pair through the child CLI. Close the parent temporary duplicate as +soon as `open_process()` returns, raises, or is cancelled, while the +child consumes its inherited descriptor before runtime startup. + +Reject bindspace transport explicitly in multiprocessing backends until +their descriptor-reduction slice is implemented. Add privilege-free +tests for CLI forwarding, merged pass-FD state, caller immutability, +source/duplicate ownership, exec failure, cancellation, absent live +authority, death-before-handshake cleanup, and MP rejection. + +## Scope boundary + +Multiprocessing FD reduction and root-process namespace entry remain +follow-up work. diff --git a/ai/prompt-io/opencode/20260830T025201Z_b1f6ade8_prompt_io.md b/ai/prompt-io/opencode/20260830T025201Z_b1f6ade8_prompt_io.md new file mode 100644 index 000000000..1f2488f4b --- /dev/null +++ b/ai/prompt-io/opencode/20260830T025201Z_b1f6ade8_prompt_io.md @@ -0,0 +1,47 @@ +--- +model: gpt-5.6-sol +service: opencode +session: tractor-net-api-extraction +timestamp: 2026-08-30T02:52:01Z +git_ref: b1f6ade8 +scope: code +substantive: true +raw_file: 20260830T025201Z_b1f6ade8_prompt_io.raw.md +--- + +## Prompt + +After rebasing the WireGuard Layer C stack, the human questioned the +placement of bindspace APIs under `tractor.discovery`, considered new +public package names, selected `tractor.net`, and authorized the +implementation. + +## Response summary + +Extracted bindspace and tunnel implementations into a lazy +`tractor.net` package. Moved public multiaddr, bindspace, tunnel, and +WireGuard symbols out of `tractor.discovery`, updated internal imports, +tests, examples, and documentation, and added cold-import regressions +that keep optional networking dependencies off the root import path. +Current-netns attachment pins `/proc/thread-self/ns/net` so calls from +non-leader threads retain the caller's namespace rather than the process +leader's. + +## Files changed + +- `tractor/net/` - lazy public network API and implementation modules. +- `tractor/discovery/` - retain actor discovery and internal address parsing. +- `tractor/__init__.py` - expose `tractor.net` lazily. +- `tests/net/` - network declaration and lifecycle coverage. +- `tests/test_lazy_imports.py` - enforce the lazy package boundary. +- `examples/multihost/wg_lan/` - use the public network package. +- `docs/` - document network APIs separately from actor discovery. + +## Human edits + +The human rejected `tractor.discovery` as the long-term public home, +considered tunnel- and namespace-specific alternatives, and selected +the broader `tractor.net` boundary because bindspaces may include plain +netns, WireGuard, VRF, veth, and later network resources. The agent +applied the resulting source changes; no direct manual edits were +observed. diff --git a/ai/prompt-io/opencode/20260830T025201Z_b1f6ade8_prompt_io.raw.md b/ai/prompt-io/opencode/20260830T025201Z_b1f6ade8_prompt_io.raw.md new file mode 100644 index 000000000..cf4c01aab --- /dev/null +++ b/ai/prompt-io/opencode/20260830T025201Z_b1f6ade8_prompt_io.raw.md @@ -0,0 +1,29 @@ +--- +model: gpt-5.6-sol +service: opencode +timestamp: 2026-08-30T02:52:01Z +git_ref: b1f6ade8 +diff_cmd: git diff HEAD~1..HEAD +--- + +# Raw output - extract public network APIs + +The human questioned whether bindspace and WireGuard lifecycle APIs +belonged under actor discovery, selected the proposed `tractor.net` +boundary, and authorized implementation. + +## Generated code + +> `git diff HEAD~1..HEAD -- tractor/net tractor/discovery tractor/__init__.py` + +Move bindspace and tunnel implementations into a lazy public network +package. Keep actor discovery focused on registry and lookup behavior, +while exposing bindspace, tunnel, WireGuard, and multiaddr declarations +through `tractor.net` without loading optional networking dependencies +during `import tractor`. + +> `git diff HEAD~1..HEAD -- tests/net tests/test_lazy_imports.py tests/ipc examples/multihost/wg_lan docs` + +Move network-focused tests to `tests/net`, update internal and public +imports, verify lazy symbol resolution and removed discovery exports, +and document the new package boundary. diff --git a/ai/prompt-io/opencode/20260830T025202Z_b1f6ade8_prompt_io.md b/ai/prompt-io/opencode/20260830T025202Z_b1f6ade8_prompt_io.md new file mode 100644 index 000000000..147d0870d --- /dev/null +++ b/ai/prompt-io/opencode/20260830T025202Z_b1f6ade8_prompt_io.md @@ -0,0 +1,52 @@ +--- +model: gpt-5.6-sol +service: opencode +session: root-bindspace-bootstrap +timestamp: 2026-08-30T02:52:02Z +git_ref: b1f6ade8 +scope: code +substantive: true +raw_file: 20260830T025202Z_b1f6ade8_prompt_io.raw.md +--- + +## Prompt + +The human chose to defer multiprocessing bindspace FD transport because +that backend may be removed, selected root/single-actor namespace +bootstrap as the next stage, approved caller-thread restoration and +public `tractor.net.open_wg_bindspace()` composition, and authorized +implementation after the stack rebase. + +## Response summary + +Added `bindspace=` to `tractor.open_root_actor()`. Root startup now +duplicates and validates the live namespace FD, enters before any +debugger, registry, IPC, or actor-runtime work, and synchronously +restores the caller thread's original namespace after complete root +teardown. Added deterministic failure/cancellation coverage, a real UDS +root actor E2E across two network namespaces, and an executable public +WireGuard-bindspace/root composition regression. Bound roots reject the +persistent `mp_forkserver` backend because a helper from an earlier +runtime may retain a stale namespace; the public annotation also remains +runtime-resolvable without importing `tractor.net` eagerly. + +## Files changed + +- `tractor/_root.py` - public root bindspace lifecycle integration. +- `tractor/spawn/_netns.py` - temporary thread-local netns enter/restore. +- `tests/test_netns_spawn.py` - deterministic and real root regressions. +- `tests/net/test_wg_iface_lifecycle.py` - public root composition test. +- `docs/api/net.rst` - root ownership and restoration contract. + +## Human edits + +The human explicitly deferred multiprocessing support, selected root +bootstrap as the next implementation stage, approved the requirement to +restore the caller's original namespace so owned teardown can complete, +and approved composition through public `tractor.net` contexts rather +than a new one-shot convenience API. The human later rewrote +`_enter_netns_temporarily()` to separate restoration from FD cleanup and +make the transition flow easier to inspect. The agent retained that +intent with nested lexical FD ownership while fixing setup-error masking, +conditional restoration, and duplicate-FD cleanup found during the +requested audit. diff --git a/ai/prompt-io/opencode/20260830T025202Z_b1f6ade8_prompt_io.raw.md b/ai/prompt-io/opencode/20260830T025202Z_b1f6ade8_prompt_io.raw.md new file mode 100644 index 000000000..7596c4045 --- /dev/null +++ b/ai/prompt-io/opencode/20260830T025202Z_b1f6ade8_prompt_io.raw.md @@ -0,0 +1,31 @@ +--- +model: gpt-5.6-sol +service: opencode +timestamp: 2026-08-30T02:52:02Z +git_ref: b1f6ade8 +diff_cmd: git diff HEAD~1..HEAD +--- + +# Raw output - bootstrap the root actor in a bindspace + +The human deferred multiprocessing descriptor transport, selected root +namespace bootstrap as the next Layer C stage, approved enter/restore +semantics and public `tractor.net` composition, and authorized +implementation. + +## Generated code + +> `git diff HEAD~1..HEAD -- tractor/_root.py tractor/spawn/_netns.py` + +Add an optional realized `Bindspace` to `open_root_actor()`. Duplicate +its namespace FD, enter before debugger, registry, IPC, or runtime +startup, and restore the caller thread's original namespace after root +teardown. Preserve caller FD ownership and primary errors across +restoration and close failures. + +> `git diff HEAD~1..HEAD -- tests/test_netns_spawn.py tests/net/test_wg_iface_lifecycle.py docs/api/net.rst` + +Exercise same-netns behavior, body errors, cancellation, restoration +failures, missing and closed FDs, real root entry/restoration under an +unprivileged user namespace, and public WireGuard-bindspace/root +composition. diff --git a/ai/prompt-io/prompts/multiaddr_declare_eps.md b/ai/prompt-io/prompts/multiaddr_declare_eps.md index dcd07cb84..ae1b6287c 100644 --- a/ai/prompt-io/prompts/multiaddr_declare_eps.md +++ b/ai/prompt-io/prompts/multiaddr_declare_eps.md @@ -47,7 +47,7 @@ Here is a small example from piker, We should take whatever common API is needed to support this and distill it into a ```python -tractor.discovery.parse_endpoints( +tractor.net.parse_endpoints( ) -> dict[ str, list[Address] diff --git a/ai/tpt-backends/00_shared_backend_contract.md b/ai/tpt-backends/00_shared_backend_contract.md index 15521287e..09b80ee40 100644 --- a/ai/tpt-backends/00_shared_backend_contract.md +++ b/ai/tpt-backends/00_shared_backend_contract.md @@ -141,9 +141,10 @@ Hard constraints learned from the existing two: *ALPN + relay/discovery realm* (plan 02). Do not overload this transport-level bind selector with process namespace lifecycle. Plan 03 augments an maddr/address declaration with a serializable - `BindspaceSpec` and a scoped, non-serializable `BindspaceHandle`; - the latter owns namespace identity/FD/lifetime and is consumed at - spawn bootstrap before a concrete address reaches transport bind. + `BindspaceSpec` request and host-local `BindspaceRef`. A scoped, + non-serializable `Bindspace` carries that ref and owns the FD/lifetime + used during spawn bootstrap before a concrete address reaches + transport bind. `Address.namespace` is already spec'd in the Protocol as "the if-available OS-specific network namespace key" and is currently unimplemented by both backends — plan 03 is the diff --git a/ai/tpt-backends/03_wg_tunnel_bindspace.md b/ai/tpt-backends/03_wg_tunnel_bindspace.md index 5a48f14a9..054e91c21 100644 --- a/ai/tpt-backends/03_wg_tunnel_bindspace.md +++ b/ai/tpt-backends/03_wg_tunnel_bindspace.md @@ -32,8 +32,10 @@ onto `trio` as the library's sans-io layer allows. by multiformats/py-multiaddr#107 and gh #483. - **today's deployable story remains declarative**: run `wg-quick` out-of-band, parse the maddr, strip its wrapper to the overlay - `(host, port)`, verify the pubkey in its host-specific role, - hand the overlay addr to `registry_addrs=`/`tpt_bind_addrs=`. + `(host, port)`, explicitly verify the declared pubkey against the + local interface key or configured peers with async + `verify_wg_peer()`, then hand the overlay addr to + `registry_addrs=`/`tpt_bind_addrs=`. The repaired `examples/multihost/wg_lan/` implementation derives from and supersedes #482's original example. - `Address.namespace` exists in the Protocol @@ -133,7 +135,7 @@ examples in gh #482) used a *suffix* form `/ip4/10.0.11.1/tcp/1616/wg/u`. That parses, but it is semantically inverted: it puts the overlay addr where the bearer belongs, `tcp` where wg's `udp` `ListenPort` goes, and declares -no overlay endpoint at all. `tractor.discovery.parse_wg_maddr()` +no overlay endpoint at all. `tractor.net.parse_wg_maddr()` now rejects it with an actionable error. Observed protocol-name lists, for writing the `match`: @@ -200,31 +202,25 @@ Observed protocol-name lists, for writing the `match`: ### 3.3 pure parser helpers + explicit verification -The parser/key-codec helpers live in -`tractor/discovery/_tunnel.py`; the impure verifier remains -example-local until layer B: +The parser/key-codec helpers and async production verifier live in +`tractor/net/_tunnel.py`; parsing remains pure while verification +is an explicit, impure caller step: ```python def parse_wg_maddr(maddr: str|Multiaddr) -> TunnelledAddress: ... def mb_pubkey(wg8_key: str) -> str: ... def wg8_pubkey(multibase_key: str) -> str: ... -async def verify_wg_key( - addr: TunnelledAddress, - role: Literal['local', 'peer'], - iface: str|None = None, - timeout: float = 5, - inspection: str|None = None, -) -> bool: ... # example-local impure probe +async def verify_wg_peer(spec: WGTunnelSpec) -> bool: ... # layer B ``` -In layer A `verify_wg_key()` may shell out to role-specific -`wg show public-key|peers` queries, but it must be a *single* -async, time-bounded function so it never blocks trio's run thread -and layer B swaps only its body. It verifies key presence only, -not `Endpoint`, `AllowedIPs`, handshake state, or routing. Never -run `tractor` as root: privileged inspection stays a separate -step whose public-key output can be passed as `inspection`. Never -call it implicitly from +Layer A's example-local `verify_wg_key()` used role-specific +`wg show public-key|peers` queries. Layer B replaces it with +`verify_wg_peer()`, backed by one pyroute2 key snapshot selected by +`spec.iface` and `spec.netns`. It validates the declared key before +I/O and accepts either the interface's own public key or a configured +peer key. It does not enforce a host-specific role and verifies key +presence only, not `Endpoint`, `AllowedIPs`, handshake state, or +routing. Never call it implicitly from `wrap_address()`/`parse_maddr()` — parsing must stay pure and side-effect-free; verification is the *caller's* explicit step (and later, the bindspace `@acm`'s). @@ -247,7 +243,7 @@ side-effect-free; verification is the *caller's* explicit step (`proto_key`/`unwrap` identical to overlay), `wrap_address()` regression (a tunnelled maddr `str` → `TunnelledAddress`; 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). --- @@ -285,17 +281,20 @@ Three integration options, in increasing trio-nativeness: - (3) reimplement the codecs. Never. **Recommended split**: ship (1) first so layer B is a small, -reviewable, behaviour-preserving swap of `verify_wg_key()`'s -body; then land (2) as a follow-up commit for the read path -(`wg get`, `link get`) where the sans-io surface is smallest, -and keep (1) for the privileged mutating ops. Measure before -converting anything else — there is no perf argument here, only -a "no foreign event loop in a trio actor" argument, which (1) -already satisfies (a thread is not an event loop). - -Explicitly **do not** pull in `trio-asyncio` for pyroute2: it -would be the one place in the runtime where an asyncio loop -exists for no reason. +reviewable replacement of the example-local verification probe with +production `verify_wg_peer()`; then land (2) as a follow-up commit for +the read path (`wg get`, `link get`) where the sans-io surface is +smallest, and keep (1) for the privileged mutating ops. Measure before +converting anything else — there is no perf argument here, only a "no +foreign event loop in a trio actor" argument, which (1) already +satisfies (a thread is not an event loop). + +Explicitly **do not** pull in `trio-asyncio` for pyroute2 or infect +every wg-using actor merely to service one-shot netlink calls. A +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 @@ -310,7 +309,8 @@ async def read_wg_peers( async def read_wg_pubkey(iface: str = 'wg0', ...) -> str: ... ``` -and `verify_wg_key()` 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)`. --- @@ -331,7 +331,68 @@ describes the data-plane socket, not who provisions it: tractor owns the lifecycle while `Endpoint`/`MsgTransport` remain responsible only for the overlay application socket. -### 5.1 the composition +### 5.1 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 capabilities. 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 either a source/listen or destination/dial handle. It does **not** @@ -339,27 +400,28 @@ select the local instance of that network stack. A netns, VRF, interface, user namespace, or equivalent platform resource is orthogonal augmentation carried alongside/below the maddr. -Keep two bindspace representations with deliberately different -lifetimes: +Keep three bindspace representations with deliberately different roles +and lifetimes: ```python class BindspaceSpec(msgspec.Struct, frozen=True): '''Serializable spawn/config declaration.''' kind: str # `netns`, later `vrf`, ... key: str|None # requested name/key, if any + lifecycle: Literal['attach', 'open'] -class BindspaceIdentity(msgspec.Struct, frozen=True): - '''Stable identity of the realized platform resource.''' +class BindspaceRef(msgspec.Struct, frozen=True): + '''Wire-safe, non-owning ref to the realized resource.''' kind: str - key: str|None - inode: int|None # Linux namespace identity + key: str|None # mutable name, absent after unlink + inode: int # host-local Linux nsfs fingerprint -class BindspaceHandle: +class Bindspace(ProcessLocal): '''Scoped, non-serializable capability for one live bindspace.''' spec: BindspaceSpec - identity: BindspaceIdentity + ref: BindspaceRef namespace_fd: int|None ownership: Literal['owned', 'borrowed'] @@ -367,31 +429,59 @@ class BindspaceHandle: @acm async def open_bindspace( spec: BindspaceSpec, - *, - role: Literal['listen', 'dial'], -) -> AsyncGenerator[BindspaceHandle, None]: +) -> AsyncGenerator[Bindspace, None]: ''' Provision/borrow one bindspace and yield its live capability. ''' ``` -The exact field set remains design work; the required split does not: -`BindspaceSpec` crosses config/spawn serialization, while -`BindspaceHandle` contains live OS resources (especially an open -namespace FD), pins identity/lifetime, and must never cross msgpack. -An FD is a stronger capability than a namespace name: it avoids -name-resolution TOCTOU, survives rename/unlink, and identifies the -exact namespace the parent provisioned. +The initial model limits `BindspaceKind` to `netns` while preserving +the required role split. `BindspaceSpec` is the requested resource and +lifecycle policy. `BindspaceRef` is a serializable, non-owning, +host-local record of the resource that was actually opened; it can be +compared or logged, but cannot reopen, pin or enter that resource. +`Bindspace` is the live capability and uses msgspec's generic struct +storage by inheriting the global `tractor.msg.ProcessLocal` marker. Its +hidden unsupported sentinel blocks direct and nested default msgspec +encoding without a recursive IPC hot-path scan. The live bindspace +validates any supplied FD against `BindspaceRef.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 a `BindspaceRef` 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 `TunnelledAddress`. At the declaration layer, listener allocation can -use the handle to replace an overlay while preserving every tunnel: +use the live bindspace to replace an overlay while preserving every +tunnel: ```python async with open_bindspace( bindspace_spec, - role='listen', ) as bindspace: listen_decl = declared_addr.get_random( bindspace=bindspace, @@ -404,6 +494,10 @@ contract open. A concrete transport call returns a concrete overlay; a declaration-level call may replace the overlay and return a new `TunnelledAddress`. In either case wrappers remain until the final transport bind/dial boundary, where `strip_tunnels()` is mandatory. +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 tunnel/bindspace layer: @@ -412,52 +506,85 @@ tunnel/bindspace layer: @acm async def open_netns( spec: BindspaceSpec, - role: Literal['listen', 'dial'], -) -> AsyncGenerator[BindspaceHandle, None]: ... +) -> AsyncGenerator[Bindspace, None]: ... @acm async def open_wg_iface( spec: WGTunnelSpec, - bindspace: BindspaceHandle, + config: WGInterfaceConfig, + bindspace: Bindspace, role: Literal['listen', 'dial'], ) -> AsyncGenerator[WGTunnelSpec, None]: ... ``` -and a driver that folds a list of specs into nested contexts -(`contextlib.AsyncExitStack` for the N-deep case). The +`WGInterfaceConfig` and each `WGPeerConfig` are process-local and +rejected by the global `ProcessLocal` wire guard. The interface config +owns its private key, local addresses and listen port; each peer owns +its public key, allowed CIDRs, optional endpoint, preshared key and +keepalive. Reprs redact private/preshared keys. `WGTunnelSpec` remains +serializable public maddr-derived identity/endpoint data. This split +supports multi-peer listeners without overloading the tunnel maddr. + +The initial `open_wg_iface()` lifecycle is owned and Linux-only. It +validates role-dependent bearer policy before side effects, creates the +iface and addresses through `IPRoute`, configures keys/peers through +`WireGuard`, raises the link, and removes it on every post-creation +exit. Creation/removal run in shielded Trio worker calls. A listen +bearer supplies the local listen port; a dial bearer supplies an +omitted endpoint only for the selected maddr peer. + +The composition driver folds a list of specs into nested contexts with +`contextlib.AsyncExitStack` for the N-deep case. The `parse_endpoints()` API (`_multiaddr.py:189`) is the front door: its `ParsedEndpoints` values already contain `Address|TunnelledAddress` declarations and preserve each tunnel stack for the eventual bindspace handler. It carries declarations; it does not *enter* their bindspaces. -The caller supplies `role`; do not infer it from maddr shape. The same -composed maddr can name a server source or client destination, and the -required local provisioning/ownership differs (§5.3). - -### 5.2 `Address.namespace`, at last - -- `TunnelledAddress.namespace` → `(kind, id)` e.g. - `('netns', 'tractor-wg0')`. -- **and** the existing backends should implement it as `None` - explicitly (they currently just don't define it), so the - Protocol stops lying. -- consumers to audit: nothing reads `.namespace` today — so - adding it is safe, but the *point* is that - `Endpoint`/`Server.pformat()` should start showing it (there's - already a `# !TODO, always be ns aware!` + - `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. +`open_wg_bindspace()` is the initial driver for one bindspace and an +ordered sequence of `(WGTunnelSpec, WGInterfaceConfig)` layers. It +opens the bindspace first, enters WG interfaces outermost-first through +`AsyncExitStack`, and yields the live `Bindspace` for endpoint +allocation. Exit is inside-out, so every interface is removed while the +namespace FD remains pinned; only then can an owned namespace be +removed. Endpoint/channel lifetimes belong inside the yielded scope. + +The caller supplies `role` to tunnel-resource contexts such as +`open_wg_iface()`; do not infer it from maddr shape. Bindspace +lifecycle remains the independent explicit policy above. The same +composed maddr can name a server source or client destination (§5.4). + +### 5.3 `Address.namespace`, at last + +- an unrealized `TunnelledAddress.namespace` reports its declared name + as `(kind, key)`, e.g. `('netns', 'tractor-wg0')`; +- `TunnelledAddress.with_bindspace_ref()` returns a frozen declaration + annotated with `bindspace.ref`, never the FD-bearing `Bindspace`. + Its `.namespace` reports `(kind, inode)` so the + realized ref remains stable across rename or unlink; +- existing plain backends implement it explicitly as `None`, so the + Protocol does not lie and tunnel delegation needs no `getattr()` + fallback. +- `Endpoint.namespace` reads the retained declaration rather than its + peeled transport addr; both `Endpoint.pformat()` and + `Server.pformat()` expose that value as the cheapest proof the layer + is wired. + +Deferred follow-ups: + +- add native tagged encoding for the complete `TunnelledAddress` graph, + including its concrete overlay-address union, tunnel-spec union and + optional `BindspaceRef`. Once that codec exists, tests should perform + typed roundtrips instead of inspecting an untyped decoded payload. Use `github/ns_aware@e4688cad` as prototype evidence, not code to cherry-pick unchanged. Its `/proc//ns/` inode reader and -`ip netns identify` probe establish the useful `(key, inode)` identity -pair. Layer C should move that shape into `BindspaceIdentity`, avoid a +`ip netns identify` probe establish the useful `(key, inode)` reference +record. Layer C should move that shape into `BindspaceRef`, avoid a subprocess where netlink/procfs suffices, and hold the namespace FD in -`BindspaceHandle` to pin the identity. +`Bindspace` to pin the referenced resource. -### 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 **runtime-level config API, not an actor-app-code API.** It is @@ -493,12 +620,18 @@ server bound in the old namespace. - the child spawn/bootstrap trampoline calls `setns()` **before** `_runtime.async_main()`, `IPCServer.listen_on()`, parent-channel connection, or creation of any worker thread/socket. + - `spawn._netns.enter_netns()` is the first private bootstrap + primitive: it checks the inherited FD against the expected inode, + calls `setns(fd, CLONE_NEWNET)`, and verifies + `/proc/self/ns/net` before returning. It deliberately does not own + or close the FD; spawn propagation and status reporting remain the + caller's next integration boundary. - only after successful entry does the child drop namespace-entry privileges and initialize the actor runtime. - a root/single-actor process follows the same ordering: enter during root bootstrap, never after actor runtime startup. - iface/route/WG provisioning is genuinely scoped and remains under - the parent/supervisor's `BindspaceHandle` context. + the parent/supervisor's `Bindspace` context. - document the constraint rather than hiding it; a `RuntimeError` if namespace entry is attempted after bootstrap. - capabilities: iface/route/WG configuration needs `CAP_NET_ADMIN`; @@ -527,7 +660,7 @@ server bound in the old namespace. - teardown follows capability ownership, not just address type: - owned listener bindspaces tear down after endpoints/channels and the actor process have exited; - - borrowed dial/actor-wide bindspaces only release their handle; + - borrowed dial/actor-wide bindspaces only release their capability; - nested resources exit inside-out, but shared resources remain until their owning supervisor drops the final capability. - teardown must be idempotent and tolerant: an iface/netns @@ -536,7 +669,7 @@ server bound in the old namespace. tolerance and `_serve_ipc_eps()`'s per-ep `try/except` 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 enter/exit ordering (outermost-last-out) via a trace list. @@ -547,7 +680,7 @@ server bound in the old namespace. and a subactor in the other, then `find_actor()` across the tunnel. This is fully self-contained — no second host and 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. - bootstrap ordering: assert the child reports the expected namespace inode before parent-channel connect and listener creation. @@ -556,6 +689,14 @@ server bound in the old namespace. - privilege drop: prove actor code lacks provisioning caps after entry. - role/ownership: fake listen/dial resources and assert owned listener teardown versus borrowed dial-handle release. +- `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. --- @@ -570,7 +711,7 @@ machinery covers any iface-layer tunnel `pyroute2` can drive — `kind: ClassVar[str]`, and dispatch `open_*` by `match` on it. Design for it now (union + `match`), implement only `wg` + `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. ## 7. Non-goals @@ -588,15 +729,17 @@ consider doing it *first* for exactly that reason. | 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 | | child starts sockets/threads before `setns()` | enter in the spawn bootstrap trampoline before `_runtime.async_main()`; assert inode ordering | | ambient capabilities leak into actor app code | split provision/enter authority and drop caps before runtime initialization | -| dial path tears down a shared actor bindspace | encode ownership in `BindspaceHandle`; borrowed handles never remove resources | +| dial path tears down a shared actor bindspace | encode ownership in `Bindspace`; borrowed bindspaces never remove resources | | py-multiaddr#108 merged but unreleased | PEP 621 direct-revision pin + `_wg_proto_code()` gate; replace with a release floor once published | | `TunnelledAddress` leaks into transport reflection/type dispatch | keep wrappers through declaration/bindspace handling, call `strip_tunnels()` at channel/endpoint boundaries, and retain the boundary regressions | | privileged ops in a library | never `sudo`; explicit cap probe + actionable error; pre-provisioned is the default | -| 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()` | ## 9. Follow-up issue seeds @@ -608,6 +751,7 @@ consider doing it *first* for exactly that reason. - `wg` proto into the multiaddr **spec** (gh #483), then flip `MsgTransport.maddr` to always return `Multiaddr` (the third #443 bullet) -- runtime-managed wg key rotation / peer add-remove as a - `tractor` service actor — the natural "actor that owns the - network" demo +- first-child `wgman` prototype: concurrent bootstrap, scoped sibling + access, infected-asyncio pyroute2 ownership and restart reconciliation +- runtime-managed wg key rotation / peer add-remove through `wgman` — + the natural "actor that owns the network" demo diff --git a/docs/api/index.rst b/docs/api/index.rst index 5782a8feb..e152c63a9 100644 --- a/docs/api/index.rst +++ b/docs/api/index.rst @@ -53,6 +53,7 @@ Most-used names at a glance: core context discovery + net errors msg trionics diff --git a/docs/api/ipc.rst b/docs/api/ipc.rst index a9a36f800..8212b0655 100644 --- a/docs/api/ipc.rst +++ b/docs/api/ipc.rst @@ -84,6 +84,7 @@ already distributed-system aware. .. seealso:: :doc:`/explain/architecture` for the transport/server - internals, :doc:`/api/discovery` for how channel addresses - get registered and found, and :doc:`/api/msg` for the codec - layer every channel speaks. + internals, :doc:`/api/net` for network declarations, + :doc:`/api/discovery` for how channel addresses get registered + and found, and :doc:`/api/msg` for the codec layer every channel + speaks. diff --git a/docs/api/net.rst b/docs/api/net.rst new file mode 100644 index 000000000..de9ef0a5d --- /dev/null +++ b/docs/api/net.rst @@ -0,0 +1,95 @@ +Network declarations and lifecycles +=================================== + +``tractor.net`` provides address composition, bindspace declarations, +and tunnel configuration. The package is lazy: importing +``tractor`` or ``tractor.net`` does not load multiaddr, WireGuard, or +pyroute2 implementation modules until a public symbol is used. + +Multiaddr helpers +----------------- + +.. currentmodule:: tractor.net + +.. autofunction:: mk_maddr + +.. autofunction:: parse_maddr + +.. autofunction:: parse_endpoints + +Bindspaces +---------- + +.. autoclass:: BindspaceSpec + +.. autoclass:: BindspaceRef + +.. autoclass:: Bindspace + +.. autofunction:: attach_netns + +.. autofunction:: open_netns + +.. autofunction:: open_bindspace + +Root actor composition +---------------------- + +A live :class:`Bindspace` can scope the root actor itself. Compose the +bindspace manager outside :func:`tractor.open_root_actor` so its network +namespace remains pinned for the complete actor runtime:: + + async with tractor.net.open_wg_bindspace( + bindspace_spec=bindspace_spec, + layers=layers, + role='listen', + ) as bindspace: + async with tractor.open_root_actor( + bindspace=bindspace, + enable_transports=['uds'], + ) as root_actor: + ... + +Root entry happens before registry probes, IPC listeners, runtime sockets, +or actor startup. On every exit, including cancellation or a body error, +the calling thread is restored to its original network namespace before +``open_root_actor()`` returns. The root context duplicates the live +``Bindspace.namespace_fd`` and never consumes or closes the descriptor +owned by ``open_wg_bindspace()``. Default child processes inherit the root +namespace naturally; passing an explicit alternate child ``bindspace`` +continues to use that spawn backend's existing behavior. Bound roots reject +the persistent ``mp_forkserver`` backend because a server started by an +earlier runtime may retain that runtime's network namespace. + +This is the current low-level composition API. A future convenience API +may accept a tunnel-bearing multiaddr, realize its WireGuard bindspace +internally, and supply that live capability to root startup. + +Tunnels and WireGuard +--------------------- + +.. autoclass:: TunnelledAddress + +.. autoclass:: WGTunnelSpec + +.. autoclass:: WGInterfaceConfig + +.. autoclass:: WGPeerConfig + +.. autofunction:: parse_wg_maddr + +.. autofunction:: mk_wg_maddr + +.. autofunction:: strip_tunnels + +.. autofunction:: tunnels_of + +.. autofunction:: open_wg_iface + +.. autofunction:: open_wg_bindspace + +.. autofunction:: read_wg_pubkey + +.. autofunction:: read_wg_peers + +.. autofunction:: verify_wg_peer diff --git a/docs/guide/discovery.rst b/docs/guide/discovery.rst index a14967e0f..cd025f9d0 100644 --- a/docs/guide/discovery.rst +++ b/docs/guide/discovery.rst @@ -264,7 +264,7 @@ terminology is retired: it's *registrar*/*registry* everywhere now substitute "registrar" and you're up to date. .. note:: - Multihoming nerds: ``tractor.discovery`` also ships + Multihoming nerds: ``tractor.net`` ships libp2p-style *multiaddr* helpers — ``mk_maddr()`` and ``parse_maddr()`` — for describing transport endpoints as structured strings. diff --git a/examples/multihost/wg_lan/README.md b/examples/multihost/wg_lan/README.md index 816392f5b..f38912db2 100644 --- a/examples/multihost/wg_lan/README.md +++ b/examples/multihost/wg_lan/README.md @@ -45,12 +45,13 @@ no `wg` codec. So `pyproject.toml` temporarily pins the merge commit in its PEP 621 dependency metadata, and a plain ```bash -uv sync +uv sync --extra wg ``` -gets you a `wg`-aware `multiaddr`. That pin goes away once a -release carries the codec. `py-multibase` is a direct project -dependency, so no separate install command is needed. +gets you a `wg`-aware `multiaddr` plus pyroute2's Linux netlink API. +The multiaddr pin goes away once a release carries the codec. +`py-multibase` is a direct project dependency, so no separate install +command is needed. Without the codec `parse_wg_maddr()` raises immediately with an actionable message — there is deliberately **no** degraded @@ -119,7 +120,7 @@ ping -c1 10.0.11.1 # from B ```bash python -c " -from tractor.discovery import mb_pubkey +from tractor.net import mb_pubkey key = open('wg_pub.key').read().strip() print(mb_pubkey(key)) " @@ -130,27 +131,21 @@ the same string — A's bearer, A's key, A's overlay ep). ## 2. verify the keys -Interface inspection commonly needs `CAP_NET_ADMIN`. Keep that -privileged operation separate from the `tractor` processes: +Both scripts explicitly call `await verify_wg_peer(addr.tunnel)` +before starting `tractor`. The helper validates the maddr's declared +key, reads one `wg0` key snapshot through pyroute2's Linux +generic-netlink API, and accepts the key when it is either the +interface's own public key or one of its configured peers. -```bash -# host A: output must equal the maddr's A_pub key -export WG_KEY_INSPECTION="$(sudo wg show wg0 public-key)" - -# host B: output must contain the maddr's A_pub key -export WG_KEY_INSPECTION="$(sudo wg show wg0 peers)" -``` +This establishes key presence only. It does not enforce a +host-specific local/peer role and does not verify `Endpoint`, +`AllowedIPs`, a recent handshake, or routing. -These checks establish only that host A uses the declared local -key and host B has that key as a configured peer. They do not -verify `Endpoint`, `AllowedIPs`, a recent handshake, or routing. -The exported text contains public keys only. Each script passes it -to `verify_wg_key()` with its host-specific role before starting -`tractor`. Callers that already have permission to inspect the -interface may omit that argument; the helper's direct query is -async and requests cancellation after five seconds. Trio's -subprocess termination escalation can make final process cleanup -take longer than that cancellation deadline. +Interface inspection commonly requires `CAP_NET_ADMIN` in the user +namespace that owns the target network namespace. Run each program in +a security context that already has the required inspection authority. +The helper never invokes `sudo` or `wg(8)`, escalates privileges, or +creates a namespace. ## 3. run @@ -162,11 +157,10 @@ python host_a_srv.py python host_b_client.py ``` -Run both `tractor` programs as the normal application account, -not as root. Privilege is needed only for tunnel setup and the -separate inspection above. If using that preflight, keep the -host-specific `WG_KEY_INSPECTION` value exported in each -program's shell. +Run both `tractor` programs as the normal application account in a +security context with the inspection authority described above. No +`WG_KEY_INSPECTION` export or subprocess preflight is used; tunnel +setup remains out-of-band. Do not run the applications as root. The client binds its own actor listener to `10.0.11.2:0`, while the service actor binds to host A's `10.0.11.1` overlay host with @@ -189,12 +183,13 @@ Four corrections, all from all. `parse_wg_maddr()` now rejects it with an actionable error. 2. **parsing is pure.** #482's helper had the key-check adjacent - to the parse; `verify_wg_key()` is now a separate, explicitly - composed step for inspection-capable callers. A parser that - shells out is a nasty surprise. -3. **no `sudo`.** #482 ran `sudo wg show`; a library/example must - never escalate or run `tractor` as root. Privileged tunnel - setup and key inspection are separate shell steps. + to the parse; async `verify_wg_peer()` is now a separate, + explicitly composed step that the caller invokes. Implicit + kernel inspection from a parser is a nasty surprise. +3. **no `sudo` or subprocess.** #482 ran `sudo wg show`; tractor's + helper reads generic netlink through pyroute2 and never attempts + privilege escalation or namespace creation. The caller must + already have the required inspection authority. 4. **no new `Address` proto-type.** The tunnel rides *beside* the overlay addr in a frozen `TunnelledAddress`, and only `.overlay` crosses into `open_nursery()`. #482 §6 floated a `WGAddress` @@ -203,9 +198,23 @@ Four corrections, all from `_addr_to_transport` wants a `MsgTransport` per addr-type, which `wg` doesn't have. -## next +## root composition + +The `TunnelledAddress`, native maddr parser, bindspace lifecycle, and +explicit pyroute2 verification APIs live in `tractor.net`. Keep the +owning bindspace context outside the root actor so its namespace FD +remains live through complete actor teardown: + +```python +async with tractor.net.open_wg_bindspace( + bindspace_spec, + layers, + role='listen', +) as bindspace: + async with tractor.open_root_actor(bindspace=bindspace): + ... +``` -Layer A's `TunnelledAddress` and native maddr parser now live in -`tractor.discovery`. Next, replace this example's `wg(8)` verification -probe with `pyroute2`, then add `open_bindspace()` `@acm`s which -create/tear down the iface and netns. +The root actor enters before registry or IPC setup and restores the +calling thread's original namespace before the outer bindspace context +removes owned WireGuard and netns resources. diff --git a/examples/multihost/wg_lan/host_a_srv.py b/examples/multihost/wg_lan/host_a_srv.py index fc380d6ba..7c53820a4 100644 --- a/examples/multihost/wg_lan/host_a_srv.py +++ b/examples/multihost/wg_lan/host_a_srv.py @@ -8,18 +8,15 @@ ''' from __future__ import annotations -import os - import tractor import trio -from tractor.discovery import ( +from tractor.net import ( TunnelledAddress, mk_maddr, parse_wg_maddr, + verify_wg_peer, ) -from wg_maddr import verify_wg_key - # bearer = host A's underlay `(ip, wg ListenPort)` # key = host A's OWN tunnel pubkey # overlay = the ep `tractor` binds, on the wg iface's addr @@ -37,12 +34,7 @@ async def echo(msg: str) -> str: async def main(): addr: TunnelledAddress = parse_wg_maddr(WG_MADDR) - inspection: str | None = os.environ.get('WG_KEY_INSPECTION') - if not await verify_wg_key( - addr, - role='local', - inspection=inspection, - ): + if not await verify_wg_peer(addr.tunnel): raise RuntimeError( f'Maddr key is not wg0 local public key!\n' f'maddr: {WG_MADDR}\n' diff --git a/examples/multihost/wg_lan/host_b_client.py b/examples/multihost/wg_lan/host_b_client.py index d2eb75a3b..a08588250 100644 --- a/examples/multihost/wg_lan/host_b_client.py +++ b/examples/multihost/wg_lan/host_b_client.py @@ -6,17 +6,15 @@ ''' from __future__ import annotations -import os - import tractor import trio -from tractor.discovery import ( +from tractor.net import ( TunnelledAddress, parse_wg_maddr, + verify_wg_peer, ) from host_a_srv import echo # noqa: F401 (RPC refs it by mod path) -from wg_maddr import verify_wg_key # same maddr as host A: A's bearer, A's key, A's overlay ep WG_MADDR: str = ( @@ -29,12 +27,7 @@ async def main(): addr: TunnelledAddress = parse_wg_maddr(WG_MADDR) - inspection: str | None = os.environ.get('WG_KEY_INSPECTION') - if not await verify_wg_key( - addr, - role='peer', - inspection=inspection, - ): + if not await verify_wg_peer(addr.tunnel): raise RuntimeError( f'Maddr key is not a configured wg0 peer!\n' f'maddr: {WG_MADDR}\n' diff --git a/examples/multihost/wg_lan/wg_maddr.py b/examples/multihost/wg_lan/wg_maddr.py deleted file mode 100644 index e729c06db..000000000 --- a/examples/multihost/wg_lan/wg_maddr.py +++ /dev/null @@ -1,85 +0,0 @@ -# tractor: distributed structured concurrency. -r''' -Verify `wg` keys 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/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 -from typing import Literal - -import trio - -from tractor.discovery import ( - TunnelledAddress, - WGTunnelSpec, -) - - -async def verify_wg_key( - addr: TunnelledAddress, - role: Literal['local', 'peer'], - iface: str | None = None, - timeout: float = 5, - inspection: str | None = None, -) -> bool: - ''' - Verify the declared key in the role required on this host. - - A bearer host uses `role='local'`; a dialer uses `role='peer'`. - This verifies only key presence. It does not inspect the peer's - endpoint, AllowedIPs, handshake state, or iface addresses. - - `inspection` accepts output captured by a separate privileged - `wg show` step. Without it, query asynchronously for callers - which already have interface-inspection permission. - - 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 - - match role: - case 'local': - field = 'public-key' - case 'peer': - field = 'peers' - case _: - raise ValueError( - f'Unknown WireGuard key role: {role!r}' - ) - - if inspection is None: - with trio.fail_after(timeout): - proc = await trio.run_process( - ['wg', 'show', iface, field], - capture_stdout=True, - check=True, - ) - inspection = proc.stdout.decode() - - if role == 'local': - return spec.peer_pubkey == inspection.strip() - return spec.peer_pubkey in inspection.split() diff --git a/pyproject.toml b/pyproject.toml index 3d3ff579f..22f8cd7b3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -62,6 +62,12 @@ dependencies = [ "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 ------ [dependency-groups] @@ -168,9 +174,8 @@ sync_pause = {requires-python = ">=3.13, <3.14"} # editable = true # ------ tool.uv.sources ------ -# TODO, distributed (multi-host) extensions -# linux kernel networking -# 'pyroute2 +# Linux kernel networking is provided by the optional `wg` extra. +# Add any temporary `pyroute2` source overrides here. # ------ tool.uv.sources ------ diff --git a/tests/discovery/test_registrar.py b/tests/discovery/test_registrar.py index e384c22ba..8fa9e7306 100644 --- a/tests/discovery/test_registrar.py +++ b/tests/discovery/test_registrar.py @@ -18,7 +18,7 @@ from tractor.trionics import collapse_eg from tractor._testing import tractor_test from tractor.discovery._addr import wrap_address -from tractor.discovery._multiaddr import mk_maddr +from tractor.net import mk_maddr import trio diff --git a/tests/discovery/test_tpt_bind_addrs.py b/tests/discovery/test_tpt_bind_addrs.py index 1de45a360..64e57942f 100644 --- a/tests/discovery/test_tpt_bind_addrs.py +++ b/tests/discovery/test_tpt_bind_addrs.py @@ -24,7 +24,7 @@ from tractor.discovery._addr import ( wrap_address, ) -from tractor.discovery._multiaddr import mk_maddr +from tractor.net import mk_maddr from tractor.ipc import _connect_chan from tractor._testing.addr import get_rando_addr diff --git a/tests/ipc/test_channel_tunnel_boundary.py b/tests/ipc/test_channel_tunnel_boundary.py index 0c04b32d5..ccc92205f 100644 --- a/tests/ipc/test_channel_tunnel_boundary.py +++ b/tests/ipc/test_channel_tunnel_boundary.py @@ -7,7 +7,7 @@ import pytest import trio -from tractor.discovery import ( +from tractor.net import ( TunnelledAddress, WGTunnelSpec, tunnels_of, diff --git a/tests/ipc/test_server_tunnel_boundary.py b/tests/ipc/test_server_tunnel_boundary.py index 6c1f6dbbb..b98cc2330 100644 --- a/tests/ipc/test_server_tunnel_boundary.py +++ b/tests/ipc/test_server_tunnel_boundary.py @@ -6,7 +6,8 @@ import trio -from tractor.discovery import ( +from tractor.net import ( + BindspaceRef, TunnelledAddress, WGTunnelSpec, tunnels_of, @@ -18,14 +19,16 @@ _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 module, so retaining a tunnel wrapper there selects `._tunnel` instead of the TCP backend. Start a real listener from the wrapper, assert the resulting `Endpoint` contains only a resolved - `TCPAddress`, and prove the original declaration still carries - its tunnel spec for the future bindspace lifecycle. + `TCPAddress`. Prove `Endpoint.declared_addr` still retains the + original declaration and realized bindspace ref for + diagnostics. This retained metadata does not claim the listener + process entered that namespace. ''' overlay = TCPAddress('127.0.0.1', 0) @@ -34,13 +37,22 @@ def test_server_peels_before_endpoint_construction(): tunnel=WGTunnelSpec( peer_pubkey=_PUBKEY, bearer=('192.168.1.50', 51820), + netns='actor-net', ), ) + ref: BindspaceRef = BindspaceRef( + kind='netns', + key='actor-net', + inode=1234, + ) + declared: TunnelledAddress = tunnelled.with_bindspace_ref( + ref, + ) async def main() -> None: async with open_ipc_server() as server: eps = await server.listen_on( - accept_addrs=[tunnelled], + accept_addrs=[declared], ) assert len(eps) == 1 endpoint = eps[0] @@ -49,9 +61,19 @@ async def main() -> None: _, host, port = endpoint.addr.unwrap() assert host == overlay.unwrap()[1] assert port > 0 - assert endpoint.addr is not tunnelled - assert tunnels_of(tunnelled) == ( - tunnelled.tunnel, + assert endpoint.addr is not declared + assert endpoint.declared_addr is declared + namespace: tuple[str, int] = ('netns', 1234) + 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 '1234' in server_repr + assert tunnels_of(declared) == ( + declared.tunnel, ) server.cancel() diff --git a/tests/msg/test_process_local.py b/tests/msg/test_process_local.py new file mode 100644 index 000000000..c8e220a2e --- /dev/null +++ b/tests/msg/test_process_local.py @@ -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) diff --git a/tests/net/__init__.py b/tests/net/__init__.py new file mode 100644 index 000000000..fabca2e87 --- /dev/null +++ b/tests/net/__init__.py @@ -0,0 +1 @@ +'''Network declaration and lifecycle tests.''' diff --git a/tests/net/test_bindspace.py b/tests/net/test_bindspace.py new file mode 100644 index 000000000..c6423dd81 --- /dev/null +++ b/tests/net/test_bindspace.py @@ -0,0 +1,572 @@ +''' +Bindspace declaration, reference 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.net import ( + Bindspace, + BindspaceOwnership, + BindspaceRef, + BindspaceSpec, + CURRENT_NETNS, + attach_netns, + open_bindspace, + open_netns, +) +from tractor.net import _bindspace +from tractor.msg import ProcessLocal + + +def test_bindspace_declarations_roundtrip() -> None: + ''' + Spawn configuration and realized refs must cross actor IPC. + + Encode both frozen structs through msgpack and decode with their + concrete types, proving names and stable inode refs survive + without carrying any process-local capability state. + + ''' + values: tuple[ + BindspaceSpec|BindspaceRef, + ..., + ] = ( + BindspaceSpec( + kind='netns', + key='tractor-wg0', + lifecycle='open', + ), + BindspaceRef( + kind='netns', + key='tractor-wg0', + inode=1234, + ), + ) + value: BindspaceSpec|BindspaceRef + for value in values: + encoded: bytes = msgspec.msgpack.encode(value) + decoded: BindspaceSpec|BindspaceRef = ( + msgspec.msgpack.decode( + encoded, + type=type(value), + ) + ) + assert decoded == value + + +def test_bindspace_pins_local_capability( + tmp_path: Path, +) -> None: + ''' + A live bindspace pins one exact FD and realized ref. + + Open a stand-in platform FD, record its inode in the realized + ref 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 + bindspace. + + ''' + 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', + ) + ref: BindspaceRef = BindspaceRef( + kind='netns', + key='tractor-wg0', + inode=inode, + ) + bindspace: Bindspace = Bindspace( + spec=spec, + ref=ref, + namespace_fd=namespace_fd, + ownership='owned', + ) + + assert bindspace.spec is spec + assert bindspace.ref is ref + assert bindspace.namespace_fd == namespace_file.fileno() + assert bindspace.ownership == 'owned' + assert isinstance(bindspace, msgspec.Struct) + assert isinstance(bindspace, ProcessLocal) + with pytest.raises( + TypeError, + match='_ProcessLocalToken.*unsupported', + ): + msgspec.msgpack.encode(bindspace) + + +def test_bindspace_rejects_mismatched_ref( + tmp_path: Path, +) -> None: + ''' + A name or inode mismatch would make a bindspace 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 the ref changes below. + ownership: BindspaceOwnership = 'borrowed' + namespace_file: BinaryIO + with token_path.open('rb') as namespace_file: + namespace_fd: int = namespace_file.fileno() + wrong_name: BindspaceRef = BindspaceRef( + kind='netns', + key='other-wg', + inode=token_path.stat().st_ino, + ) + with pytest.raises( + ValueError, + match='Spec.key.*Ref.key', + ): + Bindspace( + spec=spec, + ref=wrong_name, + namespace_fd=namespace_fd, + ownership=ownership, + ) + + wrong_inode: BindspaceRef = BindspaceRef( + kind='netns', + key='tractor-wg0', + inode=token_path.stat().st_ino + 1, + ) + with pytest.raises( + ValueError, + match='FD inode.*reference inode', + ): + Bindspace( + spec=spec, + ref=wrong_inode, + namespace_fd=namespace_fd, + ownership=ownership, + ) + + +@pytest.mark.parametrize( + ('model', 'kwargs', 'match'), + ( + pytest.param( + BindspaceRef, + { + 'kind': 'netns', + 'key': None, + 'inode': None, + }, + 'must be a positive `int`', + id='ref-requires-inode', + ), + pytest.param( + BindspaceSpec, + {'kind': 'vrf'}, + 'Unsupported bindspace kind', + id='spec-rejects-kind', + ), + pytest.param( + BindspaceRef, + { + 'kind': 'vrf', + 'key': 'blue', + 'inode': 1234, + }, + 'Unsupported bindspace kind', + id='ref-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( + BindspaceRef, + { + 'kind': 'netns', + 'key': '', + 'inode': 1234, + }, + 'BindspaceRef.key', + id='ref-rejects-empty-key', + ), + ), +) +def test_bindspace_models_reject_invalid_values( + model: type[BindspaceSpec]|type[BindspaceRef], + 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 + refs 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. + + Opening `/proc/self/ns/net` could pin the thread-group leader's + namespace when this context runs from another thread. Prove the + implementation selects `/proc/thread-self/ns/net`, records the + calling thread's stable inode and borrowed ownership, then closes + the exact descriptor 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 + assert _bindspace._THREAD_NETNS == Path( + '/proc/thread-self/ns/net' + ) + async with open_bindspace(spec) as bindspace: + namespace_fd: int|None = bindspace.namespace_fd + assert namespace_fd is not None + assert bindspace.spec is spec + assert bindspace.ref.key is None + assert bindspace.ref.inode == os.fstat( + namespace_fd + ).st_ino + assert bindspace.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 bindspace: + namespace_fd: int|None = bindspace.namespace_fd + assert namespace_fd is not None + assert bindspace.ref.key == 'tractor-wg0' + assert bindspace.ref.inode == netns_path.stat().st_ino + assert bindspace.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 ref 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 bindspace: + fd: int|None = bindspace.namespace_fd + assert fd is not None + assert bindspace.ownership == 'owned' + assert bindspace.ref.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) diff --git a/tests/discovery/test_multiaddr.py b/tests/net/test_multiaddr.py similarity index 99% rename from tests/discovery/test_multiaddr.py rename to tests/net/test_multiaddr.py index d9029d330..a9b16b6c0 100644 --- a/tests/discovery/test_multiaddr.py +++ b/tests/net/test_multiaddr.py @@ -1,7 +1,6 @@ ''' Multiaddr construction, parsing, and round-trip tests for -`tractor.discovery._multiaddr.mk_maddr()` and -`tractor.discovery._multiaddr.parse_maddr()`. +`tractor.net.mk_maddr()` and `tractor.net.parse_maddr()`. ''' from pathlib import Path @@ -10,20 +9,20 @@ import pytest from multiaddr import Multiaddr -from tractor.discovery import ( +from tractor.net import ( TunnelledAddress, WGTunnelSpec, mb_pubkey, mk_wg_maddr, + mk_maddr, + parse_endpoints, + parse_maddr, parse_wg_maddr, tunnels_of, ) from tractor.ipc._tcp import TCPAddress from tractor.ipc._uds import UDSAddress from tractor.discovery._multiaddr import ( - mk_maddr, - parse_maddr, - parse_endpoints, _tpt_proto_to_maddr, _maddr_to_tpt_proto, ) diff --git a/tests/discovery/test_tunnelled_addr.py b/tests/net/test_tunnelled_addr.py similarity index 71% rename from tests/discovery/test_tunnelled_addr.py rename to tests/net/test_tunnelled_addr.py index e9b6b4713..ed2408dab 100644 --- a/tests/discovery/test_tunnelled_addr.py +++ b/tests/net/test_tunnelled_addr.py @@ -14,7 +14,8 @@ import msgspec import pytest -from tractor.discovery import ( +from tractor.net import ( + BindspaceRef, TunnelledAddress, WGTunnelSpec, mb_pubkey, @@ -27,6 +28,7 @@ wrap_address, ) from tractor.ipc._tcp import TCPAddress +from tractor.ipc._uds import UDSAddress # a valid-looking std-base64 `wg(8)` pubkey (32B -> 44 chars) @@ -174,16 +176,13 @@ def test_namespace_comes_from_the_tunnel( overlay: TCPAddress, ): ''' - First real consumer of `Address.namespace`, spec'd in the - protocol since day one and implemented by no backend. + Plain transport addresses explicitly select no namespace, while + a tunnel can select one for the same concrete overlay. ''' - # XXX, "no backend implements it" is literal — the member - # isn't even declared, so this is `AttributeError` not `None`. - # This assert is the guard: when a backend finally declares - # `.namespace`, it fails and the `getattr()` fallback in - # `TunnelledAddress.namespace` can go. - assert not hasattr(overlay, 'namespace') + uds_addr: UDSAddress = UDSAddress('/tmp', 'tractor-test.sock') + assert overlay.namespace is None + assert uds_addr.namespace is None no_ns = TunnelledAddress( overlay=overlay, @@ -198,6 +197,71 @@ def test_namespace_comes_from_the_tunnel( assert in_ns.namespace == ('netns', 'wg-test') +def test_realized_namespace_uses_stable_ref( + overlay: TCPAddress, +) -> None: + ''' + Realization must retain a stable ref without mutating the maddr. + + Build an unrealized named declaration, annotate it with the + matching realized key and inode, and prove the frozen original is + unchanged. The annotated copy must preserve transport delegation + and expose the stable inode through `.namespace`. Direct msgspec + encoding also proves only serializable ref metadata was retained. + + ''' + declared: TunnelledAddress = TunnelledAddress( + overlay=overlay, + tunnel=WGTunnelSpec( + peer_pubkey=_PUBKEY, + netns='wg-test', + ), + ) + ref: BindspaceRef = BindspaceRef( + kind='netns', + key='wg-test', + inode=1234, + ) + realized: TunnelledAddress = declared.with_bindspace_ref( + ref, + ) + + assert declared.bindspace_ref is None + assert realized.bindspace_ref is ref + assert realized.namespace == ('netns', 1234) + assert realized.overlay is declared.overlay + assert realized.tunnel is declared.tunnel + assert realized.unwrap() == declared.unwrap() + assert realized.bindspace == declared.bindspace + + declared_payload: dict[str, object] = msgspec.msgpack.decode( + msgspec.msgpack.encode(declared) + ) + assert 'bindspace_ref' not in declared_payload + + decoded: dict[str, object] = msgspec.msgpack.decode( + msgspec.msgpack.encode(realized) + ) + assert decoded['bindspace_ref'] == { + 'kind': 'netns', + 'key': 'wg-test', + 'inode': 1234, + } + + mismatched: BindspaceRef = BindspaceRef( + kind='netns', + key='other-netns', + inode=5678, + ) + with pytest.raises( + ValueError, + match='wg-test.*other-netns', + ): + declared.with_bindspace_ref( + mismatched, + ) + + def test_strip_tunnels( tunnelled: TunnelledAddress, overlay: TCPAddress, diff --git a/tests/net/test_wg_config.py b/tests/net/test_wg_config.py new file mode 100644 index 000000000..fab3177c8 --- /dev/null +++ b/tests/net/test_wg_config.py @@ -0,0 +1,163 @@ +''' +Process-local WireGuard interface configuration contracts. + +''' +from __future__ import annotations + +import msgspec +import pytest + +from tractor.net import ( + WGInterfaceConfig, + WGPeerConfig, +) +from tractor.msg import ProcessLocal + + +_PRIVATE_KEY: str = 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=' +_PEER_KEY: str = 'r1LKM1pqhuY9Z6L4y5jQ2fGX67kJSrq5kRV5Jk2ywEo=' +_PRESHARED_KEY: str = 'BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBA=' + + +def test_wg_config_is_process_local_and_redacted() -> None: + ''' + Private WireGuard configuration must neither print nor cross IPC. + + Construct a complete local config and prove its public routing + policy remains inspectable while both keys are absent from repr. + Verify `ProcessLocal` blocks default msgpack encoding. + + ''' + peer: WGPeerConfig = WGPeerConfig( + public_key=_PEER_KEY, + allowed_ips=('10.1.0.0/16', 'fd01::/64'), + endpoint=('192.0.2.1', 51820), + preshared_key=_PRESHARED_KEY, + persistent_keepalive=25, + ) + config: WGInterfaceConfig = WGInterfaceConfig( + private_key=_PRIVATE_KEY, + addresses=('10.0.0.1/24', 'fd00::1/64'), + listen_port=51820, + peers=(peer,), + ) + + config_repr: str = repr(config) + assert isinstance(config, ProcessLocal) + assert _PRIVATE_KEY not in config_repr + assert _PRESHARED_KEY not in config_repr + assert config.addresses[0] in config_repr + assert peer.allowed_ips[0] in config_repr + with pytest.raises( + TypeError, + match='_ProcessLocalToken.*unsupported', + ): + msgspec.msgpack.encode(config) + + +@pytest.mark.parametrize( + ('kwargs', 'error'), + ( + pytest.param( + {'private_key': 'not-base64'}, + ValueError, + id='private-key', + ), + pytest.param( + { + 'private_key': _PRIVATE_KEY, + 'addresses': ('not-an-interface',), + }, + ValueError, + id='address', + ), + pytest.param( + { + 'private_key': _PRIVATE_KEY, + 'listen_port': 65536, + }, + ValueError, + id='listen-port', + ), + pytest.param( + { + 'private_key': _PRIVATE_KEY, + 'peers': ( + WGPeerConfig(public_key=_PEER_KEY), + WGPeerConfig(public_key=_PEER_KEY), + ), + }, + ValueError, + id='duplicate-peer', + ), + ), +) +def test_wg_config_rejects_invalid_values( + kwargs: dict[str, object], + error: type[Exception], +) -> None: + ''' + Invalid config must fail before kernel mutation. + + Parameterize every validated input class and prove direct msgspec + construction cannot carry malformed configuration into a future + pyroute2 interface lifecycle. + + ''' + with pytest.raises(error): + WGInterfaceConfig(**kwargs) # type: ignore[arg-type] + + +@pytest.mark.parametrize( + 'kwargs', + ( + pytest.param( + {'public_key': 'not-base64'}, + id='public-key', + ), + pytest.param( + { + 'public_key': _PEER_KEY, + 'preshared_key': 'not-base64', + }, + id='preshared-key', + ), + pytest.param( + { + 'public_key': _PEER_KEY, + 'allowed_ips': ('not-a-network',), + }, + id='allowed-ip', + ), + pytest.param( + { + 'public_key': _PEER_KEY, + 'endpoint': ('not-an-ip', 51820), + }, + id='endpoint-host', + ), + pytest.param( + { + 'public_key': _PEER_KEY, + 'endpoint': ('192.0.2.1', 65536), + }, + id='endpoint-port', + ), + pytest.param( + { + 'public_key': _PEER_KEY, + 'persistent_keepalive': -1, + }, + id='keepalive', + ), + ), +) +def test_wg_peer_config_rejects_invalid_values( + kwargs: dict[str, object], +) -> None: + ''' + Invalid peer policy must fail before kernel mutation. + + ''' + with pytest.raises(ValueError): + WGPeerConfig(**kwargs) # type: ignore[arg-type] diff --git a/tests/net/test_wg_iface_lifecycle.py b/tests/net/test_wg_iface_lifecycle.py new file mode 100644 index 000000000..f9c78b798 --- /dev/null +++ b/tests/net/test_wg_iface_lifecycle.py @@ -0,0 +1,476 @@ +''' +WireGuard interface policy and owned lifecycle contracts. + +''' +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 BinaryIO + +import pytest +import trio + +import tractor +from tractor.net import ( + Bindspace, + BindspaceRef, + BindspaceSpec, + WGInterfaceConfig, + WGPeerConfig, + WGTunnelSpec, + open_wg_bindspace, + open_wg_iface, +) +from tractor.net import _tunnel + + +_LOCAL_KEY: str = 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=' +_PEER_KEY: str = 'r1LKM1pqhuY9Z6L4y5jQ2fGX67kJSrq5kRV5Jk2ywEo=' + + +def test_wg_iface_settings_follow_role() -> None: + ''' + Listen and dial roles interpret the tunnel bearer differently. + + Prove listen derives its local port from the bearer while dial + applies the bearer as the selected peer's omitted endpoint. Other + explicit peers retain their own endpoint and routing policy. + + ''' + selected: WGPeerConfig = WGPeerConfig( + public_key=_PEER_KEY, + allowed_ips=('10.1.0.0/16',), + ) + other: WGPeerConfig = WGPeerConfig( + public_key=_LOCAL_KEY, + endpoint=('198.51.100.2', 51821), + ) + config: WGInterfaceConfig = WGInterfaceConfig( + private_key=_LOCAL_KEY, + peers=(selected, other), + ) + spec: WGTunnelSpec = WGTunnelSpec( + peer_pubkey=_PEER_KEY, + bearer=('192.0.2.1', 51820), + ) + + listen_port: int|None + listen_peers: tuple[dict[str, object], ...] + listen_port, listen_peers = _tunnel._wg_iface_settings( + spec, + config, + 'listen', + ) + assert listen_port == 51820 + # A listen bearer configures the local port, not a peer endpoint. + assert 'endpoint_addr' not in listen_peers[0] + + dial_port: int|None + dial_peers: tuple[dict[str, object], ...] + dial_port, dial_peers = _tunnel._wg_iface_settings( + spec, + config, + 'dial', + ) + # No local dial listen port was declared; the bearer is remote. + assert dial_port is None + assert dial_peers[0]['endpoint_addr'] == '192.0.2.1' + assert dial_peers[0]['endpoint_port'] == 51820 + assert dial_peers[0]['allowed_ips'] == ['10.1.0.0/16'] + assert dial_peers[1]['endpoint_addr'] == '198.51.100.2' + assert dial_peers[1]['endpoint_port'] == 51821 + + +@pytest.mark.parametrize( + ('config', 'role', 'match'), + ( + pytest.param( + WGInterfaceConfig( + private_key=_LOCAL_KEY, + ), + 'dial', + 'not in.*configured peer keys', + id='missing-dial-peer', + ), + pytest.param( + WGInterfaceConfig( + private_key=_LOCAL_KEY, + listen_port=51821, + ), + 'listen', + '51821.*51820', + id='listen-port-51821-vs-bearer-51820', + ), + pytest.param( + WGInterfaceConfig( + private_key=_LOCAL_KEY, + peers=( + WGPeerConfig( + public_key=_PEER_KEY, + endpoint=('198.51.100.1', 51820), + ), + ), + ), + 'dial', + '198.51.100.1.*192.0.2.1', + id='dial-endpoint-conflict', + ), + ), +) +def test_wg_iface_settings_reject_conflicts( + config: WGInterfaceConfig, + role: str, + match: str, +) -> None: + ''' + Role-dependent conflicts must fail before pyroute2 side effects. + + ''' + spec: WGTunnelSpec = WGTunnelSpec( + peer_pubkey=_PEER_KEY, + bearer=('192.0.2.1', 51820), + ) + with pytest.raises(ValueError, match=match): + _tunnel._wg_iface_settings( + spec, + config, + role, # type: ignore[arg-type] + ) + + +def test_open_wg_iface_shields_cancelled_cleanup( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + ''' + Cancellation after creation must still remove the owned WG iface. + + Pin a stand-in namespace FD and fake privileged create/remove + calls. Cancel inside the yielded context and prove shielded + teardown runs before cancellation leaves the enclosing scope. + + ''' + token_path: Path = tmp_path / 'netns' + token_path.touch() + events: list[str] = [] + + def create( + spec: WGTunnelSpec, + config: WGInterfaceConfig, + bindspace: Bindspace, + listen_port: int|None, + peers: tuple[dict[str, object], ...], + ) -> None: + ''' + Record the validated create request. + + ''' + assert bindspace.namespace_fd is not None + assert listen_port is None + assert peers[0]['public_key'] == _PEER_KEY + events.append('create') + + def remove( + spec: WGTunnelSpec, + bindspace: Bindspace, + ) -> None: + ''' + Record shielded removal after cancellation. + + ''' + events.append('remove') + + monkeypatch.setattr( + _tunnel, + '_sync_create_wg_iface', + create, + ) + monkeypatch.setattr( + _tunnel, + '_sync_remove_wg_iface', + remove, + ) + + namespace_file: BinaryIO + with token_path.open('rb') as namespace_file: + namespace_fd: int = namespace_file.fileno() + bindspace_spec: BindspaceSpec = BindspaceSpec( + kind='netns', + key='tractor-wg0', + ) + bindspace: Bindspace = Bindspace( + spec=bindspace_spec, + ref=BindspaceRef( + kind='netns', + key='tractor-wg0', + inode=os.fstat(namespace_fd).st_ino, + ), + namespace_fd=namespace_fd, + ownership='borrowed', + ) + peer: WGPeerConfig = WGPeerConfig( + public_key=_PEER_KEY, + ) + config: WGInterfaceConfig = WGInterfaceConfig( + private_key=_LOCAL_KEY, + peers=(peer,), + ) + spec: WGTunnelSpec = WGTunnelSpec( + peer_pubkey=_PEER_KEY, + ) + + async def main() -> None: + ''' + Cancel while the fake WG iface is owned. + + ''' + with trio.CancelScope() as scope: + async with open_wg_iface( + spec, + config, + bindspace, + 'dial', + ): + events.append('yield') + scope.cancel() + await trio.sleep_forever() + + trio.run(main) + + assert events == ['create', 'yield', 'remove'] + + +def test_open_wg_bindspace_nests_resource_lifetimes( + monkeypatch: pytest.MonkeyPatch, +) -> None: + ''' + Nested WG interfaces must exit before their bindspace capability. + + Fake two interface layers over one bindspace. Clear the caller's + mutable layer list at bindspace entry, then cancel from inside + the yielded application scope and checkpoint. The trace proves + stack snapshots its declaration before entry, layers enter + outermost-first, cancellation exits them inside-out, and the live + bindspace remains available through every interface exit. + + ''' + events: list[str] = [] + calls: list[ + tuple[ + WGTunnelSpec, + WGInterfaceConfig, + Bindspace, + _tunnel.WGRole, + ] + ] = [] + bindspace_spec: BindspaceSpec = BindspaceSpec( + kind='netns', + ) + bindspace: Bindspace = Bindspace( + spec=bindspace_spec, + ref=BindspaceRef( + kind='netns', + key=None, + inode=1, + ), + namespace_fd=None, + ownership='borrowed', + ) + outer_spec: WGTunnelSpec = WGTunnelSpec( + peer_pubkey=_PEER_KEY, + iface='wg-outer', + ) + inner_spec: WGTunnelSpec = WGTunnelSpec( + peer_pubkey=_LOCAL_KEY, + iface='wg-inner', + ) + outer_config: WGInterfaceConfig = WGInterfaceConfig( + private_key=_LOCAL_KEY, + ) + inner_config: WGInterfaceConfig = WGInterfaceConfig( + private_key=_PEER_KEY, + ) + layers: list[ + tuple[WGTunnelSpec, WGInterfaceConfig] + ] = [ + (outer_spec, outer_config), + (inner_spec, inner_config), + ] + + @acm + async def fake_open_bindspace( + spec: BindspaceSpec, + ) -> AsyncIterator[Bindspace]: + ''' + Yield the stand-in bindspace and record its full lifetime. + + ''' + assert spec is bindspace_spec + events.append('bindspace-enter') + layers.clear() + try: + yield bindspace + finally: + events.append('bindspace-exit') + + @acm + async def fake_open_wg_iface( + spec: WGTunnelSpec, + config: WGInterfaceConfig, + bindspace_arg: Bindspace, + role: _tunnel.WGRole, + ) -> AsyncIterator[WGTunnelSpec]: + ''' + Record one interface's arguments and nested lifetime. + + ''' + calls.append((spec, config, bindspace_arg, role)) + events.append(f'{spec.iface}-enter') + try: + yield spec + finally: + assert bindspace_arg is bindspace + events.append(f'{spec.iface}-exit') + + monkeypatch.setattr( + _tunnel, + 'open_bindspace', + fake_open_bindspace, + ) + monkeypatch.setattr( + _tunnel, + 'open_wg_iface', + fake_open_wg_iface, + ) + + async def main() -> None: + ''' + Cancel while both interface layers are live. + + ''' + with trio.CancelScope() as scope: + async with open_wg_bindspace( + bindspace_spec, + layers, + 'dial', + ) as opened_bindspace: + assert opened_bindspace is bindspace + events.append('yield') + scope.cancel() + await trio.sleep_forever() + + trio.run(main) + + assert calls == [ + (outer_spec, outer_config, bindspace, 'dial'), + (inner_spec, inner_config, bindspace, 'dial'), + ] + assert events == [ + 'bindspace-enter', + 'wg-outer-enter', + 'wg-inner-enter', + 'yield', + 'wg-inner-exit', + 'wg-outer-exit', + 'bindspace-exit', + ] + + +@pytest.mark.skipif( + sys.platform != 'linux', + reason='network namespaces are Linux-only', +) +def test_public_wg_bindspace_scopes_root_actor( + monkeypatch: pytest.MonkeyPatch, + tpt_proto: str, +) -> None: + ''' + Public network contexts must fully enclose the root runtime. + + Attach the real current netns through `tractor.net`, fake only WG + interface provisioning, and open a real root actor with the yielded + `Bindspace`. The trace and inode checks prove interface setup wraps + actor startup, the runtime occupies the realized bindspace, and root + restoration finishes before network-resource teardown. + + ''' + events: list[str] = [] + bindspace_spec: BindspaceSpec = BindspaceSpec( + kind='netns', + lifecycle='attach', + ) + tunnel_spec: WGTunnelSpec = WGTunnelSpec( + peer_pubkey=_PEER_KEY, + iface='wg-root', + ) + config: WGInterfaceConfig = WGInterfaceConfig( + private_key=_LOCAL_KEY, + ) + + @acm + async def fake_open_wg_iface( + spec: WGTunnelSpec, + iface_config: WGInterfaceConfig, + bindspace: Bindspace, + role: _tunnel.WGRole, + ) -> AsyncIterator[WGTunnelSpec]: + ''' + Trace one WG layer around the real root actor lifetime. + + ''' + assert spec is tunnel_spec + assert iface_config is config + assert role == 'listen' + assert bindspace.namespace_fd is not None + events.append('wg-enter') + try: + yield spec + finally: + events.append('wg-exit') + + monkeypatch.setattr( + _tunnel, + 'open_wg_iface', + fake_open_wg_iface, + ) + + async def main() -> None: + ''' + Compose the public network and root actor context managers. + + ''' + async with tractor.net.open_wg_bindspace( + bindspace_spec=bindspace_spec, + layers=((tunnel_spec, config),), + role='listen', + ) as bindspace: + events.append('bindspace-open') + async with tractor.open_root_actor( + bindspace=bindspace, + enable_transports=[tpt_proto], + ): + events.append('root-open') + assert bindspace.namespace_fd is not None + assert os.fstat( + bindspace.namespace_fd, + ).st_ino == bindspace.ref.inode + assert Path( + '/proc/thread-self/ns/net' + ).stat().st_ino == bindspace.ref.inode + events.append('root-closed') + + events.append('bindspace-closed') + + trio.run(main) + assert events == [ + 'wg-enter', + 'bindspace-open', + 'root-open', + 'root-closed', + 'wg-exit', + 'bindspace-closed', + ] diff --git a/tests/net/test_wg_inspection.py b/tests/net/test_wg_inspection.py new file mode 100644 index 000000000..7a883941e --- /dev/null +++ b/tests/net/test_wg_inspection.py @@ -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.net import ( + read_wg_peers, + read_wg_pubkey, + verify_wg_peer, + WGTunnelSpec, +) +from tractor.net 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) diff --git a/tests/test_lazy_imports.py b/tests/test_lazy_imports.py index 75c238bf3..b2b61f357 100644 --- a/tests/test_lazy_imports.py +++ b/tests/test_lazy_imports.py @@ -12,6 +12,7 @@ get_type_hints, ) +import tractor from tractor.discovery import ( _addr, _multiaddr, @@ -44,8 +45,8 @@ def test_lazy_to_asyncio_package_api(): Before the lazy conversion, package import side effects exposed `to_asyncio` to `dir()` and wildcard imports. Exercise those APIs in cold interpreters so this test proves normal `import tractor` - leaves `asyncio` unloaded, while discovery and wildcard access - still advertise and resolve the public submodule. + leaves `asyncio` unloaded, while introspection and wildcard + access still advertise and resolve the public submodule. ''' cold = run_cold_import( @@ -102,6 +103,12 @@ def test_cold_import_budget(): 'bidict', 'colorlog', 'multiaddr', + 'multibase', + 'pyroute2', + 'tractor.discovery._multiaddr', + 'tractor.net', + 'tractor.net._bindspace', + 'tractor.net._tunnel', 'wrapt', ) code = ( @@ -136,6 +143,123 @@ def test_cold_import_budget(): ) +def test_lazy_net_package_api(): + ''' + Keep the public network package cold until symbol access. + + The old discovery re-exports imported bindspace, tunnel, + multiaddr and optional dependencies while initializing a package. + Import `tractor.net` in a clean interpreter, inspect its public + surface, and prove no implementation or optional dependency was + loaded. Then resolve one symbol from each backing module and + prove the facade caches each value while preserving boundaries. + + ''' + modules: tuple[str, ...] = ( + 'tractor.net._bindspace', + 'tractor.net._tunnel', + 'tractor.discovery._multiaddr', + 'multiaddr', + 'multibase', + 'pyroute2', + ) + cold: dict[str, object] = run_cold_import( + 'import json, sys; import tractor.net as net; ' + f'names = {modules!r}; ' + 'print(json.dumps({' + '"public": all(name in dir(net) for name in net.__all__), ' + '"loaded": [name for name in names if name in sys.modules]' + '}))' + ) + assert cold == { + 'public': True, + 'loaded': [], + } + + resolved: dict[str, object] = run_cold_import( + 'import json, sys; import tractor.net as net; ' + 'bindspace = net.BindspaceSpec; ' + 'bindspace_cached = net.BindspaceSpec is bindspace; ' + 'maddr = net.mk_maddr; ' + 'maddr_cached = net.mk_maddr is maddr; ' + 'tunnel = net.WGTunnelSpec; ' + 'tunnel_cached = net.WGTunnelSpec is tunnel; ' + 'print(json.dumps({' + '"bindspace_cached": bindspace_cached, ' + '"maddr_cached": maddr_cached, ' + '"tunnel_cached": tunnel_cached, ' + '"bindspace_module": bindspace.__module__, ' + '"maddr_module": maddr.__module__, ' + '"tunnel_module": tunnel.__module__, ' + '"multiaddr_loaded": "multiaddr" in sys.modules, ' + '"pyroute2_loaded": "pyroute2" in sys.modules' + '}))' + ) + assert resolved == { + 'bindspace_cached': True, + 'maddr_cached': True, + 'tunnel_cached': True, + 'bindspace_module': 'tractor.net._bindspace', + 'maddr_module': 'tractor.discovery._multiaddr', + 'tunnel_module': 'tractor.net._tunnel', + 'multiaddr_loaded': False, + 'pyroute2_loaded': False, + } + + +def test_net_root_export_and_old_discovery_surface(): + ''' + Publish networking only from its approved namespace. + + Before extraction, unshipped network names and implementation + modules lived under `tractor.discovery`. Exercise root attribute + and wildcard access in clean interpreters, proving `tractor.net` + is discoverable and cached without loading implementations. Also + prove the old exports are absent and their modules no longer + resolve, preventing accidental compatibility aliases. + + ''' + root: dict[str, object] = run_cold_import( + 'import json, sys, tractor; ' + 'advertised = "net" in dir(tractor); ' + 'net = tractor.net; ' + 'print(json.dumps({' + '"advertised": advertised, ' + '"cached": tractor.net is net, ' + '"module": net.__name__, ' + '"bindspace_loaded": ' + '"tractor.net._bindspace" in sys.modules, ' + '"tunnel_loaded": "tractor.net._tunnel" in sys.modules' + '}))' + ) + assert root == { + 'advertised': True, + 'cached': True, + 'module': 'tractor.net', + 'bindspace_loaded': False, + 'tunnel_loaded': False, + } + + old: dict[str, object] = run_cold_import( + 'import importlib.util, json; ' + 'import tractor.discovery as discovery; ' + 'old_names = ("Bindspace", "TunnelledAddress", ' + '"mk_maddr", "parse_maddr", "parse_endpoints"); ' + 'old_modules = ("tractor.discovery._bindspace", ' + '"tractor.discovery._tunnel"); ' + 'print(json.dumps({' + '"exports": [name for name in old_names ' + 'if hasattr(discovery, name)], ' + '"modules": [name for name in old_modules ' + 'if importlib.util.find_spec(name) is not None]' + '}))' + ) + assert old == { + 'exports': [], + 'modules': [], + } + + def test_lazy_annotation_names_resolve(): ''' Resolve annotations without importing optional dependencies. @@ -157,4 +281,7 @@ def test_lazy_annotation_names_resolve(): assert get_type_hints(_addr.Address.get_random)[ 'current_actor' ] is Any + assert get_type_hints(tractor.open_root_actor)[ + 'bindspace' + ] == Any|None assert _addr.__annotations__['_address_types'].startswith('dict') diff --git a/tests/test_netns_spawn.py b/tests/test_netns_spawn.py new file mode 100644 index 000000000..6c62f7cb7 --- /dev/null +++ b/tests/test_netns_spawn.py @@ -0,0 +1,1658 @@ +''' +Pre-runtime Linux network-namespace entry validation. + +''' +from __future__ import annotations + +import errno +from functools import partial +import os +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile +from types import SimpleNamespace +from typing import ( + Any, + BinaryIO, +) + +import pytest +import trio + +import tractor +from tractor import ( + _child, + _root, +) +from tractor.devx import _proctitle +from tractor.net._bindspace import ( + Bindspace, + BindspaceRef, + BindspaceSpec, +) +from tractor.msg import Aid +from tractor.spawn import ( + _entry, + _mp, + _netns, + _spawn, + _trio, +) +from tractor.trionics import patches + + +_SELF_NETNS_PATH = Path('/proc/thread-self/ns/net') +_SELF_FD_DIR = Path('/proc/self/fd') +_linux_netns_only = pytest.mark.skipif( + sys.platform != 'linux', + reason='Linux network namespace API', +) + + +def _assert_fd_closed(namespace_fd: int) -> None: + ''' + Assert that bootstrap consumed its child-owned descriptor. + + ''' + with pytest.raises(OSError) as exc_info: + os.fstat(namespace_fd) + + assert exc_info.value.errno == errno.EBADF + + +def _fds_referencing( + reference_fd: int, +) -> set[int]: + ''' + Find this process's FDs for the same open kernel object. + + Snapshotting the matching descriptor numbers around a spawn lets + the E2E test detect a leaked `os.dup()` entry without replacing + `open_process()` or observing the child's descriptor table. + + ''' + reference_stat: os.stat_result = os.fstat(reference_fd) + matching_fds: set[int] = set() + fd_path: Path + for fd_path in _SELF_FD_DIR.iterdir(): + try: + open_fd: int = int(fd_path.name) + open_stat: os.stat_result = os.fstat(open_fd) + except (OSError, ValueError): + continue + + if ( + open_stat.st_dev == reference_stat.st_dev + and + open_stat.st_ino == reference_stat.st_ino + ): + matching_fds.add(open_fd) + + return matching_fds + + +def _bindspace_for_fd(namespace_fd: int) -> Bindspace: + ''' + Build one borrowed stand-in netns capability around a real FD. + + ''' + key: str = 'spawn-test-netns' + inode: int = os.fstat(namespace_fd).st_ino + return Bindspace( + spec=BindspaceSpec( + kind='netns', + key=key, + ), + ref=BindspaceRef( + kind='netns', + key=key, + inode=inode, + ), + namespace_fd=namespace_fd, + ownership='borrowed', + ) + + +def _run_in_unshared_netns( + test_name: str, + reexec_var: str, +) -> bool: + ''' + Re-exec one E2E test with disposable user and net namespaces. + + Return `True` in the outer pytest process after nested pytest + succeeds. Return `False` inside that nested process so the caller + performs the privileged namespace transitions itself. + ''' + if os.environ.get(reexec_var) == '1': + return False + + unshare_path: str|None = shutil.which('unshare') + if unshare_path is None: + pytest.skip('`unshare` is unavailable') + + # Give nested pytest `CAP_SYS_ADMIN` only inside a disposable + # user namespace. Probe separately so hosts disabling + # unprivileged user namespaces skip cleanly. + probe = subprocess.run( + [ + unshare_path, + '--user', + '--map-root-user', + '--net', + 'true', + ], + capture_output=True, + text=True, + check=False, + ) + if probe.returncode: + reason: str = probe.stderr.strip() + pytest.skip( + f'unprivileged user/net namespaces unavailable: ' + f'{reason}' + ) + + nested_env: dict[str, str] = dict(os.environ) + nested_env[reexec_var] = '1' + nested_env['VIRTUAL_ENV'] = sys.prefix + nested_rt_dir: Path = Path( + tempfile.mkdtemp(prefix='tne-') + ) + nested_env['XDG_RUNTIME_DIR'] = str(nested_rt_dir) + python_bin: str = str(Path(sys.executable).parent) + nested_env['PATH'] = ( + python_bin + + os.pathsep + + nested_env['PATH'] + ) + test_id: str = f'tests/test_netns_spawn.py::{test_name}' + try: + subprocess.run( + [ + unshare_path, + '--user', + '--map-root-user', + '--net', + sys.executable, + '-m', + 'pytest', + test_id, + '--spawn-backend=trio', + '--tpt-proto=uds', + '-x', + '--tb=short', + '--no-header', + '--timeout=30', + ], + env=nested_env, + check=True, + ) + finally: + shutil.rmtree(nested_rt_dir) + + return True + + +class _MockIpcServer: + ''' + Provide the peer-event state used by `trio_proc()` tests. + + ''' + def __init__(self) -> None: + self._peer_connected: dict[ + tuple[str, str], + trio.Event, + ] = {} + + async def wait_for_peer( + self, + child_uid: tuple[str, str], + ) -> tuple[trio.Event, object]: + ''' + Model a child that dies or fails before its handshake. + + ''' + await trio.sleep_forever() + + +def _netns_bootstrap_from_cmd( + command: list[str], +) -> tuple[int, int]: + ''' + Parse the namespace tuple that the exec child would receive. + + ''' + arg_index: int = command.index('--netns_bootstrap') + return _child.parse_netns_bootstrap(command[arg_index + 1]) + + +class _SpawnTestNursery: + ''' + Track provisional Trio child publication during transport tests. + + ''' + def __init__(self) -> None: + self._actor = SimpleNamespace( + ipc_server=_MockIpcServer(), + ) + self._children: dict[ + tuple[str, str], + tuple, + ] = {} + + def _register_child( + self, + subactor: object, + proc: object, + portal: object|None, + ) -> tuple[trio.Event, trio.Event, bool]: + ''' + Publish one provisional child after its peer event exists. + + ''' + uid: tuple[str, str] = subactor.aid.uid + assert uid in self._actor.ipc_server._peer_connected + assert portal is None + self._children[uid] = (subactor, proc, portal) + return (trio.Event(), trio.Event(), False) + + +def _spawn_test_subactor(uid: tuple[str, str]) -> SimpleNamespace: + ''' + Build the actor fields reached before a failed Trio handshake. + + ''' + return SimpleNamespace( + aid=Aid( + name=uid[0], + uuid=uid[1], + ), + loglevel=None, + pformat=lambda: uid[0], + ) + + +async def _report_child_netns( + inherited_fd: int, +) -> tuple[int, int]: + ''' + Report namespace and inherited-FD inodes from a spawned actor. + + `_consume_netns_bootstrap()` has already entered the target netns + and closed its bootstrap FD before this RPC can run. The unrelated + `inherited_fd` must remain open because the caller included it in + `proc_kwargs['pass_fds']` before Trio appended the netns FD. + + ''' + child_netns_fd: int = os.open( + _SELF_NETNS_PATH, + os.O_RDONLY, + ) + try: + child_netns_inode: int = os.fstat(child_netns_fd).st_ino + inherited_inode: int = os.fstat(inherited_fd).st_ino + return child_netns_inode, inherited_inode + finally: + os.close(child_netns_fd) + + +@_linux_netns_only +def test_root_netns_same_namespace_skips_setns( + monkeypatch: pytest.MonkeyPatch, +) -> None: + ''' + Root entry into the current netns must not need privilege. + + Pin the real current namespace and arm `enter_netns()` as a + failure sentinel. The context validates through a duplicate, + yield the current inode without calling `setns()`, preserve the + source capability, and close the duplicates on normal exit. + ''' + namespace_fd: int = os.open( + _SELF_NETNS_PATH, + os.O_RDONLY, + ) + bindspace: Bindspace = _bindspace_for_fd(namespace_fd) + initial_fds: set[int] = _fds_referencing(namespace_fd) + + def fail_enter_netns( + inherited_fd: int, + inode: int, + ) -> int: + ''' + Reject a privileged transition for the already-current netns. + + ''' + raise AssertionError('same-netns entry called `setns()`') + + monkeypatch.setattr(_netns, 'enter_netns', fail_enter_netns) + try: + with _netns._enter_netns_temporarily( + bindspace, + ) as entered_inode: + assert entered_inode == bindspace.ref.inode + assert os.fstat(namespace_fd).st_ino == entered_inode + # The target duplicate and original-netns snapshot both + # reference the already-current namespace. + assert len(_fds_referencing(namespace_fd)) == ( + len(initial_fds) + 2 + ) + + assert _fds_referencing(namespace_fd) == initial_fds + finally: + os.close(namespace_fd) + + +@_linux_netns_only +def test_root_netns_restores_after_body_error( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + ''' + A root-body failure must restore netns before it escapes. + + Use distinct regular files as deterministic namespace stand-ins, + replace only `enter_netns()`, and raise a unique error from the + context body. The recorded transitions prove target entry then + original restoration. FD snapshots prove neither temporary handle + leaks, while the caller-owned target FD remains live. + ''' + original_path: Path = tmp_path / 'original-netns' + target_path: Path = tmp_path / 'target-netns' + original_path.touch() + target_path.touch() + namespace_fd: int = os.open(target_path, os.O_RDONLY) + bindspace: Bindspace = _bindspace_for_fd(namespace_fd) + initial_fds: set[int] = _fds_referencing(namespace_fd) + transitions: list[int] = [] + body_error = RuntimeError('root body failed') + + def fake_enter_netns( + inherited_fd: int, + inode: int, + ) -> int: + ''' + Record each verified target or restoration descriptor. + + ''' + assert os.fstat(inherited_fd).st_ino == inode + transitions.append(inode) + return inode + + monkeypatch.setattr(_netns, '_SELF_NETNS', original_path) + monkeypatch.setattr(_netns, 'enter_netns', fake_enter_netns) + try: + with pytest.raises(RuntimeError) as exc_info: + with _netns._enter_netns_temporarily(bindspace): + raise body_error + + assert exc_info.value is body_error + # The fake records target entry before the body, then original + # restoration during context exit. + assert transitions == [ + bindspace.ref.inode, + original_path.stat().st_ino, + ] + assert _fds_referencing(namespace_fd) == initial_fds + assert os.fstat(namespace_fd).st_ino == bindspace.ref.inode + finally: + os.close(namespace_fd) + + +@_linux_netns_only +def test_root_netns_restores_on_trio_cancellation( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + ''' + Trio cancellation must not interrupt root-netns restoration. + + Use deterministic namespace stand-ins and cancel the task inside + `_enter_root_bindspace()` immediately before an explicit Trio + checkpoint. The enclosing `CancelScope` catches cancellation only + after async-context exit. Two recorded sync transitions and exact + FD state then prove restoration and close completed first. + ''' + original_path: Path = tmp_path / 'cancel-original-netns' + target_path: Path = tmp_path / 'cancel-target-netns' + original_path.touch() + target_path.touch() + namespace_fd: int = os.open(target_path, os.O_RDONLY) + bindspace: Bindspace = _bindspace_for_fd(namespace_fd) + initial_fds: set[int] = _fds_referencing(namespace_fd) + transitions: list[int] = [] + + def fake_enter_netns( + inherited_fd: int, + inode: int, + ) -> int: + ''' + Record target entry and original-netns restoration. + + ''' + assert os.fstat(inherited_fd).st_ino == inode + transitions.append(inode) + return inode + + monkeypatch.setattr(_netns, '_SELF_NETNS', original_path) + monkeypatch.setattr(_netns, 'enter_netns', fake_enter_netns) + + async def main() -> None: + ''' + Deliver cancellation at a checkpoint inside the netns scope. + + ''' + with trio.CancelScope() as cancel_scope: + async with _root._enter_root_bindspace(bindspace): + cancel_scope.cancel() + await trio.lowlevel.checkpoint() + + assert cancel_scope.cancelled_caught + + try: + trio.run(main) + # Target entry is recorded first; original-netns restoration + # is recorded when `_enter_root_bindspace()` exits. + assert transitions == [ + bindspace.ref.inode, + original_path.stat().st_ino, + ] + assert _fds_referencing(namespace_fd) == initial_fds + assert os.fstat(namespace_fd).st_ino == bindspace.ref.inode + finally: + os.close(namespace_fd) + + +@_linux_netns_only +@pytest.mark.parametrize('body_fails', (False, True)) +def test_root_netns_restore_error_precedence( + body_fails: bool, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + ''' + Netns restoration failure must not hide a root-body failure. + + Model target entry as successful and fail the second transition, + which is restoration. The normal-body case must propagate that + restoration error. The failing-body case must instead preserve + its unique error and attach restoration failure as a note. In both + schedules an exact target-FD snapshot proves cleanup still closes + the context's duplicates. + ''' + original_path: Path = tmp_path / 'failed-restore-original' + target_path: Path = tmp_path / 'failed-restore-target' + original_path.touch() + target_path.touch() + namespace_fd: int = os.open(target_path, os.O_RDONLY) + bindspace: Bindspace = _bindspace_for_fd(namespace_fd) + initial_fds: set[int] = _fds_referencing(namespace_fd) + body_error = RuntimeError('root body failed first') + restore_error = RuntimeError('root netns restore failed') + transitions: int = 0 + + def fail_restore( + inherited_fd: int, + inode: int, + ) -> int: + ''' + Enter the target once, then fail original-netns restoration. + + ''' + nonlocal transitions + assert os.fstat(inherited_fd).st_ino == inode + transitions += 1 + if transitions == 2: + raise restore_error + return inode + + monkeypatch.setattr(_netns, '_SELF_NETNS', original_path) + monkeypatch.setattr(_netns, 'enter_netns', fail_restore) + expected_error: RuntimeError = ( + body_error + if body_fails + else restore_error + ) + try: + with pytest.raises(RuntimeError) as exc_info: + with _netns._enter_netns_temporarily(bindspace): + if body_fails: + raise body_error + + assert exc_info.value is expected_error + assert transitions == 2 + if body_fails: + assert body_error.__notes__ + assert 'restore the original' in body_error.__notes__[0] + assert repr(restore_error) in body_error.__notes__[0] + assert _fds_referencing(namespace_fd) == initial_fds + finally: + os.close(namespace_fd) + + +@_linux_netns_only +def test_root_netns_requires_live_bindspace_fd() -> None: + ''' + Root entry cannot use `BindspaceRef.inode` without a live FD. + + Construct a valid ref-only `Bindspace` and enter the real root + namespace scope directly. The concrete live-FD error must occur + before namespace capture, probes, sockets, or actor runtime work. + ''' + key: str = 'missing-root-netns' + bindspace = Bindspace( + spec=BindspaceSpec( + kind='netns', + key=key, + ), + ref=BindspaceRef( + kind='netns', + key=key, + inode=1, + ), + # A stored inode cannot authorize namespace entry. + namespace_fd=None, + ownership='borrowed', + ) + + with pytest.raises( + ValueError, + match='bindspace.namespace_fd.*live netns FD', + ): + # Scope entry must reject the missing live handle. + with _netns._enter_netns_temporarily(bindspace): + pytest.fail('root scope accepted a ref-only bindspace') + + +@_linux_netns_only +def test_root_netns_rejects_closed_bindspace_fd() -> None: + ''' + A stale integer is not a live root-netns capability. + + Construct a `Bindspace` while its real current-netns FD is open, + close that caller-owned descriptor, then attempt root entry. The + concrete live-FD error proves `os.dup()` validates the descriptor + at entry time instead of trusting construction-time metadata. + ''' + namespace_fd: int = os.open( + _SELF_NETNS_PATH, + os.O_RDONLY, + ) + bindspace: Bindspace = _bindspace_for_fd(namespace_fd) + os.close(namespace_fd) + + with pytest.raises( + ValueError, + match='bindspace.namespace_fd.*live FD', + ): + with _netns._enter_netns_temporarily(bindspace): + pytest.fail('root scope accepted a closed bindspace FD') + + +@_linux_netns_only +def test_bound_root_rejects_persistent_forkserver( + monkeypatch: pytest.MonkeyPatch, +) -> None: + ''' + A persistent multiprocessing forkserver process can retain a + previous root's netns. + + Select `mp_forkserver` before opening a later bound root, modeling + reuse of the forkserver process which `multiprocessing` creates + once and uses for later child starts. The root API must reject that + backend before namespace entry or runtime startup, preventing + default children from silently inheriting its stale namespace. + + ''' + namespace_fd: int = os.open( + _SELF_NETNS_PATH, + os.O_RDONLY, + ) + bindspace: Bindspace = _bindspace_for_fd(namespace_fd) + monkeypatch.setattr( + _spawn, + '_spawn_method', + 'mp_forkserver', + ) + + async def main() -> None: + ''' + Reject the unsafe backend at root-context entry. + + ''' + with pytest.raises( + NotImplementedError, + match='persistent forkserver', + ): + async with tractor.open_root_actor( + bindspace=bindspace, + ): + pytest.fail('bound root started under mp_forkserver') + + try: + trio.run(main) + assert os.fstat(namespace_fd).st_ino == bindspace.ref.inode + finally: + os.close(namespace_fd) + + +@_linux_netns_only +def test_enter_netns_rejects_mismatched_inherited_fd( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + ''' + A stale inherited FD must not enter a replacement namespace. + + Open a real stand-in FD, declare a different expected inode and + replace `os.setns()` with a failure sentinel. The inode check must + reject the capability before any irreversible namespace entry. + + ''' + token_path: Path = tmp_path / 'netns' + token_path.touch() + + def fail_setns(namespace_fd: int, nstype: int) -> None: + raise AssertionError('`setns()` must not be called') + + monkeypatch.setattr(_netns.os, 'setns', fail_setns) + namespace_file: BinaryIO + with token_path.open('rb') as namespace_file: + inode: int = token_path.stat().st_ino + with pytest.raises( + ValueError, + match=f'{inode}.*{inode + 1}', + ): + _netns.enter_netns( + namespace_file.fileno(), + # Deliberately differ from `token_path`'s inode. + inode + 1, + ) + + +@_linux_netns_only +def test_enter_netns_verifies_post_entry_inode( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + ''' + Successful `setns()` is insufficient without post-entry proof. + + Use a real inherited FD and fake only the privileged syscall and + `/proc/thread-self/ns/net` observation. The calls prove both + hooks execute and `CLONE_NEWNET` constrains the namespace type; + the returned inode proves bootstrap observed the expected netns. + + ''' + token_path: Path = tmp_path / 'netns' + token_path.touch() + setns_calls: list[tuple[int, int]] = [] + stat_calls: list[Path] = [] + + def fake_setns(namespace_fd: int, nstype: int) -> None: + setns_calls.append((namespace_fd, nstype)) + + def fake_stat(path: Path) -> SimpleNamespace: + stat_calls.append(path) + return SimpleNamespace(st_ino=inode) + + namespace_file: BinaryIO + with token_path.open('rb') as namespace_file: + namespace_fd: int = namespace_file.fileno() + inode: int = token_path.stat().st_ino + monkeypatch.setattr(_netns.os, 'setns', fake_setns) + monkeypatch.setattr( + type(_netns._SELF_NETNS), + 'stat', + fake_stat, + ) + + entered_inode: int = _netns.enter_netns( + namespace_fd, + inode, + ) + + assert setns_calls == [ + (namespace_fd, _netns.os.CLONE_NEWNET), + ] + assert stat_calls == [_netns._SELF_NETNS] + assert entered_inode == inode + + +@_linux_netns_only +def test_enter_netns_rejects_wrong_post_entry_namespace( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + ''' + Bootstrap must stop when the process lands in an unexpected netns. + + Let the inherited FD check and fake syscall succeed, then report a + different `/proc/thread-self/ns/net` inode. The guard must raise + instead of allowing actor runtime sockets to start in the wrong + namespace. + + ''' + token_path: Path = tmp_path / 'netns' + token_path.touch() + + def fake_setns(namespace_fd: int, nstype: int) -> None: + return None + + monkeypatch.setattr( + _netns.os, + 'setns', + fake_setns, + ) + + namespace_file: BinaryIO + with token_path.open('rb') as namespace_file: + inode: int = token_path.stat().st_ino + + def fake_stat(path: Path) -> SimpleNamespace: + return SimpleNamespace(st_ino=inode + 1) + + monkeypatch.setattr( + type(_netns._SELF_NETNS), + 'stat', + fake_stat, + ) + with pytest.raises( + RuntimeError, + match=f'{inode + 1}.*{inode}', + ): + _netns.enter_netns( + namespace_file.fileno(), + # Deliberately differ from `fake_stat()`'s inode + 1. + inode, + ) + + +def test_empty_netns_bootstrap_is_a_noop( + monkeypatch: pytest.MonkeyPatch, +) -> None: + ''' + Ordinary child startup must not attempt namespace entry. + + Leave the optional capability unset and arm `enter_netns()` as a + failure sentinel. The bootstrap boundary must return without any + syscall or descriptor ownership work for existing spawn callers. + + ''' + def fail_enter_netns(namespace_fd: int, inode: int) -> int: + ''' + Reject namespace entry without an explicit capability. + + ''' + raise AssertionError('empty bootstrap attempted netns entry') + + monkeypatch.setattr(_entry, 'enter_netns', fail_enter_netns) + + assert _entry._consume_netns_bootstrap(None) is None + + +@pytest.mark.parametrize('namespace_fd', (-1, True, '1')) +def test_invalid_netns_fd_is_never_closed( + namespace_fd: object, + monkeypatch: pytest.MonkeyPatch, +) -> None: + ''' + Invalid descriptor values must not reach the OS close boundary. + + Feed negative, boolean, and non-integer values through the atomic + capability. Preserve the namespace primitive's validation error + without letting `bool` alias stdout or allowing cleanup to mask the + primary failure. + + ''' + entry_error = ValueError('invalid netns capability') + + def fail_enter_netns(namespace_fd: int, inode: int) -> int: + ''' + Raise the primary namespace bootstrap error. + + ''' + raise entry_error + + def fail_close(inherited_fd: int) -> None: + ''' + Reject cleanup for a value that cannot be an owned FD. + + ''' + raise AssertionError('invalid namespace FD reached close') + + monkeypatch.setattr(_entry, 'enter_netns', fail_enter_netns) + monkeypatch.setattr( + _entry, + 'os', + SimpleNamespace(close=fail_close), + ) + + with pytest.raises(ValueError) as exc_info: + _entry._consume_netns_bootstrap( + (namespace_fd, 1), # type: ignore[arg-type] + ) + + assert exc_info.value is entry_error + + +def test_netns_entry_error_survives_close_failure( + monkeypatch: pytest.MonkeyPatch, +) -> None: + ''' + Descriptor cleanup must not mask the primary bootstrap failure. + + Raise a unique entry error for an oversized positive integer whose + cleanup also raises `OverflowError`. The entry error must escape + with cleanup context attached instead of being replaced by the + close failure. + + ''' + namespace_fd: int = 1 << 100 + entry_error = ValueError('invalid netns capability') + + def fail_enter_netns(inherited_fd: int, inode: int) -> int: + ''' + Raise the primary namespace bootstrap error. + + ''' + assert inherited_fd == namespace_fd + raise entry_error + + monkeypatch.setattr(_entry, 'enter_netns', fail_enter_netns) + + with pytest.raises(ValueError) as exc_info: + _entry._consume_netns_bootstrap((namespace_fd, 1)) + + assert exc_info.value is entry_error + assert entry_error.__notes__ + assert 'close inherited namespace FD' in entry_error.__notes__[0] + assert 'OverflowError' in entry_error.__notes__[0] + + +def test_trio_child_cli_forwards_netns_bootstrap( + monkeypatch: pytest.MonkeyPatch, +) -> None: + ''' + The exec child must retain one atomic FD-and-inode capability. + + Supply the tuple exactly as the Trio parent emits it and replace + `_actor_child_main()` before any runtime work. The captured kwargs + prove argparse does not split, reorder, or drop either value while + forwarding the capability to the child-owned cleanup boundary. + + ''' + calls: list[dict[str, object]] = [] + uid: tuple[str, str] = ('cli-netns-child', 'test') + parent_addr: tuple[str, int] = ('127.0.0.1', 1616) + bootstrap: tuple[int, int] = (12, 3456) + + def fake_actor_child_main(**kwargs: object) -> None: + ''' + Capture parsed child-bootstrap arguments without starting Trio. + + ''' + calls.append(kwargs) + + monkeypatch.setattr( + _child, + '_actor_child_main', + fake_actor_child_main, + ) + + _child.main([ + '--uid', + str(uid), + '--parent_addr', + str(parent_addr), + '--netns_bootstrap', + str(bootstrap), + ]) + + assert calls == [{ + 'uid': uid, + 'loglevel': None, + 'parent_addr': parent_addr, + 'infect_asyncio': False, + 'spawn_method': 'trio', + 'netns_bootstrap': bootstrap, + }] + + +def test_trio_spawn_requires_live_bindspace_fd() -> None: + ''' + A `BindspaceRef` alone cannot let a child enter its namespace. + + Construct a valid `Bindspace` with its required identity metadata + but no open namespace FD. Calling `trio_proc()` must fail before + `open_process()` because an inode identifies a namespace but does + not provide an open handle that the child can inherit. + + ''' + key: str = 'missing-spawn-netns' + bindspace = Bindspace( + spec=BindspaceSpec( + kind='netns', + key=key, + ), + # A realized bindspace always retains identity metadata; this + # test isolates the missing live-FD condition. + ref=BindspaceRef( + kind='netns', + key=key, + inode=1, + ), + namespace_fd=None, + ownership='borrowed', + ) + uid: tuple[str, str] = ('missing-netns-fd', 'test') + + async def main() -> None: + ''' + Reject the ref-only capability before `open_process()`. + + ''' + with pytest.raises( + ValueError, + match='bindspace.namespace_fd.*required', + ): + await _trio.trio_proc( + name=uid[0], + actor_nursery=_SpawnTestNursery(), + subactor=_spawn_test_subactor(uid), + errors={}, + bind_addrs=[], + parent_addr=('127.0.0.1', 1616), + _runtime_vars={}, + bindspace=bindspace, + ) + + trio.run(main) + + +def test_trio_spawn_relays_bindspace_to_child_actor( + tmp_path: Path, + start_method: str, + tpt_proto: str, +) -> None: + ''' + Move a subactor into the relayed bindspace netns. + + Re-exec this one test inside an unprivileged user/net namespace, + then move the nested pytest parent into a second netns. The actor + initially inherits the second namespace but receives an FD for the + first. UDS keeps the parent handshake reachable across the netns + boundary. The child reports its resulting namespace inode and a + caller-supplied inherited FD over a real `Portal`, proving the exec + CLI, merged `pass_fds`, `setns()`, handshake, and RPC path. + + ''' + if start_method != 'trio': + pytest.skip('bindspace FD relay is implemented by Trio spawn') + + if _run_in_unshared_netns( + test_name=( + 'test_trio_spawn_relays_bindspace_to_child_actor' + ), + reexec_var='TRACTOR_TEST_NETNS_E2E_REEXEC', + ): + return + + assert start_method == 'trio' + assert tpt_proto == 'uds' + + # Namespace creation/realization is outside this transport slice: + # production spawn accepts an already-open `namespace_fd`. Build + # both disposable namespaces directly for this E2E boundary. + target_netns_fd: int = os.open( + _SELF_NETNS_PATH, + os.O_RDONLY, + ) + target_netns_inode: int = os.fstat(target_netns_fd).st_ino + initial_target_fds: set[int] = _fds_referencing( + target_netns_fd, + ) + assert target_netns_fd in initial_target_fds + + # Move the parent to a second netns after retaining an FD for the + # first. The child must use that FD to differ from its parent. + os.unshare(os.CLONE_NEWNET) + parent_netns_fd: int = os.open( + _SELF_NETNS_PATH, + os.O_RDONLY, + ) + parent_netns_inode: int = os.fstat(parent_netns_fd).st_ino + assert parent_netns_inode != target_netns_inode + + inherited_path: Path = tmp_path / 'caller-pass-fd' + inherited_path.touch() + inherited_fd: int = os.open(inherited_path, os.O_RDONLY) + bindspace: Bindspace = _bindspace_for_fd(target_netns_fd) + + async def main() -> None: + ''' + Start the child and receive its namespace observations by RPC. + + ''' + async with tractor.open_nursery() as actor_nursery: + portal: tractor.Portal = await actor_nursery.start_actor( + 'netns-bootstrap-child', + bindspace=bindspace, + enable_modules=[__name__], + proc_kwargs={ + 'pass_fds': (inherited_fd,), + }, + ) + report: tuple[int, int] = await portal.run( + _report_child_netns, + inherited_fd=inherited_fd, + ) + + ( + child_netns_inode, + inherited_inode, + ) = report + assert child_netns_inode == target_netns_inode + assert child_netns_inode != parent_netns_inode + assert inherited_inode == inherited_path.stat().st_ino + await portal.cancel_actor() + + try: + trio.run(main) + # Any `os.dup(target_netns_fd)` entry made for child exec must + # now be absent from the parent's descriptor table. + assert _fds_referencing(target_netns_fd) == initial_target_fds + # Both descriptors supplied by this parent remain open after + # the child exits; only the temporary `os.dup()` FD is closed. + assert os.fstat(target_netns_fd).st_ino == bindspace.ref.inode + assert os.fstat(inherited_fd).st_ino == inherited_path.stat().st_ino + finally: + os.close(parent_netns_fd) + os.close(target_netns_fd) + os.close(inherited_fd) + + +def test_root_actor_enters_and_restores_bindspace( + tpt_proto: str, +) -> None: + ''' + `open_root_actor()` must enter its supplied networking bindspace. + + Re-exec under an unprivileged user/net namespace, retain that + first netns as the target, then move nested pytest into a second. + A real UDS root actor must run its body in the target inode and + keep the source capability open. After full actor teardown, exact + FD and inode assertions prove its duplicate did not leak and the + caller thread returned to the second/original netns. + ''' + if _run_in_unshared_netns( + test_name='test_root_actor_enters_and_restores_bindspace', + reexec_var='TRACTOR_TEST_ROOT_NETNS_E2E_REEXEC', + ): + return + + assert tpt_proto == 'uds' + target_netns_fd: int = os.open( + _SELF_NETNS_PATH, + os.O_RDONLY, + ) + target_netns_inode: int = os.fstat(target_netns_fd).st_ino + reffed_tgt_fds: set[int] = _fds_referencing( + target_netns_fd, + ) + bindspace: Bindspace = _bindspace_for_fd(target_netns_fd) + + # Pin the first disposable netns through `target_netns_fd`, then + # move the caller into a distinct second netns. This gives the root + # one real target to enter and one real caller netns to restore. + os.unshare(os.CLONE_NEWNET) + original_netns_fd: int = os.open( + _SELF_NETNS_PATH, + os.O_RDONLY, + ) + original_netns_inode: int = os.fstat(original_netns_fd).st_ino + assert original_netns_inode != target_netns_inode + + async def main() -> None: + ''' + Inspect the real root runtime inside the target netns. + + ''' + async with tractor.open_root_actor( + bindspace=bindspace, + enable_transports=['uds'], + ): + body_netns_inode: int = _SELF_NETNS_PATH.stat().st_ino + assert body_netns_inode == target_netns_inode + assert os.fstat(target_netns_fd).st_ino == ( + target_netns_inode + ) + + try: + trio.run(main) + assert _SELF_NETNS_PATH.stat().st_ino == original_netns_inode + assert _fds_referencing( + target_netns_fd, + ) == reffed_tgt_fds + assert os.fstat(target_netns_fd).st_ino == target_netns_inode + finally: + os.close(original_netns_fd) + os.close(target_netns_fd) + + +def test_trio_spawn_failure_closes_child_netns_fd_in_parent( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + ''' + A failed exec must close the child netns FD in the parent process. + + Raise one unique error from `open_process()` after capturing and + validating the FD made by `os.dup(Bindspace.namespace_fd)`. Since + exec fails, no child inherits it. The backend must close that + parent descriptor, preserve the original error, leave the original + bindspace FD open, and avoid removing a child record that was never + added to `ActorNursery._children`. + + ''' + namespace_path: Path = tmp_path / 'failed-trio-bindspace' + namespace_path.touch() + namespace_fd: int = os.open(namespace_path, os.O_RDONLY) + bindspace: Bindspace = _bindspace_for_fd(namespace_fd) + uid: tuple[str, str] = ('failed-netns-exec', 'test') + child_fds: list[int] = [] + open_error = OSError('could not exec child') + + async def fail_open_process( + command: list[str], + **kwargs: object, + ) -> trio.Process: + ''' + Fail after checking the child FD in `pass_fds` and the CLI. + + ''' + child_fd, expected_inode = _netns_bootstrap_from_cmd(command) + assert kwargs['pass_fds'] == (child_fd,) + assert os.fstat(child_fd).st_ino == expected_inode + child_fds.append(child_fd) + raise open_error + + monkeypatch.setattr( + _trio.trio.lowlevel, + 'open_process', + fail_open_process, + ) + actor_nursery = _SpawnTestNursery() + + async def main() -> None: + ''' + Exercise cleanup before Trio child publication. + + ''' + with pytest.raises(OSError) as exc_info: + await _trio.trio_proc( + name=uid[0], + actor_nursery=actor_nursery, + subactor=_spawn_test_subactor(uid), + errors={}, + bind_addrs=[], + parent_addr=('127.0.0.1', 1616), + _runtime_vars={}, + bindspace=bindspace, + ) + + assert exc_info.value is open_error + + try: + trio.run(main) + assert len(child_fds) == 1 + _assert_fd_closed(child_fds[0]) + assert os.fstat(namespace_fd).st_ino == bindspace.ref.inode + assert actor_nursery._children == {} + finally: + os.close(namespace_fd) + + +def test_trio_spawn_cancel_closes_child_netns_fd_in_parent( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + ''' + Cancellation during exec must close the parent-side child netns FD. + + Park `open_process()` after it receives the descriptor made by + `os.dup(Bindspace.namespace_fd)`, then cancel the task running + `trio_proc()`. The controlled event fixes the cancellation point + inside the open call. Cleanup must close that descriptor in the + parent while preserving the original bindspace FD; no process or + `ActorNursery._children` entry exists to reap at this schedule. + + ''' + namespace_path: Path = tmp_path / 'cancelled-trio-bindspace' + namespace_path.touch() + namespace_fd: int = os.open(namespace_path, os.O_RDONLY) + bindspace: Bindspace = _bindspace_for_fd(namespace_fd) + uid: tuple[str, str] = ('cancelled-netns-exec', 'test') + open_called = trio.Event() + child_fds: list[int] = [] + + async def park_open_process( + command: list[str], + **kwargs: object, + ) -> trio.Process: + ''' + Record the child FD, then signal that the open call is parked. + + ''' + child_fd, expected_inode = _netns_bootstrap_from_cmd(command) + assert kwargs['pass_fds'] == (child_fd,) + assert os.fstat(child_fd).st_ino == expected_inode + child_fds.append(child_fd) + open_called.set() + try: + await trio.sleep_forever() + except trio.Cancelled: + raise + + monkeypatch.setattr( + _trio.trio.lowlevel, + 'open_process', + park_open_process, + ) + actor_nursery = _SpawnTestNursery() + + async def run_spawn() -> None: + ''' + Keep cancellation propagation explicit at the backend task. + + ''' + try: + await _trio.trio_proc( + name=uid[0], + actor_nursery=actor_nursery, + subactor=_spawn_test_subactor(uid), + errors={}, + bind_addrs=[], + parent_addr=('127.0.0.1', 1616), + _runtime_vars={}, + bindspace=bindspace, + ) + except trio.Cancelled: + raise + + async def main() -> None: + ''' + Cancel only after `open_process()` owns the checkpoint. + + ''' + async with trio.open_nursery() as nursery: + nursery.start_soon(run_spawn) + await open_called.wait() + nursery.cancel_scope.cancel() + + try: + trio.run(main) + assert len(child_fds) == 1 + _assert_fd_closed(child_fds[0]) + assert os.fstat(namespace_fd).st_ino == bindspace.ref.inode + assert actor_nursery._children == {} + finally: + os.close(namespace_fd) + + +def test_mp_spawn_rejects_bindspace_transport( + tmp_path: Path, +) -> None: + ''' + Unimplemented MP FD transfer must fail before process creation. + + Supply one valid live bindspace directly to the multiprocessing + backend. Until spawn/forkserver reduction gives the child exclusive + descriptor ownership, both variants must raise the same actionable + error instead of silently booting an actor in the parent's netns. + + ''' + namespace_path: Path = tmp_path / 'mp-bindspace' + namespace_path.touch() + namespace_fd: int = os.open(namespace_path, os.O_RDONLY) + bindspace: Bindspace = _bindspace_for_fd(namespace_fd) + + async def main() -> None: + ''' + Invoke the backend before any multiprocessing context access. + + ''' + with pytest.raises( + NotImplementedError, + match='multiprocessing spawn backends', + ): + await _mp.mp_proc( + name='unsupported-netns-child', + actor_nursery=None, # type: ignore[arg-type] + subactor=None, # type: ignore[arg-type] + errors={}, + bind_addrs=[], + parent_addr=('127.0.0.1', 1616), + _runtime_vars={}, + bindspace=bindspace, + ) + + try: + trio.run(main) + assert os.fstat(namespace_fd).st_ino == bindspace.ref.inode + finally: + os.close(namespace_fd) + + +@pytest.mark.parametrize('backend', ('mp', 'trio')) +def test_child_entry_consumes_netns_before_runtime( + backend: str, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + ''' + Child bootstrap must enter its netns before runtime side effects. + + Give each child entrypoint an exclusively owned stand-in FD. Fake + only namespace entry and every later bootstrap boundary, requiring + the FD to remain open during entry but be closed before actor state, + logging, multiprocessing setup, frame hiding, or `trio.run()`. + This proves verified entry and capability release are one + synchronous prefix of both child startup paths. + + ''' + token_path: Path = tmp_path / f'{backend}-netns' + token_path.touch() + namespace_fd: int = os.open(token_path, os.O_RDONLY) + expected_inode: int = os.fstat(namespace_fd).st_ino + events: list[str] = [] + + def fake_enter_netns( + inherited_fd: int, + inode: int, + ) -> int: + ''' + Record verified entry while the capability remains open. + + ''' + assert os.fstat(inherited_fd).st_ino == expected_inode + assert inherited_fd == namespace_fd + assert inode == expected_inode + events.append('enter-netns') + return inode + + def record( + event: str, + *args: object, + **kwargs: object, + ) -> None: + ''' + Record one post-entry operation after proving FD release. + + ''' + _assert_fd_closed(namespace_fd) + events.append(event) + + class ActorSpy: + ''' + Record multiprocessing actor-state initialization. + + ''' + loglevel = None + uid = ('netns-child', 'test') + _infected_aio = False + + def __setattr__( + self, + name: str, + value: object, + ) -> None: + ''' + Observe the first multiprocessing entrypoint mutation. + + ''' + if name == '_forkserver_info': + record('forkserver-info') + object.__setattr__(self, name, value) + + class StateSpy: + ''' + Record actor publication into runtime-global state. + + ''' + def __setattr__( + self, + name: str, + value: object, + ) -> None: + ''' + Observe `_state._current_actor` publication. + + ''' + record('runtime-state') + object.__setattr__(self, name, value) + + def fake_current_process() -> str: + ''' + Return one display value for multiprocessing startup logging. + + ''' + return 'fake-child-process' + + def fake_start_method(start_method: str) -> SimpleNamespace: + ''' + Record multiprocessing setup after namespace entry. + + ''' + record('start-method') + return SimpleNamespace( + current_process=fake_current_process, + ) + + def fake_actor(**kwargs: object) -> ActorSpy: + ''' + Record Trio child actor construction after namespace entry. + + ''' + record('actor-construction') + return ActorSpy() + + monkeypatch.setattr(_entry, 'enter_netns', fake_enter_netns) + monkeypatch.setattr(_entry, '_state', StateSpy()) + monkeypatch.setattr( + _entry._frame_stack, + 'hide_runtime_frames', + partial(record, 'hide-frames'), + ) + monkeypatch.setattr( + _entry.trio, + 'run', + partial(record, 'trio-run'), + ) + monkeypatch.setattr( + _entry, + 'log', + SimpleNamespace( + info=partial(record, 'log'), + cancel=partial(record, 'log'), + error=partial(record, 'log'), + ), + ) + monkeypatch.setattr( + _spawn, + 'try_set_start_method', + fake_start_method, + ) + monkeypatch.setattr( + patches, + 'apply_all', + partial(record, 'trio-patches'), + ) + monkeypatch.setattr(_child, 'Actor', fake_actor) + monkeypatch.setattr( + _proctitle, + 'set_actor_proctitle', + partial(record, 'proctitle'), + ) + monkeypatch.setattr( + _child, + '_trio_main', + partial(record, 'trio-main'), + ) + + actor: Any = ActorSpy() + bootstrap: tuple[int, int] = ( + namespace_fd, + expected_inode, + ) + if backend == 'mp': + _entry._mp_main( + actor, + [], + (None, None, None, None, None), + 'mp_spawn', + netns_bootstrap=bootstrap, + ) + first_runtime_event: str = 'forkserver-info' + terminal_event: str = 'trio-run' + else: + _child._actor_child_main( + uid=actor.uid, + loglevel=actor.loglevel, + parent_addr=None, + infect_asyncio=False, + netns_bootstrap=bootstrap, + ) + first_runtime_event = 'trio-patches' + terminal_event = 'trio-main' + + assert events[:2] == [ + 'enter-netns', + first_runtime_event, + ] + assert events.count(terminal_event) == 1 + _assert_fd_closed(namespace_fd) + + +@pytest.mark.parametrize('backend', ('mp', 'trio')) +def test_child_entry_failure_closes_netns_fd_before_runtime( + backend: str, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + ''' + Failed namespace entry must close its FD and abort child startup. + + Raise a unique error from the namespace boundary while a real + stand-in FD is open. Arm each entrypoint's first later operation as + a failure sentinel, then prove the original error escapes, the + descriptor is closed, and no actor, multiprocessing, frame, or + Trio runtime initialization begins. + + ''' + token_path: Path = tmp_path / f'{backend}-failed-netns' + token_path.touch() + namespace_fd: int = os.open(token_path, os.O_RDONLY) + expected_inode: int = os.fstat(namespace_fd).st_ino + entry_error = RuntimeError('netns entry failed') + events: list[str] = [] + + def fail_enter_netns( + inherited_fd: int, + inode: int, + ) -> int: + ''' + Fail entry while proving the child-owned FD is still open. + + ''' + assert os.fstat(inherited_fd).st_ino == expected_inode + assert inherited_fd == namespace_fd + assert inode == expected_inode + events.append('enter-netns') + raise entry_error + + def fail_after_entry(*args: object, **kwargs: object) -> None: + ''' + Reject any runtime operation after failed namespace entry. + + ''' + raise AssertionError('child runtime started after netns failure') + + class ActorSpy: + ''' + Reject multiprocessing actor-state initialization. + + ''' + loglevel = None + uid = ('failed-netns-child', 'test') + + def __setattr__( + self, + name: str, + value: object, + ) -> None: + ''' + Reject the first multiprocessing entrypoint mutation. + + ''' + if name == '_forkserver_info': + fail_after_entry() + object.__setattr__(self, name, value) + + monkeypatch.setattr(_entry, 'enter_netns', fail_enter_netns) + monkeypatch.setattr( + _entry._frame_stack, + 'hide_runtime_frames', + fail_after_entry, + ) + monkeypatch.setattr( + _spawn, + 'try_set_start_method', + fail_after_entry, + ) + monkeypatch.setattr( + patches, + 'apply_all', + fail_after_entry, + ) + monkeypatch.setattr(_child, 'Actor', fail_after_entry) + monkeypatch.setattr( + _proctitle, + 'set_actor_proctitle', + fail_after_entry, + ) + monkeypatch.setattr( + _child, + '_trio_main', + fail_after_entry, + ) + + actor: Any = ActorSpy() + bootstrap: tuple[int, int] = ( + namespace_fd, + expected_inode, + ) + with pytest.raises(RuntimeError) as exc_info: + if backend == 'mp': + _entry._mp_main( + actor, + [], + (None, None, None, None, None), + 'mp_spawn', + netns_bootstrap=bootstrap, + ) + else: + _child._actor_child_main( + uid=actor.uid, + loglevel=actor.loglevel, + parent_addr=None, + infect_asyncio=False, + netns_bootstrap=bootstrap, + ) + + assert exc_info.value is entry_error + assert events == ['enter-netns'] + _assert_fd_closed(namespace_fd) diff --git a/tests/test_spawning.py b/tests/test_spawning.py index ed3829d00..1573d385b 100644 --- a/tests/test_spawning.py +++ b/tests/test_spawning.py @@ -10,15 +10,25 @@ """ from functools import partial +from types import SimpleNamespace from typing import ( Any, ) +from unittest.mock import ( + AsyncMock, + MagicMock, +) import pytest import trio import tractor +from tractor._exceptions import ActorFailure from tractor._testing import tractor_test +from tractor.spawn import ( + _spawn, + _trio, +) data_to_pass_down = { 'doggy': 10, @@ -26,6 +36,396 @@ } +def test_peer_handshake_wins_child_boot_race() -> None: + ''' + A connected child must cancel process-death monitoring cleanly. + + Start the death waiter first and hold it at a checkpoint. Let the + fake server then return one peer event and channel. The helper must + preserve the normal handshake result and cancel the losing process + waiter before its nursery exits. The fake explicitly catches and + re-raises `trio.Cancelled` to prove cancellation caused its exit. + + ''' + async def main() -> None: + ''' + Control the handshake-first schedule with Trio events. + + ''' + uid: tuple[str, str] = ('handshake-child', 'test') + death_started = trio.Event() + death_cancelled = trio.Event() + peer_event = trio.Event() + channel = object() + + async def wait_for_peer( + child_uid: tuple[str, str], + ) -> tuple[trio.Event, object]: + ''' + Return the peer only after death monitoring is active. + + ''' + assert child_uid == uid + await death_started.wait() + return (peer_event, channel) + + async def wait_for_death() -> int: + ''' + Block until the winning handshake cancels this waiter. + + ''' + death_started.set() + try: + await trio.sleep_forever() + except trio.Cancelled: + death_cancelled.set() + raise + + result = await _spawn.wait_for_peer_or_proc_death( + ipc_server=SimpleNamespace( + wait_for_peer=wait_for_peer, + ), + uid=uid, + proc_wait=wait_for_death, + proc_repr='handshake-proc', + ) + + assert result == (peer_event, channel) + assert death_cancelled.is_set() + + trio.run(main) + + +def test_child_death_wins_peer_handshake_race() -> None: + ''' + Pre-handshake child death must fail startup instead of hanging. + + Start the peer waiter first and leave it parked like + `IPCServer.wait_for_peer()` on an unset event. Return a distinctive + process status from the competing waiter, then prove the helper + cancels the handshake and raises `ActorFailure` with child identity, + status, and process diagnostics. + + ''' + async def main() -> None: + ''' + Control the death-first schedule with Trio events. + + ''' + uid: tuple[str, str] = ('dead-child', 'test') + handshake_started = trio.Event() + handshake_cancelled = trio.Event() + + async def wait_for_peer( + child_uid: tuple[str, str], + ) -> tuple[trio.Event, object]: + ''' + Park until process death cancels this handshake waiter. + + ''' + assert child_uid == uid + handshake_started.set() + try: + await trio.sleep_forever() + except trio.Cancelled: + handshake_cancelled.set() + raise + + async def wait_for_death() -> int: + ''' + Report child death after handshake monitoring is active. + + ''' + await handshake_started.wait() + return 23 + + with pytest.raises(ActorFailure) as exc_info: + await _spawn.wait_for_peer_or_proc_death( + ipc_server=SimpleNamespace( + wait_for_peer=wait_for_peer, + ), + uid=uid, + proc_wait=wait_for_death, + proc_repr='dead-proc', + ) + + message: str = str(exc_info.value) + assert repr(uid) in message + assert 'died during boot' in message + assert '(rc=23)' in message + assert 'parent-handshake' in message + assert 'dead-proc' in message + assert handshake_cancelled.is_set() + + trio.run(main) + + +def test_child_death_wins_simultaneous_boot_results() -> None: + ''' + Observed process death must outrank a simultaneous handshake. + + Hold both fake waits behind one barrier with cancellation shielding, + then release them together so both publish a committed result before + sibling cancellation takes effect. Because the child has exited + before receiving its `SpawnSpec`, bootstrap must raise `ActorFailure` + rather than return its briefly established channel. + + ''' + async def main() -> None: + ''' + Release both boot outcomes from one controlled barrier. + + ''' + uid: tuple[str, str] = ('simultaneous-child', 'test') + handshake_ready = trio.Event() + death_ready = trio.Event() + release = trio.Event() + peer_event = trio.Event() + + async def wait_for_peer( + child_uid: tuple[str, str], + ) -> tuple[trio.Event, object]: + ''' + Publish a handshake despite sibling cancellation. + + ''' + assert child_uid == uid + handshake_ready.set() + with trio.CancelScope(shield=True): + await release.wait() + return (peer_event, object()) + + async def wait_for_death() -> int: + ''' + Publish process death despite sibling cancellation. + + ''' + death_ready.set() + with trio.CancelScope(shield=True): + await release.wait() + return 0 + + async def release_both() -> None: + ''' + Open the barrier only after both waiters are parked. + + ''' + await handshake_ready.wait() + await death_ready.wait() + release.set() + + async with trio.open_nursery() as nursery: + nursery.start_soon(release_both) + with pytest.raises( + ActorFailure, + match=r'simultaneous-child.*rc=0', + ): + await _spawn.wait_for_peer_or_proc_death( + ipc_server=SimpleNamespace( + wait_for_peer=wait_for_peer, + ), + uid=uid, + proc_wait=wait_for_death, + ) + + trio.run(main) + + +@pytest.mark.parametrize('failing_waiter', ('handshake', 'death')) +def test_child_boot_race_preserves_waiter_error( + failing_waiter: str, +) -> None: + ''' + Waiter failures must retain their original exception identity. + + Park the non-failing sibling and raise one unique error from either + the peer or process waiter. The helper's internal nursery must + cancel the sibling and re-raise that exact exception instead of + wrapping it in an `ExceptionGroup`. + + ''' + async def main() -> None: + ''' + Trigger one selected waiter after its sibling starts. + + ''' + uid: tuple[str, str] = ('errored-child', 'test') + sibling_started = trio.Event() + wait_error = RuntimeError(f'{failing_waiter} failed') + + async def wait_for_peer( + child_uid: tuple[str, str], + ) -> tuple[trio.Event, object]: + ''' + Raise or park according to the selected peer schedule. + + ''' + assert child_uid == uid + if failing_waiter == 'handshake': + await sibling_started.wait() + raise wait_error + + sibling_started.set() + await trio.sleep_forever() + + async def wait_for_death() -> int: + ''' + Raise or park according to the selected process schedule. + + ''' + if failing_waiter == 'death': + await sibling_started.wait() + raise wait_error + + sibling_started.set() + await trio.sleep_forever() + + with pytest.raises(RuntimeError) as exc_info: + await _spawn.wait_for_peer_or_proc_death( + ipc_server=SimpleNamespace( + wait_for_peer=wait_for_peer, + ), + uid=uid, + proc_wait=wait_for_death, + ) + + assert exc_info.value is wait_error + + trio.run(main) + + +def test_trio_proc_cleans_failed_child_peer_event( + monkeypatch: pytest.MonkeyPatch, +) -> None: + ''' + Death-first Trio startup must release provisional peer state. + + Return one already-dead fake process while its server handshake + parks forever. The fake nursery proves the peer event exists before + provisional child publication. After `ActorFailure`, both that + exact event and the provisional child record must be gone so repeated + failed spawns cannot leak server state. + + ''' + uid: tuple[str, str] = ('dead-trio-child', 'test') + proc: trio.Process = MagicMock(spec=trio.Process) + proc.pid = 1234 + proc.wait = AsyncMock(return_value=23) + proc.poll.return_value = 23 + proc.__str__.return_value = 'dead-trio-proc' + + class FakeServer: + ''' + Hold the peer registry used during Trio child startup. + + ''' + def __init__(self) -> None: + self._peer_connected: dict[ + tuple[str, str], + trio.Event, + ] = {} + + async def wait_for_peer( + self, + child_uid: tuple[str, str], + ) -> tuple[trio.Event, object]: + ''' + Park like a child that never reaches its handshake. + + ''' + assert child_uid == uid + await trio.sleep_forever() + + server = FakeServer() + + class FakeNursery: + ''' + Track provisional child publication and cleanup. + + ''' + def __init__(self) -> None: + self._actor = SimpleNamespace(ipc_server=server) + self._children: dict[tuple[str, str], tuple] = {} + + def _register_child( + self, + subactor: object, + proc: object, + portal: object|None, + ) -> tuple[trio.Event, trio.Event, bool]: + ''' + Require peer-event registration before child publication. + + ''' + assert uid in server._peer_connected + assert portal is None + self._children[uid] = (subactor, proc, portal) + return (trio.Event(), trio.Event(), False) + + async def fake_open_process( + command: list[str], + **kwargs: object, + ) -> trio.Process: + ''' + Return a process whose death wins the bootstrap race. + + ''' + assert command + return proc + + async def fake_wait_for_debugger(**kwargs: object) -> None: + ''' + Keep hard-reap cleanup deterministic and non-interactive. + + ''' + return None + + monkeypatch.setattr( + _trio.trio.lowlevel, + 'open_process', + fake_open_process, + ) + monkeypatch.setattr( + _trio.debug, + 'maybe_wait_for_debugger', + fake_wait_for_debugger, + ) + + nursery = FakeNursery() + subactor = SimpleNamespace( + aid=tractor.msg.Aid( + name=uid[0], + uuid=uid[1], + ), + loglevel=None, + pformat=lambda: 'dead-trio-child', + ) + + async def main() -> None: + ''' + Run the full Trio backend through death-first cleanup. + + ''' + with pytest.raises( + ActorFailure, + match=r'dead-trio-child.*rc=23', + ): + await _trio.trio_proc( + name=uid[0], + actor_nursery=nursery, + subactor=subactor, + errors={}, + bind_addrs=[], + parent_addr=('127.0.0.1', 1616), + _runtime_vars={}, + ) + + trio.run(main) + + assert server._peer_connected == {} + assert nursery._children == {} + + async def run_same_func_in_child( should_be_root: bool, data: dict, diff --git a/tractor/__init__.py b/tractor/__init__.py index 3568d9bd7..94fba96a8 100644 --- a/tractor/__init__.py +++ b/tractor/__init__.py @@ -18,6 +18,7 @@ tractor: structured concurrent ``trio``-"actors". """ +from types import ModuleType as _ModuleType from ._clustering import ( open_actor_cluster as open_actor_cluster, @@ -82,6 +83,7 @@ for name in globals() if not name.startswith('_') ) + ( + 'net', 'to_asyncio', ) @@ -92,21 +94,18 @@ def __dir__() -> list[str]: def __getattr__(name: str): ''' - PEP 562 lazy sub-module loading, presently only for - `.to_asyncio` which (transitively) imports `asyncio` - itself: a non-trivial multi-ms chunk of the eager - `import tractor` cost (gh #470) unneeded by - `trio`-only apps. + PEP 562 lazy public sub-package loading. - Any `tractor.to_asyncio.` access (or a - `from tractor import to_asyncio`) still works, the - sub-mod is simply imported on first-access instead - of at pkg-import time. + `tractor.to_asyncio` transitively imports `asyncio`, while + `tractor.net` owns optional network dependencies. Neither is + needed by most applications merely importing the root package. ''' - if name == 'to_asyncio': + if name in ('net', 'to_asyncio'): from importlib import import_module - return import_module('.to_asyncio', __name__) + module: _ModuleType = import_module(f'.{name}', __name__) + globals()[name] = module + return module raise AttributeError( f'module {__name__!r} has no attribute {name!r}' diff --git a/tractor/_child.py b/tractor/_child.py index a5bd346f6..3c5a1c30a 100644 --- a/tractor/_child.py +++ b/tractor/_child.py @@ -26,7 +26,10 @@ from typing import TYPE_CHECKING from .runtime._runtime import Actor -from .spawn._entry import _trio_main +from .spawn._entry import ( + _consume_netns_bootstrap, + _trio_main, +) if TYPE_CHECKING: from .discovery._addr import UnwrappedAddress @@ -46,12 +49,41 @@ def parse_ipaddr(arg): return arg +def parse_netns_bootstrap(arg: str) -> tuple[int, int]: + ''' + Parse one atomic inherited namespace capability. + + Descriptor and inode validation remains in + `_consume_netns_bootstrap()` so every valid descriptor-shaped + input reaches its exact child-owned cleanup boundary. + + ''' + try: + value: object = literal_eval(arg) + except (ValueError, SyntaxError) as exc: + raise argparse.ArgumentTypeError( + 'netns bootstrap must be an `(fd, inode)` tuple' + ) from exc + + if ( + not isinstance(value, tuple) + or + len(value) != 2 + ): + raise argparse.ArgumentTypeError( + 'netns bootstrap must be an `(fd, inode)` tuple' + ) + + return value + + def _actor_child_main( uid: tuple[str, str], loglevel: str | None, parent_addr: UnwrappedAddress | None, infect_asyncio: bool, spawn_method: SpawnMethodKey = 'trio', + netns_bootstrap: tuple[int, int]|None = None, ) -> None: ''' @@ -62,7 +94,13 @@ def _actor_child_main( invokes this from inside a fresh `concurrent.interpreters` sub-interpreter via `Interpreter.call()`. + Consume `netns_bootstrap` before Trio patching, actor construction, + process-title setup, or actor-runtime entry. The spawn backend must + supply an exclusively child-owned FD duplicate. + ''' + _consume_netns_bootstrap(netns_bootstrap) + # Apply defensive monkey-patches for upstream `trio` # bugs we've encountered while running tractor — see # `tractor.trionics.patches` for the catalog + @@ -113,7 +151,11 @@ def _actor_child_main( ) -if __name__ == "__main__": +def main(argv: list[str]|None = None) -> None: + ''' + Parse Trio child-bootstrap arguments and enter actor runtime. + + ''' __tracebackhide__: bool = True parser = argparse.ArgumentParser() @@ -121,7 +163,11 @@ def _actor_child_main( parser.add_argument("--loglevel", type=str) parser.add_argument("--parent_addr", type=parse_ipaddr) parser.add_argument("--asyncio", action='store_true') - args = parser.parse_args() + parser.add_argument( + '--netns_bootstrap', + type=parse_netns_bootstrap, + ) + args = parser.parse_args(argv) _actor_child_main( uid=args.uid, @@ -129,4 +175,9 @@ def _actor_child_main( parent_addr=args.parent_addr, infect_asyncio=args.asyncio, spawn_method='trio', + netns_bootstrap=args.netns_bootstrap, ) + + +if __name__ == "__main__": + main() diff --git a/tractor/_root.py b/tractor/_root.py index b0aa40525..13c9132a9 100644 --- a/tractor/_root.py +++ b/tractor/_root.py @@ -18,6 +18,9 @@ Root actor runtime ignition(s). ''' +from __future__ import annotations + +from collections.abc import AsyncIterator from contextlib import ( asynccontextmanager as acm, ) @@ -31,6 +34,7 @@ from typing import ( Any, Callable, + TYPE_CHECKING, ) import warnings @@ -55,7 +59,6 @@ mk_uuid, wrap_address, ) -from .discovery._tunnel import strip_tunnels from .trionics import ( is_multi_cancelled, collapse_eg, @@ -64,6 +67,11 @@ RuntimeFailure, ) +if TYPE_CHECKING: + from .net._bindspace import Bindspace +else: + Bindspace = Any + logger = log.get_logger('tractor') @@ -154,9 +162,30 @@ def block_bps(*args, **kwargs): os.environ.pop('PYTHONBREAKPOINT', None) +@acm +async def _enter_root_bindspace( + bindspace: Bindspace|None, +) -> AsyncIterator[None]: + ''' + Adapt synchronous root netns entry to the outer async lifecycle. + + The wrapped context has no checkpoints, so `trio` cancellation + cannot interrupt thread-local namespace restoration. + + ''' + from .spawn._netns import _enter_netns_temporarily + + with _enter_netns_temporarily(bindspace): + yield + + @acm async def open_root_actor( *, + # Low-level realized scope. A future tunnelled-address bootstrap + # may open and supply this capability internally. + bindspace: Bindspace|None = None, + tpt_bind_addrs: list[ Address # `Address.get_random()` case |UnwrappedAddress # registrar case `= uw_reg_addrs` @@ -220,6 +249,10 @@ async def open_root_actor( All (disjoint) actor-process-trees-as-programs are created via this entrypoint. + When `bindspace` is provided, enter its network namespace before + any registry or IPC activity and restore the calling thread's + original namespace after complete actor teardown. + ''' # XXX NEVER allow nested actor-trees! if already_actor := _state.current_actor( @@ -240,10 +273,29 @@ async def open_root_actor( f'_registry_addrs: {registry_addrs!r}\n' ) + effective_start_method: str = ( + os.environ.get('TRACTOR_SPAWN_METHOD') + or start_method + or _spawn._spawn_method + ) + if ( + bindspace is not None + and + effective_start_method == 'mp_forkserver' + ): + raise NotImplementedError( + 'Root actor bindspaces are not supported by the ' + '`mp_forkserver` spawn backend because a persistent ' + 'forkserver may retain its original network namespace!' + ) + # debug.mk_pdb().set_trace() - async with maybe_block_bp( - debug_mode=debug_mode, - maybe_enable_greenback=maybe_enable_greenback, + async with ( + _enter_root_bindspace(bindspace), + maybe_block_bp( + debug_mode=debug_mode, + maybe_enable_greenback=maybe_enable_greenback, + ), ): if enable_transports is None: enable_transports: list[str] = _state.current_ipc_protos() @@ -505,6 +557,8 @@ async def open_root_actor( # XXX INSTEAD, bind random addrs using the same tpt # proto if not already provided. if not tpt_bind_addrs: + from .net._tunnel import strip_tunnels + for addr in ponged_addrs: bindable_addr: Address = strip_tunnels(addr) tpt_bind_addrs.append( diff --git a/tractor/discovery/__init__.py b/tractor/discovery/__init__.py index 4720765c1..dee99a5bd 100644 --- a/tractor/discovery/__init__.py +++ b/tractor/discovery/__init__.py @@ -15,28 +15,8 @@ # along with this program. If not, see . ''' -Discovery (protocols) API for automatic addressing -and location management of (service) actors. +Actor discovery and registrar implementation package. -NOTE: this ``__init__`` only eagerly imports the lightweight -``._multiaddr`` and ``._tunnel`` submodules for public re-exports. -Heavier submodules like ``._addr`` and ``._api`` are NOT imported -here to avoid circular imports; use direct module paths for those. +Network declarations and helpers are public from `tractor.net`. ''' -from ._multiaddr import ( - parse_endpoints as parse_endpoints, - parse_maddr as parse_maddr, - mk_maddr as mk_maddr, -) -from ._tunnel import ( - TunnelledAddress as TunnelledAddress, - TunnelSpec as TunnelSpec, - WGTunnelSpec as WGTunnelSpec, - mb_pubkey as mb_pubkey, - mk_wg_maddr as mk_wg_maddr, - parse_wg_maddr as parse_wg_maddr, - strip_tunnels as strip_tunnels, - tunnels_of as tunnels_of, - wg8_pubkey as wg8_pubkey, -) diff --git a/tractor/discovery/_addr.py b/tractor/discovery/_addr.py index 1b4e4ddd4..ed080690d 100644 --- a/tractor/discovery/_addr.py +++ b/tractor/discovery/_addr.py @@ -42,7 +42,7 @@ if TYPE_CHECKING: # ONLY type-annots, the eager import costs ~4.5ms # of `import tractor` wall-time (gh #470). - from ._tunnel import ( + from tractor.net._tunnel import ( TunnelledAddress, ) from ..runtime._runtime import Actor @@ -237,8 +237,9 @@ def is_wrapped_addr(addr: any) -> bool: # XXX NOTE, a `TunnelledAddress` is genuinely "wrapped" but is # deliberately NOT in `_address_types`: it has no # `MsgTransport` of its own (a tunnel is transparent to - # `socket(2)`), so it gets no proto-key entry. See `._tunnel`. - from ._tunnel import TunnelledAddress + # `socket(2)`), so it gets no proto-key entry. See + # `tractor.net._tunnel`. + from tractor.net._tunnel import TunnelledAddress return ( type(addr) in _address_types.values() or @@ -333,7 +334,7 @@ def wrap_address( # multiaddr-format string, e.g. # '/ip4/127.0.0.1/tcp/1616' case str() if addr.startswith('/'): - from tractor.discovery._multiaddr import ( + from tractor.net import ( parse_maddr, ) return parse_maddr(addr) diff --git a/tractor/discovery/_multiaddr.py b/tractor/discovery/_multiaddr.py index 27ba79031..2fd7899d9 100644 --- a/tractor/discovery/_multiaddr.py +++ b/tractor/discovery/_multiaddr.py @@ -38,7 +38,7 @@ # `import tractor` path (gh #470). from multiaddr import Multiaddr from tractor.discovery._addr import Address - from tractor.discovery._tunnel import ( + from tractor.net._tunnel import ( TunnelledAddress, ) else: @@ -71,7 +71,7 @@ def mk_maddr( ''' from multiaddr import Multiaddr - from ._tunnel import ( + from tractor.net._tunnel import ( TunnelledAddress, mk_wg_maddr, ) @@ -130,7 +130,7 @@ def parse_maddr( # fails. Pre-checking the raw string would misclassify valid # values such as `/unix/tmp/wg/service.sock`. if '/wg/' in maddr_str: - from ._tunnel import _wg_proto_code + from tractor.net._tunnel import _wg_proto_code _wg_proto_code() raise proto_names: list[str] = [ @@ -156,7 +156,7 @@ def parse_maddr( ) case _ if 'wg' in proto_names: - from ._tunnel import parse_wg_maddr + from tractor.net._tunnel import parse_wg_maddr return parse_wg_maddr(maddr) case _: diff --git a/tractor/discovery/_tunnel.py b/tractor/discovery/_tunnel.py deleted file mode 100644 index 33f5eaf32..000000000 --- a/tractor/discovery/_tunnel.py +++ /dev/null @@ -1,502 +0,0 @@ -# tractor: structured concurrent "actors". -# Copyright 2018-eternity Tyler Goodlet. - -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. - -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. - -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . -r''' -Tunnelled addresses: an `Address` that rides *inside* a tunnel. - -A tunnel (`wg`, and later plain ip-in-udp, `veth`-in-netns, ..) is -**not** a `MsgTransport`. Its data plane is transparent to the -application's `socket(2)`, so it never gets its own entry in -`._addr._address_types` nor a `MsgpackTransport` impl. Instead it -*annotates* an existing L4 addr, and this module carries that -annotation beside it. - -That does not mean tractor can never provision the tunnel. Layer A -assumes an externally configured iface; a later bindspace lifecycle -may create its iface, netns, routes, and kernel-owned UDP listener -through netlink/`pyroute2`. The distinction is that this -control-plane work does not turn the bearer into an application -`Endpoint`. - -Naming follows `py-multiaddr`'s encapsulation model, where earlier -maddr segs wrap later ones (`.encapsulate()` appends): - - /ip4/192.168.1.50/udp/51820/wg/u/ip4/10.0.11.1/tcp/1616 - \_______ bearer __________/\__ key __/\______ overlay ______/ - -- **bearer**: the underlay ep the tunnel iface listens on - (`wg(8)`'s `ListenPort`). The kernel owns this data-plane socket; - tractor may later provision it through a bindspace lifecycle but - never treats it as a `MsgTransport` listener. -- **overlay**: the ep `tractor` actually binds/dials, i.e. the - application IPC endpoint handled by `Endpoint`/`MsgTransport`. - -We avoid `inner`/`outer` deliberately: in a *call* stack "inner" -reads as higher-up and later-called, whereas here the -encapsulated addr is bound *first* and sits deeper in the maddr. - -XXX XXX READ THIS BEFORE USING XXX XXX --------------------------------------- -A `TunnelledAddress` **must be unwrapped to `.overlay` before it -reaches `Endpoint`**. `Endpoint.start_listener()` resolves its -listener fns by `inspect.getmodule(self.addr)`, so a wrapper -would resolve to *this* module rather than the transport's and -silently fail to find `start_listener()`. - -If a wrapper reaches `Endpoint`, its backend lookup resolves this -module instead of the overlay transport module: - - tpt_mod = inspect.getmodule(self.addr) - await tpt_mod.start_listener(addr=self.addr) - -This module intentionally does not impersonate that transport API. -Unwrap at the parse or bindspace boundary; see `.overlay` and -`strip_tunnels()`. - -''' -from __future__ import annotations -import base64 -import ipaddress -from typing import ( - Any, - ClassVar, - TYPE_CHECKING, -) - -import msgspec -import multibase - -if TYPE_CHECKING: - from multiaddr import Multiaddr - - from ._addr import ( - Address, - UnwrappedAddress, - ) -else: - Address = Any - Multiaddr = Any - UnwrappedAddress = Any - - -class WGTunnelSpec( - msgspec.Struct, - frozen=True, -): - ''' - The `wg`-specific half of a tunnel annotation. - - Everything here is an *interface-layer* concern owned by - `wg(8)`/the kernel. A later tractor bindspace lifecycle may - provision it through netlink, but it is never an application - `MsgTransport` endpoint. - - ''' - # tunnel peer pubkey in the std-base64 `wg(8)` form, i.e. - # directly comparable to `wg show peers` output - peer_pubkey: str - - # the underlay `(ip, udp-port)` the wg iface listens on, i.e. - # wg's `ListenPort`. The kernel owns the socket even when a - # tractor bindspace lifecycle provisions it. `None` when the - # maddr declared only a key (identity) and the bearer is - # implied by local cfg. - bearer: tuple[str, int]|None = None - - iface: str = 'wg0' - netns: str|None = None - - # layer-C-only fields, unset in layer A - maybe_allowed_ips: tuple[str, ...] = () - - # the `multiaddr` proto name for this tunnel kind - tunnel_key: ClassVar[str] = 'wg' - - -# the tunnel-spec union; grows as new tunnel kinds land -# (plain ip-in-udp, `veth`-in-netns, ..) -TunnelSpec = WGTunnelSpec - - -def mb_pubkey( - wg8_key: str, -) -> str: - ''' - Encode a `wg(8)` public key as multibase base64url. - - WireGuard public keys are exactly 32 bytes. Enforce that here - before handing the `u`-prefixed result to `py-multiaddr`'s - `/wg/` codec. - - ''' - raw: bytes = base64.b64decode( - wg8_key, - validate=True, - ) - if (nbytes := len(raw)) != 32: - raise ValueError( - f'A `wg` public key must decode to 32 bytes, ' - f'not {nbytes}!' - ) - - return multibase.encode( - 'base64url', - raw, - ).decode('ascii') - - -def wg8_pubkey( - mb_key: str, -) -> str: - ''' - Decode a multibase public key to `wg(8)` standard base64. - - ''' - raw: bytes = multibase.decode(mb_key) - if (nbytes := len(raw)) != 32: - raise ValueError( - f'A `wg` public key must decode to 32 bytes, ' - f'not {nbytes}!' - ) - - return base64.b64encode(raw).decode('ascii') - - -def _wg_proto_code() -> int: - ''' - Deliver the installed `py-multiaddr` `/wg/` protocol code. - - `wg` support is merged upstream but not yet in a release, so - fail clearly when tractor was installed without the pinned rev. - - ''' - from multiaddr.exceptions import ProtocolNotFoundError - from multiaddr.protocols import protocol_with_name - - try: - return protocol_with_name('wg').code - except ProtocolNotFoundError as exc: - raise RuntimeError( - 'Installed `py-multiaddr` has no `/wg/` protocol!\n' - 'Install py-multiaddr#108 or use tractor\'s pinned ' - 'dependency revision.\n' - ) from exc - - -class TunnelledAddress( - msgspec.Struct, - frozen=True, -): - ''' - An `Address` annotated with the tunnel it must be reached - *through*. - - Address-level properties delegate to `.overlay`, so proto-key - guards and `.unwrap()` retain their existing meaning and - **nothing new crosses the wire**. Transport boundaries which - dispatch on exact type or declaring module must first call - `strip_tunnels()`. - - ''' - overlay: Address|TunnelledAddress - tunnel: TunnelSpec - - # ---- delegated, so the runtime can't tell the difference ---- - - @property - def proto_key(self) -> str: - ''' - The *overlay's* proto-key — a tunnel has no transport of - its own. - - NOTE, this is a property whereas `Address.proto_key` is - spec'd as a `ClassVar`. That's deliberate: the value is - only knowable per-instance here, and this type is never - registered in `_address_types`, so no class-level access - of it should ever occur. - - ''' - return self.overlay.proto_key - - @property - def is_valid(self) -> bool: - return self.overlay.is_valid - - @property - def bindspace(self) -> str: - return self.overlay.bindspace - - def unwrap(self) -> UnwrappedAddress: - ''' - Delegate to `.overlay`, so the tunnel annotation is - **not** serialized and no peer needs to understand it. - - ''' - return self.overlay.unwrap() - - # ---- the tunnel's own contribution ---- - - @property - def namespace(self) -> tuple[str, str|int]|None: - ''' - The tunnel's netns, when it declares one. - - This is the first real consumer of `Address.namespace`, - spec'd in the `Address` protocol since day one and - implemented by no backend. - - XXX NOTE, "implemented by no backend" is literal: neither - `TCPAddress` nor `UDSAddress` defines `.namespace` at all, - so a plain attr access on an overlay raises - `AttributeError` rather than yielding `None`. Hence the - `getattr()` — drop it once the backends actually declare - the member. - - ''' - if (netns := self.tunnel.netns) is None: - return getattr(self.overlay, 'namespace', None) - - return ('netns', netns) - - def __repr__(self) -> str: - return ( - f'{type(self).__name__}(\n' - f' overlay={self.overlay!r},\n' - f' via={self.tunnel.tunnel_key!r} ' - f'iface={self.tunnel.iface!r},\n' - f')' - ) - - -def _wg_bearer( - bearer_ma: Multiaddr, - source_ma: Multiaddr, -) -> tuple[str, int]: - ''' - Parse one kernel-owned `wg` bearer endpoint. - - ''' - proto_names: list[str] = [ - proto.name - for proto in bearer_ma.protocols() - ] - match proto_names: - case [('ip4' | 'ip6') as ip_proto, 'udp']: - return ( - bearer_ma.value_for_protocol(ip_proto), - int(bearer_ma.value_for_protocol('udp')), - ) - - case _: - raise ValueError( - f'Bad `wg` bearer, expected ' - f'`/ip4|ip6//udp/`\n' - f'got: {bearer_ma}\n' - f'from maddr: {source_ma}\n' - ) - - -def parse_wg_maddr( - maddr: str|Multiaddr, -) -> TunnelledAddress: - ''' - Parse a `wg` maddr stack into nested tunnel annotations. - - Pure: every segment operation delegates to `py-multiaddr`. - Repeated `.decapsulate_code()` calls peel the last `/wg/` - first, while `.split()` and `.join()` isolate that tunnel's - bearer without parsing slash-delimited strings ourselves. - - ''' - from multiaddr import Multiaddr - - ma: Multiaddr = ( - maddr - if isinstance(maddr, Multiaddr) - else Multiaddr(maddr) - ) - wg_code: int = _wg_proto_code() - segs: list[Multiaddr] = ma.split() - proto_names: list[str] = [ - proto.name - for seg in segs - for proto in seg.protocols() - ] - if 'wg' not in proto_names: - raise ValueError( - f'Not a `wg`-tunnelled maddr; no `/wg/` segment!\n' - f'maddr: {ma}\n' - ) - - final_wg_i: int = len(proto_names) - 1 - final_wg_i -= proto_names[::-1].index('wg') - overlay_ma: Multiaddr = Multiaddr.join( - *segs[final_wg_i + 1:] - ) - overlay_names: list[str] = [ - proto.name - for proto in overlay_ma.protocols() - ] - match overlay_names: - case [('ip4' | 'ip6'), 'tcp']: - from ._multiaddr import parse_maddr - overlay: Address|TunnelledAddress = parse_maddr( - str(overlay_ma) - ) - - case []: - raise ValueError( - f'`wg` maddr declares no overlay endpoint!\n' - f'Append the endpoint tractor should bind.\n' - f'maddr: {ma}\n' - ) - - case _: - raise ValueError( - f'Unsupported `wg` overlay protocol combo: ' - f'{overlay_names!r}\n' - f'overlay: {overlay_ma}\n' - f'from maddr: {ma}\n' - ) - - cursor: Multiaddr = ma - while any( - proto.name == 'wg' - for proto in cursor.protocols() - ): - cursor_segs: list[Multiaddr] = cursor.split() - cursor_names: list[str] = [ - proto.name - for seg in cursor_segs - for proto in seg.protocols() - ] - wg_i: int = len(cursor_names) - 1 - wg_i -= cursor_names[::-1].index('wg') - mb_key: str = cursor_segs[wg_i].value_for_protocol('wg') - - bearer_prefix: Multiaddr = cursor.decapsulate_code( - wg_code - ) - prefix_segs: list[Multiaddr] = bearer_prefix.split() - prefix_names: list[str] = [ - proto.name - for seg in prefix_segs - for proto in seg.protocols() - ] - prior_wg_i: int = ( - len(prefix_names) - 1 - - prefix_names[::-1].index('wg') - if 'wg' in prefix_names - else -1 - ) - bearer_ma: Multiaddr = Multiaddr.join( - *prefix_segs[prior_wg_i + 1:] - ) - overlay = TunnelledAddress( - overlay=overlay, - tunnel=WGTunnelSpec( - peer_pubkey=wg8_pubkey(mb_key), - bearer=_wg_bearer(bearer_ma, ma), - ), - ) - cursor = bearer_prefix - - return overlay - - -def mk_wg_maddr( - addr: TunnelledAddress, -) -> Multiaddr: - ''' - Compose nested tunnel annotations as a canonical `wg` maddr. - - Only the peer key and bearer have maddr representations. Local - interface, namespace, and allowed-IP config remains local. - - ''' - from multiaddr import Multiaddr - - _wg_proto_code() - if (bearer := addr.tunnel.bearer) is None: - raise ValueError( - f'Can not compose a `wg` maddr without a bearer!\n' - f'tunnel: {addr.tunnel!r}\n' - ) - - bindable: Address = strip_tunnels(addr) - if bindable.proto_key != 'tcp': - raise ValueError( - f'Unsupported `wg` overlay proto-key: ' - f'{bindable.proto_key!r}\n' - f'overlay: {bindable!r}\n' - ) - - host, port = bearer - ip = ipaddress.ip_address(host) - ip_proto: str = ( - 'ip4' - if ip.version == 4 - else 'ip6' - ) - bearer_ma = Multiaddr( - f'/{ip_proto}/{host}/udp/{port}' - ) - key_ma = Multiaddr( - f'/wg/{mb_pubkey(addr.tunnel.peer_pubkey)}' - ) - - from ._multiaddr import mk_maddr - overlay_ma: Multiaddr = mk_maddr(addr.overlay) - return ( - bearer_ma - .encapsulate(key_ma) - .encapsulate(overlay_ma) - ) - - -def strip_tunnels( - addr: Address|TunnelledAddress, -) -> Address: - ''' - Deliver the bindable `Address`, peeling any tunnel - annotation(s). - - Pure. Idempotent on an un-tunnelled `Address`, and loops so - a nested (tunnel-in-tunnel) stack collapses in one call. - - Call this at every bind/dial boundary. - - ''' - while isinstance(addr, TunnelledAddress): - addr = addr.overlay - - return addr - - -def tunnels_of( - addr: Address|TunnelledAddress, -) -> tuple[TunnelSpec, ...]: - ''' - Deliver every tunnel spec wrapping `addr`, outermost first. - - Pure; empty for an un-tunnelled `Address`. - - ''' - specs: list[TunnelSpec] = [] - while isinstance(addr, TunnelledAddress): - specs.append(addr.tunnel) - addr = addr.overlay - - return tuple(specs) diff --git a/tractor/ipc/_chan.py b/tractor/ipc/_chan.py index 13188ca0a..13aa02447 100644 --- a/tractor/ipc/_chan.py +++ b/tractor/ipc/_chan.py @@ -46,10 +46,6 @@ Address, UnwrappedAddress, ) -from tractor.discovery._tunnel import ( - TunnelledAddress, - strip_tunnels, -) from tractor.log import get_logger from tractor._exceptions import ( MsgTypeError, @@ -63,6 +59,9 @@ if TYPE_CHECKING: from ._transport import MsgTransport + from tractor.net._tunnel import TunnelledAddress +else: + TunnelledAddress = Any log = get_logger() @@ -190,6 +189,8 @@ async def from_addr( **kwargs ) -> Channel: + from tractor.net._tunnel import strip_tunnels + if not is_wrapped_addr(addr): addr = wrap_address(addr) diff --git a/tractor/ipc/_server.py b/tractor/ipc/_server.py index 9155f8428..f75bcae26 100644 --- a/tractor/ipc/_server.py +++ b/tractor/ipc/_server.py @@ -68,7 +68,7 @@ if TYPE_CHECKING: - from ..discovery._tunnel import TunnelledAddress + from ..net._tunnel import TunnelledAddress from ..runtime._runtime import Actor from ..runtime._supervise import ActorNursery @@ -631,6 +631,7 @@ class Endpoint(Struct): ''' addr: Address + declared_addr: Address|TunnelledAddress listen_tn: Nursery stream_handler_tn: Nursery|None = None @@ -645,15 +646,27 @@ class Endpoint(Struct): 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( self, indent: int = 0, privates: bool = False, ) -> str: type_repr: str = type(self).__name__ + namespace: tuple[str, str|int]|None = self.namespace fmtstr: str = ( - # !TODO, always be ns aware! - # f'|_netns: {netns}\n' + f' |.namespace: {namespace!r}\n' f' |.addr: {self.addr!r}\n' f' |_peers: {len(self.peer_tpts)}\n' ) @@ -931,9 +944,17 @@ def pformat( ep.addr for ep in eps ] 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 += ( f' |_endpoints: {repr_eps}\n' + f' |_namespaces: {repr_namespaces}\n' # ^TODO? how to indent closing ']'.. ) @@ -1070,7 +1091,7 @@ async def _serve_ipc_eps( `.cancel_server()` is called. ''' - from ..discovery._tunnel import strip_tunnels + from ..net._tunnel import strip_tunnels try: listen_tn: Nursery @@ -1086,6 +1107,7 @@ async def _serve_ipc_eps( addr=addr, listen_tn=listen_tn, stream_handler_tn=stream_handler_tn, + declared_addr=declared_addr, ) try: ep_sclang: str = nest_from_op( diff --git a/tractor/ipc/_tcp.py b/tractor/ipc/_tcp.py index dc88358ce..40cb8e812 100644 --- a/tractor/ipc/_tcp.py +++ b/tractor/ipc/_tcp.py @@ -37,7 +37,6 @@ from tractor.msg import MsgCodec from tractor.log import get_logger -from tractor.discovery._multiaddr import mk_maddr from tractor.ipc._transport import ( MsgTransport, MsgpackTransport, @@ -107,6 +106,14 @@ def is_valid(self) -> bool: def bindspace(self) -> str: return self._host + @property + def namespace(self) -> None: + ''' + Report that plain TCP uses the process's current namespace. + + ''' + return None + @property def domain(self) -> str: return self._host @@ -227,6 +234,8 @@ class MsgpackTCPStream(MsgpackTransport): @property def maddr(self) -> Multiaddr: + from tractor.net import mk_maddr + return mk_maddr(self.raddr) def connected(self) -> bool: diff --git a/tractor/ipc/_uds.py b/tractor/ipc/_uds.py index 72987c1f9..cfba95190 100644 --- a/tractor/ipc/_uds.py +++ b/tractor/ipc/_uds.py @@ -63,7 +63,6 @@ from tractor.msg import MsgCodec from tractor.log import get_logger -from tractor.discovery._multiaddr import mk_maddr from tractor.ipc._transport import ( MsgpackTransport, ) @@ -165,6 +164,14 @@ def bindspace(self) -> Path: self.def_bindspace ) + @property + def namespace(self) -> None: + ''' + Report that plain UDS uses the process's current namespace. + + ''' + return None + @property def sockpath(self) -> Path: return Path(self.bindspace) / self.filename @@ -603,6 +610,8 @@ class MsgpackUDSStream(MsgpackTransport): @property def maddr(self) -> Multiaddr|str: + from tractor.net import mk_maddr + if not self.raddr: return '' diff --git a/tractor/msg/__init__.py b/tractor/msg/__init__.py index 882200540..b1a1ce5c6 100644 --- a/tractor/msg/__init__.py +++ b/tractor/msg/__init__.py @@ -27,6 +27,9 @@ from .pretty_struct import ( Struct as Struct, ) +from ._local import ( + ProcessLocal as ProcessLocal, +) from ._codec import ( _def_msgspec_codec as _def_msgspec_codec, _ctxvar_MsgCodec as _ctxvar_MsgCodec, diff --git a/tractor/msg/_local.py b/tractor/msg/_local.py new file mode 100644 index 000000000..1bdd8b499 --- /dev/null +++ b/tractor/msg/_local.py @@ -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 +# . +''' +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 diff --git a/tractor/net/__init__.py b/tractor/net/__init__.py new file mode 100644 index 000000000..ed477a1e0 --- /dev/null +++ b/tractor/net/__init__.py @@ -0,0 +1,75 @@ +# tractor: structured concurrent "actors". +# Copyright 2018-eternity Tyler Goodlet. + +''' +Network declarations, bindspaces and tunnels. + +Public symbols are imported and cached on first access so importing +this package does not load optional network dependencies. + +''' +from importlib import import_module + + +_SYMBOL_MODULES: dict[str, str] = { + 'Bindspace': '._bindspace', + 'BindspaceKind': '._bindspace', + 'BindspaceLifecycle': '._bindspace', + 'BindspaceOwnership': '._bindspace', + 'BindspaceRef': '._bindspace', + 'BindspaceSpec': '._bindspace', + 'CURRENT_NETNS': '._bindspace', + 'attach_netns': '._bindspace', + 'open_bindspace': '._bindspace', + 'open_netns': '._bindspace', + 'TunnelledAddress': '._tunnel', + 'TunnelSpec': '._tunnel', + 'WGTunnelSpec': '._tunnel', + 'WGInterfaceConfig': '._tunnel', + 'WGPeerConfig': '._tunnel', + 'WGRole': '._tunnel', + 'mb_pubkey': '._tunnel', + 'mk_wg_maddr': '._tunnel', + 'open_wg_bindspace': '._tunnel', + 'open_wg_iface': '._tunnel', + 'parse_wg_maddr': '._tunnel', + 'read_wg_peers': '._tunnel', + 'read_wg_pubkey': '._tunnel', + 'strip_tunnels': '._tunnel', + 'tunnels_of': '._tunnel', + 'verify_wg_peer': '._tunnel', + 'wg8_pubkey': '._tunnel', + 'mk_maddr': '..discovery._multiaddr', + 'parse_maddr': '..discovery._multiaddr', + 'parse_endpoints': '..discovery._multiaddr', +} + +__all__: tuple[str, ...] = tuple(_SYMBOL_MODULES) + + +def __dir__() -> list[str]: + ''' + Advertise the complete lazy public API. + + ''' + return sorted(set(globals()) | set(__all__)) + + +def __getattr__(name: str) -> object: + ''' + Import and cache one public network symbol on first access. + + ''' + try: + module_name: str = _SYMBOL_MODULES[name] + except KeyError: + raise AttributeError( + f'module {__name__!r} has no attribute {name!r}' + ) from None + + value: object = getattr( + import_module(module_name, __name__), + name, + ) + globals()[name] = value + return value diff --git a/tractor/net/_bindspace.py b/tractor/net/_bindspace.py new file mode 100644 index 000000000..0c95613ed --- /dev/null +++ b/tractor/net/_bindspace.py @@ -0,0 +1,436 @@ +# 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 +# . +''' +Serializable bindspace declarations and live capabilities. + +''' +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') +_THREAD_NETNS: Path = Path('/proc/thread-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`; `BindspaceRef.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 + thread'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 BindspaceRef( + msgspec.Struct, + frozen=True, +): + ''' + Serializable, non-owning ref to one realized bindspace. + + `.key` is an optional mutable namespace locator. `.inode` is a + host-local kernel fingerprint which remains stable while the + resource exists or a live `Bindspace` pins it. This ref grants no + authority and cannot reopen the resource by itself. + + ''' + kind: BindspaceKind + key: str|None + inode: int + + def __post_init__(self) -> None: + ''' + Require a host-local resource inode and an optional locator. + + ''' + _validate_bindspace_kind(self.kind) + _validate_bindspace_key( + self.kind, + self.key, + 'BindspaceRef.key', + ) + if ( + type(self.inode) is not int + or + self.inode <= 0 + ): + raise ValueError( + '`BindspaceRef.inode` must be a positive `int`!' + ) + + +class Bindspace( + ProcessLocal, +): + ''' + Process-local capability for one live realized bindspace. + + `ProcessLocal` provides compact typed storage plus a default + wire-encoding guard. `Bindspace` construction and explicit FD + transfer belong to the supervisor's spawn/bootstrap path. + + ''' + spec: BindspaceSpec + ref: BindspaceRef + namespace_fd: int|None + ownership: BindspaceOwnership + + def __post_init__(self) -> None: + ''' + Validate and retain one scoped bindspace capability. + + ''' + spec: BindspaceSpec = self.spec + ref: BindspaceRef = self.ref + namespace_fd: int|None = self.namespace_fd + ownership: BindspaceOwnership = self.ownership + + if spec.kind != ref.kind: + raise ValueError( + '`BindspaceSpec.kind` does not match ' + '`BindspaceRef.kind`!' + ) + if ( + spec.key is not None + and + spec.key != ref.key + ): + raise ValueError( + '`BindspaceSpec.key` does not match ' + '`BindspaceRef.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 ref.inode != fd_inode: + raise ValueError( + f'Namespace FD inode {fd_inode} does not match ' + f'reference inode {ref.inode}!' + ) + + def __repr__(self) -> str: + ''' + Render the capability ref without dereferencing its FD. + + ''' + return ( + f'{type(self).__name__}(' + f'ref={self.ref!r}, ' + f'ownership={self.ownership!r}, ' + f'namespace_fd={self.namespace_fd!r})' + ) + + +@acm +async def _pin_netns( + spec: BindspaceSpec, + ownership: BindspaceOwnership, +) -> AsyncIterator[Bindspace]: + ''' + Pin one existing Linux network namespace with explicit ownership. + + ''' + key: str|None = spec.key + namespace_path: Path = ( + _THREAD_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 + ref: BindspaceRef = BindspaceRef( + kind='netns', + key=key, + inode=inode, + ) + bindspace: Bindspace = Bindspace( + spec=spec, + ref=ref, + namespace_fd=namespace_fd, + ownership=ownership, + ) + yield bindspace + finally: + os.close(namespace_fd) + + +@acm +async def attach_netns( + spec: BindspaceSpec, +) -> AsyncIterator[Bindspace]: + ''' + 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 bindspace: + yield bindspace + + +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[Bindspace]: + ''' + 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 bindspace: + yield bindspace + 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[Bindspace]: + ''' + 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 bindspace: + yield bindspace + else: + async with open_netns(spec) as bindspace: + yield bindspace diff --git a/tractor/net/_tunnel.py b/tractor/net/_tunnel.py new file mode 100644 index 000000000..34dfe79a2 --- /dev/null +++ b/tractor/net/_tunnel.py @@ -0,0 +1,1207 @@ +# tractor: structured concurrent "actors". +# Copyright 2018-eternity Tyler Goodlet. + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +r''' +Tunnelled addresses: an `Address` that rides *inside* a tunnel. + +A tunnel (`wg`, and later plain ip-in-udp, `veth`-in-netns, ..) is +**not** a `MsgTransport`. Its data plane is transparent to the +application's `socket(2)`, so it never gets its own entry in +`tractor.discovery._addr._address_types` nor a `MsgpackTransport` +impl. Instead it +*annotates* an existing L4 addr, and this module carries that +annotation beside it. + +That does not mean tractor can never provision the tunnel. Layer A +assumes an externally configured iface; a later bindspace lifecycle +may create its iface, netns, routes, and kernel-owned UDP listener +through netlink/`pyroute2`. The distinction is that this +control-plane work does not turn the bearer into an application +`Endpoint`. + +Naming follows `py-multiaddr`'s encapsulation model, where earlier +maddr segs wrap later ones (`.encapsulate()` appends): + + /ip4/192.168.1.50/udp/51820/wg/u/ip4/10.0.11.1/tcp/1616 + \_______ bearer __________/\__ key __/\______ overlay ______/ + +- **bearer**: the underlay ep the tunnel iface listens on + (`wg(8)`'s `ListenPort`). The kernel owns this data-plane socket; + tractor may later provision it through a bindspace lifecycle but + never treats it as a `MsgTransport` listener. +- **overlay**: the ep `tractor` actually binds/dials, i.e. the + application IPC endpoint handled by `Endpoint`/`MsgTransport`. + +We avoid `inner`/`outer` deliberately: in a *call* stack "inner" +reads as higher-up and later-called, whereas here the +encapsulated addr is bound *first* and sits deeper in the maddr. + +XXX XXX READ THIS BEFORE USING XXX XXX +-------------------------------------- +A `TunnelledAddress` **must be unwrapped to `.overlay` before it +reaches `Endpoint`**. `Endpoint.start_listener()` resolves its +listener fns by `inspect.getmodule(self.addr)`, so a wrapper +would resolve to *this* module rather than the transport's and +silently fail to find `start_listener()`. + +If a wrapper reaches `Endpoint`, its backend lookup resolves this +module instead of the overlay transport module: + + tpt_mod = inspect.getmodule(self.addr) + await tpt_mod.start_listener(addr=self.addr) + +This module intentionally does not impersonate that transport API. +Unwrap at the parse or bindspace boundary; see `.overlay` and +`strip_tunnels()`. + +''' +from __future__ import annotations + +from collections.abc import ( + AsyncIterator, + Sequence, +) +import base64 +from contextlib import ( + AsyncExitStack, + asynccontextmanager as acm, +) +import ipaddress +import sys +from typing import ( + Any, + ClassVar, + get_args, + Literal, + TYPE_CHECKING, +) + +import msgspec +import multibase +import trio + +from ..msg._local import ProcessLocal +from ._bindspace import ( + Bindspace, + BindspaceRef, + BindspaceSpec, + open_bindspace, +) + +if TYPE_CHECKING: + from multiaddr import Multiaddr + + from ..discovery._addr import ( + Address, + UnwrappedAddress, + ) +else: + Address = Any + Multiaddr = Any + UnwrappedAddress = Any + + +class WGTunnelSpec( + msgspec.Struct, + frozen=True, +): + ''' + The `wg`-specific half of a tunnel annotation. + + Everything here is an *interface-layer* concern owned by + `wg(8)`/the kernel. A later tractor bindspace lifecycle may + provision it through netlink, but it is never an application + `MsgTransport` endpoint. + + ''' + # tunnel peer pubkey in the std-base64 `wg(8)` form, i.e. + # directly comparable to `wg show peers` output + peer_pubkey: str + + # the underlay `(ip, udp-port)` the wg iface listens on, i.e. + # wg's `ListenPort`. The kernel owns the socket even when a + # tractor bindspace lifecycle provisions it. `None` when the + # maddr declared only a key (identity) and the bearer is + # implied by local cfg. + bearer: tuple[str, int]|None = None + + iface: str = 'wg0' + netns: str|None = None + + # the `multiaddr` proto name for this tunnel kind + tunnel_key: ClassVar[str] = 'wg' + + +# the tunnel-spec union; grows as new tunnel kinds land +# (plain ip-in-udp, `veth`-in-netns, ..) +TunnelSpec = WGTunnelSpec + + +def mb_pubkey( + wg8_key: str, +) -> str: + ''' + Encode a `wg(8)` public key as multibase base64url. + + WireGuard public keys are exactly 32 bytes. Enforce that here + before handing the `u`-prefixed result to `py-multiaddr`'s + `/wg/` codec. + + ''' + raw: bytes = base64.b64decode( + wg8_key, + validate=True, + ) + if (nbytes := len(raw)) != 32: + raise ValueError( + f'A `wg` public key must decode to 32 bytes, ' + f'not {nbytes}!' + ) + + return multibase.encode( + 'base64url', + raw, + ).decode('ascii') + + +def wg8_pubkey( + mb_key: str, +) -> str: + ''' + Decode a multibase public key to `wg(8)` standard base64. + + ''' + raw: bytes = multibase.decode(mb_key) + if (nbytes := len(raw)) != 32: + raise ValueError( + f'A `wg` public key must decode to 32 bytes, ' + f'not {nbytes}!' + ) + + return base64.b64encode(raw).decode('ascii') + + +def _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 + + +class WGPeerConfig( + ProcessLocal, +): + ''' + Process-local configuration for one WireGuard peer. + + ''' + public_key: str + allowed_ips: tuple[str, ...] = () + endpoint: tuple[str, int]|None = None + preshared_key: str|None = None + persistent_keepalive: int|None = None + + def __post_init__(self) -> None: + ''' + Validate peer identity, routes, endpoint and secret policy. + + ''' + _wg8_key_str(self.public_key) + if self.preshared_key is not None: + _wg8_key_str(self.preshared_key) + + # Validate each route. `strict=False` accepts host bits; + # pyroute2 will still receive each original declared string. + allowed_ip: str + for allowed_ip in self.allowed_ips: + ipaddress.ip_network( + allowed_ip, + strict=False, + ) + + endpoint: tuple[str, int]|None = self.endpoint + if endpoint is not None: + host: str + port: int + host, port = endpoint + ipaddress.ip_address(host) + if ( + type(port) is not int + or + not 1 <= port <= 65535 + ): + raise ValueError( + '`WGPeerConfig.endpoint` port must be in ' + f'`1..65535`, not {port!r}!' + ) + + keepalive: int|None = self.persistent_keepalive + if ( + keepalive is not None + and + ( + type(keepalive) is not int + or + not 0 <= keepalive <= 65535 + ) + ): + raise ValueError( + '`WGPeerConfig.persistent_keepalive` must be in ' + f'`0..65535` or `None`, not {keepalive!r}!' + ) + + def __repr__(self) -> str: + ''' + Render public peer policy while redacting its preshared key. + + ''' + preshared: str|None = ( + '' + if self.preshared_key is not None + else None + ) + return ( + f'{type(self).__name__}(' + f'public_key={self.public_key!r}, ' + f'allowed_ips={self.allowed_ips!r}, ' + f'endpoint={self.endpoint!r}, ' + f'preshared_key={preshared!r}, ' + f'persistent_keepalive={self.persistent_keepalive!r})' + ) + + +class WGInterfaceConfig( + ProcessLocal, +): + ''' + Process-local secrets and routing inputs for one WireGuard iface. + + Public peer identity, endpoint and iface selection remain in + `WGTunnelSpec`; private key material and local routing policy do + not belong in an maddr-derived serializable declaration. + + ''' + private_key: str + addresses: tuple[str, ...] = () + listen_port: int|None = None + peers: tuple[WGPeerConfig, ...] = () + + def __post_init__(self) -> None: + ''' + Validate private identity, local CIDRs and peer uniqueness. + + ''' + _wg8_key_str(self.private_key) + + # Validate each local CIDR; no address is selected. + address: str + for address in self.addresses: + ipaddress.ip_interface(address) + + listen_port: int|None = self.listen_port + if ( + listen_port is not None + and + ( + type(listen_port) is not int + or + not 1 <= listen_port <= 65535 + ) + ): + raise ValueError( + '`WGInterfaceConfig.listen_port` must be in ' + f'`1..65535` or `None`, not {listen_port!r}!' + ) + + peer_keys: set[str] = set() + peer: WGPeerConfig + for peer in self.peers: + if not isinstance(peer, WGPeerConfig): + raise TypeError( + '`WGInterfaceConfig.peers` must contain ' + '`WGPeerConfig` values!' + ) + if peer.public_key in peer_keys: + raise ValueError( + f'Duplicate WireGuard peer: {peer.public_key!r}' + ) + peer_keys.add(peer.public_key) + + def __repr__(self) -> str: + ''' + Render non-secret policy while redacting key material. + + ''' + return ( + f'{type(self).__name__}(' + f'private_key=, ' + f'addresses={self.addresses!r}, ' + f'listen_port={self.listen_port!r}, ' + f'peers={self.peers!r})' + ) + + +WGRole = Literal['listen', 'dial'] + + +def _wg_iface_settings( + spec: WGTunnelSpec, + config: WGInterfaceConfig, + role: WGRole, +) -> tuple[int|None, tuple[dict[str, object], ...]]: + ''' + Validate role policy and build pyroute2 WireGuard settings. + + ''' + if role not in get_args(WGRole): + raise ValueError( + f'Unsupported WireGuard role: {role!r}' + ) + + listen_port: int|None = config.listen_port + bearer: tuple[str, int]|None = spec.bearer + if ( + role == 'listen' + and + bearer is not None + ): + bearer_port: int = bearer[1] + if ( + listen_port is not None + and + listen_port != bearer_port + ): + raise ValueError( + f'`WGInterfaceConfig.listen_port={listen_port!r}` ' + f'conflicts with bearer port {bearer_port!r}!' + ) + listen_port = bearer_port + + selected_peer: bool = False + peer_settings: list[dict[str, object]] = [] + peer: WGPeerConfig + for peer in config.peers: + endpoint: tuple[str, int]|None = peer.endpoint + if ( + role == 'dial' + and + peer.public_key == spec.peer_pubkey + ): + selected_peer = True + if ( + endpoint is not None + and + bearer is not None + and + endpoint != bearer + ): + raise ValueError( + f'`WGPeerConfig.endpoint={endpoint!r}` conflicts ' + f'with `WGTunnelSpec.bearer={bearer!r}`!' + ) + endpoint = endpoint or bearer + + values: dict[str, object] = { + 'public_key': peer.public_key, + } + if peer.allowed_ips: + values['allowed_ips'] = list(peer.allowed_ips) + values['replace_allowed_ips'] = True + if endpoint is not None: + values['endpoint_addr'] = endpoint[0] + values['endpoint_port'] = endpoint[1] + if peer.preshared_key is not None: + values['preshared_key'] = peer.preshared_key + if peer.persistent_keepalive is not None: + values['persistent_keepalive'] = ( + peer.persistent_keepalive + ) + peer_settings.append(values) + + if ( + role == 'dial' + and + not selected_peer + ): + configured_keys: tuple[str, ...] = tuple( + peer.public_key + for peer in config.peers + ) + raise ValueError( + f'Dial target {spec.peer_pubkey!r} is not in ' + f'configured peer keys {configured_keys!r}!' + ) + + return listen_port, tuple(peer_settings) + + +def _sync_create_wg_iface( + spec: WGTunnelSpec, + config: WGInterfaceConfig, + bindspace: Bindspace, + listen_port: int|None, + peers: tuple[dict[str, object], ...], +) -> None: + ''' + Create and configure one WireGuard iface through pyroute2. + + ''' + try: + from pyroute2 import ( + IPRoute, + WireGuard, + ) + except ImportError as exc: + raise RuntimeError( + 'WireGuard provisioning requires the ' + '`tractor[wg]` extra.' + ) from exc + + namespace_fd: int|None = bindspace.namespace_fd + ipr: Any = IPRoute( + netns=namespace_fd, + flags=0, + ) + created: bool = False + try: + ipr.link( + 'add', + ifname=spec.iface, + kind='wireguard', + ) + created = True + indices: list[int] = ipr.link_lookup( + ifname=spec.iface, + ) + if len(indices) != 1: + raise RuntimeError( + f'Expected one index for WG iface {spec.iface!r}, ' + f'got {indices!r}!' + ) + index: int = indices[0] + + address: str + for address in config.addresses: + interface: ( + ipaddress.IPv4Interface + | ipaddress.IPv6Interface + ) = ipaddress.ip_interface(address) + ipr.addr( + 'add', + index=index, + address=str(interface.ip), + prefixlen=interface.network.prefixlen, + ) + + wg: Any = WireGuard( + netns=namespace_fd, + flags=0, + ) + try: + wg.set( + spec.iface, + private_key=config.private_key, + listen_port=listen_port, + ) + peer: dict[str, object] + for peer in peers: + wg.set( + spec.iface, + peer=peer, + ) + finally: + wg.close() + + ipr.link( + 'set', + index=index, + state='up', + ) + except BaseException: + if created: + indices = ipr.link_lookup( + ifname=spec.iface, + ) + if indices: + ipr.link( + 'del', + index=indices[0], + ) + raise + finally: + ipr.close() + + +def _sync_remove_wg_iface( + spec: WGTunnelSpec, + bindspace: Bindspace, +) -> None: + ''' + Remove one owned WireGuard iface when it still exists. + + ''' + try: + from pyroute2 import IPRoute + except ImportError as exc: + raise RuntimeError( + 'WireGuard teardown requires the `tractor[wg]` extra.' + ) from exc + + ipr: Any = IPRoute( + netns=bindspace.namespace_fd, + flags=0, + ) + try: + indices: list[int] = ipr.link_lookup( + ifname=spec.iface, + ) + if indices: + ipr.link( + 'del', + index=indices[0], + ) + finally: + ipr.close() + + +@acm +async def open_wg_iface( + spec: WGTunnelSpec, + config: WGInterfaceConfig, + bindspace: Bindspace, + role: WGRole, +) -> AsyncIterator[WGTunnelSpec]: + ''' + Create, configure and own one WireGuard interface. + + ''' + listen_port: int|None + peers: tuple[dict[str, object], ...] + listen_port, peers = _wg_iface_settings( + spec, + config, + role, + ) + created: bool = False + try: + with trio.CancelScope(shield=True): + await trio.to_thread.run_sync( + _sync_create_wg_iface, + spec, + config, + bindspace, + listen_port, + peers, + abandon_on_cancel=False, + ) + created = True + yield spec + finally: + if created: + with trio.CancelScope(shield=True): + await trio.to_thread.run_sync( + _sync_remove_wg_iface, + spec, + bindspace, + abandon_on_cancel=False, + ) + + +@acm +async def open_wg_bindspace( + bindspace_spec: BindspaceSpec, + layers: Sequence[tuple[WGTunnelSpec, WGInterfaceConfig]], + role: WGRole, +) -> AsyncIterator[Bindspace]: + ''' + Open one bindspace and its ordered WireGuard interface stack. + + `layers` is an interface stack declared outermost first: + + application scope + | + layers[-1] <- last entered, first exited + | + ... + | + layers[0] <- first entered, last exited + | + bindspace + + `AsyncExitStack` builds it bottom-up in declaration order and + unwinds it top-down before the bindspace closes. + + ''' + layer_stack: tuple[ + tuple[WGTunnelSpec, WGInterfaceConfig], + ..., + ] = tuple(layers) + async with AsyncExitStack() as stack: + bindspace: Bindspace = await ( + stack.enter_async_context( + open_bindspace(bindspace_spec) + ) + ) + + layer: tuple[WGTunnelSpec, WGInterfaceConfig] + for layer in layer_stack: + tunnel_spec: WGTunnelSpec + config: WGInterfaceConfig + tunnel_spec, config = layer + await stack.enter_async_context( + open_wg_iface( + tunnel_spec, + config, + bindspace, + role, + ) + ) + + yield bindspace + + +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: + ''' + Deliver the installed `py-multiaddr` `/wg/` protocol code. + + `wg` support is merged upstream but not yet in a release, so + fail clearly when tractor was installed without the pinned rev. + + ''' + from multiaddr.exceptions import ProtocolNotFoundError + from multiaddr.protocols import protocol_with_name + + try: + return protocol_with_name('wg').code + except ProtocolNotFoundError as exc: + raise RuntimeError( + 'Installed `py-multiaddr` has no `/wg/` protocol!\n' + 'Install py-multiaddr#108 or use tractor\'s pinned ' + 'dependency revision.\n' + ) from exc + + +class TunnelledAddress( + msgspec.Struct, + frozen=True, + omit_defaults=True, +): + ''' + An `Address` annotated with the tunnel it must be reached + *through*. + + Address-level properties delegate to `.overlay`, so proto-key + guards and `.unwrap()` retain their existing meaning and + **nothing new crosses the wire**. Transport boundaries which + dispatch on exact type or declaring module must first call + `strip_tunnels()`. + + ''' + overlay: Address|TunnelledAddress + tunnel: TunnelSpec + bindspace_ref: BindspaceRef|None = None + + def __post_init__(self) -> None: + ''' + Validate the retained ref against the tunnel declaration. + + ''' + ref: BindspaceRef|None = self.bindspace_ref + if ref is None: + return + if not isinstance(ref, BindspaceRef): + raise TypeError( + '`TunnelledAddress.bindspace_ref` must be a ' + '`BindspaceRef` or `None`!' + ) + + declared_netns: str|None = self.tunnel.netns + if ( + declared_netns is not None + and + ref.key != declared_netns + ): + raise ValueError( + f'Declared netns {declared_netns!r} does not match ' + f'realized bindspace key {ref.key!r}!' + ) + + # ---- delegated, so the runtime can't tell the difference ---- + + @property + def proto_key(self) -> str: + ''' + The *overlay's* proto-key — a tunnel has no transport of + its own. + + NOTE, this is a property whereas `Address.proto_key` is + spec'd as a `ClassVar`. That's deliberate: the value is + only knowable per-instance here, and this type is never + registered in `_address_types`, so no class-level access + of it should ever occur. + + ''' + return self.overlay.proto_key + + @property + def is_valid(self) -> bool: + return self.overlay.is_valid + + @property + def bindspace(self) -> str: + return self.overlay.bindspace + + def unwrap(self) -> UnwrappedAddress: + ''' + Delegate to `.overlay`, so the tunnel annotation is + **not** serialized and no peer needs to understand it. + + ''' + return self.overlay.unwrap() + + # ---- the tunnel's own contribution ---- + + @property + def namespace(self) -> tuple[str, str|int]|None: + ''' + Return the realized ref or declared tunnel netns. + + ''' + ref: BindspaceRef|None = self.bindspace_ref + if ref is not None: + return ( + ref.kind, + ref.inode, + ) + + if (netns := self.tunnel.netns) is None: + return self.overlay.namespace + + return ('netns', netns) + + def with_bindspace_ref( + self, + ref: BindspaceRef, + ) -> TunnelledAddress: + ''' + Return a copy retaining one realized bindspace ref. + + ''' + realized: TunnelledAddress = msgspec.structs.replace( + self, + bindspace_ref=ref, + ) + return realized + + def __repr__(self) -> str: + return ( + f'{type(self).__name__}(\n' + f' overlay={self.overlay!r},\n' + f' via={self.tunnel.tunnel_key!r} ' + f'iface={self.tunnel.iface!r},\n' + f')' + ) + + +def _wg_bearer( + bearer_ma: Multiaddr, + source_ma: Multiaddr, +) -> tuple[str, int]: + ''' + Parse one kernel-owned `wg` bearer endpoint. + + ''' + proto_names: list[str] = [ + proto.name + for proto in bearer_ma.protocols() + ] + match proto_names: + case [('ip4' | 'ip6') as ip_proto, 'udp']: + return ( + bearer_ma.value_for_protocol(ip_proto), + int(bearer_ma.value_for_protocol('udp')), + ) + + case _: + raise ValueError( + f'Bad `wg` bearer, expected ' + f'`/ip4|ip6//udp/`\n' + f'got: {bearer_ma}\n' + f'from maddr: {source_ma}\n' + ) + + +def parse_wg_maddr( + maddr: str|Multiaddr, +) -> TunnelledAddress: + ''' + Parse a `wg` maddr stack into nested tunnel annotations. + + Pure: every segment operation delegates to `py-multiaddr`. + Repeated `.decapsulate_code()` calls peel the last `/wg/` + first, while `.split()` and `.join()` isolate that tunnel's + bearer without parsing slash-delimited strings ourselves. + + ''' + from multiaddr import Multiaddr + + ma: Multiaddr = ( + maddr + if isinstance(maddr, Multiaddr) + else Multiaddr(maddr) + ) + wg_code: int = _wg_proto_code() + segs: list[Multiaddr] = ma.split() + proto_names: list[str] = [ + proto.name + for seg in segs + for proto in seg.protocols() + ] + if 'wg' not in proto_names: + raise ValueError( + f'Not a `wg`-tunnelled maddr; no `/wg/` segment!\n' + f'maddr: {ma}\n' + ) + + final_wg_i: int = len(proto_names) - 1 + final_wg_i -= proto_names[::-1].index('wg') + overlay_ma: Multiaddr = Multiaddr.join( + *segs[final_wg_i + 1:] + ) + overlay_names: list[str] = [ + proto.name + for proto in overlay_ma.protocols() + ] + match overlay_names: + case [('ip4' | 'ip6'), 'tcp']: + from ..discovery._multiaddr import parse_maddr + overlay: Address|TunnelledAddress = parse_maddr( + str(overlay_ma) + ) + + case []: + raise ValueError( + f'`wg` maddr declares no overlay endpoint!\n' + f'Append the endpoint tractor should bind.\n' + f'maddr: {ma}\n' + ) + + case _: + raise ValueError( + f'Unsupported `wg` overlay protocol combo: ' + f'{overlay_names!r}\n' + f'overlay: {overlay_ma}\n' + f'from maddr: {ma}\n' + ) + + cursor: Multiaddr = ma + while any( + proto.name == 'wg' + for proto in cursor.protocols() + ): + cursor_segs: list[Multiaddr] = cursor.split() + cursor_names: list[str] = [ + proto.name + for seg in cursor_segs + for proto in seg.protocols() + ] + wg_i: int = len(cursor_names) - 1 + wg_i -= cursor_names[::-1].index('wg') + mb_key: str = cursor_segs[wg_i].value_for_protocol('wg') + + bearer_prefix: Multiaddr = cursor.decapsulate_code( + wg_code + ) + prefix_segs: list[Multiaddr] = bearer_prefix.split() + prefix_names: list[str] = [ + proto.name + for seg in prefix_segs + for proto in seg.protocols() + ] + prior_wg_i: int = ( + len(prefix_names) - 1 + - prefix_names[::-1].index('wg') + if 'wg' in prefix_names + else -1 + ) + bearer_ma: Multiaddr = Multiaddr.join( + *prefix_segs[prior_wg_i + 1:] + ) + overlay = TunnelledAddress( + overlay=overlay, + tunnel=WGTunnelSpec( + peer_pubkey=wg8_pubkey(mb_key), + bearer=_wg_bearer(bearer_ma, ma), + ), + ) + cursor = bearer_prefix + + return overlay + + +def mk_wg_maddr( + addr: TunnelledAddress, +) -> Multiaddr: + ''' + Compose nested tunnel annotations as a canonical `wg` maddr. + + Only the peer key and bearer have maddr representations. Local + interface, namespace, and allowed-IP config remains local. + + ''' + from multiaddr import Multiaddr + + _wg_proto_code() + if (bearer := addr.tunnel.bearer) is None: + raise ValueError( + f'Can not compose a `wg` maddr without a bearer!\n' + f'tunnel: {addr.tunnel!r}\n' + ) + + bindable: Address = strip_tunnels(addr) + if bindable.proto_key != 'tcp': + raise ValueError( + f'Unsupported `wg` overlay proto-key: ' + f'{bindable.proto_key!r}\n' + f'overlay: {bindable!r}\n' + ) + + host, port = bearer + ip = ipaddress.ip_address(host) + ip_proto: str = ( + 'ip4' + if ip.version == 4 + else 'ip6' + ) + bearer_ma = Multiaddr( + f'/{ip_proto}/{host}/udp/{port}' + ) + key_ma = Multiaddr( + f'/wg/{mb_pubkey(addr.tunnel.peer_pubkey)}' + ) + + from ..discovery._multiaddr import mk_maddr + overlay_ma: Multiaddr = mk_maddr(addr.overlay) + return ( + bearer_ma + .encapsulate(key_ma) + .encapsulate(overlay_ma) + ) + + +def strip_tunnels( + addr: Address|TunnelledAddress, +) -> Address: + ''' + Deliver the bindable `Address`, peeling any tunnel + annotation(s). + + Pure. Idempotent on an un-tunnelled `Address`, and loops so + a nested (tunnel-in-tunnel) stack collapses in one call. + + Call this at every bind/dial boundary. + + ''' + while isinstance(addr, TunnelledAddress): + addr = addr.overlay + + return addr + + +def tunnels_of( + addr: Address|TunnelledAddress, +) -> tuple[TunnelSpec, ...]: + ''' + Deliver every tunnel spec wrapping `addr`, outermost first. + + Pure; empty for an un-tunnelled `Address`. + + ''' + specs: list[TunnelSpec] = [] + while isinstance(addr, TunnelledAddress): + specs.append(addr.tunnel) + addr = addr.overlay + + return tuple(specs) diff --git a/tractor/runtime/_supervise.py b/tractor/runtime/_supervise.py index 66f873578..bf13fbda7 100644 --- a/tractor/runtime/_supervise.py +++ b/tractor/runtime/_supervise.py @@ -65,6 +65,7 @@ if TYPE_CHECKING: import multiprocessing as mp + from ..net._bindspace import Bindspace # from ..ipc._server import IPCServer from ..ipc import IPCServer from ..spawn._spawn import ProcessType @@ -419,6 +420,7 @@ async def start_actor( *, bind_addrs: list[UnwrappedAddress]|None = None, + bindspace: 'Bindspace|None' = None, rpc_module_paths: list[str]|None = None, enable_transports: list[str] = [_state._def_tpt_proto], enable_modules: list[str]|None = None, @@ -504,6 +506,7 @@ async def start_actor( bind_addrs, parent_addr, _rtv, # run time vars + bindspace=bindspace, infect_asyncio=infect_asyncio, proc_kwargs=proc_kwargs ) diff --git a/tractor/spawn/_entry.py b/tractor/spawn/_entry.py index 83f1d3ab2..0ef7acebb 100644 --- a/tractor/spawn/_entry.py +++ b/tractor/spawn/_entry.py @@ -21,6 +21,7 @@ from __future__ import annotations from functools import partial import multiprocessing as mp +import os from typing import ( Any, TYPE_CHECKING, @@ -48,6 +49,7 @@ async_main, Actor, ) +from ._netns import enter_netns if TYPE_CHECKING: from ._spawn import SpawnMethodKey @@ -56,6 +58,54 @@ log = get_logger() +def _consume_netns_bootstrap( + netns_bootstrap: tuple[int, int]|None, +) -> int|None: + ''' + Enter and release one child-owned network namespace capability. + + The FD must be an exclusively child-owned backend duplicate. It is + closed whether namespace entry succeeds or fails, before any actor + runtime setup can continue. + + ''' + if netns_bootstrap is None: + return None + + namespace_fd: int + expected_inode: int + namespace_fd, expected_inode = netns_bootstrap + if ( + type(namespace_fd) is not int + or + namespace_fd < 0 + ): + # Let `enter_netns()` report its precise validation error, but + # never pass bool/non-int/negative values to `os.close()`. + return enter_netns( + namespace_fd, + expected_inode, + ) + + try: + entered_inode: int = enter_netns( + namespace_fd, + expected_inode, + ) + except BaseException as entry_error: + try: + os.close(namespace_fd) + except Exception as close_error: + entry_error.add_note( + f'Also failed to close inherited namespace FD ' + f'{namespace_fd}: {close_error!r}' + ) + raise + else: + os.close(namespace_fd) + return entered_inode + + def _mp_main( actor: Actor, @@ -64,12 +114,18 @@ def _mp_main( start_method: SpawnMethodKey, parent_addr: UnwrappedAddress | None = None, infect_asyncio: bool = False, + netns_bootstrap: tuple[int, int]|None = None, ) -> None: ''' The routine called *after fork* which invokes a fresh `trio.run()` + Consume `netns_bootstrap` before multiprocessing or actor-runtime + setup. The spawn backend must supply a child-owned FD duplicate. + ''' + _consume_netns_bootstrap(netns_bootstrap) + actor._forkserver_info = forkserver_info from ._spawn import try_set_start_method spawn_ctx: mp.context.BaseContext = try_set_start_method(start_method) diff --git a/tractor/spawn/_mp.py b/tractor/spawn/_mp.py index b0a2b5296..e0ffc434c 100644 --- a/tractor/spawn/_mp.py +++ b/tractor/spawn/_mp.py @@ -54,6 +54,7 @@ if TYPE_CHECKING: + from tractor.net._bindspace import Bindspace from tractor.ipc import ( _server, ) @@ -73,12 +74,19 @@ async def mp_proc( parent_addr: UnwrappedAddress, _runtime_vars: dict[str, Any], # serialized and sent to _child *, + bindspace: Bindspace|None = None, infect_asyncio: bool = False, task_status: TaskStatus[Portal] = trio.TASK_STATUS_IGNORED, proc_kwargs: dict[str, any] = {} ) -> None: + if bindspace is not None: + raise NotImplementedError( + 'Network namespace bindspace transport is not yet ' + 'supported by multiprocessing spawn backends!' + ) + # uggh zone try: from multiprocessing import semaphore_tracker # type: ignore diff --git a/tractor/spawn/_netns.py b/tractor/spawn/_netns.py new file mode 100644 index 000000000..9c3d0f00d --- /dev/null +++ b/tractor/spawn/_netns.py @@ -0,0 +1,284 @@ +# 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 . + +''' +Linux network-namespace actor-bootstrap primitives. + +''' +from __future__ import annotations + +from collections.abc import ( + Callable, + Iterator, +) +from contextlib import contextmanager as cm +import errno +from pathlib import Path +import os +import sys +from typing import TYPE_CHECKING + + +if TYPE_CHECKING: + from ..net._bindspace import Bindspace + + +# `setns(2)` mutates only the calling thread's namespace: +# https://man7.org/linux/man-pages/man2/setns.2.html +# `/proc/thread-self` addresses the caller's current task: +# https://man7.org/linux/man-pages/man5/proc_pid_task.5.html +# Do not use `/proc/self` because it resolves through the process +# leader. +_SELF_NETNS: Path = Path('/proc/thread-self/ns/net') + + +def enter_netns( + namespace_fd: int, + expected_inode: int, +) -> int: + ''' + Enter and verify one inherited Linux network namespace. + + The caller owns and closes `namespace_fd`. + + ''' + if sys.platform != 'linux': + raise RuntimeError( + 'Network namespace entry is Linux-only!' + ) + if ( + type(namespace_fd) is not int + or + namespace_fd < 0 + ): + raise ValueError( + '`namespace_fd` must be a non-negative `int`!' + ) + if ( + type(expected_inode) is not int + or + expected_inode <= 0 + ): + raise ValueError( + '`expected_inode` must be a positive `int`!' + ) + + setns: Callable[[int, int], None]|None = getattr( + os, + 'setns', + None, + ) + clone_newnet: int|None = getattr( + os, + 'CLONE_NEWNET', + None, + ) + if ( + setns is None + or + clone_newnet is None + ): + raise RuntimeError( + 'Python has no Linux network namespace entry support!' + ) + + inherited_inode: int = os.fstat(namespace_fd).st_ino + if inherited_inode != expected_inode: + raise ValueError( + f'Inherited namespace FD inode {inherited_inode} does not ' + f'match expected inode {expected_inode}!' + ) + + try: + setns(namespace_fd, clone_newnet) + except OSError as exc: + raise RuntimeError( + f'Could not enter network namespace inode ' + f'{expected_inode}!' + ) from exc + + entered_inode: int = _SELF_NETNS.stat().st_ino + if entered_inode != expected_inode: + raise RuntimeError( + f'Entered network namespace inode {entered_inode} does not ' + f'match expected inode {expected_inode}!' + ) + + return entered_inode + + +@cm +def close_fd( + owned_fd: int, + fd_name: str, +) -> Iterator[None]: + ''' + Close one owned netns FD without masking a prior error. + + ''' + operation: str = ( + f'close owned {fd_name} netns FD {owned_fd}' + ) + try: + yield + except BaseException as primary_error: + try: + os.close(owned_fd) + except BaseException as close_error: + primary_error.add_note( + f'Also failed to {operation}: {close_error!r}' + ) + raise primary_error + else: + try: + os.close(owned_fd) + except BaseException as close_error: + close_error.add_note( + f'Failed to {operation} during root netns cleanup.' + ) + raise close_error + + +@cm +def dup_fd( + source_fd: int, +) -> Iterator[int]: + ''' + Duplicate and own the target netns FD for this context. + + ''' + try: + owned_fd: int = os.dup(source_fd) + except OSError as dup_error: + if dup_error.errno != errno.EBADF: + raise dup_error + raise ValueError( + '`bindspace.namespace_fd` does not reference ' + 'a live FD!' + ) from dup_error + + with close_fd(owned_fd, 'target'): + yield owned_fd + + +@cm +def _enter_netns_temporarily( + bindspace: Bindspace|None, +) -> Iterator[int|None]: + ''' + Enter a root bindspace and restore the caller thread's netns. + + `_root._enter_root_bindspace()` adapts this synchronous scope to + the root actor's async lifecycle. + + Only descriptors opened or duplicated by this context are used + for validation, entry and restoration. Since `setns()` is + thread-local, this synchronous context performs no checkpoints + around either transition. + + ''' + if bindspace is None: + yield None + return + + if sys.platform != 'linux': + raise RuntimeError( + 'Network namespace entry is Linux-only!' + ) + + namespace_fd: int|None = bindspace.namespace_fd + if namespace_fd is None: + raise ValueError( + '`bindspace.namespace_fd` must be a live netns FD for ' + 'root actor entry!' + ) + if ( + type(namespace_fd) is not int + or + namespace_fd < 0 + ): + raise ValueError( + '`bindspace.namespace_fd` must be a live ' + 'non-negative FD!' + ) + + # Borrow `Bindspace.namespace_fd`; duplicate it so this context + # owns target cleanup and cannot close the caller's capability. + # Nested FD scopes aggregate later close failures as notes on the + # first body, restoration or cleanup error. + with dup_fd(namespace_fd) as tgt_fd: + tgt_stat: os.stat_result = os.fstat(tgt_fd) + tgt_inode: int = bindspace.ref.inode + if tgt_stat.st_ino != tgt_inode: + raise ValueError( + f'Target namespace FD inode ' + f'{tgt_stat.st_ino} does ' + f'not match bindspace inode {tgt_inode}!' + ) + + # Capture the calling thread's current netns before any + # transition. It need not be the process's initial netns. This + # context owns the snapshot FD even when no transition is + # needed, so keep it live through restoration and always close + # it afterward. + orig_fd = os.open( + _SELF_NETNS, + os.O_RDONLY | os.O_CLOEXEC, + ) + with close_fd(orig_fd, 'original'): + orig_stat: os.stat_result = os.fstat(orig_fd) + orig_inode: int = orig_stat.st_ino + restore_needed: bool = ( + tgt_stat.st_dev != orig_stat.st_dev + or + tgt_inode != orig_inode + ) + try: + if restore_needed: + enter_netns( + tgt_fd, + tgt_inode, + ) + + yield tgt_inode + + except BaseException as primary_error: + if restore_needed: + try: + enter_netns( + orig_fd, + orig_inode, + ) + except BaseException as restore_error: + primary_error.add_note( + 'Also failed to restore the original ' + 'network namespace: ' + f'{restore_error!r}' + ) + raise primary_error + + if restore_needed: + try: + enter_netns( + orig_fd, + orig_inode, + ) + except BaseException as restore_error: + restore_error.add_note( + 'Failed to restore the original network ' + 'namespace during root netns cleanup.' + ) + raise restore_error diff --git a/tractor/spawn/_spawn.py b/tractor/spawn/_spawn.py index 80f4579a6..93481d565 100644 --- a/tractor/spawn/_spawn.py +++ b/tractor/spawn/_spawn.py @@ -34,6 +34,7 @@ import trio from trio import TaskStatus +from .._exceptions import ActorFailure from ..devx import debug from tractor.runtime._state import ( _runtime_vars, @@ -49,7 +50,9 @@ if TYPE_CHECKING: + from tractor.net._bindspace import Bindspace from tractor.ipc import ( + _server, Channel, ) from tractor.runtime._supervise import ActorNursery @@ -81,6 +84,101 @@ async def proc_waiter(proc: mp.Process) -> None: await trio.lowlevel.wait_readable(proc.sentinel) +async def wait_for_peer_or_proc_death( + ipc_server: _server.Server, + uid: tuple[str, str], + proc_wait: Callable[[], Awaitable[int]], + proc_repr: object = '', +) -> tuple[trio.Event, Channel]: + ''' + Race a child handshake against process death during bootstrap. + + A child can exit before connecting to its parent. Waiting only on + `IPCServer.wait_for_peer()` would then park its spawning task and + leave the dead process unreaped. Run both waits in one nursery and + let either completed result cancel its sibling. + + Return the normal peer event and channel when the handshake wins. + Raise `ActorFailure` with the process status when death wins. + + Adapted from goodboy's Claude Code-assisted implementation in + commit `3b0724eba85b4014170ed95773e1e41a60d5c513`. + + ''' + handshake: tuple[trio.Event, Channel]|None = None + handshake_error: BaseException|None = None + returncode: int|None = None + death_error: BaseException|None = None + + async def wait_for_handshake() -> None: + ''' + Publish a connected peer before cancelling the death waiter. + + ''' + nonlocal handshake + nonlocal handshake_error + try: + handshake = await ipc_server.wait_for_peer(uid) + except trio.Cancelled: + if ( + returncode is not None + or + death_error is not None + ): + log.debug( + 'Peer-handshake waiter cancelled after ' + f'process wait completed for {uid!r}' + ) + raise + except BaseException as exc: + handshake_error = exc + nursery.cancel_scope.cancel() + + async def wait_for_death() -> None: + ''' + Publish child exit before cancelling the handshake waiter. + + ''' + nonlocal returncode + nonlocal death_error + try: + returncode = await proc_wait() + except trio.Cancelled: + if ( + handshake is not None + or + handshake_error is not None + ): + log.debug( + 'Process-death waiter cancelled after ' + f'peer wait completed for {uid!r}' + ) + raise + except BaseException as exc: + death_error = exc + nursery.cancel_scope.cancel() + + async with trio.open_nursery() as nursery: + nursery.start_soon(wait_for_handshake) + nursery.start_soon(wait_for_death) + + if handshake_error is not None: + raise handshake_error + if death_error is not None: + raise death_error + + if returncode is not None: + raise ActorFailure( + f'Sub-actor {uid!r} died during boot ' + f'(rc={returncode!r}) before completing ' + f'parent-handshake.\n' + f' proc: {proc_repr}' + ) + + assert handshake is not None + return handshake + + def try_set_start_method( key: SpawnMethodKey @@ -342,6 +440,7 @@ async def new_proc( *, + bindspace: Bindspace|None = None, infect_asyncio: bool = False, task_status: TaskStatus[Portal] = trio.TASK_STATUS_IGNORED, proc_kwargs: dict[str, any] = {} @@ -363,6 +462,7 @@ async def new_proc( bind_addrs, parent_addr, _runtime_vars, # run time vars + bindspace=bindspace, infect_asyncio=infect_asyncio, task_status=task_status, proc_kwargs=proc_kwargs diff --git a/tractor/spawn/_trio.py b/tractor/spawn/_trio.py index 447ac04e0..c69418b05 100644 --- a/tractor/spawn/_trio.py +++ b/tractor/spawn/_trio.py @@ -22,6 +22,7 @@ ''' from __future__ import annotations +import os import sys from typing import ( Any, @@ -51,10 +52,12 @@ from ._spawn import ( hard_kill, soft_kill, + wait_for_peer_or_proc_death, ) if TYPE_CHECKING: + from tractor.net._bindspace import Bindspace from tractor.ipc import ( _server, ) @@ -75,29 +78,31 @@ async def trio_proc( parent_addr: UnwrappedAddress, _runtime_vars: dict[str, Any], # serialized and sent to _child *, + bindspace: Bindspace|None = None, infect_asyncio: bool = False, task_status: TaskStatus[Portal] = trio.TASK_STATUS_IGNORED, proc_kwargs: dict[str, any] = {} ) -> None: ''' - Create a new ``Process`` using a "spawn method" as (configured using - ``try_set_start_method()``). + Create a new ``Process`` using a "spawn method" as (configured + using ``try_set_start_method()``). - This routine should be started in a actor runtime task and the logic - here is to be considered the core supervision strategy. + This routine should be started in a actor runtime task and the + logic here is to be considered the core supervision strategy. ''' spawn_cmd = [ sys.executable, "-m", - # Hardcode this (instead of using ``_child.__name__`` to avoid a - # double import warning: https://stackoverflow.com/a/45070583 + # Hardcode this (instead of using ``_child.__name__`` to + # avoid a double import warning: + # https://stackoverflow.com/a/45070583 "tractor._child", # We provide the child's unique identifier on this exec/spawn - # line for debugging purposes when viewing the process tree from - # the OS; it otherwise can be passed via the parent channel if - # we prefer in the future (for privacy). + # line for debugging purposes when viewing the process tree + # from the OS; it otherwise can be passed via the parent + # channel if we prefer in the future (for privacy). "--uid", # TODO, how to pass this over "wire" encodings like # cmdline args? @@ -115,20 +120,79 @@ async def trio_proc( ] # Tell child to run in guest mode on top of ``asyncio`` loop if infect_asyncio: - spawn_cmd.append("--asyncio") + spawn_cmd.append('--asyncio') + + child_netns_fd: int|None = None + if bindspace is not None: + if (namespace_fd := bindspace.namespace_fd) is None: + raise ValueError( + '`bindspace.namespace_fd` is required for ' + 'Trio child transport!' + ) + + # Snapshot caller-owned process options before duplicating the + # live `Bindspace.namespace_fd`. No checkpoint separates this + # setup from `open_process()` below. + inherited_fds: tuple[int, ...] = tuple( + proc_kwargs.get('pass_fds', ()) + ) + proc_kwargs = dict(proc_kwargs) + child_netns_fd = os.dup(namespace_fd) + try: + netns_bootstrap: tuple[int, int] = ( + # FD number retained in the child's descriptor table. + child_netns_fd, + # Namespace identity checked before the child enters it. + bindspace.ref.inode, + ) + spawn_cmd.extend(( + '--netns_bootstrap', + str(netns_bootstrap), + )) + + # Keep every descriptor requested by the caller and append + # the namespace FD needed during child bootstrap. + proc_kwargs['pass_fds'] = ( + *inherited_fds, + child_netns_fd, + ) + except BaseException: + os.close(child_netns_fd) + raise cancelled_during_spawn: bool = False proc: trio.Process|None = None ipc_server: _server.Server = actor_nursery._actor.ipc_server + peer_event: trio.Event|None = None + child_registered: bool = False try: try: - proc: trio.Process = await trio.lowlevel.open_process(spawn_cmd, **proc_kwargs) + try: + proc: trio.Process = await trio.lowlevel.open_process( + spawn_cmd, + **proc_kwargs, + ) + finally: + if child_netns_fd is not None: + # The child now has its own descriptor-table entry. + # Close the temporary entry in the parent process. + os.close(child_netns_fd) log.runtime( f'Started new child subproc\n' f'(>\n' f' |_{proc}\n' ) + # `ActorNursery.cancel()` may inspect this event as soon + # as the provisional child is published below. Register + # the event synchronously before + # `wait_for_peer_or_proc_death()` opens its nursery and + # checkpoints. + peer_event = ipc_server._peer_connected.setdefault( + subactor.aid.uid, + trio.Event(), + ) + # No `Portal` exists until the IPC handshake returns # `chan`. Replace this provisional entry with # `Portal(chan)` below. @@ -141,6 +205,7 @@ async def trio_proc( proc=proc, portal=None, ) + child_registered = True if cancel_during_registration: cancelled_during_spawn = True proc.kill() @@ -152,8 +217,11 @@ async def trio_proc( # wait for actor to spawn and connect back to us # channel should have handshake completed by the # local actor by the time we get a ref to it - event, chan = await ipc_server.wait_for_peer( - subactor.aid.uid + event, chan = await wait_for_peer_or_proc_death( + ipc_server=ipc_server, + uid=subactor.aid.uid, + proc_wait=proc.wait, + proc_repr=proc, ) except trio.Cancelled: @@ -269,21 +337,21 @@ async def trio_proc( # to hold off on relaying SIGINT until that child # is complete. # https://github.com/goodboy/tractor/issues/320 - # -[ ] we need to handle non-root parent-actors specially - # by somehow determining if a child is in debug and then - # avoiding cancel/kill of said child by this - # (intermediary) parent until such a time as the root says - # the pdb lock is released and we are good to tear down - # (our children).. + # -[ ] we need to handle non-root parent-actors + # specially by somehow determining if a child is in + # debug and then avoiding cancel/kill of said child + # by this (intermediary) parent until such a time as + # the root says the pdb lock is released and we are + # good to tear down (our children).. # # -[ ] so maybe something like this where we try to - # acquire the lock and get notified of who has it, - # check that uid against our known children? + # acquire the lock and get notified of who has + # it, check that uid against our known children? # this_uid: tuple[str, str] = current_actor().uid # await debug.acquire_debug_lock(this_uid) if proc.poll() is None: - log.cancel(f"Attempting to hard kill {proc}") + log.cancel(f'Attempting to hard kill {proc}') await hard_kill( proc, # NOTE, pass through so post-SIGKILL we @@ -302,11 +370,24 @@ async def trio_proc( subactor=subactor, ) - log.debug(f"Joined {proc}") + log.debug(f'Joined {proc}') else: log.warning('Nursery cancelled before sub-proc started') - if not cancelled_during_spawn: + if ( + peer_event is not None + and + ipc_server._peer_connected.get( + subactor.aid.uid, + ) is peer_event + ): + ipc_server._peer_connected.pop(subactor.aid.uid) + + if ( + child_registered + and + not cancelled_during_spawn + ): # pop child entry to indicate we no longer managing this # subactor actor_nursery._children.pop(subactor.aid.uid) diff --git a/tractor/to_actor/_api.py b/tractor/to_actor/_api.py index 7e6de264b..0b54e7aab 100644 --- a/tractor/to_actor/_api.py +++ b/tractor/to_actor/_api.py @@ -61,6 +61,7 @@ if TYPE_CHECKING: from ..discovery._addr import UnwrappedAddress + from ..net._bindspace import Bindspace from ..runtime._portal import Portal @@ -254,6 +255,7 @@ async def run( # is provided. name: str|None = None, bind_addrs: list[UnwrappedAddress]|None = None, + bindspace: Bindspace|None = None, enable_modules: list[str]|None = None, loglevel: str|None = None, debug_mode: bool|None = None, @@ -337,6 +339,7 @@ async def run( (enable_modules or []) ), bind_addrs=bind_addrs, + bindspace=bindspace, loglevel=loglevel, debug_mode=debug_mode, infect_asyncio=infect_asyncio,