Compare commits

...
Author SHA1 Message Date
rdbtCVSandClaude Sonnet 5.5 bb486e33cc horizon-server: [proxy], a pass-through proxy in front of the game (off by default)
The supervisor listens on the public port, signs players in itself (status, online-mode login with
encryption and compression) and logs into the game on 127.0.0.1 as that player through Velocity
forwarding, relaying packets both ways. It keeps what a later login to the game must replay:
ClientInformation, brand, registered channels, the chat session.

Refs #16251

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-14 01:29:30 +00:00
rdbtCVSandClaude Sonnet 5.5 5c639b802a Dedicated server: /restart saves, stops and exits for horizon-server to start the game again
Registered only on a server horizon-server runs (HORIZON_SUPERVISED), owner permission like /stop.
Exit code 75 is Exit::Restart in the supervisor. Unsupervised, the console says nothing would start it again.

Refs #16251

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-14 01:16:53 +00:00
25 changed files with 2096 additions and 29 deletions

No files matched your search

@@ -1223,5 +1223,9 @@
"horizon.perf.copy_hover": "Copy the link.",
"horizon.perf.retry_button": "[Retry]",
"horizon.perf.retry_hover": "Try the upload again. The profile is still saved on your computer.",
"horizon.commands.restart.restarting": "Restarting the server",
"horizon.commands.restart.unsupervised": "Nothing would start the game again: /restart needs a server run by horizon-server",
"horizon.proxy.restarting": "Restarting, back in a moment",
"horizon.proxy.unavailable": "The server is not available right now. Try again in a moment.",
"horizon.perf.link_hover": "Open the shared profile in your browser."
}
+4
View File
@@ -323,6 +323,10 @@ pub fn main_with(mods: crate::mod_api::Mods) {
}
horizon_hooks::engine::shutdown();
logging::flush();
// `/restart`: the world is saved; the supervisor reads this code as "start the game again".
if game::exit_code::restart_requested() {
std::process::exit(game::exit_code::RESTART);
}
}
/// The JVM raises its open-file limit to the hard limit as it starts
+1 -1
View File
@@ -1133,7 +1133,7 @@ impl Game<'_> {
id if id.starts_with("banlist") => self.cmd_banlist(id),
id if id.starts_with("whitelist.") => self.cmd_whitelist(id, ctx),
"setidletimeout" => self.cmd_setidle_timeout(ctx),
"stop" => self.cmd_stop(),
"stop" | "restart" => self.cmd_stop(id == "restart"),
"save-all" => self.cmd_save_all(),
"save-off" | "save-on" => self.cmd_auto_save(id == "save-on"),
"transfer" => self.cmd_transfer(ctx),
@@ -26,11 +26,18 @@ impl Game<'_> {
}
}
/// StopCommand: the server loop (game/dedicated.rs) ends after this tick.
pub(super) fn cmd_stop(&mut self) -> R<i32> {
/// StopCommand: the server loop (game/dedicated.rs) ends after this tick. `restart` (Horizon's `/restart`,
/// registered only on a server `horizon-server` runs) also has the supervisor start the game again.
pub(super) fn cmd_stop(&mut self, restart: bool) -> R<i32> {
let lists = self.dedicated_lists()?;
self.feedback(Text::translate("commands.stop.stopping", &[]));
lists.lock().unwrap().stop = true;
let reply = if restart {
let supervised = super::super::dedicated::supervised();
super::super::dedicated::request_restart(supervised, &mut lists.lock().unwrap().stop)
} else {
lists.lock().unwrap().stop = true;
Text::translate("commands.stop.stopping", &[])
};
self.feedback(reply);
Ok(1)
}
@@ -163,6 +163,23 @@ struct Setup {
port: Option<u16>,
started: Instant,
}
/// Whether `horizon-server` runs this game, and so starts it again after a `/restart`.
pub(super) fn supervised() -> bool {
std::env::var_os(super::launch_api::SUPERVISED_ENV).is_some_and(|v| !v.is_empty())
}
/// `/restart` (Horizon's own, on a server `horizon-server` runs): like `/stop`, then the process exits
/// with a code that asks the supervisor to start the game again. `stop` is the server loop's flag.
/// Unsupervised nothing would start the game again, so it says so and stops nothing.
pub(super) fn request_restart(supervised: bool, stop: &mut bool) -> Text {
if !supervised {
return Text::translate("horizon.commands.restart.unsupervised", &[]);
}
super::exit_code::set_restart_requested();
*stop = true;
Text::translate("horizon.commands.restart.restarting", &[])
}
static SETUP: OnceLock<Mutex<Option<Setup>>> = OnceLock::new();
/// usercache.json (Services.nameToIdCache): used by the server thread only.
static USER_CACHE: Mutex<Option<UserCache>> = Mutex::new(None);
@@ -465,6 +482,7 @@ pub(super) fn preflight(options: &mut Options) -> Result<bool> {
// A dedicated server's tree: /op, /ban, /whitelist... (single player's
// server thread is an integrated one and never gets here).
horizon_client::command::vanilla::set_dedicated_commands(true);
horizon_client::command::vanilla::set_supervised_commands(supervised());
// Horizon's own commands are the owner's choice, off as on vanilla.
if props
.raw
@@ -1854,6 +1872,13 @@ impl Game<'_> {
self.console_reply(Text::translate("commands.stop.stopping", &[]));
state.lists.lock().unwrap().stop = true;
}
// Horizon's own, unlike its tree entry also here without the supervisor, to say why it does nothing.
"restart" => {
let mut lists = state.lists.lock().unwrap();
let reply = request_restart(supervised(), &mut lists.stop);
drop(lists);
self.console_reply(reply);
}
// Horizon's own, on the console only: a performance report (report.rs).
"horizon-report" | "horizon-trace" => {
let trace = command == "horizon-trace";
@@ -2650,6 +2675,25 @@ mod tests {
time::{SystemTime, UNIX_EPOCH},
};
/// `/restart` without `horizon-server` refuses and stops nothing; with it, it asks the server loop to end.
#[test]
fn restart_without_a_supervisor_does_nothing_and_says_why() {
let mut stop = false;
let reply = super::request_restart(false, &mut stop);
assert!(!stop);
assert!(!super::super::exit_code::restart_requested());
assert_eq!(
reply.translation_key(),
Some("horizon.commands.restart.unsupervised")
);
let reply = super::request_restart(true, &mut stop);
assert!(stop);
assert_eq!(
reply.translation_key(),
Some("horizon.commands.restart.restarting")
);
}
/// A console line runs in the overworld even after a server tick left the nether in place.
#[test]
fn console_commands_run_in_the_overworld() {
@@ -97,8 +97,26 @@ pub mod exit_code {
pub const USAGE: i32 = 2;
/// A panic; a crash report was saved.
pub const PANIC: i32 = 101;
/// A dedicated server's `/restart`: saved and stopped, to be started again by `horizon-server`
/// (crates/horizon-server/src/engine.rs holds the same number).
pub const RESTART: i32 = 75;
static RESTART_REQUESTED: std::sync::atomic::AtomicBool =
std::sync::atomic::AtomicBool::new(false);
/// `/restart` stopped a dedicated server: entry.rs exits with `exit_code::RESTART` once it is saved.
pub fn restart_requested() -> bool {
RESTART_REQUESTED.load(std::sync::atomic::Ordering::SeqCst)
}
pub fn set_restart_requested() {
RESTART_REQUESTED.store(true, std::sync::atomic::Ordering::SeqCst);
}
}
/// Set by `horizon-server` in the game's environment: something outlives the game and starts it again.
pub const SUPERVISED_ENV: &str = "HORIZON_SUPERVISED";
const EXIT_CODES: &[(i32, &str)] = &[
(
exit_code::OK,
@@ -116,6 +134,10 @@ const EXIT_CODES: &[(i32, &str)] = &[
exit_code::PANIC,
"The game panicked. A crash report was saved.",
),
(
exit_code::RESTART,
"A dedicated server saved and stopped on `/restart`: horizon-server starts it again.",
),
];
/// A command line the game refuses: exit code 2, no crash report.
+29
View File
@@ -880,6 +880,35 @@ fn the_dedicated_commands_complete_from_the_servers_lists() {
assert!(suggest("ban ").contains(&"@a".to_owned()));
}
#[test]
fn restart_is_only_on_a_supervised_dedicated_server_and_asks_for_stop_permission() {
// A vanilla-like server's tree stays vanilla: /restart needs the supervisor.
for dev in [false, true] {
for horizon in [false, true] {
assert!(
vanilla::game_tree_with(dev, horizon, true, false)
.find("restart")
.is_none()
);
// An integrated server never has it, supervised or not.
assert!(
vanilla::game_tree_with(dev, horizon, false, true)
.find("restart")
.is_none()
);
let d = vanilla::game_tree_with(dev, horizon, true, true);
let restart = d
.find("restart")
.expect("supervised dedicated tree has /restart");
let stop = d.find("stop").unwrap();
assert_eq!(d.nodes[restart].permission, d.nodes[stop].permission);
assert_eq!(
d.parse("restart", &reg()).executable().unwrap().1,
"restart"
);
}
}
}
#[test]
fn only_a_dedicated_server_has_the_dedicated_commands() {
// Commands.CommandSelection: an integrated server (single player, a LAN
// world) registers no /op, /ban, /ban-ip, /banlist, /pardon, /pardon-ip,
+60 -20
View File
@@ -576,10 +576,11 @@ fn apply_vanilla_order(d: &mut Dispatcher) {
/// (SharedConstants.DEBUG_DEV_COMMANDS), plus Horizon's own commands when
/// the server turned them on (`set_horizon_commands`).
pub fn game_dispatcher() -> &'static Dispatcher {
game_tree(
game_tree_with(
std::env::var_os("HORIZON_DEV_COMMANDS").is_some(),
HORIZON_COMMANDS.load(std::sync::atomic::Ordering::Relaxed),
DEDICATED_COMMANDS.load(std::sync::atomic::Ordering::Relaxed),
SUPERVISED_COMMANDS.load(std::sync::atomic::Ordering::Relaxed),
)
}
/// The server's tree for this world's enabled features. Cache entries are
@@ -588,19 +589,28 @@ pub fn game_dispatcher_with_features(features: &[String]) -> &'static Dispatcher
if !features.iter().any(|f| f == MINECART_IMPROVEMENTS) {
return game_dispatcher();
}
static TREES: [std::sync::OnceLock<Dispatcher>; 8] = [const { std::sync::OnceLock::new() }; 8];
static TREES: [std::sync::OnceLock<Dispatcher>; 16] =
[const { std::sync::OnceLock::new() }; 16];
let dev = std::env::var_os("HORIZON_DEV_COMMANDS").is_some();
let horizon = HORIZON_COMMANDS.load(std::sync::atomic::Ordering::Relaxed);
let dedicated = DEDICATED_COMMANDS.load(std::sync::atomic::Ordering::Relaxed);
TREES[usize::from(dev) | usize::from(horizon) << 1 | usize::from(dedicated) << 2].get_or_init(
|| {
let mut d = build_for_features(dev, dedicated, true);
if horizon {
horizon_commands(&mut d, 0);
}
d
},
)
let supervised = dedicated && SUPERVISED_COMMANDS.load(std::sync::atomic::Ordering::Relaxed);
TREES[tree_slot(dev, horizon, dedicated, supervised)].get_or_init(|| {
let mut d = build_for_features(dev, dedicated, true);
if horizon {
horizon_commands(&mut d, 0);
}
if supervised {
supervised_commands(&mut d, 0);
}
d
})
}
fn tree_slot(dev: bool, horizon: bool, dedicated: bool, supervised: bool) -> usize {
usize::from(dev)
| usize::from(horizon) << 1
| usize::from(dedicated) << 2
| usize::from(supervised) << 3
}
/// Whether this process is a dedicated server, whose tree has the dedicated
/// commands (`/op`, `/ban`, `/whitelist`...). Single player's server thread
@@ -622,24 +632,54 @@ static HORIZON_COMMANDS: std::sync::atomic::AtomicBool = std::sync::atomic::Atom
pub fn set_horizon_commands(on: bool) {
HORIZON_COMMANDS.store(on, std::sync::atomic::Ordering::Relaxed);
}
/// Whether this dedicated server runs under `horizon-server`, which starts the game again after
/// `/restart`. Off otherwise, so the tree stays vanilla's.
static SUPERVISED_COMMANDS: std::sync::atomic::AtomicBool =
std::sync::atomic::AtomicBool::new(false);
/// A server run by `horizon-server` turns `/restart` on, for the trees `game_dispatcher` returns
/// from now on (only a dedicated server's tree has it).
pub fn set_supervised_commands(on: bool) {
SUPERVISED_COMMANDS.store(on, std::sync::atomic::Ordering::Relaxed);
}
/// The server.properties key that turns `/pregen` on (Horizon's own; vanilla
/// ignores keys it doesn't know).
pub const PREGEN_PROPERTY: &str = "horizon-pregen";
#[cfg(test)]
pub(crate) fn game_tree(dev: bool, horizon: bool, dedicated: bool) -> &'static Dispatcher {
static TREES: [std::sync::OnceLock<Dispatcher>; 8] = [const { std::sync::OnceLock::new() }; 8];
game_tree_with(dev, horizon, dedicated, false)
}
pub(crate) fn game_tree_with(
dev: bool,
horizon: bool,
dedicated: bool,
supervised: bool,
) -> &'static Dispatcher {
static TREES: [std::sync::OnceLock<Dispatcher>; 16] =
[const { std::sync::OnceLock::new() }; 16];
let supervised = dedicated && supervised;
match (dev, horizon, dedicated) {
(true, false, false) => dispatcher(),
(false, false, false) => release_dispatcher(),
_ => TREES[usize::from(dev) | usize::from(horizon) << 1 | usize::from(dedicated) << 2]
.get_or_init(|| {
let mut d = build_for(dev, dedicated);
if horizon {
horizon_commands(&mut d, 0);
}
d
}),
_ => TREES[tree_slot(dev, horizon, dedicated, supervised)].get_or_init(|| {
let mut d = build_for(dev, dedicated);
if horizon {
horizon_commands(&mut d, 0);
}
if supervised {
supervised_commands(&mut d, 0);
}
d
}),
}
}
/// `/restart`: only on a dedicated server `horizon-server` runs. It saves and stops like `/stop`, and the
/// supervisor starts the game again (docs/hosting.md). The same permission level as `/stop`.
const OWNERS: u8 = 4;
fn supervised_commands(d: &mut Dispatcher, root: usize) {
let restart = d.literal(root, "restart");
d.requires(restart, OWNERS);
d.executes(restart, "restart");
}
/// Commands vanilla doesn't have, after its own: `/pregen`
/// (docs/pregeneration.md).
fn horizon_commands(d: &mut Dispatcher, root: usize) {
+5
View File
@@ -38,6 +38,11 @@ pub mod world {
pub mod protocol {
pub use horizon_protocol::crypto::offline_uuid;
pub use horizon_protocol::server_redirect;
/// What a server that logs players in on behalf of another (`horizon-server`'s `[proxy]`) speaks: the
/// codecs, packets, ids, connection framing, encryption and the account-service hash.
pub use horizon_protocol::{
Error, PROTOCOL_VERSION, buf, connection, crypto, ids, packets, rsa,
};
}
/// The preparer: the game's files from the player's own Minecraft install.
+1 -1
View File
@@ -10,7 +10,7 @@ description = "Host a Horizon server: server.toml, the CLI and the supervisor th
# The engine, through the platform's public API (docs/layers.md): server.properties and the user lists, the
# version `server/status` reports, and region files for [prune_chunks] (prune.rs), read and rewritten as vanilla's
# RegionFile does.
horizon-platform = { path = "../horizon-platform", features = ["server-files", "world"] }
horizon-platform = { path = "../horizon-platform", features = ["server-files", "world", "protocol"] }
# `horizon-server mcp`: the Model Context Protocol, shared with horizon-ctl and the launcher.
horizon-mcp = { path = "../horizon-mcp" }
# server.toml's [anticheat], which the game reads: checked here before every start.
+10
View File
@@ -380,6 +380,8 @@ pub struct Shared {
pub event_added: std::sync::Condvar,
/// The management protocol's connections, which its notifications go to (management.rs).
pub management: crate::management::Clients,
/// `[proxy]`'s listener, when it is on (proxy/mod.rs).
pub proxy: Mutex<Option<crate::proxy::Proxy>>,
}
impl Shared {
@@ -412,9 +414,17 @@ impl Shared {
events: Mutex::default(),
event_added: std::sync::Condvar::new(),
management: crate::management::Clients::default(),
proxy: Mutex::new(None),
})
}
/// Tells `[proxy]` whether a game that answers is running.
pub fn proxy_game_up(&self, up: bool) {
if let Some(proxy) = self.proxy.lock().unwrap().as_ref() {
proxy.set_game_up(up);
}
}
/// Ends an update or rollback: its outcome, in the log and in `status`.
pub fn finish_update(&self, ok: bool, message: String) {
{
+62
View File
@@ -188,6 +188,27 @@ pub struct Config {
/// `[entities]` (docs/server.md): mob settings passed to the game; written only when it isn't the default.
#[serde(default, skip_serializing_if = "Entities::is_default")]
pub entities: Entities,
/// `[proxy]` (docs/hosting.md): the supervisor owns the public port and the game listens behind it;
/// written only when it isn't the default.
#[serde(default, skip_serializing_if = "Proxy::is_default")]
pub proxy: Proxy,
}
/// `[proxy]`: the supervisor listens on `server.port` and signs players in itself (proxy/mod.rs), and the
/// game listens on 127.0.0.1 behind it, with Velocity forwarding. Off by default. Read when horizon-server
/// starts: turning it on or off needs a restart of horizon-server, not only of the game.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct Proxy {
pub enabled: bool,
/// The port the game listens on, on 127.0.0.1; 0 takes a free one at every start of horizon-server.
pub game_port: u16,
}
impl Proxy {
fn is_default(&self) -> bool {
*self == Self::default()
}
}
/// `[entities]`: `villager_lobotomize = true` skips the brain of villagers that cannot move (stuck in a cell,
@@ -613,6 +634,9 @@ impl Config {
self.chat_filter.check()?;
self.prune_chunks.check()?;
self.entities.check()?;
if self.proxy.enabled && self.proxy.game_port == self.server.port {
return Err("proxy.game_port is server.port: the game listens behind the proxy, on its own port".into());
}
if self.server.port == 0 {
return Err("server.port must be a port number from 1 to 65535".into());
}
@@ -640,6 +664,15 @@ impl Config {
.iter()
.map(|(k, v)| (k.clone(), property_text(v))),
);
// Behind the proxy the game listens where only it goes (`game_port` is set once the proxy runs).
if self.proxy.enabled && self.proxy.game_port != 0 {
for (key, value) in crate::proxy::game_properties(self.proxy.game_port, true) {
match out.iter_mut().find(|(k, _)| *k == key) {
Some(slot) => slot.1 = value,
None => out.push((key, value)),
}
}
}
out
}
@@ -751,6 +784,7 @@ pub fn render(config: &Config) -> String {
"prune_chunks",
"combat",
"entities",
"proxy",
] {
if let Some(settings) = table.get(section) {
let mut only = toml::Table::new();
@@ -1238,4 +1272,32 @@ mod tests {
assert_eq!(back.properties["spawn-protection"], toml::Value::Integer(4));
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn the_proxy_is_off_by_default_and_moves_the_game_behind_it_when_on() {
let config = Config::default();
assert!(!config.proxy.enabled);
let key = |c: &Config, k: &str| c.vanilla().into_iter().find(|(key, _)| key == k);
// Off, the game keeps the owner's port and online mode.
assert_eq!(key(&config, "server-port").unwrap().1, "25565");
assert_eq!(key(&config, "online-mode").unwrap().1, "true");
let on = Config::parse("[proxy]\nenabled = true\ngame_port = 25999\n").unwrap();
assert!(on.proxy.enabled);
assert_eq!(key(&on, "server-port").unwrap().1, "25999");
assert_eq!(key(&on, "server-ip").unwrap().1, "127.0.0.1");
// The proxy signs players in: the game trusts what it forwards.
assert_eq!(key(&on, "online-mode").unwrap().1, "false");
// The owner's own port is where the proxy listens, and the file keeps it.
assert_eq!(on.server.port, 25565);
assert!(render(&on).contains("[proxy]"));
assert!(!render(&config).contains("[proxy]"));
}
#[test]
fn the_games_port_behind_the_proxy_is_not_the_public_one() {
let err = Config::parse("[proxy]\nenabled = true\ngame_port = 25565\n").unwrap_err();
assert!(err.contains("proxy.game_port"), "{err}");
assert!(Config::parse("[proxy]\nunknown = 1\n").is_err());
}
}
+7
View File
@@ -14,6 +14,12 @@ use std::{
/// The game's program names, most specific first: the server build (the game without sound or
/// controllers, so it needs neither ALSA nor udev on Linux; `horizon-server-linux-x86_64.tar.gz` holds it),
/// else the full game.
/// The exit code of a game that saved and stopped on `/restart`: start it again. The game's
/// `exit_code::RESTART` (crates/horizon-client/src/game/launch_api.rs) is the same number.
pub const RESTART_EXIT_CODE: i32 = 75;
/// What the game's environment carries to say this supervisor starts it again: only then does it
/// register `/restart`. The game's `SUPERVISED_ENV` is the same name.
const SUPERVISED_ENV: &str = "HORIZON_SUPERVISED";
const PROGRAMS: [&str; 2] = ["horizon-engine", "horizon-client"];
fn exe(name: &str) -> String {
@@ -282,6 +288,7 @@ fn command(
.args(["--dedicated", "--nogui"])
.current_dir(&dir)
.env("HORIZON_NO_AUDIO", "1")
.env(SUPERVISED_ENV, "1")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
+1
View File
@@ -21,6 +21,7 @@ mod management_tls;
mod mcp;
mod panel;
mod process;
mod proxy;
mod prune;
mod query;
mod rcon;
+197
View File
@@ -0,0 +1,197 @@
//! Signing a client in for the game: the proxy does the login the game would do in online mode, since only
//! the side that ran it knows the connection's encryption key (the same exchange as the game's
//! lan/conn.rs, without a connection to the account service's other endpoints).
use super::wire::{
Connection, RsaPrivateKey, login, offline_uuid, random_bytes, server_hash, translated_json,
};
use std::time::Duration;
/// The account service's discovery document; `HORIZON_API_DISCOVERY` names another one, as the game's does.
const DISCOVERY: &str = "https://discovery.minecraftservices.com/minecraft/client";
/// Who is joining, as the account service (or the offline name) says.
pub struct Profile {
pub uuid: u128,
pub name: String,
pub properties: Vec<login::Property>,
}
/// Online mode's key pair, made once.
pub struct Keys {
key: RsaPrivateKey,
public_der: Vec<u8>,
}
impl Keys {
pub fn generate() -> Result<Self, String> {
let key = RsaPrivateKey::generate(1024)
.map_err(|e| format!("Failed to generate key pair: {e}"))?;
Ok(Self {
public_der: key.public_der(),
key,
})
}
}
/// A name the login accepts: what `ServerLoginPacketListenerImpl.isValidUsername` does.
pub fn valid_name(name: &str) -> bool {
!name.is_empty() && name.len() <= 16 && name.chars().all(|c| c > ' ' && c < '\u{7f}')
}
/// The profile of `name`: offline, or after the encryption request and the account service's answer.
/// `Ok(Err(json))` is a refusal to show the client.
pub fn profile(
c: &mut Connection,
keys: Option<&Keys>,
name: &str,
) -> Result<Result<Profile, String>, String> {
let e = |e: super::wire::Error| e.to_string();
let Some(keys) = keys else {
return Ok(Ok(Profile {
uuid: offline_uuid(name),
name: name.to_owned(),
properties: Vec::new(),
}));
};
let mut challenge = [0u8; 4];
random_bytes(&mut challenge).map_err(e)?;
c.send(
&login::Clientbound::Hello(login::Hello {
server_id: String::new(),
public_key: keys.public_der.clone(),
challenge: challenge.to_vec(),
should_authenticate: true,
})
.encode(),
)
.map_err(e)?;
let (id, payload) = c.recv().map_err(e)?;
let key = match login::Serverbound::decode(id, &payload).map_err(e)? {
login::Serverbound::Key(k) => k,
_ => return Err("Unexpected packet during login".into()),
};
if keys.key.decrypt(&key.challenge).ok().as_deref() != Some(&challenge[..]) {
return Err("Protocol error".into());
}
let secret: [u8; 16] = keys
.key
.decrypt(&key.key)
.ok()
.and_then(|s| s.try_into().ok())
.ok_or("Protocol error")?;
let digest = server_hash("", &secret, &keys.public_der);
c.enable_encryption(&secret);
Ok(match has_joined(name, &digest) {
Ok(Some(p)) => Ok(p),
Ok(None) => Err(translated_json(
"multiplayer.disconnect.unverified_username",
"Failed to verify username!",
)),
Err(_) => Err(translated_json(
"multiplayer.disconnect.authservers_down",
"Authentication servers are down. Please try again later, sorry!",
)),
})
}
fn agent() -> ureq::Agent {
ureq::AgentBuilder::new()
.timeout(Duration::from_secs(15))
.build()
}
fn discovery_url() -> String {
std::env::var("HORIZON_API_DISCOVERY")
.ok()
.filter(|u| !u.is_empty())
.unwrap_or_else(|| DISCOVERY.to_owned())
}
/// HasJoinedMinecraftServer: `Ok(None)` when the player did not join with this hash.
fn has_joined(name: &str, hash: &str) -> Result<Option<Profile>, String> {
let doc: serde_json::Value = agent()
.get(&discovery_url())
.call()
.map_err(|e| e.to_string())?
.into_string()
.map_err(|e| e.to_string())
.and_then(|t| serde_json::from_str(&t).map_err(|e| e.to_string()))?;
let url = doc
.pointer("/discovery/session/endpoints/verify/uri")
.and_then(|u| u.as_str())
.ok_or("no session verify endpoint")?;
let separator = if url.contains('?') { '&' } else { '?' };
let query = format!("username={}&serverId={}", encode(name), encode(hash));
match agent().get(&format!("{url}{separator}{query}")).call() {
Ok(response) => Ok(parse_joined(&response.into_string().unwrap_or_default())),
Err(ureq::Error::Status(204 | 403 | 404, _)) => Ok(None),
Err(e) => Err(e.to_string()),
}
}
fn encode(text: &str) -> String {
let mut out = String::new();
for b in text.bytes() {
match b {
b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'.' | b'-' | b'*' | b'_' => {
out.push(char::from(b))
}
b' ' => out.push('+'),
_ => out.push_str(&format!("%{b:02X}")),
}
}
out
}
/// The answer: `id` (a UUID without dashes), `name` and `properties`. The name is the account's.
pub fn parse_joined(body: &str) -> Option<Profile> {
let v: serde_json::Value = serde_json::from_str(body).ok()?;
let id = v.get("id")?.as_str()?;
let uuid = u128::from_str_radix(&id.replace('-', ""), 16).ok()?;
let properties = v
.get("properties")
.and_then(|p| p.as_array())
.into_iter()
.flatten()
.filter_map(|p| {
Some(login::Property {
name: p.get("name")?.as_str()?.to_owned(),
value: p.get("value")?.as_str()?.to_owned(),
signature: p
.get("signature")
.and_then(|s| s.as_str())
.map(str::to_owned),
})
})
.collect();
Some(Profile {
uuid,
name: v.get("name")?.as_str()?.to_owned(),
properties,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_account_service_answer_gives_the_real_uuid_name_and_skin() {
let body = r#"{"id":"069a79f444e94726a5befca90e38aaf5","name":"Notch","properties":[{"name":"textures","value":"e30=","signature":"s"}]}"#;
let p = parse_joined(body).unwrap();
assert_eq!(p.uuid, 0x069a79f444e94726a5befca90e38aaf5);
assert_eq!(p.name, "Notch");
assert_eq!(p.properties.len(), 1);
assert_eq!(p.properties[0].signature.as_deref(), Some("s"));
assert!(parse_joined("{}").is_none());
}
#[test]
fn names_are_what_the_game_accepts() {
assert!(valid_name("Steve_01"));
assert!(!valid_name(""));
assert!(!valid_name("a name"));
assert!(!valid_name("seventeen_chars_xx"));
}
}
+219
View File
@@ -0,0 +1,219 @@
//! The proxy's login to the game as a player: the game listens on 127.0.0.1 with Velocity modern forwarding
//! (docs/velocity-forwarding.md), so the proxy says who the player is and signs it with the shared secret.
use super::wire::{
ConnReader, ConnWriter, Connection, PROTOCOL_VERSION, Writer, handshake, login, status,
};
use sha2::{Digest, Sha256};
use std::{
net::{IpAddr, SocketAddr},
time::Duration,
};
const CHANNEL: &str = "velocity:player_info";
const CONNECT_TIMEOUT: Duration = Duration::from_secs(3);
/// How long the game may take to answer during the login (it checks its lists and mods' pre-login first).
const LOGIN_TIMEOUT: Duration = Duration::from_secs(25);
/// Why a game could not be logged into.
#[derive(Debug)]
pub enum Failure {
/// Nothing answered: the game is down or not listening yet.
Down(String),
/// The game turned the player away (banned, not whitelisted, full): its reason, a JSON component.
Refused(String),
/// The game said something the login doesn't expect.
Protocol(String),
}
/// A game's connection after its login: ready to be acknowledged.
pub struct Backend {
pub reader: ConnReader,
pub writer: ConnWriter,
}
/// What the proxy vouches for to the game.
pub struct Identity<'a> {
pub address: IpAddr,
pub uuid: u128,
pub name: &'a str,
pub properties: &'a [login::Property],
}
impl std::fmt::Display for Failure {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Down(why) | Self::Protocol(why) => f.write_str(why),
Self::Refused(json) => write!(f, "refused: {json}"),
}
}
}
fn down(e: impl std::fmt::Display) -> Failure {
Failure::Down(e.to_string())
}
/// Logs into the game at `game` as `who`, up to the game's LoginFinished.
pub fn login(game: SocketAddr, secret: &[u8], who: &Identity) -> Result<Backend, Failure> {
let mut c =
Connection::connect(&game.ip().to_string(), game.port(), CONNECT_TIMEOUT).map_err(down)?;
c.reader
.set_read_timeout(Some(LOGIN_TIMEOUT))
.map_err(down)?;
c.send(&intention(game, handshake::LOGIN)).map_err(down)?;
c.send(
&login::Serverbound::Hello(login::ServerHello {
name: who.name.to_owned(),
uuid: who.uuid,
})
.encode(),
)
.map_err(down)?;
loop {
let (id, payload) = c.recv().map_err(down)?;
match login::Clientbound::decode(id, &payload)
.map_err(|e| Failure::Protocol(e.to_string()))?
{
login::Clientbound::CustomQuery(q) if q.channel == CHANNEL => {
let answer = login::Serverbound::CustomQueryAnswer(login::CustomQueryAnswer {
transaction: q.transaction,
payload: Some(forwarded(secret, who)),
})
.encode();
c.send(&answer).map_err(down)?;
}
login::Clientbound::Compression(t) => c.set_compression(t.threshold),
login::Clientbound::LoginFinished(_) => {
let (reader, writer) = c.split();
return Ok(Backend { reader, writer });
}
login::Clientbound::Disconnect(d) => return Err(Failure::Refused(d.reason_json)),
other => {
return Err(Failure::Protocol(format!(
"unexpected login packet {other:?}"
)));
}
}
}
}
/// The handshake a game at `game` is greeted with.
pub fn intention(game: SocketAddr, intent: i32) -> Vec<u8> {
handshake::Serverbound::Intention(handshake::Intention {
protocol: PROTOCOL_VERSION,
host: game.ip().to_string(),
port: game.port(),
intent,
})
.encode()
}
/// The game's server list answer, as JSON, or why there is none.
pub fn status_json(game: SocketAddr) -> Result<String, Failure> {
let mut c =
Connection::connect(&game.ip().to_string(), game.port(), CONNECT_TIMEOUT).map_err(down)?;
c.reader
.set_read_timeout(Some(CONNECT_TIMEOUT))
.map_err(down)?;
c.send(&intention(game, handshake::STATUS)).map_err(down)?;
c.send(&status::Serverbound::StatusRequest(super::wire::Empty).encode())
.map_err(down)?;
let (id, payload) = c.recv().map_err(down)?;
match status::Clientbound::decode(id, &payload).map_err(|e| Failure::Protocol(e.to_string()))? {
status::Clientbound::StatusResponse(r) => Ok(r.json),
other => Err(Failure::Protocol(format!(
"unexpected status packet {other:?}"
))),
}
}
/// LoginAcknowledged: the game starts its configuration.
pub fn acknowledge(writer: &mut ConnWriter) -> Result<(), super::wire::Error> {
writer.send(&login::Serverbound::LoginAcknowledged(super::wire::Empty).encode())
}
/// The login query answer: the signature over the data, then the data (version 1: address, UUID, name,
/// properties). The same layout the game checks in lan/forwarding.rs.
fn forwarded(secret: &[u8], who: &Identity) -> Vec<u8> {
let mut w = Writer::new();
w.varint(1);
w.string(&who.address.to_string());
w.uuid(who.uuid);
w.string(who.name);
w.varint(who.properties.len() as i32);
for p in who.properties {
w.string(&p.name);
w.string(&p.value);
w.bool(p.signature.is_some());
if let Some(s) = &p.signature {
w.string(s);
}
}
let mut out = hmac_sha256(secret, &w.data).to_vec();
out.extend_from_slice(&w.data);
out
}
/// HMAC-SHA256 (RFC 2104).
pub(super) fn hmac_sha256(key: &[u8], data: &[u8]) -> [u8; 32] {
const BLOCK: usize = 64;
let mut padded = [0u8; BLOCK];
if key.len() > BLOCK {
padded[..32].copy_from_slice(&Sha256::digest(key));
} else {
padded[..key.len()].copy_from_slice(key);
}
let inner = Sha256::new()
.chain_update(padded.map(|b| b ^ 0x36))
.chain_update(data)
.finalize();
Sha256::new()
.chain_update(padded.map(|b| b ^ 0x5c))
.chain_update(inner)
.finalize()
.into()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::proxy::wire::Reader;
#[test]
fn hmac_matches_rfc_4231_case_1() {
let mac = hmac_sha256(&[0x0b; 20], b"Hi There");
let hex: String = mac.iter().map(|b| format!("{b:02x}")).collect();
assert_eq!(
hex,
"b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7"
);
}
#[test]
fn the_forwarded_data_is_what_the_game_reads() {
let who = Identity {
address: "203.0.113.9".parse().unwrap(),
uuid: 0x069a79f444e94726a5befca90e38aaf5,
name: "Steve",
properties: &[login::Property {
name: "textures".into(),
value: "e30=".into(),
signature: Some("sig".into()),
}],
};
let answer = forwarded(b"secret", &who);
let (signature, data) = answer.split_at(32);
assert_eq!(signature, hmac_sha256(b"secret", data));
let mut r = Reader::new(data);
assert_eq!(r.varint().unwrap(), 1);
assert_eq!(r.string(64).unwrap(), "203.0.113.9");
assert_eq!(r.uuid().unwrap(), who.uuid);
assert_eq!(r.string(16).unwrap(), "Steve");
assert_eq!(r.varint().unwrap(), 1);
assert_eq!(r.string(64).unwrap(), "textures");
assert_eq!(r.string(32767).unwrap(), "e30=");
assert!(r.bool().unwrap());
assert_eq!(r.string(1024).unwrap(), "sig");
assert_eq!(r.remaining(), 0);
}
}
@@ -0,0 +1,296 @@
//! The proxy against a stand-in game: a listener that does the game's side of the forwarded login, its
//! configuration and a play that echoes, on real sockets.
use super::{
replay::body,
wire::{Connection, Reader, handshake, ids, login, status},
*,
};
use std::{
net::TcpListener,
sync::{Arc, Mutex},
};
/// What the stand-in game saw.
#[derive(Default)]
pub struct Seen {
/// (name, UUID, address) of every forwarded login, after its signature checked out.
pub logins: Vec<(String, u128, String)>,
/// The play packets it received, as (id, payload).
pub play: Vec<(i32, Vec<u8>)>,
/// The configuration packets it received.
pub config: Vec<(i32, Vec<u8>)>,
}
pub struct FakeGame {
pub port: u16,
pub seen: Arc<Mutex<Seen>>,
}
/// A game that forwards-logs players in with `secret`, shows its status and echoes play packets of id
/// `ECHO` back as `ECHOED`.
pub const ECHO: i32 = ids::play::serverbound::CHAT_COMMAND;
pub const ECHOED: i32 = ids::play::clientbound::SYSTEM_CHAT;
impl FakeGame {
pub fn start(secret: &[u8]) -> Self {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
let seen = Arc::new(Mutex::new(Seen::default()));
let (s, secret) = (seen.clone(), secret.to_vec());
thread::spawn(move || {
for stream in listener.incoming() {
let (s, secret) = (s.clone(), secret.clone());
let Ok(stream) = stream else { continue };
thread::spawn(move || {
let _ = serve(stream, &s, &secret);
});
}
});
Self { port, seen }
}
}
fn serve(stream: TcpStream, seen: &Mutex<Seen>, secret: &[u8]) -> Result<(), wire::Error> {
let mut c = Connection::from_stream(stream)?;
let (id, payload) = c.recv()?;
let hello = handshake::decode_intention(id, &payload, 32767)?;
if hello.intent == handshake::STATUS {
c.recv()?;
c.send(
&status::Clientbound::StatusResponse(status::StatusResponse {
json: r#"{"description":"fake game"}"#.into(),
})
.encode(),
)?;
return Ok(());
}
let (id, payload) = c.recv()?;
let login::Serverbound::Hello(hello) = login::Serverbound::decode(id, &payload)? else {
panic!("hello")
};
c.send(
&login::Clientbound::CustomQuery(login::CustomQuery {
transaction: 1,
channel: "velocity:player_info".into(),
data: vec![1],
})
.encode(),
)?;
let (id, payload) = c.recv()?;
let login::Serverbound::CustomQueryAnswer(answer) = login::Serverbound::decode(id, &payload)?
else {
panic!("answer")
};
let data = answer.payload.unwrap();
let (signature, data) = data.split_at(32);
assert_eq!(
signature,
super::backend::hmac_sha256(secret, data),
"signed with the shared secret"
);
let mut r = Reader::new(data);
assert_eq!(r.varint()?, 1);
let address = r.string(64)?;
let uuid = r.uuid()?;
assert_eq!(r.string(16)?, hello.name);
seen.lock()
.unwrap()
.logins
.push((hello.name.clone(), uuid, address));
c.send(
&login::Clientbound::LoginFinished(login::LoginFinished {
profile: login::GameProfile {
uuid,
name: hello.name,
properties: vec![],
},
session: uuid,
})
.encode(),
)?;
let (id, payload) = c.recv()?;
assert!(matches!(
login::Serverbound::decode(id, &payload)?,
login::Serverbound::LoginAcknowledged(_)
));
// Configuration: nothing to say but the end of it, then what the client said is read until it finishes.
c.send(&body(
ids::configuration::clientbound::FINISH_CONFIGURATION,
&[],
))?;
loop {
let (id, payload) = c.recv()?;
seen.lock().unwrap().config.push((id, payload));
if id == ids::configuration::serverbound::FINISH_CONFIGURATION {
break;
}
}
c.send(&body(ids::play::clientbound::LOGIN, b"login"))?;
loop {
let (id, payload) = c.recv()?;
seen.lock().unwrap().play.push((id, payload.clone()));
if id == ECHO {
c.send(&body(ECHOED, &payload))?;
}
}
}
/// A client's whole join through the proxy at `port`, up to the game's Login packet; the connection is kept.
pub fn join(port: u16, name: &str, said: &[(i32, Vec<u8>)]) -> Connection {
let mut c = Connection::connect("127.0.0.1", port, Duration::from_secs(5)).unwrap();
c.reader
.set_read_timeout(Some(Duration::from_secs(10)))
.unwrap();
c.send(
&handshake::Serverbound::Intention(handshake::Intention {
protocol: wire::PROTOCOL_VERSION,
host: "localhost".into(),
port,
intent: handshake::LOGIN,
})
.encode(),
)
.unwrap();
c.send(
&login::Serverbound::Hello(login::ServerHello {
name: name.into(),
uuid: 0,
})
.encode(),
)
.unwrap();
loop {
let (id, payload) = c.recv().unwrap();
match login::Clientbound::decode(id, &payload).unwrap() {
login::Clientbound::Compression(t) => c.set_compression(t.threshold),
login::Clientbound::LoginFinished(done) => {
assert_eq!(done.profile.name, name);
break;
}
other => panic!("{other:?}"),
}
}
c.send(&login::Serverbound::LoginAcknowledged(wire::Empty).encode())
.unwrap();
for (id, payload) in said {
c.send(&body(*id, payload)).unwrap();
}
// Configuration ends when the game says so; the client then finishes, and play starts with the Login.
loop {
let (id, _) = c.recv().unwrap();
if id == ids::configuration::clientbound::FINISH_CONFIGURATION {
break;
}
}
c.send(&body(
ids::configuration::serverbound::FINISH_CONFIGURATION,
&[],
))
.unwrap();
let (id, payload) = c.recv().unwrap();
assert_eq!(
(id, payload.as_slice()),
(ids::play::clientbound::LOGIN, &b"login"[..])
);
c
}
pub fn free_port() -> u16 {
TcpListener::bind("127.0.0.1:0")
.unwrap()
.local_addr()
.unwrap()
.port()
}
pub fn proxy_for(game: &FakeGame, dir: &Path) -> (Proxy, u16) {
let mut config = Config::default();
config.server.online_mode = false;
config.server.bind = "127.0.0.1".into();
config.server.port = free_port();
config.proxy.game_port = game.port;
let proxy = start(dir, &config, |_, _| {}).unwrap();
(proxy, config.server.port)
}
pub fn temp_dir(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("horizon-proxy-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn a_player_joins_chats_and_the_game_sees_who_they_are() {
let dir = temp_dir("join");
// The stand-in must know the secret before it starts: the proxy keeps the one it makes.
let secret = super::secret(&dir).unwrap();
let game = FakeGame::start(&secret);
let (proxy, port) = proxy_for(&game, &dir);
proxy.set_game_up(true);
let mut c = join(
port,
"Steve",
&[(
ids::configuration::serverbound::CLIENT_INFORMATION,
vec![1, 2, 3],
)],
);
c.send(&body(ECHO, b"hello")).unwrap();
let (id, payload) = c.recv().unwrap();
assert_eq!((id, payload.as_slice()), (ECHOED, &b"hello"[..]));
let seen = game.seen.lock().unwrap();
let (name, uuid, address) = &seen.logins[0];
assert_eq!(name, "Steve");
// Offline mode: the UUID of the name, which is the real one for an offline server.
assert_eq!(*uuid, wire::offline_uuid("Steve"));
assert_eq!(address, "127.0.0.1");
assert!(seen.config.iter().any(|(id, p)| *id
== ids::configuration::serverbound::CLIENT_INFORMATION
&& p == &[1, 2, 3]));
assert_eq!(seen.play.last().unwrap(), &(ECHO, b"hello".to_vec()));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn the_server_list_is_the_games_and_says_restarting_while_it_is_down() {
let dir = temp_dir("status");
let secret = super::secret(&dir).unwrap();
let game = FakeGame::start(&secret);
let (proxy, port) = proxy_for(&game, &dir);
let ask = || {
let mut c = Connection::connect("127.0.0.1", port, Duration::from_secs(5)).unwrap();
c.send(
&handshake::Serverbound::Intention(handshake::Intention {
protocol: wire::PROTOCOL_VERSION,
host: "localhost".into(),
port,
intent: handshake::STATUS,
})
.encode(),
)
.unwrap();
c.send(&status::Serverbound::StatusRequest(wire::Empty).encode())
.unwrap();
let (id, payload) = c.recv().unwrap();
let status::Clientbound::StatusResponse(r) =
status::Clientbound::decode(id, &payload).unwrap()
else {
panic!()
};
c.send(&status::Serverbound::PingRequest(status::Ping { time: 42 }).encode())
.unwrap();
let (id, payload) = c.recv().unwrap();
assert!(
matches!(status::Clientbound::decode(id, &payload).unwrap(), status::Clientbound::PongResponse(p) if p.time == 42)
);
r.json
};
assert!(ask().contains("restarting"), "down");
proxy.set_game_up(true);
assert!(ask().contains("fake game"));
let _ = std::fs::remove_dir_all(&dir);
}
+322
View File
@@ -0,0 +1,322 @@
//! `[proxy]`: the public port belongs to the supervisor, and the game listens on 127.0.0.1 behind it.
//!
//! The proxy logs each player in itself (handshake, status, online-mode login with its encryption and
//! compression: auth.rs), then logs into the game as that player through Velocity modern forwarding
//! (backend.rs) and relays packets both ways (session.rs). It keeps what a later login to the game must be
//! told again (replay.rs). Not a general proxy: one game, the one this supervisor runs.
mod auth;
mod backend;
#[cfg(test)]
mod join_tests;
mod replay;
mod session;
mod wire;
use crate::config::Config;
use std::{
net::{IpAddr, Ipv4Addr, SocketAddr, TcpListener, TcpStream},
path::{Path, PathBuf},
sync::{
Arc, Mutex,
atomic::{AtomicBool, Ordering},
},
thread,
time::Duration,
};
use wire::{Connection, handshake, status};
/// The secret the proxy signs forwarded players with, beside `server.properties`: the file the game reads.
const SECRET_FILE: &str = "forwarding.secret";
/// Where the proxy keeps the secret it made, so it can tell that file from one an owner put there.
const OWN_SECRET: &str = ".horizon/proxy-forwarding.secret";
/// How long a client may take over its handshake and login.
const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(30);
pub struct Inner {
/// The game, on this machine.
pub game: SocketAddr,
pub secret: Vec<u8>,
/// The threshold the proxy compresses its clients' connections with.
pub compression: i32,
keys: Option<auth::Keys>,
/// Whether a game that answers is running: the server list asks it only then.
up: AtomicBool,
log: Mutex<Box<dyn Fn(&str, &str) + Send>>,
}
impl Inner {
pub fn note(&self, level: &str, line: &str) {
(self.log.lock().unwrap_or_else(|e| e.into_inner()))(level, line);
}
}
/// The running proxy: its listener's thread stays until the supervisor ends.
#[derive(Clone)]
pub struct Proxy {
inner: Arc<Inner>,
}
/// What the game is told so it listens where only the proxy goes: `server.properties` keys.
pub fn game_properties(game_port: u16, compression_off: bool) -> Vec<(String, String)> {
let mut out = vec![
("server-ip".to_owned(), "127.0.0.1".to_owned()),
("server-port".to_owned(), game_port.to_string()),
// The proxy has signed the player in: the game trusts what it forwards (online-mode=false).
("online-mode".to_owned(), "false".to_owned()),
];
if compression_off {
// Loopback: the proxy compresses for its clients, the game need not for the proxy.
out.push(("network-compression-threshold".to_owned(), "-1".to_owned()));
}
out
}
/// Starts listening on the public address. `dir` is the server folder; `log` receives (level, line).
pub fn start(
dir: &Path,
config: &Config,
log: impl Fn(&str, &str) + Send + 'static,
) -> Result<Proxy, String> {
let bind: IpAddr = if config.server.bind.is_empty() {
Ipv4Addr::UNSPECIFIED.into()
} else {
config
.server
.bind
.parse()
.map_err(|_| format!("server.bind {:?} is not an IP address", config.server.bind))?
};
let listener = TcpListener::bind((bind, config.server.port))
.map_err(|e| format!("[proxy] can't listen on {bind}:{}: {e}", config.server.port))?;
let secret = secret(dir)?;
let keys = if config.server.online_mode {
Some(auth::Keys::generate()?)
} else {
None
};
let inner = Arc::new(Inner {
game: SocketAddr::from((Ipv4Addr::LOCALHOST, config.proxy.game_port)),
secret,
compression: compression_threshold(config),
keys,
up: AtomicBool::new(false),
log: Mutex::new(Box::new(log)),
});
let accepting = inner.clone();
thread::Builder::new()
.name("Proxy listener".into())
.spawn(move || accept(listener, &accepting))
.map_err(|e| e.to_string())?;
Ok(Proxy { inner })
}
impl Proxy {
/// The game answers (`true`) or is down or still starting (`false`).
pub fn set_game_up(&self, up: bool) {
self.inner.up.store(up, Ordering::SeqCst);
}
}
/// `network-compression-threshold` as the owner set it, vanilla's 256 otherwise.
fn compression_threshold(config: &Config) -> i32 {
config
.properties
.get("network-compression-threshold")
.and_then(|v| v.as_integer())
.and_then(|v| i32::try_from(v).ok())
.unwrap_or(256)
}
/// The shared secret: made once and kept, and put where the game reads it. A `forwarding.secret` the owner
/// wrote for a Velocity of their own is never overwritten: that and the proxy can't both be in front of the game.
fn secret(dir: &Path) -> Result<Vec<u8>, String> {
let (own, file): (PathBuf, PathBuf) = (dir.join(OWN_SECRET), dir.join(SECRET_FILE));
let kept = std::fs::read(&own)
.ok()
.map(|s| s.trim_ascii().to_vec())
.filter(|s| !s.is_empty());
let secret = match kept {
Some(s) => s,
None => {
let mut random = [0u8; 24];
getrandom::fill(&mut random)
.map_err(|e| format!("no random numbers for the proxy's secret: {e}"))?;
let hex: String = random.iter().map(|b| format!("{b:02x}")).collect();
if let Some(parent) = own.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| format!("{}: {e}", parent.display()))?;
}
std::fs::write(&own, &hex).map_err(|e| format!("{}: {e}", own.display()))?;
hex.into_bytes()
}
};
match std::fs::read(&file) {
Ok(existing) if existing.trim_ascii() != secret.as_slice() => {
return Err(format!(
"[proxy] is on but {SECRET_FILE} holds another secret (a Velocity's?): remove the file, or turn [proxy] off"
));
}
Ok(_) => {}
Err(_) => std::fs::write(&file, &secret).map_err(|e| format!("{}: {e}", file.display()))?,
}
Ok(secret)
}
/// With `[proxy]` off again, the file the proxy put there goes, or the game would refuse everyone who joins
/// without a proxy.
pub fn remove_own_secret(dir: &Path) {
let own = std::fs::read(dir.join(OWN_SECRET)).ok();
let file = dir.join(SECRET_FILE);
if let (Some(own), Ok(existing)) = (own, std::fs::read(&file))
&& own.trim_ascii() == existing.trim_ascii()
{
let _ = std::fs::remove_file(file);
}
}
fn accept(listener: TcpListener, inner: &Arc<Inner>) {
for stream in listener.incoming() {
let Ok(stream) = stream else { continue };
let inner = inner.clone();
let _ = thread::Builder::new()
.name("Proxy client".into())
.spawn(move || {
if let Err(e) = serve(stream, &inner) {
inner.note("DEBUG", &format!("A connection to the proxy ended: {e}"));
}
});
}
}
fn serve(stream: TcpStream, inner: &Arc<Inner>) -> Result<(), String> {
let peer = stream.peer_addr().map_err(|e| e.to_string())?;
let mut c = Connection::from_stream(stream).map_err(|e| e.to_string())?;
c.reader
.set_read_timeout(Some(HANDSHAKE_TIMEOUT))
.map_err(|e| e.to_string())?;
let e = |e: wire::Error| e.to_string();
let (id, payload) = c.recv().map_err(e)?;
let hello =
handshake::decode_intention(id, &payload, handshake::PROXY_HOST_LIMIT).map_err(e)?;
match hello.intent {
handshake::STATUS => serve_status(&mut c, inner, hello.protocol),
handshake::LOGIN | handshake::TRANSFER => {
if hello.protocol != wire::PROTOCOL_VERSION {
let reason = wire::translated_json(
"multiplayer.disconnect.incompatible",
"Incompatible client! Please use the matching version",
);
let _ = c.send(
&wire::login::Clientbound::Disconnect(wire::login::Disconnect {
reason_json: reason,
})
.encode(),
);
return Ok(());
}
serve_login(c, inner, peer.ip())
}
other => Err(format!("unknown handshake intent {other}")),
}
}
fn serve_login(mut c: Connection, inner: &Arc<Inner>, peer: IpAddr) -> Result<(), String> {
let e = |e: wire::Error| e.to_string();
let (id, payload) = c.recv().map_err(e)?;
let name = match wire::login::Serverbound::decode(id, &payload).map_err(e)? {
wire::login::Serverbound::Hello(h) => h.name,
_ => return Err("expected a login hello".into()),
};
if !auth::valid_name(&name) {
return Err(format!("{name:?} is not a player name"));
}
match auth::profile(&mut c, inner.keys.as_ref(), &name)? {
Ok(who) => {
session::run(inner, c, who, peer);
Ok(())
}
Err(reason_json) => {
let _ = c.send(
&wire::login::Clientbound::Disconnect(wire::login::Disconnect { reason_json })
.encode(),
);
Ok(())
}
}
}
/// The server list: the game's own answer, or one that says it is restarting.
fn serve_status(c: &mut Connection, inner: &Arc<Inner>, protocol: i32) -> Result<(), String> {
let e = |e: wire::Error| e.to_string();
loop {
let (id, payload) = c.recv().map_err(e)?;
match status::Serverbound::decode(id, &payload).map_err(e)? {
status::Serverbound::StatusRequest(_) => {
let json = inner
.up
.load(Ordering::SeqCst)
.then(|| backend::status_json(inner.game).ok())
.flatten()
.unwrap_or_else(|| restarting_status(protocol));
c.send(
&status::Clientbound::StatusResponse(status::StatusResponse { json }).encode(),
)
.map_err(e)?;
}
status::Serverbound::PingRequest(ping) => {
c.send(&status::Clientbound::PongResponse(ping).encode())
.map_err(e)?;
return Ok(());
}
status::Serverbound::Unknown(..) => return Err("unknown status packet".into()),
}
}
}
/// What the server list shows while the game is down.
fn restarting_status(protocol: i32) -> String {
serde_json::json!({
"version": { "name": wire::version_name(), "protocol": protocol },
"players": { "max": 0, "online": 0 },
"description": { "translate": "horizon.proxy.restarting", "fallback": "Restarting, back in a moment" },
})
.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_restarting_status_is_translated_and_valid_json() {
let v: serde_json::Value = serde_json::from_str(&restarting_status(777)).unwrap();
assert_eq!(v["description"]["translate"], "horizon.proxy.restarting");
assert_eq!(v["version"]["protocol"], 777);
}
#[test]
fn the_game_listens_only_for_the_proxy_and_trusts_what_it_forwards() {
let p = game_properties(25599, true);
let get = |k: &str| p.iter().find(|(key, _)| key == k).map(|(_, v)| v.as_str());
assert_eq!(get("server-ip"), Some("127.0.0.1"));
assert_eq!(get("server-port"), Some("25599"));
assert_eq!(get("online-mode"), Some("false"));
}
#[test]
fn a_secret_that_is_not_ours_stops_the_start_and_ours_is_kept() {
let dir = std::env::temp_dir().join(format!("horizon-proxy-secret-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join(SECRET_FILE), "velocitys-own").unwrap();
assert!(secret(&dir).unwrap_err().contains("another secret"));
std::fs::remove_file(dir.join(SECRET_FILE)).unwrap();
let first = secret(&dir).unwrap();
assert_eq!(secret(&dir).unwrap(), first, "kept across starts");
assert_eq!(std::fs::read(dir.join(SECRET_FILE)).unwrap(), first);
remove_own_secret(&dir);
assert!(!dir.join(SECRET_FILE).exists());
let _ = std::fs::remove_dir_all(&dir);
}
}
+164
View File
@@ -0,0 +1,164 @@
//! What a game that is logged into again must be told, because the client said it once and will not say it
//! again: its settings (ClientInformation), its brand, the plugin channels it registered and, in play, its
//! chat session (the key its signed chat is checked with). The proxy hears each as it passes and keeps the last.
use super::wire::{Reader, Writer, ids, write_varint};
use std::collections::BTreeSet;
/// Which state a client's packets are in: ids mean different packets in each.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Phase {
Config,
Play,
}
const BRAND: &str = "minecraft:brand";
const REGISTER: &str = "minecraft:register";
const UNREGISTER: &str = "minecraft:unregister";
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Replay {
info: Option<Vec<u8>>,
brand: Option<Vec<u8>>,
channels: BTreeSet<String>,
chat_session: Option<Vec<u8>>,
}
/// A packet body: its id, then the payload.
pub fn body(id: i32, payload: &[u8]) -> Vec<u8> {
let mut out = Vec::with_capacity(payload.len() + 5);
write_varint(&mut out, id);
out.extend_from_slice(payload);
out
}
impl Replay {
/// Notes a packet the client sent in `phase`; the packet itself is relayed as it is.
pub fn note(&mut self, phase: Phase, id: i32, payload: &[u8]) {
use ids::{configuration::serverbound as cfg, play::serverbound as play};
let (info, custom) = match phase {
Phase::Config => (cfg::CLIENT_INFORMATION, cfg::CUSTOM_PAYLOAD),
Phase::Play => (play::CLIENT_INFORMATION, play::CUSTOM_PAYLOAD),
};
if id == info {
self.info = Some(payload.to_vec());
} else if id == custom {
self.note_payload(payload);
} else if phase == Phase::Play && id == play::CHAT_SESSION_UPDATE {
self.chat_session = Some(payload.to_vec());
}
}
fn note_payload(&mut self, payload: &[u8]) {
let mut r = Reader::new(payload);
let Ok(channel) = r.string(32767) else { return };
let data = r.rest();
match channel.as_str() {
BRAND => self.brand = Some(data.to_vec()),
REGISTER => {
self.channels.extend(split_channels(data));
}
UNREGISTER => {
for c in split_channels(data) {
self.channels.remove(&c);
}
}
_ => {}
}
}
// Used by the hold through a restart (the next change of #16251); tested here.
#[allow(dead_code)]
/// The configuration packets (bodies) that tell a game what the client told the last one.
pub fn config_packets(&self) -> Vec<Vec<u8>> {
use ids::configuration::serverbound as cfg;
let mut out = Vec::new();
if let Some(info) = &self.info {
out.push(body(cfg::CLIENT_INFORMATION, info));
}
if let Some(brand) = &self.brand {
out.push(body(cfg::CUSTOM_PAYLOAD, &payload(BRAND, brand)));
}
if !self.channels.is_empty() {
let list: Vec<&str> = self.channels.iter().map(String::as_str).collect();
out.push(body(
cfg::CUSTOM_PAYLOAD,
&payload(REGISTER, list.join("\0").as_bytes()),
));
}
out
}
#[allow(dead_code)]
/// The play packets (bodies) for a game that has just sent its Login: the chat session.
pub fn play_packets(&self) -> Vec<Vec<u8>> {
self.chat_session
.iter()
.map(|s| body(ids::play::serverbound::CHAT_SESSION_UPDATE, s))
.collect()
}
}
fn split_channels(data: &[u8]) -> impl Iterator<Item = String> + '_ {
data.split(|b| *b == 0)
.filter(|c| !c.is_empty())
.map(|c| String::from_utf8_lossy(c).into_owned())
}
#[allow(dead_code)]
fn payload(channel: &str, data: &[u8]) -> Vec<u8> {
let mut w = Writer::new();
w.string(channel);
w.bytes(data);
w.data
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn what_the_client_said_is_said_again_to_the_next_game() {
let mut replay = Replay::default();
// A configuration client: settings, brand, two channels, then one more and one taken back.
replay.note(Phase::Config, 0, &[1, 2, 3]);
replay.note(
Phase::Config,
2,
&payload(BRAND, &[7, b'h', b'o', b'r', b'i', b'z', b'o', b'n']),
);
replay.note(Phase::Config, 2, &payload(REGISTER, b"a:x\0b:y"));
replay.note(Phase::Play, 22, &payload(REGISTER, b"c:z"));
replay.note(Phase::Play, 22, &payload(UNREGISTER, b"a:x"));
// Play's own settings and chat session; a packet nobody keeps changes nothing.
replay.note(Phase::Play, 14, &[9, 9]);
replay.note(Phase::Play, 10, &[5; 40]);
replay.note(Phase::Play, 28, &[0; 8]);
let config = replay.config_packets();
assert_eq!(config.len(), 3);
assert_eq!(config[0], [&[0u8][..], &[9, 9]].concat());
let channels = &config[2];
assert_eq!(channels[0], 2);
assert!(channels.ends_with(b"b:y\0c:z"), "{channels:?}");
assert_eq!(
replay.play_packets(),
vec![[&[10u8][..], &[5; 40]].concat()]
);
}
#[test]
fn a_client_that_said_nothing_has_nothing_to_replay() {
assert!(Replay::default().config_packets().is_empty());
assert!(Replay::default().play_packets().is_empty());
}
#[test]
fn the_same_id_means_another_packet_in_the_other_state() {
let mut replay = Replay::default();
// 14 is ClientInformation in play but nothing kept in configuration; 0 is the reverse.
replay.note(Phase::Config, 14, &[1]);
replay.note(Phase::Play, 0, &[1]);
assert_eq!(replay, Replay::default());
}
}
+197
View File
@@ -0,0 +1,197 @@
//! One player through the proxy: logged in with the client, logged in to the game, then every packet relayed
//! both ways, as it is, while the client's own settings are kept for a later login to the game (replay.rs).
use super::{
Inner,
auth::Profile,
backend::{self, Backend, Identity},
replay::{Phase, Replay, body},
wire::{ConnReader, ConnWriter, Connection, ids, login},
};
use std::{
net::IpAddr,
sync::{Arc, Mutex},
thread,
time::Duration,
};
/// How long either side may be silent: the game and the client both keep each other alive every 15 s.
const SILENCE: Duration = Duration::from_secs(30);
/// Takes a client whose name is verified through the rest of its login and its whole play.
pub fn run(inner: &Arc<Inner>, mut client: Connection, who: Profile, peer: IpAddr) {
let identity = Identity {
address: peer,
uuid: who.uuid,
name: &who.name,
properties: &who.properties,
};
let game = match backend::login(inner.game, &inner.secret, &identity) {
Ok(game) => game,
Err(backend::Failure::Refused(json)) => return refuse(&mut client, json),
Err(e) => {
inner.note(
"WARN",
&format!("{} can't join: the game gave no login ({e})", who.name),
);
let json = super::wire::translated_json(
"horizon.proxy.unavailable",
"The server is not available right now. Try again in a moment.",
);
return refuse(&mut client, json);
}
};
let finished = (|| {
if inner.compression >= 0 {
client.send(
&login::Clientbound::Compression(login::Compression {
threshold: inner.compression,
})
.encode(),
)?;
client.set_compression(inner.compression);
}
client.send(
&login::Clientbound::LoginFinished(login::LoginFinished {
profile: login::GameProfile {
uuid: who.uuid,
name: who.name.clone(),
properties: who.properties.clone(),
},
session: who.uuid,
})
.encode(),
)?;
let (id, payload) = client.recv()?;
match login::Serverbound::decode(id, &payload)? {
login::Serverbound::LoginAcknowledged(_) => Ok(()),
_ => Err(super::wire::Error::new("unexpected packet during login")),
}
})();
if finished.is_err() {
game.writer.shutdown();
return;
}
let Backend {
reader: game_reader,
writer: mut game_writer,
..
} = game;
if backend::acknowledge(&mut game_writer).is_err() {
client.writer.shutdown();
return;
}
inner.note(
"INFO",
&format!(
"{} joined through the proxy (UUID {:032x})",
who.name, who.uuid
),
);
let (client_reader, client_writer) = client.split();
let _ = client_reader.set_read_timeout(Some(SILENCE));
let both = Arc::new(Pair {
client: Mutex::new(client_writer),
game: Mutex::new(game_writer),
});
let down = {
let both = both.clone();
thread::Builder::new()
.name("Proxy game reader".into())
.spawn(move || game_to_client(game_reader, &both))
};
client_to_game(client_reader, &both);
both.close();
if let Ok(down) = down {
let _ = down.join();
}
inner.note("INFO", &format!("{} left through the proxy", who.name));
}
fn refuse(client: &mut Connection, reason_json: String) {
let _ =
client.send(&login::Clientbound::Disconnect(login::Disconnect { reason_json }).encode());
}
/// The two writers, each behind its own lock so a packet is never written in two pieces.
struct Pair {
client: Mutex<ConnWriter>,
game: Mutex<ConnWriter>,
}
impl Pair {
fn close(&self) {
for w in [&self.client, &self.game] {
w.lock().unwrap_or_else(|e| e.into_inner()).shutdown();
}
}
}
fn game_to_client(mut game: ConnReader, both: &Pair) {
set_read_timeout(&game);
while let Ok((id, payload)) = game.recv() {
let sent = both
.client
.lock()
.unwrap_or_else(|e| e.into_inner())
.send(&body(id, &payload));
if sent.is_err() {
break;
}
}
both.close();
}
fn client_to_game(mut client: ConnReader, both: &Pair) {
let (mut phase, mut replay) = (Phase::Config, Replay::default());
while let Ok((id, payload)) = client.recv() {
replay.note(phase, id, &payload);
phase = next_phase(phase, id);
let sent = both
.game
.lock()
.unwrap_or_else(|e| e.into_inner())
.send(&body(id, &payload));
if sent.is_err() {
break;
}
}
}
fn set_read_timeout(reader: &ConnReader) {
let _ = reader.set_read_timeout(Some(SILENCE));
}
/// The state the client is in after it sent packet `id`: FinishConfiguration starts play, and
/// ConfigurationAcknowledged (the answer to the game's StartConfiguration) a new configuration.
pub fn next_phase(phase: Phase, id: i32) -> Phase {
match phase {
Phase::Config if id == ids::configuration::serverbound::FINISH_CONFIGURATION => Phase::Play,
Phase::Play if id == ids::play::serverbound::CONFIGURATION_ACKNOWLEDGED => Phase::Config,
other => other,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_client_follows_the_game_through_configuration_and_back() {
use ids::{configuration::serverbound as cfg, play::serverbound as play};
let mut phase = Phase::Config;
for (id, expected) in [
(cfg::CLIENT_INFORMATION, Phase::Config),
(cfg::FINISH_CONFIGURATION, Phase::Play),
(play::KEEP_ALIVE, Phase::Play),
// 3 means FinishConfiguration only in configuration: in play it is another packet.
(cfg::FINISH_CONFIGURATION, Phase::Play),
(play::CONFIGURATION_ACKNOWLEDGED, Phase::Config),
// 16 is ConfigurationAcknowledged only in play.
(play::CONFIGURATION_ACKNOWLEDGED, Phase::Config),
] {
phase = next_phase(phase, id);
assert_eq!(phase, expected, "after packet {id}");
}
}
}
+22
View File
@@ -0,0 +1,22 @@
//! The protocol pieces the proxy speaks, through the platform's public API (docs/layers.md).
pub use horizon_platform::protocol::{
Error, PROTOCOL_VERSION,
buf::{Reader, Writer, write_varint},
connection::{ConnReader, ConnWriter, Connection},
crypto::{offline_uuid, random_bytes, server_hash},
ids,
packets::{Empty, handshake, login, status},
rsa::RsaPrivateKey,
};
/// A translatable component as JSON, for the login and the server list: the client shows it in its own
/// language, and `fallback` where it has no such key (a vanilla client).
pub fn translated_json(key: &str, fallback: &str) -> String {
serde_json::json!({ "translate": key, "fallback": fallback }).to_string()
}
/// The game version a server list shows.
pub fn version_name() -> &'static str {
horizon_platform::version::VANILLA_VERSION
}
+66 -2
View File
@@ -158,11 +158,38 @@ fn prepare_world(shared: &Arc<Shared>, dir: &Path, config: &Config) -> Result<()
Ok(())
}
/// `[proxy]` on: starts its listener on the public port and says which port the game listens on, behind it.
/// With it off, a `forwarding.secret` the proxy left is removed (the game would refuse everyone else with it).
fn start_proxy(shared: &Arc<Shared>, dir: &Path, config: &Config) -> Result<Option<u16>, String> {
if !config.proxy.enabled {
crate::proxy::remove_own_secret(dir);
return Ok(None);
}
let mut config = config.clone();
if config.proxy.game_port == 0 {
let free = std::net::TcpListener::bind(("127.0.0.1", 0))
.map_err(|e| format!("[proxy] has no free port: {e}"))?;
config.proxy.game_port = free.local_addr().map_err(|e| e.to_string())?.port();
}
let log = shared.clone();
let proxy = crate::proxy::start(dir, &config, move |level, line| note(&log, level, line))?;
*shared.proxy.lock().unwrap() = Some(proxy);
note(
shared,
"INFO",
&format!(
"Proxy on port {}: the game listens on 127.0.0.1:{} behind it",
config.server.port, config.proxy.game_port
),
);
Ok(Some(config.proxy.game_port))
}
enum Exit {
/// Asked to stop, or the game ended by itself without an error (`stop` typed in its console, the EULA
/// not agreed, the port taken): not restarted.
Stopped,
/// Asked to restart (`horizon-server restart`).
/// Asked to restart (`horizon-server restart`, `/restart`).
Restart,
Crashed(ExitStatus),
/// Started on another release, the game wasn't up in time and was stopped.
@@ -227,6 +254,8 @@ pub fn run(dir: &Path) -> Result<(), String> {
crate::discord::start(shared.clone(), config.discord.clone());
schedule_backups(shared.clone());
check_updates(shared.clone());
// `[proxy]` is decided here, once: its listener lives as long as the supervisor.
let game_port = start_proxy(&shared, dir, &config)?;
let console = console_lines();
let mut crashes: Vec<Instant> = Vec::new();
let mut watch: Option<Watch> = None;
@@ -234,7 +263,11 @@ pub fn run(dir: &Path) -> Result<(), String> {
loop {
// Read again before every start: edits to server.toml apply on the next restart, and so does a
// switch to another release.
let config = Config::load(dir)?;
let mut config = Config::load(dir)?;
config.proxy.enabled = game_port.is_some();
if let Some(port) = game_port {
config.proxy.game_port = port;
}
// Queued while the game ran, stopped or was already stopping: applied before this start,
// whatever ended the last game, not left for some later restart.
if let Some(w) = apply_queued(&shared, dir, &config) {
@@ -280,6 +313,7 @@ pub fn run(dir: &Path) -> Result<(), String> {
w.deadline = Instant::now() + w.timeout;
}
let exit = supervise(&shared, &mut child, &console, &mut watch);
shared.proxy_game_up(false);
{
let mut state = shared.state.lock().unwrap();
state.engine_pid = None;
@@ -761,6 +795,7 @@ fn supervise(
let mut not_up = false;
let mut handled = signals::requests();
loop {
shared.proxy_game_up(up(shared));
if let Some(w) = watch.as_ref() {
if up(shared) {
came_up(shared, w);
@@ -842,6 +877,16 @@ fn supervise(
match child.try_wait() {
Ok(Some(_)) if not_up => return Exit::NotUp,
Ok(Some(_)) if restart => return Exit::Restart,
// `/restart` in game, on the console or through RCON: the game saved, stopped and says so by its exit
// code. A shutdown asked for since wins: it is not started again.
Ok(Some(status))
if status.code() == Some(engine::RESTART_EXIT_CODE)
&& !shared.shutdown.load(Ordering::SeqCst) =>
{
note(shared, "INFO", "Restarting the server");
shared.set_phase(Phase::Restarting);
return Exit::Restart;
}
Ok(Some(status)) if stopping.is_some() || status.success() => return Exit::Stopped,
Ok(Some(status)) => return Exit::Crashed(status),
Ok(None) => thread::sleep(Duration::from_millis(50)),
@@ -1219,6 +1264,25 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}
#[cfg(unix)]
#[test]
fn the_restart_exit_code_starts_the_game_again_and_others_do_not() {
let dir = std::env::temp_dir().join(format!("horizon-server-exit-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let (_console, console) = mpsc::channel();
let run = |script: &str| {
let shared = Shared::with_install(&dir, None);
let mut child = game(script);
(supervise(&shared, &mut child, &console, &mut None), shared)
};
let (exit, shared) = run(&format!("exit {}", engine::RESTART_EXIT_CODE));
assert!(matches!(exit, Exit::Restart));
assert_eq!(shared.state.lock().unwrap().phase, Phase::Restarting);
assert!(matches!(run("exit 0").0, Exit::Stopped));
assert!(matches!(run("exit 1").0, Exit::Crashed(_)));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn one_supervisor_per_folder() {
let dir = std::env::temp_dir().join(format!("horizon-server-lock-{}", std::process::id()));
+3 -1
View File
@@ -69,7 +69,8 @@ Prints one JSON object on stdout and exits 0. `horizon` is the build's version;
0,
1,
2,
101
101,
75
],
"horizon": "<version>",
"launch_api": "0.1.0",
@@ -137,5 +138,6 @@ Prints one JSON object on stdout and exits 0. `horizon` is the build's version;
| 1 | The game stopped on an error. A crash report was saved. |
| 2 | The command line was refused: an unknown option, a missing or bad value. No crash report. |
| 101 | The game panicked. A crash report was saved. |
| 75 | A dedicated server saved and stopped on `/restart`: horizon-server starts it again. |
When a crash report is saved, the last line on stderr is `Horizon: crash report saved to` followed by the report's path.
+25
View File
@@ -407,6 +407,31 @@ A running server takes the difficulty, the default game mode, the whitelist, the
player limit and the backup settings at once; the rest applies at its next start, and `config set` says which. `--json` prints that as `saved`, `applied`
and `restart`.
**`/restart`** saves and stops the game as `/stop` does, and the server starts it again: from in game, the console,
RCON and `horizon-server cmd restart`, for owners (the permission `/stop` needs). The command exists only on a
server `horizon-server` runs, so vanilla's command tree is unchanged elsewhere; a game started by hand answers on its
console that nothing would start it again, and does nothing. The game program is looked up again at every start, so a
new engine put in place beforehand (`engine.path`, or the release `current` points at) runs after the restart. Put it
there with `mv` or `install`, which swap the file: copying over the running program fails or corrupts it.
Players are disconnected for now; [#16251](https://github.com/horizon-rust/horizon/issues/16251) is about holding them through it.
### Behind the server's own proxy: `[proxy]`
[proxy]
enabled = true
Off by default. With it on, `horizon-server` listens on `server.port` itself and runs the game on 127.0.0.1, on
`game_port` (0: a free port), with Velocity forwarding and a secret it makes (`forwarding.secret`, kept in
`.horizon/`; a `forwarding.secret` you wrote for a Velocity of your own stops the start). It logs each player in
itself, with `online_mode` as set (the account check and the encryption), then logs into the game as that player
and passes the packets along as they are. The game sees the player's real UUID, name, skin and address, and its own
whitelist, bans and player limit apply as always. The server list is the game's own, or says "restarting" while the
game is down. It is read when `horizon-server` starts, not when the game does.
It is not a general proxy: one game, the one this server runs, and no routing (for several servers, see
[#7484](https://github.com/horizon-rust/horizon/issues/7484)). Clients of an older release (`accept_older_clients`)
can't join through it. The game's own port is private: a login that doesn't come from the proxy is refused.
The server restarts the game by itself if it crashes: after 5 s, then waiting longer each time, up to 5
minutes. It keeps the crash report and gives up after 5 crashes in 10 minutes, and `status` says why.
+324
View File
@@ -0,0 +1,324 @@
#!/usr/bin/env python3
"""horizon-server's `[proxy]` on a real server, end to end (docs/hosting.md, #16251).
python3 tools/server_proxy_e2e.py --bin-dir target/fast
`horizon-server run` supervises Horizon's dedicated server with `[proxy] enabled = true`: the supervisor listens
on the public port, the game on 127.0.0.1 behind it with Velocity forwarding. The player is a small client
written here (the protocol of 26.3: handshake, login, configuration, play), because the check is on one TCP
connection: a horizon-bots player cannot say whether its connection was the same one.
Steps, each checked:
1. The server list through the proxy is the game's own (its motd).
2. The client joins through the proxy, reaches play (the game's Login packet) and answers keep-alives.
3. It chats a command (`/give`, after the console ops it) and the game runs it for the player: the inventory.
4. The game saved the player under the UUID of the name: the proxy forwarded who the client is.
5. A client that connects to the game's own port directly is refused (forwarding is required).
Everything runs in a fresh temporary folder with fresh HORIZON_HOME and XDG folders and no audio. Logs go to
--out. Build first:
cargo build --profile fast -p horizon-client -p horizon-server
"""
import argparse
import hashlib
import json
import os
from pathlib import Path
import shutil
import socket
import struct
import subprocess
import sys
import tempfile
import threading
import time
import uuid as uuidlib
import zlib
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 ''
PROTOCOL = 777
NAME = 'Prox'
# Packet ids of protocol 777 (crates/horizon-protocol/src/ids.rs).
HANDSHAKE, LOGIN_START, LOGIN_ACK = 0, 0, 3
CFG_FINISH, CFG_KEEP_ALIVE, CFG_PING, CFG_KNOWN_PACKS, CFG_DISCONNECT = 3, 4, 5, 15, 2
PLAY_KEEP_ALIVE_IN, PLAY_KEEP_ALIVE_OUT, PLAY_LOGIN, PLAY_DISCONNECT, PLAY_START_CONFIG = 45, 28, 50, 32, 120
PLAY_CHAT_COMMAND, PLAY_CONFIG_ACK = 7, 16
class Failed(Exception):
pass
def check(condition, what):
if not condition:
raise Failed(what)
print(f'ok: {what}')
def varint(n):
out = b''
n &= 0xFFFFFFFF
while True:
b, n = n & 0x7F, n >> 7
out += bytes([b | 0x80]) if n else bytes([b])
if not n:
return out
def read_varint(buf, i=0):
n = shift = 0
while True:
b = buf[i]
i += 1
n |= (b & 0x7F) << shift
if not b & 0x80:
return n, i
shift += 7
def string(s):
b = s.encode()
return varint(len(b)) + b
def offline_uuid(name):
md5 = bytearray(hashlib.md5(('OfflinePlayer:' + name).encode()).digest())
md5[6] = (md5[6] & 0x0F) | 0x30
md5[8] = (md5[8] & 0x3F) | 0x80
return uuidlib.UUID(bytes=bytes(md5))
class Client:
"""A player on one TCP connection. `packets` is every (state, id) it received, in order; `closed` is set
when the connection ended, with `reason` (a disconnect packet's payload, or the error)."""
def __init__(self, port, name):
self.name = name
self.sock = socket.create_connection(('127.0.0.1', port), timeout=60)
self.local = self.sock.getsockname()
self.threshold = -1
self.state = 'login'
self.packets = []
self.closed = threading.Event()
self.reason = None
self.in_play = threading.Event()
self.send(HANDSHAKE, varint(PROTOCOL) + string('localhost') + struct.pack('>H', port) + varint(2))
self.send(LOGIN_START, string(name) + offline_uuid(name).bytes)
self.thread = threading.Thread(target=self._read, daemon=True)
self.thread.start()
def send(self, packet_id, payload=b''):
body = varint(packet_id) + payload
if self.threshold >= 0:
body = varint(0) + body if len(body) < self.threshold else varint(len(body)) + zlib.compress(body)
self.sock.sendall(varint(len(body)) + body)
def _recv_exact(self, n):
data = b''
while len(data) < n:
chunk = self.sock.recv(n - len(data))
if not chunk:
raise EOFError('connection closed')
data += chunk
return data
def _frame(self):
length = shift = 0
while True:
b = self._recv_exact(1)[0]
length |= (b & 0x7F) << shift
if not b & 0x80:
break
shift += 7
body = self._recv_exact(length)
if self.threshold >= 0:
size, i = read_varint(body)
body = zlib.decompress(body[i:]) if size else body[i:]
return read_varint(body)[0], body[read_varint(body)[1]:]
def _read(self):
try:
while True:
packet_id, payload = self._frame()
self.packets.append((self.state, packet_id))
self._handle(packet_id, payload)
except (EOFError, OSError, zlib.error) as e:
self.closed.set()
self.reason = self.reason or f'{type(e).__name__}: {e}'
self.in_play.clear()
def _handle(self, i, payload):
if self.state == 'login':
if i == 0:
self.reason = ('login disconnect', payload)
elif i == 3:
self.threshold = read_varint(payload)[0]
elif i == 2:
self.send(LOGIN_ACK)
self.state = 'config'
elif self.state == 'config':
if i == CFG_DISCONNECT:
self.reason = ('config disconnect', payload)
elif i == CFG_KEEP_ALIVE:
self.send(CFG_KEEP_ALIVE, payload)
elif i == CFG_PING:
self.send(CFG_PING, payload)
elif i == CFG_KNOWN_PACKS:
self.send(7, varint(0)) # no known packs: the server sends every registry whole
elif i == CFG_FINISH:
self.send(CFG_FINISH)
self.state = 'play'
elif self.state == 'play':
if i == PLAY_DISCONNECT:
self.reason = ('play disconnect', payload)
elif i == PLAY_KEEP_ALIVE_IN:
self.send(PLAY_KEEP_ALIVE_OUT, payload)
elif i == PLAY_LOGIN:
self.in_play.set()
elif i == PLAY_START_CONFIG:
self.in_play.clear()
self.send(PLAY_CONFIG_ACK)
self.state = 'config'
def wait_play(self, seconds=120):
if not self.in_play.wait(seconds):
raise Failed(f'{self.name} did not reach play (state {self.state}, reason {self.reason})')
def command(self, text):
self.send(PLAY_CHAT_COMMAND, string(text))
def close(self):
try:
self.sock.close()
except OSError:
pass
def status_motd(port):
sock = socket.create_connection(('127.0.0.1', port), timeout=30)
sock.sendall(varint(len(b := varint(0) + varint(PROTOCOL) + string('localhost') + struct.pack('>H', port) + varint(1))) + b)
sock.sendall(b'\x01\x00')
data = b''
while True:
chunk = sock.recv(65536)
data += chunk
try:
length, i = read_varint(data)
if len(data) >= i + length:
break
except IndexError:
pass
if not chunk:
break
sock.close()
body = data[i:i + length]
_, j = read_varint(body)
text_len, k = read_varint(body, j)
return json.loads(body[k:k + text_len])
def cli(bins, folder, *words):
return subprocess.run([str(bins / f'horizon-server{EXE}'), '--dir', str(folder), *map(str, words)],
capture_output=True, text=True, env=lab.game_env(), timeout=120)
def wait_for(condition, what, seconds=60):
deadline = time.time() + seconds
while time.time() < deadline:
if condition():
return
time.sleep(0.5)
raise Failed(f'timed out waiting for {what}')
def scenario_join(bins, folder, port, game_port):
motd = status_motd(port)
check('proxy e2e' in json.dumps(motd['description']), f'the server list through the proxy is the game\'s ({motd})')
player = Client(port, NAME)
player.wait_play()
check(player.state == 'play', f'{NAME} reached play through the proxy')
out = cli(bins, folder, 'cmd', 'op', NAME)
check(out.returncode == 0, f'the console ops {NAME} ({out.stdout!r} {out.stderr!r})')
time.sleep(2)
player.command('give @s minecraft:diamond 7')
wait_for(lambda: '7' in cli(bins, folder, 'cmd', 'data', 'get', 'entity', NAME, 'Inventory').stdout,
'the game to run the player\'s /give')
check(True, f'{NAME}\'s command ran in the game and gave the items')
cli(bins, folder, 'cmd', 'save-all')
saved = folder / 'world' / 'players' / 'data' / f'{offline_uuid(NAME)}.dat'
wait_for(saved.exists, f'the game to save {NAME} as {offline_uuid(NAME)}')
check(True, f'the game knows {NAME} by the UUID the login gave: {saved.name}')
direct = Client(game_port, 'Direct')
wait_for(direct.closed.is_set, 'a direct login to be refused')
check(not direct.in_play.is_set(), 'a client that skips the proxy is refused by the game')
return player
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-proxy-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())
work = Path(tempfile.mkdtemp(prefix='hz-server-proxy-'))
folder = work / 'server'
folder.mkdir()
port, game_port = lab.free_port(), lab.free_port()
(folder / 'eula.txt').write_text('eula=true\n')
(folder / 'server.toml').write_text(f'''[server]
port = {port}
motd = "proxy e2e"
online_mode = false
whitelist = false
view_distance = 3
simulation_distance = 3
[properties]
level-type = "minecraft:flat"
generate-structures = false
spawn-protection = 0
[engine]
path = {json.dumps(str(bins / f'horizon-client{EXE}'))}
resources = {json.dumps(str(lab.RESOURCES.resolve()))}
restart_on_crash = false
[backups]
every = "0"
[panel]
bind = ""
[proxy]
enabled = true
game_port = {game_port}
''')
server = Server([bins / f'horizon-server{EXE}', '--dir', folder, 'run'], folder, out / 'server.log')
player = None
try:
server.wait_for(DONE, 300)
player = scenario_join(bins, folder, port, game_port)
cli(bins, folder, 'stop')
server.process.wait(timeout=120)
except (Failed, RuntimeError, OSError, subprocess.TimeoutExpired) as e:
sys.exit(f'server_proxy_e2e failed (logs in {out}): {e}')
finally:
if player:
player.close()
server.stop()
shutil.rmtree(work, ignore_errors=True)
print('server_proxy_e2e: a player joined through the proxy, chatted a command the game ran for them, and '
'the game knew them by their real UUID')
if __name__ == '__main__':
main()