| // Copyright 2025, The Android Open Source Project |
| // |
| // Licensed under the Apache License, Version 2.0 (the "License"); |
| // you may not use this file except in compliance with the License. |
| // You may obtain a copy of the License at |
| // |
| // http://www.apache.org/licenses/LICENSE-2.0 |
| // |
| // Unless required by applicable law or agreed to in writing, software |
| // distributed under the License is distributed on an "AS IS" BASIS, |
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| // See the License for the specific language governing permissions and |
| // limitations under the License. |
| |
| //! Libraries for Linux network |
| |
| use crate::proc::ProcHelper; |
| use anyhow::{anyhow, Context, Error}; |
| use std::collections::HashMap; |
| use std::fs::File; |
| use std::io::{self, BufRead}; |
| use std::net::{IpAddr, Ipv4Addr, SocketAddr}; |
| |
| // Parse kernel IP to IpAddr (e.g. "0100007F" -> Ipv4Addr([127, 0, 0, 1])) |
| fn parse_ip_addr(ip_from_kernel: &str) -> Result<IpAddr, Error> { |
| // Kernel keeps the IP address as big endian while Ipv{4,6}Addr takes native endian. |
| let ip_be = u32::from_str_radix(ip_from_kernel, 16) |
| .with_context(|| format!("Cannot parse {ip_from_kernel} to Ipv4Addr"))?; |
| let ip_ne = u32::from_be(ip_be); |
| Ok(IpAddr::V4(Ipv4Addr::from_bits(ip_ne))) |
| } |
| |
| // Parse IP string from kernel (e.g. "0100007F:1F40" -> 127.0.0.1:8000") |
| fn parse_socket_addr(socket_from_kernel: &str) -> Result<SocketAddr, Error> { |
| let addr: Vec<_> = socket_from_kernel.split(':').collect(); |
| let ip = parse_ip_addr(addr[0])?; |
| let port = u16::from_str_radix(addr[1], 16) |
| .with_context(|| format!("Cannot parse {socket_from_kernel} to SockAddr"))?; |
| Ok(SocketAddr::new(ip, port)) |
| } |
| |
| /// Get default gateway in IPV4 as string by reading /proc/net/route |
| pub fn get_default_gateway() -> Result<String, Error> { |
| const DEFAULT_ROUTE_DESTINATION: &str = "00000000"; |
| |
| // /proc/net/route isn't an ordinary file, so better not use tokio. |
| let file = File::open("/proc/net/route") |
| .context("Failed to open /proc/net/route. Ensure this is running with Linux kernel")?; |
| let mut lines = io::BufReader::new(file).lines(); |
| |
| let header_line = lines.next().ok_or(anyhow!("Cannot find header from /proc/net/route"))??; |
| let headers: Vec<_> = header_line.split_whitespace().collect(); |
| let dest_idx = headers |
| .iter() |
| .position(|col| *col == "Destination") |
| .ok_or(anyhow!("Cannot find 'Destination' from header"))?; |
| let gateway_idx = headers |
| .iter() |
| .position(|col| *col == "Gateway") |
| .ok_or(anyhow!("Cannot find 'Gateway' from header"))?; |
| |
| while let Some(Ok(line)) = lines.next() { |
| let items: Vec<_> = line.split_whitespace().collect(); |
| if items[dest_idx] == DEFAULT_ROUTE_DESTINATION { |
| return Ok(parse_ip_addr(items[gateway_idx])?.to_string()); |
| } |
| } |
| Err(anyhow!("Cannot find default gateway")) |
| } |
| |
| /// Get map of (port, comm) which lists listening IPv4 TCP port available from loopback |
| /// This reads /proc/net/tcp, so result may vary per permission. |
| pub fn get_listening_tcp4_ports_from_localhost() -> Result<HashMap<u16, String>, Error> { |
| let proc = ProcHelper::new()?; |
| |
| let file = File::open("/proc/net/tcp") |
| .context("Failed to open /proc/net/tcp. Ensure this is running with Linux kernel")?; |
| let mut lines = io::BufReader::new(file).lines(); |
| |
| // /proc/net/tcp{,6} format is stable and documented below. |
| // https://www.kernel.org/doc/Documentation/networking/proc_net_tcp.txt |
| const LOCAL_SOCK_ADDR_IDX: usize = 1; |
| const CONNECTION_STATE_IDX: usize = 3; |
| const INODE_IDX: usize = 9; |
| |
| // From include/netinet/tcp.h |
| const TCP_LISTEN: &str = "0A"; |
| |
| // Skip header validation. |
| let _ = lines.next().ok_or(anyhow!("Cannot find header from /proc/net/tcp"))?; |
| |
| let mut entries: HashMap<u16, String> = Default::default(); |
| while let Some(Ok(line)) = lines.next() { |
| let items: Vec<_> = line.split_whitespace().collect(); |
| if items[CONNECTION_STATE_IDX] != TCP_LISTEN { |
| continue; |
| } |
| let socket_addr = parse_socket_addr(items[LOCAL_SOCK_ADDR_IDX])?; |
| if !socket_addr.ip().is_loopback() && !socket_addr.ip().is_unspecified() { |
| continue; |
| } |
| let Some(comm) = proc.comm_with_inode(items[INODE_IDX].parse()?) else { |
| continue; |
| }; |
| entries.insert(socket_addr.port(), comm.clone()); |
| } |
| Ok(entries) |
| } |
| |
| /// This test runs on the host Linux. |
| #[cfg(test)] |
| mod tests { |
| use super::*; |
| use anyhow::ensure; |
| use std::process::Command; |
| |
| fn is_tool_available(tool_name: &str) -> bool { |
| Command::new("which").arg(tool_name).output().is_ok_and(|which| which.status.success()) |
| } |
| |
| #[test] |
| fn test_get_default_gateway() -> Result<(), Error> { |
| let gateway = get_default_gateway()?; |
| |
| let ip_route = if is_tool_available("route") { |
| Command::new("bash") |
| .arg("-c") |
| .arg("route | grep -e '^default' | awk '{print $2}'") |
| .output()? |
| } else if is_tool_available("ip") { |
| Command::new("bash") |
| .arg("-c") |
| .arg("ip route | grep -e '^default' | awk '{print $3}'") |
| .output()? |
| } else { |
| println!("Skipping test. Requires either route or ip"); |
| return Ok(()); |
| }; |
| let ip_route_gateway = str::from_utf8(&ip_route.stdout) |
| .with_context(|| format!("Failed to read default route. {ip_route:?}"))? |
| .trim(); |
| ensure!(!ip_route_gateway.is_empty(), "Failed to find default route. {ip_route:?}"); |
| |
| assert_eq!(gateway, ip_route_gateway); |
| Ok(()) |
| } |
| |
| #[test] |
| fn test_get_listening_tcp_ports() -> Result<(), Error> { |
| let mut tcp = get_listening_tcp4_ports_from_localhost()?; |
| |
| let port_comm = if is_tool_available("netstat") { |
| Command::new("bash") |
| .arg("-c") |
| .arg(r#"ss -4lntp | awk '{print $4,$6}' | sed -n 's/^\(127.0.0.[0-7]\|0.0.0.0\):\(.*\)\s*users:(("\(.*\)".*$/\2 \3/p'"#) |
| .output() |
| .context("Failed to run ss")? |
| } else if is_tool_available("ss") { |
| // Returned comm is truncated to 11 chars, while original comm is capped to 15 chars. |
| Command::new("bash") |
| .arg("-c") |
| .arg(r#"netstat -4lntp | awk '{print $4,$7}' | sed -n 's/^\(127.0.0.[0-7]\|0.0.0.0\):\([0-9]*\)\s*[0-9]*\/\(.*\)$/\2 \3/p'"#) |
| .output() |
| .context("Failed to run netstat")? |
| } else { |
| println!("Skipping test. Requires either ss or netstat"); |
| return Ok(()); |
| }; |
| let stdout = str::from_utf8(&port_comm.stdout).context("Failed to read listening ports")?; |
| for line in stdout.lines() { |
| if line.is_empty() { |
| continue; |
| } |
| let items: Vec<_> = line.split_whitespace().collect(); |
| let (port, comm) = (items[0].parse::<u16>()?, items[1]); |
| |
| let existing = |
| tcp.remove(&port).ok_or(anyhow!("Failed to find port {port} from {tcp:?}"))?; |
| assert!( |
| existing.starts_with(comm), |
| "Unexpected comm name. Expected {comm} with {port}, but was {existing}." |
| ) |
| } |
| |
| assert!(tcp.is_empty(), "Unexpected left-over ports. {tcp:?}"); |
| Ok(()) |
| } |
| } |