#![doc = include_str!("../README.md")] #![deny( rustdoc::broken_intra_doc_links, rustdoc::private_intra_doc_links, rustdoc::missing_crate_level_docs, rustdoc::invalid_codeblock_attributes, rustdoc::invalid_rust_codeblocks, rustdoc::bare_urls, rustdoc::invalid_html_tags )] #![warn( trivial_casts, trivial_numeric_casts, unused_lifetimes, unused_import_braces, unreachable_pub, clippy::dbg_macro )] mod byte_channel; mod connect; mod packet_io; use std::net::{IpAddr, Ipv4Addr, SocketAddr, SocketAddrV4}; use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::Arc; use anyhow::Context; pub use async_trait::async_trait; use bevy_app::prelude::*; use bevy_ecs::prelude::*; use connect::do_accept_loop; use flume::{Receiver, Sender}; use rand::rngs::OsRng; use rsa::{PublicKeyParts, RsaPrivateKey}; use serde::Serialize; use tokio::runtime::{Handle, Runtime}; use tokio::sync::Semaphore; use tracing::error; use uuid::Uuid; use valence_client::{ClientBundle, ClientBundleArgs, Properties, SpawnClientsSet}; use valence_core::text::Text; use valence_core::Server; pub struct NetworkPlugin; impl Plugin for NetworkPlugin { fn build(&self, app: &mut App) { if let Err(e) = build_plugin(app) { error!("failed to build network plugin: {e:#}"); } } } fn build_plugin(app: &mut App) -> anyhow::Result<()> { let compression_threshold = app .world .get_resource::() .context("missing server resource")? .compression_threshold(); let settings = app .world .get_resource_or_insert_with(NetworkSettings::default); let (new_clients_send, new_clients_recv) = flume::bounded(64); let rsa_key = RsaPrivateKey::new(&mut OsRng, 1024)?; let public_key_der = rsa_der::public_key_to_der(&rsa_key.n().to_bytes_be(), &rsa_key.e().to_bytes_be()) .into_boxed_slice(); let runtime = if settings.tokio_handle.is_none() { Some(Runtime::new()?) } else { None }; let tokio_handle = match &runtime { Some(rt) => rt.handle().clone(), None => settings.tokio_handle.clone().unwrap(), }; let shared = SharedNetworkState(Arc::new(SharedNetworkStateInner { callbacks: settings.callbacks.clone(), address: settings.address, incoming_byte_limit: settings.incoming_byte_limit, outgoing_byte_limit: settings.outgoing_byte_limit, connection_sema: Arc::new(Semaphore::new( settings.max_connections.min(Semaphore::MAX_PERMITS), )), player_count: AtomicUsize::new(0), max_players: settings.max_players, connection_mode: settings.connection_mode.clone(), compression_threshold, tokio_handle, _tokio_runtime: runtime, new_clients_send, new_clients_recv, rsa_key, public_key_der, http_client: reqwest::Client::new(), })); app.insert_resource(shared.clone()); // System for starting the accept loop. let start_accept_loop = move |shared: Res| { let _guard = shared.0.tokio_handle.enter(); // Start accepting new connections. tokio::spawn(do_accept_loop(shared.clone())); }; // System for spawning new clients. let spawn_new_clients = move |world: &mut World| { for _ in 0..shared.0.new_clients_recv.len() { match shared.0.new_clients_recv.try_recv() { Ok(args) => world.spawn(ClientBundle::new(args)), Err(_) => break, }; } }; // Start accepting connections in `PostStartup` to allow user startup code to // run first. app.add_system( start_accept_loop .in_schedule(CoreSchedule::Startup) .in_base_set(StartupSet::PostStartup), ); // Spawn new clients before the event loop starts. app.add_system(spawn_new_clients.in_set(SpawnClientsSet)); Ok(()) } #[derive(Resource, Clone)] pub struct SharedNetworkState(Arc); impl SharedNetworkState { pub fn connection_mode(&self) -> &ConnectionMode { &self.0.connection_mode } pub fn player_count(&self) -> &AtomicUsize { &self.0.player_count } pub fn max_players(&self) -> usize { self.0.max_players } } struct SharedNetworkStateInner { callbacks: ErasedNetworkCallbacks, address: SocketAddr, incoming_byte_limit: usize, outgoing_byte_limit: usize, /// Limits the number of simultaneous connections to the server before the /// play state. connection_sema: Arc, //// The number of clients in the play state, past the login state. player_count: AtomicUsize, max_players: usize, connection_mode: ConnectionMode, compression_threshold: Option, tokio_handle: Handle, // Holding a runtime handle is not enough to keep tokio working. We need // to store the runtime here so we don't drop it. _tokio_runtime: Option, /// Sender for new clients past the login stage. new_clients_send: Sender, /// Receiver for new clients past the login stage. new_clients_recv: Receiver, /// The RSA keypair used for encryption with clients. rsa_key: RsaPrivateKey, /// The public part of `rsa_key` encoded in DER, which is an ASN.1 format. /// This is sent to clients during the authentication process. public_key_der: Box<[u8]>, /// For session server requests. http_client: reqwest::Client, } /// Contains information about a new client joining the server. #[derive(Debug)] #[non_exhaustive] pub struct NewClientInfo { /// The username of the new client. pub username: String, /// The UUID of the new client. pub uuid: Uuid, /// The remote address of the new client. pub ip: IpAddr, /// The client's properties from the game profile. Typically contains a /// `textures` property with the skin and cape of the player. pub properties: Properties, } /// Settings for [`NetworkPlugin`]. Note that mutations to these fields have no /// effect after the plugin is built. #[derive(Resource, Clone)] pub struct NetworkSettings { pub callbacks: ErasedNetworkCallbacks, /// The [`Handle`] to the tokio runtime the server will use. If `None` is /// provided, the server will create its own tokio runtime at startup. /// /// # Default Value /// /// `None` pub tokio_handle: Option, /// The maximum number of simultaneous initial connections to the server. /// /// This only considers the connections _before_ the play state where the /// client is spawned into the world.. /// /// # Default Value /// /// The default value is left unspecified and may change in future versions. pub max_connections: usize, /// # Default Value /// /// `20` pub max_players: usize, /// The socket address the server will be bound to. /// /// # Default Value /// /// `0.0.0.0:25565`, which will listen on every available network interface. pub address: SocketAddr, /// The connection mode. This determines if client authentication and /// encryption should take place and if the server should get the player /// data from a proxy. /// /// **NOTE:** Mutations to this field have no effect if /// /// # Default Value /// /// [`ConnectionMode::Online`] pub connection_mode: ConnectionMode, /// The maximum capacity (in bytes) of the buffer used to hold incoming /// packet data. /// /// A larger capacity reduces the chance that a client needs to be /// disconnected due to a full buffer, but increases potential /// memory usage. /// /// # Default Value /// /// The default value is left unspecified and may change in future versions. pub incoming_byte_limit: usize, /// The maximum capacity (in bytes) of the buffer used to hold outgoing /// packet data. /// /// A larger capacity reduces the chance that a client needs to be /// disconnected due to a full buffer, but increases potential /// memory usage. /// /// # Default Value /// /// The default value is left unspecified and may change in future versions. pub outgoing_byte_limit: usize, } impl Default for NetworkSettings { fn default() -> Self { Self { callbacks: ErasedNetworkCallbacks::default(), tokio_handle: None, max_connections: 1024, max_players: 20, address: SocketAddrV4::new(Ipv4Addr::new(0, 0, 0, 0), 25565).into(), connection_mode: ConnectionMode::Online { prevent_proxy_connections: false, }, incoming_byte_limit: 2097152, // 2 MiB outgoing_byte_limit: 8388608, // 8 MiB } } } /// A type-erased wrapper around an [`NetworkCallbacks`] object. #[derive(Clone)] pub struct ErasedNetworkCallbacks { // TODO: do some shenanigans when async-in-trait is stabilized. inner: Arc, } impl ErasedNetworkCallbacks { pub fn new(callbacks: impl NetworkCallbacks) -> Self { Self { inner: Arc::new(callbacks), } } } impl Default for ErasedNetworkCallbacks { fn default() -> Self { Self { inner: Arc::new(()), } } } impl From for ErasedNetworkCallbacks { fn from(value: T) -> Self { Self::new(value) } } /// This trait uses [`mod@async_trait`]. #[async_trait] pub trait NetworkCallbacks: Send + Sync + 'static { /// Called when the server receives a Server List Ping query. /// Data for the response can be provided or the query can be ignored. /// /// This function is called from within a tokio runtime. /// /// # Default Implementation /// /// A default placeholder response is returned. async fn server_list_ping( &self, shared: &SharedNetworkState, remote_addr: SocketAddr, protocol_version: i32, ) -> ServerListPing { #![allow(unused_variables)] ServerListPing::Respond { online_players: shared.player_count().load(Ordering::Relaxed) as i32, max_players: shared.max_players() as i32, player_sample: vec![], description: "A Valence Server".into(), favicon_png: &[], } } /// Called for each client (after successful authentication if online mode /// is enabled) to determine if they can join the server. /// - If `Err(reason)` is returned, then the client is immediately /// disconnected with `reason` as the displayed message. /// - Otherwise, `Ok(f)` is returned and the client will continue the login /// process. This _may_ result in a new client being spawned with the /// [`ClientBundle`] components. `f` is stored along with the client and /// is called when the client is disconnected. /// /// `f` is a callback function used for handling resource cleanup when the /// client is dropped. This is useful because a new client entity is not /// necessarily spawned into the world after a successful login. /// /// This method is called from within a tokio runtime, and is the /// appropriate place to perform asynchronous operations such as /// database queries which may take some time to complete. /// /// # Default Implementation /// /// TODO /// /// [`Client`]: valence::client::Client async fn login( &self, shared: &SharedNetworkState, info: &NewClientInfo, ) -> Result { let _ = info; let max_players = shared.max_players(); let success = shared .player_count() .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |n| { if n < max_players { Some(n + 1) } else { None } }) .is_ok(); if success { let shared = shared.clone(); Ok(Box::new(move || { let prev = shared.player_count().fetch_sub(1, Ordering::SeqCst); debug_assert_ne!(prev, 0, "player count underflowed"); })) } else { // TODO: use correct translation key. Err("Server Full".into()) } } /// Called upon every client login to obtain the full URL to use for session /// server requests. This is done to authenticate player accounts. This /// method is not called unless [online mode] is enabled. /// /// It is assumed that upon successful request, a structure matching the /// description in the [wiki](https://wiki.vg/Protocol_Encryption#Server) was obtained. /// Providing a URL that does not return such a structure will result in a /// disconnect for every client that connects. /// /// The arguments are described in the linked wiki article. /// /// # Default Implementation /// /// Uses the official Minecraft session server. This is formatted as /// `https://sessionserver.mojang.com/session/minecraft/hasJoined?username=&serverId=&ip=`. /// /// [online mode]: ConnectionMode::Online async fn session_server( &self, shared: &SharedNetworkState, username: &str, auth_digest: &str, player_ip: &IpAddr, ) -> String { if shared.connection_mode() == (&ConnectionMode::Online { prevent_proxy_connections: true, }) { format!("https://sessionserver.mojang.com/session/minecraft/hasJoined?username={username}&serverId={auth_digest}&ip={player_ip}") } else { format!("https://sessionserver.mojang.com/session/minecraft/hasJoined?username={username}&serverId={auth_digest}") } } } /// A callback function called when the associated client is dropped. See /// [`NetworkCallbacks::login`] for more information. pub type CleanupFn = Box; struct CleanupOnDrop(Option); impl Drop for CleanupOnDrop { fn drop(&mut self) { if let Some(f) = self.0.take() { f(); } } } /// The default network callbacks. Useful as a placeholder. impl NetworkCallbacks for () {} /// Describes how new connections to the server are handled. #[derive(Clone, PartialEq)] #[non_exhaustive] pub enum ConnectionMode { /// The "online mode" fetches all player data (username, UUID, and /// properties) from the [configured session server] and enables /// encryption. /// /// This mode should be used by all publicly exposed servers which are not /// behind a proxy. /// /// [configured session server]: NetworkCallbacks::session_server Online { /// Determines if client IP validation should take place during /// authentication. /// /// When `prevent_proxy_connections` is enabled, clients can no longer /// log-in if they connected to the Yggdrasil server using a different /// IP than the one used to connect to this server. /// /// This is used by the default implementation of /// [`NetworkCallbacks::session_server`]. A different implementation may /// choose to ignore this value. prevent_proxy_connections: bool, }, /// Disables client authentication with the configured session server. /// Clients can join with any username and UUID they choose, potentially /// gaining privileges they would not otherwise have. Additionally, /// encryption is disabled and Minecraft's default skins will be used. /// /// This mode should be used for development purposes only and not for /// publicly exposed servers. Offline, /// This mode should be used under one of the following situations: /// - The server is behind a [BungeeCord]/[Waterfall] proxy with IP /// forwarding enabled. /// - The server is behind a [Velocity] proxy configured to use the `legacy` /// forwarding mode. /// /// All player data (username, UUID, and properties) is fetched from the /// proxy, but no attempt is made to stop connections originating from /// elsewhere. As a result, you must ensure clients connect through the /// proxy and are unable to connect to the server directly. Otherwise, /// clients can use any username or UUID they choose similar to /// [`ConnectionMode::Offline`]. /// /// To protect against this, a firewall can be used. However, /// [`ConnectionMode::Velocity`] is recommended as a secure alternative. /// /// [BungeeCord]: https://www.spigotmc.org/wiki/bungeecord/ /// [Waterfall]: https://github.com/PaperMC/Waterfall /// [Velocity]: https://velocitypowered.com/ BungeeCord, /// This mode is used when the server is behind a [Velocity] proxy /// configured with the forwarding mode `modern`. /// /// All player data (username, UUID, and properties) is fetched from the /// proxy and all connections originating from outside Velocity are /// blocked. /// /// [Velocity]: https://velocitypowered.com/ Velocity { /// The secret key used to prevent connections from outside Velocity. /// The proxy and Valence must be configured to use the same secret key. secret: Arc, }, } /// The result of the Server List Ping [callback]. /// /// [callback]: NetworkCallbacks::server_list_ping #[derive(Clone, Default, Debug)] pub enum ServerListPing<'a> { /// Responds to the server list ping with the given information. Respond { /// Displayed as the number of players on the server. online_players: i32, /// Displayed as the maximum number of players allowed on the server at /// a time. max_players: i32, /// The list of players visible by hovering over the player count. /// /// Has no effect if this list is empty. player_sample: Vec, /// A description of the server. description: Text, /// The server's icon as the bytes of a PNG image. /// The image must be 64x64 pixels. /// /// No icon is used if the slice is empty. favicon_png: &'a [u8], }, /// Ignores the query and disconnects from the client. #[default] Ignore, } /// Represents an individual entry in the player sample. #[derive(Clone, Debug, Serialize)] pub struct PlayerSampleEntry { /// The name of the player. /// /// This string can contain /// [legacy formatting codes](https://minecraft.fandom.com/wiki/Formatting_codes). pub name: String, /// The player UUID. pub id: Uuid, }