Relay documentation
Markdown version

Python

The Python library relay implements both ends of the channel protocol: the host of a server install and the guest of a phone or another client. It pairs two static keys through a relay, then carries messages between them with Noise IKpsk1 and KK (PROTOCOL.md sections 3 to 9). A message is a bytes object; one send arrives as one message, up to the 16 MiB limit of the protocol.

The public surface is four things: Host, Guest, a pairing, and a peer to send to and receive from. Handshakes, candidates, fragments, rekey, ping, relay failover and reconnect stay inside the library.

Install

pip install ./py

or, to work on the library, cd py && uv sync --group test. The package requires Python 3.12 or later and depends on cryptography and websockets.

A host

Host.open reads or creates the state file at the path it is given and opens a socket on every channel of every bound device. The file holds the host's static key, the bound devices and their channels, and is written atomically at mode 0600.

from relay import Host, Relay

host = await Host.open(
    "state/host",
    relays=[Relay(
        "wss://relay.example.com",         # the guests' URL
        key=project_key,
        host_url="ws://127.0.0.1:8010",    # the host's own URL
    )],
)

The relay list is ordered. The host creates a pairing channel, and later one device channel, on each relay, and keeps a socket on each. Each Relay has two URLs. url is the guests' URL: it passes the policy of section 8.1 (wss, or ws to a literal private address), it is the only relay URL in pairing payloads and relays messages, and it carries the traffic between the two sides. host_url is optional and defaults to url; the host uses it for its own HTTP calls and sockets. A ws:// host URL sends the project key and the host token in cleartext, so it is safe only on loopback or on a network whose only members are the host and the relay, such as a container network; the library cannot check that. The example runs the host on the relay's machine, so the host dials loopback and guests use the public wss:// URL.

host.pair(name) draws a device id, a pairing secret and the channels, and returns the open pairing:

pairing = await host.pair("pixel-7")
show_qr(pairing.payload)   # a string: the JSON of PROTOCOL.md section 4

pairing.payload is the pairing payload. The application renders it as a QR code or as copied text with any library it likes; the relay library does not draw it. The payload carries the pairing secret and live guest tokens, so it travels only over the application's authenticated admin connection and is never logged.

The host confirms in a later request, after a person reads the six digits on the guest:

pairing = host.pairing(device_id)            # the dev drawn by host.pair
device = await pairing.confirm(code)         # WrongCode, PairingFailed

pairing.device is the id to store with the pending pairing, for example in the admin session that renders the QR. host.pairing(id) returns that open pairing or raises PairingFailed when it has ended. confirm is the only way to bind a key. A wrong code raises WrongCode with the tries left; the third wrong code, two pending keys with the same code, the expiry or the 10-minute limit raise PairingFailed. The host never exposes the codes of pending keys.

After confirmation, messages from every bound device arrive through one iterator:

async for device, message in host:
    await device.send(message)               # echo it back

device.send waits until the device is connected; wrap it in asyncio.timeout when the caller cannot wait. host.devices lists the bound devices, each with id, name and connected. Revocation sends revoked, deletes the device's channels on every relay and unbinds its key:

await host.revoke(device)
await host.close()

Host is an async context manager: async with await Host.open(...) as host:. The iterator has one consumer, as asyncio.Queue does; the library keeps a bounded inbox so a slow consumer pushes back on the relay through the socket.

A guest

Guest.open reads or creates the guest state. Without a key argument the library draws a fresh static key for each pairing and keeps it in the state file, at mode 0600:

from relay import Guest

guest = await Guest.open("state/guest")

A native key store is passed as any object with a public byte string and a dh(peer_public) -> bytes method:

guest = await Guest.open("state/guest", key=keystore_key)

The library asks that object for Diffie-Hellman results and never sees the private key. A native key serves one pairing: a second host needs a second Guest with its own state path and key. relay.StaticKey and relay.MemoryKey describe the shape.

First contact takes the payload string and returns the paired host:

link = await guest.pair(pairing.payload)     # PayloadInvalid on a bad payload
print(link.code)                             # the six digits the person types
await link.bound()                           # the device channels carry a session

