mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 02:56:17 -10:00
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:
1 parent
9ab3cee2e8
commit
7a4f7d5c99
6 files changed
+173
-2
No files matched your search
@@ -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) {
|
||||
|
||||
@@ -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" {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user