voicechat-server: proximity voice chat on Horizon's own server (native server mod) (#173)

* voicechat-server: proximity voice chat on Horizon's own server

A native server mod (voicechat_server 1.0.0): the server half for voice chat
clients that speak Simple Voice Chat 2.6.x's protocol, such as azetech's
Complex Voice Chat. The handshake rides Horizon's mod messages on voicechat:
channel names; voices go over UDP through the server, sealed per player with
AES-128-GCM, to the players nearby in the same dimension or in the speaker's
group. A player without a voice client is never sent anything, and the UDP
port opens only when the first voice client asks.

The relay is a bundle crate (horizon-mixins 0.6.0): every native mod now pins
horizon-mixins d9c83d7, since native.py builds them with one, and package
includes a bundle's crates.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* voicechat-server: replay-proof re-authentication; packages carry LICENSE

- A secret belongs to the first address that authenticates with it: an
  Authenticate copied and sent from another address is refused however long
  the client was quiet (the 5 s move rule is gone). Exact replays are dropped
  too: the last 64 control packets' nonces per player, and a 64-wide window
  on voice packets' sequence numbers.
- A client the server stops hearing for 10 keep-alives gets a fresh secret
  over the game connection (Complex Voice Chat 2.1.0 reconnects on any
  voicechat:secret), keeping its group; the old key then opens nothing. So
  a NAT that rebinds recovers without a rejoin (2.1.0 never re-sends
  Authenticate after its ack, so the old move rule never helped it).
- Secret requests: one answer a second a player (more requests meanwhile
  get one answer), the snapshot replaces what still waited. Group and state
  changes 2 a second (burst 10). At most 512 messages wait per player.
- Client pings aren't echoed (the server never pings; SVC's server doesn't
  echo either), and the relay thread never spins on a socket error.
- The glue lists the players on a message only when the sender is new, not
  on every voice chat message.
