diff --git a/Cargo.lock b/Cargo.lock index 41b22b6..e35fe7a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -778,6 +778,12 @@ dependencies = [ "regex-syntax", ] +[[package]] +name = "regex-lite" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d942b98df5e658f56f20d592c7f868833fe38115e65c33003d8cd224b0155da" + [[package]] name = "regex-syntax" version = "0.8.8" @@ -905,6 +911,16 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + [[package]] name = "slab" version = "0.4.11" @@ -923,16 +939,6 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2a575449a5c487091e541c0cb4ccd83620167fd52363f816fe28f6f357fc00" -[[package]] -name = "socket2" -version = "0.5.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" -dependencies = [ - "libc", - "windows-sys 0.52.0", -] - [[package]] name = "socket2" version = "0.6.1" @@ -1036,7 +1042,8 @@ dependencies = [ "libc", "mio", "pin-project-lite", - "socket2 0.6.1", + "signal-hook-registry", + "socket2", "tokio-macros", "windows-sys 0.61.2", ] @@ -1088,10 +1095,10 @@ dependencies = [ "prost", "prost-build", "prost-types", + "regex-lite", "serde", "serde_json", "snmp", - "socket2 0.5.10", "tokio", "tokio-tungstenite", ] diff --git a/Cargo.toml b/Cargo.toml index e086704..fce9542 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,7 @@ edition = "2021" [dependencies] snmp = "0.2" -tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time", "net"] } +tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time", "net", "process"] } tokio-tungstenite = { version = "0.21", features = ["rustls-tls-webpki-roots"] } futures = "0.3" serde = { version = "1.0", features = ["derive"] } @@ -13,7 +13,7 @@ serde_json = "1.0" clap = { version = "4.0", features = ["derive", "env"] } prost = "0.13" prost-types = "0.13" -socket2 = "0.5" +regex-lite = "0.1" [build-dependencies] prost-build = "0.13" diff --git a/Dockerfile b/Dockerfile index aa7e777..868cf26 100644 --- a/Dockerfile +++ b/Dockerfile @@ -47,7 +47,8 @@ FROM alpine:3.19 # Install runtime dependencies # docker-cli is needed for self-update functionality -RUN apk add --no-cache ca-certificates su-exec docker-cli +# iputils provides ping with setuid root (doesn't require CAP_NET_RAW) +RUN apk add --no-cache ca-certificates su-exec docker-cli iputils # Create data directory RUN mkdir -p /data diff --git a/src/ping.rs b/src/ping.rs index 4363df3..4f71206 100644 --- a/src/ping.rs +++ b/src/ping.rs @@ -1,18 +1,16 @@ -use std::net::{IpAddr, Ipv4Addr}; +use std::net::IpAddr; +use std::process::Stdio; use std::time::Duration; -use tokio::net::UdpSocket; +use tokio::process::Command; use tokio::time::timeout; type Result = std::result::Result>; -/// ICMP message types -const ICMP_ECHO: u8 = 8; -const ICMP_ECHOREPLY: u8 = 0; - -/// Pings an IP address using raw ICMP and returns the round-trip time. +/// Pings an IP address using the system ping command and returns the round-trip time. /// -/// This implementation uses raw ICMP sockets instead of relying on the system -/// `ping` command, making it suitable for containerized environments. +/// This implementation uses the system `ping` binary which is typically setuid root, +/// allowing ICMP operations without requiring CAP_NET_RAW or elevated privileges +/// for the application itself. /// /// # Arguments /// @@ -23,206 +21,107 @@ const ICMP_ECHOREPLY: u8 = 0; /// /// * `Ok(Duration)` - The round-trip time if successful /// * `Err(anyhow::Error)` - If the ping fails or times out -/// -/// # Note -/// -/// Raw ICMP sockets require elevated privileges (CAP_NET_RAW on Linux). -/// The application should be run with appropriate capabilities. pub async fn ping(ip: IpAddr, timeout_duration: Duration) -> Result { - let start = std::time::Instant::now(); + let ip_str = ip.to_string(); + let timeout_secs = timeout_duration.as_secs().max(1); - match ip { - IpAddr::V4(ipv4) => ping_ipv4(ipv4, timeout_duration).await?, - IpAddr::V6(_) => { - return Err("IPv6 ping not yet implemented".into()); + // Build ping command arguments based on OS + let args = if cfg!(target_os = "macos") { + // macOS: -W is timeout in ms + vec![ + "-c".to_string(), + "1".to_string(), + "-W".to_string(), + (timeout_secs * 1000).to_string(), + ip_str.clone(), + ] + } else { + // Linux: -W is timeout in seconds + vec![ + "-c".to_string(), + "1".to_string(), + "-W".to_string(), + timeout_secs.to_string(), + ip_str.clone(), + ] + }; + + // Execute ping command with timeout + let result = timeout(timeout_duration + Duration::from_secs(1), async { + let output = Command::new("ping") + .args(&args) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .await?; + + if output.status.success() { + let stdout = String::from_utf8_lossy(&output.stdout); + parse_ping_output(&stdout) + } else { + let stderr = String::from_utf8_lossy(&output.stderr); + let stdout = String::from_utf8_lossy(&output.stdout); + Err(format!( + "Ping failed for {}: {}{}", + ip_str, + stdout.trim(), + stderr.trim() + ) + .into()) } + }) + .await; + + match result { + Ok(inner_result) => inner_result, + Err(_) => Err(format!("Ping timeout for {}", ip_str).into()), } - - Ok(start.elapsed()) } -async fn ping_ipv4(ip: Ipv4Addr, timeout_duration: Duration) -> Result<()> { - // Generate unique identifier and sequence number using timestamp - // This provides sufficient randomness for ICMP ping identification - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap(); - let identifier = (now.as_nanos() & 0xFFFF) as u16; - let sequence = ((now.as_nanos() >> 16) & 0xFFFF) as u16; - - // Build ICMP echo request packet - let packet = build_icmp_echo_request(identifier, sequence); - - // Create raw socket for ICMP - // Note: This requires CAP_NET_RAW capability on Linux - let socket = socket2::Socket::new_raw( - socket2::Domain::IPV4, - socket2::Type::DGRAM, - Some(socket2::Protocol::ICMPV4), - )?; - - socket.set_nonblocking(true)?; - - // Convert to tokio UdpSocket - let std_socket: std::net::UdpSocket = socket.into(); - std_socket.set_nonblocking(true)?; - let socket = UdpSocket::from_std(std_socket)?; - - // Connect to the target IP (this is for sendto/recvfrom convenience) - let addr = std::net::SocketAddr::new(IpAddr::V4(ip), 0); - socket.connect(addr).await?; - - // Send ICMP echo request - socket.send(&packet).await?; - - // Wait for ICMP echo reply with timeout - let mut buf = [0u8; 1024]; - let n = timeout(timeout_duration, socket.recv(&mut buf)) - .await - .map_err(|_| -> Box { "Ping timeout".into() })? - .map_err(|e| -> Box { - format!("Failed to receive ping reply: {}", e).into() - })?; - - // Parse and validate the reply - parse_icmp_reply(&buf[..n], identifier, sequence)?; - - Ok(()) -} - -fn build_icmp_echo_request(identifier: u16, sequence: u16) -> Vec { - // ICMP Echo Request format: - // Type (8) | Code (0) | Checksum (16) | Identifier (16) | Sequence (16) | Data (variable) - let type_code = ICMP_ECHO; - let code = 0u8; - let checksum = 0u16; // Placeholder - let data = b"towerops_ping"; // Payload - - // Build packet without checksum - let mut packet = Vec::new(); - packet.push(type_code); - packet.push(code); - packet.extend_from_slice(&checksum.to_be_bytes()); - packet.extend_from_slice(&identifier.to_be_bytes()); - packet.extend_from_slice(&sequence.to_be_bytes()); - packet.extend_from_slice(data); - - // Calculate and insert checksum - let calculated_checksum = icmp_checksum(&packet); - packet[2..4].copy_from_slice(&calculated_checksum.to_be_bytes()); - - packet -} - -fn parse_icmp_reply(packet: &[u8], expected_identifier: u16, expected_sequence: u16) -> Result<()> { - // ICMP reply might be wrapped in an IP header - // Try both raw ICMP and IP-wrapped formats - - // Try to parse as raw ICMP first - if let Ok(()) = try_parse_icmp(packet, expected_identifier, expected_sequence) { - return Ok(()); - } - - // Check if this looks like an IP packet (version 4 in high nibble of first byte) - if packet.len() >= 20 && (packet[0] >> 4) == 4 { - // Extract IP header length from IHL field (low nibble of first byte) - // IHL is in 32-bit words, so multiply by 4 to get bytes - let ihl = (packet[0] & 0x0F) as usize * 4; - - if ihl >= 20 && packet.len() > ihl { - // Try to parse ICMP after skipping the IP header - if let Ok(()) = try_parse_icmp(&packet[ihl..], expected_identifier, expected_sequence) { - return Ok(()); +/// Parse the ping output to extract round-trip time as Duration. +/// +/// Supports multiple output formats: +/// - macOS: "round-trip min/avg/max/stddev = 1.234/1.234/1.234/0.000 ms" +/// - Linux: "rtt min/avg/max/mdev = 1.234/1.234/1.234/0.000 ms" +/// - Both: "time=X.XX ms" in the reply line +fn parse_ping_output(output: &str) -> Result { + // Try macOS format: "round-trip min/avg/max/stddev = X/Y/Z/W ms" + if let Some(caps) = regex_lite::Regex::new(r"round-trip.*=\s*[\d.]+/([\d.]+)/") + .ok() + .and_then(|re| re.captures(output)) + { + if let Some(avg_ms) = caps.get(1) { + if let Ok(ms) = avg_ms.as_str().parse::() { + return Ok(Duration::from_secs_f64(ms / 1000.0)); } } } - // Log diagnostic information to help debug - // Determine the ICMP portion (might need to skip IP header) - let icmp_packet = if packet.len() >= 20 && (packet[0] >> 4) == 4 { - let ihl = (packet[0] & 0x0F) as usize * 4; - if ihl >= 20 && packet.len() > ihl { - &packet[ihl..] - } else { - packet + // Try Linux format: "rtt min/avg/max/mdev = X/Y/Z/W ms" + if let Some(caps) = regex_lite::Regex::new(r"rtt.*=\s*[\d.]+/([\d.]+)/") + .ok() + .and_then(|re| re.captures(output)) + { + if let Some(avg_ms) = caps.get(1) { + if let Ok(ms) = avg_ms.as_str().parse::() { + return Ok(Duration::from_secs_f64(ms / 1000.0)); + } } - } else { - packet - }; - - let packet_preview = if icmp_packet.len() >= 8 { - format!( - "type={} code={} id={} seq={} len={} (total={})", - icmp_packet[0], - icmp_packet.get(1).unwrap_or(&0), - u16::from_be_bytes([ - *icmp_packet.get(4).unwrap_or(&0), - *icmp_packet.get(5).unwrap_or(&0) - ]), - u16::from_be_bytes([ - *icmp_packet.get(6).unwrap_or(&0), - *icmp_packet.get(7).unwrap_or(&0) - ]), - icmp_packet.len(), - packet.len() - ) - } else { - format!("len={} (too short)", packet.len()) - }; - - Err(format!( - "Invalid ICMP reply packet (expected seq={}): {}", - expected_sequence, packet_preview - ) - .into()) -} - -fn try_parse_icmp(packet: &[u8], _expected_identifier: u16, expected_sequence: u16) -> Result<()> { - if packet.len() < 8 { - return Err("Packet too short".into()); } - let icmp_type = packet[0]; - let icmp_code = packet[1]; - let identifier = u16::from_be_bytes([packet[4], packet[5]]); - let sequence = u16::from_be_bytes([packet[6], packet[7]]); - - // Note: When using SOCK_DGRAM for ICMP on Linux, the kernel manages the identifier field - // and may overwrite what we set. We only validate type, code, and sequence number. - // The sequence number is random and unique enough given our polling intervals (300s+). - if icmp_type == ICMP_ECHOREPLY && icmp_code == 0 && sequence == expected_sequence { - Ok(()) - } else { - Err(format!( - "ICMP packet mismatch (type={}, code={}, id={}, seq={})", - icmp_type, icmp_code, identifier, sequence - ) - .into()) - } -} - -fn icmp_checksum(data: &[u8]) -> u16 { - // ICMP checksum is the 16-bit one's complement of the one's complement sum - let mut sum = 0u32; - - // Sum all 16-bit words - for chunk in data.chunks(2) { - let word = if chunk.len() == 2 { - u16::from_be_bytes([chunk[0], chunk[1]]) as u32 - } else { - // Odd byte - pad with zero - (chunk[0] as u32) << 8 - }; - sum += word; + // Try extracting from "time=X.XX ms" in the reply line + if let Some(caps) = regex_lite::Regex::new(r"time[=<]([\d.]+)\s*ms") + .ok() + .and_then(|re| re.captures(output)) + { + if let Some(time_ms) = caps.get(1) { + if let Ok(ms) = time_ms.as_str().parse::() { + return Ok(Duration::from_secs_f64(ms / 1000.0)); + } + } } - // Fold 32-bit sum to 16 bits - while sum >> 16 != 0 { - sum = (sum & 0xFFFF) + (sum >> 16); - } - - // One's complement - !sum as u16 + Err(format!("Could not parse ping output: {}", output.trim()).into()) } #[cfg(test)] @@ -230,143 +129,99 @@ mod tests { use super::*; #[test] - fn test_icmp_checksum() { - // Test that checksum calculation is consistent - // ICMP echo request packet: type=8, code=0, id=1, seq=1, data="abcd" - let mut packet = vec![ - 0x08, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x01, 0x61, 0x62, 0x63, 0x64, - ]; + fn test_parse_ping_output_macos() { + let output = r#"PING 192.168.1.1 (192.168.1.1): 56 data bytes +64 bytes from 192.168.1.1: icmp_seq=0 ttl=64 time=1.234 ms - // Calculate checksum - let checksum = icmp_checksum(&packet); - assert_ne!(checksum, 0, "Checksum should not be zero"); +--- 192.168.1.1 ping statistics --- +1 packets transmitted, 1 packets received, 0.0% packet loss +round-trip min/avg/max/stddev = 1.234/1.234/1.234/0.000 ms"#; - // Insert checksum into packet - packet[2..4].copy_from_slice(&checksum.to_be_bytes()); - - // Verify: recalculating checksum with checksum field included should give 0 - // (because the sum of all words including the checksum should wrap to 0xFFFF, - // and one's complement of 0xFFFF is 0) - let verification = icmp_checksum(&packet); - assert_eq!(verification, 0, "Checksum verification should be 0"); - } - - #[test] - fn test_build_icmp_echo_request() { - let identifier = 0x0001; - let sequence = 0x0001; - let packet = build_icmp_echo_request(identifier, sequence); - - // Verify packet structure - assert_eq!(packet[0], ICMP_ECHO); // Type - assert_eq!(packet[1], 0); // Code - - // Verify identifier and sequence - let id = u16::from_be_bytes([packet[4], packet[5]]); - let seq = u16::from_be_bytes([packet[6], packet[7]]); - assert_eq!(id, identifier); - assert_eq!(seq, sequence); - - // Verify checksum is non-zero - let checksum = u16::from_be_bytes([packet[2], packet[3]]); - assert_ne!(checksum, 0); - } - - #[test] - fn test_try_parse_icmp_success() { - // Valid ICMP echo reply packet - let packet = vec![ - 0x00, 0x00, 0x00, 0x00, // type=0 (reply), code=0, checksum - 0x12, 0x34, // identifier - 0x56, 0x78, // sequence - 0x61, 0x62, 0x63, 0x64, // data - ]; - - let result = try_parse_icmp(&packet, 0x1234, 0x5678); + let result = parse_ping_output(output); assert!(result.is_ok()); + let duration = result.unwrap(); + // Should be approximately 1.234ms + assert!(duration.as_secs_f64() > 0.001); + assert!(duration.as_secs_f64() < 0.002); } #[test] - fn test_try_parse_icmp_too_short() { - let packet = vec![0x00, 0x00, 0x00]; - let result = try_parse_icmp(&packet, 0x1234, 0x5678); - assert!(result.is_err()); - assert!(result.unwrap_err().to_string().contains("too short")); - } + fn test_parse_ping_output_linux() { + let output = r#"PING 192.168.1.1 (192.168.1.1) 56(84) bytes of data. +64 bytes from 192.168.1.1: icmp_seq=1 ttl=64 time=0.543 ms - #[test] - fn test_try_parse_icmp_wrong_type() { - let packet = vec![ - 0x08, 0x00, 0x00, 0x00, // type=8 (request, not reply), code=0 - 0x12, 0x34, // identifier - 0x56, 0x78, // sequence - ]; +--- 192.168.1.1 ping statistics --- +1 packets transmitted, 1 received, 0% packet loss, time 0ms +rtt min/avg/max/mdev = 0.543/0.543/0.543/0.000 ms"#; - let result = try_parse_icmp(&packet, 0x1234, 0x5678); - assert!(result.is_err()); - assert!(result.unwrap_err().to_string().contains("mismatch")); - } - - #[test] - fn test_try_parse_icmp_wrong_sequence() { - let packet = vec![ - 0x00, 0x00, 0x00, 0x00, // type=0, code=0 - 0x12, 0x34, // identifier (not validated) - 0x99, 0x99, // wrong sequence - ]; - - let result = try_parse_icmp(&packet, 0x1234, 0x5678); - assert!(result.is_err()); - } - - #[test] - fn test_parse_icmp_reply_raw() { - // Raw ICMP packet (no IP header) - let packet = vec![ - 0x00, 0x00, 0x00, 0x00, // type=0, code=0, checksum - 0x12, 0x34, // identifier - 0x56, 0x78, // sequence - ]; - - let result = parse_icmp_reply(&packet, 0x1234, 0x5678); + let result = parse_ping_output(output); assert!(result.is_ok()); + let duration = result.unwrap(); + // Should be approximately 0.543ms + assert!(duration.as_secs_f64() > 0.0005); + assert!(duration.as_secs_f64() < 0.001); } #[test] - fn test_parse_icmp_reply_with_ip_header() { - // IPv4 packet with ICMP payload - let packet = vec![ - 0x45, 0x00, 0x00, 0x54, // IPv4 header: version=4, IHL=5 (20 bytes) - 0x00, 0x00, 0x00, 0x00, 0x40, 0x01, 0x00, 0x00, // TTL, protocol=ICMP - 0xc0, 0xa8, 0x01, 0x01, // Source IP - 0xc0, 0xa8, 0x01, 0x02, // Dest IP - // ICMP payload starts here (at byte 20) - 0x00, 0x00, 0x00, 0x00, // type=0, code=0, checksum - 0x12, 0x34, // identifier - 0x56, 0x78, // sequence - ]; + fn test_parse_ping_output_time_only() { + // Some systems may not include the summary line + let output = "64 bytes from 192.168.1.1: icmp_seq=0 ttl=64 time=2.567 ms"; - let result = parse_icmp_reply(&packet, 0x1234, 0x5678); + let result = parse_ping_output(output); assert!(result.is_ok()); + let duration = result.unwrap(); + // Should be approximately 2.567ms + assert!(duration.as_secs_f64() > 0.002); + assert!(duration.as_secs_f64() < 0.003); } #[test] - fn test_parse_icmp_reply_invalid() { - let packet = vec![0x00, 0x00]; - let result = parse_icmp_reply(&packet, 0x1234, 0x5678); - assert!(result.is_err()); - assert!(result - .unwrap_err() - .to_string() - .contains("Invalid ICMP reply")); + fn test_parse_ping_output_time_less_than() { + // Some ping implementations use time<1 ms for very fast responses + let output = "64 bytes from 127.0.0.1: icmp_seq=0 ttl=64 time<1 ms"; + + let result = parse_ping_output(output); + assert!(result.is_ok()); + let duration = result.unwrap(); + // Should be approximately 1ms (the < becomes the value) + assert!(duration.as_secs_f64() < 0.002); } #[test] - fn test_parse_icmp_reply_short_packet_error_message() { - let packet = vec![0x01, 0x02, 0x03]; - let result = parse_icmp_reply(&packet, 0x1234, 0x5678); + fn test_parse_ping_output_invalid() { + let output = "some random text without ping data"; + let result = parse_ping_output(output); + assert!(result.is_err()); + } + + #[test] + fn test_parse_ping_output_empty() { + let result = parse_ping_output(""); + assert!(result.is_err()); + } + + #[tokio::test] + async fn test_ping_localhost() { + // This test actually pings localhost - skip if no network + let ip: IpAddr = "127.0.0.1".parse().unwrap(); + let result = ping(ip, Duration::from_secs(5)).await; + + // Localhost ping should succeed on most systems + if result.is_ok() { + let duration = result.unwrap(); + // Localhost should respond in < 100ms + assert!(duration.as_millis() < 100); + } + // If it fails, that's okay too - some systems don't allow ping to localhost + } + + #[tokio::test] + async fn test_ping_invalid_ip() { + // This should fail - non-routable IP + let ip: IpAddr = "192.0.2.1".parse().unwrap(); // TEST-NET-1, not routable + let result = ping(ip, Duration::from_secs(2)).await; + + // Should either timeout or fail assert!(result.is_err()); - let err_msg = result.unwrap_err().to_string(); - assert!(err_msg.contains("too short")); } }