Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 100 additions & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
# Architecture

NetherNet transports application packets over WebRTC data channels. Minecraft
Bedrock uses it, but this library does not interpret Minecraft game packets or
log players into Microsoft accounts. Its public API deals with peers, signalling,
connections, and byte buffers.

## Connection flow

```text
Discovery Signalling WebRTC Application
Find a peer → Exchange SDP offers, → ICE selects a route; → Send/receive
and its metadata answers and candidates establish DTLS/SCTP NetherNet packets
```

Discovery answers “which server can I contact?” Signalling exchanges the information
needed to establish a connection. WebRTC does not prescribe how applications deliver
those signalling messages; NetherNet supports several ways to do so.

An SDP offer/answer describes connection settings, ICE candidates (possible network
addresses), and DTLS certificate fingerprints. ICE tests candidate routes, potentially
using STUN or TURN when configured. DTLS secures the connection; SCTP carries WebRTC
data channels. WebRTC remains in the data path after signalling completes—it is not
just a handshake followed by plain application UDP.

NetherNet uses client/server roles: the client initiates an offer and the server
answers. A **network ID** is a 64-bit NetherNet peer identifier, not a Minecraft player
ID or an IP address. A **connection ID** identifies a negotiation/connection attempt.
An HTTP client can use `0n` as its remote network ID because the URL identifies the
server instead of LAN discovery.

## Source map

| File or directory | Responsibility |
| --- | --- |
| `src/client.js` | Client state, LAN socket/discovery lifecycle, outgoing WebRTC negotiations, and signalling dispatch. |
| `src/server.js` | Listening LAN socket, advertisements, incoming negotiations, and accepted connections. |
| `src/connection.js` | Data-channel handling, NetherNet packet fragmentation/reassembly, send queues, and connection teardown. The client/server creates the WebRTC peer connection and passes it here. |
| `src/signalling/messages.js` | Shared signalling message types, error codes, and text encoding. |
| `src/signalling/lan.js` | LAN discovery/signalling packet construction, encryption/checksums, and decoding. Socket ownership and peer lookup remain in the client/server. |
| `src/signalling/http.js` | HTTP metadata discovery and complete offer/answer exchange, including server trust policy. |
| `src/signalling/sdpIdentity.js` | Construct and verify NetherNet SDP identity assertions and their signed DTLS fingerprints. This is not account authentication. |
| `src/webrtc.js` | Select the WebRTC implementation: Werift or the optional native implementation. |
| `src/werift/` | Adapt Werift to the WebRTC interfaces the library uses, including TURN redirect handling. |
| `src/datatypes/`, `src/transforms/` | Binary discovery packet schema, ProtoDef types, and serializers. |
| `src/crypto.js` | LAN discovery packet encryption/checksum primitives, distinct from WebRTC's DTLS encryption. |
| `src/util.js` | Network ID generation and ICE server configuration helpers. |

The signalling modules are grouped by responsibility, not subclasses of a common
transport framework. The two built-in modes have different exchange patterns.

## Signalling modes

**LAN:** UDP discovery on port 7551 finds peers and their advertisements. Address
lookup then allows signalling messages to reach the selected network ID. Offers,
answers, and additional ICE candidates travel in discovery-message packets. Candidate
messages can arrive incrementally (“trickle ICE”). Advertisement bytes are opaque to
this library; a consumer such as bedrock-protocol interprets their Minecraft fields.

**HTTP:** `pingHttp()` retrieves `/v1/join` metadata, which may be absent even for a
successful response. A client configured with `http` gathers all ICE candidates,
signs the complete offer, and POSTs it to `/v1/join/{clientNetworkId}`. The response
contains the answer. This path does not send LAN discovery or trickle messages.
HTTP hosting is not implemented by `Server`.

**External services:** An application can supply `client.signalHandler` for outgoing
messages and deliver incoming messages through `client.handleSignal()`. Xbox/Minecraft
services can carry those messages, but their authentication, sessions, and service
clients belong to the application/services layer, not this package.

## Identity and trust

An application's authentication layer supplies the player's token and private key.
The library attaches an `a=identity` assertion binding that identity to the offer's
DTLS fingerprints. It does not obtain tokens or decide which Minecraft accounts may
join a world.

