relaynet

relaynet documentation

relaynet gives code in a web page, or any program that can open a WebSocket, raw IPv4 networking. The client sends Ethernet frames over a WebSocket; the relay answers DHCP and ARP, forwards TCP and UDP to the public internet, and sends the replies back as Ethernet frames. The client brings its own TCP/IP stack: a virtual machine (v86, Linux or Zephyr compiled to WebAssembly), or a user-space stack. Sessions of one account also share a private network.

This page is also available as plain Markdown at /docs.md, and an index for AI agents is at /llms.txt.

At a glance

Endpointwss://relay.relaynet.network/
Subprotocolrelay.v1
AuthenticationAPI token in a second subprotocol entry, relay.token.<token>, or Authorization: Bearer <token> outside browsers
Message formatEach binary WebSocket message is exactly one Ethernet II frame, without preamble or checksum (FCS)
NetworkIPv4 only: 10.0.2.0/24, gateway 10.0.2.2, addresses from DHCP
AccessInvite-only beta; sign in at relaynet.network and redeem an invite

What it is for

Browsers can open HTTP, WebSocket, WebTransport and WebRTC connections, but not plain TCP or UDP sockets. relaynet fills that gap for protocols below HTTP:

When to use something else

relaynet adds a hop through California and requires an IP stack in the client. Prefer another tool when one fits:

If you needUse insteadWhy
A byte stream to a server you runA plain WebSocket or WebTransport endpointNo IP stack in the client, one hop fewer
Browser-to-browser data, audio or videoWebRTCPeer to peer with NAT traversal and congestion control built for media; lower latency
HTTP APIsfetchAlready in the browser
Your home or office LAN, or inbound connections from the internetA VPN or tunnel (WireGuard, Tailscale, Cloudflare Tunnel)relaynet has no inbound ports and blocks private addresses
Bulk transfer or low latency far from CaliforniaA server near your usersSessions are capped at 1 MiB/s, and each TCP connection carries about 64 KiB per round trip
IPv6, ICMP to the internet, multicast, port 25Not available hererelaynet is IPv4 TCP and UDP only

Bringing your own TCP/IP stack

Every client needs something that turns sockets into Ethernet frames:

Joining your own machines

All sessions of one account share 10.0.2.0/24 and can reach each other by address, while other accounts' sessions are invisible. To put a regular computer on that network, run relaynet-bridge on it. It connects out to the relay with your token, so no inbound port is opened.

export RELAYNET_TOKEN=rr_...
# Let your sessions reach this machine's USB/IP server and SSH (no root):
relaynet-bridge -expose 3240 -expose 2222=127.0.0.1:22
# Reach port 3240 of the session at 10.0.2.15 as localhost:3240 (no root):
relaynet-bridge -forward 3240=10.0.2.15:3240
# Or give the machine an interface with the session address (root; Linux, macOS):
sudo -E relaynet-bridge -tun

The bridge logs the address it joined with, such as 10.0.2.17. -tun adds a route for 10.0.2.0/24 only, so the machine's default route stays on its own network. -ip 10.0.2.200 asks for a fixed address. After a reconnect the bridge reclaims its address unless another session took it.

An account can hold 4 sessions at once, and the bridge uses one, so this suits a few devices, not a fleet. Anything else that sends and receives Ethernet frames over the WebSocket works too, such as a TAP interface bridged by your own program.

Quick start

  1. Sign in at relaynet.network and redeem your invite code.
  2. In the dashboard, under API tokens, create a token. It looks like rr_<prefix>_<secret> and is shown once; copy it.
  3. Open a WebSocket with the token and connect it to your VM's network card or your TCP/IP stack:
const ws = new WebSocket("wss://relay.relaynet.network/", [
  "relay.v1",
  "relay.token." + token,
]);
ws.binaryType = "arraybuffer";
ws.onmessage = (event) => vm.receiveFrame(new Uint8Array(event.data)); // relay -> VM
vm.onFrame = (frame) => ws.send(frame);                                // VM -> relay
  1. Boot the VM and let it run DHCP as usual. It gets an address such as 10.0.2.15, a default route through 10.0.2.2 and a DNS server, and can then reach the internet.

Connecting

From a browser page

Browsers cannot set headers on a WebSocket, so the token goes in the subprotocol list next to relay.v1, as in the quick start. The relay answers with Sec-WebSocket-Protocol: relay.v1; check ws.protocol === "relay.v1" after open.

Any web page may open a session; the token is what authorizes it. The account API (/api/me) only answers browser requests from the relaynet dashboard, so manage tokens there.