- native.py package puts native/<mod>/LICENSE into the .hnmod as
  mixins/<id>/LICENSE (Horizon's bundle reader accepts LICENSE* there); the
  CI check requires it. Every native mod's package now carries its licence.

Tests: a_replayed_handshake_after_a_silence_moves_nothing,
a_client_the_server_stops_hearing_gets_a_fresh_secret,
repeated_secret_requests_and_group_changes_stay_bounded.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Ludovic MarieandClaude Opus 5.5 authored and GitHub committed 2026-11-03 04:16:31 +01:00
1 parent 58fbad4a85
commit 5628be3e6b
25 files changed
+2788 -13

No files matched your search

+3 -1
View File
@@ -78,7 +78,9 @@ jobs:
names = zipfile.ZipFile(path).namelist()
manifest = json.loads(zipfile.ZipFile(path).read("horizon-native.json"))
assert manifest["hooks"] >= 1, manifest
assert all(n == "horizon-native.json" or (n.startswith(f"mixins/{manifest['id']}/") and n.endswith((".rs", ".toml"))) for n in names), names
bundle = f"mixins/{manifest['id']}/"
assert all(n == "horizon-native.json" or n == bundle + "LICENSE" or (n.startswith(bundle) and n.endswith((".rs", ".toml"))) for n in names), names
assert bundle + "LICENSE" in names, "the mod's LICENSE travels in its package"
print(path, manifest)
PY
- uses: actions/upload-artifact@v4
+2 -1
View File
@@ -83,7 +83,8 @@ The index: `https://horizon-rust.github.io/horizon-mods/index.json`.
[horizon-mixins](https://github.com/horizon-rust/horizon-mixins) bundles on Horizon's native hook points
([docs/native-mods.md](https://github.com/horizon-rust/horizon/blob/main/docs/native-mods.md)). Each folder keeps the
history of the repository it came from. A mod builds on any Horizon whose hook points are at least the version its
`compatibility.json` names; `horizon_revision` is the commit it was last tested on.
`compatibility.json` names; `horizon_revision` is the commit it was last tested on. `smp` and `voicechat-server`
are server mods, built into the official server's engine (see their READMEs).
```sh
python3 native/tools/native.py check zoom # apply on the tested Horizon and cargo check the game
+1 -1
View File
@@ -4,5 +4,5 @@
"horizon_repository": "https://github.com/horizon-rust/horizon",
"horizon_revision": "4baa571566e39bfd6594988d3bb2bc85440c30e7",
"horizon_mixins_repository": "https://github.com/horizon-rust/horizon-mixins",
"horizon_mixins_revision": "0d6b103c70633d8fd9443d8d0acd3aadaacedb06"
"horizon_mixins_revision": "d9c83d7096b7ea5b83678da2efdfd6161f74997e"
}
+1 -1
View File
@@ -4,5 +4,5 @@
"horizon_repository": "https://github.com/horizon-rust/horizon",
"horizon_revision": "6f8c7a0e5d531d3834c4d0be4659a1db8e7e5c2a",
"horizon_mixins_repository": "https://github.com/horizon-rust/horizon-mixins",
"horizon_mixins_revision": "0d6b103c70633d8fd9443d8d0acd3aadaacedb06"
"horizon_mixins_revision": "d9c83d7096b7ea5b83678da2efdfd6161f74997e"
}
+1 -1
View File
@@ -4,5 +4,5 @@
"horizon_repository": "https://github.com/horizon-rust/horizon",
"horizon_revision": "6f8c7a0e5d531d3834c4d0be4659a1db8e7e5c2a",
"horizon_mixins_repository": "https://github.com/horizon-rust/horizon-mixins",
"horizon_mixins_revision": "0d6b103c70633d8fd9443d8d0acd3aadaacedb06"
"horizon_mixins_revision": "d9c83d7096b7ea5b83678da2efdfd6161f74997e"
}
+1 -1
View File
@@ -4,5 +4,5 @@
"horizon_repository": "https://github.com/horizon-rust/horizon",
"horizon_revision": "6f8c7a0e5d531d3834c4d0be4659a1db8e7e5c2a",
"horizon_mixins_repository": "https://github.com/horizon-rust/horizon-mixins",
"horizon_mixins_revision": "0d6b103c70633d8fd9443d8d0acd3aadaacedb06"
"horizon_mixins_revision": "d9c83d7096b7ea5b83678da2efdfd6161f74997e"
}
+1 -1
View File
@@ -4,5 +4,5 @@
"horizon_repository": "https://github.com/horizon-rust/horizon",
"horizon_revision": "4baa571566e39bfd6594988d3bb2bc85440c30e7",
"horizon_mixins_repository": "https://github.com/horizon-rust/horizon-mixins",
"horizon_mixins_revision": "0d6b103c70633d8fd9443d8d0acd3aadaacedb06"
"horizon_mixins_revision": "d9c83d7096b7ea5b83678da2efdfd6161f74997e"
}
+1 -1
View File
@@ -4,5 +4,5 @@
"horizon_repository": "https://github.com/horizon-rust/horizon",
"horizon_revision": "975c07d9151847e6af13afc4b5ab37b6bbe78ef5",
"horizon_mixins_repository": "https://github.com/horizon-rust/horizon-mixins",
"horizon_mixins_revision": "0d6b103c70633d8fd9443d8d0acd3aadaacedb06"
"horizon_mixins_revision": "d9c83d7096b7ea5b83678da2efdfd6161f74997e"
}
+10 -4
View File
@@ -170,7 +170,9 @@ def cargo(stage_dir, action):
def package(m):
"""A deterministic .hnmod: horizon-native.json and the bundle's .rs and .toml files."""
"""A deterministic .hnmod: horizon-native.json, the bundle's .rs and .toml files (its crates' too) and the mod's
LICENSE as mixins/<id>/LICENSE, so the licence's notice travels with the sources (Horizon's bundle reader
accepts LICENSE* files there)."""
manifest = {"schema": 1, "id": m.id, "version": m.version, "game": m.compat["game"],
"horizon": m.compat["horizon_revision"], "mixins": m.compat["horizon_mixins_revision"],
"hooks": m.compat["hooks"]}
@@ -178,9 +180,13 @@ def package(m):
if pinned != m.compat["horizon_revision"]:
sys.exit(f"{m.id}: horizon-mixin.toml pins {pinned}, compatibility.json {m.compat['horizon_revision']}")
files = {"horizon-native.json": (json.dumps(manifest, indent=2, sort_keys=True) + "\n").encode()}
for path in sorted(m.bundle.iterdir()):
if path.suffix in (".rs", ".toml"):
files[f"mixins/{m.id}/{path.name}"] = path.read_bytes()
# The helpers and modules, and the sources of the bundle's own crates (horizon-mixins 0.6.0).
for path in sorted(m.bundle.rglob("*")):
if path.is_file() and path.suffix in (".rs", ".toml"):
files[f"mixins/{m.id}/{path.relative_to(m.bundle).as_posix()}"] = path.read_bytes()
licence = m.root / "LICENSE"
if licence.is_file():
files.setdefault(f"mixins/{m.id}/LICENSE", licence.read_bytes())
out = TARGET / f"{m.id}-{m.version}.hnmod"
TARGET.mkdir(exist_ok=True)
with zipfile.ZipFile(out, "w", compression=zipfile.ZIP_DEFLATED) as archive:
+252
View File
@@ -0,0 +1,252 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "cc"
version = "1.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "86e055fa6e3e40ad6187ba25b6855eeb635d5271f8e0f0e1e9a5826b8d75bea0"
dependencies = [
"find-msvc-tools",
"shlex",
]
[[package]]
name = "cfg-if"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
[[package]]
name = "find-msvc-tools"
version = "0.1.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48"
[[package]]
name = "getrandom"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [
"cfg-if",
"libc",
"wasi",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "libc"
version = "0.2.191"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a"
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "ring"
version = "0.17.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
dependencies = [
"cc",
"cfg-if",
"getrandom",
"libc",
"untrusted",
"windows-sys",
]
[[package]]
name = "serde"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.154"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "shlex"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
[[package]]
name = "syn"
version = "3.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "unicode-ident"
version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
[[package]]
name = "untrusted"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "voicechat_server_relay"
version = "1.0.0"
dependencies = [
"ring",
"serde",
"serde_json",
]
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "windows-sys"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
dependencies = [
"windows-targets",
]
[[package]]
name = "windows-targets"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
dependencies = [
"windows_aarch64_gnullvm",
"windows_aarch64_msvc",
"windows_i686_gnu",
"windows_i686_gnullvm",
"windows_i686_msvc",
"windows_x86_64_gnu",
"windows_x86_64_gnullvm",
"windows_x86_64_msvc",
]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_i686_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
[[package]]
name = "windows_i686_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_x86_64_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
+4
View File
@@ -0,0 +1,4 @@
# The relay crate the bundle adds to the game (mixins/voicechat_server/relay), with its tests.
[workspace]
members = ["mixins/voicechat_server/relay"]
resolver = "3"
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Ludovic Marie and the Horizon contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+53
View File
@@ -0,0 +1,53 @@
# Voice chat server (Horizon native mod)
The server half of proximity voice chat on a Horizon dedicated server. Players who have a voice chat client mod
(azetech's Complex Voice Chat, any client that speaks Simple Voice Chat 2.6.x's protocol over Horizon's mod
messages) talk to the players near them, or to their group from anywhere. Players without one join and play as on
any server: the server sends them nothing.
- **The handshake** goes over Horizon's mod messages (`native_net_from_client` / `native_net_send_to_player`, the
`horizon:mod_net` channel) on `voicechat:` channel names, the same messages and layouts as Simple Voice Chat 2.6.x
(compatibility version 20). A client gets a fresh 16-byte secret when it asks; nothing is sent to anyone before.
- **Voices** go over UDP, through the server, never from player to player: no player learns another's address.
Each player's packets are sealed with their own secret (AES-128-GCM). The server relays a voice to the players
in the same dimension within `distance` blocks (`whisper_distance` for a whisper), and to the speaker's group
wherever they are. Groups: normal (members aren't heard outside but hear who is near), open (heard and hearing
nearby), isolated (members only).
- **Limits:** 75 voice packets a second a player (burst 30), Opus frames up to 1275 bytes, 128 groups, names and
passwords up to 24 characters, group and state changes 2 a second a player (burst 10), one secret a second a
player (more requests meanwhile get one answer), 24 messages a player a tick and at most 512 waiting.
- **Replays:** a packet that doesn't open with the player's key is dropped without an answer. A secret belongs to
the first address that authenticates with it: a handshake copied and sent from elsewhere moves nothing. Exact
copies are dropped too (the last 64 control packets' nonces, and voice packets' sequence numbers). A client the
server stops hearing for 10 keep-alives (its NAT rebound, say) gets a fresh secret over the game connection and
comes back from its new address with it; the old key then opens nothing.
- **No port is opened** until the first voice chat client asks. Then the relay binds `bind:port` (UDP).
## Config
`config/voicechat-server.json`, beside `server.properties`, written with its defaults the first time a client asks:
| Key | Default | |
|---|---|---|
| `enabled` | `true` | `false`: no port, no answer to clients |
| `port` | `24454` | the UDP port. A host that gives one port for the game usually forwards UDP on it too: use the game's port |
| `bind` | `0.0.0.0` | |
| `voice_host` | `""` | where clients send UDP if not the game server's address: a port, a host, or host:port |
| `distance` | `48.0` | blocks a voice carries |
| `whisper_distance` | `24.0` | |
| `groups` | `true` | players may make and join groups |
| `keep_alive_ms` | `1000` | a client silent 10 times this long shows as disconnected |
| `mtu` | `1024` | |
## Files
| File | What it is |
|---|---|
| `mixins/voicechat_server/horizon-mixin.toml` | The bundle: the relay crate, the glue module and four HEAD hooks (mod messages, server tick, player logged out, world closing) |
| `mixins/voicechat_server/relay/` | The relay crate (`voicechat_server_relay`): the wire format (`proto.rs`), the handshake, groups and UDP relay (`lib.rs`) and its tests over real UDP sockets (`tests.rs`). Depends on `ring` (AES-GCM) and `serde`, both in Horizon's `Cargo.lock` |
| `mixins/voicechat_server/glue.rs` | Added to the game as `game::voicechat_server`: the config, starting the relay, the players each tick |
It needs horizon-mixins 0.6.0 (bundle crates) and hook points API 57. Tests: `cargo test` here.
Not made with or endorsed by Simple Voice Chat or its author: it speaks the same protocol, written from its packet
layouts, with none of its code.
@@ -0,0 +1,8 @@
{
"game": "26.3",
"hooks": 57,
"horizon_repository": "https://github.com/horizon-rust/horizon",
"horizon_revision": "f4833621f9037e08c487b1779e8c030487e65cc2",
"horizon_mixins_repository": "https://github.com/horizon-rust/horizon-mixins",
"horizon_mixins_revision": "d9c83d7096b7ea5b83678da2efdfd6161f74997e"
}
@@ -0,0 +1,143 @@
// Voice chat's server half in the game (`game::voicechat_server`): the relay
// (crate voicechat_server_relay) opens its UDP port when the first voice chat
// client asks for a secret, hears the clients' messages through Horizon's mod
// messages, and learns each tick where every player is. A player without a
// voice chat client is never sent anything.
use crate::game::Game;
use std::sync::Mutex;
use std::time::{Duration, Instant};
use voicechat_server_relay::{Config, Player, Server, proto};
/// Messages to one player a tick at most (mod messages allow 32, shared with other mods).
const PER_TICK: usize = 24;
/// The config file in the server's `config/` folder.
const FILE: &str = "voicechat-server.json";
/// After the port failed to open, wait this long before trying again.
const RETRY: Duration = Duration::from_secs(60);
enum Relay {
Off,
Running(Server),
Failed(Instant),
}
static RELAY: Mutex<Relay> = Mutex::new(Relay::Off);
fn relay() -> std::sync::MutexGuard<'static, Relay> {
RELAY.lock().unwrap_or_else(|e| e.into_inner())
}
fn players(game: &Game<'_>) -> Vec<Player> {
game.native_server_players()
.into_iter()
.map(|p| Player {
uuid: p.uuid,
name: p.name,
dimension: p.dimension,
position: p.position,
})
.collect()
}
/// `config/voicechat-server.json`, written with its defaults the first time
/// and again with the keys a newer version adds (values kept). A file that
/// doesn't read is left alone and the defaults apply.
fn config() -> Config {
let Ok(dir) = Game::native_config_dir() else {
return Config::default();
};
let path = dir.join(FILE);
let config = match std::fs::read_to_string(&path) {
Ok(text) => match serde_json::from_str::<Config>(&text) {
Ok(c) => c,
Err(e) => {
log::warn!(
"Voice chat: {} doesn't read ({e}); using the defaults",
path.display()
);
return Config::default();
}
},
Err(_) => Config::default(),
};
if let Ok(text) = serde_json::to_string_pretty(&config) {
let _ = std::fs::write(&path, text + "\n");
}
config
}
/// A message from a player's client: only `voicechat:` channels are ours.
pub(super) fn message(game: &mut Game<'_>, player: u128, channel: &str, data: &[u8]) {
if !channel.starts_with("voicechat:") {
return;
}
let mut relay = relay();
if !matches!(*relay, Relay::Running(_)) {
if channel != proto::REQUEST_SECRET {
return;
}
if let Relay::Failed(at) = *relay
&& at.elapsed() < RETRY
{
return;
}
let config = config();
match Server::start(config.clone()) {
Ok(server) => {
let at = server
.local_addr()
.map(|a| a.to_string())
.unwrap_or_default();
log::info!("Voice chat: relaying voices on UDP {at}");
*relay = Relay::Running(server);
}
Err(e) => {
log::warn!(
"Voice chat: not started on UDP {}:{}: {e}",
config.bind,
config.port
);
*relay = Relay::Failed(Instant::now());
return;
}
}
}
let Relay::Running(server) = &*relay else {
return;
};
// A player who joined since the last tick (or a relay that just started):
// learn who is on first. Not for every message: it lists every player.
if !server.knows(player) {
server.tick(players(game));
}
server.message(player, channel, data);
}
/// Each server tick, once the relay runs.
pub(super) fn tick(game: &mut Game<'_>) {
let out = {
let relay = relay();
let Relay::Running(server) = &*relay else {
return;
};
server.tick(players(game));
server.outgoing(PER_TICK)
};
for (player, channel, data) in out {
if let Err(e) = game.native_net_send_to_player(player, &channel, &data) {
log::debug!("Voice chat: {channel} not sent: {e}");
}
}
}
pub(super) fn left(player: u128) {
if let Relay::Running(server) = &*relay() {
server.left(player);
}
}
/// The world closed: the port closes too (it opens again on the next request).
pub(super) fn stop() {
*relay() = Relay::Off;
}
@@ -0,0 +1,60 @@
[mod]
id = "voicechat_server"
version = "1.0.0"
priority = 100
# The commit it was tested on (the official server's engine).
[compatibility]
horizon_revision = "f4833621f9037e08c487b1779e8c030487e65cc2"
# The handshake, the groups and the UDP relay: plain Rust with its tests.
[[crate]]
id = "relay"
path = "relay"
# The edges between the game and the relay.
[[module]]
id = "glue"
file = "crates/horizon-client/src/game.rs"
name = "voicechat_server"
source = "glue.rs"
[[injection]]
id = "net"
kind = "HEAD"
target = "horizon_client::game::native_hooks::Game<'_>::native_net_from_client"
helper = "net.rs"
bindings = [
{ name = "game", ty = "&mut crate::game::Game<'_>", value = "&mut *self" },
{ name = "player", ty = "u128", value = "player" },
{ name = "channel", ty = "&str", value = "channel" },
{ name = "data", ty = "&[u8]", value = "data" },
]
[[injection]]
id = "tick"
kind = "HEAD"
target = "horizon_client::game::native_hooks::Game<'_>::native_server_tick"
helper = "tick.rs"
bindings = [
{ name = "game", ty = "&mut crate::game::Game<'_>", value = "&mut *self" },
{ name = "phase", ty = "crate::game::native_lifecycle::ServerTickPhase", value = "phase" },
]
[[injection]]
id = "logged_out"
kind = "HEAD"
target = "horizon_client::game::native_hooks::Game<'_>::native_player_logged_out"
helper = "logged_out.rs"
bindings = [
{ name = "player", ty = "u128", value = "player" },
]
[[injection]]
id = "lifecycle"
kind = "HEAD"
target = "horizon_client::game::native_hooks::Game<'_>::native_world_lifecycle"
helper = "lifecycle.rs"
bindings = [
{ name = "phase", ty = "crate::game::native_lifecycle::WorldPhase", value = "phase" },
]
@@ -0,0 +1,10 @@
// The world closing: the relay closes its UDP port.
fn voicechat_server_lifecycle(phase: crate::game::native_lifecycle::WorldPhase) {
use crate::game::native_lifecycle::WorldPhase;
if matches!(
phase,
WorldPhase::WorldCloseFinal | WorldPhase::WorldCloseFailed
) {
crate::game::voicechat_server::stop();
}
}
@@ -0,0 +1,4 @@
// A player left: the relay forgets them and tells the others.
fn voicechat_server_logged_out(player: u128) {
crate::game::voicechat_server::left(player);
}
@@ -0,0 +1,9 @@
// A voice chat client's message (`voicechat:` channels) to the relay.
fn voicechat_server_net(
game: &mut crate::game::Game<'_>,
player: u128,
channel: &str,
data: &[u8],
) {
crate::game::voicechat_server::message(game, player, channel, data);
}
@@ -0,0 +1,14 @@
[package]
name = "voicechat_server_relay"
version = "1.0.0"
edition = "2024"
license = "MIT"
publish = false
description = "Horizon's voice chat server: the handshake for voice chat clients and the UDP relay of their voices."
[dependencies]
ring = "0.17"
serde = { version = "1", features = ["derive"] }
[dev-dependencies]
serde_json = "1"
@@ -0,0 +1,788 @@
//! The server half of proximity voice chat on Horizon: it answers the voice
//! chat clients' handshake (sent over Horizon's mod messages) and relays their
//! voices over UDP to the players who should hear them, by distance and
//! group. Players without a voice chat client never hear of it: nothing is
//! sent to a player until their client asks.
//!
//! Voices go through the server, never player to player, so nobody learns
//! another player's address. Each player's packets are sealed with their own
//! key (AES-128-GCM), which they get over the game connection.
pub mod proto;
use proto::{FromClient, GroupKind, PlayerState, Request, ToClient};
use ring::rand::{SecureRandom, SystemRandom};
use std::collections::{HashMap, HashSet, VecDeque};
use std::io;
use std::net::{SocketAddr, UdpSocket};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, MutexGuard};
use std::thread::JoinHandle;
use std::time::{Duration, Instant};
/// `config/voicechat-server.json`.
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(default)]
pub struct Config {
/// Off: no port is opened and clients get no answer.
pub enabled: bool,
/// The UDP port voices use. A host that gives one port for the game
/// (TCP) usually forwards UDP on it too: use the game's port then.
pub port: u16,
/// The address the UDP socket binds.
pub bind: String,
/// Where clients send UDP, when not the game server's own address:
/// a port, a host, or host:port.
pub voice_host: String,
/// How far a voice carries, in blocks.
pub distance: f64,
/// How far a whisper carries.
pub whisper_distance: f64,
/// Players may make and join groups.
pub groups: bool,
pub keep_alive_ms: u32,
pub mtu: u32,
}
impl Default for Config {
fn default() -> Self {
Self {
enabled: true,
port: 24454,
bind: "0.0.0.0".into(),
voice_host: String::new(),
distance: 48.0,
whisper_distance: 24.0,
groups: true,
keep_alive_ms: 1000,
mtu: 1024,
}
}
}
/// A player on the game server, as the last tick saw them.
#[derive(Clone, Debug, PartialEq)]
pub struct Player {
pub uuid: u128,
pub name: String,
pub dimension: String,
pub position: [f64; 3],
}
/// A message for a player's client: (player, channel, data).
pub type Message = (u128, String, Vec<u8>);
/// Groups at most, and voice packets a second a player may send.
const MAX_GROUPS: usize = 128;
const MIC_PER_SECOND: f64 = 75.0;
const MIC_BURST: f64 = 30.0;
/// Group and voice state changes a second a player may make.
const OPS_PER_SECOND: f64 = 2.0;
const OPS_BURST: f64 = 10.0;
/// Secrets a player's own requests get at most this often; more requests
/// meanwhile are answered once, when the time is up.
const SECRET_EVERY: Duration = Duration::from_secs(1);
/// Messages waiting for one player at most; more are dropped.
const MAX_QUEUED: usize = 512;
/// The nonces of a player's last control packets remembered, to drop exact
/// replays; and how far behind the newest a voice packet's sequence may be.
const SEEN_NONCES: usize = 64;
const MIC_WINDOW: i128 = 64;
/// A player's voice connection, for the life of one secret.
///
/// The first address that authenticates holds the secret: no other address
/// can use it, so a handshake captured and sent again from elsewhere moves
/// nothing. A client that moves (a NAT that rebinds) gets a fresh secret
/// over the game connection, which the game session authenticates: when the
/// server stops hearing it, or when it asks again. The old key then opens
/// nothing.
struct Client {
secret: [u8; 16],
cipher: proto::Cipher,
addr: Option<SocketAddr>,
/// Answered the connection check: its voice connection works.
connected: bool,
last_keep_alive: Instant,
disabled: bool,
group: Option<u128>,
tokens: f64,
refilled: Instant,
ops: f64,
ops_refilled: Instant,
/// When this secret was given, and whether a request for another waits.
issued: Instant,
asked_again: bool,
/// Nonces of the control packets already taken (the newest last).
seen: VecDeque<[u8; 12]>,
/// The newest voice sequence, and which of the 64 before it arrived.
mic_top: Option<i64>,
mic_seen: u64,
}
impl Client {
fn new(secret: [u8; 16], now: Instant) -> Self {
Self {
secret,
cipher: proto::Cipher::new(&secret),
addr: None,
connected: false,
last_keep_alive: now,
disabled: false,
group: None,
tokens: MIC_BURST,
refilled: now,
ops: OPS_BURST,
ops_refilled: now,
issued: now,
asked_again: false,
seen: VecDeque::new(),
mic_top: None,
mic_seen: 0,
}
}
/// A control packet's nonce: false if it was already taken.
fn fresh_nonce(&mut self, nonce: [u8; 12]) -> bool {
if self.seen.contains(&nonce) {
return false;
}
if self.seen.len() >= SEEN_NONCES {
self.seen.pop_front();
}
self.seen.push_back(nonce);
true
}
/// A voice packet's sequence: false if it was already relayed or is too
/// old (a replay). A client counts up from 0 for each secret.
fn fresh_sequence(&mut self, sequence: i64) -> bool {
let Some(top) = self.mic_top else {
self.mic_top = Some(sequence);
self.mic_seen = 1;
return true;
};
let ahead = i128::from(sequence) - i128::from(top);
if ahead > 0 {
self.mic_seen = if ahead >= MIC_WINDOW {
1
} else {
(self.mic_seen << ahead) | 1
};
self.mic_top = Some(sequence);
return true;
}
let back = -ahead;
if back >= MIC_WINDOW {
return false;
}
let bit = 1u64 << back;
if self.mic_seen & bit != 0 {
return false;
}
self.mic_seen |= bit;
true
}
/// One group or state change, if the player has one left.
fn spend_op(&mut self, now: Instant) -> bool {
self.ops = (self.ops
+ now.duration_since(self.ops_refilled).as_secs_f64() * OPS_PER_SECOND)
.min(OPS_BURST);
self.ops_refilled = now;
if self.ops < 1.0 {
return false;
}
self.ops -= 1.0;
true
}
}
struct Group {
name: String,
password: Option<String>,
kind: GroupKind,
}
struct State {
config: Config,
/// The UDP port the socket got.
port: u16,
clients: HashMap<u128, Client>,
players: HashMap<u128, Player>,
groups: HashMap<u128, Group>,
/// Players whose state changed since the last tick, to tell everyone.
changed: HashSet<u128>,
outbox: HashMap<u128, VecDeque<(String, Vec<u8>)>>,
}
struct Shared {
state: Mutex<State>,
socket: UdpSocket,
rng: SystemRandom,
running: AtomicBool,
}
/// The running voice server: its UDP socket and thread. Dropping it stops them.
pub struct Server {
shared: Arc<Shared>,
thread: Option<JoinHandle<()>>,
}
fn lock(m: &Mutex<State>) -> MutexGuard<'_, State> {
m.lock().unwrap_or_else(|e| e.into_inner())
}
impl Server {
/// Opens the UDP port and starts relaying.
pub fn start(config: Config) -> io::Result<Self> {
if !config.enabled {
return Err(io::Error::other("voice chat is turned off in its config"));
}
let socket = UdpSocket::bind((config.bind.as_str(), config.port))?;
socket.set_read_timeout(Some(Duration::from_millis(100)))?;
let port = socket.local_addr()?.port();
let shared = Arc::new(Shared {
state: Mutex::new(State {
config,
port,
clients: HashMap::new(),
players: HashMap::new(),
groups: HashMap::new(),
changed: HashSet::new(),
outbox: HashMap::new(),
}),
socket,
rng: SystemRandom::new(),
running: AtomicBool::new(true),
});
let worker = shared.clone();
let thread = std::thread::Builder::new()
.name("voicechat-relay".into())
.spawn(move || relay(&worker))?;
Ok(Self {
shared,
thread: Some(thread),
})
}
/// The UDP address voices arrive on.
pub fn local_addr(&self) -> io::Result<SocketAddr> {
self.shared.socket.local_addr()
}
/// Each server tick: the players on the server now. Voice chat forgets
/// the ones who left, and tells everyone what changed.
pub fn tick(&self, players: Vec<Player>) {
let mut st = lock(&self.shared.state);
st.players = players.into_iter().map(|p| (p.uuid, p)).collect();
let gone: Vec<u128> = st
.clients
.keys()
.filter(|u| !st.players.contains_key(u))
.copied()
.collect();
for uuid in gone {
st.forget(uuid);
}
// Requests that came too soon after a secret get one now.
let due: Vec<u128> = st
.clients
.iter()
.filter(|(_, c)| c.asked_again && c.issued.elapsed() >= SECRET_EVERY)
.map(|(u, _)| *u)
.collect();
for uuid in due {
st.give_secret(&self.shared.rng, uuid, false);
}
let changed: Vec<u128> = st.changed.drain().collect();
for uuid in changed {
if let Some(s) = st.player_state(uuid) {
st.tell_all(proto::STATE, proto::state(&s));
}
}
}
/// Whether `player` was on the server at the last tick.
pub fn knows(&self, player: u128) -> bool {
lock(&self.shared.state).players.contains_key(&player)
}
/// A player left the game.
pub fn left(&self, player: u128) {
lock(&self.shared.state).forget(player);
}
/// A message from `player`'s client on a `voicechat:` channel.
pub fn message(&self, player: u128, channel: &str, data: &[u8]) {
let Some(Ok(request)) = Request::read(channel, data) else {
return;
};
let mut st = lock(&self.shared.state);
if !st.players.contains_key(&player) {
return;
}
match request {
Request::Secret { version } => {
if version != proto::COMPATIBILITY_VERSION {
return;
}
if let Some(c) = st.clients.get_mut(&player)
&& c.issued.elapsed() < SECRET_EVERY
{
c.asked_again = true;
return;
}
st.give_secret(&self.shared.rng, player, false);
}
Request::State { disabled } => {
if let Some(c) = st.clients.get_mut(&player)
&& c.spend_op(Instant::now())
&& c.disabled != disabled
{
c.disabled = disabled;
st.changed.insert(player);
}
}
Request::CreateGroup {
name,
password,
kind,
} => {
let name = name.trim().to_owned();
if !st.config.groups
|| name.is_empty()
|| st.groups.len() >= MAX_GROUPS
|| !st.spend_op(player)
{
return;
}
let mut id = [0u8; 16];
if self.shared.rng.fill(&mut id).is_err() {
return;
}
let id = u128::from_be_bytes(id);
let password = password
.map(|p| p.trim().to_owned())
.filter(|p| !p.is_empty());
let add = proto::add_group(id, &name, password.is_some(), kind);
st.groups.insert(
id,
Group {
name,
password,
kind,
},
);
st.tell_all(proto::ADD_GROUP, add);
st.join(player, id);
}
Request::JoinGroup { group, password } => {
if !st.config.groups || !st.groups.contains_key(&group) || !st.spend_op(player) {
return;
}
let Some(g) = st.groups.get(&group) else {
return;
};
let ok = g.password.is_none()
|| g.password.as_deref() == password.as_deref().map(str::trim);
if ok {
st.join(player, group);
} else {
let current = st.clients.get(&player).and_then(|c| c.group);
st.tell(
player,
proto::JOINED_GROUP,
proto::joined_group(current, true),
);
}
}
Request::LeaveGroup => {
let Some(c) = st.clients.get_mut(&player) else {
return;
};
if c.group.is_none() || !c.spend_op(Instant::now()) {
return;
}
let Some(g) = c.group.take() else { return };
st.tell(
player,
proto::JOINED_GROUP,
proto::joined_group(None, false),
);
st.changed.insert(player);
st.leave_group_cleanup(g);
}
}
}
/// What to send to the players' clients now, at most `per_player` messages each (the rest wait for the
/// next call).
pub fn outgoing(&self, per_player: usize) -> Vec<Message> {
let mut st = lock(&self.shared.state);
let mut out = Vec::new();
st.outbox.retain(|player, queue| {
for _ in 0..per_player {
let Some((channel, data)) = queue.pop_front() else {
break;
};
out.push((*player, channel, data));
}
!queue.is_empty()
});
out
}
}
impl Drop for Server {
fn drop(&mut self) {
self.shared.running.store(false, Ordering::Relaxed);
if let Some(t) = self.thread.take() {
let _ = t.join();
}
}
}
impl State {
/// Queues a message for `player`'s client (dropped when MAX_QUEUED wait).
fn tell(&mut self, player: u128, channel: &str, data: Vec<u8>) {
let queue = self.outbox.entry(player).or_default();
if queue.len() < MAX_QUEUED {
queue.push_back((channel.to_owned(), data));
}
}
fn spend_op(&mut self, player: u128) -> bool {
self.clients
.get_mut(&player)
.is_some_and(|c| c.spend_op(Instant::now()))
}
/// A fresh secret for `player`: the old one opens nothing any more.
/// `rekey`: the server's own, for a client it stopped hearing (it keeps its
/// group and state, and gets only the secret). Otherwise the client asked:
/// it starts over and gets everyone's state and the groups, instead of
/// whatever still waited for it.
fn give_secret(&mut self, rng: &SystemRandom, player: u128, rekey: bool) {
let mut secret = [0u8; 16];
if rng.fill(&mut secret).is_err() {
return;
}
let mut client = Client::new(secret, Instant::now());
let old = self.clients.remove(&player);
if rekey && let Some(old) = &old {
client.group = old.group;
client.disabled = old.disabled;
client.ops = old.ops;
client.ops_refilled = old.ops_refilled;
}
self.clients.insert(player, client);
if !rekey && let Some(g) = old.and_then(|o| o.group) {
self.leave_group_cleanup(g);
}
let c = &self.config;
let body = proto::secret(&proto::SecretInfo {
secret: &secret,
port: self.port,
player,
mtu: c.mtu.min(i32::MAX as u32) as i32,
distance: c.distance,
keep_alive_ms: c.keep_alive_ms.clamp(100, 60_000) as i32,
groups: c.groups,
voice_host: &c.voice_host,
});
self.changed.insert(player);
if rekey {
// Past the cap: the secret is what gets the client back.
self.outbox
.entry(player)
.or_default()
.push_back((proto::SECRET.to_owned(), body));
return;
}
self.outbox.remove(&player);
self.tell(player, proto::SECRET, body);
let all: Vec<PlayerState> = self
.clients
.keys()
.filter_map(|u| self.player_state(*u))
.collect();
self.tell(player, proto::STATES, proto::states(&all));
let groups: Vec<Vec<u8>> = self
.groups
.iter()
.map(|(id, g)| proto::add_group(*id, &g.name, g.password.is_some(), g.kind))
.collect();
for g in groups {
self.tell(player, proto::ADD_GROUP, g);
}
}
/// To every player with a voice chat client.
fn tell_all(&mut self, channel: &str, data: Vec<u8>) {
let all: Vec<u128> = self.clients.keys().copied().collect();
for p in all {
self.tell(p, channel, data.clone());
}
}
fn player_state(&self, uuid: u128) -> Option<PlayerState> {
let c = self.clients.get(&uuid)?;
Some(PlayerState {
uuid,
name: self
.players
.get(&uuid)
.map(|p| p.name.clone())
.unwrap_or_default(),
disabled: c.disabled,
disconnected: !c.connected,
group: c.group,
})
}
fn join(&mut self, player: u128, group: u128) {
let Some(c) = self.clients.get_mut(&player) else {
return;
};
let old = c.group.replace(group);
self.tell(
player,
proto::JOINED_GROUP,
proto::joined_group(Some(group), false),
);
self.changed.insert(player);
if let Some(old) = old
&& old != group
{
self.leave_group_cleanup(old);
}
}
/// An empty group goes away.
fn leave_group_cleanup(&mut self, group: u128) {
if self.clients.values().any(|c| c.group == Some(group)) {
return;
}
if self.groups.remove(&group).is_some() {
self.tell_all(proto::REMOVE_GROUP, proto::remove_group(group));
}
}
fn forget(&mut self, uuid: u128) {
self.outbox.remove(&uuid);
self.changed.remove(&uuid);
let Some(c) = self.clients.remove(&uuid) else {
return;
};
self.tell_all(proto::REMOVE_STATE, proto::remove_state(uuid));
if let Some(g) = c.group {
self.leave_group_cleanup(g);
}
}
/// Who hears `sender`, and how: (listener, address, positional range or None for the group's voice).
fn listeners(&self, sender: u128, whispering: bool) -> Vec<(u128, SocketAddr, Option<f64>)> {
let Some(from) = self.clients.get(&sender) else {
return Vec::new();
};
let from_kind = from.group.and_then(|g| self.groups.get(&g)).map(|g| g.kind);
let at = self.players.get(&sender);
let range = if whispering {
self.config.whisper_distance
} else {
self.config.distance
};
let mut out = Vec::new();
for (uuid, c) in &self.clients {
if *uuid == sender || c.disabled || !c.connected {
continue;
}
let Some(addr) = c.addr else { continue };
if from.group.is_some() && from.group == c.group {
out.push((*uuid, addr, None));
continue;
}
let to_kind = c.group.and_then(|g| self.groups.get(&g)).map(|g| g.kind);
// Normal and isolated groups aren't heard outside; isolated ones hear nobody outside.
if matches!(from_kind, Some(GroupKind::Normal | GroupKind::Isolated))
|| to_kind == Some(GroupKind::Isolated)
{
continue;
}
let (Some(a), Some(b)) = (at, self.players.get(uuid)) else {
continue;
};
if a.dimension != b.dimension {
continue;
}
let d = (0..3)
.map(|i| (a.position[i] - b.position[i]).powi(2))
.sum::<f64>()
.sqrt();
if d <= range {
out.push((*uuid, addr, Some(range)));
}
}
out
}
}
/// The UDP side: handshakes, keep-alives and voices.
fn relay(shared: &Shared) {
let mut buf = vec![0u8; 4096];
let mut last_keep_alive = Instant::now();
while shared.running.load(Ordering::Relaxed) {
match shared.socket.recv_from(&mut buf) {
Ok((n, from)) => datagram(shared, &buf[..n], from),
Err(e)
if matches!(
e.kind(),
io::ErrorKind::WouldBlock | io::ErrorKind::TimedOut
) => {}
// Never spin on an error that comes back at once.
Err(_) => std::thread::sleep(Duration::from_millis(10)),
}
let every = Duration::from_millis(u64::from(
lock(&shared.state).config.keep_alive_ms.clamp(100, 60_000),
));
if last_keep_alive.elapsed() >= every {
last_keep_alive = Instant::now();
keep_alive(shared, every);
}
}
}
fn send(shared: &Shared, cipher: &proto::Cipher, to: SocketAddr, packet: &ToClient) {
if let Some(sealed) = cipher.seal(&shared.rng, &packet.write()) {
let _ = shared.socket.send_to(&proto::server_datagram(&sealed), to);
}
}
/// Keep-alives to every authenticated client. One that stopped answering
/// gets a fresh secret over the game connection: if it moved (its NAT
/// rebound), it comes back from its new address with that secret.
fn keep_alive(shared: &Shared, every: Duration) {
let mut st = lock(&shared.state);
let mut lost = Vec::new();
for (uuid, c) in st.clients.iter_mut() {
let Some(addr) = c.addr else { continue };
if c.connected && c.last_keep_alive.elapsed() > every * 10 {
c.connected = false;
lost.push(*uuid);
continue;
}
send(shared, &c.cipher, addr, &ToClient::KeepAlive);
}
for uuid in lost {
st.give_secret(&shared.rng, uuid, true);
}
}
fn datagram(shared: &Shared, datagram: &[u8], from: SocketAddr) {
let Ok((player, sealed)) = proto::client_datagram(datagram) else {
return;
};
let mut st = lock(&shared.state);
let Some(c) = st.clients.get_mut(&player) else {
return;
};
let Some(plain) = c.cipher.open(sealed) else {
return;
};
let Ok(packet) = FromClient::read(&plain) else {
return;
};
// It opened, so it holds at least a nonce and a tag.
let mut nonce = [0u8; 12];
nonce.copy_from_slice(&sealed[..12]);
let now = Instant::now();
if let FromClient::Authenticate {
player: who,
secret,
} = packet
{
if who != player || !constant_eq(&secret, &c.secret) {
return;
}
// The secret belongs to the first address that authenticated with it.
if c.addr.is_some_and(|a| a != from) || !c.fresh_nonce(nonce) {
return;
}
c.addr = Some(from);
send(shared, &c.cipher, from, &ToClient::AuthenticateAck);
return;
}
// Everything else only from the address that authenticated.
if c.addr != Some(from) {
return;
}
if let FromClient::Mic { sequence, .. } = packet {
if !c.fresh_sequence(sequence) {
return;
}
} else if !c.fresh_nonce(nonce) {
return;
}
match packet {
FromClient::ConnectionCheck => {
c.last_keep_alive = now;
if !c.connected {
c.connected = true;
st.changed.insert(player);
}
let c = &st.clients[&player];
send(shared, &c.cipher, from, &ToClient::ConnectionCheckAck);
}
FromClient::KeepAlive => {
c.last_keep_alive = now;
if !c.connected {
c.connected = true;
st.changed.insert(player);
}
}
// The server never pings, so this isn't an answer: not echoed (a
// client that echoes pings would otherwise bounce it forever).
FromClient::Ping { .. } => {}
FromClient::Mic {
data,
sequence,
whispering,
} => {
if c.disabled || !c.connected {
return;
}
c.tokens = (c.tokens + now.duration_since(c.refilled).as_secs_f64() * MIC_PER_SECOND)
.min(MIC_BURST);
c.refilled = now;
if c.tokens < 1.0 {
return;
}
c.tokens -= 1.0;
for (listener, addr, range) in st.listeners(player, whispering) {
let packet = match range {
Some(r) => ToClient::Positional {
sender: player,
data: &data,
sequence,
distance: r as f32,
whispering,
},
None => ToClient::Group {
sender: player,
data: &data,
sequence,
},
};
send(shared, &st.clients[&listener].cipher, addr, &packet);
}
}
FromClient::Authenticate { .. } => {}
}
}
fn constant_eq(a: &[u8], b: &[u8]) -> bool {
a.len() == b.len() && a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,734 @@
//! The voice chat wire format Simple Voice Chat 2.6.x clients speak
//! (compatibility version 20), from the server's side: control messages on
//! `voicechat:` channels over the game connection, and AES-128-GCM UDP
//! datagrams for the voice itself.
//!
//! Numbers are big-endian, a UUID is 16 bytes (most significant half first),
//! strings and byte arrays are a VarInt length then the bytes.
/// The only version this server speaks.
pub const COMPATIBILITY_VERSION: i32 = 20;
/// Every UDP datagram starts with it.
pub const MAGIC: u8 = 0xFF;
/// The largest Opus frame relayed.
pub const MAX_OPUS: usize = 1275;
/// The largest encrypted body read from a datagram.
pub const MAX_BODY: usize = 2048;
/// Group names and passwords, in characters.
pub const MAX_GROUP_TEXT: usize = 24;
pub const REQUEST_SECRET: &str = "voicechat:request_secret";
pub const SECRET: &str = "voicechat:secret";
pub const UPDATE_STATE: &str = "voicechat:update_state";
pub const STATE: &str = "voicechat:state";
pub const STATES: &str = "voicechat:states";
pub const REMOVE_STATE: &str = "voicechat:remove_state";
pub const CREATE_GROUP: &str = "voicechat:create_group";
pub const SET_GROUP: &str = "voicechat:set_group";
pub const LEAVE_GROUP: &str = "voicechat:leave_group";
pub const JOINED_GROUP: &str = "voicechat:joined_group";
pub const ADD_GROUP: &str = "voicechat:add_group";
pub const REMOVE_GROUP: &str = "voicechat:remove_group";
/// A message that can't be read.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Malformed;
type Read<T> = Result<T, Malformed>;
pub struct In<'a> {
rest: &'a [u8],
}
impl<'a> In<'a> {
pub fn new(data: &'a [u8]) -> Self {
Self { rest: data }
}
pub fn take(&mut self, n: usize) -> Read<&'a [u8]> {
if self.rest.len() < n {
return Err(Malformed);
}
let (head, tail) = self.rest.split_at(n);
self.rest = tail;
Ok(head)
}
fn fixed<const N: usize>(&mut self) -> Read<[u8; N]> {
let mut out = [0; N];
out.copy_from_slice(self.take(N)?);
Ok(out)
}
pub fn byte(&mut self) -> Read<u8> {
Ok(self.take(1)?[0])
}
pub fn flag(&mut self) -> Read<bool> {
Ok(self.byte()? != 0)
}
pub fn short(&mut self) -> Read<i16> {
Ok(i16::from_be_bytes(self.fixed()?))
}
pub fn int(&mut self) -> Read<i32> {
Ok(i32::from_be_bytes(self.fixed()?))
}
pub fn long(&mut self) -> Read<i64> {
Ok(i64::from_be_bytes(self.fixed()?))
}
pub fn uuid(&mut self) -> Read<u128> {
Ok(u128::from_be_bytes(self.fixed()?))
}
pub fn varint(&mut self) -> Read<i32> {
let mut value = 0u32;
for shift in 0..5 {
let b = self.byte()?;
value |= u32::from(b & 0x7F) << (shift * 7);
if b & 0x80 == 0 {
return Ok(value as i32);
}
}
Err(Malformed)
}
/// A VarInt length of at most `max`, then that many bytes.
pub fn bytes(&mut self, max: usize) -> Read<&'a [u8]> {
let n = usize::try_from(self.varint()?).map_err(|_| Malformed)?;
if n > max {
return Err(Malformed);
}
self.take(n)
}
/// A string of at most `max` characters.
pub fn text(&mut self, max: usize) -> Read<String> {
let raw = self.bytes(max.saturating_mul(4))?;
let s = std::str::from_utf8(raw).map_err(|_| Malformed)?;
if s.chars().count() > max {
return Err(Malformed);
}
Ok(s.to_owned())
}
}
#[derive(Default)]
pub struct Out(pub Vec<u8>);
impl Out {
pub fn byte(&mut self, v: u8) -> &mut Self {
self.0.push(v);
self
}
pub fn flag(&mut self, v: bool) -> &mut Self {
self.byte(u8::from(v))
}
pub fn short(&mut self, v: i16) -> &mut Self {
self.0.extend(v.to_be_bytes());
self
}
pub fn int(&mut self, v: i32) -> &mut Self {
self.0.extend(v.to_be_bytes());
self
}
pub fn long(&mut self, v: i64) -> &mut Self {
self.0.extend(v.to_be_bytes());
self
}
pub fn float(&mut self, v: f32) -> &mut Self {
self.0.extend(v.to_be_bytes());
self
}
pub fn double(&mut self, v: f64) -> &mut Self {
self.0.extend(v.to_be_bytes());
self
}
pub fn uuid(&mut self, v: u128) -> &mut Self {
self.0.extend(v.to_be_bytes());
self
}
pub fn varint(&mut self, v: i32) -> &mut Self {
let mut v = v as u32;
while v >= 0x80 {
self.0.push((v as u8) | 0x80);
v >>= 7;
}
self.0.push(v as u8);
self
}
pub fn bytes(&mut self, b: &[u8]) -> &mut Self {
self.varint(b.len() as i32);
self.0.extend_from_slice(b);
self
}
pub fn text(&mut self, s: &str) -> &mut Self {
self.bytes(s.as_bytes())
}
pub fn done(&mut self) -> Vec<u8> {
std::mem::take(&mut self.0)
}
}
// ------------------------------------------------------------ the game connection
/// How a group hears the world.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum GroupKind {
/// Members hear each other anywhere and players nearby; nobody outside hears them.
Normal,
/// Members hear each other anywhere; players nearby hear them and are heard.
Open,
/// Members hear only each other.
Isolated,
}
impl GroupKind {
pub fn from_wire(v: i16) -> Self {
match v {
1 => Self::Open,
2 => Self::Isolated,
_ => Self::Normal,
}
}
pub fn wire(self) -> i16 {
match self {
Self::Normal => 0,
Self::Open => 1,
Self::Isolated => 2,
}
}
}
/// What a client asks on a `voicechat:` channel.
#[derive(Debug, Clone, PartialEq)]
pub enum Request {
Secret {
version: i32,
},
State {
disabled: bool,
},
CreateGroup {
name: String,
password: Option<String>,
kind: GroupKind,
},
JoinGroup {
group: u128,
password: Option<String>,
},
LeaveGroup,
}
impl Request {
/// None: not a channel a client sends on.
pub fn read(channel: &str, data: &[u8]) -> Option<Read<Self>> {
let mut r = In::new(data);
let parsed = match channel {
REQUEST_SECRET => r.int().map(|version| Self::Secret { version }),
UPDATE_STATE => r.flag().map(|disabled| Self::State { disabled }),
CREATE_GROUP => (|| {
let name = r.text(MAX_GROUP_TEXT)?;
let password = if r.flag()? {
Some(r.text(MAX_GROUP_TEXT)?)
} else {
None
};
let kind = GroupKind::from_wire(r.short()?);
Ok(Self::CreateGroup {
name,
password,
kind,
})
})(),
SET_GROUP => (|| {
let group = r.uuid()?;
let password = if r.flag()? {
Some(r.text(MAX_GROUP_TEXT)?)
} else {
None
};
Ok(Self::JoinGroup { group, password })
})(),
LEAVE_GROUP => Ok(Self::LeaveGroup),
_ => return None,
};
Some(parsed)
}
}
/// `voicechat:secret`: what the client needs to open its voice connection.
pub struct SecretInfo<'a> {
pub secret: &'a [u8; 16],
pub port: u16,
pub player: u128,
pub mtu: i32,
pub distance: f64,
pub keep_alive_ms: i32,
pub groups: bool,
/// Empty: the game server's own address.
pub voice_host: &'a str,
}
pub fn secret(s: &SecretInfo) -> Vec<u8> {
let mut o = Out::default();
o.0.extend_from_slice(s.secret);
o.int(i32::from(s.port))
.uuid(s.player)
.byte(0) // the Opus application: VoIP
.int(s.mtu)
.double(s.distance)
.int(s.keep_alive_ms)
.flag(s.groups)
.text(s.voice_host)
.flag(false) // recording not allowed
.done()
}
/// A player's voice chat state, as `voicechat:state` and each of `voicechat:states` carry it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PlayerState {
pub uuid: u128,
pub name: String,
pub disabled: bool,
pub disconnected: bool,
pub group: Option<u128>,
}
fn put_state(o: &mut Out, s: &PlayerState) {
o.flag(s.disabled)
.flag(s.disconnected)
.uuid(s.uuid)
.text(&s.name)
.flag(s.group.is_some());
if let Some(g) = s.group {
o.uuid(g);
}
}
pub fn state(s: &PlayerState) -> Vec<u8> {
let mut o = Out::default();
put_state(&mut o, s);
o.done()
}
pub fn states(all: &[PlayerState]) -> Vec<u8> {
let mut o = Out::default();
o.int(all.len() as i32);
for s in all {
put_state(&mut o, s);
}
o.done()
}
pub fn remove_state(uuid: u128) -> Vec<u8> {
Out::default().uuid(uuid).done()
}
pub fn add_group(id: u128, name: &str, has_password: bool, kind: GroupKind) -> Vec<u8> {
Out::default()
.uuid(id)
.text(name)
.flag(has_password)
.flag(false) // persistent
.flag(false) // hidden
.short(kind.wire())
.done()
}
pub fn remove_group(id: u128) -> Vec<u8> {
Out::default().uuid(id).done()
}
pub fn joined_group(group: Option<u128>, wrong_password: bool) -> Vec<u8> {
let mut o = Out::default();
o.flag(group.is_some());
if let Some(g) = group {
o.uuid(g);
}
o.flag(wrong_password).done()
}
// ------------------------------------------------------------ UDP
/// A voice packet a client sends.
#[derive(Debug, Clone, PartialEq)]
pub enum FromClient {
/// The microphone: an Opus frame (empty: stopped talking).
Mic {
data: Vec<u8>,
sequence: i64,
whispering: bool,
},
Authenticate {
player: u128,
secret: [u8; 16],
},
Ping {
id: u128,
timestamp: i64,
},
KeepAlive,
ConnectionCheck,
}
impl FromClient {
pub fn read(plain: &[u8]) -> Read<Self> {
let mut r = In::new(plain);
Ok(match r.byte()? {
1 => Self::Mic {
data: r.bytes(MAX_OPUS)?.to_vec(),
sequence: r.long()?,
whispering: r.flag()?,
},
5 => Self::Authenticate {
player: r.uuid()?,
secret: r.fixed()?,
},
7 => Self::Ping {
id: r.uuid()?,
timestamp: r.long()?,
},
8 => Self::KeepAlive,
9 => Self::ConnectionCheck,
_ => return Err(Malformed),
})
}
}
/// A voice packet this server sends.
#[derive(Debug, Clone, PartialEq)]
pub enum ToClient<'a> {
/// A voice heard where `sender` stands.
Positional {
sender: u128,
data: &'a [u8],
sequence: i64,
distance: f32,
whispering: bool,
},
/// A group member's voice, heard everywhere.
Group {
sender: u128,
data: &'a [u8],
sequence: i64,
},
AuthenticateAck,
Ping {
id: u128,
timestamp: i64,
},
KeepAlive,
ConnectionCheckAck,
}
impl ToClient<'_> {
pub fn write(&self) -> Vec<u8> {
let mut o = Out::default();
match self {
// The channel is the sender: the client places a positional voice at that entity.
Self::Positional {
sender,
data,
sequence,
distance,
whispering,
} => o
.byte(2)
.uuid(*sender)
.uuid(*sender)
.bytes(data)
.long(*sequence)
.float(*distance)
.flag(*whispering),
Self::Group {
sender,
data,
sequence,
} => o
.byte(3)
.uuid(*sender)
.uuid(*sender)
.bytes(data)
.long(*sequence)
.byte(0),
Self::AuthenticateAck => o.byte(6),
Self::Ping { id, timestamp } => o.byte(7).uuid(*id).long(*timestamp),
Self::KeepAlive => o.byte(8),
Self::ConnectionCheckAck => o.byte(10),
};
o.done()
}
}
/// AES-128-GCM keyed by a player's secret: a random 12-byte nonce, then the
/// ciphertext and its 16-byte tag.
pub struct Cipher {
key: ring::aead::LessSafeKey,
}
impl Cipher {
pub fn new(secret: &[u8; 16]) -> Self {
let key =
ring::aead::UnboundKey::new(&ring::aead::AES_128_GCM, secret).expect("a 16-byte key");
Self {
key: ring::aead::LessSafeKey::new(key),
}
}
pub fn seal(&self, rng: &dyn ring::rand::SecureRandom, plain: &[u8]) -> Option<Vec<u8>> {
let mut nonce = [0u8; 12];
rng.fill(&mut nonce).ok()?;
let mut body = plain.to_vec();
self.key
.seal_in_place_append_tag(
ring::aead::Nonce::assume_unique_for_key(nonce),
ring::aead::Aad::empty(),
&mut body,
)
.ok()?;
let mut out = nonce.to_vec();
out.extend(body);
Some(out)
}
pub fn open(&self, sealed: &[u8]) -> Option<Vec<u8>> {
if sealed.len() < 12 + 16 {
return None;
}
let (nonce, body) = sealed.split_at(12);
let nonce = ring::aead::Nonce::try_assume_unique_for_key(nonce).ok()?;
let mut body = body.to_vec();
let plain = self
.key
.open_in_place(nonce, ring::aead::Aad::empty(), &mut body)
.ok()?;
Some(plain.to_vec())
}
}
/// A client's datagram: magic, the player's UUID (in clear, to find their key), the sealed packet.
pub fn client_datagram(datagram: &[u8]) -> Read<(u128, &[u8])> {
let mut r = In::new(datagram);
if r.byte()? != MAGIC {
return Err(Malformed);
}
let player = r.uuid()?;
Ok((player, r.bytes(MAX_BODY)?))
}
/// A server datagram: magic, then the sealed packet.
pub fn server_datagram(sealed: &[u8]) -> Vec<u8> {
Out::default().byte(MAGIC).bytes(sealed).done()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn varints_match_minecraft() {
for (v, bytes) in [
(0, vec![0]),
(127, vec![0x7F]),
(128, vec![0x80, 1]),
(300, vec![0xAC, 2]),
(-1, vec![0xFF, 0xFF, 0xFF, 0xFF, 0x0F]),
] {
assert_eq!(Out::default().varint(v).done(), bytes, "{v}");
assert_eq!(In::new(&bytes).varint().unwrap(), v);
}
assert_eq!(In::new(&[0x80; 6]).varint(), Err(Malformed));
}
#[test]
fn requests_read_their_layouts() {
assert_eq!(
Request::read(REQUEST_SECRET, &[0, 0, 0, 20]),
Some(Ok(Request::Secret { version: 20 }))
);
assert_eq!(
Request::read(UPDATE_STATE, &[1]),
Some(Ok(Request::State { disabled: true }))
);
// name "ab", a password "x", kind open.
assert_eq!(
Request::read(CREATE_GROUP, &[2, b'a', b'b', 1, 1, b'x', 0, 1]),
Some(Ok(Request::CreateGroup {
name: "ab".into(),
password: Some("x".into()),
kind: GroupKind::Open
}))
);
let mut join = 7u128.to_be_bytes().to_vec();
join.push(0);
assert_eq!(
Request::read(SET_GROUP, &join),
Some(Ok(Request::JoinGroup {
group: 7,
password: None
}))
);
assert_eq!(
Request::read(LEAVE_GROUP, &[]),
Some(Ok(Request::LeaveGroup))
);
assert_eq!(
Request::read("voicechat:secret", &[]),
None,
"a server's channel"
);
let long = [&[30u8][..], &[b'n'; 30]].concat();
assert_eq!(
Request::read(CREATE_GROUP, &[&long[..], &[0, 0, 0]].concat()),
Some(Err(Malformed))
);
assert_eq!(Request::read(REQUEST_SECRET, &[0, 0]), Some(Err(Malformed)));
}
#[test]
fn secret_layout() {
let key = [9u8; 16];
let bytes = secret(&SecretInfo {
secret: &key,
port: 24454,
player: 0xAB,
mtu: 1024,
distance: 48.0,
keep_alive_ms: 1000,
groups: true,
voice_host: "",
});
// key, port, uuid, codec, mtu, distance, keep-alive, groups, "" , recording
assert_eq!(bytes.len(), 16 + 4 + 16 + 1 + 4 + 8 + 4 + 1 + 1 + 1);
assert_eq!(&bytes[..16], &key);
assert_eq!(&bytes[16..20], &24454i32.to_be_bytes());
assert_eq!(&bytes[20..36], &0xABu128.to_be_bytes());
assert_eq!(&bytes[41..49], &48.0f64.to_be_bytes());
assert_eq!(bytes[53], 1);
}
#[test]
fn states_and_groups_layouts() {
let a = PlayerState {
uuid: 1,
name: "a".into(),
disabled: true,
disconnected: false,
group: Some(2),
};
assert_eq!(
state(&a),
[
&[1u8, 0][..],
&1u128.to_be_bytes(),
&[1, b'a', 1],
&2u128.to_be_bytes()
]
.concat()
);
let both = states(&[
a.clone(),
PlayerState {
group: None,
..a.clone()
},
]);
assert_eq!(&both[..4], &2i32.to_be_bytes());
assert_eq!(both.len(), 4 + 37 + 21);
let g = add_group(5, "Base", true, GroupKind::Isolated);
assert_eq!(
g,
[
&5u128.to_be_bytes()[..],
&[4, b'B', b'a', b's', b'e', 1, 0, 0, 0, 2]
]
.concat()
);
assert_eq!(joined_group(None, true), vec![0, 1]);
assert_eq!(
joined_group(Some(3), false),
[&[1u8][..], &3u128.to_be_bytes(), &[0]].concat()
);
}
#[test]
fn voice_packets() {
let mic = [&[1u8, 2, 0xAA, 0xBB][..], &5i64.to_be_bytes(), &[1]].concat();
assert_eq!(
FromClient::read(&mic),
Ok(FromClient::Mic {
data: vec![0xAA, 0xBB],
sequence: 5,
whispering: true
})
);
let auth = [&[5u8][..], &7u128.to_be_bytes(), &[3; 16]].concat();
assert_eq!(
FromClient::read(&auth),
Ok(FromClient::Authenticate {
player: 7,
secret: [3; 16]
})
);
assert_eq!(FromClient::read(&[8]), Ok(FromClient::KeepAlive));
assert_eq!(FromClient::read(&[9]), Ok(FromClient::ConnectionCheck));
assert_eq!(FromClient::read(&[2]), Err(Malformed), "a server's packet");
let big = [
&[1u8][..],
&Out::default().varint(1276).done(),
&[0; 1276],
&[0; 9],
]
.concat();
assert_eq!(FromClient::read(&big), Err(Malformed));
let sound = ToClient::Positional {
sender: 4,
data: &[1, 2],
sequence: 9,
distance: 48.0,
whispering: false,
}
.write();
assert_eq!(sound[0], 2);
assert_eq!(&sound[1..17], &4u128.to_be_bytes());
assert_eq!(&sound[17..33], &4u128.to_be_bytes());
assert_eq!(&sound[33..36], &[2, 1, 2]);
assert_eq!(&sound[36..44], &9i64.to_be_bytes());
assert_eq!(&sound[44..48], &48.0f32.to_be_bytes());
assert_eq!(sound[48], 0);
assert_eq!(sound.len(), 49);
let group = ToClient::Group {
sender: 4,
data: &[1],
sequence: 2,
}
.write();
assert_eq!(
(group[0], group.len(), group[group.len() - 1]),
(3, 1 + 32 + 2 + 8 + 1, 0)
);
assert_eq!(ToClient::AuthenticateAck.write(), vec![6]);
assert_eq!(ToClient::ConnectionCheckAck.write(), vec![10]);
}
#[test]
fn sealed_bodies_round_trip_and_reject_tampering() {
let rng = ring::rand::SystemRandom::new();
let c = Cipher::new(&[1; 16]);
let sealed = c.seal(&rng, b"hello").unwrap();
assert_eq!(sealed.len(), 12 + 5 + 16);
assert_eq!(c.open(&sealed).unwrap(), b"hello");
let mut bad = sealed.clone();
bad[14] ^= 1;
assert_eq!(c.open(&bad), None);
assert_eq!(Cipher::new(&[2; 16]).open(&sealed), None);
assert_ne!(
c.seal(&rng, b"hello").unwrap(),
sealed,
"fresh nonce each time"
);
let datagram = [
&[MAGIC][..],
&9u128.to_be_bytes(),
&Out::default().bytes(&sealed).done(),
]
.concat();
assert_eq!(client_datagram(&datagram), Ok((9, &sealed[..])));
assert_eq!(client_datagram(&datagram[1..]), Err(Malformed));
assert_eq!(server_datagram(&[1, 2])[..], [MAGIC, 2, 1, 2]);
}
}
@@ -0,0 +1,653 @@
//! The relay with real UDP sockets on localhost: clients that speak the
//! wire format the way a voice chat client does, written from the layouts.
use super::*;
use proto::{In, Out};
const A: u128 = 0xA;
const B: u128 = 0xB;
const FAR: u128 = 0xC;
const NETHER: u128 = 0xD;
const PLAIN: u128 = 0xE;
fn player(uuid: u128, name: &str, dimension: &str, x: f64) -> Player {
Player {
uuid,
name: name.into(),
dimension: dimension.into(),
position: [x, 64.0, 0.0],
}
}
fn world() -> Vec<Player> {
vec![
player(A, "alice", "minecraft:overworld", 0.0),
player(B, "bob", "minecraft:overworld", 10.0),
player(FAR, "carol", "minecraft:overworld", 500.0),
player(NETHER, "dave", "minecraft:the_nether", 0.0),
// Joined without a voice chat client: never asks, never told.
player(PLAIN, "erin", "minecraft:overworld", 5.0),
]
}
fn server() -> Server {
server_with(Config::default())
}
fn server_with(config: Config) -> Server {
let s = Server::start(Config {
bind: "127.0.0.1".into(),
port: 0,
..config
})
.unwrap();
s.tick(world());
s
}
/// The messages for `who` among `all`.
fn for_player(all: &[Message], who: u128) -> Vec<(String, Vec<u8>)> {
all.iter()
.filter(|m| m.0 == who)
.map(|m| (m.1.clone(), m.2.clone()))
.collect()
}
struct Voice {
uuid: u128,
secret: [u8; 16],
cipher: proto::Cipher,
socket: UdpSocket,
server: SocketAddr,
rng: SystemRandom,
/// The datagrams of its handshake: authenticate, connection check.
handshake: Vec<Vec<u8>>,
}
impl Voice {
/// Reads the secret the server gave, then authenticates and checks the connection.
fn connect(uuid: u128, secret_message: &[u8]) -> Self {
let mut r = In::new(secret_message);
let secret: [u8; 16] = r.take(16).unwrap().try_into().unwrap();
let port = r.int().unwrap() as u16;
assert_eq!(r.uuid().unwrap(), uuid);
let socket = UdpSocket::bind("127.0.0.1:0").unwrap();
socket
.set_read_timeout(Some(Duration::from_millis(300)))
.unwrap();
let mut v = Self {
uuid,
secret,
cipher: proto::Cipher::new(&secret),
socket,
server: SocketAddr::from(([127, 0, 0, 1], port)),
rng: SystemRandom::new(),
handshake: Vec::new(),
};
let auth = v.send(
&Out::default()
.byte(5)
.uuid(uuid)
.done()
.into_iter()
.chain(secret)
.collect::<Vec<_>>(),
);
assert_eq!(v.next(|p| p == [6]), Some(vec![6]), "authenticated");
let check = v.send(&[9]);
assert_eq!(v.next(|p| p == [10]), Some(vec![10]), "connection checked");
v.handshake = vec![auth, check];
v
}
/// Sends a sealed packet; returns the datagram.
fn send(&self, plain: &[u8]) -> Vec<u8> {
let sealed = self.cipher.seal(&self.rng, plain).unwrap();
let datagram = Out::default()
.byte(proto::MAGIC)
.uuid(self.uuid)
.bytes(&sealed)
.done();
self.socket.send_to(&datagram, self.server).unwrap();
datagram
}
/// Answers keep-alives for a while, as a live client does.
fn idle(&self, for_ms: u64) {
let until = Instant::now() + Duration::from_millis(for_ms);
while Instant::now() < until {
self.next(|_| false);
}
}
fn talk(&self, sequence: i64, whispering: bool) -> Vec<u8> {
self.send(
&Out::default()
.byte(1)
.bytes(&[0xAB; 40])
.long(sequence)
.flag(whispering)
.done(),
)
}
/// The next packet `wanted` accepts, skipping keep-alives (answered) and others; None after a quiet while.
fn next(&self, wanted: impl Fn(&[u8]) -> bool) -> Option<Vec<u8>> {
let mut buf = [0u8; 4096];
let until = Instant::now() + Duration::from_millis(900);
while Instant::now() < until {
let Ok((n, _)) = self.socket.recv_from(&mut buf) else {
continue;
};
let mut r = In::new(&buf[..n]);
assert_eq!(r.byte().unwrap(), proto::MAGIC);
let plain = self
.cipher
.open(r.bytes(4096).unwrap())
.expect("sealed with my key");
if plain == [8] {
self.send(&[8]);
}
if wanted(&plain) {
return Some(plain);
}
}
None
}
fn hears(&self) -> Option<Vec<u8>> {
self.next(|p| p[0] == 2 || p[0] == 3)
}
}
fn ask_secret(s: &Server, who: u128) -> Vec<u8> {
s.message(who, proto::REQUEST_SECRET, &20i32.to_be_bytes());
let out = s.outgoing(64);
for_player(&out, who)
.into_iter()
.find(|(c, _)| c == proto::SECRET)
.expect("a secret")
.1
}
#[test]
fn only_asking_clients_hear_of_voice_chat() {
let s = server();
assert!(s.outgoing(64).is_empty(), "nothing before anyone asks");
// Wrong version: no answer.
s.message(A, proto::REQUEST_SECRET, &21i32.to_be_bytes());
assert!(s.outgoing(64).is_empty());
s.message(A, proto::REQUEST_SECRET, &20i32.to_be_bytes());
s.tick(world());
let out = s.outgoing(64);
assert!(out.iter().all(|m| m.0 == A), "{out:?}");
let channels: Vec<String> = for_player(&out, A).into_iter().map(|m| m.0).collect();
assert_eq!(channels, [proto::SECRET, proto::STATES, proto::STATE]);
// A message from someone not on the server, or garbage, is ignored.
s.message(0xF, proto::REQUEST_SECRET, &20i32.to_be_bytes());
s.message(B, proto::CREATE_GROUP, &[200]);
s.tick(world());
assert!(s.outgoing(64).is_empty());
}
#[test]
fn voices_reach_who_is_near_in_the_same_dimension() {
let s = server();
let voices: Vec<Voice> = [A, B, FAR, NETHER]
.iter()
.map(|&u| Voice::connect(u, &ask_secret(&s, u)))
.collect();
let [a, b, far, nether] = &voices[..] else {
unreachable!()
};
s.tick(world());
let states = s.outgoing(64);
assert!(
for_player(&states, PLAIN).is_empty(),
"the player without voice chat gets nothing"
);
a.talk(0, false);
let heard = b.hears().expect("bob, 10 blocks away, hears alice");
assert_eq!(heard[0], 2, "positional");
assert_eq!(&heard[1..17], &A.to_be_bytes(), "at alice");
assert_eq!(&heard[33..34], &[40]);
assert_eq!(
&heard[heard.len() - 13..heard.len() - 5],
&0i64.to_be_bytes()
);
assert_eq!(far.hears(), None, "500 blocks away");
assert_eq!(nether.hears(), None, "another dimension");
assert_eq!(a.hears(), None, "not your own voice");
// A whisper carries 24 blocks: bob at 10 still hears it; at 30 he doesn't.
a.talk(1, true);
assert_eq!(
b.hears().map(|p| p[p.len() - 1]),
Some(1),
"whispering flag"
);
let mut moved = world();
moved[1].position[0] = 30.0;
s.tick(moved);
a.talk(2, true);
assert_eq!(b.hears(), None);
a.talk(3, false);
assert!(b.hears().is_some());
s.tick(world());
// Voice chat off: bob neither hears nor is heard.
s.message(B, proto::UPDATE_STATE, &[1]);
a.talk(4, false);
assert_eq!(b.hears(), None);
b.talk(0, false);
assert_eq!(a.hears(), None);
s.message(B, proto::UPDATE_STATE, &[0]);
a.talk(5, false);
assert!(b.hears().is_some());
}
#[test]
fn groups_follow_their_kind() {
let s = server();
let a = Voice::connect(A, &ask_secret(&s, A));
let b = Voice::connect(B, &ask_secret(&s, B));
let far = Voice::connect(FAR, &ask_secret(&s, FAR));
s.tick(world());
s.outgoing(64);
// Alice makes an isolated group with a password; carol joins it from 500 blocks.
s.message(
A,
proto::CREATE_GROUP,
&Out::default()
.text("Base")
.flag(true)
.text("pw")
.short(2)
.done(),
);
let out = s.outgoing(64);
let add = for_player(&out, FAR)
.into_iter()
.find(|m| m.0 == proto::ADD_GROUP)
.expect("everyone sees the group")
.1;
let id = In::new(&add).uuid().unwrap();
let joined = for_player(&out, A)
.into_iter()
.find(|m| m.0 == proto::JOINED_GROUP)
.unwrap()
.1;
assert_eq!(joined, proto::joined_group(Some(id), false));
s.message(
FAR,
proto::SET_GROUP,
&Out::default().uuid(id).flag(true).text("nope").done(),
);
let out = s.outgoing(64);
assert_eq!(
for_player(&out, FAR),
vec![(
proto::JOINED_GROUP.to_owned(),
proto::joined_group(None, true)
)]
);
s.message(
FAR,
proto::SET_GROUP,
&Out::default().uuid(id).flag(true).text("pw").done(),
);
s.tick(world());
s.outgoing(64);
a.talk(0, false);
assert_eq!(
far.hears().map(|p| p[0]),
Some(3),
"a group voice, from anywhere"
);
assert_eq!(b.hears(), None, "isolated: bob nearby doesn't hear alice");
b.talk(0, false);
assert_eq!(a.hears(), None, "isolated: alice doesn't hear bob");
// Both leave: the empty group goes away for everyone.
s.message(A, proto::LEAVE_GROUP, &[]);
s.message(FAR, proto::LEAVE_GROUP, &[]);
let out = s.outgoing(64);
assert!(
for_player(&out, B).contains(&(proto::REMOVE_GROUP.to_owned(), proto::remove_group(id)))
);
a.talk(1, false);
assert_eq!(b.hears().map(|p| p[0]), Some(2), "back to proximity");
// An open group: members are heard nearby, and hear who is nearby.
s.message(
A,
proto::CREATE_GROUP,
&Out::default().text("Open").flag(false).short(1).done(),
);
s.tick(world());
s.outgoing(64);
a.talk(2, false);
assert_eq!(b.hears().map(|p| p[0]), Some(2));
b.talk(1, false);
assert_eq!(a.hears().map(|p| p[0]), Some(2));
// A normal one: members aren't heard outside, but hear who is nearby.
s.message(
A,
proto::CREATE_GROUP,
&Out::default().text("Normal").flag(false).short(0).done(),
);
s.tick(world());
s.outgoing(64);
a.talk(3, false);
assert_eq!(b.hears(), None);
b.talk(2, false);
assert_eq!(a.hears().map(|p| p[0]), Some(2));
}
#[test]
fn leaving_and_strangers() {
let s = server();
let a = Voice::connect(A, &ask_secret(&s, A));
let b = Voice::connect(B, &ask_secret(&s, B));
s.tick(world());
s.outgoing(64);
// Someone without the key can't speak as alice, nor take her address.
let stranger = UdpSocket::bind("127.0.0.1:0").unwrap();
let fake = proto::Cipher::new(&[0; 16])
.seal(&SystemRandom::new(), &[1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 9, 0])
.unwrap();
stranger
.send_to(
&Out::default()
.byte(proto::MAGIC)
.uuid(A)
.bytes(&fake)
.done(),
b.server,
)
.unwrap();
assert_eq!(b.hears(), None);
// A copy of alice's own handshake from elsewhere, while she's active, is refused.
let replay = a
.cipher
.seal(
&a.rng,
&Out::default()
.byte(5)
.uuid(A)
.done()
.into_iter()
.chain(a.secret)
.collect::<Vec<_>>(),
)
.unwrap();
stranger
.set_read_timeout(Some(Duration::from_millis(300)))
.unwrap();
stranger
.send_to(
&Out::default()
.byte(proto::MAGIC)
.uuid(A)
.bytes(&replay)
.done(),
b.server,
)
.unwrap();
let mut buf = [0u8; 64];
assert!(
stranger.recv_from(&mut buf).is_err(),
"no answer to a stranger"
);
a.talk(0, false);
assert!(
b.hears().is_some(),
"alice still talks from her own address"
);
// Bob leaves the game: alice is told, and his voice is gone.
let mut without_bob = world();
without_bob.retain(|p| p.uuid != B);
s.tick(without_bob);
let out = s.outgoing(64);
assert_eq!(
for_player(&out, A),
vec![(proto::REMOVE_STATE.to_owned(), proto::remove_state(B))]
);
b.talk(5, false);
assert_eq!(a.hears(), None);
s.left(A);
assert!(s.outgoing(64).is_empty());
}
#[test]
fn outgoing_is_paced_per_player() {
let s = server();
ask_secret(&s, A);
for i in 0..10 {
s.message(
A,
proto::CREATE_GROUP,
&Out::default()
.text(&format!("g{i}"))
.flag(false)
.short(0)
.done(),
);
}
let first = s.outgoing(4);
assert_eq!(first.len(), 4);
let mut rest = 0;
loop {
let more = s.outgoing(4);
if more.is_empty() {
break;
}
assert!(more.len() <= 4);
rest += more.len();
}
// Each group: its add and alice's joined (the old one, emptied, is removed).
assert_eq!(4 + rest, 10 * 2 + 9);
}
#[test]
fn config_defaults_fill_in() {
let c: Config = serde_json::from_str(r#"{"port": 25565}"#).unwrap();
assert_eq!((c.port, c.distance, c.enabled), (25565, 48.0, true));
assert!(
Server::start(Config {
enabled: false,
..Config::default()
})
.is_err()
);
}
/// A socket elsewhere that copies datagrams it saw and sends them as its own.
fn stranger() -> UdpSocket {
let socket = UdpSocket::bind("127.0.0.1:0").unwrap();
socket
.set_read_timeout(Some(Duration::from_millis(300)))
.unwrap();
socket
}
fn answered(socket: &UdpSocket) -> bool {
let mut buf = [0u8; 4096];
socket.recv_from(&mut buf).is_ok()
}
#[test]
fn a_replayed_handshake_after_a_silence_moves_nothing() {
let s = server();
let a = Voice::connect(A, &ask_secret(&s, A));
let b = Voice::connect(B, &ask_secret(&s, B));
s.tick(world());
s.outgoing(64);
// Alice says nothing for longer than the old 5 s move rule (but less than
// the 10 s after which she counts as lost).
std::thread::sleep(Duration::from_millis(5300));
let eve = stranger();
for datagram in &a.handshake {
eve.send_to(datagram, a.server).unwrap();
assert!(!answered(&eve), "no answer to a copied handshake");
}
b.talk(0, false);
assert!(a.hears().is_some(), "bob's voice still goes to alice");
assert!(!answered(&eve), "and not to the copier");
// Exact copies from alice's own address are dropped too.
a.socket.send_to(&a.handshake[1], a.server).unwrap();
assert_eq!(a.next(|p| p == [10]), None, "a copied check isn't answered");
let said = a.talk(0, false);
assert!(b.hears().is_some());
a.socket.send_to(&said, a.server).unwrap();
assert_eq!(b.hears(), None, "a copied voice packet isn't relayed twice");
a.talk(1, false);
assert!(b.hears().is_some(), "the next one is");
// Once her handshake has left the nonces remembered, the secret still
// belongs to her address alone.
for _ in 0..SEEN_NONCES + 4 {
a.send(&[8]);
}
a.idle(200);
for datagram in &a.handshake {
eve.send_to(datagram, a.server).unwrap();
assert!(!answered(&eve), "no answer to an old copy either");
}
b.talk(1, false);
assert!(a.hears().is_some());
assert!(!answered(&eve));
}
#[test]
fn a_client_the_server_stops_hearing_gets_a_fresh_secret() {
let s = server_with(Config {
// Lost after 3 s: bob, who answers keep-alives only while a test
// reads his socket, must not count as lost too.
keep_alive_ms: 300,
..Config::default()
});
let a = Voice::connect(A, &ask_secret(&s, A));
let b = Voice::connect(B, &ask_secret(&s, B));
s.message(
A,
proto::CREATE_GROUP,
&Out::default().text("Open").flag(false).short(1).done(),
);
s.tick(world());
let out = s.outgoing(64);
let group = for_player(&out, A)
.into_iter()
.find(|m| m.0 == proto::ADD_GROUP)
.map(|m| In::new(&m.1).uuid().unwrap())
.unwrap();
// Alice's NAT rebinds: the server hears nothing from her for 10 keep-alives.
b.idle(3500);
let out = s.outgoing(64);
let to_alice = for_player(&out, A);
assert_eq!(
to_alice.iter().map(|m| m.0.as_str()).collect::<Vec<_>>(),
[proto::SECRET],
"only a new secret, over the game connection"
);
let fresh = &to_alice[0].1;
assert_ne!(&fresh[..16], &a.secret, "a new key");
// The old key opens nothing now, from anywhere.
let eve = stranger();
eve.send_to(&a.handshake[0], a.server).unwrap();
assert!(!answered(&eve));
a.send(&[9]);
assert_eq!(a.next(|p| p == [10]), None);
// From her new address with the new secret, she is back, in her group.
let moved = Voice::connect(A, fresh);
s.tick(world());
let out = s.outgoing(64);
let alice = proto::state(&PlayerState {
uuid: A,
name: "alice".into(),
disabled: false,
disconnected: false,
group: Some(group),
});
assert!(
for_player(&out, B).contains(&(proto::STATE.to_owned(), alice)),
"{out:?}"
);
b.talk(0, false);
assert!(
moved.hears().is_some(),
"bob's voice reaches her new address"
);
assert_eq!(a.hears(), None, "and not the old one");
moved.talk(0, false);
assert!(b.hears().is_some());
}
#[test]
fn repeated_secret_requests_and_group_changes_stay_bounded() {
let s = server();
// 40 players, each in a group of their own.
let many: Vec<Player> = (0..40u128)
.map(|i| player(0x100 + i, &format!("p{i}"), "minecraft:overworld", 0.0))
.collect();
s.tick(many.clone());
for p in &many {
ask_secret(&s, p.uuid);
s.message(
p.uuid,
proto::CREATE_GROUP,
&Out::default().text(&p.name).flag(false).short(0).done(),
);
}
s.tick(many.clone());
s.outgoing(usize::MAX);
// A thousand requests in a row: answered once now, once more a second later.
let first = many[0].uuid;
for _ in 0..1000 {
s.message(first, proto::REQUEST_SECRET, &20i32.to_be_bytes());
}
let count = |out: &[Message], channel: &str| {
out.iter()
.filter(|m| m.0 == first && m.1 == channel)
.count()
};
let out = s.outgoing(usize::MAX);
assert_eq!(count(&out, proto::SECRET), 0, "too soon after the last one");
std::thread::sleep(SECRET_EVERY + Duration::from_millis(50));
s.tick(many.clone());
let out = s.outgoing(usize::MAX);
assert_eq!(count(&out, proto::SECRET), 1);
// Starting over, it left its own group, which went away.
assert_eq!(count(&out, proto::ADD_GROUP), 39, "the groups once");
let mine: Vec<String> = for_player(&out, first).into_iter().map(|m| m.0).collect();
assert!(mine.len() <= 1 + 1 + 39 + 1, "{mine:?}");
s.tick(many.clone());
assert_eq!(count(&s.outgoing(usize::MAX), proto::SECRET), 0);
// Group changes: a burst of 10, then 2 a second.
let second = many[1].uuid;
for i in 0..100 {
s.message(
second,
proto::CREATE_GROUP,
&Out::default()
.text(&format!("g{i}"))
.flag(false)
.short(0)
.done(),
);
}
let out = s.outgoing(usize::MAX);
let joined = out
.iter()
.filter(|m| m.0 == second && m.1 == proto::JOINED_GROUP)
.count();
// Its bucket refilled during the wait: 10 of the 100.
assert_eq!(joined, OPS_BURST as usize);
}
@@ -0,0 +1,13 @@
// The end of each server tick: the relay learns where everyone is, and its
// messages leave for the players' clients.
fn voicechat_server_tick(
game: &mut crate::game::Game<'_>,
phase: crate::game::native_lifecycle::ServerTickPhase,
) {
if matches!(
phase,
crate::game::native_lifecycle::ServerTickPhase::ServerTickEnd { .. }
) {
crate::game::voicechat_server::tick(game);
}
}
+1 -1
View File
@@ -4,5 +4,5 @@
"horizon_repository": "https://github.com/horizon-rust/horizon",
"horizon_revision": "6f8c7a0e5d531d3834c4d0be4659a1db8e7e5c2a",
"horizon_mixins_repository": "https://github.com/horizon-rust/horizon-mixins",
"horizon_mixins_revision": "0d6b103c70633d8fd9443d8d0acd3aadaacedb06"
"horizon_mixins_revision": "d9c83d7096b7ea5b83678da2efdfd6161f74997e"
}