mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-13 20:13:13 -10:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bb486e33cc | ||
|
|
5c639b802a |
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."
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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", ®()).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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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) {
|
||||
{
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
@@ -21,6 +21,7 @@ mod management_tls;
|
||||
mod mcp;
|
||||
mod panel;
|
||||
mod process;
|
||||
mod proxy;
|
||||
mod prune;
|
||||
mod query;
|
||||
mod rcon;
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()));
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user