Use discovery's `wg` parser in examples
Drop the example-local address struct and hand-rolled single-tunnel parser now that discovery owns the production implementation. Keep only the explicit `wg(8)` peer probe in the multihost helper, and update the examples and plan for nested parsing, packaged codec dependencies and tractor-owned bindspace provisioning. Prompt-IO: ai/prompt-io/opencode/20260818T075031Z_dd02c7c0_prompt_io.md (this patch was generated in some part by `opencode` using `gpt-5.6-sol` (`openai`))piker_pin
parent
a26a0a48b6
commit
22e154900b
|
|
@ -19,35 +19,35 @@ onto `trio` as the library's sans-io layer allows.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1. What exists today (verified, per #482)
|
## 1. What exists today (derived from #482)
|
||||||
|
|
||||||
- `wrap_address()` accepts maddr `str`s (leading-`/` dispatch,
|
- `wrap_address()` accepts maddr `str`s (leading-`/` dispatch,
|
||||||
`_addr.py:262`) but `parse_maddr()` only knows
|
`_addr.py:262`). `parse_maddr()` and `mk_maddr()` support plain
|
||||||
`/ip4|ip6/<h>/tcp/<p>` and `/unix/<p>`; a `.../wg/u<key>`
|
TCP/UDS addresses plus nested, canonical bearer-first `/wg/`
|
||||||
maddr raises `ValueError('Unsupported multiaddr protocol
|
stacks represented locally as `TunnelledAddress` wrappers.
|
||||||
combo')`.
|
|
||||||
- there is no `wg` proto in the multiaddr *spec* yet, but
|
- there is no `wg` proto in the multiaddr *spec* yet, but
|
||||||
multiformats/py-multiaddr#108 (key form `u<base64url>`) is
|
multiformats/py-multiaddr#108 (key form `u<base64url>`) is
|
||||||
**merged** as of 2026-07-28 (`f86519da`) — and unreleased, the
|
**merged** as of 2026-07-28 (`f86519da`) — and unreleased, the
|
||||||
latest `0.2.0` predating it. Spec registration is still tracked
|
latest `0.2.0` predating it. Spec registration is still tracked
|
||||||
by multiformats/py-multiaddr#107 and gh #483.
|
by multiformats/py-multiaddr#107 and gh #483.
|
||||||
- so **today's deployable story is declarative**: run `wg-quick`
|
- **today's deployable story remains declarative**: run `wg-quick`
|
||||||
out-of-band, parse the maddr, strip to the overlay
|
out-of-band, parse the maddr, strip its wrapper to the overlay
|
||||||
`(host, port)`, verify the pubkey in its host-specific role,
|
`(host, port)`, verify the pubkey in its host-specific role,
|
||||||
hand the overlay addr to `registry_addrs=`/`tpt_bind_addrs=`.
|
hand the overlay addr to `registry_addrs=`/`tpt_bind_addrs=`.
|
||||||
#482 already contains working example code for exactly this.
|
The repaired `examples/multihost/wg_lan/` implementation derives
|
||||||
|
from and supersedes #482's original example.
|
||||||
- `Address.namespace` exists in the Protocol
|
- `Address.namespace` exists in the Protocol
|
||||||
(`_addr.py:94-101`, "the if-available OS-specific network
|
(`_addr.py:94-101`, "the if-available OS-specific network
|
||||||
namespace key") and **no backend implements it**. This plan is
|
namespace key"). `TunnelledAddress` implements it from its spec;
|
||||||
its first consumer.
|
no concrete transport backend implements it yet.
|
||||||
|
|
||||||
## 2. Three layers, three PRs
|
## 2. Three layers, three PRs
|
||||||
|
|
||||||
| layer | what | dep | ships |
|
| layer | what | dep | ships |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| **A. declarative** | commit #482's examples; `parse_maddr()` learns `/wg/u<key>` → overlay `Address` + verified pubkey | `multiaddr` (already), `wg(8)` CLI | first |
|
| **A. declarative** | land repaired examples derived from #482; `parse_maddr()` learns `/wg/u<key>` → `TunnelledAddress` wrappers carrying overlay `Address` values and declared WG pubkeys | `multiaddr`, `py-multibase`, `wg(8)` CLI | first |
|
||||||
| **B. `pyroute2` read/verify** | replace the `subprocess.run(['sudo','wg','show'])` shelling with netlink queries | `pyroute2` extra | second |
|
| **B. `pyroute2` read/verify** | replace the example-local, role-aware async `wg(8)` verification probe with netlink queries | `pyroute2` extra | second |
|
||||||
| **C. `@acm` lifecycle** | create/configure/tear down wg ifaces + netns *from the runtime*, as nested bindspaces; implement `Address.namespace` | `pyroute2` + `CAP_NET_ADMIN` | third |
|
| **C. `@acm` lifecycle** | create/configure/tear down wg ifaces + netns *from the runtime*, consume `Address.namespace` for nested bindspaces, and implement explicit `None` on concrete transports | `pyroute2` + `CAP_NET_ADMIN` | third |
|
||||||
|
|
||||||
Each is independently valuable and independently reviewable.
|
Each is independently valuable and independently reviewable.
|
||||||
**Do not attempt C first** — the interesting design (nested
|
**Do not attempt C first** — the interesting design (nested
|
||||||
|
|
@ -73,29 +73,24 @@ does not create a new address type.** Two candidate encodings;
|
||||||
overlay: Address # e.g. TCPAddress
|
overlay: Address # e.g. TCPAddress
|
||||||
tunnel: WGTunnelSpec # proto-specific, frozen
|
tunnel: WGTunnelSpec # proto-specific, frozen
|
||||||
```
|
```
|
||||||
with `.proto_key` **delegating to `overlay.proto_key`** so every
|
with `.proto_key`, `.bindspace`, and `.unwrap()` delegating to
|
||||||
existing table lookup (`_addr_to_transport`,
|
the overlay so transport guards retain their existing meaning
|
||||||
`enable_transports` guard at `_root.py:391`,
|
and **nothing new crosses the wire**. `.namespace` derives from
|
||||||
`transport_from_addr()`) keeps working untouched, and
|
the tunnel spec. Exact-type dispatch through
|
||||||
`.unwrap()` delegating to `overlay.unwrap()` so **nothing new
|
`_addr_to_transport`/`transport_from_addr()` still requires the
|
||||||
crosses the wire**. `.namespace` and `.bindspace` come from
|
wrapper to be stripped (`→ .overlay`) at bind/connect time.
|
||||||
the tunnel spec. The wrapper is stripped (`→ .overlay`) at the
|
- ⚠️ `is_wrapped_addr()` explicitly recognizes
|
||||||
moment of bind/connect.
|
`TunnelledAddress` even though the wrapper is deliberately not
|
||||||
- ⚠️ `is_wrapped_addr()` (`_addr.py:194`) tests
|
in `_address_types`: it has no `MsgTransport` of its own and
|
||||||
`type(addr) in _address_types.values()` — the build-registered
|
therefore gets no build-registered proto-key entry.
|
||||||
protocol-key-to-address-type registry. `TunnelledAddress`
|
|
||||||
isn't in it and must not be (it has no transport of its own).
|
|
||||||
So either add an explicit
|
|
||||||
`isinstance(addr, TunnelledAddress)` clause there, or give
|
|
||||||
the wrapper a marker and test structurally. Do the former;
|
|
||||||
it's two lines and honest.
|
|
||||||
- the reflection in `Endpoint.start_listener()`
|
- the reflection in `Endpoint.start_listener()`
|
||||||
(`inspect.getmodule(self.addr)`) would resolve to the
|
(`inspect.getmodule(self.addr)`) would resolve to the
|
||||||
*wrapper's* module, not the transport's. **So the wrapper
|
*wrapper's* module, not the transport's. **So the wrapper
|
||||||
must be unwrapped before it reaches `Endpoint`** — i.e. by
|
must be unwrapped before it reaches `Endpoint`** — i.e. by
|
||||||
the bindspace `@acm` (layer C) or by `parse_maddr()`
|
the bindspace `@acm` (layer C) or explicitly via `.overlay`
|
||||||
(layer A). State this loudly in the docstring; it's the #1
|
or `strip_tunnels()` at each bind/dial boundary (layer A).
|
||||||
way to get this wrong.
|
State this loudly in the docstring; it's the #1 way to get
|
||||||
|
this wrong.
|
||||||
- (b) add fields to each existing `Address` type. Rejected:
|
- (b) add fields to each existing `Address` type. Rejected:
|
||||||
duplicates tunnel logic per-backend and pollutes `.unwrap()`.
|
duplicates tunnel logic per-backend and pollutes `.unwrap()`.
|
||||||
|
|
||||||
|
|
@ -104,11 +99,11 @@ class WGTunnelSpec(
|
||||||
msgspec.Struct,
|
msgspec.Struct,
|
||||||
frozen=True,
|
frozen=True,
|
||||||
):
|
):
|
||||||
pubkey: str # std-base64 `wg(8)` form
|
peer_pubkey: str # std-base64 `wg(8)` form
|
||||||
|
bearer: tuple[str, int]|None = None
|
||||||
iface: str = 'wg0'
|
iface: str = 'wg0'
|
||||||
netns: str|None = None
|
netns: str|None = None
|
||||||
# layer-C-only fields, unset in layer A
|
# layer-C-only fields, unset in layer A
|
||||||
maybe_endpoint: tuple[str, int]|None = None
|
|
||||||
maybe_allowed_ips: tuple[str, ...] = ()
|
maybe_allowed_ips: tuple[str, ...] = ()
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -138,9 +133,8 @@ examples in gh #482) used a *suffix* form
|
||||||
`/ip4/10.0.11.1/tcp/1616/wg/u<key>`. That parses, but it is
|
`/ip4/10.0.11.1/tcp/1616/wg/u<key>`. That parses, but it is
|
||||||
semantically inverted: it puts the overlay addr where the bearer
|
semantically inverted: it puts the overlay addr where the bearer
|
||||||
belongs, `tcp` where wg's `udp` `ListenPort` goes, and declares
|
belongs, `tcp` where wg's `udp` `ListenPort` goes, and declares
|
||||||
no overlay endpoint at all. `parse_wg_maddr()` in
|
no overlay endpoint at all. `tractor.discovery.parse_wg_maddr()`
|
||||||
`examples/multihost/wg_lan/` now rejects it with an actionable
|
now rejects it with an actionable error.
|
||||||
error.
|
|
||||||
Observed protocol-name lists, for writing the `match`:
|
Observed protocol-name lists, for writing the `match`:
|
||||||
|
|
||||||
| maddr | `[p.name for p in m.protocols()]` |
|
| maddr | `[p.name for p in m.protocols()]` |
|
||||||
|
|
@ -169,7 +163,7 @@ Observed protocol-name lists, for writing the `match`:
|
||||||
|
|
||||||
| need | API |
|
| need | API |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| isolate the bearer | `ma.decapsulate_code(P_WG)` |
|
| isolate the bearer | `ma.decapsulate_code(_wg_proto_code())` |
|
||||||
| drop the overlay, keep bearer+key | `ma.decapsulate(overlay_ma)` |
|
| drop the overlay, keep bearer+key | `ma.decapsulate(overlay_ma)` |
|
||||||
| per-seg maddrs | `ma.split()` |
|
| per-seg maddrs | `ma.split()` |
|
||||||
| rejoin a seg tail | `Multiaddr.join(*segs)` |
|
| rejoin a seg tail | `Multiaddr.join(*segs)` |
|
||||||
|
|
@ -185,23 +179,18 @@ Observed protocol-name lists, for writing the `match`:
|
||||||
silently returns the **first** match, i.e. the bearer's host.
|
silently returns the **first** match, i.e. the bearer's host.
|
||||||
Always call it on a peeled sub-maddr, never the whole stack.
|
Always call it on a peeled sub-maddr, never the whole stack.
|
||||||
|
|
||||||
- `parse_maddr()` gains a case on
|
- keep the existing 2-proto cases byte-identical; add
|
||||||
`[('ip4'|'ip6'), 'udp', 'wg', ('ip4'|'ip6'), <overlay-l4>]` →
|
`case _ if 'wg' in proto_names:` after them.
|
||||||
peel w/ the API above, decode the multibase key to std-base64,
|
- that case delegates to `parse_wg_maddr()`, which repeatedly
|
||||||
and return `TunnelledAddress(overlay=..., tunnel=WGTunnelSpec(
|
peels the last `/wg/`, decodes its key to std-base64, records
|
||||||
...))` w/ the bearer recorded in the spec.
|
its bearer in `WGTunnelSpec`, and wraps the overlay in one
|
||||||
- keep the existing 2-proto cases byte-identical; add the new
|
`TunnelledAddress` per segment.
|
||||||
case *after* them.
|
|
||||||
- layer A rejects more than one `/wg/` segment. Its wrapper stores
|
|
||||||
one bearer, key, and overlay, so accepting wg-in-wg would
|
|
||||||
silently misrepresent the maddr. Nested tunnel support needs a
|
|
||||||
different data shape and belongs in a later layer.
|
|
||||||
- `mk_maddr()` inverse for `TunnelledAddress` is just
|
- `mk_maddr()` inverse for `TunnelledAddress` is just
|
||||||
`.encapsulate()` composition; don't rebuild `str`s by hand.
|
`.encapsulate()` composition; don't rebuild `str`s by hand.
|
||||||
- **pending an upstream release**: py-multiaddr#108 is merged, so
|
- **pending an upstream release**: py-multiaddr#108 is merged, so
|
||||||
`Multiaddr('/…/wg/u…')` parses — but off a `[tool.uv.sources]`
|
`Multiaddr('/…/wg/u…')` parses off a PEP 621 direct-revision pin,
|
||||||
`rev` pin, since no release carries the codec. Gate the tests
|
since no release carries the codec. Gate parser entry on
|
||||||
on `_have_wg_maddr_proto()`, implemented as
|
`_wg_proto_code()`, implemented as
|
||||||
`protocols.protocol_with_name('wg')` under
|
`protocols.protocol_with_name('wg')` under
|
||||||
`except ProtocolNotFoundError`. Do **not** probe by parsing a
|
`except ProtocolNotFoundError`. Do **not** probe by parsing a
|
||||||
dummy like `Multiaddr('/wg/uAAAA')` — the codec enforces a
|
dummy like `Multiaddr('/wg/uAAAA')` — the codec enforces a
|
||||||
|
|
@ -209,19 +198,23 @@ Observed protocol-name lists, for writing the `match`:
|
||||||
**not** hand-roll a `wg` parser in `tractor` — the whole point
|
**not** hand-roll a `wg` parser in `tractor` — the whole point
|
||||||
of #429 was dropping the NIH parser.
|
of #429 was dropping the NIH parser.
|
||||||
|
|
||||||
### 3.3 verification helper (pure, composable)
|
### 3.3 pure parser helpers + explicit verification
|
||||||
|
|
||||||
Port #482 §2's helpers into `tractor/discovery/_tunnel.py` as
|
The parser/key-codec helpers live in
|
||||||
*pure functions* + one impure probe, cleanly separated:
|
`tractor/discovery/_tunnel.py`; the impure verifier remains
|
||||||
|
example-local until layer B:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
def parse_wg_maddr(maddr: str) -> TunnelledAddress: ... # pure
|
def parse_wg_maddr(maddr: str|Multiaddr) -> TunnelledAddress: ...
|
||||||
def wg8_pubkey(multibase_key: str) -> str: ... # pure
|
def mb_pubkey(wg8_key: str) -> str: ...
|
||||||
|
def wg8_pubkey(multibase_key: str) -> str: ...
|
||||||
async def verify_wg_key(
|
async def verify_wg_key(
|
||||||
spec: WGTunnelSpec,
|
addr: TunnelledAddress,
|
||||||
role: Literal['local', 'peer'],
|
role: Literal['local', 'peer'],
|
||||||
|
iface: str|None = None,
|
||||||
|
timeout: float = 5,
|
||||||
inspection: str|None = None,
|
inspection: str|None = None,
|
||||||
) -> bool: ... # impure probe
|
) -> bool: ... # example-local impure probe
|
||||||
```
|
```
|
||||||
|
|
||||||
In layer A `verify_wg_key()` may shell out to role-specific
|
In layer A `verify_wg_key()` may shell out to role-specific
|
||||||
|
|
@ -367,11 +360,11 @@ async def open_wg_iface(spec: WGTunnelSpec) -> AsyncGenerator[WGTunnelSpec, None
|
||||||
|
|
||||||
and a driver that folds a list of specs into nested contexts
|
and a driver that folds a list of specs into nested contexts
|
||||||
(`contextlib.AsyncExitStack` for the N-deep case). The
|
(`contextlib.AsyncExitStack` for the N-deep case). The
|
||||||
`parse_endpoints()` API (`_multiaddr.py:153`) is the front door:
|
`parse_endpoints()` API (`_multiaddr.py:189`) is the front door:
|
||||||
it already returns `dict[name, list[Address]]` and the
|
its `ParsedEndpoints` values already contain
|
||||||
`multiaddr_declare_eps.md` sketch anticipates the recursive
|
`Address|TunnelledAddress` declarations and preserve each tunnel
|
||||||
`dict[str, list[Address]]|dict[...]` return for tunnelled
|
stack for the eventual bindspace handler. It carries declarations;
|
||||||
entries. Extend it to carry the tunnel stack, not to *enter* it.
|
it does not *enter* their bindspaces.
|
||||||
|
|
||||||
### 5.2 `Address.namespace`, at last
|
### 5.2 `Address.namespace`, at last
|
||||||
|
|
||||||
|
|
@ -486,8 +479,8 @@ consider doing it *first* for exactly that reason.
|
||||||
| risk | mitigation |
|
| risk | mitigation |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `to_thread` worker runs in the wrong netns | §5.3; pass `netns=` to pyroute2 or pin a worker; test-first |
|
| `to_thread` worker runs in the wrong netns | §5.3; pass `netns=` to pyroute2 or pin a worker; test-first |
|
||||||
| py-multiaddr#108 merged but unreleased | `[tool.uv.sources]` `rev` pin + `_have_wg_maddr_proto()` gate; layer A's overlay-addr path works regardless |
|
| 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 `Endpoint` and breaks `inspect.getmodule()` | unwrap at parse/bindspace boundary; assert `not isinstance(ep.addr, TunnelledAddress)` in `Endpoint.__post_init__` |
|
| `TunnelledAddress` leaks into `Endpoint` and breaks `inspect.getmodule()` | unwrap with `.overlay`/`strip_tunnels()` at every bind/dial or bindspace boundary; assert `not isinstance(ep.addr, TunnelledAddress)` in `Endpoint.__post_init__` |
|
||||||
| privileged ops in a library | never `sudo`; explicit cap probe + actionable error; pre-provisioned is the default |
|
| privileged ops in a library | never `sudo`; explicit cap probe + actionable error; pre-provisioned is the default |
|
||||||
| pyroute2 0.9 asyncio core drags a loop into the actor | option (1) is a *thread*, not a loop; forbid `trio-asyncio` here (§4.1) |
|
| pyroute2 0.9 asyncio core drags a loop into the actor | option (1) is a *thread*, not a loop; forbid `trio-asyncio` here (§4.1) |
|
||||||
| netns teardown strands actor teardown | idempotent/tolerant teardown mirroring `_uds.close_listener()` |
|
| netns teardown strands actor teardown | idempotent/tolerant teardown mirroring `_uds.close_listener()` |
|
||||||
|
|
|
||||||
|
|
@ -21,16 +21,16 @@ Supersedes the example set in gh
|
||||||
/ip4/192.168.1.50/udp/51820/wg/u<A_pub>/ip4/10.0.11.1/tcp/1616
|
/ip4/192.168.1.50/udp/51820/wg/u<A_pub>/ip4/10.0.11.1/tcp/1616
|
||||||
\____ wg bearer ___________/\__ key __/\____ tractor ep _____/
|
\____ wg bearer ___________/\__ key __/\____ tractor ep _____/
|
||||||
underlay, wg `ListenPort` overlay, on the wg iface
|
underlay, wg `ListenPort` overlay, on the wg iface
|
||||||
(kernel/`wg(8)` owns it) (the ONLY part tractor binds)
|
(kernel owns the socket) (`MsgTransport` binds this)
|
||||||
```
|
```
|
||||||
|
|
||||||
Three parts, three different owners:
|
Three parts, three different owners:
|
||||||
|
|
||||||
| part | who binds it | in the runtime? |
|
| part | socket owner / provisioner | runtime role |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `/ip4/../udp/51820` bearer | kernel via `wg-quick`/`pyroute2` | no |
|
| `/ip4/../udp/51820` bearer | kernel-owned; `wg-quick` now, tractor bindspace later | control-plane metadata |
|
||||||
| `/wg/u<key>` | nothing — it's an identity | no, verified out-of-band |
|
| `/wg/u<key>` | nothing — it's an identity | parsed, verified explicitly |
|
||||||
| `/ip4/../tcp/1616` overlay | `tractor`'s `IPCServer` | **yes**, as `.overlay` |
|
| `/ip4/../tcp/1616` overlay | `tractor`'s `IPCServer` | application `MsgTransport` |
|
||||||
|
|
||||||
Verified against py-multiaddr
|
Verified against py-multiaddr
|
||||||
[#108](https://github.com/multiformats/py-multiaddr/pull/108):
|
[#108](https://github.com/multiformats/py-multiaddr/pull/108):
|
||||||
|
|
@ -41,30 +41,29 @@ this composed form parses and round-trips
|
||||||
|
|
||||||
py-multiaddr #108 is **merged** (2026-07-28) but ships in no
|
py-multiaddr #108 is **merged** (2026-07-28) but ships in no
|
||||||
release yet — the latest `0.2.0` (2026-03-17) predates it and has
|
release yet — the latest `0.2.0` (2026-03-17) predates it and has
|
||||||
no `wg` codec. So `pyproject.toml` carries a temporary
|
no `wg` codec. So `pyproject.toml` temporarily pins the merge commit
|
||||||
`[tool.uv.sources]` `rev` pin at the merge commit, and a plain
|
in its PEP 621 dependency metadata, and a plain
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uv sync
|
uv sync
|
||||||
```
|
```
|
||||||
|
|
||||||
gets you a `wg`-aware `multiaddr`. That pin goes away once a
|
gets you a `wg`-aware `multiaddr`. That pin goes away once a
|
||||||
release carries the codec. Its `py-multibase` dependency provides
|
release carries the codec. `py-multibase` is a direct project
|
||||||
the imported `multibase` module; no separate install command is
|
dependency, so no separate install command is needed.
|
||||||
needed.
|
|
||||||
|
|
||||||
Without the codec `parse_wg_maddr()` raises immediately with an
|
Without the codec `parse_wg_maddr()` raises immediately with an
|
||||||
actionable message — there is deliberately **no** degraded
|
actionable message — there is deliberately **no** degraded
|
||||||
hand-split fallback. `_have_wg_maddr_proto()` is the predicate.
|
hand-split fallback. `_wg_proto_code()` performs the capability
|
||||||
|
check before parsing.
|
||||||
|
|
||||||
Every peel and re-compose here goes through `py-multiaddr`'s own
|
Every peel and re-compose here goes through `py-multiaddr`'s own
|
||||||
tunnel API (`.decapsulate_code()`, `.split()`, `.join()`,
|
tunnel API (`.decapsulate_code()`, `.split()`, `.join()`,
|
||||||
`.encapsulate()`, `.value_for_protocol()`) rather than any
|
`.encapsulate()`, `.value_for_protocol()`) rather than any
|
||||||
bespoke segment slicing — see its README "En/decapsulate" and
|
bespoke segment slicing — see its README "En/decapsulate" and
|
||||||
"Tunneling" sections. gh #429 was about *dropping* our NIH
|
"Tunneling" sections. gh #429 was about *dropping* our NIH
|
||||||
parser, and that applies to peeling this composed maddr just as
|
parser, and that applies to peeling nested tunnel stacks just as
|
||||||
much as to decoding one proto. This example rejects multiple
|
much as to decoding one proto.
|
||||||
`/wg/` segments because `WGTunnelledAddr` stores one tunnel.
|
|
||||||
|
|
||||||
## 0. tunnel setup (out-of-band, both hosts)
|
## 0. tunnel setup (out-of-band, both hosts)
|
||||||
|
|
||||||
|
|
@ -120,7 +119,7 @@ ping -c1 10.0.11.1 # from B
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python -c "
|
python -c "
|
||||||
from wg_maddr import mb_pubkey
|
from tractor.discovery import mb_pubkey
|
||||||
key = open('wg_pub.key').read().strip()
|
key = open('wg_pub.key').read().strip()
|
||||||
print(mb_pubkey(key))
|
print(mb_pubkey(key))
|
||||||
"
|
"
|
||||||
|
|
@ -197,7 +196,7 @@ Four corrections, all from
|
||||||
never escalate or run `tractor` as root. Privileged tunnel
|
never escalate or run `tractor` as root. Privileged tunnel
|
||||||
setup and key inspection are separate shell steps.
|
setup and key inspection are separate shell steps.
|
||||||
4. **no new `Address` proto-type.** The tunnel rides *beside* the
|
4. **no new `Address` proto-type.** The tunnel rides *beside* the
|
||||||
overlay addr in a frozen `WGTunnelledAddr`, and only `.overlay`
|
overlay addr in a frozen `TunnelledAddress`, and only `.overlay`
|
||||||
crosses into `open_nursery()`. #482 §6 floated a `WGAddress`
|
crosses into `open_nursery()`. #482 §6 floated a `WGAddress`
|
||||||
registered in `_address_types` — that registry maps available
|
registered in `_address_types` — that registry maps available
|
||||||
transport keys to concrete address types, and
|
transport keys to concrete address types, and
|
||||||
|
|
@ -206,8 +205,7 @@ Four corrections, all from
|
||||||
|
|
||||||
## next
|
## next
|
||||||
|
|
||||||
`WGTunnelledAddr` is deliberately example-local. Promoting it to
|
Layer A's `TunnelledAddress` and native maddr parser now live in
|
||||||
`tractor.discovery` as a `TunnelledAddress` whose
|
`tractor.discovery`. Next, replace this example's `wg(8)` verification
|
||||||
`.proto_key`/`.unwrap()` delegate to `.overlay`, plus
|
probe with `pyroute2`, then add `open_bindspace()` `@acm`s which
|
||||||
`open_bindspace()` `@acm`s that create/tear down the iface +
|
create/tear down the iface and netns.
|
||||||
netns via `pyroute2`, is layers A→C of the plan doc.
|
|
||||||
|
|
|
||||||
|
|
@ -6,20 +6,20 @@ Binds `tractor`'s registrar + an `echo_srv` sub-actor on the
|
||||||
tunnel's *overlay* addr, declared as a single `wg` maddr.
|
tunnel's *overlay* addr, declared as a single `wg` maddr.
|
||||||
|
|
||||||
'''
|
'''
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
|
||||||
import tractor
|
import tractor
|
||||||
import trio
|
import trio
|
||||||
|
from tractor.discovery import (
|
||||||
from wg_maddr import (
|
TunnelledAddress,
|
||||||
|
mk_maddr,
|
||||||
parse_wg_maddr,
|
parse_wg_maddr,
|
||||||
verify_wg_key,
|
|
||||||
WGTunnelledAddr,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from wg_maddr import verify_wg_key
|
||||||
|
|
||||||
# bearer = host A's underlay `(ip, wg ListenPort)`
|
# bearer = host A's underlay `(ip, wg ListenPort)`
|
||||||
# key = host A's OWN tunnel pubkey
|
# key = host A's OWN tunnel pubkey
|
||||||
# overlay = the ep `tractor` binds, on the wg iface's addr
|
# overlay = the ep `tractor` binds, on the wg iface's addr
|
||||||
|
|
@ -36,7 +36,7 @@ async def echo(msg: str) -> str:
|
||||||
|
|
||||||
|
|
||||||
async def main():
|
async def main():
|
||||||
addr: WGTunnelledAddr = parse_wg_maddr(WG_MADDR)
|
addr: TunnelledAddress = parse_wg_maddr(WG_MADDR)
|
||||||
inspection: str | None = os.environ.get('WG_KEY_INSPECTION')
|
inspection: str | None = os.environ.get('WG_KEY_INSPECTION')
|
||||||
if not await verify_wg_key(
|
if not await verify_wg_key(
|
||||||
addr,
|
addr,
|
||||||
|
|
@ -46,25 +46,26 @@ async def main():
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
f'Maddr key is not wg0 local public key!\n'
|
f'Maddr key is not wg0 local public key!\n'
|
||||||
f'maddr: {WG_MADDR}\n'
|
f'maddr: {WG_MADDR}\n'
|
||||||
f'key: {addr.wg_pubkey}\n'
|
f'key: {addr.tunnel.peer_pubkey}\n'
|
||||||
)
|
)
|
||||||
print(
|
print(
|
||||||
f'wg bearer (kernel-owned): {addr.bearer}\n'
|
f'wg bearer (kernel-owned): {addr.tunnel.bearer}\n'
|
||||||
f'tractor overlay ep: {addr.overlay}\n'
|
f'tractor overlay ep: {addr.overlay}\n'
|
||||||
)
|
)
|
||||||
async with tractor.open_nursery(
|
async with tractor.open_nursery(
|
||||||
# XXX only `.overlay` crosses into the runtime; the bearer
|
# XXX only `.overlay` crosses into the runtime; the bearer
|
||||||
# + key are iface-layer concerns `tractor` never binds.
|
# + key are bindspace metadata, never `Endpoint` addrs.
|
||||||
registry_addrs=[addr.overlay],
|
registry_addrs=[addr.overlay],
|
||||||
enable_transports=[addr.overlay_proto],
|
enable_transports=[addr.overlay.proto_key],
|
||||||
) as an:
|
) as an:
|
||||||
|
overlay_host, _ = addr.unwrap()
|
||||||
await an.start_actor(
|
await an.start_actor(
|
||||||
'echo_srv',
|
'echo_srv',
|
||||||
bind_addrs=[(addr.overlay[0], 0)],
|
bind_addrs=[(overlay_host, 0)],
|
||||||
enable_transports=[addr.overlay_proto],
|
enable_transports=[addr.overlay.proto_key],
|
||||||
enable_modules=['host_a_srv'],
|
enable_modules=['host_a_srv'],
|
||||||
)
|
)
|
||||||
print(f'echo_srv up on\n {addr.maddr}\n')
|
print(f'echo_srv up on\n {mk_maddr(addr)}\n')
|
||||||
await trio.sleep_forever()
|
await trio.sleep_forever()
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,20 +4,19 @@ Host B: workstation dialing host A's actor tree through the
|
||||||
`wg` tunnel.
|
`wg` tunnel.
|
||||||
|
|
||||||
'''
|
'''
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
|
||||||
import tractor
|
import tractor
|
||||||
import trio
|
import trio
|
||||||
|
from tractor.discovery import (
|
||||||
|
TunnelledAddress,
|
||||||
|
parse_wg_maddr,
|
||||||
|
)
|
||||||
|
|
||||||
from host_a_srv import echo # noqa: F401 (RPC refs it by mod path)
|
from host_a_srv import echo # noqa: F401 (RPC refs it by mod path)
|
||||||
from wg_maddr import (
|
from wg_maddr import verify_wg_key
|
||||||
parse_wg_maddr,
|
|
||||||
verify_wg_key,
|
|
||||||
WGTunnelledAddr,
|
|
||||||
)
|
|
||||||
|
|
||||||
# same maddr as host A: A's bearer, A's key, A's overlay ep
|
# same maddr as host A: A's bearer, A's key, A's overlay ep
|
||||||
WG_MADDR: str = (
|
WG_MADDR: str = (
|
||||||
|
|
@ -29,7 +28,7 @@ LOCAL_OVERLAY_BIND: tuple[str, int] = ('10.0.11.2', 0)
|
||||||
|
|
||||||
|
|
||||||
async def main():
|
async def main():
|
||||||
addr: WGTunnelledAddr = parse_wg_maddr(WG_MADDR)
|
addr: TunnelledAddress = parse_wg_maddr(WG_MADDR)
|
||||||
inspection: str | None = os.environ.get('WG_KEY_INSPECTION')
|
inspection: str | None = os.environ.get('WG_KEY_INSPECTION')
|
||||||
if not await verify_wg_key(
|
if not await verify_wg_key(
|
||||||
addr,
|
addr,
|
||||||
|
|
@ -39,14 +38,14 @@ async def main():
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
f'Maddr key is not a configured wg0 peer!\n'
|
f'Maddr key is not a configured wg0 peer!\n'
|
||||||
f'maddr: {WG_MADDR}\n'
|
f'maddr: {WG_MADDR}\n'
|
||||||
f'key: {addr.wg_pubkey}\n'
|
f'key: {addr.tunnel.peer_pubkey}\n'
|
||||||
)
|
)
|
||||||
async with (
|
async with (
|
||||||
tractor.open_root_actor(
|
tractor.open_root_actor(
|
||||||
name='wg_client',
|
name='wg_client',
|
||||||
tpt_bind_addrs=[LOCAL_OVERLAY_BIND],
|
tpt_bind_addrs=[LOCAL_OVERLAY_BIND],
|
||||||
registry_addrs=[addr.overlay],
|
registry_addrs=[addr.overlay],
|
||||||
enable_transports=[addr.overlay_proto],
|
enable_transports=[addr.overlay.proto_key],
|
||||||
),
|
),
|
||||||
tractor.find_actor(
|
tractor.find_actor(
|
||||||
'echo_srv',
|
'echo_srv',
|
||||||
|
|
|
||||||
|
|
@ -1,308 +1,37 @@
|
||||||
# tractor: distributed structured concurrency.
|
# tractor: distributed structured concurrency.
|
||||||
r'''
|
r'''
|
||||||
Parse `wg`-tunnelled multiaddrs into `tractor`-ready addrs.
|
Verify `wg` keys declared by tractor's multiaddr parser.
|
||||||
|
|
||||||
The canonical form (per py-multiaddr #108, verified against its
|
`tractor.discovery.parse_wg_maddr()` owns pure parsing and delegates
|
||||||
upstream merge) nests the *overlay* endpoint **after** the `/wg/`
|
all tunnel peeling to `py-multiaddr`. This example keeps only the
|
||||||
segment:
|
explicit impure probe used by the two-host demo; parsing never shells
|
||||||
|
out or verifies local interface state implicitly.
|
||||||
|
|
||||||
|
The canonical maddr form is:
|
||||||
|
|
||||||
/ip4/10.0.0.1/udp/51820/wg/u<key>/ip4/10.0.11.1/tcp/1616
|
/ip4/10.0.0.1/udp/51820/wg/u<key>/ip4/10.0.11.1/tcp/1616
|
||||||
\_______ wg bearer ______/\_ key _/\____ tractor ep _____/
|
\_______ wg bearer ______/\_ key _/\____ tractor ep _____/
|
||||||
(underlay, wg
|
|
||||||
`ListenPort`)
|
|
||||||
|
|
||||||
Naming follows `py-multiaddr`'s own encapsulation model, where
|
The kernel owns the bearer socket. A future tractor bindspace may
|
||||||
earlier segments *wrap* later ones (`.encapsulate()` appends), so
|
provision it through netlink, but only the overlay is an application
|
||||||
the two roles are:
|
`MsgTransport` endpoint.
|
||||||
|
|
||||||
- **bearer**: the segs *before* `/wg/`, i.e. the underlay
|
|
||||||
`(ip, udp-port)` that `wg(8)` itself listens on. Nothing in
|
|
||||||
`tractor` ever binds this — the kernel/`wg` iface owns it.
|
|
||||||
- **overlay**: the segs *after*, i.e. the addr `tractor` actually
|
|
||||||
binds/dials. The only part the runtime ever sees.
|
|
||||||
|
|
||||||
We deliberately avoid `inner`/`outer` for these two: in a *call*
|
|
||||||
stack "inner" reads as higher-up and later-called, whereas here
|
|
||||||
the encapsulated addr is bound *first* and sits deeper in the
|
|
||||||
maddr — two opposite intuitions on one word.
|
|
||||||
|
|
||||||
`/wg/u<key>` itself carries a declared Curve25519 pubkey
|
|
||||||
as multibase base64url (std base64 from `wg(8)` contains `/` and
|
|
||||||
so can't go in a `/`-delimited maddr). It binds nothing at all;
|
|
||||||
it's an identity, verified out-of-band.
|
|
||||||
|
|
||||||
XXX NOTE, `tractor`'s own `parse_maddr()` can't parse this yet
|
|
||||||
(`ValueError('Unsupported multiaddr protocol combo')`), which is
|
|
||||||
why this module exists: peel here, hand `.overlay` to the
|
|
||||||
runtime.
|
|
||||||
|
|
||||||
Design rules this module follows (see
|
|
||||||
`ai/tpt-backends/03_wg_tunnel_bindspace.md`):
|
|
||||||
|
|
||||||
- **let `py-multiaddr` do the parsing**. Every peel/compose goes
|
|
||||||
through `.decapsulate_code()`, `.split()`, `.join()`,
|
|
||||||
`.encapsulate()` and `.value_for_protocol()`. We hand-roll no
|
|
||||||
segment splitting whatsoever — the whole point of gh #429 was
|
|
||||||
dropping the NIH parser, and that applies to peeling this
|
|
||||||
composed maddr every bit as much as to decoding one proto.
|
|
||||||
- **parsing is pure**. `parse_wg_maddr()` does no I/O, no
|
|
||||||
`subprocess`, no netlink. A parser that shells out is a nasty
|
|
||||||
surprise.
|
|
||||||
- **verification is an explicit, separate step**. The caller
|
|
||||||
composes `verify_wg_key()` when it has permission to inspect
|
|
||||||
the iface; nothing implicit.
|
|
||||||
- **exactly one `wg` segment is supported**. `WGTunnelledAddr`
|
|
||||||
stores one bearer and one key, so accepting another segment
|
|
||||||
would silently misrepresent the maddr.
|
|
||||||
- **no new `Address` proto-type**. `wg` gets no entry in
|
|
||||||
`tractor.discovery._addr._address_types`, which maps available
|
|
||||||
transport keys to concrete address types, bc it has no
|
|
||||||
`MsgTransport` of its own. The
|
|
||||||
tunnel is a *bindspace*, so we carry it beside the overlay
|
|
||||||
addr and strip to `.overlay` at bind/dial time.
|
|
||||||
|
|
||||||
'''
|
'''
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
import base64
|
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
import msgspec
|
|
||||||
from multiaddr import Multiaddr
|
|
||||||
import trio
|
import trio
|
||||||
|
|
||||||
|
from tractor.discovery import (
|
||||||
IPProto = Literal['ip4', 'ip6']
|
TunnelledAddress,
|
||||||
|
WGTunnelSpec,
|
||||||
|
)
|
||||||
class WGTunnelledAddr(
|
|
||||||
msgspec.Struct,
|
|
||||||
frozen=True,
|
|
||||||
):
|
|
||||||
'''
|
|
||||||
A `wg`-tunnelled endpoint: the underlay bearer, the tunnel
|
|
||||||
key, and the overlay addr `tractor` binds/dials.
|
|
||||||
|
|
||||||
'''
|
|
||||||
|
|
||||||
# underlay, owned by `wg(8)`/the kernel — NEVER bound by us
|
|
||||||
bearer: tuple[str, int]
|
|
||||||
|
|
||||||
# declared wg pubkey in std-base64 `wg(8)` form; it is the
|
|
||||||
# local key on the bearer host and a configured peer on a dialer
|
|
||||||
wg_pubkey: str
|
|
||||||
|
|
||||||
# overlay ep: an `UnwrappedAddress` as accepted by
|
|
||||||
# `tractor.discovery.wrap_address()`
|
|
||||||
overlay: tuple[str, int]
|
|
||||||
overlay_proto: Literal['tcp'] = 'tcp'
|
|
||||||
|
|
||||||
# kept so `.as_multiaddr()` re-renders the same ip family it
|
|
||||||
# was parsed from, rather than assuming v4
|
|
||||||
bearer_ip: IPProto = 'ip4'
|
|
||||||
overlay_ip: IPProto = 'ip4'
|
|
||||||
|
|
||||||
def as_multiaddr(self) -> Multiaddr:
|
|
||||||
'''
|
|
||||||
Re-compose the canonical `Multiaddr`, bearer outward-in,
|
|
||||||
using `.encapsulate()` exactly as py-multiaddr's own
|
|
||||||
tunneling example does.
|
|
||||||
|
|
||||||
'''
|
|
||||||
b_host, b_port = self.bearer
|
|
||||||
o_host, o_port = self.overlay
|
|
||||||
return (
|
|
||||||
Multiaddr(f'/{self.bearer_ip}/{b_host}/udp/{b_port}')
|
|
||||||
.encapsulate(
|
|
||||||
Multiaddr(f'/wg/{mb_pubkey(self.wg_pubkey)}')
|
|
||||||
)
|
|
||||||
.encapsulate(
|
|
||||||
Multiaddr(
|
|
||||||
f'/{self.overlay_ip}/{o_host}'
|
|
||||||
f'/{self.overlay_proto}/{o_port}'
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def maddr(self) -> str:
|
|
||||||
'''
|
|
||||||
The canonical maddr `str` form.
|
|
||||||
|
|
||||||
'''
|
|
||||||
return str(self.as_multiaddr())
|
|
||||||
|
|
||||||
|
|
||||||
def mb_pubkey(wg8_key: str) -> str:
|
|
||||||
'''
|
|
||||||
`wg(8)` std-base64 pubkey -> multibase base64url (`u`-prefixed).
|
|
||||||
|
|
||||||
'''
|
|
||||||
import multibase
|
|
||||||
|
|
||||||
raw: bytes = base64.b64decode(wg8_key, validate=True)
|
|
||||||
if len(raw) != 32:
|
|
||||||
raise ValueError(
|
|
||||||
f'WireGuard public keys must decode to 32 bytes, '
|
|
||||||
f'not {len(raw)}'
|
|
||||||
)
|
|
||||||
return multibase.encode('base64url', raw).decode('ascii')
|
|
||||||
|
|
||||||
|
|
||||||
def wg8_pubkey(mb_key: str) -> str:
|
|
||||||
'''
|
|
||||||
Inverse of `mb_pubkey()`: multibase -> `wg(8)` std-base64.
|
|
||||||
|
|
||||||
'''
|
|
||||||
import multibase
|
|
||||||
|
|
||||||
raw: bytes = multibase.decode(mb_key)
|
|
||||||
if len(raw) != 32:
|
|
||||||
raise ValueError(
|
|
||||||
f'WireGuard public keys must decode to 32 bytes, '
|
|
||||||
f'not {len(raw)}'
|
|
||||||
)
|
|
||||||
return base64.b64encode(raw).decode('ascii')
|
|
||||||
|
|
||||||
|
|
||||||
_wg_proto_known: bool | None = None
|
|
||||||
|
|
||||||
|
|
||||||
def _have_wg_maddr_proto() -> bool:
|
|
||||||
'''
|
|
||||||
True iff the installed `py-multiaddr` knows the `/wg/` proto,
|
|
||||||
i.e. carries py-multiaddr#108.
|
|
||||||
|
|
||||||
Merged upstream 2026-07-28 (`f86519da`) but in no release as
|
|
||||||
of `0.2.0`, hence the `[tool.uv.sources]` `rev` pin in
|
|
||||||
`pyproject.toml`.
|
|
||||||
|
|
||||||
Pure predicate; result cached since it can't change without a
|
|
||||||
reinstall.
|
|
||||||
|
|
||||||
'''
|
|
||||||
global _wg_proto_known
|
|
||||||
if _wg_proto_known is None:
|
|
||||||
from multiaddr.protocols import protocol_with_name
|
|
||||||
from multiaddr.exceptions import ProtocolNotFoundError
|
|
||||||
|
|
||||||
try:
|
|
||||||
protocol_with_name('wg')
|
|
||||||
_wg_proto_known = True
|
|
||||||
except ProtocolNotFoundError:
|
|
||||||
_wg_proto_known = False
|
|
||||||
|
|
||||||
return _wg_proto_known
|
|
||||||
|
|
||||||
|
|
||||||
def parse_wg_maddr(
|
|
||||||
maddr: str | Multiaddr,
|
|
||||||
) -> WGTunnelledAddr:
|
|
||||||
'''
|
|
||||||
Peel a `wg`-tunnelled maddr into its bearer/key/overlay
|
|
||||||
parts. Pure — no I/O.
|
|
||||||
|
|
||||||
Every cut is made by `py-multiaddr`, so a malformed maddr
|
|
||||||
(incl. a `wg` key that isn't exactly 32B) raises out of
|
|
||||||
`Multiaddr()` rather than yielding a struct quietly built
|
|
||||||
from garbage segs.
|
|
||||||
|
|
||||||
'''
|
|
||||||
if not _have_wg_maddr_proto():
|
|
||||||
raise RuntimeError(
|
|
||||||
f'Installed `py-multiaddr` has no `/wg/` proto!\n'
|
|
||||||
f'Needs py-multiaddr#108, merged upstream but not\n'
|
|
||||||
f'yet released; a `uv sync` picks up the pinned rev.\n'
|
|
||||||
f'maddr: {maddr!r}\n'
|
|
||||||
)
|
|
||||||
|
|
||||||
ma: Multiaddr = (
|
|
||||||
maddr
|
|
||||||
if isinstance(maddr, Multiaddr)
|
|
||||||
else Multiaddr(maddr)
|
|
||||||
)
|
|
||||||
segs: list[Multiaddr] = ma.split()
|
|
||||||
names: list[str] = [
|
|
||||||
proto.name
|
|
||||||
for seg in segs
|
|
||||||
for proto in seg.protocols()
|
|
||||||
]
|
|
||||||
wg_count: int = names.count('wg')
|
|
||||||
if not wg_count:
|
|
||||||
raise ValueError(
|
|
||||||
f'Not a `wg`-tunnelled maddr, no `/wg/` segment ??\n'
|
|
||||||
f'maddr: {ma}\n'
|
|
||||||
)
|
|
||||||
if wg_count > 1:
|
|
||||||
raise ValueError(
|
|
||||||
f'Nested `wg` segments are not supported; '
|
|
||||||
f'`WGTunnelledAddr` stores one tunnel only.\n'
|
|
||||||
f'maddr: {ma}\n'
|
|
||||||
)
|
|
||||||
|
|
||||||
# Resolve the unreleased protocol only after the capability
|
|
||||||
# check, so importing this module works with released multiaddr.
|
|
||||||
from multiaddr.protocols import protocol_with_name
|
|
||||||
|
|
||||||
wg_code: int = protocol_with_name('wg').code
|
|
||||||
|
|
||||||
# NOTE, `.decapsulate_code()` cuts at the LAST occurrence of
|
|
||||||
# the proto and keeps the *prefix*, which is exactly the
|
|
||||||
# bearer. It handles `/wg/` cleanly precisely bc it cuts on
|
|
||||||
# proto-code and never tries to match an addr value — the
|
|
||||||
# key seg has no addr of its own.
|
|
||||||
bearer_ma: Multiaddr = ma.decapsulate_code(wg_code)
|
|
||||||
overlay_ma: Multiaddr = Multiaddr.join(
|
|
||||||
*segs[names.index('wg') + 1:]
|
|
||||||
)
|
|
||||||
|
|
||||||
match [proto.name for proto in bearer_ma.protocols()]:
|
|
||||||
case [('ip4' | 'ip6') as b_ip, 'udp']:
|
|
||||||
bearer = (
|
|
||||||
bearer_ma.value_for_protocol(b_ip),
|
|
||||||
int(bearer_ma.value_for_protocol('udp')),
|
|
||||||
)
|
|
||||||
case _:
|
|
||||||
raise ValueError(
|
|
||||||
f'Bad `wg` bearer, expected `/ip4|ip6/<h>/udp/<p>`\n'
|
|
||||||
f'got: {bearer_ma}\n'
|
|
||||||
f'from maddr: {ma}\n'
|
|
||||||
)
|
|
||||||
|
|
||||||
match [proto.name for proto in overlay_ma.protocols()]:
|
|
||||||
case [('ip4' | 'ip6') as o_ip, ('tcp') as l4]:
|
|
||||||
overlay = (
|
|
||||||
overlay_ma.value_for_protocol(o_ip),
|
|
||||||
int(overlay_ma.value_for_protocol(l4)),
|
|
||||||
)
|
|
||||||
case []:
|
|
||||||
raise ValueError(
|
|
||||||
f'`wg` maddr declares no overlay endpoint!\n'
|
|
||||||
f'A bare `/…/wg/<key>` names only the tunnel; '
|
|
||||||
f'append the ep `tractor` should bind, e.g.\n'
|
|
||||||
f' {ma}/ip4/10.0.11.1/tcp/1616\n'
|
|
||||||
)
|
|
||||||
case _:
|
|
||||||
raise ValueError(
|
|
||||||
f'Unsupported `wg` overlay proto combo\n'
|
|
||||||
f'got: {overlay_ma}\n'
|
|
||||||
f'from maddr: {ma}\n'
|
|
||||||
)
|
|
||||||
|
|
||||||
return WGTunnelledAddr(
|
|
||||||
bearer=bearer,
|
|
||||||
wg_pubkey=wg8_pubkey(ma.value_for_protocol('wg')),
|
|
||||||
overlay=overlay,
|
|
||||||
overlay_proto=l4,
|
|
||||||
bearer_ip=b_ip,
|
|
||||||
overlay_ip=o_ip,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def verify_wg_key(
|
async def verify_wg_key(
|
||||||
addr: WGTunnelledAddr,
|
addr: TunnelledAddress,
|
||||||
role: Literal['local', 'peer'],
|
role: Literal['local', 'peer'],
|
||||||
iface: str = 'wg0',
|
iface: str | None = None,
|
||||||
timeout: float = 5,
|
timeout: float = 5,
|
||||||
inspection: str | None = None,
|
inspection: str | None = None,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
|
|
@ -317,22 +46,30 @@ async def verify_wg_key(
|
||||||
`wg show` step. Without it, query asynchronously for callers
|
`wg show` step. Without it, query asynchronously for callers
|
||||||
which already have interface-inspection permission.
|
which already have interface-inspection permission.
|
||||||
|
|
||||||
IMPURE + explicit by design: never called from
|
IMPURE + explicit by design: neither `parse_wg_maddr()` nor
|
||||||
`parse_wg_maddr()`.
|
`tractor.discovery.parse_maddr()` calls this probe.
|
||||||
|
|
||||||
?TODO, per plan-03 layer B, swap this body for `pyroute2`
|
?TODO, per plan-03 layer B, swap this body for `pyroute2`
|
||||||
(keeping the signature) — and note `setns(2)` is *per-thread*,
|
while retaining the explicit verification boundary.
|
||||||
so a query issued via `trio.to_thread` lands in the ORIGINAL
|
|
||||||
netns unless `netns=` is passed down.
|
|
||||||
|
|
||||||
'''
|
'''
|
||||||
|
spec = addr.tunnel
|
||||||
|
if not isinstance(spec, WGTunnelSpec):
|
||||||
|
raise TypeError(
|
||||||
|
f'Unsupported tunnel spec: {type(spec)!r}'
|
||||||
|
)
|
||||||
|
|
||||||
|
iface = iface or spec.iface
|
||||||
|
|
||||||
match role:
|
match role:
|
||||||
case 'local':
|
case 'local':
|
||||||
field = 'public-key'
|
field = 'public-key'
|
||||||
case 'peer':
|
case 'peer':
|
||||||
field = 'peers'
|
field = 'peers'
|
||||||
case _:
|
case _:
|
||||||
raise ValueError(f'Unknown WireGuard key role: {role!r}')
|
raise ValueError(
|
||||||
|
f'Unknown WireGuard key role: {role!r}'
|
||||||
|
)
|
||||||
|
|
||||||
if inspection is None:
|
if inspection is None:
|
||||||
with trio.fail_after(timeout):
|
with trio.fail_after(timeout):
|
||||||
|
|
@ -344,5 +81,5 @@ async def verify_wg_key(
|
||||||
inspection = proc.stdout.decode()
|
inspection = proc.stdout.decode()
|
||||||
|
|
||||||
if role == 'local':
|
if role == 'local':
|
||||||
return addr.wg_pubkey == inspection.strip()
|
return spec.peer_pubkey == inspection.strip()
|
||||||
return addr.wg_pubkey in inspection.split()
|
return spec.peer_pubkey in inspection.split()
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue