Sketch first-child `wgman` supervisor

Add a candidate Layer-C architecture where a private, eagerly
spawned manager owns pyroute2 and tunnel provisioning for a simple
WG-enabled actor tree.

Deats,
- overlap manager reconciliation with sibling process startup
- contain `AsyncWireGuard` in an infected-asyncio child
- limit requests and capabilities by bindspace security domain
- define readiness, crash, restart and teardown semantics
- retain pre-provisioned and multi-manager escape hatches

(this patch was generated in some part by `opencode` using `gpt-5.6-sol` (`openai`))
Gud Boi 2026-08-21 22:28:03 -04:00
parent f671b589b7
commit e4dc8d2406
1 changed files with 91 additions and 16 deletions

View File

@ -243,7 +243,7 @@ side-effect-free; verification is the *caller's* explicit step
(`proto_key`/`unwrap` identical to overlay), `wrap_address()` (`proto_key`/`unwrap` identical to overlay), `wrap_address()`
regression (a tunnelled maddr `str``TunnelledAddress`; a regression (a tunnelled maddr `str``TunnelledAddress`; a
plain one → unchanged), and **a real end-to-end over a plain one → unchanged), and **a real end-to-end over a
locally-created wg pair** gated on `CAP_NET_ADMIN` (see §5.3). locally-created wg pair** gated on `CAP_NET_ADMIN` (see §5.4).
--- ---
@ -289,9 +289,12 @@ converting anything else — there is no perf argument here, only
a "no foreign event loop in a trio actor" argument, which (1) a "no foreign event loop in a trio actor" argument, which (1)
already satisfies (a thread is not an event loop). already satisfies (a thread is not an event loop).
Explicitly **do not** pull in `trio-asyncio` for pyroute2: it Explicitly **do not** pull in `trio-asyncio` for pyroute2 or infect
would be the one place in the runtime where an asyncio loop every wg-using actor merely to service one-shot netlink calls. A
exists for no reason. dedicated `wgman` actor (§5.1) is the one plausible asyncio-hosted
shape: it can use tractor's own `.to_asyncio` task linkage while
keeping the foreign loop and provisioning authority out of ordinary
actor processes.
### 4.2 API shape ### 4.2 API shape
@ -327,7 +330,68 @@ describes the data-plane socket, not who provisions it: tractor owns
the lifecycle while `Endpoint`/`MsgTransport` remain responsible only the lifecycle while `Endpoint`/`MsgTransport` remain responsible only
for the overlay application socket. for the overlay application socket.
### 5.1 the composition ### 5.1 candidate default: first-child `wgman`
For a WG-enabled deployment profile, consider eagerly spawning one
private **WireGuard manager** (`wgman`) as the root actor's logical
first child. It is a narrow network-control-plane service, not a
general worker and not an application-visible transport endpoint.
The naive profile gets one manager for the actor tree; advanced
deployments may disable it for pre-provisioned networking or place
one manager in each capability/bindspace security domain.
"First child" describes supervision and teardown ordering, not a
serial startup barrier. Submit the `wgman` spawn in the same startup
wave as ordinary children, start its pyroute2 import, generic-netlink
discovery and declared-tunnel reconciliation immediately, and publish
a readiness signal separately. Sibling processes can boot in parallel;
only their first WG-dependent bind/dial waits for manager readiness.
This overlaps setup with actor-tree startup and avoids every sibling
paying its own pyroute2/loop/socket initialization latency.
The initial manager can still call the sync helpers from §4.1. A
natural follow-up is to spawn it with `infect_asyncio=True` and keep
`AsyncWireGuard` clients alive on asyncio's host loop through
`tractor.to_asyncio.run_task()`. Tractor then owns cross-loop task
linkage, cancellation and error propagation, while normal siblings
remain plain Trio actors. Keep one client per realized namespace or
other kernel control domain; do not share a pyroute2 socket across
domains merely to reduce object count.
Keep the authority surface deliberately small:
- accept structured inspect/verify/ensure/release requests derived
from `WGTunnelSpec`, `BindspaceSpec` and explicit `role`; never
expose arbitrary pyroute2 calls, shell commands or `setns()` RPC;
- let the root/supervisor mediate access initially, or hand siblings
a scoped manager capability; do not register a privileged `wgman`
endpoint for unrestricted cluster-wide discovery;
- never return private keys or namespace FDs to application actors;
pass secrets and live capabilities into the manager through the
supervisor-owned bootstrap path;
- grant only the capabilities required for the manager's assigned
domain. Prefer a manager already placed in that user/net namespace
over one process holding ambient authority across every namespace;
- make ensure/release idempotent and reference-count ownership so one
sibling cannot tear down a tunnel still borrowed by another.
The root owns the manager's lifetime. `wgman` must outlive all
siblings borrowing its tunnels and exit before the root drops the
underlying namespace/capability handles. A manager crash fails closed:
dependent operations receive an explicit service error; restart, if
enabled, reconciles declared state idempotently before advertising
readiness again. Do not silently let siblings fall back to privileged
local provisioning, since that defeats both the security boundary and
the single warm control-plane benefit.
Treat eager `wgman` as a measured deployment-profile choice. Compare
root startup with no WG declarations, pre-provisioned read-only WG,
and runtime-managed tunnels before making it unconditional whenever
the `wg` extra is installed. The intended invariant is "one warm
manager per simple WG actor tree", not "every tractor program spawns
a privileged child".
### 5.2 the composition
The maddr describes the composed network path and can be used as The maddr describes the composed network path and can be used as
either a source/listen or destination/dial handle. It does **not** either a source/listen or destination/dial handle. It does **not**
@ -430,9 +494,9 @@ entries. Extend it to carry the tunnel stack, not to *enter* it.
The caller supplies `role`; do not infer it from maddr shape. The same The caller supplies `role`; do not infer it from maddr shape. The same
composed maddr can name a server source or client destination, and the composed maddr can name a server source or client destination, and the
required local provisioning/ownership differs (§5.3). required local provisioning/ownership differs (§5.4).
### 5.2 `Address.namespace`, at last ### 5.3 `Address.namespace`, at last
- `TunnelledAddress.namespace``(kind, id)` e.g. - `TunnelledAddress.namespace``(kind, id)` e.g.
`('netns', 'tractor-wg0')`. `('netns', 'tractor-wg0')`.
@ -454,7 +518,7 @@ pair. Layer C should move that shape into `BindspaceIdentity`, avoid a
subprocess where netlink/procfs suffices, and hold the namespace FD in subprocess where netlink/procfs suffices, and hold the namespace FD in
`BindspaceHandle` to pin the identity. `BindspaceHandle` to pin the identity.
### 5.3 the netns/process reality — read this before designing ### 5.4 the netns/process reality — read this before designing
**The headline consequence, stated up front**: netns is a **The headline consequence, stated up front**: netns is a
**runtime-level config API, not an actor-app-code API.** It is **runtime-level config API, not an actor-app-code API.** It is
@ -533,7 +597,7 @@ server bound in the old namespace.
tolerance and `_serve_ipc_eps()`'s per-ep `try/except` tolerance and `_serve_ipc_eps()`'s per-ep `try/except`
encode. Mirror both. encode. Mirror both.
### 5.4 tests for layer C ### 5.5 tests for layer C
- unit: fold-N-tunnel-specs-into-nested-`@acm`s, with fakes; assert - unit: fold-N-tunnel-specs-into-nested-`@acm`s, with fakes; assert
enter/exit ordering (outermost-last-out) via a trace list. enter/exit ordering (outermost-last-out) via a trace list.
@ -543,7 +607,7 @@ server bound in the old namespace.
one and a subactor in the other, `find_actor()` across the one and a subactor in the other, `find_actor()` across the
tunnel. This is a *fantastic* test to have and is fully tunnel. This is a *fantastic* test to have and is fully
self-contained — no second host, no `sudo` in the test body. self-contained — no second host, no `sudo` in the test body.
- the `to_thread`-netns-mismatch regression from §5.3, written - the `to_thread`-netns-mismatch regression from §5.4, written
**first** (red), then the fix (green), per project convention. **first** (red), then the fix (green), per project convention.
- bootstrap ordering: assert the child reports the expected namespace - bootstrap ordering: assert the child reports the expected namespace
inode before parent-channel connect and listener creation. inode before parent-channel connect and listener creation.
@ -552,6 +616,14 @@ server bound in the old namespace.
- privilege drop: prove actor code lacks provisioning caps after entry. - privilege drop: prove actor code lacks provisioning caps after entry.
- role/ownership: fake listen/dial resources and assert owned listener - role/ownership: fake listen/dial resources and assert owned listener
teardown versus borrowed dial-handle release. teardown versus borrowed dial-handle release.
- `wgman` bootstrap: prove sibling process startup overlaps manager
reconciliation while the first WG operation still waits for its
readiness signal.
- `wgman` authority: reject arbitrary callers/operations and prove an
unprivileged sibling cannot receive secrets, FDs or provisioning
authority through the manager API.
- `wgman` lifetime: prove it outlives tunnel borrowers, fails pending
requests explicitly on crash and reconciles before restart-ready.
--- ---
@ -566,7 +638,7 @@ machinery covers any iface-layer tunnel `pyroute2` can drive —
`kind: ClassVar[str]`, and dispatch `open_*` by `match` on it. `kind: ClassVar[str]`, and dispatch `open_*` by `match` on it.
Design for it now (union + `match`), implement only `wg` + Design for it now (union + `match`), implement only `wg` +
`netns`. `veth`-pairs-in-netns is the natural second one because `netns`. `veth`-pairs-in-netns is the natural second one because
it makes the §5.4 integration test possible without wg at all — it makes the §5.5 integration test possible without wg at all —
consider doing it *first* for exactly that reason. consider doing it *first* for exactly that reason.
## 7. Non-goals ## 7. Non-goals
@ -584,7 +656,7 @@ consider doing it *first* for exactly that reason.
| risk | mitigation | | risk | mitigation |
| --- | --- | | --- | --- |
| `to_thread` worker runs in the wrong netns | §5.3; pass `netns=` to pyroute2 or pin a worker; test-first | | `to_thread` worker runs in the wrong netns | §5.4; pass `netns=` to pyroute2 or pin a worker; test-first |
| namespace name is renamed/replaced between provision and spawn | pass an open namespace FD; verify `(key, inode)` after child entry | | namespace name is renamed/replaced between provision and spawn | pass an open namespace FD; verify `(key, inode)` after child entry |
| child starts sockets/threads before `setns()` | enter in the spawn bootstrap trampoline before `_runtime.async_main()`; assert inode ordering | | child starts sockets/threads before `setns()` | enter in the spawn bootstrap trampoline before `_runtime.async_main()`; assert inode ordering |
| ambient capabilities leak into actor app code | split provision/enter authority and drop caps before runtime initialization | | ambient capabilities leak into actor app code | split provision/enter authority and drop caps before runtime initialization |
@ -592,7 +664,9 @@ consider doing it *first* for exactly that reason.
| py-multiaddr#108 merged but unreleased | PEP 621 direct-revision pin + `_wg_proto_code()` gate; replace with a release floor once published | | py-multiaddr#108 merged but unreleased | PEP 621 direct-revision pin + `_wg_proto_code()` gate; replace with a release floor once published |
| `TunnelledAddress` leaks into transport reflection/type dispatch | keep wrappers through declaration/bindspace handling, call `strip_tunnels()` at channel/endpoint boundaries, and retain the boundary regressions | | `TunnelledAddress` leaks into transport reflection/type dispatch | keep wrappers through declaration/bindspace handling, call `strip_tunnels()` at channel/endpoint boundaries, and retain the boundary regressions |
| privileged ops in a library | never `sudo`; explicit cap probe + actionable error; pre-provisioned is the default | | privileged ops in a library | never `sudo`; explicit cap probe + actionable error; pre-provisioned is the default |
| pyroute2 0.9 asyncio core drags a loop into the actor | option (1) is a *thread*, not a loop; forbid `trio-asyncio` here (§4.1) | | pyroute2 0.9 asyncio core drags a loop into every actor | use a worker for one-shots; confine persistent asyncio to an infected `wgman` (§4.1, §5.1) |
| eager `wgman` serializes or slows root bootstrap | spawn it in parallel; gate only WG-dependent operations on readiness; measure before making the profile unconditional |
| `wgman` becomes a cluster-wide privilege oracle | keep it private/scoped, expose structured verbs only and split managers by capability domain |
| netns teardown strands actor teardown | idempotent/tolerant teardown mirroring `_uds.close_listener()` | | netns teardown strands actor teardown | idempotent/tolerant teardown mirroring `_uds.close_listener()` |
## 9. Follow-up issue seeds ## 9. Follow-up issue seeds
@ -604,6 +678,7 @@ consider doing it *first* for exactly that reason.
- `wg` proto into the multiaddr **spec** (gh #483), then flip - `wg` proto into the multiaddr **spec** (gh #483), then flip
`MsgTransport.maddr` to always return `Multiaddr` (the third `MsgTransport.maddr` to always return `Multiaddr` (the third
#443 bullet) #443 bullet)
- runtime-managed wg key rotation / peer add-remove as a - first-child `wgman` prototype: concurrent bootstrap, scoped sibling
`tractor` service actor — the natural "actor that owns the access, infected-asyncio pyroute2 ownership and restart reconciliation
network" demo - runtime-managed wg key rotation / peer add-remove through `wgman`
the natural "actor that owns the network" demo