diff --git a/Cargo.lock b/Cargo.lock index 559ff08..3fdf17c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1184,7 +1184,7 @@ dependencies = [ [[package]] name = "idevice" -version = "0.1.32" +version = "0.1.33" dependencies = [ "async-recursion", "base64", diff --git a/idevice/src/lib.rs b/idevice/src/lib.rs index 4bbfa29..5ff3006 100644 --- a/idevice/src/lib.rs +++ b/idevice/src/lib.rs @@ -173,6 +173,31 @@ impl Idevice { } } + /// Sends a binary plist-formatted message to the device + /// + /// # Arguments + /// * `message` - The plist value to send + /// + /// # Errors + /// Returns `IdeviceError` if serialization or transmission fails + async fn send_bplist(&mut self, message: plist::Value) -> Result<(), IdeviceError> { + if let Some(socket) = &mut self.socket { + debug!("Sending plist: {}", pretty_print_plist(&message)); + + let buf = Vec::new(); + let mut writer = BufWriter::new(buf); + message.to_writer_binary(&mut writer)?; + let message = writer.into_inner().unwrap(); + let len = message.len() as u32; + socket.write_all(&len.to_be_bytes()).await?; + socket.write_all(&message).await?; + socket.flush().await?; + Ok(()) + } else { + Err(IdeviceError::NoEstablishedConnection) + } + } + /// Sends raw binary data to the device /// /// # Arguments @@ -327,6 +352,23 @@ impl Idevice { } } + async fn read_until_byte(&mut self, stopper: u8) -> Result, IdeviceError> { + if let Some(socket) = &mut self.socket { + let mut buf = Vec::new(); + + loop { + let b = socket.read_u8().await?; + if b == stopper { + return Ok(buf); + } else { + buf.push(b); + } + } + } else { + Err(IdeviceError::NoEstablishedConnection) + } + } + /// Upgrades the connection to TLS using device pairing credentials /// /// # Arguments diff --git a/idevice/src/services/mod.rs b/idevice/src/services/mod.rs index 3f0fab3..a276190 100644 --- a/idevice/src/services/mod.rs +++ b/idevice/src/services/mod.rs @@ -21,6 +21,8 @@ pub mod lockdown; pub mod misagent; #[cfg(feature = "mobile_image_mounter")] pub mod mobile_image_mounter; +#[cfg(feature = "syslog_relay")] +pub mod os_trace_relay; #[cfg(feature = "springboardservices")] pub mod springboardservices; #[cfg(feature = "syslog_relay")] diff --git a/idevice/src/services/os_trace_relay.rs b/idevice/src/services/os_trace_relay.rs new file mode 100644 index 0000000..822369a --- /dev/null +++ b/idevice/src/services/os_trace_relay.rs @@ -0,0 +1,359 @@ +//! iOS Device OsTraceRelay Service Abstraction +//! Note that there are unknown fields that will hopefully be filled in the future. +//! Huge thanks to pymobiledevice3 for the struct implementation +//! https://github.com/doronz88/pymobiledevice3/blob/master/pymobiledevice3/services/os_trace.py + +use chrono::{DateTime, NaiveDateTime}; +use plist::Dictionary; +use tokio::io::AsyncWriteExt; + +use crate::{lockdown::LockdownClient, Idevice, IdeviceError, IdeviceService}; + +/// Client for interacting with the iOS device OsTraceRelay service +pub struct OsTraceRelayClient { + /// The underlying device connection with established OsTraceRelay service + pub idevice: Idevice, +} + +impl IdeviceService for OsTraceRelayClient { + /// Returns the OsTraceRelay service name as registered with lockdownd + fn service_name() -> &'static str { + "com.apple.os_trace_relay" + } + + /// Establishes a connection to the OsTraceRelay service + /// + /// # Arguments + /// * `provider` - Device connection provider + /// + /// # Returns + /// A connected `OsTraceRelayClient` instance + /// + /// # Errors + /// Returns `IdeviceError` if any step of the connection process fails + /// + /// # Process + /// 1. Connects to lockdownd service + /// 2. Starts a lockdown session + /// 3. Requests the OsTraceRelay service port + /// 4. Establishes connection to the OsTraceRelay port + /// 5. Optionally starts TLS if required by service + async fn connect( + provider: &dyn crate::provider::IdeviceProvider, + ) -> Result { + let mut lockdown = LockdownClient::connect(provider).await?; + lockdown + .start_session(&provider.get_pairing_file().await?) + .await?; + + let (port, ssl) = lockdown.start_service(Self::service_name()).await?; + + let mut idevice = provider.connect(port).await?; + if ssl { + idevice + .start_session(&provider.get_pairing_file().await?) + .await?; + } + + Ok(Self { idevice }) + } +} + +/// An initialized client for receiving logs +pub struct OsTraceRelayReceiver { + inner: OsTraceRelayClient, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct OsTraceLog { + pid: u32, + timestamp: NaiveDateTime, + level: LogLevel, + image_name: String, + filename: String, + message: String, + label: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SyslogLabel { + subsystem: String, + category: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum LogLevel { + Notice = 0, + Info = 1, + Debug = 2, + Error = 10, + Fault = 11, +} + +impl OsTraceRelayClient { + /// Starts the stream of logs from the relay + /// + /// # Arguments + /// * `pid` - An optional pid to stream logs from + pub async fn start_trace( + mut self, + pid: Option, + ) -> Result { + let pid = match pid { + Some(p) => p as i64, + None => -1, + }; + let mut req = Dictionary::new(); + req.insert("Request".into(), "StartActivity".into()); + req.insert("Pid".into(), Into::into(pid)); + req.insert("MessageFilter".into(), Into::into(65_535)); + req.insert("StreamFlags".into(), Into::into(60)); + + self.idevice + .send_bplist(plist::Value::Dictionary(req)) + .await?; + + // Read a single byte + self.idevice.read_raw(1).await?; + + // Result + let res = self.idevice.read_plist().await?; + + match res.get("Status").and_then(|x| x.as_string()) { + Some(r) => { + if r == "RequestSuccessful" { + Ok(OsTraceRelayReceiver { inner: self }) + } else { + Err(IdeviceError::UnexpectedResponse) + } + } + None => Err(IdeviceError::UnexpectedResponse), + } + } + + /// Get the list of available PIDs + pub async fn get_pid_list(&mut self) -> Result, IdeviceError> { + let mut req = Dictionary::new(); + req.insert("Request".into(), "PidList".into()); + + self.idevice + .send_bplist(plist::Value::Dictionary(req)) + .await?; + + // Read a single byte + self.idevice.read_raw(1).await?; + + // Result + let res = self.idevice.read_plist().await?; + + if let Some(pids) = res.get("Pids").and_then(|x| x.as_array()) { + pids.iter() + .map(|x| { + x.as_unsigned_integer() + .ok_or(IdeviceError::UnexpectedResponse) + }) + .collect() + } else { + Err(IdeviceError::UnexpectedResponse) + } + } + + /// Create a log archive and write it to the provided writer + pub async fn create_archive( + &mut self, + out: &mut W, + size_limit: Option, + age_limit: Option, + start_time: Option, + ) -> Result<(), IdeviceError> { + let mut req = Dictionary::new(); + req.insert("Request".into(), "CreateArchive".into()); + + if let Some(size) = size_limit { + req.insert("SizeLimit".into(), size.into()); + } + + if let Some(age) = age_limit { + req.insert("AgeLimit".into(), age.into()); + } + + if let Some(time) = start_time { + req.insert("StartTime".into(), time.into()); + } + + self.idevice + .send_bplist(plist::Value::Dictionary(req)) + .await?; + + // Read a single byte + if self.idevice.read_raw(1).await?[0] != 1 { + return Err(IdeviceError::UnexpectedResponse); + } + + // Check status + let res = self.idevice.read_plist().await?; + match res.get("Status").and_then(|x| x.as_string()) { + Some("RequestSuccessful") => {} + _ => return Err(IdeviceError::UnexpectedResponse), + } + + // Read archive data + loop { + match self.idevice.read_raw(1).await { + Ok(data) if data[0] == 0x03 => { + let length_bytes = self.idevice.read_raw(4).await?; + let length = u32::from_le_bytes([ + length_bytes[0], + length_bytes[1], + length_bytes[2], + length_bytes[3], + ]); + let data = self.idevice.read_raw(length as usize).await?; + out.write_all(&data).await?; + } + Err(IdeviceError::Socket(_)) => break, + _ => return Err(IdeviceError::UnexpectedResponse), + } + } + + Ok(()) + } +} + +impl OsTraceRelayReceiver { + /// Get the next log from the relay + /// + /// # Returns + /// A string containing the log + /// + /// # Errors + /// UnexpectedResponse if the service sends an EOF + pub async fn next(&mut self) -> Result { + // Read 0x02, at the beginning of each packet + if self.inner.idevice.read_raw(1).await?[0] != 0x02 { + return Err(IdeviceError::UnexpectedResponse); + } + + // Read the len of the packet + let pl = self.inner.idevice.read_raw(4).await?; + let packet_length = u32::from_le_bytes([pl[0], pl[1], pl[2], pl[3]]); + + let packet = self.inner.idevice.read_raw(packet_length as usize).await?; + + // 9 bytes of padding + let packet = &packet[9..]; + + // Parse PID (4 bytes) + let pid = u32::from_le_bytes([packet[0], packet[1], packet[2], packet[3]]); + let packet = &packet[4..]; + + // Skip 42 unknown bytes + let packet = &packet[42..]; + + // Parse timestamp (seconds + microseconds) + let seconds = u32::from_le_bytes([packet[0], packet[1], packet[2], packet[3]]); + let packet = &packet[8..]; // skip 4 bytes padding after seconds + let microseconds = u32::from_le_bytes([packet[0], packet[1], packet[2], packet[3]]); + let packet = &packet[4..]; + + // Skip 1 byte padding + let packet = &packet[1..]; + + // Parse log level + let log_level = packet[0]; + let log_level: LogLevel = log_level.try_into()?; + let packet = &packet[1..]; + + // Skip 38 unknown bytes + let packet = &packet[38..]; + + // Parse string sizes + let image_name_size = u16::from_le_bytes([packet[0], packet[1]]) as usize; + let packet = &packet[2..]; + let message_size = u16::from_le_bytes([packet[0], packet[1]]) as usize; + let packet = &packet[2..]; + + // Skip 6 bytes + let packet = &packet[6..]; + + // Parse subsystem and category sizes + let subsystem_size = + u32::from_le_bytes([packet[0], packet[1], packet[2], packet[3]]) as usize; + let packet = &packet[4..]; + let category_size = + u32::from_le_bytes([packet[0], packet[1], packet[2], packet[3]]) as usize; + let packet = &packet[4..]; + + // Skip 4 bytes + let packet = &packet[4..]; + + // Parse filename (null-terminated string) + let filename_end = packet + .iter() + .position(|&b| b == 0) + .ok_or(IdeviceError::UnexpectedResponse)?; + let filename = String::from_utf8_lossy(&packet[..filename_end]).into_owned(); + let packet = &packet[filename_end + 1..]; + + // Parse image name + let image_name_bytes = &packet[..image_name_size]; + let image_name = + String::from_utf8_lossy(&image_name_bytes[..image_name_bytes.len() - 1]).into_owned(); + let packet = &packet[image_name_size..]; + + // Parse message + let message_bytes = &packet[..message_size]; + let message = + String::from_utf8_lossy(&message_bytes[..message_bytes.len() - 1]).into_owned(); + let packet = &packet[message_size..]; + + // Parse label if subsystem and category exist + let label = if subsystem_size > 0 && category_size > 0 && !packet.is_empty() { + let subsystem_bytes = &packet[..subsystem_size]; + let subsystem = + String::from_utf8_lossy(&subsystem_bytes[..subsystem_bytes.len() - 1]).into_owned(); + let packet = &packet[subsystem_size..]; + + let category_bytes = &packet[..category_size]; + let category = + String::from_utf8_lossy(&category_bytes[..category_bytes.len() - 1]).into_owned(); + + Some(SyslogLabel { + subsystem, + category, + }) + } else { + None + }; + + let timestamp = match DateTime::from_timestamp(seconds as i64, microseconds) { + Some(t) => t.naive_local(), + None => return Err(IdeviceError::UnexpectedResponse), + }; + + Ok(OsTraceLog { + pid, + timestamp, + level: log_level, + image_name, + filename, + message, + label, + }) + } +} + +impl TryFrom for LogLevel { + type Error = IdeviceError; + + fn try_from(value: u8) -> Result { + Ok(match value { + 0 => Self::Notice, + 1 => Self::Info, + 2 => Self::Debug, + 0x10 => Self::Error, + 0x11 => Self::Fault, + _ => return Err(IdeviceError::UnexpectedResponse), + }) + } +} diff --git a/tools/Cargo.toml b/tools/Cargo.toml index 9927c1e..f28d262 100644 --- a/tools/Cargo.toml +++ b/tools/Cargo.toml @@ -77,6 +77,10 @@ path = "src/pair.rs" name = "syslog_relay" path = "src/syslog_relay.rs" +[[bin]] +name = "os_trace_relay" +path = "src/os_trace_relay.rs" + [dependencies] idevice = { path = "../idevice", features = ["full"] } tokio = { version = "1.43", features = ["io-util", "macros", "time", "full"] } diff --git a/tools/src/os_trace_relay.rs b/tools/src/os_trace_relay.rs new file mode 100644 index 0000000..45c1b76 --- /dev/null +++ b/tools/src/os_trace_relay.rs @@ -0,0 +1,68 @@ +// Jackson Coxson + +use clap::{Arg, Command}; +use idevice::{os_trace_relay::OsTraceRelayClient, IdeviceService}; + +mod common; + +#[tokio::main] +async fn main() { + env_logger::init(); + + let matches = Command::new("os_trace_relay") + .about("Relay system logs") + .arg( + Arg::new("host") + .long("host") + .value_name("HOST") + .help("IP address of the device"), + ) + .arg( + Arg::new("pairing_file") + .long("pairing-file") + .value_name("PATH") + .help("Path to the pairing file"), + ) + .arg( + Arg::new("udid") + .value_name("UDID") + .help("UDID of the device (overrides host/pairing file)"), + ) + .arg( + Arg::new("about") + .long("about") + .help("Show about information") + .action(clap::ArgAction::SetTrue), + ) + .get_matches(); + + if matches.get_flag("about") { + println!("Relay logs on the device"); + println!("Copyright (c) 2025 Jackson Coxson"); + return; + } + + let udid = matches.get_one::("udid"); + let host = matches.get_one::("host"); + let pairing_file = matches.get_one::("pairing_file"); + + let provider = match common::get_provider(udid, host, pairing_file, "misagent-jkcoxson").await { + Ok(p) => p, + Err(e) => { + eprintln!("{e}"); + return; + } + }; + let log_client = OsTraceRelayClient::connect(&*provider) + .await + .expect("Unable to connect to misagent"); + + let mut relay = log_client.start_trace(None).await.expect("Start failed"); + + loop { + println!( + "{:#?}", + relay.next().await.expect("Failed to read next log") + ); + } +}