v86

v86 opens the relay WebSocket itself and does not pass subprotocols, so wrap WebSocket before creating the emulator. The wrapper adds the token only to connections to the relay:

function useRelayToken(relayUrl, token) {
  const Native = window.WebSocket;
  const relayHost = new URL(relayUrl).host;
  window.WebSocket = function (url, protocols) {
    if (new URL(String(url), location.href).host !== relayHost) {
      return protocols === undefined ? new Native(url) : new Native(url, protocols);
    }
    return new Native(url, ["relay.v1", "relay.token." + token]);
  };
  window.WebSocket.prototype = Native.prototype;
  for (const key of ["CONNECTING", "OPEN", "CLOSING", "CLOSED"]) window.WebSocket[key] = Native[key];
}

useRelayToken("wss://relay.relaynet.network/", token);
const emulator = new V86({
  // ...
  net_device: { type: "virtio", relay_url: "wss://relay.relaynet.network/" },
});

From Node.js, Python or other programs

Programs that can set headers may send the token as a bearer token instead of a subprotocol entry. They still request relay.v1.

// Node 22+ has WebSocket built in; with the "ws" package:
import WebSocket from "ws";
const ws = new WebSocket("wss://relay.relaynet.network/", ["relay.v1"], {
  headers: { Authorization: `Bearer ${process.env.RELAY_TOKEN}` },
});

API tokens

curl -s -H "Authorization: Bearer $RELAY_TOKEN" https://relay.relaynet.network/api/me

Protocol

relaynet speaks the rootlessRelay WebSocket protocol, version relay.v1.

Handshake

GET / HTTP/1.1
Host: relay.relaynet.network
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Version: 13
Sec-WebSocket-Key: <random>
Sec-WebSocket-Protocol: relay.v1, relay.token.rr_abcd1234_...
Origin: https://your-site.example

On success the relay answers 101 Switching Protocols with Sec-WebSocket-Protocol: relay.v1. On failure it answers with an HTTP status and a JSON body such as {"error":"invalid-token"} and closes the connection (see Errors). Any path works; use /.

Messages

DirectionTypeContent
Client to relaybinaryOne Ethernet II frame from the VM: destination MAC, source MAC, EtherType, payload. No preamble, no FCS. At most 65,549 bytes.
Relay to clientbinaryOne Ethernet II frame for the VM, same format.
Client to relaytextping:<anything>: the relay answers pong:<anything>. Optional; useful for measuring latency. Other text messages are ignored.

There is no other framing, no handshake inside the WebSocket and no per-message metadata. Messages are delivered in order. Frames are never merged or split: one VM frame is one WebSocket message.

Virtual network

ItemValue
Subnet10.0.2.0/24 (netmask 255.255.255.0)
Gateway and DHCP server10.0.2.2, MAC 52:54:00:12:34:56
VM addresses10.0.2.15 to 10.0.2.254, one per session, from DHCP
DNS server offered8.8.8.8 (the VM may use any public resolver)
MTU1500 (DHCP option 26)
Lease time3600 s; leases end when the session closes
ProtocolsARP and IPv4. IPv6 and other EtherTypes are dropped.

DHCP

Send a normal DHCPDISCOVER (UDP 0.0.0.0:68 to 255.255.255.255:67); the relay answers with an offer and acknowledges the request. Replies carry: message type (53), server identifier (54) 10.0.2.2, lease time (51) 3600, subnet mask (1), router (3) 10.0.2.2, DNS server (6) 8.8.8.8 and interface MTU (26) 1500.

A VM may also skip DHCP and use a static address in the range 10.0.2.15–10.0.2.254; the first source address it sends from becomes its address for the session, if no other session holds it.

What the relay does with each packet

Address and identity rules

Outbound rules

Keepalive and timeouts

Errors

Before the handshake completes, the relay answers with an HTTP status and a JSON error. Browsers hide these and report a failed connection (close code 1006); use the curl check under API tokens, or a non-browser client, to see the reason.

StatuserrorMeaning
401authentication-requiredNo token was sent.
401invalid-tokenThe token is wrong or was deleted.
401token-revoked, token-expiredCreate a new token.
403account-pendingThe account has not redeemed an invite.
403account-suspendedThe account is suspended; contact support.
429session limit reached for this accountClose another session first.
429connection limit reached for this addressToo many sessions from one client IP.
429quota exceededMonthly data quota used up.
503address pool exhausted, server shutting downThe relay is full or restarting; retry with backoff.

After the handshake, the relay closes sessions with these codes:

