Skip to main content

packet_logger/
packet_logger.rs

1//! A packet-logging proxy built with `azalea-protocol`, based on the
2//! `handshake_proxy` example.
3//!
4//! You should adjust the constant variables defined below.
5//!
6//! When running the proxy, the following `.txt` files will be created:
7//! - `serverbound.txt` - Every packet that was sent to the target server, as
8//!   parsed by Azalea.
9//! - `clientbound.txt` - Every packet that was sent to the client, as parsed by
10//!   Azalea.
11//! - `combined.txt` - The combined serverbound and clientbound logs combined
12//!   into one file.
13//!
14//! Note that the packet parsing assumes that the protocol is always in the
15//! `game` state, so some early packets will be decoded incorrectly.
16
17use std::{
18    error::Error,
19    io::Cursor,
20    sync::{Arc, LazyLock},
21};
22
23use azalea_auth::{AuthOpts, sessionserver::ClientSessionServerError};
24use azalea_protocol::{
25    address::ServerAddr,
26    connect::Connection,
27    packets::{
28        self, ClientIntention, PROTOCOL_VERSION, VERSION_NAME,
29        game::{ClientboundGamePacket, ServerboundGamePacket},
30        handshake::{
31            ClientboundHandshakePacket, ServerboundHandshakePacket,
32            s_intention::ServerboundIntention,
33        },
34        login::{
35            ClientboundLoginPacket, ServerboundKey, ServerboundLoginPacket,
36            s_hello::ServerboundHello,
37        },
38        status::{
39            ServerboundStatusPacket,
40            c_pong_response::ClientboundPongResponse,
41            c_status_response::{ClientboundStatusResponse, Players, Version},
42        },
43    },
44    read::ReadPacketError,
45    resolver::resolve_address,
46};
47use futures::FutureExt;
48use parking_lot::Mutex;
49use tokio::{
50    fs::File,
51    io::AsyncWriteExt,
52    net::{TcpListener, TcpStream},
53};
54use tracing::{Level, debug, error, info, warn};
55use uuid::Uuid;
56
57/// The address that the proxy server will be created at.
58const LISTEN_ADDR: &str = "127.0.0.1:25566";
59/// The destination server that the proxy will connect to.
60const TARGET_ADDR: &str = "localhost";
61/// The account to join as. If this isn't an email, then it will try to join as
62/// offline-mode.
63const ACCOUNT: &str = "[email protected]";
64
65const PROXY_DESC: &str = "An Azalea Minecraft Proxy";
66// String must be formatted like "data:image/png;base64,<data>"
67static PROXY_FAVICON: LazyLock<Option<String>> = LazyLock::new(|| None);
68static PROXY_VERSION: LazyLock<Version> = LazyLock::new(|| Version {
69    name: VERSION_NAME.to_string(),
70    protocol: PROTOCOL_VERSION,
71});
72const PROXY_PLAYERS: Players = Players {
73    max: 1,
74    online: 0,
75    sample: Vec::new(),
76};
77const PROXY_SECURE_CHAT: Option<bool> = Some(false);
78
79#[tokio::main]
80async fn main() -> eyre::Result<()> {
81    tracing_subscriber::fmt()
82        .with_max_level(Level::DEBUG)
83        .init();
84
85    // Bind to an address and port
86    let listener = TcpListener::bind(LISTEN_ADDR).await?;
87
88    info!("Listening on {LISTEN_ADDR}, proxying to {TARGET_ADDR}");
89
90    loop {
91        // When a connection is made, pass it off to another thread
92        let (stream, _) = listener.accept().await?;
93        tokio::spawn(handle_connection(stream));
94    }
95}
96
97async fn handle_connection(stream: TcpStream) -> eyre::Result<()> {
98    stream.set_nodelay(true)?;
99    let ip = stream.peer_addr()?;
100    let mut conn: Connection<ServerboundHandshakePacket, ClientboundHandshakePacket> =
101        Connection::wrap(stream);
102
103    // The first packet sent from a client is the intent packet.
104    // This specifies whether the client is pinging
105    // the server or is going to join the game.
106    let intent = match conn.read().await {
107        Ok(packet) => match packet {
108            ServerboundHandshakePacket::Intention(packet) => {
109                info!(
110                    "New connection from {}, hostname {:?}:{}, version {}, {:?}",
111                    ip.ip(),
112                    packet.hostname,
113                    packet.port,
114                    packet.protocol_version,
115                    packet.intention
116                );
117                packet
118            }
119        },
120        Err(e) => {
121            let e = e.into();
122            warn!("Error during intent: {e}");
123            return Err(e);
124        }
125    };
126
127    match intent.intention {
128        // If the client is pinging the proxy, reply with the information below.
129        ClientIntention::Status => {
130            let mut conn = conn.status();
131            loop {
132                match conn.read().await {
133                    Ok(p) => match p {
134                        ServerboundStatusPacket::StatusRequest(_) => {
135                            conn.write(ClientboundStatusResponse {
136                                description: PROXY_DESC.into(),
137                                favicon: PROXY_FAVICON.clone(),
138                                players: PROXY_PLAYERS.clone(),
139                                version: PROXY_VERSION.clone(),
140                                enforces_secure_chat: PROXY_SECURE_CHAT,
141                            })
142                            .await?;
143                        }
144                        ServerboundStatusPacket::PingRequest(p) => {
145                            conn.write(ClientboundPongResponse { time: p.time }).await?;
146                            break;
147                        }
148                    },
149                    Err(e) => match *e {
150                        ReadPacketError::ConnectionClosed => {
151                            break;
152                        }
153                        e => {
154                            warn!("Error during status: {e}");
155                            return Err(e.into());
156                        }
157                    },
158                }
159            }
160        }
161        // If the client intends to join the proxy, wait for them to send the `Hello` packet to log
162        // their username and uuid, then start proxying their connection.
163        ClientIntention::Login => {
164            let mut conn = conn.login();
165            loop {
166                match conn.read().await {
167                    Ok(p) => {
168                        if let ServerboundLoginPacket::Hello(hello) = p {
169                            info!(
170                                "Player \'{}\' from {} logging in with uuid: {}",
171                                hello.name,
172                                ip.ip(),
173                                hello.profile_id.to_string()
174                            );
175
176                            tokio::spawn(proxy_conn(conn).map(|r| {
177                                if let Err(e) = r {
178                                    error!("Failed to proxy: {e}");
179                                }
180                            }));
181
182                            break;
183                        }
184                    }
185                    Err(e) => match *e {
186                        ReadPacketError::ConnectionClosed => {
187                            break;
188                        }
189                        e => {
190                            warn!("Error during login: {e}");
191                            return Err(e.into());
192                        }
193                    },
194                }
195            }
196        }
197        ClientIntention::Transfer => {
198            warn!("Client attempted to join via transfer")
199        }
200    }
201
202    Ok(())
203}
204
205async fn proxy_conn(
206    mut client_conn: Connection<ServerboundLoginPacket, ClientboundLoginPacket>,
207) -> Result<(), Box<dyn Error>> {
208    // resolve TARGET_ADDR
209    let parsed_target_addr = ServerAddr::try_from(TARGET_ADDR).unwrap();
210    let resolved_target_addr = resolve_address(&parsed_target_addr).await?;
211
212    let mut server_conn = Connection::new(&resolved_target_addr).await?;
213
214    let account = if ACCOUNT.contains('@') {
215        Account::microsoft(ACCOUNT).await?
216    } else {
217        Account::offline(ACCOUNT)
218    };
219    println!("got account: {:?}", account);
220
221    server_conn
222        .write(ServerboundIntention {
223            protocol_version: PROTOCOL_VERSION,
224            hostname: parsed_target_addr.host,
225            port: parsed_target_addr.port,
226            intention: ClientIntention::Login,
227        })
228        .await?;
229    let mut server_conn = server_conn.login();
230
231    // login
232    server_conn
233        .write(ServerboundHello {
234            name: account.username().to_owned(),
235            profile_id: account.uuid(),
236        })
237        .await?;
238
239    let (server_conn, login_finished) = loop {
240        let packet = server_conn.read().await?;
241
242        println!("got packet: {:?}", packet);
243
244        match packet {
245            ClientboundLoginPacket::Hello(p) => {
246                debug!("Got encryption request");
247                let e = azalea_crypto::encrypt(&p.public_key, &p.challenge).unwrap();
248
249                if let Some(access_token) = account.access_token() {
250                    // keep track of the number of times we tried
251                    // authenticating so we can give up after too many
252                    let mut attempts: usize = 1;
253
254                    while let Err(e) = {
255                        server_conn
256                            .authenticate(&access_token, &account.uuid(), e.secret_key, &p, None)
257                            .await
258                    } {
259                        if attempts >= 2 {
260                            // if this is the second attempt and we failed
261                            // both times, give up
262                            return Err(e.into());
263                        }
264                        if matches!(
265                            e,
266                            ClientSessionServerError::InvalidSession
267                                | ClientSessionServerError::ForbiddenOperation
268                        ) {
269                            // uh oh, we got an invalid session and have
270                            // to reauthenticate now
271                            account.refresh().await?;
272                        } else {
273                            return Err(e.into());
274                        }
275                        attempts += 1;
276                    }
277                }
278
279                server_conn
280                    .write(ServerboundKey {
281                        key_bytes: e.encrypted_public_key,
282                        encrypted_challenge: e.encrypted_challenge,
283                    })
284                    .await?;
285
286                server_conn.set_encryption_key(e.secret_key);
287            }
288            ClientboundLoginPacket::LoginCompression(p) => {
289                debug!("Got compression request {:?}", p.compression_threshold);
290                server_conn.set_compression_threshold(p.compression_threshold);
291            }
292            ClientboundLoginPacket::LoginFinished(p) => {
293                debug!(
294                    "Got profile {:?}. handshake is finished and we're now switching to the configuration state",
295                    p.game_profile
296                );
297                // server_conn.write(ServerboundLoginAcknowledged {}).await?;
298                break (server_conn.config(), p);
299            }
300            ClientboundLoginPacket::LoginDisconnect(p) => {
301                error!("Got disconnect {p:?}");
302                return Err("Disconnected".into());
303            }
304            ClientboundLoginPacket::CustomQuery(p) => {
305                debug!("Got custom query {:?}", p);
306                // replying to custom query is done in
307                // packet_handling::login::process_packet_events
308            }
309            ClientboundLoginPacket::CookieRequest(p) => {
310                debug!("Got cookie request {:?}", p);
311
312                server_conn
313                    .write(packets::login::ServerboundCookieResponse {
314                        key: p.key,
315                        // cookies aren't implemented
316                        payload: None,
317                    })
318                    .await?;
319            }
320        }
321    };
322
323    // give the client the login_finished
324    println!("got the login_finished: {:?}", login_finished);
325    client_conn.write(login_finished).await?;
326    let client_conn = client_conn.config();
327
328    info!("started direct bridging");
329
330    // bridge packets
331    let listen_raw_reader = client_conn.reader.raw;
332    let listen_raw_writer = client_conn.writer.raw;
333
334    let target_raw_reader = server_conn.reader.raw;
335    let target_raw_writer = server_conn.writer.raw;
336
337    let packet_logs_txt = Arc::new(tokio::sync::Mutex::new(
338        File::create("combined.txt").await.unwrap(),
339    ));
340
341    let packet_logs_txt_clone = packet_logs_txt.clone();
342    let copy_listen_to_target = tokio::spawn(async move {
343        let mut listen_raw_reader = listen_raw_reader;
344        let mut target_raw_writer = target_raw_writer;
345
346        let packet_logs_txt = packet_logs_txt_clone;
347
348        let mut serverbound_parsed_txt = File::create("serverbound.txt").await.unwrap();
349
350        loop {
351            let packet = match listen_raw_reader.read().await {
352                Ok(p) => p,
353                Err(e) => {
354                    error!("Error reading packet from listen: {e}");
355                    return;
356                }
357            };
358
359            // decode as a game packet
360            let decoded_packet = azalea_protocol::read::deserialize_packet::<ServerboundGamePacket>(
361                &mut Cursor::new(&packet),
362            );
363
364            if let Ok(decoded_packet) = decoded_packet {
365                let timestamp = chrono::Utc::now();
366                let _ = serverbound_parsed_txt
367                    .write_all(format!("{timestamp} {:?}\n", decoded_packet).as_bytes())
368                    .await;
369                let _ = packet_logs_txt
370                    .lock()
371                    .await
372                    .write_all(format!("{timestamp} <- {:?}\n", decoded_packet).as_bytes())
373                    .await;
374            }
375
376            match target_raw_writer.write(&packet).await {
377                Ok(_) => {}
378                Err(e) => {
379                    error!("Error writing packet to target: {e}");
380                    return;
381                }
382            }
383        }
384    });
385
386    // write to clientbound.txt in a separate task so it doesn't block receiving
387    // packets
388    let (clientbound_tx, mut clientbound_rx) = tokio::sync::mpsc::unbounded_channel::<Box<[u8]>>();
389    let copy_clientbound_to_file = tokio::spawn(async move {
390        let mut clientbound_parsed_txt = File::create("clientbound.txt").await.unwrap();
391
392        loop {
393            let Some(packet) = clientbound_rx.recv().await else {
394                return;
395            };
396
397            // decode as a game packet
398            let decoded_packet = azalea_protocol::read::deserialize_packet::<ClientboundGamePacket>(
399                &mut Cursor::new(&packet),
400            );
401
402            if let Ok(decoded_packet) = decoded_packet {
403                let timestamp = chrono::Utc::now();
404                let _ = clientbound_parsed_txt
405                    .write_all(format!("{timestamp} {decoded_packet:?}\n").as_bytes())
406                    .await;
407                let _ = packet_logs_txt
408                    .lock()
409                    .await
410                    .write_all(format!("{timestamp} -> {decoded_packet:?}\n").as_bytes())
411                    .await;
412            }
413        }
414    });
415
416    let copy_remote_to_local = tokio::spawn(async move {
417        let mut target_raw_reader = target_raw_reader;
418        let mut listen_raw_writer = listen_raw_writer;
419
420        loop {
421            let packet = match target_raw_reader.read().await {
422                Ok(p) => p,
423                Err(e) => {
424                    error!("Error reading packet from target: {e}");
425                    return;
426                }
427            };
428
429            clientbound_tx.send(packet.clone()).unwrap();
430
431            match listen_raw_writer.write(&packet).await {
432                Ok(_) => {}
433                Err(e) => {
434                    error!("Error writing packet to listen: {e}");
435                    return;
436                }
437            }
438        }
439    });
440
441    tokio::try_join!(
442        copy_listen_to_target,
443        copy_remote_to_local,
444        copy_clientbound_to_file
445    )?;
446
447    Ok(())
448}
449
450#[derive(Debug)]
451enum Account {
452    Microsoft {
453        cache_key: String,
454        username: String,
455        uuid: Uuid,
456        access_token: Mutex<String>,
457        // certs: Mutex<Option<String>>,
458    },
459    Offline {
460        username: String,
461    },
462}
463impl Account {
464    async fn microsoft(cache_key: &str) -> Result<Self, azalea_auth::AuthError> {
465        let minecraft_dir = minecraft_folder_path::minecraft_dir().unwrap_or_else(|| {
466            panic!(
467                "No {} environment variable found",
468                minecraft_folder_path::home_env_var()
469            )
470        });
471        let cache_file = minecraft_dir.join("azalea-auth.json");
472
473        let auth_result = azalea_auth::auth(
474            cache_key,
475            AuthOpts {
476                cache_file: Some(cache_file),
477                ..Default::default()
478            },
479        )
480        .await?;
481
482        Ok(Self::Microsoft {
483            cache_key: cache_key.to_owned(),
484            username: auth_result.profile.name,
485            uuid: auth_result.profile.id,
486            access_token: Mutex::new(auth_result.access_token),
487            // certs: Mutex::new(None),
488        })
489    }
490    fn offline(username: &str) -> Self {
491        Self::Offline {
492            username: username.to_owned(),
493        }
494    }
495
496    async fn refresh(&self) -> Result<(), azalea_auth::AuthError> {
497        match self {
498            Account::Microsoft {
499                cache_key,
500                access_token,
501                ..
502            } => {
503                let acc = Account::microsoft(cache_key).await?;
504                *access_token.lock() = acc.access_token().unwrap();
505            }
506            Account::Offline { .. } => {}
507        }
508
509        Ok(())
510    }
511
512    fn username(&self) -> &str {
513        match self {
514            Account::Microsoft { username, .. } => username,
515            Account::Offline { username } => username,
516        }
517    }
518    fn uuid(&self) -> Uuid {
519        match self {
520            Account::Microsoft { uuid, .. } => *uuid,
521            Account::Offline { username } => azalea_crypto::offline::generate_uuid(username),
522        }
523    }
524    fn access_token(&self) -> Option<String> {
525        match self {
526            Account::Microsoft { access_token, .. } => Some(access_token.lock().to_owned()),
527            Account::Offline { .. } => None,
528        }
529    }
530}