Skip to main content

netget/server/minecraft/
mod.rs

1//! Minecraft Java Edition server: the server-list ping (modern and legacy) and the front of
2//! login. Rust owns framing, the handshake, ping/pong and every bound; the handler decides the
3//! server-list entry and the reason a joining player is disconnected. No game is run.
4pub mod actions;
5pub mod wire;
6
7use crate::llm::action_helper::call_llm;
8use crate::llm::actions::protocol_trait::ActionResult;
9use crate::logging::emit::Log;
10use crate::protocol::{Event, SpawnContext};
11use crate::server::connection::ConnectionId;
12use crate::state::server::{ConnectionState, ConnectionStatus, ProtocolConnectionInfo};
13use anyhow::{Context, Result};
14use serde_json::{json, Value};
15use std::{net::SocketAddr, time::Duration};
16use tokio::{
17    io::{AsyncReadExt, AsyncWriteExt},
18    net::{TcpListener, TcpStream},
19};
20
21/// Longest per-packet deadline a caller may configure.
22pub const MAX_IDLE_TIMEOUT_SECS: u64 = 300;
23
24fn idle(ctx: &SpawnContext) -> Result<Duration> {
25    let secs = ctx
26        .startup_params
27        .as_ref()
28        .map(|p| p.get_optional_u64("idle_timeout_secs"))
29        .transpose()?
30        .flatten()
31        .unwrap_or(wire::IO_TIMEOUT.as_secs());
32    anyhow::ensure!(
33        (1..=MAX_IDLE_TIMEOUT_SECS).contains(&secs),
34        "idle_timeout_secs must be between 1 and {MAX_IDLE_TIMEOUT_SECS}"
35    );
36    Ok(Duration::from_secs(secs))
37}
38
39pub async fn spawn(ctx: SpawnContext) -> Result<SocketAddr> {
40    let idle = idle(&ctx)?;
41    let listener = TcpListener::bind(ctx.legacy_listen_addr()).await?;
42    let addr = listener.local_addr()?;
43    Log::new(Some(&ctx.status_tx)).info(format!("Minecraft listening on {addr}"));
44    let state = ctx.state.clone();
45    let server_id = ctx.server_id;
46    let accept = tokio::spawn(async move {
47        let limiter = crate::server::accept_bounded::ConnectionLimiter::new(
48            crate::server::accept_bounded::DEFAULT_MAX_CONNECTIONS,
49        );
50        loop {
51            let (socket, peer, permit) = match crate::server::accept_bounded::accept_bounded(
52                &listener,
53                &limiter,
54                b"",
55                "Minecraft",
56                Some(&ctx.status_tx),
57            )
58            .await
59            {
60                Ok(v) => v,
61                Err(_) => break,
62            };
63            let id = ConnectionId::new(ctx.state.get_next_unified_id().await);
64            let now = crate::utils::clock::Instant::now();
65            ctx.state
66                .add_connection_to_server(
67                    server_id,
68                    ConnectionState {
69                        id,
70                        remote_addr: peer,
71                        local_addr: addr,
72                        bytes_sent: 0,
73                        bytes_received: 0,
74                        packets_sent: 0,
75                        packets_received: 0,
76                        last_activity: now,
77                        status: ConnectionStatus::Active,
78                        status_changed_at: now,
79                        protocol_info: ProtocolConnectionInfo::empty(),
80                    },
81                )
82                .await;
83            let child = ctx.clone();
84            ctx.state
85                .spawn_server_task(server_id, async move {
86                    let _permit = permit;
87                    if let Err(e) = session(&child, id, socket, peer, idle).await {
88                        Log::new(Some(&child.status_tx))
89                            .warn(format!("Minecraft connection {id} ended: {e}"));
90                    }
91                    child
92                        .state
93                        .update_connection_status(server_id, id, ConnectionStatus::Closed)
94                        .await;
95                    let _ = child.status_tx.send("__UPDATE_UI__".into());
96                })
97                .await;
98        }
99    });
100    state.register_server_task(server_id, accept).await;
101    Ok(addr)
102}
103
104async fn send<W: tokio::io::AsyncWrite + Unpin>(
105    ctx: &SpawnContext,
106    id: ConnectionId,
107    w: &mut W,
108    bytes: &[u8],
109) -> Result<()> {
110    tokio::time::timeout(wire::IO_TIMEOUT, async {
111        w.write_all(bytes).await?;
112        w.flush().await
113    })
114    .await
115    .context("Minecraft write deadline")??;
116    ctx.state
117        .update_connection_stats(
118            ctx.server_id,
119            id,
120            None,
121            Some(bytes.len() as u64),
122            None,
123            Some(1),
124        )
125        .await;
126    Ok(())
127}
128
129async fn received(ctx: &SpawnContext, id: ConnectionId, len: usize) {
130    ctx.state
131        .update_connection_stats(ctx.server_id, id, Some(len as u64), None, Some(1), None)
132        .await;
133}
134
135fn outcome(ctx: &SpawnContext, id: ConnectionId, operation: &str, decision: &str) {
136    let summary = format!("Minecraft connection {id} operation={operation} decision={decision}");
137    let log = Log::new(Some(&ctx.status_tx));
138    if decision.starts_with("fail_closed_") || decision == "model_silent" {
139        log.error(summary);
140    } else {
141        log.info(summary);
142    }
143}
144
145/// Ask the handler and return the one answer it gave: (action name, data). `Ok(None)` when it
146/// gave none (`model_silent`, already logged); `Err` on a failure (already logged).
147async fn decide(
148    ctx: &SpawnContext,
149    id: ConnectionId,
150    event: Event,
151) -> Result<Option<(String, Value)>> {
152    let result = match call_llm(
153        &ctx.llm_client,
154        &ctx.state,
155        ctx.server_id,
156        Some(id),
157        &event,
158        &actions::MinecraftProtocol,
159    )
160    .await
161    {
162        Ok(result) => result,
163        Err(error) => {
164            outcome(ctx, id, event.id(), "fail_closed_llm_error");
165            return Err(error);
166        }
167    };
168    if !result.failures.is_empty() {
169        outcome(ctx, id, event.id(), "fail_closed_invalid_reply");
170        anyhow::bail!("Minecraft handler supplied an invalid action");
171    }
172    let mut found = None;
173    let mut pending = result.protocol_results;
174    while let Some(result) = pending.pop() {
175        match result {
176            ActionResult::Custom { name, data } if name.starts_with("minecraft_") => {
177                if found.is_some() {
178                    outcome(ctx, id, event.id(), "fail_closed_invalid_reply");
179                    anyhow::bail!("Multiple Minecraft answers to one request");
180                }
181                found = Some((name, data));
182            }
183            ActionResult::Multiple(items) => pending.extend(items),
184            _ => {}
185        }
186    }
187    if found.is_none() {
188        outcome(ctx, id, event.id(), "model_silent");
189    }
190    Ok(found)
191}
192
193/// A status answer for this event, or `None` when the connection should close unanswered.
194async fn status_answer(ctx: &SpawnContext, id: ConnectionId, event: Event) -> Option<Value> {
195    match decide(ctx, id, event).await {
196        Ok(Some((name, data))) if name == "minecraft_status" => {
197            outcome(ctx, id, "minecraft_status_request", "model_answer");
198            Some(data)
199        }
200        Ok(Some((name, _))) if name == "minecraft_refuse" => {
201            outcome(ctx, id, "minecraft_status_request", "model_reject");
202            None
203        }
204        Ok(Some(_)) => {
205            outcome(
206                ctx,
207                id,
208                "minecraft_status_request",
209                "fail_closed_invalid_reply",
210            );
211            None
212        }
213        // A ping the handler did not answer is closed: a fabricated status would assert a
214        // server-list entry nobody decided.
215        Ok(None) | Err(_) => None,
216    }
217}
218
219async fn session(
220    ctx: &SpawnContext,
221    id: ConnectionId,
222    socket: TcpStream,
223    peer: SocketAddr,
224    idle: Duration,
225) -> Result<()> {
226    let (mut reader, mut writer) = tokio::io::split(socket);
227    let first = match tokio::time::timeout(idle, reader.read_u8()).await {
228        Ok(Ok(b)) => b,
229        Ok(Err(_)) => return Ok(()),
230        Err(_) => anyhow::bail!("no handshake within {}s", idle.as_secs()),
231    };
232    if first == 0xFE {
233        let ping = wire::read_legacy_ping(&mut reader).await?;
234        received(ctx, id, 1).await;
235        let event = Event::new(
236            &actions::STATUS_EVENT,
237            json!({
238                "protocol_version": ping.protocol_version,
239                "server_address": ping.server_address,
240                "server_port": ping.server_port,
241                "legacy": true,
242                "remote_addr": peer.to_string(),
243            }),
244        );
245        if let Some(answer) = status_answer(ctx, id, event).await {
246            let reply = wire::encode_legacy_reply(&ping, &answer)?;
247            send(ctx, id, &mut writer, &reply).await?;
248        }
249        return Ok(());
250    }
251    let first = [first];
252    let mut chained = (&first[..]).chain(&mut reader);
253    let Some((packet_id, body)) =
254        wire::read_packet(&mut chained, wire::MAX_SERVERBOUND_PACKET, idle).await?
255    else {
256        return Ok(());
257    };
258    received(ctx, id, body.len() + 2).await;
259    anyhow::ensure!(
260        packet_id == 0x00,
261        "first packet is 0x{packet_id:02x}, not a handshake"
262    );
263    let handshake = wire::parse_handshake(&body)?;
264    if handshake.next_state == wire::STATE_STATUS {
265        status_state(ctx, id, &mut reader, &mut writer, peer, idle, &handshake).await
266    } else {
267        login_state(ctx, id, &mut reader, &mut writer, peer, idle, &handshake).await
268    }
269}
270
271async fn status_state<R, W>(
272    ctx: &SpawnContext,
273    id: ConnectionId,
274    reader: &mut R,
275    writer: &mut W,
276    peer: SocketAddr,
277    idle: Duration,
278    handshake: &wire::Handshake,
279) -> Result<()>
280where
281    R: tokio::io::AsyncRead + Unpin,
282    W: tokio::io::AsyncWrite + Unpin,
283{
284    let mut answered = false;
285    loop {
286        let Some((packet_id, body)) =
287            wire::read_packet(reader, wire::MAX_SERVERBOUND_PACKET, idle).await?
288        else {
289            return Ok(());
290        };
291        received(ctx, id, body.len() + 2).await;
292        match packet_id {
293            // One status request per connection, as the vanilla server allows.
294            0x00 if !answered && body.is_empty() => {
295                answered = true;
296                let event = Event::new(
297                    &actions::STATUS_EVENT,
298                    json!({
299                        "protocol_version": handshake.protocol_version,
300                        "server_address": handshake.server_address,
301                        "server_port": handshake.server_port,
302                        "legacy": false,
303                        "remote_addr": peer.to_string(),
304                    }),
305                );
306                let Some(answer) = status_answer(ctx, id, event).await else {
307                    return Ok(());
308                };
309                let json = wire::status_json(&answer, handshake.protocol_version)?;
310                let mut body = Vec::new();
311                wire::put_string(&mut body, &json);
312                send(ctx, id, writer, &wire::frame(0x00, &body)).await?;
313            }
314            // Ping: echo the payload and close, as the vanilla server does.
315            0x01 if body.len() == 8 => {
316                send(ctx, id, writer, &wire::frame(0x01, &body)).await?;
317                return Ok(());
318            }
319            other => anyhow::bail!(
320                "unexpected status packet 0x{other:02x} ({} bytes)",
321                body.len()
322            ),
323        }
324    }
325}
326
327async fn login_state<R, W>(
328    ctx: &SpawnContext,
329    id: ConnectionId,
330    reader: &mut R,
331    writer: &mut W,
332    peer: SocketAddr,
333    idle: Duration,
334    handshake: &wire::Handshake,
335) -> Result<()>
336where
337    R: tokio::io::AsyncRead + Unpin,
338    W: tokio::io::AsyncWrite + Unpin,
339{
340    let Some((packet_id, body)) =
341        wire::read_packet(reader, wire::MAX_SERVERBOUND_PACKET, idle).await?
342    else {
343        return Ok(());
344    };
345    received(ctx, id, body.len() + 2).await;
346    anyhow::ensure!(
347        packet_id == 0x00,
348        "login packet 0x{packet_id:02x} is not Login Start"
349    );
350    let (username, uuid) = wire::parse_login_start(&body)?;
351    let event = Event::new(
352        &actions::LOGIN_EVENT,
353        json!({
354            "username": username,
355            "uuid": uuid,
356            "protocol_version": handshake.protocol_version,
357            "server_address": handshake.server_address,
358            "server_port": handshake.server_port,
359            "transfer": handshake.next_state == wire::STATE_TRANSFER,
360            "remote_addr": peer.to_string(),
361        }),
362    );
363    let reason = match decide(ctx, id, event).await {
364        Ok(Some((name, data))) if name == "minecraft_disconnect" => {
365            outcome(ctx, id, "minecraft_login", "model_answer");
366            data["reason"].as_str().unwrap_or_default().to_string()
367        }
368        Ok(Some((name, _))) if name == "minecraft_refuse" => {
369            outcome(ctx, id, "minecraft_login", "model_reject");
370            return Ok(());
371        }
372        Ok(Some(_)) => {
373            outcome(ctx, id, "minecraft_login", "fail_closed_invalid_reply");
374            crate::utils::WireFailure::Unavailable
375                .prefixed_text()
376                .to_string()
377        }
378        // Disconnecting is a refusal, so a generic reason invents nothing about the server.
379        Ok(None) => crate::utils::WireFailure::Unavailable
380            .prefixed_text()
381            .to_string(),
382        Err(e) => crate::utils::wire_failure::prefixed_wire_failure_text(&e).to_string(),
383    };
384    let mut body = Vec::new();
385    wire::put_string(&mut body, &wire::disconnect_json(&reason)?);
386    send(ctx, id, writer, &wire::frame(0x00, &body)).await
387}