From 7fd9772c044cec3a7f42a45604471cbac2b42531 Mon Sep 17 00:00:00 2001 From: cyqsimon <28627918+cyqsimon@users.noreply.github.com> Date: Mon, 16 Dec 2024 13:17:30 +0800 Subject: [PATCH 1/3] Rewrite `lsof` module MSRV bumped to 1.76.0 for using `Result::inspect_err` --- Cargo.toml | 2 +- src/network/connection.rs | 25 ++-- src/os/lsof.rs | 208 +++++++++++++++++++++++++++++- src/os/lsof_utils.rs | 257 -------------------------------------- src/os/mod.rs | 3 - 5 files changed, 221 insertions(+), 274 deletions(-) delete mode 100644 src/os/lsof_utils.rs diff --git a/Cargo.toml b/Cargo.toml index 7589bf4e1..12b230c39 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,7 +17,7 @@ keywords = ["networking", "utilization", "cli"] license = "MIT" readme = "README.md" repository = "https://github.com/imsnif/bandwhich" -rust-version = "1.75.0" +rust-version = "1.76.0" description = "Display current network utilization by process, connection and remote IP/hostname" [features] diff --git a/src/network/connection.rs b/src/network/connection.rs index 263ba5a52..f250cfedc 100644 --- a/src/network/connection.rs +++ b/src/network/connection.rs @@ -2,25 +2,17 @@ use std::{ collections::HashMap, fmt, net::{IpAddr, SocketAddr}, + str::FromStr, }; +use eyre::bail; + #[derive(PartialEq, Hash, Eq, Clone, PartialOrd, Ord, Debug, Copy)] pub enum Protocol { Tcp, Udp, } -impl Protocol { - #[allow(dead_code)] - pub fn from_str(string: &str) -> Option { - match string { - "TCP" => Some(Protocol::Tcp), - "UDP" => Some(Protocol::Udp), - _ => None, - } - } -} - impl fmt::Display for Protocol { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { match *self { @@ -30,6 +22,17 @@ impl fmt::Display for Protocol { } } +impl FromStr for Protocol { + type Err = eyre::Report; + fn from_str(s: &str) -> Result { + match s { + "tcp" | "TCP" => Ok(Protocol::Tcp), + "udp" | "UDP" => Ok(Protocol::Udp), + p => bail!("Unknown protocol `{p}`"), + } + } +} + #[derive(Clone, Ord, PartialOrd, PartialEq, Eq, Hash, Copy)] pub struct Socket { pub ip: IpAddr, diff --git a/src/os/lsof.rs b/src/os/lsof.rs index aec444d9f..d8d670cb7 100644 --- a/src/os/lsof.rs +++ b/src/os/lsof.rs @@ -1,9 +1,213 @@ -use crate::{os::lsof_utils::get_connections, OpenSockets}; +use std::{ + ffi::OsStr, + net::{IpAddr, Ipv4Addr, Ipv6Addr}, + process::Command, + str::FromStr, +}; + +use eyre::{bail, Context}; +use log::warn; +use once_cell::sync::Lazy; +use regex::Regex; + +use crate::{ + network::{LocalSocket, Protocol}, + os::ProcessInfo, + OpenSockets, +}; pub(crate) fn get_open_sockets() -> OpenSockets { let sockets_to_procs = get_connections() - .filter_map(|raw| raw.as_local_socket().map(|s| (s, raw.proc_info))) + .into_iter() + .map(|conn| (conn.as_local_socket(), conn.proc_info)) .collect(); OpenSockets { sockets_to_procs } } + +fn get_connections() -> Vec { + let raw_lines = run_lsof(["-n", "-P", "-i4", "-i6", "+c", "0"]); + + raw_lines + .lines() + .map(Connection::from_str) + .filter_map(|res| res.inspect_err(|err| warn!("{err}")).ok()) + .collect() +} + +fn run_lsof(args: I) -> String +where + I: IntoIterator, + S: AsRef, +{ + let output = Command::new("lsof") + .args(args) + .output() + .expect("failed to execute process"); + + String::from_utf8_lossy(&output.stdout).into_owned() +} + +/// Helper enum for strong typing. +#[derive(Copy, Clone, Debug)] +enum IpVer { + V4, + V6, +} +impl IpVer { + fn get_null_addr(&self) -> IpAddr { + match self { + Self::V4 => Ipv4Addr::UNSPECIFIED.into(), + Self::V6 => Ipv6Addr::UNSPECIFIED.into(), + } + } +} + +#[allow(dead_code)] +#[derive(Debug, Clone)] +struct Connection { + local: (IpAddr, u16), + /// None if listening + remote: Option<(IpAddr, u16)>, + protocol: Protocol, + proc_info: ProcessInfo, +} + +impl FromStr for Connection { + type Err = eyre::Report; + + fn from_str(raw_line: &str) -> Result { + // Example row + // com.apple 664 user 198u IPv4 0xeb179a6650592b8d 0t0 TCP 192.168.1.187:58535->1.2.3.4:443 (ESTABLISHED) + let columns = raw_line.split_ascii_whitespace().collect::>(); + if columns.len() < 9 { + bail!(r#"lsof output line contains fewer than 9 columns: "{raw_line}""#); + } + + let process_name = columns[0].replace("\\x20", " "); + let pid = columns[1] + .parse() + .wrap_err_with(|| format!("PID `{}` failed parsing", columns[1]))?; + let proc_info = ProcessInfo::new(&process_name, pid); + + let _username = columns[2]; + let _fd = columns[3]; + + let ip_ver = if columns[4].contains('4') { + IpVer::V4 + } else { + IpVer::V6 + }; + + let _device = columns[5]; + let _size = columns[6]; + + let protocol = columns[7].parse().wrap_err_with(|| { + format!( + "Protocol `{}` failed parsing for process `{process_name}`", + columns[7], + ) + })?; + + let connection_str = columns[8]; + static ESTABLISHED_REGEX: Lazy = + Lazy::new(|| Regex::new(r"\[?([^\s\]]*)\]?:(\d+)->\[?([^\s\]]*)\]?:(\d+)").unwrap()); + static LISTENING_REGEX: Lazy = + Lazy::new(|| Regex::new(r"\[?([^\s\[\]]*)\]?:(.*)").unwrap()); + let (local, remote) = if let Some(caps) = ESTABLISHED_REGEX.captures(connection_str) { + macro_rules! parse { + ($n: expr, $name: expr) => {{ + let s = caps.get($n).unwrap().as_str(); + s.parse().wrap_err_with(|| { + format!( + "{} `{s}` failed parsing for process `{process_name}`", + $name + ) + }) + }}; + } + let local_ip = parse!(1, "Local IP")?; + let local_port = parse!(2, "Local port")?; + let remote_ip = parse!(3, "Remote IP")?; + let remote_port = parse!(4, "Remote port")?; + ((local_ip, local_port), Some((remote_ip, remote_port))) + } else if let Some(caps) = LISTENING_REGEX.captures(connection_str) { + let local_ip = match caps.get(1).unwrap().as_str() { + "*" => ip_ver.get_null_addr(), + ip => ip.parse().wrap_err_with(|| { + format!("Local IP `{ip}` failed parsing for process `{process_name}`") + })?, + }; + let local_port = match caps.get(2).unwrap().as_str() { + "*" => 0, + port => port.parse().wrap_err_with(|| { + format!("Local port `{port}` failed parsing for process `{process_name}`") + })?, + }; + ((local_ip, local_port), None) + } else { + bail!( + r#"lsof output line matches matches neither established nor listening format: "{raw_line}""# + ); + }; + + // "(LISTEN)" or "(ESTABLISHED)", this column may or may not be present + let _connection_state = columns[9]; + + Ok(Self { + local, + remote, + protocol, + proc_info, + }) + } +} + +impl Connection { + fn as_local_socket(&self) -> LocalSocket { + let &Self { + local: (ip, port), + protocol, + .. + } = self; + LocalSocket { ip, port, protocol } + } +} + +#[cfg(test)] +mod tests { + use rstest::rstest; + + use super::*; + + const IPV6_LINE_RAW_OUTPUT: &str = "ProcessName 29266 user 9u IPv6 0x5d53dfe5445cee01 0t0 UDP [fe80:4::aede:48ff:fe00:1122]:1111->[fe80:4::aede:48ff:fe33:4455]:2222"; + const IPV4_LINE_RAW_OUTPUT: &str = "ProcessName 29266 user 39u IPv4 0x28ffb9c0021196bf 0t0 UDP 192.168.0.1:1111->198.252.206.25:2222"; + const FULL_RAW_OUTPUT: &str = r#" +com.apple 590 etoledom 193u IPv4 0x28ffb9c041115627 0t0 TCP 192.168.1.37:60298->31.13.83.36:443 (ESTABLISHED) +com.apple 590 etoledom 198u IPv4 0x28ffb9c04110ea8f 0t0 TCP 192.168.1.37:60299->31.13.83.8:443 (ESTABLISHED) +com.apple 590 etoledom 203u IPv4 0x28ffb9c04110ea8f 0t0 TCP 192.168.1.37:60299->31.13.83.8:443 (ESTABLISHED) +com.apple 590 etoledom 204u IPv4 0x28ffb9c04111253f 0t0 TCP 192.168.1.37:60374->140.82.114.26:443 +"#; + + #[test] + fn test_multiline_parse() { + for res in FULL_RAW_OUTPUT.lines().map(Connection::from_str) { + let _conn = res.unwrap(); + } + } + + #[rstest] + #[case(IPV4_LINE_RAW_OUTPUT, "ProcessName", Protocol::Udp, 1111)] + #[case(IPV6_LINE_RAW_OUTPUT, "ProcessName", Protocol::Udp, 1111)] + fn test_parse( + #[case] raw: &str, + #[case] process_name: &str, + #[case] protocol: Protocol, + #[case] port: u16, + ) { + let conn = Connection::from_str(raw).unwrap(); + assert_eq!(conn.proc_info.name, process_name); + assert_eq!(conn.protocol, protocol); + assert_eq!(conn.local.1, port); + } +} diff --git a/src/os/lsof_utils.rs b/src/os/lsof_utils.rs deleted file mode 100644 index 4a61c6756..000000000 --- a/src/os/lsof_utils.rs +++ /dev/null @@ -1,257 +0,0 @@ -use std::{ffi::OsStr, net::IpAddr, process::Command}; - -use log::warn; -use once_cell::sync::Lazy; -use regex::Regex; - -use crate::{ - network::{LocalSocket, Protocol}, - os::ProcessInfo, -}; - -#[allow(dead_code)] -#[derive(Debug, Clone)] -pub struct RawConnection { - remote_ip: String, - local_ip: String, - local_port: String, - remote_port: String, - protocol: String, - pub proc_info: ProcessInfo, -} - -fn get_null_addr(ip_type: &str) -> &str { - if ip_type.contains('4') { - "0.0.0.0" - } else { - "::0" - } -} - -impl RawConnection { - pub fn new(raw_line: &str) -> Option { - // Example row - // com.apple 664 user 198u IPv4 0xeb179a6650592b8d 0t0 TCP 192.168.1.187:58535->1.2.3.4:443 (ESTABLISHED) - let columns: Vec<&str> = raw_line.split_ascii_whitespace().collect(); - if columns.len() < 9 { - return None; - } - let process_name = columns[0].replace("\\x20", " "); - let pid = columns[1].parse().ok()?; - let proc_info = ProcessInfo::new(&process_name, pid); - // Unneeded - // let username = columns[2]; - // let fd = columns[3]; - - // IPv4 or IPv6 - let ip_type = columns[4]; - // let device = columns[5]; - // let size = columns[6]; - // UDP/TCP - let protocol = columns[7].to_ascii_uppercase(); - if protocol != "TCP" && protocol != "UDP" { - return None; - } - let connection_str = columns[8]; - // "(LISTEN)" or "(ESTABLISHED)", this column may or may not be present - // let connection_state = columns[9]; - - static CONNECTION_REGEX: Lazy = - Lazy::new(|| Regex::new(r"\[?([^\s\]]*)\]?:(\d+)->\[?([^\s\]]*)\]?:(\d+)").unwrap()); - static LISTEN_REGEX: Lazy = - Lazy::new(|| Regex::new(r"\[?([^\s\[\]]*)\]?:(.*)").unwrap()); - // If this socket is in a "connected" state - if let Some(caps) = CONNECTION_REGEX.captures(connection_str) { - // Example - // 192.168.1.187:64230->0.1.2.3:5228 - // *:* - // *:4567 - let local_ip = String::from(caps.get(1).unwrap().as_str()); - let local_port = String::from(caps.get(2).unwrap().as_str()); - let remote_ip = String::from(caps.get(3).unwrap().as_str()); - let remote_port = String::from(caps.get(4).unwrap().as_str()); - let connection = RawConnection { - local_ip, - local_port, - remote_ip, - remote_port, - protocol, - proc_info, - }; - Some(connection) - } else if let Some(caps) = LISTEN_REGEX.captures(connection_str) { - let local_ip = if caps.get(1).unwrap().as_str() == "*" { - get_null_addr(ip_type) - } else { - caps.get(1).unwrap().as_str() - }; - let local_ip = String::from(local_ip); - let local_port = String::from(if caps.get(2).unwrap().as_str() == "*" { - "0" - } else { - caps.get(2).unwrap().as_str() - }); - let remote_ip = String::from(get_null_addr(ip_type)); - let remote_port = String::from("0"); - let connection = RawConnection { - local_ip, - local_port, - remote_ip, - remote_port, - protocol, - proc_info, - }; - Some(connection) - } else { - None - } - } - - pub fn get_protocol(&self) -> Option { - Protocol::from_str(&self.protocol) - } - - pub fn get_local_ip(&self) -> Option { - self.local_ip.parse().ok() - } - - pub fn get_local_port(&self) -> Option { - self.local_port.parse::().ok() - } - - pub fn as_local_socket(&self) -> Option { - let process = &self.proc_info.name; - - let Some(ip) = self.get_local_ip() else { - warn!(r#"Failed to get the local IP of a connection belonging to "{process}"."#); - return None; - }; - let Some(port) = self.get_local_port() else { - warn!(r#"Failed to get the local port of a connection belonging to "{process}"."#); - return None; - }; - let Some(protocol) = self.get_protocol() else { - warn!(r#"Failed to get the protocol of a connection belonging to "{process}"."#); - return None; - }; - - Some(LocalSocket { ip, port, protocol }) - } -} - -pub fn get_connections() -> RawConnections { - let content = run(["-n", "-P", "-i4", "-i6", "+c", "0"]); - RawConnections::new(content) -} - -fn run(args: I) -> String -where - I: IntoIterator, - S: AsRef, -{ - let output = Command::new("lsof") - .args(args) - .output() - .expect("failed to execute process"); - - String::from_utf8_lossy(&output.stdout).into_owned() -} - -pub struct RawConnections { - content: Vec, -} - -impl RawConnections { - pub fn new(content: String) -> RawConnections { - let lines: Vec = content.lines().flat_map(RawConnection::new).collect(); - - RawConnections { content: lines } - } -} - -impl Iterator for RawConnections { - type Item = RawConnection; - - fn next(&mut self) -> Option { - self.content.pop() - } -} - -#[cfg(test)] -mod tests { - use super::*; - - const IPV6_LINE_RAW_OUTPUT: &str = "ProcessName 29266 user 9u IPv6 0x5d53dfe5445cee01 0t0 UDP [fe80:4::aede:48ff:fe00:1122]:1111->[fe80:4::aede:48ff:fe33:4455]:2222"; - const LINE_RAW_OUTPUT: &str = "ProcessName 29266 user 39u IPv4 0x28ffb9c0021196bf 0t0 UDP 192.168.0.1:1111->198.252.206.25:2222"; - const FULL_RAW_OUTPUT: &str = r#" -com.apple 590 etoledom 193u IPv4 0x28ffb9c041115627 0t0 TCP 192.168.1.37:60298->31.13.83.36:443 (ESTABLISHED) -com.apple 590 etoledom 198u IPv4 0x28ffb9c04110ea8f 0t0 TCP 192.168.1.37:60299->31.13.83.8:443 (ESTABLISHED) -com.apple 590 etoledom 203u IPv4 0x28ffb9c04110ea8f 0t0 TCP 192.168.1.37:60299->31.13.83.8:443 (ESTABLISHED) -com.apple 590 etoledom 204u IPv4 0x28ffb9c04111253f 0t0 TCP 192.168.1.37:60374->140.82.114.26:443 -"#; - - #[test] - fn test_iterator_multiline() { - let iterator = RawConnections::new(String::from(FULL_RAW_OUTPUT)); - let connections: Vec = iterator.collect(); - assert_eq!(connections.len(), 4); - } - - #[test] - fn test_raw_connection_is_created_from_raw_output_ipv4() { - test_raw_connection_is_created_from_raw_output(LINE_RAW_OUTPUT); - } - #[test] - fn test_raw_connection_is_created_from_raw_output_ipv6() { - test_raw_connection_is_created_from_raw_output(IPV6_LINE_RAW_OUTPUT); - } - fn test_raw_connection_is_created_from_raw_output(raw_output: &str) { - let connection = RawConnection::new(raw_output); - assert!(connection.is_some()); - } - - #[test] - fn test_raw_connection_is_not_created_from_wrong_raw_output() { - let connection = RawConnection::new("not a process"); - assert!(connection.is_none()); - } - - #[test] - fn test_raw_connection_parse_local_port_ipv4() { - test_raw_connection_parse_local_port(LINE_RAW_OUTPUT); - } - #[test] - fn test_raw_connection_parse_local_port_ipv6() { - test_raw_connection_parse_local_port(IPV6_LINE_RAW_OUTPUT); - } - fn test_raw_connection_parse_local_port(raw_output: &str) { - let connection = RawConnection::new(raw_output).unwrap(); - assert_eq!(connection.get_local_port(), Some(1111)); - } - - #[test] - fn test_raw_connection_parse_protocol_ipv4() { - test_raw_connection_parse_protocol(LINE_RAW_OUTPUT); - } - #[test] - fn test_raw_connection_parse_protocol_ipv6() { - test_raw_connection_parse_protocol(IPV6_LINE_RAW_OUTPUT); - } - fn test_raw_connection_parse_protocol(raw_line: &str) { - let connection = RawConnection::new(raw_line).unwrap(); - assert_eq!(connection.get_protocol(), Some(Protocol::Udp)); - } - - #[test] - fn test_raw_connection_parse_process_name_ipv4() { - test_raw_connection_parse_process_name(LINE_RAW_OUTPUT); - } - #[test] - fn test_raw_connection_parse_process_name_ipv6() { - test_raw_connection_parse_process_name(IPV6_LINE_RAW_OUTPUT); - } - fn test_raw_connection_parse_process_name(raw_line: &str) { - let connection = RawConnection::new(raw_line).unwrap(); - assert_eq!(connection.proc_info.name, String::from("ProcessName")); - } -} diff --git a/src/os/mod.rs b/src/os/mod.rs index 434c9d7c5..91cc63122 100644 --- a/src/os/mod.rs +++ b/src/os/mod.rs @@ -4,9 +4,6 @@ mod linux; #[cfg(any(target_os = "macos", target_os = "freebsd"))] mod lsof; -#[cfg(any(target_os = "macos", target_os = "freebsd"))] -mod lsof_utils; - #[cfg(target_os = "windows")] mod windows; From dd80ff52f204e5efa8d6d419dfe453265efe9b5f Mon Sep 17 00:00:00 2001 From: cyqsimon <28627918+cyqsimon@users.noreply.github.com> Date: Mon, 16 Dec 2024 13:46:45 +0800 Subject: [PATCH 2/3] Fix lsof parsing logic --- src/os/lsof.rs | 53 ++++++++++++++++++++++++++++---------------------- 1 file changed, 30 insertions(+), 23 deletions(-) diff --git a/src/os/lsof.rs b/src/os/lsof.rs index d8d670cb7..33a9ce4fc 100644 --- a/src/os/lsof.rs +++ b/src/os/lsof.rs @@ -5,7 +5,7 @@ use std::{ str::FromStr, }; -use eyre::{bail, Context}; +use eyre::{bail, Context, OptionExt}; use log::warn; use once_cell::sync::Lazy; use regex::Regex; @@ -79,37 +79,44 @@ impl FromStr for Connection { fn from_str(raw_line: &str) -> Result { // Example row // com.apple 664 user 198u IPv4 0xeb179a6650592b8d 0t0 TCP 192.168.1.187:58535->1.2.3.4:443 (ESTABLISHED) - let columns = raw_line.split_ascii_whitespace().collect::>(); - if columns.len() < 9 { - bail!(r#"lsof output line contains fewer than 9 columns: "{raw_line}""#); - } - - let process_name = columns[0].replace("\\x20", " "); - let pid = columns[1] - .parse() - .wrap_err_with(|| format!("PID `{}` failed parsing", columns[1]))?; + let mut fields = raw_line.split_ascii_whitespace(); + + let process_name = fields + .next() + .ok_or_eyre("Missing field: process name")? + .replace("\\x20", " "); + let pid = { + let pid_str = fields.next().ok_or_eyre("Missing field: PID")?; + pid_str + .parse() + .wrap_err_with(|| format!("PID `{pid_str}` failed parsing"))? + }; let proc_info = ProcessInfo::new(&process_name, pid); - let _username = columns[2]; - let _fd = columns[3]; + let _user = fields.next().ok_or_eyre("Missing field: user")?; + let _fd = fields.next().ok_or_eyre("Missing field: file descriptor")?; - let ip_ver = if columns[4].contains('4') { + let ip_ver = if fields + .next() + .ok_or_eyre("Missing field: IP version")? + .contains('4') + { IpVer::V4 } else { IpVer::V6 }; - let _device = columns[5]; - let _size = columns[6]; + let _device = fields.next().ok_or_eyre("Missing field: device")?; + let _size = fields.next().ok_or_eyre("Missing field: size")?; - let protocol = columns[7].parse().wrap_err_with(|| { - format!( - "Protocol `{}` failed parsing for process `{process_name}`", - columns[7], - ) - })?; + let protocol = { + let proto_str = fields.next().ok_or_eyre("Missing field: protocol")?; + proto_str.parse().wrap_err_with(|| { + format!("Protocol `{proto_str}` failed parsing for process `{process_name}`") + })? + }; - let connection_str = columns[8]; + let connection_str = fields.next().ok_or_eyre("Missing field: connection")?; static ESTABLISHED_REGEX: Lazy = Lazy::new(|| Regex::new(r"\[?([^\s\]]*)\]?:(\d+)->\[?([^\s\]]*)\]?:(\d+)").unwrap()); static LISTENING_REGEX: Lazy = @@ -152,7 +159,7 @@ impl FromStr for Connection { }; // "(LISTEN)" or "(ESTABLISHED)", this column may or may not be present - let _connection_state = columns[9]; + let _connection_state = fields.next(); // allow missing Ok(Self { local, From 4f2dfab04ba60831ec12f721f67ccc1cccf7c491 Mon Sep 17 00:00:00 2001 From: cyqsimon <28627918+cyqsimon@users.noreply.github.com> Date: Mon, 16 Dec 2024 13:52:31 +0800 Subject: [PATCH 3/3] Fix missing linebreaks in test input --- src/os/lsof.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/os/lsof.rs b/src/os/lsof.rs index 33a9ce4fc..60224d2a4 100644 --- a/src/os/lsof.rs +++ b/src/os/lsof.rs @@ -189,12 +189,12 @@ mod tests { const IPV6_LINE_RAW_OUTPUT: &str = "ProcessName 29266 user 9u IPv6 0x5d53dfe5445cee01 0t0 UDP [fe80:4::aede:48ff:fe00:1122]:1111->[fe80:4::aede:48ff:fe33:4455]:2222"; const IPV4_LINE_RAW_OUTPUT: &str = "ProcessName 29266 user 39u IPv4 0x28ffb9c0021196bf 0t0 UDP 192.168.0.1:1111->198.252.206.25:2222"; - const FULL_RAW_OUTPUT: &str = r#" + const FULL_RAW_OUTPUT: &str = "\ com.apple 590 etoledom 193u IPv4 0x28ffb9c041115627 0t0 TCP 192.168.1.37:60298->31.13.83.36:443 (ESTABLISHED) com.apple 590 etoledom 198u IPv4 0x28ffb9c04110ea8f 0t0 TCP 192.168.1.37:60299->31.13.83.8:443 (ESTABLISHED) com.apple 590 etoledom 203u IPv4 0x28ffb9c04110ea8f 0t0 TCP 192.168.1.37:60299->31.13.83.8:443 (ESTABLISHED) -com.apple 590 etoledom 204u IPv4 0x28ffb9c04111253f 0t0 TCP 192.168.1.37:60374->140.82.114.26:443 -"#; +com.apple 590 etoledom 204u IPv4 0x28ffb9c04111253f 0t0 TCP 192.168.1.37:60374->140.82.114.26:443\ +"; #[test] fn test_multiline_parse() {