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}