guest.pair returns once the host holds the guest's key candidate and the pairing code exists, which is before the person has typed it. link.bound() returns when the host has confirmed and the device channels work; until then the link may send a ping and receive its pong, and nothing else.

await link.send(b"hello")
async for message in link:
    ...

The iterator raises Revoked when the host revokes the device. Unpairing sends close, waits for closed and forgets the host; guest.close() only stops the connections and keeps the pairings on disk:

await link.unpair()
await guest.close()

After a restart, guest.hosts lists the paired hosts; each is the same PairedHost object pair returned, and the library reconnects them. A pairing that a restart interrupts is not kept: the person scans a new payload.

Three rounds in which every relay answers 4401 or 4410 make the guest treat the pairing as revoked: it deletes the pairing from its store, and guest.hosts no longer lists it. A relay that lost its channel store, or that swept a channel with no socket for 30 days, causes the same. The person then scans a new payload.

Store and errors

The state path is the whole store. The Python library has no store interface: it writes one JSON file atomically at mode 0600, and the application chooses its directory and its backup. Two processes must not open the same state path.

Error Raised when
PayloadInvalid The payload is not the JSON of section 4, or every relay in it fails the URL policy.
WrongCode The typed code matches no pending key; tries_left says how many remain.
PairingFailed The pairing expired, collided, ran out of tries, or has ended.
Revoked The host revoked this device, or every relay lost the channel.
RelayError Any other failure, including a damaged state file.

All four specific errors derive from RelayError. Transient network errors are retried inside the library and never raised.

What the application still owns

PROTOCOL.md section 10 lists five duties outside the encrypted stream. The library's shape leaves them where they belong:

Duty In a Python application
The code that holds the guest key The application ships and updates the guest code; Guest.open(key=...) can hand the private key to native code that exposes only dh.
The host key at rest The state file's mode and directory belong to the application; the library writes 0600 and nothing wider.
The confirmation pairing.confirm(code) takes the code; the application decides who may call it and over which connection.
What a session may do The application reads device and message and decides what to run; the library has no command set.
Revocation await host.revoke(device) sends revoked, deletes the channels and unbinds the key.

The application also owns the QR display, the admin login that carries the payload and the code, and the choice of relays.

Worked example

The example is one program with an echo host and a guest in the same process. It starts both from temporary state, pairs them through a relay, sends one message from the guest, echoes it from the host and prints the reply. The person's step is played by the program: pairing.confirm(link.code) uses the code the guest shows, where a real application would read it from a person.

"""An echo host and a guest that pair through a relay and exchange one message.

Run it with a relay on a private address (PROTOCOL.md 8.1):

    RELAY_URL=ws://10.255.0.1:8010 RELAY_KEY=<project key> \
        uv run --project py python py/examples/echo.py
"""

import asyncio
import os
import tempfile

from relay import Guest, Host, Relay


async def main() -> None:
    relay = os.environ.get("RELAY_URL", "ws://10.255.0.1:8010")
    key = os.environ.get("RELAY_KEY", "example-key")
    with tempfile.TemporaryDirectory() as directory:
        host = await Host.open(
            f"{directory}/host", relays=[Relay(relay, key=key)]
        )
        guest = await Guest.open(f"{directory}/guest")

        pairing = await host.pair("echo")
        link = await guest.pair(pairing.payload)
        # A person reads link.code on the guest and types it into the host.
        device = await pairing.confirm(link.code)
        await link.bound()

        async def echo() -> None:
            async for sender, message in host:
                await sender.send(message)

        task = asyncio.create_task(echo())
        await link.send(b"hello from the guest")
        async for reply in link:
            print(f"guest received: {reply!r}")
            break

        await link.unpair()
        await guest.close()
        await host.close()
        task.cancel()


asyncio.run(main())

It runs against a relay on a private address, because a guest accepts ws:// only to one (section 8.1):

RELAY_URL=ws://10.255.0.1:8010 RELAY_KEY=<project key> \
    uv run --project py python py/examples/echo.py

The output is one line, guest received: b'hello from the guest'. The relay page covers the relay, its keys file and its checks.