On the HTTP client path, the server answer's self-signed operator token and fingerprint
signature are verified before applying the SDP. These signatures establish possession
of a key; they do not independently establish that it is the intended server. HTTPS
provides endpoint trust through normal TLS certificate validation. Plain HTTP requires
a pinned operator key or explicit application approval. A configured pin is enforced
on HTTPS too. The application owns approval prompts and persistence.

This describes HTTP server-answer verification, not a general authentication guarantee
for every signalling mode. The LAN/server paths do not gain HTTP's trust policy merely
because they share message definitions.

## Lifecycle and boundaries

`Client` and `Server` own sockets, negotiation timers, and connections. HTTP signalling
uses the client's existing offer cancellation signal and response deadline; it does
not introduce a second lifecycle manager. Closing a client cancels pending work, and
an approval that resolves after cancellation cannot apply an answer. An application
approval callback itself must manage any UI/resources it creates.

Keep SDP, ICE, and signalling transport details here. Keep Minecraft version selection,
game packet serialization, account authentication, Xbox sessions, and persistent trust
stores in consumers. The README documents the public API; this file explains ownership
so changes can go in the appropriate module without adding unnecessary abstractions.
46 changes: 46 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@

A Node.js 24+ implementation of the NetherNet protocol.

See [Architecture](ARCHITECTURE.md) for the connection flow, signalling modes,
and a guide to the source code.

## Install

```sh
Expand Down Expand Up @@ -127,3 +130,46 @@ still require testing against a real environment before release.
Set `TEST_NATIVE_WEBRTC=1` to also run the Werift/native interoperability matrix
(requires the development dependencies). CI tests both backends on Linux, macOS,
and Windows. Normal consumers do not install the native optional peer automatically.

### Direct HTTP signalling

For a dedicated server exposing `/v1/join`, configure the client with an HTTP(S)
origin. HTTP does not need the server's LAN network ID; pass `0n` instead.

```js
const { Client, pingHttp } = require('nethernet')

const url = 'http://localhost:19132'
const advertisement = await pingHttp(url, { timeout: 1000 })
// Metadata (including protocol/version) is optional; raw is the original body.
console.log(advertisement)

