Rust Wiki

RustRelay

RustRelay

RustRelay is built into Rust. When enabled, the Rust server forwards its network packets to an HTTP endpoint you run, called the recipient server. This page covers both halves: configuring the relay on the Rust server, then implementing a recipient server to receive it.

Much of the RustRelay feature is subject to change, use with caution

Rust Server Configuration

Set these server convars, then run relay.restart to apply. Config is saved to cfg/relay_cfg.json. Changing the URL, token, encryption, or fake-player convar stops the relay, so follow it with relay.restart.

Convar Default Description
relay.cfg_server_url "" Base URL of your recipient server, e.g. https://my-relay.example.com.
relay.cfg_server_auth_token null Bearer token sent with every request.
relay.cfg_encryptpackets false Encrypt every packet, not just console packets (see Encryption).
relay.cfg_fakeplayer false Relay only packets destined for the fake spectator connection.
relay.cfg_filtermode 0 RPC filter: 0 ignore, 1 relay all, 2 whitelist only.
relay.cfg_sendvoicedata false Relay voice packets (off by default for legal reasons).
relay.cfg_sendconsoledata true Relay console/chat packets (always encrypted, needs key exchange).

Commands:

Convar Action
relay.restart Start, or restart to apply config changes.
relay.shutdown Stop the relay.
relay.status Print a status report.
relay.clear_queue Clear the send queue.
relay.cfg_reload Reload relay_cfg.json from disk (stops the relay).
relay.cfg_save Save the current config to disk.
relay.rpc_whitelist_add <names…> Add RPC names to the whitelist.
relay.rpc_whitelist_remove <names…> Remove RPC names from the whitelist.
relay.rpc_whitelist_rebuild Rebuild the whitelist id lookup.

FakePlayer (recommended)

A Rust server sends its packets per connection: every online player gets their own copy of each entity update and position. With relay.cfg_fakeplayer off, the relay forwards all of that, so you receive one duplicate stream per connected player and your volume scales with the player count.

With relay.cfg_fakeplayer on, Rust registers a single fake spectator connection and the relay forwards only the packets destined for it. You get one stream instead of N copies, which is much less data to handle.

It defaults to disabled, so you have to turn it on deliberately. Do that unless you have a specific reason to capture the raw per-connection traffic.


Recipient Server Outline

This is the contract your HTTP endpoint has to implement. All integers are little-endian.

Authentication

Rust sends AuthToken as a bearer token. Validate it however you like; the protocol just carries the string.

  • HTTP: Authorization: Bearer <token>
  • /ws/ingest handshake: also as the access_token query parameter.

Reject anything without a valid token.

Context on every request

  • Wipe id: X-Wipe-Id header or wipeId query param. Identifies one session; keep wipes separate.
  • Server time: X-Server-Time header, in 100-nanosecond ticks since the Unix epoch. Divide by 10,000 for milliseconds.

Endpoints

Method Path Body Purpose
WS /ws/ingest?wipeId=<id> binary, one packet per message live packet stream
POST /api/Packet octet-stream, length-prefixed packets packet batch
POST /api/Snapshot binary base world snapshot
POST /api/MapSnapshot multipart (map required, dat optional) map files
POST /api/Manifest JSON prefab metadata
POST /api/StringPool JSON { "<hash>": "<string>" } pooled-string lookup
POST /api/Auth/exchangeKey JSON (see Encryption) key exchange

Return 200 OK on success. All require a valid token.

Packet ingest

  • /ws/ingest: each binary WebSocket message is one whole packet (reassemble frames until EndOfMessage).

Session marker. Before real packets, Rust sends a 12-byte marker:

[ int32 magic = 0x53545252 ][ int64 serverTime ]

0x53545252 is RRTS (RustRelayTimeStamp). A packet is a marker only if it's exactly 12 bytes and starts with that magic number.

A marker isn't sent once and forgotten, nor is it sent on every frame. Rust sends one whenever the server time changes from the last marker, immediately before that frame's packets. In practice that means a fresh marker precedes each new frame that actually has packets to relay (frames with nothing to send produce no marker). Treat each marker as the timestamp for the packets that follow it, and drop ordinary packets until you've seen the first one.

Packet format

Each packet starts with a type byte:

  • <= 140 → RakNet internals, ignore (these aren't sent anyway).
  • otherwise subtract 140 to get the message type.
Value Name Body (after type byte)
5 Entities [uint32 reserved][bytes: protobuf Entity]
6 EntityDestroy [uint64 id][byte mode]
9 RPCMessage [uint64 id][uint32 rpcId]
10 EntityPosition [uint64 id][float x,y,z][float rotX,rotY,rotZ][float time][uint64 parent?]
11 ConsoleMessage [uint32 len][utf8] (encrypted)
12 ConsoleCommand [uint32 len][utf8] (encrypted)
13 Effect [bytes: protobuf EffectData]

Other values (1–4, 7–8, 14–28) are valid but carry nothing you need to handle. parent on EntityPosition is present only if bytes remain. Drop any packet too short for its layout.

The Entities and Effect payloads are Rust protobufs. The Rust.Polyfills NuGet package provides the classes (ProtoBuf.Entity, EffectData), so ProtoBuf.Entity.Deserialize(bytes) gives a typed object. It does not provide the message-type enum above; declare that yourself.

Encryption

ConsoleMessage and ConsoleCommand are always encrypted (skip the key exchange and just forward them undecoded if you don't need them). With relay.cfg_encryptpackets on, every packet is encrypted the same way.

Key exchange is X25519 ECDH at POST /api/Auth/exchangeKey:

{ "WipeId": "...", "ClientPublicKey": "rk1..." } // -> { "ServerPublicKey": "rk1..." }

Keys are Bech32-encoded with the rk prefix. Derive the AES-256 key as SHA256(sharedSecret). Encrypted packet layout (AES-256-GCM), type byte in the clear:

[ byte type ][ 12-byte nonce ][ ciphertext ][ 16-byte tag ]

Deserializing packets (.NET)

Add the package:

<PackageReference Include="Rust.Polyfills" Version="0.0.2" />

Strip the type byte, then either deserialize the protobuf payload or read the fixed fields:

using System.Buffers.Binary; using ProtoBuf; // Rust.Polyfills: Entity, EffectData, ... void Handle(ReadOnlySpan<byte> packet) { if (packet.Length == 0) return; byte raw = packet[0]; if (raw <= 140) return; // RakNet, ignore var type = raw - 140; var body = packet[1..]; switch (type) { case 5: // Entities: skip the leading uint32, rest is a protobuf Entity Entity entity = Entity.Deserialize(body[sizeof(uint)..].ToArray()); ulong id = entity.baseNetworkable.uid.Value; break; case 13: // Effect EffectData effect = EffectData.Deserialize(body.ToArray()); break; case 10: // EntityPosition: fixed layout, read by hand ulong uid = BinaryPrimitives.ReadUInt64LittleEndian(body); var f = body[sizeof(ulong)..]; float x = BinaryPrimitives.ReadSingleLittleEndian(f); float y = BinaryPrimitives.ReadSingleLittleEndian(f[4..]); float z = BinaryPrimitives.ReadSingleLittleEndian(f[8..]); // rotX/Y/Z at 12/16/20, time at 24, optional parent uint64 at 28 break; case 6: // EntityDestroy ulong destroyed = BinaryPrimitives.ReadUInt64LittleEndian(body); break; } }

The Entity object exposes the networked sub-objects: basePlayer, buildingBlock, baseCombat, metabolism, flags, and the rest.