Harden the wg LAN example for real hosts

Validate WireGuard keys and tunnel descriptors strictly. Inspect
iface keys asynchronously by local/peer role and reject unsupported
nested tunnels.

Correct both host binds and service publication, document an
unprivileged two-host setup and pin the merged `py-multiaddr` codec
revision in the lock.

Prompt-IO: ai/prompt-io/opencode/20260831T022317Z_768b5316_prompt_io.md

(this patch was generated in some part by `opencode` using `gpt-5.6-sol` (`openai`))
ng_tpts_planning
Gud Boi 2026-08-31 12:28:19 -04:00
parent 7c2de6c359
commit e4e27ec71d
8 changed files with 339 additions and 90 deletions

View File

@ -0,0 +1,72 @@
---
model: gpt-5.6-sol
service: opencode
session: f3a5eef0-9695-48ab-a336-861187473f8a
timestamp: 2026-08-31T02:23:17Z
git_ref: ng_tpts_planning
diff_cmd: git diff 768b531662a88037e3988885102d2ff24a7ce34e..HEAD
scope: code
substantive: true
raw_file: 20260831T022317Z_768b5316_prompt_io.raw.md
---
## Prompt
The human asked to continue the retained PR landing sequence and
selected "Repair all blockers" after a read-only review of PR #492.
They then corrected the TIPC assessment:
> oh one thing on the tipc findings, they may have already been
> addressed in the downstream patch branch no?
The repair therefore had to use downstream #493 as implementation
evidence, avoid duplicating solved TIPC work, preserve the dirty
downstream worktree, and repair the remaining shared, QUIC,
WireGuard, lock, and provenance defects without committing,
pushing, or publishing.
## Response summary
Reworked PR #492's plans and executable WireGuard example into a
consistent landing candidate. The shared and TIPC documents now
match current runtime/downstream behavior while naming remaining
risks. The QUIC plan now defines transport ownership across the
whole actor lifecycle. The WireGuard example validates keys and
binds both hosts and the service actor correctly without elevating
Tractor. The dependency lock and historical provenance pointers
are synchronized.
## Files changed
- `ai/tpt-backends/00_shared_backend_contract.md` - current backend
contract, dispatch, capability, and locality rules.
- `ai/tpt-backends/01_tipc_backend.md` - #493 reconciliation and
bounded unresolved work.
- `ai/tpt-backends/02_quic_iroh_backend.md` - lifecycle-safe QUIC
and UniFFI implementation plan.
- `ai/tpt-backends/03_wg_tunnel_bindspace.md` - corrected address
registry and privilege semantics.
- `ai/tpt-backends/README.md` - refreshed plan summaries.
- `examples/multihost/wg_lan/README.md` - unprivileged two-host
setup and accurate verification limits.
- `examples/multihost/wg_lan/host_a_srv.py` - local key check,
overlay child bind, and stable RPC exposure.
- `examples/multihost/wg_lan/host_b_client.py` - peer key check,
host-B bind, and explicit missing-service failure.
- `examples/multihost/wg_lan/wg_maddr.py` - strict parsing and
asynchronous role-specific key inspection.
- `uv.lock` - exact `py-multiaddr` Git source resolution.
- `ai/prompt-io/claude/20260813T001102Z_27c34aeb_prompt_io.md` -
valid scope and immutable Git reference.
- `ai/prompt-io/claude/20260813T001102Z_27c34aeb_prompt_io.raw.md`
- immutable historical diff pointers.
## Human edits
The human chose the full repair path rather than reducing the PR
to planning documents or publishing the initial review. They also
identified that the initial TIPC review had not accounted for the
downstream implementation branch. That correction materially
changed the work: solved TIPC items were backported into the plan,
remaining defects were separated from implemented behavior, and
the dirty downstream worktree was kept read-only.

View File