const client = new Client(0n, undefined, {
http: { url, serverKey: process.env.BDS_SERVER_KEY },
identity: { privateKey, token }, // credentials supplied by your authentication layer
responseTimeoutMs: 15000
})
client.on('error', console.error)
client.on('disconnect', (id, reason) => console.log(reason))
client.connect()
```

The client gathers the complete ICE offer, signs its identity assertion, posts it,
and verifies the server's identity token and signed DTLS fingerprints before using
the answer. HTTP clients do not send LAN discovery or trickle-candidate messages.
`responseTimeoutMs` covers offer/answer negotiation, including ICE gathering and key
approval; `close()` cancels pending work. An exhausted negotiation deadline emits
`disconnect`, as for LAN negotiation. HTTP response, identity and trust errors emit
`error` and close the client.

HTTPS uses Node's normal certificate verification. Plain HTTP additionally requires
`serverKey`, or an `http.onServerKey(fingerprint, origin)` callback returning `true`
(or a promise of `true`) to approve an unknown key. The fingerprint is `sha256:` plus
the lowercase SHA-256 hex digest of the operator public key's DER SPKI encoding.
A configured pin is enforced on HTTPS too, and a mismatch never calls the approval
callback. Identity signatures are verified before asking for approval. Applications
own user prompts and pin persistence; no unknown key is automatically trusted.

`pingHttp()` only retrieves metadata; operator verification happens during connection
setup. Redirects are rejected for both requests. Authentication, Minecraft version
selection and HTTP server hosting are outside this API.
25 changes: 25 additions & 0 deletions index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,14 +108,39 @@ declare module 'nethernet' {
domain?: string;
}

export interface HttpOptions {
/** HTTP(S) origin, without a path, query, or credentials. */
url: string;
/** sha256:<lowercase hex> of the operator public key's DER SPKI. */
serverKey?: string;
/** Explicit approval for an unknown plain-HTTP key, after signature verification. */
onServerKey?: (fingerprint: string, origin: string) => boolean | Promise<boolean>;
}

export interface HttpAdvertisement {
name?: string;
protocol?: number;
version?: string;
level?: string;
players?: number;
maxPlayers?: number;
gameType?: number;
raw: string;
}

export function pingHttp(origin: string, options?: { timeout?: number, signal?: AbortSignal }): Promise<HttpAdvertisement>;

export interface ClientOptions {
/** Direct HTTP signalling instead of LAN discovery. Requires identity; use 0n as the remote ID. */
http?: HttpOptions;
/** Default: werift. auto prefers the optional native backend when loadable. */
webrtcBackend?: WebRTCBackend;
identity?: Identity;
networkId?: bigint;
connectionId?: bigint;
credentials?: (string | IceServer)[];
iceServers?: (string | IceServer)[];
/** Offer/answer deadline, including HTTP ICE gathering and key approval (default: 15000). */
responseTimeoutMs?: number;
inactivityTimeoutMs?: number;
}
Expand Down
4 changes: 3 additions & 1 deletion index.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
const { pingHttp } = require('./src/signalling/http')
const { Client } = require('./src/client')
const { Server } = require('./src/server')
const { ErrorCode, SignalStructure } = require('./src/signalling')
const { ErrorCode, SignalStructure } = require('./src/signalling/messages')

const SignalType = {
ConnectRequest: 'CONNECTREQUEST',
Expand All @@ -11,6 +12,7 @@ const SignalType = {

module.exports = {
Client,
pingHttp,
ErrorCode,
Server,
SignalType,
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
"typescript": "^7.0.2"
},
"dependencies": {
"jsonwebtoken": "^9.0.3",
"protodef": "^1.19.0",
"werift": "0.24.4"
},
Expand Down
35 changes: 26 additions & 9 deletions src/client.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
const { attachIdentity } = require('./identity')
const { exchangeOffer, signallingUrl } = require('./signalling/http')
const { attachIdentity } = require('./signalling/sdpIdentity')
const dgram = require('node:dgram')
const { EventEmitter } = require('node:events')
const { Connection } = require('./connection')
const { ErrorCode, SignalType, SignalStructure } = require('./signalling')
const { ErrorCode, SignalType, SignalStructure } = require('./signalling/messages')

const { getRandomUint64, normalizeIceServers, validateIceServers, createPacketData, prepareSecurePacket, processSecurePacket } = require('./util')
const { getRandomUint64, normalizeIceServers, validateIceServers } = require('./util')
const { createPacketData, prepareSecurePacket, processSecurePacket } = require('./signalling/lan')
const { getWebRTC } = require('./webrtc')
const { PACKET_TYPE, createSerializer, createDeserializer } = require('./transforms/serializer')

Expand All @@ -20,6 +22,7 @@ class Client extends EventEmitter {
constructor (networkId, broadcastAddress = BROADCAST_ADDRESS, options = {}) {
super()

this.http = options.http && { ...options.http, url: signallingUrl(options.http.url) }
this.webrtc = getWebRTC(options.webrtcBackend)

this.serverNetworkId = networkId
Expand Down Expand Up @@ -59,15 +62,14 @@ class Client extends EventEmitter {

this._signalHandler = this.sendDiscoveryMessage.bind(this)

this.sendDiscoveryRequest()

this.pingInterval = setInterval(() => {
if (!this.http) {
this.sendDiscoveryRequest()
}, 2000)
this.pingInterval = setInterval(() => this.sendDiscoveryRequest(), 2000)
}

this._hasEmittedConnected = false
this._pendingConnect = false
this._externalSignaling = false
this._externalSignaling = Boolean(this.http)
this._negotiationTimeout = null
}

Expand Down Expand Up @@ -241,7 +243,7 @@ class Client extends EventEmitter {
const isCurrent = () => !this._closed && this.connection === connection && !connection.closed

rtcConnection.onicecandidate = (event) => {
if (event.candidate && isCurrent()) {
if (!this.http && event.candidate && isCurrent()) {
debug('Sending CandidateAdd to networkId:', this.serverNetworkId)
const signal = new SignalStructure(SignalType.CandidateAdd, this.connectionId, event.candidate.candidate, this.serverNetworkId)

Expand Down Expand Up @@ -304,6 +306,21 @@ class Client extends EventEmitter {
return
}

if (this.http) {
try {
const sdp = await exchangeOffer(this, controller.signal)
if (isCurrent()) await this.handleAnswer({ data: sdp, networkId: this.serverNetworkId })
} catch (err) {
if (!isCurrent() || controller.signal.aborted) return
try {
this.reportError(err)
} finally {
this.close('HTTP signalling failed')
}
}
return
}

try {
const localDesc = this.rtcConnection.localDescription
let sdp = localDesc.sdp
Expand Down
5 changes: 3 additions & 2 deletions src/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,12 @@ const { EventEmitter } = require('node:events')
const { getWebRTC } = require('./webrtc')

const { Connection } = require('./connection')
const { ErrorCode, SignalStructure, SignalType } = require('./signalling')
const { ErrorCode, SignalStructure, SignalType } = require('./signalling/messages')

const { PACKET_TYPE, createSerializer, createDeserializer } = require('./transforms/serializer')

const { getRandomUint64, normalizeIceServers, validateIceServers, createPacketData, prepareSecurePacket, processSecurePacket } = require('./util')
const { getRandomUint64, normalizeIceServers, validateIceServers } = require('./util')
const { createPacketData, prepareSecurePacket, processSecurePacket } = require('./signalling/lan')

const debug = require('debug')('nethernet')
const DEFAULT_ACCEPT_TIMEOUT_MS = 5_000
Expand Down
44 changes: 44 additions & 0 deletions src/signalling/http.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
const { once } = require('node:events')
const { attachIdentity, verifyServerIdentity } = require('./sdpIdentity')

function signallingUrl (origin) {
const url = new URL(origin)
if (!['http:', 'https:'].includes(url.protocol) || url.pathname !== '/' || url.search || url.hash || url.username || url.password) {
throw new Error('HTTP signalling URL must be an HTTP(S) origin')
}
return url
}

async function pingHttp (origin, { timeout = 1000, signal } = {}) {
const deadline = AbortSignal.timeout(timeout)
signal = signal ? AbortSignal.any([signal, deadline]) : deadline
const response = await fetch(new URL('/v1/join', signallingUrl(origin)), { signal, redirect: 'error' })
if (!response.ok) throw new Error(`Nethernet HTTP discovery returned ${response.status}`)
const raw = await response.text()
// A successful response can omit metadata (for example with BDS LAN visibility off).
return { ...(raw ? JSON.parse(raw) : {}), raw }
}

async function exchangeOffer (client, signal) {
const rtc = client.rtcConnection
signal.throwIfAborted()
while (rtc.iceGatheringState !== 'complete') await once(rtc, 'icegatheringstatechange', { signal })
if (!client.identity) throw new Error('Nethernet HTTP offer requires a player identity')
// Sign the complete local description after ICE gathering, with no trickle candidates.
const body = attachIdentity(rtc.localDescription.sdp, client.identity)
const { url, serverKey, onServerKey } = client.http
const response = await fetch(new URL(`/v1/join/${client.networkId}`, url), {
method: 'POST', headers: { 'content-type': 'application/sdp' }, body, signal, redirect: 'error'
})
if (!response.ok) throw new Error(`Nethernet HTTP signalling returned ${response.status}`)
const answer = await response.text()
if (!answer.startsWith('v=')) throw new Error(`Nethernet HTTP rejected the offer: ${answer}`)
const verified = verifyServerIdentity(answer)
let trusted = serverKey ? serverKey === verified.fingerprint : url.protocol === 'https:'
if (!trusted && !serverKey && onServerKey) trusted = await onServerKey(verified.fingerprint, url.origin) === true
if (!trusted) throw new Error(`Untrusted Nethernet server key ${verified.fingerprint}; configure serverKey after verifying it`)
signal.throwIfAborted()
return verified.sdp
}

module.exports = { pingHttp, signallingUrl, exchangeOffer }
Loading
Loading