chunkedge_network/lib.rs
1#![doc = include_str!("../README.md")]
2
3mod byte_channel;
4mod connect;
5mod legacy_ping;
6mod packet_io;
7
8use std::borrow::Cow;
9use std::collections::BTreeMap;
10use std::net::{IpAddr, Ipv4Addr, SocketAddr, SocketAddrV4};
11use std::sync::atomic::{AtomicUsize, Ordering};
12use std::sync::Arc;
13use std::time::Duration;
14
15use anyhow::Context;
16pub use async_trait::async_trait;
17use bevy_app::prelude::*;
18use bevy_ecs::prelude::*;
19use bevy_ecs::system::SystemParam;
20use chunkedge_protocol::packets::configuration::client_information_c2s::ParticleMode;
21use chunkedge_protocol::packets::play::client_information_c2s::{
22 ChatMode, DisplayedSkinParts, MainArm,
23};
24use chunkedge_protocol::text::IntoText;
25use chunkedge_protocol::VarInt;
26use chunkedge_server::client::{ClientBundle, ClientBundleArgs, Properties, SpawnClientsSet};
27use chunkedge_server::registry::biome::{Biome, BiomeId};
28use chunkedge_server::registry::dimension_type::{DimensionType, DimensionTypeId};
29use chunkedge_server::registry::{BiomeRegistry, DimensionTypeRegistry, Registry, TagsRegistry};
30use chunkedge_server::{
31 CompressionThreshold, Ident, Server, Text, MINECRAFT_VERSION, PROTOCOL_VERSION,
32};
33use connect::do_accept_loop;
34pub use connect::HandshakeData;
35use flume::{Receiver, Sender};
36pub use legacy_ping::{ServerListLegacyPingPayload, ServerListLegacyPingResponse};
37use rand::rngs::OsRng;
38use rsa::traits::PublicKeyParts;
39use rsa::RsaPrivateKey;
40use serde::Serialize;
41use tokio::net::UdpSocket;
42use tokio::runtime::{Handle, Runtime};
43use tokio::sync::Semaphore;
44use tokio::time;
45use tracing::error;
46use uuid::Uuid;
47
48pub struct NetworkPlugin;
49
50impl Plugin for NetworkPlugin {
51 fn build(&self, app: &mut App) {
52 if let Err(e) = build_plugin(app) {
53 error!("failed to build network plugin: {e:#}");
54 }
55 }
56}
57
58// World state required during a client's login process.
59#[allow(clippy::struct_field_names)]
60#[derive(SystemParam)]
61struct WorldLoginStateParam<'w> {
62 biome_registry: Res<'w, BiomeRegistry>,
63 dimension_registry: Res<'w, DimensionTypeRegistry>,
64 tag_registry: Res<'w, TagsRegistry>,
65}
66
67#[allow(clippy::struct_field_names)]
68#[derive(Debug, Clone)]
69pub(crate) struct WorldLoginState {
70 pub biome_registry: Registry<BiomeId, Biome>,
71 pub dimension_registry: Registry<DimensionTypeId, DimensionType>,
72 pub tag_registry: BTreeMap<Ident<String>, BTreeMap<Ident<String>, Vec<VarInt>>>,
73}
74
75fn build_plugin(app: &mut App) -> anyhow::Result<()> {
76 let threshold = app
77 .world()
78 .get_resource::<Server>()
79 .context("missing server resource")?
80 .compression_threshold();
81
82 let settings = app
83 .world_mut()
84 .get_resource_or_insert_with(NetworkSettings::default);
85
86 let (new_clients_send, new_clients_recv) = flume::bounded(64);
87
88 let rsa_key = RsaPrivateKey::new(&mut OsRng, 1024)?;
89
90 let public_key_der =
91 rsa_der::public_key_to_der(&rsa_key.n().to_bytes_be(), &rsa_key.e().to_bytes_be())
92 .into_boxed_slice();
93
94 #[allow(clippy::if_then_some_else_none)]
95 let runtime = if settings.tokio_handle.is_none() {
96 Some(Runtime::new()?)
97 } else {
98 None
99 };
100
101 let tokio_handle = match &runtime {
102 Some(rt) => rt.handle().clone(),
103 None => settings.tokio_handle.clone().unwrap(),
104 };
105
106 let shared = SharedNetworkState(Arc::new(SharedNetworkStateInner {
107 callbacks: settings.callbacks.clone(),
108 address: settings.address,
109 incoming_byte_limit: settings.incoming_byte_limit,
110 outgoing_byte_limit: settings.outgoing_byte_limit,
111 connection_sema: Arc::new(Semaphore::new(
112 settings.max_connections.min(Semaphore::MAX_PERMITS),
113 )),
114 player_count: AtomicUsize::new(0),
115 max_players: settings.max_players,
116 connection_mode: settings.connection_mode.clone(),
117 threshold,
118 tokio_handle,
119 _tokio_runtime: runtime,
120 new_clients_send,
121 new_clients_recv,
122 rsa_key,
123 public_key_der,
124 http_client: reqwest::Client::new(),
125 }));
126
127 app.insert_resource(shared.clone());
128
129 // System for starting the accept loop.
130 let start_accept_loop = move |shared: Res<SharedNetworkState>,
131 world_state: WorldLoginStateParam| {
132 let _guard = shared.0.tokio_handle.enter();
133 let world_login_state = WorldLoginState {
134 biome_registry: world_state.biome_registry.clone(),
135 dimension_registry: world_state.dimension_registry.clone(),
136 tag_registry: world_state.tag_registry.registries.clone(),
137 };
138
139 // Start accepting new connections.
140 tokio::spawn(do_accept_loop(shared.clone(), world_login_state));
141 };
142
143 let start_broadcast_to_lan_loop = move |shared: Res<SharedNetworkState>| {
144 let _guard = shared.0.tokio_handle.enter();
145
146 tokio::spawn(do_broadcast_to_lan_loop(shared.clone()));
147 };
148
149 // System for spawning new clients.
150 let spawn_new_clients = move |world: &mut World| {
151 for _ in 0..shared.0.new_clients_recv.len() {
152 match shared.0.new_clients_recv.try_recv() {
153 Ok(args) => world.spawn(ClientBundle::new(args)),
154 Err(_) => break,
155 };
156 }
157 };
158
159 // Start accepting connections in `PostStartup` to allow user startup code to
160 // run first.
161 app.add_systems(PostStartup, start_accept_loop);
162
163 // Start the loop that will broadcast messages for the LAN discovery list.
164 app.add_systems(PostStartup, start_broadcast_to_lan_loop);
165
166 // Spawn new clients before the event loop starts.
167 app.add_systems(PreUpdate, spawn_new_clients.in_set(SpawnClientsSet));
168
169 Ok(())
170}
171
172#[derive(Resource, Clone)]
173pub struct SharedNetworkState(Arc<SharedNetworkStateInner>);
174
175impl SharedNetworkState {
176 pub fn connection_mode(&self) -> &ConnectionMode {
177 &self.0.connection_mode
178 }
179
180 pub fn player_count(&self) -> &AtomicUsize {
181 &self.0.player_count
182 }
183
184 pub fn max_players(&self) -> usize {
185 self.0.max_players
186 }
187}
188struct SharedNetworkStateInner {
189 callbacks: ErasedNetworkCallbacks,
190 address: SocketAddr,
191 incoming_byte_limit: usize,
192 outgoing_byte_limit: usize,
193 /// Limits the number of simultaneous connections to the server before the
194 /// play state.
195 connection_sema: Arc<Semaphore>,
196 //// The number of clients in the play state, past the login state.
197 player_count: AtomicUsize,
198 max_players: usize,
199 connection_mode: ConnectionMode,
200 threshold: CompressionThreshold,
201 tokio_handle: Handle,
202 // Holding a runtime handle is not enough to keep tokio working. We need
203 // to store the runtime here so we don't drop it.
204 _tokio_runtime: Option<Runtime>,
205 /// Sender for new clients past the login stage.
206 new_clients_send: Sender<ClientBundleArgs>,
207 /// Receiver for new clients past the login stage.
208 new_clients_recv: Receiver<ClientBundleArgs>,
209 /// The RSA keypair used for encryption with clients.
210 rsa_key: RsaPrivateKey,
211 /// The public part of `rsa_key` encoded in DER, which is an ASN.1 format.
212 /// This is sent to clients during the authentication process.
213 public_key_der: Box<[u8]>,
214 /// For session server requests.
215 http_client: reqwest::Client,
216}
217
218/// Contains information about a new client joining the server.
219#[derive(Debug)]
220#[non_exhaustive]
221pub struct NewClientInfo {
222 /// The username of the new client.
223 pub username: String,
224 /// The UUID of the new client.
225 pub uuid: Uuid,
226 /// The remote address of the new client.
227 pub ip: IpAddr,
228 /// The requested view distance of the new client.
229 pub view_distance: u8,
230 /// Client locale from the configuration phase.
231 pub locale: String,
232 pub chat_mode: ChatMode,
233 pub chat_colors: bool,
234 pub displayed_skin_parts: DisplayedSkinParts,
235 pub main_arm: MainArm,
236 pub enable_text_filtering: bool,
237 pub allow_server_listings: bool,
238 pub particle_mode: ParticleMode,
239 /// The client's properties from the game profile. Typically contains a
240 /// `textures` property with the skin and cape of the player.
241 pub properties: Properties,
242}
243
244/// Settings for [`NetworkPlugin`]. Note that mutations to these fields have no
245/// effect after the plugin is built.
246#[derive(Resource, Clone)]
247pub struct NetworkSettings {
248 pub callbacks: ErasedNetworkCallbacks,
249 /// The [`Handle`] to the tokio runtime the server will use. If `None` is
250 /// provided, the server will create its own tokio runtime at startup.
251 ///
252 /// # Default Value
253 ///
254 /// `None`
255 pub tokio_handle: Option<Handle>,
256 /// The maximum number of simultaneous initial connections to the server.
257 ///
258 /// This only considers the connections _before_ the play state where the
259 /// client is spawned into the world..
260 ///
261 /// # Default Value
262 ///
263 /// The default value is left unspecified and may change in future versions.
264 pub max_connections: usize,
265 /// # Default Value
266 ///
267 /// `20`
268 pub max_players: usize,
269 /// The socket address the server will be bound to.
270 ///
271 /// # Default Value
272 ///
273 /// `0.0.0.0:25565`, which will listen on every available network interface.
274 pub address: SocketAddr,
275 /// The connection mode. This determines if client authentication and
276 /// encryption should take place and if the server should get the player
277 /// data from a proxy.
278 ///
279 /// **NOTE:** Mutations to this field have no effect if
280 ///
281 /// # Default Value
282 ///
283 /// [`ConnectionMode::Online`]
284 pub connection_mode: ConnectionMode,
285 /// The maximum capacity (in bytes) of the buffer used to hold incoming
286 /// packet data.
287 ///
288 /// A larger capacity reduces the chance that a client needs to be
289 /// disconnected due to a full buffer, but increases potential
290 /// memory usage.
291 ///
292 /// # Default Value
293 ///
294 /// The default value is left unspecified and may change in future versions.
295 pub incoming_byte_limit: usize,
296 /// The maximum capacity (in bytes) of the buffer used to hold outgoing
297 /// packet data.
298 ///
299 /// A larger capacity reduces the chance that a client needs to be
300 /// disconnected due to a full buffer, but increases potential
301 /// memory usage.
302 ///
303 /// # Default Value
304 ///
305 /// The default value is left unspecified and may change in future versions.
306 pub outgoing_byte_limit: usize,
307}
308
309impl Default for NetworkSettings {
310 fn default() -> Self {
311 Self {
312 callbacks: ErasedNetworkCallbacks::default(),
313 tokio_handle: None,
314 max_connections: 1024,
315 max_players: 20,
316 address: SocketAddrV4::new(Ipv4Addr::new(0, 0, 0, 0), 25565).into(),
317 connection_mode: ConnectionMode::Online {
318 prevent_proxy_connections: false,
319 },
320 incoming_byte_limit: 2097152, // 2 MiB
321 outgoing_byte_limit: 8388608, // 8 MiB
322 }
323 }
324}
325
326/// A type-erased wrapper around an [`NetworkCallbacks`] object.
327#[derive(Clone)]
328pub struct ErasedNetworkCallbacks {
329 // TODO: do some shenanigans when async-in-trait is stabilized.
330 inner: Arc<dyn NetworkCallbacks>,
331}
332
333impl ErasedNetworkCallbacks {
334 pub fn new<C: NetworkCallbacks>(callbacks: C) -> Self {
335 Self {
336 inner: Arc::new(callbacks),
337 }
338 }
339}
340
341impl Default for ErasedNetworkCallbacks {
342 fn default() -> Self {
343 Self {
344 inner: Arc::new(()),
345 }
346 }
347}
348
349impl<T: NetworkCallbacks> From<T> for ErasedNetworkCallbacks {
350 fn from(value: T) -> Self {
351 Self::new(value)
352 }
353}
354
355/// This trait uses [`mod@async_trait`].
356#[async_trait]
357pub trait NetworkCallbacks: Send + Sync + 'static {
358 /// Called when the server receives a Server List Ping query.
359 /// Data for the response can be provided or the query can be ignored.
360 ///
361 /// This function is called from within a tokio runtime.
362 ///
363 /// # Default Implementation
364 ///
365 /// A default placeholder response is returned.
366 async fn server_list_ping(
367 &self,
368 shared: &SharedNetworkState,
369 remote_addr: SocketAddr,
370 handshake_data: &HandshakeData,
371 ) -> ServerListPing {
372 #![allow(unused_variables)]
373
374 ServerListPing::Respond {
375 online_players: shared.player_count().load(Ordering::Relaxed) as i32,
376 max_players: shared.max_players() as i32,
377 player_sample: vec![],
378 description: "A ChunkEdge Server".into_text(),
379 favicon_png: &[],
380 version_name: MINECRAFT_VERSION.to_owned(),
381 protocol: PROTOCOL_VERSION,
382 }
383 }
384
385 /// Called when the server receives a Server List Legacy Ping query.
386 /// Data for the response can be provided or the query can be ignored.
387 ///
388 /// This function is called from within a tokio runtime.
389 ///
390 /// # Default Implementation
391 ///
392 /// [`server_list_ping`][Self::server_list_ping] re-used.
393 async fn server_list_legacy_ping(
394 &self,
395 shared: &SharedNetworkState,
396 remote_addr: SocketAddr,
397 payload: ServerListLegacyPingPayload,
398 ) -> ServerListLegacyPing {
399 #![allow(unused_variables)]
400
401 let handshake_data = match payload {
402 ServerListLegacyPingPayload::Pre1_7 {
403 protocol,
404 hostname,
405 port,
406 } => HandshakeData {
407 protocol_version: protocol,
408 server_address: hostname,
409 server_port: port,
410 },
411 _ => HandshakeData::default(),
412 };
413
414 match self
415 .server_list_ping(shared, remote_addr, &handshake_data)
416 .await
417 {
418 ServerListPing::Respond {
419 online_players,
420 max_players,
421 player_sample,
422 description,
423 favicon_png,
424 version_name,
425 protocol,
426 } => ServerListLegacyPing::Respond(
427 ServerListLegacyPingResponse::new(protocol, online_players, max_players)
428 .version(version_name)
429 .description(description.to_legacy_lossy()),
430 ),
431 ServerListPing::Ignore => ServerListLegacyPing::Ignore,
432 }
433 }
434
435 /// This function is called every 1.5 seconds to broadcast a packet over the
436 /// local network in order to advertise the server to the multiplayer
437 /// screen with a configurable MOTD.
438 ///
439 /// # Default Implementation
440 ///
441 /// The default implementation returns [`BroadcastToLan::Disabled`],
442 /// disabling LAN discovery.
443 async fn broadcast_to_lan(&self, shared: &SharedNetworkState) -> BroadcastToLan {
444 #![allow(unused_variables)]
445
446 BroadcastToLan::Disabled
447 }
448
449 /// Called for each client (after successful authentication if online mode
450 /// is enabled) to determine if they can join the server.
451 /// - If `Err(reason)` is returned, then the client is immediately
452 /// disconnected with `reason` as the displayed message.
453 /// - Otherwise, `Ok(f)` is returned and the client will continue the login
454 /// process. This _may_ result in a new client being spawned with the
455 /// [`ClientBundle`] components. `f` is stored along with the client and
456 /// is called when the client is disconnected.
457 ///
458 /// `f` is a callback function used for handling resource cleanup when the
459 /// client is dropped. This is useful because a new client entity is not
460 /// necessarily spawned into the world after a successful login.
461 ///
462 /// This method is called from within a tokio runtime, and is the
463 /// appropriate place to perform asynchronous operations such as
464 /// database queries which may take some time to complete.
465 ///
466 /// # Default Implementation
467 ///
468 /// TODO
469 ///
470 /// [`Client`]: chunkedge_server::client::Client
471 async fn login(
472 &self,
473 shared: &SharedNetworkState,
474 info: &NewClientInfo,
475 ) -> Result<CleanupFn, Text> {
476 let _ = info;
477
478 let max_players = shared.max_players();
479
480 let success = shared
481 .player_count()
482 .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |n| {
483 (n < max_players).then_some(n + 1)
484 })
485 .is_ok();
486
487 if success {
488 let shared = shared.clone();
489
490 Ok(Box::new(move || {
491 let prev = shared.player_count().fetch_sub(1, Ordering::SeqCst);
492 debug_assert_ne!(prev, 0, "player count underflowed");
493 }))
494 } else {
495 // TODO: use correct translation key.
496 Err("Server Full".into_text())
497 }
498 }
499
500 /// Called upon every client login to obtain the full URL to use for session
501 /// server requests. This is done to authenticate player accounts. This
502 /// method is not called unless [online mode] is enabled.
503 ///
504 /// It is assumed that upon successful request, a structure matching the
505 /// description in the [wiki](https://wiki.vg/Protocol_Encryption#Server) was obtained.
506 /// Providing a URL that does not return such a structure will result in a
507 /// disconnect for every client that connects.
508 ///
509 /// The arguments are described in the linked wiki article.
510 ///
511 /// # Default Implementation
512 ///
513 /// Uses the official Minecraft session server. This is formatted as
514 /// `https://sessionserver.mojang.com/session/minecraft/hasJoined?username=<username>&serverId=<auth-digest>&ip=<player-ip>`.
515 ///
516 /// [online mode]: ConnectionMode::Online
517 async fn session_server(
518 &self,
519 shared: &SharedNetworkState,
520 username: &str,
521 auth_digest: &str,
522 player_ip: &IpAddr,
523 ) -> String {
524 if shared.connection_mode()
525 == (&ConnectionMode::Online {
526 prevent_proxy_connections: true,
527 })
528 {
529 format!("https://sessionserver.mojang.com/session/minecraft/hasJoined?username={username}&serverId={auth_digest}&ip={player_ip}")
530 } else {
531 format!("https://sessionserver.mojang.com/session/minecraft/hasJoined?username={username}&serverId={auth_digest}")
532 }
533 }
534}
535
536/// A callback function called when the associated client is dropped. See
537/// [`NetworkCallbacks::login`] for more information.
538pub type CleanupFn = Box<dyn FnOnce() + Send + Sync + 'static>;
539struct CleanupOnDrop(Option<CleanupFn>);
540
541impl Drop for CleanupOnDrop {
542 fn drop(&mut self) {
543 if let Some(f) = self.0.take() {
544 f();
545 }
546 }
547}
548
549/// The default network callbacks. Useful as a placeholder.
550impl NetworkCallbacks for () {}
551
552/// Describes how new connections to the server are handled.
553#[derive(Clone, PartialEq)]
554#[non_exhaustive]
555pub enum ConnectionMode {
556 /// The "online mode" fetches all player data (username, UUID, and
557 /// properties) from the [configured session server] and enables
558 /// encryption.
559 ///
560 /// This mode should be used by all publicly exposed servers which are not
561 /// behind a proxy.
562 ///
563 /// [configured session server]: NetworkCallbacks::session_server
564 Online {
565 /// Determines if client IP validation should take place during
566 /// authentication.
567 ///
568 /// When `prevent_proxy_connections` is enabled, clients can no longer
569 /// log-in if they connected to the Yggdrasil server using a different
570 /// IP than the one used to connect to this server.
571 ///
572 /// This is used by the default implementation of
573 /// [`NetworkCallbacks::session_server`]. A different implementation may
574 /// choose to ignore this value.
575 prevent_proxy_connections: bool,
576 },
577 /// Disables client authentication with the configured session server.
578 /// Clients can join with any username and UUID they choose, potentially
579 /// gaining privileges they would not otherwise have. Additionally,
580 /// encryption is disabled and Minecraft's default skins will be used.
581 ///
582 /// This mode should be used for development purposes only and not for
583 /// publicly exposed servers.
584 Offline,
585 /// This mode should be used under one of the following situations:
586 /// - The server is behind a [BungeeCord]/[Waterfall] proxy with IP
587 /// forwarding enabled.
588 /// - The server is behind a [Velocity] proxy configured to use the `legacy`
589 /// forwarding mode.
590 ///
591 /// All player data (username, UUID, and properties) is fetched from the
592 /// proxy, but no attempt is made to stop connections originating from
593 /// elsewhere. As a result, you must ensure clients connect through the
594 /// proxy and are unable to connect to the server directly. Otherwise,
595 /// clients can use any username or UUID they choose similar to
596 /// [`ConnectionMode::Offline`].
597 ///
598 /// To protect against this, a firewall can be used. However,
599 /// [`ConnectionMode::Velocity`] is recommended as a secure alternative.
600 ///
601 /// [BungeeCord]: https://www.spigotmc.org/wiki/bungeecord/
602 /// [Waterfall]: https://github.com/PaperMC/Waterfall
603 /// [Velocity]: https://velocitypowered.com/
604 BungeeCord,
605 /// This mode is used when the server is behind a [Velocity] proxy
606 /// configured with the forwarding mode `modern`.
607 ///
608 /// All player data (username, UUID, and properties) is fetched from the
609 /// proxy and all connections originating from outside Velocity are
610 /// blocked.
611 ///
612 /// [Velocity]: https://velocitypowered.com/
613 Velocity {
614 /// The secret key used to prevent connections from outside Velocity.
615 /// The proxy and ChunkEdge must be configured to use the same secret key.
616 secret: Arc<str>,
617 },
618}
619
620/// The result of the Server List Ping [callback].
621///
622/// [callback]: NetworkCallbacks::server_list_ping
623#[derive(Clone, Default, Debug)]
624pub enum ServerListPing<'a> {
625 /// Responds to the server list ping with the given information.
626 Respond {
627 /// Displayed as the number of players on the server.
628 online_players: i32,
629 /// Displayed as the maximum number of players allowed on the server at
630 /// a time.
631 max_players: i32,
632 /// The list of players visible by hovering over the player count.
633 ///
634 /// Has no effect if this list is empty.
635 player_sample: Vec<PlayerSampleEntry>,
636 /// A description of the server.
637 description: Text,
638 /// The server's icon as the bytes of a PNG image.
639 /// The image must be 64x64 pixels.
640 ///
641 /// No icon is used if the slice is empty.
642 favicon_png: &'a [u8],
643 /// The version name of the server. Displayed when client is using a
644 /// different protocol.
645 ///
646 /// Can be formatted using `ยง` and format codes. Or use
647 /// [`chunkedge_protocol::text::Text::to_legacy_lossy`].
648 version_name: String,
649 /// The protocol version of the server.
650 protocol: i32,
651 },
652 /// Ignores the query and disconnects from the client.
653 #[default]
654 Ignore,
655}
656
657/// The result of the Server List Legacy Ping [callback].
658///
659/// [callback]: NetworkCallbacks::server_list_legacy_ping
660#[derive(Clone, Default, Debug)]
661pub enum ServerListLegacyPing {
662 /// Responds to the server list legacy ping with the given information.
663 Respond(ServerListLegacyPingResponse),
664 /// Ignores the query and disconnects from the client.
665 #[default]
666 Ignore,
667}
668
669/// The result of the Broadcast To Lan [callback].
670///
671/// [callback]: NetworkCallbacks::broadcast_to_lan
672#[derive(Clone, Default, Debug)]
673pub enum BroadcastToLan<'a> {
674 /// Disabled Broadcast To Lan.
675 #[default]
676 Disabled,
677 /// Send packet to broadcast to LAN every 1.5 seconds with specified MOTD.
678 Enabled(Cow<'a, str>),
679}
680
681/// Represents an individual entry in the player sample.
682#[derive(Clone, Debug, Serialize)]
683pub struct PlayerSampleEntry {
684 /// The name of the player.
685 ///
686 /// This string can contain
687 /// [legacy formatting codes](https://minecraft.wiki/w/Formatting_codes).
688 pub name: String,
689 /// The player UUID.
690 pub id: Uuid,
691}
692
693#[allow(clippy::infinite_loop)]
694async fn do_broadcast_to_lan_loop(shared: SharedNetworkState) {
695 let port = shared.0.address.port();
696
697 let Ok(socket) = UdpSocket::bind("0.0.0.0:0").await else {
698 tracing::error!("Failed to bind to UDP socket for broadcast to LAN");
699 return;
700 };
701
702 loop {
703 let motd = match shared.0.callbacks.inner.broadcast_to_lan(&shared).await {
704 BroadcastToLan::Disabled => {
705 time::sleep(Duration::from_millis(1500)).await;
706 continue;
707 }
708 BroadcastToLan::Enabled(motd) => motd,
709 };
710
711 let message = format!("[MOTD]{motd}[/MOTD][AD]{port}[/AD]");
712
713 if let Err(e) = socket.send_to(message.as_bytes(), "224.0.2.60:4445").await {
714 tracing::warn!("Failed to send broadcast to LAN packet: {}", e);
715 }
716
717 // wait 1.5 seconds
718 tokio::time::sleep(std::time::Duration::from_millis(1500)).await;
719 }
720}