@ -0,0 +1,60 @@
---
model: gpt-5.6-sol
service: opencode
timestamp: 2026-08-31T02:23:17Z
git_ref: ng_tpts_planning
diff_cmd: git diff 768b531662a88037e3988885102d2ff24a7ce34e..HEAD
---
# Raw output - repair PR #492 for landing
## Generated changes
> `git diff 768b531662a88037e3988885102d2ff24a7ce34e..HEAD -- ai/tpt-backends/`
- Reconciled the shared backend contract with current address,
dispatch, capability, locality, and listener-rebind APIs.
- Updated the TIPC plan from downstream #493 implementation and
tests, preserving unresolved registrar election, collision,
locality, and socket-cleanup work as explicit follow-ups.
- Reworked the QUIC plan around a launch-time transport bootstrap,
one actor-owned endpoint, a transport nursery spanning parent
dial through deregistration, supervised UniFFI cleanup, complete
routable addresses, connection leases, and listener-owned tasks.
- Corrected the WireGuard bindspace plan to distinguish the
build-registered address registry from runtime capability.
> `git diff 768b531662a88037e3988885102d2ff24a7ce34e..HEAD -- examples/multihost/wg_lan/`
- Hardened WireGuard key conversion and parsing with strict
32-byte validation, lazy protocol lookup, and explicit rejection
of unsupported nested tunnel descriptors.
- Made interface inspection asynchronous, bounded at its
cancellation request, role-specific, and separable from
privileged preflight commands.
- Corrected host-local binds, subactor overlay publication, stable
RPC module exposure, and missing-actor handling in the two-host
example.
- Updated the README so Tractor remains unprivileged and the local
versus remote overlay roles are explicit.
> `git diff 768b531662a88037e3988885102d2ff24a7ce34e..HEAD -- pyproject.toml uv.lock ai/prompt-io/`
- Regenerated `uv.lock` for the exact merged `py-multiaddr` WireGuard
codec revision.
- Replaced mutable historical prompt diff pointers with immutable
refs and normalized the substantive scope without rewriting the
historical raw response.
## Verification output
- `git diff --check`: passed.
- `uv lock --check`: passed.
- Ruff on the WireGuard example directory: passed.
- Python compilation of the WireGuard example directory: passed.
- Tractor imported from the PR worktree's existing environment.
- WireGuard address round-trip, local/peer role checks, malformed
base64 rejection, and nested-tunnel rejection: passed.
- Three independent final re-reviews reported no actionable
findings in the shared/TIPC, QUIC, or WireGuard slices.
- No live TIPC, iroh/UniFFI, or WireGuard network test was run.

View File

