Skip to content

wg multiaddr: LAN deployment examples #482

Description

@goodboy

tractor + wg multiaddr: LAN deployment examples

Draft examples for a small today-deployable setup: a tractor
actor tree on another LAN host reachable over a WireGuard tunnel,
with endpoints declared as wg multiaddrs (py-multiaddr PR
#108, key form u<base64url> per commit 8be3a8b).

Facts these examples rely on (verified against tractor@main):

  • tractor accepts maddr strings anywhere an addr is taken
    (wrap_address() dispatches on a leading /
    tractor/discovery/_addr.py:262), but parse_maddr()
    (tractor/discovery/_multiaddr.py) only understands
    /ip4|ip6/<h>/tcp/<p> and /unix/<path> combos — a
    .../wg/u<key> maddr raises
    ValueError('Unsupported multiaddr protocol combo') today.
  • so: WG runs at the iface layer (transparent to sockets) and
    the wg maddr segment is used declaratively — parsed via
    py-multiaddr then stripped to the inner (host, port) for
    tractor, with the pubkey checked against the live tunnel.
  • relevant kwargs: open_root_actor(registry_addrs=..., tpt_bind_addrs=..., enable_transports=['tcp']) (all forwarded
    by open_nursery()); client side tractor.find_actor(name, registry_addrs=[...]).
  • roadmap for first-class support:
    tractor/ai/prompt-io/prompts/multiaddr_declare_eps.md
    (tunnelled-maddr nested-bindspace @acms + pyroute2 netns).

0. WireGuard tunnel setup (both hosts)

Host A = service host on your LAN (underlay e.g. 192.168.1.50),
host B = your workstation. Overlay net 10.0.11.0/24.

# on each host
umask 077
wg genkey | tee wg_priv.key | wg pubkey > wg_pub.key

/etc/wireguard/wg0.conf on host A:

[Interface]
PrivateKey = <A_priv>
Address = 10.0.11.1/24
ListenPort = 51820

[Peer]
PublicKey = <B_pub>
AllowedIPs = 10.0.11.2/32

on host B:

[Interface]
PrivateKey = <B_priv>
Address = 10.0.11.2/24

[Peer]
PublicKey = <A_pub>
Endpoint = 192.168.1.50:51820
AllowedIPs = 10.0.11.1/32
PersistentKeepalive = 25

Bring up + smoke test:

sudo wg-quick up wg0   # both hosts
ping -c1 10.0.11.1     # from B

1. Declaring the endpoint as a wg maddr

The canonical endpoint string for host A's registrar, carrying
the tunnel pubkey for verification:

/ip4/10.0.11.1/tcp/1616/wg/u<A_pub-as-base64url>

Convert a wg(8) pubkey (std base64) to the multibase form:

python -c "
import base64, multibase
key: str = open('wg_pub.key').read().strip()
print(
    multibase.encode(
        'base64url',
        base64.b64decode(key),
    ).decode('ascii')
)
"

2. Shared helper: wg maddr -> tractor addr + key check

wg_maddr.py (drop next to both scripts below):

'''
Parse `/ip4/<h>/tcp/<p>/wg/u<key>` maddrs into `tractor`
addr tuples, verifying the embedded WireGuard pubkey
against the live `wg0` tunnel state.

'''
import base64
import subprocess

import multibase
from multiaddr import Multiaddr


def parse_wg_maddr(
    maddr: str,
) -> tuple[tuple[str, int], str]:
    '''
    Split a wg-suffixed maddr into
    `((host, port), wg8_pubkey)` where the key is
    re-encoded to the std-base64 `wg(8)` form.

    '''
    ma = Multiaddr(maddr)
    host: str = ma.value_for_protocol('ip4')
    port = int(ma.value_for_protocol('tcp'))
    key_mb: str = ma.value_for_protocol('wg')
    raw: bytes = multibase.decode(key_mb)
    wg8_key: str = base64.b64encode(raw).decode('ascii')
    return (host, port), wg8_key


def verify_wg_peer(
    wg8_key: str,
    iface: str = 'wg0',
) -> bool:
    '''
    True iff `wg8_key` is a configured peer (or our own
    pubkey) on `iface`.

    '''
    peers: str = subprocess.run(
        ['sudo', 'wg', 'show', iface, 'peers'],
        capture_output=True,
        text=True,
        check=True,
    ).stdout
    own: str = subprocess.run(
        ['sudo', 'wg', 'show', iface, 'public-key'],
        capture_output=True,
        text=True,
        check=True,
    ).stdout
    return (
        wg8_key in peers.split()
        or
        wg8_key == own.strip()
    )

3. Host A: registrar root + service daemon on the wg iface

host_a_srv.py:

import tractor
import trio

from wg_maddr import parse_wg_maddr, verify_wg_peer

WG_MADDR: str = (
    '/ip4/10.0.11.1/tcp/1616/wg/u<A_pub_b64url>'
)


async def echo(msg: str) -> str:
    actor = tractor.current_actor()
    return f'{actor.uid!r} echoes: {msg}'


async def main():
    reg_addr, wg8_key = parse_wg_maddr(WG_MADDR)
    assert verify_wg_peer(wg8_key), (
        f'wg pubkey from maddr not active on wg0!\n'
        f'maddr: {WG_MADDR}\n'
    )
    async with tractor.open_nursery(
        # registrar binds the wg-overlay IP; subactors
        # get random ports in the same bindspace so
        # they stay wg-reachable too.
        registry_addrs=[reg_addr],
        enable_transports=['tcp'],
    ) as an:
        await an.start_actor(
            'echo_srv',
            enable_modules=['host_a_srv'],
        )
        print(f'echo_srv up on maddr:\n  {WG_MADDR}\n')
        await trio.sleep_forever()


if __name__ == '__main__':
    trio.run(main)

Run on host A (from a venv with tractor + this py-multiaddr
branch installed):

python host_a_srv.py

4. Host B: client dialing through the tunnel

host_b_client.py:

import tractor
import trio

from host_a_srv import echo
from wg_maddr import parse_wg_maddr, verify_wg_peer

# same maddr string as host A: the *service* host's
# overlay addr + *its* tunnel pubkey.
WG_MADDR: str = (
    '/ip4/10.0.11.1/tcp/1616/wg/u<A_pub_b64url>'
)


async def main():
    reg_addr, wg8_key = parse_wg_maddr(WG_MADDR)
    assert verify_wg_peer(wg8_key), (
        f'wg pubkey from maddr not a peer on wg0!\n'
        f'maddr: {WG_MADDR}\n'
    )
    async with (
        tractor.open_root_actor(
            name='wg_client',
            registry_addrs=[reg_addr],
            enable_transports=['tcp'],
        ),
        tractor.find_actor(
            'echo_srv',
            registry_addrs=[reg_addr],
        ) as portal,
    ):
        res: str = await portal.run(
            echo,
            msg='hello over wg!',
        )
        print(res)


if __name__ == '__main__':
    trio.run(main)

Note: host_a_srv.py must be importable on host B too (same
file on both hosts) since portal.run() refs the fn by module
path — standard tractor RPC semantics.

5. Variant: pure-maddr config (no wg segment)

Since tractor already parses plain tcp maddr strings, the
minimal no-helper variant just drops the /wg/... suffix and
trusts the tunnel implicitly:

async with tractor.open_nursery(
    registry_addrs=['/ip4/10.0.11.1/tcp/1616'],
    enable_transports=['tcp'],
) as an:
    ...

Useful as a fallback if the helper's key-check gets in the way
during first bring-up.

6. Follow-up: first-class wg proto in tractor

For the future (per multiaddr_declare_eps.md): teach
tractor/discovery/_multiaddr.py::parse_maddr() a
[('ip4'|'ip6'), 'tcp', 'wg'] case that returns a
TCPAddress annotated with the peer pubkey (or a new
WGAddress type registered in _address_types /
_addr_to_transport), doing the sec. 2 helper's
verification inside the runtime's bind/connect paths, and
eventually pyroute2-managed netns + tunnel lifecycle as
nested bindspace @acms.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions