diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3ae03fb..9cfc7e4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,9 +2,9 @@ name: Rust on: push: - branches: [ "main" ] + branches: ["main"] pull_request: - branches: [ "main" ] + branches: ["main"] env: CARGO_TERM_COLOR: always @@ -19,32 +19,35 @@ jobs: runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v3 - - uses: dtolnay/rust-toolchain@stable - with: - components: clippy, rustfmt - - run: cp crates/playground/src/playground.template.rs crates/playground/src/playground.rs - - name: Set up cargo cache - uses: actions/cache@v3 - continue-on-error: false - with: - path: | - ~/.cargo/bin/ - ~/.cargo/registry/index/ - ~/.cargo/registry/cache/ - ~/.cargo/git/db/ - target/ - key: ${{ runner.os }}-cargo-target-${{ hashFiles('**/Cargo.toml') }} - restore-keys: | - ${{ runner.os }}-cargo-target-${{ hashFiles('**/Cargo.toml') }} - ${{ runner.os }}-cargo-target - - name: Validate formatting - run: cargo fmt --all -- --check - - name: Validate documentation - run: cargo doc --workspace --no-deps --all-features --document-private-items - - name: Run clippy lints - run: cargo clippy --workspace --no-deps --all-features --all-targets -- -D warnings - - name: Run tests - run: cargo test --workspace --all-features --all-targets - - name: Run valence_nbt tests without preserve_order feature - run: cargo test -p valence_nbt --all-targets + - uses: actions/checkout@v3 + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy, rustfmt + - run: cp crates/playground/src/playground.template.rs crates/playground/src/playground.rs + - name: Set up cargo cache + uses: actions/cache@v3 + continue-on-error: false + with: + path: | + ~/.cargo/bin/ + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + target/ + key: ${{ runner.os }}-cargo-target-${{ hashFiles('**/Cargo.toml') }} + restore-keys: | + ${{ runner.os }}-cargo-target-${{ hashFiles('**/Cargo.toml') }} + ${{ runner.os }}-cargo-target + - name: Install Dependencies (Linux) + run: sudo apt-get update && sudo apt-get install -y libxcb-render0-dev libxcb-shape0-dev libxcb-xfixes0-dev libxkbcommon-dev libssl-dev libclang-dev libgtk-3-dev + if: matrix.os == 'ubuntu-latest' + - name: Validate formatting + run: cargo fmt --all -- --check + - name: Validate documentation + run: cargo doc --workspace --no-deps --all-features --document-private-items + - name: Run clippy lints + run: cargo clippy --workspace --no-deps --all-features --all-targets -- -D warnings + - name: Run tests + run: cargo test --workspace --all-features --all-targets + - name: Run valence_nbt tests without preserve_order feature + run: cargo test -p valence_nbt --all-targets diff --git a/crates/packet_inspector/Cargo.toml b/crates/packet_inspector/Cargo.toml index 96612f1..e509f90 100644 --- a/crates/packet_inspector/Cargo.toml +++ b/crates/packet_inspector/Cargo.toml @@ -4,11 +4,33 @@ version = "0.1.0" edition = "2021" description = "A simple Minecraft proxy for inspecting packets." +[features] +default = ["syntax_highlighting"] +syntax_highlighting = ["syntect"] + [dependencies] anyhow = "1" clap = { version = "4.0.30", features = ["derive"] } -owo-colors = "3.5.0" regex = "1.6.0" tokio = { version = "1", features = ["full"] } tracing-subscriber = "0.3.16" -valence_protocol = { path = "../valence_protocol", version = "0.1.0", features = ["compression"] } +valence_protocol = { path = "../valence_protocol", version = "0.1.0", features = [ + "compression", +] } + +egui = "0.21.0" +eframe = { version = "0.21.0", default-features = false, features = [ + "default_fonts", # Embed the default egui fonts. + "glow", # Use the glow rendering backend. Alternative: "wgpu". +] } +enum-map = "2.4.2" +syntect = { version = "5", optional = true, default-features = false, features = [ + "default-fancy", +] } +time = { version = "0.3.17", features = ["local-offset"] } +rfd = "0.11" +owo-colors = "3.5.0" +atty = "0.2" +directories = "4.0" +serde = { version = "1.0.152", features = ["derive"] } +toml = "0.7.2" diff --git a/crates/packet_inspector/README.md b/crates/packet_inspector/README.md index a26389f..5a10d7f 100644 --- a/crates/packet_inspector/README.md +++ b/crates/packet_inspector/README.md @@ -1,44 +1,87 @@ # What's This? -The packet inspector is a very simple Minecraft proxy for viewing the contents of packets as they are sent/received. -It uses Valence's protocol facilities to print packet contents. -This was made for three purposes: +The packet inspector is a Minecraft proxy for viewing the contents of packets as +they are sent/received. It uses Valence's protocol facilities to display packet +contents. This was made for three purposes: - Check that packets between Valence and client are matching your expectations. -- Check that packets between vanilla server and client are parsed correctly by Valence. +- Check that packets between vanilla server and client are parsed correctly by + Valence. - Understand how the protocol works between the vanilla server and client. # Usage -First, start a server +Firstly, we should have a server running that we're going to be +proxying/inspecting. ```sh cargo r -r --example conway ``` -In a separate terminal, start the packet inspector. +Next up, we need to run the proxy server, this can be done in 2 different ways, +either using the GUI application (default) or using the `--nogui` flag to log +the packets to a terminal instance. -```sh -cargo r -r -p packet_inspector -- 127.0.0.1:25566 127.0.0.1:25565 +To assist, `--help` will produce the following: + +``` +A simple Minecraft proxy for inspecting packets. + +Usage: packet_inspector [OPTIONS] [CLIENT_ADDR] [SERVER_ADDR] + +Arguments: + [CLIENT_ADDR] The socket address to listen for connections on. This is the address clients should connect to + [SERVER_ADDR] The socket address the proxy will connect to. This is the address of the server + +Options: + -m, --max-connections + The maximum number of connections allowed to the proxy. By default, there is no limit + --nogui + Disable the GUI. Logging to stdout + -i, --include-filter + Only show packets that match the filter + -e, --exclude-filter + Hide packets that match the filter. Note: Only in effect if nogui is set + -h, --help + Print help + -V, --version + Print version ``` -The client must connect to `localhost:25566`. You should see the packets in `stdout`. +To launch in a Gui environment, simply launch `packet_inspector[.exe]` (or +`cargo r -r -p packet_inspector` to run from source). The gui will prompt you +for the `CLIENT_ADDR` and `SERVER_ADDR` if they have not been supplied via the +command line arguments. -The `-i` and `-e` flags accept a regex to filter packets according to their name. The `-i` regex includes matching -packets while the `-e` regex excludes matching packets. +In a terminal only environment, use the `--nogui` option and supply +`CLIENT_ADDR` and `SERVER_ADDR` as arguments. -For instance, if you only want to print the packets `Foo`, `Bar`, and `Baz`, you can use a regex such -as `^(Foo|Bar|Baz)$` with the `-i` flag. - -```sh -cargo r -r -p packet_inspector -- 127.0.0.1:25566 127.0.0.1:25565 -i '^(Foo|Bar|Baz)$' +```bash +cargo r -r -p packet_inspector -- --nogui 127.0.0.1:25566 127.0.0.1:25565 ``` -Packets are printed to `stdout` while errors are printed to `stderr`. If you only want to see errors in your terminal, -direct `stdout` elsewhere. +The client must connect to `localhost:25566`. You should see the packets in +`stdout` when running in `--nogui`, or you should see packets streaming in on +the Gui. + +The `-i` and `-e` flags accept a regex to filter packets according to their +name. The `-i` regex includes matching packets while the `-e` regex excludes +matching packets. Do note that `-e` only applies in `--nogui` environment, as +the Gui has a "packet selector" to enable/disable packets dynamically. The `-i` +parameter value will be included in the `Filter` input field on the Gui. + +For instance, if you only want to print the packets `Foo`, `Bar`, and `Baz`, you +can use a regex such as `^(Foo|Bar|Baz)$` with the `-i` flag. ```sh -cargo r -r -p packet_inspector -- 127.0.0.1:25566 127.0.0.1:25565 > log.txt +cargo r -r -p packet_inspector -- --nogui 127.0.0.1:25566 127.0.0.1:25565 -i '^(Foo|Bar|Baz)$' +``` + +Packets are printed to `stdout` while errors are printed to `stderr`. If you +only want to see errors in your terminal, direct `stdout` elsewhere. + +```sh +cargo r -r -p packet_inspector -- --nogui 127.0.0.1:25566 127.0.0.1:25565 > log.txt ``` ## Quick start with Vanilla Server via Docker @@ -64,7 +107,7 @@ docker exec -i mc rcon-cli In a separate terminal, start the packet inspector. ```sh -cargo r -r -p packet_inspector -- 127.0.0.1:25566 127.0.0.1:25565 +cargo r -r -p packet_inspector -- --nogui 127.0.0.1:25566 127.0.0.1:25565 ``` Open Minecraft and connect to `localhost:25566`. diff --git a/crates/packet_inspector/src/config.rs b/crates/packet_inspector/src/config.rs new file mode 100644 index 0000000..d96ee51 --- /dev/null +++ b/crates/packet_inspector/src/config.rs @@ -0,0 +1,131 @@ +use std::collections::BTreeMap; +use std::net::SocketAddr; +use std::path::PathBuf; + +use directories::ProjectDirs; +use serde::{Deserialize, Serialize}; + +use crate::MetaPacket; + +#[derive(Serialize, Deserialize)] +pub struct ApplicationConfig { + server_addr: SocketAddr, + client_addr: SocketAddr, + max_connections: Option, + filter: Option, + selected_packets: Option>, + + #[serde(skip_serializing, skip_deserializing)] + must_save: bool, +} + +impl Default for ApplicationConfig { + fn default() -> Self { + Self { + server_addr: "127.0.0.1:25565".parse().unwrap(), + client_addr: "127.0.0.1:25566".parse().unwrap(), + max_connections: None, + filter: None, + selected_packets: None, + must_save: false, + } + } +} + +impl ApplicationConfig { + pub fn load() -> Result> { + let config_dir = get_or_create_project_dirs()?; + + let config_file = config_dir.join("config.toml"); + + if config_file.exists() { + let config = std::fs::read_to_string(config_file)?; + + toml::from_str(&config).map_err(|e| e.into()) + } else { + let config = toml::to_string(&ApplicationConfig::default()).unwrap(); + std::fs::write(config_file, config)?; + Ok(ApplicationConfig::default()) + } + } + + pub fn server_addr(&self) -> SocketAddr { + self.server_addr + } + + pub fn client_addr(&self) -> SocketAddr { + self.client_addr + } + + pub fn max_connections(&self) -> Option { + self.max_connections + } + + pub fn filter(&self) -> &Option { + &self.filter + } + + pub fn selected_packets(&self) -> &Option> { + &self.selected_packets + } + + pub fn set_server_addr(&mut self, addr: SocketAddr) { + self.must_save = true; + self.server_addr = addr; + } + + pub fn set_client_addr(&mut self, addr: SocketAddr) { + self.must_save = true; + self.client_addr = addr; + } + + pub fn set_max_connections(&mut self, max: Option) { + self.must_save = true; + self.max_connections = max; + } + + pub fn set_filter(&mut self, filter: Option) { + self.must_save = true; + self.filter = filter; + } + + pub fn set_selected_packets(&mut self, packets: BTreeMap) { + self.must_save = true; + self.selected_packets = Some(packets); + } + + pub fn save(&mut self) -> Result<(), Box> { + if !self.must_save { + return Ok(()); + } + + self.must_save = false; + + let config_dir = match get_or_create_project_dirs() { + Ok(dir) => dir, + Err(e) => { + eprintln!("Could not find config directory: {}", e); + return Ok(()); + } + }; + + let config_file = config_dir.join("config.toml"); + + let config = toml::to_string(&self).unwrap(); + std::fs::write(config_file, config).unwrap(); + Ok(()) + } +} + +fn get_or_create_project_dirs() -> Result> { + if let Some(proj_dirs) = ProjectDirs::from("com", "valence", "inspector") { + // check if the directory exists, if not create it + if !proj_dirs.config_dir().exists() { + std::fs::create_dir_all(proj_dirs.config_dir())?; + } + + Ok(proj_dirs.config_dir().to_owned()) + } else { + Err("Could not find project directories".into()) + } +} diff --git a/crates/packet_inspector/src/context.rs b/crates/packet_inspector/src/context.rs new file mode 100644 index 0000000..0e8e842 --- /dev/null +++ b/crates/packet_inspector/src/context.rs @@ -0,0 +1,386 @@ +use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::sync::RwLock; + +use owo_colors::{OwoColorize, Style}; +use regex::Regex; +use serde::{Deserialize, Serialize}; +use time::OffsetDateTime; +use valence_protocol::codec::PacketDecoder; +use valence_protocol::packet::c2s::handshake::HandshakeC2s; +use valence_protocol::packet::c2s::login::{LoginHelloC2s, LoginKeyC2s}; +use valence_protocol::packet::c2s::status::{QueryPingC2s, QueryRequestC2s}; +use valence_protocol::packet::s2c::login::LoginSuccessS2c; +use valence_protocol::packet::s2c::status::{QueryPongS2c, QueryResponseS2c}; +use valence_protocol::packet::{C2sPlayPacket, S2cLoginPacket, S2cPlayPacket}; +use valence_protocol::raw::RawPacket; + +use crate::packet_widget::{systemtime_strftime, PacketDirection}; + +#[derive(Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] +pub enum Stage { + HandshakeC2s, + QueryRequestC2s, + QueryResponseS2c, + QueryPingC2s, + QueryPongS2c, + LoginHelloC2s, + S2cLoginPacket, + LoginKeyC2s, + LoginSuccessS2c, + C2sPlayPacket, + S2cPlayPacket, +} + +impl From for usize { + fn from(stage: Stage) -> Self { + match stage { + Stage::HandshakeC2s => 0, + Stage::QueryRequestC2s => 1, + Stage::QueryResponseS2c => 2, + Stage::QueryPingC2s => 3, + Stage::QueryPongS2c => 4, + Stage::LoginHelloC2s => 5, + Stage::S2cLoginPacket => 6, + Stage::LoginKeyC2s => 7, + Stage::LoginSuccessS2c => 8, + Stage::C2sPlayPacket => 9, + Stage::S2cPlayPacket => 10, + } + } +} + +impl TryFrom for Stage { + type Error = anyhow::Error; + + fn try_from(value: usize) -> anyhow::Result { + match value { + 0 => Ok(Stage::HandshakeC2s), + 1 => Ok(Stage::QueryRequestC2s), + 2 => Ok(Stage::QueryResponseS2c), + 3 => Ok(Stage::QueryPingC2s), + 4 => Ok(Stage::QueryPongS2c), + 5 => Ok(Stage::LoginHelloC2s), + 6 => Ok(Stage::S2cLoginPacket), + 7 => Ok(Stage::LoginKeyC2s), + 8 => Ok(Stage::LoginSuccessS2c), + 9 => Ok(Stage::C2sPlayPacket), + 10 => Ok(Stage::S2cPlayPacket), + _ => Err(anyhow::anyhow!("Invalid stage")), + } + } +} + +#[derive(Clone)] +pub struct Packet { + pub(crate) id: usize, + pub(crate) direction: PacketDirection, + pub(crate) selected: bool, + pub(crate) use_compression: bool, + pub(crate) packet_data: Vec, + pub(crate) stage: Stage, + pub(crate) packet_type: i32, + pub(crate) packet_name: String, + pub(crate) created_at: OffsetDateTime, +} + +impl Packet { + pub(crate) fn selected(&mut self, value: bool) { + self.selected = value; + } + + pub fn get_raw_packet(&self) -> Vec { + let mut dec = PacketDecoder::new(); + dec.set_compression(self.use_compression); + dec.queue_slice(&self.packet_data); + + let pkt = match dec.try_next_packet::() { + Ok(Some(pkt)) => pkt, + Ok(None) => return vec![], + Err(e) => { + eprintln!("Error decoding packet: {e}"); + return vec![]; + } + }; + + pkt.0.to_vec() + } + + pub fn get_packet_string(&self, formatted: bool) -> String { + let mut dec = PacketDecoder::new(); + dec.set_compression(self.use_compression); + dec.queue_slice(&self.packet_data); + + match self.stage { + Stage::HandshakeC2s => { + let pkt = match dec.try_next_packet::() { + Ok(Some(pkt)) => pkt, + Ok(None) => return "HandshakeC2s".to_string(), + Err(err) => return format!("{:?}", err), + }; + if formatted { + format!("{pkt:#?}") + } else { + format!("{pkt:?}") + } + } + Stage::QueryRequestC2s => { + let pkt = match dec.try_next_packet::() { + Ok(Some(pkt)) => pkt, + Ok(None) => return "QueryRequestC2s".to_string(), + Err(err) => return format!("{:?}", err), + }; + + if formatted { + format!("{pkt:#?}") + } else { + format!("{pkt:?}") + } + } + Stage::QueryResponseS2c => { + let pkt = match dec.try_next_packet::() { + Ok(Some(pkt)) => pkt, + Ok(None) => return "QueryResponseS2c".to_string(), + Err(err) => return format!("{:?}", err), + }; + + if formatted { + format!("{pkt:#?}") + } else { + format!("{pkt:?}") + } + } + Stage::QueryPingC2s => { + let pkt = match dec.try_next_packet::() { + Ok(Some(pkt)) => pkt, + Ok(None) => return "QueryPingC2s".to_string(), + Err(err) => return format!("{:?}", err), + }; + + if formatted { + format!("{pkt:#?}") + } else { + format!("{pkt:?}") + } + } + Stage::QueryPongS2c => { + let pkt = match dec.try_next_packet::() { + Ok(Some(pkt)) => pkt, + Ok(None) => return "QueryPongS2c".to_string(), + Err(err) => return format!("{:?}", err), + }; + + if formatted { + format!("{pkt:#?}") + } else { + format!("{pkt:?}") + } + } + Stage::LoginHelloC2s => { + let pkt = match dec.try_next_packet::() { + Ok(Some(pkt)) => pkt, + Ok(None) => return "LoginHelloC2s".to_string(), + Err(err) => return format!("{:?}", err), + }; + + if formatted { + format!("{pkt:#?}") + } else { + format!("{pkt:?}") + } + } + Stage::S2cLoginPacket => { + let pkt = match dec.try_next_packet::() { + Ok(Some(pkt)) => pkt, + Ok(None) => return "S2cLoginPacket".to_string(), + Err(err) => return format!("{:?}", err), + }; + + if formatted { + format!("{pkt:#?}") + } else { + format!("{pkt:?}") + } + } + Stage::LoginKeyC2s => { + let pkt = match dec.try_next_packet::() { + Ok(Some(pkt)) => pkt, + Ok(None) => return "LoginKeyC2s".to_string(), + Err(err) => return format!("{:?}", err), + }; + + if formatted { + format!("{pkt:#?}") + } else { + format!("{pkt:?}") + } + } + Stage::LoginSuccessS2c => { + let pkt = match dec.try_next_packet::() { + Ok(Some(pkt)) => pkt, + Ok(None) => return "LoginSuccessS2c".to_string(), + Err(err) => return format!("{:?}", err), + }; + + if formatted { + format!("{pkt:#?}") + } else { + format!("{pkt:?}") + } + } + Stage::C2sPlayPacket => { + let pkt = match dec.try_next_packet::() { + Ok(Some(pkt)) => pkt, + Ok(None) => return "C2sPlayPacket".to_string(), + Err(err) => return format!("{:?}", err), + }; + + if formatted { + format!("{pkt:#?}") + } else { + format!("{pkt:?}") + } + } + Stage::S2cPlayPacket => { + let pkt = match dec.try_next_packet::() { + Ok(Some(pkt)) => pkt, + Ok(None) => return "S2cPlayPacket".to_string(), + Err(err) => return format!("{:?}", err), + }; + + if formatted { + format!("{pkt:#?}") + } else { + format!("{pkt:?}") + } + } + } + } +} + +pub struct Logger { + pub include_filter: Option, + pub exclude_filter: Option, +} + +pub enum ContextMode { + Gui(egui::Context), + Cli(Logger), +} + +pub struct Context { + pub mode: ContextMode, + pub last_packet: AtomicUsize, + pub selected_packet: RwLock>, + pub(crate) packets: RwLock>, + pub(crate) packet_count: RwLock, + pub(crate) has_encryption_enabled_error: AtomicBool, + pub filter: RwLock, + c2s_style: Style, + s2c_style: Style, +} + +impl Context { + pub fn new(mode: ContextMode) -> Self { + Self { + mode, + last_packet: AtomicUsize::new(0), + selected_packet: RwLock::new(None), + packets: RwLock::new(Vec::new()), + filter: RwLock::new("".into()), + packet_count: RwLock::new(0), + + has_encryption_enabled_error: AtomicBool::new(false), + + c2s_style: Style::new().green(), + s2c_style: Style::new().purple(), + } + } + + pub fn clear(&self) { + self.last_packet.store(0, Ordering::Relaxed); + *self.selected_packet.write().expect("Poisoned RwLock") = None; + self.packets.write().expect("Poisoned RwLock").clear(); + if let ContextMode::Gui(ctx) = &self.mode { + ctx.request_repaint(); + } + } + + pub fn add(&self, mut packet: Packet) { + match &self.mode { + ContextMode::Gui(ctx) => { + packet.id = self.last_packet.fetch_add(1, Ordering::Relaxed); + self.packets.write().expect("Poisoned RwLock").push(packet); + ctx.request_repaint(); + } + ContextMode::Cli(logger) => { + if let Some(include_filter) = &logger.include_filter { + if !include_filter.is_match(&packet.packet_name) { + return; + } + } + if let Some(exclude_filter) = &logger.exclude_filter { + if exclude_filter.is_match(&packet.packet_name) { + return; + } + } + + let arrow = match &packet.direction { + PacketDirection::ClientToServer => "↑", + PacketDirection::ServerToClient => "↓", + }; + + if atty::is(atty::Stream::Stdout) { + let style = match &packet.direction { + PacketDirection::ClientToServer => self.c2s_style, + PacketDirection::ServerToClient => self.s2c_style, + }; + + println!( + "[{}] ({}) {}", + systemtime_strftime(packet.created_at), + arrow.style(style), + packet.get_packet_string(false).style(style) + ); + } else { + println!( + "[{}] ({}) {}", + systemtime_strftime(packet.created_at), + arrow, + packet.get_packet_string(false) + ); + } + } + } + } + + pub fn set_selected_packet(&self, idx: usize) { + *self.selected_packet.write().expect("Poisoned RwLock") = Some(idx); + } + + pub fn set_filter(&self, filter: String) { + *self.filter.write().expect("Posisoned RwLock") = filter; + *self.selected_packet.write().expect("Poisoned RwLock") = None; + } + + pub fn save(&self, path: PathBuf) -> Result<(), std::io::Error> { + let packets = self + .packets + .read() + .expect("Poisoned RwLock") + .iter() + .map(|packet| { + format!( + "[{}] {}", + systemtime_strftime(packet.created_at), + packet.get_packet_string(false) + ) + }) + .collect::>() + .join("\n"); + + std::fs::write(path, packets)?; + + Ok(()) + } +} diff --git a/crates/packet_inspector/src/hex_viewer.rs b/crates/packet_inspector/src/hex_viewer.rs new file mode 100644 index 0000000..a1158e3 --- /dev/null +++ b/crates/packet_inspector/src/hex_viewer.rs @@ -0,0 +1,45 @@ +use std::io::Read; + +use egui::Color32; + +pub fn hex_view_ui(ui: &mut egui::Ui, mut file: &[u8]) { + let mut buf = [0u8; 16]; + let mut count = 0; + + egui::Grid::new("hex_grid") + .spacing([4.0, 1.5]) + .striped(true) + .min_col_width(0.0) + .show(ui, |ui| { + ui.label(" "); + for i in 0..16 { + ui.label(format!("{:02X}", i)); + } + ui.end_row(); + loop { + let bytes_read = file.read(&mut buf).unwrap(); + if bytes_read == 0 { + break; + } + + ui.label(format!("{:08X}", count)); + for b in buf.iter().take(bytes_read) { + ui.colored_label(Color32::from_rgb(255, 255, 255), format!("{:02X}", b)); + } + for _ in 0..16 - bytes_read { + ui.label(" "); + } + ui.label(" "); + for b in buf.iter().take(bytes_read) { + if *b >= 0x20 && *b <= 0x7e { + ui.label(format!("{}", *b as char)); + } else { + ui.label("."); + } + } + + ui.end_row(); + count += bytes_read; + } + }); +} diff --git a/crates/packet_inspector/src/main.rs b/crates/packet_inspector/src/main.rs index 6979001..f83392f 100644 --- a/crates/packet_inspector/src/main.rs +++ b/crates/packet_inspector/src/main.rs @@ -1,14 +1,25 @@ -use std::error::Error; -use std::fmt::Write; -use std::io; -use std::io::ErrorKind; +mod config; +mod context; +mod hex_viewer; +mod packet_widget; +mod state; +mod syntax_highlighting; + +use std::collections::BTreeMap; use std::net::SocketAddr; -use std::sync::Arc; +use std::str::FromStr; +use std::sync::atomic::Ordering; +use std::sync::{Arc, RwLock}; use anyhow::bail; use clap::Parser; -use owo_colors::OwoColorize; +use config::ApplicationConfig; +use context::{Context, Packet}; +use egui::{Align2, RichText}; +use hex_viewer::hex_view_ui; use regex::Regex; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; +use syntax_highlighting::code_view_ui; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::tcp::{OwnedReadHalf, OwnedWriteHalf}; use tokio::net::{TcpListener, TcpStream}; @@ -23,107 +34,79 @@ use valence_protocol::packet::c2s::status::{QueryPingC2s, QueryRequestC2s}; use valence_protocol::packet::s2c::login::LoginSuccessS2c; use valence_protocol::packet::s2c::status::{QueryPongS2c, QueryResponseS2c}; use valence_protocol::packet::{C2sPlayPacket, S2cLoginPacket, S2cPlayPacket}; -use valence_protocol::Packet; + +use crate::context::{ContextMode, Stage}; +use crate::packet_widget::PacketDirection; +use crate::state::State; #[derive(Parser, Clone, Debug)] #[clap(author, version, about)] struct Cli { /// The socket address to listen for connections on. This is the address /// clients should connect to. - client_addr: SocketAddr, + #[arg(required_if_eq("nogui", "true"))] + client_addr: Option, + /// The socket address the proxy will connect to. This is the address of the /// server. - server_addr: SocketAddr, - /// An optional regular expression to use on packet names. Packet names - /// matching the regex are printed while those that don't are ignored. - /// - /// If no regex is provided, all packets are considered matching. - #[clap(short, long)] - include_regex: Option, - /// An optional regular expression to use on packet names. Packet names - /// matching the regex are ignored while those are don't are printed. - /// - /// If no regex is provided, all packets are not considered matching. - #[clap(short, long)] - exclude_regex: Option, + #[arg(required_if_eq("nogui", "true"))] + server_addr: Option, + /// The maximum number of connections allowed to the proxy. By default, /// there is no limit. #[clap(short, long)] max_connections: Option, -} -struct State { - cli: Arc, - enc: PacketEncoder, - dec: PacketDecoder, - read: OwnedReadHalf, - write: OwnedWriteHalf, - buf: String, - style: owo_colors::Style, -} + /// Disable the GUI. Logging to stdout. + #[clap(long)] + nogui: bool, -impl State { - pub async fn rw_packet<'a, P>(&'a mut self) -> anyhow::Result

- where - P: Packet<'a>, - { - while !self.dec.has_next_packet()? { - self.dec.reserve(4096); - let mut buf = self.dec.take_capacity(); + /// Only show packets that match the filter. + #[clap(short, long)] + include_filter: Option, - if self.read.read_buf(&mut buf).await? == 0 { - return Err(io::Error::from(ErrorKind::UnexpectedEof).into()); - } - - self.dec.queue_bytes(buf); - } - - let pkt: P = self.dec.try_next_packet()?.unwrap(); - - self.enc.append_packet(&pkt)?; - - let bytes = self.enc.take(); - self.write.write_all(&bytes).await?; - - self.buf.clear(); - write!(&mut self.buf, "{pkt:?}")?; - - let packet_name = self - .buf - .split_once(|ch: char| !ch.is_ascii_alphabetic()) - .map(|(fst, _)| fst) - .unwrap_or(&self.buf); - - if let Some(r) = &self.cli.include_regex { - if !r.is_match(packet_name) { - return Ok(pkt); - } - } - - if let Some(r) = &self.cli.exclude_regex { - if r.is_match(packet_name) { - return Ok(pkt); - } - } - - println!("{}", self.buf.style(self.style)); - - Ok(pkt) - } + /// Hide packets that match the filter. Note: Only in effect if nogui is + /// set. + #[clap(short, long)] + exclude_filter: Option, } #[tokio::main] -async fn main() -> Result<(), Box> { +async fn main() -> Result<(), Box> { tracing_subscriber::fmt() .with_max_level(LevelFilter::DEBUG) .init(); let cli = Arc::new(Cli::parse()); + match cli.nogui { + true => start_cli(cli).await?, + false => start_gui(cli)?, + }; + + Ok(()) +} + +async fn start_cli(cli: Arc) -> Result<(), Box> { + let context = Arc::new(Context::new(ContextMode::Cli(context::Logger { + include_filter: cli.include_filter.clone(), + exclude_filter: cli.exclude_filter.clone(), + }))); + let sema = Arc::new(Semaphore::new(cli.max_connections.unwrap_or(100_000))); - eprintln!("Waiting for connections on {}", cli.client_addr); - let listen = TcpListener::bind(cli.client_addr).await?; + let client_addr = match cli.client_addr { + Some(addr) => addr, + None => return Err("Missing Client Address".into()), + }; + + let server_addr = match cli.server_addr { + Some(addr) => addr, + None => return Err("Missing Server Address".into()), + }; + + eprintln!("Waiting for connections on {}", client_addr); + let listen = TcpListener::bind(client_addr).await?; while let Ok(permit) = sema.clone().acquire_owned().await { let (client, remote_client_addr) = listen.accept().await?; @@ -133,9 +116,9 @@ async fn main() -> Result<(), Box> { eprintln!("Failed to set TCP_NODELAY: {e}"); } - let cli = cli.clone(); + let context = context.clone(); tokio::spawn(async move { - if let Err(e) = handle_connection(client, cli).await { + if let Err(e) = handle_connection(client, server_addr, context).await { eprintln!("Connection to {remote_client_addr} ended with: {e:#}"); } else { eprintln!("Connection to {remote_client_addr} ended."); @@ -147,10 +130,50 @@ async fn main() -> Result<(), Box> { Ok(()) } -async fn handle_connection(client: TcpStream, cli: Arc) -> anyhow::Result<()> { - eprintln!("Connecting to {}", cli.server_addr); +fn start_gui(cli: Arc) -> Result<(), Box> { + let native_options = eframe::NativeOptions { + initial_window_size: Some(egui::Vec2::new(800.0, 600.0)), + decorated: true, + ..Default::default() + }; - let server = TcpStream::connect(cli.server_addr).await?; + let server_addr = cli.server_addr; + let client_addr = cli.client_addr; + let max_connections = cli.max_connections.unwrap_or(100_000); + + let filter = cli + .include_filter + .clone() + .map(|f| f.to_string()) + .unwrap_or("".to_string()); + + eframe::run_native( + "Valence Packet Inspector", + native_options, + Box::new(move |cc| { + let gui_app = GuiApp::new(cc, filter); + + if let Some(server_addr) = server_addr { + if let Some(client_addr) = client_addr { + gui_app.start_listening(client_addr, server_addr, max_connections); + } + } + + Box::new(gui_app) + }), + )?; + + Ok(()) +} + +async fn handle_connection( + client: TcpStream, + server_addr: SocketAddr, + context: Arc, +) -> anyhow::Result<()> { + eprintln!("Connecting to {}", server_addr); + + let server = TcpStream::connect(server_addr).await?; if let Err(e) = server.set_nodelay(true) { eprintln!("Failed to set TCP_NODELAY: {e}"); @@ -160,48 +183,55 @@ async fn handle_connection(client: TcpStream, cli: Arc) -> anyhow::Result<( let (server_read, server_write) = server.into_split(); let mut s2c = State { - cli: cli.clone(), enc: PacketEncoder::new(), dec: PacketDecoder::new(), read: server_read, write: client_write, - buf: String::new(), - style: owo_colors::Style::new().purple(), + direction: PacketDirection::ServerToClient, + context: context.clone(), }; let mut c2s = State { - cli, enc: PacketEncoder::new(), dec: PacketDecoder::new(), read: client_read, write: server_write, - buf: String::new(), - style: owo_colors::Style::new().green(), + direction: PacketDirection::ClientToServer, + context: context.clone(), }; - let handshake: HandshakeC2s = c2s.rw_packet().await?; + let handshake: HandshakeC2s = c2s.rw_packet(Stage::HandshakeC2s).await?; match handshake.next_state { NextState::Status => { - c2s.rw_packet::().await?; - s2c.rw_packet::().await?; - c2s.rw_packet::().await?; - s2c.rw_packet::().await?; + c2s.rw_packet::(Stage::QueryRequestC2s) + .await?; + s2c.rw_packet::(Stage::QueryResponseS2c) + .await?; + c2s.rw_packet::(Stage::QueryPingC2s).await?; + s2c.rw_packet::(Stage::QueryPongS2c).await?; Ok(()) } NextState::Login => { - c2s.rw_packet::().await?; + c2s.rw_packet::(Stage::LoginHelloC2s).await?; - match s2c.rw_packet::().await? { + match s2c + .rw_packet::(Stage::S2cLoginPacket) + .await? + { S2cLoginPacket::LoginHelloS2c(_) => { - c2s.rw_packet::().await?; + c2s.rw_packet::(Stage::LoginKeyC2s).await?; eprintln!( - "Encryption was enabled! Packet contents are inaccessible to the proxy. \ - Disable online_mode to fix this." + "Encryption is enabled! Packet contents are inaccessible to the proxy. \ + Disable online mode to fix this." ); + context + .has_encryption_enabled_error + .store(true, Ordering::Relaxed); + return tokio::select! { c2s_res = passthrough(c2s.read, c2s.write) => c2s_res, s2c_res = passthrough(s2c.read, s2c.write) => s2c_res, @@ -215,30 +245,31 @@ async fn handle_connection(client: TcpStream, cli: Arc) -> anyhow::Result<( c2s.enc.set_compression(Some(threshold)); c2s.dec.set_compression(true); - s2c.rw_packet::().await?; + s2c.rw_packet::(Stage::LoginSuccessS2c) + .await?; } S2cLoginPacket::LoginSuccessS2c(_) => {} S2cLoginPacket::LoginDisconnectS2c(_) => return Ok(()), S2cLoginPacket::LoginQueryRequestS2c(_) => { - bail!("got login query request. Don't know how to proceed.") + bail!("got login plugin request. Don't know how to proceed.") } } let c2s_fut: JoinHandle> = tokio::spawn(async move { loop { - c2s.rw_packet::().await?; + c2s.rw_packet::(Stage::C2sPlayPacket).await?; } }); - let s2c_fut = async move { + let s2c_fut: JoinHandle> = tokio::spawn(async move { loop { - s2c.rw_packet::().await?; + s2c.rw_packet::(Stage::S2cPlayPacket).await?; } - }; + }); tokio::select! { c2s = c2s_fut => Ok(c2s??), - s2c = s2c_fut => s2c, + s2c = s2c_fut => Ok(s2c??), } } } @@ -257,3 +288,607 @@ async fn passthrough(mut read: OwnedReadHalf, mut write: OwnedWriteHalf) -> anyh write.write_all(bytes).await?; } } + +#[derive(Clone)] +pub struct MetaPacket { + id: i32, + stage: Stage, + direction: PacketDirection, + name: String, +} + +// manually implement Serialize and Deserialize that use the ToString and +// FromStr implementaions for keys +impl Serialize for MetaPacket { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(&self.to_string()) + } +} + +impl<'de> Deserialize<'de> for MetaPacket { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let s = String::deserialize(deserializer)?; + MetaPacket::from_str(&s).map_err(serde::de::Error::custom) + } +} + +impl From<(Stage, i32, PacketDirection, String)> for MetaPacket { + fn from((stage, id, direction, name): (Stage, i32, PacketDirection, String)) -> Self { + Self { + stage, + id, + direction, + name, + } + } +} + +// to string and from string to be used in toml +impl ToString for MetaPacket { + fn to_string(&self) -> String { + let stage: usize = self.stage.clone().into(); + + format!( + "{}:{}:{}:{}", + stage, + self.id, + match self.direction { + PacketDirection::ClientToServer => 0, + PacketDirection::ServerToClient => 1, + }, + self.name + ) + } +} + +impl FromStr for MetaPacket { + type Err = anyhow::Error; + + fn from_str(s: &str) -> Result { + let mut split = s.split(':'); + let stage = match split.next().unwrap().parse::() { + Ok(stage) => Stage::try_from(stage)?, + Err(_) => bail!("invalid stage"), + }; + let id = split.next().unwrap().parse::()?; + let direction = match split.next().unwrap().parse::()? { + 0 => PacketDirection::ClientToServer, + 1 => PacketDirection::ServerToClient, + _ => bail!("invalid direction"), + }; + let name = split.next().unwrap().to_string(); + + Ok(Self { + stage, + id, + direction, + name, + }) + } +} + +impl Ord for MetaPacket { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + #[derive(PartialEq, Eq, PartialOrd, Ord)] + struct OrdMetaPacket { + stage: Stage, + id: i32, + direction: PacketDirection, + } + + let left = OrdMetaPacket { + stage: self.stage.clone(), + id: self.id, + direction: self.direction.clone(), + }; + + let right = OrdMetaPacket { + stage: other.stage.clone(), + id: other.id, + direction: other.direction.clone(), + }; + + left.cmp(&right) + } +} + +impl PartialOrd for MetaPacket { + fn partial_cmp(&self, other: &Self) -> std::option::Option { + Some(self.cmp(other)) + } +} + +impl PartialEq for MetaPacket { + fn eq(&self, other: &Self) -> bool { + self.stage == other.stage && self.id == other.id && self.direction == other.direction + } +} + +impl Eq for MetaPacket {} + +struct GuiApp { + config: ApplicationConfig, + temp_server_addr: String, + temp_client_addr: String, + temp_max_connections: String, + + server_addr_error: bool, + client_addr_error: bool, + max_connections_error: bool, + + context: Arc, + filter: String, + selected_packets: BTreeMap, + buffer: String, + is_listening: RwLock, + window_open: bool, + encryption_error_dialog_open: bool, + + config_load_error: Option, + config_load_error_window_open: bool, + + raw_packet: Vec, + view_hex: bool, +} + +impl GuiApp { + fn new(cc: &eframe::CreationContext<'_>, filter: String) -> Self { + let ctx = cc.egui_ctx.clone(); + + let context = Context::new(ContextMode::Gui(ctx)); + + let mut config_load_error: Option = None; + + let mut config = match ApplicationConfig::load() { + Ok(config) => config, + Err(e) => { + config_load_error = Some(format!("Failed to load config:\n{}", e)); + ApplicationConfig::default() + } + }; + + let mut filter = filter; + + if filter.is_empty() { + if let Some(c_filter) = config.filter() { + filter = c_filter.to_string(); + } + } else { + config.set_filter(Some(filter.clone())); + } + + { + let mut f = context.filter.write().expect("Poisoned filter"); + *f = filter.clone(); + } + + let context = Arc::new(context); + + let temp_server_addr = config.server_addr().to_string(); + let temp_client_addr = config.client_addr().to_string(); + let temp_max_connections = match config.max_connections() { + Some(max_connections) => max_connections.to_string(), + None => String::new(), + }; + + let selected_packets = match config.selected_packets().clone() { + Some(selected_packets) => selected_packets, + None => BTreeMap::new(), + }; + + Self { + config, + context, + filter, + selected_packets, + buffer: String::new(), + is_listening: RwLock::new(false), + window_open: false, + encryption_error_dialog_open: false, + + temp_server_addr, + temp_client_addr, + temp_max_connections, + + server_addr_error: false, + client_addr_error: false, + max_connections_error: false, + + config_load_error, + config_load_error_window_open: false, + + raw_packet: Vec::new(), + view_hex: false, + } + } + + fn start_listening( + &self, + client_addr: SocketAddr, + server_addr: SocketAddr, + max_connections: usize, + ) { + let t_context = self.context.clone(); + tokio::spawn(async move { + let sema = Arc::new(Semaphore::new(max_connections)); + + let listen = TcpListener::bind(client_addr).await?; + eprintln!("Waiting for connections on {}", client_addr); + + while let Ok(permit) = sema.clone().acquire_owned().await { + let (client, remote_client_addr) = listen.accept().await?; + eprintln!("Accepted connection to {remote_client_addr}"); + + if let Err(e) = client.set_nodelay(true) { + eprintln!("Failed to set TCP_NODELAY: {e}"); + } + + let t2_context = t_context.clone(); + tokio::spawn(async move { + if let Err(e) = handle_connection(client, server_addr, t2_context).await { + eprintln!("Connection to {remote_client_addr} ended with: {e:#}"); + } else { + eprintln!("Connection to {remote_client_addr} ended."); + } + drop(permit); + }); + } + + Ok::<(), anyhow::Error>(()) + }); + *self.is_listening.write().expect("Poisoned is_listening") = true; + } + + fn nested_menus(&mut self, ui: &mut egui::Ui) { + let mut changed = false; + self.selected_packets + .iter_mut() + .for_each(|(m_packet, selected)| { + // todo: format, add arrows, etc + if ui.checkbox(selected, m_packet.name.clone()).changed() { + changed = true; + ui.ctx().request_repaint(); + } + }); + + if changed { + self.config + .set_selected_packets(self.selected_packets.clone()); + } + } +} + +impl eframe::App for GuiApp { + fn on_close_event(&mut self) -> bool { + match self.config.save() { + Ok(_) => {} + Err(e) => { + eprintln!("Failed to save config: {e}"); + } + } + true + } + + fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + if !*self.is_listening.read().expect("Poisoned is_listening") { + self.window_open = true; + } + + if self + .context + .has_encryption_enabled_error + .load(Ordering::Relaxed) + { + self.encryption_error_dialog_open = true; + } + + if self.encryption_error_dialog_open { + egui::Window::new("Encryption Error") + .anchor(Align2::CENTER_CENTER, [0.0, 0.0]) + .open(&mut self.encryption_error_dialog_open) + .movable(false) + .collapsible(false) + .resizable(false) + .show(ctx, |ui| { + ui.label( + "Encryption is enabled! Packet contents are inaccessible to the proxy. \ + Disable online mode to fix this.", + ); + }); + + // it was true, now it's false, the user acknowledged the error, set it to false + if !self.encryption_error_dialog_open { + self.context + .has_encryption_enabled_error + .store(false, Ordering::Relaxed); + } + } + + if self.config_load_error.is_some() { + self.config_load_error_window_open = true; + } + + if self.config_load_error_window_open { + if let Some(err) = &self.config_load_error { + egui::Window::new("Config Error") + .anchor(Align2::CENTER_CENTER, [0.0, 0.0]) + .open(&mut self.config_load_error_window_open) + .movable(false) + .collapsible(false) + .resizable(false) + .show(ctx, |ui| { + ui.label(err); + }); + } + + if !self.config_load_error_window_open { + self.config_load_error = None; + } + + return; + } + + if self.window_open { + egui::Window::new("Setup") + .anchor(Align2::CENTER_CENTER, [0.0, 0.0]) + .movable(false) + .collapsible(false) + .resizable(false) + .show(ctx, |ui| { + egui::Grid::new("setup_grid") + .num_columns(2) + .spacing([40.0, 4.0]) + .striped(true) + .show(ui, |ui| { + ui.label(RichText::new("Server address:").color( + match self.server_addr_error { + true => egui::Color32::RED, + false => egui::Color32::WHITE, + }, + )); + if ui + .text_edit_singleline(&mut self.temp_server_addr) + .on_hover_text( + "The socket address the proxy will connect to. This is the \ + address of the server.", + ) + .changed() + { + self.server_addr_error = false; + }; + ui.end_row(); + ui.label(RichText::new("Client address:").color( + match self.client_addr_error { + true => egui::Color32::RED, + false => egui::Color32::WHITE, + }, + )); + ui.text_edit_singleline(&mut self.temp_client_addr) + .on_hover_text( + "The socket address to listen for connections on. This is the \ + address clients should connect to.", + ); + ui.end_row(); + ui.label(RichText::new("Max Connections:").color( + match self.max_connections_error { + true => egui::Color32::RED, + false => egui::Color32::WHITE, + }, + )); + ui.text_edit_singleline(&mut self.temp_max_connections) + .on_hover_text( + "The maximum number of connections allowed to the proxy. By \ + default, there is no limit.", + ); + ui.end_row(); + if ui.button("Start Proxy").clicked() { + self.window_open = false; + } + }); + }); + + if !self.window_open { + let server_addr = self.temp_server_addr.parse::().map_err(|_| { + self.server_addr_error = true; + }); + + let client_addr = self.temp_client_addr.parse::().map_err(|_| { + self.client_addr_error = true; + }); + + let max_connections = if self.temp_max_connections.is_empty() { + Ok(100_000) + } else { + self.temp_max_connections.parse::().map_err(|_| { + self.max_connections_error = true; + }) + }; + + if server_addr.is_err() || client_addr.is_err() || max_connections.is_err() { + self.window_open = true; + return; + } + + self.config.set_server_addr(server_addr.unwrap()); + self.config.set_client_addr(client_addr.unwrap()); + self.config + .set_max_connections(if self.temp_max_connections.is_empty() { + None + } else { + Some(self.temp_max_connections.parse::().unwrap()) + }); + + self.start_listening( + client_addr.unwrap(), + server_addr.unwrap(), + max_connections.unwrap(), + ); + } + + return; + } + + egui::TopBottomPanel::top("header").show(ctx, |ui| { + ui.horizontal(|ui| { + ui.label("Filter:"); + if ui.text_edit_singleline(&mut self.filter).changed() { + self.context.set_filter(self.filter.clone()); + + self.config.set_filter(match self.filter.is_empty() { + true => None, + false => Some(self.filter.clone()), + }); + } + ui.menu_button("Packets", |ui| { + ui.set_max_width(250.0); + ui.set_max_height(400.0); + egui::ScrollArea::vertical() + .auto_shrink([true, true]) + .show(ui, |ui| { + self.nested_menus(ui); + }); + }); + }); + }); + + egui::SidePanel::left("side_panel") + .min_width(150.0) + .default_width(250.0) + .show(ctx, |ui| { + ui.horizontal(|ui| { + ui.heading("Packets"); + + let count = self.context.packet_count.read().expect("Poisoned RwLock"); + let total = self.context.packets.read().expect("Poisoned RwLock").len(); + + let all_selected = self.selected_packets.values().all(|v| *v); + + if self.filter.is_empty() && all_selected { + ui.label(format!("({total})")); + } else { + ui.label(format!("({count}/{total})")); + } + + if ui.button("Clear").clicked() { + self.context.clear(); + self.buffer = String::new(); + self.raw_packet = vec![]; + } + + if ui.button("Export").clicked() { + if let Some(path) = rfd::FileDialog::new() + .add_filter("Text Document", &["txt"]) + .save_file() + { + match self.context.save(path) { + Ok(_) => {} + Err(err) => { + eprintln!("Failed to save: {}", err); + } + } + } + } + }); + egui::ScrollArea::vertical() + .auto_shrink([false, false]) + .stick_to_bottom(true) + .show(ui, |ui| { + let mut f = self.context.packets.write().expect("Poisoned RwLock"); + + let f: Vec<&mut Packet> = f + .iter_mut() + .filter(|p| { + let m_packet = MetaPacket { + stage: p.stage.clone(), + id: p.packet_type, + direction: p.direction.clone(), + name: p.packet_name.clone(), + }; + + if !self.selected_packets.contains_key(&m_packet) { + self.selected_packets.insert(m_packet.clone(), true); + self.config + .set_selected_packets(self.selected_packets.clone()); + } else { + // if it does exist, check if the names are the same, if not + // update the key + let (existing, value) = + self.selected_packets.get_key_value(&m_packet).unwrap(); + if existing.name != m_packet.name { + let value = *value; // keep the old value + self.selected_packets.remove(&m_packet); + self.selected_packets.insert(m_packet.clone(), value); + self.config + .set_selected_packets(self.selected_packets.clone()); + } + } + + if let Some(selected) = self.selected_packets.get(&m_packet) { + if !*selected { + return false; + } + } + + if self.filter.is_empty() { + return true; + } + + if let Ok(re) = regex::Regex::new(&self.filter) { + return re.is_match(&p.packet_name); + } + + false + }) + .collect(); + + *self.context.packet_count.write().expect("Poisoned RwLock") = f.len(); + + for packet in f { + { + let selected = self + .context + .selected_packet + .read() + .expect("Poisoned RwLock"); + if let Some(idx) = *selected { + if idx == packet.id { + packet.selected(true); + self.buffer = packet.get_packet_string(true); + self.raw_packet = packet.get_raw_packet(); + } else { + packet.selected(false); + } + } else { + packet.selected(false); + } + } + + if ui.add(packet.clone()).clicked() { + self.context.set_selected_packet(packet.id); + } + } + }); + }); + egui::CentralPanel::default().show(ctx, |ui| { + ui.horizontal(|ui| { + ui.checkbox(&mut self.view_hex, "Hex View"); + }); + + egui::ScrollArea::both() + .auto_shrink([false, false]) + .show(ui, |ui| { + if self.view_hex { + hex_view_ui(ui, &self.raw_packet); + } else { + code_view_ui(ui, &self.buffer); + } + }); + }); + } +} diff --git a/crates/packet_inspector/src/packet_widget.rs b/crates/packet_inspector/src/packet_widget.rs new file mode 100644 index 0000000..72454b7 --- /dev/null +++ b/crates/packet_inspector/src/packet_widget.rs @@ -0,0 +1,151 @@ +use eframe::epaint::{PathShape, RectShape}; +use egui::{ + Pos2, Rect, Response, Rgba, Rounding, Sense, Shape, Stroke, TextStyle, Ui, Vec2, Widget, + WidgetText, +}; +use serde::{Deserialize, Serialize}; +use time::OffsetDateTime; + +use crate::context::Packet; + +pub fn systemtime_strftime(odt: OffsetDateTime) -> String { + let hour = odt.hour(); + let minute = odt.minute(); + let second = odt.second(); + let millis = odt.millisecond(); + + format!("{hour:0>2}:{minute:0>2}:{second:0>2}.{millis:0>4}") +} + +#[derive(Clone, PartialEq, PartialOrd, Eq, Ord, Serialize, Deserialize)] +pub enum PacketDirection { + ClientToServer, + ServerToClient, +} + +impl PacketDirection { + fn get_shape(&self, outer_rect: &Rect) -> PathShape { + let rect = Rect::from_min_size( + Pos2 { + x: outer_rect.left() + 6.0, + y: outer_rect.top() + 8.0, + }, + Vec2 { x: 8.0, y: 8.0 }, + ); + + let color = match self { + PacketDirection::ServerToClient => Rgba::from_rgb(255.0, 0.0, 0.0), + PacketDirection::ClientToServer => Rgba::from_rgb(0.0, 255.0, 0.0), + }; + + let points = match self { + PacketDirection::ServerToClient => vec![ + Pos2 { + x: rect.left() + (rect.width() / 2.0), + y: rect.top() + rect.height(), + }, + Pos2 { + x: rect.left() + 0.0, + y: rect.top(), + }, + Pos2 { + x: rect.left() + rect.width(), + y: rect.top(), + }, + ], + PacketDirection::ClientToServer => vec![ + Pos2 { + x: rect.left() + (rect.width() / 2.0), + y: rect.top() + 0.0, + }, + Pos2 { + x: rect.left() + 0.0, + y: rect.top() + rect.height(), + }, + Pos2 { + x: rect.left() + rect.width(), + y: rect.top() + rect.height(), + }, + ], + }; + + let mut shape = PathShape::closed_line(points, Stroke::new(2.0, color)); + shape.fill = color.into(); + + shape + } +} + +impl Widget for Packet { + fn ui(self, ui: &mut Ui) -> Response { + let (mut rect, response) = ui.allocate_at_least( + Vec2 { + x: ui.available_width(), + y: 24.0, + }, + Sense::click(), + ); + + let fill = match self.selected { + true => Rgba::from_rgba_premultiplied(0.0, 0.0, 0.0, 0.4), + false => Rgba::from_rgba_premultiplied(0.0, 0.0, 0.0, 0.0), + }; + + if ui.is_rect_visible(rect) { + ui.painter().add(Shape::Rect(RectShape { + rect, + rounding: Rounding::none(), + fill: fill.into(), + stroke: Stroke::new(1.0, Rgba::BLACK), + })); + + let shape = self.direction.get_shape(&rect); + ui.painter().add(Shape::Path(shape)); + + let identifier: WidgetText = format!("0x{:0>2X?}", self.packet_type).into(); + + let identifier = + identifier.into_galley(ui, Some(false), rect.width() - 21.0, TextStyle::Button); + + let label: WidgetText = self.packet_name.into(); + let label = label.into_galley(ui, Some(false), rect.width() - 60.0, TextStyle::Button); + + let timestamp: WidgetText = systemtime_strftime(self.created_at).into(); + let timestamp = + timestamp.into_galley(ui, Some(false), rect.width() - 60.0, TextStyle::Button); + + identifier.paint_with_fallback_color( + ui.painter(), + Pos2 { + x: rect.left() + 21.0, + y: rect.top() + 6.0, + }, + ui.visuals().weak_text_color(), + ); + + rect.set_width(rect.width() - 5.0); + + let label_width = label.size().x + 50.0; + + label.paint_with_fallback_color( + &ui.painter().with_clip_rect(rect), + Pos2 { + x: rect.left() + 55.0, + y: rect.top() + 6.0, + }, + ui.visuals().strong_text_color(), + ); + + timestamp.paint_with_fallback_color( + &ui.painter().with_clip_rect(rect), + Pos2 { + x: rect.left() + label_width + 8.0, + y: rect.top() + 6.0, + }, + ui.visuals().weak_text_color(), + ); + } + + response + } +} diff --git a/crates/packet_inspector/src/state.rs b/crates/packet_inspector/src/state.rs new file mode 100644 index 0000000..3cb2556 --- /dev/null +++ b/crates/packet_inspector/src/state.rs @@ -0,0 +1,65 @@ +use std::io::ErrorKind; +use std::sync::Arc; + +use time::OffsetDateTime; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::tcp::{OwnedReadHalf, OwnedWriteHalf}; +use valence_protocol::codec::{PacketDecoder, PacketEncoder}; +use valence_protocol::Packet as ValencePacket; + +use crate::context::{Context, Packet, Stage}; +use crate::packet_widget::PacketDirection; + +pub struct State { + pub direction: PacketDirection, + pub context: Arc, + pub enc: PacketEncoder, + pub dec: PacketDecoder, + pub read: OwnedReadHalf, + pub write: OwnedWriteHalf, +} + +impl State { + pub async fn rw_packet<'a, P>(&'a mut self, stage: Stage) -> anyhow::Result

+ where + P: ValencePacket<'a>, + { + while !self.dec.has_next_packet()? { + self.dec.reserve(4096); + let mut buf = self.dec.take_capacity(); + + if self.read.read_buf(&mut buf).await? == 0 { + return Err(std::io::Error::from(ErrorKind::UnexpectedEof).into()); + } + + self.dec.queue_bytes(buf); + } + + let has_compression = self.dec.compression(); + let pkt: P = self.dec.try_next_packet()?.unwrap(); + + self.enc.append_packet(&pkt)?; + + let bytes = self.enc.take(); + self.write.write_all(&bytes).await?; + + let time = match OffsetDateTime::now_local() { + Ok(time) => time, + Err(_) => OffsetDateTime::now_utc(), + }; + + self.context.add(Packet { + id: 0, // updated when added to context + direction: self.direction.clone(), + use_compression: has_compression, + packet_data: bytes.to_vec(), + stage, + created_at: time, + selected: false, + packet_type: pkt.packet_id(), + packet_name: pkt.packet_name().to_string(), + }); + + Ok(pkt) + } +} diff --git a/crates/packet_inspector/src/syntax_highlighting.rs b/crates/packet_inspector/src/syntax_highlighting.rs new file mode 100644 index 0000000..b40db71 --- /dev/null +++ b/crates/packet_inspector/src/syntax_highlighting.rs @@ -0,0 +1,513 @@ +// From: https://github.com/emilk/egui/blob/master/crates/egui_demo_lib/src/syntax_highlighting.rs + +use egui::text::LayoutJob; + +/// View some code with syntax highlighting and selection. +pub fn code_view_ui(ui: &mut egui::Ui, mut code: &str) { + let language = "rs"; + let theme = CodeTheme::from_memory(ui.ctx()); + + let mut layouter = |ui: &egui::Ui, string: &str, wrap_width: f32| { + let mut layout_job = highlight(ui.ctx(), &theme, string, language); + layout_job.wrap.max_width = wrap_width; // no wrapping + ui.fonts(|f| f.layout_job(layout_job)) + }; + + ui.add( + egui::TextEdit::multiline(&mut code) + .font(egui::TextStyle::Monospace) // for cursor height + .code_editor() + .desired_width(ui.available_width()) + .desired_rows(24) + .lock_focus(true) + .layouter(&mut layouter), + ); +} + +/// Memoized Code highlighting +pub fn highlight(ctx: &egui::Context, theme: &CodeTheme, code: &str, language: &str) -> LayoutJob { + impl egui::util::cache::ComputerMut<(&CodeTheme, &str, &str), LayoutJob> for Highlighter { + fn compute(&mut self, (theme, code, lang): (&CodeTheme, &str, &str)) -> LayoutJob { + self.highlight(theme, code, lang) + } + } + + type HighlightCache = egui::util::cache::FrameCache; + + ctx.memory_mut(|mem| { + mem.caches + .cache::() + .get((theme, code, language)) + }) +} + +// ---------------------------------------------------------------------------- + +#[cfg(not(feature = "syntect"))] +#[derive(Clone, Copy, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +#[derive(enum_map::Enum)] +enum TokenType { + Comment, + Keyword, + Literal, + StringLiteral, + Punctuation, + Whitespace, +} + +#[cfg(feature = "syntect")] +#[derive(Clone, Copy, Hash, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +#[allow(unused)] +enum SyntectTheme { + Base16EightiesDark, + Base16MochaDark, + Base16OceanDark, + Base16OceanLight, + InspiredGitHub, + SolarizedDark, + SolarizedLight, +} + +#[allow(unused)] +#[cfg(feature = "syntect")] +impl SyntectTheme { + fn all() -> impl ExactSizeIterator { + [ + Self::Base16EightiesDark, + Self::Base16MochaDark, + Self::Base16OceanDark, + Self::Base16OceanLight, + Self::InspiredGitHub, + Self::SolarizedDark, + Self::SolarizedLight, + ] + .iter() + .copied() + } + + fn name(&self) -> &'static str { + match self { + Self::Base16EightiesDark => "Base16 Eighties (dark)", + Self::Base16MochaDark => "Base16 Mocha (dark)", + Self::Base16OceanDark => "Base16 Ocean (dark)", + Self::Base16OceanLight => "Base16 Ocean (light)", + Self::InspiredGitHub => "InspiredGitHub (light)", + Self::SolarizedDark => "Solarized (dark)", + Self::SolarizedLight => "Solarized (light)", + } + } + + fn syntect_key_name(&self) -> &'static str { + match self { + Self::Base16EightiesDark => "base16-eighties.dark", + Self::Base16MochaDark => "base16-mocha.dark", + Self::Base16OceanDark => "base16-ocean.dark", + Self::Base16OceanLight => "base16-ocean.light", + Self::InspiredGitHub => "InspiredGitHub", + Self::SolarizedDark => "Solarized (dark)", + Self::SolarizedLight => "Solarized (light)", + } + } + + pub fn is_dark(&self) -> bool { + match self { + Self::Base16EightiesDark + | Self::Base16MochaDark + | Self::Base16OceanDark + | Self::SolarizedDark => true, + + Self::Base16OceanLight | Self::InspiredGitHub | Self::SolarizedLight => false, + } + } +} + +#[derive(Clone, Hash, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +#[cfg_attr(feature = "serde", serde(default))] +pub struct CodeTheme { + dark_mode: bool, + + #[cfg(feature = "syntect")] + syntect_theme: SyntectTheme, + + #[cfg(not(feature = "syntect"))] + formats: enum_map::EnumMap, +} + +impl Default for CodeTheme { + fn default() -> Self { + Self::dark() + } +} + +#[allow(unused)] +impl CodeTheme { + pub fn from_style(style: &egui::Style) -> Self { + if style.visuals.dark_mode { + Self::dark() + } else { + Self::light() + } + } + + pub fn from_memory(ctx: &egui::Context) -> Self { + if ctx.style().visuals.dark_mode { + ctx.data_mut(|d| { + d.get_persisted(egui::Id::new("dark")) + .unwrap_or_else(CodeTheme::dark) + }) + } else { + ctx.data_mut(|d| { + d.get_persisted(egui::Id::new("light")) + .unwrap_or_else(CodeTheme::light) + }) + } + } + + pub fn store_in_memory(self, ctx: &egui::Context) { + if self.dark_mode { + ctx.data_mut(|d| d.insert_persisted(egui::Id::new("dark"), self)); + } else { + ctx.data_mut(|d| d.insert_persisted(egui::Id::new("light"), self)); + } + } +} + +#[allow(unused)] +#[cfg(feature = "syntect")] +impl CodeTheme { + pub fn dark() -> Self { + Self { + dark_mode: true, + syntect_theme: SyntectTheme::SolarizedDark, + } + } + + pub fn light() -> Self { + Self { + dark_mode: false, + syntect_theme: SyntectTheme::SolarizedLight, + } + } + + pub fn ui(&mut self, ui: &mut egui::Ui) { + egui::widgets::global_dark_light_mode_buttons(ui); + + for theme in SyntectTheme::all() { + if theme.is_dark() == self.dark_mode { + ui.radio_value(&mut self.syntect_theme, theme, theme.name()); + } + } + } +} + +#[cfg(not(feature = "syntect"))] +impl CodeTheme { + pub fn dark() -> Self { + let font_id = egui::FontId::monospace(10.0); + use egui::{Color32, TextFormat}; + Self { + dark_mode: true, + formats: enum_map::enum_map![ + TokenType::Comment => TextFormat::simple(font_id.clone(), Color32::from_gray(120)), + TokenType::Keyword => TextFormat::simple(font_id.clone(), Color32::from_rgb(255, 100, 100)), + TokenType::Literal => TextFormat::simple(font_id.clone(), Color32::from_rgb(87, 165, 171)), + TokenType::StringLiteral => TextFormat::simple(font_id.clone(), Color32::from_rgb(109, 147, 226)), + TokenType::Punctuation => TextFormat::simple(font_id.clone(), Color32::LIGHT_GRAY), + TokenType::Whitespace => TextFormat::simple(font_id.clone(), Color32::TRANSPARENT), + ], + } + } + + pub fn light() -> Self { + let font_id = egui::FontId::monospace(10.0); + use egui::{Color32, TextFormat}; + Self { + dark_mode: false, + #[cfg(not(feature = "syntect"))] + formats: enum_map::enum_map![ + TokenType::Comment => TextFormat::simple(font_id.clone(), Color32::GRAY), + TokenType::Keyword => TextFormat::simple(font_id.clone(), Color32::from_rgb(235, 0, 0)), + TokenType::Literal => TextFormat::simple(font_id.clone(), Color32::from_rgb(153, 134, 255)), + TokenType::StringLiteral => TextFormat::simple(font_id.clone(), Color32::from_rgb(37, 203, 105)), + TokenType::Punctuation => TextFormat::simple(font_id.clone(), Color32::DARK_GRAY), + TokenType::Whitespace => TextFormat::simple(font_id.clone(), Color32::TRANSPARENT), + ], + } + } + + pub fn ui(&mut self, ui: &mut egui::Ui) { + ui.horizontal_top(|ui| { + let selected_id = egui::Id::null(); + let mut selected_tt: TokenType = + ui.data_mut(|d| *d.get_persisted_mut_or(selected_id, TokenType::Comment)); + + ui.vertical(|ui| { + ui.set_width(150.0); + egui::widgets::global_dark_light_mode_buttons(ui); + + ui.add_space(8.0); + ui.separator(); + ui.add_space(8.0); + + ui.scope(|ui| { + for (tt, tt_name) in [ + (TokenType::Comment, "// comment"), + (TokenType::Keyword, "keyword"), + (TokenType::Literal, "literal"), + (TokenType::StringLiteral, "\"string literal\""), + (TokenType::Punctuation, "punctuation ;"), + // (TokenType::Whitespace, "whitespace"), + ] { + let format = &mut self.formats[tt]; + ui.style_mut().override_font_id = Some(format.font_id.clone()); + ui.visuals_mut().override_text_color = Some(format.color); + ui.radio_value(&mut selected_tt, tt, tt_name); + } + }); + + let reset_value = if self.dark_mode { + CodeTheme::dark() + } else { + CodeTheme::light() + }; + + if ui + .add_enabled(*self != reset_value, egui::Button::new("Reset theme")) + .clicked() + { + *self = reset_value; + } + }); + + ui.add_space(16.0); + + ui.data_mut(|d| d.insert_persisted(selected_id, selected_tt)); + + egui::Frame::group(ui.style()) + .inner_margin(egui::Vec2::splat(2.0)) + .show(ui, |ui| { + // ui.group(|ui| { + ui.style_mut().override_text_style = Some(egui::TextStyle::Small); + ui.spacing_mut().slider_width = 128.0; // Controls color picker size + egui::widgets::color_picker::color_picker_color32( + ui, + &mut self.formats[selected_tt].color, + egui::color_picker::Alpha::Opaque, + ); + }); + }); + } +} + +// ---------------------------------------------------------------------------- + +#[cfg(feature = "syntect")] +struct Highlighter { + ps: syntect::parsing::SyntaxSet, + ts: syntect::highlighting::ThemeSet, +} + +#[cfg(feature = "syntect")] +impl Default for Highlighter { + fn default() -> Self { + Self { + ps: syntect::parsing::SyntaxSet::load_defaults_newlines(), + ts: syntect::highlighting::ThemeSet::load_defaults(), + } + } +} + +#[cfg(feature = "syntect")] +impl Highlighter { + #[allow(clippy::unused_self, clippy::unnecessary_wraps)] + fn highlight(&self, theme: &CodeTheme, code: &str, lang: &str) -> LayoutJob { + self.highlight_impl(theme, code, lang).unwrap_or_else(|| { + // Fallback: + LayoutJob::simple( + code.into(), + egui::FontId::monospace(12.0), + if theme.dark_mode { + egui::Color32::LIGHT_GRAY + } else { + egui::Color32::DARK_GRAY + }, + f32::INFINITY, + ) + }) + } + + fn highlight_impl(&self, theme: &CodeTheme, text: &str, language: &str) -> Option { + use syntect::easy::HighlightLines; + use syntect::highlighting::FontStyle; + use syntect::util::LinesWithEndings; + + let syntax = self + .ps + .find_syntax_by_name(language) + .or_else(|| self.ps.find_syntax_by_extension(language))?; + + let theme = theme.syntect_theme.syntect_key_name(); + let mut h = HighlightLines::new(syntax, &self.ts.themes[theme]); + + use egui::text::{LayoutSection, TextFormat}; + + let mut job = LayoutJob { + text: text.into(), + ..Default::default() + }; + + for line in LinesWithEndings::from(text) { + for (style, range) in h.highlight_line(line, &self.ps).ok()? { + let fg = style.foreground; + let text_color = egui::Color32::from_rgb(fg.r, fg.g, fg.b); + let italics = style.font_style.contains(FontStyle::ITALIC); + let underline = style.font_style.contains(FontStyle::ITALIC); + let underline = if underline { + egui::Stroke::new(1.0, text_color) + } else { + egui::Stroke::NONE + }; + job.sections.push(LayoutSection { + leading_space: 0.0, + byte_range: as_byte_range(text, range), + format: TextFormat { + font_id: egui::FontId::monospace(12.0), + color: text_color, + italics, + underline, + ..Default::default() + }, + }); + } + } + + Some(job) + } +} + +#[cfg(feature = "syntect")] +fn as_byte_range(whole: &str, range: &str) -> std::ops::Range { + let whole_start = whole.as_ptr() as usize; + let range_start = range.as_ptr() as usize; + assert!(whole_start <= range_start); + assert!(range_start + range.len() <= whole_start + whole.len()); + let offset = range_start - whole_start; + offset..(offset + range.len()) +} + +// ---------------------------------------------------------------------------- + +#[cfg(not(feature = "syntect"))] +#[derive(Default)] +struct Highlighter {} + +#[cfg(not(feature = "syntect"))] +impl Highlighter { + #[allow(clippy::unused_self, clippy::unnecessary_wraps)] + fn highlight(&self, theme: &CodeTheme, mut text: &str, _language: &str) -> LayoutJob { + // Extremely simple syntax highlighter for when we compile without syntect + + let mut job = LayoutJob::default(); + + while !text.is_empty() { + if text.starts_with("//") { + let end = text.find('\n').unwrap_or(text.len()); + job.append(&text[..end], 0.0, theme.formats[TokenType::Comment].clone()); + text = &text[end..]; + } else if text.starts_with('"') { + let end = text[1..] + .find('"') + .map(|i| i + 2) + .or_else(|| text.find('\n')) + .unwrap_or(text.len()); + job.append( + &text[..end], + 0.0, + theme.formats[TokenType::StringLiteral].clone(), + ); + text = &text[end..]; + } else if text.starts_with(|c: char| c.is_ascii_alphanumeric()) { + let end = text[1..] + .find(|c: char| !c.is_ascii_alphanumeric()) + .map_or_else(|| text.len(), |i| i + 1); + let word = &text[..end]; + let tt = if is_keyword(word) { + TokenType::Keyword + } else { + TokenType::Literal + }; + job.append(word, 0.0, theme.formats[tt].clone()); + text = &text[end..]; + } else if text.starts_with(|c: char| c.is_ascii_whitespace()) { + let end = text[1..] + .find(|c: char| !c.is_ascii_whitespace()) + .map_or_else(|| text.len(), |i| i + 1); + job.append( + &text[..end], + 0.0, + theme.formats[TokenType::Whitespace].clone(), + ); + text = &text[end..]; + } else { + let mut it = text.char_indices(); + it.next(); + let end = it.next().map_or(text.len(), |(idx, _chr)| idx); + job.append( + &text[..end], + 0.0, + theme.formats[TokenType::Punctuation].clone(), + ); + text = &text[end..]; + } + } + + job + } +} + +#[cfg(not(feature = "syntect"))] +fn is_keyword(word: &str) -> bool { + matches!( + word, + "as" | "async" + | "await" + | "break" + | "const" + | "continue" + | "crate" + | "dyn" + | "else" + | "enum" + | "extern" + | "false" + | "fn" + | "for" + | "if" + | "impl" + | "in" + | "let" + | "loop" + | "match" + | "mod" + | "move" + | "mut" + | "pub" + | "ref" + | "return" + | "self" + | "Self" + | "static" + | "struct" + | "super" + | "trait" + | "true" + | "type" + | "unsafe" + | "use" + | "where" + | "while" + ) +}