@ -33,7 +33,7 @@ onto `trio` as the library's sans-io layer allows.
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` - so **today's deployable story is declarative**: run `wg-quick`
out-of-band, parse the maddr, strip to the overlay out-of-band, parse the maddr, strip to the overlay
`(host, port)`, verify the pubkey against the live tunnel, `(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. #482 already contains working example code for exactly this.
- `Address.namespace` exists in the Protocol - `Address.namespace` exists in the Protocol
@ -82,9 +82,10 @@ does not create a new address type.** Two candidate encodings;
the tunnel spec. The wrapper is stripped (`→ .overlay`) at the the tunnel spec. The wrapper is stripped (`→ .overlay`) at the
moment of bind/connect. moment of bind/connect.
- ⚠️ `is_wrapped_addr()` (`_addr.py:194`) tests - ⚠️ `is_wrapped_addr()` (`_addr.py:194`) tests
`type(addr) in _address_types.values()` — a `bidict` of `type(addr) in _address_types.values()` — the build-registered
proto_key→type. `TunnelledAddress` isn't in it and must not protocol-key-to-address-type registry. `TunnelledAddress`
be (it's not 1:1 with a proto). So either add an explicit 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 `isinstance(addr, TunnelledAddress)` clause there, or give
the wrapper a marker and test structurally. Do the former; the wrapper a marker and test structurally. Do the former;
it's two lines and honest. it's two lines and honest.
@ -103,7 +104,7 @@ class WGTunnelSpec(
msgspec.Struct, msgspec.Struct,
frozen=True, frozen=True,
): ):
peer_pubkey: str # std-base64 `wg(8)` form pubkey: str # std-base64 `wg(8)` form
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
@ -191,9 +192,10 @@ Observed protocol-name lists, for writing the `match`:
...))` w/ the bearer recorded in the spec. ...))` w/ the bearer recorded in the spec.
- keep the existing 2-proto cases byte-identical; add the new - keep the existing 2-proto cases byte-identical; add the new
case *after* them. case *after* them.
- nesting (wg-in-wg) falls out of `.decapsulate_code()` cutting - layer A rejects more than one `/wg/` segment. Its wrapper stores
at the *last* occurrence — peel repeatedly rather than one bearer, key, and overlay, so accepting wg-in-wg would
recursing through a bespoke splitter. 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
@ -215,12 +217,21 @@ Port #482 §2's helpers into `tractor/discovery/_tunnel.py` as
```python ```python
def parse_wg_maddr(maddr: str) -> TunnelledAddress: ... # pure def parse_wg_maddr(maddr: str) -> TunnelledAddress: ... # pure
def wg8_pubkey(multibase_key: str) -> str: ... # pure def wg8_pubkey(multibase_key: str) -> str: ... # pure
def verify_wg_peer(spec: WGTunnelSpec) -> bool: ... # impure probe async def verify_wg_key(
spec: WGTunnelSpec,
role: Literal['local', 'peer'],
inspection: str|None = None,
) -> bool: ... # impure probe
``` ```
In layer A `verify_wg_peer()` may shell out (`wg show <if> In layer A `verify_wg_key()` may shell out to role-specific
peers`), but it must be a *single* function so layer B swaps `wg show <if> public-key|peers` queries, but it must be a *single*
only its body. Never call it implicitly from 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
`wrap_address()`/`parse_maddr()` — parsing must stay pure and `wrap_address()`/`parse_maddr()` — parsing must stay pure and
side-effect-free; verification is the *caller's* explicit step side-effect-free; verification is the *caller's* explicit step
(and later, the bindspace `@acm`'s). (and later, the bindspace `@acm`'s).
@ -281,7 +292,7 @@ Three integration options, in increasing trio-nativeness:
- (3) reimplement the codecs. Never. - (3) reimplement the codecs. Never.
**Recommended split**: ship (1) first so layer B is a small, **Recommended split**: ship (1) first so layer B is a small,
reviewable, behaviour-preserving swap of `verify_wg_peer()`'s reviewable, behaviour-preserving swap of `verify_wg_key()`'s
body; then land (2) as a follow-up commit for the read path body; then land (2) as a follow-up commit for the read path
(`wg get`, `link get`) where the sans-io surface is smallest, (`wg get`, `link get`) where the sans-io surface is smallest,
and keep (1) for the privileged mutating ops. Measure before and keep (1) for the privileged mutating ops. Measure before
@ -306,7 +317,7 @@ async def read_wg_peers(
async def read_wg_pubkey(iface: str = 'wg0', ...) -> str: ... async def read_wg_pubkey(iface: str = 'wg0', ...) -> str: ...
``` ```
and `verify_wg_peer()` becomes a thin composition over the two. and `verify_wg_key()` becomes a thin composition over the two.
Note the pure-getter rule: no `read_wg_peers(..., create=True)`. Note the pure-getter rule: no `read_wg_peers(..., create=True)`.
--- ---

View File

@ -49,11 +49,9 @@ uv sync
``` ```
gets you a `wg`-aware `multiaddr`. That pin goes away once a gets you a `wg`-aware `multiaddr`. That pin goes away once a
release carries the codec. You also need `multibase`: release carries the codec. Its `py-multibase` dependency provides
the imported `multibase` module; no separate install command is
```bash needed.
uv pip install multibase
```
Without the codec `parse_wg_maddr()` raises immediately with an Without the codec `parse_wg_maddr()` raises immediately with an
actionable message — there is deliberately **no** degraded actionable message — there is deliberately **no** degraded
@ -64,8 +62,9 @@ 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 a tunnel stack just as much parser, and that applies to peeling this composed maddr just as
as to decoding one proto. much as to decoding one proto. This example rejects multiple
`/wg/` segments because `WGTunnelledAddr` stores one tunnel.
## 0. tunnel setup (out-of-band, both hosts) ## 0. tunnel setup (out-of-band, both hosts)
@ -106,8 +105,11 @@ AllowedIPs = 10.0.11.1/32
PersistentKeepalive = 25 PersistentKeepalive = 25
``` ```
Note how `ListenPort` and `Endpoint` are exactly the maddr's This example configures host A's `ListenPort` and host B's
bearer segment, and `[Interface] Address` is its overlay host. `Endpoint` from the maddr bearer, and configures host A's
`[Interface] Address` from its overlay host. The verification
step below checks keys only; it does not inspect those fields or
either peer's `AllowedIPs`.
```bash ```bash
sudo wg-quick up wg0 # both hosts sudo wg-quick up wg0 # both hosts
@ -118,16 +120,40 @@ ping -c1 10.0.11.1 # from B
```bash ```bash
python -c " python -c "
import base64, multibase from wg_maddr import mb_pubkey
key = open('wg_pub.key').read().strip() key = open('wg_pub.key').read().strip()
print(multibase.encode('base64url', base64.b64decode(key)).decode()) print(mb_pubkey(key))
" "
``` ```
Paste the `u...` output into `WG_MADDR` in both scripts (they use Paste the `u...` output into `WG_MADDR` in both scripts (they use
the same string — A's bearer, A's key, A's overlay ep). the same string — A's bearer, A's key, A's overlay ep).
## 2. run ## 2. verify the keys
Interface inspection commonly needs `CAP_NET_ADMIN`. Keep that
privileged operation separate from the `tractor` processes:
```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)"
```
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.
## 3. run
```bash ```bash
# host A # host A
@ -137,6 +163,17 @@ python host_a_srv.py
python host_b_client.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.
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
a random port. Keep `LOCAL_OVERLAY_BIND` aligned with host B's
WireGuard interface address if adapting this example.
`host_a_srv.py` must be importable on host B too, since `host_a_srv.py` must be importable on host B too, since
`portal.run()` refs the fn by module path — standard `tractor` `portal.run()` refs the fn by module path — standard `tractor`
RPC semantics. RPC semantics.
@ -153,19 +190,19 @@ Four corrections, all from
all. `parse_wg_maddr()` now rejects it with an actionable all. `parse_wg_maddr()` now rejects it with an actionable
error. error.
2. **parsing is pure.** #482's helper had the key-check adjacent 2. **parsing is pure.** #482's helper had the key-check adjacent
to the parse; `verify_wg_peer()` is now a separate, explicitly to the parse; `verify_wg_key()` is now a separate, explicitly
composed step that the caller invokes. A parser that shells composed step for inspection-capable callers. A parser that
out is a nasty surprise. shells out is a nasty surprise.
3. **no `sudo`.** #482 ran `sudo wg show`; a library/example must 3. **no `sudo`.** #482 ran `sudo wg show`; a library/example must
never escalate. `wg show` works unprivileged for read on most never escalate or run `tractor` as root. Privileged tunnel
setups; if yours needs root, run the script as root rather setup and key inspection are separate shell steps.
than embedding `sudo`.
4. **no new `Address` proto-type.** The tunnel rides *beside* the 4. **no new `Address` proto-type.** The tunnel rides *beside* the
overlay addr in a frozen `WGTunnelledAddr`, and only `.overlay` overlay addr in a frozen `WGTunnelledAddr`, and only `.overlay`
crosses into `open_nursery()`. #482 §6 floated a `WGAddress` crosses into `open_nursery()`. #482 §6 floated a `WGAddress`
registered in `_address_types` — that table is a `bidict` registered in `_address_types` — that registry maps available
(1:1 proto-key↔type) and `_addr_to_transport` wants a transport keys to concrete address types, and
`MsgTransport` per addr-type, which `wg` doesn't have. `_addr_to_transport` wants a `MsgTransport` per addr-type,
which `wg` doesn't have.
## next ## next

View File

@ -6,14 +6,17 @@ 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 tractor import tractor
import trio import trio
from wg_maddr import ( from wg_maddr import (
parse_wg_maddr, parse_wg_maddr,
verify_wg_peer, verify_wg_key,
WGTunnelledAddr, WGTunnelledAddr,
) )
@ -34,10 +37,16 @@ async def echo(msg: str) -> str:
async def main(): async def main():
addr: WGTunnelledAddr = parse_wg_maddr(WG_MADDR) addr: WGTunnelledAddr = parse_wg_maddr(WG_MADDR)
assert verify_wg_peer(addr), ( inspection: str | None = os.environ.get('WG_KEY_INSPECTION')
f'wg pubkey from maddr not active on wg0 !\n' if not await verify_wg_key(
addr,
role='local',
inspection=inspection,
):
raise RuntimeError(
f'Maddr key is not wg0 local public key!\n'
f'maddr: {WG_MADDR}\n' f'maddr: {WG_MADDR}\n'
f'key: {addr.peer_pubkey}\n' f'key: {addr.wg_pubkey}\n'
) )
print( print(
f'wg bearer (kernel-owned): {addr.bearer}\n' f'wg bearer (kernel-owned): {addr.bearer}\n'
@ -51,7 +60,9 @@ async def main():
) as an: ) as an:
await an.start_actor( await an.start_actor(
'echo_srv', 'echo_srv',
enable_modules=[__name__], bind_addrs=[(addr.overlay[0], 0)],
enable_transports=[addr.overlay_proto],
enable_modules=['host_a_srv'],
) )
print(f'echo_srv up on\n {addr.maddr}\n') print(f'echo_srv up on\n {addr.maddr}\n')
await trio.sleep_forever() await trio.sleep_forever()

View File

@ -4,15 +4,18 @@ 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 tractor import tractor
import trio import trio
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 (
parse_wg_maddr, parse_wg_maddr,
verify_wg_peer, verify_wg_key,
WGTunnelledAddr, WGTunnelledAddr,
) )
@ -22,23 +25,33 @@ WG_MADDR: str = (
'/wg/u<A_pub_b64url>' '/wg/u<A_pub_b64url>'
'/ip4/10.0.11.1/tcp/1616' '/ip4/10.0.11.1/tcp/1616'
) )
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: WGTunnelledAddr = parse_wg_maddr(WG_MADDR)
assert verify_wg_peer(addr), ( inspection: str | None = os.environ.get('WG_KEY_INSPECTION')
f'wg pubkey from maddr not a peer on wg0 !\n' if not await verify_wg_key(
addr,
role='peer',
inspection=inspection,
):
raise RuntimeError(
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'
) )
async with ( async with (
tractor.open_root_actor( tractor.open_root_actor(
name='wg_client', name='wg_client',
tpt_bind_addrs=[LOCAL_OVERLAY_BIND],
registry_addrs=[addr.overlay], registry_addrs=[addr.overlay],
enable_transports=[addr.overlay_proto], enable_transports=[addr.overlay_proto],
), ),
tractor.find_actor( tractor.find_actor(
'echo_srv', 'echo_srv',
registry_addrs=[addr.overlay], registry_addrs=[addr.overlay],
raise_on_none=True,
) as portal, ) as portal,
): ):
res: str = await portal.run( res: str = await portal.run(

View File

@ -26,7 +26,7 @@ stack "inner" reads as higher-up and later-called, whereas here
the encapsulated addr is bound *first* and sits deeper in the the encapsulated addr is bound *first* and sits deeper in the
maddr two opposite intuitions on one word. maddr two opposite intuitions on one word.
`/wg/u<key>` itself carries the tunnel peer's Curve25519 pubkey `/wg/u<key>` itself carries a declared Curve25519 pubkey
as multibase base64url (std base64 from `wg(8)` contains `/` and as multibase base64url (std base64 from `wg(8)` contains `/` and
so can't go in a `/`-delimited maddr). It binds nothing at all; so can't go in a `/`-delimited maddr). It binds nothing at all;
it's an identity, verified out-of-band. it's an identity, verified out-of-band.
@ -43,28 +43,33 @@ Design rules this module follows (see
through `.decapsulate_code()`, `.split()`, `.join()`, through `.decapsulate_code()`, `.split()`, `.join()`,
`.encapsulate()` and `.value_for_protocol()`. We hand-roll no `.encapsulate()` and `.value_for_protocol()`. We hand-roll no
segment splitting whatsoever the whole point of gh #429 was segment splitting whatsoever the whole point of gh #429 was
dropping the NIH parser, and that applies to *peeling a tunnel dropping the NIH parser, and that applies to peeling this
stack* every bit as much as to decoding a single proto. composed maddr every bit as much as to decoding one proto.
- **parsing is pure**. `parse_wg_maddr()` does no I/O, no - **parsing is pure**. `parse_wg_maddr()` does no I/O, no
`subprocess`, no netlink. A parser that shells out is a nasty `subprocess`, no netlink. A parser that shells out is a nasty
surprise. surprise.
- **verification is an explicit, separate step**. The caller - **verification is an explicit, separate step**. The caller
composes `verify_wg_peer()` when it wants it; nothing implicit. 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 - **no new `Address` proto-type**. `wg` gets no entry in
`tractor.discovery._addr._address_types` (a `bidict`, so 1:1 `tractor.discovery._addr._address_types`, which maps available
proto-key<->type) bc it has no `MsgTransport` of its own. The 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 tunnel is a *bindspace*, so we carry it beside the overlay
addr and strip to `.overlay` at bind/dial time. addr and strip to `.overlay` at bind/dial time.
''' '''
from __future__ import annotations from __future__ import annotations
import base64 import base64
import subprocess
from typing import Literal from typing import Literal
import msgspec import msgspec
from multiaddr import Multiaddr from multiaddr import Multiaddr
from multiaddr.protocols import P_WG import trio
IPProto = Literal['ip4', 'ip6'] IPProto = Literal['ip4', 'ip6']
@ -76,15 +81,16 @@ class WGTunnelledAddr(
): ):
''' '''
A `wg`-tunnelled endpoint: the underlay bearer, the tunnel A `wg`-tunnelled endpoint: the underlay bearer, the tunnel
peer key, and the overlay addr `tractor` binds/dials. key, and the overlay addr `tractor` binds/dials.
''' '''
# underlay, owned by `wg(8)`/the kernel — NEVER bound by us # underlay, owned by `wg(8)`/the kernel — NEVER bound by us
bearer: tuple[str, int] bearer: tuple[str, int]
# tunnel peer pubkey in the std-base64 `wg(8)` form, i.e. # declared wg pubkey in std-base64 `wg(8)` form; it is the
# directly comparable to `wg show <if> peers` output # local key on the bearer host and a configured peer on a dialer
peer_pubkey: str wg_pubkey: str
# overlay ep: an `UnwrappedAddress` as accepted by # overlay ep: an `UnwrappedAddress` as accepted by
# `tractor.discovery.wrap_address()` # `tractor.discovery.wrap_address()`
@ -108,7 +114,7 @@ class WGTunnelledAddr(
return ( return (
Multiaddr(f'/{self.bearer_ip}/{b_host}/udp/{b_port}') Multiaddr(f'/{self.bearer_ip}/{b_host}/udp/{b_port}')
.encapsulate( .encapsulate(
Multiaddr(f'/wg/{mb_pubkey(self.peer_pubkey)}') Multiaddr(f'/wg/{mb_pubkey(self.wg_pubkey)}')
) )
.encapsulate( .encapsulate(
Multiaddr( Multiaddr(
@ -133,7 +139,13 @@ def mb_pubkey(wg8_key: str) -> str:
''' '''
import multibase import multibase
raw: bytes = base64.b64decode(wg8_key)
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') return multibase.encode('base64url', raw).decode('ascii')
@ -143,11 +155,17 @@ def wg8_pubkey(mb_key: str) -> str:
''' '''
import multibase import multibase
raw: bytes = multibase.decode(mb_key) 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') return base64.b64encode(raw).decode('ascii')
_wg_proto_known: bool|None = None _wg_proto_known: bool | None = None
def _have_wg_maddr_proto() -> bool: def _have_wg_maddr_proto() -> bool:
@ -167,6 +185,7 @@ def _have_wg_maddr_proto() -> bool:
if _wg_proto_known is None: if _wg_proto_known is None:
from multiaddr.protocols import protocol_with_name from multiaddr.protocols import protocol_with_name
from multiaddr.exceptions import ProtocolNotFoundError from multiaddr.exceptions import ProtocolNotFoundError
try: try:
protocol_with_name('wg') protocol_with_name('wg')
_wg_proto_known = True _wg_proto_known = True
@ -177,7 +196,7 @@ def _have_wg_maddr_proto() -> bool:
def parse_wg_maddr( def parse_wg_maddr(
maddr: str|Multiaddr, maddr: str | Multiaddr,
) -> WGTunnelledAddr: ) -> WGTunnelledAddr:
''' '''
Peel a `wg`-tunnelled maddr into its bearer/key/overlay Peel a `wg`-tunnelled maddr into its bearer/key/overlay
@ -208,18 +227,31 @@ def parse_wg_maddr(
for seg in segs for seg in segs
for proto in seg.protocols() for proto in seg.protocols()
] ]
if 'wg' not in names: wg_count: int = names.count('wg')
if not wg_count:
raise ValueError( raise ValueError(
f'Not a `wg`-tunnelled maddr, no `/wg/` segment ??\n' f'Not a `wg`-tunnelled maddr, no `/wg/` segment ??\n'
f'maddr: {ma}\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 # NOTE, `.decapsulate_code()` cuts at the LAST occurrence of
# the proto and keeps the *prefix*, which is exactly the # the proto and keeps the *prefix*, which is exactly the
# bearer. It handles `/wg/` cleanly precisely bc it cuts on # bearer. It handles `/wg/` cleanly precisely bc it cuts on
# proto-code and never tries to match an addr value — the # proto-code and never tries to match an addr value — the
# key seg has no addr of its own. # key seg has no addr of its own.
bearer_ma: Multiaddr = ma.decapsulate_code(P_WG) bearer_ma: Multiaddr = ma.decapsulate_code(wg_code)
overlay_ma: Multiaddr = Multiaddr.join( overlay_ma: Multiaddr = Multiaddr.join(
*segs[names.index('wg') + 1:] *segs[names.index('wg') + 1:]
) )
@ -259,7 +291,7 @@ def parse_wg_maddr(
return WGTunnelledAddr( return WGTunnelledAddr(
bearer=bearer, bearer=bearer,
peer_pubkey=wg8_pubkey(ma.value_for_protocol('wg')), wg_pubkey=wg8_pubkey(ma.value_for_protocol('wg')),
overlay=overlay, overlay=overlay,
overlay_proto=l4, overlay_proto=l4,
bearer_ip=b_ip, bearer_ip=b_ip,
@ -267,13 +299,23 @@ def parse_wg_maddr(
) )
def verify_wg_peer( async def verify_wg_key(
addr: WGTunnelledAddr, addr: WGTunnelledAddr,
role: Literal['local', 'peer'],
iface: str = 'wg0', iface: str = 'wg0',
timeout: float = 5,
inspection: str | None = None,
) -> bool: ) -> bool:
''' '''
True iff `addr.peer_pubkey` is a configured peer (or our own Verify the declared key in the role required on this host.
pubkey) on `iface`.
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: never called from IMPURE + explicit by design: never called from
`parse_wg_maddr()`. `parse_wg_maddr()`.
@ -284,16 +326,23 @@ def verify_wg_peer(
netns unless `netns=` is passed down. netns unless `netns=` is passed down.
''' '''
def _wg(*args: str) -> str: match role:
return subprocess.run( case 'local':
['wg', 'show', iface, *args], field = 'public-key'
capture_output=True, case 'peer':
text=True, field = 'peers'
check=True, case _:
).stdout raise ValueError(f'Unknown WireGuard key role: {role!r}')
return ( if inspection is None:
addr.peer_pubkey in _wg('peers').split() with trio.fail_after(timeout):
or proc = await trio.run_process(
addr.peer_pubkey == _wg('public-key').strip() ['wg', 'show', iface, field],
capture_stdout=True,
check=True,
) )
inspection = proc.stdout.decode()
if role == 'local':
return addr.wg_pubkey == inspection.strip()
return addr.wg_pubkey in inspection.split()

14
uv.lock
View File

@ -273,9 +273,9 @@ name = "greenback"
version = "1.2.1" version = "1.2.1"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "greenlet", marker = "python_full_version < '3.14'" }, { name = "greenlet" },
{ name = "outcome", marker = "python_full_version < '3.14'" }, { name = "outcome" },
{ name = "sniffio", marker = "python_full_version < '3.14'" }, { name = "sniffio" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/dc/c1/ab3a42c0f3ed56df9cd33de1539b3198d98c6ccbaf88a73d6be0b72d85e0/greenback-1.2.1.tar.gz", hash = "sha256:de3ca656885c03b96dab36079f3de74bb5ba061da9bfe3bb69dccc866ef95ea3", size = 42597, upload-time = "2024-02-20T21:23:13.239Z" } sdist = { url = "https://files.pythonhosted.org/packages/dc/c1/ab3a42c0f3ed56df9cd33de1539b3198d98c6ccbaf88a73d6be0b72d85e0/greenback-1.2.1.tar.gz", hash = "sha256:de3ca656885c03b96dab36079f3de74bb5ba061da9bfe3bb69dccc866ef95ea3", size = 42597, upload-time = "2024-02-20T21:23:13.239Z" }
wheels = [ wheels = [
@ -518,7 +518,7 @@ wheels = [
[[package]] [[package]]
name = "multiaddr" name = "multiaddr"
version = "0.2.0" version = "0.2.0"
source = { registry = "https://pypi.org/simple" } source = { git = "https://github.com/multiformats/py-multiaddr.git?rev=f86519daaa21699023d0037c58cdff600313dd09#f86519daaa21699023d0037c58cdff600313dd09" }
dependencies = [ dependencies = [
{ name = "base58" }, { name = "base58" },
{ name = "dnspython" }, { name = "dnspython" },
@ -533,10 +533,6 @@ dependencies = [
{ name = "trio-typing" }, { name = "trio-typing" },
{ name = "varint" }, { name = "varint" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/c7/10/4e26a8577cfce1c0febc8d83087e1373e93c695c6e73ad010546fb67e229/multiaddr-0.2.0.tar.gz", hash = "sha256:acb6b25c332ec1b2f1f8fef8d03a8c63385d34a87d690df0f4bba43cdf6efe8d", size = 58356, upload-time = "2026-03-17T21:51:00.274Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b5/13/56e503d01218d1ca27ea9fda862045a4b400cae5e756f47315f5aaba0eee/multiaddr-0.2.0-py3-none-any.whl", hash = "sha256:bcff7bf3d7de3d6da0b865b25423bcb411de1d20d70cc6abfacf75170d17866c", size = 40424, upload-time = "2026-03-17T21:50:58.833Z" },
]
[[package]] [[package]]
name = "mypy-extensions" name = "mypy-extensions"
@ -1193,7 +1189,7 @@ requires-dist = [
{ name = "bidict", specifier = ">=0.23.1" }, { name = "bidict", specifier = ">=0.23.1" },
{ name = "colorlog", specifier = ">=6.8.2,<7" }, { name = "colorlog", specifier = ">=6.8.2,<7" },
{ name = "msgspec", specifier = ">=0.20.0" }, { name = "msgspec", specifier = ">=0.20.0" },
{ name = "multiaddr", specifier = ">=0.2.0" }, { name = "multiaddr", git = "https://github.com/multiformats/py-multiaddr.git?rev=f86519daaa21699023d0037c58cdff600313dd09" },
{ name = "pdbp", specifier = ">=1.8.2,<2" }, { name = "pdbp", specifier = ">=1.8.2,<2" },
{ name = "platformdirs", specifier = ">=4.4.0" }, { name = "platformdirs", specifier = ">=4.4.0" },
{ name = "setproctitle", specifier = ">=1.3,<2" }, { name = "setproctitle", specifier = ">=1.3,<2" },