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
parent
7c2de6c359
commit
e4e27ec71d
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -33,7 +33,7 @@ onto `trio` as the library's sans-io layer allows.
|
|||
by multiformats/py-multiaddr#107 and gh #483.
|
||||
- so **today's deployable story is declarative**: run `wg-quick`
|
||||
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=`.
|
||||
#482 already contains working example code for exactly this.
|
||||
- `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
|
||||
moment of bind/connect.
|
||||
- ⚠️ `is_wrapped_addr()` (`_addr.py:194`) tests
|
||||
`type(addr) in _address_types.values()` — a `bidict` of
|
||||
proto_key→type. `TunnelledAddress` isn't in it and must not
|
||||
be (it's not 1:1 with a proto). So either add an explicit
|
||||
`type(addr) in _address_types.values()` — the build-registered
|
||||
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.
|
||||
|
|
@ -103,7 +104,7 @@ class WGTunnelSpec(
|
|||
msgspec.Struct,
|
||||
frozen=True,
|
||||
):
|
||||
peer_pubkey: str # std-base64 `wg(8)` form
|
||||
pubkey: str # std-base64 `wg(8)` form
|
||||
iface: str = 'wg0'
|
||||
netns: str|None = None
|
||||
# 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.
|
||||
- keep the existing 2-proto cases byte-identical; add the new
|
||||
case *after* them.
|
||||
- nesting (wg-in-wg) falls out of `.decapsulate_code()` cutting
|
||||
at the *last* occurrence — peel repeatedly rather than
|
||||
recursing through a bespoke splitter.
|
||||
- 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
|
||||
`.encapsulate()` composition; don't rebuild `str`s by hand.
|
||||
- **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
|
||||
def parse_wg_maddr(maddr: str) -> TunnelledAddress: ... # 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>
|
||||
peers`), but it must be a *single* function so layer B swaps
|
||||
only its body. Never call it implicitly from
|
||||
In layer A `verify_wg_key()` may shell out to role-specific
|
||||
`wg show <if> public-key|peers` queries, but it must be a *single*
|
||||
async, time-bounded function so it never blocks trio's run thread
|
||||
and layer B swaps only its body. It verifies key presence only,
|
||||
not `Endpoint`, `AllowedIPs`, handshake state, or routing. Never
|
||||
run `tractor` as root: privileged inspection stays a separate
|
||||
step whose public-key output can be passed as `inspection`. Never
|
||||
call it implicitly from
|
||||
`wrap_address()`/`parse_maddr()` — parsing must stay pure and
|
||||
side-effect-free; verification is the *caller's* explicit step
|
||||
(and later, the bindspace `@acm`'s).
|
||||
|
|
@ -281,7 +292,7 @@ Three integration options, in increasing trio-nativeness:
|
|||
- (3) reimplement the codecs. Never.
|
||||
|
||||
**Recommended split**: ship (1) first so layer B is a small,
|
||||
reviewable, behaviour-preserving swap of `verify_wg_peer()`'s
|
||||
reviewable, behaviour-preserving swap of `verify_wg_key()`'s
|
||||
body; then land (2) as a follow-up commit for the read path
|
||||
(`wg get`, `link get`) where the sans-io surface is smallest,
|
||||
and keep (1) for the privileged mutating ops. Measure before
|
||||
|
|
@ -306,7 +317,7 @@ async def read_wg_peers(
|
|||
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)`.
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -49,11 +49,9 @@ uv sync
|
|||
```
|
||||
|
||||
gets you a `wg`-aware `multiaddr`. That pin goes away once a
|
||||
release carries the codec. You also need `multibase`:
|
||||
|
||||
```bash
|
||||
uv pip install multibase
|
||||
```
|
||||
release carries the codec. Its `py-multibase` dependency provides
|
||||
the imported `multibase` module; no separate install command is
|
||||
needed.
|
||||
|
||||
Without the codec `parse_wg_maddr()` raises immediately with an
|
||||
actionable message — there is deliberately **no** degraded
|
||||
|
|
@ -64,8 +62,9 @@ tunnel API (`.decapsulate_code()`, `.split()`, `.join()`,
|
|||
`.encapsulate()`, `.value_for_protocol()`) rather than any
|
||||
bespoke segment slicing — see its README "En/decapsulate" and
|
||||
"Tunneling" sections. gh #429 was about *dropping* our NIH
|
||||
parser, and that applies to peeling a tunnel stack just as much
|
||||
as to decoding one proto.
|
||||
parser, and that applies to peeling this composed maddr just as
|
||||
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)
|
||||
|
||||
|
|
@ -106,8 +105,11 @@ AllowedIPs = 10.0.11.1/32
|
|||
PersistentKeepalive = 25
|
||||
```
|
||||
|
||||
Note how `ListenPort` and `Endpoint` are exactly the maddr's
|
||||
bearer segment, and `[Interface] Address` is its overlay host.
|
||||
This example configures host A's `ListenPort` and host B's
|
||||
`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
|
||||
sudo wg-quick up wg0 # both hosts
|
||||
|
|
@ -118,16 +120,40 @@ ping -c1 10.0.11.1 # from B
|
|||
|
||||
```bash
|
||||
python -c "
|
||||
import base64, multibase
|
||||
from wg_maddr import mb_pubkey
|
||||
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
|
||||
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
|
||||
# host A
|
||||
|
|
@ -137,6 +163,17 @@ python host_a_srv.py
|
|||
python host_b_client.py
|
||||
```
|
||||
|
||||
Run both `tractor` programs as the normal application account,
|
||||
not as root. Privilege is needed only for tunnel setup and the
|
||||
separate inspection above. If using that preflight, keep the
|
||||
host-specific `WG_KEY_INSPECTION` value exported in each
|
||||
program's shell.
|
||||
|
||||
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
|
||||
`portal.run()` refs the fn by module path — standard `tractor`
|
||||
RPC semantics.
|
||||
|
|
@ -153,19 +190,19 @@ Four corrections, all from
|
|||
all. `parse_wg_maddr()` now rejects it with an actionable
|
||||
error.
|
||||
2. **parsing is pure.** #482's helper had the key-check adjacent
|
||||
to the parse; `verify_wg_peer()` is now a separate, explicitly
|
||||
composed step that the caller invokes. A parser that shells
|
||||
out is a nasty surprise.
|
||||
to the parse; `verify_wg_key()` is now a separate, explicitly
|
||||
composed step for inspection-capable callers. A parser that
|
||||
shells out is a nasty surprise.
|
||||
3. **no `sudo`.** #482 ran `sudo wg show`; a library/example must
|
||||
never escalate. `wg show` works unprivileged for read on most
|
||||
setups; if yours needs root, run the script as root rather
|
||||
than embedding `sudo`.
|
||||
never escalate or run `tractor` as root. Privileged tunnel
|
||||
setup and key inspection are separate shell steps.
|
||||
4. **no new `Address` proto-type.** The tunnel rides *beside* the
|
||||
overlay addr in a frozen `WGTunnelledAddr`, and only `.overlay`
|
||||
crosses into `open_nursery()`. #482 §6 floated a `WGAddress`
|
||||
registered in `_address_types` — that table is a `bidict`
|
||||
(1:1 proto-key↔type) and `_addr_to_transport` wants a
|
||||
`MsgTransport` per addr-type, which `wg` doesn't have.
|
||||
registered in `_address_types` — that registry maps available
|
||||
transport keys to concrete address types, and
|
||||
`_addr_to_transport` wants a `MsgTransport` per addr-type,
|
||||
which `wg` doesn't have.
|
||||
|
||||
## next
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
'''
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
import tractor
|
||||
import trio
|
||||
|
||||
from wg_maddr import (
|
||||
parse_wg_maddr,
|
||||
verify_wg_peer,
|
||||
verify_wg_key,
|
||||
WGTunnelledAddr,
|
||||
)
|
||||
|
||||
|
|
@ -34,10 +37,16 @@ async def echo(msg: str) -> str:
|
|||
|
||||
async def main():
|
||||
addr: WGTunnelledAddr = parse_wg_maddr(WG_MADDR)
|
||||
assert verify_wg_peer(addr), (
|
||||
f'wg pubkey from maddr not active on wg0 !\n'
|
||||
inspection: str | None = os.environ.get('WG_KEY_INSPECTION')
|
||||
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'key: {addr.peer_pubkey}\n'
|
||||
f'key: {addr.wg_pubkey}\n'
|
||||
)
|
||||
print(
|
||||
f'wg bearer (kernel-owned): {addr.bearer}\n'
|
||||
|
|
@ -51,7 +60,9 @@ async def main():
|
|||
) as an:
|
||||
await an.start_actor(
|
||||
'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')
|
||||
await trio.sleep_forever()
|
||||
|
|
|
|||
|
|
@ -4,15 +4,18 @@ Host B: workstation dialing host A's actor tree through the
|
|||
`wg` tunnel.
|
||||
|
||||
'''
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
import tractor
|
||||
import trio
|
||||
|
||||
from host_a_srv import echo # noqa: F401 (RPC refs it by mod path)
|
||||
from wg_maddr import (
|
||||
parse_wg_maddr,
|
||||
verify_wg_peer,
|
||||
verify_wg_key,
|
||||
WGTunnelledAddr,
|
||||
)
|
||||
|
||||
|
|
@ -22,23 +25,33 @@ WG_MADDR: str = (
|
|||
'/wg/u<A_pub_b64url>'
|
||||
'/ip4/10.0.11.1/tcp/1616'
|
||||
)
|
||||
LOCAL_OVERLAY_BIND: tuple[str, int] = ('10.0.11.2', 0)
|
||||
|
||||
|
||||
async def main():
|
||||
addr: WGTunnelledAddr = parse_wg_maddr(WG_MADDR)
|
||||
assert verify_wg_peer(addr), (
|
||||
f'wg pubkey from maddr not a peer on wg0 !\n'
|
||||
inspection: str | None = os.environ.get('WG_KEY_INSPECTION')
|
||||
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'key: {addr.wg_pubkey}\n'
|
||||
)
|
||||
async with (
|
||||
tractor.open_root_actor(
|
||||
name='wg_client',
|
||||
tpt_bind_addrs=[LOCAL_OVERLAY_BIND],
|
||||
registry_addrs=[addr.overlay],
|
||||
enable_transports=[addr.overlay_proto],
|
||||
),
|
||||
tractor.find_actor(
|
||||
'echo_srv',
|
||||
registry_addrs=[addr.overlay],
|
||||
raise_on_none=True,
|
||||
) as portal,
|
||||
):
|
||||
res: str = await portal.run(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
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
|
||||
so can't go in a `/`-delimited maddr). It binds nothing at all;
|
||||
it's an identity, verified out-of-band.
|
||||
|
|
@ -43,28 +43,33 @@ Design rules this module follows (see
|
|||
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 a tunnel
|
||||
stack* every bit as much as to decoding a single proto.
|
||||
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_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
|
||||
`tractor.discovery._addr._address_types` (a `bidict`, so 1:1
|
||||
proto-key<->type) bc it has no `MsgTransport` of its own. The
|
||||
`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
|
||||
import base64
|
||||
import subprocess
|
||||
from typing import Literal
|
||||
|
||||
import msgspec
|
||||
from multiaddr import Multiaddr
|
||||
from multiaddr.protocols import P_WG
|
||||
import trio
|
||||
|
||||
|
||||
IPProto = Literal['ip4', 'ip6']
|
||||
|
|
@ -76,15 +81,16 @@ class WGTunnelledAddr(
|
|||
):
|
||||
'''
|
||||
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
|
||||
bearer: tuple[str, int]
|
||||
|
||||
# tunnel peer pubkey in the std-base64 `wg(8)` form, i.e.
|
||||
# directly comparable to `wg show <if> peers` output
|
||||
peer_pubkey: str
|
||||
# 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()`
|
||||
|
|
@ -108,7 +114,7 @@ class WGTunnelledAddr(
|
|||
return (
|
||||
Multiaddr(f'/{self.bearer_ip}/{b_host}/udp/{b_port}')
|
||||
.encapsulate(
|
||||
Multiaddr(f'/wg/{mb_pubkey(self.peer_pubkey)}')
|
||||
Multiaddr(f'/wg/{mb_pubkey(self.wg_pubkey)}')
|
||||
)
|
||||
.encapsulate(
|
||||
Multiaddr(
|
||||
|
|
@ -133,7 +139,13 @@ def mb_pubkey(wg8_key: str) -> str:
|
|||
|
||||
'''
|
||||
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')
|
||||
|
||||
|
||||
|
|
@ -143,11 +155,17 @@ def wg8_pubkey(mb_key: str) -> str:
|
|||
|
||||
'''
|
||||
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
|
||||
_wg_proto_known: bool | None = None
|
||||
|
||||
|
||||
def _have_wg_maddr_proto() -> bool:
|
||||
|
|
@ -167,6 +185,7 @@ def _have_wg_maddr_proto() -> bool:
|
|||
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
|
||||
|
|
@ -177,7 +196,7 @@ def _have_wg_maddr_proto() -> bool:
|
|||
|
||||
|
||||
def parse_wg_maddr(
|
||||
maddr: str|Multiaddr,
|
||||
maddr: str | Multiaddr,
|
||||
) -> WGTunnelledAddr:
|
||||
'''
|
||||
Peel a `wg`-tunnelled maddr into its bearer/key/overlay
|
||||
|
|
@ -208,18 +227,31 @@ def parse_wg_maddr(
|
|||
for seg in segs
|
||||
for proto in seg.protocols()
|
||||
]
|
||||
if 'wg' not in names:
|
||||
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(P_WG)
|
||||
bearer_ma: Multiaddr = ma.decapsulate_code(wg_code)
|
||||
overlay_ma: Multiaddr = Multiaddr.join(
|
||||
*segs[names.index('wg') + 1:]
|
||||
)
|
||||
|
|
@ -259,7 +291,7 @@ def parse_wg_maddr(
|
|||
|
||||
return WGTunnelledAddr(
|
||||
bearer=bearer,
|
||||
peer_pubkey=wg8_pubkey(ma.value_for_protocol('wg')),
|
||||
wg_pubkey=wg8_pubkey(ma.value_for_protocol('wg')),
|
||||
overlay=overlay,
|
||||
overlay_proto=l4,
|
||||
bearer_ip=b_ip,
|
||||
|
|
@ -267,13 +299,23 @@ def parse_wg_maddr(
|
|||
)
|
||||
|
||||
|
||||
def verify_wg_peer(
|
||||
async def verify_wg_key(
|
||||
addr: WGTunnelledAddr,
|
||||
role: Literal['local', 'peer'],
|
||||
iface: str = 'wg0',
|
||||
timeout: float = 5,
|
||||
inspection: str | None = None,
|
||||
) -> bool:
|
||||
'''
|
||||
True iff `addr.peer_pubkey` is a configured peer (or our own
|
||||
pubkey) on `iface`.
|
||||
Verify the declared key in the role required on this host.
|
||||
|
||||
A bearer host uses `role='local'`; a dialer uses `role='peer'`.
|
||||
This verifies only key presence. It does not inspect the peer's
|
||||
endpoint, AllowedIPs, handshake state, or iface addresses.
|
||||
|
||||
`inspection` accepts output captured by a separate privileged
|
||||
`wg show` step. Without it, query asynchronously for callers
|
||||
which already have interface-inspection permission.
|
||||
|
||||
IMPURE + explicit by design: never called from
|
||||
`parse_wg_maddr()`.
|
||||
|
|
@ -284,16 +326,23 @@ def verify_wg_peer(
|
|||
netns unless `netns=` is passed down.
|
||||
|
||||
'''
|
||||
def _wg(*args: str) -> str:
|
||||
return subprocess.run(
|
||||
['wg', 'show', iface, *args],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
).stdout
|
||||
match role:
|
||||
case 'local':
|
||||
field = 'public-key'
|
||||
case 'peer':
|
||||
field = 'peers'
|
||||
case _:
|
||||
raise ValueError(f'Unknown WireGuard key role: {role!r}')
|
||||
|
||||
return (
|
||||
addr.peer_pubkey in _wg('peers').split()
|
||||
or
|
||||
addr.peer_pubkey == _wg('public-key').strip()
|
||||
if inspection is None:
|
||||
with trio.fail_after(timeout):
|
||||
proc = await trio.run_process(
|
||||
['wg', 'show', iface, field],
|
||||
capture_stdout=True,
|
||||
check=True,
|
||||
)
|
||||
inspection = proc.stdout.decode()
|
||||
|
||||
if role == 'local':
|
||||
return addr.wg_pubkey == inspection.strip()
|
||||
return addr.wg_pubkey in inspection.split()
|
||||
|
|
|
|||
14
uv.lock
14
uv.lock
|
|
@ -273,9 +273,9 @@ name = "greenback"
|
|||
version = "1.2.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "greenlet", marker = "python_full_version < '3.14'" },
|
||||
{ name = "outcome", marker = "python_full_version < '3.14'" },
|
||||
{ name = "sniffio", marker = "python_full_version < '3.14'" },
|
||||
{ name = "greenlet" },
|
||||
{ name = "outcome" },
|
||||
{ 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" }
|
||||
wheels = [
|
||||
|
|
@ -518,7 +518,7 @@ wheels = [
|
|||
[[package]]
|
||||
name = "multiaddr"
|
||||
version = "0.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
source = { git = "https://github.com/multiformats/py-multiaddr.git?rev=f86519daaa21699023d0037c58cdff600313dd09#f86519daaa21699023d0037c58cdff600313dd09" }
|
||||
dependencies = [
|
||||
{ name = "base58" },
|
||||
{ name = "dnspython" },
|
||||
|
|
@ -533,10 +533,6 @@ dependencies = [
|
|||
{ name = "trio-typing" },
|
||||
{ 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]]
|
||||
name = "mypy-extensions"
|
||||
|
|
@ -1193,7 +1189,7 @@ requires-dist = [
|
|||
{ name = "bidict", specifier = ">=0.23.1" },
|
||||
{ name = "colorlog", specifier = ">=6.8.2,<7" },
|
||||
{ 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 = "platformdirs", specifier = ">=4.4.0" },
|
||||
{ name = "setproctitle", specifier = ">=1.3,<2" },
|
||||
|
|
|
|||
Loading…
Reference in New Issue