CodeReasonMeaning
1000idle timeout, session expiredNormal end; reconnect if needed.
1001server shutting downThe relay is restarting (deploys). Reconnect after a few seconds.
1008token revokedThe token was revoked.
1008account suspended, account deletedThe account can no longer connect.
1008quota exceededThe monthly quota ran out during the session.
1008terminated by operatorClosed by the relaynet operator.

Reconnect with exponential backoff (for example 1, 2, 4 … 60 seconds), and do not retry on 401 or 403.

Minimal client example

A complete Node 22+ program that connects, gets a DHCP lease and prints it. It builds the frames by hand, which shows everything the protocol involves:

const MAC = Buffer.from([0x02, 0, 0, 0, 0, 1]);
const ws = new WebSocket("wss://relay.relaynet.network/", ["relay.v1", "relay.token." + process.env.RELAY_TOKEN]);
ws.binaryType = "arraybuffer";

function sum16(buf) {
  let s = 0;
  for (let i = 0; i < buf.length; i += 2) s += buf.readUInt16BE(i);
  while (s >> 16) s = (s & 0xffff) + (s >> 16);
  return ~s & 0xffff;
}

ws.onopen = () => {
  const dhcp = Buffer.alloc(244);                      // BOOTP request
  dhcp.set([1, 1, 6]);                                 // op, htype, hlen
  dhcp.writeUInt32BE(0x12345678, 4);                   // transaction id
  MAC.copy(dhcp, 28);
  dhcp.writeUInt32BE(0x63825363, 236);                 // magic cookie
  dhcp.set([53, 1, 1, 255], 240);                      // DHCPDISCOVER, end
  const udp = Buffer.alloc(8);
  udp.writeUInt16BE(68, 0); udp.writeUInt16BE(67, 2); udp.writeUInt16BE(8 + dhcp.length, 4);
  const ip = Buffer.alloc(20);
  ip.set([0x45]); ip.writeUInt16BE(20 + 8 + dhcp.length, 2); ip[8] = 64; ip[9] = 17;
  ip.set([255, 255, 255, 255], 16);                    // from 0.0.0.0 to broadcast
  ip.writeUInt16BE(sum16(ip), 10);
  const eth = Buffer.concat([Buffer.alloc(6, 0xff), MAC, Buffer.from([0x08, 0x00])]);
  ws.send(Buffer.concat([eth, ip, udp, dhcp]));
};

ws.onmessage = ({ data }) => {
  const f = Buffer.from(data);
  if (f.readUInt16BE(12) === 0x0800 && f[23] === 17 && f.readUInt16BE(34) === 67) {
    console.log("leased", [...f.subarray(58, 62)].join("."), "from", [...f.subarray(26, 30)].join("."));
    ws.close();
  }
};

Limits

Beta accounts have these limits. They are per account unless noted.

LimitValue
Concurrent sessions4 per account, 4 per client IP address
Bandwidth1 MiB/s download and 1 MiB/s upload, per session
Data quota20 GiB per calendar month (UTC), both directions combined
Open TCP connections256 per session
New TCP connections50 per second per session
UDP flows256 per session
DNS queries100 per second per session
Session length12 hours; 30 minutes idle
Blocked ports25

Traffic over a limit is slowed down or dropped rather than causing an error: TCP slows down, excess SYNs and UDP packets are dropped, and the VM's own retransmissions recover. Use GET /api/me to see your limits and GET /api/me/usage to see this month's usage.

Account API

The account API is served by the relay at https://relay.relaynet.network, uses Authorization: Bearer <token> with an API token or a dashboard sign-in, and returns JSON. Everything is scoped to your own account.

Method and pathPurpose
GET /api/meAccount, status and limits
GET /api/me/usageData used this month and the quota
GET /api/me/sessionsOpen sessions: VM address, bytes, connections, start time
DELETE /api/me/sessions/<sessionId>Close one of your sessions
GET /api/me/tokensToken list (names, prefixes, dates; never the secret)
POST /api/me/tokensCreate a token; body {"name": "...", "expiresInDays": 90}. Dashboard sign-in only.
DELETE /api/me/tokens/<id>Revoke a token. Dashboard sign-in only.
POST /api/me/redeemRedeem an invite; body {"code": "RLY-XXXX-XXXX-XXXX"}
DELETE /api/meDelete the account; body {"confirm": "<username>"}. Dashboard sign-in only.

Troubleshooting

Notes for AI agents

Support and policies

relaynet runs rootlessRelay, an open-source user-space network relay, in its hosted mode.