From 5429c9ac5ed2176c89f48b7416317ad9b9c587d1 Mon Sep 17 00:00:00 2001 From: Tom Forbes Date: Sun, 15 Dec 2024 16:52:00 +0000 Subject: [PATCH] Rewrite the pinger library --- .github/workflows/docker.yml | 3 +- .github/workflows/test.yml | 20 +--- Cargo.lock | 113 +++++++++++++++++--- Cross.toml | 41 ++++--- Dockerfile | 2 +- gping/build.rs | 4 +- gping/src/main.rs | 5 +- pinger/Cargo.toml | 14 ++- pinger/README.md | 6 +- pinger/examples/simple-ping.rs | 13 ++- pinger/src/bsd.rs | 53 +++++----- pinger/src/fake.rs | 38 +++---- pinger/src/lib.rs | 188 +++++++++++++++++---------------- pinger/src/linux.rs | 170 +++++++++++++++-------------- pinger/src/macos.rs | 55 ++++------ pinger/src/target.rs | 67 ++++++++++++ pinger/src/test.rs | 126 ++++++++++++++++++---- pinger/src/windows.rs | 71 +++++++------ 18 files changed, 622 insertions(+), 367 deletions(-) create mode 100644 pinger/src/target.rs diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 299049440..0e5abde6c 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -41,6 +41,7 @@ jobs: uses: docker/setup-buildx-action@v3 - name: Log in to the Container registry + if: github.event_name == 'tag' || github.ref_name == 'master' uses: docker/login-action@v3 with: registry: ${{ env.REGISTRY }} @@ -57,7 +58,7 @@ jobs: uses: docker/build-push-action@v6 with: context: . - push: true + push: ${{ github.event_name == 'tag' || github.ref_name == 'master' }} tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} platforms: | diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 933f65321..ec87db3e3 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -14,6 +14,7 @@ jobs: runs-on: ${{ matrix.os }} container: ${{ matrix.container }} strategy: + fail-fast: false matrix: os: [ ubuntu-latest, macos-latest, windows-latest ] include: @@ -69,25 +70,8 @@ jobs: name: build-${{ matrix.name }} path: ${{ matrix.name }} - test_alpine: - name: Test in Alpine - runs-on: ubuntu-latest - container: - image: alpine:latest - steps: - - uses: actions/checkout@v4 - - run: apk add --no-cache libgcc gcc musl-dev bash curl - - - name: Install Rust - uses: dtolnay/rust-toolchain@stable - - uses: Swatinem/rust-cache@v2 - with: - prefix-key: alpine-build- - - name: Run tests - run: cargo test - cross_builds: - name: Cross-build + name: ${{ matrix.target }} runs-on: ubuntu-latest strategy: matrix: diff --git a/Cargo.lock b/Cargo.lock index 63ab0efe3..60f70ee09 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -186,7 +186,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.90", ] [[package]] @@ -287,7 +287,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn", + "syn 2.0.90", ] [[package]] @@ -298,7 +298,7 @@ checksum = "d336a2a514f6ccccaa3e09b02d41d35330c07ddf03a62165fcec10bb561c7806" dependencies = [ "darling_core", "quote", - "syn", + "syn 2.0.90", ] [[package]] @@ -435,6 +435,16 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" +[[package]] +name = "indexmap" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62f822373a4fe84d4bb149bf54e584a7f4abec90e072ed49cda0edea5b95471f" +dependencies = [ + "equivalent", + "hashbrown", +] + [[package]] name = "indoc" version = "2.0.5" @@ -452,7 +462,7 @@ dependencies = [ "pretty_assertions", "proc-macro2", "quote", - "syn", + "syn 2.0.90", ] [[package]] @@ -512,7 +522,7 @@ dependencies = [ "proc-macro2", "quote", "regex", - "syn", + "syn 2.0.90", ] [[package]] @@ -576,6 +586,39 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "ntest" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb183f0a1da7a937f672e5ee7b7edb727bf52b8a52d531374ba8ebb9345c0330" +dependencies = [ + "ntest_test_cases", + "ntest_timeout", +] + +[[package]] +name = "ntest_test_cases" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16d0d3f2a488592e5368ebbe996e7f1d44aa13156efad201f5b4d84e150eaa93" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "ntest_timeout" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcc7c92f190c97f79b4a332f5e81dcf68c8420af2045c936c9be0bc9de6f63b5" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "num-conv" version = "0.1.0" @@ -651,8 +694,8 @@ name = "pinger" version = "1.3.0" dependencies = [ "anyhow", - "dns-lookup", "lazy-regex", + "ntest", "os_info", "rand", "thiserror", @@ -684,6 +727,15 @@ dependencies = [ "yansi", ] +[[package]] +name = "proc-macro-crate" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecf48c7ca261d60b74ab1a7b20da18bede46776b2e55535cb958eb595c5fa7b" +dependencies = [ + "toml_edit", +] + [[package]] name = "proc-macro2" version = "1.0.92" @@ -839,7 +891,7 @@ checksum = "46f859dbbf73865c6627ed570e78961cd3ac92407a2d117204c49232485da55e" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.90", ] [[package]] @@ -936,7 +988,18 @@ dependencies = [ "proc-macro2", "quote", "rustversion", - "syn", + "syn 2.0.90", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", ] [[package]] @@ -967,7 +1030,7 @@ checksum = "e1d8749b4531af2117677a5fcd12b1348a3fe2b81e36e61ffeac5c4aa3273e36" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.90", ] [[package]] @@ -1003,6 +1066,23 @@ dependencies = [ "time-core", ] +[[package]] +name = "toml_datetime" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dd7358ecb8fc2f8d014bf86f6f638ce72ba252a2c3a2572f2a795f1d23efb41" + +[[package]] +name = "toml_edit" +version = "0.22.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ae48d6208a266e853d946088ed816055e556cc6028c5e8e2b84d9fa5dd7c7f5" +dependencies = [ + "indexmap", + "toml_datetime", + "winnow", +] + [[package]] name = "unicode-ident" version = "1.0.14" @@ -1077,7 +1157,7 @@ dependencies = [ "log", "proc-macro2", "quote", - "syn", + "syn 2.0.90", "wasm-bindgen-shared", ] @@ -1099,7 +1179,7 @@ checksum = "30d7a95b763d3c45903ed6c81f156801839e5ee968bb07e534c44df0fcd330c2" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.90", "wasm-bindgen-backend", "wasm-bindgen-shared", ] @@ -1299,6 +1379,15 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "winnow" +version = "0.6.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36c1fec1a2bb5866f07c25f68c26e565c4c200aebb96d7e55710c19d3e8ac49b" +dependencies = [ + "memchr", +] + [[package]] name = "winping" version = "0.10.1" @@ -1334,5 +1423,5 @@ checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.90", ] diff --git a/Cross.toml b/Cross.toml index ec0b78464..779fd9fa0 100644 --- a/Cross.toml +++ b/Cross.toml @@ -1,20 +1,27 @@ -[target."armv7-linux-androideabi"] -pre-build = ["apt-get update && apt-get install --assume-yes iputils-ping"] - -[target."armv7-unknown-linux-gnueabihf"] -pre-build = ["apt-get update && apt-get install --assume-yes iputils-ping"] - -[target."armv7-unknown-linux-musleabihf"] -pre-build = ["apt-get update && apt-get install --assume-yes iputils-ping"] +#[target."armv7-linux-androideabi"] +#pre-build = ["apt-get update && apt-get install --assume-yes iputils-ping"] +# +#[target."armv7-unknown-linux-gnueabihf"] +#pre-build = ["apt-get update && apt-get install --assume-yes iputils-ping"] +# +#[target."armv7-unknown-linux-musleabihf"] +#pre-build = ["apt-get update && apt-get install --assume-yes iputils-ping"] +# +#[target."aarch64-linux-android"] +#pre-build = ["apt-get update && apt-get install --assume-yes iputils-ping"] +# +#[target."aarch64-unknown-linux-gnu"] +#pre-build = ["apt-get update && apt-get install --assume-yes iputils-ping"] +# +#[target."aarch64-unknown-linux-musl"] +#pre-build = ["apt-get update && apt-get install --assume-yes iputils-ping"] +# +#[target."x86_64-unknown-linux-musl"] +#pre-build = ["apt-get update && apt-get install --assume-yes iputils-ping"] +# -[target."aarch64-linux-android"] +[build] pre-build = ["apt-get update && apt-get install --assume-yes iputils-ping"] -[target."aarch64-unknown-linux-gnu"] -pre-build = ["apt-get update && apt-get install --assume-yes iputils-ping"] - -[target."aarch64-unknown-linux-musl"] -pre-build = ["apt-get update && apt-get install --assume-yes iputils-ping"] - -[target."x86_64-unknown-linux-musl"] -pre-build = ["apt-get update && apt-get install --assume-yes iputils-ping"] +[build.env] +passthrough = ["CI", "GITHUB_ACTIONS"] \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 52dd79182..968b56005 100644 --- a/Dockerfile +++ b/Dockerfile @@ -17,6 +17,6 @@ RUN apt-get update \ && apt-get install -y iputils-ping \ && rm -rf /var/lib/apt/lists/* -COPY --from=builder /usr/local/cargo/bin/gping /usr/local/bin/gping +COPY --link --from=builder /usr/local/cargo/bin/gping /usr/local/bin/gping ENTRYPOINT ["gping"] diff --git a/gping/build.rs b/gping/build.rs index 4a0dfc459..1b74ba6ba 100644 --- a/gping/build.rs +++ b/gping/build.rs @@ -1,3 +1,3 @@ -fn main() -> shadow_rs::SdResult<()> { - shadow_rs::new() +fn main() { + shadow_rs::ShadowBuilder::builder().build().unwrap(); } diff --git a/gping/src/main.rs b/gping/src/main.rs index 69c9df2d7..9f88927ac 100644 --- a/gping/src/main.rs +++ b/gping/src/main.rs @@ -11,7 +11,7 @@ use crossterm::{ }; use dns_lookup::lookup_host; use itertools::{Itertools, MinMaxResult}; -use pinger::{ping_with_interval, PingResult}; +use pinger::{ping, PingOptions, PingResult}; use std::io; use std::io::BufWriter; use std::iter; @@ -310,7 +310,8 @@ fn start_ping_thread( ) -> Result>> { let interval = Duration::from_millis((watch_interval.unwrap_or(0.2) * 1000.0) as u64); // Pump ping messages into the queue - let stream = ping_with_interval(host, interval, interface)?; + let ping_opts = PingOptions::new(host, interval, interface); + let stream = ping(ping_opts)?; Ok(thread::spawn(move || -> Result<()> { while !kill_event.load(Ordering::Acquire) { match stream.recv() { diff --git a/pinger/Cargo.toml b/pinger/Cargo.toml index 6383fd203..11f93ee71 100644 --- a/pinger/Cargo.toml +++ b/pinger/Cargo.toml @@ -8,14 +8,18 @@ description = "A small cross-platform library to execute the ping command and pa repository = "https://github.com/orf/pinger/" [dependencies] -anyhow = "1.0.94" -thiserror = "2.0.6" -rand = "0.8.5" -lazy-regex = "3.1.0" +thiserror = "2.0.7" +lazy-regex = "3.3.0" +rand = { version = "0.8.5", optional = true } [target.'cfg(windows)'.dependencies] winping = "0.10.1" -dns-lookup = "2.0.0" [dev-dependencies] os_info = "3.9.0" +ntest = "0.9.3" +anyhow = "1.0.94" + +[features] +default = [] +fake-ping = ["rand"] \ No newline at end of file diff --git a/pinger/README.md b/pinger/README.md index ae1c96d3d..3e4bc8601 100644 --- a/pinger/README.md +++ b/pinger/README.md @@ -14,10 +14,12 @@ A full example of using the library can be found in the `examples/` directory, b interface is quite simple: ```rust -use pinger::ping; +use std::time::Duration; +use pinger::{ping, PingOptions}; fn ping_google() { - let stream = ping("google.com", None).expect("Error pinging"); + let options = PingOptions::new("google.com", Duration::from_secs(1), None); + let stream = ping(options).expect("Error pinging"); for message in stream { match message { pinger::PingResult::Pong(duration, _) => { diff --git a/pinger/examples/simple-ping.rs b/pinger/examples/simple-ping.rs index 9afe06495..cea23a7c1 100644 --- a/pinger/examples/simple-ping.rs +++ b/pinger/examples/simple-ping.rs @@ -1,10 +1,13 @@ -use pinger::ping_with_interval; +use pinger::{ping, PingOptions}; + +const LIMIT: usize = 3; pub fn main() { let target = "tomforb.es".to_string(); - let interval = std::time::Duration::from_secs(1); - let stream = ping_with_interval(target, interval, None).expect("Error pinging"); - for message in stream { + let interval = std::time::Duration::from_millis(500); + let options = PingOptions::new(target, interval, None); + let stream = ping(options).expect("Error pinging"); + for message in stream.into_iter().take(LIMIT) { match message { pinger::PingResult::Pong(duration, line) => { println!("Duration: {:?}\t\t(raw: {:?})", duration, line) @@ -12,7 +15,7 @@ pub fn main() { pinger::PingResult::Timeout(line) => println!("Timeout! (raw: {line:?})"), pinger::PingResult::Unknown(line) => println!("Unknown line: {:?}", line), pinger::PingResult::PingExited(code, stderr) => { - println!("Ping exited! Code: {:?}. Stderr: {:?}", code, stderr) + panic!("Ping exited! Code: {:?}. Stderr: {:?}", code, stderr) } } } diff --git a/pinger/src/bsd.rs b/pinger/src/bsd.rs index 8e6ecc907..41f99b93e 100644 --- a/pinger/src/bsd.rs +++ b/pinger/src/bsd.rs @@ -1,49 +1,44 @@ -use crate::{Parser, PingResult, Pinger}; +use crate::{extract_regex, PingCreationError, PingOptions, PingResult, Pinger}; use lazy_regex::*; -use std::time::Duration; pub static RE: Lazy = lazy_regex!(r"time=(?:(?P[0-9]+).(?P[0-9]+)\s+ms)"); pub struct BSDPinger { - interval: Duration, - interface: Option, + options: PingOptions, +} + +pub(crate) fn parse_bsd(line: String) -> Option { + if line.starts_with("PING ") { + return None; + } + if line.starts_with("Request timeout") { + return Some(PingResult::Timeout(line)); + } + extract_regex(&RE, line) } impl Pinger for BSDPinger { - type Parser = BSDParser; + fn from_options(options: PingOptions) -> Result + where + Self: Sized, + { + Ok(Self { options }) + } - fn new(interval: Duration, interface: Option) -> Self { - Self { - interface, - interval, - } + fn parse_fn(&self) -> fn(String) -> Option { + parse_bsd } - fn ping_args(&self, target: String) -> (&str, Vec) { + fn ping_args(&self) -> (&str, Vec) { let mut args = vec![format!( "-i{:.1}", - self.interval.as_millis() as f32 / 1_000_f32 + self.options.interval.as_millis() as f32 / 1_000_f32 )]; - if let Some(interface) = &self.interface { + if let Some(interface) = &self.options.interface { args.push("-I".into()); args.push(interface.clone()); } - args.push(target); + args.push(self.options.target.to_string()); ("ping", args) } } - -#[derive(Default)] -pub struct BSDParser {} - -impl Parser for BSDParser { - fn parse(&self, line: String) -> Option { - if line.starts_with("PING ") { - return None; - } - if line.starts_with("Request timeout") { - return Some(PingResult::Timeout(line)); - } - self.extract_regex(&RE, line) - } -} diff --git a/pinger/src/fake.rs b/pinger/src/fake.rs index daa281644..c9a82e050 100644 --- a/pinger/src/fake.rs +++ b/pinger/src/fake.rs @@ -1,4 +1,4 @@ -use crate::{Parser, PingResult, Pinger}; +use crate::{PingCreationError, PingOptions, PingResult, Pinger}; use rand::prelude::*; use std::sync::mpsc; use std::sync::mpsc::Receiver; @@ -6,22 +6,31 @@ use std::thread; use std::time::Duration; pub struct FakePinger { - interval: Duration, + options: PingOptions, } impl Pinger for FakePinger { - type Parser = FakeParser; + fn from_options(options: PingOptions) -> Result + where + Self: Sized, + { + Ok(Self { options }) + } - fn new(interval: Duration, _interface: Option) -> Self { - Self { interval } + fn parse_fn(&self) -> fn(String) -> Option { + unimplemented!("parse for FakeParser not implemented") } - fn start(&self, _target: String) -> anyhow::Result> { + fn ping_args(&self) -> (&str, Vec) { + unimplemented!("ping_args not implemented for FakePinger") + } + + fn start(&self) -> Result, PingCreationError> { let (tx, rx) = mpsc::channel(); - let sleep_time = self.interval; + let sleep_time = self.options.interval; thread::spawn(move || { - let mut random = rand::thread_rng(); + let mut random = thread_rng(); loop { let fake_seconds = random.gen_range(50..150); let ping_result = PingResult::Pong( @@ -38,17 +47,4 @@ impl Pinger for FakePinger { Ok(rx) } - - fn ping_args(&self, _target: String) -> (&str, Vec) { - unimplemented!("ping_args not implemented for FakePinger") - } -} - -#[derive(Default)] -pub struct FakeParser {} - -impl Parser for FakeParser { - fn parse(&self, _line: String) -> Option { - unimplemented!("parse for FakeParser not implemented") - } } diff --git a/pinger/src/lib.rs b/pinger/src/lib.rs index 9f863d94e..04397fd57 100644 --- a/pinger/src/lib.rs +++ b/pinger/src/lib.rs @@ -1,12 +1,13 @@ #[cfg(unix)] -use crate::linux::{detect_linux_ping, LinuxPingType}; +use crate::linux::LinuxPinger; /// Pinger /// This crate exposes a simple function to ping remote hosts across different operating systems. /// Example: /// ```no_run -/// use pinger::{ping, PingResult}; -/// -/// let stream = ping("tomforb.es".to_string(), None).expect("Error pinging"); +/// use std::time::Duration; +/// use pinger::{ping, PingResult, PingOptions}; +/// let options = PingOptions::new("tomforb.es".to_string(), Duration::from_secs(1), None); +/// let stream = ping(options).expect("Error pinging"); /// for message in stream { /// match message { /// PingResult::Pong(duration, line) => println!("{:?} (line: {})", duration, line), @@ -16,29 +17,62 @@ use crate::linux::{detect_linux_ping, LinuxPingType}; /// } /// } /// ``` -use anyhow::{Context, Result}; use lazy_regex::Regex; -use std::fmt::Formatter; +use std::ffi::OsStr; +use std::fmt::{Debug, Formatter}; use std::io::{BufRead, BufReader}; use std::process::{Child, Command, ExitStatus, Stdio}; -use std::sync::mpsc; +use std::sync::{mpsc, Arc}; use std::time::Duration; -use std::{fmt, thread}; +use std::{fmt, io, thread}; +use target::Target; use thiserror::Error; pub mod linux; -// pub mod alpine' pub mod macos; #[cfg(windows)] pub mod windows; mod bsd; +#[cfg(feature = "fake-ping")] mod fake; +mod target; #[cfg(test)] mod test; -pub fn run_ping(cmd: &str, args: Vec) -> Result { - Command::new(cmd) +#[derive(Debug, Clone)] +pub struct PingOptions { + pub target: Target, + pub interval: Duration, + pub interface: Option, +} + +impl PingOptions { + pub fn from_target(target: Target, interval: Duration, interface: Option) -> Self { + Self { + target, + interval, + interface, + } + } + pub fn new(target: impl ToString, interval: Duration, interface: Option) -> Self { + Self::from_target(Target::new_any(target), interval, interface) + } + + pub fn new_ipv4(target: impl ToString, interval: Duration, interface: Option) -> Self { + Self::from_target(Target::new_ipv4(target), interval, interface) + } + + pub fn new_ipv6(target: impl ToString, interval: Duration, interface: Option) -> Self { + Self::from_target(Target::new_ipv6(target), interval, interface) + } +} + +pub fn run_ping( + cmd: impl AsRef + Debug, + args: Vec + Debug>, +) -> Result { + Ok(Command::new(cmd.as_ref()) .args(&args) .stdout(Stdio::piped()) .stderr(Stdio::piped()) @@ -46,28 +80,53 @@ pub fn run_ping(cmd: &str, args: Vec) -> Result { // using locale specific delimiters. .env("LANG", "C") .env("LC_ALL", "C") - .spawn() - .with_context(|| format!("Failed to run ping with args {:?}", &args)) + .spawn()?) +} + +pub(crate) fn extract_regex(regex: &Regex, line: String) -> Option { + let cap = regex.captures(&line)?; + let ms = cap + .name("ms") + .expect("No capture group named 'ms'") + .as_str() + .parse::() + .ok()?; + let ns = match cap.name("ns") { + None => 0, + Some(cap) => { + let matched_str = cap.as_str(); + let number_of_digits = matched_str.len() as u32; + let fractional_ms = matched_str.parse::().ok()?; + fractional_ms * (10u64.pow(6 - number_of_digits)) + } + }; + let duration = Duration::from_millis(ms) + Duration::from_nanos(ns); + Some(PingResult::Pong(duration, line)) } -pub trait Pinger { - type Parser: Parser; +pub trait Pinger: Send + Sync { + fn from_options(options: PingOptions) -> std::result::Result + where + Self: Sized; + + fn parse_fn(&self) -> fn(String) -> Option; - fn new(interval: Duration, interface: Option) -> Self; + fn ping_args(&self) -> (&str, Vec); - fn start(&self, target: String) -> Result> { + fn start(&self) -> Result, PingCreationError> { let (tx, rx) = mpsc::channel(); - let (cmd, args) = self.ping_args(target); + let (cmd, args) = self.ping_args(); let mut child = run_ping(cmd, args)?; - let stdout = child.stdout.take().context("child did not have a stdout")?; + let stdout = child.stdout.take().expect("child did not have a stdout"); + + let parse_fn = self.parse_fn(); thread::spawn(move || { - let parser = Self::Parser::default(); let reader = BufReader::new(stdout).lines(); for line in reader { match line { Ok(msg) => { - if let Some(result) = parser.parse(msg) { + if let Some(result) = parse_fn(msg) { if tx.send(result).is_err() { break; } @@ -83,35 +142,6 @@ pub trait Pinger { Ok(rx) } - - fn ping_args(&self, target: String) -> (&str, Vec) { - ("ping", vec![target]) - } -} - -pub trait Parser: Default { - fn parse(&self, line: String) -> Option; - - fn extract_regex(&self, regex: &Regex, line: String) -> Option { - let cap = regex.captures(&line)?; - let ms = cap - .name("ms") - .expect("No capture group named 'ms'") - .as_str() - .parse::() - .ok()?; - let ns = match cap.name("ns") { - None => 0, - Some(cap) => { - let matched_str = cap.as_str(); - let number_of_digits = matched_str.len() as u32; - let fractional_ms = matched_str.parse::().ok()?; - fractional_ms * (10u64.pow(6 - number_of_digits)) - } - }; - let duration = Duration::from_millis(ms) + Duration::from_nanos(ns); - Some(PingResult::Pong(duration, line)) - } } #[derive(Debug)] @@ -134,50 +164,34 @@ impl fmt::Display for PingResult { } #[derive(Error, Debug)] -pub enum PingDetectionError { +pub enum PingCreationError { #[error("Could not detect ping. Stderr: {stderr:?}\nStdout: {stdout:?}")] UnknownPing { stderr: Vec, stdout: Vec, }, - #[error(transparent)] - CommandError(#[from] anyhow::Error), + #[error("Error spawning ping: {0}")] + SpawnError(#[from] io::Error), #[error("Installed ping is not supported: {alternative}")] NotSupported { alternative: String }, -} -#[derive(Error, Debug)] -pub enum PingError { - #[error("Could not detect ping command type")] - UnsupportedPing(#[from] PingDetectionError), #[error("Invalid or unresolvable hostname {0}")] HostnameError(String), } -/// Start pinging a an address. The address can be either a hostname or an IP address. -pub fn ping(addr: String, interface: Option) -> Result> { - ping_with_interval(addr, Duration::from_millis(200), interface) -} - -/// Start pinging a an address. The address can be either a hostname or an IP address. -pub fn ping_with_interval( - addr: String, - interval: Duration, - interface: Option, -) -> Result> { +pub fn get_pinger(options: PingOptions) -> std::result::Result, PingCreationError> { + #[cfg(feature = "fake-ping")] if std::env::var("PINGER_FAKE_PING") .map(|e| e == "1") - .unwrap_or(false) + .unwrap_or_default() { - let fake = fake::FakePinger::new(interval, interface); - return fake.start(addr); + return Ok(Arc::new(fake::FakePinger::from_options(options)?)); } #[cfg(windows)] { - let p = windows::WindowsPinger::new(interval, interface); - return p.start(addr); + return Ok(Arc::new(windows::WindowsPinger::from_options(options)?)); } #[cfg(unix)] { @@ -186,23 +200,19 @@ pub fn ping_with_interval( || cfg!(target_os = "openbsd") || cfg!(target_os = "netbsd") { - let p = bsd::BSDPinger::new(interval, interface); - p.start(addr) + Ok(Arc::new(bsd::BSDPinger::from_options(options)?)) } else if cfg!(target_os = "macos") { - let p = macos::MacOSPinger::new(interval, interface); - p.start(addr) + Ok(Arc::new(macos::MacOSPinger::from_options(options)?)) } else { - match detect_linux_ping() { - Ok(LinuxPingType::IPTools) => { - let p = linux::LinuxPinger::new(interval, interface); - p.start(addr) - } - Ok(LinuxPingType::BusyBox) => { - let p = linux::AlpinePinger::new(interval, interface); - p.start(addr) - } - Err(e) => Err(PingError::UnsupportedPing(e))?, - } + Ok(Arc::new(LinuxPinger::from_options(options)?)) } } } + +/// Start pinging a an address. The address can be either a hostname or an IP address. +pub fn ping( + options: PingOptions, +) -> std::result::Result, PingCreationError> { + let pinger = get_pinger(options)?; + pinger.start() +} diff --git a/pinger/src/linux.rs b/pinger/src/linux.rs index 952239f4d..1640337b6 100644 --- a/pinger/src/linux.rs +++ b/pinger/src/linux.rs @@ -1,96 +1,103 @@ -use crate::{run_ping, Parser, PingDetectionError, PingResult, Pinger}; -use anyhow::Context; +use crate::{extract_regex, run_ping, PingCreationError, PingOptions, PingResult, Pinger}; use lazy_regex::*; -use std::time::Duration; -#[derive(Debug, Eq, PartialEq)] -pub enum LinuxPingType { - BusyBox, - IPTools, +pub static UBUNTU_RE: Lazy = lazy_regex!(r"(?i-u)time=(?P\d+)(?:\.(?P\d+))? *ms"); + +#[derive(Debug)] +pub enum LinuxPinger { + // Alpine + BusyBox(PingOptions), + // Debian, Ubuntu, etc + IPTools(PingOptions), } -pub fn detect_linux_ping() -> Result { - let child = run_ping("ping", vec!["-V".to_string()])?; - let output = child - .wait_with_output() - .context("Error getting ping stdout/stderr")?; - let stdout = String::from_utf8(output.stdout).context("Error decoding ping stdout")?; - let stderr = String::from_utf8(output.stderr).context("Error decoding ping stderr")?; +impl LinuxPinger { + pub fn detect_platform_ping(options: PingOptions) -> Result { + let child = run_ping("ping", vec!["-V".to_string()])?; + let output = child.wait_with_output()?; + let stdout = String::from_utf8(output.stdout).expect("Error decoding ping stdout"); + let stderr = String::from_utf8(output.stderr).expect("Error decoding ping stderr"); - if stderr.contains("BusyBox") { - Ok(LinuxPingType::BusyBox) - } else if stdout.contains("iputils") { - Ok(LinuxPingType::IPTools) - } else if stdout.contains("inetutils") { - Err(PingDetectionError::NotSupported { - alternative: "Please use iputils ping, not inetutils.".to_string(), - }) - } else { - let first_two_lines_stderr: Vec = - stderr.lines().take(2).map(str::to_string).collect(); - let first_two_lines_stout: Vec = - stdout.lines().take(2).map(str::to_string).collect(); - Err(PingDetectionError::UnknownPing { - stdout: first_two_lines_stout, - stderr: first_two_lines_stderr, - }) + if stderr.contains("BusyBox") { + Ok(LinuxPinger::BusyBox(options)) + } else if stdout.contains("iputils") { + Ok(LinuxPinger::IPTools(options)) + } else if stdout.contains("inetutils") { + Err(PingCreationError::NotSupported { + alternative: "Please use iputils ping, not inetutils.".to_string(), + }) + } else { + let first_two_lines_stderr: Vec = + stderr.lines().take(2).map(str::to_string).collect(); + let first_two_lines_stout: Vec = + stdout.lines().take(2).map(str::to_string).collect(); + Err(PingCreationError::UnknownPing { + stdout: first_two_lines_stout, + stderr: first_two_lines_stderr, + }) + } } } -pub struct LinuxPinger { - interval: Duration, - interface: Option, -} - impl Pinger for LinuxPinger { - type Parser = LinuxParser; - fn new(interval: Duration, interface: Option) -> Self { - Self { - interval, - interface, - } + fn from_options(options: PingOptions) -> Result + where + Self: Sized, + { + Self::detect_platform_ping(options) } - fn ping_args(&self, target: String) -> (&str, Vec) { - // The -O flag ensures we "no answer yet" messages from ping - // See https://superuser.com/questions/270083/linux-ping-show-time-out - let mut args = vec![ - "-O".to_string(), - format!("-i{:.1}", self.interval.as_millis() as f32 / 1_000_f32), - ]; - if let Some(interface) = &self.interface { - args.push("-I".into()); - args.push(interface.clone()); + fn parse_fn(&self) -> fn(String) -> Option { + |line| { + #[cfg(test)] + eprintln!("Got line {line}"); + if line.starts_with("64 bytes from") { + return extract_regex(&UBUNTU_RE, line); + } else if line.starts_with("no answer yet") { + return Some(PingResult::Timeout(line)); + } + None } - args.push(target); - ("ping", args) } -} -pub struct AlpinePinger {} + fn ping_args(&self) -> (&str, Vec) { + match self { + // Alpine doesn't support timeout notifications, so we don't add the -O flag here. + LinuxPinger::BusyBox(options) => { + let cmd = if options.target.is_ipv6() { + "ping6" + } else { + "ping" + }; -// Alpine doesn't support timeout notifications, so we don't add the -O flag here -impl Pinger for AlpinePinger { - type Parser = LinuxParser; + let args = vec![ + options.target.to_string(), + format!("-i{:.1}", options.interval.as_millis() as f32 / 1_000_f32), + ]; - fn new(_interval: Duration, _interface: Option) -> Self { - Self {} - } -} - -pub static UBUNTU_RE: Lazy = lazy_regex!(r"(?i-u)time=(?P\d+)(?:\.(?P\d+))? *ms"); - -#[derive(Default)] -pub struct LinuxParser {} + (cmd, args) + } + LinuxPinger::IPTools(options) => { + let cmd = if options.target.is_ipv6() { + "ping6" + } else { + "ping" + }; -impl Parser for LinuxParser { - fn parse(&self, line: String) -> Option { - if line.starts_with("64 bytes from") { - return self.extract_regex(&UBUNTU_RE, line); - } else if line.starts_with("no answer yet") { - return Some(PingResult::Timeout(line)); + // The -O flag ensures we "no answer yet" messages from ping + // See https://superuser.com/questions/270083/linux-ping-show-time-out + let mut args = vec![ + "-O".to_string(), + format!("-i{:.1}", options.interval.as_millis() as f32 / 1_000_f32), + ]; + if let Some(interface) = &options.interface { + args.push("-I".into()); + args.push(interface.clone()); + } + args.push(options.target.to_string()); + (cmd, args) + } } - None } } @@ -101,13 +108,20 @@ mod tests { fn test_linux_detection() { use super::*; use os_info::Type; - let ping_type = detect_linux_ping().expect("Error getting ping"); + use std::time::Duration; + + let platform = LinuxPinger::detect_platform_ping(PingOptions::new( + "foo.com".to_string(), + Duration::from_secs(1), + None, + )) + .unwrap(); match os_info::get().os_type() { Type::Alpine => { - assert_eq!(ping_type, LinuxPingType::BusyBox) + assert!(matches!(platform, LinuxPinger::BusyBox(_))) } Type::Ubuntu => { - assert_eq!(ping_type, LinuxPingType::IPTools) + assert!(matches!(platform, LinuxPinger::IPTools(_))) } _ => {} } diff --git a/pinger/src/macos.rs b/pinger/src/macos.rs index 9156b9009..1ff57aeed 100644 --- a/pinger/src/macos.rs +++ b/pinger/src/macos.rs @@ -1,35 +1,39 @@ -use crate::{Parser, PingResult, Pinger}; +use crate::bsd::parse_bsd; +use crate::{PingCreationError, PingOptions, PingResult, Pinger}; use lazy_regex::*; -use std::net::Ipv6Addr; -use std::time::Duration; pub static RE: Lazy = lazy_regex!(r"time=(?:(?P[0-9]+).(?P[0-9]+)\s+ms)"); pub struct MacOSPinger { - interval: Duration, - interface: Option, + options: PingOptions, } impl Pinger for MacOSPinger { - type Parser = MacOSParser; + fn from_options(options: PingOptions) -> Result + where + Self: Sized, + { + Ok(Self { options }) + } - fn new(interval: Duration, interface: Option) -> Self { - Self { - interval, - interface, - } + fn parse_fn(&self) -> fn(String) -> Option { + parse_bsd } - fn ping_args(&self, target: String) -> (&str, Vec) { - let cmd = match target.parse::() { - Ok(_) => "ping6", - Err(_) => "ping", + fn ping_args(&self) -> (&str, Vec) { + let cmd = if self.options.target.is_ipv6() { + "ping6" + } else { + "ping" }; let mut args = vec![ - format!("-i{:.1}", self.interval.as_millis() as f32 / 1_000_f32), - target, + format!( + "-i{:.1}", + self.options.interval.as_millis() as f32 / 1_000_f32 + ), + self.options.target.to_string(), ]; - if let Some(interface) = &self.interface { + if let Some(interface) = &self.options.interface { args.push("-b".into()); args.push(interface.clone()); } @@ -37,18 +41,3 @@ impl Pinger for MacOSPinger { (cmd, args) } } - -#[derive(Default)] -pub struct MacOSParser {} - -impl Parser for MacOSParser { - fn parse(&self, line: String) -> Option { - if line.starts_with("PING ") { - return None; - } - if line.starts_with("Request timeout") { - return Some(PingResult::Timeout(line)); - } - self.extract_regex(&RE, line) - } -} diff --git a/pinger/src/target.rs b/pinger/src/target.rs new file mode 100644 index 000000000..bcf164200 --- /dev/null +++ b/pinger/src/target.rs @@ -0,0 +1,67 @@ +use std::fmt; +use std::fmt::{Display, Formatter}; +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; + +#[derive(Debug, Copy, Clone, Eq, PartialEq)] +pub enum IPVersion { + V4, + V6, + Any, +} + +#[derive(Debug, Clone)] +pub enum Target { + IP(IpAddr), + Hostname { domain: String, version: IPVersion }, +} + +impl Target { + pub fn is_ipv6(&self) -> bool { + match self { + Target::IP(ip) => ip.is_ipv6(), + Target::Hostname { version, .. } => *version == IPVersion::V6, + } + } + + pub fn new_any(value: impl ToString) -> Self { + let value = value.to_string(); + if let Ok(ip) = value.parse::() { + return Self::IP(ip); + } + Self::Hostname { + domain: value, + version: IPVersion::Any, + } + } + + pub fn new_ipv4(value: impl ToString) -> Self { + let value = value.to_string(); + if let Ok(ip) = value.parse::() { + return Self::IP(IpAddr::V4(ip)); + } + Self::Hostname { + domain: value.to_string(), + version: IPVersion::V4, + } + } + + pub fn new_ipv6(value: impl ToString) -> Self { + let value = value.to_string(); + if let Ok(ip) = value.parse::() { + return Self::IP(IpAddr::V6(ip)); + } + Self::Hostname { + domain: value.to_string(), + version: IPVersion::V6, + } + } +} + +impl Display for Target { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + match self { + Target::IP(v) => Display::fmt(&v, f), + Target::Hostname { domain, .. } => Display::fmt(&domain, f), + } + } +} diff --git a/pinger/src/test.rs b/pinger/src/test.rs index c4be0acf6..2bfb990eb 100644 --- a/pinger/src/test.rs +++ b/pinger/src/test.rs @@ -1,22 +1,92 @@ #[cfg(test)] mod tests { - use crate::bsd::BSDParser; - use crate::linux::LinuxParser; - use crate::macos::MacOSParser; - use crate::{Parser, PingResult}; - + use crate::bsd::BSDPinger; + use crate::linux::LinuxPinger; + use crate::macos::MacOSPinger; #[cfg(windows)] - use crate::windows::WindowsParser; + use crate::windows::WindowsPinger; + use crate::{PingOptions, PingResult, Pinger}; + use anyhow::bail; + use ntest::timeout; + use std::time::Duration; + + const IS_GHA: bool = option_env!("GITHUB_ACTIONS").is_some(); + + #[test] + #[timeout(20_000)] + fn test_integration_any() { + run_integration_test(PingOptions::new( + "tomforb.es", + Duration::from_millis(500), + None, + )) + .unwrap(); + } + #[test] + #[timeout(20_000)] + fn test_integration_ipv4() { + run_integration_test(PingOptions::new_ipv4( + "tomforb.es", + Duration::from_millis(500), + None, + )) + .unwrap(); + } + #[test] + #[timeout(20_000)] + fn test_integration_ip6() { + let res = run_integration_test(PingOptions::new_ipv6( + "tomforb.es", + Duration::from_millis(500), + None, + )); + // ipv6 tests are allowed to fail on Gitlab CI, as it doesn't support ipv6, apparently. + if !IS_GHA { + res.unwrap(); + } + } + + fn run_integration_test(options: PingOptions) -> anyhow::Result<()> { + let stream = crate::ping(options.clone())?; + + let mut success = 0; + let mut errors = 0; - fn test_parser(contents: &str) - where - T: Parser, - { - let parser = T::default(); + for message in stream.into_iter().take(3) { + match message { + PingResult::Pong(_, m) | PingResult::Timeout(m) => { + eprintln!("Message: {}", m); + success += 1; + } + PingResult::Unknown(line) => { + eprintln!("Unknown line: {}", line); + errors += 1; + } + PingResult::PingExited(code, stderr) => { + bail!("Ping exited with code: {}, stderr: {}", code, stderr); + } + } + } + assert_eq!(success, 3, "Success != 3 with opts {options:?}"); + assert_eq!(errors, 0, "Errors != 0 with opts {options:?}"); + Ok(()) + } + + fn opts() -> PingOptions { + PingOptions::new("foo".to_string(), Duration::from_secs(1), None) + } + + fn test_parser(contents: &str) { + let pinger = T::from_options(opts()).unwrap(); + run_parser_test(contents, &pinger); + } + + fn run_parser_test(contents: &str, pinger: &impl Pinger) { + let parser = pinger.parse_fn(); let test_file: Vec<&str> = contents.split("-----").collect(); let input = test_file[0].trim().split('\n'); let expected: Vec<&str> = test_file[1].trim().split('\n').collect(); - let parsed: Vec> = input.map(|l| parser.parse(l.to_string())).collect(); + let parsed: Vec> = input.map(|l| parser(l.to_string())).collect(); assert_eq!( parsed.len(), @@ -41,52 +111,64 @@ mod tests { #[test] fn macos() { - test_parser::(include_str!("tests/macos.txt")); + test_parser::(include_str!("tests/macos.txt")); } #[test] fn freebsd() { - test_parser::(include_str!("tests/bsd.txt")); + test_parser::(include_str!("tests/bsd.txt")); } #[test] fn dragonfly() { - test_parser::(include_str!("tests/bsd.txt")); + test_parser::(include_str!("tests/bsd.txt")); } #[test] fn openbsd() { - test_parser::(include_str!("tests/bsd.txt")); + test_parser::(include_str!("tests/bsd.txt")); } #[test] fn netbsd() { - test_parser::(include_str!("tests/bsd.txt")); + test_parser::(include_str!("tests/bsd.txt")); } #[test] fn ubuntu() { - test_parser::(include_str!("tests/ubuntu.txt")); + run_parser_test( + include_str!("tests/ubuntu.txt"), + &LinuxPinger::IPTools(opts()), + ); } #[test] fn debian() { - test_parser::(include_str!("tests/debian.txt")); + run_parser_test( + include_str!("tests/debian.txt"), + &LinuxPinger::IPTools(opts()), + ); } #[cfg(windows)] #[test] fn windows() { - test_parser::(include_str!("tests/windows.txt")); + test_parser::(include_str!("tests/windows.txt")); } #[test] fn android() { - test_parser::(include_str!("tests/android.txt")); + run_parser_test( + include_str!("tests/android.txt"), + &LinuxPinger::BusyBox(opts()), + ); } #[test] fn alpine() { - test_parser::(include_str!("tests/alpine.txt")); + run_parser_test( + include_str!("tests/alpine.txt"), + &LinuxPinger::BusyBox(opts()), + ); } } diff --git a/pinger/src/windows.rs b/pinger/src/windows.rs index 18cb48adb..766262bd4 100644 --- a/pinger/src/windows.rs +++ b/pinger/src/windows.rs @@ -1,8 +1,8 @@ -use crate::{Parser, PingError, PingResult, Pinger}; -use anyhow::Result; -use dns_lookup::lookup_host; +use crate::target::{IPVersion, Target}; +use crate::PingCreationError; +use crate::{extract_regex, PingOptions, PingResult, Pinger}; use lazy_regex::*; -use std::net::IpAddr; +use std::net::{IpAddr, ToSocketAddrs}; use std::sync::mpsc; use std::thread; use std::time::Duration; @@ -11,29 +11,52 @@ use winping::{Buffer, Pinger as WinPinger}; pub static RE: Lazy = lazy_regex!(r"(?ix-u)time=(?P\d+)(?:\.(?P\d+))?"); pub struct WindowsPinger { - interval: Duration, + options: PingOptions, } impl Pinger for WindowsPinger { - type Parser = WindowsParser; + fn from_options(options: PingOptions) -> Result { + Ok(Self { options }) + } + + fn parse_fn(&self) -> fn(String) -> Option { + |line| { + if line.contains("timed out") || line.contains("failure") { + return Some(PingResult::Timeout(line)); + } + extract_regex(&RE, line) + } + } - fn new(interval: Duration, _interface: Option) -> Self { - Self { interval } + fn ping_args(&self) -> (&str, Vec) { + unimplemented!("ping_args for WindowsPinger is not implemented") } - fn start(&self, target: String) -> Result> { - let interval = self.interval; - let parsed_ip: IpAddr = match target.parse() { - Err(_) => { - let things = lookup_host(target.as_str())?; - if things.is_empty() { - Err(PingError::HostnameError(target)) + fn start(&self) -> Result, PingCreationError> { + let interval = self.options.interval; + let parsed_ip = match &self.options.target { + Target::IP(ip) => ip.clone(), + Target::Hostname { domain, version } => { + let ips = (domain.as_str(), 0).to_socket_addrs()?; + let selected_ips: Vec<_> = if *version == IPVersion::Any { + ips.collect() } else { - Ok(things[0]) + ips.into_iter() + .filter(|addr| { + if *version == IPVersion::V6 { + matches!(addr.ip(), IpAddr::V6(_)) + } else { + matches!(addr.ip(), IpAddr::V4(_)) + } + }) + .collect() + }; + if selected_ips.is_empty() { + return Err(PingCreationError::HostnameError(domain.clone()).into()); } + selected_ips[0].ip() } - Ok(addr) => Ok(addr), - }?; + }; let (tx, rx) = mpsc::channel(); @@ -67,15 +90,3 @@ impl Pinger for WindowsPinger { Ok(rx) } } - -#[derive(Default)] -pub struct WindowsParser {} - -impl Parser for WindowsParser { - fn parse(&self, line: String) -> Option { - if line.contains("timed out") || line.contains("failure") { - return Some(PingResult::Timeout(line)); - } - self.extract_regex(&RE, line) - } -}