Status: Draft / implementer-ready · Scope: wire protocol, observable behavior only Covers: LAN discovery + LAN signaling + the abstract signaling-channel interface + WebRTC session negotiation and data-channel framing.
What NetherNet is. NetherNet is a peer-to-peer transport for LAN and online multiplayer, layered on top of WebRTC (ICE + DTLS + SCTP data channels): a lightweight signaling plane carries SDP offers/answers and ICE candidates between two peers, after which an ordinary WebRTC peer connection carries application traffic over two SCTP data channels. On LAN, an encrypted UDP discovery plane both advertises hosts and tunnels the signaling messages, so no server is required.
- The key words MUST, MUST NOT, SHOULD, SHOULD NOT, and MAY are to be interpreted as in RFC 2119.
- All integers on the wire are little-endian, unless a field is defined as an ASCII text token (signaling) or is an opaque, application-defined payload.
- "Peer" identities are
NetworkIDs (§3). A "session" (a single connection attempt between two peers) is identified by a 64-bitConnectionId(a.k.a.RAWNETWORKID, §3.3). - Byte-offset tables list fields top-to-bottom in ascending memory/wire order.
u16/u32/u64denote little-endian unsigned integers of that width. - An implementation is conformant if it produces and accepts the byte sequences and message orderings defined here. Configurable parameters state their default values inline; non-normative notes are collected in §12.
Two peers establish a connection through three cooperating planes:
sequenceDiagram
participant D as Dialer (offerer)
participant L as Listener (answerer)
Note over D: create WebRTC offer + 2 data channels
D->>L: CONNECTREQUEST(connId, offerSDP)
Note over L: create answer
L->>D: CONNECTRESPONSE(connId, answerSDP)
D-->>L: CANDIDATEADD(connId, candidate)
L-->>D: CANDIDATEADD(connId, candidate)
Note over D,L: trickle ICE, both directions
Note over D,L: ICE connectivity checks then DTLS handshake then SCTP association
Note over D,L: ReliableDataChannel + UnreliableDataChannel open
Note over D,L: application packets flow (framed per section 6)
- Signaling plane (§5): short ASCII text messages (
CONNECTREQUEST,CONNECTRESPONSE,CANDIDATEADD,CONNECTERROR) exchanged through a pluggable signaling channel (§5.1). The LAN discovery-message transport (§7.4) is one such channel. - Transport plane (§6): a standard WebRTC
PeerConnectionwith two SCTP data channels. - LAN discovery plane (§7–§8): encrypted UDP broadcast for host advertisement and for carrying signaling messages between LAN peers.
Roles: the dialer is the WebRTC offerer (creates the data channels); the listener is the answerer. A peer MAY act in either role per session.
A NetworkID identifies a peer and is one of:
| Form | Underlying value | String form (toString) |
|---|---|---|
| P2P | u64 |
decimal digits of the u64, e.g. "1234567890" |
| Realms | 128-bit UUID | canonical UUID string |
| (unset) | none | empty / invalid |
A peer used over LAN signaling and discovery is always the P2P form (a u64). The
Realms form appears in online contexts. An implementation targeting LAN MAY support only the
P2P form.
Given a string, determine the form in this order:
- If it parses as an unsigned decimal integer with no surrounding whitespace → P2P (value = the integer).
- Else if it is a parseable UUID → Realms.
- Else → unset/invalid.
RAWNETWORKID is a u64. It is used as the session/connection id in signaling
messages (§5). The dialer MUST generate a fresh, effectively-random u64 ConnectionId
for each outgoing session and reuse it for every signaling message of that session; the
listener echoes it back. Implementations SHOULD use a cryptographically-unpredictable or
at least well-distributed source (e.g. 8 random bytes).
For logging, a P2P NetworkID MAY be rendered as a correlation id:
"<nethernet>WWWW-WWWW-WWWW-WWWW", where the four WWWW groups are the four 16-bit words of
the u64, most-significant word first, each formatted as 4 lowercase hex digits. This is
diagnostic only and never appears on the wire.
A single enum is used for session errors throughout (carried in CONNECTERROR and surfaced
to the application on session close). Values are stable:
| Value | Name | Value | Name | |
|---|---|---|---|---|
| 0 | None | 18 | SignalingUnknownError | |
| 1 | DestinationNotLoggedIn | 19 | SignalingUnicastMessageDeliveryFailed | |
| 2 | NegotiationTimeout | 20 | SignalingBroadcastDeliveryFailed | |
| 3 | WrongTransportVersion | 21 | SignalingMessageDeliveryFailed | |
| 4 | FailedToCreatePeerConnection | 22 | SignalingTurnAuthFailed | |
| 5 | ICE | 23 | SignalingFallbackToBestEffortDelivery | |
| 6 | ConnectRequest | 24 | NoSignalingChannel | |
| 7 | ConnectResponse | 25 | NotLoggedIn | |
| 8 | CandidateAdd | 26 | SignalingFailedToSend | |
| 9 | InactivityTimeout | 27 | RelayServerConfigurationResultFailure | |
| 10 | FailedToCreateOffer | 28 | RelayServerConfigurationResultParsingErrorNoUrls | |
| 11 | FailedToCreateAnswer | 29 | RelayServerConfigurationResultParsingErrorNoCreds | |
| 12 | FailedToSetLocalDescription | 30 | RelayServerConfigurationResultParsingErrorNoServers | |
| 13 | FailedToSetRemoteDescription | 31 | RelayServerConfigurationResultParsingErrorNoExpiration | |
| 14 | NegotiationTimeoutWaitingForResponse | 32 | DataChannelClosed | |
| 15 | NegotiationTimeoutWaitingForAccept | 33 | InternalErrorJsonSerialization | |
| 16 | IncomingConnectionIgnored | 34 | InvalidArgument | |
| 17 | SignalingParsingFailure | 35 | GenericFailure |
Send reliability (ESendType): Unreliable = 0, Reliable = 1.
Connection flags (EConnectionFlags, bitmask, configure ICE candidate filtering):
None=0, RelayOnly=1, NoTCPCandidates=2, NoLocalCandidates=4,
NoServerReflexiveCandidates=8, NoPeerReflexiveCandidates=16, NoRelayCandidates=32.
When RelayOnly is set, the peer MUST restrict ICE to relay (TURN) candidates only —
equivalently, set the WebRTC RTCConfiguration.type to IceTransportsType::kRelay (value
1).
A signaling channel delivers signaling messages between peers and MUST provide:
- send: deliver a message string from a local
NetworkIDto a remoteNetworkID. - receive: surface inbound
(senderNetworkID, messageString)events.
The LAN binding (§7.4) carries the exact same message strings inside encrypted discovery
Message packets. A peer reachable on LAN and with LAN signaling enabled SHOULD prefer
the LAN binding when the remote NetworkID is known to be on the LAN.
Every signaling message is an ASCII string of exactly three space-separated tokens:
<IDENTIFIER> <connectionId> <data>
- Token 0 =
IDENTIFIER(§5.3), an uppercase ASCII keyword. - Token 1 =
connectionId, the sessionu64rendered as decimal ASCII. - Token 2 =
data, message-specific. It MAY itself contain spaces and newlines (SDP blobs do). Therefore parsing MUST split on the first two spaces only and treat the remainder asdata.
A receiver MUST reject (ignore) a message whose token 0 is not a recognized identifier,
whose token 1 does not parse as a u64, or that has fewer than three tokens.
| Identifier | Direction | data (token 2) |
|---|---|---|
CONNECTREQUEST |
dialer → listener | SDP offer (full SDP text) |
CONNECTRESPONSE |
listener → dialer | SDP answer (full SDP text) |
CANDIDATEADD |
both directions | one ICE candidate (SDP candidate: attribute value) |
CONNECTERROR |
either | ESessionError value as decimal ASCII |
Serialization is the literal concatenation IDENTIFIER + " " + decimal(connectionId) + " " + data.
CONNECTREQUEST— carries the offerer's SDP. The receiver builds a WebRTC remote description of type offer fromdata.CONNECTRESPONSE— carries the answerer's SDP; remote description of type answer.CANDIDATEADD— carries a single trickle ICE candidate. The receiver constructs an ICE candidate fromdata(empty sdp-mid / mline-index 0) and adds it to the peer connection. Candidates received before the remote description is set MUST be buffered and applied immediately afterward.CONNECTERROR—datais the decimalESessionError. On receipt the session is closed with that error (see §5.5).
CANDIDATEADD data is the candidate as produced/consumed by WebRTC's standard candidate
serialization (candidate:<foundation> <component> <protocol> <priority> <ip> <port> typ <type> …). Implementations MUST emit candidates in this WebRTC-compatible form so a
standard WebRTC peer can parse them.
A CONNECTERROR with ESessionError = SignalingUnicastMessageDeliveryFailed (19) MUST
NOT close an incoming (listener-side) session — only outgoing (dialer-side) sessions act
on it. All other errors close the addressed session.
After signaling completes, the two peers run a standard WebRTC negotiation:
- ICE for connectivity (host / server-reflexive via STUN / relay via TURN, subject to
the connection flags of §4). STUN/TURN servers are supplied as a relay configuration
(
uri,username,password; up to 16 servers). - DTLS for the secure transport; role chosen by the SDP
setupattribute (actpass/active/passive). - SCTP carrying the data channels.
The SDP MUST advertise a=max-message-size:262144 (matching standard WebRTC) so that the
fragment size of §6.3 is permitted.
The dialer MUST create exactly two data channels in its offer; the listener receives them via the standard on-data-channel event (it MUST NOT create its own):
| Label | Reliability | WebRTC init |
|---|---|---|
ReliableDataChannel |
reliable, ordered | default (ordered, no maxRetransmits) |
UnreliableDataChannel |
unreliable, unordered | ordered = false, maxRetransmits = 0 |
Channel selection on receive is by label string (exact match): "ReliableDataChannel"
→ the reliable/ordered queue; anything else → the unreliable/unordered queue.
Application send reliability maps directly: ESendType::Reliable → ReliableDataChannel,
ESendType::Unreliable → UnreliableDataChannel.
Application packets are not sent raw — each data-channel message is a fragment:
fragment = [ header : u8 ][ payload : up to 262143 bytes ]
Define FRAGMENT_SIZE = 262143 (0x3FFFF). The header byte is the count of fragments
that follow this one (so the final fragment of a packet always has header == 0).
Sending an application packet of N bytes with reliability type:
- If
N ≤ FRAGMENT_SIZE(i.e.N < FRAGMENT_SIZE + 1): send a single fragment withheader = 0and the whole packet as payload, on the channel selected bytype. - Else if
N > 0x3FBFF01(≈ 66.98 MB): the packet is too large — reject (drop). - Else if
type != Reliable: a packet larger than one fragment MUST NOT be sent on the unreliable channel — drop it. - Else (reliable, multi-fragment): let
fragments = (N − 1) / FRAGMENT_SIZE + 1(1…256). Split the packet into consecutive chunks of up toFRAGMENT_SIZEbytes; send them in order, with theheaderof each fragment counting down fromfragments − 1to0. The last chunk carriesheader = 0.
Because the multi-fragment path is reliable-and-ordered only, the maximum fragment count is
256 (one u8 of countdown), bounding a single reliable packet at 0x3FBFF01 bytes.
Receiving:
- Reliable/ordered channel: maintain a per-session reassembly buffer. For each inbound
fragment, append its payload (bytes after the header) to the buffer; when a fragment with
header == 0arrives, the buffer now holds one complete application packet — deliver it and clear the buffer. (Correctness relies on SCTP's ordered, reliable delivery; no sequence numbers are carried beyond the countdown.) - Unreliable/unordered channel: every inbound message is a complete packet on its own —
strip the 1-byte header and deliver the remaining bytes. (Unreliable packets are never
fragmented, so the header is always
0.) - A zero-length data-channel message MUST be ignored.
Delivered packets are queued FIFO. peek returns the size of the head packet (or "empty");
read copies up to the caller's buffer size from the head packet — if the caller's buffer is
smaller than the packet, the copied prefix is removed and the remainder stays at the head for
the next read; otherwise the packet is dequeued.
LAN peers find each other and exchange signaling without any server, over UDP broadcast, with every datagram encrypted and authenticated (§8).
- Datagrams are sent to the IPv4 directed/limited broadcast address and to the IPv6
all-hosts link-local address, on a broadcast port. This SHOULD be configurable;
default value =
7551. Both peers MUST use the same port. - An Application Id (
u64) scopes discovery: it is the input to the encryption key (§8.1) and therefore acts as a shared secret / network selector. This SHOULD be configurable; default value =0xDEADBEEF(3735928559). Peers MUST share the same Application Id to interoperate. - A host that has broadcast discovery enabled re-broadcasts a discovery Request on a
fixed interval for each local id it is advertising. This SHOULD be configurable;
default value =
2000 ms. - The sealed datagram size means application payloads are bounded (§7.3): response payload ≤ 1148 bytes, message payload ≤ 1140 bytes.
All discovery packets begin with a fixed, packed (1-byte aligned, no implicit padding)
header. The base layout (DiscoveryPacket, 20 bytes):
| Offset | Size | Field | Notes |
|---|---|---|---|
| 0 | 2 | PacketLength (u16) |
total packet length = header size + payload size |
| 2 | 2 | PacketType (u16) |
0=Request, 1=Response, 2=Message |
| 4 | 8 | SenderId (u64) |
sender's P2P NetworkID |
| 12 | 8 | Pad |
reserved, MUST be zero on send, ignored on receive |
Note the
u64 SenderIdsits at offset 4 (unaligned) — the struct is packed; a natural layout would be wrong on the wire.
Request (type 0, 20 bytes, no payload) — DiscoveryRequestPacket is exactly the base
header with PacketLength = 20. Broadcast to solicit responses.
Response (type 1) — header DiscoveryResponsePacketHeader (24 bytes), then payload:
| Offset | Size | Field |
|---|---|---|
| 0..19 | 20 | base header (type = 1) |
| 20 | 4 | ApplicationDataLength (u32) |
| 24 | … | application data (ApplicationDataLength bytes, ≤ 1148) |
PacketLength = 24 + ApplicationDataLength. The application data is opaque to NetherNet —
it is the application's host advertisement (e.g. server name / MOTD / player counts), filled
and parsed by the application. A host produces it on demand when answering a Request.
Message (type 2) — header DiscoveryMessagePacketHeader (32 bytes), then payload:
| Offset | Size | Field |
|---|---|---|
| 0..19 | 20 | base header (type = 2) |
| 20 | 8 | RecipientId (u64) — addressee P2P NetworkID |
| 28 | 4 | MessageDataLength (u32) |
| 32 | … | message data (MessageDataLength bytes, ≤ 1140) |
PacketLength = 32 + MessageDataLength. The message data is a signaling message string
(§5.2) — this is the LAN signaling binding (§7.4).
A receiver MUST decrypt/verify (§8) every datagram, then dispatch by PacketType,
ignoring any packet whose SenderId equals its own id:
- Request received: if this peer is advertising a host, obtain the host advertisement
bytes from the application (≤ 1148), build a Response (
SenderId= local id), and send it unicast back to the requester's source address. - Response received: deliver
(SenderId, applicationData)to the application as a discovered host. - Message received with
RecipientId == local id: treatmessageDataas an inbound signaling message fromSenderId(feed it to §5 parsing, tagged as a LAN-sourced signal). Messages addressed to other recipients are ignored.
To send a signaling message over LAN, a peer builds a Message packet
(SenderId = self, RecipientId = target, messageData = the §5.2 string) and broadcasts
it (or unicasts to a known peer address). Peers learned via discovery MAY be cached with
their last-seen address for unicast delivery.
Every discovery datagram is sealed as an authenticated, encrypted envelope. The scheme is AES-256-ECB for confidentiality plus HMAC-SHA256 for integrity, under a key derived from the Application Id.
key = SHA256( appId_as_8_little_endian_bytes ) // 32 bytes
The same 32-byte key is used both as the AES-256 key and as the HMAC-SHA256 key.
envelope = [ MAC : 32 bytes ][ ciphertext : N bytes ]
└ HMAC-SHA256(key, plaintext)
└ AES-256-ECB(key, PKCS7-pad(plaintext))
where plaintext is the full discovery packet of §7 (header + payload, PacketLength
bytes). The whole envelope is the UDP datagram body.
- AES mode: ECB, PKCS#7 padding. (ECB is part of the scheme; identical plaintext blocks therefore produce identical ciphertext blocks — a known weakness, retained for interoperability.)
- MAC: HMAC-SHA256 computed over the plaintext (not the ciphertext), 32 bytes, prepended.
- Build the packet plaintext
P(§7). MAC = HMAC-SHA256(key, P)(32 bytes).C = AES-256-ECB-encrypt(key, PKCS7-pad(P)).- Transmit
MAC || C.
- If the datagram is shorter than 32 bytes, reject.
- Split into
receivedMAC(first 32 bytes) andC(remainder). P = unpad( AES-256-ECB-decrypt(key, C) ); if decryption or PKCS7 unpadding fails orCis not a whole number of blocks, reject.computedMAC = HMAC-SHA256(key, P).- Compare
receivedMACandcomputedMACin constant time; if they differ, reject. - Parse
Pas a discovery packet (§7).
A rejected datagram MUST be silently dropped.
stateDiagram-v2
[*] --> Idle
Idle --> OfferSent: open session<br/>create PeerConnection + Reliable & Unreliable channels<br/>CreateOffer, SetLocalDescription<br/>send CONNECTREQUEST(connId, localSDP)
OfferSent --> OfferSent: local ICE candidate, send CANDIDATEADD<br/>recv CANDIDATEADD, AddIceCandidate (buffer if no remote desc yet)
OfferSent --> Negotiating: recv CONNECTRESPONSE, SetRemoteDescription(answer)
OfferSent --> Closed: timeout, close(14 WaitingForResponse)
Negotiating --> Connected: ICE connected, notify session open
Negotiating --> Closed: ICE failed, close(5 ICE)
Negotiating --> Closed: recv CONNECTERROR, close(that error)
Connected --> Connected: data channels open, application traffic (section 6.3)
Closed --> [*]
stateDiagram-v2
[*] --> Idle
Idle --> AnswerSent: recv CONNECTREQUEST(connId, offerSDP)<br/>create PeerConnection (apply flags / RelayOnly per section 4)<br/>SetRemoteDescription(offer), CreateAnswer, SetLocalDescription<br/>send CONNECTRESPONSE(connId, localSDP)
AnswerSent --> AnswerSent: local ICE candidate, send CANDIDATEADD<br/>recv CANDIDATEADD, AddIceCandidate (buffer if needed)
AnswerSent --> Connected: ICE connected, data channels arrive (on-data-channel)
AnswerSent --> Closed: ICE failed, close(5 ICE)
AnswerSent --> Closed: recv CONNECTERROR, close (except error 19 on incoming, section 5.5)
Connected --> Connected: application traffic (section 6.3)
Closed --> [*]
- Trickle (default): the offer/answer is sent as soon as the local description is set;
ICE candidates are then streamed individually as
CANDIDATEADDmessages as they are gathered. - Non-trickle: the peer waits until ICE gathering is complete, so all candidates are
embedded in the SDP, then sends a single offer/answer and emits no
CANDIDATEADDmessages. A non-trickle peer interoperates with a trickle peer (it simply also accepts inboundCANDIDATEADD). Trickle on/off is a per-endpoint policy.
The negotiation timeout bounds how long a peer waits for the expected next message. This
SHOULD be configurable; default value = 10 s. On expiry the session closes with
NegotiationTimeoutWaitingForResponse (14) (waiting for an answer) or
NegotiationTimeoutWaitingForAccept (15). Long-idle established sessions close with
InactivityTimeout (9).
A peer closes a session by ceasing traffic and tearing down the peer connection; the local
application is notified with the terminating ESessionError. A peer MAY send
CONNECTERROR to inform the remote. A data channel closing unexpectedly maps to
DataChannelClosed (32).
| Constant | Value | § |
|---|---|---|
Fragment size (FRAGMENT_SIZE) |
262143 (0x3FFFF) bytes |
6.3 |
SDP max-message-size |
262144 |
6.1 |
| Max single reliable packet | 0x3FBFF01 bytes (≤ 256 fragments) |
6.3 |
| Max fragment count | 256 |
6.3 |
| Reliable channel label | "ReliableDataChannel" |
6.2 |
| Unreliable channel label | "UnreliableDataChannel" (ordered=false, maxRetransmits=0) |
6.2 |
| Discovery base header | 20 bytes, packed | 7.2 |
| Discovery Response header | 24 bytes; payload ≤ 1148 |
7.3 |
| Discovery Message header | 32 bytes; payload ≤ 1140 |
7.3 |
| Discovery packet types | Request 0, Response 1, Message 2 |
7.3 |
| Broadcast interval | default 2000 ms (configurable) |
7.1 |
| Broadcast port | default 7551 (u16, configurable) |
7.1 |
| Application Id | default 0xDEADBEEF (u64, configurable) |
7.1 |
| Negotiation timeout | default 10 s (configurable) |
9.4 |
RelayOnly → RTCConfiguration.type |
IceTransportsType::kRelay (1) |
4 |
| Discovery cipher | AES-256-ECB + PKCS7 | 8 |
| Discovery MAC | HMAC-SHA256 (32 bytes, prepended, over plaintext) | 8 |
| Discovery key | SHA256(LE u64 appId) |
8.1 |
| Signaling identifiers | CONNECTREQUEST / CONNECTRESPONSE / CANDIDATEADD / CONNECTERROR |
5.3 |
| Signaling delimiter | single space, first-two-spaces split | 5.2 |
An implementation SHOULD be validated by:
- Codec round-trips. Encode then decode each artifact and assert byte-equality: signaling strings (§5.2), discovery packets (§7), and sealed envelopes (§8). A sealed discovery packet from this implementation MUST decrypt/verify under another conformant implementation using the same Application Id.
- Differential testing against an independent implementation: feed identical inputs to both and compare outputs byte-for-byte — discovery builders, signaling serialization/parsing, and the AES+HMAC envelope.
- Live interop against a reference NetherNet peer: complete a LAN discovery exchange (Request → Response) and a full P2P session (offer/answer/candidates → open data channels → application traffic).
- Fragmentation fixtures: packets at sizes
1,FRAGMENT_SIZE,FRAGMENT_SIZE+1, and a large multi-fragment reliable packet, asserting fragment headers count down to0and reassembly reproduces the input.
CANDIDATEADDcandidate text. Thedatafield is produced/consumed by WebRTC's standard ICE-candidate serialization (thecandidate:SDP attribute value), not a NetherNet-specific format — an implementation interoperates by emitting standard WebRTC candidate lines.- LAN first-discovery probe (optional). When a peer first records a newly-seen peer in
its discovery table — i.e. on any non-
Requestpacket (§7.3) whoseSenderIdis non-zero and not already known — a reference peer sends that peer a Message packet (§7.3) withSenderId = 0and an emptymessageData. Because the payload is an empty string it is ignored by signaling parsing (§5.2), and the zeroSenderIdstops the recipient from treating it as a new discovery (so no probe is echoed back) — it is effectively a presence/keepalive ping toward the discovered peer's address. Emitting it is OPTIONAL: a peer interoperates without it and receivers already drop it, but an implementation comparing wire traffic against a reference peer will observe these empty Messages.