Dedicated server: horizon-clients-only lets only the Horizon client join

Off by default. With horizon-clients-only=true in server.properties (server.toml's [properties]), a
client whose minecraft:brand isn't "horizon" (vanilla's client says "vanilla"; one that names none
counts as vanilla, as on vanilla's server) is disconnected during configuration with "This server is
for the Horizon client: playhorizon.dev", and the console logs which client it was. The brand can be
spoofed: this is a gate for players who didn't install Horizon, not security.

horizon-bots gets --brand and prints a configuration disconnect's reason;
tools/server_horizon_only_e2e.py checks both settings on a real dedicated server.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Ludovic MarieandClaude Opus 5.5 committed 2026-10-30 18:32:25 +01:00
1 parent 9ab3cee2e8
commit 7a4f7d5c99
6 files changed
+173 -2

No files matched your search

+21 -2
View File
@@ -67,6 +67,7 @@ horizon-bots: load-test bots for a Minecraft Java Edition 26.3 server (offline m
--seed N default 12345
--explore-speed B blocks a second an explore bot flies; default 22 (as fast as chunks come)
--name PREFIX bot names: PREFIX0, PREFIX1...; default Bot
--brand B the client brand sent in configuration (minecraft:brand); default none
--report FILE a JSON summary at the end (- prints it after the per-second lines)
";
@@ -120,6 +121,8 @@ struct Options {
flood: usize,
no_read: bool,
name: String,
/// The `minecraft:brand` to send, if any.
brand: Option<String>,
report: Option<String>,
}
@@ -138,6 +141,7 @@ fn options() -> Result<Options, String> {
no_read: false,
name: "Bot".into(),
report: None,
brand: None,
};
let mut args = std::env::args().skip(1);
while let Some(arg) = args.next() {
@@ -169,6 +173,7 @@ fn options() -> Result<Options, String> {
"--flood" => o.flood = value.parse().map_err(|_| number("packets a tick"))?,
"--name" => o.name = value,
"--report" => o.report = Some(value),
"--brand" => o.brand = Some(value),
_ => return Err(format!("unknown option {arg}; --help lists them")),
}
}
@@ -398,6 +403,19 @@ fn join(o: &Options, name: &str) -> Result<Connection, String> {
.encode(),
)
.map_err(err)?;
// ClientBrandRetriever: vanilla's client says "vanilla", Horizon's "horizon".
if let Some(brand) = &o.brand {
let mut w = Writer::new();
w.string(brand);
c.send(
&configuration::Serverbound::CustomPayload(configuration::CustomPayload {
channel: "minecraft:brand".into(),
data: w.data,
})
.encode(),
)
.map_err(err)?;
}
loop {
let (id, body) = c.recv().map_err(err)?;
let reply = match id {
@@ -425,8 +443,9 @@ fn join(o: &Options, name: &str) -> Result<Connection, String> {
payload: None,
})
}
configuration::Clientbound::Disconnect(_) => {
return Err("disconnected during configuration".into());
configuration::Clientbound::Disconnect(p) => {
let reason = horizon_protocol::text::plain(&p.reason, &Default::default());
return Err(format!("disconnected during configuration: {reason}"));
}
configuration::Clientbound::FinishConfiguration(_) => {
c.send(&configuration::Serverbound::FinishConfiguration(Empty).encode())
@@ -734,6 +734,10 @@ impl Game<'_> {
// DedicatedServer.serverLinks: `bug-report-link` as the only link
// (ServerLinks.KnownLinkType.BUG_REPORT is id 0).
levels: self.level_shapes,
horizon_clients_only: props
.raw
.get(horizon_client::lan::HORIZON_CLIENTS_ONLY)
.is_some_and(|v| v.trim().eq_ignore_ascii_case("true")),
server_links: if props.bug_report_link.is_empty() {
Vec::new()
} else if super::server_links::valid_untrusted_uri(&props.bug_report_link) {
+15
View File
@@ -861,6 +861,21 @@ fn login_and_configure(
_ => {}
}
};
// horizon-clients-only: the brand the client sent ("vanilla" when it sent
// none) must be Horizon's.
if shared.config.horizon_clients_only && brand != "horizon" {
let text = super::HORIZON_CLIENTS_ONLY_REASON;
let reason = horizon_world::nbt::Tag::String(horizon_world::nbt::JavaString::from(text));
let _ = send(c, cfg::Clientbound::Disconnect(cfg::Disconnect { reason }));
if let Some(log) = &shared.config.log {
let line = format!(
"Disconnecting {name}: its client is \"{brand}\", not Horizon (horizon-clients-only)"
);
(log.write)("Server thread", "INFO", &line);
}
lost(shared, &format!("{name} ({})", uuid_string(uuid)), text);
return Ok(None);
}
// A Horizon client hears that this server runs its mods' quiet commands
// (horizon_protocol::quiet); a vanilla one is told nothing.
if brand == "horizon" {
+11
View File
@@ -132,6 +132,12 @@ pub struct NetLog {
pub log_ips: bool,
}
/// The server.properties key that lets only the Horizon client join (off unless "true").
pub const HORIZON_CLIENTS_ONLY: &str = "horizon-clients-only";
/// What another client is told.
pub const HORIZON_CLIENTS_ONLY_REASON: &str =
"This server is for the Horizon client: playhorizon.dev";
/// How the server side of the network behaves: an integrated server's LAN
/// uses the defaults, a dedicated server takes them from server.properties.
#[derive(Clone)]
@@ -173,6 +179,10 @@ pub struct NetConfig {
/// sent in configuration when non-empty
/// (ServerConfigurationPacketListenerImpl.startConfiguration).
pub server_links: Vec<horizon_protocol::packets::configuration::ServerLink>,
/// `horizon-clients-only` (Horizon's own server.properties key, off): a
/// client whose `minecraft:brand` isn't Horizon's is turned away during
/// configuration. A brand can be spoofed: a gate, not security.
pub horizon_clients_only: bool,
/// The shapes of the world's levels: their dimension types go to
/// clients when the world's data packs changed them.
pub levels: crate::chunk::LevelShapes,
@@ -204,6 +214,7 @@ impl NetConfig {
log: None,
resource_pack: None,
server_links: Vec::new(),
horizon_clients_only: false,
levels: crate::chunk::LevelShapes::VANILLA,
}
}
+5
View File
@@ -221,6 +221,11 @@ console commands do. While it's stopped, they edit vanilla's files directly. Any
To generate the world around spawn ahead of the players, Horizon has its own `pregen` command, off until
`server.toml` has `horizon-pregen = true` under `[properties]`: `horizon-server cmd pregen start 2500`
([pregeneration](pregeneration.md)).
To let only the Horizon client join, set `horizon-clients-only = true` under `[properties]` (off by default).
Other clients are turned away while they connect with "This server is for the Horizon client:
playhorizon.dev", and the console says which client it was. The check reads the brand a client names itself
by (`minecraft:brand`), which any client can fake: it keeps out players who didn't install Horizon, not
cheaters. The server's own checks (reach, movement) are what protect it.
`config set` changes `server.toml` without opening it: `horizon-server config set world.difficulty=hard
server.max_players=10`. Each key is a section and a name from the file (`properties.spawn-protection=0` for any
+117
View File
@@ -0,0 +1,117 @@
#!/usr/bin/env python3
"""`horizon-clients-only`, Horizon's own server.properties key, end to end on a dedicated server.
python3 tools/server_horizon_only_e2e.py --bin-dir target/fast
- off (the default): a client that sends no brand joins, as on vanilla.
- on (`horizon-clients-only=true`): a client with no brand, or with vanilla's "vanilla", is disconnected during
configuration with "This server is for the Horizon client: playhorizon.dev", and the console says why; a
client whose `minecraft:brand` is "horizon" joins. (Any client can send that brand: the gate keeps out
players who didn't install Horizon, not cheaters.)
horizon-bots stand in for the clients (`--brand`). Each server runs in a fresh folder with fresh HORIZON_HOME
and XDG folders (lab.game_env), on a free port. Build first:
cargo build --profile fast -p horizon-client --bin horizon-client
cargo build --profile fast -p horizon-bots
"""
import argparse
import json
import os
from pathlib import Path
import shutil
import subprocess
import sys
import tempfile
sys.path.insert(0, str(Path(__file__).resolve().parent))
import lab # noqa: E402
from server_bench import DONE, Server # noqa: E402
EXE = '.exe' if os.name == 'nt' else ''
REASON = 'This server is for the Horizon client: playhorizon.dev'
PROPERTIES = {
'online-mode': 'false',
'white-list': 'false',
'level-type': 'minecraft\\:flat',
'generate-structures': 'false',
'gamemode': 'creative',
'force-gamemode': 'true',
'view-distance': '3',
'simulation-distance': '3',
}
class Failed(Exception):
pass
def bot(bins, port, out, name, *extra):
"""One bot for a few seconds: its report's entry."""
report = out / f'{name}.json'
subprocess.run([bins / f'horizon-bots{EXE}', '--server', f'127.0.0.1:{port}', '--bots', '1',
'--behaviour', 'idle', '--seconds', '3', '--name', name, '--report', report, *extra],
check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=180)
return json.loads(report.read_text())['per_bot'][0]
def server(bins, folder, out, name, extra):
folder.mkdir(parents=True)
port = lab.free_port()
props = dict(PROPERTIES, **extra, **{'server-port': str(port)})
(folder / 'server.properties').write_text(''.join(f'{k}={v}\n' for k, v in props.items()))
(folder / 'eula.txt').write_text('eula=true\n')
command = [bins / f'horizon-client{EXE}', '--resources', lab.RESOURCES, '--dedicated', '--nogui']
s = Server(command, folder, out / f'{name}.log')
s.wait_for(DONE, 300)
return s, port
def joined(entry):
return entry['joined_after_ms'] > 0 and not entry['error']
def check(bins, scratch, out):
s, port = server(bins, scratch / 'off', out, 'off', {})
try:
plain = bot(bins, port, out, 'Plain')
finally:
s.stop()
if not joined(plain):
raise Failed(f'off: a client without a brand was refused: {plain["error"]}')
print('off: a client without a brand joins')
s, port = server(bins, scratch / 'on', out, 'on', {'horizon-clients-only': 'true'})
try:
results = {name: bot(bins, port, out, name, *extra) for name, extra in
[('None', ()), ('Vanilla', ('--brand', 'vanilla')), ('Horizon', ('--brand', 'horizon'))]}
lines = s.since(0)
finally:
s.stop()
for name in ('None', 'Vanilla'):
entry = results[name]
if joined(entry) or REASON not in (entry['error'] or ''):
raise Failed(f'on: {name} was not turned away with the reason: {entry["error"]}')
if not any(f'Disconnecting {name}0' in l and 'horizon-clients-only' in l for l in lines):
raise Failed(f'on: the console did not say why {name}0 was turned away')
if not joined(results['Horizon']):
raise Failed(f'on: the Horizon client was refused: {results["Horizon"]["error"]}')
print('on: no brand and "vanilla" are turned away with the reason; "horizon" joins')
def main():
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument('--bin-dir', default=str(lab.ROOT / 'target/fast'))
parser.add_argument('--out', default=str(lab.LOCAL / 'server-horizon-only-e2e'))
args = parser.parse_args()
bins, out = Path(args.bin_dir).resolve(), Path(args.out)
shutil.rmtree(out, ignore_errors=True)
out.mkdir(parents=True)
os.environ.update(lab.game_env())
with tempfile.TemporaryDirectory(prefix='server-horizon-only-e2e-') as scratch:
try:
check(bins, Path(scratch), out)
except (Failed, RuntimeError) as e:
sys.exit(f'server_horizon_only_e2e failed (logs in {out}): {e}')
if __name__ == '__main__':
main()